Coverage for trimesh/voxel/base.py: 82%

170 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-24 04:40 +0000

1""" 

2voxel.py 

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

4 

5Convert meshes to a simple voxel data structure and back again. 

6""" 

7 

8from hashlib import sha256 

9 

10import numpy as np 

11 

12from .. import bounds as bounds_module 

13from .. import caching, util 

14from .. import transformations as tr 

15from ..constants import log 

16from ..exchange.binvox import export_binvox 

17from ..parent import Geometry 

18from . import morphology, ops, transforms 

19from .encoding import DenseEncoding, Encoding 

20 

21 

22class VoxelGrid(Geometry): 

23 """ 

24 Store 3D voxels. 

25 """ 

26 

27 def __init__(self, encoding, transform=None, metadata=None): 

28 if transform is None: 

29 transform = np.eye(4) 

30 if isinstance(encoding, np.ndarray): 

31 encoding = DenseEncoding(encoding.astype(bool)) 

32 if encoding.dtype != bool: 

33 raise ValueError("encoding must have dtype bool") 

34 self._data = caching.DataStore() 

35 self.encoding = encoding 

36 self._transform = transforms.Transform(transform, datastore=self._data) 

37 self._cache = caching.Cache(id_function=self._data.__hash__) 

38 

39 self.metadata = {} 

40 # update the mesh metadata with passed metadata 

41 if isinstance(metadata, dict): 

42 self.metadata.update(metadata) 

43 elif metadata is not None: 

44 raise ValueError(f"metadata should be a dict or None, got {metadata!s}") 

45 

46 def __hash__(self): 

47 """ 

48 Get the hash of the current transformation matrix. 

49 

50 Returns 

51 ------------ 

52 hash : str 

53 Hash of transformation matrix 

54 """ 

55 return self._data.__hash__() 

56 

57 @property 

58 def identifier_hash(self) -> str: 

59 return sha256(hash(self).to_bytes()).hexdigest() 

60 

61 @property 

62 def encoding(self): 

63 """ 

64 `Encoding` object providing the occupancy grid. 

65 

66 See `trimesh.voxel.encoding` for implementations. 

67 """ 

68 return self._data["encoding"] 

69 

70 @encoding.setter 

71 def encoding(self, encoding): 

72 if isinstance(encoding, np.ndarray): 

73 encoding = DenseEncoding(encoding) 

74 elif not isinstance(encoding, Encoding): 

75 raise ValueError(f"encoding must be an Encoding, got {encoding!s}") 

76 if len(encoding.shape) != 3: 

77 raise ValueError(f"encoding must be rank 3, got shape {encoding.shape!s}") 

78 if encoding.dtype != bool: 

79 raise ValueError(f"encoding must be binary, got {encoding.dtype}") 

80 self._data["encoding"] = encoding 

81 

82 @property 

83 def transform(self): 

84 """4x4 homogeneous transformation matrix.""" 

85 return self._transform.matrix 

86 

87 @transform.setter 

88 def transform(self, matrix): 

89 """4x4 homogeneous transformation matrix.""" 

90 self._transform.matrix = matrix 

91 

92 @property 

93 def translation(self): 

94 """Location of voxel at [0, 0, 0].""" 

95 return self._transform.translation 

96 

97 @property 

98 def scale(self): 

99 """ 

100 3-element float representing per-axis scale. 

101 

102 Raises a `RuntimeError` if `self.transform` has rotation or 

103 shear components. 

104 """ 

105 return self._transform.scale 

106 

107 @property 

108 def pitch(self): 

109 """ 

110 Uniform scaling factor representing the side length of 

111 each voxel. 

112 

113 Returns 

114 ----------- 

115 pitch : float 

116 Pitch of the voxels. 

117 

118 Raises 

119 ------------ 

120 `RuntimeError` 

121 If `self.transformation` has rotation or shear 

122 components of has non-uniform scaling. 

123 """ 

124 return self._transform.pitch 

125 

126 @property 

127 def element_volume(self): 

128 return self._transform.unit_volume 

129 

130 def apply_transform(self, matrix): 

131 self._transform.apply_transform(matrix) 

132 return self 

133 

134 def strip(self): 

135 """ 

136 Mutate self by stripping leading/trailing planes of zeros. 

137 

138 Returns 

139 -------- 

140 self after mutation occurs in-place 

141 """ 

142 encoding, padding = self.encoding.stripped 

143 self.encoding = encoding 

144 self._transform.matrix[:3, 3] = self.indices_to_points(padding[:, 0]) 

145 return self 

146 

147 @caching.cache_decorator 

148 def bounds(self): 

149 indices = self.sparse_indices 

150 # get all 8 corners of the AABB 

151 corners = bounds_module.corners( 

152 [indices.min(axis=0) - 0.5, indices.max(axis=0) + 0.5] 

153 ) 

154 # transform these corners to a new frame 

155 corners = self._transform.transform_points(corners) 

156 # get the AABB of corners in-frame 

157 bounds = np.array([corners.min(axis=0), corners.max(axis=0)]) 

158 bounds.flags.writeable = False 

159 return bounds 

160 

161 @caching.cache_decorator 

162 def extents(self): 

163 bounds = self.bounds 

164 extents = bounds[1] - bounds[0] 

165 extents.flags.writeable = False 

166 return extents 

167 

168 @caching.cache_decorator 

169 def is_empty(self): 

170 return self.encoding.is_empty 

171 

172 @property 

173 def shape(self): 

174 """3-tuple of ints denoting shape of occupancy grid.""" 

175 return self.encoding.shape 

176 

177 @caching.cache_decorator 

178 def filled_count(self): 

179 """int, number of occupied voxels in the grid.""" 

180 return self.encoding.sum.item() 

181 

182 def is_filled(self, point): 

183 """ 

184 Query points to see if the voxel cells they lie in are 

185 filled or not. 

186 

187 Parameters 

188 ---------- 

189 point : (n, 3) float 

190 Points in space 

191 

192 Returns 

193 --------- 

194 is_filled : (n,) bool 

195 Is cell occupied or not for each point 

196 """ 

197 point = np.asanyarray(point) 

198 indices = self.points_to_indices(point) 

199 in_range = np.logical_and( 

200 np.all(indices < np.array(self.shape), axis=-1), np.all(indices >= 0, axis=-1) 

201 ) 

202 

203 is_filled = np.zeros_like(in_range) 

204 is_filled[in_range] = self.encoding.gather_nd(indices[in_range]) 

205 return is_filled 

206 

207 def fill(self, method="holes", **kwargs): 

208 """ 

209 Mutates self by filling in the encoding according 

210 to `morphology.fill`. 

211 

212 Parameters 

213 ---------- 

214 method : hashable 

215 Implementation key, one of 

216 `trimesh.voxel.morphology.fill.fillers` keys 

217 **kwargs : dict 

218 Additional kwargs passed through to 

219 the keyed implementation. 

220 

221 Returns 

222 ---------- 

223 self : VoxelGrid 

224 After replacing encoding with a filled version. 

225 """ 

226 self.encoding = morphology.fill(self.encoding, method=method, **kwargs) 

227 return self 

228 

229 def hollow(self): 

230 """ 

231 Mutates self by removing internal voxels 

232 leaving only surface elements. 

233 

234 Surviving elements are those in encoding that are 

235 adjacent to an empty voxel where adjacency is 

236 controlled by `structure`. 

237 

238 Returns 

239 ---------- 

240 self : VoxelGrid 

241 After replacing encoding with a surface version. 

242 """ 

243 self.encoding = morphology.surface(self.encoding) 

244 return self 

245 

246 @caching.cache_decorator 

247 def marching_cubes(self): 

248 """ 

249 A marching cubes Trimesh representation of the voxels. 

250 

251 No effort was made to clean or smooth the result in any way; 

252 it is merely the result of applying the scikit-image 

253 measure.marching_cubes function to self.encoding.dense. 

254 

255 Returns 

256 --------- 

257 meshed : trimesh.Trimesh 

258 Representing the current voxel 

259 object as returned by marching cubes algorithm. 

260 """ 

261 return ops.matrix_to_marching_cubes(matrix=self.matrix) 

262 

263 @property 

264 def matrix(self): 

265 """ 

266 Return a DENSE matrix of the current voxel encoding. 

267 

268 Returns 

269 ------------- 

270 dense : (a, b, c) bool 

271 Numpy array of dense matrix 

272 Shortcut to voxel.encoding.dense 

273 """ 

274 return self.encoding.dense 

275 

276 @caching.cache_decorator 

277 def volume(self): 

278 """ 

279 What is the volume of the filled cells in the current 

280 voxel object. 

281 

282 Returns 

283 --------- 

284 volume : float 

285 Volume of filled cells. 

286 """ 

287 return self.filled_count * self.element_volume 

288 

289 @caching.cache_decorator 

290 def points(self): 

