Counting bicycles with Frigate and a doorbell
/ 9 min read
Updated:Table of Contents
I wanted a count of how many bicycles pass the house. There’s a UniFi G4 Doorbell Pro already pointed at the street, so the camera was solved — everything else needed building.
This is the setup and the things that caught me out.
Why not just use Protect
UniFi Protect’s smart detection covers person, vehicle, animal and package. There’s no bicycle class, and the vehicle model is trained on cars and trucks, so cyclists don’t register.
Frigate detects bicycle natively — it’s a COCO class, third in the model’s labelmap after __background__ and person. So Protect stays as camera and NVR, and Frigate does detection.
The architecture
flowchart TB DOOR["🚪 G4 Doorbell Pro<br/>192.168.200.87 · VLAN 200"] UDM["🛡️ UDM · Protect controller<br/>RTSPS on 192.168.1.1:7441"] FRIG["🧠 Frigate on k3s<br/>detect 960x720 @ 10fps"] GPU["⚡ Iris Xe iGPU<br/>OpenVINO · ~7ms inference"] PVC["📸 Snapshots<br/>local-path PVC on the node"] NAS["🎞️ Recordings · Synology NFS<br/>/volume1/data/frigate-recordings<br/>2.5 TB free"] MQTT["📡 Mosquitto<br/>LXC 105 · VLAN 100"] HA["🏠 Home Assistant<br/>counter + statistics"] DOOR -->|"camera feeds the NVR"| UDM UDM -->|"rtsps token per quality"| FRIG FRIG <-->|"inference"| GPU FRIG -->|"one JPEG per object"| PVC FRIG -->|"only event footage"| NAS FRIG -->|"frigate/events"| MQTT MQTT --> HA
Frigate runs on the k3s node because that’s where the passed-through Intel Iris Xe iGPU lives. It shares that GPU with Jellyfin via the Intel device plugin, which needs -shared-dev-num=2 — the node otherwise advertises exactly one gpu.intel.com/i915, Jellyfin claims it, and Frigate sits Pending forever. It’s time-sharing, not partitioning, but one camera plus occasional transcoding is comfortable.
Everything is deployed by Flux from a git repo. Stream URLs come from 1Password through External Secrets — a Protect RTSPS URL embeds a token that’s the only thing between the network and a live view of the front door, so it never goes in git. MQTT uses a dedicated frigate broker user rather than the shared account Home Assistant and Zigbee2MQTT both authenticate with.
The streams
Measured rather than taken from the spec sheet, which was worth doing — I’d assumed a doorbell shoots portrait with a narrow field of view, and it doesn’t:
| Stream | Resolution | Bitrate | Continuous |
|---|---|---|---|
| High | 1600×1200 @ 30 | ~0.81 Mbps | 8.8 GB/day |
| Medium | 960×720 @ 30 | ~0.26 Mbps | 2.8 GB/day |
Both 4:3 landscape. Each quality has its own token and URL — they’re separate streams, not a parameter.
Detection resolution
Standard Frigate advice is to detect on a low-resolution substream and keep the high stream for recording. That advice assumes CPU decode and a lot of cameras. With one camera and an iGPU, it’s worth reconsidering, because Frigate doesn’t feed whole frames to the model — it finds motion regions, crops them, and resizes each crop to the model’s 300×300 input. For a small or distant object, a crop from 1600×1200 carries more detail than the same crop from 960×720. A bike on the far side of the road is exactly that case.
Moving detection to the high stream also makes the substream redundant, so two connections collapse into one input carrying both roles:
flowchart TB BEFORE["❌ Before · two RTSP connections"] BM["📹 Medium 960x720<br/>role: detect"] BH["🎞️ High 1600x1200<br/>role: record"] BCROP["🔍 crops resized from 960x720"] BCPU["⚙️ 0.56 cores"] BEFORE --> BM BEFORE --> BH BM --> BCROP BCROP --> BCPU BH --> BCPU AFTER["✅ After · one RTSP connection"] AH["🎞️ High 1600x1200<br/>roles: detect + record"] ACROP["🔍 crops resized from 1600x1200"] ACPU["⚙️ 0.58 cores"] AFTER --> AH AH --> ACROP ACROP --> ACPU
CPU before the switch, sampling /proc for eight seconds:
| Process | CPU | Scales with resolution? |
|---|---|---|
| ffmpeg (detect pipeline) | ~14% | yes |
frigate.process | 5.5% | yes |
frigate.capture | 4.6% | yes |
| ffmpeg (record, stream-copy) | ~4% | no |
| detector / output / embeddings | ~21% | no |
| total | 0.56 cores of 6 |
2.78× the pixels on the ~24% that scales suggested about 1.0 core. The actual figure afterwards was 0.58 cores — essentially flat, because dropping the second connection paid for the extra decode. Inference went 6.26 ms → 7.22 ms against a 100 ms budget at 10 fps, with zero skipped frames.
Update, 5 August 2026: this was reverted the same evening. Detection runs on the medium substream at 960×720 again, and the high stream keeps an explicit record role — explicit because of the role-assignment gotcha below.
The crop reasoning above still holds: Frigate resizes each crop, so source resolution genuinely does help small and distant objects, and detecting on the substream gives up some recall. It just wasn’t the binding constraint here. Of 47 confirmed-missed bikes, 46 were already ≥43 px wide (median 75, max 155) — only one sat in the band where the extra pixels would have mattered. Recall was limited by the model, not the resolution. Part 2 covers what actually fixed it.
CPU was never the motivation either way: measured 0.58 cores detecting on High against 0.56 on Medium.
Storage
Recordings go to the Synology over NFS. The two ends of that mount are worth naming separately, because they look like different things when you go looking for the files:
| Path | |
|---|---|
| On the Synology | /volume1/data/frigate-recordings (the data share) |
| Inside the container | /media/frigate/recordings |
The container path is mounted inside the media PVC — so snapshots and thumbnails stay on local disk where the UI reads them constantly, and only the part that grows without bound leaves the node.
That split exists because of two things that combine badly:
local-path enforces no quota. A PVC claiming 10Gi is a label, not a limit — nothing stops a pod writing until the node’s root disk is full. Adding up every PVC on my node: 93 Gi claimed, 8.5 GB actually used. Wildly overcommitted and completely fine, until something writes video.
Frigate’s disk cleanup reads filesystem free space, not its volume’s. Its storage API reported 53.28 GB used of 95.85 GB — the whole node disk, not its 10Gi claim. That disk also holds Prometheus, paperless, Jellyfin and k3s state, so filling it doesn’t degrade a bike counter, it takes down the cluster. At 8.8 GB/day for the high stream — and that’s a quiet scene, a busy one is 2–4× — that’s under a week.
The Synology has 2.5 TB free, which removes the problem entirely. One useful detail: that export doesn’t root_squash, so Frigate writes as root directly and none of the runAsUser handling my paperless backup job needs applies here.
Retention keeps only footage attached to a review item — continuous and motion both zero, alerts and detections at 30 days. A street generates near-constant motion, so a rolling buffer would dwarf the part anyone actually watches. Bicycles land in detections; Frigate’s default alert labels are person and car.
Gotchas
Protect serves streams from the controller, not the camera
Probing the doorbell’s IP on the RTSP ports gave refusals on 7441 and 7447 with 443 open. The obvious reading is “RTSP isn’t enabled in Protect”, since Protect doesn’t open its RTSP listener until a camera has streaming switched on.
What said otherwise was the timing: both failed in 0 seconds, not after the 8-second timeout. An instant failure is a TCP RST — the packet arrived and the host said nothing is listening. A timeout is a firewall dropping it silently. Inter-VLAN is deny-by-default on my network, so I’d expected a firewall problem, and the RST ruled it out.
The streams live on the UDM at 192.168.1.1:7441. The camera’s own IP only serves a web interface. Probing the camera and concluding RTSP was off would have meant tuning settings that were already correct.
Worth separating connection refused from connection timed out rather than collapsing both into “it didn’t work”.
A healthy pod doesn’t mean a working detector
The first deploy came up 1/1 Running with every probe green, ExternalSecret synced, PVCs bound, GPU claimed. It was detecting nothing — the detector process had died at startup:
TypeError: stat: path should be string, bytes, os.PathLike or integer, not NoneTypeThe Frigate docs say the bundled OpenVINO model “is used by this detector type by default”. It isn’t — without an explicit model: block the path resolves to None. The fix is six lines:
model: width: 300 height: 300 input_tensor: nhwc input_pixel_format: bgr path: /openvino-model/ssdlite_mobilenet_v2.xml labelmap_path: /openvino-model/coco_91cl_bkgr.txtNothing flagged it because Frigate’s health endpoint is served by nginx, which is up whether or not a detector exists. Liveness passed, readiness passed, the rollout reported success, and a server-side dry-run had validated every manifest.
The check that actually works is /api/stats, which reports inference_speed per detector. No inference speed, no detection, regardless of pod status.
Frigate assigns the record role itself
My config declared one input — the medium substream — with a single role, detect. The running config reported:
roles: ['record', 'detect']Frigate attaches the record role somewhere if you don’t, which is reasonable. But it meant that enabling recordings later would have recorded 960×720 while I believed I’d chosen 1600×1200. Nothing was broken and nothing warned.
Worth querying /api/config on the running instance rather than reading the file you wrote — the file is what you asked for, the API is what you got.
The Prometheus alert already existed
I’d planned a custom alert on node disk free %. kube-prometheus-stack already ships NodeFilesystemAlmostOutOfSpace and the predictive NodeFilesystemSpaceFillingUp, and node-exporter was already scraping the node’s root filesystem. Writing my own would have duplicated them.
Where it stands
Detection runs on the medium substream at 960×720, 10 fps, zero skipped frames, ~6 ms inference. Recordings land on the NAS, events flow to MQTT, everything is in git and deployed by Flux.
What’s untested is whether a doorbell mounted by the front door sees enough of the street for the counts to mean anything. A bike crosses the frame in well under a second. The motion mask isn’t tuned and the thresholds are deliberately loose.
That gets answered by riding a bike past the house about ten times while watching the debug view, rather than waiting for passers-by — passive observation shows what Frigate caught but not what it missed, and recall is the whole question. Twelve events in a day means nothing unless you know whether twelve bikes passed or forty.
If bikes come back labelled person — the common small-model failure where a cyclist reads as a human and the bicycle box gets dropped — that’s a model swap rather than a config change, and the iGPU has headroom for YOLOv9 or RF-DETR. If they barely appear in frame at all, the doorbell is the wrong sensor for this and a second camera aimed at the street is the answer.
They did come back labelled person — 40 of 49 passes over 11 hours. Part 2 is the model swap, the counting layer in Home Assistant, and a non-maximum-suppression fix that worked offline and did nothing in production.