Otsu-Schwellwert in eigenes reines Modul ausgelagert (threshold.js)

Zieht otsuThreshold()/grayscaleHistogram() aus ocr.js in src/threshold.js,
ohne das Verhalten von preprocess() zu aendern (bestehende Tests in
ocr-preprocess.test.js bleiben unveraendert gruen). Vorbereitung fuer die
neue Zaehl-Funktion, die dieselbe Schwellwertbestimmung braucht, aber nichts
mit tesseract.js (ocr.js) zu tun haben soll.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-29 15:47:22 +02:00
co-authored by Claude Opus 5
parent 87ba439287
commit 500f9b6c3d
2 changed files with 97 additions and 64 deletions
+6 -64
View File
@@ -1,4 +1,5 @@
import { createWorker } from 'tesseract.js'; import { createWorker } from 'tesseract.js';
import { computeOtsuThreshold } from './threshold.js';
// Der einmal erzeugte Erkennungs-Arbeiter wird als Versprechen zwischen- // Der einmal erzeugte Erkennungs-Arbeiter wird als Versprechen zwischen-
// gespeichert, damit sich auch mehrere schnell hintereinander gestartete // gespeichert, damit sich auch mehrere schnell hintereinander gestartete
@@ -16,65 +17,15 @@ let workerPromise = null;
// Oberflaeche verschwinden lassen - siehe Bericht, Abschnitt Selbstpruefung. // Oberflaeche verschwinden lassen - siehe Bericht, Abschnitt Selbstpruefung.
let available = true; let available = true;
/**
* 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. * Graustufen, Otsu-Schwellwert.
* Glaenzende Metalletiketten liefern flaue, kontrastarme Bilder mit * Glaenzende Metalletiketten liefern flaue, kontrastarme Bilder mit
* Lichtreflexen als Normalfall; ohne diese Aufbereitung liest Tesseract dort * Lichtreflexen als Normalfall; ohne diese Aufbereitung liest Tesseract dort
* kaum etwas Brauchbares. Der Schwellwert wird aus der Helligkeitsverteilung * kaum etwas Brauchbares. Der Schwellwert wird aus der Helligkeitsverteilung
* des Bildes selbst bestimmt (Otsu), statt aus einer Kontrastspreizung * des Bildes selbst bestimmt (Otsu, siehe threshold.js), statt aus einer
* zwischen dunkelstem und hellstem Pixel - ein einzelner Reflex kann so * Kontrastspreizung zwischen dunkelstem und hellstem Pixel - ein einzelner
* nicht mehr Schrift und Untergrund gemeinsam auf dieselbe Seite eines * Reflex kann so nicht mehr Schrift und Untergrund gemeinsam auf dieselbe
* festen Schwellwerts ziehen. * 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
@@ -82,16 +33,7 @@ function otsuThreshold(histogram, totalPixels) {
*/ */
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, threshold } = computeOtsuThreshold(imageData);
const histogram = new Uint32Array(256);
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;
histogram[value] += 1;
}
const threshold = otsuThreshold(histogram, gray.length);
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 value = gray[p] >= threshold ? 255 : 0; const value = gray[p] >= threshold ? 255 : 0;
+91
View File
@@ -0,0 +1,91 @@
// Otsu-Schwellwertbestimmung: reines Modul ohne jeden Browser-Zugriff (kein
// window/document/canvas). Vorher steckte das ausschließlich in `preprocess`
// in ocr.js - das aber tesseract.js einbindet und damit nichts mit reiner
// Bildauswertung zu tun hat. Sowohl `preprocess` (ocr.js, für die
// Texterkennung) als auch `countObjects` (count-objects.js, für die
// Zähl-Funktion) brauchen denselben ersten Schritt - Graustufen bilden und
// die Helligkeitsverteilung in zwei Klassen teilen - und lesen ihn deshalb
// beide von hier.
/**
* 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
*/
export 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 (gewichtetes RGB, wie menschliche Helligkeitswahrnehmung) und
* das zugehörige 256-stufige Helligkeitshistogramm - der gemeinsame erste
* Schritt für `preprocess` (ocr.js) und `countObjects` (count-objects.js).
* @param {{width: number, height: number, data: Uint8ClampedArray}} imageData
* @returns {{gray: Uint8ClampedArray, histogram: Uint32Array}}
*/
export function grayscaleHistogram(imageData) {
const { data } = imageData;
const gray = new Uint8ClampedArray(data.length / 4);
const histogram = new Uint32Array(256);
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;
histogram[value] += 1;
}
return { gray, histogram };
}
/**
* Graustufen und Otsu-Schwellwert in einem Schritt - der gemeinsame
* Ausgangspunkt für `preprocess` und `countObjects`.
* @param {{width: number, height: number, data: Uint8ClampedArray}} imageData
* @returns {{gray: Uint8ClampedArray, threshold: number}}
*/
export function computeOtsuThreshold(imageData) {
const { gray, histogram } = grayscaleHistogram(imageData);
const threshold = otsuThreshold(histogram, gray.length);
return { gray, threshold };
}