Parameter Sweeps
How to find the hero shots — automated parameter sweeps, sampling strategies, and the heuristics that pick winners.
Why sweep
You’ve written an evolving artwork. The parameter space has 3–7 knobs. You know some tuples produce beautiful output and some produce noise. The question is how to find the good ones efficiently.
You don’t find them by hand. A 4-dimensional real-valued parameter space with (-3, 3) ranges has ~10¹⁵ candidate tuples. Even if you spent ten seconds on each — twenty minutes per good tuple — you’d die before you saw the best one.
You find them by sweep: run the algorithm thousands of times across a sampling of the parameter space, score each output, and keep the top-K. The “sweep” is the engine room of every generative-art minting operation. fxhash seeds, Art Blocks mints, and the hero contact sheets in this library are all sweep outputs.
The minimum-viable sweep worker
Three things a sweep worker needs:
- A way to pick the next parameter tuple — sampling strategy.
- A way to render one output — instantiate, step, render.
- A way to score one output — heuristic.
import multiprocessing as mp
import numpy as np
from dataclasses import dataclass
@dataclass
class SweepResult:
params: dict
score: float
png_path: str | None
def run_one(sketch_cls, param_space, sample_fn, score_fn,
steps=400, out_dir=None, timeout_s=30):
"""One worker: sample, render, score, save."""
import signal, time
params = sample_fn(param_space)
try:
s = sketch_cls(**params)
s.step(steps)
img = s.render()
score = score_fn(img)
path = None
if out_dir is not None and score > 0: # keep the non-broken ones
path = f"{out_dir}/seed_{params['seed']:08d}.png"
import imageio.v2 as imageio
imageio.imwrite(path, img)
return SweepResult(params, score, path)
except Exception as e:
return SweepResult(params, -1.0, None)
def sweep(sketch_cls, param_space, sample_fn, score_fn,
n=512, n_workers=8, steps=400, out_dir="sweep"):
import os
os.makedirs(out_dir, exist_ok=True)
args = [(sketch_cls, param_space, sample_fn, score_fn, steps, out_dir)] * n
with mp.Pool(n_workers) as pool:
results = pool.starmap(run_one, args)
results.sort(key=lambda r: r.score, reverse=True)
return results
That run_one is the smallest worker that does anything useful. In production you’d want:
- Time-budget awareness — kill workers that exceed
timeout_sso one bad seed doesn’t stall the pool. - Resumable checkpointing — write
params + scoreto a JSONL after every result, so a crash mid-sweep doesn’t lose work. - Score-then-decide-save — only
imageio.imwritefor outputs above a score threshold; the rest are scored and discarded.
Time-budget awareness
This is the most common failure mode: you sweep 1000 seeds, 950 finish in 8 seconds, 50 take 10 minutes because they hit a degenerate-but-not-fatal state (e.g., particle systems where all agents clustered into a single pixel and the render loop is iterating over a million near-coincident points). The wall-clock for your sweep is determined by the slowest worker, not the median.
A safe pattern:
def run_with_budget(sketch_cls, params, steps, budget_s):
s = sketch_cls(**params)
start = time.time()
for _ in range(steps):
if time.time() - start > budget_s:
return None
s.step(1)
return s.render()
If the worker overruns, it returns None and the sweep manager treats it as a zero-score result.
Sampling strategies
The choice of sampling strategy matters enormously. Three useful ones:
Uniform grid
Sample each parameter on a regular grid, take the Cartesian product. Predictable, easy to reason about, terrible coverage in high dimensions.
def uniform_grid(space, n_per_axis=8):
"""Worst-case coverage. Use only for 2D parameter sweeps."""
import itertools
axes = []
for name, (lo, hi, _) in space.items():
axes.append((name, np.linspace(lo, hi, n_per_axis)))
keys = [a[0] for a in axes]
grids = [a[1] for a in axes]
for combo in itertools.product(*grids):
yield dict(zip(keys, combo))
For 2D sweeps over (a, b) with 16 samples per axis you get 256 runs — fine. For 5D you get a million. Don’t.
Latin hypercube
Sample N points such that each parameter is divided into N equal-probability bins, and exactly one sample falls in each bin per parameter. Far better coverage than grid in high dimensions.
def latin_hypercube(space, n=512, seed=0):
"""Stratified 1D-per-axis sampling. Standard for parameter sweeps."""
rng = np.random.default_rng(seed)
keys = list(space.keys())
samples = np.zeros((n, len(keys)))
for j, k in enumerate(keys):
lo, hi, _ = space[k]
# N bins in [lo, hi], one sample per bin, permuted
bins = np.arange(n)
rng.shuffle(bins)
for i in range(n):
u = (bins[i] + rng.uniform()) / n # uniform within bin
samples[i, j] = lo + (hi - lo) * u
for i in range(n):
yield dict(zip(keys, samples[i]))
n=512 over a 5D space gives a stratified spread where no parameter is under-sampled. This is the default strategy used by Art Blocks’ generation engine and by most production generative-art tooling.
Hand-picked landmarks
When you already know the interesting region of the space — say, (a, b, c, d) ∈ [-2.5, 2.5]^4 for PeterDeJong — you can drop landmark samples into the sweep to guarantee coverage of known-good tuples even if random sampling misses them.
LANDMARKS = [
{"a": 2.01, "b": -2.53, "c": 1.61, "d": -0.33}, # Tarbell's default
{"a": 2.20, "b": -2.40, "c": 1.10, "d": -0.50}, # "spiral" attractor
{"a": 1.40, "b": -2.30, "c": 0.50, "d": -2.20}, # "filament" attractor
{"a": -1.80, "b": 2.20, "c": -0.60, "d": 1.50}, # "lattice" attractor
]
The production pattern is: latin-hypercube the bulk, sprinkle landmarks, hand-curate the survivors.
The “hero shot” mindset
A sweep returns 512 ranked images. Which do you keep?
The answer depends on what the work is for. For a minting inventory you want a small set of high-distinctness images — 8 to 16 works that look meaningfully different from each other but recognisably members of the same family. For a contact-sheet marketing piece you want the top-50 by score displayed in a grid.
The hero-shot picking problem is a small clustering task. Pseudocode:
def pick_heroes(sweep_results, k=8, distinctness_threshold=0.3):
"""Greedy distinctness: pick the highest-scoring, then the next one
that is *visually* far from every picked hero."""
heroes = []
for r in sorted(sweep_results, key=lambda r: -r.score):
if len(heroes) >= k: break
# distinctness: smallest pixel-L2 distance to any existing hero
if not heroes or all(distance(r.img, h.img) > distinctness_threshold
for h in heroes):
heroes.append(r)
return heroes
The distance metric is up to you. Perceptual distance (lpips, SSIM, or a tiny learned embedding) beats pixel-L2 but is slower. For most generative-art use cases, downsample to 64×64 and use pixel-L2 — it’s a rough heuristic but it’s enough to filter out near-duplicates.
Auto-pick heuristics
You need a score. Three cheap, surprisingly effective ones.
Std-of-pixel-values (catches noise and blank frames)
def score_std(img):
"""High std = interesting structure. Low std = blank or solid."""
return float(np.std(img.astype(np.float32) / 255.0))
A PeterDeJong attractor that converged to a single fixed point has std ≈ 0 — a black dot on white. An attractor that hasn’t converged yet also has std ≈ 0 — pure white canvas. A converged attractor with rich structure has std ≈ 0.15-0.25.
Non-zero-pixel ratio (catches degenerate attractors)
def score_nonzero(img, threshold=8):
"""Fraction of pixels that have *any* non-trivial value."""
flat = img.mean(axis=2)
return float((flat < 255 - threshold).sum() / flat.size)
Catches both blank frames (score ≈ 0) and full-saturation frames (score ≈ 1.0 — usually a sign of overflow or NaN propagation).
Symmetry / structure
Harder. For some families — lindenmayer bushes, HansmeyerSubdivision columns — symmetry is a desired property. For others — peterdejong attractors, Guts tendrils — symmetry is a sign of a degenerate fixed point. The cheap proxy: split the image into quadrants and compute pairwise correlation. High symmetry means high correlation; you then either want it (L-systems) or reject it (attractors) depending on the family.
In practice, combine the cheap heuristics with rejection rules:
def score(img):
s_std = np.std(img.astype(np.float32) / 255.0)
s_nz = score_nonzero(img)
if s_std < 0.02: return 0.0 # blank
if s_nz > 0.99: return 0.0 # full-saturation overflow
if s_nz < 0.005: return 0.0 # too sparse
return s_std * (1.0 - abs(s_nz - 0.4)) # peak around 40% coverage
This is the score function for the sweep script behind the 52 hero renders in this corpus’s hero/ directory.
Three hero cards from the existing library
These are the canonical “best face” variants for three different algorithm families — chosen because each is in a different parameter-space region, so they exemplify the breadth of what the same algorithm can produce.
Hero 1 — Guts at seed=42
A seven-tendril growth with warm-to-cool gradient, sand-painter streaks, and the canonical curl pattern Tarbell photographed in his 2004 gallery. The default seed — almost every sweep ranks it first.
Guts(dim=500, num=7, numsands=3, seed=42)
Why it’s canonical: balances the warm-palette to cool-palette transition along the tendril length, has all three sand-painter zones active, and shows the characteristic thickening-then-thinning pattern that means the cpath hit both grth > 26 and grth < 6 clamps.
Hero 2 — PeterDeJong at (a=2.20, b=-2.40, c=1.10, d=-0.50)
A spiral attractor — 4000 travelers carving a near-perfect Archimedean-spiral trajectory over 200 steps. One of the most-photographed PeterDeJong parameter tuples, often cited in attractor galleries.
PeterDeJong(dim=500, a=2.20, b=-2.40, c=1.10, d=-0.50,
maxnum=4000, maxage=128, alpha=5, seed=0x5EEED07D)
Why it’s canonical: the (a, b, c, d) tuple produces a contracting spiral — every traveler converges inward to a limit cycle. The alpha=5 per-pixel value gives the trailing black-translucent look Tarbell used in the medium variant.
Hero 3 — Lindenmayer with fractal_weed preset at seed=2
The fractal_weed L-system — 5 iterations of X → F+[[X]-X]-F[-FX]+X — at the canonical seed produces a self-similar branching plant that fills the canvas edge-to-edge.
Lindenmayer(dim=600, preset="fractal_weed", iterations=5, seed=2)
Why it’s canonical: the angle (22.5°) and step length (6.0 × 0.72^5 ≈ 1.45px) are tuned so the branches just barely reach the canvas edges. Smaller iterations and the plant is a stick; larger and the string is too long to render in memory.
What to do with the survivors
The top-K from a sweep become the minting inventory. The mapping is direct:
- Top-1 hero → edition of 1 (one-of-one NFT, single seed).
- Top-8 heroes → edition of 8 (each hero a different seed, same algorithm).
- All 512 sweep results → contact-sheet marketing image.
The Art Blocks pattern — Fidenza, Ringers, Chromie Squiggle — uses the top-K-of-sweep model. Tyler Hobbs has spoken publicly about how his Fidenza mint was a sweep of ~2000 parameter tuples, then a hand-curated reduction to ~999 unique mints (one per token ID).
Where to go next
The reproducibility story for the sweep — making sure that the top-K hero shots you publish can be regenerated by anyone with the seed list — is in Chapter 4. The full hero contact sheets for the 26 library sketches are linked from /library/ under each work’s page.