System designHow-to

Multiple camera streams in a grid with per-camera output folders

Tile many camera feeds into one window with a simple grid rule, run detection on full-resolution frames, and save every camera's results to its own folder.

LumaSpec Vision11 min read Share on LinkedIn

On this page 10 sections
  1. 01What you need
  2. 02Step 1: size the grid from camera count and monitor
  3. 03Step 2: open one capture thread per camera
  4. 04Step 3: run the model at full resolution, then paste one canvas
  5. 05Step 4: write every result into the camera's own folder
  6. 06Step 5: prove the layout with fake frames
  7. 07Hardware: ports, decode and disk
  8. 08Common mistakes that stack the windows
  9. 09When one process per camera is the better layout
  10. 10Questions

Run one detection model on several cameras and the problem arrives immediately: every imshow call opens its own window, the windows stack on top of each other, and every detection lands in one shared folder where you cannot tell which camera found what. This how-to fixes both in one pass. You compute a grid from the camera count, paste each camera's newest frame into a single canvas sized to your monitor, and give every camera its own output folder keyed by its ID. The worked layout is nine cameras on a 1920 × 1080 monitor; the same arithmetic rescales to six, twelve or any count. Everything runs in one Python process with OpenCV, on any vision controller with enough Ethernet ports.

What you need

You need three inputs decided up front: the camera count, one stable ID per camera, and the monitor size you design for. Concretely:

  • Python 3.9 or newer with the opencv-python and numpy packages
  • One source per camera: an RTSP URL for IP cameras, or a device index or serial number for USB and GigE models
  • The monitor size to design for — the worked example assumes 1920 × 1080
  • One Ethernet port per uncompressed camera, or one switch whose uplink covers the RTSP streams
  • Disk for annotated saves; step 4 puts a number on it

Everything runs in one Python process. The entry controller below is the class of machine this code assumes — fanless, camera ports on board, memory enough for a light detection model.

Vision controllersHikrobot MV-VC2140-128G40-1T VC2100 Vision Controller, N97, 8GB+128G SSD+ 1THDDMV-VC2140-128G40-1T
LAN ports
3-4 LAN ports
USB 3 ports
2 USB 3 ports
Light outputs
4 built-in channels
Memory
8 GB
$1,679.00View partBuild a system with it

Step 1: size the grid from camera count and monitor

Work out the layout before opening a capture, because these numbers decide everything after:

  1. Count the cameras: N.
  2. Set cols = ceil(sqrt(N)) and rows = ceil(N / cols).
  3. Set tile_w = screen width / cols and tile_h = screen height / rows.
  4. Compare each camera's resolution with its tile; the ratio is the display scale factor.
cols = ceil(sqrt(N)) rows = ceil(N / cols) tile_w = floor(screen_w / cols) tile_h = floor(screen_h / rows) scale = min(tile_w / frame_w, tile_h / frame_h)

Nine cameras on a 1920 × 1080 monitor: cols = ceil(sqrt(9)) = 3 and rows = ceil(9 / 3) = 3, so a 3 × 3 grid. Each tile is 1920 / 3 = 640 px wide and 1080 / 3 = 360 px tall, so the canvas lands at exactly 1920 × 1080 and the window fills the screen without rescaling. Six cameras: cols = ceil(sqrt(6)) = ceil(2.45) = 3, rows = ceil(6 / 3) = 2 — a 3 × 2 grid on the same 640 × 360 tiles.

Mixed resolutions only change the per-tile scale. A 12 MP camera such as the LS-CMR-0166 delivers 4000 × 3000 frames. Its tile scale is min(640 / 4000, 360 / 3000) = min(0.16, 0.12) = 0.12, so the tile shows 480 × 360 (4000 × 0.12 = 480, 3000 × 0.12 = 360) with (640 − 480) / 2 = 80 px black bars each side. A 16:9 camera at 2560 × 1440 gives scale = min(0.25, 0.25) = 0.25 and fills its tile edge to edge.

Commit to the tile sizes: 640 × 360 for nine cameras on 1080p, 480 × 360 for twelve, where cols = ceil(sqrt(12)) = 4 and rows = ceil(12 / 4) = 3 makes tile_w = 1920 / 4 = 480 and tile_h = 1080 / 3 = 360 — which a 4:3 camera fills exactly. The scale also explains the one rule of this whole build: at 0.12, a 100 px part shrinks to 100 × 0.12 = 12 px, where small-object detection falls apart. Detection runs on the full-resolution frame in step 3; tiles are display only.

