仿真感知:真值孔位 + 真值相机 + 补 /home,让流程不改代码就能跑仿真

三块补齐,目标是"同一个流程节点,仿真/真机都不改代码":

1. 真值孔位(truth_holes.py):把 scene.yaml 与模型 SDF 里已知的孔几何
   直接投影到相机像素坐标,替代 HoughCircles。perception.source 选:
     yolo / truth / external —— truth 就走这条路。
   为什么不用 Hough:仿真里孔是"方孔 + 贴片亮环"的近似(SDF 没有布尔运算),
   靠图像门槛去凑 radus 区间很脆;而孔位本来就是场景里已知量,直接投影
   既准又稳,还能当真实检测算法的对照基线。

2. 真值相机(sim_truth_perception.py):同理,直接算物体位姿,供
   visual_servo/filtered_point 用,绕开 ONNX。

3. 补 /home 与 set_named_pose 服务(sim_home_service.py):真机上这两个
   由 Flexiv 驱动提供,仿真里没有 —— 缺了 S01 直接超时。

4. sim_tactile_adapter 容错导入 ros_gz_interfaces:那一路只是"接触点",
   力本身走 geometry_msgs/Wrench。typesupport 加载不了时退化成
   contact 恒 False + centroid 恒 0,三力判据(S07 用的)不受影响 ——
   宁可用退化模式跑,也不要整个适配器起不来。

5. repo_root 解析(已装包要能找回源码仓库根,见 test_repo_root.py 的用例)
   与 use_sim_time 向子 launch 的传递。

