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

3.1.3 Nonlinear Distortions

Move past affine: warp an image by *coordinate remapping*. Use sinusoidal, radial, and rotational offsets to build wave, barrel, and swirl distortions, then combine two waves for a wobbly effect.

Duration20–24 min
Levelintermediate
Load3 core concepts
Prereqs3.1.2 (affine), basic trig

Overview

The last lesson stayed inside the affine family — straight lines stayed straight. Nonlinear distortions break that rule on purpose: vertical lines become wavy, circles bulge out into fish-eye shapes, and the interior of an image swirls around its centre. The unifying technique is coordinate remapping: rather than transforming the pixels with a matrix, you transform the coordinates with a function, then read the pixel at the new location. The mechanic is the same one used by lens-correction software, by photo-editing filters like “twirl” and “spherize,” and by every CRT-distortion shader [1, 2].

Learning objectives

  1. Use inverse mapping — for each output pixel, compute where to read from in the input — and explain why it avoids holes.
  2. Implement wave distortion with a sinusoidal offset, tuning amplitude and frequency independently.
  3. Recognise barrel and swirl as two flavours of radial remapping that share a polar-coordinate skeleton.
  4. Combine two perpendicular waves into a wobble effect, picking different frequencies for richer patterns.

Quick start — a horizontal sine wave

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

