Index Ai Academy
Phase 0 · Tools · Working with data

Project: images are arrays

Gray, crop, brighten, watermark and palette tools built on NumPy and Pillow.

About 45 minutes · Free lesson

Start this lesson

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

  1. The brief
  2. Drawing a scene with masks
  3. Flip, crop and gray
  4. Brighter without wrapping around
  5. A border around a photo
  6. Pillow: text, shapes and files
  7. A color palette
  8. Swap red and blue
  9. 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")
It runs right here in your browser, with nothing to install.

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).

Try it now

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.

Quick check
What does this print?
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())
  1. [44, 160] [255, 160]
  2. [255, 160] [255, 160]
  3. [300, 160] [255, 160]
  4. [44, 160] [44, 160]
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.

Drill

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)
In the lesson, your code is checked as you write it.

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.

Drill

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))
In the lesson, your code is checked as you write it.
Worked example

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")
In the lesson you can change this and run it.

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

Start this lesson

Try a quiz question

Why does img + 50 on a uint8 image leave dark speckles in bright areas?

  1. uint8 holds only 0 to 255, so values past 255 wrap around to small numbers
  2. NumPy rounds the result down
  3. Adding to an image always darkens its brightest pixels
  4. The image is converted to gray first

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson