diff --git a/tools/OcrDaemon/Daemon.cs b/tools/OcrDaemon/Daemon.cs index 9c2e491..2fc36d5 100644 --- a/tools/OcrDaemon/Daemon.cs +++ b/tools/OcrDaemon/Daemon.cs @@ -105,9 +105,10 @@ static class Daemon private static object HandleDiffOcrPython(OcrHandler ocrHandler, PythonOcrBridge pythonBridge, Request request) { var sw = System.Diagnostics.Stopwatch.StartNew(); - var p = request.Threshold > 0 - ? new DiffOcrParams { DiffThresh = request.Threshold } - : new DiffOcrParams(); + // Use default params (same wide crop as Tesseract path). + // Background subtraction below eliminates stash items from the image. + var p = new DiffOcrParams(); + if (request.Threshold > 0) p.DiffThresh = request.Threshold; var cropResult = ocrHandler.DiffCrop(request, p); if (cropResult == null) @@ -115,22 +116,29 @@ static class Daemon var (cropped, refCropped, current, region) = cropResult.Value; using var _current = current; - using var _refCropped = refCropped; + + // Apply background subtraction to isolate tooltip text. + // This removes stash items and game world — only tooltip text remains. + // No upscale (upscale=1) to keep the image small for EasyOCR speed. + // Hard threshold (softThreshold=false) produces clean binary for OCR. + using var processed = ImagePreprocessor.PreprocessWithBackgroundSub( + cropped, refCropped, dimPercentile: 40, textThresh: 60, upscale: 1, softThreshold: false); + cropped.Dispose(); + refCropped.Dispose(); var diffMs = sw.ElapsedMilliseconds; - // Save crop to requested path if provided + // Save processed crop if path provided if (!string.IsNullOrEmpty(request.Path)) { var dir = Path.GetDirectoryName(request.Path); if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir); - cropped.Save(request.Path, ImageUtils.GetImageFormat(request.Path)); + processed.Save(request.Path, ImageUtils.GetImageFormat(request.Path)); } - // Send crop to Python via base64 over pipe (no temp file I/O) + // Send processed image to Python OCR via base64 sw.Restart(); - var ocrResult = pythonBridge.OcrFromBitmap(cropped, request.Engine!); - cropped.Dispose(); + var ocrResult = pythonBridge.OcrFromBitmap(processed, request.Engine!); var ocrMs = sw.ElapsedMilliseconds; Console.Error.WriteLine($" diff-ocr-python: diff={diffMs}ms ocr={ocrMs}ms total={diffMs + ocrMs}ms crop={region.Width}x{region.Height}"); diff --git a/tools/OcrDaemon/OcrHandler.cs b/tools/OcrDaemon/OcrHandler.cs index 9fab0c8..04dbe07 100644 --- a/tools/OcrDaemon/OcrHandler.cs +++ b/tools/OcrDaemon/OcrHandler.cs @@ -144,7 +144,6 @@ class OcrHandler(TesseractEngine engine) int rowRangeLen = bestRowEnd - bestRowStart + 1; if (rowRangeLen <= 200) { - // Small range: serial is faster than Parallel overhead for (int y = bestRowStart; y <= bestRowEnd; y++) { int rowOffset = y * stride; diff --git a/tools/OcrDaemon/tessdata/images/vertex1_crop.png b/tools/OcrDaemon/tessdata/images/vertex1_crop.png deleted file mode 100644 index f30b2b8..0000000 Binary files a/tools/OcrDaemon/tessdata/images/vertex1_crop.png and /dev/null differ diff --git a/tools/OcrDaemon/tessdata/images/vertex1_tight.png b/tools/OcrDaemon/tessdata/images/vertex1_tight.png deleted file mode 100644 index adb91df..0000000 Binary files a/tools/OcrDaemon/tessdata/images/vertex1_tight.png and /dev/null differ diff --git a/tools/OcrDaemon/tessdata/images/vertex2_crop.png b/tools/OcrDaemon/tessdata/images/vertex2_crop.png deleted file mode 100644 index fbd8e77..0000000 Binary files a/tools/OcrDaemon/tessdata/images/vertex2_crop.png and /dev/null differ diff --git a/tools/OcrDaemon/tessdata/images/vertex2_tight.png b/tools/OcrDaemon/tessdata/images/vertex2_tight.png deleted file mode 100644 index b971e17..0000000 Binary files a/tools/OcrDaemon/tessdata/images/vertex2_tight.png and /dev/null differ diff --git a/tools/test-easyocr.js b/tools/test-easyocr.js new file mode 100644 index 0000000..49b247b --- /dev/null +++ b/tools/test-easyocr.js @@ -0,0 +1,104 @@ +import { spawn } from 'child_process'; +import { join } from 'path'; + +const EXE = join('tools', 'OcrDaemon', 'bin', 'Release', 'net8.0-windows10.0.19041.0', 'OcrDaemon.exe'); +const TESSDATA = join('tools', 'OcrDaemon', 'bin', 'Release', 'net8.0-windows10.0.19041.0', 'tessdata'); +const SAVE_DIR = join('tools', 'OcrDaemon', 'bin', 'Release', 'net8.0-windows10.0.19041.0', 'tessdata', 'images'); + +const expected = { + vertex1: [ + 'The Vertex', 'Tribal Mask', 'Helmet', 'Quality: +20%', + 'Evasion Rating: 79', 'Energy Shield: 34', 'Requires: Level 33', + '16% Increased Life Regeneration Rate', 'Has no Attribute Requirements', + '+15% to Chaos Resistance', 'Skill gems have no attribute requirements', + '+3 to level of all skills', '15% increased mana cost efficiency', + 'Twice Corrupted', 'Asking Price:', '7x Divine Orb', + ], + vertex2: [ + 'The Vertex', 'Tribal Mask', 'Helmet', 'Quality: +20%', + 'Evasion Rating: 182', 'Energy Shield: 77', 'Requires: Level 33', + '+29 To Spirit', '+1 to Level of All Minion Skills', + 'Has no Attribute Requirements', '130% increased Evasion and Energy Shield', + '27% Increased Critical Hit Chance', '+13% to Chaos Resistance', + '+2 to level of all skills', 'Twice Corrupted', 'Asking Price:', '35x Divine Orb', + ], +}; + +function levenshteinSim(a, b) { + a = a.toLowerCase(); b = b.toLowerCase(); + if (a === b) return 1; + const la = a.length, lb = b.length; + if (!la || !lb) return 0; + const d = Array.from({ length: la + 1 }, (_, i) => { const r = new Array(lb + 1); r[0] = i; return r; }); + for (let j = 0; j <= lb; j++) d[0][j] = j; + for (let i = 1; i <= la; i++) + for (let j = 1; j <= lb; j++) { + const cost = a[i-1] === b[j-1] ? 0 : 1; + d[i][j] = Math.min(d[i-1][j]+1, d[i][j-1]+1, d[i-1][j-1]+cost); + } + return 1 - d[la][lb] / Math.max(la, lb); +} + +async function run() { + const proc = spawn(EXE, [], { stdio: ['pipe', 'pipe', 'pipe'] }); + let buffer = ''; + let resolveNext; + proc.stdout.on('data', (data) => { + buffer += data.toString(); + let idx; + while ((idx = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + if (!line) continue; + try { const p = JSON.parse(line); if (resolveNext) { const r = resolveNext; resolveNext = null; r(p); } } catch {} + } + }); + proc.stderr.on('data', (data) => process.stderr.write(data)); + function sendCmd(cmd) { return new Promise((resolve) => { resolveNext = resolve; proc.stdin.write(JSON.stringify(cmd) + '\n'); }); } + await new Promise((resolve) => { resolveNext = resolve; }); + + const cases = [ + { id: 'vertex1', image: 'vertex1.png', snapshot: 'vertex-snapshot.png' }, + { id: 'vertex2', image: 'vertex2.png', snapshot: 'vertex-snapshot.png' }, + ]; + + for (const tc of cases) { + const snapPath = join(TESSDATA, 'images', tc.snapshot); + const imgPath = join(TESSDATA, 'images', tc.image); + + // 3 runs: first saves crop, rest just timing + for (let i = 0; i < 3; i++) { + await sendCmd({ cmd: 'snapshot', file: snapPath }); + const savePath = i === 0 ? join(SAVE_DIR, `${tc.id}_easyocr_crop.png`) : undefined; + const t0 = performance.now(); + const resp = await sendCmd({ cmd: 'diff-ocr', file: imgPath, engine: 'easyocr', ...(savePath ? { path: savePath } : {}) }); + const ms = (performance.now() - t0).toFixed(0); + const region = resp.region; + const lines = (resp.lines || []).map(l => l.text.trim()).filter(l => l.length > 0); + + if (i === 0) { + // Accuracy check on first run + const exp = expected[tc.id]; + const used = new Set(); + let matched = 0, fuzzy = 0, missed = 0; + for (const e of exp) { + let bestIdx = -1, bestSim = 0; + for (let j = 0; j < lines.length; j++) { + if (used.has(j)) continue; + const sim = levenshteinSim(e, lines[j]); + if (sim > bestSim) { bestSim = sim; bestIdx = j; } + } + if (bestIdx >= 0 && bestSim >= 0.75) { used.add(bestIdx); if (bestSim >= 0.95) matched++; else fuzzy++; } + else { missed++; console.log(` MISS: ${e}${bestIdx >= 0 ? ` (best: "${lines[bestIdx]}", sim=${bestSim.toFixed(2)})` : ''}`); } + } + console.log(`${tc.id}: ${ms}ms crop=${region?.width}x${region?.height} at (${region?.x},${region?.y}) ${matched} OK / ${fuzzy}~ / ${missed} miss lines=${lines.length}${savePath ? ' [saved]' : ''}`); + } else { + console.log(`${tc.id}: ${ms}ms crop=${region?.width}x${region?.height}`); + } + } + console.log(); + } + proc.stdin.end(); + proc.kill(); +} +run().catch(console.error);