Real-Time CV Systems: Tracking, Latency, Streaming
In this series (30 parts)
- Computer Vision Roadmap: From Basics to Real Projects
- What is Computer Vision? From Pixels to Decisions
- A Short History of Computer Vision: 1545 to Now
- Math for CV Beginners: Vectors, Matrices, Convolutions
- Image Fundamentals: Color, Histograms, Noise, Filtering
- How Images and Video Are Stored: Colour, JPEG, Frames
- OpenCV Setup + First 10 Tasks in Python
- Vision Metrics: Accuracy, Precision, Recall, mAP, IoU
- CV Project Workflow: Dataset, Baseline, Error Iteration
- Intensity Transforms and Frequency-Domain Filtering
- Edge Detection and Thresholding That Actually Work
- Morphology, Contours, and Shape Analysis for Real Images
- Feature Matching (SIFT/ORB) and Image Stitching
- Camera Calibration and Perspective Correction
- Epipolar Geometry, Stereo Vision, and Depth Estimation
- Optical Flow and Motion Tracking in Video
- HOG, HOF and MBH: Descriptors Before Deep Learning
- Your First Image Classifier (PyTorch + Transfer Learning)
- Data Pipelines and Augmentation for Vision Models
- CNN Architectures Explained: From LeNet to ResNet
- YOLO Detection Pipeline: Data to Inference
- Semantic and Instance Segmentation: U-Net to Mask R-CNN
- Vision Transformers (ViT) and When to Use Them
- CV Explainability: Grad-CAM, Failures, Bias Checks
- 3D Vision Basics: SfM, Point Clouds, and Pose Estimation
- Multimodal Vision: CLIP, Embeddings, and Retrieval Systems
- Video Understanding: Flow Networks, Interpolation, Stabilisation
- Real-Time CV Systems: Tracking, Latency, Streaming
- Deploying CV Models: ONNX, TensorRT, Edge, and APIs
- 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.
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:
| # | 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:
Detect every 5th frame:
| # | 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.
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.
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.
flowchart LR A["Camera<br/>30 fps"] --> B["Queue<br/>maxsize=2"] B -->|"full"| X["Drop oldest<br/>increment counter"] B --> C["Batch across streams"] C --> D["GPU inference"] D --> E["NMS + decode"] E --> F["Tracker"] F --> G["Counting logic"] G --> H["Publish"] X -.->|"drop rate > 5%"| I["Alert"]
Little’s Law
A useful sanity check. In a stable system:
where is the number of items in the system, the arrival rate, and the time each spends inside.
With 8 streams at 30 fps and 30.5 ms per frame:
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.
| Property | IoU tracker | SORT | DeepSORT | ByteTrack |
|---|---|---|---|---|
| Cost per update | ~0.4 ms | ~1.5 ms | ~12 ms | ~2 ms |
| Motion model | none | Kalman filter | Kalman filter | Kalman filter |
| Appearance features | no | no | yes (CNN) | no |
| Survives occlusion | poorly | briefly | well | well |
| ID switches (MOT17) | high | moderate | low | low |
| Good for | Sparse, fast-moving | General default | Crowded scenes | Best general choice now |
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.
- Objects move fast relative to their size and a tracker would lose them
- You must not miss a single event, such as a safety trigger
- You have the GPU budget for it
- Objects appear and disappear frequently rather than persisting
- Objects persist across many frames — track instead and save 3x
- You are GPU-constrained and accuracy has headroom
- The scene is crowded, where detection is expensive and tracking is cheap
- Publishing is aggregated per second anyway, so per-frame detection adds nothing
Practice task
Use any RTSP stream or a looping video file.
- Instrument each stage with timers. Build the budget table for your own hardware.
- Compute your frame budget from the source FPS and compare.
- Log per-frame latency for 10 minutes and compute p50, p95, p99, and max. Note the gap.
- Set the queue to
maxsize=1000and run a deliberately slow model. Watch the result age grow. - Change it to
maxsize=2with drop-oldest. Watch the drop counter instead. - Measure inference at batch 1, 4, 8, 16. Plot throughput and latency together.
- Implement detect-every-N with a simple IoU tracker. Measure capacity and counting accuracy for N in 1, 3, 5, 10.
- 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.
- Write the latency budget first: stage times must sum to under the frame interval.
- Optimise by share of budget. Inference at 59% is worth four times what the tracker at 5% is.
- Detect every Nth frame and track between — 2.5x the capacity for about a point of accuracy.
- Past N=5 the tracker loses objects between detections and accuracy falls sharply.
- Batching raises throughput and raises latency. Batch across streams, not within one.
- Report throughput and p99 latency together. Neither number substitutes for the other.
- The mean hides everything: 28.4 ms mean, 68 ms p99, 210 ms max on the same stream.
- A sustained shortfall has no steady state — 7% slow means 8.5 minutes behind after 2 hours.
- Keep the queue at 1 or 2 and drop the oldest frame. A big queue hides staleness rather than fixing it.
- Little's Law: L = λW. If in-flight frames far exceed the prediction, you are already behind.
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.