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

1""" 

2color.py 

3------------- 

4 

5Hold and deal with visual information about meshes. 

6 

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: 

10 

111) colors, defined for a face, vertex, or material 

122) textures, defined as an image and UV coordinates for each vertex 

13 

14This module only implements diffuse colors at the moment. 

15 

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""" 

24 

25import copy 

26from typing import Any 

27 

28import numpy as np 

29 

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 

45 

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 

53 

54 

55class ColorVisuals(Visuals): 

56 """ 

57 Store color information about a mesh. 

58 """ 

59 

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. 

68 

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__) 

82 

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!") 

90 

91 @caching.cache_decorator 

92 def transparency(self) -> bool: 

93 """ 

94 Does the current object contain any transparency. 

95 

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 

106 

107 return bool(a_min < 255) 

108 

109 @property 

110 def defined(self) -> bool: 

111 """ 

112 Are any colors defined for the current mesh. 

113 

114 Returns 

115 --------- 

116 defined : bool 

117 Are colors defined or not. 

118 """ 

119 return self.kind is not None 

120 

121 @property 

122 def kind(self) -> str | None: 

123 """ 

124 What color mode has been set. 

125 

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 

134 

135 self._verify_hash() 

136 

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" 

142 

143 return None 

144 

145 def __hash__(self): 

146 return self._data.__hash__() 

147 

148 def copy(self) -> "ColorVisuals": 

149 """ 

150 Return a copy of the current ColorVisuals object. 

151 

152 

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) 

164 

165 return copied 

166 

167 @property 

168 def face_colors(self) -> NDArray[np.uint8]: 

169 """ 

170 Colors defined for each face of a mesh. 

171 

172 If no colors are defined, defaults are returned. 

173 

174 Returns 

175 ---------- 

176 colors : (len(mesh.faces), 4) uint8 

177 RGBA color for each face 

178 """ 

179 return self._get_colors(name="face") 

180 

181 @face_colors.setter 

182 def face_colors(self, values: ArrayLike): 

183 """ 

184 Set the colors for each face of a mesh. 

185 

186 This will apply these colors and delete any previously specified 

187 color information. 

188 

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 

200 

201 colors = to_rgba(values) 

202 

203 if self.mesh is not None and colors.shape == (4,): 

204 count = len(self.mesh.faces) 

205 colors = np.tile(colors, (count, 1)) 

206 

207 # if we set any color information, clear the others 

208 self._data.clear() 

209 self._data["face_colors"] = colors 

210 self._cache.verify() 

211 

212 @property 

213 def vertex_colors(self) -> NDArray[np.uint8]: 

214 """ 

215 Return the colors for each vertex of a mesh 

216 

217 Returns 

218 ------------ 

219 colors: (len(mesh.vertices), 4) uint8, color for each vertex 

220 """ 

221 return self._get_colors(name="vertex") 

222 

223 @vertex_colors.setter 

224 def vertex_colors(self, values: ArrayLike): 

225 """ 

226 Set the colors for each vertex of a mesh 

227 

228 This will apply these colors and delete any previously specified 

229 color information. 

230 

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 

242 

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 

253 

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)) 

258 

259 # if we set any color information, clear the others 

260 self._data.clear() 

261 self._data["vertex_colors"] = colors 

262 self._cache.verify() 

263 

264 def _get_colors(self, name): 

265 """ 

266 A magical function which maintains the sanity of vertex and face colors. 

267 

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. 

277 

278 Parameters 

279 ----------- 

280 name : str 

281 Values 'face' or 'vertex' 

282 

283 Returns 

284 ----------- 

285 colors : (count, 4) uint8 

286 RGBA colors 

287 """ 

288 

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 

297 

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" 

302 

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] 

307 

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!!") 

344 

345 if count is not None and colors.shape != (count, 4): 

346 raise ValueError("face colors incorrect shape!") 

347 

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) 

353 

354 return colors 

355 

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. 

361 

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 

368 

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" 

374 

375 if key_colors not in self._cache: 

376 continue 

377 

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() 

389 

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") 

395 

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") 

401 

402 def face_subset(self, face_index: ArrayLike): 

403 """ 

404 Given a mask of face indices, return a sliced version. 

