/** * Image Processing Utilities * * Provides methods for converting images to printer-compatible formats. * Supports multiple dithering algorithms and image preprocessing. */ import { BluetoothPrintError, ErrorCode } from '@/errors/baseError'; /** Entry in an error-diffusion kernel: relative position + fractional weight. */ interface ErrorKernelEntry { readonly dx: number; readonly dy: number; readonly weight: number; } export class ImageProcessing { // Pre-computed Bayer matrices for ordered dithering private static readonly BAYER_MATRIX_2: ReadonlyArray> = [ [0, 2], [3, 1], ]; private static readonly BAYER_MATRIX_4: ReadonlyArray> = [ [0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5], ]; // 8x8 Bayer matrix computed from 4x4 private static readonly BAYER_MATRIX_8: ReadonlyArray> = (() => { const m4 = ImageProcessing.BAYER_MATRIX_4; const m8: number[][] = []; for (let i = 0; i < 8; i++) { m8[i] = []; for (let j = 0; j < 8; j++) { const v = (m4[i >> 1]?.[j >> 1] ?? 0) + (i % 2) * 32 + (j % 2) * 64; m8[i]![j] = v; } } return m8; })(); // Quality presets private static readonly QUALITY_PRESETS = { draft: { contrast: 0.9, brightness: -0.05, algorithm: 'ordered' as const }, normal: { contrast: 1.0, brightness: 0.0, algorithm: 'floyd-steinberg' as const }, high: { contrast: 1.15, brightness: 0.08, algorithm: 'halftone' as const }, }; // ─── Data-driven error-diffusion kernels ─────────────────────────────────── private static readonly ERROR_KERNELS: Record< 'floyd-steinberg' | 'atkinson' | 'sierra' | 'stucki', ReadonlyArray > = { 'floyd-steinberg': [ { dx: 1, dy: 0, weight: 7 / 16 }, { dx: -1, dy: 1, weight: 3 / 16 }, { dx: 0, dy: 1, weight: 5 / 16 }, { dx: 1, dy: 1, weight: 1 / 16 }, ], atkinson: [ { dx: 1, dy: 0, weight: 1 / 8 }, { dx: 2, dy: 0, weight: 1 / 8 }, { dx: -1, dy: 1, weight: 1 / 8 }, { dx: 0, dy: 1, weight: 1 / 8 }, { dx: 1, dy: 1, weight: 1 / 8 }, { dx: 0, dy: 2, weight: 1 / 8 }, ], sierra: [ { dx: 1, dy: 0, weight: 5 / 32 }, { dx: 2, dy: 0, weight: 2 / 32 }, { dx: -2, dy: 1, weight: 2 / 32 }, { dx: -1, dy: 1, weight: 3 / 32 }, { dx: 0, dy: 1, weight: 5 / 32 }, { dx: 1, dy: 1, weight: 2 / 32 }, { dx: -1, dy: 2, weight: 2 / 32 }, { dx: 0, dy: 2, weight: 3 / 32 }, { dx: 1, dy: 2, weight: 2 / 32 }, { dx: 2, dy: 2, weight: 1 / 32 }, ], stucki: [ { dx: 1, dy: 0, weight: 8 / 42 }, { dx: 2, dy: 0, weight: 4 / 42 }, { dx: -2, dy: 1, weight: 2 / 42 }, { dx: -1, dy: 1, weight: 4 / 42 }, { dx: 0, dy: 1, weight: 8 / 42 }, { dx: 1, dy: 1, weight: 4 / 42 }, { dx: 2, dy: 1, weight: 2 / 42 }, { dx: -2, dy: 2, weight: 1 / 42 }, { dx: -1, dy: 2, weight: 2 / 42 }, { dx: 0, dy: 2, weight: 4 / 42 }, { dx: 1, dy: 2, weight: 2 / 42 }, { dx: 2, dy: 2, weight: 1 / 42 }, ], }; // Halftone threshold cache (keyed by "cellSize_dotType") private static readonly halftoneThresholdCache = new Map< string, ReadonlyArray> >(); /** * Convert RGBA data to monochrome bitmap (1 bit per pixel) * suitable for ESC/POS GS v 0 command. * * @example * ```typescript * const bitmap = ImageProcessing.toBitmap(rgbaData, width, height, { * targetWidth: 384, * ditheringAlgorithm: 'ordered', * contrast: 1.2, * brightness: 0.1 * }); * ``` */ static toBitmap( data: Uint8Array, width: number, height: number, options?: { targetWidth?: number; targetHeight?: number; useDithering?: boolean; /** 'floyd-steinberg' | 'atkinson' | 'ordered' | 'halftone' | 'sierra' | 'stucki' (default: 'floyd-steinberg') */ ditheringAlgorithm?: | 'floyd-steinberg' | 'atkinson' | 'ordered' | 'halftone' | 'sierra' | 'stucki'; scalingAlgorithm?: 'nearest' | 'bilinear'; contrast?: number; brightness?: number; threshold?: number; orderedMatrixSize?: 2 | 4 | 8; halftoneDotType?: 'round' | 'diamond' | 'square'; qualityPreset?: 'draft' | 'normal' | 'high'; } ): Uint8Array { if (!data || !(data instanceof Uint8Array) || width <= 0 || height <= 0) { return new Uint8Array(0); } if (data.length !== width * height * 4) { throw new BluetoothPrintError( ErrorCode.INVALID_IMAGE_DATA, `Invalid image data length: expected ${width * height * 4}, got ${data.length}` ); } let opts = options || {}; if (opts.qualityPreset) { const preset = this.QUALITY_PRESETS[opts.qualityPreset]; opts = { ...opts, ...preset }; } const { targetWidth, targetHeight, useDithering = true, ditheringAlgorithm = 'floyd-steinberg', scalingAlgorithm = 'nearest', contrast = 1.0, brightness = 0.0, threshold = 128, orderedMatrixSize = 4, halftoneDotType = 'round', } = opts; let processedData = data; let processedWidth = width; let processedHeight = height; if (targetWidth || targetHeight) { const scaled = this.scaleImage( data, width, height, targetWidth || width, targetHeight || height, { algorithm: scalingAlgorithm } ); processedData = scaled.newData; processedWidth = scaled.newWidth; processedHeight = scaled.newHeight; } const bytesPerLine = Math.ceil(processedWidth / 8); const bitmap = new Uint8Array(bytesPerLine * processedHeight); // Fused grayscale conversion + contrast/brightness adjustment (single pass) const grayscale = this.toGrayscaleAdjusted( processedData, processedWidth, processedHeight, contrast, brightness ); if (useDithering) { this.applyDithering(grayscale, processedWidth, processedHeight, bitmap, bytesPerLine, { algorithm: ditheringAlgorithm, threshold, orderedMatrixSize, halftoneDotType, }); } else { this.applyThresholdDithering( grayscale, processedWidth, processedHeight, bitmap, bytesPerLine, () => threshold ); } return bitmap; } /** * Image preprocessing pipeline * @example * ```typescript * const processed = ImageProcessing.preprocessImage(data, w, h, { * denoise: true, * sharpen: true, * gamma: 1.2, * posterize: 4 * }); * ``` */ static preprocessImage( data: Uint8Array, width: number, height: number, options?: { denoise?: boolean; sharpen?: boolean; gamma?: number; posterize?: number; } ): Uint8Array { if (!data || !(data instanceof Uint8Array)) return data; let result = data; if (options?.gamma && options.gamma !== 1.0) { result = this.applyGammaCorrection(result, options.gamma); } if (options?.denoise) { result = this.applyMedianFilter(result, width, height); } if (options?.sharpen) { result = this.applyUnsharpMask(result, width, height); } if (options?.posterize) { result = this.applyPosterization(result, options.posterize); } return result; } // ─── Private helpers ──────────────────────────────────────────────────────── /** * Fused RGBA→grayscale + contrast/brightness adjustment in a single pass. * Eliminates the extra Float32Array allocation from the separate methods. */ private static toGrayscaleAdjusted( data: Uint8Array, width: number, height: number, contrast: number, brightness: number ): Float32Array { const len = width * height; const grayscale = new Float32Array(len); const needAdjust = contrast !== 1.0 || brightness !== 0.0; const bAdj = brightness * 255; for (let i = 0; i < len; i++) { const ri = i << 2; const r = data[ri]!; const g = data[ri + 1]!; const b = data[ri + 2]!; let val = (r * 299 + g * 587 + b * 114) / 1000; if (needAdjust) { val = (val - 128) * contrast + 128 + bAdj; grayscale[i] = val < 0 ? 0 : val > 255 ? 255 : val; } else { grayscale[i] = val; } } return grayscale; } /** Dispatch to the appropriate dithering algorithm. */ private static applyDithering( grayscale: Float32Array, width: number, height: number, bitmap: Uint8Array, bytesPerLine: number, opts: { algorithm: 'floyd-steinberg' | 'atkinson' | 'ordered' | 'halftone' | 'sierra' | 'stucki'; threshold: number; orderedMatrixSize: 2 | 4 | 8; halftoneDotType: 'round' | 'diamond' | 'square'; } ): void { switch (opts.algorithm) { case 'ordered': { const matrix = opts.orderedMatrixSize === 2 ? this.BAYER_MATRIX_2 : opts.orderedMatrixSize === 8 ? this.BAYER_MATRIX_8 : this.BAYER_MATRIX_4; const matrixMax = opts.orderedMatrixSize === 4 ? 16 : opts.orderedMatrixSize === 8 ? 64 : 4; const th = opts.threshold; const ms = opts.orderedMatrixSize; this.applyThresholdDithering( grayscale, width, height, bitmap, bytesPerLine, (x, y) => th + ((matrix[y % ms]?.[x % ms] ?? 0) / matrixMax) * 48 ); break; } case 'halftone': { const cellSize = 4; const thresholds = this.getHalftoneThresholds(cellSize, opts.halftoneDotType); const th = opts.threshold; this.applyThresholdDithering( grayscale, width, height, bitmap, bytesPerLine, (x, y, pixel) => { const t = thresholds[y % cellSize]?.[x % cellSize] ?? 128; return t + (pixel < th ? -30 : 30); } ); break; } case 'floyd-steinberg': case 'atkinson': case 'sierra': case 'stucki': { const kernel = this.ERROR_KERNELS[opts.algorithm]; this.applyErrorDiffusionDithering( grayscale, width, height, bitmap, bytesPerLine, opts.threshold, kernel ); break; } default: { // Fallback to Floyd-Steinberg this.applyErrorDiffusionDithering( grayscale, width, height, bitmap, bytesPerLine, opts.threshold, this.ERROR_KERNELS['floyd-steinberg'] ); } } } // ─── Generic error-diffusion dithering ───────────────────────────────────── /** * Data-driven error-diffusion dithering. Replaces the four independent * methods (Floyd-Steinberg / Atkinson / Sierra / Stucki) with a single * loop that reads from a kernel table. * * Error distribution is inlined to eliminate per-pixel function-call overhead. * Bit operations (>> 3, & 7, 0x80 >>) replace Math.floor and %. */ private static applyErrorDiffusionDithering( grayscale: Float32Array, width: number, height: number, bitmap: Uint8Array, bytesPerLine: number, threshold: number, kernel: ReadonlyArray ): void { // Pre-compute kernel length for loop optimization const kLen = kernel.length; // Process each row for (let y = 0; y < height; y++) { this.processErrorDiffusionRow( grayscale, width, height, bitmap, bytesPerLine, y, threshold, kernel, kLen ); } } /** * Process a single row of error-diffusion dithering. * Extracted to reduce cognitive complexity of the main function. */ private static processErrorDiffusionRow( grayscale: Float32Array, width: number, height: number, bitmap: Uint8Array, bytesPerLine: number, y: number, threshold: number, kernel: ReadonlyArray, kLen: number ): void { const yBaseIdx = y * width; const byteRowOffset = y * bytesPerLine; for (let x = 0; x < width; x++) { const idx = yBaseIdx + x; const oldPixel = grayscale[idx]!; // Threshold decision const newPixel = oldPixel < threshold ? 0 : 255; // Write to bitmap if black pixel if (newPixel === 0) { this.writeBlackPixel(bitmap, byteRowOffset, x); } // Calculate quantization error const err = oldPixel - newPixel; // Distribute error to neighboring pixels if (err !== 0) { this.distributeError(grayscale, width, height, x, y, err, kernel, kLen); } } } /** * Write a black pixel to the bitmap at the given position. * Uses bit operations for efficiency. */ private static writeBlackPixel(bitmap: Uint8Array, byteRowOffset: number, x: number): void { const byteIdx = byteRowOffset + (x >> 3); const bitMask = 0x80 >> (x & 7); bitmap[byteIdx] = (bitmap[byteIdx] ?? 0) | bitMask; } /** * Distribute quantization error to neighboring pixels according to the kernel. * Clamps values to [0, 255] range. */ private static distributeError( grayscale: Float32Array, width: number, height: number, x: number, y: number, err: number, kernel: ReadonlyArray, kLen: number ): void { for (let k = 0; k < kLen; k++) { const e = kernel[k]!; const nx = x + e.dx; const ny = y + e.dy; // Boundary check if (nx >= 0 && nx < width && ny >= 0 && ny < height) { const nIdx = ny * width + nx; const newValue = grayscale[nIdx]! + err * e.weight; // Clamp to valid range using ternary (faster than Math.min/max) grayscale[nIdx] = newValue < 0 ? 0 : newValue > 255 ? 255 : newValue; } } } // ─── Generic threshold-based dithering ───────────────────────────────────── /** * Unified threshold dithering for ordered / halftone / simple threshold. * Bit operations replace Math.floor and %. */ private static applyThresholdDithering( grayscale: Float32Array, width: number, height: number, bitmap: Uint8Array, bytesPerLine: number, getThreshold: (x: number, y: number, pixel: number) => number ): void { for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const idx = y * width + x; const pixel = grayscale[idx]!; if (pixel < getThreshold(x, y, pixel)) { const byteIdx = y * bytesPerLine + (x >> 3); bitmap[byteIdx] = (bitmap[byteIdx] ?? 0) | (0x80 >> (x & 7)); } } } } // ─── Halftone threshold cache ────────────────────────────────────────────── private static getHalftoneThresholds( cellSize: number, dotType: 'round' | 'diamond' | 'square' ): ReadonlyArray> { const key = `${cellSize}_${dotType}`; let cached = this.halftoneThresholdCache.get(key); if (!cached) { cached = this.computeHalftoneThresholds(cellSize, dotType); this.halftoneThresholdCache.set(key, cached); } return cached; } private static computeHalftoneThresholds( cellSize: number, dotType: 'round' | 'diamond' | 'square' ): number[][] { const thresholds: number[][] = []; const center = (cellSize - 1) / 2; for (let y = 0; y < cellSize; y++) { thresholds[y] = []; for (let x = 0; x < cellSize; x++) { let dist: number; if (dotType === 'round') { dist = Math.sqrt((x - center) ** 2 + (y - center) ** 2) / center; } else if (dotType === 'diamond') { dist = (Math.abs(x - center) + Math.abs(y - center)) / center; } else { dist = Math.max(Math.abs(x - center), Math.abs(y - center)) / center; } thresholds[y]![x] = Math.min(255, Math.round(dist * 224 + 32)); } } return thresholds; } // ─── Scaling ──────────────────────────────────────────────────────────────── private static scaleImage( data: Uint8Array, width: number, height: number, targetWidth: number, targetHeight: number, options?: { algorithm?: 'nearest' | 'bilinear' } ): { newData: Uint8Array; newWidth: number; newHeight: number } { const aspectRatio = width / height; let newWidth = targetWidth; let newHeight = targetHeight; if (newWidth / newHeight > aspectRatio) { newWidth = Math.round(newHeight * aspectRatio); } else { newHeight = Math.round(newWidth / aspectRatio); } const newData = new Uint8Array(newWidth * newHeight * 4); if (options?.algorithm === 'bilinear') { this.applyBilinearInterpolation(data, width, height, newData, newWidth, newHeight); } else { this.applyNearestNeighbor(data, width, height, newData, newWidth, newHeight); } return { newData, newWidth, newHeight }; } private static applyNearestNeighbor( srcData: Uint8Array, srcWidth: number, srcHeight: number, destData: Uint8Array, destWidth: number, destHeight: number ): void { this.resampleImage( srcWidth, srcHeight, destWidth, destHeight, (_x, _y, srcX, srcY, destIdx) => { const srcIdx = (srcY * srcWidth + srcX) * 4; destData[destIdx] = srcData[srcIdx]!; destData[destIdx + 1] = srcData[srcIdx + 1]!; destData[destIdx + 2] = srcData[srcIdx + 2]!; destData[destIdx + 3] = srcData[srcIdx + 3]!; } ); } private static applyBilinearInterpolation( srcData: Uint8Array, srcWidth: number, srcHeight: number, destData: Uint8Array, destWidth: number, destHeight: number ): void { this.resampleImage( srcWidth, srcHeight, destWidth, destHeight, (x, y, _srcX, _srcY, destIdx) => { const fx = Math.min(srcWidth - 1, x * (srcWidth / destWidth)); const fy = Math.min(srcHeight - 1, y * (srcHeight / destHeight)); const x1 = Math.floor(fx); const y1 = Math.floor(fy); const x2 = Math.min(x1 + 1, srcWidth - 1); const y2 = Math.min(y1 + 1, srcHeight - 1); const fx2 = fx - x1; const fy2 = fy - y1; const w1 = (1 - fx2) * (1 - fy2); const w2 = fx2 * (1 - fy2); const w3 = (1 - fx2) * fy2; const w4 = fx2 * fy2; for (let c = 0; c < 4; c++) { const ii1 = (y1 * srcWidth + x1) * 4 + c; const ii2 = (y1 * srcWidth + x2) * 4 + c; const ii3 = (y2 * srcWidth + x1) * 4 + c; const ii4 = (y2 * srcWidth + x2) * 4 + c; const v = srcData[ii1]! * w1 + srcData[ii2]! * w2 + srcData[ii3]! * w3 + srcData[ii4]! * w4; destData[destIdx + c] = Math.round(v); } } ); } private static resampleImage( srcWidth: number, srcHeight: number, destWidth: number, destHeight: number, samplePixel: (x: number, y: number, srcX: number, srcY: number, destIdx: number) => void ): void { const scaleX = srcWidth / destWidth; const scaleY = srcHeight / destHeight; for (let y = 0; y < destHeight; y++) { for (let x = 0; x < destWidth; x++) { const srcX = Math.min(srcWidth - 1, Math.round(x * scaleX)); const srcY = Math.min(srcHeight - 1, Math.round(y * scaleY)); const destIdx = (y * destWidth + x) * 4; samplePixel(x, y, srcX, srcY, destIdx); } } } // ─── Preprocessing ───────────────────────────────────────────────────────── private static applyGammaCorrection(data: Uint8Array, gamma: number): Uint8Array { const invGamma = 1.0 / gamma; const lut = new Uint8Array(256); for (let i = 0; i < 256; i++) { lut[i] = Math.round(Math.pow(i / 255, invGamma) * 255); } const result = new Uint8Array(data.length); for (let i = 0; i < data.length; i += 4) { // Safe: lut has 256 entries, data[i] is 0-255 const rIdx = data[i]!; const gIdx = data[i + 1]!; const bIdx = data[i + 2]!; result[i] = lut[rIdx]!; result[i + 1] = lut[gIdx]!; result[i + 2] = lut[bIdx]!; result[i + 3] = data[i + 3]!; } return result; } private static processPixels( data: Uint8Array, width: number, height: number, processor: (result: Uint8Array, x: number, y: number, destIdx: number) => void ): Uint8Array { if (data.length < 4 || width <= 0 || height <= 0) return new Uint8Array(0); const result = new Uint8Array(data.length); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { processor(result, x, y, (y * width + x) * 4); } } return result; } private static applyMedianFilter(data: Uint8Array, width: number, height: number): Uint8Array { return this.processPixels(data, width, height, (result, x, y, di) => { const window = this.collectNeighborhoodWindow(data, width, height, x, y); window.sort((a, b) => a - b); for (let c = 0; c < 4; c++) { result[di + c] = window[18 + c * 9] ?? 128; } }); } /** * Collect pixel values from a 3x3 neighborhood around the given position. * Handles boundary conditions by clamping coordinates. * Returns an array of 36 values (9 pixels × 4 channels). */ private static collectNeighborhoodWindow( data: Uint8Array, width: number, height: number, x: number, y: number ): number[] { const window: number[] = []; const halfWindow = 1; for (let dy = -halfWindow; dy <= halfWindow; dy++) { for (let dx = -halfWindow; dx <= halfWindow; dx++) { // Clamp coordinates to image bounds const nx = this.clampCoordinate(x + dx, width); const ny = this.clampCoordinate(y + dy, height); // Read pixel data const si = (ny * width + nx) * 4; for (let c = 0; c < 4; c++) { window.push(data[si + c]!); } } } return window; } /** * Clamp a coordinate to valid image bounds [0, max - 1]. */ private static clampCoordinate(value: number, max: number): number { return value < 0 ? 0 : value >= max ? max - 1 : value; } private static applyUnsharpMask(data: Uint8Array, width: number, height: number): Uint8Array { const kernel = this.getSharpeningKernel(); return this.processPixels(data, width, height, (result, x, y, di) => { for (let c = 0; c < 4; c++) { const convolvedValue = this.convolveChannel(data, width, height, x, y, c, kernel); result[di + c] = this.clampToByte(convolvedValue); } }); } /** * Get the pre-defined sharpening kernel. * Center weight = 3, edge weights = -0.5, corner weights = -1.0 */ private static getSharpeningKernel(): number[][] { return [ [-0.5, -1.0, -0.5], [-1.0, 3.0, -1.0], [-0.5, -1.0, -0.5], ]; } /** * Apply convolution for a single channel at the given position. * Handles boundary conditions by clamping coordinates. */ private static convolveChannel( data: Uint8Array, width: number, height: number, x: number, y: number, channel: number, kernel: number[][] ): number { let sum = 0; const kernelSize = 3; const halfKernel = 1; for (let ky = 0; ky < kernelSize; ky++) { for (let kx = 0; kx < kernelSize; kx++) { // Calculate neighbor coordinates with boundary clamping const nx = this.clampCoordinate(x + kx - halfKernel, width); const ny = this.clampCoordinate(y + ky - halfKernel, height); // Read and weight the pixel value const si = (ny * width + nx) * 4 + channel; const kernelWeight = kernel[ky]?.[kx] ?? 0; sum += data[si]! * kernelWeight; } } return sum; } /** * Clamp a numeric value to valid byte range [0, 255]. */ private static clampToByte(value: number): number { return Math.max(0, Math.min(255, Math.round(value))); } private static applyPosterization(data: Uint8Array, levels: number): Uint8Array { if (data.length < 4) return new Uint8Array(0); const lv = Math.max(1, Math.min(8, levels)); const step = 255 / (Math.pow(2, lv) - 1); const result = new Uint8Array(data.length); for (let i = 0; i < data.length; i += 4) { result[i] = Math.round(Math.round(data[i]! / step) * step); result[i + 1] = Math.round(Math.round(data[i + 1]! / step) * step); result[i + 2] = Math.round(Math.round(data[i + 2]! / step) * step); result[i + 3] = data[i + 3]!; } return result; } }