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 |