405 

406 Parameters 

407 ---------- 

408 face_index: (n,) int, mask for faces 

409 (n,) bool, mask for faces 

410 

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]) 

419 

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) 

424 

425 result = ColorVisuals(**kwargs) 

426 

427 return result 

428 

429 @property 

430 def main_color(self) -> NDArray[np.uint8]: 

431 """ 

432 What is the most commonly occurring color. 

433 

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!") 

446 

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]] 

453 

454 return color 

455 

456 def to_texture(self): 

457 """ 

458 Convert the current ColorVisuals object to a texture 

459 with a `SimpleMaterial` defined. 

460 

461 Returns 

462 ------------ 

463 visual : trimesh.visual.TextureVisuals 

464 Copy of the current visuals as a texture. 

465 """ 

466 from .texture import TextureVisuals 

467 

468 mat, uv = color_to_uv(vertex_colors=self.vertex_colors) 

469 return TextureVisuals(material=mat, uv=uv) 

470 

471 def concatenate(self, other: Iterable[Visuals] | Visuals | ArrayLike, *args): 

472 """ 

473 Concatenate two or more ColorVisuals objects 

474 into a single object. 

475 

476 Parameters 

477 ----------- 

478 other : ColorVisuals 

479 Object to append 

480 *args: ColorVisuals objects 

481 

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 

490 

491 result = objects.concatenate(self, other, *args) 

492 return result 

493 

494 def _update_key(self, mask, key): 

495 """ 

496 Mask the value contained in the DataStore at a specified key. 

497 

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] 

507 

508 

509class VertexColor(Visuals): 

510 """ 

511 Create a simple visual object to hold just vertex colors 

512 for objects such as PointClouds. 

513 """ 

514 

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 

521 

522 @property 

523 def kind(self): 

524 return "vertex" 

525 

526 def update_vertices(self, mask): 

527 if self._colors is not None: 

528 self._colors = self._colors[mask] 

529 

530 def update_faces(self, mask): 

531 pass 

532 

533 @property 

534 def vertex_colors(self): 

535 return self._colors 

536 

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)) 

548 

549 def copy(self): 

550 """ 

551 Return a copy of the current visuals 

552 """ 

553 return copy.deepcopy(self) 

554 

555 def concatenate(self, other): 

556 """ 

557 Concatenate this visual object with another 

558 VertexVisuals. 

559 

560 Parameters 

561 ----------- 

562 other : VertexColors or ColorVisuals 

563 Other object to concatenate 

564 

565 Returns 

566 ------------ 

567 concate : VertexColor 

568 Object with both colors 

569 """ 

570 return VertexColor(colors=np.vstack(self.vertex_colors, other.vertex_colors)) 

571 

572 def __hash__(self): 

573 return self._colors.__hash__() 

574 

575 

576def to_rgba(colors: Any, dtype: DTypeLike = np.uint8) -> NDArray: 

577 """ 

578 Convert a single or multiple RGB colors to RGBA colors. 

579 

580 Parameters 

581 ---------- 

582 colors : (n, 3) or (n, 4) array 

583 RGB or RGBA colors or None 

584 

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 

595 

596 # colors as numpy array 

597 colors = np.asanyarray(colors) 

598 dtype = np.dtype(dtype) 

