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

3.1.1 Rotation

Rotate a NumPy image array by an arbitrary angle with `scipy.ndimage.rotate`, then turn that one call into a fan-shaped composition by accumulating rotated copies with additive blending.

Duration18–22 min
Levelbeginner
Load3 core concepts
Prereqs2.3.4 (parametric trajectories), basic NumPy slicing

Overview

A rotation is a geometric transformation that preserves distances. Every pixel travels along an arc around a chosen pivot, and the picture as a whole turns by a single angle. Computationally it is one library call — scipy.ndimage.rotate — but it is also the first transformation in this module that produces resampled pixels: most rotated coordinates do not land on integer grid positions, so the library has to interpolate. In this lesson you will rotate a single shape, watch interpolation smooth the edges, and then iterate the rotation in a loop to draw a radial fan from one rectangle [1].

Learning objectives

  1. Rotate an image array by any angle with scipy.ndimage.rotate and read off the side-by-side comparison.
  2. Distinguish the four parameters that decide what a rotation looks like — angle, pivot, interpolation order, output reshape.
  3. Choose between reshape=True (no clipping, larger output) and reshape=False (same size, clipped corners).
  4. Stack rotated copies of one shape into a fan composition using additive blending.

Quick start — one 45° rotation

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

canvas_size = 400
image = np.zeros((canvas_size, canvas_size, 3), dtype=np.uint8)

# A cyan rectangle, centred horizontally
image[150:250, 100:300] = [0, 200, 200]

rotated = ndimage.rotate(image, 45, reshape=False, mode='constant', cval=0)

# Side-by-side: original on the left, rotated on the right
comparison = np.zeros((canvas_size, canvas_size * 2 + 20, 3), dtype=np.uint8)
comparison[:, :canvas_size] = image
comparison[:, canvas_size + 20:] = rotated

Image.fromarray(comparison).save('simple_rotation.png')
A horizontal cyan rectangle on the left half of the canvas, and the same rectangle rotated 45 degrees on the right half, both on a black background
Fig. 1 Left: a horizontal cyan rectangle. Right: the same rectangle rotated 45° around the image centre. The four corners now sit off the canvas because `reshape=False` clips them.

Core concepts

Concept 1 — The rotation matrix

A 2D rotation by angle θ around the origin is the linear map whose 2×2 matrix is

text
R(θ) = | cos θ   -sin θ |
       | sin θ    cos θ |

Multiply any column vector (x, y)ᵀ by R(θ) and you get the rotated point [2]. The matrix is orthogonal — its transpose is its inverse — which is the algebraic statement of “rotation preserves lengths.” scipy.ndimage.rotate does this for you; you write the angle in degrees and the library converts to radians, builds the matrix, and applies it pixel by pixel.

Concept 2 — Pivot, interpolation, and reshape

Four parameters decide the output of a rotation.

  • Angle — degrees, signed.
  • Pivot — the point that does not move. ndimage.rotate always uses the image centre; if you want a different pivot you translate the image first.
  • Interpolation order — how to compute the colour at a rotated coordinate that falls between grid cells. order=0 is nearest-neighbour (fast, blocky); order=1 is bilinear (the default, a good balance); order=3 is cubic (smoother but slower) [3].
  • Reshapereshape=True enlarges the output so every rotated pixel still fits; reshape=False keeps the original size and clips the four corners that swing outside the frame.
A two-panel diagram. Left panel labelled centre rotation shows a square turning in place around its own centre. Right panel labelled corner rotation shows a square orbiting around its lower-left corner.
Fig. 2 Same angle, two different pivots. The image centre is the default; offset pivots make the shape orbit instead of spin.

The reshape choice is the trade-off you have to make every time:

python · reshape_demo.py
clipped = ndimage.rotate(image, 45, reshape=False)
print(clipped.shape)   # (400, 400, 3) — corners gone

full    = ndimage.rotate(image, 45, reshape=True)
print(full.shape)      # (566, 566, 3) — bigger canvas, nothing lost

Reshape True is right for archival output; reshape=False is right when you are layering many rotated copies onto a fixed-size canvas, because all the copies need the same dimensions to add together.

Concept 3 — Iterated rotations as composition

Once one rotation is a single library call, $N$ rotations are a loop. The rotated copies share the same canvas dimensions (because reshape=False), so they can be summed pixel-by-pixel. The classic move is additive blending: convert to int16 to dodge overflow, add scaled contributions, then clip back to uint8. With an off-centre rectangle, eighteen rotations spread across 180° trace the fan shape in Figure 3.

python · fan_demo.py
shape = np.zeros_like(canvas)
shape[200:300, 250:450] = [0, 180, 220]  # off-centre rectangle

for i in range(18):
    rotated = ndimage.rotate(shape, i * 10, reshape=False)
    canvas = np.clip(
        canvas.astype(np.int16) + (rotated * 0.4).astype(np.int16),
        0, 255,
    ).astype(np.uint8)

