recognition types and preprocessing

This commit is contained in:
2026-02-17 09:57:22 +02:00
parent 1b814d9cd4
commit 4851639aad
2 changed files with 167 additions and 0 deletions
+137
View File
@@ -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)
}
+30
View File
@@ -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
}