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
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-24 04:40 +0000
1"""
2voxel.py
3-----------
5Convert meshes to a simple voxel data structure and back again.
6"""
8from hashlib import sha256
10import numpy as np
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
22class VoxelGrid(Geometry):
23 """
24 Store 3D voxels.
25 """
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__)
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}")
46 def __hash__(self):
47 """
48 Get the hash of the current transformation matrix.
50 Returns
51 ------------
52 hash : str
53 Hash of transformation matrix
54 """
55 return self._data.__hash__()
57 @property
58 def identifier_hash(self) -> str:
59 return sha256(hash(self).to_bytes()).hexdigest()
61 @property
62 def encoding(self):
63 """
64 `Encoding` object providing the occupancy grid.
66 See `trimesh.voxel.encoding` for implementations.
67 """
68 return self._data["encoding"]
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
82 @property
83 def transform(self):
84 """4x4 homogeneous transformation matrix."""
85 return self._transform.matrix
87 @transform.setter
88 def transform(self, matrix):
89 """4x4 homogeneous transformation matrix."""
90 self._transform.matrix = matrix
92 @property
93 def translation(self):
94 """Location of voxel at [0, 0, 0]."""
95 return self._transform.translation
97 @property
98 def scale(self):
99 """
100 3-element float representing per-axis scale.
102 Raises a `RuntimeError` if `self.transform` has rotation or
103 shear components.
104 """
105 return self._transform.scale
107 @property
108 def pitch(self):
109 """
110 Uniform scaling factor representing the side length of
111 each voxel.
113 Returns
114 -----------
115 pitch : float
116 Pitch of the voxels.
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
126 @property
127 def element_volume(self):
128 return self._transform.unit_volume
130 def apply_transform(self, matrix):
131 self._transform.apply_transform(matrix)
132 return self
134 def strip(self):
135 """
136 Mutate self by stripping leading/trailing planes of zeros.
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
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
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
168 @caching.cache_decorator
169 def is_empty(self):
170 return self.encoding.is_empty
172 @property
173 def shape(self):
174 """3-tuple of ints denoting shape of occupancy grid."""
175 return self.encoding.shape
177 @caching.cache_decorator
178 def filled_count(self):
179 """int, number of occupied voxels in the grid."""
180 return self.encoding.sum.item()
182 def is_filled(self, point):
183 """
184 Query points to see if the voxel cells they lie in are
185 filled or not.
187 Parameters
188 ----------
189 point : (n, 3) float
190 Points in space
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 )
203 is_filled = np.zeros_like(in_range)
204 is_filled[in_range] = self.encoding.gather_nd(indices[in_range])
205 return is_filled
207 def fill(self, method="holes", **kwargs):
208 """
209 Mutates self by filling in the encoding according
210 to `morphology.fill`.
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.
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
229 def hollow(self):
230 """
231 Mutates self by removing internal voxels
232 leaving only surface elements.
234 Surviving elements are those in encoding that are
235 adjacent to an empty voxel where adjacency is
236 controlled by `structure`.
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
246 @caching.cache_decorator
247 def marching_cubes(self):
248 """
249 A marching cubes Trimesh representation of the voxels.
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.
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)
263 @property
264 def matrix(self):
265 """
266 Return a DENSE matrix of the current voxel encoding.
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
276 @caching.cache_decorator
277 def volume(self):
278 """
279 What is the volume of the filled cells in the current
280 voxel object.
282 Returns
283 ---------
284 volume : float
285 Volume of filled cells.
286 """
287 return self.filled_count * self.element_volume
289 @caching.cache_decorator
290 def points(self):
291 """
292 The center of each filled cell as a list of points.
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))
301 @property
302 def sparse_indices(self):
303 """(n, 3) int array of sparse indices of occupied voxels."""
304 return self.encoding.sparse_indices
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.
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)
317 Returns
318 ---------
319 mesh : trimesh.Trimesh
320 Mesh with one box per filled cell.
321 """
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
338 mesh = ops.multibox(centers=self.sparse_indices.astype(float), colors=colors)
340 mesh = mesh.apply_transform(self.transform)
341 return mesh
343 def points_to_indices(self, points):
344 """
345 Convert points to indices in the matrix array.
347 Parameters
348 ----------
349 points: (n, 3) float, point in space
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)
358 def indices_to_points(self, indices):
359 return self._transform.transform_points(indices.astype(float))
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)
368 def copy(self):
369 return VoxelGrid(self.encoding.copy(), self._transform.matrix.copy())
371 def export(self, file_obj=None, file_type=None, **kwargs):
372 """
373 Export the current VoxelGrid.
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.
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()
390 if file_type != "binvox":
391 raise ValueError("only binvox exports supported!")
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
401 def revoxelized(self, shape):
402 """
403 Create a new VoxelGrid without rotations, reflections
404 or shearing.
406 Parameters
407 ----------
408 shape : (3, int)
409 The shape of the returned VoxelGrid.
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))
426 def __add__(self, other):
427 raise NotImplementedError("TODO : implement voxel concatenation")