The 0.4 scaling is what stops the overlapping centre from saturating to pure white on the second or third rotation. Each contribution stays sub-maximal, and only the regions that many rotations overlap reach full brightness — that gradient is what makes the fan readable [4].

Exercises

Three exercises in Execute → Modify → Create order: run the fan pattern, change three parameters at once, then build a single-rotation compositor from scratch.

EXECUTE I.

Run the 18-rotation fan

Run rotation_pattern.py from the downloads. It draws an off-centre cyan rectangle, then rotates it 18 times across a 180° spread, blending each rotation into the canvas.

A radial fan-like pattern of overlapping cyan rectangles, denser in the centre where the rotations overlap most
Fig. 3 18 rotations across 180°. The bright centre is where every contribution overlaps; the outer edges show individual rectangle silhouettes.

Reflection questions

  • Why is the centre of the fan brighter than the outer edges?
  • What would happen if you swapped the additive blend (np.clip(canvas + contribution)) for a plain overwrite (canvas[mask] = rotated[mask])?
  • Why does the rectangle being off-centre matter for the fan shape?
MODIFY II.

Three pattern variations

Edit rotation_pattern.py to produce these three pictures. Each goal changes only the marked variables.

Goals

  1. Sparse fan — drop number_of_rotations to 6 and watch the gaps open up between the rectangles.
  2. Warm palette — change the cyan [0, 180, 220] to an orange [255, 120, 50].
  3. Full circle — change the 180° spread to 360° so the fan completes into a wheel.
CREATE III.

Single-shape rotation compositor

Build a small tool that rotates one shape onto a coloured background — no iteration, just clean foreground-over-background compositing.

Requirements

  • Dark blue background [30, 30, 50].
  • Orange rectangle [255, 150, 50], drawn near the centre.
  • Rotate the shape by 30°.
  • Save the composite as my_rotation.png.
python · exercise3_starter.py
import numpy as np
from scipy import ndimage
from PIL import Image

canvas_size = 400
rotation_angle = 30
background_color = [30, 30, 50]
shape_color      = [255, 150, 50]

# TODO 1: create the canvas and fill it with background_color.
canvas = np.zeros((canvas_size, canvas_size, 3), dtype=np.uint8)

# TODO 2: draw a rectangle on a separate black layer (shape_layer)
#         using shape_layer[top:bottom, left:right] = shape_color.
shape_layer = np.zeros((canvas_size, canvas_size, 3), dtype=np.uint8)

# TODO 3: rotate the shape_layer with ndimage.rotate(..., reshape=False, mode='constant', cval=0).

# TODO 4: build a boolean mask of where the rotated layer has any colour,
#         then copy those pixels onto canvas — canvas[mask] = rotated[mask].

Image.fromarray(canvas).save('my_rotation.png')

Make it your own

  • Loop rotation_angle from 0 to 360 in steps of 10 and save 36 frames; assemble them with imageio.mimsave for a spinning-rectangle GIF.
  • Replace the rectangle with a triangle using the line code from 2.1.2 — the rotation pipeline is shape-agnostic.
  • Use the alpha-blend version from Concept 3 — replace canvas[mask] = ... with canvas = np.clip(canvas + 0.5 * rotated, 0, 255) — to layer multiple rotated shapes with a soft glow.

Downloads

simple_rotation.py — quick-start side-by-side rotation_pattern.py — 18-rotation fan rotation_starter.py — Exercise 3 starter rotation_solution.py — Exercise 3 reference

Summary

Common pitfalls to avoid

  • reshape=True inside a loop produces outputs of varying sizes that no longer add together — use reshape=False for stacking.
  • Skipping the astype(np.int16) cast around the additive blend overflows uint8 and produces black bands where bright regions wrap around to zero.
  • Centring the shape on the image centre defeats the fan — every rotation lands on the same pixels.
  • Default order=1 softens hard edges; if the lesson calls for crisp pixel art, drop to order=0 (nearest-neighbour).
  • Confusing screen direction with mathematical direction — positive degrees rotate counter-clockwise in maths and clockwise on screen because image arrays index y downward.

References

  1. [1] Gonzalez, R. C., & Woods, R. E. (2018). Digital Image Processing (4th ed.). Pearson.
  2. [2] Foley, J. D., van Dam, A., Feiner, S. K., & Hughes, J. F. (1990). Computer Graphics: Principles and Practice (2nd ed.). Addison-Wesley.
  3. [3] SciPy Community. (2024). scipy.ndimage.rotate. SciPy Documentation. docs.scipy.org
  4. [4] Pearson, M. (2011). Generative Art: A Practical Guide Using Processing. Manning.
  5. [5] Szeliski, R. (2022). Computer Vision: Algorithms and Applications (2nd ed.). Springer.
  6. [6] 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