Spaces:
Running
Running
| /** | |
| * 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 | |
| */ | |
| ; | |
| 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 }; | |