Walldye

Cyclone, line by line

Line by line, a dithered satellite picture of a cyclone comes in. The line being written is lit above bare static.

Made with Claude Opus 5.5

Technique
dithering, instrument displays
Shape
Any screen
Added
27 September 2026

Versions

Colours

  • #1C1B1Abackground
  • #DAD8CEforeground
  • #CF6A4Caccent

Export

Format
Shape
Size

Notes

NOAA’s polar-orbiting weather satellites broadcast Automatic Picture Transmission (APT) at 137 MHz, two lines a second, so a ground station draws the picture from the top down while the satellite passes overhead. Each line opens with seven sync pulses, which stack into the striped strip down the left edge; the stepped column on the right is a simplified telemetry wedge. NOAA-15, the last satellite sending APT, was switched off in August 2025.

Sources

  1. Automatic picture transmission.
  2. Martín Bernardi, How noaa-apt works.

Source code

wallpapers/wefax-cyclone/design.py, 100 lines

"""A weather-satellite picture of a cyclone arriving line by line over APT, Floyd-Steinberg dithered, with the live scanline picked out."""

import numpy as np
from numpy.typing import NDArray

from walldye import ACCENT, BG_ALT, UI, UI_ALT, UI_HI, Canvas, P, Params, design, knob
from walldye.pixel import dither, glyphs, grid_runs


class Pass(Params):
    line: int = knob(default=177, lo=110, hi=255, doc="lines received, counted from the top")
    complete: bool = False  # the whole frame received and the scanline gone


VARIANTS = {"complete": Pass(complete=True)}

CELL = 3
SYNC, WEDGE = 16, 4  # sync strip and telemetry wedge widths in cells
EYE = 5  # eye radius in cells
PALETTE = (None, BG_ALT, UI, UI_ALT, UI_HI)
EYEWALL = 4
PULSE = [0, 0] + [2, 0] * 7  # APT sync A: seven pulses
SPECKS = 30 / (81 * 354)  # static specks per unreceived cell
DRIFT = (1.7, 5.9)  # noise offset, so the eye does not sit on a lattice zero


def snap(v: float) -> int:
    """`v` rounded to a whole number of cells, in canvas units."""
    return round(v / CELL) * CELL


@design(aspects="any", variants=VARIANTS)
def draw(s: Canvas[Pass]) -> None:
    # The eye sits 100 lines down in every frame; a portrait frame is narrower and runs longer.
    eye_row = 100
    if s.landscape:
        cols, rows, eye_col = 374, 260, 207
        y0 = snap(s.center.y - rows * CELL / 2)
    else:
        cols, rows = (s.w - 180) // CELL, max(260, int(s.h * 0.4) // CELL)
        eye_col = round(cols * 0.553)
        y0 = snap(s.h * 0.37) - eye_row * CELL  # the eye a little above the middle
    x0 = snap(s.center.x - cols * CELL / 2)
    play = rows if s.params.complete else min(s.params.line, rows)  # first unreceived line

    # Eye-relative canvas units at each image cell, so every screen shape gets the same storm.
    inner = cols - SYNC - WEDGE
    ys, xs = np.mgrid[0:rows, 0:inner]
    x = (xs + SYNC - eye_col) * CELL
    y = (ys - eye_row) * CELL
    r = np.hypot(x, y) + 1

    def fbm(key: int, scale: float) -> NDArray[np.float64]:
        u, v = x / (scale * CELL) + DRIFT[0], y / (scale * CELL) + DRIFT[1]
        return 1.25 * s.noise(key).fbm(u, v, octaves=3)

    a = np.arctan2(y, x) + 2.4 * np.log(r / 50)  # log-spiral twist
    arms = (0.5 + 0.5 * np.cos(2 * a + 1.2 * fbm(3, 40))) ** 1.7
    body = np.exp(-((r / 280) ** 2))
    core = np.exp(-((r / 100) ** 3))  # dense overcast around the eye
    rag = np.clip(0.75 + 1.2 * fbm(5, 24), 0, 1.3)
    field = 0.85 * core + arms * body * rag + 0.2 * fbm(7, 12)
    field += 0.5 * np.clip(fbm(9, 14) - 0.12, 0, 1) * (1 - body)  # fair-weather cumulus
    field *= np.clip((ys * CELL - 20) / 90, 0, 1)  # cloud-free under the frame's top edge
    tone = np.clip((field - 0.16) * 1.1, 0, 1)
    rng = s.np_rng(5)
    tone += (tone > 0.08) * rng.uniform(-0.06, 0.06, rows)[:, None]  # per-line reception banding

    grid = dither(tone, 3, method="fs", serpentine=True)
    rc = r / CELL
    grid[rc < EYE] = 0
    grid[(rc >= EYE) & (rc < EYE + 1.2)] = EYEWALL
    for j in rng.choice(np.arange(8, play - 4), 4, replace=False):  # dropout lines
        grid[j] = np.minimum(grid[j], 1) if j % 2 else 0
    grid[play:] = 0

    full = np.zeros((rows, cols), int)
    full[:, SYNC:-WEDGE] = grid
    live = np.arange(play)
    full[:play, :SYNC] = np.where((live % 4 != 3)[:, None], PULSE, 0)
    full[:play, -WEDGE + 1 :] = (1 + (live // 10) % 2)[:, None]  # stepped calibration wedge
    n = round(SPECKS * (rows - play - 2) * inner)
    if n > 0:  # radio static below the scanline
        full[rng.integers(play + 1, rows - 1, n), rng.integers(SYNC, cols - WEDGE, n)] = 1
    grid_runs(s, full, PALETTE, CELL, (x0, y0))

    w, h = cols * CELL, rows * CELL
    s.stroke(P().rect(x0 - 0.5, y0 - 0.5, w + 1, h + 1), UI_ALT, 1)
    ty = y0 - 24
    glyphs(s, ["NOAA-19  APT  137.100 MHz  CH2"], UI_HI, at=(x0, ty), font="5x8", px=2)
    label = f"LINE {play:04d}/{rows:04d}"
    glyphs(s, [label], UI_HI, at=(x0 + w + 2, ty), font="5x8", px=2, anchor="end")
    # The scanline and its write head on the sync strip; after the last line the head is parked.
    head = P()
    if play < rows:
        py = y0 + play * CELL
        head.rect(x0, py, w, 3).rect(x0 + 2 * CELL, py - 6, 13 * CELL, 15)
    else:
        head.rect(x0 + 2 * CELL, y0 + h - 15, 13 * CELL, 15)
    s.fill(head, ACCENT)

Run it yourself

$ git clone https://github.com/nickolaj-jepsen/walldye && cd walldye$ uv run walldye render wefax-cyclone --theme fireproof -o wefax-cyclone-fireproof-16x9.svg