Files
Presa-Mano/docs/superpowers/specs/2026-05-20-right-arm-tab-design.md
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

266 lines
14 KiB
Markdown

# 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`:
```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:
```json
{
"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).