Walldye

Eddies in a field of strokes

inspired by Vera Molnár, Interruptions1

Randomly turned short strokes fill a grid with five clearings, and the strokes at each rim swing round it. In the largest clearing, fourteen lie parallel.

Made with Claude Opus 5.5

Technique
line art
Inspired by
early computer art
Shape
Any screen
Added
27 September 2026

Colours

  • #1C1B1Abackground
  • #DAD8CEforeground
  • #CF6A4Caccent

Export

Format
Shape
Size

Notes

The field takes the idea of Vera Molnár’s Interruptions: a grid of equal lines, each turned at random, with patches left empty. Here the clearings pull on their surroundings. A stroke at a rim lies along it, and the pull fades over the next row or two, so each clearing sits in a faint eddy. Inside the largest, a small block of strokes lies parallel.

Sources

  1. Vera Molnár, Interruptions, 1968. ↑

Source code

wallpapers/interruptions/design.py, 91 lines

"""A grid of randomly turned strokes broken by clearings; strokes at each rim swing round it, and order is found in the largest."""

import math

import numpy as np
from scipy import ndimage

from walldye import ACCENT, ACCENT_1, ACCENT_3, UI, Canvas, P, Rect, Vec, design, polar
from walldye.field import cells, gauss, noise_grid

# grid pitch, stroke length, least margin, pitch of the ordered sub-grid
PITCH, SEG, M, SUB = 30, 24, 40, 34
NOISE_SEED = 33
RIM = 1.6  # cells over which the rim's pull on stroke angles fades
# (fx, fy, radius) of the clearings: the hero that holds the ordered strokes, a secondary echo,
# three small ones. Portrait keeps the diagonal from the hero down to the echo.
LANDSCAPE = (
    (0.719, 0.389, 195),
    (0.224, 0.731, 72),
    (0.401, 0.269, 52),
    (0.552, 0.787, 56),
    (0.901, 0.778, 48),
)
PORTRAIT = (
    (0.62, 0.36, 195),
    (0.27, 0.7, 72),
    (0.28, 0.2, 52),
    (0.8, 0.6, 56),
    (0.66, 0.85, 52),
)
ORDER_DEG = -35  # the ordered strokes all point this way
TONES = (ACCENT, ACCENT_1, ACCENT_3)  # ordered strokes, core outwards


@design(aspects="any")
def draw(s: Canvas) -> None:
    rng = s.np_rng(1969)
    cols, rows = (s.w - 2 * M) // PITCH + 1, (s.h - 2 * M) // PITCH + 1
    gw, gh = cols * PITCH, rows * PITCH
    X, Y = cells(Rect((s.w - gw) / 2, (s.h - gh) / 2, gw, gh), PITCH)
    voids = [(s.frac(fx, fy), r) for fx, fy, r in (LANDSCAPE if s.landscape else PORTRAIT)]

    noise = noise_grid(cols, rows, 12, s.np_rng(NOISE_SEED), octaves=2)
    noise = (noise - noise.min()) / np.ptp(noise)
    # each bump crosses 0.5 at its radius; the noise only roughens the rims
    field = 0.5 * (noise - 0.5)
    for c, r in voids:
        field += gauss(np.hypot(X - c.x, Y - c.y), r / math.sqrt(math.log(2)))
    void = field > 0.5
    # fill back rim fragments and slits: they read as dropped strokes, not interruptions
    labels, n = ndimage.label(void)
    for k in range(1, n + 1):
        blob = labels == k
        js, iis = np.nonzero(blob)
        h, w = np.ptp(js) + 1, np.ptp(iis) + 1
        if blob.sum() < 6 or max(h, w) > 3 * min(h, w):
            void[blob] = False
    labels, _ = ndimage.label(void)
    hc = voids[0][0]
    hero = labels == labels.flat[np.argmin(np.hypot(X - hc.x, Y - hc.y))]
    # optical centre: the middle of the clearing's deepest core, not its centre of mass
    depth = ndimage.distance_transform_edt(np.pad(hero, 1))[1:-1, 1:-1]
    core = depth > 0.75 * depth.max()
    centre = Vec(X[core].mean(), Y[core].mean())

    strokes = P()
    ang = rng.uniform(0, math.pi, X.shape)
    # near a clearing a stroke swings towards the rim's tangent, fully at the rim, fading over RIM cells
    d = ndimage.distance_transform_edt(~void)
    gy, gx = np.gradient(ndimage.gaussian_filter(field, 1.5))
    tangent = np.arctan2(gy, gx) + math.pi / 2
    pull = np.exp(-np.clip(d - 1, 0, None) / RIM)
    ang = tangent + (1 - pull) * (ang - math.pi / 2)
    for j, i in zip(*np.nonzero(~void), strict=True):
        c, half = Vec(X[j, i], Y[j, i]), polar((0, 0), SEG / 2, rad=ang[j, i])
        strokes.M(c - half).L(c + half)
    s.stroke(strokes, UI, 1.4, cap="round")

    # 14 ordered strokes: a 4x4 sub-grid on the optical centre minus the corners off the stroke axis
    half = polar((0, 0), SEG / 2, deg=ORDER_DEG)
    with s.buckets(TONES, "stroke", stroke_width=2, stroke_linecap="round") as tone:
        for u in range(-1, 3):
            for v in range(-1, 3):
                if (u, v) in ((-1, -1), (2, 2)):
                    continue
                p = centre + (
                    (u - 0.5) * SUB + rng.normal(0, 1.5),
                    (v - 0.5) * SUB + rng.normal(0, 1.5),
                )
                r = math.hypot(u - 0.5, v - 0.5)
                tone[0 if r < 1 else 1 if r < 1.6 else 2].M(p - half).L(p + half)

Run it yourself

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