From 495ead0d13729d413880c62b2b15f58502673c56 Mon Sep 17 00:00:00 2001 From: lashman Date: Thu, 19 Feb 2026 19:42:06 +0200 Subject: [PATCH] dollar sign recognizer --- frontend/src/recognition/strokeMatcher.ts | 213 ++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 frontend/src/recognition/strokeMatcher.ts diff --git a/frontend/src/recognition/strokeMatcher.ts b/frontend/src/recognition/strokeMatcher.ts new file mode 100644 index 0000000..dd7e23a --- /dev/null +++ b/frontend/src/recognition/strokeMatcher.ts @@ -0,0 +1,213 @@ +import type { Stroke, RecognitionCandidate } from './types' + +// $1 Unistroke Recognizer adapted for letter recognition. +// Resamples strokes to N points, rotates to canonical angle, +// scales to a unit square, then compares against templates +// using path distance. Handles variation in size, speed, and angle. + +const N = 64 // resample point count +const SQUARE_SIZE = 250 + +interface Point { x: number; y: number } + +function resample(points: Point[], n: number): Point[] { + const totalLen = pathLength(points) + const interval = totalLen / (n - 1) + const out: Point[] = [points[0]] + let D = 0 + + for (let i = 1; i < points.length; i++) { + const d = dist(points[i - 1], points[i]) + if (D + d >= interval) { + const t = (interval - D) / d + const q: Point = { + x: points[i - 1].x + t * (points[i].x - points[i - 1].x), + y: points[i - 1].y + t * (points[i].y - points[i - 1].y), + } + out.push(q) + points.splice(i, 0, q) + D = 0 + } else { + D += d + } + } + + while (out.length < n) out.push(points[points.length - 1]) + return out.slice(0, n) +} + +function rotateToZero(points: Point[]): Point[] { + const c = centroid(points) + const angle = Math.atan2(c.y - points[0].y, c.x - points[0].x) + return rotateBy(points, -angle) +} + +function scaleToSquare(points: Point[]): Point[] { + const bb = boundingBox(points) + const w = bb.maxX - bb.minX + const h = bb.maxY - bb.minY + if (w === 0 || h === 0) return points + return points.map(p => ({ + x: p.x * (SQUARE_SIZE / w), + y: p.y * (SQUARE_SIZE / h), + })) +} + +function translateToOrigin(points: Point[]): Point[] { + const c = centroid(points) + return points.map(p => ({ x: p.x - c.x, y: p.y - c.y })) +} + +function pathDistance(a: Point[], b: Point[]): number { + let d = 0 + for (let i = 0; i < Math.min(a.length, b.length); i++) { + d += dist(a[i], b[i]) + } + return d / Math.min(a.length, b.length) +} + +function dist(a: Point, b: Point): number { + return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2) +} + +function pathLength(points: Point[]): number { + let d = 0 + for (let i = 1; i < points.length; i++) d += dist(points[i - 1], points[i]) + return d +} + +function centroid(points: Point[]): Point { + let x = 0, y = 0 + for (const p of points) { x += p.x; y += p.y } + return { x: x / points.length, y: y / points.length } +} + +function boundingBox(points: Point[]) { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity + for (const p of points) { + if (p.x < minX) minX = p.x + if (p.y < minY) minY = p.y + if (p.x > maxX) maxX = p.x + if (p.y > maxY) maxY = p.y + } + return { minX, minY, maxX, maxY } +} + +function rotateBy(points: Point[], angle: number): Point[] { + const c = centroid(points) + const cos = Math.cos(angle) + const sin = Math.sin(angle) + return points.map(p => ({ + x: (p.x - c.x) * cos - (p.y - c.y) * sin + c.x, + y: (p.x - c.x) * sin + (p.y - c.y) * cos + c.y, + })) +} + +function normalize(points: Point[]): Point[] { + let p = resample(points, N) + p = rotateToZero(p) + p = scaleToSquare(p) + p = translateToOrigin(p) + return p +} + +// generate a template from a set of points defined as relative coordinates +function makeTemplate(coords: [number, number][]): Point[] { + const points = coords.map(([x, y]) => ({ x, y })) + return normalize(points) +} + +// letter templates defined as coordinate paths +// drawn at roughly 100x100 scale +const LETTER_TEMPLATES: { letter: string; points: Point[] }[] = [] + +function addTemplate(letter: string, coords: [number, number][]) { + LETTER_TEMPLATES.push({ letter, points: makeTemplate(coords) }) +} + +// uppercase printing forms +addTemplate('A', [[0,100],[50,0],[100,100],[75,50],[25,50]]) +addTemplate('A', [[50,0],[0,100],[0,100],[25,50],[75,50],[100,100]]) +addTemplate('B', [[0,100],[0,0],[80,0],[80,40],[0,50],[80,50],[80,100],[0,100]]) +addTemplate('C', [[100,10],[50,0],[0,30],[0,70],[50,100],[100,90]]) +addTemplate('D', [[0,100],[0,0],[70,0],[100,30],[100,70],[70,100],[0,100]]) +addTemplate('E', [[100,0],[0,0],[0,50],[70,50],[0,50],[0,100],[100,100]]) +addTemplate('F', [[100,0],[0,0],[0,50],[70,50],[0,50],[0,100]]) +addTemplate('G', [[100,10],[50,0],[0,30],[0,70],[50,100],[100,70],[100,50],[50,50]]) +addTemplate('H', [[0,0],[0,100],[0,50],[100,50],[100,0],[100,100]]) +addTemplate('I', [[50,0],[50,100]]) +addTemplate('J', [[80,0],[80,80],[50,100],[20,80]]) +addTemplate('K', [[0,0],[0,100],[0,50],[100,0],[0,50],[100,100]]) +addTemplate('L', [[0,0],[0,100],[100,100]]) +addTemplate('M', [[0,100],[0,0],[50,40],[100,0],[100,100]]) +addTemplate('N', [[0,100],[0,0],[100,100],[100,0]]) +addTemplate('O', [[50,0],[0,30],[0,70],[50,100],[100,70],[100,30],[50,0]]) +addTemplate('P', [[0,100],[0,0],[80,0],[80,50],[0,50]]) +addTemplate('Q', [[50,0],[0,30],[0,70],[50,100],[100,70],[100,30],[50,0],[80,80],[100,100]]) +addTemplate('R', [[0,100],[0,0],[80,0],[80,50],[0,50],[100,100]]) +addTemplate('S', [[100,10],[60,0],[20,10],[0,30],[20,45],[80,55],[100,70],[80,90],[40,100],[0,90]]) +addTemplate('T', [[0,0],[100,0],[50,0],[50,100]]) +addTemplate('U', [[0,0],[0,80],[30,100],[70,100],[100,80],[100,0]]) +addTemplate('V', [[0,0],[50,100],[100,0]]) +addTemplate('W', [[0,0],[25,100],[50,40],[75,100],[100,0]]) +addTemplate('X', [[0,0],[100,100],[50,50],[100,0],[0,100]]) +addTemplate('Y', [[0,0],[50,50],[100,0],[50,50],[50,100]]) +addTemplate('Z', [[0,0],[100,0],[0,100],[100,100]]) + +// lowercase variants that differ from uppercase +addTemplate('A', [[80,40],[60,20],[30,20],[10,40],[10,70],[30,90],[60,90],[80,70],[80,40],[80,90]]) // circle+stick a +addTemplate('B', [[10,0],[10,100],[10,60],[40,50],[70,60],[70,80],[40,100],[10,100]]) // round b +addTemplate('D', [[80,0],[80,100],[80,60],[50,50],[20,60],[20,80],[50,100],[80,100]]) // round d +addTemplate('E', [[70,50],[20,50],[10,40],[20,20],[50,15],[80,25],[80,50],[60,70],[30,80],[10,65]]) // curly e +addTemplate('G', [[80,40],[60,20],[30,20],[10,40],[10,70],[30,90],[60,90],[80,70],[80,100]]) // g with tail +addTemplate('N', [[10,100],[10,40],[10,30],[40,30],[70,40],[70,100]]) // lowercase n hump +addTemplate('R', [[10,100],[10,40],[30,30],[60,35]]) // lowercase r + +// single-stroke S variants (people draw S many ways) +addTemplate('S', [[80,15],[50,0],[15,15],[10,35],[40,50],[70,55],[90,70],[85,90],[50,100],[15,85]]) +addTemplate('S', [[70,0],[30,0],[10,20],[10,40],[50,50],[90,60],[90,80],[70,100],[30,100]]) + +export function recognizeStroke(strokes: Stroke[]): RecognitionCandidate[] { + if (strokes.length === 0 || strokes.every(s => s.length < 3)) return [] + + // combine all strokes into one path (for multi-stroke letters) + const combined: Point[] = [] + for (const stroke of strokes) { + for (const pt of stroke) { + combined.push({ x: pt.x, y: pt.y }) + } + } + + if (combined.length < 3) return [] + + const input = normalize(combined) + + const scores: { letter: string; distance: number }[] = [] + for (const tmpl of LETTER_TEMPLATES) { + const d = pathDistance(input, tmpl.points) + scores.push({ letter: tmpl.letter, distance: d }) + } + + // keep best score per letter + const best = new Map() + for (const s of scores) { + const prev = best.get(s.letter) + if (prev === undefined || s.distance < prev) { + best.set(s.letter, s.distance) + } + } + + // convert distance to confidence (lower distance = higher confidence) + // typical distances range from 0 (perfect) to ~150 (bad match) + const maxDist = 120 + const candidates = Array.from(best.entries()) + .map(([letter, distance]) => ({ + letter, + confidence: Math.max(0, 1 - distance / maxDist), + })) + .filter(c => c.confidence > 0.05) + .sort((a, b) => b.confidence - a.confidence) + .slice(0, 5) + + return candidates +}