mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-11 12:28:59 +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
@@ -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