Search…

Real-Time CV Systems: Tracking, Latency, Streaming

In this series (30 parts)
  1. Computer Vision Roadmap: From Basics to Real Projects
  2. What is Computer Vision? From Pixels to Decisions
  3. A Short History of Computer Vision: 1545 to Now
  4. Math for CV Beginners: Vectors, Matrices, Convolutions
  5. Image Fundamentals: Color, Histograms, Noise, Filtering
  6. How Images and Video Are Stored: Colour, JPEG, Frames
  7. OpenCV Setup + First 10 Tasks in Python
  8. Vision Metrics: Accuracy, Precision, Recall, mAP, IoU
  9. CV Project Workflow: Dataset, Baseline, Error Iteration
  10. Intensity Transforms and Frequency-Domain Filtering
  11. Edge Detection and Thresholding That Actually Work
  12. Morphology, Contours, and Shape Analysis for Real Images
  13. Feature Matching (SIFT/ORB) and Image Stitching
  14. Camera Calibration and Perspective Correction
  15. Epipolar Geometry, Stereo Vision, and Depth Estimation
  16. Optical Flow and Motion Tracking in Video
  17. HOG, HOF and MBH: Descriptors Before Deep Learning
  18. Your First Image Classifier (PyTorch + Transfer Learning)
  19. Data Pipelines and Augmentation for Vision Models
  20. CNN Architectures Explained: From LeNet to ResNet
  21. YOLO Detection Pipeline: Data to Inference
  22. Semantic and Instance Segmentation: U-Net to Mask R-CNN
  23. Vision Transformers (ViT) and When to Use Them
  24. CV Explainability: Grad-CAM, Failures, Bias Checks
  25. 3D Vision Basics: SfM, Point Clouds, and Pose Estimation
  26. Multimodal Vision: CLIP, Embeddings, and Retrieval Systems
  27. Video Understanding: Flow Networks, Interpolation, Stabilisation
  28. Real-Time CV Systems: Tracking, Latency, Streaming
  29. Deploying CV Models: ONNX, TensorRT, Edge, and APIs
  30. Responsible CV: Privacy, Fairness, Security, Governance

A batch job that takes four hours instead of three is mildly annoying. A live camera pipeline that falls 10% behind is a completely different kind of problem: the deficit never clears. After a minute you are two seconds behind, after ten minutes you are twenty, and the vehicle count you publish describes a junction as it was some time ago.

Prerequisites: Optical flow and motion tracking and YOLO object detection.

The job

Eight traffic cameras, 1080p at 30 fps, one GPU. Count vehicles by type and direction, publish counts every second.

8 cameras×30 fps=240 frames per second8 \text{ cameras} \times 30 \text{ fps} = 240 \text{ frames per second}

That number is the whole problem. Everything below is about whether 240 frames per second can be processed, and what to do when it cannot.

The latency budget

Before any code, write down where the time goes.

latency budget A per-frame time allowance broken down by stage, summing to less than the interval between frames. Anything over budget means falling behind permanently.

At 30 fps a frame arrives every:

1000 ms30=33.33 ms\frac{1000 \text{ ms}}{30} = 33.33 \text{ ms}

# Stage Time (ms) target Share Runs on
1 H.264 decode 4.0 13.1% GPU (NVDEC)
2 Resize + normalise 3.0 9.8% GPU
3 Model forward pass 18.0 59.0% GPU
4 NMS + decode boxes 2.0 6.6% GPU/CPU
5 Tracker update 1.5 4.9% CPU
6 Count logic + publish 2.0 6.6% CPU
7 Total 30.5 100%
8 Frame budget at 30 fps 33.33
9 Headroom 2.83 8.5%

Per-frame budget for one stream. 8.5% headroom is uncomfortably thin.

This chart is the most useful artefact in the project. It tells you that shaving 20% off inference buys 3.6 ms, more than the entire tracker and publish stages combined.

One GPU, eight streams

The budget above was for one stream. The GPU is shared.

Not every stage costs the same on every frame if you detect less often. Separate the fixed per-frame cost from the detection cost:

# Cost group Stages ms per frame target
1 Always decode + publish 6.0
2 On detection frames preprocess + inference + NMS 23.0
3 On tracked frames tracker update only 1.5

Splitting the budget by what runs on which frames.

Now compute GPU time per stream per second under different detection intervals:

Detect every frame: 30×(6.0+23.0)=30×29.0=870 ms per second30 \times (6.0 + 23.0) = 30 \times 29.0 = 870 \text{ ms per second} streams per GPU=1000870=1.15\text{streams per GPU} = \frac{1000}{870} = 1.15

Detect every 5th frame: 6×29.0+24×7.5=174+180=354 ms per second6 \times 29.0 + 24 \times 7.5 = 174 + 180 = 354 \text{ ms per second} streams per GPU=1000354=2.82\text{streams per GPU} = \frac{1000}{354} = 2.82

# Detect every N frames Detections/s GPU ms per stream/s Streams per GPU target Counting accuracy
1 1 30 870.0 1.15 99.2%
2 2 15 547.5 1.83 99.1%
3 3 10 440.0 2.27 98.8%
4 5 6 354.0 2.82 97.9%
5 10 3 289.5 3.45 94.1%
6 15 2 268.0 3.73 88.6%

Detect-and-track. Going from every frame to every 5th nearly triples capacity for 1.3 points of counting accuracy.

tracking by detection Running the detector occasionally and using a cheap tracker to follow objects between detections, instead of detecting on every frame.

This is the highest-leverage optimisation in most video systems, and it costs nothing but a config change. Eight streams still need three GPUs at N=5, but that is far better than seven.

Throughput is not latency

Two numbers, often confused, that move in opposite directions when you batch.

throughput How many frames the system finishes per second, in aggregate. latency How long one specific frame waits between arriving and having its result published.
# Batch size Inference (ms) ms per image Throughput (img/s) target Worst-case latency (ms)
1 1 18.0 18.0 55.6 18.0
2 4 41.0 10.3 97.6 41.0
3 8 68.0 8.5 117.6 68.0
4 16 122.0 7.6 131.1 122.0
5 32 231.0 7.2 138.5 231.0

Batching on one GPU. Batch 8 gives 2.1x the throughput of batch 1 and 3.8x the latency.

The reason batching helps at all is that a GPU running batch 1 is mostly idle — kernel launch overhead and memory transfers dominate, and the arithmetic units are underused. Batching amortises that fixed cost.

Since our 8 streams are independent, we can batch across streams rather than delaying frames from one stream. That is the good kind of batching: the batch fills in about 4 ms because frames are arriving from 8 sources at once, so the latency cost is small and the throughput gain is real.

Percentiles, not averages

Measured over an hour on one stream:

# Statistic Latency (ms) target Note
1 mean 28.4 Comfortably under the 33.3 ms budget
2 p50 (median) 27.1 Typical frame
3 p90 34.8 Already over budget
4 p95 41.2 Falling behind on 1 frame in 20
5 p99 68.0 2 frames of delay
6 p99.9 156.0 Nearly 5 frames
7 max 210.0 Something stalled

The mean says everything is fine. The p90 says it is not.

The tail usually comes from garbage collection, memory allocation, another process on the GPU, or a key frame in the video stream that is more expensive to decode. Find the cause of the p99 rather than trying to shave the median.

When you cannot keep up

Arrival rate 30 fps, service rate 28 fps. The deficit is 2 frames per second, and it accumulates.

# Time running Frames arrived Frames processed Queue depth Result age target
1 10 s 300 280 20 0.71 s
2 60 s 1800 1680 120 4.3 s
3 5 min 9000 8400 600 21.4 s
4 30 min 54000 50400 3600 2 min 8 s
5 2 hours 216000 201600 14400 8 min 34 s

A 7% shortfall. After two hours the system reports what happened eight minutes ago, and memory holds 14,400 frames.

backpressure What a system does when work arrives faster than it can be processed. The options are to queue it, to reject it, or to drop it.

For live video, drop. A frame from four seconds ago has no value, and holding it costs memory and delays every frame behind it.

import queue, threading

frames = queue.Queue(maxsize=2)     # tiny on purpose

def capture(cap):
    while True:
        ok, frame = cap.read()
        if not ok: break
        try:
            frames.put_nowait(frame)
        except queue.Full:
            try: frames.get_nowait()   # discard the oldest
            except queue.Empty: pass
            frames.put_nowait(frame)   # keep the newest

The maxsize=2 is the important line. A large queue does not prevent the problem, it hides it — the system appears to keep up for several minutes while quietly building a backlog, then reports results that are minutes stale with no error anywhere.

Little’s Law

A useful sanity check. In a stable system:

L=λWL = \lambda W

where LL is the number of items in the system, λ\lambda the arrival rate, and WW the time each spends inside.

With 8 streams at 30 fps and 30.5 ms per frame:

λ=240 frames/s,W=0.0305 s\lambda = 240 \text{ frames/s}, \qquad W = 0.0305 \text{ s} L=240×0.0305=7.32 framesL = 240 \times 0.0305 = 7.32 \text{ frames}

So roughly 7 frames should be in flight at any moment. If your monitoring shows 400, the system is not stable and the queue is absorbing a deficit you have not noticed yet.

Tracking between detections

The tracker is what makes N=5 work. The cheapest useful version matches detections to existing tracks by IoU.

# Existing track Predicted box New detection IoU target Match?
1 #12 (car) (320, 180, 96, 72) (328, 184, 94, 71) 0.847 yes
2 #13 (van) (510, 150, 120, 96) (516, 152, 118, 95) 0.891 yes
3 #14 (car) (88, 210, 88, 66) — none nearby — 0.000 no, age it
4 — new — (700, 165, 102, 78) create track #15

One tracker update. Two matches, one track ageing out, one new track created.

A track that misses several consecutive detections is deleted. Set that threshold against the detection interval: at N=5 detections come 167 ms apart, so allowing 3 misses tolerates half a second of occlusion.

PropertyIoU trackerSORTDeepSORTByteTrack
Cost per update~0.4 ms~1.5 ms~12 ms~2 ms
Motion modelnoneKalman filterKalman filterKalman filter
Appearance featuresnonoyes (CNN)no
Survives occlusionpoorlybrieflywellwell
ID switches (MOT17)highmoderatelowlow
Good forSparse, fast-movingGeneral defaultCrowded scenesBest general choice now
Trackers by cost. DeepSORT's appearance model costs 8x what ByteTrack does for similar accuracy on most scenes.

ByteTrack’s contribution is worth knowing because it is counterintuitive: it keeps the low-confidence detections that everyone else discards, and uses them in a second matching pass against tracks that failed to match in the first. A partially occluded car often scores 0.3 rather than 0.7, and that low-confidence box is exactly what is needed to keep its ID alive.

Practice task

Use any RTSP stream or a looping video file.

  1. Instrument each stage with timers. Build the budget table for your own hardware.
  2. Compute your frame budget from the source FPS and compare.
  3. Log per-frame latency for 10 minutes and compute p50, p95, p99, and max. Note the gap.
  4. Set the queue to maxsize=1000 and run a deliberately slow model. Watch the result age grow.
  5. Change it to maxsize=2 with drop-oldest. Watch the drop counter instead.
  6. Measure inference at batch 1, 4, 8, 16. Plot throughput and latency together.
  7. Implement detect-every-N with a simple IoU tracker. Measure capacity and counting accuracy for N in 1, 3, 5, 10.
  8. Apply Little’s Law to your running system and check the predicted in-flight count against reality.

Step 4 is the one worth doing badly on purpose. Seeing the system report cheerful, well-formed results that are two minutes stale, with no error and no warning, is the fastest way to understand why the queue must be small.

Summary

Eight 1080p streams at 30 fps is 240 frames per second, and the per-frame budget is 33.33 ms. Ours summed to 30.5 ms, leaving 8.5% headroom, with inference at 59% — the only stage worth optimising first.

Detect-and-track changed capacity more than anything else: every frame gave 1.15 streams per GPU, every 5th gave 2.82, for 1.3 points of counting accuracy. Past N=5 accuracy collapsed to 88.6%.

Batching raised throughput from 55.6 to 117.6 images per second at batch 8, while latency went from 18 ms to 68 ms. Batching across streams instead of within one gets the throughput without most of the latency.

The mean latency of 28.4 ms looked fine while p90 was 34.8 ms, already over budget. A 7% service shortfall put the system 4.3 seconds behind after a minute and 8.5 minutes behind after two hours, with 14,400 frames in memory. Little’s Law says only 7.32 frames should be in flight.

When you cannot keep up, drop the oldest frame and count the drops.

What comes next

The 18 ms inference figure assumed a particular model on a particular GPU. Deploying computer vision models covers how to move that number — ONNX export, TensorRT, and INT8 quantisation with the scale and zero-point arithmetic worked out — and what it costs in accuracy.

Test your understanding
Your pipeline reports 32 FPS on a 30 FPS stream, but the published counts are consistently about 40 seconds behind. What is happening?
Test your understanding
You increase batch size from 1 to 16 and throughput improves from 56 to 131 images per second, but a downstream safety alert now fires noticeably late. Why?

Frequently asked questions

How do I decide the detection interval N?
From how far an object moves between detections relative to its own size. If a car crosses 12 pixels per frame and is 96 pixels wide, then at N=5 it moves 60 pixels between detections, over half its width, and IoU-based matching starts failing. Compute that ratio for the fastest object you care about and pick the largest N that keeps displacement under about a third of the object size.
Should inference run on CPU or GPU?
GPU for anything above a few frames per second at meaningful resolution, and it is not close — a typical detector is 10 to 50 times faster there. CPU is viable for small models on low-resolution input, or when you are detecting every 30th frame on a few streams. The deciding factor is usually not raw speed but whether you can afford a GPU per site, which is what pushes edge deployments toward quantised models on accelerators.
How do I handle cameras that go offline?
Watch the frame timestamp rather than the process. A common failure is that the RTSP read succeeds but returns the same buffered frame forever, so the process is healthy, the FPS counter looks normal, and the data is frozen. Check that the newest frame is less than a couple of seconds old, and if not, tear down the connection and rebuild it rather than trying to recover it.
Is it worth running multiple models on one GPU?
Only with care. Two processes sharing a GPU interleave unpredictably, which is a common source of the p99 latency spikes that no amount of code optimisation removes. Use CUDA streams within one process if you need concurrency, or NVIDIA MPS if you must have separate processes. Simplest of all is one process handling all streams with cross-stream batching, which is also the fastest.
What should I monitor in production?
Frame drop rate, p99 latency, age of the newest processed frame, and detections per minute per camera. The last one catches the failures no technical metric will: a camera nudged by a cleaner, a lens that fogged, a light that failed. Detection counts falling to zero on one camera while everything else is healthy is the signal, and no amount of latency monitoring would have found it.
Start typing to search across all content
navigate Enter open Esc close