Skip to content
Sample of Paint by Numbers (Haeberli)

generative_pioneers · haeberli_painterly

Paint by Numbers (Haeberli)

Paul Haeberli's 1990 SIGGRAPH 'Paint by Numbers' — the founding paper of stroke-based rendering. A continuous-valued target field (here one of four analytic color fields: diagonal_band, sunset, rose, valley) supplies a luminance image; N random strokes are placed uniformly across the canvas, each is assigned the local color and a local-gradient direction (with a small random jitter), and crucially the strokes are sorted by luminance and drawn back-to-front in increasing order. The result is the classic 'painterly' impressionist render: bright strokes land last and pop, dim strokes land first and recede. The simple ordering trick is the algorithmic kernel that has dominated the SBR family ever since.

Original: https://www.dgp.toronto.edu/~hertzman/sbr02/hertzmann-cga03.pdf

▶ Try the live demo (JS port) ▶ Run reference.py via Pyodide 10 formulas have instant JS ports. The Pyodide path runs the actual Python in your browser — first load is ~6 MB, then instant.

Family
generative_pioneers
Source
generative pioneers
Evolution
static_arrangement
Render
stroke_based_painting
Complexity
medium
Agents
1200
Year
1990
Author
Paul Haeberli

Math essence

Sample N points {(x_i, y_i)} ~ U(canvas²); each gets color C_i = target[y_i, x_i] + ε, where ε ~ U(−j, j) per channel; stroke orientation θ_i = atan2(∇lum) + jitter. Sort the N strokes by L(y_i, x_i) ascending and render with low-alpha alpha_base per stroke so each lays down a soft pigment deposit that accumulates.

Parameters

Name Type Default Range Description
dim int 512 200–1200 Square canvas side length
n_strokes int 1200 100–8000 Number of strokes to lay down
stroke_length int 18 4–80 Length per stroke (pixels)
stroke_thickness int 6 1–30 Radius of each stroke (pixels)
source str diagonal_band null–null Target field: diagonal_band | sunset | rose | valley
color_jitter float 0.15 0–0.6 Per-channel color jitter (fraction of 255)
luminance_seed int 42 0–999999 Seed for stroke placement RNG
alpha_base float 0.32 0.05–1 Per-stroke alpha
bg_color tuple 245,240,230 null–null Canvas RGB
seed int null 0–999999 Outer RNG seed

Reference

First 60 lines of reference.py. Full source: library_external/generative_pioneers/haeberli_painterly/reference.py

from __future__ import annotations

from typing import ClassVar

import imageio.v2 as imageio
import numpy as np


class HaeberliPainterly:
    family: ClassVar[str] = "generative_pioneers"
    sketch: ClassVar[str] = "haeberli_painterly"

    def __init__(
        self,
        dim: int = 512,
        n_strokes: int = 1200,
        stroke_length: int = 18,
        stroke_thickness: int = 6,
        source: str = "diagonal_band",
        color_jitter: float = 0.15,
        luminance_seed: int = 42,
        alpha_base: float = 0.32,
        bg_color: tuple[int, int, int] = (245, 240, 230),
        seed: int | None = None,
    ) -> None:
        self.dim = dim
        self.n_strokes = n_strokes
        self.stroke_length = stroke_length
        self.stroke_thickness = stroke_thickness
        self.source = source
        self.color_jitter = color_jitter
        self.luminance_seed = luminance_seed
        self.alpha_base = alpha_base
        self.bg_color = np.array(bg_color, dtype=np.uint8)
        self._seed = seed
        self.rng = np.random.default_rng(seed)
        self.canvas = np.full((dim, dim, 3), self.bg_color, dtype=np.uint8)
        self.time = 0
        self._dirty = True

    def reset(self, seed: int | None = None) -> None:
        if seed is not None:
            self._seed = seed
        self.rng = np.random.default_rng(self._seed)
        self.canvas = np.full((self.dim, self.dim, 3), self.bg_color, dtype=np.uint8)
        self.time = 0
        self._dirty = True

    def _build_target_field(self) -> np.ndarray:
        d = self.dim
        ys, xs = np.indices((d, d))
        xn = (xs / d - 0.5) * 2.0
        yn = (ys / d - 0.5) * 2.0
        if self.source == "diagonal_band":
            v = 0.5 + 0.45 * np.sin((xn + yn) * 3.0)
        elif self.source == "sunset":
            v = 0.55 + 0.45 * (np.sin(xn * 2.0) * np.cos(yn * 2.5))
        elif self.source == "rose":
            r = np.hypot(xn, yn)
            v = 0.5 + 0.5 * np.sin(r * 8.0 - xn * 3.0)

Try running it locally

The reference implementation is self-contained Python with numpy. Install the package and run:

python library_external/generative_pioneers/haeberli_painterly/reference.py

Need help? See Learn for the standard environment setup, or Playground for an in-browser sketchbook.

More from generative pioneers

Sample of Interruptions (Molnár)

Interruptions (Molnár)

generative_pioneers · molnar_interruptions

Vera Molnár's 1968 'Interruptions' series: a regular grid of rectangle cells where each cell's four border edges are drawn independently, each with its own random probability of being omitted.

View →
Sample of Line Arrangements (Mohr)

Line Arrangements (Mohr)

generative_pioneers · mohr_line_arrangement

Manfred Mohr's 1969 plotter 'Line Arrangements': a fixed grid of columns and rows, each carrying a small set of nearly-parallel lines whose exact positions are random within the cell, overlaid with a layer of 45° diagonals whose length, position and orientation are independently random.

View →
Sample of Plotter Trajectories (Hébert)

Plotter Trajectories (Hébert)

generative_pioneers · hebert_plotter_trajectory

Jean-Pierre Hébert's plotter-trajectory idiom — parametric Lissajous-like curves drawn over multiple passes, each pass slightly phase-shifted, each segment modulated by the local velocity of the pen so that slow regions lay down heavier pigment and fast regions trace a ghost line.

View →
Sample of Walk-through-Raster (Nake)

Walk-through-Raster (Nake)

generative_pioneers · nake_walk_sequence

Frieder Nake's 1966 'Walk-through-Raster' program (Algol 60 on a Telefunken TR4, plotted on a ZUSE Graphomat Z64): a repertoire of six signs — empty, vshort, vlong, hshort, hlong, dot — is sampled into a Markov chain whose transition probabilities vary with chain position so that 'vertical strokes are high at the chain's middle, empties dominate at the ends'.

View →

See all generative_pioneers artworks →