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)| Function | Coordinates are | Note |
|---|---|---|
line, rectangle, circle | Integer pixels | Filled when thickness is -1 |
ellipse | Centre plus axes and angle | Angle is degrees, clockwise |
polylines | A list of coordinate arrays | Set isClosed=True to close the shape |
putText | Bottom-left of the text baseline | Not the top-left, which is the usual surprise |
addWeighted | Two images and two weights | Weights should sum to 1 for no brightness change |
copyTo | Source plus a mask | The cleanest way to composite through a mask |
putTextdraws from the baseline, so text placed atyappears 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_AAanti-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.Related
Reading, writing and inspecting images Performance for real-time vision
Last refreshed 2026-09-18.