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:
+122
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user