Coverage for trimesh/exchange/binvox.py: 75%

176 statements  

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

1""" 

2Parsing functions for Binvox files. 

3 

4https://www.patrickmin.com/binvox/binvox.html 

5 

6Exporting meshes as binvox files requires the 

7`binvox` executable to be in your path. 

8""" 

9 

10import collections 

11import os 

12import subprocess 

13from tempfile import TemporaryDirectory 

14 

15import numpy as np 

16 

17from .. import util 

18from ..base import Trimesh 

19from ..util import comment_strip, decode_text 

20 

21# find the executable for binvox in PATH 

22binvox_encoder = util.which("binvox") 

23Binvox = collections.namedtuple("Binvox", ["rle_data", "shape", "translate", "scale"]) 

24 

25 

26def _parse_binvox_header(file_obj): 

27 """ 

28 Read the header from a binvox file. 

29 Spec available: 

30 https://www.patrickmin.com/binvox/binvox.html 

31 

32 Parameters 

33 ------------ 

34 fp: file-object 

35 File like object with binvox file 

36 

37 Returns 

38 ---------- 

39 shape : tuple 

40 Shape of binvox according to binvox spec 

41 translate : tuple 

42 Translation 

43 scale : float 

44 Scale of voxels 

45 

46 Raises 

47 ------------ 

48 IOError 

49 If invalid binvox file. 

50 """ 

51 

52 # check for the magic string in the first line 

53 first = decode_text(file_obj.readline()).strip() 

54 if "binvox" not in first.lower(): 

55 raise ValueError("File is not in the binvox format!") 

56 

57 header = {} 

58 reached_data = False 

59 # do a capped iteration 

60 for _ in range(100): 

61 # get the line as a lower-case, comment-stripped split list 

62 line = ( 

63 comment_strip(decode_text(file_obj.readline()).lower(), "#").strip().split() 

64 ) 

65 # if the line was a comment or whitespace don't include it 

66 if len(line) == 0: 

67 continue 

68 

69 elif line[0] == "data": 

70 # we need to read up until we see "data" so the 

71 # read-the-rest-of-the-payload operation is correct 

72 reached_data = True 

73 break 

74 

75 # save the keyed header data 

76 header[line[0]] = line[1:] 

77 

78 if not reached_data: 

79 raise ValueError("Didn't reach header termination magic word `data`") 

80 

81 if "dim" not in header.keys(): 

82 raise ValueError( 

83 f"Malformed binvox header: `dim` is required, only received `{header.keys()}`" 

84 ) 

85 

86 # dimension of voxel array is required 

87 shape = np.array(header["dim"], dtype=np.int64) 

88 

89 # provide default values for translation and scale 

90 translate = np.array(header.get("translate", [0, 0, 0]), np.float64) 

91 scale = np.array(header.get("scale", [1]), dtype=np.float64) 

92 

93 return shape, translate, scale[0] 

94 

95 

96def parse_binvox(fp, writeable=False): 

97 """ 

98 Read a binvox file, spec at 

99 https://www.patrickmin.com/binvox/binvox.html 

100 

101 Parameters 

102 ------------ 

103 fp: file-object 

104 File like object with binvox file 

105 

106 Returns 

107 ---------- 

108 binvox : namedtuple 

109 Containing data 

110 rle : numpy array 

111 Run length encoded data 

112 

113 Raises 

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

115 IOError 

116 If invalid binvox file 

117 """ 

118 # get the header info 

119 shape, translate, scale = _parse_binvox_header(fp) 

120 # get the rest of the file 

121 data = fp.read() 

122 # convert to numpy array 

123 rle_data = np.frombuffer(data, dtype=np.uint8) 

124 

125 if writeable: 

126 rle_data = rle_data.copy() 

127 return Binvox(rle_data, shape, translate, scale) 

128 

129 

130def binvox_header(shape, translate, scale): 

131 """ 

132 Get a binvox header string. 

133 

134 Parameters 

135 -------- 

136 shape: length 3 iterable of ints denoting shape of voxel grid. 

137 translate: length 3 iterable of floats denoting translation. 

138 scale: num length of entire voxel grid. 

139 

140 Returns 

141 -------- 

142 string including "data\n" line. 

143 """ 

