inkmap 0.1.2__tar.gz → 0.1.3__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 (39) hide show
  1. inkmap-0.1.3/.claude/skills/inkmap-dev-validation/SKILL.md +111 -0
  2. {inkmap-0.1.2 → inkmap-0.1.3}/PKG-INFO +64 -11
  3. {inkmap-0.1.2 → inkmap-0.1.3}/README.md +63 -10
  4. inkmap-0.1.3/docs/methods-and-results.md +505 -0
  5. inkmap-0.1.3/docs/methods-and-results.pdf +0 -0
  6. inkmap-0.1.2/inkmap/data.py → inkmap-0.1.3/inkmap/corpus.py +35 -17
  7. inkmap-0.1.3/inkmap/data/commons_marks.json +3458 -0
  8. inkmap-0.1.3/inkmap/data/skills/inkmap-usage/SKILL.md +222 -0
  9. inkmap-0.1.3/inkmap/locate/barcode.py +258 -0
  10. {inkmap-0.1.2 → inkmap-0.1.3}/inkmap/synth.py +2 -2
  11. {inkmap-0.1.2 → inkmap-0.1.3}/pyproject.toml +1 -1
  12. inkmap-0.1.3/scripts/build_report.py +92 -0
  13. {inkmap-0.1.2 → inkmap-0.1.3}/scripts/corpus_eval.py +1 -1
  14. inkmap-0.1.3/scripts/evaluate.py +319 -0
  15. inkmap-0.1.3/scripts/evaluate_dibco.py +165 -0
  16. inkmap-0.1.3/scripts/evaluate_text.py +217 -0
  17. {inkmap-0.1.2 → inkmap-0.1.3}/tests/test_inkmap.py +65 -0
  18. inkmap-0.1.2/inkmap/assets/commons_qr.json +0 -1514
  19. inkmap-0.1.2/inkmap/locate/barcode.py +0 -159
  20. {inkmap-0.1.2 → inkmap-0.1.3}/.github/workflows/ci.yml +0 -0
  21. {inkmap-0.1.2 → inkmap-0.1.3}/.gitignore +0 -0
  22. {inkmap-0.1.2 → inkmap-0.1.3}/LICENSE +0 -0
  23. {inkmap-0.1.2 → inkmap-0.1.3}/docs/terminology.md +0 -0
  24. {inkmap-0.1.2 → inkmap-0.1.3}/docs/validation.md +0 -0
  25. {inkmap-0.1.2 → inkmap-0.1.3}/inkmap/__init__.py +0 -0
  26. {inkmap-0.1.2 → inkmap-0.1.3}/inkmap/config.py +0 -0
  27. {inkmap-0.1.2 → inkmap-0.1.3}/inkmap/coverage.py +0 -0
  28. {inkmap-0.1.2/inkmap/assets → inkmap-0.1.3/inkmap/data}/default_qr.txt +0 -0
  29. {inkmap-0.1.2 → inkmap-0.1.3}/inkmap/geometry.py +0 -0
  30. {inkmap-0.1.2 → inkmap-0.1.3}/inkmap/locate/__init__.py +0 -0
  31. {inkmap-0.1.2 → inkmap-0.1.3}/inkmap/locate/qr.py +0 -0
  32. {inkmap-0.1.2 → inkmap-0.1.3}/inkmap/models/__init__.py +0 -0
  33. {inkmap-0.1.2 → inkmap-0.1.3}/inkmap/models/base.py +0 -0
  34. {inkmap-0.1.2 → inkmap-0.1.3}/inkmap/models/freeform.py +0 -0
  35. {inkmap-0.1.2 → inkmap-0.1.3}/inkmap/models/grid.py +0 -0
  36. {inkmap-0.1.2 → inkmap-0.1.3}/inkmap/photometry.py +0 -0
  37. {inkmap-0.1.2 → inkmap-0.1.3}/inkmap/types.py +0 -0
  38. {inkmap-0.1.2 → inkmap-0.1.3}/inkmap/viz.py +0 -0
  39. {inkmap-0.1.2 → inkmap-0.1.3}/scripts/validate.py +0 -0
