From a4c3185a21a760e39e25fca84803b17325a413ef Mon Sep 17 00:00:00 2001 From: patrick Date: Wed, 13 May 2026 18:09:20 -0400 Subject: [PATCH] feat: add photon WASM utilities - Image loading and canvas conversion - Crop to aspect ratio - Resize with Lanczos3 sampling - Greyscale conversion - Brightness, contrast, gamma adjustments - Dithering algorithms (Floyd-Steinberg, ordered, Atkinson) - Quantization and auto-levels Co-Authored-By: Claude --- src/lib/processing/photon.ts | 337 +++++++++++++++++++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 src/lib/processing/photon.ts diff --git a/src/lib/processing/photon.ts b/src/lib/processing/photon.ts new file mode 100644 index 0000000..cb17c58 --- /dev/null +++ b/src/lib/processing/photon.ts @@ -0,0 +1,337 @@ +// Photon WASM wrapper for image processing (main thread fallback) + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type PhotonModule = any; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type PhotonImage = any; + +let photon: PhotonModule | null = null; + +export async function initPhoton(): Promise { + if (photon) return photon; + const module = await import('@silvia-odwyer/photon'); + await module.default(); + photon = module; + return photon; +} + +function getPhoton(): PhotonModule { + if (!photon) throw new Error('Photon not initialized. Call initPhoton() first.'); + return photon; +} + +export async function loadImageFromFile(file: File | Blob): Promise { + const p = getPhoton(); + const bitmap = await createImageBitmap(file); + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Could not get canvas context'); + + ctx.drawImage(bitmap, 0, 0); + bitmap.close(); + + return p.open_image(canvas, ctx); +} + +export function cropToAspectRatio(img: PhotonImage, targetAspect: number): PhotonImage { + const p = getPhoton(); + const width = img.get_width(); + const height = img.get_height(); + + if (width <= 0 || height <= 0 || targetAspect <= 0) return img; + + const currentAspect = width / height; + let cropX1: number, cropY1: number, cropX2: number, cropY2: number; + + if (currentAspect > targetAspect) { + const newWidth = Math.max(1, Math.floor(height * targetAspect)); + const xOffset = Math.floor((width - newWidth) / 2); + cropX1 = Math.max(0, xOffset); + cropY1 = 0; + cropX2 = Math.min(width, cropX1 + newWidth); + cropY2 = height; + } else { + const newHeight = Math.max(1, Math.floor(width / targetAspect)); + const yOffset = Math.floor((height - newHeight) / 2); + cropX1 = 0; + cropY1 = Math.max(0, yOffset); + cropX2 = width; + cropY2 = Math.min(height, cropY1 + newHeight); + } + + if (cropX2 <= cropX1 || cropY2 <= cropY1) return img; + + return p.crop(img, cropX1, cropY1, cropX2, cropY2); +} + +export function resizeImage( + img: PhotonImage, + targetWidth: number, + targetHeight: number +): PhotonImage { + const p = getPhoton(); + return p.resize(img, targetWidth, targetHeight, p.SamplingFilter.Lanczos3); +} + +export function toGreyscale(img: PhotonImage): void { + const p = getPhoton(); + p.grayscale(img); +} + +export function imageToCanvas(img: PhotonImage): HTMLCanvasElement { + const p = getPhoton(); + const width = img.get_width(); + const height = img.get_height(); + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext('2d'); + if (!ctx) return canvas; + + p.putImageData(canvas, ctx, img); + return canvas; +} + +export function quantizeCanvas(canvas: HTMLCanvasElement, levels: number): void { + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const data = imageData.data; + const step = 255 / (levels - 1); + + for (let i = 0; i < data.length; i += 4) { + const grey = data[i]; + const quantized = Math.round(grey / step) * step; + data[i] = quantized; + data[i + 1] = quantized; + data[i + 2] = quantized; + } + + ctx.putImageData(imageData, 0, 0); +} + +export function quantizeToCanvas(img: PhotonImage, levels: number): HTMLCanvasElement { + const canvas = imageToCanvas(img); + quantizeCanvas(canvas, levels); + return canvas; +} + +export function canvasToBlob( + canvas: HTMLCanvasElement, + format: 'png' | 'jpeg' = 'png', + quality = 0.92 +): Promise { + return new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => { + if (blob) resolve(blob); + else reject(new Error('Failed to create blob')); + }, + format === 'jpeg' ? 'image/jpeg' : 'image/png', + quality + ); + }); +} + +export function canvasToDataUrl( + canvas: HTMLCanvasElement, + format: 'png' | 'jpeg' = 'png', + quality = 0.92 +): string { + return canvas.toDataURL(format === 'jpeg' ? 'image/jpeg' : 'image/png', quality); +} + +export function adjustBrightness(img: PhotonImage, value: number): void { + const p = getPhoton(); + p.adjust_brightness(img, Math.round(value)); +} + +export function adjustContrast(img: PhotonImage, value: number): void { + const p = getPhoton(); + p.adjust_contrast(img, value); +} + +export function applyGamma(img: PhotonImage, gamma: number): void { + const p = getPhoton(); + p.gamma_correction(img, gamma); +} + +export function sharpenImage(img: PhotonImage, amount: number): void { + const p = getPhoton(); + if (amount > 0) p.sharpen(img); +} + +export function autoLevels(canvas: HTMLCanvasElement, clipPercent: number = 0.5): void { + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const data = imageData.data; + + const histogram = new Array(256).fill(0); + for (let i = 0; i < data.length; i += 4) { + histogram[data[i]]++; + } + + const totalPixels = canvas.width * canvas.height; + const clipPixels = Math.floor(totalPixels * (clipPercent / 100)); + + let minLevel = 0, + count = 0; + for (let i = 0; i < 256; i++) { + count += histogram[i]; + if (count > clipPixels) { + minLevel = i; + break; + } + } + + let maxLevel = 255; + count = 0; + for (let i = 255; i >= 0; i--) { + count += histogram[i]; + if (count > clipPixels) { + maxLevel = i; + break; + } + } + + if (maxLevel <= minLevel) maxLevel = minLevel + 1; + const range = maxLevel - minLevel; + + for (let i = 0; i < data.length; i += 4) { + const grey = data[i]; + const stretched = Math.round(((grey - minLevel) / range) * 255); + const clamped = Math.max(0, Math.min(255, stretched)); + data[i] = clamped; + data[i + 1] = clamped; + data[i + 2] = clamped; + } + + ctx.putImageData(imageData, 0, 0); +} + +export function applyFloydSteinbergDither(canvas: HTMLCanvasElement, levels: number): void { + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const data = imageData.data; + const width = canvas.width; + const height = canvas.height; + const step = 255 / (levels - 1); + + const getGrey = (x: number, y: number) => data[(y * width + x) * 4]; + const setGrey = (x: number, y: number, val: number) => { + const idx = (y * width + x) * 4; + const clamped = Math.max(0, Math.min(255, val)); + data[idx] = clamped; + data[idx + 1] = clamped; + data[idx + 2] = clamped; + }; + const addError = (x: number, y: number, err: number, factor: number) => { + if (x < 0 || x >= width || y < 0 || y >= height) return; + const idx = (y * width + x) * 4; + data[idx] = Math.max(0, Math.min(255, data[idx] + err * factor)); + data[idx + 1] = data[idx]; + data[idx + 2] = data[idx]; + }; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const oldVal = getGrey(x, y); + const newVal = Math.round(oldVal / step) * step; + const error = oldVal - newVal; + + setGrey(x, y, newVal); + addError(x + 1, y, error, 7 / 16); + addError(x - 1, y + 1, error, 3 / 16); + addError(x, y + 1, error, 5 / 16); + addError(x + 1, y + 1, error, 1 / 16); + } + } + + ctx.putImageData(imageData, 0, 0); +} + +export function applyOrderedDither(canvas: HTMLCanvasElement, levels: number): void { + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const data = imageData.data; + const width = canvas.width; + + const bayer = [ + [0, 8, 2, 10], + [12, 4, 14, 6], + [3, 11, 1, 9], + [15, 7, 13, 5] + ]; + + const step = 255 / (levels - 1); + + for (let i = 0; i < data.length; i += 4) { + const pixelIdx = i / 4; + const x = pixelIdx % width; + const y = Math.floor(pixelIdx / width); + + const threshold = (bayer[y % 4][x % 4] / 16 - 0.5) * step; + const grey = data[i] + threshold; + const quantized = Math.round(grey / step) * step; + const clamped = Math.max(0, Math.min(255, quantized)); + + data[i] = clamped; + data[i + 1] = clamped; + data[i + 2] = clamped; + } + + ctx.putImageData(imageData, 0, 0); +} + +export function applyAtkinsonDither(canvas: HTMLCanvasElement, levels: number): void { + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const data = imageData.data; + const width = canvas.width; + const height = canvas.height; + const step = 255 / (levels - 1); + + const addError = (x: number, y: number, err: number) => { + if (x < 0 || x >= width || y < 0 || y >= height) return; + const idx = (y * width + x) * 4; + data[idx] = Math.max(0, Math.min(255, data[idx] + err)); + data[idx + 1] = data[idx]; + data[idx + 2] = data[idx]; + }; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 4; + const oldVal = data[idx]; + const newVal = Math.round(oldVal / step) * step; + const error = (oldVal - newVal) / 8; + + data[idx] = newVal; + data[idx + 1] = newVal; + data[idx + 2] = newVal; + + addError(x + 1, y, error); + addError(x + 2, y, error); + addError(x - 1, y + 1, error); + addError(x, y + 1, error); + addError(x + 1, y + 1, error); + addError(x, y + 2, error); + } + } + + ctx.putImageData(imageData, 0, 0); +}