← All Writing
July 26, 202613 min read

Bike, or Not Bike?

My camera kept logging bicycles that were actually jacaranda branches moving in the wind. No amount of tuning could fix it, so I fine-tuned the detector itself.

YieldA two-wheeler detector fine-tuned on my own block, running alongside the stock model as an ensemble — precision 69.1% to 85.7% and false positives 25 to 9 on a held-out split, plus the grid search that proved the fine-tune was necessary in the first place
DifficultyIntermediate (a grid-search script, a dataset exporter, and one YOLO fine-tune on a laptop — the labeling UI already existed, which is what made it tractable)
Total Cook TimeOne long session — hours of tuning, a slower stretch of labeling, and a training run that happened while I did something else

Ingredients

The Problem: My Camera Kept Seeing Bikes in a Tree

There is a joke in Silicon Valley about an app that was supposed to identify any food you pointed it at, and shipped only ever knowing two answers: hot dog, and not hot dog. My version is bike, or not bike — and when I started, it was getting that answer wrong about a third of the time, because of a tree.

Some background, briefly. A while back I put a camera on my street to track parking occupancy on my block in LA. It watches the bays and logs events, and sometimes when it checks whether a bay is occupied it logs a bicycle in the frame too. The parking part works. The bicycles were the problem.

My street has jacarandas, and a jacaranda branch is thin, articulated, and hangs into the frame. When the wind picks up it swings through — and to a detector working from shape alone, a wind-blown branch and a passing bicycle are close to the same object. Both are transient. Both move. Neither has a rider. I already had geometric rules to kill the obvious phantoms (a two-wheeler that sits perfectly still is scenery, because there is no outdoor bike parking in view), but a swinging branch is not still. What makes a branch obviously not a bike to a human is texture and context; my rules were only looking at motion.

Two numbers run through the rest of this: precision (when the system says “bike,” how often is it right) and recall (of all the real bikes, how many it caught). They trade against each other, and my rules were biased toward never missing a bike — paying for it with a pile of phantoms.

Step One: Trying to Tune My Way Out

I swept 48 threshold combinations against 631 of my own labeled crops and took the best one. Precision went from 66.3% to 70.7% while recall held at 94.4%, and false positives dropped from 163 to 132. A real improvement — and, as it turned out, the last one tuning had to give.

Every threshold in the ghost-suppression rules had been set by eye: I looked at a handful of crops, picked a number that felt right, moved on. But by now the review queue had accumulated 631 human-classified detections, each one a crop I had personally judged, so instead of guessing I could fit. The script rebuilds the suppression registry from cached detections under a given set of parameters, re-decides every labeled detection, and scores the result against my own verdicts. The three settings that moved were fixed_min_occupancy (how much of a clip a spot must fill to count as a fixed object), min_events (how many separate events must confirm a spot as scenery before it is condemned), and tight_min_events (the same idea for the tighter night-time flicker rule).

🔧 Developer section: Fitting the thresholds to human labels

tools/fit_thresholds.py
631 human-labelled detections

# current settings, as set by eye
precision 66.3% recall 95.0% F1 0.781 (fp=163)

evaluating 48 parameter sets…

# BEST PRECISION with recall >= 92%
occupancy=0.3 boxes=3 min_events=2 tight_min_events=2
precision 70.7% recall 94.4% F1 0.809 (fp=132)

The best of 48. Thirty-one fewer false positives — and nothing in the sweep went past 70.7%.

The Number That Would Not Move

Of all 48 parameter sets, none beat 70.7% precision. Not one. That flat result is the most useful thing this project produced, and it is the reason there is a rest of this post. The ceiling was not a local maximum I could climb out of with a finer sweep — 4.4 percentage points was the entire budget available from tuning, full stop.

The false positives that survived were all the same thing: foliage that is transient, moving, and riderless, and therefore geometrically identical to a passing bike. My rules only reason about where a box is and how long it stays there, and by those measures a swinging branch is a bicycle. There is no threshold that separates two things which, in the only vocabulary your rules speak, are the same thing. That is a detector problem, not a threshold problem — and realizing it was worth more than the tuning was, because it is what stops you burning a month.

The discipline, stated plainly

Before you fine-tune a model, prove that tuning cannot save you. Sweep the parameters you have against labels you trust, and look at what survives. If the leftover errors are cases your rules literally cannot describe, no amount of knob-turning fixes it — and now you know that instead of suspecting it. A flat grid search is not a wasted afternoon; it is the evidence that justifies the expensive next step.

Step Two: Learning My Street Is Not Mostly Bicycles

I split the single other label into six classes. That turned a bucket I could not learn from into a training signal — and along the way it surfaced a data bug that would have silently dropped 1,115 rows out of the training set.

To fine-tune, I needed labels, and my review queue only offered three buttons: bicycle, other, none. Going back through the 121 detections I had marked “other,” the composition was the surprise — 35% of everything real on this street is not a bicycle. Motorcycles, kick scooters, strollers, the occasional delivery robot. A third of my real two-wheeler-shaped traffic was flattened into a bucket that told a training run nothing. So I split the taxonomy into seven labels: bicycle, motorcycle, scooter, stroller, delivery_robot, other, and none.

