Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,010 changes: 1,010 additions & 0 deletions MEDIAPIPE_INTEGRATION.md

Large diffs are not rendered by default.

13,634 changes: 13,634 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
},
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@mediapipe/tasks-vision": "^0.10.22-rc.20250304",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-aspect-ratio": "^1.1.1",
Expand Down Expand Up @@ -75,9 +76,9 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/three": "^0.169.0",
"@vitejs/plugin-react": "^4.3.4",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.12",
Expand All @@ -86,4 +87,4 @@
"typescript": "^5",
"vite": "^5.4.10"
}
}
}
176 changes: 176 additions & 0 deletions src/components/mediapipe/hand-tracker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// Main hand tracking component combining webcam, MediaPipe, and visualization

import { useState, useCallback, useEffect } from 'react'
import { useMediaPipe } from '@/hooks/useMediaPipe'
import { useWebcam } from '@/hooks/useWebcam'
import { useHandTracking } from '@/hooks/useHandTracking'
import { useGestureControl } from '@/hooks/useGestureControl'
import { HandVisualizer } from './hand-visualizer'
import type {
ProcessedHandData,
GestureResult,
JointAngles,
DiscreteCommand,
MediaPipeConfig,
ControlMappingConfig,
} from '@/types/mediapipe'

interface HandTrackerProps {
enabled?: boolean
showVideo?: boolean
showVisualization?: boolean
mediaConfig?: Partial<MediaPipeConfig>
controlConfig?: Partial<ControlMappingConfig>
onJointAnglesChange?: (angles: JointAngles) => void
onDiscreteCommand?: (command: DiscreteCommand) => void
onEStop?: () => void
onTrackingStateChange?: (isTracking: boolean) => void
onHandDataChange?: (data: ProcessedHandData | null) => void
onGestureChange?: (gesture: GestureResult | null) => void
className?: string
videoWidth?: number
videoHeight?: number
}

export function HandTracker({
enabled = false,
showVideo = true,
showVisualization = true,
mediaConfig,
controlConfig,
onJointAnglesChange,
onDiscreteCommand,
onEStop,
onTrackingStateChange,
onHandDataChange,
onGestureChange,
className = '',
videoWidth = 320,
videoHeight = 240,
}: HandTrackerProps) {
const [isInitialized, setIsInitialized] = useState(false)

// Initialize MediaPipe
const {
isInitialized: mediaPipeReady,
isLoading: mediaPipeLoading,
error: mediaPipeError,
gestureRecognizer,
} = useMediaPipe(mediaConfig)

// Initialize webcam
const {
videoRef,
isStreaming,
error: webcamError,
startStream,
stopStream,
} = useWebcam({ width: videoWidth, height: videoHeight })

// Initialize hand tracking
const {
isTracking,
handData,
gesture,
fps,
startTracking,
stopTracking,
} = useHandTracking(videoRef, gestureRecognizer, {
enabled,
mirrorMode: mediaConfig?.mirrorMode ?? true,
onLandmarksUpdate: onHandDataChange,
onGestureDetected: onGestureChange,
})

// Initialize gesture control
const {
jointAngles,
discreteCommand,
isControlling,
enable: enableControl,
disable: disableControl,
} = useGestureControl(handData, gesture, {
config: controlConfig,
onJointAnglesChange,
onDiscreteCommand,
onEStop,
})

// Handle enable/disable
useEffect(() => {
if (enabled && mediaPipeReady && !isStreaming) {
startStream().catch(console.error)
} else if (!enabled && isStreaming) {
stopTracking()
disableControl()
stopStream()
}
}, [enabled, mediaPipeReady, isStreaming, startStream, stopStream, stopTracking, disableControl])

// Start tracking when stream is ready
useEffect(() => {
if (enabled && isStreaming && mediaPipeReady && !isTracking) {
startTracking()
enableControl()
setIsInitialized(true)
}
}, [enabled, isStreaming, mediaPipeReady, isTracking, startTracking, enableControl])

// Notify parent of tracking state changes
useEffect(() => {
onTrackingStateChange?.(isTracking)
}, [isTracking, onTrackingStateChange])

const error = mediaPipeError || webcamError

return (
<div className={`relative ${className}`} style={{ width: videoWidth, height: videoHeight }}>
{/* Status overlay */}
{!isInitialized && (
<div className="absolute inset-0 bg-black/80 flex items-center justify-center z-20">
<div className="text-center text-white">
{mediaPipeLoading && <p className="animate-pulse">Loading MediaPipe...</p>}
{error && <p className="text-red-400">Error: {error.message}</p>}
{!enabled && !error && <p className="text-gray-400">Hand tracking disabled</p>}
</div>
</div>
)}

{/* Video element */}
<video
ref={videoRef}
className={`absolute inset-0 w-full h-full object-cover ${showVideo ? '' : 'opacity-0'}`}
style={{ transform: 'scaleX(-1)' }} // Mirror for user-facing camera
playsInline
muted
/>

{/* Hand visualization overlay */}
{showVisualization && handData && (
<HandVisualizer
handData={handData}
width={videoWidth}
height={videoHeight}
className="z-10"
/>
)}

{/* Stats overlay */}
{isTracking && (
<div className="absolute top-2 left-2 bg-black/60 text-white text-xs px-2 py-1 rounded z-20">
<div>FPS: {fps}</div>
{gesture && gesture.categoryName !== 'None' && (
<div className="text-orange-400">
{gesture.categoryName} ({(gesture.score * 100).toFixed(0)}%)
</div>
)}
{handData && (
<div className="text-green-400">
Pinch: {(handData.pinchDistance * 100).toFixed(0)}%
</div>
)}
</div>
)}
</div>
)
}
Loading