reverse3d 0.5.1__tar.gz → 0.6.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 (75) hide show
  1. {reverse3d-0.5.1/reverse3d.egg-info → reverse3d-0.6.0}/PKG-INFO +4 -1
  2. {reverse3d-0.5.1 → reverse3d-0.6.0}/README.md +2 -0
  3. {reverse3d-0.5.1 → reverse3d-0.6.0}/pyproject.toml +1 -0
  4. reverse3d-0.6.0/reverse3d/_version.py +1 -0
  5. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/agent/tools.py +6 -0
  6. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/autopilot.py +22 -1
  7. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/cli.py +25 -0
  8. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/recipes.py +85 -28
  9. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/reconstruction/photogrammetry.py +70 -13
  10. reverse3d-0.6.0/reverse3d/reconstruction/silhouette.py +336 -0
  11. reverse3d-0.6.0/reverse3d/scenes.py +392 -0
  12. {reverse3d-0.5.1 → reverse3d-0.6.0/reverse3d.egg-info}/PKG-INFO +4 -1
  13. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d.egg-info/SOURCES.txt +4 -0
  14. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d.egg-info/requires.txt +1 -0
  15. {reverse3d-0.5.1 → reverse3d-0.6.0}/tests/test_automation.py +5 -1
  16. {reverse3d-0.5.1 → reverse3d-0.6.0}/tests/test_offline_autopilot.py +8 -1
  17. {reverse3d-0.5.1 → reverse3d-0.6.0}/tests/test_reconstruction.py +4 -1
  18. reverse3d-0.6.0/tests/test_scenes.py +114 -0
  19. reverse3d-0.6.0/tests/test_silhouette.py +106 -0
  20. reverse3d-0.5.1/reverse3d/_version.py +0 -1
  21. {reverse3d-0.5.1 → reverse3d-0.6.0}/LICENSE +0 -0
  22. {reverse3d-0.5.1 → reverse3d-0.6.0}/NOTICE +0 -0
  23. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/__init__.py +0 -0
  24. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/__main__.py +0 -0
  25. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/_about.py +0 -0
  26. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/agent/__init__.py +0 -0
  27. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/agent/compact.py +0 -0
  28. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/agent/mcp_server.py +0 -0
  29. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/agent/prompts.py +0 -0
  30. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/agent/skill/SKILL.md +0 -0
  31. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/config.py +0 -0
  32. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/doctor.py +0 -0
  33. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/errors.py +0 -0
  34. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/hardware.py +0 -0
  35. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/imaging.py +0 -0
  36. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/logging_utils.py +0 -0
  37. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/mesh_io.py +0 -0
  38. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/offline.py +0 -0
  39. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/profiles.py +0 -0
  40. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/py.typed +0 -0
  41. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/reconstruction/__init__.py +0 -0
  42. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/reconstruction/depth_estimation.py +0 -0
  43. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/reconstruction/pointcloud.py +0 -0
  44. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/rendering/__init__.py +0 -0
  45. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/rendering/_blender_job.py +0 -0
  46. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/rendering/blender_render.py +0 -0
  47. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/rendering/preview.py +0 -0
  48. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/reverse_eng/__init__.py +0 -0
  49. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/reverse_eng/geometry_fit.py +0 -0
  50. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/reverse_eng/measure.py +0 -0
  51. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/reverse_eng/mesh_cleaner.py +0 -0
  52. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/reverse_eng/parametric.py +0 -0
  53. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/security.py +0 -0
  54. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/setup_agents.py +0 -0
  55. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/userconfig.py +0 -0
  56. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/vision/__init__.py +0 -0
  57. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/vision/feature_matcher.py +0 -0
  58. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/vision/mask_extractor.py +0 -0
  59. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d/watch.py +0 -0
  60. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d.egg-info/dependency_links.txt +0 -0
  61. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d.egg-info/entry_points.txt +0 -0
  62. {reverse3d-0.5.1 → reverse3d-0.6.0}/reverse3d.egg-info/top_level.txt +0 -0
  63. {reverse3d-0.5.1 → reverse3d-0.6.0}/setup.cfg +0 -0
  64. {reverse3d-0.5.1 → reverse3d-0.6.0}/tests/test_agent_tools.py +0 -0
  65. {reverse3d-0.5.1 → reverse3d-0.6.0}/tests/test_authorship.py +0 -0
  66. {reverse3d-0.5.1 → reverse3d-0.6.0}/tests/test_cli.py +0 -0
  67. {reverse3d-0.5.1 → reverse3d-0.6.0}/tests/test_cli_automation.py +0 -0
  68. {reverse3d-0.5.1 → reverse3d-0.6.0}/tests/test_config.py +0 -0
  69. {reverse3d-0.5.1 → reverse3d-0.6.0}/tests/test_geometry_fit.py +0 -0
  70. {reverse3d-0.5.1 → reverse3d-0.6.0}/tests/test_heic.py +0 -0
  71. {reverse3d-0.5.1 → reverse3d-0.6.0}/tests/test_mesh.py +0 -0
  72. {reverse3d-0.5.1 → reverse3d-0.6.0}/tests/test_optional_extras.py +0 -0
  73. {reverse3d-0.5.1 → reverse3d-0.6.0}/tests/test_render.py +0 -0
  74. {reverse3d-0.5.1 → reverse3d-0.6.0}/tests/test_security.py +0 -0
  75. {reverse3d-0.5.1 → reverse3d-0.6.0}/tests/test_vision.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reverse3d
