scry/lib/recognition/imageUtils.ts
Chris Kruining 83ab4df537
Migrate from .NET MAUI to Expo + Convex
Complete rewrite of Scry using TypeScript stack:

- Expo/React Native with Expo Router (file-based routing)
- Convex backend (serverless functions + real-time database)
- Adaptive camera system (expo-camera in Expo Go, Vision Camera in production)
- React Native Skia + fast-opencv for image processing
- GDPR-compliant auth setup with Zitadel OIDC (pending configuration)

Key features:
- Card recognition pipeline ported to TypeScript
- Perceptual hashing (192-bit color pHash)
- CLAHE preprocessing for lighting normalization
- Local SQLite cache with Convex sync
- Collection management with offline support

Removes all .NET/MAUI code (src/, test/, tools/).

💘 Generated with Crush

Assisted-by: Claude Opus 4.5 via Crush <crush@charm.land>
2026-02-09 16:16:34 +01:00

292 lines
7.2 KiB
TypeScript

/**
* Image utility functions for the recognition pipeline.
* Provides resize, rotation, and pixel manipulation helpers.
*/
export interface Point {
x: number;
y: number;
}
export interface Size {
width: number;
height: number;
}
/**
* Resize RGBA pixel data to target dimensions using bilinear interpolation.
*/
export function resizeImage(
pixels: Uint8Array | Uint8ClampedArray,
srcWidth: number,
srcHeight: number,
dstWidth: number,
dstHeight: number
): Uint8Array {
const result = new Uint8Array(dstWidth * dstHeight * 4);
const xRatio = srcWidth / dstWidth;
const yRatio = srcHeight / dstHeight;
for (let y = 0; y < dstHeight; y++) {
for (let x = 0; x < dstWidth; x++) {
const srcX = x * xRatio;
const srcY = y * yRatio;
const x0 = Math.floor(srcX);
const y0 = Math.floor(srcY);
const x1 = Math.min(x0 + 1, srcWidth - 1);
const y1 = Math.min(y0 + 1, srcHeight - 1);
const xFrac = srcX - x0;
const yFrac = srcY - y0;
const dstIdx = (y * dstWidth + x) * 4;
for (let c = 0; c < 4; c++) {
const idx00 = (y0 * srcWidth + x0) * 4 + c;
const idx10 = (y0 * srcWidth + x1) * 4 + c;
const idx01 = (y1 * srcWidth + x0) * 4 + c;
const idx11 = (y1 * srcWidth + x1) * 4 + c;
const top = pixels[idx00] + (pixels[idx10] - pixels[idx00]) * xFrac;
const bottom = pixels[idx01] + (pixels[idx11] - pixels[idx01]) * xFrac;
result[dstIdx + c] = Math.round(top + (bottom - top) * yFrac);
}
}
}
return result;
}
/**
* Rotate RGBA pixel data by 90, 180, or 270 degrees.
*/
export function rotateImage(
pixels: Uint8Array | Uint8ClampedArray,
width: number,
height: number,
degrees: 0 | 90 | 180 | 270
): { pixels: Uint8Array; width: number; height: number } {
if (degrees === 0) {
return { pixels: new Uint8Array(pixels), width, height };
}
const [newWidth, newHeight] = degrees === 90 || degrees === 270
? [height, width]
: [width, height];
const result = new Uint8Array(newWidth * newHeight * 4);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const srcIdx = (y * width + x) * 4;
let dstX: number, dstY: number;
switch (degrees) {
case 90:
dstX = height - 1 - y;
dstY = x;
break;
case 180:
dstX = width - 1 - x;
dstY = height - 1 - y;
break;
case 270:
dstX = y;
dstY = width - 1 - x;
break;
default:
dstX = x;
dstY = y;
}
const dstIdx = (dstY * newWidth + dstX) * 4;
result[dstIdx] = pixels[srcIdx];
result[dstIdx + 1] = pixels[srcIdx + 1];
result[dstIdx + 2] = pixels[srcIdx + 2];
result[dstIdx + 3] = pixels[srcIdx + 3];
}
}
return { pixels: result, width: newWidth, height: newHeight };
}
/**
* Convert RGBA to grayscale using luminance formula.
*/
export function toGrayscale(pixels: Uint8Array | Uint8ClampedArray): Uint8Array {
const numPixels = pixels.length / 4;
const gray = new Uint8Array(numPixels);
for (let i = 0; i < numPixels; i++) {
const idx = i * 4;
gray[i] = Math.round(
0.299 * pixels[idx] +
0.587 * pixels[idx + 1] +
0.114 * pixels[idx + 2]
);
}
return gray;
}
/**
* Convert grayscale back to RGBA.
*/
export function grayscaleToRgba(
gray: Uint8Array,
width: number,
height: number
): Uint8Array {
const rgba = new Uint8Array(width * height * 4);
for (let i = 0; i < gray.length; i++) {
const idx = i * 4;
rgba[idx] = gray[i];
rgba[idx + 1] = gray[i];
rgba[idx + 2] = gray[i];
rgba[idx + 3] = 255;
}
return rgba;
}
/**
* Apply Gaussian blur to grayscale image.
* Uses separable 1D convolution for efficiency.
*/
export function gaussianBlur(
gray: Uint8Array,
width: number,
height: number,
radius: number = 5
): Uint8Array {
// Generate Gaussian kernel
const sigma = radius / 2;
const size = radius * 2 + 1;
const kernel = new Float32Array(size);
let sum = 0;
for (let i = 0; i < size; i++) {
const x = i - radius;
kernel[i] = Math.exp(-(x * x) / (2 * sigma * sigma));
sum += kernel[i];
}
// Normalize
for (let i = 0; i < size; i++) {
kernel[i] /= sum;
}
// Horizontal pass
const temp = new Uint8Array(width * height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let value = 0;
for (let k = -radius; k <= radius; k++) {
const sx = Math.max(0, Math.min(width - 1, x + k));
value += gray[y * width + sx] * kernel[k + radius];
}
temp[y * width + x] = Math.round(value);
}
}
// Vertical pass
const result = new Uint8Array(width * height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let value = 0;
for (let k = -radius; k <= radius; k++) {
const sy = Math.max(0, Math.min(height - 1, y + k));
value += temp[sy * width + x] * kernel[k + radius];
}
result[y * width + x] = Math.round(value);
}
}
return result;
}
/**
* Calculate distance between two points.
*/
export function distance(a: Point, b: Point): number {
const dx = b.x - a.x;
const dy = b.y - a.y;
return Math.sqrt(dx * dx + dy * dy);
}
/**
* Calculate cross product of vectors (b-a) and (c-b).
*/
export function crossProduct(a: Point, b: Point, c: Point): number {
return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
}
/**
* Calculate point-to-line distance.
*/
export function pointToLineDistance(point: Point, lineStart: Point, lineEnd: Point): number {
const dx = lineEnd.x - lineStart.x;
const dy = lineEnd.y - lineStart.y;
const lengthSquared = dx * dx + dy * dy;
if (lengthSquared === 0) {
return distance(point, lineStart);
}
let t = ((point.x - lineStart.x) * dx + (point.y - lineStart.y) * dy) / lengthSquared;
t = Math.max(0, Math.min(1, t));
const projection = {
x: lineStart.x + t * dx,
y: lineStart.y + t * dy,
};
return distance(point, projection);
}
/**
* Sample a pixel from RGBA data using bilinear interpolation.
*/
export function sampleBilinear(
pixels: Uint8Array | Uint8ClampedArray,
width: number,
height: number,
x: number,
y: number
): [number, number, number, number] {
// Clamp to valid range
x = Math.max(0, Math.min(width - 1, x));
y = Math.max(0, Math.min(height - 1, y));
const x0 = Math.floor(x);
const y0 = Math.floor(y);
const x1 = Math.min(x0 + 1, width - 1);
const y1 = Math.min(y0 + 1, height - 1);
const xFrac = x - x0;
const yFrac = y - y0;
const idx00 = (y0 * width + x0) * 4;
const idx10 = (y0 * width + x1) * 4;
const idx01 = (y1 * width + x0) * 4;
const idx11 = (y1 * width + x1) * 4;
const result: [number, number, number, number] = [0, 0, 0, 0];
for (let c = 0; c < 4; c++) {
const c00 = pixels[idx00 + c];
const c10 = pixels[idx10 + c];
const c01 = pixels[idx01 + c];
const c11 = pixels[idx11 + c];
const top = c00 + (c10 - c00) * xFrac;
const bottom = c01 + (c11 - c01) * xFrac;
result[c] = Math.round(top + (bottom - top) * yFrac);
}
return result;
}