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>
This commit is contained in:
1 parent
491d1b60aa
commit
53c0e03219
1 file changed
+265
@@ -0,0 +1,265 @@
|
|||||||
|
# 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).
|
||||||
Reference in new issue
Block a user