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.
- {reverse3d-0.6.0/reverse3d.egg-info → reverse3d-0.7.0}/PKG-INFO +1 -1
- reverse3d-0.7.0/reverse3d/_version.py +1 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/offline.py +9 -4
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/recipes.py +15 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/reconstruction/silhouette.py +41 -1
- reverse3d-0.7.0/reverse3d/vision/segmenter.py +136 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0/reverse3d.egg-info}/PKG-INFO +1 -1
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d.egg-info/SOURCES.txt +1 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_offline_autopilot.py +2 -1
- {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_silhouette.py +52 -0
- reverse3d-0.6.0/reverse3d/_version.py +0 -1
- {reverse3d-0.6.0 → reverse3d-0.7.0}/LICENSE +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/NOTICE +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/README.md +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/pyproject.toml +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/__init__.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/__main__.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/_about.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/agent/__init__.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/agent/compact.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/agent/mcp_server.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/agent/prompts.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/agent/skill/SKILL.md +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/agent/tools.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/autopilot.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/cli.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/config.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/doctor.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/errors.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/hardware.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/imaging.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/logging_utils.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/mesh_io.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/profiles.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/py.typed +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/reconstruction/__init__.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/reconstruction/depth_estimation.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/reconstruction/photogrammetry.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/reconstruction/pointcloud.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/rendering/__init__.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/rendering/_blender_job.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/rendering/blender_render.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/rendering/preview.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/reverse_eng/__init__.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/reverse_eng/geometry_fit.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/reverse_eng/measure.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/reverse_eng/mesh_cleaner.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/reverse_eng/parametric.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/scenes.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/security.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/setup_agents.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/userconfig.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/vision/__init__.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/vision/feature_matcher.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/vision/mask_extractor.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d/watch.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d.egg-info/dependency_links.txt +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d.egg-info/entry_points.txt +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d.egg-info/requires.txt +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/reverse3d.egg-info/top_level.txt +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/setup.cfg +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_agent_tools.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_authorship.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_automation.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_cli.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_cli_automation.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_config.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_geometry_fit.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_heic.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_mesh.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_optional_extras.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_reconstruction.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_render.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_scenes.py +0 -0
- {reverse3d-0.6.0 → reverse3d-0.7.0}/tests/test_security.py +0 -0
- {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.
|
|
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
|
|
106
|
+
return {**ALLOWED_MODELS, **ALLOWED_SEGMENTERS}
|
|
105
107
|
|
|
106
108
|
|
|
107
109
|
def _default_model() -> str:
|
|
@@ -177,7 +179,7 @@ def resolve_model(model_id: str, settings: Settings) -> Path:
|
|
|
177
179
|
return local
|
|
178
180
|
if is_offline(settings):
|
|
179
181
|
raise DependencyMissingError(
|
|
180
|
-
"The AI
|
|
182
|
+
"The AI model is not on this computer and there is no internet connection.",
|
|
181
183
|
hint="Connect to the internet once and run: reverse3d offline prepare",
|
|
182
184
|
)
|
|
183
185
|
try:
|
|
@@ -190,7 +192,7 @@ def resolve_model(model_id: str, settings: Settings) -> Path:
|
|
|
190
192
|
except Exception as exc: # network errors come in many shapes; report them plainly
|
|
191
193
|
has_internet(refresh=True)
|
|
192
194
|
raise DependencyMissingError(
|
|
193
|
-
f"Could not download the AI
|
|
195
|
+
f"Could not download the AI model ({exc.__class__.__name__}).",
|
|
194
196
|
hint="Check your connection and run: reverse3d offline prepare",
|
|
195
197
|
) from exc
|
|
196
198
|
|
|
@@ -201,7 +203,10 @@ def prepare(settings: Settings, *, include_non_commercial: bool = False) -> dict
|
|
|
201
203
|
Safe to run any time: models already on disk are skipped. It never raises
|
|
202
204
|
for a missing optional stack; that is reported in the result instead.
|
|
203
205
|
"""
|
|
204
|
-
|
|
206
|
+
from reverse3d.vision.segmenter import DEFAULT_SEGMENTER
|
|
207
|
+
|
|
208
|
+
# Depth (reliefs) and segmentation (object silhouettes), both commercial-friendly.
|
|
209
|
+
models = [_default_model(), DEFAULT_SEGMENTER]
|
|
205
210
|
if include_non_commercial:
|
|
206
211
|
models += [m for m in _allowed_models() if m not in models]
|
|
207
212
|
|
|
@@ -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(
|
|
374
|
+
return HullResult(
|
|
375
|
+
str(output_path), len(views), voxels, len(mesh.faces), plane is not None, ai is not None
|
|
376
|
+
)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""AI object segmentation (Segment Anything) for silhouette carving.
|
|
2
|
+
|
|
3
|
+
GrabCut only compares colours, so a dark cap or handle in front of a dark
|
|
4
|
+
background is cut off. Segment Anything (Meta, Apache-2.0) understands what
|
|
5
|
+
"the object" is: given a box and a point on it, it returns the whole object,
|
|
6
|
+
cap and handle included.
|
|
7
|
+
|
|
8
|
+
The model is allowlisted, pinned to an exact commit and loaded from
|
|
9
|
+
safetensors only (see :mod:`reverse3d.offline`); after the first download it
|
|
10
|
+
works without internet. Everything degrades gracefully: without the AI extra
|
|
11
|
+
or the model, callers fall back to GrabCut.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from functools import lru_cache
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from reverse3d.config import Settings
|
|
23
|
+
from reverse3d.errors import DependencyMissingError
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
#: Allowlisted segmentation models: id -> (kind, license).
|
|
28
|
+
ALLOWED_SEGMENTERS: dict[str, tuple[str, str]] = {
|
|
29
|
+
"facebook/sam-vit-base": ("sam", "Apache-2.0"),
|
|
30
|
+
}
|
|
31
|
+
DEFAULT_SEGMENTER = "facebook/sam-vit-base"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def segmenter_available() -> bool:
|
|
35
|
+
"""True when the AI stack (torch + transformers) is installed."""
|
|
36
|
+
try:
|
|
37
|
+
import torch # noqa: F401
|
|
38
|
+
import transformers # noqa: F401
|
|
39
|
+
except ImportError:
|
|
40
|
+
return False
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@lru_cache(maxsize=2)
|
|
45
|
+
def _load(model_id: str, device: str, model_dir: str) -> tuple[Any, Any]:
|
|
46
|
+
from transformers import SamModel, SamProcessor
|
|
47
|
+
|
|
48
|
+
try: # keep CLI/agent output clean: no loading progress bars
|
|
49
|
+
from transformers.utils import logging as hf_logging
|
|
50
|
+
|
|
51
|
+
hf_logging.disable_progress_bar()
|
|
52
|
+
hf_logging.set_verbosity_error()
|
|
53
|
+
except ImportError: # pragma: no cover - very old transformers
|
|
54
|
+
pass
|
|
55
|
+
# model_dir is the local snapshot of a commit-pinned, allowlisted model
|
|
56
|
+
# (reverse3d.offline.resolve_model): nothing is fetched here.
|
|
57
|
+
model: Any = SamModel.from_pretrained( # nosec B615
|
|
58
|
+
model_dir, local_files_only=True, use_safetensors=True, trust_remote_code=False
|
|
59
|
+
)
|
|
60
|
+
processor = SamProcessor.from_pretrained(model_dir, local_files_only=True) # nosec B615
|
|
61
|
+
model.to(device).eval()
|
|
62
|
+
logger.info("Loaded %s on %s", model_id, device)
|
|
63
|
+
return model, processor
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ObjectSegmenter:
|
|
67
|
+
"""Segments the object inside a box (plus a point on it) in RGB images.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
DependencyMissingError: the AI extra or the model is not available.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, settings: Settings, device: str = "cpu", model_id: str = DEFAULT_SEGMENTER):
|
|
74
|
+
if not segmenter_available():
|
|
75
|
+
raise DependencyMissingError(
|
|
76
|
+
"The AI segmentation stack is not installed.",
|
|
77
|
+
hint='pip install "reverse3d[depth]"',
|
|
78
|
+
)
|
|
79
|
+
from reverse3d.offline import force_offline_env, is_offline, resolve_model
|
|
80
|
+
|
|
81
|
+
self.device = device
|
|
82
|
+
model_dir = resolve_model(model_id, settings)
|
|
83
|
+
if is_offline(settings):
|
|
84
|
+
force_offline_env()
|
|
85
|
+
self.model, self.processor = _load(model_id, device, str(model_dir))
|
|
86
|
+
|
|
87
|
+
def embed(self, rgb: np.ndarray) -> dict[str, Any]:
|
|
88
|
+
"""Run the heavy image encoder once; reuse the result for several prompts."""
|
|
89
|
+
import torch
|
|
90
|
+
|
|
91
|
+
inputs = self.processor(rgb, return_tensors="pt").to(self.device)
|
|
92
|
+
with torch.no_grad():
|
|
93
|
+
embeddings = self.model.get_image_embeddings(inputs["pixel_values"])
|
|
94
|
+
return {
|
|
95
|
+
"rgb": rgb,
|
|
96
|
+
"embeddings": embeddings,
|
|
97
|
+
"original_sizes": inputs["original_sizes"],
|
|
98
|
+
"reshaped_input_sizes": inputs["reshaped_input_sizes"],
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
def segment(
|
|
102
|
+
self,
|
|
103
|
+
image: np.ndarray | dict[str, Any],
|
|
104
|
+
box: tuple[int, int, int, int],
|
|
105
|
+
point: tuple[int, int],
|
|
106
|
+
) -> np.ndarray:
|
|
107
|
+
"""Object mask (uint8, 255 = object) for a box ``(x0, y0, x1, y1)`` and a point.
|
|
108
|
+
|
|
109
|
+
``image`` is an RGB array or the result of :meth:`embed` (much faster).
|
|
110
|
+
"""
|
|
111
|
+
import torch
|
|
112
|
+
|
|
113
|
+
state = image if isinstance(image, dict) else self.embed(image)
|
|
114
|
+
prompts = self.processor(
|
|
115
|
+
state["rgb"],
|
|
116
|
+
input_boxes=[[[float(v) for v in box]]],
|
|
117
|
+
input_points=[[[[float(point[0]), float(point[1])]]]],
|
|
118
|
+
input_labels=[[[1]]],
|
|
119
|
+
return_tensors="pt",
|
|
120
|
+
).to(self.device)
|
|
121
|
+
with torch.no_grad():
|
|
122
|
+
outputs = self.model(
|
|
123
|
+
image_embeddings=state["embeddings"],
|
|
124
|
+
input_boxes=prompts["input_boxes"],
|
|
125
|
+
input_points=prompts["input_points"],
|
|
126
|
+
input_labels=prompts["input_labels"],
|
|
127
|
+
multimask_output=True,
|
|
128
|
+
)
|
|
129
|
+
masks = self.processor.image_processor.post_process_masks(
|
|
130
|
+
outputs.pred_masks.cpu(),
|
|
131
|
+
state["original_sizes"].cpu(),
|
|
132
|
+
state["reshaped_input_sizes"].cpu(),
|
|
133
|
+
)[0][0]
|
|
134
|
+
scores = outputs.iou_scores.cpu()[0, 0]
|
|
135
|
+
best = int(torch.argmax(scores))
|
|
136
|
+
return np.where(masks[best].numpy(), 255, 0).astype(np.uint8)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: reverse3d
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: AI-agent-ready toolkit for 3D reverse engineering: image analysis, photogrammetry, mesh repair, primitive fitting and headless rendering.
|
|
5
5
|
Author-email: Voxel Enterprise <info@voxel.lat>
|
|
6
6
|
Maintainer-email: Voxel Enterprise <info@voxel.lat>
|
|
@@ -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
|
|
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
|