rspace-online/modules/rswag/dither.ts

576 lines
15 KiB
TypeScript

/**
* Dithering engine — palette building, dithering algorithms, and color separations.
*
* Ported from Python hitherdither-based service to pure TypeScript with Sharp.
* Supports 11 algorithms (8 error diffusion + 3 ordered).
*/
import sharp from "sharp";
// ── Types ──
export type DitherAlgorithm =
| "floyd-steinberg"
| "atkinson"
| "stucki"
| "burkes"
| "sierra"
| "sierra-two-row"
| "sierra-lite"
| "jarvis-judice-ninke"
| "bayer"
| "ordered"
| "cluster-dot";
export type PaletteMode = "auto" | "grayscale" | "spot" | "custom";
export interface DitherOptions {
algorithm?: DitherAlgorithm;
paletteMode?: PaletteMode;
numColors?: number;
customColors?: string[];
threshold?: number;
order?: number;
}
export interface DitherResult {
buffer: Buffer;
algorithm: DitherAlgorithm;
paletteMode: PaletteMode;
numColors: number;
colorsUsed: string[];
cached: boolean;
}
export interface SeparationResult {
composite: Buffer;
separations: Map<string, Buffer>;
colors: string[];
}
// ── Constants ──
const MAX_DITHER_DIM = 512;
const MAX_CACHE = 200;
// ── Cache ──
const ditherCache = new Map<string, { buffer: Buffer; meta: Omit<DitherResult, "buffer" | "cached"> }>();
const separationCache = new Map<string, SeparationResult>();
function cacheKey(...parts: (string | number)[]): string {
// Simple hash via string joining (no crypto needed for cache keys)
return parts.join("|");
}
function evict<V>(cache: Map<string, V>, maxSize = MAX_CACHE) {
if (cache.size <= maxSize) return;
const iter = cache.keys();
while (cache.size > maxSize) {
const next = iter.next();
if (next.done) break;
cache.delete(next.value);
}
}
// ── Color helpers ──
function hexToRgb(hex: string): [number, number, number] {
const h = hex.replace(/^#/, "");
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
}
function rgbToHex(r: number, g: number, b: number): string {
return `${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`.toUpperCase();
}
function colorDistance(r1: number, g1: number, b1: number, r2: number, g2: number, b2: number): number {
return (r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2;
}
function nearestColor(r: number, g: number, b: number, palette: [number, number, number][]): number {
let minDist = Infinity;
let idx = 0;
for (let i = 0; i < palette.length; i++) {
const d = colorDistance(r, g, b, palette[i][0], palette[i][1], palette[i][2]);
if (d < minDist) {
minDist = d;
idx = i;
}
}
return idx;
}
// ── Palette building ──
/**
* Build a color palette from an image using median-cut quantization,
* grayscale ramp, or custom colors.
*/
export async function buildPalette(
inputBuffer: Buffer,
mode: PaletteMode,
numColors = 8,
customColors?: string[],
): Promise<[number, number, number][]> {
if (mode === "custom" && customColors?.length) {
return customColors.map(hexToRgb);
}
if (mode === "grayscale") {
const step = Math.floor(255 / Math.max(numColors - 1, 1));
return Array.from({ length: numColors }, (_, i) => {
const v = Math.min(i * step, 255);
return [v, v, v] as [number, number, number];
});
}
if (mode === "spot" && customColors?.length) {
return customColors.slice(0, numColors).map(hexToRgb);
}
// Auto mode: extract dominant colors via Sharp + quantization
const { data, info } = await sharp(inputBuffer)
.resize(64, 64, { fit: "cover" })
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
// Simple median-cut quantization
const pixels: [number, number, number][] = [];
for (let i = 0; i < data.length; i += 3) {
pixels.push([data[i], data[i + 1], data[i + 2]]);
}
return medianCut(pixels, numColors);
}
/** Simple median-cut quantization. */
function medianCut(pixels: [number, number, number][], numColors: number): [number, number, number][] {
type Box = [number, number, number][];
const boxes: Box[] = [pixels];
while (boxes.length < numColors) {
// Find the box with the largest range
let maxRange = -1;
let maxIdx = 0;
let splitChannel = 0;
for (let i = 0; i < boxes.length; i++) {
const box = boxes[i];
for (let ch = 0; ch < 3; ch++) {
let min = 255, max = 0;
for (const p of box) {
if (p[ch] < min) min = p[ch];
if (p[ch] > max) max = p[ch];
}
const range = max - min;
if (range > maxRange) {
maxRange = range;
maxIdx = i;
splitChannel = ch;
}
}
}
if (maxRange <= 0) break;
const box = boxes.splice(maxIdx, 1)[0];
box.sort((a, b) => a[splitChannel] - b[splitChannel]);
const mid = Math.floor(box.length / 2);
boxes.push(box.slice(0, mid), box.slice(mid));
}
// Average each box to get palette colors
return boxes.map((box) => {
if (box.length === 0) return [0, 0, 0] as [number, number, number];
let rSum = 0, gSum = 0, bSum = 0;
for (const [r, g, b] of box) {
rSum += r; gSum += g; bSum += b;
}
const n = box.length;
return [Math.round(rSum / n), Math.round(gSum / n), Math.round(bSum / n)] as [number, number, number];
});
}
// ── Error diffusion kernels ──
type DiffusionKernel = { offsets: [number, number, number][] }; // [dx, dy, weight]
const KERNELS: Record<string, DiffusionKernel> = {
"floyd-steinberg": {
offsets: [[1, 0, 7 / 16], [-1, 1, 3 / 16], [0, 1, 5 / 16], [1, 1, 1 / 16]],
},
atkinson: {
offsets: [
[1, 0, 1 / 8], [2, 0, 1 / 8],
[-1, 1, 1 / 8], [0, 1, 1 / 8], [1, 1, 1 / 8],
[0, 2, 1 / 8],
],
},
stucki: {
offsets: [
[1, 0, 8 / 42], [2, 0, 4 / 42],
[-2, 1, 2 / 42], [-1, 1, 4 / 42], [0, 1, 8 / 42], [1, 1, 4 / 42], [2, 1, 2 / 42],
[-2, 2, 1 / 42], [-1, 2, 2 / 42], [0, 2, 4 / 42], [1, 2, 2 / 42], [2, 2, 1 / 42],
],
},
burkes: {
offsets: [
[1, 0, 8 / 32], [2, 0, 4 / 32],
[-2, 1, 2 / 32], [-1, 1, 4 / 32], [0, 1, 8 / 32], [1, 1, 4 / 32], [2, 1, 2 / 32],
],
},
sierra: {
offsets: [
[1, 0, 5 / 32], [2, 0, 3 / 32],
[-2, 1, 2 / 32], [-1, 1, 4 / 32], [0, 1, 5 / 32], [1, 1, 4 / 32], [2, 1, 2 / 32],
[-1, 2, 2 / 32], [0, 2, 3 / 32], [1, 2, 2 / 32],
],
},
"sierra-two-row": {
offsets: [
[1, 0, 4 / 16], [2, 0, 3 / 16],
[-2, 1, 1 / 16], [-1, 1, 2 / 16], [0, 1, 3 / 16], [1, 1, 2 / 16], [2, 1, 1 / 16],
],
},
"sierra-lite": {
offsets: [[1, 0, 2 / 4], [0, 1, 1 / 4], [-1, 1, 1 / 4]],
},
"jarvis-judice-ninke": {
offsets: [
[1, 0, 7 / 48], [2, 0, 5 / 48],
[-2, 1, 3 / 48], [-1, 1, 5 / 48], [0, 1, 7 / 48], [1, 1, 5 / 48], [2, 1, 3 / 48],
[-2, 2, 1 / 48], [-1, 2, 3 / 48], [0, 2, 5 / 48], [1, 2, 3 / 48], [2, 2, 1 / 48],
],
},
};
// ── Dithering algorithms ──
function errorDiffusionDither(
data: Uint8Array,
width: number,
height: number,
palette: [number, number, number][],
kernel: DiffusionKernel,
): Uint8Array {
// Work with float copy to handle error accumulation
const pixels = new Float32Array(data.length);
for (let i = 0; i < data.length; i++) pixels[i] = data[i];
const output = new Uint8Array(data.length);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 3;
const r = Math.max(0, Math.min(255, Math.round(pixels[idx])));
const g = Math.max(0, Math.min(255, Math.round(pixels[idx + 1])));
const b = Math.max(0, Math.min(255, Math.round(pixels[idx + 2])));
const nearest = nearestColor(r, g, b, palette);
const [nr, ng, nb] = palette[nearest];
output[idx] = nr;
output[idx + 1] = ng;
output[idx + 2] = nb;
const errR = r - nr;
const errG = g - ng;
const errB = b - nb;
for (const [dx, dy, weight] of kernel.offsets) {
const nx = x + dx;
const ny = y + dy;
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
const nIdx = (ny * width + nx) * 3;
pixels[nIdx] += errR * weight;
pixels[nIdx + 1] += errG * weight;
pixels[nIdx + 2] += errB * weight;
}
}
}
}
return output;
}
function bayerDither(
data: Uint8Array,
width: number,
height: number,
palette: [number, number, number][],
threshold: number,
order: number,
): Uint8Array {
const matrix = generateBayerMatrix(order);
const matrixSize = matrix.length;
const output = new Uint8Array(data.length);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 3;
const bayerVal = matrix[y % matrixSize][x % matrixSize];
const factor = (bayerVal - 0.5) * threshold;
const r = Math.max(0, Math.min(255, Math.round(data[idx] + factor)));
const g = Math.max(0, Math.min(255, Math.round(data[idx + 1] + factor)));
const b = Math.max(0, Math.min(255, Math.round(data[idx + 2] + factor)));
const nearest = nearestColor(r, g, b, palette);
output[idx] = palette[nearest][0];
output[idx + 1] = palette[nearest][1];
output[idx + 2] = palette[nearest][2];
}
}
return output;
}
function generateBayerMatrix(order: number): number[][] {
if (order <= 1) return [[0]];
const size = 1 << order; // 2^order
const matrix: number[][] = Array.from({ length: size }, () => new Array(size));
// Generate recursively
const base = [[0, 2], [3, 1]];
function fill(mat: number[][], size: number): void {
if (size === 2) {
for (let y = 0; y < 2; y++)
for (let x = 0; x < 2; x++)
mat[y][x] = base[y][x];
return;
}
const half = size / 2;
const sub: number[][] = Array.from({ length: half }, () => new Array(half));
fill(sub, half);
for (let y = 0; y < half; y++) {
for (let x = 0; x < half; x++) {
const val = sub[y][x] * 4;
mat[y][x] = val;
mat[y][x + half] = val + 2;
mat[y + half][x] = val + 3;
mat[y + half][x + half] = val + 1;
}
}
}
fill(matrix, size);
// Normalize to [0, 1]
const total = size * size;
for (let y = 0; y < size; y++)
for (let x = 0; x < size; x++)
matrix[y][x] = (matrix[y][x] + 0.5) / total;
return matrix;
}
function clusterDotDither(
data: Uint8Array,
width: number,
height: number,
palette: [number, number, number][],
threshold: number,
): Uint8Array {
// 4x4 cluster-dot pattern
const cluster = [
[12, 5, 6, 13],
[4, 0, 1, 7],
[11, 3, 2, 8],
[15, 10, 9, 14],
];
const output = new Uint8Array(data.length);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 3;
const cVal = (cluster[y % 4][x % 4] / 16 - 0.5) * threshold;
const r = Math.max(0, Math.min(255, Math.round(data[idx] + cVal)));
const g = Math.max(0, Math.min(255, Math.round(data[idx + 1] + cVal)));
const b = Math.max(0, Math.min(255, Math.round(data[idx + 2] + cVal)));
const nearest = nearestColor(r, g, b, palette);
output[idx] = palette[nearest][0];
output[idx + 1] = palette[nearest][1];
output[idx + 2] = palette[nearest][2];
}
}
return output;
}
// ── Main API ──
/**
* Dither a design image with the specified algorithm and palette.
* Results are cached by slug+params combination.
*/
export async function ditherDesign(
inputBuffer: Buffer,
slug: string,
options: DitherOptions = {},
): Promise<DitherResult> {
const {
algorithm = "floyd-steinberg",
paletteMode = "auto",
numColors = 8,
customColors,
threshold = 64,
order = 3,
} = options;
const key = cacheKey(slug, algorithm, paletteMode, numColors, (customColors || []).join(","), threshold, order);
const cached = ditherCache.get(key);
if (cached) {
return { ...cached.meta, buffer: cached.buffer, cached: true };
}
// Downscale for performance
const resized = await sharp(inputBuffer)
.resize(MAX_DITHER_DIM, MAX_DITHER_DIM, { fit: "inside", withoutEnlargement: true })
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const { data, info } = resized;
const { width, height } = info;
// Build palette
const palette = await buildPalette(inputBuffer, paletteMode, numColors, customColors);
// Apply dithering
let dithered: Uint8Array;
if (algorithm === "bayer" || algorithm === "ordered") {
dithered = bayerDither(new Uint8Array(data), width, height, palette, threshold, order);
} else if (algorithm === "cluster-dot") {
dithered = clusterDotDither(new Uint8Array(data), width, height, palette, threshold);
} else {
const kernel = KERNELS[algorithm] || KERNELS["floyd-steinberg"];
dithered = errorDiffusionDither(new Uint8Array(data), width, height, palette, kernel);
}
// Convert back to PNG
const buffer = await sharp(Buffer.from(dithered), {
raw: { width, height, channels: 3 },
})
.png({ compressionLevel: 9 })
.toBuffer();
const colorsUsed = palette.map(([r, g, b]) => rgbToHex(r, g, b));
const meta = { algorithm, paletteMode, numColors: colorsUsed.length, colorsUsed };
ditherCache.set(key, { buffer, meta });
evict(ditherCache);
return { ...meta, buffer, cached: false };
}
/**
* Generate color separations for screen printing.
* Returns composite dithered image + individual per-color separation PNGs.
*/
export async function generateColorSeparations(
inputBuffer: Buffer,
slug: string,
numColors = 4,
algorithm: DitherAlgorithm = "floyd-steinberg",
spotColors?: string[],
): Promise<SeparationResult> {
const paletteMode: PaletteMode = spotColors?.length ? "spot" : "auto";
const key = cacheKey("sep", slug, numColors, algorithm, (spotColors || []).join(","));
const cached = separationCache.get(key);
if (cached) return cached;
// First dither the image
const ditherResult = await ditherDesign(inputBuffer, `${slug}-sep`, {
algorithm,
paletteMode,
numColors,
customColors: spotColors,
});
const composite = ditherResult.buffer;
// Get raw pixel data from the dithered result
const { data, info } = await sharp(composite)
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const { width, height } = info;
const palette = ditherResult.colorsUsed.map(hexToRgb);
const separations = new Map<string, Buffer>();
// Generate per-color separation images
for (let pi = 0; pi < palette.length; pi++) {
const [pr, pg, pb] = palette[pi];
const hexColor = ditherResult.colorsUsed[pi];
const sepData = new Uint8Array(width * height * 3);
// White background
sepData.fill(255);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 3;
const r = data[idx], g = data[idx + 1], b = data[idx + 2];
// Match with tolerance
if (
Math.abs(r - pr) < 16 &&
Math.abs(g - pg) < 16 &&
Math.abs(b - pb) < 16
) {
sepData[idx] = pr;
sepData[idx + 1] = pg;
sepData[idx + 2] = pb;
}
}
}
const sepBuffer = await sharp(Buffer.from(sepData), {
raw: { width, height, channels: 3 },
})
.png({ compressionLevel: 9 })
.toBuffer();
separations.set(hexColor, sepBuffer);
}
const result: SeparationResult = {
composite,
separations,
colors: ditherResult.colorsUsed,
};
separationCache.set(key, result);
evict(separationCache);
return result;
}
/** List all supported dithering algorithms. */
export const ALGORITHMS: DitherAlgorithm[] = [
"floyd-steinberg",
"atkinson",
"stucki",
"burkes",
"sierra",
"sierra-two-row",
"sierra-lite",
"jarvis-judice-ninke",
"bayer",
"ordered",
"cluster-dot",
];