diff --git a/src/ocr.js b/src/ocr.js index a73222a..bbca8b8 100644 --- a/src/ocr.js +++ b/src/ocr.js @@ -1,4 +1,5 @@ import { createWorker } from 'tesseract.js'; +import { computeOtsuThreshold } from './threshold.js'; // Der einmal erzeugte Erkennungs-Arbeiter wird als Versprechen zwischen- // gespeichert, damit sich auch mehrere schnell hintereinander gestartete @@ -16,65 +17,15 @@ let workerPromise = null; // Oberflaeche verschwinden lassen - siehe Bericht, Abschnitt Selbstpruefung. let available = true; -/** - * Bestimmt den Otsu-Schwellwert aus einem Histogramm der 256 Helligkeits- - * stufen. Otsu sucht den Schwellwert, der die Pixel in zwei Gruppen teilt - * (dunkler / heller) und dabei die Streuung innerhalb jeder Gruppe minimiert - * bzw. die Streuung zwischen den Gruppen maximiert - beides ist aequivalent - * und wird hier ueber die Zwischen-Klassen-Varianz berechnet. - * Da der Schwellwert aus der gesamten Verteilung stammt, verschiebt ein - * einzelnes Ausreisserpixel (z. B. ein Lichtreflex) das Histogramm kaum und - * damit auch den Schwellwert kaum - anders als eine Min/Max-Spreizung, die - * von genau diesem einen Pixel dominiert wird. - * @param {Uint32Array | number[]} histogram 256 Eintraege, einer je Helligkeitsstufe - * @param {number} totalPixels Anzahl der Pixel, aus denen das Histogramm stammt - * @returns {number} Schwellwert (0-255): Helligkeit darunter wird schwarz, ab hier weiss - */ -function otsuThreshold(histogram, totalPixels) { - if (totalPixels === 0) return 128; - - let sumAll = 0; - for (let level = 0; level < 256; level += 1) { - sumAll += level * histogram[level]; - } - - let sumBackground = 0; - let weightBackground = 0; - let bestVariance = -1; - let bestThreshold = 128; - - for (let level = 0; level < 256; level += 1) { - weightBackground += histogram[level]; - if (weightBackground === 0) continue; - - const weightForeground = totalPixels - weightBackground; - if (weightForeground === 0) break; - - sumBackground += level * histogram[level]; - - const meanBackground = sumBackground / weightBackground; - const meanForeground = (sumAll - sumBackground) / weightForeground; - const meanDiff = meanBackground - meanForeground; - const betweenClassVariance = weightBackground * weightForeground * meanDiff * meanDiff; - - if (betweenClassVariance > bestVariance) { - bestVariance = betweenClassVariance; - bestThreshold = level + 1; - } - } - - return bestThreshold; -} - /** * Graustufen, Otsu-Schwellwert. * Glaenzende Metalletiketten liefern flaue, kontrastarme Bilder mit * Lichtreflexen als Normalfall; ohne diese Aufbereitung liest Tesseract dort * kaum etwas Brauchbares. Der Schwellwert wird aus der Helligkeitsverteilung - * des Bildes selbst bestimmt (Otsu), statt aus einer Kontrastspreizung - * zwischen dunkelstem und hellstem Pixel - ein einzelner Reflex kann so - * nicht mehr Schrift und Untergrund gemeinsam auf dieselbe Seite eines - * festen Schwellwerts ziehen. + * des Bildes selbst bestimmt (Otsu, siehe threshold.js), statt aus einer + * Kontrastspreizung zwischen dunkelstem und hellstem Pixel - ein einzelner + * Reflex kann so nicht mehr Schrift und Untergrund gemeinsam auf dieselbe + * Seite eines festen Schwellwerts ziehen. * Rein rechnend - erzeugt kein Canvas, fasst kein DOM an und ist damit * ohne Browser testbar. * @param {{width: number, height: number, data: Uint8ClampedArray}} imageData @@ -82,16 +33,7 @@ function otsuThreshold(histogram, totalPixels) { */ export function preprocess(imageData) { const { width, height, data } = imageData; - const gray = new Uint8ClampedArray(width * height); - const histogram = new Uint32Array(256); - - 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; - histogram[value] += 1; - } - - const threshold = otsuThreshold(histogram, gray.length); + const { gray, threshold } = computeOtsuThreshold(imageData); const out = new Uint8ClampedArray(data.length); for (let p = 0; p < gray.length; p += 1) { const value = gray[p] >= threshold ? 255 : 0; diff --git a/src/threshold.js b/src/threshold.js new file mode 100644 index 0000000..9f8972d --- /dev/null +++ b/src/threshold.js @@ -0,0 +1,91 @@ +// Otsu-Schwellwertbestimmung: reines Modul ohne jeden Browser-Zugriff (kein +// window/document/canvas). Vorher steckte das ausschließlich in `preprocess` +// in ocr.js - das aber tesseract.js einbindet und damit nichts mit reiner +// Bildauswertung zu tun hat. Sowohl `preprocess` (ocr.js, für die +// Texterkennung) als auch `countObjects` (count-objects.js, für die +// Zähl-Funktion) brauchen denselben ersten Schritt - Graustufen bilden und +// die Helligkeitsverteilung in zwei Klassen teilen - und lesen ihn deshalb +// beide von hier. + +/** + * Bestimmt den Otsu-Schwellwert aus einem Histogramm der 256 Helligkeits- + * stufen. Otsu sucht den Schwellwert, der die Pixel in zwei Gruppen teilt + * (dunkler / heller) und dabei die Streuung innerhalb jeder Gruppe minimiert + * bzw. die Streuung zwischen den Gruppen maximiert - beides ist aequivalent + * und wird hier ueber die Zwischen-Klassen-Varianz berechnet. + * Da der Schwellwert aus der gesamten Verteilung stammt, verschiebt ein + * einzelnes Ausreisserpixel (z. B. ein Lichtreflex) das Histogramm kaum und + * damit auch den Schwellwert kaum - anders als eine Min/Max-Spreizung, die + * von genau diesem einen Pixel dominiert wird. + * @param {Uint32Array | number[]} histogram 256 Eintraege, einer je Helligkeitsstufe + * @param {number} totalPixels Anzahl der Pixel, aus denen das Histogramm stammt + * @returns {number} Schwellwert (0-255): Helligkeit darunter wird schwarz, ab hier weiss + */ +export function otsuThreshold(histogram, totalPixels) { + if (totalPixels === 0) return 128; + + let sumAll = 0; + for (let level = 0; level < 256; level += 1) { + sumAll += level * histogram[level]; + } + + let sumBackground = 0; + let weightBackground = 0; + let bestVariance = -1; + let bestThreshold = 128; + + for (let level = 0; level < 256; level += 1) { + weightBackground += histogram[level]; + if (weightBackground === 0) continue; + + const weightForeground = totalPixels - weightBackground; + if (weightForeground === 0) break; + + sumBackground += level * histogram[level]; + + const meanBackground = sumBackground / weightBackground; + const meanForeground = (sumAll - sumBackground) / weightForeground; + const meanDiff = meanBackground - meanForeground; + const betweenClassVariance = weightBackground * weightForeground * meanDiff * meanDiff; + + if (betweenClassVariance > bestVariance) { + bestVariance = betweenClassVariance; + bestThreshold = level + 1; + } + } + + return bestThreshold; +} + +/** + * Graustufen (gewichtetes RGB, wie menschliche Helligkeitswahrnehmung) und + * das zugehörige 256-stufige Helligkeitshistogramm - der gemeinsame erste + * Schritt für `preprocess` (ocr.js) und `countObjects` (count-objects.js). + * @param {{width: number, height: number, data: Uint8ClampedArray}} imageData + * @returns {{gray: Uint8ClampedArray, histogram: Uint32Array}} + */ +export function grayscaleHistogram(imageData) { + const { data } = imageData; + const gray = new Uint8ClampedArray(data.length / 4); + const histogram = new Uint32Array(256); + + 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; + histogram[value] += 1; + } + + return { gray, histogram }; +} + +/** + * Graustufen und Otsu-Schwellwert in einem Schritt - der gemeinsame + * Ausgangspunkt für `preprocess` und `countObjects`. + * @param {{width: number, height: number, data: Uint8ClampedArray}} imageData + * @returns {{gray: Uint8ClampedArray, threshold: number}} + */ +export function computeOtsuThreshold(imageData) { + const { gray, histogram } = grayscaleHistogram(imageData); + const threshold = otsuThreshold(histogram, gray.length); + return { gray, threshold }; +}