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:
Chris Kruining 2026-02-09 16:16:34 +01:00
parent 56499d5af9
commit 83ab4df537
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
138 changed files with 19136 additions and 7681 deletions

View file

@ -0,0 +1,207 @@
/**
* Perspective correction to transform a quadrilateral region into a rectangle.
* Uses homography transformation with bilinear sampling.
*/
import { Point, sampleBilinear } from "./imageUtils";
/** Standard output size for corrected card images (63:88 aspect ratio). */
export const OUTPUT_WIDTH = 480;
export const OUTPUT_HEIGHT = 670;
/**
* Apply perspective correction to extract and normalize a card from an image.
*
* @param pixels - Source RGBA pixel data
* @param width - Source image width
* @param height - Source image height
* @param corners - Four corners in order: top-left, top-right, bottom-right, bottom-left
* @param outputWidth - Width of output image
* @param outputHeight - Height of output image
* @returns Perspective-corrected RGBA pixel data
*/
export function warpPerspective(
pixels: Uint8Array | Uint8ClampedArray,
width: number,
height: number,
corners: Point[],
outputWidth: number = OUTPUT_WIDTH,
outputHeight: number = OUTPUT_HEIGHT
): { pixels: Uint8Array; width: number; height: number } {
if (corners.length !== 4) {
throw new Error("Exactly 4 corners required");
}
// Determine if card is landscape (rotated 90°)
const width1 = distance(corners[0], corners[1]);
const height1 = distance(corners[1], corners[2]);
let orderedCorners: Point[];
if (width1 > height1) {
// Card is landscape - rotate corners to portrait
orderedCorners = [corners[1], corners[2], corners[3], corners[0]];
} else {
orderedCorners = corners;
}
// Compute the perspective transform matrix
const matrix = computePerspectiveTransform(orderedCorners, outputWidth, outputHeight);
const inverse = invertMatrix3x3(matrix);
// Apply the transform
const result = new Uint8Array(outputWidth * outputHeight * 4);
for (let y = 0; y < outputHeight; y++) {
for (let x = 0; x < outputWidth; x++) {
// Apply inverse transform to find source coordinates
const srcPoint = applyTransform(inverse, x, y);
// Bilinear interpolation for smooth sampling
const [r, g, b, a] = sampleBilinear(pixels, width, height, srcPoint.x, srcPoint.y);
const idx = (y * outputWidth + x) * 4;
result[idx] = r;
result[idx + 1] = g;
result[idx + 2] = b;
result[idx + 3] = a;
}
}
return { pixels: result, width: outputWidth, height: outputHeight };
}
/**
* Compute a perspective transform matrix from quad corners to rectangle.
* Uses the Direct Linear Transform (DLT) algorithm.
*/
function computePerspectiveTransform(
src: Point[],
dstWidth: number,
dstHeight: number
): number[] {
// Destination corners (rectangle)
const dst: Point[] = [
{ x: 0, y: 0 },
{ x: dstWidth - 1, y: 0 },
{ x: dstWidth - 1, y: dstHeight - 1 },
{ x: 0, y: dstHeight - 1 },
];
// Build the 8x8 matrix for solving the homography
const A: number[][] = [];
const b: number[] = [];
for (let i = 0; i < 4; i++) {
const sx = src[i].x;
const sy = src[i].y;
const dx = dst[i].x;
const dy = dst[i].y;
A.push([sx, sy, 1, 0, 0, 0, -dx * sx, -dx * sy]);
b.push(dx);
A.push([0, 0, 0, sx, sy, 1, -dy * sx, -dy * sy]);
b.push(dy);
}
// Solve using Gaussian elimination
const h = solveLinearSystem(A, b);
// Return 3x3 matrix as flat array [h11, h12, h13, h21, h22, h23, h31, h32, h33]
return [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1];
}
/**
* Solve a linear system Ax = b using Gaussian elimination with partial pivoting.
*/
function solveLinearSystem(A: number[][], b: number[]): number[] {
const n = b.length;
// Create augmented matrix
const augmented: number[][] = A.map((row, i) => [...row, b[i]]);
// Forward elimination with partial pivoting
for (let col = 0; col < n; col++) {
// Find pivot
let maxRow = col;
for (let row = col + 1; row < n; row++) {
if (Math.abs(augmented[row][col]) > Math.abs(augmented[maxRow][col])) {
maxRow = row;
}
}
// Swap rows
[augmented[col], augmented[maxRow]] = [augmented[maxRow], augmented[col]];
// Eliminate
for (let row = col + 1; row < n; row++) {
if (Math.abs(augmented[col][col]) < 1e-10) continue;
const factor = augmented[row][col] / augmented[col][col];
for (let j = col; j <= n; j++) {
augmented[row][j] -= factor * augmented[col][j];
}
}
}
// Back substitution
const x = new Array(n).fill(0);
for (let i = n - 1; i >= 0; i--) {
x[i] = augmented[i][n];
for (let j = i + 1; j < n; j++) {
x[i] -= augmented[i][j] * x[j];
}
if (Math.abs(augmented[i][i]) > 1e-10) {
x[i] /= augmented[i][i];
}
}
return x;
}
/**
* Apply a 3x3 transform matrix to a point.
*/
function applyTransform(H: number[], x: number, y: number): Point {
let w = H[6] * x + H[7] * y + H[8];
if (Math.abs(w) < 1e-10) w = 1e-10;
return {
x: (H[0] * x + H[1] * y + H[2]) / w,
y: (H[3] * x + H[4] * y + H[5]) / w,
};
}
/**
* Invert a 3x3 matrix.
*/
function invertMatrix3x3(m: number[]): number[] {
const det =
m[0] * (m[4] * m[8] - m[5] * m[7]) -
m[1] * (m[3] * m[8] - m[5] * m[6]) +
m[2] * (m[3] * m[7] - m[4] * m[6]);
const invDet = Math.abs(det) < 1e-10 ? 1e10 : 1 / det;
return [
(m[4] * m[8] - m[5] * m[7]) * invDet,
(m[2] * m[7] - m[1] * m[8]) * invDet,
(m[1] * m[5] - m[2] * m[4]) * invDet,
(m[5] * m[6] - m[3] * m[8]) * invDet,
(m[0] * m[8] - m[2] * m[6]) * invDet,
(m[2] * m[3] - m[0] * m[5]) * invDet,
(m[3] * m[7] - m[4] * m[6]) * invDet,
(m[1] * m[6] - m[0] * m[7]) * invDet,
(m[0] * m[4] - m[1] * m[3]) * invDet,
];
}
/**
* Calculate distance between two points.
*/
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);
}