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.
Files changed (80) hide show
  1. {reverse3d-0.9.0/reverse3d.egg-info → reverse3d-0.10.0}/PKG-INFO +6 -3
  2. {reverse3d-0.9.0 → reverse3d-0.10.0}/README.md +4 -2
  3. {reverse3d-0.9.0 → reverse3d-0.10.0}/pyproject.toml +1 -0
  4. reverse3d-0.10.0/reverse3d/_version.py +1 -0
  5. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/offline.py +65 -0
  6. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/recipes.py +67 -5
  7. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/reconstruction/photogrammetry.py +39 -2
  8. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/reconstruction/silhouette.py +6 -1
  9. reverse3d-0.10.0/reverse3d/reconstruction/texture.py +195 -0
  10. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/rendering/_blender_job.py +7 -0
  11. {reverse3d-0.9.0 → reverse3d-0.10.0/reverse3d.egg-info}/PKG-INFO +6 -3
  12. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d.egg-info/SOURCES.txt +2 -0
  13. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d.egg-info/requires.txt +1 -0
  14. {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_color.py +15 -0
  15. reverse3d-0.10.0/tests/test_texture.py +36 -0
  16. reverse3d-0.9.0/reverse3d/_version.py +0 -1
  17. {reverse3d-0.9.0 → reverse3d-0.10.0}/LICENSE +0 -0
  18. {reverse3d-0.9.0 → reverse3d-0.10.0}/NOTICE +0 -0
  19. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/__init__.py +0 -0
  20. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/__main__.py +0 -0
  21. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/_about.py +0 -0
  22. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/agent/__init__.py +0 -0
  23. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/agent/compact.py +0 -0
  24. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/agent/mcp_server.py +0 -0
  25. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/agent/prompts.py +0 -0
  26. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/agent/skill/SKILL.md +0 -0
  27. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/agent/tools.py +0 -0
  28. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/autopilot.py +0 -0
  29. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/cli.py +0 -0
  30. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/config.py +0 -0
  31. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/doctor.py +0 -0
  32. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/errors.py +0 -0
  33. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/hardware.py +0 -0
  34. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/imaging.py +0 -0
  35. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/logging_utils.py +0 -0
  36. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/mesh_io.py +0 -0
  37. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/profiles.py +0 -0
  38. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/py.typed +0 -0
  39. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/reconstruction/__init__.py +0 -0
  40. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/reconstruction/depth_estimation.py +0 -0
  41. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/reconstruction/pointcloud.py +0 -0
  42. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/rendering/__init__.py +0 -0
  43. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/rendering/blender_render.py +0 -0
  44. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/rendering/preview.py +0 -0
  45. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/reverse_eng/__init__.py +0 -0
  46. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/reverse_eng/geometry_fit.py +0 -0
  47. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/reverse_eng/measure.py +0 -0
  48. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/reverse_eng/mesh_cleaner.py +0 -0
  49. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/reverse_eng/parametric.py +0 -0
  50. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/scenes.py +0 -0
  51. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/security.py +0 -0
  52. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/setup_agents.py +0 -0
  53. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/userconfig.py +0 -0
  54. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/vision/__init__.py +0 -0
  55. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/vision/classifier.py +0 -0
  56. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/vision/feature_matcher.py +0 -0
  57. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/vision/mask_extractor.py +0 -0
  58. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/vision/segmenter.py +0 -0
  59. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d/watch.py +0 -0
  60. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d.egg-info/dependency_links.txt +0 -0
  61. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d.egg-info/entry_points.txt +0 -0
  62. {reverse3d-0.9.0 → reverse3d-0.10.0}/reverse3d.egg-info/top_level.txt +0 -0
  63. {reverse3d-0.9.0 → reverse3d-0.10.0}/setup.cfg +0 -0
  64. {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_agent_tools.py +0 -0
  65. {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_authorship.py +0 -0
  66. {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_automation.py +0 -0
  67. {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_cli.py +0 -0
  68. {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_cli_automation.py +0 -0
  69. {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_config.py +0 -0
  70. {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_geometry_fit.py +0 -0
  71. {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_heic.py +0 -0
  72. {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_mesh.py +0 -0
  73. {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_offline_autopilot.py +0 -0
  74. {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_optional_extras.py +0 -0
  75. {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_reconstruction.py +0 -0
  76. {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_render.py +0 -0
  77. {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_scenes.py +0 -0
  78. {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_security.py +0 -0
  79. {reverse3d-0.9.0 → reverse3d-0.10.0}/tests/test_silhouette.py +0 -0
  80. {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.9.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 profundidad, unos 750 MB) para que todo funcione sin internet |
223
+ | *(en todos los modos)* | *(en todos los modos)* | **La IA local se instala y se descarga siempre** (clasificador, segmentación, profundidad y puntos con IA, unos 800 MB) para que todo funcione sin internet |
223
224
  | `--no-auto-approve` | `-NoAutoApprove` | Que los agentes pregunten antes de cada herramienta |
224
225
  | `--with recon,depth` | `-With recon,depth` | Extras a elección |
225
226
  | `--device auto\|cpu\|gpu` | `-Device …` | Dispositivo por defecto |
@@ -358,7 +359,7 @@ reverse3d config set verbosity full # si alguna vez necesitas el detalle com
358
359
 
359
360
  ## 🔌 Sin internet (modo offline)
360
361
 
361
- reverse3d **funciona sin conexión**. Todo el cálculo es local (OpenCV, COLMAP, Open3D, trimesh, Blender). Lo único que se descarga es el modelo de IA de profundidad, y **el instalador lo deja descargado**.
362
+ reverse3d **funciona sin conexión**. Todo el cálculo es local (OpenCV, COLMAP, Open3D, trimesh, Blender). Lo único que se descarga son los modelos de IA local (sujeto, recorte, profundidad y los puntos ALIKED + LightGlue), y **el instalador los deja descargados**.
362
363
 
363
364
  - **Cambio automático:** si el equipo no detecta internet, reverse3d pasa solo a modo offline. No tienes que hacer nada.
364
365
  - **Comprobar:** `reverse3d offline status` dice si hay conexión y qué funciona sin ella. `reverse3d doctor` lo incluye en su informe.
@@ -416,6 +417,8 @@ Si las fotos están borrosas o falta una herramienta, **no reintenta en vano**:
416
417
  | 🧱 **Plano → pieza** | Extrusión de contornos a sólido 3D con escala real | Shapely + trimesh |
417
418
  | 📷 **Fotogrametría** | Fotos → SfM → MVS denso → nube → malla | COLMAP + Open3D |
418
419
  | 🌊 **Profundidad con IA** | Una foto → mapa de profundidad → relieve 2.5D | Depth Anything V2 |
420
+ | 🎨 **Texturas** | Textura fotográfica 2048² desde las propias fotos (UV, visibilidad, sin costuras) en `model.glb` | xatlas + OpenCV |
421
+ | 🧠 **Puntos con IA** | Rescate automático de capturas difíciles con puntos y emparejamiento aprendidos | ALIKED + LightGlue (ONNX) |
419
422
  | 🛠️ **Reparación de mallas** | Limpieza, restos sueltos, agujeros, normales, Taubin, decimación, alineación OBB/PCA, escala | trimesh |
420
423
  | 📏 **Metrología** | Cotas AABB/OBB, área, volumen, centro de masa, estanqueidad | trimesh |
421
424
  | 📐 **Primitivas** | Planos, cilindros y esferas con RANSAC (radios, ejes, normales) | NumPy |
@@ -151,7 +151,7 @@ Cada archivo modificado se **respalda antes** (`*.reverse3d-backup-<fecha>`). So
151
151
  | `--lite` | `-Lite` | Todo, con la IA local en la CPU (PyTorch CPU, ~250 MB en vez de ~2.5 GB) |
152
152
  | `--minimal` | `-Minimal` | Núcleo + MCP + IA local, sin herramientas externas |
153
153
  | `--no-tools` | `-NoTools` | No instalar Blender ni COLMAP |
154
- | *(en todos los modos)* | *(en todos los modos)* | **La IA local se instala y se descarga siempre** (clasificador, segmentación y profundidad, unos 750 MB) para que todo funcione sin internet |
154
+ | *(en todos los modos)* | *(en todos los modos)* | **La IA local se instala y se descarga siempre** (clasificador, segmentación, profundidad y puntos con IA, unos 800 MB) para que todo funcione sin internet |
155
155
  | `--no-auto-approve` | `-NoAutoApprove` | Que los agentes pregunten antes de cada herramienta |
156
156
  | `--with recon,depth` | `-With recon,depth` | Extras a elección |
157
157
  | `--device auto\|cpu\|gpu` | `-Device …` | Dispositivo por defecto |
@@ -290,7 +290,7 @@ reverse3d config set verbosity full # si alguna vez necesitas el detalle com
290
290
 
291
291
  ## 🔌 Sin internet (modo offline)
292
292
 
293
- reverse3d **funciona sin conexión**. Todo el cálculo es local (OpenCV, COLMAP, Open3D, trimesh, Blender). Lo único que se descarga es el modelo de IA de profundidad, y **el instalador lo deja descargado**.
293
+ reverse3d **funciona sin conexión**. Todo el cálculo es local (OpenCV, COLMAP, Open3D, trimesh, Blender). Lo único que se descarga son los modelos de IA local (sujeto, recorte, profundidad y los puntos ALIKED + LightGlue), y **el instalador los deja descargados**.
294
294
 
295
295
  - **Cambio automático:** si el equipo no detecta internet, reverse3d pasa solo a modo offline. No tienes que hacer nada.
296
296
  - **Comprobar:** `reverse3d offline status` dice si hay conexión y qué funciona sin ella. `reverse3d doctor` lo incluye en su informe.
@@ -348,6 +348,8 @@ Si las fotos están borrosas o falta una herramienta, **no reintenta en vano**:
348
348
  | 🧱 **Plano → pieza** | Extrusión de contornos a sólido 3D con escala real | Shapely + trimesh |
349
349
  | 📷 **Fotogrametría** | Fotos → SfM → MVS denso → nube → malla | COLMAP + Open3D |
350
350
  | 🌊 **Profundidad con IA** | Una foto → mapa de profundidad → relieve 2.5D | Depth Anything V2 |
351
+ | 🎨 **Texturas** | Textura fotográfica 2048² desde las propias fotos (UV, visibilidad, sin costuras) en `model.glb` | xatlas + OpenCV |
352
+ | 🧠 **Puntos con IA** | Rescate automático de capturas difíciles con puntos y emparejamiento aprendidos | ALIKED + LightGlue (ONNX) |
351
353
  | 🛠️ **Reparación de mallas** | Limpieza, restos sueltos, agujeros, normales, Taubin, decimación, alineación OBB/PCA, escala | trimesh |
352
354
  | 📏 **Metrología** | Cotas AABB/OBB, área, volumen, centro de masa, estanqueidad | trimesh |
353
355
  | 📐 **Primitivas** | Planos, cilindros y esferas con RANSAC (radios, ejes, normales) | NumPy |
@@ -40,6 +40,7 @@ dependencies = [
40
40
  "numpy>=1.26",
41
41
  "scipy>=1.11",
42
42
  "scikit-image>=0.22",
43
+ "xatlas>=0.0.9",
43
44
  "opencv-python-headless>=4.9",
44
45
  "pillow>=10.3",
45
46
  "pillow-heif>=1.1",
@@ -0,0 +1 @@
1
+ __version__ = "0.10.0"
@@ -59,6 +59,55 @@ MODEL_REVISIONS: dict[str, str] = {
59
59
  }
60
60
 
61
61
 
62
+ #: Learned feature models COLMAP runs through ONNX (ALIKED, BSD-3; LightGlue,
63
+ #: Apache-2.0): (download URL, pinned SHA-256). Used to rescue hard photo sets.
64
+ COLMAP_MODELS: dict[str, tuple[str, str]] = {
65
+ "aliked-n16rot.onnx": (
66
+ "https://github.com/colmap/colmap/releases/download/3.13.0/aliked-n16rot.onnx",
67
+ "39c423d0a6f03d39ec89d3d1d61853765c2fb6a8b8381376c703e5758778a547",
68
+ ),
69
+ "aliked-lightglue.onnx": (
70
+ "https://github.com/colmap/colmap/releases/download/3.13.0/aliked-lightglue.onnx",
71
+ "b9a5de7204648b18a8cf5dcac819f9d30de1a5961ef03756803c8b86c2dceb8d",
72
+ ),
73
+ }
74
+
75
+
76
+ def colmap_model_path(name: str) -> Path | None:
77
+ """Local copy of a COLMAP ONNX model, if downloaded (filesystem check only)."""
78
+ path = models_dir() / "colmap" / name
79
+ return path if path.is_file() else None
80
+
81
+
82
+ def download_colmap_models() -> dict[str, str]:
83
+ """Download the learned-feature models, verifying their pinned SHA-256."""
84
+ import hashlib
85
+ import urllib.request
86
+
87
+ status: dict[str, str] = {}
88
+ folder = models_dir() / "colmap"
89
+ folder.mkdir(parents=True, exist_ok=True)
90
+ for name, (url, sha) in COLMAP_MODELS.items():
91
+ target = folder / name
92
+ if target.is_file() and hashlib.sha256(target.read_bytes()).hexdigest() == sha:
93
+ status[name] = "ready"
94
+ continue
95
+ try:
96
+ with urllib.request.urlopen(url, timeout=120) as response: # noqa: S310 # nosec B310 - pinned https URL, SHA-256 checked
97
+ data = response.read()
98
+ except Exception as exc: # network errors come in many shapes
99
+ status[name] = f"failed ({exc.__class__.__name__})"
100
+ continue
101
+ if hashlib.sha256(data).hexdigest() != sha:
102
+ status[name] = "failed (checksum mismatch)"
103
+ continue
104
+ tmp = target.with_suffix(".part")
105
+ tmp.write_bytes(data)
106
+ tmp.replace(target)
107
+ status[name] = "downloaded"
108
+ return status
109
+
110
+
62
111
  def models_dir() -> Path:
63
112
  """Folder that holds the downloaded AI models (``~/.reverse3d/models``)."""
64
113
  return home_dir() / "models"
@@ -263,6 +312,15 @@ def prepare(settings: Settings, *, include_non_commercial: bool = False) -> dict
263
312
  }
264
313
  )
265
314
 
315
+ if online or all(colmap_model_path(n) for n in COLMAP_MODELS):
316
+ for name, state in download_colmap_models().items():
317
+ results.append({"model": f"colmap/{name}", "status": state.split(" ")[0]})
318
+ else:
319
+ results += [
320
+ {"model": f"colmap/{n}", "status": "missing", "detail": "No internet connection."}
321
+ for n in COLMAP_MODELS
322
+ if not colmap_model_path(n)
323
+ ]
266
324
  ready = all(r["status"] in ("ready", "downloaded") for r in results)
267
325
  return {
268
326
  "ready": ready,
@@ -294,6 +352,7 @@ def offline_status(settings: Settings) -> dict[str, Any]:
294
352
  "ai_segmentation": DEFAULT_SEGMENTER,
295
353
  "ai_depth": _default_model(),
296
354
  }
355
+ features_ready = all(colmap_model_path(n) for n in COLMAP_MODELS)
297
356
  on_disk = {key: local_model_path(model) is not None for key, model in ai_models.items()}
298
357
  internet = None if settings.offline != "auto" else has_internet()
299
358
 
@@ -310,6 +369,7 @@ def offline_status(settings: Settings) -> dict[str, Any]:
310
369
  key: {"available": depth_installed, "offline": depth_installed and on_disk[key]}
311
370
  for key in ai_models
312
371
  },
372
+ "ai_features": {"available": True, "offline": features_ready},
313
373
  }
314
374
  missing = []
315
375
  names = {
@@ -321,6 +381,11 @@ def offline_status(settings: Settings) -> dict[str, Any]:
321
381
  missing.append(
322
382
  "AI depth model not downloaded yet: run `reverse3d offline prepare` while online."
323
383
  )
384
+ if not features_ready:
385
+ missing.append(
386
+ "AI feature models (ALIKED + LightGlue) not downloaded yet: "
387
+ "run `reverse3d offline prepare` while online."
388
+ )
324
389
  for key in ("ai_subject", "ai_segmentation"):
325
390
  if depth_installed and not on_disk[key]:
326
391
  missing.append(
@@ -36,6 +36,7 @@ from reverse3d.config import Settings
36
36
  from reverse3d.errors import (
37
37
  CaptureQualityError,
38
38
  DependencyMissingError,
39
+ ExternalToolError,
39
40
  InputValidationError,
40
41
  ProcessingError,
41
42
  Reverse3DError,
@@ -161,7 +162,7 @@ def _clean(
161
162
  import trimesh
162
163
 
163
164
  glb = dst.with_name("model.glb")
164
- trimesh.load(color_ply, process=False, force="mesh").export(glb)
165
+ _export_glb(trimesh.load(color_ply, process=False, force="mesh"), glb)
165
166
  run.outputs["model_color"] = str(color_ply)
166
167
  run.outputs["model_glb"] = str(glb)
167
168
  except Exception as exc: # colour is a bonus: never fail the model for it
@@ -169,6 +170,20 @@ def _clean(
169
170
  return report.after
170
171
 
171
172
 
173
+ def _export_glb(mesh: Any, path: Path) -> None:
174
+ """Write a GLB that stands up in any viewer: glTF is Y-up, our models are Z-up.
175
+
176
+ The geometry keeps its Z-up coordinates and a root node rotates it, so
177
+ Blender, game engines and web viewers show it upright.
178
+ """
179
+ import trimesh
180
+
181
+ scene = trimesh.Scene()
182
+ up = trimesh.transformations.rotation_matrix(-np.pi / 2, [1, 0, 0])
183
+ scene.add_geometry(mesh, node_name="model", transform=up)
184
+ scene.export(path)
185
+
186
+
172
187
  def _has_vertex_colors(path: Path) -> bool:
173
188
  import trimesh
174
189
 
@@ -253,6 +268,30 @@ _DEFAULT_LOOK = {
253
268
  }
254
269
 
255
270
 
271
+ def _texture(run: _Run, model: Path, photo: Any, to_world: list[list[float]], scale: float) -> None:
272
+ """Bake a photo texture onto the final model: model.glb gets real detail."""
273
+ try:
274
+ import trimesh
275
+
276
+ from reverse3d.reconstruction.texture import bake_texture
277
+
278
+ mesh: Any = trimesh.load(model, process=False, force="mesh")
279
+ # The cleaned model is the carved hull scaled by ``scale``.
280
+ world = np.asarray(to_world) @ np.diag([1 / scale, 1 / scale, 1 / scale, 1.0])
281
+ textured = run.step(
282
+ "texture",
283
+ lambda: bake_texture(
284
+ mesh, Path(photo.images_dir), Path(photo.sparse_text_dir), to_world=world
285
+ ),
286
+ )
287
+ glb = model.with_name("model.glb")
288
+ _export_glb(textured, glb)
289
+ run.outputs["model_glb"] = str(glb)
290
+ run.details["texture"] = {"size": 2048, "faces": len(textured.faces)}
291
+ except Exception as exc: # the texture is a bonus: never fail the model for it
292
+ logger.info("Texture skipped: %s", exc)
293
+
294
+
256
295
  def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
257
296
  from reverse3d.reconstruction.photogrammetry import (
258
297
  PhotogrammetryOptions,
@@ -304,7 +343,7 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
304
343
  # white or glossy surfaces. Open scenes keep classic dense reconstruction.
305
344
  silhouette = scene in ("object", "character")
306
345
 
307
- def photogrammetry(classic: bool) -> PhotogrammetryResult:
346
+ def photogrammetry(classic: bool, features: str = "sift") -> PhotogrammetryResult:
308
347
  return run_photogrammetry(
309
348
  src,
310
349
  run.out / "reconstruction",
@@ -317,13 +356,28 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
317
356
  build_mesh=classic,
318
357
  poisson_depth=preset.poisson_depth,
319
358
  density_quantile=preset.density_quantile,
359
+ features=features, # type: ignore[arg-type]
320
360
  ),
321
- overwrite=opt.overwrite or not classic,
361
+ overwrite=opt.overwrite or not classic or features != "sift",
322
362
  )
323
363
 
324
- photo = run.step("photogrammetry", lambda: photogrammetry(classic=not silhouette))
364
+ try:
365
+ photo = run.step("photogrammetry", lambda: photogrammetry(classic=not silhouette))
366
+ except ProcessingError as exc:
367
+ # Rescue for hard sets: learned AI features (ALIKED + LightGlue) place
368
+ # photos that SIFT cannot. Slower (CPU), so only when SIFT fell short.
369
+ logger.info("SIFT could not place the photos (%s); retrying with AI features", exc.message)
370
+ try:
371
+ photo = run.step(
372
+ "photogrammetry_ai",
373
+ lambda: photogrammetry(classic=not silhouette, features="aliked"),
374
+ )
375
+ except (ProcessingError, DependencyMissingError, ExternalToolError):
376
+ raise exc from None
377
+ run.warnings.append("Photos placed with AI features (ALIKED + LightGlue).")
325
378
  mesh_path: str | None = photo.mesh_path
326
379
  upright = False
380
+ hull_world: list[list[float]] | None = None
327
381
  if silhouette:
328
382
  from reverse3d.reconstruction.silhouette import visual_hull
329
383
  from reverse3d.vision.segmenter import ObjectSegmenter
@@ -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
- _render(run, Path(run.outputs.get("model_color") or run.outputs["model"]), opt.overwrite)
670
+ textured = run.details.get("texture") and run.outputs.get("model_glb")
671
+ _render(
672
+ run,
673
+ Path(textured or run.outputs.get("model_color") or run.outputs["model"]),
674
+ opt.overwrite,
675
+ )
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
- mesh.apply_translation([-mesh.centroid[0], -mesh.centroid[1], -mesh.bounds[0][2]])
545
+ shift = np.array([-mesh.centroid[0], -mesh.centroid[1], -mesh.bounds[0][2]])
546
+ mesh.apply_translation(shift)
547
+ world_to_model = trimesh.transformations.translation_matrix(shift) @ transform
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.9.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 profundidad, unos 750 MB) para que todo funcione sin internet |
223
+ | *(en todos los modos)* | *(en todos los modos)* | **La IA local se instala y se descarga siempre** (clasificador, segmentación, profundidad y puntos con IA, unos 800 MB) para que todo funcione sin internet |
223
224
  | `--no-auto-approve` | `-NoAutoApprove` | Que los agentes pregunten antes de cada herramienta |
224
225
  | `--with recon,depth` | `-With recon,depth` | Extras a elección |
225
226
  | `--device auto\|cpu\|gpu` | `-Device …` | Dispositivo por defecto |
@@ -358,7 +359,7 @@ reverse3d config set verbosity full # si alguna vez necesitas el detalle com
358
359
 
359
360
  ## 🔌 Sin internet (modo offline)
360
361
 
361
- reverse3d **funciona sin conexión**. Todo el cálculo es local (OpenCV, COLMAP, Open3D, trimesh, Blender). Lo único que se descarga es el modelo de IA de profundidad, y **el instalador lo deja descargado**.
362
+ reverse3d **funciona sin conexión**. Todo el cálculo es local (OpenCV, COLMAP, Open3D, trimesh, Blender). Lo único que se descarga son los modelos de IA local (sujeto, recorte, profundidad y los puntos ALIKED + LightGlue), y **el instalador los deja descargados**.
362
363
 
363
364
  - **Cambio automático:** si el equipo no detecta internet, reverse3d pasa solo a modo offline. No tienes que hacer nada.
364
365
  - **Comprobar:** `reverse3d offline status` dice si hay conexión y qué funciona sin ella. `reverse3d doctor` lo incluye en su informe.
@@ -416,6 +417,8 @@ Si las fotos están borrosas o falta una herramienta, **no reintenta en vano**:
416
417
  | 🧱 **Plano → pieza** | Extrusión de contornos a sólido 3D con escala real | Shapely + trimesh |
417
418
  | 📷 **Fotogrametría** | Fotos → SfM → MVS denso → nube → malla | COLMAP + Open3D |
418
419
  | 🌊 **Profundidad con IA** | Una foto → mapa de profundidad → relieve 2.5D | Depth Anything V2 |
420
+ | 🎨 **Texturas** | Textura fotográfica 2048² desde las propias fotos (UV, visibilidad, sin costuras) en `model.glb` | xatlas + OpenCV |
421
+ | 🧠 **Puntos con IA** | Rescate automático de capturas difíciles con puntos y emparejamiento aprendidos | ALIKED + LightGlue (ONNX) |
419
422
  | 🛠️ **Reparación de mallas** | Limpieza, restos sueltos, agujeros, normales, Taubin, decimación, alineación OBB/PCA, escala | trimesh |
420
423
  | 📏 **Metrología** | Cotas AABB/OBB, área, volumen, centro de masa, estanqueidad | trimesh |
421
424
  | 📐 **Primitivas** | Planos, cilindros y esferas con RANSAC (radios, ejes, normales) | NumPy |
@@ -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
@@ -1,6 +1,7 @@
1
1
  numpy>=1.26
2
2
  scipy>=1.11
3
3
  scikit-image>=0.22
4
+ xatlas>=0.0.9
4
5
  opencv-python-headless>=4.9
5
6
  pillow>=10.3
6
7
  pillow-heif>=1.1
@@ -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