reverse3d 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. reverse3d/__init__.py +39 -0
  2. reverse3d/__main__.py +4 -0
  3. reverse3d/_about.py +15 -0
  4. reverse3d/_version.py +1 -0
  5. reverse3d/agent/__init__.py +5 -0
  6. reverse3d/agent/compact.py +110 -0
  7. reverse3d/agent/mcp_server.py +171 -0
  8. reverse3d/agent/prompts.py +173 -0
  9. reverse3d/agent/skill/SKILL.md +53 -0
  10. reverse3d/agent/tools.py +811 -0
  11. reverse3d/autopilot.py +355 -0
  12. reverse3d/cli.py +1054 -0
  13. reverse3d/config.py +167 -0
  14. reverse3d/doctor.py +113 -0
  15. reverse3d/errors.py +83 -0
  16. reverse3d/hardware.py +115 -0
  17. reverse3d/imaging.py +79 -0
  18. reverse3d/logging_utils.py +41 -0
  19. reverse3d/mesh_io.py +152 -0
  20. reverse3d/offline.py +313 -0
  21. reverse3d/profiles.py +33 -0
  22. reverse3d/py.typed +0 -0
  23. reverse3d/recipes.py +394 -0
  24. reverse3d/reconstruction/__init__.py +22 -0
  25. reverse3d/reconstruction/depth_estimation.py +263 -0
  26. reverse3d/reconstruction/photogrammetry.py +460 -0
  27. reverse3d/reconstruction/pointcloud.py +120 -0
  28. reverse3d/rendering/__init__.py +10 -0
  29. reverse3d/rendering/_blender_job.py +284 -0
  30. reverse3d/rendering/blender_render.py +215 -0
  31. reverse3d/rendering/preview.py +80 -0
  32. reverse3d/reverse_eng/__init__.py +33 -0
  33. reverse3d/reverse_eng/geometry_fit.py +478 -0
  34. reverse3d/reverse_eng/measure.py +87 -0
  35. reverse3d/reverse_eng/mesh_cleaner.py +304 -0
  36. reverse3d/reverse_eng/parametric.py +154 -0
  37. reverse3d/security.py +294 -0
  38. reverse3d/setup_agents.py +517 -0
  39. reverse3d/userconfig.py +156 -0
  40. reverse3d/vision/__init__.py +12 -0
  41. reverse3d/vision/feature_matcher.py +272 -0
  42. reverse3d/vision/mask_extractor.py +352 -0
  43. reverse3d/watch.py +238 -0
  44. reverse3d-0.4.0.dist-info/METADATA +650 -0
  45. reverse3d-0.4.0.dist-info/RECORD +50 -0
  46. reverse3d-0.4.0.dist-info/WHEEL +5 -0
  47. reverse3d-0.4.0.dist-info/entry_points.txt +2 -0
  48. reverse3d-0.4.0.dist-info/licenses/LICENSE +202 -0
  49. reverse3d-0.4.0.dist-info/licenses/NOTICE +27 -0
  50. reverse3d-0.4.0.dist-info/top_level.txt +1 -0
reverse3d/recipes.py ADDED
@@ -0,0 +1,394 @@
1
+ """One-call end-to-end workflows ("recipes").
2
+
3
+ A recipe chains the individual tools into a complete job and returns a short
4
+ summary. For AI agents this is the main token saver: one tool call and one
5
+ small result instead of five or six round-trips with full intermediate
6
+ reports. The complete details are written to ``<output_dir>/report.json``.
7
+
8
+ Recipes
9
+ -------
10
+ photos_to_model photo set → analysis → photogrammetry → clean/align → measure → render
11
+ plan_to_part plan/stencil → mask → extrusion → clean → measure → render
12
+ scan_to_cad mesh/scan → clean/align → measure → primitives → OpenSCAD + CadQuery → render
13
+ photo_to_relief one photo → depth → 2.5D mesh → clean → render
14
+ mesh_report mesh → measure → render
15
+ auto anything → detects the input and runs the right recipe, retrying
16
+ with other parameters when that can help (see autopilot)
17
+
18
+ Device (CPU/GPU) and speed/quality come from ``Settings.device`` and
19
+ ``Settings.profile`` unless explicitly overridden.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import logging
26
+ import time
27
+ from collections.abc import Callable
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+ from typing import Any, Literal, TypeVar
31
+
32
+ from reverse3d.config import Settings
33
+ from reverse3d.errors import (
34
+ CaptureQualityError,
35
+ DependencyMissingError,
36
+ InputValidationError,
37
+ ProcessingError,
38
+ )
39
+ from reverse3d.hardware import torch_device, wants_gpu
40
+ from reverse3d.profiles import profile_values
41
+ from reverse3d.reverse_eng.measure import MeshStats
42
+ from reverse3d.security import prepare_output_dir
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ RecipeName = Literal[
47
+ "photos_to_model", "plan_to_part", "scan_to_cad", "photo_to_relief", "mesh_report", "auto"
48
+ ]
49
+ RECIPES: tuple[RecipeName, ...] = (
50
+ "photos_to_model",
51
+ "plan_to_part",
52
+ "scan_to_cad",
53
+ "photo_to_relief",
54
+ "mesh_report",
55
+ "auto",
56
+ )
57
+ MaskMethod = Literal["otsu", "adaptive", "canny", "alpha"]
58
+ _T = TypeVar("_T")
59
+
60
+
61
+ @dataclass
62
+ class RecipeOptions:
63
+ """Optional knobs shared by recipes. ``None`` means "use the profile"."""
64
+
65
+ thickness: float = 5.0
66
+ units_per_pixel: float | None = None
67
+ scale: float = 1.0
68
+ render: bool = True
69
+ quality: Literal["low", "medium", "high"] | None = None
70
+ force_analysis: bool = False
71
+ overwrite: bool = False
72
+ #: plan_to_part: how the drawing is separated from the background.
73
+ mask_method: MaskMethod = "otsu"
74
+ #: mesh repair strength; "strong" closes much larger holes.
75
+ repair: Literal["normal", "strong"] = "normal"
76
+
77
+
78
+ @dataclass
79
+ class _Run:
80
+ recipe: str
81
+ settings: Settings
82
+ out: Path
83
+ outputs: dict[str, str] = field(default_factory=dict)
84
+ warnings: list[str] = field(default_factory=list)
85
+ steps: list[dict[str, Any]] = field(default_factory=list)
86
+ details: dict[str, Any] = field(default_factory=dict)
87
+
88
+ def step(self, name: str, fn: Callable[[], _T]) -> _T:
89
+ start = time.perf_counter()
90
+ try:
91
+ value = fn()
92
+ except Exception:
93
+ self.steps.append(
94
+ {"step": name, "ok": False, "s": round(time.perf_counter() - start, 2)}
95
+ )
96
+ raise
97
+ self.steps.append({"step": name, "ok": True, "s": round(time.perf_counter() - start, 2)})
98
+ return value
99
+
100
+
101
+ def _summary_dims(stats: MeshStats) -> dict[str, Any]:
102
+ return {
103
+ "size_obb": stats.obb_extents,
104
+ "size_aabb": stats.aabb_extents,
105
+ "volume": stats.volume,
106
+ "area": stats.surface_area,
107
+ "watertight": stats.watertight,
108
+ "faces": stats.faces,
109
+ }
110
+
111
+
112
+ def _clean(
113
+ run: _Run,
114
+ src: Path,
115
+ dst: Path,
116
+ *,
117
+ scale: float,
118
+ overwrite: bool,
119
+ fill_holes: bool = True,
120
+ align: Literal["none", "obb"] = "obb",
121
+ keep_largest: bool = False,
122
+ repair: Literal["normal", "strong"] = "normal",
123
+ ) -> MeshStats:
124
+ """Repair + align. ``keep_largest`` only for photogrammetry/relief output,
125
+ where floating fragments are noise; plans and scans may have several real
126
+ parts, and tiny debris is already dropped by the area filter."""
127
+ from reverse3d.reverse_eng.mesh_cleaner import CleanOptions, clean_mesh_file
128
+
129
+ report = run.step(
130
+ "clean",
131
+ lambda: clean_mesh_file(
132
+ src,
133
+ dst,
134
+ run.settings,
135
+ CleanOptions(
136
+ keep_largest_only=keep_largest,
137
+ fill_holes=fill_holes,
138
+ align=align,
139
+ ground=align != "none",
140
+ scale=scale,
141
+ max_hole_edges=2000 if repair == "strong" else 200,
142
+ ),
143
+ overwrite=overwrite,
144
+ ),
145
+ )
146
+ run.warnings.extend(report.warnings)
147
+ run.outputs["model"] = str(dst)
148
+ run.details["clean"] = report.to_dict()
149
+ return report.after
150
+
151
+
152
+ def _render(run: _Run, mesh: Path, overwrite: bool) -> None:
153
+ from reverse3d.rendering.blender_render import RenderOptions, render_mesh
154
+
155
+ prof = profile_values(run.settings)
156
+ options = RenderOptions(
157
+ width=prof.render_width,
158
+ height=prof.render_height,
159
+ samples=prof.render_samples,
160
+ device="gpu" if wants_gpu(run.settings) else "cpu",
161
+ )
162
+ try:
163
+ result = run.step(
164
+ "render",
165
+ lambda: render_mesh(
166
+ mesh, run.out / "render.png", run.settings, options, overwrite=overwrite
167
+ ),
168
+ )
169
+ except DependencyMissingError as exc:
170
+ run.warnings.append(f"Render skipped: {exc.message} {exc.hint or ''}".strip())
171
+ return
172
+ run.outputs["render"] = result.files[0]
173
+ run.details["render"] = result.to_dict()
174
+
175
+
176
+ def _measure(run: _Run, mesh: Path) -> MeshStats:
177
+ from reverse3d.reverse_eng.measure import measure_mesh_file
178
+
179
+ stats = run.step("measure", lambda: measure_mesh_file(mesh, run.settings))
180
+ run.details["measure"] = stats.to_dict()
181
+ return stats
182
+
183
+
184
+ # ─── recipes ──────────────────────────────────────────────────────────────────
185
+
186
+
187
+ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
188
+ from reverse3d.reconstruction.photogrammetry import PhotogrammetryOptions, run_photogrammetry
189
+ from reverse3d.vision.feature_matcher import analyze_image_set
190
+
191
+ analysis = run.step("analyze", lambda: analyze_image_set(src, run.settings))
192
+ run.details["analysis"] = analysis.to_dict()
193
+ if not analysis.suitable_for_photogrammetry:
194
+ if not opt.force_analysis:
195
+ raise CaptureQualityError(
196
+ "The photo set is not suitable for photogrammetry: "
197
+ + " ".join(analysis.recommendations),
198
+ hint="Improve the capture, or pass force_analysis=true to try anyway.",
199
+ )
200
+ run.warnings.extend(analysis.recommendations)
201
+
202
+ quality = opt.quality or profile_values(run.settings).photo_quality
203
+ photo = run.step(
204
+ "photogrammetry",
205
+ lambda: run_photogrammetry(
206
+ src,
207
+ run.out / "reconstruction",
208
+ run.settings,
209
+ PhotogrammetryOptions(quality=quality, use_gpu=wants_gpu(run.settings)), # type: ignore[arg-type]
210
+ overwrite=opt.overwrite,
211
+ ),
212
+ )
213
+ run.warnings.extend(photo.warnings)
214
+ run.details["photogrammetry"] = photo.to_dict()
215
+ run.outputs["point_cloud"] = photo.dense_points_path or photo.sparse_points_path
216
+ if not photo.mesh_path:
217
+ raise ProcessingError(
218
+ "Point cloud created but no mesh could be built.",
219
+ hint='Install the meshing extra: pip install "reverse3d[recon]" (Python ≤ 3.12).',
220
+ )
221
+ model = run.out / "model.stl"
222
+ _clean(
223
+ run,
224
+ Path(photo.mesh_path),
225
+ model,
226
+ scale=opt.scale,
227
+ overwrite=opt.overwrite,
228
+ keep_largest=True,
229
+ repair=opt.repair,
230
+ )
231
+ return _measure(run, model)
232
+
233
+
234
+ def _plan_to_part(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
235
+ from reverse3d.vision.mask_extractor import extract_mask, silhouette_to_mesh
236
+
237
+ mask = run.step(
238
+ "mask",
239
+ lambda: extract_mask(
240
+ src,
241
+ run.out / "mask",
242
+ run.settings,
243
+ method=opt.mask_method,
244
+ units_per_pixel=opt.units_per_pixel,
245
+ overwrite=opt.overwrite,
246
+ ),
247
+ )
248
+ run.warnings.extend(mask.warnings)
249
+ run.outputs["contours_svg"] = mask.svg_path
250
+ if opt.units_per_pixel is None:
251
+ run.warnings.append("No units_per_pixel given: sizes are in pixels.")
252
+ raw = run.out / "extruded.stl"
253
+ run.step(
254
+ "extrude",
255
+ lambda: silhouette_to_mesh(
256
+ mask.contours_path, raw, run.settings, thickness=opt.thickness, overwrite=opt.overwrite
257
+ ),
258
+ )
259
+ model = run.out / "model.stl"
260
+ _clean(run, raw, model, scale=opt.scale, overwrite=opt.overwrite)
261
+ return _measure(run, model)
262
+
263
+
264
+ def _scan_to_cad(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
265
+ from reverse3d.reverse_eng.geometry_fit import fit_primitives_file
266
+ from reverse3d.reverse_eng.parametric import export_parametric
267
+
268
+ model = run.out / "model.stl"
269
+ _clean(run, src, model, scale=opt.scale, overwrite=opt.overwrite, repair=opt.repair)
270
+ stats = _measure(run, model)
271
+ samples = profile_values(run.settings).fit_samples
272
+ fit = run.step("fit", lambda: fit_primitives_file(model, run.settings, sample_count=samples))
273
+ run.details["primitives"] = fit.to_dict()
274
+ run.details["primitive_summary"] = [
275
+ {
276
+ "kind": p.kind,
277
+ **{k: v for k, v in p.params.items() if k in ("radius", "height", "normal")},
278
+ }
279
+ for p in fit.primitives
280
+ ]
281
+ if fit.primitives:
282
+ run.outputs["cad_openscad"] = str(
283
+ export_parametric(fit, run.out / "cad.scad", overwrite=opt.overwrite)
284
+ )
285
+ run.outputs["cad_cadquery"] = str(
286
+ export_parametric(fit, run.out / "cad.py", overwrite=opt.overwrite)
287
+ )
288
+ else:
289
+ run.warnings.append("No primitives detected; no CAD code generated.")
290
+ return stats
291
+
292
+
293
+ def _photo_to_relief(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
294
+ from reverse3d.reconstruction.depth_estimation import estimate_depth
295
+
296
+ depth = run.step(
297
+ "depth",
298
+ lambda: estimate_depth(
299
+ src,
300
+ run.out / "depth",
301
+ run.settings,
302
+ device=torch_device(run.settings),
303
+ stride=profile_values(run.settings).depth_stride,
304
+ overwrite=opt.overwrite,
305
+ ),
306
+ )
307
+ run.details["depth"] = depth.to_dict()
308
+ run.warnings.append("Monocular depth is relative: shape is approximate, not metric.")
309
+ if depth.mesh_path is None: # pragma: no cover - make_mesh is always on here
310
+ raise ProcessingError("Depth estimation produced no mesh.")
311
+ model = run.out / "model.stl"
312
+ _clean(
313
+ run,
314
+ Path(depth.mesh_path),
315
+ model,
316
+ scale=opt.scale,
317
+ overwrite=opt.overwrite,
318
+ fill_holes=False,
319
+ align="none",
320
+ keep_largest=True,
321
+ )
322
+ return _measure(run, model)
323
+
324
+
325
+ def _mesh_report(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
326
+ run.outputs["model"] = str(src)
327
+ return _measure(run, src)
328
+
329
+
330
+ _RECIPES: dict[str, Callable[[_Run, Path, RecipeOptions], MeshStats]] = {
331
+ "photos_to_model": _photos_to_model,
332
+ "plan_to_part": _plan_to_part,
333
+ "scan_to_cad": _scan_to_cad,
334
+ "photo_to_relief": _photo_to_relief,
335
+ "mesh_report": _mesh_report,
336
+ }
337
+
338
+
339
+ def run_recipe(
340
+ recipe: str,
341
+ input_path: str | Path,
342
+ output_dir: str | Path,
343
+ settings: Settings,
344
+ options: RecipeOptions | None = None,
345
+ ) -> dict[str, Any]:
346
+ """Run a recipe and return a compact summary (full report in report.json).
347
+
348
+ Raises:
349
+ InputValidationError: unknown recipe or invalid options.
350
+ Reverse3DError: any step failure (the error names the failing step).
351
+ """
352
+ if recipe == "auto":
353
+ from reverse3d.autopilot import auto_run
354
+
355
+ return auto_run(input_path, output_dir, settings, options)
356
+ if recipe not in _RECIPES:
357
+ raise InputValidationError(
358
+ f"Unknown recipe '{recipe}'.", hint=f"Use one of: {', '.join(RECIPES)}"
359
+ )
360
+ opt = options or RecipeOptions()
361
+ if not 0 < opt.thickness <= 1e6:
362
+ raise InputValidationError("thickness must be in (0, 1e6].")
363
+ if not 1e-9 < opt.scale < 1e9:
364
+ raise InputValidationError("scale must be a positive factor.")
365
+
366
+ out = prepare_output_dir(output_dir)
367
+ run = _Run(recipe=recipe, settings=settings, out=out)
368
+ start = time.perf_counter()
369
+ stats = _RECIPES[recipe](run, Path(input_path), opt)
370
+ if opt.render:
371
+ _render(run, Path(run.outputs["model"]), opt.overwrite)
372
+
373
+ summary: dict[str, Any] = {
374
+ "recipe": recipe,
375
+ "outputs": run.outputs,
376
+ "dimensions": _summary_dims(stats),
377
+ "device": "gpu" if wants_gpu(settings) else "cpu",
378
+ "profile": settings.profile,
379
+ "seconds": round(time.perf_counter() - start, 1),
380
+ "steps": [f"{s['step']}:{s['s']}s" for s in run.steps],
381
+ "warnings": run.warnings,
382
+ }
383
+ if "primitive_summary" in run.details:
384
+ summary["primitives"] = run.details["primitive_summary"]
385
+
386
+ report_path = out / "report.json"
387
+ if report_path.exists() and not opt.overwrite:
388
+ report_path = out / f"report_{int(time.time())}.json"
389
+ report_path.write_text(
390
+ json.dumps({**summary, "details": run.details}, indent=2, default=str), encoding="utf-8"
391
+ )
392
+ summary["outputs"]["report"] = str(report_path)
393
+ logger.info("Recipe %s finished in %.1fs", recipe, summary["seconds"])
394
+ return summary
@@ -0,0 +1,22 @@
1
+ """3D reconstruction: COLMAP photogrammetry, monocular depth and point-cloud meshing.
2
+
3
+ Heavy optional imports (Open3D, PyTorch) happen inside the functions that need
4
+ them, so importing this package is always cheap.
5
+ """
6
+
7
+ from reverse3d.reconstruction.depth_estimation import depth_to_mesh, estimate_depth
8
+ from reverse3d.reconstruction.photogrammetry import (
9
+ ColmapPipeline,
10
+ PhotogrammetryOptions,
11
+ run_photogrammetry,
12
+ )
13
+ from reverse3d.reconstruction.pointcloud import poisson_mesh
14
+
15
+ __all__ = [
16
+ "ColmapPipeline",
17
+ "PhotogrammetryOptions",
18
+ "depth_to_mesh",
19
+ "estimate_depth",
20
+ "poisson_mesh",
21
+ "run_photogrammetry",
22
+ ]
@@ -0,0 +1,263 @@
1
+ """Monocular depth estimation (feed-forward, single image) and depth → mesh.
2
+
3
+ Inference uses Hugging Face ``transformers`` (optional ``[depth]`` extra).
4
+
5
+ Supply-chain controls (OWASP LLM03 / ASI04):
6
+
7
+ * Only models in :data:`ALLOWED_MODELS` can be loaded — an agent cannot make the
8
+ toolkit download arbitrary repositories.
9
+ * ``trust_remote_code`` is never enabled, so no repository Python code runs.
10
+ * Weights are loaded with ``use_safetensors=True``: pickle-based ``.bin``
11
+ checkpoints (arbitrary code execution on load) are refused.
12
+ * Models are loaded from ``~/.reverse3d/models`` (see :mod:`reverse3d.offline`):
13
+ once downloaded, depth estimation works without internet.
14
+
15
+ The geometric part (:func:`depth_to_mesh`) is pure NumPy/trimesh and works
16
+ without the extra, e.g. with depth maps from other sources.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import math
23
+ from dataclasses import asdict, dataclass
24
+ from pathlib import Path
25
+ from typing import Any, Literal
26
+
27
+ import cv2
28
+ import numpy as np
29
+ import trimesh
30
+
31
+ from reverse3d.config import Settings
32
+ from reverse3d.errors import DependencyMissingError, InputValidationError
33
+ from reverse3d.imaging import load_image, save_image
34
+ from reverse3d.mesh_io import save_mesh
35
+ from reverse3d.security import prepare_output_dir, prepare_output_file
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ DepthMode = Literal["disparity", "depth"]
40
+
41
+ #: model id → (output semantics, license note). Depth Anything V2 predicts
42
+ #: relative inverse depth (disparity).
43
+ ALLOWED_MODELS: dict[str, tuple[DepthMode, str]] = {
44
+ "depth-anything/Depth-Anything-V2-Small-hf": ("disparity", "Apache-2.0"),
45
+ "depth-anything/Depth-Anything-V2-Base-hf": ("disparity", "CC-BY-NC-4.0 (non-commercial)"),
46
+ "depth-anything/Depth-Anything-V2-Large-hf": ("disparity", "CC-BY-NC-4.0 (non-commercial)"),
47
+ }
48
+ DEFAULT_MODEL = "depth-anything/Depth-Anything-V2-Small-hf"
49
+
50
+
51
+ @dataclass
52
+ class DepthResult:
53
+ """Outputs of :func:`estimate_depth`."""
54
+
55
+ depth_png_path: str
56
+ depth_npy_path: str
57
+ mesh_path: str | None
58
+ model: str
59
+ model_license: str
60
+ width: int
61
+ height: int
62
+ mesh_faces: int | None
63
+
64
+ def to_dict(self) -> dict[str, object]:
65
+ return asdict(self)
66
+
67
+
68
+ def _load_pipeline(model: str, device: str, settings: Settings | None = None) -> Any:
69
+ try:
70
+ from transformers import pipeline
71
+ except ImportError as exc:
72
+ raise DependencyMissingError(
73
+ "transformers/torch are required for depth estimation.",
74
+ hint='pip install "reverse3d[depth]"',
75
+ ) from exc
76
+ from reverse3d.offline import force_offline_env, is_offline, resolve_model
77
+
78
+ settings = settings or Settings.load()
79
+ local_path = resolve_model(model, settings)
80
+ if is_offline(settings):
81
+ force_offline_env()
82
+ try: # keep CLI/agent output clean: no download/loading progress bars
83
+ from transformers.utils import logging as hf_logging
84
+
85
+ hf_logging.disable_progress_bar()
86
+ hf_logging.set_verbosity_error()
87
+ except ImportError: # pragma: no cover - very old transformers
88
+ pass
89
+ return pipeline(
90
+ task="depth-estimation",
91
+ model=str(local_path),
92
+ device=device,
93
+ trust_remote_code=False,
94
+ model_kwargs={"use_safetensors": True},
95
+ )
96
+
97
+
98
+ def predict_depth(
99
+ rgb: np.ndarray,
100
+ model: str = DEFAULT_MODEL,
101
+ device: str = "cpu",
102
+ settings: Settings | None = None,
103
+ ) -> np.ndarray:
104
+ """Run the depth model on an RGB uint8 image and return a float32 map of the
105
+ same height/width (relative units, semantics per :data:`ALLOWED_MODELS`)."""
106
+ if model not in ALLOWED_MODELS:
107
+ raise InputValidationError(
108
+ f"Model '{model}' is not in the allowlist.",
109
+ hint=f"Allowed: {', '.join(ALLOWED_MODELS)}",
110
+ )
111
+ if device not in {"cpu", "cuda", "mps"}:
112
+ raise InputValidationError("device must be cpu, cuda or mps.")
113
+ from PIL import Image
114
+
115
+ pipe = _load_pipeline(model, device, settings)
116
+ output = pipe(Image.fromarray(rgb))
117
+ predicted = output["predicted_depth"]
118
+ depth = np.asarray(predicted.squeeze().detach().cpu().numpy(), dtype=np.float32)
119
+ h, w = rgb.shape[:2]
120
+ if depth.shape != (h, w):
121
+ depth = cv2.resize(depth, (w, h), interpolation=cv2.INTER_CUBIC)
122
+ return depth
123
+
124
+
125
+ def _normalize(values: np.ndarray) -> np.ndarray:
126
+ lo, hi = np.percentile(values, [1, 99])
127
+ if hi - lo < 1e-9:
128
+ return np.zeros_like(values, dtype=np.float64)
129
+ return np.clip((values - lo) / (hi - lo), 0.0, 1.0)
130
+
131
+
132
+ def depth_to_mesh(
133
+ depth: np.ndarray,
134
+ rgb: np.ndarray | None = None,
135
+ *,
136
+ mode: DepthMode = "disparity",
137
+ fov_deg: float = 60.0,
138
+ near: float = 1.0,
139
+ far: float = 5.0,
140
+ stride: int = 2,
141
+ max_depth_jump: float = 0.08,
142
+ mask: np.ndarray | None = None,
143
+ ) -> trimesh.Trimesh:
144
+ """Back-project a (relative) depth map into a triangulated height-field mesh.
145
+
146
+ Relative predictions are mapped to the metric-like range ``[near, far]``.
147
+ Triangles spanning a depth discontinuity larger than ``max_depth_jump``
148
+ (relative to the range) are dropped to avoid "rubber sheet" artifacts
149
+ between foreground and background. Camera looks down -Z (OpenGL convention).
150
+
151
+ Args:
152
+ depth: HxW array.
153
+ rgb: optional HxWx3 uint8 colors (RGB order).
154
+ mask: optional HxW boolean/uint8 array; only masked pixels are meshed.
155
+ """
156
+ if depth.ndim != 2:
157
+ raise InputValidationError("depth must be a 2D array.")
158
+ if not 10 <= fov_deg <= 170:
159
+ raise InputValidationError("fov_deg must be in [10, 170].")
160
+ if not 0 < near < far:
161
+ raise InputValidationError("Require 0 < near < far.")
162
+ if not 1 <= stride <= 32:
163
+ raise InputValidationError("stride must be in [1, 32].")
164
+
165
+ d = _normalize(depth[::stride, ::stride].astype(np.float64))
166
+ if mode == "disparity":
167
+ # Interpolate in inverse depth: disparity 1 → near, 0 → far.
168
+ z = 1.0 / (d * (1.0 / near - 1.0 / far) + 1.0 / far)
169
+ else:
170
+ z = near + d * (far - near)
171
+
172
+ h, w = z.shape
173
+ full_w = depth.shape[1]
174
+ f = (full_w / 2.0) / math.tan(math.radians(fov_deg) / 2.0) / stride
175
+ u, v = np.meshgrid(np.arange(w, dtype=np.float64), np.arange(h, dtype=np.float64))
176
+ x = (u - w / 2.0) * z / f
177
+ y = -(v - h / 2.0) * z / f
178
+ vertices = np.stack([x, y, -z], axis=-1).reshape(-1, 3)
179
+
180
+ idx = np.arange(h * w).reshape(h, w)
181
+ a, b = idx[:-1, :-1].ravel(), idx[:-1, 1:].ravel()
182
+ c, e = idx[1:, :-1].ravel(), idx[1:, 1:].ravel()
183
+ faces = np.concatenate([np.stack([a, c, b], 1), np.stack([b, c, e], 1)])
184
+
185
+ zf = z.ravel()[faces]
186
+ keep = (zf.max(axis=1) - zf.min(axis=1)) <= max_depth_jump * (far - near)
187
+ if mask is not None:
188
+ m = np.asarray(mask)[::stride, ::stride].astype(bool).ravel()
189
+ keep &= m[faces].all(axis=1)
190
+ faces = faces[keep]
191
+ if len(faces) == 0:
192
+ raise InputValidationError("No triangles left after filtering; relax max_depth_jump.")
193
+
194
+ colors = None
195
+ if rgb is not None:
196
+ small = rgb[::stride, ::stride][:h, :w]
197
+ colors = np.concatenate(
198
+ [small.reshape(-1, 3), np.full((h * w, 1), 255, dtype=np.uint8)], axis=1
199
+ )
200
+ mesh = trimesh.Trimesh(vertices=vertices, faces=faces, vertex_colors=colors, process=False)
201
+ mesh.remove_unreferenced_vertices()
202
+ return mesh
203
+
204
+
205
+ def estimate_depth(
206
+ image_path: str | Path,
207
+ output_dir: str | Path,
208
+ settings: Settings,
209
+ *,
210
+ model: str = DEFAULT_MODEL,
211
+ device: str = "cpu",
212
+ make_mesh: bool = True,
213
+ fov_deg: float = 60.0,
214
+ stride: int = 2,
215
+ mask_path: str | Path | None = None,
216
+ overwrite: bool = False,
217
+ ) -> DepthResult:
218
+ """Estimate depth for one image and optionally build a 2.5D mesh.
219
+
220
+ Outputs in ``output_dir``: ``<stem>_depth.png`` (16-bit visualisation),
221
+ ``<stem>_depth.npy`` (raw float32) and ``<stem>_depth_mesh.ply``.
222
+ """
223
+ bgr = load_image(image_path, settings)
224
+ rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
225
+ mask = None
226
+ if mask_path is not None:
227
+ mask = load_image(mask_path, settings, flags=cv2.IMREAD_GRAYSCALE) > 127
228
+ if mask.shape != rgb.shape[:2]:
229
+ raise InputValidationError("Mask size must match the image size.")
230
+
231
+ depth = predict_depth(rgb, model=model, device=device, settings=settings)
232
+ out_dir = prepare_output_dir(output_dir)
233
+ stem = Path(image_path).stem
234
+
235
+ png = save_image(
236
+ (_normalize(depth) * 65535).astype(np.uint16),
237
+ out_dir / f"{stem}_depth.png",
238
+ overwrite=overwrite,
239
+ )
240
+ npy = prepare_output_file(
241
+ out_dir / f"{stem}_depth.npy", allowed_extensions={".npy"}, overwrite=overwrite
242
+ )
243
+ np.save(npy, depth.astype(np.float32), allow_pickle=False)
244
+
245
+ mesh_path: str | None = None
246
+ faces: int | None = None
247
+ if make_mesh:
248
+ mode, _ = ALLOWED_MODELS[model]
249
+ mesh = depth_to_mesh(depth, rgb, mode=mode, fov_deg=fov_deg, stride=stride, mask=mask)
250
+ mesh_path = str(save_mesh(mesh, out_dir / f"{stem}_depth_mesh.ply", overwrite=overwrite))
251
+ faces = len(mesh.faces)
252
+
253
+ h, w = depth.shape
254
+ return DepthResult(
255
+ depth_png_path=str(png),
256
+ depth_npy_path=str(npy),
257
+ mesh_path=mesh_path,
258
+ model=model,
259
+ model_license=ALLOWED_MODELS[model][1],
260
+ width=w,
261
+ height=h,
262
+ mesh_faces=faces,
263
+ )