reverse3d 0.4.0__py3-none-any.whl
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/__init__.py +39 -0
- reverse3d/__main__.py +4 -0
- reverse3d/_about.py +15 -0
- reverse3d/_version.py +1 -0
- reverse3d/agent/__init__.py +5 -0
- reverse3d/agent/compact.py +110 -0
- reverse3d/agent/mcp_server.py +171 -0
- reverse3d/agent/prompts.py +173 -0
- reverse3d/agent/skill/SKILL.md +53 -0
- reverse3d/agent/tools.py +811 -0
- reverse3d/autopilot.py +355 -0
- reverse3d/cli.py +1054 -0
- reverse3d/config.py +167 -0
- reverse3d/doctor.py +113 -0
- reverse3d/errors.py +83 -0
- reverse3d/hardware.py +115 -0
- reverse3d/imaging.py +79 -0
- reverse3d/logging_utils.py +41 -0
- reverse3d/mesh_io.py +152 -0
- reverse3d/offline.py +313 -0
- reverse3d/profiles.py +33 -0
- reverse3d/py.typed +0 -0
- reverse3d/recipes.py +394 -0
- reverse3d/reconstruction/__init__.py +22 -0
- reverse3d/reconstruction/depth_estimation.py +263 -0
- reverse3d/reconstruction/photogrammetry.py +460 -0
- reverse3d/reconstruction/pointcloud.py +120 -0
- reverse3d/rendering/__init__.py +10 -0
- reverse3d/rendering/_blender_job.py +284 -0
- reverse3d/rendering/blender_render.py +215 -0
- reverse3d/rendering/preview.py +80 -0
- reverse3d/reverse_eng/__init__.py +33 -0
- reverse3d/reverse_eng/geometry_fit.py +478 -0
- reverse3d/reverse_eng/measure.py +87 -0
- reverse3d/reverse_eng/mesh_cleaner.py +304 -0
- reverse3d/reverse_eng/parametric.py +154 -0
- reverse3d/security.py +294 -0
- reverse3d/setup_agents.py +517 -0
- reverse3d/userconfig.py +156 -0
- reverse3d/vision/__init__.py +12 -0
- reverse3d/vision/feature_matcher.py +272 -0
- reverse3d/vision/mask_extractor.py +352 -0
- reverse3d/watch.py +238 -0
- reverse3d-0.4.0.dist-info/METADATA +650 -0
- reverse3d-0.4.0.dist-info/RECORD +50 -0
- reverse3d-0.4.0.dist-info/WHEEL +5 -0
- reverse3d-0.4.0.dist-info/entry_points.txt +2 -0
- reverse3d-0.4.0.dist-info/licenses/LICENSE +202 -0
- reverse3d-0.4.0.dist-info/licenses/NOTICE +27 -0
- reverse3d-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Image quality analysis and feature matching for multi-view capture sets.
|
|
2
|
+
|
|
3
|
+
Before spending minutes (or hours) in COLMAP, an agent can call
|
|
4
|
+
:func:`analyze_image_set` to learn whether the photos are sharp, well exposed,
|
|
5
|
+
textured enough and overlapping enough, and get actionable recommendations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from dataclasses import asdict, dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
import cv2
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from reverse3d.config import Settings
|
|
19
|
+
from reverse3d.errors import InputValidationError
|
|
20
|
+
from reverse3d.imaging import downscale, load_image, save_image
|
|
21
|
+
from reverse3d.security import list_images, validate_input_dir
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# AKAZE is intentionally not offered: OpenCV 5 moved it out of the base package.
|
|
26
|
+
Detector = Literal["sift", "orb"]
|
|
27
|
+
|
|
28
|
+
_ANALYSIS_MAX_DIM = 1600
|
|
29
|
+
# Empirical thresholds on images downscaled to 1600 px; documented in docs/architecture.md.
|
|
30
|
+
_BLUR_THRESHOLD = 100.0
|
|
31
|
+
_MIN_KEYPOINTS = 500
|
|
32
|
+
_MIN_PAIR_INLIERS = 40
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ImageQuality:
|
|
37
|
+
"""Per-image quality metrics."""
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
width: int
|
|
41
|
+
height: int
|
|
42
|
+
sharpness: float
|
|
43
|
+
brightness: float
|
|
44
|
+
contrast: float
|
|
45
|
+
keypoints: int
|
|
46
|
+
issues: list[str] = field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class PairMatch:
|
|
51
|
+
"""Feature matching statistics for an image pair."""
|
|
52
|
+
|
|
53
|
+
image_a: str
|
|
54
|
+
image_b: str
|
|
55
|
+
detector: str
|
|
56
|
+
keypoints_a: int
|
|
57
|
+
keypoints_b: int
|
|
58
|
+
tentative_matches: int
|
|
59
|
+
inliers: int
|
|
60
|
+
inlier_ratio: float
|
|
61
|
+
visualization_path: str | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class ImageSetReport:
|
|
66
|
+
"""Aggregate report returned by :func:`analyze_image_set`."""
|
|
67
|
+
|
|
68
|
+
image_count: int
|
|
69
|
+
images: list[ImageQuality]
|
|
70
|
+
pairs: list[PairMatch]
|
|
71
|
+
weak_links: list[str]
|
|
72
|
+
suitable_for_photogrammetry: bool
|
|
73
|
+
recommendations: list[str]
|
|
74
|
+
|
|
75
|
+
def to_dict(self) -> dict[str, object]:
|
|
76
|
+
return asdict(self)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _create_detector(detector: Detector, max_features: int) -> cv2.Feature2D:
|
|
80
|
+
if detector == "sift":
|
|
81
|
+
return cv2.SIFT.create(nfeatures=max_features)
|
|
82
|
+
if detector == "orb":
|
|
83
|
+
return cv2.ORB.create(nfeatures=max_features)
|
|
84
|
+
raise InputValidationError(f"Unknown detector: {detector}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _norm_type(detector: Detector) -> int:
|
|
88
|
+
return cv2.NORM_L2 if detector == "sift" else cv2.NORM_HAMMING
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _gray(image: np.ndarray) -> np.ndarray:
|
|
92
|
+
return cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 else image
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def assess_image(path: Path, settings: Settings, detector: Detector = "sift") -> ImageQuality:
|
|
96
|
+
"""Compute sharpness (variance of Laplacian), exposure and texture metrics."""
|
|
97
|
+
image = load_image(path, settings)
|
|
98
|
+
h, w = image.shape[:2]
|
|
99
|
+
small, _ = downscale(image, _ANALYSIS_MAX_DIM)
|
|
100
|
+
gray = _gray(small)
|
|
101
|
+
sharpness = float(cv2.Laplacian(gray, cv2.CV_64F).var())
|
|
102
|
+
brightness = float(gray.mean())
|
|
103
|
+
contrast = float(gray.std())
|
|
104
|
+
keypoints = len(_create_detector(detector, 8000).detect(gray, None))
|
|
105
|
+
|
|
106
|
+
issues: list[str] = []
|
|
107
|
+
if sharpness < _BLUR_THRESHOLD:
|
|
108
|
+
issues.append("blurry")
|
|
109
|
+
if brightness < 40:
|
|
110
|
+
issues.append("underexposed")
|
|
111
|
+
elif brightness > 220:
|
|
112
|
+
issues.append("overexposed")
|
|
113
|
+
if contrast < 20:
|
|
114
|
+
issues.append("low_contrast")
|
|
115
|
+
if keypoints < _MIN_KEYPOINTS:
|
|
116
|
+
issues.append("low_texture")
|
|
117
|
+
return ImageQuality(
|
|
118
|
+
name=path.name,
|
|
119
|
+
width=w,
|
|
120
|
+
height=h,
|
|
121
|
+
sharpness=round(sharpness, 2),
|
|
122
|
+
brightness=round(brightness, 2),
|
|
123
|
+
contrast=round(contrast, 2),
|
|
124
|
+
keypoints=keypoints,
|
|
125
|
+
issues=issues,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def match_pair(
|
|
130
|
+
image_a: str | Path,
|
|
131
|
+
image_b: str | Path,
|
|
132
|
+
settings: Settings,
|
|
133
|
+
*,
|
|
134
|
+
detector: Detector = "sift",
|
|
135
|
+
ratio: float = 0.75,
|
|
136
|
+
max_features: int = 8000,
|
|
137
|
+
visualization_path: str | Path | None = None,
|
|
138
|
+
overwrite: bool = False,
|
|
139
|
+
) -> PairMatch:
|
|
140
|
+
"""Detect and match features between two images and verify them geometrically.
|
|
141
|
+
|
|
142
|
+
Uses Lowe's ratio test followed by a RANSAC fundamental-matrix estimation, so
|
|
143
|
+
``inliers`` approximates how many correspondences a reconstruction could use.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
visualization_path: optional PNG/JPG showing the inlier matches.
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
InputValidationError: bad arguments or unreadable images.
|
|
150
|
+
"""
|
|
151
|
+
if not 0.5 <= ratio <= 0.95:
|
|
152
|
+
raise InputValidationError("ratio must be in [0.5, 0.95].")
|
|
153
|
+
if not 100 <= max_features <= 50_000:
|
|
154
|
+
raise InputValidationError("max_features must be in [100, 50000].")
|
|
155
|
+
|
|
156
|
+
img_a, _ = downscale(load_image(image_a, settings), _ANALYSIS_MAX_DIM)
|
|
157
|
+
img_b, _ = downscale(load_image(image_b, settings), _ANALYSIS_MAX_DIM)
|
|
158
|
+
feat = _create_detector(detector, max_features)
|
|
159
|
+
kp_a, des_a = feat.detectAndCompute(_gray(img_a), None)
|
|
160
|
+
kp_b, des_b = feat.detectAndCompute(_gray(img_b), None)
|
|
161
|
+
|
|
162
|
+
tentative: list[cv2.DMatch] = []
|
|
163
|
+
if des_a is not None and des_b is not None and len(des_a) >= 2 and len(des_b) >= 2:
|
|
164
|
+
matcher = cv2.BFMatcher(_norm_type(detector))
|
|
165
|
+
for pair in matcher.knnMatch(des_a, des_b, k=2):
|
|
166
|
+
if len(pair) == 2 and pair[0].distance < ratio * pair[1].distance:
|
|
167
|
+
tentative.append(pair[0])
|
|
168
|
+
|
|
169
|
+
inlier_matches: list[cv2.DMatch] = []
|
|
170
|
+
if len(tentative) >= 8:
|
|
171
|
+
pts_a = np.asarray([kp_a[m.queryIdx].pt for m in tentative], dtype=np.float32)
|
|
172
|
+
pts_b = np.asarray([kp_b[m.trainIdx].pt for m in tentative], dtype=np.float32)
|
|
173
|
+
_, inlier_mask = cv2.findFundamentalMat(pts_a, pts_b, cv2.FM_RANSAC, 1.5, 0.999)
|
|
174
|
+
if inlier_mask is not None:
|
|
175
|
+
inlier_matches = [m for m, ok in zip(tentative, inlier_mask.ravel(), strict=True) if ok]
|
|
176
|
+
|
|
177
|
+
vis_out: str | None = None
|
|
178
|
+
if visualization_path is not None:
|
|
179
|
+
# outImg=None is valid at runtime (OpenCV allocates it); the stubs disagree.
|
|
180
|
+
canvas = cv2.drawMatches( # type: ignore[call-overload]
|
|
181
|
+
img_a,
|
|
182
|
+
kp_a,
|
|
183
|
+
img_b,
|
|
184
|
+
kp_b,
|
|
185
|
+
inlier_matches[:300],
|
|
186
|
+
None,
|
|
187
|
+
matchColor=(0, 220, 0),
|
|
188
|
+
flags=cv2.DrawMatchesFlags_NOT_DRAW_SINGLE_POINTS,
|
|
189
|
+
)
|
|
190
|
+
vis_out = str(save_image(canvas, visualization_path, overwrite=overwrite))
|
|
191
|
+
|
|
192
|
+
return PairMatch(
|
|
193
|
+
image_a=Path(image_a).name,
|
|
194
|
+
image_b=Path(image_b).name,
|
|
195
|
+
detector=detector,
|
|
196
|
+
keypoints_a=len(kp_a),
|
|
197
|
+
keypoints_b=len(kp_b),
|
|
198
|
+
tentative_matches=len(tentative),
|
|
199
|
+
inliers=len(inlier_matches),
|
|
200
|
+
inlier_ratio=round(len(inlier_matches) / len(tentative), 3) if tentative else 0.0,
|
|
201
|
+
visualization_path=vis_out,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def analyze_image_set(
|
|
206
|
+
image_dir: str | Path,
|
|
207
|
+
settings: Settings,
|
|
208
|
+
*,
|
|
209
|
+
detector: Detector = "sift",
|
|
210
|
+
max_pairs: int = 60,
|
|
211
|
+
) -> ImageSetReport:
|
|
212
|
+
"""Assess every image in ``image_dir`` and match consecutive pairs.
|
|
213
|
+
|
|
214
|
+
Images are assumed to be captured in order around the object (the usual
|
|
215
|
+
turntable / walk-around workflow). Pairs ``(i, i+1)`` and the closing pair
|
|
216
|
+
``(n-1, 0)`` are matched, up to ``max_pairs``.
|
|
217
|
+
"""
|
|
218
|
+
if not 1 <= max_pairs <= 1000:
|
|
219
|
+
raise InputValidationError("max_pairs must be in [1, 1000].")
|
|
220
|
+
directory = validate_input_dir(image_dir)
|
|
221
|
+
images = list_images(
|
|
222
|
+
directory, max_images=settings.max_images, max_bytes=settings.max_input_bytes
|
|
223
|
+
)
|
|
224
|
+
qualities = [assess_image(p, settings, detector) for p in images]
|
|
225
|
+
|
|
226
|
+
pairs: list[PairMatch] = []
|
|
227
|
+
if len(images) >= 2:
|
|
228
|
+
indices = [(i, i + 1) for i in range(len(images) - 1)]
|
|
229
|
+
if len(images) > 2:
|
|
230
|
+
indices.append((len(images) - 1, 0))
|
|
231
|
+
for i, j in indices[:max_pairs]:
|
|
232
|
+
pairs.append(match_pair(images[i], images[j], settings, detector=detector))
|
|
233
|
+
|
|
234
|
+
weak = [f"{p.image_a} <-> {p.image_b}" for p in pairs if p.inliers < _MIN_PAIR_INLIERS]
|
|
235
|
+
recommendations: list[str] = []
|
|
236
|
+
n = len(images)
|
|
237
|
+
if n < 3:
|
|
238
|
+
recommendations.append(
|
|
239
|
+
"Capture at least 20-40 photos around the object for photogrammetry."
|
|
240
|
+
)
|
|
241
|
+
elif n < 15:
|
|
242
|
+
recommendations.append("Few images: add more viewpoints (aim for 60-80% overlap).")
|
|
243
|
+
blurry = [q.name for q in qualities if "blurry" in q.issues]
|
|
244
|
+
if blurry:
|
|
245
|
+
recommendations.append(
|
|
246
|
+
f"Retake or drop {len(blurry)} blurry image(s): use a tripod or faster shutter."
|
|
247
|
+
)
|
|
248
|
+
if any("low_texture" in q.issues for q in qualities):
|
|
249
|
+
recommendations.append(
|
|
250
|
+
"Low texture detected: add matte spray, markers or a patterned background."
|
|
251
|
+
)
|
|
252
|
+
if any(i in q.issues for q in qualities for i in ("underexposed", "overexposed")):
|
|
253
|
+
recommendations.append("Fix exposure: use diffuse, even lighting and avoid glare.")
|
|
254
|
+
if weak:
|
|
255
|
+
recommendations.append(
|
|
256
|
+
f"{len(weak)} consecutive pair(s) share few matches: add intermediate shots."
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
bad_ratio = sum(1 for q in qualities if q.issues) / max(n, 1)
|
|
260
|
+
suitable = n >= 3 and bad_ratio <= 0.3 and len(weak) <= max(1, len(pairs) // 5)
|
|
261
|
+
if suitable and not recommendations:
|
|
262
|
+
recommendations.append("Image set looks suitable for photogrammetry.")
|
|
263
|
+
|
|
264
|
+
logger.info("Analyzed %d image(s), %d pair(s)", n, len(pairs))
|
|
265
|
+
return ImageSetReport(
|
|
266
|
+
image_count=n,
|
|
267
|
+
images=qualities,
|
|
268
|
+
pairs=pairs,
|
|
269
|
+
weak_links=weak,
|
|
270
|
+
suitable_for_photogrammetry=suitable,
|
|
271
|
+
recommendations=recommendations,
|
|
272
|
+
)
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""Mask, silhouette and contour extraction from photos, plans and stencils.
|
|
2
|
+
|
|
3
|
+
Typical inputs are a technical drawing, a scanned stencil/template ("careta"),
|
|
4
|
+
a product photo on a plain background, or an image with an alpha channel. The
|
|
5
|
+
output is a binary mask, vector contours (outer boundaries + holes) in JSON and
|
|
6
|
+
SVG, and optionally an extruded 3D solid built from those contours.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
from dataclasses import asdict, dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Literal, cast
|
|
16
|
+
|
|
17
|
+
import cv2
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from reverse3d.config import Settings
|
|
21
|
+
from reverse3d.errors import InputValidationError, ProcessingError
|
|
22
|
+
from reverse3d.imaging import load_image, save_image
|
|
23
|
+
from reverse3d.security import (
|
|
24
|
+
JSON_EXTENSIONS,
|
|
25
|
+
prepare_output_dir,
|
|
26
|
+
prepare_output_file,
|
|
27
|
+
validate_input_file,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
MaskMethod = Literal["otsu", "adaptive", "canny", "alpha"]
|
|
33
|
+
CONTOURS_SCHEMA_VERSION = 1
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Polygon2D:
|
|
38
|
+
"""A closed outline in pixel coordinates (x right, y down) with optional holes."""
|
|
39
|
+
|
|
40
|
+
exterior: list[list[float]]
|
|
41
|
+
holes: list[list[list[float]]] = field(default_factory=list)
|
|
42
|
+
area_px: float = 0.0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class MaskResult:
|
|
47
|
+
"""Outputs of :func:`extract_mask`."""
|
|
48
|
+
|
|
49
|
+
mask_path: str
|
|
50
|
+
contours_path: str
|
|
51
|
+
svg_path: str
|
|
52
|
+
image_size: tuple[int, int]
|
|
53
|
+
polygon_count: int
|
|
54
|
+
total_area_px: float
|
|
55
|
+
bbox_px: tuple[int, int, int, int] | None
|
|
56
|
+
units_per_pixel: float | None
|
|
57
|
+
warnings: list[str] = field(default_factory=list)
|
|
58
|
+
|
|
59
|
+
def to_dict(self) -> dict[str, object]:
|
|
60
|
+
return asdict(self)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _binary_mask(image: np.ndarray, method: MaskMethod, blur: int) -> np.ndarray:
|
|
64
|
+
if method == "alpha":
|
|
65
|
+
if image.ndim != 3 or image.shape[2] != 4:
|
|
66
|
+
raise InputValidationError(
|
|
67
|
+
"method='alpha' requires an image with an alpha channel (RGBA PNG)."
|
|
68
|
+
)
|
|
69
|
+
return np.where(image[:, :, 3] > 127, 255, 0).astype(np.uint8)
|
|
70
|
+
|
|
71
|
+
gray = cv2.cvtColor(image[:, :, :3], cv2.COLOR_BGR2GRAY) if image.ndim == 3 else image
|
|
72
|
+
if blur > 1:
|
|
73
|
+
k = blur if blur % 2 == 1 else blur + 1
|
|
74
|
+
gray = cv2.GaussianBlur(gray, (k, k), 0)
|
|
75
|
+
|
|
76
|
+
if method == "otsu":
|
|
77
|
+
_, mask = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
|
78
|
+
elif method == "adaptive":
|
|
79
|
+
mask = cv2.adaptiveThreshold(
|
|
80
|
+
gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 51, 5
|
|
81
|
+
)
|
|
82
|
+
elif method == "canny":
|
|
83
|
+
edges = cv2.Canny(gray, 50, 150)
|
|
84
|
+
edges = cv2.dilate(edges, np.ones((3, 3), np.uint8), iterations=2)
|
|
85
|
+
# Fill closed edge loops so the mask covers the object area, not only its outline.
|
|
86
|
+
contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
87
|
+
mask = np.zeros_like(gray)
|
|
88
|
+
cv2.drawContours(mask, contours, -1, 255, thickness=cv2.FILLED)
|
|
89
|
+
return mask
|
|
90
|
+
else: # pragma: no cover - guarded by Literal + CLI/agent validation
|
|
91
|
+
raise InputValidationError(f"Unknown mask method: {method}")
|
|
92
|
+
return mask
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _auto_invert(mask: np.ndarray) -> np.ndarray:
|
|
96
|
+
"""Make the object white: if the image border is mostly white the
|
|
97
|
+
background was thresholded as foreground (dark object on light paper)."""
|
|
98
|
+
border = np.concatenate([mask[0, :], mask[-1, :], mask[:, 0], mask[:, -1]])
|
|
99
|
+
return cv2.bitwise_not(mask) if border.mean() > 127 else mask
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _contours_to_polygons(
|
|
103
|
+
mask: np.ndarray, min_area_px: float, epsilon_ratio: float
|
|
104
|
+
) -> list[Polygon2D]:
|
|
105
|
+
contours, hierarchy = cv2.findContours(mask, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE)
|
|
106
|
+
if hierarchy is None:
|
|
107
|
+
return []
|
|
108
|
+
hierarchy = hierarchy[0]
|
|
109
|
+
polygons: list[Polygon2D] = []
|
|
110
|
+
|
|
111
|
+
def simplify(contour: np.ndarray) -> list[list[float]]:
|
|
112
|
+
eps = epsilon_ratio * cv2.arcLength(contour, True)
|
|
113
|
+
approx = cv2.approxPolyDP(contour, eps, True) if eps > 0 else contour
|
|
114
|
+
return [[float(x), float(y)] for x, y in approx.reshape(-1, 2)]
|
|
115
|
+
|
|
116
|
+
for idx, contour in enumerate(contours):
|
|
117
|
+
# RETR_CCOMP: top-level contours have parent == -1, their children are holes.
|
|
118
|
+
if hierarchy[idx][3] != -1:
|
|
119
|
+
continue
|
|
120
|
+
area = float(cv2.contourArea(contour))
|
|
121
|
+
if area < min_area_px:
|
|
122
|
+
continue
|
|
123
|
+
exterior = simplify(contour)
|
|
124
|
+
if len(exterior) < 3:
|
|
125
|
+
continue
|
|
126
|
+
holes: list[list[list[float]]] = []
|
|
127
|
+
hole_area = 0.0
|
|
128
|
+
child = hierarchy[idx][2]
|
|
129
|
+
while child != -1:
|
|
130
|
+
child_area = float(cv2.contourArea(contours[child]))
|
|
131
|
+
if child_area >= min_area_px:
|
|
132
|
+
hole = simplify(contours[child])
|
|
133
|
+
if len(hole) >= 3:
|
|
134
|
+
holes.append(hole)
|
|
135
|
+
hole_area += child_area
|
|
136
|
+
child = hierarchy[child][0]
|
|
137
|
+
polygons.append(Polygon2D(exterior=exterior, holes=holes, area_px=area - hole_area))
|
|
138
|
+
|
|
139
|
+
polygons.sort(key=lambda p: p.area_px, reverse=True)
|
|
140
|
+
return polygons
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _write_svg(path: Path, polygons: list[Polygon2D], width: int, height: int) -> None:
|
|
144
|
+
def ring(points: list[list[float]]) -> str:
|
|
145
|
+
head, *tail = points
|
|
146
|
+
return (
|
|
147
|
+
f"M{head[0]:.2f},{head[1]:.2f} " + " ".join(f"L{x:.2f},{y:.2f}" for x, y in tail) + " Z"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
paths = []
|
|
151
|
+
for poly in polygons:
|
|
152
|
+
d = " ".join([ring(poly.exterior), *(ring(h) for h in poly.holes)])
|
|
153
|
+
paths.append(
|
|
154
|
+
f' <path d="{d}" fill="#1f2937" fill-rule="evenodd" '
|
|
155
|
+
'stroke="#ef4444" stroke-width="1"/>'
|
|
156
|
+
)
|
|
157
|
+
svg = (
|
|
158
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
|
|
159
|
+
f'viewBox="0 0 {width} {height}">\n' + "\n".join(paths) + "\n</svg>\n"
|
|
160
|
+
)
|
|
161
|
+
path.write_text(svg, encoding="utf-8")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def extract_mask(
|
|
165
|
+
image_path: str | Path,
|
|
166
|
+
output_dir: str | Path,
|
|
167
|
+
settings: Settings,
|
|
168
|
+
*,
|
|
169
|
+
method: MaskMethod = "otsu",
|
|
170
|
+
invert: bool | None = None,
|
|
171
|
+
blur: int = 5,
|
|
172
|
+
min_area_ratio: float = 0.001,
|
|
173
|
+
simplify_ratio: float = 0.002,
|
|
174
|
+
units_per_pixel: float | None = None,
|
|
175
|
+
overwrite: bool = False,
|
|
176
|
+
) -> MaskResult:
|
|
177
|
+
"""Extract a binary mask and vector contours from an image.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
image_path: photo, plan or stencil image.
|
|
181
|
+
output_dir: directory that receives ``<stem>_mask.png``,
|
|
182
|
+
``<stem>_contours.json`` and ``<stem>_contours.svg``.
|
|
183
|
+
settings: runtime limits.
|
|
184
|
+
method: ``otsu`` (plain backgrounds), ``adaptive`` (uneven lighting,
|
|
185
|
+
line drawings), ``canny`` (edge-based, textured objects) or
|
|
186
|
+
``alpha`` (use the PNG alpha channel).
|
|
187
|
+
invert: force inversion; ``None`` auto-detects from the image border.
|
|
188
|
+
blur: Gaussian blur kernel applied before thresholding (0 disables).
|
|
189
|
+
min_area_ratio: discard regions smaller than this fraction of the image.
|
|
190
|
+
simplify_ratio: Douglas-Peucker tolerance as a fraction of the perimeter.
|
|
191
|
+
units_per_pixel: optional real-world scale (e.g. mm/px) stored with the
|
|
192
|
+
contours and used later by :func:`silhouette_to_mesh`.
|
|
193
|
+
overwrite: replace existing outputs.
|
|
194
|
+
|
|
195
|
+
Raises:
|
|
196
|
+
InputValidationError: bad arguments or image.
|
|
197
|
+
ProcessingError: no region survived the filters.
|
|
198
|
+
"""
|
|
199
|
+
if not 0 <= min_area_ratio < 1:
|
|
200
|
+
raise InputValidationError("min_area_ratio must be in [0, 1).")
|
|
201
|
+
if not 0 <= simplify_ratio <= 0.1:
|
|
202
|
+
raise InputValidationError("simplify_ratio must be in [0, 0.1].")
|
|
203
|
+
if not 0 <= blur <= 51:
|
|
204
|
+
raise InputValidationError("blur must be in [0, 51].")
|
|
205
|
+
if units_per_pixel is not None and units_per_pixel <= 0:
|
|
206
|
+
raise InputValidationError("units_per_pixel must be positive.")
|
|
207
|
+
|
|
208
|
+
flags = cv2.IMREAD_UNCHANGED if method == "alpha" else cv2.IMREAD_COLOR
|
|
209
|
+
image = load_image(image_path, settings, flags=flags)
|
|
210
|
+
if image.ndim == 3 and image.shape[2] == 4 and method != "alpha":
|
|
211
|
+
image = image[:, :, :3]
|
|
212
|
+
height, width = image.shape[:2]
|
|
213
|
+
|
|
214
|
+
mask = _binary_mask(image, method, blur)
|
|
215
|
+
if invert is None and method != "alpha":
|
|
216
|
+
mask = _auto_invert(mask)
|
|
217
|
+
elif invert:
|
|
218
|
+
mask = cv2.bitwise_not(mask)
|
|
219
|
+
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
|
220
|
+
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=2)
|
|
221
|
+
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=1)
|
|
222
|
+
|
|
223
|
+
polygons = _contours_to_polygons(mask, min_area_ratio * width * height, simplify_ratio)
|
|
224
|
+
if not polygons:
|
|
225
|
+
raise ProcessingError(
|
|
226
|
+
"No region found in the mask.",
|
|
227
|
+
hint="Try another method (adaptive/canny), invert=true, or a lower min_area_ratio.",
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
warnings: list[str] = []
|
|
231
|
+
coverage = sum(p.area_px for p in polygons) / (width * height)
|
|
232
|
+
if coverage > 0.95:
|
|
233
|
+
warnings.append("Mask covers >95% of the image; inversion may be wrong.")
|
|
234
|
+
|
|
235
|
+
out_dir = prepare_output_dir(output_dir)
|
|
236
|
+
stem = Path(image_path).stem
|
|
237
|
+
mask_path = save_image(mask, out_dir / f"{stem}_mask.png", overwrite=overwrite)
|
|
238
|
+
contours_path = prepare_output_file(
|
|
239
|
+
out_dir / f"{stem}_contours.json", allowed_extensions=JSON_EXTENSIONS, overwrite=overwrite
|
|
240
|
+
)
|
|
241
|
+
svg_path = prepare_output_file(
|
|
242
|
+
out_dir / f"{stem}_contours.svg", allowed_extensions={".svg"}, overwrite=overwrite
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
contours_doc = {
|
|
246
|
+
"schema_version": CONTOURS_SCHEMA_VERSION,
|
|
247
|
+
"source_image": Path(image_path).name,
|
|
248
|
+
"image_size": [width, height],
|
|
249
|
+
"units_per_pixel": units_per_pixel,
|
|
250
|
+
"polygons": [asdict(p) for p in polygons],
|
|
251
|
+
}
|
|
252
|
+
contours_path.write_text(json.dumps(contours_doc, indent=2), encoding="utf-8")
|
|
253
|
+
_write_svg(svg_path, polygons, width, height)
|
|
254
|
+
|
|
255
|
+
xs, ys, ws, hs = cv2.boundingRect(mask)
|
|
256
|
+
logger.info("Extracted %d polygon(s) from %s", len(polygons), Path(image_path).name)
|
|
257
|
+
return MaskResult(
|
|
258
|
+
mask_path=str(mask_path),
|
|
259
|
+
contours_path=str(contours_path),
|
|
260
|
+
svg_path=str(svg_path),
|
|
261
|
+
image_size=(width, height),
|
|
262
|
+
polygon_count=len(polygons),
|
|
263
|
+
total_area_px=round(sum(p.area_px for p in polygons), 2),
|
|
264
|
+
bbox_px=(xs, ys, ws, hs) if ws and hs else None,
|
|
265
|
+
units_per_pixel=units_per_pixel,
|
|
266
|
+
warnings=warnings,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def load_contours(path: str | Path, settings: Settings) -> dict[str, Any]:
|
|
271
|
+
"""Load and structurally validate a contours JSON produced by :func:`extract_mask`."""
|
|
272
|
+
resolved = validate_input_file(
|
|
273
|
+
path,
|
|
274
|
+
allowed_extensions=JSON_EXTENSIONS,
|
|
275
|
+
max_bytes=min(settings.max_input_bytes, 64 * 1024 * 1024),
|
|
276
|
+
)
|
|
277
|
+
try:
|
|
278
|
+
doc = json.loads(resolved.read_text(encoding="utf-8"))
|
|
279
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
280
|
+
raise InputValidationError(f"Invalid contours JSON: {resolved.name}") from exc
|
|
281
|
+
if not isinstance(doc, dict) or doc.get("schema_version") != CONTOURS_SCHEMA_VERSION:
|
|
282
|
+
raise InputValidationError("Contours file has an unknown schema version.")
|
|
283
|
+
polygons = doc.get("polygons")
|
|
284
|
+
if not isinstance(polygons, list) or not polygons:
|
|
285
|
+
raise InputValidationError("Contours file has no polygons.")
|
|
286
|
+
for poly in polygons:
|
|
287
|
+
if not isinstance(poly, dict) or not isinstance(poly.get("exterior"), list):
|
|
288
|
+
raise InputValidationError("Malformed polygon in contours file.")
|
|
289
|
+
return doc
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def silhouette_to_mesh(
|
|
293
|
+
contours_path: str | Path,
|
|
294
|
+
output_path: str | Path,
|
|
295
|
+
settings: Settings,
|
|
296
|
+
*,
|
|
297
|
+
thickness: float = 10.0,
|
|
298
|
+
units_per_pixel: float | None = None,
|
|
299
|
+
max_polygons: int = 50,
|
|
300
|
+
overwrite: bool = False,
|
|
301
|
+
) -> dict[str, object]:
|
|
302
|
+
"""Extrude the polygons of a contours JSON into a 3D solid (plan → part).
|
|
303
|
+
|
|
304
|
+
Image Y points down; the outline is mirrored so the solid keeps the drawing's
|
|
305
|
+
orientation when viewed from +Z. Coordinates are scaled by
|
|
306
|
+
``units_per_pixel`` (argument, else the value stored in the JSON, else 1).
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
A dict with the output path and basic stats of the extruded mesh.
|
|
310
|
+
"""
|
|
311
|
+
import shapely.geometry as sg
|
|
312
|
+
import trimesh
|
|
313
|
+
|
|
314
|
+
from reverse3d.mesh_io import save_mesh
|
|
315
|
+
|
|
316
|
+
if not 0 < thickness <= 1e6:
|
|
317
|
+
raise InputValidationError("thickness must be in (0, 1e6].")
|
|
318
|
+
doc = load_contours(contours_path, settings)
|
|
319
|
+
scale = units_per_pixel or doc.get("units_per_pixel") or 1.0
|
|
320
|
+
if not isinstance(scale, (int, float)) or scale <= 0:
|
|
321
|
+
raise InputValidationError("units_per_pixel must be positive.")
|
|
322
|
+
|
|
323
|
+
meshes = []
|
|
324
|
+
for raw in doc["polygons"][:max_polygons]:
|
|
325
|
+
|
|
326
|
+
def to_xy(points: list[list[float]]) -> np.ndarray:
|
|
327
|
+
arr = np.asarray(points, dtype=np.float64).reshape(-1, 2) * float(scale)
|
|
328
|
+
arr[:, 1] *= -1.0
|
|
329
|
+
return arr
|
|
330
|
+
|
|
331
|
+
shell = to_xy(raw["exterior"])
|
|
332
|
+
holes = [to_xy(h) for h in raw.get("holes", [])]
|
|
333
|
+
polygon = sg.Polygon(shell, holes).buffer(0) # buffer(0) repairs self-intersections
|
|
334
|
+
if polygon.is_empty:
|
|
335
|
+
continue
|
|
336
|
+
parts = list(polygon.geoms) if hasattr(polygon, "geoms") else [polygon]
|
|
337
|
+
for part in parts:
|
|
338
|
+
if part.area > 0:
|
|
339
|
+
meshes.append(trimesh.creation.extrude_polygon(part, height=float(thickness)))
|
|
340
|
+
if not meshes:
|
|
341
|
+
raise ProcessingError("No valid polygon could be extruded.")
|
|
342
|
+
|
|
343
|
+
mesh = cast(trimesh.Trimesh, trimesh.util.concatenate(meshes))
|
|
344
|
+
out = save_mesh(mesh, output_path, overwrite=overwrite)
|
|
345
|
+
return {
|
|
346
|
+
"output_path": str(out),
|
|
347
|
+
"faces": len(mesh.faces),
|
|
348
|
+
"watertight": bool(mesh.is_watertight),
|
|
349
|
+
"extents": [round(float(v), 6) for v in mesh.extents],
|
|
350
|
+
"volume": round(float(mesh.volume), 6) if mesh.is_watertight else None,
|
|
351
|
+
"units_per_pixel": float(scale),
|
|
352
|
+
}
|