3
- Version: 0.5.1
3
+ Version: 0.6.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>
@@ -31,6 +31,7 @@ License-File: LICENSE
31
31
  License-File: NOTICE
32
32
  Requires-Dist: numpy>=1.26
33
33
  Requires-Dist: scipy>=1.11
34
+ Requires-Dist: scikit-image>=0.22
34
35
  Requires-Dist: opencv-python-headless>=4.9
35
36
  Requires-Dist: pillow>=10.3
36
37
  Requires-Dist: pillow-heif>=1.1
@@ -425,6 +426,8 @@ Si las fotos están borrosas o falta una herramienta, **no reintenta en vano**:
425
426
 
426
427
  **Formatos:** imágenes `png jpg jpeg tif tiff bmp webp`, y fotos de iPhone `heic heif` (se convierten a JPEG conservando el EXIF) · mallas `stl obj ply glb gltf off` · nubes `ply xyz pcd`.
427
428
 
429
+ **Perfiles de escena:** reverse3d predice si las fotos son de un objeto, un personaje, arquitectura o un paisaje (`reverse3d scene detect <carpeta>`) y ajusta la reconstrucción; también puedes elegirlo con `reverse3d auto <carpeta> --scene architecture`.
430
+
428
431
  **¿Cómo tomar las fotos?** Mínimo 20, recomendadas 40-60, en 4 perfiles (anillos a 10-20°, 35-45° y 60-70°, y 4 desde arriba), sin mover el objeto, con luz pareja y sin reflejos. Guía completa: [docs/captura.md](docs/captura.md).
429
432
 
430
433
  ---
@@ -358,6 +358,8 @@ Si las fotos están borrosas o falta una herramienta, **no reintenta en vano**:
358
358
 
359
359
  **Formatos:** imágenes `png jpg jpeg tif tiff bmp webp`, y fotos de iPhone `heic heif` (se convierten a JPEG conservando el EXIF) · mallas `stl obj ply glb gltf off` · nubes `ply xyz pcd`.
360
360
 
361
+ **Perfiles de escena:** reverse3d predice si las fotos son de un objeto, un personaje, arquitectura o un paisaje (`reverse3d scene detect <carpeta>`) y ajusta la reconstrucción; también puedes elegirlo con `reverse3d auto <carpeta> --scene architecture`.
362
+
361
363
  **¿Cómo tomar las fotos?** Mínimo 20, recomendadas 40-60, en 4 perfiles (anillos a 10-20°, 35-45° y 60-70°, y 4 desde arriba), sin mover el objeto, con luz pareja y sin reflejos. Guía completa: [docs/captura.md](docs/captura.md).
362
364
 
363
365
  ---
