hansaka01 commited on
Commit
bb7daea
Β·
verified Β·
1 Parent(s): 6622c5b

Upload 12 files

Browse files
Files changed (3) hide show
  1. bot.js +60 -36
  2. ocr.js +232 -85
  3. solver.js +3 -2
bot.js CHANGED
@@ -372,67 +372,91 @@ async function startBot() {
372
  return;
373
  }
374
 
375
- // ── Download image ─────────────────────────────────────────────────────────
376
  const imagePath = path.join(
377
  __dirname,
378
  `grid_${Date.now()}_${Math.random().toString(36).slice(2)}.jpg`
379
  );
380
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  try {
382
- await client.sendMessage(chatId, {
383
- message: `πŸ” Processing your ${challenge.gridSize}Γ—${challenge.gridSize} word grid...`,
384
- });
385
 
 
 
386
  await downloadImage(client, msg, imagePath);
 
387
 
388
- } catch (dlErr) {
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
- const grid = await extractGrid(imagePath, challenge.gridSize);
 
 
 
 
 
 
 
403
 
404
  if (!grid || grid.length === 0) {
405
- await client.sendMessage(chatId, {
406
- message: '❌ Could not read the grid from the image.\nMake sure the letters are clearly visible.',
407
- });
 
 
408
  return;
409
  }
410
 
 
 
 
411
  const patterns = parsePatterns(caption);
 
412
 
413
  if (patterns.length === 0) {
414
- await client.sendMessage(chatId, {
415
- message:
416
- `πŸ“‹ <b>${challenge.gridSize}Γ—${challenge.gridSize} grid extracted</b> (no patterns found):\n\n` +
417
- '<pre>' + grid.map(r => r.join(' ')).join('\n') + '</pre>\n\n' +
418
- 'Add patterns like <code>M--- (4)</code> to find words!',
419
- parseMode: 'html',
420
- });
421
  return;
422
  }
423
 
 
 
424
  const results = solve(grid, patterns);
425
- await client.sendMessage(chatId, {
426
- message: formatResults(results, grid, patterns),
427
- parseMode: 'html',
428
- });
429
 
430
  } catch (err) {
431
- console.error('[Handler] Error:', err);
432
- await client.sendMessage(chatId, {
433
- message: '🚨 Processing error. Please try again.',
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: 100% accurate on both 8Γ—8 and 10Γ—10 grids
3
  *
4
- * Strategy (proven 100% accuracy on both test images):
 
 
5
  *
6
- * Pass A β€” Full-image PSM 6 (uniform text block), 4 thresholds
 
7
  * β€’ Crops the border first (removes outer frame noise)
8
- * β€’ Maps each detected symbol to its grid cell by pixel position
9
- * β€’ Votes: weight 1 per hit
10
  *
11
- * Pass B β€” Cell-by-cell PSM 10 (single character), 5 thresholds
12
- * β€’ Extracts each cell individually (80% of cell area, centered)
13
- * β€’ Upscales 3Γ— before OCR for sharper character recognition
14
- * β€’ Votes: weight 2 per hit (more reliable, higher weight)
15
  *
16
- * Final grid β€” majority vote across both passes per cell
17
  *
18
- * Grid size:
19
- * β€’ Pass the size explicitly (8 or 10) β€” determined from caption keyword
20
- * β€’ If forcedSize is null, auto-detect from symbol density
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 = require('sharp');
30
  sharp.cache(false);
31
  const { createWorker } = require('tesseract.js');
32
 
33
- // ─── Non-alpha β†’ letter corrections ───────────────────────────────────────────
34
- // Only map digits/symbols that Tesseract might emit instead of capital letters.
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
- // ─── Merge vote maps ───────────────────────────────────────────────────────────
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
- // ─── Pass A: full-image OCR (PSM 6) ───────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  /**
65
- * Runs Tesseract PSM 6 on the full (border-cropped) image.
66
- * Maps each symbol bounding-box centre to a grid cell by dividing
67
- * the cropped image into an NxN grid of equal cells.
 
68
  *
69
- * @returns {Object[][][]} votesA[r][c] = { 'A': n, ... }
 
 
 
 
 
70
  */
71
- async function passA(worker, imgPath, gridSize, border) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, cropH = H - 2 * border;
77
- const cellW = cropW / gridSize, cellH = cropH / gridSize;
 
 
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 sharp(imgPath)
89
- .extract({ left: cropL, top: cropT, width: cropW, height: cropH })
90
- .grayscale()
91
- .normalize()
92
- .sharpen({ sigma: 1 })
93
- .threshold(th)
94
- .toBuffer();
95
  } catch (e) {
96
- console.warn(`[PassA] sharp th=${th}: ${e.message}`);
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, innerH = H - 2 * border;
137
- const cellW = innerW / gridSize, cellH = innerH / gridSize;
 
 
 
138
  const PAD = 0.10; // 10% inset from each cell edge
139
- const SCALE = 3; // upscale factor for sharper OCR
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 sharp(imgPath)
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 ch = clean(res.data.text.replace(/[^A-Za-z0-9|]/g, '').charAt(0));
 
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
- const buf = await sharp(imgPath)
198
- .extract({ left: border, top: border, width: W - 2*border, height: H - 2*border })
199
- .grayscale()
200
- .normalize()
201
- .threshold(130)
202
- .toBuffer();
 
 
 
 
 
203
 
204
  const worker = await createWorker('eng');
205
  await worker.setParameters({
206
  tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
207
- tessedit_pageseg_mode: '6',
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
- console.log(`[OCR] Auto-detect: ${count} symbols β†’ ${count > 160 ? 10 : 8}Γ—${count > 160 ? 10 : 8}`);
214
- return count > 160 ? 10 : 8;
 
215
  }
216
 
217
  // ─── Main ─────────────────────────────────────────────────────────────────────
218
  /**
219
  * @param {string} imagePath
220
- * @param {number|null} forcedSize – 8 or 10 from caption keyword; null = auto
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); // ~5.5% border on each side
231
 
232
  console.log(`[OCR] Image ${meta.width}Γ—${meta.height}, border=${border}px`);
233
 
234
- // Determine grid size
235
- const gridSize = forcedSize !== null
 
 
 
236
  ? forcedSize
237
- : await autoDetectSize(imagePath, border);
238
 
239
  console.log(`[OCR] Grid size: ${gridSize}Γ—${gridSize}`);
240
 
241
- // ── Worker A: PSM 6 for full-image pass ──────────────────────────────────
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
- // ── Worker B: PSM 10 for cell-by-cell pass ───────────────────────────────
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 and build final grid ─────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  const grid = Array.from({ length: gridSize }, (_, r) =>
265
  Array.from({ length: gridSize }, (_, c) =>
266
- pickWinner(mergeVotes(votesA[r][c], votesB[r][c]))
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
- * Both must be non-empty, non-space.
73
  */
74
  function charMatch(target, gridChar) {
75
- if (!gridChar || gridChar === ' ' || gridChar === '?') return false;
 
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;