System designHow-to

Display multiple IP camera streams in one window without overlap

Display multiple IP camera streams in one window without overlap: a fixed cell grid, per-camera threads, and snapshot logging to your server on each violation.

LumaSpec Vision9 min read Share on LinkedIn

On this page 9 sections
  1. 01Why five streams overlap
  2. 02The tile rule: one canvas, one cell per camera
  3. 03Worked example: five 1080p cameras on a 1920 × 1080 window
  4. 04Build it: one thread per camera, one paste per frame
    1. The compose loop, with a test
  5. 05Save a snapshot on every violation
  6. 06Get the log to your server
  7. 07When one window is the wrong answer
  8. 08Checks before you run it
  9. 09Questions

Five RTSP cameras on one screen should be a layout problem, not a rewrite. Give each camera its own cv2.imshow call and the operating system stacks five windows into a pile — the overlapping screens you are seeing. The detector is not at fault; the presentation is. This guide shows you how to display all five feeds in one window with a fixed grid, run one capture thread per camera, and save a timestamped snapshot to your server every time the model flags a missing helmet. The build is about an hour of work.

Why five streams overlap

cv2.imshow opens one native window per window name. Five cameras with five names hand the OS five windows to place wherever it likes, and on most desktops they land on top of each other. Separate capture, detection and presentation. Detection loops stay per camera; rendering happens once: a single NumPy canvas, every camera drawn into its own cell, one cv2.imshow call per loop with one window name. One canvas cannot overlap itself, and the loop rate becomes a single number you can measure and log.

The tile rule: one canvas, one cell per camera

Decide the grid from the camera count. Columns are the ceiling of √N, rows absorb the remainder: five cameras want ceil(√5) = 3 columns and ceil(5 / 3) = 2 rows. Divide the canvas by the grid to size the cell, then fit each frame inside its cell with one scale factor so nothing spills into the neighboring rectangle.

cols = ceil(sqrt(N)) rows = ceil(N / cols) cell_w = canvas_w / cols cell_h = canvas_h / rows scale = min(cell_w / frame_w, cell_h / frame_h)

Paste by slice, not by stacking windows: cell (row, col) occupies rows row × cell_h through (row + 1) × cell_h and columns col × cell_w through (col + 1) × cell_w, and the resized frame is written into exactly that rectangle. Every rectangle is disjoint by construction, so overlap is impossible. If all frames share one resolution you can compose the same canvas with np.hstack and np.vstack; the slice paste also takes streams at mixed sizes.

Worked example: five 1080p cameras on a 1920 × 1080 window

Inputs: N = 5 cameras (your helmet line); native stream 1920 × 1080 at 15 fps, H.264 at about 4 Mbit/s (common camera defaults — read the real values from each camera's stream settings page); one canvas of 1920 × 1080 px on the monitoring PC. Apply the tile rule, then check the two budgets the grid rides on: link bandwidth and decode CPU.

cols = ceil(√5) = ceil(2.236) = 3; rows = ceil(5 / 3) = 2. Cell: 1920 / 3 = 640 px wide, 1080 / 2 = 540 px tall. Scale: min(640 / 1920, 540 / 1080) = min(0.333, 0.500) = 0.333, so each camera lands as a 640 × 360 px tile inside its 640 × 540 slot. Bandwidth: 5 × 4 Mbit/s = 20 Mbit/s = 2.5 MB/s, about 2% of the roughly 940 Mbit/s a 1 GbE link carries in practice. Decode: five 1080p H.264 streams at 15 fps cost about one to two modern cores (estimate; hardware decode lowers it further).

Decision: one Ethernet port carries all five streams with 98% of the link free, so do not add network cards for bandwidth. Spend the attention on CPU: one capture thread per camera, the detector on the remaining cores, and stream rates raised on the camera's page only as CPU allows. Moving the cameras to 1080p30 doubles the link load to about 4% but roughly doubles decode cost — that is the point to step up to a 32 GB, Core i5-class host.

Build it: one thread per camera, one paste per frame

  1. Collect one RTSP URL per camera; the camera's web page lists the exact stream path.
  2. Force TCP transport before opening. OpenCV's FFMPEG backend reads capture options from an environment variable; set the RTSP transport to tcp there. Default UDP loses packets on a busy switch and shows gray blocks.
  3. Start one daemon thread per camera. Sequential cap.read() calls across five cameras age frames by hundreds of milliseconds; a thread per camera that keeps only its latest frame keeps every feed current.
  4. Size the canvas once: 1920 × 1080, 3 columns, 2 rows, slots of 640 × 540 px.
  5. Each loop, build a fresh canvas, resize every frame to 640 × 360 and paste it at (row × 540, col × 640). One cv2.imshow, one cv2.waitKey per loop.
  6. On a missing-helmet detection, crop the person and hand the JPEG to the logging step below.

A recent desktop shows this grid without strain. On a production line the same job belongs on a vision controller, which adds industrial ports and light outputs in one box. The MV-VC3301H-128G66 brings 5+ LAN ports, an Intel Core i3 and 8 GB of memory.

Vision controllersHikrobot MV-VC3301H-128G66 I3-8100, 8GB+128G SSD, No PoE, PCIe expansion supported, Incl. VM6200Pro licenseMV-VC3301H-128G66
LAN ports
5+ LAN ports
USB 3 ports
3-4 USB 3 ports
Light outputs
4 built-in channels
Memory
8 GB
$2,419.00View partBuild a system with it

When the IP cameras take power over Ethernet and the detector is heavier, the MV-VC5525-256G70-NN pairs a 12th-generation Intel Core i5 with 32 GB of memory, PoE camera ports and 8 built-in light channels.

Vision controllersHikrobot MV-VC5525-256G70-NN VC5000 Vision Controller, 12th-I5, 32GB+256G SSD, Supported PCIEMV-VC5525-256G70-NN
LAN ports
5+ LAN ports
USB 3 ports
3-4 USB 3 ports
PoE
PoE camera ports
Light outputs
8 built-in channels
$4,479.00 · quotedView and quoteBuild a system with it

The compose loop, with a test

import math
import threading
import numpy as np
import cv2

STREAMS = [                                # list[str]: one RTSP URL per camera
    'rtsp://192.168.1.11/stream',
    'rtsp://192.168.1.12/stream',
    'rtsp://192.168.1.13/stream',
    'rtsp://192.168.1.14/stream',
    'rtsp://192.168.1.15/stream',
]                                          # illustrative addresses, not real cameras
N = len(STREAMS)                           # int = 5
CANVAS_W, CANVAS_H = 1920, 1080            # int, the single full-HD window
COLS = math.ceil(math.sqrt(N))             # int = 3
ROWS = math.ceil(N / COLS)                 # int = 2
CELL_W = CANVAS_W // COLS                  # int = 640
CELL_H = CANVAS_H // ROWS                  # int = 540
frames = [None] * N                        # list[ndarray or None]: latest frame per camera

def grab(url, slot):                       # one daemon thread per camera
    cap = cv2.VideoCapture(url)            # RTSP transport forced to TCP before this
    while True:
        ok, frame = cap.read()
        if ok:
            frames[slot] = frame           # latest frame wins, no queue buildup

for i, url in enumerate(STREAMS):
    threading.Thread(target=grab, args=(url, i), daemon=True).start()

while True:
    canvas = np.zeros((CANVAS_H, CANVAS_W, 3), dtype=np.uint8)
    for i in range(N):
        if frames[i] is None:
            continue
        r, c = divmod(i, COLS)
        h, w = frames[i].shape[:2]         # 1080, 1920 for these streams
        scale = min(CELL_W / w, CELL_H / h)
        size = (round(w * scale), round(h * scale))   # (640, 360) here
        cell = cv2.resize(frames[i], size)
        y, x = r * CELL_H, c * CELL_W
        canvas[y:y + size[1], x:x + size[0]] = cell
    cv2.imshow('Helmet line', canvas)
    if cv2.waitKey(1) == 27:               # Esc quits
        break

The test is the disjointness itself:

assert canvas.shape == (CANVAS_H, CANVAS_W, 3)     # one canvas, full HD
assert canvas[0:360, 0:640].any()                  # camera 1 fills cell (0, 0)
assert canvas[0:360, 640:1280].any()               # camera 2 fills cell (0, 1)
assert not canvas[540:1080, 1280:1920].any()       # slot 6 is empty and untouched

Camera 1 occupies rows 0 to 360 and columns 0 to 640 exactly. If any tile were drawn over a neighbor, the empty sixth cell would hold non-zero pixels and the last assert would fail.

Save a snapshot on every violation

When the detector returns a box around a head without a helmet, crop with about 20% padding so the shoulders stay in frame, then encode with cv2.imencode('.jpg', roi), which returns the JPEG bytes. Build the file name from camera number and timestamp and post the bytes to your logging endpoint.

import time
import requests

ok, jpg = cv2.imencode('.jpg', roi)       # roi: uint8 H x W x 3 crop of the person
if ok:
    stamp = time.strftime('%Y%m%d_%H%M%S')
    name = f'no_helmet_cam{i}_{stamp}.jpg'
    requests.post(SERVER_URL,             # str: your logging endpoint
                  files={'snapshot': (name, jpg.tobytes())},
                  data={'camera': str(i), 'time': stamp}, timeout=2)

A 640 × 360 JPEG runs about 40-80 KB. At 20 violations per hour that is 20 × 60 KB ≈ 1.2 MB per hour, roughly 0.86 GB per month on a line running around the clock. Snapshots cost little; full-frame video does not, so keep video out of the event path unless policy requires it. Give each camera its own folder on the server — the per-camera output folder layout keeps five cameras' snapshots separable — and the remote snapshot URL guide shows how the cameras themselves can serve a still for spot checks.

Get the log to your server

Three paths, in order of simplicity. HTTP POST from the application, as above: one call carries the JPEG, camera id and timestamp; keep the timeout at a couple of seconds and retry from a queue so a slow server never stalls the display loop. Second, write the JPEGs into a watched network folder and let the server ingest. Third, when events and snapshots accumulate across shifts, the smart camera data logging guide works out the sustained-traffic math.

Source cameras fit this grid two ways: a machine-vision camera feeding frames to the PC, or one of the smart cameras that inspects on the device. The MV-CU020-90GC is a 2.5 MP color GigE area scan camera with a global shutter: frames arrive through the SDK and the paste step is unchanged. The MV-SC5060XM-08M-WBN inspects on the camera itself — 6 MP on GigE with an M12-mount lens — so the PC shows results only.

Area scan camerasHikrobot MV-CU020-90GC 2.5 MP Area Scan Camera, GigE, GMAX4002, ColorMV-CU020-90GC
Sensor format
1/1.7"
Lens mount
C-mount
$309.00View part
Smart cameras & vision sensorsHikrobot MV-SC5060XM-08M-WBN SC5000X series Smart Camera, 6 MP, Mono, 8 mm focal length, M12-mountMV-SC5060XM-08M-WBN
Interface
GigE
Resolution
6-10 MP
$1,629.00View part

When one window is the wrong answer

Three cases call for a different layout. If each camera must be inspected at full frame rate, treat the tiled view as a status board: the inspection pipelines run per camera with one acquisition object each, the one-camera-per-process pattern. If operators watch from a shop-floor panel rather than a Python window, many HMI panels take RTSP directly — the panel RTSP setup guide walks through it, and the HMI-screen version of this layout compares the options. SCADA dashboards can pull the same feeds through a proxy module for IP camera streams. And if any camera rides on Wi-Fi, shared airtime replaces the switch as the limit; the Wi-Fi camera bandwidth math shows how fast that budget spends.

Checks before you run it

  • Loop period: time the compose loop and keep it under 70 ms (about 14 fps) for flicker-free monitoring.
  • Frame age: store time.monotonic() with each frame; an age above 0.5 s means the link or detector is backing up, not the display.
  • Gray or green blocks: UDP loss — switch the RTSP transport to TCP, then read the switch port's error counters.
  • A dead camera: paste a timestamped placeholder into its cell so an empty slot is visible, never silent.
  • One test event: post a snapshot and confirm camera id and timestamp arrive on the server before the line goes live.

If you are specifying hardware for a new monitoring cell, the camera finder and the system configurator are the starting points, and contact us gets you a build list to quote. The other display and multi-camera layouts sit on the systems topic page.

Questions

How many IP cameras fit in one OpenCV window before the tiles get too small?

Cell size follows the grid: nine cameras give 3 × 3 tiles of 640 × 360 px on a 1920 × 1080 canvas, still readable for PPE checks. Sixteen cameras drop the tiles to 480 × 270 and twenty-five to 384 × 216, where a helmet strap is hard to judge. Past about nine to twelve feeds, move to a 4K canvas or split the cameras across two named windows.

Why do my RTSP frames arrive gray, blocky or half green in OpenCV?

UDP packet loss. OpenCV's FFMPEG backend requests RTSP over UDP by default; on a busy switch some packets never arrive and the decoder fills the gaps with gray. Force the RTSP transport to TCP in the capture options before opening the stream, and confirm the camera's port links at 100 or 1000 Mbit/s. If blocks persist on TCP, lower the camera's bit rate or frame rate on its stream page.

How much storage does a year of helmet-violation snapshots need?

Rate × size × time. At 20 violations per hour and 60 KB per JPEG, a line running around the clock writes about 864 MB per month, so roughly 10 GB per year — reserve 20 GB and the folder never fills. Bursts barely move the total: even 100 violations in one hour add only about 6 MB.

Can I run each camera in a separate process instead of a thread?

Yes, and it isolates faults: one process per camera plus a small display process that collects the latest frames is a common pattern for larger installations. The cost is plumbing — shared memory or sockets to move frames to the compositor. With five streams, threads are simpler; move to processes when a camera SDK can crash its neighbors or you need per-camera restarts.

Does the same grid work for machine-vision GigE cameras?

The layout math is identical, but the source is not RTSP: GigE Vision cameras deliver frames through their SDK or a GenICam source, and one process holds one camera. Open the five streams with the camera SDK, convert each frame to NumPy, and paste into the same canvas; exposure and gain stay set per camera as usual.