Metadata-Version: 2.5
Name: inkmap
Version: 0.1.3
Summary: Per-pixel foreground coverage for photographed marks -- QR, barcodes, text, any shape you can model
Project-URL: Homepage, https://github.com/thorwhalen/inkmap
Author: Thor Whalen
License: MIT
License-File: LICENSE
Keywords: barcode,computer-vision,coverage,foreground,image-processing,ink,ocr,photometry,qrcode,segmentation,subpixel
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Scientific/Engineering :: Image Processing
Requires-Python: >=3.10
Requires-Dist: numpy>=1.23
Requires-Dist: opencv-python-headless>=4.7
Requires-Dist: zxing-cpp>=2.0
Provides-Extra: dev
Requires-Dist: pillow>=9.0; extra == 'dev'
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Requires-Dist: ruff>=0.1.0; extra == 'dev'
Requires-Dist: segno>=1.5; extra == 'dev'
Provides-Extra: docs
Requires-Dist: sphinx-rtd-theme>=1.0; extra == 'docs'
Requires-Dist: sphinx>=6.0; extra == 'docs'
Provides-Extra: synth
Requires-Dist: segno>=1.5; extra == 'synth'
Provides-Extra: text
Requires-Dist: pillow>=9.0; extra == 'text'
Description-Content-Type: text/markdown

# inkmap

Per-pixel foreground coverage for photographed marks.

Given a photo of a printed mark and a **model** of its shape, say — for every pixel —
how much of it is foreground (the mark) and how much is background (the substrate). Not
a boolean mask: a continuous 0–1 area fraction, accurate enough to measure on.

```bash
pip install inkmap
```

```python
import inkmap

coverage = inkmap.foreground("photo.jpg")   # (H, W) float32 in [0, 1]
```

## The idea

Decoding a QR code is a solved problem. Reading text is a solved problem. **Measuring**
the mark itself is not: if you need to know the reflectance, density, or colour of the
printed material — as opposed to what it says — you need to know exactly which pixels
are mark, which are substrate, and which are neither.

The shape is the input, not a special case. A model answers one question — *is this
point, in my own coordinate system, foreground?* — and everything else (locating,
sub-pixel refinement, rasterising, measuring) is written against that one question. So
QR symbols, barcodes, printed letters and arbitrary artwork all go through one pipeline.

```python
from inkmap import models

inkmap.find(photo)                                        # auto-detect QR / barcode
inkmap.find(photo, model=models.text_model("ABC"), quad=q)  # printed text
inkmap.find(photo, model=models.RasterModel(logo), quad=q)  # anything you can draw
inkmap.find(photo, model=models.PolygonModel(outlines), quad=q)   # exact vector shape
```

Writing your own is two members and no base class:

```python
class Disc:
    extent = (20.0, 20.0)      # model space is 20 x 20 units
    feature_size = 10.0        # smallest feature that must be resolved

    def occupancy(self, u, v):                    # vectorised
        return (u - 10) ** 2 + (v - 10) ** 2 < 81

inkmap.find(photo, model=Disc(), quad=corners)
```

## What you get back

```python
ink = inkmap.find("photo.jpg")        # an InkMap, or None

ink.placement.quad                    # (4, 2) corners — a quadrilateral, not a
                                      # rectangle; perspective is expected
ink.placement.transform               # (3, 3) model space -> image pixels
ink.placement.pixels_per_feature      # the resolution figure that governs everything

ink.coverage                          # (H, W) float32 — foreground area fraction
ink.region                            # OUTSIDE / MARGIN / BACKGROUND / FOREGROUND
ink.weight                            # (H, W) float32 — safe-to-measure weighting
```

Then measure:

```python
result = inkmap.measure("photo.jpg", ink)

result.foreground_mean                # sRGB-linearised mean over safe pixels
result.background_mean
result.contrast
result.measurability.ok               # False if this image can't support it
```

`measure` accepts any frame registered to the one you located in, so geometry can be
established once on the sharpest image and reused across exposures, channels or
modalities.

