Skip to content

Fix the quality of blue noise dithering - #22

Open
kmaddock wants to merge 2 commits into
paperlesspaper:mainfrom
kmaddock:blue-noise
Open

Fix the quality of blue noise dithering#22
kmaddock wants to merge 2 commits into
paperlesspaper:mainfrom
kmaddock:blue-noise

Conversation

@kmaddock

Copy link
Copy Markdown

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):

kernel max |dL| (Oklab) max |dhue|
old ordered 19.0 88.9°
old blue-noise (RGB) 24.6 50.9°
coverage + Bayer 8×8 1.23 2.16°
coverage + noise 0.12 0.66°

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.

Kester Maddock 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.
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