mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-13 00:15:03 +00:00
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:
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 |
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user