Strokes with clearings
inspired by Vera Molnár, Interruptions1
Randomly turned strokes fill a grid, but beside each empty clearing they turn to lie along its rim.
Made with Claude Opus 5.5
- Technique
- line art
- Inspired by
- early computer art
- Shape
- Any screen
- Added
- 27 September 2026
Export
Colors
- #1C1B1Abackground
- #DAD8CEforeground
- #CF6A4Caccent
Notes
Vera Molnár’s Interruptions (1968) starts from 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. Inside the largest clearing, a small block of strokes lies parallel.
Sources
- Vera Molnár, Interruptions, 1968. ↑
Source code
wallpapers/
"""A grid of randomly turned strokes with clearings, the strokes at each rim turning to follow it."""
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 center: the middle of the clearing's deepest core, not its center of mass
depth = ndimage.distance_transform_edt(np.pad(hero, 1))[1:-1, 1:-1]
core = depth > 0.75 * depth.max()
center = 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 center 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 = center + (
(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)"""A grid of randomly turned strokes with clearings, the strokes at each rim turning to follow it."""
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 center: the middle of the clearing's deepest core, not its center of mass
depth = ndimage.distance_transform_edt(np.pad(hero, 1))[1:-1, 1:-1]
core = depth > 0.75 * depth.max()
center = 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 center 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 = center + (
(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 strokes-with-clearings --theme fireproof -o strokes-with-clearings-fireproof-16x9.svg