sherd 0.1.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.
- sherd/__init__.py +6 -0
- sherd/__main__.py +7 -0
- sherd/core/__init__.py +1 -0
- sherd/core/axis.py +89 -0
- sherd/core/batch_processor.py +202 -0
- sherd/core/calibration.py +109 -0
- sherd/core/contour.py +90 -0
- sherd/core/exporter.py +292 -0
- sherd/core/profile.py +84 -0
- sherd/core/segmentation.py +187 -0
- sherd/main.py +37 -0
- sherd/models/__init__.py +1 -0
- sherd/models/settings.py +73 -0
- sherd/models/sherd_state.py +68 -0
- sherd/ui/__init__.py +1 -0
- sherd/ui/axis_items.py +186 -0
- sherd/ui/batch_dialog.py +288 -0
- sherd/ui/calibration_dialog.py +112 -0
- sherd/ui/correction_scene.py +245 -0
- sherd/ui/export_dialog.py +163 -0
- sherd/ui/image_panel.py +565 -0
- sherd/ui/main_window.py +978 -0
- sherd/ui/preview_panel.py +65 -0
- sherd/ui/shortcuts_dialog.py +60 -0
- sherd/ui/theme.py +196 -0
- sherd/ui/tools_panel.py +368 -0
- sherd/ui/workers.py +123 -0
- sherd/utils/__init__.py +1 -0
- sherd/utils/image_io.py +75 -0
- sherd/utils/sidecar.py +48 -0
- sherd/utils/svg_utils.py +72 -0
- sherd-0.1.0.dist-info/METADATA +18 -0
- sherd-0.1.0.dist-info/RECORD +35 -0
- sherd-0.1.0.dist-info/WHEEL +5 -0
- sherd-0.1.0.dist-info/top_level.txt +1 -0
sherd/__init__.py
ADDED
sherd/__main__.py
ADDED
sherd/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core image processing pipeline modules."""
|
sherd/core/axis.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Profile axis detection — centring axis, rim, and base estimation."""
|
|
2
|
+
|
|
3
|
+
import cv2
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ProfileAxis:
|
|
8
|
+
"""Detect and manage the vertical centring axis and vertical extents.
|
|
9
|
+
|
|
10
|
+
The centring axis is the axis of rotational symmetry for a pottery
|
|
11
|
+
vessel. Rim = topmost point, base = bottommost point.
|
|
12
|
+
|
|
13
|
+
Detection strategy:
|
|
14
|
+
1. Fit ellipse to the top 30% of contour points (rim region).
|
|
15
|
+
2. Use ellipse centre x as the candidate axis.
|
|
16
|
+
3. Fallback to bounding-rect centre x if ellipse fit fails.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
centring_x : float or None
|
|
21
|
+
Pixel x-coordinate of the vertical centring axis.
|
|
22
|
+
rim_y : float or None
|
|
23
|
+
Pixel y-coordinate of the rim (topmost).
|
|
24
|
+
base_y : float or None
|
|
25
|
+
Pixel y-coordinate of the base (bottommost).
|
|
26
|
+
is_manual : bool
|
|
27
|
+
True if the user has manually overridden any value.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self.centring_x: float | None = None
|
|
32
|
+
self.rim_y: float | None = None
|
|
33
|
+
self.base_y: float | None = None
|
|
34
|
+
self.is_manual: bool = False
|
|
35
|
+
|
|
36
|
+
def detect(
|
|
37
|
+
self, contour: np.ndarray, image_shape: tuple[int, int]
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Run auto-detection on a contour.
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
contour : np.ndarray
|
|
44
|
+
Contour array of shape (N, 1, 2).
|
|
45
|
+
image_shape : tuple[int, int]
|
|
46
|
+
(height, width) of the source image.
|
|
47
|
+
"""
|
|
48
|
+
h, w = image_shape[:2]
|
|
49
|
+
x, y, bw, bh = cv2.boundingRect(contour)
|
|
50
|
+
self.rim_y = float(y)
|
|
51
|
+
self.base_y = float(y + bh)
|
|
52
|
+
|
|
53
|
+
# Attempt ellipse fit on top 30% of points for better axis
|
|
54
|
+
pts = contour.reshape(-1, 2)
|
|
55
|
+
rim_pts = pts[pts[:, 1] < (y + bh * 0.30)]
|
|
56
|
+
if len(rim_pts) >= 5:
|
|
57
|
+
rim_pts_cv = rim_pts[:, np.newaxis, :]
|
|
58
|
+
try:
|
|
59
|
+
ellipse = cv2.fitEllipse(rim_pts_cv)
|
|
60
|
+
self.centring_x = ellipse[0][0]
|
|
61
|
+
except cv2.error:
|
|
62
|
+
self.centring_x = float(x + bw / 2)
|
|
63
|
+
else:
|
|
64
|
+
self.centring_x = float(x + bw / 2)
|
|
65
|
+
|
|
66
|
+
def override(
|
|
67
|
+
self,
|
|
68
|
+
centring_x: float | None = None,
|
|
69
|
+
rim_y: float | None = None,
|
|
70
|
+
base_y: float | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Manually override detected axis values.
|
|
73
|
+
|
|
74
|
+
Parameters
|
|
75
|
+
----------
|
|
76
|
+
centring_x : float or None
|
|
77
|
+
New centring axis x-coordinate, or None to keep existing.
|
|
78
|
+
rim_y : float or None
|
|
79
|
+
New rim y-coordinate, or None to keep existing.
|
|
80
|
+
base_y : float or None
|
|
81
|
+
New base y-coordinate, or None to keep existing.
|
|
82
|
+
"""
|
|
83
|
+
if centring_x is not None:
|
|
84
|
+
self.centring_x = centring_x
|
|
85
|
+
if rim_y is not None:
|
|
86
|
+
self.rim_y = rim_y
|
|
87
|
+
if base_y is not None:
|
|
88
|
+
self.base_y = base_y
|
|
89
|
+
self.is_manual = True
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Batch processor — run the full CV pipeline on a folder of images.
|
|
2
|
+
|
|
3
|
+
Each image is loaded, segmented, cleaned, axis-detected, profiled,
|
|
4
|
+
and exported as SVG + JSON sidecar. Calibration and smoothing config
|
|
5
|
+
are shared across the batch.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Callable
|
|
13
|
+
|
|
14
|
+
import cv2
|
|
15
|
+
|
|
16
|
+
from sherd.core.axis import ProfileAxis
|
|
17
|
+
from sherd.core.calibration import ScaleCalibration
|
|
18
|
+
from sherd.core.exporter import SVGExporter
|
|
19
|
+
from sherd.core.profile import march_profile
|
|
20
|
+
from sherd.core.segmentation import (
|
|
21
|
+
clean_mask,
|
|
22
|
+
extract_main_contour,
|
|
23
|
+
segment_adaptive,
|
|
24
|
+
segment_grabcut,
|
|
25
|
+
segment_otsu,
|
|
26
|
+
)
|
|
27
|
+
from sherd.utils.image_io import load_image
|
|
28
|
+
from sherd.utils.sidecar import write_sidecar
|
|
29
|
+
|
|
30
|
+
SUPPORTED_EXTENSIONS: set[str] = {
|
|
31
|
+
".jpg", ".jpeg", ".png", ".tif", ".tiff",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class BatchJob:
|
|
37
|
+
"""Configuration for a batch processing run.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
input_dir : Path
|
|
42
|
+
Directory containing sherd photographs.
|
|
43
|
+
output_dir : Path
|
|
44
|
+
Directory to write SVG + JSON output files.
|
|
45
|
+
calibration : ScaleCalibration
|
|
46
|
+
Calibration to use for all images (set from a reference image).
|
|
47
|
+
segmentation_method : str
|
|
48
|
+
``"otsu"``, ``"adaptive"``, or ``"grabcut"``.
|
|
49
|
+
smoothing_level : str
|
|
50
|
+
``"fine"``, ``"medium"``, or ``"coarse"``.
|
|
51
|
+
metadata_defaults : dict
|
|
52
|
+
Shared metadata fields (site_code, operator, etc.).
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
input_dir: Path
|
|
56
|
+
output_dir: Path
|
|
57
|
+
calibration: ScaleCalibration = field(default_factory=ScaleCalibration)
|
|
58
|
+
segmentation_method: str = "otsu"
|
|
59
|
+
smoothing_level: str = "medium"
|
|
60
|
+
metadata_defaults: dict = field(default_factory=dict)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class BatchResult:
|
|
65
|
+
"""Outcome of processing a single image.
|
|
66
|
+
|
|
67
|
+
Parameters
|
|
68
|
+
----------
|
|
69
|
+
image_path : Path
|
|
70
|
+
Input image path.
|
|
71
|
+
status : str
|
|
72
|
+
``"ok"``, ``"failed"``, or ``"skipped"``.
|
|
73
|
+
svg_path : Path or None
|
|
74
|
+
Path to generated SVG (None on failure).
|
|
75
|
+
error : str or None
|
|
76
|
+
Error message if failed.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
image_path: Path
|
|
80
|
+
status: str = "ok"
|
|
81
|
+
svg_path: Path | None = None
|
|
82
|
+
error: str | None = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class BatchProcessor:
|
|
86
|
+
"""Process a folder of sherd photographs with a shared config.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
job : BatchJob
|
|
91
|
+
Batch configuration.
|
|
92
|
+
progress_callback : Callable[[int, int], None] or None
|
|
93
|
+
Called with ``(current, total)`` after each image.
|
|
94
|
+
cancel_check : Callable[[], bool] or None
|
|
95
|
+
Called before each image; return True to abort.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
job: BatchJob,
|
|
101
|
+
progress_callback: Callable[[int, int], None] | None = None,
|
|
102
|
+
cancel_check: Callable[[], bool] | None = None,
|
|
103
|
+
) -> None:
|
|
104
|
+
self._job = job
|
|
105
|
+
self._progress = progress_callback or (lambda a, b: None)
|
|
106
|
+
self._cancel = cancel_check or (lambda: False)
|
|
107
|
+
|
|
108
|
+
def run(self) -> list[BatchResult]:
|
|
109
|
+
"""Process all images in the input directory.
|
|
110
|
+
|
|
111
|
+
Returns a list of ``BatchResult``, one per image.
|
|
112
|
+
"""
|
|
113
|
+
# Ensure output dir exists
|
|
114
|
+
self._job.output_dir.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
|
|
116
|
+
images = sorted(
|
|
117
|
+
p for p in self._job.input_dir.iterdir()
|
|
118
|
+
if p.suffix.lower() in SUPPORTED_EXTENSIONS
|
|
119
|
+
)
|
|
120
|
+
if not images:
|
|
121
|
+
return []
|
|
122
|
+
|
|
123
|
+
results: list[BatchResult] = []
|
|
124
|
+
for i, img_path in enumerate(images):
|
|
125
|
+
if self._cancel():
|
|
126
|
+
break
|
|
127
|
+
self._progress(i + 1, len(images))
|
|
128
|
+
result = self._process_one(img_path)
|
|
129
|
+
results.append(result)
|
|
130
|
+
return results
|
|
131
|
+
|
|
132
|
+
# ── Single image pipeline ─────────────────────────────────
|
|
133
|
+
|
|
134
|
+
def _process_one(self, img_path: Path) -> BatchResult:
|
|
135
|
+
"""Run the full pipeline on a single image."""
|
|
136
|
+
try:
|
|
137
|
+
bgr = load_image(str(img_path))
|
|
138
|
+
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
|
|
139
|
+
except Exception as exc:
|
|
140
|
+
return BatchResult(img_path, "failed", None, str(exc))
|
|
141
|
+
|
|
142
|
+
# Segmentation
|
|
143
|
+
try:
|
|
144
|
+
if self._job.segmentation_method == "otsu":
|
|
145
|
+
mask = segment_otsu(gray)
|
|
146
|
+
elif self._job.segmentation_method == "adaptive":
|
|
147
|
+
mask = segment_adaptive(gray)
|
|
148
|
+
else:
|
|
149
|
+
h, w = gray.shape[:2]
|
|
150
|
+
rect = (int(w * 0.05), int(h * 0.05),
|
|
151
|
+
int(w * 0.9), int(h * 0.9))
|
|
152
|
+
mask = segment_grabcut(bgr, rect)
|
|
153
|
+
|
|
154
|
+
cleaned = clean_mask(mask)
|
|
155
|
+
contour = extract_main_contour(cleaned)
|
|
156
|
+
if contour is None:
|
|
157
|
+
return BatchResult(
|
|
158
|
+
img_path, "failed", None,
|
|
159
|
+
"No contour found after segmentation",
|
|
160
|
+
)
|
|
161
|
+
except Exception as exc:
|
|
162
|
+
return BatchResult(img_path, "failed", None, str(exc))
|
|
163
|
+
|
|
164
|
+
# Axis detection
|
|
165
|
+
axis = ProfileAxis()
|
|
166
|
+
axis.detect(contour, gray.shape)
|
|
167
|
+
|
|
168
|
+
# Profile
|
|
169
|
+
profile = march_profile(contour, axis, mode="exterior_only")
|
|
170
|
+
|
|
171
|
+
# Build output filename
|
|
172
|
+
stem = img_path.stem.replace(" ", "_")
|
|
173
|
+
svg_name = f"{stem}_profile.svg"
|
|
174
|
+
svg_path = self._job.output_dir / svg_name
|
|
175
|
+
|
|
176
|
+
# Metadata
|
|
177
|
+
metadata = dict(self._job.metadata_defaults)
|
|
178
|
+
metadata["source_image"] = str(img_path.name)
|
|
179
|
+
if self._job.calibration.is_calibrated():
|
|
180
|
+
metadata["pixels_per_mm"] = self._job.calibration.pixels_per_mm
|
|
181
|
+
metadata["profile_mode"] = "exterior_only"
|
|
182
|
+
|
|
183
|
+
# Export
|
|
184
|
+
try:
|
|
185
|
+
if self._job.calibration.is_calibrated():
|
|
186
|
+
cal = self._job.calibration
|
|
187
|
+
else:
|
|
188
|
+
# Unity calibration for pixel-coordinate export
|
|
189
|
+
cal = ScaleCalibration()
|
|
190
|
+
cal.set_points((0, 0), (1, 0))
|
|
191
|
+
cal.set_real_distance(1.0)
|
|
192
|
+
|
|
193
|
+
exporter = SVGExporter(
|
|
194
|
+
cal, axis,
|
|
195
|
+
settings={"smoothingLevel": self._job.smoothing_level},
|
|
196
|
+
)
|
|
197
|
+
exporter.export(profile, metadata, str(svg_path))
|
|
198
|
+
write_sidecar(str(svg_path), metadata)
|
|
199
|
+
except Exception as exc:
|
|
200
|
+
return BatchResult(img_path, "failed", None, str(exc))
|
|
201
|
+
|
|
202
|
+
return BatchResult(img_path, "ok", svg_path, None)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Scale calibration — compute pixels-per-mm from a known reference."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ScaleCalibration:
|
|
7
|
+
"""Compute and apply pixel-to-mm conversion from user-defined reference points.
|
|
8
|
+
|
|
9
|
+
The user clicks two points on a known scale reference (ruler, coin,
|
|
10
|
+
calibration tile) and enters the real-world distance. The calibration
|
|
11
|
+
persists for the session and is used for SVG output coordinates and
|
|
12
|
+
scale bar generation.
|
|
13
|
+
|
|
14
|
+
Parameters
|
|
15
|
+
----------
|
|
16
|
+
point_a : (float, float) or None
|
|
17
|
+
First calibration point in pixel coordinates.
|
|
18
|
+
point_b : (float, float) or None
|
|
19
|
+
Second calibration point in pixel coordinates.
|
|
20
|
+
real_world_mm : float or None
|
|
21
|
+
Known real-world distance between point_a and point_b in mm.
|
|
22
|
+
pixels_per_mm : float or None
|
|
23
|
+
Computed calibration factor (px / mm).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
self.point_a: tuple[float, float] | None = None
|
|
28
|
+
self.point_b: tuple[float, float] | None = None
|
|
29
|
+
self.real_world_mm: float | None = None
|
|
30
|
+
self.pixels_per_mm: float | None = None
|
|
31
|
+
|
|
32
|
+
def set_points(self, a: tuple[float, float], b: tuple[float, float]) -> None:
|
|
33
|
+
"""Set the two pixel-coordinate reference points."""
|
|
34
|
+
self.point_a = a
|
|
35
|
+
self.point_b = b
|
|
36
|
+
if self.real_world_mm is not None:
|
|
37
|
+
self._compute()
|
|
38
|
+
|
|
39
|
+
def set_real_distance(self, mm: float) -> None:
|
|
40
|
+
"""Set the known real-world distance and compute calibration."""
|
|
41
|
+
self.real_world_mm = mm
|
|
42
|
+
if self.point_a is not None and self.point_b is not None:
|
|
43
|
+
self._compute()
|
|
44
|
+
|
|
45
|
+
def _compute(self) -> None:
|
|
46
|
+
"""Compute pixels_per_mm from points and real distance."""
|
|
47
|
+
if self.point_a is None or self.point_b is None:
|
|
48
|
+
return
|
|
49
|
+
px_dist = math.dist(self.point_a, self.point_b)
|
|
50
|
+
if self.real_world_mm and self.real_world_mm > 0:
|
|
51
|
+
self.pixels_per_mm = px_dist / self.real_world_mm
|
|
52
|
+
|
|
53
|
+
def px_to_mm(self, pixels: float) -> float:
|
|
54
|
+
"""Convert pixel distance to mm.
|
|
55
|
+
|
|
56
|
+
Raises
|
|
57
|
+
------
|
|
58
|
+
RuntimeError
|
|
59
|
+
If calibration has not been set.
|
|
60
|
+
"""
|
|
61
|
+
if self.pixels_per_mm is None:
|
|
62
|
+
raise RuntimeError("Calibration not set")
|
|
63
|
+
return pixels / self.pixels_per_mm
|
|
64
|
+
|
|
65
|
+
def mm_to_px(self, mm: float) -> float:
|
|
66
|
+
"""Convert mm to pixel distance.
|
|
67
|
+
|
|
68
|
+
Raises
|
|
69
|
+
------
|
|
70
|
+
RuntimeError
|
|
71
|
+
If calibration has not been set.
|
|
72
|
+
"""
|
|
73
|
+
if self.pixels_per_mm is None:
|
|
74
|
+
raise RuntimeError("Calibration not set")
|
|
75
|
+
return mm * self.pixels_per_mm
|
|
76
|
+
|
|
77
|
+
def is_calibrated(self) -> bool:
|
|
78
|
+
"""Return True if calibration has been computed successfully."""
|
|
79
|
+
return self.pixels_per_mm is not None
|
|
80
|
+
|
|
81
|
+
def scale_bar_svg(self, length_mm: int = 50) -> str:
|
|
82
|
+
"""Return SVG markup for a scale bar of given length in mm.
|
|
83
|
+
|
|
84
|
+
Parameters
|
|
85
|
+
----------
|
|
86
|
+
length_mm : int
|
|
87
|
+
Length of the scale bar in mm (default 50).
|
|
88
|
+
|
|
89
|
+
Returns
|
|
90
|
+
-------
|
|
91
|
+
str
|
|
92
|
+
SVG ``<g>`` element containing the scale bar.
|
|
93
|
+
"""
|
|
94
|
+
if not self.is_calibrated():
|
|
95
|
+
raise RuntimeError("Calibration not set")
|
|
96
|
+
bar_px = self.mm_to_px(length_mm)
|
|
97
|
+
tick_h = 4 # px
|
|
98
|
+
return (
|
|
99
|
+
f'<g id="scale-bar" class="scale-bar">'
|
|
100
|
+
f'<line x1="0" y1="{tick_h}" x2="0" y2="0" '
|
|
101
|
+
f'stroke="black" stroke-width="0.5pt" vector-effect="non-scaling-stroke"/>'
|
|
102
|
+
f'<line x1="0" y1="{tick_h / 2}" x2="{bar_px}" y2="{tick_h / 2}" '
|
|
103
|
+
f'stroke="black" stroke-width="0.5pt" vector-effect="non-scaling-stroke"/>'
|
|
104
|
+
f'<line x1="{bar_px}" y1="{tick_h}" x2="{bar_px}" y2="0" '
|
|
105
|
+
f'stroke="black" stroke-width="0.5pt" vector-effect="non-scaling-stroke"/>'
|
|
106
|
+
f'<text x="{bar_px / 2}" y="{tick_h + 8}" text-anchor="middle" '
|
|
107
|
+
f'font-size="8" font-family="Arial">{length_mm} mm</text>'
|
|
108
|
+
f'</g>'
|
|
109
|
+
)
|
sherd/core/contour.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Contour smoothing and SVG path generation."""
|
|
2
|
+
|
|
3
|
+
import cv2
|
|
4
|
+
import numpy as np
|
|
5
|
+
from scipy.interpolate import splev, splprep
|
|
6
|
+
|
|
7
|
+
SMOOTHING_EPSILON = {
|
|
8
|
+
"fine": 0.0005, # Very close to original
|
|
9
|
+
"medium": 0.002, # Good balance — default
|
|
10
|
+
"coarse": 0.008, # Fewer nodes, more generalised
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
SUPPORTED_LEVELS = frozenset(SMOOTHING_EPSILON.keys())
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def smooth_contour(
|
|
17
|
+
contour: np.ndarray,
|
|
18
|
+
level: str = "medium",
|
|
19
|
+
closed: bool = True,
|
|
20
|
+
) -> np.ndarray:
|
|
21
|
+
"""Smooth a contour using approxPolyDP followed by parametric B-spline.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
contour : np.ndarray
|
|
26
|
+
Input contour array of shape (N, 1, 2).
|
|
27
|
+
level : str
|
|
28
|
+
Smoothing level: ``"fine"``, ``"medium"`` (default), or ``"coarse"``.
|
|
29
|
+
closed : bool
|
|
30
|
+
Whether the contour is closed (default True).
|
|
31
|
+
|
|
32
|
+
Returns
|
|
33
|
+
-------
|
|
34
|
+
np.ndarray
|
|
35
|
+
Smoothed (x, y) point array of shape (M, 2).
|
|
36
|
+
"""
|
|
37
|
+
if level not in SUPPORTED_LEVELS:
|
|
38
|
+
opts = sorted(SUPPORTED_LEVELS)
|
|
39
|
+
msg = f"Unknown smoothing level '{level}'; choose from {opts}"
|
|
40
|
+
raise ValueError(msg)
|
|
41
|
+
|
|
42
|
+
# OpenCV requires int32 or float32 for arcLength/approxPolyDP
|
|
43
|
+
if contour.dtype not in (np.int32, np.float32):
|
|
44
|
+
contour = contour.astype(np.float32)
|
|
45
|
+
|
|
46
|
+
# Handle (N, 2) shape by adding channel dim for OpenCV
|
|
47
|
+
if contour.ndim == 2:
|
|
48
|
+
contour = contour.reshape(-1, 1, 2)
|
|
49
|
+
|
|
50
|
+
arc_len = cv2.arcLength(contour, closed)
|
|
51
|
+
epsilon = SMOOTHING_EPSILON[level] * arc_len
|
|
52
|
+
poly = cv2.approxPolyDP(contour, epsilon, closed)
|
|
53
|
+
pts = poly.reshape(-1, 2).astype(float)
|
|
54
|
+
|
|
55
|
+
if len(pts) < 4:
|
|
56
|
+
return pts # Not enough points to spline
|
|
57
|
+
|
|
58
|
+
x, y = pts[:, 0], pts[:, 1]
|
|
59
|
+
if closed:
|
|
60
|
+
x = np.append(x, x[0])
|
|
61
|
+
y = np.append(y, y[0])
|
|
62
|
+
|
|
63
|
+
# Parametric B-spline through the points
|
|
64
|
+
tck, _ = splprep([x, y], s=0, per=closed, k=3)
|
|
65
|
+
n_out = max(200, len(pts) * 4)
|
|
66
|
+
u_new = np.linspace(0, 1, n_out)
|
|
67
|
+
xn, yn = splev(u_new, tck)
|
|
68
|
+
return np.column_stack([xn, yn])
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def contour_to_svg_path(points: np.ndarray) -> str:
|
|
72
|
+
"""Convert an Nx2 point array to an SVG path ``d``-attribute string.
|
|
73
|
+
|
|
74
|
+
Parameters
|
|
75
|
+
----------
|
|
76
|
+
points : np.ndarray
|
|
77
|
+
Array of (x, y) coordinates, shape (N, 2).
|
|
78
|
+
|
|
79
|
+
Returns
|
|
80
|
+
-------
|
|
81
|
+
str
|
|
82
|
+
SVG path data string ending with ``Z`` (closed path).
|
|
83
|
+
"""
|
|
84
|
+
if len(points) == 0:
|
|
85
|
+
return ""
|
|
86
|
+
coords = " ".join(
|
|
87
|
+
f"{'M' if i == 0 else 'L'}{p[0]:.2f},{p[1]:.2f}"
|
|
88
|
+
for i, p in enumerate(points)
|
|
89
|
+
)
|
|
90
|
+
return coords + " Z"
|