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 <noreply@anthropic.com>
This commit is contained in:
337
src/lib/processing/photon.ts
Normal file
337
src/lib/processing/photon.ts
Normal file
@@ -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<PhotonModule> {
|
||||
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<PhotonImage> {
|
||||
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<Blob> {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user