inkmap 0.1.1__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.
- inkmap-0.1.1/.github/workflows/ci.yml +46 -0
- inkmap-0.1.1/.gitignore +24 -0
- inkmap-0.1.1/LICENSE +21 -0
- inkmap-0.1.1/PKG-INFO +242 -0
- inkmap-0.1.1/README.md +209 -0
- inkmap-0.1.1/docs/terminology.md +145 -0
- inkmap-0.1.1/docs/validation.md +197 -0
- inkmap-0.1.1/inkmap/__init__.py +271 -0
- inkmap-0.1.1/inkmap/assets/commons_qr.json +1514 -0
- inkmap-0.1.1/inkmap/assets/default_qr.txt +31 -0
- inkmap-0.1.1/inkmap/config.py +79 -0
- inkmap-0.1.1/inkmap/coverage.py +343 -0
- inkmap-0.1.1/inkmap/data.py +124 -0
- inkmap-0.1.1/inkmap/geometry.py +211 -0
- inkmap-0.1.1/inkmap/locate/__init__.py +117 -0
- inkmap-0.1.1/inkmap/locate/barcode.py +159 -0
- inkmap-0.1.1/inkmap/locate/qr.py +276 -0
- inkmap-0.1.1/inkmap/models/__init__.py +34 -0
- inkmap-0.1.1/inkmap/models/base.py +163 -0
- inkmap-0.1.1/inkmap/models/freeform.py +179 -0
- inkmap-0.1.1/inkmap/models/grid.py +100 -0
- inkmap-0.1.1/inkmap/photometry.py +321 -0
- inkmap-0.1.1/inkmap/synth.py +209 -0
- inkmap-0.1.1/inkmap/types.py +207 -0
- inkmap-0.1.1/inkmap/viz.py +158 -0
- inkmap-0.1.1/pyproject.toml +189 -0
- inkmap-0.1.1/scripts/corpus_eval.py +114 -0
- inkmap-0.1.1/scripts/validate.py +164 -0
- inkmap-0.1.1/tests/test_inkmap.py +469 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# wads CI — calls the reusable workflow hosted in i2mint/wads.
|
|
2
|
+
#
|
|
3
|
+
# All configuration comes from this repo's pyproject.toml [tool.wads.ci.*].
|
|
4
|
+
# To customize the workflow itself (rare), replace this file with the
|
|
5
|
+
# full inline template `wads/data/github_ci_uv.yml` from i2mint/wads.
|
|
6
|
+
#
|
|
7
|
+
# Pinning: `@master` floats with wads. If you need version stability for
|
|
8
|
+
# a release-sensitive repo, change `@master` to a wads tag (e.g. `@v0.1.81`).
|
|
9
|
+
# CI failure does not block a published release — it blocks the publish
|
|
10
|
+
# step itself — so floating master is generally safe.
|
|
11
|
+
#
|
|
12
|
+
# Permissions: GitHub validates that the caller grants AT LEAST the
|
|
13
|
+
# permissions any job in the called workflow requests — at workflow-parse
|
|
14
|
+
# time, not at run-time, even if the job would be skipped via `if:`.
|
|
15
|
+
# The reusable workflow needs:
|
|
16
|
+
# contents: write for the publish job's version-bump push-back
|
|
17
|
+
# pages: write for the github-pages publish (if enabled)
|
|
18
|
+
# id-token: write for the github-pages OIDC handshake (if enabled)
|
|
19
|
+
# Granting these in the stub keeps it working on any caller, including
|
|
20
|
+
# personal-account repos where id-token is not write-by-default.
|
|
21
|
+
name: Continuous Integration
|
|
22
|
+
on: [push, pull_request]
|
|
23
|
+
jobs:
|
|
24
|
+
ci:
|
|
25
|
+
uses: i2mint/wads/.github/workflows/uv-ci.yml@master
|
|
26
|
+
permissions:
|
|
27
|
+
contents: write
|
|
28
|
+
pages: write
|
|
29
|
+
id-token: write
|
|
30
|
+
# Explicit pass-through (not `secrets: inherit`) because `inherit` does
|
|
31
|
+
# not reliably propagate caller-repo secrets to a reusable workflow
|
|
32
|
+
# owned by a different account (verified empirically: caller in a
|
|
33
|
+
# personal account, called in i2mint org → `${{ secrets.PYPI_PASSWORD }}`
|
|
34
|
+
# resolved to empty inside the reusable workflow despite the secret
|
|
35
|
+
# being set on the caller repo). Listing each secret here makes the
|
|
36
|
+
# propagation unambiguous regardless of caller-vs-called ownership.
|
|
37
|
+
# Missing secrets on the caller resolve to empty strings, harmless for
|
|
38
|
+
# the optional ones; PYPI_PASSWORD must be set for the publish job.
|
|
39
|
+
secrets:
|
|
40
|
+
PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
|
|
41
|
+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
42
|
+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
43
|
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
|
44
|
+
HUGGINGFACE_TOKEN: ${{ secrets.HUGGINGFACE_TOKEN }}
|
|
45
|
+
KAGGLE_USERNAME: ${{ secrets.KAGGLE_USERNAME }}
|
|
46
|
+
KAGGLE_KEY: ${{ secrets.KAGGLE_KEY }}
|
inkmap-0.1.1/.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*.egg-info/
|
|
4
|
+
build/
|
|
5
|
+
dist/
|
|
6
|
+
.eggs/
|
|
7
|
+
|
|
8
|
+
.venv/
|
|
9
|
+
venv/
|
|
10
|
+
.env
|
|
11
|
+
|
|
12
|
+
.pytest_cache/
|
|
13
|
+
.ruff_cache/
|
|
14
|
+
.coverage
|
|
15
|
+
coverage.xml
|
|
16
|
+
htmlcov/
|
|
17
|
+
|
|
18
|
+
.DS_Store
|
|
19
|
+
.idea/
|
|
20
|
+
.vscode/
|
|
21
|
+
|
|
22
|
+
# Fetched test images are cached outside the repo, but guard against a stray --dest.
|
|
23
|
+
/qrdata/
|
|
24
|
+
scrap/
|
inkmap-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thor Whalen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
inkmap-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: inkmap
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Per-pixel foreground coverage for photographed marks -- QR, barcodes, text, any shape you can model
|
|
5
|
+
Project-URL: Homepage, https://github.com/thorwhalen/inkmap
|
|
6
|
+
Author: Thor Whalen
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: barcode,computer-vision,coverage,foreground,image-processing,ink,ocr,photometry,qrcode,segmentation,subpixel
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Image Processing
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: numpy>=1.23
|
|
17
|
+
Requires-Dist: opencv-python-headless>=4.7
|
|
18
|
+
Requires-Dist: zxing-cpp>=2.0
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pillow>=9.0; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: segno>=1.5; extra == 'dev'
|
|
25
|
+
Provides-Extra: docs
|
|
26
|
+
Requires-Dist: sphinx-rtd-theme>=1.0; extra == 'docs'
|
|
27
|
+
Requires-Dist: sphinx>=6.0; extra == 'docs'
|
|
28
|
+
Provides-Extra: synth
|
|
29
|
+
Requires-Dist: segno>=1.5; extra == 'synth'
|
|
30
|
+
Provides-Extra: text
|
|
31
|
+
Requires-Dist: pillow>=9.0; extra == 'text'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# inkmap
|
|
35
|
+
|
|
36
|
+
Per-pixel foreground coverage for photographed marks.
|
|
37
|
+
|
|
38
|
+
Given a photo of a printed mark and a **model** of its shape, say — for every pixel —
|
|
39
|
+
how much of it is foreground (the mark) and how much is background (the substrate). Not
|
|
40
|
+
a boolean mask: a continuous 0–1 area fraction, accurate enough to measure on.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install inkmap
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import inkmap
|
|
48
|
+
|
|
49
|
+
coverage = inkmap.foreground("photo.jpg") # (H, W) float32 in [0, 1]
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## The idea
|
|
53
|
+
|
|
54
|
+
Decoding a QR code is a solved problem. Reading text is a solved problem. **Measuring**
|
|
55
|
+
the mark itself is not: if you need to know the reflectance, density, or colour of the
|
|
56
|
+
printed material — as opposed to what it says — you need to know exactly which pixels
|
|
57
|
+
are mark, which are substrate, and which are neither.
|
|
58
|
+
|
|
59
|
+
The shape is the input, not a special case. A model answers one question — *is this
|
|
60
|
+
point, in my own coordinate system, foreground?* — and everything else (locating,
|
|
61
|
+
sub-pixel refinement, rasterising, measuring) is written against that one question. So
|
|
62
|
+
QR symbols, barcodes, printed letters and arbitrary artwork all go through one pipeline.
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from inkmap import models
|
|
66
|
+
|
|
67
|
+
inkmap.find(photo) # auto-detect QR / barcode
|
|
68
|
+
inkmap.find(photo, model=models.text_model("ABC"), quad=q) # printed text
|
|
69
|
+
inkmap.find(photo, model=models.RasterModel(logo), quad=q) # anything you can draw
|
|
70
|
+
inkmap.find(photo, model=models.PolygonModel(outlines), quad=q) # exact vector shape
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Writing your own is two members and no base class:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
class Disc:
|
|
77
|
+
extent = (20.0, 20.0) # model space is 20 x 20 units
|
|
78
|
+
feature_size = 10.0 # smallest feature that must be resolved
|
|
79
|
+
|
|
80
|
+
def occupancy(self, u, v): # vectorised
|
|
81
|
+
return (u - 10) ** 2 + (v - 10) ** 2 < 81
|
|
82
|
+
|
|
83
|
+
inkmap.find(photo, model=Disc(), quad=corners)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## What you get back
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
ink = inkmap.find("photo.jpg") # an InkMap, or None
|
|
90
|
+
|
|
91
|
+
ink.placement.quad # (4, 2) corners — a quadrilateral, not a
|
|
92
|
+
# rectangle; perspective is expected
|
|
93
|
+
ink.placement.transform # (3, 3) model space -> image pixels
|
|
94
|
+
ink.placement.pixels_per_feature # the resolution figure that governs everything
|
|
95
|
+
|
|
96
|
+
ink.coverage # (H, W) float32 — foreground area fraction
|
|
97
|
+
ink.region # OUTSIDE / MARGIN / BACKGROUND / FOREGROUND
|
|
98
|
+
ink.weight # (H, W) float32 — safe-to-measure weighting
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Then measure:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
result = inkmap.measure("photo.jpg", ink)
|
|
105
|
+
|
|
106
|
+
result.foreground_mean # sRGB-linearised mean over safe pixels
|
|
107
|
+
result.background_mean
|
|
108
|
+
result.contrast
|
|
109
|
+
result.measurability.ok # False if this image can't support it
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`measure` accepts any frame registered to the one you located in, so geometry can be
|
|
113
|
+
established once on the sharpest image and reused across exposures, channels or
|
|
114
|
+
modalities.
|
|
115
|
+
|
|
116
|
+
## The three 0–1 channels
|
|
117
|
+
|
|
118
|
+
Conflating these is the fastest route to a wrong measurement:
|
|
119
|
+
|
|
120
|
+
| | what it is | use it for |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| `coverage` | **geometric** — fraction of the pixel's area under foreground | reporting, print QC, finding mixed pixels |
|
|
123
|
+
| `weight` | **blur-aware** — coverage eroded by how far the PSF actually reaches | **the only channel that should gate a measurement** |
|
|
124
|
+
| `confidence` | **epistemic** — how much to trust the label here | quality gating, artefact rejection |
|
|
125
|
+
|
|
126
|
+
If the underlying reality is binary, "probability this pixel is foreground" is
|
|
127
|
+
`confidence`. If it isn't — a pixel genuinely straddling an edge — the degree of
|
|
128
|
+
membership is `coverage`. They are different numbers and both are worth having.
|
|
129
|
+
|
|
130
|
+
Measured on synthetic scenes with exact ground truth, recovering a foreground-only
|
|
131
|
+
signal at 6 px per feature:
|
|
132
|
+
|
|
133
|
+
| pixel selection | no blur | σ=1 | σ=2 | σ=3 |
|
|
134
|
+
|---|---|---|---|---|
|
|
135
|
+
| `coverage > 0.5` (what a threshold gives you) | −3.3% | −12.2% | −23.3% | −32.4% |
|
|
136
|
+
| `coverage == 1.0` (geometrically pure) | −0.0% | −8.0% | −19.9% | −30.1% |
|
|
137
|
+
| **`weight`** (PSF-eroded) | **+0.0%** | **−0.2%** | **−0.6%** | **−1.5%** |
|
|
138
|
+
|
|
139
|
+
Geometric purity is *not* sufficient: blur drags background signal into pixels entirely
|
|
140
|
+
under foreground, and that contamination has a different footprint from partial
|
|
141
|
+
coverage — so no coverage-based weighting removes it. Only spatial erosion does. See
|
|
142
|
+
[docs/validation.md](docs/validation.md).
|
|
143
|
+
|
|
144
|
+
## Terminology
|
|
145
|
+
|
|
146
|
+
The classes are **foreground** and **background**, never dark/light — appearance
|
|
147
|
+
inverts (white-on-black print, reflectance-reversed symbols, inverted imaging), so an
|
|
148
|
+
appearance-based name would mean the mark in one image and the substrate in the next.
|
|
149
|
+
`Polarity` carries that mapping explicitly:
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
from inkmap import Polarity
|
|
153
|
+
inkmap.find(photo, polarity=Polarity.FOREGROUND_IS_LIGHT)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The continuous value is **coverage**, after ISO 12647-2 *area coverage*. Full
|
|
157
|
+
cross-field mapping to ISO/IEC 18004, 15415, DIBCO, printing, matting, remote sensing
|
|
158
|
+
and medical-imaging vocabulary: [docs/terminology.md](docs/terminology.md).
|
|
159
|
+
|
|
160
|
+
## How it works
|
|
161
|
+
|
|
162
|
+
1. **Locate** — a cascade of `zxing-cpp`, OpenCV's Aruco-based QR detector and OpenCV's
|
|
163
|
+
classic detector, plus linear barcodes. On 189 real photographs: zxing 74%, aruco
|
|
164
|
+
68%, OpenCV 43%, **union 80%**. For marks nothing can auto-detect, `inkmap.place()`
|
|
165
|
+
takes the corners from you.
|
|
166
|
+
2. **Recover the model** — from a bit-exact rectification where a decoder offers one,
|
|
167
|
+
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.
|
|
170
|
+
3. **Refine** — align the rendered model to the photograph with ECC. Four locator
|
|
171
|
+
corners are four observations; the model has thousands. Takes corner error from
|
|
172
|
+
~1 px to **0.02–0.03 px**.
|
|
173
|
+
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 σ.
|
|
176
|
+
|
|
177
|
+
Decoding is optional throughout. A mark whose material barely deposited will not
|
|
178
|
+
decode, and that is exactly the case that must still work.
|
|
179
|
+
|
|
180
|
+
## Extending it
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
inkmap.LOCATORS["mine"] = my_locator # (bgr, gray) -> list[Placement]
|
|
184
|
+
inkmap.find(photo, locators=["mine", "qr_zxing"])
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Tunable defaults live in `inkmap.config` — supersampling, ECC parameters, erosion
|
|
188
|
+
margin, saturation levels — as documented constants rather than inline magic numbers.
|
|
189
|
+
|
|
190
|
+
## Test data
|
|
191
|
+
|
|
192
|
+
189 real photographs of marks in the wild (CC0 / public domain / CC BY / CC BY-SA),
|
|
193
|
+
fetched on demand and cached:
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
from inkmap import data
|
|
197
|
+
paths = data.fetch()
|
|
198
|
+
print(data.attribution_text())
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Synthetic scenes with **exact** ground-truth coverage — the only way to validate a 0–1
|
|
202
|
+
output, since no photograph comes with per-pixel truth:
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
from inkmap import synth
|
|
206
|
+
|
|
207
|
+
scene = synth.render(my_model, perspective=0.05, blur=1.2, glare=0.3, jpeg_quality=80)
|
|
208
|
+
scene.image # the degraded photograph
|
|
209
|
+
scene.coverage # exact ground truth, by construction
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Looking at results
|
|
213
|
+
|
|
214
|
+
Coverage maps look plausible when subtly wrong, so check them:
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
from inkmap import viz
|
|
218
|
+
import cv2
|
|
219
|
+
|
|
220
|
+
# original | overlay | coverage | weight
|
|
221
|
+
cv2.imwrite("check.png", viz.panel(photo, ink))
|
|
222
|
+
cv2.imwrite("grid.png", viz.overlay(photo, ink, draw_grid=True))
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## Install notes
|
|
226
|
+
|
|
227
|
+
Hard dependencies are `numpy`, `opencv-python-headless` and `zxing-cpp` — all
|
|
228
|
+
permissively licensed (BSD / Apache-2.0), all pure wheels, no system libraries.
|
|
229
|
+
|
|
230
|
+
If you already have `opencv-python` or `opencv-contrib-python`, install with
|
|
231
|
+
`--no-deps` and keep yours: all four `opencv-*` distributions provide the same `cv2`
|
|
232
|
+
and conflict when more than one is installed.
|
|
233
|
+
|
|
234
|
+
`segno` is needed only to build a QR for an arbitrary payload (`inkmap[synth]`);
|
|
235
|
+
Pillow only for `GlyphModel` (`inkmap[text]`).
|
|
236
|
+
|
|
237
|
+
Deliberately **not** used: `qrdet` / `qreader`, which are MIT on the tin but depend on
|
|
238
|
+
`ultralytics`, which is AGPL-3.0.
|
|
239
|
+
|
|
240
|
+
## License
|
|
241
|
+
|
|
242
|
+
MIT
|
inkmap-0.1.1/README.md
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# inkmap
|
|
2
|
+
|
|
3
|
+
Per-pixel foreground coverage for photographed marks.
|
|
4
|
+
|
|
5
|
+
Given a photo of a printed mark and a **model** of its shape, say — for every pixel —
|
|
6
|
+
how much of it is foreground (the mark) and how much is background (the substrate). Not
|
|
7
|
+
a boolean mask: a continuous 0–1 area fraction, accurate enough to measure on.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install inkmap
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import inkmap
|
|
15
|
+
|
|
16
|
+
coverage = inkmap.foreground("photo.jpg") # (H, W) float32 in [0, 1]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## The idea
|
|
20
|
+
|
|
21
|
+
Decoding a QR code is a solved problem. Reading text is a solved problem. **Measuring**
|
|
22
|
+
the mark itself is not: if you need to know the reflectance, density, or colour of the
|
|
23
|
+
printed material — as opposed to what it says — you need to know exactly which pixels
|
|
24
|
+
are mark, which are substrate, and which are neither.
|
|
25
|
+
|
|
26
|
+
The shape is the input, not a special case. A model answers one question — *is this
|
|
27
|
+
point, in my own coordinate system, foreground?* — and everything else (locating,
|
|
28
|
+
sub-pixel refinement, rasterising, measuring) is written against that one question. So
|
|
29
|
+
QR symbols, barcodes, printed letters and arbitrary artwork all go through one pipeline.
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from inkmap import models
|
|
33
|
+
|
|
34
|
+
inkmap.find(photo) # auto-detect QR / barcode
|
|
35
|
+
inkmap.find(photo, model=models.text_model("ABC"), quad=q) # printed text
|
|
36
|
+
inkmap.find(photo, model=models.RasterModel(logo), quad=q) # anything you can draw
|
|
37
|
+
inkmap.find(photo, model=models.PolygonModel(outlines), quad=q) # exact vector shape
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Writing your own is two members and no base class:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
class Disc:
|
|
44
|
+
extent = (20.0, 20.0) # model space is 20 x 20 units
|
|
45
|
+
feature_size = 10.0 # smallest feature that must be resolved
|
|
46
|
+
|
|
47
|
+
def occupancy(self, u, v): # vectorised
|
|
48
|
+
return (u - 10) ** 2 + (v - 10) ** 2 < 81
|
|
49
|
+
|
|
50
|
+
inkmap.find(photo, model=Disc(), quad=corners)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## What you get back
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
ink = inkmap.find("photo.jpg") # an InkMap, or None
|
|
57
|
+
|
|
58
|
+
ink.placement.quad # (4, 2) corners — a quadrilateral, not a
|
|
59
|
+
# rectangle; perspective is expected
|
|
60
|
+
ink.placement.transform # (3, 3) model space -> image pixels
|
|
61
|
+
ink.placement.pixels_per_feature # the resolution figure that governs everything
|
|
62
|
+
|
|
63
|
+
ink.coverage # (H, W) float32 — foreground area fraction
|
|
64
|
+
ink.region # OUTSIDE / MARGIN / BACKGROUND / FOREGROUND
|
|
65
|
+
ink.weight # (H, W) float32 — safe-to-measure weighting
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Then measure:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
result = inkmap.measure("photo.jpg", ink)
|
|
72
|
+
|
|
73
|
+
result.foreground_mean # sRGB-linearised mean over safe pixels
|
|
74
|
+
result.background_mean
|
|
75
|
+
result.contrast
|
|
76
|
+
result.measurability.ok # False if this image can't support it
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`measure` accepts any frame registered to the one you located in, so geometry can be
|
|
80
|
+
established once on the sharpest image and reused across exposures, channels or
|
|
81
|
+
modalities.
|
|
82
|
+
|
|
83
|
+
## The three 0–1 channels
|
|
84
|
+
|
|
85
|
+
Conflating these is the fastest route to a wrong measurement:
|
|
86
|
+
|
|
87
|
+
| | what it is | use it for |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| `coverage` | **geometric** — fraction of the pixel's area under foreground | reporting, print QC, finding mixed pixels |
|
|
90
|
+
| `weight` | **blur-aware** — coverage eroded by how far the PSF actually reaches | **the only channel that should gate a measurement** |
|
|
91
|
+
| `confidence` | **epistemic** — how much to trust the label here | quality gating, artefact rejection |
|
|
92
|
+
|
|
93
|
+
If the underlying reality is binary, "probability this pixel is foreground" is
|
|
94
|
+
`confidence`. If it isn't — a pixel genuinely straddling an edge — the degree of
|
|
95
|
+
membership is `coverage`. They are different numbers and both are worth having.
|
|
96
|
+
|
|
97
|
+
Measured on synthetic scenes with exact ground truth, recovering a foreground-only
|
|
98
|
+
signal at 6 px per feature:
|
|
99
|
+
|
|
100
|
+
| pixel selection | no blur | σ=1 | σ=2 | σ=3 |
|
|
101
|
+
|---|---|---|---|---|
|
|
102
|
+
| `coverage > 0.5` (what a threshold gives you) | −3.3% | −12.2% | −23.3% | −32.4% |
|
|
103
|
+
| `coverage == 1.0` (geometrically pure) | −0.0% | −8.0% | −19.9% | −30.1% |
|
|
104
|
+
| **`weight`** (PSF-eroded) | **+0.0%** | **−0.2%** | **−0.6%** | **−1.5%** |
|
|
105
|
+
|
|
106
|
+
Geometric purity is *not* sufficient: blur drags background signal into pixels entirely
|
|
107
|
+
under foreground, and that contamination has a different footprint from partial
|
|
108
|
+
coverage — so no coverage-based weighting removes it. Only spatial erosion does. See
|
|
109
|
+
[docs/validation.md](docs/validation.md).
|
|
110
|
+
|
|
111
|
+
## Terminology
|
|
112
|
+
|
|
113
|
+
The classes are **foreground** and **background**, never dark/light — appearance
|
|
114
|
+
inverts (white-on-black print, reflectance-reversed symbols, inverted imaging), so an
|
|
115
|
+
appearance-based name would mean the mark in one image and the substrate in the next.
|
|
116
|
+
`Polarity` carries that mapping explicitly:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from inkmap import Polarity
|
|
120
|
+
inkmap.find(photo, polarity=Polarity.FOREGROUND_IS_LIGHT)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The continuous value is **coverage**, after ISO 12647-2 *area coverage*. Full
|
|
124
|
+
cross-field mapping to ISO/IEC 18004, 15415, DIBCO, printing, matting, remote sensing
|
|
125
|
+
and medical-imaging vocabulary: [docs/terminology.md](docs/terminology.md).
|
|
126
|
+
|
|
127
|
+
## How it works
|
|
128
|
+
|
|
129
|
+
1. **Locate** — a cascade of `zxing-cpp`, OpenCV's Aruco-based QR detector and OpenCV's
|
|
130
|
+
classic detector, plus linear barcodes. On 189 real photographs: zxing 74%, aruco
|
|
131
|
+
68%, OpenCV 43%, **union 80%**. For marks nothing can auto-detect, `inkmap.place()`
|
|
132
|
+
takes the corners from you.
|
|
133
|
+
2. **Recover the model** — from a bit-exact rectification where a decoder offers one,
|
|
134
|
+
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.
|
|
137
|
+
3. **Refine** — align the rendered model to the photograph with ECC. Four locator
|
|
138
|
+
corners are four observations; the model has thousands. Takes corner error from
|
|
139
|
+
~1 px to **0.02–0.03 px**.
|
|
140
|
+
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 σ.
|
|
143
|
+
|
|
144
|
+
Decoding is optional throughout. A mark whose material barely deposited will not
|
|
145
|
+
decode, and that is exactly the case that must still work.
|
|
146
|
+
|
|
147
|
+
## Extending it
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
inkmap.LOCATORS["mine"] = my_locator # (bgr, gray) -> list[Placement]
|
|
151
|
+
inkmap.find(photo, locators=["mine", "qr_zxing"])
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Tunable defaults live in `inkmap.config` — supersampling, ECC parameters, erosion
|
|
155
|
+
margin, saturation levels — as documented constants rather than inline magic numbers.
|
|
156
|
+
|
|
157
|
+
## Test data
|
|
158
|
+
|
|
159
|
+
189 real photographs of marks in the wild (CC0 / public domain / CC BY / CC BY-SA),
|
|
160
|
+
fetched on demand and cached:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
from inkmap import data
|
|
164
|
+
paths = data.fetch()
|
|
165
|
+
print(data.attribution_text())
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Synthetic scenes with **exact** ground-truth coverage — the only way to validate a 0–1
|
|
169
|
+
output, since no photograph comes with per-pixel truth:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
from inkmap import synth
|
|
173
|
+
|
|
174
|
+
scene = synth.render(my_model, perspective=0.05, blur=1.2, glare=0.3, jpeg_quality=80)
|
|
175
|
+
scene.image # the degraded photograph
|
|
176
|
+
scene.coverage # exact ground truth, by construction
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Looking at results
|
|
180
|
+
|
|
181
|
+
Coverage maps look plausible when subtly wrong, so check them:
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
from inkmap import viz
|
|
185
|
+
import cv2
|
|
186
|
+
|
|
187
|
+
# original | overlay | coverage | weight
|
|
188
|
+
cv2.imwrite("check.png", viz.panel(photo, ink))
|
|
189
|
+
cv2.imwrite("grid.png", viz.overlay(photo, ink, draw_grid=True))
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Install notes
|
|
193
|
+
|
|
194
|
+
Hard dependencies are `numpy`, `opencv-python-headless` and `zxing-cpp` — all
|
|
195
|
+
permissively licensed (BSD / Apache-2.0), all pure wheels, no system libraries.
|
|
196
|
+
|
|
197
|
+
If you already have `opencv-python` or `opencv-contrib-python`, install with
|
|
198
|
+
`--no-deps` and keep yours: all four `opencv-*` distributions provide the same `cv2`
|
|
199
|
+
and conflict when more than one is installed.
|
|
200
|
+
|
|
201
|
+
`segno` is needed only to build a QR for an arbitrary payload (`inkmap[synth]`);
|
|
202
|
+
Pillow only for `GlyphModel` (`inkmap[text]`).
|
|
203
|
+
|
|
204
|
+
Deliberately **not** used: `qrdet` / `qreader`, which are MIT on the tin but depend on
|
|
205
|
+
`ultralytics`, which is AGPL-3.0.
|
|
206
|
+
|
|
207
|
+
## License
|
|
208
|
+
|
|
209
|
+
MIT
|