Erste Produktivversion

This commit is contained in:
2026-08-08 20:31:58 +02:00
parent d8ded2816d
commit 7b21e2d1b4
110 changed files with 18170 additions and 644 deletions

210
tools/test-barcode.mjs Normal file
View File

@@ -0,0 +1,210 @@
// Test des Strichcode-Decoders ohne Kamera.
//
// node tools/test-barcode.mjs
//
// Erzeugt synthetische EAN-13-, EAN-8- und UPC-A-Signale, verzerrt sie
// (Unschärfe, Rauschen, unterschiedliche Skalierung, umgedreht) und
// prüft, ob der Decoder sie zurückliest - und ob er auf leeren Flächen
// und Rauschen schweigt.
import {
checksumValid,
decodeImage,
decodeLine,
} from "../web/html/js/barcode.js";
// Deterministischer Zufallszahlengenerator (xorshift32).
//
// Math.random() hätte hier einen Test ergeben, der mal durchgeht und mal
// nicht - genau das ist beim ersten Gesamtdurchlauf passiert. Ein Test
// mit wechselndem Ergebnis ist schlimmer als keiner: Man gewöhnt sich an,
// ihn zu ignorieren.
let _seed = 0x2f6f4e01;
function rnd() {
_seed ^= _seed << 13; _seed >>>= 0;
_seed ^= _seed >> 17;
_seed ^= _seed << 5; _seed >>>= 0;
return _seed / 0x100000000;
}
function reseed(value) { _seed = value >>> 0 || 1; }
// --- Kodierer zum Gegentesten (nur im Test, nicht in der App) ---
const L = ["0001101","0011001","0010011","0111101","0100011",
"0110001","0101111","0111011","0110111","0001011"];
const R_ = L.map((s) => [...s].map((b) => (b === "0" ? "1" : "0")).join(""));
const G = R_.map((s) => [...s].reverse().join(""));
const R = L.map((s) => [...s].map((b) => (b === "0" ? "1" : "0")).join(""));
const PARITY = ["LLLLLL","LLGLGG","LLGGLG","LLGGGL","LGLLGG",
"LGGLLG","LGGGLL","LGLGLG","LGLGGL","LGGLGL"];
function checkDigit(body) {
const d = [...body].map(Number);
let sum = 0;
for (let i = d.length - 1, w = 3; i >= 0; i--, w = 4 - w) sum += d[i] * w;
return (10 - (sum % 10)) % 10;
}
function encodeEAN13(body12) {
const code = body12 + checkDigit(body12);
const first = Number(code[0]);
const parity = PARITY[first];
let bits = "101";
for (let i = 0; i < 6; i++) {
const d = Number(code[i + 1]);
bits += parity[i] === "L" ? L[d] : G[d];
}
bits += "01010";
for (let i = 0; i < 6; i++) bits += R[Number(code[i + 7])];
bits += "101";
return { code, bits };
}
function encodeEAN8(body7) {
const code = body7 + checkDigit(body7);
let bits = "101";
for (let i = 0; i < 4; i++) bits += L[Number(code[i])];
bits += "01010";
for (let i = 4; i < 8; i++) bits += R[Number(code[i])];
bits += "101";
return { code, bits };
}
/** Bitmuster -> Helligkeitszeile, mit Rand, Skalierung und Rauschen. */
function toLine(bits, { scale = 3, quiet = null, noise = 0, blur = 0 } = {}) {
// Ruhezone in MODULEN, nicht in Pixeln: Die Norm verlangt 7 bis 11
// Module. Ein fester Pixelwert wäre bei großer Skalierung zu schmal
// und würde einen Fehler vortäuschen, den es in der Wirklichkeit
// nicht gibt.
const margin = quiet ?? scale * 8;
const px = [];
for (let i = 0; i < margin; i++) px.push(255);
for (const b of bits) {
for (let s = 0; s < scale; s++) px.push(b === "1" ? 20 : 235);
}
for (let i = 0; i < margin; i++) px.push(255);
let out = px;
if (blur) {
out = px.map((_, i) => {
let sum = 0, n = 0;
for (let k = -blur; k <= blur; k++) {
if (px[i + k] !== undefined) { sum += px[i + k]; n++; }
}
return sum / n;
});
}
if (noise) {
out = out.map((v) => Math.max(0, Math.min(255, v + (rnd() - 0.5) * noise)));
}
return Uint8Array.from(out.map(Math.round));
}
function lineToImageData(line, height = 30) {
const width = line.length;
const data = new Uint8ClampedArray(width * height * 4);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const p = (y * width + x) * 4;
data[p] = data[p + 1] = data[p + 2] = line[x];
data[p + 3] = 255;
}
}
return { width, height, data };
}
let pass = 0, fail = 0;
const check = (name, ok, extra = "") => {
if (ok) { pass++; console.log(` ok ${name}`); }
else { fail++; console.log(` FEHL ${name} ${extra}`); }
};
console.log("--- Prüfziffer ---");
check("EAN-13 gültig", checksumValid("4001234567890") === (checkDigit("400123456789") === 0));
check("bekannte EAN-13", checksumValid("4006381333931"));
check("verfälschte EAN-13 abgelehnt", !checksumValid("4006381333932"));
check("EAN-8", checksumValid("96385074"));
console.log("\n--- Sauberes Signal ---");
for (const body of ["400123456789", "978020137", "012345678", "590123412345".slice(0,12)]) {
const b12 = body.padEnd(12, "0").slice(0, 12);
const { code, bits } = encodeEAN13(b12);
check(`EAN-13 ${code}`, decodeLine(toLine(bits)) === code, `-> ${decodeLine(toLine(bits))}`);
}
{
const { code, bits } = encodeEAN8("9638507");
check(`EAN-8 ${code}`, decodeLine(toLine(bits)) === code, `-> ${decodeLine(toLine(bits))}`);
}
console.log("\n--- Umgedreht gehalten ---");
{
const { code, bits } = encodeEAN13("400123456789");
const line = toLine(bits);
check(`rückwärts ${code}`, decodeLine(Uint8Array.from([...line].reverse())) === code);
}
console.log("\n--- Unscharf und verrauscht ---");
for (const [blur, noise, scale] of [[1, 20, 3], [2, 30, 4], [1, 40, 5], [3, 25, 6]]) {
const { code, bits } = encodeEAN13("426000123456");
const got = decodeLine(toLine(bits, { blur, noise, scale }));
check(`blur=${blur} noise=${noise} scale=${scale}`, got === code, `-> ${got}`);
}
console.log("\n--- Knappe Ruhezone ---");
{
const { code, bits } = encodeEAN13("426000123456");
// 6 Module Rand: unterschreitet die Norm, wird aber noch gelesen
const ok6 = decodeLine(toLine(bits, { scale: 4, quiet: 24 }));
check("6 Module Rand wird gelesen", ok6 === code, `-> ${ok6}`);
// 2 Module Rand: wird abgelehnt. Das ist der Preis dafür, dass
// Rauschen keine Fehltreffer erzeugt.
const ok2 = decodeLine(toLine(bits, { scale: 4, quiet: 8 }));
check("2 Module Rand wird abgelehnt", ok2 === null, `-> ${ok2}`);
}
console.log("\n--- Ganzes Bild ---");
{
const { code, bits } = encodeEAN13("400123456789");
const line = toLine(bits, { blur: 1, noise: 25, scale: 4 });
check("decodeImage", decodeImage(lineToImageData(line)) === code);
}
console.log("\n--- Kein Strichcode im Bild ---");
{
const flat = Uint8Array.from({ length: 400 }, () => 200);
check("gleichmäßige Fläche -> null", decodeLine(flat) === null);
// Fehltreffer statistisch messen statt einmal zu würfeln.
//
// Eine einzelne Zeile kann in reinem Rauschen zufällig eine gültige
// Kombination ergeben - Startzeichen, zwölf Ziffern, passende
// Prüfziffer. Die Ruhezonenprüfung drückt das stark, ganz ausschließen
// lässt es sich nicht.
//
// Entscheidend ist die Ebene darüber: decodeImage verlangt, dass ZWEI
// Abtastlinien dasselbe Ergebnis liefern. Genau das ist im Sucher der
// Anwendung im Einsatz.
let lineHits = 0;
const LINE_TRIES = 500;
for (let i = 0; i < LINE_TRIES; i++) {
reseed(0x1000 + i);
const noise = Uint8Array.from({ length: 400 }, () => (rnd() * 255) | 0);
if (decodeLine(noise)) lineHits++;
}
console.log(` info Einzelzeile auf Rauschen: ${lineHits}/${LINE_TRIES} Fehltreffer`);
check(`Einzelzeile: Fehltrefferquote unter 2 %`, lineHits / LINE_TRIES < 0.02,
`(${lineHits}/${LINE_TRIES})`);
let imageHits = 0;
const IMAGE_TRIES = 200;
for (let i = 0; i < IMAGE_TRIES; i++) {
reseed(0x9000 + i);
const noise = Uint8Array.from({ length: 400 }, () => (rnd() * 255) | 0);
if (decodeImage(lineToImageData(noise))) imageHits++;
}
console.log(` info Ganzes Bild auf Rauschen: ${imageHits}/${IMAGE_TRIES} Fehltreffer`);
check("Ganzes Bild: keine Fehltreffer auf Rauschen", imageHits === 0,
`(${imageHits}/${IMAGE_TRIES})`);
}
console.log(`\n${pass} bestanden, ${fail} fehlgeschlagen`);
process.exit(fail ? 1 : 0);