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>
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:
- Vedere fronte + polso, lanciare YOLO on-demand per cercare un oggetto (default
pestello). - Su immagine polso, eseguire YOLO+SAM21 per ottenere centroide e distanza camera→oggetto.
- Muovere il polso destro verso l'oggetto con offset XYZ configurabili.
- 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_relativedi 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: defaulttrue(gesto pericoloso)./home,/custom_pose: defaultfalse(gesti progettati).
Data flow — /move/toward
- GUI invia
{class_name, conf, dry_run, force}. - 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 perclass_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||.
- Calcolo delta in frame polso:
Nessuna rotazione: si assume assi camera ≈ assi polso a meno dei segni (calibrazione empirica via flag). Default
off_m = move_offset_camera_mm / 1000 sign = axis_sign_cam_to_wrist delta_wrist = sign * (centroid_camera_m - off_m)(+1, -1, -1)riflette l'inversione tipica Y-down/Z-forward camera OpenCV vs Y-up/Z-up polso — da tarare empiricamente. - Guardia: se
||delta_wrist|| > max_step_meforce != true→ 422. - Z floor (se
z_floor_m != null):- leggo
wrist_before.position.z, calcolotarget_z = wrist_before.z + delta_wrist[2]. - se
target_z < z_floor→ clipdelta_wrist[2] = z_floor - wrist_before.z; annotoz_clipped: truein response.
- leggo
g1(dry_run=req.dry_run).wrist_relative(side="right", delta_position=delta_wrist.tolist()).- 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:
setIntervalogni 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=truee mostra il delta calcolato in mm in un riquadro di conferma; secondo click confermadry_run=false. Toggledry-runesplicito 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 != g1owrist_side != right. In quel caso visibile il pulsante "Attiva G1 right" che chiamaPOST /activate. Verde altrimenti. - Refresh posa polso:
setInterval2s, override manuale con pulsante "Refresh".
Testing
Unit (tests/test_right_arm_geometry.py)
rpy_to_quaternion/quaternion_to_rpyroundtrip 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_clippedsettato. - Guardia
max_step: oltre soglia → eccezione; conforce=Truepassa.
Integration (tests/test_right_arm_routes.py)
Con respx: mock di Nuwa, front camera, YOLO, SAM21, G1. Coverage:
/statecon e senzahome_posedefinita./activatecambia config e ritorna nuovo state./snapshot/{front,wrist}ritorna JPEG e content-type giusto./detect/frontcon YOLO ok; con YOLO che fallisce su URL diretto e funziona via proxy; con 0 detection./detect/wristcon YOLO+SAM21 ok; con SAM21 < 10 punti (422); con SAM21 unreachable (502)./move/towarddry_run vs no; force vs guardia; z_floor clip; 412 quando active diverso./homee/custom_posedry_run.PUT /custom_poseePUT /offsetaggiornano config (verifica round-trip viaGET /api/config).
Smoke manuale
In ordine, da riportare nel design al follow-up:
POST /api/right_arm/activate→ state mostrag1/right.POST /api/right_arm/home(dry_run=true) → preview pose target; poidry_run=false.GET /api/right_arm/snapshot/front→ JPEG riconoscibile.POST /api/right_arm/detect/wrist→ centroide e distanza coerenti con scena nota.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_mcome guardia hard, bypassabile solo conforce=trueesplicito.z_floor_mclip preventivo sulla Z (memoriafeedback_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 usarego_neutralsul Home.project_h1_wrist_is_hand_tcp→ analogamente, su G1 verifichiamo chepositionritornata da/api/wristsia il TCP (assunto, da confermare in smoke test).