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.
Ingredients
- A webcam in a window — pointed at the block at 1080p. Video only, no audio ever recorded. Most people who have worked from home have a spare one in a drawer, which is genuinely the entire hardware budget (already owned)
- YOLOv8 — the open-source object detector that draws boxes around cars, from Ultralytics via
pip install ultralytics. Runs locally, on my own machine. I use two sizes: the accurateyolov8sfor the parking pass and the fasteryolov8nfor everything else (free) - OpenCV — the image-handling library that does the cropping, the color math, and the contrast tricks I lean on after dark (free)
- A hand-drawn bay map — seven polygons traced onto a still frame in a little browser calibration tool, saved to a
geometry.jsonfile (free) - SQLite — one
bay_occupancytable that accumulates per-bay samples so I can ask questions about last week, not just right now (free)
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.
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
- Installed with
pip install ultralytics(pinnedultralytics>=8.3) — the.ptweights download themselves the first time you name a model - Ran
tools/calibrate_bays.pyand dragged the 7 bay polygons on a live snapshot atlocalhost:8390; Save backs up and rewritesgeometry.json - Brightened that calibration snapshot deliberately — at dusk the raw frame is too dark to place a corner accurately
- Split the models in
config.yaml:yolov8s.pton the 1/min parking pass,yolov8n.pton the ~8fps counter, wheresdropped ~30% of frames - Gotcha: saving is not applying.
traffic.pyreadsgeometry.jsononce at startup, so a perfect calibration does nothing until capture restarts
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)
- What counts as a car — once a minute, the accurate model runs on a full frame. Any car, truck, or bus box whose position falls inside a bay polygon is one positive snapshot for that bay.
- What makes a bay occupied — the last five snapshots (roughly five minutes) are pooled. Two positives out of five means occupied. Fewer means open.
- Why pool at all — a single frame is twitchy, and checking sixty times a minute would burn a lot of computer to watch the detector flicker. Parked cars do not move fast enough for five minutes of lag to matter, so two-of-five costs nothing and kills the noise.
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.
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.
The occluded-bay rules (F1 and F4 only)
- Trigger — a bay is on this path only if it is on the hand-listed occluded set. That is exactly two of the seven. The other five keep the plain two-of-five vote, untouched.
- The grass vote — crop the middle band of the bay and measure it. Above 50% green votes vacant. Above 15% very-bright pixels votes car. Anything in between votes nothing and defers to the detector.
- Acquire — latch as occupied on two positives out of the last five, where a positive can come from either the detector or the grass vote.
- Hold — once latched, stay latched through detector silence. Missing evidence is not vacancy evidence.
- Release — only on positive proof of emptiness: the grass sightline comes back, or the empty-reference check (below) says empty. Failing that, a safety valve trips after about 45 minutes with zero evidence of any kind, so nothing can latch forever.
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
- Opted bays in one at a time via
parking.occluded_spots: [3, 6]inconfig.yaml—geometry.jsonindices for F1 and F4 - Wrote
_occluded_confirm()inwatch/traffic.pyto measure green pixels across the bay’s mid-band, 20%–70% of bay height - Treated grass as the release signal, not the detection signal: seeing grass proves vacancy, where failing to see a car proves nothing
- Bailed to a no-vote when the mid-band’s own luma falls under
45— without it the grass test votes vacant on every dark frame - Gotcha: F4 was missing from
occluded_spotsat first. Same tree, no persistence backstop, so it read empty for hours
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.
Where the threshold sits matters more than what it is. 40 is low enough that deep dusk still counts as daytime.
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)
- Contrast boost — each bay crop gets a contrast and gamma lift, which pulls a surprising amount of shape out of what looks like a black rectangle.
- The blob cue — look for the compact flare of a headlight, taillight, or a reflection catching a streetlight. On one bay, a dark sedan’s reflection reads at 222 against an asphalt floor of about 32. That blob is positive vehicle evidence you cannot get off the car’s dark body.
- Persistence for everyone — the hold-once-latched rule stops being an occluded-bay special case and applies to all seven.
- Release — a bay occupied at dusk stays occupied unless a car is actually observed leaving, or the same ~45-minute zero-evidence valve trips.
🔧 Developer section: Night mode implementation
- Set
parking.night_luma: 40inconfig.yaml, off a mean luma computed on a cheap240x135downscale - Boosted each bay crop in
_crop_check()withcv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8))on the LAB L channel, then a1 / 2.2gamma LUT - Measured the payoff on a real miss (2026-07-16): a dark car scored
0.0raw and0.36after the boost - Dropped the detector floor to
conf=0.06on night frames, since CLAHE’d dark cars peak low - Gotcha: the boost was gated per-crop at mean under 60, which missed dark cars in streetlight-spilled bays. Forcing it frame-wide fixed it
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
- Verdict: empty — the crop structurally matches its known-empty reference. This is a real, positive “nothing is here,” which is what makes it a trustworthy release signal instead of a timer.
- Verdict: occupied — the crop looks nothing like its own empty reference, so something is in it. This is the rescue path for dark or oddly-angled cars the detector misses entirely.
- Verdict: defer — if no reference exists at a brightness close to the current frame, it says nothing rather than comparing a noon crop against a reference shot at dusk.
- How much it counts — one vote into the same five-snapshot pool as everything else. It can nudge a bay. It can never flip one on its own.
🔧 Developer section: Empty-bay references
- Wrote
watch/baydiff.pyto score each bay against a known-empty reference on SSIM plus edge density (ssim_empty: 0.72) - Let a confident
emptyverdict veto the low-confidence crop-rescue path, which fixed bay N3 reading as falsely taken - Bucketed references by lighting and set
luma_match_tol: 28— beyond that it defers rather than compare a noon crop to a dusk reference - Seeded with
tools/seed_bay_refs.py, run at morning, overcast, dusk and night while the bays were genuinely empty (--dry-runscores without writing) - Gotcha: references are camera-pose-specific and nothing errors when the pose changes — wipe
data/bay_refsand re-seed after any re-mount
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
- Trigger — the frame-health check notices the feed has stopped changing.
- Action — hold the last known parking state and write nothing: no false zeros, no invented occupancy, no new rows.
- Why — a gap in the data is annoying. Plausible-looking wrong data is dangerous, because you will build on it before you notice.
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
- Getting to a first number — an off-the-shelf detector, seven hand-drawn polygons, and a point-in-polygon check produced a plausible occupancy count in an afternoon. That first 60% really is as easy as the tutorials suggest.
- The calibration tool — building a browser page just to drag bay corners felt like a detour, and it immediately paid for itself, because every later fix started with “look at the actual frame.”
- Dwell analysis — because the bays never move, measuring how long cars stay needed no object tracking whatsoever. Fixed regions turned a hard computer-vision problem into a straightforward query.
What needed patience
- Accepting that the tree wins — the answer was never a better detector. It was reasoning about time, and about what a parked car fundamentally is.
- Tuning hysteresis without making it stubborn — a bay that holds its state too eagerly is just a bay that lies slowly. Getting the release conditions right took more iterations than the acquire side.
- Night mode, twice — the first version switched modes too early and spent whole evenings preserving a state captured while the light was already bad. Dropping the threshold to 40 fixed more than any amount of image processing did.
- Learning that references are pose-specific — nothing errors when the camera moves. The system just gets quietly, subtly worse, which is the hardest kind of bug to notice.
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.