One decision in there is worth explaining, because I got it wrong on the first pass. Only none blacklists a location. When I mark a detection none — nothing is there, that is a tree — the system remembers those pixels and stops trusting them, which is correct for scenery, because scenery never moves. Do the same for a stroller or a delivery robot and you condemn a patch of sidewalk that real things move through, silently suppressing the next actual cyclist to cross those pixels.

The bug that would have quietly poisoned the training set

While wiring the new labels up I found something unpleasant. The review queue has an auto-resolve feature: when I judge one detection, it settles obvious neighbours at the same spot so I do not have to click the same branch forty times. That auto-resolve was setting a verdict but never a label. On the dashboard everything looked fine — rows resolved, queue drained, counts right. But the dataset exporter selects on label IS NOT NULL, so 1,115 rows would have silently dropped out of any training export. Not errored. Not warned. Just quietly not there.

I would have trained on a fraction of my data while believing I had all of it, gotten a mediocre model, and had no idea why. That is my most consistent lesson from building data pipelines without an engineering background: the failures that hurt are the ones where a filter quietly excludes most of your rows and everything downstream keeps running on a fraction of the truth. Count your rows at every stage, and compare the counts.

Step Three: Fine-Tuning (And Why 1280 Instead of 640)

I fine-tuned YOLOv8s on my own labeled crops at an image size of 1280, training locally on a laptop with no cloud GPU — and without drawing a single bounding box by hand.

That last part is what makes this cheaper than it sounds. YOLO had already proposed every box; my verdict was the only missing piece. An approved detection becomes a positive example at coordinates the model itself supplied. A rejected one becomes a hard negative — the same image with that box deliberately left out, which is how you teach a model that a backlit jacaranda branch is not a bicycle.

🔧 Developer section: Building the training set

The single most consequential setting turned out to be image size. YOLO trains at 640 pixels by default, and my detections are small: a typical two-wheeler box on this camera is around 73 pixels across in a 1920x1080 frame. Resize that frame down to 640 and the box collapses to roughly 24 pixels — right at the floor of what the model can resolve. At that size the wheels, the frame and the rider are all gone, and what is left of a bicycle is a smudge that looks exactly like what is left of a branch. That is a large part of why the two score so similarly, and why the fine-tune trains at 1280.

🔧 Developer section: Running the fine-tune

The Results

On the held-out split the fine-tuned model scored 85.7% precision against stock’s 69.1%, cutting false positives from 25 to 9. Recall dipped, 91.8% to 88.5%. Both models were scored on the same events — a model that only looks good against its own training distribution has told you nothing.

stock yolov8sfine-tuned69.1%85.7%Precision70.7% ceiling — no parameter set beat it91.8%88.5%RecallPrecision up 16.6 points. Recall down 3.3 — a trade I made on purpose.False positives25 → 9on the held-out split
Both models on the same held-out events. Sixteen points of precision for three points of recall, false positives cut by nearly two thirds, and the whole thing well clear of the ceiling tuning could reach.

I want to be straight about the recall dip, because it would be easy to bury. Recall went down, 91.8% to 88.5% — the fine-tuned model misses slightly more real bikes than the stock one does. That is the trade, and I took it knowingly: at 25 false positives per split, the bicycle count on my dashboard was not a measurement, it was a rumor. Three points of recall for sixteen points of precision turns a number I could not cite into one I can.

The most reassuring result, though, was neither headline number: the improvement held across confidence thresholds. A fine-tune that only wins at one cutoff is a threshold in a trench coat, hiding the errors rather than resolving them. Stable across the range means the model genuinely learned the difference.

Shipping It: The Model That Only Knows Six Things

I deployed the fine-tuned weights as an ensemble rather than a replacement: it supplies bicycle and motorcycle boxes, stock YOLOv8s supplies everything else, and the pipeline falls back to stock entirely if the weights go missing.

Which brings me back to the hot dog app. My fine-tuned model was trained on its six sidewalk classes and nothing else — every other head stock YOLOv8s ships with (person, car, dog) simply is not in there. So it does not replace anything, it joins. Swapping it in wholesale would have silently erased every non-two-wheeler detection in my catalog — frightening precisely because nothing would have crashed.

🔧 Developer section: Wiring the ensemble

Two things it actually fixed

What went fast

What needed patience

The most valuable output of this whole project was a negative result. Forty-eight parameter sets, none better than 70.7%, and that flat line is what told me to stop tuning and go label my own data. If your off-the-shelf model almost works, I would do it in the same order: get real labels, sweep your parameters against them, and look hard at whatever errors survive. If those errors are things your rules cannot even describe, no threshold will save you — and you have just saved yourself a month of turning knobs. Then go do the boring part, because that is the part that works.

← Back to all writing