dollar sign recognizer
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user