Fix the quality of blue noise dithering - #22
Open
kmaddock wants to merge 2 commits into
Open
Conversation
added 2 commits
September 1, 2026 10:37
- Generate a full panel res blue noise texture, 16 bit. - Sample the correct proportion of inks to avoid hue shifts. This fixes poor quality for bayer, ordered, and blue noise dithers.
Browser-based resample causes artifacts.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This change improves the quality of ordered, bayer, and blue-noise dither implementations.
All three threshold dithers worked by perturbing a pixel's colour by the kernel value and snapping to the nearest palette entry. That has nothing forcing the local average back to the colour you started with, resulting in a large hue shift.
The gap shows up as a flattened tone curve and a hue pulled toward whichever inks are crowded.
The blue-noise texture was also a 64×64 tile inlined as a JS array, repeated ~19×25 times across a panel. The eye picks a repeating tile out as a grid long before it notices noise.
We can solve for the ink proportions whose linear-light mix is the target colour, and then sample that (as a probability distribution) using the dither kernel to pick a single ink. The expected mix is then the target colour, by construction.
Three colour equations plus "the weights sum to one" is four unknowns, so the best mix always lies on a face of at most four inks. src/utils/coverage.ts enumerates every non-degenerate tetrahedron of the palette, solves each exactly with a 4×4 inverse, and keeps the set that reaches the colour — breaking ties toward the tightest set of inks, which speckles least.
Measured on the Spectra 6 palette, over in-gamut targets only (an unreachable colour can't be a fair test of bias):
The residual ~1.2 L on the ordered variant is the Bayer matrix's own quantisation (64 levels), not bias.
Also in this PR
scripts/generate-blue-noise.mjs — void-and-cluster (Ulichney 1993) generator, no dependencies, ~28 s for a 1200×1600 mask over 1441 passes. Batched by taking strict local minima of the blurred pattern (mutually separated by construction) with a pace bound, and blurring via FFT so the mask wraps.
src/dither/data/blue-noise-1200x1600.png — one panel-sized 16-bit greyscale mask, replacing the inlined 64×64 tile.
src/utils/png.ts — minimal greyscale PNG decoder (8/16-bit, all five filter types, non-interlaced). Needed because a canvas hands back 8-bit channels and would discard half the file. Falls back to the platform decoder at 8-bit if the strict path throws.
examples/ — the preview now renders 1:1 against device pixels in "Show original size". A CSS pixel isn't a device pixel on a scaled display, so the old width: auto still resampled by that ratio, and a dither is all near-Nyquist detail, so it beat into a visible moiré.
Review notes / risks
The blue noise PNG is 3.8 MB and ships inside the package.
Coverage is capped at 4–8 inks.C(N,4) grows fast, so outside that range coverageDither returns false and the original perturb-and-snap loop runs unchanged. bayerDither is a general-purpose export, so silently hanging on a 256-colour palette wasn't acceptable. The e-paper palettes are 6–7.
The solve is cached per distinct RGB value, which is what makes it affordable; the threshold is still applied per pixel.
The 16-bit mask genuinely matters now — the variate feeds the CDF comparison at full precision, unlike the old kernels which perturbed 8-bit channel values.