Produce placeholder images
Show a stand-in image wherever the real one is still missing, in a mock-up or an unfinished catalogue.
RecommendedN0 Reviewed on
| Rung | Approach | Cost | Latency | Data | Deterministic | Verdict |
|---|---|---|---|---|---|---|
| N0 — Rule and classic algorithm | Deterministic SVG derived from a hash of the identifier | None | <1 ms | Nothing leaves | Yes | Recommended |
| N1 — Lightweight classic model | Rung not applicable 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. | |||||
| N2 — Small self-hosted specialised model | Rung not applicable A self-hosted diffusion model to draw a few coloured rectangles is oversizing in its purest form: a permanent service, a graphics accelerator, and an image you cannot reproduce byte for byte after a library or driver upgrade. It also produces exactly what a stand-in must not be — a plausible picture of the missing object, which readers will take for the real one. | |||||
| N3 — General-purpose LLM API | Rung not applicable The same disproportion, with two extra faults: every missing tile becomes a billed call to a third party, and nothing guarantees the same identifier gives back the same image, the provider's model shifting under your feet. Re-render the catalogue a month later and its stand-ins have all changed. | |||||
N0 — Rule and classic algorithm Rule and classic algorithm Recommended
Deterministic SVG derived from a hash of the identifier
- Cost
- None
- Latency
<1 ms
Proof of execution : Code runs as shown
This snippet runs with its real dependencies, and its test runs on every build of the site.
Python
"""
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,
on every machine, in every language, for ever. A placeholder that changed on
each render would flicker in a grid and defeat every HTTP cache.
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.
"""
# 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
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.
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.
The ampersand goes first, or it would escape the escapes.
"""
text = text.replace("&", "&").replace("<", "<").replace(">", ">")
return text.replace('"', """)
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
/**
* 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, on every machine, in every language, for ever. A placeholder that
* changed on each render would flicker in a grid and defeat every HTTP cache.
*
* 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;
const GRID = 5; // cells per side
const COLUMNS = 3; // independent columns; the remaining two mirror them
/**
* FNV-1a on 32 bits.
*
* 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.
*
* The ampersand goes first, or it would escape the escapes.
*/
function escape(text) {
return text.replaceAll('&', '&').replaceAll('<', '<')
.replaceAll('>', '>').replaceAll('"', '"');
}
/**
* 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('');
}Risks
- Data leaving
- Nothing leaves
- Determinism
- Yes
- Testability
- Unit testable
- Vendor dependency
- None
- Footprint
- Negligible
- 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 rung
There is no rung 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.
N1 — Lightweight classic model Lightweight classic model
Rung not applicable
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.
N2 — Small self-hosted specialised model Small self-hosted specialised model
Rung not applicable
A self-hosted diffusion model to draw a few coloured rectangles is oversizing in its purest form: a permanent service, a graphics accelerator, and an image you cannot reproduce byte for byte after a library or driver upgrade. It also produces exactly what a stand-in must not be — a plausible picture of the missing object, which readers will take for the real one.
N3 — General-purpose LLM API General-purpose LLM API
Rung not applicable
The same disproportion, with two extra faults: every missing tile becomes a billed call to a third party, and nothing guarantees the same identifier gives back the same image, the provider's model shifting under your feet. Re-render the catalogue a month later and its stand-ins have all changed.
The verdict
RecommendedN0
This ladder has a single rung, 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.
Further reading
- W3C — SVG 2, la spécification du format
- FNV — la page de référence de Landon Curt Noll sur cette famille de hachages
- W3C — SVG Accessibility API Mappings, le rôle img et le nom accessible
- Wikipédia — Identicon, l'image dérivée d'un hachage