599 

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}`") 

607 

608 if colors.dtype.kind == "f": 

609 # replace any `nan` or `inf` values with zero 

610 colors[~np.isfinite(colors)] = 0.0 

611 

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) 

615 

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() 

621 

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!") 

630 

631 return colors.astype(dtype) 

632 

633 

634def to_float(colors: ArrayLike) -> NDArray[np.float64]: 

635 """ 

636 Convert integer colors to 0.0-1.0 floating point colors 

637 

638 Parameters 

639 ------------- 

640 colors : (n, d) int 

641 Integer colors 

642 

643 Returns 

644 ------------- 

645 as_float : (n, d) float 

646 Float colors 0.0 - 1.0 

647 """ 

648 

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!") 

659 

660 

661def hex_to_rgba(color: str) -> NDArray[np.uint8]: 

662 """ 

663 Turn a string hex color to a (4,) RGBA color. 

664 

665 Parameters 

666 ----------- 

667 color: str, hex color 

668 

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") 

679 

680 return rgba 

681 

682 

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. 

687 

688 A vectorized implementation that matches `colorsys.hsv_to_rgb`. 

689 

690 Parameters 

691 ----------- 

692 hsv 

693 Should be `(n, 3)` array of 0.0-1.0 values. 

694 

695 Returns 

696 ------------ 

697 rgba 

698 An (n, 4) array of RGBA colors. 

699 """ 

700 

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) 

706 

707 # expand into flat arrays for each of 

708 # hue, saturation, and value 

709 H, S, V = hsv.T 

710 

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)) 

715 

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)) 

720 

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] 

724 

725 # stack it into the final RGBA array 

726 RGBA = np.column_stack((RGBi + (V - C).reshape((-1, 1)), np.ones(len(H)))) 

727 

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) 

734 

735 raise ValueError(f"dtype `{dtype}` not supported") 

736 

737 

738def linear_to_srgb(linear: ArrayLike) -> NDArray[np.float64]: 

739 """ 

740 Converts linear color values to sRGB color values. 

741 

742 See: https://entropymine.com/imageworsener/srgbformula/ 

743 

744 Parameters 

745 ---------- 

746 linear 

747 Linear color values of any shape since this 

748 is a per-element transformation 

749 

750 Returns 

751 --------- 

752 srgb 

753 Values scaled to an sRGB scale. 

754 """ 

755 linear = to_float(linear) 

756 

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] 

761 

762 return srgb 

763 

764 

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 """ 

770 

771 # make sure the color values are floating point scaled 

772 srgb = to_float(srgb) 

773 

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) 

778 

779 return linear 

780 

781 

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. 

789 

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. 

799 

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 

807 

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) 

812 

813 # unspecified count is a single color 

814 if count is None: 

815 return colors[0] 

816 return colors 

817 

818 

819def vertex_to_face_color(vertex_colors: ArrayLike, faces: ArrayLike) -> NDArray[np.uint8]: 

820 """ 

821 Convert a list of vertex colors to face colors. 

822 

823 Parameters 

824 ---------- 

825 vertex_colors: (n,(3,4)), colors 

826 faces: (m,3) int, face indexes 

827 

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) 

835 

836 

837def face_to_vertex_color( 

838 mesh, face_colors: ArrayLike, dtype: DTypeLike = np.uint8 

839) -> NDArray: 

840 """ 

841 Convert face colors into vertex colors. 

842 

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. 

851 

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 

860 

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)) 

865 

866 assert vertex.shape == (len(mesh.vertices), 4) 

867 

868 return vertex.astype(dtype) 

869 

870 

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. 

875 

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 

882 

883 Returns 

884 ----------- 

885 diffuse : (m, 4) int 

886 Colors 

887 index : (count,) int 

888 Index of each color 

889 """ 

890 

891 # convert RGB to RGBA 

892 rgba = to_rgba(colors) 

893 

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!") 

905 

906 return diffuse, index 

907 

908 

909def linear_color_map(values: ArrayLike, color_range: ArrayLike | None = None) -> NDArray: 

910 """ 

911 Linearly interpolate a color lookup table from normalized 

912 values. 

913 

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`]. 

917 

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']` 

922 

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`. 

930 

931 Returns 

932 --------------- 

933 colors : (n, 4) color_range.dtype 

934 RGBA colors for interpolated values 

935 """ 

936 

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) 

943 

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 ) 

949 

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 ) 

954 

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 

959 

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) 

966 

967 # get the factor of how far each point is between `bounds` pair 

968 factor = index - bounds[:, 0] 

969 

970 # reshape the factor into an interpolation 

971 multiplier = np.column_stack((1.0 - factor, factor)).reshape((-1, 2, 1)) 

972 

973 # get both colors, multiply them by the interpolation multiplier, and sum 

974 interpolated = (color_range.astype(np.float64)[bounds] * multiplier).sum(axis=1) 

975 

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) 

979 

980 return interpolated.astype(color_range.dtype) 

981 

982 

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. 

991 

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 

1000 

1001 

1002 Returns 

1003 ------------- 

1004 interpolated : (n, 4) dtype 

1005 Interpolated RGBA colors 

1006 """ 

1007 

1008 # make `viridis` the default just like everyone else 

1009 if color_map is None: 

1010 color_map = "viridis" 

1011 

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 ) 

1027 

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)}`") 

