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.
- {reverse3d-0.5.0/reverse3d.egg-info → reverse3d-0.5.2}/PKG-INFO +5 -1
- {reverse3d-0.5.0 → reverse3d-0.5.2}/README.md +4 -0
- reverse3d-0.5.2/reverse3d/_version.py +1 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/agent/tools.py +6 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/autopilot.py +31 -6
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/cli.py +25 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/recipes.py +32 -3
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/reconstruction/photogrammetry.py +41 -12
- reverse3d-0.5.2/reverse3d/scenes.py +392 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/vision/feature_matcher.py +5 -2
- {reverse3d-0.5.0 → reverse3d-0.5.2/reverse3d.egg-info}/PKG-INFO +5 -1
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d.egg-info/SOURCES.txt +2 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/tests/test_offline_autopilot.py +8 -1
- reverse3d-0.5.2/tests/test_scenes.py +114 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/tests/test_vision.py +1 -1
- reverse3d-0.5.0/reverse3d/_version.py +0 -1
- {reverse3d-0.5.0 → reverse3d-0.5.2}/LICENSE +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/NOTICE +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/pyproject.toml +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/__init__.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/__main__.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/_about.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/agent/__init__.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/agent/compact.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/agent/mcp_server.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/agent/prompts.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/agent/skill/SKILL.md +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/config.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/doctor.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/errors.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/hardware.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/imaging.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/logging_utils.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/mesh_io.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/offline.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/profiles.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/py.typed +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/reconstruction/__init__.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/reconstruction/depth_estimation.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/reconstruction/pointcloud.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/rendering/__init__.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/rendering/_blender_job.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/rendering/blender_render.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/rendering/preview.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/reverse_eng/__init__.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/reverse_eng/geometry_fit.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/reverse_eng/measure.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/reverse_eng/mesh_cleaner.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/reverse_eng/parametric.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/security.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/setup_agents.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/userconfig.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/vision/__init__.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/vision/mask_extractor.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d/watch.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d.egg-info/dependency_links.txt +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d.egg-info/entry_points.txt +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d.egg-info/requires.txt +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/reverse3d.egg-info/top_level.txt +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/setup.cfg +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/tests/test_agent_tools.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/tests/test_authorship.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/tests/test_automation.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/tests/test_cli.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/tests/test_cli_automation.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/tests/test_config.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/tests/test_geometry_fit.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/tests/test_heic.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/tests/test_mesh.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/tests/test_optional_extras.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/tests/test_reconstruction.py +0 -0
- {reverse3d-0.5.0 → reverse3d-0.5.2}/tests/test_render.py +0 -0
- {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.
|
|
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
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|