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) <noreply@anthropic.com>
This commit is contained in:
1 parent
3284cd0424
commit
2f6444c0a5
5 files changed
+57
-93
No files matched your search
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -336,11 +336,6 @@
|
||||
|
||||
<!-- ============================== BRACCIO DX ============================== -->
|
||||
<section class="panel" id="tab-right-arm">
|
||||
<div id="raStatusBanner" class="ra-banner ra-banner-warn" hidden>
|
||||
<span id="raStatusLabel">Braccio DX non attivo</span>
|
||||
<button id="raActivateBtn" type="button">Attiva G1 right</button>
|
||||
</div>
|
||||
|
||||
<div class="ra-grid">
|
||||
<!-- FRONTE -->
|
||||
<div class="ra-card">
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user