266 lines
8.7 KiB
JavaScript
266 lines
8.7 KiB
JavaScript
// Decoder für EAN-13, EAN-8 und UPC-A. Ohne Fremdbibliothek.
|
||
//
|
||
// Warum selbst geschrieben: Die native BarcodeDetector-API gibt es nur in
|
||
// Chrome und auf Android. Die übliche Alternative wäre ZXing - eine
|
||
// 250-KB-Datei aus dem npm-Ökosystem. Für drei Strichcode-Formate, die
|
||
// alle nach demselben simplen Schema arbeiten, ist das unverhältnismäßig.
|
||
//
|
||
// Verfahren: Aus einem Kamerabild werden mehrere waagerechte Linien
|
||
// abgetastet, in Hell-Dunkel-Folgen zerlegt und gegen die Zifferntabellen
|
||
// gehalten. Ein Ergebnis gilt erst als sicher, wenn es zweimal
|
||
// unabhängig herauskommt und die Prüfziffer stimmt.
|
||
"use strict";
|
||
|
||
// Jede Ziffer besteht aus 7 Modulen in 4 Streifen. Die Tabelle enthält
|
||
// die Streifenbreiten der L-Kodierung; daraus lassen sich die beiden
|
||
// anderen ableiten:
|
||
// L Streifenbreiten, beginnend mit hell (linke Hälfte)
|
||
// G dieselben Breiten rückwärts, hell (linke Hälfte)
|
||
// R dieselben Breiten, beginnend mit dunkel (rechte Hälfte)
|
||
const L_WIDTHS = [
|
||
[3, 2, 1, 1], [2, 2, 2, 1], [2, 1, 2, 2], [1, 4, 1, 1], [1, 1, 3, 2],
|
||
[1, 2, 3, 1], [1, 1, 1, 4], [1, 3, 1, 2], [1, 2, 1, 3], [3, 1, 1, 2],
|
||
];
|
||
|
||
// Welche Abfolge von L und G in der linken Hälfte steht, verrät die
|
||
// erste Ziffer - sie ist selbst nicht als Streifen kodiert.
|
||
const PARITY = [
|
||
"LLLLLL", "LLGLGG", "LLGGLG", "LLGGGL", "LGLLGG",
|
||
"LGGLLG", "LGGGLL", "LGLGLG", "LGLGGL", "LGGLGL",
|
||
];
|
||
|
||
const MAX_DEVIATION = 0.42; // erlaubte Abweichung je Streifen, in Modulen
|
||
|
||
/** Vergleicht vier gemessene Streifen mit einem Sollmuster.
|
||
* Verglichen werden Verhältnisse, keine absoluten Breiten - dadurch
|
||
* spielt es keine Rolle, wie weit die Kamera vom Code entfernt ist. */
|
||
function patternDistance(counters, pattern) {
|
||
const total = counters[0] + counters[1] + counters[2] + counters[3];
|
||
if (total <= 0) return Infinity;
|
||
const unit = total / 7;
|
||
// Zu schmale Streifen deuten auf Rauschen statt auf einen Strichcode.
|
||
if (unit < 0.6) return Infinity;
|
||
|
||
let sum = 0;
|
||
for (let i = 0; i < 4; i++) {
|
||
const deviation = Math.abs(counters[i] / unit - pattern[i]);
|
||
if (deviation > MAX_DEVIATION * 2) return Infinity;
|
||
sum += deviation;
|
||
}
|
||
return sum;
|
||
}
|
||
|
||
/** @returns {{digit: number, code: "L"|"G"|"R"}|null} */
|
||
function matchDigit(counters, half) {
|
||
let best = null;
|
||
let bestDistance = MAX_DEVIATION * 4;
|
||
|
||
for (let digit = 0; digit < 10; digit++) {
|
||
if (half === "left") {
|
||
const dL = patternDistance(counters, L_WIDTHS[digit]);
|
||
if (dL < bestDistance) { bestDistance = dL; best = { digit, code: "L" }; }
|
||
|
||
const reversed = [...L_WIDTHS[digit]].reverse();
|
||
const dG = patternDistance(counters, reversed);
|
||
if (dG < bestDistance) { bestDistance = dG; best = { digit, code: "G" }; }
|
||
} else {
|
||
const dR = patternDistance(counters, L_WIDTHS[digit]);
|
||
if (dR < bestDistance) { bestDistance = dR; best = { digit, code: "R" }; }
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
/** Prüfziffer nach dem Modulo-10-Verfahren. Gilt für EAN-13, EAN-8
|
||
* und UPC-A gleichermaßen - nur die Gewichtung beginnt je nach Länge
|
||
* bei 1 oder 3. */
|
||
export function checksumValid(code) {
|
||
const digits = [...code].map(Number);
|
||
if (digits.some(Number.isNaN)) return false;
|
||
|
||
const check = digits.pop();
|
||
let sum = 0;
|
||
// Von rechts nach links abwechselnd ×3 und ×1.
|
||
for (let i = digits.length - 1, weight = 3; i >= 0; i--, weight = 4 - weight) {
|
||
sum += digits[i] * weight;
|
||
}
|
||
return (10 - (sum % 10)) % 10 === check;
|
||
}
|
||
|
||
/** Zerlegt eine Helligkeitszeile in Streifenlängen.
|
||
* Der Schwellwert wird je Zeile aus deren eigenem Hell-Dunkel-Umfang
|
||
* gebildet - so stört ein Schatten über dem halben Bild nicht. */
|
||
function toRuns(line) {
|
||
let min = 255;
|
||
let max = 0;
|
||
for (const value of line) {
|
||
if (value < min) min = value;
|
||
if (value > max) max = value;
|
||
}
|
||
// Zu wenig Kontrast: da ist kein Strichcode.
|
||
if (max - min < 40) return null;
|
||
|
||
const threshold = (min + max) / 2;
|
||
const runs = [];
|
||
let dark = line[0] < threshold;
|
||
let length = 0;
|
||
|
||
for (const value of line) {
|
||
const isDark = value < threshold;
|
||
if (isDark === dark) {
|
||
length++;
|
||
} else {
|
||
runs.push({ dark, length });
|
||
dark = isDark;
|
||
length = 1;
|
||
}
|
||
}
|
||
runs.push({ dark, length });
|
||
return runs;
|
||
}
|
||
|
||
// Vor dem Startzeichen verlangt die Norm eine helle Ruhezone von elf
|
||
// Modulen. Diese Prüfung ist der wirksamste Schutz gegen Fehltreffer:
|
||
// In zufälligem Rauschen gibt es keine so breiten hellen Flächen, in
|
||
// einem echten Kamerabild dagegen immer. Konservativ angesetzt, weil
|
||
// der Rand bei knapper Bildausschnittwahl auch mal kürzer ausfällt.
|
||
const MIN_QUIET_ZONE = 3.5;
|
||
|
||
/** Versucht ab einem dunklen Streifen einen vollständigen Code zu lesen. */
|
||
function decodeAt(runs, start, digitCount) {
|
||
// digitCount: 13 -> je 6 Ziffern pro Hälfte, 8 -> je 4
|
||
const perHalf = digitCount === 13 ? 6 : 4;
|
||
|
||
// Startzeichen: drei Streifen von je einem Modul.
|
||
const guard = [runs[start], runs[start + 1], runs[start + 2]];
|
||
if (guard.some((r) => r === undefined)) return null;
|
||
const unit = (guard[0].length + guard[1].length + guard[2].length) / 3;
|
||
if (unit < 0.7) return null;
|
||
for (const r of guard) {
|
||
if (Math.abs(r.length / unit - 1) > MAX_DEVIATION) return null;
|
||
}
|
||
|
||
// Ruhezone davor
|
||
const before = runs[start - 1];
|
||
if (start > 0 && (before.dark || before.length < unit * MIN_QUIET_ZONE)) {
|
||
return null;
|
||
}
|
||
|
||
let index = start + 3;
|
||
const digits = [];
|
||
let parity = "";
|
||
|
||
for (let i = 0; i < perHalf; i++) {
|
||
const counters = [
|
||
runs[index]?.length, runs[index + 1]?.length,
|
||
runs[index + 2]?.length, runs[index + 3]?.length,
|
||
];
|
||
if (counters.some((c) => c === undefined)) return null;
|
||
|
||
const match = matchDigit(counters, "left");
|
||
if (!match) return null;
|
||
digits.push(match.digit);
|
||
parity += match.code === "R" ? "L" : match.code;
|
||
index += 4;
|
||
}
|
||
|
||
// Trennzeichen in der Mitte: fünf Streifen von je einem Modul.
|
||
for (let i = 0; i < 5; i++) {
|
||
const run = runs[index + i];
|
||
if (!run) return null;
|
||
if (Math.abs(run.length / unit - 1) > MAX_DEVIATION * 1.5) return null;
|
||
}
|
||
index += 5;
|
||
|
||
for (let i = 0; i < perHalf; i++) {
|
||
const counters = [
|
||
runs[index]?.length, runs[index + 1]?.length,
|
||
runs[index + 2]?.length, runs[index + 3]?.length,
|
||
];
|
||
if (counters.some((c) => c === undefined)) return null;
|
||
|
||
const match = matchDigit(counters, "right");
|
||
if (!match) return null;
|
||
digits.push(match.digit);
|
||
index += 4;
|
||
}
|
||
|
||
// Schlusszeichen
|
||
for (let i = 0; i < 3; i++) {
|
||
const run = runs[index + i];
|
||
if (!run) return null;
|
||
if (Math.abs(run.length / unit - 1) > MAX_DEVIATION * 1.5) return null;
|
||
}
|
||
|
||
// Ruhezone dahinter - oder das Ende der abgetasteten Zeile.
|
||
const after = runs[index + 3];
|
||
if (after && (after.dark || after.length < unit * MIN_QUIET_ZONE)) return null;
|
||
|
||
let code;
|
||
if (digitCount === 13) {
|
||
const first = PARITY.indexOf(parity);
|
||
if (first < 0) return null;
|
||
code = String(first) + digits.join("");
|
||
} else {
|
||
code = digits.join("");
|
||
}
|
||
|
||
return checksumValid(code) ? code : null;
|
||
}
|
||
|
||
/** Liest eine einzelne Helligkeitszeile. */
|
||
export function decodeLine(line) {
|
||
const runs = toRuns(line);
|
||
if (!runs || runs.length < 30) return null;
|
||
|
||
for (const direction of ["forward", "backward"]) {
|
||
// Ein auf dem Kopf stehender Code liest sich rückwärts genauso.
|
||
const sequence = direction === "forward" ? runs : [...runs].reverse();
|
||
|
||
for (let i = 0; i < sequence.length - 20; i++) {
|
||
if (!sequence[i].dark) continue;
|
||
for (const digitCount of [13, 8]) {
|
||
const code = decodeAt(sequence, i, digitCount);
|
||
if (code) return code;
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Sucht in einem Bild nach einem Strichcode.
|
||
*
|
||
* @param {ImageData} imageData
|
||
* @param {number} lines Anzahl waagerechter Abtastlinien
|
||
* @returns {string|null}
|
||
*/
|
||
export function decodeImage(imageData, lines = 21) {
|
||
const { width, height, data } = imageData;
|
||
const results = new Map();
|
||
|
||
for (let n = 0; n < lines; n++) {
|
||
// Linien über die mittleren zwei Drittel verteilen: Dort liegt der
|
||
// Code, wenn der Nutzer ihn im Sucherrahmen hält.
|
||
const y = Math.round(height * (1 / 6 + (2 / 3) * (n / (lines - 1 || 1))));
|
||
const row = new Uint8Array(width);
|
||
const offset = y * width * 4;
|
||
|
||
for (let x = 0; x < width; x++) {
|
||
const p = offset + x * 4;
|
||
// Grauwert nach Wahrnehmungsgewichtung.
|
||
row[x] = (data[p] * 77 + data[p + 1] * 150 + data[p + 2] * 29) >> 8;
|
||
}
|
||
|
||
const code = decodeLine(row);
|
||
if (!code) continue;
|
||
|
||
const count = (results.get(code) || 0) + 1;
|
||
// Erst wenn zwei Linien dasselbe ergeben, gilt es als sicher. Ein
|
||
// Einzeltreffer kann von Rauschen kommen.
|
||
if (count >= 2) return code;
|
||
results.set(code, count);
|
||
}
|
||
return null;
|
||
}
|