Pixels2GenAI
Path i Foundations
M 03 · 3.2.1 · hands-on

3.2.1 Boolean Masks

Build a 2D boolean array — `True` for *keep*, `False` for *cut* — and use it as the gatekeeper for every per-pixel edit. A circular vignette, a colour swap, and a custom shape are all the same one-line indexing trick.

Duration15–18 min
Levelbeginner
Load3 core concepts
Prereqs1.1.1 (RGB arrays), basic NumPy broadcasting

Overview

The next subtopic — masking and compositing — runs on a single idea: a boolean mask is a 2D array of True/False values, one per pixel, that decides where an operation applies. Where the mask is True, the operation fires; where it is False, the original pixels are left untouched. Every alpha channel, every layer mask in Photoshop, every “select all blue pixels” tool comes down to the same construction [1]. This lesson focuses on the boolean operation itself: build a mask from a geometric condition, apply it with NumPy fancy indexing, and read it as a single composable operator that the rest of the subtopic builds on.

Learning objectives

  1. Build a 2D boolean mask from a geometric condition (a circle, a rectangle, a vertical band).
  2. Apply a mask with fancy indexing — image[mask] = value — and predict which pixels get touched.
  3. Combine multiple masks with &, |, and ~ to express AND, OR, and NOT.
  4. Use a colour-channel threshold (image[:, :, 2] > 180) as a content-aware mask source.

Quick start — a circular vignette

python · quick_start.py
import numpy as np
from PIL import Image

image = np.array(Image.open('bbtor.jpg'))   # any RGB photo will do
h, w = image.shape[:2]

# Grid of pixel coordinates
Y, X = np.ogrid[:h, :w]

# Distance² from the image centre — cheap because we never sqrt
cx, cy = w / 2, h / 2
distance_sq = (X - cx) ** 2 + (Y - cy) ** 2

# Mask: True where the pixel is *outside* the circle
outside = distance_sq > (w * h) / 6

# Blacken every "outside" pixel
image[outside] = 0

Image.fromarray(image).save('mask.png')
A photograph of the Brandenburg Gate at dusk, with everything outside a central circle blacked out, leaving a circular vignette
Fig. 1 A circular vignette built from one boolean condition. `outside` is `True` where the squared distance from the centre exceeds the threshold, and those pixels get set to zero.

Core concepts

Concept 1 — A mask is a (H, W) boolean array

Every per-pixel operation in this subtopic needs an answer to the same question: should I touch this pixel? The cleanest place to keep that answer is a 2D array with one bool per pixel, called a mask.

mask = some_condition_over_pixel_coordinates   # shape (H, W), dtype bool

You build the mask from a vectorised expression — a comparison, a range check, a colour-channel threshold. Anything that produces a (H, W) boolean output is valid. NumPy’s ogrid is the standard way to materialise the coordinate grid:

Y, X = np.ogrid[:h, :w]                # X has shape (1, w), Y has shape (h, 1)
mask = (X - cx) ** 2 + (Y - cy) ** 2 < r_sq    # circle interior, shape (h, w)

The fact that the coordinate inputs to the condition are arrays — not Python loops — is what makes the mask cheap. The whole evaluation runs in vectorised C.

Concept 2 — Applying a mask with fancy indexing

Three patterns recur. All three use the mask as a selector on the left of =.

Solid colour where True.

image[mask] = [255, 0, 0]    # everywhere the mask is True → red

Copy from another image where True.

image[mask] = other[mask]    # everywhere the mask is True → take from other

Per-channel edit.

image[mask, 0] = 0            # zero out the red channel only, where the mask is True

Notice that the right-hand side never has to match the shape of the masked region — NumPy broadcasts. A 3-element list becomes the colour for every selected pixel; a same-shape other[mask] substitutes pixel-for-pixel.

Concept 3 — Boolean algebra on masks

Two masks combine with the same & | ~ operators you would use on individual booleans. Because the operands are NumPy arrays, the bitwise operators are the correct ones — and/or would short-circuit on the whole array as a single truth value and throw.

inner_circle = (X - cx)**2 + (Y - cy)**2 < r1_sq
outer_circle = (X - cx)**2 + (Y - cy)**2 < r2_sq

ring   = outer_circle & ~inner_circle      # AND NOT — inside outer but not inner
either = outer_circle | top_half_strip     # OR
not_in_circle = ~inner_circle              # negation

The boolean algebra is exactly what lets you build complex masks compositionally — a vignette, a ring, a chequered window, a colour-thresholded patch — without ever leaving NumPy.

Exercises

Three exercises in Execute → Modify → Create order: run the vignette, swap the mask formula, then build a custom shape mask of your own.

EXECUTE I.

Run the circular vignette

Run mask.py from the downloads. The script loads bbtor.jpg, builds the squared-distance mask, and blackens every outside pixel.

Reflection questions

  • The threshold is w * h / 6 rather than a tidy radius. Why does that magic number happen to produce a roughly circular crop?
  • What would image[~outside] = 0 produce instead?
  • What changes if the mask is shape (h, w, 3) instead of (h, w)?
MODIFY II.

Three mask variations

Edit mask.py to produce these three pictures.

Goals

  1. Inverse vignette — keep the outside, blacken the centre disc.
  2. Vertical band — keep a vertical strip down the middle, blacken the sides.
  3. Two circles — keep the union of two overlapping circles, blacken everything else.
CREATE III.

A custom heart mask

Build a heart-shaped mask using one of two routes: an algebraic curve, or two circles plus a triangle. Apply it to any photo so the heart stays full-colour and the rest goes black.

python · exercise3_starter.py
import numpy as np
from PIL import Image

image = np.array(Image.open('bbtor.jpg'))
h, w = image.shape[:2]

# Normalise coordinates so the heart can be tuned in (−1, 1)
Y, X = np.ogrid[:h, :w]
nx = (X - w / 2) / (w / 2)
ny = (Y - h / 2) / (h / 2)
ny = -ny     # flip y so the heart points "up" on screen

# TODO 1: build the heart mask. Two starter routes — pick one.
# Algebraic curve:
#   (x² + y² − 1)³ − x² · y³ < 0  is the inside of a heart.
# Or: two circles for the lobes + a downward triangle for the point.

# TODO 2: blacken everything *outside* the heart.

Image.fromarray(image).save('heart_mask.png')

Make it your own

  • Replace image[~heart_inside] = 0 with image[~heart_inside] //= 4 to dim the outside rather than nuke it — a soft vignette.
  • Combine with the wave distortion from 3.1.3: distort the image first, then apply the heart mask. Edge pixels under wrap will look interestingly fragmented.
  • Build the mask from image[:, :, 2] > 200 (bright-blue threshold) — a content-aware mask that selects the sky pixels in the original photo.

Downloads

mask.py — vignette starter bbtor.jpg — input photograph

Summary

Common pitfalls to avoid

  • Using Python’s and/or instead of &/| — the former raise on whole arrays; only the bitwise operators broadcast.
  • Building a (H, W, 3) mask when a (H, W) mask would do — indexing semantics differ in surprising ways.
  • Forgetting parentheses around chained comparisons: (X > 100) & (X < 200) is correct; X > 100 & X < 200 is parsed as X > (100 & X) < 200 and crashes.
  • Computing sqrt(d²) when you only need to compare against a radius — squaring the radius and skipping sqrt is faster and numerically cleaner.
  • Assigning a tuple image[mask] = (255, 0, 0) — works, but a list [255, 0, 0] is the more conventional NumPy style.

References

  1. [1] Gonzalez, R. C., & Woods, R. E. (2018). Digital Image Processing (4th ed.). Pearson.
  2. [2] Akenine-Möller, T., Haines, E., & Hoffman, N. (2018). Real-Time Rendering (4th ed.). CRC Press.
  3. [3] Weisstein, E. W. (2024). Heart curve. MathWorld — A Wolfram Web Resource. mathworld.wolfram.com/HeartCurve
  4. [4] Harris, C. R., Millman, K. J., van der Walt, S. J., et al. (2020). Array programming with NumPy. Nature, 585, 357–362. doi:10.1038/s41586-020-2649-2
  5. [5] NumPy Community. (2024). Boolean array indexing. NumPy Documentation. numpy.org
  6. [6] Foley, J. D., van Dam, A., Feiner, S. K., & Hughes, J. F. (1990). Computer Graphics: Principles and Practice (2nd ed.). Addison-Wesley.