← All Writing
July 14, 202615 min read

How Many Parking Spots Are Open on My Block? I Built a Camera to Find Out.

I have a parking space. My friends don’t — and the question they text before driving over is always some version of “is there parking on your street?” So I pointed a webcam at the seven bays outside my window and tried to answer it properly, once a minute.

YieldA once-a-minute occupancy read on seven street parking bays — a single number between 0 and 7 — plus a per-bay history table that answers how long cars actually stay
DifficultyIntermediate (an off-the-shelf object detector is the easy 60%; the other 40% is small, unglamorous Python that decides what to believe when the detector says nothing)
Total Cook TimeA weekend to get bay outlines and a first occupancy number, then several evenings of tuning spread over the following weeks

Ingredients

The Problem: The Text Before Everyone Drives Over

I have a space in the garage, so this was never about finding my own spot — which is what makes it a better problem, because I had no personal stake in fudging the answer. It’s about the people coming to see me: before anyone drives over, some version of the same message arrives — is there parking on your street? I already had a camera aimed at that exact stretch of curb, so the question became narrow and testable: can I turn the view into an honest count of the seven bays I can actually see?

Where the AI Actually Runs

Worth clearing up early, because “I used AI to detect cars” now makes people assume a cloud service and a bill. YOLOv8 is an open-source model that runs entirely on my own machine. There is no API call, no per-detection cost, no account, and no frame ever leaves the computer sitting by the window.

Getting it is easier than the acronym suggests. YOLOv8 comes from a company called Ultralytics and installs like any other Python library — pip install ultralytics — and from there it is two lines: from ultralytics import YOLO, then YOLO("yolov8s.pt"). You never go hunting for the model file; the first time you name one, it downloads automatically and caches to disk. The letter on the end is the size dial: yolov8n is nano, the fastest and least accurate, and yolov8s is small, slower but sharper. I run both — nano where I need to keep up with the video stream, small where I need to be right.

The trade is that you supply the compute yourself, and that is far less than people expect. The models are two files on disk — 6.5 MB for the fast one and 22.6 MB for the accurate one — and the memory footprint is essentially that model plus one video frame at a time. The sustained cost is CPU, not RAM: the parking pass is a single inference per minute, and the heavier continuous job is the fast model keeping pace with a roughly 8-frames-per-second stream. There is no GPU here. An ordinary always-on desktop handles all of it alongside everything else it is doing, and the real requirement turns out to be more boring than horsepower: the machine has to stay awake.

Seven Rectangles

I hand-traced seven bay outlines onto a still frame and saved them to a file, and that one afternoon of drawing is what turns a video feed into a countable thing. Three bays are on the near curb — N1, N2, N3 — and four are on the far curb, F1 through F4. Everything else in this project is rules layered on top of those seven shapes.

FAR CURBF1tree-splitF2F3F4tree-splitroadwayN1N2N3NEAR CURBcamera
The seven tracked bays. The two in yellow, F1 and F4, sit behind the same street tree — and they are where nearly all of the difficulty lives.

Drawing them by hand sounds primitive, and it is, but a camera in a window sees the street at an oblique angle that no automatic lane-finder handles well. Ten minutes of dragging corners on a screenshot beat every clever idea I had about deriving the bays automatically.

🔧 Developer section: Install and bay calibration

With the bays defined, here is the baseline that covers the five well-behaved ones on a normal afternoon:

The default rules (daylight, unobstructed bay)

That is the entire naive version of this project, and it works about as well as you would guess: fine most of the time, embarrassingly wrong the rest. Everything below is a specific rule for a specific way it broke.

Break #1: The Tree

Two of the seven bays sit behind a street tree, and the detector was finding a car in them roughly one frame in five. I fixed it without touching the detector at all — by adding a persistence rule and a second, sideways way of seeing that a bay is empty.

The tree’s trunk and lower branches slice vertically across bays F1 and F4, so a car parked in either one arrives as two disconnected slivers with a tree in the middle. That one-in-five figure is measured against what I could see with my own eyes out the same window.

My first instinct was wrong: get a better detector, a bigger model, retrain something. But four frames out of five, the evidence genuinely isn’t there — no model recovers information a tree is physically blocking. What cracked it was a sentence I said out loud while staring at the frames: a parked car doesn’t go anywhere. If I saw a car in F1 a minute ago, the odds it is still there are enormous. The detector failing to find it now is not evidence it left. It is the tree.

Absence of evidence

This is the idea the whole parking system is built on. “I didn’t see a car” and “I saw that there is no car” are completely different statements, and a naive detector treats them as the same one. Almost every accuracy fix in this project is some version of teaching the code to tell those two apart.

The second half of the fix was finding a way to actually see emptiness. Behind the far bays is a strip of parkway grass, and a parked car blocks it. So instead of asking “is there a car,” the code crops the middle band of the bay and asks “can I still see an uninterrupted green sightline?” That question survives the tree, because the trunk splits the bay vertically while the grass runs horizontally across it.