1033 

1034 # make input always float 

1035 values = np.asanyarray(values, dtype=np.float64).ravel() 

1036 

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 

1044 

1045 # scale values to 0.0 - 1.0 and get colors 

1046 colors = cmap(values) 

1047 

1048 # convert to 0-255 RGBA 

1049 rgba = to_rgba(colors, dtype=dtype) 

1050 

1051 return rgba 

1052 

1053 

1054def uv_to_color(uv, image) -> NDArray[np.uint8]: 

1055 """ 

1056 Get the color in a texture image. 

1057 

1058 Parameters 

1059 ------------- 

1060 uv : (n, 2) float 

1061 UV coordinates on texture image 

1062 image : PIL.Image 

1063 Texture image 

1064 

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 

1072 

1073 # UV coordinates should be (n, 2) float 

1074 uv = np.asanyarray(uv, dtype=np.float64) 

1075 

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 

1079 

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 ] 

1086 

1087 # conversion to RGBA should have corrected shape 

1088 assert colors.ndim == 2 and colors.shape[1] == 4 

1089 assert colors.dtype == np.uint8 

1090 

1091 return colors 

1092 

1093 

1094def uv_to_interpolated_color(uv: ArrayLike, image) -> NDArray[np.uint8]: 

1095 """ 

1096 Get the color from texture image using bilinear sampling. 

1097 

1098 Parameters 

1099 ------------- 

1100 uv : (n, 2) float 

1101 UV coordinates on texture image 

1102 image : PIL.Image 

1103 Texture image 

1104 

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 

1112 

1113 # UV coordinates should be (n, 2) float 

1114 uv = np.asanyarray(uv, dtype=np.float64) 

1115 

1116 # get texture image pixel positions of UV coordinates 

1117 x = uv[:, 0] * (image.width - 1) 

1118 y = (1 - uv[:, 1]) * (image.height - 1) 

1119 

1120 x_floor = np.floor(x).astype(np.int64) % image.width 

1121 y_floor = np.floor(y).astype(np.int64) % image.height 

1122 

1123 x_ceil = np.ceil(x).astype(np.int64) % image.width 

1124 y_ceil = np.ceil(y).astype(np.int64) % image.height 

1125 

1126 dx = x % image.width - x_floor 

1127 dy = y % image.height - y_floor 

1128 

1129 img = np.asanyarray(image.convert("RGBA")) 

1130 

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] 

1135 

1136 a00 = (1 - dx) * (1 - dy) 

1137 a01 = dx * (1 - dy) 

1138 a10 = (1 - dx) * dy 

1139 a11 = dx * dy 

1140 

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) 

1145 

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 ) 

1152 

1153 # conversion to RGBA should have corrected shape 

1154 assert colors.ndim == 2 and colors.shape[1] == 4 

1155 assert colors.dtype == np.uint8 

1156 

1157 return colors 

1158 

1159 

1160def color_to_uv(vertex_colors: ArrayLike): 

1161 """ 

1162 Pack vertex colors into UV coordinates and a simple image material 

1163 

1164 Parameters 

1165 ------------ 

1166 vertex_colors : (n, 4) float 

1167 Array of vertex colors. 

1168 

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 

1177 

1178 # deduplicate the vertex colors 

1179 unique, inverse = unique_rows(vertex_colors) 

1180 

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 

1187 

1188 from PIL import Image 

1189 

1190 # return a square image of (size, size) 

1191 size = int(np.ceil(np.sqrt(len(unique)))) 

1192 ctype = vertex_colors.shape[1] 

1193 

1194 colors = np.zeros((size**2, ctype), dtype=vertex_colors.dtype) 

1195 colors[: len(unique)] = vertex_colors[unique] 

1196 

1197 # PIL has reversed x-y coordinates 

1198 image = Image.fromarray(colors.reshape((size, size, ctype))[::-1]) 

1199 

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))) 

1203 

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] 

1208 

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) 

1213 

1214 return SimpleMaterial(image=image), uvs 

1215 

1216 

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}