reverse3d 0.5.0__tar.gz → 0.5.2__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 (73) hide show
  1. {reverse3d-0.5.0/reverse3d.egg-info → reverse3d-0.5.2}/PKG-INFO +5 -1
  2. {reverse3d-0.5.0 → reverse3d-0.5.2}/README.md +4 -0
  3. reverse3d-0.5.2/reverse3d/_version.py +1 -0
  4. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/agent/tools.py +6 -0
  5. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/autopilot.py +31 -6
  6. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/cli.py +25 -0
  7. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/recipes.py +32 -3
  8. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/reconstruction/photogrammetry.py +41 -12
  9. reverse3d-0.5.2/reverse3d/scenes.py +392 -0
  10. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/vision/feature_matcher.py +5 -2
  11. {reverse3d-0.5.0 → reverse3d-0.5.2/reverse3d.egg-info}/PKG-INFO +5 -1
  12. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d.egg-info/SOURCES.txt +2 -0
  13. {reverse3d-0.5.0 → reverse3d-0.5.2}/tests/test_offline_autopilot.py +8 -1
  14. reverse3d-0.5.2/tests/test_scenes.py +114 -0
  15. {reverse3d-0.5.0 → reverse3d-0.5.2}/tests/test_vision.py +1 -1
  16. reverse3d-0.5.0/reverse3d/_version.py +0 -1
  17. {reverse3d-0.5.0 → reverse3d-0.5.2}/LICENSE +0 -0
  18. {reverse3d-0.5.0 → reverse3d-0.5.2}/NOTICE +0 -0
  19. {reverse3d-0.5.0 → reverse3d-0.5.2}/pyproject.toml +0 -0
  20. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/__init__.py +0 -0
  21. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/__main__.py +0 -0
  22. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/_about.py +0 -0
  23. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/agent/__init__.py +0 -0
  24. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/agent/compact.py +0 -0
  25. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/agent/mcp_server.py +0 -0
  26. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/agent/prompts.py +0 -0
  27. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/agent/skill/SKILL.md +0 -0
  28. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/config.py +0 -0
  29. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/doctor.py +0 -0
  30. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/errors.py +0 -0
  31. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/hardware.py +0 -0
  32. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/imaging.py +0 -0
  33. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/logging_utils.py +0 -0
  34. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/mesh_io.py +0 -0
  35. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/offline.py +0 -0
  36. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/profiles.py +0 -0
  37. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/py.typed +0 -0
  38. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/reconstruction/__init__.py +0 -0
  39. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/reconstruction/depth_estimation.py +0 -0
  40. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/reconstruction/pointcloud.py +0 -0
  41. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/rendering/__init__.py +0 -0
  42. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/rendering/_blender_job.py +0 -0
  43. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/rendering/blender_render.py +0 -0
  44. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/rendering/preview.py +0 -0
  45. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/reverse_eng/__init__.py +0 -0
  46. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/reverse_eng/geometry_fit.py +0 -0
  47. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/reverse_eng/measure.py +0 -0
  48. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/reverse_eng/mesh_cleaner.py +0 -0
  49. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/reverse_eng/parametric.py +0 -0
  50. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/security.py +0 -0
  51. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/setup_agents.py +0 -0
  52. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/userconfig.py +0 -0
  53. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/vision/__init__.py +0 -0
  54. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/vision/mask_extractor.py +0 -0
  55. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/watch.py +0 -0
  56. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d.egg-info/dependency_links.txt +0 -0
  57. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d.egg-info/entry_points.txt +0 -0
  58. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d.egg-info/requires.txt +0 -0
  59. {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d.egg-info/top_level.txt +0 -0
  60. {reverse3d-0.5.0 → reverse3d-0.5.2}/setup.cfg +0 -0
  61. {reverse3d-0.5.0 → reverse3d-0.5.2}/tests/test_agent_tools.py +0 -0
  62. {reverse3d-0.5.0 → reverse3d-0.5.2}/tests/test_authorship.py +0 -0
  63. {reverse3d-0.5.0 → reverse3d-0.5.2}/tests/test_automation.py +0 -0
  64. {reverse3d-0.5.0 → reverse3d-0.5.2}/tests/test_cli.py +0 -0
  65. {reverse3d-0.5.0 → reverse3d-0.5.2}/tests/test_cli_automation.py +0 -0
  66. {reverse3d-0.5.0 → reverse3d-0.5.2}/tests/test_config.py +0 -0
  67. {reverse3d-0.5.0 → reverse3d-0.5.2}/tests/test_geometry_fit.py +0 -0
  68. {reverse3d-0.5.0 → reverse3d-0.5.2}/tests/test_heic.py +0 -0
  69. {reverse3d-0.5.0 → reverse3d-0.5.2}/tests/test_mesh.py +0 -0
  70. {reverse3d-0.5.0 → reverse3d-0.5.2}/tests/test_optional_extras.py +0 -0
  71. {reverse3d-0.5.0 → reverse3d-0.5.2}/tests/test_reconstruction.py +0 -0
  72. {reverse3d-0.5.0 → reverse3d-0.5.2}/tests/test_render.py +0 -0
  73. {reverse3d-0.5.0 → reverse3d-0.5.2}/tests/test_security.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reverse3d
3
- Version: 0.5.0
3
+ Version: 0.5.2
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>
@@ -425,6 +425,10 @@ Si las fotos están borrosas o falta una herramienta, **no reintenta en vano**:
425
425
 
426
426
  **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
427
 
428
+ **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`.
429
+
430
+ **¿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).
431
+
428
432
  ---
429
433
 
430
434
  ## Herramientas externas opcionales
@@ -358,6 +358,10 @@ 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
+
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).
364
+
361
365
  ---
362
366
 
363
367
  ## Herramientas externas opcionales
@@ -0,0 +1 @@
1
+ __version__ = "0.5.2"
@@ -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
  )
@@ -68,6 +68,14 @@ KIND_LABEL_ES: dict[str, str] = {
68
68
  }
69
69
 
70
70
  _MIN_PHOTOS = 3
71
+
72
+ #: Capture guide in one sentence (docs/captura.md has the full guide; Studio shows it too).
73
+ CAPTURE_ADVICE_ES = (
74
+ "Para un modelo 3D completo: 40-60 fotos (mínimo 20) en 4 perfiles: anillo bajo a 10-20° "
75
+ "(16 fotos), medio a 35-45° (16), alto a 60-70° (12) y 4 desde arriba; con 60-80 % de "
76
+ "solape, sin mover el objeto, luz pareja y sin reflejos (luces RGB apagadas; en superficies "
77
+ "brillantes, spray mate o talco). Guía completa: docs/captura.md."
78
+ )
71
79
  _ANALYSIS_SIDE = 256
72
80
 
73
81
 
@@ -149,7 +157,7 @@ def detect_input(path: str | Path) -> Detection:
149
157
  "unknown",
150
158
  None,
151
159
  f"only {len(images)} photos: photogrammetry needs at least {_MIN_PHOTOS} "
152
- "(ideally 20-60)",
160
+ "(ideally 40-60)",
153
161
  str(src),
154
162
  )
155
163
  return Detection("unknown", None, "no photos, drawings or 3D files in the folder", str(src))
@@ -291,6 +299,8 @@ def _relief_fallback(
291
299
  ) -> dict[str, Any]:
292
300
  """Photogrammetry was not possible: build an approximate model from the best photo."""
293
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
294
304
  logger.info("Photogrammetry not possible; building an AI-depth relief from %s", photo.name)
295
305
  summary = run_recipe(
296
306
  "photo_to_relief",
@@ -344,6 +354,16 @@ def auto_run(
344
354
  source = _mesh_from_points(detection, out, settings, base.overwrite)
345
355
 
346
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"]}
347
367
  summary: dict[str, Any] | None = None
348
368
  last_error: Reverse3DError | None = None
349
369
  capture_advice: list[str] = []
@@ -388,11 +408,14 @@ def auto_run(
388
408
 
389
409
  if summary is None and detection.kind == "photos" and last_error is not None:
390
410
  reason = last_error.message.split(":", 1)[0].rstrip(".")
391
- capture_advice = [
392
- "Para un modelo 3D completo: 30-60 fotos en orden alrededor del objeto, sin moverlo, "
393
- "con luz pareja y sin reflejos (las luces RGB apagadas; en superficies brillantes, "
394
- "spray mate o talco).",
395
- ]
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
+ )
396
419
  try:
397
420
  summary = _relief_fallback(source, out, settings, base, reason, history)
398
421
  except Reverse3DError as exc:
@@ -405,6 +428,8 @@ def auto_run(
405
428
  raise last_error or ProcessingError("No processing attempt could run.")
406
429
 
407
430
  summary["auto"] = {"detected": detection.to_dict(), "attempts": history}
431
+ if scene_info is not None:
432
+ summary["auto"]["scene"] = scene_info
408
433
  summary["next_steps"] = capture_advice + next_steps(detection, summary, base)
409
434
  summary["outputs"]["summary"] = str(write_summary(out, detection, summary))
410
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}…",
@@ -73,6 +73,8 @@ class RecipeOptions:
73
73
  overwrite: bool = False
74
74
  #: plan_to_part: how the drawing is separated from the background.
75
75
  mask_method: MaskMethod = "otsu"
76
+ #: photos_to_model: scene profile (object, character, architecture, landscape) or "auto".
77
+ scene: str = "auto"
76
78
  #: photo_to_relief: object mask (white = keep), e.g. from the autopilot fallback.
77
79
  mask_path: str | None = None
78
80
  #: mesh repair strength; "strong" closes much larger holes.
@@ -194,8 +196,26 @@ _MIN_GROUP = 8
194
196
 
195
197
  def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
196
198
  from reverse3d.reconstruction.photogrammetry import PhotogrammetryOptions, run_photogrammetry
199
+ from reverse3d.scenes import SCENE_LABEL_ES, SCENE_PRESETS, detect_scene
197
200
  from reverse3d.vision.feature_matcher import analyze_image_set
198
201
 
202
+ scene: str
203
+ if opt.scene == "auto":
204
+ detection = run.step("scene", lambda: detect_scene(src, run.settings))
205
+ scene, confidence, predicted = detection.kind, detection.confidence, True
206
+ elif opt.scene in SCENE_PRESETS:
207
+ scene, confidence, predicted = opt.scene, 1.0, False
208
+ else:
209
+ raise InputValidationError(
210
+ f"Unknown scene '{opt.scene}'.", hint=f"Use auto or one of: {', '.join(SCENE_PRESETS)}"
211
+ )
212
+ preset = SCENE_PRESETS[scene]
213
+ run.details["scene"] = {
214
+ "kind": scene,
215
+ "label": SCENE_LABEL_ES[scene],
216
+ "confidence": confidence,
217
+ "predicted": predicted,
218
+ }
199
219
  analysis = run.step("analyze", lambda: analyze_image_set(src, run.settings))
200
220
  run.details["analysis"] = analysis.to_dict()
201
221
  if not analysis.suitable_for_photogrammetry and analysis.connected_images >= _MIN_GROUP:
@@ -223,14 +243,20 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
223
243
  )
224
244
  run.warnings.extend(analysis.recommendations)
225
245
 
226
- quality = opt.quality or profile_values(run.settings).photo_quality
246
+ quality = opt.quality or preset.quality or profile_values(run.settings).photo_quality
227
247
  photo = run.step(
228
248
  "photogrammetry",
229
249
  lambda: run_photogrammetry(
230
250
  src,
231
251
  run.out / "reconstruction",
232
252
  run.settings,
233
- PhotogrammetryOptions(quality=quality, use_gpu=wants_gpu(run.settings)), # type: ignore[arg-type]
253
+ PhotogrammetryOptions(
254
+ quality=quality, # type: ignore[arg-type]
255
+ matcher=preset.matcher,
256
+ use_gpu=wants_gpu(run.settings),
257
+ poisson_depth=preset.poisson_depth,
258
+ density_quantile=preset.density_quantile,
259
+ ),
234
260
  overwrite=opt.overwrite,
235
261
  ),
236
262
  )
@@ -249,7 +275,8 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
249
275
  model,
250
276
  scale=opt.scale,
251
277
  overwrite=opt.overwrite,
252
- keep_largest=True,
278
+ fill_holes=preset.fill_holes,
279
+ keep_largest=preset.keep_largest,
253
280
  repair=opt.repair,
254
281
  )
255
282
  return _measure(run, model)
@@ -452,6 +479,8 @@ def run_recipe(
452
479
  "steps": [f"{s['step']}:{s['s']}s" for s in run.steps],
453
480
  "warnings": run.warnings,
454
481
  }
482
+ if "scene" in run.details:
483
+ summary["scene"] = run.details["scene"]
455
484
  if "primitive_summary" in run.details:
456
485
  summary["primitives"] = run.details["primitive_summary"]
457
486
 
@@ -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(
@@ -82,12 +82,20 @@ class PhotogrammetryOptions:
82
82
  camera_model: str = "SIMPLE_RADIAL"
83
83
  single_camera: bool = True
84
84
  build_mesh: bool = True
85
+ #: Poisson octree depth; None uses the quality preset (scene profiles raise it).
86
+ poisson_depth: int | None = None
87
+ #: share of the least supported surface trimmed after Poisson.
88
+ density_quantile: float = 0.02
85
89
 
86
90
  def validate(self) -> None:
87
91
  if self.quality not in _PRESETS:
88
92
  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")
93
+ if self.matcher not in ("auto", "exhaustive", "sequential", "spatial"):
94
+ raise InputValidationError("matcher must be auto, exhaustive, sequential or spatial")
95
+ if self.poisson_depth is not None and not 5 <= self.poisson_depth <= 13:
96
+ raise InputValidationError("poisson_depth must be in [5, 13]")
97
+ if not 0 <= self.density_quantile < 0.5:
98
+ raise InputValidationError("density_quantile must be in [0, 0.5)")
91
99
  if self.dense not in ("auto", "on", "off"):
92
100
  raise InputValidationError("dense must be auto, on or off")
93
101
  if self.camera_model not in CAMERA_MODELS:
@@ -296,18 +304,38 @@ class ColmapPipeline:
296
304
 
297
305
  # 2. Matching
298
306
  matcher = options.matcher
307
+ if matcher == "spatial":
308
+ from reverse3d.scenes import has_gps
309
+
310
+ # GPS-guided matching (drone / large outdoor sets); without GPS, sequential.
311
+ matcher = "spatial" if has_gps(staged) else "sequential"
299
312
  if matcher == "auto":
300
313
  matcher = "exhaustive" if len(images) <= _EXHAUSTIVE_LIMIT else "sequential"
301
314
  command = f"{matcher}_matcher"
302
- self._stage(
303
- result,
304
- command,
305
- self._with_options(
315
+ extra: dict[tuple[str, ...], str] = {
316
+ ("FeatureMatching.use_gpu", "SiftMatching.use_gpu"): gpu
317
+ }
318
+ if matcher == "spatial":
319
+ extra[("SpatialMatching.is_gps",)] = "1"
320
+ try:
321
+ self._stage(
322
+ result,
306
323
  command,
307
- [command, "--database_path", str(db)],
308
- {("FeatureMatching.use_gpu", "SiftMatching.use_gpu"): gpu},
309
- ),
310
- )
324
+ self._with_options(command, [command, "--database_path", str(db)], extra),
325
+ )
326
+ except ExternalToolError:
327
+ if matcher != "spatial":
328
+ raise
329
+ result.warnings.append("GPS matching failed; used sequential matching instead.")
330
+ self._stage(
331
+ result,
332
+ "sequential_matcher",
333
+ self._with_options(
334
+ "sequential_matcher",
335
+ ["sequential_matcher", "--database_path", str(db)],
336
+ {("FeatureMatching.use_gpu", "SiftMatching.use_gpu"): gpu},
337
+ ),
338
+ )
311
339
 
312
340
  # 3. Sparse reconstruction (SfM)
313
341
  self._stage(
@@ -389,7 +417,8 @@ class ColmapPipeline:
389
417
  cloud,
390
418
  out / "mesh.ply",
391
419
  self.settings,
392
- depth=preset["poisson_depth"],
420
+ depth=options.poisson_depth or preset["poisson_depth"],
421
+ density_quantile=options.density_quantile,
393
422
  overwrite=True,
394
423
  )
395
424
  result.mesh_path = meshing.mesh_path
@@ -0,0 +1,392 @@
1
+ """Scene profiles: what kind of capture a photo set is, and how to process it.
2
+
3
+ reverse3d handles very different captures: a part on a table, a person for a
4
+ game character, a building, or a landscape flown with a drone. They need
5
+ different photogrammetry settings, cleaning and advice. :func:`detect_scene`
6
+ predicts the kind from a quick look at a sample of the photos (EXIF, GPS,
7
+ drone metadata, how isolated the subject is, people, straight lines, sky), and
8
+ :data:`SCENE_PRESETS` holds the settings for each kind.
9
+
10
+ The prediction is deterministic (plain image statistics, no AI model), so it
11
+ works offline and gives the same answer on every machine. It always returns
12
+ its reasons, so the user can confirm or change it.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import math
19
+ from dataclasses import asdict, dataclass, field
20
+ from pathlib import Path
21
+ from typing import Literal
22
+
23
+ import cv2
24
+ import numpy as np
25
+
26
+ from reverse3d.config import Settings
27
+ from reverse3d.imaging import downscale, load_image
28
+ from reverse3d.security import list_images, validate_input_dir
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ SceneKind = Literal["object", "character", "architecture", "landscape"]
33
+ SCENES: tuple[SceneKind, ...] = ("object", "character", "architecture", "landscape")
34
+
35
+ SCENE_LABEL_ES: dict[str, str] = {
36
+ "object": "Objeto o pieza",
37
+ "character": "Personaje o persona",
38
+ "architecture": "Arquitectura",
39
+ "landscape": "Paisaje o terreno",
40
+ }
41
+
42
+ _SAMPLE = 8
43
+ _SIDE = 640
44
+ _DRONE_MAKES = ("dji", "parrot", "autel", "skydio", "yuneec")
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class ScenePreset:
49
+ """How a scene kind is captured and processed."""
50
+
51
+ min_photos: int
52
+ recommended: tuple[int, int]
53
+ #: photogrammetry quality; None keeps the hardware profile's choice.
54
+ quality: Literal["low", "medium", "high"] | None
55
+ #: "spatial" uses GPS when the photos have it (falls back to sequential).
56
+ matcher: Literal["auto", "exhaustive", "sequential", "spatial"]
57
+ poisson_depth: int | None
58
+ #: share of the least supported surface removed after Poisson (open scenes need more).
59
+ density_quantile: float
60
+ #: closed solid (objects) or open surface (buildings, terrain).
61
+ fill_holes: bool
62
+ keep_largest: bool
63
+
64
+
65
+ SCENE_PRESETS: dict[str, ScenePreset] = {
66
+ "object": ScenePreset(20, (40, 60), None, "auto", None, 0.02, True, True),
67
+ "character": ScenePreset(40, (60, 100), "high", "auto", 10, 0.03, True, True),
68
+ "architecture": ScenePreset(50, (80, 200), None, "auto", 11, 0.05, False, False),
69
+ "landscape": ScenePreset(60, (100, 400), None, "spatial", 11, 0.06, False, False),
70
+ }
71
+
72
+
73
+ @dataclass
74
+ class SceneSignals:
75
+ """What the photos look like (all shares are 0..1)."""
76
+
77
+ photos: int
78
+ sampled: int
79
+ gps_photos: int = 0
80
+ gps_spread_m: float | None = None
81
+ drone: bool = False
82
+ nadir_share: float = 0.0
83
+ isolation: float = 0.0
84
+ people_share: float = 0.0
85
+ rectilinear: float = 0.0
86
+ sky_share: float = 0.0
87
+ camera: str | None = None
88
+
89
+
90
+ @dataclass
91
+ class SceneDetection:
92
+ """Result of :func:`detect_scene`."""
93
+
94
+ kind: SceneKind
95
+ confidence: float
96
+ #: short codes the apps translate (drone, gps_wide, nadir, people, isolated_object,
97
+ #: straight_lines, sky, few_photos).
98
+ reasons: list[str]
99
+ scores: dict[str, float]
100
+ signals: SceneSignals
101
+ min_photos: int
102
+ recommended: tuple[int, int]
103
+ enough_photos: bool
104
+ advice: list[str] = field(default_factory=list)
105
+
106
+ def to_dict(self) -> dict[str, object]:
107
+ return asdict(self)
108
+
109
+
110
+ # ─── EXIF / XMP ───────────────────────────────────────────────────────────────
111
+
112
+
113
+ def _rational(value: object) -> float:
114
+ try:
115
+ return float(value) # type: ignore[arg-type]
116
+ except (TypeError, ValueError, ZeroDivisionError):
117
+ return float("nan")
118
+
119
+
120
+ def _gps_degrees(dms: object, ref: object) -> float | None:
121
+ if not isinstance(dms, (tuple, list)) or len(dms) != 3:
122
+ return None
123
+ d, m, s = (_rational(x) for x in dms)
124
+ if any(math.isnan(x) for x in (d, m, s)):
125
+ return None
126
+ value = d + m / 60 + s / 3600
127
+ return -value if str(ref).upper() in ("S", "W") else value
128
+
129
+
130
+ def _photo_metadata(path: Path) -> tuple[str | None, tuple[float, float] | None, float | None]:
131
+ """(camera "make model", (lat, lon), gimbal pitch in degrees) of one photo."""
132
+ from PIL import Image
133
+
134
+ camera: str | None = None
135
+ position: tuple[float, float] | None = None
136
+ pitch: float | None = None
137
+ try:
138
+ with Image.open(path) as image:
139
+ exif = image.getexif()
140
+ make, model = str(exif.get(271, "")).strip(), str(exif.get(272, "")).strip()
141
+ camera = f"{make} {model}".strip() or None
142
+ gps = exif.get_ifd(0x8825)
143
+ lat = _gps_degrees(gps.get(2), gps.get(1))
144
+ lon = _gps_degrees(gps.get(4), gps.get(3))
145
+ if lat is not None and lon is not None and (lat, lon) != (0.0, 0.0):
146
+ position = (lat, lon)
147
+ except Exception as exc: # unreadable metadata is not an error: it is just missing
148
+ logger.debug("No EXIF in %s: %s", path.name, exc)
149
+ try:
150
+ head = path.read_bytes()[:262_144]
151
+ marker = b'GimbalPitchDegree="'
152
+ k = head.find(marker)
153
+ if k >= 0:
154
+ end = head.find(b'"', k + len(marker))
155
+ pitch = float(head[k + len(marker) : end].decode("ascii", "ignore"))
156
+ except (OSError, ValueError):
157
+ pass
158
+ return camera, position, pitch
159
+
160
+
161
+ def _spread_m(points: list[tuple[float, float]]) -> float | None:
162
+ """Largest distance between GPS positions, in metres (equirectangular)."""
163
+ if len(points) < 2:
164
+ return None
165
+ lat0 = math.radians(sum(p[0] for p in points) / len(points))
166
+ xy = np.array([(math.radians(lon) * math.cos(lat0), math.radians(lat)) for lat, lon in points])
167
+ xy *= 6_371_000.0
168
+ span = xy.max(axis=0) - xy.min(axis=0)
169
+ return float(np.hypot(*span))
170
+
171
+
172
+ # ─── image statistics ─────────────────────────────────────────────────────────
173
+
174
+
175
+ def _rectilinear(gray: np.ndarray) -> float:
176
+ """How much of the picture is long straight vertical/horizontal edges (buildings)."""
177
+ edges = cv2.Canny(gray, 60, 160)
178
+ diag = math.hypot(*gray.shape[:2])
179
+ lines = cv2.HoughLinesP(edges, 1, np.pi / 180, 60, minLineLength=diag * 0.08, maxLineGap=6)
180
+ if lines is None:
181
+ return 0.0
182
+ total = 0.0
183
+ straight = 0.0
184
+ for x1, y1, x2, y2 in np.asarray(lines).reshape(-1, 4):
185
+ length = math.hypot(x2 - x1, y2 - y1)
186
+ angle = abs(math.degrees(math.atan2(y2 - y1, x2 - x1))) % 180
187
+ total += length
188
+ if min(angle, 180 - angle) < 8 or abs(angle - 90) < 8:
189
+ straight += length
190
+ # ~6 picture diagonals of straight lines is already a very rectilinear scene.
191
+ return float(min(straight / (6 * diag), 1.0)) * (straight / total if total else 0.0)
192
+
193
+
194
+ def _sky(bgr: np.ndarray) -> float:
195
+ """Share of the top third that looks like sky (bright and grey, or blue)."""
196
+ top = bgr[: bgr.shape[0] // 3]
197
+ hsv = cv2.cvtColor(top, cv2.COLOR_BGR2HSV)
198
+ h, s, v = hsv[..., 0], hsv[..., 1], hsv[..., 2]
199
+ grey_sky = (v > 175) & (s < 45)
200
+ blue_sky = (h >= 90) & (h <= 130) & (s > 40) & (v > 110)
201
+ return float((grey_sky | blue_sky).mean())
202
+
203
+
204
+ def _looks_like_person(bgr: np.ndarray, mask: np.ndarray) -> bool:
205
+ """A standing person: an isolated subject clearly taller than wide, with skin.
206
+
207
+ A plain-statistics heuristic (no detector model, so it works offline); the
208
+ apps always let the user confirm or change the predicted scene.
209
+ """
210
+ inside = mask > 0
211
+ if inside.mean() < 0.04:
212
+ return False
213
+ ys, xs = np.nonzero(inside)
214
+ tall = (np.ptp(ys) + 1) / (np.ptp(xs) + 1)
215
+ ycrcb = cv2.cvtColor(bgr, cv2.COLOR_BGR2YCrCb)
216
+ cr, cb = ycrcb[..., 1], ycrcb[..., 2]
217
+ skin = (cr >= 135) & (cr <= 175) & (cb >= 85) & (cb <= 130)
218
+ skin_share = float(skin[inside].mean())
219
+ return bool(tall >= 1.5 and 0.04 <= skin_share <= 0.6)
220
+
221
+
222
+ # ─── prediction ───────────────────────────────────────────────────────────────
223
+
224
+
225
+ def _signals(images: list[Path], settings: Settings) -> SceneSignals:
226
+ from reverse3d.vision.mask_extractor import foreground_mask, mask_score
227
+
228
+ n = len(images)
229
+ step = max(1, n // _SAMPLE)
230
+ sample = images[::step][:_SAMPLE]
231
+ cameras: list[str] = []
232
+ positions: list[tuple[float, float]] = []
233
+ pitches: list[float] = []
234
+ for path in images:
235
+ camera, position, pitch = _photo_metadata(path)
236
+ if camera:
237
+ cameras.append(camera)
238
+ if position:
239
+ positions.append(position)
240
+ if pitch is not None:
241
+ pitches.append(pitch)
242
+
243
+ isolation, people, rect, sky = [], 0, [], []
244
+ for path in sample:
245
+ small, _ = downscale(load_image(path, settings), _SIDE)
246
+ gray = cv2.cvtColor(small, cv2.COLOR_BGR2GRAY)
247
+ try:
248
+ mask = foreground_mask(small, work_side=320)
249
+ isolation.append(mask_score(mask))
250
+ people += _looks_like_person(small, mask)
251
+ except cv2.error:
252
+ isolation.append(0.0)
253
+ rect.append(_rectilinear(gray))
254
+ sky.append(_sky(small))
255
+
256
+ camera = max(set(cameras), key=cameras.count) if cameras else None
257
+ drone = bool(camera and camera.lower().startswith(_DRONE_MAKES)) or bool(pitches)
258
+ return SceneSignals(
259
+ photos=n,
260
+ sampled=len(sample),
261
+ gps_photos=len(positions),
262
+ gps_spread_m=_spread_m(positions),
263
+ drone=drone,
264
+ nadir_share=sum(1 for p in pitches if p <= -60) / len(pitches) if pitches else 0.0,
265
+ isolation=float(np.mean(isolation)) if isolation else 0.0,
266
+ people_share=people / len(sample) if sample else 0.0,
267
+ rectilinear=float(np.mean(rect)) if rect else 0.0,
268
+ sky_share=float(np.mean(sky)) if sky else 0.0,
269
+ camera=camera,
270
+ )
271
+
272
+
273
+ def score_scene(signals: SceneSignals) -> tuple[dict[str, float], list[str]]:
274
+ """Scores per scene kind (higher wins) and the reasons that drove them."""
275
+ s = signals
276
+ wide = s.gps_spread_m is not None and s.gps_spread_m > 80
277
+ building_range = s.gps_spread_m is not None and 8 < s.gps_spread_m < 400
278
+ reasons: list[str] = []
279
+ if s.drone:
280
+ reasons.append("drone")
281
+ if wide:
282
+ reasons.append("gps_wide")
283
+ if s.nadir_share > 0.3:
284
+ reasons.append("nadir")
285
+ if s.people_share >= 0.4:
286
+ reasons.append("people")
287
+ if s.isolation >= 0.45:
288
+ reasons.append("isolated_object")
289
+ if s.rectilinear >= 0.25:
290
+ reasons.append("straight_lines")
291
+ if s.sky_share >= 0.25:
292
+ reasons.append("sky")
293
+ scores = {
294
+ "object": 0.15 + 0.7 * s.isolation + 0.15 * (1 - s.rectilinear) - 0.4 * s.people_share,
295
+ "character": 0.9 * s.people_share + 0.2 * s.isolation,
296
+ "architecture": 0.65 * s.rectilinear
297
+ + 0.25 * s.sky_share
298
+ + 0.15 * building_range
299
+ + 0.2 * (1 - s.isolation)
300
+ - 0.3 * s.nadir_share,
301
+ "landscape": 0.45 * s.drone
302
+ + 0.3 * wide
303
+ + 0.35 * s.nadir_share
304
+ + 0.25 * s.sky_share * (1 - s.rectilinear)
305
+ + 0.1 * (1 - s.isolation),
306
+ }
307
+ return {k: round(max(v, 0.0), 3) for k, v in scores.items()}, reasons
308
+
309
+
310
+ def detect_scene(image_dir: str | Path, settings: Settings) -> SceneDetection:
311
+ """Predict the scene kind of a photo folder (takes a few seconds)."""
312
+ directory = validate_input_dir(image_dir)
313
+ images = list_images(
314
+ directory, max_images=settings.max_images, max_bytes=settings.max_input_bytes
315
+ )
316
+ signals = _signals(images, settings)
317
+ scores, reasons = score_scene(signals)
318
+ kind: SceneKind = max(SCENES, key=lambda k: scores[k])
319
+ ranked = sorted(scores.values(), reverse=True)
320
+ margin = ranked[0] - (ranked[1] if len(ranked) > 1 else 0.0)
321
+ confidence = round(min(0.97, 0.5 + margin * 1.5), 2)
322
+ preset = SCENE_PRESETS[kind]
323
+ enough = signals.photos >= preset.min_photos
324
+ if not enough:
325
+ reasons.append("few_photos")
326
+ advice = scene_advice(kind, signals.photos)
327
+ logger.info(
328
+ "Scene: %s (%.0f%%) from %d photo(s): %s", kind, confidence * 100, signals.photos, reasons
329
+ )
330
+ return SceneDetection(
331
+ kind=kind,
332
+ confidence=confidence,
333
+ reasons=reasons,
334
+ scores=scores,
335
+ signals=signals,
336
+ min_photos=preset.min_photos,
337
+ recommended=preset.recommended,
338
+ enough_photos=enough,
339
+ advice=advice,
340
+ )
341
+
342
+
343
+ _ADVICE_ES: dict[str, str] = {
344
+ "object": (
345
+ "Objeto: 40-60 fotos (mínimo 20) en 4 perfiles: anillo bajo a 10-20° (16), medio a "
346
+ "35-45° (16), alto a 60-70° (12) y 4 desde arriba, sin mover el objeto, luz pareja y "
347
+ "sin reflejos."
348
+ ),
349
+ "character": (
350
+ "Personaje: 60-100 fotos (mínimo 40) en menos de 2 minutos, con la persona quieta en "
351
+ "pose A (brazos separados), mirando a un punto fijo. Anillos a la altura de las rodillas "
352
+ "(24), del pecho (24) y por encima de la cabeza (16), más 10-12 de cerca de la cara. "
353
+ "Ropa mate, sin pelo suelto al viento."
354
+ ),
355
+ "architecture": (
356
+ "Arquitectura: 80-200 fotos (mínimo 50). Rodea el edificio a distancia constante con "
357
+ "una foto cada 2-3 pasos (70 % de solape), a la altura de los ojos y otra vuelta más "
358
+ "alejada para ver el techo; fachadas de frente y esquinas a 45°. Interiores: desde las "
359
+ "esquinas en diagonal, una foto por metro. Día nublado, sin gente ni autos moviéndose."
360
+ ),
361
+ "landscape": (
362
+ "Paisaje (dron): 100-400 fotos (mínimo 60). Vuelo en cuadrícula a altura constante "
363
+ "(30-120 m) con 75-80 % de solape frontal y 65-70 % lateral, cámara vertical (90°), más "
364
+ "una pasada oblicua a 45-60° para taludes y fachadas. GPS activado, misma exposición, "
365
+ "sin sombras duras del mediodía."
366
+ ),
367
+ }
368
+
369
+
370
+ def scene_advice(kind: str, photos: int) -> list[str]:
371
+ """Capture advice (Spanish) for a scene kind, plus a note if photos are few."""
372
+ preset = SCENE_PRESETS[kind]
373
+ advice = [_ADVICE_ES[kind]]
374
+ if photos < preset.min_photos:
375
+ advice.insert(
376
+ 0,
377
+ f"Solo hay {photos} fotos: para {SCENE_LABEL_ES[kind].lower()} se necesitan al "
378
+ f"menos {preset.min_photos} ({preset.recommended[0]}-{preset.recommended[1]} "
379
+ "recomendadas).",
380
+ )
381
+ return advice
382
+
383
+
384
+ def has_gps(image_dir: Path, minimum: int = 3) -> bool:
385
+ """True when at least ``minimum`` photos carry GPS positions (spatial matching)."""
386
+ count = 0
387
+ for path in sorted(image_dir.iterdir()):
388
+ if path.is_file() and _photo_metadata(path)[1] is not None:
389
+ count += 1
390
+ if count >= minimum:
391
+ return True
392
+ return False
@@ -308,10 +308,13 @@ def analyze_image_set(
308
308
  recommendations: list[str] = []
309
309
  if n < 3:
310
310
  recommendations.append(
311
- "Capture at least 20-40 photos around the object for photogrammetry."
311
+ "Take at least 20 photos (40-60 recommended) around the object: three rings at "
312
+ "10-20, 35-45 and 60-70 degrees plus a few from above (see docs/captura.md)."
312
313
  )
313
314
  elif n < 15:
314
- recommendations.append("Few images: add more viewpoints (aim for 60-80% overlap).")
315
+ recommendations.append(
316
+ "Few images: aim for 40-60 photos with 60-80% overlap between neighbours."
317
+ )
315
318
  blurry = [q.name for q in qualities if "blurry" in q.issues]
316
319
  if blurry:
317
320
  recommendations.append(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reverse3d
3
- Version: 0.5.0
3
+ Version: 0.5.2
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>
@@ -425,6 +425,10 @@ Si las fotos están borrosas o falta una herramienta, **no reintenta en vano**:
425
425
 
426
426
  **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
427
 
428
+ **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`.
429
+
430
+ **¿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).
431
+
428
432
  ---
