Images as Numbers: Pixels & Colour Spaces

30 min read Module 2 of 12 Topic 2 of 12

What you'll learn

  • Understand that images are arrays of numbers, not magic
  • Explain RGB, grayscale, HSV colour spaces and when to use each
  • Read, inspect, and manipulate image data with OpenCV and NumPy
  • Know why colour space choice affects detection accuracy
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

Every Image Is Just a Grid of Numbers

Here’s the most important thing to understand before writing a single line of computer vision code: a digital image is not a picture inside your computer. It is a rectangular grid of numbers.

When you look at a photo of a sunset, you see warm colours, cloud shapes, and a horizon. Your computer sees something like this:

[[ 255, 200, 100 ],
 [ 252, 198, 98  ],
 [ 248, 195, 95  ],
 ...

Each row in that grid is a row of pixels. Each pixel is a number (or a small group of numbers) representing colour. Everything in computer vision, neural networks, edge detectors, filters, is ultimately mathematics applied to these grids of numbers.

Once you truly internalize this, code that seemed mysterious becomes obvious.


Pixels: The Atom of Digital Images

The word pixel comes from “picture element”, it’s the smallest building block of a digital image. Zoom into any digital photo far enough and you’ll see the individual coloured squares that make it up. Each of those squares is one pixel.

For a grayscale (black-and-white) image:

  • Each pixel is a single number between 0 and 255
  • 0 = completely black
  • 255 = completely white
  • 128 = middle grey

Why 0 to 255? Because each pixel is stored as 8 bits of data (one byte), and 8 bits can represent 256 different values (2⁸ = 256).

A grayscale image is a 2D array: shape is (height, width). A 640×480 grayscale image has 640×480 = 307,200 numbers.

For a colour image, each pixel has three numbers, one for Red, one for Green, one for Blue. This is the RGB colour model, and it matches how colour screens and human eyes work: every colour you can see is a combination of red, green, and blue light.

A colour image is a 3D array: shape is (height, width, 3).


Loading and Inspecting Images in Python

Let’s bring this to life with code. First, load an image and look at what Python actually sees:

import cv2
import numpy as np

# cv2.imread returns a NumPy array
img = cv2.imread('photo.jpg')

print(type(img))    # <class 'numpy.ndarray'>
print(img.shape)    # (480, 640, 3)
print(img.dtype)    # uint8

Let’s understand what those three print statements tell us:

  • numpy.ndarray, the image is a NumPy array, Python’s standard structure for numerical data
  • (480, 640, 3), this image is 480 pixels tall, 640 pixels wide, with 3 colour channels per pixel
  • uint8, values are stored as unsigned 8-bit integers: whole numbers from 0 to 255

Now let’s access specific pixels:

# Get the colour values of the pixel at row 100, column 200
pixel = img[100, 200]
print(pixel)   # e.g., [45, 89, 142], these are B, G, R values

# Get just the Red channel value of that pixel
red_value = img[100, 200, 2]   # channel 2 = Red in BGR
print(red_value)   # e.g., 142

Important gotcha, BGR, not RGB: OpenCV loads images in BGR order (Blue, Green, Red) rather than the usual RGB (Red, Green, Blue). This is a historical quirk. Always remember: when you see img[row, col], the three values come back as [Blue, Green, Red].

To extract a rectangular region of the image (extremely useful for cropping and windowing):

# Extract a 50-row by 100-column region starting at row 50, column 200
region = img[50:100, 200:300]
print(region.shape)   # (50, 100, 3)

The : notation is NumPy slicing, 50:100 means “rows 50 through 99.” This is identical to Python list slicing.


The BGR Trap: Converting to RGB

Most libraries other than OpenCV expect RGB order (Red, Green, Blue). If you try to display an OpenCV image directly in Matplotlib or feed it to a PyTorch model expecting RGB, colours will look wrong, reds and blues will be swapped.

Always convert when crossing library boundaries:

import matplotlib.pyplot as plt

img_bgr = cv2.imread('photo.jpg')

# Wrong: colours will be off (red objects look blue)
plt.imshow(img_bgr)

# Correct: convert BGR → RGB first
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
plt.imshow(img_rgb)
plt.axis('off')
plt.show()

cv2.cvtColor is OpenCV’s colour space conversion function. You’ll use it constantly.


Grayscale: Simpler and Faster

Many CV algorithms don’t need colour. Edge detection, text recognition, and depth estimation all work perfectly on grayscale. Converting to grayscale reduces data by 3×, making operations faster.

# Convert BGR to grayscale
img_gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)

