Coverage for trimesh/voxel/ops.py: 78%

186 statements  

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

1import numpy as np 

2 

3from .. import util 

4from ..constants import log 

5from ..typed import ArrayLike, Number 

6 

7 

8def fill_orthographic(dense): 

9 shape = dense.shape 

10 indices = np.stack( 

11 np.meshgrid(*(np.arange(s) for s in shape), indexing="ij"), axis=-1 

12 ) 

13 empty = np.logical_not(dense) 

14 

15 def fill_axis(axis): 

16 base_local_indices = indices[..., axis] 

17 local_indices = base_local_indices.copy() 

18 local_indices[empty] = shape[axis] 

19 mins = np.min(local_indices, axis=axis, keepdims=True) 

20 local_indices = base_local_indices.copy() 

21 local_indices[empty] = -1 

22 maxs = np.max(local_indices, axis=axis, keepdims=True) 

23 

24 return np.logical_and( 

25 base_local_indices >= mins, 

26 base_local_indices <= maxs, 

27 ) 

28 

29 filled = fill_axis(axis=0) 

30 for axis in range(1, len(shape)): 

31 filled = np.logical_and(filled, fill_axis(axis)) 

32 return filled 

33 

34 

35def fill_base(sparse_indices): 

36 """ 

37 Given a sparse surface voxelization, fill in between columns. 

38 

39 Parameters 

40 -------------- 

41 sparse_indices: (n, 3) int, location of filled cells 

42 

43 Returns 

44 -------------- 

45 filled: (m, 3) int, location of filled cells 

46 """ 

47 # validate inputs 

48 sparse_indices = np.asanyarray(sparse_indices, dtype=np.int64) 

49 if not util.is_shape(sparse_indices, (-1, 3)): 

50 raise ValueError("incorrect shape") 

51 

52 # create grid and mark inner voxels 

53 max_value = sparse_indices.max() + 3 

54 

55 grid = np.zeros((max_value, max_value, max_value), bool) 

56 voxels_sparse = np.add(sparse_indices, 1) 

57 

58 grid[tuple(voxels_sparse.T)] = 1 

59 

60 for i in range(max_value): 

61 check_dir2 = False 

62 for j in range(0, max_value - 1): 

63 idx = [] 

64 # find transitions first 

65 # transition positions are from 0 to 1 and from 1 to 0 

66 eq = np.equal(grid[i, j, :-1], grid[i, j, 1:]) 

67 idx = np.where(np.logical_not(eq))[0] + 1 

68 c = len(idx) 

69 check_dir2 = (c % 4) > 0 and c > 4 

70 if c < 4: 

71 continue 

72 for s in range(0, c - c % 4, 4): 

73 grid[i, j, idx[s] : idx[s + 3]] = 1 

74 if not check_dir2: 

75 continue 

76 

77 # check another direction for robustness 

78 for k in range(0, max_value - 1): 

79 idx = [] 

80 # find transitions first 

81 eq = np.equal(grid[i, :-1, k], grid[i, 1:, k]) 

82 idx = np.where(np.logical_not(eq))[0] + 1 

83 c = len(idx) 

84 if c < 4: 

85 continue 

86 for s in range(0, c - c % 4, 4): 

87 grid[i, idx[s] : idx[s + 3], k] = 1 

88 

89 # generate new voxels 

90 filled = np.column_stack(np.where(grid)) 

91 filled -= 1 

92 

93 return filled 

94 

95 

96fill_voxelization = fill_base 

97 

98 

99def matrix_to_marching_cubes( 

100 matrix: ArrayLike, 

101 pitch: Number | ArrayLike = 1.0, 

102 threshold: Number | None = None, 

103): 

104 """ 

105 Convert an (n, m, p) matrix into a mesh, using marching_cubes. 

106 

107 Parameters 

108 ----------- 

109 matrix : (n, m, p) bool 

110 Occupancy array 

111 pitch : float or length-3 tuple of floats, optional 

112 Voxel spacing in each dimension 

113 threshold : float or None, optional 

114 If specified, converts the input into a boolean 

115 matrix by considering values above `threshold` as True 

116 

117 

118 Returns 

119 ---------- 

120 mesh : trimesh.Trimesh 

121 Mesh generated by meshing voxels using 

122 the marching cubes algorithm in skimage 

123 """ 