144 sx, sy, sz = (int(s) for s in shape) 

145 tx, ty, tz = translate 

146 

147 return f"""#binvox 1 

148# generated in `trimesh` 

149dim {sx} {sy} {sz} 

150translate {tx} {ty} {tz} 

151scale {scale} 

152data 

153""" 

154 

155 

156def binvox_bytes(rle_data, shape, translate=(0, 0, 0), scale=1): 

157 """Get a binary representation of binvox data. 

158 

159 Parameters 

160 -------- 

161 rle_data : numpy array 

162 Run-length encoded numpy array. 

163 shape : (3,) int 

164 Shape of voxel grid. 

165 translate : (3,) float 

166 Translation of voxels 

167 scale : float 

168 Length of entire voxel grid. 

169 

170 Returns 

171 -------- 

172 data : bytes 

173 Suitable for writing to binary file 

174 """ 

175 if rle_data.dtype != np.uint8: 

176 raise ValueError(f"rle_data.dtype must be np.uint8, got {rle_data.dtype}") 

177 

178 header = binvox_header(shape, translate, scale).encode() 

179 return header + rle_data.tobytes() 

180 

181 

182def voxel_from_binvox(rle_data, shape, translate=None, scale=1.0, axis_order="xzy"): 

183 """ 

184 Factory for building from data associated with binvox files. 

185 

186 Parameters 

187 --------- 

188 rle_data : numpy 

189 Run-length-encoded of flat voxel 

190 values, or a `trimesh.rle.RunLengthEncoding` object. 

191 See `trimesh.rle` documentation for description of encoding 

192 shape : (3,) int 

193 Shape of voxel grid. 

194 translate : (3,) float 

195 Translation of voxels 

196 scale : float 

197 Length of entire voxel grid. 

198 encoded_axes : iterable 

199 With values in ('x', 'y', 'z', 0, 1, 2), 

200 where x => 0, y => 1, z => 2 

201 denoting the order of axes in the encoded data. binvox by 

202 default saves in xzy order, but using `xyz` (or (0, 1, 2)) will 

203 be faster in some circumstances. 

204 

205 Returns 

206 --------- 

207 result : VoxelGrid 

208 Loaded voxels 

209 """ 

210 # shape must be uniform else scale is ambiguous 

211 from .. import transformations 

212 from ..voxel import encoding as enc 

213 from ..voxel.base import VoxelGrid 

214 

215 if isinstance(rle_data, enc.RunLengthEncoding): 

216 encoding = rle_data 

217 else: 

218 encoding = enc.RunLengthEncoding(rle_data, dtype=bool) 

219 

220 # translate = np.asanyarray(translate) * scale) 

221 # translate = [0, 0, 0] 

222 transform = transformations.scale_and_translate( 

223 scale=scale / (np.array(shape) - 1), translate=translate 

224 ) 

225 

226 if axis_order == "xzy": 

227 perm = (0, 2, 1) 

228 shape = tuple(shape[p] for p in perm) 

229 encoding = encoding.reshape(shape).transpose(perm) 

230 elif axis_order is None or axis_order == "xyz": 

231 encoding = encoding.reshape(shape) 

232 else: 

233 raise ValueError( 

234 "Invalid axis_order '%s': must be None, 'xyz' or 'xzy'", axis_order 

235 ) 

236 

237 assert encoding.shape == shape 

238 

239 return VoxelGrid(encoding, transform) 

240 

241 

242def load_binvox(file_obj, resolver=None, axis_order="xzy", file_type=None): 

243 """ 

244 Load trimesh `VoxelGrid` instance from file. 

245 

246 Parameters 

247 ----------- 

248 file_obj : file-like object 

249 Contains binvox data 

250 resolver : unused 

251 axis_order : str 

252 Order of axes in encoded data. 

253 Binvox default is 'xzy', but 'xyz' may be faster 

254 where this is not relevant. 

255 

256 Returns 

257 --------- 

258 result : trimesh.voxel.VoxelGrid 

259 Loaded voxel data 

260 """ 

261 if file_type is not None and file_type != "binvox": 

262 raise ValueError(f"file_type must be None or binvox, got {file_type}") 

263 data = parse_binvox(file_obj, writeable=True) 

