reverse3d 0.8.1__tar.gz → 0.9.0__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 (79) hide show
  1. {reverse3d-0.8.1/reverse3d.egg-info → reverse3d-0.9.0}/PKG-INFO +1 -1
  2. reverse3d-0.9.0/reverse3d/_version.py +1 -0
  3. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/recipes.py +34 -6
  4. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/reconstruction/silhouette.py +115 -1
  5. {reverse3d-0.8.1 → reverse3d-0.9.0/reverse3d.egg-info}/PKG-INFO +1 -1
  6. reverse3d-0.9.0/tests/test_silhouette.py +284 -0
  7. reverse3d-0.8.1/reverse3d/_version.py +0 -1
  8. reverse3d-0.8.1/tests/test_silhouette.py +0 -158
  9. {reverse3d-0.8.1 → reverse3d-0.9.0}/LICENSE +0 -0
  10. {reverse3d-0.8.1 → reverse3d-0.9.0}/NOTICE +0 -0
  11. {reverse3d-0.8.1 → reverse3d-0.9.0}/README.md +0 -0
  12. {reverse3d-0.8.1 → reverse3d-0.9.0}/pyproject.toml +0 -0
  13. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/__init__.py +0 -0
  14. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/__main__.py +0 -0
  15. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/_about.py +0 -0
  16. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/agent/__init__.py +0 -0
  17. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/agent/compact.py +0 -0
  18. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/agent/mcp_server.py +0 -0
  19. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/agent/prompts.py +0 -0
  20. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/agent/skill/SKILL.md +0 -0
  21. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/agent/tools.py +0 -0
  22. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/autopilot.py +0 -0
  23. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/cli.py +0 -0
  24. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/config.py +0 -0
  25. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/doctor.py +0 -0
  26. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/errors.py +0 -0
  27. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/hardware.py +0 -0
  28. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/imaging.py +0 -0
  29. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/logging_utils.py +0 -0
  30. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/mesh_io.py +0 -0
  31. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/offline.py +0 -0
  32. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/profiles.py +0 -0
  33. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/py.typed +0 -0
  34. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/reconstruction/__init__.py +0 -0
  35. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/reconstruction/depth_estimation.py +0 -0
  36. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/reconstruction/photogrammetry.py +0 -0
  37. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/reconstruction/pointcloud.py +0 -0
  38. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/rendering/__init__.py +0 -0
  39. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/rendering/_blender_job.py +0 -0
  40. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/rendering/blender_render.py +0 -0
  41. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/rendering/preview.py +0 -0
  42. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/reverse_eng/__init__.py +0 -0
  43. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/reverse_eng/geometry_fit.py +0 -0
  44. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/reverse_eng/measure.py +0 -0
  45. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/reverse_eng/mesh_cleaner.py +0 -0
  46. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/reverse_eng/parametric.py +0 -0
  47. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/scenes.py +0 -0
  48. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/security.py +0 -0
  49. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/setup_agents.py +0 -0
  50. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/userconfig.py +0 -0
  51. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/vision/__init__.py +0 -0
  52. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/vision/classifier.py +0 -0
  53. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/vision/feature_matcher.py +0 -0
  54. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/vision/mask_extractor.py +0 -0
  55. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/vision/segmenter.py +0 -0
  56. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/watch.py +0 -0
  57. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d.egg-info/SOURCES.txt +0 -0
  58. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d.egg-info/dependency_links.txt +0 -0
  59. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d.egg-info/entry_points.txt +0 -0
  60. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d.egg-info/requires.txt +0 -0
  61. {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d.egg-info/top_level.txt +0 -0
  62. {reverse3d-0.8.1 → reverse3d-0.9.0}/setup.cfg +0 -0
  63. {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_agent_tools.py +0 -0
  64. {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_authorship.py +0 -0
  65. {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_automation.py +0 -0
  66. {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_cli.py +0 -0
  67. {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_cli_automation.py +0 -0
  68. {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_color.py +0 -0
  69. {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_config.py +0 -0
  70. {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_geometry_fit.py +0 -0
  71. {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_heic.py +0 -0
  72. {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_mesh.py +0 -0
  73. {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_offline_autopilot.py +0 -0
  74. {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_optional_extras.py +0 -0
  75. {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_reconstruction.py +0 -0
  76. {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_render.py +0 -0
  77. {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_scenes.py +0 -0
  78. {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_security.py +0 -0
  79. {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_vision.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reverse3d
3
- Version: 0.8.1
3
+ Version: 0.9.0
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.9.0"
@@ -340,6 +340,18 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
340
340
  )
341
341
  return None
342
342
 
343
+ def load_depth() -> Callable[[np.ndarray], np.ndarray] | None:
344
+ # AI depth reveals concavities and holes (cup insides, handle loops).
345
+ from reverse3d.reconstruction.depth_estimation import predict_depth
346
+
347
+ device = torch_device(run.settings)
348
+ try:
349
+ predict_depth(np.zeros((32, 32, 3), np.uint8), device=device, settings=run.settings)
350
+ except Reverse3DError as exc:
351
+ logger.info("AI depth unavailable (%s); concavities stay filled", exc.message)
352
+ return None
353
+ return lambda rgb: predict_depth(rgb, device=device, settings=run.settings)
354
+
343
355
  try:
344
356
  hull = run.step(
345
357
  "silhouette",
@@ -348,6 +360,7 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
348
360
  Path(photo.sparse_text_dir or ""),
349
361
  run.out / "reconstruction" / "object_hull.ply",
350
362
  segmenter=load_segmenter,
363
+ depth=load_depth,
351
364
  ),
352
365
  )
353
366
  except ProcessingError as exc:
@@ -374,14 +387,29 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
374
387
  hint='Install the meshing extra: pip install "reverse3d[recon]" (Python ≤ 3.12).',
375
388
  )
376
389
  cloud = photo.dense_points_path or photo.sparse_points_path
377
- if not upright and cloud:
390
+ if not upright:
391
+ # Dense scenes: colour from the photos themselves (sharp, independent of how
392
+ # dense the cloud is); the cloud's colours are the fallback.
393
+ colored_path = run.out / "reconstruction" / "mesh_color.ply"
394
+ colored: Path | None = None
378
395
  try:
379
- colored = _color_from_cloud(
380
- Path(mesh_path), Path(cloud), run.out / "reconstruction" / "mesh_color.ply"
381
- )
382
- mesh_path = str(colored) if colored else mesh_path
396
+ from reverse3d.reconstruction.silhouette import colorize_from_photos
397
+
398
+ if photo.images_dir and photo.sparse_text_dir:
399
+ colored = colorize_from_photos(
400
+ Path(mesh_path),
401
+ Path(photo.images_dir),
402
+ Path(photo.sparse_text_dir),
403
+ colored_path,
404
+ )
383
405
  except Exception as exc: # colour is a bonus
384
- logger.info("Colouring from the point cloud skipped: %s", exc)
406
+ logger.info("Colouring from the photos skipped: %s", exc)
407
+ if colored is None and cloud:
408
+ try:
409
+ colored = _color_from_cloud(Path(mesh_path), Path(cloud), colored_path)
410
+ except Exception as exc: # colour is a bonus
411
+ logger.info("Colouring from the point cloud skipped: %s", exc)
412
+ mesh_path = str(colored) if colored else mesh_path
385
413
  model = run.out / "model.stl"
386
414
  _clean(
387
415
  run,
@@ -69,6 +69,7 @@ class HullResult:
69
69
  faces: int
70
70
  support_plane: bool
71
71
  ai_segmentation: bool = False
72
+ depth_carved_voxels: int = 0
72
73
 
73
74
  def to_dict(self) -> dict[str, object]:
74
75
  return asdict(self)
@@ -250,6 +251,102 @@ def _vertex_colors(
250
251
  return colors
251
252
 
252
253
 
254
+ def _fit_inverse_depth(disparity: np.ndarray, depth: np.ndarray) -> tuple[float, float] | None:
255
+ """Robust fit 1/depth ≈ s·disparity + t (relative AI disparity → metric-like depth)."""
256
+ inv = 1.0 / depth
257
+ keep = np.ones(len(inv), bool)
258
+ for _ in range(3):
259
+ if keep.sum() < 50:
260
+ return None
261
+ a = np.column_stack([disparity[keep], np.ones(int(keep.sum()))])
262
+ (s, t), *_ = np.linalg.lstsq(a, inv[keep], rcond=None)
263
+ residual = np.abs(s * disparity + t - inv)
264
+ mad = float(np.median(residual[keep])) or 1e-9
265
+ keep = residual < 3 * 1.4826 * mad
266
+ return (float(s), float(t)) if s > 0 else None
267
+
268
+
269
+ def _depth_carve(
270
+ keep: np.ndarray,
271
+ grid: np.ndarray,
272
+ views: list[_View],
273
+ images: list[tuple[np.ndarray, float]],
274
+ projections: list[tuple[np.ndarray, np.ndarray, np.ndarray]],
275
+ masks: list[np.ndarray],
276
+ estimator: Callable[[np.ndarray], np.ndarray],
277
+ *,
278
+ tolerance: float = 0.05,
279
+ min_votes: int = 3,
280
+ min_share: float = 0.5,
281
+ ) -> np.ndarray:
282
+ """Empty the voxels that sit clearly in front of the surface the AI depth sees,
283
+ in at least ``min_votes`` photos *and* in most (``min_share``) of the photos
284
+ that actually see them. A photo where the voxel lies on the surface votes
285
+ solid; one where it is hidden behind the surface abstains. A few wrong depth
286
+ maps therefore cannot carve real material.
287
+
288
+ The relative AI depth is calibrated per photo against the carved hull itself
289
+ (a robust fit on the object's pixels), so only real disagreements carve.
290
+ """
291
+ votes = np.zeros(len(grid), np.int32)
292
+ seen = np.zeros(len(grid), np.int32)
293
+ for (small, _), view, (u, v, inside), mask in zip(
294
+ images, views, projections, masks, strict=True
295
+ ):
296
+ sel = keep & inside
297
+ if sel.sum() < 100:
298
+ continue
299
+ h, w = small.shape[:2]
300
+ z = (grid[sel] @ view.rotation.T + view.translation)[:, 2]
301
+ us, vs = u[sel], v[sel]
302
+ # Depth of the hull's visible surface per pixel (z-buffer of kept voxels).
303
+ hull_depth = np.full((h, w), np.inf)
304
+ np.minimum.at(hull_depth, (vs, us), z)
305
+ object_px = (mask > 0) & np.isfinite(hull_depth)
306
+ if object_px.sum() < 100:
307
+ continue
308
+ disparity = np.asarray(estimator(cv2.cvtColor(small, cv2.COLOR_BGR2RGB)), np.float64)
309
+ if disparity.shape != (h, w):
310
+ disparity = cv2.resize(disparity, (w, h), interpolation=cv2.INTER_LINEAR)
311
+ fit = _fit_inverse_depth(disparity[object_px], hull_depth[object_px])
312
+ if fit is None:
313
+ continue
314
+ s, t = fit
315
+ with np.errstate(divide="ignore", invalid="ignore"):
316
+ observed = 1.0 / np.maximum(s * disparity + t, 1e-9)
317
+ # A voxel strictly in front of the observed surface, on an object pixel, is air.
318
+ in_mask = mask[vs, us] > 0
319
+ front = z < observed[vs, us] * (1.0 - tolerance)
320
+ on_surface = np.abs(z - observed[vs, us]) <= tolerance * observed[vs, us]
321
+ idx = np.flatnonzero(sel)
322
+ seen[idx[in_mask & (front | on_surface)]] += 1 # hidden voxels abstain
323
+ votes[idx[in_mask & front]] += 1
324
+ air = (votes >= min_votes) & (votes >= min_share * np.maximum(seen, 1))
325
+ return keep & ~air
326
+
327
+
328
+ def colorize_from_photos(
329
+ mesh_path: Path, image_dir: Path, model_text_dir: Path, output_path: Path
330
+ ) -> Path:
331
+ """Paint any mesh in COLMAP coordinates with the photos that see it (dense scenes)."""
332
+ views, _ = read_colmap_text(model_text_dir)
333
+ images: list[tuple[np.ndarray, float]] = []
334
+ for view in views:
335
+ bgr = cv2.imread(str(image_dir / view.name))
336
+ if bgr is None:
337
+ raise ProcessingError(f"Could not read {view.name} for colouring.")
338
+ h, w = bgr.shape[:2]
339
+ scale = 1280 / max(h, w)
340
+ size = (round(w * scale), round(h * scale))
341
+ images.append((cv2.resize(bgr, size, interpolation=cv2.INTER_AREA), scale))
342
+ mesh: trimesh.Trimesh = trimesh.load(mesh_path, process=False, force="mesh") # type: ignore[assignment]
343
+ mesh.visual = trimesh.visual.ColorVisuals(
344
+ mesh, vertex_colors=_vertex_colors(mesh, views, images)
345
+ )
346
+ mesh.export(output_path)
347
+ return output_path
348
+
349
+
253
350
  def visual_hull(
254
351
  image_dir: Path,
255
352
  model_text_dir: Path,
@@ -258,6 +355,7 @@ def visual_hull(
258
355
  resolution: int = 176,
259
356
  refinements: int = 3,
260
357
  segmenter: Callable[[], ObjectSegmenter | None] | None = None,
358
+ depth: Callable[[], Callable[[np.ndarray], np.ndarray] | None] | None = None,
261
359
  ) -> HullResult:
262
360
  """Carve the object seen by every photo; writes a watertight, upright mesh.
263
361
 
@@ -385,6 +483,16 @@ def visual_hull(
385
483
  masks = refined
386
484
  keep = carve(masks, 1)
387
485
 
486
+ # Concavities and through-holes (a handle loop, the inside of a cup) never
487
+ # show in a silhouette: the AI depth of each photo reveals them.
488
+ estimator = depth() if depth is not None else None
489
+ carved_by_depth = 0
490
+ if estimator is not None:
491
+ before = int(keep.sum())
492
+ keep = _depth_carve(keep, grid, views, images, projections, masks, estimator)
493
+ carved_by_depth = before - int(keep.sum())
494
+ logger.info("AI depth carved %d voxel(s) of concavities", carved_by_depth)
495
+
388
496
  volume = keep.reshape(n, n, n).astype(np.float32)
389
497
  plane = _support_plane(points, center, radius, cameras)
390
498
  if plane is not None:
@@ -438,5 +546,11 @@ def visual_hull(
438
546
  mesh.export(output_path)
439
547
  logger.info("Silhouette model: %d views, %d faces", len(views), len(mesh.faces))
440
548
  return HullResult(
441
- str(output_path), len(views), voxels, len(mesh.faces), plane is not None, ai is not None
549
+ str(output_path),
550
+ len(views),
551
+ voxels,
552
+ len(mesh.faces),
553
+ plane is not None,
554
+ ai is not None,
555
+ carved_by_depth,
442
556
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reverse3d
3
- Version: 0.8.1
3
+ Version: 0.9.0
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,284 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import cv2
6
+ import numpy as np
7
+ import pytest
8
+ import trimesh
9
+
10
+ pytest.importorskip("skimage")
11
+
12
+ from reverse3d.errors import ProcessingError
13
+ from reverse3d.reconstruction.silhouette import convergence_point, read_colmap_text, visual_hull
14
+
15
+ SIZE = 400
16
+ F = 420.0
17
+ RADIUS, HEIGHT = 0.35, 1.2 # the object: an upright cylinder standing on z = 0
18
+
19
+
20
+ def _look_at(eye: np.ndarray, target: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
21
+ """World→camera rotation and translation (COLMAP convention: +Z forward, +Y down)."""
22
+ forward = target - eye
23
+ forward /= np.linalg.norm(forward)
24
+ right = np.cross(forward, [0.0, 0.0, 1.0])
25
+ right /= np.linalg.norm(right)
26
+ down = np.cross(forward, right)
27
+ rot = np.stack([right, down, forward])
28
+ return rot, -rot @ eye
29
+
30
+
31
+ def _quat(rot: np.ndarray) -> list[float]:
32
+ return list(
33
+ trimesh.transformations.quaternion_from_matrix(
34
+ np.vstack([np.hstack([rot, [[0], [0], [0]]]), [0, 0, 0, 1]])
35
+ )
36
+ )
37
+
38
+
39
+ def _scene(folder: Path) -> tuple[Path, Path]:
40
+ images = folder / "images"
41
+ text = folder / "txt"
42
+ images.mkdir(parents=True)
43
+ text.mkdir()
44
+ rng = np.random.default_rng(0)
45
+ theta = np.linspace(0, 2 * np.pi, 64, endpoint=False)
46
+ rim = np.concatenate(
47
+ [
48
+ np.stack([RADIUS * np.cos(theta), RADIUS * np.sin(theta), np.full_like(theta, z)], 1)
49
+ for z in (0.0, HEIGHT)
50
+ ]
51
+ )
52
+ lines = []
53
+ k = 0
54
+ for height, count in ((0.6, 12), (2.2, 12), (3.4, 6)):
55
+ for i in range(count):
56
+ a = 2 * np.pi * i / count
57
+ eye = np.array([3.2 * np.cos(a), 3.2 * np.sin(a), height])
58
+ rot, t = _look_at(eye, np.array([0.0, 0.0, 0.6]))
59
+ # Textured background, then the white cylinder's silhouette.
60
+ image = rng.integers(0, 120, (SIZE, SIZE, 3), dtype=np.uint8)
61
+ image = cv2.GaussianBlur(image, (5, 5), 0)
62
+ cam = rim @ rot.T + t
63
+ uv = (cam[:, :2] / cam[:, 2:3]) * F + SIZE / 2
64
+ cv2.fillConvexPoly(image, cv2.convexHull(uv.astype(np.int32)), (235, 235, 235))
65
+ name = f"img_{k:02d}.jpg"
66
+ cv2.imwrite(str(images / name), image)
67
+ q = _quat(rot)
68
+ lines.append(f"{k + 1} {q[0]} {q[1]} {q[2]} {q[3]} {t[0]} {t[1]} {t[2]} 1 {name}\n\n")
69
+ k += 1
70
+ (text / "cameras.txt").write_text(f"1 PINHOLE {SIZE} {SIZE} {F} {F} {SIZE / 2} {SIZE / 2}\n")
71
+ (text / "images.txt").write_text("".join(lines))
72
+ table = np.column_stack([rng.uniform(-1, 1, 400), rng.uniform(-1, 1, 400), np.zeros(400)])
73
+ table = table[np.hypot(table[:, 0], table[:, 1]) > RADIUS + 0.05]
74
+ (text / "points3D.txt").write_text(
75
+ "".join(f"{i + 1} {x} {y} {z} 0 0 0 0\n" for i, (x, y, z) in enumerate(table))
76
+ )
77
+ return images, text
78
+
79
+
80
+ def test_cameras_converge_on_the_object(tmp_path: Path) -> None:
81
+ _, text = _scene(tmp_path)
82
+ views, points = read_colmap_text(text)
83
+ center, distance = convergence_point(views)
84
+ assert len(views) == 30 and len(points) > 100
85
+ assert np.linalg.norm(center - [0, 0, 0.6]) < 0.05
86
+ assert 3.0 < distance < 3.8
87
+
88
+
89
+ def test_visual_hull_rebuilds_an_upright_cylinder(tmp_path: Path) -> None:
90
+ images, text = _scene(tmp_path)
91
+ result = visual_hull(images, text, tmp_path / "hull.ply", resolution=80, refinements=1)
92
+ mesh = trimesh.load(result.mesh_path)
93
+ assert result.support_plane
94
+ assert mesh.is_watertight
95
+ width = (mesh.extents[0] + mesh.extents[1]) / 2
96
+ assert mesh.bounds[0][2] == pytest.approx(0.0, abs=1e-6)
97
+ # Height / diameter of the real cylinder is 1.2 / 0.7 ≈ 1.7.
98
+ assert 1.3 < mesh.extents[2] / width < 2.2
99
+
100
+
101
+ def test_too_few_photos(tmp_path: Path) -> None:
102
+ images, text = _scene(tmp_path)
103
+ rows = (text / "images.txt").read_text().split("\n\n")
104
+ (text / "images.txt").write_text("\n\n".join(rows[:5]) + "\n\n")
105
+ with pytest.raises(ProcessingError, match="at least"):
106
+ visual_hull(images, text, tmp_path / "hull.ply", resolution=48, refinements=0)
107
+
108
+
109
+ class _FakeSegmenter:
110
+ """Stands in for Segment Anything: the object is the bright part inside the box."""
111
+
112
+ def __init__(self) -> None:
113
+ self.calls = 0
114
+
115
+ def embed(self, rgb: np.ndarray) -> dict[str, np.ndarray]:
116
+ return {"rgb": rgb}
117
+
118
+ def segment(
119
+ self, state: dict[str, np.ndarray], box: tuple[int, int, int, int], point: tuple[int, int]
120
+ ) -> np.ndarray:
121
+ self.calls += 1
122
+ gray = state["rgb"].mean(axis=2)
123
+ mask = np.zeros(gray.shape, np.uint8)
124
+ x0, y0, x1, y1 = box
125
+ mask[y0 : y1 + 1, x0 : x1 + 1] = np.where(gray[y0 : y1 + 1, x0 : x1 + 1] > 200, 255, 0)
126
+ return mask
127
+
128
+
129
+ def test_ai_segmenter_is_loaded_lazily_and_used(tmp_path: Path) -> None:
130
+ images, text = _scene(tmp_path)
131
+ fake = _FakeSegmenter()
132
+ loads: list[int] = []
133
+
134
+ def factory() -> _FakeSegmenter:
135
+ loads.append(1)
136
+ return fake
137
+
138
+ result = visual_hull(
139
+ images,
140
+ text,
141
+ tmp_path / "hull.ply",
142
+ resolution=64,
143
+ refinements=1,
144
+ segmenter=factory, # type: ignore[arg-type]
145
+ )
146
+ assert loads == [1] and fake.calls == 2 * 30 # two AI rounds over the 30 photos
147
+ assert result.ai_segmentation
148
+ mesh = trimesh.load(result.mesh_path)
149
+ width = (mesh.extents[0] + mesh.extents[1]) / 2
150
+ assert mesh.is_watertight and 1.3 < mesh.extents[2] / width < 2.2
151
+
152
+
153
+ def test_without_ai_segmenter_grabcut_is_used(tmp_path: Path) -> None:
154
+ images, text = _scene(tmp_path)
155
+ result = visual_hull(
156
+ images, text, tmp_path / "hull.ply", resolution=48, refinements=1, segmenter=lambda: None
157
+ )
158
+ assert not result.ai_segmentation
159
+
160
+
161
+ def _cup() -> trimesh.Trimesh:
162
+ """An open-top box: its silhouette is a solid block from every side."""
163
+ parts = [
164
+ trimesh.creation.box(
165
+ extents=(0.8, 0.8, 0.12),
166
+ transform=trimesh.transformations.translation_matrix((0, 0, 0.06)),
167
+ ),
168
+ trimesh.creation.box(
169
+ extents=(0.12, 0.8, 1.0),
170
+ transform=trimesh.transformations.translation_matrix((0.34, 0, 0.5)),
171
+ ),
172
+ trimesh.creation.box(
173
+ extents=(0.12, 0.8, 1.0),
174
+ transform=trimesh.transformations.translation_matrix((-0.34, 0, 0.5)),
175
+ ),
176
+ trimesh.creation.box(
177
+ extents=(0.8, 0.12, 1.0),
178
+ transform=trimesh.transformations.translation_matrix((0, 0.34, 0.5)),
179
+ ),
180
+ trimesh.creation.box(
181
+ extents=(0.8, 0.12, 1.0),
182
+ transform=trimesh.transformations.translation_matrix((0, -0.34, 0.5)),
183
+ ),
184
+ ]
185
+ return trimesh.util.concatenate(parts)
186
+
187
+
188
+ def _ray_boxes(origin: np.ndarray, dirs: np.ndarray, boxes: list[np.ndarray]) -> np.ndarray:
189
+ """Distance along each ray to the nearest axis-aligned box (inf when missed)."""
190
+ best = np.full(len(dirs), np.inf)
191
+ with np.errstate(divide="ignore", invalid="ignore"):
192
+ inv = 1.0 / dirs
193
+ for lo, hi in boxes:
194
+ t1, t2 = (lo - origin) * inv, (hi - origin) * inv
195
+ near = np.nanmax(np.minimum(t1, t2), axis=1)
196
+ far = np.nanmin(np.maximum(t1, t2), axis=1)
197
+ hit = (near <= far) & (far > 0)
198
+ best = np.where(hit & (near < best), np.maximum(near, 0), best)
199
+ return best
200
+
201
+
202
+ def _cup_scene(
203
+ folder: Path, size: int = 120, focal: float = 130.0
204
+ ) -> tuple[Path, Path, list[tuple[np.ndarray, np.ndarray]]]:
205
+ """Photos of the cup with known cameras, plus the true AI-like disparity of each."""
206
+ images, text = folder / "images", folder / "txt"
207
+ images.mkdir(parents=True)
208
+ text.mkdir()
209
+ boxes = [part.bounds for part in _cup().split(only_watertight=False)]
210
+ rng = np.random.default_rng(1)
211
+ jj, ii = np.meshgrid(np.arange(size), np.arange(size))
212
+ rays_cam = np.stack(
213
+ [(jj - size / 2) / focal, (ii - size / 2) / focal, np.ones_like(jj, float)], -1
214
+ ).reshape(-1, 3)
215
+ lines: list[str] = []
216
+ depth_by_image: list[tuple[np.ndarray, np.ndarray]] = []
217
+ k = 0
218
+ for height, count in ((0.7, 10), (2.6, 10), (3.6, 6)):
219
+ for i in range(count):
220
+ a = 2 * np.pi * i / count
221
+ eye = np.array([2.6 * np.cos(a), 2.6 * np.sin(a), height])
222
+ rot, t = _look_at(eye, np.array([0.0, 0.0, 0.5]))
223
+ dirs = rays_cam @ rot # camera → world
224
+ dirs /= np.linalg.norm(dirs, axis=1, keepdims=True)
225
+ depth = _ray_boxes(eye, dirs, boxes) * (dirs @ rot[2])
226
+ depth = depth.reshape(size, size)
227
+ image = cv2.GaussianBlur(
228
+ rng.integers(0, 110, (size, size, 3), dtype=np.uint8), (3, 3), 0
229
+ )
230
+ image[np.isfinite(depth)] = (235, 235, 235)
231
+ name = f"img_{k:02d}.png"
232
+ cv2.imwrite(str(images / name), image)
233
+ far = float(np.nanmax(np.where(np.isfinite(depth), depth, np.nan))) * 2
234
+ disparity = 1.0 / np.where(np.isfinite(depth), depth, far)
235
+ depth_by_image.append((cv2.imread(str(images / name)).astype(np.float32), disparity))
236
+ q = _quat(rot)
237
+ lines.append(f"{k + 1} {q[0]} {q[1]} {q[2]} {q[3]} {t[0]} {t[1]} {t[2]} 1 {name}\n\n")
238
+ k += 1
239
+ (text / "cameras.txt").write_text(
240
+ f"1 PINHOLE {size} {size} {focal} {focal} {size / 2} {size / 2}\n"
241
+ )
242
+ (text / "images.txt").write_text("".join(lines))
243
+ table = np.column_stack([rng.uniform(-1, 1, 400), rng.uniform(-1, 1, 400), np.zeros(400)])
244
+ table = table[(np.abs(table[:, 0]) > 0.45) | (np.abs(table[:, 1]) > 0.45)]
245
+ rows = (f"{i + 1} {x} {y} {z} 0 0 0 0\n" for i, (x, y, z) in enumerate(table))
246
+ (text / "points3D.txt").write_text("".join(rows))
247
+ return images, text, depth_by_image
248
+
249
+
250
+ def test_ai_depth_carves_the_inside_of_a_cup(tmp_path: Path) -> None:
251
+ images, text, truth = _cup_scene(tmp_path)
252
+
253
+ def estimator(rgb: np.ndarray) -> np.ndarray:
254
+ # The hull hands over its (resized) copy of a photo: find which one it is
255
+ # and return that photo's true disparity, like a perfect depth AI would.
256
+ small = cv2.resize(cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR), truth[0][0].shape[1::-1])
257
+ best = min(truth, key=lambda item: float(np.abs(item[0] - small).mean()))
258
+ return cv2.resize(best[1], (rgb.shape[1], rgb.shape[0]), interpolation=cv2.INTER_NEAREST)
259
+
260
+ plain = visual_hull(images, text, tmp_path / "plain.ply", resolution=64, refinements=1)
261
+ carved = visual_hull(
262
+ images, text, tmp_path / "carved.ply", resolution=64, refinements=1, depth=lambda: estimator
263
+ )
264
+ assert carved.depth_carved_voxels > 0
265
+ solid = trimesh.load(plain.mesh_path)
266
+ cup = trimesh.load(carved.mesh_path)
267
+ # The silhouettes alone fill the cup; the AI depth empties a good part of it.
268
+ assert cup.volume < 0.8 * solid.volume
269
+ assert cup.is_watertight
270
+
271
+
272
+ def test_any_mesh_is_coloured_from_the_photos(tmp_path: Path) -> None:
273
+ from reverse3d.reconstruction.silhouette import colorize_from_photos
274
+
275
+ images, text = _scene(tmp_path)
276
+ mesh = trimesh.creation.cylinder(radius=RADIUS * 0.98, height=HEIGHT * 0.98, sections=48)
277
+ mesh = mesh.subdivide().subdivide() # vertices along the sides, not only at the caps
278
+ mesh.apply_translation([0, 0, HEIGHT / 2])
279
+ plain = tmp_path / "cylinder.ply"
280
+ mesh.export(plain)
281
+ out = colorize_from_photos(plain, images, text, tmp_path / "colour.ply")
282
+ colours = np.asarray(trimesh.load(out, process=False).visual.vertex_colors)[:, :3]
283
+ side = np.abs(mesh.vertices[:, 2] - HEIGHT / 2) < HEIGHT * 0.4
284
+ assert colours[side].mean() > 200 # the photographed cylinder is white
@@ -1 +0,0 @@
1
- __version__ = "0.8.1"
@@ -1,158 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from pathlib import Path
4
-
5
- import cv2
6
- import numpy as np
7
- import pytest
8
- import trimesh
9
-
10
- pytest.importorskip("skimage")
11
-
12
- from reverse3d.errors import ProcessingError
13
- from reverse3d.reconstruction.silhouette import convergence_point, read_colmap_text, visual_hull
14
-
15
- SIZE = 400
16
- F = 420.0
17
- RADIUS, HEIGHT = 0.35, 1.2 # the object: an upright cylinder standing on z = 0
18
-
19
-
20
- def _look_at(eye: np.ndarray, target: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
21
- """World→camera rotation and translation (COLMAP convention: +Z forward, +Y down)."""
22
- forward = target - eye
23
- forward /= np.linalg.norm(forward)
24
- right = np.cross(forward, [0.0, 0.0, 1.0])
25
- right /= np.linalg.norm(right)
26
- down = np.cross(forward, right)
27
- rot = np.stack([right, down, forward])
28
- return rot, -rot @ eye
29
-
30
-
31
- def _quat(rot: np.ndarray) -> list[float]:
32
- return list(
33
- trimesh.transformations.quaternion_from_matrix(
34
- np.vstack([np.hstack([rot, [[0], [0], [0]]]), [0, 0, 0, 1]])
35
- )
36
- )
37
-
38
-
39
- def _scene(folder: Path) -> tuple[Path, Path]:
40
- images = folder / "images"
41
- text = folder / "txt"
42
- images.mkdir(parents=True)
43
- text.mkdir()
44
- rng = np.random.default_rng(0)
45
- theta = np.linspace(0, 2 * np.pi, 64, endpoint=False)
46
- rim = np.concatenate(
47
- [
48
- np.stack([RADIUS * np.cos(theta), RADIUS * np.sin(theta), np.full_like(theta, z)], 1)
49
- for z in (0.0, HEIGHT)
50
- ]
51
- )
52
- lines = []
53
- k = 0
54
- for height, count in ((0.6, 12), (2.2, 12), (3.4, 6)):
55
- for i in range(count):
56
- a = 2 * np.pi * i / count
57
- eye = np.array([3.2 * np.cos(a), 3.2 * np.sin(a), height])
58
- rot, t = _look_at(eye, np.array([0.0, 0.0, 0.6]))
59
- # Textured background, then the white cylinder's silhouette.
60
- image = rng.integers(0, 120, (SIZE, SIZE, 3), dtype=np.uint8)
61
- image = cv2.GaussianBlur(image, (5, 5), 0)
62
- cam = rim @ rot.T + t
63
- uv = (cam[:, :2] / cam[:, 2:3]) * F + SIZE / 2
64
- cv2.fillConvexPoly(image, cv2.convexHull(uv.astype(np.int32)), (235, 235, 235))
65
- name = f"img_{k:02d}.jpg"
66
- cv2.imwrite(str(images / name), image)
67
- q = _quat(rot)
68
- lines.append(f"{k + 1} {q[0]} {q[1]} {q[2]} {q[3]} {t[0]} {t[1]} {t[2]} 1 {name}\n\n")
69
- k += 1
70
- (text / "cameras.txt").write_text(f"1 PINHOLE {SIZE} {SIZE} {F} {F} {SIZE / 2} {SIZE / 2}\n")
71
- (text / "images.txt").write_text("".join(lines))
72
- table = np.column_stack([rng.uniform(-1, 1, 400), rng.uniform(-1, 1, 400), np.zeros(400)])
73
- table = table[np.hypot(table[:, 0], table[:, 1]) > RADIUS + 0.05]
74
- (text / "points3D.txt").write_text(
75
- "".join(f"{i + 1} {x} {y} {z} 0 0 0 0\n" for i, (x, y, z) in enumerate(table))
76
- )
77
- return images, text
78
-
79
-
80
- def test_cameras_converge_on_the_object(tmp_path: Path) -> None:
81
- _, text = _scene(tmp_path)
82
- views, points = read_colmap_text(text)
83
- center, distance = convergence_point(views)
84
- assert len(views) == 30 and len(points) > 100
85
- assert np.linalg.norm(center - [0, 0, 0.6]) < 0.05
86
- assert 3.0 < distance < 3.8
87
-
88
-
89
- def test_visual_hull_rebuilds_an_upright_cylinder(tmp_path: Path) -> None:
90
- images, text = _scene(tmp_path)
91
- result = visual_hull(images, text, tmp_path / "hull.ply", resolution=80, refinements=1)
92
- mesh = trimesh.load(result.mesh_path)
93
- assert result.support_plane
94
- assert mesh.is_watertight
95
- width = (mesh.extents[0] + mesh.extents[1]) / 2
96
- assert mesh.bounds[0][2] == pytest.approx(0.0, abs=1e-6)
97
- # Height / diameter of the real cylinder is 1.2 / 0.7 ≈ 1.7.
98
- assert 1.3 < mesh.extents[2] / width < 2.2
99
-
100
-
101
- def test_too_few_photos(tmp_path: Path) -> None:
102
- images, text = _scene(tmp_path)
103
- rows = (text / "images.txt").read_text().split("\n\n")
104
- (text / "images.txt").write_text("\n\n".join(rows[:5]) + "\n\n")
105
- with pytest.raises(ProcessingError, match="at least"):
106
- visual_hull(images, text, tmp_path / "hull.ply", resolution=48, refinements=0)
107
-
108
-
109
- class _FakeSegmenter:
110
- """Stands in for Segment Anything: the object is the bright part inside the box."""
111
-
112
- def __init__(self) -> None:
113
- self.calls = 0
114
-
115
- def embed(self, rgb: np.ndarray) -> dict[str, np.ndarray]:
116
- return {"rgb": rgb}
117
-
118
- def segment(
119
- self, state: dict[str, np.ndarray], box: tuple[int, int, int, int], point: tuple[int, int]
120
- ) -> np.ndarray:
121
- self.calls += 1
122
- gray = state["rgb"].mean(axis=2)
123
- mask = np.zeros(gray.shape, np.uint8)
124
- x0, y0, x1, y1 = box
125
- mask[y0 : y1 + 1, x0 : x1 + 1] = np.where(gray[y0 : y1 + 1, x0 : x1 + 1] > 200, 255, 0)
126
- return mask
127
-
128
-
129
- def test_ai_segmenter_is_loaded_lazily_and_used(tmp_path: Path) -> None:
130
- images, text = _scene(tmp_path)
131
- fake = _FakeSegmenter()
132
- loads: list[int] = []
133
-
134
- def factory() -> _FakeSegmenter:
135
- loads.append(1)
136
- return fake
137
-
138
- result = visual_hull(
139
- images,
140
- text,
141
- tmp_path / "hull.ply",
142
- resolution=64,
143
- refinements=1,
144
- segmenter=factory, # type: ignore[arg-type]
145
- )
146
- assert loads == [1] and fake.calls == 2 * 30 # two AI rounds over the 30 photos
147
- assert result.ai_segmentation
148
- mesh = trimesh.load(result.mesh_path)
149
- width = (mesh.extents[0] + mesh.extents[1]) / 2
150
- assert mesh.is_watertight and 1.3 < mesh.extents[2] / width < 2.2
151
-
152
-
153
- def test_without_ai_segmenter_grabcut_is_used(tmp_path: Path) -> None:
154
- images, text = _scene(tmp_path)
155
- result = visual_hull(
156
- images, text, tmp_path / "hull.ply", resolution=48, refinements=1, segmenter=lambda: None
157
- )
158
- assert not result.ai_segmentation
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes