reverse3d 0.6.0__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.6.0/reverse3d.egg-info → reverse3d-0.7.0}/PKG-INFO +1 -1
  2. reverse3d-0.7.0/reverse3d/_version.py +1 -0
  3. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/offline.py +9 -4
  4. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/recipes.py +15 -0
  5. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/reconstruction/silhouette.py +41 -1
  6. reverse3d-0.7.0/reverse3d/vision/segmenter.py +136 -0
  7. {reverse3d-0.6.0 → reverse3d-0.7.0/reverse3d.egg-info}/PKG-INFO +1 -1
  8. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d.egg-info/SOURCES.txt +1 -0
  9. {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_offline_autopilot.py +2 -1
  10. {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_silhouette.py +52 -0
  11. reverse3d-0.6.0/reverse3d/_version.py +0 -1
  12. {reverse3d-0.6.0 → reverse3d-0.7.0}/LICENSE +0 -0
  13. {reverse3d-0.6.0 → reverse3d-0.7.0}/NOTICE +0 -0
  14. {reverse3d-0.6.0 → reverse3d-0.7.0}/README.md +0 -0
  15. {reverse3d-0.6.0 → reverse3d-0.7.0}/pyproject.toml +0 -0
  16. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/__init__.py +0 -0
  17. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/__main__.py +0 -0
  18. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/_about.py +0 -0
  19. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/agent/__init__.py +0 -0
  20. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/agent/compact.py +0 -0
  21. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/agent/mcp_server.py +0 -0
  22. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/agent/prompts.py +0 -0
  23. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/agent/skill/SKILL.md +0 -0
  24. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/agent/tools.py +0 -0
  25. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/autopilot.py +0 -0
  26. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/cli.py +0 -0
  27. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/config.py +0 -0
  28. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/doctor.py +0 -0
  29. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/errors.py +0 -0
  30. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/hardware.py +0 -0
  31. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/imaging.py +0 -0
  32. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/logging_utils.py +0 -0
  33. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/mesh_io.py +0 -0
  34. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/profiles.py +0 -0
  35. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/py.typed +0 -0
  36. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/reconstruction/__init__.py +0 -0
  37. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/reconstruction/depth_estimation.py +0 -0
  38. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/reconstruction/photogrammetry.py +0 -0
  39. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/reconstruction/pointcloud.py +0 -0
  40. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/rendering/__init__.py +0 -0
  41. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/rendering/_blender_job.py +0 -0
  42. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/rendering/blender_render.py +0 -0
  43. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/rendering/preview.py +0 -0
  44. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/reverse_eng/__init__.py +0 -0
  45. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/reverse_eng/geometry_fit.py +0 -0
  46. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/reverse_eng/measure.py +0 -0
  47. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/reverse_eng/mesh_cleaner.py +0 -0
  48. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/reverse_eng/parametric.py +0 -0
  49. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/scenes.py +0 -0
  50. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/security.py +0 -0
  51. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/setup_agents.py +0 -0
  52. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/userconfig.py +0 -0
  53. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/vision/__init__.py +0 -0
  54. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/vision/feature_matcher.py +0 -0
  55. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/vision/mask_extractor.py +0 -0
  56. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/watch.py +0 -0
  57. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d.egg-info/dependency_links.txt +0 -0
  58. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d.egg-info/entry_points.txt +0 -0
  59. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d.egg-info/requires.txt +0 -0
  60. {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d.egg-info/top_level.txt +0 -0
  61. {reverse3d-0.6.0 → reverse3d-0.7.0}/setup.cfg +0 -0
  62. {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_agent_tools.py +0 -0
  63. {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_authorship.py +0 -0
  64. {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_automation.py +0 -0
  65. {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_cli.py +0 -0
  66. {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_cli_automation.py +0 -0
  67. {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_config.py +0 -0
  68. {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_geometry_fit.py +0 -0
  69. {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_heic.py +0 -0
  70. {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_mesh.py +0 -0
  71. {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_optional_extras.py +0 -0
  72. {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_reconstruction.py +0 -0
  73. {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_render.py +0 -0
  74. {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_scenes.py +0 -0
  75. {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_security.py +0 -0
  76. {reverse3d-0.6.0 → 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.6.0
3
+ Version: 0.7.0
4
4
  Summary: AI-agent-ready toolkit for 3D reverse engineering: image analysis, photogrammetry, mesh repair, primitive fitting and headless rendering.
5
5
  Author-email: Voxel Enterprise <info@voxel.lat>
6
6
  Maintainer-email: Voxel Enterprise <info@voxel.lat>
@@ -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
 
@@ -36,6 +36,7 @@ from reverse3d.errors import (
36
36
  DependencyMissingError,
37
37
  InputValidationError,
38
38
  ProcessingError,
39
+ Reverse3DError,
39
40
  )
40
41
  from reverse3d.hardware import torch_device, wants_gpu
41
42
  from reverse3d.profiles import profile_values
@@ -262,6 +263,19 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
262
263
  upright = False
263
264
  if silhouette:
264
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
265
279
 
266
280
  try:
267
281
  hull = run.step(
@@ -270,6 +284,7 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
270
284
  Path(photo.images_dir or ""),
271
285
  Path(photo.sparse_text_dir or ""),
272
286
  run.out / "reconstruction" / "object_hull.ply",
287
+ segmenter=load_segmenter,
273
288
  ),
274
289
  )
275
290
  except ProcessingError as exc:
@@ -23,6 +23,7 @@ from __future__ import annotations
23
23
  import logging
24
24
  from dataclasses import asdict, dataclass
25
25
  from pathlib import Path
26
+ from typing import TYPE_CHECKING
26
27
 
27
28
  import cv2
28
29
  import numpy as np
@@ -30,6 +31,11 @@ import trimesh
30
31
 
31
32
  from reverse3d.errors import DependencyMissingError, ProcessingError
32
33
 
34
+ if TYPE_CHECKING:
35
+ from collections.abc import Callable
36
+
37
+ from reverse3d.vision.segmenter import ObjectSegmenter
38
+
33
39
  logger = logging.getLogger(__name__)
34
40
 
35
41
  _WORK_SIDE = 640
@@ -62,6 +68,7 @@ class HullResult:
62
68
  voxels: int
63
69
  faces: int
64
70
  support_plane: bool
71
+ ai_segmentation: bool = False
65
72
 
66
73
  def to_dict(self) -> dict[str, object]:
67
74
  return asdict(self)
@@ -191,6 +198,7 @@ def visual_hull(
191
198
  *,
192
199
  resolution: int = 176,
193
200
  refinements: int = 3,
201
+ segmenter: Callable[[], ObjectSegmenter | None] | None = None,
194
202
  ) -> HullResult:
195
203
  """Carve the object seen by every photo; writes a watertight, upright mesh.
196
204
 
@@ -269,6 +277,36 @@ def visual_hull(
269
277
  return (seen >= 3) & (votes >= seen - tolerance)
270
278
 
271
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
272
310
  for _ in range(refinements):
273
311
  refined: list[np.ndarray] = []
274
312
  for (small, _), (u, v, inside), old in zip(images, projections, masks, strict=True):
@@ -333,4 +371,6 @@ def visual_hull(
333
371
  output_path.parent.mkdir(parents=True, exist_ok=True)
334
372
  mesh.export(output_path)
335
373
  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)
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.6.0
3
+ Version: 0.7.0
4
4
  Summary: AI-agent-ready toolkit for 3D reverse engineering: image analysis, photogrammetry, mesh repair, primitive fitting and headless rendering.
5
5
  Author-email: Voxel Enterprise <info@voxel.lat>
6
6
  Maintainer-email: Voxel Enterprise <info@voxel.lat>
@@ -53,6 +53,7 @@ reverse3d/reverse_eng/parametric.py
53
53
  reverse3d/vision/__init__.py
54
54
  reverse3d/vision/feature_matcher.py
55
55
  reverse3d/vision/mask_extractor.py
56
+ reverse3d/vision/segmenter.py
56
57
  tests/test_agent_tools.py
57
58
  tests/test_authorship.py
58
59
  tests/test_automation.py
@@ -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)
@@ -104,3 +104,55 @@ def test_too_few_photos(tmp_path: Path) -> None:
104
104
  (text / "images.txt").write_text("\n\n".join(rows[:5]) + "\n\n")
105
105
  with pytest.raises(ProcessingError, match="at least"):
106
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.6.0"
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