feat(vision): disable auto-detection with feature flag

- Add ENABLE_AUTO_DETECTION flag (set to false) in DockedVisionFeed
- Conditionally import detection modules for tree-shaking when disabled
- Guard all detection processing, loops, and value handlers
- Hide detection overlay when auto-detection is disabled
- Remove vision toggle button from ActiveSession (no longer needed)
- Clean up unused imports and code
- Format fixes from biome

The camera feed still works for observation mode, but the ML/CV
bead detection is disabled until accuracy is improved.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2026-01-01 15:55:50 -06:00
parent a8fb77e8e3
commit a5025f01bc
8 changed files with 188 additions and 159 deletions

View File

@ -7,8 +7,6 @@ import { useMyAbacus } from '@/contexts/MyAbacusContext'
import { useTheme } from '@/contexts/ThemeContext'
import {
getCurrentProblemInfo,
isInRetryEpoch,
needsRetryTransition,
type ProblemSlot,
type SessionHealth,
type SessionPart,
@ -50,8 +48,6 @@ import { PracticeHelpOverlay } from './PracticeHelpOverlay'
import { ProblemDebugPanel } from './ProblemDebugPanel'
import { VerticalProblem } from './VerticalProblem'
import type { ReceivedAbacusControl } from '@/hooks/useSessionBroadcast'
import { AbacusVisionBridge } from '../vision'
import { Z_INDEX } from '@/constants/zIndex'
/**
* Timing data for the current problem attempt
@ -995,9 +991,6 @@ export function ActiveSession({
// Track previous epoch to detect epoch changes
const prevEpochRef = useRef<number>(0)
// Vision mode state - for physical abacus camera detection
const [isVisionEnabled, setIsVisionEnabled] = useState(false)
// Browse mode state - isBrowseMode is controlled via props
// browseIndex can be controlled (browseIndexProp + onBrowseIndexChange) or internal
const [internalBrowseIndex, setInternalBrowseIndex] = useState(0)
@ -1323,17 +1316,6 @@ export function ActiveSession({
[setAnswer]
)
// Handle value detected from vision (physical abacus camera)
const handleVisionValueDetected = useCallback(
(value: number) => {
// Update the docked abacus to show the detected value
setDockedValue(value)
// Also set the answer input
setAnswer(String(value))
},
[setDockedValue, setAnswer]
)
// Handle submit
const handleSubmit = useCallback(async () => {
// Allow submitting from inputting, awaitingDisambiguation, or helpMode
@ -1996,56 +1978,22 @@ export function ActiveSession({
{/* Abacus dock - positioned absolutely so it doesn't affect problem centering */}
{/* Width 100% matches problem width, height matches problem height */}
{currentPart.type === 'abacus' && !showHelpOverlay && (problemHeight ?? 0) > 0 && (
<>
<AbacusDock
id="practice-abacus"
columns={calculateAbacusColumns(attempt.problem.terms)}
interactive={true}
showNumbers={false}
animated={true}
onValueChange={handleAbacusDockValueChange}
className={css({
position: 'absolute',
left: '100%',
top: 0,
width: '100%',
marginLeft: '1.5rem',
})}
style={{ height: problemHeight }}
/>
{/* Vision mode toggle button */}
<button
type="button"
data-action="toggle-vision"
data-enabled={isVisionEnabled}
onClick={() => setIsVisionEnabled((prev) => !prev)}
className={css({
position: 'absolute',
left: '100%',
bottom: 0,
marginLeft: '1.5rem',
px: 2,
py: 1,
display: 'flex',
alignItems: 'center',
gap: 1,
fontSize: 'xs',
bg: isVisionEnabled ? 'green.600' : isDark ? 'gray.700' : 'gray.200',
color: isVisionEnabled ? 'white' : isDark ? 'gray.300' : 'gray.700',
border: 'none',
borderRadius: 'md',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
bg: isVisionEnabled ? 'green.500' : isDark ? 'gray.600' : 'gray.300',
},
})}
title="Use camera to detect physical abacus"
>
<span>📷</span>
<span>Vision</span>
</button>
</>
<AbacusDock
id="practice-abacus"
columns={calculateAbacusColumns(attempt.problem.terms)}
interactive={true}
showNumbers={false}
animated={true}
onValueChange={handleAbacusDockValueChange}
className={css({
position: 'absolute',
left: '100%',
top: 0,
width: '100%',
marginLeft: '1.5rem',
})}
style={{ height: problemHeight }}
/>
)}
</animated.div>
</animated.div>
@ -2130,27 +2078,6 @@ export function ActiveSession({
/>
)}
{/* Abacus Vision Bridge - floating camera panel for physical abacus detection */}
{isVisionEnabled && currentPart.type === 'abacus' && attempt && (
<div
data-component="vision-panel"
className={css({
position: 'fixed',
top: '200px', // Below main nav (80px) + sub nav (~56px) + mini sub-nav (~60px)
right: '1rem',
zIndex: Z_INDEX.DROPDOWN, // Above content but below modals
boxShadow: 'xl',
borderRadius: 'xl',
})}
>
<AbacusVisionBridge
columnCount={abacusDisplayConfig.physicalAbacusColumns}
onValueDetected={handleVisionValueDetected}
onClose={() => setIsVisionEnabled(false)}
/>
</div>
)}
{/* Session Paused Modal - rendered here as single source of truth */}
<SessionPausedModal
isOpen={isPaused}

View File

@ -3,8 +3,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useMyAbacus } from '@/contexts/MyAbacusContext'
import { useRemoteCameraDesktop } from '@/hooks/useRemoteCameraDesktop'
import { analyzeColumns, analysesToDigits, digitsToNumber } from '@/lib/vision/beadDetector'
import { processVideoFrame, processImageFrame } from '@/lib/vision/frameProcessor'
import {
cleanupArucoDetector,
detectMarkers,
@ -17,6 +15,43 @@ import { VisionCameraFeed } from './VisionCameraFeed'
import { css } from '../../../styled-system/css'
import type { CalibrationGrid } from '@/types/vision'
/**
* Feature flag: Enable automatic abacus value detection from video feed.
*
* When enabled:
* - Runs CV-based bead detection on video frames
* - Shows detected value overlay
* - Calls setDockedValue and onValueDetected with detected values
*
* When disabled:
* - Only shows the video feed (no detection)
* - Hides the detection overlay
* - Does not interfere with student's manual input
*
* Set to true when ready to work on improving detection accuracy.
*/
const ENABLE_AUTO_DETECTION = false
// Only import detection modules when auto-detection is enabled
// This ensures the detection code is tree-shaken when disabled
let analyzeColumns: typeof import('@/lib/vision/beadDetector').analyzeColumns
let analysesToDigits: typeof import('@/lib/vision/beadDetector').analysesToDigits
let digitsToNumber: typeof import('@/lib/vision/beadDetector').digitsToNumber
let processVideoFrame: typeof import('@/lib/vision/frameProcessor').processVideoFrame
let processImageFrame: typeof import('@/lib/vision/frameProcessor').processImageFrame
if (ENABLE_AUTO_DETECTION) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const beadDetector = require('@/lib/vision/beadDetector')
// eslint-disable-next-line @typescript-eslint/no-require-imports
const frameProcessor = require('@/lib/vision/frameProcessor')
analyzeColumns = beadDetector.analyzeColumns
analysesToDigits = beadDetector.analysesToDigits
digitsToNumber = beadDetector.digitsToNumber
processVideoFrame = frameProcessor.processVideoFrame
processImageFrame = frameProcessor.processImageFrame
}
interface DockedVisionFeedProps {
/** Called when a stable value is detected */
onValueDetected?: (value: number) => void
@ -33,7 +68,8 @@ interface DockedVisionFeedProps {
* - Shows the video feed with detection overlay
*/
export function DockedVisionFeed({ onValueDetected, columnCount = 5 }: DockedVisionFeedProps) {
const { visionConfig, setDockedValue, setVisionEnabled, setVisionCalibration, emitVisionFrame } = useMyAbacus()
const { visionConfig, setDockedValue, setVisionEnabled, setVisionCalibration, emitVisionFrame } =
useMyAbacus()
const videoRef = useRef<HTMLVideoElement>(null)
const remoteImageRef = useRef<HTMLImageElement>(null)
@ -51,6 +87,7 @@ export function DockedVisionFeed({ onValueDetected, columnCount = 5 }: DockedVis
const [isArucoReady, setIsArucoReady] = useState(false)
const [markersFound, setMarkersFound] = useState(0)
// Stability tracking for detected values (hook must be called unconditionally)
const stability = useFrameStability()
// Determine camera source (must be before effects that use these)
@ -152,7 +189,14 @@ export function DockedVisionFeed({ onValueDetected, columnCount = 5 }: DockedVis
markerDetectionFrameRef.current = null
}
}
}, [visionConfig.enabled, isLocalCamera, videoStream, isArucoReady, columnCount, setVisionCalibration])
}, [
visionConfig.enabled,
isLocalCamera,
videoStream,
isArucoReady,
columnCount,
setVisionCalibration,
])
// Remote camera hook
const {
@ -234,7 +278,13 @@ export function DockedVisionFeed({ onValueDetected, columnCount = 5 }: DockedVis
return () => {
remoteUnsubscribe()
}
}, [visionConfig.enabled, isRemoteCamera, visionConfig.remoteCameraSessionId, remoteSubscribe, remoteUnsubscribe])
}, [
visionConfig.enabled,
isRemoteCamera,
visionConfig.remoteCameraSessionId,
remoteSubscribe,
remoteUnsubscribe,
])
// Update loading state when remote camera connects
useEffect(() => {
@ -243,8 +293,11 @@ export function DockedVisionFeed({ onValueDetected, columnCount = 5 }: DockedVis
}
}, [isRemoteCamera, remoteIsPhoneConnected])
// Process local camera frames for detection
// Process local camera frames for detection (only when enabled)
const processLocalFrame = useCallback(() => {
// Skip detection when feature is disabled
if (!ENABLE_AUTO_DETECTION) return
const now = performance.now()
if (now - lastInferenceTimeRef.current < INFERENCE_INTERVAL_MS) {
return
@ -270,8 +323,11 @@ export function DockedVisionFeed({ onValueDetected, columnCount = 5 }: DockedVis
stability.pushFrame(value, minConfidence)
}, [visionConfig.calibration, stability])
// Process remote camera frames for detection
// Process remote camera frames for detection (only when enabled)
useEffect(() => {
// Skip detection when feature is disabled
if (!ENABLE_AUTO_DETECTION) return
if (!isRemoteCamera || !remoteIsPhoneConnected || !remoteLatestFrame) {
return
}
@ -302,8 +358,11 @@ export function DockedVisionFeed({ onValueDetected, columnCount = 5 }: DockedVis
stability.pushFrame(value, minConfidence)
}, [isRemoteCamera, remoteIsPhoneConnected, remoteLatestFrame, columnCount, stability])
// Local camera detection loop
// Local camera detection loop (only when enabled)
useEffect(() => {
// Skip detection loop when feature is disabled
if (!ENABLE_AUTO_DETECTION) return
if (!visionConfig.enabled || !isLocalCamera || !videoStream || !visionConfig.calibration) {
return
}
@ -326,17 +385,32 @@ export function DockedVisionFeed({ onValueDetected, columnCount = 5 }: DockedVis
animationFrameRef.current = null
}
}
}, [visionConfig.enabled, isLocalCamera, videoStream, visionConfig.calibration, processLocalFrame])
}, [
visionConfig.enabled,
isLocalCamera,
videoStream,
visionConfig.calibration,
processLocalFrame,
])
// Handle stable value changes
// Handle stable value changes (only when auto-detection is enabled)
useEffect(() => {
// Skip value updates when feature is disabled
if (!ENABLE_AUTO_DETECTION) return
if (stability.stableValue !== null && stability.stableValue !== detectedValue) {
setDetectedValue(stability.stableValue)
setConfidence(stability.currentConfidence)
setDockedValue(stability.stableValue)
onValueDetected?.(stability.stableValue)
}
}, [stability.stableValue, stability.currentConfidence, detectedValue, setDockedValue, onValueDetected])
}, [
stability.stableValue,
stability.currentConfidence,
detectedValue,
setDockedValue,
onValueDetected,
])
// Broadcast vision frames to observers (5fps to save bandwidth)
const BROADCAST_INTERVAL_MS = 200
@ -530,53 +604,62 @@ export function DockedVisionFeed({ onValueDetected, columnCount = 5 }: DockedVis
</div>
)}
{/* Detection overlay */}
<div
data-element="detection-overlay"
className={css({
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
p: 2,
bg: 'rgba(0, 0, 0, 0.7)',
backdropFilter: 'blur(4px)',
})}
>
{/* Detected value */}
<div className={css({ display: 'flex', alignItems: 'center', gap: 2 })}>
<span className={css({ fontSize: 'lg', fontWeight: 'bold', color: 'white', fontFamily: 'mono' })}>
{detectedValue !== null ? detectedValue : '---'}
</span>
{detectedValue !== null && (
<span className={css({ fontSize: 'xs', color: 'gray.400' })}>
{Math.round(confidence * 100)}%
{/* Detection overlay - only shown when auto-detection is enabled */}
{ENABLE_AUTO_DETECTION && (
<div
data-element="detection-overlay"
className={css({
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
p: 2,
bg: 'rgba(0, 0, 0, 0.7)',
backdropFilter: 'blur(4px)',
})}
>
{/* Detected value */}
<div className={css({ display: 'flex', alignItems: 'center', gap: 2 })}>
<span
className={css({
fontSize: 'lg',
fontWeight: 'bold',
color: 'white',
fontFamily: 'mono',
})}
>
{detectedValue !== null ? detectedValue : '---'}
</span>
)}
</div>
{detectedValue !== null && (
<span className={css({ fontSize: 'xs', color: 'gray.400' })}>
{Math.round(confidence * 100)}%
</span>
)}
</div>
{/* Stability indicator */}
<div className={css({ display: 'flex', alignItems: 'center', gap: 1 })}>
{stability.consecutiveFrames > 0 && (
<div className={css({ display: 'flex', gap: 0.5 })}>
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className={css({
w: '6px',
h: '6px',
borderRadius: 'full',
bg: i < stability.consecutiveFrames ? 'green.500' : 'gray.600',
})}
/>
))}
</div>
)}
{/* Stability indicator */}
<div className={css({ display: 'flex', alignItems: 'center', gap: 1 })}>
{stability.consecutiveFrames > 0 && (
<div className={css({ display: 'flex', gap: 0.5 })}>
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className={css({
w: '6px',
h: '6px',
borderRadius: 'full',
bg: i < stability.consecutiveFrames ? 'green.500' : 'gray.600',
})}
/>
))}
</div>
)}
</div>
</div>
</div>
)}
{/* Disable button */}
<button