@@ -39,6 +39,7 @@ classifiers = [
39
39
  dependencies = [
40
40
  "numpy>=1.26",
41
41
  "scipy>=1.11",
42
+ "scikit-image>=0.22",
42
43
  "opencv-python-headless>=4.9",
43
44
  "pillow>=10.3",
44
45
  "pillow-heif>=1.1",
@@ -0,0 +1 @@
1
+ __version__ = "0.6.0"
@@ -240,6 +240,11 @@ class RunRecipeArgs(_Args):
240
240
  force_analysis: bool = Field(
241
241
  False, description="photos_to_model: continue even if the photo check fails."
242
242
  )
243
+ scene: Literal["auto", "object", "character", "architecture", "landscape"] = Field(
244
+ "auto",
245
+ description="Photo sets: auto predicts it. object=part/product, character=person "
246
+ "for games, architecture=building/interior, landscape=terrain/drone flight.",
247
+ )
243
248
  overwrite: bool = _OVERWRITE
244
249
 
245
250
 
@@ -471,6 +476,7 @@ def _run_recipe(a: RunRecipeArgs, s: Settings) -> dict[str, Any]:
471
476
  render=a.render,
472
477
  quality=a.quality,
473
478
  force_analysis=a.force_analysis,
479
+ scene=a.scene,
474
480
  overwrite=a.overwrite,
475
481
  ),
476
482
  )
@@ -299,6 +299,8 @@ def _relief_fallback(
299
299
  ) -> dict[str, Any]:
300
300
  """Photogrammetry was not possible: build an approximate model from the best photo."""
301
301
  photo, mask = _best_photo(source, out, settings)
302
+ if base.scene in ("architecture", "landscape"):
303
+ mask = None # an open scene: keep the whole view as a relief
302
304
  logger.info("Photogrammetry not possible; building an AI-depth relief from %s", photo.name)