291 """ 

292 The center of each filled cell as a list of points. 

293 

294 Returns 

295 ---------- 

296 points : (self.filled, 3) float 

297 Points in space. 

298 """ 

299 return self._transform.transform_points(self.sparse_indices.astype(float)) 

300 

301 @property 

302 def sparse_indices(self): 

303 """(n, 3) int array of sparse indices of occupied voxels.""" 

304 return self.encoding.sparse_indices 

305 

306 def as_boxes(self, colors=None, **kwargs): 

307 """ 

308 A rough Trimesh representation of the voxels with a box 

309 for each filled voxel. 

310 

311 Parameters 

312 ---------- 

313 colors : None, (3,) or (4,) float or uint8 

314 (X, Y, Z, 3) or (X, Y, Z, 4) float or uint8 

315 Where matrix.shape == (X, Y, Z) 

316 

317 Returns 

318 --------- 

319 mesh : trimesh.Trimesh 

320 Mesh with one box per filled cell. 

321 """ 

322 

323 if colors is not None: 

324 colors = np.asanyarray(colors) 

325 if colors.ndim == 4: 

326 encoding = self.encoding 

327 if colors.shape[:3] == encoding.shape: 

328 # TODO jackd: more efficient implementation? 

329 # encoding.as_mask? 

330 colors = colors[encoding.dense] 

331 else: 

332 log.warning("colors incorrect shape!") 

333 colors = None 

334 elif colors.shape not in ((3,), (4,)): 

335 log.warning("colors incorrect shape!") 

336 colors = None 

337 

338 mesh = ops.multibox(centers=self.sparse_indices.astype(float), colors=colors) 

339 

340 mesh = mesh.apply_transform(self.transform) 

341 return mesh 

342 

343 def points_to_indices(self, points): 

344 """ 

345 Convert points to indices in the matrix array. 

346 

347 Parameters 

348 ---------- 

349 points: (n, 3) float, point in space 

350 

351 Returns 

352 --------- 

353 indices: (n, 3) int array of indices into self.encoding 

354 """ 

355 points = self._transform.inverse_transform_points(points) 

356 return np.round(points).astype(int) 

357 

358 def indices_to_points(self, indices): 

359 return self._transform.transform_points(indices.astype(float)) 

360 

361 def show(self, *args, **kwargs): 

362 """ 

363 Convert the current set of voxels into a trimesh for visualization 

364 and show that via its built- in preview method. 

365 """ 

366 return self.as_boxes(kwargs.pop("colors", None)).show(*args, **kwargs) 

367 

368 def copy(self): 

369 return VoxelGrid(self.encoding.copy(), self._transform.matrix.copy()) 

370 

371 def export(self, file_obj=None, file_type=None, **kwargs): 

372 """ 

373 Export the current VoxelGrid. 

374 

375 Parameters 

376 ------------ 

377 file_obj : file-like or str 

378 File or file-name to export to. 

379 file_type : None or str 

380 Only 'binvox' currently supported. 

381 

382 Returns 

383 --------- 

384 export : bytes 

385 Value of export. 

386 """ 

387 if isinstance(file_obj, str) and file_type is None: 

388 file_type = util.split_extension(file_obj).lower() 

389 

390 if file_type != "binvox": 

391 raise ValueError("only binvox exports supported!") 

392 

393 exported = export_binvox(self, **kwargs) 

394 if hasattr(file_obj, "write"): 

395 file_obj.write(exported) 

396 elif isinstance(file_obj, str): 

397 with open(file_obj, "wb") as f: 

398 f.write(exported) 

399 return exported 

400 

401 def revoxelized(self, shape): 

402 """ 

403 Create a new VoxelGrid without rotations, reflections 

404 or shearing. 

405 

406 Parameters 

407 ---------- 

408 shape : (3, int) 

409 The shape of the returned VoxelGrid. 

410 

411 Returns 

412 ---------- 

413 vox : VoxelGrid 

414 Of the given shape with possibly non-uniform 

415 scale and translation transformation matrix. 

416 """ 

417 shape = tuple(shape) 

418 bounds = self.bounds.copy() 

419 extents = self.extents 

420 points = util.grid_linspace(bounds, shape).reshape(shape + (3,)) 

421 dense = self.is_filled(points) 

422 scale = extents / np.asanyarray(shape) 

423 translate = bounds[0] 

424 return VoxelGrid(dense, transform=tr.scale_and_translate(scale, translate)) 

425 

426 def __add__(self, other): 

427 raise NotImplementedError("TODO : implement voxel concatenation")