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 black255= completely white128= 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 pixeluint8, 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 Space | Use When |
|---|---|
| BGR/RGB | Input to neural networks; displaying images |
| Grayscale | Edge detection, OCR, any task where colour isn’t needed |
| HSV | Colour-based detection; tracking objects by colour |
| LAB | Comparing 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?