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