reverse3d 0.6.0__tar.gz → 0.8.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 (77) hide show
  1. {reverse3d-0.6.0/reverse3d.egg-info → reverse3d-0.8.0}/PKG-INFO +1 -1
  2. reverse3d-0.8.0/reverse3d/_version.py +1 -0
  3. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/autopilot.py +6 -1
  4. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/offline.py +38 -5
  5. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/recipes.py +69 -7
  6. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/reconstruction/silhouette.py +107 -1
  7. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/rendering/_blender_job.py +41 -7
  8. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/rendering/blender_render.py +55 -1
  9. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/scenes.py +44 -0
  10. reverse3d-0.8.0/reverse3d/vision/classifier.py +170 -0
  11. reverse3d-0.8.0/reverse3d/vision/segmenter.py +136 -0
  12. {reverse3d-0.6.0 → reverse3d-0.8.0/reverse3d.egg-info}/PKG-INFO +1 -1
  13. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d.egg-info/SOURCES.txt +2 -0
  14. {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_offline_autopilot.py +4 -1
  15. {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_scenes.py +52 -0
  16. {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_silhouette.py +52 -0
  17. reverse3d-0.6.0/reverse3d/_version.py +0 -1
  18. {reverse3d-0.6.0 → reverse3d-0.8.0}/LICENSE +0 -0
  19. {reverse3d-0.6.0 → reverse3d-0.8.0}/NOTICE +0 -0
  20. {reverse3d-0.6.0 → reverse3d-0.8.0}/README.md +0 -0
  21. {reverse3d-0.6.0 → reverse3d-0.8.0}/pyproject.toml +0 -0
  22. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/__init__.py +0 -0
  23. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/__main__.py +0 -0
  24. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/_about.py +0 -0
  25. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/agent/__init__.py +0 -0
  26. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/agent/compact.py +0 -0
  27. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/agent/mcp_server.py +0 -0
  28. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/agent/prompts.py +0 -0
  29. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/agent/skill/SKILL.md +0 -0
  30. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/agent/tools.py +0 -0
  31. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/cli.py +0 -0
  32. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/config.py +0 -0
  33. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/doctor.py +0 -0
  34. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/errors.py +0 -0
  35. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/hardware.py +0 -0
  36. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/imaging.py +0 -0
  37. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/logging_utils.py +0 -0
  38. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/mesh_io.py +0 -0
  39. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/profiles.py +0 -0
  40. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/py.typed +0 -0
  41. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/reconstruction/__init__.py +0 -0
  42. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/reconstruction/depth_estimation.py +0 -0
  43. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/reconstruction/photogrammetry.py +0 -0
  44. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/reconstruction/pointcloud.py +0 -0
  45. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/rendering/__init__.py +0 -0
  46. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/rendering/preview.py +0 -0
  47. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/reverse_eng/__init__.py +0 -0
  48. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/reverse_eng/geometry_fit.py +0 -0
  49. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/reverse_eng/measure.py +0 -0
  50. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/reverse_eng/mesh_cleaner.py +0 -0
  51. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/reverse_eng/parametric.py +0 -0
  52. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/security.py +0 -0
  53. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/setup_agents.py +0 -0
  54. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/userconfig.py +0 -0
  55. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/vision/__init__.py +0 -0
  56. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/vision/feature_matcher.py +0 -0
  57. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/vision/mask_extractor.py +0 -0
  58. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/watch.py +0 -0
  59. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d.egg-info/dependency_links.txt +0 -0
  60. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d.egg-info/entry_points.txt +0 -0
  61. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d.egg-info/requires.txt +0 -0
  62. {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d.egg-info/top_level.txt +0 -0
  63. {reverse3d-0.6.0 → reverse3d-0.8.0}/setup.cfg +0 -0
  64. {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_agent_tools.py +0 -0
  65. {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_authorship.py +0 -0
  66. {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_automation.py +0 -0
  67. {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_cli.py +0 -0
  68. {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_cli_automation.py +0 -0
  69. {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_config.py +0 -0
  70. {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_geometry_fit.py +0 -0
  71. {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_heic.py +0 -0
  72. {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_mesh.py +0 -0
  73. {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_optional_extras.py +0 -0
  74. {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_reconstruction.py +0 -0
  75. {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_render.py +0 -0
  76. {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_security.py +0 -0
  77. {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_vision.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reverse3d
3
- Version: 0.6.0
3
+ Version: 0.8.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>
@@ -0,0 +1 @@
1
+ __version__ = "0.8.0"
@@ -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
@@ -54,6 +54,8 @@ MODEL_REVISIONS: dict[str, str] = {
54
54
  "depth-anything/Depth-Anything-V2-Small-hf": "5426e4f0f36572d16453bbda7a8389317b1bef99",
55
55
  "depth-anything/Depth-Anything-V2-Base-hf": "b1958afc87fb45a9e3746cb387596094de553ed8",
56
56
  "depth-anything/Depth-Anything-V2-Large-hf": "7581137eff8d4e94f6e796d3baea0e9fa79b22d2",
57
+ "facebook/sam-vit-base": "70c1a07f894ebb5b307fd9eaaee97b9dfc16068f",
58
+ "wkcn/TinyCLIP-ViT-40M-32-Text-19M-LAION400M": "95ec8197b3f2fe7f747865c61ca556cf0768b2f7",
57
59
  }
58
60
 
59
61
 
@@ -100,8 +102,10 @@ def is_offline(settings: Settings) -> bool:
100
102
 
101
103
  def _allowed_models() -> dict[str, tuple[str, str]]:
102
104
  from reverse3d.reconstruction.depth_estimation import ALLOWED_MODELS
105
+ from reverse3d.vision.classifier import ALLOWED_CLASSIFIERS
106
+ from reverse3d.vision.segmenter import ALLOWED_SEGMENTERS
103
107
 
104
- return dict(ALLOWED_MODELS)
108
+ return {**ALLOWED_MODELS, **ALLOWED_SEGMENTERS, **ALLOWED_CLASSIFIERS}
105
109
 
106
110
 
107
111
  def _default_model() -> str:
@@ -177,7 +181,7 @@ def resolve_model(model_id: str, settings: Settings) -> Path:
177
181
  return local
178
182
  if is_offline(settings):
179
183
  raise DependencyMissingError(
180
- "The AI depth model is not on this computer and there is no internet connection.",
184
+ "The AI model is not on this computer and there is no internet connection.",
181
185
  hint="Connect to the internet once and run: reverse3d offline prepare",
182
186
  )
183
187
  try:
@@ -190,7 +194,7 @@ def resolve_model(model_id: str, settings: Settings) -> Path:
190
194
  except Exception as exc: # network errors come in many shapes; report them plainly
191
195
  has_internet(refresh=True)
192
196
  raise DependencyMissingError(
193
- f"Could not download the AI depth model ({exc.__class__.__name__}).",
197
+ f"Could not download the AI model ({exc.__class__.__name__}).",
194
198
  hint="Check your connection and run: reverse3d offline prepare",
195
199
  ) from exc
196
200
 
@@ -201,7 +205,12 @@ def prepare(settings: Settings, *, include_non_commercial: bool = False) -> dict
201
205
  Safe to run any time: models already on disk are skipped. It never raises
202
206
  for a missing optional stack; that is reported in the result instead.
203
207
  """
204
- models = [_default_model()]
208
+ from reverse3d.vision.classifier import DEFAULT_CLASSIFIER
209
+ from reverse3d.vision.segmenter import DEFAULT_SEGMENTER
210
+
211
+ # Depth (reliefs), segmentation (silhouettes) and the subject classifier,
212
+ # all commercial-friendly.
213
+ models = [_default_model(), DEFAULT_SEGMENTER, DEFAULT_CLASSIFIER]
205
214
  if include_non_commercial:
206
215
  models += [m for m in _allowed_models() if m not in models]
207
216
 
@@ -274,8 +283,18 @@ def offline_status(settings: Settings) -> dict[str, Any]:
274
283
 
275
284
  env = environment_report(settings, include_offline=False)
276
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
+
277
289
  depth_installed = bool(caps.get("depth_estimation"))
278
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()}
279
298
  internet = None if settings.offline != "auto" else has_internet()
280
299
 
281
300
  components = {
@@ -287,13 +306,27 @@ def offline_status(settings: Settings) -> dict[str, Any]:
287
306
  "available": caps.get("render_blender", False) or caps.get("render_pyvista", False),
288
307
  "offline": True,
289
308
  },
290
- "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
+ },
291
313
  }
292
314
  missing = []
315
+ names = {
316
+ "ai_subject": "AI subject classifier",
317
+ "ai_segmentation": "AI segmentation",
318
+ "ai_depth": "AI depth",
319
+ }
293
320
  if depth_installed and not model_on_disk:
294
321
  missing.append(
295
322
  "AI depth model not downloaded yet: run `reverse3d offline prepare` while online."
296
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
+ )
297
330
  working_offline = is_offline(settings)
298
331
  return {
299
332
  "mode": settings.offline,
@@ -36,6 +36,7 @@ from reverse3d.errors import (
36
36
  DependencyMissingError,
37
37
  InputValidationError,
38
38
  ProcessingError,
39
+ Reverse3DError,
39
40
  )
40
41
  from reverse3d.hardware import torch_device, wants_gpu
41
42
  from reverse3d.profiles import profile_values
@@ -74,6 +75,9 @@ class RecipeOptions:
74
75
  mask_method: MaskMethod = "otsu"
75
76
  #: photos_to_model: scene profile (object, character, architecture, landscape) or "auto".
76
77
  scene: str = "auto"
78
+ #: what was photographed (AI classifier) and the render look; None = from the scene.
79
+ subject: str | None = None
80
+ render_preset: str | None = None
77
81
  #: photo_to_relief: object mask (white = keep), e.g. from the autopilot fallback.
78
82
  mask_path: str | None = None
79
83
  #: mesh repair strength; "strong" closes much larger holes.
@@ -155,15 +159,20 @@ def _clean(
155
159
 
156
160
 
157
161
  def _render(run: _Run, mesh: Path, overwrite: bool) -> None:
158
- from reverse3d.rendering.blender_render import RenderOptions, render_mesh
162
+ from reverse3d.rendering.blender_render import RenderOptions, preset_options, render_mesh
159
163
 
160
164
  prof = profile_values(run.settings)
161
- options = RenderOptions(
162
- width=prof.render_width,
163
- height=prof.render_height,
164
- samples=prof.render_samples,
165
- device="gpu" if wants_gpu(run.settings) else "cpu",
165
+ look = str(run.details.get("scene", {}).get("render") or "studio")
166
+ options = preset_options(
167
+ look,
168
+ RenderOptions(
169
+ width=prof.render_width,
170
+ height=prof.render_height,
171
+ samples=prof.render_samples,
172
+ device="gpu" if wants_gpu(run.settings) else "cpu",
173
+ ),
166
174
  )
175
+ run.details["render_look"] = look
167
176
  try:
168
177
  result = run.step(
169
178
  "render",
@@ -193,6 +202,36 @@ def _measure(run: _Run, mesh: Path) -> MeshStats:
193
202
  _MIN_GROUP = 8
194
203
 
195
204
 
205
+ _DEFAULT_LOOK = {
206
+ "object": "studio",
207
+ "character": "portrait",
208
+ "architecture": "architecture",
209
+ "landscape": "aerial",
210
+ }
211
+
212
+
213
+ def _color_outputs(run: _Run, colored: Path, model: Path, scale: float) -> None:
214
+ """Give the final model the photo colours of the carved hull: model.glb (games,
215
+ web, viewers) and model_color.ply (renders)."""
216
+ import numpy as np
217
+ import trimesh
218
+ from scipy.spatial import cKDTree
219
+
220
+ source: Any = trimesh.load(colored, process=False, force="mesh")
221
+ target: Any = trimesh.load(model, process=False, force="mesh")
222
+ colors = getattr(source.visual, "vertex_colors", None)
223
+ if colors is None or len(colors) != len(source.vertices):
224
+ return
225
+ _, nearest = cKDTree(np.asarray(source.vertices) * scale).query(np.asarray(target.vertices))
226
+ target.visual = trimesh.visual.ColorVisuals(target, vertex_colors=np.asarray(colors)[nearest])
227
+ glb = run.out / "model.glb"
228
+ ply = run.out / "model_color.ply"
229
+ target.export(glb)
230
+ target.export(ply)
231
+ run.outputs["model_glb"] = str(glb)
232
+ run.outputs["model_color"] = str(ply)
233
+
234
+
196
235
  def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
197
236
  from reverse3d.reconstruction.photogrammetry import (
198
237
  PhotogrammetryOptions,
@@ -203,9 +242,11 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
203
242
  from reverse3d.vision.feature_matcher import analyze_image_set
204
243
 
205
244
  scene: str
245
+ subject, look = opt.subject, opt.render_preset
206
246
  if opt.scene == "auto":
207
247
  detection = run.step("scene", lambda: detect_scene(src, run.settings))
208
248
  scene, confidence, predicted = detection.kind, detection.confidence, True
249
+ subject, look = detection.subject, detection.render
209
250
  elif opt.scene in SCENE_PRESETS:
210
251
  scene, confidence, predicted = opt.scene, 1.0, False
211
252
  else:
@@ -218,6 +259,8 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
218
259
  "label": SCENE_LABEL_ES[scene],
219
260
  "confidence": confidence,
220
261
  "predicted": predicted,
262
+ "subject": subject,
263
+ "render": look or _DEFAULT_LOOK[scene],
221
264
  }
222
265
  analysis = run.step("analyze", lambda: analyze_image_set(src, run.settings))
223
266
  run.details["analysis"] = analysis.to_dict()
@@ -262,6 +305,19 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
262
305
  upright = False
263
306
  if silhouette:
264
307
  from reverse3d.reconstruction.silhouette import visual_hull
308
+ from reverse3d.vision.segmenter import ObjectSegmenter
309
+
310
+ def load_segmenter() -> ObjectSegmenter | None:
311
+ # The AI segmenter keeps dark caps, handles and thin parts; without it
312
+ # (no AI extra, or offline before the first download) GrabCut is used.
313
+ try:
314
+ return ObjectSegmenter(run.settings, device=torch_device(run.settings))
315
+ except Reverse3DError as exc:
316
+ logger.info("AI segmentation unavailable (%s); using GrabCut", exc.message)
317
+ run.warnings.append(
318
+ "AI segmentation unavailable: thin or dark parts may be missing."
319
+ )
320
+ return None
265
321
 
266
322
  try:
267
323
  hull = run.step(
@@ -270,6 +326,7 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
270
326
  Path(photo.images_dir or ""),
271
327
  Path(photo.sparse_text_dir or ""),
272
328
  run.out / "reconstruction" / "object_hull.ply",
329
+ segmenter=load_segmenter,
273
330
  ),
274
331
  )
275
332
  except ProcessingError as exc:
@@ -307,6 +364,11 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
307
364
  keep_largest=preset.keep_largest,
308
365
  repair=opt.repair,
309
366
  )
367
+ if upright:
368
+ try:
369
+ _color_outputs(run, Path(mesh_path), model, opt.scale)
370
+ except Exception as exc: # colour is a bonus: never fail the model for it
371
+ logger.info("Colour transfer skipped: %s", exc)
310
372
  return _measure(run, model)
311
373
 
312
374
 
@@ -495,7 +557,7 @@ def run_recipe(
495
557
  source = prepare_heic_input(source, out) # iPhone HEIC photos -> JPEG
496
558
  stats = _RECIPES[recipe](run, source, opt)
497
559
  if opt.render:
498
- _render(run, Path(run.outputs["model"]), opt.overwrite)
560
+ _render(run, Path(run.outputs.get("model_color") or run.outputs["model"]), opt.overwrite)
499
561
 
500
562
  summary: dict[str, Any] = {
501
563
  "recipe": recipe,
@@ -23,6 +23,7 @@ from __future__ import annotations
23
23
  import logging
24
24
  from dataclasses import asdict, dataclass
25
25
  from pathlib import Path
26
+ from typing import TYPE_CHECKING
26
27
 
27
28
  import cv2
28
29
  import numpy as np
@@ -30,6 +31,11 @@ import trimesh
30
31
 
31
32
  from reverse3d.errors import DependencyMissingError, ProcessingError
32
33
 
34
+ if TYPE_CHECKING:
35
+ from collections.abc import Callable
36
+
37
+ from reverse3d.vision.segmenter import ObjectSegmenter
38
+
33
39
  logger = logging.getLogger(__name__)
34
40
 
35
41
  _WORK_SIDE = 640
@@ -62,6 +68,7 @@ class HullResult:
62
68
  voxels: int
63
69
  faces: int
64
70
  support_plane: bool
71
+ ai_segmentation: bool = False
65
72
 
66
73
  def to_dict(self) -> dict[str, object]:
67
74
  return asdict(self)
@@ -184,6 +191,65 @@ def _support_plane(
184
191
  return n, d
185
192
 
186
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
+
187
253
  def visual_hull(
188
254
  image_dir: Path,
189
255
  model_text_dir: Path,
@@ -191,6 +257,7 @@ def visual_hull(
191
257
  *,
192
258
  resolution: int = 176,
193
259
  refinements: int = 3,
260
+ segmenter: Callable[[], ObjectSegmenter | None] | None = None,
194
261
  ) -> HullResult:
195
262
  """Carve the object seen by every photo; writes a watertight, upright mesh.
196
263
 
@@ -269,6 +336,36 @@ def visual_hull(
269
336
  return (seen >= 3) & (votes >= seen - tolerance)
270
337
 
271
338
  keep = carve(masks, 2)
339
+ # The AI segmenter is only loaded (or downloaded) once the photos proved usable.
340
+ ai = segmenter() if segmenter is not None else None
341
+ if ai is not None:
342
+ # AI segmentation: a box around the projected hull (taller upwards, where caps
343
+ # and handles get lost) and a point on it. Two rounds, each from the new hull.
344
+ states = [ai.embed(cv2.cvtColor(small, cv2.COLOR_BGR2RGB)) for small, _ in images]
345
+ for _ in range(2):
346
+ ai_masks: list[np.ndarray] = []
347
+ for state, (small, _), (u, v, inside), old in zip(
348
+ states, images, projections, masks, strict=True
349
+ ):
350
+ sel = keep & inside
351
+ if not sel.any():
352
+ ai_masks.append(old)
353
+ continue
354
+ us, vs = u[sel], v[sel]
355
+ x0, x1 = int(us.min()), int(us.max())
356
+ y0, y1 = int(vs.min()), int(vs.max())
357
+ pad_x, pad_y = 0.15 * (x1 - x0 + 1), 0.15 * (y1 - y0 + 1)
358
+ box = (
359
+ max(0, int(x0 - pad_x)),
360
+ max(0, int(y0 - 2.5 * pad_y)),
361
+ min(small.shape[1] - 1, int(x1 + pad_x)),
362
+ min(small.shape[0] - 1, int(y1 + pad_y)),
363
+ )
364
+ point = (int(np.median(us)), int(np.median(vs)))
365
+ ai_masks.append(ai.segment(state, box, point))
366
+ masks = ai_masks
367
+ keep = carve(masks, 1)
368
+ refinements = 0
272
369
  for _ in range(refinements):
273
370
  refined: list[np.ndarray] = []
274
371
  for (small, _), (u, v, inside), old in zip(images, projections, masks, strict=True):
@@ -294,6 +391,8 @@ def visual_hull(
294
391
  normal, offset = plane
295
392
  height = (grid @ normal + offset).reshape(n, n, n)
296
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)
297
396
  labels, count = ndimage.label(volume > 0.5)
298
397
  if count == 0:
299
398
  raise ProcessingError("No object could be isolated in the photos.")
@@ -318,6 +417,11 @@ def visual_hull(
318
417
  verts = (verts - 2) / (n - 1) * 2 * radius - radius + center
319
418
  mesh = trimesh.Trimesh(verts, faces, process=True)
320
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
+ )
321
425
 
322
426
  # Stand it upright: +Z along the table normal (or the mean camera "up").
323
427
  up = plane[0] if plane is not None else np.mean(cameras - center, axis=0)
@@ -333,4 +437,6 @@ def visual_hull(
333
437
  output_path.parent.mkdir(parents=True, exist_ok=True)
334
438
  mesh.export(output_path)
335
439
  logger.info("Silhouette model: %d views, %d faces", len(views), len(mesh.faces))
336
- return HullResult(str(output_path), len(views), voxels, len(mesh.faces), plane is not None)
440
+ return HullResult(
441
+ str(output_path), len(views), voxels, len(mesh.faces), plane is not None, ai is not None
442
+ )
@@ -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