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;
})
+42
View File
@@ -36,3 +36,45 @@ test('preprocess laesst Breite und Hoehe unveraendert', () => {
assert.equal(result.width, 3);
assert.equal(result.height, 1);
});
test('preprocess trennt Schrift vom Untergrund trotz einzelnem Reflexpixel', () => {
// Untergrund 60, Schrift 80, dazu ein einzelnes Reflexpixel 255.
// Ein fester Schwellwert nach Kontrastspreizung reisst hier ab: das eine
// Reflexpixel dominiert Minimum/Maximum und drueckt Untergrund und Schrift
// gemeinsam unter den Schwellwert - die Schrift verschwindet.
//
// Untergrund und Schrift stehen hier zu gleichen Teilen im Bild (je 200
// Pixel), so wie auf einem echten Foto Untergrund- und Schriftflaechen
// die grosse Masse der Pixel stellen und ein einzelner Reflex nur ein
// einzelnes Pixel unter vielen ist. Bei einem Miniaturbild mit nur ein-
// oder zweistelliger Pixelzahl waere der Reflex dagegen ein spuerbarer
// Bruchteil des gesamten Histogramms; dann kann selbst Otsu ihn nicht
// von echten Bildmerkmalen unterscheiden. Das ist keine Schwaeche des
// Verfahrens, sondern der Grund, warum die Aussage "ein einzelnes Pixel
// verschiebt das Histogramm kaum" erst bei realistischer Pixelzahl gilt.
const background = new Array(200).fill(60);
const text = new Array(200).fill(80);
const reflex = [255];
const result = preprocess(image([...background, ...text, ...reflex]));
assert.equal(result.data[0], 0, 'Untergrund bleibt schwarz');
assert.equal(result.data[199 * 4], 0, 'letztes Untergrundpixel bleibt schwarz');
assert.equal(result.data[200 * 4], 255, 'erstes Schriftpixel wird weiss');
assert.equal(result.data[399 * 4], 255, 'letztes Schriftpixel wird weiss');
});
test('preprocess liefert bei einem Bild ohne Pixel kein NaN und wirft nicht', () => {
const result = preprocess(image([]));
assert.equal(result.width, 0);
assert.equal(result.height, 1);
assert.equal(result.data.length, 0);
});
test('preprocess wirft nicht, wenn alle Pixel dieselbe Helligkeit haben', () => {
const result = preprocess(image([128, 128, 128, 128]));
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.ok(!Number.isNaN(value));
}
});