All work
Case study 03VideoAnalyticsProduction system · 2024

The day the traffic count hit zero

How a city-wide video system stopped dropping frames, and what it took to scale it past five cameras.

PythonGStreamerTurboJPEGRabbitMQYOLOv8ByteTrackMongoDBMySQLDash
Cameras
broke at 5 → 100live RTSP streams in production
Each stream
25 fps, HD~2.5–3 MB per raw frame → 150–200 KB compressed
Frame loss
−30%vs. the OpenCV + RabbitMQ pipeline
Incident to alert
under 2 sfrom the frame to the alarm
My role
Architect & developerdesigned and built the whole system

The job

The brief sounded simple: watch a city's traffic cameras and react on its own. Count vehicles, spot congestion, and raise an alarm the moment there's an accident, a fire or a fight, without a person staring at a wall of screens.

The models were the easy part. YOLOv8 for incidents, ByteTrack to follow vehicles from frame to frame. With one or two cameras on a dev machine, everything worked.

The symptom

Then we added more cameras.

At around five streams, the traffic counts started sliding towards zero.

Roads that were obviously busy reported almost no vehicles. Nothing crashed and no errors were logged. The numbers just quietly became wrong, which is the worst way for a system to fail.

The diagnosis

The model wasn't the problem. Frames were never reaching it.

A tracker like ByteTrack needs a continuous run of frames to follow a car across the junction. When frames go missing, a car can appear once and vanish, and a car seen once is never counted.

EVERY FRAMEcounted ✓FRAMES MISSINGnever counted
Fig. 1 · A car seen once is never countedThe drop in counts was a drop in frames

So the question changed from "why is the model wrong?" to "where are the frames going?"

Two attempts that didn't hold

Attempt 1

OpenCV + Kafka

Read each stream with OpenCV and push frames through Kafka to the inference workers. It moved the frames, but it didn't stop them being lost before they ever got to Kafka. Kafka was later dropped entirely.

Attempt 2

OpenCV + RabbitMQ

I swapped the broker. Same result.

Changing the queue changed nothing, which pointed upstream, to how the frames were being read in the first place.

The root cause: reading video was blocking

OpenCV's VideoCapture.read() is synchronous. Each call blocks until the next frame is decoded. One stream at 25 fps is fine. With many streams, every reader sits waiting on its own decode, falls behind, and the camera's buffer overflows. Frames are silently dropped, or arrive late and stale.

BLOCKING · VideoCapture.read()EVENT-DRIVEN · appsink → new-samplecam 1cam 1cam 2cam 2cam 3cam 3cam 4cam 4cam 5cam 5cam 6cam 6
Fig. 2 · Pulling frames vs. being handed themBlocking I/O falls off a cliff

The bottleneck was never the broker. It was a blocking read loop at the very front of the pipeline.

The turn: GStreamer

GStreamer works the other way round. It is event-driven: you describe a pipeline (source → depay → decode → convert → sink), and it calls you when a frame is ready (appsink → new-sample). Decoding runs inside GStreamer's own threads, and the Python side just receives finished frames.

That one change, from pulling frames to being handed them, fixed the frame loss at the source.

GStreamer isn't magic

It still took tuning to hold up against real city cameras.

  • rtspsrc over TCP with latency=0. Reliable delivery, and no jitter buffer quietly piling up old frames.
  • A fixed frame rate and a queue between decode and the sink, so a slow consumer can't stall the decoder.
  • Self-healing streams. Each pipeline watches its bus; when a camera drops off it is torn down and restarted after a pause. Flaky cameras were tracked and kept out of the main pool.

The bandwidth wall

Fixing the reads exposed the next problem: sheer volume.

The arithmetic100 cameras × 25 frames/s × ~3 MB ≈ 7.5 GB of raw pixels every second

No queue or network link is built for that. So frames are compressed to JPEG at the source with TurboJPEG, before they ever touch the broker. Each frame shrinks from ~3 MB to 150–200 KB, and the fleet goes from ~7.5 GB/s to roughly 0.4–0.5 GB/s. The GPU side decodes them back for inference.

RAW · 100 × 25 fps × ~3 MB≈ 7.5 GB/sCOMPRESSED AT THE SOURCE · TurboJPEG≈ 0.4–0.5 GB/s
Fig. 3 · Compress at the edge~15–20× smaller per frame

The architecture that scaled

An event-driven producer/consumer system, split across servers.

CAMERASRTSPcam 1cam 2cam 3cam 4PRODUCERSGStreamer + JPEGproducer 1producer 2producer 3producer 4RABBITMQqueue per camGPU CONSUMERSYOLOv8 + ByteTrackconsumer 1consumer 2consumer 3consumer 4STOREMongo · MySQLMongoDBMongoDBMySQLMySQLdashboard · alerts with the frame
Fig. 4 · Producers, a queue per camera, consumersOne bad camera can't stall the others
  • Producers (ingest server). One GStreamer process per camera, each decoding, compressing and publishing to its own RabbitMQ queue.
  • Broker. A queue per camera, so the producing and consuming sides scale and fail independently.
  • Consumers (GPU server). One consumer per camera runs YOLOv8 + ByteTrack, and reconnects with a growing wait, up to 30 seconds, if the broker drops.
  • Storage and outputs. Detections, counts and incident frames in MongoDB; stream health and summaries in MySQL; a Dash dashboard per location and alerts with the captured frame.

Making the alarm worth trusting

With frames flowing reliably, the risk flipped from missing incidents to raising false ones. A detector that's right 95% of the time still cries wolf all day across a hundred cameras.

LAST 25 FRAMES4 / 25 agreeALERT · 22 / 25 AGREE
Fig. 5 · Alerts from sequences, not single frames4 / 25 agree

So alerts come from sequences, not single frames: an incident is raised only when 22 of the last 25 frames agree above the confidence threshold, then a cooldown suppresses repeats for the same event. That costs a fraction of a second and removes the one-frame glitches.

Results

Scale

5 → 100 cameras

From a system that broke at five cameras to 100 live HD streams in production.

Frames

30% less frame loss

Than the OpenCV + RabbitMQ pipeline it replaced.

Speed

Under 2 seconds

From incident to alert.

Truth

Counts that match the road

Which was the bug that started all this.

What it taught me

Four things I kept.

01

Check the data path before the model.

When the numbers go quietly wrong, the model may be fine. It just wasn't seeing anything.

02

Swap a component only if it's the bottleneck.

Two broker changes proved the broker wasn't the problem.

03

Blocking I/O falls off a cliff.

It doesn't scale linearly. Five streams worked; six didn't.

04

Compress at the edge.

Moving pixels is the expensive part.

Got a hard problem?

Let's talk about yours.