## The three 0–1 channels

Conflating these is the fastest route to a wrong measurement:

| | what it is | use it for |
|---|---|---|
| `coverage` | **geometric** — fraction of the pixel's area under foreground | reporting, print QC, finding mixed pixels |
| `weight` | **blur-aware** — coverage eroded by how far the PSF actually reaches | **the only channel that should gate a measurement** |
| `confidence` | **epistemic** — how much to trust the label here | quality gating, artefact rejection |

If the underlying reality is binary, "probability this pixel is foreground" is
`confidence`. If it isn't — a pixel genuinely straddling an edge — the degree of
membership is `coverage`. They are different numbers and both are worth having.

Measured on synthetic scenes with exact ground truth, recovering a foreground-only
signal at 6 px per feature:

| pixel selection | no blur | σ=1 | σ=2 | σ=3 |
|---|---|---|---|---|
| `coverage > 0.5` (what a threshold gives you) | −3.3% | −12.2% | −23.3% | −32.4% |
| `coverage == 1.0` (geometrically pure) | −0.0% | −8.0% | −19.9% | −30.1% |
| **`weight`** (PSF-eroded) | **+0.0%** | **−0.2%** | **−0.6%** | **−1.5%** |

Geometric purity is *not* sufficient: blur drags background signal into pixels entirely
under foreground, and that contamination has a different footprint from partial
coverage — so no coverage-based weighting removes it. Only spatial erosion does. See
[docs/validation.md](docs/validation.md).

## Terminology

The classes are **foreground** and **background**, never dark/light — appearance
inverts (white-on-black print, reflectance-reversed symbols, inverted imaging), so an
appearance-based name would mean the mark in one image and the substrate in the next.
`Polarity` carries that mapping explicitly:

```python
from inkmap import Polarity
inkmap.find(photo, polarity=Polarity.FOREGROUND_IS_LIGHT)
```

The continuous value is **coverage**, after ISO 12647-2 *area coverage*. Full
cross-field mapping to ISO/IEC 18004, 15415, DIBCO, printing, matting, remote sensing
and medical-imaging vocabulary: [docs/terminology.md](docs/terminology.md).

## How it works

```
photograph ──► locate ──► recover model ──► refine transform ──► rasterise ──► erode
```

1. **Locate** — a cascade of `zxing-cpp`, OpenCV's Aruco-based QR detector and OpenCV's
   classic detector, plus linear barcodes. On 189 real photographs: zxing 74%, aruco
   68%, OpenCV 43%, **union 80%**. For marks nothing can auto-detect, `inkmap.place()`
   takes the corners from you.
2. **Recover the model** — from a bit-exact rectification where a decoder offers one,
   otherwise sampled from the image. *Never* by re-encoding a decoded payload:
   error-correction level, mask pattern and code-set choices are not recoverable from
   the text, so re-encoding can silently produce a different pattern.
3. **Refine** — align the rendered model to the photograph with ECC. Four locator
   corners are four observations; the model has thousands. Takes corner error from
   ~1 px to **0.02–0.03 px** (4–53× depending on conditions).
4. **Rasterise** — supersample each pixel, back-project through the transform, ask the
   model `occupancy(u, v)`. Exact up to the supersampling rate, and independent of
   lighting. Coverage MAE against exact ground truth is **0.0000–0.0005**.
5. **Erode to a weight** — by ~2× the estimated blur σ, because geometric purity is not
   enough (see the table above).

Decoding is optional throughout. A mark whose material barely deposited will not
decode, and that is exactly the case that must still work.

Sub-pixel geometry turns out to hinge on **three half-pixel conventions agreeing** —
pixel-centre vs pixel-corner rasterisation, the template raster, and the fact that
different locators disagree about what a "corner" is. Getting them wrong cost 50× in
accuracy during development, and none of it is visible on inspection: the mask still
looks like a mask. The diagnostic, if you ever chase this: *if the error grows with the
mark's rotation angle following `R(d) − d`, the residual `d` is a convention offset, not
noise.*

## How well it works

Validated three ways, because no single way is sufficient — synthetic scenes (exact
ground truth by construction), real photographs (round-trip through an independent
decoder), and DIBCO (hand-labelled per-pixel ink truth).

| | detection | round-trip geometry | round-trip when *measurable* |
|---|---|---|---|
| **QR** (189 real photos) | 80% | 77% | **96%** |
| **Barcode** (56 real photos) | 95% | 48% | **89%** |
| **Text** (86 real words) | — (no locator; you supply the quad) | — | see below |

The conditional matters: **when inkmap judges an image measurable, an independent decoder
confirms the geometry 96% of the time.** Failures concentrate where the library already
says not to trust it.

On **real ink with hand-labelled ground truth** (DIBCO), eroding a *perfect* mask by one
pixel shifts the measured foreground mean by **17% of the foreground/background
contrast** — so the contamination argument is not an artefact of synthetic data.

**Text is the honest weak spot.** Placement works (IoU 0.76 with an image-derived shape)
but a glyph model with the wrong font does not (IoU 0.17). Measured directly: with the
correct font IoU is 0.997–1.000; with a wrong one, 0.46–0.80. For text, pass the actual
font or take the shape from the image.

📄 **[Methods and results](docs/methods-and-results.md)** ([PDF](docs/methods-and-results.pdf))
— the full write-up: approach, algorithms, every number, the datasets, the limits, and a
barcode defect the evaluation caught and forced a fix for (round-trip 5% → 48%).

## Extending it

```python
inkmap.LOCATORS["mine"] = my_locator          # (bgr, gray) -> list[Placement]
inkmap.find(photo, locators=["mine", "qr_zxing"])
```

Tunable defaults live in `inkmap.config` — supersampling, ECC parameters, erosion
margin, saturation levels — as documented constants rather than inline magic numbers.

## Skills for AI agents

If you drive inkmap from an agent, the shipped skill
[`inkmap-usage`](inkmap/data/skills/inkmap-usage/SKILL.md) is the short version of
everything that is easy to get wrong: choosing a model, the corner-order trap, which of
the three 0–1 channels to use for what, and gating on measurability. Install it with
[`skill`](https://github.com/thorwhalen/skill), or just read it.

## Test data

384 real photographs of marks in the wild — 189 QR, 56 barcodes, 139 printed text —
(CC0 / public domain / CC BY / CC BY-SA), fetched on demand and cached:

```python
from inkmap import corpus

corpus.kinds()                        # {'qr': 189, 'barcode': 56, 'text': 139}
paths = corpus.fetch(kind="barcode")
print(corpus.attribution_text())      # attribution is required for CC BY / CC BY-SA
```

Synthetic scenes with **exact** ground-truth coverage — the only way to validate a 0–1
output, since no photograph comes with per-pixel truth:

```python
from inkmap import synth

scene = synth.render(my_model, perspective=0.05, blur=1.2, glare=0.3, jpeg_quality=80)
scene.image      # the degraded photograph
scene.coverage   # exact ground truth, by construction
```

## Looking at results

Coverage maps look plausible when subtly wrong, so check them:

```python
from inkmap import viz
import cv2

# original | overlay | coverage | weight
cv2.imwrite("check.png", viz.panel(photo, ink))
cv2.imwrite("grid.png", viz.overlay(photo, ink, draw_grid=True))
```

## Install notes

Hard dependencies are `numpy`, `opencv-python-headless` and `zxing-cpp` — all
permissively licensed (BSD / Apache-2.0), all pure wheels, no system libraries.

If you already have `opencv-python` or `opencv-contrib-python`, install with
`--no-deps` and keep yours: all four `opencv-*` distributions provide the same `cv2`
and conflict when more than one is installed.

`segno` is needed only to build a QR for an arbitrary payload (`inkmap[synth]`);
Pillow only for `GlyphModel` (`inkmap[text]`).

Deliberately **not** used: `qrdet` / `qreader`, which are MIT on the tin but depend on
`ultralytics`, which is AGPL-3.0.

## License

MIT
