/** * 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; colors: string[]; } // ── Constants ── const MAX_DITHER_DIM = 512; const MAX_CACHE = 200; // ── Cache ── const ditherCache = new Map }>(); const separationCache = new Map(); function cacheKey(...parts: (string | number)[]): string { // Simple hash via string joining (no crypto needed for cache keys) return parts.join("|"); } function evict(cache: Map, 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 = { "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 { 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 { 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(); // 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", ];