NumPy·Capstone·20 min·0/7 exercises

Capstone: edit a photo with nothing but NumPy

Every idea from the track, applied to something you can see.

3-D arrayschannel axisslicingbroadcastingnp.whereastypeclip

Watch it happen

Play it through, or step back and forth yourself.

photo
shape (36, 48, 3) · dtype uint8 · 5,184 values

A picture. Nothing special about it — except that as far as NumPy is concerned, it isn't a picture at all.

The idea

Everything so far has been arrays of numbers standing in for something. This time the array is the thing: photo is a picture, and every edit you make is one of the operations you already know.

There's no image library here. The photo above was generated by the setup code with mgrid, some broadcasting and an np.where — worth reading, because it's already using half the track.

The shape

photo.shape is (120, 160, 3)rows, columns, channels, in that order. Height comes first, which trips up anyone used to saying "160 by 120". The last axis holds red, green and blue.

The dtype is uint8: whole numbers from 0 to 255, one byte each. That's the standard for images and it has teeth — see below.

Hand-drawn notes showing a photo as an array of rows, columns and channels, with slicing used to mirror it, crop it and pull out a single colour channel.

The moves

photo[20:80, 40:120]      # crop        — slicing
photo[:, ::-1]            # mirror      — negative step
photo[::-1]               # upside down — same, other axis
photo[:, :, 0]            # red channel — 2-D, shape (120, 160)
photo[:, :, ::-1]         # RGB -> BGR  — reverse the channel axis

The uint8 trap

This is the one thing in the capstone that will genuinely catch you. uint8 wraps around:

photo + 60                                        # 250 becomes 54. Black holes.
np.clip(photo.astype(int) + 60, 0, 255).astype(np.uint8)   # correct

Widen to a bigger integer type, do the arithmetic, clamp the range, then convert back. Every brightness or contrast adjustment follows that pattern.

Hand-drawn notes showing that adding brightness directly to a uint8 photo wraps bright pixels to black, and that widening, clipping and casting back is the safe order.

Going grayscale

Averaging the three channels works, but looks wrong — human eyes are far more sensitive to green than to blue. The standard weights are 0.299, 0.587, 0.114, and the neat way to apply them is a dot product across the channel axis:

gray = (photo @ np.array([0.299, 0.587, 0.114])).astype(np.uint8)
gray.shape        # (120, 160) — the channel axis is gone

Work through these

The exercises below build up in order. Use the playground freely between them — change a number, re-run, and see what happens to the picture. That feedback loop is the point of the whole lab.

Practice

Write it yourself. The answer is there when you want it.

Putting the kettle on…

Starting up…

Write it yourself

not graded

Print the photo's shape and dtype. Then brighten the whole picture: cast to int so the arithmetic has room, add 55, np.clip back into 0–255, and cast to uint8 again. Leave that last expression as the result and it gets drawn.

Write something and press Run — the output appears here.

Your turn

7 exercises. Write the code yourself, then press Check — a nudge and the answer are there if you want them.

Return the shape of photo. Read it carefully — which number is the height?

your answer

Crop to rows 20 up to 80 and columns 40 up to 120.

your answer

Mirror the photo left-to-right.

your answer

Pull out just the red channel as a 2-D array. It'll be drawn as a grayscale image.

your answer

Brighten the photo by 55 without wrapping. The result must still be uint8.

your answer

Convert to grayscale using the weights 0.299, 0.587, 0.114. Return a uint8 array of shape (120, 160).

your answer

Find the bright parts. Return a uint8 array, same shape as the grayscale image, that is 255 where the grayscale value is above 120 and 0 everywhere else.

your answer