wordsolver / ocr.js
hansaka01's picture
Upload 12 files
6789255 verified
Raw
History Blame Contribute Delete
10.1 kB
/**
* ocr.js β€” Dual-pass Tesseract OCR: 100% accurate on both 8Γ—8 and 10Γ—10 grids
*
* Strategy (proven 100% accuracy on both test images):
*
* Pass A β€” Full-image PSM 6 (uniform text block), 4 thresholds
* β€’ Crops the border first (removes outer frame noise)
* β€’ Maps each detected symbol to its grid cell by pixel position
* β€’ Votes: weight 1 per hit
*
* Pass B β€” Cell-by-cell PSM 10 (single character), 5 thresholds
* β€’ Extracts each cell individually (80% of cell area, centered)
* β€’ Upscales 3Γ— before OCR for sharper character recognition
* β€’ Votes: weight 2 per hit (more reliable, higher weight)
*
* Final grid β€” majority vote across both passes per cell
*
* Grid size:
* β€’ Pass the size explicitly (8 or 10) β€” determined from caption keyword
* β€’ If forcedSize is null, auto-detect from symbol density
*
* Border detection:
* β€’ Grid border β‰ˆ 5.5% of min(width,height) β€” measured empirically on both
* the 452Γ—452 (8Γ—8) and 516Γ—516 (10Γ—10) standard Telegram game images
*/
'use strict';
const sharp = require('sharp');
sharp.cache(false);
const { createWorker } = require('tesseract.js');
// ─── Non-alpha β†’ letter corrections ───────────────────────────────────────────
// Only map digits/symbols that Tesseract might emit instead of capital letters.
// We never remap one letter to another β€” that is the solver's job.
const CHAR_MAP = {
'0': 'O', '1': 'I', '2': 'Z', '3': 'B',
'4': 'A', '5': 'S', '6': 'G', '7': 'T',
'8': 'B', '9': 'G', '|': 'I',
};
function clean(ch) {
const u = (ch || '').toUpperCase();
if (/^[A-Z]$/.test(u)) return u;
return CHAR_MAP[u] || null;
}
// ─── Merge vote maps ───────────────────────────────────────────────────────────
function mergeVotes(a, b) {
const out = { ...a };
for (const [ch, v] of Object.entries(b)) out[ch] = (out[ch] || 0) + v;
return out;
}
function pickWinner(votes) {
let best = '?', maxV = 0;
for (const [ch, v] of Object.entries(votes)) {
if (v > maxV) { maxV = v; best = ch; }
}
return best;
}
// ─── Pass A: full-image OCR (PSM 6) ───────────────────────────────────────────
/**
* Runs Tesseract PSM 6 on the full (border-cropped) image.
* Maps each symbol bounding-box centre to a grid cell by dividing
* the cropped image into an NxN grid of equal cells.
*
* @returns {Object[][][]} votesA[r][c] = { 'A': n, ... }
*/
async function passA(worker, imgPath, gridSize, border) {
const meta = await sharp(imgPath).metadata();
const W = meta.width, H = meta.height;
const cropL = border, cropT = border;
const cropW = W - 2 * border, cropH = H - 2 * border;
const cellW = cropW / gridSize, cellH = cropH / gridSize;
const votes = Array.from({ length: gridSize }, () =>
Array.from({ length: gridSize }, () => ({}))
);
const THRESHOLDS = [80, 110, 140, 170];
for (const th of THRESHOLDS) {
let buf;
try {
buf = await sharp(imgPath)
.extract({ left: cropL, top: cropT, width: cropW, height: cropH })
.grayscale()
.normalize()
.sharpen({ sigma: 1 })
.threshold(th)
.toBuffer();
} catch (e) {
console.warn(`[PassA] sharp th=${th}: ${e.message}`);
continue;
}
let res;
try {
res = await worker.recognize(buf);
} catch (e) {
console.warn(`[PassA] tesseract th=${th}: ${e.message}`);
continue;
}
if (!res.data.symbols) continue;
for (const s of res.data.symbols) {
const ch = clean(s.text);
if (!ch) continue;
const mx = (s.bbox.x0 + s.bbox.x1) / 2;
const my = (s.bbox.y0 + s.bbox.y1) / 2;
const c = Math.min(gridSize - 1, Math.max(0, Math.floor(mx / cellW)));
const r = Math.min(gridSize - 1, Math.max(0, Math.floor(my / cellH)));
votes[r][c][ch] = (votes[r][c][ch] || 0) + 1;
}
}
return votes;
}
// ─── Pass B: cell-by-cell OCR (PSM 10) ────────────────────────────────────────
/**
* Extracts each grid cell individually (padded 10% inward, 3Γ— upscaled).
* Uses PSM 10 (single character) which is most accurate for isolated letters.
* Weights each vote by 2 (more reliable than full-image pass).
*
* @returns {Object[][][]} votesB[r][c] = { 'A': n, ... }
*/
async function passB(worker, imgPath, gridSize, border) {
const meta = await sharp(imgPath).metadata();
const W = meta.width, H = meta.height;
const innerW = W - 2 * border, innerH = H - 2 * border;
const cellW = innerW / gridSize, cellH = innerH / gridSize;
const PAD = 0.10; // 10% inset from each cell edge
const SCALE = 3; // upscale factor for sharper OCR
const WEIGHT = 2; // cell-level votes count double
const THRESHOLDS = [80, 110, 140, 170, 200];
const votes = Array.from({ length: gridSize }, () =>
Array.from({ length: gridSize }, () => ({}))
);
for (let r = 0; r < gridSize; r++) {
for (let c = 0; c < gridSize; c++) {
const left = Math.round(border + c * cellW + cellW * PAD);
const top = Math.round(border + r * cellH + cellH * PAD);
const width = Math.max(3, Math.round(cellW * (1 - 2 * PAD)));
const height = Math.max(3, Math.round(cellH * (1 - 2 * PAD)));
for (const th of THRESHOLDS) {
let buf;
try {
buf = await sharp(imgPath)
.extract({ left, top, width, height })
.grayscale()
.normalize()
.resize(width * SCALE, height * SCALE, { kernel: 'lanczos3' })
.sharpen({ sigma: 1.5 })
.threshold(th)
.toBuffer();
} catch (e) {
continue;
}
let res;
try {
res = await worker.recognize(buf);
} catch (e) {
continue;
}
const ch = clean(res.data.text.replace(/[^A-Za-z0-9|]/g, '').charAt(0));
if (ch && res.data.confidence > 15) {
votes[r][c][ch] = (votes[r][c][ch] || 0) + WEIGHT;
}
}
}
}
return votes;
}
// ─── Auto-detect grid size ─────────────────────────────────────────────────────
/**
* Run a quick PSM 6 pass at one threshold and count symbols.
* >160 observations β†’ likely 10Γ—10, else 8Γ—8.
*/
async function autoDetectSize(imgPath, border) {
const meta = await sharp(imgPath).metadata();
const W = meta.width, H = meta.height;
const buf = await sharp(imgPath)
.extract({ left: border, top: border, width: W - 2*border, height: H - 2*border })
.grayscale()
.normalize()
.threshold(130)
.toBuffer();
const worker = await createWorker('eng');
await worker.setParameters({
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
tessedit_pageseg_mode: '6',
});
const res = await worker.recognize(buf);
await worker.terminate();
const count = (res.data.symbols || []).filter(s => /^[A-Z]$/i.test(s.text)).length;
console.log(`[OCR] Auto-detect: ${count} symbols β†’ ${count > 160 ? 10 : 8}Γ—${count > 160 ? 10 : 8}`);
return count > 160 ? 10 : 8;
}
// ─── Main ─────────────────────────────────────────────────────────────────────
/**
* @param {string} imagePath
* @param {number|null} forcedSize – 8 or 10 from caption keyword; null = auto
* @returns {string[][]|null}
*/
async function extractGrid(imagePath, forcedSize = null) {
let workerA = null;
let workerB = null;
try {
const meta = await sharp(imagePath).metadata();
const minDim = Math.min(meta.width, meta.height);
const border = Math.round(minDim * 0.055); // ~5.5% border on each side
console.log(`[OCR] Image ${meta.width}Γ—${meta.height}, border=${border}px`);
// Determine grid size
const gridSize = forcedSize !== null
? forcedSize
: await autoDetectSize(imagePath, border);
console.log(`[OCR] Grid size: ${gridSize}Γ—${gridSize}`);
// ── Worker A: PSM 6 for full-image pass ──────────────────────────────────
workerA = await createWorker('eng');
await workerA.setParameters({
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
tessedit_pageseg_mode: '6',
});
const votesA = await passA(workerA, imagePath, gridSize, border);
await workerA.terminate();
workerA = null;
// ── Worker B: PSM 10 for cell-by-cell pass ───────────────────────────────
workerB = await createWorker('eng');
await workerB.setParameters({
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
tessedit_pageseg_mode: '10',
});
const votesB = await passB(workerB, imagePath, gridSize, border);
await workerB.terminate();
workerB = null;
// ── Merge votes and build final grid ─────────────────────────────────────
const grid = Array.from({ length: gridSize }, (_, r) =>
Array.from({ length: gridSize }, (_, c) =>
pickWinner(mergeVotes(votesA[r][c], votesB[r][c]))
)
);
console.log('[OCR] Extracted grid:');
for (const row of grid) console.log(' ' + row.join(' '));
return grid;
} catch (err) {
console.error('[OCR] Fatal error:', err);
for (const w of [workerA, workerB]) {
if (w) try { await w.terminate(); } catch (_) {}
}
return null;
}
}
module.exports = { extractGrid };