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;
|
let available = true;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Graustufen, Kontrastspreizung, globaler Schwellwert.
|
* Bestimmt den Otsu-Schwellwert aus einem Histogramm der 256 Helligkeits-
|
||||||
* Glaenzende Metalletiketten liefern flaue, kontrastarme Bilder; ohne diese
|
* stufen. Otsu sucht den Schwellwert, der die Pixel in zwei Gruppen teilt
|
||||||
* Aufbereitung liest Tesseract dort kaum etwas Brauchbares.
|
* (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
|
* Rein rechnend - erzeugt kein Canvas, fasst kein DOM an und ist damit
|
||||||
* ohne Browser testbar.
|
* ohne Browser testbar.
|
||||||
* @param {{width: number, height: number, data: Uint8ClampedArray}} imageData
|
* @param {{width: number, height: number, data: Uint8ClampedArray}} imageData
|
||||||
@@ -28,26 +83,18 @@ let available = true;
|
|||||||
export function preprocess(imageData) {
|
export function preprocess(imageData) {
|
||||||
const { width, height, data } = imageData;
|
const { width, height, data } = imageData;
|
||||||
const gray = new Uint8ClampedArray(width * height);
|
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) {
|
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]);
|
const value = Math.round(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]);
|
||||||
gray[p] = value;
|
gray[p] = value;
|
||||||
if (value < min) min = value;
|
histogram[value] += 1;
|
||||||
if (value > max) max = value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Math.max(1, ...) verhindert eine Division durch null, wenn alle Pixel
|
const threshold = otsuThreshold(histogram, gray.length);
|
||||||
// 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);
|
const out = new Uint8ClampedArray(data.length);
|
||||||
for (let p = 0; p < gray.length; p += 1) {
|
for (let p = 0; p < gray.length; p += 1) {
|
||||||
const stretched = ((gray[p] - min) * 255) / range;
|
const value = gray[p] >= threshold ? 255 : 0;
|
||||||
const value = stretched >= threshold ? 255 : 0;
|
|
||||||
const i = p * 4;
|
const i = p * 4;
|
||||||
out[i] = value;
|
out[i] = value;
|
||||||
out[i + 1] = value;
|
out[i + 1] = value;
|
||||||
@@ -71,7 +118,7 @@ function getWorker() {
|
|||||||
.then(async (worker) => {
|
.then(async (worker) => {
|
||||||
await worker.setParameters({
|
await worker.setParameters({
|
||||||
// Etiketten enthalten nur Grossbuchstaben, Ziffern und wenige Sonderzeichen.
|
// Etiketten enthalten nur Grossbuchstaben, Ziffern und wenige Sonderzeichen.
|
||||||
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-/. ',
|
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-/. x',
|
||||||
});
|
});
|
||||||
return worker;
|
return worker;
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -36,3 +36,45 @@ test('preprocess laesst Breite und Hoehe unveraendert', () => {
|
|||||||
assert.equal(result.width, 3);
|
assert.equal(result.width, 3);
|
||||||
assert.equal(result.height, 1);
|
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