@@ -0,0 +1,111 @@
1
+ ---
2
+ name: inkmap-dev-validation
3
+ description: Read before changing anything in inkmap that could move a number — geometry, coverage rasterisation, refinement, blur estimation, measurement weighting, or a locator — and before regenerating docs/methods-and-results.md or docs/validation.md. Covers the three validation tiers (synthetic ground truth, real-photograph round-trip, corpus detection), how to run each, which numbers live in which document, and the half-pixel convention failures that any of these changes can silently reintroduce. Triggers on "regenerate the benchmarks", "update the validation numbers", "the report is stale", "did that change accuracy", or any edit to coverage.py / geometry.py / models/ / locate/.
4
+ metadata:
5
+ audience: developers
6
+ project: inkmap
7
+ ---
8
+
9
+ # Validating inkmap
10
+
11
+ Three tiers, answering three different questions. Changing anything geometric means
12
+ re-running at least tiers 1 and 2, because the failure mode here is *silent*: a wrong
13
+ mask still looks like a mask.
14
+
15
+ | Tier | Question | Ground truth | Script |
16
+ |---|---|---|---|
17
+ | 1. Synthetic | Is the coverage value *correct*? | exact, by construction | `scripts/validate.py` |
18
+ | 2. Round-trip | Is the geometry right on *real* photos? | an independent decoder | `scripts/evaluate.py` |
19
+ | 3. Corpus | Does detection survive the real world? | none (rates only) | `scripts/corpus_eval.py` |
20
+
21
+ ## Tier 1 — synthetic, exact
22
+
23
+ ```bash
24
+ python scripts/validate.py > /tmp/validation.md
25
+ ```
26
+
27
+ No network, ~2 minutes. Produces the geometric-accuracy, coverage-MAE,
28
+ photometric-bias and blur-estimation tables in `docs/validation.md`.
29
+
30
+ **Why it can be exact:** `synth.render` builds the image *from* the coverage map —
31
+ supersample the model through a known transform, then form the image as
32
+ `255 * (1 - coverage)`. Ground truth and stimulus are the same object, so degradations
33
+ applied afterwards leave the truth correct. `test_scene_ground_truth_is_self_consistent`
34
+ guards that property; if it fails, every number in tier 1 is meaningless.
35
+
36
+ ## Tier 2 — round-trip on real photographs
37
+
38
+ ```bash
39
+ python scripts/evaluate.py <corpus_root> eval.json
40
+ ```
41
+
42
+ `<corpus_root>` holds `qr/`, `barcode/`, `text/` subfolders of real images.
43
+
44
+ **The trick:** no per-pixel ground truth exists for real photographs, so instead sample
45
+ the model through inkmap's *own* fitted transform, re-render it clean, and hand it to a
46
+ decoder. The decoder was not involved in fitting the geometry and has error correction,
47
+ so it does not accept near-misses.
48
+
49
+ **Calibrated sensitivity** (measured, and worth re-checking if you touch sampling):
50
+ round-trip passes at ≤0.25 units of transform error and fails at ≥0.5. So it certifies
51
+ "within a quarter of a unit", not "perfect". Do not oversell it.
52
+
53
+ Also computed: independent-model agreement (our sampled QR matrix vs OpenCV's separate
54
+ rectification) and class separation (background minus foreground grey level, which
55
+ collapses if the mask is misaligned).
56
+
57
+ ## Tier 3 — corpus detection rates
58
+
59
+ ```bash
60
+ python scripts/corpus_eval.py
61
+ ```
62
+
63
+ Fetches and caches ~189 CC-licensed Commons photographs on first run (several minutes;
64
+ Commons rate-limits and the fetcher backs off). Produces the per-locator detection table.
65
+
66
+ ## What lives where
67
+
68
+ - `docs/validation.md` — the numbers, all tiers. Regenerate wholesale; do not hand-edit
69
+ a table, or it will drift from the script that claims to produce it.
70
+ - `docs/methods-and-results.md` — the narrative: approach, algorithms, results, limits.
71
+ The published report; also the source of the PDF.
72
+ - `README.md` — only the headline photometric-bias table and the detection rates. If you
73
+ change those numbers, update the README too; there is no automation tying them.
74
+
75
+ Rebuild the PDF after editing the report:
76
+
77
+ ```bash
78
+ python scripts/build_report.py
79
+ ```
80
+
81
+ ## The failure this all exists to catch
82
+
83
+ Three half-pixel conventions have to agree, and each is invisible on inspection:
84
+
85
+ 1. **Pixel centre vs corner** — OpenCV's integer coordinate `x` is the *centre* of a
86
+ pixel spanning `[x-0.5, x+0.5)`. Rasterising `[x, x+1)` offsets the whole map half a
87
+ pixel.
88
+ 2. **The ECC template raster** — a model rendered at `s` px/unit puts template pixel `t`
89
+ over `[t/s, (t+1)/s)`, so its centre is `(t+0.5)/s`.
90
+ 3. **Locator corner conventions differ** — OpenCV's QR corners are the last *contained*
91
+ pixel index; zxing-cpp returns the continuous outer boundary.
92
+
93
+ Getting these wrong cost 50× in accuracy during development. **The diagnostic:** if
94
+ refinement error grows with the mark's rotation angle following `R(d) − d`, the residual
95
+ `d` is a coordinate-convention offset, not noise. `test_rotation_does_not_scramble_the_model`
96
+ is the regression test; it is parameterised over eight angles precisely because the bug
97
+ is invisible at 0°.
98
+
99
+ A fourth, related trap: the ECC template needs a rendered clear margin. Blurring a
100
+ template that stops at the mark's edge makes the kernel reflect interior content across
101
+ the border, inventing an edge profile the photograph does not have — ECC then shifts the
102
+ whole fit to match the artefact (~0.45 px).
103
+
104
+ ## Before you claim an improvement
105
+
106
+ - Run the full test suite; `tests/test_inkmap.py` is parameterised across model kinds
107
+ (matrix, bars, raster, polygon, glyph) on purpose — a change that helps QR and breaks
108
+ glyphs is not an improvement.
109
+ - Re-run tier 1 and compare tables, not vibes.
110
+ - Check that `measurability` still refuses the cases it should. Making more images
111
+ "measurable" is a regression unless the bias table improved with it.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: inkmap
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Per-pixel foreground coverage for photographed marks -- QR, barcodes, text, any shape you can model
5
5
  Project-URL: Homepage, https://github.com/thorwhalen/inkmap
