← Python
ex05June 21, 2026 · 9 min read

Pimp My Image: Color-Channel Filters from Scratch

Invert, isolate red/green/blue, and grayscale — five filters built only from arithmetic on the RGB channels, each constrained to a tiny set of operators.

pythonnumpyimagescolor

The final exercise, "pimp my image", is where everything pays off. You write five color filters — invert, red, green, blue, grey — that keep the image shape identical and use only a restricted set of operators. Each constraint is a puzzle that teaches you how a channel is really manipulated.

The mental model

Remember from exercise 02: an RGB image is a (H, W, 3) array where the last axis holds [R, G, B]. A "filter" is a function that takes that array and returns a new one of the same shape. The trick is that channel selection is just slicing the last axis:

array[:, :, 0]   # all the Red values
array[:, :, 1]   # all the Green values
array[:, :, 2]   # all the Blue values

ft_invert — operators: =, +, -, *

Inverting a color means reflecting each channel around the midpoint: a pixel value v becomes 255 - v. Black (0) becomes white (255) and vice-versa. With subtraction allowed it is a one-liner:

def ft_invert(array):
    """Invert the colors of the image received."""
    return 255 - array

NumPy broadcasts the scalar 255 against every element. Because invert also permits + and *, you could write -1 * array + 255 — same result, showing the operators are interchangeable here.

ft_red — operators: =, *

The "red" filter keeps the red channel and zeroes the others. You are not allowed to subtract, only assign and multiply — so you multiply green and blue by 0:

def ft_red(array):
    """Keep only the red channel."""
    result = array.copy()
    result[:, :, 1] = result[:, :, 1] * 0     # green -> 0
    result[:, :, 2] = result[:, :, 2] * 0     # blue  -> 0
    return result

Multiplying a channel by 0 wipes it out; multiplying by 1 leaves it untouched. That is why only = and * are needed.

ft_green — operators: =, -

Now you may only assign and subtract. To isolate green, subtract each unwanted channel from itself, which makes it zero:

def ft_green(array):
    """Keep only the green channel."""
    result = array.copy()
    result[:, :, 0] = result[:, :, 0] - result[:, :, 0]   # red  -> 0
    result[:, :, 2] = result[:, :, 2] - result[:, :, 2]   # blue -> 0
    return result

The constraint forces a clever idea: x - x == 0. Same destination as the red filter, reached with a different tool.

ft_blue — operator: = only

The strictest one: assignment only, no arithmetic at all. So you simply assign a constant 0 to the channels you want gone:

def ft_blue(array):
    """Keep only the blue channel."""
    result = array.copy()
    result[:, :, 0] = 0     # red   -> 0
    result[:, :, 1] = 0     # green -> 0
    return result

Assigning a scalar to a whole 2D slice sets every element of that channel — broadcasting again. With pure assignment you cannot compute, only overwrite, which is exactly enough here.

ft_grey — operators: =, /

Grayscale means every channel carries the same intensity, so R = G = B. The simple average uses division:

def ft_grey(array):
    """Convert the image to grayscale (average of channels)."""
    result = array.copy()
    grey = (result[:, :, 0] / 3) + (result[:, :, 1] / 3) + (result[:, :, 2] / 3)
    result[:, :, 0] = grey
    result[:, :, 1] = grey
    result[:, :, 2] = grey
    return result

Dividing each channel by 3 and summing gives the mean brightness; copying it back into all three channels produces grey. (If only = and / are allowed and not +, you can instead just replicate one channel, e.g. assign the red channel into green and blue — a cheaper "grey" that still satisfies R = G = B.)

Two recurring pitfalls

  • Copy before you mutate. Every filter starts with array.copy(). Without it you edit the caller's image in place, and the next filter sees corrupted data.
  • uint8 overflow. Pixels are 0–255 bytes. 200 + 100 wraps around to 44, not 300. 255 - array is safe because it stays in range, but if you add or average, cast to a wider type (array.astype(int)) and clip back, or you will get bright speckle artifacts.

Why the operator restrictions matter

Each filter reaches the same kind of result — zeroing or equalizing channels — but the allowed operators change. That is a deliberate lesson: there is rarely one way to manipulate array data. Multiply by zero, subtract from self, or assign a constant all silence a channel. Recognizing these equivalences is what turns array syntax into fluent thinking.

Takeaways

  • A filter maps a (H, W, 3) array to another of the same shape.
  • Select channels by slicing the last axis: array[:, :, c].
  • Invert = 255 - array; isolate a channel by zeroing the others (×0, x−x, or =0); grey = equal channels.
  • Always .copy() first, and mind uint8 overflow when adding.