Skip to content

feat: add dizzy dithering (DitherMode 9)#68

Merged
g4bri3lDev merged 7 commits into
mainfrom
feat/dizzy-dithering
Jul 22, 2026
Merged

feat: add dizzy dithering (DitherMode 9)#68
g4bri3lDev merged 7 commits into
mainfrom
feat/dizzy-dithering

Conversation

@g4bri3lDev

Copy link
Copy Markdown
Member

Adds dizzy dithering — error diffusion that visits pixels in pseudo-random order instead of a raster scan, spreading each pixel's error only to neighbours not yet quantized. No scan direction means no directional artifacts.

Algorithm by Liam Appelbe. Implemented from the written description — the article has no code listing, so this is an independent implementation, not a port.

opendisplay_structs.h:187 states DitherMode ids are owned by this repo, so no protocol change is needed; 9 was the next free value.

Evaluation — read this before merging

The article demonstrates the technique on continuous-tone greyscale. This library targets 2–7 ink measured palettes where hue error dominates, so the result does not transfer cleanly. Mean OKLab ΔE on 4×4-block-averaged output, 8 fixture images:

mode mean ΔE
burkes 0.1520
dizzy 0.1528
floyd_steinberg 0.1535

Overall it is a wash — the spread is within noise. Split by palette it is not:

  • spectra6 (the measured 6-ink palette this library actually targets): dizzy wins or ties on 7/8 images, up to ~17% better (unicorn.png 0.1321 vs 0.1596).
  • mono (2-ink B/W): dizzy loses on 5/8 and is the worst of the three, up to ~14% worse (frankfurt_nacht.png 0.2762 vs 0.2425).

Metric sanity check: DitherMode::None scores 0.1843, ~21% worse than every diffusion mode — so the metric is measuring what we think it is.

Cost: 1.09× Burkes at 100×100, 1.25× at 400×300. The gap widens with size, consistent with random access over a multi-hundred-KB f64 buffer being cache-hostile. This matters because Home Assistant renders on this path.

Verdict: an opt-in mode for multi-ink palettes, not a new default. Burkes stays the default.

Correctness

The load-bearing property is that the traversal visits every pixel exactly once. If it didn't, pixels would be silently skipped or quantized twice and the output would still look entirely plausible — no image-quality test would catch it.

Multiplication by an odd number is invertible mod 2^k and XOR is self-inverse, so five rounds compose to a bijection. Verified exhaustively for all n from 1 to 3000 plus real panel sizes (384,000 / 524,288 / 1024×576): zero failures.

Also pinned: error-conservation over all 255 non-empty neighbour subsets, determinism, canonical-ink pinning, and a frozen 16-value vector proving Rust, Python and TypeScript agree byte for byte. 16 new regression references (8 images × 2 tags) bring the suite to 40; zero existing references changed.

Notes

  • serpentine is ignored — there is no scan direction to reverse. Documented alongside NONE and ORDERED.
  • Canonical-pinned pixels are marked processed before the walk, so no neighbour wastes error on a pixel that will ignore it. This deliberately differs from the raster path, where pinned pixels accumulate error that is then discarded.
  • When every neighbour is already quantized the error is dropped. Inherent to the algorithm, and commented so nobody "fixes" it.
  • Pre-existing bug found, not fixed here: benches/dithering.rs's bench_real_images unconditionally loads a missing katzi.png at group-construction time, so an unfiltered cargo bench panics. Confirmed present on main. Worth a separate PR.
  • Downstream: odl-renderer and py-opendisplay must adopt id 9 before this is reachable from Home Assistant.

Adds an example comparing dizzy dithering against Burkes and
Floyd-Steinberg via block-averaged OKLab dE, and extends the
error_diffusion criterion benchmark group with a dizzy entry at the
same sizes as the other kernels.
Implemented from the written description in the linked article; the article
contains no code listing, so this is an independent implementation rather than
a port. Credits the two design choices taken directly from it: the stateless
multiply-and-xor permutation, and the 10:1 orthogonal:diagonal error ratio.
@g4bri3lDev
g4bri3lDev merged commit 4192900 into main Jul 22, 2026
9 checks passed
@g4bri3lDev
g4bri3lDev deleted the feat/dizzy-dithering branch July 22, 2026 13:35
@github-actions github-actions Bot mentioned this pull request Jul 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant