From 4a5b6ef903ee44d4802bf9a4eb29ce4ac9259edf Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 17 Apr 2026 11:53:52 +0700 Subject: [PATCH] =?UTF-8?q?feat(importer):=20phase=204=20=E2=80=94=20more?= =?UTF-8?q?=20dithering=20algorithms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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. --- .../phase-04-dithering-algorithms.md | 14 +-- .../plan.md | 2 +- scripts/image-to-colors.js | 9 +- src/client/components/ImageImporter.svelte | 19 +-- src/lib/dither-kernels.js | 102 ++++++++++++++++ src/lib/image-to-palette.js | 81 +++++++++---- test/lib/image-to-palette.test.js | 110 ++++++++++++++++++ 7 files changed, 298 insertions(+), 39 deletions(-) create mode 100644 src/lib/dither-kernels.js create mode 100644 test/lib/image-to-palette.test.js diff --git a/plans/260417-1125-image-importer-enhancements/phase-04-dithering-algorithms.md b/plans/260417-1125-image-importer-enhancements/phase-04-dithering-algorithms.md index 67f372c..a6f446d 100644 --- a/plans/260417-1125-image-importer-enhancements/phase-04-dithering-algorithms.md +++ b/plans/260417-1125-image-importer-enhancements/phase-04-dithering-algorithms.md @@ -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. diff --git a/plans/260417-1125-image-importer-enhancements/plan.md b/plans/260417-1125-image-importer-enhancements/plan.md index 5fdc602..6ea4612 100644 --- a/plans/260417-1125-image-importer-enhancements/plan.md +++ b/plans/260417-1125-image-importer-enhancements/plan.md @@ -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 | diff --git a/scripts/image-to-colors.js b/scripts/image-to-colors.js index f208506..a23a9ed 100644 --- a/scripts/image-to-colors.js +++ b/scripts/image-to-colors.js @@ -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 [-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 [-o output.json] [--alpha-threshold 128] [--dither|--dither-method ] [--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}`); diff --git a/src/client/components/ImageImporter.svelte b/src/client/components/ImageImporter.svelte index aa770f1..7a0e457 100644 --- a/src/client/components/ImageImporter.svelte +++ b/src/client/components/ImageImporter.svelte @@ -1,6 +1,6 @@