sherd 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. sherd-0.1.0/PKG-INFO +18 -0
  2. sherd-0.1.0/README.md +227 -0
  3. sherd-0.1.0/pyproject.toml +53 -0
  4. sherd-0.1.0/setup.cfg +4 -0
  5. sherd-0.1.0/sherd/__init__.py +6 -0
  6. sherd-0.1.0/sherd/__main__.py +7 -0
  7. sherd-0.1.0/sherd/core/__init__.py +1 -0
  8. sherd-0.1.0/sherd/core/axis.py +89 -0
  9. sherd-0.1.0/sherd/core/batch_processor.py +202 -0
  10. sherd-0.1.0/sherd/core/calibration.py +109 -0
  11. sherd-0.1.0/sherd/core/contour.py +90 -0
  12. sherd-0.1.0/sherd/core/exporter.py +292 -0
  13. sherd-0.1.0/sherd/core/profile.py +84 -0
  14. sherd-0.1.0/sherd/core/segmentation.py +187 -0
  15. sherd-0.1.0/sherd/main.py +37 -0
  16. sherd-0.1.0/sherd/models/__init__.py +1 -0
  17. sherd-0.1.0/sherd/models/settings.py +73 -0
  18. sherd-0.1.0/sherd/models/sherd_state.py +68 -0
  19. sherd-0.1.0/sherd/ui/__init__.py +1 -0
  20. sherd-0.1.0/sherd/ui/axis_items.py +186 -0
  21. sherd-0.1.0/sherd/ui/batch_dialog.py +288 -0
  22. sherd-0.1.0/sherd/ui/calibration_dialog.py +112 -0
  23. sherd-0.1.0/sherd/ui/correction_scene.py +245 -0
  24. sherd-0.1.0/sherd/ui/export_dialog.py +163 -0
  25. sherd-0.1.0/sherd/ui/image_panel.py +565 -0
  26. sherd-0.1.0/sherd/ui/main_window.py +978 -0
  27. sherd-0.1.0/sherd/ui/preview_panel.py +65 -0
  28. sherd-0.1.0/sherd/ui/shortcuts_dialog.py +60 -0
  29. sherd-0.1.0/sherd/ui/theme.py +196 -0
  30. sherd-0.1.0/sherd/ui/tools_panel.py +368 -0
  31. sherd-0.1.0/sherd/ui/workers.py +123 -0
  32. sherd-0.1.0/sherd/utils/__init__.py +1 -0
  33. sherd-0.1.0/sherd/utils/image_io.py +75 -0
  34. sherd-0.1.0/sherd/utils/sidecar.py +48 -0
  35. sherd-0.1.0/sherd/utils/svg_utils.py +72 -0
  36. sherd-0.1.0/sherd.egg-info/PKG-INFO +18 -0
  37. sherd-0.1.0/sherd.egg-info/SOURCES.txt +46 -0
  38. sherd-0.1.0/sherd.egg-info/dependency_links.txt +1 -0
  39. sherd-0.1.0/sherd.egg-info/requires.txt +11 -0
  40. sherd-0.1.0/sherd.egg-info/top_level.txt +1 -0
  41. sherd-0.1.0/tests/test_axis.py +72 -0
  42. sherd-0.1.0/tests/test_calibration.py +83 -0
  43. sherd-0.1.0/tests/test_contour.py +86 -0
  44. sherd-0.1.0/tests/test_exporter.py +100 -0
  45. sherd-0.1.0/tests/test_image_io.py +53 -0
  46. sherd-0.1.0/tests/test_profile.py +66 -0
  47. sherd-0.1.0/tests/test_segmentation.py +100 -0
  48. sherd-0.1.0/tests/test_sidecar.py +73 -0
sherd-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: sherd
3
+ Version: 0.1.0
4
+ Summary: Pottery Profile Vectoriser — automated SVG pottery profile drawings from photographs
5
+ Author: Mark Bouck
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/mabo-du/sherd
8
+ Requires-Python: >=3.12
9
+ Requires-Dist: opencv-python>=4.10
10
+ Requires-Dist: PyQt6>=6.7
11
+ Requires-Dist: svgwrite>=1.4
12
+ Requires-Dist: scipy>=1.13
13
+ Requires-Dist: numpy>=1.26
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest; extra == "dev"
16
+ Requires-Dist: pytest-qt; extra == "dev"
17
+ Requires-Dist: ruff; extra == "dev"
18
+ Requires-Dist: pyinstaller; extra == "dev"
sherd-0.1.0/README.md ADDED
@@ -0,0 +1,227 @@
1
+ # Sherd — Pottery Profile Vectoriser
2
+
3
+ [![CI](https://github.com/mabo-du/sherd/actions/workflows/ci.yml/badge.svg)](https://github.com/mabo-du/sherd/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/sherd)](https://pypi.org/project/sherd/)
5
+ [![Python](https://img.shields.io/pypi/pyversions/sherd)](https://pypi.org/project/sherd/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ Automated publication-ready SVG pottery profile drawings from photographs.
9
+ Replaces manual tracing on light tables and Illustrator/Inkscape workflows.
10
+
11
+ Place a sherd on white paper with a scale reference, photograph it, and
12
+ receive a clean SVG conforming to international archaeological ceramic
13
+ illustration conventions.
14
+
15
+ ---
16
+
17
+ ## Features
18
+
19
+ - **Three segmentation methods** — Otsu, Adaptive, and GrabCut for standard, textured, and low-contrast sherds
20
+ - **Interactive axis overlay** — drag the centring axis, rim line, and base line to align with your piece
21
+ - **Scale calibration** — click two points on a ruler in the photograph to set the real-world mm scale
22
+ - **Manual correction tools** — paint mask additions and erasures with 50-level undo/redo
23
+ - **Full cross-section mode** — draw the interior wall curve for complete archaeological profiles
24
+ - **Publication-ready SVG** — `vector-effect="non-scaling-stroke"`, 45° hatch fill, scale bar, and embedded metadata
25
+ - **JSON sidecar** — machine-readable metadata compatible with Cache & Carry and HOARD pipelines
26
+ - **Batch processing** — process a whole folder of photographs with shared calibration
27
+ - **Dark archaeological theme** — low-eye-strain interface designed for long drawing sessions
28
+
29
+ ## Screenshots
30
+
31
+ > _Screenshots coming soon — contributions welcome._
32
+
33
+ ---
34
+
35
+ ## Quick Start
36
+
37
+ ### Standalone Binary (recommended)
38
+
39
+ Download the latest release for your platform from the
40
+ [Releases page](https://github.com/mabo-du/sherd/releases).
41
+ No Python installation required — just unzip and run.
42
+
43
+ ### From PyPI
44
+
45
+ ```bash
46
+ pip install sherd
47
+ python -m sherd
48
+ ```
49
+
50
+ ### From Source
51
+
52
+ ```bash
53
+ git clone https://github.com/mabo-du/sherd.git
54
+ cd sherd
55
+ python3 -m venv .venv
56
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
57
+ pip install -e ".[dev]"
58
+ python -m sherd
59
+ ```
60
+
61
+ ---
62
+
63
+ ## Workflow
64
+
65
+ ```
66
+ 1. Open a sherd photograph Ctrl+O
67
+ 2. Adjust threshold slider ← / → drag
68
+ 3. Accept Contour click button
69
+ 4. Drag axis lines if needed click + drag
70
+ 5. Calibrate scale Calibrate button
71
+ 6. (Optional) Correct mask B / E keys
72
+ 7. Switch profile mode Exterior / Full
73
+ 8. Export SVG + JSON sidecar Ctrl+E
74
+ ```
75
+
76
+ See the full **[User Guide](docs/UserGuide.md)** for detailed instructions,
77
+ photography tips, and troubleshooting.
78
+
79
+ ---
80
+
81
+ ## Requirements
82
+
83
+ | Component | Minimum |
84
+ |-----------|---------|
85
+ | Python | 3.12 |
86
+ | OpenCV | 4.10 |
87
+ | PyQt6 | 6.7 |
88
+ | SciPy | 1.13 |
89
+ | NumPy | 1.26 |
90
+
91
+ ---
92
+
93
+ ## Keyboard Shortcuts
94
+
95
+ | Shortcut | Action |
96
+ |----------|--------|
97
+ | `Ctrl+O` | Open image |
98
+ | `Ctrl+E` | Export SVG |
99
+ | `Ctrl+B` | Batch process folder |
100
+ | `Ctrl+Z` | Undo correction |
101
+ | `Ctrl+Shift+Z` | Redo correction |
102
+ | `B` | Brush mode |
103
+ | `E` | Eraser mode |
104
+ | `[` / `]` | Decrease / increase brush size |
105
+ | `Ctrl+0` | Fit image to view |
106
+ | `Ctrl+Q` | Exit |
107
+
108
+ ---
109
+
110
+ ## SVG Output Convention
111
+
112
+ All exported SVGs follow this structure and are compatible with Illustrator,
113
+ Inkscape, and CAD workflows:
114
+
115
+ ```xml
116
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:sherd="…">
117
+ <metadata>
118
+ <sherd:metadata accession_number="…" site_code="…" …/>
119
+ </metadata>
120
+ <defs>
121
+ <pattern id="hatch-45">…</pattern>
122
+ </defs>
123
+ <g id="profile">
124
+ <path id="exterior" vector-effect="non-scaling-stroke" …/>
125
+ <path id="interior" vector-effect="non-scaling-stroke" …/>
126
+ <path id="wall-section" fill="url(#hatch-45)" …/>
127
+ <line id="centring-axis" stroke-dasharray="6,3" …/>
128
+ <g id="scale-bar">…</g>
129
+ </g>
130
+ </svg>
131
+ ```
132
+
133
+ Stroke weights are in `pt` units with `vector-effect="non-scaling-stroke"` for
134
+ zoom-independent rendering at any print size.
135
+
136
+ ---
137
+
138
+ ## Project Structure
139
+
140
+ ```
141
+ sherd/
142
+ ├── sherd/
143
+ │ ├── core/ # Computer vision pipeline
144
+ │ │ ├── segmentation.py # Otsu, Adaptive, GrabCut
145
+ │ │ ├── calibration.py # Pixels-per-mm computation
146
+ │ │ ├── contour.py # B-spline smoothing, SVG path gen
147
+ │ │ ├── axis.py # Centring axis detection
148
+ │ │ ├── profile.py # Half-profile construction
149
+ │ │ ├── exporter.py # SVG + metadata generation
150
+ │ │ └── batch_processor.py
151
+ │ ├── ui/ # PyQt6 interface
152
+ │ │ ├── main_window.py # Three-panel layout
153
+ │ │ ├── image_panel.py # Photo + overlay display
154
+ │ │ ├── tools_panel.py # Controls sidebar
155
+ │ │ ├── preview_panel.py # SVG preview
156
+ │ │ ├── correction_scene.py # Mask painting
157
+ │ │ ├── axis_items.py # Draggable axis lines
158
+ │ │ ├── calibration_dialog.py
159
+ │ │ ├── export_dialog.py
160
+ │ │ ├── batch_dialog.py
161
+ │ │ ├── shortcuts_dialog.py
162
+ │ │ ├── workers.py # Background threads
163
+ │ │ └── theme.py # Dark archaeological theme
164
+ │ ├── utils/
165
+ │ │ ├── image_io.py
166
+ │ │ ├── svg_utils.py
167
+ │ │ └── sidecar.py # JSON sidecar writer
168
+ │ └── models/
169
+ │ ├── sherd_state.py # Central data model
170
+ │ └── settings.py # Persistent preferences
171
+ ├── tests/ # 122 tests (core + UI smoke)
172
+ ├── scripts/
173
+ │ └── poc_pipeline.py # CLI pipeline validator
174
+ ├── docs/
175
+ │ └── UserGuide.md
176
+ └── pyproject.toml
177
+ ```
178
+
179
+ ---
180
+
181
+ ## Development
182
+
183
+ ### Run tests
184
+
185
+ ```bash
186
+ pytest --tb=short
187
+ ```
188
+
189
+ ### Lint
190
+
191
+ ```bash
192
+ ruff check sherd/ tests/
193
+ ```
194
+
195
+ ### Build standalone binary
196
+
197
+ ```bash
198
+ pip install pyinstaller
199
+ pyinstaller sherd.spec --clean
200
+ # Output: dist/sherd/
201
+ ```
202
+
203
+ ---
204
+
205
+ ## Ecosystem Integration
206
+
207
+ Sherd is part of the open-source heritage science toolkit:
208
+
209
+ | Project | What Sherd provides |
210
+ |---------|-------------------|
211
+ | **Cache & Carry** | SVG + JSON as media attachment |
212
+ | **Trowel** | SVG embeds in Finds Catalogue plate |
213
+ | **HOARD** | SVG + JSON sidecar for finds appendix |
214
+ | **Chroma** | Munsell fabric colour cross-reference |
215
+
216
+ ---
217
+
218
+ ## Contributing
219
+
220
+ Contributions, bug reports, and feature requests are welcome.
221
+ Please open an issue before submitting a pull request so we can discuss the approach.
222
+
223
+ ---
224
+
225
+ ## Licence
226
+
227
+ MIT — see [LICENCE](LICENCE) for details.
@@ -0,0 +1,53 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sherd"
7
+ version = "0.1.0"
8
+ description = "Pottery Profile Vectoriser — automated SVG pottery profile drawings from photographs"
9
+ requires-python = ">=3.12"
10
+ license = {text = "MIT"}
11
+ authors = [
12
+ {name = "Mark Bouck"},
13
+ ]
14
+ dependencies = [
15
+ "opencv-python>=4.10",
16
+ "PyQt6>=6.7",
17
+ "svgwrite>=1.4",
18
+ "scipy>=1.13",
19
+ "numpy>=1.26",
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ dev = [
24
+ "pytest",
25
+ "pytest-qt",
26
+ "ruff",
27
+ "pyinstaller",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/mabo-du/sherd"
32
+
33
+ [tool.setuptools.packages.find]
34
+ include = ["sherd*"]
35
+
36
+ [tool.setuptools.package-data]
37
+ sherd = ["py.typed"]
38
+
39
+ [tool.ruff]
40
+ line-length = 88
41
+ target-version = "py312"
42
+
43
+ [tool.ruff.lint]
44
+ select = ["E", "F", "I", "N", "W"]
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["tests"]
48
+ python_files = ["test_*.py"]
49
+ filterwarnings = ["ignore::DeprecationWarning"]
50
+
51
+ [tool.coverage.run]
52
+ source = ["sherd"]
53
+ omit = ["tests/*"]
sherd-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"
@@ -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())
@@ -0,0 +1 @@
1
+ """Core image processing pipeline modules."""
@@ -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)