feat(ocr): adaptive binarisation + per-word confidence filter

Two layers to stop tesseract hallucinating text on phone photos:

1. Bradley adaptive threshold (O(N) via integral images) turns the
   downscaled grayscale image into a binary B/W mask. Cuts out
   gradients, shadows, and surface texture that tesseract
   misreads as ink.
2. After recognise(), filter data.words by confidence >= 60 and
   re-assemble lines from kept words. Status line surfaces how
   many were dropped so the cost is visible.

Output of preprocess stage is PNG (not JPEG) so the sharp B/W
edges don't get re-blurred.
This commit is contained in:
João Pedro Battistella Nadas
2026-06-09 18:54:27 +02:00
parent dcac01e51b
commit 4d4e351860
2 changed files with 92 additions and 18 deletions

View File

@@ -329,30 +329,97 @@
scanStatus.classList.toggle("error", error);
}
// Downscale to at most OCR_MAX_DIM on the long edge. A typical phone
// photo is 4000+ px on the long edge — Tesseract is 4-8× faster at
// 1600 px with no real accuracy loss for printed text.
// Downscale to at most OCR_MAX_DIM on the long edge AND binarise with
// Bradley's adaptive threshold. Phone photos have gradients, shadows
// and texture that Tesseract trained on scanned pages doesn't expect,
// so it hallucinates text. Adaptive thresholding turns the input into
// a binary "ink vs paper" mask which is what the recogniser wants.
const OCR_MAX_DIM = 1600;
async function downscaleForOCR(file) {
const OCR_CONFIDENCE_MIN = 60; // word-level confidence (0-100)
async function preprocessForOCR(file) {
const bitmap = await createImageBitmap(file);
const long = Math.max(bitmap.width, bitmap.height);
if (long <= OCR_MAX_DIM) {
bitmap.close?.();
return { blob: file, w: bitmap.width, h: bitmap.height, scaled: false };
}
const scale = OCR_MAX_DIM / long;
const scale = Math.min(1, OCR_MAX_DIM / long);
const w = Math.round(bitmap.width * scale);
const h = Math.round(bitmap.height * scale);
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
ctx.drawImage(bitmap, 0, 0, w, h);
bitmap.close?.();
const blob = await new Promise(resolve =>
canvas.toBlob(resolve, "image/jpeg", 0.85)
);
return { blob, w, h, scaled: true, origLong: long };
binarizeAdaptive(ctx, w, h);
// PNG, not JPEG — JPEG would re-blur the freshly-sharp B/W edges.
const blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png"));
return { blob, w, h, scaled: scale < 1, origLong: long };
}
// Bradley/Roth adaptive threshold: compare each pixel against the
// mean of a local window. Computed in O(N) total via an integral
// image. Output is a 3-channel B/W image (R=G=B=0 or 255).
function binarizeAdaptive(ctx, w, h) {
const imgData = ctx.getImageData(0, 0, w, h);
const data = imgData.data;
// 1. Grayscale via BT.601 luminance
const gray = new Uint8Array(w * h);
for (let i = 0, j = 0; i < data.length; i += 4, j++) {
gray[j] = (data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114) | 0;
}
// 2. Integral image (summed-area table)
const integral = new Float64Array(w * h);
for (let y = 0; y < h; y++) {
let rowSum = 0;
const yw = y * w;
for (let x = 0; x < w; x++) {
rowSum += gray[yw + x];
integral[yw + x] = rowSum + (y > 0 ? integral[yw - w + x] : 0);
}
}
// 3. Threshold: pixel becomes black if it's > T% below the local mean
const winSize = Math.max(15, Math.floor(w / 32)) | 1; // odd window
const halfWin = (winSize - 1) >> 1;
const T = 0.15;
for (let y = 0; y < h; y++) {
const yw = y * w;
const y1 = Math.max(0, y - halfWin);
const y2 = Math.min(h - 1, y + halfWin);
for (let x = 0; x < w; x++) {
const x1 = Math.max(0, x - halfWin);
const x2 = Math.min(w - 1, x + halfWin);
const count = (x2 - x1 + 1) * (y2 - y1 + 1);
const A = (x1 > 0 && y1 > 0) ? integral[(y1 - 1) * w + (x1 - 1)] : 0;
const B = (y1 > 0) ? integral[(y1 - 1) * w + x2] : 0;
const C = (x1 > 0) ? integral[y2 * w + (x1 - 1)] : 0;
const D = integral[y2 * w + x2];
const mean = (D - B - C + A) / count;
const black = gray[yw + x] < mean * (1 - T) ? 0 : 255;
const idx = (yw + x) * 4;
data[idx] = data[idx + 1] = data[idx + 2] = black;
}
}
ctx.putImageData(imgData, 0, 0);
}
// Drop words below confidence threshold; re-assemble preserving line breaks.
function filterByConfidence(result, threshold = OCR_CONFIDENCE_MIN) {
const lines = result.lines || [];
if (!lines.length) return result.text || "";
const keptLines = lines.map(line => {
const words = (line.words || [])
.filter(w => (w.confidence ?? 100) >= threshold)
.map(w => w.text);
return words.length ? words.join(" ") : "";
});
// Collapse consecutive blank lines so dropped runs don't leave gaps.
return keptLines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
}
async function runOCR(file) {
@@ -364,9 +431,11 @@
const t0 = performance.now();
try {
const { blob, w, h, scaled, origLong } = await downscaleForOCR(file);
const { blob, w, h, scaled, origLong } = await preprocessForOCR(file);
if (scaled) {
setScanStatus(`Downscaled ${origLong}px → ${Math.max(w, h)}px`);
setScanStatus(`Preprocessed ${origLong}px → ${Math.max(w, h)}px (binarised)`);
} else {
setScanStatus(`Preprocessed ${Math.max(w, h)}px (binarised)`);
}
// Lazy import — first scan triggers the ~5 MB JS + language pack
@@ -381,15 +450,18 @@
}
},
});
const { data: { text } } = await worker.recognize(blob);
const { data } = await worker.recognize(blob);
await worker.terminate();
const text = filterByConfidence(data);
const droppedWords = (data.words || []).filter(w => (w.confidence ?? 100) < OCR_CONFIDENCE_MIN).length;
editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: text.trim() },
changes: { from: 0, to: editor.state.doc.length, insert: text },
selection: { anchor: 0 },
});
const dt = ((performance.now() - t0) / 1000).toFixed(1);
setScanStatus(`OCR done in ${dt}s (${lang})`);
const dropped = droppedWords ? `, dropped ${droppedWords} low-confidence` : "";
setScanStatus(`OCR done in ${dt}s (${lang}${dropped})`);
} catch (e) {
console.error(e);
setScanStatus(`OCR failed: ${e.message || e}`, { error: true });