CCCURBCHECK
FIELD SPEC SHEET  //  JURISDICTION: SAN FRANCISCO
ISSUE 02  //  SHUBHAM GOEL  //  JUNE 2026
Vision-language models // post-training study

Can a small model tell you if you can park?

One week in the city. Two parking tickets, roughly $160, off a single pole carrying four stacked signs. So I taught a 3-billion-parameter model to read the pole I could not.

Side by side: a synthetic rendered four-sign parking pole drawn from Caltrans specs, next to a real faded sun-bleached SF parking sign photographed against a blue sky.
LEFT: synthetic render, perfectly labeled by construction. RIGHT: the real thing, faded and oblique. The gap between them is most of this story.
01

Two tickets, one week

The whole problem was printed on a pole. The failure was parsing it in time.

I drove up from San Jose for a week in April and came home with two citations. Both times the cause was identical: I stood in front of a single pole holding four stacked signs and could not work out whether the curb in front of me was legal. A two-hour limit. Except with an Area permit. Except street-cleaning Tuesday mornings. Also tow-away during evening rush.

Every fact I needed was on the metal. The information was complete. What defeated me was the resolution step: take four overlapping rules, hold them against the current day and time, produce one answer before the meter maid does. Perception, plus rule-logic, plus a clock. My brain dropped a window. Twice.

That is a strange failure, because none of it is ambiguous. A parking pole is not a judgment call; it is a small, badly-formatted program. Which raised the question that became this project.

Can a small, cheap model, the kind that could one day run on a phone, do the thing my brain failed to do? Not a frontier model in a datacenter. A 3B vision-language model, fine-tuned for under five dollars. That is the bet curbcheck tests.
02

Read, then reason

The decision the whole project rests on: never let the model do the logic.

The instinct is to show a model the photo and ask "can I park?" That fails, and it fails worst exactly where it matters, on the busy four-sign poles. Juggling four time windows, a permit exemption, and a severity ranking in one forward pass is too much for a 3B model. So curbcheck splits the job in two, and only the first half touches a neural network.

STEP 01

Read the pole

The VLM looks at the photo and transcribes every sign into structured JSON: kind, days, hours, limits, permit area, week-of-month. It only perceives.

Neural // Qwen2.5-VL-3B
STEP 02

Resolve the rules

A 120-line deterministic resolver (schema/rules.py) applies those rules to the current time. Strictest wins. No model in the loop.

Deterministic // pure Python
STEP 03

Show both halves

You get a verdict (ok / limited / no / tow-risk) and the reason, plus what each sign read. A misread is visible, not hidden in a black box.

UI // honest by design

The split is the whole trick. The model is allowed to be a fallible reader; it is never allowed to be a fallible logician. Because the logic is exact and inspectable, stacking signs does not break the answer, it just adds another line of JSON for the resolver to chew on. And because both halves are shown, you can always see whether a wrong verdict came from a misread or, impossibly, from the rules.

03

The pole that cost $160

The exact four-sign stack, decoded into JSON, then resolved against a time.
Decoder rig // live trace Tuesday, 7:30 PM, no permit
↳ What the VLM reads (one JSON line per plate)
1{ "kind":"permit_limit", "limit":240, "area":"A" }
2{ "kind":"time_limit", "days":"MON–SAT", "limit":120 }
3{ "kind":"street_cleaning", "day":"TUE", "start":"07:00" }
4{ "kind":"tow_away", "days":"MON–FRI", "start":"16:00", "end":"18:00" }
resolver input
Tue · 19:30
permits: none
Verdict
NO
Tow-away (plate 4) and the 2-hour limit (plate 2) both ended at 18:00; street cleaning already passed. At 19:30 only the 4-hour permit zone applies, and you hold no Area A permit. Park elsewhere.
OK LIMITED NO TOW RISK
The model's only job was to turn the metal into those four JSON lines. Every comparison after that, which window is active at 19:30, which exemption applies, which restriction is strictest, runs in plain Python that cannot make a logic error. That is why a four-sign pole no longer wins.
04

How it was built

A frozen-eyed 3B model, a free synthetic data faucet, and a frontier teacher for the real photos.

The model

The base is Qwen2.5-VL-3B-Instruct. I fine-tuned it with QLoRA at rank 16, on the language layers only; the vision encoder stays frozen. The goal was to teach the model to reason about sign content, not to see differently. Training ran on Modal on a single A100, and the whole run cost roughly four dollars. Hold onto the frozen-encoder detail; it returns as the punchline.

The data