429
433
 
430
434
  ## Herramientas externas opcionales
@@ -19,6 +19,7 @@ reverse3d/offline.py
19
19
  reverse3d/profiles.py
20
20
  reverse3d/py.typed
21
21
  reverse3d/recipes.py
22
+ reverse3d/scenes.py
22
23
  reverse3d/security.py
23
24
  reverse3d/setup_agents.py
24
25
  reverse3d/userconfig.py
@@ -64,5 +65,6 @@ tests/test_offline_autopilot.py
64
65
  tests/test_optional_extras.py
65
66
  tests/test_reconstruction.py
66
67
  tests/test_render.py
68
+ tests/test_scenes.py
67
69
  tests/test_security.py
68
70
  tests/test_vision.py
@@ -278,7 +278,14 @@ class TestAutoRun:
278
278
  assert summary["fallback"]["from"] == "photos_to_model"
279
279
  assert summary["warnings"][0].startswith("A full 3D model could not be reconstructed")
280
280
  assert [a["ok"] for a in summary["auto"]["attempts"]] == [False, True]
281
- assert summary["next_steps"][0].startswith("Para un modelo 3D completo")
281
+ # Capture advice for the predicted scene comes first.
282
+ assert summary["auto"]["scene"]["kind"] in (
283
+ "object",
284
+ "character",
285
+ "architecture",
286
+ "landscape",
287
+ )
288
+ assert "fotos" in summary["next_steps"][0]
282
289
  assert (tmp_path / "o" / "RESUMEN.md").is_file()
