Files
Presa-Mano/docs/superpowers/specs/2026-05-20-right-arm-tab-design.md
T
Adriano Dal PastroandClaude Opus 4.7 53c0e03219 docs(spec): design tab Braccio DX (G1 right, front + wrist Nuwa)
Design del nuovo tab "Braccio DX": fronte (depth cam fissa sul robot)
+ polso (Nuwa eye-in-hand) + controlli move/home/custom-pose. Router
/api/right_arm/*, JS dedicato, persistenza in config tab_right_arm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:04:08 +02:00

14 KiB

Tab "Braccio DX" — design

Data: 2026-05-20 Autore: brainstorm con Adriano Stato: design approvato, pronto per writing-plans

Obiettivo

Aggiungere una tab GUI dedicata all'operatività del braccio destro del robot G1 in setup nuovo:

  • Camera depth fissa frontale a http://192.168.123.99:8878 (servizio Nuwa-like in docker sul robot).
  • Camera Nuwa sul polso destro (eye-in-hand): la Nuwa esistente (localhost:8877) è stata spostata fisicamente sul polso.

La tab consente, in un unico posto:

  1. Vedere fronte + polso, lanciare YOLO on-demand per cercare un oggetto (default pestello).
  2. Su immagine polso, eseguire YOLO+SAM21 per ottenere centroide e distanza camera→oggetto.
  3. Muovere il polso destro verso l'oggetto con offset XYZ configurabili.
  4. Andare a una Posa Home preset o a una Posa Custom editabile, entrambe persistite in config.

Non-goals

  • Niente calibrazione eye-in-hand reale (T_wrist_camera). L'offset XYZ + sign flips è il workaround dichiarato.
  • Niente sequenza approach+grasp. Il "muovi verso pezzo" è un singolo wrist_relative.
  • Niente IK custom: ci si appoggia interamente a wrist_absolute/wrist_relative di G1.
  • Niente persistenza delle detection. Ogni snapshot/detect è stateless.
  • Niente segmentazione/distanza sul flusso fronte: il fronte serve solo per detection 2D (overview).

Architettura

Nuovi moduli

File Ruolo
src/presa_mano/api/right_arm_routes.py Router /api/right_arm/*
web/right_arm.js Logica e wiring della tab (vanilla, pattern di work_plane.js)
tests/test_right_arm_geometry.py Unit test: delta move, RPY↔quat
tests/test_right_arm_routes.py Integration: tutti gli endpoint con respx

Modifiche a moduli esistenti

File Modifica
src/presa_mano/main.py Include right_arm_routes.router con prefix /api/right_arm
src/presa_mano/api/_deps.py Aggiunge front_camera() -> NuwaClient (legge services.front_camera)
src/presa_mano/clients/g1.py Aggiunge arms_neutral() (proxy a POST /arms/go_neutral) — disponibile ma non usato dal Home
src/presa_mano/geometry.py Aggiunge rpy_to_quaternion(roll, pitch, yaw) e quaternion_to_rpy(q) in gradi
web/index.html Nuova tab right-arm tra "Vista 3D" e "Movimento EE"; markup pannello
config.yaml Aggiunge services.front_camera e blocco tab_right_arm (vedi sotto)

Niente client nuovo per la front camera

Il servizio frontale risponde con lo stesso envelope {success, data, error} e gli stessi endpoint (/api/rgb, /api/depth, /api/intrinsics, /api/status, /api/health) della Nuwa: si riusa NuwaClient puntato al nuovo base_url.

Config

Aggiunte a config.yaml:

services:
  front_camera:
    base_url: http://192.168.123.99:8878
    timeout_s: 5.0

tab_right_arm:
  yolo_class: pestello
  yolo_conf_front: 0.4
  yolo_conf_wrist: 0.4
  move_offset_camera_mm: { x: 0, y: 0, z: 100 }
  axis_sign_cam_to_wrist:  { x: 1, y: -1, z: -1 }
  max_step_m: 0.15
  z_floor_m: null            # se settato, clip Z target a max(target, floor)
  home_pose:
    position:   [0.30, -0.20, 0.30]   # m, frame base
    rpy_deg:    [0, 0, 0]
  custom_pose:
    position:   [0.30, -0.20, 0.30]
    rpy_deg:    [0, 0, 0]

Persistenza: la GUI usa PUT /api/config (deep-merge esistente) per aggiornare tab_right_arm.move_offset_camera_mm, axis_sign_cam_to_wrist e custom_pose. Home è considerata stabile: editabile solo a mano nel file o via API esplicita.

Endpoint /api/right_arm/*

Pattern di risposta: {..., "timings_ms": {step: ms, ..., total: ms}} ovunque.

Method Path Body / Query Risposta sintetica
GET /state — {wrist:{position, quaternion, rpy_deg}, robot_active, side, home_pose, custom_pose, offset_camera_mm, axis_signs, max_step_m, z_floor_m}
POST /activate — PUT /api/config {robot:{active:"g1", wrist_side:"right"}}; ritorna nuovo /state
GET /snapshot/front — streaming image/jpeg da front_camera().rgb_jpeg()
GET /snapshot/wrist — streaming image/jpeg da nuwa().rgb_jpeg()
POST /detect/front {class_name?, conf?, proxy_base_url?} {detections:[{class, confidence, bbox}], image_size:[w,h]}
POST /detect/wrist {class_name?, conf?} {detection, bbox, centroid_camera_m, distance_m, n_sam21_points}
POST /move/toward {class_name?, conf?, dry_run?, force?} vedi data flow
POST /home {dry_run?} wrist_absolute(side=right, position=home_pose.position, quaternion=rpy_to_quat(home_pose.rpy_deg))
POST /custom_pose {dry_run?} come /home ma con custom_pose
PUT /custom_pose {position?, rpy_deg?} aggiorna tab_right_arm.custom_pose
PUT /offset {move_offset_camera_mm?, axis_sign_cam_to_wrist?} aggiorna config

YOLO ↔ front camera: fallback proxy

Il YOLO server (:8879) prende image_url e fa pull. Se non raggiunge 192.168.123.99:8878, /detect/front riprova con image_url = <proxy_base_url>/api/right_arm/snapshot/front. La GUI passa window.location.origin come proxy_base_url. Se anche questo fallisce → 502.

Default dry_run

  • /move/toward: default true (gesto pericoloso).
  • /home, /custom_pose: default false (gesti progettati).

Data flow — /move/toward

  1. GUI invia {class_name, conf, dry_run, force}.
  2. Backend esegue internamente la stessa funzione di /detect/wrist:
    • nuwa.rgb_jpeg() + nuwa.depth_png() + nuwa.intrinsics() (3 GET, skew accettato).
    • YOLO su nuwa.rgb_url() filtrato per class_name, top per confidence → bbox.
    • SAM21 segment_pointcloud(rgb, depth, intrinsics, bbox) → Nx3 in metri, frame camera RGB OpenCV (X-right, Y-down, Z-forward).
    • Se len(points) < 10 → 422.
    • centroid_camera_m = points.mean(axis=0); distance_m = ||centroid||.
  3. Calcolo delta in frame polso:
    off_m       = move_offset_camera_mm / 1000
    sign        = axis_sign_cam_to_wrist
    delta_wrist = sign * (centroid_camera_m - off_m)
    
    Nessuna rotazione: si assume assi camera ≈ assi polso a meno dei segni (calibrazione empirica via flag). Default (+1, -1, -1) riflette l'inversione tipica Y-down/Z-forward camera OpenCV vs Y-up/Z-up polso — da tarare empiricamente.
  4. Guardia: se ||delta_wrist|| > max_step_m e force != true → 422.
  5. Z floor (se z_floor_m != null):
    • leggo wrist_before.position.z, calcolo target_z = wrist_before.z + delta_wrist[2].
    • se target_z < z_floor → clip delta_wrist[2] = z_floor - wrist_before.z; annoto z_clipped: true in response.
  6. g1(dry_run=req.dry_run).wrist_relative(side="right", delta_position=delta_wrist.tolist()).
  7. Response:
    {
      "detection": {...},
      "centroid_camera_m": [x,y,z],
      "distance_m": 0.187,
      "offset_camera_m": [0,0,0.1],
      "axis_signs": [1,-1,-1],
      "delta_wrist_m": [dx,dy,dz],
      "delta_wrist_mm": [dx_mm,dy_mm,dz_mm],
      "z_clipped": false,
      "wrist_before": {position:[...], quaternion:[...]},
      "wrist_after":  {position:[...], quaternion:[...]} or null,
      "applied": true,
      "timings_ms": {...}
    }
    

Logging: ogni /move/toward log INFO con class, conf, centroid_camera, offset, sign, delta_wrist, dry_run, applied. Primo dato per debug empirico dei sign flip.

Niente sweep automatici: una call = un move (rispetta feedback_no_sweep_live_robot).

Error map

Condizione HTTP Detail
YOLO unreachable 502 "YOLO unreachable: ..."
YOLO 0 detections (post-filter classe) 404 "no '<class>' detected"
Front cam unreachable 502 "front_camera unreachable: ..."
SAM21 unreachable 502 "sam21 unreachable: ..."
SAM21 < 10 punti 422 "too few sam21 points: N"
G1 unreachable 502 "G1 unavailable: ..."
robot.active != g1 o wrist_side != right 412 "activate G1 right first"
||delta_wrist|| > max_step senza force 422 "delta_wrist {N}mm > max {M}mm"
home_pose/custom_pose malformati 422 pydantic validation

Layout GUI

Nuova tab tra "Vista 3D" e "Movimento EE", label Braccio DX.

+----------------------------------------------------------------------+
| [ Braccio DX attivo: g1 / right ]   [ Attiva G1 right ]  ← se diverso |
+----------------------------------------------------------------------+
| FRONTE                  | POLSO                  | BRACCIO DX        |
|  <img .front>           |  <img .wrist>          |  Posa corrente:   |
|  classe [pestello ▼]    |  classe [pestello ▼]   |   X: 0.301 m      |
|  conf  [0.40 ]          |  conf  [0.40 ]         |   Y:-0.187 m      |
|  [ Cerca su fronte ]    |  [ Cerca su polso ]    |   Z: 0.302 m      |
|  ── risultato ──        |  ── risultato ──       |   R: 12.4°        |
|  classe: pestello       |  classe: pestello      |   P: -3.1°        |
|  conf: 0.87             |  conf: 0.91            |   Y: 88.6°        |
|  bbox: [120,80,210,180] |  bbox: [...]           |   [ Refresh ]     |
|                         |  centroide cam:        |                   |
|  auto-refresh 1s [✓]    |   x: 0.012  y:-0.003   |                   |
|                         |   z: 0.187  (m)        |                   |
|                         |  distanza: 187 mm      |                   |
|                         |  punti SAM21: 1842     |                   |
|                         |  auto-refresh 1s [✓]   |                   |
+----------------------------------------------------------------------+
| OFFSET PRESA RISPETTO A CAMERA (mm)   SIGN FLIP cam→polso             |
|  X [   0]  Y [   0]  Z [ 100]         X [+1]  Y [-1]  Z [-1]          |
|  [ Salva offset ]    [ Muovi verso pezzo ]   dry-run [✓]              |
+----------------------------------------------------------------------+
| POSA HOME (preset)              | POSA CUSTOM (editabile)             |
|  X [ 0.30] Y [-0.20] Z [ 0.30]  |  X [ 0.30] Y [-0.20] Z [ 0.30]      |
|  R [   0] P [   0  ] Y [   0]   |  R [   0] P [   0  ] Y [   0]       |
|  [ Vai a Home ]                 |  [ Vai a Custom ] [ Salva custom ]  |
|                                 |  [ Usa posa corrente ]              |
+----------------------------------------------------------------------+

Interazioni

  • Auto-refresh immagini: setInterval ogni 1s (configurabile per pannello); chiama /snapshot/{front,wrist} e aggiorna <img> con object URL (revoca quello precedente).
  • YOLO trigger separato: pulsanti "Cerca su fronte"/"Cerca su polso". Il bbox è disegnato su un <canvas> overlay e persiste finché non parte un nuovo detect.
  • "Muovi verso pezzo": primo click esegue con dry_run=true e mostra il delta calcolato in mm in un riquadro di conferma; secondo click conferma dry_run=false. Toggle dry-run esplicito sempre visibile (se off, salta lo step di preview).
  • "Salva offset" → PUT /offset; "Salva custom" → PUT /custom_pose.
  • "Usa posa corrente": copia la posa attuale del polso (convertita in RPY) nei campi custom (no save automatico).
  • Banner: rosso se robot.active != g1 o wrist_side != right. In quel caso visibile il pulsante "Attiva G1 right" che chiama POST /activate. Verde altrimenti.
  • Refresh posa polso: setInterval 2s, override manuale con pulsante "Refresh".

Testing

Unit (tests/test_right_arm_geometry.py)

  • rpy_to_quaternion / quaternion_to_rpy roundtrip su 10+ campioni.
  • Casi del delta:
    • centroid=(0,0,1), offset=(0,0,0), sign=(1,1,1) → delta=(0,0,1)
    • centroid=(0,0,1), offset=(0,0,0.1), sign=(1,-1,-1) → delta=(0,0,-0.9)
    • applicazione sign solo dopo (centroid - offset)
  • Z-floor clip: target sotto floor → delta_z ridotto; flag z_clipped settato.
  • Guardia max_step: oltre soglia → eccezione; con force=True passa.

Integration (tests/test_right_arm_routes.py)

Con respx: mock di Nuwa, front camera, YOLO, SAM21, G1. Coverage:

  • /state con e senza home_pose definita.
  • /activate cambia config e ritorna nuovo state.
  • /snapshot/{front,wrist} ritorna JPEG e content-type giusto.
  • /detect/front con YOLO ok; con YOLO che fallisce su URL diretto e funziona via proxy; con 0 detection.
  • /detect/wrist con YOLO+SAM21 ok; con SAM21 < 10 punti (422); con SAM21 unreachable (502).
  • /move/toward dry_run vs no; force vs guardia; z_floor clip; 412 quando active diverso.
  • /home e /custom_pose dry_run.
  • PUT /custom_pose e PUT /offset aggiornano config (verifica round-trip via GET /api/config).

Smoke manuale

In ordine, da riportare nel design al follow-up:

  1. POST /api/right_arm/activate → state mostra g1/right.
  2. POST /api/right_arm/home (dry_run=true) → preview pose target; poi dry_run=false.
  3. GET /api/right_arm/snapshot/front → JPEG riconoscibile.
  4. POST /api/right_arm/detect/wrist → centroide e distanza coerenti con scena nota.
  5. POST /api/right_arm/move/toward (dry_run=true) → delta in mm ragionevole; rifiuta se non c'è pestello in scena.

Sicurezza fisica

  • Dry-run di default su /move/toward.
  • max_step_m come guardia hard, bypassabile solo con force=true esplicito.
  • z_floor_m clip preventivo sulla Z (memoria feedback_z_floor_safe).
  • Banner rosso se non in g1/right: impedisce visivamente comandi su robot/braccio sbagliato.
  • Nessuno sweep: ogni call è un singolo move (memoria feedback_no_sweep_live_robot).

Tradeoff accettati

  • 3 GET separati per rgb+depth+intrinsics ad ogni /detect/wrist: piccolo skew temporale, scene lente → accettabile.
  • Switching robot.active=g1 è globale: rompe flow H1 left attivi. Il banner + pulsante esplicito lo rende visibile.
  • Assunzione "assi camera ≈ assi polso a meno dei segni": semplifica enormemente ma costringe l'utente a tarare i sign flip empiricamente. Documentato nel layout.

Memorie applicabili

  • feedback_no_sweep_live_robot → niente loop di test sul fisico.
  • feedback_reason_in_grasp_point → distanze ragionate sul punto di presa (offset XYZ rappresenta proprio la differenza camera↔grasp).
  • feedback_z_floor_safe → clip Z implementato.
  • project_emergency_stop_behavior → motivazione per non usare go_neutral sul Home.
  • project_h1_wrist_is_hand_tcp → analogamente, su G1 verifichiamo che position ritornata da /api/wrist sia il TCP (assunto, da confermare in smoke test).