reverse3d 0.8.1__tar.gz → 0.9.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.8.1/reverse3d.egg-info → reverse3d-0.9.0}/PKG-INFO +1 -1
- reverse3d-0.9.0/reverse3d/_version.py +1 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/recipes.py +34 -6
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/reconstruction/silhouette.py +115 -1
- {reverse3d-0.8.1 → reverse3d-0.9.0/reverse3d.egg-info}/PKG-INFO +1 -1
- reverse3d-0.9.0/tests/test_silhouette.py +284 -0
- reverse3d-0.8.1/reverse3d/_version.py +0 -1
- reverse3d-0.8.1/tests/test_silhouette.py +0 -158
- {reverse3d-0.8.1 → reverse3d-0.9.0}/LICENSE +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/NOTICE +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/README.md +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/pyproject.toml +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/__init__.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/__main__.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/_about.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/agent/__init__.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/agent/compact.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/agent/mcp_server.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/agent/prompts.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/agent/skill/SKILL.md +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/agent/tools.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/autopilot.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/cli.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/config.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/doctor.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/errors.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/hardware.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/imaging.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/logging_utils.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/mesh_io.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/offline.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/profiles.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/py.typed +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/reconstruction/__init__.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/reconstruction/depth_estimation.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/reconstruction/photogrammetry.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/reconstruction/pointcloud.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/rendering/__init__.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/rendering/_blender_job.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/rendering/blender_render.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/rendering/preview.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/reverse_eng/__init__.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/reverse_eng/geometry_fit.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/reverse_eng/measure.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/reverse_eng/mesh_cleaner.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/reverse_eng/parametric.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/scenes.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/security.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/setup_agents.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/userconfig.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/vision/__init__.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/vision/classifier.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/vision/feature_matcher.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/vision/mask_extractor.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/vision/segmenter.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d/watch.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d.egg-info/SOURCES.txt +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d.egg-info/dependency_links.txt +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d.egg-info/entry_points.txt +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d.egg-info/requires.txt +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/reverse3d.egg-info/top_level.txt +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/setup.cfg +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_agent_tools.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_authorship.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_automation.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_cli.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_cli_automation.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_color.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_config.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_geometry_fit.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_heic.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_mesh.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_offline_autopilot.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_optional_extras.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_reconstruction.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_render.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_scenes.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.0}/tests/test_security.py +0 -0
- {reverse3d-0.8.1 → reverse3d-0.9.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.9.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>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.9.0"
|
|
@@ -340,6 +340,18 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
|
|
|
340
340
|
)
|
|
341
341
|
return None
|
|
342
342
|
|
|
343
|
+
def load_depth() -> Callable[[np.ndarray], np.ndarray] | None:
|
|
344
|
+
# AI depth reveals concavities and holes (cup insides, handle loops).
|
|
345
|
+
from reverse3d.reconstruction.depth_estimation import predict_depth
|
|
346
|
+
|
|
347
|
+
device = torch_device(run.settings)
|
|
348
|
+
try:
|
|
349
|
+
predict_depth(np.zeros((32, 32, 3), np.uint8), device=device, settings=run.settings)
|
|
350
|
+
except Reverse3DError as exc:
|
|
351
|
+
logger.info("AI depth unavailable (%s); concavities stay filled", exc.message)
|
|
352
|
+
return None
|
|
353
|
+
return lambda rgb: predict_depth(rgb, device=device, settings=run.settings)
|
|
354
|
+
|
|
343
355
|
try:
|
|
344
356
|
hull = run.step(
|
|
345
357
|
"silhouette",
|
|
@@ -348,6 +360,7 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
|
|
|
348
360
|
Path(photo.sparse_text_dir or ""),
|
|
349
361
|
run.out / "reconstruction" / "object_hull.ply",
|
|
350
362
|
segmenter=load_segmenter,
|
|
363
|
+
depth=load_depth,
|
|
351
364
|
),
|
|
352
365
|
)
|
|
353
366
|
except ProcessingError as exc:
|
|
@@ -374,14 +387,29 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
|
|
|
374
387
|
hint='Install the meshing extra: pip install "reverse3d[recon]" (Python ≤ 3.12).',
|
|
375
388
|
)
|
|
376
389
|
cloud = photo.dense_points_path or photo.sparse_points_path
|
|
377
|
-
if not upright
|
|
390
|
+
if not upright:
|
|
391
|
+
# Dense scenes: colour from the photos themselves (sharp, independent of how
|
|
392
|
+
# dense the cloud is); the cloud's colours are the fallback.
|
|
393
|
+
colored_path = run.out / "reconstruction" / "mesh_color.ply"
|
|
394
|
+
colored: Path | None = None
|
|
378
395
|
try:
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
396
|
+
from reverse3d.reconstruction.silhouette import colorize_from_photos
|
|
397
|
+
|
|
398
|
+
if photo.images_dir and photo.sparse_text_dir:
|
|
399
|
+
colored = colorize_from_photos(
|
|
400
|
+
Path(mesh_path),
|
|
401
|
+
Path(photo.images_dir),
|
|
402
|
+
Path(photo.sparse_text_dir),
|
|
403
|
+
colored_path,
|
|
404
|
+
)
|
|
383
405
|
except Exception as exc: # colour is a bonus
|
|
384
|
-
logger.info("Colouring from the
|
|
406
|
+
logger.info("Colouring from the photos skipped: %s", exc)
|
|
407
|
+
if colored is None and cloud:
|
|
408
|
+
try:
|
|
409
|
+
colored = _color_from_cloud(Path(mesh_path), Path(cloud), colored_path)
|
|
410
|
+
except Exception as exc: # colour is a bonus
|
|
411
|
+
logger.info("Colouring from the point cloud skipped: %s", exc)
|
|
412
|
+
mesh_path = str(colored) if colored else mesh_path
|
|
385
413
|
model = run.out / "model.stl"
|
|
386
414
|
_clean(
|
|
387
415
|
run,
|
|
@@ -69,6 +69,7 @@ class HullResult:
|
|
|
69
69
|
faces: int
|
|
70
70
|
support_plane: bool
|
|
71
71
|
ai_segmentation: bool = False
|
|
72
|
+
depth_carved_voxels: int = 0
|
|
72
73
|
|
|
73
74
|
def to_dict(self) -> dict[str, object]:
|
|
74
75
|
return asdict(self)
|
|
@@ -250,6 +251,102 @@ def _vertex_colors(
|
|
|
250
251
|
return colors
|
|
251
252
|
|
|
252
253
|
|
|
254
|
+
def _fit_inverse_depth(disparity: np.ndarray, depth: np.ndarray) -> tuple[float, float] | None:
|
|
255
|
+
"""Robust fit 1/depth ≈ s·disparity + t (relative AI disparity → metric-like depth)."""
|
|
256
|
+
inv = 1.0 / depth
|
|
257
|
+
keep = np.ones(len(inv), bool)
|
|
258
|
+
for _ in range(3):
|
|
259
|
+
if keep.sum() < 50:
|
|
260
|
+
return None
|
|
261
|
+
a = np.column_stack([disparity[keep], np.ones(int(keep.sum()))])
|
|
262
|
+
(s, t), *_ = np.linalg.lstsq(a, inv[keep], rcond=None)
|
|
263
|
+
residual = np.abs(s * disparity + t - inv)
|
|
264
|
+
mad = float(np.median(residual[keep])) or 1e-9
|
|
265
|
+
keep = residual < 3 * 1.4826 * mad
|
|
266
|
+
return (float(s), float(t)) if s > 0 else None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _depth_carve(
|
|
270
|
+
keep: np.ndarray,
|
|
271
|
+
grid: np.ndarray,
|
|
272
|
+
views: list[_View],
|
|
273
|
+
images: list[tuple[np.ndarray, float]],
|
|
274
|
+
projections: list[tuple[np.ndarray, np.ndarray, np.ndarray]],
|
|
275
|
+
masks: list[np.ndarray],
|
|
276
|
+
estimator: Callable[[np.ndarray], np.ndarray],
|
|
277
|
+
*,
|
|
278
|
+
tolerance: float = 0.05,
|
|
279
|
+
min_votes: int = 3,
|
|
280
|
+
min_share: float = 0.5,
|
|
281
|
+
) -> np.ndarray:
|
|
282
|
+
"""Empty the voxels that sit clearly in front of the surface the AI depth sees,
|
|
283
|
+
in at least ``min_votes`` photos *and* in most (``min_share``) of the photos
|
|
284
|
+
that actually see them. A photo where the voxel lies on the surface votes
|
|
285
|
+
solid; one where it is hidden behind the surface abstains. A few wrong depth
|
|
286
|
+
maps therefore cannot carve real material.
|
|
287
|
+
|
|
288
|
+
The relative AI depth is calibrated per photo against the carved hull itself
|
|
289
|
+
(a robust fit on the object's pixels), so only real disagreements carve.
|
|
290
|
+
"""
|
|
291
|
+
votes = np.zeros(len(grid), np.int32)
|
|
292
|
+
seen = np.zeros(len(grid), np.int32)
|
|
293
|
+
for (small, _), view, (u, v, inside), mask in zip(
|
|
294
|
+
images, views, projections, masks, strict=True
|
|
295
|
+
):
|
|
296
|
+
sel = keep & inside
|
|
297
|
+
if sel.sum() < 100:
|
|
298
|
+
continue
|
|
299
|
+
h, w = small.shape[:2]
|
|
300
|
+
z = (grid[sel] @ view.rotation.T + view.translation)[:, 2]
|
|
301
|
+
us, vs = u[sel], v[sel]
|
|
302
|
+
# Depth of the hull's visible surface per pixel (z-buffer of kept voxels).
|
|
303
|
+
hull_depth = np.full((h, w), np.inf)
|
|
304
|
+
np.minimum.at(hull_depth, (vs, us), z)
|
|
305
|
+
object_px = (mask > 0) & np.isfinite(hull_depth)
|
|
306
|
+
if object_px.sum() < 100:
|
|
307
|
+
continue
|
|
308
|
+
disparity = np.asarray(estimator(cv2.cvtColor(small, cv2.COLOR_BGR2RGB)), np.float64)
|
|
309
|
+
if disparity.shape != (h, w):
|
|
310
|
+
disparity = cv2.resize(disparity, (w, h), interpolation=cv2.INTER_LINEAR)
|
|
311
|
+
fit = _fit_inverse_depth(disparity[object_px], hull_depth[object_px])
|
|
312
|
+
if fit is None:
|
|
313
|
+
continue
|
|
314
|
+
s, t = fit
|
|
315
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
316
|
+
observed = 1.0 / np.maximum(s * disparity + t, 1e-9)
|
|
317
|
+
# A voxel strictly in front of the observed surface, on an object pixel, is air.
|
|
318
|
+
in_mask = mask[vs, us] > 0
|
|
319
|
+
front = z < observed[vs, us] * (1.0 - tolerance)
|
|
320
|
+
on_surface = np.abs(z - observed[vs, us]) <= tolerance * observed[vs, us]
|
|
321
|
+
idx = np.flatnonzero(sel)
|
|
322
|
+
seen[idx[in_mask & (front | on_surface)]] += 1 # hidden voxels abstain
|
|
323
|
+
votes[idx[in_mask & front]] += 1
|
|
324
|
+
air = (votes >= min_votes) & (votes >= min_share * np.maximum(seen, 1))
|
|
325
|
+
return keep & ~air
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def colorize_from_photos(
|
|
329
|
+
mesh_path: Path, image_dir: Path, model_text_dir: Path, output_path: Path
|
|
330
|
+
) -> Path:
|
|
331
|
+
"""Paint any mesh in COLMAP coordinates with the photos that see it (dense scenes)."""
|
|
332
|
+
views, _ = read_colmap_text(model_text_dir)
|
|
333
|
+
images: list[tuple[np.ndarray, float]] = []
|
|
334
|
+
for view in views:
|
|
335
|
+
bgr = cv2.imread(str(image_dir / view.name))
|
|
336
|
+
if bgr is None:
|
|
337
|
+
raise ProcessingError(f"Could not read {view.name} for colouring.")
|
|
338
|
+
h, w = bgr.shape[:2]
|
|
339
|
+
scale = 1280 / max(h, w)
|
|
340
|
+
size = (round(w * scale), round(h * scale))
|
|
341
|
+
images.append((cv2.resize(bgr, size, interpolation=cv2.INTER_AREA), scale))
|
|
342
|
+
mesh: trimesh.Trimesh = trimesh.load(mesh_path, process=False, force="mesh") # type: ignore[assignment]
|
|
343
|
+
mesh.visual = trimesh.visual.ColorVisuals(
|
|
344
|
+
mesh, vertex_colors=_vertex_colors(mesh, views, images)
|
|
345
|
+
)
|
|
346
|
+
mesh.export(output_path)
|
|
347
|
+
return output_path
|
|
348
|
+
|
|
349
|
+
|
|
253
350
|
def visual_hull(
|
|
254
351
|
image_dir: Path,
|
|
255
352
|
model_text_dir: Path,
|
|
@@ -258,6 +355,7 @@ def visual_hull(
|
|
|
258
355
|
resolution: int = 176,
|
|
259
356
|
refinements: int = 3,
|
|
260
357
|
segmenter: Callable[[], ObjectSegmenter | None] | None = None,
|
|
358
|
+
depth: Callable[[], Callable[[np.ndarray], np.ndarray] | None] | None = None,
|
|
261
359
|
) -> HullResult:
|
|
262
360
|
"""Carve the object seen by every photo; writes a watertight, upright mesh.
|
|
263
361
|
|
|
@@ -385,6 +483,16 @@ def visual_hull(
|
|
|
385
483
|
masks = refined
|
|
386
484
|
keep = carve(masks, 1)
|
|
387
485
|
|
|
486
|
+
# Concavities and through-holes (a handle loop, the inside of a cup) never
|
|
487
|
+
# show in a silhouette: the AI depth of each photo reveals them.
|
|
488
|
+
estimator = depth() if depth is not None else None
|
|
489
|
+
carved_by_depth = 0
|
|
490
|
+
if estimator is not None:
|
|
491
|
+
before = int(keep.sum())
|
|
492
|
+
keep = _depth_carve(keep, grid, views, images, projections, masks, estimator)
|
|
493
|
+
carved_by_depth = before - int(keep.sum())
|
|
494
|
+
logger.info("AI depth carved %d voxel(s) of concavities", carved_by_depth)
|
|
495
|
+
|
|
388
496
|
volume = keep.reshape(n, n, n).astype(np.float32)
|
|
389
497
|
plane = _support_plane(points, center, radius, cameras)
|
|
390
498
|
if plane is not None:
|
|
@@ -438,5 +546,11 @@ def visual_hull(
|
|
|
438
546
|
mesh.export(output_path)
|
|
439
547
|
logger.info("Silhouette model: %d views, %d faces", len(views), len(mesh.faces))
|
|
440
548
|
return HullResult(
|
|
441
|
-
str(output_path),
|
|
549
|
+
str(output_path),
|
|
550
|
+
len(views),
|
|
551
|
+
voxels,
|
|
552
|
+
len(mesh.faces),
|
|
553
|
+
plane is not None,
|
|
554
|
+
ai is not None,
|
|
555
|
+
carved_by_depth,
|
|
442
556
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: reverse3d
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.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>
|
|
@@ -0,0 +1,284 @@
|
|
|
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
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _cup() -> trimesh.Trimesh:
|
|
162
|
+
"""An open-top box: its silhouette is a solid block from every side."""
|
|
163
|
+
parts = [
|
|
164
|
+
trimesh.creation.box(
|
|
165
|
+
extents=(0.8, 0.8, 0.12),
|
|
166
|
+
transform=trimesh.transformations.translation_matrix((0, 0, 0.06)),
|
|
167
|
+
),
|
|
168
|
+
trimesh.creation.box(
|
|
169
|
+
extents=(0.12, 0.8, 1.0),
|
|
170
|
+
transform=trimesh.transformations.translation_matrix((0.34, 0, 0.5)),
|
|
171
|
+
),
|
|
172
|
+
trimesh.creation.box(
|
|
173
|
+
extents=(0.12, 0.8, 1.0),
|
|
174
|
+
transform=trimesh.transformations.translation_matrix((-0.34, 0, 0.5)),
|
|
175
|
+
),
|
|
176
|
+
trimesh.creation.box(
|
|
177
|
+
extents=(0.8, 0.12, 1.0),
|
|
178
|
+
transform=trimesh.transformations.translation_matrix((0, 0.34, 0.5)),
|
|
179
|
+
),
|
|
180
|
+
trimesh.creation.box(
|
|
181
|
+
extents=(0.8, 0.12, 1.0),
|
|
182
|
+
transform=trimesh.transformations.translation_matrix((0, -0.34, 0.5)),
|
|
183
|
+
),
|
|
184
|
+
]
|
|
185
|
+
return trimesh.util.concatenate(parts)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _ray_boxes(origin: np.ndarray, dirs: np.ndarray, boxes: list[np.ndarray]) -> np.ndarray:
|
|
189
|
+
"""Distance along each ray to the nearest axis-aligned box (inf when missed)."""
|
|
190
|
+
best = np.full(len(dirs), np.inf)
|
|
191
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
192
|
+
inv = 1.0 / dirs
|
|
193
|
+
for lo, hi in boxes:
|
|
194
|
+
t1, t2 = (lo - origin) * inv, (hi - origin) * inv
|
|
195
|
+
near = np.nanmax(np.minimum(t1, t2), axis=1)
|
|
196
|
+
far = np.nanmin(np.maximum(t1, t2), axis=1)
|
|
197
|
+
hit = (near <= far) & (far > 0)
|
|
198
|
+
best = np.where(hit & (near < best), np.maximum(near, 0), best)
|
|
199
|
+
return best
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _cup_scene(
|
|
203
|
+
folder: Path, size: int = 120, focal: float = 130.0
|
|
204
|
+
) -> tuple[Path, Path, list[tuple[np.ndarray, np.ndarray]]]:
|
|
205
|
+
"""Photos of the cup with known cameras, plus the true AI-like disparity of each."""
|
|
206
|
+
images, text = folder / "images", folder / "txt"
|
|
207
|
+
images.mkdir(parents=True)
|
|
208
|
+
text.mkdir()
|
|
209
|
+
boxes = [part.bounds for part in _cup().split(only_watertight=False)]
|
|
210
|
+
rng = np.random.default_rng(1)
|
|
211
|
+
jj, ii = np.meshgrid(np.arange(size), np.arange(size))
|
|
212
|
+
rays_cam = np.stack(
|
|
213
|
+
[(jj - size / 2) / focal, (ii - size / 2) / focal, np.ones_like(jj, float)], -1
|
|
214
|
+
).reshape(-1, 3)
|
|
215
|
+
lines: list[str] = []
|
|
216
|
+
depth_by_image: list[tuple[np.ndarray, np.ndarray]] = []
|
|
217
|
+
k = 0
|
|
218
|
+
for height, count in ((0.7, 10), (2.6, 10), (3.6, 6)):
|
|
219
|
+
for i in range(count):
|
|
220
|
+
a = 2 * np.pi * i / count
|
|
221
|
+
eye = np.array([2.6 * np.cos(a), 2.6 * np.sin(a), height])
|
|
222
|
+
rot, t = _look_at(eye, np.array([0.0, 0.0, 0.5]))
|
|
223
|
+
dirs = rays_cam @ rot # camera → world
|
|
224
|
+
dirs /= np.linalg.norm(dirs, axis=1, keepdims=True)
|
|
225
|
+
depth = _ray_boxes(eye, dirs, boxes) * (dirs @ rot[2])
|
|
226
|
+
depth = depth.reshape(size, size)
|
|
227
|
+
image = cv2.GaussianBlur(
|
|
228
|
+
rng.integers(0, 110, (size, size, 3), dtype=np.uint8), (3, 3), 0
|
|
229
|
+
)
|
|
230
|
+
image[np.isfinite(depth)] = (235, 235, 235)
|
|
231
|
+
name = f"img_{k:02d}.png"
|
|
232
|
+
cv2.imwrite(str(images / name), image)
|
|
233
|
+
far = float(np.nanmax(np.where(np.isfinite(depth), depth, np.nan))) * 2
|
|
234
|
+
disparity = 1.0 / np.where(np.isfinite(depth), depth, far)
|
|
235
|
+
depth_by_image.append((cv2.imread(str(images / name)).astype(np.float32), disparity))
|
|
236
|
+
q = _quat(rot)
|
|
237
|
+
lines.append(f"{k + 1} {q[0]} {q[1]} {q[2]} {q[3]} {t[0]} {t[1]} {t[2]} 1 {name}\n\n")
|
|
238
|
+
k += 1
|
|
239
|
+
(text / "cameras.txt").write_text(
|
|
240
|
+
f"1 PINHOLE {size} {size} {focal} {focal} {size / 2} {size / 2}\n"
|
|
241
|
+
)
|
|
242
|
+
(text / "images.txt").write_text("".join(lines))
|
|
243
|
+
table = np.column_stack([rng.uniform(-1, 1, 400), rng.uniform(-1, 1, 400), np.zeros(400)])
|
|
244
|
+
table = table[(np.abs(table[:, 0]) > 0.45) | (np.abs(table[:, 1]) > 0.45)]
|
|
245
|
+
rows = (f"{i + 1} {x} {y} {z} 0 0 0 0\n" for i, (x, y, z) in enumerate(table))
|
|
246
|
+
(text / "points3D.txt").write_text("".join(rows))
|
|
247
|
+
return images, text, depth_by_image
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_ai_depth_carves_the_inside_of_a_cup(tmp_path: Path) -> None:
|
|
251
|
+
images, text, truth = _cup_scene(tmp_path)
|
|
252
|
+
|
|
253
|
+
def estimator(rgb: np.ndarray) -> np.ndarray:
|
|
254
|
+
# The hull hands over its (resized) copy of a photo: find which one it is
|
|
255
|
+
# and return that photo's true disparity, like a perfect depth AI would.
|
|
256
|
+
small = cv2.resize(cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR), truth[0][0].shape[1::-1])
|
|
257
|
+
best = min(truth, key=lambda item: float(np.abs(item[0] - small).mean()))
|
|
258
|
+
return cv2.resize(best[1], (rgb.shape[1], rgb.shape[0]), interpolation=cv2.INTER_NEAREST)
|
|
259
|
+
|
|
260
|
+
plain = visual_hull(images, text, tmp_path / "plain.ply", resolution=64, refinements=1)
|
|
261
|
+
carved = visual_hull(
|
|
262
|
+
images, text, tmp_path / "carved.ply", resolution=64, refinements=1, depth=lambda: estimator
|
|
263
|
+
)
|
|
264
|
+
assert carved.depth_carved_voxels > 0
|
|
265
|
+
solid = trimesh.load(plain.mesh_path)
|
|
266
|
+
cup = trimesh.load(carved.mesh_path)
|
|
267
|
+
# The silhouettes alone fill the cup; the AI depth empties a good part of it.
|
|
268
|
+
assert cup.volume < 0.8 * solid.volume
|
|
269
|
+
assert cup.is_watertight
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_any_mesh_is_coloured_from_the_photos(tmp_path: Path) -> None:
|
|
273
|
+
from reverse3d.reconstruction.silhouette import colorize_from_photos
|
|
274
|
+
|
|
275
|
+
images, text = _scene(tmp_path)
|
|
276
|
+
mesh = trimesh.creation.cylinder(radius=RADIUS * 0.98, height=HEIGHT * 0.98, sections=48)
|
|
277
|
+
mesh = mesh.subdivide().subdivide() # vertices along the sides, not only at the caps
|
|
278
|
+
mesh.apply_translation([0, 0, HEIGHT / 2])
|
|
279
|
+
plain = tmp_path / "cylinder.ply"
|
|
280
|
+
mesh.export(plain)
|
|
281
|
+
out = colorize_from_photos(plain, images, text, tmp_path / "colour.ply")
|
|
282
|
+
colours = np.asarray(trimesh.load(out, process=False).visual.vertex_colors)[:, :3]
|
|
283
|
+
side = np.abs(mesh.vertices[:, 2] - HEIGHT / 2) < HEIGHT * 0.4
|
|
284
|
+
assert colours[side].mean() > 200 # the photographed cylinder is white
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.8.1"
|
|
@@ -1,158 +0,0 @@
|
|
|
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
|
|
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
|
|
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
|