View File

@ -64,7 +64,12 @@ export function ObserverVisionFeed({ frame }: ObserverVisionFeedProps) {
{/* Detected value */}
<div className={css({ display: 'flex', alignItems: 'center', gap: 2 })}>
<span
className={css({ fontSize: 'lg', fontWeight: 'bold', color: 'white', fontFamily: 'mono' })}
className={css({
fontSize: 'lg',
fontWeight: 'bold',
color: 'white',
fontFamily: 'mono',
})}
>
{frame.detectedValue !== null ? frame.detectedValue : '---'}
</span>

View File

@ -22,7 +22,10 @@ interface VisionIndicatorProps {
* - If not configured: opens setup modal
* - If configured: toggles vision on/off
*/
export function VisionIndicator({ size = 'medium', position = 'bottom-right' }: VisionIndicatorProps) {
export function VisionIndicator({
size = 'medium',
position = 'bottom-right',
}: VisionIndicatorProps) {
const { visionConfig, isVisionSetupComplete, openVisionSetup } = useMyAbacus()
const handleClick = (e: React.MouseEvent) => {
@ -65,8 +68,9 @@ export function VisionIndicator({ size = 'medium', position = 'bottom-right' }:
return (
<button
type="button"
data-action="toggle-vision"
data-vision-status={!isVisionSetupComplete ? 'not-configured' : visionConfig.enabled ? 'enabled' : 'disabled'}
data-vision-status={
!isVisionSetupComplete ? 'not-configured' : visionConfig.enabled ? 'enabled' : 'disabled'
}
onClick={handleClick}
onContextMenu={handleContextMenu}
title={`${statusLabel} (right-click for settings)`}
@ -97,8 +101,7 @@ export function VisionIndicator({ size = 'medium', position = 'bottom-right' }:
>
{/* Camera icon */}
<span style={{ position: 'relative' }}>
📷
{/* Status dot */}
📷{/* Status dot */}
<span
data-element="vision-status-dot"
className={css({

View File

@ -85,7 +85,6 @@ export function VisionSetupModal() {
{isVisionSetupComplete && (
<button
type="button"
data-action="toggle-vision"
onClick={() => {
setVisionEnabled(!visionConfig.enabled)
}}
@ -236,7 +235,9 @@ function StatusRow({
isConfigured: boolean
}) {
return (
<div className={css({ display: 'flex', justifyContent: 'space-between', alignItems: 'center' })}>
<div
className={css({ display: 'flex', justifyContent: 'space-between', alignItems: 'center' })}
>
<span className={css({ color: 'gray.400', fontSize: 'sm' })}>{label}</span>
<span
className={css({

View File

@ -184,7 +184,14 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
const subscribe = useCallback(
(sessionId: string) => {
console.log('[RemoteCameraDesktop] Subscribing to session:', sessionId, 'socket:', !!socket, 'connected:', isConnected)
console.log(
'[RemoteCameraDesktop] Subscribing to session:',
sessionId,
'socket:',
!!socket,
'connected:',
isConnected
)
if (!socket || !isConnected) {
console.error('[RemoteCameraDesktop] Socket not connected!')
setError('Socket not connected')

View File

@ -326,7 +326,14 @@ export function useRemoteCameraPhone(
const connect = useCallback(
(sessionId: string) => {
const socket = socketRef.current
console.log('[RemoteCameraPhone] Connecting to session:', sessionId, 'socket:', !!socket, 'connected:', isSocketConnected)
console.log(
'[RemoteCameraPhone] Connecting to session:',
sessionId,
'socket:',
!!socket,
'connected:',
isSocketConnected
)
if (!socket || !isSocketConnected) {
console.error('[RemoteCameraPhone] Socket not connected!')
setError('Socket not connected')

View File

@ -66,11 +66,7 @@ export interface UseSessionBroadcastResult {
/** Send part transition complete event to observers */
sendPartTransitionComplete: () => void
/** Send vision frame to observers (when student has vision mode enabled) */
sendVisionFrame: (
imageData: string,
detectedValue: number | null,
confidence: number
) => void
sendVisionFrame: (imageData: string, detectedValue: number | null, confidence: number) => void
}
export function useSessionBroadcast(