feat(playground): add MediaPipe hand tracking input with dev panel
Hand tracking via webcam as alternative to joystick input: - Right hand tracks 14 derived features (palm XY, finger curls, spread, roll, pitch, pinch) through a separate 14-input MLP - Left hand gesture recognition (1 finger = thumbs up, 2 = thumbs down) with 400ms debounce hold - Split-zone PIP display (no camera feed, skeleton only) with dashed divider and cross-zone dimming - Dual IML architecture: independent imlJoy (2 inputs) and imlHand (14 inputs) with pointer swap, preserving training data per mode - Dev panel (?devmode=true): draggable/collapsible floating panel with sliders for MediaPipe confidence thresholds, smoothing, gesture hold time, world landmarks toggle, and live feature bar monitor - Visual presets always route to joystick IML (prevents dimension mismatch) - Race condition guard on input mode switching
This commit is contained in:
parent
f8983c4806
commit
5a4924728f
5 changed files with 1267 additions and 19 deletions
|
|
@ -75,6 +75,20 @@
|
|||
<div id="gamepad-status" style="color: #ff6a00; font-size: 0.6rem; text-align: center; position: absolute; bottom: -16px; left: 0; right: 0; pointer-events: none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Hand tracking PIP (replaces joystick position when active) -->
|
||||
<div class="hand-pip hidden" id="hand-pip">
|
||||
<video id="hand-video" playsinline autoplay muted></video>
|
||||
<canvas id="hand-overlay"></canvas>
|
||||
<div class="hand-status" id="hand-status">Loading...</div>
|
||||
<div class="gesture-indicator" id="gesture-indicator">
|
||||
<svg class="gesture-ring" viewBox="0 0 36 36">
|
||||
<circle class="gesture-ring-bg" cx="18" cy="18" r="16" />
|
||||
<circle class="gesture-ring-progress" cx="18" cy="18" r="16" />
|
||||
</svg>
|
||||
<span class="gesture-label" id="gesture-label"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RL floating buttons -->
|
||||
<div class="rl-buttons" id="rl-buttons">
|
||||
<button class="rl-btn rl-down" id="btn-thumbsdown" title="Explore more"><span class="rl-icon">−</span><span class="key-num">1</span></button>
|
||||
|
|
@ -85,6 +99,10 @@
|
|||
<div class="bottom-sheet collapsed" id="bottom-sheet">
|
||||
<!-- Floating mode bar (sticky inside sheet) -->
|
||||
<div class="floating-bar" id="floating-bar">
|
||||
<div class="pill-toggle pill-toggle-sm" id="input-toggle">
|
||||
<button class="pill-opt active" data-input="joystick">Joystick</button>
|
||||
<button class="pill-opt" data-input="hands">Hands</button>
|
||||
</div>
|
||||
<div class="pill-toggle pill-toggle-sm" id="output-toggle-float">
|
||||
<button class="pill-opt active" data-mode="visual">Visual</button>
|
||||
<button class="pill-opt" data-mode="synth">Synth</button>
|
||||
|
|
@ -237,6 +255,21 @@
|
|||
</table>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h3>Hand Tracking</h3>
|
||||
<p>Toggle to <strong>Hands</strong> mode in the bottom bar to use your webcam for input.</p>
|
||||
<ul>
|
||||
<li><strong>Right hand</strong> controls parameters — palm position, finger curls, spread, rotation, and pinch map to 14 input dimensions</li>
|
||||
<li><strong>Left hand</strong> gives feedback via gestures:</li>
|
||||
<ul>
|
||||
<li><strong>1 finger</strong> (index) held 0.4s → positive feedback (+)</li>
|
||||
<li><strong>2 fingers</strong> (index + middle) held 0.4s → negative feedback (−)</li>
|
||||
</ul>
|
||||
<li>If only one hand is visible, it's treated as the tracking hand — use keyboard/buttons for feedback</li>
|
||||
<li>Camera permission is requested only when you switch to Hands mode</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h3>Tips</h3>
|
||||
<ul>
|
||||
|
|
|
|||
|
|
@ -261,6 +261,260 @@ html, body {
|
|||
50% { box-shadow: 0 0 12px 3px rgba(255, 106, 0, 0.5); }
|
||||
}
|
||||
|
||||
/* ---- Hand tracking PIP ---- */
|
||||
.hand-pip {
|
||||
position: fixed;
|
||||
bottom: 136px;
|
||||
left: 24px;
|
||||
width: 180px;
|
||||
height: 135px;
|
||||
z-index: 30;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.hand-pip.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hand-pip video {
|
||||
/* Hidden — only used as MediaPipe source, not displayed */
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hand-pip canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: scaleX(-1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hand-status {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
pointer-events: none;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.8);
|
||||
}
|
||||
|
||||
.hand-status.tracking {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.gesture-indicator {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.gesture-indicator.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.gesture-ring {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.gesture-ring-bg {
|
||||
fill: none;
|
||||
stroke: rgba(255,255,255,0.1);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.gesture-ring-progress {
|
||||
fill: none;
|
||||
stroke: var(--accent);
|
||||
stroke-width: 3;
|
||||
stroke-dasharray: 100.53;
|
||||
stroke-dashoffset: 100.53;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dashoffset 0.05s linear;
|
||||
}
|
||||
|
||||
.gesture-label {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.8);
|
||||
}
|
||||
|
||||
/* ---- Dev panel (hand tracking tuning) ---- */
|
||||
.dev-panel {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
right: 16px;
|
||||
width: 220px;
|
||||
z-index: 90;
|
||||
background: rgba(10, 10, 10, 0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
user-select: none;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.dev-panel.collapsed {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.dev-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
cursor: grab;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
|
||||
.dev-panel-header:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.dev-panel-title {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.dev-panel-collapse {
|
||||
background: none;
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
color: var(--text-dim);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dev-panel-body {
|
||||
padding: 6px 10px 10px;
|
||||
}
|
||||
|
||||
.dev-row {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.dev-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 9px;
|
||||
color: rgba(255,255,255,0.5);
|
||||
margin-bottom: 2px;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.dev-slider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dev-slider {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dev-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dev-slider::-moz-range-thumb {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dev-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 9px;
|
||||
color: rgba(255,255,255,0.6);
|
||||
min-width: 28px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dev-toggle {
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dev-row-actions {
|
||||
margin-top: 8px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
|
||||
.dev-reset-btn {
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
color: var(--text-dim);
|
||||
font-size: 9px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dev-reset-btn:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dev-feature-bars {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ---- RL floating buttons ---- */
|
||||
.rl-buttons {
|
||||
position: fixed;
|
||||
|
|
@ -1356,6 +1610,13 @@ html, body {
|
|||
height: 180px;
|
||||
}
|
||||
|
||||
.hand-pip {
|
||||
width: 200px;
|
||||
height: 150px;
|
||||
bottom: 156px;
|
||||
left: 32px;
|
||||
}
|
||||
|
||||
.rl-buttons {
|
||||
bottom: 92px;
|
||||
gap: 32px;
|
||||
|
|
|
|||
|
|
@ -8,10 +8,14 @@ import { Arpeggiator } from './synth/arpeggiator.js';
|
|||
import { MIDIInput } from './synth/midi-input.js';
|
||||
import { SYNTH_PARAM_MAP, SYNTH_PARAM_NAMES, SYNTH_PARAM_COLORS, applyCurve, applyGroupOverride } from './synth/param-map.js';
|
||||
import { GamepadInput } from './ui/gamepad.js';
|
||||
import { HandTracker } from './ui/hand-tracker.js';
|
||||
import { createDevPanel } from './ui/dev-panel.js';
|
||||
import { SYNTH_PRESETS, PRESET_TIERS } from './synth/presets.js';
|
||||
|
||||
// ---- Constants ----
|
||||
const N_INPUTS = 2;
|
||||
const N_JOY_INPUTS = 2;
|
||||
const N_HAND_INPUTS = 14;
|
||||
const N_INPUTS = N_JOY_INPUTS; // default (joystick)
|
||||
const N_VISUAL_OUTPUTS = 20;
|
||||
const N_SYNTH_OUTPUTS = SYNTH_PARAM_MAP.length; // 126
|
||||
const N_OUTPUTS = N_SYNTH_OUTPUTS; // MLP always produces full output; visual uses first 20
|
||||
|
|
@ -61,7 +65,11 @@ const PRESETS = {
|
|||
};
|
||||
|
||||
// ---- App state ----
|
||||
let iml;
|
||||
let iml; // active IML (points to imlJoy or imlHand)
|
||||
let imlJoy; // IML for joystick mode (2 inputs)
|
||||
let imlHand; // IML for hand tracking mode (14 inputs)
|
||||
let inputMode = 'joystick'; // 'joystick' | 'hands'
|
||||
let handTracker = null;
|
||||
let visualizer;
|
||||
let synthVisualizer;
|
||||
let c15 = null;
|
||||
|
|
@ -673,9 +681,12 @@ async function init() {
|
|||
if (isNaN(spreadLevel)) spreadLevel = 0.6;
|
||||
spreadLevel = Math.max(0, Math.min(1, spreadLevel));
|
||||
|
||||
// IML — WASM-backed, fresh random weights each boot
|
||||
iml = await WasmIML.create(N_INPUTS, N_OUTPUTS, [32, 48, 64], 1000, 1.0, 0.00001);
|
||||
iml.setLogger(msg => console.log('[NISPS]', msg));
|
||||
// Dual IML instances — joystick (2 inputs) and hand tracking (14 inputs)
|
||||
imlJoy = await WasmIML.create(N_JOY_INPUTS, N_OUTPUTS, [32, 48, 64], 1000, 1.0, 0.00001);
|
||||
imlJoy.setLogger(msg => console.log('[NISPS:joy]', msg));
|
||||
imlHand = await WasmIML.create(N_HAND_INPUTS, N_OUTPUTS, [48, 48, 64], 1000, 1.0, 0.00001);
|
||||
imlHand.setLogger(msg => console.log('[NISPS:hand]', msg));
|
||||
iml = imlJoy; // default to joystick
|
||||
|
||||
// Canvas + Visualizer
|
||||
$canvas = document.getElementById('vis-canvas');
|
||||
|
|
@ -736,6 +747,8 @@ async function init() {
|
|||
wireSynthControls();
|
||||
wireGamepad();
|
||||
wireKeyboard();
|
||||
wireInputToggle();
|
||||
createDevPanel(() => handTracker);
|
||||
wireQuickPlayControls();
|
||||
wireGroupDrawer();
|
||||
wireHelp();
|
||||
|
|
@ -1010,6 +1023,7 @@ function updateFollowUI() {
|
|||
}
|
||||
|
||||
function onJoystickMove() {
|
||||
if (inputMode !== 'joystick') return;
|
||||
iml.setInput(0, joyX);
|
||||
iml.setInput(1, joyY);
|
||||
iml.process();
|
||||
|
|
@ -1099,6 +1113,167 @@ function wireControls() {
|
|||
updateNoiseRing();
|
||||
}
|
||||
|
||||
// ---- Input mode (joystick / hands) ----
|
||||
function wireInputToggle() {
|
||||
document.querySelectorAll('#input-toggle .pill-opt').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const mode = btn.dataset.input;
|
||||
if (mode === inputMode) return;
|
||||
setInputMode(mode);
|
||||
syncInputToggle(mode);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function syncInputToggle(mode) {
|
||||
document.querySelectorAll('#input-toggle .pill-opt').forEach(b =>
|
||||
b.classList.toggle('active', b.dataset.input === mode)
|
||||
);
|
||||
}
|
||||
|
||||
let _inputModeSwitching = false;
|
||||
async function setInputMode(mode) {
|
||||
if (_inputModeSwitching) return;
|
||||
_inputModeSwitching = true;
|
||||
try {
|
||||
await _setInputModeInner(mode);
|
||||
} finally {
|
||||
_inputModeSwitching = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function _setInputModeInner(mode) {
|
||||
inputMode = mode;
|
||||
const $pip = document.getElementById('hand-pip');
|
||||
|
||||
if (mode === 'hands') {
|
||||
iml = imlHand;
|
||||
$joystickContainer.style.display = 'none';
|
||||
$pip.classList.remove('hidden');
|
||||
|
||||
if (!handTracker) {
|
||||
const $status = document.getElementById('hand-status');
|
||||
$status.textContent = 'Loading model...';
|
||||
|
||||
handTracker = new HandTracker({
|
||||
videoElement: document.getElementById('hand-video'),
|
||||
overlayCanvas: document.getElementById('hand-overlay'),
|
||||
onTrackingInput: onHandInput,
|
||||
onGesture: onHandGesture,
|
||||
onConnectionChange: (active) => {
|
||||
console.log('[HandTracker] active:', active);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await handTracker.start();
|
||||
$status.textContent = 'Tracking';
|
||||
$status.classList.add('tracking');
|
||||
} catch (e) {
|
||||
$status.textContent = 'Camera error';
|
||||
console.error('[HandTracker]', e);
|
||||
setInputMode('joystick');
|
||||
syncInputToggle('joystick');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await handTracker.start();
|
||||
document.getElementById('hand-status').textContent = 'Tracking';
|
||||
document.getElementById('hand-status').classList.add('tracking');
|
||||
}
|
||||
|
||||
iml.process();
|
||||
routeOutputs(iml.getOutputs());
|
||||
updateHeatmap(iml.getOutputs());
|
||||
} else {
|
||||
iml = imlJoy;
|
||||
$joystickContainer.style.display = '';
|
||||
$pip.classList.add('hidden');
|
||||
|
||||
if (handTracker) {
|
||||
handTracker.stop();
|
||||
document.getElementById('hand-status').classList.remove('tracking');
|
||||
}
|
||||
|
||||
iml.setInput(0, joyX);
|
||||
iml.setInput(1, joyY);
|
||||
iml.process();
|
||||
routeOutputs(iml.getOutputs());
|
||||
updateHeatmap(iml.getOutputs());
|
||||
}
|
||||
|
||||
updateStatus();
|
||||
drawJoyMap();
|
||||
}
|
||||
|
||||
function getCurrentInputs() {
|
||||
if (inputMode === 'hands' && handTracker) {
|
||||
return [...handTracker.features];
|
||||
}
|
||||
return [joyX, joyY];
|
||||
}
|
||||
|
||||
function setCurrentInputs() {
|
||||
if (inputMode === 'hands' && handTracker) {
|
||||
const f = handTracker.features;
|
||||
for (let i = 0; i < f.length; i++) iml.setInput(i, f[i]);
|
||||
} else {
|
||||
iml.setInput(0, joyX);
|
||||
iml.setInput(1, joyY);
|
||||
}
|
||||
}
|
||||
|
||||
function onHandInput(features) {
|
||||
if (inputMode !== 'hands') return;
|
||||
for (let i = 0; i < features.length; i++) {
|
||||
iml.setInput(i, features[i]);
|
||||
}
|
||||
iml.process();
|
||||
|
||||
const outputs = iml.getOutputs();
|
||||
routeOutputs(outputs);
|
||||
updateHeatmap(outputs);
|
||||
syncRawParamsFromOutputs(outputs);
|
||||
updateGestureIndicator();
|
||||
}
|
||||
|
||||
function onHandGesture(gesture) {
|
||||
if (gesture === 'thumbsup') {
|
||||
onThumbsUp();
|
||||
} else if (gesture === 'thumbsdown') {
|
||||
onThumbsDown();
|
||||
}
|
||||
}
|
||||
|
||||
function updateGestureIndicator() {
|
||||
if (!handTracker) return;
|
||||
const $indicator = document.getElementById('gesture-indicator');
|
||||
const $label = document.getElementById('gesture-label');
|
||||
const $progress = $indicator.querySelector('.gesture-ring-progress');
|
||||
|
||||
if (handTracker.gestureCandidate && handTracker.gestureProgress > 0) {
|
||||
$indicator.classList.add('active');
|
||||
const circumference = 2 * Math.PI * 16;
|
||||
const offset = circumference * (1 - handTracker.gestureProgress);
|
||||
$progress.style.strokeDashoffset = offset;
|
||||
$label.textContent = handTracker.gestureCandidate === 'thumbsup' ? '+' : '\u2212';
|
||||
} else {
|
||||
$indicator.classList.remove('active');
|
||||
}
|
||||
|
||||
const $status = document.getElementById('hand-status');
|
||||
if (handTracker.active) {
|
||||
if (handTracker.trackingRight && handTracker.trackingLeft) {
|
||||
$status.textContent = 'Both hands';
|
||||
} else if (handTracker.trackingRight) {
|
||||
$status.textContent = 'Tracking';
|
||||
} else {
|
||||
$status.textContent = 'No hand';
|
||||
}
|
||||
$status.classList.toggle('tracking', handTracker.trackingRight);
|
||||
}
|
||||
}
|
||||
|
||||
function syncOutputToggles(mode) {
|
||||
document.querySelectorAll('#output-toggle-float .pill-opt').forEach(b => b.classList.toggle('active', b.dataset.mode === mode));
|
||||
}
|
||||
|
|
@ -1148,7 +1323,7 @@ function updateNoiseRing() {
|
|||
|
||||
// ---- Examples mode ----
|
||||
function onAddExample() {
|
||||
const inputs = [joyX, joyY];
|
||||
const inputs = getCurrentInputs();
|
||||
const outputs = [...rawParamValues];
|
||||
iml.addExample(inputs, outputs);
|
||||
updateStatus();
|
||||
|
|
@ -1164,8 +1339,7 @@ function onTrain() {
|
|||
|
||||
function onRandomize() {
|
||||
iml.randomiseWeights(spreadLevel);
|
||||
iml.setInput(0, joyX);
|
||||
iml.setInput(1, joyY);
|
||||
setCurrentInputs();
|
||||
iml.process();
|
||||
const outputs = iml.getOutputs();
|
||||
routeOutputs(outputs);
|
||||
|
|
@ -1197,7 +1371,7 @@ function onClear() {
|
|||
function onThumbsUp() {
|
||||
if (iml.isTraining) return;
|
||||
|
||||
const inputs = [joyX, joyY];
|
||||
const inputs = getCurrentInputs();
|
||||
const outputs = [...iml.getOutputs()];
|
||||
iml.addExample(inputs, outputs);
|
||||
|
||||
|
|
@ -1248,13 +1422,23 @@ function loadPreset(name) {
|
|||
const preset = PRESETS[name];
|
||||
if (!preset) return;
|
||||
|
||||
iml.clearDataset();
|
||||
// Visual presets have 2-element inputs — always apply to joystick IML
|
||||
imlJoy.clearDataset();
|
||||
for (const ex of preset) {
|
||||
iml.addExample(ex.input, padPresetOutputs(ex.output));
|
||||
imlJoy.addExample(ex.input, padPresetOutputs(ex.output));
|
||||
}
|
||||
|
||||
// Temporarily point iml to imlJoy for training, then restore
|
||||
const prevIml = iml;
|
||||
iml = imlJoy;
|
||||
const loss = trainModel();
|
||||
const outputs = iml.getOutputs();
|
||||
iml = prevIml;
|
||||
|
||||
// Show results from joystick IML
|
||||
imlJoy.setInput(0, joyX);
|
||||
imlJoy.setInput(1, joyY);
|
||||
imlJoy.process();
|
||||
const outputs = imlJoy.getOutputs();
|
||||
routeOutputs(outputs);
|
||||
updateHeatmap(outputs);
|
||||
syncRawParamsFromOutputs(outputs);
|
||||
|
|
@ -1994,10 +2178,13 @@ function flash(id) {
|
|||
function saveState() {
|
||||
try {
|
||||
const state = {
|
||||
features: iml.dataset.features,
|
||||
labels: iml.dataset.labels,
|
||||
features: imlJoy.dataset.features,
|
||||
labels: imlJoy.dataset.labels,
|
||||
handFeatures: imlHand.dataset.features,
|
||||
handLabels: imlHand.dataset.labels,
|
||||
noiseLevel,
|
||||
outputMode,
|
||||
inputMode,
|
||||
joyX,
|
||||
joyY,
|
||||
groupOverrides,
|
||||
|
|
@ -2015,16 +2202,30 @@ function loadState() {
|
|||
if (!raw) return;
|
||||
const state = JSON.parse(raw);
|
||||
|
||||
// Restore training data (pad old 20-element labels to N_OUTPUTS)
|
||||
// Restore joystick IML training data (pad old 20-element labels to N_OUTPUTS)
|
||||
if (state.features && state.labels && state.features.length > 0) {
|
||||
for (let i = 0; i < state.features.length; i++) {
|
||||
iml.addExample(state.features[i], padPresetOutputs(state.labels[i]));
|
||||
imlJoy.addExample(state.features[i], padPresetOutputs(state.labels[i]));
|
||||
}
|
||||
// Retrain with restored data
|
||||
const prevIml = iml;
|
||||
iml = imlJoy;
|
||||
trainModel();
|
||||
routeOutputs(iml.getOutputs());
|
||||
iml = prevIml;
|
||||
}
|
||||
|
||||
// Restore hand IML training data
|
||||
if (state.handFeatures && state.handLabels && state.handFeatures.length > 0) {
|
||||
for (let i = 0; i < state.handFeatures.length; i++) {
|
||||
imlHand.addExample(state.handFeatures[i], padPresetOutputs(state.handLabels[i]));
|
||||
}
|
||||
const prevIml = iml;
|
||||
iml = imlHand;
|
||||
trainModel();
|
||||
iml = prevIml;
|
||||
}
|
||||
|
||||
routeOutputs(iml.getOutputs());
|
||||
|
||||
if (typeof state.noiseLevel === 'number') noiseLevel = state.noiseLevel;
|
||||
if (typeof state.joyX === 'number') joyX = state.joyX;
|
||||
if (typeof state.joyY === 'number') joyY = state.joyY;
|
||||
|
|
@ -2058,7 +2259,8 @@ function loadState() {
|
|||
syncOutputToggles(outputMode);
|
||||
}
|
||||
|
||||
console.log(`[NISPS] Restored ${state.features?.length || 0} examples from storage`);
|
||||
// Note: don't auto-restore inputMode='hands' — requires camera permission
|
||||
console.log(`[NISPS] Restored ${state.features?.length || 0} joy examples, ${state.handFeatures?.length || 0} hand examples from storage`);
|
||||
} catch (e) {
|
||||
console.warn('[NISPS] Failed to load state:', e);
|
||||
}
|
||||
|
|
|
|||
222
playground/js/ui/dev-panel.js
Normal file
222
playground/js/ui/dev-panel.js
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
// Dev panel — draggable, collapsible floating panel for tuning hand tracking parameters
|
||||
// Gated on ?devmode=true URL parameter
|
||||
|
||||
import { HAND_TRACKER_DEFAULTS } from './hand-tracker.js';
|
||||
|
||||
const PARAMS = [
|
||||
{
|
||||
key: 'minHandDetectionConfidence',
|
||||
label: 'Detection confidence',
|
||||
min: 0.1, max: 1.0, step: 0.05,
|
||||
help: 'Palm detection threshold. Lower = more aggressive detection, more false positives.',
|
||||
},
|
||||
{
|
||||
key: 'minHandPresenceConfidence',
|
||||
label: 'Presence confidence',
|
||||
min: 0.1, max: 1.0, step: 0.05,
|
||||
help: 'Hand presence threshold. Lower = holds onto tracked hands longer.',
|
||||
},
|
||||
{
|
||||
key: 'minTrackingConfidence',
|
||||
label: 'Tracking confidence',
|
||||
min: 0.1, max: 1.0, step: 0.05,
|
||||
help: 'Frame-to-frame tracking IoU. Lower = tracks through fast movement.',
|
||||
},
|
||||
{
|
||||
key: 'smoothingFactor',
|
||||
label: 'Smoothing',
|
||||
min: 0.05, max: 1.0, step: 0.05,
|
||||
help: 'Feature smoothing (EMA factor). Lower = smoother/laggier, higher = more responsive/jittery.',
|
||||
},
|
||||
{
|
||||
key: 'gestureHoldMs',
|
||||
label: 'Gesture hold (ms)',
|
||||
min: 100, max: 1500, step: 50,
|
||||
help: 'How long a gesture must be held before firing.',
|
||||
},
|
||||
{
|
||||
key: 'useWorldLandmarks',
|
||||
label: 'World landmarks',
|
||||
type: 'toggle',
|
||||
help: 'Use real-world meter coords (distance-independent) vs image-normalized coords.',
|
||||
},
|
||||
];
|
||||
|
||||
export function createDevPanel(handTrackerGetter) {
|
||||
if (new URLSearchParams(window.location.search).get('devmode') !== 'true') return null;
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'dev-panel';
|
||||
panel.innerHTML = `
|
||||
<div class="dev-panel-header" id="dev-panel-header">
|
||||
<span class="dev-panel-title">Hand Tracking Dev</span>
|
||||
<button class="dev-panel-collapse" id="dev-panel-collapse">_</button>
|
||||
</div>
|
||||
<div class="dev-panel-body" id="dev-panel-body"></div>
|
||||
`;
|
||||
document.body.appendChild(panel);
|
||||
|
||||
const $body = panel.querySelector('#dev-panel-body');
|
||||
const $header = panel.querySelector('#dev-panel-header');
|
||||
const $collapse = panel.querySelector('#dev-panel-collapse');
|
||||
const sliders = {};
|
||||
|
||||
// Build controls
|
||||
for (const p of PARAMS) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'dev-row';
|
||||
|
||||
if (p.type === 'toggle') {
|
||||
row.innerHTML = `
|
||||
<label class="dev-label" title="${p.help}">
|
||||
${p.label}
|
||||
<input type="checkbox" class="dev-toggle" data-key="${p.key}"
|
||||
${HAND_TRACKER_DEFAULTS[p.key] ? 'checked' : ''}>
|
||||
</label>
|
||||
`;
|
||||
const input = row.querySelector('input');
|
||||
input.addEventListener('change', () => {
|
||||
applyOption(p.key, input.checked);
|
||||
});
|
||||
sliders[p.key] = input;
|
||||
} else {
|
||||
const defaultVal = HAND_TRACKER_DEFAULTS[p.key];
|
||||
row.innerHTML = `
|
||||
<label class="dev-label" title="${p.help}">${p.label}</label>
|
||||
<div class="dev-slider-row">
|
||||
<input type="range" class="dev-slider" data-key="${p.key}"
|
||||
min="${p.min}" max="${p.max}" step="${p.step}" value="${defaultVal}">
|
||||
<span class="dev-value" data-key="${p.key}">${formatVal(p.key, defaultVal)}</span>
|
||||
</div>
|
||||
`;
|
||||
const input = row.querySelector('input[type=range]');
|
||||
const display = row.querySelector('.dev-value');
|
||||
input.addEventListener('input', () => {
|
||||
const val = parseFloat(input.value);
|
||||
display.textContent = formatVal(p.key, val);
|
||||
applyOption(p.key, val);
|
||||
});
|
||||
sliders[p.key] = input;
|
||||
}
|
||||
|
||||
$body.appendChild(row);
|
||||
}
|
||||
|
||||
// Reset button
|
||||
const resetRow = document.createElement('div');
|
||||
resetRow.className = 'dev-row dev-row-actions';
|
||||
resetRow.innerHTML = `<button class="dev-reset-btn">Reset defaults</button>`;
|
||||
resetRow.querySelector('button').addEventListener('click', () => {
|
||||
for (const p of PARAMS) {
|
||||
const def = HAND_TRACKER_DEFAULTS[p.key];
|
||||
if (p.type === 'toggle') {
|
||||
sliders[p.key].checked = def;
|
||||
} else {
|
||||
sliders[p.key].value = def;
|
||||
sliders[p.key].parentElement.querySelector('.dev-value').textContent = formatVal(p.key, def);
|
||||
}
|
||||
applyOption(p.key, def);
|
||||
}
|
||||
});
|
||||
$body.appendChild(resetRow);
|
||||
|
||||
// Feature monitor (live feature values)
|
||||
const monitorRow = document.createElement('div');
|
||||
monitorRow.className = 'dev-row';
|
||||
monitorRow.innerHTML = `
|
||||
<label class="dev-label">Features</label>
|
||||
<canvas id="dev-feature-bars" class="dev-feature-bars" width="200" height="56"></canvas>
|
||||
`;
|
||||
$body.appendChild(monitorRow);
|
||||
const $featureBars = monitorRow.querySelector('canvas');
|
||||
const featureCtx = $featureBars.getContext('2d');
|
||||
|
||||
// --- Collapse ---
|
||||
let collapsed = false;
|
||||
$collapse.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
collapsed = !collapsed;
|
||||
$body.style.display = collapsed ? 'none' : '';
|
||||
$collapse.textContent = collapsed ? '+' : '_';
|
||||
panel.classList.toggle('collapsed', collapsed);
|
||||
});
|
||||
|
||||
// --- Drag ---
|
||||
let dragging = false, dragOffX = 0, dragOffY = 0;
|
||||
$header.addEventListener('pointerdown', (e) => {
|
||||
if (e.target === $collapse) return;
|
||||
dragging = true;
|
||||
dragOffX = e.clientX - panel.offsetLeft;
|
||||
dragOffY = e.clientY - panel.offsetTop;
|
||||
$header.setPointerCapture(e.pointerId);
|
||||
e.preventDefault();
|
||||
});
|
||||
$header.addEventListener('pointermove', (e) => {
|
||||
if (!dragging) return;
|
||||
panel.style.left = (e.clientX - dragOffX) + 'px';
|
||||
panel.style.top = (e.clientY - dragOffY) + 'px';
|
||||
panel.style.right = 'auto';
|
||||
panel.style.bottom = 'auto';
|
||||
});
|
||||
$header.addEventListener('pointerup', () => { dragging = false; });
|
||||
|
||||
// --- Apply option to hand tracker ---
|
||||
function applyOption(key, value) {
|
||||
const ht = handTrackerGetter();
|
||||
if (ht) {
|
||||
ht.setOptions({ [key]: value });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Feature bar animation ---
|
||||
const FEATURE_LABELS = [
|
||||
'pX','pY','tC','iC','mC','rC','pnC','s01','s12','s23','s34','rol','pit','pnc'
|
||||
];
|
||||
|
||||
function drawFeatureBars() {
|
||||
const ht = handTrackerGetter();
|
||||
const w = $featureBars.width;
|
||||
const h = $featureBars.height;
|
||||
featureCtx.clearRect(0, 0, w, h);
|
||||
|
||||
if (!ht || !ht.active) {
|
||||
featureCtx.fillStyle = 'rgba(255,255,255,0.1)';
|
||||
featureCtx.font = '9px monospace';
|
||||
featureCtx.fillText('No tracking', 60, h / 2 + 3);
|
||||
requestAnimationFrame(drawFeatureBars);
|
||||
return;
|
||||
}
|
||||
|
||||
const features = ht.features;
|
||||
const barW = (w - 2) / 14;
|
||||
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const v = features[i];
|
||||
const x = i * barW + 1;
|
||||
const barH = v * (h - 12);
|
||||
|
||||
// Bar background
|
||||
featureCtx.fillStyle = 'rgba(255,255,255,0.05)';
|
||||
featureCtx.fillRect(x, 10, barW - 1, h - 12);
|
||||
|
||||
// Bar fill
|
||||
featureCtx.fillStyle = `hsl(${(i / 14) * 360}, 70%, 55%)`;
|
||||
featureCtx.fillRect(x, h - barH, barW - 1, barH);
|
||||
|
||||
// Label
|
||||
featureCtx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||
featureCtx.font = '6px monospace';
|
||||
featureCtx.fillText(FEATURE_LABELS[i], x, 8);
|
||||
}
|
||||
|
||||
requestAnimationFrame(drawFeatureBars);
|
||||
}
|
||||
drawFeatureBars();
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
function formatVal(key, val) {
|
||||
if (key === 'gestureHoldMs') return `${val}`;
|
||||
return val.toFixed(2);
|
||||
}
|
||||
530
playground/js/ui/hand-tracker.js
Normal file
530
playground/js/ui/hand-tracker.js
Normal file
|
|
@ -0,0 +1,530 @@
|
|||
// HandTracker — MediaPipe hand tracking input for NISPS playground
|
||||
// Extracts 14 derived features from right hand, gesture recognition from left hand
|
||||
|
||||
// MediaPipe landmark indices
|
||||
const WRIST = 0;
|
||||
const THUMB_CMC = 1, THUMB_MCP = 2, THUMB_IP = 3, THUMB_TIP = 4;
|
||||
const INDEX_MCP = 5, INDEX_PIP = 6, INDEX_DIP = 7, INDEX_TIP = 8;
|
||||
const MIDDLE_MCP = 9, MIDDLE_PIP = 10, MIDDLE_DIP = 11, MIDDLE_TIP = 12;
|
||||
const RING_MCP = 13, RING_PIP = 14, RING_DIP = 15, RING_TIP = 16;
|
||||
const PINKY_MCP = 17, PINKY_PIP = 18, PINKY_DIP = 19, PINKY_TIP = 20;
|
||||
|
||||
const FINGER_LANDMARKS = [
|
||||
[THUMB_CMC, THUMB_MCP, THUMB_IP, THUMB_TIP],
|
||||
[INDEX_MCP, INDEX_PIP, INDEX_DIP, INDEX_TIP],
|
||||
[MIDDLE_MCP, MIDDLE_PIP, MIDDLE_DIP, MIDDLE_TIP],
|
||||
[RING_MCP, RING_PIP, RING_DIP, RING_TIP],
|
||||
[PINKY_MCP, PINKY_PIP, PINKY_DIP, PINKY_TIP],
|
||||
];
|
||||
|
||||
const FINGER_TIPS = [THUMB_TIP, INDEX_TIP, MIDDLE_TIP, RING_TIP, PINKY_TIP];
|
||||
const FINGER_MCPS = [THUMB_MCP, INDEX_MCP, MIDDLE_MCP, RING_MCP, PINKY_MCP];
|
||||
|
||||
// Hand connections for skeleton drawing
|
||||
const HAND_CONNECTIONS = [
|
||||
[0,1],[1,2],[2,3],[3,4],
|
||||
[0,5],[5,6],[6,7],[7,8],
|
||||
[0,9],[9,10],[10,11],[11,12],
|
||||
[0,13],[13,14],[14,15],[15,16],
|
||||
[0,17],[17,18],[18,19],[19,20],
|
||||
[5,9],[9,13],[13,17],
|
||||
];
|
||||
|
||||
// Default tuning parameters (exported for dev panel)
|
||||
export const HAND_TRACKER_DEFAULTS = {
|
||||
minHandDetectionConfidence: 0.5,
|
||||
minHandPresenceConfidence: 0.5,
|
||||
minTrackingConfidence: 0.5,
|
||||
smoothingFactor: 0.4,
|
||||
gestureHoldMs: 400,
|
||||
useWorldLandmarks: false,
|
||||
};
|
||||
|
||||
export class HandTracker {
|
||||
/**
|
||||
* @param {Object} options
|
||||
* @param {function(number[])} options.onTrackingInput - called with 14 derived features [0,1]
|
||||
* @param {function('thumbsup'|'thumbsdown')} options.onGesture - called when gesture confirmed
|
||||
* @param {function(boolean)} [options.onConnectionChange] - called when tracking starts/stops
|
||||
* @param {HTMLVideoElement} options.videoElement - video element for camera feed
|
||||
* @param {HTMLCanvasElement} options.overlayCanvas - canvas for skeleton drawing
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this.onTrackingInput = options.onTrackingInput || (() => {});
|
||||
this.onGesture = options.onGesture || (() => {});
|
||||
this.onConnectionChange = options.onConnectionChange || null;
|
||||
this.videoElement = options.videoElement;
|
||||
this.overlayCanvas = options.overlayCanvas;
|
||||
this.overlayCtx = this.overlayCanvas?.getContext('2d');
|
||||
|
||||
this.active = false;
|
||||
this.features = new Array(14).fill(0.5);
|
||||
this._handLandmarker = null;
|
||||
this._stream = null;
|
||||
this._rafId = null;
|
||||
this._lastDetectTime = 0;
|
||||
this._minDetectInterval = 33; // ~30fps, will increase if slow
|
||||
|
||||
// Tuning parameters (runtime-adjustable via setOptions)
|
||||
this.opts = { ...HAND_TRACKER_DEFAULTS };
|
||||
|
||||
// Gesture state
|
||||
this._gestureCandidate = null; // 'thumbsup' | 'thumbsdown' | null
|
||||
this._gestureStartTime = 0;
|
||||
this._gestureProgress = 0; // 0-1 for UI
|
||||
this._lastGestureFired = 0;
|
||||
|
||||
// Smoothing
|
||||
this._smoothedFeatures = new Array(14).fill(0.5);
|
||||
|
||||
// Status
|
||||
this._trackingRight = false;
|
||||
this._trackingLeft = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tuning parameters at runtime.
|
||||
* Confidence changes require re-creating the HandLandmarker (async).
|
||||
*/
|
||||
async setOptions(patch) {
|
||||
const prev = { ...this.opts };
|
||||
Object.assign(this.opts, patch);
|
||||
|
||||
// Check if MediaPipe confidence thresholds changed — requires re-init
|
||||
const confidenceChanged =
|
||||
prev.minHandDetectionConfidence !== this.opts.minHandDetectionConfidence ||
|
||||
prev.minHandPresenceConfidence !== this.opts.minHandPresenceConfidence ||
|
||||
prev.minTrackingConfidence !== this.opts.minTrackingConfidence;
|
||||
|
||||
if (confidenceChanged && this._handLandmarker) {
|
||||
await this._handLandmarker.setOptions({
|
||||
minHandDetectionConfidence: this.opts.minHandDetectionConfidence,
|
||||
minHandPresenceConfidence: this.opts.minHandPresenceConfidence,
|
||||
minTrackingConfidence: this.opts.minTrackingConfidence,
|
||||
});
|
||||
console.log('[HandTracker] Updated confidence thresholds:', this.opts);
|
||||
}
|
||||
}
|
||||
|
||||
get gestureProgress() { return this._gestureProgress; }
|
||||
get gestureCandidate() { return this._gestureCandidate; }
|
||||
get trackingRight() { return this._trackingRight; }
|
||||
get trackingLeft() { return this._trackingLeft; }
|
||||
|
||||
async start() {
|
||||
if (this.active) return;
|
||||
|
||||
try {
|
||||
// Request camera
|
||||
this._stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } }
|
||||
});
|
||||
this.videoElement.srcObject = this._stream;
|
||||
await this.videoElement.play();
|
||||
|
||||
// Load MediaPipe (only once)
|
||||
if (!this._handLandmarker) {
|
||||
await this._initHandLandmarker();
|
||||
}
|
||||
|
||||
this.active = true;
|
||||
if (this.onConnectionChange) this.onConnectionChange(true);
|
||||
this._detectLoop();
|
||||
} catch (e) {
|
||||
console.error('[HandTracker] Failed to start:', e);
|
||||
this.stop();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.active = false;
|
||||
if (this._rafId) {
|
||||
cancelAnimationFrame(this._rafId);
|
||||
this._rafId = null;
|
||||
}
|
||||
if (this._stream) {
|
||||
for (const track of this._stream.getTracks()) track.stop();
|
||||
this._stream = null;
|
||||
}
|
||||
this.videoElement.srcObject = null;
|
||||
this._trackingRight = false;
|
||||
this._trackingLeft = false;
|
||||
if (this.onConnectionChange) this.onConnectionChange(false);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.stop();
|
||||
if (this._handLandmarker) {
|
||||
this._handLandmarker.close();
|
||||
this._handLandmarker = null;
|
||||
}
|
||||
}
|
||||
|
||||
async _initHandLandmarker() {
|
||||
// Dynamic import of MediaPipe vision tasks
|
||||
const vision = await import('https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.18/vision_bundle.mjs');
|
||||
const { HandLandmarker, FilesetResolver } = vision;
|
||||
|
||||
const wasmFileset = await FilesetResolver.forVisionTasks(
|
||||
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.18/wasm'
|
||||
);
|
||||
|
||||
this._handLandmarker = await HandLandmarker.createFromOptions(wasmFileset, {
|
||||
baseOptions: {
|
||||
modelAssetPath: 'https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task',
|
||||
delegate: 'GPU',
|
||||
},
|
||||
runningMode: 'VIDEO',
|
||||
numHands: 2,
|
||||
minHandDetectionConfidence: this.opts.minHandDetectionConfidence,
|
||||
minHandPresenceConfidence: this.opts.minHandPresenceConfidence,
|
||||
minTrackingConfidence: this.opts.minTrackingConfidence,
|
||||
});
|
||||
}
|
||||
|
||||
_detectLoop() {
|
||||
if (!this.active) return;
|
||||
|
||||
const now = performance.now();
|
||||
if (now - this._lastDetectTime >= this._minDetectInterval) {
|
||||
const frameStart = now;
|
||||
|
||||
if (this.videoElement.readyState >= 2 && this._handLandmarker) {
|
||||
const results = this._handLandmarker.detectForVideo(this.videoElement, now);
|
||||
this._processResults(results, now);
|
||||
}
|
||||
|
||||
// Adaptive frame rate: slow down if detection is heavy, recover gradually
|
||||
const elapsed = performance.now() - frameStart;
|
||||
if (elapsed > 25) {
|
||||
this._minDetectInterval = 66; // drop to 15fps
|
||||
} else if (this._minDetectInterval > 33) {
|
||||
this._minDetectInterval = 33; // recover to 30fps
|
||||
}
|
||||
}
|
||||
|
||||
this._rafId = requestAnimationFrame(() => this._detectLoop());
|
||||
}
|
||||
|
||||
_processResults(results, now) {
|
||||
// Set canvas to a fixed size matching the PIP aspect ratio
|
||||
if (this.overlayCtx) {
|
||||
this.overlayCanvas.width = 360;
|
||||
this.overlayCanvas.height = 270;
|
||||
this._drawBackground();
|
||||
}
|
||||
|
||||
let rightHand = null;
|
||||
let leftHand = null;
|
||||
let rightHandNorm = null; // always normalized (for drawing)
|
||||
let leftHandNorm = null;
|
||||
|
||||
// Classify hands
|
||||
if (results.handednesses && results.landmarks) {
|
||||
// Choose landmark source: world (meters, hand-centric) or normalized (image-relative)
|
||||
const useWorld = this.opts.useWorldLandmarks && results.worldLandmarks;
|
||||
const lmSource = useWorld ? results.worldLandmarks : results.landmarks;
|
||||
|
||||
for (let i = 0; i < results.handednesses.length; i++) {
|
||||
const handedness = results.handednesses[i][0];
|
||||
const landmarks = lmSource[i];
|
||||
const normLandmarks = results.landmarks[i]; // always keep normalized for drawing
|
||||
if (handedness.categoryName === 'Right') {
|
||||
rightHand = landmarks;
|
||||
rightHandNorm = normLandmarks;
|
||||
} else {
|
||||
leftHand = landmarks;
|
||||
leftHandNorm = normLandmarks;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If only one hand detected, use it as tracking hand
|
||||
if (!rightHand && leftHand) {
|
||||
rightHand = leftHand;
|
||||
rightHandNorm = leftHandNorm;
|
||||
leftHand = null;
|
||||
leftHandNorm = null;
|
||||
}
|
||||
|
||||
this._trackingRight = !!rightHand;
|
||||
this._trackingLeft = !!leftHand;
|
||||
|
||||
// Extract features from tracking hand (right)
|
||||
if (rightHand) {
|
||||
const raw = this._extractFeatures(rightHand);
|
||||
// Smooth features
|
||||
const sf = this.opts.smoothingFactor;
|
||||
for (let i = 0; i < 14; i++) {
|
||||
this._smoothedFeatures[i] += (raw[i] - this._smoothedFeatures[i]) * sf;
|
||||
this.features[i] = this._smoothedFeatures[i];
|
||||
}
|
||||
this.onTrackingInput(this.features);
|
||||
}
|
||||
|
||||
// Gesture recognition from left hand (uses normalized landmarks for finger counting)
|
||||
if (leftHandNorm) {
|
||||
this._processGesture(leftHandNorm, now);
|
||||
} else {
|
||||
this._gestureCandidate = null;
|
||||
this._gestureProgress = 0;
|
||||
}
|
||||
|
||||
// Draw skeletons with zone awareness (always use normalized landmarks for drawing)
|
||||
// In canvas (pre-CSS-mirror) coords: right 1/3 = gesture zone, left 2/3 = tracking zone
|
||||
// After CSS scaleX(-1): left 1/3 = gesture, right 2/3 = tracking
|
||||
if (this.overlayCtx) {
|
||||
const w = this.overlayCanvas.width;
|
||||
const dividerX = w * (2 / 3);
|
||||
|
||||
if (rightHandNorm) {
|
||||
const avgX = rightHandNorm[WRIST].x * w;
|
||||
const crossingZone = avgX > dividerX;
|
||||
this._drawSkeleton(rightHandNorm, '#ff6a00', crossingZone ? 0.25 : 0.9);
|
||||
}
|
||||
if (leftHandNorm) {
|
||||
const avgX = leftHandNorm[WRIST].x * w;
|
||||
const crossingZone = avgX < dividerX;
|
||||
this._drawSkeleton(leftHandNorm, '#00ccff', crossingZone ? 0.25 : 0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_extractFeatures(landmarks) {
|
||||
const f = new Array(14);
|
||||
const isWorld = this.opts.useWorldLandmarks;
|
||||
|
||||
// 0-1: Palm position X, Y
|
||||
// For world landmarks, x/y are in meters centered on hand — normalize differently
|
||||
if (isWorld) {
|
||||
// World coords: origin at hand center, range roughly ±0.1m
|
||||
f[0] = clamp01((landmarks[WRIST].x + 0.1) / 0.2);
|
||||
f[1] = clamp01((landmarks[WRIST].y + 0.1) / 0.2);
|
||||
} else {
|
||||
f[0] = 1.0 - landmarks[WRIST].x; // mirror X
|
||||
f[1] = landmarks[WRIST].y;
|
||||
}
|
||||
|
||||
// 2-6: Finger curl (thumb through pinky)
|
||||
for (let fi = 0; fi < 5; fi++) {
|
||||
f[2 + fi] = this._fingerCurl(landmarks, fi);
|
||||
}
|
||||
|
||||
// 7-10: Finger spread (4 adjacent pairs)
|
||||
for (let fi = 0; fi < 4; fi++) {
|
||||
f[7 + fi] = this._fingerSpread(landmarks, fi);
|
||||
}
|
||||
|
||||
// 11: Hand roll (rotation around forward axis)
|
||||
const wrist = landmarks[WRIST];
|
||||
const middleMcp = landmarks[MIDDLE_MCP];
|
||||
const dx = middleMcp.x - wrist.x;
|
||||
const dy = middleMcp.y - wrist.y;
|
||||
const roll = (Math.atan2(dx, -dy) / Math.PI + 1) * 0.5;
|
||||
f[11] = clamp01(roll);
|
||||
|
||||
// 12: Hand pitch (tilt forward/back from z-depth difference)
|
||||
const avgTipZ = (landmarks[INDEX_TIP].z + landmarks[MIDDLE_TIP].z + landmarks[RING_TIP].z) / 3;
|
||||
if (isWorld) {
|
||||
// World z is in meters — typical pitch range ~±0.05m
|
||||
f[12] = clamp01((wrist.z - avgTipZ + 0.05) / 0.1);
|
||||
} else {
|
||||
f[12] = clamp01((wrist.z - avgTipZ + 0.15) / 0.3);
|
||||
}
|
||||
|
||||
// 13: Pinch distance (thumb tip to index tip)
|
||||
const pinch = dist3d(landmarks[THUMB_TIP], landmarks[INDEX_TIP]);
|
||||
if (isWorld) {
|
||||
// World pinch: range 0–0.15m typically
|
||||
f[13] = clamp01(1.0 - pinch / 0.15);
|
||||
} else {
|
||||
f[13] = clamp01(1.0 - pinch / 0.3);
|
||||
}
|
||||
|
||||
return f;
|
||||
}
|
||||
|
||||
_fingerCurl(landmarks, fingerIndex) {
|
||||
const joints = FINGER_LANDMARKS[fingerIndex];
|
||||
// Angle at PIP joint (middle joint)
|
||||
const a = landmarks[joints[0]]; // MCP/CMC
|
||||
const b = landmarks[joints[1]]; // MCP/PIP
|
||||
const c = landmarks[joints[2]]; // PIP/DIP
|
||||
const d = landmarks[joints[3]]; // DIP/TIP
|
||||
|
||||
// Use angle between base→mid and mid→tip vectors
|
||||
const v1x = b.x - a.x, v1y = b.y - a.y, v1z = b.z - a.z;
|
||||
const v2x = d.x - b.x, v2y = d.y - b.y, v2z = d.z - b.z;
|
||||
|
||||
const dot = v1x * v2x + v1y * v2y + v1z * v2z;
|
||||
const mag1 = Math.sqrt(v1x * v1x + v1y * v1y + v1z * v1z) || 0.001;
|
||||
const mag2 = Math.sqrt(v2x * v2x + v2y * v2y + v2z * v2z) || 0.001;
|
||||
|
||||
const cosAngle = clamp(dot / (mag1 * mag2), -1, 1);
|
||||
const angle = Math.acos(cosAngle); // 0 = straight, PI = fully bent
|
||||
|
||||
// Also consider distance from tip to MCP (more robust)
|
||||
const tipDist = dist3d(landmarks[joints[0]], landmarks[joints[3]]);
|
||||
const baseDist = dist3d(landmarks[joints[0]], landmarks[joints[2]]);
|
||||
const ratio = baseDist > 0.001 ? tipDist / (baseDist * 1.8) : 1;
|
||||
|
||||
// Blend angle-based and distance-based curl
|
||||
const angleCurl = clamp01(1.0 - angle / Math.PI);
|
||||
const distCurl = clamp01(1.0 - ratio);
|
||||
|
||||
return clamp01(angleCurl * 0.4 + distCurl * 0.6);
|
||||
}
|
||||
|
||||
_fingerSpread(landmarks, pairIndex) {
|
||||
// Spread between adjacent finger tips
|
||||
const tip1 = landmarks[FINGER_TIPS[pairIndex]];
|
||||
const tip2 = landmarks[FINGER_TIPS[pairIndex + 1]];
|
||||
const mcp1 = landmarks[FINGER_MCPS[pairIndex]];
|
||||
const mcp2 = landmarks[FINGER_MCPS[pairIndex + 1]];
|
||||
|
||||
// Direction vectors from MCP to tip
|
||||
const v1x = tip1.x - mcp1.x, v1y = tip1.y - mcp1.y;
|
||||
const v2x = tip2.x - mcp2.x, v2y = tip2.y - mcp2.y;
|
||||
|
||||
const dot = v1x * v2x + v1y * v2y;
|
||||
const mag1 = Math.sqrt(v1x * v1x + v1y * v1y) || 0.001;
|
||||
const mag2 = Math.sqrt(v2x * v2x + v2y * v2y) || 0.001;
|
||||
|
||||
const cosAngle = clamp(dot / (mag1 * mag2), -1, 1);
|
||||
const angle = Math.acos(cosAngle); // 0 = parallel, larger = more spread
|
||||
|
||||
// Normalize: typical spread is 0-0.5 radians
|
||||
return clamp01(angle / 0.6);
|
||||
}
|
||||
|
||||
_processGesture(landmarks, now) {
|
||||
const extended = this._countExtendedFingers(landmarks);
|
||||
|
||||
let candidate = null;
|
||||
if (extended === 1) candidate = 'thumbsup';
|
||||
else if (extended === 2) candidate = 'thumbsdown';
|
||||
|
||||
if (candidate !== this._gestureCandidate) {
|
||||
// New gesture or cleared
|
||||
this._gestureCandidate = candidate;
|
||||
this._gestureStartTime = now;
|
||||
this._gestureProgress = 0;
|
||||
} else if (candidate) {
|
||||
// Same gesture continuing
|
||||
const elapsed = now - this._gestureStartTime;
|
||||
this._gestureProgress = Math.min(elapsed / this.opts.gestureHoldMs, 1);
|
||||
|
||||
if (this._gestureProgress >= 1 && now - this._lastGestureFired > 800) {
|
||||
// Fire gesture
|
||||
this.onGesture(candidate);
|
||||
this._lastGestureFired = now;
|
||||
this._gestureCandidate = null;
|
||||
this._gestureProgress = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_countExtendedFingers(landmarks) {
|
||||
let count = 0;
|
||||
|
||||
// Thumb: check if tip is far from palm center (different axis)
|
||||
const thumbExtended = dist3d(landmarks[THUMB_TIP], landmarks[THUMB_MCP]) >
|
||||
dist3d(landmarks[THUMB_IP], landmarks[THUMB_MCP]) * 1.2;
|
||||
|
||||
// Other fingers: tip should be farther from wrist than PIP
|
||||
for (let fi = 1; fi < 5; fi++) {
|
||||
const joints = FINGER_LANDMARKS[fi];
|
||||
const tipToWrist = dist3d(landmarks[joints[3]], landmarks[WRIST]);
|
||||
const pipToWrist = dist3d(landmarks[joints[1]], landmarks[WRIST]);
|
||||
if (tipToWrist > pipToWrist * 1.05) count++;
|
||||
}
|
||||
|
||||
// Don't count thumb for gesture (only counting index, middle, ring, pinky)
|
||||
return count;
|
||||
}
|
||||
|
||||
_drawBackground() {
|
||||
const ctx = this.overlayCtx;
|
||||
const w = this.overlayCanvas.width;
|
||||
const h = this.overlayCanvas.height;
|
||||
|
||||
// Dark background
|
||||
ctx.fillStyle = '#0a0a0a';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// Zone backgrounds (subtle tint)
|
||||
// In canvas coords (pre-CSS-mirror): left 2/3 = tracking (right hand), right 1/3 = gesture (left hand)
|
||||
const dividerX = w * (2 / 3);
|
||||
|
||||
// Tracking zone — very subtle warm tint
|
||||
ctx.fillStyle = 'rgba(255, 106, 0, 0.03)';
|
||||
ctx.fillRect(0, 0, dividerX, h);
|
||||
|
||||
// Gesture zone — very subtle cool tint
|
||||
ctx.fillStyle = 'rgba(0, 204, 255, 0.03)';
|
||||
ctx.fillRect(dividerX, 0, w - dividerX, h);
|
||||
|
||||
// Dashed divider line
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(dividerX, 0);
|
||||
ctx.lineTo(dividerX, h);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Zone labels (drawn in canvas coords, CSS mirror flips them)
|
||||
ctx.font = '9px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
|
||||
// Tracking label (left 2/3 of canvas → right 2/3 of display)
|
||||
ctx.fillStyle = 'rgba(255, 106, 0, 0.3)';
|
||||
ctx.fillText('TRACKING', dividerX / 2, 12);
|
||||
|
||||
// Gesture label (right 1/3 of canvas → left 1/3 of display)
|
||||
ctx.fillStyle = 'rgba(0, 204, 255, 0.3)';
|
||||
ctx.fillText('GESTURE', dividerX + (w - dividerX) / 2, 12);
|
||||
|
||||
ctx.textAlign = 'start'; // reset
|
||||
}
|
||||
|
||||
_drawSkeleton(landmarks, color, opacity) {
|
||||
const ctx = this.overlayCtx;
|
||||
const w = this.overlayCanvas.width;
|
||||
const h = this.overlayCanvas.height;
|
||||
|
||||
// Draw connections
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.globalAlpha = opacity * 0.8;
|
||||
|
||||
for (const [a, b] of HAND_CONNECTIONS) {
|
||||
const la = landmarks[a], lb = landmarks[b];
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(la.x * w, la.y * h);
|
||||
ctx.lineTo(lb.x * w, lb.y * h);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw landmarks
|
||||
ctx.fillStyle = color;
|
||||
ctx.globalAlpha = opacity;
|
||||
for (const lm of landmarks) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(lm.x * w, lm.y * h, 3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Utility ---
|
||||
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
|
||||
function clamp01(v) { return clamp(v, 0, 1); }
|
||||
function dist3d(a, b) {
|
||||
const dx = a.x - b.x, dy = a.y - b.y, dz = (a.z || 0) - (b.z || 0);
|
||||
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
Loading…
Reference in a new issue