diff --git a/apps/web/scripts/train-column-classifier/train_model.py b/apps/web/scripts/train-column-classifier/train_model.py index f9cd7bb7..57b94b03 100644 --- a/apps/web/scripts/train-column-classifier/train_model.py +++ b/apps/web/scripts/train-column-classifier/train_model.py @@ -305,14 +305,16 @@ def main(): print("Install with: pip install tensorflow") sys.exit(1) - # Check tensorflowjs is available + # Check tensorflowjs is available (optional - can convert later) + tfjs_available = False try: import tensorflowjs print(f"TensorFlow.js converter version: {tensorflowjs.__version__}") - except ImportError: - print("Error: tensorflowjs not installed") - print("Install with: pip install tensorflowjs") - sys.exit(1) + tfjs_available = True + except (ImportError, AttributeError) as e: + print(f"Note: tensorflowjs not available ({type(e).__name__})") + print("Model will be saved as Keras format. Convert later with:") + print(" tensorflowjs_converter --input_format=keras model.keras output_dir/") print() @@ -352,9 +354,14 @@ def main(): # Save Keras model save_keras_model(model, args.output_dir) - # Export to TensorFlow.js - print("\nExporting to TensorFlow.js format...") - export_to_tfjs(model, args.output_dir) + # Export to TensorFlow.js (if available) + if tfjs_available: + print("\nExporting to TensorFlow.js format...") + export_to_tfjs(model, args.output_dir) + else: + print("\nSkipping TensorFlow.js export (tensorflowjs not available)") + print("Convert later with:") + print(f" tensorflowjs_converter --input_format=keras {args.output_dir}/column-classifier.keras {args.output_dir}") print("\nTraining complete!") print(f"Model files saved to: {args.output_dir}") diff --git a/apps/web/src/app/remote-camera/[sessionId]/page.tsx b/apps/web/src/app/remote-camera/[sessionId]/page.tsx index aa7c8fa3..c697bb73 100644 --- a/apps/web/src/app/remote-camera/[sessionId]/page.tsx +++ b/apps/web/src/app/remote-camera/[sessionId]/page.tsx @@ -182,8 +182,12 @@ export default function RemoteCameraPage() { if (isSending) { updateCalibration(desktopCalibration) } + } else if (usingDesktopCalibration) { + // Desktop cleared calibration - go back to auto-detection + setUsingDesktopCalibration(false) + setCalibration(null) } - }, [desktopCalibration, isSending, updateCalibration]) + }, [desktopCalibration, isSending, updateCalibration, usingDesktopCalibration]) // Auto-detect markers (always runs unless using desktop calibration) useEffect(() => { @@ -201,18 +205,28 @@ export default function RemoteCameraPage() { if (result.allMarkersFound && result.quadCorners) { // Auto-calibration successful! + // NOTE: detectMarkers() returns corners swapped for Desk View camera (180° rotated). + // Phone camera is NOT Desk View, so we need to swap corners back to get correct orientation. + // detectMarkers maps: marker 2 (physical BR) → topLeft, marker 0 (physical TL) → bottomRight + // For phone camera we need: marker 0 (physical TL) → topLeft, marker 2 (physical BR) → bottomRight + const phoneCorners = { + topLeft: result.quadCorners.bottomRight, // marker 0 (physical TL) + topRight: result.quadCorners.bottomLeft, // marker 1 (physical TR) + bottomRight: result.quadCorners.topLeft, // marker 2 (physical BR) + bottomLeft: result.quadCorners.topRight, // marker 3 (physical BL) + } const grid: CalibrationGrid = { roi: { - x: Math.min(result.quadCorners.topLeft.x, result.quadCorners.bottomLeft.x), - y: Math.min(result.quadCorners.topLeft.y, result.quadCorners.topRight.y), + x: Math.min(phoneCorners.topLeft.x, phoneCorners.bottomLeft.x), + y: Math.min(phoneCorners.topLeft.y, phoneCorners.topRight.y), width: - Math.max(result.quadCorners.topRight.x, result.quadCorners.bottomRight.x) - - Math.min(result.quadCorners.topLeft.x, result.quadCorners.bottomLeft.x), + Math.max(phoneCorners.topRight.x, phoneCorners.bottomRight.x) - + Math.min(phoneCorners.topLeft.x, phoneCorners.bottomLeft.x), height: - Math.max(result.quadCorners.bottomLeft.y, result.quadCorners.bottomRight.y) - - Math.min(result.quadCorners.topLeft.y, result.quadCorners.topRight.y), + Math.max(phoneCorners.bottomLeft.y, phoneCorners.bottomRight.y) - + Math.min(phoneCorners.topLeft.y, phoneCorners.topRight.y), }, - corners: result.quadCorners, + corners: phoneCorners, columnCount: 13, columnDividers: Array.from({ length: 12 }, (_, i) => (i + 1) / 13), rotation: 0, @@ -221,7 +235,7 @@ export default function RemoteCameraPage() { // Update the calibration for the sending loop and switch to cropped mode // BUT: don't switch to cropped if desktop is actively calibrating (they need raw frames) if (isSending && !desktopIsCalibrating) { - updateCalibration(result.quadCorners) + updateCalibration(phoneCorners) setFrameMode('cropped') } } diff --git a/apps/web/src/components/vision/AbacusVisionBridge.tsx b/apps/web/src/components/vision/AbacusVisionBridge.tsx index ffbfbd56..18fafc27 100644 --- a/apps/web/src/components/vision/AbacusVisionBridge.tsx +++ b/apps/web/src/components/vision/AbacusVisionBridge.tsx @@ -90,6 +90,7 @@ export function AbacusVisionBridge({ unsubscribe: remoteUnsubscribe, setPhoneFrameMode: remoteSetPhoneFrameMode, sendCalibration: remoteSendCalibration, + clearCalibration: remoteClearCalibration, } = useRemoteCameraDesktop() // Handle switching to phone camera @@ -129,12 +130,15 @@ export function AbacusVisionBridge({ // Tell phone to use its auto-calibration (cropped frames) remoteSetPhoneFrameMode('cropped') setRemoteIsCalibrating(false) + // Clear desktop calibration on phone so it goes back to auto-detection + remoteClearCalibration() + setRemoteCalibration(null) } else { // Tell phone to send raw frames for desktop calibration remoteSetPhoneFrameMode('raw') } }, - [remoteSetPhoneFrameMode] + [remoteSetPhoneFrameMode, remoteClearCalibration] ) // Start remote camera calibration @@ -408,28 +412,94 @@ export function AbacusVisionBridge({ - {/* Camera selector (if multiple cameras and using local) */} - {cameraSource === 'local' && vision.availableDevices.length > 1 && ( - + {/* Camera selector (if multiple cameras) */} + {vision.availableDevices.length > 1 && ( + + )} + + {/* Flip camera button */} + + + {/* Torch toggle button (only if available) */} + {vision.isTorchAvailable && ( + + )} + )} {/* Calibration mode toggle (both local and phone camera) */} @@ -636,6 +706,7 @@ export function AbacusVisionBridge({ borderRadius: 'lg', overflow: 'hidden', minHeight: '200px', + userSelect: 'none', // Prevent text selection from spanning into video feed })} > {!remoteCameraSessionId ? ( @@ -652,7 +723,7 @@ export function AbacusVisionBridge({ ) : !remoteIsPhoneConnected ? ( - /* Waiting for phone to connect */ + /* Waiting for phone to connect/reconnect - reuse existing session */

