#image #dithering #palette #cdn #halftone

bin+lib bulb

Image CDN with stylized dithering support

3 stable releases

Uses new Rust 2024

2.0.1 Jun 4, 2026
1.0.0 Apr 13, 2026

#342 in Images

MIT license

110KB
2.5K SLoC

bulb

Ordered dithering and palette extraction for images.

Bulb provides Bayer, clustered-dot, and noise dithering methods with two colour modes: uniform quantisation (fixed levels per channel) and palette-based quantisation (automatic palette extraction via k-means and farthest point sampling).

Library usage

use bulb::dither::{DitherMethod, adjust, custom, json, ordered, presets::Preset, svg};
use bulb::dither::ordered::OrderedOptions;
use bulb::dither::palette::{self, DitherOptions, PaletteMethod, dither_palette};

let mut img = image::open("photo.png")?.to_rgba8();

// Optional pre-pass: gamma / contrast / brightness.
adjust::apply(&mut img, adjust::Adjust { gamma: 1.2, contrast: 1.1, brightness: 0.0 });

// Ordered dither with 4 levels per channel.
ordered::dither_cpu(&mut img, OrderedOptions {
    method: DitherMethod::Bayer8x8, levels: 4, edge_threshold: None,
});

// Palette sources: extract, preset, or custom hex.
let pal = palette::extract_palette(&img, 8, 2, 10_000, PaletteMethod::Hybrid, true, false);
let pal = Preset::GameBoy.colors();
let pal = custom::palette_from_hex(&["#0f380f", "#306230", "#8bac0f", "#9bbc0f"])?;

// Dither to palette with edge preservation; returns per-pixel palette indices.
let (w, h) = (img.width(), img.height());
let opts = DitherOptions { method: DitherMethod::Bayer8x8, edge_threshold: Some(0.05) };
let indices = dither_palette(&mut img, &pal, opts);

// Uniform-levels dither also accepts an edge-preservation gate.
ordered::dither_cpu(&mut img, OrderedOptions {
    method: DitherMethod::Bayer8x8, levels: 4, edge_threshold: Some(0.05),
});

// Optional vector / index outputs from the same indices.
let svg_str = svg::render_svg(&indices, w, h, &pal, svg::SvgOptions {
    shape: svg::Shape::Circle, dot_scale: 1.0, background: None,
});
let json_str = json::render_json(&indices, w, h, &pal);

Add to your Cargo.toml:

[dependencies]
bulb = { version = "0.1", default-features = false }

The default feature enables the built-in HTTP server and CLI. Use default-features = false for just the dithering library.

Dither methods

Method Pattern
Bayer2x2 2x2 Bayer matrix, fastest, most visible pattern
Bayer4x4 4x4 Bayer matrix, good balance
Bayer8x8 8x8 Bayer matrix, subtle pattern
Cluster4 4x4 clustered dot, halftone look
Cluster6 6x6 clustered dot
Cluster8 8x8 clustered dot
Noise Per-pixel random thresholds, no visible pattern (deterministic)

Palette extraction

extract_palette builds a 5-bit-per-channel histogram, filters noise, then selects colours using one of three methods:

  • Hybrid (default) -- k-means for base colours, farthest point sampling for accents
  • Kmeans -- minimises quantisation error
  • Fps -- maximises colour gamut coverage

Options include linear-light conversion for perceptually meaningful distances and a perceptual cap to prevent dominant colours from crowding out rarer ones.

Preset and custom palettes

Skip extraction by supplying a fixed palette:

  • Presets (bulb::dither::presets::Preset): GameBoy, Nes, Cga, Pico8, Mac, C64.
  • Custom hex (bulb::dither::custom::palette_from_hex): parses #rrggbb / #rgb strings into a dither-ready palette.

Pre-pass adjustments

bulb::dither::adjust applies brightness, contrast, and gamma via a single 256-entry LUT before dithering. Pipeline order: brightness (additive, in [-1.0, 1.0]) → contrast (multiplicative around 0.5) → gamma (pow(1 / gamma)). Alpha is untouched.

Edge preservation

edge_threshold (palette mode via DitherOptions, uniform mode via OrderedOptions) runs a one-time luminance Sobel pass and hard-gate-snaps pixels whose normalised gradient magnitude exceeds the threshold straight to the nearest palette colour / quantised level. Lower values snap more pixels; None disables the pass. A good starting point is Some(0.05).

Vector and index outputs

dither_palette_indexed_with returns Vec<u16> palette indices alongside the RGBA result. Two renderers consume those indices:

  • bulb::dither::svg::render_svg — square or circle dots, configurable dot_scale and optional background.
  • bulb::dither::json::render_json — JSON dump of width, height, palette and indices for downstream tooling.

CLI

# Ordered dither, 3 levels
dither -i photo.png -o out.png -m bayer8x8 -l 3

# Palette mode, 8 colours (extracted)
dither -i photo.png -o out.png -m bayer8x8 -k 8 --linear

# Built-in preset palette
dither -i photo.png -o out.png -m bayer8x8 --preset gameboy

# Custom palette via hex list
dither -i photo.png -o out.png -m bayer8x8 \
    --palette-hex "#0f380f,#306230,#8bac0f,#9bbc0f"

# Pre-pass adjustments + edge preservation
dither -i photo.png -o out.png -m bayer8x8 --preset nes \
    --gamma 1.2 --contrast 1.1 --brightness 0.0 --edge-threshold 0.05

# SVG vector output (requires a palette source)
dither -i photo.png -o out.svg --preset pico8 \
    --svg-shape circle --svg-dot-scale 0.9

# JSON palette-index dump
dither -i photo.png -o out.json --preset gameboy

# Black and white
dither -i photo.png -o out.png -m cluster8 -l 2 --grayscale

The output format is picked from the extension: .png / .jpg / .webp for raster, .svg for vector, .json for the index dump. SVG and JSON outputs require a palette source (--preset, --palette-hex, or --colors).

Features

Feature Default Description
server yes HTTP server, CLI tools, full image format support
rerun no Rerun.io visualisation for palette debugging

License

MIT

Dependencies

~8–56MB
~1M SLoC