Coverage for trimesh/visual/color.py: 86%
408 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-24 04:40 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-24 04:40 +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)
43from .base import Visuals
45# Save a lookup table for an integer to match the
46# cases for HSV conversion specified on the wikipedia article
47# Where indexes 0=C, 1=X, 2=0.0
48_HSV_LOOKUP = np.array(
49 [[0, 1, 2], [1, 0, 2], [2, 0, 1], [2, 1, 0], [1, 2, 0], [0, 2, 1]], dtype=np.int64
50)
51_HSV_LOOKUP.flags.writeable = False
54class ColorVisuals(Visuals):
55 """
56 Store color information about a mesh.
57 """
59 def __init__(
60 self,
61 mesh=None,
62 face_colors: ArrayLike | None = None,
63 vertex_colors: ArrayLike | None = None,
64 ):
65 """
66 Store color information about a mesh.
68 Parameters
69 ----------
70 mesh : Trimesh
71 Object that these visual properties
72 are associated with
73 face_ colors : (n,3|4) or (3,) or (4,) uint8
74 Colors per-face
75 vertex_colors : (n,3|4) or (3,) or (4,) uint8
76 Colors per-vertex
77 """
78 self.mesh = mesh
79 self._data = caching.DataStore()
80 self._cache = caching.Cache(id_function=self._data.__hash__)
82 try:
83 if face_colors is not None:
84 self.face_colors = face_colors
85 if vertex_colors is not None:
86 self.vertex_colors = vertex_colors
87 except ValueError:
88 util.log.warning("unable to convert colors!")
90 @caching.cache_decorator
91 def transparency(self) -> bool:
92 """
93 Does the current object contain any transparency.
95 Returns
96 ----------
97 transparency: bool, does the current visual contain transparency
98 """
99 if "vertex_colors" in self._data:
100 a_min = self._data["vertex_colors"][:, 3].min()
101 elif "face_colors" in self._data:
102 a_min = self._data["face_colors"][:, 3].min()
103 else:
104 return False
106 return bool(a_min < 255)
108 @property
109 def defined(self) -> bool:
110 """
111 Are any colors defined for the current mesh.
113 Returns
114 ---------
115 defined : bool
116 Are colors defined or not.
117 """
118 return self.kind is not None
120 @property
121 def kind(self) -> str | None:
122 """
123 What color mode has been set.
125 Returns
126 ----------
127 mode : str or None
128 One of ('face', 'vertex', None)
129 """
130 # if nothing is stored anywhere it's a safe bet mode is None
131 if not (len(self._cache.cache) > 0 or len(self._data.data) > 0):
132 return None
134 self._verify_hash()
136 # check modes in data
137 if "vertex_colors" in self._data:
138 return "vertex"
139 elif "face_colors" in self._data:
140 return "face"
142 return None
144 def __hash__(self):
145 return self._data.__hash__()
147 def copy(self) -> "ColorVisuals":
148 """
149 Return a copy of the current ColorVisuals object.
152 Returns
153 ----------
154 copied : ColorVisuals
155 Contains the same information as self
156 """
157 copied = ColorVisuals()
158 # call the literally insane generators to validate
159 self.face_colors # noqa
160 self.vertex_colors # noqa
161 # copy anything that's actually data
162 copied._data.data = copy.deepcopy(self._data.data)
164 return copied
166 @property
167 def face_colors(self) -> NDArray[np.uint8]:
168 """
169 Colors defined for each face of a mesh.
171 If no colors are defined, defaults are returned.
173 Returns
174 ----------
175 colors : (len(mesh.faces), 4) uint8
176 RGBA color for each face
177 """
178 return self._get_colors(name="face")
180 @face_colors.setter
181 def face_colors(self, values: ArrayLike):
182 """
183 Set the colors for each face of a mesh.
185 This will apply these colors and delete any previously specified
186 color information.
188 Parameters
189 ------------
190 colors : (len(mesh.faces), 3), set each face to the specified color
191 (len(mesh.faces), 4), set each face to the specified color
192 (3,) int, set the whole mesh this color
193 (4,) int, set the whole mesh this color
194 """
195 if values is None:
196 if "face_colors" in self._data:
197 self._data.data.pop("face_colors")
198 return
200 colors = to_rgba(values)
202 if self.mesh is not None and colors.shape == (4,):
203 count = len(self.mesh.faces)
204 colors = np.tile(colors, (count, 1))
206 # if we set any color information, clear the others
207 self._data.clear()
208 self._data["face_colors"] = colors
209 self._cache.verify()
211 @property
212 def vertex_colors(self) -> NDArray[np.uint8]:
213 """
214 Return the colors for each vertex of a mesh
216 Returns
217 ------------
218 colors: (len(mesh.vertices), 4) uint8, color for each vertex
219 """
220 return self._get_colors(name="vertex")
222 @vertex_colors.setter
223 def vertex_colors(self, values: ArrayLike):
224 """
225 Set the colors for each vertex of a mesh
227 This will apply these colors and delete any previously specified
228 color information.
230 Parameters
231 ------------
232 colors : (len(mesh.vertices), 3), set each face to the color
233 (len(mesh.vertices), 4), set each face to the color
234 (3,) int, set the whole mesh this color
235 (4,) int, set the whole mesh this color
236 """
237 if values is None:
238 if "vertex_colors" in self._data:
239 self._data.data.pop("vertex_colors")
240 return
242 # make sure passed values are numpy array
243 values = np.asanyarray(values)
244 # Ensure the color shape is sane
245 if self.mesh is not None and not (
246 values.shape == (len(self.mesh.vertices), 3)
247 or values.shape == (len(self.mesh.vertices), 4)
248 or values.shape == (3,)
249 or values.shape == (4,)
250 ):
251 return
253 colors = to_rgba(values)
254 if self.mesh is not None and colors.shape == (4,):
255 count = len(self.mesh.vertices)
256 colors = np.tile(colors, (count, 1))
258 # if we set any color information, clear the others
259 self._data.clear()
260 self._data["vertex_colors"] = colors
261 self._cache.verify()
263 def _get_colors(self, name):
264 """
265 A magical function which maintains the sanity of vertex and face colors.
267 * If colors have been explicitly stored or changed, they are considered
268 user data, stored in self._data (DataStore), and are returned immediately
269 when requested.
270 * If colors have never been set, a (count,4) tiled copy of the default diffuse
271 color will be stored in the cache
272 ** the hash on creation for these cached default colors will also be stored
273 ** if the cached color array is altered (different hash than when it was
274 created) we consider that now to be user data and the array is moved from
275 the cache to the DataStore.
277 Parameters
278 -----------
279 name : str
280 Values 'face' or 'vertex'
282 Returns
283 -----------
284 colors : (count, 4) uint8
285 RGBA colors
286 """
288 count = None
289 try:
290 if name == "face":
291 count = len(self.mesh.faces)
292 elif name == "vertex":
293 count = len(self.mesh.vertices)
294 except BaseException:
295 pass
297 # the face or vertex colors
298 key_colors = str(name) + "_colors"
299 # the initial hash of the colors
300 key_hash = key_colors + "_hash"
302 if key_colors in self._data:
303 # if a user has explicitly stored or changed the color it
304 # will be in data
305 return self._data[key_colors]
307 elif key_colors in self._cache:
308 # if the colors have been autogenerated already they
309 # will be in the cache
310 colors = self._cache[key_colors]
311 # if the cached colors have been changed since creation we move
312 # them to data
313 if hash(colors) != self._cache[key_hash]:
314 # cached colors were mutated — promote to user data via
315 # the appropriate property setter
316 if name == "face":
317 self.face_colors = colors
318 elif name == "vertex":
319 self.vertex_colors = colors
320 else:
321 raise ValueError("unsupported name!!!")
322 self._cache.verify()
323 # return the stored copy of the colors
324 return self._data[key_colors]
325 # hashes match: colors are unmodified, return the cached object directly
326 return colors
327 else:
328 # colors have never been accessed
329 if self.kind is None:
330 # no colors are defined, so create a (count, 4) tiled
331 # copy of the default color
332 colors = np.tile(DEFAULT_MAT["material_diffuse"], (count, 1))
333 elif self.kind == "vertex" and name == "face":
334 colors = vertex_to_face_color(
335 vertex_colors=self.vertex_colors, faces=self.mesh.faces
336 )
337 elif self.kind == "face" and name == "vertex":
338 colors = face_to_vertex_color(
339 mesh=self.mesh, face_colors=self.face_colors
340 )
341 else:
342 raise ValueError("self.kind not accepted values!!")
344 if count is not None and colors.shape != (count, 4):
345 raise ValueError("face colors incorrect shape!")
347 # subclass the array to track for changes using a hash
348 colors = caching.tracked_array(colors)
349 # put the generated colors and their initial checksum into cache
350 self._cache[key_colors] = colors
351 self._cache[key_hash] = hash(colors)
353 return colors
355 def _verify_hash(self):
356 """
357 Verify the checksums of cached face and vertex color, to verify
358 that a user hasn't altered them since they were generated from
359 defaults.
361 If the colors have been altered since creation, move them into
362 the DataStore at self._data since the user action has made them
363 user data.
364 """
365 if not hasattr(self, "_cache") or len(self._cache) == 0:
366 return
368 for name in ["face", "vertex"]:
369 # the face or vertex colors
370 key_colors = str(name) + "_colors"
371 # the initial hash of the colors
372 key_hash = key_colors + "_hash"
374 if key_colors not in self._cache:
375 continue
377 colors = self._cache[key_colors]
378 # if the cached colors have been changed since creation
379 # move them to data
380 if hash(colors) != self._cache[key_hash]:
381 if name == "face":
382 self.face_colors = colors
383 elif name == "vertex":
384 self.vertex_colors = colors
385 else:
386 raise ValueError("unsupported name!!!")
387 self._cache.verify()
389 def update_vertices(self, mask: ArrayLike):
390 """
391 Apply a mask to remove or duplicate vertex properties.
392 """
393 self._update_key(mask, "vertex_colors")
395 def update_faces(self, mask: ArrayLike):
396 """
397 Apply a mask to remove or duplicate face properties
398 """
399 self._update_key(mask, "face_colors")
401 def face_subset(self, face_index: ArrayLike):
402 """
403 Given a mask of face indices, return a sliced version.
405 Parameters
406 ----------
407 face_index: (n,) int, mask for faces
408 (n,) bool, mask for faces
410 Returns
411 ----------
412 visual: ColorVisuals object containing a subset of faces.
413 """
414 kwargs = {}
415 if self.defined:
416 if self.face_colors is not None:
417 kwargs.update(face_colors=self.face_colors[face_index])
419 if self.vertex_colors is not None:
420 indices = np.unique(self.mesh.faces[face_index].flatten())
421 vertex_colors = self.vertex_colors[indices]
422 kwargs.update(vertex_colors=vertex_colors)
424 result = ColorVisuals(**kwargs)
426 return result
428 @property
429 def main_color(self) -> NDArray[np.uint8]:
430 """
431 What is the most commonly occurring color.
433 Returns
434 ------------
435 color: (4,) uint8, most common color
436 """
437 if self.kind is None:
438 return DEFAULT_COLOR
439 elif self.kind == "face":
440 colors = self.face_colors
441 elif self.kind == "vertex":
442 colors = self.vertex_colors
443 else:
444 raise ValueError("color kind incorrect!")
446 # find the unique colors
447 unique, inverse = unique_rows(colors)
448 # the most commonly occurring color, or mode
449 # this will be an index of inverse, not colors
450 mode_index = np.bincount(inverse).argmax()
451 color = colors[unique[mode_index]]
453 return color
455 def to_texture(self):
456 """
457 Convert the current ColorVisuals object to a texture
458 with a `SimpleMaterial` defined.
460 Returns
461 ------------
462 visual : trimesh.visual.TextureVisuals
463 Copy of the current visuals as a texture.
464 """
465 from .texture import TextureVisuals
467 mat, uv = color_to_uv(vertex_colors=self.vertex_colors)
468 return TextureVisuals(material=mat, uv=uv)
470 def concatenate(self, other: Iterable[Visuals] | Visuals | ArrayLike, *args):
471 """
472 Concatenate two or more ColorVisuals objects
473 into a single object.
475 Parameters
476 -----------
477 other : ColorVisuals
478 Object to append
479 *args: ColorVisuals objects
481 Returns
482 -----------
483 result : ColorVisuals
484 Containing information from current
485 object and others in the order it was passed.
486 """
487 # avoid a circular import
488 from . import objects
490 result = objects.concatenate(self, other, *args)
491 return result
493 def _update_key(self, mask, key):
494 """
495 Mask the value contained in the DataStore at a specified key.
497 Parameters
498 -----------
499 mask: (n,) int
500 (n,) bool
501 key: hashable object, in self._data
502 """
503 mask = np.asanyarray(mask)
504 if key in self._data:
505 self._data[key] = self._data[key][mask]
508class VertexColor(Visuals):
509 """
510 Create a simple visual object to hold just vertex colors
511 for objects such as PointClouds.
512 """
514 def __init__(self, colors=None, obj=None):
515 """
516 Create a vertex color visual
517 """
518 self.obj = obj
519 self.vertex_colors = colors
521 @property
522 def kind(self):
523 return "vertex"
525 def update_vertices(self, mask):
526 if self._colors is not None:
527 self._colors = self._colors[mask]
529 def update_faces(self, mask):
530 pass
532 @property
533 def vertex_colors(self):
534 return self._colors
536 @vertex_colors.setter
537 def vertex_colors(self, data):
538 if data is None:
539 self._colors = caching.tracked_array(None)
540 else:
541 # tile single color into color array
542 data = np.asanyarray(data)
543 if data.shape in [(3,), (4,)]:
544 data = np.tile(data, (len(self.obj.vertices), 1))
545 # track changes in colors and convert to RGBA
546 self._colors = caching.tracked_array(to_rgba(data))
548 def copy(self):
549 """
550 Return a copy of the current visuals
551 """
552 return copy.deepcopy(self)
554 def concatenate(self, other):
555 """
556 Concatenate this visual object with another
557 VertexVisuals.
559 Parameters
560 -----------
561 other : VertexColors or ColorVisuals
562 Other object to concatenate
564 Returns
565 ------------
566 concate : VertexColor
567 Object with both colors
568 """
569 return VertexColor(colors=np.vstack(self.vertex_colors, other.vertex_colors))
571 def __hash__(self):
572 return self._colors.__hash__()
575def to_rgba(colors: Any, dtype: DTypeLike = np.uint8) -> NDArray:
576 """
577 Convert a single or multiple RGB colors to RGBA colors.
579 Parameters
580 ----------
581 colors : (n, 3) or (n, 4) array
582 RGB or RGBA colors or None
584 Returns
585 ----------
586 colors : (n, 4) list of RGBA colors
587 (4,) single RGBA color
588 """
589 if colors is None:
590 return DEFAULT_COLOR
591 # if MTL uses 0 as None
592 if isinstance(colors, (int, float)) and colors == 0:
593 return DEFAULT_COLOR
595 # colors as numpy array
596 colors = np.asanyarray(colors)
597 dtype = np.dtype(dtype)
599 # what is the output dtype opaque value
600 if dtype.kind in "iu":
601 opaque = np.iinfo(dtype).max
602 elif dtype.kind == "f":
603 opaque = 1.0
604 else:
605 raise ValueError(f"Unknown dtype: `{dtype}`")
607 if colors.dtype.kind == "f":
608 # replace any `nan` or `inf` values with zero
609 colors[~np.isfinite(colors)] = 0.0
611 # multiple the 0.0 - 1.0 colors by the opaque value
612 # to scale them to the output data type's proper range
613 colors = np.clip(colors * opaque, 0.0, opaque)
615 # if the requested output type is integer-like
616 # make sure to round the multiplied floats
617 # before the `astype` on the return
618 if dtype.kind in "iu":
619 colors = colors.round()
621 if util.is_shape(colors, (-1, 3)):
622 # add an opaque alpha for RGB colors
623 colors = np.column_stack((colors, opaque * np.ones(len(colors))))
624 elif util.is_shape(colors, (3,)):
625 # if passed a single RGB color add an alpha
626 colors = np.append(colors, opaque)
627 if not (util.is_shape(colors, (4,)) or util.is_shape(colors, (-1, 4))):
628 raise ValueError("Colors not of appropriate shape!")
630 return colors.astype(dtype)
633def to_float(colors: ArrayLike) -> NDArray[np.float64]:
634 """
635 Convert integer colors to 0.0-1.0 floating point colors
637 Parameters
638 -------------
639 colors : (n, d) int
640 Integer colors
642 Returns
643 -------------
644 as_float : (n, d) float
645 Float colors 0.0 - 1.0
646 """
648 # colors as numpy array
649 colors = np.asanyarray(colors)
650 if colors.dtype.kind == "f":
651 return colors.astype(np.float64)
652 elif colors.dtype.kind in "iu":
653 # integer value for opaque alpha given our datatype
654 opaque = np.iinfo(colors.dtype).max
655 return colors.astype(np.float64) / opaque
656 else:
657 raise ValueError("only works on int or float colors!")
660def hex_to_rgba(color: str) -> NDArray[np.uint8]:
661 """
662 Turn a string hex color to a (4,) RGBA color.
664 Parameters
665 -----------
666 color: str, hex color
668 Returns
669 -----------
670 rgba: (4,) np.uint8, RGBA color
671 """
672 value = str(color).lstrip("#").strip()
673 if len(value) == 6:
674 rgb = [int(value[i : i + 2], 16) for i in (0, 2, 4)]
675 rgba = np.append(rgb, 255).astype(np.uint8)
676 else:
677 raise ValueError("Only RGB supported")
679 return rgba
682def hsv_to_rgba(hsv: ArrayLike, dtype: DTypeLike = np.uint8) -> NDArray:
683 """
684 Convert an (n, 3) array of 0.0-1.0 HSV colors into an
685 array of RGBA colors.
687 A vectorized implementation that matches `colorsys.hsv_to_rgb`.
689 Parameters
690 -----------
691 hsv
692 Should be `(n, 3)` array of 0.0-1.0 values.
694 Returns
695 ------------
696 rgba
697 An (n, 4) array of RGBA colors.
698 """
700 hsv = np.asanyarray(hsv, dtype=np.float64)
701 if len(hsv.shape) != 2 or hsv.shape[1] != 3:
702 raise ValueError("(n, 3) values of HSV are required")
703 # clip values in-place to 0.0-1.0 range
704 np.clip(hsv, a_min=0.0, a_max=1.0, out=hsv)
706 # expand into flat arrays for each of
707 # hue, saturation, and value
708 H, S, V = hsv.T
710 # chroma and other values for the equation
711 C = S * V
712 Hi = H * 6.0
713 X = C * (1.0 - np.abs((Hi % 2.0) - 1.0))
715 # stack values we need so we can access them with the lookup table
716 stacked = np.column_stack((C, X, np.zeros_like(X)))
717 # get the indexes per-row and then increment them so we can use them on the stack
718 indexes = _HSV_LOOKUP[Hi.astype(np.int64)] + (np.arange(len(H)) * 3).reshape((-1, 1))
720 # get the intermediate value, described by wikipedia as
721 # the point along the bottom three faces of the RGB cube
722 RGBi = stacked.ravel()[indexes]
724 # stack it into the final RGBA array
725 RGBA = np.column_stack((RGBi + (V - C).reshape((-1, 1)), np.ones(len(H))))
727 # now return the correct type of color
728 dtype = np.dtype(dtype)
729 if dtype.kind == "f":
730 return RGBA.astype(dtype)
731 elif dtype.kind in "iu":
732 return (RGBA * np.iinfo(dtype).max).round().astype(dtype)
734 raise ValueError(f"dtype `{dtype}` not supported")
737def linear_to_srgb(linear: ArrayLike) -> NDArray[np.float64]:
738 """
739 Converts linear color values to sRGB color values.
741 See: https://entropymine.com/imageworsener/srgbformula/
743 Parameters
744 ----------
745 linear
746 Linear color values of any shape since this
747 is a per-element transformation
749 Returns
750 ---------
751 srgb
752 Values scaled to an sRGB scale.
753 """
754 linear = to_float(linear)
756 mask = linear > 0.00313066844250063
757 srgb = np.zeros(linear.shape, dtype=np.float64)
758 srgb[mask] = 1.055 * np.power(linear[mask], (1.0 / 2.4)) - 0.055
759 srgb[~mask] = 12.92 * linear[~mask]
761 return srgb
764def srgb_to_linear(srgb: ArrayLike) -> NDArray[np.float64]:
765 """
766 Converts sRGB color values to linear color values.
767 See: https://entropymine.com/imageworsener/srgbformula/
768 """
770 # make sure the color values are floating point scaled
771 srgb = to_float(srgb)
773 mask = srgb <= 0.0404482362771082
774 linear = np.zeros(srgb.shape, dtype=np.float64)
775 linear[mask] = srgb[mask] / 12.92
776 linear[~mask] = np.power(((srgb[~mask] + 0.055) / 1.055), 2.4)
778 return linear
781def random_color(dtype: DTypeLike = np.uint8, count: Integer | None = None) -> NDArray:
782 """
783 Return a random RGB color using datatype specified.
785 Parameters
786 ----------
787 dtype
788 Color type of result.
789 count
790 If passed return (count, 4) colors instead of
791 a single (4,) color.
793 Returns
794 ----------
795 color : (4,) or (count, 4)
796 Random color or colors that look "OK"
797 """
798 # generate a random hue
799 hue = (np.random.random(count or 1) + 0.61803) % 1.0
801 # saturation and "value" as constant
802 sv = np.ones_like(hue) * 0.99
803 # convert our random hue to RGBA
804 colors = hsv_to_rgba(np.column_stack((hue, sv, sv)))
806 # unspecified count is a single color
807 if count is None:
808 return colors[0]
809 return colors
812def vertex_to_face_color(vertex_colors: ArrayLike, faces: ArrayLike) -> NDArray[np.uint8]:
813 """
814 Convert a list of vertex colors to face colors.
816 Parameters
817 ----------
818 vertex_colors: (n,(3,4)), colors
819 faces: (m,3) int, face indexes
821 Returns
822 -----------
823 face_colors: (m,4) colors
824 """
825 vertex_colors = to_rgba(vertex_colors)
826 face_colors = vertex_colors[faces].mean(axis=1)
827 return face_colors.astype(np.uint8)
830def face_to_vertex_color(
831 mesh, face_colors: ArrayLike, dtype: DTypeLike = np.uint8
832) -> NDArray:
833 """
834 Convert face colors into vertex colors.
836 Parameters
837 -----------
838 mesh : trimesh.Trimesh
839 Mesh to convert colors for
840 face_colors : `(len(mesh.faces), (3 | 4))` int
841 The colors for each face of the mesh
842 dtype
843 What should colors be returned in.
845 Returns
846 -----------
847 vertex_colors : `(len(mesh.vertices), 4)`
848 Color for each vertex
849 """
850 rgba = to_rgba(face_colors)
851 vertex = mesh.faces_sparse.dot(rgba.astype(np.float64))
852 degree = mesh.vertex_degree
854 # normalize color by the number of faces including
855 # the vertex (i.e. the vertex degree)
856 nonzero = degree > 0
857 vertex[nonzero] /= degree[nonzero].reshape((-1, 1))
859 assert vertex.shape == (len(mesh.vertices), 4)
861 return vertex.astype(dtype)
864def colors_to_materials(colors: ArrayLike, count: Integer | None = None):
865 """
866 Convert a list of colors into a list of unique materials
867 and material indexes.
869 Parameters
870 -----------
871 colors : (n, 3) or (n, 4) float
872 RGB or RGBA colors
873 count : int
874 Number of entities to apply color to
876 Returns
877 -----------
878 diffuse : (m, 4) int
879 Colors
880 index : (count,) int
881 Index of each color
882 """
884 # convert RGB to RGBA
885 rgba = to_rgba(colors)
887 # if we were only passed a single color
888 if util.is_shape(rgba, (4,)) and count is not None:
889 diffuse = rgba.reshape((-1, 4))
890 index = np.zeros(count, dtype=np.int64)
891 elif util.is_shape(rgba, (-1, 4)):
892 # we were passed multiple colors
893 # find the unique colors in the list to save as materials
894 unique, index = unique_rows(rgba)
895 diffuse = rgba[unique]
896 else:
897 raise ValueError("Colors not convertible!")
899 return diffuse, index
902def linear_color_map(values: ArrayLike, color_range: ArrayLike | None = None) -> NDArray:
903 """
904 Linearly interpolate a color lookup table from normalized
905 values.
907 For example if `color_range` has two values [`a`, `b`]
908 `values` is `[0.0, 0.5, 1.0]`, this function will return
909 [`a`, `(a+b)/2`, `b`].
911 The default value for `color_range` is red-green, or you
912 can pass in a full lookup table for a color map, i.e. a
913 `(256, 3) float64` array of RGB colors such as our defaults:
914 `trimesh.resources.get_json('color_map.json.gzip')['viridis']`
916 Parameters
917 --------------
918 values : (n, ) float
919 Normalized to 0.0-1.0 values to interpolate
920 color_range : None or (n, 3|4)
921 Evenly spaced colors to interpolate through
922 where `n >= 2`.
924 Returns
925 ---------------
926 colors : (n, 4) color_range.dtype
927 RGBA colors for interpolated values
928 """
930 if color_range is None:
931 # do a very unimaginative "red to green" linear scale
932 color_range = np.array([[255, 0, 0, 255], [0, 255, 0, 255]], dtype=np.uint8)
933 else:
934 # make sure we have a numpy array
935 color_range = np.asanyarray(color_range)
937 # do simple checks on the color range shape
938 if color_range.shape[0] < 2 or color_range.shape[1] < 3:
939 raise ValueError(
940 "color_range must be RGBA convertible and have more than 2 values!"
941 )
943 # float 1D array clamped to 0.0 - 1.0
944 values = np.clip(np.asanyarray(values, dtype=np.float64).ravel(), 0.0, 1.0).reshape(
945 (-1, 1)
946 )
948 # what is the maximum index of our colors
949 max_index = len(color_range) - 1
950 # convert our normalized values into a fractional index
951 index = values.ravel() * max_index
953 # get the left and right indexes
954 # clipping should be a no-op based on above normalization but
955 # be extra sure ceil isn't pushing us out of our array range
956 bounds = np.clip(
957 np.column_stack((np.floor(index), np.ceil(index))), 0.0, max_index
958 ).astype(np.int64)
960 # get the factor of how far each point is between `bounds` pair
961 factor = index - bounds[:, 0]
963 # reshape the factor into an interpolation
964 multiplier = np.column_stack((1.0 - factor, factor)).reshape((-1, 2, 1))
966 # get both colors, multiply them by the interpolation multiplier, and sum
967 interpolated = (color_range.astype(np.float64)[bounds] * multiplier).sum(axis=1)
969 # if we're returning integers make sure to round first
970 if color_range.dtype.kind in "iu":
971 return interpolated.round().astype(color_range.dtype)
973 return interpolated.astype(color_range.dtype)
976def interpolate(
977 values: ArrayLike,
978 color_map: None | ColorMapType | Callable = None,
979 dtype: DTypeLike = np.uint8,
980) -> NDArray:
981 """
982 Given a 1D list of values, return interpolated colors
983 for the range.
985 Parameters
986 ---------------
987 values : (n, ) float
988 Values to be interpolated over
989 color_map
990 One of the four included color maps:
991 ("viridis", "inferno", "plasma", "magma")
992 Or a function, `matplotlib.pyplot.get_cmap
995 Returns
996 -------------
997 interpolated : (n, 4) dtype
998 Interpolated RGBA colors
999 """
1001 # make `viridis` the default just like everyone else
1002 if color_map is None:
1003 color_map = "viridis"
1005 if callable(color_map):
1006 # should be a `matplotlib.pyplot.get_cmap` callable
1007 cmap = color_map
1008 elif isinstance(color_map, str):
1009 # color map is a named key in our packaged color maps
1010 available = get_json("color_map.json.gzip")
1011 if color_map not in available:
1012 # we could have added a fallback to matplotlib:
1013 # `from matplotlib.pyplot import get_cmap; cmap = get_cmap(name)`
1014 # but we don't want trimesh to depend on matplotlib as it is quite heavy
1015 raise ValueError(
1016 f"Included color maps are: {available.keys()}.\n\n"
1017 + "If you want to use a `matplotlib` color map you can "
1018 + "pass it as `color_map=matplotlib.pyplot.get_cmap(name)`"
1019 )
1021 # pass in the retrieved color map values to linear_color_map
1022 def cmap(x):
1023 return linear_color_map(x, np.array(available[color_map]))
1024 else:
1025 raise TypeError(f"Unknown color map: `{type(color_map)}`")
1027 # make input always float
1028 values = np.asanyarray(values, dtype=np.float64).ravel()
1030 # get both minumium and maximum values for range normalization
1031 v_min, v_max = values.min(), values.max()
1032 # offset to zero
1033 values -= v_min
1034 # normalize to the 0.0 - 1.0 range
1035 if v_min != v_max:
1036 values /= v_max - v_min
1038 # scale values to 0.0 - 1.0 and get colors
1039 colors = cmap(values)
1041 # convert to 0-255 RGBA
1042 rgba = to_rgba(colors, dtype=dtype)
1044 return rgba
1047def uv_to_color(uv, image) -> NDArray[np.uint8]:
1048 """
1049 Get the color in a texture image.
1051 Parameters
1052 -------------
1053 uv : (n, 2) float
1054 UV coordinates on texture image
1055 image : PIL.Image
1056 Texture image
1058 Returns
1059 ----------
1060 colors : (n, 4) uint4
1061 RGBA color at each of the UV coordinates
1062 """
1063 if image is None or uv is None:
1064 return None
1066 # UV coordinates should be (n, 2) float
1067 uv = np.asanyarray(uv, dtype=np.float64)
1069 # get texture image pixel positions of UV coordinates
1070 x = (uv[:, 0] * (image.width - 1)) % image.width
1071 y = ((1 - uv[:, 1]) * (image.height - 1)) % image.height
1073 # access colors from pixel locations
1074 # make sure image is RGBA before getting values
1075 colors = np.asanyarray(image.convert("RGBA"))[
1076 y.round().astype(np.int64) % image.height,
1077 x.round().astype(np.int64) % image.width,
1078 ]
1080 # conversion to RGBA should have corrected shape
1081 assert colors.ndim == 2 and colors.shape[1] == 4
1082 assert colors.dtype == np.uint8
1084 return colors
1087def uv_to_interpolated_color(uv: ArrayLike, image) -> NDArray[np.uint8]:
1088 """
1089 Get the color from texture image using bilinear sampling.
1091 Parameters
1092 -------------
1093 uv : (n, 2) float
1094 UV coordinates on texture image
1095 image : PIL.Image
1096 Texture image
1098 Returns
1099 ----------
1100 colors : (n, 4) uint8
1101 RGBA color at each of the UV coordinates.
1102 """
1103 if image is None or uv is None:
1104 return None
1106 # UV coordinates should be (n, 2) float
1107 uv = np.asanyarray(uv, dtype=np.float64)
1109 # get texture image pixel positions of UV coordinates
1110 x = uv[:, 0] * (image.width - 1)
1111 y = (1 - uv[:, 1]) * (image.height - 1)
1113 x_floor = np.floor(x).astype(np.int64) % image.width
1114 y_floor = np.floor(y).astype(np.int64) % image.height
1116 x_ceil = np.ceil(x).astype(np.int64) % image.width
1117 y_ceil = np.ceil(y).astype(np.int64) % image.height
1119 dx = x % image.width - x_floor
1120 dy = y % image.height - y_floor
1122 img = np.asanyarray(image.convert("RGBA"))
1124 colors00 = img[y_floor, x_floor]
1125 colors01 = img[y_floor, x_ceil]
1126 colors10 = img[y_ceil, x_floor]
1127 colors11 = img[y_ceil, x_ceil]
1129 a00 = (1 - dx) * (1 - dy)
1130 a01 = dx * (1 - dy)
1131 a10 = (1 - dx) * dy
1132 a11 = dx * dy
1134 a00 = np.repeat(a00[:, None], 4, axis=1)
1135 a01 = np.repeat(a01[:, None], 4, axis=1)
1136 a10 = np.repeat(a10[:, None], 4, axis=1)
1137 a11 = np.repeat(a11[:, None], 4, axis=1)
1139 # interpolated colors as floating point then convert back to uint8
1140 colors = (
1141 (a00 * colors00 + a01 * colors01 + a10 * colors10 + a11 * colors11)
1142 .round()
1143 .astype(np.uint8)
1144 )
1146 # conversion to RGBA should have corrected shape
1147 assert colors.ndim == 2 and colors.shape[1] == 4
1148 assert colors.dtype == np.uint8
1150 return colors
1153def color_to_uv(vertex_colors: ArrayLike):
1154 """
1155 Pack vertex colors into UV coordinates and a simple image material
1157 Parameters
1158 ------------
1159 vertex_colors : (n, 4) float
1160 Array of vertex colors.
1162 Returns
1163 ------------
1164 material : SimpleMaterial
1165 Material containing color information.
1166 uv : (n, 2) float
1167 Normalized UV coordinates
1168 """
1169 from .material import SimpleMaterial, empty_material
1171 # deduplicate the vertex colors
1172 unique, inverse = unique_rows(vertex_colors)
1174 # if there is only one color return a
1175 if len(unique) == 1:
1176 # return a simple single-pixel material
1177 material = empty_material(color=vertex_colors[unique[0]])
1178 uvs = np.zeros((len(vertex_colors), 2)) + 0.5
1179 return material, uvs
1181 from PIL import Image
1183 # return a square image of (size, size)
1184 size = int(np.ceil(np.sqrt(len(unique))))
1185 ctype = vertex_colors.shape[1]
1187 colors = np.zeros((size**2, ctype), dtype=vertex_colors.dtype)
1188 colors[: len(unique)] = vertex_colors[unique]
1190 # PIL has reversed x-y coordinates
1191 image = Image.fromarray(colors.reshape((size, size, ctype))[::-1])
1193 pos = np.arange(len(unique))
1194 # create tiled coordinates for the color pixels
1195 coords = np.column_stack((pos % size, np.floor(pos / size)))
1197 # normalize the index coords into 0.0 - 1.0
1198 # and offset them to be centered on the pixel
1199 coords = (coords / size) + (1.0 / (size * 2.0))
1200 uvs = coords[inverse]
1202 if tol.strict:
1203 # check the packed colors against the image
1204 check = uv_to_color(image=image, uv=uvs)
1205 assert np.all(check == vertex_colors)
1207 return SimpleMaterial(image=image), uvs
1210# set an arbitrary grey as the default color
1211DEFAULT_COLOR = np.array([102, 102, 102, 255], dtype=np.uint8)
1212DEFAULT_MAT = {
1213 "material_diffuse": np.array([102, 102, 102, 255], dtype=np.uint8),
1214 "material_ambient": np.array([64, 64, 64, 255], dtype=np.uint8),
1215 "material_specular": np.array([197, 197, 197, 255], dtype=np.uint8),
1216 "material_shine": 77.0,
1217}