feat: OCR-Adapter mit Bildaufbereitung

preprocess() rechnet rein (Graustufen, Kontrastspreizung, Schwellwert) ohne
DOM-Zugriff und ist damit ohne Browser testbar. runOcr() ist der Browser-
Adapter, cacht den Tesseract-Arbeiter und laesst isOcrAvailable() sich nach
einem Fehlschlag wieder erholen statt dauerhaft einzufrieren - analog zum
ensureReady()-Muster in barcode.js.
This commit is contained in:
vchuser
2026-07-28 16:43:49 +02:00
parent 87d7f7c393
commit 65f699a704
2 changed files with 160 additions and 0 deletions
+122
View File
@@ -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<string>}
*/
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;
}
}