+1 (415) 360-7596

Small objects in 4K frames: tiled (SAHI-style) inference in OpenCV 5 without losing the seams

Almost every drone, rail, solar-farm and wide-area inspection project we get called into arrives with the same complaint: "the model works on the crops, but it misses everything on the real frames." The client shows a 4K or 8K image, a defect that occupies 18 x 14 pixels, and a detector that was trained and validated on 640 x 640 tiles where that same defect filled a quarter of the image.

Nothing is wrong with the model. The problem is that the standard inference path resizes a 3840 x 2160 frame down to 640 x 640 before the first convolution. That is a 6x linear downscale: an 18-pixel defect becomes 3 pixels, smaller than the stride of the network's shallowest detection head. It is not a detection failure, it is a sampling failure — the object was destroyed before inference began.

The fix is sliced (tiled) inference: cut the big frame into overlapping windows, run the detector at native resolution on each one, map the boxes back to full-frame coordinates, and merge. The technique is usually associated with SAHI (Slicing Aided Hyper Inference), but you do not need a framework — OpenCV 5 plus a few dozen lines gives you a tiling layer you can tune, profile and defend.

This tutorial builds that layer end to end, and — more importantly — shows how to decide the tile size and overlap from the client's own pixel budget rather than by guessing.

1. Decide tiling with arithmetic, not vibes

Before writing code, compute the object's size in network pixels. Two numbers decide everything:

scale   = network_input_size / tile_size
px_net  = px_native * scale

A detector with a 640 x 640 input reliably finds objects at roughly 16 px and up in network space; below about 10 px, recall collapses on almost any anchor-free or anchor-based head. So:

  • Frame 3840 x 2160, object 18 px, whole-frame inference: scale = 640/3840 = 0.167, giving 3 px. Hopeless.
  • Same frame, 960 x 960 tiles: scale = 640/960 = 0.667, giving 12 px. Marginal.
  • Same frame, 640 x 640 tiles (no resize at all): scale = 1.0, giving 18 px. Workable.

That arithmetic also tells you the cost. A 3840 x 2160 frame at 640 x 640 tiles with 20% overlap is 7 x 5 = 35 tiles, i.e. 35 forward passes per frame instead of one. At 12 ms per pass on a mid-range GPU that is 420 ms — 2.4 FPS. If the client expects 15 FPS on live video, tiling alone will not deliver it, and you need to say so in the first week, not the last. Section 6 covers the ways out.

Overlap should be at least the largest expected object dimension, plus a margin, expressed as a fraction of tile size:

overlap_ratio >= (max_object_px + 16) / tile_size

With 640 px tiles and objects up to 64 px, that is (64 + 16)/640 = 0.125, so a 0.15–0.20 overlap is sensible. Too little overlap and objects straddling a seam get cut into two half-detections, each below threshold. Too much and your tile count — and your bill — climbs for nothing.

2. Generate the tile grid

import cv2
import numpy as np

def tile_grid(width, height, tile=640, overlap=0.2):
    """Return a list of (x0, y0, x1, y1) windows covering the frame."""
    step = int(tile * (1.0 - overlap))
    xs = list(range(0, max(width - tile, 0) + 1, step))
    ys = list(range(0, max(height - tile, 0) + 1, step))
    # Ensure the right/bottom edges are always covered
    if xs[-1] + tile < width:
        xs.append(width - tile)
    if ys[-1] + tile < height:
        ys.append(height - tile)
    return [(x, y, x + tile, y + tile) for y in ys for x in xs]

windows = tile_grid(3840, 2160, tile=640, overlap=0.2)
print(len(windows), "tiles")   # 35

The edge-snapping step matters. A naive range() grid silently drops a strip along the right and bottom of every frame, and in aerial work that is exactly where half the field of view lives. Snapping the final tile back from the edge produces a slightly larger overlap there and zero blind spots — far better than padding, which introduces a synthetic black border the model has never seen in training.

3. Batch the tiles through the OpenCV 5 DNN engine

OpenCV 5's reworked DNN engine takes ONNX models and batches cleanly. Build one blob per batch of tiles rather than one per tile — the per-call overhead is real, and on GPU backends a batch of 8 is typically 3–4x faster than eight single inferences.

net = cv2.dnn.readNetFromONNX("detector.onnx")
net.setPreferableBackend(cv2.dnn.DNN_BACKEND_CUDA)
net.setPreferableTarget(cv2.dnn.DNN_TARGET_CUDA_FP16)

BATCH = 8
INPUT = 640

def run_tiles(frame, windows, conf_thres=0.25):
    boxes, scores, class_ids = [], [], []

    for i in range(0, len(windows), BATCH):
        chunk = windows[i:i + BATCH]
        crops = [frame[y0:y1, x0:x1] for (x0, y0, x1, y1) in chunk]
        blob = cv2.dnn.blobFromImages(
            crops, scalefactor=1 / 255.0, size=(INPUT, INPUT),
            swapRB=True, crop=False)
        net.setInput(blob)
        outputs = net.forward()          # (B, 84, N) for a YOLO-style head

        for b, (x0, y0, _, _) in enumerate(chunk):
            pred = outputs[b].T          # (N, 84): cx, cy, w, h, then class scores
            cls_scores = pred[:, 4:]
            best = cls_scores.argmax(axis=1)
            conf = cls_scores[np.arange(len(best)), best]
            keep = conf > conf_thres
            if not keep.any():
                continue

            # tile pixels -> full-frame pixels
            sx = (x0 + 0.0, INPUT / (chunk[b][2] - x0))
            tile_w = chunk[b][2] - x0
            tile_h = chunk[b][3] - y0
            fx, fy = tile_w / INPUT, tile_h / INPUT

            for (cx, cy, w, h), s, c in zip(pred[keep, :4], conf[keep], best[keep]):
                boxes.append([x0 + (cx - w / 2) * fx,
                              y0 + (cy - h / 2) * fy,
                              w * fx, h * fy])
                scores.append(float(s))
                class_ids.append(int(c))

    return np.array(boxes, dtype=np.float32), np.array(scores, dtype=np.float32), np.array(class_ids)

The coordinate transform is where most tiling implementations quietly break. Two rules keep it honest: apply the tile-to-input scale factor before adding the tile origin, and keep boxes in float until the very end. Rounding per tile accumulates a one-to-two pixel bias that will wreck any downstream measurement work.

Sanity-check it once, visually, with a single synthetic object of known position before you trust any recall number.

4. Merge detections across seams

Plain NMS over the merged list is a decent first pass but has a known failure mode with tiling: an object cut by a seam produces two partial boxes with low IoU to each other, and both survive. Three mitigations, in increasing order of effort:

Drop boxes that touch a tile edge when the tile is interior. If a detection's box is flush against a seam and that seam is not the frame boundary, the same object has a better-centred detection in the overlapping neighbour tile.

def is_edge_box(box, window, frame_shape, margin=4):
    bx, by, bw, bh = box
    x0, y0, x1, y1 = window
    H, W = frame_shape[:2]
    touches = (abs(bx - x0) < margin and x0 > 0) or \
              (abs(bx + bw - x1) < margin and x1 < W) or \
              (abs(by - y0) < margin and y0 > 0) or \
              (abs(by + bh - y1) < margin and y1 < H)
    return touches

Use class-aware NMS, not global. OpenCV 5 exposes cv2.dnn.NMSBoxesBatched, which takes class ids and suppresses only within a class — essential when a defect sits on top of a component.

idx = cv2.dnn.NMSBoxesBatched(boxes.tolist(), scores.tolist(),
                              class_ids.tolist(), 0.25, 0.45)
final = [(boxes[i], scores[i], class_ids[i]) for i in np.array(idx).flatten()]

Use Soft-NMS or box fusion for fragmenting objects. Where seam fragments are common (long cracks, cables, weld seams), replace hard suppression with weighted box fusion: cluster boxes with IoU above a loose threshold and average their corners, weighted by score. It consistently beats NMS on elongated classes, at the cost of a few milliseconds in Python.

5. Add a full-frame pass for the big objects

Tiles find small objects and lose large ones: an object spanning three tiles is never fully visible in any of them. The standard answer is a hybrid — one downscaled whole-frame inference alongside the tiled passes, merged into the same NMS stage.

full_boxes, full_scores, full_ids = run_tiles(frame, [(0, 0, W, H)], conf_thres=0.3)
boxes = np.vstack([tile_boxes, full_boxes])

Give the full-frame pass a slightly higher confidence threshold; downscaled inference produces more low-confidence noise, and you do not want it polluting the tiled results. If your object sizes span a wide range, a second tile scale (say 1280 px tiles resized to 640) is often cheaper than it looks: it adds only 6–8 tiles and covers the middle of the size distribution.

6. Making it fast enough to ship

Thirty-five forward passes per frame is a budget problem, and there are four levers worth pulling before you tell the client to buy a bigger GPU:

  • Region-of-interest tiling. Most frames are mostly background. A cheap pre-pass — frame differencing, a coarse low-resolution saliency or segmentation model, or a fixed inspection mask — typically reduces the active tile set by 60–80%. On a static camera this is the single biggest win, and it costs under 2 ms.
  • Temporal scheduling. On video with slow-moving content you rarely need every tile every frame. Run the full grid once per second and, in between, only tiles that contained a detection recently or showed motion. Document the latency this adds to first detection; that number belongs in the acceptance criteria.
  • INT8 quantisation. A 640 x 640 detector at INT8 on an NPU or TensorRT engine typically runs 2.5–4x faster than FP16, and tiling multiplies that saving by 35. Our INT8 quantisation walkthrough covers the calibration set design that keeps the accuracy loss signable.
  • Overlap pruning. Re-measure the real object-size distribution after a month of production data. Teams routinely pick 25% overlap defensively and discover the largest real object is 40 px; dropping to 10% removes six tiles per frame for free.

7. Prove the gain, do not assume it

Tiling is not free accuracy. It increases false positives — a background texture that looks harmless at 1/6 scale can look exactly like a defect at native resolution — and it increases latency. Measure both on a held-out set of full-size frames, never on crops:

ConfigurationRecall (small objects)Precisionms/frame
Whole frame, 6400.210.8812
640 tiles, 20% overlap0.790.71418
640 tiles + full pass + WBF0.830.80430
ROI-gated tiles (INT8)0.810.7974

Numbers like these, from the client's own data, are what turn an argument about frameworks into a decision. Report recall split by object size bucket — under 16 px, 16–32 px, over 32 px — because a single aggregate recall figure hides exactly the regression tiling is meant to fix. Then wire the whole thing into a golden-frame regression test so a future model swap cannot silently undo it; our pipeline regression testing guide has the harness.

Getting help with high-resolution detection

Tiled inference is simple to prototype and surprisingly easy to get subtly wrong: dropped edge strips, off-by-one coordinate transforms, seam fragments inflating the false-positive rate, and a frame budget nobody costed. If you are staring at a 4K or 8K inspection problem and a detector that misses the things that matter, our OpenCV consultants have built these pipelines for drone survey, rail, PCB and web-inspection clients. Get in touch with your frame size, object size and target frame rate, and we will tell you in one conversation whether tiling solves it and what it costs.