Use look_at_image command instead of manual angle math
Browse files- App now sends pixel coordinates via lookAtImage(u, v) — daemon does
all the camera intrinsics, undistortion, and rotation math
- Removed gain/deadzone tuning (no longer needed)
- Added lookAtImage() to reachy-mini.js library
- Simplified control loop: detect hand → smooth → send pixels
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- app.js +33 -56
- index.html +0 -10
- reachy-mini.js +11 -0
app.js
CHANGED
|
@@ -20,20 +20,17 @@ let controlTimer = null;
|
|
| 20 |
|
| 21 |
// Tracking state — shared between render loop and control loop
|
| 22 |
let trackingEnabled = true;
|
| 23 |
-
let
|
| 24 |
-
let currentYaw = 0, currentPitch = 0;
|
| 25 |
-
let noHandFrames = 0;
|
| 26 |
let lastDetectTs = -1;
|
| 27 |
let frameCount = 0;
|
| 28 |
let fpsTime = 0;
|
| 29 |
-
|
| 30 |
|
| 31 |
// Tuning params
|
| 32 |
-
let gain = 60;
|
| 33 |
let smoothing = 0.60;
|
| 34 |
-
let deadzone = 0.03;
|
| 35 |
const CONTROL_HZ = 5;
|
| 36 |
const CONTROL_INTERVAL = 1000 / CONTROL_HZ; // 200ms
|
|
|
|
| 37 |
|
| 38 |
// Canvas sync cache
|
| 39 |
let cachedVideoW = 0, cachedVideoH = 0;
|
|
@@ -192,7 +189,7 @@ function updateTrackBadge() {
|
|
| 192 |
if (!trackingEnabled) {
|
| 193 |
b.className = 'badge tracking-disabled';
|
| 194 |
b.textContent = 'Tracking off';
|
| 195 |
-
} else if (isNaN(
|
| 196 |
b.className = 'badge tracking-searching';
|
| 197 |
b.textContent = 'Searching...';
|
| 198 |
} else {
|
|
@@ -205,8 +202,8 @@ function updateTrackBadge() {
|
|
| 205 |
$('trackingToggle').addEventListener('change', (e) => {
|
| 206 |
trackingEnabled = e.target.checked;
|
| 207 |
if (trackingEnabled) {
|
| 208 |
-
|
| 209 |
-
|
| 210 |
}
|
| 211 |
updateTrackBadge();
|
| 212 |
if (!trackingEnabled) clearCanvas();
|
|
@@ -218,10 +215,8 @@ $('tuneBtn').addEventListener('click', () => {
|
|
| 218 |
});
|
| 219 |
|
| 220 |
function initTuning() {
|
| 221 |
-
const
|
| 222 |
-
gs.addEventListener('input', () => { gain = +gs.value; $('gainVal').textContent = gain; });
|
| 223 |
ss.addEventListener('input', () => { smoothing = +ss.value / 100; $('smoothVal').textContent = smoothing.toFixed(2); });
|
| 224 |
-
ds.addEventListener('input', () => { deadzone = +ds.value / 100; $('dzVal').textContent = deadzone.toFixed(2); });
|
| 225 |
}
|
| 226 |
|
| 227 |
// ─── MediaPipe detector ───
|
|
@@ -330,7 +325,7 @@ function renderFrame(now) {
|
|
| 330 |
const hand = results.landmarks[0];
|
| 331 |
drawHand(hand, w, h);
|
| 332 |
|
| 333 |
-
// Compute centroid
|
| 334 |
let cx = 0, cy = 0;
|
| 335 |
for (let i = 0; i < hand.length; i++) { cx += hand[i].x; cy += hand[i].y; }
|
| 336 |
cx /= hand.length;
|
|
@@ -342,28 +337,27 @@ function renderFrame(now) {
|
|
| 342 |
ctx.fillStyle = 'rgba(255, 107, 53, 0.8)';
|
| 343 |
ctx.fill();
|
| 344 |
|
| 345 |
-
// Update shared hand position with EMA smoothing
|
| 346 |
const alpha = 1 - smoothing;
|
| 347 |
-
|
| 348 |
-
|
|
|
|
|
|
|
| 349 |
|
| 350 |
-
// Draw error line
|
| 351 |
ctx.beginPath();
|
| 352 |
ctx.moveTo(0.5 * w, 0.5 * h);
|
| 353 |
-
ctx.lineTo(
|
| 354 |
ctx.strokeStyle = 'rgba(255, 107, 53, 0.4)';
|
| 355 |
ctx.lineWidth = 2;
|
| 356 |
ctx.setLineDash([6, 4]);
|
| 357 |
ctx.stroke();
|
| 358 |
ctx.setLineDash([]);
|
| 359 |
-
} else {
|
| 360 |
-
// No hand detected this frame — don't clear handX/handY immediately,
|
| 361 |
-
// the control loop handles timeout
|
| 362 |
}
|
| 363 |
}
|
| 364 |
|
| 365 |
// ═══════════════════════════════════════════════════════════════
|
| 366 |
-
// CONTROL LOOP — fixed 5 Hz,
|
| 367 |
// ═══════════════════════════════════════════════════════════════
|
| 368 |
|
| 369 |
function startControl() {
|
|
@@ -378,44 +372,28 @@ function stopControl() {
|
|
| 378 |
function controlTick() {
|
| 379 |
if (robot.state !== 'streaming') { stopControl(); return; }
|
| 380 |
|
| 381 |
-
if (!trackingEnabled || isNaN(
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
if (
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
}
|
| 390 |
} else {
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
let errorX = handX - 0.5;
|
| 395 |
-
let errorY = handY - 0.5;
|
| 396 |
-
|
| 397 |
-
// Deadzone
|
| 398 |
-
if (Math.abs(errorX) < deadzone) errorX = 0;
|
| 399 |
-
if (Math.abs(errorY) < deadzone) errorY = 0;
|
| 400 |
-
|
| 401 |
-
// Simple proportional: error * gain = target angle
|
| 402 |
-
// gain=60, max error=0.5 → 30° max, good fit for robot limits
|
| 403 |
-
currentYaw = -gain * errorX;
|
| 404 |
-
currentPitch = gain * errorY;
|
| 405 |
-
|
| 406 |
-
// Clamp
|
| 407 |
-
currentYaw = Math.max(-45, Math.min(45, currentYaw));
|
| 408 |
-
currentPitch = Math.max(-30, Math.min(30, currentPitch));
|
| 409 |
}
|
| 410 |
|
| 411 |
-
// Send command
|
| 412 |
-
robot.setHeadPose(0, currentPitch, currentYaw);
|
| 413 |
-
|
| 414 |
// Update HUD
|
| 415 |
updateTrackBadge();
|
| 416 |
-
|
|
|
|
|
|
|
| 417 |
|
| 418 |
-
// Latency (every 2s
|
| 419 |
const now = performance.now();
|
| 420 |
if (now - lastLatencyTime >= 2000) {
|
| 421 |
lastLatencyTime = now;
|
|
@@ -462,10 +440,9 @@ function clearCanvas() {
|
|
| 462 |
}
|
| 463 |
|
| 464 |
function resetTrackingState() {
|
| 465 |
-
|
| 466 |
-
currentYaw = 0; currentPitch = 0;
|
| 467 |
lastDetectTs = -1;
|
| 468 |
-
|
| 469 |
}
|
| 470 |
|
| 471 |
// ─── Latency badge ───
|
|
|
|
| 20 |
|
| 21 |
// Tracking state — shared between render loop and control loop
|
| 22 |
let trackingEnabled = true;
|
| 23 |
+
let handPixelX = NaN, handPixelY = NaN; // latest smoothed hand position in pixels
|
|
|
|
|
|
|
| 24 |
let lastDetectTs = -1;
|
| 25 |
let frameCount = 0;
|
| 26 |
let fpsTime = 0;
|
| 27 |
+
let noHandTicks = 0;
|
| 28 |
|
| 29 |
// Tuning params
|
|
|
|
| 30 |
let smoothing = 0.60;
|
|
|
|
| 31 |
const CONTROL_HZ = 5;
|
| 32 |
const CONTROL_INTERVAL = 1000 / CONTROL_HZ; // 200ms
|
| 33 |
+
const NO_HAND_RETURN_TICKS = 30; // at 5Hz = 6 seconds
|
| 34 |
|
| 35 |
// Canvas sync cache
|
| 36 |
let cachedVideoW = 0, cachedVideoH = 0;
|
|
|
|
| 189 |
if (!trackingEnabled) {
|
| 190 |
b.className = 'badge tracking-disabled';
|
| 191 |
b.textContent = 'Tracking off';
|
| 192 |
+
} else if (isNaN(handPixelX)) {
|
| 193 |
b.className = 'badge tracking-searching';
|
| 194 |
b.textContent = 'Searching...';
|
| 195 |
} else {
|
|
|
|
| 202 |
$('trackingToggle').addEventListener('change', (e) => {
|
| 203 |
trackingEnabled = e.target.checked;
|
| 204 |
if (trackingEnabled) {
|
| 205 |
+
handPixelX = NaN;
|
| 206 |
+
handPixelY = NaN;
|
| 207 |
}
|
| 208 |
updateTrackBadge();
|
| 209 |
if (!trackingEnabled) clearCanvas();
|
|
|
|
| 215 |
});
|
| 216 |
|
| 217 |
function initTuning() {
|
| 218 |
+
const ss = $('smoothSlider');
|
|
|
|
| 219 |
ss.addEventListener('input', () => { smoothing = +ss.value / 100; $('smoothVal').textContent = smoothing.toFixed(2); });
|
|
|
|
| 220 |
}
|
| 221 |
|
| 222 |
// ─── MediaPipe detector ───
|
|
|
|
| 325 |
const hand = results.landmarks[0];
|
| 326 |
drawHand(hand, w, h);
|
| 327 |
|
| 328 |
+
// Compute centroid in normalized coords (0-1)
|
| 329 |
let cx = 0, cy = 0;
|
| 330 |
for (let i = 0; i < hand.length; i++) { cx += hand[i].x; cy += hand[i].y; }
|
| 331 |
cx /= hand.length;
|
|
|
|
| 337 |
ctx.fillStyle = 'rgba(255, 107, 53, 0.8)';
|
| 338 |
ctx.fill();
|
| 339 |
|
| 340 |
+
// Update shared hand position with EMA smoothing (in pixels)
|
| 341 |
const alpha = 1 - smoothing;
|
| 342 |
+
const rawPixelX = cx * w;
|
| 343 |
+
const rawPixelY = cy * h;
|
| 344 |
+
if (isNaN(handPixelX)) { handPixelX = rawPixelX; handPixelY = rawPixelY; }
|
| 345 |
+
else { handPixelX = alpha * rawPixelX + smoothing * handPixelX; handPixelY = alpha * rawPixelY + smoothing * handPixelY; }
|
| 346 |
|
| 347 |
+
// Draw error line from center to smoothed position
|
| 348 |
ctx.beginPath();
|
| 349 |
ctx.moveTo(0.5 * w, 0.5 * h);
|
| 350 |
+
ctx.lineTo(handPixelX, handPixelY);
|
| 351 |
ctx.strokeStyle = 'rgba(255, 107, 53, 0.4)';
|
| 352 |
ctx.lineWidth = 2;
|
| 353 |
ctx.setLineDash([6, 4]);
|
| 354 |
ctx.stroke();
|
| 355 |
ctx.setLineDash([]);
|
|
|
|
|
|
|
|
|
|
| 356 |
}
|
| 357 |
}
|
| 358 |
|
| 359 |
// ═══════════════════════════════════════════════════════════════
|
| 360 |
+
// CONTROL LOOP — fixed 5 Hz, sends look_at_image commands
|
| 361 |
// ═══════════════════════════════════════════════════════════════
|
| 362 |
|
| 363 |
function startControl() {
|
|
|
|
| 372 |
function controlTick() {
|
| 373 |
if (robot.state !== 'streaming') { stopControl(); return; }
|
| 374 |
|
| 375 |
+
if (!trackingEnabled || isNaN(handPixelX)) {
|
| 376 |
+
noHandTicks++;
|
| 377 |
+
// After timeout with no hand, look at center
|
| 378 |
+
if (noHandTicks > NO_HAND_RETURN_TICKS) {
|
| 379 |
+
const w = video.videoWidth, h = video.videoHeight;
|
| 380 |
+
if (w && h) {
|
| 381 |
+
robot.lookAtImage(w / 2, h / 2);
|
| 382 |
+
}
|
| 383 |
}
|
| 384 |
} else {
|
| 385 |
+
noHandTicks = 0;
|
| 386 |
+
// Send pixel coordinates — the daemon handles all the math
|
| 387 |
+
robot.lookAtImage(handPixelX, handPixelY);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 388 |
}
|
| 389 |
|
|
|
|
|
|
|
|
|
|
| 390 |
// Update HUD
|
| 391 |
updateTrackBadge();
|
| 392 |
+
if (!isNaN(handPixelX)) {
|
| 393 |
+
$('headBadge').textContent = `px:${Math.round(handPixelX)},${Math.round(handPixelY)}`;
|
| 394 |
+
}
|
| 395 |
|
| 396 |
+
// Latency (every 2s)
|
| 397 |
const now = performance.now();
|
| 398 |
if (now - lastLatencyTime >= 2000) {
|
| 399 |
lastLatencyTime = now;
|
|
|
|
| 440 |
}
|
| 441 |
|
| 442 |
function resetTrackingState() {
|
| 443 |
+
handPixelX = NaN; handPixelY = NaN;
|
|
|
|
| 444 |
lastDetectTs = -1;
|
| 445 |
+
noHandTicks = 0;
|
| 446 |
}
|
| 447 |
|
| 448 |
// ─── Latency badge ───
|
index.html
CHANGED
|
@@ -78,21 +78,11 @@
|
|
| 78 |
</div>
|
| 79 |
|
| 80 |
<div class="tuning hidden" id="tuningPanel">
|
| 81 |
-
<label>
|
| 82 |
-
Gain
|
| 83 |
-
<input type="range" id="gainSlider" min="5" max="120" value="60">
|
| 84 |
-
<span class="value" id="gainVal">60</span>
|
| 85 |
-
</label>
|
| 86 |
<label>
|
| 87 |
Smooth
|
| 88 |
<input type="range" id="smoothSlider" min="0" max="95" value="60">
|
| 89 |
<span class="value" id="smoothVal">0.60</span>
|
| 90 |
</label>
|
| 91 |
-
<label>
|
| 92 |
-
Dead
|
| 93 |
-
<input type="range" id="dzSlider" min="0" max="15" value="3">
|
| 94 |
-
<span class="value" id="dzVal">0.03</span>
|
| 95 |
-
</label>
|
| 96 |
</div>
|
| 97 |
</main>
|
| 98 |
</div>
|
|
|
|
| 78 |
</div>
|
| 79 |
|
| 80 |
<div class="tuning hidden" id="tuningPanel">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
<label>
|
| 82 |
Smooth
|
| 83 |
<input type="range" id="smoothSlider" min="0" max="95" value="60">
|
| 84 |
<span class="value" id="smoothVal">0.60</span>
|
| 85 |
</label>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
</div>
|
| 87 |
</main>
|
| 88 |
</div>
|
reachy-mini.js
CHANGED
|
@@ -541,6 +541,17 @@ export class ReachyMini extends EventTarget {
|
|
| 541 |
return this._sendCommand({ type: "set_target", head: rpyToMatrix(roll, pitch, yaw).flat() });
|
| 542 |
}
|
| 543 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 544 |
/**
|
| 545 |
* Set antenna positions.
|
| 546 |
* @param {number} rightDeg @param {number} leftDeg
|
|
|
|
| 541 |
return this._sendCommand({ type: "set_target", head: rpyToMatrix(roll, pitch, yaw).flat() });
|
| 542 |
}
|
| 543 |
|
| 544 |
+
/**
|
| 545 |
+
* Make the robot look at a pixel position in the camera frame.
|
| 546 |
+
* @param {number} u — horizontal pixel coordinate
|
| 547 |
+
* @param {number} v — vertical pixel coordinate
|
| 548 |
+
* @param {number} [duration=0] — movement duration in seconds (0 = immediate)
|
| 549 |
+
* @returns {boolean}
|
| 550 |
+
*/
|
| 551 |
+
lookAtImage(u, v, duration = 0) {
|
| 552 |
+
return this._sendCommand({ type: "look_at_image", u, v, duration });
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
/**
|
| 556 |
* Set antenna positions.
|
| 557 |
* @param {number} rightDeg @param {number} leftDeg
|