Skip to content

Repository files navigation

ParkPulse

CI Live demo

Timed parking-bay occupancy from a single fixed camera.

parkpulse-lyart.vercel.app — a full worked example with no backend behind it.

Per-bay state lanes being scrubbed: three bays showing EMPTY / ARRIVING / OCCUPIED / LEAVING over 45 minutes, with the contact, movement and dropout signals that drive them

That is the engine's reasoning, not a rendering of its output. Each lane is one bay second by second; underneath it are the three signals the state machine actually reads. Watch movement fall to nothing before a bay turns OCCUPIED — that is arrival being confirmed by the vehicle coming to rest, and the event's arrival time then being backdated to the first ground-contact, not to the moment it settled. A dropout spike with the lane staying OCCUPIED is the tracker refusing to call a lost detection a departure.

The CI badge means the suites and the regression gate pass. It is not an accuracy badge, and there deliberately isn't one — see Accuracy.

ParkPulse watches a row of parking bays and answers one question precisely:

At what second did a vehicle arrive in this bay, and at what second did it leave?

Not "is this bay occupied right now" — a still-image classifier solves that. ParkPulse produces timed events on a wall-clock timeline, which means it has to be right about edges, to within a few seconds. That is a meaningfully harder problem, and it is the reason this repository exists.


The idea worth reading the code for

A camera watching bays from an oblique angle sees a vehicle's bounding box overlap the bay polygon well before the vehicle is parked in it — the box covers the bay while the car is still in the drive lane, approaching. So the obvious signal, box-overlaps-bay, fires early.

The fix is physical rather than statistical. A vehicle touches the ground at the bottom-centre of its bounding box. That point — the ground-contact point — crosses into the bay polygon at roughly the moment the wheels do.

        ┌───────────────┐
        │               │   box overlaps the bay
        │     ╭───╮     │   while the car is still
        │    ╱     ╲    │   in the lane  →  fires early
        │   │  car  │   │
        │    ╲     ╱    │
        │     ╰───╯     │
        └───────●───────┘   ← ground-contact point:
                            bottom-centre. Enters the bay
    ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁    when the wheels do.

Three things fall out of that one decision:

  • Operators draw the wheel-contact band, not the whole car. The editor teaches this, and overlays the contact dot live so you can see what the engine actually keys on.
  • Little to no per-site tuning, because the signal is geometry that holds for any camera angle, rather than a threshold fitted to one viewpoint.
  • The polygon is small and specific, which makes overlap between adjacent bays rare.

Design decisions

Decision Why
No OCR clock. Wall time = recorded_at + frame_pts. PTS is exact, free, and camera-agnostic. Reading a burned-in clock is per-camera bespoke work that fails silently03-04 is ambiguous between March and April.
ROIs belong to the camera, not the video file. Otherwise re-encoding a site's footage orphans every polygon.
Polygons stored normalized (0..1). 4K and 720p of the same camera are the same bays.
The engine is pure Python — no web, no DB, no network. It is the only directory a reviewer needs to read, and it runs standalone in CI.
Jobs are a database table, claimed atomically. Multi-worker-safe by construction. In-process progress dicts pin you to one worker and orphan jobs on restart.
ONNX Runtime, not torch. Runtime is onnxruntime + opencv + numpy — a 697MB image, measured (docs/DEPLOY.md). Ultralytics is used once, offline, to export the model.
Events get flagged, never deleted. A deleted mis-detection is a lost training signal.
Reads are public. A demo behind a login wall is a demo nobody sees.

Accuracy — what this repo does and does not claim

ParkPulse has not yet published an accuracy figure, and this README will not carry one until the harness in this repo measures it.

Every threshold in engine/parkpulse_engine/profile.py currently ships as a documented placeholder: a reasoned first guess, annotated with what it means physically and how to measure it. They are not validated values.

The mechanism for turning them into real ones is built in, and runs:

  • scripts/make_synthetic_video.py renders a deterministic, licence-free fixture with frame-exact ground truth, so the state machine is tested from day one.
  • engine/parkpulse_engine/eval.py scores predictions against ground truth — recall, false positives, fused/split events, and arrival/departure edge error.
  • scripts/run_eval.py runs a whole dataset end to end and applies a committed gate, so every number is one command away from being regenerated.

What CI enforces today is a regression gate, not an accuracy gate. It renders the fixture, runs the real pipeline over its pixels, and fails the build if an event goes missing, a phantom appears, a changeover fuses, or an edge slips more than one sampling tick. That catches breakage. It cannot tell you the product works, because the scenario is authored — the engine agreeing with it proves the state machine's logic, not its perception.

The one thing standing between this repo and a real number is footage. The runner, the dataset format, the scoring and the CI wiring are all built; see datasets/README.md for what a labelled clip has to look like, and gates/README.md for how the thresholds were set.

See docs/ACCURACY.md for the method and the current state.

Status

The demo is live and the pipeline is deployed. There is still no accuracy figure, and that is the honest state of the project — M3's harness is built and gating in CI, but the number it exists to produce needs footage that does not yet exist.

  • M0 — end-to-end path, engine unit-tested against synthetic fixtures
  • M1 — trace artifact + state-machine visualizer + timeline scrubber
  • M2 — analytics: dwell distribution, hour-of-day heatmap, turnover, overstay
  • M3 — own footage + ground truth + --gate in CI + accuracy badge
    • reproducible runner: dataset → detection → scored report (scripts/run_eval.py)
    • dataset format + labelling protocol + privacy bar (datasets/README.md)
    • regression gate wired into CI, thresholds committed and justified from measurement
    • pre-labelling checklist and first-run expectations (docs/TESTING.md)
    • own footage, hand-labelled ← the blocker; everything below waits on it
    • accuracy figure in docs/ACCURACY.md
    • accuracy gate + accuracy badge
  • M4 — deploy: Vercel + Fly, precomputed fixture, demo GIF
    • frontend live on Vercel in fixture mode — no backend, no cold start
    • API containerised, image built and verified: migrations run, /health green, non-root, volume-backed (docs/DEPLOY.md)
    • README GIF — captured from the live deployment, so it cannot drift from what a visitor actually sees
    • API hosted — config is written and tested, deploy deliberately not run (Fly now requires billing, and phase 1 stands alone). See DEPLOY.md for the runbook and the three things a production detection deployment still needs
  • M5 — in-browser inference (onnxruntime-web) webcam demo; detection-profile A/B diff

Two things the milestone list does not capture, both worth knowing before trusting this:

  • The engine has been run end to end on real footage once, off-repo, on a clip that SPEC §4 excludes from this repository. It worked mechanically — real 1280×720 video at 27× real time, motion gate skipping ~64% of ticks, no phantom events from pedestrians or passing traffic. It also missed events and reported departures early, with unfitted thresholds and hastily drawn bays. No number from that run appears anywhere in this repo, and none should; it is recorded here only because "never tested on real video" would now be false and "validated on real video" would be a lie.
  • Every threshold is still a placeholder (SPEC §5). The gap between the synthetic fixture scoring perfectly and real footage scoring poorly is exactly what docs/ACCURACY.md refuses to paper over.

Architecture

engine/     pure python. numpy + opencv + onnxruntime. no web, no db.
              geometry · detector · motion · tracker · timeline · profile · pipeline · eval
api/        FastAPI. APIRouter per domain. Pydantic v2 is the contract.
worker/     separate process. claims jobs from the table. safe to run N instances.
frontend/   React 19 + Vite + TypeScript + Tailwind.
scripts/    synthetic fixture generator · eval runner · fixture detector · ONNX export
datasets/   labelled footage. empty today — the format, not the data.
gates/      committed quality bars. limits, never results.

Quick start

# engine tests (no model or footage needed — uses synthetic fixtures)
cd engine && pip install -r requirements.txt && python -m pytest -q

# api + worker
cd api && pip install -r requirements.txt
alembic upgrade head
uvicorn parkpulse_api.main:app --reload
python -m worker.main          # in a second terminal

# frontend
cd frontend && npm install && npm run dev

Score the engine against ground truth — the same command CI runs:

python scripts/run_eval.py --synthetic --gate gates/synthetic.json

Add --out DIR to keep predictions.json, report.json, and the fixture it scored.

Testing it on your own footage? Read docs/TESTING.md first. It covers what a clip has to satisfy before it is worth hand-labelling — a camera that never moves, an oblique angle, no headlights into the lens — and what a first run with unfitted thresholds actually looks like.

The frontend runs without a backend: if VITE_API_BASE is unset it renders a bundled precomputed fixture. That is deliberate — the deployed demo has no server behind it.

Privacy

This repository contains no footage today. The test fixture is synthetic — rendered in-process at test time by scripts/make_synthetic_video.py, never committed — which is why the engine suite runs anywhere without a download.

When author-shot footage lands at M3, this is the policy it will be held to: shot on private property, at an oblique side-on angle that renders number plates illegible by construction, with faces blurred before anything is committed. No third-party, customer, or public-space footage will ever be included.

Licence

GNU AGPL-3.0.

This is not incidental. ParkPulse uses Ultralytics YOLOv8 for detection, and both the Ultralytics code and its pretrained weights are AGPL-3.0. Any public, network-accessible service built on them must offer its complete corresponding source under the same terms, or hold a commercial licence from Ultralytics.

Where that stands, precisely. This repository is public and AGPL-3.0, which is what discharges that obligation: anyone using a service built from this source can read the source. That was the point of choosing the licence rather than a consequence of it.

Two clarifications worth keeping straight:

  • The deployed frontend does not engage the network clause on its own. Fixture mode loads no model and runs no inference, so nothing AGPL-covered is being served.
  • The obligation attaches when detection is hosted, and is satisfied because this repository is public. If you fork it private, that obligation follows you — swap the detector for a permissively licensed one (YOLOX, RF-DETR), or buy the Ultralytics commercial licence. Deploying a closed fork and saying nothing is the outcome this section exists to prevent.

If you fork this into something closed, that obligation follows you — swap the detector for a permissively licensed one (YOLOX, RF-DETR) or buy the Ultralytics enterprise licence.

About

Timed parking-bay occupancy from a fixed camera: at what second did a vehicle arrive, and when did it leave?

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages