reverse3d 0.5.2__tar.gz → 0.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {reverse3d-0.5.2/reverse3d.egg-info → reverse3d-0.7.0}/PKG-INFO +2 -1
- {reverse3d-0.5.2 → reverse3d-0.7.0}/pyproject.toml +1 -0
- reverse3d-0.7.0/reverse3d/_version.py +1 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/offline.py +9 -4
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/recipes.py +68 -25
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/reconstruction/photogrammetry.py +29 -1
- reverse3d-0.7.0/reverse3d/reconstruction/silhouette.py +376 -0
- reverse3d-0.7.0/reverse3d/vision/segmenter.py +136 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0/reverse3d.egg-info}/PKG-INFO +2 -1
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d.egg-info/SOURCES.txt +3 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d.egg-info/requires.txt +1 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/tests/test_automation.py +5 -1
- {reverse3d-0.5.2 → reverse3d-0.7.0}/tests/test_offline_autopilot.py +2 -1
- {reverse3d-0.5.2 → reverse3d-0.7.0}/tests/test_reconstruction.py +4 -1
- reverse3d-0.7.0/tests/test_silhouette.py +158 -0
- reverse3d-0.5.2/reverse3d/_version.py +0 -1
- {reverse3d-0.5.2 → reverse3d-0.7.0}/LICENSE +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/NOTICE +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/README.md +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/__init__.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/__main__.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/_about.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/agent/__init__.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/agent/compact.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/agent/mcp_server.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/agent/prompts.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/agent/skill/SKILL.md +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/agent/tools.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/autopilot.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/cli.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/config.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/doctor.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/errors.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/hardware.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/imaging.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/logging_utils.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/mesh_io.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/profiles.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/py.typed +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/reconstruction/__init__.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/reconstruction/depth_estimation.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/reconstruction/pointcloud.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/rendering/__init__.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/rendering/_blender_job.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/rendering/blender_render.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/rendering/preview.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/reverse_eng/__init__.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/reverse_eng/geometry_fit.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/reverse_eng/measure.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/reverse_eng/mesh_cleaner.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/reverse_eng/parametric.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/scenes.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/security.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/setup_agents.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/userconfig.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/vision/__init__.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/vision/feature_matcher.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/vision/mask_extractor.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/watch.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d.egg-info/dependency_links.txt +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d.egg-info/entry_points.txt +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d.egg-info/top_level.txt +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/setup.cfg +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/tests/test_agent_tools.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/tests/test_authorship.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/tests/test_cli.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/tests/test_cli_automation.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/tests/test_config.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/tests/test_geometry_fit.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/tests/test_heic.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/tests/test_mesh.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/tests/test_optional_extras.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/tests/test_render.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/tests/test_scenes.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.0}/tests/test_security.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.7.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.7.0
|
|
4
4
|
Summary: AI-agent-ready toolkit for 3D reverse engineering: image analysis, photogrammetry, mesh repair, primitive fitting and headless rendering.
|
|
5
5
|
Author-email: Voxel Enterprise <info@voxel.lat>
|
|
6
6
|
Maintainer-email: Voxel Enterprise <info@voxel.lat>
|
|
@@ -31,6 +31,7 @@ License-File: LICENSE
|
|
|
31
31
|
License-File: NOTICE
|
|
32
32
|
Requires-Dist: numpy>=1.26
|
|
33
33
|
Requires-Dist: scipy>=1.11
|
|
34
|
+
Requires-Dist: scikit-image>=0.22
|
|
34
35
|
Requires-Dist: opencv-python-headless>=4.9
|
|
35
36
|
Requires-Dist: pillow>=10.3
|
|
36
37
|
Requires-Dist: pillow-heif>=1.1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.7.0"
|
|
@@ -54,6 +54,7 @@ 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",
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
|
|
@@ -100,8 +101,9 @@ def is_offline(settings: Settings) -> bool:
|
|
|
100
101
|
|
|
101
102
|
def _allowed_models() -> dict[str, tuple[str, str]]:
|
|
102
103
|
from reverse3d.reconstruction.depth_estimation import ALLOWED_MODELS
|
|
104
|
+
from reverse3d.vision.segmenter import ALLOWED_SEGMENTERS
|
|
103
105
|
|
|
104
|
-
return
|
|
106
|
+
return {**ALLOWED_MODELS, **ALLOWED_SEGMENTERS}
|
|
105
107
|
|
|
106
108
|
|
|
107
109
|
def _default_model() -> str:
|
|
@@ -177,7 +179,7 @@ def resolve_model(model_id: str, settings: Settings) -> Path:
|
|
|
177
179
|
return local
|
|
178
180
|
if is_offline(settings):
|
|
179
181
|
raise DependencyMissingError(
|
|
180
|
-
"The AI
|
|
182
|
+
"The AI model is not on this computer and there is no internet connection.",
|
|
181
183
|
hint="Connect to the internet once and run: reverse3d offline prepare",
|
|
182
184
|
)
|
|
183
185
|
try:
|
|
@@ -190,7 +192,7 @@ def resolve_model(model_id: str, settings: Settings) -> Path:
|
|
|
190
192
|
except Exception as exc: # network errors come in many shapes; report them plainly
|
|
191
193
|
has_internet(refresh=True)
|
|
192
194
|
raise DependencyMissingError(
|
|
193
|
-
f"Could not download the AI
|
|
195
|
+
f"Could not download the AI model ({exc.__class__.__name__}).",
|
|
194
196
|
hint="Check your connection and run: reverse3d offline prepare",
|
|
195
197
|
) from exc
|
|
196
198
|
|
|
@@ -201,7 +203,10 @@ def prepare(settings: Settings, *, include_non_commercial: bool = False) -> dict
|
|
|
201
203
|
Safe to run any time: models already on disk are skipped. It never raises
|
|
202
204
|
for a missing optional stack; that is reported in the result instead.
|
|
203
205
|
"""
|
|
204
|
-
|
|
206
|
+
from reverse3d.vision.segmenter import DEFAULT_SEGMENTER
|
|
207
|
+
|
|
208
|
+
# Depth (reliefs) and segmentation (object silhouettes), both commercial-friendly.
|
|
209
|
+
models = [_default_model(), DEFAULT_SEGMENTER]
|
|
205
210
|
if include_non_commercial:
|
|
206
211
|
models += [m for m in _allowed_models() if m not in models]
|
|
207
212
|
|
|
@@ -24,7 +24,6 @@ from __future__ import annotations
|
|
|
24
24
|
import json
|
|
25
25
|
import logging
|
|
26
26
|
import math
|
|
27
|
-
import shutil
|
|
28
27
|
import time
|
|
29
28
|
from collections.abc import Callable
|
|
30
29
|
from dataclasses import dataclass, field
|
|
@@ -37,6 +36,7 @@ from reverse3d.errors import (
|
|
|
37
36
|
DependencyMissingError,
|
|
38
37
|
InputValidationError,
|
|
39
38
|
ProcessingError,
|
|
39
|
+
Reverse3DError,
|
|
40
40
|
)
|
|
41
41
|
from reverse3d.hardware import torch_device, wants_gpu
|
|
42
42
|
from reverse3d.profiles import profile_values
|
|
@@ -195,7 +195,11 @@ _MIN_GROUP = 8
|
|
|
195
195
|
|
|
196
196
|
|
|
197
197
|
def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
|
|
198
|
-
from reverse3d.reconstruction.photogrammetry import
|
|
198
|
+
from reverse3d.reconstruction.photogrammetry import (
|
|
199
|
+
PhotogrammetryOptions,
|
|
200
|
+
PhotogrammetryResult,
|
|
201
|
+
run_photogrammetry,
|
|
202
|
+
)
|
|
199
203
|
from reverse3d.scenes import SCENE_LABEL_ES, SCENE_PRESETS, detect_scene
|
|
200
204
|
from reverse3d.vision.feature_matcher import analyze_image_set
|
|
201
205
|
|
|
@@ -219,21 +223,9 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
|
|
|
219
223
|
analysis = run.step("analyze", lambda: analyze_image_set(src, run.settings))
|
|
220
224
|
run.details["analysis"] = analysis.to_dict()
|
|
221
225
|
if not analysis.suitable_for_photogrammetry and analysis.connected_images >= _MIN_GROUP:
|
|
222
|
-
#
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
group_dir.mkdir(parents=True, exist_ok=True)
|
|
226
|
-
for image in sorted(src.iterdir()):
|
|
227
|
-
if image.is_file() and image.name not in dropped and not image.name.startswith("."):
|
|
228
|
-
target = group_dir / image.name
|
|
229
|
-
if not target.exists():
|
|
230
|
-
shutil.copy2(image, target)
|
|
231
|
-
run.warnings.append(
|
|
232
|
-
f"Used the {analysis.connected_images} photos that overlap; skipped "
|
|
233
|
-
f"{len(dropped)} that do not match the rest."
|
|
234
|
-
)
|
|
235
|
-
run.details["photos_used"] = analysis.connected_images
|
|
236
|
-
src = group_dir
|
|
226
|
+
# COLMAP places cameras far better than this quick check (it often uses
|
|
227
|
+
# the whole room), so every photo goes in; the check is only advice.
|
|
228
|
+
run.warnings.extend(analysis.recommendations)
|
|
237
229
|
elif not analysis.suitable_for_photogrammetry:
|
|
238
230
|
if not opt.force_analysis:
|
|
239
231
|
raise CaptureQualityError(
|
|
@@ -244,26 +236,76 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
|
|
|
244
236
|
run.warnings.extend(analysis.recommendations)
|
|
245
237
|
|
|
246
238
|
quality = opt.quality or preset.quality or profile_values(run.settings).photo_quality
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
239
|
+
# Objects and characters: COLMAP only places the cameras (fast, no dense stage)
|
|
240
|
+
# and the shape is carved from the silhouettes, which also works on plain,
|
|
241
|
+
# white or glossy surfaces. Open scenes keep classic dense reconstruction.
|
|
242
|
+
silhouette = scene in ("object", "character")
|
|
243
|
+
|
|
244
|
+
def photogrammetry(classic: bool) -> PhotogrammetryResult:
|
|
245
|
+
return run_photogrammetry(
|
|
250
246
|
src,
|
|
251
247
|
run.out / "reconstruction",
|
|
252
248
|
run.settings,
|
|
253
249
|
PhotogrammetryOptions(
|
|
254
250
|
quality=quality, # type: ignore[arg-type]
|
|
255
251
|
matcher=preset.matcher,
|
|
252
|
+
dense="auto" if classic else "off",
|
|
256
253
|
use_gpu=wants_gpu(run.settings),
|
|
254
|
+
build_mesh=classic,
|
|
257
255
|
poisson_depth=preset.poisson_depth,
|
|
258
256
|
density_quantile=preset.density_quantile,
|
|
259
257
|
),
|
|
260
|
-
overwrite=opt.overwrite,
|
|
261
|
-
)
|
|
262
|
-
|
|
258
|
+
overwrite=opt.overwrite or not classic,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
photo = run.step("photogrammetry", lambda: photogrammetry(classic=not silhouette))
|
|
262
|
+
mesh_path: str | None = photo.mesh_path
|
|
263
|
+
upright = False
|
|
264
|
+
if silhouette:
|
|
265
|
+
from reverse3d.reconstruction.silhouette import visual_hull
|
|
266
|
+
from reverse3d.vision.segmenter import ObjectSegmenter
|
|
267
|
+
|
|
268
|
+
def load_segmenter() -> ObjectSegmenter | None:
|
|
269
|
+
# The AI segmenter keeps dark caps, handles and thin parts; without it
|
|
270
|
+
# (no AI extra, or offline before the first download) GrabCut is used.
|
|
271
|
+
try:
|
|
272
|
+
return ObjectSegmenter(run.settings, device=torch_device(run.settings))
|
|
273
|
+
except Reverse3DError as exc:
|
|
274
|
+
logger.info("AI segmentation unavailable (%s); using GrabCut", exc.message)
|
|
275
|
+
run.warnings.append(
|
|
276
|
+
"AI segmentation unavailable: thin or dark parts may be missing."
|
|
277
|
+
)
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
hull = run.step(
|
|
282
|
+
"silhouette",
|
|
283
|
+
lambda: visual_hull(
|
|
284
|
+
Path(photo.images_dir or ""),
|
|
285
|
+
Path(photo.sparse_text_dir or ""),
|
|
286
|
+
run.out / "reconstruction" / "object_hull.ply",
|
|
287
|
+
segmenter=load_segmenter,
|
|
288
|
+
),
|
|
289
|
+
)
|
|
290
|
+
except ProcessingError as exc:
|
|
291
|
+
logger.info("Silhouette carving failed (%s); using dense reconstruction", exc.message)
|
|
292
|
+
run.warnings.append(
|
|
293
|
+
f"Silhouette carving failed ({exc.message}); used dense reconstruction."
|
|
294
|
+
)
|
|
295
|
+
photo = run.step("photogrammetry", lambda: photogrammetry(classic=True))
|
|
296
|
+
mesh_path = photo.mesh_path
|
|
297
|
+
else:
|
|
298
|
+
run.details["silhouette"] = hull.to_dict()
|
|
299
|
+
mesh_path = hull.mesh_path
|
|
300
|
+
upright = True
|
|
301
|
+
if photo.registered_images >= 0.9 * max(photo.total_images, 1):
|
|
302
|
+
# COLMAP placed (almost) every photo: the quick pre-check's capture advice
|
|
303
|
+
# would only confuse.
|
|
304
|
+
run.warnings[:] = [w for w in run.warnings if w not in analysis.recommendations]
|
|
263
305
|
run.warnings.extend(photo.warnings)
|
|
264
306
|
run.details["photogrammetry"] = photo.to_dict()
|
|
265
307
|
run.outputs["point_cloud"] = photo.dense_points_path or photo.sparse_points_path
|
|
266
|
-
if not
|
|
308
|
+
if not mesh_path:
|
|
267
309
|
raise ProcessingError(
|
|
268
310
|
"Point cloud created but no mesh could be built.",
|
|
269
311
|
hint='Install the meshing extra: pip install "reverse3d[recon]" (Python ≤ 3.12).',
|
|
@@ -271,11 +313,12 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
|
|
|
271
313
|
model = run.out / "model.stl"
|
|
272
314
|
_clean(
|
|
273
315
|
run,
|
|
274
|
-
Path(
|
|
316
|
+
Path(mesh_path),
|
|
275
317
|
model,
|
|
276
318
|
scale=opt.scale,
|
|
277
319
|
overwrite=opt.overwrite,
|
|
278
320
|
fill_holes=preset.fill_holes,
|
|
321
|
+
align="none" if upright else "obb",
|
|
279
322
|
keep_largest=preset.keep_largest,
|
|
280
323
|
repair=opt.repair,
|
|
281
324
|
)
|
|
@@ -68,7 +68,15 @@ _MIN_SPARSE_POINTS = 1000
|
|
|
68
68
|
_EXHAUSTIVE_LIMIT = 150
|
|
69
69
|
|
|
70
70
|
# Names of the artifacts this pipeline creates; only these are removed on overwrite.
|
|
71
|
-
_ARTIFACTS = (
|
|
71
|
+
_ARTIFACTS = (
|
|
72
|
+
"database.db",
|
|
73
|
+
"images",
|
|
74
|
+
"sparse",
|
|
75
|
+
"sparse_txt",
|
|
76
|
+
"dense",
|
|
77
|
+
"sparse_points.ply",
|
|
78
|
+
"mesh.ply",
|
|
79
|
+
)
|
|
72
80
|
|
|
73
81
|
|
|
74
82
|
@dataclass
|
|
@@ -122,6 +130,9 @@ class PhotogrammetryResult:
|
|
|
122
130
|
sparse_points: int
|
|
123
131
|
stages: list[StageTiming] = field(default_factory=list)
|
|
124
132
|
warnings: list[str] = field(default_factory=list)
|
|
133
|
+
#: the photos as COLMAP saw them, and the chosen model as text (silhouette carving).
|
|
134
|
+
images_dir: str | None = None
|
|
135
|
+
sparse_text_dir: str | None = None
|
|
125
136
|
|
|
126
137
|
def to_dict(self) -> dict[str, object]:
|
|
127
138
|
return asdict(self)
|
|
@@ -394,6 +405,23 @@ class ColmapPipeline:
|
|
|
394
405
|
],
|
|
395
406
|
)
|
|
396
407
|
result.sparse_points_path = str(sparse_ply)
|
|
408
|
+
result.images_dir = str(staged)
|
|
409
|
+
text_dir = out / "sparse_txt"
|
|
410
|
+
text_dir.mkdir(parents=True, exist_ok=True)
|
|
411
|
+
self._stage(
|
|
412
|
+
result,
|
|
413
|
+
"model_converter_txt",
|
|
414
|
+
[
|
|
415
|
+
"model_converter",
|
|
416
|
+
"--input_path",
|
|
417
|
+
str(best),
|
|
418
|
+
"--output_path",
|
|
419
|
+
str(text_dir),
|
|
420
|
+
"--output_type",
|
|
421
|
+
"TXT",
|
|
422
|
+
],
|
|
423
|
+
)
|
|
424
|
+
result.sparse_text_dir = str(text_dir)
|
|
397
425
|
|
|
398
426
|
# 4. Dense reconstruction (MVS) — CUDA only
|
|
399
427
|
run_dense = options.dense == "on" or (options.dense == "auto" and options.use_gpu)
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Shape from silhouettes (visual hull) for objects photographed all around.
|
|
2
|
+
|
|
3
|
+
Classic multi-view stereo needs texture on the object itself, so plain,
|
|
4
|
+
white, glossy or dark objects (a thermos, a bottle, a vase) come out as holes
|
|
5
|
+
or blobs, while the textured room around them reconstructs fine. A visual
|
|
6
|
+
hull does not need texture on the object: COLMAP only has to place the
|
|
7
|
+
cameras (the room helps with that), then the object is carved as the volume
|
|
8
|
+
that falls inside its silhouette in every photo.
|
|
9
|
+
|
|
10
|
+
Pipeline:
|
|
11
|
+
|
|
12
|
+
1. read the COLMAP model (cameras, poses, sparse points) exported as text;
|
|
13
|
+
2. the object is where the cameras' optical axes converge;
|
|
14
|
+
3. per photo, segment the object (GrabCut around its projected position);
|
|
15
|
+
4. carve a voxel grid; re-segment each photo guided by the projected hull
|
|
16
|
+
and carve again (a few rounds tighten the silhouettes);
|
|
17
|
+
5. cut the support (table) plane, keep the main part, mesh it with marching
|
|
18
|
+
cubes, smooth it and stand it upright on its base.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
from dataclasses import asdict, dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import TYPE_CHECKING
|
|
27
|
+
|
|
28
|
+
import cv2
|
|
29
|
+
import numpy as np
|
|
30
|
+
import trimesh
|
|
31
|
+
|
|
32
|
+
from reverse3d.errors import DependencyMissingError, ProcessingError
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from collections.abc import Callable
|
|
36
|
+
|
|
37
|
+
from reverse3d.vision.segmenter import ObjectSegmenter
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
_WORK_SIDE = 640
|
|
42
|
+
_MIN_VIEWS = 8
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class _Camera:
|
|
47
|
+
width: int
|
|
48
|
+
height: int
|
|
49
|
+
fx: float
|
|
50
|
+
fy: float
|
|
51
|
+
cx: float
|
|
52
|
+
cy: float
|
|
53
|
+
k: float
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class _View:
|
|
58
|
+
name: str
|
|
59
|
+
rotation: np.ndarray
|
|
60
|
+
translation: np.ndarray
|
|
61
|
+
camera: _Camera
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class HullResult:
|
|
66
|
+
mesh_path: str
|
|
67
|
+
views: int
|
|
68
|
+
voxels: int
|
|
69
|
+
faces: int
|
|
70
|
+
support_plane: bool
|
|
71
|
+
ai_segmentation: bool = False
|
|
72
|
+
|
|
73
|
+
def to_dict(self) -> dict[str, object]:
|
|
74
|
+
return asdict(self)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _rotation(q: np.ndarray) -> np.ndarray:
|
|
78
|
+
w, x, y, z = q
|
|
79
|
+
return np.array(
|
|
80
|
+
[
|
|
81
|
+
[1 - 2 * y * y - 2 * z * z, 2 * x * y - 2 * w * z, 2 * x * z + 2 * w * y],
|
|
82
|
+
[2 * x * y + 2 * w * z, 1 - 2 * x * x - 2 * z * z, 2 * y * z - 2 * w * x],
|
|
83
|
+
[2 * x * z - 2 * w * y, 2 * y * z + 2 * w * x, 1 - 2 * x * x - 2 * y * y],
|
|
84
|
+
]
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def read_colmap_text(model_dir: Path) -> tuple[list[_View], np.ndarray]:
|
|
89
|
+
"""Views (intrinsics + world→camera pose) and sparse points of a TXT model."""
|
|
90
|
+
cameras: dict[int, _Camera] = {}
|
|
91
|
+
for line in (model_dir / "cameras.txt").read_text(encoding="utf-8").splitlines():
|
|
92
|
+
if not line or line.startswith("#"):
|
|
93
|
+
continue
|
|
94
|
+
p = line.split()
|
|
95
|
+
model, params = p[1], [float(v) for v in p[4:]]
|
|
96
|
+
if model in ("SIMPLE_RADIAL", "SIMPLE_PINHOLE", "RADIAL"):
|
|
97
|
+
f, cx, cy = params[:3]
|
|
98
|
+
k = params[3] if model in ("SIMPLE_RADIAL", "RADIAL") else 0.0
|
|
99
|
+
cam = _Camera(int(p[2]), int(p[3]), f, f, cx, cy, k)
|
|
100
|
+
else: # PINHOLE, OPENCV...: fx fy cx cy [distortion ignored]
|
|
101
|
+
cam = _Camera(int(p[2]), int(p[3]), params[0], params[1], params[2], params[3], 0.0)
|
|
102
|
+
cameras[int(p[0])] = cam
|
|
103
|
+
# Each image takes two lines (header, then its 2D points, which may be empty).
|
|
104
|
+
rows = [
|
|
105
|
+
line
|
|
106
|
+
for line in (model_dir / "images.txt").read_text(encoding="utf-8").splitlines()
|
|
107
|
+
if not line.startswith("#")
|
|
108
|
+
]
|
|
109
|
+
views = []
|
|
110
|
+
i = 0
|
|
111
|
+
while i < len(rows):
|
|
112
|
+
p = rows[i].split()
|
|
113
|
+
if len(p) < 10:
|
|
114
|
+
i += 1
|
|
115
|
+
continue
|
|
116
|
+
q = np.array([float(v) for v in p[1:5]])
|
|
117
|
+
t = np.array([float(v) for v in p[5:8]])
|
|
118
|
+
views.append(_View(" ".join(p[9:]), _rotation(q), t, cameras[int(p[8])]))
|
|
119
|
+
i += 2
|
|
120
|
+
points = []
|
|
121
|
+
for line in (model_dir / "points3D.txt").read_text(encoding="utf-8").splitlines():
|
|
122
|
+
if line and not line.startswith("#"):
|
|
123
|
+
points.append([float(v) for v in line.split()[1:4]])
|
|
124
|
+
return views, np.asarray(points, dtype=np.float64).reshape(-1, 3)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _project(points: np.ndarray, view: _View) -> tuple[np.ndarray, np.ndarray]:
|
|
128
|
+
cam = view.camera
|
|
129
|
+
xc = points @ view.rotation.T + view.translation
|
|
130
|
+
z = xc[:, 2]
|
|
131
|
+
safe = np.where(np.abs(z) < 1e-9, 1e-9, z)
|
|
132
|
+
x, y = xc[:, 0] / safe, xc[:, 1] / safe
|
|
133
|
+
s = 1 + cam.k * (x * x + y * y)
|
|
134
|
+
return np.stack([cam.fx * x * s + cam.cx, cam.fy * y * s + cam.cy], axis=1), z
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def convergence_point(views: list[_View]) -> tuple[np.ndarray, float]:
|
|
138
|
+
"""Point closest to every camera's optical axis, and the median camera distance."""
|
|
139
|
+
a = np.zeros((3, 3))
|
|
140
|
+
b = np.zeros(3)
|
|
141
|
+
centers = []
|
|
142
|
+
for view in views:
|
|
143
|
+
c = -view.rotation.T @ view.translation
|
|
144
|
+
d = view.rotation.T @ np.array([0.0, 0.0, 1.0])
|
|
145
|
+
d /= np.linalg.norm(d)
|
|
146
|
+
m = np.eye(3) - np.outer(d, d)
|
|
147
|
+
a += m
|
|
148
|
+
b += m @ c
|
|
149
|
+
centers.append(c)
|
|
150
|
+
point = np.linalg.solve(a, b)
|
|
151
|
+
return point, float(np.median(np.linalg.norm(np.asarray(centers) - point, axis=1)))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _grabcut(
|
|
155
|
+
image: np.ndarray, init: np.ndarray, rect: tuple[int, int, int, int] | None
|
|
156
|
+
) -> np.ndarray:
|
|
157
|
+
bgd, fgd = np.zeros((1, 65)), np.zeros((1, 65))
|
|
158
|
+
mode = cv2.GC_INIT_WITH_RECT if rect is not None else cv2.GC_INIT_WITH_MASK
|
|
159
|
+
try:
|
|
160
|
+
cv2.grabCut(image, init, rect or (0, 0, 0, 0), bgd, fgd, 4, mode)
|
|
161
|
+
except cv2.error:
|
|
162
|
+
return np.zeros(image.shape[:2], np.uint8)
|
|
163
|
+
return np.where((init == cv2.GC_FGD) | (init == cv2.GC_PR_FGD), 255, 0).astype(np.uint8)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _support_plane(
|
|
167
|
+
points: np.ndarray, center: np.ndarray, radius: float, cameras: np.ndarray
|
|
168
|
+
) -> tuple[np.ndarray, float] | None:
|
|
169
|
+
"""The table under the object: RANSAC plane through nearby sparse points, normal up."""
|
|
170
|
+
near = points[np.linalg.norm(points - center, axis=1) < 1.2 * radius]
|
|
171
|
+
if len(near) < 30:
|
|
172
|
+
return None
|
|
173
|
+
rng = np.random.default_rng(0)
|
|
174
|
+
best: tuple[np.ndarray, float, int] | None = None
|
|
175
|
+
for _ in range(400):
|
|
176
|
+
s = near[rng.choice(len(near), 3, replace=False)]
|
|
177
|
+
n = np.cross(s[1] - s[0], s[2] - s[0])
|
|
178
|
+
norm = np.linalg.norm(n)
|
|
179
|
+
if norm < 1e-12:
|
|
180
|
+
continue
|
|
181
|
+
n /= norm
|
|
182
|
+
d = -float(n @ s[0])
|
|
183
|
+
count = int((np.abs(near @ n + d) < 0.01 * radius).sum())
|
|
184
|
+
if best is None or count > best[2]:
|
|
185
|
+
best = (n, d, count)
|
|
186
|
+
if best is None or best[2] < 20:
|
|
187
|
+
return None
|
|
188
|
+
n, d, _ = best
|
|
189
|
+
if np.median(cameras @ n + d) < 0: # cameras are above the table
|
|
190
|
+
n, d = -n, -d
|
|
191
|
+
return n, d
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def visual_hull(
|
|
195
|
+
image_dir: Path,
|
|
196
|
+
model_text_dir: Path,
|
|
197
|
+
output_path: Path,
|
|
198
|
+
*,
|
|
199
|
+
resolution: int = 176,
|
|
200
|
+
refinements: int = 3,
|
|
201
|
+
segmenter: Callable[[], ObjectSegmenter | None] | None = None,
|
|
202
|
+
) -> HullResult:
|
|
203
|
+
"""Carve the object seen by every photo; writes a watertight, upright mesh.
|
|
204
|
+
|
|
205
|
+
Raises:
|
|
206
|
+
ProcessingError: too few placed photos, or no object could be isolated.
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
from scipy import ndimage
|
|
210
|
+
from skimage.measure import marching_cubes
|
|
211
|
+
except ImportError as exc: # pragma: no cover - core dependencies
|
|
212
|
+
raise DependencyMissingError(
|
|
213
|
+
"scikit-image and scipy are needed for silhouette reconstruction.",
|
|
214
|
+
hint="pip install scikit-image scipy",
|
|
215
|
+
) from exc
|
|
216
|
+
|
|
217
|
+
views, points = read_colmap_text(model_text_dir)
|
|
218
|
+
if len(views) < _MIN_VIEWS:
|
|
219
|
+
raise ProcessingError(
|
|
220
|
+
f"Only {len(views)} photos were placed; at least {_MIN_VIEWS} are needed."
|
|
221
|
+
)
|
|
222
|
+
center, distance = convergence_point(views)
|
|
223
|
+
radius = 0.45 * distance
|
|
224
|
+
cameras = np.asarray([-v.rotation.T @ v.translation for v in views])
|
|
225
|
+
|
|
226
|
+
images: list[tuple[np.ndarray, float]] = []
|
|
227
|
+
for view in views:
|
|
228
|
+
bgr = cv2.imread(str(image_dir / view.name))
|
|
229
|
+
if bgr is None:
|
|
230
|
+
raise ProcessingError(f"Could not read {view.name} for silhouette carving.")
|
|
231
|
+
h, w = bgr.shape[:2]
|
|
232
|
+
scale = _WORK_SIDE / max(h, w)
|
|
233
|
+
images.append(
|
|
234
|
+
(
|
|
235
|
+
cv2.resize(bgr, (round(w * scale), round(h * scale)), interpolation=cv2.INTER_AREA),
|
|
236
|
+
scale,
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# First silhouettes: GrabCut in a box around where the object projects.
|
|
241
|
+
masks: list[np.ndarray] = []
|
|
242
|
+
for (small, scale), view in zip(images, views, strict=True):
|
|
243
|
+
uv, z = _project(center[None], view)
|
|
244
|
+
if z[0] <= 0:
|
|
245
|
+
masks.append(np.zeros(small.shape[:2], np.uint8))
|
|
246
|
+
continue
|
|
247
|
+
cu, cv_ = uv[0] * scale
|
|
248
|
+
half = view.camera.fx * scale * 0.6 * radius / z[0]
|
|
249
|
+
x0, y0 = max(0, int(cu - half)), max(0, int(cv_ - 1.6 * half))
|
|
250
|
+
x1 = min(small.shape[1] - 1, int(cu + half))
|
|
251
|
+
y1 = min(small.shape[0] - 1, int(cv_ + 1.6 * half))
|
|
252
|
+
init = np.zeros(small.shape[:2], np.uint8)
|
|
253
|
+
rect = (x0, y0, max(2, x1 - x0), max(2, y1 - y0))
|
|
254
|
+
fg = _grabcut(small, init, rect)
|
|
255
|
+
masks.append(cv2.morphologyEx(fg, cv2.MORPH_CLOSE, np.ones((7, 7), np.uint8)))
|
|
256
|
+
|
|
257
|
+
n = resolution
|
|
258
|
+
axis = np.linspace(-radius, radius, n)
|
|
259
|
+
gx, gy, gz = np.meshgrid(axis, axis, axis, indexing="ij")
|
|
260
|
+
grid = np.stack([gx.ravel(), gy.ravel(), gz.ravel()], axis=1) + center
|
|
261
|
+
projections = []
|
|
262
|
+
for (small, scale), view in zip(images, views, strict=True):
|
|
263
|
+
uv, z = _project(grid, view)
|
|
264
|
+
u = np.round(uv[:, 0] * scale).astype(np.int64)
|
|
265
|
+
v = np.round(uv[:, 1] * scale).astype(np.int64)
|
|
266
|
+
inside = (z > 0) & (u >= 0) & (v >= 0) & (u < small.shape[1]) & (v < small.shape[0])
|
|
267
|
+
projections.append((u, v, inside))
|
|
268
|
+
|
|
269
|
+
def carve(mask_list: list[np.ndarray], tolerance: int) -> np.ndarray:
|
|
270
|
+
votes = np.zeros(len(grid), np.int32)
|
|
271
|
+
seen = np.zeros(len(grid), np.int32)
|
|
272
|
+
for fg, (u, v, inside) in zip(mask_list, projections, strict=True):
|
|
273
|
+
seen += inside
|
|
274
|
+
hit = np.zeros(len(grid), bool)
|
|
275
|
+
hit[inside] = fg[v[inside], u[inside]] > 0
|
|
276
|
+
votes += hit
|
|
277
|
+
return (seen >= 3) & (votes >= seen - tolerance)
|
|
278
|
+
|
|
279
|
+
keep = carve(masks, 2)
|
|
280
|
+
# The AI segmenter is only loaded (or downloaded) once the photos proved usable.
|
|
281
|
+
ai = segmenter() if segmenter is not None else None
|
|
282
|
+
if ai is not None:
|
|
283
|
+
# AI segmentation: a box around the projected hull (taller upwards, where caps
|
|
284
|
+
# and handles get lost) and a point on it. Two rounds, each from the new hull.
|
|
285
|
+
states = [ai.embed(cv2.cvtColor(small, cv2.COLOR_BGR2RGB)) for small, _ in images]
|
|
286
|
+
for _ in range(2):
|
|
287
|
+
ai_masks: list[np.ndarray] = []
|
|
288
|
+
for state, (small, _), (u, v, inside), old in zip(
|
|
289
|
+
states, images, projections, masks, strict=True
|
|
290
|
+
):
|
|
291
|
+
sel = keep & inside
|
|
292
|
+
if not sel.any():
|
|
293
|
+
ai_masks.append(old)
|
|
294
|
+
continue
|
|
295
|
+
us, vs = u[sel], v[sel]
|
|
296
|
+
x0, x1 = int(us.min()), int(us.max())
|
|
297
|
+
y0, y1 = int(vs.min()), int(vs.max())
|
|
298
|
+
pad_x, pad_y = 0.15 * (x1 - x0 + 1), 0.15 * (y1 - y0 + 1)
|
|
299
|
+
box = (
|
|
300
|
+
max(0, int(x0 - pad_x)),
|
|
301
|
+
max(0, int(y0 - 2.5 * pad_y)),
|
|
302
|
+
min(small.shape[1] - 1, int(x1 + pad_x)),
|
|
303
|
+
min(small.shape[0] - 1, int(y1 + pad_y)),
|
|
304
|
+
)
|
|
305
|
+
point = (int(np.median(us)), int(np.median(vs)))
|
|
306
|
+
ai_masks.append(ai.segment(state, box, point))
|
|
307
|
+
masks = ai_masks
|
|
308
|
+
keep = carve(masks, 1)
|
|
309
|
+
refinements = 0
|
|
310
|
+
for _ in range(refinements):
|
|
311
|
+
refined: list[np.ndarray] = []
|
|
312
|
+
for (small, _), (u, v, inside), old in zip(images, projections, masks, strict=True):
|
|
313
|
+
hull: np.ndarray = np.zeros(small.shape[:2], np.uint8)
|
|
314
|
+
sel = keep & inside
|
|
315
|
+
hull[v[sel], u[sel]] = 255
|
|
316
|
+
hull = cv2.morphologyEx(hull, cv2.MORPH_CLOSE, np.ones((9, 9), np.uint8))
|
|
317
|
+
if not hull.any():
|
|
318
|
+
refined.append(old)
|
|
319
|
+
continue
|
|
320
|
+
init = np.full(small.shape[:2], cv2.GC_BGD, np.uint8)
|
|
321
|
+
init[cv2.dilate(hull, np.ones((25, 25), np.uint8)) > 0] = cv2.GC_PR_BGD
|
|
322
|
+
init[hull > 0] = cv2.GC_PR_FGD
|
|
323
|
+
init[cv2.erode(hull, np.ones((21, 21), np.uint8)) > 0] = cv2.GC_FGD
|
|
324
|
+
fg = _grabcut(small, init, None)
|
|
325
|
+
refined.append(cv2.morphologyEx(fg, cv2.MORPH_OPEN, np.ones((5, 5), np.uint8)))
|
|
326
|
+
masks = refined
|
|
327
|
+
keep = carve(masks, 1)
|
|
328
|
+
|
|
329
|
+
volume = keep.reshape(n, n, n).astype(np.float32)
|
|
330
|
+
plane = _support_plane(points, center, radius, cameras)
|
|
331
|
+
if plane is not None:
|
|
332
|
+
normal, offset = plane
|
|
333
|
+
height = (grid @ normal + offset).reshape(n, n, n)
|
|
334
|
+
volume[height < 0.015 * radius] = 0
|
|
335
|
+
labels, count = ndimage.label(volume > 0.5)
|
|
336
|
+
if count == 0:
|
|
337
|
+
raise ProcessingError("No object could be isolated in the photos.")
|
|
338
|
+
sizes = ndimage.sum(np.ones_like(labels), labels, range(1, count + 1))
|
|
339
|
+
volume = (labels == 1 + int(np.argmax(sizes))).astype(np.float32)
|
|
340
|
+
voxels = int(volume.sum())
|
|
341
|
+
if voxels < 500:
|
|
342
|
+
raise ProcessingError("The isolated object is too small to build a model.")
|
|
343
|
+
touches_border = bool(
|
|
344
|
+
volume[0].any()
|
|
345
|
+
or volume[-1].any()
|
|
346
|
+
or volume[:, 0].any()
|
|
347
|
+
or volume[:, -1].any()
|
|
348
|
+
or volume[:, :, 0].any()
|
|
349
|
+
or volume[:, :, -1].any()
|
|
350
|
+
)
|
|
351
|
+
if touches_border:
|
|
352
|
+
logger.info("The object reaches the edge of the carving volume; it may be cropped.")
|
|
353
|
+
# One empty voxel of margin all around: marching cubes then yields a closed surface.
|
|
354
|
+
volume = np.pad(ndimage.gaussian_filter(volume, 1.4), 2)
|
|
355
|
+
verts, faces, _, _ = marching_cubes(volume, 0.5)
|
|
356
|
+
verts = (verts - 2) / (n - 1) * 2 * radius - radius + center
|
|
357
|
+
mesh = trimesh.Trimesh(verts, faces, process=True)
|
|
358
|
+
trimesh.smoothing.filter_taubin(mesh, lamb=0.5, nu=-0.53, iterations=15)
|
|
359
|
+
|
|
360
|
+
# Stand it upright: +Z along the table normal (or the mean camera "up").
|
|
361
|
+
up = plane[0] if plane is not None else np.mean(cameras - center, axis=0)
|
|
362
|
+
up = up / np.linalg.norm(up)
|
|
363
|
+
side = np.cross([0.0, 1.0, 0.0] if abs(up[1]) < 0.9 else [1.0, 0.0, 0.0], up)
|
|
364
|
+
side /= np.linalg.norm(side)
|
|
365
|
+
basis = np.stack([side, np.cross(up, side), up])
|
|
366
|
+
transform = np.eye(4)
|
|
367
|
+
transform[:3, :3] = basis
|
|
368
|
+
mesh.apply_transform(transform)
|
|
369
|
+
mesh.apply_translation([-mesh.centroid[0], -mesh.centroid[1], -mesh.bounds[0][2]])
|
|
370
|
+
|
|
371
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
372
|
+
mesh.export(output_path)
|
|
373
|
+
logger.info("Silhouette model: %d views, %d faces", len(views), len(mesh.faces))
|
|
374
|
+
return HullResult(
|
|
375
|
+
str(output_path), len(views), voxels, len(mesh.faces), plane is not None, ai is not None
|
|
376
|
+
)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""AI object segmentation (Segment Anything) for silhouette carving.
|
|
2
|
+
|
|
3
|
+
GrabCut only compares colours, so a dark cap or handle in front of a dark
|
|
4
|
+
background is cut off. Segment Anything (Meta, Apache-2.0) understands what
|
|
5
|
+
"the object" is: given a box and a point on it, it returns the whole object,
|
|
6
|
+
cap and handle included.
|
|
7
|
+
|
|
8
|
+
The model is allowlisted, pinned to an exact commit and loaded from
|
|
9
|
+
safetensors only (see :mod:`reverse3d.offline`); after the first download it
|
|
10
|
+
works without internet. Everything degrades gracefully: without the AI extra
|
|
11
|
+
or the model, callers fall back to GrabCut.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from functools import lru_cache
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from reverse3d.config import Settings
|
|
23
|
+
from reverse3d.errors import DependencyMissingError
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
#: Allowlisted segmentation models: id -> (kind, license).
|
|
28
|
+
ALLOWED_SEGMENTERS: dict[str, tuple[str, str]] = {
|
|
29
|
+
"facebook/sam-vit-base": ("sam", "Apache-2.0"),
|
|
30
|
+
}
|
|
31
|
+
DEFAULT_SEGMENTER = "facebook/sam-vit-base"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def segmenter_available() -> bool:
|
|
35
|
+
"""True when the AI stack (torch + transformers) is installed."""
|
|
36
|
+
try:
|
|
37
|
+
import torch # noqa: F401
|
|
38
|
+
import transformers # noqa: F401
|
|
39
|
+
except ImportError:
|
|
40
|
+
return False
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@lru_cache(maxsize=2)
|
|
45
|
+
def _load(model_id: str, device: str, model_dir: str) -> tuple[Any, Any]:
|
|
46
|
+
from transformers import SamModel, SamProcessor
|
|
47
|
+
|
|
48
|
+
try: # keep CLI/agent output clean: no loading progress bars
|
|
49
|
+
from transformers.utils import logging as hf_logging
|
|
50
|
+
|
|
51
|
+
hf_logging.disable_progress_bar()
|
|
52
|
+
hf_logging.set_verbosity_error()
|
|
53
|
+
except ImportError: # pragma: no cover - very old transformers
|
|
54
|
+
pass
|
|
55
|
+
# model_dir is the local snapshot of a commit-pinned, allowlisted model
|
|
56
|
+
# (reverse3d.offline.resolve_model): nothing is fetched here.
|
|
57
|
+
model: Any = SamModel.from_pretrained( # nosec B615
|
|
58
|
+
model_dir, local_files_only=True, use_safetensors=True, trust_remote_code=False
|
|
59
|
+
)
|
|
60
|
+
processor = SamProcessor.from_pretrained(model_dir, local_files_only=True) # nosec B615
|
|
61
|
+
model.to(device).eval()
|
|
62
|
+
logger.info("Loaded %s on %s", model_id, device)
|
|
63
|
+
return model, processor
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ObjectSegmenter:
|
|
67
|
+
"""Segments the object inside a box (plus a point on it) in RGB images.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
DependencyMissingError: the AI extra or the model is not available.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, settings: Settings, device: str = "cpu", model_id: str = DEFAULT_SEGMENTER):
|
|
74
|
+
if not segmenter_available():
|
|
75
|
+
raise DependencyMissingError(
|
|
76
|
+
"The AI segmentation stack is not installed.",
|
|
77
|
+
hint='pip install "reverse3d[depth]"',
|
|
78
|
+
)
|
|
79
|
+
from reverse3d.offline import force_offline_env, is_offline, resolve_model
|
|
80
|
+
|
|
81
|
+
self.device = device
|
|
82
|
+
model_dir = resolve_model(model_id, settings)
|
|
83
|
+
if is_offline(settings):
|
|
84
|
+
force_offline_env()
|
|
85
|
+
self.model, self.processor = _load(model_id, device, str(model_dir))
|
|
86
|
+
|
|
87
|
+
def embed(self, rgb: np.ndarray) -> dict[str, Any]:
|
|
88
|
+
"""Run the heavy image encoder once; reuse the result for several prompts."""
|
|
89
|
+
import torch
|
|
90
|
+
|
|
91
|
+
inputs = self.processor(rgb, return_tensors="pt").to(self.device)
|
|
92
|
+
with torch.no_grad():
|
|
93
|
+
embeddings = self.model.get_image_embeddings(inputs["pixel_values"])
|
|
94
|
+
return {
|
|
95
|
+
"rgb": rgb,
|
|
96
|
+
"embeddings": embeddings,
|
|
97
|
+
"original_sizes": inputs["original_sizes"],
|
|
98
|
+
"reshaped_input_sizes": inputs["reshaped_input_sizes"],
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
def segment(
|
|
102
|
+
self,
|
|
103
|
+
image: np.ndarray | dict[str, Any],
|
|
104
|
+
box: tuple[int, int, int, int],
|
|
105
|
+
point: tuple[int, int],
|
|
106
|
+
) -> np.ndarray:
|
|
107
|
+
"""Object mask (uint8, 255 = object) for a box ``(x0, y0, x1, y1)`` and a point.
|
|
108
|
+
|
|
109
|
+
``image`` is an RGB array or the result of :meth:`embed` (much faster).
|
|
110
|
+
"""
|
|
111
|
+
import torch
|
|
112
|
+
|
|
113
|
+
state = image if isinstance(image, dict) else self.embed(image)
|
|
114
|
+
prompts = self.processor(
|
|
115
|
+
state["rgb"],
|
|
116
|
+
input_boxes=[[[float(v) for v in box]]],
|
|
117
|
+
input_points=[[[[float(point[0]), float(point[1])]]]],
|
|
118
|
+
input_labels=[[[1]]],
|
|
119
|
+
return_tensors="pt",
|
|
120
|
+
).to(self.device)
|
|
121
|
+
with torch.no_grad():
|
|
122
|
+
outputs = self.model(
|
|
123
|
+
image_embeddings=state["embeddings"],
|
|
124
|
+
input_boxes=prompts["input_boxes"],
|
|
125
|
+
input_points=prompts["input_points"],
|
|
126
|
+
input_labels=prompts["input_labels"],
|
|
127
|
+
multimask_output=True,
|
|
128
|
+
)
|
|
129
|
+
masks = self.processor.image_processor.post_process_masks(
|
|
130
|
+
outputs.pred_masks.cpu(),
|
|
131
|
+
state["original_sizes"].cpu(),
|
|
132
|
+
state["reshaped_input_sizes"].cpu(),
|
|
133
|
+
)[0][0]
|
|
134
|
+
scores = outputs.iou_scores.cpu()[0, 0]
|
|
135
|
+
best = int(torch.argmax(scores))
|
|
136
|
+
return np.where(masks[best].numpy(), 255, 0).astype(np.uint8)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: reverse3d
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: AI-agent-ready toolkit for 3D reverse engineering: image analysis, photogrammetry, mesh repair, primitive fitting and headless rendering.
|
|
5
5
|
Author-email: Voxel Enterprise <info@voxel.lat>
|
|
6
6
|
Maintainer-email: Voxel Enterprise <info@voxel.lat>
|
|
@@ -31,6 +31,7 @@ License-File: LICENSE
|
|
|
31
31
|
License-File: NOTICE
|
|
32
32
|
Requires-Dist: numpy>=1.26
|
|
33
33
|
Requires-Dist: scipy>=1.11
|
|
34
|
+
Requires-Dist: scikit-image>=0.22
|
|
34
35
|
Requires-Dist: opencv-python-headless>=4.9
|
|
35
36
|
Requires-Dist: pillow>=10.3
|
|
36
37
|
Requires-Dist: pillow-heif>=1.1
|
|
@@ -40,6 +40,7 @@ reverse3d/reconstruction/__init__.py
|
|
|
40
40
|
reverse3d/reconstruction/depth_estimation.py
|
|
41
41
|
reverse3d/reconstruction/photogrammetry.py
|
|
42
42
|
reverse3d/reconstruction/pointcloud.py
|
|
43
|
+
reverse3d/reconstruction/silhouette.py
|
|
43
44
|
reverse3d/rendering/__init__.py
|
|
44
45
|
reverse3d/rendering/_blender_job.py
|
|
45
46
|
reverse3d/rendering/blender_render.py
|
|
@@ -52,6 +53,7 @@ reverse3d/reverse_eng/parametric.py
|
|
|
52
53
|
reverse3d/vision/__init__.py
|
|
53
54
|
reverse3d/vision/feature_matcher.py
|
|
54
55
|
reverse3d/vision/mask_extractor.py
|
|
56
|
+
reverse3d/vision/segmenter.py
|
|
55
57
|
tests/test_agent_tools.py
|
|
56
58
|
tests/test_authorship.py
|
|
57
59
|
tests/test_automation.py
|
|
@@ -67,4 +69,5 @@ tests/test_reconstruction.py
|
|
|
67
69
|
tests/test_render.py
|
|
68
70
|
tests/test_scenes.py
|
|
69
71
|
tests/test_security.py
|
|
72
|
+
tests/test_silhouette.py
|
|
70
73
|
tests/test_vision.py
|
|
@@ -442,7 +442,11 @@ def test_colmap_without_cuda_degrades_to_cpu(
|
|
|
442
442
|
if args[0] == "mapper":
|
|
443
443
|
(Path(args[args.index("--output_path") + 1]) / "0").mkdir()
|
|
444
444
|
if args[0] == "model_converter":
|
|
445
|
-
Path(args[args.index("--output_path") + 1])
|
|
445
|
+
target = Path(args[args.index("--output_path") + 1])
|
|
446
|
+
if args[args.index("--output_type") + 1] == "TXT":
|
|
447
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
448
|
+
else:
|
|
449
|
+
target.write_text("ply")
|
|
446
450
|
return subprocess.CompletedProcess(args, 0, "", "Registered images: 6\nPoints: 6000")
|
|
447
451
|
|
|
448
452
|
monkeypatch.setattr(pg, "run_command", fake)
|
|
@@ -125,8 +125,9 @@ class TestModels:
|
|
|
125
125
|
|
|
126
126
|
def test_every_allowlisted_model_is_pinned(self, tmp_path: Path) -> None:
|
|
127
127
|
from reverse3d.reconstruction.depth_estimation import ALLOWED_MODELS
|
|
128
|
+
from reverse3d.vision.segmenter import ALLOWED_SEGMENTERS
|
|
128
129
|
|
|
129
|
-
assert set(ALLOWED_MODELS) == set(offline.MODEL_REVISIONS)
|
|
130
|
+
assert set(ALLOWED_MODELS) | set(ALLOWED_SEGMENTERS) == set(offline.MODEL_REVISIONS)
|
|
130
131
|
assert all(len(sha) == 40 for sha in offline.MODEL_REVISIONS.values())
|
|
131
132
|
other = tmp_path / "_r3d_home" / "models" / ("models--" + SMALL.replace("/", "--"))
|
|
132
133
|
(other / "snapshots" / "unreviewed").mkdir(parents=True)
|
|
@@ -82,7 +82,10 @@ class FakeColmap:
|
|
|
82
82
|
args, 0, "", f"Registered images: {n}\nPoints: {n * self.points_per_image}"
|
|
83
83
|
)
|
|
84
84
|
elif command == "model_converter":
|
|
85
|
-
|
|
85
|
+
if opt("--output_type") == "TXT":
|
|
86
|
+
Path(opt("--output_path")).mkdir(parents=True, exist_ok=True)
|
|
87
|
+
else:
|
|
88
|
+
Path(opt("--output_path")).write_text("ply\n")
|
|
86
89
|
return subprocess.CompletedProcess(args, 0, "", "")
|
|
87
90
|
|
|
88
91
|
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import cv2
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pytest
|
|
8
|
+
import trimesh
|
|
9
|
+
|
|
10
|
+
pytest.importorskip("skimage")
|
|
11
|
+
|
|
12
|
+
from reverse3d.errors import ProcessingError
|
|
13
|
+
from reverse3d.reconstruction.silhouette import convergence_point, read_colmap_text, visual_hull
|
|
14
|
+
|
|
15
|
+
SIZE = 400
|
|
16
|
+
F = 420.0
|
|
17
|
+
RADIUS, HEIGHT = 0.35, 1.2 # the object: an upright cylinder standing on z = 0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _look_at(eye: np.ndarray, target: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
21
|
+
"""World→camera rotation and translation (COLMAP convention: +Z forward, +Y down)."""
|
|
22
|
+
forward = target - eye
|
|
23
|
+
forward /= np.linalg.norm(forward)
|
|
24
|
+
right = np.cross(forward, [0.0, 0.0, 1.0])
|
|
25
|
+
right /= np.linalg.norm(right)
|
|
26
|
+
down = np.cross(forward, right)
|
|
27
|
+
rot = np.stack([right, down, forward])
|
|
28
|
+
return rot, -rot @ eye
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _quat(rot: np.ndarray) -> list[float]:
|
|
32
|
+
return list(
|
|
33
|
+
trimesh.transformations.quaternion_from_matrix(
|
|
34
|
+
np.vstack([np.hstack([rot, [[0], [0], [0]]]), [0, 0, 0, 1]])
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _scene(folder: Path) -> tuple[Path, Path]:
|
|
40
|
+
images = folder / "images"
|
|
41
|
+
text = folder / "txt"
|
|
42
|
+
images.mkdir(parents=True)
|
|
43
|
+
text.mkdir()
|
|
44
|
+
rng = np.random.default_rng(0)
|
|
45
|
+
theta = np.linspace(0, 2 * np.pi, 64, endpoint=False)
|
|
46
|
+
rim = np.concatenate(
|
|
47
|
+
[
|
|
48
|
+
np.stack([RADIUS * np.cos(theta), RADIUS * np.sin(theta), np.full_like(theta, z)], 1)
|
|
49
|
+
for z in (0.0, HEIGHT)
|
|
50
|
+
]
|
|
51
|
+
)
|
|
52
|
+
lines = []
|
|
53
|
+
k = 0
|
|
54
|
+
for height, count in ((0.6, 12), (2.2, 12), (3.4, 6)):
|
|
55
|
+
for i in range(count):
|
|
56
|
+
a = 2 * np.pi * i / count
|
|
57
|
+
eye = np.array([3.2 * np.cos(a), 3.2 * np.sin(a), height])
|
|
58
|
+
rot, t = _look_at(eye, np.array([0.0, 0.0, 0.6]))
|
|
59
|
+
# Textured background, then the white cylinder's silhouette.
|
|
60
|
+
image = rng.integers(0, 120, (SIZE, SIZE, 3), dtype=np.uint8)
|
|
61
|
+
image = cv2.GaussianBlur(image, (5, 5), 0)
|
|
62
|
+
cam = rim @ rot.T + t
|
|
63
|
+
uv = (cam[:, :2] / cam[:, 2:3]) * F + SIZE / 2
|
|
64
|
+
cv2.fillConvexPoly(image, cv2.convexHull(uv.astype(np.int32)), (235, 235, 235))
|
|
65
|
+
name = f"img_{k:02d}.jpg"
|
|
66
|
+
cv2.imwrite(str(images / name), image)
|
|
67
|
+
q = _quat(rot)
|
|
68
|
+
lines.append(f"{k + 1} {q[0]} {q[1]} {q[2]} {q[3]} {t[0]} {t[1]} {t[2]} 1 {name}\n\n")
|
|
69
|
+
k += 1
|
|
70
|
+
(text / "cameras.txt").write_text(f"1 PINHOLE {SIZE} {SIZE} {F} {F} {SIZE / 2} {SIZE / 2}\n")
|
|
71
|
+
(text / "images.txt").write_text("".join(lines))
|
|
72
|
+
table = np.column_stack([rng.uniform(-1, 1, 400), rng.uniform(-1, 1, 400), np.zeros(400)])
|
|
73
|
+
table = table[np.hypot(table[:, 0], table[:, 1]) > RADIUS + 0.05]
|
|
74
|
+
(text / "points3D.txt").write_text(
|
|
75
|
+
"".join(f"{i + 1} {x} {y} {z} 0 0 0 0\n" for i, (x, y, z) in enumerate(table))
|
|
76
|
+
)
|
|
77
|
+
return images, text
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_cameras_converge_on_the_object(tmp_path: Path) -> None:
|
|
81
|
+
_, text = _scene(tmp_path)
|
|
82
|
+
views, points = read_colmap_text(text)
|
|
83
|
+
center, distance = convergence_point(views)
|
|
84
|
+
assert len(views) == 30 and len(points) > 100
|
|
85
|
+
assert np.linalg.norm(center - [0, 0, 0.6]) < 0.05
|
|
86
|
+
assert 3.0 < distance < 3.8
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_visual_hull_rebuilds_an_upright_cylinder(tmp_path: Path) -> None:
|
|
90
|
+
images, text = _scene(tmp_path)
|
|
91
|
+
result = visual_hull(images, text, tmp_path / "hull.ply", resolution=80, refinements=1)
|
|
92
|
+
mesh = trimesh.load(result.mesh_path)
|
|
93
|
+
assert result.support_plane
|
|
94
|
+
assert mesh.is_watertight
|
|
95
|
+
width = (mesh.extents[0] + mesh.extents[1]) / 2
|
|
96
|
+
assert mesh.bounds[0][2] == pytest.approx(0.0, abs=1e-6)
|
|
97
|
+
# Height / diameter of the real cylinder is 1.2 / 0.7 ≈ 1.7.
|
|
98
|
+
assert 1.3 < mesh.extents[2] / width < 2.2
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_too_few_photos(tmp_path: Path) -> None:
|
|
102
|
+
images, text = _scene(tmp_path)
|
|
103
|
+
rows = (text / "images.txt").read_text().split("\n\n")
|
|
104
|
+
(text / "images.txt").write_text("\n\n".join(rows[:5]) + "\n\n")
|
|
105
|
+
with pytest.raises(ProcessingError, match="at least"):
|
|
106
|
+
visual_hull(images, text, tmp_path / "hull.ply", resolution=48, refinements=0)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class _FakeSegmenter:
|
|
110
|
+
"""Stands in for Segment Anything: the object is the bright part inside the box."""
|
|
111
|
+
|
|
112
|
+
def __init__(self) -> None:
|
|
113
|
+
self.calls = 0
|
|
114
|
+
|
|
115
|
+
def embed(self, rgb: np.ndarray) -> dict[str, np.ndarray]:
|
|
116
|
+
return {"rgb": rgb}
|
|
117
|
+
|
|
118
|
+
def segment(
|
|
119
|
+
self, state: dict[str, np.ndarray], box: tuple[int, int, int, int], point: tuple[int, int]
|
|
120
|
+
) -> np.ndarray:
|
|
121
|
+
self.calls += 1
|
|
122
|
+
gray = state["rgb"].mean(axis=2)
|
|
123
|
+
mask = np.zeros(gray.shape, np.uint8)
|
|
124
|
+
x0, y0, x1, y1 = box
|
|
125
|
+
mask[y0 : y1 + 1, x0 : x1 + 1] = np.where(gray[y0 : y1 + 1, x0 : x1 + 1] > 200, 255, 0)
|
|
126
|
+
return mask
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_ai_segmenter_is_loaded_lazily_and_used(tmp_path: Path) -> None:
|
|
130
|
+
images, text = _scene(tmp_path)
|
|
131
|
+
fake = _FakeSegmenter()
|
|
132
|
+
loads: list[int] = []
|
|
133
|
+
|
|
134
|
+
def factory() -> _FakeSegmenter:
|
|
135
|
+
loads.append(1)
|
|
136
|
+
return fake
|
|
137
|
+
|
|
138
|
+
result = visual_hull(
|
|
139
|
+
images,
|
|
140
|
+
text,
|
|
141
|
+
tmp_path / "hull.ply",
|
|
142
|
+
resolution=64,
|
|
143
|
+
refinements=1,
|
|
144
|
+
segmenter=factory, # type: ignore[arg-type]
|
|
145
|
+
)
|
|
146
|
+
assert loads == [1] and fake.calls == 2 * 30 # two AI rounds over the 30 photos
|
|
147
|
+
assert result.ai_segmentation
|
|
148
|
+
mesh = trimesh.load(result.mesh_path)
|
|
149
|
+
width = (mesh.extents[0] + mesh.extents[1]) / 2
|
|
150
|
+
assert mesh.is_watertight and 1.3 < mesh.extents[2] / width < 2.2
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_without_ai_segmenter_grabcut_is_used(tmp_path: Path) -> None:
|
|
154
|
+
images, text = _scene(tmp_path)
|
|
155
|
+
result = visual_hull(
|
|
156
|
+
images, text, tmp_path / "hull.ply", resolution=48, refinements=1, segmenter=lambda: None
|
|
157
|
+
)
|
|
158
|
+
assert not result.ai_segmentation
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.5.2"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|