Metadata-Version: 2.5
Name: qrink
Version: 0.1.1
Summary: Per-pixel ink coverage for photographed QR codes
Project-URL: Homepage, https://github.com/thorwhalen/qrink
Author: Thor Whalen
License: MIT
License-File: LICENSE
Keywords: barcode,computer-vision,coverage,image-processing,ink,photometry,qr,qrcode,segmentation
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: 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'
Description-Content-Type: text/markdown

# qrink

Per-pixel ink coverage for photographed QR codes.

Find the QR symbol in a photo, then say — for every pixel — how much of it is printed
ink and how much is bare substrate. Not a boolean mask: a continuous 0–1 area fraction,
accurate enough to make physical measurements on the ink.

```bash
pip install qrink
```

```python
import qrink

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

## What it's for

Decoding a QR code is a solved problem with a dozen good libraries. **Measuring** one
is not. If you need to know the reflectance, the fluorescence, or the colour of the ink
itself — as opposed to what the code says — you need to know exactly which pixels are
ink, which are substrate, and which are neither. That is what this does.

The bare-substrate pixels *inside* the symbol matter as much as the ink: they sample
the illumination across the marker, so the scene calibrates itself.

## The three-minute version

```python
import qrink

ink = qrink.find("photo.jpg")  # an InkMap, or None

# A quadrilateral, not a rectangle — perspective is expected.
ink.symbol.quad  # (4, 2) image-space corners
ink.symbol.modules  # (N, N) module matrix, True = dark module
ink.symbol.pixels_per_module  # the resolution figure that governs everything

ink.coverage  # (H, W) float32 — ink area fraction per pixel
ink.region  # (H, W) OUTSIDE / QUIET_ZONE / SUBSTRATE / INK
ink.weight  # (H, W) float32 — safe-to-measure weighting
```

Then measure:

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

result.ink_mean  # sRGB-linearised mean over safe ink pixels
result.substrate_mean  # ... and over safe substrate pixels
result.contrast  # the calibrated difference
result.measurability.ok  # False if this image cannot support the measurement
```

`measurability` is not decoration. Below roughly 6 pixels per module per unit of blur,
the point spread function is wide relative to a module, no erosion leaves an
uncontaminated pixel, and a mean over "ink" pixels is biased by tens of percent. The
library says so rather than returning a confidently wrong number.

## Why the 0–1 value, and which one

There are three different 0–1 quantities here and conflating them is the fastest way to
a wrong measurement:

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

A pixel can be exactly half ink (`coverage` 0.5) and we can be completely certain of
that (`confidence` 1.0). A pixel deep inside a module can be pure ink geometrically
(`coverage` 1.0) and still be useless to measure, because the blur has dragged
substrate signal into it (`weight` < 1).

Measured on synthetic scenes with exact ground truth — recovering an ink-only signal at
6 px/module, from a frame blurred by the camera point spread function:

| pixel selection | no blur | PSF σ=1 | PSF σ=2 | PSF σ=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: the blur drags substrate signal into pixels that
are entirely under ink, 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 two classes are **ink** and **substrate**, never dark/light and never
foreground/background — because under fluorescence excitation the ink is typically the
*brighter* class, so a `dark_mask` would mean ink in one frame and substrate in the
next. The continuous value is **coverage**, after ISO 12647‑2 *area coverage*.

Polarity is explicit:

```python
from qrink import Polarity

qrink.find(photo, polarity=Polarity.INK_IS_DARK)  # reflectance frame
qrink.find(photo, polarity=Polarity.INK_IS_LIGHT)  # excitation frame
```

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

1. **Locate** — a cascade of `zxing-cpp`, OpenCV's Aruco-based QR detector, and
   OpenCV's classic detector. On 189 real photographs from Wikimedia Commons: zxing 74%,
   aruco 68%, OpenCV 43%, **union 80%**. Using one detector forfeits ~37 points of recall.
2. **Recover the module matrix** — from OpenCV's bit-exact `straight_qrcode` where
   available, otherwise sampled from the image at the fitted grid. *Never* by re-encoding
   the decoded payload: error-correction level and mask pattern are not recoverable from
   the text, so re-encoding can silently produce a different matrix.
3. **Refine the geometry** — align the rendered module pattern to the photograph with
   ECC. Four detector corners are four observations; the pattern has N². This takes
   corner error from ~1.0–2.8 px to **0.02–0.03 px**.
4. **Rasterise coverage** — supersample each pixel, back-project through the homography,
   average. Exact up to quantisation, and independent of lighting.
5. **Erode to a measurement weight** — by 2–3× the estimated blur σ.

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

## Extending it

Every strategy is a plain function, and the registries are public:

```python
def my_locator(bgr, gray):
    return [qrink.QRSymbol(...)]


qrink.LOCATORS["mine"] = my_locator
qrink.find(photo, locators=["mine", "zxing"])
```

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

## Test data

189 real photographs of QR codes in the wild (CC0 / public domain / CC BY / CC BY-SA),
fetched on demand and cached:

```python
from qrink import data

paths = data.fetch()
print(data.attribution_text())
```

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

```python
from qrink import synth

scene = synth.render(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 they are subtly wrong, so check them:

```python
from qrink import viz
import cv2

# Four panes: 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 for `qrink.synth` (`pip install 'qrink[synth]'`).

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

## License

MIT
