576 lines
15 KiB
TypeScript
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",
|
|
];
|