Fix: OCR-Schwellwert nach Otsu statt fester Schwellwert nach Kontrastspreizung

Ein einzelnes Reflexpixel (Normalfall bei glaenzenden Metalletiketten)
dominierte bisher Minimum/Maximum der Kontrastspreizung und drueckte
Schrift und Untergrund gemeinsam unter den festen Schwellwert 128 - die
Schrift verschwand. Der Schwellwert wird jetzt per Otsu-Verfahren aus dem
Helligkeits-Histogramm des Bildes selbst bestimmt und bleibt dadurch gegen
einzelne Ausreisser unempfindlich. Ausserdem: fehlendes Kleinbuchstaben-x
in der Tesseract-Zeichenliste ergaenzt (Bestueckungsangabe "4DRx4").

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
vchuser
2026-07-28 16:53:42 +02:00
co-authored by Claude Opus 5
parent 65f699a704
commit 78592d03a1
2 changed files with 105 additions and 16 deletions
+63 -16
View File
@@ -17,9 +17,64 @@ let workerPromise = null;
let available = true;
/**
* Graustufen, Kontrastspreizung, globaler Schwellwert.
* Glaenzende Metalletiketten liefern flaue, kontrastarme Bilder; ohne diese
* Aufbereitung liest Tesseract dort kaum etwas Brauchbares.
* 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.
* Rein rechnend - erzeugt kein Canvas, fasst kein DOM an und ist damit
* ohne Browser testbar.
* @param {{width: number, height: number, data: Uint8ClampedArray}} imageData
@@ -28,26 +83,18 @@ let available = true;
export function preprocess(imageData) {
const { width, height, data } = imageData;
const gray = new Uint8ClampedArray(width * height);
const histogram = new Uint32Array(256);
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;
histogram[value] += 1;
}
// 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 threshold = otsuThreshold(histogram, gray.length);
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 value = gray[p] >= threshold ? 255 : 0;
const i = p * 4;
out[i] = value;
out[i + 1] = value;
@@ -71,7 +118,7 @@ function getWorker() {
.then(async (worker) => {
await worker.setParameters({
// Etiketten enthalten nur Grossbuchstaben, Ziffern und wenige Sonderzeichen.
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-/. ',
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-/. x',
});
return worker;
})