303
305
  summary = run_recipe(
304
306
  "photo_to_relief",
@@ -352,6 +354,16 @@ def auto_run(
352
354
  source = _mesh_from_points(detection, out, settings, base.overwrite)
353
355
 
354
356
  history: list[dict[str, Any]] = []
357
+ scene_info: dict[str, Any] | None = None
358
+ if detection.kind == "photos":
359
+ from reverse3d.scenes import SCENE_PRESETS, detect_scene
360
+
361
+ if base.scene == "auto":
362
+ predicted = detect_scene(source, settings)
363
+ scene_info = predicted.to_dict()
364
+ base = replace(base, scene=predicted.kind)
365
+ elif base.scene in SCENE_PRESETS:
366
+ scene_info = {"kind": base.scene, "confidence": 1.0, "reasons": ["chosen"]}
355
367
  summary: dict[str, Any] | None = None
356
368
  last_error: Reverse3DError | None = None
357
369
  capture_advice: list[str] = []
@@ -396,7 +408,14 @@ def auto_run(
396
408
 
397
409
  if summary is None and detection.kind == "photos" and last_error is not None:
398
410
  reason = last_error.message.split(":", 1)[0].rstrip(".")
399
- capture_advice = [CAPTURE_ADVICE_ES]
411
+ from reverse3d.scenes import scene_advice
412
+
413
+ photos = len(
414
+ list_images(source, max_images=settings.max_images, max_bytes=settings.max_input_bytes)
415
+ )
416
+ capture_advice = (
417
+ scene_advice(base.scene, photos) if base.scene != "auto" else [CAPTURE_ADVICE_ES]
418
+ )
400
419
  try:
401
420
  summary = _relief_fallback(source, out, settings, base, reason, history)
402
421
  except Reverse3DError as exc:
@@ -409,6 +428,8 @@ def auto_run(
409
428
  raise last_error or ProcessingError("No processing attempt could run.")
410
429
 
411
430
  summary["auto"] = {"detected": detection.to_dict(), "attempts": history}
431
+ if scene_info is not None:
432
+ summary["auto"]["scene"] = scene_info
412
433
  summary["next_steps"] = capture_advice + next_steps(detection, summary, base)
413
434
  summary["outputs"]["summary"] = str(write_summary(out, detection, summary))
414
435
  return summary
@@ -67,6 +67,10 @@ app.add_typer(vision_app, name="vision")
67
67
  app.add_typer(recon_app, name="recon")
68
68
  app.add_typer(mesh_app, name="mesh")
69
69
  app.add_typer(agent_app, name="agent")
70
+ scene_app = typer.Typer(
71
+ help="Scene profiles: object, character, architecture, landscape.", no_args_is_help=True
72
+ )
73
+ app.add_typer(scene_app, name="scene")
70
74
 
71
75
 
72
76
  @dataclass
@@ -757,6 +761,10 @@ def auto_cmd(
757
761
  units_per_pixel: float | None = typer.Option(None, min=0, help="Drawing scale (mm/px)."),
758
762
  scale: float = typer.Option(1.0, min=1e-9, help="Final uniform scale (m→mm: 1000)."),
759
763
  render: bool = typer.Option(True, help="Render a PNG preview at the end."),
764
+ scene: str = typer.Option(
765
+ "auto",
766
+ help="Photo sets: auto (predicted), object, character, architecture or landscape.",
767
+ ),
760
768
  force: bool = typer.Option(False, "--force", "-f", help="Overwrite outputs."),
761
769
  ) -> None:
762
770
  """Detect what INPUT is and process it automatically (no recipe to choose)."""
@@ -769,6 +777,7 @@ def auto_cmd(
769
777
  units_per_pixel=units_per_pixel,
770
778
  scale=scale,
771
779
  render=render,
780
+ scene=scene,
772
781
  overwrite=force,
773
782
  )
774
783
 
@@ -780,6 +789,22 @@ def auto_cmd(
780
789
  _run(ctx, job)
781
790
 
782
791
 
792
+ @scene_app.command("detect")
793
+ def scene_detect_cmd(
794
+ ctx: typer.Context,
795
+ folder: Path = typer.Argument(..., help="Folder with the photos."),
796
+ ) -> None:
797
+ """Predict the scene of a photo set (object, character, architecture, landscape)."""
798
+ from reverse3d.imaging import prepare_heic_input
799
+ from reverse3d.scenes import detect_scene
800
+
801
+ def job(s: Settings) -> Any:
802
+ work = s.workspace / ".scene"
803
+ return detect_scene(prepare_heic_input(folder, work), s).to_dict()
804
+
805
+ _run(ctx, job)
806
+
807
+
783
808
  _WATCH_TEXT = {
784
809
  "detected": "📥 Nuevo: {item} (esperando a que termine de copiarse…)",
785
810
  "processing": "⚙️ Procesando {item}…",
@@ -24,7 +24,6 @@ from __future__ import annotations
24
24
  import json
25
25
  import logging
26
26
  import math
27
- import shutil
28
27
  import time
29
28
  from collections.abc import Callable
30
29
  from dataclasses import dataclass, field
@@ -73,6 +72,8 @@ class RecipeOptions:
73
72
  overwrite: bool = False
74
73
  #: plan_to_part: how the drawing is separated from the background.
75
74
  mask_method: MaskMethod = "otsu"
75
+ #: photos_to_model: scene profile (object, character, architecture, landscape) or "auto".
76
+ scene: str = "auto"
76
77
  #: photo_to_relief: object mask (white = keep), e.g. from the autopilot fallback.
77
78
  mask_path: str | None = None
78
79
  #: mesh repair strength; "strong" closes much larger holes.
@@ -193,27 +194,37 @@ _MIN_GROUP = 8
193
194
 
194
195
 
195
196
  def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
196
- from reverse3d.reconstruction.photogrammetry import PhotogrammetryOptions, run_photogrammetry
197
+ from reverse3d.reconstruction.photogrammetry import (
198
+ PhotogrammetryOptions,
199
+ PhotogrammetryResult,
200
+ run_photogrammetry,
201
+ )
202
+ from reverse3d.scenes import SCENE_LABEL_ES, SCENE_PRESETS, detect_scene
197
203
  from reverse3d.vision.feature_matcher import analyze_image_set
198
204
 
205
+ scene: str
206
+ if opt.scene == "auto":
207
+ detection = run.step("scene", lambda: detect_scene(src, run.settings))
208
+ scene, confidence, predicted = detection.kind, detection.confidence, True
209
+ elif opt.scene in SCENE_PRESETS:
210
+ scene, confidence, predicted = opt.scene, 1.0, False
211
+ else:
212
+ raise InputValidationError(
213
+ f"Unknown scene '{opt.scene}'.", hint=f"Use auto or one of: {', '.join(SCENE_PRESETS)}"
214
+ )
215
+ preset = SCENE_PRESETS[scene]
216
+ run.details["scene"] = {
217
+ "kind": scene,
218
+ "label": SCENE_LABEL_ES[scene],
219
+ "confidence": confidence,
220
+ "predicted": predicted,
221
+ }
199
222
  analysis = run.step("analyze", lambda: analyze_image_set(src, run.settings))
200
223
  run.details["analysis"] = analysis.to_dict()
201
224
  if not analysis.suitable_for_photogrammetry and analysis.connected_images >= _MIN_GROUP:
202
- # Part of the set overlaps well: reconstruct from that group only.
203
- dropped = set(analysis.weak_links)
204
- group_dir = run.out / "photos_used"
205
- group_dir.mkdir(parents=True, exist_ok=True)
206
- for image in sorted(src.iterdir()):
207
- if image.is_file() and image.name not in dropped and not image.name.startswith("."):
208
- target = group_dir / image.name
209
- if not target.exists():
210
- shutil.copy2(image, target)
211
- run.warnings.append(
212
- f"Used the {analysis.connected_images} photos that overlap; skipped "
213
- f"{len(dropped)} that do not match the rest."
214
- )
215
- run.details["photos_used"] = analysis.connected_images
216
- src = group_dir
225
+ # COLMAP places cameras far better than this quick check (it often uses
226
+ # the whole room), so every photo goes in; the check is only advice.
227
+ run.warnings.extend(analysis.recommendations)
217
228
  elif not analysis.suitable_for_photogrammetry:
218
229
  if not opt.force_analysis:
219
230
  raise CaptureQualityError(
@@ -223,21 +234,63 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
223
234
  )
224
235
  run.warnings.extend(analysis.recommendations)
225
236
 
226
- quality = opt.quality or profile_values(run.settings).photo_quality
227
- photo = run.step(
228
- "photogrammetry",
229
- lambda: run_photogrammetry(
237
+ quality = opt.quality or preset.quality or profile_values(run.settings).photo_quality
238
+ # Objects and characters: COLMAP only places the cameras (fast, no dense stage)
239
+ # and the shape is carved from the silhouettes, which also works on plain,
240
+ # white or glossy surfaces. Open scenes keep classic dense reconstruction.
241
+ silhouette = scene in ("object", "character")
242
+
243
+ def photogrammetry(classic: bool) -> PhotogrammetryResult:
244
+ return run_photogrammetry(
230
245
  src,
231
246
  run.out / "reconstruction",
232
247
  run.settings,
233
- PhotogrammetryOptions(quality=quality, use_gpu=wants_gpu(run.settings)), # type: ignore[arg-type]
234
- overwrite=opt.overwrite,
235
- ),
236
- )
248
+ PhotogrammetryOptions(
249
+ quality=quality, # type: ignore[arg-type]
250
+ matcher=preset.matcher,
251
+ dense="auto" if classic else "off",
252
+ use_gpu=wants_gpu(run.settings),
253
+ build_mesh=classic,
254
+ poisson_depth=preset.poisson_depth,
255
+ density_quantile=preset.density_quantile,
256
+ ),
257
+ overwrite=opt.overwrite or not classic,
258
+ )
259
+
260
+ photo = run.step("photogrammetry", lambda: photogrammetry(classic=not silhouette))
261
+ mesh_path: str | None = photo.mesh_path
262
+ upright = False
263
+ if silhouette:
264
+ from reverse3d.reconstruction.silhouette import visual_hull
265
+
266
+ try:
267
+ hull = run.step(
268
+ "silhouette",
269
+ lambda: visual_hull(
270
+ Path(photo.images_dir or ""),
271
+ Path(photo.sparse_text_dir or ""),
272
+ run.out / "reconstruction" / "object_hull.ply",
273
+ ),
274
+ )
275
+ except ProcessingError as exc:
276
+ logger.info("Silhouette carving failed (%s); using dense reconstruction", exc.message)
277
+ run.warnings.append(
278
+ f"Silhouette carving failed ({exc.message}); used dense reconstruction."
279
+ )
280
+ photo = run.step("photogrammetry", lambda: photogrammetry(classic=True))
281
+ mesh_path = photo.mesh_path
282
+ else:
283
+ run.details["silhouette"] = hull.to_dict()
284
+ mesh_path = hull.mesh_path
285
+ upright = True
286
+ if photo.registered_images >= 0.9 * max(photo.total_images, 1):
287
+ # COLMAP placed (almost) every photo: the quick pre-check's capture advice
288
+ # would only confuse.
289
+ run.warnings[:] = [w for w in run.warnings if w not in analysis.recommendations]
237
290
  run.warnings.extend(photo.warnings)
238
291
  run.details["photogrammetry"] = photo.to_dict()
239
292
  run.outputs["point_cloud"] = photo.dense_points_path or photo.sparse_points_path
240
- if not photo.mesh_path:
293
+ if not mesh_path:
241
294
  raise ProcessingError(
242
295
  "Point cloud created but no mesh could be built.",
243
296
  hint='Install the meshing extra: pip install "reverse3d[recon]" (Python ≤ 3.12).',
@@ -245,11 +298,13 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
245
298
  model = run.out / "model.stl"
246
299
  _clean(
247
300
  run,
248
- Path(photo.mesh_path),
301
+ Path(mesh_path),
249
302
  model,
250
303
  scale=opt.scale,
251
304
  overwrite=opt.overwrite,
252
- keep_largest=True,
305
+ fill_holes=preset.fill_holes,
306
+ align="none" if upright else "obb",
307
+ keep_largest=preset.keep_largest,
253
308
  repair=opt.repair,
254
309
  )
255
310
  return _measure(run, model)
@@ -452,6 +507,8 @@ def run_recipe(
452
507
  "steps": [f"{s['step']}:{s['s']}s" for s in run.steps],
453
508
  "warnings": run.warnings,
454
509
  }
510
+ if "scene" in run.details:
511
+ summary["scene"] = run.details["scene"]
455
512
  if "primitive_summary" in run.details:
456
513
  summary["primitives"] = run.details["primitive_summary"]
457
514
 
@@ -46,7 +46,7 @@ from reverse3d.security import (
46
46
  logger = logging.getLogger(__name__)
47
47
 
48
48
  Quality = Literal["low", "medium", "high"]
49
- MatcherKind = Literal["auto", "exhaustive", "sequential"]
49
+ MatcherKind = Literal["auto", "exhaustive", "sequential", "spatial"]
50
50
  DenseMode = Literal["auto", "on", "off"]
51
51
 
52
52
  CAMERA_MODELS = frozenset(
@@ -68,7 +68,15 @@ _MIN_SPARSE_POINTS = 1000
68
68
  _EXHAUSTIVE_LIMIT = 150
69
69
 
70
70
  # Names of the artifacts this pipeline creates; only these are removed on overwrite.
71
- _ARTIFACTS = ("database.db", "images", "sparse", "dense", "sparse_points.ply", "mesh.ply")
71
+ _ARTIFACTS = (
72
+ "database.db",
73
+ "images",
74
+ "sparse",
75
+ "sparse_txt",
76
+ "dense",
77
+ "sparse_points.ply",
78
+ "mesh.ply",
79
+ )
72
80
 
73
81
 
74
82
  @dataclass
@@ -82,12 +90,20 @@ class PhotogrammetryOptions:
82
90
  camera_model: str = "SIMPLE_RADIAL"
83
91
  single_camera: bool = True
84
92
  build_mesh: bool = True
93
+ #: Poisson octree depth; None uses the quality preset (scene profiles raise it).
94
+ poisson_depth: int | None = None
95
+ #: share of the least supported surface trimmed after Poisson.
96
+ density_quantile: float = 0.02
85
97
 
86
98
  def validate(self) -> None:
87
99
  if self.quality not in _PRESETS:
88
100
  raise InputValidationError(f"quality must be one of {sorted(_PRESETS)}")
89
- if self.matcher not in ("auto", "exhaustive", "sequential"):
90
- raise InputValidationError("matcher must be auto, exhaustive or sequential")
101
+ if self.matcher not in ("auto", "exhaustive", "sequential", "spatial"):
102
+ raise InputValidationError("matcher must be auto, exhaustive, sequential or spatial")
103
+ if self.poisson_depth is not None and not 5 <= self.poisson_depth <= 13:
104
+ raise InputValidationError("poisson_depth must be in [5, 13]")
105
+ if not 0 <= self.density_quantile < 0.5:
106
+ raise InputValidationError("density_quantile must be in [0, 0.5)")
91
107
  if self.dense not in ("auto", "on", "off"):
92
108
  raise InputValidationError("dense must be auto, on or off")
93
109
  if self.camera_model not in CAMERA_MODELS:
@@ -114,6 +130,9 @@ class PhotogrammetryResult:
114
130
  sparse_points: int
115
131
  stages: list[StageTiming] = field(default_factory=list)
116
132
  warnings: list[str] = field(default_factory=list)
133
+ #: the photos as COLMAP saw them, and the chosen model as text (silhouette carving).
134
+ images_dir: str | None = None
135
+ sparse_text_dir: str | None = None
117
136
 
118
137
  def to_dict(self) -> dict[str, object]:
119
138
  return asdict(self)
@@ -296,18 +315,38 @@ class ColmapPipeline:
296
315
 
297
316
  # 2. Matching
298
317
  matcher = options.matcher
318
+ if matcher == "spatial":
319
+ from reverse3d.scenes import has_gps
320
+
321
+ # GPS-guided matching (drone / large outdoor sets); without GPS, sequential.
322
+ matcher = "spatial" if has_gps(staged) else "sequential"
299
323
  if matcher == "auto":
300
324
  matcher = "exhaustive" if len(images) <= _EXHAUSTIVE_LIMIT else "sequential"
301
325
  command = f"{matcher}_matcher"
302
- self._stage(
303
- result,
304
- command,
305
- self._with_options(
326
+ extra: dict[tuple[str, ...], str] = {
327
+ ("FeatureMatching.use_gpu", "SiftMatching.use_gpu"): gpu
328
+ }
329
+ if matcher == "spatial":
330
+ extra[("SpatialMatching.is_gps",)] = "1"
331
+ try:
332
+ self._stage(
333
+ result,
306
334
  command,
307
- [command, "--database_path", str(db)],
308
- {("FeatureMatching.use_gpu", "SiftMatching.use_gpu"): gpu},
309
- ),
310
- )
335
+ self._with_options(command, [command, "--database_path", str(db)], extra),
336
+ )
337
+ except ExternalToolError:
338
+ if matcher != "spatial":
339
+ raise
340
+ result.warnings.append("GPS matching failed; used sequential matching instead.")
341
+ self._stage(
342
+ result,
343
+ "sequential_matcher",
344
+ self._with_options(
345
+ "sequential_matcher",
346
+ ["sequential_matcher", "--database_path", str(db)],
347
+ {("FeatureMatching.use_gpu", "SiftMatching.use_gpu"): gpu},
348
+ ),
349
+ )
311
350
 
312
351
  # 3. Sparse reconstruction (SfM)
313
352
  self._stage(
@@ -366,6 +405,23 @@ class ColmapPipeline:
366
405
  ],
367
406
  )
368
407
  result.sparse_points_path = str(sparse_ply)
408
+ result.images_dir = str(staged)
409
+ text_dir = out / "sparse_txt"
410
+ text_dir.mkdir(parents=True, exist_ok=True)
411
+ self._stage(
412
+ result,
413
+ "model_converter_txt",
414
+ [
415
+ "model_converter",
416
+ "--input_path",
417
+ str(best),
418
+ "--output_path",
419
+ str(text_dir),
420
+ "--output_type",
421
+ "TXT",
422
+ ],
423
+ )
424
+ result.sparse_text_dir = str(text_dir)
369
425
 
370
426
  # 4. Dense reconstruction (MVS) — CUDA only
371
427
  run_dense = options.dense == "on" or (options.dense == "auto" and options.use_gpu)
@@ -389,7 +445,8 @@ class ColmapPipeline:
389
445
  cloud,
390
446
  out / "mesh.ply",
391
447
  self.settings,
392
- depth=preset["poisson_depth"],
448
+ depth=options.poisson_depth or preset["poisson_depth"],
449
+ density_quantile=options.density_quantile,
393
450
  overwrite=True,
394
451
  )
395
452
  result.mesh_path = meshing.mesh_path