reverse3d 0.8.0__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.0/reverse3d.egg-info → reverse3d-0.9.0}/PKG-INFO +5 -4
  2. {reverse3d-0.8.0 → reverse3d-0.9.0}/README.md +4 -3
  3. reverse3d-0.9.0/reverse3d/_version.py +1 -0
  4. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/recipes.py +94 -41
  5. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/reconstruction/silhouette.py +115 -1
  6. {reverse3d-0.8.0 → reverse3d-0.9.0/reverse3d.egg-info}/PKG-INFO +5 -4
  7. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d.egg-info/SOURCES.txt +1 -0
  8. reverse3d-0.9.0/tests/test_color.py +49 -0
  9. reverse3d-0.9.0/tests/test_silhouette.py +284 -0
  10. reverse3d-0.8.0/reverse3d/_version.py +0 -1
  11. reverse3d-0.8.0/tests/test_silhouette.py +0 -158
  12. {reverse3d-0.8.0 → reverse3d-0.9.0}/LICENSE +0 -0
  13. {reverse3d-0.8.0 → reverse3d-0.9.0}/NOTICE +0 -0
  14. {reverse3d-0.8.0 → reverse3d-0.9.0}/pyproject.toml +0 -0
  15. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/__init__.py +0 -0
  16. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/__main__.py +0 -0
  17. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/_about.py +0 -0
  18. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/agent/__init__.py +0 -0
  19. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/agent/compact.py +0 -0
  20. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/agent/mcp_server.py +0 -0
  21. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/agent/prompts.py +0 -0
  22. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/agent/skill/SKILL.md +0 -0
  23. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/agent/tools.py +0 -0
  24. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/autopilot.py +0 -0
  25. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/cli.py +0 -0
  26. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/config.py +0 -0
  27. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/doctor.py +0 -0
  28. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/errors.py +0 -0
  29. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/hardware.py +0 -0
  30. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/imaging.py +0 -0
  31. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/logging_utils.py +0 -0
  32. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/mesh_io.py +0 -0
  33. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/offline.py +0 -0
  34. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/profiles.py +0 -0
  35. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/py.typed +0 -0
  36. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/reconstruction/__init__.py +0 -0
  37. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/reconstruction/depth_estimation.py +0 -0
  38. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/reconstruction/photogrammetry.py +0 -0
  39. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/reconstruction/pointcloud.py +0 -0
  40. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/rendering/__init__.py +0 -0
  41. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/rendering/_blender_job.py +0 -0
  42. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/rendering/blender_render.py +0 -0
  43. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/rendering/preview.py +0 -0
  44. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/reverse_eng/__init__.py +0 -0
  45. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/reverse_eng/geometry_fit.py +0 -0
  46. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/reverse_eng/measure.py +0 -0
  47. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/reverse_eng/mesh_cleaner.py +0 -0
  48. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/reverse_eng/parametric.py +0 -0
  49. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/scenes.py +0 -0
  50. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/security.py +0 -0
  51. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/setup_agents.py +0 -0
  52. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/userconfig.py +0 -0
  53. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/vision/__init__.py +0 -0
  54. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/vision/classifier.py +0 -0
  55. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/vision/feature_matcher.py +0 -0
  56. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/vision/mask_extractor.py +0 -0
  57. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/vision/segmenter.py +0 -0
  58. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/watch.py +0 -0
  59. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d.egg-info/dependency_links.txt +0 -0
  60. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d.egg-info/entry_points.txt +0 -0
  61. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d.egg-info/requires.txt +0 -0
  62. {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d.egg-info/top_level.txt +0 -0
  63. {reverse3d-0.8.0 → reverse3d-0.9.0}/setup.cfg +0 -0
  64. {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_agent_tools.py +0 -0
  65. {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_authorship.py +0 -0
  66. {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_automation.py +0 -0
  67. {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_cli.py +0 -0
  68. {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_cli_automation.py +0 -0
  69. {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_config.py +0 -0
  70. {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_geometry_fit.py +0 -0
  71. {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_heic.py +0 -0
  72. {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_mesh.py +0 -0
  73. {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_offline_autopilot.py +0 -0
  74. {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_optional_extras.py +0 -0
  75. {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_reconstruction.py +0 -0
  76. {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_render.py +0 -0
  77. {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_scenes.py +0 -0
  78. {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_security.py +0 -0
  79. {reverse3d-0.8.0 → 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.0
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>
@@ -215,10 +215,11 @@ Cada archivo modificado se **respalda antes** (`*.reverse3d-backup-<fecha>`). So
215
215
 
216
216
  | bash (`install.sh`) | PowerShell (`install.ps1`) | Efecto |
217
217
  |---------------------|----------------------------|--------|
218
- | *(sin opciones)* | *(sin opciones)* | **Todo**: núcleo, MCP, render, mallado, IA de profundidad, Blender y COLMAP |
219
- | `--lite` | `-Lite` | Todo menos la IA de profundidad (PyTorch); ahorra hasta ~2.5 GB |
220
- | `--minimal` | `-Minimal` | Solo núcleo + MCP, sin herramientas externas |
218
+ | *(sin opciones)* | *(sin opciones)* | **Todo**: núcleo, MCP, render, mallado, IA local (con CUDA si hay NVIDIA), Blender y COLMAP |
219
+ | `--lite` | `-Lite` | Todo, con la IA local en la CPU (PyTorch CPU, ~250 MB en vez de ~2.5 GB) |
220
+ | `--minimal` | `-Minimal` | Núcleo + MCP + IA local, sin herramientas externas |
221
221
  | `--no-tools` | `-NoTools` | No instalar Blender ni COLMAP |
222
+ | *(en todos los modos)* | *(en todos los modos)* | **La IA local se instala y se descarga siempre** (clasificador, segmentación y profundidad, unos 750 MB) para que todo funcione sin internet |
222
223
  | `--no-auto-approve` | `-NoAutoApprove` | Que los agentes pregunten antes de cada herramienta |
223
224
  | `--with recon,depth` | `-With recon,depth` | Extras a elección |
224
225
  | `--device auto\|cpu\|gpu` | `-Device …` | Dispositivo por defecto |
@@ -147,10 +147,11 @@ Cada archivo modificado se **respalda antes** (`*.reverse3d-backup-<fecha>`). So
147
147
 
148
148
  | bash (`install.sh`) | PowerShell (`install.ps1`) | Efecto |
149
149
  |---------------------|----------------------------|--------|
150
- | *(sin opciones)* | *(sin opciones)* | **Todo**: núcleo, MCP, render, mallado, IA de profundidad, Blender y COLMAP |
151
- | `--lite` | `-Lite` | Todo menos la IA de profundidad (PyTorch); ahorra hasta ~2.5 GB |
152
- | `--minimal` | `-Minimal` | Solo núcleo + MCP, sin herramientas externas |
150
+ | *(sin opciones)* | *(sin opciones)* | **Todo**: núcleo, MCP, render, mallado, IA local (con CUDA si hay NVIDIA), Blender y COLMAP |
151
+ | `--lite` | `-Lite` | Todo, con la IA local en la CPU (PyTorch CPU, ~250 MB en vez de ~2.5 GB) |
152
+ | `--minimal` | `-Minimal` | Núcleo + MCP + IA local, sin herramientas externas |
153
153
  | `--no-tools` | `-NoTools` | No instalar Blender ni COLMAP |
154
+ | *(en todos los modos)* | *(en todos los modos)* | **La IA local se instala y se descarga siempre** (clasificador, segmentación y profundidad, unos 750 MB) para que todo funcione sin internet |
154
155
  | `--no-auto-approve` | `-NoAutoApprove` | Que los agentes pregunten antes de cada herramienta |
155
156
  | `--with recon,depth` | `-With recon,depth` | Extras a elección |
156
157
  | `--device auto\|cpu\|gpu` | `-Device …` | Dispositivo por defecto |
@@ -0,0 +1 @@
1
+ __version__ = "0.9.0"
@@ -30,6 +30,8 @@ from dataclasses import dataclass, field
30
30
  from pathlib import Path
31
31
  from typing import Any, Literal, TypeVar
32
32
 
33
+ import numpy as np
34
+
33
35
  from reverse3d.config import Settings
34
36
  from reverse3d.errors import (
35
37
  CaptureQualityError,
@@ -135,29 +137,70 @@ def _clean(
135
137
  parts, and tiny debris is already dropped by the area filter."""
136
138
  from reverse3d.reverse_eng.mesh_cleaner import CleanOptions, clean_mesh_file
137
139
 
140
+ options = CleanOptions(
141
+ keep_largest_only=keep_largest,
142
+ fill_holes=fill_holes,
143
+ align=align,
144
+ ground=align != "none",
145
+ scale=scale,
146
+ max_hole_edges=2000 if repair == "strong" else 200,
147
+ )
138
148
  report = run.step(
139
149
  "clean",
140
- lambda: clean_mesh_file(
141
- src,
142
- dst,
143
- run.settings,
144
- CleanOptions(
145
- keep_largest_only=keep_largest,
146
- fill_holes=fill_holes,
147
- align=align,
148
- ground=align != "none",
149
- scale=scale,
150
- max_hole_edges=2000 if repair == "strong" else 200,
151
- ),
152
- overwrite=overwrite,
153
- ),
150
+ lambda: clean_mesh_file(src, dst, run.settings, options, overwrite=overwrite),
154
151
  )
155
152
  run.warnings.extend(report.warnings)
156
153
  run.outputs["model"] = str(dst)
157
154
  run.details["clean"] = report.to_dict()
155
+ if _has_vertex_colors(src):
156
+ # Same cleaning on the coloured mesh (deterministic, colours follow the
157
+ # vertices): model_color.ply for renders and model.glb for games and viewers.
158
+ try:
159
+ color_ply = dst.with_name("model_color.ply")
160
+ clean_mesh_file(src, color_ply, run.settings, options, overwrite=True)
161
+ import trimesh
162
+
163
+ glb = dst.with_name("model.glb")
164
+ trimesh.load(color_ply, process=False, force="mesh").export(glb)
165
+ run.outputs["model_color"] = str(color_ply)
166
+ run.outputs["model_glb"] = str(glb)
167
+ except Exception as exc: # colour is a bonus: never fail the model for it
168
+ logger.info("Coloured model skipped: %s", exc)
158
169
  return report.after
159
170
 
160
171
 
172
+ def _has_vertex_colors(path: Path) -> bool:
173
+ import trimesh
174
+
175
+ try:
176
+ mesh: Any = trimesh.load(path, process=False, force="mesh")
177
+ except Exception:
178
+ return False
179
+ colors = getattr(mesh.visual, "vertex_colors", None)
180
+ if colors is None or len(colors) != len(mesh.vertices):
181
+ return False
182
+ rgb = np.asarray(colors)[:, :3]
183
+ return float(np.max(np.std(rgb, axis=0))) > 1.0 # not just a uniform default colour
184
+
185
+
186
+ def _color_from_cloud(mesh_path: Path, cloud_path: Path, out: Path) -> Path | None:
187
+ """Paint a mesh with the colours of the point cloud it was built from."""
188
+ import trimesh
189
+ from scipy.spatial import cKDTree
190
+
191
+ cloud: Any = trimesh.load(cloud_path, process=False)
192
+ colors = getattr(cloud, "colors", None)
193
+ if colors is None or len(colors) != len(getattr(cloud, "vertices", [])):
194
+ return None
195
+ mesh: Any = trimesh.load(mesh_path, process=False, force="mesh")
196
+ _, idx = cKDTree(np.asarray(cloud.vertices)).query(np.asarray(mesh.vertices), k=4)
197
+ rgb = np.asarray(colors)[idx][..., :3].astype(np.float64).mean(axis=1)
198
+ rgba = np.column_stack([rgb, np.full(len(rgb), 255.0)]).clip(0, 255).astype(np.uint8)
199
+ mesh.visual = trimesh.visual.ColorVisuals(mesh, vertex_colors=rgba)
200
+ mesh.export(out)
201
+ return out
202
+
203
+
161
204
  def _render(run: _Run, mesh: Path, overwrite: bool) -> None:
162
205
  from reverse3d.rendering.blender_render import RenderOptions, preset_options, render_mesh
163
206
 
@@ -210,28 +253,6 @@ _DEFAULT_LOOK = {
210
253
  }
211
254
 
212
255
 
213
- def _color_outputs(run: _Run, colored: Path, model: Path, scale: float) -> None:
214
- """Give the final model the photo colours of the carved hull: model.glb (games,
215
- web, viewers) and model_color.ply (renders)."""
216
- import numpy as np
217
- import trimesh
218
- from scipy.spatial import cKDTree
219
-
220
- source: Any = trimesh.load(colored, process=False, force="mesh")
221
- target: Any = trimesh.load(model, process=False, force="mesh")
222
- colors = getattr(source.visual, "vertex_colors", None)
223
- if colors is None or len(colors) != len(source.vertices):
224
- return
225
- _, nearest = cKDTree(np.asarray(source.vertices) * scale).query(np.asarray(target.vertices))
226
- target.visual = trimesh.visual.ColorVisuals(target, vertex_colors=np.asarray(colors)[nearest])
227
- glb = run.out / "model.glb"
228
- ply = run.out / "model_color.ply"
229
- target.export(glb)
230
- target.export(ply)
231
- run.outputs["model_glb"] = str(glb)
232
- run.outputs["model_color"] = str(ply)
233
-
234
-
235
256
  def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
236
257
  from reverse3d.reconstruction.photogrammetry import (
237
258
  PhotogrammetryOptions,
@@ -319,6 +340,18 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
319
340
  )
320
341
  return None
321
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
+
322
355
  try:
323
356
  hull = run.step(
324
357
  "silhouette",
@@ -327,6 +360,7 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
327
360
  Path(photo.sparse_text_dir or ""),
328
361
  run.out / "reconstruction" / "object_hull.ply",
329
362
  segmenter=load_segmenter,
363
+ depth=load_depth,
330
364
  ),
331
365
  )
332
366
  except ProcessingError as exc:
@@ -352,6 +386,30 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
352
386
  "Point cloud created but no mesh could be built.",
353
387
  hint='Install the meshing extra: pip install "reverse3d[recon]" (Python ≤ 3.12).',
354
388
  )
389
+ cloud = photo.dense_points_path or photo.sparse_points_path
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
395
+ try:
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
+ )
405
+ except Exception as exc: # colour is a bonus
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
355
413
  model = run.out / "model.stl"
356
414
  _clean(
357
415
  run,
@@ -364,11 +422,6 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
364
422
  keep_largest=preset.keep_largest,
365
423
  repair=opt.repair,
366
424
  )
367
- if upright:
368
- try:
369
- _color_outputs(run, Path(mesh_path), model, opt.scale)
370
- except Exception as exc: # colour is a bonus: never fail the model for it
371
- logger.info("Colour transfer skipped: %s", exc)
372
425
  return _measure(run, model)
373
426
 
374
427
 
@@ -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.0
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>
@@ -215,10 +215,11 @@ Cada archivo modificado se **respalda antes** (`*.reverse3d-backup-<fecha>`). So
215
215
 
216
216
  | bash (`install.sh`) | PowerShell (`install.ps1`) | Efecto |
217
217
  |---------------------|----------------------------|--------|
218
- | *(sin opciones)* | *(sin opciones)* | **Todo**: núcleo, MCP, render, mallado, IA de profundidad, Blender y COLMAP |
219
- | `--lite` | `-Lite` | Todo menos la IA de profundidad (PyTorch); ahorra hasta ~2.5 GB |
220
- | `--minimal` | `-Minimal` | Solo núcleo + MCP, sin herramientas externas |
218
+ | *(sin opciones)* | *(sin opciones)* | **Todo**: núcleo, MCP, render, mallado, IA local (con CUDA si hay NVIDIA), Blender y COLMAP |
219
+ | `--lite` | `-Lite` | Todo, con la IA local en la CPU (PyTorch CPU, ~250 MB en vez de ~2.5 GB) |
220
+ | `--minimal` | `-Minimal` | Núcleo + MCP + IA local, sin herramientas externas |
221
221
  | `--no-tools` | `-NoTools` | No instalar Blender ni COLMAP |
222
+ | *(en todos los modos)* | *(en todos los modos)* | **La IA local se instala y se descarga siempre** (clasificador, segmentación y profundidad, unos 750 MB) para que todo funcione sin internet |
222
223
  | `--no-auto-approve` | `-NoAutoApprove` | Que los agentes pregunten antes de cada herramienta |
223
224
  | `--with recon,depth` | `-With recon,depth` | Extras a elección |
224
225
  | `--device auto\|cpu\|gpu` | `-Device …` | Dispositivo por defecto |
@@ -60,6 +60,7 @@ tests/test_authorship.py
60
60
  tests/test_automation.py
61
61
  tests/test_cli.py
62
62
  tests/test_cli_automation.py
63
+ tests/test_color.py
63
64
  tests/test_config.py
64
65
  tests/test_geometry_fit.py
65
66
  tests/test_heic.py
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import numpy as np
6
+ import trimesh
7
+
8
+ from reverse3d.config import Settings
9
+ from reverse3d.recipes import _clean, _color_from_cloud, _has_vertex_colors, _Run
10
+
11
+
12
+ def _two_tone_cloud(path: Path) -> Path:
13
+ """Points on a sphere: red on top, blue at the bottom (like a COLMAP fused.ply)."""
14
+ pts = trimesh.creation.icosphere(subdivisions=5).vertices
15
+ rgb = np.where(pts[:, 2:3] > 0, [220, 30, 30], [30, 30, 220])
16
+ trimesh.PointCloud(pts, colors=np.column_stack([rgb, np.full(len(pts), 255)])).export(path)
17
+ return path
18
+
19
+
20
+ def test_mesh_takes_the_colours_of_its_point_cloud(tmp_path: Path) -> None:
21
+ plain = tmp_path / "mesh.ply"
22
+ trimesh.creation.icosphere(subdivisions=3).export(plain)
23
+ assert not _has_vertex_colors(plain)
24
+ colored = _color_from_cloud(plain, _two_tone_cloud(tmp_path / "cloud.ply"), tmp_path / "c.ply")
25
+ assert colored is not None and _has_vertex_colors(colored)
26
+ mesh = trimesh.load(colored, process=False)
27
+ top = mesh.vertices[:, 2] > 0.3
28
+ assert mesh.visual.vertex_colors[top, 0].mean() > 150 # red on top
29
+ assert mesh.visual.vertex_colors[~top & (mesh.vertices[:, 2] < -0.3), 2].mean() > 150
30
+
31
+
32
+ def test_cleaning_a_coloured_mesh_also_writes_glb(tmp_path: Path, settings: Settings) -> None:
33
+ plain = tmp_path / "mesh.ply"
34
+ trimesh.creation.icosphere(subdivisions=3).export(plain)
35
+ colored = _color_from_cloud(plain, _two_tone_cloud(tmp_path / "cloud.ply"), tmp_path / "c.ply")
36
+ assert colored is not None
37
+ run = _Run(recipe="photos_to_model", settings=settings, out=tmp_path)
38
+ _clean(run, colored, tmp_path / "model.stl", scale=1.0, overwrite=True)
39
+ assert {"model", "model_color", "model_glb"} <= set(run.outputs)
40
+ glb = trimesh.load(run.outputs["model_glb"], force="mesh")
41
+ assert len(np.unique(np.asarray(glb.visual.vertex_colors)[:, :3], axis=0)) > 1
42
+
43
+
44
+ def test_plain_meshes_get_no_colour_outputs(tmp_path: Path, settings: Settings) -> None:
45
+ plain = tmp_path / "mesh.ply"
46
+ trimesh.creation.icosphere(subdivisions=3).export(plain)
47
+ run = _Run(recipe="mesh_report", settings=settings, out=tmp_path)
48
+ _clean(run, plain, tmp_path / "model.stl", scale=1.0, overwrite=True)
49
+ assert "model_glb" not in run.outputs
@@ -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.0"
@@ -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