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>
This commit is contained in:
parent
56499d5af9
commit
83ab4df537
138 changed files with 19136 additions and 7681 deletions
292
lib/recognition/imageUtils.ts
Normal file
292
lib/recognition/imageUtils.ts
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue