From 78592d03a19bf26a80e6b631fb83543ba5ced8ec Mon Sep 17 00:00:00 2001 From: vchuser Date: Tue, 28 Jul 2026 16:53:42 +0200 Subject: [PATCH] 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) --- src/ocr.js | 79 +++++++++++++++++++++++++++++-------- test/ocr-preprocess.test.js | 42 ++++++++++++++++++++ 2 files changed, 105 insertions(+), 16 deletions(-) diff --git a/src/ocr.js b/src/ocr.js index e270285..f039ff1 100644 --- a/src/ocr.js +++ b/src/ocr.js @@ -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; }) diff --git a/test/ocr-preprocess.test.js b/test/ocr-preprocess.test.js index e72c9dc..2c60069 100644 --- a/test/ocr-preprocess.test.js +++ b/test/ocr-preprocess.test.js @@ -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)); + } +});