Every counting, safety or inspection pipeline we have shipped eventually stops being an algorithm problem and becomes an ingest problem. The detector runs at 90 FPS on the bench. The customer then says "we have 32 cameras in the plant, and the analytics box is already bought." Two weeks later the logs are full of [h264 @ 0x...] error while decoding MB and someone is arguing about whether the missing forklift event was a model failure or a dropped frame.
It was a dropped frame. It usually is.
This tutorial is about the unglamorous half of a production OpenCV 5 deployment: getting many RTSP streams into your process reliably, decoding them on hardware instead of CPU, keeping latency bounded when the network hiccups, and producing a frame budget you can put in front of a customer before you quote the box.
1. Why cv2.VideoCapture(url) stops scaling
cv2.VideoCapture("rtsp://...") is a wonderful five-second demo and a poor production ingest. Three things break as you add cameras:
It decodes on the CPU. The default FFmpeg backend gives you software H.264/H.265 decode. One 1080p25 H.264 stream costs roughly 8–15% of a modern x86 core; 4K H.265 costs three to five times that. At sixteen cameras you have spent most of your machine before a single convolution runs.
It buffers. FFmpeg keeps an internal queue. If your consumer loop is slower than real time — and a detector loop always is, intermittently — read() hands you an older and older frame. Latency grows without bound and nothing in the API tells you. The frame you drew a "person in zone" box on can be eight seconds old.
It fails silently and permanently. When a camera reboots or a switch drops, read() starts returning False and keeps returning False. There is no reconnect. A pipeline that ran for six days and then quietly stopped looking at camera 12 is worse than one that crashed.
All three are fixable, but not by changing flags on VideoCapture. They are fixed by controlling the pipeline.
2. Set the decode path explicitly with GStreamer
Build OpenCV 5 with -DWITH_GSTREAMER=ON and you can hand VideoCapture a full pipeline string instead of a URL. That is the single highest-leverage change in this whole article, because it lets you name the decoder, cap the buffering, and choose the colour conversion.
Confirm the build first — this is the check that saves an afternoon:
import cv2
print(cv2.getBuildInformation())
# look for: GStreamer: YES (1.24.x)
# FFMPEG: YES
A CPU-decode baseline with bounded latency:
PIPELINE = (
"rtspsrc location={url} protocols=tcp latency=200 "
"drop-on-latency=true ! "
"rtph264depay ! h264parse ! avdec_h264 ! "
"videoconvert ! video/x-raw,format=BGR ! "
"appsink max-buffers=1 drop=true sync=false"
)
cap = cv2.VideoCapture(PIPELINE.format(url=url), cv2.CAP_GSTREAMER)
Three elements of that string matter more than the rest:
protocols=tcp— RTP over UDP loses packets on a congested plant network, and lost packets are exactly the corrupt-macroblock errors in your logs. TCP costs a little latency and removes a whole class of artefacts.appsink max-buffers=1 drop=true— this is the fix for unbounded latency. The pipeline keeps one frame. If your loop is slow, GStreamer drops old frames rather than queueing them, so whatread()returns is always the newest available. You trade completeness for freshness, which is the right trade for live analytics and the wrong trade for forensic recording. Know which one you are building.sync=false— do not throttle to the stream clock; hand frames over as soon as they exist.
Hardware decode, per platform
Swap the decoder element and the decode leaves the CPU entirely:
NVIDIA dGPU (NVDEC): rtph264depay ! h264parse ! nvh264dec ! cudadownload ! videoconvert
NVIDIA Jetson (L4T): rtph264depay ! h264parse ! nvv4l2decoder ! nvvidconv ! video/x-raw,format=BGRx
Intel (VA-API/QSV): rtph264depay ! h264parse ! vaapih264dec ! vaapipostproc ! video/x-raw,format=BGRx
Rockchip / ARM SoC: rtph264depay ! h264parse ! mppvideodec ! videoconvert
Measured on a mid-range x86 box with an entry-level NVIDIA card, sixteen 1080p25 H.264 streams went from roughly 210% CPU (software avdec_h264) to under 25% CPU with nvh264dec. That is the difference between "we need a second server" and "we do not".
One caveat that catches people: the download from GPU memory can cost more than the decode you just saved. If your inference also runs on the GPU, keep the frame there. cv2.cuda_GpuMat plus a CUDA-aware pipeline, or a full DeepStream/VPI path, avoids a per-frame round trip over PCIe. Only pull to BGR host memory when a human or a CPU-side algorithm truly needs it.
3. One thread per camera, one queue per camera
Never call read() for several cameras in one loop. A single slow stream then stalls every other stream. The pattern that has survived every deployment we have done is a reader thread per camera that owns its capture and publishes into a depth-1 slot:
import threading, time
import cv2
class CameraReader(threading.Thread):
"""Owns one capture. Always exposes the newest frame. Reconnects forever."""
def __init__(self, cam_id, pipeline, backoff_max=30.0):
super().__init__(daemon=True)
self.cam_id = cam_id
self.pipeline = pipeline
self.backoff_max = backoff_max
self._lock = threading.Lock()
self._frame = None
self._stamp = 0.0
self._stop = threading.Event()
self.stats = {"frames": 0, "reconnects": 0, "last_ok": 0.0}
def latest(self, max_age=1.0):
"""Newest frame, or None if it is stale (camera is effectively down)."""
with self._lock:
if self._frame is None or time.monotonic() - self._stamp > max_age:
return None
return self._frame, self._stamp
def run(self):
backoff = 1.0
while not self._stop.is_set():
cap = cv2.VideoCapture(self.pipeline, cv2.CAP_GSTREAMER)
if not cap.isOpened():
self.stats["reconnects"] += 1
time.sleep(backoff)
backoff = min(backoff * 2, self.backoff_max)
continue
backoff = 1.0
fail = 0
while not self._stop.is_set():
ok, frame = cap.read()
if not ok:
fail += 1
if fail > 15: # ~0.5 s of nothing at 25 fps
break # tear down and reconnect
time.sleep(0.02)
continue
fail = 0
now = time.monotonic()
with self._lock:
self._frame, self._stamp = frame, now
self.stats["frames"] += 1
self.stats["last_ok"] = now
cap.release()
self.stats["reconnects"] += 1
def stop(self):
self._stop.set()
Design points worth stating explicitly, because they are the ones reviewers query:
- Depth-1, last-write-wins. The reader never blocks on the consumer. A slow detector causes dropped frames, never growing latency.
latest()can returnNone. Staleness is a first-class state. Your analytics loop should emit acamera_stalehealth event, not silently reuse a five-minute-old frame and keep reporting "zone clear".- Exponential backoff with a ceiling. Thirty-two cameras hammering a rebooting NVR with reconnects is a self-inflicted denial of service.
- Monotonic clocks.
time.time()jumps when NTP corrects the box; every latency metric you derive from it becomes fiction. - The GIL is not the problem here.
cap.read()releases it during decode and copy, so reader threads genuinely overlap. What does hurt is doingcv2.resizeor colour conversion inside the reader thread — keep readers dumb.
At high camera counts, prefer processes over threads (one process per 8–12 cameras, frames shared via multiprocessing.shared_memory or written straight to GPU memory) so that one crashed decoder cannot take down the whole ingest.
4. Do not run the detector on every frame of every camera
Thirty-two cameras at 25 FPS is 800 frames per second. Nobody's detector does that, and almost no application needs it. The scheduler is where your headroom actually comes from:
Batch across cameras, not across time. Collect the newest frame from N cameras and run one inference with batch N. On GPU this is close to free up to the point of saturation — batch 8 typically costs 1.6–2.2× a batch of 1, not 8×, so effective throughput rises three- to fourfold.
Give each camera a rate it deserves. A loading-bay counter needs 10 FPS. A perimeter camera watching an empty corridor needs 2 FPS until motion appears. Drive per-camera rates from the use case and from cheap motion gating (a downscaled frame difference or an MOG2 background model costs microseconds and can suppress 80% of inference on quiet scenes).
Degrade on a policy, not by accident. When the box is overloaded, decide in code which cameras lose frames. An explicit priority list is defensible in a review; whichever thread happens to win the GPU is not.
def schedule(readers, budget_fps):
"""Yield (cam_id, frame) respecting per-camera target rates and a global budget."""
next_due = {r.cam_id: 0.0 for r in readers}
interval = 1.0 / budget_fps
while True:
now = time.monotonic()
due = [r for r in readers if now >= next_due[r.cam_id]]
due.sort(key=lambda r: (-PRIORITY[r.cam_id], next_due[r.cam_id]))
batch = []
for r in due[:MAX_BATCH]:
got = r.latest(max_age=1.0)
if got is None:
health.mark_stale(r.cam_id)
next_due[r.cam_id] = now + 1.0
continue
frame, stamp = got
batch.append((r.cam_id, frame, stamp))
next_due[r.cam_id] = now + 1.0 / TARGET_FPS[r.cam_id]
if batch:
yield batch
time.sleep(interval)
5. The frame budget you quote from
Before you size hardware, write down the per-camera cost of each stage. Measure it; do not estimate it. A worked example for 1080p H.264 on a small x86 + entry GPU box:
| Stage | Per frame | Per camera @ 25 FPS | 16 cameras |
|---|---|---|---|
| NVDEC decode | 1.1 ms GPU | 27 ms/s GPU | 44% of one NVDEC engine |
| GPU→host download + BGR | 2.4 ms | 60 ms/s CPU | 0.96 core |
| Motion gate (downscaled) | 0.3 ms | 7 ms/s | 0.12 core |
| Detector @ 8 FPS/cam, batch 8 | 6.2 ms/frame in batch | 50 ms/s GPU | 0.8 s GPU per second ⚠ |
| Tracking + zone logic | 0.9 ms | 7 ms/s | 0.12 core |
Two things fall out of that table immediately. The detector is at 80% GPU utilisation, which is the real limit — not decode. And the GPU→host download costs more than twice the decode, which is the argument for keeping frames on device. The table is also the artefact that makes a scoping conversation honest: "sixteen cameras at 8 FPS each fits this box at ~80% GPU; twenty-four does not, and here is the line."
Our pipeline profiling guide covers how to measure each of those numbers properly with cv2.TickMeter, CUDA events and Nsight rather than wall-clock guesses.
6. Health, not just throughput
A multi-camera ingest needs an observable per-camera state or you will be debugging by SSH forever. Export at minimum, per camera:
frames_received_totalandframes_dropped_totalreconnects_total(a slow climb is a failing PoE switch or a dying camera; a step change is a network event)frame_age_seconds— now minus the newest frame's stamp; alert above 2 sdecode_errors_totalfrom the GStreamer businference_fpsper camera, against its configured target
Then set the alarms on the ones that mean something: frame_age_seconds > 2 for 30 s, or reconnects_total rising more than three times in five minutes. "The analytics were down all weekend and nobody knew" is the failure mode these metrics exist to prevent, and it is the one customers remember.
Two operational habits worth building in from day one: log the camera's own timestamp alongside your receive timestamp (they drift, and forensic questions need both), and record a short rolling buffer of the raw stream for any camera that fires an event. When someone disputes an event three weeks later, the clip settles it in a minute.
7. A short checklist before you deploy
- OpenCV 5 built with
WITH_GSTREAMER=ON; verified ingetBuildInformation(). - Hardware decode element chosen per target platform and confirmed with a CPU measurement, not assumed.
protocols=tcpon everyrtspsrcunless you have proven UDP is clean.appsink max-buffers=1 drop=true sync=false— freshness over completeness for live analytics.- One reader thread (or process group) per camera, depth-1 slot, reconnect with capped exponential backoff.
- Staleness is an explicit state and an alert, never a silently reused frame.
- Per-camera target FPS, motion gating, batched inference across cameras, explicit degradation priority.
- A measured frame-budget table before the hardware is quoted.
- Per-camera health metrics exported and alarmed.
- Monotonic clocks everywhere latency is computed.
Need this built or reviewed?
Multi-camera ingest is where a promising proof of concept usually meets its real constraints: a fixed box, a shared network and cameras nobody will move. SentientSight's senior OpenCV consultants have built and rescued these pipelines across manufacturing, logistics, retail and transport — sizing the hardware honestly, choosing the decode path, and leaving behind a frame budget and health dashboard your team can operate.
If you have a camera count and a box and you are not sure the two are compatible, get in touch with the stream count, resolution, codec and target platform and we will tell you where the wall is before you buy.