Erste Produktivversion
This commit is contained in:
265
web/html/js/barcode.js
Normal file
265
web/html/js/barcode.js
Normal file
@@ -0,0 +1,265 @@
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user