Colour spaces and channel operations
BGR, RGB, HSV, LAB and grayscale, splitting and merging channels, and using inRange for colour segmentation that survives lighting changes.
Choosing a colour space
import cv2
import numpy as np
image = cv2.imread("flowers.jpg") # BGR, uint8, shape (h, w, 3)
rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
lab = cv2.cvtColor(image, cv2.COLOR_BGR2LAB)
print(image.shape, gray.shape, hsv.shape)
# HSV is the practical space for colour work: hue is roughly lighting-independent
h, s, v = cv2.split(hsv)
print("hue range", h.min(), h.max(), "saturation mean", round(float(s.mean()), 1))
# LAB is perceptually uniform: useful for measuring a difference a human would see
l, a, b = cv2.split(lab)
print("L mean", round(float(l.mean()), 1))
# MATLAB convention: OpenCV's 8-bit hue is mapped to 0-179, not 0-359
# so a 90-degree hue value is stored as 45| Space | Channels | Range in 8-bit OpenCV | Use for |
|---|---|---|---|
| BGR | B, G, R | 0-255 each | Reading, writing, drawing, storage |
| RGB | R, G, B | 0-255 each | Handing images to a library expecting RGB |
| Grayscale | 1 | 0-255 | Most classical processing and detection |
| HSV | H, S, V | H 0-179, S 0-255, V 0-255 | Colour segmentation, tracking by colour |
| LAB | L, A, B | L 0-255, A/B 0-255 offset by 128 | Perceptual difference, colour correction |
| YCrCb | Y, Cr, Cb | 0-255 each | Skin detection, some video work |
- OpenCV loads BGR. Every other library (PIL, matplotlib, most deep-learning pipelines) expects RGB, and mixing them produces the classic blue-orange swapped image rather than an error.
- Hue in OpenCV is 0-179 in 8-bit, so 180 degrees is stored as 90. Hard-coded thresholds copied from a 0-359 reference will be wrong by a factor of two.
- Grayscale conversion is not a simple average: it is a weighted sum reflecting human sensitivity. Converting to grayscale discards all colour information irreversibly.
- HSV and LAB conversions cost a few milliseconds per megapixel. For a video loop, convert once per frame and reuse the result rather than converting repeatedly inside an inner function.
Splitting, merging and manipulating channels
b, g, r = cv2.split(image)
merged = cv2.merge([b, g, r])
# indexing is faster than split() when you only need one channel
blue_only = image[:, :, 0]
red_only = image[:, :, 2]
# a zero-filled channel: useful for isolating colour contributions
empty = np.zeros_like(b)
red_on_black = cv2.merge([empty, empty, r])
green_on_black = cv2.merge([empty, g, empty])
blue_on_black = cv2.merge([b, empty, empty])
# swap channels to convert BGR to RGB without cvtColor
rgb_direct = image[:, :, ::-1].copy() # copy() matters: this is a view otherwise
print(rgb_direct.flags["C_CONTIGUOUS"])
# alpha channel handling: read and preserve transparency
with_alpha = cv2.imread("logo.png", cv2.IMREAD_UNCHANGED)
print(with_alpha.shape) # (h, w, 4) when the file has alpha
if with_alpha.shape[2] == 4:
bgr = with_alpha[:, :, :3]
alpha = with_alpha[:, :, 3]
white = np.full_like(bgr, 255)
a = (alpha.astype("float32") / 255)[:, :, None]
flattened = (bgr.astype("float32") * a + white * (1 - a)).astype("uint8")cv2.splitreturns separate arrays;image[:, :, i]returns a view into the original. Views are fast but writing to them modifies the source image.- Reversing the channel order with a slice returns a negative-stride view, which many OpenCV functions reject. Copy it before passing it on.
cv2.imreaddrops the alpha channel by default. UseIMREAD_UNCHANGEDwhen you need transparency, or it disappears with no warning.- When you composite an RGBA image onto a background, do the arithmetic in float32 and convert back to uint8 at the end. Intermediate uint8 maths wraps around at 255 and produces visible artefacts.
Colour segmentation with inRange
# find the hue distribution of the object you care about, by hand, once
hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
def hue_histogram(hsv_image, region=None):
if region is not None:
x, y, w, h = region
patch = hsv_image[y:y + h, x:x + w]
else:
patch = hsv_image
histogram = cv2.calcHist([patch], [0], None, [180], [0, 180]).ravel()
top = np.argsort(histogram)[::-1][:5]
return [(int(t), int(histogram[t])) for t in top]
print(hue_histogram(hsv, region=(300, 200, 80, 80))) # x, y, w, h
# a mask for red: red wraps around the hue circle, so it needs two ranges
lower_red_a = np.array([0, 120, 70])
upper_red_a = np.array([10, 255, 255])
lower_red_b = np.array([170, 120, 70])
upper_red_b = np.array([180, 255, 255])
mask = cv2.bitwise_or(
cv2.inRange(hsv, lower_red_a, upper_red_a),
cv2.inRange(hsv, lower_red_b, upper_red_b),
)
# clean up the mask before using it
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=1)
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=2)
result = cv2.bitwise_and(image, image, mask=mask)
print("mask coverage", round(float(mask.mean()) / 255, 4))
cv2.imwrite("mask.png", mask)- Find thresholds from a histogram of the actual pixels, not from a tutorial. A saturation floor of 120 is a reasonable default; a hue window is always image-specific.
- Red, and any hue near the 0/180 boundary, needs two ranges combined with
bitwise_or. A single range silently misses half the object. - Raise the saturation minimum to reject grey and white pixels, and raise the value minimum to reject shadows. Most false positives come from low-saturation pixels.
- A colour mask is a starting point. Follow it with morphological opening to remove speckle and closing to fill holes before you look for contours.
⚠️
Colour thresholds tuned on one image rarely survive a change of camera, exposure or white balance. Record the capture conditions, re-tune when the hardware changes, and prefer a white-balance step or an adaptive method over a hard-coded hue window for anything that has to work unattended.
FAQ
Should I convert to grayscale before processing?
Yes for edges, contours, thresholding and detection. Keep colour only when the colour itself is the signal, as in segmentation or tracking a coloured marker.
Why do my HSV thresholds work in Python but not on another image?
Almost always lighting or white balance. Hue is relatively stable, but saturation and value shift with exposure. Re-tune the S and V bounds and keep the hue window.
Related
Reading, writing and inspecting images Thresholding and image segmentation
Last refreshed 2026-09-18.