# Build a small checkerboard so the distortion is easy to see
size = 400
tile = 50
image = np.zeros((size, size, 3), dtype=np.uint8)
colors = [(255, 100, 100), (100, 100, 255), (100, 255, 100), (255, 255, 100)]
for r in range(size // tile):
    for c in range(size // tile):
        image[r*tile:(r+1)*tile, c*tile:(c+1)*tile] = colors[(r + c) % 4]

# Sine-wave horizontal shift — x depends on y
amplitude = 20
frequency = 3
distorted = np.zeros_like(image)
for y in range(size):
    offset = int(amplitude * np.sin(2 * np.pi * frequency * y / size))
    for x in range(size):
        source_x = (x + offset) % size
        distorted[y, x] = image[y, source_x]

Image.fromarray(distorted).save('wave_distortion.png')
A colorful checkerboard with vertical edges turned into sine waves; horizontal edges remain perfectly straight
Fig. 1 A 3-cycle horizontal wave. Vertical edges ripple; horizontal edges stay flat because every pixel in a row shifts by the same amount.

Core concepts

Concept 1 — Inverse mapping

The naive approach to image warping is forward mapping: take each input pixel, compute its new location, and write it there. The problem is rounding — most output positions get written multiple times, and some get written not at all. The result is full of black-pixel holes [3].

The standard fix is inverse mapping: loop over output pixels, and for each one compute the source coordinate to read from. Every output pixel gets a value, by construction. The source coordinate may be non-integer (rounded with int(...)) or out of bounds (wrapped with % size, or clipped with np.clip), but it always exists.

python · inverse_map.py
for y in range(height):
    for x in range(width):
        sx = f_x(x, y)     # where to read in the input
        sy = f_y(x, y)
        output[y, x] = source[int(sy) % height, int(sx) % width]
Two grids side by side: the left labelled input shows a regular square grid; the right labelled output shows the same grid with its vertical lines warped into sine waves
Fig. 2 Coordinate remapping. The output grid samples the input at curved positions; the visual warp is the *negative* of those sampling curves.

Concept 2 — Wave, barrel, swirl

Three classic remapping formulas cover most of what you will see in image filters.

Wave — add a sinusoidal offset to one axis:

source_x = (x + amplitude * sin(2*pi * freq * y / H)) % W

The y-dependence is what makes vertical edges wavy. Two superposed waves (source_x from y, source_y from x) give the wobble in Exercise 3.

Barrel (fish-eye) — push pixels radially outward, more strongly near the edges:

dx, dy = x - cx, y - cy
r2 = dx*dx + dy*dy
factor = 1 + strength * r2 / max_r2
source_x = cx + dx / factor
source_y = cy + dy / factor

A positive strength produces barrel distortion (image bulges outward, like looking through a glass sphere); a negative strength gives pincushion (image pulls inward at the corners) [4].

Swirl — rotate pixels around the centre by an angle that decreases with radius:

r = sqrt(dx*dx + dy*dy)
theta = atan2(dy, dx)
twist = twist_amount * (1 - r / max_r)
source_x = cx + r * cos(theta - twist)
source_y = cy + r * sin(theta - twist)

Centre pixels spin most; edge pixels barely move. The result is the classic “twirl filter” found in every image editor.

A two by two grid of wave-distorted checkerboards: top-left low frequency low amplitude, top-right low frequency high amplitude, bottom-left high frequency low amplitude, bottom-right high frequency high amplitude
Fig. 3 One formula, two knobs. Amplitude controls how far pixels shift; frequency controls how many cycles fit across the image.

Concept 3 — Out-of-bounds and sampling

Distortions push source coordinates outside the input image. Two policies, both legitimate.

  • Wrap with source_x % width — the image tiles. Good for waves, seamless textures, and the side-of-a-vinyl-record look. Bad for photos: faces wrap onto themselves.
  • Clip with np.clip(source_x, 0, width - 1) — out-of-range pixels read from the nearest edge. The edges stretch into bands. Good for photos and lens effects.

Choosing the wrong policy is the most common visual bug in coordinate-remapping code. The wobbling checkerboard wraps cleanly because the input is already periodic; a face under the same wrap policy is going to look unsettling.

Exercises

Three exercises in Execute → Modify → Create order: run a single wave, swap axes/parameters, then build a 2-axis wobble from scratch.

EXECUTE I.

Run the horizontal wave

Run simple_wave_distortion.py from the downloads. Inspect the output and read through the loop body — the whole transformation is three lines.

Reflection questions

  • Horizontal lines stay straight; vertical lines become wavy. Why does the formula force that asymmetry?
  • What is the role of the % size at the end of source_x = (x + offset) % size?
  • Replace frequency = 3 with frequency = 6. How many sine cycles do you expect to see?
MODIFY II.

Three variations on one wave

Starting from simple_wave_distortion.py, edit the script to produce each picture.

Goals

  1. Vertical wave — horizontal lines should ripple, vertical lines should stay straight. Swap which coordinate gets the offset.
  2. Bigger amplitude — set amplitude = 40 and observe how the wave height grows.
  3. Diagonal wave — offset depends on x + y instead of y alone.
CREATE III.

Two-axis wobble

Combine a horizontal wave (offsets source_x based on y) and a vertical wave (offsets source_y based on x). Use different frequencies for the two waves so the pattern does not repeat trivially.

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

size = 400
tile = 50
image = np.zeros((size, size, 3), dtype=np.uint8)
colors = [(255, 100, 100), (100, 100, 255), (100, 255, 100), (255, 255, 100)]
for r in range(size // tile):
    for c in range(size // tile):
        image[r*tile:(r+1)*tile, c*tile:(c+1)*tile] = colors[(r + c) % 4]

# TODO 1: pick amplitude + frequency for both axes (different freqs!)
# h_amp, h_freq = ...
# v_amp, v_freq = ...

distorted = np.zeros_like(image)
for y in range(size):
    for x in range(size):
        # TODO 2: compute h_offset (a function of y) and v_offset (a function of x).
        # TODO 3: combine into source_x and source_y, wrap with % size, and copy.
        pass

Image.fromarray(distorted).save('combined_waves.png')

Make it your own

  • Replace the checkerboard input with a real photo (use np.array(Image.open('photo.jpg'))) and try wrap vs clip — wrap looks alien on faces.
  • Add a third wave: source_x += amplitude * sin(2π · freq · (x + y) / size) for a diagonal ripple on top of the two-axis wobble.
  • Decay the amplitude with distance from the centre: multiply each offset by 1 - r/r_max. The corners settle while the centre keeps wobbling.

Downloads

simple_wave_distortion.py — horizontal wave starter wave_variations.py — amplitude/frequency grid barrel_distortion.py — fish-eye reference swirl_distortion.py — twirl reference combined_waves_solution.py — Exercise 3 solution

Summary

Common pitfalls to avoid

  • Forward-mapping instead of inverse-mapping — the output ends up speckled with holes.
  • Forgetting to convert source coordinates to int before indexing — Python list indexing with floats raises; NumPy fancy-indexing with floats silently misbehaves.
  • Wrap vs clip mix-up — wrapping a photo makes faces fold onto themselves; clipping a tileable texture creates visible edges.
  • Plain (x + offset) without % size on an offset that can go negative — Python’s % behaves correctly, but the same code in C-like languages flips signs.
  • Inflating amplitude so much that the wave reads from far past the source image — at some point the pattern is all wraparound.

References

  1. [1] Wolberg, G. (1990). Digital Image Warping. IEEE Computer Society Press.
  2. [2] Gonzalez, R. C., & Woods, R. E. (2018). Digital Image Processing (4th ed.). Pearson.
  3. [3] Szeliski, R. (2022). Computer Vision: Algorithms and Applications (2nd ed.). Springer. szeliski.org/Book
  4. [4] Brown, D. C. (1966). Decentering distortion of lenses. Photogrammetric Engineering, 32(3), 444–462.
  5. [5] Foley, J. D., van Dam, A., Feiner, S. K., & Hughes, J. F. (1990). Computer Graphics: Principles and Practice (2nd ed.). Addison-Wesley.
  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