feat: add iOS support and harden receipt scanning

This commit is contained in:
Timo
2026-08-20 23:48:26 +02:00
parent f5e06c32b0
commit cf1b799e1b
107 changed files with 11755 additions and 122 deletions

View File

@@ -3,6 +3,7 @@
*/
import { describe, test, expect } from "./runner";
import sharp from "sharp";
import {
AI_IMAGE_LONG_EDGE_PX,
AI_IMAGE_MIN_WIDTH_PX,
@@ -10,10 +11,12 @@ import {
} from "../../src/lib/image/resize";
import {
contentBoundingBox,
detectPaperQuadrilateral,
estimateSkewDegrees,
needsContrastBoost,
paperReceiptBoundingBox,
} from "../../src/lib/image/enhance";
import { processReceiptImage } from "../../src/lib/image/processor";
import { assessReceiptDetailQuality, processReceiptImage } from "../../src/lib/image/processor";
describe("Sprint C — Mindestbreite", () => {
test("Querformat bleibt am 1536-Long-Edge", () => {
@@ -86,6 +89,74 @@ describe("Sprint C — Crop", () => {
const data = new Uint8Array(40 * 40).fill(200);
expect(contentBoundingBox(data, 40, 40)).toBeNull();
});
test("heller Thermobon wird auf strukturiertem farbigem Untergrund erkannt", () => {
const w = 100;
const h = 140;
const data = new Uint8Array(w * h * 3);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = (y * w + x) * 3;
const onPaper = x >= 32 && x < 68;
data[i] = onPaper ? 200 : 180;
data[i + 1] = onPaper ? 207 : 132;
data[i + 2] = onPaper ? 214 : 82;
}
}
const box = paperReceiptBoundingBox(data, w, h, 3);
expect(box).not.toBeNull();
expect(box!.left).toBeLessThanOrEqual(32);
expect(box!.left + box!.width).toBeGreaterThanOrEqual(68);
expect(box!.width).toBeLessThan(60);
});
test("Perspektive, Schatten, Falte und gemusterter Untergrund werden konservativ erkannt", () => {
const w = 120;
const h = 180;
const data = new Uint8Array(w * h * 3);
for (let y = 0; y < h; y++) {
const progress = y / (h - 1);
const left = Math.round(32 - progress * 10);
const right = Math.round(88 + progress * 10);
for (let x = 0; x < w; x++) {
const offset = (y * w + x) * 3;
const onPaper = x >= left && x <= right;
const folded = Math.abs(y - 92) < 2;
const shadow = 165 + Math.round(progress * 65);
data[offset] = onPaper ? (folded ? 125 : shadow) : 115 + ((x + y) % 18);
data[offset + 1] = onPaper ? (folded ? 125 : shadow) : 72 + ((x * 3 + y) % 14);
data[offset + 2] = onPaper ? (folded ? 125 : shadow) : 35 + ((x + y * 2) % 11);
}
}
const quad = detectPaperQuadrilateral(data, w, h, 3);
expect(quad).not.toBeNull();
expect(quad!.topLeft.x).toBeGreaterThan(quad!.bottomLeft.x);
expect(quad!.topRight.x).toBeLessThan(quad!.bottomRight.x);
expect(quad!.confidence).toBeGreaterThanOrEqual(0.72);
});
test("unsichere helle Vollfläche wird nicht als Perspektivkorrektur behandelt", () => {
const data = new Uint8Array(100 * 140 * 3).fill(220);
expect(detectPaperQuadrilateral(data, 100, 140, 3)).toBeNull();
});
});
describe("Sprint C — lokale Lesbarkeit", () => {
test("Unschärfe erhält einen niedrigeren Qualitätswert als klare Druckzeilen", async () => {
const width = 200;
const height = 300;
const raw = Buffer.alloc(width * height, 235);
for (let y = 12; y < height - 12; y += 18) {
for (let yy = y; yy < y + 3; yy++) {
raw.fill(25, yy * width + 18, yy * width + width - 18);
}
}
const crisp = await sharp(raw, { raw: { width, height, channels: 1 } }).jpeg().toBuffer();
const blurred = await sharp(crisp).blur(4).jpeg().toBuffer();
expect(await assessReceiptDetailQuality(crisp)).toBeGreaterThan(
await assessReceiptDetailQuality(blurred)
);
});
});
describe("Sprint C — Deskew", () => {