A pixel is just numbers - until you say what they mean
In the previous part we saw how a camera sensor turns light into a grid of numbers, one small reading per pixel. What we did not dwell on is that those numbers only become a colour once we agree on how to read them. The same three numbers, say 200, 30, 30, could mean a bright red, a dull brown, or nothing at all, depending on the rules you use to interpret them. Those rules are what we call a colour space.
A useful analogy is temperature scales. Twenty degrees Celsius and sixty-eight degrees Fahrenheit describe exactly the same warmth, but you cannot compare the raw numbers 20 and 68 without knowing which scale each one is written in. Colour spaces work the same way: RGB, greyscale and HSV all describe the same underlying light, but they package the information differently, and some packagings make certain jobs much easier than others. Picking out "everything orange" in a photo, for instance, is fiddly in RGB and almost trivial in HSV, as you will see later in this part.
RGB: the space your camera and screen speak
RGB stands for Red, Green, Blue. It is an additive colour model, which means you build colours by adding light together, the opposite of mixing paint. A screen pixel is made of three tiny lights, one red, one green, one blue, and your eye blends them. Turn all three up fully and you get white. Turn all three off and you get black. This matches how your camera's sensor works too, since each sensor pixel sits behind a red, green or blue filter, as covered in the previous part.
In an 8-bit image, the most common case, each of the three channels holds a value from 0 to 255. Zero means none of that colour of light, 255 means the maximum the format can represent. That gives 256 times 256 times 256, a little over 16.7 million, possible combinations per pixel. A full colour image is simply a grid of pixels, each one holding three such numbers.
- Pure red is (255, 0, 0): all red, no green, no blue
- Pure green is (0, 255, 0)
- Pure blue is (0, 0, 255)
- Yellow is (255, 255, 0): red and green light combined, no blue
- White is (255, 255, 255): all three channels at maximum
- Black is (0, 0, 0): all three channels at zero
- A mid grey is (128, 128, 128): equal amounts of all three, at half brightness
Notice the last point: whenever the three channels are equal, you get a shade of grey, from black up to white. Colour appears precisely when the channels are unequal. That single observation is the seed of the greyscale conversion we will look at next.
import numpy as np
# An image in NumPy is just an array with shape (height, width, channels).
# Here we build a tiny 2x2 image by hand, listing channels as (R, G, B)
# for this example only - note that OpenCV itself expects (B, G, R), which
# is exactly the trap covered later in this part.
swatch_rgb = np.array([
[[255, 0, 0], [0, 255, 0]],
[[0, 0, 255], [255, 255, 0]]
], dtype=np.uint8)
print("shape:", swatch_rgb.shape) # (2, 2, 3) -> height, width, channels
print("top-left pixel:", swatch_rgb[0, 0]) # [255, 0, 0] -> red
print("bottom-right pixel:", swatch_rgb[1, 1]) # [255, 255, 0] -> yellow
This code needs nothing more than NumPy and runs anywhere, no camera or image file required. The shape (2, 2, 3) tells you this is a 2 pixel tall, 2 pixel wide image with 3 channels per pixel. Indexing is height first, then width, which is the opposite of the x, y order you might expect from maths or graphics - worth keeping in mind every time you slice an image array, since getting it backwards is a common source of bugs later in this path.
Greyscale: keeping only brightness
A greyscale image throws away colour and keeps only brightness, one number per pixel instead of three. This sounds like a downgrade, and in one sense it is, but it is also extremely useful. Many of the operations coming up later in this path, edge detection, thresholding and contour finding among them, care only about how light or dark something is, not what hue it is. Working with one channel instead of three is simpler to reason about and roughly three times less data to process, which matters once you are running these operations on every frame of a webcam feed.
A tempting shortcut is to average the three channels: grey equals (R plus G plus B) divided by three. This is not wrong exactly, but it does not match how human eyes perceive brightness. Your eyes are considerably more sensitive to green light than to blue light, so a pure green pixel looks noticeably brighter to a person than a pure blue pixel of the same numeric intensity, even though a simple average would treat them as identical. Image processing libraries correct for this by using a weighted sum instead of a plain average, giving green the largest weight, red a middling weight, and blue the smallest.
OpenCV's standard grey conversion uses weights of approximately 0.299 for red, 0.587 for green and 0.114 for blue. These are the commonly documented weights for this conversion, but treat the exact decimal values as a detail to double check against your installed version's documentation rather than something to memorise, since conversion formulas occasionally differ slightly between standards and library versions.
import numpy as np
import cv2
print("OpenCV version:", cv2.__version__)
# Remember: OpenCV images are NumPy arrays in BGR order (Blue, Green, Red),
# not RGB. We build three 1x1 patches of pure colour using that order.
pure_blue = np.full((1, 1, 3), (255, 0, 0), dtype=np.uint8) # B=255
pure_green = np.full((1, 1, 3), (0, 255, 0), dtype=np.uint8) # G=255
pure_red = np.full((1, 1, 3), (0, 0, 255), dtype=np.uint8) # R=255
for name, patch in [("blue", pure_blue), ("green", pure_green), ("red", pure_red)]:
grey = cv2.cvtColor(patch, cv2.COLOR_BGR2GRAY)
print(f"pure {name} -> grey value {grey[0, 0]}")
Running this prints roughly grey value 29 for pure blue, 150 for pure green and 76 for pure red. All three started from a channel at full intensity, 255, yet the grey results are very different. That is the weighting at work: a pixel that is entirely blue is converted to a fairly dark grey, while a pixel that is entirely green ends up noticeably lighter, which matches how bright those colours actually appear to a human eye. If you had used a plain average instead, all three would have come out identical at roughly 85, which would not reflect how we perceive them at all.
The BGR trap: OpenCV's one big surprise
Here is a detail that catches almost everyone the first time they use OpenCV: when you load a colour image with cv2.imread, the array you get back stores channels in the order Blue, Green, Red, not Red, Green, Blue. This is a long-standing convention in OpenCV, not a bug, and it has stayed this way across versions for backward compatibility, but it means that any time you hand an OpenCV image to a library that expects RGB, such as Matplotlib or Pillow, the colours will come out visibly wrong, typically with reds and blues swapped, so skin tones look bluish and skies look orange.
import cv2
import matplotlib.pyplot as plt
# Use any photo you have on your laptop, for example one you copied over
# from your phone, or a screenshot saved as a JPG or PNG.
img_bgr = cv2.imread("photo.jpg")
if img_bgr is None:
raise FileNotFoundError("Could not find photo.jpg - put an image file next to this script")
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
fig, axes = plt.subplots(1, 2, figsize=(8, 4))
axes[0].imshow(img_bgr)
axes[0].set_title("As loaded (colours swapped)")
axes[0].axis("off")
axes[1].imshow(img_rgb)
axes[1].set_title("After BGR2RGB (correct)")
axes[1].axis("off")
plt.show()
The left-hand image will look off, usually with a blue or orange cast depending on the photo, because Matplotlib assumes RGB order and is given BGR data instead. The right-hand image, converted with cv2.cvtColor and the COLOR_BGR2RGB flag, should look like the original photo. The fix is one line, but it is a line you need every single time you move image data between OpenCV and almost any other Python library, so it is worth committing to memory now, well before part four covers OpenCV's image reading and display functions in more depth.
HSV: separating colour from light
RGB is a natural fit for cameras and screens, but it is an awkward space for reasoning about colour the way people actually talk about it. If you wanted to write a rule that says "find anything orange in this image", you would need to figure out every combination of red, green and blue values that looks orange, which is a surprisingly wide and irregular set of triples. HSV was designed to make exactly this kind of question easy to ask.
HSV stands for Hue, Saturation and Value, and it splits colour into three more intuitive ideas. Hue is the colour itself, the position on a colour wheel: red, orange, yellow, green, cyan, blue, magenta, and back to red. Saturation is how vivid or washed out that colour is, from a pale, greyish version up to a bold, fully saturated one. Value is simply how bright or dark the colour is, from black up to full brightness. A helpful way to picture it: hue picks which crayon you grabbed, saturation is how hard you pressed it onto the paper, and value is how much light is falling on the page.
Here is the precise part, and it includes an OpenCV-specific detail worth knowing. In most descriptions of HSV, hue is measured in degrees around a wheel, from 0 to 360. But OpenCV stores images using 8 bits per channel, and 8 bits can only count up to 255, not 360, so OpenCV halves every hue value to fit: its hue channel runs from 0 to 179 instead of 0 to 360. Saturation and value both run from 0 to 255, same as any other 8-bit channel. Forgetting this halving is one of the most common mistakes people make the first time they pick colour thresholds by hand, since every tutorial or colour picker written for the general 0 to 360 scale needs its hue values divided by two before you use them in OpenCV.
import numpy as np
import cv2
colours_bgr = {
"red": (0, 0, 255),
"green": (0, 255, 0),
"blue": (255, 0, 0),
"yellow": (0, 255, 255),
}
for name, bgr in colours_bgr.items():
patch = np.full((1, 1, 3), bgr, dtype=np.uint8)
hsv = cv2.cvtColor(patch, cv2.COLOR_BGR2HSV)
h, s, v = hsv[0, 0]
print(f"{name:6s} BGR={bgr} -> H={h}, S={s}, V={v}")
The output should show something close to: red at H=0, green at H=60, blue at H=120, yellow at H=30, each with saturation and value at 255 since these are pure, fully bright colours. Compare this with the standard 0 to 360 hue wheel, where red sits at 0 degrees, green at 120 degrees, blue at 240 degrees and yellow at 60 degrees - every value here is exactly half, which is the 0 to 179 scaling in action.
Using HSV to find objects by colour
The reason HSV is so widely used in computer vision is that it isolates colour identity, the hue, from lighting conditions, which mostly show up as changes in value and, to a lesser extent, saturation. A red mug sitting in bright sunlight and the same mug in a dim room will have very different RGB values, since all three channels drop together as the light dims, but its hue stays close to the same spot on the wheel in both cases. That makes thresholding on a narrow band of hue, combined with reasonable saturation and value limits, a far more robust way to pick out "the red mug" than trying to threshold directly on RGB values.
If you do not already have a suitably colourful object to try this on, you can grab a single frame from your webcam, which works on any laptop with a built-in or USB camera.
import cv2
cap = cv2.VideoCapture(0)
ret, frame = cap.read()
cap.release()
if ret:
cv2.imwrite("webcam_frame.jpg", frame)
print("Saved a frame from your webcam as webcam_frame.jpg")
else:
print("Could not read from the webcam - check it is connected and not in use by another app")
Point an orange object, such as a piece of fruit or a highlighter, at the camera before running this, then use the saved frame in the next step.
import numpy as np
import cv2
frame = cv2.imread("webcam_frame.jpg")
if frame is None:
raise FileNotFoundError("webcam_frame.jpg not found - run the capture script first")
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
# A rough hue band for orange, remembering OpenCV's 0-179 scale.
# Saturation and value limits exclude dull, dark or washed-out pixels.
lower_orange = np.array([5, 100, 100])
upper_orange = np.array([20, 255, 255])
mask = cv2.inRange(hsv, lower_orange, upper_orange)
cv2.imwrite("orange_mask.jpg", mask)
print("Saved orange_mask.jpg - white pixels are inside the hue range, black pixels are outside it")
Open orange_mask.jpg and you should see a black and white image: white wherever the original pixel's hue, saturation and value all fell inside the ranges given, black everywhere else. If your object does not show up well, try widening the ranges slightly, particularly the saturation and value lower bounds, since indoor lighting often produces duller colours than you would expect. This same inRange technique is the foundation of the thresholding and masking methods covered in more depth in part seven of this path; here the goal is just to see HSV earning its keep.
Which space to use, when
- RGB: the space to use for display, for saving files, and as the format most image files and libraries expect by default. It is what you start and end with, even if you convert to something else in between.
- Greyscale: the space to use when colour is irrelevant and you care about shape, brightness or structure. Edge detection, many thresholding techniques, and contour finding, covered in parts five, six and seven, typically work on a greyscale image rather than a full colour one, partly for simplicity and partly for speed.
- HSV: the space to use when you want to find or separate things by colour, especially under changing or uneven lighting, since hue stays relatively stable while value absorbs most of the brightness changes.
It is worth knowing that RGB, greyscale and HSV are not the only colour spaces in use. Others such as LAB or YCrCb show up in specific tasks like colour correction or video compression, but RGB, greyscale and HSV cover the overwhelming majority of what you will need for the rest of this path, so we will not go further into the alternatives here.
Common mistakes
- Assuming cv2.imread returns RGB data. It returns BGR, and forgetting this is the single most common source of wrong-looking colours when combining OpenCV with other libraries.
- Assuming hue runs from 0 to 360 in OpenCV. For 8-bit images it runs from 0 to 179, so hue values copied from a design tool or colour picker that uses the 0 to 360 scale need to be halved first.
- Forgetting that red wraps around the hue wheel. Red sits at both ends of the scale, roughly 0 to 10 and roughly 170 to 179, so isolating red objects usually needs two separate inRange calls combined, rather than one range as with other colours.
- Converting to greyscale with a plain average of the three channels instead of letting cv2.cvtColor apply the proper weighted formula, which produces a result closer to how brightness is actually perceived.
- Doing arithmetic directly on uint8 image arrays and getting unexpected wraparound. Pixel values are stored as 8-bit integers, 0 to 255, and plain NumPy addition wraps around silently rather than capping, as shown below.
import numpy as np
a = np.array([200], dtype=np.uint8)
b = np.array([100], dtype=np.uint8)
print("plain NumPy addition:", a + b) # wraps around to 44, not 300
import cv2
print("cv2.add (saturates):", cv2.add(a, b)) # correctly caps at 255
200 plus 100 is 300, but a uint8 value can only hold up to 255, so plain NumPy addition wraps around and silently gives 44 instead of raising an error. OpenCV's own arithmetic functions, such as cv2.add, are aware of this limit and cap the result at 255 instead of wrapping. This distinction matters once you start combining or blending images in later parts of this path, and it is easy to miss because NumPy will not warn you when it happens.
Summary and what comes next
A colour space is simply an agreed way of reading the numbers stored in a pixel. RGB describes colour as a mix of red, green and blue light, matching how cameras capture and screens display images, but OpenCV stores those three channels in the reverse order, BGR, which you need to correct for whenever you work with other libraries. Greyscale compresses a pixel down to a single brightness value using a weighted combination of the three channels, favouring green since the eye is most sensitive to it. HSV separates colour identity, hue, from vividness, saturation, and brightness, value, making it far better suited to tasks like finding objects by colour, with the particular detail that OpenCV scales hue to 0-179 rather than the usual 0-360.
With pixels, images and colour spaces now covered, the next part moves into OpenCV itself in more detail: reading images and video properly, showing them in windows, saving results to disk, and the handful of functions you will reuse constantly throughout the rest of this path.
Comments (0)
No comments yet. Be the first to share your thoughts.