print(img_gray.shape)    # (480, 640), only 2D now, no channel dimension
print(img_gray[100, 200])  # a single number, e.g., 97

The conversion isn’t just an average of R, G, B. OpenCV uses a weighted formula based on human perception: Gray = 0.299×R + 0.587×G + 0.114×B. Green gets the highest weight because our eyes are most sensitive to green light.


HSV: Separating Colour from Lighting

Here’s a common problem in computer vision: you want to detect all orange objects in a scene. In RGB, the “same” orange object looks completely different depending on the lighting. Under bright sunlight it might be (255, 140, 0); under dim indoor light it might be (120, 66, 0). These look very different in RGB space, making detection unreliable.

HSV (Hue, Saturation, Value) solves this by splitting colour into three meaningful components:

  • Hue (0–179 in OpenCV): The pure colour, where on the rainbow does this sit? Red≈0, Yellow≈30, Green≈60, Blue≈120, Purple≈150
  • Saturation (0–255): How vivid is the colour? 0=completely grey, 255=intensely vivid
  • Value (0–255): How bright is it? 0=black, 255=full brightness

With HSV, “orange” is always around Hue=15 regardless of brightness. This makes colour-based detection much more robust.

# Convert to HSV
img_hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV)

# Define a colour range for "orange" in HSV
lower_orange = np.array([5,  100, 50])   # min Hue, Saturation, Value
upper_orange = np.array([25, 255, 255])  # max Hue, Saturation, Value

The np.array defines the bounds of our colour range. Let’s unpack the first one:

  • 5, Hue must be at least 5 (avoids pure red)
  • 100, Saturation must be at least 100 (must be reasonably vivid, not grey)
  • 50, Value must be at least 50 (not nearly black)

Now create a binary mask, white where pixels match the colour range, black everywhere else:

# Create a mask: 255 where orange pixels are, 0 everywhere else
mask = cv2.inRange(img_hsv, lower_orange, upper_orange)

# Apply mask to isolate only orange regions in the original image
orange_only = cv2.bitwise_and(img_bgr, img_bgr, mask=mask)

cv2.inRange outputs a 2D array where each value is either 255 (pixel is within range) or 0 (pixel is not). cv2.bitwise_and then zeroes out all pixels that aren’t orange. This pattern, convert to HSV, create mask, apply mask, is the foundation of colour-based object isolation.


Choosing the Right Colour Space

Colour SpaceUse When
BGR/RGBInput to neural networks; displaying images
GrayscaleEdge detection, OCR, any task where colour isn’t needed
HSVColour-based detection; tracking objects by colour
LABComparing colour similarity; normalising across cameras

A Practical Image Inspection Function

Here’s a template that prints key information about any image, use it at the start of any new CV project to understand your data:

import cv2
import numpy as np

def inspect_image(path: str):
    img = cv2.imread(path)
    if img is None:
        print(f"Could not load: {path}")
        return
    
    h, w, c = img.shape
    print(f"Size: {w}×{h} pixels, {c} channels")
    print(f"Data type: {img.dtype}")
    print(f"Value range: {img.min()} to {img.max()}")
    
    # Per-channel statistics
    for i, name in enumerate(['Blue', 'Green', 'Red']):
        ch = img[:, :, i]
        print(f"  {name}: mean={ch.mean():.1f}, std={ch.std():.1f}")

inspect_image('photo.jpg')

The per-channel statistics are especially useful: if the mean is much higher for one channel, your image has a colour cast. If standard deviation is very low, the image might be nearly uniform (over/under-exposed).

Exercise: Load any JPEG from your computer. Print its shape and dtype. Convert to HSV. Pick any colour in the image and try to isolate it with cv2.inRange: experiment with adjusting the H, S, V bounds and observe how the mask changes. How narrow can you make the bounds while still capturing the colour you want?

Knowledge Check

3 questions to test your understanding

1 A colour photo is stored as a 3D NumPy array. What do the three dimensions represent?

2 You want to detect orange objects regardless of whether the room is brightly or dimly lit. Which colour space should you use?

3 What does `img[100, 200, 2]` return for a BGR colour image loaded with OpenCV?

Discussion

Questions and notes from learners on this topic

Loading discussion…

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan