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.
Files changed (76) hide show
  1. {reverse3d-0.5.2/reverse3d.egg-info → reverse3d-0.7.0}/PKG-INFO +2 -1
  2. {reverse3d-0.5.2 → reverse3d-0.7.0}/pyproject.toml +1 -0
  3. reverse3d-0.7.0/reverse3d/_version.py +1 -0
  4. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/offline.py +9 -4
  5. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/recipes.py +68 -25
  6. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/reconstruction/photogrammetry.py +29 -1
  7. reverse3d-0.7.0/reverse3d/reconstruction/silhouette.py +376 -0
  8. reverse3d-0.7.0/reverse3d/vision/segmenter.py +136 -0
  9. {reverse3d-0.5.2 → reverse3d-0.7.0/reverse3d.egg-info}/PKG-INFO +2 -1
  10. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d.egg-info/SOURCES.txt +3 -0
  11. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d.egg-info/requires.txt +1 -0
  12. {reverse3d-0.5.2 → reverse3d-0.7.0}/tests/test_automation.py +5 -1
  13. {reverse3d-0.5.2 → reverse3d-0.7.0}/tests/test_offline_autopilot.py +2 -1
  14. {reverse3d-0.5.2 → reverse3d-0.7.0}/tests/test_reconstruction.py +4 -1
  15. reverse3d-0.7.0/tests/test_silhouette.py +158 -0
  16. reverse3d-0.5.2/reverse3d/_version.py +0 -1
  17. {reverse3d-0.5.2 → reverse3d-0.7.0}/LICENSE +0 -0
  18. {reverse3d-0.5.2 → reverse3d-0.7.0}/NOTICE +0 -0
  19. {reverse3d-0.5.2 → reverse3d-0.7.0}/README.md +0 -0
  20. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/__init__.py +0 -0
  21. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/__main__.py +0 -0
  22. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/_about.py +0 -0
  23. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/agent/__init__.py +0 -0
  24. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/agent/compact.py +0 -0
  25. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/agent/mcp_server.py +0 -0
  26. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/agent/prompts.py +0 -0
  27. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/agent/skill/SKILL.md +0 -0
  28. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/agent/tools.py +0 -0
  29. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/autopilot.py +0 -0
  30. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/cli.py +0 -0
  31. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/config.py +0 -0
  32. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/doctor.py +0 -0
  33. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/errors.py +0 -0
  34. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/hardware.py +0 -0
  35. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/imaging.py +0 -0
  36. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/logging_utils.py +0 -0
  37. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/mesh_io.py +0 -0
  38. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/profiles.py +0 -0
  39. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/py.typed +0 -0
  40. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/reconstruction/__init__.py +0 -0
  41. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/reconstruction/depth_estimation.py +0 -0
  42. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/reconstruction/pointcloud.py +0 -0
  43. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/rendering/__init__.py +0 -0
  44. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/rendering/_blender_job.py +0 -0
  45. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/rendering/blender_render.py +0 -0
  46. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/rendering/preview.py +0 -0
  47. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/reverse_eng/__init__.py +0 -0
  48. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/reverse_eng/geometry_fit.py +0 -0
  49. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/reverse_eng/measure.py +0 -0
  50. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/reverse_eng/mesh_cleaner.py +0 -0
  51. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/reverse_eng/parametric.py +0 -0
  52. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/scenes.py +0 -0
  53. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/security.py +0 -0
  54. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/setup_agents.py +0 -0
  55. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/userconfig.py +0 -0
  56. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/vision/__init__.py +0 -0
  57. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/vision/feature_matcher.py +0 -0
  58. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/vision/mask_extractor.py +0 -0
  59. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d/watch.py +0 -0
  60. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d.egg-info/dependency_links.txt +0 -0
  61. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d.egg-info/entry_points.txt +0 -0
  62. {reverse3d-0.5.2 → reverse3d-0.7.0}/reverse3d.egg-info/top_level.txt +0 -0
  63. {reverse3d-0.5.2 → reverse3d-0.7.0}/setup.cfg +0 -0
  64. {reverse3d-0.5.2 → reverse3d-0.7.0}/tests/test_agent_tools.py +0 -0
  65. {reverse3d-0.5.2 → reverse3d-0.7.0}/tests/test_authorship.py +0 -0
  66. {reverse3d-0.5.2 → reverse3d-0.7.0}/tests/test_cli.py +0 -0
  67. {reverse3d-0.5.2 → reverse3d-0.7.0}/tests/test_cli_automation.py +0 -0
  68. {reverse3d-0.5.2 → reverse3d-0.7.0}/tests/test_config.py +0 -0
  69. {reverse3d-0.5.2 → reverse3d-0.7.0}/tests/test_geometry_fit.py +0 -0
  70. {reverse3d-0.5.2 → reverse3d-0.7.0}/tests/test_heic.py +0 -0
  71. {reverse3d-0.5.2 → reverse3d-0.7.0}/tests/test_mesh.py +0 -0
  72. {reverse3d-0.5.2 → reverse3d-0.7.0}/tests/test_optional_extras.py +0 -0
  73. {reverse3d-0.5.2 → reverse3d-0.7.0}/tests/test_render.py +0 -0
  74. {reverse3d-0.5.2 → reverse3d-0.7.0}/tests/test_scenes.py +0 -0
  75. {reverse3d-0.5.2 → reverse3d-0.7.0}/tests/test_security.py +0 -0
  76. {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.5.2
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
@@ -39,6 +39,7 @@ classifiers = [
39
39
  dependencies = [
40
40
  "numpy>=1.26",
41
41
  "scipy>=1.11",
42
+ "scikit-image>=0.22",
42
43
  "opencv-python-headless>=4.9",
43
44
  "pillow>=10.3",
44
45
  "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 dict(ALLOWED_MODELS)
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 depth model is not on this computer and there is no internet connection.",
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 depth model ({exc.__class__.__name__}).",
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
- models = [_default_model()]
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 PhotogrammetryOptions, run_photogrammetry
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
- # Part of the set overlaps well: reconstruct from that group only.
223
- dropped = set(analysis.weak_links)
224
- group_dir = run.out / "photos_used"
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
- photo = run.step(
248
- "photogrammetry",
249
- lambda: run_photogrammetry(
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 photo.mesh_path:
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(photo.mesh_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 = ("database.db", "images", "sparse", "dense", "sparse_points.ply", "mesh.ply")
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.5.2
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
@@ -1,5 +1,6 @@
1
1
  numpy>=1.26
2
2
  scipy>=1.11
3
+ scikit-image>=0.22
3
4
  opencv-python-headless>=4.9
4
5
  pillow>=10.3
5
6
  pillow-heif>=1.1
@@ -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]).write_text("ply")
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
- Path(opt("--output_path")).write_text("ply\n")
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