UNPKG

handible

Version:

Revolutionary hand tracking and gesture control for the web. Transform any webcam into a powerful 3D controller with MediaPipe and Three.js.

540 lines (456 loc) 22.1 kB
// Updated handTracking.js import * as THREE from "three"; import { ArrowHelper } from "three"; import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js'; import { isPinching2D, onPinchStart, onPinchEnd, updateRaycast, getRayVisualsPerHand, getConeVisualsPerHand, isPinchingState } from "./gestureControl.js"; import { getSceneObjects } from "./sceneManager.js"; const NUM_HANDS_TO_DETECT = 2; const EMA_ALPHA = 0.35; export const handConfig = { xScale: 2, yScale: -2, zMagnification: 2, zOffset: 0, rotationOffset: new THREE.Euler(0, 0, 0) // Default no rotation }; const HAND_CONNECTIONS = [ [0, 1], [1, 2], [2, 3], [3, 4], // Thumb [0, 5], [5, 6], [6, 7], [7, 8], // Index [0, 9], [9, 10], [10, 11], [11, 12], // Middle [0, 13], [13, 14], [14, 15], [15, 16], // Ring [0, 17], [17, 18], [18, 19], [19, 20], // Pinky ]; const landmarkVisualsPerHand = []; const connectionVisualsPerHand = []; const smoothedLandmarksPerHand = []; const zAxisVisualsPerHand = []; // Only blue z-arrow per hand const laserVisualsPerHand = []; // Laser line per hand const palmSpheresPerHand = []; // New: Persistent spheres per hand let lastVideoTime = -1; let results = undefined; let fpsCounterElement = document.getElementById("fps-counter"); let frameCount = 0; let lastFpsUpdateTime = performance.now(); const PALM_FACING_THRESHOLD = 0.5; // Adjust based on testing; assumes normal.z > this for facing camera const PALM_SPHERE_RADIUS = 0.1; // Size of sphere in palm // New: UI panel for left hand let uiPanel = null; const UI_PANEL_WIDTH = 1.0; const UI_PANEL_HEIGHT = 0.6; const UI_PANEL_OFFSET = new THREE.Vector3(0.6, 0.3, -0.5); // x=0.6 (right), y=0.3 (up), z=-0.5 (back) const UI_PANEL_TILT = -Math.PI / 12; // Slight tilt downwards const smoothedUIPosition = new THREE.Vector3(); // For smooth following // New: Anti-flicker for UI panel let consecutiveFacingTrue = 0; let consecutiveFacingFalse = 0; const FACING_TRUE_THRESHOLD = 3; // ~100ms at 30fps for show delay (made easier) const FACING_FALSE_THRESHOLD = 10; // ~333ms at 30fps for hide delay (made easier) // Moved: Define isUIActive at module level let isUIActive = false; export async function setupHandTracking(scene) { // Create UI panel once const panelGeometry = new RoundedBoxGeometry(UI_PANEL_WIDTH, UI_PANEL_HEIGHT, 0.05, 8, 0.5); // Width, height, depth, segments, radius for rounded corners const panelMaterial = new THREE.MeshStandardMaterial({ color: 0xbffbff, // white blue transparent: false, // opacity: 0.85, // Slight transparency without expensive transmission metalness: 0.1, roughness: 0.3, // Balanced roughness for good look without heavy computation side: THREE.DoubleSide }); uiPanel = new THREE.Mesh(panelGeometry, panelMaterial); uiPanel.visible = false; uiPanel.castShadow = true; uiPanel.receiveShadow = true; scene.add(uiPanel); // Comment out button creation for UI panel const buttonPositions = [ { x: -0.3, y: 0, color: 0xffaa00, action: 'switchToThreeScene', label: 'Main' }, // Orange button - Main Scene { x: 0, y: 0, color: 0x00ff00, action: 'switchToTableScene', label: 'Table' }, // Green button - Table Scene { x: 0.3, y: 0, color: 0x0066ff, action: 'switchToSimpleScene', label: 'Simple' } // Blue button - Simple Scene ]; buttonPositions.forEach(pos => { const buttonGeometry = new THREE.CylinderGeometry(0.1, 0.1, 0.05, 32); // Smaller for UI const buttonMaterial = new THREE.MeshStandardMaterial({ color: pos.color, roughness: 0.4, metalness: 0.5 }); const button = new THREE.Mesh(buttonGeometry, buttonMaterial); button.position.set(pos.x, pos.y, 0.03); // Slightly above panel button.rotation.x = Math.PI / 2; button.userData.isUIButton = true; // Mark as UI button for interaction button.userData.defaultColor = pos.color; button.userData.hoverColor = 0xffff00; // Yellow for hover button.userData.activeColor = 0xffa500; // Orange for pressed button.userData.defaultPosition = button.position.clone(); // Store default position for reset button.userData.action = pos.action; // Use the action from button position data button.userData.label = pos.label; // Store label for identification uiPanel.add(button); }); for (let i = 0; i < NUM_HANDS_TO_DETECT; i++) { const currentHandSpheres = []; const currentSmoothedLandmarks = []; const sharedMaterial = new THREE.MeshBasicMaterial({ color: 0xbffbff, transparent: true, opacity: 0.5}); const sphereGeometry = new THREE.SphereGeometry(0.03, 16, 16); // 0.02 original for (let j = 0; j < 21; j++) { const sphereMaterial = (j === 4 || j === 8) ? new THREE.MeshBasicMaterial({ color: 0x00ffff }) : sharedMaterial; const sphere = new THREE.Mesh(sphereGeometry, sphereMaterial); sphere.visible = false; scene.add(sphere); currentHandSpheres.push(sphere); currentSmoothedLandmarks.push(new THREE.Vector3()); } landmarkVisualsPerHand.push(currentHandSpheres); smoothedLandmarksPerHand.push(currentSmoothedLandmarks); const currentHandConnections = []; const capsuleGeometry = new THREE.CapsuleGeometry(0.04, 1, 8, 16); // Thick radius 0.03, base length 1 const capsuleMaterial = new THREE.MeshBasicMaterial({ color: 0xbffbff, transparent: true, opacity: 0.5 }); for (const connection of HAND_CONNECTIONS) { const capsule = new THREE.Mesh(capsuleGeometry, capsuleMaterial); capsule.visible = false; scene.add(capsule); currentHandConnections.push(capsule); } connectionVisualsPerHand.push(currentHandConnections); // Z-axis visual (blue) const zDir = new THREE.Vector3(0, 0, 1); const zArrow = new ArrowHelper(zDir, new THREE.Vector3(), 0.2, 0x0000ff); zArrow.visible = false; scene.add(zArrow); zAxisVisualsPerHand.push(zArrow); // Laser visual const laserMaterial = new THREE.LineBasicMaterial({ color: 0xff00ff }); const laserGeometry = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]); const laserLine = new THREE.Line(laserGeometry, laserMaterial); laserLine.visible = false; scene.add(laserLine); laserVisualsPerHand.push(laserLine); // New: Create persistent palm sphere per hand const palmSphereGeometry = new THREE.SphereGeometry(PALM_SPHERE_RADIUS, 16, 16); const palmSphereMaterial = new THREE.MeshStandardMaterial({ color: 0x00ff00 }); // Green sphere for visibility const palmSphere = new THREE.Mesh(palmSphereGeometry, palmSphereMaterial); palmSphere.visible = false; // Initially hidden palmSphere.castShadow = true; palmSphere.receiveShadow = true; scene.add(palmSphere); palmSpheresPerHand.push(palmSphere); } } export function getWristPosition(handIndex) { if (handIndex >= 0 && handIndex < smoothedLandmarksPerHand.length) { return smoothedLandmarksPerHand[handIndex][0].clone(); } return new THREE.Vector3(); } export function getForwardDirection(handIndex) { if (handIndex >= 0 && handIndex < smoothedLandmarksPerHand.length) { const wrist = smoothedLandmarksPerHand[handIndex][0]; const middleBase = smoothedLandmarksPerHand[handIndex][9]; return middleBase.clone().sub(wrist).normalize(); } return new THREE.Vector3(0, 0, -1); } // Updated: Function to check if open palm is facing the camera, handling left/right handedness function isPalmFacingCamera(handIndex, smoothedLandmarks, handedness) { const wrist = smoothedLandmarks[0]; const indexBase = smoothedLandmarks[5]; const pinkyBase = smoothedLandmarks[17]; const vec1 = new THREE.Vector3().subVectors(indexBase, wrist); const vec2 = new THREE.Vector3().subVectors(pinkyBase, wrist); let normal = new THREE.Vector3().crossVectors(vec1, vec2).normalize(); // Adjust for handedness: Flip normal for left hand to consistent direction if (handedness === 'Left') { normal.negate(); } // Assuming camera is at positive z, facing negative z; palm facing camera if normal z > threshold return normal.z > PALM_FACING_THRESHOLD; } // New: Function to update palm sphere position and visibility function updatePalmSphere(handIndex, smoothedLandmarks, isFacing) { const palmSphere = palmSpheresPerHand[handIndex]; if (!palmSphere) return; if (isFacing) { // Palm center approx average of wrist, index/pinky/ring/middle bases const palmCenter = new THREE.Vector3() .add(smoothedLandmarks[0]) .add(smoothedLandmarks[5]) .add(smoothedLandmarks[9]) .add(smoothedLandmarks[13]) .add(smoothedLandmarks[17]) .divideScalar(5); palmSphere.position.copy(palmCenter); // Hide sphere when palm is facing camera (user requested) palmSphere.visible = false; // console.log(`Hand ${handIndex} palm sphere toggled OFF (facing camera)`); } else { // Show sphere when palm is NOT facing camera palmSphere.visible = false; // Keep hidden always if you don't want to see it // console.log(`Hand ${handIndex} palm sphere toggled OFF (not facing)`); } } // New: Function to update UI panel for left hand with anti-flicker function updateUIPanel(smoothedLandmarks, isFacing) { if (!uiPanel) return; if (isFacing) { consecutiveFacingTrue++; consecutiveFacingFalse = 0; if (consecutiveFacingTrue >= FACING_TRUE_THRESHOLD) { // Hand center for positioning (e.g., wrist) const handCenter = smoothedLandmarks[0].clone(); // Wrist // Smooth position smoothedUIPosition.lerp(handCenter.add(UI_PANEL_OFFSET), EMA_ALPHA); uiPanel.position.copy(smoothedUIPosition); // Face camera with slight tilt uiPanel.lookAt(getSceneObjects().camera.position); // Face camera uiPanel.rotation.x += UI_PANEL_TILT; // Add slight tilt uiPanel.visible = true; // console.log("UI panel toggled ON"); } } else { consecutiveFacingFalse++; consecutiveFacingTrue = 0; if (consecutiveFacingFalse >= FACING_FALSE_THRESHOLD) { uiPanel.visible = false; // console.log("UI panel toggled OFF"); } } } export function cleanupHandTracking(scene) { // New: Take scene for removal/disposal // Dispose and remove visuals landmarkVisualsPerHand.forEach((handVisuals, i) => { handVisuals.forEach(sphere => { scene.remove(sphere); sphere.geometry.dispose(); sphere.material.dispose(); }); }); landmarkVisualsPerHand.length = 0; // Repeat for each visual array connectionVisualsPerHand.forEach(handConnections => { handConnections.forEach(capsule => { scene.remove(capsule); capsule.geometry.dispose(); capsule.material.dispose(); }); }); connectionVisualsPerHand.length = 0; zAxisVisualsPerHand.forEach(arrow => scene.remove(arrow)); zAxisVisualsPerHand.length = 0; laserVisualsPerHand.forEach(laser => { scene.remove(laser); laser.geometry.dispose(); laser.material.dispose(); }); laserVisualsPerHand.length = 0; palmSpheresPerHand.forEach(sphere => { scene.remove(sphere); sphere.geometry.dispose(); sphere.material.dispose(); }); palmSpheresPerHand.length = 0; // gives error // palmRayVisualsPerHand.forEach(ray => { // scene.remove(ray); // ray.geometry.dispose(); // ray.material.dispose(); // }); // palmRayVisualsPerHand.length = 0; // UI panel if (uiPanel) { uiPanel.traverse(child => { if (child.geometry) child.geometry.dispose(); if (child.material) child.material.dispose(); }); scene.remove(uiPanel); uiPanel = null; } // Counters/smoothed clears (unchanged) consecutiveFacingTrue = 0; consecutiveFacingFalse = 0; isUIActive = false; smoothedUIPosition.set(0, 0, 0); smoothedLandmarksPerHand.length = 0; } export function predictWebcam(video, handLandmarker) { if (!handLandmarker || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { requestAnimationFrame(() => predictWebcam(video, handLandmarker)); return; } const startTimeMs = performance.now(); lastVideoTime = video.currentTime; results = handLandmarker.detectForVideo(video, startTimeMs); frameCount++; const currentTime = performance.now(); if (currentTime - lastFpsUpdateTime >= 1000) { fpsCounterElement.textContent = `FPS: ${frameCount}`; frameCount = 0; lastFpsUpdateTime = currentTime; } // Hide visuals for all hands by default for (let i = 0; i < NUM_HANDS_TO_DETECT; i++) { if (i >= landmarkVisualsPerHand.length || !landmarkVisualsPerHand[i]) continue; landmarkVisualsPerHand[i].forEach((sphere) => (sphere.visible = false)); if (i >= connectionVisualsPerHand.length || !connectionVisualsPerHand[i]) continue; connectionVisualsPerHand[i].forEach((capsule) => (capsule.visible = false)); if (i >= zAxisVisualsPerHand.length || !zAxisVisualsPerHand[i]) continue; zAxisVisualsPerHand[i].visible = false; if (i >= laserVisualsPerHand.length || !laserVisualsPerHand[i]) continue; laserVisualsPerHand[i].visible = false; if (i >= palmSpheresPerHand.length || !palmSpheresPerHand[i]) continue; palmSpheresPerHand[i].visible = false; // Hide palm spheres by default } if (uiPanel) uiPanel.visible = false; // Hide UI panel by default // New: Reset chessboard highlights if present const scene = getSceneObjects().scene; const chessboard = scene.getObjectByProperty('isChessboard', true); // Recursive find if (chessboard) { chessboard.children.forEach(square => { square.material.color.set(square.userData.defaultColor); }); } // Reset UI active flag per frame - will be set by left hand if facing let tempUIActive = false; // First pass: determine if UI should be active (check left hand) if (results && results.landmarks && results.landmarks.length > 0) { for (let handIndex = 0; handIndex < results.landmarks.length; handIndex++) { const handedness = results.handedness[handIndex][0].categoryName; if (handedness === 'Left' && handIndex < smoothedLandmarksPerHand.length && smoothedLandmarksPerHand[handIndex]) { const currentSmoothedLandmarks = smoothedLandmarksPerHand[handIndex]; const facingNow = isPalmFacingCamera(handIndex, currentSmoothedLandmarks, handedness); console.log(`Left hand facing: ${facingNow}, consecutiveFacingTrue: ${consecutiveFacingTrue}`); if (facingNow) { tempUIActive = true; updateUIPanel(currentSmoothedLandmarks, facingNow); } else { updateUIPanel(currentSmoothedLandmarks, facingNow); } break; // Found left hand, no need to continue } } } // Update global UI state isUIActive = tempUIActive; // Debug logging if (tempUIActive) { console.log("UI is now ACTIVE - right hand should be able to interact with UI panel"); } if (results && results.landmarks && results.landmarks.length > 0) { for (let handIndex = 0; handIndex < results.landmarks.length; handIndex++) { if (handIndex >= landmarkVisualsPerHand.length || !landmarkVisualsPerHand[handIndex]) continue; const handedness = results.handedness[handIndex][0].categoryName; // 'Left' or 'Right' const currentHandLandmarks = results.landmarks[handIndex]; const pinchingNow = isPinching2D(currentHandLandmarks, video.videoWidth, video.videoHeight); if (pinchingNow && !isPinchingState[handIndex]) { // console.log(`Hand ${handIndex} PINCH START`); isPinchingState[handIndex] = true; onPinchStart(handIndex, handedness, isUIActive); // Pass handedness and isUIActive } else if (!pinchingNow && isPinchingState[handIndex]) { // console.log(`Hand ${handIndex} PINCH END`); isPinchingState[handIndex] = false; onPinchEnd(handIndex); } if (handIndex >= landmarkVisualsPerHand.length) continue; const tipColor = pinchingNow ? 0xff0000 : 0x00ffff; landmarkVisualsPerHand[handIndex][4].material.color.set(tipColor); landmarkVisualsPerHand[handIndex][8].material.color.set(tipColor); const currentLandmarkSpheres = landmarkVisualsPerHand[handIndex]; const currentHandConnections = connectionVisualsPerHand[handIndex]; const currentSmoothedLandmarks = smoothedLandmarksPerHand[handIndex]; const currentZArrow = zAxisVisualsPerHand[handIndex]; const currentLaser = laserVisualsPerHand[handIndex]; currentLandmarkSpheres.forEach((sphere) => (sphere.visible = true)); currentHandConnections.forEach((capsule) => (capsule.visible = true)); currentZArrow.visible = true; // currentLaser.visible = true; // DISABLED: Pink raycast ray turned off // New: Quaternion from config rotation (computed once per hand for efficiency) const tiltQuat = new THREE.Quaternion().setFromEuler(handConfig.rotationOffset); // Smoothing landmarks for (let i = 0; i < currentHandLandmarks.length; i++) { const rawLandmark = currentHandLandmarks[i]; // New: Center and rotate raw coordinates as a vector const rawVec = new THREE.Vector3( rawLandmark.x - 0.5, rawLandmark.y - 0.5, rawLandmark.z ); rawVec.applyQuaternion(tiltQuat); // Updated: Use configurable variables for position calculation const targetX = (1.0 - rawLandmark.x - 0.5) * handConfig.xScale; const targetY = (rawLandmark.y - 0.5) * handConfig.yScale; const targetZ = rawLandmark.z * handConfig.zMagnification + handConfig.zOffset; const currentPosition = new THREE.Vector3(targetX, targetY, targetZ); currentSmoothedLandmarks[i].lerp(currentPosition, EMA_ALPHA); currentLandmarkSpheres[i].position.copy(currentSmoothedLandmarks[i]); // currentLandmarkSpheres[i].rotation.copy(handConfig.rotationOffset); // Apply rotation to sphere // currentLandmarkSpheres[i].rotation.copy(handConfig.rotationOffset); } const wrist = currentSmoothedLandmarks[0].clone(); // Center for rotation const quat = new THREE.Quaternion().setFromEuler(handConfig.rotationOffset); // Convert Euler to Quaternion for (let i = 0; i < currentSmoothedLandmarks.length; i++) { const vec = currentSmoothedLandmarks[i].clone().sub(wrist); // Vector from wrist vec.applyQuaternion(quat); // Rotate the vector currentSmoothedLandmarks[i] = wrist.clone().add(vec); // New position currentLandmarkSpheres[i].position.copy(currentSmoothedLandmarks[i]); // Update sphere position } for (let i = 0; i < HAND_CONNECTIONS.length; i++) { const connection = HAND_CONNECTIONS[i]; const startLandmarkIndex = connection[0]; const endLandmarkIndex = connection[1]; const capsule = currentHandConnections[i]; const startPos = currentSmoothedLandmarks[startLandmarkIndex]; const endPos = currentSmoothedLandmarks[endLandmarkIndex]; // Position midway capsule.position.copy(startPos.clone().add(endPos).multiplyScalar(0.5)); // Direction and length const direction = endPos.clone().sub(startPos).normalize(); const length = endPos.distanceTo(startPos); // Scale (CapsuleGeometry is along y, length in y) capsule.scale.set(1, length, 1); // Rotation: align y-axis with direction const up = new THREE.Vector3(0, 1, 0); const quaternion = new THREE.Quaternion().setFromUnitVectors(up, direction); capsule.quaternion.copy(quaternion); // capsule.quaternion.multiply(new THREE.Quaternion().setFromEuler(handConfig.rotationOffset)); } // Update z-axis visual (blue arrow) const forward = getForwardDirection(handIndex); currentZArrow.position.copy(wrist); currentZArrow.setDirection(forward); currentZArrow.setLength(0.2); // Update laser visual const laserEnd = wrist.clone().add(forward.multiplyScalar(10)); // Use forward for alignment const positions = currentLaser.geometry.attributes.position.array; positions[0] = wrist.x; positions[1] = wrist.y; positions[2] = wrist.z; positions[3] = laserEnd.x; positions[4] = laserEnd.y; positions[5] = laserEnd.z; currentLaser.geometry.attributes.position.needsUpdate = true; // New: Detect palm facing camera and toggle/update sphere visibility const facingNow = isPalmFacingCamera(handIndex, currentSmoothedLandmarks, handedness); updatePalmSphere(handIndex, currentSmoothedLandmarks, facingNow); // Update raycast for this hand (now uses globally set isUIActive) updateRaycast(handIndex, handedness, isUIActive); } } // Hide rays for undetected hands const rayVisuals = getRayVisualsPerHand(); for (let i = results && results.landmarks ? results.landmarks.length : 0; i < NUM_HANDS_TO_DETECT; i++) { const rayLine = rayVisuals[i]; if (rayLine) rayLine.visible = false; } // Hide cones for undetected hands const coneVisuals = getConeVisualsPerHand(); for (let i = results && results.landmarks ? results.landmarks.length : 0; i < NUM_HANDS_TO_DETECT; i++) { const cone = coneVisuals[i]; if (cone) cone.visible = false; } requestAnimationFrame(() => predictWebcam(video, handLandmarker)); } export function getHandTrackingData() { // Maybe need null checks here? return { landmarkVisualsPerHand, connectionVisualsPerHand, smoothedLandmarksPerHand }; }