264 return voxel_from_binvox( 

265 rle_data=data.rle_data, 

266 shape=data.shape, 

267 translate=data.translate, 

268 scale=data.scale, 

269 axis_order=axis_order, 

270 ) 

271 

272 

273def export_binvox(voxel, axis_order="xzy"): 

274 """ 

275 Export `trimesh.voxel.VoxelGrid` instance to bytes 

276 

277 Parameters 

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

279 voxel : `trimesh.voxel.VoxelGrid` 

280 Assumes axis ordering of `xyz` and encodes 

281 in binvox default `xzy` ordering. 

282 axis_order : str 

283 Eements in ('x', 'y', 'z', 0, 1, 2), the order 

284 of axes to encode data (standard is 'xzy' for binvox). `voxel` 

285 data is assumed to be in order 'xyz'. 

286 

287 Returns 

288 ----------- 

289 result : bytes 

290 Representation according to binvox spec 

291 """ 

292 translate = voxel.translation 

293 scale = voxel.scale * (np.array(voxel.shape) - 1) 

294 (neg_scale,) = np.where(scale < 0) 

295 encoding = voxel.encoding.flip(neg_scale) 

296 scale = np.abs(scale) 

297 if not util.allclose(scale[0], scale[1:], 1e-6 * scale[0] + 1e-8): 

298 raise ValueError("Can only export binvox with uniform scale") 

299 scale = scale[0] 

300 if axis_order == "xzy": 

301 encoding = encoding.transpose((0, 2, 1)) 

302 elif axis_order != "xyz": 

303 raise ValueError('Invalid axis_order: must be one of ("xyz", "xzy")') 

304 rle_data = encoding.flat.run_length_data(dtype=np.uint8) 

305 return binvox_bytes(rle_data, shape=voxel.shape, translate=translate, scale=scale) 

306 

307 

308class Binvoxer: 

309 """ 

310 Interface for binvox CL tool. 

311 

312 This class is responsible purely for making calls to the CL tool. It 

313 makes no attempt to integrate with the rest of trimesh at all. 

314 

315 Constructor args configure command line options. 

316 

317 `Binvoxer.__call__` operates on the path to a mode file. 

318 

319 If using this interface in published works, please cite the references 

320 below. 

321 

322 See CL tool website for further details. 

323 

324 https://www.patrickmin.com/binvox/ 

325 

326 @article{nooruddin03, 

327 author = {Fakir S. Nooruddin and Greg Turk}, 

328 title = {Simplification and Repair of Polygonal Models Using Volumetric 

329 Techniques}, 

330 journal = {IEEE Transactions on Visualization and Computer Graphics}, 

331 volume = {9}, 

332 number = {2}, 

333 pages = {191--205}, 

334 year = {2003} 

335 } 

336 

337 @Misc{binvox, 

338 author = {Patrick Min}, 

339 title = {binvox}, 

340 howpublished = {{\tt http://www.patrickmin.com/binvox} or 

341 {\tt https://www.google.com/search?q=binvox}}, 

342 year = {2004 - 2019}, 

343 note = {Accessed: yyyy-mm-dd} 

344 } 

345 """ 

346 

347 SUPPORTED_INPUT_TYPES = ( 

348 "ug", 

349 "obj", 

350 "off", 

351 "dfx", 

352 "xgl", 

353 "pov", 

354 "brep", 

355 "ply", 

356 "jot", 

357 ) 

358 

359 SUPPORTED_OUTPUT_TYPES = ( 

360 "binvox", 

361 "hips", 

362 "mira", 

363 "vtk", 

364 "raw", 

365 "schematic", 

366 "msh", 

367 ) 

368 

369 def __init__( 

370 self, 

371 dimension=32, 

372 file_type="binvox", 

373 z_buffer_carving=True, 

374 z_buffer_voting=True, 

375 dilated_carving=False, 

376 exact=True, 

377 bounding_box=None, 

378 remove_internal=False, 

379 center=False, 

380 rotate_x=0, 

381 rotate_z=0, 

382 wireframe=False, 

383 fit=False, 

384 block_id=None, 

385 use_material_block_id=False, 

386 use_offscreen_pbuffer=False, 

387 downsample_factor=None, 

388 downsample_threshold=None, 

389 verbose=False, 

390 binvox_path=None, 

391 ): 

392 """ 

393 Configure the voxelizer. 

394 

395 Parameters 

396 ------------ 

397 dimension: voxel grid size (max 1024 when not using exact) 

398 file_type: str 

399 Output file type, supported types are: 

400 'binvox' 

401 'hips' 

402 'mira' 

403 'vtk' 

404 'raw' 

405 'schematic' 

406 'msh' 

407 z_buffer_carving : use z buffer based carving. At least one of 

408 `z_buffer_carving` and `z_buffer_voting` must be True. 

409 z_buffer_voting: use z-buffer based parity voting method. 

410 dilated_carving: stop carving 1 voxel before intersection. 

411 exact: any voxel with part of a triangle gets set. Does not use 

412 graphics card. 

413 bounding_box: 6-element float list/tuple of min, max values, 

414 (minx, miny, minz, maxx, maxy, maxz) 

415 remove_internal: remove internal voxels if True. Note there is some odd 

416 behaviour if boundary voxels are occupied. 

417 center: center model inside unit cube. 

418 rotate_x: number of 90 degree ccw rotations around x-axis before 

419 voxelizing. 

420 rotate_z: number of 90 degree cw rotations around z-axis before 

421 voxelizing. 

422 wireframe: also render the model in wireframe (helps with thin parts). 

423 fit: only write voxels in the voxel bounding box. 

424 block_id: when converting to schematic, use this as the block ID. 

425 use_matrial_block_id: when converting from obj to schematic, parse 

426 block ID from material spec "usemtl blockid_<id>" (ids 1-255 only). 

427 use_offscreen_pbuffer: use offscreen pbuffer instead of onscreen 

428 window. 

429 downsample_factor: downsample voxels by this factor in each dimension. 

430 Must be a power of 2 or None. If not None/1 and `core dumped` 

431 errors occur, try slightly adjusting dimensions. 

432 downsample_threshold: when downsampling, destination voxel is on if 

433 more than this number of voxels are on. 

434 verbose : bool 

435 If False, silences stdout/stderr from subprocess call. 

436 binvox_path : str 

437 Path to binvox executable. The default looks for an 

438 executable called `binvox` on your `PATH`. 

439 """ 

440 if binvox_path is None: 

441 encoder = binvox_encoder 

442 else: 

443 encoder = binvox_path 

444 

445 if encoder is None: 

446 raise OSError( 

447 " ".join( 

448 [ 

449 "No `binvox_path` provided and no binvox executable found", 

450 "on PATH, please go to https://www.patrickmin.com/binvox/ and", 

451 "download the appropriate version.", 

452 ] 

453 ) 

454 ) 

455 

456 if dimension > 1024 and not exact: 

457 raise ValueError("Maximum dimension using exact is 1024, got %d", dimension) 

458 if file_type not in Binvoxer.SUPPORTED_OUTPUT_TYPES: 

459 raise ValueError( 

460 f"file_type {file_type} not in set of supported output types {Binvoxer.SUPPORTED_OUTPUT_TYPES!s}" 

461 ) 

462 args = [encoder, "-d", str(dimension), "-t", file_type] 

463 if exact: 

464 args.append("-e") 

465 if z_buffer_carving: 

466 if z_buffer_voting: 

467 pass 

468 else: 

469 args.append("-c") 

470 elif z_buffer_voting: 

471 args.append("-v") 

472 else: 

473 raise ValueError( 

474 "One of `z_buffer_carving` or `z_buffer_voting` must be True" 

475 ) 

476 if dilated_carving: 

477 args.append("-dc") 

478 

479 # Additional parameters 

480 if bounding_box is not None: 

481 if len(bounding_box) != 6: 

482 raise ValueError("bounding_box must have 6 elements") 

483 args.append("-bb") 

484 args.extend(str(b) for b in bounding_box) 

485 if remove_internal: 

486 args.append("-ri") 

487 if center: 

488 args.append("-cb") 

489 args.extend(("-rotx",) * rotate_x) 

490 args.extend(("-rotz",) * rotate_z) 

491 if wireframe: 

492 args.append("-aw") 

493 if fit: 

494 args.append("-fit") 

495 if block_id is not None: 

496 args.extend(("-bi", block_id)) 

497 if use_material_block_id: 

498 args.append("-mb") 

499 if use_offscreen_pbuffer: 

500 args.append("-pb") 

501 if downsample_factor is not None: 

502 times = np.log2(downsample_factor) 

503 if int(times) != times: 

504 raise ValueError( 

505 "downsample_factor must be a power of 2, got %d", downsample_factor 

506 ) 

507 args.extend(("-down",) * int(times)) 

508 if downsample_threshold is not None: 

509 args.extend(("-dmin", str(downsample_threshold))) 

510 args.append("PATH") 

511 self._args = args 

512 self._file_type = file_type 

513 

514 self.verbose = verbose 

515 

516 @property 

517 def file_type(self): 

518 return self._file_type 

519 

520 def __call__(self, path, overwrite=False): 

521 """ 

522 Create an voxel file in the same directory as model at `path`. 

523 

524 Parameters 

525 ------------ 

526 path: string path to model file. Supported types: 

527 'ug' 

528 'obj' 

529 'off' 

530 'dfx' 

531 'xgl' 

532 'pov' 

533 'brep' 

534 'ply' 

535 'jot' (polygongs only) 

536 overwrite: if False, checks the output path (head.file_type) is empty 

537 before running. If True and a file exists, raises an IOError. 

538 

539 Returns 

540 ------------ 

541 string path to voxel file. File type give by file_type in constructor. 

542 """ 

543 head, ext = os.path.splitext(path) 

544 ext = ext[1:].lower() 

545 if ext not in Binvoxer.SUPPORTED_INPUT_TYPES: 

546 raise ValueError( 

547 f"file_type {ext} not in set of supported input types {Binvoxer.SUPPORTED_INPUT_TYPES!s}" 

548 ) 

549 out_path = f"{head}.{self._file_type}" 

550 if os.path.isfile(out_path) and not overwrite: 

551 raise OSError("Attempted to voxelize object at existing path") 

552 self._args[-1] = path 

553 

554 # generalizes to python2 and python3 

555 # will capture terminal output into variable rather than printing 

556 verbosity = subprocess.check_output(self._args, stderr=subprocess.STDOUT) 

557 

558 # if requested print ourselves 

559 if self.verbose: 

560 util.log.debug(verbosity) 

561 

562 return out_path 

563 

564 

565def voxelize_mesh(mesh, binvoxer=None, export_type="off", **binvoxer_kwargs): 

566 """ 

567 Interface for voxelizing Trimesh object via the binvox tool. 

568 

569 Implementation simply saved the mesh in the specified export_type then 

570 runs the `Binvoxer.__call__` (using either the supplied `binvoxer` or 

571 creating one via `binvoxer_kwargs`) 

572 

573 Parameters 

574 ------------ 

575 mesh: Trimesh object to voxelize. 

576 binvoxer: optional Binvoxer instance. 

577 export_type: file type to export mesh as temporarily for Binvoxer to 

578 operate on. 

579 **binvoxer_kwargs: kwargs for creating a new Binvoxer instance. If binvoxer 

580 if provided, this must be empty. 

581 

582 Returns 

583 ------------ 

584 `VoxelGrid` object resulting. 

585 """ 

586 if not isinstance(mesh, Trimesh): 

587 raise ValueError(f"mesh must be Trimesh instance, got {mesh!s}") 

588 if binvoxer is None: 

589 binvoxer = Binvoxer(**binvoxer_kwargs) 

590 elif len(binvoxer_kwargs) > 0: 

591 raise ValueError("Cannot provide binvoxer and binvoxer_kwargs") 

592 if binvoxer.file_type != "binvox": 

593 raise ValueError('Only "binvox" binvoxer `file_type` currently supported') 

594 with TemporaryDirectory() as folder: 

595 model_path = os.path.join(folder, f"model.{export_type}") 

596 with open(model_path, "wb") as fp: 

597 mesh.export(fp, file_type=export_type) 

598 out_path = binvoxer(model_path) 

599 with open(out_path, "rb") as fp: 

600 out_model = load_binvox(fp) 

601 

602 return out_model 

603 

604 

605_binvox_loaders = {"binvox": load_binvox}