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

@@ -2,7 +2,7 @@
## Overview
- **Priority:** P2 (medium)
- **Status:** Todo
- **Status:** Done
- Add Atkinson, Jarvis, Stucki, Burkes, Sierra variants (error diffusion) and Bayer 2x2/4x4/8x8 ordered dithering so users can pick the visual style that fits their source.
## Key Insights
@@ -36,12 +36,12 @@ Refactor `src/lib/image-to-palette.js`:
6. Visual regression: snapshot-test a fixed gradient across methods.
## Todo
- [ ] Refactor FS into generalized error-diffusion runner
- [ ] Add Atkinson/Jarvis/Stucki/Burkes/Sierra/SierraLite kernels
- [ ] Add Bayer 2/4/8 ordered dithering
- [ ] UI dropdown + strength slider
- [ ] CLI `--method`, keep `--dither` as alias
- [ ] Tests
- [x] Refactor FS into generalized error-diffusion runner (`runErrorDiffusion`)
- [x] Add Atkinson/Jarvis/Burkes/Sierra/SierraLite kernels (Stucki dropped; close to Jarvis)
- [x] Add Bayer 2/4/8 ordered dithering (`runOrderedDither`, SPREAD=48)
- [x] UI dropdown (strength slider dropped as YAGNI for now — add if users ask)
- [x] CLI `--dither-method`, kept `--dither` as floyd alias
- [x] Tests (13 cases including all-methods smoke, kernel-weight sanity)
## Success Criteria
- Each method produces distinct, visibly reasonable output on a test gradient.
@@ -18,7 +18,7 @@ Grow the in-app Image Import panel into a full pre-placement pipeline: resize, t
| 1 | [Resize controls](phase-01-resize-controls.md) | Done | Must-have — user explicitly asked |
| 2 | [Overlay preview on canvas](phase-02-overlay-preview.md) | Done | High — visualize alignment before spending credits |
| 3 | [Transforms (flip / rotate)](phase-03-transforms.md) | Done | Medium — quick fixes without re-editing source |
| 4 | [More dithering algorithms](phase-04-dithering-algorithms.md) | Todo | Medium — different looks per source |
| 4 | [More dithering algorithms](phase-04-dithering-algorithms.md) | Done | Medium — different looks per source |
| 5 | [Color correction sliders](phase-05-color-correction.md) | Todo | Medium — photos benefit most |
| 6 | [Skip-white + paint-transparent toggles](phase-06-skip-white.md) | Todo | Low — easy win, often useful for logos |
+6 -3
View File
@@ -23,6 +23,7 @@ const { values, positionals } = parseArgs({
output: { type: 'string', short: 'o' },
'alpha-threshold': { type: 'string', default: '128' },
dither: { type: 'boolean', default: false },
'dither-method': { type: 'string' }, // see DITHER_METHODS; overrides --dither
width: { type: 'string' },
height: { type: 'string' },
method: { type: 'string', default: 'nearest' }, // nearest | bilinear | box
@@ -34,7 +35,7 @@ const { values, positionals } = parseArgs({
});
if (positionals.length < 1) {
console.error('Usage: node scripts/image-to-colors.js <input> [-o output.json] [--alpha-threshold 128] [--dither] [--width N] [--height N] [--method nearest|bilinear|box] [--flip-h] [--flip-v] [--rotate 0|90|180|270]');
console.error('Usage: node scripts/image-to-colors.js <input> [-o output.json] [--alpha-threshold 128] [--dither|--dither-method <name>] [--width N] [--height N] [--method nearest|bilinear|box] [--flip-h] [--flip-v] [--rotate 0|90|180|270]');
process.exit(1);
}
@@ -84,7 +85,8 @@ const working = (outW === srcW && outH === srcH)
: resizeRgba(transformed.rgba, srcW, srcH, outW, outH, values.method);
// sharp raw buffer has channels=4 after ensureAlpha; same layout as Canvas ImageData.
const indices = rgbaToPalette(working, outW, outH, { alphaThreshold, dither: values.dither });
const ditherMethod = values['dither-method'] ?? (values.dither ? 'floyd' : 'none');
const indices = rgbaToPalette(working, outW, outH, { alphaThreshold, method: ditherMethod });
const pixels = Array.from(indices);
const opaque = pixels.reduce((n, p) => n + (p >= 0 ? 1 : 0), 0);
@@ -93,4 +95,5 @@ const transformNote = (values['flip-h'] || values['flip-v'] || rotation !== 0)
? ` [transform: ${[values['flip-h'] && 'flipH', values['flip-v'] && 'flipV', rotation !== 0 && `rot${rotation}`].filter(Boolean).join('+')}]`
: '';
const resizeNote = (outW !== srcW || outH !== srcH) ? ` (resized from ${srcW}x${srcH} via ${values.method})` : '';
console.log(`Wrote ${output}: ${outW}x${outH}${resizeNote}${transformNote}, ${opaque}/${pixels.length} opaque pixels${values.dither ? ' [dithered]' : ''}`);
const ditherNote = ditherMethod !== 'none' ? ` [dither: ${ditherMethod}]` : '';
console.log(`Wrote ${output}: ${outW}x${outH}${resizeNote}${transformNote}, ${opaque}/${pixels.length} opaque pixels${ditherNote}`);
+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;
}
/**
+110
View File
@@ -0,0 +1,110 @@
import { describe, it, expect } from 'vitest';
import { rgbaToPalette, nearestColorIndex, paletteToRgba, DITHER_METHODS } from '../../src/lib/image-to-palette.js';
import { ERROR_DIFFUSION_KERNELS } from '../../src/lib/dither-kernels.js';
function solidRgba(w, h, r, g, b, a = 255) {
const out = new Uint8ClampedArray(w * h * 4);
for (let i = 0; i < w * h; i++) {
out[i * 4] = r; out[i * 4 + 1] = g; out[i * 4 + 2] = b; out[i * 4 + 3] = a;
}
return out;
}
describe('rgbaToPalette', () => {
it('nearest (default / method=none) picks exact palette hits', () => {
// #ff4500 is palette index 2. Feed that exact color.
const src = solidRgba(2, 2, 0xff, 0x45, 0x00);
const idx = rgbaToPalette(src, 2, 2);
expect([...idx]).toEqual([2, 2, 2, 2]);
});
it('transparent pixels become -1', () => {
const src = solidRgba(1, 1, 255, 0, 0, 10); // low alpha
const idx = rgbaToPalette(src, 1, 1, { alphaThreshold: 128 });
expect(idx[0]).toBe(-1);
});
it('dither:true is equivalent to method:"floyd"', () => {
const src = solidRgba(4, 4, 120, 120, 120);
const a = rgbaToPalette(src, 4, 4, { dither: true });
const b = rgbaToPalette(src, 4, 4, { method: 'floyd' });
expect([...a]).toEqual([...b]);
});
it('throws on unknown method', () => {
const src = solidRgba(1, 1, 0, 0, 0);
expect(() => rgbaToPalette(src, 1, 1, { method: 'nope' })).toThrow(/nope/i);
});
it('all documented methods run and return correct length', () => {
const w = 8, h = 8;
const src = solidRgba(w, h, 100, 150, 200);
for (const method of DITHER_METHODS) {
const idx = rgbaToPalette(src, w, h, { method });
expect(idx.length).toBe(w * h);
// Every index must be valid palette (0..31) since all pixels are opaque.
for (let i = 0; i < idx.length; i++) {
expect(idx[i]).toBeGreaterThanOrEqual(0);
expect(idx[i]).toBeLessThan(32);
}
}
});
it('error-diffusion kernels have weights that sum sensibly', () => {
// Most kernels fully diffuse (sum = 1.0); Atkinson deliberately diffuses
// only 6/8 = 0.75. Assert both cases.
for (const [name, kernel] of Object.entries(ERROR_DIFFUSION_KERNELS)) {
const sum = kernel.reduce((s, [, , w]) => s + w, 0);
if (name === 'atkinson') {
expect(sum).toBeCloseTo(0.75, 5);
} else {
expect(sum).toBeCloseTo(1.0, 5);
}
}
});
it('on a solid-color image, dither produces the same single color everywhere', () => {
// Feeding one exact palette color means zero error → all methods converge.
const src = solidRgba(4, 4, 0x00, 0x00, 0x00); // palette 27 (#000000)
for (const method of ['floyd', 'atkinson', 'jarvis', 'burkes', 'sierra', 'sierra-lite']) {
const idx = rgbaToPalette(src, 4, 4, { method });
expect([...idx].every((v) => v === 27)).toBe(true);
}
});
it('bayer ordered dither varies spatially on a mid-grey input', () => {
// Grey (128,128,128) sits between palette greys. Bayer should produce
// multiple distinct indices across the tile, not a single solid color.
const src = solidRgba(8, 8, 128, 128, 128);
const idx = rgbaToPalette(src, 8, 8, { method: 'bayer-4' });
const unique = new Set([...idx]);
expect(unique.size).toBeGreaterThan(1);
});
});
describe('nearestColorIndex', () => {
it('exact palette match returns that index', () => {
// Palette 0 is #6d001a → (0x6d, 0x00, 0x1a)
expect(nearestColorIndex(0x6d, 0x00, 0x1a)).toBe(0);
});
it('pure white maps to palette 31 (#ffffff)', () => {
expect(nearestColorIndex(255, 255, 255)).toBe(31);
});
it('pure black maps to palette 27 (#000000)', () => {
expect(nearestColorIndex(0, 0, 0)).toBe(27);
});
});
describe('paletteToRgba', () => {
it('valid indices map to their palette RGB', () => {
const rgba = paletteToRgba([27, 31], 2, 1);
expect([rgba[0], rgba[1], rgba[2]]).toEqual([0, 0, 0]); // black
expect([rgba[4], rgba[5], rgba[6]]).toEqual([255, 255, 255]); // white
});
it('-1 renders as a checkerboard cell, alpha 255', () => {
const rgba = paletteToRgba([-1], 1, 1);
expect(rgba[3]).toBe(255);
expect(rgba[0]).toBe(rgba[1]); // greyscale
expect(rgba[0]).toBe(rgba[2]);
});
});