仿真感知:真值孔位 + 真值相机 + 补 /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:
@@ -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())
|
||||
Reference in New Issue
Block a user