recognition types and preprocessing
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import type { Stroke } from './types'
|
||||
|
||||
interface BoundingBox {
|
||||
minX: number
|
||||
minY: number
|
||||
maxX: number
|
||||
maxY: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export function getBoundingBox(strokes: Stroke[]): BoundingBox {
|
||||
let minX = Infinity, minY = Infinity
|
||||
let maxX = -Infinity, maxY = -Infinity
|
||||
|
||||
for (const stroke of strokes) {
|
||||
for (const pt of stroke) {
|
||||
if (pt.x < minX) minX = pt.x
|
||||
if (pt.y < minY) minY = pt.y
|
||||
if (pt.x > maxX) maxX = pt.x
|
||||
if (pt.y > maxY) maxY = pt.y
|
||||
}
|
||||
}
|
||||
|
||||
return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY }
|
||||
}
|
||||
|
||||
// normalize stroke coordinates to 0-1 range within bounding box
|
||||
export function normalizeStrokes(strokes: Stroke[]): Stroke[] {
|
||||
const bb = getBoundingBox(strokes)
|
||||
if (bb.width === 0 && bb.height === 0) return strokes
|
||||
|
||||
const scale = Math.max(bb.width, bb.height)
|
||||
const offsetX = (scale - bb.width) / 2
|
||||
const offsetY = (scale - bb.height) / 2
|
||||
|
||||
return strokes.map(stroke =>
|
||||
stroke.map(pt => ({
|
||||
...pt,
|
||||
x: (pt.x - bb.minX + offsetX) / scale,
|
||||
y: (pt.y - bb.minY + offsetY) / scale,
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
// render strokes to a grayscale image for ONNX (28x28)
|
||||
// torchvision handles EMNIST transpose internally, so model expects
|
||||
// normal orientation. apply same normalization as training: (x-0.5)/0.5
|
||||
export function strokesToImage28(strokes: Stroke[]): Float32Array {
|
||||
const size = 28
|
||||
const padding = 4
|
||||
const inner = size - padding * 2
|
||||
const data = new Float32Array(size * size)
|
||||
|
||||
const bb = getBoundingBox(strokes)
|
||||
if (bb.width === 0 && bb.height === 0) return data
|
||||
|
||||
const scale = Math.max(bb.width, bb.height)
|
||||
const offsetX = (scale - bb.width) / 2
|
||||
const offsetY = (scale - bb.height) / 2
|
||||
|
||||
for (const stroke of strokes) {
|
||||
for (let i = 1; i < stroke.length; i++) {
|
||||
const x0 = ((stroke[i - 1].x - bb.minX + offsetX) / scale) * inner + padding
|
||||
const y0 = ((stroke[i - 1].y - bb.minY + offsetY) / scale) * inner + padding
|
||||
const x1 = ((stroke[i].x - bb.minX + offsetX) / scale) * inner + padding
|
||||
const y1 = ((stroke[i].y - bb.minY + offsetY) / scale) * inner + padding
|
||||
|
||||
const dist = Math.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2)
|
||||
const steps = Math.max(Math.ceil(dist * 2), 1)
|
||||
for (let s = 0; s <= steps; s++) {
|
||||
const t = s / steps
|
||||
const px = Math.round(x0 + (x1 - x0) * t)
|
||||
const py = Math.round(y0 + (y1 - y0) * t)
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
const ix = px + dx
|
||||
const iy = py + dy
|
||||
if (ix >= 0 && ix < size && iy >= 0 && iy < size) {
|
||||
data[iy * size + ix] = 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// transpose (swap rows and columns) to match model's expected orientation
|
||||
const out = new Float32Array(size * size)
|
||||
for (let r = 0; r < size; r++) {
|
||||
for (let c = 0; c < size; c++) {
|
||||
out[c * size + r] = (data[r * size + c] - 0.5) / 0.5
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// render strokes to a canvas ImageData for Tesseract (black on white, larger)
|
||||
export function strokesToCanvas(strokes: Stroke[], size: number = 128): ImageData {
|
||||
const canvas = new OffscreenCanvas(size, size)
|
||||
const ctx = canvas.getContext('2d')!
|
||||
|
||||
// white background
|
||||
ctx.fillStyle = 'white'
|
||||
ctx.fillRect(0, 0, size, size)
|
||||
|
||||
const bb = getBoundingBox(strokes)
|
||||
if (bb.width === 0 && bb.height === 0) {
|
||||
return ctx.getImageData(0, 0, size, size)
|
||||
}
|
||||
|
||||
const padding = 10
|
||||
const inner = size - padding * 2
|
||||
const scale = Math.max(bb.width, bb.height)
|
||||
|
||||
ctx.strokeStyle = 'black'
|
||||
ctx.lineWidth = 4
|
||||
ctx.lineCap = 'round'
|
||||
ctx.lineJoin = 'round'
|
||||
|
||||
for (const stroke of strokes) {
|
||||
if (stroke.length < 2) continue
|
||||
ctx.beginPath()
|
||||
const x0 = ((stroke[0].x - bb.minX) / scale) * inner + padding
|
||||
const y0 = ((stroke[0].y - bb.minY) / scale) * inner + padding
|
||||
ctx.moveTo(x0, y0)
|
||||
for (let i = 1; i < stroke.length; i++) {
|
||||
const x = ((stroke[i].x - bb.minX) / scale) * inner + padding
|
||||
const y = ((stroke[i].y - bb.minY) / scale) * inner + padding
|
||||
ctx.lineTo(x, y)
|
||||
}
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
return ctx.getImageData(0, 0, size, size)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export interface StrokePoint {
|
||||
x: number
|
||||
y: number
|
||||
pressure: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export type Stroke = StrokePoint[]
|
||||
|
||||
export interface RecognitionCandidate {
|
||||
letter: string
|
||||
confidence: number
|
||||
}
|
||||
|
||||
export interface EngineResult {
|
||||
engine: 'onnx' | 'tesseract' | 'stroke'
|
||||
candidates: RecognitionCandidate[]
|
||||
}
|
||||
|
||||
export interface EnsembleResult {
|
||||
candidates: RecognitionCandidate[]
|
||||
decision: 'auto-fill' | 'disambiguate' | 'redraw'
|
||||
}
|
||||
|
||||
export interface RecognitionEngine {
|
||||
name: string
|
||||
init(): Promise<void>
|
||||
recognize(strokes: Stroke[]): Promise<RecognitionCandidate[]>
|
||||
isReady(): boolean
|
||||
}
|
||||
Reference in New Issue
Block a user