Getting Started
Read, Show & Save Images
OpenCV reads images in BGR order (not RGB) — a common pitfall when interoperating with matplotlib or PIL. cv2.waitKey(0) blocks until a key is pressed; always call destroyAllWindows() to close windows. JPEG quality is 0-100.
import cv2
import numpy as np
# read an image (BGR format by default)
img = cv2.imread("photo.jpg")
print(img.shape) # (height, width, channels)
# read as grayscale
gray = cv2.imread("photo.jpg", cv2.IMREAD_GRAYSCALE)
# read with alpha channel
rgba = cv2.imread("logo.png", cv2.IMREAD_UNCHANGED)
# display
cv2.imshow("window", img)
cv2.waitKey(0)
cv2.destroyAllWindows()
# save
cv2.imwrite("output.jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 90])Install & Import
opencv-python ships only the main modules; opencv-contrib-python adds extra (often patent-encumbered) modules like SIFT/SURF and ArUco. Use the headless variant in servers/Docker to avoid pulling X11 dependencies. Despite the name cv2, OpenCV 4.x still imports as cv2.
# Install OpenCV (Python bindings)
pip install opencv-python # main modules only
pip install opencv-contrib-python # includes extra modules (SIFT, SURF, aruco...)
pip install opencv-python-headless # no GUI (servers / Docker)
# The universal alias is cv2
import cv2
print(cv2.__version__) # e.g. 4.9.0
# NumPy is required — images are ndarray
import numpy as npImage as NumPy Array
OpenCV images are NumPy arrays, so everything you know about NumPy applies. The coordinate convention is img[y, x] (row, col) — the opposite of (x, y) image coordinates, a frequent source of bugs. Color images are (H, W, 3) in BGR; grayscale are (H, W).
import cv2
import numpy as np
img = cv2.imread("photo.jpg") # BGR uint8, shape (H, W, 3)
print(type(img)) # <class 'numpy.ndarray'>
print(img.shape, img.dtype) # (480, 640, 3) uint8
# A grayscale image is 2-D: (H, W)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
print(gray.shape) # (480, 640)
# Coordinate convention: img[y, x] (row, col)
px = img[100, 200] # BGR pixel at (x=200, y=100)
blue, green, red = img[100, 200]
# Vectorized access to a region (ROI)
roi = img[50:150, 100:300] # rows 50-149, cols 100-299Pixel Access & ROI
Per-pixel access via img[y, x] is slow and discouraged in loops — always prefer NumPy slicing. .copy() when assigning a ROI so later changes to the source don't bleed in. Modifying img[:, :, channel] is the fastest way to operate on a single color channel.
import cv2
import numpy as np
img = cv2.imread("photo.jpg")
# access a single pixel (slow)
b, g, r = img[100, 200]
# access a single channel faster
blue = img[100, 200, 0]
img[100, 200] = [255, 255, 255] # set to white
# numpy slicing is much faster than item-by-item
roi = img[100:200, 200:400].copy()
# copy ROI to another location
img[0:100, 0:200] = roi
# modify a whole channel
img[:, :, 2] = 0 # zero out the red channelData Types & Conversion
Most OpenCV functions expect uint8 (0-255), but algorithms like SIFT, optical flow, and edge detection often need float32. Never cast directly with .astype() after subtraction — uint8 overflow turns -1 into 255. Use cv2.absdiff or convert to float first. Always cast back to uint8 before cv2.imwrite / imshow.
import cv2
import numpy as np
img = cv2.imread("photo.jpg") # uint8, 0-255
print(img.dtype, img.min(), img.max()) # uint8 0 255
# convert to float32 (0.0-1.0 for many algorithms)
f = img.astype(np.float32) / 255.0
# convert back to uint8 for display/save
u = (f * 255).astype(np.uint8)
# clamp then cast (avoid overflow when subtracting)
diff = cv2.absdiff(img, img) # safe subtraction
print(diff.dtype) # uint8
# CV_32F / CV_64F / CV_8U constants (used in some functions)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
print(gray.dtype) # uint8Video Capture
Open Webcam
VideoCapture(0) opens the default webcam; use 1, 2... for additional cameras. Always call cap.release() to free the device — forgetting it can leave the camera busy for other apps. waitKey(1) waits ~1ms; the & 0xFF trick ensures compatibility on 64-bit systems where waitKey returns more than 8 bits.
import cv2
cap = cv2.VideoCapture(0) # 0 = default camera
print(cap.isOpened()) # True if camera opened
while True:
ret, frame = cap.read() # ret is False at end of stream
if not ret:
break
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
cv2.imshow("frame", gray)
if cv2.waitKey(1) & 0xFF == ord('q'):
break
cap.release()
cv2.destroyAllWindows()Read Video File
CAP_PROP_FRAME_COUNT is often approximate and unreliable for some codecs — don't rely on it for exact seeking. waitKey(int(1000/fps)) plays at roughly the correct speed; for precise timing use a real clock. Some compressed videos won't open without the right backend (FFmpeg).
import cv2
cap = cv2.VideoCapture("movie.mp4")
# video properties
fps = cap.get(cv2.CAP_PROP_FPS)
width = cap.get(cv2.CAP_PROP_FRAME_WIDTH)
height = cap.get(cv2.CAP_PROP_FRAME_HEIGHT)
count = cap.get(cv2.CAP_PROP_FRAME_COUNT)
print(f"{width}x{height} @ {fps} fps, {count} frames")
while cap.isOpened():
ret, frame = cap.read()
if not ret:
break
cv2.imshow("video", frame)
if cv2.waitKey(int(1000 / fps)) & 0xFF == ord('q'):
break
cap.release()
cv2.destroyAllWindows()Save / Write Video
The FourCC code is platform- and codec-dependent: 'mp4v' is widely portable, 'XVID' produces .avi, 'avc1' gives H.264 (may not be available without proprietary codecs). The frame size passed to VideoWriter MUST match the frames you write, otherwise the file is corrupted. Always call out.release() to flush and close the file.
import cv2
cap = cv2.VideoCapture(0)
fourcc = cv2.VideoWriter_fourcc(*'mp4v') # or 'XVID', 'avc1'
fps = 20.0
size = (640, 480)
out = cv2.VideoWriter("out.mp4", fourcc, fps, size)
while cap.isOpened():
ret, frame = cap.read()
if not ret:
break
frame = cv2.resize(frame, size)
out.write(frame)
cv2.imshow("rec", frame)
if cv2.waitKey(1) & 0xFF == ord('q'):
break
cap.release()
out.release()
cv2.destroyAllWindows()Camera Settings
Camera properties are hardware- and driver-dependent — many webcams silently ignore unsupported settings or snap to nearest valid values, so always read back the actual value. CAP_PROP_EXPOSURE uses log2 of seconds (-4 = 1/16 s). Auto-exposure must be disabled (set to 1) before manual exposure takes effect on most cameras.
import cv2
cap = cv2.VideoCapture(0)
# set resolution
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)
# set exposure / focus / brightness
cap.set(cv2.CAP_PROP_EXPOSURE, -4) # log2 exposure time
cap.set(cv2.CAP_PROP_BRIGHTNESS, 128)
cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 1) # 1=manual, 3=auto
# disable autofocus and set manual focus
cap.set(cv2.CAP_PROP_AUTOFOCUS, 0)
cap.set(cv2.CAP_PROP_FOCUS, 50)
print(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) # actual width may differFrame Rate & Timing
cap.get(CAP_PROP_FPS) returns the configured FPS, not the actual capture rate — measure with a real clock. A 30-frame moving average smooths jitter. The biggest FPS killers are cv2.imshow (GUI thread) and per-frame cv2.imwrite; for benchmarking, disable display. For deterministic timing, accumulate frames in a thread and process from a queue.
import cv2
import time
cap = cv2.VideoCapture(0)
prev = time.time()
fps_hist = []
while True:
ret, frame = cap.read()
if not ret:
break
now = time.time()
dt = now - prev
prev = now
if dt > 0:
fps_hist.append(1.0 / dt)
fps = sum(fps_hist[-30:]) / min(len(fps_hist), 30)
cv2.putText(frame, f"FPS: {fps:.1f}", (10, 30),
cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)
cv2.imshow("fps", frame)
if cv2.waitKey(1) & 0xFF == ord('q'):
break
cap.release()
cv2.destroyAllWindows()Drawing
Line & Rectangle
All drawing functions mutate the image in place — pass a copy if you want to keep the original. Coordinates are (x, y), opposite to NumPy's [y, x] indexing. Colors are BGR tuples. Use thickness=-1 for filled shapes and cv2.LINE_AA for smooth (anti-aliased) lines on diagonal edges.
import cv2
import numpy as np
img = np.zeros((400, 400, 3), dtype=np.uint8)
# line(img, pt1, pt2, color, thickness)
cv2.line(img, (10, 10), (390, 10), (0, 255, 0), 2)
# rectangle(img, pt1, pt2, color, thickness)
cv2.rectangle(img, (50, 50), (200, 150), (255, 0, 0), 3)
# filled rectangle (thickness = -1)
cv2.rectangle(img, (220, 50), (370, 150), (0, 0, 255), -1)
# anti-aliased line (cv2.LINE_AA)
cv2.line(img, (10, 380), (390, 200), (255, 255, 0), 1, cv2.LINE_AA)
cv2.imshow("img", img)
cv2.waitKey(0)Circle & Ellipse
ellipse's axes parameter is (halfWidth, halfHeight) — full width is 2 * axes[0]. angle rotates the whole ellipse; startAngle/endAngle draw partial arcs (0° = right, counterclockwise). Drawing partial arcs is the standard way to render pie-chart slices or motion-direction indicators.
import cv2
import numpy as np
img = np.zeros((400, 400, 3), dtype=np.uint8)
# circle(img, center, radius, color, thickness)
cv2.circle(img, (100, 100), 50, (0, 255, 255), 3)
cv2.circle(img, (300, 100), 50, (255, 0, 255), -1) # filled
# ellipse(img, center, axes, angle, startAngle, endAngle, color, thickness)
cv2.ellipse(img, (200, 250), (100, 50), 30, 0, 360, (0, 255, 0), 2)
# partial arc (0 to 180 degrees)
cv2.ellipse(img, (200, 250), (100, 50), 30, 0, 180, (0, 0, 255), 3)
cv2.imshow("img", img)
cv2.waitKey(0)Polygon & Polylines
polylines accepts a list of point arrays, so you can draw multiple polygons in one call. The points array must be int32 and reshaped to (-1, 1, 2). Use fillPoly for filled shapes; for translucent fills, draw onto an overlay and blend with addWeighted. The isClosed flag connects the last point back to the first.
import cv2
import numpy as np
img = np.zeros((400, 400, 3), dtype=np.uint8)
# polygon points: shape (N, 1, 2), int32
pts = np.array([[50, 50], [200, 30], [350, 200],
[300, 350], [80, 300]], dtype=np.int32)
pts = pts.reshape((-1, 1, 2))
# outline only (not closed)
cv2.polylines(img, [pts], False, (0, 255, 0), 2)
# closed polygon outline
cv2.polylines(img, [pts], True, (255, 0, 0), 3)
# filled polygon
cv2.fillPoly(img, [pts], (0, 0, 255))
cv2.imshow("img", img)
cv2.waitKey(0)Text
OpenCV's built-in fonts are Hershey vector fonts — they don't support CJK/Chinese characters. For non-Latin text, render with PIL (Pillow) ImageFont and convert back, or use cv2.freetype from opencv-contrib. getTextSize returns ((width, height), baseline) — useful to center text or draw a background box behind it.
import cv2
import numpy as np
img = np.zeros((300, 600, 3), dtype=np.uint8)
# putText(img, text, org, fontFace, fontScale, color, thickness)
cv2.putText(img, "Hello OpenCV", (10, 50),
cv2.FONT_HERSHEY_SIMPLEX, 1.2, (255, 255, 255), 2)
# fonts
fonts = [cv2.FONT_HERSHEY_SIMPLEX, cv2.FONT_HERSHEY_PLAIN,
cv2.FONT_HERSHEY_DUPLEX, cv2.FONT_HERSHEY_COMPLEX,
cv2.FONT_HERSHEY_TRIPLEX, cv2.FONT_ITALIC]
for i, f in enumerate(fonts):
cv2.putText(img, f"font {i}", (10, 100 + i*30), f, 0.8, (0, 255, 0), 1)
# measure text size before drawing
(text_w, text_h), baseline = cv2.getTextSize(
"Measured", cv2.FONT_HERSHEY_SIMPLEX, 1.0, 2)
cv2.rectangle(img, (10, 260), (10 + text_w, 260 + text_h + baseline),
(0, 0, 255), 1)Arrow & Marker
arrowedLine's tipLength is a fraction of the line length, so longer arrows have proportionally bigger tips — set it explicitly if you want consistent arrowhead sizes. drawMarker is convenient for highlighting key points (e.g. detected features or corners); MARKER_TILTED_CROSS gives an X-shaped marker.
import cv2
import numpy as np
img = np.zeros((400, 400, 3), dtype=np.uint8)
# arrowedLine(img, pt1, pt2, color, thickness, tipLength)
cv2.arrowedLine(img, (50, 50), (350, 50), (0, 255, 0), 2, tipLength=0.05)
cv2.arrowedLine(img, (200, 200), (200, 380), (0, 0, 255), 3, tipLength=0.1)
# drawMarker (cross, tilted cross, circle, square, diamond)
cv2.drawMarker(img, (100, 300), (255, 0, 255),
markerType=cv2.MARKER_CROSS, markerSize=30, thickness=2)
cv2.drawMarker(img, (300, 300), (255, 255, 0),
markerType=cv2.MARKER_DIAMOND, markerSize=30, thickness=2)
cv2.imshow("img", img)
cv2.waitKey(0)Overlay & Annotation
The overlay technique (draw on a copy, then addWeighted) is the standard way to draw translucent fills — OpenCV has no native alpha for shapes. Always draw solid text AFTER blending so it stays crisp; drawing text on the overlay before blending makes it blurry. This pattern is everywhere in detection/segmentation visualization.
import cv2
import numpy as np
img = cv2.imread("photo.jpg")
overlay = img.copy()
# draw a translucent rectangle on the overlay
cv2.rectangle(overlay, (50, 50), (250, 150), (0, 0, 255), -1)
cv2.putText(overlay, "REGION", (60, 110),
cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2)
# blend overlay onto the original (alpha=0.4)
out = cv2.addWeighted(overlay, 0.4, img, 0.6, 0)
cv2.imshow("out", out)
cv2.waitKey(0)Image Arithmetic
Add & Subtract
OpenCV's cv2.add saturates at 255 (250 + 10 = 255), while NumPy's + wraps around (250 + 10 = 8 due to uint8 overflow). For images, always use cv2.add / cv2.subtract — wrapping creates visible black/white artifacts. absdiff is the workhorse for background subtraction and motion detection.
import cv2
import numpy as np
a = cv2.imread("a.jpg")
b = cv2.imread("b.jpg")
b = cv2.resize(b, (a.shape[1], a.shape[0])) # match sizes
# OpenCV addition: saturated (caps at 255)
add_cv = cv2.add(a, b)
# NumPy addition: wraps around (250 + 10 = 264 -> 8)
add_np = a + b
# subtract (saturated, never goes below 0)
sub = cv2.subtract(a, b)
# absolute difference (handy for motion detection)
diff = cv2.absdiff(a, b)Blending & AddWeighted
addWeighted requires both images to have identical size and dtype — resize first. The gamma term adds a constant to every pixel, useful for brightness control. The classic fade/cross-dissolve between two images is just addWeighted with alpha sweeping from 0 to 1.
import cv2
import numpy as np
a = cv2.imread("a.jpg")
b = cv2.imread("b.jpg")
b = cv2.resize(b, (a.shape[1], a.shape[0]))
# dst = alpha * a + beta * b + gamma
blend = cv2.addWeighted(a, 0.7, b, 0.3, 0)
# brighten by adding a constant (gamma)
bright = cv2.addWeighted(a, 1.0, a, 0.0, 50)
# fade between two images over N steps
for i in range(11):
alpha = i / 10.0
frame = cv2.addWeighted(a, alpha, b, 1 - alpha, 0)
cv2.imshow("fade", frame)
cv2.waitKey(200)Bitwise Operations
Bitwise ops are the standard way to apply a mask: cv2.bitwise_or(src, dst, mask=mask) copies src into dst only where mask is non-zero. The mask must be a single-channel 8-bit image where non-zero means 'keep'. This is the foundation of ROI extraction and background replacement.
import cv2
import numpy as np
a = np.zeros((200, 200), dtype=np.uint8)
cv2.rectangle(a, (20, 20), (120, 120), 255, -1)
b = np.zeros((200, 200), dtype=np.uint8)
cv2.circle(b, (100, 100), 60, 255, -1)
# bitwise AND / OR / XOR / NOT
and_ = cv2.bitwise_and(a, b)
or_ = cv2.bitwise_or(a, b)
xor = cv2.bitwise_xor(a, b)
not_ = cv2.bitwise_not(a)
# use a mask to copy only part of an image
mask = np.zeros((200, 200), dtype=np.uint8)
cv2.circle(mask, (100, 100), 50, 255, -1)
src = np.full((200, 200, 3), (0, 255, 0), dtype=np.uint8)
dst = cv2.imread("photo.jpg")
dst = cv2.resize(dst, (200, 200))
result = cv2.bitwise_or(src, dst, mask=mask)Image Difference & Motion
Frame differencing is the simplest motion detector: threshold the absolute difference between consecutive frames. It fails when the camera moves and ignores slow motion — for real systems use background subtraction (cv2.createBackgroundSubtractorMOG2) or optical flow. Morphological opening removes noise; dilation merges nearby blobs.
import cv2
import numpy as np
prev = cv2.imread("frame1.jpg", cv2.IMREAD_GRAYSCALE)
curr = cv2.imread("frame2.jpg", cv2.IMREAD_GRAYSCALE)
# absolute difference
diff = cv2.absdiff(prev, curr)
# threshold to get a binary motion mask
_, mask = cv2.threshold(diff, 25, 255, cv2.THRESH_BINARY)
# clean up with morphology
kernel = np.ones((5, 5), np.uint8)
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
mask = cv2.dilate(mask, kernel, iterations=2)
# find moving contours
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE)
for c in contours:
if cv2.contourArea(c) > 500:
x, y, w, h = cv2.boundingRect(c)
cv2.rectangle(curr, (x, y), (x+w, y+h), (0, 255, 0), 2)ROI & Masking
bitwise_and with a mask is the idiomatic way to extract irregular regions. Boolean NumPy indexing (img[mask]) is more flexible but returns a flat array of pixels, not a shaped image. Always .copy() ROIs you plan to reuse — slicing returns a view that aliases the original.
import cv2
import numpy as np
img = cv2.imread("photo.jpg")
h, w = img.shape[:2]
# build a mask: white circle on black background
mask = np.zeros((h, w), dtype=np.uint8)
cv2.circle(mask, (w // 2, h // 2), 150, 255, -1)
# keep only the masked region, zero elsewhere
masked = cv2.bitwise_and(img, img, mask=mask)
# extract ROI by bounding box
roi = img[100:300, 200:500].copy()
# put ROI back (or to a different location)
img[0:200, 0:300] = roi
# boolean mask (NumPy) — also works, but converts to 0/255
bool_mask = np.zeros((h, w), dtype=bool)
bool_mask[100:300, 200:500] = True
img_masked = img.copy()
img_masked[~bool_mask] = 0Saturation vs Wrapping
The uint8 overflow trap: NumPy wraps (300 -> 44), OpenCV saturates (300 -> 255). For any arithmetic that may exceed 0-255, either use cv2.add/subtract or upcast to int16/float32 first and clip back. Mixing cv2 and NumPy ops in the same pipeline is the #1 source of mysterious dark/bright pixel artifacts.
import cv2
import numpy as np
img = cv2.imread("photo.jpg")
# WRONG: NumPy arithmetic wraps around (200 + 100 -> 44)
bad = img + 100
# RIGHT: cv2.add saturates at 255 (200 + 100 -> 255)
good = cv2.add(img, np.full_like(img, 100))
# subtracting a bright image: cv2 clamps to 0
dark = cv2.subtract(img, np.full_like(img, 100))
# manual saturation (equivalent to cv2.add)
manual = np.clip(img.astype(np.int16) + 100, 0, 255).astype(np.uint8)
# mixing cv2 and numpy is a common bug source:
# half = img // 2 # NumPy: fine (no overflow)
# half_then_add = half + half # might equal original (ok)
# but bright = img + img # wraps! 150+150=300 -> 44Geometric Transforms
Resize / Scale
INTER_AREA is best for downscaling (avoids aliasing), INTER_CUBIC or INTER_LINEAR for upscaling. INTER_NEAREST is fastest but blocky — used for label/mask images where you must not introduce new pixel values. The dsize tuple is (width, height), the opposite of img.shape which is (height, width).
import cv2
img = cv2.imread("photo.jpg")
h, w = img.shape[:2]
# resize to a fixed size
small = cv2.resize(img, (320, 240))
# resize by a scale factor (fx, fy)
big = cv2.resize(img, None, fx=2.0, fy=2.0)
# resize keeping aspect ratio
scale = 0.5
new = cv2.resize(img, (int(w * scale), int(h * scale)))
# interpolation methods
down = cv2.resize(img, (w // 2, h // 2),
interpolation=cv2.INTER_AREA) # shrinking
up = cv2.resize(img, (w * 2, h * 2),
interpolation=cv2.INTER_CUBIC) # enlargingRotation
cv2.rotate is much faster than warpAffine for 90° multiples. getRotationMatrix2D's angle is in degrees, positive = counterclockwise. To rotate without clipping, recompute the bounding box (new_w, new_h) and shift the rotation matrix — otherwise the corners of a rotated large image get cut off.