Felix Itzenplitz
de

gsichtl: deterministic pixel-art avatars from any seed

Issued
Extent
6 min read · 1185 words
By
Felix Itzenplitz
A grid of 32 generated 8x8 pixel-art avatars on dark backgrounds: colorful monsters with horns, antennae and big eyes, mixed with human faces wearing hair, glasses and headphones
32 faces, from the seeds user-0 to user-31.
Contents

Anything with user accounts needs a picture for the people who never upload one, and groups need one too. The usual answers are a grey silhouette or a pair of initials in a colored circle. Both work, and both make a list of people look like a list of the same person.

gsichtl is a small Rust crate that draws something better from any seed: a little monster or a nerdy human face for a person, and a heraldic crest for a group. The same id gives the same picture on every machine, so each client draws it locally and nothing is stored or transmitted.

Gsichtl is Bavarian for “little face”. Source is at github.com/cebor/gsichtl (opens in a new window) , the crate is on crates.io (opens in a new window) , and the API docs are on docs.rs (opens in a new window) . The current version is 0.1.1.

Four generators

The seed is any byte string, typically a public user or group id. There are four functions in gsichtl to turn it into a picture:

FunctionGridDrawsCombinations
monster8x8Antennae, horns or ears, big eyes and a mouthup to 324,000
nerd8x8A human face with hair, glasses or goggles and sometimes headphonesup to 1,327,104
face8x8A monster or a nerd, about half each—
crest10x10A shield with a division and an emblem, for groups28,800

A monster is 4 backgrounds, 10 bodies, 6 accents, 6 tops, 3 silhouettes, 3 brows, 5 eyes and 5 mouths. A nerd is 4 backgrounds, 6 skins, 8 hair colors, 3 frames, 6 accents, 2 heads, 6 hair styles, 4 eyes and 4 mouths, with or without headphones. A crest is 4 backgrounds, 6 fields, 5 second fields, 2 metals, 3 shields, 5 divisions and 8 emblems.

The face counts are upper bounds. Some parts don’t use the accent color at all, and two faces that differ only in their accent then look the same. face(seed) has its own context and spends one draw on choosing between monster and nerd, so it is not necessarily monster(seed) or nerd(seed) for the same seed.

A grid of 32 generated 10x10 pixel-art crests: shields in red, blue, green, purple, orange and teal, split into two fields, each carrying a gold or white emblem such as a star, crown, crescent, cross, mug or lightning bolt
32 crests, from the seeds community-0 to community-31.

Using it

[dependencies]
gsichtl = { version = "0.1", features = ["png"] }

The png feature is optional. Without it you get RGBA8 pixels and do the encoding yourself.

Render to RGBA8:

let avatar = gsichtl::face(b"alice");
// 12 px per cell, 16 px of background around the grid: 128 x 128 RGBA8.
let image = avatar.to_rgba(12, 16);
assert_eq!((image.width, image.height), (128, 128));

Draw the cells yourself:

let a = gsichtl::crest(b"community-7");
for y in 0..a.side() {
    for x in 0..a.side() {
        let color = a.cell(x, y).unwrap_or(a.background());
        // Fill the square at (x, y) with `color`, an sRGB `[u8; 3]`.
    }
}

Encode as PNG, with the png feature:

let png = gsichtl::face(b"alice").to_png(16, 8)?;
std::fs::write("alice.png", png)?;

An image is side * cell_px + 2 * margin_px pixels square, at most 3060, and cell_px == 0 gives a plain square of background. Drawing the cells yourself is how you get an SVG, a canvas or a terminal rendering, without the crate knowing about any of them.

How a seed becomes a face

The seed goes through BLAKE3 in derive-key mode, with one context string per generator:

gsichtl 2026-10-02 face v1
gsichtl 2026-10-02 monster v1
gsichtl 2026-10-02 nerd v1
gsichtl 2026-10-02 crest v1

The seed is the input, and finalize_xof() turns the hash into an endless stream of bytes. Every choice the generator makes is a draw from that stream: 4 bytes, read as a little-endian u32, modulo the number of options.

pub(crate) fn new(context: &'static str, seed: &[u8]) -> Self {
    let mut hasher = blake3::Hasher::new_derive_key(context);
    hasher.update(seed);
    Self(hasher.finalize_xof())
}

/// A number in `0..n`; `0` when `n` is `0`.
pub(crate) fn pick(&mut self, n: usize) -> usize {
    let mut bytes = [0u8; 4];
    self.0.fill(&mut bytes);
    u32::from_le_bytes(bytes) as usize % n.max(1)
}

BLAKE3 is a deterministic mixer here, not a security measure. Nothing about an avatar is secret. What matters is that its output is fixed by its specification, so updating the blake3 crate cannot repaint anyone.

The parts themselves are small grids of characters. These are the five monster eyes, each a Layer with the row it starts on, its rows, and whether it is clipped:

const EYES: [Layer; 5] = [
    // looking right
    Layer::new(4, &["#ww##ww#", "#wp##wp#"], true),
    // looking left
    Layer::new(4, &["#ww##ww#", "#pw##pw#"], true),
    // looking up
    Layer::new(4, &["#pw##pw#", "#ww##ww#"], true),
    // cyclops
    Layer::new(4, &["##wwww##", "##wppw##"], true),
    // sleepy
    Layer::new(4, &["#dd##dd#", "#wp##wp#"], true),
];

Each character is an ink: # body, d dark, w white, p pupil, a accent, h hair, g frame, t second field, e metal, and . for nothing.

The layers are painted in a fixed order. A layer with clip: true, which means brows, eyes and mouths, paints only cells that are already painted, so it can never leave the silhouette. In monsters, the dark ink takes the background color, so a mouth or a sleepy eyelid reads as a hole in the body rather than a black line on it.

Crests have one more rule. The second field is drawn with pick_other, which picks from one option fewer and skips the first field’s color, so the two halves of a shield never share a color.

Same seed, same face

Within a 0.x minor version, the same seed gives the same avatar. Changing a part, a palette, the draw order or a context string is a breaking change and bumps the minor version. That promise is the whole point: everyone who draws an avatar for the same id has to see the same picture, and they won’t all update at the same time.

Two tests hold the crate to it. seeds_keep_their_pictures in tests/gsichtl.rs pins BLAKE3 hashes of 1-px renders for the seeds alice and 32 zero bytes, so any change to the output fails the test suite. distinct_seeds_look_distinct renders 1000 seeds and requires at least 990 distinct pictures from face, monster and nerd. Crests only get a threshold of 950: there are only 28,800 of them, and by the birthday problem about 17 collisions per 1000 seeds are expected.

Not an identity check

Warning

Anyone can upload a picture that looks exactly like someone else’s generated face. A generated avatar is decoration. Never present it as proof of who someone is.

Small on purpose

The crate does no I/O and is #![forbid(unsafe_code)]. It has one dependency, blake3, plus png behind the optional feature. It needs Rust 1.91 with edition 2024 and is MIT licensed.

Releases are a pushed tag. A GitHub Actions workflow builds and publishes through crates.io trusted publishing, so no token is stored anywhere. Trusted publishing only works for a crate that already exists, so 0.1.0 had to go out by hand once.

To render your own sheet of faces and crests:

cargo run --example render --features png -- out 32

That writes face-NN.png from the seeds user-N and crest-NN.png from community-N into out/, at 16 px per cell with an 8 px margin. The code is at github.com/cebor/gsichtl (opens in a new window) .