Step 2: open one capture thread per camera

Blocking reads are the reason grids freeze, so isolate each camera:

  1. Map every camera ID to its source string.
  2. Start one daemon thread per camera that reads in a loop.
  3. Keep only the newest frame, guarded by a lock.
  4. Sleep briefly when a read fails, so an offline camera does not spin a core.
import time
import threading
import cv2  # OpenCV's documented Python module

class CameraGrabber:
    '''One background reader per camera; keeps only the newest frame.'''
    def __init__(self, cam_id: str, source: str):
        self.cam_id = cam_id                  # 'cam_01' — also the folder name
        self.cap = cv2.VideoCapture(source)   # documented OpenCV call
        self.lock = threading.Lock()
        self.frame = None                     # newest BGR frame (ndarray)
        threading.Thread(target=self._loop, daemon=True).start()

    def _loop(self) -> None:
        while True:
            ok, frame = self.cap.read()
            if not ok:
                time.sleep(0.05)              # offline or reconnecting
                continue
            with self.lock:
                self.frame = frame

    def latest(self):
        with self.lock:
            return self.frame

cap.read() blocks. Read nine cameras in sequence and one stalled RTSP source stops the whole wall for its reconnect timeout; one thread per camera confines the stall to a single tile that shows its ID and waits. Keeping only the newest frame also keeps display latency flat — the same principle as the two-camera grab-and-process pattern and the fix for acquisition that outruns processing.

Use the serial number as cam_id where the SDK exposes it, not the enumeration index: the ID names the output folder in step 4, and selecting multiple cameras by serial number is the same discipline.

Step 3: run the model at full resolution, then paste one canvas

The display loop does one thing per frame:

  1. Collect each grabber's newest frame into a dict keyed by cam_id.
  2. Run detection on the full-resolution frame and draw boxes on a copy.
  3. Resize the annotated frame into its tile and paste it into the canvas slot.
  4. Call imshow once for the whole canvas.
import math
import numpy as np

TILE_W, TILE_H = 640, 360                      # from step 1
CAM_IDS = [f'cam_{i:02d}' for i in range(1, 10)]
cols = math.ceil(math.sqrt(len(CAM_IDS)))      # 9 -> 3
rows = math.ceil(len(CAM_IDS) / cols)          # 9 -> 3

def tile_of(frames: dict) -> np.ndarray:
    '''frames: cam_id -> full-resolution BGR frame, or None when offline.'''
    canvas = np.zeros((rows * TILE_H, cols * TILE_W, 3), dtype=np.uint8)
    for index, cam_id in enumerate(sorted(frames)):
        r, c = divmod(index, cols)
        x, y = c * TILE_W, r * TILE_H
        frame = frames[cam_id]
        if frame is None:
            cv2.putText(canvas, f'{cam_id} offline', (x + 8, y + 28),
                        cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2)
            continue
        annotated = detect_and_draw(frame)     # your model, full resolution
        h, w = annotated.shape[:2]
        scale = min(TILE_W / w, TILE_H / h)
        small = cv2.resize(annotated, (round(w * scale), round(h * scale)))
        canvas[y:y + small.shape[0], x:x + small.shape[1]] = small
        cv2.putText(canvas, cam_id, (x + 8, y + 28),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2)
    return canvas

while True:
    frames = {cid: grabbers[cid].latest() for cid in CAM_IDS}
    cv2.imshow('grid', tile_of(frames))        # one window, never nine
    if cv2.waitKey(1) == ord('q'):
        break

Here grabbers is the dict of CameraGrabber instances from step 2 and detect_and_draw is your own model wrapper; cv2.resize, cv2.putText, cv2.imshow and cv2.waitKey are documented OpenCV calls. Drawing boxes before resizing means they land on the objects automatically — no coordinate transforms.

Pasting first and resizing later is what kills naive attempts: nine 4000 × 3000 frames pasted raw make a 12000 × 9000 canvas, and 12000 × 9000 × 3 B ≈ 324 MB per frame. Resized first, the canvas is 1920 × 1080 × 3 ≈ 6.2 MB per update; at 15 fps that is 93 MB/s of memory copies, which any controller of the class below absorbs.

Step 4: write every result into the camera's own folder

Folders keyed by cam_id remove the collisions for free:

  1. Create a dated folder per camera at startup, for example runs/cam_01/20250617.
  2. Build file names from the camera ID plus time.time_ns(), which never repeats.
  3. Append one CSV row per detection and save each box as a crop.
  4. Save the annotated full-resolution frame only when it contains detections.
from pathlib import Path
import time

def save_detections(cam_id: str, frame, detections) -> None:
    out_dir = Path('runs') / cam_id / time.strftime('%Y%m%d')
    crops = out_dir / 'crops'
    crops.mkdir(parents=True, exist_ok=True)   # runs/cam_01/20250617/crops
    stamp = time.time_ns()                     # strictly increasing
    cv2.imwrite(str(out_dir / f'{cam_id}_{stamp}.jpg'), frame)
    with open(out_dir / 'detections.csv', 'a') as f:
        for box in detections:                 # your detection output fields
            f.write(f'{stamp},{box.label},{box.x0},{box.y0},{box.x1},{box.y1}\n')
            crop = frame[box.y0:box.y1, box.x0:box.x1]
            cv2.imwrite(str(crops / f'{cam_id}_{stamp}_{box.label}.jpg'), crop)

Two cameras cannot collide because their paths start with different IDs, and one camera cannot collide with itself because time.time_ns() is monotonic — so no file locking anywhere. label, x0 and the other box fields are your detection output; everything else is documented Python.

Budget the disk from the JPEG size, not the sensor size. A 1 MP annotated frame at quality 85 is typically 250-400 KB; take 350 KB. At two saves per second per camera: 2 × 350 KB = 700 KB/s per camera, nine cameras 6.3 MB/s, and 6.3 × 3600 ≈ 22,700 MB ≈ 23 GB per hour. Saving only frames with detections plus crops keeps most lines well under that. Writing every frame at full rate is a different problem — multi-camera recording without dropped frames covers it, and the hardware-trigger grab loop that saves each frame shows the save-per-frame pattern this extends.

Step 5: prove the layout with fake frames

Test the geometry with the cameras unplugged:

  1. Build a dict of black 4000 × 3000 frames for all nine IDs.
  2. Call tile_of on it.
  3. Assert the canvas shape and a green ID label in every tile.
  4. Repeat for six and twelve cameras with their tile sizes.
def test_grid_nine() -> None:
    frames = {f'cam_{i:02d}': np.zeros((3000, 4000, 3), dtype=np.uint8)
              for i in range(1, 10)}
    canvas = tile_of(frames)
    assert canvas.shape == (1080, 1920, 3)     # 3 rows x 360, 3 cols x 640
    for index in range(9):
        r, c = divmod(index, 3)
        tile = canvas[r * 360:(r + 1) * 360, c * 640:(c + 1) * 640]
        assert tile[:, :, 1].max() == 255      # green ID label in every tile

The green-channel check works because every filled tile carries its green cam_id label while the black test frames contribute nothing. If this passes, the geometry is right before any network, camera or model variable exists. Re-run with range(1, 7) for the 3 × 2 case, and with TILE_W, TILE_H = 480, 360 and range(1, 13) for the twelve-camera 4 × 3 case — the same assertions should pass.

Hardware: ports, decode and disk

Compressed RTSP streams are light on the wire and heavy on the CPU. A 1080p H.264 stream at 15-25 fps typically carries 2-8 Mbit/s, so nine cameras put 18-72 Mbit/s on the network — one 1 Gbit/s switch uplink carries the worst case with about 14× headroom. Decode is the cost: software decoding of 1080p H.264 takes roughly a quarter of a modern core per stream, so nine streams consume 2-3 cores before the model runs. A current quad-core controller holds the wall and still leaves cores for a light detector; heavier models want the larger class below.

If the wall itself must appear on a shop-floor HMI panel rather than in your Python window, hand the RTSP streams to the panel — showing RTSP camera streams on an HMI panel walks that side of it.

Uncompressed area scan cameras invert the bottleneck. A 2560 × 1440 Mono8 frame is 2560 × 1440 × 1 B ≈ 3.69 MB, so 15 fps is 55.3 MB/s ≈ 442 Mbit/s per camera — a full GigE port each, nothing shared. Count LAN ports before camera count.

Size the controller from port count, PoE for the camera side, RAM for frame buffers and disk for step 4's saves.

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 MV-VC5525-256G70-NN carries 5+ LAN ports with PoE camera ports, 8 built-in light channels and 32 GB of memory. Five ports take five uncompressed cameras directly; in the nine-camera RTSP build, one port reaches the switch while PoE feeds the camera side.

