ballot-interpreter · bubble scoring · commit 23b6abe9

Sixty-four pixels at a time

To read a voter's mark, the interpreter first has to find the printed bubble — by trying 196 candidate positions and scoring every one. Packing each row of that comparison into a single 64-bit word turns a thousand byte reads per candidate into a handful of shifts and popcounts, making bubble scoring six times faster with bit-identical results.

4.7 → 0.8 ms scoring 100 bubbles (Intel N97); roughly 1 ms per sheet of end-to-end interpretation
720 → 20 ops per candidate position, for this page's 36×20 toy bubble: byte compares → row popcounts
3,040,128 grid positions re-scored across 2,436 field-scanned pages: zero differences

01Find the bubble, then read it

Paper stretches, feeds skew, printers drift — so a bubble is never exactly where the ballot layout says it should be. Before judging whether a bubble is filled, the interpreter slides the bubble's printed template over the scan, trying every offset within ±7 pixels (14 × 14 = 196 placements), and keeps the one that matches best.

The match score counts pixels satisfying source_is_dark || template_is_white. Since a template pixel is either white background (always matches) or outline ink, the only way a pixel can miss is if the template has outline there and the scan doesn't have ink. Maximizing the score means locking onto the printed oval:

196 candidate placements · x-major, ties keep the first

The scan, with the template at the current offset

Match score at each of the 196 offsets

template outline at current offset higher match score best so far
press ▶ Search to sweep the offsets, or hover the heatmap after a sweep

The toy bubble here is a 36×20 template searched at production's real ±7-pixel distance. The scanned oval is printed 2 px right and 3 px up of where the layout expects it, with ink dropouts and a partial voter mark — the search finds it anyway.

02A byte per pixel is 64× too wide

The old search evaluated that predicate pixel by pixel: for each of 196 placements, walk all 720 template positions, load a byte of scan and a byte of template, compare, branch, count. But the predicate only has two inputs — is the scan dark here and is the template white here — each a single bit. A CPU register holds 64 of those bits and the ALU operates on all of them at once.

So pack once, up front: every row of the search window becomes one u64 of dark-pixel bits, and every row of the template becomes one u64 of white-pixel bits — 53 words in total, built in one pass over the pixels:

fn pack_row(row: &[u8], is_set: impl Fn(u8) -> bool) -> u64 {
    row.iter()
        .enumerate()
        .fold(0u64, |bits, (c, &p)| bits | (u64::from(is_set(p)) << c))
}
// window rows:   is_set = |p| p <= threshold   (bit = pixel is dark)
// template rows: is_set = |p| p == 255         (bit = pixel is white)

03One row of the comparison, in one instruction's worth of bits

Scoring a placement is now a per-row dance of exactly the original predicate: shift the window row right by dx to align it with the template, OR in the template's white bits, mask to the template width, and popcount the ones. Every set bit is a matching pixel; every hole is outline that found no ink. Drag the offset and watch the bits align:

dy pinned to the best vertical offset

Which row we're scoring

The three registers (bit 0 drawn leftmost, matching image columns)

set bit hole in the result — outline pixel with no ink masked out (beyond the 36-bit template width)

The real loop is four operations per row — ((window >> dx) | template) & mask, then count_ones() — summed over 20 rows per placement. On today's baseline x86-64 build, count_ones() compiles to a ~17-instruction bit dance that still counts all 64 bits at once; a later commit in this series raises the build target to x86-64-v3, where it becomes a single popcnt.

fn match_count(&self, dx: usize, dy: usize) -> u32 {
    self.window_rows[dy..dy + self.template_rows.len()]
        .iter()
        .zip(&self.template_rows)
        .map(|(&window, &template)| {
            (((window >> dx) | template) & self.template_width_mask).count_ones()
        })
        .sum()
}

04The bill

Per bubble, the byte search did 196 placements × 720 loads-compares-branches ≈ 141,000 byte operations. The packed search pays a one-time 53-row packing pass, then 196 × 20 word operations ≈ 3,900 — about 36× less work, which cashes out as a measured 6× (the packing pass, the fill score, and loop overhead keep it honest). Both bars below advance at their measured rates:

scoring 100 bubbles, measured on Intel N97, slowed ~700×
byte per pixel
0.00 ms
bit-packed rows
0.00 ms

A typical ballot page has a few dozen bubbles per side; end-to-end interpretation gains roughly 1 ms per sheet.

05Why bit-identical, not just close

The popcount counts exactly the predicate the byte loop counted — dark || white, one bit per pixel, no approximation anywhere. The packed search also iterates placements in the same x-major order with the same strictly-greater update, so ties break identically; the fill score is still computed by the original byte-wise code, once, at the winning placement; and bubbles whose search window is clipped by the image edge (or too wide for a u64) skip the packed path entirely and use the byte loop as before.

The commit pins all of that down twice: a property test asserts the packed and byte searches agree on arbitrary generated inputs, and every interior timing-mark grid cell of the TRR corpus — 3,040,128 positions across 2,436 field-scanned pages — was re-scored with zero differences in bounds, match score, or fill score. This page's two figures run both algorithms live and verify they agree on every one of the 196 offsets before drawing.

06Check your understanding

Seven 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.