124 from skimage import measure 

125 

126 from ..base import Trimesh 

127 

128 if threshold is not None: 

129 matrix = np.asanyarray(matrix) > threshold 

130 else: 

131 matrix = np.asanyarray(matrix, dtype=bool) 

132 

133 rev_matrix = np.logical_not(matrix) # Takes set about 0. 

134 # Add in padding so marching cubes can function properly with 

135 # voxels on edge of AABB 

136 pad_width = 1 

137 rev_matrix = np.pad( 

138 rev_matrix, pad_width=(pad_width), mode="constant", constant_values=(1) 

139 ) 

140 

141 # pick between old and new API 

142 if hasattr(measure, "marching_cubes_lewiner"): 

143 func = measure.marching_cubes_lewiner 

144 else: 

145 func = measure.marching_cubes 

146 

147 # Run marching cubes. 

148 pitch = np.asanyarray(pitch) 

149 if pitch.size == 1: 

150 pitch = (pitch,) * 3 

151 meshed = func( 

152 volume=rev_matrix, 

153 level=0.5, 

154 spacing=pitch, # it is a boolean voxel grid 

155 ) 

156 

157 # allow results from either marching cubes function in skimage 

158 # binaries available for python 3.3 and 3.4 appear to use the classic 

159 # method 

160 if len(meshed) == 2: 

161 log.warning("using old marching cubes, may not be watertight!") 

162 vertices, faces = meshed 

163 normals = None 

164 elif len(meshed) == 4: 

165 vertices, faces, normals, _vals = meshed 

166 

167 # Return to the origin, add in the pad_width 

168 vertices = np.subtract(vertices, pad_width * pitch) 

169 # create the mesh 

170 mesh = Trimesh(vertices=vertices, faces=faces, vertex_normals=normals) 

171 return mesh 

172 

173 

174def sparse_to_matrix(sparse): 

175 """ 

176 Take a sparse (n,3) list of integer indexes of filled cells, 

177 turn it into a dense (m,o,p) matrix. 

178 

179 Parameters 

180 ----------- 

181 sparse : (n, 3) int 

182 Index of filled cells 

183 

184 Returns 

185 ------------ 

186 dense : (m, o, p) bool 

187 Matrix of filled cells 

188 """ 

189 

190 sparse = np.asanyarray(sparse, dtype=np.int64) 

191 if not util.is_shape(sparse, (-1, 3)): 

192 raise ValueError("sparse must be (n,3)!") 

193 

194 shape = sparse.max(axis=0) + 1 

195 matrix = np.zeros(np.prod(shape), dtype=bool) 

196 multiplier = np.array([np.prod(shape[1:]), shape[2], 1]) 

197 

198 index = (sparse * multiplier).sum(axis=1) 

199 matrix[index] = True 

200 

201 dense = matrix.reshape(shape) 

202 return dense 

203 

204 

205def points_to_marching_cubes(points, pitch=1.0): 

206 """ 

207 Mesh points by assuming they fill a voxel box, and then 

208 running marching cubes on them 

209 

210 Parameters 

211 ------------ 

212 points : (n, 3) float 

213 Points in 3D space 

214 

215 Returns 

216 ------------- 

217 mesh : trimesh.Trimesh 

218 Points meshed using marching cubes 

219 """ 

220 # make sure inputs are as expected 

221 points = np.asanyarray(points, dtype=np.float64) 

222 pitch = np.asanyarray(pitch, dtype=float) 

223 

224 # find the minimum value of points for origin 

225 origin = points.min(axis=0) 

226 # convert points to occupied voxel cells 

227 index = ((points - origin) / pitch).round().astype(np.int64) 

228 

229 # convert voxel indices to a matrix 

230 matrix = sparse_to_matrix(index) 

231 

232 # run marching cubes on the matrix to generate a mesh 

233 mesh = matrix_to_marching_cubes(matrix, pitch=pitch) 

234 mesh.vertices += origin 

235 

236 return mesh 

