reverse3d 0.9.0__tar.gz → 0.11.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 (80) hide show
  1. {reverse3d-0.9.0/reverse3d.egg-info → reverse3d-0.11.0}/PKG-INFO +24 -3
  2. {reverse3d-0.9.0 → reverse3d-0.11.0}/README.md +22 -2
  3. {reverse3d-0.9.0 → reverse3d-0.11.0}/pyproject.toml +1 -0
  4. reverse3d-0.11.0/reverse3d/_version.py +1 -0
  5. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/doctor.py +12 -0
  6. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/offline.py +65 -0
  7. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/recipes.py +85 -7
  8. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/reconstruction/photogrammetry.py +45 -3
  9. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/reconstruction/silhouette.py +123 -6
  10. reverse3d-0.11.0/reverse3d/reconstruction/texture.py +195 -0
  11. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/rendering/_blender_job.py +7 -0
  12. {reverse3d-0.9.0 → reverse3d-0.11.0/reverse3d.egg-info}/PKG-INFO +24 -3
  13. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d.egg-info/SOURCES.txt +2 -0
  14. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d.egg-info/requires.txt +1 -0
  15. {reverse3d-0.9.0 → reverse3d-0.11.0}/tests/test_color.py +15 -0
  16. {reverse3d-0.9.0 → reverse3d-0.11.0}/tests/test_silhouette.py +57 -0
  17. reverse3d-0.11.0/tests/test_texture.py +36 -0
  18. reverse3d-0.9.0/reverse3d/_version.py +0 -1
  19. {reverse3d-0.9.0 → reverse3d-0.11.0}/LICENSE +0 -0
  20. {reverse3d-0.9.0 → reverse3d-0.11.0}/NOTICE +0 -0
  21. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/__init__.py +0 -0
  22. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/__main__.py +0 -0
  23. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/_about.py +0 -0
  24. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/agent/__init__.py +0 -0
  25. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/agent/compact.py +0 -0
  26. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/agent/mcp_server.py +0 -0
  27. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/agent/prompts.py +0 -0
  28. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/agent/skill/SKILL.md +0 -0
  29. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/agent/tools.py +0 -0
  30. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/autopilot.py +0 -0
  31. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/cli.py +0 -0
  32. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/config.py +0 -0
  33. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/errors.py +0 -0
  34. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/hardware.py +0 -0
  35. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/imaging.py +0 -0
  36. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/logging_utils.py +0 -0
  37. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/mesh_io.py +0 -0
  38. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/profiles.py +0 -0
  39. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/py.typed +0 -0
  40. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/reconstruction/__init__.py +0 -0
  41. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/reconstruction/depth_estimation.py +0 -0
  42. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/reconstruction/pointcloud.py +0 -0
  43. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/rendering/__init__.py +0 -0
  44. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/rendering/blender_render.py +0 -0
  45. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/rendering/preview.py +0 -0
  46. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/reverse_eng/__init__.py +0 -0
  47. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/reverse_eng/geometry_fit.py +0 -0
  48. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/reverse_eng/measure.py +0 -0
  49. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/reverse_eng/mesh_cleaner.py +0 -0
  50. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/reverse_eng/parametric.py +0 -0
  51. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/scenes.py +0 -0
  52. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/security.py +0 -0
  53. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/setup_agents.py +0 -0
  54. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/userconfig.py +0 -0
  55. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/vision/__init__.py +0 -0
  56. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/vision/classifier.py +0 -0
  57. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/vision/feature_matcher.py +0 -0
  58. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/vision/mask_extractor.py +0 -0
  59. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/vision/segmenter.py +0 -0
  60. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d/watch.py +0 -0
  61. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d.egg-info/dependency_links.txt +0 -0
  62. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d.egg-info/entry_points.txt +0 -0
  63. {reverse3d-0.9.0 → reverse3d-0.11.0}/reverse3d.egg-info/top_level.txt +0 -0
  64. {reverse3d-0.9.0 → reverse3d-0.11.0}/setup.cfg +0 -0
  65. {reverse3d-0.9.0 → reverse3d-0.11.0}/tests/test_agent_tools.py +0 -0
  66. {reverse3d-0.9.0 → reverse3d-0.11.0}/tests/test_authorship.py +0 -0
  67. {reverse3d-0.9.0 → reverse3d-0.11.0}/tests/test_automation.py +0 -0
  68. {reverse3d-0.9.0 → reverse3d-0.11.0}/tests/test_cli.py +0 -0
  69. {reverse3d-0.9.0 → reverse3d-0.11.0}/tests/test_cli_automation.py +0 -0
  70. {reverse3d-0.9.0 → reverse3d-0.11.0}/tests/test_config.py +0 -0
  71. {reverse3d-0.9.0 → reverse3d-0.11.0}/tests/test_geometry_fit.py +0 -0
  72. {reverse3d-0.9.0 → reverse3d-0.11.0}/tests/test_heic.py +0 -0
  73. {reverse3d-0.9.0 → reverse3d-0.11.0}/tests/test_mesh.py +0 -0
  74. {reverse3d-0.9.0 → reverse3d-0.11.0}/tests/test_offline_autopilot.py +0 -0
  75. {reverse3d-0.9.0 → reverse3d-0.11.0}/tests/test_optional_extras.py +0 -0
  76. {reverse3d-0.9.0 → reverse3d-0.11.0}/tests/test_reconstruction.py +0 -0
  77. {reverse3d-0.9.0 → reverse3d-0.11.0}/tests/test_render.py +0 -0
  78. {reverse3d-0.9.0 → reverse3d-0.11.0}/tests/test_scenes.py +0 -0
  79. {reverse3d-0.9.0 → reverse3d-0.11.0}/tests/test_security.py +0 -0
  80. {reverse3d-0.9.0 → reverse3d-0.11.0}/tests/test_vision.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reverse3d
3
- Version: 0.9.0
3
+ Version: 0.11.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
@@ -96,6 +97,7 @@ Desarrollado por **[Voxel Enterprise](https://voxel.lat)**
96
97
  - [💰 Ahorro de tokens](#-ahorro-de-tokens)
97
98
  - [🔌 Sin internet (modo offline)](#-sin-internet-modo-offline)
98
99
  - [🤖 Piloto automático](#-piloto-automático)
100
+ - [🧠 Reverse3D Core (motor exclusivo)](#-reverse3d-core-motor-exclusivo)
99
101
  - [Capacidades](#capacidades)
100
102
  - [Herramientas externas opcionales](#herramientas-externas-opcionales)
101
103
  - [Referencia de la CLI](#referencia-de-la-cli)
@@ -219,7 +221,7 @@ Cada archivo modificado se **respalda antes** (`*.reverse3d-backup-<fecha>`). So
219
221
  | `--lite` | `-Lite` | Todo, con la IA local en la CPU (PyTorch CPU, ~250 MB en vez de ~2.5 GB) |
220
222
  | `--minimal` | `-Minimal` | Núcleo + MCP + IA local, sin herramientas externas |
221
223
  | `--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 |
224
+ | *(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
225
  | `--no-auto-approve` | `-NoAutoApprove` | Que los agentes pregunten antes de cada herramienta |
224
226
  | `--with recon,depth` | `-With recon,depth` | Extras a elección |
225
227
  | `--device auto\|cpu\|gpu` | `-Device …` | Dispositivo por defecto |
@@ -358,7 +360,7 @@ reverse3d config set verbosity full # si alguna vez necesitas el detalle com
358
360
 
359
361
  ## 🔌 Sin internet (modo offline)
360
362
 
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**.
363
+ 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
364
 
363
365
  - **Cambio automático:** si el equipo no detecta internet, reverse3d pasa solo a modo offline. No tienes que hacer nada.
364
366
  - **Comprobar:** `reverse3d offline status` dice si hay conexión y qué funciona sin ella. `reverse3d doctor` lo incluye en su informe.
@@ -407,6 +409,23 @@ Si las fotos están borrosas o falta una herramienta, **no reintenta en vano**:
407
409
 
408
410
  ---
409
411
 
412
+ ## 🧠 Reverse3D Core (motor exclusivo)
413
+
414
+ **Reverse3D Core** es el motor de reconstrucción propio de Voxel Enterprise. Es **propietario** (no forma parte de este repositorio de código abierto) y viene **incluido en reverse3d Studio**, que lo instala solo.
415
+
416
+ - **Cuando está instalado**, reverse3d lo usa automáticamente para fusionar las siluetas de los objetos. Aprende qué fotos son fiables, así que un brillo, un mal recorte o una foto mal ubicada **ya no borran el objeto**, y cada foto extra suma precisión.
417
+ - **Sin él**, reverse3d funciona igual con su método abierto.
418
+ - **Comprobarlo:** `reverse3d doctor` muestra `core: {available: true, version: …}`. El informe de cada trabajo indica qué motor se usó (`details.silhouette.engine`).
419
+
420
+ | Prueba interna (objeto de geometría conocida) | Método abierto | Reverse3D Core |
421
+ |---|---|---|
422
+ | Recortes perfectos | 0,943 | 0,941 |
423
+ | 15 % de recortes defectuosos | 0,000 (pierde el objeto) | **0,921** |
424
+
425
+ *(Coincidencia volumétrica, IoU: 1 = idéntico.)*
426
+
427
+ ---
428
+
410
429
  ## Capacidades
411
430
 
412
431
  | Área | Qué hace | Tecnología |
@@ -416,6 +435,8 @@ Si las fotos están borrosas o falta una herramienta, **no reintenta en vano**:
416
435
  | 🧱 **Plano → pieza** | Extrusión de contornos a sólido 3D con escala real | Shapely + trimesh |
417
436
  | 📷 **Fotogrametría** | Fotos → SfM → MVS denso → nube → malla | COLMAP + Open3D |
418
437
  | 🌊 **Profundidad con IA** | Una foto → mapa de profundidad → relieve 2.5D | Depth Anything V2 |
438
+ | 🎨 **Texturas** | Textura fotográfica 2048² desde las propias fotos (UV, visibilidad, sin costuras) en `model.glb` | xatlas + OpenCV |
439
+ | 🧠 **Puntos con IA** | Rescate automático de capturas difíciles con puntos y emparejamiento aprendidos | ALIKED + LightGlue (ONNX) |
419
440
  | 🛠️ **Reparación de mallas** | Limpieza, restos sueltos, agujeros, normales, Taubin, decimación, alineación OBB/PCA, escala | trimesh |
420
441
  | 📏 **Metrología** | Cotas AABB/OBB, área, volumen, centro de masa, estanqueidad | trimesh |
421
442
  | 📐 **Primitivas** | Planos, cilindros y esferas con RANSAC (radios, ejes, normales) | NumPy |
@@ -28,6 +28,7 @@ Desarrollado por **[Voxel Enterprise](https://voxel.lat)**
28
28
  - [💰 Ahorro de tokens](#-ahorro-de-tokens)
29
29
  - [🔌 Sin internet (modo offline)](#-sin-internet-modo-offline)
30
30
  - [🤖 Piloto automático](#-piloto-automático)
31
+ - [🧠 Reverse3D Core (motor exclusivo)](#-reverse3d-core-motor-exclusivo)
31
32
  - [Capacidades](#capacidades)
32
33
  - [Herramientas externas opcionales](#herramientas-externas-opcionales)
33
34
  - [Referencia de la CLI](#referencia-de-la-cli)
@@ -151,7 +152,7 @@ Cada archivo modificado se **respalda antes** (`*.reverse3d-backup-<fecha>`). So
151
152
  | `--lite` | `-Lite` | Todo, con la IA local en la CPU (PyTorch CPU, ~250 MB en vez de ~2.5 GB) |
152
153
  | `--minimal` | `-Minimal` | Núcleo + MCP + IA local, sin herramientas externas |
153
154
  | `--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 |
155
+ | *(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
156
  | `--no-auto-approve` | `-NoAutoApprove` | Que los agentes pregunten antes de cada herramienta |
156
157
  | `--with recon,depth` | `-With recon,depth` | Extras a elección |
157
158
  | `--device auto\|cpu\|gpu` | `-Device …` | Dispositivo por defecto |
@@ -290,7 +291,7 @@ reverse3d config set verbosity full # si alguna vez necesitas el detalle com
290
291
 
291
292
  ## 🔌 Sin internet (modo offline)
292
293
 
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**.
294
+ 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
295
 
295
296
  - **Cambio automático:** si el equipo no detecta internet, reverse3d pasa solo a modo offline. No tienes que hacer nada.
296
297
  - **Comprobar:** `reverse3d offline status` dice si hay conexión y qué funciona sin ella. `reverse3d doctor` lo incluye en su informe.
@@ -339,6 +340,23 @@ Si las fotos están borrosas o falta una herramienta, **no reintenta en vano**:
339
340
 
340
341
  ---
341
342
 
343
+ ## 🧠 Reverse3D Core (motor exclusivo)
344
+
345
+ **Reverse3D Core** es el motor de reconstrucción propio de Voxel Enterprise. Es **propietario** (no forma parte de este repositorio de código abierto) y viene **incluido en reverse3d Studio**, que lo instala solo.
346
+
347
+ - **Cuando está instalado**, reverse3d lo usa automáticamente para fusionar las siluetas de los objetos. Aprende qué fotos son fiables, así que un brillo, un mal recorte o una foto mal ubicada **ya no borran el objeto**, y cada foto extra suma precisión.
348
+ - **Sin él**, reverse3d funciona igual con su método abierto.
349
+ - **Comprobarlo:** `reverse3d doctor` muestra `core: {available: true, version: …}`. El informe de cada trabajo indica qué motor se usó (`details.silhouette.engine`).
350
+
351
+ | Prueba interna (objeto de geometría conocida) | Método abierto | Reverse3D Core |
352
+ |---|---|---|
353
+ | Recortes perfectos | 0,943 | 0,941 |
354
+ | 15 % de recortes defectuosos | 0,000 (pierde el objeto) | **0,921** |
355
+
356
+ *(Coincidencia volumétrica, IoU: 1 = idéntico.)*
357
+
358
+ ---
359
+
342
360
  ## Capacidades
343
361
 
344
362
  | Área | Qué hace | Tecnología |
@@ -348,6 +366,8 @@ Si las fotos están borrosas o falta una herramienta, **no reintenta en vano**:
348
366
  | 🧱 **Plano → pieza** | Extrusión de contornos a sólido 3D con escala real | Shapely + trimesh |
349
367
  | 📷 **Fotogrametría** | Fotos → SfM → MVS denso → nube → malla | COLMAP + Open3D |
350
368
  | 🌊 **Profundidad con IA** | Una foto → mapa de profundidad → relieve 2.5D | Depth Anything V2 |
369
+ | 🎨 **Texturas** | Textura fotográfica 2048² desde las propias fotos (UV, visibilidad, sin costuras) en `model.glb` | xatlas + OpenCV |
370
+ | 🧠 **Puntos con IA** | Rescate automático de capturas difíciles con puntos y emparejamiento aprendidos | ALIKED + LightGlue (ONNX) |
351
371
  | 🛠️ **Reparación de mallas** | Limpieza, restos sueltos, agujeros, normales, Taubin, decimación, alineación OBB/PCA, escala | trimesh |
352
372
  | 📏 **Metrología** | Cotas AABB/OBB, área, volumen, centro de masa, estanqueidad | trimesh |
353
373
  | 📐 **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.11.0"
@@ -41,6 +41,16 @@ def _package_version(dist: str) -> str | None:
41
41
  return "installed"
42
42
 
43
43
 
44
+ def core_status() -> dict[str, object]:
45
+ """Whether reverse3d-core, the proprietary reconstruction engine, is active."""
46
+ from reverse3d.reconstruction.silhouette import core_engine
47
+
48
+ core = core_engine()
49
+ if core is None:
50
+ return {"available": False, "hint": "Included in reverse3d Studio."}
51
+ return {"available": True, "version": str(getattr(core, "__version__", "?"))}
52
+
53
+
44
54
  def environment_report(settings: Settings, *, include_offline: bool = True) -> dict[str, object]:
45
55
  """Return a JSON-serialisable description of the runtime environment.
46
56
 
@@ -70,6 +80,8 @@ def environment_report(settings: Settings, *, include_offline: bool = True) -> d
70
80
  "reverse3d": __version__,
71
81
  "author": f"{AUTHOR} ({AUTHOR_URL})",
72
82
  "official_source": OFFICIAL_REPOSITORY,
83
+ # Voxel Enterprise's proprietary engine (bundled with reverse3d Studio).
84
+ "core": core_status(),
73
85
  "python": sys.version.split()[0],
74
86
  "platform": platform.platform(),
75
87
  "workspace": str(settings.workspace),
@@ -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,27 @@ def _clean(
169
170
  return report.after
170
171
 
171
172
 
173
+ # SIFT features per photo when COLMAP only places the cameras.
174
+ _CAMERA_FEATURES = 4096
175
+
176
+ # Exhaustive matching stays practical for object captures up to this size.
177
+ _OBJECT_EXHAUSTIVE_LIMIT = 400
178
+
179
+
180
+ def _export_glb(mesh: Any, path: Path) -> None:
181
+ """Write a GLB that stands up in any viewer: glTF is Y-up, our models are Z-up.
182
+
183
+ The geometry keeps its Z-up coordinates and a root node rotates it, so
184
+ Blender, game engines and web viewers show it upright.
185
+ """
186
+ import trimesh
187
+
188
+ scene = trimesh.Scene()
189
+ up = trimesh.transformations.rotation_matrix(-np.pi / 2, [1, 0, 0])
190
+ scene.add_geometry(mesh, node_name="model", transform=up)
191
+ scene.export(path)
192
+
193
+
172
194
  def _has_vertex_colors(path: Path) -> bool:
173
195
  import trimesh
174
196
 
@@ -253,6 +275,30 @@ _DEFAULT_LOOK = {
253
275
  }
254
276
 
255
277
 
278
+ def _texture(run: _Run, model: Path, photo: Any, to_world: list[list[float]], scale: float) -> None:
279
+ """Bake a photo texture onto the final model: model.glb gets real detail."""
280
+ try:
281
+ import trimesh
282
+
283
+ from reverse3d.reconstruction.texture import bake_texture
284
+
285
+ mesh: Any = trimesh.load(model, process=False, force="mesh")
286
+ # The cleaned model is the carved hull scaled by ``scale``.
287
+ world = np.asarray(to_world) @ np.diag([1 / scale, 1 / scale, 1 / scale, 1.0])
288
+ textured = run.step(
289
+ "texture",
290
+ lambda: bake_texture(
291
+ mesh, Path(photo.images_dir), Path(photo.sparse_text_dir), to_world=world
292
+ ),
293
+ )
294
+ glb = model.with_name("model.glb")
295
+ _export_glb(textured, glb)
296
+ run.outputs["model_glb"] = str(glb)
297
+ run.details["texture"] = {"size": 2048, "faces": len(textured.faces)}
298
+ except Exception as exc: # the texture is a bonus: never fail the model for it
299
+ logger.info("Texture skipped: %s", exc)
300
+
301
+
256
302
  def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
257
303
  from reverse3d.reconstruction.photogrammetry import (
258
304
  PhotogrammetryOptions,
@@ -303,27 +349,51 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
303
349
  # and the shape is carved from the silhouettes, which also works on plain,
304
350
  # white or glossy surfaces. Open scenes keep classic dense reconstruction.
305
351
  silhouette = scene in ("object", "character")
306
-
307
- def photogrammetry(classic: bool) -> PhotogrammetryResult:
352
+ # An object is shot in several rings plus close-ups, never in one ordered
353
+ # sequence: match every photo with every other so all of them join one model
354
+ # (sequential matching split a 153-photo bottle into four pieces).
355
+ matcher = preset.matcher
356
+ if silhouette and matcher == "auto" and analysis.image_count <= _OBJECT_EXHAUSTIVE_LIMIT:
357
+ matcher = "exhaustive"
358
+
359
+ def photogrammetry(classic: bool, features: str = "sift") -> PhotogrammetryResult:
308
360
  return run_photogrammetry(
309
361
  src,
310
362
  run.out / "reconstruction",
311
363
  run.settings,
312
364
  PhotogrammetryOptions(
313
365
  quality=quality, # type: ignore[arg-type]
314
- matcher=preset.matcher,
366
+ matcher=matcher,
315
367
  dense="auto" if classic else "off",
316
368
  use_gpu=wants_gpu(run.settings),
317
369
  build_mesh=classic,
318
370
  poisson_depth=preset.poisson_depth,
319
371
  density_quantile=preset.density_quantile,
372
+ features=features, # type: ignore[arg-type]
373
+ # Silhouette objects only need the cameras: fewer features keep
374
+ # exhaustive matching of a few hundred photos fast on any GPU.
375
+ max_features=None if classic else _CAMERA_FEATURES,
320
376
  ),
321
- overwrite=opt.overwrite or not classic,
377
+ overwrite=opt.overwrite or not classic or features != "sift",
322
378
  )
323
379
 
324
- photo = run.step("photogrammetry", lambda: photogrammetry(classic=not silhouette))
380
+ try:
381
+ photo = run.step("photogrammetry", lambda: photogrammetry(classic=not silhouette))
382
+ except ProcessingError as exc:
383
+ # Rescue for hard sets: learned AI features (ALIKED + LightGlue) place
384
+ # photos that SIFT cannot. Slower (CPU), so only when SIFT fell short.
385
+ logger.info("SIFT could not place the photos (%s); retrying with AI features", exc.message)
386
+ try:
387
+ photo = run.step(
388
+ "photogrammetry_ai",
389
+ lambda: photogrammetry(classic=not silhouette, features="aliked"),
390
+ )
391
+ except (ProcessingError, DependencyMissingError, ExternalToolError):
392
+ raise exc from None
393
+ run.warnings.append("Photos placed with AI features (ALIKED + LightGlue).")
325
394
  mesh_path: str | None = photo.mesh_path
326
395
  upright = False
396
+ hull_world: list[list[float]] | None = None
327
397
  if silhouette:
328
398
  from reverse3d.reconstruction.silhouette import visual_hull
329
399
  from reverse3d.vision.segmenter import ObjectSegmenter
@@ -374,6 +444,7 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
374
444
  run.details["silhouette"] = hull.to_dict()
375
445
  mesh_path = hull.mesh_path
376
446
  upright = True
447
+ hull_world = hull.to_world
377
448
  if photo.registered_images >= 0.9 * max(photo.total_images, 1):
378
449
  # COLMAP placed (almost) every photo: the quick pre-check's capture advice
379
450
  # would only confuse.
@@ -422,6 +493,8 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
422
493
  keep_largest=preset.keep_largest,
423
494
  repair=opt.repair,
424
495
  )
496
+ if upright and hull_world is not None:
497
+ _texture(run, model, photo, hull_world, opt.scale)
425
498
  return _measure(run, model)
426
499
 
427
500
 
@@ -610,7 +683,12 @@ def run_recipe(
610
683
  source = prepare_heic_input(source, out) # iPhone HEIC photos -> JPEG
611
684
  stats = _RECIPES[recipe](run, source, opt)
612
685
  if opt.render:
613
- _render(run, Path(run.outputs.get("model_color") or run.outputs["model"]), opt.overwrite)
686
+ textured = run.details.get("texture") and run.outputs.get("model_glb")
687
+ _render(
688
+ run,
689
+ Path(textured or run.outputs.get("model_color") or run.outputs["model"]),
690
+ opt.overwrite,
691
+ )
614
692
 
615
693
  summary: dict[str, Any] = {
616
694
  "recipe": recipe,
@@ -94,6 +94,12 @@ 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"
100
+ #: SIFT features per photo; None uses the quality preset. Placing cameras
101
+ #: alone (silhouette objects) needs far fewer than a dense surface.
102
+ max_features: int | None = None
97
103
 
98
104
  def validate(self) -> None:
99
105
  if self.quality not in _PRESETS:
@@ -297,18 +303,43 @@ class ColmapPipeline:
297
303
  "--ImageReader.single_camera",
298
304
  "1" if options.single_camera else "0",
299
305
  ]
306
+ learned = options.features == "aliked"
307
+ if learned:
308
+ from reverse3d.offline import colmap_model_path, download_colmap_models
309
+
310
+ if not colmap_model_path("aliked-n16rot.onnx"):
311
+ download_colmap_models()
312
+ aliked = colmap_model_path("aliked-n16rot.onnx")
313
+ if aliked is None:
314
+ raise DependencyMissingError(
315
+ "The AI feature model (ALIKED) is not on this computer.",
316
+ hint="Connect to the internet once and run: reverse3d offline prepare",
317
+ )
318
+ # ONNX on the CPU: the CUDA runtime it needs is not bundled with COLMAP.
319
+ args += [
320
+ "--FeatureExtraction.type",
321
+ "ALIKED_N16ROT",
322
+ "--FeatureExtraction.use_gpu",
323
+ "0",
324
+ "--AlikedExtraction.n16rot_model_path",
325
+ str(aliked),
326
+ ]
300
327
  self._stage(
301
328
  result,
302
329
  "feature_extractor",
303
330
  self._with_options(
304
331
  "feature_extractor",
305
332
  args,
306
- {
333
+ {}
334
+ if learned
335
+ else {
307
336
  ("FeatureExtraction.use_gpu", "SiftExtraction.use_gpu"): gpu,
308
337
  ("FeatureExtraction.max_image_size", "SiftExtraction.max_image_size"): str(
309
338
  preset["max_image_size"]
310
339
  ),
311
- ("SiftExtraction.max_num_features",): str(preset["max_features"]),
340
+ ("SiftExtraction.max_num_features",): str(
341
+ options.max_features or preset["max_features"]
342
+ ),
312
343
  },
313
344
  ),
314
345
  )
@@ -324,8 +355,19 @@ class ColmapPipeline:
324
355
  matcher = "exhaustive" if len(images) <= _EXHAUSTIVE_LIMIT else "sequential"
325
356
  command = f"{matcher}_matcher"
326
357
  extra: dict[tuple[str, ...], str] = {
327
- ("FeatureMatching.use_gpu", "SiftMatching.use_gpu"): gpu
358
+ ("FeatureMatching.use_gpu", "SiftMatching.use_gpu"): "0" if learned else gpu
328
359
  }
360
+ if learned:
361
+ from reverse3d.offline import colmap_model_path
362
+
363
+ glue = colmap_model_path("aliked-lightglue.onnx")
364
+ if glue is None:
365
+ raise DependencyMissingError(
366
+ "The AI matching model (LightGlue) is not on this computer.",
367
+ hint="Connect to the internet once and run: reverse3d offline prepare",
368
+ )
369
+ extra[("FeatureMatching.type",)] = "ALIKED_LIGHTGLUE"
370
+ extra[("AlikedMatching.lightglue_model_path",)] = str(glue)
329
371
  if matcher == "spatial":
330
372
  extra[("SpatialMatching.is_gps",)] = "1"
331
373
  try: