shubhamgoel27 commited on
Commit
79d2392
·
verified ·
1 Parent(s): 41378a2

curbcheck: read-then-resolve VLM parking checker

Browse files
.gitattributes CHANGED
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ examples/dpw_0accb82f29.jpg filter=lfs diff=lfs merge=lfs -text
37
+ examples/dpw_0b0dc146cc.jpg filter=lfs diff=lfs merge=lfs -text
38
+ examples/dpw_0b6a0cec08.jpg filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,13 +1,58 @@
1
  ---
2
- title: Curbcheck
3
- emoji:
4
- colorFrom: pink
5
- colorTo: yellow
6
  sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.12'
9
  app_file: app.py
10
- pinned: false
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: curbcheck
3
+ emoji: 🅿️
4
+ colorFrom: red
5
+ colorTo: gray
6
  sdk: gradio
 
 
7
  app_file: app.py
8
+ pinned: true
9
+ license: mit
10
+ tags:
11
+ - build-small-hackathon
12
+ - backyard-ai
13
+ - vision-language-model
14
+ - qlora
15
+ - qwen2.5-vl
16
+ short_description: Can a small VLM tell you if you can legally park in SF?
17
  ---
18
 
19
+ # 🅿️ curbcheck
20
+
21
+ **Can a small vision-language model tell you if you can legally park in San Francisco?**
22
+
23
+ Upload a photo of a parking-sign pole, pick a day and time, and a QLoRA-tuned
24
+ **Qwen2.5-VL-3B** reads each sign into structured rules. A tiny deterministic resolver
25
+ then applies them to that moment and returns the verdict, showing you *both* what the
26
+ model read and why. Read-then-resolve: the VLM only perceives; the logic is exact.
27
+
28
+ I came to SF for a week in April 2026 and left with two parking tickets, both because I
29
+ couldn't parse a pole holding four stacked signs. curbcheck is the model that gets it.
30
+
31
+ ## Track & badges
32
+
33
+ - **Track:** Backyard AI (a real, local, personal problem solved with a small model)
34
+ - A 3B model, well under the 32B cap, runs on ZeroGPU.
35
+
36
+ ## How it works
37
+
38
+ ```
39
+ photo ─▶ VLM (reads each sign to JSON) ─▶ deterministic resolver ─▶ verdict + reason
40
+ ```
41
+
42
+ - The model only **reads** the pole into structured restrictions (kind, days, hours, limits, permits).
43
+ - A deterministic resolver (`rules.py`, no model in the loop) applies them to the current time.
44
+ - Both halves are shown, so misreads are visible, not buried in a confident sentence.
45
+
46
+ ## The result
47
+
48
+ A stock Qwen2.5-VL-3B scores 0.16 on "can I park here right now", below random. One QLoRA
49
+ run takes it to **0.82 reasoning** and **0.98 read accuracy** on the synthetic benchmark; on
50
+ real SF photos the read-then-resolve pipeline reasons at **0.89**.
51
+
52
+ Full benchmark, training, and honest results: **https://github.com/shubhamgoel27/curbcheck**
53
+
54
+ ## Links
55
+
56
+ - Code + benchmark: https://github.com/shubhamgoel27/curbcheck
57
+ - Demo video: _(add link)_
58
+ - Social post: _(add link)_
__pycache__/app.cpython-311.pyc ADDED
Binary file (17.8 kB). View file
 
__pycache__/rules.cpython-311.pyc ADDED
Binary file (7.85 kB). View file
 
app.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """curbcheck demo: can a small VLM tell you if you can legally park in SF?
2
+
3
+ Upload a photo of a parking-sign pole, pick a day/time, and the model reads each
4
+ sign into structured rules, then a deterministic resolver applies them to that
5
+ moment and returns a verdict, with its reasoning shown. Read-then-resolve: the
6
+ VLM only perceives, the logic is exact.
7
+
8
+ Qwen2.5-VL-3B + a QLoRA adapter (curbcheck v4), on ZeroGPU.
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ from datetime import datetime, time as dtime
15
+
16
+ import gradio as gr
17
+ import spaces
18
+ import torch
19
+ from PIL import Image
20
+ from peft import PeftModel
21
+ from transformers import AutoProcessor, Qwen2_5_VLForConditionalGeneration
22
+ from qwen_vl_utils import process_vision_info
23
+
24
+ from rules import Kind, Day, Restriction, Window, SignStack, can_park, Verdict
25
+
26
+ BASE_ID = "Qwen/Qwen2.5-VL-3B-Instruct"
27
+ ADAPTER_REPO = os.environ.get("ADAPTER_REPO", "shubhamgoel27/curbcheck-qwen25vl3b-v4-lora")
28
+
29
+ READ_PROMPT = """Look at the parking sign stack in this image. Extract EVERY sign as a JSON array.
30
+ Each element: {"kind": one of [no_parking, no_stopping, tow_away, time_limit, permit_limit, street_cleaning, loading_only, angle_parking] (use permit_limit when the sign has a permit exemption like EXCEPT AREA X PERMIT, time_limit otherwise; use angle_parking for orientation signs like "PARK AT 90 DEGREES" which do not restrict parking),
31
+ "days": list like ["MON","TUE"...] (the days the restriction applies, null for angle_parking),
32
+ "start": "HH:MM" 24h, "end": "HH:MM" 24h,
33
+ "limit_minutes": int or null, "permit_area": letter or null, "tow": true/false,
34
+ "weeks": list of which weeks of the month it applies like [2,4] for "2nd & 4th MONDAY" (works on ANY sign type, not just cleaning), or null for every week}.
35
+ Respond with ONLY the JSON array, nothing else."""
36
+
37
+ # ---- load model once (CPU at startup; moved to GPU inside the @spaces.GPU fn) ----
38
+ print("loading base + adapter...")
39
+ processor = AutoProcessor.from_pretrained(BASE_ID)
40
+ model = Qwen2_5_VLForConditionalGeneration.from_pretrained(BASE_ID, torch_dtype=torch.bfloat16)
41
+ model = PeftModel.from_pretrained(model, ADAPTER_REPO)
42
+ model.eval()
43
+ print("model ready")
44
+
45
+ dec = json.JSONDecoder()
46
+
47
+
48
+ def extract(t):
49
+ t = re.sub(r"```(?:json)?", "", t).strip("` \n")
50
+ for i, ch in enumerate(t):
51
+ if ch in "[{":
52
+ try:
53
+ return dec.raw_decode(t[i:])[0]
54
+ except json.JSONDecodeError:
55
+ continue
56
+ return None
57
+
58
+
59
+ def build_stack(read_json):
60
+ out = []
61
+ for r in read_json if isinstance(read_json, list) else []:
62
+ if not isinstance(r, dict):
63
+ continue
64
+ try:
65
+ kind = Kind(str(r["kind"]).lower().replace("-", "_"))
66
+ if kind is Kind.ANGLE_PARKING:
67
+ out.append(Restriction(kind, Window(frozenset(), dtime(0), dtime(0))))
68
+ continue
69
+ days = frozenset(Day[str(d)[:3].upper()] for d in (r.get("days") or []))
70
+ sh, sm = map(int, str(r["start"]).split(":"))
71
+ eh, em = map(int, str(r["end"]).split(":"))
72
+ wk = frozenset(int(x) for x in (r.get("weeks") or []))
73
+ out.append(Restriction(kind, Window(days, dtime(sh, sm), dtime(eh, em), weeks=wk),
74
+ limit_minutes=r.get("limit_minutes"),
75
+ permit_area=r.get("permit_area"), tow=bool(r.get("tow"))))
76
+ except Exception:
77
+ continue
78
+ return SignStack(out)
79
+
80
+
81
+ VERDICT_UI = {
82
+ Verdict.OK: ("✅ You can park", "#0a7d3c"),
83
+ Verdict.LIMITED: ("⏱️ Limited parking", "#b8860b"),
84
+ Verdict.NO: ("🚫 No parking", "#c1452a"),
85
+ Verdict.TOW_RISK: ("🚨 Tow risk", "#8b0000"),
86
+ Verdict.ABSTAIN: ("🤔 Can't tell from the sign", "#555"),
87
+ }
88
+
89
+
90
+ @spaces.GPU(duration=90)
91
+ def read_signs(image):
92
+ model.to("cuda")
93
+ msgs = [{"role": "user", "content": [
94
+ {"type": "image", "image": image}, {"type": "text", "text": READ_PROMPT}]}]
95
+ text = processor.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
96
+ imgs, vids = process_vision_info(msgs)
97
+ inp = processor(text=[text], images=imgs, return_tensors="pt").to("cuda")
98
+ with torch.no_grad():
99
+ out = model.generate(**inp, max_new_tokens=400, do_sample=False)
100
+ trim = out[0][inp.input_ids.shape[1]:]
101
+ return processor.decode(trim, skip_special_tokens=True)
102
+
103
+
104
+ DOW = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
105
+ KIND_LABEL = {
106
+ Kind.NO_PARKING: "No parking", Kind.NO_STOPPING: "No stopping",
107
+ Kind.TOW_AWAY: "Tow away", Kind.TIME_LIMIT: "Time limit",
108
+ Kind.STREET_CLEANING: "Street cleaning", Kind.PERMIT_EXEMPT_LIMIT: "Permit / time limit",
109
+ Kind.LOADING_ONLY: "Loading only", Kind.ANGLE_PARKING: "Angle parking (info)",
110
+ }
111
+
112
+
113
+ def fmt_restriction(r):
114
+ if r.kind is Kind.ANGLE_PARKING:
115
+ return f"- **{KIND_LABEL[r.kind]}** (does not restrict parking)"
116
+ days = ", ".join(d.name.title() for d in sorted(r.window.days, key=lambda d: d.value)) or "every day"
117
+ span = f"{r.window.start.strftime('%-I:%M%p').lower()}–{r.window.end.strftime('%-I:%M%p').lower()}"
118
+ bits = [f"**{KIND_LABEL.get(r.kind, r.kind.value)}**", span, days]
119
+ if r.window.weeks:
120
+ bits.append("weeks " + "/".join(str(w) for w in sorted(r.window.weeks)) + " of month")
121
+ if r.limit_minutes:
122
+ bits.append(f"{r.limit_minutes}min limit")
123
+ if r.permit_area:
124
+ bits.append(f"except Area {r.permit_area} permit")
125
+ if r.tow:
126
+ bits.append("TOW")
127
+ return "- " + " · ".join(bits)
128
+
129
+
130
+ def predict(image, day, hour, minute, ampm, permit):
131
+ if image is None:
132
+ return "### Upload a photo of a parking sign first.", "", ""
133
+ raw = read_signs(image)
134
+ parsed = extract(raw)
135
+ stack = build_stack(parsed)
136
+
137
+ # build the "when"
138
+ h = int(hour) % 12 + (12 if ampm == "PM" else 0)
139
+ dow_idx = DOW.index(day)
140
+ # next date matching that weekday (anchored to 2026-06-15, a Monday)
141
+ base = datetime(2026, 6, 15, h, int(minute))
142
+ when = base.replace(day=15 + ((dow_idx - 0) % 7))
143
+
144
+ permit_set = frozenset(p.strip().upper() for p in permit.split(",") if p.strip())
145
+ ans = can_park(stack, when, permit_areas=permit_set)
146
+
147
+ label, color = VERDICT_UI.get(ans.verdict, ("?", "#555"))
148
+ detail = ""
149
+ if ans.verdict is Verdict.LIMITED and ans.limit_minutes:
150
+ detail = f" — up to {ans.limit_minutes} minutes"
151
+ verdict_md = (
152
+ f"<div style='font-size:1.6em;font-weight:700;color:{color}'>{label}{detail}</div>"
153
+ f"<div style='color:#666;margin-top:6px'>on {day} at {int(hour)}:{int(minute):02d} {ampm}"
154
+ + (f", with permit {','.join(permit_set)}" if permit_set else ", no permit") + "</div>"
155
+ f"<div style='margin-top:8px'>{ans.reason}</div>"
156
+ )
157
+
158
+ if stack.restrictions:
159
+ signs_md = "### What the model read on the pole\n" + "\n".join(
160
+ fmt_restriction(r) for r in stack.restrictions)
161
+ else:
162
+ signs_md = "### What the model read on the pole\n_No structured signs parsed._"
163
+
164
+ return verdict_md, signs_md, raw.strip()
165
+
166
+
167
+ THEME = gr.themes.Soft(primary_hue="red", neutral_hue="stone")
168
+
169
+ with gr.Blocks(theme=THEME, title="curbcheck") as demo:
170
+ gr.Markdown(
171
+ "# 🅿️ curbcheck\n"
172
+ "**Can a small VLM tell you if you can legally park in San Francisco?** "
173
+ "Upload a photo of a sign pole, pick a day and time, and a QLoRA-tuned "
174
+ "Qwen2.5-VL-3B reads each sign into structured rules. A deterministic resolver "
175
+ "then decides the verdict, so you see *both* what it read and why. "
176
+ "[Project + benchmark on GitHub](https://github.com/shubhamgoel27/curbcheck)."
177
+ )
178
+ with gr.Row():
179
+ with gr.Column(scale=1):
180
+ img = gr.Image(type="pil", label="Parking sign photo", height=360)
181
+ with gr.Row():
182
+ day = gr.Dropdown(DOW, value="Tuesday", label="Day")
183
+ hour = gr.Dropdown([str(i) for i in range(1, 13)], value="5", label="Hour")
184
+ minute = gr.Dropdown(["00", "15", "30", "45"], value="30", label="Min")
185
+ ampm = gr.Dropdown(["AM", "PM"], value="PM", label="")
186
+ permit = gr.Textbox(label="Your permit area(s), if any", placeholder="e.g. S")
187
+ btn = gr.Button("Can I park here?", variant="primary")
188
+ with gr.Column(scale=1):
189
+ verdict_out = gr.Markdown()
190
+ signs_out = gr.Markdown()
191
+ with gr.Accordion("Raw model output (JSON)", open=False):
192
+ raw_out = gr.Code(language="json")
193
+
194
+ btn.click(predict, [img, day, hour, minute, ampm, permit],
195
+ [verdict_out, signs_out, raw_out])
196
+
197
+ import glob
198
+ ex = sorted(glob.glob("examples/*.jpg"))[:4]
199
+ if ex:
200
+ gr.Examples([[e, "Tuesday", "5", "30", "PM", ""] for e in ex],
201
+ [img, day, hour, minute, ampm, permit], label="Try a real SF photo")
202
+
203
+ if __name__ == "__main__":
204
+ demo.launch()
examples/dpw_0accb82f29.jpg ADDED

Git LFS Details

  • SHA256: ecde4dedd33d2b22772f858ca0f2fc115420fc42dcd7ca276aab35a206c7f745
  • Pointer size: 131 Bytes
  • Size of remote file: 247 kB
examples/dpw_0b0dc146cc.jpg ADDED

Git LFS Details

  • SHA256: 2f8c949afc1edac78eccd3207f9e47d717e7ffe99f391d9a9d583512c76c1de4
  • Pointer size: 131 Bytes
  • Size of remote file: 290 kB
examples/dpw_0b6a0cec08.jpg ADDED

Git LFS Details

  • SHA256: 189d8020b390bad2ea106528c0bb003171f4ffbe45ed37ebdfe13e1f678bf66b
  • Pointer size: 131 Bytes
  • Size of remote file: 159 kB
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ spaces
2
+ torch
3
+ transformers>=4.49.0
4
+ accelerate>=1.2.0
5
+ peft>=0.14.0
6
+ qwen-vl-utils
7
+ pillow
rules.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The rule schema: what a San Francisco parking sign stack can say, as data.
2
+
3
+ Design notes:
4
+ - A pole holds a stack of Restriction objects (top to bottom matters for rendering,
5
+ not for semantics).
6
+ - Answering "can I park here at time T?" applies every restriction independently;
7
+ the most severe applicable verdict wins (TOW > NO_PARK > NO_STOP > TIME_LIMIT > FREE).
8
+ - v1 scope: sign-stack-only. Curb paint and meters are out of frame; queries that
9
+ depend on them must be answered ABSTAIN by a correct model.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+ from datetime import datetime, time
16
+ from enum import Enum
17
+
18
+
19
+ class Day(Enum):
20
+ MON, TUE, WED, THU, FRI, SAT, SUN = range(7)
21
+
22
+
23
+ WEEKDAYS = frozenset({Day.MON, Day.TUE, Day.WED, Day.THU, Day.FRI})
24
+ EVERY_DAY = frozenset(Day)
25
+
26
+
27
+ class Kind(Enum):
28
+ NO_STOPPING = "no_stopping" # CA R26(S): no stopping at any time in window
29
+ NO_PARKING = "no_parking" # CA R26: no parking in window
30
+ TOW_AWAY = "tow_away" # modifier or standalone: violation = tow
31
+ TIME_LIMIT = "time_limit" # CA R30: e.g. 2-hour parking 9am-6pm
32
+ STREET_CLEANING = "street_cleaning" # CA R32: no parking, specific weekday window
33
+ PERMIT_EXEMPT_LIMIT = "permit_limit" # RPP: time limit EXCEPT vehicles with area permit
34
+ LOADING_ONLY = "loading_only" # passenger/commercial loading zone window
35
+ ANGLE_PARKING = "angle_parking" # informational: "PARK AT 90 DEGREES" etc. No verdict effect.
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Window:
40
+ """A recurring weekly time window, e.g. Tue 8:00-10:00."""
41
+ days: frozenset[Day]
42
+ start: time
43
+ end: time
44
+ weeks: frozenset[int] = frozenset() # which weeks of the month (1-5); empty = every week
45
+
46
+ def contains(self, dt: datetime) -> bool:
47
+ if Day(dt.weekday()) not in self.days or not (self.start <= dt.time() < self.end):
48
+ return False
49
+ if self.weeks: # "2nd & 4th Monday" style: nth occurrence of the weekday in the month
50
+ week_of_month = (dt.day - 1) // 7 + 1
51
+ if week_of_month not in self.weeks:
52
+ return False
53
+ return True
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Restriction:
58
+ kind: Kind
59
+ window: Window
60
+ limit_minutes: int | None = None # TIME_LIMIT / PERMIT_EXEMPT_LIMIT
61
+ permit_area: str | None = None # RPP area letter, e.g. "S"
62
+ tow: bool = False # tow-away enforcement on violation
63
+
64
+
65
+ @dataclass
66
+ class SignStack:
67
+ """Everything on one pole."""
68
+ restrictions: list[Restriction] = field(default_factory=list)
69
+ pole_id: str | None = None
70
+
71
+
72
+ class Verdict(Enum):
73
+ TOW_RISK = "tow_risk" # parking now risks a tow
74
+ NO = "no" # no parking (citation risk)
75
+ LIMITED = "limited" # ok up to N minutes
76
+ OK = "ok" # no restriction applies right now
77
+ ABSTAIN = "abstain" # not decidable from the sign stack alone
78
+
79
+
80
+ @dataclass
81
+ class Answer:
82
+ verdict: Verdict
83
+ limit_minutes: int | None = None # for LIMITED
84
+ until: datetime | None = None # next moment the verdict changes (v2)
85
+ reason: str = ""
86
+
87
+
88
+ SEVERITY = [Verdict.TOW_RISK, Verdict.NO, Verdict.LIMITED, Verdict.OK]
89
+
90
+
91
+ def can_park(stack: SignStack, when: datetime, permit_areas: frozenset[str] = frozenset()) -> Answer:
92
+ """The resolver: ground truth for every generated question."""
93
+ verdicts: list[Answer] = []
94
+ for r in stack.restrictions:
95
+ if r.kind is Kind.ANGLE_PARKING:
96
+ continue # informational ("park at 90 degrees"), never affects the verdict
97
+ if not r.window.contains(when):
98
+ continue
99
+ if r.kind in (Kind.NO_STOPPING, Kind.NO_PARKING, Kind.STREET_CLEANING, Kind.LOADING_ONLY):
100
+ v = Verdict.TOW_RISK if (r.tow or r.kind is Kind.NO_STOPPING) else Verdict.NO
101
+ verdicts.append(Answer(v, reason=f"{r.kind.value} in effect"))
102
+ elif r.kind is Kind.TIME_LIMIT:
103
+ verdicts.append(Answer(Verdict.LIMITED, limit_minutes=r.limit_minutes,
104
+ reason=f"{r.limit_minutes}min limit"))
105
+ elif r.kind is Kind.PERMIT_EXEMPT_LIMIT:
106
+ # permit_area must be a hashable scalar; malformed model output (e.g. a list)
107
+ # is treated as "no matching permit"
108
+ if isinstance(r.permit_area, str) and r.permit_area in permit_areas:
109
+ verdicts.append(Answer(Verdict.OK, reason=f"permit {r.permit_area} exempts"))
110
+ else:
111
+ verdicts.append(Answer(Verdict.LIMITED, limit_minutes=r.limit_minutes,
112
+ reason=f"{r.limit_minutes}min limit without permit {r.permit_area}"))
113
+ if not verdicts:
114
+ return Answer(Verdict.OK, reason="no restriction in effect")
115
+ verdicts.sort(key=lambda a: SEVERITY.index(a.verdict))
116
+ best = verdicts[0]
117
+ if best.verdict is Verdict.LIMITED:
118
+ # multiple limits: strictest applies. Tolerate None (malformed model reads).
119
+ limits = [a.limit_minutes for a in verdicts
120
+ if a.verdict is Verdict.LIMITED and a.limit_minutes is not None]
121
+ best.limit_minutes = min(limits) if limits else None
122
+ return best