From 2f6444c0a5699998d871d321c1fb326eeeb0c3fa Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Thu, 21 May 2026 11:43:05 +0200 Subject: [PATCH] fix(right_arm): bbox dict, image_size da /api/status, tab non seleziona robot Tre problemi della tab Braccio DX dopo il primo rodaggio: 1. /detect/front e /detect/wrist ritornavano bbox raw dal YOLO server, che lo manda come lista [x1,y1,x2,y2]; la GUI legge bbox.x1 e mostra NaN. Ora il router normalizza il payload via perception/detector ._normalize, allineandosi al resto del codice. Aggiunta regressione con payload reale del server. 2. La tab gateava su robot.active==g1 && wrist_side==right con banner "Attiva G1 right" e POST /activate. Confonde: la selezione del robot e' globale e non si flippa per-feature. Rimossi banner, bottone, endpoint /activate e _guard_g1_right(); gli endpoint operano sul polso destro del robot attivo qualunque esso sia. 3. image_size: [0,0] perche' Nuwa /api/intrinsics non include width/height; vivono in /api/status > data.resolution. Aggiunto NuwaClient.image_size() e usato in detect_front. Aggiornato docstring di intrinsics() per coerenza con il payload reale. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/presa_mano/api/right_arm_routes.py | 32 +++---------- src/presa_mano/clients/nuwa.py | 15 ++++++- tests/test_right_arm_routes.py | 62 +++++++++++++++----------- web/index.html | 5 --- web/right_arm.js | 36 --------------- 5 files changed, 57 insertions(+), 93 deletions(-) diff --git a/src/presa_mano/api/right_arm_routes.py b/src/presa_mano/api/right_arm_routes.py index e8837a9..b503dbe 100644 --- a/src/presa_mano/api/right_arm_routes.py +++ b/src/presa_mano/api/right_arm_routes.py @@ -13,6 +13,7 @@ import numpy as np from ..clients import G1Unavailable from ..config import update_config from ..geometry import quaternion_xyzw_to_rpy_deg, rpy_deg_to_quaternion_xyzw +from ..perception.detector import _normalize as _normalize_yolo from ..perception.sam21_bridge import Sam21Fallback, segment_with_sam21 from ..right_arm.move import DeltaOversize, apply_z_floor, compute_delta_wrist from ..timing import Stopwatch @@ -54,17 +55,6 @@ def get_state() -> dict[str, Any]: raise HTTPException(status_code=502, detail=str(e)) -@router.post("/activate") -def post_activate() -> dict[str, Any]: - sw = Stopwatch() - with sw.step("config_update"): - update_config({"robot": {"active": "g1", "wrist_side": "right"}}) - try: - return _state_payload(sw) - except G1Unavailable as e: - raise HTTPException(status_code=502, detail=str(e)) - - @router.get("/snapshot/front") def snapshot_front() -> Response: try: @@ -109,7 +99,7 @@ def _yolo_detect_with_fallback(image_url: str, conf: float | None, def _top_detection(payload: dict[str, Any], class_name: str | None) -> dict[str, Any] | None: - dets = payload.get("detections") or payload.get("data", {}).get("detections") or [] + dets = _normalize_yolo(payload) if class_name: dets = [d for d in dets if (d.get("class") or "").lower() == class_name.lower()] if not dets: @@ -127,7 +117,7 @@ def detect_front(req: DetectFrontReq) -> dict[str, Any]: if req.proxy_base_url: proxy_url = f"{req.proxy_base_url.rstrip('/')}/api/right_arm/snapshot/front" res = _yolo_detect_with_fallback(image_url, req.conf, proxy_url, sw) - dets_raw = res.get("detections") or res.get("data", {}).get("detections") or [] + dets_raw = _normalize_yolo(res) if req.class_name: dets_filtered = [d for d in dets_raw if (d.get("class") or "").lower() == req.class_name.lower()] @@ -136,10 +126,11 @@ def detect_front(req: DetectFrontReq) -> dict[str, Any]: if not dets_filtered: raise HTTPException(status_code=404, detail=f"no '{req.class_name or 'object'}' detected") dets_filtered.sort(key=lambda d: float(d.get("confidence", 0.0)), reverse=True) - intr = cam.intrinsics() + with sw.step("front_size"): + w, h = cam.image_size() return { "detections": dets_filtered, - "image_size": [int(intr.get("width", 0)), int(intr.get("height", 0))], + "image_size": [w, h], "timings_ms": sw.as_dict(), } @@ -197,19 +188,9 @@ class MoveTowardReq(BaseModel): force: bool = False -def _guard_g1_right() -> None: - if _deps.active_robot_key() != "g1": - raise HTTPException(status_code=412, - detail=f"activate G1 right first (active={_deps.active_robot_key()})") - if _deps.default_side() != "right": - raise HTTPException(status_code=412, - detail=f"activate G1 right first (side={_deps.default_side()})") - - @router.post("/move/toward") def move_toward(req: MoveTowardReq) -> dict[str, Any]: sw = Stopwatch() - _guard_g1_right() cfg = _deps.tab_right_arm_cfg() off_mm = cfg.get("move_offset_camera_mm", {"x": 0, "y": 0, "z": 0}) sign = cfg.get("axis_sign_cam_to_wrist", {"x": 1, "y": 1, "z": 1}) @@ -291,7 +272,6 @@ class OffsetUpdateReq(BaseModel): def _exec_named_pose(pose_key: str, dry_run: bool, sw: Stopwatch) -> dict[str, Any]: - _guard_g1_right() cfg = _deps.tab_right_arm_cfg() pose = cfg.get(pose_key) or {} pos = pose.get("position") diff --git a/src/presa_mano/clients/nuwa.py b/src/presa_mano/clients/nuwa.py index df1b36e..ad6dbcc 100644 --- a/src/presa_mano/clients/nuwa.py +++ b/src/presa_mano/clients/nuwa.py @@ -39,7 +39,9 @@ class NuwaClient: return self._get("/api/rgb").content def intrinsics(self) -> dict[str, Any]: - """Returns {fx, fy, cx, cy, width, height, dist_coeffs?}.""" + """Nuwa /api/intrinsics shape: {ir:{...}, rgb:{...}, extrinsics_ir_to_rgb:{...}}. + Width/height are NOT included here — use `image_size()` (or read them + from the decoded frame) when you need pixel dimensions.""" data = self._get("/api/intrinsics").json() if isinstance(data, dict) and "data" in data: payload = data.get("data") or {} @@ -48,6 +50,17 @@ class NuwaClient: return payload return data + def image_size(self) -> tuple[int, int]: + """Returns (width, height) of the RGB stream from /api/status.""" + data = self._get("/api/status").json() + if isinstance(data, dict) and "data" in data: + data = data.get("data") or {} + res = (data or {}).get("resolution") or {} + w, h = res.get("width"), res.get("height") + if not (isinstance(w, int) and isinstance(h, int)): + raise NuwaError(f"status missing resolution.width/height: {res!r}") + return w, h + def depth_png(self) -> bytes: """16-bit depth PNG (values in mm).""" return self._get("/api/depth").content diff --git a/tests/test_right_arm_routes.py b/tests/test_right_arm_routes.py index 7ba96ed..54e2a7b 100644 --- a/tests/test_right_arm_routes.py +++ b/tests/test_right_arm_routes.py @@ -49,19 +49,6 @@ def test_state_502_when_g1_unavailable(): assert r.status_code == 502 -def test_activate_sets_robot_active_g1_and_side_right(): - # /activate è idempotente: chiama PUT /api/config e ritorna /state - with patch("presa_mano.api.right_arm_routes._deps.g1") as g1_factory: - g1 = MagicMock() - g1.wrist.return_value = _fake_wrist_response() - g1_factory.return_value = g1 - r = client.post("/api/right_arm/activate") - assert r.status_code == 200 - body = r.json() - assert body["robot_active"] == "g1" - assert body["side"] == "right" - - def test_snapshot_front_returns_jpeg(): fake_jpeg = b"\xff\xd8\xff" + b"\x00" * 100 # magic JPEG bytes with patch("presa_mano.api.right_arm_routes._deps.front_camera") as factory: @@ -112,7 +99,7 @@ def test_detect_front_filters_class_and_returns_top(): ying.return_value = yolo cam = MagicMock() cam.rgb_url.return_value = "http://192.168.123.99:8878/api/rgb" - cam.intrinsics.return_value = {"width": 640, "height": 480} + cam.image_size.return_value = (640, 480) cam_f.return_value = cam r = client.post("/api/right_arm/detect/front", @@ -123,6 +110,40 @@ def test_detect_front_filters_class_and_returns_top(): assert body["image_size"] == [640, 480] +def test_detect_front_normalizes_real_yolo_envelope_with_list_bbox(): + """Regressione: il YOLO server reale ritorna bbox come lista [x1,y1,x2,y2] + dentro {success, data, error}. Il router deve normalizzare in {x1,y1,x2,y2}.""" + real_payload = { + "success": True, + "data": { + "detections": [ + {"bbox": [376.7, 17.1, 500.7, 193.7], + "confidence": 0.949, "class": "Pestello"}, + {"bbox": [95.6, 134.8, 207.7, 231.3], + "confidence": 0.917, "class": "Mortaio"}, + ], + }, + "error": None, + } + with patch("presa_mano.api.right_arm_routes._deps.yolo") as ying, \ + patch("presa_mano.api.right_arm_routes._deps.front_camera") as cam_f: + yolo = MagicMock() + yolo.detect_url.return_value = real_payload + ying.return_value = yolo + cam = MagicMock() + cam.rgb_url.return_value = "http://192.168.123.99:8878/api/rgb" + cam.image_size.return_value = (640, 480) + cam_f.return_value = cam + + r = client.post("/api/right_arm/detect/front", + json={"class_name": "Pestello"}) + assert r.status_code == 200 + body = r.json() + top = body["detections"][0] + assert top["class"] == "Pestello" + assert top["bbox"] == {"x1": 376.7, "y1": 17.1, "x2": 500.7, "y2": 193.7} + + def test_detect_front_404_when_no_match(): with patch("presa_mano.api.right_arm_routes._deps.yolo") as ying, \ patch("presa_mano.api.right_arm_routes._deps.front_camera") as cam_f: @@ -131,7 +152,7 @@ def test_detect_front_404_when_no_match(): ying.return_value = yolo cam = MagicMock() cam.rgb_url.return_value = "http://x/api/rgb" - cam.intrinsics.return_value = {"width": 640, "height": 480} + cam.image_size.return_value = (640, 480) cam_f.return_value = cam r = client.post("/api/right_arm/detect/front", json={"class_name": "pestello"}) @@ -155,7 +176,7 @@ def test_detect_front_falls_back_to_proxy_url_on_yolo_failure(): ying.return_value = yolo cam = MagicMock() cam.rgb_url.return_value = "http://192.168.123.99:8878/api/rgb" - cam.intrinsics.return_value = {"width": 640, "height": 480} + cam.image_size.return_value = (640, 480) cam_f.return_value = cam r = client.post("/api/right_arm/detect/front", @@ -319,15 +340,6 @@ def test_move_toward_422_oversize_delta_without_force(): assert "delta" in r.json()["detail"].lower() -def test_move_toward_412_when_not_g1_right(): - """Se config attiva != g1 o side != right, rifiuta con 412.""" - with patch("presa_mano.api.right_arm_routes._deps.active_robot_key", return_value="h1"), \ - patch("presa_mano.api.right_arm_routes._deps.default_side", return_value="left"): - r = client.post("/api/right_arm/move/toward", - json={"class_name": "pestello", "dry_run": True}) - assert r.status_code == 412 - - def test_home_calls_wrist_absolute_with_home_pose(): calls = [] def _abs(**kw): diff --git a/web/index.html b/web/index.html index 232483d..ab5bf3c 100644 --- a/web/index.html +++ b/web/index.html @@ -336,11 +336,6 @@
- -
diff --git a/web/right_arm.js b/web/right_arm.js index 7779c0d..9c19611 100644 --- a/web/right_arm.js +++ b/web/right_arm.js @@ -48,40 +48,6 @@ function clearCanvas(canvas) { ctx.clearRect(0, 0, canvas.width, canvas.height); } -// ---------------- Banner stato ---------------- - -async function refreshBanner() { - let state; - try { - state = await jsonFetch('/api/right_arm/state'); - } catch (e) { - $('raStatusLabel').textContent = `Errore stato: ${e.message}`; - $('raStatusBanner').hidden = false; - $('raStatusBanner').className = 'ra-banner ra-banner-warn'; - return; - } - const ok = state.robot_active === 'g1' && state.side === 'right'; - const banner = $('raStatusBanner'); - if (ok) { - banner.hidden = true; - } else { - banner.hidden = false; - banner.className = 'ra-banner ra-banner-warn'; - $('raStatusLabel').textContent = `Robot attivo: ${state.robot_active} / ${state.side}. Per usare questa tab serve G1 / right.`; - } - return state; -} - -async function onActivateClick() { - try { - await jsonFetch('/api/right_arm/activate', { method: 'POST' }); - await refreshBanner(); - await refreshPose(); - } catch (e) { - alert(`Attivazione fallita: ${e.message}`); - } -} - // ---------------- Immagini ---------------- function urlWithBust(path) { return `${path}?t=${Date.now()}`; } @@ -356,7 +322,6 @@ function markUserTouchedOnInput(prefix) { function init() { if (!$('tab-right-arm')) return; - $('raActivateBtn').addEventListener('click', onActivateClick); $('raFrontDetectBtn').addEventListener('click', onDetectFront); $('raWristDetectBtn').addEventListener('click', onDetectWrist); $('raMoveBtn').addEventListener('click', onMove); @@ -374,7 +339,6 @@ function init() { populateClassDropdowns(); startFrontLoop(); startWristLoop(); - refreshBanner(); refreshPose(); if (poseTimer) clearInterval(poseTimer); poseTimer = setInterval(refreshPose, POSE_REFRESH_MS);