3 stable releases
Uses new Rust 2024
| 2.0.1 | Jun 4, 2026 |
|---|---|
| 1.0.0 | Apr 13, 2026 |
#342 in Images
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/#rgbstrings 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, configurabledot_scaleand 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