211 lines
7.5 KiB
JavaScript
211 lines
7.5 KiB
JavaScript
// 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);
|