reverse3d 0.8.1__tar.gz → 0.10.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 (81) hide show
  1. {reverse3d-0.8.1/reverse3d.egg-info → reverse3d-0.10.0}/PKG-INFO +6 -3
  2. {reverse3d-0.8.1 → reverse3d-0.10.0}/README.md +4 -2
  3. {reverse3d-0.8.1 → reverse3d-0.10.0}/pyproject.toml +1 -0
  4. reverse3d-0.10.0/reverse3d/_version.py +1 -0
  5. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/offline.py +65 -0
  6. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/recipes.py +101 -11
  7. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/reconstruction/photogrammetry.py +39 -2
  8. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/reconstruction/silhouette.py +121 -2
  9. reverse3d-0.10.0/reverse3d/reconstruction/texture.py +195 -0
  10. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/rendering/_blender_job.py +7 -0
  11. {reverse3d-0.8.1 → reverse3d-0.10.0/reverse3d.egg-info}/PKG-INFO +6 -3
  12. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d.egg-info/SOURCES.txt +2 -0
  13. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d.egg-info/requires.txt +1 -0
  14. {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_color.py +15 -0
  15. reverse3d-0.10.0/tests/test_silhouette.py +284 -0
  16. reverse3d-0.10.0/tests/test_texture.py +36 -0
  17. reverse3d-0.8.1/reverse3d/_version.py +0 -1
  18. reverse3d-0.8.1/tests/test_silhouette.py +0 -158
  19. {reverse3d-0.8.1 → reverse3d-0.10.0}/LICENSE +0 -0
  20. {reverse3d-0.8.1 → reverse3d-0.10.0}/NOTICE +0 -0
  21. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/__init__.py +0 -0
  22. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/__main__.py +0 -0
  23. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/_about.py +0 -0
  24. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/agent/__init__.py +0 -0
  25. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/agent/compact.py +0 -0
  26. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/agent/mcp_server.py +0 -0
  27. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/agent/prompts.py +0 -0
  28. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/agent/skill/SKILL.md +0 -0
  29. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/agent/tools.py +0 -0
  30. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/autopilot.py +0 -0
  31. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/cli.py +0 -0
  32. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/config.py +0 -0
  33. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/doctor.py +0 -0
  34. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/errors.py +0 -0
  35. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/hardware.py +0 -0
  36. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/imaging.py +0 -0
  37. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/logging_utils.py +0 -0
  38. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/mesh_io.py +0 -0
  39. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/profiles.py +0 -0
  40. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/py.typed +0 -0
  41. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/reconstruction/__init__.py +0 -0
  42. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/reconstruction/depth_estimation.py +0 -0
  43. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/reconstruction/pointcloud.py +0 -0
  44. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/rendering/__init__.py +0 -0
  45. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/rendering/blender_render.py +0 -0
  46. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/rendering/preview.py +0 -0
  47. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/reverse_eng/__init__.py +0 -0
  48. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/reverse_eng/geometry_fit.py +0 -0
  49. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/reverse_eng/measure.py +0 -0
  50. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/reverse_eng/mesh_cleaner.py +0 -0
  51. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/reverse_eng/parametric.py +0 -0
  52. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/scenes.py +0 -0
  53. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/security.py +0 -0
  54. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/setup_agents.py +0 -0
  55. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/userconfig.py +0 -0
  56. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/vision/__init__.py +0 -0
  57. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/vision/classifier.py +0 -0
  58. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/vision/feature_matcher.py +0 -0
  59. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/vision/mask_extractor.py +0 -0
  60. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/vision/segmenter.py +0 -0
  61. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/watch.py +0 -0
  62. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d.egg-info/dependency_links.txt +0 -0
  63. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d.egg-info/entry_points.txt +0 -0
  64. {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d.egg-info/top_level.txt +0 -0
  65. {reverse3d-0.8.1 → reverse3d-0.10.0}/setup.cfg +0 -0
  66. {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_agent_tools.py +0 -0
  67. {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_authorship.py +0 -0
  68. {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_automation.py +0 -0
  69. {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_cli.py +0 -0
  70. {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_cli_automation.py +0 -0
  71. {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_config.py +0 -0
  72. {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_geometry_fit.py +0 -0
  73. {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_heic.py +0 -0
  74. {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_mesh.py +0 -0
  75. {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_offline_autopilot.py +0 -0
  76. {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_optional_extras.py +0 -0
  77. {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_reconstruction.py +0 -0
  78. {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_render.py +0 -0
  79. {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_scenes.py +0 -0
  80. {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_security.py +0 -0
  81. {reverse3d-0.8.1 → reverse3d-0.10.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.10.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>
@@ -32,6 +32,7 @@ License-File: NOTICE
32
32
  Requires-Dist: numpy>=1.26
33
33
  Requires-Dist: scipy>=1.11
34
34
  Requires-Dist: scikit-image>=0.22
35
+ Requires-Dist: xatlas>=0.0.9
35
36
  Requires-Dist: opencv-python-headless>=4.9
36
37
  Requires-Dist: pillow>=10.3
37
38
  Requires-Dist: pillow-heif>=1.1
@@ -219,7 +220,7 @@ Cada archivo modificado se **respalda antes** (`*.reverse3d-backup-<fecha>`). So
219
220
  | `--lite` | `-Lite` | Todo, con la IA local en la CPU (PyTorch CPU, ~250 MB en vez de ~2.5 GB) |
220
221
  | `--minimal` | `-Minimal` | Núcleo + MCP + IA local, sin herramientas externas |
221
222
  | `--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 |
223
+ | *(en todos los modos)* | *(en todos los modos)* | **La IA local se instala y se descarga siempre** (clasificador, segmentación, profundidad y puntos con IA, unos 800 MB) para que todo funcione sin internet |
223
224
  | `--no-auto-approve` | `-NoAutoApprove` | Que los agentes pregunten antes de cada herramienta |
224
225
  | `--with recon,depth` | `-With recon,depth` | Extras a elección |
225
226
  | `--device auto\|cpu\|gpu` | `-Device …` | Dispositivo por defecto |
@@ -358,7 +359,7 @@ reverse3d config set verbosity full # si alguna vez necesitas el detalle com
358
359
 
359
360
  ## 🔌 Sin internet (modo offline)
360
361
 
361
- reverse3d **funciona sin conexión**. Todo el cálculo es local (OpenCV, COLMAP, Open3D, trimesh, Blender). Lo único que se descarga es el modelo de IA de profundidad, y **el instalador lo deja descargado**.
362
+ reverse3d **funciona sin conexión**. Todo el cálculo es local (OpenCV, COLMAP, Open3D, trimesh, Blender). Lo único que se descarga son los modelos de IA local (sujeto, recorte, profundidad y los puntos ALIKED + LightGlue), y **el instalador los deja descargados**.
362
363
 
363
364
  - **Cambio automático:** si el equipo no detecta internet, reverse3d pasa solo a modo offline. No tienes que hacer nada.
364
365
  - **Comprobar:** `reverse3d offline status` dice si hay conexión y qué funciona sin ella. `reverse3d doctor` lo incluye en su informe.
@@ -416,6 +417,8 @@ Si las fotos están borrosas o falta una herramienta, **no reintenta en vano**:
416
417
  | 🧱 **Plano → pieza** | Extrusión de contornos a sólido 3D con escala real | Shapely + trimesh |
417
418
  | 📷 **Fotogrametría** | Fotos → SfM → MVS denso → nube → malla | COLMAP + Open3D |
418
419
  | 🌊 **Profundidad con IA** | Una foto → mapa de profundidad → relieve 2.5D | Depth Anything V2 |
420
+ | 🎨 **Texturas** | Textura fotográfica 2048² desde las propias fotos (UV, visibilidad, sin costuras) en `model.glb` | xatlas + OpenCV |
421
+ | 🧠 **Puntos con IA** | Rescate automático de capturas difíciles con puntos y emparejamiento aprendidos | ALIKED + LightGlue (ONNX) |
419
422
  | 🛠️ **Reparación de mallas** | Limpieza, restos sueltos, agujeros, normales, Taubin, decimación, alineación OBB/PCA, escala | trimesh |
420
423
  | 📏 **Metrología** | Cotas AABB/OBB, área, volumen, centro de masa, estanqueidad | trimesh |
421
424
  | 📐 **Primitivas** | Planos, cilindros y esferas con RANSAC (radios, ejes, normales) | NumPy |
@@ -151,7 +151,7 @@ Cada archivo modificado se **respalda antes** (`*.reverse3d-backup-<fecha>`). So
151
151
  | `--lite` | `-Lite` | Todo, con la IA local en la CPU (PyTorch CPU, ~250 MB en vez de ~2.5 GB) |
152
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
+ | *(en todos los modos)* | *(en todos los modos)* | **La IA local se instala y se descarga siempre** (clasificador, segmentación, profundidad y puntos con IA, unos 800 MB) para que todo funcione sin internet |
155
155
  | `--no-auto-approve` | `-NoAutoApprove` | Que los agentes pregunten antes de cada herramienta |
156
156
  | `--with recon,depth` | `-With recon,depth` | Extras a elección |
157
157
  | `--device auto\|cpu\|gpu` | `-Device …` | Dispositivo por defecto |
@@ -290,7 +290,7 @@ reverse3d config set verbosity full # si alguna vez necesitas el detalle com
290
290
 
291
291
  ## 🔌 Sin internet (modo offline)
292
292
 
293
- reverse3d **funciona sin conexión**. Todo el cálculo es local (OpenCV, COLMAP, Open3D, trimesh, Blender). Lo único que se descarga es el modelo de IA de profundidad, y **el instalador lo deja descargado**.
293
+ reverse3d **funciona sin conexión**. Todo el cálculo es local (OpenCV, COLMAP, Open3D, trimesh, Blender). Lo único que se descarga son los modelos de IA local (sujeto, recorte, profundidad y los puntos ALIKED + LightGlue), y **el instalador los deja descargados**.
294
294
 
295
295
  - **Cambio automático:** si el equipo no detecta internet, reverse3d pasa solo a modo offline. No tienes que hacer nada.
296
296
  - **Comprobar:** `reverse3d offline status` dice si hay conexión y qué funciona sin ella. `reverse3d doctor` lo incluye en su informe.
@@ -348,6 +348,8 @@ Si las fotos están borrosas o falta una herramienta, **no reintenta en vano**:
348
348
  | 🧱 **Plano → pieza** | Extrusión de contornos a sólido 3D con escala real | Shapely + trimesh |
349
349
  | 📷 **Fotogrametría** | Fotos → SfM → MVS denso → nube → malla | COLMAP + Open3D |
350
350
  | 🌊 **Profundidad con IA** | Una foto → mapa de profundidad → relieve 2.5D | Depth Anything V2 |
351
+ | 🎨 **Texturas** | Textura fotográfica 2048² desde las propias fotos (UV, visibilidad, sin costuras) en `model.glb` | xatlas + OpenCV |
352
+ | 🧠 **Puntos con IA** | Rescate automático de capturas difíciles con puntos y emparejamiento aprendidos | ALIKED + LightGlue (ONNX) |
351
353
  | 🛠️ **Reparación de mallas** | Limpieza, restos sueltos, agujeros, normales, Taubin, decimación, alineación OBB/PCA, escala | trimesh |
352
354
  | 📏 **Metrología** | Cotas AABB/OBB, área, volumen, centro de masa, estanqueidad | trimesh |
353
355
  | 📐 **Primitivas** | Planos, cilindros y esferas con RANSAC (radios, ejes, normales) | NumPy |
@@ -40,6 +40,7 @@ dependencies = [
40
40
  "numpy>=1.26",
41
41
  "scipy>=1.11",
42
42
  "scikit-image>=0.22",
43
+ "xatlas>=0.0.9",
43
44
  "opencv-python-headless>=4.9",
44
45
  "pillow>=10.3",
45
46
  "pillow-heif>=1.1",
@@ -0,0 +1 @@
1
+ __version__ = "0.10.0"
@@ -59,6 +59,55 @@ MODEL_REVISIONS: dict[str, str] = {
59
59
  }
60
60
 
61
61
 
62
+ #: Learned feature models COLMAP runs through ONNX (ALIKED, BSD-3; LightGlue,
63
+ #: Apache-2.0): (download URL, pinned SHA-256). Used to rescue hard photo sets.
64
+ COLMAP_MODELS: dict[str, tuple[str, str]] = {
65
+ "aliked-n16rot.onnx": (
66
+ "https://github.com/colmap/colmap/releases/download/3.13.0/aliked-n16rot.onnx",
67
+ "39c423d0a6f03d39ec89d3d1d61853765c2fb6a8b8381376c703e5758778a547",
68
+ ),
69
+ "aliked-lightglue.onnx": (
70
+ "https://github.com/colmap/colmap/releases/download/3.13.0/aliked-lightglue.onnx",
71
+ "b9a5de7204648b18a8cf5dcac819f9d30de1a5961ef03756803c8b86c2dceb8d",
72
+ ),
73
+ }
74
+
75
+
76
+ def colmap_model_path(name: str) -> Path | None:
77
+ """Local copy of a COLMAP ONNX model, if downloaded (filesystem check only)."""
78
+ path = models_dir() / "colmap" / name
79
+ return path if path.is_file() else None
80
+
81
+
82
+ def download_colmap_models() -> dict[str, str]:
83
+ """Download the learned-feature models, verifying their pinned SHA-256."""
84
+ import hashlib
85
+ import urllib.request
86
+
87
+ status: dict[str, str] = {}
88
+ folder = models_dir() / "colmap"
89
+ folder.mkdir(parents=True, exist_ok=True)
90
+ for name, (url, sha) in COLMAP_MODELS.items():
91
+ target = folder / name
92
+ if target.is_file() and hashlib.sha256(target.read_bytes()).hexdigest() == sha:
93
+ status[name] = "ready"
94
+ continue
95
+ try:
96
+ with urllib.request.urlopen(url, timeout=120) as response: # noqa: S310 # nosec B310 - pinned https URL, SHA-256 checked
97
+ data = response.read()
98
+ except Exception as exc: # network errors come in many shapes
99
+ status[name] = f"failed ({exc.__class__.__name__})"
100
+ continue
101
+ if hashlib.sha256(data).hexdigest() != sha:
102
+ status[name] = "failed (checksum mismatch)"
103
+ continue
104
+ tmp = target.with_suffix(".part")
105
+ tmp.write_bytes(data)
106
+ tmp.replace(target)
107
+ status[name] = "downloaded"
108
+ return status
109
+
110
+
62
111
  def models_dir() -> Path:
63
112
  """Folder that holds the downloaded AI models (``~/.reverse3d/models``)."""
64
113
  return home_dir() / "models"
@@ -263,6 +312,15 @@ def prepare(settings: Settings, *, include_non_commercial: bool = False) -> dict
263
312
  }
264
313
  )
265
314
 
315
+ if online or all(colmap_model_path(n) for n in COLMAP_MODELS):
316
+ for name, state in download_colmap_models().items():
317
+ results.append({"model": f"colmap/{name}", "status": state.split(" ")[0]})
318
+ else:
319
+ results += [
320
+ {"model": f"colmap/{n}", "status": "missing", "detail": "No internet connection."}
321
+ for n in COLMAP_MODELS
322
+ if not colmap_model_path(n)
323
+ ]
266
324
  ready = all(r["status"] in ("ready", "downloaded") for r in results)
267
325
  return {
268
326
  "ready": ready,
@@ -294,6 +352,7 @@ def offline_status(settings: Settings) -> dict[str, Any]:
294
352
  "ai_segmentation": DEFAULT_SEGMENTER,
295
353
  "ai_depth": _default_model(),
296
354
  }
355
+ features_ready = all(colmap_model_path(n) for n in COLMAP_MODELS)
297
356
  on_disk = {key: local_model_path(model) is not None for key, model in ai_models.items()}
298
357
  internet = None if settings.offline != "auto" else has_internet()
299
358
 
@@ -310,6 +369,7 @@ def offline_status(settings: Settings) -> dict[str, Any]:
310
369
  key: {"available": depth_installed, "offline": depth_installed and on_disk[key]}
311
370
  for key in ai_models
312
371
  },
372
+ "ai_features": {"available": True, "offline": features_ready},
313
373
  }
314
374
  missing = []
315
375
  names = {
@@ -321,6 +381,11 @@ def offline_status(settings: Settings) -> dict[str, Any]:
321
381
  missing.append(
322
382
  "AI depth model not downloaded yet: run `reverse3d offline prepare` while online."
323
383
  )
384
+ if not features_ready:
385
+ missing.append(
386
+ "AI feature models (ALIKED + LightGlue) not downloaded yet: "
387
+ "run `reverse3d offline prepare` while online."
388
+ )
324
389
  for key in ("ai_subject", "ai_segmentation"):
325
390
  if depth_installed and not on_disk[key]:
326
391
  missing.append(
@@ -36,6 +36,7 @@ from reverse3d.config import Settings
36
36
  from reverse3d.errors import (
37
37
  CaptureQualityError,
38
38
  DependencyMissingError,
39
+ ExternalToolError,
39
40
  InputValidationError,
40
41
  ProcessingError,
41
42
  Reverse3DError,
@@ -161,7 +162,7 @@ def _clean(
161
162
  import trimesh
162
163
 
163
164
  glb = dst.with_name("model.glb")
164
- trimesh.load(color_ply, process=False, force="mesh").export(glb)
165
+ _export_glb(trimesh.load(color_ply, process=False, force="mesh"), glb)
165
166
  run.outputs["model_color"] = str(color_ply)
166
167
  run.outputs["model_glb"] = str(glb)
167
168
  except Exception as exc: # colour is a bonus: never fail the model for it
@@ -169,6 +170,20 @@ def _clean(
169
170
  return report.after
170
171
 
171
172
 
173
+ def _export_glb(mesh: Any, path: Path) -> None:
174
+ """Write a GLB that stands up in any viewer: glTF is Y-up, our models are Z-up.
175
+
176
+ The geometry keeps its Z-up coordinates and a root node rotates it, so
177
+ Blender, game engines and web viewers show it upright.
178
+ """
179
+ import trimesh
180
+
181
+ scene = trimesh.Scene()
182
+ up = trimesh.transformations.rotation_matrix(-np.pi / 2, [1, 0, 0])
183
+ scene.add_geometry(mesh, node_name="model", transform=up)
184
+ scene.export(path)
185
+
186
+
172
187
  def _has_vertex_colors(path: Path) -> bool:
173
188
  import trimesh
174
189
 
@@ -253,6 +268,30 @@ _DEFAULT_LOOK = {
253
268
  }
254
269
 
255
270
 
271
+ def _texture(run: _Run, model: Path, photo: Any, to_world: list[list[float]], scale: float) -> None:
272
+ """Bake a photo texture onto the final model: model.glb gets real detail."""
273
+ try:
274
+ import trimesh
275
+
276
+ from reverse3d.reconstruction.texture import bake_texture
277
+
278
+ mesh: Any = trimesh.load(model, process=False, force="mesh")
279
+ # The cleaned model is the carved hull scaled by ``scale``.
280
+ world = np.asarray(to_world) @ np.diag([1 / scale, 1 / scale, 1 / scale, 1.0])
281
+ textured = run.step(
282
+ "texture",
283
+ lambda: bake_texture(
284
+ mesh, Path(photo.images_dir), Path(photo.sparse_text_dir), to_world=world
285
+ ),
286
+ )
287
+ glb = model.with_name("model.glb")
288
+ _export_glb(textured, glb)
289
+ run.outputs["model_glb"] = str(glb)
290
+ run.details["texture"] = {"size": 2048, "faces": len(textured.faces)}
291
+ except Exception as exc: # the texture is a bonus: never fail the model for it
292
+ logger.info("Texture skipped: %s", exc)
293
+
294
+
256
295
  def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
257
296
  from reverse3d.reconstruction.photogrammetry import (
258
297
  PhotogrammetryOptions,
@@ -304,7 +343,7 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
304
343
  # white or glossy surfaces. Open scenes keep classic dense reconstruction.
305
344
  silhouette = scene in ("object", "character")
306
345
 
307
- def photogrammetry(classic: bool) -> PhotogrammetryResult:
346
+ def photogrammetry(classic: bool, features: str = "sift") -> PhotogrammetryResult:
308
347
  return run_photogrammetry(
309
348
  src,
310
349
  run.out / "reconstruction",
@@ -317,13 +356,28 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
317
356
  build_mesh=classic,
318
357
  poisson_depth=preset.poisson_depth,
319
358
  density_quantile=preset.density_quantile,
359
+ features=features, # type: ignore[arg-type]
320
360
  ),
321
- overwrite=opt.overwrite or not classic,
361
+ overwrite=opt.overwrite or not classic or features != "sift",
322
362
  )
323
363
 
324
- photo = run.step("photogrammetry", lambda: photogrammetry(classic=not silhouette))
364
+ try:
365
+ photo = run.step("photogrammetry", lambda: photogrammetry(classic=not silhouette))
366
+ except ProcessingError as exc:
367
+ # Rescue for hard sets: learned AI features (ALIKED + LightGlue) place
368
+ # photos that SIFT cannot. Slower (CPU), so only when SIFT fell short.
369
+ logger.info("SIFT could not place the photos (%s); retrying with AI features", exc.message)
370
+ try:
371
+ photo = run.step(
372
+ "photogrammetry_ai",
373
+ lambda: photogrammetry(classic=not silhouette, features="aliked"),
374
+ )
375
+ except (ProcessingError, DependencyMissingError, ExternalToolError):
376
+ raise exc from None
377
+ run.warnings.append("Photos placed with AI features (ALIKED + LightGlue).")
325
378
  mesh_path: str | None = photo.mesh_path
326
379
  upright = False
380
+ hull_world: list[list[float]] | None = None
327
381
  if silhouette:
328
382
  from reverse3d.reconstruction.silhouette import visual_hull
329
383
  from reverse3d.vision.segmenter import ObjectSegmenter
@@ -340,6 +394,18 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
340
394
  )
341
395
  return None
342
396
 
397
+ def load_depth() -> Callable[[np.ndarray], np.ndarray] | None:
398
+ # AI depth reveals concavities and holes (cup insides, handle loops).
399
+ from reverse3d.reconstruction.depth_estimation import predict_depth
400
+
401
+ device = torch_device(run.settings)
402
+ try:
403
+ predict_depth(np.zeros((32, 32, 3), np.uint8), device=device, settings=run.settings)
404
+ except Reverse3DError as exc:
405
+ logger.info("AI depth unavailable (%s); concavities stay filled", exc.message)
406
+ return None
407
+ return lambda rgb: predict_depth(rgb, device=device, settings=run.settings)
408
+
343
409
  try:
344
410
  hull = run.step(
345
411
  "silhouette",
@@ -348,6 +414,7 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
348
414
  Path(photo.sparse_text_dir or ""),
349
415
  run.out / "reconstruction" / "object_hull.ply",
350
416
  segmenter=load_segmenter,
417
+ depth=load_depth,
351
418
  ),
352
419
  )
353
420
  except ProcessingError as exc:
@@ -361,6 +428,7 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
361
428
  run.details["silhouette"] = hull.to_dict()
362
429
  mesh_path = hull.mesh_path
363
430
  upright = True
431
+ hull_world = hull.to_world
364
432
  if photo.registered_images >= 0.9 * max(photo.total_images, 1):
365
433
  # COLMAP placed (almost) every photo: the quick pre-check's capture advice
366
434
  # would only confuse.
@@ -374,14 +442,29 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
374
442
  hint='Install the meshing extra: pip install "reverse3d[recon]" (Python ≤ 3.12).',
375
443
  )
376
444
  cloud = photo.dense_points_path or photo.sparse_points_path
377
- if not upright and cloud:
445
+ if not upright:
446
+ # Dense scenes: colour from the photos themselves (sharp, independent of how
447
+ # dense the cloud is); the cloud's colours are the fallback.
448
+ colored_path = run.out / "reconstruction" / "mesh_color.ply"
449
+ colored: Path | None = None
378
450
  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
451
+ from reverse3d.reconstruction.silhouette import colorize_from_photos
452
+
453
+ if photo.images_dir and photo.sparse_text_dir:
454
+ colored = colorize_from_photos(
455
+ Path(mesh_path),
456
+ Path(photo.images_dir),
457
+ Path(photo.sparse_text_dir),
458
+ colored_path,
459
+ )
383
460
  except Exception as exc: # colour is a bonus
384
- logger.info("Colouring from the point cloud skipped: %s", exc)
461
+ logger.info("Colouring from the photos skipped: %s", exc)
462
+ if colored is None and cloud:
463
+ try:
464
+ colored = _color_from_cloud(Path(mesh_path), Path(cloud), colored_path)
465
+ except Exception as exc: # colour is a bonus
466
+ logger.info("Colouring from the point cloud skipped: %s", exc)
467
+ mesh_path = str(colored) if colored else mesh_path
385
468
  model = run.out / "model.stl"
386
469
  _clean(
387
470
  run,
@@ -394,6 +477,8 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
394
477
  keep_largest=preset.keep_largest,
395
478
  repair=opt.repair,
396
479
  )
480
+ if upright and hull_world is not None:
481
+ _texture(run, model, photo, hull_world, opt.scale)
397
482
  return _measure(run, model)
398
483
 
399
484
 
@@ -582,7 +667,12 @@ def run_recipe(
582
667
  source = prepare_heic_input(source, out) # iPhone HEIC photos -> JPEG
583
668
  stats = _RECIPES[recipe](run, source, opt)
584
669
  if opt.render:
585
- _render(run, Path(run.outputs.get("model_color") or run.outputs["model"]), opt.overwrite)
670
+ textured = run.details.get("texture") and run.outputs.get("model_glb")
671
+ _render(
672
+ run,
673
+ Path(textured or run.outputs.get("model_color") or run.outputs["model"]),
674
+ opt.overwrite,
675
+ )
586
676
 
587
677
  summary: dict[str, Any] = {
588
678
  "recipe": recipe,
@@ -94,6 +94,9 @@ class PhotogrammetryOptions:
94
94
  poisson_depth: int | None = None
95
95
  #: share of the least supported surface trimmed after Poisson.
96
96
  density_quantile: float = 0.02
97
+ #: "sift" (fast, GPU) or "aliked": learned ALIKED features + LightGlue matching,
98
+ #: which places photos of hard sets SIFT cannot (CPU, much slower).
99
+ features: Literal["sift", "aliked"] = "sift"
97
100
 
98
101
  def validate(self) -> None:
99
102
  if self.quality not in _PRESETS:
@@ -297,13 +300,36 @@ class ColmapPipeline:
297
300
  "--ImageReader.single_camera",
298
301
  "1" if options.single_camera else "0",
299
302
  ]
303
+ learned = options.features == "aliked"
304
+ if learned:
305
+ from reverse3d.offline import colmap_model_path, download_colmap_models
306
+
307
+ if not colmap_model_path("aliked-n16rot.onnx"):
308
+ download_colmap_models()
309
+ aliked = colmap_model_path("aliked-n16rot.onnx")
310
+ if aliked is None:
311
+ raise DependencyMissingError(
312
+ "The AI feature model (ALIKED) is not on this computer.",
313
+ hint="Connect to the internet once and run: reverse3d offline prepare",
314
+ )
315
+ # ONNX on the CPU: the CUDA runtime it needs is not bundled with COLMAP.
316
+ args += [
317
+ "--FeatureExtraction.type",
318
+ "ALIKED_N16ROT",
319
+ "--FeatureExtraction.use_gpu",
320
+ "0",
321
+ "--AlikedExtraction.n16rot_model_path",
322
+ str(aliked),
323
+ ]
300
324
  self._stage(
301
325
  result,
302
326
  "feature_extractor",
303
327
  self._with_options(
304
328
  "feature_extractor",
305
329
  args,
306
- {
330
+ {}
331
+ if learned
332
+ else {
307
333
  ("FeatureExtraction.use_gpu", "SiftExtraction.use_gpu"): gpu,
308
334
  ("FeatureExtraction.max_image_size", "SiftExtraction.max_image_size"): str(
309
335
  preset["max_image_size"]
@@ -324,8 +350,19 @@ class ColmapPipeline:
324
350
  matcher = "exhaustive" if len(images) <= _EXHAUSTIVE_LIMIT else "sequential"
325
351
  command = f"{matcher}_matcher"
326
352
  extra: dict[tuple[str, ...], str] = {
327
- ("FeatureMatching.use_gpu", "SiftMatching.use_gpu"): gpu
353
+ ("FeatureMatching.use_gpu", "SiftMatching.use_gpu"): "0" if learned else gpu
328
354
  }
355
+ if learned:
356
+ from reverse3d.offline import colmap_model_path
357
+
358
+ glue = colmap_model_path("aliked-lightglue.onnx")
359
+ if glue is None:
360
+ raise DependencyMissingError(
361
+ "The AI matching model (LightGlue) is not on this computer.",
362
+ hint="Connect to the internet once and run: reverse3d offline prepare",
363
+ )
364
+ extra[("FeatureMatching.type",)] = "ALIKED_LIGHTGLUE"
365
+ extra[("AlikedMatching.lightglue_model_path",)] = str(glue)
329
366
  if matcher == "spatial":
330
367
  extra[("SpatialMatching.is_gps",)] = "1"
331
368
  try:
@@ -69,6 +69,9 @@ class HullResult:
69
69
  faces: int
70
70
  support_plane: bool
71
71
  ai_segmentation: bool = False
72
+ depth_carved_voxels: int = 0
73
+ #: 4x4 matrix from the model's coordinates back to the COLMAP world (textures).
74
+ to_world: list[list[float]] | None = None
72
75
 
73
76
  def to_dict(self) -> dict[str, object]:
74
77
  return asdict(self)
@@ -250,6 +253,102 @@ def _vertex_colors(
250
253
  return colors
251
254
 
252
255
 
256
+ def _fit_inverse_depth(disparity: np.ndarray, depth: np.ndarray) -> tuple[float, float] | None:
257
+ """Robust fit 1/depth ≈ s·disparity + t (relative AI disparity → metric-like depth)."""
258
+ inv = 1.0 / depth
259
+ keep = np.ones(len(inv), bool)
260
+ for _ in range(3):
261
+ if keep.sum() < 50:
262
+ return None
263
+ a = np.column_stack([disparity[keep], np.ones(int(keep.sum()))])
264
+ (s, t), *_ = np.linalg.lstsq(a, inv[keep], rcond=None)
265
+ residual = np.abs(s * disparity + t - inv)
266
+ mad = float(np.median(residual[keep])) or 1e-9
267
+ keep = residual < 3 * 1.4826 * mad
268
+ return (float(s), float(t)) if s > 0 else None
269
+
270
+
271
+ def _depth_carve(
272
+ keep: np.ndarray,
273
+ grid: np.ndarray,
274
+ views: list[_View],
275
+ images: list[tuple[np.ndarray, float]],
276
+ projections: list[tuple[np.ndarray, np.ndarray, np.ndarray]],
277
+ masks: list[np.ndarray],
278
+ estimator: Callable[[np.ndarray], np.ndarray],
279
+ *,
280
+ tolerance: float = 0.05,
281
+ min_votes: int = 3,
282
+ min_share: float = 0.5,
283
+ ) -> np.ndarray:
284
+ """Empty the voxels that sit clearly in front of the surface the AI depth sees,
285
+ in at least ``min_votes`` photos *and* in most (``min_share``) of the photos
286
+ that actually see them. A photo where the voxel lies on the surface votes
287
+ solid; one where it is hidden behind the surface abstains. A few wrong depth
288
+ maps therefore cannot carve real material.
289
+
290
+ The relative AI depth is calibrated per photo against the carved hull itself
291
+ (a robust fit on the object's pixels), so only real disagreements carve.
292
+ """
293
+ votes = np.zeros(len(grid), np.int32)
294
+ seen = np.zeros(len(grid), np.int32)
295
+ for (small, _), view, (u, v, inside), mask in zip(
296
+ images, views, projections, masks, strict=True
297
+ ):
298
+ sel = keep & inside
299
+ if sel.sum() < 100:
300
+ continue
301
+ h, w = small.shape[:2]
302
+ z = (grid[sel] @ view.rotation.T + view.translation)[:, 2]
303
+ us, vs = u[sel], v[sel]
304
+ # Depth of the hull's visible surface per pixel (z-buffer of kept voxels).
305
+ hull_depth = np.full((h, w), np.inf)
306
+ np.minimum.at(hull_depth, (vs, us), z)
307
+ object_px = (mask > 0) & np.isfinite(hull_depth)
308
+ if object_px.sum() < 100:
309
+ continue
310
+ disparity = np.asarray(estimator(cv2.cvtColor(small, cv2.COLOR_BGR2RGB)), np.float64)
311
+ if disparity.shape != (h, w):
312
+ disparity = cv2.resize(disparity, (w, h), interpolation=cv2.INTER_LINEAR)
313
+ fit = _fit_inverse_depth(disparity[object_px], hull_depth[object_px])
314
+ if fit is None:
315
+ continue
316
+ s, t = fit
317
+ with np.errstate(divide="ignore", invalid="ignore"):
318
+ observed = 1.0 / np.maximum(s * disparity + t, 1e-9)
319
+ # A voxel strictly in front of the observed surface, on an object pixel, is air.
320
+ in_mask = mask[vs, us] > 0
321
+ front = z < observed[vs, us] * (1.0 - tolerance)
322
+ on_surface = np.abs(z - observed[vs, us]) <= tolerance * observed[vs, us]
323
+ idx = np.flatnonzero(sel)
324
+ seen[idx[in_mask & (front | on_surface)]] += 1 # hidden voxels abstain
325
+ votes[idx[in_mask & front]] += 1
326
+ air = (votes >= min_votes) & (votes >= min_share * np.maximum(seen, 1))
327
+ return keep & ~air
328
+
329
+
330
+ def colorize_from_photos(
331
+ mesh_path: Path, image_dir: Path, model_text_dir: Path, output_path: Path
332
+ ) -> Path:
333
+ """Paint any mesh in COLMAP coordinates with the photos that see it (dense scenes)."""
334
+ views, _ = read_colmap_text(model_text_dir)
335
+ images: list[tuple[np.ndarray, float]] = []
336
+ for view in views:
337
+ bgr = cv2.imread(str(image_dir / view.name))
338
+ if bgr is None:
339
+ raise ProcessingError(f"Could not read {view.name} for colouring.")
340
+ h, w = bgr.shape[:2]
341
+ scale = 1280 / max(h, w)
342
+ size = (round(w * scale), round(h * scale))
343
+ images.append((cv2.resize(bgr, size, interpolation=cv2.INTER_AREA), scale))
344
+ mesh: trimesh.Trimesh = trimesh.load(mesh_path, process=False, force="mesh") # type: ignore[assignment]
345
+ mesh.visual = trimesh.visual.ColorVisuals(
346
+ mesh, vertex_colors=_vertex_colors(mesh, views, images)
347
+ )
348
+ mesh.export(output_path)
349
+ return output_path
350
+
351
+
253
352
  def visual_hull(
254
353
  image_dir: Path,
255
354
  model_text_dir: Path,
@@ -258,6 +357,7 @@ def visual_hull(
258
357
  resolution: int = 176,
259
358
  refinements: int = 3,
260
359
  segmenter: Callable[[], ObjectSegmenter | None] | None = None,
360
+ depth: Callable[[], Callable[[np.ndarray], np.ndarray] | None] | None = None,
261
361
  ) -> HullResult:
262
362
  """Carve the object seen by every photo; writes a watertight, upright mesh.
263
363
 
@@ -385,6 +485,16 @@ def visual_hull(
385
485
  masks = refined
386
486
  keep = carve(masks, 1)
387
487
 
488
+ # Concavities and through-holes (a handle loop, the inside of a cup) never
489
+ # show in a silhouette: the AI depth of each photo reveals them.
490
+ estimator = depth() if depth is not None else None
491
+ carved_by_depth = 0
492
+ if estimator is not None:
493
+ before = int(keep.sum())
494
+ keep = _depth_carve(keep, grid, views, images, projections, masks, estimator)
495
+ carved_by_depth = before - int(keep.sum())
496
+ logger.info("AI depth carved %d voxel(s) of concavities", carved_by_depth)
497
+
388
498
  volume = keep.reshape(n, n, n).astype(np.float32)
389
499
  plane = _support_plane(points, center, radius, cameras)
390
500
  if plane is not None:
@@ -432,11 +542,20 @@ def visual_hull(
432
542
  transform = np.eye(4)
433
543
  transform[:3, :3] = basis
434
544
  mesh.apply_transform(transform)
435
- mesh.apply_translation([-mesh.centroid[0], -mesh.centroid[1], -mesh.bounds[0][2]])
545
+ shift = np.array([-mesh.centroid[0], -mesh.centroid[1], -mesh.bounds[0][2]])
546
+ mesh.apply_translation(shift)
547
+ world_to_model = trimesh.transformations.translation_matrix(shift) @ transform
436
548
 
437
549
  output_path.parent.mkdir(parents=True, exist_ok=True)
438
550
  mesh.export(output_path)
439
551
  logger.info("Silhouette model: %d views, %d faces", len(views), len(mesh.faces))
440
552
  return HullResult(
441
- str(output_path), len(views), voxels, len(mesh.faces), plane is not None, ai is not None
553
+ str(output_path),
554
+ len(views),
555
+ voxels,
556
+ len(mesh.faces),
557
+ plane is not None,
558
+ ai is not None,
559
+ carved_by_depth,
560
+ np.linalg.inv(world_to_model).tolist(),
442
561
  )