克隆下来必须带上正在用的视觉权重和本机 site.yaml,否则感知只能指到一台机器上的路径,别人起不来。

顶视迁臂改走厂家 MoveL,D405 框心对准后再对孔;夹爪停在 caught 时 open 不再把相同目标当成已经张开,init 会真的回零。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
arm_dev
2026-10-12 17:08:43 +08:00
parent b903161113
commit f882c4309c
187 changed files with 22898 additions and 104 deletions
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""拍一张顶视 D435,把 k0 用眼在手外标定变到左臂基座。
stdout 只打一行,给 run_all.sh --d435-only 接厂家 MoveL:
K0_BASE x y z
x y z 单位米,坐标系是这条臂的基座(与 RDK 的 WORLD 同一套)。
变换就是 handeye/results/<臂>_d435_eye_to_hand.yaml 的 T_4x4:
p_基座 = T_cam_to_base · p_相机
k0 的 z 是工件表面,不是工具尖该去的高度。运动脚本会保持当前尖高,只用这里的 xy。
没有可信 k0 就退出,不用检测框中心冒充。
"""
from __future__ import annotations
import sys
import time
from pathlib import Path
import numpy as np
import rclpy
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from sensor_msgs.msg import CameraInfo, Image
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts" / "lib"))
from export_site_env import load_site, perception_overrides # noqa: E402
from arm_application.geom_ik import load_handeye_T # noqa: E402
from arm_application.overhead_oneshot import ( # noqa: E402
depth_raw_to_m,
oneshot_k0_base,
select_oneshot_det,
)
from arm_application.perception_node import _bgr_copy # noqa: E402
from arm_application.yolo_detector import build_detector # noqa: E402
def _decode_depth(msg: Image):
w, h = int(msg.width), int(msg.height)
enc = (msg.encoding or "").lower()
if enc in ("16uc1", "mono16"):
raw = np.frombuffer(msg.data, dtype=np.uint16)
if raw.size < w * h:
return None
return raw[: w * h].reshape(h, w)
if enc == "32fc1":
raw = np.frombuffer(msg.data, dtype=np.float32)
if raw.size < w * h:
return None
return raw[: w * h].reshape(h, w)
return None
class OneShot(Node):
def __init__(self, topics: dict) -> None:
super().__init__("d435_k0_once")
self.color = None
self.depth = None
self.info = None
self.create_subscription(Image, topics["image"], self._on_color, qos_profile_sensor_data)
self.create_subscription(Image, topics["depth"], self._on_depth, qos_profile_sensor_data)
self.create_subscription(CameraInfo, topics["info"], self._on_info, qos_profile_sensor_data)
def _on_color(self, msg: Image) -> None:
self.color = msg
def _on_depth(self, msg: Image) -> None:
self.depth = msg
def _on_info(self, msg: CameraInfo) -> None:
self.info = msg
def ready(self) -> bool:
return self.color is not None and self.depth is not None and self.info is not None
def main() -> int:
perc = perception_overrides(load_site())
if not perc.get("overhead_oneshot_enable"):
print("site 没有接上 D435 一次性(overhead_oneshot_enable)", file=sys.stderr)
return 2
handeye = str(perc.get("overhead_handeye_file") or "")
topics = {
"image": str(perc.get("overhead_image_topic") or ""),
"depth": str(perc.get("overhead_depth_topic") or ""),
"info": str(perc.get("overhead_info_topic") or ""),
}
if not handeye or not all(topics.values()):
print(f"眼在手外话题或标定文件没接上:{handeye} {topics}", file=sys.stderr)
return 2
T = load_handeye_T(handeye)
print(f"手眼 {handeye}", file=sys.stderr)
print(f"图 {topics['image']}", file=sys.stderr)
weights = str(perc.get("weights") or "")
detector = build_detector(
weights,
backend=str(perc.get("backend") or "auto"),
conf=float(perc.get("conf") or 0.1),
iou=float(perc.get("iou") or 0.45),
device=str(perc.get("device") or "0"),
imgsz=int(perc.get("imgsz") or 640),
)
rclpy.init()
node = OneShot(topics)
t0 = time.monotonic()
try:
while rclpy.ok() and not node.ready():
rclpy.spin_once(node, timeout_sec=0.2)
if time.monotonic() - t0 > 15.0:
print("15s 内没有凑齐彩色图、深度、内参", file=sys.stderr)
return 3
color, depth, info = node.color, node.depth, node.info
finally:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
bgr = _bgr_copy(color)
dets = detector.detect(bgr)
min_conf = float(perc["overhead_oneshot_conf"])
det = select_oneshot_det(dets, min_conf)
if det is None:
print(f"画面里没有 conf≥{min_conf:.2f} 的工件", file=sys.stderr)
return 4
raw = _decode_depth(depth)
depth_m = depth_raw_to_m(raw, depth.encoding or "")
if depth_m is None:
print("深度无法转成米", file=sys.stderr)
return 4
k = info.k
got = oneshot_k0_base(
det,
depth_m,
fx=float(k[0]),
fy=float(k[4]),
cx=float(k[2]),
cy=float(k[5]),
T_cam_to_base=T,
min_z_m=float(perc["overhead_min_depth_m"]),
max_z_m=float(perc["overhead_max_depth_m"]),
kpt_index=int(perc.get("keypoint_index") or 0),
kpt_min_conf=float(perc.get("keypoint_min_conf") or 0.30),
min_det_conf=min_conf,
color_wh=(int(color.width), int(color.height)),
depth_wh=(int(depth_m.shape[1]), int(depth_m.shape[0])),
)
if got is None:
print("k0 或深度不可用,不发点", file=sys.stderr)
return 4
p_base, is_k0 = got
if not is_k0:
print(
f"顶视没有可信 k0,只有框心 ({p_base[0]:.3f},{p_base[1]:.3f},{p_base[2]:.3f}),拒绝",
file=sys.stderr,
)
return 4
print(
f"k0 基座 ({p_base[0]:.4f}, {p_base[1]:.4f}, {p_base[2]:.4f}) m conf={float(det.conf):.2f}",
file=sys.stderr,
)
print(f"K0_BASE {p_base[0]:.6f} {p_base[1]:.6f} {p_base[2]:.6f}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+8 -1
View File
@@ -903,6 +903,7 @@ def servo_overrides(data: dict) -> dict:
"ee_hold_kp", "ee_w_max",
"lock_hold_sec",
"lock_still_sec",
"wrist_align_sec",
"lock_still_move_m",
"a_max", "max_qdot", "damping",
"vel_lpf_alpha", "dq_window_size", "traj_stream_hz", "exec_horizon_sec",
@@ -934,7 +935,12 @@ def servo_overrides(data: dict) -> dict:
if "lock_still_sec" not in servo:
raise RuntimeError(
"servo.lock_still_sec 必须显式写"
"(D405 跟住后法兰静止多久才进二阶段)。不许用代码默认 3。"
"(D435 到位后法兰停稳多久才交回 D405)。不许用代码默认 3。"
)
if "wrist_align_sec" not in servo:
raise RuntimeError(
"servo.wrist_align_sec 必须显式写"
"(D405 框心对准多久才进对孔)。不许用代码默认 1。"
)
if "lock_still_move_m" not in servo:
raise RuntimeError(
@@ -942,6 +948,7 @@ def servo_overrides(data: dict) -> dict:
"(静止判据的法兰 XY 位移上限)。不许用代码默认 15mm。"
)
out["lock_still_sec"] = float(servo["lock_still_sec"])
out["wrist_align_sec"] = float(servo["wrist_align_sec"])
out["lock_still_move_m"] = float(servo["lock_still_move_m"])
out["overhead_k0_topic"] = "visual_servo/overhead_k0"
out["wrist_conf_topic"] = "visual_servo/wrist_conf"
+19
View File
@@ -0,0 +1,19 @@
"""D435 一次性要走厂家 MoveL 时,先认出谁占着 RDK。
`ros2_control_node` 和真机 `stack.launch` 占着唯一的 RDK 连接。
感知、伺服、本脚本自己不算。误伤它们会把正在推理的进程一起杀掉。
"""
from __future__ import annotations
def is_control_stack_cmd(cmd: str) -> bool:
"""这条命令行是不是占着 RDK 的真机控制栈。"""
text = cmd or ""
if "rdk_release_movel" in text:
return False
if "ros2_control_node" in text:
return True
if "stack.launch.py" in text and "backend:=real" in text:
return True
return False
+12
View File
@@ -17,6 +17,7 @@
[x, y, z, qw, qx, qy, qz],单位米,坐标系是机器人世界系(基座那套 WORLD)。
位置是当前 Studio 工具的 TCP(现在是 tool10120938 的尖),不是法兰,也不是相机。
4. 目标只换 x、y、z。姿态沿用现在的朝向,折成厂家 MoveL 要的欧拉角(度,ZYX)。
`--keep-z` 时 z 保持当前工具尖:K0 的 z 是工件表面,不能把尖送到那上面。
5. SwitchMode(NRT_PRIMITIVE_EXECUTION),ExecutePrimitive("MoveL", ...)。
6. 轮询 primitive_states()["reachedTarget"]。到了再读一次 tcp_pose,印出还差多少毫米。
@@ -134,6 +135,11 @@ def main() -> int:
parser.add_argument("--vel", type=float, default=0.05, help="TCP 线速度 m/s,默认 0.05")
parser.add_argument("--max-step-m", type=float, default=0.40, help="离当前点超过这么多米就拒绝,除非 --go")
parser.add_argument("--go", action="store_true", help="允许超过 --max-step-m")
parser.add_argument(
"--keep-z",
action="store_true",
help="XY 用给定点,Z 保持当前工具尖。K0 的 Z 是工件表面,不能把尖送到那上面",
)
parser.add_argument("--timeout", type=float, default=60.0, help="等 reachedTarget 的秒数")
args = parser.parse_args()
@@ -154,6 +160,12 @@ def main() -> int:
return 0
target_xyz = [float(v) for v in args.xyz]
if args.keep_z:
print(
f"给定点 z={target_xyz[2]:.4f} m 是工件表面,不下发。"
f"工具尖高度保持当前 {pose[2]:.4f} m"
)
target_xyz[2] = float(pose[2])
dist = math.dist(pose[:3], target_xyz)
print(f"目标 xyz=({target_xyz[0]:.4f}, {target_xyz[1]:.4f}, {target_xyz[2]:.4f}) m")
print(f"直线距离 {dist * 1000:.0f} mm 速度 {args.vel:.3f} m/s 姿态保持当前")
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""D435 这一点用厂家 MoveL 送到工具尖,再把真机控制栈拉回来。
RDK 同时只能有一个连接。混合流程里 `run_real.sh` 已经占着它,
所以顺序是:停真机栈 → MoveL(`--keep-z --go`)→ 用 `--no-stop` 把栈拉回来。
`--no-stop` 是为了不扫 `install/`:感知和伺服还在跑,扫了会把自己杀掉。
YOLO 不在这里关。
"""
from __future__ import annotations
import argparse
import os
import signal
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts" / "lib"))
from rdk_release import is_control_stack_cmd # noqa: E402
RDK_PYTHON = os.environ.get("RDK_PYTHON", "/home/cc/rdk_py310/bin/python")
def _procs() -> list[tuple[int, str]]:
out: list[tuple[int, str]] = []
proc = Path("/proc")
for entry in proc.iterdir():
if not entry.name.isdigit():
continue
pid = int(entry.name)
if pid == os.getpid():
continue
try:
raw = (entry / "cmdline").read_bytes().replace(b"\x00", b" ")
except OSError:
continue
cmd = raw.decode("utf-8", "replace").strip()
if cmd:
out.append((pid, cmd))
return out
def _control_pids() -> list[tuple[int, str]]:
return [(pid, cmd) for pid, cmd in _procs() if is_control_stack_cmd(cmd)]
def _term(pids: list[int]) -> None:
for pid in pids:
try:
os.kill(pid, signal.SIGTERM)
except OSError:
pass
deadline = time.monotonic() + 8.0
while time.monotonic() < deadline:
alive = []
for pid in pids:
try:
os.kill(pid, 0)
except OSError:
continue
alive.append(pid)
if not alive:
return
time.sleep(0.2)
pids = alive
for pid in pids:
try:
os.kill(pid, signal.SIGKILL)
except OSError:
pass
def _service_up() -> bool:
try:
listed = subprocess.run(
["timeout", "3", "ros2", "service", "list"],
capture_output=True,
text=True,
check=False,
)
except OSError:
return False
return "/controller_manager/list_controllers" in (listed.stdout or "")
def _controller_active() -> bool:
if not _service_up():
return False
try:
call = subprocess.run(
[
"timeout", "3", "ros2", "service", "call",
"/controller_manager/list_controllers",
"controller_manager_msgs/srv/ListControllers",
],
capture_output=True,
text=True,
check=False,
)
except OSError:
return False
text = (call.stdout or "") + (call.stderr or "")
return "name='rizon_arm_controller', state='active'" in text
def _wait_controller(up: bool, timeout_s: float) -> bool:
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
active = _controller_active()
if active == up:
return True
time.sleep(0.5)
return _controller_active() == up
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--xyz", nargs=3, type=float, required=True, metavar=("X", "Y", "Z"))
ap.add_argument("--arm", default=os.environ.get("SITE_ROBOT_ARM", "89"))
ap.add_argument("--vel", type=float, default=0.05)
args = ap.parse_args()
held = _control_pids()
if held:
print(f"放开 RDK:停 {len(held)} 个真机控制进程(感知不停)")
for pid, cmd in held:
print(f" {pid} {cmd[:160]}")
_term([pid for pid, _cmd in held])
if not _wait_controller(False, 20.0):
print("ERROR: 控制器服务还在,MoveL 会和它抢 RDK", file=sys.stderr)
return 1
else:
print("真机控制栈没在跑,直接 MoveL")
x, y, z = (float(v) for v in args.xyz)
movel = [
RDK_PYTHON,
str(ROOT / "scripts" / "rdk_goto_xyz.py"),
"--xyz", f"{x}", f"{y}", f"{z}",
"--keep-z", "--go",
"--vel", f"{float(args.vel)}",
]
print("厂家 MoveL:XY 对准 D435 这一点,工具尖高度保持当前")
got = subprocess.run(movel, check=False)
if got.returncode != 0:
print(f"ERROR: MoveL 退出码 {got.returncode}", file=sys.stderr)
if held:
log_path = Path("/tmp/run_all_real_restart.log")
print(f"拉回真机栈(--no-stop,不扫感知) log={log_path}")
log_f = log_path.open("ab")
subprocess.Popen(
["bash", str(ROOT / "scripts" / "run_real.sh"), "--no-stop", "--arm", str(args.arm)],
stdout=log_f,
stderr=subprocess.STDOUT,
start_new_session=True,
)
if not _wait_controller(True, 120.0):
print(
"ERROR: 控制栈没回到 ACTIVE。看 /tmp/run_all_real_restart.log",
file=sys.stderr,
)
return 1
print("rizon_arm_controller 已回到 ACTIVE")
return 0 if got.returncode == 0 else int(got.returncode)
if __name__ == "__main__":
raise SystemExit(main())
+44 -1
View File
@@ -21,7 +21,7 @@
# bash ~/ros2/scripts/run_all.sh --arm 89 --no-camera # 相机已在别处拉起了
# bash ~/ros2/scripts/run_all.sh --arm 89 --no-tactile # 不起触觉服务
# bash ~/ros2/scripts/run_all.sh --arm 89 --no-descent # 对准后只降到观测高度(默认离面 15mm)
# bash ~/ros2/scripts/run_all.sh --arm 89 --d435-only # 只测 D435 单次位移,到位后不交 D405、不进二阶段
# bash ~/ros2/scripts/run_all.sh --arm 89 --d435-only # 拍一张 K0,厂家 MoveL 把工具尖 XY 对准它(先停 run_real.sh)
#
# 前置(另开一个终端,先跑起来):
# bash ~/ros2/scripts/run_real.sh --arm 89
@@ -86,6 +86,49 @@ fi
echo "=== run_all(三阶段串联) arm=${SITE_ROBOT_ARM:-?} ${SITE_ROBOT_LABEL:-} (${SITE_ROBOT_SN:-?}) ==="
# D435 单次定位不走关节轨迹。拍一张 → K0 用眼在手外 T 变到基座 → 厂家 MoveL。
# RDK 与 run_real.sh 互斥,所以这条路径要求控制器没开。
if [[ "${D435_ONLY}" == true ]]; then
echo "=== D435 单次定位:厂家 RDK MoveL(先停 run_real.sh)==="
if timeout 3 ros2 service list 2>/dev/null | grep -q "^/controller_manager/list_controllers$"; then
echo "ERROR: run_real.sh 还占着 RDK,MoveL 会被打回 IDLE。先在那个终端 Ctrl-C。" >&2
exit 1
fi
D435_TOPIC="/d435/color/image_raw"
_d435_ok() {
timeout 2 ros2 topic list 2>/dev/null | grep -qx "${D435_TOPIC}" || return 1
timeout 3 ros2 topic echo --once "${D435_TOPIC}" 2>/dev/null | grep -q "^header:"
}
if _d435_ok; then
echo "=== 顶视 ${D435_TOPIC} 已在发图 ==="
else
echo "=== 拉起顶视 D435 ==="
nohup bash "${ROOT}/scripts/run_camera_d435.sh" --no-preview > /tmp/run_all_d435.log 2>&1 &
echo " d435 pid=$! log=/tmp/run_all_d435.log"
d435_ok=false
for _i in $(seq 1 24); do
if _d435_ok; then d435_ok=true; break; fi
sleep 0.5
done
if [[ "${d435_ok}" != true ]]; then
echo "ERROR: ${D435_TOPIC} 仍无图像。看 /tmp/run_all_d435.log" >&2
exit 1
fi
fi
echo "=== 拍一张,K0 × 眼在手外 T → 基座 ==="
k0_line="$(python3 "${ROOT}/scripts/d435_k0_once.py")"
echo " ${k0_line}"
read -r _tag kx ky kz <<<"${k0_line}"
if [[ "${_tag}" != "K0_BASE" ]]; then
echo "ERROR: 没有得到 K0_BASE,得到:${k0_line}" >&2
exit 1
fi
echo "=== MoveL:XY 对准 K0,工具尖高度保持当前(K0 的 z 是工件表面)==="
# --go:不按直线距离拦截。800 mm 是脚本自己的保护,不是机械臂限位。
exec /home/cc/rdk_py310/bin/python "${ROOT}/scripts/rdk_goto_xyz.py" \
--xyz "${kx}" "${ky}" "${kz}" --keep-z --go --vel 0.05
fi
# ---- 1) 控制器必须在。判活方式与 run_stage2_locate.sh 保持一致 ----
echo "=== 等待 rizon_arm_controller ACTIVE(T1: run_real.sh --arm ${SITE_ROBOT_ARM:-?})==="
# 先看服务在不在,再 call —— 顺序反过来的话,服务不存在时每次 call 都要耗满
+6
View File
@@ -10,10 +10,12 @@ set -eo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
FLEXIV_PREFIX="${HOME}/flexiv_install"
LIST_ONLY=false
NO_STOP=false
while [[ $# -gt 0 ]]; do
case "$1" in
--sn|--arm) export ROBOT_SN="${2:?}"; export ROBOT_ARM="${2}"; shift 2 ;;
--no-stop) NO_STOP=true; shift ;;
--list|-l) LIST_ONLY=true; shift ;;
-h|--help)
sed -n '2,8p' "$0"
@@ -54,6 +56,9 @@ echo "=== catalog: ${SITE_ROBOT_CATALOG:-} ==="
echo "=== handeye: ${SITE_HANDEYE_FILE:-} ==="
echo "=== camera serial: ${SITE_CAM_SERIAL:-} gripper: ${SITE_GRIP_MODEL:-} ${SITE_GRIP_PORT:-} ==="
echo "=== network iface=${NET_IFACE:-auto} robot=${ROBOT_IP} pc=${SITE_PC_IP:-} ==="
if [[ "${NO_STOP}" == true ]]; then
echo "=== --no-stop:不扫 install/(感知和伺服还在,扫了会把自己杀掉)==="
else
echo "=== stop leftover real stack (RDK exclusive) ==="
# 以前这里是两行 pkill(ros2_control_node + stack.launch),漏掉了
# arm_control 那一族**独立存活**的孤儿(adapter / named_pose_server /
@@ -75,6 +80,7 @@ if [ "${#_stale[@]}" != "0" ]; then
unset _pid
fi
unset _stale
fi
sleep 1
# 清掉把 91 的 .100 误绑到 89 MAC 上的陈旧 ARP
if [[ -n "${NET_IFACE}" ]]; then