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.
- reverse3d/__init__.py +39 -0
- reverse3d/__main__.py +4 -0
- reverse3d/_about.py +15 -0
- reverse3d/_version.py +1 -0
- reverse3d/agent/__init__.py +5 -0
- reverse3d/agent/compact.py +110 -0
- reverse3d/agent/mcp_server.py +171 -0
- reverse3d/agent/prompts.py +173 -0
- reverse3d/agent/skill/SKILL.md +53 -0
- reverse3d/agent/tools.py +811 -0
- reverse3d/autopilot.py +355 -0
- reverse3d/cli.py +1054 -0
- reverse3d/config.py +167 -0
- reverse3d/doctor.py +113 -0
- reverse3d/errors.py +83 -0
- reverse3d/hardware.py +115 -0
- reverse3d/imaging.py +79 -0
- reverse3d/logging_utils.py +41 -0
- reverse3d/mesh_io.py +152 -0
- reverse3d/offline.py +313 -0
- reverse3d/profiles.py +33 -0
- reverse3d/py.typed +0 -0
- reverse3d/recipes.py +394 -0
- reverse3d/reconstruction/__init__.py +22 -0
- reverse3d/reconstruction/depth_estimation.py +263 -0
- reverse3d/reconstruction/photogrammetry.py +460 -0
- reverse3d/reconstruction/pointcloud.py +120 -0
- reverse3d/rendering/__init__.py +10 -0
- reverse3d/rendering/_blender_job.py +284 -0
- reverse3d/rendering/blender_render.py +215 -0
- reverse3d/rendering/preview.py +80 -0
- reverse3d/reverse_eng/__init__.py +33 -0
- reverse3d/reverse_eng/geometry_fit.py +478 -0
- reverse3d/reverse_eng/measure.py +87 -0
- reverse3d/reverse_eng/mesh_cleaner.py +304 -0
- reverse3d/reverse_eng/parametric.py +154 -0
- reverse3d/security.py +294 -0
- reverse3d/setup_agents.py +517 -0
- reverse3d/userconfig.py +156 -0
- reverse3d/vision/__init__.py +12 -0
- reverse3d/vision/feature_matcher.py +272 -0
- reverse3d/vision/mask_extractor.py +352 -0
- reverse3d/watch.py +238 -0
- reverse3d-0.4.0.dist-info/METADATA +650 -0
- reverse3d-0.4.0.dist-info/RECORD +50 -0
- reverse3d-0.4.0.dist-info/WHEEL +5 -0
- reverse3d-0.4.0.dist-info/entry_points.txt +2 -0
- reverse3d-0.4.0.dist-info/licenses/LICENSE +202 -0
- reverse3d-0.4.0.dist-info/licenses/NOTICE +27 -0
- 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
|
+
)
|