283
290
 
284
291
  def test_capture_error_is_kept_when_the_fallback_fails(
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import cv2
6
+ import numpy as np
7
+ import pytest
8
+ from PIL import Image
9
+
10
+ from reverse3d.config import Settings
11
+ from reverse3d.scenes import (
12
+ SCENE_PRESETS,
13
+ SceneSignals,
14
+ _photo_metadata,
15
+ _rectilinear,
16
+ _spread_m,
17
+ detect_scene,
18
+ has_gps,
19
+ scene_advice,
20
+ score_scene,
21
+ )
22
+
23
+
24
+ def _winner(**signals: object) -> str:
25
+ scores, _ = score_scene(SceneSignals(photos=50, sampled=8, **signals)) # type: ignore[arg-type]
26
+ return max(scores, key=scores.__getitem__)
27
+
28
+
29
+ @pytest.mark.parametrize(
30
+ ("signals", "kind"),
31
+ [
32
+ ({"isolation": 0.7, "rectilinear": 0.05}, "object"),
33
+ ({"isolation": 0.55, "people_share": 0.8}, "character"),
34
+ (
35
+ {"isolation": 0.1, "rectilinear": 0.6, "sky_share": 0.4, "gps_spread_m": 60.0},
36
+ "architecture",
37
+ ),
38
+ (
39
+ {"drone": True, "nadir_share": 0.9, "gps_spread_m": 400.0, "isolation": 0.05},
40
+ "landscape",
41
+ ),
42
+ ],
43
+ )
44
+ def test_each_scene_wins_with_its_signals(signals: dict[str, object], kind: str) -> None:
45
+ assert _winner(**signals) == kind
46
+
47
+
48
+ def test_reasons_explain_the_prediction() -> None:
49
+ _, reasons = score_scene(
50
+ SceneSignals(photos=200, sampled=8, drone=True, nadir_share=0.9, gps_spread_m=300.0)
51
+ )
52
+ assert {"drone", "nadir", "gps_wide"} <= set(reasons)
53
+
54
+
55
+ def _facade() -> np.ndarray:
56
+ image = np.full((480, 640), 120, np.uint8)
57
+ for x in range(40, 640, 80):
58
+ cv2.line(image, (x, 60), (x, 470), 255, 3)
59
+ for y in range(60, 480, 60):
60
+ cv2.line(image, (40, y), (600, y), 30, 3)
61
+ return image
62
+
63
+
64
+ def test_straight_lines_are_measured() -> None:
65
+ noise = np.random.default_rng(0).integers(0, 255, (480, 640), dtype=np.uint8)
66
+ assert _rectilinear(_facade()) > 0.25 > _rectilinear(cv2.GaussianBlur(noise, (9, 9), 0))
67
+
68
+
69
+ def _dms(value: float) -> tuple[float, float, float]:
70
+ d = int(value)
71
+ m = int((value - d) * 60)
72
+ return (float(d), float(m), round(((value - d) * 60 - m) * 60, 4))
73
+
74
+
75
+ def _gps_jpeg(path: Path, lat: float, lon: float, make: str = "DJI", model: str = "FC3582") -> Path:
76
+ image = Image.fromarray(np.random.default_rng(1).integers(0, 255, (64, 64, 3), np.uint8))
77
+ exif = Image.Exif()
78
+ exif[271], exif[272] = make, model
79
+ gps = exif.get_ifd(0x8825)
80
+ gps[1], gps[2] = ("N" if lat >= 0 else "S"), _dms(abs(lat))
81
+ gps[3], gps[4] = ("E" if lon >= 0 else "W"), _dms(abs(lon))
82
+ path.parent.mkdir(parents=True, exist_ok=True)
83
+ image.save(path, "JPEG", exif=exif)
84
+ return path
85
+
86
+
87
+ def test_exif_gps_and_drone_camera(tmp_path: Path) -> None:
88
+ a = _gps_jpeg(tmp_path / "a.jpg", 8.98, -79.52)
89
+ camera, position, _ = _photo_metadata(a)
90
+ assert camera == "DJI FC3582"
91
+ assert position is not None and position[0] == pytest.approx(8.98, abs=1e-4)
92
+ assert position[1] == pytest.approx(-79.52, abs=1e-4)
93
+ for i in range(1, 3):
94
+ _gps_jpeg(tmp_path / f"{i}.jpg", 8.98 + i * 0.001, -79.52)
95
+ assert has_gps(tmp_path)
96
+ assert _spread_m([(8.98, -79.52), (8.981, -79.52)]) == pytest.approx(111.2, rel=0.02)
97
+
98
+
99
+ def test_detect_scene_on_a_drone_set(tmp_path: Path, settings: Settings) -> None:
100
+ for i in range(6):
101
+ _gps_jpeg(tmp_path / "vuelo" / f"DJI_{i:04d}.jpg", 8.98 + i * 0.0005, -79.52 + i * 0.0005)
102
+ detection = detect_scene(tmp_path / "vuelo", settings)
103
+ assert detection.kind == "landscape"
104
+ assert "drone" in detection.reasons
105
+ assert not detection.enough_photos and "few_photos" in detection.reasons
106
+ assert detection.advice[0].startswith("Solo hay 6 fotos")
107
+
108
+
109
+ def test_presets_and_advice_cover_every_scene() -> None:
110
+ for kind, preset in SCENE_PRESETS.items():
111
+ assert preset.min_photos <= preset.recommended[0] < preset.recommended[1]
112
+ assert scene_advice(kind, preset.recommended[0])[0]
113
+ assert not SCENE_PRESETS["architecture"].fill_holes
114
+ assert SCENE_PRESETS["object"].fill_holes
@@ -119,7 +119,7 @@ class TestFeatureMatching:
119
119
  assert report.image_count == 2
120
120
  assert len(report.pairs) == 1
121
121
  assert report.suitable_for_photogrammetry is False # only 2 images
122
- assert any("20-40" in r for r in report.recommendations)
122
+ assert any("at least 20 photos" in r for r in report.recommendations)
123
123
 
124
124
 
125
125
  def test_largest_group_ignores_capture_order() -> None:
@@ -1 +0,0 @@
1
- __version__ = "0.5.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