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

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) 

43from .base import Visuals 

44 

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 

52 

53 

54class ColorVisuals(Visuals): 

55 """ 

56 Store color information about a mesh. 

57 """ 

58 

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. 

67 

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

81 

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

89 

90 @caching.cache_decorator 

91 def transparency(self) -> bool: 

92 """ 

93 Does the current object contain any transparency. 

94 

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 

105 

106 return bool(a_min < 255) 

107 

108 @property 

109 def defined(self) -> bool: 

110 """ 

111 Are any colors defined for the current mesh. 

112 

113 Returns 

114 --------- 

115 defined : bool 

116 Are colors defined or not. 

117 """ 

118 return self.kind is not None 

119 

120 @property 

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

122 """ 

123 What color mode has been set. 

124 

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 

133 

134 self._verify_hash() 

135 

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" 

141 

142 return None 

143 

144 def __hash__(self): 

145 return self._data.__hash__() 

146 

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

148 """ 

149 Return a copy of the current ColorVisuals object. 

150 

151 

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) 

163 

164 return copied 

165 

166 @property 

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

168 """ 

169 Colors defined for each face of a mesh. 

170 

171 If no colors are defined, defaults are returned. 

172 

173 Returns 

174 ---------- 

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

176 RGBA color for each face 

177 """ 

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

179 

180 @face_colors.setter 

181 def face_colors(self, values: ArrayLike): 

182 """ 

183 Set the colors for each face of a mesh. 

184 

185 This will apply these colors and delete any previously specified 

186 color information. 

187 

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 

199 

200 colors = to_rgba(values) 

201 

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

203 count = len(self.mesh.faces) 

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

205 

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

207 self._data.clear() 

208 self._data["face_colors"] = colors 

209 self._cache.verify() 

210 

211 @property 

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

213 """ 

214 Return the colors for each vertex of a mesh 

215 

216 Returns 

217 ------------ 

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

219 """ 

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

221 

222 @vertex_colors.setter 

223 def vertex_colors(self, values: ArrayLike): 

224 """ 

225 Set the colors for each vertex of a mesh 

226 

227 This will apply these colors and delete any previously specified 

228 color information. 

229 

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 

241 

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 

252 

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

257 

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

259 self._data.clear() 

260 self._data["vertex_colors"] = colors 

261 self._cache.verify() 

262 

263 def _get_colors(self, name): 

264 """ 

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

266 

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. 

276 

277 Parameters 

278 ----------- 

279 name : str 

280 Values 'face' or 'vertex' 

281 

282 Returns 

283 ----------- 

284 colors : (count, 4) uint8 

285 RGBA colors 

286 """ 

287 

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 

296 

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" 

301 

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] 

306 

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

343 

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

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

346 

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) 

352 

353 return colors 

354 

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. 

360 

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 

367 

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" 

373 

374 if key_colors not in self._cache: 

375 continue 

376 

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

388 

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

394 

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

400 

401 def face_subset(self, face_index: ArrayLike): 

402 """ 

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

404 

405 Parameters 

406 ---------- 

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

408 (n,) bool, mask for faces 

409 

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

418 

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) 

423 

424 result = ColorVisuals(**kwargs) 

425 

426 return result 

427 

428 @property 

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

430 """ 

431 What is the most commonly occurring color. 

432 

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

445 

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

452 

453 return color 

454 

455 def to_texture(self): 

456 """ 

457 Convert the current ColorVisuals object to a texture 

458 with a `SimpleMaterial` defined. 

459 

460 Returns 

461 ------------ 

462 visual : trimesh.visual.TextureVisuals 

463 Copy of the current visuals as a texture. 

464 """ 

465 from .texture import TextureVisuals 

466 

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

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

469 

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

471 """ 

472 Concatenate two or more ColorVisuals objects 

473 into a single object. 

474 

475 Parameters 

476 ----------- 

477 other : ColorVisuals 

