AI & Computer Vision, lesson 4 of 52
Images, Pixels & Colour Spaces: Finding the Pitch with HSV
Introduction
You know a pixel is three numbers. But which three numbers is a choice, and the right choice makes a computer-vision job easy or impossible. Today you'll meet four ways of describing colour, and use the best one, HSV, to make a computer find the green football pitch in a photo, even where the grass is in shadow.
By the end of this lesson you'll be able to:
- Explain what a pixel is and how an image is stored as a NumPy array
- Describe the RGB colour model and read single-channel values
- Convert an image between BGR, RGB, grayscale and HSV with OpenCV
- Explain why HSV is more useful than RGB for colour-based automation
- Write a function that isolates a colour range with
cv2.inRange() - Use a hue mask to isolate the green football pitch from a real image
Working principle
What is a pixel?
A pixel (picture element) is the smallest unit of a digital image. A colour image has three numbers per pixel, one for each colour channel.
Key fact. A 640 by 480 webcam frame is 640 columns by 480 rows, which is 307,200 pixels. Each has 3 values (blue, green, red) from 0 to 255, so one frame is 921,600 numbers. A CV model processes that every frame, 30 times a second.
You met this in lesson 3. In Python an image is a 3-D array with shape (height, width, channels):
import cv2
img = cv2.imread("football_pitch.jpg")
print(img.shape) # (480, 640, 3): height, width, channels
print(img.dtype) # uint8: values 0 to 255
print(img[240, 320]) # one pixel at row 240, column 320: [B, G, R]Colour models: BGR, RGB, grayscale, HSV
OpenCV loads images as BGR (blue first), not RGB. This is one of the most common beginner mistakes.
| Model | What it stores | Best used for | OpenCV conversion |
|---|---|---|---|
| RGB or BGR | 3 channels: red, green, blue (0 to 255). OpenCV reads BGR. | Display, basic image work. Not ideal for colour filtering. | cv2.cvtColor(img, cv2.COLOR_BGR2RGB) |
| Grayscale | 1 channel: brightness only (0 black, 255 white) | Edges, shapes, motion | cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) |
| HSV | 3 channels: hue (colour type, 0 to 179), saturation (richness, 0 to 255), value (brightness, 0 to 255) | Colour filtering and masks. Best for isolating the pitch. | cv2.cvtColor(img, cv2.COLOR_BGR2HSV) |
| LAB | Lightness, a (green to red), b (blue to yellow). Matches how people see colour differences. | Colour correction, advanced filtering | cv2.cvtColor(img, cv2.COLOR_BGR2LAB) |
Why HSV beats RGB for automation
RGB mixes colour and brightness together. If the light changes, the RGB values of the grass change a lot, which makes one reliable rule hard to write. HSV separates hue (what colour) from value (how bright). You can describe "pitch green" once by its hue, and it keeps working in different light.
These values were measured with cv2.cvtColor on the same grass in three lighting conditions:
| Condition | BGR | HSV (H, S, V) |
|---|---|---|
| Pitch green, sunny | (34, 142, 28) | (62, 205, 142) |
| Pitch green, shade | (18, 82, 14) | (62, 211, 82) |
| Pitch green, floodlit | (22, 100, 20) | (61, 204, 100) |
| What changes? | All three numbers change a lot | Hue stays at about 62. Only value (brightness) changes. |
The HSV hue wheel: finding pitch green
In OpenCV, hue runs from 0 to 179, not 0 to 360. OpenCV halves it so it fits in one byte. Reference ranges:
| Colour | Hue range (OpenCV) | Sports use | Lower and upper bound (H, S, V) |
|---|---|---|---|
| Red (kit) | 0 to 10 and 160 to 179 | Red team shirt | (0, 120, 70) to (10, 255, 255) |
| Orange (ball) | 10 to 25 | Orange ball, cones | (10, 150, 100) to (25, 255, 255) |
| Yellow (kit) | 25 to 35 | Yellow shirt, yellow card | (25, 150, 100) to (35, 255, 255) |
| Green (pitch) | 35 to 85 | Isolating the pitch | (35, 50, 50) to (85, 255, 255) |
| Blue (sky, kit) | 100 to 130 | Blue shirt, sky | (100, 120, 70) to (130, 255, 255) |
| White (lines) | Any hue, low saturation | Pitch lines | (0, 0, 200) to (179, 40, 255) |
Red wraps around the end of the hue wheel, so it needs two ranges joined together.
Grass has hue ≈ 60 in the sun and in the shade, so one range catches both. 16 of 24 pixels are in the mask. Try a range that also grabs the red shirts (hue ≈ 5).
What you'll need
- Your
cv_courseenvironment with Jupyter - A football photo,
football_pitch.jpg(a wide shot with lots of grass works best) - A webcam for the live extension
Build it
Each cell is a step. You run the cells in order, look at the output, then answer the question under each one. The first cell makes a fake pitch if there is no photo, so the lab always runs.
Cell 1: set-up and load
import cv2
import numpy as np
import matplotlib.pyplot as plt
# OpenCV reads BGR
img_bgr = cv2.imread("football_pitch.jpg")
if img_bgr is None: # no photo? make a fake pitch
img_bgr = np.zeros((300, 400, 3), dtype=np.uint8)
img_bgr[:] = (34, 142, 28) # grass (B, G, R)
img_bgr[:60, :] = (120, 120, 120) # grey stand at the top
img_bgr[140:150, :] = (255, 255, 255) # white line
cv2.circle(img_bgr, (120, 200), 14, (200, 60, 30), -1) # a blue-shirt player
cv2.circle(img_bgr, (260, 220), 14, (30, 30, 210), -1) # a red-shirt player
# Matplotlib wants RGB
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
h, w = img_bgr.shape[:2]
print(f"Shape: {img_bgr.shape}")
print(f"Dtype: {img_bgr.dtype}")
print(f"Centre pixel BGR: {img_bgr[h // 2, w // 2]}")
plt.imshow(img_rgb)
plt.title("Original image")
plt.axis("off")
plt.show()Question: what is the shape of the image? What do the three numbers mean?
Cell 2: split into channels
b_ch, g_ch, r_ch = cv2.split(img_bgr)
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
for ax, ch, title in zip(axes, [b_ch, g_ch, r_ch], ["Blue", "Green", "Red"]):
ax.imshow(ch, cmap="gray")
ax.set_title(title)
ax.axis("off")
plt.show()Question: which channel looks brightest over the grass? Why does that make sense?
Cell 3: grayscale and HSV
gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV)
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
axes[0].imshow(img_rgb)
axes[0].set_title("Original")
axes[1].imshow(gray, cmap="gray")
axes[1].set_title("Grayscale")
axes[2].imshow(hsv[:, :, 0], cmap="hsv")
axes[2].set_title("Hue channel")
for ax in axes:
ax.axis("off")
plt.show()
print(f"Centre pixel HSV: {hsv[h // 2, w // 2]}")Question: print the HSV of a pixel you know is on the pitch. What is the hue? Is it between 35 and 85?
Cell 4: bounds and mask
lower_green = np.array([35, 50, 50]) # [Hue min, Saturation min, Value min]
upper_green = np.array([85, 255, 255]) # [Hue max, Saturation max, Value max]
# White where the colour is in range, black everywhere else
mask = cv2.inRange(hsv, lower_green, upper_green)
print(f"Mask shape: {mask.shape}")
print(f"Pitch pixels detected: {np.sum(mask > 0)}")
plt.figure(figsize=(8, 5))
plt.imshow(mask, cmap="gray")
plt.title("Green mask (white = pitch pixels)")
plt.axis("off")
plt.show()Question: does the white area match the pitch? Are any shirts leaking in? Try raising the saturation minimum.
Cell 5: apply the mask
pitch_only = cv2.bitwise_and(img_bgr, img_bgr, mask=mask)
pitch_rgb = cv2.cvtColor(pitch_only, cv2.COLOR_BGR2RGB)
fig, axes = plt.subplots(1, 3, figsize=(18, 5))
axes[0].imshow(img_rgb)
axes[0].set_title("Original")
axes[1].imshow(mask, cmap="gray")
axes[1].set_title("Mask")
axes[2].imshow(pitch_rgb)
axes[2].set_title("Pitch isolated")
for ax in axes:
ax.axis("off")
plt.tight_layout()
plt.show()
total_pixels = mask.shape[0] * mask.shape[1]
pitch_pixels = np.sum(mask > 0)
coverage = pitch_pixels / total_pixels * 100
print(f"Pitch covers {coverage:.1f}% of the image")Question: what percentage of the image is pitch? What is still leaking through the mask?
Cell 6: extension, isolate a kit colour
lower_kit = np.array([100, 120, 70]) # blue kit example
upper_kit = np.array([130, 255, 255])
kit_mask = cv2.inRange(hsv, lower_kit, upper_kit)
kit_only = cv2.bitwise_and(img_bgr, img_bgr, mask=kit_mask)
kit_rgb = cv2.cvtColor(kit_only, cv2.COLOR_BGR2RGB)
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
axes[0].imshow(kit_mask, cmap="gray")
axes[0].set_title("Kit mask")
axes[1].imshow(kit_rgb)
axes[1].set_title("Kit isolated")
for ax in axes:
ax.axis("off")
plt.show()
print("Kit pixels:", int(np.sum(kit_mask > 0)))Question: can you cleanly isolate one team's kit? What problems did you hit? How might you fix them?
Extension: live HSV pitch mask on the webcam
Add this to the lesson 2 webcam_lab.py loop. Press H to toggle the mask. Point the camera at anything green.
import cv2
import numpy as np
lower_green = np.array([35, 50, 50])
upper_green = np.array([85, 255, 255])
cap = cv2.VideoCapture(0)
show_mask = False
while True:
ret, frame = cap.read()
if not ret:
break
key = cv2.waitKey(1) & 0xFF
if key == ord("q"):
break
if key == ord("h"):
show_mask = not show_mask
if show_mask:
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
mask = cv2.inRange(hsv, lower_green, upper_green)
frame = cv2.bitwise_and(frame, frame, mask=mask)
cv2.putText(frame, "HSV MASK: GREEN", (10, frame.shape[0] - 15),
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2, cv2.LINE_AA)
cv2.imshow("Live mask", frame)
cap.release()
cv2.destroyAllWindows()If it goes wrong
| Error | Cause | Fix |
|---|---|---|
| Image shows with wrong colours (red grass, blue sky) | imread gives BGR, Matplotlib expects RGB | Convert with cv2.COLOR_BGR2RGB before plt.imshow |
cv2.imread() returns None | Wrong path or filename | import os and check os.path.exists("football_pitch.jpg") |
| Mask is completely black | HSV bounds don't fit this image, or S and V minimums are too high | Print the HSV of a pixel you can see is pitch, and set the bounds around it |
| Kit colour leaks into the pitch mask | A green shirt falls inside the hue range | Raise the saturation minimum. Grass is usually more saturated than a shirt. |
cv2.inRange error about bounds | Bounds have the wrong number of values or the wrong type | Each must be np.array([H, S, V]) with exactly 3 values |
| The red kit mask misses half the shirt | Red wraps around the hue wheel | Build two masks (0 to 10 and 160 to 179) and combine them with cv2.bitwise_or |
Try it
- Extension cell: isolate a kit colour and count how many pixels belong to each team
- Use the mask to calculate what percentage of the frame is pitch, crowd and players
- Research: why does OpenCV use 0 to 179 for hue instead of 0 to 360?
Check what you learnt
A 640 by 480 colour image has which shape in NumPy?
What is OpenCV's channel order?
Why is HSV better than RGB for finding a colour?
What does cv2.inRange(hsv, lower, upper) give you?
What range does OpenCV use for hue?
Conclusion
A pixel can be described as BGR, RGB, greyscale or HSV. For finding things by colour, HSV wins: hue says which colour, and it stays steady when the light changes. cv2.inRange() turns a hue range into a mask, the first real "decision" a computer-vision program makes.
What next?
Next: Video basics: frames, FPS, reading & saving video. Next you go from one picture to many: video.

