reverse3d 0.11.1__tar.gz → 0.11.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. {reverse3d-0.11.1/reverse3d.egg-info → reverse3d-0.11.2}/PKG-INFO +1 -1
  2. reverse3d-0.11.2/reverse3d/_version.py +1 -0
  3. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/reconstruction/silhouette.py +212 -32
  4. {reverse3d-0.11.1 → reverse3d-0.11.2/reverse3d.egg-info}/PKG-INFO +1 -1
  5. {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_silhouette.py +57 -0
  6. reverse3d-0.11.1/reverse3d/_version.py +0 -1
  7. {reverse3d-0.11.1 → reverse3d-0.11.2}/LICENSE +0 -0
  8. {reverse3d-0.11.1 → reverse3d-0.11.2}/NOTICE +0 -0
  9. {reverse3d-0.11.1 → reverse3d-0.11.2}/README.md +0 -0
  10. {reverse3d-0.11.1 → reverse3d-0.11.2}/pyproject.toml +0 -0
  11. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/__init__.py +0 -0
  12. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/__main__.py +0 -0
  13. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/_about.py +0 -0
  14. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/agent/__init__.py +0 -0
  15. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/agent/compact.py +0 -0
  16. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/agent/mcp_server.py +0 -0
  17. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/agent/prompts.py +0 -0
  18. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/agent/skill/SKILL.md +0 -0
  19. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/agent/tools.py +0 -0
  20. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/autopilot.py +0 -0
  21. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/cli.py +0 -0
  22. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/config.py +0 -0
  23. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/doctor.py +0 -0
  24. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/errors.py +0 -0
  25. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/hardware.py +0 -0
  26. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/imaging.py +0 -0
  27. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/logging_utils.py +0 -0
  28. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/mesh_io.py +0 -0
  29. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/offline.py +0 -0
  30. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/profiles.py +0 -0
  31. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/py.typed +0 -0
  32. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/recipes.py +0 -0
  33. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/reconstruction/__init__.py +0 -0
  34. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/reconstruction/depth_estimation.py +0 -0
  35. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/reconstruction/photogrammetry.py +0 -0
  36. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/reconstruction/pointcloud.py +0 -0
  37. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/reconstruction/texture.py +0 -0
  38. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/rendering/__init__.py +0 -0
  39. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/rendering/_blender_job.py +0 -0
  40. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/rendering/blender_render.py +0 -0
  41. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/rendering/preview.py +0 -0
  42. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/reverse_eng/__init__.py +0 -0
  43. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/reverse_eng/geometry_fit.py +0 -0
  44. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/reverse_eng/measure.py +0 -0
  45. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/reverse_eng/mesh_cleaner.py +0 -0
  46. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/reverse_eng/parametric.py +0 -0
  47. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/scenes.py +0 -0
  48. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/security.py +0 -0
  49. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/setup_agents.py +0 -0
  50. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/userconfig.py +0 -0
  51. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/vision/__init__.py +0 -0
  52. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/vision/classifier.py +0 -0
  53. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/vision/feature_matcher.py +0 -0
  54. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/vision/mask_extractor.py +0 -0
  55. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/vision/segmenter.py +0 -0
  56. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/watch.py +0 -0
  57. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d.egg-info/SOURCES.txt +0 -0
  58. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d.egg-info/dependency_links.txt +0 -0
  59. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d.egg-info/entry_points.txt +0 -0
  60. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d.egg-info/requires.txt +0 -0
  61. {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d.egg-info/top_level.txt +0 -0
  62. {reverse3d-0.11.1 → reverse3d-0.11.2}/setup.cfg +0 -0
  63. {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_agent_tools.py +0 -0
  64. {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_authorship.py +0 -0
  65. {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_automation.py +0 -0
  66. {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_cli.py +0 -0
  67. {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_cli_automation.py +0 -0
  68. {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_color.py +0 -0
  69. {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_config.py +0 -0
  70. {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_geometry_fit.py +0 -0
  71. {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_heic.py +0 -0
  72. {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_mesh.py +0 -0
  73. {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_offline_autopilot.py +0 -0
  74. {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_optional_extras.py +0 -0
  75. {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_reconstruction.py +0 -0
  76. {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_render.py +0 -0
  77. {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_scenes.py +0 -0
  78. {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_security.py +0 -0
  79. {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_texture.py +0 -0
  80. {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_vision.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reverse3d
3
- Version: 0.11.1
3
+ Version: 0.11.2
4
4
  Summary: AI-agent-ready toolkit for 3D reverse engineering: image analysis, photogrammetry, mesh repair, primitive fitting and headless rendering.
5
5
  Author-email: Voxel Enterprise <info@voxel.lat>
6
6
  Maintainer-email: Voxel Enterprise <info@voxel.lat>
@@ -0,0 +1 @@
1
+ __version__ = "0.11.2"
@@ -22,7 +22,9 @@ from __future__ import annotations
22
22
 
23
23
  import importlib
24
24
  import logging
25
+ import os
25
26
  from dataclasses import asdict, dataclass, field
27
+ from functools import partial
26
28
  from pathlib import Path
27
29
  from typing import TYPE_CHECKING, Any
28
30
 
@@ -184,14 +186,20 @@ def _grabcut(
184
186
  return np.where((init == cv2.GC_FGD) | (init == cv2.GC_PR_FGD), 255, 0).astype(np.uint8)
185
187
 
186
188
 
189
+ # Largest pixel coordinate kept in the int16 projections (outside any work image).
190
+ _PIXEL_MAX = 32000
191
+
187
192
  # reverse3d-core weight below which a photo is reported as inconsistent.
188
193
  _UNRELIABLE_WEIGHT = 0.3
189
194
 
190
195
  # A support plane must face the photographer's "up" (phones are held upright).
191
196
  _MAX_TABLE_TILT_DEG = 30.0
197
+ _MIN_TABLE_COS = float(np.cos(np.radians(_MAX_TABLE_TILT_DEG)))
198
+ # Sparse points a table needs (a dark slatted table yields few).
199
+ _MIN_TABLE_POINTS = 12
192
200
  # The carved hull has an unseen keel under the object, so a real table may cut a
193
201
  # fair part of it; a plane cutting more than this is not under the object.
194
- _MAX_PLANE_CUT = 0.45
202
+ _MAX_PLANE_CUT = 0.3
195
203
 
196
204
 
197
205
  def _support_plane(
@@ -209,13 +217,27 @@ def _support_plane(
209
217
  cameras' mean "up", the object stands on top of it and close to it, and it
210
218
  does not cut through the carved object. Otherwise no plane is used at all.
211
219
  """
212
- near = points[np.linalg.norm(points - center, axis=1) < 1.2 * radius]
220
+ if occupied is not None and len(occupied):
221
+ # Around the carved object itself (a tall object's table is far from its centre).
222
+ lo = occupied.min(axis=0) - 0.5 * radius
223
+ hi = occupied.max(axis=0) + 0.5 * radius
224
+ near = points[np.all((points >= lo) & (points <= hi), axis=1)]
225
+ else:
226
+ near = points[np.linalg.norm(points - center, axis=1) < 1.2 * radius]
227
+ if up is not None and occupied is not None and len(occupied) and len(near) >= 30:
228
+ layer = _table_layer(near, occupied, up, radius)
229
+ if layer is not None:
230
+ n, d = layer
231
+ if np.median(cameras @ n + d) >= 0 and float(center @ n + d) > 0:
232
+ cut = float(np.mean(occupied @ n + d < 0.015 * radius))
233
+ if cut <= _MAX_PLANE_CUT:
234
+ return n, d
213
235
  rng = np.random.default_rng(0)
214
236
  for _ in range(5):
215
237
  if len(near) < 30:
216
238
  return None
217
239
  best: tuple[np.ndarray, float, int] | None = None
218
- for _ in range(400):
240
+ for _ in range(1500):
219
241
  s = near[rng.choice(len(near), 3, replace=False)]
220
242
  n = np.cross(s[1] - s[0], s[2] - s[0])
221
243
  norm = np.linalg.norm(n)
@@ -223,10 +245,30 @@ def _support_plane(
223
245
  continue
224
246
  n /= norm
225
247
  d = -float(n @ s[0])
248
+ # Only roughly horizontal planes compete: walls and doors have far more
249
+ # points than a dark table and would always win.
250
+ if up is not None and abs(float(n @ up)) < _MIN_TABLE_COS:
251
+ continue
252
+ # ... and only below the object, close to it (not the top of a cap,
253
+ # a shelf or a chair seat behind it).
254
+ if np.median(cameras @ n + d) < 0:
255
+ n, d = -n, -d
256
+ if float(center @ n + d) < 0.0:
257
+ continue
258
+ if occupied is not None and len(occupied):
259
+ # The object must reach down to it (not a floor far below the table)
260
+ # and stand on it (not a shelf or a seat at mid height).
261
+ above = occupied @ n + d
262
+ if float(np.min(above)) > 0.05 * radius:
263
+ continue
264
+ if float(np.mean(above < 0.015 * radius)) > _MAX_PLANE_CUT:
265
+ continue
266
+ elif float(center @ n + d) > radius:
267
+ continue
226
268
  count = int((np.abs(near @ n + d) < 0.01 * radius).sum())
227
269
  if best is None or count > best[2]:
228
270
  best = (n, d, count)
229
- if best is None or best[2] < 20:
271
+ if best is None or best[2] < _MIN_TABLE_POINTS:
230
272
  return None
231
273
  n, d, _ = best
232
274
  if np.median(cameras @ n + d) < 0: # cameras are above the table
@@ -242,7 +284,7 @@ def _support_plane(
242
284
  "object below it"
243
285
  if height < 0
244
286
  else "too far below the object"
245
- if height > radius
287
+ if (occupied is None or not len(occupied)) and height > radius
246
288
  else f"tilted {tilt:.0f} deg from the cameras' up"
247
289
  if tilt > _MAX_TABLE_TILT_DEG
248
290
  else f"it would cut {cut:.0%} of the object"
@@ -256,6 +298,121 @@ def _support_plane(
256
298
  return None
257
299
 
258
300
 
301
+ # Base completion: the typical cross-section is measured in this band of the
302
+ # object's height, and a complete one is searched for no higher than this.
303
+ _BASE_TYPICAL = (0.2, 0.6)
304
+ _BASE_MAX_SEARCH = 0.4
305
+
306
+
307
+ def _complete_base(
308
+ volume: np.ndarray, grid: np.ndarray, normal: np.ndarray, offset: float, voxel: float
309
+ ) -> np.ndarray:
310
+ """Extend an object standing on a table down to it.
311
+
312
+ The lowest band of an object is where cut-outs fail most (a white base on a
313
+ light table, its reflection, a shadow), leaving a slanted or bitten base. A
314
+ standing object reaches the table, so the cross-section just above that band
315
+ is extruded down to the table. Only voxels under that cross-section are added
316
+ (legs stay legs), and nothing is removed.
317
+ """
318
+ occ = volume.reshape(-1) > 0.5
319
+ if not occ.any():
320
+ return volume
321
+ h = grid @ normal + offset
322
+ top = float(h[occ].max())
323
+ if top <= 0:
324
+ return volume
325
+ # Reference: the lowest cross-section that is already complete (area close to
326
+ # the typical one of the object's middle), searched upwards from the table.
327
+ hs = h[occ]
328
+ edges = np.arange(0.0, top + voxel, voxel)
329
+ area, _ = np.histogram(hs, edges)
330
+ mid = (edges[:-1] >= _BASE_TYPICAL[0] * top) & (edges[:-1] < _BASE_TYPICAL[1] * top)
331
+ if not mid.any():
332
+ return volume
333
+ typical = float(np.median(area[mid]))
334
+ lo = None
335
+ for k in range(len(area) - 2):
336
+ if edges[k] > _BASE_MAX_SEARCH * top:
337
+ break
338
+ if min(area[k], area[k + 1], area[k + 2]) >= 0.9 * typical:
339
+ lo = float(edges[k])
340
+ break
341
+ if lo is None or lo <= voxel:
342
+ return volume # already whole down to the table, or no clean reference
343
+ ref = occ & (h >= lo) & (h < lo + 3 * voxel)
344
+ if ref.sum() < 20:
345
+ return volume
346
+ helper = np.array([1.0, 0.0, 0.0]) if abs(normal[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
347
+ e1 = np.cross(normal, helper)
348
+ e1 /= np.linalg.norm(e1)
349
+ e2 = np.cross(normal, e1)
350
+ xy = np.stack([grid @ e1, grid @ e2], axis=1)
351
+ origin = xy.min(axis=0)
352
+ ij = np.floor((xy - origin) / voxel).astype(np.int64)
353
+ footprint = np.zeros(ij.max(axis=0) + 1, bool)
354
+ footprint[ij[ref, 0], ij[ref, 1]] = True
355
+ from scipy import ndimage
356
+
357
+ footprint = ndimage.binary_closing(footprint, iterations=1)
358
+ below = (h >= 0) & (h < lo) & footprint[ij[:, 0], ij[:, 1]]
359
+ added = int((below & ~occ).sum())
360
+ if added:
361
+ logger.info("Base completed down to the table (%d voxels)", added)
362
+ out = volume.reshape(-1).copy()
363
+ out[below] = 1.0
364
+ return out.reshape(volume.shape)
365
+
366
+
367
+ def _coarse_grabcut(image: np.ndarray, rect: tuple[int, int, int, int]) -> np.ndarray:
368
+ """GrabCut at half resolution (4x fewer pixels): enough for a first silhouette."""
369
+ h, w = image.shape[:2]
370
+ half = cv2.resize(image, (w // 2, h // 2), interpolation=cv2.INTER_AREA)
371
+ x, y, rw, rh = rect
372
+ fg = _grabcut(
373
+ half, np.zeros(half.shape[:2], np.uint8), (x // 2, y // 2, max(2, rw // 2), max(2, rh // 2))
374
+ )
375
+ return cv2.resize(fg, (w, h), interpolation=cv2.INTER_NEAREST)
376
+
377
+
378
+ def _parallel(jobs: list[Callable[[], np.ndarray]]) -> list[np.ndarray]:
379
+ """Run per-photo OpenCV jobs on every core (OpenCV releases the GIL)."""
380
+ from concurrent.futures import ThreadPoolExecutor
381
+
382
+ workers = max(1, min(len(jobs), (os.cpu_count() or 2)))
383
+ with ThreadPoolExecutor(max_workers=workers) as pool:
384
+ return list(pool.map(lambda job: job(), jobs))
385
+
386
+
387
+ def _table_layer(
388
+ near: np.ndarray, occupied: np.ndarray, up: np.ndarray, radius: float
389
+ ) -> tuple[np.ndarray, float] | None:
390
+ """The densest horizontal layer of sparse points just under the object.
391
+
392
+ Knowing "up" turns the table search into 1-D: a histogram of point heights
393
+ around the object's lowest point. The layer is fitted by least squares, which
394
+ tolerates a slatted or slightly uneven top that thin RANSAC bands miss.
395
+ """
396
+ heights = near @ up
397
+ base = float(np.min(occupied @ up))
398
+ step = 0.02 * radius
399
+ edges = np.arange(base - 0.5 * radius, base + 0.3 * radius + step, step)
400
+ counts, _ = np.histogram(heights, edges)
401
+ if counts.max(initial=0) < _MIN_TABLE_POINTS:
402
+ return None
403
+ peak = int(np.argmax(counts))
404
+ level = (edges[peak] + edges[peak + 1]) / 2
405
+ layer = near[np.abs(heights - level) < 2 * step]
406
+ if len(layer) < _MIN_TABLE_POINTS:
407
+ return None
408
+ centre = layer.mean(axis=0)
409
+ _, _, vt = np.linalg.svd(layer - centre, full_matrices=False)
410
+ n = vt[2] if vt[2] @ up > 0 else -vt[2]
411
+ if float(n @ up) < _MIN_TABLE_COS:
412
+ return None
413
+ return n, -float(n @ centre)
414
+
415
+
259
416
  def _box_around(
260
417
  sel: np.ndarray, u: np.ndarray, v: np.ndarray, shape: tuple[int, ...]
261
418
  ) -> tuple[int, int, int, int] | None:
@@ -529,25 +686,31 @@ def visual_hull(
529
686
  ) -> tuple[list[np.ndarray], np.ndarray, list[tuple[np.ndarray, np.ndarray, np.ndarray]]]:
530
687
  """First silhouettes (GrabCut in a box around where the object projects),
531
688
  the voxel grid of the cube center +/- radius and its projection in every photo."""
532
- masks: list[np.ndarray] = []
689
+ rects: list[tuple[int, int, int, int] | None] = []
533
690
  for i, ((small, scale), view) in enumerate(zip(images, views, strict=True)):
534
691
  if boxes is not None and boxes[i] is not None:
535
- fg = _grabcut(small, np.zeros(small.shape[:2], np.uint8), boxes[i])
536
- masks.append(cv2.morphologyEx(fg, cv2.MORPH_CLOSE, np.ones((7, 7), np.uint8)))
692
+ rects.append(boxes[i])
537
693
  continue
538
694
  uv, z = _project(center[None], view)
539
695
  if z[0] <= 0:
540
- masks.append(np.zeros(small.shape[:2], np.uint8))
696
+ rects.append(None)
541
697
  continue
542
698
  cu, cv_ = uv[0] * scale
543
699
  half = view.camera.fx * scale * 0.6 * radius / z[0]
544
700
  x0, y0 = max(0, int(cu - half)), max(0, int(cv_ - 1.6 * half))
545
701
  x1 = min(small.shape[1] - 1, int(cu + half))
546
702
  y1 = min(small.shape[0] - 1, int(cv_ + 1.6 * half))
547
- init = np.zeros(small.shape[:2], np.uint8)
548
- rect = (x0, y0, max(2, x1 - x0), max(2, y1 - y0))
549
- fg = _grabcut(small, init, rect)
550
- masks.append(cv2.morphologyEx(fg, cv2.MORPH_CLOSE, np.ones((7, 7), np.uint8)))
703
+ rects.append((x0, y0, max(2, x1 - x0), max(2, y1 - y0)))
704
+
705
+ def cut(small: np.ndarray, rect: tuple[int, int, int, int] | None) -> np.ndarray:
706
+ if rect is None:
707
+ return np.zeros(small.shape[:2], np.uint8)
708
+ fg = _coarse_grabcut(small, rect)
709
+ return cv2.morphologyEx(fg, cv2.MORPH_CLOSE, np.ones((7, 7), np.uint8))
710
+
711
+ masks = _parallel(
712
+ [partial(cut, small, rect) for (small, _), rect in zip(images, rects, strict=True)]
713
+ )
551
714
 
552
715
  axis = np.linspace(-radius, radius, n)
553
716
  gx, gy, gz = np.meshgrid(axis, axis, axis, indexing="ij")
@@ -555,8 +718,10 @@ def visual_hull(
555
718
  projections = []
556
719
  for (small, scale), view in zip(images, views, strict=True):
557
720
  uv, z = _project(grid, view)
558
- u = np.round(uv[:, 0] * scale).astype(np.int64)
559
- v = np.round(uv[:, 1] * scale).astype(np.int64)
721
+ # int16 pixel coordinates (work images are at most 640 px): 4x less memory
722
+ # than int64 for millions of voxels times hundreds of photos.
723
+ u = np.clip(np.round(uv[:, 0] * scale), -1, _PIXEL_MAX).astype(np.int16)
724
+ v = np.clip(np.round(uv[:, 1] * scale), -1, _PIXEL_MAX).astype(np.int16)
560
725
  inside = (z > 0) & (u >= 0) & (v >= 0) & (u < small.shape[1]) & (v < small.shape[0])
561
726
  projections.append((u, v, inside))
562
727
 
@@ -592,7 +757,12 @@ def visual_hull(
592
757
  if core is not None and core_cameras is not None:
593
758
  try:
594
759
  binary = [np.where(m > 0, 255, 0).astype(np.uint8) for m in mask_list]
595
- prob, core_weights = core.fuse_cameras(core_cameras, binary, grid)
760
+ try: # reverse3d-core >= 0.2: coarse-to-fine on the grid
761
+ prob, core_weights = core.fuse_cameras(
762
+ core_cameras, binary, grid, grid_shape=(n, n, n)
763
+ )
764
+ except TypeError:
765
+ prob, core_weights = core.fuse_cameras(core_cameras, binary, grid)
596
766
  return np.asarray(prob) > 0.5
597
767
  except Exception as exc: # never fail a job because of the optional engine
598
768
  logger.warning("reverse3d-core failed (%s); using the open method", exc)
@@ -629,6 +799,7 @@ def visual_hull(
629
799
  center = (occupied.min(axis=0) + occupied.max(axis=0)) / 2
630
800
  radius *= 1.5
631
801
  logger.info("Object reaches the carving volume's edge: re-centred and enlarged")
802
+ projections.clear() # free the previous projections first (gigabytes)
632
803
  masks, grid, projections = frame(center, radius)
633
804
  keep = carve(masks, 2)
634
805
  # The first boxes come from the cameras' convergence point and can crop the object
@@ -640,6 +811,7 @@ def visual_hull(
640
811
  _box_around(keep & inside, u, v, small.shape)
641
812
  for (u, v, inside), (small, _) in zip(projections, images, strict=True)
642
813
  ]
814
+ projections.clear() # free the previous projections first (gigabytes)
643
815
  masks, grid, projections = frame(center, radius, boxes)
644
816
  keep = carve(masks, 2)
645
817
  if int(keep.sum()) < 1.05 * before:
@@ -675,23 +847,30 @@ def visual_hull(
675
847
  masks = ai_masks
676
848
  keep = carve(masks, 1)
677
849
  refinements = 0
850
+
851
+ def refine(
852
+ small: np.ndarray, u: np.ndarray, v: np.ndarray, inside: np.ndarray, old: np.ndarray
853
+ ) -> np.ndarray:
854
+ hull: np.ndarray = np.zeros(small.shape[:2], np.uint8)
855
+ sel = keep & inside
856
+ hull[v[sel], u[sel]] = 255
857
+ hull = cv2.morphologyEx(hull, cv2.MORPH_CLOSE, np.ones((9, 9), np.uint8))
858
+ if not hull.any():
859
+ return old
860
+ init = np.full(small.shape[:2], cv2.GC_BGD, np.uint8)
861
+ init[cv2.dilate(hull, np.ones((25, 25), np.uint8)) > 0] = cv2.GC_PR_BGD
862
+ init[hull > 0] = cv2.GC_PR_FGD
863
+ init[cv2.erode(hull, np.ones((21, 21), np.uint8)) > 0] = cv2.GC_FGD
864
+ fg = _grabcut(small, init, None)
865
+ return cv2.morphologyEx(fg, cv2.MORPH_OPEN, np.ones((5, 5), np.uint8))
866
+
678
867
  for _ in range(refinements):
679
- refined: list[np.ndarray] = []
680
- for (small, _), (u, v, inside), old in zip(images, projections, masks, strict=True):
681
- hull: np.ndarray = np.zeros(small.shape[:2], np.uint8)
682
- sel = keep & inside
683
- hull[v[sel], u[sel]] = 255
684
- hull = cv2.morphologyEx(hull, cv2.MORPH_CLOSE, np.ones((9, 9), np.uint8))
685
- if not hull.any():
686
- refined.append(old)
687
- continue
688
- init = np.full(small.shape[:2], cv2.GC_BGD, np.uint8)
689
- init[cv2.dilate(hull, np.ones((25, 25), np.uint8)) > 0] = cv2.GC_PR_BGD
690
- init[hull > 0] = cv2.GC_PR_FGD
691
- init[cv2.erode(hull, np.ones((21, 21), np.uint8)) > 0] = cv2.GC_FGD
692
- fg = _grabcut(small, init, None)
693
- refined.append(cv2.morphologyEx(fg, cv2.MORPH_OPEN, np.ones((5, 5), np.uint8)))
694
- masks = refined
868
+ masks = _parallel(
869
+ [
870
+ partial(refine, small, u, v, inside, old)
871
+ for (small, _), (u, v, inside), old in zip(images, projections, masks, strict=True)
872
+ ]
873
+ )
695
874
  keep = carve(masks, 1)
696
875
 
697
876
  # Concavities and through-holes (a handle loop, the inside of a cup) never
@@ -727,6 +906,7 @@ def visual_hull(
727
906
  normal, offset = plane
728
907
  height = (grid @ normal + offset).reshape(n, n, n)
729
908
  volume[height < 0.015 * radius] = 0
909
+ volume = _complete_base(volume, grid, normal, offset, 2 * radius / (n - 1))
730
910
  # Drop structures thinner than a couple of voxels (stray spikes from a few views).
731
911
  volume = ndimage.binary_opening(volume > 0.5, iterations=2).astype(np.float32)
732
912
  labels, count = ndimage.label(volume > 0.5)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reverse3d
3
- Version: 0.11.1
3
+ Version: 0.11.2
4
4
  Summary: AI-agent-ready toolkit for 3D reverse engineering: image analysis, photogrammetry, mesh repair, primitive fitting and headless rendering.
5
5
  Author-email: Voxel Enterprise <info@voxel.lat>
6
6
  Maintainer-email: Voxel Enterprise <info@voxel.lat>
@@ -364,3 +364,60 @@ def test_a_wall_through_the_object_is_not_taken_for_the_table() -> None:
364
364
  normal, offset = plane
365
365
  assert abs(normal[2]) > 0.99 # the table, not the wall
366
366
  assert np.mean(obj @ normal + offset < 0) < 0.05
367
+
368
+
369
+ def test_a_sparse_slatted_table_under_a_tall_object_is_found() -> None:
370
+ from reverse3d.reconstruction.silhouette import _support_plane
371
+
372
+ rng = np.random.default_rng(1)
373
+ # Few points on an uneven, slatted table top (z ~ 0 +- 0.03), far below the centre.
374
+ slats = rng.uniform(-1.5, 1.5, (60, 2))
375
+ table = np.column_stack([slats, rng.normal(0.0, 0.015, 60)])
376
+ # Many points on a wall and on the top of the object (its cap).
377
+ wall = np.column_stack([np.full(600, 1.2), rng.uniform(-2, 2, 600), rng.uniform(0, 4, 600)])
378
+ cap = np.column_stack([rng.uniform(-0.2, 0.2, (300, 2)), np.full(300, 3.4)])
379
+ obj = np.column_stack([rng.uniform(-0.4, 0.4, (4000, 2)), rng.uniform(0.0, 3.4, 4000)])
380
+ cameras = np.array([[4.0, 0.0, 2.0], [-4.0, 0.5, 3.5], [0.0, 4.0, 1.0], [0.5, -4.0, 4.5]])
381
+ center = np.array([0.0, 0.0, 2.6]) # pulled up by close-ups of the cap
382
+ up = np.array([0.0, 0.0, 1.0])
383
+ plane = _support_plane(np.vstack([table, wall, cap]), center, 1.0, cameras, obj, up)
384
+ assert plane is not None
385
+ normal, offset = plane
386
+ assert normal[2] > 0.99
387
+ assert abs(offset) < 0.05 # the table top at z = 0
388
+
389
+
390
+ def _voxels(n: int = 60) -> tuple[np.ndarray, float]:
391
+ axis = np.linspace(-1.5, 1.5, n)
392
+ gx, gy, gz = np.meshgrid(axis, axis, axis + 1.4, indexing="ij")
393
+ return np.stack([gx.ravel(), gy.ravel(), gz.ravel()], axis=1), float(axis[1] - axis[0])
394
+
395
+
396
+ def test_a_bitten_base_is_completed_down_to_the_table() -> None:
397
+ from reverse3d.reconstruction.silhouette import _complete_base
398
+
399
+ grid, voxel = _voxels()
400
+ r = np.hypot(grid[:, 0], grid[:, 1])
401
+ bottle = (r < 0.5) & (grid[:, 2] >= 0) & (grid[:, 2] < 2.5)
402
+ bitten = bottle & ~((grid[:, 0] > 0) & (grid[:, 2] < 0.25)) # half the base missing
403
+ volume = bitten.astype(np.float32).reshape(60, 60, 60)
404
+ out = _complete_base(volume, grid, np.array([0.0, 0.0, 1.0]), 0.0, voxel).reshape(-1) > 0.5
405
+ base = bottle & (grid[:, 2] < 0.25)
406
+ assert out[base].mean() > 0.95 # the base is whole again
407
+ assert not (out & ~bottle).any() # nothing added outside the object
408
+
409
+
410
+ def test_legs_stay_legs_when_the_base_is_completed() -> None:
411
+ from reverse3d.reconstruction.silhouette import _complete_base
412
+
413
+ grid, voxel = _voxels()
414
+ x, y, z = grid.T
415
+ legs = (
416
+ ((np.abs(np.abs(x) - 0.8) < 0.12) & (np.abs(np.abs(y) - 0.8) < 0.12)) & (z >= 0) & (z < 1.0)
417
+ )
418
+ top = (np.abs(x) < 1.0) & (np.abs(y) < 1.0) & (z >= 1.0) & (z < 1.2)
419
+ table = legs | top
420
+ volume = table.astype(np.float32).reshape(60, 60, 60)
421
+ out = _complete_base(volume, grid, np.array([0.0, 0.0, 1.0]), 0.0, voxel).reshape(-1) > 0.5
422
+ between = (np.abs(x) < 0.4) & (np.abs(y) < 0.4) & (z < 0.5)
423
+ assert not out[between].any()
@@ -1 +0,0 @@
1
- __version__ = "0.11.1"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes