ballot-interpreter · qr detection · commit 210663ff

Every other line

The interpreter finds each ballot's QR code by sweeping the corner of the page with one-dimensional scan lines and watching for the finder patterns' 1:1:3:1:1 signature. The patterns are ~25 pixels wide — so scanning every other line still crosses each one many times, and half the scan lines turn out to be enough for every code in the corpus.

1.45 → 0.85 ms per detection area — the zedbar scan measured 41% faster
+7 −1 the whole diff: one scan_density(2, 2) config call
2,550 images 2,465 QR detections re-run: identical successes, failures, payloads, and orientations

01The code on every ballot

Before the interpreter can score a single bubble, it has to know which ballot it's looking at. That metadata — election, ballot style, sheet — rides in a QR code (~118 px at scan resolution) printed near a corner of every page. The interpreter crops the two corner regions where the code can appear and hands them to a decoder: zedbar first, with rqrr as a fallback, stopping at the first hit.

How do you find a two-dimensional code without looking at every pixel in two dimensions? You don't search for the square — you search for its signature. Each of the three big corner squares (the finder patterns) is built so that any horizontal or vertical line through its middle crosses dark and light runs in the ratio 1:1:3:1:1 — dark, light, a triple-wide dark core, light, dark. That ratio survives scaling, and it reads the same left-to-right, right-to-left, up, or down. So the scanner sweeps the region with plain 1-D scan lines and flags every place that ratio appears; where flagged lines cluster, there's a finder pattern.

drag the slider (or drag on the code) to move the scan line

The QR code at real scan resolution (~3.5 px per module)

What this scan line sees: dark/light runs, in pixels

run window matching 1:1:3:1:1 — a finder signature the scan line

The finder patterns and timing/alignment structure here are real QR geometry at the real ballot scale; the data modules are randomized, since detection never reads them — only the decode stage does, after the code has been located. Note the signature only fires for lines through a finder's middle band, and fires twice when one line crosses two finders.

02The change: skip every other line

zedbar scanned every row and every column of each detection area. But the signature isn't fragile: a finder pattern's middle band is about 10 px tall at scan resolution, so consecutive scan lines through it are nearly redundant — at full density each finder gets crossed by ~10 signature rows and ~10 signature columns. Clustering needs only a handful. The entire change is one config call:

let config = DecoderConfig::new()
    .enable(config::QrCode)
    .scan_density(2, 2);   // every other row, every other column

Step through densities below and watch how many scan lines still produce a finder signature. Density 2 keeps a comfortable margin; push further and the margin evaporates — which is exactly why the commit stops at 2:

scan density (1 = every line):

Scan lines over the detection area

Signature crossings per finder pattern

scan line line with a signature on a finder pattern line whose only signature is a chance match in the data

Counts are computed live by running the ratio detector over every drawn line. Note the amber lines: random data occasionally fakes the ratio on a single line, which is exactly why the scanner clusters lines instead of trusting one — real finders produce stacks of agreeing signatures. The work is proportional to the number of lines: density 2 does half the scanning while keeping ~10 crossings per finder.

03The bill

Halving the lines doesn't halve the total time: locating clusters, decoding the located symbol, and per-image setup don't shrink. The line-scanning stage dominates, so the measured result is 41% — both bars advance at their measured rates:

per detection area, measured on Intel N97, slowed ~1,800×
every line
0.00 ms
every other line
0.00 ms

A page has up to two detection areas, and since the crops became lazy (#9096) the common case scans only one.

04Measured identical, not provably identical

Earlier changes in this series were provable no-ops — reordered integer arithmetic. This one is not: detection is a heuristic stage, and in principle a scan grid could be coarse enough to miss a marginal code. The safety argument is empirical instead, and it was made against the whole corpus: 2,550 images (2,472 real field scans plus every hmpb and test fixture), 2,465 QR detections — identical successes, identical failures, identical decoded payloads, identical orientations. Not "still decodes": identical outcomes in both directions, including the pages that fail to decode.

The one observable difference: the reported QR bounds can shift by up to 2 px, because corner positions are estimated from the coarser sampling grid. Those bounds feed only debug images — the decoded payload comes from sampling the code's modules after it's been located, which is why the payloads can't drift even when the bounds do.

05Check your understanding

Six questions — half on the ideas, half on the code the PR actually changes. Pick an answer to see the explanation; your first try is what's scored.