6
6
  Author: Thor Whalen
@@ -159,24 +159,67 @@ and medical-imaging vocabulary: [docs/terminology.md](docs/terminology.md).
159
159
 
160
160
  ## How it works
161
161
 
162
+ ```
163
+ photograph ──► locate ──► recover model ──► refine transform ──► rasterise ──► erode
164
+ ```
165
+
162
166
  1. **Locate** — a cascade of `zxing-cpp`, OpenCV's Aruco-based QR detector and OpenCV's
163
167
  classic detector, plus linear barcodes. On 189 real photographs: zxing 74%, aruco
164
168
  68%, OpenCV 43%, **union 80%**. For marks nothing can auto-detect, `inkmap.place()`
165
169
  takes the corners from you.
166
170
  2. **Recover the model** — from a bit-exact rectification where a decoder offers one,
167
171
  otherwise sampled from the image. *Never* by re-encoding a decoded payload:
168
- error-correction level and mask pattern are not recoverable from the text, so
169
- re-encoding can silently produce a different pattern.
172
+ error-correction level, mask pattern and code-set choices are not recoverable from
173
+ the text, so re-encoding can silently produce a different pattern.
170
174
  3. **Refine** — align the rendered model to the photograph with ECC. Four locator
171
175
  corners are four observations; the model has thousands. Takes corner error from
172
- ~1 px to **0.02–0.03 px**.
176
+ ~1 px to **0.02–0.03 px** (4–53× depending on conditions).
173
177
  4. **Rasterise** — supersample each pixel, back-project through the transform, ask the
174
- model. Exact up to quantisation, and independent of lighting.
175
- 5. **Erode to a weight** — by 2–3× the estimated blur σ.
178
+ model `occupancy(u, v)`. Exact up to the supersampling rate, and independent of
179
+ lighting. Coverage MAE against exact ground truth is **0.0000–0.0005**.
180
+ 5. **Erode to a weight** — by ~2× the estimated blur σ, because geometric purity is not
181
+ enough (see the table above).
176
182
 
177
183
  Decoding is optional throughout. A mark whose material barely deposited will not
178
184
  decode, and that is exactly the case that must still work.
179
185
 
186
+ Sub-pixel geometry turns out to hinge on **three half-pixel conventions agreeing** —
187
+ pixel-centre vs pixel-corner rasterisation, the template raster, and the fact that
188
+ different locators disagree about what a "corner" is. Getting them wrong cost 50× in
189
+ accuracy during development, and none of it is visible on inspection: the mask still
190
+ looks like a mask. The diagnostic, if you ever chase this: *if the error grows with the
191
+ mark's rotation angle following `R(d) − d`, the residual `d` is a convention offset, not
192
+ noise.*
193
+
194
+ ## How well it works
195
+
196
+ Validated three ways, because no single way is sufficient — synthetic scenes (exact
197
+ ground truth by construction), real photographs (round-trip through an independent
198
+ decoder), and DIBCO (hand-labelled per-pixel ink truth).
199
+
200
+ | | detection | round-trip geometry | round-trip when *measurable* |
201
+ |---|---|---|---|
202
+ | **QR** (189 real photos) | 80% | 77% | **96%** |
203
+ | **Barcode** (56 real photos) | 95% | 48% | **89%** |
204
+ | **Text** (86 real words) | — (no locator; you supply the quad) | — | see below |
205
+
206
+ The conditional matters: **when inkmap judges an image measurable, an independent decoder
207
+ confirms the geometry 96% of the time.** Failures concentrate where the library already
208
+ says not to trust it.
209
+
210
+ On **real ink with hand-labelled ground truth** (DIBCO), eroding a *perfect* mask by one
211
+ pixel shifts the measured foreground mean by **17% of the foreground/background
212
+ contrast** — so the contamination argument is not an artefact of synthetic data.
213
+
214
+ **Text is the honest weak spot.** Placement works (IoU 0.76 with an image-derived shape)
215
+ but a glyph model with the wrong font does not (IoU 0.17). Measured directly: with the
216
+ correct font IoU is 0.997–1.000; with a wrong one, 0.46–0.80. For text, pass the actual
217
+ font or take the shape from the image.
218
+
219
+ 📄 **[Methods and results](docs/methods-and-results.md)** ([PDF](docs/methods-and-results.pdf))
220
+ — the full write-up: approach, algorithms, every number, the datasets, the limits, and a
221
+ barcode defect the evaluation caught and forced a fix for (round-trip 5% → 48%).
222
+
180
223
  ## Extending it
181
224
 
182
225
  ```python
@@ -187,15 +230,25 @@ inkmap.find(photo, locators=["mine", "qr_zxing"])
187
230
  Tunable defaults live in `inkmap.config` — supersampling, ECC parameters, erosion
188
231
  margin, saturation levels — as documented constants rather than inline magic numbers.
189
232
 
233
+ ## Skills for AI agents
234
+
235
+ If you drive inkmap from an agent, the shipped skill
236
+ [`inkmap-usage`](inkmap/data/skills/inkmap-usage/SKILL.md) is the short version of
237
+ everything that is easy to get wrong: choosing a model, the corner-order trap, which of
238
+ the three 0–1 channels to use for what, and gating on measurability. Install it with
239
+ [`skill`](https://github.com/thorwhalen/skill), or just read it.
240
+
190
241
  ## Test data
191
242
 
192
- 189 real photographs of marks in the wild (CC0 / public domain / CC BY / CC BY-SA),
193
- fetched on demand and cached:
243
+ 384 real photographs of marks in the wild — 189 QR, 56 barcodes, 139 printed text —
244
+ (CC0 / public domain / CC BY / CC BY-SA), fetched on demand and cached:
194
245
 
195
246
  ```python
