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
SpaceChannelsRange in 8-bit OpenCVUse for
BGRB, G, R0-255 eachReading, writing, drawing, storage
RGBR, G, B0-255 eachHanding images to a library expecting RGB
Grayscale10-255Most classical processing and detection
HSVH, S, VH 0-179, S 0-255, V 0-255Colour segmentation, tracking by colour
LABL, A, BL 0-255, A/B 0-255 offset by 128Perceptual difference, colour correction
YCrCbY, Cr, Cb0-255 eachSkin 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.split returns 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.imread drops the alpha channel by default. Use IMREAD_UNCHANGED when 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.

Reading, writing and inspecting images Thresholding and image segmentation

Last refreshed 2026-09-18.