237 

238 

239def multibox(centers, pitch=1.0, colors=None, remove_internal_faces=False): 

240 """ 

241 Return a Trimesh object with a box at every center. 

242 

243 Doesn't do anything nice or fancy. 

244 

245 Parameters 

246 ----------- 

247 centers : (n, 3) float 

248 Center of boxes that are occupied 

249 pitch : float 

250 The edge length of a voxel 

251 colors : (3,) or (4,) or (n,3) or (n, 4) float 

252 Color of boxes 

253 remove_internal_faces : bool 

254 If True, removes internal faces shared between adjacent boxes 

255 

256 Returns 

257 --------- 

258 rough : Trimesh 

259 Mesh object representing inputs 

260 """ 

261 from .. import primitives 

262 from ..base import Trimesh 

263 

264 # get centers as numpy array 

265 centers = np.asanyarray(centers, dtype=np.float64) 

266 

267 # get a basic box 

268 b = primitives.Box() 

269 # apply the pitch 

270 b.apply_scale(float(pitch)) 

271 # tile into one box vertex per center 

272 v = np.tile(centers, (1, len(b.vertices))).reshape((-1, 3)) 

273 # offset to centers 

274 v += np.tile(b.vertices, (len(centers), 1)) 

275 

276 f = np.tile(b.faces, (len(centers), 1)) 

277 f += np.repeat(np.arange(len(centers)) * len(b.vertices), len(b.faces))[:, None] 

278 

279 if remove_internal_faces: 

280 # Get 12 unit normals (1 per triangle face) indicating face direction 

281 base_normals = np.round(b.face_normals).astype(int) # (12, 3) 

282 # Expand those directions across all voxel boxes so as to check neighbor presence 

283 face_normals = np.tile(base_normals, (len(centers), 1)) # (len(centers) * 12, 3) 

284 

285 # Maps each face to the voxel box it came from 

286 face_voxel_idx = np.repeat( 

287 np.arange(len(centers)), len(b.faces) 

288 ) # (len(centers) * 12, ) 

289 # Converts voxel centers to discrete grid coordinates 

290 voxel_coords = np.round(centers / pitch).astype(int) # (len(centers), 3) 

291 # Creates a fast lookup structure for checking voxel neighbors 

292 voxel_set = set(map(tuple, voxel_coords)) 

293 

294 # Gets the grid coordinate of the voxel that owns each face 

295 voxel_face_coords = voxel_coords[face_voxel_idx] 

296 # Computes the adjacent voxel coordinate in the face direction 

297 neighbor_coords = voxel_face_coords + face_normals 

298 # Keeps only faces whose neighboring voxel does not exist 

299 keep_mask = np.array([tuple(c) not in voxel_set for c in neighbor_coords]) 

300 else: 

301 keep_mask = np.ones(len(f), dtype=bool) 

302 

303 face_colors = None 

304 if colors is not None: 

305 colors = np.asarray(colors) 

306 if colors.ndim == 1: 

307 colors = colors[None].repeat(len(centers), axis=0) 

308 if colors.ndim == 2 and len(colors) == len(centers): 

309 face_colors = colors.repeat(12, axis=0)[keep_mask] 

310 

311 mesh = Trimesh(vertices=v, faces=f[keep_mask], face_colors=face_colors) 

312 

313 return mesh 

314 

315 

316def boolean_sparse(a, b, operation=np.logical_and): 

317 """ 

318 Find common rows between two arrays very quickly 

319 using 3D boolean sparse matrices. 

320 

321 Parameters 

322 ----------- 

323 a: (n, d) int, coordinates in space 

324 b: (m, d) int, coordinates in space 

325 operation: numpy operation function, ie: 

326 np.logical_and 

327 np.logical_or 

328 

329 Returns 

330 ----------- 

331 coords: (q, d) int, coordinates in space 

332 """ 

333 # 3D sparse arrays, using wrapped scipy.sparse 

334 # pip install sparse 

335 import sparse 

336 

337 # find the bounding box of both arrays 

338 extrema = np.array([a.min(axis=0), a.max(axis=0), b.min(axis=0), b.max(axis=0)]) 

