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 ADDED
@@ -0,0 +1,6 @@
1
+ """Sherd — Pottery Profile Vectoriser.
2
+
3
+ Automated SVG pottery profile drawings from photographs.
4
+ """
5
+
6
+ __version__ = "0.1.0"
sherd/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Sherd — CLI entry point for ``python -m sherd``."""
2
+
3
+ import sys
4
+
5
+ from sherd.main import main
6
+
7
+ sys.exit(main())
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"