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