339 origin = extrema.min(axis=0) - 1 

340 size = tuple(np.ptp(extrema, axis=0) + 2) 

341 

342 # put nearby voxel arrays into same shape sparse array 

343 sp_a = sparse.COO((a - origin).T, data=np.ones(len(a), dtype=bool), shape=size) 

344 sp_b = sparse.COO((b - origin).T, data=np.ones(len(b), dtype=bool), shape=size) 

345 

346 # apply the logical operation 

347 # get a sparse matrix out 

348 applied = operation(sp_a, sp_b) 

349 # reconstruct the original coordinates 

350 coords = np.column_stack(applied.coords) + origin 

351 

352 return coords 

353 

354 

355def strip_array(data): 

356 shape = data.shape 

357 ndims = len(shape) 

358 padding = [] 

359 slices = [] 

360 for dim in range(len(shape)): 

361 axis = tuple(range(dim)) + tuple(range(dim + 1, ndims)) 

362 filled = np.any(data, axis=axis) 

363 (indices,) = np.nonzero(filled) 

364 pad_left = indices[0] 

365 pad_right = indices[-1] 

366 padding.append([pad_left, pad_right]) 

367 slices.append(slice(pad_left, pad_right)) 

368 return data[tuple(slices)], np.array(padding, int) 

369 

370 

371def indices_to_points(indices, pitch=None, origin=None): 

372 """ 

373 Convert indices of an (n,m,p) matrix into a set of voxel center points. 

374 

375 Parameters 

376 ---------- 

377 indices: (q, 3) int, index of voxel matrix (n,m,p) 

378 pitch: float, what pitch was the voxel matrix computed with 

379 origin: (3,) float, what is the origin of the voxel matrix 

380 

381 Returns 

382 ---------- 

383 points: (q, 3) float, list of points 

384 """ 

385 indices = np.asanyarray(indices) 

386 if indices.shape[1:] != (3,): 

387 raise ValueError("shape of indices must be (q, 3)") 

388 

389 points = np.array(indices, dtype=np.float64) 

390 if pitch is not None: 

391 points *= float(pitch) 

392 if origin is not None: 

393 origin = np.asanyarray(origin) 

394 if origin.shape != (3,): 

395 raise ValueError("shape of origin must be (3,)") 

396 points += origin 

397 

398 return points 

399 

400 

401def matrix_to_points(matrix, pitch=None, origin=None): 

402 """ 

403 Convert an (n,m,p) matrix into a set of points for each voxel center. 

404 

405 Parameters 

406 ----------- 

407 matrix: (n,m,p) bool, voxel matrix 

408 pitch: float, what pitch was the voxel matrix computed with 

409 origin: (3,) float, what is the origin of the voxel matrix 

410 

411 Returns 

412 ---------- 

413 points: (q, 3) list of points 

414 """ 

415 indices = np.column_stack(np.nonzero(matrix)) 

416 points = indices_to_points(indices=indices, pitch=pitch, origin=origin) 

417 return points 

418 

419 

420def points_to_indices(points, pitch=None, origin=None): 

421 """ 

422 Convert center points of an (n,m,p) matrix into its indices. 

423 

424 Parameters 

425 ---------- 

426 points : (q, 3) float 

427 Center points of voxel matrix (n,m,p) 

428 pitch : float 

429 What pitch was the voxel matrix computed with 

430 origin : (3,) float 

431 What is the origin of the voxel matrix 

432 

433 Returns 

434 ---------- 

435 indices : (q, 3) int 

436 List of indices 

437 """ 

438 points = np.array(points, dtype=np.float64) 

439 if points.shape != (points.shape[0], 3): 

440 raise ValueError("shape of points must be (q, 3)") 

441 

442 if origin is not None: 

443 origin = np.asanyarray(origin) 

444 if origin.shape != (3,): 

445 raise ValueError("shape of origin must be (3,)") 

446 points -= origin 

447 if pitch is not None: 

448 points /= pitch 

449 

450 origin = np.asanyarray(origin, dtype=np.float64) 

451 pitch = float(pitch) 

452 

453 indices = np.round(points).astype(int) 

454 return indices