diff --git a/src/presa_mano/api/grasp_routes.py b/src/presa_mano/api/grasp_routes.py
index 103c4e3..2cb541c 100644
--- a/src/presa_mano/api/grasp_routes.py
+++ b/src/presa_mano/api/grasp_routes.py
@@ -5,32 +5,17 @@ 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 pydantic import BaseModel
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.sam21_bridge import Sam21Fallback, points_via_sam21
-from ..perception.work_plane import (
- WorkPlane as PerceptionWorkPlane,
- apply_below_plane_filter,
- get_work_plane,
-)
-from ..perception.work_plane import set_work_plane as _set_perception_work_plane
+from ..perception.work_plane import apply_below_plane_filter, get_work_plane
from .perception_routes import _bbox_to_roi
from ..timing import Stopwatch
from . import _deps
@@ -187,184 +172,3 @@ def execute(req: ExecuteReq) -> dict[str, Any]:
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)
- # Keep perception.work_plane singleton in sync until Task 4 removes
- # the visual-servo duplicate. Default offset_mm so the filter has a
- # reasonable threshold without forcing the user through the new endpoint.
- _set_perception_work_plane(PerceptionWorkPlane(
- normal=wp.normal, offset=wp.offset, centroid=wp.centroid,
- n_inliers=wp.n_inliers, fit_rms_mm=wp.fit_rms_mm,
- roi=wp.roi, offset_mm=5.0, created_at=wp.created_at,
- ))
- 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)
- _set_perception_work_plane(None)
- return {"ok": True, "set": False}
diff --git a/src/presa_mano/grasp/visual_servo.py b/src/presa_mano/grasp/visual_servo.py
deleted file mode 100644
index 0a376f0..0000000
--- a/src/presa_mano/grasp/visual_servo.py
+++ /dev/null
@@ -1,504 +0,0 @@
-"""Marker-relative visual servoing (no static hand-eye calibration).
-
-Idea: the wrist-mounted ArUco marker is our live reference. Each step we read
-current wrist pose from G1, detect the marker in the camera frame, detect the
-object (YOLO bbox → Nuwa ROI point cloud → centroid), and plan a wrist move
-that advances a fraction of the camera-frame marker→object vector.
-
-Mapping a camera-frame delta to a base-frame wrist delta needs `R_base_camera`,
-which is derived live each step:
-
- R_base_camera = R_base_wrist @ R_wrist_marker @ R_cam_marker^-1
-
-Only the *rotation* part of `T_wrist_marker` matters for deltas — translation
-cancels. We take `R_wrist_marker` from config (rvec), defaulting to identity
-when the marker is roughly aligned with the wrist frame.
-
-Stop condition: camera-frame marker→object distance ≤ `min_distance_m`. This
-is a direct physical proxy for wrist-to-object proximity, independent of the
-(unknown) marker-to-wrist translation offset.
-"""
-
-from __future__ import annotations
-
-import json
-import logging
-import time
-from dataclasses import asdict, dataclass
-from pathlib import Path
-from typing import Any
-
-import numpy as np
-
-from ..clients import G1Client, MarkerClient, NuwaClient, YoloClient
-from ..config import data_dir
-from ..geometry import (
- T_from_pos_quat,
- invert_T,
- make_T,
- quat_xyzw_to_R,
- rvec_to_R,
-)
-from ..perception import filter_outliers, nuwa_to_cv_frame
-from ..perception.aruco_pose import nuwa_intrinsics_to_matrix, solve_square_marker
-from ..perception.detector import ObjectDetector
-
-log = logging.getLogger(__name__)
-
-
-class ServoError(RuntimeError):
- pass
-
-
-@dataclass
-class WorkPlane:
- """Plane in camera frame: normal·X + offset = 0, normal is unit-length and
- pointing roughly toward the camera (i.e. has negative Z in OpenCV frame)."""
- normal: list[float]
- offset: float
- centroid: list[float]
- n_inliers: int
- fit_rms_mm: float
- roi: list[int]
- created_at: float
-
-
-_WORK_PLANE: WorkPlane | None = None
-_WORK_PLANE_LOADED = False
-
-
-def _work_plane_path() -> Path:
- return data_dir() / "work_plane.json"
-
-
-def _save_work_plane_to_disk(wp: WorkPlane | None) -> None:
- path = _work_plane_path()
- try:
- if wp is None:
- if path.exists():
- path.unlink()
- return
- path.parent.mkdir(parents=True, exist_ok=True)
- with open(path, "w") as f:
- json.dump(asdict(wp), f, indent=2)
- except Exception as e:
- log.warning("failed to persist work plane: %s", e)
-
-
-def _load_work_plane_from_disk() -> WorkPlane | None:
- path = _work_plane_path()
- try:
- with open(path, "r") as f:
- d = json.load(f)
- return WorkPlane(**d)
- except FileNotFoundError:
- return None
- except Exception as e:
- log.warning("failed to load persisted work plane (%s)", e)
- return None
-
-
-def get_work_plane() -> WorkPlane | None:
- global _WORK_PLANE, _WORK_PLANE_LOADED
- if not _WORK_PLANE_LOADED:
- _WORK_PLANE = _load_work_plane_from_disk()
- _WORK_PLANE_LOADED = True
- return _WORK_PLANE
-
-
-def set_work_plane(wp: WorkPlane | None) -> None:
- global _WORK_PLANE, _WORK_PLANE_LOADED
- _WORK_PLANE = wp
- _WORK_PLANE_LOADED = True
- _save_work_plane_to_disk(wp)
-
-
-def fit_plane_svd(points_xyz: np.ndarray
- ) -> tuple[np.ndarray, float, np.ndarray, float]:
- """Least-squares plane via SVD. Returns (normal, offset, centroid, rms_mm).
-
- Plane equation: normal·X + offset = 0. Normal is oriented to point toward
- the camera origin (negative Z in CV frame)."""
- pts = np.asarray(points_xyz, dtype=np.float64)
- if pts.ndim != 2 or pts.shape[1] < 3:
- raise ServoError(f"unexpected points shape for plane fit: {pts.shape}")
- xyz = pts[:, :3]
- if len(xyz) < 10:
- raise ServoError(f"too few points to fit plane: {len(xyz)}")
- c = xyz.mean(axis=0)
- centered = xyz - c
- _, _, Vt = np.linalg.svd(centered, full_matrices=False)
- normal = Vt[-1]
- n = float(np.linalg.norm(normal))
- if n < 1e-9:
- raise ServoError("degenerate plane (zero normal)")
- normal = normal / n
- # Orient normal to point toward the camera origin: the signed distance of
- # the origin from the plane must be positive, i.e. d = -n·c > 0 → n·c < 0.
- # Flipping only on normal[2] is wrong for tilted planes (the camera may
- # not be above the plane in Z-only sense).
- if float(normal @ c) > 0:
- normal = -normal
- offset = -float(normal @ c)
- signed = centered @ normal
- rms_mm = float(np.sqrt(np.mean(signed * signed)) * 1000.0)
- return normal, offset, c, rms_mm
-
-
-def project_point_on_plane(P: np.ndarray, normal: np.ndarray, offset: float
- ) -> tuple[np.ndarray, float]:
- """Perpendicular projection of a 3D point onto the plane.
- Returns (foot, signed_distance). Signed distance > 0 means P is on the
- side of the plane the normal points toward (camera side in our convention).
- """
- s = float(normal @ P + offset)
- return P - s * normal, s
-
-
-def backproject_pixel_on_plane(uv: np.ndarray, K: np.ndarray,
- normal: np.ndarray, offset: float
- ) -> np.ndarray:
- """Intersect the viewing ray through pixel (u,v) with the work plane."""
- r = np.linalg.inv(K) @ np.array([float(uv[0]), float(uv[1]), 1.0])
- denom = float(normal @ r)
- if abs(denom) < 1e-9:
- raise ServoError("ray parallel to work plane")
- t = -offset / denom
- if t <= 0:
- raise ServoError("plane intersection is behind the camera")
- return t * r
-
-
-@dataclass
-class ServoPlan:
- # Truth (from G1)
- wrist_position_base: list[float]
- wrist_quaternion_base: list[float]
-
- # Camera-frame observations
- marker_position_cam: list[float]
- object_position_cam: list[float]
-
- # Pixel-space positions for the 2D overlay (u, v in image coordinates).
- marker_pixel: list[float]
- object_pixel: list[float]
-
- # Perpendicular depth to the camera plane (Z component, m). This is what
- # drives the pinhole mm/px scale — NOT the slant range.
- marker_depth_z_m: float
- object_depth_z_m: float
- # Euclidean distance camera origin → point (slant range, m).
- marker_distance_cam_m: float
- object_distance_cam_m: float
-
- # Pinhole scale at the point's depth: mm-per-pixel = (z_mm / fx), i.e.
- # physical size of one image pixel at that depth. Depends on Z only, so
- # two points at the same Z but different XY will share mm/px.
- marker_mm_per_px: float
- object_mm_per_px: float
-
- # Approximate base-frame positions for visualisation only. Computed with
- # T_wrist_marker = identity: relative distances are correct, absolute
- # positions are offset by the unknown marker-to-wrist translation.
- marker_position_base_est: list[float]
- object_position_base_est: list[float]
- T_base_camera_est: list[list[float]]
-
- # Scalar cues
- distance_m: float # camera-frame magnitude of marker→object
- done: bool # True when distance ≤ min_distance_m
-
- # Next wrist target in base frame (None when done)
- target_position_base: list[float] | None
- target_quaternion_base: list[float] | None
- planned_step_m: float # how much the wrist is about to move (base frame)
-
- # Work-plane projection block — present only when a plane has been fitted.
- # Everything camera-frame. `in_plane_distance_m` is the Euclidean distance
- # between the two foot-of-perpendicular points (lives on the plane).
- plane: dict[str, Any] | None = None
-
- # Diagnostics
- detection: dict[str, Any] | None = None
- n_object_points: int = 0
- marker_reprojection_error_px: float | None = None
- marker_translation_source: str = "solvepnp" # "nuwa_depth" | "solvepnp"
- object_point_source: str = "centroid" # "centroid" | "highest_above_plane(...)"
- reason: str | None = None
-
-
-def _resolve_wrist_pose(g1: G1Client, side: str) -> tuple[np.ndarray, np.ndarray, list[float]]:
- w = g1.wrist()
- body = w.get("data") if isinstance(w.get("data"), dict) else w
- pose = body.get(f"{side}_wrist") or body.get(side)
- if pose is None or "position" not in pose or "quaternion" not in pose:
- raise ServoError(f"G1 /wrist missing '{side}' pose: {w!r}")
- pos = np.asarray(pose["position"], dtype=np.float64)
- quat = np.asarray(pose["quaternion"], dtype=np.float64)
- return pos, quat, list(map(float, pose["quaternion"]))
-
-
-def _detect_marker_cam(nuwa: NuwaClient, marker: MarkerClient,
- marker_id: int, size_mm: float
- ) -> tuple[np.ndarray, np.ndarray, np.ndarray, float | None, str]:
- """Return (R_cam_marker, t_cam_marker, corners_px, reprojection_error_px, source).
-
- Translation comes from the Nuwa depth at the marker centre pixel whenever
- possible — much more stable than solvePnP on a small planar marker, which
- suffers from the IPPE_SQUARE planar ambiguity (two solutions, same
- reprojection error) at ~1.4 m distance. Rotation still comes from solvePnP
- because the servo mapping cam→base needs it.
-
- `source` reports how `t_cam_marker` was obtained: "nuwa_depth" when the
- depth-sensor value at the marker pixel was usable, "solvepnp" otherwise.
- """
- result = marker.detect_url(
- markers=[{"id": int(marker_id), "size_mm": float(size_mm)}],
- image_url=nuwa.rgb_url(),
- )
- found = result.get("found", [])
- entry = next((m for m in found if int(m["id"]) == int(marker_id)), None)
- if entry is None:
- raise ServoError(f"marker id={marker_id} not detected (not_found={result.get('not_found')})")
- corners = np.asarray(entry["corners"], dtype=np.float64)
-
- R_cm = None
- t_pnp = None
- reproj_err: float | None = None
- try:
- intr = nuwa.intrinsics()
- K, dist = nuwa_intrinsics_to_matrix(intr)
- sol = solve_square_marker(corners, float(size_mm) / 1000.0, K, dist)
- R_cm = sol["R"]
- t_pnp = sol["t"].flatten()
- reproj_err = float(sol["reprojection_error_px"])
- except Exception as e:
- log.warning("local pnp failed (%s) — using marker-finder orientation", e)
- R_cm = np.asarray(entry["rotation_matrix"], dtype=np.float64).reshape(3, 3)
- t_pnp = np.asarray(entry["position_m"], dtype=np.float64).flatten()
-
- # Prefer Nuwa depth at the marker centre pixel for the translation.
- centre = corners.mean(axis=0)
- u = int(round(float(centre[0])))
- v = int(round(float(centre[1])))
- t_depth = None
- try:
- m = nuwa.measure(u, v, radius=8)
- pt = m.get("point_m") if isinstance(m, dict) else None
- if not isinstance(pt, dict):
- pt = m
- x = float(pt.get("x", pt.get("X")))
- y = float(pt.get("y", pt.get("Y")))
- z = float(pt.get("z", pt.get("Z")))
- # /api/measure returns already-OpenCV-frame coords (Z>0 in front of
- # the camera). No flip.
- t_depth = np.array([x, y, z], dtype=np.float64)
- if not np.all(np.isfinite(t_depth)) or abs(t_depth[2]) < 1e-3:
- t_depth = None
- except Exception as e:
- log.warning("nuwa.measure failed at (%d,%d): %s", u, v, e)
- t_depth = None
-
- if t_depth is not None:
- return R_cm, t_depth, corners, reproj_err, "nuwa_depth"
- return R_cm, t_pnp, corners, reproj_err, "solvepnp"
-
-
-def _detect_object_point_cam(nuwa: NuwaClient, yolo: YoloClient,
- conf: float | None, class_name: str | None,
- pointcloud_step: int, pointcloud_max_depth_mm: int,
- work_plane: WorkPlane | None,
- top_fraction: float = 0.05,
- ) -> tuple[np.ndarray, dict[str, Any], int, str]:
- """Return (point_cam_xyz, top_detection, n_points, source).
-
- When a work plane is available, returns the mean of the top `top_fraction`
- of points (by signed distance from the plane) — this is the highest part
- of the object above the work surface, which is the natural grasp target.
- Without a plane, falls back to the centroid of all filtered points."""
- detector = ObjectDetector(yolo, nuwa)
- dets = detector.detect(conf=conf)
- if class_name:
- dets = [d for d in dets if (d.get("class") or "").lower() == class_name.lower()]
- if not dets:
- raise ServoError("no object detected" + (f" for class '{class_name}'" if class_name else ""))
- dets.sort(key=lambda d: d["confidence"], reverse=True)
- top = dets[0]
- b = top["bbox"]
- u_min = max(0, int(np.floor(b["x1"])))
- v_min = max(0, int(np.floor(b["y1"])))
- u_max = int(np.ceil(b["x2"]))
- v_max = int(np.ceil(b["y2"]))
- pc, _meta = nuwa.pointcloud_roi(u_min, v_min, u_max, v_max,
- step=pointcloud_step,
- max_depth_mm=pointcloud_max_depth_mm)
- # Empirical: Nuwa ROI points come in Nuwa-native frame (Z<0 in front of
- # camera) despite the docstring. Flip to true OpenCV frame so comparisons
- # with the plane (also CV) are consistent.
- cropped = filter_outliers(pc)
- if len(cropped) < 10:
- raise ServoError(f"too few valid depth points in bbox: {len(cropped)}")
- pts_cv = nuwa_to_cv_frame(cropped[:, :3])
- if work_plane is None:
- return pts_cv.mean(axis=0), top, int(len(pts_cv)), "centroid"
- n_vec = np.asarray(work_plane.normal, dtype=np.float64)
- d_off = float(work_plane.offset)
- signed = pts_cv @ n_vec + d_off # Nx (positive = above plane)
- k = max(1, int(round(float(top_fraction) * len(pts_cv))))
- idx = np.argpartition(signed, -k)[-k:]
- highest = pts_cv[idx].mean(axis=0)
- return highest, top, int(len(pts_cv)), f"highest_above_plane(top {k}/{len(pts_cv)})"
-
-
-def _r_wrist_marker_from_config(cfg: dict[str, Any]) -> np.ndarray:
- """Rotation part of the marker-on-wrist rigid offset. Translation does not
- matter for camera-frame delta servo. Read from
- `grasp.visual_servo.wrist_marker_rvec` as a 3-vector (Rodrigues), default
- identity."""
- vs = cfg.get("grasp", {}).get("visual_servo", {}) or {}
- rvec = vs.get("wrist_marker_rvec") or [0.0, 0.0, 0.0]
- return rvec_to_R(np.asarray(rvec, dtype=np.float64))
-
-
-def plan(*, side: str, marker_id: int, size_mm: float,
- conf: float | None, class_name: str | None,
- min_distance_m: float, step_fraction: float,
- pointcloud_step: int, pointcloud_max_depth_mm: int,
- cfg: dict[str, Any],
- nuwa: NuwaClient, marker: MarkerClient, yolo: YoloClient, g1: G1Client,
- ) -> ServoPlan:
- # 1) Wrist pose in base
- wrist_pos, wrist_quat, wrist_quat_list = _resolve_wrist_pose(g1, side)
- R_bw = quat_xyzw_to_R(wrist_quat)
-
- # 2) Marker pose in camera
- R_cm, t_cm, corners_px, reproj_err, marker_t_source = _detect_marker_cam(
- nuwa, marker, marker_id, size_mm,
- )
- marker_pixel = corners_px.mean(axis=0) # centre of the 4 corners
- # Intrinsics for mm/px scale at each point's depth.
- try:
- intr = nuwa.intrinsics()
- K, _ = nuwa_intrinsics_to_matrix(intr)
- fx = float(K[0, 0])
- except Exception as e:
- log.warning("intrinsics unavailable for mm/px (%s)", e)
- fx = float("nan")
-
- # 3) Object reference point in camera: highest-above-plane if a plane is
- # set, otherwise centroid of the bbox pointcloud.
- wp_here = get_work_plane()
- centroid_cam, detection, n_pts, object_point_source = _detect_object_point_cam(
- nuwa, yolo, conf, class_name,
- pointcloud_step, pointcloud_max_depth_mm,
- work_plane=wp_here,
- )
- b = detection["bbox"]
- object_pixel = np.array([0.5 * (b["x1"] + b["x2"]), 0.5 * (b["y1"] + b["y2"])])
-
- # 4) Rotation mapping cam→base (identity marker-to-wrist rotation by default)
- R_wm = _r_wrist_marker_from_config(cfg)
- R_base_cam = R_bw @ R_wm @ np.linalg.inv(R_cm)
-
- # 5) Camera-frame vector & distance (marker → object)
- d_cam = centroid_cam - t_cm
- distance_m = float(np.linalg.norm(d_cam))
- done = distance_m <= float(min_distance_m)
-
- # 6) Estimate T_base_camera for visualisation (identity T_wrist_marker):
- # translation part is off by the real marker-to-wrist translation, but
- # relative positions of marker and object in base frame are preserved.
- T_bc_est = np.eye(4)
- T_bc_est[:3, :3] = R_base_cam
- T_bw_full = T_from_pos_quat(wrist_pos, wrist_quat)
- T_wm_identity = np.eye(4) # translation part left at zero
- T_wm_identity[:3, :3] = R_wm
- T_cm_full = make_T(R_cm, t_cm)
- T_bc_est = T_bw_full @ T_wm_identity @ invert_T(T_cm_full)
-
- marker_base_est = (T_bc_est @ np.array([*t_cm, 1.0]))[:3]
- object_base_est = (T_bc_est @ np.array([*centroid_cam, 1.0]))[:3]
-
- # 7) Plan step
- target_pos = None
- target_quat = None
- planned_step_m = 0.0
- reason = None
- if done:
- reason = f"distance {distance_m*1000:.1f} mm ≤ {min_distance_m*1000:.0f} mm — reached"
- else:
- step_cam = d_cam * float(step_fraction)
- step_len = float(np.linalg.norm(step_cam))
- # Don't overshoot below min_distance_m
- if distance_m - step_len < float(min_distance_m):
- step_cam = d_cam * ((distance_m - float(min_distance_m)) / distance_m)
- step_len = float(np.linalg.norm(step_cam))
- reason = "clamped to stop at min_distance_m"
- step_base = R_base_cam @ step_cam
- target_pos = wrist_pos + step_base
- target_quat = wrist_quat_list
- planned_step_m = float(np.linalg.norm(step_base))
-
- # 8) Work-plane projection (optional)
- plane_block = None
- wp = get_work_plane()
- if wp is not None:
- try:
- K_plane, _ = nuwa_intrinsics_to_matrix(nuwa.intrinsics())
- n_vec = np.asarray(wp.normal, dtype=np.float64)
- d_off = float(wp.offset)
- # Perpendicular foot of the measured 3D points.
- m_foot, m_signed = project_point_on_plane(np.asarray(t_cm), n_vec, d_off)
- o_foot, o_signed = project_point_on_plane(np.asarray(centroid_cam), n_vec, d_off)
- # Back-projected pixel hits on the plane (trust pixel + depth plane,
- # ignoring the measured Z of each point — more stable when depth is
- # noisy, at the cost of assuming the point actually lies on the
- # plane).
- m_hit = backproject_pixel_on_plane(np.asarray(marker_pixel), K_plane, n_vec, d_off)
- o_hit = backproject_pixel_on_plane(np.asarray(object_pixel), K_plane, n_vec, d_off)
- plane_block = {
- "normal": wp.normal,
- "offset": wp.offset,
- "fit_rms_mm": wp.fit_rms_mm,
- "roi": wp.roi,
- "marker_foot_cam": [float(x) for x in m_foot],
- "object_foot_cam": [float(x) for x in o_foot],
- "marker_signed_distance_m": float(m_signed),
- "object_signed_distance_m": float(o_signed),
- "marker_pixel_hit_cam": [float(x) for x in m_hit],
- "object_pixel_hit_cam": [float(x) for x in o_hit],
- "in_plane_distance_m": float(np.linalg.norm(o_foot - m_foot)),
- "in_plane_pixel_distance_m": float(np.linalg.norm(o_hit - m_hit)),
- }
- except Exception as e:
- log.warning("plane projection failed: %s", e)
-
- return ServoPlan(
- wrist_position_base=[float(x) for x in wrist_pos],
- wrist_quaternion_base=wrist_quat_list,
- marker_position_cam=[float(x) for x in t_cm],
- object_position_cam=[float(x) for x in centroid_cam],
- marker_pixel=[float(x) for x in marker_pixel],
- object_pixel=[float(x) for x in object_pixel],
- marker_depth_z_m=float(abs(t_cm[2])),
- object_depth_z_m=float(abs(centroid_cam[2])),
- marker_distance_cam_m=float(np.linalg.norm(t_cm)),
- object_distance_cam_m=float(np.linalg.norm(centroid_cam)),
- marker_mm_per_px=float(abs(t_cm[2]) * 1000.0 / fx) if fx == fx and fx > 0 else 0.0,
- object_mm_per_px=float(abs(centroid_cam[2]) * 1000.0 / fx) if fx == fx and fx > 0 else 0.0,
- marker_position_base_est=[float(x) for x in marker_base_est],
- object_position_base_est=[float(x) for x in object_base_est],
- T_base_camera_est=T_bc_est.tolist(),
- distance_m=distance_m,
- done=done,
- target_position_base=[float(x) for x in target_pos] if target_pos is not None else None,
- target_quaternion_base=target_quat,
- planned_step_m=planned_step_m,
- plane=plane_block,
- detection=detection,
- n_object_points=n_pts,
- marker_reprojection_error_px=reproj_err,
- marker_translation_source=marker_t_source,
- object_point_source=object_point_source,
- reason=reason,
- )
diff --git a/src/presa_mano/main.py b/src/presa_mano/main.py
index 3fcbd97..6be1893 100644
--- a/src/presa_mano/main.py
+++ b/src/presa_mano/main.py
@@ -103,7 +103,7 @@ def index() -> HTMLResponse:
idx = WEB_DIR / "index.html"
html = idx.read_text(encoding="utf-8")
# Cache-bust app.js and style.css with their mtime so a rebuild forces refetch.
- for name in ("app.js", "servo.js", "style.css"):
+ for name in ("app.js", "work_plane.js", "style.css"):
f = WEB_DIR / name
if f.exists():
v = int(f.stat().st_mtime)
diff --git a/web/index.html b/web/index.html
index 20a6fb1..1ee5cb1 100644
--- a/web/index.html
+++ b/web/index.html
@@ -14,7 +14,6 @@
-
@@ -234,87 +233,6 @@
-
-
-
- —
-
-
-
-
-
-
-
-
-
-
RGB live · ciano = marker · giallo = oggetto · wheel zoom · drag pan · doppio click reset
-
Vista 3D (frame camera)
-
-
-
-
origine = camera · piano viola · marker ciano · oggetto giallo · proiezioni più chiare · drag ruota · wheel zoom
-
-
marker polso
-
oggetto (centro bbox)
-
-
-
-
Parametri
-
-
- Avvicinamento marker-relativo senza hand-eye calibration: la camera vede marker e oggetto nello stesso frame, ogni step sposta il polso della frazione indicata della distanza rimanente. Si ferma a min_distance_m.
-
-
-
-
-
-
-
-
-
-
-
- Orientazione marker rispetto al polso (rvec in gradi). Se non allineato al frame polso il target risulta spostato: tuna finché marker, oggetto e target sono coerenti.
-
-
-
-
-
-
-
-
-
-
-
-
-
- Piano di lavoro. Clicca "Seleziona area", poi traccia un rettangolo sull'immagine con shift+drag: il backend calcola il piano dalla depth in quell'area. Marker e oggetto vengono poi proiettati su questo piano.
-