Download ocr.js from wgsolve/wgsrobot: direct link, hf CLI and curl.
- Browser
- Download file 16.3 kB
-
https://huggingface.co/spaces/wgsolve/wgsrobot/resolve/main/ocr.js
- Command line
-
hf download hf://spaces/wgsolve/wgsrobot/ocr.js
-
curl -L -o ocr.js https://huggingface.co/spaces/wgsolve/wgsrobot/resolve/main/ocr.js
16.3 kB
| /** | |
| * ocr.js β Dual-pass Tesseract OCR for word grid images | |
| * | |
| * Supports BOTH colour schemes automatically: | |
| * β’ Light background, dark letters (white/grey BG, black letters) | |
| * β’ Dark background, light letters (black BG, white letters) | |
| * | |
| * Strategy: | |
| * Pass A β Full-image PSM 6 (uniform text block), multiple thresholds | |
| * β’ Crops the border first (removes outer frame noise) | |
| * β’ Maps each detected symbol bbox-centre to its grid cell | |
| * β’ Weight 1 per vote | |
| * | |
| * Pass B β Cell-by-cell PSM 10 (single character), multiple thresholds | |
| * β’ Extracts each cell individually, 3Γ upscaled | |
| * β’ Weight 2 per vote (cell-level is more reliable) | |
| * | |
| * Final β majority vote per cell across both passes | |
| * | |
| * Background detection: | |
| * Samples the mean pixel value of the border region. | |
| * If mean < 128 β dark background β negate before thresholding | |
| * so Tesseract always receives black-text-on-white. | |
| */ | |
| ; | |
| const sharp = require('sharp'); | |
| sharp.cache(false); | |
| const { createWorker } = require('tesseract.js'); | |
| // βββ Character normalisation βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Map digits/symbols Tesseract sometimes emits to their closest letter. | |
| // 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; | |
| } | |
| // βββ Vote helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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; | |
| } | |
| // βββ Background detection ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** | |
| * Detect whether the image has a dark background. | |
| * Samples a thin ring just inside the border region and computes mean luminance. | |
| * Returns true if background is dark (mean < 128) β need to negate for Tesseract. | |
| * | |
| * @param {string} imgPath | |
| * @param {number} border β border thickness in pixels | |
| * @returns {Promise<boolean>} | |
| */ | |
| async function isDarkBackground(imgPath, border) { | |
| try { | |
| const meta = await sharp(imgPath).metadata(); | |
| const W = meta.width, H = meta.height; | |
| // Sample the four corner cells of the grid border area | |
| // Use a small strip just inside the outer border | |
| const sampleSize = Math.max(4, Math.round(border * 0.8)); | |
| // Top-left corner sample | |
| const sample = await sharp(imgPath) | |
| .extract({ | |
| left: Math.max(0, border - sampleSize), | |
| top: Math.max(0, border - sampleSize), | |
| width: sampleSize * 2, | |
| height: sampleSize * 2, | |
| }) | |
| .grayscale() | |
| .raw() | |
| .toBuffer(); | |
| const mean = sample.reduce((s, v) => s + v, 0) / sample.length; | |
| const dark = mean < 128; | |
| console.log(`[OCR] Background mean luminance: ${mean.toFixed(1)} β ${dark ? 'DARK (will negate)' : 'LIGHT'}`); | |
| return dark; | |
| } catch (e) { | |
| console.warn('[OCR] Background detection failed, assuming light:', e.message); | |
| return false; | |
| } | |
| } | |
| // βββ Sharp pipeline builder ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** | |
| * Build a preprocessed image buffer from a region of the source image. | |
| * Handles both light and dark backgrounds: | |
| * - Dark BG: negate BEFORE threshold so letters become dark on light BG | |
| * - Light BG: threshold directly | |
| * | |
| * @param {string} imgPath | |
| * @param {object} region β { left, top, width, height } | |
| * @param {number} threshold β binarisation threshold (0-255) | |
| * @param {boolean} darkBg β true if image has dark background | |
| * @param {number} scale β upscale factor (1 = no scaling) | |
| * @returns {Promise<Buffer>} | |
| */ | |
| async function buildBuf(imgPath, region, threshold, darkBg, scale = 1) { | |
| let pipeline = sharp(imgPath).extract(region).grayscale().normalize(); | |
| if (darkBg) { | |
| // Negate so white letters become black β Tesseract needs black text on white | |
| pipeline = pipeline.negate(); | |
| } | |
| pipeline = pipeline.sharpen({ sigma: 1.2 }); | |
| if (scale > 1) { | |
| pipeline = pipeline.resize( | |
| region.width * scale, | |
| region.height * scale, | |
| { kernel: 'lanczos3' } | |
| ); | |
| } | |
| pipeline = pipeline.threshold(threshold); | |
| return pipeline.toBuffer(); | |
| } | |
| // βββ Pass A: full-image OCR (PSM 6) βββββββββββββββββββββββββββββββββββββββββββ | |
| async function passA(worker, imgPath, gridSize, border, darkBg) { | |
| const meta = await sharp(imgPath).metadata(); | |
| const W = meta.width, H = meta.height; | |
| const cropL = border, cropT = border; | |
| const cropW = W - 2 * border; | |
| const cropH = H - 2 * border; | |
| const cellW = cropW / gridSize; | |
| const cellH = cropH / gridSize; | |
| const votes = Array.from({ length: gridSize }, () => | |
| Array.from({ length: gridSize }, () => ({})) | |
| ); | |
| // Use thresholds on the light side β after negate (dark BG) or direct (light BG) | |
| const THRESHOLDS = [80, 110, 140, 170]; | |
| for (const th of THRESHOLDS) { | |
| let buf; | |
| try { | |
| buf = await buildBuf( | |
| imgPath, | |
| { left: cropL, top: cropT, width: cropW, height: cropH }, | |
| th, darkBg, 1 | |
| ); | |
| } catch (e) { | |
| console.warn(`[PassA] preprocess 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) ββββββββββββββββββββββββββββββββββββββββ | |
| async function passB(worker, imgPath, gridSize, border, darkBg) { | |
| const meta = await sharp(imgPath).metadata(); | |
| const W = meta.width, H = meta.height; | |
| const innerW = W - 2 * border; | |
| const innerH = H - 2 * border; | |
| const cellW = innerW / gridSize; | |
| const cellH = innerH / gridSize; | |
| const PAD = 0.10; // 10% inset from each cell edge | |
| const SCALE = 3; // upscale 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 buildBuf(imgPath, { left, top, width, height }, th, darkBg, SCALE); | |
| } catch (e) { | |
| continue; | |
| } | |
| let res; | |
| try { | |
| res = await worker.recognize(buf); | |
| } catch (e) { | |
| continue; | |
| } | |
| const rawCh = (res.data.text || '').replace(/[^A-Za-z0-9|]/g, '').charAt(0); | |
| const ch = clean(rawCh); | |
| if (ch && res.data.confidence > 15) { | |
| votes[r][c][ch] = (votes[r][c][ch] || 0) + WEIGHT; | |
| } | |
| } | |
| } | |
| } | |
| return votes; | |
| } | |
| // βββ Auto-detect grid size βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function autoDetectSize(imgPath, border, darkBg) { | |
| const meta = await sharp(imgPath).metadata(); | |
| const W = meta.width, H = meta.height; | |
| let buf; | |
| try { | |
| buf = await buildBuf( | |
| imgPath, | |
| { left: border, top: border, width: W - 2 * border, height: H - 2 * border }, | |
| 130, darkBg, 1 | |
| ); | |
| } catch (e) { | |
| console.warn('[OCR] autoDetect preprocess failed:', e.message); | |
| return 8; | |
| } | |
| 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; | |
| const size = count > 160 ? 10 : 8; | |
| console.log(`[OCR] Auto-detect: ${count} symbols β ${size}Γ${size}`); | |
| return size; | |
| } | |
| // βββ Main βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** | |
| * @param {string} imagePath | |
| * @param {number|null} forcedSize β 8 or 10; null = auto-detect | |
| * @returns {Promise<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); | |
| console.log(`[OCR] Image ${meta.width}Γ${meta.height}, border=${border}px`); | |
| // ββ Detect background colour scheme ββββββββββββββββββββββββββββββββββββββ | |
| const darkBg = await isDarkBackground(imagePath, border); | |
| // ββ Determine grid size βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const gridSize = (forcedSize !== null) | |
| ? forcedSize | |
| : await autoDetectSize(imagePath, border, darkBg); | |
| console.log(`[OCR] Grid size: ${gridSize}Γ${gridSize}`); | |
| // ββ Pass A: full-image PSM 6 ββββββββββββββββββββββββββββββββββββββββββββββ | |
| workerA = await createWorker('eng'); | |
| await workerA.setParameters({ | |
| tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', | |
| tessedit_pageseg_mode: '6', | |
| }); | |
| const votesA = await passA(workerA, imagePath, gridSize, border, darkBg); | |
| await workerA.terminate(); | |
| workerA = null; | |
| // ββ Pass B: cell-by-cell PSM 10 βββββββββββββββββββββββββββββββββββββββββββ | |
| workerB = await createWorker('eng'); | |
| await workerB.setParameters({ | |
| tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', | |
| tessedit_pageseg_mode: '10', | |
| }); | |
| const votesB = await passB(workerB, imagePath, gridSize, border, darkBg); | |
| await workerB.terminate(); | |
| workerB = null; | |
| // ββ Merge votes βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const mergedVotes = Array.from({ length: gridSize }, (_, r) => | |
| Array.from({ length: gridSize }, (_, c) => | |
| mergeVotes(votesA[r][c], votesB[r][c]) | |
| ) | |
| ); | |
| // ββ Pass C: rescue unknown cells ββββββββββββββββββββββββββββββββββββββββββ | |
| // For any cell that is still '?' after the two main passes, run an | |
| // aggressive extra-contrast re-try with more threshold variants. | |
| // This handles thin letters like I/L on dark backgrounds. | |
| const meta2 = await sharp(imagePath).metadata(); | |
| const innerW2 = meta2.width - 2 * border; | |
| const innerH2 = meta2.height - 2 * border; | |
| const cellW2 = innerW2 / gridSize; | |
| const cellH2 = innerH2 / gridSize; | |
| let rescueWorker = null; | |
| const unknownCells = []; | |
| for (let r = 0; r < gridSize; r++) { | |
| for (let c = 0; c < gridSize; c++) { | |
| if (pickWinner(mergedVotes[r][c]) === '?') unknownCells.push({ r, c }); | |
| } | |
| } | |
| if (unknownCells.length > 0) { | |
| console.log(`[OCR] PassC: rescuing ${unknownCells.length} unknown cell(s)...`); | |
| // Try multiple PSM modes β thin letters like I/L need PSM 7 or 8 | |
| const RESCUE_PSM_MODES = ['10', '7', '8', '13']; | |
| const RESCUE_THRESHOLDS = [50, 70, 90, 110, 130, 150, 170, 190, 210, 230]; | |
| const RESCUE_SCALE = 6; // larger upscale for thin single-stroke characters | |
| for (const psmMode of RESCUE_PSM_MODES) { | |
| rescueWorker = await createWorker('eng'); | |
| await rescueWorker.setParameters({ | |
| tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', | |
| tessedit_pageseg_mode: psmMode, | |
| }); | |
| for (const { r, c } of unknownCells) { | |
| // Skip if already resolved in a previous PSM pass | |
| if (pickWinner(mergedVotes[r][c]) !== '?') continue; | |
| // Use full cell (minimal padding) for rescue to capture thin strokes | |
| const left = Math.round(border + c * cellW2 + cellW2 * 0.02); | |
| const top = Math.round(border + r * cellH2 + cellH2 * 0.02); | |
| const width = Math.max(3, Math.round(cellW2 * 0.96)); | |
| const height = Math.max(3, Math.round(cellH2 * 0.96)); | |
| for (const th of RESCUE_THRESHOLDS) { | |
| try { | |
| const buf = await buildBuf( | |
| imagePath, { left, top, width, height }, | |
| th, darkBg, RESCUE_SCALE | |
| ); | |
| const res = await rescueWorker.recognize(buf); | |
| const rawCh = (res.data.text || '').replace(/[^A-Za-z0-9|]/g, '').charAt(0); | |
| const ch = clean(rawCh); | |
| // Only accept high-confidence votes in rescue pass to avoid noise | |
| if (ch && res.data.confidence > 40) { | |
| mergedVotes[r][c][ch] = (mergedVotes[r][c][ch] || 0) + 1; | |
| } | |
| } catch (_) {} | |
| } | |
| } | |
| await rescueWorker.terminate(); | |
| rescueWorker = null; | |
| } | |
| for (const { r, c } of unknownCells) { | |
| console.log(`[OCR] PassC cell[${r}][${c}]: votes=${JSON.stringify(mergedVotes[r][c])} β ${pickWinner(mergedVotes[r][c])}`); | |
| } | |
| } | |
| // ββ Build final grid ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const grid = Array.from({ length: gridSize }, (_, r) => | |
| Array.from({ length: gridSize }, (_, c) => | |
| pickWinner(mergedVotes[r][c]) | |
| ) | |
| ); | |
| console.log('[OCR] Extracted grid:'); | |
| for (const row of grid) console.log(' ' + row.join(' ')); | |
| // Sanity check: if more than 40% of cells are '?' β likely failed | |
| const totalCells = gridSize * gridSize; | |
| const unknowns = grid.flat().filter(c => c === '?').length; | |
| if (unknowns > totalCells * 0.4) { | |
| console.error(`[OCR] Too many unknown cells (${unknowns}/${totalCells}) β extraction unreliable`); | |
| return null; | |
| } | |
| 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 }; | |