Files
scan-receipts/tests/e2e/sprint_c_image.test.ts

187 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Sprint C: image quality — min width, contrast gate, crop box, deskew.
*/
import { describe, test, expect } from "./runner";
import sharp from "sharp";
import {
AI_IMAGE_LONG_EDGE_PX,
AI_IMAGE_MIN_WIDTH_PX,
computeReceiptResize,
} from "../../src/lib/image/resize";
import {
contentBoundingBox,
detectPaperQuadrilateral,
estimateSkewDegrees,
needsContrastBoost,
paperReceiptBoundingBox,
} from "../../src/lib/image/enhance";
import { assessReceiptDetailQuality, processReceiptImage } from "../../src/lib/image/processor";
describe("Sprint C — Mindestbreite", () => {
test("Querformat bleibt am 1536-Long-Edge", () => {
const out = computeReceiptResize(2000, 1400);
expect(out.width).toBe(1536);
expect(out.height).toBe(1075);
});
test("quadratische kleine Fotos werden nicht hochskaliert", () => {
expect(computeReceiptResize(400, 400)).toEqual({ width: 400, height: 400 });
});
test("hoher Thermobon wird breiter als bei reinem Long-Edge-Cap", () => {
const srcW = 800;
const srcH = 3500;
const longEdgeOnlyW = Math.round(srcW * (AI_IMAGE_LONG_EDGE_PX / srcH));
const out = computeReceiptResize(srcW, srcH);
expect(longEdgeOnlyW).toBeLessThan(400);
expect(out.width).toBeGreaterThan(longEdgeOnlyW);
expect(out.width).toBeGreaterThanOrEqual(650);
expect(Math.max(out.width, out.height)).toBeLessThanOrEqual(4096);
});
test("Min-Width-Konstante ist 1100", () => {
expect(AI_IMAGE_MIN_WIDTH_PX).toBe(1100);
});
});
describe("Sprint C — Kontrast", () => {
test("verwaschenes Thermobild (niedrige Stdev) bekommt Boost", () => {
expect(
needsContrastBoost({
channels: [{ mean: 170, stdev: 12, min: 140, max: 200 }],
})
).toBe(true);
});
test("farbstarkes Foto bekommt keinen Boost", () => {
expect(
needsContrastBoost({
channels: [
{ mean: 90, stdev: 55, min: 10, max: 240 },
{ mean: 100, stdev: 60, min: 8, max: 250 },
{ mean: 80, stdev: 50, min: 5, max: 230 },
],
})
).toBe(false);
});
});
describe("Sprint C — Crop", () => {
test("dunkles Rechteck auf weißem Grund wird als Inhalt erkannt", () => {
const w = 80;
const h = 80;
const data = new Uint8Array(w * h).fill(250);
for (let y = 18; y < 62; y++) {
for (let x = 16; x < 64; x++) {
data[y * w + x] = 20;
}
}
const box = contentBoundingBox(data, w, h);
expect(box).not.toBeNull();
expect(box!.left).toBeLessThanOrEqual(16);
expect(box!.top).toBeLessThanOrEqual(18);
expect(box!.left + box!.width).toBeGreaterThanOrEqual(64);
expect(box!.top + box!.height).toBeGreaterThanOrEqual(62);
});
test("einfarbiges Bild wird nicht beschnitten", () => {
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", () => {
test("waagerechte Textzeilen → Winkel ~ 0", () => {
const w = 64;
const h = 80;
const data = new Uint8Array(w * h);
for (let y = 0; y < h; y++) {
const dark = y % 8 < 3;
data.fill(dark ? 15 : 230, y * w, y * w + w);
}
expect(Math.abs(estimateSkewDegrees(data, w, h))).toBeLessThan(1);
});
});
describe("Sprint C — Pipeline", () => {
test("1×1-PNG überlebt Deskew/Crop/Resize", async () => {
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64"
);
const result = await processReceiptImage(png, "image/png");
expect(result.mimeType).toBe("image/jpeg");
expect(result.width).toBeGreaterThan(0);
expect(result.height).toBeGreaterThan(0);
});
});