feat(vision): add AbacusVisionBridge for physical soroban detection
Implements camera-based detection system for physical abacus: - VisionCameraFeed: Camera display with Desk View auto-detection - CalibrationOverlay: Interactive quad corner calibration with proper letterbox handling and bounds clamping - Perspective transform using OpenCV.js for rectification - Live rectified preview during calibration - Hooks: useAbacusVision, useDeskViewCamera, useCameraCalibration, useFrameStability 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
156a0dfe96
commit
47088e4850
|
|
@ -0,0 +1,417 @@
|
|||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { useAbacusVision } from '@/hooks/useAbacusVision'
|
||||
import type { QuadCorners } from '@/types/vision'
|
||||
import { DEFAULT_STABILITY_CONFIG } from '@/types/vision'
|
||||
import { isOpenCVReady, loadOpenCV, rectifyQuadrilateral } from '@/lib/vision/perspectiveTransform'
|
||||
import { CalibrationOverlay } from './CalibrationOverlay'
|
||||
import { VisionCameraFeed } from './VisionCameraFeed'
|
||||
import { VisionStatusIndicator } from './VisionStatusIndicator'
|
||||
|
||||
export interface AbacusVisionBridgeProps {
|
||||
/** Number of abacus columns to detect */
|
||||
columnCount: number
|
||||
/** Called when a stable value is detected */
|
||||
onValueDetected: (value: number) => void
|
||||
/** Called when vision mode is closed */
|
||||
onClose: () => void
|
||||
/** Called on error */
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* AbacusVisionBridge - Main UI for physical abacus detection
|
||||
*
|
||||
* Provides:
|
||||
* - Camera feed with Desk View auto-detection
|
||||
* - Interactive calibration UI
|
||||
* - Real-time detection status
|
||||
* - Stable value output to control digital abacus
|
||||
*/
|
||||
export function AbacusVisionBridge({
|
||||
columnCount,
|
||||
onValueDetected,
|
||||
onClose,
|
||||
onError,
|
||||
}: AbacusVisionBridgeProps): ReactNode {
|
||||
const [videoDimensions, setVideoDimensions] = useState<{
|
||||
width: number
|
||||
height: number
|
||||
containerWidth: number
|
||||
containerHeight: number
|
||||
} | null>(null)
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const cameraFeedContainerRef = useRef<HTMLDivElement>(null)
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||
const previewCanvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
const [calibrationCorners, setCalibrationCorners] = useState<QuadCorners | null>(null)
|
||||
const [opencvReady, setOpencvReady] = useState(false)
|
||||
|
||||
const vision = useAbacusVision({
|
||||
columnCount,
|
||||
onValueDetected,
|
||||
})
|
||||
|
||||
// Start camera on mount
|
||||
useEffect(() => {
|
||||
vision.enable()
|
||||
return () => vision.disable()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []) // Only run on mount/unmount - vision functions are stable
|
||||
|
||||
// Report errors
|
||||
useEffect(() => {
|
||||
if (vision.cameraError && onError) {
|
||||
onError(vision.cameraError)
|
||||
}
|
||||
}, [vision.cameraError, onError])
|
||||
|
||||
// Load OpenCV when calibrating
|
||||
useEffect(() => {
|
||||
if (vision.isCalibrating && !opencvReady) {
|
||||
loadOpenCV()
|
||||
.then(() => setOpencvReady(true))
|
||||
.catch((err) => console.error('Failed to load OpenCV:', err))
|
||||
}
|
||||
}, [vision.isCalibrating, opencvReady])
|
||||
|
||||
// Render preview when calibrating
|
||||
useEffect(() => {
|
||||
if (!vision.isCalibrating || !calibrationCorners || !videoRef.current || !previewCanvasRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
let running = true
|
||||
const video = videoRef.current
|
||||
const canvas = previewCanvasRef.current
|
||||
|
||||
const drawPreview = () => {
|
||||
if (!running || video.readyState < 2) {
|
||||
if (running) requestAnimationFrame(drawPreview)
|
||||
return
|
||||
}
|
||||
|
||||
if (opencvReady && isOpenCVReady()) {
|
||||
rectifyQuadrilateral(video, calibrationCorners, canvas, {
|
||||
outputWidth: 300,
|
||||
outputHeight: 200,
|
||||
})
|
||||
}
|
||||
|
||||
requestAnimationFrame(drawPreview)
|
||||
}
|
||||
|
||||
drawPreview()
|
||||
return () => { running = false }
|
||||
}, [vision.isCalibrating, calibrationCorners, opencvReady])
|
||||
|
||||
// Handle video ready - get dimensions
|
||||
const handleVideoReady = useCallback(
|
||||
(width: number, height: number) => {
|
||||
const feedContainer = cameraFeedContainerRef.current
|
||||
if (!feedContainer) return
|
||||
|
||||
const rect = feedContainer.getBoundingClientRect()
|
||||
setVideoDimensions({
|
||||
width,
|
||||
height,
|
||||
containerWidth: rect.width,
|
||||
containerHeight: rect.height,
|
||||
})
|
||||
|
||||
// Initialize calibration with default grid if not already calibrated
|
||||
if (!vision.isCalibrated) {
|
||||
// Will start calibration on user action
|
||||
}
|
||||
},
|
||||
[vision.isCalibrated]
|
||||
)
|
||||
|
||||
// Handle calibration complete
|
||||
const handleCalibrationComplete = useCallback(
|
||||
(grid: Parameters<typeof vision.finishCalibration>[0]) => {
|
||||
vision.finishCalibration(grid)
|
||||
},
|
||||
[vision]
|
||||
)
|
||||
|
||||
// Camera selector
|
||||
const handleCameraSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
vision.selectCamera(e.target.value)
|
||||
},
|
||||
[vision]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-component="abacus-vision-bridge"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 3,
|
||||
p: 3,
|
||||
bg: 'gray.900',
|
||||
borderRadius: 'xl',
|
||||
maxWidth: '400px',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
data-element="header"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: 2 })}>
|
||||
<span className={css({ fontSize: 'lg' })}>📷</span>
|
||||
<span className={css({ color: 'white', fontWeight: 'medium' })}>Abacus Vision</span>
|
||||
{vision.isDeskViewDetected && (
|
||||
<span
|
||||
className={css({
|
||||
px: 2,
|
||||
py: 0.5,
|
||||
bg: 'green.900',
|
||||
color: 'green.300',
|
||||
fontSize: 'xs',
|
||||
borderRadius: 'full',
|
||||
})}
|
||||
>
|
||||
Desk View
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={css({
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bg: 'gray.700',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 'full',
|
||||
cursor: 'pointer',
|
||||
fontSize: 'lg',
|
||||
_hover: { bg: 'gray.600' },
|
||||
})}
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Camera selector (if multiple cameras) */}
|
||||
{vision.availableDevices.length > 1 && (
|
||||
<select
|
||||
data-element="camera-selector"
|
||||
value={vision.selectedDeviceId ?? ''}
|
||||
onChange={handleCameraSelect}
|
||||
className={css({
|
||||
p: 2,
|
||||
bg: 'gray.800',
|
||||
color: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.600',
|
||||
borderRadius: 'md',
|
||||
fontSize: 'sm',
|
||||
})}
|
||||
>
|
||||
{vision.availableDevices.map((device) => (
|
||||
<option key={device.deviceId} value={device.deviceId}>
|
||||
{device.label || `Camera ${device.deviceId.slice(0, 8)}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{/* Camera feed */}
|
||||
<div ref={cameraFeedContainerRef} className={css({ position: 'relative' })}>
|
||||
<VisionCameraFeed
|
||||
videoStream={vision.videoStream}
|
||||
isLoading={vision.isCameraLoading}
|
||||
calibration={vision.calibrationGrid}
|
||||
showCalibrationGrid={!vision.isCalibrating && !vision.isCalibrated}
|
||||
showRectifiedView={vision.isCalibrated && !vision.isCalibrating}
|
||||
videoRef={(el) => {
|
||||
videoRef.current = el
|
||||
}}
|
||||
onVideoReady={handleVideoReady}
|
||||
>
|
||||
{/* Calibration overlay when calibrating */}
|
||||
{vision.isCalibrating && videoDimensions && (
|
||||
<CalibrationOverlay
|
||||
columnCount={columnCount}
|
||||
videoWidth={videoDimensions.width}
|
||||
videoHeight={videoDimensions.height}
|
||||
containerWidth={videoDimensions.containerWidth}
|
||||
containerHeight={videoDimensions.containerHeight}
|
||||
initialCalibration={vision.calibrationGrid}
|
||||
onComplete={handleCalibrationComplete}
|
||||
onCancel={vision.cancelCalibration}
|
||||
videoElement={videoRef.current}
|
||||
onCornersChange={setCalibrationCorners}
|
||||
/>
|
||||
)}
|
||||
</VisionCameraFeed>
|
||||
|
||||
{/* Status indicator */}
|
||||
{vision.isEnabled && !vision.isCalibrating && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
left: 2,
|
||||
})}
|
||||
>
|
||||
<VisionStatusIndicator
|
||||
isCalibrated={vision.isCalibrated}
|
||||
isDetecting={vision.isDetecting}
|
||||
confidence={vision.confidence}
|
||||
handDetected={vision.isHandDetected}
|
||||
detectedValue={vision.currentDetectedValue}
|
||||
consecutiveFrames={vision.consecutiveFrames}
|
||||
minFrames={DEFAULT_STABILITY_CONFIG.minConsecutiveFrames}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rectified preview during calibration */}
|
||||
{vision.isCalibrating && (
|
||||
<div
|
||||
data-element="calibration-preview"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
})}
|
||||
>
|
||||
<span className={css({ color: 'gray.400', fontSize: 'xs' })}>
|
||||
Rectified Preview
|
||||
</span>
|
||||
<canvas
|
||||
ref={previewCanvasRef}
|
||||
className={css({
|
||||
borderRadius: 'md',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.600',
|
||||
bg: 'gray.800',
|
||||
})}
|
||||
width={300}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div
|
||||
data-element="actions"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
})}
|
||||
>
|
||||
{!vision.isCalibrated ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={vision.startCalibration}
|
||||
disabled={!videoDimensions}
|
||||
className={css({
|
||||
flex: 1,
|
||||
py: 2,
|
||||
bg: 'blue.600',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 'md',
|
||||
fontWeight: 'medium',
|
||||
cursor: 'pointer',
|
||||
_hover: { bg: 'blue.500' },
|
||||
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
|
||||
})}
|
||||
>
|
||||
Calibrate
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={vision.startCalibration}
|
||||
className={css({
|
||||
flex: 1,
|
||||
py: 2,
|
||||
bg: 'gray.700',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 'md',
|
||||
cursor: 'pointer',
|
||||
_hover: { bg: 'gray.600' },
|
||||
})}
|
||||
>
|
||||
Recalibrate
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={vision.resetCalibration}
|
||||
className={css({
|
||||
py: 2,
|
||||
px: 3,
|
||||
bg: 'red.700',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 'md',
|
||||
cursor: 'pointer',
|
||||
_hover: { bg: 'red.600' },
|
||||
})}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
{!vision.isCalibrated && !vision.isCalibrating && (
|
||||
<p
|
||||
className={css({
|
||||
color: 'gray.400',
|
||||
fontSize: 'sm',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Point your camera at a soroban and click Calibrate to set up detection
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Error display */}
|
||||
{vision.cameraError && (
|
||||
<div
|
||||
className={css({
|
||||
p: 3,
|
||||
bg: 'red.900',
|
||||
color: 'red.200',
|
||||
borderRadius: 'md',
|
||||
fontSize: 'sm',
|
||||
})}
|
||||
>
|
||||
{vision.cameraError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AbacusVisionBridge
|
||||
|
|
@ -0,0 +1,560 @@
|
|||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import type { CalibrationGrid, Point, QuadCorners, ROI } from '@/types/vision'
|
||||
|
||||
export interface CalibrationOverlayProps {
|
||||
/** Number of columns to configure */
|
||||
columnCount: number
|
||||
/** Video dimensions */
|
||||
videoWidth: number
|
||||
videoHeight: number
|
||||
/** Container dimensions (displayed size) */
|
||||
containerWidth: number
|
||||
containerHeight: number
|
||||
/** Current calibration (if any) */
|
||||
initialCalibration?: CalibrationGrid | null
|
||||
/** Called when calibration is completed */
|
||||
onComplete: (grid: CalibrationGrid) => void
|
||||
/** Called when calibration is cancelled */
|
||||
onCancel: () => void
|
||||
/** Video element for live preview */
|
||||
videoElement?: HTMLVideoElement | null
|
||||
/** Called when corners change (for external preview) */
|
||||
onCornersChange?: (corners: QuadCorners) => void
|
||||
}
|
||||
|
||||
type CornerKey = 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight'
|
||||
type DragTarget = CornerKey | 'quad' | `divider-${number}` | null
|
||||
|
||||
/**
|
||||
* Convert QuadCorners to legacy ROI format (bounding box)
|
||||
*/
|
||||
function cornersToROI(corners: QuadCorners): ROI {
|
||||
const minX = Math.min(corners.topLeft.x, corners.bottomLeft.x)
|
||||
const maxX = Math.max(corners.topRight.x, corners.bottomRight.x)
|
||||
const minY = Math.min(corners.topLeft.y, corners.topRight.y)
|
||||
const maxY = Math.max(corners.bottomLeft.y, corners.bottomRight.y)
|
||||
return {
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate a point along the edge of the quadrilateral
|
||||
* @param t - Position along the top/bottom edge (0 = left, 1 = right)
|
||||
* @param corners - The quadrilateral corners
|
||||
* @returns Top and bottom points for a vertical line at position t
|
||||
*/
|
||||
function getColumnLine(t: number, corners: QuadCorners): { top: Point; bottom: Point } {
|
||||
return {
|
||||
top: {
|
||||
x: corners.topLeft.x + t * (corners.topRight.x - corners.topLeft.x),
|
||||
y: corners.topLeft.y + t * (corners.topRight.y - corners.topLeft.y),
|
||||
},
|
||||
bottom: {
|
||||
x: corners.bottomLeft.x + t * (corners.bottomRight.x - corners.bottomLeft.x),
|
||||
y: corners.bottomLeft.y + t * (corners.bottomRight.y - corners.bottomLeft.y),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CalibrationOverlay - Interactive quadrilateral calibration editor
|
||||
*
|
||||
* Allows users to:
|
||||
* 1. Drag 4 corners independently to match perspective
|
||||
* 2. Adjust column dividers within the quadrilateral
|
||||
* 3. Align the virtual grid over their physical abacus
|
||||
*/
|
||||
export function CalibrationOverlay({
|
||||
columnCount,
|
||||
videoWidth,
|
||||
videoHeight,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
initialCalibration,
|
||||
onComplete,
|
||||
onCancel,
|
||||
videoElement,
|
||||
onCornersChange,
|
||||
}: CalibrationOverlayProps): ReactNode {
|
||||
// Calculate actual visible video bounds (accounting for object-fit: contain letterboxing)
|
||||
const videoAspect = videoWidth / videoHeight
|
||||
const containerAspect = containerWidth / containerHeight
|
||||
|
||||
let displayedVideoWidth: number
|
||||
let displayedVideoHeight: number
|
||||
let videoOffsetX: number
|
||||
let videoOffsetY: number
|
||||
|
||||
if (videoAspect > containerAspect) {
|
||||
// Video is wider than container - letterbox top/bottom
|
||||
displayedVideoWidth = containerWidth
|
||||
displayedVideoHeight = containerWidth / videoAspect
|
||||
videoOffsetX = 0
|
||||
videoOffsetY = (containerHeight - displayedVideoHeight) / 2
|
||||
} else {
|
||||
// Video is taller than container - letterbox left/right
|
||||
displayedVideoHeight = containerHeight
|
||||
displayedVideoWidth = containerHeight * videoAspect
|
||||
videoOffsetX = (containerWidth - displayedVideoWidth) / 2
|
||||
videoOffsetY = 0
|
||||
}
|
||||
|
||||
// Uniform scale factor (maintains aspect ratio)
|
||||
const scale = displayedVideoWidth / videoWidth
|
||||
|
||||
// Initialize corners state
|
||||
const getDefaultCorners = (): QuadCorners => {
|
||||
// Default to a slightly trapezoidal shape (wider at bottom for typical desk perspective)
|
||||
// Use larger margins to ensure all corners are visible and draggable
|
||||
const topMargin = 0.15
|
||||
const bottomMargin = 0.2 // Larger margin at bottom to keep handles visible
|
||||
const sideMargin = 0.15
|
||||
const topInset = 0.03 // Make top slightly narrower than bottom for perspective
|
||||
return {
|
||||
topLeft: { x: videoWidth * (sideMargin + topInset), y: videoHeight * topMargin },
|
||||
topRight: { x: videoWidth * (1 - sideMargin - topInset), y: videoHeight * topMargin },
|
||||
bottomLeft: { x: videoWidth * sideMargin, y: videoHeight * (1 - bottomMargin) },
|
||||
bottomRight: { x: videoWidth * (1 - sideMargin), y: videoHeight * (1 - bottomMargin) },
|
||||
}
|
||||
}
|
||||
|
||||
const [corners, setCorners] = useState<QuadCorners>(() => {
|
||||
if (initialCalibration?.corners) {
|
||||
return initialCalibration.corners
|
||||
}
|
||||
// Convert from legacy ROI if available
|
||||
if (initialCalibration?.roi) {
|
||||
const roi = initialCalibration.roi
|
||||
return {
|
||||
topLeft: { x: roi.x, y: roi.y },
|
||||
topRight: { x: roi.x + roi.width, y: roi.y },
|
||||
bottomLeft: { x: roi.x, y: roi.y + roi.height },
|
||||
bottomRight: { x: roi.x + roi.width, y: roi.y + roi.height },
|
||||
}
|
||||
}
|
||||
return getDefaultCorners()
|
||||
})
|
||||
|
||||
// Initialize column dividers (evenly spaced)
|
||||
const getDefaultDividers = (): number[] => {
|
||||
const dividers: number[] = []
|
||||
for (let i = 1; i < columnCount; i++) {
|
||||
dividers.push(i / columnCount)
|
||||
}
|
||||
return dividers
|
||||
}
|
||||
|
||||
const [dividers, setDividers] = useState<number[]>(
|
||||
initialCalibration?.columnDividers ?? getDefaultDividers()
|
||||
)
|
||||
|
||||
// Instructions visibility
|
||||
const [showInstructions, setShowInstructions] = useState(true)
|
||||
|
||||
// Drag state
|
||||
const [dragTarget, setDragTarget] = useState<DragTarget>(null)
|
||||
const dragStartRef = useRef<{
|
||||
x: number
|
||||
y: number
|
||||
corners: QuadCorners
|
||||
dividers: number[]
|
||||
} | null>(null)
|
||||
|
||||
// Notify parent when corners change
|
||||
useEffect(() => {
|
||||
onCornersChange?.(corners)
|
||||
}, [corners, onCornersChange])
|
||||
|
||||
// Handle pointer down on corners or dividers
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent, target: DragTarget) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragTarget(target)
|
||||
dragStartRef.current = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
corners: { ...corners },
|
||||
dividers: [...dividers],
|
||||
}
|
||||
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
|
||||
},
|
||||
[corners, dividers]
|
||||
)
|
||||
|
||||
// Handle pointer move during drag
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!dragTarget || !dragStartRef.current) return
|
||||
|
||||
const dx = (e.clientX - dragStartRef.current.x) / scale
|
||||
const dy = (e.clientY - dragStartRef.current.y) / scale
|
||||
const startCorners = dragStartRef.current.corners
|
||||
|
||||
if (dragTarget === 'quad') {
|
||||
// Move entire quadrilateral
|
||||
// Calculate bounds to keep all corners within video
|
||||
const minX = Math.min(startCorners.topLeft.x, startCorners.bottomLeft.x)
|
||||
const maxX = Math.max(startCorners.topRight.x, startCorners.bottomRight.x)
|
||||
const minY = Math.min(startCorners.topLeft.y, startCorners.topRight.y)
|
||||
const maxY = Math.max(startCorners.bottomLeft.y, startCorners.bottomRight.y)
|
||||
|
||||
// Clamp movement to keep quad within video bounds
|
||||
const clampedDx = Math.max(-minX, Math.min(videoWidth - maxX, dx))
|
||||
const clampedDy = Math.max(-minY, Math.min(videoHeight - maxY, dy))
|
||||
|
||||
setCorners({
|
||||
topLeft: { x: startCorners.topLeft.x + clampedDx, y: startCorners.topLeft.y + clampedDy },
|
||||
topRight: {
|
||||
x: startCorners.topRight.x + clampedDx,
|
||||
y: startCorners.topRight.y + clampedDy,
|
||||
},
|
||||
bottomLeft: {
|
||||
x: startCorners.bottomLeft.x + clampedDx,
|
||||
y: startCorners.bottomLeft.y + clampedDy,
|
||||
},
|
||||
bottomRight: {
|
||||
x: startCorners.bottomRight.x + clampedDx,
|
||||
y: startCorners.bottomRight.y + clampedDy,
|
||||
},
|
||||
})
|
||||
} else if (
|
||||
dragTarget === 'topLeft' ||
|
||||
dragTarget === 'topRight' ||
|
||||
dragTarget === 'bottomLeft' ||
|
||||
dragTarget === 'bottomRight'
|
||||
) {
|
||||
// Move single corner
|
||||
const startPoint = startCorners[dragTarget]
|
||||
const newPoint: Point = {
|
||||
x: Math.max(0, Math.min(videoWidth, startPoint.x + dx)),
|
||||
y: Math.max(0, Math.min(videoHeight, startPoint.y + dy)),
|
||||
}
|
||||
setCorners((prev) => ({
|
||||
...prev,
|
||||
[dragTarget]: newPoint,
|
||||
}))
|
||||
} else if (dragTarget.startsWith('divider-')) {
|
||||
// Move divider
|
||||
const index = Number.parseInt(dragTarget.split('-')[1], 10)
|
||||
const startDividers = dragStartRef.current.dividers
|
||||
|
||||
// Calculate dx as fraction of quad width (average of top and bottom widths)
|
||||
const topWidth = startCorners.topRight.x - startCorners.topLeft.x
|
||||
const bottomWidth = startCorners.bottomRight.x - startCorners.bottomLeft.x
|
||||
const avgWidth = (topWidth + bottomWidth) / 2
|
||||
const dxFraction = dx / avgWidth
|
||||
|
||||
const newDividers = [...startDividers]
|
||||
const minPos = index === 0 ? 0.05 : startDividers[index - 1] + 0.05
|
||||
const maxPos = index === startDividers.length - 1 ? 0.95 : startDividers[index + 1] - 0.05
|
||||
newDividers[index] = Math.max(minPos, Math.min(maxPos, startDividers[index] + dxFraction))
|
||||
setDividers(newDividers)
|
||||
}
|
||||
},
|
||||
[dragTarget, scale, videoWidth, videoHeight]
|
||||
)
|
||||
|
||||
// Handle pointer up
|
||||
const handlePointerUp = useCallback(() => {
|
||||
setDragTarget(null)
|
||||
dragStartRef.current = null
|
||||
}, [])
|
||||
|
||||
// Handle complete
|
||||
const handleComplete = useCallback(() => {
|
||||
const grid: CalibrationGrid = {
|
||||
roi: cornersToROI(corners),
|
||||
corners,
|
||||
columnCount,
|
||||
columnDividers: dividers,
|
||||
rotation: 0, // Deprecated - perspective handled by corners
|
||||
}
|
||||
onComplete(grid)
|
||||
}, [corners, columnCount, dividers, onComplete])
|
||||
|
||||
// Convert corners to display coordinates (accounting for letterbox offset)
|
||||
const displayCorners: QuadCorners = {
|
||||
topLeft: { x: corners.topLeft.x * scale + videoOffsetX, y: corners.topLeft.y * scale + videoOffsetY },
|
||||
topRight: { x: corners.topRight.x * scale + videoOffsetX, y: corners.topRight.y * scale + videoOffsetY },
|
||||
bottomLeft: { x: corners.bottomLeft.x * scale + videoOffsetX, y: corners.bottomLeft.y * scale + videoOffsetY },
|
||||
bottomRight: { x: corners.bottomRight.x * scale + videoOffsetX, y: corners.bottomRight.y * scale + videoOffsetY },
|
||||
}
|
||||
|
||||
// Create SVG path for the quadrilateral
|
||||
const quadPath = `M ${displayCorners.topLeft.x} ${displayCorners.topLeft.y}
|
||||
L ${displayCorners.topRight.x} ${displayCorners.topRight.y}
|
||||
L ${displayCorners.bottomRight.x} ${displayCorners.bottomRight.y}
|
||||
L ${displayCorners.bottomLeft.x} ${displayCorners.bottomLeft.y} Z`
|
||||
|
||||
const handleSize = 16
|
||||
|
||||
// Corner positions for handles
|
||||
const cornerPositions: { key: CornerKey; point: Point }[] = [
|
||||
{ key: 'topLeft', point: displayCorners.topLeft },
|
||||
{ key: 'topRight', point: displayCorners.topRight },
|
||||
{ key: 'bottomLeft', point: displayCorners.bottomLeft },
|
||||
{ key: 'bottomRight', point: displayCorners.bottomRight },
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="calibration-overlay"
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 10,
|
||||
})}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
>
|
||||
{/* Semi-transparent overlay outside quadrilateral */}
|
||||
<svg
|
||||
width={containerWidth}
|
||||
height={containerHeight}
|
||||
className={css({ position: 'absolute', inset: 0 })}
|
||||
>
|
||||
{/* Darkened area outside quadrilateral */}
|
||||
<defs>
|
||||
<mask id="quad-mask">
|
||||
<rect width="100%" height="100%" fill="white" />
|
||||
<path d={quadPath} fill="black" />
|
||||
</mask>
|
||||
</defs>
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
fill="rgba(0, 0, 0, 0.5)"
|
||||
mask="url(#quad-mask)"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
|
||||
{/* Clickable fill area inside quadrilateral - for moving the entire quad */}
|
||||
<path
|
||||
d={quadPath}
|
||||
fill="transparent"
|
||||
style={{ cursor: dragTarget === 'quad' ? 'grabbing' : 'grab' }}
|
||||
onPointerDown={(e) => handlePointerDown(e, 'quad')}
|
||||
/>
|
||||
|
||||
{/* Quadrilateral border */}
|
||||
<path
|
||||
d={quadPath}
|
||||
fill="none"
|
||||
stroke="#4ade80"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="8,4"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
|
||||
{/* Column divider lines */}
|
||||
{dividers.map((divider, i) => {
|
||||
const line = getColumnLine(divider, displayCorners)
|
||||
return (
|
||||
<line
|
||||
key={i}
|
||||
x1={line.top.x}
|
||||
y1={line.top.y}
|
||||
x2={line.bottom.x}
|
||||
y2={line.bottom.y}
|
||||
stroke="#facc15"
|
||||
strokeWidth="3"
|
||||
style={{ cursor: 'ew-resize' }}
|
||||
onPointerDown={(e) => handlePointerDown(e, `divider-${i}`)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Beam indicator line (20% from top, interpolated) */}
|
||||
{(() => {
|
||||
const beamT = 0.2
|
||||
const leftPoint: Point = {
|
||||
x:
|
||||
displayCorners.topLeft.x +
|
||||
beamT * (displayCorners.bottomLeft.x - displayCorners.topLeft.x),
|
||||
y:
|
||||
displayCorners.topLeft.y +
|
||||
beamT * (displayCorners.bottomLeft.y - displayCorners.topLeft.y),
|
||||
}
|
||||
const rightPoint: Point = {
|
||||
x:
|
||||
displayCorners.topRight.x +
|
||||
beamT * (displayCorners.bottomRight.x - displayCorners.topRight.x),
|
||||
y:
|
||||
displayCorners.topRight.y +
|
||||
beamT * (displayCorners.bottomRight.y - displayCorners.topRight.y),
|
||||
}
|
||||
return (
|
||||
<line
|
||||
x1={leftPoint.x}
|
||||
y1={leftPoint.y}
|
||||
x2={rightPoint.x}
|
||||
y2={rightPoint.y}
|
||||
stroke="#22d3ee"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="4,4"
|
||||
opacity="0.7"
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</svg>
|
||||
|
||||
{/* Corner drag handles */}
|
||||
{cornerPositions.map(({ key, point }) => (
|
||||
<div
|
||||
key={key}
|
||||
data-element={`handle-${key}`}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
width: `${handleSize}px`,
|
||||
height: `${handleSize}px`,
|
||||
bg: 'green.400',
|
||||
border: '2px solid white',
|
||||
borderRadius: 'full',
|
||||
cursor: 'move',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
_hover: { bg: 'green.300', transform: 'translate(-50%, -50%) scale(1.2)' },
|
||||
})}
|
||||
style={{
|
||||
left: point.x,
|
||||
top: point.y,
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(e, key)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Compact instructions (collapsible) */}
|
||||
{showInstructions && (
|
||||
<div
|
||||
data-element="calibration-instructions"
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
left: 2,
|
||||
p: 2,
|
||||
bg: 'rgba(0, 0, 0, 0.75)',
|
||||
borderRadius: 'md',
|
||||
color: 'white',
|
||||
fontSize: 'xs',
|
||||
maxWidth: '180px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontWeight: 'medium' })}>Calibration</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowInstructions(false)}
|
||||
className={css({
|
||||
bg: 'transparent',
|
||||
border: 'none',
|
||||
color: 'gray.400',
|
||||
cursor: 'pointer',
|
||||
p: 0,
|
||||
fontSize: 'sm',
|
||||
lineHeight: 1,
|
||||
_hover: { color: 'white' },
|
||||
})}
|
||||
aria-label="Hide instructions"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<p className={css({ color: 'gray.300' })}>
|
||||
Drag inside to move. Drag corners to resize. Yellow = columns.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show instructions button (when hidden) */}
|
||||
{!showInstructions && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowInstructions(true)}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
left: 2,
|
||||
px: 2,
|
||||
py: 1,
|
||||
bg: 'rgba(0, 0, 0, 0.6)',
|
||||
border: 'none',
|
||||
borderRadius: 'md',
|
||||
color: 'gray.300',
|
||||
fontSize: 'xs',
|
||||
cursor: 'pointer',
|
||||
_hover: { bg: 'rgba(0, 0, 0, 0.8)', color: 'white' },
|
||||
})}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Control buttons */}
|
||||
<div
|
||||
data-element="calibration-controls"
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
right: 2,
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className={css({
|
||||
px: 3,
|
||||
py: 1.5,
|
||||
bg: 'gray.700',
|
||||
color: 'white',
|
||||
borderRadius: 'md',
|
||||
fontSize: 'sm',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
_hover: { bg: 'gray.600' },
|
||||
})}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleComplete}
|
||||
className={css({
|
||||
px: 3,
|
||||
py: 1.5,
|
||||
bg: 'green.600',
|
||||
color: 'white',
|
||||
borderRadius: 'md',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
_hover: { bg: 'green.500' },
|
||||
})}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CalibrationOverlay
|
||||
|
|
@ -0,0 +1,453 @@
|
|||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import type { CalibrationGrid, QuadCorners } from '@/types/vision'
|
||||
import { isOpenCVReady, loadOpenCV, rectifyQuadrilateral } from '@/lib/vision/perspectiveTransform'
|
||||
|
||||
export interface VisionCameraFeedProps {
|
||||
/** Video stream to display */
|
||||
videoStream: MediaStream | null
|
||||
/** Whether camera is currently loading */
|
||||
isLoading?: boolean
|
||||
/** Calibration grid to visualize (optional) */
|
||||
calibration?: CalibrationGrid | null
|
||||
/** Whether to show the calibration grid overlay */
|
||||
showCalibrationGrid?: boolean
|
||||
/** Whether to show rectified (perspective-corrected) view */
|
||||
showRectifiedView?: boolean
|
||||
/** Video element ref callback for external access */
|
||||
videoRef?: (el: HTMLVideoElement | null) => void
|
||||
/** Called when video metadata is loaded (provides dimensions) */
|
||||
onVideoReady?: (width: number, height: number) => void
|
||||
/** Children rendered over the video (e.g., CalibrationOverlay) */
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Get corners from calibration (prefer QuadCorners, fall back to ROI rectangle)
|
||||
*/
|
||||
function getCornersFromCalibration(calibration: CalibrationGrid): QuadCorners {
|
||||
if (calibration.corners) {
|
||||
return calibration.corners
|
||||
}
|
||||
const roi = calibration.roi
|
||||
return {
|
||||
topLeft: { x: roi.x, y: roi.y },
|
||||
topRight: { x: roi.x + roi.width, y: roi.y },
|
||||
bottomLeft: { x: roi.x, y: roi.y + roi.height },
|
||||
bottomRight: { x: roi.x + roi.width, y: roi.y + roi.height },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VisionCameraFeed - Displays camera video with optional calibration grid overlay
|
||||
*
|
||||
* Renders the video stream and optionally draws the calibration grid
|
||||
* showing the ROI and column dividers. Can also show a rectified
|
||||
* (perspective-corrected) view of the calibrated region.
|
||||
*/
|
||||
export function VisionCameraFeed({
|
||||
videoStream,
|
||||
isLoading = false,
|
||||
calibration,
|
||||
showCalibrationGrid = false,
|
||||
showRectifiedView = false,
|
||||
videoRef: externalVideoRef,
|
||||
onVideoReady,
|
||||
children,
|
||||
}: VisionCameraFeedProps): ReactNode {
|
||||
const internalVideoRef = useRef<HTMLVideoElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const rectifiedCanvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const animationFrameRef = useRef<number | null>(null)
|
||||
|
||||
const [opencvReady, setOpencvReady] = useState(false)
|
||||
|
||||
// Load OpenCV when rectified view is needed
|
||||
useEffect(() => {
|
||||
if (showRectifiedView && !opencvReady) {
|
||||
loadOpenCV()
|
||||
.then(() => setOpencvReady(true))
|
||||
.catch((err) => console.error('[VisionCameraFeed] Failed to load OpenCV:', err))
|
||||
}
|
||||
}, [showRectifiedView, opencvReady])
|
||||
|
||||
// Set video ref for external access
|
||||
useEffect(() => {
|
||||
if (externalVideoRef) {
|
||||
externalVideoRef(internalVideoRef.current)
|
||||
}
|
||||
}, [externalVideoRef])
|
||||
|
||||
// Attach stream to video element
|
||||
useEffect(() => {
|
||||
const video = internalVideoRef.current
|
||||
if (!video) return
|
||||
|
||||
let isMounted = true
|
||||
|
||||
if (videoStream) {
|
||||
video.srcObject = videoStream
|
||||
video.play().catch((err) => {
|
||||
// Ignore AbortError - happens when component unmounts during play()
|
||||
if (isMounted && err.name !== 'AbortError') {
|
||||
console.error('Failed to play video:', err)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
video.srcObject = null
|
||||
}
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [videoStream])
|
||||
|
||||
// Handle video metadata loaded
|
||||
useEffect(() => {
|
||||
const video = internalVideoRef.current
|
||||
if (!video || !videoStream) return
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
if (onVideoReady && video.videoWidth > 0 && video.videoHeight > 0) {
|
||||
onVideoReady(video.videoWidth, video.videoHeight)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if already loaded (e.g., from cache or fast load)
|
||||
if (video.readyState >= 1 && video.videoWidth > 0) {
|
||||
handleLoadedMetadata()
|
||||
}
|
||||
|
||||
video.addEventListener('loadedmetadata', handleLoadedMetadata)
|
||||
return () => {
|
||||
video.removeEventListener('loadedmetadata', handleLoadedMetadata)
|
||||
}
|
||||
}, [videoStream, onVideoReady])
|
||||
|
||||
// Rectified view processing loop
|
||||
useEffect(() => {
|
||||
if (!showRectifiedView || !calibration || !opencvReady) {
|
||||
return
|
||||
}
|
||||
|
||||
const video = internalVideoRef.current
|
||||
const canvas = rectifiedCanvasRef.current
|
||||
if (!video || !canvas) return
|
||||
|
||||
const corners = getCornersFromCalibration(calibration)
|
||||
let running = true
|
||||
|
||||
const processFrame = () => {
|
||||
if (!running) return
|
||||
|
||||
if (video.readyState >= 2 && isOpenCVReady()) {
|
||||
rectifyQuadrilateral(video, corners, canvas)
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(processFrame)
|
||||
}
|
||||
|
||||
processFrame()
|
||||
|
||||
return () => {
|
||||
running = false
|
||||
if (animationFrameRef.current !== null) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
animationFrameRef.current = null
|
||||
}
|
||||
}
|
||||
}, [showRectifiedView, calibration, opencvReady])
|
||||
|
||||
// Draw calibration grid overlay (only when not in rectified mode)
|
||||
useEffect(() => {
|
||||
if (!showCalibrationGrid || !calibration || showRectifiedView) return
|
||||
|
||||
const canvas = canvasRef.current
|
||||
const video = internalVideoRef.current
|
||||
if (!canvas || !video) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const drawGrid = () => {
|
||||
// Match canvas size to video display size
|
||||
const rect = video.getBoundingClientRect()
|
||||
canvas.width = rect.width
|
||||
canvas.height = rect.height
|
||||
|
||||
// Calculate scale factors
|
||||
const scaleX = rect.width / video.videoWidth
|
||||
const scaleY = rect.height / video.videoHeight
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
const corners = getCornersFromCalibration(calibration)
|
||||
|
||||
// Scale corners to display coordinates
|
||||
const displayCorners = {
|
||||
topLeft: { x: corners.topLeft.x * scaleX, y: corners.topLeft.y * scaleY },
|
||||
topRight: { x: corners.topRight.x * scaleX, y: corners.topRight.y * scaleY },
|
||||
bottomLeft: { x: corners.bottomLeft.x * scaleX, y: corners.bottomLeft.y * scaleY },
|
||||
bottomRight: { x: corners.bottomRight.x * scaleX, y: corners.bottomRight.y * scaleY },
|
||||
}
|
||||
|
||||
// Draw quadrilateral border
|
||||
ctx.strokeStyle = '#00ff00'
|
||||
ctx.lineWidth = 2
|
||||
ctx.setLineDash([5, 5])
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(displayCorners.topLeft.x, displayCorners.topLeft.y)
|
||||
ctx.lineTo(displayCorners.topRight.x, displayCorners.topRight.y)
|
||||
ctx.lineTo(displayCorners.bottomRight.x, displayCorners.bottomRight.y)
|
||||
ctx.lineTo(displayCorners.bottomLeft.x, displayCorners.bottomLeft.y)
|
||||
ctx.closePath()
|
||||
ctx.stroke()
|
||||
|
||||
// Draw column dividers (interpolate along top and bottom edges)
|
||||
ctx.setLineDash([])
|
||||
ctx.strokeStyle = '#00ff00'
|
||||
ctx.lineWidth = 1
|
||||
|
||||
for (const divider of calibration.columnDividers) {
|
||||
const topX =
|
||||
displayCorners.topLeft.x +
|
||||
divider * (displayCorners.topRight.x - displayCorners.topLeft.x)
|
||||
const topY =
|
||||
displayCorners.topLeft.y +
|
||||
divider * (displayCorners.topRight.y - displayCorners.topLeft.y)
|
||||
const bottomX =
|
||||
displayCorners.bottomLeft.x +
|
||||
divider * (displayCorners.bottomRight.x - displayCorners.bottomLeft.x)
|
||||
const bottomY =
|
||||
displayCorners.bottomLeft.y +
|
||||
divider * (displayCorners.bottomRight.y - displayCorners.bottomLeft.y)
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(topX, topY)
|
||||
ctx.lineTo(bottomX, bottomY)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// Draw beam line (20% from top, interpolated along left and right edges)
|
||||
ctx.strokeStyle = '#ffff00'
|
||||
ctx.setLineDash([3, 3])
|
||||
const beamT = 0.2
|
||||
const beamLeftX =
|
||||
displayCorners.topLeft.x + beamT * (displayCorners.bottomLeft.x - displayCorners.topLeft.x)
|
||||
const beamLeftY =
|
||||
displayCorners.topLeft.y + beamT * (displayCorners.bottomLeft.y - displayCorners.topLeft.y)
|
||||
const beamRightX =
|
||||
displayCorners.topRight.x +
|
||||
beamT * (displayCorners.bottomRight.x - displayCorners.topRight.x)
|
||||
const beamRightY =
|
||||
displayCorners.topRight.y +
|
||||
beamT * (displayCorners.bottomRight.y - displayCorners.topRight.y)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(beamLeftX, beamLeftY)
|
||||
ctx.lineTo(beamRightX, beamRightY)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// Draw on next frame
|
||||
const animationId = requestAnimationFrame(drawGrid)
|
||||
return () => cancelAnimationFrame(animationId)
|
||||
}, [showCalibrationGrid, calibration, showRectifiedView])
|
||||
|
||||
// Draw column dividers on rectified view
|
||||
const drawRectifiedOverlay = useCallback(() => {
|
||||
if (!showRectifiedView || !calibration) return
|
||||
|
||||
const canvas = rectifiedCanvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const width = canvas.width
|
||||
const height = canvas.height
|
||||
|
||||
// Draw column dividers on rectified canvas
|
||||
ctx.strokeStyle = '#00ff00'
|
||||
ctx.lineWidth = 1
|
||||
ctx.setLineDash([])
|
||||
|
||||
for (const divider of calibration.columnDividers) {
|
||||
const x = divider * width
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, 0)
|
||||
ctx.lineTo(x, height)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// Draw beam line
|
||||
ctx.strokeStyle = '#ffff00'
|
||||
ctx.setLineDash([3, 3])
|
||||
const beamY = height * 0.2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, beamY)
|
||||
ctx.lineTo(width, beamY)
|
||||
ctx.stroke()
|
||||
}, [showRectifiedView, calibration])
|
||||
|
||||
// Draw overlay after each rectification frame
|
||||
useEffect(() => {
|
||||
if (!showRectifiedView || !calibration || !opencvReady) return
|
||||
|
||||
// Set up a loop to draw overlay after rectification
|
||||
let running = true
|
||||
const overlayLoop = () => {
|
||||
if (!running) return
|
||||
drawRectifiedOverlay()
|
||||
requestAnimationFrame(overlayLoop)
|
||||
}
|
||||
// Delay slightly to let rectification happen first
|
||||
const timeoutId = setTimeout(() => {
|
||||
overlayLoop()
|
||||
}, 100)
|
||||
|
||||
return () => {
|
||||
running = false
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}, [showRectifiedView, calibration, opencvReady, drawRectifiedOverlay])
|
||||
|
||||
if (!videoStream) {
|
||||
return (
|
||||
<div
|
||||
data-component="vision-camera-feed"
|
||||
data-status={isLoading ? 'loading' : 'no-stream'}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
bg: 'gray.900',
|
||||
color: 'gray.400',
|
||||
borderRadius: 'lg',
|
||||
aspectRatio: '4/3',
|
||||
fontSize: 'sm',
|
||||
})}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div
|
||||
className={css({
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.600',
|
||||
borderTopColor: 'blue.400',
|
||||
borderRadius: 'full',
|
||||
})}
|
||||
style={{ animation: 'spin 1s linear infinite' }}
|
||||
/>
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
<span>Requesting camera...</span>
|
||||
</>
|
||||
) : (
|
||||
'No camera feed'
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show loading indicator while keeping video element mounted (so stream stays attached)
|
||||
const isLoadingOpenCV = showRectifiedView && !opencvReady
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-component="vision-camera-feed"
|
||||
data-status={showRectifiedView ? 'rectified' : 'active'}
|
||||
className={css({
|
||||
position: 'relative',
|
||||
borderRadius: 'lg',
|
||||
overflow: 'hidden',
|
||||
bg: 'black',
|
||||
})}
|
||||
>
|
||||
{/* Video element - always mounted (may be hidden when showing rectified view) */}
|
||||
<video
|
||||
ref={internalVideoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
display: showRectifiedView && !isLoadingOpenCV ? 'none' : 'block',
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Loading OpenCV indicator (overlays video) */}
|
||||
{isLoadingOpenCV && (
|
||||
<div
|
||||
data-element="opencv-loading-overlay"
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
bg: 'rgba(0, 0, 0, 0.7)',
|
||||
color: 'gray.400',
|
||||
fontSize: 'sm',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.600',
|
||||
borderTopColor: 'blue.400',
|
||||
borderRadius: 'full',
|
||||
})}
|
||||
style={{ animation: 'spin 1s linear infinite' }}
|
||||
/>
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
<span>Loading vision processing...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rectified view canvas */}
|
||||
{showRectifiedView && !isLoadingOpenCV && (
|
||||
<canvas
|
||||
ref={rectifiedCanvasRef}
|
||||
data-element="rectified-canvas"
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
display: 'block',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Calibration grid overlay (only when not in rectified mode) */}
|
||||
{showCalibrationGrid && !showRectifiedView && (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
data-element="calibration-grid-canvas"
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Children (e.g., CalibrationOverlay during calibration) */}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VisionCameraFeed
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { css } from '../../../styled-system/css'
|
||||
|
||||
export interface VisionStatusIndicatorProps {
|
||||
/** Whether calibration is complete */
|
||||
isCalibrated: boolean
|
||||
/** Whether actively detecting */
|
||||
isDetecting: boolean
|
||||
/** Detection confidence (0-1) */
|
||||
confidence: number
|
||||
/** Whether hand motion is detected */
|
||||
handDetected: boolean
|
||||
/** Current detected value (null if not stable) */
|
||||
detectedValue: number | null
|
||||
/** Number of consecutive stable frames */
|
||||
consecutiveFrames: number
|
||||
/** Minimum frames needed for stable detection */
|
||||
minFrames?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* VisionStatusIndicator - Shows current detection status
|
||||
*
|
||||
* Displays:
|
||||
* - Calibration status
|
||||
* - Detection status (detecting, stable, hand blocking)
|
||||
* - Confidence level
|
||||
* - Current detected value
|
||||
*/
|
||||
export function VisionStatusIndicator({
|
||||
isCalibrated,
|
||||
isDetecting,
|
||||
confidence,
|
||||
handDetected,
|
||||
detectedValue,
|
||||
consecutiveFrames,
|
||||
minFrames = 10,
|
||||
}: VisionStatusIndicatorProps): ReactNode {
|
||||
// Determine status
|
||||
let status: 'uncalibrated' | 'detecting' | 'stable' | 'hand-blocking' = 'uncalibrated'
|
||||
let statusColor = 'gray.500'
|
||||
let statusText = 'Not calibrated'
|
||||
|
||||
if (!isCalibrated) {
|
||||
status = 'uncalibrated'
|
||||
statusColor = 'gray.500'
|
||||
statusText = 'Not calibrated'
|
||||
} else if (handDetected) {
|
||||
status = 'hand-blocking'
|
||||
statusColor = 'orange.500'
|
||||
statusText = 'Hand detected'
|
||||
} else if (detectedValue !== null && consecutiveFrames >= minFrames) {
|
||||
status = 'stable'
|
||||
statusColor = 'green.500'
|
||||
statusText = 'Stable'
|
||||
} else if (isDetecting) {
|
||||
status = 'detecting'
|
||||
statusColor = 'yellow.500'
|
||||
statusText = 'Detecting...'
|
||||
}
|
||||
|
||||
// Confidence bar width
|
||||
const confidencePercent = Math.round(confidence * 100)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="vision-status-indicator"
|
||||
data-status={status}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
p: 2,
|
||||
bg: 'rgba(0, 0, 0, 0.7)',
|
||||
borderRadius: 'lg',
|
||||
minWidth: '120px',
|
||||
})}
|
||||
>
|
||||
{/* Status indicator */}
|
||||
<div
|
||||
data-element="status-row"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
})}
|
||||
>
|
||||
{/* Status dot */}
|
||||
<div
|
||||
data-element="status-dot"
|
||||
className={css({
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: 'full',
|
||||
animation: status === 'detecting' ? 'pulse 1s infinite' : 'none',
|
||||
})}
|
||||
style={{ backgroundColor: `var(--colors-${statusColor.replace('.', '-')})` }}
|
||||
/>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
color: 'white',
|
||||
fontWeight: 'medium',
|
||||
})}
|
||||
>
|
||||
{statusText}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Detected value */}
|
||||
{isCalibrated && detectedValue !== null && (
|
||||
<div
|
||||
data-element="detected-value"
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: status === 'stable' ? 'green.300' : 'yellow.300',
|
||||
fontFamily: 'mono',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{detectedValue}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confidence bar */}
|
||||
{isCalibrated && isDetecting && (
|
||||
<div
|
||||
data-element="confidence-bar"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.5,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
color: 'gray.400',
|
||||
})}
|
||||
>
|
||||
Confidence: {confidencePercent}%
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
height: '4px',
|
||||
bg: 'gray.700',
|
||||
borderRadius: 'full',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
height: '100%',
|
||||
borderRadius: 'full',
|
||||
transition: 'width 0.2s',
|
||||
})}
|
||||
style={{
|
||||
width: `${confidencePercent}%`,
|
||||
backgroundColor:
|
||||
confidence > 0.8
|
||||
? 'var(--colors-green-500)'
|
||||
: confidence > 0.5
|
||||
? 'var(--colors-yellow-500)'
|
||||
: 'var(--colors-red-500)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stability progress */}
|
||||
{isCalibrated && isDetecting && !handDetected && consecutiveFrames > 0 && (
|
||||
<div
|
||||
data-element="stability-progress"
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
color: 'gray.400',
|
||||
})}
|
||||
>
|
||||
{consecutiveFrames >= minFrames
|
||||
? 'Locked'
|
||||
: `Stabilizing... ${consecutiveFrames}/${minFrames}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VisionStatusIndicator
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* Vision components for physical abacus detection
|
||||
*
|
||||
* @module components/vision
|
||||
*/
|
||||
|
||||
export { AbacusVisionBridge } from './AbacusVisionBridge'
|
||||
export type { AbacusVisionBridgeProps } from './AbacusVisionBridge'
|
||||
|
||||
export { VisionCameraFeed } from './VisionCameraFeed'
|
||||
export type { VisionCameraFeedProps } from './VisionCameraFeed'
|
||||
|
||||
export { CalibrationOverlay } from './CalibrationOverlay'
|
||||
export type { CalibrationOverlayProps } from './CalibrationOverlay'
|
||||
|
||||
export { VisionStatusIndicator } from './VisionStatusIndicator'
|
||||
export type { VisionStatusIndicatorProps } from './VisionStatusIndicator'
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { CalibrationGrid, UseAbacusVisionReturn } from '@/types/vision'
|
||||
import { useCameraCalibration } from './useCameraCalibration'
|
||||
import { useDeskViewCamera } from './useDeskViewCamera'
|
||||
import { useFrameStability } from './useFrameStability'
|
||||
|
||||
export interface UseAbacusVisionOptions {
|
||||
/** Number of abacus columns to detect */
|
||||
columnCount?: number
|
||||
/** Called when a stable value is detected */
|
||||
onValueDetected?: (value: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* useAbacusVision - Primary coordinator hook for abacus vision detection
|
||||
*
|
||||
* Combines camera management, calibration, and frame processing into
|
||||
* a single hook that outputs stable detected values.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* const vision = useAbacusVision({
|
||||
* columnCount: 5,
|
||||
* onValueDetected: (value) => setDockedValue(value)
|
||||
* })
|
||||
*
|
||||
* return (
|
||||
* <VisionCameraFeed
|
||||
* videoStream={vision.videoStream}
|
||||
* calibration={vision.calibrationGrid}
|
||||
* />
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export function useAbacusVision(options: UseAbacusVisionOptions = {}): UseAbacusVisionReturn {
|
||||
const { columnCount = 5, onValueDetected } = options
|
||||
|
||||
// State
|
||||
const [isEnabled, setIsEnabled] = useState(false)
|
||||
const [isDetecting, setIsDetecting] = useState(false)
|
||||
|
||||
// Sub-hooks
|
||||
const camera = useDeskViewCamera()
|
||||
const calibration = useCameraCalibration()
|
||||
const stability = useFrameStability()
|
||||
|
||||
// Video element ref for frame capture
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const animationFrameRef = useRef<number | null>(null)
|
||||
|
||||
// Track previous stable value to avoid duplicate callbacks
|
||||
const lastStableValueRef = useRef<number | null>(null)
|
||||
|
||||
// Sync device ID to calibration hook when camera device changes
|
||||
useEffect(() => {
|
||||
if (camera.currentDevice?.deviceId) {
|
||||
calibration.setDeviceId(camera.currentDevice.deviceId)
|
||||
}
|
||||
}, [camera.currentDevice?.deviceId, calibration])
|
||||
|
||||
/**
|
||||
* Enable vision mode - start camera and detection
|
||||
*/
|
||||
const enable = useCallback(async () => {
|
||||
setIsEnabled(true)
|
||||
await camera.requestCamera()
|
||||
}, [camera])
|
||||
|
||||
/**
|
||||
* Disable vision mode - stop camera and detection
|
||||
*/
|
||||
const disable = useCallback(() => {
|
||||
setIsEnabled(false)
|
||||
setIsDetecting(false)
|
||||
camera.stopCamera()
|
||||
stability.reset()
|
||||
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
animationFrameRef.current = null
|
||||
}
|
||||
}, [camera, stability])
|
||||
|
||||
/**
|
||||
* Start calibration mode
|
||||
*/
|
||||
const startCalibration = useCallback(() => {
|
||||
calibration.startCalibration()
|
||||
}, [calibration])
|
||||
|
||||
/**
|
||||
* Finish calibration
|
||||
*/
|
||||
const finishCalibration = useCallback(
|
||||
(grid: CalibrationGrid) => {
|
||||
calibration.updateCalibration(grid)
|
||||
calibration.finishCalibration()
|
||||
},
|
||||
[calibration]
|
||||
)
|
||||
|
||||
/**
|
||||
* Cancel calibration
|
||||
*/
|
||||
const cancelCalibration = useCallback(() => {
|
||||
calibration.cancelCalibration()
|
||||
}, [calibration])
|
||||
|
||||
/**
|
||||
* Select specific camera
|
||||
*/
|
||||
const selectCamera = useCallback(
|
||||
(deviceId: string) => {
|
||||
camera.requestCamera(deviceId)
|
||||
},
|
||||
[camera]
|
||||
)
|
||||
|
||||
/**
|
||||
* Reset calibration
|
||||
*/
|
||||
const resetCalibration = useCallback(() => {
|
||||
calibration.resetCalibration()
|
||||
}, [calibration])
|
||||
|
||||
/**
|
||||
* Process a video frame for detection
|
||||
* (Stub - actual classification will be added when model is ready)
|
||||
*/
|
||||
const processFrame = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
const canvas = canvasRef.current
|
||||
if (!video || !canvas || !calibration.isCalibrated || !calibration.calibration) {
|
||||
return
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// Set canvas size to match video
|
||||
canvas.width = video.videoWidth
|
||||
canvas.height = video.videoHeight
|
||||
|
||||
// Draw video frame to canvas
|
||||
ctx.drawImage(video, 0, 0)
|
||||
|
||||
// Get full frame for motion detection
|
||||
const roi = calibration.calibration.roi
|
||||
const frameData = ctx.getImageData(roi.x, roi.y, roi.width, roi.height)
|
||||
stability.pushFrameData(frameData)
|
||||
|
||||
// TODO: When model is ready, slice into columns and classify
|
||||
// For now, we'll simulate detection with a placeholder
|
||||
// This will be replaced with actual TensorFlow.js inference
|
||||
|
||||
// Placeholder: Read "value" from a simple heuristic or return null
|
||||
// Real implementation will use useColumnClassifier
|
||||
}, [calibration.isCalibrated, calibration.calibration, stability])
|
||||
|
||||
/**
|
||||
* Detection loop
|
||||
*/
|
||||
const runDetectionLoop = useCallback(() => {
|
||||
if (!isEnabled || !calibration.isCalibrated || calibration.isCalibrating) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsDetecting(true)
|
||||
processFrame()
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(runDetectionLoop)
|
||||
}, [isEnabled, calibration.isCalibrated, calibration.isCalibrating, processFrame])
|
||||
|
||||
// Start/stop detection loop based on state
|
||||
useEffect(() => {
|
||||
if (isEnabled && calibration.isCalibrated && !calibration.isCalibrating && camera.videoStream) {
|
||||
runDetectionLoop()
|
||||
} else {
|
||||
setIsDetecting(false)
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
animationFrameRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
animationFrameRef.current = null
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isEnabled,
|
||||
calibration.isCalibrated,
|
||||
calibration.isCalibrating,
|
||||
camera.videoStream,
|
||||
runDetectionLoop,
|
||||
])
|
||||
|
||||
// Notify when stable value changes
|
||||
useEffect(() => {
|
||||
if (stability.stableValue !== null && stability.stableValue !== lastStableValueRef.current) {
|
||||
lastStableValueRef.current = stability.stableValue
|
||||
onValueDetected?.(stability.stableValue)
|
||||
}
|
||||
}, [stability.stableValue, onValueDetected])
|
||||
|
||||
// Create hidden canvas for frame processing
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) {
|
||||
canvasRef.current = document.createElement('canvas')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Cleanup animation frame on unmount (camera cleanup handled by disable())
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
}
|
||||
// Note: camera.stopCamera() is called by disable() - don't duplicate
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// Vision state
|
||||
isEnabled,
|
||||
isCalibrated: calibration.isCalibrated,
|
||||
isDetecting,
|
||||
currentDetectedValue: stability.stableValue,
|
||||
confidence: stability.currentConfidence,
|
||||
columnConfidences: [], // TODO: per-column confidences from classifier
|
||||
|
||||
// Camera state
|
||||
isCameraLoading: camera.isLoading,
|
||||
videoStream: camera.videoStream,
|
||||
cameraError: camera.error,
|
||||
selectedDeviceId: camera.currentDevice?.deviceId ?? null,
|
||||
availableDevices: camera.availableDevices,
|
||||
isDeskViewDetected: camera.isDeskViewDetected,
|
||||
|
||||
// Calibration state
|
||||
calibrationGrid: calibration.calibration,
|
||||
isCalibrating: calibration.isCalibrating,
|
||||
|
||||
// Stability state
|
||||
isHandDetected: stability.isHandDetected,
|
||||
consecutiveFrames: stability.consecutiveFrames,
|
||||
|
||||
// Actions
|
||||
enable,
|
||||
disable,
|
||||
startCalibration,
|
||||
finishCalibration,
|
||||
cancelCalibration,
|
||||
selectCamera,
|
||||
resetCalibration,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { CalibrationGrid, StoredCalibration } from '@/types/vision'
|
||||
import { CALIBRATION_STORAGE_KEY } from '@/types/vision'
|
||||
|
||||
export interface UseCameraCalibrationReturn {
|
||||
/** Whether a valid calibration exists */
|
||||
isCalibrated: boolean
|
||||
/** Current calibration grid */
|
||||
calibration: CalibrationGrid | null
|
||||
/** Whether currently in calibration mode */
|
||||
isCalibrating: boolean
|
||||
|
||||
/** Start interactive calibration mode */
|
||||
startCalibration: () => void
|
||||
/** Update calibration during drag */
|
||||
updateCalibration: (partial: Partial<CalibrationGrid>) => void
|
||||
/** Finish and save calibration */
|
||||
finishCalibration: () => void
|
||||
/** Cancel calibration without saving */
|
||||
cancelCalibration: () => void
|
||||
/** Reset/clear saved calibration */
|
||||
resetCalibration: () => void
|
||||
/** Load calibration from localStorage */
|
||||
loadCalibration: (deviceId?: string) => CalibrationGrid | null
|
||||
/** Create default calibration for given dimensions */
|
||||
createDefaultCalibration: (
|
||||
videoWidth: number,
|
||||
videoHeight: number,
|
||||
columnCount: number
|
||||
) => CalibrationGrid
|
||||
/** Set the current device ID for saving calibration */
|
||||
setDeviceId: (deviceId: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing camera calibration with localStorage persistence
|
||||
*/
|
||||
export function useCameraCalibration(): UseCameraCalibrationReturn {
|
||||
const [calibration, setCalibration] = useState<CalibrationGrid | null>(null)
|
||||
const [isCalibrating, setIsCalibrating] = useState(false)
|
||||
const [currentDeviceId, setCurrentDeviceId] = useState<string | null>(null)
|
||||
|
||||
const isCalibrated = calibration !== null
|
||||
|
||||
/**
|
||||
* Create a default calibration grid centered in the video
|
||||
*/
|
||||
const createDefaultCalibration = useCallback(
|
||||
(videoWidth: number, videoHeight: number, columnCount: number): CalibrationGrid => {
|
||||
// Default to center 60% of video
|
||||
const roiWidth = videoWidth * 0.6
|
||||
const roiHeight = videoHeight * 0.7
|
||||
const roiX = (videoWidth - roiWidth) / 2
|
||||
const roiY = (videoHeight - roiHeight) / 2
|
||||
|
||||
// Create evenly-spaced column dividers
|
||||
const columnDividers: number[] = []
|
||||
for (let i = 1; i < columnCount; i++) {
|
||||
columnDividers.push(i / columnCount)
|
||||
}
|
||||
|
||||
return {
|
||||
roi: {
|
||||
x: roiX,
|
||||
y: roiY,
|
||||
width: roiWidth,
|
||||
height: roiHeight,
|
||||
},
|
||||
columnCount,
|
||||
columnDividers,
|
||||
rotation: 0,
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
/**
|
||||
* Load calibration from localStorage for a specific device
|
||||
*/
|
||||
const loadCalibration = useCallback((deviceId?: string): CalibrationGrid | null => {
|
||||
try {
|
||||
const stored = localStorage.getItem(CALIBRATION_STORAGE_KEY)
|
||||
if (!stored) return null
|
||||
|
||||
const data = JSON.parse(stored) as StoredCalibration
|
||||
if (data.version !== 1) return null
|
||||
|
||||
// If deviceId specified, only use if it matches
|
||||
if (deviceId && data.deviceId !== deviceId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return data.grid
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Save calibration to localStorage
|
||||
*/
|
||||
const saveCalibration = useCallback((grid: CalibrationGrid, deviceId: string) => {
|
||||
const stored: StoredCalibration = {
|
||||
version: 1,
|
||||
grid,
|
||||
createdAt: new Date().toISOString(),
|
||||
deviceId,
|
||||
}
|
||||
localStorage.setItem(CALIBRATION_STORAGE_KEY, JSON.stringify(stored))
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Start calibration mode
|
||||
*/
|
||||
const startCalibration = useCallback(() => {
|
||||
setIsCalibrating(true)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Update calibration during interactive adjustment
|
||||
* Can also set a complete new calibration if none exists
|
||||
*/
|
||||
const updateCalibration = useCallback((partial: Partial<CalibrationGrid>) => {
|
||||
setCalibration((prev) => {
|
||||
// If we have a complete grid (has all required fields), use it directly
|
||||
if ('roi' in partial && 'columnCount' in partial && 'columnDividers' in partial) {
|
||||
return partial as CalibrationGrid
|
||||
}
|
||||
// Otherwise merge with existing
|
||||
if (!prev) return prev
|
||||
return { ...prev, ...partial }
|
||||
})
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Finish calibration and save
|
||||
*/
|
||||
const finishCalibration = useCallback(() => {
|
||||
if (calibration && currentDeviceId) {
|
||||
saveCalibration(calibration, currentDeviceId)
|
||||
}
|
||||
setIsCalibrating(false)
|
||||
}, [calibration, currentDeviceId, saveCalibration])
|
||||
|
||||
/**
|
||||
* Cancel calibration without saving
|
||||
*/
|
||||
const cancelCalibration = useCallback(() => {
|
||||
// Reload saved calibration
|
||||
const saved = loadCalibration(currentDeviceId ?? undefined)
|
||||
setCalibration(saved)
|
||||
setIsCalibrating(false)
|
||||
}, [currentDeviceId, loadCalibration])
|
||||
|
||||
/**
|
||||
* Reset/clear saved calibration
|
||||
*/
|
||||
const resetCalibration = useCallback(() => {
|
||||
localStorage.removeItem(CALIBRATION_STORAGE_KEY)
|
||||
setCalibration(null)
|
||||
setIsCalibrating(false)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Initialize calibration with device ID and optionally load from storage
|
||||
*/
|
||||
const initializeCalibration = useCallback(
|
||||
(deviceId: string, videoWidth: number, videoHeight: number, columnCount: number) => {
|
||||
setCurrentDeviceId(deviceId)
|
||||
|
||||
// Try to load saved calibration
|
||||
const saved = loadCalibration(deviceId)
|
||||
if (saved) {
|
||||
setCalibration(saved)
|
||||
} else {
|
||||
// Create default calibration
|
||||
setCalibration(createDefaultCalibration(videoWidth, videoHeight, columnCount))
|
||||
}
|
||||
},
|
||||
[loadCalibration, createDefaultCalibration]
|
||||
)
|
||||
|
||||
/**
|
||||
* Set the device ID for saving calibration
|
||||
*/
|
||||
const setDeviceId = useCallback((deviceId: string) => {
|
||||
setCurrentDeviceId(deviceId)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isCalibrated,
|
||||
calibration,
|
||||
isCalibrating,
|
||||
startCalibration,
|
||||
updateCalibration,
|
||||
finishCalibration,
|
||||
cancelCalibration,
|
||||
resetCalibration,
|
||||
loadCalibration,
|
||||
createDefaultCalibration,
|
||||
setDeviceId,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { DESK_VIEW_PATTERNS } from '@/types/vision'
|
||||
|
||||
export interface UseDeskViewCameraReturn {
|
||||
/** Whether camera is currently loading */
|
||||
isLoading: boolean
|
||||
/** Error message if camera failed */
|
||||
error: string | null
|
||||
/** Active video stream */
|
||||
videoStream: MediaStream | null
|
||||
/** Currently selected device */
|
||||
currentDevice: MediaDeviceInfo | null
|
||||
/** All available video input devices */
|
||||
availableDevices: MediaDeviceInfo[]
|
||||
/** Whether Desk View camera was auto-detected */
|
||||
isDeskViewDetected: boolean
|
||||
|
||||
/** Request camera access, optionally specifying device ID */
|
||||
requestCamera: (deviceId?: string) => Promise<void>
|
||||
/** Stop camera stream */
|
||||
stopCamera: () => void
|
||||
/** Refresh device list */
|
||||
enumerateDevices: () => Promise<MediaDeviceInfo[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing camera access with Desk View auto-detection
|
||||
*
|
||||
* Prioritizes finding Apple's "Desk View" camera (via Continuity Camera),
|
||||
* but falls back to manual device selection if not available.
|
||||
*/
|
||||
export function useDeskViewCamera(): UseDeskViewCameraReturn {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [videoStream, setVideoStream] = useState<MediaStream | null>(null)
|
||||
const [currentDevice, setCurrentDevice] = useState<MediaDeviceInfo | null>(null)
|
||||
const [availableDevices, setAvailableDevices] = useState<MediaDeviceInfo[]>([])
|
||||
const [isDeskViewDetected, setIsDeskViewDetected] = useState(false)
|
||||
|
||||
const streamRef = useRef<MediaStream | null>(null)
|
||||
const requestIdRef = useRef(0) // Track request ID to ignore stale completions
|
||||
|
||||
/**
|
||||
* Enumerate available video input devices
|
||||
*/
|
||||
const enumerateDevices = useCallback(async (): Promise<MediaDeviceInfo[]> => {
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||
const videoInputs = devices.filter((d) => d.kind === 'videoinput')
|
||||
setAvailableDevices(videoInputs)
|
||||
return videoInputs
|
||||
} catch (err) {
|
||||
console.error('Failed to enumerate devices:', err)
|
||||
return []
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Check if a device label matches Desk View patterns
|
||||
*/
|
||||
const isDeskViewDevice = useCallback((device: MediaDeviceInfo): boolean => {
|
||||
const label = device.label.toLowerCase()
|
||||
return DESK_VIEW_PATTERNS.some((pattern) => label.includes(pattern))
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Find Desk View camera from device list
|
||||
*/
|
||||
const findDeskViewCamera = useCallback(
|
||||
(devices: MediaDeviceInfo[]): MediaDeviceInfo | null => {
|
||||
for (const device of devices) {
|
||||
if (isDeskViewDevice(device)) {
|
||||
return device
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
[isDeskViewDevice]
|
||||
)
|
||||
|
||||
/**
|
||||
* Request camera access
|
||||
*/
|
||||
const requestCamera = useCallback(
|
||||
async (deviceId?: string): Promise<void> => {
|
||||
const thisRequestId = ++requestIdRef.current
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Stop any existing stream
|
||||
if (streamRef.current) {
|
||||
for (const track of streamRef.current.getTracks()) {
|
||||
track.stop()
|
||||
}
|
||||
streamRef.current = null
|
||||
}
|
||||
|
||||
const devices = await enumerateDevices()
|
||||
|
||||
// Check if this request is still the latest
|
||||
if (thisRequestId !== requestIdRef.current) return
|
||||
|
||||
// If no deviceId specified, try to find Desk View
|
||||
let targetDeviceId = deviceId
|
||||
if (!targetDeviceId) {
|
||||
const deskViewDevice = findDeskViewCamera(devices)
|
||||
if (deskViewDevice) {
|
||||
targetDeviceId = deskViewDevice.deviceId
|
||||
setIsDeskViewDetected(true)
|
||||
} else {
|
||||
setIsDeskViewDetected(false)
|
||||
}
|
||||
}
|
||||
|
||||
const constraints: MediaStreamConstraints = {
|
||||
video: {
|
||||
width: { ideal: 1920 },
|
||||
height: { ideal: 1440 },
|
||||
...(targetDeviceId ? { deviceId: { exact: targetDeviceId } } : {}),
|
||||
},
|
||||
audio: false,
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints)
|
||||
|
||||
// Check again if this request is still the latest
|
||||
if (thisRequestId !== requestIdRef.current) {
|
||||
for (const track of stream.getTracks()) {
|
||||
track.stop()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
streamRef.current = stream
|
||||
setVideoStream(stream)
|
||||
|
||||
// Find which device we got
|
||||
const videoTrack = stream.getVideoTracks()[0]
|
||||
if (videoTrack) {
|
||||
const settings = videoTrack.getSettings()
|
||||
const matchingDevice = devices.find((d) => d.deviceId === settings.deviceId)
|
||||
if (matchingDevice) {
|
||||
setCurrentDevice(matchingDevice)
|
||||
setIsDeskViewDetected(isDeskViewDevice(matchingDevice))
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
} catch (err) {
|
||||
console.error('[DeskViewCamera] Failed to access camera:', err)
|
||||
setError(err instanceof Error ? err.message : 'Failed to access camera')
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[enumerateDevices, findDeskViewCamera, isDeskViewDevice]
|
||||
)
|
||||
|
||||
/**
|
||||
* Stop the camera stream
|
||||
*/
|
||||
const stopCamera = useCallback(() => {
|
||||
requestIdRef.current++
|
||||
|
||||
if (streamRef.current) {
|
||||
for (const track of streamRef.current.getTracks()) {
|
||||
track.stop()
|
||||
}
|
||||
streamRef.current = null
|
||||
}
|
||||
setVideoStream(null)
|
||||
setCurrentDevice(null)
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (streamRef.current) {
|
||||
for (const track of streamRef.current.getTracks()) {
|
||||
track.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Listen for device changes (e.g., iPhone connected/disconnected)
|
||||
useEffect(() => {
|
||||
const handleDeviceChange = () => {
|
||||
enumerateDevices()
|
||||
}
|
||||
|
||||
navigator.mediaDevices.addEventListener('devicechange', handleDeviceChange)
|
||||
return () => {
|
||||
navigator.mediaDevices.removeEventListener('devicechange', handleDeviceChange)
|
||||
}
|
||||
}, [enumerateDevices])
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
error,
|
||||
videoStream,
|
||||
currentDevice,
|
||||
availableDevices,
|
||||
isDeskViewDetected,
|
||||
requestCamera,
|
||||
stopCamera,
|
||||
enumerateDevices,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import type { FrameStabilityConfig } from '@/types/vision'
|
||||
import { DEFAULT_STABILITY_CONFIG } from '@/types/vision'
|
||||
|
||||
export interface UseFrameStabilityReturn {
|
||||
/** Current stable value (null if not stable) */
|
||||
stableValue: number | null
|
||||
/** How many consecutive frames have shown current value */
|
||||
consecutiveFrames: number
|
||||
/** Is currently detecting hand movement */
|
||||
isHandDetected: boolean
|
||||
/** Current unstable/in-progress value */
|
||||
currentValue: number | null
|
||||
/** Current average confidence */
|
||||
currentConfidence: number
|
||||
|
||||
/** Feed new detection result */
|
||||
pushFrame: (value: number, confidence: number) => void
|
||||
/** Feed frame data for motion detection */
|
||||
pushFrameData: (frameData: ImageData) => void
|
||||
/** Reset stability tracking */
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for debouncing frame-by-frame detection results
|
||||
*
|
||||
* Tracks consecutive frames showing the same value and only
|
||||
* outputs a "stable" value once the threshold is met.
|
||||
* Also detects large frame-to-frame changes indicating hand motion.
|
||||
*/
|
||||
export function useFrameStability(
|
||||
config: Partial<FrameStabilityConfig> = {}
|
||||
): UseFrameStabilityReturn {
|
||||
const mergedConfig = { ...DEFAULT_STABILITY_CONFIG, ...config }
|
||||
|
||||
const [stableValue, setStableValue] = useState<number | null>(null)
|
||||
const [consecutiveFrames, setConsecutiveFrames] = useState(0)
|
||||
const [isHandDetected, setIsHandDetected] = useState(false)
|
||||
const [currentValue, setCurrentValue] = useState<number | null>(null)
|
||||
const [currentConfidence, setCurrentConfidence] = useState(0)
|
||||
|
||||
// Track last detected value for comparison
|
||||
const lastValueRef = useRef<number | null>(null)
|
||||
const confidenceHistoryRef = useRef<number[]>([])
|
||||
|
||||
// Track previous frame for motion detection
|
||||
const previousFrameRef = useRef<ImageData | null>(null)
|
||||
|
||||
/**
|
||||
* Push a new detection result
|
||||
*/
|
||||
const pushFrame = useCallback(
|
||||
(value: number, confidence: number) => {
|
||||
// Skip if confidence too low
|
||||
if (confidence < mergedConfig.minConfidence) {
|
||||
return
|
||||
}
|
||||
|
||||
setCurrentValue(value)
|
||||
setCurrentConfidence(confidence)
|
||||
|
||||
// Track confidence history (last 10 frames)
|
||||
confidenceHistoryRef.current.push(confidence)
|
||||
if (confidenceHistoryRef.current.length > 10) {
|
||||
confidenceHistoryRef.current.shift()
|
||||
}
|
||||
|
||||
// Check if value matches previous
|
||||
if (value === lastValueRef.current) {
|
||||
setConsecutiveFrames((prev) => {
|
||||
const next = prev + 1
|
||||
// Check if we've reached stability threshold
|
||||
if (next >= mergedConfig.minConsecutiveFrames) {
|
||||
setStableValue(value)
|
||||
}
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
// Value changed, reset counter
|
||||
lastValueRef.current = value
|
||||
setConsecutiveFrames(1)
|
||||
// Clear stable value since we're seeing a new value
|
||||
setStableValue(null)
|
||||
}
|
||||
},
|
||||
[mergedConfig.minConfidence, mergedConfig.minConsecutiveFrames]
|
||||
)
|
||||
|
||||
/**
|
||||
* Push frame data for motion detection
|
||||
*/
|
||||
const pushFrameData = useCallback(
|
||||
(frameData: ImageData) => {
|
||||
const prevFrame = previousFrameRef.current
|
||||
if (!prevFrame) {
|
||||
previousFrameRef.current = frameData
|
||||
setIsHandDetected(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Compare frames for motion
|
||||
let changedPixels = 0
|
||||
const totalPixels = frameData.width * frameData.height
|
||||
const threshold = 30 // Pixel value difference threshold
|
||||
|
||||
// Sample every 4th pixel for performance
|
||||
for (let i = 0; i < frameData.data.length; i += 16) {
|
||||
const diff = Math.abs(frameData.data[i] - prevFrame.data[i])
|
||||
if (diff > threshold) {
|
||||
changedPixels++
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust for sampling rate
|
||||
const sampledPixels = totalPixels / 4
|
||||
const changeRatio = changedPixels / sampledPixels
|
||||
|
||||
// Update hand detection state
|
||||
const handDetected = changeRatio > mergedConfig.handMotionThreshold
|
||||
setIsHandDetected(handDetected)
|
||||
|
||||
// If hand detected, reset stability
|
||||
if (handDetected) {
|
||||
setStableValue(null)
|
||||
setConsecutiveFrames(0)
|
||||
lastValueRef.current = null
|
||||
}
|
||||
|
||||
// Store current frame for next comparison
|
||||
previousFrameRef.current = frameData
|
||||
},
|
||||
[mergedConfig.handMotionThreshold]
|
||||
)
|
||||
|
||||
/**
|
||||
* Reset all stability tracking
|
||||
*/
|
||||
const reset = useCallback(() => {
|
||||
setStableValue(null)
|
||||
setConsecutiveFrames(0)
|
||||
setIsHandDetected(false)
|
||||
setCurrentValue(null)
|
||||
setCurrentConfidence(0)
|
||||
lastValueRef.current = null
|
||||
confidenceHistoryRef.current = []
|
||||
previousFrameRef.current = null
|
||||
}, [])
|
||||
|
||||
return {
|
||||
stableValue,
|
||||
consecutiveFrames,
|
||||
isHandDetected,
|
||||
currentValue,
|
||||
currentConfidence,
|
||||
pushFrame,
|
||||
pushFrameData,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
/**
|
||||
* Perspective Transform utilities using OpenCV.js
|
||||
*
|
||||
* Provides functions to rectify a quadrilateral region from a video frame
|
||||
* into a rectangle, correcting for camera perspective distortion.
|
||||
*/
|
||||
|
||||
import type { QuadCorners } from '@/types/vision'
|
||||
|
||||
// OpenCV.js types (minimal subset we need)
|
||||
declare global {
|
||||
interface Window {
|
||||
cv: {
|
||||
Mat: new (rows?: number, cols?: number, type?: number) => CvMat
|
||||
matFromImageData: (imageData: ImageData) => CvMat
|
||||
matFromArray: (rows: number, cols: number, type: number, data: number[]) => CvMat
|
||||
CV_8UC4: number
|
||||
CV_32FC1: number
|
||||
CV_32FC2: number
|
||||
getPerspectiveTransform: (src: CvMat, dst: CvMat) => CvMat
|
||||
warpPerspective: (
|
||||
src: CvMat,
|
||||
dst: CvMat,
|
||||
M: CvMat,
|
||||
dsize: { width: number; height: number },
|
||||
flags?: number,
|
||||
borderMode?: number,
|
||||
borderValue?: unknown
|
||||
) => void
|
||||
cvtColor: (src: CvMat, dst: CvMat, code: number) => void
|
||||
COLOR_RGBA2RGB: number
|
||||
COLOR_RGB2RGBA: number
|
||||
INTER_LINEAR: number
|
||||
BORDER_CONSTANT: number
|
||||
onRuntimeInitialized?: () => void
|
||||
}
|
||||
}
|
||||
|
||||
interface CvMat {
|
||||
rows: number
|
||||
cols: number
|
||||
data: Uint8Array
|
||||
data32F: Float32Array
|
||||
delete: () => void
|
||||
}
|
||||
}
|
||||
|
||||
let opencvLoaded = false
|
||||
let opencvLoadPromise: Promise<void> | null = null
|
||||
|
||||
/**
|
||||
* Load OpenCV.js dynamically
|
||||
*/
|
||||
export async function loadOpenCV(): Promise<void> {
|
||||
if (opencvLoaded) return
|
||||
|
||||
if (opencvLoadPromise) {
|
||||
return opencvLoadPromise
|
||||
}
|
||||
|
||||
opencvLoadPromise = new Promise<void>((resolve, reject) => {
|
||||
// Check if already loaded
|
||||
if (typeof window !== 'undefined' && window.cv && window.cv.Mat) {
|
||||
opencvLoaded = true
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
// Load the script
|
||||
const script = document.createElement('script')
|
||||
script.src = '/opencv.js'
|
||||
script.async = true
|
||||
|
||||
script.onload = () => {
|
||||
// OpenCV.js uses a callback when ready
|
||||
if (window.cv && window.cv.onRuntimeInitialized !== undefined) {
|
||||
window.cv.onRuntimeInitialized = () => {
|
||||
opencvLoaded = true
|
||||
resolve()
|
||||
}
|
||||
} else {
|
||||
// Fallback: poll for cv.Mat availability
|
||||
const checkReady = setInterval(() => {
|
||||
if (window.cv && window.cv.Mat) {
|
||||
clearInterval(checkReady)
|
||||
opencvLoaded = true
|
||||
resolve()
|
||||
}
|
||||
}, 100)
|
||||
|
||||
// Timeout after 10 seconds
|
||||
setTimeout(() => {
|
||||
clearInterval(checkReady)
|
||||
if (!opencvLoaded) {
|
||||
reject(new Error('OpenCV.js failed to initialize'))
|
||||
}
|
||||
}, 10000)
|
||||
}
|
||||
}
|
||||
|
||||
script.onerror = () => {
|
||||
reject(new Error('Failed to load OpenCV.js'))
|
||||
}
|
||||
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
|
||||
return opencvLoadPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OpenCV is loaded and ready
|
||||
*/
|
||||
export function isOpenCVReady(): boolean {
|
||||
return opencvLoaded && typeof window !== 'undefined' && !!window.cv?.Mat
|
||||
}
|
||||
|
||||
export interface RectifyOptions {
|
||||
/** Output width (default: calculated from quad aspect ratio) */
|
||||
outputWidth?: number
|
||||
/** Output height (default: calculated from quad aspect ratio) */
|
||||
outputHeight?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Rectify a quadrilateral region from video to a rectangle
|
||||
*
|
||||
* @param video - Source video element
|
||||
* @param corners - Quadrilateral corners in video coordinates
|
||||
* @param canvas - Destination canvas for rectified output
|
||||
* @param options - Output size options
|
||||
*/
|
||||
export function rectifyQuadrilateral(
|
||||
video: HTMLVideoElement,
|
||||
corners: QuadCorners,
|
||||
canvas: HTMLCanvasElement,
|
||||
options: RectifyOptions = {}
|
||||
): boolean {
|
||||
if (!isOpenCVReady()) {
|
||||
console.warn('[perspectiveTransform] OpenCV not ready')
|
||||
return false
|
||||
}
|
||||
|
||||
const cv = window.cv
|
||||
|
||||
// Calculate default output dimensions based on quad size
|
||||
const topWidth = Math.hypot(
|
||||
corners.topRight.x - corners.topLeft.x,
|
||||
corners.topRight.y - corners.topLeft.y
|
||||
)
|
||||
const bottomWidth = Math.hypot(
|
||||
corners.bottomRight.x - corners.bottomLeft.x,
|
||||
corners.bottomRight.y - corners.bottomLeft.y
|
||||
)
|
||||
const leftHeight = Math.hypot(
|
||||
corners.bottomLeft.x - corners.topLeft.x,
|
||||
corners.bottomLeft.y - corners.topLeft.y
|
||||
)
|
||||
const rightHeight = Math.hypot(
|
||||
corners.bottomRight.x - corners.topRight.x,
|
||||
corners.bottomRight.y - corners.topRight.y
|
||||
)
|
||||
|
||||
const avgWidth = (topWidth + bottomWidth) / 2
|
||||
const avgHeight = (leftHeight + rightHeight) / 2
|
||||
|
||||
const outputWidth = options.outputWidth ?? Math.round(avgWidth)
|
||||
const outputHeight = options.outputHeight ?? Math.round(avgHeight)
|
||||
|
||||
// Set canvas size
|
||||
canvas.width = outputWidth
|
||||
canvas.height = outputHeight
|
||||
|
||||
// Create temporary canvas to capture video frame
|
||||
const tempCanvas = document.createElement('canvas')
|
||||
tempCanvas.width = video.videoWidth
|
||||
tempCanvas.height = video.videoHeight
|
||||
const tempCtx = tempCanvas.getContext('2d')
|
||||
if (!tempCtx) return false
|
||||
|
||||
// Draw video frame
|
||||
tempCtx.drawImage(video, 0, 0)
|
||||
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height)
|
||||
|
||||
// Create OpenCV matrices
|
||||
let srcMat: CvMat | null = null
|
||||
let dstMat: CvMat | null = null
|
||||
let srcPoints: CvMat | null = null
|
||||
let dstPoints: CvMat | null = null
|
||||
let transformMatrix: CvMat | null = null
|
||||
|
||||
try {
|
||||
// Source image
|
||||
srcMat = cv.matFromImageData(imageData)
|
||||
|
||||
// Source points (quadrilateral corners) - order: TL, TR, BR, BL
|
||||
srcPoints = cv.matFromArray(4, 1, cv.CV_32FC2, [
|
||||
corners.topLeft.x,
|
||||
corners.topLeft.y,
|
||||
corners.topRight.x,
|
||||
corners.topRight.y,
|
||||
corners.bottomRight.x,
|
||||
corners.bottomRight.y,
|
||||
corners.bottomLeft.x,
|
||||
corners.bottomLeft.y,
|
||||
])
|
||||
|
||||
// Destination points (rectangle corners) - map to standard rectangle
|
||||
// Order: TL, TR, BR, BL (matching source points order)
|
||||
dstPoints = cv.matFromArray(4, 1, cv.CV_32FC2, [
|
||||
0,
|
||||
0, // TL → top-left of output
|
||||
outputWidth,
|
||||
0, // TR → top-right of output
|
||||
outputWidth,
|
||||
outputHeight, // BR → bottom-right of output
|
||||
0,
|
||||
outputHeight, // BL → bottom-left of output
|
||||
])
|
||||
|
||||
// Calculate perspective transform matrix
|
||||
transformMatrix = cv.getPerspectiveTransform(srcPoints, dstPoints)
|
||||
|
||||
// Create output matrix
|
||||
dstMat = new cv.Mat(outputHeight, outputWidth, cv.CV_8UC4)
|
||||
|
||||
// Apply perspective warp
|
||||
cv.warpPerspective(
|
||||
srcMat,
|
||||
dstMat,
|
||||
transformMatrix,
|
||||
{ width: outputWidth, height: outputHeight },
|
||||
cv.INTER_LINEAR,
|
||||
cv.BORDER_CONSTANT
|
||||
)
|
||||
|
||||
// Copy result to canvas
|
||||
const outputCtx = canvas.getContext('2d')
|
||||
if (!outputCtx) return false
|
||||
|
||||
const outputData = new ImageData(new Uint8ClampedArray(dstMat.data), outputWidth, outputHeight)
|
||||
outputCtx.putImageData(outputData, 0, 0)
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('[perspectiveTransform] Error:', err)
|
||||
return false
|
||||
} finally {
|
||||
// Clean up OpenCV matrices
|
||||
srcMat?.delete()
|
||||
dstMat?.delete()
|
||||
srcPoints?.delete()
|
||||
dstPoints?.delete()
|
||||
transformMatrix?.delete()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a rectified frame processor that continuously updates a canvas
|
||||
*
|
||||
* @param video - Source video element
|
||||
* @param corners - Quadrilateral corners
|
||||
* @param canvas - Output canvas
|
||||
* @param options - Rectify options
|
||||
* @returns Stop function
|
||||
*/
|
||||
export function createRectifiedFrameLoop(
|
||||
video: HTMLVideoElement,
|
||||
corners: QuadCorners,
|
||||
canvas: HTMLCanvasElement,
|
||||
options: RectifyOptions = {}
|
||||
): () => void {
|
||||
let running = true
|
||||
let animationId: number | null = null
|
||||
|
||||
const processFrame = () => {
|
||||
if (!running) return
|
||||
|
||||
if (video.readyState >= 2) {
|
||||
// HAVE_CURRENT_DATA
|
||||
rectifyQuadrilateral(video, corners, canvas, options)
|
||||
}
|
||||
|
||||
animationId = requestAnimationFrame(processFrame)
|
||||
}
|
||||
|
||||
processFrame()
|
||||
|
||||
return () => {
|
||||
running = false
|
||||
if (animationId !== null) {
|
||||
cancelAnimationFrame(animationId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
/**
|
||||
* Types for the AbacusVisionBridge feature
|
||||
*
|
||||
* Enables students to control their digital abacus using a physical soroban
|
||||
* and camera (targeting Apple's Desk View).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Region of Interest (ROI) rectangle in video coordinates
|
||||
* @deprecated Use QuadCorners for perspective-aware calibration
|
||||
*/
|
||||
export interface ROI {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A point in 2D space (video coordinates)
|
||||
*/
|
||||
export interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Four corners of a quadrilateral for perspective-aware ROI
|
||||
* Allows independent positioning of each corner to match camera perspective
|
||||
*/
|
||||
export interface QuadCorners {
|
||||
/** Top-left corner */
|
||||
topLeft: Point
|
||||
/** Top-right corner */
|
||||
topRight: Point
|
||||
/** Bottom-left corner */
|
||||
bottomLeft: Point
|
||||
/** Bottom-right corner */
|
||||
bottomRight: Point
|
||||
}
|
||||
|
||||
/**
|
||||
* Calibration grid defining how to slice the video feed into columns
|
||||
*/
|
||||
export interface CalibrationGrid {
|
||||
/** Bounding box in video coordinates (legacy - for backward compat) */
|
||||
roi: ROI
|
||||
/** Four corners of the quadrilateral ROI (preferred) */
|
||||
corners?: QuadCorners
|
||||
/** Number of abacus columns to detect */
|
||||
columnCount: number
|
||||
/** Column divider positions as fractions (0-1) within ROI */
|
||||
columnDividers: number[]
|
||||
/** Rotation angle in degrees (for skewed cameras) - deprecated, use corners instead */
|
||||
rotation: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored calibration data in localStorage
|
||||
*/
|
||||
export interface StoredCalibration {
|
||||
version: 1
|
||||
grid: CalibrationGrid
|
||||
createdAt: string
|
||||
deviceId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of classifying a single column
|
||||
*/
|
||||
export interface ColumnClassificationResult {
|
||||
digit: number
|
||||
confidence: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of classifying all columns in a frame
|
||||
*/
|
||||
export interface FrameClassificationResult {
|
||||
digits: number[]
|
||||
confidences: number[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* State returned by useAbacusVision hook
|
||||
*/
|
||||
export interface AbacusVisionState {
|
||||
// Vision state
|
||||
isEnabled: boolean
|
||||
isCalibrated: boolean
|
||||
isDetecting: boolean
|
||||
currentDetectedValue: number | null
|
||||
confidence: number
|
||||
columnConfidences: number[]
|
||||
|
||||
// Camera state
|
||||
isCameraLoading: boolean
|
||||
videoStream: MediaStream | null
|
||||
cameraError: string | null
|
||||
selectedDeviceId: string | null
|
||||
availableDevices: MediaDeviceInfo[]
|
||||
isDeskViewDetected: boolean
|
||||
|
||||
// Calibration state
|
||||
calibrationGrid: CalibrationGrid | null
|
||||
isCalibrating: boolean
|
||||
|
||||
// Stability state
|
||||
isHandDetected: boolean
|
||||
consecutiveFrames: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions returned by useAbacusVision hook
|
||||
*/
|
||||
export interface AbacusVisionActions {
|
||||
/** Start the camera and vision processing */
|
||||
enable: () => Promise<void>
|
||||
/** Stop the camera and vision processing */
|
||||
disable: () => void
|
||||
/** Enter calibration mode */
|
||||
startCalibration: () => void
|
||||
/** Save calibration and exit calibration mode */
|
||||
finishCalibration: (grid: CalibrationGrid) => void
|
||||
/** Cancel calibration without saving */
|
||||
cancelCalibration: () => void
|
||||
/** Select a specific camera device */
|
||||
selectCamera: (deviceId: string) => void
|
||||
/** Clear saved calibration */
|
||||
resetCalibration: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined state and actions from useAbacusVision
|
||||
*/
|
||||
export type UseAbacusVisionReturn = AbacusVisionState & AbacusVisionActions
|
||||
|
||||
/**
|
||||
* Configuration for frame stability detection
|
||||
*/
|
||||
export interface FrameStabilityConfig {
|
||||
/** Minimum consecutive frames showing same value to consider stable */
|
||||
minConsecutiveFrames: number
|
||||
/** Minimum confidence threshold for a valid detection */
|
||||
minConfidence: number
|
||||
/** Pixel change ratio threshold for detecting hand motion */
|
||||
handMotionThreshold: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Default stability configuration
|
||||
*/
|
||||
export const DEFAULT_STABILITY_CONFIG: FrameStabilityConfig = {
|
||||
minConsecutiveFrames: 10, // ~300ms at 30fps
|
||||
minConfidence: 0.7,
|
||||
handMotionThreshold: 0.3,
|
||||
}
|
||||
|
||||
/**
|
||||
* Patterns to detect Desk View camera by device label
|
||||
*/
|
||||
export const DESK_VIEW_PATTERNS = ['desk view', 'continuity camera', 'iphone camera']
|
||||
|
||||
/**
|
||||
* localStorage key for calibration data
|
||||
*/
|
||||
export const CALIBRATION_STORAGE_KEY = 'abacus-vision-calibration'
|
||||
Loading…
Reference in New Issue