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.
@@ -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 }}
@@ -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