reverse3d 0.6.0__tar.gz → 0.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {reverse3d-0.6.0/reverse3d.egg-info → reverse3d-0.8.0}/PKG-INFO +1 -1
- reverse3d-0.8.0/reverse3d/_version.py +1 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/autopilot.py +6 -1
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/offline.py +38 -5
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/recipes.py +69 -7
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/reconstruction/silhouette.py +107 -1
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/rendering/_blender_job.py +41 -7
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/rendering/blender_render.py +55 -1
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/scenes.py +44 -0
- reverse3d-0.8.0/reverse3d/vision/classifier.py +170 -0
- reverse3d-0.8.0/reverse3d/vision/segmenter.py +136 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0/reverse3d.egg-info}/PKG-INFO +1 -1
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d.egg-info/SOURCES.txt +2 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_offline_autopilot.py +4 -1
- {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_scenes.py +52 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_silhouette.py +52 -0
- reverse3d-0.6.0/reverse3d/_version.py +0 -1
- {reverse3d-0.6.0 → reverse3d-0.8.0}/LICENSE +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/NOTICE +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/README.md +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/pyproject.toml +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/__init__.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/__main__.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/_about.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/agent/__init__.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/agent/compact.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/agent/mcp_server.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/agent/prompts.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/agent/skill/SKILL.md +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/agent/tools.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/cli.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/config.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/doctor.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/errors.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/hardware.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/imaging.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/logging_utils.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/mesh_io.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/profiles.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/py.typed +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/reconstruction/__init__.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/reconstruction/depth_estimation.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/reconstruction/photogrammetry.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/reconstruction/pointcloud.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/rendering/__init__.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/rendering/preview.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/reverse_eng/__init__.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/reverse_eng/geometry_fit.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/reverse_eng/measure.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/reverse_eng/mesh_cleaner.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/reverse_eng/parametric.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/security.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/setup_agents.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/userconfig.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/vision/__init__.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/vision/feature_matcher.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/vision/mask_extractor.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d/watch.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d.egg-info/dependency_links.txt +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d.egg-info/entry_points.txt +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d.egg-info/requires.txt +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/reverse3d.egg-info/top_level.txt +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/setup.cfg +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_agent_tools.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_authorship.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_automation.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_cli.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_cli_automation.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_config.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_geometry_fit.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_heic.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_mesh.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_optional_extras.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_reconstruction.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_render.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_security.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.8.0}/tests/test_vision.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: reverse3d
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: AI-agent-ready toolkit for 3D reverse engineering: image analysis, photogrammetry, mesh repair, primitive fitting and headless rendering.
|
|
5
5
|
Author-email: Voxel Enterprise <info@voxel.lat>
|
|
6
6
|
Maintainer-email: Voxel Enterprise <info@voxel.lat>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.8.0"
|
|
@@ -361,7 +361,12 @@ def auto_run(
|
|
|
361
361
|
if base.scene == "auto":
|
|
362
362
|
predicted = detect_scene(source, settings)
|
|
363
363
|
scene_info = predicted.to_dict()
|
|
364
|
-
base = replace(
|
|
364
|
+
base = replace(
|
|
365
|
+
base,
|
|
366
|
+
scene=predicted.kind,
|
|
367
|
+
subject=predicted.subject,
|
|
368
|
+
render_preset=predicted.render,
|
|
369
|
+
)
|
|
365
370
|
elif base.scene in SCENE_PRESETS:
|
|
366
371
|
scene_info = {"kind": base.scene, "confidence": 1.0, "reasons": ["chosen"]}
|
|
367
372
|
summary: dict[str, Any] | None = None
|
|
@@ -54,6 +54,8 @@ MODEL_REVISIONS: dict[str, str] = {
|
|
|
54
54
|
"depth-anything/Depth-Anything-V2-Small-hf": "5426e4f0f36572d16453bbda7a8389317b1bef99",
|
|
55
55
|
"depth-anything/Depth-Anything-V2-Base-hf": "b1958afc87fb45a9e3746cb387596094de553ed8",
|
|
56
56
|
"depth-anything/Depth-Anything-V2-Large-hf": "7581137eff8d4e94f6e796d3baea0e9fa79b22d2",
|
|
57
|
+
"facebook/sam-vit-base": "70c1a07f894ebb5b307fd9eaaee97b9dfc16068f",
|
|
58
|
+
"wkcn/TinyCLIP-ViT-40M-32-Text-19M-LAION400M": "95ec8197b3f2fe7f747865c61ca556cf0768b2f7",
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
|
|
@@ -100,8 +102,10 @@ def is_offline(settings: Settings) -> bool:
|
|
|
100
102
|
|
|
101
103
|
def _allowed_models() -> dict[str, tuple[str, str]]:
|
|
102
104
|
from reverse3d.reconstruction.depth_estimation import ALLOWED_MODELS
|
|
105
|
+
from reverse3d.vision.classifier import ALLOWED_CLASSIFIERS
|
|
106
|
+
from reverse3d.vision.segmenter import ALLOWED_SEGMENTERS
|
|
103
107
|
|
|
104
|
-
return
|
|
108
|
+
return {**ALLOWED_MODELS, **ALLOWED_SEGMENTERS, **ALLOWED_CLASSIFIERS}
|
|
105
109
|
|
|
106
110
|
|
|
107
111
|
def _default_model() -> str:
|
|
@@ -177,7 +181,7 @@ def resolve_model(model_id: str, settings: Settings) -> Path:
|
|
|
177
181
|
return local
|
|
178
182
|
if is_offline(settings):
|
|
179
183
|
raise DependencyMissingError(
|
|
180
|
-
"The AI
|
|
184
|
+
"The AI model is not on this computer and there is no internet connection.",
|
|
181
185
|
hint="Connect to the internet once and run: reverse3d offline prepare",
|
|
182
186
|
)
|
|
183
187
|
try:
|
|
@@ -190,7 +194,7 @@ def resolve_model(model_id: str, settings: Settings) -> Path:
|
|
|
190
194
|
except Exception as exc: # network errors come in many shapes; report them plainly
|
|
191
195
|
has_internet(refresh=True)
|
|
192
196
|
raise DependencyMissingError(
|
|
193
|
-
f"Could not download the AI
|
|
197
|
+
f"Could not download the AI model ({exc.__class__.__name__}).",
|
|
194
198
|
hint="Check your connection and run: reverse3d offline prepare",
|
|
195
199
|
) from exc
|
|
196
200
|
|
|
@@ -201,7 +205,12 @@ def prepare(settings: Settings, *, include_non_commercial: bool = False) -> dict
|
|
|
201
205
|
Safe to run any time: models already on disk are skipped. It never raises
|
|
202
206
|
for a missing optional stack; that is reported in the result instead.
|
|
203
207
|
"""
|
|
204
|
-
|
|
208
|
+
from reverse3d.vision.classifier import DEFAULT_CLASSIFIER
|
|
209
|
+
from reverse3d.vision.segmenter import DEFAULT_SEGMENTER
|
|
210
|
+
|
|
211
|
+
# Depth (reliefs), segmentation (silhouettes) and the subject classifier,
|
|
212
|
+
# all commercial-friendly.
|
|
213
|
+
models = [_default_model(), DEFAULT_SEGMENTER, DEFAULT_CLASSIFIER]
|
|
205
214
|
if include_non_commercial:
|
|
206
215
|
models += [m for m in _allowed_models() if m not in models]
|
|
207
216
|
|
|
@@ -274,8 +283,18 @@ def offline_status(settings: Settings) -> dict[str, Any]:
|
|
|
274
283
|
|
|
275
284
|
env = environment_report(settings, include_offline=False)
|
|
276
285
|
caps: dict[str, bool] = env["capabilities"] # type: ignore[assignment]
|
|
286
|
+
from reverse3d.vision.classifier import DEFAULT_CLASSIFIER
|
|
287
|
+
from reverse3d.vision.segmenter import DEFAULT_SEGMENTER
|
|
288
|
+
|
|
277
289
|
depth_installed = bool(caps.get("depth_estimation"))
|
|
278
290
|
model_on_disk = local_model_path(_default_model()) is not None
|
|
291
|
+
# The local AI team: what each model does, and whether it is on this computer.
|
|
292
|
+
ai_models = {
|
|
293
|
+
"ai_subject": DEFAULT_CLASSIFIER,
|
|
294
|
+
"ai_segmentation": DEFAULT_SEGMENTER,
|
|
295
|
+
"ai_depth": _default_model(),
|
|
296
|
+
}
|
|
297
|
+
on_disk = {key: local_model_path(model) is not None for key, model in ai_models.items()}
|
|
279
298
|
internet = None if settings.offline != "auto" else has_internet()
|
|
280
299
|
|
|
281
300
|
components = {
|
|
@@ -287,13 +306,27 @@ def offline_status(settings: Settings) -> dict[str, Any]:
|
|
|
287
306
|
"available": caps.get("render_blender", False) or caps.get("render_pyvista", False),
|
|
288
307
|
"offline": True,
|
|
289
308
|
},
|
|
290
|
-
|
|
309
|
+
**{
|
|
310
|
+
key: {"available": depth_installed, "offline": depth_installed and on_disk[key]}
|
|
311
|
+
for key in ai_models
|
|
312
|
+
},
|
|
291
313
|
}
|
|
292
314
|
missing = []
|
|
315
|
+
names = {
|
|
316
|
+
"ai_subject": "AI subject classifier",
|
|
317
|
+
"ai_segmentation": "AI segmentation",
|
|
318
|
+
"ai_depth": "AI depth",
|
|
319
|
+
}
|
|
293
320
|
if depth_installed and not model_on_disk:
|
|
294
321
|
missing.append(
|
|
295
322
|
"AI depth model not downloaded yet: run `reverse3d offline prepare` while online."
|
|
296
323
|
)
|
|
324
|
+
for key in ("ai_subject", "ai_segmentation"):
|
|
325
|
+
if depth_installed and not on_disk[key]:
|
|
326
|
+
missing.append(
|
|
327
|
+
f"{names[key]} model not downloaded yet: "
|
|
328
|
+
"run `reverse3d offline prepare` while online."
|
|
329
|
+
)
|
|
297
330
|
working_offline = is_offline(settings)
|
|
298
331
|
return {
|
|
299
332
|
"mode": settings.offline,
|
|
@@ -36,6 +36,7 @@ from reverse3d.errors import (
|
|
|
36
36
|
DependencyMissingError,
|
|
37
37
|
InputValidationError,
|
|
38
38
|
ProcessingError,
|
|
39
|
+
Reverse3DError,
|
|
39
40
|
)
|
|
40
41
|
from reverse3d.hardware import torch_device, wants_gpu
|
|
41
42
|
from reverse3d.profiles import profile_values
|
|
@@ -74,6 +75,9 @@ class RecipeOptions:
|
|
|
74
75
|
mask_method: MaskMethod = "otsu"
|
|
75
76
|
#: photos_to_model: scene profile (object, character, architecture, landscape) or "auto".
|
|
76
77
|
scene: str = "auto"
|
|
78
|
+
#: what was photographed (AI classifier) and the render look; None = from the scene.
|
|
79
|
+
subject: str | None = None
|
|
80
|
+
render_preset: str | None = None
|
|
77
81
|
#: photo_to_relief: object mask (white = keep), e.g. from the autopilot fallback.
|
|
78
82
|
mask_path: str | None = None
|
|
79
83
|
#: mesh repair strength; "strong" closes much larger holes.
|
|
@@ -155,15 +159,20 @@ def _clean(
|
|
|
155
159
|
|
|
156
160
|
|
|
157
161
|
def _render(run: _Run, mesh: Path, overwrite: bool) -> None:
|
|
158
|
-
from reverse3d.rendering.blender_render import RenderOptions, render_mesh
|
|
162
|
+
from reverse3d.rendering.blender_render import RenderOptions, preset_options, render_mesh
|
|
159
163
|
|
|
160
164
|
prof = profile_values(run.settings)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
165
|
+
look = str(run.details.get("scene", {}).get("render") or "studio")
|
|
166
|
+
options = preset_options(
|
|
167
|
+
look,
|
|
168
|
+
RenderOptions(
|
|
169
|
+
width=prof.render_width,
|
|
170
|
+
height=prof.render_height,
|
|
171
|
+
samples=prof.render_samples,
|
|
172
|
+
device="gpu" if wants_gpu(run.settings) else "cpu",
|
|
173
|
+
),
|
|
166
174
|
)
|
|
175
|
+
run.details["render_look"] = look
|
|
167
176
|
try:
|
|
168
177
|
result = run.step(
|
|
169
178
|
"render",
|
|
@@ -193,6 +202,36 @@ def _measure(run: _Run, mesh: Path) -> MeshStats:
|
|
|
193
202
|
_MIN_GROUP = 8
|
|
194
203
|
|
|
195
204
|
|
|
205
|
+
_DEFAULT_LOOK = {
|
|
206
|
+
"object": "studio",
|
|
207
|
+
"character": "portrait",
|
|
208
|
+
"architecture": "architecture",
|
|
209
|
+
"landscape": "aerial",
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _color_outputs(run: _Run, colored: Path, model: Path, scale: float) -> None:
|
|
214
|
+
"""Give the final model the photo colours of the carved hull: model.glb (games,
|
|
215
|
+
web, viewers) and model_color.ply (renders)."""
|
|
216
|
+
import numpy as np
|
|
217
|
+
import trimesh
|
|
218
|
+
from scipy.spatial import cKDTree
|
|
219
|
+
|
|
220
|
+
source: Any = trimesh.load(colored, process=False, force="mesh")
|
|
221
|
+
target: Any = trimesh.load(model, process=False, force="mesh")
|
|
222
|
+
colors = getattr(source.visual, "vertex_colors", None)
|
|
223
|
+
if colors is None or len(colors) != len(source.vertices):
|
|
224
|
+
return
|
|
225
|
+
_, nearest = cKDTree(np.asarray(source.vertices) * scale).query(np.asarray(target.vertices))
|
|
226
|
+
target.visual = trimesh.visual.ColorVisuals(target, vertex_colors=np.asarray(colors)[nearest])
|
|
227
|
+
glb = run.out / "model.glb"
|
|
228
|
+
ply = run.out / "model_color.ply"
|
|
229
|
+
target.export(glb)
|
|
230
|
+
target.export(ply)
|
|
231
|
+
run.outputs["model_glb"] = str(glb)
|
|
232
|
+
run.outputs["model_color"] = str(ply)
|
|
233
|
+
|
|
234
|
+
|
|
196
235
|
def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
|
|
197
236
|
from reverse3d.reconstruction.photogrammetry import (
|
|
198
237
|
PhotogrammetryOptions,
|
|
@@ -203,9 +242,11 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
|
|
|
203
242
|
from reverse3d.vision.feature_matcher import analyze_image_set
|
|
204
243
|
|
|
205
244
|
scene: str
|
|
245
|
+
subject, look = opt.subject, opt.render_preset
|
|
206
246
|
if opt.scene == "auto":
|
|
207
247
|
detection = run.step("scene", lambda: detect_scene(src, run.settings))
|
|
208
248
|
scene, confidence, predicted = detection.kind, detection.confidence, True
|
|
249
|
+
subject, look = detection.subject, detection.render
|
|
209
250
|
elif opt.scene in SCENE_PRESETS:
|
|
210
251
|
scene, confidence, predicted = opt.scene, 1.0, False
|
|
211
252
|
else:
|
|
@@ -218,6 +259,8 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
|
|
|
218
259
|
"label": SCENE_LABEL_ES[scene],
|
|
219
260
|
"confidence": confidence,
|
|
220
261
|
"predicted": predicted,
|
|
262
|
+
"subject": subject,
|
|
263
|
+
"render": look or _DEFAULT_LOOK[scene],
|
|
221
264
|
}
|
|
222
265
|
analysis = run.step("analyze", lambda: analyze_image_set(src, run.settings))
|
|
223
266
|
run.details["analysis"] = analysis.to_dict()
|
|
@@ -262,6 +305,19 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
|
|
|
262
305
|
upright = False
|
|
263
306
|
if silhouette:
|
|
264
307
|
from reverse3d.reconstruction.silhouette import visual_hull
|
|
308
|
+
from reverse3d.vision.segmenter import ObjectSegmenter
|
|
309
|
+
|
|
310
|
+
def load_segmenter() -> ObjectSegmenter | None:
|
|
311
|
+
# The AI segmenter keeps dark caps, handles and thin parts; without it
|
|
312
|
+
# (no AI extra, or offline before the first download) GrabCut is used.
|
|
313
|
+
try:
|
|
314
|
+
return ObjectSegmenter(run.settings, device=torch_device(run.settings))
|
|
315
|
+
except Reverse3DError as exc:
|
|
316
|
+
logger.info("AI segmentation unavailable (%s); using GrabCut", exc.message)
|
|
317
|
+
run.warnings.append(
|
|
318
|
+
"AI segmentation unavailable: thin or dark parts may be missing."
|
|
319
|
+
)
|
|
320
|
+
return None
|
|
265
321
|
|
|
266
322
|
try:
|
|
267
323
|
hull = run.step(
|
|
@@ -270,6 +326,7 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
|
|
|
270
326
|
Path(photo.images_dir or ""),
|
|
271
327
|
Path(photo.sparse_text_dir or ""),
|
|
272
328
|
run.out / "reconstruction" / "object_hull.ply",
|
|
329
|
+
segmenter=load_segmenter,
|
|
273
330
|
),
|
|
274
331
|
)
|
|
275
332
|
except ProcessingError as exc:
|
|
@@ -307,6 +364,11 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
|
|
|
307
364
|
keep_largest=preset.keep_largest,
|
|
308
365
|
repair=opt.repair,
|
|
309
366
|
)
|
|
367
|
+
if upright:
|
|
368
|
+
try:
|
|
369
|
+
_color_outputs(run, Path(mesh_path), model, opt.scale)
|
|
370
|
+
except Exception as exc: # colour is a bonus: never fail the model for it
|
|
371
|
+
logger.info("Colour transfer skipped: %s", exc)
|
|
310
372
|
return _measure(run, model)
|
|
311
373
|
|
|
312
374
|
|
|
@@ -495,7 +557,7 @@ def run_recipe(
|
|
|
495
557
|
source = prepare_heic_input(source, out) # iPhone HEIC photos -> JPEG
|
|
496
558
|
stats = _RECIPES[recipe](run, source, opt)
|
|
497
559
|
if opt.render:
|
|
498
|
-
_render(run, Path(run.outputs["model"]), opt.overwrite)
|
|
560
|
+
_render(run, Path(run.outputs.get("model_color") or run.outputs["model"]), opt.overwrite)
|
|
499
561
|
|
|
500
562
|
summary: dict[str, Any] = {
|
|
501
563
|
"recipe": recipe,
|
|
@@ -23,6 +23,7 @@ from __future__ import annotations
|
|
|
23
23
|
import logging
|
|
24
24
|
from dataclasses import asdict, dataclass
|
|
25
25
|
from pathlib import Path
|
|
26
|
+
from typing import TYPE_CHECKING
|
|
26
27
|
|
|
27
28
|
import cv2
|
|
28
29
|
import numpy as np
|
|
@@ -30,6 +31,11 @@ import trimesh
|
|
|
30
31
|
|
|
31
32
|
from reverse3d.errors import DependencyMissingError, ProcessingError
|
|
32
33
|
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from collections.abc import Callable
|
|
36
|
+
|
|
37
|
+
from reverse3d.vision.segmenter import ObjectSegmenter
|
|
38
|
+
|
|
33
39
|
logger = logging.getLogger(__name__)
|
|
34
40
|
|
|
35
41
|
_WORK_SIDE = 640
|
|
@@ -62,6 +68,7 @@ class HullResult:
|
|
|
62
68
|
voxels: int
|
|
63
69
|
faces: int
|
|
64
70
|
support_plane: bool
|
|
71
|
+
ai_segmentation: bool = False
|
|
65
72
|
|
|
66
73
|
def to_dict(self) -> dict[str, object]:
|
|
67
74
|
return asdict(self)
|
|
@@ -184,6 +191,65 @@ def _support_plane(
|
|
|
184
191
|
return n, d
|
|
185
192
|
|
|
186
193
|
|
|
194
|
+
def _vertex_colors(
|
|
195
|
+
mesh: trimesh.Trimesh,
|
|
196
|
+
views: list[_View],
|
|
197
|
+
images: list[tuple[np.ndarray, float]],
|
|
198
|
+
masks: list[np.ndarray] | None = None,
|
|
199
|
+
) -> np.ndarray:
|
|
200
|
+
"""Real colour of every vertex from the photos that see it head-on.
|
|
201
|
+
|
|
202
|
+
Each photo votes with weight cos(angle)^2 for the vertices that face it and
|
|
203
|
+
are not hidden (coarse depth buffer); vertices no photo sees get the median.
|
|
204
|
+
"""
|
|
205
|
+
verts = np.asarray(mesh.vertices)
|
|
206
|
+
normals = np.asarray(mesh.vertex_normals)
|
|
207
|
+
total = np.zeros((len(verts), 3))
|
|
208
|
+
weight = np.zeros(len(verts))
|
|
209
|
+
for i, ((small, scale), view) in enumerate(zip(images, views, strict=True)):
|
|
210
|
+
# Only pixels of the object itself give colour (the carved hull is a bit
|
|
211
|
+
# larger than the object, so its rim would otherwise pick up background).
|
|
212
|
+
inner = (
|
|
213
|
+
cv2.erode(masks[i], np.ones((5, 5), np.uint8)) > 0
|
|
214
|
+
if masks is not None and masks[i].shape == small.shape[:2]
|
|
215
|
+
else None
|
|
216
|
+
)
|
|
217
|
+
center = -view.rotation.T @ view.translation
|
|
218
|
+
to_cam = center - verts
|
|
219
|
+
to_cam /= np.linalg.norm(to_cam, axis=1, keepdims=True)
|
|
220
|
+
facing = np.einsum("ij,ij->i", normals, to_cam)
|
|
221
|
+
uv, z = _project(verts, view)
|
|
222
|
+
u = uv[:, 0] * scale
|
|
223
|
+
v = uv[:, 1] * scale
|
|
224
|
+
h, w = small.shape[:2]
|
|
225
|
+
ok = (facing > 0.2) & (z > 0) & (u >= 0) & (v >= 0) & (u < w - 1) & (v < h - 1)
|
|
226
|
+
if not ok.any():
|
|
227
|
+
continue
|
|
228
|
+
# Coarse z-buffer: a vertex counts only if it is (about) the nearest in its cell.
|
|
229
|
+
cell = 4
|
|
230
|
+
cu = (u[ok] / cell).astype(np.int64)
|
|
231
|
+
cv_ = (v[ok] / cell).astype(np.int64)
|
|
232
|
+
zbuf = np.full(((h // cell) + 1, (w // cell) + 1), np.inf)
|
|
233
|
+
np.minimum.at(zbuf, (cv_, cu), z[ok])
|
|
234
|
+
depth_span = float(np.ptp(z[ok])) or 1.0
|
|
235
|
+
visible = z[ok] <= zbuf[cv_, cu] + 0.03 * depth_span
|
|
236
|
+
idx = np.flatnonzero(ok)[visible]
|
|
237
|
+
if inner is not None:
|
|
238
|
+
idx = idx[inner[v[idx].astype(np.int64), u[idx].astype(np.int64)]]
|
|
239
|
+
px = small[v[idx].astype(np.int64), u[idx].astype(np.int64)][:, ::-1].astype(np.float64)
|
|
240
|
+
wgt = facing[idx] ** 2
|
|
241
|
+
total[idx] += px * wgt[:, None]
|
|
242
|
+
weight[idx] += wgt
|
|
243
|
+
colors = np.zeros((len(verts), 4), np.uint8)
|
|
244
|
+
seen = weight > 0
|
|
245
|
+
rgb = np.zeros((len(verts), 3))
|
|
246
|
+
rgb[seen] = total[seen] / weight[seen, None]
|
|
247
|
+
rgb[~seen] = np.median(rgb[seen], axis=0) if seen.any() else 180
|
|
248
|
+
colors[:, :3] = np.clip(rgb, 0, 255).astype(np.uint8)
|
|
249
|
+
colors[:, 3] = 255
|
|
250
|
+
return colors
|
|
251
|
+
|
|
252
|
+
|
|
187
253
|
def visual_hull(
|
|
188
254
|
image_dir: Path,
|
|
189
255
|
model_text_dir: Path,
|
|
@@ -191,6 +257,7 @@ def visual_hull(
|
|
|
191
257
|
*,
|
|
192
258
|
resolution: int = 176,
|
|
193
259
|
refinements: int = 3,
|
|
260
|
+
segmenter: Callable[[], ObjectSegmenter | None] | None = None,
|
|
194
261
|
) -> HullResult:
|
|
195
262
|
"""Carve the object seen by every photo; writes a watertight, upright mesh.
|
|
196
263
|
|
|
@@ -269,6 +336,36 @@ def visual_hull(
|
|
|
269
336
|
return (seen >= 3) & (votes >= seen - tolerance)
|
|
270
337
|
|
|
271
338
|
keep = carve(masks, 2)
|
|
339
|
+
# The AI segmenter is only loaded (or downloaded) once the photos proved usable.
|
|
340
|
+
ai = segmenter() if segmenter is not None else None
|
|
341
|
+
if ai is not None:
|
|
342
|
+
# AI segmentation: a box around the projected hull (taller upwards, where caps
|
|
343
|
+
# and handles get lost) and a point on it. Two rounds, each from the new hull.
|
|
344
|
+
states = [ai.embed(cv2.cvtColor(small, cv2.COLOR_BGR2RGB)) for small, _ in images]
|
|
345
|
+
for _ in range(2):
|
|
346
|
+
ai_masks: list[np.ndarray] = []
|
|
347
|
+
for state, (small, _), (u, v, inside), old in zip(
|
|
348
|
+
states, images, projections, masks, strict=True
|
|
349
|
+
):
|
|
350
|
+
sel = keep & inside
|
|
351
|
+
if not sel.any():
|
|
352
|
+
ai_masks.append(old)
|
|
353
|
+
continue
|
|
354
|
+
us, vs = u[sel], v[sel]
|
|
355
|
+
x0, x1 = int(us.min()), int(us.max())
|
|
356
|
+
y0, y1 = int(vs.min()), int(vs.max())
|
|
357
|
+
pad_x, pad_y = 0.15 * (x1 - x0 + 1), 0.15 * (y1 - y0 + 1)
|
|
358
|
+
box = (
|
|
359
|
+
max(0, int(x0 - pad_x)),
|
|
360
|
+
max(0, int(y0 - 2.5 * pad_y)),
|
|
361
|
+
min(small.shape[1] - 1, int(x1 + pad_x)),
|
|
362
|
+
min(small.shape[0] - 1, int(y1 + pad_y)),
|
|
363
|
+
)
|
|
364
|
+
point = (int(np.median(us)), int(np.median(vs)))
|
|
365
|
+
ai_masks.append(ai.segment(state, box, point))
|
|
366
|
+
masks = ai_masks
|
|
367
|
+
keep = carve(masks, 1)
|
|
368
|
+
refinements = 0
|
|
272
369
|
for _ in range(refinements):
|
|
273
370
|
refined: list[np.ndarray] = []
|
|
274
371
|
for (small, _), (u, v, inside), old in zip(images, projections, masks, strict=True):
|
|
@@ -294,6 +391,8 @@ def visual_hull(
|
|
|
294
391
|
normal, offset = plane
|
|
295
392
|
height = (grid @ normal + offset).reshape(n, n, n)
|
|
296
393
|
volume[height < 0.015 * radius] = 0
|
|
394
|
+
# Drop structures thinner than a couple of voxels (stray spikes from a few views).
|
|
395
|
+
volume = ndimage.binary_opening(volume > 0.5, iterations=2).astype(np.float32)
|
|
297
396
|
labels, count = ndimage.label(volume > 0.5)
|
|
298
397
|
if count == 0:
|
|
299
398
|
raise ProcessingError("No object could be isolated in the photos.")
|
|
@@ -318,6 +417,11 @@ def visual_hull(
|
|
|
318
417
|
verts = (verts - 2) / (n - 1) * 2 * radius - radius + center
|
|
319
418
|
mesh = trimesh.Trimesh(verts, faces, process=True)
|
|
320
419
|
trimesh.smoothing.filter_taubin(mesh, lamb=0.5, nu=-0.53, iterations=15)
|
|
420
|
+
mesh.fix_normals()
|
|
421
|
+
# Real colours from the photos (kept by PLY export; the model gets them as GLB).
|
|
422
|
+
mesh.visual = trimesh.visual.ColorVisuals(
|
|
423
|
+
mesh, vertex_colors=_vertex_colors(mesh, views, images, masks)
|
|
424
|
+
)
|
|
321
425
|
|
|
322
426
|
# Stand it upright: +Z along the table normal (or the mean camera "up").
|
|
323
427
|
up = plane[0] if plane is not None else np.mean(cameras - center, axis=0)
|
|
@@ -333,4 +437,6 @@ def visual_hull(
|
|
|
333
437
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
334
438
|
mesh.export(output_path)
|
|
335
439
|
logger.info("Silhouette model: %d views, %d faces", len(views), len(mesh.faces))
|
|
336
|
-
return HullResult(
|
|
440
|
+
return HullResult(
|
|
441
|
+
str(output_path), len(views), voxels, len(mesh.faces), plane is not None, ai is not None
|
|
442
|
+
)
|
|
@@ -136,8 +136,15 @@ def make_material(obj: bpy.types.Object, job: dict) -> None:
|
|
|
136
136
|
obj.data.materials.append(mat)
|
|
137
137
|
|
|
138
138
|
|
|
139
|
-
def add_light(
|
|
139
|
+
def add_light(
|
|
140
|
+
name: str,
|
|
141
|
+
location: tuple[float, float, float],
|
|
142
|
+
energy: float,
|
|
143
|
+
size: float,
|
|
144
|
+
color: tuple[float, float, float] = (1.0, 1.0, 1.0),
|
|
145
|
+
) -> None:
|
|
140
146
|
data = bpy.data.lights.new(name, type="AREA")
|
|
147
|
+
data.color = color
|
|
141
148
|
data.energy = energy
|
|
142
149
|
data.size = size
|
|
143
150
|
light = bpy.data.objects.new(name, data)
|
|
@@ -153,7 +160,37 @@ def setup_world(job: dict) -> None:
|
|
|
153
160
|
world.use_nodes = True
|
|
154
161
|
bg = world.node_tree.nodes.get("Background")
|
|
155
162
|
bg.inputs["Color"].default_value = _hex_to_rgba(job["background"])
|
|
156
|
-
|
|
163
|
+
outdoor = job.get("preset") in ("architecture", "aerial")
|
|
164
|
+
bg.inputs["Strength"].default_value = 1.0 if outdoor else 0.6
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def add_sun(elevation_deg: float, azimuth_deg: float, energy: float) -> None:
|
|
168
|
+
data = bpy.data.lights.new("sun", type="SUN")
|
|
169
|
+
data.energy = energy
|
|
170
|
+
data.angle = math.radians(1.5) # soft daylight shadows
|
|
171
|
+
sun = bpy.data.objects.new("sun", data)
|
|
172
|
+
bpy.context.collection.objects.link(sun)
|
|
173
|
+
sun.rotation_euler = (math.radians(90 - elevation_deg), 0.0, math.radians(azimuth_deg))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def setup_lights(r: float, job: dict) -> None:
|
|
177
|
+
"""Lighting of each look (see RENDER_PRESETS in blender_render.py)."""
|
|
178
|
+
preset = job.get("preset", "studio")
|
|
179
|
+
if preset in ("architecture", "aerial"):
|
|
180
|
+
add_sun(55 if preset == "architecture" else 65, 140, 4.0)
|
|
181
|
+
add_light("sky_fill", (-r * 4, -r * 3, r * 3), 150 * r * r, r * 4, (0.85, 0.9, 1.0))
|
|
182
|
+
elif preset == "portrait":
|
|
183
|
+
add_light("key", (r * 3, -r * 3, r * 2.5), 900 * r * r, r * 1.5, (1.0, 0.93, 0.85))
|
|
184
|
+
add_light("fill", (-r * 4, -r * 2, r * 1.5), 160 * r * r, r * 3)
|
|
185
|
+
add_light("rim", (-r * 1, r * 4, r * 3), 900 * r * r, r * 1.2, (0.75, 0.85, 1.0))
|
|
186
|
+
elif preset == "showroom":
|
|
187
|
+
add_light("top", (0, 0, r * 5), 1200 * r * r, r * 4)
|
|
188
|
+
add_light("key", (r * 4, -r * 3, r * 2), 600 * r * r, r * 3)
|
|
189
|
+
add_light("rim", (-r * 3, r * 4, r * 2), 700 * r * r, r * 2.5)
|
|
190
|
+
else: # studio
|
|
191
|
+
add_light("key", (r * 3, -r * 3, r * 4), 800 * r * r, r * 2)
|
|
192
|
+
add_light("fill", (-r * 4, -r * 2, r * 2), 300 * r * r, r * 3)
|
|
193
|
+
add_light("rim", (0, r * 4, r * 3), 500 * r * r, r * 2)
|
|
157
194
|
|
|
158
195
|
|
|
159
196
|
def setup_camera(radius: float, center_z: float, job: dict) -> bpy.types.Object:
|
|
@@ -162,7 +199,7 @@ def setup_camera(radius: float, center_z: float, job: dict) -> bpy.types.Object:
|
|
|
162
199
|
rig.location = (0, 0, center_z)
|
|
163
200
|
|
|
164
201
|
cam_data = bpy.data.cameras.new("reverse3d_camera")
|
|
165
|
-
cam_data.lens = 50
|
|
202
|
+
cam_data.lens = float(job.get("lens_mm", 50))
|
|
166
203
|
cam = bpy.data.objects.new("reverse3d_camera", cam_data)
|
|
167
204
|
bpy.context.collection.objects.link(cam)
|
|
168
205
|
# camera.angle is the FOV of the longer image side; fit the sphere in the shorter one.
|
|
@@ -248,10 +285,7 @@ def main() -> None:
|
|
|
248
285
|
shade(obj, job["shading"])
|
|
249
286
|
make_material(obj, job)
|
|
250
287
|
setup_world(job)
|
|
251
|
-
|
|
252
|
-
add_light("key", (r * 3, -r * 3, r * 4), 800 * r * r, r * 2)
|
|
253
|
-
add_light("fill", (-r * 4, -r * 2, r * 2), 300 * r * r, r * 3)
|
|
254
|
-
add_light("rim", (0, r * 4, r * 3), 500 * r * r, r * 2)
|
|
288
|
+
setup_lights(max(radius, 0.1), job)
|
|
255
289
|
rig = setup_camera(radius, center_z, job)
|
|
256
290
|
setup_ground(job)
|
|
257
291
|
setup_render(job)
|
|
@@ -19,7 +19,7 @@ import re
|
|
|
19
19
|
import tempfile
|
|
20
20
|
from dataclasses import asdict, dataclass, field
|
|
21
21
|
from pathlib import Path
|
|
22
|
-
from typing import Literal
|
|
22
|
+
from typing import Any, Literal
|
|
23
23
|
|
|
24
24
|
from reverse3d.config import Settings
|
|
25
25
|
from reverse3d.errors import DependencyMissingError, InputValidationError, ProcessingError
|
|
@@ -63,6 +63,9 @@ class RenderOptions:
|
|
|
63
63
|
ground: bool = True
|
|
64
64
|
shading: Literal["auto", "smooth", "flat"] = "auto"
|
|
65
65
|
vertex_colors: bool = True
|
|
66
|
+
#: look of the shot: studio, showroom, portrait, architecture or aerial.
|
|
67
|
+
preset: str = "studio"
|
|
68
|
+
lens_mm: float = 50.0
|
|
66
69
|
|
|
67
70
|
def validate(self) -> None:
|
|
68
71
|
checks = [
|
|
@@ -82,12 +85,63 @@ class RenderOptions:
|
|
|
82
85
|
(0 <= self.azimuth_deg <= 360, "azimuth_deg must be in [0, 360]"),
|
|
83
86
|
(0 <= self.turntable_frames <= 360, "turntable_frames must be in [0, 360]"),
|
|
84
87
|
(self.shading in ("auto", "smooth", "flat"), "shading must be auto, smooth or flat"),
|
|
88
|
+
(self.preset in RENDER_PRESETS, f"preset must be one of {', '.join(RENDER_PRESETS)}"),
|
|
89
|
+
(10 <= self.lens_mm <= 300, "lens_mm must be in [10, 300]"),
|
|
85
90
|
]
|
|
86
91
|
for ok, message in checks:
|
|
87
92
|
if not ok:
|
|
88
93
|
raise InputValidationError(message)
|
|
89
94
|
|
|
90
95
|
|
|
96
|
+
#: Camera, lens and backdrop of each look (lights are set up in the Blender job).
|
|
97
|
+
RENDER_PRESETS: dict[str, dict[str, Any]] = {
|
|
98
|
+
# Products and parts: seamless light backdrop, three soft boxes.
|
|
99
|
+
"studio": {
|
|
100
|
+
"lens_mm": 50.0,
|
|
101
|
+
"elevation_deg": 25.0,
|
|
102
|
+
"azimuth_deg": 35.0,
|
|
103
|
+
"background": "#f4f5f7",
|
|
104
|
+
},
|
|
105
|
+
# Vehicles: longer lens, low angle, glossy paint, showroom grey.
|
|
106
|
+
"showroom": {
|
|
107
|
+
"lens_mm": 70.0,
|
|
108
|
+
"elevation_deg": 12.0,
|
|
109
|
+
"azimuth_deg": 40.0,
|
|
110
|
+
"background": "#e6e9ee",
|
|
111
|
+
"roughness": 0.22,
|
|
112
|
+
},
|
|
113
|
+
# People and animals: portrait lens, eye level, dark backdrop, warm key + cool rim.
|
|
114
|
+
"portrait": {
|
|
115
|
+
"lens_mm": 85.0,
|
|
116
|
+
"elevation_deg": 6.0,
|
|
117
|
+
"azimuth_deg": 25.0,
|
|
118
|
+
"background": "#1d222b",
|
|
119
|
+
},
|
|
120
|
+
# Buildings and interiors: wide lens, sun and sky.
|
|
121
|
+
"architecture": {
|
|
122
|
+
"lens_mm": 28.0,
|
|
123
|
+
"elevation_deg": 20.0,
|
|
124
|
+
"azimuth_deg": 40.0,
|
|
125
|
+
"background": "#bcd4ea",
|
|
126
|
+
},
|
|
127
|
+
# Terrain and forests: high aerial view in daylight.
|
|
128
|
+
"aerial": {
|
|
129
|
+
"lens_mm": 35.0,
|
|
130
|
+
"elevation_deg": 55.0,
|
|
131
|
+
"azimuth_deg": 30.0,
|
|
132
|
+
"background": "#cfe0ee",
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def preset_options(preset: str, base: RenderOptions | None = None) -> RenderOptions:
|
|
138
|
+
"""Render options for a look, keeping size, samples and device from ``base``."""
|
|
139
|
+
from dataclasses import replace
|
|
140
|
+
|
|
141
|
+
name = preset if preset in RENDER_PRESETS else "studio"
|
|
142
|
+
return replace(base or RenderOptions(), preset=name, **RENDER_PRESETS[name])
|
|
143
|
+
|
|
144
|
+
|
|
91
145
|
@dataclass
|
|
92
146
|
class RenderResult:
|
|
93
147
|
"""Paths of the rendered images and the backend that produced them."""
|
|
@@ -102,6 +102,12 @@ class SceneDetection:
|
|
|
102
102
|
recommended: tuple[int, int]
|
|
103
103
|
enough_photos: bool
|
|
104
104
|
advice: list[str] = field(default_factory=list)
|
|
105
|
+
#: what the AI classifier saw (product, person, animal, vehicle, building...), if available.
|
|
106
|
+
subject: str | None = None
|
|
107
|
+
subject_label: str | None = None
|
|
108
|
+
subject_scores: dict[str, float] = field(default_factory=dict)
|
|
109
|
+
#: render look: studio, showroom, portrait, architecture or aerial.
|
|
110
|
+
render: str = "studio"
|
|
105
111
|
|
|
106
112
|
def to_dict(self) -> dict[str, object]:
|
|
107
113
|
return asdict(self)
|
|
@@ -307,6 +313,26 @@ def score_scene(signals: SceneSignals) -> tuple[dict[str, float], list[str]]:
|
|
|
307
313
|
return {k: round(max(v, 0.0), 3) for k, v in scores.items()}, reasons
|
|
308
314
|
|
|
309
315
|
|
|
316
|
+
def _ai_subject(images: list[Path], settings: Settings) -> tuple[str | None, dict[str, float]]:
|
|
317
|
+
"""The AI classifier's subject for a sample of the photos (None if unavailable)."""
|
|
318
|
+
from reverse3d.errors import Reverse3DError
|
|
319
|
+
from reverse3d.vision.classifier import classify_photos
|
|
320
|
+
|
|
321
|
+
step = max(1, len(images) // _SAMPLE)
|
|
322
|
+
try:
|
|
323
|
+
scores = classify_photos(images[::step][:_SAMPLE], settings)
|
|
324
|
+
except Reverse3DError as exc:
|
|
325
|
+
logger.info("AI subject classifier unavailable: %s", exc.message)
|
|
326
|
+
return None, {}
|
|
327
|
+
return max(scores, key=scores.__getitem__), scores
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _subject_label(subject: str | None) -> str | None:
|
|
331
|
+
from reverse3d.vision.classifier import SUBJECT_LABEL_ES
|
|
332
|
+
|
|
333
|
+
return SUBJECT_LABEL_ES.get(subject) if subject else None
|
|
334
|
+
|
|
335
|
+
|
|
310
336
|
def detect_scene(image_dir: str | Path, settings: Settings) -> SceneDetection:
|
|
311
337
|
"""Predict the scene kind of a photo folder (takes a few seconds)."""
|
|
312
338
|
directory = validate_input_dir(image_dir)
|
|
@@ -319,6 +345,20 @@ def detect_scene(image_dir: str | Path, settings: Settings) -> SceneDetection:
|
|
|
319
345
|
ranked = sorted(scores.values(), reverse=True)
|
|
320
346
|
margin = ranked[0] - (ranked[1] if len(ranked) > 1 else 0.0)
|
|
321
347
|
confidence = round(min(0.97, 0.5 + margin * 1.5), 2)
|
|
348
|
+
subject, subject_scores = _ai_subject(images, settings)
|
|
349
|
+
render = (
|
|
350
|
+
"aerial" if kind == "landscape" else "architecture" if kind == "architecture" else "studio"
|
|
351
|
+
)
|
|
352
|
+
if subject is not None:
|
|
353
|
+
from reverse3d.vision.classifier import SUBJECT_RENDER, SUBJECT_SCENE
|
|
354
|
+
|
|
355
|
+
flight = signals.drone and signals.nadir_share > 0.3
|
|
356
|
+
if flight: # a drone looking down is terrain whatever the AI thinks it sees
|
|
357
|
+
subject = "terrain" if subject not in ("forest", "terrain") else subject
|
|
358
|
+
kind = SUBJECT_SCENE[subject] # type: ignore[assignment]
|
|
359
|
+
render = SUBJECT_RENDER[subject]
|
|
360
|
+
confidence = round(min(0.98, max(subject_scores.get(subject, 0.0), 0.5)), 2)
|
|
361
|
+
reasons.insert(0, f"ai_{subject}")
|
|
322
362
|
preset = SCENE_PRESETS[kind]
|
|
323
363
|
enough = signals.photos >= preset.min_photos
|
|
324
364
|
if not enough:
|
|
@@ -337,6 +377,10 @@ def detect_scene(image_dir: str | Path, settings: Settings) -> SceneDetection:
|
|
|
337
377
|
recommended=preset.recommended,
|
|
338
378
|
enough_photos=enough,
|
|
339
379
|
advice=advice,
|
|
380
|
+
subject=subject,
|
|
381
|
+
subject_label=_subject_label(subject),
|
|
382
|
+
subject_scores={k: round(v, 3) for k, v in subject_scores.items()},
|
|
383
|
+
render=render,
|
|
340
384
|
)
|
|
341
385
|
|
|
342
386
|
|