When 353 "Speeding" Events Were Actually a Parked Bus

I built a speed camera for the street outside my house, mostly out of curiosity about how bad the speeding actually was. It uses a Raspberry Pi, a wide-angle camera, and a fairly standard computer-vision pipeline: background subtraction to find moving blobs, a centroid tracker to follow them frame to frame, and speed computed from how far a tracked object moved between frames, given a known real-world distance calibration.

A few weeks in, I pulled a report to share the aggregate numbers — average speed, percent over the limit, that kind of thing — and while I was in there I sorted by top speed. The list had entries in the 46-100mph range. On a residential street with a 15mph limit and a stop sign forty feet away. That’s not “someone was speeding,” that’s “a car achieved liftoff.”

The instinct to distrust the outlier

The tempting move here is to just delete the obviously-wrong numbers and move on. I didn’t want to do that, because if the pipeline could produce a fake 100mph reading, I had no way of knowing how many smaller, more plausible-looking fake readings were quietly sitting in the “legitimate” data too. A wrong number that looks wrong is a data quality problem. A wrong number that looks right is worse.

So instead of deleting them, I pulled the actual captured images for the worst offenders. Every single one was either a parked car with headlight glare flickering across it, or a vehicle that had already stopped, with the tracker’s blob-detection picking up noise and interpreting it as motion. Nothing on that list had actually moved anywhere near the speed the pipeline claimed.

Finding the actual pattern

The obvious question: what do all these bad readings have in common, besides being wrong?

I pulled the frame counts — how many frames the tracker had followed each object across before computing a speed — for every reading over 35mph, going back through the full history. The real fast passes (the kind you can see real motion blur on) were reliably long tracks: 60, 80, 100+ frames of consistent motion. The bogus ones clustered tightly around a single specific frame count, just one tick above whatever threshold I’d set for “this track is long enough to trust.” Not a wide scatter of short, noisy tracks — a suspiciously exact pile-up right at the edge of the existing filter.

That’s the tell that the fix isn’t “raise the threshold a bit and hope.” A threshold sitting exactly at the edge of a cliff invites the next glitch to land exactly one frame past it. The actual fix needed to be a second, independent signal — something that couldn’t be gamed by an object that happens to get tracked for just barely long enough.

Building a cross-check instead of a bigger gate

The signal I landed on: instead of only trusting the start-to-end distance calculation, recompute the object’s velocity as the median of its frame-to-frame movement, and separately compute the ratio between the single largest frame-to-frame jump and that median. Call it a jump ratio.

A vehicle that’s actually moving at a constant speed across the frame produces a jump ratio close to 1 — every frame’s worth of movement looks like every other frame’s. A tracker glitch — where a blob briefly jumps to an unrelated bit of noise a full body-length away and then jumps back — produces a jump ratio in the hundreds. One frame is wildly out of line with all the others, and the start-to-end math has no way to know that, because it never looks at what happened in between.

Running this against history: a synthetic test case I built on purpose (a mostly-still track with one artificially injected large jump) recomputed from a fake 59mph down to about 12mph, with a jump ratio of 198. Real, clean passes came back with jump ratios close to 1 every time, agreeing with the original number within a couple of mph.

Applying the frame-count-plus-jump-ratio combination retroactively against the full history flagged 353 detections that had made it past the naive frame-count-only filter. All 353 got archived — not deleted, moved to a separate bucket, since a labeled example of a known failure mode is worth more sitting in a folder than gone. It barely moved the honest aggregate stats (a percentage point, maybe two), which was itself a useful sanity check — if archiving all the fake readings had wrecked the real numbers, that would have meant the “real” numbers were resting on bad data too.

The twist: the fix can overcorrect

Weeks later, I got a report of a specific reading — a 40mph pass, flagged by the same short-track gate as a probable glitch, sitting in the “needs review” queue instead of the confirmed list. I pulled the recompute: jump ratio of 1.2, about as clean as a real pass gets. I pulled the image: a real car, real motion blur, no glare artifact anywhere in frame.

The gate that had correctly caught 353 fake readings had also, this time, caught a real one. It wasn’t wrong to be suspicious — a 24-frame track is short — but “short and fast” isn’t the same signal as “short, fast, and a huge jump ratio.” The frame-count heuristic alone is a blunt instrument; it flags based on a proxy (track length) rather than the thing you actually care about (does the motion look physically continuous). The independent cross-check that had caught the glitches was the same thing that vindicated the real reading — the gate that flags is not the same thing as the gate that decides, and conflating the two produces exactly this kind of false alarm.

What generalizes

None of this is specific to speed cameras. Any pipeline that has a “this looks suspicious, flag it for review” step is making a probabilistic guess based on a cheap proxy signal, and cheap proxy signals have edge cases where a real event looks exactly like a fake one from that one angle. The fix isn’t to keep tightening the proxy threshold — that just moves the cliff edge, it doesn’t remove it. The fix is a second, differently-shaped check that measures something the first one is structurally blind to.

And when you find a batch of confidently-wrong numbers, resist archiving them silently and moving on. Understanding exactly why they were wrong — down to the specific frame count they clustered at — is what tells you whether your fix will actually hold, or just relocate the same failure one threshold further out.


← all writing