Upload 12 files
Browse files
bot.js
CHANGED
|
@@ -372,67 +372,91 @@ async function startBot() {
|
|
| 372 |
return;
|
| 373 |
}
|
| 374 |
|
| 375 |
-
// ββ Download
|
| 376 |
const imagePath = path.join(
|
| 377 |
__dirname,
|
| 378 |
`grid_${Date.now()}_${Math.random().toString(36).slice(2)}.jpg`
|
| 379 |
);
|
| 380 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
try {
|
| 382 |
-
await
|
| 383 |
-
|
| 384 |
-
|
| 385 |
|
|
|
|
|
|
|
| 386 |
await downloadImage(client, msg, imagePath);
|
|
|
|
| 387 |
|
| 388 |
-
|
| 389 |
-
console.error('[Download] Failed:', dlErr.message);
|
| 390 |
-
console.error(dlErr.stack);
|
| 391 |
-
await client.sendMessage(chatId, {
|
| 392 |
-
message: `β Could not download image: ${dlErr.message}`,
|
| 393 |
-
});
|
| 394 |
-
await deleteFile(imagePath);
|
| 395 |
-
return;
|
| 396 |
-
}
|
| 397 |
-
|
| 398 |
-
// ββ OCR + Solve ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 399 |
-
try {
|
| 400 |
stats.imagesProcessed++;
|
| 401 |
-
|
| 402 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
|
| 404 |
if (!grid || grid.length === 0) {
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
|
|
|
|
|
|
| 408 |
return;
|
| 409 |
}
|
| 410 |
|
|
|
|
|
|
|
|
|
|
| 411 |
const patterns = parsePatterns(caption);
|
|
|
|
| 412 |
|
| 413 |
if (patterns.length === 0) {
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
parseMode: 'html'
|
| 420 |
-
|
| 421 |
return;
|
| 422 |
}
|
| 423 |
|
|
|
|
|
|
|
| 424 |
const results = solve(grid, patterns);
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
|
| 430 |
} catch (err) {
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
});
|
| 435 |
} finally {
|
|
|
|
| 436 |
await deleteFile(imagePath);
|
| 437 |
}
|
| 438 |
|
|
|
|
| 372 |
return;
|
| 373 |
}
|
| 374 |
|
| 375 |
+
// ββ Download + OCR + Solve (all in one guarded block) βββββββββββββββββββββ
|
| 376 |
const imagePath = path.join(
|
| 377 |
__dirname,
|
| 378 |
`grid_${Date.now()}_${Math.random().toString(36).slice(2)}.jpg`
|
| 379 |
);
|
| 380 |
|
| 381 |
+
// Safe send β never throws, logs errors instead
|
| 382 |
+
const safeSend = async (text, opts = {}) => {
|
| 383 |
+
try {
|
| 384 |
+
await client.sendMessage(chatId, { message: text, ...opts });
|
| 385 |
+
} catch (sendErr) {
|
| 386 |
+
console.error('[safeSend] Failed to send message:', sendErr.message);
|
| 387 |
+
// Try plain text fallback if HTML parse failed
|
| 388 |
+
if (opts.parseMode) {
|
| 389 |
+
try {
|
| 390 |
+
const plain = text.replace(/<[^>]+>/g, '');
|
| 391 |
+
await client.sendMessage(chatId, { message: plain });
|
| 392 |
+
} catch (_) {}
|
| 393 |
+
}
|
| 394 |
+
}
|
| 395 |
+
};
|
| 396 |
+
|
| 397 |
try {
|
| 398 |
+
await safeSend(
|
| 399 |
+
`π Processing your ${challenge.gridSize}Γ${challenge.gridSize} word grid...`
|
| 400 |
+
);
|
| 401 |
|
| 402 |
+
// ββ Step 1: Download ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 403 |
+
console.log(`[Handler] Starting download for msg=${msg.id}`);
|
| 404 |
await downloadImage(client, msg, imagePath);
|
| 405 |
+
console.log(`[Handler] Download complete: ${imagePath}`);
|
| 406 |
|
| 407 |
+
// ββ Step 2: OCR βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
stats.imagesProcessed++;
|
| 409 |
+
console.log(`[Handler] Starting OCR (${challenge.gridSize}Γ${challenge.gridSize})...`);
|
| 410 |
+
|
| 411 |
+
let grid;
|
| 412 |
+
try {
|
| 413 |
+
grid = await extractGrid(imagePath, challenge.gridSize);
|
| 414 |
+
} catch (ocrErr) {
|
| 415 |
+
console.error('[Handler] OCR threw:', ocrErr.message, ocrErr.stack);
|
| 416 |
+
grid = null;
|
| 417 |
+
}
|
| 418 |
|
| 419 |
if (!grid || grid.length === 0) {
|
| 420 |
+
console.warn('[Handler] OCR returned null/empty grid');
|
| 421 |
+
await safeSend(
|
| 422 |
+
'β Could not read the grid from this image.\n' +
|
| 423 |
+
'Make sure the letters are clearly visible and the image is not blurry.'
|
| 424 |
+
);
|
| 425 |
return;
|
| 426 |
}
|
| 427 |
|
| 428 |
+
console.log(`[Handler] OCR done β ${grid.length}Γ${grid[0].length} grid`);
|
| 429 |
+
|
| 430 |
+
// ββ Step 3: Parse patterns βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 431 |
const patterns = parsePatterns(caption);
|
| 432 |
+
console.log(`[Handler] Patterns: ${patterns.map(p => p.pattern).join(', ') || '(none)'}`);
|
| 433 |
|
| 434 |
if (patterns.length === 0) {
|
| 435 |
+
// Show grid even without patterns
|
| 436 |
+
const gridText = '<pre>' + grid.map(r => r.join(' ')).join('\n') + '</pre>';
|
| 437 |
+
await safeSend(
|
| 438 |
+
`π <b>${challenge.gridSize}Γ${challenge.gridSize} grid extracted</b> (no patterns found):\n\n` +
|
| 439 |
+
gridText + '\n\nAdd patterns like <code>M--- (4)</code> to find words!',
|
| 440 |
+
{ parseMode: 'html' }
|
| 441 |
+
);
|
| 442 |
return;
|
| 443 |
}
|
| 444 |
|
| 445 |
+
// ββ Step 4: Solve ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 446 |
+
console.log('[Handler] Solving...');
|
| 447 |
const results = solve(grid, patterns);
|
| 448 |
+
const reply = formatResults(results, grid, patterns);
|
| 449 |
+
|
| 450 |
+
await safeSend(reply, { parseMode: 'html' });
|
| 451 |
+
console.log('[Handler] Done β');
|
| 452 |
|
| 453 |
} catch (err) {
|
| 454 |
+
// Catch-all for download errors and any unexpected throws
|
| 455 |
+
console.error('[Handler] Unhandled error:', err.message);
|
| 456 |
+
console.error(err.stack);
|
| 457 |
+
await safeSend(`β Error: ${err.message}`);
|
| 458 |
} finally {
|
| 459 |
+
// Always clean up the temp file
|
| 460 |
await deleteFile(imagePath);
|
| 461 |
}
|
| 462 |
|
ocr.js
CHANGED
|
@@ -1,37 +1,36 @@
|
|
| 1 |
/**
|
| 2 |
-
* ocr.js β Dual-pass Tesseract OCR
|
| 3 |
*
|
| 4 |
-
*
|
|
|
|
|
|
|
| 5 |
*
|
| 6 |
-
*
|
|
|
|
| 7 |
* β’ Crops the border first (removes outer frame noise)
|
| 8 |
-
* β’ Maps each detected symbol to its grid cell
|
| 9 |
-
* β’
|
| 10 |
*
|
| 11 |
-
* Pass B β Cell-by-cell PSM 10 (single character),
|
| 12 |
-
* β’ Extracts each cell individually
|
| 13 |
-
* β’
|
| 14 |
-
* β’ Votes: weight 2 per hit (more reliable, higher weight)
|
| 15 |
*
|
| 16 |
-
* Final
|
| 17 |
*
|
| 18 |
-
*
|
| 19 |
-
*
|
| 20 |
-
*
|
| 21 |
-
*
|
| 22 |
-
* Border detection:
|
| 23 |
-
* β’ Grid border β 5.5% of min(width,height) β measured empirically on both
|
| 24 |
-
* the 452Γ452 (8Γ8) and 516Γ516 (10Γ10) standard Telegram game images
|
| 25 |
*/
|
| 26 |
|
| 27 |
'use strict';
|
| 28 |
|
| 29 |
-
const sharp
|
| 30 |
sharp.cache(false);
|
| 31 |
const { createWorker } = require('tesseract.js');
|
| 32 |
|
| 33 |
-
// βββ
|
| 34 |
-
//
|
| 35 |
// We never remap one letter to another β that is the solver's job.
|
| 36 |
const CHAR_MAP = {
|
| 37 |
'0': 'O', '1': 'I', '2': 'Z', '3': 'B',
|
|
@@ -45,7 +44,7 @@ function clean(ch) {
|
|
| 45 |
return CHAR_MAP[u] || null;
|
| 46 |
}
|
| 47 |
|
| 48 |
-
// βββ
|
| 49 |
function mergeVotes(a, b) {
|
| 50 |
const out = { ...a };
|
| 51 |
for (const [ch, v] of Object.entries(b)) out[ch] = (out[ch] || 0) + v;
|
|
@@ -60,40 +59,111 @@ function pickWinner(votes) {
|
|
| 60 |
return best;
|
| 61 |
}
|
| 62 |
|
| 63 |
-
// βββ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
/**
|
| 65 |
-
*
|
| 66 |
-
*
|
| 67 |
-
*
|
|
|
|
| 68 |
*
|
| 69 |
-
* @
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
*/
|
| 71 |
-
async function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
const meta = await sharp(imgPath).metadata();
|
| 73 |
const W = meta.width, H = meta.height;
|
| 74 |
|
| 75 |
const cropL = border, cropT = border;
|
| 76 |
-
const cropW = W - 2 * border
|
| 77 |
-
const
|
|
|
|
|
|
|
| 78 |
|
| 79 |
const votes = Array.from({ length: gridSize }, () =>
|
| 80 |
Array.from({ length: gridSize }, () => ({}))
|
| 81 |
);
|
| 82 |
|
|
|
|
| 83 |
const THRESHOLDS = [80, 110, 140, 170];
|
| 84 |
|
| 85 |
for (const th of THRESHOLDS) {
|
| 86 |
let buf;
|
| 87 |
try {
|
| 88 |
-
buf = await
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
.threshold(th)
|
| 94 |
-
.toBuffer();
|
| 95 |
} catch (e) {
|
| 96 |
-
console.warn(`[PassA]
|
| 97 |
continue;
|
| 98 |
}
|
| 99 |
|
|
@@ -122,21 +192,17 @@ async function passA(worker, imgPath, gridSize, border) {
|
|
| 122 |
}
|
| 123 |
|
| 124 |
// βββ Pass B: cell-by-cell OCR (PSM 10) ββββββββββββββββββββββββββββββββββββββββ
|
| 125 |
-
|
| 126 |
-
* Extracts each grid cell individually (padded 10% inward, 3Γ upscaled).
|
| 127 |
-
* Uses PSM 10 (single character) which is most accurate for isolated letters.
|
| 128 |
-
* Weights each vote by 2 (more reliable than full-image pass).
|
| 129 |
-
*
|
| 130 |
-
* @returns {Object[][][]} votesB[r][c] = { 'A': n, ... }
|
| 131 |
-
*/
|
| 132 |
-
async function passB(worker, imgPath, gridSize, border) {
|
| 133 |
const meta = await sharp(imgPath).metadata();
|
| 134 |
const W = meta.width, H = meta.height;
|
| 135 |
|
| 136 |
-
const innerW = W - 2 * border
|
| 137 |
-
const
|
|
|
|
|
|
|
|
|
|
| 138 |
const PAD = 0.10; // 10% inset from each cell edge
|
| 139 |
-
const SCALE = 3; // upscale
|
| 140 |
const WEIGHT = 2; // cell-level votes count double
|
| 141 |
|
| 142 |
const THRESHOLDS = [80, 110, 140, 170, 200];
|
|
@@ -155,14 +221,7 @@ async function passB(worker, imgPath, gridSize, border) {
|
|
| 155 |
for (const th of THRESHOLDS) {
|
| 156 |
let buf;
|
| 157 |
try {
|
| 158 |
-
buf = await
|
| 159 |
-
.extract({ left, top, width, height })
|
| 160 |
-
.grayscale()
|
| 161 |
-
.normalize()
|
| 162 |
-
.resize(width * SCALE, height * SCALE, { kernel: 'lanczos3' })
|
| 163 |
-
.sharpen({ sigma: 1.5 })
|
| 164 |
-
.threshold(th)
|
| 165 |
-
.toBuffer();
|
| 166 |
} catch (e) {
|
| 167 |
continue;
|
| 168 |
}
|
|
@@ -174,7 +233,8 @@ async function passB(worker, imgPath, gridSize, border) {
|
|
| 174 |
continue;
|
| 175 |
}
|
| 176 |
|
| 177 |
-
const
|
|
|
|
| 178 |
if (ch && res.data.confidence > 15) {
|
| 179 |
votes[r][c][ch] = (votes[r][c][ch] || 0) + WEIGHT;
|
| 180 |
}
|
|
@@ -186,39 +246,41 @@ async function passB(worker, imgPath, gridSize, border) {
|
|
| 186 |
}
|
| 187 |
|
| 188 |
// βββ Auto-detect grid size βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 189 |
-
|
| 190 |
-
* Run a quick PSM 6 pass at one threshold and count symbols.
|
| 191 |
-
* >160 observations β likely 10Γ10, else 8Γ8.
|
| 192 |
-
*/
|
| 193 |
-
async function autoDetectSize(imgPath, border) {
|
| 194 |
const meta = await sharp(imgPath).metadata();
|
| 195 |
const W = meta.width, H = meta.height;
|
| 196 |
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
|
| 204 |
const worker = await createWorker('eng');
|
| 205 |
await worker.setParameters({
|
| 206 |
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
| 207 |
-
tessedit_pageseg_mode:
|
| 208 |
});
|
| 209 |
const res = await worker.recognize(buf);
|
| 210 |
await worker.terminate();
|
| 211 |
|
| 212 |
const count = (res.data.symbols || []).filter(s => /^[A-Z]$/i.test(s.text)).length;
|
| 213 |
-
|
| 214 |
-
|
|
|
|
| 215 |
}
|
| 216 |
|
| 217 |
// βββ Main βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 218 |
/**
|
| 219 |
* @param {string} imagePath
|
| 220 |
-
* @param {number|null} forcedSize
|
| 221 |
-
* @returns {string[][]|null}
|
| 222 |
*/
|
| 223 |
async function extractGrid(imagePath, forcedSize = null) {
|
| 224 |
let workerA = null;
|
|
@@ -227,49 +289,134 @@ async function extractGrid(imagePath, forcedSize = null) {
|
|
| 227 |
try {
|
| 228 |
const meta = await sharp(imagePath).metadata();
|
| 229 |
const minDim = Math.min(meta.width, meta.height);
|
| 230 |
-
const border = Math.round(minDim * 0.055);
|
| 231 |
|
| 232 |
console.log(`[OCR] Image ${meta.width}Γ${meta.height}, border=${border}px`);
|
| 233 |
|
| 234 |
-
//
|
| 235 |
-
const
|
|
|
|
|
|
|
|
|
|
| 236 |
? forcedSize
|
| 237 |
-
: await autoDetectSize(imagePath, border);
|
| 238 |
|
| 239 |
console.log(`[OCR] Grid size: ${gridSize}Γ${gridSize}`);
|
| 240 |
|
| 241 |
-
// ββ
|
| 242 |
workerA = await createWorker('eng');
|
| 243 |
await workerA.setParameters({
|
| 244 |
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
| 245 |
tessedit_pageseg_mode: '6',
|
| 246 |
});
|
| 247 |
-
|
| 248 |
-
const votesA = await passA(workerA, imagePath, gridSize, border);
|
| 249 |
await workerA.terminate();
|
| 250 |
workerA = null;
|
| 251 |
|
| 252 |
-
// ββ
|
| 253 |
workerB = await createWorker('eng');
|
| 254 |
await workerB.setParameters({
|
| 255 |
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
| 256 |
tessedit_pageseg_mode: '10',
|
| 257 |
});
|
| 258 |
-
|
| 259 |
-
const votesB = await passB(workerB, imagePath, gridSize, border);
|
| 260 |
await workerB.terminate();
|
| 261 |
workerB = null;
|
| 262 |
|
| 263 |
-
// ββ Merge votes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
const grid = Array.from({ length: gridSize }, (_, r) =>
|
| 265 |
Array.from({ length: gridSize }, (_, c) =>
|
| 266 |
-
pickWinner(
|
| 267 |
)
|
| 268 |
);
|
| 269 |
|
| 270 |
console.log('[OCR] Extracted grid:');
|
| 271 |
for (const row of grid) console.log(' ' + row.join(' '));
|
| 272 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
return grid;
|
| 274 |
|
| 275 |
} catch (err) {
|
|
|
|
| 1 |
/**
|
| 2 |
+
* ocr.js β Dual-pass Tesseract OCR for word grid images
|
| 3 |
*
|
| 4 |
+
* Supports BOTH colour schemes automatically:
|
| 5 |
+
* β’ Light background, dark letters (white/grey BG, black letters)
|
| 6 |
+
* β’ Dark background, light letters (black BG, white letters)
|
| 7 |
*
|
| 8 |
+
* Strategy:
|
| 9 |
+
* Pass A β Full-image PSM 6 (uniform text block), multiple thresholds
|
| 10 |
* β’ Crops the border first (removes outer frame noise)
|
| 11 |
+
* β’ Maps each detected symbol bbox-centre to its grid cell
|
| 12 |
+
* β’ Weight 1 per vote
|
| 13 |
*
|
| 14 |
+
* Pass B β Cell-by-cell PSM 10 (single character), multiple thresholds
|
| 15 |
+
* β’ Extracts each cell individually, 3Γ upscaled
|
| 16 |
+
* β’ Weight 2 per vote (cell-level is more reliable)
|
|
|
|
| 17 |
*
|
| 18 |
+
* Final β majority vote per cell across both passes
|
| 19 |
*
|
| 20 |
+
* Background detection:
|
| 21 |
+
* Samples the mean pixel value of the border region.
|
| 22 |
+
* If mean < 128 β dark background β negate before thresholding
|
| 23 |
+
* so Tesseract always receives black-text-on-white.
|
|
|
|
|
|
|
|
|
|
| 24 |
*/
|
| 25 |
|
| 26 |
'use strict';
|
| 27 |
|
| 28 |
+
const sharp = require('sharp');
|
| 29 |
sharp.cache(false);
|
| 30 |
const { createWorker } = require('tesseract.js');
|
| 31 |
|
| 32 |
+
// βββ Character normalisation βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 33 |
+
// Map digits/symbols Tesseract sometimes emits to their closest letter.
|
| 34 |
// We never remap one letter to another β that is the solver's job.
|
| 35 |
const CHAR_MAP = {
|
| 36 |
'0': 'O', '1': 'I', '2': 'Z', '3': 'B',
|
|
|
|
| 44 |
return CHAR_MAP[u] || null;
|
| 45 |
}
|
| 46 |
|
| 47 |
+
// βββ Vote helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 48 |
function mergeVotes(a, b) {
|
| 49 |
const out = { ...a };
|
| 50 |
for (const [ch, v] of Object.entries(b)) out[ch] = (out[ch] || 0) + v;
|
|
|
|
| 59 |
return best;
|
| 60 |
}
|
| 61 |
|
| 62 |
+
// βββ Background detection ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 63 |
+
/**
|
| 64 |
+
* Detect whether the image has a dark background.
|
| 65 |
+
* Samples a thin ring just inside the border region and computes mean luminance.
|
| 66 |
+
* Returns true if background is dark (mean < 128) β need to negate for Tesseract.
|
| 67 |
+
*
|
| 68 |
+
* @param {string} imgPath
|
| 69 |
+
* @param {number} border β border thickness in pixels
|
| 70 |
+
* @returns {Promise<boolean>}
|
| 71 |
+
*/
|
| 72 |
+
async function isDarkBackground(imgPath, border) {
|
| 73 |
+
try {
|
| 74 |
+
const meta = await sharp(imgPath).metadata();
|
| 75 |
+
const W = meta.width, H = meta.height;
|
| 76 |
+
|
| 77 |
+
// Sample the four corner cells of the grid border area
|
| 78 |
+
// Use a small strip just inside the outer border
|
| 79 |
+
const sampleSize = Math.max(4, Math.round(border * 0.8));
|
| 80 |
+
|
| 81 |
+
// Top-left corner sample
|
| 82 |
+
const sample = await sharp(imgPath)
|
| 83 |
+
.extract({
|
| 84 |
+
left: Math.max(0, border - sampleSize),
|
| 85 |
+
top: Math.max(0, border - sampleSize),
|
| 86 |
+
width: sampleSize * 2,
|
| 87 |
+
height: sampleSize * 2,
|
| 88 |
+
})
|
| 89 |
+
.grayscale()
|
| 90 |
+
.raw()
|
| 91 |
+
.toBuffer();
|
| 92 |
+
|
| 93 |
+
const mean = sample.reduce((s, v) => s + v, 0) / sample.length;
|
| 94 |
+
const dark = mean < 128;
|
| 95 |
+
console.log(`[OCR] Background mean luminance: ${mean.toFixed(1)} οΏ½οΏ½οΏ½ ${dark ? 'DARK (will negate)' : 'LIGHT'}`);
|
| 96 |
+
return dark;
|
| 97 |
+
} catch (e) {
|
| 98 |
+
console.warn('[OCR] Background detection failed, assuming light:', e.message);
|
| 99 |
+
return false;
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
// βββ Sharp pipeline builder ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 104 |
/**
|
| 105 |
+
* Build a preprocessed image buffer from a region of the source image.
|
| 106 |
+
* Handles both light and dark backgrounds:
|
| 107 |
+
* - Dark BG: negate BEFORE threshold so letters become dark on light BG
|
| 108 |
+
* - Light BG: threshold directly
|
| 109 |
*
|
| 110 |
+
* @param {string} imgPath
|
| 111 |
+
* @param {object} region β { left, top, width, height }
|
| 112 |
+
* @param {number} threshold β binarisation threshold (0-255)
|
| 113 |
+
* @param {boolean} darkBg β true if image has dark background
|
| 114 |
+
* @param {number} scale β upscale factor (1 = no scaling)
|
| 115 |
+
* @returns {Promise<Buffer>}
|
| 116 |
*/
|
| 117 |
+
async function buildBuf(imgPath, region, threshold, darkBg, scale = 1) {
|
| 118 |
+
let pipeline = sharp(imgPath).extract(region).grayscale().normalize();
|
| 119 |
+
|
| 120 |
+
if (darkBg) {
|
| 121 |
+
// Negate so white letters become black β Tesseract needs black text on white
|
| 122 |
+
pipeline = pipeline.negate();
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
pipeline = pipeline.sharpen({ sigma: 1.2 });
|
| 126 |
+
|
| 127 |
+
if (scale > 1) {
|
| 128 |
+
pipeline = pipeline.resize(
|
| 129 |
+
region.width * scale,
|
| 130 |
+
region.height * scale,
|
| 131 |
+
{ kernel: 'lanczos3' }
|
| 132 |
+
);
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
pipeline = pipeline.threshold(threshold);
|
| 136 |
+
return pipeline.toBuffer();
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
// βββ Pass A: full-image OCR (PSM 6) βββββββββββββββββββββββββββββββββββββββββββ
|
| 140 |
+
async function passA(worker, imgPath, gridSize, border, darkBg) {
|
| 141 |
const meta = await sharp(imgPath).metadata();
|
| 142 |
const W = meta.width, H = meta.height;
|
| 143 |
|
| 144 |
const cropL = border, cropT = border;
|
| 145 |
+
const cropW = W - 2 * border;
|
| 146 |
+
const cropH = H - 2 * border;
|
| 147 |
+
const cellW = cropW / gridSize;
|
| 148 |
+
const cellH = cropH / gridSize;
|
| 149 |
|
| 150 |
const votes = Array.from({ length: gridSize }, () =>
|
| 151 |
Array.from({ length: gridSize }, () => ({}))
|
| 152 |
);
|
| 153 |
|
| 154 |
+
// Use thresholds on the light side β after negate (dark BG) or direct (light BG)
|
| 155 |
const THRESHOLDS = [80, 110, 140, 170];
|
| 156 |
|
| 157 |
for (const th of THRESHOLDS) {
|
| 158 |
let buf;
|
| 159 |
try {
|
| 160 |
+
buf = await buildBuf(
|
| 161 |
+
imgPath,
|
| 162 |
+
{ left: cropL, top: cropT, width: cropW, height: cropH },
|
| 163 |
+
th, darkBg, 1
|
| 164 |
+
);
|
|
|
|
|
|
|
| 165 |
} catch (e) {
|
| 166 |
+
console.warn(`[PassA] preprocess th=${th}: ${e.message}`);
|
| 167 |
continue;
|
| 168 |
}
|
| 169 |
|
|
|
|
| 192 |
}
|
| 193 |
|
| 194 |
// βββ Pass B: cell-by-cell OCR (PSM 10) ββββββββββββββββββββββββββββββββββββββββ
|
| 195 |
+
async function passB(worker, imgPath, gridSize, border, darkBg) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
const meta = await sharp(imgPath).metadata();
|
| 197 |
const W = meta.width, H = meta.height;
|
| 198 |
|
| 199 |
+
const innerW = W - 2 * border;
|
| 200 |
+
const innerH = H - 2 * border;
|
| 201 |
+
const cellW = innerW / gridSize;
|
| 202 |
+
const cellH = innerH / gridSize;
|
| 203 |
+
|
| 204 |
const PAD = 0.10; // 10% inset from each cell edge
|
| 205 |
+
const SCALE = 3; // upscale for sharper OCR
|
| 206 |
const WEIGHT = 2; // cell-level votes count double
|
| 207 |
|
| 208 |
const THRESHOLDS = [80, 110, 140, 170, 200];
|
|
|
|
| 221 |
for (const th of THRESHOLDS) {
|
| 222 |
let buf;
|
| 223 |
try {
|
| 224 |
+
buf = await buildBuf(imgPath, { left, top, width, height }, th, darkBg, SCALE);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
} catch (e) {
|
| 226 |
continue;
|
| 227 |
}
|
|
|
|
| 233 |
continue;
|
| 234 |
}
|
| 235 |
|
| 236 |
+
const rawCh = (res.data.text || '').replace(/[^A-Za-z0-9|]/g, '').charAt(0);
|
| 237 |
+
const ch = clean(rawCh);
|
| 238 |
if (ch && res.data.confidence > 15) {
|
| 239 |
votes[r][c][ch] = (votes[r][c][ch] || 0) + WEIGHT;
|
| 240 |
}
|
|
|
|
| 246 |
}
|
| 247 |
|
| 248 |
// βββ Auto-detect grid size βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 249 |
+
async function autoDetectSize(imgPath, border, darkBg) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
const meta = await sharp(imgPath).metadata();
|
| 251 |
const W = meta.width, H = meta.height;
|
| 252 |
|
| 253 |
+
let buf;
|
| 254 |
+
try {
|
| 255 |
+
buf = await buildBuf(
|
| 256 |
+
imgPath,
|
| 257 |
+
{ left: border, top: border, width: W - 2 * border, height: H - 2 * border },
|
| 258 |
+
130, darkBg, 1
|
| 259 |
+
);
|
| 260 |
+
} catch (e) {
|
| 261 |
+
console.warn('[OCR] autoDetect preprocess failed:', e.message);
|
| 262 |
+
return 8;
|
| 263 |
+
}
|
| 264 |
|
| 265 |
const worker = await createWorker('eng');
|
| 266 |
await worker.setParameters({
|
| 267 |
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
| 268 |
+
tessedit_pageseg_mode: '6',
|
| 269 |
});
|
| 270 |
const res = await worker.recognize(buf);
|
| 271 |
await worker.terminate();
|
| 272 |
|
| 273 |
const count = (res.data.symbols || []).filter(s => /^[A-Z]$/i.test(s.text)).length;
|
| 274 |
+
const size = count > 160 ? 10 : 8;
|
| 275 |
+
console.log(`[OCR] Auto-detect: ${count} symbols β ${size}Γ${size}`);
|
| 276 |
+
return size;
|
| 277 |
}
|
| 278 |
|
| 279 |
// βββ Main βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 280 |
/**
|
| 281 |
* @param {string} imagePath
|
| 282 |
+
* @param {number|null} forcedSize β 8 or 10; null = auto-detect
|
| 283 |
+
* @returns {Promise<string[][]|null>}
|
| 284 |
*/
|
| 285 |
async function extractGrid(imagePath, forcedSize = null) {
|
| 286 |
let workerA = null;
|
|
|
|
| 289 |
try {
|
| 290 |
const meta = await sharp(imagePath).metadata();
|
| 291 |
const minDim = Math.min(meta.width, meta.height);
|
| 292 |
+
const border = Math.round(minDim * 0.055);
|
| 293 |
|
| 294 |
console.log(`[OCR] Image ${meta.width}Γ${meta.height}, border=${border}px`);
|
| 295 |
|
| 296 |
+
// ββ Detect background colour scheme ββββββββββββββββββββββββββββββββββββββ
|
| 297 |
+
const darkBg = await isDarkBackground(imagePath, border);
|
| 298 |
+
|
| 299 |
+
// ββ Determine grid size βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 300 |
+
const gridSize = (forcedSize !== null)
|
| 301 |
? forcedSize
|
| 302 |
+
: await autoDetectSize(imagePath, border, darkBg);
|
| 303 |
|
| 304 |
console.log(`[OCR] Grid size: ${gridSize}Γ${gridSize}`);
|
| 305 |
|
| 306 |
+
// ββ Pass A: full-image PSM 6 ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 307 |
workerA = await createWorker('eng');
|
| 308 |
await workerA.setParameters({
|
| 309 |
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
| 310 |
tessedit_pageseg_mode: '6',
|
| 311 |
});
|
| 312 |
+
const votesA = await passA(workerA, imagePath, gridSize, border, darkBg);
|
|
|
|
| 313 |
await workerA.terminate();
|
| 314 |
workerA = null;
|
| 315 |
|
| 316 |
+
// ββ Pass B: cell-by-cell PSM 10 βββββββββββββββββββββββββββββββββββββββββββ
|
| 317 |
workerB = await createWorker('eng');
|
| 318 |
await workerB.setParameters({
|
| 319 |
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
| 320 |
tessedit_pageseg_mode: '10',
|
| 321 |
});
|
| 322 |
+
const votesB = await passB(workerB, imagePath, gridSize, border, darkBg);
|
|
|
|
| 323 |
await workerB.terminate();
|
| 324 |
workerB = null;
|
| 325 |
|
| 326 |
+
// ββ Merge votes βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 327 |
+
const mergedVotes = Array.from({ length: gridSize }, (_, r) =>
|
| 328 |
+
Array.from({ length: gridSize }, (_, c) =>
|
| 329 |
+
mergeVotes(votesA[r][c], votesB[r][c])
|
| 330 |
+
)
|
| 331 |
+
);
|
| 332 |
+
|
| 333 |
+
// ββ Pass C: rescue unknown cells ββββββββββββββββββββββββββββββββββββββββββ
|
| 334 |
+
// For any cell that is still '?' after the two main passes, run an
|
| 335 |
+
// aggressive extra-contrast re-try with more threshold variants.
|
| 336 |
+
// This handles thin letters like I/L on dark backgrounds.
|
| 337 |
+
const meta2 = await sharp(imagePath).metadata();
|
| 338 |
+
const innerW2 = meta2.width - 2 * border;
|
| 339 |
+
const innerH2 = meta2.height - 2 * border;
|
| 340 |
+
const cellW2 = innerW2 / gridSize;
|
| 341 |
+
const cellH2 = innerH2 / gridSize;
|
| 342 |
+
|
| 343 |
+
let rescueWorker = null;
|
| 344 |
+
const unknownCells = [];
|
| 345 |
+
for (let r = 0; r < gridSize; r++) {
|
| 346 |
+
for (let c = 0; c < gridSize; c++) {
|
| 347 |
+
if (pickWinner(mergedVotes[r][c]) === '?') unknownCells.push({ r, c });
|
| 348 |
+
}
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
if (unknownCells.length > 0) {
|
| 352 |
+
console.log(`[OCR] PassC: rescuing ${unknownCells.length} unknown cell(s)...`);
|
| 353 |
+
|
| 354 |
+
// Try multiple PSM modes β thin letters like I/L need PSM 7 or 8
|
| 355 |
+
const RESCUE_PSM_MODES = ['10', '7', '8', '13'];
|
| 356 |
+
const RESCUE_THRESHOLDS = [50, 70, 90, 110, 130, 150, 170, 190, 210, 230];
|
| 357 |
+
const RESCUE_SCALE = 6; // larger upscale for thin single-stroke characters
|
| 358 |
+
|
| 359 |
+
for (const psmMode of RESCUE_PSM_MODES) {
|
| 360 |
+
rescueWorker = await createWorker('eng');
|
| 361 |
+
await rescueWorker.setParameters({
|
| 362 |
+
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
| 363 |
+
tessedit_pageseg_mode: psmMode,
|
| 364 |
+
});
|
| 365 |
+
|
| 366 |
+
for (const { r, c } of unknownCells) {
|
| 367 |
+
// Skip if already resolved in a previous PSM pass
|
| 368 |
+
if (pickWinner(mergedVotes[r][c]) !== '?') continue;
|
| 369 |
+
|
| 370 |
+
// Use full cell (minimal padding) for rescue to capture thin strokes
|
| 371 |
+
const left = Math.round(border + c * cellW2 + cellW2 * 0.02);
|
| 372 |
+
const top = Math.round(border + r * cellH2 + cellH2 * 0.02);
|
| 373 |
+
const width = Math.max(3, Math.round(cellW2 * 0.96));
|
| 374 |
+
const height = Math.max(3, Math.round(cellH2 * 0.96));
|
| 375 |
+
|
| 376 |
+
for (const th of RESCUE_THRESHOLDS) {
|
| 377 |
+
try {
|
| 378 |
+
const buf = await buildBuf(
|
| 379 |
+
imagePath, { left, top, width, height },
|
| 380 |
+
th, darkBg, RESCUE_SCALE
|
| 381 |
+
);
|
| 382 |
+
const res = await rescueWorker.recognize(buf);
|
| 383 |
+
const rawCh = (res.data.text || '').replace(/[^A-Za-z0-9|]/g, '').charAt(0);
|
| 384 |
+
const ch = clean(rawCh);
|
| 385 |
+
// Only accept high-confidence votes in rescue pass to avoid noise
|
| 386 |
+
if (ch && res.data.confidence > 40) {
|
| 387 |
+
mergedVotes[r][c][ch] = (mergedVotes[r][c][ch] || 0) + 1;
|
| 388 |
+
}
|
| 389 |
+
} catch (_) {}
|
| 390 |
+
}
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
await rescueWorker.terminate();
|
| 394 |
+
rescueWorker = null;
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
for (const { r, c } of unknownCells) {
|
| 398 |
+
console.log(`[OCR] PassC cell[${r}][${c}]: votes=${JSON.stringify(mergedVotes[r][c])} β ${pickWinner(mergedVotes[r][c])}`);
|
| 399 |
+
}
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
// ββ Build final grid ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 403 |
const grid = Array.from({ length: gridSize }, (_, r) =>
|
| 404 |
Array.from({ length: gridSize }, (_, c) =>
|
| 405 |
+
pickWinner(mergedVotes[r][c])
|
| 406 |
)
|
| 407 |
);
|
| 408 |
|
| 409 |
console.log('[OCR] Extracted grid:');
|
| 410 |
for (const row of grid) console.log(' ' + row.join(' '));
|
| 411 |
|
| 412 |
+
// Sanity check: if more than 40% of cells are '?' β likely failed
|
| 413 |
+
const totalCells = gridSize * gridSize;
|
| 414 |
+
const unknowns = grid.flat().filter(c => c === '?').length;
|
| 415 |
+
if (unknowns > totalCells * 0.4) {
|
| 416 |
+
console.error(`[OCR] Too many unknown cells (${unknowns}/${totalCells}) β extraction unreliable`);
|
| 417 |
+
return null;
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
return grid;
|
| 421 |
|
| 422 |
} catch (err) {
|
solver.js
CHANGED
|
@@ -69,10 +69,11 @@ for (const [key, alts] of Object.entries(LOOKALIKES_RAW)) {
|
|
| 69 |
|
| 70 |
/**
|
| 71 |
* Does `gridChar` match `targetChar` considering OCR lookalikes?
|
| 72 |
-
*
|
| 73 |
*/
|
| 74 |
function charMatch(target, gridChar) {
|
| 75 |
-
if (!gridChar || gridChar === ' '
|
|
|
|
| 76 |
const t = target.toUpperCase();
|
| 77 |
const g = gridChar.toUpperCase();
|
| 78 |
if (t === g) return true;
|
|
|
|
| 69 |
|
| 70 |
/**
|
| 71 |
* Does `gridChar` match `targetChar` considering OCR lookalikes?
|
| 72 |
+
* '?' means OCR was uncertain β treat as wildcard (matches any target).
|
| 73 |
*/
|
| 74 |
function charMatch(target, gridChar) {
|
| 75 |
+
if (!gridChar || gridChar === ' ') return false;
|
| 76 |
+
if (gridChar === '?') return true; // OCR unknown β wildcard, solver decides
|
| 77 |
const t = target.toUpperCase();
|
| 78 |
const g = gridChar.toUpperCase();
|
| 79 |
if (t === g) return true;
|