| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 'use strict'; |
|
|
| const sharp = require('sharp'); |
| sharp.cache(false); |
| const { createWorker } = require('tesseract.js'); |
|
|
| |
| |
| |
| 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; |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function isDarkBackground(imgPath, border) { |
| try { |
| const meta = await sharp(imgPath).metadata(); |
| const W = meta.width, H = meta.height; |
|
|
| |
| |
| const sampleSize = Math.max(4, Math.round(border * 0.8)); |
|
|
| |
| 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; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function buildBuf(imgPath, region, threshold, darkBg, scale = 1) { |
| let pipeline = sharp(imgPath).extract(region).grayscale().normalize(); |
|
|
| if (darkBg) { |
| |
| 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(); |
| } |
|
|
| |
| 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 }, () => ({})) |
| ); |
|
|
| |
| 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; |
| } |
|
|
| |
| 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; |
| const SCALE = 3; |
| const WEIGHT = 2; |
|
|
| 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; |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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`); |
|
|
| |
| const darkBg = await isDarkBackground(imagePath, border); |
|
|
| |
| const gridSize = (forcedSize !== null) |
| ? forcedSize |
| : await autoDetectSize(imagePath, border, darkBg); |
|
|
| console.log(`[OCR] Grid size: ${gridSize}Γ${gridSize}`); |
|
|
| |
| 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; |
|
|
| |
| 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; |
|
|
| |
| const mergedVotes = Array.from({ length: gridSize }, (_, r) => |
| Array.from({ length: gridSize }, (_, c) => |
| mergeVotes(votesA[r][c], votesB[r][c]) |
| ) |
| ); |
|
|
| |
| |
| |
| |
| 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)...`); |
|
|
| |
| 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; |
|
|
| 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) { |
| |
| if (pickWinner(mergedVotes[r][c]) !== '?') continue; |
|
|
| |
| 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); |
| |
| 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])}`); |
| } |
| } |
|
|
| |
| 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(' ')); |
|
|
| |
| 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 }; |
|
|