diff --git a/src/ocr.js b/src/ocr.js new file mode 100644 index 0000000..e270285 --- /dev/null +++ b/src/ocr.js @@ -0,0 +1,122 @@ +import { createWorker } from 'tesseract.js'; + +// Der einmal erzeugte Erkennungs-Arbeiter wird als Versprechen zwischen- +// gespeichert, damit sich auch mehrere schnell hintereinander gestartete +// Aufrufe dasselbe (bereits laufende oder fertige) Laden teilen, statt es +// bei jedem Aufruf neu anzustossen - Tesseract laedt Sprachdaten und WASM, +// das kann auf einem Handy mehrere Sekunden dauern. Gleiches Muster wie +// vorbereitungsPromise in barcode.js. +let workerPromise = null; + +// Optimistisch: Bis zum ersten Fehlschlag gilt Texterkennung als verfuegbar. +// Ein Fehlschlag (Laden oder einzelne Erkennung) setzt sie vorlaeufig auf +// nicht verfuegbar; ein anschliessend erfolgreicher Aufruf hebt das wieder +// auf. Ein einzelnes schlechtes Foto oder ein voruebergehender Netzwerk- +// haenger beim Laden darf die Rueckfallebene nicht dauerhaft aus der +// Oberflaeche verschwinden lassen - siehe Bericht, Abschnitt Selbstpruefung. +let available = true; + +/** + * Graustufen, Kontrastspreizung, globaler Schwellwert. + * Glaenzende Metalletiketten liefern flaue, kontrastarme Bilder; ohne diese + * Aufbereitung liest Tesseract dort kaum etwas Brauchbares. + * Rein rechnend - erzeugt kein Canvas, fasst kein DOM an und ist damit + * ohne Browser testbar. + * @param {{width: number, height: number, data: Uint8ClampedArray}} imageData + * @returns {{width: number, height: number, data: Uint8ClampedArray}} + */ +export function preprocess(imageData) { + const { width, height, data } = imageData; + const gray = new Uint8ClampedArray(width * height); + + let min = 255; + let max = 0; + for (let i = 0, p = 0; i < data.length; i += 4, p += 1) { + const value = Math.round(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]); + gray[p] = value; + if (value < min) min = value; + if (value > max) max = value; + } + + // Math.max(1, ...) verhindert eine Division durch null, wenn alle Pixel + // gleich hell sind (max === min, z. B. ein leeres oder ueberbelichtetes + // Bild). Das Ergebnis wird dann komplett schwarz - ein kontrastloses Foto + // enthaelt ohnehin keinen lesbaren Text. + const range = Math.max(1, max - min); + const threshold = 128; + const out = new Uint8ClampedArray(data.length); + for (let p = 0; p < gray.length; p += 1) { + const stretched = ((gray[p] - min) * 255) / range; + const value = stretched >= threshold ? 255 : 0; + const i = p * 4; + out[i] = value; + out[i + 1] = value; + out[i + 2] = value; + out[i + 3] = 255; + } + + return { width, height, data: out }; +} + +/** + * Erzeugt den Tesseract-Arbeiter beim ersten Aufruf und liefert danach immer + * dasselbe Versprechen zurueck. Scheitert das Laden, wird der Zwischen- + * speicher geleert, damit der naechste Aufruf einen echten neuen Versuch + * unternimmt, statt dauerhaft an einem fehlgeschlagenen Versprechen + * haengenzubleiben - gleiches Muster wie ensureReady() in barcode.js. + */ +function getWorker() { + if (!workerPromise) { + workerPromise = createWorker('eng') + .then(async (worker) => { + await worker.setParameters({ + // Etiketten enthalten nur Grossbuchstaben, Ziffern und wenige Sonderzeichen. + tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-/. ', + }); + return worker; + }) + .catch((error) => { + workerPromise = null; + throw error; + }); + } + return workerPromise; +} + +/** + * True, solange der letzte Lade- oder Erkennungsversuch nicht fehlgeschlagen + * ist. Kann sich nach einem Fehlschlag wieder erholen, sobald ein weiterer + * Aufruf von runOcr() erfolgreich ist. + * @returns {boolean} + */ +export function isOcrAvailable() { + return available; +} + +/** + * Liest den Klartext eines Etiketts per Texterkennung. + * Scheitert der Aufruf, bleibt die App barcode-only nutzbar: pipeline.js + * faengt den geworfenen Fehler ab, bricht nach einer Zeitgrenze ab und + * behandelt beides wie leeren Text. + * @param {ImageData} imageData + * @returns {Promise} + */ +export async function runOcr(imageData) { + const prepared = preprocess(imageData); + const canvas = document.createElement('canvas'); + canvas.width = prepared.width; + canvas.height = prepared.height; + canvas + .getContext('2d') + .putImageData(new ImageData(prepared.data, prepared.width, prepared.height), 0, 0); + + try { + const worker = await getWorker(); + const { data } = await worker.recognize(canvas); + available = true; + return data.text ?? ''; + } catch (error) { + available = false; + throw error; + } +} diff --git a/test/ocr-preprocess.test.js b/test/ocr-preprocess.test.js new file mode 100644 index 0000000..e72c9dc --- /dev/null +++ b/test/ocr-preprocess.test.js @@ -0,0 +1,38 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { preprocess } from '../src/ocr.js'; + +/** Minimaler ImageData-Ersatz, damit der Test ohne Browser laeuft. */ +function image(pixels) { + const data = new Uint8ClampedArray(pixels.length * 4); + pixels.forEach((value, i) => { + data[i * 4] = value; + data[i * 4 + 1] = value; + data[i * 4 + 2] = value; + data[i * 4 + 3] = 255; + }); + return { width: pixels.length, height: 1, data }; +} + +test('preprocess erzeugt ein reines Schwarz-Weiss-Bild', () => { + const result = preprocess(image([10, 40, 200, 250])); + for (let i = 0; i < result.data.length; i += 4) { + const value = result.data[i]; + assert.ok(value === 0 || value === 255, `Pixel ${i / 4} ist ${value}`); + assert.equal(result.data[i + 1], value); + assert.equal(result.data[i + 2], value); + assert.equal(result.data[i + 3], 255); + } +}); + +test('preprocess trennt dunkle von hellen Pixeln', () => { + const result = preprocess(image([10, 40, 200, 250])); + assert.equal(result.data[0], 0, 'dunkelstes Pixel wird schwarz'); + assert.equal(result.data[12], 255, 'hellstes Pixel wird weiss'); +}); + +test('preprocess laesst Breite und Hoehe unveraendert', () => { + const result = preprocess(image([0, 128, 255])); + assert.equal(result.width, 3); + assert.equal(result.height, 1); +});