Skip to content

OpenCV and Image I/O Cheat Sheet

A dense reference for everyday image and video handling in Python: reading, writing, and displaying images; color conversions; resizing; drawing; video I/O; and moving images between NumPy, OpenCV, Pillow, and PyTorch without silent bugs. Written for OpenCV 4.10+ and 5.x.

Related chapters: Setting up your vision toolkit ยท Digital images ยท Images as arrays ยท Working with images and video

import cv2
import numpy as np
from PIL import Image, ImageOps
import torch
from skimage import data

# test files for trying the snippets on this page
cv2.imwrite("photo.jpg", cv2.cvtColor(data.astronaut(), cv2.COLOR_RGB2BGR))
writer = cv2.VideoWriter("clip.mp4", cv2.VideoWriter_fourcc(*"mp4v"), 10, (160, 120))
for i in range(20):
    writer.write(np.full((120, 160, 3), i * 10, np.uint8))
writer.release()

The conventions that cause most bugs

OpenCV Pillow scikit-image matplotlib torchvision
Type ndarray Image ndarray ndarray Tensor
Channel order BGR RGB RGB RGB RGB
Layout HWC โ€” HWC HWC CHW / NCHW
Usual dtype, range uint8, 0โ€“255 8-bit uint8 in, float 0โ€“1 out uint8 0โ€“255 or float 0โ€“1 uint8, or float32 0โ€“1
Size / point order (w, h), (x, y) (w, h), (x, y) (rows, cols) (x, y) (H, W)

img.shape is always (H, W, C) for NumPy images. img[y, x] is the pixel at point \((x, y)\).

Reading and writing images

Task Code
Read color (BGR, 3 channels) img = cv2.imread("photo.jpg")
Read straight to RGB (OpenCV 5, recent 4.x) cv2.imread(path, cv2.IMREAD_COLOR_RGB)
Read grayscale cv2.imread(path, cv2.IMREAD_GRAYSCALE)
Read as stored (alpha, 16-bit, float) cv2.imread(path, cv2.IMREAD_UNCHANGED)
Ignore EXIF orientation cv2.imread(path, cv2.IMREAD_COLOR | cv2.IMREAD_IGNORE_ORIENTATION)
Check for failure if img is None: raise FileNotFoundError(path)
Write (format from extension) ok = cv2.imwrite("out.png", img_bgr)
JPEG quality (0โ€“100, default 95) cv2.imwrite("o.jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 90])
PNG compression (0โ€“9) cv2.imwrite("o.png", img, [cv2.IMWRITE_PNG_COMPRESSION, 6])
WebP quality (above 100 = lossless) cv2.imwrite("o.webp", img, [cv2.IMWRITE_WEBP_QUALITY, 80])
Encode to bytes in memory ok, buf = cv2.imencode(".jpg", img); data = buf.tobytes()
Decode from bytes img = cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR)
Non-ASCII path (Windows) cv2.imdecode(np.fromfile(path, np.uint8), cv2.IMREAD_COLOR)
Pillow: open (lazy), to RGB pil = Image.open(path).convert("RGB")
Pillow: apply EXIF rotation pil = ImageOps.exif_transpose(Image.open(path))
Pillow: save JPEG pil.save("o.jpg", quality=90)
Pillow size and mode pil.size == (w, h), pil.mode in {"L", "RGB", "RGBA", "P", "I;16"}
bgr = cv2.imread("photo.jpg")
if bgr is None:
    raise FileNotFoundError("photo.jpg")
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)          # convert once, right after loading
ok = cv2.imwrite("copy.png", cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR))   # and back, right before saving

Displaying

Task Code
Show an OpenCV image plt.imshow(cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB))
Show gray at true brightness plt.imshow(gray, cmap="gray", vmin=0, vmax=255)
Show float image values must be in \([0, 1]\): plt.imshow(np.clip(f, 0, 1))
See individual pixels plt.imshow(img, interpolation="nearest")
Side by side fig, axes = plt.subplots(1, n, figsize=(10, 3)), then ax.axis("off")
Desktop window (non-headless OpenCV) cv2.imshow("win", bgr); cv2.waitKey(0); cv2.destroyAllWindows()

cv2.imshow doesn't work in notebooks, on servers, or with opencv-python-headless. Use matplotlib.

import matplotlib.pyplot as plt
fig, axes = plt.subplots(1, 2, figsize=(8, 4))
axes[0].imshow(rgb)
axes[1].imshow(cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY), cmap="gray", vmin=0, vmax=255)
for ax in axes:
    ax.axis("off")
plt.show()

Color conversions

Conversion Code Output ranges (8-bit input)
BGR โ†” RGB cv2.COLOR_BGR2RGB (same as RGB2BGR), or img[..., ::-1] โ€”
To gray cv2.COLOR_BGR2GRAY / RGB2GRAY \(0.299R + 0.587G + 0.114B\)
Gray to 3 channels cv2.COLOR_GRAY2BGR copies the channel
HSV cv2.COLOR_BGR2HSV H 0โ€“179 (degrees / 2), S and V 0โ€“255
HSV, full hue range cv2.COLOR_BGR2HSV_FULL H 0โ€“255
Lab cv2.COLOR_BGR2Lab Lยท255/100, a+128, b+128
YCrCb (JPEG) cv2.COLOR_BGR2YCrCb order is Y, Cr, Cb
Add / drop alpha cv2.COLOR_BGR2BGRA / cv2.COLOR_BGRA2BGR alpha 255
Demosaic RGGB sensor cv2.COLOR_BAYER_RGGB2RGB (5.x) = cv2.COLOR_BayerBG2RGB (4.x name) โ€”

With float32 input in \([0, 1]\), HSV gives H in degrees (0โ€“360) and S, V in \([0, 1]\); Lab gives L in 0โ€“100 and a, b roughly โˆ’128 to 127. scikit-image's rgb2hsv uses \([0, 1]\) for all three channels.

hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
orange = cv2.inRange(hsv, (5, 100, 100), (20, 255, 255))     # hue 10-40 degrees -> 5-20
lab = cv2.cvtColor(bgr.astype(np.float32) / 255, cv2.COLOR_BGR2Lab)   # float: true L*, a*, b*

Resizing and interpolation

cv2.resize(img, (new_w, new_h), interpolation=...). The size is (width, height). Or use scale factors: cv2.resize(img, None, fx=0.5, fy=0.5, interpolation=cv2.INTER_AREA).

Use Flag Notes
Shrinking photos cv2.INTER_AREA averages the footprint; no aliasing
Enlarging, fast cv2.INTER_LINEAR the default; aliases when shrinking a lot
Enlarging, sharper cv2.INTER_CUBIC can overshoot slightly near edges
Enlarging, sharpest cv2.INTER_LANCZOS4 slowest; may ring
Masks, labels, class IDs cv2.INTER_NEAREST never invents new values
Library Equivalent antialiased shrink
Pillow pil.resize((w, h), Image.Resampling.LANCZOS) (default is bicubic with antialiasing)
scikit-image transform.resize(img, (h, w), anti_aliasing=True) (returns float)
torchvision v2.functional.resize(t, [h, w], antialias=True)

Keep the aspect ratio:

def resize_max_side(img, max_side, interp=cv2.INTER_AREA):
    h, w = img.shape[:2]
    s = max_side / max(h, w)
    return cv2.resize(img, (round(w * s), round(h * s)), interpolation=interp) if s < 1 else img

def letterbox(img, size=(640, 640), pad=114):
    tw, th = size
    h, w = img.shape[:2]
    s = min(tw / w, th / h)
    nw, nh = round(w * s), round(h * s)
    out = np.full((th, tw) + img.shape[2:], pad, img.dtype)
    px, py = (tw - nw) // 2, (th - nh) // 2
    out[py:py + nh, px:px + nw] = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_AREA if s < 1 else cv2.INTER_LINEAR)
    return out, s, (px, py)          # map back: x = (x' - px) / s, y = (y' - py) / s

thumb = resize_max_side(bgr, 256)
boxed, s, (px, py) = letterbox(bgr, (320, 240))

Other geometry one-liners: cv2.flip(img, 1) (horizontal), cv2.flip(img, 0) (vertical), cv2.rotate(img, cv2.ROTATE_90_CLOCKWISE), crop with img[y0:y1, x0:x1] (a view; .copy() it if needed).

Drawing

All drawing functions modify the image in place, take integer (x, y) points, and take colors in the image's channel order. Thickness -1 (cv2.FILLED) fills; cv2.LINE_AA antialiases. The image must be a contiguous uint8 array (np.ascontiguousarray if it's a view such as img[..., ::-1]).

