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>
278 lines
7.8 KiB
TypeScript
278 lines
7.8 KiB
TypeScript
/**
|
|
* CLAHE (Contrast Limited Adaptive Histogram Equalization) implementation.
|
|
*
|
|
* This is a pure TypeScript implementation for when OpenCV is not available.
|
|
* For better performance, use OpenCV's createCLAHE when available.
|
|
*/
|
|
|
|
interface CLAHEOptions {
|
|
clipLimit?: number;
|
|
tileGridSize?: { width: number; height: number };
|
|
}
|
|
|
|
const DEFAULT_OPTIONS: Required<CLAHEOptions> = {
|
|
clipLimit: 2.0,
|
|
tileGridSize: { width: 8, height: 8 },
|
|
};
|
|
|
|
/**
|
|
* Convert RGB to LAB color space.
|
|
* Returns L channel (0-100 range, scaled to 0-255 for processing).
|
|
*/
|
|
function rgbToLab(r: number, g: number, b: number): { l: number; a: number; b: number } {
|
|
// Normalize RGB to 0-1
|
|
let rNorm = r / 255;
|
|
let gNorm = g / 255;
|
|
let bNorm = b / 255;
|
|
|
|
// Apply gamma correction
|
|
rNorm = rNorm > 0.04045 ? Math.pow((rNorm + 0.055) / 1.055, 2.4) : rNorm / 12.92;
|
|
gNorm = gNorm > 0.04045 ? Math.pow((gNorm + 0.055) / 1.055, 2.4) : gNorm / 12.92;
|
|
bNorm = bNorm > 0.04045 ? Math.pow((bNorm + 0.055) / 1.055, 2.4) : bNorm / 12.92;
|
|
|
|
// Convert to XYZ
|
|
const x = (rNorm * 0.4124564 + gNorm * 0.3575761 + bNorm * 0.1804375) / 0.95047;
|
|
const y = rNorm * 0.2126729 + gNorm * 0.7151522 + bNorm * 0.0721750;
|
|
const z = (rNorm * 0.0193339 + gNorm * 0.1191920 + bNorm * 0.9503041) / 1.08883;
|
|
|
|
// Convert to LAB
|
|
const xNorm = x > 0.008856 ? Math.pow(x, 1 / 3) : 7.787 * x + 16 / 116;
|
|
const yNorm = y > 0.008856 ? Math.pow(y, 1 / 3) : 7.787 * y + 16 / 116;
|
|
const zNorm = z > 0.008856 ? Math.pow(z, 1 / 3) : 7.787 * z + 16 / 116;
|
|
|
|
return {
|
|
l: Math.max(0, 116 * yNorm - 16), // 0-100
|
|
a: 500 * (xNorm - yNorm),
|
|
b: 200 * (yNorm - zNorm),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Convert LAB to RGB color space.
|
|
*/
|
|
function labToRgb(l: number, a: number, b: number): { r: number; g: number; b: number } {
|
|
// Convert LAB to XYZ
|
|
const yNorm = (l + 16) / 116;
|
|
const xNorm = a / 500 + yNorm;
|
|
const zNorm = yNorm - b / 200;
|
|
|
|
const x3 = Math.pow(xNorm, 3);
|
|
const y3 = Math.pow(yNorm, 3);
|
|
const z3 = Math.pow(zNorm, 3);
|
|
|
|
const x = (x3 > 0.008856 ? x3 : (xNorm - 16 / 116) / 7.787) * 0.95047;
|
|
const y = y3 > 0.008856 ? y3 : (yNorm - 16 / 116) / 7.787;
|
|
const z = (z3 > 0.008856 ? z3 : (zNorm - 16 / 116) / 7.787) * 1.08883;
|
|
|
|
// Convert XYZ to RGB
|
|
let rNorm = x * 3.2404542 + y * -1.5371385 + z * -0.4985314;
|
|
let gNorm = x * -0.9692660 + y * 1.8760108 + z * 0.0415560;
|
|
let bNorm = x * 0.0556434 + y * -0.2040259 + z * 1.0572252;
|
|
|
|
// Apply gamma correction
|
|
rNorm = rNorm > 0.0031308 ? 1.055 * Math.pow(rNorm, 1 / 2.4) - 0.055 : 12.92 * rNorm;
|
|
gNorm = gNorm > 0.0031308 ? 1.055 * Math.pow(gNorm, 1 / 2.4) - 0.055 : 12.92 * gNorm;
|
|
bNorm = bNorm > 0.0031308 ? 1.055 * Math.pow(bNorm, 1 / 2.4) - 0.055 : 12.92 * bNorm;
|
|
|
|
return {
|
|
r: Math.round(Math.max(0, Math.min(255, rNorm * 255))),
|
|
g: Math.round(Math.max(0, Math.min(255, gNorm * 255))),
|
|
b: Math.round(Math.max(0, Math.min(255, bNorm * 255))),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Compute histogram for a region.
|
|
*/
|
|
function computeHistogram(
|
|
data: Uint8Array,
|
|
startX: number,
|
|
startY: number,
|
|
width: number,
|
|
height: number,
|
|
stride: number
|
|
): number[] {
|
|
const histogram = new Array(256).fill(0);
|
|
|
|
for (let y = startY; y < startY + height; y++) {
|
|
for (let x = startX; x < startX + width; x++) {
|
|
const value = data[y * stride + x];
|
|
histogram[value]++;
|
|
}
|
|
}
|
|
|
|
return histogram;
|
|
}
|
|
|
|
/**
|
|
* Clip histogram and redistribute excess.
|
|
*/
|
|
function clipHistogram(histogram: number[], clipLimit: number, numPixels: number): number[] {
|
|
const limit = Math.floor(clipLimit * numPixels / 256);
|
|
const clipped = [...histogram];
|
|
|
|
let excess = 0;
|
|
for (let i = 0; i < 256; i++) {
|
|
if (clipped[i] > limit) {
|
|
excess += clipped[i] - limit;
|
|
clipped[i] = limit;
|
|
}
|
|
}
|
|
|
|
// Redistribute excess
|
|
const increment = Math.floor(excess / 256);
|
|
const remainder = excess % 256;
|
|
|
|
for (let i = 0; i < 256; i++) {
|
|
clipped[i] += increment;
|
|
if (i < remainder) {
|
|
clipped[i]++;
|
|
}
|
|
}
|
|
|
|
return clipped;
|
|
}
|
|
|
|
/**
|
|
* Create lookup table from clipped histogram.
|
|
*/
|
|
function createLUT(histogram: number[], numPixels: number): Uint8Array {
|
|
const lut = new Uint8Array(256);
|
|
let cumSum = 0;
|
|
|
|
for (let i = 0; i < 256; i++) {
|
|
cumSum += histogram[i];
|
|
lut[i] = Math.round((cumSum / numPixels) * 255);
|
|
}
|
|
|
|
return lut;
|
|
}
|
|
|
|
/**
|
|
* Bilinear interpolation between four values.
|
|
*/
|
|
function bilinearInterpolate(
|
|
topLeft: number,
|
|
topRight: number,
|
|
bottomLeft: number,
|
|
bottomRight: number,
|
|
xFrac: number,
|
|
yFrac: number
|
|
): number {
|
|
const top = topLeft + (topRight - topLeft) * xFrac;
|
|
const bottom = bottomLeft + (bottomRight - bottomLeft) * xFrac;
|
|
return Math.round(top + (bottom - top) * yFrac);
|
|
}
|
|
|
|
/**
|
|
* Apply CLAHE to RGBA pixel data.
|
|
* Processes only the L channel in LAB color space.
|
|
*
|
|
* @param pixels RGBA pixel data
|
|
* @param width Image width
|
|
* @param height Image height
|
|
* @param options CLAHE options
|
|
* @returns Processed RGBA pixel data
|
|
*/
|
|
export function applyCLAHE(
|
|
pixels: Uint8Array | Uint8ClampedArray,
|
|
width: number,
|
|
height: number,
|
|
options: CLAHEOptions = {}
|
|
): Uint8Array {
|
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
const { clipLimit, tileGridSize } = opts;
|
|
|
|
const tileWidth = Math.floor(width / tileGridSize.width);
|
|
const tileHeight = Math.floor(height / tileGridSize.height);
|
|
|
|
// Extract L channel from LAB
|
|
const lChannel = new Uint8Array(width * height);
|
|
const aChannel = new Float32Array(width * height);
|
|
const bChannel = new Float32Array(width * height);
|
|
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
const idx = (y * width + x) * 4;
|
|
const lab = rgbToLab(pixels[idx], pixels[idx + 1], pixels[idx + 2]);
|
|
const pixelIdx = y * width + x;
|
|
lChannel[pixelIdx] = Math.round((lab.l / 100) * 255);
|
|
aChannel[pixelIdx] = lab.a;
|
|
bChannel[pixelIdx] = lab.b;
|
|
}
|
|
}
|
|
|
|
// Compute LUTs for each tile
|
|
const luts: Uint8Array[][] = [];
|
|
const numPixelsPerTile = tileWidth * tileHeight;
|
|
|
|
for (let ty = 0; ty < tileGridSize.height; ty++) {
|
|
luts[ty] = [];
|
|
for (let tx = 0; tx < tileGridSize.width; tx++) {
|
|
const startX = tx * tileWidth;
|
|
const startY = ty * tileHeight;
|
|
|
|
const histogram = computeHistogram(
|
|
lChannel,
|
|
startX,
|
|
startY,
|
|
tileWidth,
|
|
tileHeight,
|
|
width
|
|
);
|
|
|
|
const clipped = clipHistogram(histogram, clipLimit, numPixelsPerTile);
|
|
luts[ty][tx] = createLUT(clipped, numPixelsPerTile);
|
|
}
|
|
}
|
|
|
|
// Apply CLAHE with bilinear interpolation
|
|
const result = new Uint8Array(pixels.length);
|
|
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
const pixelIdx = y * width + x;
|
|
const rgbaIdx = pixelIdx * 4;
|
|
|
|
// Determine tile position
|
|
const tileX = Math.min(x / tileWidth, tileGridSize.width - 1);
|
|
const tileY = Math.min(y / tileHeight, tileGridSize.height - 1);
|
|
|
|
const tx = Math.floor(tileX);
|
|
const ty = Math.floor(tileY);
|
|
|
|
const xFrac = tileX - tx;
|
|
const yFrac = tileY - ty;
|
|
|
|
// Get surrounding tiles (handle edges)
|
|
const tx1 = Math.min(tx + 1, tileGridSize.width - 1);
|
|
const ty1 = Math.min(ty + 1, tileGridSize.height - 1);
|
|
|
|
const lValue = lChannel[pixelIdx];
|
|
|
|
// Interpolate LUT values
|
|
const newL = bilinearInterpolate(
|
|
luts[ty][tx][lValue],
|
|
luts[ty][tx1][lValue],
|
|
luts[ty1][tx][lValue],
|
|
luts[ty1][tx1][lValue],
|
|
xFrac,
|
|
yFrac
|
|
);
|
|
|
|
// Convert back to RGB
|
|
const rgb = labToRgb(
|
|
(newL / 255) * 100,
|
|
aChannel[pixelIdx],
|
|
bChannel[pixelIdx]
|
|
);
|
|
|
|
result[rgbaIdx] = rgb.r;
|
|
result[rgbaIdx + 1] = rgb.g;
|
|
result[rgbaIdx + 2] = rgb.b;
|
|
result[rgbaIdx + 3] = pixels[rgbaIdx + 3]; // Preserve alpha
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|