How to draw text and shape overlays with OpenCV

Image overlays turn detection coordinates, regions of interest, and visual debug hints into marks that stay with the saved frame. In OpenCV, the same image array that holds pixels can receive labels, boxes, markers, and polylines before the file is written.

Drawing calls such as cv.rectangle(), cv.putText(), cv.circle(), and cv.polylines() modify the target image in place. Work on a copy when the original pixels need to remain available for comparison or later processing.

Color tuples for OpenCV image I/O use blue, green, red channel order. A translucent callout also needs a separate overlay buffer and a blend step, because drawing functions do not alpha-composite directly onto a four-channel image in the way most annotation workflows expect.

Steps to draw text and shape overlays with OpenCV:

  1. Place the source image at input/scene.png.

    Use any image format that cv.imread() can load as a color image. The output examples use a 720 by 480 pixel scene so the coordinates and pixel counts remain easy to compare.

  2. Create draw_image_overlays.py.
    draw_image_overlays.py
    #!/usr/bin/env python3
    import argparse
    from pathlib import Path
     
    import cv2 as cv
    import numpy as np
     
     
    def clamp_alpha(value: str) -> float:
        alpha = float(value)
        if not 0.0 <= alpha <= 1.0:
            raise argparse.ArgumentTypeError("alpha must be between 0.0 and 1.0")
        return alpha
     
     
    parser = argparse.ArgumentParser(description="Draw text and shape overlays with OpenCV.")
    parser.add_argument("input_image", type=Path)
    parser.add_argument("output_image", type=Path)
    parser.add_argument("--alpha", type=clamp_alpha, default=0.35)
    args = parser.parse_args()
     
    image = cv.imread(str(args.input_image), cv.IMREAD_COLOR)
    if image is None:
        raise SystemExit(f"could not read image: {args.input_image}")
     
    annotated = image.copy()
    height, width = annotated.shape[:2]
     
    box_start = (int(width * 0.12), int(height * 0.18))
    box_end = (int(width * 0.62), int(height * 0.58))
    label_origin = (box_start[0] + 18, box_start[1] - 14)
    center = (int(width * 0.75), int(height * 0.34))
    radius = max(18, min(width, height) // 16)
    polyline = np.array(
        [
            (int(width * 0.18), int(height * 0.76)),
            (int(width * 0.36), int(height * 0.66)),
            (int(width * 0.55), int(height * 0.82)),
        ],
        dtype=np.int32,
    ).reshape((-1, 1, 2))
     
    overlay = annotated.copy()
    cv.rectangle(overlay, box_start, box_end, (0, 180, 255), cv.FILLED)
    cv.addWeighted(overlay, args.alpha, annotated, 1.0 - args.alpha, 0, annotated)
     
    cv.rectangle(annotated, box_start, box_end, (0, 140, 255), 3, cv.LINE_AA)
    cv.putText(
        annotated,
        "inspection zone",
        label_origin,
        cv.FONT_HERSHEY_SIMPLEX,
        0.8,
        (0, 0, 0),
        4,
        cv.LINE_AA,
    )
    cv.putText(
        annotated,
        "inspection zone",
        label_origin,
        cv.FONT_HERSHEY_SIMPLEX,
        0.8,
        (255, 255, 255),
        2,
        cv.LINE_AA,
    )
    cv.circle(annotated, center, radius, (255, 80, 80), 3, cv.LINE_AA)
    cv.drawMarker(annotated, center, (255, 255, 255), cv.MARKER_CROSS, radius * 2, 2, cv.LINE_AA)
    cv.polylines(annotated, [polyline], False, (80, 255, 80), 4, cv.LINE_AA)
     
    args.output_image.parent.mkdir(parents=True, exist_ok=True)
    if not cv.imwrite(str(args.output_image), annotated):
        raise SystemExit(f"could not write image: {args.output_image}")
     
    changed = cv.absdiff(image, annotated)
    changed_pixels = int(np.count_nonzero(cv.cvtColor(changed, cv.COLOR_BGR2GRAY)))
    x1, y1 = box_start
    x2, y2 = box_end
    roi_changed = cv.absdiff(image[y1:y2, x1:x2], annotated[y1:y2, x1:x2])
    roi_pixels = int(np.count_nonzero(cv.cvtColor(roi_changed, cv.COLOR_BGR2GRAY)))
     
    print(f"input: {args.input_image}")
    print(f"alpha: {args.alpha:.2f}")
    print(f"changed pixels: {changed_pixels}")
    print(f"inspection zone changed pixels: {roi_pixels}")
    print(f"output: {args.output_image}")
  3. Run the overlay script and write the annotated image.
    $ python3 draw_image_overlays.py input/scene.png output/scene-overlays.png
    input: input/scene.png
    alpha: 0.35
    changed pixels: 79925
    inspection zone changed pixels: 69120
    output: output/scene-overlays.png

    The changed-pixel counts compare the input frame with the saved annotation output. A nonzero count in the inspection zone confirms that the translucent rectangle, border, and label altered the intended region.

  4. Tune direct overlay colors and coordinates in blue, green, red order.
    cv.rectangle(annotated, box_start, box_end, (0, 140, 255), 3, cv.LINE_AA)
    cv.circle(annotated, center, radius, (255, 80, 80), 3, cv.LINE_AA)
    cv.polylines(annotated, [polyline], False, (80, 255, 80), 4, cv.LINE_AA)

    cv.LINE_AA draws antialiased edges on 8-bit images. Swap color tuple values only after accounting for OpenCV BGR order.
    Related: How to convert image color spaces with OpenCV

  5. Blend filled translucent regions from a separate buffer.
    overlay = annotated.copy()
    cv.rectangle(overlay, box_start, box_end, (0, 180, 255), cv.FILLED)
    cv.addWeighted(overlay, args.alpha, annotated, 1.0 - args.alpha, 0, annotated)

    Keep alpha between 0.0 and 1.0. The copied overlay has the same size and type as the image, which keeps cv.addWeighted() on a valid pair of arrays.

  6. Verify that the saved overlay image can be reopened.
    $ python3 - <<'PY'
    import cv2 as cv
    image = cv.imread("output/scene-overlays.png")
    if image is None:
        raise SystemExit("could not read output/scene-overlays.png")
    print(f"overlay output readable: {image.shape[1]}x{image.shape[0]}")
    PY
    overlay output readable: 720x480
  7. Remove the one-off overlay script after moving the drawing calls into project code.
    $ rm draw_image_overlays.py