EMPTY BAYasphaltmid-band grass ≈ 80%clear sightlinevote: VACANTOCCUPIED BAYmid-band grass 18–25%bright body blocking the stripvote: CAR
The grass heuristic. The tree trunk crosses both views and ruins the detector either way — but the parkway strip behind the bay still tells you the truth.

The occluded-bay rules (F1 and F4 only)

Those thresholds came from staring at ground-truth frames, not theory: an empty far bay reads around 80% green across that band, a bay with a car drops to 18–25%, and a white van pushed the bright-pixel share to about 30% — which is why the bright cue exists at all.

🔧 Developer section: The occluded-bay path

Break #2: The Sun Went Down

The daytime system got good, and then the overnight data showed the block apparently emptying out completely every night, which is the opposite of true. I added a separate night path built on contrast-boosted crops and held state — on one ground-truth miss it took the detector’s confidence in a dark bay from 0.0 to 0.36, which is the difference between invisible and countable.

A dark car on dark asphalt under a streetlight is, to an object detector, mostly just dark asphalt. The trigger for switching modes turned out to be pleasingly crude: the average brightness of the whole frame, on a 0-to-255 scale. Daylight frames sit above 90. Night frames measure around 12. No ambiguity at all, so a single threshold does the job.

the once-a-minute parking pass
# full-frame mean luma decides the mode
luma 96 → DAY — plain detector + 2-of-5 vote
luma 51 → DAY — deep dusk, still day-mode on purpose
threshold: 40
luma 12 → NIGHT — contrast boost, blob cue, hold state

Where the threshold sits matters more than what it is. 40 is low enough that deep dusk still counts as daytime.

What luma means

Luma is brightness with the color thrown away. Every pixel gets collapsed to a single grey value from 0 (black) to 255 (white), and then you average all of them across the frame. One number, describing how much light is in the scene. It is a much better “is it dark now” signal than a clock, because a clock doesn’t know about overcast afternoons, a burnt-out streetlight, or the fact that sunset drifts by an hour and a half across the year. The camera just measures what it can actually see.

That choice of 40 is the detail I am quietly proudest of. It would be natural to switch modes early, while there is still light. But night mode works by holding the state it inherits, so the last confident daylight read seeds the entire night. A low threshold keeps the system in day mode through deep dusk, latching a clean, well-lit picture of the block before the lights go out. Switch too early and you spend the night faithfully preserving a bad guess.

What night mode changes (every bay)

🔧 Developer section: Night mode implementation

Break #3: Knowing What Empty Looks Like

I built a library of what each bay looks like when it is known to be empty, and compared every new crop against it. That gave me the one thing all the persistence rules were missing — a trustworthy way to say a bay is free — and it immediately cleared a bay that had been falsely reading as taken.

It works by inverting the question. Everything above tries to detect a car; this detects emptiness. The reference images are filed by lighting condition, and every minute the current crop of each bay is compared against the closest match.

The empty-reference rules

🔧 Developer section: Empty-bay references

The pattern under all three fixes

Abstaining is a legitimate answer. The grass test declines to vote in shade, the reference check declines when the light doesn’t match, and the occluded bays decline to call themselves empty just because nobody saw a car. A system that always has an opinion is a system that is confidently wrong a lot.

Break #4: When the Camera Lies About Being Alive

I added a check that detects a frozen feed and makes the system stop writing entirely. The outcome is a deliberate hole in the data instead of a stretch of confident fiction, which is the trade I want every time.

A feed can freeze while still appearing healthy — the same frame delivered over and over, timestamps ticking along happily. Left alone, the system would keep faithfully recording whatever the frozen frame showed.

The stall rule

What Actually Comes Out

Live, there is a number: how many of seven bays are open, updated once a minute, with a little map of which ones. Historically, every snapshot lands in a bay_occupancy table, one row per bay per window. Because the bays are fixed regions rather than moving objects, dwell analysis needs no car tracking at all — a read-only script thresholds each fifteen-minute window per bay and measures how long the occupied runs last, bridging single-window flickers so one bad minute does not split a real four-hour stay. Out of that comes median and mean dwell per bay, turnover counts, and occupancy percentage. Which bay turns over fastest is exactly what you want to know when you are deciding where to start looking.

There is a dashboard on my own network, and a page on this site at /parking that draws the block and its occupancy. It’s password-gated, and not out of hesitation — it’s already shared with the friends who come over often enough for the number to matter, which is the entire audience. A stranger in another neighborhood has no use for the occupancy of my seven bays. So this post is the tour rather than the demo.

What went fast

What needed patience

What I took away is smaller and more general than parking. Nearly every improvement came from separating “I have no evidence” from “I have evidence of nothing,” and then letting the system abstain instead of guess. The detector was the easy part and never the bottleneck; the bottleneck was deciding what to believe when the picture was incomplete, which is most of the job in most systems I have built, camera or not. These days when someone texts asking about parking, I send a number. If you have a window over a block your friends complain about, it is a genuinely great weekend problem. Just be prepared for the tree.

← Back to all writing