diff --git a/frontend/src/recognition/tesseractEngine.ts b/frontend/src/recognition/tesseractEngine.ts new file mode 100644 index 0000000..fb8a30c --- /dev/null +++ b/frontend/src/recognition/tesseractEngine.ts @@ -0,0 +1,57 @@ +import type { Stroke, RecognitionCandidate } from './types' +import { strokesToCanvas } from './preprocess' +import type Tesseract from 'tesseract.js' +import type { PSM } from 'tesseract.js' + +let worker: Tesseract.Worker | null = null +let loading = false + +export function isReady(): boolean { + return worker !== null +} + +export async function init(): Promise { + if (worker || loading) return + loading = true + + try { + const Tesseract = await import('tesseract.js') + worker = await Tesseract.createWorker('eng') + await worker.setParameters({ + tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + // SAFETY: '10' is exactly PSM.SINGLE_CHAR, so the literal lands inside the enum's value set + tessedit_pageseg_mode: '10' as PSM, // single character + }) + } catch (e) { + console.warn('Tesseract init failed:', e) + worker = null + } + loading = false +} + +export async function recognize(strokes: Stroke[]): Promise { + if (!worker) return [] + + try { + const imageData = strokesToCanvas(strokes, 128) + + // convert ImageData to canvas for tesseract + const canvas = new OffscreenCanvas(128, 128) + const ctx = canvas.getContext('2d')! + ctx.putImageData(imageData, 0, 0) + const blob = await canvas.convertToBlob({ type: 'image/png' }) + + const result = await worker.recognize(blob) + const text = result.data.text.trim().toUpperCase() + + if (text.length === 1 && text >= 'A' && text <= 'Z') { + const confidence = result.data.confidence / 100 + return [{ letter: text, confidence }] + } + + return [] + } catch (e) { + console.warn('Tesseract recognition failed:', e) + return [] + } +}