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

+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}`);