ballot-interpreter · vertical streak detection · commit afec8a13
Streak detection reads every pixel of a ballot scan either way. Reordering those reads to match how the image sits in memory — and skipping the detailed pass unless a column earns it — made the slowest stage of interpretation about five times faster, with bit-identical results.
A speck of debris on a scanner's glass drags a dark vertical line down every page that passes over it. On a ballot, that line can cut through bubbles and read as phantom marks, so the interpreter checks every scanned page for them and rejects affected sheets. The rule of thumb: a streak is a column of pixels that is dark almost top-to-bottom, with no white gap large enough to suggest it's a printed feature.
A scanned page on the glass
A toy ballot page: timing marks around the border, contest text, bubbles — and a two-pixel-wide streak from debris on the glass, running down one column with only tiny gaps.
The detector's gate is simple: count the black pixels in every column. Any column that is ≥ 25% black is a candidate worth a closer look. On almost every real page, no column qualifies.
The real detector also ignores 20 border columns on each side (where the timing marks live) and applies a stricter 75% two-column score before calling anything a streak.
A grayscale scan isn't stored as a grid — it's a single flat array of bytes, one row after another.
Pixel (x, y) lives at raw[y * width + x]. The CPU never fetches one byte at a
time; it pulls memory in 64-byte cache lines. Read a byte and its 63 neighbors arrive for free.
That makes traversal order the whole game. Walking a row touches consecutive bytes: one line fetch serves 64 pixels. Walking a column jumps a full row's width between reads — on a real scan that stride is ~1,700 bytes, so every single read lands on a different cache line.
The image (24 × 10 pixels)
The same 240 bytes, as they actually sit in memory (ticks every 8-byte cache line)
Toy scale: 24-byte rows, 8-byte cache lines. Real scale: ~1,700-byte rows, 64-byte lines — so a column walk fetches a new line on every read, and one column's 2,200 lines (~140 KB) overflow L1 cache before the next column comes back for them.
The old detector walked the page column by column — the worst possible order — binarizing each full column just to count its black pixels: about 3.7 million strided reads per page. That made streak detection the single most expensive stage of interpretation, paid on every sheet, streak or not.
The fix keeps the arithmetic and flips the loop. One row-major pass accumulates all ~1,700 per-column counts simultaneously — like keeping a running tally per column while reading the page the way memory wants to be read. Only columns whose count clears the 25% gate — usually none — get the detailed two-column analysis, which is completely unchanged:
before — one strided walk per column
// for every column: walk the whole column (stride = width,
// a new cache line on every read)
for x in x_range {
fill_column(&mut next_col, x + 1); // `height` strided reads
let black = next_col.iter().filter(|&&b| b).count();
if score(black) >= MIN_ONE_COLUMN_STREAK_SCORE {
// detailed two-column analysis …
}
mem::swap(&mut cur_col, &mut next_col);
}
after — one row-major pass, then only the candidates
// count black pixels in every column at once, in memory order
let mut counts = vec![0u32; width];
for row in raw.chunks_exact(width) {
for (count, &p) in counts.iter_mut().zip(row) {
*count += u32::from(p <= thresh);
}
}
for x in x_range {
if score(counts[x]) >= MIN_ONE_COLUMN_STREAK_SCORE {
fill_column(&mut cur_col, x); // rare: candidates only
fill_column(&mut next_col, x + 1);
// detailed two-column analysis (unchanged) …
}
}
Simplified from image_utils.rs — the real diff is
+18/−11 lines in detect_vertical_streaks.
Both detectors below run on the same toy page, and both panels advance at their measured real-world rates (2.8 ms vs 0.6 ms per page), slowed down about 2,500× so you can watch. The bars under each page are the per-column black counts — the same integers, arriving in a different order.
With the streak toggled off — the common case for real ballots — the new pass finds zero candidates and the detailed column analysis never runs at all. The old code paid the full strided walk either way.
This is a pure reordering. Addition commutes, so the row-major tally produces the same integers the column walk produced — not approximately, exactly. Same counts means the same columns clear the 25% gate, and the detailed two-column analysis that actually declares a streak wasn't touched: it still reads the candidate columns directly and applies the same 75% score and white-gap rules.
The commit backs that argument with data: all 2,472 real scans in the validation corpus produce identical detected streaks before and after — including the three pages with genuine scanner streaks — and the change survives the interpreter's full test suite. The result is a 29-line diff that removes the most expensive stage of ballot interpretation from the common path entirely: ~2.8 ms → ~0.6 ms per page.
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.