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.
Files changed (50) hide show
  1. reverse3d/__init__.py +39 -0
  2. reverse3d/__main__.py +4 -0
  3. reverse3d/_about.py +15 -0
  4. reverse3d/_version.py +1 -0
  5. reverse3d/agent/__init__.py +5 -0
  6. reverse3d/agent/compact.py +110 -0
  7. reverse3d/agent/mcp_server.py +171 -0
  8. reverse3d/agent/prompts.py +173 -0
  9. reverse3d/agent/skill/SKILL.md +53 -0
  10. reverse3d/agent/tools.py +811 -0
  11. reverse3d/autopilot.py +355 -0
  12. reverse3d/cli.py +1054 -0
  13. reverse3d/config.py +167 -0
  14. reverse3d/doctor.py +113 -0
  15. reverse3d/errors.py +83 -0
  16. reverse3d/hardware.py +115 -0
  17. reverse3d/imaging.py +79 -0
  18. reverse3d/logging_utils.py +41 -0
  19. reverse3d/mesh_io.py +152 -0
  20. reverse3d/offline.py +313 -0
  21. reverse3d/profiles.py +33 -0
  22. reverse3d/py.typed +0 -0
  23. reverse3d/recipes.py +394 -0
  24. reverse3d/reconstruction/__init__.py +22 -0
  25. reverse3d/reconstruction/depth_estimation.py +263 -0
  26. reverse3d/reconstruction/photogrammetry.py +460 -0
  27. reverse3d/reconstruction/pointcloud.py +120 -0
  28. reverse3d/rendering/__init__.py +10 -0
  29. reverse3d/rendering/_blender_job.py +284 -0
  30. reverse3d/rendering/blender_render.py +215 -0
  31. reverse3d/rendering/preview.py +80 -0
  32. reverse3d/reverse_eng/__init__.py +33 -0
  33. reverse3d/reverse_eng/geometry_fit.py +478 -0
  34. reverse3d/reverse_eng/measure.py +87 -0
  35. reverse3d/reverse_eng/mesh_cleaner.py +304 -0
  36. reverse3d/reverse_eng/parametric.py +154 -0
  37. reverse3d/security.py +294 -0
  38. reverse3d/setup_agents.py +517 -0
  39. reverse3d/userconfig.py +156 -0
  40. reverse3d/vision/__init__.py +12 -0
  41. reverse3d/vision/feature_matcher.py +272 -0
  42. reverse3d/vision/mask_extractor.py +352 -0
  43. reverse3d/watch.py +238 -0
  44. reverse3d-0.4.0.dist-info/METADATA +650 -0
  45. reverse3d-0.4.0.dist-info/RECORD +50 -0
  46. reverse3d-0.4.0.dist-info/WHEEL +5 -0
  47. reverse3d-0.4.0.dist-info/entry_points.txt +2 -0
  48. reverse3d-0.4.0.dist-info/licenses/LICENSE +202 -0
  49. reverse3d-0.4.0.dist-info/licenses/NOTICE +27 -0
  50. 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
+ }