feat(importer): phase 4 — more dithering algorithms

Replace the single Floyd-Steinberg toggle with a method dropdown offering
none, floyd, atkinson, jarvis, burkes, sierra, sierra-lite (error diffusion)
and bayer-2 / bayer-4 / bayer-8 (ordered). Kernels live in
src/lib/dither-kernels.js as plain data; rgbaToPalette dispatches via a
shared runErrorDiffusion runner and a runOrderedDither for Bayer.

The legacy `dither: true` option keeps working (maps to `method: 'floyd'`),
so existing CLI invocations and tests stay green. CLI gains
`--dither-method <name>` which takes precedence over `--dither`.

13 unit tests cover exact-palette hits, transparent pixels, all-method
smoke, kernel weight sums (Atkinson=0.75, others=1.0), and verify Bayer
actually scatters on mid-grey where plain nearest would produce one color.
This commit is contained in:
tiennm99 committed 2026-04-17 11:53:52 +07:00
1 parent fc0121fd48
commit 4a5b6ef903
7 files changed
+298 -39

No files matched your search

+12 -7
View File
@@ -1,6 +1,6 @@
<script>
import { CANVAS_WIDTH, CANVAS_HEIGHT } from '../../lib/constants.js';
import { rgbaToPalette, paletteToRgba } from '../../lib/image-to-palette.js';
import { rgbaToPalette, paletteToRgba, DITHER_METHODS } from '../../lib/image-to-palette.js';
import { resizeRgba } from '../../lib/image-resize.js';
import { transformRgba } from '../../lib/image-transform.js';
import { createImageUploader } from '../../lib/image-uploader.js';
@@ -24,7 +24,7 @@
let originX = $state(0);
let originY = $state(0);
let skipMatching = $state(true);
let dither = $state(false);
let ditherMethod = $state('none'); // see DITHER_METHODS
// Resize controls — init to source dims on file load
let resizeW = $state(0);
@@ -97,7 +97,7 @@
const resized = (resizeW === transformed.width && resizeH === transformed.height)
? transformed.rgba
: resizeRgba(transformed.rgba, transformed.width, transformed.height, resizeW, resizeH, resampleMethod);
const idx = rgbaToPalette(resized, resizeW, resizeH, { dither });
const idx = rgbaToPalette(resized, resizeW, resizeH, { method: ditherMethod });
paletteIndices = idx;
let count = 0;
for (let i = 0; i < idx.length; i++) if (idx[i] >= 0) count++;
@@ -344,10 +344,15 @@
<input type="checkbox" bind:checked={skipMatching} />
Skip pixels that already match
</label>
<label class="row checkbox" title="Floyd-Steinberg error diffusion. Better for photos & gradients; noisier for flat art.">
<input type="checkbox" bind:checked={dither} />
Dither (Floyd-Steinberg)
</label>
<div class="row">
<label>Dither
<select bind:value={ditherMethod} title="error-diffusion (floyd/atkinson/…) or ordered (bayer-*)">
{#each DITHER_METHODS as m}
<option value={m}>{m}</option>
{/each}
</select>
</label>
</div>
{#if total > 0}
<div class="progress">
+102
View File
@@ -0,0 +1,102 @@
/**
* Dither kernels (error-diffusion) and threshold matrices (ordered).
*
* Error-diffusion kernel entries: [dx, dy, weight]. Weights are fractions so
* sums tell you how much error is redistributed (Floyd = 1.0, Atkinson = 0.75).
*
* Bayer matrices are normalized to 0..1 (divided by N*N).
*/
const FS = 1 / 16;
export const KERNEL_FLOYD = [
[ 1, 0, 7 * FS],
[-1, 1, 3 * FS],
[ 0, 1, 5 * FS],
[ 1, 1, 1 * FS],
];
// Atkinson (original Mac) — only diffuses 6/8 of the error; gives a softer look.
const ATK = 1 / 8;
export const KERNEL_ATKINSON = [
[ 1, 0, ATK],
[ 2, 0, ATK],
[-1, 1, ATK],
[ 0, 1, ATK],
[ 1, 1, ATK],
[ 0, 2, ATK],
];
// Jarvis-Judice-Ninke — 12-entry kernel over 3 rows, smoother than FS.
const JJN = 1 / 48;
export const KERNEL_JARVIS = [
[ 1, 0, 7 * JJN], [ 2, 0, 5 * JJN],
[-2, 1, 3 * JJN], [-1, 1, 5 * JJN], [0, 1, 7 * JJN], [1, 1, 5 * JJN], [2, 1, 3 * JJN],
[-2, 2, 1 * JJN], [-1, 2, 3 * JJN], [0, 2, 5 * JJN], [1, 2, 3 * JJN], [2, 2, 1 * JJN],
];
// Burkes
const BUR = 1 / 32;
export const KERNEL_BURKES = [
[ 1, 0, 8 * BUR], [ 2, 0, 4 * BUR],
[-2, 1, 2 * BUR], [-1, 1, 4 * BUR], [0, 1, 8 * BUR], [1, 1, 4 * BUR], [2, 1, 2 * BUR],
];
// Sierra (3-line)
const SIE = 1 / 32;
export const KERNEL_SIERRA = [
[ 1, 0, 5 * SIE], [ 2, 0, 3 * SIE],
[-2, 1, 2 * SIE], [-1, 1, 4 * SIE], [0, 1, 5 * SIE], [1, 1, 4 * SIE], [2, 1, 2 * SIE],
[-1, 2, 2 * SIE], [0, 2, 3 * SIE], [1, 2, 2 * SIE],
];
// Sierra Lite — cheapest non-trivial
const SLT = 1 / 4;
export const KERNEL_SIERRA_LITE = [
[ 1, 0, 2 * SLT],
[-1, 1, 1 * SLT],
[ 0, 1, 1 * SLT],
];
export const ERROR_DIFFUSION_KERNELS = {
floyd: KERNEL_FLOYD,
atkinson: KERNEL_ATKINSON,
jarvis: KERNEL_JARVIS,
burkes: KERNEL_BURKES,
sierra: KERNEL_SIERRA,
'sierra-lite': KERNEL_SIERRA_LITE,
};
// Bayer threshold matrices. Values are normalized to [0, 1).
const BAYER_2 = [
[0, 2],
[3, 1],
].map((row) => row.map((v) => v / 4));
const BAYER_4 = [
[ 0, 8, 2, 10],
[12, 4, 14, 6],
[ 3, 11, 1, 9],
[15, 7, 13, 5],
].map((row) => row.map((v) => v / 16));
const BAYER_8 = [
[ 0, 32, 8, 40, 2, 34, 10, 42],
[48, 16, 56, 24, 50, 18, 58, 26],
[12, 44, 4, 36, 14, 46, 6, 38],
[60, 28, 52, 20, 62, 30, 54, 22],
[ 3, 35, 11, 43, 1, 33, 9, 41],
[51, 19, 59, 27, 49, 17, 57, 25],
[15, 47, 7, 39, 13, 45, 5, 37],
[63, 31, 55, 23, 61, 29, 53, 21],
].map((row) => row.map((v) => v / 64));
export const BAYER_MATRICES = {
'bayer-2': BAYER_2,
'bayer-4': BAYER_4,
'bayer-8': BAYER_8,
};
export const DITHER_METHODS = [
'none', 'floyd', 'atkinson', 'jarvis', 'burkes', 'sierra', 'sierra-lite',
'bayer-2', 'bayer-4', 'bayer-8',
];
+60 -21
View File
@@ -1,4 +1,7 @@
import { COLORS_RGBA } from './constants.js';
import { ERROR_DIFFUSION_KERNELS, BAYER_MATRICES, DITHER_METHODS } from './dither-kernels.js';
export { DITHER_METHODS };
/**
* Return the palette index closest to (r, g, b) in squared-RGB distance.
@@ -25,19 +28,30 @@ export function nearestColorIndex(r, g, b) {
/**
* Convert an RGBA pixel buffer into a flat array of palette indices.
* Pixels with alpha < threshold become -1 (caller should skip on upload).
*
* Back-compat: `dither: true` is treated as `method: 'floyd'`.
*
* @param {Uint8Array|Uint8ClampedArray|number[]} rgba - length = width*height*4
* @param {number} width
* @param {number} height
* @param {Object} [options]
* @param {number} [options.alphaThreshold=128]
* @param {boolean} [options.dither=false] - Floyd-Steinberg error diffusion
* @param {boolean} [options.dither=false] - legacy; prefer `method`
* @param {('none'|'floyd'|'atkinson'|'jarvis'|'burkes'|'sierra'|'sierra-lite'|'bayer-2'|'bayer-4'|'bayer-8')} [options.method]
* @returns {Int16Array} length = width*height
*/
export function rgbaToPalette(rgba, width, height, options = {}) {
const { alphaThreshold = 128, dither = false } = options;
return dither
? ditherFloydSteinberg(rgba, width, height, alphaThreshold)
: quantizeNearest(rgba, width, height, alphaThreshold);
const method = options.method ?? (dither ? 'floyd' : 'none');
if (method === 'none') return quantizeNearest(rgba, width, height, alphaThreshold);
if (ERROR_DIFFUSION_KERNELS[method]) {
return runErrorDiffusion(rgba, width, height, alphaThreshold, ERROR_DIFFUSION_KERNELS[method]);
}
if (BAYER_MATRICES[method]) {
return runOrderedDither(rgba, width, height, alphaThreshold, BAYER_MATRICES[method]);
}
throw new Error(`Unknown dither method: ${method}. Valid: ${DITHER_METHODS.join(', ')}.`);
}
function quantizeNearest(rgba, width, height, alphaThreshold) {
@@ -51,13 +65,14 @@ function quantizeNearest(rgba, width, height, alphaThreshold) {
return out;
}
function ditherFloydSteinberg(rgba, width, height, alphaThreshold) {
/** Generic error-diffusion runner. Kernel rows only diffuse forward (dy > 0 or
* dy === 0 && dx > 0) so the working buffer can be a single-pass float image. */
function runErrorDiffusion(rgba, width, height, alphaThreshold, kernel) {
const count = width * height;
const out = new Int16Array(count);
// Working RGB buffer in float so we can accumulate error without clamping early.
const buf = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
buf[i * 3] = rgba[i * 4];
buf[i * 3] = rgba[i * 4];
buf[i * 3 + 1] = rgba[i * 4 + 1];
buf[i * 3 + 2] = rgba[i * 4 + 2];
}
@@ -67,9 +82,7 @@ function ditherFloydSteinberg(rgba, width, height, alphaThreshold) {
const i = y * width + x;
if (rgba[i * 4 + 3] < alphaThreshold) { out[i] = -1; continue; }
const r = buf[i * 3];
const g = buf[i * 3 + 1];
const b = buf[i * 3 + 2];
const r = buf[i * 3], g = buf[i * 3 + 1], b = buf[i * 3 + 2];
const idx = nearestColorIndex(
r < 0 ? 0 : r > 255 ? 255 : r,
g < 0 ? 0 : g > 255 ? 255 : g,
@@ -79,22 +92,48 @@ function ditherFloydSteinberg(rgba, width, height, alphaThreshold) {
const [pr, pg, pb] = COLORS_RGBA[idx];
const er = r - pr, eg = g - pg, eb = b - pb;
diffuse(buf, rgba, width, height, x + 1, y, er, eg, eb, 7 / 16, alphaThreshold);
diffuse(buf, rgba, width, height, x - 1, y + 1, er, eg, eb, 3 / 16, alphaThreshold);
diffuse(buf, rgba, width, height, x, y + 1, er, eg, eb, 5 / 16, alphaThreshold);
diffuse(buf, rgba, width, height, x + 1, y + 1, er, eg, eb, 1 / 16, alphaThreshold);
for (const [dx, dy, w] of kernel) {
const nx = x + dx;
const ny = y + dy;
if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue;
const ni = ny * width + nx;
if (rgba[ni * 4 + 3] < alphaThreshold) continue; // skip transparent neighbors
buf[ni * 3] += er * w;
buf[ni * 3 + 1] += eg * w;
buf[ni * 3 + 2] += eb * w;
}
}
}
return out;
}
function diffuse(buf, rgba, width, height, nx, ny, er, eg, eb, w, alphaThreshold) {
if (nx < 0 || nx >= width || ny < 0 || ny >= height) return;
const ni = ny * width + nx;
if (rgba[ni * 4 + 3] < alphaThreshold) return; // skip transparent neighbors
buf[ni * 3] += er * w;
buf[ni * 3 + 1] += eg * w;
buf[ni * 3 + 2] += eb * w;
/** Ordered dither — bias each pixel by a threshold-matrix shift before
* nearest-color lookup. No error propagation; cheap and stateless. */
function runOrderedDither(rgba, width, height, alphaThreshold, matrix) {
const mh = matrix.length;
const mw = matrix[0].length;
// Shift in RGB units: roughly the distance between neighboring palette
// colors. The grey ramp in our palette spans ~50 units per step so 48
// keeps the pattern visible without over-banding.
const SPREAD = 48;
const count = width * height;
const out = new Int16Array(count);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = y * width + x;
if (rgba[i * 4 + 3] < alphaThreshold) { out[i] = -1; continue; }
const bias = (matrix[y % mh][x % mw] - 0.5) * SPREAD;
const r = rgba[i * 4] + bias;
const g = rgba[i * 4 + 1] + bias;
const b = rgba[i * 4 + 2] + bias;
out[i] = nearestColorIndex(
r < 0 ? 0 : r > 255 ? 255 : r,
g < 0 ? 0 : g > 255 ? 255 : g,
b < 0 ? 0 : b > 255 ? 255 : b,
);
}
}
return out;
}
/**