Produce placeholder images

Show a stand-in image wherever the real one is still missing, in a mock-up or an unfinished catalogue.

No AI needed

FreeInstantYour data never leaves

The verdict

This ladder has a single level, and that is the point: the need is decorative and arbitrary, which is exactly the case where a hash function gives the exact answer rather than an approximation. Nothing leaves the process, the marginal cost is nil, and the result is unit testable down to the character — the same expected markup is spelled out in both the Python and the JavaScript test, which pins the two implementations to each other. The way out of this entry is not upwards: it is getting hold of the real picture.

Deterministic SVG derived from a hash of the identifier

No AI needed

Rule and classic algorithmN0FreeInstant

Python

snippets/generate-placeholder-images/n0.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
A placeholder image without a model: an SVG derived from a hash of the identifier.

Rung N0. No file is written and no byte is downloaded: the function returns a
string of markup that a template can inline or a handler can serve.

Two properties carry the whole approach.

It is deterministic. The same identifier always yields exactly the same image,
in Python as in JavaScript, for as long as this code is left unchanged.

Its arithmetic is integer. Hue, chroma and cell positions are computed without
a single division that could round differently from one runtime to another,
which is what lets the Python and the JavaScript version agree character for
character.
"""

import re

# FNV-1a, the same constants as the rest of the catalogue, so that identifiers
# hash identically wherever they are hashed.
FNV_OFFSET = 2166136261
FNV_PRIME = 16777619
MASK32 = 0xFFFFFFFF

# Characters XML 1.0 does not allow anywhere in a document, escaped or not: C0
# controls other than tab and line breaks, lone surrogates, U+FFFE and U+FFFF.
NOT_XML = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f\ud800-\udfff\ufffe\uffff]")

GRID = 5  # cells per side
COLUMNS = 3  # independent columns; the remaining two mirror them


def stable_hash(text: str) -> int:
    """
    FNV-1a on 32 bits, fed code points rather than UTF-8 bytes: identical to
    the reference on ASCII, different from it on any other character.

    Not the built-in hash: that one is salted per process, so it would give a
    different image every time the server restarted.
    """
    digest = FNV_OFFSET
    for char in text:
        digest = ((digest ^ ord(char)) * FNV_PRIME) & MASK32
    return digest


def _hex_colour(hue: int, saturation: int, lightness: int) -> str:
    """HSL to hexadecimal, in integers only, all three arguments in percent."""
    chroma = (255 * saturation * (100 - abs(2 * lightness - 100))) // 10000
    edge = (chroma * (60 - abs((hue % 120) - 60))) // 60
    floor = (255 * lightness) // 100 - chroma // 2
    wheel = ((chroma, edge, 0), (edge, chroma, 0), (0, chroma, edge),
             (0, edge, chroma), (edge, 0, chroma), (chroma, 0, edge))
    red, green, blue = wheel[(hue // 60) % 6]
    return "#%02x%02x%02x" % (red + floor, green + floor, blue + floor)


def _escape(text: str) -> str:
    """
    The identifier ends up inside an attribute, so it is markup until escaped.

    Escaping is not enough: a control character pasted from a spreadsheet has
    no escaped form in XML 1.0 and would make the whole SVG unreadable, so it
    is dropped. The ampersand goes first, or it would escape the escapes.
    """
    text = NOT_XML.sub("", text)
    text = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
    return text.replace('"', "&quot;")


def placeholder_svg(identifier: str, size: int = 240) -> str:
    """
    Build a symmetric two-tone figure on a tinted ground.

    The high bits of the hash choose the hue, the low ones switch cells on and
    off. Mirroring the left columns onto the right ones costs one line and is
    what makes the result read as a mark rather than as noise.
    """
    digest = stable_hash(identifier)
    hue = (digest >> 16) % 360
    cell = size // GRID
    margin = (size - cell * GRID) // 2

    parts = [
        f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}"'
        f' viewBox="0 0 {size} {size}" role="img" aria-label="{_escape(identifier)}">',
        f'<rect width="{size}" height="{size}" fill="{_hex_colour(hue, 45, 90)}"/>',
    ]
    ink = _hex_colour(hue, 55, 42)
    for row in range(GRID):
        for column in range(GRID):
            mirrored = min(column, GRID - 1 - column)
            if not (digest >> (row * COLUMNS + mirrored)) & 1:
                continue
            x = margin + column * cell
            y = margin + row * cell
            parts.append(
                f'<rect x="{x}" y="{y}" width="{cell}" height="{cell}" fill="{ink}"/>'
            )
    parts.append("</svg>")
    return "".join(parts)

JavaScript

snippets/generate-placeholder-images/n0.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * A placeholder image without a model: an SVG derived from a hash of the identifier.
 *
 * Rung N0. No file is written and no byte is downloaded: the function returns
 * a string of markup that a template can inline or a handler can serve.
 *
 * Two properties carry the whole approach.
 *
 * It is deterministic. The same identifier always yields exactly the same
 * image, in JavaScript as in Python, for as long as this code is left unchanged.
 *
 * Its arithmetic is integer. Hue, chroma and cell positions are computed
 * without a single division that could round differently from one runtime to
 * another, which is what lets the JavaScript and the Python version agree
 * character for character.
 */

// FNV-1a, the same constants as the rest of the catalogue, so that identifiers
// hash identically wherever they are hashed.
const FNV_OFFSET = 2166136261;
const FNV_PRIME = 16777619;

// Characters XML 1.0 does not allow anywhere in a document, escaped or not: C0
// controls other than tab and line breaks, lone surrogates, U+FFFE and U+FFFF.
const NOT_XML = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\ud800-\udfff\ufffe\uffff]/gu;

const GRID = 5; // cells per side
const COLUMNS = 3; // independent columns; the remaining two mirror them

/**
 * FNV-1a on 32 bits, fed code points rather than UTF-8 bytes: identical to
 * the reference on ASCII, different from it on any other character.
 *
 * Math.imul is what keeps it exact: a plain multiplication on numbers this
 * large loses precision past 2^53 and would silently drift away from the
 * integers Python computes.
 */
export function stableHash(text) {
  let digest = FNV_OFFSET;
  for (const char of text) {
    digest = Math.imul(digest ^ char.codePointAt(0), FNV_PRIME) >>> 0;
  }
  return digest;
}

/** HSL to hexadecimal, in integers only, all three arguments in percent. */
function hexColour(hue, saturation, lightness) {
  const chroma = Math.floor((255 * saturation * (100 - Math.abs(2 * lightness - 100))) / 10000);
  const edge = Math.floor((chroma * (60 - Math.abs((hue % 120) - 60))) / 60);
  const floor = Math.floor((255 * lightness) / 100) - Math.floor(chroma / 2);
  const wheel = [[chroma, edge, 0], [edge, chroma, 0], [0, chroma, edge],
    [0, edge, chroma], [edge, 0, chroma], [chroma, 0, edge]];
  const channels = wheel[Math.floor(hue / 60) % 6];
  return `#${channels.map((v) => (v + floor).toString(16).padStart(2, '0')).join('')}`;
}

/**
 * The identifier ends up inside an attribute, so it is markup until escaped.
 *
 * Escaping is not enough: a control character pasted from a spreadsheet has
 * no escaped form in XML 1.0 and would make the whole SVG unreadable, so it
 * is dropped. The ampersand goes first, or it would escape the escapes.
 */
function escape(text) {
  return text.replace(NOT_XML, '').replaceAll('&', '&amp;').replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;').replaceAll('"', '&quot;');
}

/**
 * Build a symmetric two-tone figure on a tinted ground.
 *
 * The high bits of the hash choose the hue, the low ones switch cells on and
 * off. Mirroring the left columns onto the right ones costs one line and is
 * what makes the result read as a mark rather than as noise.
 */
export function placeholderSvg(identifier, size = 240) {
  const digest = stableHash(identifier);
  const hue = (digest >>> 16) % 360;
  const cell = Math.floor(size / GRID);
  const margin = Math.floor((size - cell * GRID) / 2);

  const parts = [
    `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}"` +
      ` viewBox="0 0 ${size} ${size}" role="img" aria-label="${escape(identifier)}">`,
    `<rect width="${size}" height="${size}" fill="${hexColour(hue, 45, 90)}"/>`,
  ];
  const ink = hexColour(hue, 55, 42);
  for (let row = 0; row < GRID; row += 1) {
    for (let column = 0; column < GRID; column += 1) {
      const mirrored = Math.min(column, GRID - 1 - column);
      if (!((digest >>> (row * COLUMNS + mirrored)) & 1)) continue;
      const x = margin + column * cell;
      const y = margin + row * cell;
      parts.push(`<rect x="${x}" y="${y}" width="${cell}" height="${cell}" fill="${ink}"/>`);
    }
  }
  parts.push('</svg>');
  return parts.join('');
}

Try it

interactive

The code above runs in your browser. Type in the box: the result is redone in front of you.

Type an identifier: a product reference, an address, a customer name. The same string always gives exactly the same image.

yours to edit

Recomputed on every keystroke.

Or start from an example

Free typing and the other examples need JavaScript. The example shown below was computed when the site was built.

What the code makes of it

What it returns

Hue 44° — yellow

24 flat rectangles in 2 colours, and nothing to download: the whole markup sits in the page.

The image produced

Placeholder image for “Boulangerie Martin”

“Boulangerie Martin” will give this same image, in JavaScript as in Python, for as long as this code is left unchanged.

What this means for you

  • Your data stays put
  • Always the same result
  • Unit-testable, case by case
  • No provider in the loop
  • Negligible footprint

What this adds to your regulatory scope

  • No specific scope added: the image is computed inside your own process, nothing is fetched and nothing is sent
  • The identifier you pass is copied into the aria-label of the markup served: whatever it holds is published with the page

Breaking point

The image says nothing about what it stands in for: the hash decides everything, the meaning of the identifier decides nothing. The test pins this on three counts — “red velvet sofa” does not land in the reds, sofa-1 and sofa-2 get hues far apart although they are the same couch, and the output is never anything but flat rectangles in two colours.

When to move up a level

There is no level above this one. The day you need an actual photograph of the thing, what is being asked of you is no longer a stand-in, and no approach on this page answers it.

The four options

  1. RecommendedN0Deterministic SVG derived from a hash of the identifierFree · Instant
  2. N1Not worth it here
  3. N2Not worth it here
  4. N3Not worth it here

The other options

N1Not worth it here

Not worth it here

Lightweight classic modelN1

There is nothing to learn. A classic model is trained to predict a right answer from examples; here no image is more correct than another for a given identifier, the wanted output being decorative and arbitrary. No labelled set can exist, and a trained model would only add a weights file to carry around where a hash function already gives a stable answer.

N2Not worth it here

Not worth it here

Small self-hosted specialised modelN2

A self-hosted diffusion model to draw a few coloured rectangles is oversizing in its purest form, and it loses the one property this entry needs: PyTorch, which Hugging Face’s diffusion library runs on, does not guarantee reproducible results across its releases, across platforms, or between CPU and GPU, even with the same seed. It also produces exactly what a stand-in must not be: a plausible picture of the missing object.

N3Not worth it here

Not worth it here

General-purpose LLM APIN3

The same disproportion, with two extra faults: every missing tile becomes a billed call to a third party, and the model drawing it has an end date. The provider used as an example shut down DALL·E 2 and DALL·E 3 on 12 May 2026: a catalogue whose stand-ins came from those models can no longer ask for them again.

Further reading

Where the figures come from

Metadata