Project: images are arrays
Gray, crop, brighten, watermark and palette tools built on NumPy and Pillow.
Why it matters
Every image a vision model sees is an array like the ones you'll build here, and every image tool you've used (filters, crops, thumbnails) is array math underneath. This project makes NumPy concrete: you'll see slicing, broadcasting and masks as pictures. It also gives you a handful of functions you'll actually use, from thumbnails to watermarks to color palettes.
What you'll be able to do
- Treat a picture as a (height, width, 3) array of numbers from 0 to 255
- Draw shapes with boolean masks, then flip, crop and turn an image gray with slicing and math
- Brighten an image without numbers wrapping around past 255
- Use Pillow to add text and save a file, and find an image's most common colors
What it covers
- The brief
- Drawing a scene with masks
- Flip, crop and gray
- Brighter without wrapping around
- A border around a photo
- Pillow: text, shapes and files
- A color palette
- Swap red and blue
- Worked example: a contact sheet
The brief
The practice shop wants product shots processed before they go on the website: turned gray for a print catalog, brightened, cropped square for thumbnails, stamped with a watermark, and summed up as a color palette. You'll draw a test image in code, then write one function for each job.
A picture is a grid of numbers
A color image is a 3-D array with the shape (height, width, 3). Each pixel holds three numbers: how much red, green and blue light it gives off, from 0 (none) to 255 (full). The type is uint8, an "unsigned 8-bit integer", which holds exactly the whole numbers 0 to 255.
import matplotlib.pyplot as plt import numpy as np tiny = np.array([ [[255, 0, 0], [0, 255, 0], [0, 0, 255]], [[255, 255, 255], [128, 128, 128], [0, 0, 0]], ], dtype=np.uint8) print(tiny.shape, tiny.dtype) print(tiny[0, 2]) # row 0, column 2: the blue pixel fig, ax = plt.subplots(figsize=(3, 2)) ax.imshow(tiny) ax.axis("off")
Row 0 is the top of the picture. Red, green and blue at full strength make white; all three at 0 make black; equal amounts in between make gray. imshow draws an array as an image, and axis("off") hides the tick marks, which mean nothing for a photo.
Drawing a scene with masks
To draw a shape, make a mask: a True/False array with one value per pixel, True inside the shape. Assigning to img[mask] colors every True pixel at once, with no loop over pixels at all. The trick for building masks is a column of row numbers and a row of column numbers. Broadcasting compares them into a full grid:
import matplotlib.pyplot as plt import numpy as np def make_scene(h=120, w=160): img = np.zeros((h, w, 3), dtype=np.uint8) rows = np.arange(h)[:, None] # shape (h, 1): 0, 1, 2 ... down the side cols = np.arange(w)[None, :] # shape (1, w): 0, 1, 2 ... along the top img[:, :] = (150, 200, 235) # sky everywhere img[h * 2 // 3:, :] = (70, 130, 60) # grass on the bottom third sun = (rows - 25) ** 2 + (cols - 130) ** 2 <= 12 ** 2 img[sun] = (250, 215, 80) tent = (rows >= 30) & (rows <= 100) & (abs(cols - 70) <= (rows - 30) * 0.8) img[tent] = (230, 120, 40) return img scene = make_scene() print(scene.shape) fig, ax = plt.subplots(figsize=(4, 3)) ax.imshow(scene) ax.axis("off")
The sun is every pixel within 12 of a center point, the formula for a circle. The tent is a triangle: the further down a row is, the wider the band of columns it covers. Both masks have the shape (120, 160), one value per pixel, so img[sun] picks exactly the pixels to paint.
Flip, crop and gray
Slicing does most of the simple jobs, because an image is just an array:
import numpy as np img = np.arange(24, dtype=np.uint8).reshape(2, 4, 3) # a 2 x 4 image print(img[::-1].shape) # upside down: rows reversed print(img[:, ::-1].shape) # mirrored: columns reversed print(img[:, 1:3].shape) # a crop: columns 1 and 2 print(img[:, :, 0]) # the red channel alone: a 2 x 4 grid
A gray image has one number per pixel. You can't just average red, green and blue, because our eyes are far more sensitive to green than to blue. The standard weights are 0.299 for red, 0.587 for green and 0.114 for blue. Matrix multiplication applies them to every pixel at once: img @ [0.299, 0.587, 0.114] turns shape (h, w, 3) into (h, w).
Start with the tent's orange (230, 120, 40) and brighten by 40: as uint8 the red value wraps around to 14, so bright red turns almost black. Clipping stops at 255 instead. Then try pure green against pure blue to see how different their grays are.
Brighter without wrapping around
A uint8 can't hold 256, so arithmetic wraps around: 250 + 10 becomes 4, the way a car's odometer rolls over. NumPy does this silently, and a "brightened" photo comes out with dark speckles wherever it was already bright.
import numpy as np pixels = np.array([250, 100, 5], dtype=np.uint8) print(pixels + 10) # wraps: 250 becomes 4 safe = np.clip(pixels.astype(int) + 10, 0, 255).astype(np.uint8) print(safe) # stops at 255 print(np.clip(pixels.astype(int) - 20, 0, 255).astype(np.uint8)) # darker stops at 0
The fix has three steps. astype(int) switches to a type with room for big and negative numbers. np.clip(..., 0, 255) pins every value into range. astype(np.uint8) converts back. The same pattern applies to any image math: work in a roomy type, then clip and convert at the end.
import numpy as np a = np.array([200, 60], dtype=np.uint8) print((a + 100).tolist(), np.clip(a.astype(int) + 100, 0, 255).tolist())
Show the answer
[44, 160] [255, 160]
As uint8, 200 + 100 = 300 wraps to 300 − 256 = 44. In a roomy int type it's 300, and clip pins it to 255. 160 fits either way.
A border around a photo
Write add_border(img, width, color): return a new image, larger by width pixels on every side, filled with color and with img in the middle. Start from np.full(...) with the new shape, then paste img in with slicing.
import numpy as np def add_border(img, width, color): pass photo = np.full((4, 6, 3), 200, dtype=np.uint8) framed = add_border(photo, 2, (0, 0, 0)) print(framed.shape)
Pillow: text, shapes and files
NumPy is great for math on pixels but has no idea how to draw letters. Pillow (imported as PIL) is Python's standard image library: it opens and saves image files and can draw text and shapes. Moving between the two is one line each way:
import io import numpy as np from PIL import Image, ImageDraw img = np.full((60, 200, 3), (40, 60, 90), dtype=np.uint8) picture = Image.fromarray(img) # NumPy array -> Pillow image draw = ImageDraw.Draw(picture) draw.rectangle((5, 5, 194, 54), outline=(255, 255, 255)) draw.text((14, 22), "Practice shop", fill=(255, 255, 255)) back = np.array(picture) # Pillow image -> NumPy array print(back.shape, (back != img).any(axis=2).sum(), "pixels changed") buffer = io.BytesIO() picture.save(buffer, format="PNG") print(len(buffer.getvalue()), "bytes as a PNG file")
ImageDraw.Draw(picture) gives you a pen for that picture; rectangle and text take pixel positions as (x, y), where x counts from the left and y from the top. Notice that order is the reverse of NumPy's [row, column]. Pillow thinks like a screen, NumPy like a table. On your own computer, picture.save("shot.png") writes a real file, and Image.open("photo.jpg") reads one.
The file type matters. PNG keeps every pixel exactly, which suits screenshots, logos and anything with sharp edges or text. JPEG throws away detail the eye barely notices to make photos much smaller, but each save loses a little more, and text gets blurry halos. A good rule: PNG for graphics and anything you'll edit again, JPEG for finished photos.
A color palette
Which colors does an image use most? Flatten the grid into a plain list of pixels, then count each distinct one:
import numpy as np img = np.zeros((4, 4, 3), dtype=np.uint8) img[:, :] = (150, 200, 235) img[3, :] = (70, 130, 60) img[0, 0] = (250, 215, 80) pixels = img.reshape(-1, 3) # 16 rows of (r, g, b) colors, counts = np.unique(pixels, axis=0, return_counts=True) order = np.argsort(counts)[::-1] # most common first for i in order: print(tuple(int(v) for v in colors[i]), counts[i])
reshape(-1, 3) keeps the 3 color values per pixel and works out the rest (-1): 16 pixels. np.unique(..., axis=0) treats each row as one item, and return_counts=True counts them. argsort gives the positions that would sort the counts. Reversed, they put the most common first.
Real photos have thousands of slightly different colors, because light and shadow shift every pixel a little. So a palette tool for photos first groups similar colors together. That's clustering, which you'll meet in Phase 3.
Swap red and blue
Some tools store pixels as blue, green, red instead of red, green, blue. Write bgr_to_rgb(img) that reverses the color channels and returns the new image. One slice does it.
import numpy as np def bgr_to_rgb(img): pass pixel = np.array([[[10, 20, 30]]], dtype=np.uint8) print(bgr_to_rgb(pixel))
Worked example: a contact sheet
Show four versions of the scene side by side as one image, the way a photo editor previews filters.
import matplotlib.pyplot as plt import numpy as np def make_scene(h=120, w=160): img = np.zeros((h, w, 3), dtype=np.uint8) rows = np.arange(h)[:, None] cols = np.arange(w)[None, :] img[:, :] = (150, 200, 235) img[h * 2 // 3:, :] = (70, 130, 60) img[(rows - 25) ** 2 + (cols - 130) ** 2 <= 12 ** 2] = (250, 215, 80) img[(rows >= 30) & (rows <= 100) & (abs(cols - 70) <= (rows - 30) * 0.8)] = (230, 120, 40) return img scene = make_scene() gray = np.round(scene @ [0.299, 0.587, 0.114]).astype(np.uint8) gray3 = np.stack([gray, gray, gray], axis=2) dark = np.clip(scene.astype(int) - 70, 0, 255).astype(np.uint8) mirrored = scene[:, ::-1] top = np.hstack([scene, gray3]) bottom = np.hstack([dark, mirrored]) sheet = np.vstack([top, bottom]) print(sheet.shape) fig, ax = plt.subplots(figsize=(6, 4.5)) ax.imshow(sheet) ax.axis("off")
The gray image has one value per pixel, so np.stack([gray, gray, gray], axis=2) copies it into three equal channels. Now it has the same shape as the color versions and can sit next to them. np.hstack joins arrays side by side (along the columns) and np.vstack stacks them on top of each other (along the rows), so four 120 × 160 images become one 240 × 320 sheet. Darkening uses the same roomy-type-then-clip pattern as brightening, just with a negative amount.
Practice what you just read
- 7 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Why does img + 50 on a uint8 image leave dark speckles in bright areas?