reverse3d 0.5.2__tar.gz → 0.6.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.6.0}/PKG-INFO +2 -1
- {reverse3d-0.5.2 → reverse3d-0.6.0}/pyproject.toml +1 -0
- reverse3d-0.6.0/reverse3d/_version.py +1 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/recipes.py +53 -25
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/reconstruction/photogrammetry.py +29 -1
- reverse3d-0.6.0/reverse3d/reconstruction/silhouette.py +336 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0/reverse3d.egg-info}/PKG-INFO +2 -1
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d.egg-info/SOURCES.txt +2 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d.egg-info/requires.txt +1 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_automation.py +5 -1
- {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_reconstruction.py +4 -1
- reverse3d-0.6.0/tests/test_silhouette.py +106 -0
- reverse3d-0.5.2/reverse3d/_version.py +0 -1
- {reverse3d-0.5.2 → reverse3d-0.6.0}/LICENSE +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/NOTICE +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/README.md +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/__init__.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/__main__.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/_about.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/agent/__init__.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/agent/compact.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/agent/mcp_server.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/agent/prompts.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/agent/skill/SKILL.md +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/agent/tools.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/autopilot.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/cli.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/config.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/doctor.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/errors.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/hardware.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/imaging.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/logging_utils.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/mesh_io.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/offline.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/profiles.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/py.typed +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/reconstruction/__init__.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/reconstruction/depth_estimation.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/reconstruction/pointcloud.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/rendering/__init__.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/rendering/_blender_job.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/rendering/blender_render.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/rendering/preview.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/reverse_eng/__init__.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/reverse_eng/geometry_fit.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/reverse_eng/measure.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/reverse_eng/mesh_cleaner.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/reverse_eng/parametric.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/scenes.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/security.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/setup_agents.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/userconfig.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/vision/__init__.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/vision/feature_matcher.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/vision/mask_extractor.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/watch.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d.egg-info/dependency_links.txt +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d.egg-info/entry_points.txt +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d.egg-info/top_level.txt +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/setup.cfg +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_agent_tools.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_authorship.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_cli.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_cli_automation.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_config.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_geometry_fit.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_heic.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_mesh.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_offline_autopilot.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_optional_extras.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_render.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_scenes.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_security.py +0 -0
- {reverse3d-0.5.2 → reverse3d-0.6.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.6.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.6.0"
|
|
@@ -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
|
|
@@ -195,7 +194,11 @@ _MIN_GROUP = 8
|
|
|
195
194
|
|
|
196
195
|
|
|
197
196
|
def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
|
|
198
|
-
from reverse3d.reconstruction.photogrammetry import
|
|
197
|
+
from reverse3d.reconstruction.photogrammetry import (
|
|
198
|
+
PhotogrammetryOptions,
|
|
199
|
+
PhotogrammetryResult,
|
|
200
|
+
run_photogrammetry,
|
|
201
|
+
)
|
|
199
202
|
from reverse3d.scenes import SCENE_LABEL_ES, SCENE_PRESETS, detect_scene
|
|
200
203
|
from reverse3d.vision.feature_matcher import analyze_image_set
|
|
201
204
|
|
|
@@ -219,21 +222,9 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
|
|
|
219
222
|
analysis = run.step("analyze", lambda: analyze_image_set(src, run.settings))
|
|
220
223
|
run.details["analysis"] = analysis.to_dict()
|
|
221
224
|
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
|
|
225
|
+
# COLMAP places cameras far better than this quick check (it often uses
|
|
226
|
+
# the whole room), so every photo goes in; the check is only advice.
|
|
227
|
+
run.warnings.extend(analysis.recommendations)
|
|
237
228
|
elif not analysis.suitable_for_photogrammetry:
|
|
238
229
|
if not opt.force_analysis:
|
|
239
230
|
raise CaptureQualityError(
|
|
@@ -244,26 +235,62 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
|
|
|
244
235
|
run.warnings.extend(analysis.recommendations)
|
|
245
236
|
|
|
246
237
|
quality = opt.quality or preset.quality or profile_values(run.settings).photo_quality
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
238
|
+
# Objects and characters: COLMAP only places the cameras (fast, no dense stage)
|
|
239
|
+
# and the shape is carved from the silhouettes, which also works on plain,
|
|
240
|
+
# white or glossy surfaces. Open scenes keep classic dense reconstruction.
|
|
241
|
+
silhouette = scene in ("object", "character")
|
|
242
|
+
|
|
243
|
+
def photogrammetry(classic: bool) -> PhotogrammetryResult:
|
|
244
|
+
return run_photogrammetry(
|
|
250
245
|
src,
|
|
251
246
|
run.out / "reconstruction",
|
|
252
247
|
run.settings,
|
|
253
248
|
PhotogrammetryOptions(
|
|
254
249
|
quality=quality, # type: ignore[arg-type]
|
|
255
250
|
matcher=preset.matcher,
|
|
251
|
+
dense="auto" if classic else "off",
|
|
256
252
|
use_gpu=wants_gpu(run.settings),
|
|
253
|
+
build_mesh=classic,
|
|
257
254
|
poisson_depth=preset.poisson_depth,
|
|
258
255
|
density_quantile=preset.density_quantile,
|
|
259
256
|
),
|
|
260
|
-
overwrite=opt.overwrite,
|
|
261
|
-
)
|
|
262
|
-
|
|
257
|
+
overwrite=opt.overwrite or not classic,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
photo = run.step("photogrammetry", lambda: photogrammetry(classic=not silhouette))
|
|
261
|
+
mesh_path: str | None = photo.mesh_path
|
|
262
|
+
upright = False
|
|
263
|
+
if silhouette:
|
|
264
|
+
from reverse3d.reconstruction.silhouette import visual_hull
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
hull = run.step(
|
|
268
|
+
"silhouette",
|
|
269
|
+
lambda: visual_hull(
|
|
270
|
+
Path(photo.images_dir or ""),
|
|
271
|
+
Path(photo.sparse_text_dir or ""),
|
|
272
|
+
run.out / "reconstruction" / "object_hull.ply",
|
|
273
|
+
),
|
|
274
|
+
)
|
|
275
|
+
except ProcessingError as exc:
|
|
276
|
+
logger.info("Silhouette carving failed (%s); using dense reconstruction", exc.message)
|
|
277
|
+
run.warnings.append(
|
|
278
|
+
f"Silhouette carving failed ({exc.message}); used dense reconstruction."
|
|
279
|
+
)
|
|
280
|
+
photo = run.step("photogrammetry", lambda: photogrammetry(classic=True))
|
|
281
|
+
mesh_path = photo.mesh_path
|
|
282
|
+
else:
|
|
283
|
+
run.details["silhouette"] = hull.to_dict()
|
|
284
|
+
mesh_path = hull.mesh_path
|
|
285
|
+
upright = True
|
|
286
|
+
if photo.registered_images >= 0.9 * max(photo.total_images, 1):
|
|
287
|
+
# COLMAP placed (almost) every photo: the quick pre-check's capture advice
|
|
288
|
+
# would only confuse.
|
|
289
|
+
run.warnings[:] = [w for w in run.warnings if w not in analysis.recommendations]
|
|
263
290
|
run.warnings.extend(photo.warnings)
|
|
264
291
|
run.details["photogrammetry"] = photo.to_dict()
|
|
265
292
|
run.outputs["point_cloud"] = photo.dense_points_path or photo.sparse_points_path
|
|
266
|
-
if not
|
|
293
|
+
if not mesh_path:
|
|
267
294
|
raise ProcessingError(
|
|
268
295
|
"Point cloud created but no mesh could be built.",
|
|
269
296
|
hint='Install the meshing extra: pip install "reverse3d[recon]" (Python ≤ 3.12).',
|
|
@@ -271,11 +298,12 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
|
|
|
271
298
|
model = run.out / "model.stl"
|
|
272
299
|
_clean(
|
|
273
300
|
run,
|
|
274
|
-
Path(
|
|
301
|
+
Path(mesh_path),
|
|
275
302
|
model,
|
|
276
303
|
scale=opt.scale,
|
|
277
304
|
overwrite=opt.overwrite,
|
|
278
305
|
fill_holes=preset.fill_holes,
|
|
306
|
+
align="none" if upright else "obb",
|
|
279
307
|
keep_largest=preset.keep_largest,
|
|
280
308
|
repair=opt.repair,
|
|
281
309
|
)
|
|
@@ -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,336 @@
|
|
|
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
|
+
|
|
27
|
+
import cv2
|
|
28
|
+
import numpy as np
|
|
29
|
+
import trimesh
|
|
30
|
+
|
|
31
|
+
from reverse3d.errors import DependencyMissingError, ProcessingError
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
_WORK_SIDE = 640
|
|
36
|
+
_MIN_VIEWS = 8
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class _Camera:
|
|
41
|
+
width: int
|
|
42
|
+
height: int
|
|
43
|
+
fx: float
|
|
44
|
+
fy: float
|
|
45
|
+
cx: float
|
|
46
|
+
cy: float
|
|
47
|
+
k: float
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class _View:
|
|
52
|
+
name: str
|
|
53
|
+
rotation: np.ndarray
|
|
54
|
+
translation: np.ndarray
|
|
55
|
+
camera: _Camera
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class HullResult:
|
|
60
|
+
mesh_path: str
|
|
61
|
+
views: int
|
|
62
|
+
voxels: int
|
|
63
|
+
faces: int
|
|
64
|
+
support_plane: bool
|
|
65
|
+
|
|
66
|
+
def to_dict(self) -> dict[str, object]:
|
|
67
|
+
return asdict(self)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _rotation(q: np.ndarray) -> np.ndarray:
|
|
71
|
+
w, x, y, z = q
|
|
72
|
+
return np.array(
|
|
73
|
+
[
|
|
74
|
+
[1 - 2 * y * y - 2 * z * z, 2 * x * y - 2 * w * z, 2 * x * z + 2 * w * y],
|
|
75
|
+
[2 * x * y + 2 * w * z, 1 - 2 * x * x - 2 * z * z, 2 * y * z - 2 * w * x],
|
|
76
|
+
[2 * x * z - 2 * w * y, 2 * y * z + 2 * w * x, 1 - 2 * x * x - 2 * y * y],
|
|
77
|
+
]
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def read_colmap_text(model_dir: Path) -> tuple[list[_View], np.ndarray]:
|
|
82
|
+
"""Views (intrinsics + world→camera pose) and sparse points of a TXT model."""
|
|
83
|
+
cameras: dict[int, _Camera] = {}
|
|
84
|
+
for line in (model_dir / "cameras.txt").read_text(encoding="utf-8").splitlines():
|
|
85
|
+
if not line or line.startswith("#"):
|
|
86
|
+
continue
|
|
87
|
+
p = line.split()
|
|
88
|
+
model, params = p[1], [float(v) for v in p[4:]]
|
|
89
|
+
if model in ("SIMPLE_RADIAL", "SIMPLE_PINHOLE", "RADIAL"):
|
|
90
|
+
f, cx, cy = params[:3]
|
|
91
|
+
k = params[3] if model in ("SIMPLE_RADIAL", "RADIAL") else 0.0
|
|
92
|
+
cam = _Camera(int(p[2]), int(p[3]), f, f, cx, cy, k)
|
|
93
|
+
else: # PINHOLE, OPENCV...: fx fy cx cy [distortion ignored]
|
|
94
|
+
cam = _Camera(int(p[2]), int(p[3]), params[0], params[1], params[2], params[3], 0.0)
|
|
95
|
+
cameras[int(p[0])] = cam
|
|
96
|
+
# Each image takes two lines (header, then its 2D points, which may be empty).
|
|
97
|
+
rows = [
|
|
98
|
+
line
|
|
99
|
+
for line in (model_dir / "images.txt").read_text(encoding="utf-8").splitlines()
|
|
100
|
+
if not line.startswith("#")
|
|
101
|
+
]
|
|
102
|
+
views = []
|
|
103
|
+
i = 0
|
|
104
|
+
while i < len(rows):
|
|
105
|
+
p = rows[i].split()
|
|
106
|
+
if len(p) < 10:
|
|
107
|
+
i += 1
|
|
108
|
+
continue
|
|
109
|
+
q = np.array([float(v) for v in p[1:5]])
|
|
110
|
+
t = np.array([float(v) for v in p[5:8]])
|
|
111
|
+
views.append(_View(" ".join(p[9:]), _rotation(q), t, cameras[int(p[8])]))
|
|
112
|
+
i += 2
|
|
113
|
+
points = []
|
|
114
|
+
for line in (model_dir / "points3D.txt").read_text(encoding="utf-8").splitlines():
|
|
115
|
+
if line and not line.startswith("#"):
|
|
116
|
+
points.append([float(v) for v in line.split()[1:4]])
|
|
117
|
+
return views, np.asarray(points, dtype=np.float64).reshape(-1, 3)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _project(points: np.ndarray, view: _View) -> tuple[np.ndarray, np.ndarray]:
|
|
121
|
+
cam = view.camera
|
|
122
|
+
xc = points @ view.rotation.T + view.translation
|
|
123
|
+
z = xc[:, 2]
|
|
124
|
+
safe = np.where(np.abs(z) < 1e-9, 1e-9, z)
|
|
125
|
+
x, y = xc[:, 0] / safe, xc[:, 1] / safe
|
|
126
|
+
s = 1 + cam.k * (x * x + y * y)
|
|
127
|
+
return np.stack([cam.fx * x * s + cam.cx, cam.fy * y * s + cam.cy], axis=1), z
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def convergence_point(views: list[_View]) -> tuple[np.ndarray, float]:
|
|
131
|
+
"""Point closest to every camera's optical axis, and the median camera distance."""
|
|
132
|
+
a = np.zeros((3, 3))
|
|
133
|
+
b = np.zeros(3)
|
|
134
|
+
centers = []
|
|
135
|
+
for view in views:
|
|
136
|
+
c = -view.rotation.T @ view.translation
|
|
137
|
+
d = view.rotation.T @ np.array([0.0, 0.0, 1.0])
|
|
138
|
+
d /= np.linalg.norm(d)
|
|
139
|
+
m = np.eye(3) - np.outer(d, d)
|
|
140
|
+
a += m
|
|
141
|
+
b += m @ c
|
|
142
|
+
centers.append(c)
|
|
143
|
+
point = np.linalg.solve(a, b)
|
|
144
|
+
return point, float(np.median(np.linalg.norm(np.asarray(centers) - point, axis=1)))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _grabcut(
|
|
148
|
+
image: np.ndarray, init: np.ndarray, rect: tuple[int, int, int, int] | None
|
|
149
|
+
) -> np.ndarray:
|
|
150
|
+
bgd, fgd = np.zeros((1, 65)), np.zeros((1, 65))
|
|
151
|
+
mode = cv2.GC_INIT_WITH_RECT if rect is not None else cv2.GC_INIT_WITH_MASK
|
|
152
|
+
try:
|
|
153
|
+
cv2.grabCut(image, init, rect or (0, 0, 0, 0), bgd, fgd, 4, mode)
|
|
154
|
+
except cv2.error:
|
|
155
|
+
return np.zeros(image.shape[:2], np.uint8)
|
|
156
|
+
return np.where((init == cv2.GC_FGD) | (init == cv2.GC_PR_FGD), 255, 0).astype(np.uint8)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _support_plane(
|
|
160
|
+
points: np.ndarray, center: np.ndarray, radius: float, cameras: np.ndarray
|
|
161
|
+
) -> tuple[np.ndarray, float] | None:
|
|
162
|
+
"""The table under the object: RANSAC plane through nearby sparse points, normal up."""
|
|
163
|
+
near = points[np.linalg.norm(points - center, axis=1) < 1.2 * radius]
|
|
164
|
+
if len(near) < 30:
|
|
165
|
+
return None
|
|
166
|
+
rng = np.random.default_rng(0)
|
|
167
|
+
best: tuple[np.ndarray, float, int] | None = None
|
|
168
|
+
for _ in range(400):
|
|
169
|
+
s = near[rng.choice(len(near), 3, replace=False)]
|
|
170
|
+
n = np.cross(s[1] - s[0], s[2] - s[0])
|
|
171
|
+
norm = np.linalg.norm(n)
|
|
172
|
+
if norm < 1e-12:
|
|
173
|
+
continue
|
|
174
|
+
n /= norm
|
|
175
|
+
d = -float(n @ s[0])
|
|
176
|
+
count = int((np.abs(near @ n + d) < 0.01 * radius).sum())
|
|
177
|
+
if best is None or count > best[2]:
|
|
178
|
+
best = (n, d, count)
|
|
179
|
+
if best is None or best[2] < 20:
|
|
180
|
+
return None
|
|
181
|
+
n, d, _ = best
|
|
182
|
+
if np.median(cameras @ n + d) < 0: # cameras are above the table
|
|
183
|
+
n, d = -n, -d
|
|
184
|
+
return n, d
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def visual_hull(
|
|
188
|
+
image_dir: Path,
|
|
189
|
+
model_text_dir: Path,
|
|
190
|
+
output_path: Path,
|
|
191
|
+
*,
|
|
192
|
+
resolution: int = 176,
|
|
193
|
+
refinements: int = 3,
|
|
194
|
+
) -> HullResult:
|
|
195
|
+
"""Carve the object seen by every photo; writes a watertight, upright mesh.
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
ProcessingError: too few placed photos, or no object could be isolated.
|
|
199
|
+
"""
|
|
200
|
+
try:
|
|
201
|
+
from scipy import ndimage
|
|
202
|
+
from skimage.measure import marching_cubes
|
|
203
|
+
except ImportError as exc: # pragma: no cover - core dependencies
|
|
204
|
+
raise DependencyMissingError(
|
|
205
|
+
"scikit-image and scipy are needed for silhouette reconstruction.",
|
|
206
|
+
hint="pip install scikit-image scipy",
|
|
207
|
+
) from exc
|
|
208
|
+
|
|
209
|
+
views, points = read_colmap_text(model_text_dir)
|
|
210
|
+
if len(views) < _MIN_VIEWS:
|
|
211
|
+
raise ProcessingError(
|
|
212
|
+
f"Only {len(views)} photos were placed; at least {_MIN_VIEWS} are needed."
|
|
213
|
+
)
|
|
214
|
+
center, distance = convergence_point(views)
|
|
215
|
+
radius = 0.45 * distance
|
|
216
|
+
cameras = np.asarray([-v.rotation.T @ v.translation for v in views])
|
|
217
|
+
|
|
218
|
+
images: list[tuple[np.ndarray, float]] = []
|
|
219
|
+
for view in views:
|
|
220
|
+
bgr = cv2.imread(str(image_dir / view.name))
|
|
221
|
+
if bgr is None:
|
|
222
|
+
raise ProcessingError(f"Could not read {view.name} for silhouette carving.")
|
|
223
|
+
h, w = bgr.shape[:2]
|
|
224
|
+
scale = _WORK_SIDE / max(h, w)
|
|
225
|
+
images.append(
|
|
226
|
+
(
|
|
227
|
+
cv2.resize(bgr, (round(w * scale), round(h * scale)), interpolation=cv2.INTER_AREA),
|
|
228
|
+
scale,
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# First silhouettes: GrabCut in a box around where the object projects.
|
|
233
|
+
masks: list[np.ndarray] = []
|
|
234
|
+
for (small, scale), view in zip(images, views, strict=True):
|
|
235
|
+
uv, z = _project(center[None], view)
|
|
236
|
+
if z[0] <= 0:
|
|
237
|
+
masks.append(np.zeros(small.shape[:2], np.uint8))
|
|
238
|
+
continue
|
|
239
|
+
cu, cv_ = uv[0] * scale
|
|
240
|
+
half = view.camera.fx * scale * 0.6 * radius / z[0]
|
|
241
|
+
x0, y0 = max(0, int(cu - half)), max(0, int(cv_ - 1.6 * half))
|
|
242
|
+
x1 = min(small.shape[1] - 1, int(cu + half))
|
|
243
|
+
y1 = min(small.shape[0] - 1, int(cv_ + 1.6 * half))
|
|
244
|
+
init = np.zeros(small.shape[:2], np.uint8)
|
|
245
|
+
rect = (x0, y0, max(2, x1 - x0), max(2, y1 - y0))
|
|
246
|
+
fg = _grabcut(small, init, rect)
|
|
247
|
+
masks.append(cv2.morphologyEx(fg, cv2.MORPH_CLOSE, np.ones((7, 7), np.uint8)))
|
|
248
|
+
|
|
249
|
+
n = resolution
|
|
250
|
+
axis = np.linspace(-radius, radius, n)
|
|
251
|
+
gx, gy, gz = np.meshgrid(axis, axis, axis, indexing="ij")
|
|
252
|
+
grid = np.stack([gx.ravel(), gy.ravel(), gz.ravel()], axis=1) + center
|
|
253
|
+
projections = []
|
|
254
|
+
for (small, scale), view in zip(images, views, strict=True):
|
|
255
|
+
uv, z = _project(grid, view)
|
|
256
|
+
u = np.round(uv[:, 0] * scale).astype(np.int64)
|
|
257
|
+
v = np.round(uv[:, 1] * scale).astype(np.int64)
|
|
258
|
+
inside = (z > 0) & (u >= 0) & (v >= 0) & (u < small.shape[1]) & (v < small.shape[0])
|
|
259
|
+
projections.append((u, v, inside))
|
|
260
|
+
|
|
261
|
+
def carve(mask_list: list[np.ndarray], tolerance: int) -> np.ndarray:
|
|
262
|
+
votes = np.zeros(len(grid), np.int32)
|
|
263
|
+
seen = np.zeros(len(grid), np.int32)
|
|
264
|
+
for fg, (u, v, inside) in zip(mask_list, projections, strict=True):
|
|
265
|
+
seen += inside
|
|
266
|
+
hit = np.zeros(len(grid), bool)
|
|
267
|
+
hit[inside] = fg[v[inside], u[inside]] > 0
|
|
268
|
+
votes += hit
|
|
269
|
+
return (seen >= 3) & (votes >= seen - tolerance)
|
|
270
|
+
|
|
271
|
+
keep = carve(masks, 2)
|
|
272
|
+
for _ in range(refinements):
|
|
273
|
+
refined: list[np.ndarray] = []
|
|
274
|
+
for (small, _), (u, v, inside), old in zip(images, projections, masks, strict=True):
|
|
275
|
+
hull: np.ndarray = np.zeros(small.shape[:2], np.uint8)
|
|
276
|
+
sel = keep & inside
|
|
277
|
+
hull[v[sel], u[sel]] = 255
|
|
278
|
+
hull = cv2.morphologyEx(hull, cv2.MORPH_CLOSE, np.ones((9, 9), np.uint8))
|
|
279
|
+
if not hull.any():
|
|
280
|
+
refined.append(old)
|
|
281
|
+
continue
|
|
282
|
+
init = np.full(small.shape[:2], cv2.GC_BGD, np.uint8)
|
|
283
|
+
init[cv2.dilate(hull, np.ones((25, 25), np.uint8)) > 0] = cv2.GC_PR_BGD
|
|
284
|
+
init[hull > 0] = cv2.GC_PR_FGD
|
|
285
|
+
init[cv2.erode(hull, np.ones((21, 21), np.uint8)) > 0] = cv2.GC_FGD
|
|
286
|
+
fg = _grabcut(small, init, None)
|
|
287
|
+
refined.append(cv2.morphologyEx(fg, cv2.MORPH_OPEN, np.ones((5, 5), np.uint8)))
|
|
288
|
+
masks = refined
|
|
289
|
+
keep = carve(masks, 1)
|
|
290
|
+
|
|
291
|
+
volume = keep.reshape(n, n, n).astype(np.float32)
|
|
292
|
+
plane = _support_plane(points, center, radius, cameras)
|
|
293
|
+
if plane is not None:
|
|
294
|
+
normal, offset = plane
|
|
295
|
+
height = (grid @ normal + offset).reshape(n, n, n)
|
|
296
|
+
volume[height < 0.015 * radius] = 0
|
|
297
|
+
labels, count = ndimage.label(volume > 0.5)
|
|
298
|
+
if count == 0:
|
|
299
|
+
raise ProcessingError("No object could be isolated in the photos.")
|
|
300
|
+
sizes = ndimage.sum(np.ones_like(labels), labels, range(1, count + 1))
|
|
301
|
+
volume = (labels == 1 + int(np.argmax(sizes))).astype(np.float32)
|
|
302
|
+
voxels = int(volume.sum())
|
|
303
|
+
if voxels < 500:
|
|
304
|
+
raise ProcessingError("The isolated object is too small to build a model.")
|
|
305
|
+
touches_border = bool(
|
|
306
|
+
volume[0].any()
|
|
307
|
+
or volume[-1].any()
|
|
308
|
+
or volume[:, 0].any()
|
|
309
|
+
or volume[:, -1].any()
|
|
310
|
+
or volume[:, :, 0].any()
|
|
311
|
+
or volume[:, :, -1].any()
|
|
312
|
+
)
|
|
313
|
+
if touches_border:
|
|
314
|
+
logger.info("The object reaches the edge of the carving volume; it may be cropped.")
|
|
315
|
+
# One empty voxel of margin all around: marching cubes then yields a closed surface.
|
|
316
|
+
volume = np.pad(ndimage.gaussian_filter(volume, 1.4), 2)
|
|
317
|
+
verts, faces, _, _ = marching_cubes(volume, 0.5)
|
|
318
|
+
verts = (verts - 2) / (n - 1) * 2 * radius - radius + center
|
|
319
|
+
mesh = trimesh.Trimesh(verts, faces, process=True)
|
|
320
|
+
trimesh.smoothing.filter_taubin(mesh, lamb=0.5, nu=-0.53, iterations=15)
|
|
321
|
+
|
|
322
|
+
# Stand it upright: +Z along the table normal (or the mean camera "up").
|
|
323
|
+
up = plane[0] if plane is not None else np.mean(cameras - center, axis=0)
|
|
324
|
+
up = up / np.linalg.norm(up)
|
|
325
|
+
side = np.cross([0.0, 1.0, 0.0] if abs(up[1]) < 0.9 else [1.0, 0.0, 0.0], up)
|
|
326
|
+
side /= np.linalg.norm(side)
|
|
327
|
+
basis = np.stack([side, np.cross(up, side), up])
|
|
328
|
+
transform = np.eye(4)
|
|
329
|
+
transform[:3, :3] = basis
|
|
330
|
+
mesh.apply_transform(transform)
|
|
331
|
+
mesh.apply_translation([-mesh.centroid[0], -mesh.centroid[1], -mesh.bounds[0][2]])
|
|
332
|
+
|
|
333
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
334
|
+
mesh.export(output_path)
|
|
335
|
+
logger.info("Silhouette model: %d views, %d faces", len(views), len(mesh.faces))
|
|
336
|
+
return HullResult(str(output_path), len(views), voxels, len(mesh.faces), plane is not None)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: reverse3d
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.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
|
|
@@ -67,4 +68,5 @@ tests/test_reconstruction.py
|
|
|
67
68
|
tests/test_render.py
|
|
68
69
|
tests/test_scenes.py
|
|
69
70
|
tests/test_security.py
|
|
71
|
+
tests/test_silhouette.py
|
|
70
72
|
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)
|
|
@@ -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,106 @@
|
|
|
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)
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|