reverse3d 0.7.0__tar.gz → 0.8.1__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 (78) hide show
  1. {reverse3d-0.7.0/reverse3d.egg-info → reverse3d-0.8.1}/PKG-INFO +5 -4
  2. {reverse3d-0.7.0 → reverse3d-0.8.1}/README.md +4 -3
  3. reverse3d-0.8.1/reverse3d/_version.py +1 -0
  4. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/autopilot.py +6 -1
  5. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/offline.py +32 -4
  6. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/recipes.py +93 -21
  7. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/reconstruction/silhouette.py +66 -0
  8. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/rendering/_blender_job.py +41 -7
  9. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/rendering/blender_render.py +55 -1
  10. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/scenes.py +44 -0
  11. reverse3d-0.8.1/reverse3d/vision/classifier.py +170 -0
  12. {reverse3d-0.7.0 → reverse3d-0.8.1/reverse3d.egg-info}/PKG-INFO +5 -4
  13. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d.egg-info/SOURCES.txt +2 -0
  14. reverse3d-0.8.1/tests/test_color.py +49 -0
  15. {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_offline_autopilot.py +3 -1
  16. {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_scenes.py +52 -0
  17. reverse3d-0.7.0/reverse3d/_version.py +0 -1
  18. {reverse3d-0.7.0 → reverse3d-0.8.1}/LICENSE +0 -0
  19. {reverse3d-0.7.0 → reverse3d-0.8.1}/NOTICE +0 -0
  20. {reverse3d-0.7.0 → reverse3d-0.8.1}/pyproject.toml +0 -0
  21. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/__init__.py +0 -0
  22. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/__main__.py +0 -0
  23. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/_about.py +0 -0
  24. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/agent/__init__.py +0 -0
  25. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/agent/compact.py +0 -0
  26. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/agent/mcp_server.py +0 -0
  27. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/agent/prompts.py +0 -0
  28. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/agent/skill/SKILL.md +0 -0
  29. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/agent/tools.py +0 -0
  30. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/cli.py +0 -0
  31. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/config.py +0 -0
  32. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/doctor.py +0 -0
  33. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/errors.py +0 -0
  34. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/hardware.py +0 -0
  35. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/imaging.py +0 -0
  36. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/logging_utils.py +0 -0
  37. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/mesh_io.py +0 -0
  38. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/profiles.py +0 -0
  39. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/py.typed +0 -0
  40. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/reconstruction/__init__.py +0 -0
  41. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/reconstruction/depth_estimation.py +0 -0
  42. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/reconstruction/photogrammetry.py +0 -0
  43. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/reconstruction/pointcloud.py +0 -0
  44. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/rendering/__init__.py +0 -0
  45. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/rendering/preview.py +0 -0
  46. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/reverse_eng/__init__.py +0 -0
  47. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/reverse_eng/geometry_fit.py +0 -0
  48. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/reverse_eng/measure.py +0 -0
  49. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/reverse_eng/mesh_cleaner.py +0 -0
  50. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/reverse_eng/parametric.py +0 -0
  51. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/security.py +0 -0
  52. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/setup_agents.py +0 -0
  53. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/userconfig.py +0 -0
  54. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/vision/__init__.py +0 -0
  55. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/vision/feature_matcher.py +0 -0
  56. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/vision/mask_extractor.py +0 -0
  57. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/vision/segmenter.py +0 -0
  58. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/watch.py +0 -0
  59. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d.egg-info/dependency_links.txt +0 -0
  60. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d.egg-info/entry_points.txt +0 -0
  61. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d.egg-info/requires.txt +0 -0
  62. {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d.egg-info/top_level.txt +0 -0
  63. {reverse3d-0.7.0 → reverse3d-0.8.1}/setup.cfg +0 -0
  64. {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_agent_tools.py +0 -0
  65. {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_authorship.py +0 -0
  66. {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_automation.py +0 -0
  67. {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_cli.py +0 -0
  68. {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_cli_automation.py +0 -0
  69. {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_config.py +0 -0
  70. {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_geometry_fit.py +0 -0
  71. {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_heic.py +0 -0
  72. {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_mesh.py +0 -0
  73. {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_optional_extras.py +0 -0
  74. {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_reconstruction.py +0 -0
  75. {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_render.py +0 -0
  76. {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_security.py +0 -0
  77. {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_silhouette.py +0 -0
  78. {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_vision.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reverse3d
3
- Version: 0.7.0
3
+ Version: 0.8.1
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>
@@ -215,10 +215,11 @@ Cada archivo modificado se **respalda antes** (`*.reverse3d-backup-<fecha>`). So
215
215
 
216
216
  | bash (`install.sh`) | PowerShell (`install.ps1`) | Efecto |
217
217
  |---------------------|----------------------------|--------|
218
- | *(sin opciones)* | *(sin opciones)* | **Todo**: núcleo, MCP, render, mallado, IA de profundidad, Blender y COLMAP |
219
- | `--lite` | `-Lite` | Todo menos la IA de profundidad (PyTorch); ahorra hasta ~2.5 GB |
220
- | `--minimal` | `-Minimal` | Solo núcleo + MCP, sin herramientas externas |
218
+ | *(sin opciones)* | *(sin opciones)* | **Todo**: núcleo, MCP, render, mallado, IA local (con CUDA si hay NVIDIA), Blender y COLMAP |
219
+ | `--lite` | `-Lite` | Todo, con la IA local en la CPU (PyTorch CPU, ~250 MB en vez de ~2.5 GB) |
220
+ | `--minimal` | `-Minimal` | Núcleo + MCP + IA local, sin herramientas externas |
221
221
  | `--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 |
222
223
  | `--no-auto-approve` | `-NoAutoApprove` | Que los agentes pregunten antes de cada herramienta |
223
224
  | `--with recon,depth` | `-With recon,depth` | Extras a elección |
224
225
  | `--device auto\|cpu\|gpu` | `-Device …` | Dispositivo por defecto |
@@ -147,10 +147,11 @@ Cada archivo modificado se **respalda antes** (`*.reverse3d-backup-<fecha>`). So
147
147
 
148
148
  | bash (`install.sh`) | PowerShell (`install.ps1`) | Efecto |
149
149
  |---------------------|----------------------------|--------|
150
- | *(sin opciones)* | *(sin opciones)* | **Todo**: núcleo, MCP, render, mallado, IA de profundidad, Blender y COLMAP |
151
- | `--lite` | `-Lite` | Todo menos la IA de profundidad (PyTorch); ahorra hasta ~2.5 GB |
152
- | `--minimal` | `-Minimal` | Solo núcleo + MCP, sin herramientas externas |
150
+ | *(sin opciones)* | *(sin opciones)* | **Todo**: núcleo, MCP, render, mallado, IA local (con CUDA si hay NVIDIA), Blender y COLMAP |
151
+ | `--lite` | `-Lite` | Todo, con la IA local en la CPU (PyTorch CPU, ~250 MB en vez de ~2.5 GB) |
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
155
  | `--no-auto-approve` | `-NoAutoApprove` | Que los agentes pregunten antes de cada herramienta |
155
156
  | `--with recon,depth` | `-With recon,depth` | Extras a elección |
156
157
  | `--device auto\|cpu\|gpu` | `-Device …` | Dispositivo por defecto |
@@ -0,0 +1 @@
1
+ __version__ = "0.8.1"
@@ -361,7 +361,12 @@ def auto_run(
361
361
  if base.scene == "auto":
362
362
  predicted = detect_scene(source, settings)
363
363
  scene_info = predicted.to_dict()
364
- base = replace(base, scene=predicted.kind)
364
+ base = replace(
365
+ base,
366
+ scene=predicted.kind,
367
+ subject=predicted.subject,
368
+ render_preset=predicted.render,
369
+ )
365
370
  elif base.scene in SCENE_PRESETS:
366
371
  scene_info = {"kind": base.scene, "confidence": 1.0, "reasons": ["chosen"]}
367
372
  summary: dict[str, Any] | None = None
@@ -55,6 +55,7 @@ MODEL_REVISIONS: dict[str, str] = {
55
55
  "depth-anything/Depth-Anything-V2-Base-hf": "b1958afc87fb45a9e3746cb387596094de553ed8",
56
56
  "depth-anything/Depth-Anything-V2-Large-hf": "7581137eff8d4e94f6e796d3baea0e9fa79b22d2",
57
57
  "facebook/sam-vit-base": "70c1a07f894ebb5b307fd9eaaee97b9dfc16068f",
58
+ "wkcn/TinyCLIP-ViT-40M-32-Text-19M-LAION400M": "95ec8197b3f2fe7f747865c61ca556cf0768b2f7",
58
59
  }
59
60
 
60
61
 
@@ -101,9 +102,10 @@ def is_offline(settings: Settings) -> bool:
101
102
 
102
103
  def _allowed_models() -> dict[str, tuple[str, str]]:
103
104
  from reverse3d.reconstruction.depth_estimation import ALLOWED_MODELS
105
+ from reverse3d.vision.classifier import ALLOWED_CLASSIFIERS
104
106
  from reverse3d.vision.segmenter import ALLOWED_SEGMENTERS
105
107
 
106
- return {**ALLOWED_MODELS, **ALLOWED_SEGMENTERS}
108
+ return {**ALLOWED_MODELS, **ALLOWED_SEGMENTERS, **ALLOWED_CLASSIFIERS}
107
109
 
108
110
 
109
111
  def _default_model() -> str:
@@ -203,10 +205,12 @@ def prepare(settings: Settings, *, include_non_commercial: bool = False) -> dict
203
205
  Safe to run any time: models already on disk are skipped. It never raises
204
206
  for a missing optional stack; that is reported in the result instead.
205
207
  """
208
+ from reverse3d.vision.classifier import DEFAULT_CLASSIFIER
206
209
  from reverse3d.vision.segmenter import DEFAULT_SEGMENTER
207
210
 
208
- # Depth (reliefs) and segmentation (object silhouettes), both commercial-friendly.
209
- models = [_default_model(), DEFAULT_SEGMENTER]
211
+ # Depth (reliefs), segmentation (silhouettes) and the subject classifier,
212
+ # all commercial-friendly.
213
+ models = [_default_model(), DEFAULT_SEGMENTER, DEFAULT_CLASSIFIER]
210
214
  if include_non_commercial:
211
215
  models += [m for m in _allowed_models() if m not in models]
212
216
 
@@ -279,8 +283,18 @@ def offline_status(settings: Settings) -> dict[str, Any]:
279
283
 
280
284
  env = environment_report(settings, include_offline=False)
281
285
  caps: dict[str, bool] = env["capabilities"] # type: ignore[assignment]
286
+ from reverse3d.vision.classifier import DEFAULT_CLASSIFIER
287
+ from reverse3d.vision.segmenter import DEFAULT_SEGMENTER
288
+
282
289
  depth_installed = bool(caps.get("depth_estimation"))
283
290
  model_on_disk = local_model_path(_default_model()) is not None
291
+ # The local AI team: what each model does, and whether it is on this computer.
292
+ ai_models = {
293
+ "ai_subject": DEFAULT_CLASSIFIER,
294
+ "ai_segmentation": DEFAULT_SEGMENTER,
295
+ "ai_depth": _default_model(),
296
+ }
297
+ on_disk = {key: local_model_path(model) is not None for key, model in ai_models.items()}
284
298
  internet = None if settings.offline != "auto" else has_internet()
285
299
 
286
300
  components = {
@@ -292,13 +306,27 @@ def offline_status(settings: Settings) -> dict[str, Any]:
292
306
  "available": caps.get("render_blender", False) or caps.get("render_pyvista", False),
293
307
  "offline": True,
294
308
  },
295
- "ai_depth": {"available": depth_installed, "offline": depth_installed and model_on_disk},
309
+ **{
310
+ key: {"available": depth_installed, "offline": depth_installed and on_disk[key]}
311
+ for key in ai_models
312
+ },
296
313
  }
297
314
  missing = []
315
+ names = {
316
+ "ai_subject": "AI subject classifier",
317
+ "ai_segmentation": "AI segmentation",
318
+ "ai_depth": "AI depth",
319
+ }
298
320
  if depth_installed and not model_on_disk:
299
321
  missing.append(
300
322
  "AI depth model not downloaded yet: run `reverse3d offline prepare` while online."
301
323
  )
324
+ for key in ("ai_subject", "ai_segmentation"):
325
+ if depth_installed and not on_disk[key]:
326
+ missing.append(
327
+ f"{names[key]} model not downloaded yet: "
328
+ "run `reverse3d offline prepare` while online."
329
+ )
302
330
  working_offline = is_offline(settings)
303
331
  return {
304
332
  "mode": settings.offline,
@@ -30,6 +30,8 @@ from dataclasses import dataclass, field
30
30
  from pathlib import Path
31
31
  from typing import Any, Literal, TypeVar
32
32
 
33
+ import numpy as np
34
+
33
35
  from reverse3d.config import Settings
34
36
  from reverse3d.errors import (
35
37
  CaptureQualityError,
@@ -75,6 +77,9 @@ class RecipeOptions:
75
77
  mask_method: MaskMethod = "otsu"
76
78
  #: photos_to_model: scene profile (object, character, architecture, landscape) or "auto".
77
79
  scene: str = "auto"
80
+ #: what was photographed (AI classifier) and the render look; None = from the scene.
81
+ subject: str | None = None
82
+ render_preset: str | None = None
78
83
  #: photo_to_relief: object mask (white = keep), e.g. from the autopilot fallback.
79
84
  mask_path: str | None = None
80
85
  #: mesh repair strength; "strong" closes much larger holes.
@@ -132,39 +137,85 @@ def _clean(
132
137
  parts, and tiny debris is already dropped by the area filter."""
133
138
  from reverse3d.reverse_eng.mesh_cleaner import CleanOptions, clean_mesh_file
134
139
 
140
+ options = CleanOptions(
141
+ keep_largest_only=keep_largest,
142
+ fill_holes=fill_holes,
143
+ align=align,
144
+ ground=align != "none",
145
+ scale=scale,
146
+ max_hole_edges=2000 if repair == "strong" else 200,
147
+ )
135
148
  report = run.step(
136
149
  "clean",
137
- lambda: clean_mesh_file(
138
- src,
139
- dst,
140
- run.settings,
141
- CleanOptions(
142
- keep_largest_only=keep_largest,
143
- fill_holes=fill_holes,
144
- align=align,
145
- ground=align != "none",
146
- scale=scale,
147
- max_hole_edges=2000 if repair == "strong" else 200,
148
- ),
149
- overwrite=overwrite,
150
- ),
150
+ lambda: clean_mesh_file(src, dst, run.settings, options, overwrite=overwrite),
151
151
  )
152
152
  run.warnings.extend(report.warnings)
153
153
  run.outputs["model"] = str(dst)
154
154
  run.details["clean"] = report.to_dict()
155
+ if _has_vertex_colors(src):
156
+ # Same cleaning on the coloured mesh (deterministic, colours follow the
157
+ # vertices): model_color.ply for renders and model.glb for games and viewers.
158
+ try:
159
+ color_ply = dst.with_name("model_color.ply")
160
+ clean_mesh_file(src, color_ply, run.settings, options, overwrite=True)
161
+ import trimesh
162
+
163
+ glb = dst.with_name("model.glb")
164
+ trimesh.load(color_ply, process=False, force="mesh").export(glb)
165
+ run.outputs["model_color"] = str(color_ply)
166
+ run.outputs["model_glb"] = str(glb)
167
+ except Exception as exc: # colour is a bonus: never fail the model for it
168
+ logger.info("Coloured model skipped: %s", exc)
155
169
  return report.after
156
170
 
157
171
 
172
+ def _has_vertex_colors(path: Path) -> bool:
173
+ import trimesh
174
+
175
+ try:
176
+ mesh: Any = trimesh.load(path, process=False, force="mesh")
177
+ except Exception:
178
+ return False
179
+ colors = getattr(mesh.visual, "vertex_colors", None)
180
+ if colors is None or len(colors) != len(mesh.vertices):
181
+ return False
182
+ rgb = np.asarray(colors)[:, :3]
183
+ return float(np.max(np.std(rgb, axis=0))) > 1.0 # not just a uniform default colour
184
+
185
+
186
+ def _color_from_cloud(mesh_path: Path, cloud_path: Path, out: Path) -> Path | None:
187
+ """Paint a mesh with the colours of the point cloud it was built from."""
188
+ import trimesh
189
+ from scipy.spatial import cKDTree
190
+
191
+ cloud: Any = trimesh.load(cloud_path, process=False)
192
+ colors = getattr(cloud, "colors", None)
193
+ if colors is None or len(colors) != len(getattr(cloud, "vertices", [])):
194
+ return None
195
+ mesh: Any = trimesh.load(mesh_path, process=False, force="mesh")
196
+ _, idx = cKDTree(np.asarray(cloud.vertices)).query(np.asarray(mesh.vertices), k=4)
197
+ rgb = np.asarray(colors)[idx][..., :3].astype(np.float64).mean(axis=1)
198
+ rgba = np.column_stack([rgb, np.full(len(rgb), 255.0)]).clip(0, 255).astype(np.uint8)
199
+ mesh.visual = trimesh.visual.ColorVisuals(mesh, vertex_colors=rgba)
200
+ mesh.export(out)
201
+ return out
202
+
203
+
158
204
  def _render(run: _Run, mesh: Path, overwrite: bool) -> None:
159
- from reverse3d.rendering.blender_render import RenderOptions, render_mesh
205
+ from reverse3d.rendering.blender_render import RenderOptions, preset_options, render_mesh
160
206
 
161
207
  prof = profile_values(run.settings)
162
- options = RenderOptions(
163
- width=prof.render_width,
164
- height=prof.render_height,
165
- samples=prof.render_samples,
166
- device="gpu" if wants_gpu(run.settings) else "cpu",
208
+ look = str(run.details.get("scene", {}).get("render") or "studio")
209
+ options = preset_options(
210
+ look,
211
+ RenderOptions(
212
+ width=prof.render_width,
213
+ height=prof.render_height,
214
+ samples=prof.render_samples,
215
+ device="gpu" if wants_gpu(run.settings) else "cpu",
216
+ ),
167
217
  )
218
+ run.details["render_look"] = look
168
219
  try:
169
220
  result = run.step(
170
221
  "render",
@@ -194,6 +245,14 @@ def _measure(run: _Run, mesh: Path) -> MeshStats:
194
245
  _MIN_GROUP = 8
195
246
 
196
247
 
248
+ _DEFAULT_LOOK = {
249
+ "object": "studio",
250
+ "character": "portrait",
251
+ "architecture": "architecture",
252
+ "landscape": "aerial",
253
+ }
254
+
255
+
197
256
  def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
198
257
  from reverse3d.reconstruction.photogrammetry import (
199
258
  PhotogrammetryOptions,
@@ -204,9 +263,11 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
204
263
  from reverse3d.vision.feature_matcher import analyze_image_set
205
264
 
206
265
  scene: str
266
+ subject, look = opt.subject, opt.render_preset
207
267
  if opt.scene == "auto":
208
268
  detection = run.step("scene", lambda: detect_scene(src, run.settings))
209
269
  scene, confidence, predicted = detection.kind, detection.confidence, True
270
+ subject, look = detection.subject, detection.render
210
271
  elif opt.scene in SCENE_PRESETS:
211
272
  scene, confidence, predicted = opt.scene, 1.0, False
212
273
  else:
@@ -219,6 +280,8 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
219
280
  "label": SCENE_LABEL_ES[scene],
220
281
  "confidence": confidence,
221
282
  "predicted": predicted,
283
+ "subject": subject,
284
+ "render": look or _DEFAULT_LOOK[scene],
222
285
  }
223
286
  analysis = run.step("analyze", lambda: analyze_image_set(src, run.settings))
224
287
  run.details["analysis"] = analysis.to_dict()
@@ -310,6 +373,15 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
310
373
  "Point cloud created but no mesh could be built.",
311
374
  hint='Install the meshing extra: pip install "reverse3d[recon]" (Python ≤ 3.12).',
312
375
  )
376
+ cloud = photo.dense_points_path or photo.sparse_points_path
377
+ if not upright and cloud:
378
+ try:
379
+ colored = _color_from_cloud(
380
+ Path(mesh_path), Path(cloud), run.out / "reconstruction" / "mesh_color.ply"
381
+ )
382
+ mesh_path = str(colored) if colored else mesh_path
383
+ except Exception as exc: # colour is a bonus
384
+ logger.info("Colouring from the point cloud skipped: %s", exc)
313
385
  model = run.out / "model.stl"
314
386
  _clean(
315
387
  run,
@@ -510,7 +582,7 @@ def run_recipe(
510
582
  source = prepare_heic_input(source, out) # iPhone HEIC photos -> JPEG
511
583
  stats = _RECIPES[recipe](run, source, opt)
512
584
  if opt.render:
513
- _render(run, Path(run.outputs["model"]), opt.overwrite)
585
+ _render(run, Path(run.outputs.get("model_color") or run.outputs["model"]), opt.overwrite)
514
586
 
515
587
  summary: dict[str, Any] = {
516
588
  "recipe": recipe,
@@ -191,6 +191,65 @@ def _support_plane(
191
191
  return n, d
192
192
 
193
193
 
194
+ def _vertex_colors(
195
+ mesh: trimesh.Trimesh,
196
+ views: list[_View],
197
+ images: list[tuple[np.ndarray, float]],
198
+ masks: list[np.ndarray] | None = None,
199
+ ) -> np.ndarray:
200
+ """Real colour of every vertex from the photos that see it head-on.
201
+
202
+ Each photo votes with weight cos(angle)^2 for the vertices that face it and
203
+ are not hidden (coarse depth buffer); vertices no photo sees get the median.
204
+ """
205
+ verts = np.asarray(mesh.vertices)
206
+ normals = np.asarray(mesh.vertex_normals)
207
+ total = np.zeros((len(verts), 3))
208
+ weight = np.zeros(len(verts))
209
+ for i, ((small, scale), view) in enumerate(zip(images, views, strict=True)):
210
+ # Only pixels of the object itself give colour (the carved hull is a bit
211
+ # larger than the object, so its rim would otherwise pick up background).
212
+ inner = (
213
+ cv2.erode(masks[i], np.ones((5, 5), np.uint8)) > 0
214
+ if masks is not None and masks[i].shape == small.shape[:2]
215
+ else None
216
+ )
217
+ center = -view.rotation.T @ view.translation
218
+ to_cam = center - verts
219
+ to_cam /= np.linalg.norm(to_cam, axis=1, keepdims=True)
220
+ facing = np.einsum("ij,ij->i", normals, to_cam)
221
+ uv, z = _project(verts, view)
222
+ u = uv[:, 0] * scale
223
+ v = uv[:, 1] * scale
224
+ h, w = small.shape[:2]
225
+ ok = (facing > 0.2) & (z > 0) & (u >= 0) & (v >= 0) & (u < w - 1) & (v < h - 1)
226
+ if not ok.any():
227
+ continue
228
+ # Coarse z-buffer: a vertex counts only if it is (about) the nearest in its cell.
229
+ cell = 4
230
+ cu = (u[ok] / cell).astype(np.int64)
231
+ cv_ = (v[ok] / cell).astype(np.int64)
232
+ zbuf = np.full(((h // cell) + 1, (w // cell) + 1), np.inf)
233
+ np.minimum.at(zbuf, (cv_, cu), z[ok])
234
+ depth_span = float(np.ptp(z[ok])) or 1.0
235
+ visible = z[ok] <= zbuf[cv_, cu] + 0.03 * depth_span
236
+ idx = np.flatnonzero(ok)[visible]
237
+ if inner is not None:
238
+ idx = idx[inner[v[idx].astype(np.int64), u[idx].astype(np.int64)]]
239
+ px = small[v[idx].astype(np.int64), u[idx].astype(np.int64)][:, ::-1].astype(np.float64)
240
+ wgt = facing[idx] ** 2
241
+ total[idx] += px * wgt[:, None]
242
+ weight[idx] += wgt
243
+ colors = np.zeros((len(verts), 4), np.uint8)
244
+ seen = weight > 0
245
+ rgb = np.zeros((len(verts), 3))
246
+ rgb[seen] = total[seen] / weight[seen, None]
247
+ rgb[~seen] = np.median(rgb[seen], axis=0) if seen.any() else 180
248
+ colors[:, :3] = np.clip(rgb, 0, 255).astype(np.uint8)
249
+ colors[:, 3] = 255
250
+ return colors
251
+
252
+
194
253
  def visual_hull(
195
254
  image_dir: Path,
196
255
  model_text_dir: Path,
@@ -332,6 +391,8 @@ def visual_hull(
332
391
  normal, offset = plane
333
392
  height = (grid @ normal + offset).reshape(n, n, n)
334
393
  volume[height < 0.015 * radius] = 0
394
+ # Drop structures thinner than a couple of voxels (stray spikes from a few views).
395
+ volume = ndimage.binary_opening(volume > 0.5, iterations=2).astype(np.float32)
335
396
  labels, count = ndimage.label(volume > 0.5)
336
397
  if count == 0:
337
398
  raise ProcessingError("No object could be isolated in the photos.")
@@ -356,6 +417,11 @@ def visual_hull(
356
417
  verts = (verts - 2) / (n - 1) * 2 * radius - radius + center
357
418
  mesh = trimesh.Trimesh(verts, faces, process=True)
358
419
  trimesh.smoothing.filter_taubin(mesh, lamb=0.5, nu=-0.53, iterations=15)
420
+ mesh.fix_normals()
421
+ # Real colours from the photos (kept by PLY export; the model gets them as GLB).
422
+ mesh.visual = trimesh.visual.ColorVisuals(
423
+ mesh, vertex_colors=_vertex_colors(mesh, views, images, masks)
424
+ )
359
425
 
360
426
  # Stand it upright: +Z along the table normal (or the mean camera "up").
361
427
  up = plane[0] if plane is not None else np.mean(cameras - center, axis=0)
@@ -136,8 +136,15 @@ def make_material(obj: bpy.types.Object, job: dict) -> None:
136
136
  obj.data.materials.append(mat)
137
137
 
138
138
 
139
- def add_light(name: str, location: tuple[float, float, float], energy: float, size: float) -> None:
139
+ def add_light(
140
+ name: str,
141
+ location: tuple[float, float, float],
142
+ energy: float,
143
+ size: float,
144
+ color: tuple[float, float, float] = (1.0, 1.0, 1.0),
145
+ ) -> None:
140
146
  data = bpy.data.lights.new(name, type="AREA")
147
+ data.color = color
141
148
  data.energy = energy
142
149
  data.size = size
143
150
  light = bpy.data.objects.new(name, data)
@@ -153,7 +160,37 @@ def setup_world(job: dict) -> None:
153
160
  world.use_nodes = True
154
161
  bg = world.node_tree.nodes.get("Background")
155
162
  bg.inputs["Color"].default_value = _hex_to_rgba(job["background"])
156
- bg.inputs["Strength"].default_value = 0.6
163
+ outdoor = job.get("preset") in ("architecture", "aerial")
164
+ bg.inputs["Strength"].default_value = 1.0 if outdoor else 0.6
165
+
166
+
167
+ def add_sun(elevation_deg: float, azimuth_deg: float, energy: float) -> None:
168
+ data = bpy.data.lights.new("sun", type="SUN")
169
+ data.energy = energy
170
+ data.angle = math.radians(1.5) # soft daylight shadows
171
+ sun = bpy.data.objects.new("sun", data)
172
+ bpy.context.collection.objects.link(sun)
173
+ sun.rotation_euler = (math.radians(90 - elevation_deg), 0.0, math.radians(azimuth_deg))
174
+
175
+
176
+ def setup_lights(r: float, job: dict) -> None:
177
+ """Lighting of each look (see RENDER_PRESETS in blender_render.py)."""
178
+ preset = job.get("preset", "studio")
179
+ if preset in ("architecture", "aerial"):
180
+ add_sun(55 if preset == "architecture" else 65, 140, 4.0)
181
+ add_light("sky_fill", (-r * 4, -r * 3, r * 3), 150 * r * r, r * 4, (0.85, 0.9, 1.0))
182
+ elif preset == "portrait":
183
+ add_light("key", (r * 3, -r * 3, r * 2.5), 900 * r * r, r * 1.5, (1.0, 0.93, 0.85))
184
+ add_light("fill", (-r * 4, -r * 2, r * 1.5), 160 * r * r, r * 3)
185
+ add_light("rim", (-r * 1, r * 4, r * 3), 900 * r * r, r * 1.2, (0.75, 0.85, 1.0))
186
+ elif preset == "showroom":
187
+ add_light("top", (0, 0, r * 5), 1200 * r * r, r * 4)
188
+ add_light("key", (r * 4, -r * 3, r * 2), 600 * r * r, r * 3)
189
+ add_light("rim", (-r * 3, r * 4, r * 2), 700 * r * r, r * 2.5)
190
+ else: # studio
191
+ add_light("key", (r * 3, -r * 3, r * 4), 800 * r * r, r * 2)
192
+ add_light("fill", (-r * 4, -r * 2, r * 2), 300 * r * r, r * 3)
193
+ add_light("rim", (0, r * 4, r * 3), 500 * r * r, r * 2)
157
194
 
158
195
 
159
196
  def setup_camera(radius: float, center_z: float, job: dict) -> bpy.types.Object:
@@ -162,7 +199,7 @@ def setup_camera(radius: float, center_z: float, job: dict) -> bpy.types.Object:
162
199
  rig.location = (0, 0, center_z)
163
200
 
164
201
  cam_data = bpy.data.cameras.new("reverse3d_camera")
165
- cam_data.lens = 50
202
+ cam_data.lens = float(job.get("lens_mm", 50))
166
203
  cam = bpy.data.objects.new("reverse3d_camera", cam_data)
167
204
  bpy.context.collection.objects.link(cam)
168
205
  # camera.angle is the FOV of the longer image side; fit the sphere in the shorter one.
@@ -248,10 +285,7 @@ def main() -> None:
248
285
  shade(obj, job["shading"])
249
286
  make_material(obj, job)
250
287
  setup_world(job)
251
- r = max(radius, 0.1)
252
- add_light("key", (r * 3, -r * 3, r * 4), 800 * r * r, r * 2)
253
- add_light("fill", (-r * 4, -r * 2, r * 2), 300 * r * r, r * 3)
254
- add_light("rim", (0, r * 4, r * 3), 500 * r * r, r * 2)
288
+ setup_lights(max(radius, 0.1), job)
255
289
  rig = setup_camera(radius, center_z, job)
256
290
  setup_ground(job)
257
291
  setup_render(job)
@@ -19,7 +19,7 @@ import re
19
19
  import tempfile
20
20
  from dataclasses import asdict, dataclass, field
21
21
  from pathlib import Path
22
- from typing import Literal
22
+ from typing import Any, Literal
23
23
 
24
24
  from reverse3d.config import Settings
25
25
  from reverse3d.errors import DependencyMissingError, InputValidationError, ProcessingError
@@ -63,6 +63,9 @@ class RenderOptions:
63
63
  ground: bool = True
64
64
  shading: Literal["auto", "smooth", "flat"] = "auto"
65
65
  vertex_colors: bool = True
66
+ #: look of the shot: studio, showroom, portrait, architecture or aerial.
67
+ preset: str = "studio"
68
+ lens_mm: float = 50.0
66
69
 
67
70
  def validate(self) -> None:
68
71
  checks = [
@@ -82,12 +85,63 @@ class RenderOptions:
82
85
  (0 <= self.azimuth_deg <= 360, "azimuth_deg must be in [0, 360]"),
83
86
  (0 <= self.turntable_frames <= 360, "turntable_frames must be in [0, 360]"),
84
87
  (self.shading in ("auto", "smooth", "flat"), "shading must be auto, smooth or flat"),
88
+ (self.preset in RENDER_PRESETS, f"preset must be one of {', '.join(RENDER_PRESETS)}"),
89
+ (10 <= self.lens_mm <= 300, "lens_mm must be in [10, 300]"),
85
90
  ]
86
91
  for ok, message in checks:
87
92
  if not ok:
88
93
  raise InputValidationError(message)
89
94
 
90
95
 
96
+ #: Camera, lens and backdrop of each look (lights are set up in the Blender job).
97
+ RENDER_PRESETS: dict[str, dict[str, Any]] = {
98
+ # Products and parts: seamless light backdrop, three soft boxes.
99
+ "studio": {
100
+ "lens_mm": 50.0,
101
+ "elevation_deg": 25.0,
102
+ "azimuth_deg": 35.0,
103
+ "background": "#f4f5f7",
104
+ },
105
+ # Vehicles: longer lens, low angle, glossy paint, showroom grey.
106
+ "showroom": {
107
+ "lens_mm": 70.0,
108
+ "elevation_deg": 12.0,
109
+ "azimuth_deg": 40.0,
110
+ "background": "#e6e9ee",
111
+ "roughness": 0.22,
112
+ },
113
+ # People and animals: portrait lens, eye level, dark backdrop, warm key + cool rim.
114
+ "portrait": {
115
+ "lens_mm": 85.0,
116
+ "elevation_deg": 6.0,
117
+ "azimuth_deg": 25.0,
118
+ "background": "#1d222b",
119
+ },
120
+ # Buildings and interiors: wide lens, sun and sky.
121
+ "architecture": {
122
+ "lens_mm": 28.0,
123
+ "elevation_deg": 20.0,
124
+ "azimuth_deg": 40.0,
125
+ "background": "#bcd4ea",
126
+ },
127
+ # Terrain and forests: high aerial view in daylight.
128
+ "aerial": {
129
+ "lens_mm": 35.0,
130
+ "elevation_deg": 55.0,
131
+ "azimuth_deg": 30.0,
132
+ "background": "#cfe0ee",
133
+ },
134
+ }
135
+
136
+
137
+ def preset_options(preset: str, base: RenderOptions | None = None) -> RenderOptions:
138
+ """Render options for a look, keeping size, samples and device from ``base``."""
139
+ from dataclasses import replace
140
+
141
+ name = preset if preset in RENDER_PRESETS else "studio"
142
+ return replace(base or RenderOptions(), preset=name, **RENDER_PRESETS[name])
143
+
144
+
91
145
  @dataclass
92
146
  class RenderResult:
93
147
  """Paths of the rendered images and the backend that produced them."""
@@ -102,6 +102,12 @@ class SceneDetection:
102
102
  recommended: tuple[int, int]
103
103
  enough_photos: bool
104
104
  advice: list[str] = field(default_factory=list)
105
+ #: what the AI classifier saw (product, person, animal, vehicle, building...), if available.
106
+ subject: str | None = None
107
+ subject_label: str | None = None
108
+ subject_scores: dict[str, float] = field(default_factory=dict)
109
+ #: render look: studio, showroom, portrait, architecture or aerial.
110
+ render: str = "studio"
105
111
 
106
112
  def to_dict(self) -> dict[str, object]:
107
113
  return asdict(self)
@@ -307,6 +313,26 @@ def score_scene(signals: SceneSignals) -> tuple[dict[str, float], list[str]]:
307
313
  return {k: round(max(v, 0.0), 3) for k, v in scores.items()}, reasons
308
314
 
309
315
 
316
+ def _ai_subject(images: list[Path], settings: Settings) -> tuple[str | None, dict[str, float]]:
317
+ """The AI classifier's subject for a sample of the photos (None if unavailable)."""
318
+ from reverse3d.errors import Reverse3DError
319
+ from reverse3d.vision.classifier import classify_photos
320
+
321
+ step = max(1, len(images) // _SAMPLE)
322
+ try:
323
+ scores = classify_photos(images[::step][:_SAMPLE], settings)
324
+ except Reverse3DError as exc:
325
+ logger.info("AI subject classifier unavailable: %s", exc.message)
326
+ return None, {}
327
+ return max(scores, key=scores.__getitem__), scores
328
+
329
+
330
+ def _subject_label(subject: str | None) -> str | None:
331
+ from reverse3d.vision.classifier import SUBJECT_LABEL_ES
332
+
333
+ return SUBJECT_LABEL_ES.get(subject) if subject else None
334
+
335
+
310
336
  def detect_scene(image_dir: str | Path, settings: Settings) -> SceneDetection:
311
337
  """Predict the scene kind of a photo folder (takes a few seconds)."""
312
338
  directory = validate_input_dir(image_dir)
@@ -319,6 +345,20 @@ def detect_scene(image_dir: str | Path, settings: Settings) -> SceneDetection:
319
345
  ranked = sorted(scores.values(), reverse=True)
320
346
  margin = ranked[0] - (ranked[1] if len(ranked) > 1 else 0.0)
321
347
  confidence = round(min(0.97, 0.5 + margin * 1.5), 2)
348
+ subject, subject_scores = _ai_subject(images, settings)
349
+ render = (
350
+ "aerial" if kind == "landscape" else "architecture" if kind == "architecture" else "studio"
351
+ )
352
+ if subject is not None:
353
+ from reverse3d.vision.classifier import SUBJECT_RENDER, SUBJECT_SCENE
354
+
355
+ flight = signals.drone and signals.nadir_share > 0.3
356
+ if flight: # a drone looking down is terrain whatever the AI thinks it sees
357
+ subject = "terrain" if subject not in ("forest", "terrain") else subject
358
+ kind = SUBJECT_SCENE[subject] # type: ignore[assignment]
359
+ render = SUBJECT_RENDER[subject]
360
+ confidence = round(min(0.98, max(subject_scores.get(subject, 0.0), 0.5)), 2)
361
+ reasons.insert(0, f"ai_{subject}")
322
362
  preset = SCENE_PRESETS[kind]
323
363
  enough = signals.photos >= preset.min_photos
324
364
  if not enough:
@@ -337,6 +377,10 @@ def detect_scene(image_dir: str | Path, settings: Settings) -> SceneDetection:
337
377
  recommended=preset.recommended,
338
378
  enough_photos=enough,
339
379
  advice=advice,
380
+ subject=subject,
381
+ subject_label=_subject_label(subject),
382
+ subject_scores={k: round(v, 3) for k, v in subject_scores.items()},
383
+ render=render,
340
384
  )
341
385
 
342
386
 
@@ -0,0 +1,170 @@
1
+ """What was photographed: a small local AI (TinyCLIP, MIT) that names the subject.
2
+
3
+ The subject drives everything downstream: how the photos are reconstructed
4
+ (scene profile), how the model is rendered (studio, portrait, architecture or
5
+ aerial light) and which capture advice is given. TinyCLIP compares the photos
6
+ with short descriptions of each subject (zero-shot), so no training data is
7
+ needed and new subjects are a line of text.
8
+
9
+ Like the other AI models, it is allowlisted, pinned to an exact commit,
10
+ loaded from safetensors only and works offline after the first download.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ from functools import lru_cache
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import numpy as np
21
+
22
+ from reverse3d.config import Settings
23
+ from reverse3d.errors import DependencyMissingError
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ ALLOWED_CLASSIFIERS: dict[str, tuple[str, str]] = {
28
+ "wkcn/TinyCLIP-ViT-40M-32-Text-19M-LAION400M": ("clip", "MIT"),
29
+ }
30
+ DEFAULT_CLASSIFIER = "wkcn/TinyCLIP-ViT-40M-32-Text-19M-LAION400M"
31
+
32
+ #: subject -> descriptions (averaged). Order matters only for ties.
33
+ SUBJECTS: dict[str, tuple[str, ...]] = {
34
+ "product": (
35
+ "a photo of a product on a table",
36
+ "a photo of a bottle, cup or household object",
37
+ "a photo of an object on a desk",
38
+ ),
39
+ "part": ("a photo of a mechanical part", "a photo of a tool or machine component"),
40
+ "figure": ("a photo of a statue or figurine", "a photo of a toy or sculpture"),
41
+ "person": ("a photo of a person", "a photo of a man or a woman standing"),
42
+ "animal": ("a photo of an animal", "a photo of a dog, cat or pet", "a photo of a wild animal"),
43
+ "vehicle": ("a photo of a car", "a photo of a vehicle or motorcycle"),
44
+ "building": ("a photo of a building exterior", "a photo of a house facade"),
45
+ "interior": ("a photo of a room interior", "a photo of the inside of a house"),
46
+ "forest": ("a photo of a forest or trees", "a photo of plants and vegetation"),
47
+ "terrain": ("an aerial photo of land or terrain", "a photo of a landscape or field"),
48
+ }
49
+
50
+ #: How each subject is reconstructed (scene profile) ...
51
+ SUBJECT_SCENE: dict[str, str] = {
52
+ "product": "object",
53
+ "part": "object",
54
+ "figure": "object",
55
+ "vehicle": "object",
56
+ "person": "character",
57
+ "animal": "character",
58
+ "building": "architecture",
59
+ "interior": "architecture",
60
+ "forest": "landscape",
61
+ "terrain": "landscape",
62
+ }
63
+ #: ... and rendered.
64
+ SUBJECT_RENDER: dict[str, str] = {
65
+ "product": "studio",
66
+ "part": "studio",
67
+ "figure": "studio",
68
+ "vehicle": "showroom",
69
+ "person": "portrait",
70
+ "animal": "portrait",
71
+ "building": "architecture",
72
+ "interior": "architecture",
73
+ "forest": "aerial",
74
+ "terrain": "aerial",
75
+ }
76
+ SUBJECT_LABEL_ES: dict[str, str] = {
77
+ "product": "Objeto o producto",
78
+ "part": "Pieza mecánica",
79
+ "figure": "Figura o escultura",
80
+ "vehicle": "Vehículo",
81
+ "person": "Persona",
82
+ "animal": "Animal",
83
+ "building": "Edificio",
84
+ "interior": "Interior",
85
+ "forest": "Bosque o vegetación",
86
+ "terrain": "Terreno o paisaje",
87
+ }
88
+
89
+
90
+ def classifier_available() -> bool:
91
+ """True when the AI stack (torch + transformers) is installed."""
92
+ try:
93
+ import torch # noqa: F401
94
+ import transformers # noqa: F401
95
+ except ImportError:
96
+ return False
97
+ return True
98
+
99
+
100
+ def _features(output: Any) -> Any:
101
+ """Projected embeddings: a tensor in transformers 4, an output object in 5."""
102
+ return output if hasattr(output, "norm") else output.pooler_output
103
+
104
+
105
+ @lru_cache(maxsize=1)
106
+ def _load(model_dir: str) -> tuple[Any, Any, Any]:
107
+ import torch
108
+ from transformers import CLIPModel, CLIPProcessor
109
+
110
+ try:
111
+ from transformers.utils import logging as hf_logging
112
+
113
+ hf_logging.disable_progress_bar()
114
+ hf_logging.set_verbosity_error()
115
+ except ImportError: # pragma: no cover
116
+ pass
117
+ # model_dir is the local snapshot of a commit-pinned, allowlisted model.
118
+ model: Any = CLIPModel.from_pretrained( # nosec B615
119
+ model_dir, local_files_only=True, use_safetensors=True, trust_remote_code=False
120
+ )
121
+ processor = CLIPProcessor.from_pretrained(model_dir, local_files_only=True) # nosec B615
122
+ model.eval()
123
+ prompts = [p for texts in SUBJECTS.values() for p in texts]
124
+ with torch.no_grad():
125
+ tokens = processor(text=prompts, return_tensors="pt", padding=True)
126
+ text = _features(model.get_text_features(**tokens))
127
+ text = text / text.norm(dim=-1, keepdim=True)
128
+ # One averaged, normalised embedding per subject.
129
+ rows, start = [], 0
130
+ for texts in SUBJECTS.values():
131
+ emb = text[start : start + len(texts)].mean(0)
132
+ rows.append(emb / emb.norm())
133
+ start += len(texts)
134
+ return model, processor, torch.stack(rows)
135
+
136
+
137
+ def classify_photos(paths: list[Path], settings: Settings) -> dict[str, float]:
138
+ """Probability of each subject for a set of photos (averaged over them).
139
+
140
+ Raises:
141
+ DependencyMissingError: the AI extra or the model is not available.
142
+ """
143
+ if not classifier_available():
144
+ raise DependencyMissingError(
145
+ "The AI subject classifier is not installed.", hint='pip install "reverse3d[depth]"'
146
+ )
147
+ import torch
148
+ from PIL import Image
149
+
150
+ from reverse3d.offline import force_offline_env, is_offline, resolve_model
151
+
152
+ model_dir = resolve_model(DEFAULT_CLASSIFIER, settings)
153
+ if is_offline(settings):
154
+ force_offline_env()
155
+ model, processor, subjects = _load(str(model_dir))
156
+ images = []
157
+ for path in paths:
158
+ with Image.open(path) as image:
159
+ rgb = image.convert("RGB")
160
+ rgb.thumbnail((448, 448))
161
+ images.append(rgb)
162
+ with torch.no_grad():
163
+ pixels = processor(images=images, return_tensors="pt")
164
+ feats = _features(model.get_image_features(**pixels))
165
+ feats = feats / feats.norm(dim=-1, keepdim=True)
166
+ logits = model.logit_scale.exp() * feats @ subjects.T
167
+ probs = logits.softmax(dim=-1).mean(0).cpu().numpy()
168
+ result = {name: float(p) for name, p in zip(SUBJECTS, np.asarray(probs), strict=True)}
169
+ logger.info("Subject: %s", max(result, key=result.__getitem__))
170
+ return result
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reverse3d
3
- Version: 0.7.0
3
+ Version: 0.8.1
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>
@@ -215,10 +215,11 @@ Cada archivo modificado se **respalda antes** (`*.reverse3d-backup-<fecha>`). So
215
215
 
216
216
  | bash (`install.sh`) | PowerShell (`install.ps1`) | Efecto |
217
217
  |---------------------|----------------------------|--------|
218
- | *(sin opciones)* | *(sin opciones)* | **Todo**: núcleo, MCP, render, mallado, IA de profundidad, Blender y COLMAP |
219
- | `--lite` | `-Lite` | Todo menos la IA de profundidad (PyTorch); ahorra hasta ~2.5 GB |
220
- | `--minimal` | `-Minimal` | Solo núcleo + MCP, sin herramientas externas |
218
+ | *(sin opciones)* | *(sin opciones)* | **Todo**: núcleo, MCP, render, mallado, IA local (con CUDA si hay NVIDIA), Blender y COLMAP |
219
+ | `--lite` | `-Lite` | Todo, con la IA local en la CPU (PyTorch CPU, ~250 MB en vez de ~2.5 GB) |
220
+ | `--minimal` | `-Minimal` | Núcleo + MCP + IA local, sin herramientas externas |
221
221
  | `--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 |
222
223
  | `--no-auto-approve` | `-NoAutoApprove` | Que los agentes pregunten antes de cada herramienta |
223
224
  | `--with recon,depth` | `-With recon,depth` | Extras a elección |
224
225
  | `--device auto\|cpu\|gpu` | `-Device …` | Dispositivo por defecto |
@@ -51,6 +51,7 @@ reverse3d/reverse_eng/measure.py
51
51
  reverse3d/reverse_eng/mesh_cleaner.py
52
52
  reverse3d/reverse_eng/parametric.py
53
53
  reverse3d/vision/__init__.py
54
+ reverse3d/vision/classifier.py
54
55
  reverse3d/vision/feature_matcher.py
55
56
  reverse3d/vision/mask_extractor.py
56
57
  reverse3d/vision/segmenter.py
@@ -59,6 +60,7 @@ tests/test_authorship.py
59
60
  tests/test_automation.py
60
61
  tests/test_cli.py
61
62
  tests/test_cli_automation.py
63
+ tests/test_color.py
62
64
  tests/test_config.py
63
65
  tests/test_geometry_fit.py
64
66
  tests/test_heic.py
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import numpy as np
6
+ import trimesh
7
+
8
+ from reverse3d.config import Settings
9
+ from reverse3d.recipes import _clean, _color_from_cloud, _has_vertex_colors, _Run
10
+
11
+
12
+ def _two_tone_cloud(path: Path) -> Path:
13
+ """Points on a sphere: red on top, blue at the bottom (like a COLMAP fused.ply)."""
14
+ pts = trimesh.creation.icosphere(subdivisions=5).vertices
15
+ rgb = np.where(pts[:, 2:3] > 0, [220, 30, 30], [30, 30, 220])
16
+ trimesh.PointCloud(pts, colors=np.column_stack([rgb, np.full(len(pts), 255)])).export(path)
17
+ return path
18
+
19
+
20
+ def test_mesh_takes_the_colours_of_its_point_cloud(tmp_path: Path) -> None:
21
+ plain = tmp_path / "mesh.ply"
22
+ trimesh.creation.icosphere(subdivisions=3).export(plain)
23
+ assert not _has_vertex_colors(plain)
24
+ colored = _color_from_cloud(plain, _two_tone_cloud(tmp_path / "cloud.ply"), tmp_path / "c.ply")
25
+ assert colored is not None and _has_vertex_colors(colored)
26
+ mesh = trimesh.load(colored, process=False)
27
+ top = mesh.vertices[:, 2] > 0.3
28
+ assert mesh.visual.vertex_colors[top, 0].mean() > 150 # red on top
29
+ assert mesh.visual.vertex_colors[~top & (mesh.vertices[:, 2] < -0.3), 2].mean() > 150
30
+
31
+
32
+ def test_cleaning_a_coloured_mesh_also_writes_glb(tmp_path: Path, settings: Settings) -> None:
33
+ plain = tmp_path / "mesh.ply"
34
+ trimesh.creation.icosphere(subdivisions=3).export(plain)
35
+ colored = _color_from_cloud(plain, _two_tone_cloud(tmp_path / "cloud.ply"), tmp_path / "c.ply")
36
+ assert colored is not None
37
+ run = _Run(recipe="photos_to_model", settings=settings, out=tmp_path)
38
+ _clean(run, colored, tmp_path / "model.stl", scale=1.0, overwrite=True)
39
+ assert {"model", "model_color", "model_glb"} <= set(run.outputs)
40
+ glb = trimesh.load(run.outputs["model_glb"], force="mesh")
41
+ assert len(np.unique(np.asarray(glb.visual.vertex_colors)[:, :3], axis=0)) > 1
42
+
43
+
44
+ def test_plain_meshes_get_no_colour_outputs(tmp_path: Path, settings: Settings) -> None:
45
+ plain = tmp_path / "mesh.ply"
46
+ trimesh.creation.icosphere(subdivisions=3).export(plain)
47
+ run = _Run(recipe="mesh_report", settings=settings, out=tmp_path)
48
+ _clean(run, plain, tmp_path / "model.stl", scale=1.0, overwrite=True)
49
+ assert "model_glb" not in run.outputs
@@ -125,9 +125,11 @@ class TestModels:
125
125
 
126
126
  def test_every_allowlisted_model_is_pinned(self, tmp_path: Path) -> None:
127
127
  from reverse3d.reconstruction.depth_estimation import ALLOWED_MODELS
128
+ from reverse3d.vision.classifier import ALLOWED_CLASSIFIERS
128
129
  from reverse3d.vision.segmenter import ALLOWED_SEGMENTERS
129
130
 
130
- assert set(ALLOWED_MODELS) | set(ALLOWED_SEGMENTERS) == set(offline.MODEL_REVISIONS)
131
+ allowed = set(ALLOWED_MODELS) | set(ALLOWED_SEGMENTERS) | set(ALLOWED_CLASSIFIERS)
132
+ assert allowed == set(offline.MODEL_REVISIONS)
131
133
  assert all(len(sha) == 40 for sha in offline.MODEL_REVISIONS.values())
132
134
  other = tmp_path / "_r3d_home" / "models" / ("models--" + SMALL.replace("/", "--"))
133
135
  (other / "snapshots" / "unreviewed").mkdir(parents=True)
@@ -112,3 +112,55 @@ def test_presets_and_advice_cover_every_scene() -> None:
112
112
  assert scene_advice(kind, preset.recommended[0])[0]
113
113
  assert not SCENE_PRESETS["architecture"].fill_holes
114
114
  assert SCENE_PRESETS["object"].fill_holes
115
+
116
+
117
+ def _fake_subject(monkeypatch: pytest.MonkeyPatch, subject: str) -> None:
118
+ from reverse3d import scenes
119
+
120
+ monkeypatch.setattr(
121
+ scenes, "_ai_subject", lambda *_a: (subject, {subject: 0.9, "product": 0.1})
122
+ )
123
+
124
+
125
+ @pytest.mark.parametrize(
126
+ ("subject", "kind", "render"),
127
+ [
128
+ ("person", "character", "portrait"),
129
+ ("animal", "character", "portrait"),
130
+ ("vehicle", "object", "showroom"),
131
+ ("building", "architecture", "architecture"),
132
+ ("forest", "landscape", "aerial"),
133
+ ],
134
+ )
135
+ def test_ai_subject_decides_scene_and_render(
136
+ tmp_path: Path,
137
+ settings: Settings,
138
+ monkeypatch: pytest.MonkeyPatch,
139
+ subject: str,
140
+ kind: str,
141
+ render: str,
142
+ ) -> None:
143
+ _fake_subject(monkeypatch, subject)
144
+ (tmp_path / "set").mkdir()
145
+ for i in range(4):
146
+ cv2.imwrite(str(tmp_path / "set" / f"{i}.jpg"), _facade())
147
+ detection = detect_scene(tmp_path / "set", settings)
148
+ assert (detection.kind, detection.render, detection.subject) == (kind, render, subject)
149
+ assert detection.reasons[0] == f"ai_{subject}" and detection.subject_label
150
+
151
+
152
+ def test_a_drone_flight_is_terrain_whatever_the_ai_says(
153
+ tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch
154
+ ) -> None:
155
+ _fake_subject(monkeypatch, "building")
156
+ for i in range(6):
157
+ _gps_jpeg(tmp_path / "vuelo" / f"DJI_{i:04d}.jpg", 8.98 + i * 0.0005, -79.52)
158
+ # Gimbal pointing straight down, as DJI writes it in the XMP block.
159
+ path = tmp_path / "vuelo" / f"DJI_{i:04d}.jpg"
160
+ path.write_bytes(path.read_bytes() + b'<x drone-dji:GimbalPitchDegree="-90.0"/>')
161
+ detection = detect_scene(tmp_path / "vuelo", settings)
162
+ assert (detection.kind, detection.subject, detection.render) == (
163
+ "landscape",
164
+ "terrain",
165
+ "aerial",
166
+ )
@@ -1 +0,0 @@
1
- __version__ = "0.7.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