Shape Code
Line cv2.line(img, (x1, y1), (x2, y2), (0, 255, 0), 2)
Rectangle cv2.rectangle(img, (x1, y1), (x2, y2), (0, 0, 255), 2)
Circle cv2.circle(img, (cx, cy), r, (255, 0, 0), -1)
Ellipse cv2.ellipse(img, (cx, cy), (ax1, ax2), angle, 0, 360, color, 2)
Polygon cv2.polylines(img, [pts.astype(np.int32)], True, color, 2)
Filled polygon cv2.fillPoly(img, [pts.astype(np.int32)], color)
Text (origin = bottom-left) cv2.putText(img, "txt", (x, y), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 1, cv2.LINE_AA)
Text size (tw, th), baseline = cv2.getTextSize("txt", font, scale, thickness)
Translucent overlay draw on overlay = img.copy(), then cv2.addWeighted(overlay, a, img, 1 - a, 0)
canvas = bgr.copy()
x1, y1, x2, y2 = 160, 60, 340, 230
cv2.rectangle(canvas, (x1, y1), (x2, y2), (0, 255, 0), 2)
(tw, th), base = cv2.getTextSize("face 0.98", cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1)
cv2.rectangle(canvas, (x1, y1 - th - base - 4), (x1 + tw + 4, y1), (0, 255, 0), cv2.FILLED)
cv2.putText(canvas, "face 0.98", (x1 + 2, y1 - base - 2), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 0), 1, cv2.LINE_AA)

Text metrics and the look of the built-in fonts differ between OpenCV 4 and 5, so measure text with getTextSize rather than hard-coding offsets. For real fonts or non-Latin scripts, draw with Pillow's ImageDraw.text and an ImageFont.truetype font.

Video I/O

Task Code
Open a file or stream cap = cv2.VideoCapture("clip.mp4")
Open a webcam cap = cv2.VideoCapture(0)
Check cap.isOpened()
Read next frame (BGR) ok, frame = cap.read() (ok is False at the end)
Properties cap.get(cv2.CAP_PROP_FPS), CAP_PROP_FRAME_WIDTH, CAP_PROP_FRAME_HEIGHT, CAP_PROP_FRAME_COUNT (approximate)
Seek to frame cap.set(cv2.CAP_PROP_POS_FRAMES, n) (may be inexact for compressed video)
Release cap.release()
Writer cv2.VideoWriter("o.mp4", cv2.VideoWriter_fourcc(*"mp4v"), fps, (w, h))
Codecs in pip wheels "mp4v" โ†’ .mp4, "MJPG" โ†’ .avi; "avc1" (H.264) usually unavailable
Write a frame writer.write(frame): (h, w, 3) uint8 BGR matching the writer's size
Finish writer.release()
cap = cv2.VideoCapture("clip.mp4")
fps = cap.get(cv2.CAP_PROP_FPS)
w, h = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
means = []
while True:
    ok, frame = cap.read()
    if not ok:
        break
    means.append(cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY).mean())
cap.release()

# note: frame is None here, because the last read failed
writer = cv2.VideoWriter("gray.mp4", cv2.VideoWriter_fourcc(*"mp4v"), fps, (w, h))   # (width, height)
assert writer.isOpened()
for m in means:
    writer.write(np.full((h, w, 3), int(m), np.uint8))
writer.release()

A writer given frames of the wrong size or channel count usually writes an empty or unplayable file without raising. Read the output back to verify. For webcams with cv2.imshow, call cv2.waitKey(1) every frame so the window refreshes.

Converting between NumPy, OpenCV, Pillow, and PyTorch

From โ†’ To Code
OpenCV BGR โ†’ RGB array rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
RGB array โ†’ Pillow Image.fromarray(rgb) (needs uint8 HWC, or 2D for gray)
Pillow โ†’ array np.asarray(pil) (may be read-only) or np.array(pil) (writable copy)
Pillow โ†’ OpenCV cv2.cvtColor(np.array(pil.convert("RGB")), cv2.COLOR_RGB2BGR)
uint8 โ†’ float 0โ€“1 img.astype(np.float32) / 255 or skimage.img_as_float32(img)
float 0โ€“1 โ†’ uint8 (np.clip(f, 0, 1) * 255).round().astype(np.uint8)
HWC โ†’ CHW (NumPy) img.transpose(2, 0, 1) (a view; np.ascontiguousarray to copy)
NumPy HWC uint8 โ†’ tensor CHW float torch.from_numpy(rgb).permute(2, 0, 1).float().div(255)
Tensor CHW โ†’ NumPy HWC uint8 (t.clamp(0, 1) * 255).round().byte().permute(1, 2, 0).cpu().numpy()
Add batch axis t.unsqueeze(0) โ†’ (1, C, H, W)
Stack a batch torch.stack(list_of_chw) / np.stack(list_of_hwc)
torchvision read torchvision.io.read_image(path) โ†’ uint8 CHW RGB tensor
torchvision v2 pipeline v2.Compose([v2.ToImage(), v2.ToDtype(torch.float32, scale=True), v2.Normalize(mean, std)])
ImageNet normalization mean (0.485, 0.456, 0.406), std (0.229, 0.224, 0.225) (RGB, on 0โ€“1 values)
Undo normalization t * std.view(-1, 1, 1) + mean.view(-1, 1, 1)
t = torch.from_numpy(rgb).permute(2, 0, 1).float().div(255)      # (3, H, W)
mean = torch.tensor([0.485, 0.456, 0.406]).view(-1, 1, 1)
std = torch.tensor([0.229, 0.224, 0.225]).view(-1, 1, 1)
batch = ((t - mean) / std).unsqueeze(0)                            # (1, 3, H, W)
back = ((batch[0] * std + mean).clamp(0, 1) * 255).round().byte().permute(1, 2, 0).numpy()
assert np.array_equal(back, rgb)

torch.from_numpy shares memory with the NumPy array; .float() (or any dtype change) makes a copy. Never use reshape or view to move the channel axis.

dtype arithmetic

Operation Wrong (wraps) Right
Brighten img + 50 cv2.add(img, np.full_like(img, 50)), or np.clip(img.astype(np.int16) + 50, 0, 255).astype(np.uint8)
Darken img - 50 cv2.subtract(img, np.full_like(img, 50))
Average two images (a + b) / 2 cv2.addWeighted(a, 0.5, b, 0.5, 0) or ((a.astype(np.uint16) + b) // 2).astype(np.uint8)
Difference a - b cv2.absdiff(a, b)
Float โ†’ uint8 f.astype(np.uint8) np.clip(np.round(f), 0, 255).astype(np.uint8)
Scale contrast img * 1.5 then astype cv2.convertScaleAbs(img, alpha=1.5, beta=0) (saturates)

In OpenCV 5, cv2.add(img, 50) adds 50 to every channel; in OpenCV 4 a bare number became (50, 0, 0, 0) and changed only the first channel. Pass a full array to be safe on both.

Common pitfalls

Symptom Likely cause Fix
Faces look blue, skies orange BGR shown or used as RGB cv2.cvtColor(img, cv2.COLOR_BGR2RGB) at the boundary
'NoneType' object has no attribute 'shape' cv2.imread failed (bad path, unreadable file) check img is None; print the path
Bright areas turned dark, or dark areas bright uint8 wraparound saturating ops, or compute in float/int16 and clip
Image all white in matplotlib float image in 0โ€“255 divide by 255 or convert to uint8
Gray image looks purple-yellow 2D array without cmap cmap="gray", vmin=0, vmax=255
Dim image looks normal in a plot matplotlib auto-scaled the range pass vmin, vmax
Image stretched or squashed resized to a fixed size ignoring aspect ratio letterbox or center crop
cv2.resize output transposed passed (h, w) instead of (w, h) cv2.resize(img, (w, h))
Points or boxes in the wrong place mixed (x, y) with (row, col), or forgot the letterbox offset img[y, x]; map back with (x' - px) / s
Mask has class IDs that don't exist resized a mask with bilinear interpolation cv2.INTER_NEAREST
Jagged edges, moirรฉ after shrinking no antialiasing cv2.INTER_AREA
"Layout of the output array ... incompatible with cv::Mat" drawing on a non-contiguous view np.ascontiguousarray(img)
Photo rotated 90ยฐ in one tool but not another EXIF orientation handled inconsistently one policy everywhere; ImageOps.exif_transpose in Pillow
16-bit depth map became 8-bit default imread flag cv2.IMREAD_UNCHANGED
Channels scrambled in a tensor reshape/view instead of permute permute(2, 0, 1) / transpose(2, 0, 1)
Model predicts confidently but wrongly preprocessing differs from training (order, scale, mean/std, resize) one shared preprocessing function; test it
VideoWriter file empty or unplayable frame size or channels mismatch, unsupported FourCC check isOpened(), frame shape (h, w, 3), use mp4v
cv2.imshow error "not implemented" headless OpenCV build use matplotlib, or install opencv-python (only one OpenCV package)
import cv2 behaves oddly two OpenCV pip packages installed uninstall all, install exactly one
Masks have fuzzy edges after saving saved as JPEG save masks as PNG