Files
soroban-abacus-flashcards/apps/web/src/lib/vision/columnClassifier.ts
Thomas Hallock 8846cece93 feat(vision): improve remote camera calibration and UX
- Fix remote camera autocrop rotation by swapping ArUco corners for phone camera
  (detectMarkers assumes Desk View orientation which is 180° rotated)
- Add rotate left/right buttons to CalibrationOverlay for manual calibration
- Fix mode switching bug: switching to auto mode now clears desktop calibration
  on phone via new 'remote-camera:clear-calibration' socket event
- Add copy button to QR code URL with visual feedback
- Fix text selection spanning into video feed with userSelect: none
- Add flip camera and torch controls to local camera UI
- Add session persistence for remote camera reconnection
- Fix 4:3 aspect ratio for cropped abacus output

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 22:04:06 -06:00

286 lines
7.0 KiB
TypeScript

/**
* TensorFlow.js Column Classifier
*
* Lazy-loads the TensorFlow.js model for abacus column digit classification.
* Follows the lazy-loading pattern from useDocumentDetection.ts.
*/
// TensorFlow.js types (dynamically imported)
type TFLite = typeof import('@tensorflow/tfjs')
type LayersModel = import('@tensorflow/tfjs').LayersModel
// Model configuration
const MODEL_PATH = '/models/abacus-column-classifier/model.json'
const INPUT_WIDTH = 64
const INPUT_HEIGHT = 128
const NUM_CLASSES = 10
// Cached model and TensorFlow instance
let tfInstance: TFLite | null = null
let modelInstance: LayersModel | null = null
let modelLoadPromise: Promise<LayersModel | null> | null = null
let modelCheckFailed = false // Track if model doesn't exist
/**
* Lazy load TensorFlow.js
*/
async function loadTensorFlow(): Promise<TFLite> {
if (tfInstance) return tfInstance
// Dynamic import for code splitting
const tf = await import('@tensorflow/tfjs')
// Use WebGL backend for GPU acceleration
await tf.setBackend('webgl')
await tf.ready()
tfInstance = tf
return tf
}
/**
* Check if the model file exists before attempting to load it
*/
async function checkModelExists(): Promise<boolean> {
if (modelCheckFailed) return false
try {
const response = await fetch(MODEL_PATH, { method: 'HEAD' })
if (!response.ok) {
modelCheckFailed = true
return false
}
return true
} catch {
modelCheckFailed = true
return false
}
}
/**
* Lazy load the classification model
* Returns null if model doesn't exist (not yet trained)
*/
async function loadModel(): Promise<LayersModel | null> {
if (modelInstance) return modelInstance
if (modelCheckFailed) return null
// Prevent multiple simultaneous loads
if (modelLoadPromise) return modelLoadPromise
modelLoadPromise = (async () => {
// Check if model exists before trying to load
const exists = await checkModelExists()
if (!exists) {
console.warn(
'[ColumnClassifier] Model not found at',
MODEL_PATH,
'- ML classification disabled. Vision will work in manual mode only.'
)
return null
}
const tf = await loadTensorFlow()
console.log('[ColumnClassifier] Loading model from', MODEL_PATH)
const startTime = performance.now()
try {
// Load as LayersModel (exported from Keras)
const model = await tf.loadLayersModel(MODEL_PATH)
const loadTime = performance.now() - startTime
console.log(`[ColumnClassifier] Model loaded in ${loadTime.toFixed(0)}ms`)
modelInstance = model
return model
} catch (error) {
console.error('[ColumnClassifier] Failed to load model:', error)
modelLoadPromise = null
modelCheckFailed = true
return null
}
})()
return modelLoadPromise
}
/**
* Preprocess an image for classification
*
* @param imageData - Raw image data from canvas
* @returns Preprocessed tensor ready for inference
*/
async function preprocessImage(imageData: ImageData): Promise<import('@tensorflow/tfjs').Tensor4D> {
const tf = await loadTensorFlow()
// Convert ImageData to tensor
const tensor = tf.browser.fromPixels(imageData, 1) // Grayscale
// Resize to model input size
const resized = tf.image.resizeBilinear(tensor, [INPUT_HEIGHT, INPUT_WIDTH])
// Normalize to [0, 1]
const normalized = resized.div(255)
// Add batch dimension
const batched = normalized.expandDims(0) as import('@tensorflow/tfjs').Tensor4D
// Clean up intermediate tensors
tensor.dispose()
resized.dispose()
normalized.dispose()
return batched
}
export interface ClassificationResult {
/** Predicted digit (0-9) */
digit: number
/** Confidence score (0-1) */
confidence: number
/** All class probabilities */
probabilities: number[]
}
/**
* Classify a single column image
*
* @param imageData - Grayscale image data of a single abacus column
* @returns Classification result with digit and confidence, or null if model not available
*/
export async function classifyColumn(imageData: ImageData): Promise<ClassificationResult | null> {
const model = await loadModel()
if (!model) return null
const tf = await loadTensorFlow()
// Preprocess
const input = await preprocessImage(imageData)
// Run inference
const output = model.predict(input) as import('@tensorflow/tfjs').Tensor
// Get probabilities
const probabilities = await output.data()
// Find predicted class
let maxProb = 0
let predictedDigit = 0
for (let i = 0; i < NUM_CLASSES; i++) {
if (probabilities[i] > maxProb) {
maxProb = probabilities[i]
predictedDigit = i
}
}
// Clean up tensors
input.dispose()
output.dispose()
return {
digit: predictedDigit,
confidence: maxProb,
probabilities: Array.from(probabilities),
}
}
/**
* Classify multiple column images in a batch
*
* @param columnImages - Array of grayscale image data for each column
* @returns Array of classification results, or null if model not available
*/
export async function classifyColumns(
columnImages: ImageData[]
): Promise<ClassificationResult[] | null> {
if (columnImages.length === 0) return []
const model = await loadModel()
if (!model) return null
const tf = await loadTensorFlow()
// Preprocess all images
const preprocessed = await Promise.all(columnImages.map((img) => preprocessImage(img)))
// Stack into a batch
const batch = tf.concat(preprocessed, 0)
// Run inference on batch
const output = model.predict(batch) as import('@tensorflow/tfjs').Tensor
// Get all probabilities
const allProbs = await output.data()
// Parse results
const results: ClassificationResult[] = []
for (let i = 0; i < columnImages.length; i++) {
const start = i * NUM_CLASSES
const probs = Array.from(allProbs.slice(start, start + NUM_CLASSES))
let maxProb = 0
let predictedDigit = 0
for (let j = 0; j < NUM_CLASSES; j++) {
if (probs[j] > maxProb) {
maxProb = probs[j]
predictedDigit = j
}
}
results.push({
digit: predictedDigit,
confidence: maxProb,
probabilities: probs,
})
}
// Clean up tensors
preprocessed.forEach((t) => t.dispose())
batch.dispose()
output.dispose()
return results
}
/**
* Check if the model is currently loaded
*/
export function isModelLoaded(): boolean {
return modelInstance !== null
}
/**
* Check if model loading has failed (model doesn't exist)
*/
export function isModelUnavailable(): boolean {
return modelCheckFailed
}
/**
* Preload the model (for eager initialization)
* Returns true if model loaded successfully, false if unavailable
*/
export async function preloadModel(): Promise<boolean> {
const model = await loadModel()
return model !== null
}
/**
* Dispose of the model to free memory
*/
export function disposeModel(): void {
if (modelInstance) {
modelInstance.dispose()
modelInstance = null
modelLoadPromise = null
}
}
/**
* Get model input dimensions
*/
export function getModelInputSize(): { width: number; height: number } {
return { width: INPUT_WIDTH, height: INPUT_HEIGHT }
}