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.
- sherd-0.1.0/PKG-INFO +18 -0
- sherd-0.1.0/README.md +227 -0
- sherd-0.1.0/pyproject.toml +53 -0
- sherd-0.1.0/setup.cfg +4 -0
- sherd-0.1.0/sherd/__init__.py +6 -0
- sherd-0.1.0/sherd/__main__.py +7 -0
- sherd-0.1.0/sherd/core/__init__.py +1 -0
- sherd-0.1.0/sherd/core/axis.py +89 -0
- sherd-0.1.0/sherd/core/batch_processor.py +202 -0
- sherd-0.1.0/sherd/core/calibration.py +109 -0
- sherd-0.1.0/sherd/core/contour.py +90 -0
- sherd-0.1.0/sherd/core/exporter.py +292 -0
- sherd-0.1.0/sherd/core/profile.py +84 -0
- sherd-0.1.0/sherd/core/segmentation.py +187 -0
- sherd-0.1.0/sherd/main.py +37 -0
- sherd-0.1.0/sherd/models/__init__.py +1 -0
- sherd-0.1.0/sherd/models/settings.py +73 -0
- sherd-0.1.0/sherd/models/sherd_state.py +68 -0
- sherd-0.1.0/sherd/ui/__init__.py +1 -0
- sherd-0.1.0/sherd/ui/axis_items.py +186 -0
- sherd-0.1.0/sherd/ui/batch_dialog.py +288 -0
- sherd-0.1.0/sherd/ui/calibration_dialog.py +112 -0
- sherd-0.1.0/sherd/ui/correction_scene.py +245 -0
- sherd-0.1.0/sherd/ui/export_dialog.py +163 -0
- sherd-0.1.0/sherd/ui/image_panel.py +565 -0
- sherd-0.1.0/sherd/ui/main_window.py +978 -0
- sherd-0.1.0/sherd/ui/preview_panel.py +65 -0
- sherd-0.1.0/sherd/ui/shortcuts_dialog.py +60 -0
- sherd-0.1.0/sherd/ui/theme.py +196 -0
- sherd-0.1.0/sherd/ui/tools_panel.py +368 -0
- sherd-0.1.0/sherd/ui/workers.py +123 -0
- sherd-0.1.0/sherd/utils/__init__.py +1 -0
- sherd-0.1.0/sherd/utils/image_io.py +75 -0
- sherd-0.1.0/sherd/utils/sidecar.py +48 -0
- sherd-0.1.0/sherd/utils/svg_utils.py +72 -0
- sherd-0.1.0/sherd.egg-info/PKG-INFO +18 -0
- sherd-0.1.0/sherd.egg-info/SOURCES.txt +46 -0
- sherd-0.1.0/sherd.egg-info/dependency_links.txt +1 -0
- sherd-0.1.0/sherd.egg-info/requires.txt +11 -0
- sherd-0.1.0/sherd.egg-info/top_level.txt +1 -0
- sherd-0.1.0/tests/test_axis.py +72 -0
- sherd-0.1.0/tests/test_calibration.py +83 -0
- sherd-0.1.0/tests/test_contour.py +86 -0
- sherd-0.1.0/tests/test_exporter.py +100 -0
- sherd-0.1.0/tests/test_image_io.py +53 -0
- sherd-0.1.0/tests/test_profile.py +66 -0
- sherd-0.1.0/tests/test_segmentation.py +100 -0
- 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
|
+
[](https://github.com/mabo-du/sherd/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/sherd/)
|
|
5
|
+
[](https://pypi.org/project/sherd/)
|
|
6
|
+
[](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 @@
|
|
|
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)
|