Vision controllersHikrobot MV-VC4712-256G10-2TH Ultra 7 255H, 16G×2+256GSSD+2THDD, PCIe expansion, Without VM license keyMV-VC4712-256G10-2TH
LAN ports
3-4 LAN ports
USB 3 ports
2 USB 3 ports
PoE
PoE camera ports
Light outputs
6 built-in channels
$6,049.00 · quotedView and quoteBuild a system with it

Where saves run every shift, disk decides: the MV-VC4712-256G10-2TH pairs 16 GB of memory with a 2 TB HDD beside its SSD.

At the step 4 rate, that 2 TB drive holds 2000 GB / 23 GB/h ≈ 87 hours — about three and a half days of continuous saves before an offload.

Common mistakes that stack the windows

  1. One imshow call per camera — that is the overlapping symptom itself. Assemble one canvas, show one named window.
  2. Detection on the tile — at the 0.12 scale of step 1 a 100 px part is 12 px. Detect at full resolution and resize the annotated frame.
  3. Sequential cap.read() across cameras — one stalled RTSP source freezes the wall. One thread per camera, newest frame only.
  4. A shared folder with timestamp-only names — two cameras in the same second overwrite each other. Key folders by cam_id, stamp files with time.time_ns().
  5. Capture properties set every loop iteration — configure once at open; the safe pattern is in camera parameters in a capture loop.
  6. Iterating camera dicts in insertion order — enumerate over sorted(cam_id) so tiles keep their positions when one camera drops.

When one process per camera is the better layout

One process is right while the cameras share a host, a model and one operator. Split into one process per camera — the one-camera-per-process pattern — when cameras sit on different hosts, when a crashed model must not blank the whole wall, or when different lines run different models and cadences. Nothing about the folder scheme changes: each process writes into its own runs folder keyed by its cam_id exactly as in step 4, and a thin viewer that tiles each camera's newest saved frame rebuilds the grid from files alone. The inverse layout — one camera split into several displayed ROIs — reuses the same paste arithmetic, as in splitting one camera image into multiple ROIs.

To spec the box before you wire the cameras, compare port counts in the vision controllers category, filter area scan cameras by resolution and interface, or send your camera list and frame-rate targets through the quote request. More capture-and-display guides sit on the systems topic page.

Questions

How many cameras can one grid window stay readable for?

On a 1920 × 1080 monitor, 3 × 3 is the practical limit: 640 × 360 tiles still show a person or a pallet clearly. A 4 × 4 layout drops tiles to 480 × 270 — workable for scene awareness, tight for detail. Past nine cameras, either move to a 3840 × 2160 monitor, which gives 768 × 432 tiles at 5 × 5, or page between two grids.

Should detection run on the tile or the original frame?

Always the original frame. In the worked example the 4000 px wide frame lands on its tile at 0.12 scale, turning a 100 px part into 12 px — small defects disappear at that size. Detect at full resolution, draw the boxes, then resize the annotated frame into the tile; the boxes land on the objects with no coordinate math. Only if you draw after resizing do you multiply box coordinates by the tile scale and add the padding offset.

How do I stop two cameras writing files with the same name?

Give every camera its own folder keyed by its ID and build file names inside that folder from the ID plus time.time_ns(). Per-camera folders mean two writers never touch one path, so no lock is needed, and the nanosecond stamp never repeats within one camera's stream. A shared folder with second-resolution timestamps does collide: two cameras saving in the same second overwrite each other.

How much disk do annotated saves need per hour?

Estimate from the JPEG size, not the sensor: a 1 MP annotated frame at quality 85 is typically 250-400 KB. At 350 KB and two saves per second, one camera writes 0.7 MB/s, nine cameras 6.3 MB/s, and 6.3 × 3600 ≈ 23 GB per hour. Saving only frames that contain detections usually lands far below that figure; continuous full-rate recording is a different budget.

Do tiles have to keep the camera's aspect ratio?

Keep it when you can. Scaling with min(tile_w / frame_w, tile_h / frame_h) preserves proportions and letterboxes the remainder: a 16:9 stream fills a 640 × 360 tile exactly, while a 4:3 stream leaves 80 px bars each side. Stretching to fill distorts anything you draw afterwards, because horizontal and vertical scale factors no longer match the original boxes.