Waiting for phone to connect...

- +
) : ( /* Show camera frames */ diff --git a/apps/web/src/components/vision/CalibrationOverlay.tsx b/apps/web/src/components/vision/CalibrationOverlay.tsx index 3f072cbb..30138968 100644 --- a/apps/web/src/components/vision/CalibrationOverlay.tsx +++ b/apps/web/src/components/vision/CalibrationOverlay.tsx @@ -284,6 +284,32 @@ export function CalibrationOverlay({ dragStartRef.current = null }, []) + /** + * Rotate corners 90° clockwise or counter-clockwise around the quad center + * This reassigns corner labels, not their positions + */ + const handleRotate = useCallback((direction: 'left' | 'right') => { + setCorners((prev) => { + if (direction === 'right') { + // Rotate 90° clockwise: TL→TR, TR→BR, BR→BL, BL→TL + return { + topLeft: prev.bottomLeft, + topRight: prev.topLeft, + bottomRight: prev.topRight, + bottomLeft: prev.bottomRight, + } + } else { + // Rotate 90° counter-clockwise: TL→BL, BL→BR, BR→TR, TR→TL + return { + topLeft: prev.topRight, + topRight: prev.bottomRight, + bottomRight: prev.bottomLeft, + bottomLeft: prev.topLeft, + } + } + }) + }, []) + // Handle complete const handleComplete = useCallback(() => { const grid: CalibrationGrid = { @@ -547,6 +573,51 @@ export function CalibrationOverlay({ gap: 2, })} > + {/* Rotation buttons */} + + ) } diff --git a/apps/web/src/hooks/useAbacusVision.ts b/apps/web/src/hooks/useAbacusVision.ts index 20f0db59..4080ef9f 100644 --- a/apps/web/src/hooks/useAbacusVision.ts +++ b/apps/web/src/hooks/useAbacusVision.ts @@ -431,6 +431,9 @@ export function useAbacusVision(options: UseAbacusVisionOptions = {}): UseAbacus selectedDeviceId: camera.currentDevice?.deviceId ?? null, availableDevices: camera.availableDevices, isDeskViewDetected: camera.isDeskViewDetected, + facingMode: camera.facingMode, + isTorchOn: camera.isTorchOn, + isTorchAvailable: camera.isTorchAvailable, // Calibration state calibrationGrid: calibration.calibration, @@ -451,5 +454,7 @@ export function useAbacusVision(options: UseAbacusVisionOptions = {}): UseAbacus selectCamera, resetCalibration, setCalibrationMode, + flipCamera: camera.flipCamera, + toggleTorch: camera.toggleTorch, } } diff --git a/apps/web/src/hooks/useColumnClassifier.ts b/apps/web/src/hooks/useColumnClassifier.ts index 14398a49..eb193007 100644 --- a/apps/web/src/hooks/useColumnClassifier.ts +++ b/apps/web/src/hooks/useColumnClassifier.ts @@ -144,6 +144,9 @@ export function useColumnClassifier(): UseColumnClassifierReturn { const results = await classifier.classifyColumns(columnImages) + // Model unavailable + if (!results) return null + return { digits: results.map((r) => r.digit), confidences: results.map((r) => r.confidence), diff --git a/apps/web/src/hooks/useDeskViewCamera.ts b/apps/web/src/hooks/useDeskViewCamera.ts index c627500a..3fd552e5 100644 --- a/apps/web/src/hooks/useDeskViewCamera.ts +++ b/apps/web/src/hooks/useDeskViewCamera.ts @@ -16,6 +16,12 @@ export interface UseDeskViewCameraReturn { availableDevices: MediaDeviceInfo[] /** Whether Desk View camera was auto-detected */ isDeskViewDetected: boolean + /** Current facing mode */ + facingMode: 'user' | 'environment' + /** Whether torch is currently on */ + isTorchOn: boolean + /** Whether torch is available on current device */ + isTorchAvailable: boolean /** Request camera access, optionally specifying device ID */ requestCamera: (deviceId?: string) => Promise @@ -23,6 +29,10 @@ export interface UseDeskViewCameraReturn { stopCamera: () => void /** Refresh device list */ enumerateDevices: () => Promise + /** Flip between front and back camera */ + flipCamera: () => Promise + /** Toggle torch on/off */ + toggleTorch: () => Promise } /** @@ -38,9 +48,13 @@ export function useDeskViewCamera(): UseDeskViewCameraReturn { const [currentDevice, setCurrentDevice] = useState(null) const [availableDevices, setAvailableDevices] = useState([]) const [isDeskViewDetected, setIsDeskViewDetected] = useState(false) + const [facingMode, setFacingMode] = useState<'user' | 'environment'>('environment') + const [isTorchOn, setIsTorchOn] = useState(false) + const [isTorchAvailable, setIsTorchAvailable] = useState(false) const streamRef = useRef(null) const requestIdRef = useRef(0) // Track request ID to ignore stale completions + const facingModeRef = useRef<'user' | 'environment'>('environment') /** * Enumerate available video input devices @@ -80,6 +94,35 @@ export function useDeskViewCamera(): UseDeskViewCameraReturn { [isDeskViewDevice] ) + /** + * Check if torch is available on a video track + */ + const checkTorchAvailability = useCallback((track: MediaStreamTrack): boolean => { + try { + const capabilities = track.getCapabilities() as MediaTrackCapabilities & { + torch?: boolean + } + return capabilities.torch === true + } catch { + return false + } + }, []) + + /** + * Apply torch setting to track + */ + const applyTorch = useCallback(async (track: MediaStreamTrack, on: boolean): Promise => { + try { + await track.applyConstraints({ + advanced: [{ torch: on } as MediaTrackConstraintSet], + }) + return true + } catch (err) { + console.warn('[DeskViewCamera] Failed to apply torch:', err) + return false + } + }, []) + /** * Request camera access */ @@ -122,7 +165,9 @@ export function useDeskViewCamera(): UseDeskViewCameraReturn { // Try to disable face-tracking auto-focus (not all cameras support this) // @ts-expect-error - focusMode is valid but not in TS types focusMode: 'continuous', - ...(targetDeviceId ? { deviceId: { exact: targetDeviceId } } : {}), + ...(targetDeviceId + ? { deviceId: { exact: targetDeviceId } } + : { facingMode: { ideal: facingModeRef.current } }), }, audio: false, } @@ -140,7 +185,7 @@ export function useDeskViewCamera(): UseDeskViewCameraReturn { streamRef.current = stream setVideoStream(stream) - // Find which device we got + // Find which device we got and check torch availability const videoTrack = stream.getVideoTracks()[0] if (videoTrack) { const settings = videoTrack.getSettings() @@ -149,6 +194,11 @@ export function useDeskViewCamera(): UseDeskViewCameraReturn { setCurrentDevice(matchingDevice) setIsDeskViewDetected(isDeskViewDevice(matchingDevice)) } + + // Check torch availability + const torchAvailable = checkTorchAvailability(videoTrack) + setIsTorchAvailable(torchAvailable) + setIsTorchOn(false) } setIsLoading(false) @@ -158,7 +208,7 @@ export function useDeskViewCamera(): UseDeskViewCameraReturn { setIsLoading(false) } }, - [enumerateDevices, findDeskViewCamera, isDeskViewDevice] + [enumerateDevices, findDeskViewCamera, isDeskViewDevice, checkTorchAvailability] ) /** @@ -176,6 +226,8 @@ export function useDeskViewCamera(): UseDeskViewCameraReturn { setVideoStream(null) setCurrentDevice(null) setError(null) + setIsTorchOn(false) + setIsTorchAvailable(false) }, []) // Cleanup on unmount @@ -206,6 +258,33 @@ export function useDeskViewCamera(): UseDeskViewCameraReturn { } }, [enumerateDevices]) + /** + * Flip between front and back camera + */ + const flipCamera = useCallback(async () => { + const newFacingMode = facingMode === 'user' ? 'environment' : 'user' + facingModeRef.current = newFacingMode + setFacingMode(newFacingMode) + // Re-request camera with new facing mode (don't pass device ID to use facingMode) + await requestCamera() + }, [facingMode, requestCamera]) + + /** + * Toggle torch on/off + */ + const toggleTorch = useCallback(async () => { + if (!streamRef.current || !isTorchAvailable) return + + const videoTrack = streamRef.current.getVideoTracks()[0] + if (!videoTrack) return + + const newState = !isTorchOn + const success = await applyTorch(videoTrack, newState) + if (success) { + setIsTorchOn(newState) + } + }, [isTorchAvailable, isTorchOn, applyTorch]) + return { isLoading, error, @@ -213,8 +292,13 @@ export function useDeskViewCamera(): UseDeskViewCameraReturn { currentDevice, availableDevices, isDeskViewDetected, + facingMode, + isTorchOn, + isTorchAvailable, requestCamera, stopCamera, enumerateDevices, + flipCamera, + toggleTorch, } } diff --git a/apps/web/src/hooks/useRemoteCameraDesktop.ts b/apps/web/src/hooks/useRemoteCameraDesktop.ts index 7799f547..757bcd6b 100644 --- a/apps/web/src/hooks/useRemoteCameraDesktop.ts +++ b/apps/web/src/hooks/useRemoteCameraDesktop.ts @@ -35,6 +35,8 @@ interface UseRemoteCameraDesktopReturn { setPhoneFrameMode: (mode: FrameMode) => void /** Send calibration to the phone */ sendCalibration: (corners: QuadCorners) => void + /** Clear desktop calibration on phone (go back to auto-detection) */ + clearCalibration: () => void } /** @@ -212,6 +214,18 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn { [socket] ) + /** + * Clear desktop calibration on phone + * This tells the phone to forget the desktop calibration and go back to auto-detection + */ + const clearCalibration = useCallback(() => { + if (!socket || !currentSessionId.current) return + + socket.emit('remote-camera:clear-calibration', { + sessionId: currentSessionId.current, + }) + }, [socket]) + // Cleanup on unmount useEffect(() => { return () => { @@ -234,5 +248,6 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn { unsubscribe, setPhoneFrameMode, sendCalibration, + clearCalibration, } } diff --git a/apps/web/src/hooks/useRemoteCameraPhone.ts b/apps/web/src/hooks/useRemoteCameraPhone.ts index 4e4caab9..e75b0c0a 100644 --- a/apps/web/src/hooks/useRemoteCameraPhone.ts +++ b/apps/web/src/hooks/useRemoteCameraPhone.ts @@ -17,12 +17,18 @@ interface UseRemoteCameraPhoneOptions { targetFps?: number /** JPEG quality (0-1, default 0.8) */ jpegQuality?: number - /** Target width for cropped image (default 400) */ + /** Target width for cropped image (default 300) */ targetWidth?: number /** Target width for raw frames (default 640) */ rawWidth?: number } +/** + * Fixed aspect ratio for cropped abacus images. + * Abacus is 4 units high by 3 units wide, giving a 4:3 height:width ratio. + */ +const ABACUS_ASPECT_RATIO = 4 / 3 + interface UseRemoteCameraPhoneReturn { /** Whether connected to the session */ isConnected: boolean @@ -58,7 +64,10 @@ interface UseRemoteCameraPhoneReturn { export function useRemoteCameraPhone( options: UseRemoteCameraPhoneOptions = {} ): UseRemoteCameraPhoneReturn { - const { targetFps = 10, jpegQuality = 0.8, targetWidth = 400, rawWidth = 640 } = options + const { targetFps = 10, jpegQuality = 0.8, targetWidth = 300, rawWidth = 640 } = options + + // Calculate fixed output height based on aspect ratio (4 units tall by 3 units wide) + const targetHeight = Math.round(targetWidth * ABACUS_ASPECT_RATIO) const [isSocketConnected, setIsSocketConnected] = useState(false) const [isConnected, setIsConnected] = useState(false) @@ -157,20 +166,31 @@ export function useRemoteCameraPhone( frameModeRef.current = 'cropped' } + // Handle clear calibration from desktop (go back to auto-detection) + const handleClearCalibration = () => { + console.log('[RemoteCameraPhone] Desktop cleared calibration - returning to auto-detection') + setDesktopCalibration(null) + calibrationRef.current = null + } + socket.on('remote-camera:error', handleError) socket.on('remote-camera:set-mode', handleSetMode) socket.on('remote-camera:set-calibration', handleSetCalibration) + socket.on('remote-camera:clear-calibration', handleClearCalibration) return () => { socket.off('remote-camera:error', handleError) socket.off('remote-camera:set-mode', handleSetMode) socket.off('remote-camera:set-calibration', handleSetCalibration) + socket.off('remote-camera:clear-calibration', handleClearCalibration) } }, [isSocketConnected]) // Re-run when socket connects /** * Apply perspective transform and extract the quadrilateral region * Uses OpenCV for proper perspective correction + * + * Output is fixed at 4:3 height:width aspect ratio (abacus is 4 units tall by 3 units wide) */ const cropToQuad = useCallback( (video: HTMLVideoElement, quad: QuadCorners): string | null => { @@ -181,11 +201,12 @@ export function useRemoteCameraPhone( return rectifyQuadrilateralToBase64(video, quad, { outputWidth: targetWidth, + outputHeight: targetHeight, // Fixed 4:3 aspect ratio jpegQuality, rotate180: false, // Phone camera: no rotation needed, direct mapping }) }, - [targetWidth, jpegQuality] + [targetWidth, targetHeight, jpegQuality] ) /** diff --git a/apps/web/src/hooks/useRemoteCameraSession.ts b/apps/web/src/hooks/useRemoteCameraSession.ts index 4400ccc2..cdaadc8c 100644 --- a/apps/web/src/hooks/useRemoteCameraSession.ts +++ b/apps/web/src/hooks/useRemoteCameraSession.ts @@ -17,6 +17,8 @@ interface UseRemoteCameraSessionReturn { error: string | null /** Create a new remote camera session */ createSession: () => Promise + /** Set an existing session ID (for reconnection scenarios) */ + setExistingSession: (sessionId: string) => void /** Clear the current session */ clearSession: () => void /** Get the URL for the phone to scan */ @@ -67,6 +69,17 @@ export function useRemoteCameraSession(): UseRemoteCameraSessionReturn { } }, []) + const setExistingSession = useCallback((sessionId: string) => { + // Use existing session ID without creating a new one on the server + // This is used for reconnection when the phone reloads + setSession({ + sessionId, + expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), // Assume 10 min remaining + phoneConnected: false, + }) + setError(null) + }, []) + const clearSession = useCallback(() => { setSession(null) setError(null) @@ -112,6 +125,7 @@ export function useRemoteCameraSession(): UseRemoteCameraSessionReturn { isCreating, error, createSession, + setExistingSession, clearSession, getPhoneUrl, } diff --git a/apps/web/src/lib/vision/columnClassifier.ts b/apps/web/src/lib/vision/columnClassifier.ts index ed546963..bf425ce8 100644 --- a/apps/web/src/lib/vision/columnClassifier.ts +++ b/apps/web/src/lib/vision/columnClassifier.ts @@ -7,7 +7,7 @@ // TensorFlow.js types (dynamically imported) type TFLite = typeof import('@tensorflow/tfjs') -type GraphModel = import('@tensorflow/tfjs').GraphModel +type LayersModel = import('@tensorflow/tfjs').LayersModel // Model configuration const MODEL_PATH = '/models/abacus-column-classifier/model.json' @@ -17,8 +17,8 @@ const NUM_CLASSES = 10 // Cached model and TensorFlow instance let tfInstance: TFLite | null = null -let modelInstance: GraphModel | null = null -let modelLoadPromise: Promise | null = null +let modelInstance: LayersModel | null = null +let modelLoadPromise: Promise | null = null let modelCheckFailed = false // Track if model doesn't exist /** @@ -61,7 +61,7 @@ async function checkModelExists(): Promise { * Lazy load the classification model * Returns null if model doesn't exist (not yet trained) */ -async function loadModel(): Promise { +async function loadModel(): Promise { if (modelInstance) return modelInstance if (modelCheckFailed) return null @@ -86,8 +86,8 @@ async function loadModel(): Promise { const startTime = performance.now() try { - // Load as GraphModel for optimized inference - const model = await tf.loadGraphModel(MODEL_PATH) + // 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`) diff --git a/apps/web/src/socket-server.ts b/apps/web/src/socket-server.ts index 7492a835..adfd6849 100644 --- a/apps/web/src/socket-server.ts +++ b/apps/web/src/socket-server.ts @@ -1228,6 +1228,13 @@ export function initializeSocketServer(httpServer: HTTPServer) { } ) + // Remote Camera: Desktop clears calibration (tell phone to go back to auto-detection) + socket.on('remote-camera:clear-calibration', ({ sessionId }: { sessionId: string }) => { + // Forward clear calibration to phone + socket.to(`remote-camera:${sessionId}`).emit('remote-camera:clear-calibration', {}) + console.log(`🖥️ Desktop cleared remote camera calibration`) + }) + // Remote Camera: Leave session socket.on('remote-camera:leave', async ({ sessionId }: { sessionId: string }) => { try { diff --git a/apps/web/src/types/vision.ts b/apps/web/src/types/vision.ts index d26dca6e..e2846096 100644 --- a/apps/web/src/types/vision.ts +++ b/apps/web/src/types/vision.ts @@ -125,6 +125,9 @@ export interface AbacusVisionState { selectedDeviceId: string | null availableDevices: MediaDeviceInfo[] isDeskViewDetected: boolean + facingMode: 'user' | 'environment' + isTorchOn: boolean + isTorchAvailable: boolean // Calibration state calibrationGrid: CalibrationGrid | null @@ -157,6 +160,10 @@ export interface AbacusVisionActions { resetCalibration: () => void /** Set calibration mode (auto uses ArUco markers, manual uses drag handles) */ setCalibrationMode: (mode: CalibrationMode) => void + /** Flip between front and back camera */ + flipCamera: () => Promise + /** Toggle torch on/off */ + toggleTorch: () => Promise } /**