Files
Presa-Mano/src/presa_mano/api/right_arm_routes.py
T
Adriano Dal PastroandClaude Opus 4.7 57954b5c8d feat(right_arm): perm assi cam->polso, precise=true default, verify_tag UI
Lavoro accumulato nella sessione 2026-05-21 prima del brainstorming
della tab Pose 3D:

- compute_delta_wrist generalizzato con cam_to_wrist_perm: la mappa
  cam->polso non e' solo sign-flip ma una permutazione (default
  identita' per backward compat). DeltaOversize ora espone X/Y/Z
  nella stringa d'errore. Test aggiornati.

- G1Client.wrist_absolute / wrist_relative: precise=True di default
  (era False). Stessa cosa per wrist_routes.AbsoluteReq Pydantic
  e per agent.tools move_wrist_*. Movimenti reali ora chiedono
  controllo accurato salvo override esplicito.

- Tab Braccio DX:
  - /verify_tag endpoint: snapshot wrist FK + tag id=1 dalla front
    camera. Per verifica relativa moveto vs marker (no calibrazione).
  - GUI: pannello "Verifica tag id=1" + campi camera_offset_wrist_mm.
  - markUserTouchedOnInput esteso a raOff/raSgn/raCamOff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:44:48 +02:00

439 lines
17 KiB
Python

"""Endpoint per la tab Braccio DX (G1, polso destro, Nuwa eye-in-hand)."""
from __future__ import annotations
import logging
from typing import Any, Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel
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.cluster import filter_nearest_cluster
from ..right_arm.move import DeltaOversize, apply_z_floor, compute_delta_wrist
from ..timing import Stopwatch
from . import _deps
from .perception_routes import _bbox_to_roi
router = APIRouter()
log = logging.getLogger(__name__)
def _state_payload(sw: Stopwatch) -> dict[str, Any]:
cfg = _deps.tab_right_arm_cfg()
with sw.step("g1_wrist"):
wrist = _deps.g1().wrist()["right_wrist"]
pos = wrist["position"]
quat = wrist["quaternion"]
rpy = quaternion_xyzw_to_rpy_deg(quat)
return {
"wrist": {"position": pos, "quaternion": quat,
"rpy_deg": [rpy[0], rpy[1], rpy[2]]},
"robot_active": _deps.active_robot_key(),
"side": _deps.default_side(),
"home_pose": cfg.get("home_pose"),
"custom_pose": cfg.get("custom_pose"),
"offset_camera_mm": cfg.get("move_offset_camera_mm"),
"axis_signs": cfg.get("axis_sign_cam_to_wrist"),
"camera_offset_wrist_mm": cfg.get("camera_offset_wrist_mm"),
"cam_to_wrist_perm": cfg.get("cam_to_wrist_perm"),
"cluster_depth_window_m": cfg.get("cluster_depth_window_m"),
"max_step_m": cfg.get("max_step_m"),
"z_floor_m": cfg.get("z_floor_m"),
"timings_ms": sw.as_dict(),
}
@router.get("/state")
def get_state() -> dict[str, Any]:
sw = Stopwatch()
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:
jpeg = _deps.front_camera().rgb_jpeg()
except Exception as e:
raise HTTPException(status_code=502, detail=f"front_camera unreachable: {e}")
return Response(content=jpeg, media_type="image/jpeg")
@router.get("/snapshot/wrist")
def snapshot_wrist() -> Response:
try:
jpeg = _deps.nuwa().rgb_jpeg()
except Exception as e:
raise HTTPException(status_code=502, detail=f"wrist nuwa unreachable: {e}")
return Response(content=jpeg, media_type="image/jpeg")
class DetectFrontReq(BaseModel):
class_name: Optional[str] = None
conf: Optional[float] = None
proxy_base_url: Optional[str] = None # se YOLO non raggiunge la front camera
def _yolo_detect_with_fallback(image_url: str, conf: float | None,
proxy_image_url: str | None, sw: Stopwatch
) -> dict[str, Any]:
"""Prova YOLO sull'URL diretto; se fallisce e proxy_image_url è settato, ritenta."""
yolo = _deps.yolo()
try:
with sw.step("yolo"):
return yolo.detect_url(image_url=image_url, conf=conf)
except Exception as e:
if not proxy_image_url:
raise HTTPException(status_code=502, detail=f"YOLO unreachable: {e}")
log.warning("YOLO failed on direct URL (%s), retrying via proxy %s", e, proxy_image_url)
try:
with sw.step("yolo_proxy"):
return yolo.detect_url(image_url=proxy_image_url, conf=conf)
except Exception as e2:
raise HTTPException(status_code=502, detail=f"YOLO unreachable (proxy): {e2}")
def _top_detection(payload: dict[str, Any], class_name: str | None) -> dict[str, Any] | None:
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:
return None
dets.sort(key=lambda d: float(d.get("confidence", 0.0)), reverse=True)
return dets[0]
@router.post("/detect/front")
def detect_front(req: DetectFrontReq) -> dict[str, Any]:
sw = Stopwatch()
cam = _deps.front_camera()
image_url = cam.rgb_url()
proxy_url = None
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 = _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()]
else:
dets_filtered = list(dets_raw)
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)
with sw.step("front_size"):
w, h = cam.image_size()
return {
"detections": dets_filtered,
"image_size": [w, h],
"timings_ms": sw.as_dict(),
}
class DetectWristReq(BaseModel):
class_name: Optional[str] = None
conf: Optional[float] = None
def _detect_wrist_core(class_name: str | None, conf: float | None, sw: Stopwatch
) -> dict[str, Any]:
"""Funzione core riusata da /detect/wrist e da /move/toward."""
nuwa = _deps.nuwa()
image_url = nuwa.rgb_url()
try:
with sw.step("yolo"):
yres = _deps.yolo().detect_url(image_url=image_url, conf=conf)
except Exception as e:
raise HTTPException(status_code=502, detail=f"YOLO unreachable: {e}")
top = _top_detection(yres, class_name)
if top is None:
raise HTTPException(status_code=404, detail=f"no '{class_name or 'object'}' detected")
bbox = _bbox_to_roi(top["bbox"])
try:
seg = segment_with_sam21(nuwa, _deps.sam21(), bbox, sw)
except Sam21Fallback as e:
raise HTTPException(status_code=422, detail=f"SAM21 fallback: {e}")
pts_all = np.asarray(seg.points_cv_m, dtype=np.float64)
if pts_all.shape[0] < 10:
raise HTTPException(status_code=422, detail=f"too few sam21 points: {pts_all.shape[0]}")
cfg = _deps.tab_right_arm_cfg()
window_m = float(cfg.get("cluster_depth_window_m", 0.20))
with sw.step("cluster_filter"):
pts, cluster_diag = filter_nearest_cluster(pts_all, window_m=window_m)
if pts.shape[0] < 10:
raise HTTPException(
status_code=422,
detail=f"cluster troppo piccolo: {pts.shape[0]} punti dopo filtro "
f"(z_near={cluster_diag['z_near_m']:.3f}m window={window_m:.3f}m)")
with sw.step("centroid"):
centroid = pts.mean(axis=0)
distance = float(np.linalg.norm(centroid))
return {
"detection": top,
"bbox": list(bbox),
"centroid_camera_m": [float(centroid[0]), float(centroid[1]), float(centroid[2])],
"distance_m": distance,
"n_sam21_points": int(pts_all.shape[0]),
"n_centroid_points": int(pts.shape[0]),
"cluster_window_m": window_m,
"cluster_z_near_m": cluster_diag["z_near_m"],
"cluster_z_cutoff_m": cluster_diag["z_cutoff_m"],
}
@router.post("/detect/wrist")
def detect_wrist(req: DetectWristReq) -> dict[str, Any]:
sw = Stopwatch()
body = _detect_wrist_core(req.class_name, req.conf, sw)
body["timings_ms"] = sw.as_dict()
return body
class MoveTowardReq(BaseModel):
class_name: Optional[str] = None
conf: Optional[float] = None
dry_run: bool = True
force: bool = False
@router.post("/move/toward")
def move_toward(req: MoveTowardReq) -> dict[str, Any]:
sw = Stopwatch()
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})
cam_off_mm = cfg.get("camera_offset_wrist_mm", {"x": 0, "y": 0, "z": 0})
perm_cfg = cfg.get("cam_to_wrist_perm", ["x", "y", "z"])
max_step_m = float(cfg.get("max_step_m", 0.15))
z_floor = cfg.get("z_floor_m")
z_floor_m = float(z_floor) if z_floor is not None else None
det = _detect_wrist_core(req.class_name, req.conf, sw)
centroid = np.asarray(det["centroid_camera_m"], dtype=np.float64)
offset_m = np.array([off_mm["x"], off_mm["y"], off_mm["z"]], dtype=np.float64) / 1000.0
cam_offset_wrist_m = np.array(
[cam_off_mm["x"], cam_off_mm["y"], cam_off_mm["z"]], dtype=np.float64) / 1000.0
signs = (int(sign["x"]), int(sign["y"]), int(sign["z"]))
_AXIS_IDX = {"x": 0, "y": 1, "z": 2}
try:
perm = tuple(_AXIS_IDX[str(p).lower()] for p in perm_cfg)
except KeyError as e:
raise HTTPException(status_code=422,
detail=f"cam_to_wrist_perm: asse sconosciuto {e}")
try:
delta = compute_delta_wrist(centroid, offset_m, signs,
cam_to_wrist_perm=perm,
camera_offset_wrist_m=cam_offset_wrist_m,
max_step_m=max_step_m, force=req.force)
except DeltaOversize as e:
raise HTTPException(status_code=422, detail=str(e))
# Leggi posa polso PRIMA del move per applicare z-floor
try:
with sw.step("g1_wrist_before"):
wrist_before = _deps.g1().wrist()["right_wrist"]
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=f"G1 unavailable: {e}")
delta, z_clipped = apply_z_floor(float(wrist_before["position"][2]), delta, z_floor_m)
try:
with sw.step("wrist_relative"):
_deps.g1(dry_run=req.dry_run).wrist_relative(
side="right", delta_position=delta.tolist(),
)
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=f"G1 unavailable: {e}")
wrist_after: dict[str, Any] | None = None
if not req.dry_run:
try:
with sw.step("g1_wrist_after"):
wrist_after = _deps.g1().wrist()["right_wrist"]
except G1Unavailable:
wrist_after = None # best-effort; il move è stato già inviato
log.info(
"move_toward class=%s conf=%s centroid=%s offset=%s sign=%s delta=%s dry_run=%s applied=%s",
req.class_name, req.conf, centroid.tolist(),
offset_m.tolist(), signs, delta.tolist(), req.dry_run, not req.dry_run,
)
return {
"detection": det["detection"],
"centroid_camera_m": det["centroid_camera_m"],
"distance_m": det["distance_m"],
"offset_camera_m": offset_m.tolist(),
"axis_signs": list(signs),
"camera_offset_wrist_m": cam_offset_wrist_m.tolist(),
"delta_wrist_m": delta.tolist(),
"delta_wrist_mm": (delta * 1000.0).tolist(),
"z_clipped": bool(z_clipped),
"wrist_before": wrist_before,
"wrist_after": wrist_after,
"applied": not req.dry_run,
"n_sam21_points": det["n_sam21_points"],
"timings_ms": sw.as_dict(),
}
class PoseExecReq(BaseModel):
dry_run: bool = False
class PoseUpdateReq(BaseModel):
position: Optional[list[float]] = None
rpy_deg: Optional[list[float]] = None
class OffsetUpdateReq(BaseModel):
move_offset_camera_mm: Optional[dict[str, float]] = None
axis_sign_cam_to_wrist: Optional[dict[str, int]] = None
camera_offset_wrist_mm: Optional[dict[str, float]] = None
def _exec_named_pose(pose_key: str, dry_run: bool, sw: Stopwatch) -> dict[str, Any]:
cfg = _deps.tab_right_arm_cfg()
pose = cfg.get(pose_key) or {}
pos = pose.get("position")
rpy = pose.get("rpy_deg")
if not (pos and rpy and len(pos) == 3 and len(rpy) == 3):
raise HTTPException(status_code=422, detail=f"{pose_key} malformed in config")
quat = rpy_deg_to_quaternion_xyzw(rpy[0], rpy[1], rpy[2])
try:
with sw.step("wrist_absolute"):
res = _deps.g1(dry_run=dry_run).wrist_absolute(
side="right", position=list(pos), quaternion=quat.tolist(),
)
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=f"G1 unavailable: {e}")
return {"pose_key": pose_key, "position": pos, "rpy_deg": rpy,
"quaternion": quat.tolist(), "result": res, "dry_run": dry_run,
"applied": not dry_run, "timings_ms": sw.as_dict()}
@router.post("/home")
def post_home(req: PoseExecReq) -> dict[str, Any]:
return _exec_named_pose("home_pose", req.dry_run, Stopwatch())
@router.post("/custom_pose")
def post_custom_pose(req: PoseExecReq) -> dict[str, Any]:
return _exec_named_pose("custom_pose", req.dry_run, Stopwatch())
@router.put("/custom_pose")
def put_custom_pose(req: PoseUpdateReq) -> dict[str, Any]:
patch: dict[str, Any] = {}
if req.position is not None:
if len(req.position) != 3:
raise HTTPException(status_code=422, detail="position must be 3 floats")
patch["position"] = list(req.position)
if req.rpy_deg is not None:
if len(req.rpy_deg) != 3:
raise HTTPException(status_code=422, detail="rpy_deg must be 3 floats")
patch["rpy_deg"] = list(req.rpy_deg)
if not patch:
raise HTTPException(status_code=422, detail="nothing to update")
update_config({"tab_right_arm": {"custom_pose": patch}})
return {"custom_pose": _deps.tab_right_arm_cfg().get("custom_pose")}
def _front_cam_marker_intrinsics() -> dict[str, Any]:
"""Estrae le intrinsics RGB della front camera nel formato marker-finder
(camera_matrix + dist_coeffs). marker-finder ha intrinsics 'baked-in'
della D435i che non combaciano con la Nuwa: passarle per-call evita
errori di scala sistematici sulla posa del tag."""
intr = _deps.front_camera().intrinsics()
rgb = (intr or {}).get("rgb") or {}
K = [[float(rgb.get("fx", 0)), 0.0, float(rgb.get("cx", 0))],
[0.0, float(rgb.get("fy", 0)), float(rgb.get("cy", 0))],
[0.0, 0.0, 1.0]]
dist = rgb.get("distortion") or {}
d = [float(dist.get("k1", 0.0)), float(dist.get("k2", 0.0)),
float(dist.get("p1", 0.0)), float(dist.get("p2", 0.0)),
float(dist.get("k3", 0.0))]
return {"camera_matrix": K, "dist_coeffs": d}
@router.post("/verify_tag")
def post_verify_tag() -> dict[str, Any]:
"""Snapshot per verifica relativa: posa polso (G1 FK) + posa tag (marker
finder sulla front camera). Da chiamare ripetutamente con il polso in
posizioni diverse; il client confronta i moduli dei delta consecutivi.
Niente calibrazione assoluta richiesta: il tag e' rigidamente attaccato
al polso, quindi ||Δtag|| dovrebbe approssimare ||Δwrist|| (a meno di
qualche % di errore intrinsics)."""
sw = Stopwatch()
cfg = _deps.tab_right_arm_cfg()
vm = cfg.get("verify_marker") or {"id": 1, "size_mm": 40.0}
try:
with sw.step("g1_wrist"):
wrist = _deps.g1().wrist()["right_wrist"]
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=f"G1 unavailable: {e}")
try:
with sw.step("front_intr"):
intr = _front_cam_marker_intrinsics()
with sw.step("marker_detect"):
image_url = _deps.front_camera().rgb_url()
res = _deps.marker().detect_url(
markers=[{"id": int(vm["id"]), "size_mm": float(vm["size_mm"])}],
image_url=image_url,
intrinsics=intr,
)
except Exception as e:
raise HTTPException(status_code=502, detail=f"marker-finder: {e}")
found = (res or {}).get("found") or []
if not found:
raise HTTPException(status_code=404,
detail=f"tag id={vm['id']} non rilevato dalla front camera")
tag = found[0]
return {
"marker_id": int(vm["id"]),
"wrist_position_m": list(wrist["position"]),
"wrist_quaternion": list(wrist["quaternion"]),
"tag_position_m_in_front_cam": list(tag["position_m"]),
"tag_quaternion": list(tag.get("quaternion_xyzw") or []),
"timings_ms": sw.as_dict(),
}
@router.put("/offset")
def put_offset(req: OffsetUpdateReq) -> dict[str, Any]:
patch: dict[str, Any] = {}
if req.move_offset_camera_mm is not None:
for k in ("x", "y", "z"):
if k not in req.move_offset_camera_mm:
raise HTTPException(status_code=422, detail=f"move_offset_camera_mm missing '{k}'")
patch["move_offset_camera_mm"] = {k: float(req.move_offset_camera_mm[k]) for k in ("x", "y", "z")}
if req.axis_sign_cam_to_wrist is not None:
for k in ("x", "y", "z"):
v = req.axis_sign_cam_to_wrist.get(k)
if v not in (-1, 1):
raise HTTPException(status_code=422, detail=f"axis_sign_cam_to_wrist['{k}'] must be +/-1")
patch["axis_sign_cam_to_wrist"] = {k: int(req.axis_sign_cam_to_wrist[k]) for k in ("x", "y", "z")}
if req.camera_offset_wrist_mm is not None:
for k in ("x", "y", "z"):
if k not in req.camera_offset_wrist_mm:
raise HTTPException(status_code=422, detail=f"camera_offset_wrist_mm missing '{k}'")
patch["camera_offset_wrist_mm"] = {k: float(req.camera_offset_wrist_mm[k]) for k in ("x", "y", "z")}
if not patch:
raise HTTPException(status_code=422, detail="nothing to update")
update_config({"tab_right_arm": patch})
cfg = _deps.tab_right_arm_cfg()
return {"move_offset_camera_mm": cfg.get("move_offset_camera_mm"),
"axis_sign_cam_to_wrist": cfg.get("axis_sign_cam_to_wrist"),
"camera_offset_wrist_mm": cfg.get("camera_offset_wrist_mm")}