reverse3d 0.9.0__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.9.0/reverse3d.egg-info → reverse3d-0.10.0}/PKG-INFO +6 -3
- {reverse3d-0.9.0 → reverse3d-0.10.0}/README.md +4 -2
- {reverse3d-0.9.0 → reverse3d-0.10.0}/pyproject.toml +1 -0
- reverse3d-0.10.0/reverse3d/_version.py +1 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/offline.py +65 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/recipes.py +67 -5
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/reconstruction/photogrammetry.py +39 -2
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/reconstruction/silhouette.py +6 -1
- reverse3d-0.10.0/reverse3d/reconstruction/texture.py +195 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/rendering/_blender_job.py +7 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0/reverse3d.egg-info}/PKG-INFO +6 -3
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d.egg-info/SOURCES.txt +2 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d.egg-info/requires.txt +1 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_color.py +15 -0
- reverse3d-0.10.0/tests/test_texture.py +36 -0
- reverse3d-0.9.0/reverse3d/_version.py +0 -1
- {reverse3d-0.9.0 → reverse3d-0.10.0}/LICENSE +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/NOTICE +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/__init__.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/__main__.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/_about.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/agent/__init__.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/agent/compact.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/agent/mcp_server.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/agent/prompts.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/agent/skill/SKILL.md +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/agent/tools.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/autopilot.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/cli.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/config.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/doctor.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/errors.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/hardware.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/imaging.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/logging_utils.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/mesh_io.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/profiles.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/py.typed +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/reconstruction/__init__.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/reconstruction/depth_estimation.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/reconstruction/pointcloud.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/rendering/__init__.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/rendering/blender_render.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/rendering/preview.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/reverse_eng/__init__.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/reverse_eng/geometry_fit.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/reverse_eng/measure.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/reverse_eng/mesh_cleaner.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/reverse_eng/parametric.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/scenes.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/security.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/setup_agents.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/userconfig.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/vision/__init__.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/vision/classifier.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/vision/feature_matcher.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/vision/mask_extractor.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/vision/segmenter.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/watch.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d.egg-info/dependency_links.txt +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d.egg-info/entry_points.txt +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d.egg-info/top_level.txt +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/setup.cfg +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_agent_tools.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_authorship.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_automation.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_cli.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_cli_automation.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_config.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_geometry_fit.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_heic.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_mesh.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_offline_autopilot.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_optional_extras.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_reconstruction.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_render.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_scenes.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_security.py +0 -0
- {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_silhouette.py +0 -0
- {reverse3d-0.9.0 → 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
|
|
@@ -374,6 +428,7 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
|
|
|
374
428
|
run.details["silhouette"] = hull.to_dict()
|
|
375
429
|
mesh_path = hull.mesh_path
|
|
376
430
|
upright = True
|
|
431
|
+
hull_world = hull.to_world
|
|
377
432
|
if photo.registered_images >= 0.9 * max(photo.total_images, 1):
|
|
378
433
|
# COLMAP placed (almost) every photo: the quick pre-check's capture advice
|
|
379
434
|
# would only confuse.
|
|
@@ -422,6 +477,8 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
|
|
|
422
477
|
keep_largest=preset.keep_largest,
|
|
423
478
|
repair=opt.repair,
|
|
424
479
|
)
|
|
480
|
+
if upright and hull_world is not None:
|
|
481
|
+
_texture(run, model, photo, hull_world, opt.scale)
|
|
425
482
|
return _measure(run, model)
|
|
426
483
|
|
|
427
484
|
|
|
@@ -610,7 +667,12 @@ def run_recipe(
|
|
|
610
667
|
source = prepare_heic_input(source, out) # iPhone HEIC photos -> JPEG
|
|
611
668
|
stats = _RECIPES[recipe](run, source, opt)
|
|
612
669
|
if opt.render:
|
|
613
|
-
|
|
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
|
+
)
|
|
614
676
|
|
|
615
677
|
summary: dict[str, Any] = {
|
|
616
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:
|
|
@@ -70,6 +70,8 @@ class HullResult:
|
|
|
70
70
|
support_plane: bool
|
|
71
71
|
ai_segmentation: bool = False
|
|
72
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
|
|
73
75
|
|
|
74
76
|
def to_dict(self) -> dict[str, object]:
|
|
75
77
|
return asdict(self)
|
|
@@ -540,7 +542,9 @@ def visual_hull(
|
|
|
540
542
|
transform = np.eye(4)
|
|
541
543
|
transform[:3, :3] = basis
|
|
542
544
|
mesh.apply_transform(transform)
|
|
543
|
-
|
|
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
|
|
544
548
|
|
|
545
549
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
546
550
|
mesh.export(output_path)
|
|
@@ -553,4 +557,5 @@ def visual_hull(
|
|
|
553
557
|
plane is not None,
|
|
554
558
|
ai is not None,
|
|
555
559
|
carved_by_depth,
|
|
560
|
+
np.linalg.inv(world_to_model).tolist(),
|
|
556
561
|
)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Photo textures: a UV-mapped texture baked from the photos (detail beyond vertices).
|
|
2
|
+
|
|
3
|
+
Per-vertex colour can only be as detailed as the mesh. A texture is not: every
|
|
4
|
+
texel gets its own colour, so logos, lettering and fine patterns survive on a
|
|
5
|
+
light mesh. Steps:
|
|
6
|
+
|
|
7
|
+
1. unwrap the mesh into a UV atlas (xatlas, MIT);
|
|
8
|
+
2. rasterise each triangle into the atlas to know the 3D point and normal of
|
|
9
|
+
every texel;
|
|
10
|
+
3. colour each texel from the photos that see it head-on and unoccluded
|
|
11
|
+
(weights favour frontal views, so the result stays sharp);
|
|
12
|
+
4. bleed colour into the empty gutters so mip-mapping shows no seams.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import cv2
|
|
22
|
+
import numpy as np
|
|
23
|
+
import trimesh
|
|
24
|
+
|
|
25
|
+
from reverse3d.errors import DependencyMissingError, ProcessingError
|
|
26
|
+
from reverse3d.reconstruction.silhouette import _project, _View, read_colmap_text
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _unwrap(mesh: trimesh.Trimesh) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
32
|
+
try:
|
|
33
|
+
import xatlas
|
|
34
|
+
except ImportError as exc: # pragma: no cover - core dependency
|
|
35
|
+
raise DependencyMissingError(
|
|
36
|
+
"xatlas is needed for textures.", hint="pip install xatlas"
|
|
37
|
+
) from exc
|
|
38
|
+
vmapping, indices, uvs = xatlas.parametrize(
|
|
39
|
+
np.asarray(mesh.vertices, np.float32), np.asarray(mesh.faces, np.uint32)
|
|
40
|
+
)
|
|
41
|
+
return np.asarray(vmapping), np.asarray(indices, np.int64), np.asarray(uvs, np.float64)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _texel_points(
|
|
45
|
+
verts: np.ndarray, faces: np.ndarray, uvs: np.ndarray, size: int
|
|
46
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
47
|
+
"""For every covered texel: (row, col), its 3D point and the face normal."""
|
|
48
|
+
face_id = np.full((size, size), -1, np.int32)
|
|
49
|
+
px = uvs * (size - 1)
|
|
50
|
+
px[:, 1] = (size - 1) - px[:, 1] # glTF v goes up, image rows go down
|
|
51
|
+
for f, tri in enumerate(faces):
|
|
52
|
+
cv2.fillConvexPoly(face_id, np.round(px[tri]).astype(np.int32), f)
|
|
53
|
+
rows, cols = np.nonzero(face_id >= 0)
|
|
54
|
+
fids = face_id[rows, cols]
|
|
55
|
+
tri = faces[fids]
|
|
56
|
+
a, b, c = px[tri[:, 0]], px[tri[:, 1]], px[tri[:, 2]]
|
|
57
|
+
p = np.column_stack([cols, rows]).astype(np.float64)
|
|
58
|
+
v0, v1, v2 = b - a, c - a, p - a
|
|
59
|
+
d00 = (v0 * v0).sum(1)
|
|
60
|
+
d01 = (v0 * v1).sum(1)
|
|
61
|
+
d11 = (v1 * v1).sum(1)
|
|
62
|
+
d20 = (v2 * v0).sum(1)
|
|
63
|
+
d21 = (v2 * v1).sum(1)
|
|
64
|
+
denom = d00 * d11 - d01 * d01
|
|
65
|
+
denom[np.abs(denom) < 1e-12] = 1e-12
|
|
66
|
+
w1 = np.clip((d11 * d20 - d01 * d21) / denom, 0, 1)
|
|
67
|
+
w2 = np.clip((d00 * d21 - d01 * d20) / denom, 0, 1)
|
|
68
|
+
w0 = np.clip(1 - w1 - w2, 0, 1)
|
|
69
|
+
points = (
|
|
70
|
+
w0[:, None] * verts[tri[:, 0]]
|
|
71
|
+
+ w1[:, None] * verts[tri[:, 1]]
|
|
72
|
+
+ w2[:, None] * verts[tri[:, 2]]
|
|
73
|
+
)
|
|
74
|
+
e1 = verts[tri[:, 1]] - verts[tri[:, 0]]
|
|
75
|
+
e2 = verts[tri[:, 2]] - verts[tri[:, 0]]
|
|
76
|
+
normals = np.cross(e1, e2)
|
|
77
|
+
normals /= np.maximum(np.linalg.norm(normals, axis=1, keepdims=True), 1e-12)
|
|
78
|
+
return np.column_stack([rows, cols]), points, normals
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _bilinear(img: np.ndarray, u: np.ndarray, v: np.ndarray) -> np.ndarray:
|
|
82
|
+
"""Bilinear colour samples at sub-pixel positions (no size limits, unlike remap)."""
|
|
83
|
+
x0 = np.floor(u).astype(np.int64)
|
|
84
|
+
y0 = np.floor(v).astype(np.int64)
|
|
85
|
+
fx = (u - x0)[:, None]
|
|
86
|
+
fy = (v - y0)[:, None]
|
|
87
|
+
x1 = np.minimum(x0 + 1, img.shape[1] - 1)
|
|
88
|
+
y1 = np.minimum(y0 + 1, img.shape[0] - 1)
|
|
89
|
+
top = img[y0, x0] * (1 - fx) + img[y0, x1] * fx
|
|
90
|
+
bottom = img[y1, x0] * (1 - fx) + img[y1, x1] * fx
|
|
91
|
+
return np.asarray(top * (1 - fy) + bottom * fy, np.float64)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _bake(
|
|
95
|
+
points: np.ndarray,
|
|
96
|
+
normals: np.ndarray,
|
|
97
|
+
views: list[_View],
|
|
98
|
+
images: list[tuple[np.ndarray, float]],
|
|
99
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
100
|
+
"""Colour per texel and whether any photo saw it."""
|
|
101
|
+
total = np.zeros((len(points), 3))
|
|
102
|
+
weight = np.zeros(len(points))
|
|
103
|
+
for (img, scale), view in zip(images, views, strict=True):
|
|
104
|
+
center = -view.rotation.T @ view.translation
|
|
105
|
+
to_cam = center - points
|
|
106
|
+
to_cam /= np.linalg.norm(to_cam, axis=1, keepdims=True)
|
|
107
|
+
facing = np.einsum("ij,ij->i", normals, to_cam)
|
|
108
|
+
uv, z = _project(points, view)
|
|
109
|
+
u, v = uv[:, 0] * scale, uv[:, 1] * scale
|
|
110
|
+
h, w = img.shape[:2]
|
|
111
|
+
ok = (facing > 0.15) & (z > 0) & (u >= 0) & (v >= 0) & (u < w - 1) & (v < h - 1)
|
|
112
|
+
if not ok.any():
|
|
113
|
+
continue
|
|
114
|
+
cell = 3
|
|
115
|
+
cu = (u[ok] / cell).astype(np.int64)
|
|
116
|
+
cv_ = (v[ok] / cell).astype(np.int64)
|
|
117
|
+
zbuf = np.full((h // cell + 1, w // cell + 1), np.inf)
|
|
118
|
+
np.minimum.at(zbuf, (cv_, cu), z[ok])
|
|
119
|
+
span = float(np.ptp(z[ok])) or 1.0
|
|
120
|
+
visible = z[ok] <= zbuf[cv_, cu] + 0.02 * span
|
|
121
|
+
idx = np.flatnonzero(ok)[visible]
|
|
122
|
+
sample = _bilinear(img, u[idx], v[idx])[:, ::-1] # BGR -> RGB
|
|
123
|
+
wgt = facing[idx] ** 6 # strongly prefer frontal views: sharp, not averaged mush
|
|
124
|
+
total[idx] += sample * wgt[:, None]
|
|
125
|
+
weight[idx] += wgt
|
|
126
|
+
seen = weight > 0
|
|
127
|
+
colours = np.zeros((len(points), 3))
|
|
128
|
+
colours[seen] = total[seen] / weight[seen, None]
|
|
129
|
+
return colours, seen
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def bake_texture(
|
|
133
|
+
mesh: trimesh.Trimesh,
|
|
134
|
+
image_dir: Path,
|
|
135
|
+
model_text_dir: Path,
|
|
136
|
+
*,
|
|
137
|
+
to_world: np.ndarray | None = None,
|
|
138
|
+
size: int = 2048,
|
|
139
|
+
max_side: int = 2048,
|
|
140
|
+
) -> trimesh.Trimesh:
|
|
141
|
+
"""A copy of ``mesh`` with UVs and a texture baked from the COLMAP photos.
|
|
142
|
+
|
|
143
|
+
``to_world`` maps the mesh's coordinates to the COLMAP world (4x4), when the
|
|
144
|
+
mesh was moved or scaled after reconstruction.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
ProcessingError: photos cannot be read or nothing could be baked.
|
|
148
|
+
"""
|
|
149
|
+
from PIL import Image
|
|
150
|
+
|
|
151
|
+
views, _ = read_colmap_text(model_text_dir)
|
|
152
|
+
images: list[tuple[np.ndarray, float]] = []
|
|
153
|
+
for view in views:
|
|
154
|
+
bgr = cv2.imread(str(image_dir / view.name))
|
|
155
|
+
if bgr is None:
|
|
156
|
+
raise ProcessingError(f"Could not read {view.name} for the texture.")
|
|
157
|
+
h, w = bgr.shape[:2]
|
|
158
|
+
scale = min(1.0, max_side / max(h, w))
|
|
159
|
+
if scale < 1.0:
|
|
160
|
+
bgr = cv2.resize(
|
|
161
|
+
bgr, (round(w * scale), round(h * scale)), interpolation=cv2.INTER_AREA
|
|
162
|
+
)
|
|
163
|
+
images.append((bgr, scale))
|
|
164
|
+
|
|
165
|
+
vmapping, faces, uvs = _unwrap(mesh)
|
|
166
|
+
verts_model = np.asarray(mesh.vertices)[vmapping]
|
|
167
|
+
verts_world = verts_model
|
|
168
|
+
if to_world is not None:
|
|
169
|
+
verts_world = trimesh.transform_points(verts_model, to_world)
|
|
170
|
+
texel_rc, points, normals = _texel_points(verts_world, faces, uvs, size)
|
|
171
|
+
colours, seen = _bake(points, normals, views, images)
|
|
172
|
+
if not seen.any():
|
|
173
|
+
raise ProcessingError("No photo sees the model: the texture could not be baked.")
|
|
174
|
+
|
|
175
|
+
texture: np.ndarray = np.zeros((size, size, 3), np.uint8)
|
|
176
|
+
covered = np.zeros((size, size), np.uint8)
|
|
177
|
+
fill = np.median(colours[seen], axis=0)
|
|
178
|
+
colours[~seen] = fill
|
|
179
|
+
rr, cc = texel_rc[:, 0], texel_rc[:, 1]
|
|
180
|
+
texture[rr, cc] = np.clip(colours, 0, 255).astype(np.uint8)
|
|
181
|
+
covered[rr, cc] = 255
|
|
182
|
+
# Isolated wrong texels (a grazing view catching background) look like salt and
|
|
183
|
+
# pepper; a 3x3 median removes them and keeps lettering and edges.
|
|
184
|
+
texture = cv2.medianBlur(texture, 3)
|
|
185
|
+
# Bleed colours into the gutters between UV islands (no seams when filtered).
|
|
186
|
+
texture = cv2.inpaint(texture, 255 - covered, 3, cv2.INPAINT_TELEA) if size <= 2048 else texture
|
|
187
|
+
|
|
188
|
+
material = trimesh.visual.material.PBRMaterial(
|
|
189
|
+
baseColorTexture=Image.fromarray(texture), metallicFactor=0.0, roughnessFactor=0.7
|
|
190
|
+
)
|
|
191
|
+
textured = trimesh.Trimesh(verts_model, faces, process=False)
|
|
192
|
+
visual: Any = trimesh.visual.TextureVisuals(uv=uvs, material=material)
|
|
193
|
+
textured.visual = visual
|
|
194
|
+
logger.info("Texture baked: %dx%d from %d photos", size, size, len(views))
|
|
195
|
+
return textured
|
|
@@ -121,6 +121,13 @@ def shade(obj: bpy.types.Object, mode: str) -> None:
|
|
|
121
121
|
|
|
122
122
|
|
|
123
123
|
def make_material(obj: bpy.types.Object, job: dict) -> None:
|
|
124
|
+
existing = obj.active_material
|
|
125
|
+
if (
|
|
126
|
+
existing is not None
|
|
127
|
+
and existing.node_tree is not None
|
|
128
|
+
and any(node.type == "TEX_IMAGE" for node in existing.node_tree.nodes)
|
|
129
|
+
):
|
|
130
|
+
return # a photo texture (glTF import): keep it as it is
|
|
124
131
|
mat = bpy.data.materials.new("reverse3d_material")
|
|
125
132
|
mat.use_nodes = True
|
|
126
133
|
bsdf = mat.node_tree.nodes.get("Principled BSDF")
|
|
@@ -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 |
|
|
@@ -41,6 +41,7 @@ reverse3d/reconstruction/depth_estimation.py
|
|
|
41
41
|
reverse3d/reconstruction/photogrammetry.py
|
|
42
42
|
reverse3d/reconstruction/pointcloud.py
|
|
43
43
|
reverse3d/reconstruction/silhouette.py
|
|
44
|
+
reverse3d/reconstruction/texture.py
|
|
44
45
|
reverse3d/rendering/__init__.py
|
|
45
46
|
reverse3d/rendering/_blender_job.py
|
|
46
47
|
reverse3d/rendering/blender_render.py
|
|
@@ -72,4 +73,5 @@ tests/test_render.py
|
|
|
72
73
|
tests/test_scenes.py
|
|
73
74
|
tests/test_security.py
|
|
74
75
|
tests/test_silhouette.py
|
|
76
|
+
tests/test_texture.py
|
|
75
77
|
tests/test_vision.py
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
|
|
5
5
|
import numpy as np
|
|
6
|
+
import pytest
|
|
6
7
|
import trimesh
|
|
7
8
|
|
|
8
9
|
from reverse3d.config import Settings
|
|
@@ -47,3 +48,17 @@ def test_plain_meshes_get_no_colour_outputs(tmp_path: Path, settings: Settings)
|
|
|
47
48
|
run = _Run(recipe="mesh_report", settings=settings, out=tmp_path)
|
|
48
49
|
_clean(run, plain, tmp_path / "model.stl", scale=1.0, overwrite=True)
|
|
49
50
|
assert "model_glb" not in run.outputs
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_glb_stands_up_in_y_up_viewers(tmp_path: Path) -> None:
|
|
54
|
+
from reverse3d.recipes import _export_glb
|
|
55
|
+
|
|
56
|
+
tall = trimesh.creation.box(extents=(1.0, 1.0, 4.0)) # Z-up, like our models
|
|
57
|
+
tall.apply_translation((0, 0, 2.0))
|
|
58
|
+
_export_glb(tall, tmp_path / "m.glb")
|
|
59
|
+
scene = trimesh.load(tmp_path / "m.glb")
|
|
60
|
+
extents = scene.to_geometry().extents
|
|
61
|
+
assert extents[1] == pytest.approx(4.0) # glTF up axis is Y
|
|
62
|
+
# Viewers that read only the geometry (Studio) still get the Z-up mesh.
|
|
63
|
+
raw = next(iter(scene.geometry.values()))
|
|
64
|
+
assert raw.extents[2] == pytest.approx(4.0)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pytest
|
|
8
|
+
import trimesh
|
|
9
|
+
|
|
10
|
+
pytest.importorskip("xatlas")
|
|
11
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
12
|
+
|
|
13
|
+
from test_silhouette import HEIGHT, RADIUS, _scene
|
|
14
|
+
|
|
15
|
+
from reverse3d.reconstruction.texture import bake_texture
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_texture_is_baked_from_the_photos(tmp_path: Path) -> None:
|
|
19
|
+
images, text = _scene(tmp_path)
|
|
20
|
+
mesh = trimesh.creation.cylinder(radius=RADIUS * 0.98, height=HEIGHT * 0.98, sections=48)
|
|
21
|
+
mesh = mesh.subdivide()
|
|
22
|
+
mesh.apply_translation([0, 0, HEIGHT / 2])
|
|
23
|
+
# The model is usually moved after reconstruction: give the baker the way back.
|
|
24
|
+
moved = mesh.copy()
|
|
25
|
+
moved.apply_translation([5.0, 0, 0])
|
|
26
|
+
to_world = trimesh.transformations.translation_matrix([-5.0, 0, 0])
|
|
27
|
+
textured = bake_texture(moved, images, text, to_world=to_world, size=256)
|
|
28
|
+
image = np.asarray(textured.visual.material.baseColorTexture)
|
|
29
|
+
assert image.shape == (256, 256, 3)
|
|
30
|
+
assert textured.visual.uv.shape == (len(textured.vertices), 2)
|
|
31
|
+
assert np.allclose(textured.bounds, moved.bounds, atol=1e-6) # geometry unchanged
|
|
32
|
+
# The cylinder is white in every photo: most of the texture is bright.
|
|
33
|
+
assert np.median(image) > 200
|
|
34
|
+
glb = tmp_path / "model.glb"
|
|
35
|
+
textured.export(glb)
|
|
36
|
+
assert glb.stat().st_size > 1000
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.9.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|