cduss Claude Opus 4.6 commited on
Commit
af34f4d
·
1 Parent(s): ed089b5

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>

Files changed (3) hide show
  1. app.js +33 -56
  2. index.html +0 -10
  3. 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 handX = NaN, handY = NaN; // latest smoothed hand position (0-1), NaN = no hand
24
- let currentYaw = 0, currentPitch = 0;
25
- let noHandFrames = 0;
26
  let lastDetectTs = -1;
27
  let frameCount = 0;
28
  let fpsTime = 0;
29
- const NO_HAND_RETURN_FRAMES = 30; // at 5Hz = 6 seconds
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(handX)) {
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
- handX = NaN;
209
- handY = NaN;
210
  }
211
  updateTrackBadge();
212
  if (!trackingEnabled) clearCanvas();
@@ -218,10 +215,8 @@ $('tuneBtn').addEventListener('click', () => {
218
  });
219
 
220
  function initTuning() {
221
- const gs = $('gainSlider'), ss = $('smoothSlider'), ds = $('dzSlider');
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
- if (isNaN(handX)) { handX = cx; handY = cy; }
348
- else { handX = alpha * cx + smoothing * handX; handY = alpha * cy + smoothing * handY; }
 
 
349
 
350
- // Draw error line
351
  ctx.beginPath();
352
  ctx.moveTo(0.5 * w, 0.5 * h);
353
- ctx.lineTo(handX * w, handY * h);
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, reads handX/handY, sends commands
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(handX)) {
382
- // No hand: count frames, slowly return to center
383
- noHandFrames++;
384
- if (noHandFrames > NO_HAND_RETURN_FRAMES) {
385
- currentYaw *= 0.95;
386
- currentPitch *= 0.95;
387
- if (Math.abs(currentYaw) < 0.3) currentYaw = 0;
388
- if (Math.abs(currentPitch) < 0.3) currentPitch = 0;
389
  }
390
  } else {
391
- noHandFrames = 0;
392
-
393
- // Error from center (0.5, 0.5)
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
- $('headBadge').textContent = `Y:${currentYaw.toFixed(1)} P:${currentPitch.toFixed(1)}`;
 
 
417
 
418
- // Latency (every 2s = every 10 ticks)
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
- handX = NaN; handY = NaN;
466
- currentYaw = 0; currentPitch = 0;
467
  lastDetectTs = -1;
468
- noHandFrames = 0;
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