AI & Computer Vision, lesson 3 of 52
Python & NumPy Crash Course: An Image Is an Array
Introduction
In lesson 2 you printed frame.shape and got (480, 640, 3). That frame is a NumPy array: a grid of numbers Python can change all at once. Today you learn to handle it. You'll crop a photo, flip it, pull out one colour, and brighten it, using nothing but array code.
By the end of this lesson you'll be able to:
- Use lists, loops and functions to process a small set of values
- Create NumPy arrays and read their
shape,dtypeandsize - Explain why a greyscale photo is a 2-D array and a colour photo is a 3-D array
- Crop and flip an image using slicing
- Explain what goes wrong when a
uint8value goes past 255, and brighten an image safely - Explain why array code is much faster than a Python loop over pixels
Working principle
Python refresher: lists, loops, functions
You may know this already. If so, skim it.
scores = [12, 7, 15, 9] # a list
total = 0
for s in scores: # a loop
total = total + s
def average(values): # a function
return sum(values) / len(values)
print(total / len(scores), average(scores))NumPy arrays
A NumPy array holds numbers of one type in a fixed-size grid. It does maths on the whole grid at once.
| Idea | Example | Meaning |
|---|---|---|
| Create | np.array([12, 7, 15, 9]) | A 1-D array |
shape | (4,) or (480, 640, 3) | Size in each direction |
dtype | uint8 | The type of each number. uint8 means whole numbers 0 to 255. |
size | 921600 | How many numbers in total |
| Whole-array maths | a * 2 | Every number is doubled, with no loop |
An image is an array
- A greyscale image has shape
(height, width). One number per pixel. - A colour image has shape
(height, width, 3). Three numbers per pixel, in the order B, G, R in OpenCV. - Indexing is
img[row, column], which meansimg[y, x]. Row first. This is the number one mistake.
Slicing
| Goal | Code | Notes |
|---|---|---|
| Crop | img[y1:y2, x1:x2] | Rows first, then columns |
| Flip left to right | img[:, ::-1] | ::-1 means "backwards" |
| Flip upside down | img[::-1, :] | |
| One colour channel | img[:, :, 0] | 0 is blue, 1 is green, 2 is red |
| Paint a region | img[50:100, 50:100] = (0, 0, 255) | Writes red into a block |
Slices are views, not copies. crop = img[0:100, 0:100] shares memory with img. If you change crop, the original changes too. Use .copy() when you want an independent picture.
The uint8 trap
uint8 can only hold 0 to 255. Go past it and the number wraps round silently: 250 plus 10 is 4, not 255. A bright patch turns dark. The fix is to do the maths in a bigger type, clip, and convert back.
250 + 10 = 260, but uint8 only goes to 255, so it rolls round like a car's odometer to 4. A bright pixel turns dark! Clipping stops at 255.
What you'll need
- Your
cv_coursevirtual environment from lesson 2, with Jupyter installed - A football photo called
football_pitch.jpg(any wide photo works). No photo? The notebook makes a pretend pitch for you.
Build it
Each cell is a step. Run the cells in order and answer the question under each one. No football photo? Cell 3 makes a pretend pitch.
Cell 1: Python warm-up
scores = [12, 7, 15, 9]
total = 0
for s in scores:
total = total + s
print("Mean by loop:", total / len(scores))
def average(values):
return sum(values) / len(values)
print("Mean by function:", average(scores))Question: how would you change average so it ignores zeros?
Cell 2: NumPy basics
import numpy as np
a = np.array([12, 7, 15, 9])
print(a.shape, a.dtype, a.size)
print(a * 2)
print(a.mean())
grid = np.array([[0, 128, 255],
[128, 255, 128],
[255, 128, 0]], dtype=np.uint8)
print(grid.shape) # (3, 3)
print(grid[1, 2]) # row 1, column 2
print(grid[0, :]) # the whole top row
print(grid[:, 0]) # the whole left columnQuestion: what does grid[1, 2] print, and which row and column is that?
Cell 3: load an image (or make one)
import cv2
import matplotlib.pyplot as plt
img = cv2.imread("football_pitch.jpg")
if img is None: # no photo yet? make a fake pitch
img = np.zeros((300, 400, 3), dtype=np.uint8)
img[:] = (40, 140, 40) # green grass (B, G, R)
img[140:160, :] = (255, 255, 255) # white line
img[100:200, 180:220] = (0, 0, 220) # a red "player"
print("shape:", img.shape)
print("dtype:", img.dtype)
print("size :", img.size)
def show(image, title=""):
if image.ndim == 3:
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
plt.imshow(image)
else:
plt.imshow(image, cmap="gray")
plt.title(title)
plt.axis("off")
plt.show()
show(img, "Original")Question: what are the three numbers in img.shape? Which is the height?
Cell 4: crop, flip, channel
h, w = img.shape[:2]
crop = img[h // 4: 3 * h // 4, w // 4: 3 * w // 4].copy()
flipped = img[:, ::-1]
upside_down = img[::-1, :]
blue_only = img[:, :, 0]
print(crop.shape, flipped.shape, blue_only.shape)
show(crop, "Crop (middle half)")
show(flipped, "Flipped left to right")
show(upside_down, "Upside down")
show(blue_only, "Blue channel only")Question: why is blue_only a 2-D array? Why does the green grass look dark in it?
Cell 5: brighten, the wrong way and the right way
a = np.array([250, 100], dtype=np.uint8)
print("wrong:", a + 10) # 250 + 10 wraps round to 4
safe = np.clip(a.astype(np.int16) + 10, 0, 255).astype(np.uint8)
print("right:", safe)
bright = np.clip(img.astype(np.int16) + 60, 0, 255).astype(np.uint8)
dark = np.clip(img.astype(np.int16) - 60, 0, 255).astype(np.uint8)
show(bright, "Brighter (+60)")
show(dark, "Darker (-60)")Question: predict what a + 10 prints before you run it. Why does the white line stay white in bright?
Cell 6: the speed test
import time
big = np.random.randint(0, 256, (480, 640), dtype=np.uint8)
start = time.time()
total = 0
for row in big:
for pixel in row:
total += int(pixel)
loop_time = time.time() - start
start = time.time()
total2 = int(big.sum())
numpy_time = time.time() - start
print(f"Loop : {loop_time:.4f} s")
print(f"NumPy: {numpy_time:.6f} s")
print("Same answer:", total == total2)
print(f"NumPy is about {loop_time / max(numpy_time, 1e-9):.0f} times faster")Question: at 30 frames per second, how long can one frame take? Could the loop version keep up?
Cell 7: combine and save
result = np.hstack([img, bright]) # two images side by side
cv2.imwrite("s3_result.jpg", result)
print("Saved", result.shape)If it goes wrong
| Error | Cause | Fix |
|---|---|---|
IndexError: index 400 is out of bounds | x and y swapped | Rows (y) first, columns (x) second: img[y, x] |
img is None and img.shape fails | The path is wrong, so imread found nothing | Check the filename and folder. Use the fake pitch if needed. |
| Colours look wrong in Matplotlib | OpenCV is BGR, Matplotlib expects RGB | Convert with cv2.cvtColor(img, cv2.COLOR_BGR2RGB) before plt.imshow |
| Changing the crop changed the original | A slice is a view | Use .copy() |
| Bright image has black patches | uint8 wrapped past 255 | Convert to int16, add, np.clip, convert back |
ValueError: operands could not be broadcast | Shapes don't match | Print both shapes and check which is which |
np.hstack fails | The two images have different heights | Resize one first, or only combine same-size images |
Try it
- Mirror only the right half of the image, so the left half stays as it is
- Make a "photo negative" with
255 - imgand explain why it never overflows - Draw a 10-pixel black border by slicing the four edges
Check what you learnt
A colour photo 640 pixels wide and 480 tall. What is its shape?
You want rows 100 to 200 and columns 50 to 150. Which slice?
What does img[:, ::-1] do?
In uint8, what is 250 + 10?
You change crop[:] = 0 and the original photo goes black too. Why?
Conclusion
A photo is a NumPy array: (height, width, 3), rows first. Slicing crops and flips it, whole-array maths changes every pixel at once (hundreds of times faster than a loop), and uint8 wraps round past 255, so brighten with int16 and np.clip.
What next?
Next: Images, pixels & colour spaces. You'll find out what those three numbers per pixel really mean, and use colour to pick out the pitch.

