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.
- {reverse3d-0.8.1/reverse3d.egg-info → reverse3d-0.10.0}/PKG-INFO +6 -3
- {reverse3d-0.8.1 → reverse3d-0.10.0}/README.md +4 -2
- {reverse3d-0.8.1 → reverse3d-0.10.0}/pyproject.toml +1 -0
- reverse3d-0.10.0/reverse3d/_version.py +1 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/offline.py +65 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/recipes.py +101 -11
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/reconstruction/photogrammetry.py +39 -2
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/reconstruction/silhouette.py +121 -2
- reverse3d-0.10.0/reverse3d/reconstruction/texture.py +195 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/rendering/_blender_job.py +7 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0/reverse3d.egg-info}/PKG-INFO +6 -3
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d.egg-info/SOURCES.txt +2 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d.egg-info/requires.txt +1 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_color.py +15 -0
- reverse3d-0.10.0/tests/test_silhouette.py +284 -0
- reverse3d-0.10.0/tests/test_texture.py +36 -0
- reverse3d-0.8.1/reverse3d/_version.py +0 -1
- reverse3d-0.8.1/tests/test_silhouette.py +0 -158
- {reverse3d-0.8.1 → reverse3d-0.10.0}/LICENSE +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/NOTICE +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/__init__.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/__main__.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/_about.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/agent/__init__.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/agent/compact.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/agent/mcp_server.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/agent/prompts.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/agent/skill/SKILL.md +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/agent/tools.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/autopilot.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/cli.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/config.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/doctor.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/errors.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/hardware.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/imaging.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/logging_utils.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/mesh_io.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/profiles.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/py.typed +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/reconstruction/__init__.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/reconstruction/depth_estimation.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/reconstruction/pointcloud.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/rendering/__init__.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/rendering/blender_render.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/rendering/preview.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/reverse_eng/__init__.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/reverse_eng/geometry_fit.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/reverse_eng/measure.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/reverse_eng/mesh_cleaner.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/reverse_eng/parametric.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/scenes.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/security.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/setup_agents.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/userconfig.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/vision/__init__.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/vision/classifier.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/vision/feature_matcher.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/vision/mask_extractor.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/vision/segmenter.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d/watch.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d.egg-info/dependency_links.txt +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d.egg-info/entry_points.txt +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/reverse3d.egg-info/top_level.txt +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/setup.cfg +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_agent_tools.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_authorship.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_automation.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_cli.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_cli_automation.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_config.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_geometry_fit.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_heic.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_mesh.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_offline_autopilot.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_optional_extras.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_reconstruction.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_render.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_scenes.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.10.0}/tests/test_security.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 |
|
|
@@ -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")
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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),
|
|
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
|
)
|