Files
Presa-Mano/src/presa_mano/api/grasp_routes.py
T
Adriano Dal PastroandClaude Opus 4.7 2f76df4c06 refactor(grasp): extract _points_via_bbox helper
Pure refactor in preparation for the SAM21 branch. _object_points_in_base
keeps its signature and the bbox→ROI→base-frame logic moves into the
private helper. Meta now includes source='bbox_roi'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:46:47 +02:00

336 lines
12 KiB
Python

"""Grasp planning + execution endpoints."""
from __future__ import annotations
from typing import Any, Optional
import numpy as np
import time
from dataclasses import asdict
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from ..calibration import CalibrationStore
from ..clients import G1Unavailable
from ..config import get_config
from ..geometry import transform_points
from ..grasp import GraspPlanner, compute_axes
from ..grasp.visual_servo import (
ServoError,
WorkPlane,
fit_plane_svd,
get_work_plane,
plan as servo_plan,
set_work_plane,
)
from ..perception import ObjectDetector, filter_outliers, nuwa_to_cv_frame
from .perception_routes import _bbox_to_roi
from ..timing import Stopwatch
from . import _deps
router = APIRouter()
class PlanReq(BaseModel):
conf: Optional[float] = None
class_name: Optional[str] = None
class ExecuteReq(PlanReq):
dry_run: bool = True
def _object_points_in_base(conf: float | None, class_name: str | None, sw: Stopwatch
) -> tuple[np.ndarray, dict[str, Any]]:
cfg = get_config()
nuwa = _deps.nuwa()
yolo = _deps.yolo()
side = cfg.get("robot", {}).get("wrist_side", "right")
T_base_cam = CalibrationStore(_deps.calibration_file_path(side)).T_base_camera()
if T_base_cam is None:
raise HTTPException(status_code=412, detail=f"calibration missing for side='{side}': run /api/calibration/solve first")
detector = ObjectDetector(yolo, nuwa)
try:
with sw.step("yolo"):
dets = detector.detect(conf=conf)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=502, detail=f"YOLO detect failed: {e}")
if class_name:
dets = [d for d in dets if (d.get("class") or "").lower() == class_name.lower()]
if not dets:
raise HTTPException(status_code=404, detail="no object detected")
dets.sort(key=lambda d: d["confidence"], reverse=True)
top = dets[0]
bbox = _bbox_to_roi(top["bbox"])
pts_base = _points_via_bbox(nuwa, T_base_cam, bbox, sw)
meta = {
"detection": top,
"T_base_camera": T_base_cam.tolist(),
"n_points": int(len(pts_base)),
"source": "bbox_roi",
}
return pts_base, meta
def _points_via_bbox(nuwa, T_base_cam: np.ndarray, bbox: tuple[int, int, int, int],
sw: Stopwatch) -> np.ndarray:
"""Legacy path: bbox → Nuwa ROI cloud → filter outliers → base frame.
Raises HTTPException(422) on <10 valid depth points (existing behaviour).
"""
cfg = get_config()
u_min, v_min, u_max, v_max = bbox
try:
with sw.step("pointcloud_roi"):
pc, _meta = nuwa.pointcloud_roi(
u_min, v_min, u_max, v_max,
step=int(cfg.get("grasp", {}).get("pointcloud_step", 1)),
max_depth_mm=int(cfg.get("grasp", {}).get("pointcloud_max_depth_mm", 2000)),
)
except Exception as e:
raise HTTPException(status_code=502, detail=f"Nuwa ROI pointcloud failed: {e}")
with sw.step("filter_outliers"):
cropped = filter_outliers(pc)
if len(cropped) < 10:
raise HTTPException(status_code=422, detail=f"too few valid depth points in bbox: {len(cropped)}")
with sw.step("to_base_frame"):
pts_cv = nuwa_to_cv_frame(cropped[:, :3])
pts_base = transform_points(T_base_cam, pts_cv)
return pts_base
@router.post("/plan")
def plan(req: PlanReq) -> dict[str, Any]:
sw = Stopwatch()
cfg = get_config()
pts_base, meta = _object_points_in_base(req.conf, req.class_name, sw)
with sw.step("pca_axes"):
axes = compute_axes(pts_base)
planner = GraspPlanner(
approach_offset_m=float(cfg["grasp"]["approach_offset_m"]),
max_object_width_m=float(cfg["grasp"]["max_object_width_m"]),
)
with sw.step("plan"):
g = planner.plan(axes)
return {"plan": g.to_dict(), "timings_ms": sw.as_dict(), **meta}
@router.post("/execute")
def execute(req: ExecuteReq) -> dict[str, Any]:
sw = Stopwatch()
cfg = get_config()
side = cfg["robot"]["wrist_side"]
pts_base, meta = _object_points_in_base(req.conf, req.class_name, sw)
with sw.step("pca_axes"):
axes = compute_axes(pts_base)
planner = GraspPlanner(
approach_offset_m=float(cfg["grasp"]["approach_offset_m"]),
max_object_width_m=float(cfg["grasp"]["max_object_width_m"]),
)
with sw.step("plan"):
g = planner.plan(axes)
open_action = "RIGHT_OPEN" if side == "right" else "LEFT_OPEN"
close_action = "RIGHT_CLOSE" if side == "right" else "LEFT_CLOSE"
g1 = _deps.g1(dry_run=req.dry_run)
steps: list[dict[str, Any]] = []
try:
with sw.step("hand_open"):
steps.append({"step": "hand_open", "result": g1.hand_action(open_action)})
with sw.step("pre_grasp"):
steps.append({"step": "pre_grasp", "result": g1.wrist_absolute(
side=side,
position=g.pre_grasp_position.tolist(),
quaternion=g.grasp_quaternion.tolist(),
)})
with sw.step("grasp_approach"):
steps.append({"step": "grasp_approach", "result": g1.wrist_absolute(
side=side,
position=g.grasp_position.tolist(),
quaternion=g.grasp_quaternion.tolist(),
)})
with sw.step("hand_close"):
steps.append({"step": "hand_close", "result": g1.hand_action(close_action)})
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=f"G1 unavailable: {e}")
return {"plan": g.to_dict(), "steps": steps, "timings_ms": sw.as_dict(), **meta}
# ---------------------------------------------------------------------------
# Visual servo (marker-relative, no static hand-eye calibration).
# ---------------------------------------------------------------------------
class VisualServoReq(BaseModel):
conf: Optional[float] = None
class_name: Optional[str] = None
side: Optional[str] = None
min_distance_m: Optional[float] = None # default from config (0.1 m)
step_fraction: Optional[float] = None # default from config (0.5)
# Override for grasp.visual_servo.wrist_marker_rvec (radians). Useful for
# live tuning from the UI without persisting to config on every try.
wrist_marker_rvec: Optional[list[float]] = None
class VisualServoStepReq(VisualServoReq):
dry_run: bool = True
timeout_s: float = 5.0
def _servo_plan_from_req(req: VisualServoReq, sw: Stopwatch):
cfg = get_config()
vs_cfg = (cfg.get("grasp", {}) or {}).get("visual_servo", {}) or {}
side = req.side or cfg["robot"]["wrist_side"]
min_dist = float(req.min_distance_m
if req.min_distance_m is not None
else vs_cfg.get("min_distance_m", 0.10))
step_frac = float(req.step_fraction
if req.step_fraction is not None
else vs_cfg.get("step_fraction", 0.5))
# Request-level rvec override: thread it into a shallow-copied cfg so the
# core plan() function keeps reading from a single place.
if req.wrist_marker_rvec is not None:
cfg = {**cfg, "grasp": {**(cfg.get("grasp") or {}),
"visual_servo": {**vs_cfg,
"wrist_marker_rvec": list(req.wrist_marker_rvec)}}}
marker_id = int(cfg["aruco"]["wrist_marker"]["id"])
size_mm = float(cfg["aruco"]["wrist_marker"]["size_mm"])
pc_step = int(cfg.get("grasp", {}).get("pointcloud_step", 1))
pc_max_depth = int(cfg.get("grasp", {}).get("pointcloud_max_depth_mm", 2000))
try:
with sw.step("servo_plan"):
result = servo_plan(
side=side, marker_id=marker_id, size_mm=size_mm,
conf=req.conf, class_name=req.class_name,
min_distance_m=min_dist, step_fraction=step_frac,
pointcloud_step=pc_step, pointcloud_max_depth_mm=pc_max_depth,
cfg=cfg,
nuwa=_deps.nuwa(), marker=_deps.marker(),
yolo=_deps.yolo(), g1=_deps.g1(dry_run=False),
)
except ServoError as e:
raise HTTPException(status_code=422, detail=str(e))
except Exception as e:
raise HTTPException(status_code=502, detail=f"servo plan failed: {e}")
return side, min_dist, step_frac, result
@router.post("/visual_servo/plan")
def visual_servo_plan(req: VisualServoReq) -> dict[str, Any]:
"""Perception + next-step computation, no motion. Useful for UI preview."""
sw = Stopwatch()
side, min_dist, step_frac, p = _servo_plan_from_req(req, sw)
return {
"side": side,
"min_distance_m": min_dist,
"step_fraction": step_frac,
"plan": p.__dict__,
"timings_ms": sw.as_dict(),
}
@router.post("/visual_servo/step")
def visual_servo_step(req: VisualServoStepReq) -> dict[str, Any]:
"""Plan + execute one half-step toward the object. Returns the move result
plus the plan (before the move) so the UI can visualise what just happened;
the next click re-plans from the new wrist pose."""
sw = Stopwatch()
side, min_dist, step_frac, p = _servo_plan_from_req(req, sw)
move_result: dict[str, Any] | None = None
if p.done or p.target_position_base is None:
return {
"side": side,
"min_distance_m": min_dist,
"step_fraction": step_frac,
"plan": p.__dict__,
"moved": False,
"reason": p.reason or "reached",
"timings_ms": sw.as_dict(),
}
g1 = _deps.g1(dry_run=req.dry_run)
try:
with sw.step("wrist_absolute"):
move_result = g1.wrist_absolute(
side=side,
position=list(p.target_position_base),
quaternion=list(p.target_quaternion_base or p.wrist_quaternion_base),
wait=True,
timeout_s=req.timeout_s,
)
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=f"G1 unavailable: {e}")
return {
"side": side,
"min_distance_m": min_dist,
"step_fraction": step_frac,
"plan": p.__dict__,
"moved": True,
"move_result": move_result,
"timings_ms": sw.as_dict(),
}
# ---------------------------------------------------------------------------
# Work plane (for the visual-servo tab): fit once from a user-selected ROI,
# stored in-memory (singleton). Subsequent visual_servo/plan calls enrich the
# response with plane projections of marker and object.
# ---------------------------------------------------------------------------
class PlaneFitReq(BaseModel):
u_min: int = Field(..., ge=0)
v_min: int = Field(..., ge=0)
u_max: int = Field(..., gt=0)
v_max: int = Field(..., gt=0)
max_depth_mm: Optional[int] = None
@router.post("/visual_servo/plane/fit")
def visual_servo_plane_fit(req: PlaneFitReq) -> dict[str, Any]:
cfg = get_config()
nuwa = _deps.nuwa()
pc_max = req.max_depth_mm or int(cfg.get("grasp", {}).get("pointcloud_max_depth_mm", 2000))
try:
pc, _meta = nuwa.pointcloud_roi(
req.u_min, req.v_min, req.u_max, req.v_max,
step=1, max_depth_mm=pc_max,
)
except Exception as e:
raise HTTPException(status_code=502, detail=f"Nuwa pointcloud_roi failed: {e}")
if len(pc) < 10:
raise HTTPException(status_code=422, detail=f"too few depth points in ROI: {len(pc)}")
pts_cv = nuwa_to_cv_frame(pc[:, :3])
# Drop gross outliers before SVD (stray far-depth pixels around the edges).
pts_cv = filter_outliers(pts_cv, n_std=2.0)
try:
normal, offset, centroid, rms_mm = fit_plane_svd(pts_cv)
except ServoError as e:
raise HTTPException(status_code=422, detail=str(e))
wp = WorkPlane(
normal=[float(x) for x in normal],
offset=float(offset),
centroid=[float(x) for x in centroid],
n_inliers=int(len(pts_cv)),
fit_rms_mm=float(rms_mm),
roi=[int(req.u_min), int(req.v_min), int(req.u_max), int(req.v_max)],
created_at=time.time(),
)
set_work_plane(wp)
return {"ok": True, "plane": asdict(wp)}
@router.get("/visual_servo/plane")
def visual_servo_plane_get() -> dict[str, Any]:
wp = get_work_plane()
return {"set": wp is not None, "plane": asdict(wp) if wp is not None else None}
@router.delete("/visual_servo/plane")
def visual_servo_plane_clear() -> dict[str, Any]:
set_work_plane(None)
return {"ok": True, "set": False}