dollar sign recognizer

This commit is contained in:
2026-02-19 19:42:06 +02:00
parent 4851639aad
commit 495ead0d13
+213
View File
@@ -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<string, number>()
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
}