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
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
"""Photogrammetry pipeline built on the COLMAP command-line interface.
|
|
2
|
+
|
|
3
|
+
Stages::
|
|
4
|
+
|
|
5
|
+
stage images → feature_extractor → (exhaustive|sequential)_matcher → mapper
|
|
6
|
+
→ model_analyzer (pick largest model) → model_converter (sparse PLY)
|
|
7
|
+
→ [dense: image_undistorter → patch_match_stereo → stereo_fusion] (CUDA)
|
|
8
|
+
→ [mesh: Poisson via Open3D] ([recon])
|
|
9
|
+
|
|
10
|
+
Design notes:
|
|
11
|
+
|
|
12
|
+
* COLMAP option names changed across releases (e.g. ``SiftExtraction.use_gpu``
|
|
13
|
+
became ``FeatureExtraction.use_gpu`` in 3.12). Instead of pinning a version,
|
|
14
|
+
each stage probes ``<command> -h`` once and uses whichever option exists.
|
|
15
|
+
* Input images are validated and **copied** into ``<output>/images`` so COLMAP
|
|
16
|
+
only ever reads files that passed the allowlist and size checks.
|
|
17
|
+
* Dense MVS requires CUDA. With ``dense="auto"`` a dense failure degrades
|
|
18
|
+
gracefully to the sparse cloud and is reported as a warning.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
import re
|
|
25
|
+
import shutil
|
|
26
|
+
import time
|
|
27
|
+
from dataclasses import asdict, dataclass, field, replace
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Literal
|
|
30
|
+
|
|
31
|
+
from reverse3d.config import Settings
|
|
32
|
+
from reverse3d.errors import (
|
|
33
|
+
DependencyMissingError,
|
|
34
|
+
ExternalToolError,
|
|
35
|
+
InputValidationError,
|
|
36
|
+
ProcessingError,
|
|
37
|
+
)
|
|
38
|
+
from reverse3d.security import (
|
|
39
|
+
list_images,
|
|
40
|
+
prepare_output_dir,
|
|
41
|
+
resolve_executable,
|
|
42
|
+
run_command,
|
|
43
|
+
validate_input_dir,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
Quality = Literal["low", "medium", "high"]
|
|
49
|
+
MatcherKind = Literal["auto", "exhaustive", "sequential"]
|
|
50
|
+
DenseMode = Literal["auto", "on", "off"]
|
|
51
|
+
|
|
52
|
+
CAMERA_MODELS = frozenset(
|
|
53
|
+
{"SIMPLE_PINHOLE", "PINHOLE", "SIMPLE_RADIAL", "RADIAL", "OPENCV", "FULL_OPENCV"}
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
_PRESETS: dict[str, dict[str, int]] = {
|
|
57
|
+
"low": {"max_image_size": 1000, "max_features": 4096, "poisson_depth": 8},
|
|
58
|
+
"medium": {"max_image_size": 1600, "max_features": 8192, "poisson_depth": 9},
|
|
59
|
+
"high": {"max_image_size": 3200, "max_features": 16384, "poisson_depth": 10},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Above this many images exhaustive matching (O(n²)) becomes impractical.
|
|
63
|
+
_EXHAUSTIVE_LIMIT = 150
|
|
64
|
+
|
|
65
|
+
# Names of the artifacts this pipeline creates; only these are removed on overwrite.
|
|
66
|
+
_ARTIFACTS = ("database.db", "images", "sparse", "dense", "sparse_points.ply", "mesh.ply")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class PhotogrammetryOptions:
|
|
71
|
+
"""User-facing options of :meth:`ColmapPipeline.run`."""
|
|
72
|
+
|
|
73
|
+
quality: Quality = "medium"
|
|
74
|
+
matcher: MatcherKind = "auto"
|
|
75
|
+
dense: DenseMode = "auto"
|
|
76
|
+
use_gpu: bool = False
|
|
77
|
+
camera_model: str = "SIMPLE_RADIAL"
|
|
78
|
+
single_camera: bool = True
|
|
79
|
+
build_mesh: bool = True
|
|
80
|
+
|
|
81
|
+
def validate(self) -> None:
|
|
82
|
+
if self.quality not in _PRESETS:
|
|
83
|
+
raise InputValidationError(f"quality must be one of {sorted(_PRESETS)}")
|
|
84
|
+
if self.matcher not in ("auto", "exhaustive", "sequential"):
|
|
85
|
+
raise InputValidationError("matcher must be auto, exhaustive or sequential")
|
|
86
|
+
if self.dense not in ("auto", "on", "off"):
|
|
87
|
+
raise InputValidationError("dense must be auto, on or off")
|
|
88
|
+
if self.camera_model not in CAMERA_MODELS:
|
|
89
|
+
raise InputValidationError(f"camera_model must be one of {sorted(CAMERA_MODELS)}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class StageTiming:
|
|
94
|
+
name: str
|
|
95
|
+
seconds: float
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class PhotogrammetryResult:
|
|
100
|
+
"""Outputs of a photogrammetry run (paths are absolute strings)."""
|
|
101
|
+
|
|
102
|
+
output_dir: str
|
|
103
|
+
sparse_model_dir: str
|
|
104
|
+
sparse_points_path: str
|
|
105
|
+
dense_points_path: str | None
|
|
106
|
+
mesh_path: str | None
|
|
107
|
+
total_images: int
|
|
108
|
+
registered_images: int
|
|
109
|
+
sparse_points: int
|
|
110
|
+
stages: list[StageTiming] = field(default_factory=list)
|
|
111
|
+
warnings: list[str] = field(default_factory=list)
|
|
112
|
+
|
|
113
|
+
def to_dict(self) -> dict[str, object]:
|
|
114
|
+
return asdict(self)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class ColmapPipeline:
|
|
118
|
+
"""Thin, safe wrapper around the ``colmap`` executable."""
|
|
119
|
+
|
|
120
|
+
def __init__(self, settings: Settings, executable: Path | None = None) -> None:
|
|
121
|
+
self.settings = settings
|
|
122
|
+
self.executable = executable or resolve_executable(
|
|
123
|
+
["colmap", "COLMAP", "colmap.exe", "COLMAP.bat"], settings.colmap_path, tool="colmap"
|
|
124
|
+
)
|
|
125
|
+
self._help_cache: dict[str, str] = {}
|
|
126
|
+
|
|
127
|
+
# ── helpers ───────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
def _help(self, command: str) -> str:
|
|
130
|
+
if command not in self._help_cache:
|
|
131
|
+
proc = run_command(
|
|
132
|
+
self.executable, [command, "-h"], timeout_s=60, tool="colmap", allow_nonzero=True
|
|
133
|
+
)
|
|
134
|
+
self._help_cache[command] = (proc.stdout or "") + (proc.stderr or "")
|
|
135
|
+
return self._help_cache[command]
|
|
136
|
+
|
|
137
|
+
def _pick_option(self, command: str, candidates: tuple[str, ...]) -> str | None:
|
|
138
|
+
help_text = self._help(command)
|
|
139
|
+
for option in candidates:
|
|
140
|
+
if f"--{option}" in help_text:
|
|
141
|
+
return option
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
def _with_options(
|
|
145
|
+
self, command: str, args: list[str], options: dict[tuple[str, ...], str]
|
|
146
|
+
) -> list[str]:
|
|
147
|
+
for candidates, value in options.items():
|
|
148
|
+
name = self._pick_option(command, candidates)
|
|
149
|
+
if name:
|
|
150
|
+
args += [f"--{name}", value]
|
|
151
|
+
else:
|
|
152
|
+
logger.debug("COLMAP %s: none of %s supported, skipping", command, candidates)
|
|
153
|
+
return args
|
|
154
|
+
|
|
155
|
+
def _stage(self, result: PhotogrammetryResult, name: str, args: list[str]) -> str:
|
|
156
|
+
start = time.perf_counter()
|
|
157
|
+
logger.info("COLMAP stage: %s", name)
|
|
158
|
+
proc = run_command(
|
|
159
|
+
self.executable,
|
|
160
|
+
args,
|
|
161
|
+
timeout_s=self.settings.subprocess_timeout_s,
|
|
162
|
+
tool=f"colmap {name}",
|
|
163
|
+
)
|
|
164
|
+
result.stages.append(StageTiming(name, round(time.perf_counter() - start, 2)))
|
|
165
|
+
return (proc.stdout or "") + (proc.stderr or "")
|
|
166
|
+
|
|
167
|
+
def version(self) -> str:
|
|
168
|
+
"""Return the COLMAP version banner (first line of ``colmap help``)."""
|
|
169
|
+
proc = run_command(
|
|
170
|
+
self.executable, ["help"], timeout_s=60, tool="colmap", allow_nonzero=True
|
|
171
|
+
)
|
|
172
|
+
text = (proc.stdout or proc.stderr or "").strip()
|
|
173
|
+
return text.splitlines()[0] if text else "unknown"
|
|
174
|
+
|
|
175
|
+
def has_cuda(self) -> bool:
|
|
176
|
+
"""False only when COLMAP's banner states it was built ``without CUDA``."""
|
|
177
|
+
proc = run_command(
|
|
178
|
+
self.executable, ["help"], timeout_s=60, tool="colmap", allow_nonzero=True
|
|
179
|
+
)
|
|
180
|
+
return "without cuda" not in ((proc.stdout or "") + (proc.stderr or "")).lower()
|
|
181
|
+
|
|
182
|
+
# ── pipeline ──────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
def _prepare_workspace(self, image_dir: Path, output_dir: Path, overwrite: bool) -> list[Path]:
|
|
185
|
+
images = list_images(
|
|
186
|
+
image_dir, max_images=self.settings.max_images, max_bytes=self.settings.max_input_bytes
|
|
187
|
+
)
|
|
188
|
+
if len(images) < 3:
|
|
189
|
+
raise InputValidationError("Photogrammetry needs at least 3 images.")
|
|
190
|
+
existing = [name for name in _ARTIFACTS if (output_dir / name).exists()]
|
|
191
|
+
if existing and not overwrite:
|
|
192
|
+
raise InputValidationError(
|
|
193
|
+
"Output directory already contains a reconstruction.",
|
|
194
|
+
hint="Use another output_dir or pass overwrite=true.",
|
|
195
|
+
)
|
|
196
|
+
for name in existing:
|
|
197
|
+
target = output_dir / name
|
|
198
|
+
if target.is_dir():
|
|
199
|
+
shutil.rmtree(target)
|
|
200
|
+
else:
|
|
201
|
+
target.unlink()
|
|
202
|
+
staged = output_dir / "images"
|
|
203
|
+
staged.mkdir()
|
|
204
|
+
for img in images:
|
|
205
|
+
shutil.copy2(img, staged / img.name)
|
|
206
|
+
return images
|
|
207
|
+
|
|
208
|
+
@staticmethod
|
|
209
|
+
def _parse_model_stats(text: str) -> tuple[int, int]:
|
|
210
|
+
def grab(label: str) -> int:
|
|
211
|
+
match = re.search(rf"{label}\s*:\s*(\d+)", text)
|
|
212
|
+
return int(match.group(1)) if match else 0
|
|
213
|
+
|
|
214
|
+
return grab("Registered images"), grab("Points")
|
|
215
|
+
|
|
216
|
+
def run(
|
|
217
|
+
self,
|
|
218
|
+
image_dir: str | Path,
|
|
219
|
+
output_dir: str | Path,
|
|
220
|
+
options: PhotogrammetryOptions | None = None,
|
|
221
|
+
*,
|
|
222
|
+
overwrite: bool = False,
|
|
223
|
+
) -> PhotogrammetryResult:
|
|
224
|
+
"""Run the full pipeline and return paths and statistics.
|
|
225
|
+
|
|
226
|
+
Raises:
|
|
227
|
+
InputValidationError: invalid options or image folder.
|
|
228
|
+
ExternalToolError: a COLMAP stage failed.
|
|
229
|
+
ProcessingError: no model could be reconstructed.
|
|
230
|
+
"""
|
|
231
|
+
options = options or PhotogrammetryOptions()
|
|
232
|
+
options.validate()
|
|
233
|
+
preset = _PRESETS[options.quality]
|
|
234
|
+
src = validate_input_dir(image_dir)
|
|
235
|
+
out = prepare_output_dir(output_dir)
|
|
236
|
+
# Overwrite deletes our artifact folders; never let that touch the source photos.
|
|
237
|
+
if any(src.is_relative_to(out / name) for name in _ARTIFACTS):
|
|
238
|
+
raise InputValidationError(
|
|
239
|
+
"image_dir must not be inside the reconstruction artifacts of output_dir."
|
|
240
|
+
)
|
|
241
|
+
images = self._prepare_workspace(src, out, overwrite)
|
|
242
|
+
|
|
243
|
+
db = out / "database.db"
|
|
244
|
+
staged = out / "images"
|
|
245
|
+
sparse_root = out / "sparse"
|
|
246
|
+
sparse_root.mkdir()
|
|
247
|
+
result = PhotogrammetryResult(
|
|
248
|
+
output_dir=str(out),
|
|
249
|
+
sparse_model_dir="",
|
|
250
|
+
sparse_points_path="",
|
|
251
|
+
dense_points_path=None,
|
|
252
|
+
mesh_path=None,
|
|
253
|
+
total_images=len(images),
|
|
254
|
+
registered_images=0,
|
|
255
|
+
sparse_points=0,
|
|
256
|
+
)
|
|
257
|
+
if options.use_gpu and not self.has_cuda():
|
|
258
|
+
# A CPU-only COLMAP build fails (or uses headless-incompatible OpenGL)
|
|
259
|
+
# with use_gpu=1, so degrade instead of failing the whole job.
|
|
260
|
+
options = replace(options, use_gpu=False)
|
|
261
|
+
result.warnings.append("COLMAP was built without CUDA; running on CPU.")
|
|
262
|
+
gpu = "1" if options.use_gpu else "0"
|
|
263
|
+
|
|
264
|
+
# 1. Features
|
|
265
|
+
args = [
|
|
266
|
+
"feature_extractor",
|
|
267
|
+
"--database_path",
|
|
268
|
+
str(db),
|
|
269
|
+
"--image_path",
|
|
270
|
+
str(staged),
|
|
271
|
+
"--ImageReader.camera_model",
|
|
272
|
+
options.camera_model,
|
|
273
|
+
"--ImageReader.single_camera",
|
|
274
|
+
"1" if options.single_camera else "0",
|
|
275
|
+
]
|
|
276
|
+
self._stage(
|
|
277
|
+
result,
|
|
278
|
+
"feature_extractor",
|
|
279
|
+
self._with_options(
|
|
280
|
+
"feature_extractor",
|
|
281
|
+
args,
|
|
282
|
+
{
|
|
283
|
+
("FeatureExtraction.use_gpu", "SiftExtraction.use_gpu"): gpu,
|
|
284
|
+
("FeatureExtraction.max_image_size", "SiftExtraction.max_image_size"): str(
|
|
285
|
+
preset["max_image_size"]
|
|
286
|
+
),
|
|
287
|
+
("SiftExtraction.max_num_features",): str(preset["max_features"]),
|
|
288
|
+
},
|
|
289
|
+
),
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# 2. Matching
|
|
293
|
+
matcher = options.matcher
|
|
294
|
+
if matcher == "auto":
|
|
295
|
+
matcher = "exhaustive" if len(images) <= _EXHAUSTIVE_LIMIT else "sequential"
|
|
296
|
+
command = f"{matcher}_matcher"
|
|
297
|
+
self._stage(
|
|
298
|
+
result,
|
|
299
|
+
command,
|
|
300
|
+
self._with_options(
|
|
301
|
+
command,
|
|
302
|
+
[command, "--database_path", str(db)],
|
|
303
|
+
{("FeatureMatching.use_gpu", "SiftMatching.use_gpu"): gpu},
|
|
304
|
+
),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# 3. Sparse reconstruction (SfM)
|
|
308
|
+
self._stage(
|
|
309
|
+
result,
|
|
310
|
+
"mapper",
|
|
311
|
+
[
|
|
312
|
+
"mapper",
|
|
313
|
+
"--database_path",
|
|
314
|
+
str(db),
|
|
315
|
+
"--image_path",
|
|
316
|
+
str(staged),
|
|
317
|
+
"--output_path",
|
|
318
|
+
str(sparse_root),
|
|
319
|
+
],
|
|
320
|
+
)
|
|
321
|
+
models = sorted(p for p in sparse_root.iterdir() if p.is_dir())
|
|
322
|
+
if not models:
|
|
323
|
+
raise ProcessingError(
|
|
324
|
+
"COLMAP could not register the images into a 3D model.",
|
|
325
|
+
hint="Run the image analysis tool: more overlap, texture and sharpness are needed.",
|
|
326
|
+
)
|
|
327
|
+
best, best_stats = models[0], (0, 0)
|
|
328
|
+
for model in models:
|
|
329
|
+
stats = self._parse_model_stats(
|
|
330
|
+
self._stage(
|
|
331
|
+
result, f"model_analyzer:{model.name}", ["model_analyzer", "--path", str(model)]
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
if stats[0] > best_stats[0]:
|
|
335
|
+
best, best_stats = model, stats
|
|
336
|
+
result.sparse_model_dir = str(best)
|
|
337
|
+
result.registered_images, result.sparse_points = best_stats
|
|
338
|
+
if result.registered_images and result.registered_images < len(images) * 0.5:
|
|
339
|
+
result.warnings.append(
|
|
340
|
+
f"Only {result.registered_images}/{len(images)} images were registered."
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
sparse_ply = out / "sparse_points.ply"
|
|
344
|
+
self._stage(
|
|
345
|
+
result,
|
|
346
|
+
"model_converter",
|
|
347
|
+
[
|
|
348
|
+
"model_converter",
|
|
349
|
+
"--input_path",
|
|
350
|
+
str(best),
|
|
351
|
+
"--output_path",
|
|
352
|
+
str(sparse_ply),
|
|
353
|
+
"--output_type",
|
|
354
|
+
"PLY",
|
|
355
|
+
],
|
|
356
|
+
)
|
|
357
|
+
result.sparse_points_path = str(sparse_ply)
|
|
358
|
+
|
|
359
|
+
# 4. Dense reconstruction (MVS) — CUDA only
|
|
360
|
+
run_dense = options.dense == "on" or (options.dense == "auto" and options.use_gpu)
|
|
361
|
+
if run_dense:
|
|
362
|
+
try:
|
|
363
|
+
result.dense_points_path = str(self._dense(result, staged, best, out, preset))
|
|
364
|
+
except ExternalToolError as exc:
|
|
365
|
+
if options.dense == "on":
|
|
366
|
+
raise
|
|
367
|
+
result.warnings.append(f"Dense stage skipped ({exc.message}); using sparse cloud.")
|
|
368
|
+
elif options.dense == "auto":
|
|
369
|
+
result.warnings.append("Dense MVS skipped (requires use_gpu=true and CUDA COLMAP).")
|
|
370
|
+
|
|
371
|
+
# 5. Surface
|
|
372
|
+
if options.build_mesh:
|
|
373
|
+
from reverse3d.reconstruction.pointcloud import poisson_mesh
|
|
374
|
+
|
|
375
|
+
cloud = result.dense_points_path or result.sparse_points_path
|
|
376
|
+
try:
|
|
377
|
+
meshing = poisson_mesh(
|
|
378
|
+
cloud,
|
|
379
|
+
out / "mesh.ply",
|
|
380
|
+
self.settings,
|
|
381
|
+
depth=preset["poisson_depth"],
|
|
382
|
+
overwrite=True,
|
|
383
|
+
)
|
|
384
|
+
result.mesh_path = meshing.mesh_path
|
|
385
|
+
except (DependencyMissingError, ProcessingError) as exc:
|
|
386
|
+
result.warnings.append(f"Meshing skipped: {exc.message}")
|
|
387
|
+
|
|
388
|
+
logger.info(
|
|
389
|
+
"Photogrammetry done: %d/%d images registered", result.registered_images, len(images)
|
|
390
|
+
)
|
|
391
|
+
return result
|
|
392
|
+
|
|
393
|
+
def _dense(
|
|
394
|
+
self,
|
|
395
|
+
result: PhotogrammetryResult,
|
|
396
|
+
staged: Path,
|
|
397
|
+
model: Path,
|
|
398
|
+
out: Path,
|
|
399
|
+
preset: dict[str, int],
|
|
400
|
+
) -> Path:
|
|
401
|
+
dense = out / "dense"
|
|
402
|
+
self._stage(
|
|
403
|
+
result,
|
|
404
|
+
"image_undistorter",
|
|
405
|
+
[
|
|
406
|
+
"image_undistorter",
|
|
407
|
+
"--image_path",
|
|
408
|
+
str(staged),
|
|
409
|
+
"--input_path",
|
|
410
|
+
str(model),
|
|
411
|
+
"--output_path",
|
|
412
|
+
str(dense),
|
|
413
|
+
"--output_type",
|
|
414
|
+
"COLMAP",
|
|
415
|
+
"--max_image_size",
|
|
416
|
+
str(preset["max_image_size"]),
|
|
417
|
+
],
|
|
418
|
+
)
|
|
419
|
+
self._stage(
|
|
420
|
+
result,
|
|
421
|
+
"patch_match_stereo",
|
|
422
|
+
[
|
|
423
|
+
"patch_match_stereo",
|
|
424
|
+
"--workspace_path",
|
|
425
|
+
str(dense),
|
|
426
|
+
"--workspace_format",
|
|
427
|
+
"COLMAP",
|
|
428
|
+
"--PatchMatchStereo.geom_consistency",
|
|
429
|
+
"true",
|
|
430
|
+
],
|
|
431
|
+
)
|
|
432
|
+
fused = dense / "fused.ply"
|
|
433
|
+
self._stage(
|
|
434
|
+
result,
|
|
435
|
+
"stereo_fusion",
|
|
436
|
+
[
|
|
437
|
+
"stereo_fusion",
|
|
438
|
+
"--workspace_path",
|
|
439
|
+
str(dense),
|
|
440
|
+
"--workspace_format",
|
|
441
|
+
"COLMAP",
|
|
442
|
+
"--input_type",
|
|
443
|
+
"geometric",
|
|
444
|
+
"--output_path",
|
|
445
|
+
str(fused),
|
|
446
|
+
],
|
|
447
|
+
)
|
|
448
|
+
return fused
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def run_photogrammetry(
|
|
452
|
+
image_dir: str | Path,
|
|
453
|
+
output_dir: str | Path,
|
|
454
|
+
settings: Settings,
|
|
455
|
+
options: PhotogrammetryOptions | None = None,
|
|
456
|
+
*,
|
|
457
|
+
overwrite: bool = False,
|
|
458
|
+
) -> PhotogrammetryResult:
|
|
459
|
+
"""Convenience wrapper: locate COLMAP and run :class:`ColmapPipeline`."""
|
|
460
|
+
return ColmapPipeline(settings).run(image_dir, output_dir, options, overwrite=overwrite)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Point cloud → surface meshing with Open3D (optional ``[recon]`` extra)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from reverse3d.config import Settings
|
|
13
|
+
from reverse3d.errors import DependencyMissingError, InputValidationError, ProcessingError
|
|
14
|
+
from reverse3d.security import (
|
|
15
|
+
MESH_EXTENSIONS,
|
|
16
|
+
POINT_CLOUD_EXTENSIONS,
|
|
17
|
+
prepare_output_file,
|
|
18
|
+
validate_input_file,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def require_open3d() -> Any:
|
|
25
|
+
"""Import Open3D or raise a helpful :class:`DependencyMissingError`."""
|
|
26
|
+
try:
|
|
27
|
+
import open3d as o3d
|
|
28
|
+
except ImportError as exc:
|
|
29
|
+
raise DependencyMissingError(
|
|
30
|
+
"Open3D is required for point-cloud meshing.",
|
|
31
|
+
hint='pip install "reverse3d[recon]" (Open3D wheels exist for Python 3.10-3.12).',
|
|
32
|
+
) from exc
|
|
33
|
+
return o3d
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class MeshingResult:
|
|
38
|
+
"""Outputs of :func:`poisson_mesh`."""
|
|
39
|
+
|
|
40
|
+
mesh_path: str
|
|
41
|
+
input_points: int
|
|
42
|
+
filtered_points: int
|
|
43
|
+
vertices: int
|
|
44
|
+
faces: int
|
|
45
|
+
poisson_depth: int
|
|
46
|
+
|
|
47
|
+
def to_dict(self) -> dict[str, object]:
|
|
48
|
+
return asdict(self)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def poisson_mesh(
|
|
52
|
+
cloud_path: str | Path,
|
|
53
|
+
output_path: str | Path,
|
|
54
|
+
settings: Settings,
|
|
55
|
+
*,
|
|
56
|
+
depth: int = 9,
|
|
57
|
+
voxel_size: float | None = None,
|
|
58
|
+
density_quantile: float = 0.02,
|
|
59
|
+
outlier_std_ratio: float = 2.0,
|
|
60
|
+
overwrite: bool = False,
|
|
61
|
+
) -> MeshingResult:
|
|
62
|
+
"""Reconstruct a watertight-ish surface from a point cloud (screened Poisson).
|
|
63
|
+
|
|
64
|
+
Steps: optional voxel downsampling → statistical outlier removal → normal
|
|
65
|
+
estimation and consistent orientation → Poisson → removal of low-density
|
|
66
|
+
vertices (the "balloon" Poisson extrapolates into empty space) → crop to the
|
|
67
|
+
input bounding box.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
DependencyMissingError: Open3D not installed.
|
|
71
|
+
ProcessingError: too few points or empty result.
|
|
72
|
+
"""
|
|
73
|
+
if not 5 <= depth <= 12:
|
|
74
|
+
raise InputValidationError("depth must be in [5, 12].")
|
|
75
|
+
if not 0 <= density_quantile < 0.5:
|
|
76
|
+
raise InputValidationError("density_quantile must be in [0, 0.5).")
|
|
77
|
+
o3d = require_open3d()
|
|
78
|
+
|
|
79
|
+
src = validate_input_file(
|
|
80
|
+
cloud_path, allowed_extensions=POINT_CLOUD_EXTENSIONS, max_bytes=settings.max_input_bytes
|
|
81
|
+
)
|
|
82
|
+
out = prepare_output_file(output_path, allowed_extensions=MESH_EXTENSIONS, overwrite=overwrite)
|
|
83
|
+
|
|
84
|
+
pcd = o3d.io.read_point_cloud(str(src))
|
|
85
|
+
n_input = len(pcd.points)
|
|
86
|
+
if n_input < 100:
|
|
87
|
+
raise ProcessingError(f"Point cloud too small for meshing ({n_input} points).")
|
|
88
|
+
|
|
89
|
+
if voxel_size:
|
|
90
|
+
pcd = pcd.voxel_down_sample(voxel_size)
|
|
91
|
+
pcd, _ = pcd.remove_statistical_outlier(nb_neighbors=20, std_ratio=outlier_std_ratio)
|
|
92
|
+
extent = float(np.linalg.norm(pcd.get_axis_aligned_bounding_box().get_extent()))
|
|
93
|
+
radius = max(extent * 0.01, 1e-6)
|
|
94
|
+
if not pcd.has_normals():
|
|
95
|
+
pcd.estimate_normals(o3d.geometry.KDTreeSearchParamHybrid(radius=radius * 3, max_nn=30))
|
|
96
|
+
pcd.orient_normals_consistent_tangent_plane(15)
|
|
97
|
+
|
|
98
|
+
mesh, densities = o3d.geometry.TriangleMesh.create_from_point_cloud_poisson(pcd, depth=depth)
|
|
99
|
+
densities = np.asarray(densities)
|
|
100
|
+
if density_quantile > 0 and len(densities):
|
|
101
|
+
mesh.remove_vertices_by_mask(densities < np.quantile(densities, density_quantile))
|
|
102
|
+
mesh = mesh.crop(pcd.get_axis_aligned_bounding_box())
|
|
103
|
+
mesh.remove_degenerate_triangles()
|
|
104
|
+
mesh.remove_unreferenced_vertices()
|
|
105
|
+
if len(mesh.triangles) == 0:
|
|
106
|
+
raise ProcessingError("Poisson reconstruction produced an empty mesh.")
|
|
107
|
+
if len(mesh.triangles) > settings.max_mesh_faces:
|
|
108
|
+
mesh = mesh.simplify_quadric_decimation(settings.max_mesh_faces)
|
|
109
|
+
|
|
110
|
+
if not o3d.io.write_triangle_mesh(str(out), mesh):
|
|
111
|
+
raise ProcessingError(f"Open3D could not write {out.name}")
|
|
112
|
+
logger.info("Poisson mesh %s: %d faces", out.name, len(mesh.triangles))
|
|
113
|
+
return MeshingResult(
|
|
114
|
+
mesh_path=str(out),
|
|
115
|
+
input_points=n_input,
|
|
116
|
+
filtered_points=len(pcd.points),
|
|
117
|
+
vertices=len(mesh.vertices),
|
|
118
|
+
faces=len(mesh.triangles),
|
|
119
|
+
poisson_depth=depth,
|
|
120
|
+
)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Automated rendering: headless Blender (Cycles/Eevee/Workbench) or PyVista."""
|
|
2
|
+
|
|
3
|
+
from reverse3d.rendering.blender_render import (
|
|
4
|
+
RenderOptions,
|
|
5
|
+
RenderResult,
|
|
6
|
+
render_mesh,
|
|
7
|
+
render_with_blender,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = ["RenderOptions", "RenderResult", "render_mesh", "render_with_blender"]
|