/writing/i-built-a-speed-camera-for-my-street

I Built a Speed Camera for My Street

The street outside my house has a 15mph limit. It also has, as far as I could tell from standing at the window, a steady supply of people treating that as advisory.

That’s the whole origin story. Not a grievance — a genuinely open question. Was the street actually bad, or had I just noticed the loud ones and built a story out of it? Standing at a window is a spectacularly biased sampling method. You remember the one that made you flinch and forget the two hundred that didn’t.

So I built something to count.

A speed camera is four problems in a trench coat

The concept is almost insultingly simple. Point a camera at the road. Notice when something moves. Follow it. Know how far it went and how long it took. Divide.

Every one of those four steps is where the difficulty lives, and none of them are hard in the way you’d guess going in.

The hardware is nearly trivial: a Raspberry Pi and the official wide-angle camera module, running continuously. No GPU, almost no power draw, and the interesting part is all software.

“Nearly” is carrying weight in that sentence. I started on a Pi 3, and it could not keep up. I read that as a software problem for longer than I should have and spent real effort optimising around it, before accepting that continuous background subtraction over a full frame is simply more than that board has. A Pi 5 with the same camera fixed it outright.

I’ll come back to why that mattered, because an underpowered board doesn’t just make this kind of pipeline slow. It makes it wrong, in a specific and non-obvious direction.

Problem one: pixels are not meters

This is the one that surprises people, and it’s the one that makes a naive build produce confidently wrong numbers forever.

The camera measures displacement in pixels per frame. You want meters per second. The conversion factor between them is not a constant — it depends on how far the object is from the camera. A car on the far side of the road moves fewer pixels for the same real distance than a car on the near side. Same speed, different number.

So you calibrate. I painted lines across the street at a known spacing, measured the gap between them in pixels, and derived a scale. Now pixels have units.

That gives you a scale factor. It does not give you a correct speed, and the gap between those two things is worth being precise about — because the second exercise is the one that actually earns your trust in the numbers.

So I drove the street myself, repeatedly, at a series of exactly-known speeds, and checked what the pipeline reported back.

That is a fundamentally different test, and it’s the one I’d tell anyone to do first. The painted lines validate exactly one number: the pixels-to-metres conversion. Driving at a known speed validates the entire pipeline end to end — the scale, the mounting angle, the tracker’s frame-to-frame matching, and the timing — all at once, against ground truth I controlled completely. The scale factor can be perfect while the system is still wrong, because the error lives in one of the other three and no amount of staring at painted lines will show you that.

It’s the same lesson this project kept teaching me in different costumes: one check validates a component, and only a differently-shaped second check validates the system.

And then geometry takes a swing at you.

Why pixel displacement is not speed A camera views a road at an angle. A car crossing perpendicular to the camera axis maps its full travel onto the image plane. A car travelling at an angle projects a shorter distance onto the image plane, so the measured speed reads low by the cosine of that angle. Objects further from the camera also cover fewer pixels for the same real distance. The same real distance is a different number of pixels near lane: many pixels far lane: few pixels camera travel at an angle only this component is measured Calibrate against a known real-world length, and measure across the frame, not toward it.
Two independent ways to under-report: distance from the camera, and angle of travel.

Speed measured across the image is only the component of motion perpendicular to the camera’s axis. Anything angled away gets multiplied by the cosine of that angle, and cosine error is sneaky because it always errs the same direction — it makes cars look slower than they are. A camera aimed obliquely down a street systematically under-reports, quietly, forever, and nothing about the output looks wrong. The mounting angle is a measurement decision disguised as a mounting decision.

Problem two: “moving” is a much harder word than it looks

The standard approach is background subtraction: build a statistical model of what each pixel normally looks like, then flag pixels that currently disagree with it. Group the disagreeing pixels into blobs. Blobs are things that moved.

The road is not a laboratory. Over one day, that model has to survive:

  • The sun, which sweeps shadows across the entire frame over hours.
  • Trees, which move constantly and go nowhere.
  • Clouds, which change global exposure in seconds.
  • Rain, which adds moving texture to every pixel simultaneously.
  • Night, where the only bright things are headlights — and headlights aren’t attached to the car in any geometrically convenient way. A headlight’s glare moves across parked cars and road surface independently of the vehicle producing it.

Background models handle this by forgetting: they adapt, absorbing anything that changes slowly enough into “normal.” Which is exactly right for the sun, and exactly wrong for a vehicle that stops — sit still long enough and you are dissolved into the background, then re-detected as fresh motion when you pull away.

The thing that separates signal from noise here is not brightness or size. It’s persistence and coherence. Real vehicles move consistently in one direction over many frames. Glare flickers. Shadows sweep and reverse. Everything expensive I learned on this project came back to that distinction.

Problem three: following one thing and knowing it’s still that thing

Detection gives you blobs per frame. To get speed you need identity across frames — that blob is this blob, one frame later. A centroid tracker does the simplest possible version: match each blob to the nearest blob in the previous frame.

It works well and fails in specific, entertaining ways:

  • Two vehicles overlap and become one blob, so two tracks merge into one that appears to teleport sideways.
  • One vehicle splits — a dark car against dark asphalt can fragment into two blobs — and a single car becomes two tracks at half the mass.
  • A track jumps to an unrelated blob a body-length away, then jumps back.

That last one is the important one, and it’s the seed of the best bug I found. A blob that briefly hops onto a patch of noise and returns produces a start-to-end displacement that is enormous, over a track that is short. Divide one by the other and you get a parked car doing 100mph.

I’ve written that story up separately — it involved a stationary bus, 353 bogus detections, and the discovery that the fake readings all clustered at exactly one frame count. The short version of the fix: don’t tighten the threshold, add a second, differently-shaped check. Compare the largest single frame-to-frame jump against the median one. Real motion is boringly consistent and scores near 1. A tracker glitch scores in the hundreds.

Problem four: time, which you probably assumed was free

If you compute speed as distance divided by frame count, you have quietly assumed a perfectly constant frame rate.

A Pi doing continuous vision work outdoors does not have a perfectly constant frame rate. It has thermal throttling, other processes, and variable per-frame work depending on how much of the frame is moving. Frames get dropped precisely when the scene is busiest — which is to say, precisely when a vehicle is in it.

Timestamp your frames and compute against elapsed time rather than frame count. Otherwise you have an error source that correlates with exactly the events you care about, which is the worst kind.

This is the real reason the Pi 3 had to go, and why I framed it earlier as a correctness problem rather than a performance one. An underpowered board doesn’t drop frames uniformly. It drops them when there’s the most work to do — when a large object is moving through a large part of the frame. Which is precisely, exclusively, when a vehicle is being measured.

So the frame loss isn’t noise. It’s a bias, concentrated entirely on the measurement events, and pointed in a consistent direction. “The old board was a bit slow” and “the old board systematically corrupted the thing I was trying to measure” are the same sentence about the same hardware, and only one of them makes you replace it.

An honest footnote, because the argument above is theoretical and I later went looking for it in the data. I couldn’t find it. Searching for the swap’s fingerprint in the detection record turned up nothing — the days around the likely changeover look unremarkable next to their neighbours, and the only real evidence the swap happened at all is a burst of reboots in the system journal. So: the mechanism is sound and the board genuinely couldn’t keep up, but I can’t show you the moment it stopped mattering. I’d rather say that than imply a before-and-after I never measured.

The stage I didn’t plan for

Everything above happens on the Pi, in the moment, and it is not enough. Background subtraction plus a tracker will confidently hand you a “vehicle” that is a shadow, a bird, a rain streak, or a corrupt frame. Those aren’t tracking glitches with a clever signature — they’re just not cars, and no amount of reasoning about motion consistency fixes a misidentification.

So there’s a second pass, and it runs somewhere else entirely: a scheduled batch job on a home Kubernetes node with a consumer GPU, firing every thirty minutes. It pulls the captures off the Pi, runs an object-detection model over them, and writes a verdict back — was there actually a vehicle in this frame?

Two things about that arrangement I’d repeat.

Putting it on different hardware, off the critical path, was the right call. The Pi’s job is to never miss anything and never fall behind; it is the worst possible place to also run model inference. Detection is real-time and cheap, classification is batch and expensive, and they have no business sharing a board. The thirty-minute lag costs nothing — nobody needs to know about a speeding car within thirty minutes — and the GPU is a machine I already had doing other work.

It is by a wide margin the biggest filter in the system, and I did not expect that. The motion-consistency gates I spent the most design effort on — the frame-count threshold and the jump-ratio check from the parked-bus saga — reject about 3% of detections between them. The “is that actually a vehicle” pass rejects roughly 19%. Six and a half times as much.

There’s a lesson in that ratio. I built the clever gates in response to a dramatic, memorable failure: the bus doing 100mph. The boring classifier catches the quiet, unglamorous majority — the frames where nothing was there at all. The bug you remember is rarely the bug that dominates your data, and effort follows memorability rather than volume unless you go and count.

Deciding what not to keep

Worth saying plainly, because it shaped the build: a camera pointed at a public street sees other people’s cars, and there is a version of this project that is basically surveillance of your neighbours.

The design choice that resolves it is about retention, not capture. The pipeline needs pixels to find motion; it does not need to keep them. What has durable value is the derived record — a timestamp, a speed, a direction, a track length. Everything upstream of that is working memory and can age out fast. No plates, no faces, no long-lived footage of people going about their day.

There’s a second-order benefit I didn’t anticipate: it made the project shareable. Numbers about a street are interesting to everyone on it. Footage of a street is interesting to nobody on it, in a bad way. Choosing early to keep only the derived data is what let this be something I could talk about at all.

What I’d tell someone building one

Calibrate before you optimise. A beautifully tuned detector feeding a wrong scale factor produces precise wrong answers, and precision reads as correctness.

Assume every early number is wrong and go look at the pictures. I found the worst bug in this project by sorting by top speed and pulling the actual frames. Aggregate statistics are where bad data goes to hide — a fake 100mph is obvious, and a fake 24mph sitting in your average is not.

Build the second check early. Any single heuristic for “is this real” will have a cliff edge, and glitches pile up exactly at it. Two differently-shaped checks are worth far more than one carefully-tuned one.

Expect the environment to be the adversary. Not the cars — the sun, the trees, the rain, and the way a parked vehicle slowly becomes part of the road.

And then, about a hundred days in, I went to write up what the street was actually doing — and discovered I couldn’t trust a single number the thing had produced. Not because the pipeline was wrong. Because I had been changing it the entire time it was recording, and nothing in the data said so.

That’s the next post.