Two sources, labeled in completely different ways. About 77% is synthetic: sign-stacks rendered from public-domain Caltrans R-series specs (R26 no-parking, R30 time-limit, R32 street-cleaning), with rule distributions seeded by SFMTA's 144,333-row street-sign inventory so the mix looks like real San Francisco. Because each image is drawn from a rule object, the label exists before the pixels do; there is no annotation step and no label error.

The other 23% is real: SF street-space permit photos and 311 cases, pseudo-labeled by Claude Opus as a teacher (the same distillation move the Meta ads pipeline used) and human-verified. I added renderer realism on the synthetic side too, sky and building backgrounds, fading, occlusion, perspective, to attack the gap between clean renders and bleached Mission Street poles.

05

On clean signs, yes

On the synthetic benchmark the tuned model is nearly perfect, and the pipeline split erases the four-sign cliff.
Bar chart comparing base versus QLoRA-tuned Qwen2.5-VL-3B. Read F1 rises from 0.34 to 0.98; reasoning accuracy rises from 0.16 (below the 0.25 random floor) to 0.82.
SYNTHETIC BENCHMARK // 100 IMAGES, 400 QUESTIONS. The base model's 0.16 reasoning score sits below the 0.25 random-guess floor; it is a degenerate pessimist that calls almost everything tow-away. Fine-tuning is doing real work, not formatting.
Synthetic eval // base vs tuned (v4)
Metricbasetuned
Read F10.340.985
Reason, end-to-end (0.25 floor)0.160.82
Reason, via pipeline0.830.99

The pipeline column is the product path: the model reads, the resolver decides. It posts the highest score because the resolver never makes a logic mistake.

Tuned reasoning by stack size (end-to-end)
1 sign0.95
2 signs0.80
3 signs0.67
4 signs0.56

Difficulty scales with the number of signs. The four-sign pole, the one that cost the tickets, is hardest at 0.56. The amber tick marks where an earlier model version sat (0.39). Route that same read through the resolver and four-sign accuracy jumps to 0.89.

06

The honest part

Clean renders are not bleached Mission Street poles. On real photos, reading is still hard, and the data told me why.

Synthetic scores are the easy victory. The real test is held-out San Francisco photos, faded, oblique, sticker-covered. Fine-tuning helps a lot there too: reading goes from near-blind to usable, and pipeline reasoning lands at a deployable 0.89.

Real read F1
0.040.34
The base model genuinely cannot read real signs. Tuning gives an 8x lift, but reading stays the bottleneck.
Pipeline reasoning
0.780.89
The deployable metric. The resolver carries it even while reading is stuck, the architecture earning its keep.
End-to-end reasoning
0.100.41
It regressed from earlier: a model trained on busy stacks over-calls restrictions on mostly-simple real poles. Deploy the pipeline, not end-to-end.
The result I did not expect

I doubled the real data. Reading moved one point.

The whole v4 effort, double the real training photos, human-verified labels, two new sign types, and the renderer-realism augmentation, was aimed squarely at the sim-to-real reading gap. It moved real read F1 from 0.33 to 0.34.

+0.01 A one-point move after doubling the data is not a data problem. It is strong evidence that the real-photo gap is about model capacity, not data volume.

And the reason is the detail to hold onto: the vision encoder is frozen. I fine-tuned the language layers; the eyes never changed. No amount of extra data, and no full SFT, can teach faded, oblique, real-world glyphs while the vision tower stays frozen. The next experiment writes itself: a vision-encoder LoRA, the one thing I have not unfrozen yet.

Meanwhile the architecture keeps paying off. Reading is stuck at 0.34, and pipeline reasoning still holds at 0.89, because the deterministic resolver never fluffs the logic. That split, the entire premise of the project, is what lets a half-blind reader still ship a useful verdict.

The poles it reads in the wild

A real San Francisco temporary no-stopping sign mounted on an orange post on a sidewalk, with dates, a 7AM to 3PM Saturday-only window, and a PG&E permit notice.
A real San Francisco temporary no-stopping parking sign on an orange post at a residential street corner, listing dates and a Friday-only restriction window.
A weathered real San Francisco no-stopping construction sign on a wooden post against a tiled building wall, listing a February to August Monday-to-Saturday window.

Three held-out SF 311 photos. Real signs are sun-bleached, angled, and partly occluded; this is the distribution the frozen encoder struggles on.

07

Try it yourself

A live Hugging Face Space. Upload a sign, pick a day and time, get the verdict plus what each sign read.

Curbcheck, live

Point it at any San Francisco parking pole. The Space runs the tuned model to read the signs, then the same deterministic resolver decides whether you can park, and shows both halves so you can catch a misread.

modelQwen2.5-VL-3B + QLoRA
runtimeGradio · ZeroGPU
you givephoto + day + time
you getverdict + per-sign read
train cost~$4 on Modal