mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-14 12:17:05 +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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user