196
- from inkmap import data
197
- paths = data.fetch()
198
- print(data.attribution_text())
247
+ from inkmap import corpus
248
+
249
+ corpus.kinds() # {'qr': 189, 'barcode': 56, 'text': 139}
250
+ paths = corpus.fetch(kind="barcode")
251
+ print(corpus.attribution_text()) # attribution is required for CC BY / CC BY-SA
199
252
  ```
200
253
 
201
254
  Synthetic scenes with **exact** ground-truth coverage — the only way to validate a 0–1
@@ -126,24 +126,67 @@ and medical-imaging vocabulary: [docs/terminology.md](docs/terminology.md).
126
126
 
127
127
  ## How it works
128
128
 
129
+ ```
130
+ photograph ──► locate ──► recover model ──► refine transform ──► rasterise ──► erode
131
+ ```
132
+
129
133
  1. **Locate** — a cascade of `zxing-cpp`, OpenCV's Aruco-based QR detector and OpenCV's
130
134
  classic detector, plus linear barcodes. On 189 real photographs: zxing 74%, aruco
131
135
  68%, OpenCV 43%, **union 80%**. For marks nothing can auto-detect, `inkmap.place()`
132
136
  takes the corners from you.
133
137
  2. **Recover the model** — from a bit-exact rectification where a decoder offers one,
134
138
  otherwise sampled from the image. *Never* by re-encoding a decoded payload:
135
- error-correction level and mask pattern are not recoverable from the text, so
136
- re-encoding can silently produce a different pattern.
139
+ error-correction level, mask pattern and code-set choices are not recoverable from
140
+ the text, so re-encoding can silently produce a different pattern.
137
141
  3. **Refine** — align the rendered model to the photograph with ECC. Four locator
138
142
  corners are four observations; the model has thousands. Takes corner error from
139
- ~1 px to **0.02–0.03 px**.
143
+ ~1 px to **0.02–0.03 px** (4–53× depending on conditions).
140
144
  4. **Rasterise** — supersample each pixel, back-project through the transform, ask the
141
- model. Exact up to quantisation, and independent of lighting.
142
- 5. **Erode to a weight** — by 2–3× the estimated blur σ.
145
+ model `occupancy(u, v)`. Exact up to the supersampling rate, and independent of
146
+ lighting. Coverage MAE against exact ground truth is **0.0000–0.0005**.
147
+ 5. **Erode to a weight** — by ~2× the estimated blur σ, because geometric purity is not
148
+ enough (see the table above).
143
149
 
144
150
  Decoding is optional throughout. A mark whose material barely deposited will not
145
151
  decode, and that is exactly the case that must still work.
146
152
 
153
+ Sub-pixel geometry turns out to hinge on **three half-pixel conventions agreeing** —
154
+ pixel-centre vs pixel-corner rasterisation, the template raster, and the fact that
155
+ different locators disagree about what a "corner" is. Getting them wrong cost 50× in
156
+ accuracy during development, and none of it is visible on inspection: the mask still
157
+ looks like a mask. The diagnostic, if you ever chase this: *if the error grows with the
158
+ mark's rotation angle following `R(d) − d`, the residual `d` is a convention offset, not
159
+ noise.*
160
+
161
+ ## How well it works
162
+
163
+ Validated three ways, because no single way is sufficient — synthetic scenes (exact
164
+ ground truth by construction), real photographs (round-trip through an independent
165
+ decoder), and DIBCO (hand-labelled per-pixel ink truth).
166
+
167
+ | | detection | round-trip geometry | round-trip when *measurable* |
168
+ |---|---|---|---|
169
+ | **QR** (189 real photos) | 80% | 77% | **96%** |
170
+ | **Barcode** (56 real photos) | 95% | 48% | **89%** |
171
+ | **Text** (86 real words) | — (no locator; you supply the quad) | — | see below |
172
+
173
+ The conditional matters: **when inkmap judges an image measurable, an independent decoder
174
+ confirms the geometry 96% of the time.** Failures concentrate where the library already
175
+ says not to trust it.
176
+
177
+ On **real ink with hand-labelled ground truth** (DIBCO), eroding a *perfect* mask by one
178
+ pixel shifts the measured foreground mean by **17% of the foreground/background
179
+ contrast** — so the contamination argument is not an artefact of synthetic data.
180
+
181
+ **Text is the honest weak spot.** Placement works (IoU 0.76 with an image-derived shape)
182
+ but a glyph model with the wrong font does not (IoU 0.17). Measured directly: with the
183
+ correct font IoU is 0.997–1.000; with a wrong one, 0.46–0.80. For text, pass the actual
184
+ font or take the shape from the image.
185
+
186
+ 📄 **[Methods and results](docs/methods-and-results.md)** ([PDF](docs/methods-and-results.pdf))
187
+ — the full write-up: approach, algorithms, every number, the datasets, the limits, and a
188
+ barcode defect the evaluation caught and forced a fix for (round-trip 5% → 48%).
189
+
147
190
  ## Extending it
148
191
 
149
192
  ```python
@@ -154,15 +197,25 @@ inkmap.find(photo, locators=["mine", "qr_zxing"])
154
197
  Tunable defaults live in `inkmap.config` — supersampling, ECC parameters, erosion
155
198
  margin, saturation levels — as documented constants rather than inline magic numbers.
156
199
 
200
+ ## Skills for AI agents
201
+
202
+ If you drive inkmap from an agent, the shipped skill
203
+ [`inkmap-usage`](inkmap/data/skills/inkmap-usage/SKILL.md) is the short version of
204
+ everything that is easy to get wrong: choosing a model, the corner-order trap, which of
205
+ the three 0–1 channels to use for what, and gating on measurability. Install it with
206
+ [`skill`](https://github.com/thorwhalen/skill), or just read it.
207
+
157
208
  ## Test data
158
209
 
159
- 189 real photographs of marks in the wild (CC0 / public domain / CC BY / CC BY-SA),
160
- fetched on demand and cached:
210
+ 384 real photographs of marks in the wild — 189 QR, 56 barcodes, 139 printed text —
211
+ (CC0 / public domain / CC BY / CC BY-SA), fetched on demand and cached:
161
212
 
162
213
  ```python
163
- from inkmap import data
164
- paths = data.fetch()
165
- print(data.attribution_text())
214
+ from inkmap import corpus
215
+
216
+ corpus.kinds() # {'qr': 189, 'barcode': 56, 'text': 139}
217
+ paths = corpus.fetch(kind="barcode")
218
+ print(corpus.attribution_text()) # attribution is required for CC BY / CC BY-SA
166
219
  ```
167
220
 
168
221
  Synthetic scenes with **exact** ground-truth coverage — the only way to validate a 0–1