Quiet Render

The generator

This is the whole renderer — every image on this site came out of this file. It has two dependencies, NumPy and Pillow, and no configuration: parameters are drawn from the seed, so the only input is an integer.

Download render.py 299 lines · 12 KB · public domain

Run it: python gen/render.py --seed 1000 --size 4k writes the PNG, a WebP preview and a JSON file of the parameters.

"""Quiet Render — a deterministic generative image renderer.Every image is derived entirely from its seed: the same seed always produces thesame picture, so the parameters printed next to a piece on the site are enoughto reproduce it byte for byte.Usage:    python gen/render.py --seed 8412 --size 4k --out out/    python gen/render.py --count 20 --out out/"""from __future__ import annotationsimport argparseimport jsonimport timefrom dataclasses import dataclass, asdictfrom pathlib import Pathimport numpy as npfrom PIL import ImageSIZES = {    "preview": (1600, 900),    "hd": (1920, 1080),    "4k": (3840, 2160),    "5k": (5120, 2880),}# Palettes are ordered dark -> light. Values are plain 8-bit RGB stops; the# renderer interpolates between them in linear space.PALETTES: dict[str, list[tuple[int, int, int]]] = {    "ash": [(9, 12, 20), (32, 44, 66), (98, 116, 140), (176, 186, 194), (226, 222, 210)],    "kelp": [(6, 16, 14), (18, 48, 44), (52, 102, 92), (140, 172, 154), (222, 226, 210)],    "ember": [(14, 11, 10), (58, 30, 22), (132, 66, 40), (198, 132, 84), (238, 220, 196)],    "quartz": [(16, 14, 26), (48, 38, 68), (108, 86, 122), (176, 148, 168), (232, 220, 224)],    "graphite": [(8, 9, 11), (38, 41, 46), (92, 97, 104), (158, 163, 170), (228, 230, 232)],    "tide": [(7, 14, 24), (20, 52, 72), (44, 106, 122), (128, 176, 178), (224, 232, 226)],}ALGORITHMS = ("flow", "warp", "strata", "cells")@dataclassclass Params:    """Everything needed to reproduce a render. Published next to the piece."""    seed: int    algorithm: str    palette: str    octaves: int    frequency: int    warp: float    contrast: float    grain: float    rotation: float    @classmethod    def from_seed(cls, seed: int) -> "Params":        rng = np.random.default_rng(seed)        return cls(            seed=seed,            algorithm=str(rng.choice(ALGORITHMS)),            palette=str(rng.choice(list(PALETTES))),            octaves=int(rng.integers(3, 7)),            frequency=int(rng.integers(2, 7)),            warp=round(float(rng.uniform(0.0, 0.9)), 3),            contrast=round(float(rng.uniform(0.85, 1.6)), 3),            grain=round(float(rng.uniform(0.004, 0.016)), 4),            rotation=round(float(rng.uniform(0.0, np.pi)), 3),        )def _value_noise(rng: np.random.Generator, height: int, width: int, freq: int) -> np.ndarray:    """Smoothed value noise on a `freq`-cell lattice, bilinearly upsampled."""    lattice = rng.random((freq + 1, freq + 1), dtype=np.float32)    ys = np.linspace(0.0, freq, height, endpoint=False, dtype=np.float32)    xs = np.linspace(0.0, freq, width, endpoint=False, dtype=np.float32)    y0 = np.floor(ys).astype(np.int32)    x0 = np.floor(xs).astype(np.int32)    fy = ys - y0    fx = xs - x0    # Smoothstep the interpolant so cell borders do not show as creases.    fy = fy * fy * (3.0 - 2.0 * fy)    fx = fx * fx * (3.0 - 2.0 * fx)    top = lattice[y0][:, x0] + (lattice[y0][:, x0 + 1] - lattice[y0][:, x0]) * fx[None, :]    bot = lattice[y0 + 1][:, x0] + (lattice[y0 + 1][:, x0 + 1] - lattice[y0 + 1][:, x0]) * fx[None, :]    return top + (bot - top) * fy[:, None]def _fbm(rng: np.random.Generator, height: int, width: int, freq: int, octaves: int) -> np.ndarray:    """Fractal Brownian motion: octaves of value noise at doubling frequency."""    total = np.zeros((height, width), dtype=np.float32)    amplitude = 1.0    norm = 0.0    for octave in range(octaves):        total += amplitude * _value_noise(rng, height, width, freq * 2**octave)        norm += amplitude        amplitude *= 0.5    return total / normdef _normalize(field: np.ndarray) -> np.ndarray:    lo, hi = float(field.min()), float(field.max())    if hi - lo < 1e-9:        return np.zeros_like(field)    return (field - lo) / (hi - lo)def _sample(field: np.ndarray, yy: np.ndarray, xx: np.ndarray) -> np.ndarray:    """Bilinear gather. Nearest-neighbour here leaves visible column smears    wherever the displacement is flat, so the interpolation is not optional."""    height, width = field.shape    yy = np.clip(yy, 0.0, height - 1.001)    xx = np.clip(xx, 0.0, width - 1.001)    y0 = yy.astype(np.int32)    x0 = xx.astype(np.int32)    fy = (yy - y0).astype(np.float32)    fx = (xx - x0).astype(np.float32)    top = field[y0, x0] + (field[y0, x0 + 1] - field[y0, x0]) * fx    bot = field[y0 + 1, x0] + (field[y0 + 1, x0 + 1] - field[y0 + 1, x0]) * fx    return top + (bot - top) * fydef _render_warp(rng, height, width, p: Params) -> np.ndarray:    """Domain warping: a noise field sampled through an offset of itself."""    base = _fbm(rng, height, width, p.frequency, p.octaves)    if p.warp > 0.0:        dx = _fbm(rng, height, width, p.frequency, max(2, p.octaves - 1))        dy = _fbm(rng, height, width, p.frequency, max(2, p.octaves - 1))        shift = p.warp * min(height, width) * 0.12        yy = np.arange(height, dtype=np.float32)[:, None] + (dy - 0.5) * shift        xx = np.arange(width, dtype=np.float32)[None, :] + (dx - 0.5) * shift        base = _sample(base, np.broadcast_to(yy, base.shape), np.broadcast_to(xx, base.shape))    return _normalize(base)def _render_strata(rng, height, width, p: Params) -> np.ndarray:    """Banded sediment: a warped field folded into layers."""    field = _render_warp(rng, height, width, p)    bands = 4 + int(p.frequency)    folded = np.abs(np.sin(field * bands * np.pi * 0.5))    # Weighted towards the underlying field: pure folding blows the highlights    # out into flat paper-white.    return _normalize(folded * 0.45 + field * 0.55)def _render_cells(rng, height, width, p: Params) -> np.ndarray:    """Worley-like cells: distance to the nearest of a scattered point set."""    count = 24 + p.frequency * 12    points = rng.random((count, 2), dtype=np.float32)    ys = np.linspace(0.0, 1.0, height, dtype=np.float32)[:, None]    xs = np.linspace(0.0, 1.0, width, dtype=np.float32)[None, :]    aspect = width / height    nearest = np.full((height, width), np.inf, dtype=np.float32)    second = np.full((height, width), np.inf, dtype=np.float32)    for py, px in points:        d = np.sqrt((ys - py) ** 2 + ((xs - px) * aspect) ** 2 / aspect)        second = np.minimum(second, np.maximum(nearest, d))        nearest = np.minimum(nearest, d)    # F2-F1 has a long tail: the pixels deepest inside the largest cell sit    # several times above the median, so normalising by the maximum pushed the    # bulk of the frame into the bottom fifth of the scale and produced a wide    # flat black patch. Clip the tail at the 98th percentile, then square-root    # what is left to lift the mid-tones back off the floor.    gap = second - nearest    edges = np.sqrt(np.clip(gap / np.percentile(gap, 98), 0.0, 1.0))    warped = _render_warp(rng, height, width, p)    return _normalize(edges * 0.65 + warped * 0.35)def _render_flow(rng, height, width, p: Params) -> np.ndarray:    """Streamlines: particles advected through a noise-driven vector field."""    angle_field = _fbm(rng, height, width, p.frequency, p.octaves) * np.pi * 2.0 + p.rotation    particles = 60_000    steps = 220    step_len = min(height, width) / 900.0    py = rng.random(particles, dtype=np.float32) * (height - 1)    px = rng.random(particles, dtype=np.float32) * (width - 1)    density = np.zeros(height * width, dtype=np.float32)    for _ in range(steps):        iy = py.astype(np.int32)        ix = px.astype(np.int32)        angle = angle_field[iy, ix]        py += np.sin(angle) * step_len        px += np.cos(angle) * step_len        inside = (py >= 0) & (py < height - 1) & (px >= 0) & (px < width - 1)        # Respawn escaped particles so the frame stays evenly covered.        if not inside.all():            escaped = ~inside            py[escaped] = rng.random(int(escaped.sum()), dtype=np.float32) * (height - 1)            px[escaped] = rng.random(int(escaped.sum()), dtype=np.float32) * (width - 1)        flat = py.astype(np.int32) * width + px.astype(np.int32)        density += np.bincount(flat, minlength=height * width).astype(np.float32)    density = density.reshape(height, width)    density = np.log1p(density * 3.0)    backdrop = _fbm(rng, height, width, max(2, p.frequency // 2), 3)    return _normalize(_normalize(density) * 0.78 + backdrop * 0.22)RENDERERS = {    "flow": _render_flow,    "warp": _render_warp,    "strata": _render_strata,    "cells": _render_cells,}def _colorize(field: np.ndarray, palette: str, contrast: float) -> np.ndarray:    """Map a 0..1 field through the palette, in linear light."""    stops = np.array(PALETTES[palette], dtype=np.float32) / 255.0    linear_stops = stops**2.2    shaped = np.clip((field - 0.5) * contrast + 0.5, 0.0, 1.0)    position = shaped * (len(linear_stops) - 1)    idx = np.clip(position.astype(np.int32), 0, len(linear_stops) - 2)    frac = (position - idx)[..., None]    lo = linear_stops[idx]    hi = linear_stops[idx + 1]    linear = lo + (hi - lo) * frac    return np.clip(linear ** (1.0 / 2.2), 0.0, 1.0)def render(seed: int, size: str = "4k") -> tuple[Image.Image, Params]:    width, height = SIZES[size]    params = Params.from_seed(seed)    # A second stream keeps pixel data independent of the parameter draw, so    # changing the parameter schema later does not reshuffle existing pieces.    rng = np.random.default_rng(seed ^ 0x5EED)    field = RENDERERS[params.algorithm](rng, height, width, params)    rgb = _colorize(field, params.palette, params.contrast)    # Grain is part of the look, and it is what gives the files their weight:    # a smooth gradient would compress down to almost nothing.    if params.grain > 0.0:        rgb = np.clip(rgb + rng.normal(0.0, params.grain, rgb.shape).astype(np.float32), 0.0, 1.0)    return Image.fromarray((rgb * 255.0 + 0.5).astype(np.uint8), mode="RGB"), paramsdef write_piece(seed: int, out_dir: Path, size: str = "4k") -> dict:    out_dir.mkdir(parents=True, exist_ok=True)    started = time.time()    image, params = render(seed, size)    stem = f"qr-{seed:06d}"    full_path = out_dir / f"{stem}-{size}.png"    image.save(full_path, format="PNG", optimize=False)    preview = image.resize(SIZES["preview"], Image.LANCZOS)    preview_path = out_dir / f"{stem}-preview.webp"    preview.save(preview_path, format="WEBP", quality=82, method=4)    meta = {        **asdict(params),        "size": size,        "width": image.width,        "height": image.height,        "bytes": full_path.stat().st_size,        "render_seconds": round(time.time() - started, 2),    }    (out_dir / f"{stem}.json").write_text(json.dumps(meta, indent=2) + "\n")    return metadef main() -> None:    parser = argparse.ArgumentParser(description="Render Quiet Render pieces.")    parser.add_argument("--seed", type=int, help="render a single seed")    parser.add_argument("--count", type=int, default=1, help="render N sequential seeds")    parser.add_argument("--start", type=int, default=1000, help="first seed when using --count")    parser.add_argument("--size", choices=sorted(SIZES), default="4k")    parser.add_argument("--out", type=Path, default=Path("out"))    args = parser.parse_args()    seeds = [args.seed] if args.seed is not None else range(args.start, args.start + args.count)    for seed in seeds:        meta = write_piece(seed, args.out, args.size)        print(            f"qr-{seed:06d}  {meta['algorithm']:<6} {meta['palette']:<9} "            f"{meta['bytes'] / 1e6:6.1f} MB  {meta['render_seconds']:5.1f}s"        )if __name__ == "__main__":    main()