478 Object to append 

479 *args: ColorVisuals objects 

480 

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 

489 

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

491 return result 

492 

493 def _update_key(self, mask, key): 

494 """ 

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

496 

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] 

506 

507 

508class VertexColor(Visuals): 

509 """ 

510 Create a simple visual object to hold just vertex colors 

511 for objects such as PointClouds. 

512 """ 

513 

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 

520 

521 @property 

522 def kind(self): 

523 return "vertex" 

524 

525 def update_vertices(self, mask): 

526 if self._colors is not None: 

527 self._colors = self._colors[mask] 

528 

529 def update_faces(self, mask): 

530 pass 

531 

532 @property 

533 def vertex_colors(self): 

534 return self._colors 

535 

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

547 

548 def copy(self): 

549 """ 

550 Return a copy of the current visuals 

551 """ 

552 return copy.deepcopy(self) 

553 

554 def concatenate(self, other): 

555 """ 

556 Concatenate this visual object with another 

557 VertexVisuals. 

558 

559 Parameters 

560 ----------- 

561 other : VertexColors or ColorVisuals 

562 Other object to concatenate 

563 

564 Returns 

565 ------------ 

566 concate : VertexColor 

567 Object with both colors 

568 """ 

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

570 

571 def __hash__(self): 

572 return self._colors.__hash__() 

573 

574 

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

576 """ 

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

578 

579 Parameters 

580 ---------- 

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

582 RGB or RGBA colors or None 

583 

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 

594 

595 # colors as numpy array 

596 colors = np.asanyarray(colors) 

597 dtype = np.dtype(dtype) 

598 

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

606 

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

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

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

610 

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) 

614 

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

620 

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

629 

630 return colors.astype(dtype) 

631 

632 

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

634 """ 

635 Convert integer colors to 0.0-1.0 floating point colors 

636 

637 Parameters 

638 ------------- 

639 colors : (n, d) int 

640 Integer colors 

641 

642 Returns 

643 ------------- 

644 as_float : (n, d) float 

645 Float colors 0.0 - 1.0 

646 """ 

647 

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

658 

659 

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

661 """ 

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

663 

664 Parameters 

665 ----------- 

666 color: str, hex color 

667 

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

678 

679 return rgba 

680 

681 

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. 

686 

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

688 

689 Parameters 

690 ----------- 

691 hsv 

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

693 

694 Returns 

695 ------------ 

696 rgba 

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

698 """ 

699 

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) 

705 

706 # expand into flat arrays for each of 

707 # hue, saturation, and value 

708 H, S, V = hsv.T 

709 

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

714 

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

719 

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] 

723 

724 # stack it into the final RGBA array 

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

726 

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) 

733 

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

735 

736 

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

738 """ 

739 Converts linear color values to sRGB color values. 

740 

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

742 

743 Parameters 

744 ---------- 

745 linear 

746 Linear color values of any shape since this 

747 is a per-element transformation 

748 

749 Returns 

750 --------- 

751 srgb 

752 Values scaled to an sRGB scale. 

753 """ 

754 linear = to_float(linear) 

755 

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] 

760 

761 return srgb 

762 

763 

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

769 

770 # make sure the color values are floating point scaled 

771 srgb = to_float(srgb) 

772 

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) 

777 

778 return linear 

779 

780 

781def random_color(dtype: DTypeLike = np.uint8, count: Integer | None = None) -> NDArray: 

782 """ 

783 Return a random RGB color using datatype specified. 

784 

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. 

792 

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 

800 

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

805 

806 # unspecified count is a single color 

807 if count is None: 

808 return colors[0] 

809 return colors 

810 

811 

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

813 """ 

814 Convert a list of vertex colors to face colors. 

815 

816 Parameters 

817 ---------- 

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

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

820 

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) 

828 

829 

830def face_to_vertex_color( 

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

832) -> NDArray: 

833 """ 

834 Convert face colors into vertex colors. 

835 

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. 

844 

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 

853 

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

858 

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

860 

861 return vertex.astype(dtype) 

862 

863 

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. 

868 

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 

875 

876 Returns 

877 ----------- 

878 diffuse : (m, 4) int 

879 Colors 

880 index : (count,) int 

881 Index of each color 

882 """ 

883 

884 # convert RGB to RGBA 

885 rgba = to_rgba(colors) 

886 

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

898 

899 return diffuse, index 

900 

901 

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

903 """ 

904 Linearly interpolate a color lookup table from normalized 

905 values. 

906 

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

910 

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

915 

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

923 

924 Returns 

925 --------------- 

926 colors : (n, 4) color_range.dtype 

927 RGBA colors for interpolated values 

928 """ 

929 

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) 

936 

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 ) 

942 

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 ) 

947 

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 

952 

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) 

959 

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

961 factor = index - bounds[:, 0] 

962 

963 # reshape the factor into an interpolation 

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

965 

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

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

968 

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) 

972 

973 return interpolated.astype(color_range.dtype) 

974 

975 

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. 

984 

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 

993 

994 

995 Returns 

996 ------------- 

997 interpolated : (n, 4) dtype 

998 Interpolated RGBA colors 

999 """ 

1000 

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

1002 if color_map is None: 

1003 color_map = "viridis" 

1004 

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 ) 

1020 

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

1026 

1027 # make input always float 

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

1029 

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 

1037 

1038 # scale values to 0.0 - 1.0 and get colors 

1039 colors = cmap(values) 

1040 

1041 # convert to 0-255 RGBA 

1042 rgba = to_rgba(colors, dtype=dtype) 

1043 

1044 return rgba 

1045 

1046 

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

1048 """ 

1049 Get the color in a texture image. 

1050 

1051 Parameters 

1052 ------------- 

1053 uv : (n, 2) float 

1054 UV coordinates on texture image 

1055 image : PIL.Image 

1056 Texture image 

1057 

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 

1065 

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

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

1068 

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 

1072 

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 ] 

1079 

1080 # conversion to RGBA should have corrected shape 

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

1082 assert colors.dtype == np.uint8 

1083 

1084 return colors 

1085 

1086 

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

1088 """ 

1089 Get the color from texture image using bilinear sampling. 

1090 

1091 Parameters 

1092 ------------- 

1093 uv : (n, 2) float 

1094 UV coordinates on texture image 

1095 image : PIL.Image 

1096 Texture image 

1097 

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 

1105 

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

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

1108 

1109 # get texture image pixel positions of UV coordinates 

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

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

1112 

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

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

1115 

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

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

1118 

1119 dx = x % image.width - x_floor 

1120 dy = y % image.height - y_floor 

1121 

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

1123 

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] 

1128 

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

1130 a01 = dx * (1 - dy) 

1131 a10 = (1 - dx) * dy 

1132 a11 = dx * dy 

1133 

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) 

1138 

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 ) 

1145 

1146 # conversion to RGBA should have corrected shape 

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

1148 assert colors.dtype == np.uint8 

1149 

1150 return colors 

1151 

1152 

1153def color_to_uv(vertex_colors: ArrayLike): 

1154 """ 

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

1156 

1157 Parameters 

1158 ------------ 

1159 vertex_colors : (n, 4) float 

1160 Array of vertex colors. 

1161 

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 

1170 

1171 # deduplicate the vertex colors 

1172 unique, inverse = unique_rows(vertex_colors) 

1173 

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 

1180 

1181 from PIL import Image 

1182 

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

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

1185 ctype = vertex_colors.shape[1] 

1186 

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

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

1189 

1190 # PIL has reversed x-y coordinates 

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

1192 

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

1196 

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] 

1201 

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) 

1206 

1207 return SimpleMaterial(image=image), uvs 

1208 

1209 

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}