Coverage for trimesh/visual/color.py: 86%
408 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-31 23:55 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-31 23:55 +0000
1"""
2color.py
3-------------
5Hold and deal with visual information about meshes.
7There are lots of ways to encode visual information, and the goal of this
8architecture is to make it possible to define one, and then transparently
9get the others. The two general categories are:
111) colors, defined for a face, vertex, or material
122) textures, defined as an image and UV coordinates for each vertex
14This module only implements diffuse colors at the moment.
16Goals
17----------
181) If nothing is defined sane defaults should be returned
192) If a user alters or sets a value, that is considered user data
20 and should be saved and treated as such.
213) Only one 'mode' of visual (vertex or face) is allowed at a time
22 and setting or altering a value should automatically change the mode.
23"""
25import copy
26from typing import Any
28import numpy as np
30from .. import caching, util
31from ..constants import tol
32from ..grouping import unique_rows
33from ..resources import get_json
34from ..typed import (
35 ArrayLike,
36 Callable,
37 ColorMapType,
38 DTypeLike,
39 Integer,
40 Iterable,
41 NDArray,
42 Seed,
43)
44from .base import Visuals
46# Save a lookup table for an integer to match the
47# cases for HSV conversion specified on the wikipedia article
48# Where indexes 0=C, 1=X, 2=0.0
49_HSV_LOOKUP = np.array(
50 [[0, 1, 2], [1, 0, 2], [2, 0, 1], [2, 1, 0], [1, 2, 0], [0, 2, 1]], dtype=np.int64
51)
52_HSV_LOOKUP.flags.writeable = False
55class ColorVisuals(Visuals):
56 """
57 Store color information about a mesh.
58 """
60 def __init__(
61 self,
62 mesh=None,
63 face_colors: ArrayLike | None = None,
64 vertex_colors: ArrayLike | None = None,
65 ):
66 """
67 Store color information about a mesh.
69 Parameters
70 ----------
71 mesh : Trimesh
72 Object that these visual properties
73 are associated with
74 face_ colors : (n,3|4) or (3,) or (4,) uint8
75 Colors per-face
76 vertex_colors : (n,3|4) or (3,) or (4,) uint8
77 Colors per-vertex
78 """
79 self.mesh = mesh
80 self._data = caching.DataStore()
81 self._cache = caching.Cache(id_function=self._data.__hash__)
83 try:
84 if face_colors is not None:
85 self.face_colors = face_colors
86 if vertex_colors is not None:
87 self.vertex_colors = vertex_colors
88 except ValueError:
89 util.log.warning("unable to convert colors!")
91 @caching.cache_decorator
92 def transparency(self) -> bool:
93 """
94 Does the current object contain any transparency.
96 Returns
97 ----------
98 transparency: bool, does the current visual contain transparency
99 """
100 if "vertex_colors" in self._data:
101 a_min = self._data["vertex_colors"][:, 3].min()
102 elif "face_colors" in self._data:
103 a_min = self._data["face_colors"][:, 3].min()
104 else:
105 return False
107 return bool(a_min < 255)
109 @property
110 def defined(self) -> bool:
111 """
112 Are any colors defined for the current mesh.
114 Returns
115 ---------
116 defined : bool
117 Are colors defined or not.
118 """
119 return self.kind is not None
121 @property
122 def kind(self) -> str | None:
123 """
124 What color mode has been set.
126 Returns
127 ----------
128 mode : str or None
129 One of ('face', 'vertex', None)
130 """
131 # if nothing is stored anywhere it's a safe bet mode is None
132 if not (len(self._cache.cache) > 0 or len(self._data.data) > 0):
133 return None
135 self._verify_hash()
137 # check modes in data
138 if "vertex_colors" in self._data:
139 return "vertex"
140 elif "face_colors" in self._data:
141 return "face"
143 return None
145 def __hash__(self):
146 return self._data.__hash__()
148 def copy(self) -> "ColorVisuals":
149 """
150 Return a copy of the current ColorVisuals object.
153 Returns
154 ----------
155 copied : ColorVisuals
156 Contains the same information as self
157 """
158 copied = ColorVisuals()
159 # call the literally insane generators to validate
160 self.face_colors # noqa
161 self.vertex_colors # noqa
162 # copy anything that's actually data
163 copied._data.data = copy.deepcopy(self._data.data)
165 return copied
167 @property
168 def face_colors(self) -> NDArray[np.uint8]:
169 """
170 Colors defined for each face of a mesh.
172 If no colors are defined, defaults are returned.
174 Returns
175 ----------
176 colors : (len(mesh.faces), 4) uint8
177 RGBA color for each face
178 """
179 return self._get_colors(name="face")
181 @face_colors.setter
182 def face_colors(self, values: ArrayLike):
183 """
184 Set the colors for each face of a mesh.
186 This will apply these colors and delete any previously specified
187 color information.
189 Parameters
190 ------------
191 colors : (len(mesh.faces), 3), set each face to the specified color
192 (len(mesh.faces), 4), set each face to the specified color
193 (3,) int, set the whole mesh this color
194 (4,) int, set the whole mesh this color
195 """
196 if values is None:
197 if "face_colors" in self._data:
198 self._data.data.pop("face_colors")
199 return
201 colors = to_rgba(values)
203 if self.mesh is not None and colors.shape == (4,):
204 count = len(self.mesh.faces)
205 colors = np.tile(colors, (count, 1))
207 # if we set any color information, clear the others
208 self._data.clear()
209 self._data["face_colors"] = colors
210 self._cache.verify()
212 @property
213 def vertex_colors(self) -> NDArray[np.uint8]:
214 """
215 Return the colors for each vertex of a mesh
217 Returns
218 ------------
219 colors: (len(mesh.vertices), 4) uint8, color for each vertex
220 """
221 return self._get_colors(name="vertex")
223 @vertex_colors.setter
224 def vertex_colors(self, values: ArrayLike):
225 """
226 Set the colors for each vertex of a mesh
228 This will apply these colors and delete any previously specified
229 color information.
231 Parameters
232 ------------
233 colors : (len(mesh.vertices), 3), set each face to the color
234 (len(mesh.vertices), 4), set each face to the color
235 (3,) int, set the whole mesh this color
236 (4,) int, set the whole mesh this color
237 """
238 if values is None:
239 if "vertex_colors" in self._data:
240 self._data.data.pop("vertex_colors")
241 return
243 # make sure passed values are numpy array
244 values = np.asanyarray(values)
245 # Ensure the color shape is sane
246 if self.mesh is not None and not (
247 values.shape == (len(self.mesh.vertices), 3)
248 or values.shape == (len(self.mesh.vertices), 4)
249 or values.shape == (3,)
250 or values.shape == (4,)
251 ):
252 return
254 colors = to_rgba(values)
255 if self.mesh is not None and colors.shape == (4,):
256 count = len(self.mesh.vertices)
257 colors = np.tile(colors, (count, 1))
259 # if we set any color information, clear the others
260 self._data.clear()
261 self._data["vertex_colors"] = colors
262 self._cache.verify()
264 def _get_colors(self, name):
265 """
266 A magical function which maintains the sanity of vertex and face colors.
268 * If colors have been explicitly stored or changed, they are considered
269 user data, stored in self._data (DataStore), and are returned immediately
270 when requested.
271 * If colors have never been set, a (count,4) tiled copy of the default diffuse
272 color will be stored in the cache
273 ** the hash on creation for these cached default colors will also be stored
274 ** if the cached color array is altered (different hash than when it was
275 created) we consider that now to be user data and the array is moved from
276 the cache to the DataStore.
278 Parameters
279 -----------
280 name : str
281 Values 'face' or 'vertex'
283 Returns
284 -----------
285 colors : (count, 4) uint8
286 RGBA colors
287 """
289 count = None
290 try:
291 if name == "face":
292 count = len(self.mesh.faces)
293 elif name == "vertex":
294 count = len(self.mesh.vertices)
295 except BaseException:
296 pass
298 # the face or vertex colors
299 key_colors = str(name) + "_colors"
300 # the initial hash of the colors
301 key_hash = key_colors + "_hash"
303 if key_colors in self._data:
304 # if a user has explicitly stored or changed the color it
305 # will be in data
306 return self._data[key_colors]
308 elif key_colors in self._cache:
309 # if the colors have been autogenerated already they
310 # will be in the cache
311 colors = self._cache[key_colors]
312 # if the cached colors have been changed since creation we move
313 # them to data
314 if hash(colors) != self._cache[key_hash]:
315 # cached colors were mutated — promote to user data via
316 # the appropriate property setter
317 if name == "face":
318 self.face_colors = colors
319 elif name == "vertex":
320 self.vertex_colors = colors
321 else:
322 raise ValueError("unsupported name!!!")
323 self._cache.verify()
324 # return the stored copy of the colors
325 return self._data[key_colors]
326 # hashes match: colors are unmodified, return the cached object directly
327 return colors
328 else:
329 # colors have never been accessed
330 if self.kind is None:
331 # no colors are defined, so create a (count, 4) tiled
332 # copy of the default color
333 colors = np.tile(DEFAULT_MAT["material_diffuse"], (count, 1))
334 elif self.kind == "vertex" and name == "face":
335 colors = vertex_to_face_color(
336 vertex_colors=self.vertex_colors, faces=self.mesh.faces
337 )
338 elif self.kind == "face" and name == "vertex":
339 colors = face_to_vertex_color(
340 mesh=self.mesh, face_colors=self.face_colors
341 )
342 else:
343 raise ValueError("self.kind not accepted values!!")
345 if count is not None and colors.shape != (count, 4):
346 raise ValueError("face colors incorrect shape!")
348 # subclass the array to track for changes using a hash
349 colors = caching.tracked_array(colors)
350 # put the generated colors and their initial checksum into cache
351 self._cache[key_colors] = colors
352 self._cache[key_hash] = hash(colors)
354 return colors
356 def _verify_hash(self):
357 """
358 Verify the checksums of cached face and vertex color, to verify
359 that a user hasn't altered them since they were generated from
360 defaults.
362 If the colors have been altered since creation, move them into
363 the DataStore at self._data since the user action has made them
364 user data.
365 """
366 if not hasattr(self, "_cache") or len(self._cache) == 0:
367 return
369 for name in ["face", "vertex"]:
370 # the face or vertex colors
371 key_colors = str(name) + "_colors"
372 # the initial hash of the colors
373 key_hash = key_colors + "_hash"
375 if key_colors not in self._cache:
376 continue
378 colors = self._cache[key_colors]
379 # if the cached colors have been changed since creation
380 # move them to data
381 if hash(colors) != self._cache[key_hash]:
382 if name == "face":
383 self.face_colors = colors
384 elif name == "vertex":
385 self.vertex_colors = colors
386 else:
387 raise ValueError("unsupported name!!!")
388 self._cache.verify()
390 def update_vertices(self, mask: ArrayLike):
391 """
392 Apply a mask to remove or duplicate vertex properties.
393 """
394 self._update_key(mask, "vertex_colors")
396 def update_faces(self, mask: ArrayLike):
397 """
398 Apply a mask to remove or duplicate face properties
399 """
400 self._update_key(mask, "face_colors")
402 def face_subset(self, face_index: ArrayLike):
403 """
404 Given a mask of face indices, return a sliced version.
406 Parameters
407 ----------
408 face_index: (n,) int, mask for faces
409 (n,) bool, mask for faces
411 Returns
412 ----------
413 visual: ColorVisuals object containing a subset of faces.
414 """
415 kwargs = {}
416 if self.defined:
417 if self.face_colors is not None:
418 kwargs.update(face_colors=self.face_colors[face_index])
420 if self.vertex_colors is not None:
421 indices = np.unique(self.mesh.faces[face_index].flatten())
422 vertex_colors = self.vertex_colors[indices]
423 kwargs.update(vertex_colors=vertex_colors)
425 result = ColorVisuals(**kwargs)
427 return result
429 @property
430 def main_color(self) -> NDArray[np.uint8]:
431 """
432 What is the most commonly occurring color.
434 Returns
435 ------------
436 color: (4,) uint8, most common color
437 """
438 if self.kind is None:
439 return DEFAULT_COLOR
440 elif self.kind == "face":
441 colors = self.face_colors
442 elif self.kind == "vertex":
443 colors = self.vertex_colors
444 else:
445 raise ValueError("color kind incorrect!")
447 # find the unique colors
448 unique, inverse = unique_rows(colors)
449 # the most commonly occurring color, or mode
450 # this will be an index of inverse, not colors
451 mode_index = np.bincount(inverse).argmax()
452 color = colors[unique[mode_index]]
454 return color
456 def to_texture(self):
457 """
458 Convert the current ColorVisuals object to a texture
459 with a `SimpleMaterial` defined.
461 Returns
462 ------------
463 visual : trimesh.visual.TextureVisuals
464 Copy of the current visuals as a texture.
465 """
466 from .texture import TextureVisuals
468 mat, uv = color_to_uv(vertex_colors=self.vertex_colors)
469 return TextureVisuals(material=mat, uv=uv)
471 def concatenate(self, other: Iterable[Visuals] | Visuals | ArrayLike, *args):
472 """
473 Concatenate two or more ColorVisuals objects
474 into a single object.
476 Parameters
477 -----------
478 other : ColorVisuals
479 Object to append
480 *args: ColorVisuals objects
482 Returns
483 -----------
484 result : ColorVisuals
485 Containing information from current
486 object and others in the order it was passed.
487 """
488 # avoid a circular import
489 from . import objects
491 result = objects.concatenate(self, other, *args)
492 return result
494 def _update_key(self, mask, key):
495 """
496 Mask the value contained in the DataStore at a specified key.
498 Parameters
499 -----------
500 mask: (n,) int
501 (n,) bool
502 key: hashable object, in self._data
503 """
504 mask = np.asanyarray(mask)
505 if key in self._data:
506 self._data[key] = self._data[key][mask]
509class VertexColor(Visuals):
510 """
511 Create a simple visual object to hold just vertex colors
512 for objects such as PointClouds.
513 """
515 def __init__(self, colors=None, obj=None):
516 """
517 Create a vertex color visual
518 """
519 self.obj = obj
520 self.vertex_colors = colors
522 @property
523 def kind(self):
524 return "vertex"
526 def update_vertices(self, mask):
527 if self._colors is not None:
528 self._colors = self._colors[mask]
530 def update_faces(self, mask):
531 pass
533 @property
534 def vertex_colors(self):
535 return self._colors
537 @vertex_colors.setter
538 def vertex_colors(self, data):
539 if data is None:
540 self._colors = caching.tracked_array(None)
541 else:
542 # tile single color into color array
543 data = np.asanyarray(data)
544 if data.shape in [(3,), (4,)]:
545 data = np.tile(data, (len(self.obj.vertices), 1))
546 # track changes in colors and convert to RGBA
547 self._colors = caching.tracked_array(to_rgba(data))
549 def copy(self):
550 """
551 Return a copy of the current visuals
552 """
553 return copy.deepcopy(self)
555 def concatenate(self, other):
556 """
557 Concatenate this visual object with another
558 VertexVisuals.
560 Parameters
561 -----------
562 other : VertexColors or ColorVisuals
563 Other object to concatenate
565 Returns
566 ------------
567 concate : VertexColor
568 Object with both colors
569 """
570 return VertexColor(colors=np.vstack(self.vertex_colors, other.vertex_colors))
572 def __hash__(self):
573 return self._colors.__hash__()
576def to_rgba(colors: Any, dtype: DTypeLike = np.uint8) -> NDArray:
577 """
578 Convert a single or multiple RGB colors to RGBA colors.
580 Parameters
581 ----------
582 colors : (n, 3) or (n, 4) array
583 RGB or RGBA colors or None
585 Returns
586 ----------
587 colors : (n, 4) list of RGBA colors
588 (4,) single RGBA color
589 """
590 if colors is None:
591 return DEFAULT_COLOR
592 # if MTL uses 0 as None
593 if isinstance(colors, (int, float)) and colors == 0:
594 return DEFAULT_COLOR
596 # colors as numpy array
597 colors = np.asanyarray(colors)
598 dtype = np.dtype(dtype)
600 # what is the output dtype opaque value
601 if dtype.kind in "iu":
602 opaque = np.iinfo(dtype).max
603 elif dtype.kind == "f":
604 opaque = 1.0
605 else:
606 raise ValueError(f"Unknown dtype: `{dtype}`")
608 if colors.dtype.kind == "f":
609 # replace any `nan` or `inf` values with zero
610 colors[~np.isfinite(colors)] = 0.0
612 # multiple the 0.0 - 1.0 colors by the opaque value
613 # to scale them to the output data type's proper range
614 colors = np.clip(colors * opaque, 0.0, opaque)
616 # if the requested output type is integer-like
617 # make sure to round the multiplied floats
618 # before the `astype` on the return
619 if dtype.kind in "iu":
620 colors = colors.round()
622 if util.is_shape(colors, (-1, 3)):
623 # add an opaque alpha for RGB colors
624 colors = np.column_stack((colors, opaque * np.ones(len(colors))))
625 elif util.is_shape(colors, (3,)):
626 # if passed a single RGB color add an alpha
627 colors = np.append(colors, opaque)
628 if not (util.is_shape(colors, (4,)) or util.is_shape(colors, (-1, 4))):
629 raise ValueError("Colors not of appropriate shape!")
631 return colors.astype(dtype)
634def to_float(colors: ArrayLike) -> NDArray[np.float64]:
635 """
636 Convert integer colors to 0.0-1.0 floating point colors
638 Parameters
639 -------------
640 colors : (n, d) int
641 Integer colors
643 Returns
644 -------------
645 as_float : (n, d) float
646 Float colors 0.0 - 1.0
647 """
649 # colors as numpy array
650 colors = np.asanyarray(colors)
651 if colors.dtype.kind == "f":
652 return colors.astype(np.float64)
653 elif colors.dtype.kind in "iu":
654 # integer value for opaque alpha given our datatype
655 opaque = np.iinfo(colors.dtype).max
656 return colors.astype(np.float64) / opaque
657 else:
658 raise ValueError("only works on int or float colors!")
661def hex_to_rgba(color: str) -> NDArray[np.uint8]:
662 """
663 Turn a string hex color to a (4,) RGBA color.
665 Parameters
666 -----------
667 color: str, hex color
669 Returns
670 -----------
671 rgba: (4,) np.uint8, RGBA color
672 """
673 value = str(color).lstrip("#").strip()
674 if len(value) == 6:
675 rgb = [int(value[i : i + 2], 16) for i in (0, 2, 4)]
676 rgba = np.append(rgb, 255).astype(np.uint8)
677 else:
678 raise ValueError("Only RGB supported")
680 return rgba
683def hsv_to_rgba(hsv: ArrayLike, dtype: DTypeLike = np.uint8) -> NDArray:
684 """
685 Convert an (n, 3) array of 0.0-1.0 HSV colors into an
686 array of RGBA colors.
688 A vectorized implementation that matches `colorsys.hsv_to_rgb`.
690 Parameters
691 -----------
692 hsv
693 Should be `(n, 3)` array of 0.0-1.0 values.
695 Returns
696 ------------
697 rgba
698 An (n, 4) array of RGBA colors.
699 """
701 hsv = np.asanyarray(hsv, dtype=np.float64)
702 if len(hsv.shape) != 2 or hsv.shape[1] != 3:
703 raise ValueError("(n, 3) values of HSV are required")
704 # clip values in-place to 0.0-1.0 range
705 np.clip(hsv, a_min=0.0, a_max=1.0, out=hsv)
707 # expand into flat arrays for each of
708 # hue, saturation, and value
709 H, S, V = hsv.T
711 # chroma and other values for the equation
712 C = S * V
713 Hi = H * 6.0
714 X = C * (1.0 - np.abs((Hi % 2.0) - 1.0))
716 # stack values we need so we can access them with the lookup table
717 stacked = np.column_stack((C, X, np.zeros_like(X)))
718 # get the indexes per-row and then increment them so we can use them on the stack
719 indexes = _HSV_LOOKUP[Hi.astype(np.int64)] + (np.arange(len(H)) * 3).reshape((-1, 1))
721 # get the intermediate value, described by wikipedia as
722 # the point along the bottom three faces of the RGB cube
723 RGBi = stacked.ravel()[indexes]
725 # stack it into the final RGBA array
726 RGBA = np.column_stack((RGBi + (V - C).reshape((-1, 1)), np.ones(len(H))))
728 # now return the correct type of color
729 dtype = np.dtype(dtype)
730 if dtype.kind == "f":
731 return RGBA.astype(dtype)
732 elif dtype.kind in "iu":
733 return (RGBA * np.iinfo(dtype).max).round().astype(dtype)
735 raise ValueError(f"dtype `{dtype}` not supported")
738def linear_to_srgb(linear: ArrayLike) -> NDArray[np.float64]:
739 """
740 Converts linear color values to sRGB color values.
742 See: https://entropymine.com/imageworsener/srgbformula/
744 Parameters
745 ----------
746 linear
747 Linear color values of any shape since this
748 is a per-element transformation
750 Returns
751 ---------
752 srgb
753 Values scaled to an sRGB scale.
754 """
755 linear = to_float(linear)
757 mask = linear > 0.00313066844250063
758 srgb = np.zeros(linear.shape, dtype=np.float64)
759 srgb[mask] = 1.055 * np.power(linear[mask], (1.0 / 2.4)) - 0.055
760 srgb[~mask] = 12.92 * linear[~mask]
762 return srgb
765def srgb_to_linear(srgb: ArrayLike) -> NDArray[np.float64]:
766 """
767 Converts sRGB color values to linear color values.
768 See: https://entropymine.com/imageworsener/srgbformula/
769 """
771 # make sure the color values are floating point scaled
772 srgb = to_float(srgb)
774 mask = srgb <= 0.0404482362771082
775 linear = np.zeros(srgb.shape, dtype=np.float64)
776 linear[mask] = srgb[mask] / 12.92
777 linear[~mask] = np.power(((srgb[~mask] + 0.055) / 1.055), 2.4)
779 return linear
782def random_color(
783 dtype: DTypeLike = np.uint8,
784 count: Integer | None = None,
785 seed: Seed = None,
786) -> NDArray:
787 """
788 Return a random RGB color using datatype specified.
790 Parameters
791 ----------
792 dtype
793 Color type of result.
794 count
795 If passed return (count, 4) colors instead of
796 a single (4,) color.
797 seed
798 Seed for deterministic results, otherwise OS entropy.
800 Returns
801 ----------
802 color : (4,) or (count, 4)
803 Random color or colors that look "OK"
804 """
805 # generate a random hue
806 hue = (util.random_generator(seed).random(count or 1) + 0.61803) % 1.0
808 # saturation and "value" as constant
809 sv = np.ones_like(hue) * 0.99
810 # convert our random hue to RGBA
811 colors = hsv_to_rgba(np.column_stack((hue, sv, sv)), dtype=dtype)
813 # unspecified count is a single color
814 if count is None:
815 return colors[0]
816 return colors
819def vertex_to_face_color(vertex_colors: ArrayLike, faces: ArrayLike) -> NDArray[np.uint8]:
820 """
821 Convert a list of vertex colors to face colors.
823 Parameters
824 ----------
825 vertex_colors: (n,(3,4)), colors
826 faces: (m,3) int, face indexes
828 Returns
829 -----------
830 face_colors: (m,4) colors
831 """
832 vertex_colors = to_rgba(vertex_colors)
833 face_colors = vertex_colors[faces].mean(axis=1)
834 return face_colors.astype(np.uint8)
837def face_to_vertex_color(
838 mesh, face_colors: ArrayLike, dtype: DTypeLike = np.uint8
839) -> NDArray:
840 """
841 Convert face colors into vertex colors.
843 Parameters
844 -----------
845 mesh : trimesh.Trimesh
846 Mesh to convert colors for
847 face_colors : `(len(mesh.faces), (3 | 4))` int
848 The colors for each face of the mesh
849 dtype
850 What should colors be returned in.
852 Returns
853 -----------
854 vertex_colors : `(len(mesh.vertices), 4)`
855 Color for each vertex
856 """
857 rgba = to_rgba(face_colors)
858 vertex = mesh.faces_sparse.dot(rgba.astype(np.float64))
859 degree = mesh.vertex_degree
861 # normalize color by the number of faces including
862 # the vertex (i.e. the vertex degree)
863 nonzero = degree > 0
864 vertex[nonzero] /= degree[nonzero].reshape((-1, 1))
866 assert vertex.shape == (len(mesh.vertices), 4)
868 return vertex.astype(dtype)
871def colors_to_materials(colors: ArrayLike, count: Integer | None = None):
872 """
873 Convert a list of colors into a list of unique materials
874 and material indexes.
876 Parameters
877 -----------
878 colors : (n, 3) or (n, 4) float
879 RGB or RGBA colors
880 count : int
881 Number of entities to apply color to
883 Returns
884 -----------
885 diffuse : (m, 4) int
886 Colors
887 index : (count,) int
888 Index of each color
889 """
891 # convert RGB to RGBA
892 rgba = to_rgba(colors)
894 # if we were only passed a single color
895 if util.is_shape(rgba, (4,)) and count is not None:
896 diffuse = rgba.reshape((-1, 4))
897 index = np.zeros(count, dtype=np.int64)
898 elif util.is_shape(rgba, (-1, 4)):
899 # we were passed multiple colors
900 # find the unique colors in the list to save as materials
901 unique, index = unique_rows(rgba)
902 diffuse = rgba[unique]
903 else:
904 raise ValueError("Colors not convertible!")
906 return diffuse, index
909def linear_color_map(values: ArrayLike, color_range: ArrayLike | None = None) -> NDArray:
910 """
911 Linearly interpolate a color lookup table from normalized
912 values.
914 For example if `color_range` has two values [`a`, `b`]
915 `values` is `[0.0, 0.5, 1.0]`, this function will return
916 [`a`, `(a+b)/2`, `b`].
918 The default value for `color_range` is red-green, or you
919 can pass in a full lookup table for a color map, i.e. a
920 `(256, 3) float64` array of RGB colors such as our defaults:
921 `trimesh.resources.get_json('color_map.json.gzip')['viridis']`
923 Parameters
924 --------------
925 values : (n, ) float
926 Normalized to 0.0-1.0 values to interpolate
927 color_range : None or (n, 3|4)
928 Evenly spaced colors to interpolate through
929 where `n >= 2`.
931 Returns
932 ---------------
933 colors : (n, 4) color_range.dtype
934 RGBA colors for interpolated values
935 """
937 if color_range is None:
938 # do a very unimaginative "red to green" linear scale
939 color_range = np.array([[255, 0, 0, 255], [0, 255, 0, 255]], dtype=np.uint8)
940 else:
941 # make sure we have a numpy array
942 color_range = np.asanyarray(color_range)
944 # do simple checks on the color range shape
945 if color_range.shape[0] < 2 or color_range.shape[1] < 3:
946 raise ValueError(
947 "color_range must be RGBA convertible and have more than 2 values!"
948 )
950 # float 1D array clamped to 0.0 - 1.0
951 values = np.clip(np.asanyarray(values, dtype=np.float64).ravel(), 0.0, 1.0).reshape(
952 (-1, 1)
953 )
955 # what is the maximum index of our colors
956 max_index = len(color_range) - 1
957 # convert our normalized values into a fractional index
958 index = values.ravel() * max_index
960 # get the left and right indexes
961 # clipping should be a no-op based on above normalization but
962 # be extra sure ceil isn't pushing us out of our array range
963 bounds = np.clip(
964 np.column_stack((np.floor(index), np.ceil(index))), 0.0, max_index
965 ).astype(np.int64)
967 # get the factor of how far each point is between `bounds` pair
968 factor = index - bounds[:, 0]
970 # reshape the factor into an interpolation
971 multiplier = np.column_stack((1.0 - factor, factor)).reshape((-1, 2, 1))
973 # get both colors, multiply them by the interpolation multiplier, and sum
974 interpolated = (color_range.astype(np.float64)[bounds] * multiplier).sum(axis=1)
976 # if we're returning integers make sure to round first
977 if color_range.dtype.kind in "iu":
978 return interpolated.round().astype(color_range.dtype)
980 return interpolated.astype(color_range.dtype)
983def interpolate(
984 values: ArrayLike,
985 color_map: ColorMapType | Callable | None = None,
986 dtype: DTypeLike = np.uint8,
987) -> NDArray:
988 """
989 Given a 1D list of values, return interpolated colors
990 for the range.
992 Parameters
993 ---------------
994 values : (n, ) float
995 Values to be interpolated over
996 color_map
997 One of the four included color maps:
998 ("viridis", "inferno", "plasma", "magma")
999 Or a function, `matplotlib.pyplot.get_cmap
1002 Returns
1003 -------------
1004 interpolated : (n, 4) dtype
1005 Interpolated RGBA colors
1006 """
1008 # make `viridis` the default just like everyone else
1009 if color_map is None:
1010 color_map = "viridis"
1012 if callable(color_map):
1013 # should be a `matplotlib.pyplot.get_cmap` callable
1014 cmap = color_map
1015 elif isinstance(color_map, str):
1016 # color map is a named key in our packaged color maps
1017 available = get_json("color_map.json.gzip")
1018 if color_map not in available:
1019 # we could have added a fallback to matplotlib:
1020 # `from matplotlib.pyplot import get_cmap; cmap = get_cmap(name)`
1021 # but we don't want trimesh to depend on matplotlib as it is quite heavy
1022 raise ValueError(
1023 f"Included color maps are: {available.keys()}.\n\n"
1024 + "If you want to use a `matplotlib` color map you can "
1025 + "pass it as `color_map=matplotlib.pyplot.get_cmap(name)`"
1026 )
1028 # pass in the retrieved color map values to linear_color_map
1029 def cmap(x):
1030 return linear_color_map(x, np.array(available[color_map]))
1031 else:
1032 raise TypeError(f"Unknown color map: `{type(color_map)}`")
1034 # make input always float
1035 values = np.asanyarray(values, dtype=np.float64).ravel()
1037 # get both minumium and maximum values for range normalization
1038 v_min, v_max = values.min(), values.max()
1039 # offset to zero
1040 values -= v_min
1041 # normalize to the 0.0 - 1.0 range
1042 if v_min != v_max:
1043 values /= v_max - v_min
1045 # scale values to 0.0 - 1.0 and get colors
1046 colors = cmap(values)
1048 # convert to 0-255 RGBA
1049 rgba = to_rgba(colors, dtype=dtype)
1051 return rgba
1054def uv_to_color(uv, image) -> NDArray[np.uint8]:
1055 """
1056 Get the color in a texture image.
1058 Parameters
1059 -------------
1060 uv : (n, 2) float
1061 UV coordinates on texture image
1062 image : PIL.Image
1063 Texture image
1065 Returns
1066 ----------
1067 colors : (n, 4) uint4
1068 RGBA color at each of the UV coordinates
1069 """
1070 if image is None or uv is None:
1071 return None
1073 # UV coordinates should be (n, 2) float
1074 uv = np.asanyarray(uv, dtype=np.float64)
1076 # get texture image pixel positions of UV coordinates
1077 x = (uv[:, 0] * (image.width - 1)) % image.width
1078 y = ((1 - uv[:, 1]) * (image.height - 1)) % image.height
1080 # access colors from pixel locations
1081 # make sure image is RGBA before getting values
1082 colors = np.asanyarray(image.convert("RGBA"))[
1083 y.round().astype(np.int64) % image.height,
1084 x.round().astype(np.int64) % image.width,
1085 ]
1087 # conversion to RGBA should have corrected shape
1088 assert colors.ndim == 2 and colors.shape[1] == 4
1089 assert colors.dtype == np.uint8
1091 return colors
1094def uv_to_interpolated_color(uv: ArrayLike, image) -> NDArray[np.uint8]:
1095 """
1096 Get the color from texture image using bilinear sampling.
1098 Parameters
1099 -------------
1100 uv : (n, 2) float
1101 UV coordinates on texture image
1102 image : PIL.Image
1103 Texture image
1105 Returns
1106 ----------
1107 colors : (n, 4) uint8
1108 RGBA color at each of the UV coordinates.
1109 """
1110 if image is None or uv is None:
1111 return None
1113 # UV coordinates should be (n, 2) float
1114 uv = np.asanyarray(uv, dtype=np.float64)
1116 # get texture image pixel positions of UV coordinates
1117 x = uv[:, 0] * (image.width - 1)
1118 y = (1 - uv[:, 1]) * (image.height - 1)
1120 x_floor = np.floor(x).astype(np.int64) % image.width
1121 y_floor = np.floor(y).astype(np.int64) % image.height
1123 x_ceil = np.ceil(x).astype(np.int64) % image.width
1124 y_ceil = np.ceil(y).astype(np.int64) % image.height
1126 dx = x % image.width - x_floor
1127 dy = y % image.height - y_floor
1129 img = np.asanyarray(image.convert("RGBA"))
1131 colors00 = img[y_floor, x_floor]
1132 colors01 = img[y_floor, x_ceil]
1133 colors10 = img[y_ceil, x_floor]
1134 colors11 = img[y_ceil, x_ceil]
1136 a00 = (1 - dx) * (1 - dy)
1137 a01 = dx * (1 - dy)
1138 a10 = (1 - dx) * dy
1139 a11 = dx * dy
1141 a00 = np.repeat(a00[:, None], 4, axis=1)
1142 a01 = np.repeat(a01[:, None], 4, axis=1)
1143 a10 = np.repeat(a10[:, None], 4, axis=1)
1144 a11 = np.repeat(a11[:, None], 4, axis=1)
1146 # interpolated colors as floating point then convert back to uint8
1147 colors = (
1148 (a00 * colors00 + a01 * colors01 + a10 * colors10 + a11 * colors11)
1149 .round()
1150 .astype(np.uint8)
1151 )
1153 # conversion to RGBA should have corrected shape
1154 assert colors.ndim == 2 and colors.shape[1] == 4
1155 assert colors.dtype == np.uint8
1157 return colors
1160def color_to_uv(vertex_colors: ArrayLike):
1161 """
1162 Pack vertex colors into UV coordinates and a simple image material
1164 Parameters
1165 ------------
1166 vertex_colors : (n, 4) float
1167 Array of vertex colors.
1169 Returns
1170 ------------
1171 material : SimpleMaterial
1172 Material containing color information.
1173 uv : (n, 2) float
1174 Normalized UV coordinates
1175 """
1176 from .material import SimpleMaterial, empty_material
1178 # deduplicate the vertex colors
1179 unique, inverse = unique_rows(vertex_colors)
1181 # if there is only one color return a
1182 if len(unique) == 1:
1183 # return a simple single-pixel material
1184 material = empty_material(color=vertex_colors[unique[0]])
1185 uvs = np.zeros((len(vertex_colors), 2)) + 0.5
1186 return material, uvs
1188 from PIL import Image
1190 # return a square image of (size, size)
1191 size = int(np.ceil(np.sqrt(len(unique))))
1192 ctype = vertex_colors.shape[1]
1194 colors = np.zeros((size**2, ctype), dtype=vertex_colors.dtype)
1195 colors[: len(unique)] = vertex_colors[unique]
1197 # PIL has reversed x-y coordinates
1198 image = Image.fromarray(colors.reshape((size, size, ctype))[::-1])
1200 pos = np.arange(len(unique))
1201 # create tiled coordinates for the color pixels
1202 coords = np.column_stack((pos % size, np.floor(pos / size)))
1204 # normalize the index coords into 0.0 - 1.0
1205 # and offset them to be centered on the pixel
1206 coords = (coords / size) + (1.0 / (size * 2.0))
1207 uvs = coords[inverse]
1209 if tol.strict:
1210 # check the packed colors against the image
1211 check = uv_to_color(image=image, uv=uvs)
1212 assert np.all(check == vertex_colors)
1214 return SimpleMaterial(image=image), uvs
1217# set an arbitrary grey as the default color
1218DEFAULT_COLOR = np.array([102, 102, 102, 255], dtype=np.uint8)
1219DEFAULT_MAT = {
1220 "material_diffuse": np.array([102, 102, 102, 255], dtype=np.uint8),
1221 "material_ambient": np.array([64, 64, 64, 255], dtype=np.uint8),
1222 "material_specular": np.array([197, 197, 197, 255], dtype=np.uint8),
1223 "material_shine": 77.0,
1224}