Files
ros_contorl/scripts/sense_tool_length.py
T
2026-10-09 14:51:03 +08:00

310 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""真机标定 `tool_tip_below_flange_m`:**规尖**在法兰下方多远 —— 视觉感知。
为什么需要单独标定
------------------
规是**夹在爪里**的,露出多少随装夹变化(仿真上料脚本实测见过 243 / 254mm 两种)。
而真机上**没有"规尖"的 TF**:规只是被夹住的一个零件,TF 树里查不到它。所以只能
**感知**:腕部相机看到规,用 `T_base = T_base_flange · T_flange_cam` 把手眼链
反投影回基座,量「法兰 z − 规尖 z」。
这个值一旦偏小,下游几何就会把规按进工件(site.yaml 记着 2026-09-29 的事故:
悬停这一步就把规插进工件,S05 横向对准变成拿规尖拨工件,工件被推走 19mm)。
口径(必须显式)
----------------
* 量的是**竖直**距离(基座 z 之差),不是相机光轴方向的深度 —— 两者因相机
29.7° 的倾角不同,混用会差 20mm 量级。
* 只在法兰近似**竖直朝下**时成立(悬停/跟踪都是该姿态)。
自证(不许"看起来差不多")
--------------------------
先把**当场**反投影出来的**工件上表面** z 与 site.yaml 记录的
`workpiece_surface_z_m` 比:差 > `--tol-mm`(默认 5mm)就判链路/手眼/深度有
系统性问题并**拒绝输出**。这一步用的是独立已知量,能真的失败。
用法
----
bash ~/ros2/scripts/run_real.sh --arm 89 # 另开终端:起控制栈
bash ~/ros2/scripts/run_camera_d405.sh --arm 89 # 起相机
# 把臂开到**悬停高**(stage2 的 hole_hover_z_m,竖直朝下、规在视野里)
python3 ~/ros2/scripts/sense_tool_length.py --sn Rizon4-063989
输出一行 `**tool_tip_below_flange_m: 0.xxx**` 与它是否通过自证。
"""
from __future__ import annotations
import argparse
import sys
import time
from pathlib import Path
import numpy as np
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts" / "lib"))
def _load_frame(sn: str, handeye_file: Path):
"""读手眼 T_flange_cam(eye-in-hand)。缺文件直接失败。"""
import yaml
doc = yaml.safe_load(handeye_file.read_text(encoding="utf-8")) or {}
if "T_4x4" not in doc:
raise SystemExit(f"手眼文件 {handeye_file} 里没有 T_4x4(键:{sorted(doc)})")
return np.array(doc["T_4x4"], dtype=float)
class _Grabber:
"""抓彩色/对齐深度/内参 + 查 TF。放在类里是为了让订阅和 TF 监听一起 spin。"""
def __init__(self, cam_ns: str):
import rclpy
from rclpy.node import Node
from rclpy.qos import QoSProfile, ReliabilityPolicy
from sensor_msgs.msg import CameraInfo, Image
import tf2_ros
self.rclpy = rclpy
self.node = Node("sense_tool_length")
self.color = self.depth = self.info = None
qos = QoSProfile(depth=5, reliability=ReliabilityPolicy.BEST_EFFORT)
ns = cam_ns.strip("/")
self.node.create_subscription(Image, f"/{ns}/color/image_raw",
lambda m: setattr(self, "color", m), qos)
self.node.create_subscription(Image, f"/{ns}/aligned_depth_to_color/image_raw",
lambda m: setattr(self, "depth", m), qos)
self.node.create_subscription(CameraInfo, f"/{ns}/color/camera_info",
lambda m: setattr(self, "info", m), qos)
self.buf = tf2_ros.Buffer()
self.lis = tf2_ros.TransformListener(self.buf, self.node)
def spin(self, dt: float = 0.05) -> None:
self.rclpy.spin_once(self.node, timeout_sec=dt)
def wait_frames(self, timeout_s: float = 8.0) -> bool:
t0 = time.monotonic()
while time.monotonic() - t0 < timeout_s:
self.spin()
if self.color is not None and self.depth is not None and self.info is not None:
return True
return False
def need_fresh(self, timeout_s: float = 4.0) -> bool:
t0 = time.monotonic()
while time.monotonic() - t0 < timeout_s:
self.spin()
if self.color is None or self.depth is None:
continue
now = self.node.get_clock().now().nanoseconds * 1e-9
def age(m):
return now - m.header.stamp.sec - m.header.stamp.nanosec * 1e-9
if age(self.color) < 1.0 and age(self.depth) < 1.0:
return True
return False
def T(self, parent: str, child: str, timeout_s: float = 3.0):
t0 = time.monotonic()
while time.monotonic() - t0 < timeout_s:
self.spin()
try:
tr = self.buf.lookup_transform(parent, child, self.rclpy.time.Time())
except Exception:
continue
t, r = tr.transform.translation, tr.transform.rotation
x, y, z, w = r.x, r.y, r.z, r.w
M = np.eye(4)
M[:3, :3] = [
[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)],
]
M[:3, 3] = [t.x, t.y, t.z]
return M
return None
def _depth_m(msg) -> np.ndarray:
if (msg.encoding or "").lower() != "16uc1":
raise SystemExit(f"深度编码应为 16UC1(mm),实际 {msg.encoding!r}")
return (np.frombuffer(msg.data, dtype=np.uint16)
.reshape(int(msg.height), int(msg.width)).astype(np.float32) * 1e-3)
def _near_tip(depth: np.ndarray, near_m: float, min_m: float, max_m: float):
"""近端连通域里"最深"的像素 = 规尖候选。
为什么用连通域而不是全图最深处:工件面/台面比规更远,全图最深处会落到工件上;
规是**唯一**贴着相机(< near_m)的物体,所以先在近端找块,再取块内最深点。
"""
import cv2
valid = (depth > min_m) & (depth < max_m)
near = (valid & (depth < near_m)).astype(np.uint8)
if int(near.sum()) < 150:
return None
n, lab, stats, _ = cv2.connectedComponentsWithStats(near, 8)
if n <= 1:
return None
big = max(range(1, n), key=lambda i: stats[i, 4])
ys, xs = np.where(lab == big)
dv = depth[ys, xs]
k = np.percentile(dv, 97) # 块内最深 3% ≈ 规尖
sel = dv >= k - 1e-6
ty = int(round(float(np.median(ys[sel]))))
tx = int(round(float(np.median(xs[sel]))))
return tx, ty
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description="真机视觉标定 tool_tip_below_flange_m")
ap.add_argument("--sn", default="", help="机械臂序列号(默认取 site.yaml robot.sn)")
ap.add_argument("--site", default=str(ROOT / "config" / "site.yaml"))
ap.add_argument("--frames", type=int, default=8, help="取多少帧求中值")
ap.add_argument("--near-m", type=float, default=0.15, help="近端阈值:规比这更近 [m]")
ap.add_argument("--min-m", type=float, default=0.04)
ap.add_argument("--max-m", type=float, default=0.60)
ap.add_argument("--tol-mm", type=float, default=5.0,
help="反投影出的工件面 z 与 site.yaml 记录值的最大允许差 [mm]")
ap.add_argument("--save", default="", help="把带标记的彩图写到这个路径(可选)")
args = ap.parse_args(argv)
import export_site_env as ex
data = ex.load_site(Path(args.site))
sn = (args.sn or "").strip() or str((data.get("robot") or {}).get("sn") or "").strip()
if not sn:
raise SystemExit("没给 --sn,且 site.yaml 的 robot.sn 为空")
merged, robot = ex.apply_active_arm(data, sn)
cam_ns = ex.camera_ns(merged)
base = str(merged.get("servo", {}).get("base_frame") or f"{sn}_base_link")
flange = f"{sn}_flange"
he_file = Path(str((merged.get("paths") or {}).get("handeye_file") or ""))
if not he_file.is_file():
raise SystemExit(f"手眼文件不存在:{he_file}")
gp = (merged.get("gauge_assembly") or {})
surface_ref = float(gp.get("workpiece_surface_z_m") or 0.0)
cur = float(gp.get("tool_tip_below_flange_m") or 0.0)
print(f"=== 真机工具长标定 SN={sn} cam=/{cam_ns.strip('/')} ===")
print(f" 基座帧={base} 法兰帧={flange}")
print(f" 手眼 ={he_file}")
print(f" site 现值 tool_tip_below_flange_m={cur:.4f} 工件面={surface_ref:.4f}")
import rclpy
rclpy.init()
try:
g = _Grabber(cam_ns)
except Exception as exc: # noqa: BLE001
print(f"节点初始化失败:{exc}", file=sys.stderr)
return 2
try:
if not g.wait_frames(10.0):
print("错误:没收到相机帧。先起相机:bash scripts/run_camera_d405.sh --arm <臂>",
file=sys.stderr)
return 1
T_flange_cam = _load_frame(sn, he_file)
tips, flange_zs, surf = [], [], []
for i in range(max(1, args.frames)):
if not g.need_fresh():
continue
dep = _depth_m(g.depth)
k = g.info.k
fx, fy, cx, cy = float(k[0]), float(k[4]), float(k[2]), float(k[5])
T_bf = g.T(base, flange)
if T_bf is None:
print("错误:TF 查不到 base→flange(控制栈起了吗?)", file=sys.stderr)
return 1
T_bc = T_bf @ T_flange_cam
got = _near_tip(dep, args.near_m, args.min_m, args.max_m)
if got is None:
continue
ux, vy = got
# 尖端深度取 5x5 中值抑噪;再用整条链反投到基座
y0, y1 = max(0, vy - 2), min(dep.shape[0], vy + 3)
x0, x1 = max(0, ux - 2), min(dep.shape[1], ux + 3)
patch = dep[y0:y1, x0:x1]
pv = patch[(patch > args.min_m) & (patch < args.max_m)]
if pv.size == 0:
continue
zt = float(np.median(pv))
p_cam = np.array([(ux - cx) * zt / fx, (vy - cy) * zt / fy, zt, 1.0])
tip_base = (T_bc @ p_cam)[:3]
tips.append(float(T_bf[2, 3]) - float(tip_base[2]))
flange_zs.append(float(T_bf[2, 3]))
# 工件面:取画面下部中央一块(工件/台面),用同一链反投影
h, w = dep.shape
band = dep[int(h * 0.80):int(h * 0.95), int(w * 0.30):int(w * 0.70)]
bv = band[(band > args.min_m) & (band < args.max_m)]
if bv.size > 50:
zb = float(np.median(bv))
bu, bvv = int(w * 0.50), int(h * 0.88)
pb = (T_bc @ np.array([(bu - cx) * zb / fx, (bvv - cy) * zb / fy, zb, 1.0]))[:3]
surf.append(float(pb[2]))
if args.save and args.frames == 1:
try:
import cv2
bgr = np.frombuffer(g.color.data, np.uint8).reshape(
int(g.color.height), int(g.color.width), 3).copy()
cv2.circle(bgr, (ux, vy), 8, (0, 0, 255), 2)
cv2.imwrite(args.save, bgr)
except Exception:
pass
g.spin(0.05)
if not tips:
print("错误:没找到规(近端 < %.0fmm 的物体)。确认规夹在爪里、且臂在竖直朝下姿态。"
% (args.near_m * 1000), file=sys.stderr)
return 1
tl = float(np.median(tips))
spread = (max(tips) - min(tips)) * 1000.0
fz = float(np.median(flange_zs))
print(f"\n 帧数={len(tips)} 法兰 z={fz:.4f}")
print(f" 感知工具长 = **{tl:.4f} m** (逐帧散布 {spread:.1f}mm)")
ok = True
tip_z = fz - tl
if surf:
zs = float(np.median(surf))
# 画面下部那块可能是**工件面**(site 记录 surface_ref)也可能是**台面**
# (真机基座与台面同高 ⇒ ≈0)。两者都是**独立已知量**,用它们卡链路:
# 只要反投影出的 z 落在允许窗内,就说明手眼/深度/姿态没有系统性问题。
lo = min(0.0, surface_ref) - 0.03
hi = max(0.0, surface_ref) + 0.04
tag = "OK" if lo <= zs <= hi else "FAIL"
print(f" 自证 反投影支撑面 z={zs:.4f}"
f"(site 工件面 {surface_ref:.4f}、台面≈0)允许 [{lo:+.3f},{hi:+.3f}] → [{tag}]")
if not (lo <= zs <= hi):
ok = False
if not (tip_z > zs + 0.003):
print(f" 自证:规尖 z={tip_z:.4f} 不在支撑面 z={zs:.4f} 之上 → FAIL")
ok = False
else:
print(" 自证:画面下部没采到有效深度,跳过(不敢保证链路)")
if not (0.10 <= tl <= 0.35):
print(f" 自证:工具长 {tl:.3f} 不在合理区间 [0.10, 0.35] → FAIL")
ok = False
if not ok:
print("\n ✗ 自证未过 —— **不要**把这个值写进 site.yaml,先查手眼/深度/姿态。")
return 4
print(f"\n ✓ 可写:gauge_assembly.tool_tip_below_flange_m: {tl:.3f}"
f"(现值 {cur:.4f},差 {(tl - cur) * 1000:+.1f}mm)")
return 0
finally:
try:
g.rclpy.shutdown()
except Exception:
pass
if __name__ == "__main__":
raise SystemExit(main())