Drawing, annotation and pipeline structure

Shapes, text, masks and overlays, a reusable pipeline of named steps, and saving intermediate results so a failure can be diagnosed.

Drawing on images

import cv2
import numpy as np

canvas = np.zeros((480, 640, 3), dtype="uint8")

cv2.line(canvas, (20, 20), (300, 120), (0, 255, 0), 2)
cv2.rectangle(canvas, (50, 150), (250, 300), (255, 0, 0), 3)
cv2.rectangle(canvas, (300, 150), (500, 300), (0, 0, 255), -1)      # filled
cv2.circle(canvas, (400, 400), 60, (0, 255, 255), 2)
cv2.ellipse(canvas, (150, 400), (80, 40), 30, 0, 360, (255, 255, 0), 2)

points = np.array([[560, 380], [620, 430], [580, 470], [530, 440]], dtype="int32")
cv2.polylines(canvas, [points], isClosed=True, color=(255, 0, 255), thickness=2)

cv2.putText(canvas, "confidence 0.94", (20, 460),
            cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2, cv2.LINE_AA)

# semi-transparent overlay: blend a copy of the image with itself
overlay = canvas.copy()
cv2.rectangle(overlay, (300, 150), (500, 300), (0, 0, 255), -1)
blended = cv2.addWeighted(overlay, 0.35, canvas, 0.65, 0)

# a mask-based annotation: draw on a mask, then composite only where it is set
mask = np.zeros(canvas.shape[:2], dtype="uint8")
cv2.circle(mask, (400, 400), 60, 255, -1)
highlight = canvas.copy()
highlight[mask == 255] = (0, 255, 255)
composited = cv2.addWeighted(highlight, 0.5, canvas, 0.5, 0)

print(canvas.shape, blended.shape, composited.shape)
FunctionCoordinates areNote
line, rectangle, circleInteger pixelsFilled when thickness is -1
ellipseCentre plus axes and angleAngle is degrees, clockwise
polylinesA list of coordinate arraysSet isClosed=True to close the shape
putTextBottom-left of the text baselineNot the top-left, which is the usual surprise
addWeightedTwo images and two weightsWeights should sum to 1 for no brightness change
copyToSource plus a maskThe cleanest way to composite through a mask
  • putText draws from the baseline, so text placed at y appears above that line. Its height depends on the font scale, and there is no automatic wrapping.
  • Drawing functions modify the image in place. Copy first if you need the original — this is the most frequent annotation bug in report generation.
  • LINE_AA anti-aliases text and curves at a small cost. Use it for anything a human will read.
  • For coloured output, draw on a copy and blend; drawing directly with a bright colour is fine for diagnosis and poor for a finished image.

A structured pipeline

from dataclasses import dataclass, field
from pathlib import Path
import time

@dataclass
class Frame:
    index: int
    image: np.ndarray
    original: np.ndarray
    data: dict = field(default_factory=dict)
    timings: dict = field(default_factory=dict)

class Step:
    name = "step"

    def run(self, frame: Frame) -> Frame:
        raise NotImplementedError

class ResizeStep(Step):
    name = "resize"

    def __init__(self, width=960):
        self.width = width

    def run(self, frame):
        h, w = frame.image.shape[:2]
        if w > self.width:
            scale = self.width / w
            frame.image = cv2.resize(frame.image, (self.width, int(h * scale)),
                                     interpolation=cv2.INTER_AREA)
        return frame

class GrayStep(Step):
    name = "gray"

    def run(self, frame):
        frame.data["gray"] = cv2.cvtColor(frame.image, cv2.COLOR_BGR2GRAY)
        return frame

class ThresholdStep(Step):
    name = "threshold"

    def __init__(self, block_size=31, c=5):
        self.block_size = block_size
        self.c = c

    def run(self, frame):
        frame.data["mask"] = cv2.adaptiveThreshold(
            frame.data["gray"], 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
            cv2.THRESH_BINARY, self.block_size, self.c)
        return frame

class Pipeline:
    def __init__(self, steps, debug_dir=None, keep_every=1):
        self.steps = steps
        self.debug_dir = Path(debug_dir) if debug_dir else None
        self.keep_every = keep_every

    def process(self, frame: Frame) -> Frame:
        for step in self.steps:
            start = time.perf_counter()
            frame = step.run(frame)
            frame.timings[step.name] = round((time.perf_counter() - start) * 1000, 2)
            if self.debug_dir and frame.index % self.keep_every == 0:
                self._dump(frame, step.name)
        return frame

    def _dump(self, frame, step_name):
        self.debug_dir.mkdir(parents=True, exist_ok=True)
        output = frame.image if frame.image.ndim == 3 else cv2.cvtColor(frame.image, cv2.COLOR_GRAY2BGR)
        cv2.imwrite(str(self.debug_dir / f"{frame.index:05d}_{step_name}.png"), output)

pipeline = Pipeline([ResizeStep(960), GrayStep(), ThresholdStep()], debug_dir="debug")
raw = cv2.imread("input.png")
frame = Frame(index=0, image=raw.copy(), original=raw.copy())
result = pipeline.process(frame)
print(result.timings, result.data["mask"].shape)
  • A named step is a place to attach timing, which turns a slow pipeline into a list of numbers rather than a guess.
  • Pass an immutable reference to the original through the pipeline, so any step can compare its output against the input.
  • Dump intermediate images keyed by frame index and step name. When something fails on frame 4,812 you need the data from that frame, not a reproduction attempt.
  • Keep the debug output behind a flag. Writing a PNG per step per frame will dominate the runtime and fill a disk.

Annotating results

def annotate_results(image, detections, labels=None, show_index=True):
    """Draw boxes with readable labels, clipped to the image."""
    annotated = image.copy()
    h, w = annotated.shape[:2]

    for index, detection in enumerate(detections):
        x, y, bw, bh = detection["box"]
        score = detection.get("score", 0.0)
        label = (labels or {}).get(detection.get("class_id"), str(detection.get("class_id", "")))

        x0, y0 = max(0, x), max(0, y)
        x1, y1 = min(w - 1, x + bw), min(h - 1, y + bh)
        if x1 <= x0 or y1 <= y0:
            continue

        colour = (0, 200, 0) if score > 0.7 else (0, 165, 255)
        cv2.rectangle(annotated, (x0, y0), (x1, y1), colour, 2)

        text = f"{label} {score:.2f}" if show_index else label
        (tw, th), baseline = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1)
        ty = y0 - 6 if y0 - th - 6 > 0 else y1 + th + 6
        cv2.rectangle(annotated, (x0, ty - th - 4), (x0 + tw + 4, ty + baseline),
                      colour, -1)
        cv2.putText(annotated, text, (x0 + 2, ty),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 0), 1, cv2.LINE_AA)

    return annotated

def side_by_side(original, processed, gap=10):
    height = max(original.shape[0], processed.shape[0])
    def pad(image):
        if image.ndim == 2:
            image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
        missing = height - image.shape[0]
        return cv2.copyMakeBorder(image, 0, missing, 0, 0, cv2.BORDER_CONSTANT,
                                  value=(30, 30, 30))
    return np.hstack([pad(original), np.full((height, gap, 3), 30, dtype="uint8"),
                      pad(processed)])
💡
Keep the annotation code separate from the detection code. Detection answers a question about the image; annotation answers a question about the answer. Mixing them makes both harder to test, and the visual output becomes the only evidence you have.

FAQ

How do I draw Chinese or other non-Latin text?
putText only supports the Hershey fonts, which are Latin-only. Render the text with PIL over the same array, or draw a pre-rendered label image. OpenCV will not warn you; it draws question marks.
Why did my original image change?
Drawing functions and many processing functions modify in place, and slicing returns a view rather than a copy. Call .copy() before annotating or writing into a region you need to keep.

Reading, writing and inspecting images Performance for real-time vision

Last refreshed 2026-09-18.