Capstone: Image as an Array
Reviewed & published by Brayan K
An image is simply a 3D NumPy array of shape (height, width, 3) holding uint8 values from 0 to 255 — one number per color channel (red, green, blue) for every pixel — which means every editing trick is really just array indexing, slicing, broadcasting, and aggregation. In this final capstone you'll build a synthetic image, set its channels, convert it to grayscale, flip and crop with slicing, invert its colors, and brighten it with clip — tying together everything from the whole course.
Part of the free NumPy course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- Images as 3D (height, width, 3) uint8 arrays
- Creating a blank image with np.zeros
- Setting color channels with slicing
- Grayscale via a weighted channel sum
- Flipping and cropping with slices
- Inverting and brightening with broadcasting & clip
1 An Image Is a 3D Array
A color image is a grid of pixels, and each pixel is three numbers — red, green, blue. NumPy stores that as a 3D array with shape (height, width, 3) and dtype uint8 (0–255). Create a blank black image with np.zeros.
The three axes are: axis 0 = rows (height), axis 1 = columns (width), axis 2 = color channels (R, G, B).
import numpy as np
# A 4 x 6 black image: 4 rows, 6 cols, 3 color channels
img = np.zeros((4, 6, 3), dtype=np.uint8)
print(img.shape) # (4, 6, 3)
print(img.dtype) # uint8
print(img[0, 0]) # [0 0 0] (every pixel starts black)
# ✅ Expected output:
# (4, 6, 3)
# uint8
# [0 0 0]Expected output: shape (4, 6, 3), dtype uint8, and the first pixel [0 0 0] (pure black).
2 Setting Color Channels
The last axis selects a channel: index 0 is red, 1 is green, 2 is blue. Assigning to a channel slice uses broadcasting to fill many pixels at once. Color cheat-sheet: [255, 0, 0] is red, [0, 255, 0] is green, [0, 0, 255] is blue, and [255, 255, 255] is white.
import numpy as np
img = np.zeros((4, 6, 3), dtype=np.uint8)
# Set the RED channel (index 0) of every pixel to full
img[:, :, 0] = 255
print(img[0, 0]) # [255 0 0] -> red
# Paint a green square in the lower-left 2x2 corner via broadcasting
img[2:4, 0:2] = [0, 255, 0]
print(img[3, 0]) # [ 0 255 0] -> green
# ✅ Expected output:
# [255 0 0]
# [ 0 255 0]Expected output: a red pixel [255 0 0] and a green corner pixel [0 255 0].
3 Converting to Grayscale
Grayscale collapses the 3 channels into a single brightness value per pixel using a weighted sum across the channel axis. The standard luminosity weights are 0.2989·R + 0.5870·G + 0.1140·B, which match how the human eye perceives color. np.dot applies them across the last axis.
import numpy as np
# A tiny 2 x 2 image: red, green / blue, white
img = np.array([
[[255, 0, 0], [ 0, 255, 0]],
[[ 0, 0, 255], [255, 255, 255]],
], dtype=np.uint8)
print(img.shape) # (2, 2, 3)
# Weighted sum across the channel axis -> a 2D (height, width) array
weights = np.array([0.2989, 0.5870, 0.1140]) # luminosity weights R, G, B
gray = np.dot(img[..., :3], weights)
print(gray.shape) # (2, 2)
print(gray.round(1)) # [[ 76.2 149.7]
# [ 29.1 255. ]]
# ✅ Expected output:
# (2, 2, 3)
# (2, 2)
# [[ 76.2 149.7]
# [ 29.1 255. ]]Expected output: a 2D (2, 2) array of brightness values about 76.2 (red), 149.7 (green), 29.1 (blue), 255.0 (white).
4 Flipping, Cropping, Inverting & Brightening
Geometry operations are pure slicing. img[::-1] reverses the row axis to flip top-to-bottom; img[:, ::-1] flips left-to-right. Cropping is a slice of the grid: img[r0:r1, c0:c1] keeps a rectangle of pixels (with all channels).
import numpy as np
# Label rows 0..3 by their red value so flips are easy to see
img = np.zeros((4, 4, 3), dtype=np.uint8)
for r in range(4):
img[r, :, 0] = r * 10 # red channel = 0, 10, 20, 30 by row
print(img[:, 0, 0]) # [ 0 10 20 30] (top -> bottom)
# Flip top-to-bottom (reverse rows), then crop the top-left 2x2 block
flipped = img[::-1]
print(flipped[:, 0, 0]) # [30 20 10 0]
print(img[0:2, 0:2].shape) # (2, 2, 3)
# ✅ Expected output:
# [ 0 10 20 30]
# [30 20 10 0]
# (2, 2, 3)Expected output: red values [0 10 20 30] top-to-bottom, reversed to [30 20 10 0] after the flip, and a crop of shape (2, 2, 3).
Inverting colors is broadcasting a scalar across the whole array: 255 - img turns black to white and red to cyan. Brightening adds a value but must stay in range — use np.clip(..., 0, 255) so nothing overflows past 255.
import numpy as np
img = np.array([[[ 10, 20, 30],
[200, 250, 100]]], dtype=np.uint8)
# Invert: broadcast 255 - img across every value
inverted = 255 - img
print(inverted[0, 0]) # [245 235 225]
print(inverted[0, 1]) # [ 55 5 155]
# Brighten by 40, clipping so values saturate at 255 (no uint8 wrap-around)
brighter = np.clip(img.astype(np.int16) + 40, 0, 255).astype(np.uint8)
print(brighter[0, 1]) # [240 255 140] (250 + 40 clips to 255)
# ✅ Expected output:
# [245 235 225]
# [ 55 5 155]
# [240 255 140]Expected output: inverted pixels [245 235 225] and [55 5 155]; the brightened pixel [240 255 140] — note 250 + 40 clips to 255, not a wrap-around.
🎯 YOUR TURN: Fill in the Blanks
Replace each ___ so the program builds a small blue image, then inverts it to yellow.
import numpy as np
# A 3 x 3 black image: rows, cols, and 3 color channels
img = np.zeros((3, 3, ___), dtype=np.uint8) # fill in the channel count
img[:, :, ___] = 255 # fill in the BLUE channel index
print(img[0, 0]) # [ 0 0 255] -> blue
# Invert by broadcasting 255 minus the image
inverted = ___ - img # fill in the value to subtract from
print(inverted[0, 0]) # [255 255 0] -> yellowExpected output: [0 0 255] then [255 255 0]. (Answers: 3, 2, 255.)
⚠️ Common Errors & Quick Reference
With uint8, 250 + 40 wraps to 34 instead of saturating, giving weird colors.
✅ Fix: compute in a wider type, then np.clip(result, 0, 255).astype(np.uint8).
❌ Mixing up axis order (width vs height)
The shape is (height, width, 3): rows come first, columns second.
✅ Fix: index as img[row, col], and remember axis 2 is the color channel.
| Task | Code |
|---|---|
| Blank image | np.zeros((h, w, 3), np.uint8) |
| Set red channel | img[:, :, 0] = 255 |
| Flip / crop | img[::-1] · img[r0:r1, c0:c1] |
| Invert | 255 - img |
| Brighten safely | np.clip(img + 40, 0, 255) |
🏆 Mini Challenge: A Full Edit Pipeline
Build a gradient image, brighten it with clip, flip it, then report its average brightness — tying together shape, dtype, slicing, broadcasting, clip, and aggregation.
import numpy as np
# A 4-pixel-wide gray gradient, 2 rows tall, 3 channels
row = np.array([50, 100, 150, 200], dtype=np.uint8)
img = np.stack([np.stack([row] * 3, axis=1)] * 2, axis=0)
print(img.shape) # (2, 4, 3)
# Brighten by 60, clipping so we saturate at 255
bright = np.clip(img.astype(np.int16) + 60, 0, 255).astype(np.uint8)
print(bright[0, :, 0]) # [110 160 210 255]
# Flip left-to-right (reverse columns), then average brightness
flipped = bright[:, ::-1]
print(flipped[0, :, 0]) # [255 210 160 110]
print(round(float(flipped.mean()), 1)) # 183.8
# ✅ Expected output:
# (2, 4, 3)
# [110 160 210 255]
# [255 210 160 110]
# 183.8❓ Frequently Asked Questions
🎉 Capstone complete — you've finished the NumPy course!
You treated an image as a 3D array and used every core NumPy skill — shape and dtype, indexing and slicing, broadcasting, aggregation, and clip — to create, recolor, grayscale, flip, crop, invert, and brighten it. That is real image processing, powered entirely by arrays.
🚀 Congratulations! From your first np.array to a full image-editing pipeline, you now have a working command of NumPy. Go build something amazing with it.
Practice quiz
What shape does a color image have as a NumPy array?
- (width, height)
- (channels, height, width)
- (height, width, channels)
- (height, width)
Answer: (height, width, channels). Color images are 3D arrays of shape (height, width, channels).
Which dtype is used for standard 0-255 image pixels?
- uint8
- float64
- int32
- bool
Answer: uint8. uint8 holds exactly the 0-255 range in a single byte per value.
What does np.zeros((4, 6, 3), dtype=np.uint8) create?
- A white image
- A 4x6 black RGB image
- A 6x4 grayscale image
- A 1D array
Answer: A 4x6 black RGB image. All-zero RGB pixels are black, with shape (4, 6, 3).
In an RGB image, which index selects the blue channel?
- 0
- 3
- 1
- 2
Answer: 2. Channel order is R=0, G=1, B=2, so blue is index 2.
What does img[:, :, 0] = 255 do?
- Sets every pixel to white
- Deletes the red channel
- Sets the red channel of every pixel to full
- Sets only the first pixel
Answer: Sets the red channel of every pixel to full. It assigns 255 to the red channel (index 0) of all pixels.
How is an RGB image converted to grayscale?
- A weighted sum across the channel axis
- By dropping the red channel
- By summing all axes
- By transposing the array
Answer: A weighted sum across the channel axis. Grayscale is a luminosity-weighted sum: 0.2989R + 0.5870G + 0.1140B.
What does img[::-1] do to an image?
- Inverts the colors
- Flips it top-to-bottom (reverses rows)
- Crops the image
- Flips it left-to-right
Answer: Flips it top-to-bottom (reverses rows). Slicing the row axis with ::-1 reverses the rows, a vertical flip.
Which expression inverts an image's colors?
- img * -1
- img.T
- np.flip(img)
- 255 - img
Answer: 255 - img. Broadcasting 255 - img turns each value into its complement.
Why use np.clip when brightening uint8 pixels?
- To make the image larger
- To prevent overflow wrap-around past 255
- To convert to float
- To flip the image
Answer: To prevent overflow wrap-around past 255. Clipping to 0-255 stops values from wrapping around in uint8.
Cropping a rectangle of an image is done with...
- slicing like img[r0:r1, c0:c1]
- np.crop(img)
- img.resize()
- img.reshape()
Answer: slicing like img[r0:r1, c0:c1]. Cropping is just slicing the row and column axes.
Continue this course
- Previous: Checkpoint: Scientific Computing
- Quick reference: NumPy cheat sheet
Frequently asked questions
How is a color image represented as a NumPy array?
A color image is a 3D array with shape (height, width, channels). The first two axes are the pixel grid and the last axis holds the color channels — usually 3 for red, green, and blue. Each value is a uint8 integer from 0 to 255, where 0 is no intensity and 255 is full intensity.
Why are image pixels stored as uint8?
uint8 is an unsigned 8-bit integer that holds exactly the 0-255 range each color channel needs, using just one byte per value. That keeps images compact in memory and matches how virtually every image format and display expects color data.
How do you convert, flip, or crop an image in NumPy?
Grayscale is a weighted sum across the channel axis: np.dot(img[..., :3], [0.2989, 0.5870, 0.1140]) collapses the channels into a 2D brightness array. Flipping and cropping are just slicing: img[::-1] reverses the rows (top-to-bottom flip), img[:, ::-1] flips left-to-right, and img[10:50, 20:80] crops a rectangle of pixels.