另:scripts/gen_sim_handeye.py 把 URDF 真值导出手眼文件(仿真不标定)。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
arm_dev
2026-09-24 14:43:13 +08:00
parent ddf30e57f8
commit b29339e522
24 changed files with 2232 additions and 74 deletions
+112 -9
View File
@@ -54,11 +54,11 @@ except ImportError as exc: # pragma: no cover - 环境问题,不是逻辑问
# 默认期望:哪台相机"必须"看见哪个平面。
# d435 是手外相机,它的职责就是看见台面(P6 的粗定位要靠它),
# sim_d435 是手外相机,它的职责就是看见台面(P6 的粗定位要靠它),
# 所以要求 ≥10% 的像素落在台面上。
# d405 是腕上相机,视野窄且会拍到机械臂自己,台面占比要求放宽到 5%。
# sim_d405 是腕上相机(仿真那台 = 左臂),视野窄且会拍到机械臂自己,台面占比放宽到 5%。
# 地面(z=0) 不作为要求:它被拍到多少取决于姿态,不构成"对不对"的判据。
DEFAULT_EXPECT = ("d435:0.40:10", "d405:0.40:5")
DEFAULT_EXPECT = ("sim_d435:0.40:10", "sim_d405:0.40:5")
def quat_to_R(q) -> np.ndarray:
@@ -115,6 +115,95 @@ def spin_until(node: Node, pred, timeout: float) -> bool:
return False
def _tf_rot(node: "Grab", parent: str, child: str) -> np.ndarray:
"""TF parent ← child 的旋转部分(child 的三根轴在 parent 系里的表示)。"""
tf = node.buf.lookup_transform(parent, child, rclpy.time.Time())
r = tf.transform.rotation
return quat_to_R((r.x, r.y, r.z, r.w))
def mount_problems(node: "Grab", ns: str, frame: str, problems: list[str]) -> None:
"""核对**安装朝向**:腕相机视轴要沿法兰轴线,手外相机要朝下看台面。
为什么单独查这一条(光看"命中平面"不够):
腕相机要是朝侧面看,它照样能反投影出 z≈0 的地面 —— 几何自洽、数值"正常",
只是**看不见工件**。表现全在应用侧:"目标不在相机前方"、伺服一直找工件。
所以这里查的不是"看见了什么",而是"朝哪看":前者会被姿态带偏,后者不会。
"""
try:
from arm_sim_gazebo.sim_cameras import (
MOUNT_TOL_DEG,
WORLD_LOOK_DOWN_COS,
flange_frame_for,
load_cameras,
mount_axis_deg,
world_look_down_cos,
)
except ImportError as exc:
# 不静默跳过:安装朝向是本工具**最重要**的一条判据(它拦的是"相机装歪
# 但数值全正常"那类问题),悄悄不查等于把这条守门人撤了。
problems.append(
f"拿不到 sim_cameras 的安装朝向工具({exc})—— install_sim 是旧的,"
f"先跑 bash scripts/build_sim.sh"
)
return
spec = next((s for s in load_cameras() if s.ros_ns == ns), None)
if spec is None:
problems.append(f"cameras.yaml 里找不到 ros_ns={ns} 的相机声明,无法核对安装朝向")
return
now = rclpy.time.Time()
flange = flange_frame_for(spec)
if flange is not None and node.buf.can_transform(flange, frame, now):
R = _tf_rot(node, flange, frame)
ang = mount_axis_deg(R)
print(f" 相对法兰 {flange} 的视轴夹角: {ang:.1f}° (0° = 正装在法兰轴线上)")
if ang > MOUNT_TOL_DEG:
problems.append(
f"腕部相机视轴与法兰 +Z 差 {ang:.1f}°(要求 ≤{MOUNT_TOL_DEG:.0f}°)—— "
f"相机装歪了,法兰垂直朝下时它看不到下面的工件。\n"
f" 90° 左右就是「少转一个直角」:话题/图像/内参全都正常,\n"
f" 而且反投影仍会命中地面(看的是远处),所以只查平面发现不了。\n"
f" 修法:devices/sim/gazebo/urdf/arm.sim.sensors.xacro 里"
f" d405_pitch(正装 = -1.5708)。"
)
else:
cos = world_look_down_cos(_tf_rot(node, "world", frame))
print(f" 视轴在世界系里的 z 分量: {cos:+.2f} (-1 = 正朝下看)")
if cos > WORLD_LOOK_DOWN_COS:
problems.append(
f"手外相机没在朝下看台面(视轴 z 分量 {cos:+.2f},要求 ≤{WORLD_LOOK_DOWN_COS:+.1f})—— "
f"位姿 rpy 写错时几何自洽、不报错,只是拍不到工件。\n"
f" 修法:devices/sim/gazebo/worlds/assembly.world 里该 <model> 的 <pose> pitch。"
)
def looking_at_table(node: "Grab", frame: str) -> "bool | None":
"""相机此刻是不是**朝下看着台面**(光心在台面 xy 内 + 视轴朝下)。
为什么要问这一句:腕相机的职责是"悬在工件上方时看清螺纹孔"。机械臂停在初始位
或别处时它当然只看得见地面(初始位那副姿态里法兰甚至是横着的)—— 这时候拿
"命中台面 ≥5%"判它不合格是**假警报**,而假警报多了真问题就没人看了。
拿不到场景配置时返回 None(按"不知道"处理,照常判)。
"""
try:
from arm_sim_gazebo.sim_cameras import WORLD_LOOK_DOWN_COS, world_look_down_cos
from arm_sim_gazebo import sim_scene
scene = sim_scene.load_scene()
b = scene.table_bounds
tf = node.buf.lookup_transform("world", frame, rclpy.time.Time())
x = tf.transform.translation.x
y = tf.transform.translation.y
margin = 0.05
inside = (b.x_min - margin <= x <= b.x_max + margin) and (
b.y_min - margin <= y <= b.y_max + margin
)
downward = world_look_down_cos(_tf_rot(node, "world", frame)) <= WORLD_LOOK_DOWN_COS
return inside and downward
except Exception: # noqa: BLE001 —— 拿不到就不拦人
return None
def expected_intrinsics(ns: str):
"""从 cameras.yaml 算出该相机的应有内参。拿不到就返回 None(不阻断检查)。"""
try:
@@ -210,6 +299,9 @@ def check_one(ns: str, args) -> tuple[bool, list[str]]:
]
)
zw = (pts @ R.T + t)[:, 2]
# 安装朝向要在 destroy_node 之前查 —— 它还要用 TF(腕相机的视轴相对法兰)
mount_problems(node, ns, frame, problems)
above_table = looking_at_table(node, frame)
node.destroy_node()
print(f"\n[{ns}] 光学系={frame} fx={fx:.1f} fy={fy:.1f} cx={cx:.1f} cy={cy:.1f}")
@@ -223,15 +315,26 @@ def check_one(ns: str, args) -> tuple[bool, list[str]]:
print(f" 命中 {name}(z={pz:+.2f}) : {rate:5.1f}%")
# 期望判据
table_z = 0.40
for want_z, want_min in args.expect.get(ns, []):
name = dict(args.planes).get(want_z, f"z={want_z}")
got = rates.get(want_z, 0.0)
if got < want_min:
problems.append(
f"只看见 {name} 的 {got:.1f}%,要求 ≥{want_min:.0f}%。\n"
f" 相机很可能是**背对**目标(位姿 rpy 写错)—— "
f"这种错几何自洽、不报错,只会让相机拍不到该拍的东西。"
if got >= want_min:
continue
if abs(want_z - table_z) < 1e-9 and above_table is False:
# 腕相机不在台面上方时,"没看见台面"是正常的(它看见的是地面)。
print(
f" ⚠ 跳过「{name}」判据:相机此刻没朝下看着台面"
f"(光心不在 scene.yaml 的 table_bounds 内,或视轴没朝下)——\n"
f" 此刻只看得见地面是正常的。\n"
f" 想让这条生效:把机械臂摆到工件上方(例如 S04 跟踪时)再跑本脚本。"
)
continue
problems.append(
f"只看见 {name} 的 {got:.1f}%,要求 ≥{want_min:.0f}%。\n"
f" 相机很可能是**背对**目标(位姿 rpy 写错)—— "
f"这种错几何自洽、不报错,只会让相机拍不到该拍的东西。"
)
total = sum(rates.values())
if total < 1.0:
@@ -279,7 +382,7 @@ def main() -> int:
description="仿真相机几何验收:反投影深度,检查是否命中预期平面。",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
ap.add_argument("--cameras", default="d405,d435", help="要检查的相机命名空间,逗号分隔")
ap.add_argument("--cameras", default="sim_d405,sim_d435", help="要检查的相机命名空间,逗号分隔")
ap.add_argument(
"--planes",
type=parse_planes,
+36 -2
View File
@@ -125,8 +125,23 @@ def effective_gates(root: Optional[Path] = None) -> dict:
}
def sim_robot_servo(doc: dict) -> dict:
"""仿真那台臂的 servo 段(顶层 servo 被 robots[] 里 sim 那台覆盖后的结果)。
⚠️ 必须合并,不能只读顶层:顶层那套是**真机**的(台面与基座同高,所以
工作盒 z 是 0.18~0.65),而仿真里机械臂站在地面、台面 0.40 高,一切 Z 都
大 0.412。只读顶层会拿真机的界去核对仿真的悬停高 —— 看起来"过了",
而真正生效的配置可能根本不对(或者反过来,明明配对了却报越界)。
"""
servo = dict(doc.get("servo") or {})
for robot in (doc.get("robot") or {}).get("robots") or []:
if str(robot.get("arm") or "") == "sim" or str(robot.get("sn") or "") == "sim":
servo.update(robot.get("servo") or {})
return servo
def workspace_z_limits(root: Optional[Path] = None):
"""从 site.yaml 读机械臂法兰的工作空间 Z 上下界(用于核对悬停高度)。"""
"""机械臂法兰的工作空间 Z 上下界(按**仿真那台臂**的生效值)。"""
import yaml
base_root = Path(root) if root is not None else REPO_ROOT
@@ -136,7 +151,7 @@ def workspace_z_limits(root: Optional[Path] = None):
if not site.is_file():
return None
doc = yaml.safe_load(site.read_text(encoding="utf-8")) or {}
servo = doc.get("servo") or {}
servo = sim_robot_servo(doc)
try:
return float(servo["workspace_xyz_min"][2]), float(servo["workspace_xyz_max"][2])
except (KeyError, IndexError, TypeError, ValueError):
@@ -217,6 +232,25 @@ def check_hover_height(scene, root) -> list[str]:
)
elif fz < lo:
out.append(f"算出的悬停法兰高度 z≈{fz:.3f}m 低于工作空间下界 {lo}m。")
# 一阶段跟踪高还要再抬 TRACK_CLEARANCE_M(见 pipeline_node 的常量)。
# 单独查这一条的原因:二阶段能过、S04 一跟踪就被"超出工作盒"截断,
# 现象是"跟踪位置总差一截/越跟踪越偏",而配置看着完全合理。
try:
from arm_application.pipeline_node import TRACK_CLEARANCE_M
except Exception: # noqa: BLE001 —— 拿不到常量就不查,不因此拦人
return out
track = fz + float(TRACK_CLEARANCE_M)
if track > hi:
out.append(
f"一阶段跟踪高 = 悬停 {fz:.3f} + {TRACK_CLEARANCE_M:.2f} = {track:.3f}m "
f"**超出工作空间上界 {hi}m**。\n"
f" 二阶段的位姿检查能过、S04 一跟踪却会被「超出工作盒」截断 —— "
f"现象是「越跟踪越偏」,很难往配置上想。\n"
f" 修法(仿真里都自由):把 site.yaml 里仿真那台臂的 "
f"servo.workspace_xyz_max.z 抬到 ≥ {track:.3f},"
f"或调小 scene.yaml 的 camera_to_part_m(法兰随之降低)。"
)
return out
+292
View File
@@ -0,0 +1,292 @@
#!/usr/bin/env python3
"""把仿真里的**手眼真值**导出成和真机同格式的手眼文件。
为什么要有它
------------
真机的手眼标定是"标出来"的(``run_handeye.sh``)。仿真里不用标:腕部相机在
URDF 里就固定在法兰上,位姿**本来就知道**,而且就是真值。但应用读的是
``handeye/results/*.yaml`` 这个接口(``servo_node`` 需要 ``T_flange_cam``
矩阵,``visual_servo.launch.py`` 缺文件会直接拒绝启动)。
所以这里的做法不是"让应用判断自己在不在仿真",而是**把真值导出成同一格式的
文件**,路径在 ``site.yaml`` 的仿真台条目里指定。应用一行都不用改。
同时导出两个(真机上对应两次标定)::
sim_eye_in_hand.yaml flange ← 腕部相机光学系(eye-in-hand,跟踪/精定位用)
sim_d435_eye_to_hand.yaml base ← 手外相机光学系(eye-to-hand,S03 粗定位用)
用法::
python3 scripts/gen_sim_handeye.py # 生成/刷新(仿真栈要在跑)
python3 scripts/gen_sim_handeye.py --check # 只校验:现有文件是否仍是当前真值
source scripts/sim_env.sh && python3 scripts/gen_sim_handeye.py
为什么要 ``--check``:URDF 一改(相机装到别处、加了垫片),那份文件就过期了,
而过期的表现是"定位总能到附近、永远差一点"—— 看起来像算法不准。
"""
from __future__ import annotations
import argparse
import math
import os
import sys
import tempfile
import time
from pathlib import Path
from typing import Optional, Sequence
import numpy as np
_HERE = Path(__file__).resolve()
sys.path.insert(0, str(_HERE.parent)) # scripts/
sys.path.insert(0, str(_HERE.parent / "lib")) # scripts/lib/
import export_site_env as site_env # noqa: E402
from arm_application.handeye_lib import R_to_quat_xyzw # noqa: E402
try:
import rclpy
from rclpy.node import Node
from rclpy.time import Time
from tf2_ros import Buffer, TransformListener
except ImportError as exc: # pragma: no cover - 只在没 source 环境时触发
sys.exit(
f"找不到 {exc.name}:先 source 仿真环境再跑本脚本\n"
" source scripts/sim_env.sh\n"
"若仍失败,说明 install_sim/ 还没构建:bash scripts/build_sim.sh"
)
#: 认为"文件与真值一致"的容差。1e-9 m 太苛刻(YAML 打印会截断),
#: 1e-6 m = 1 微米、1e-6 rad 级别足够:相机装配误差远大于它。
TOL_M = 1e-6
TOL_RAD = 1e-6
class HandeyeError(RuntimeError):
"""面向使用者的可预期失败。"""
def _matrix_to_yaml(T: np.ndarray) -> str:
rows = []
for i in range(4):
vals = ", ".join(f"{float(v):.8f}" for v in T[i])
rows.append(f" - [{vals}]")
return "\n".join(rows)
def _render(
*, mode: str, parent: str, child: str, T: np.ndarray, note: str
) -> str:
xyz = T[:3, 3]
return (
f"# 仿真手眼真值 —— **自动生成,不要手改**\n"
f"# mode: {mode}\n"
f"# 来源: Gazebo/URDF 的 TF 真值 `{parent}` ← `{child}`(仿真不需要标定)\n"
f"# 生成: {time.strftime('%Y-%m-%dT%H:%M:%S%z')} 生成器: scripts/gen_sim_handeye.py\n"
f"# {note}\n"
f"translation_m: [{float(xyz[0]):.8f}, {float(xyz[1]):.8f}, {float(xyz[2]):.8f}]\n"
f"T_4x4:\n{_matrix_to_yaml(T)}\n"
)
class _TfGrab(Node):
"""只做一件事:等到能查到某对坐标系,然后把 4x4 拿出来。"""
def __init__(self, timeout_s: float) -> None:
super().__init__("gen_sim_handeye")
self._buf = Buffer()
self._listener = TransformListener(self._buf, self)
self._timeout = timeout_s
def look(self, parent: str, child: str) -> np.ndarray:
deadline = time.monotonic() + self._timeout
last = ""
while time.monotonic() < deadline:
self._spin(0.1)
try:
# 用 Time()(=最新可用)而不是 Time(0):0 在 ROS 里也表示最新,
# 但语义上容易被读成"仿真时刻 0",这里写明意图。
tf = self._buf.lookup_transform(parent, child, Time())
except Exception as exc: # noqa: BLE001
last = f"{type(exc).__name__}: {exc}"
continue
return _tf_to_matrix(tf)
raise HandeyeError(
f"{self._timeout:.0f}s 内查不到 TF `{parent}` ← `{child}`:{last}\n"
" 仿真栈没起来?先 bash scripts/run_sim_gz.sh\n"
" 名字对不上?site.yaml 的仿真台条目里 ee_frame / camera.name 要与 URDF 一致"
)
def _spin(self, dt: float) -> None:
rclpy.spin_once(self, timeout_sec=dt)
def _tf_to_matrix(tf) -> np.ndarray:
from arm_application.handeye_lib import quat_xyzw_to_R
t = tf.transform.translation
q = tf.transform.rotation
T = np.eye(4)
T[:3, :3] = quat_xyzw_to_R([q.x, q.y, q.z, q.w])
T[:3, 3] = [t.x, t.y, t.z]
return T
def _read_matrix(path: Path) -> Optional[np.ndarray]:
"""读回文件里的 T_4x4(与 ``handeye_lib.read_transform_yaml`` 同一份解析)。"""
from arm_application.handeye_lib import read_transform_yaml
if not path.is_file():
return None
xyz, quat = read_transform_yaml(str(path))
from arm_application.handeye_lib import quat_xyzw_to_R
T = np.eye(4)
T[:3, :3] = quat_xyzw_to_R(list(quat))
T[:3, 3] = list(xyz)
return T
def _same(a: np.ndarray, b: np.ndarray) -> bool:
if not np.allclose(a[:3, 3], b[:3, 3], atol=TOL_M):
return False
# 旋转差用"作用到单位向量上的最大偏差"衡量,比逐元素比更直观
R = a[:3, :3].T @ b[:3, :3]
ang = math.acos(max(-1.0, min(1.0, (float(np.trace(R)) - 1.0) / 2.0)))
return ang <= TOL_RAD
def _write_atomic(path: Path, text: str) -> None:
"""原子落盘:脚本/节点可能正读这个文件,写一半会被读到半截。"""
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=path.name, suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(text)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
except BaseException:
Path(tmp).unlink(missing_ok=True)
raise
def _targets(arm: str) -> Sequence[tuple[str, str, str, str, Path]]:
"""返回 (mode, parent, child, note, 输出路径)。
parent/child 全部从 site.yaml 现取 —— 名字写在配置里,脚本不猜。
"""
data = site_env.load_site()
# 注意第二个返回值才是"这台臂"自己的字典(含 sn / base_frame / ee_frame / camera);
# 第一个是整份 site,顶层没有 sn(顶层的是 robot.sn = 默认臂)。
try:
_merged, robot = site_env.apply_active_arm(data, arm)
except ValueError as exc:
raise HandeyeError(str(exc)) from exc
sn = str(robot.get("sn") or "")
if not sn:
raise HandeyeError(f"site.yaml 里选不出机械臂(--arm {arm})")
prefix = f"{sn}_"
# 关节/坐标系命名沿用应用的口径:base/ee 未显式配就用 <sn>_ 前缀(见 stage2)
base = str(robot.get("base_frame") or f"{prefix}base_link")
ee = str(robot.get("ee_frame") or f"{prefix}flange")
cam = str((robot.get("camera") or {}).get("name") or "").strip().strip("/")
if not cam:
raise HandeyeError(f"`--arm {arm}` 的 camera.name 是空的,没法确定腕部相机")
# 手外相机:与 gauge_assembly_node._d435_ns 同一套判据(role 前缀 + 同臂)
coarse = ""
arm_name = str(robot.get("arm") or "").strip()
for cfg in (data.get("cameras") or {}).values():
if not isinstance(cfg, dict):
continue
if not str(cfg.get("role") or "").startswith("overhead"):
continue
if arm_name and str(cfg.get("arm") or "").strip() != arm_name:
continue
coarse = str(cfg.get("name") or "").strip().strip("/")
break
root = site_env._root()
out = [
(
"eye_in_hand",
ee,
f"{cam}_color_optical_frame",
f"{note_who(robot)}:腕部相机装在法兰上,位姿来自 URDF",
root / "handeye" / "results" / f"{sn}_eye_in_hand.yaml",
)
]
if coarse:
out.append(
(
"eye_to_hand",
base,
f"{coarse}_color_optical_frame",
f"{note_who(robot)}:手外相机固定在工位/世界上(不随臂动)",
root / "handeye" / "results" / f"{coarse}_eye_to_hand.yaml",
)
)
return out
def note_who(merged: dict) -> str:
return str(merged.get("label") or merged.get("sn") or "仿真台")
def main(argv: Optional[Sequence[str]] = None) -> int:
ap = argparse.ArgumentParser(description="把仿真手眼真值导出成手眼文件")
ap.add_argument("--arm", default="sim", help="site.yaml 里仿真台的别名(默认 sim)")
ap.add_argument("--check", action="store_true", help="只校验,不写文件")
ap.add_argument("--timeout", type=float, default=20.0, help="等 TF 出现的秒数")
args = ap.parse_args(argv)
try:
targets = _targets(args.arm)
except HandeyeError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
rclpy.init()
node = _TfGrab(args.timeout)
rc = 0
try:
for mode, parent, child, note, path in targets:
try:
T = node.look(parent, child)
except HandeyeError as exc:
print(f"FAIL: [{mode}] {exc}", file=sys.stderr)
rc = 1
continue
old = _read_matrix(path)
if args.check:
if old is None:
print(f"FAIL: [{mode}] 文件不存在:{path}")
rc = 1
elif _same(old, T):
print(f"OK: [{mode}] {path.name} 与当前真值一致")
else:
d = float(np.linalg.norm(old[:3, 3] - T[:3, 3]))
print(
f"FAIL: [{mode}] {path.name} 已过期:平移差 {d * 1000:.3f}mm"
f"(URDF 改过?重跑本脚本刷新)"
)
rc = 1
continue
if old is not None and _same(old, T):
print(f"OK: [{mode}] {path} 已是最新(无需重写)")
continue
_write_atomic(path, _render(mode=mode, parent=parent, child=child, T=T, note=note))
print(f"写入: [{mode}] {path} ({parent} ← {child})")
finally:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
return rc
if __name__ == "__main__":
sys.exit(main())