// 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);