Breaking wave
inspired by Katsushika Hokusai, The Great Wave off Kanagawa1
A swell curls over a hollow barrel in Jarvis-Judice-Ninke error diffusion. Its foaming lip and spray are lit.
Made with Claude Opus 5.5
- Technique
- dithering
- Inspired by
- Japanese art
- Shape
- 16:9, 32:9, 9:19.5, 10:16 (cropped for other screens)
- Added
- 27 September 2026
Colours
- #1C1B1Abackground
- #DAD8CEforeground
- #CF6A4Caccent
Export
FormatThis browser can’t make WebP files.
Shapecropped from 16:9
Crop
SizeThis browser can’t draw a file that large.
Notes
After the curling wave in Hokusai’s The Great Wave off Kanagawa, without the boats or the mountain.
Sources
- Katsushika Hokusai, The Great Wave off Kanagawa, 1831. ↑
- J. F. Jarvis, C. N. Judice and W. H. Ninke, A survey of techniques for the display of continuous tone pictures on bilevel displays, 1976.
Source code
wallpapers/dither-wave/design.py, 135 lines
"""A breaking wave curls over from the left with foam on its lip, in Jarvis-Judice-Ninke error diffusion."""
from itertools import pairwise
import numpy as np
from numpy.typing import ArrayLike, NDArray
from scipy.ndimage import distance_transform_edt
from skimage.draw import line as draw_line
from skimage.draw import polygon as fill_poly
from walldye import ACCENT, ACCENT_3, BG_ALT, UI, UI_ALT, Canvas, Rect, Vec, design
from walldye.field import cells, falloff, gauss, noise_grid
from walldye.geom import spline_points
from walldye.pixel import dither, grid_runs
type Field = NDArray[np.float64]
CELL = 3
PAD = 64 # cells of margin, so distances count the curve just off the screen
# Wave-local coordinates, in which the wave sits as it does on a 16:9 screen.
SEA = 900 # the sea surface
LIP = (672, 606) # the tip of the curl, placed by s.pick
# the back rises out of the sea well left of any screen, crests, and curls over into the lip
TOP = (
(-1700, 897),
(-1100, 882),
(-560, 838),
(-220, 752),
(0, 660),
(150, 575),
(320, 498),
(460, 454),
(560, 460),
(636, 496),
(682, 548),
(688, 594),
(664, 624),
(636, 624),
)
UNDER = ((636, 624), (652, 600), (644, 574), (612, 560), (574, 568), (550, 606))
FACE = ((548, 600), (550, 690), (600, 790), (700, 862), (860, 893), (1100, SEA))
# spray streamers flung forward off the lip, falling away down-right
STREAMERS = (
((692, 566), (735, 572), (772, 596), (796, 634), (806, 676)),
((690, 584), (722, 600), (744, 630), (754, 664)),
((694, 552), (742, 548), (786, 566), (818, 598)),
)
# body tones 0-3 (0 left bare), then the two foam steps
PALETTE = (None, BG_ALT, UI, UI_ALT, ACCENT_3, ACCENT)
def to_cells(pts: ArrayLike, o: Vec) -> Field:
"""Wave-local points as fractional (col, row) cell indices, cell centres at whole numbers."""
return (np.asarray(pts, float) + o) / CELL - 0.5
def edge_dist(curve: Field, shape: tuple[int, int]) -> Field:
"""Canvas-unit distance from every cell centre to the polyline `curve`, in cell indices."""
rows, cols = shape
free = np.ones((rows + 2 * PAD, cols + 2 * PAD), bool)
q = np.rint(curve).astype(int) + PAD
for (c0, r0), (c1, r1) in pairwise(q):
rr, cc = draw_line(r0, c0, r1, c1)
ok = (rr >= 0) & (rr < free.shape[0]) & (cc >= 0) & (cc < free.shape[1])
free[rr[ok], cc[ok]] = False
dist: Field = distance_transform_edt(free)
return dist[PAD:-PAD, PAD:-PAD] * CELL
@design(aspects=("16:9", "32:9", "9:19.5", "10:16"))
def draw(s: Canvas) -> None:
# the lip on the left third of a landscape screen; a little right of centre and low on a
# portrait one, with open sky above; whole cells, so the wave draws the same everywhere
o = s.pick(landscape=(0.35, 0.561), portrait=(0.6, 0.66), snap=CELL) - LIP
xs, ys = cells(Rect(0, 0, s.w, s.h), CELL)
rows, cols = xs.shape
xx, yy = xs - o.x, ys - o.y # wave-local cell centres
top = to_cells(spline_points(TOP, 40), o)
under = to_cells(spline_points(UNDER, 40), o)
face = to_cells(spline_points(FACE, 40), o)
base = SEA + 90 + s.h - o.y # below the screen, so the body reaches its bottom edge
foot = to_cells(((FACE[-1][0], base), (TOP[0][0], base)), o)
outline = np.vstack([top, under, face, foot])
wave = np.zeros((rows, cols), bool)
rr, cc = fill_poly(outline[:, 1], outline[:, 0], (rows, cols))
wave[rr, cc] = True
sea = yy >= SEA
d_top = edge_dist(np.vstack([top, under]), (rows, cols))
d_face = edge_dist(face, (rows, cols))
n = noise_grid(cols, rows, 64, s.np_rng(3), octaves=3)
# body: parallel streaks following the crest (brighter in the upper swell), a lit concave
# face, a darker base
streak = (0.5 + 0.5 * np.sin(d_top / 14 + n * 2.5)) ** 5
upper = np.clip((640 - yy) / 120, 0, 1)
body = (
0.16
+ (0.30 + 0.42 * upper) * streak * np.exp(-d_top / 220)
+ 0.5 * np.exp(-d_face / 70) * np.clip((1060 - xx) / 360, 0, 1)
- 0.14 * (yy - 450) / 630
)
swell = 0.12 + 0.07 * np.sin(np.maximum(yy - SEA, 0) ** 0.8 / 2.2 + n * 2)
sink = np.clip((yy - SEA + 90) / 180, 0, 1) # the swell's base settles into the sea
# faint volume inside the barrel so the hollow doesn't read as a punched hole
barrel = 0.26 * np.exp(-np.hypot((xx - 610) / 1.3, yy - 660) / 70) * np.exp(-d_face / 90)
tone = np.where(
wave, body * (1 - sink) + swell * sink, np.where(sea, swell, barrel * (xx > 540))
)
g = dither(tone, 4, method="jarvis", serpentine=True)
# foam: a band straddling the crest that thins to a hairline away from the lip, and fades
# where the back sinks toward the sea (only wider screens show that far)
near = np.maximum(
np.clip((xx - 420) / 240, 0, 1) ** 1.6,
np.exp(-np.hypot(xx - LIP[0], yy - LIP[1]) / 90),
)
width = 6 + 34 * near
sd = np.where(wave, d_top, -d_top)
foam = gauss((sd - 0.45 * width) / width, 0.5) * (0.45 + 0.5 * near) * (1 + 0.6 * n * near)
foam = np.clip(foam, 0, 1) * (sd > -width) * np.clip((SEA - 60 - yy) / 150, 0, 1)
grain = noise_grid(cols, rows, 8, s.np_rng(9))
spray = sum(
np.exp(-edge_dist(to_cells(spline_points(c, 20), o), (rows, cols)) / 5)
* falloff(np.hypot(xx - c[0][0], yy - c[0][1]), 160, 1.6)
for c in STREAMERS
)
foam = np.maximum(foam, np.clip(spray * (0.35 + 0.9 * grain), 0, 1) * ~wave)
f = dither(foam, 3, method="jarvis", serpentine=True)
grid = np.where(f > 0, 3 + f, g)
# a thin solid rim right at the lip tip
grid[wave & (d_top <= CELL * 2) & (np.hypot(xx - LIP[0], yy - LIP[1]) < 34)] = 5
grid_runs(s, grid, PALETTE, CELL)"""A breaking wave curls over from the left with foam on its lip, in Jarvis-Judice-Ninke error diffusion."""
from itertools import pairwise
import numpy as np
from numpy.typing import ArrayLike, NDArray
from scipy.ndimage import distance_transform_edt
from skimage.draw import line as draw_line
from skimage.draw import polygon as fill_poly
from walldye import ACCENT, ACCENT_3, BG_ALT, UI, UI_ALT, Canvas, Rect, Vec, design
from walldye.field import cells, falloff, gauss, noise_grid
from walldye.geom import spline_points
from walldye.pixel import dither, grid_runs
type Field = NDArray[np.float64]
CELL = 3
PAD = 64 # cells of margin, so distances count the curve just off the screen
# Wave-local coordinates, in which the wave sits as it does on a 16:9 screen.
SEA = 900 # the sea surface
LIP = (672, 606) # the tip of the curl, placed by s.pick
# the back rises out of the sea well left of any screen, crests, and curls over into the lip
TOP = (
(-1700, 897),
(-1100, 882),
(-560, 838),
(-220, 752),
(0, 660),
(150, 575),
(320, 498),
(460, 454),
(560, 460),
(636, 496),
(682, 548),
(688, 594),
(664, 624),
(636, 624),
)
UNDER = ((636, 624), (652, 600), (644, 574), (612, 560), (574, 568), (550, 606))
FACE = ((548, 600), (550, 690), (600, 790), (700, 862), (860, 893), (1100, SEA))
# spray streamers flung forward off the lip, falling away down-right
STREAMERS = (
((692, 566), (735, 572), (772, 596), (796, 634), (806, 676)),
((690, 584), (722, 600), (744, 630), (754, 664)),
((694, 552), (742, 548), (786, 566), (818, 598)),
)
# body tones 0-3 (0 left bare), then the two foam steps
PALETTE = (None, BG_ALT, UI, UI_ALT, ACCENT_3, ACCENT)
def to_cells(pts: ArrayLike, o: Vec) -> Field:
"""Wave-local points as fractional (col, row) cell indices, cell centres at whole numbers."""
return (np.asarray(pts, float) + o) / CELL - 0.5
def edge_dist(curve: Field, shape: tuple[int, int]) -> Field:
"""Canvas-unit distance from every cell centre to the polyline `curve`, in cell indices."""
rows, cols = shape
free = np.ones((rows + 2 * PAD, cols + 2 * PAD), bool)
q = np.rint(curve).astype(int) + PAD
for (c0, r0), (c1, r1) in pairwise(q):
rr, cc = draw_line(r0, c0, r1, c1)
ok = (rr >= 0) & (rr < free.shape[0]) & (cc >= 0) & (cc < free.shape[1])
free[rr[ok], cc[ok]] = False
dist: Field = distance_transform_edt(free)
return dist[PAD:-PAD, PAD:-PAD] * CELL
@design(aspects=("16:9", "32:9", "9:19.5", "10:16"))
def draw(s: Canvas) -> None:
# the lip on the left third of a landscape screen; a little right of centre and low on a
# portrait one, with open sky above; whole cells, so the wave draws the same everywhere
o = s.pick(landscape=(0.35, 0.561), portrait=(0.6, 0.66), snap=CELL) - LIP
xs, ys = cells(Rect(0, 0, s.w, s.h), CELL)
rows, cols = xs.shape
xx, yy = xs - o.x, ys - o.y # wave-local cell centres
top = to_cells(spline_points(TOP, 40), o)
under = to_cells(spline_points(UNDER, 40), o)
face = to_cells(spline_points(FACE, 40), o)
base = SEA + 90 + s.h - o.y # below the screen, so the body reaches its bottom edge
foot = to_cells(((FACE[-1][0], base), (TOP[0][0], base)), o)
outline = np.vstack([top, under, face, foot])
wave = np.zeros((rows, cols), bool)
rr, cc = fill_poly(outline[:, 1], outline[:, 0], (rows, cols))
wave[rr, cc] = True
sea = yy >= SEA
d_top = edge_dist(np.vstack([top, under]), (rows, cols))
d_face = edge_dist(face, (rows, cols))
n = noise_grid(cols, rows, 64, s.np_rng(3), octaves=3)
# body: parallel streaks following the crest (brighter in the upper swell), a lit concave
# face, a darker base
streak = (0.5 + 0.5 * np.sin(d_top / 14 + n * 2.5)) ** 5
upper = np.clip((640 - yy) / 120, 0, 1)
body = (
0.16
+ (0.30 + 0.42 * upper) * streak * np.exp(-d_top / 220)
+ 0.5 * np.exp(-d_face / 70) * np.clip((1060 - xx) / 360, 0, 1)
- 0.14 * (yy - 450) / 630
)
swell = 0.12 + 0.07 * np.sin(np.maximum(yy - SEA, 0) ** 0.8 / 2.2 + n * 2)
sink = np.clip((yy - SEA + 90) / 180, 0, 1) # the swell's base settles into the sea
# faint volume inside the barrel so the hollow doesn't read as a punched hole
barrel = 0.26 * np.exp(-np.hypot((xx - 610) / 1.3, yy - 660) / 70) * np.exp(-d_face / 90)
tone = np.where(
wave, body * (1 - sink) + swell * sink, np.where(sea, swell, barrel * (xx > 540))
)
g = dither(tone, 4, method="jarvis", serpentine=True)
# foam: a band straddling the crest that thins to a hairline away from the lip, and fades
# where the back sinks toward the sea (only wider screens show that far)
near = np.maximum(
np.clip((xx - 420) / 240, 0, 1) ** 1.6,
np.exp(-np.hypot(xx - LIP[0], yy - LIP[1]) / 90),
)
width = 6 + 34 * near
sd = np.where(wave, d_top, -d_top)
foam = gauss((sd - 0.45 * width) / width, 0.5) * (0.45 + 0.5 * near) * (1 + 0.6 * n * near)
foam = np.clip(foam, 0, 1) * (sd > -width) * np.clip((SEA - 60 - yy) / 150, 0, 1)
grain = noise_grid(cols, rows, 8, s.np_rng(9))
spray = sum(
np.exp(-edge_dist(to_cells(spline_points(c, 20), o), (rows, cols)) / 5)
* falloff(np.hypot(xx - c[0][0], yy - c[0][1]), 160, 1.6)
for c in STREAMERS
)
foam = np.maximum(foam, np.clip(spray * (0.35 + 0.9 * grain), 0, 1) * ~wave)
f = dither(foam, 3, method="jarvis", serpentine=True)
grid = np.where(f > 0, 3 + f, g)
# a thin solid rim right at the lip tip
grid[wave & (d_top <= CELL * 2) & (np.hypot(xx - LIP[0], yy - LIP[1]) < 34)] = 5
grid_runs(s, grid, PALETTE, CELL)
Run it yourself
$ git clone https://github.com/nickolaj-jepsen/walldye && cd walldye$ uv run walldye render dither-wave --theme fireproof -o dither-wave-fireproof-16x9.svg