克隆后还缺仿真安装树和两边的编译目录。把 build、build_sim、install_sim 一并放进仓库;仿真入口不再写死本机 conda,build 里指向源码的链接改成相对路径。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
# EASY-INSTALL-ENTRY-SCRIPT: 'arm_sim_gazebo==0.1.0','console_scripts','gazebo_sim_launcher'
|
||||
import re
|
||||
import sys
|
||||
|
||||
# for compatibility with easy_install; see #2198
|
||||
__requires__ = 'arm_sim_gazebo==0.1.0'
|
||||
|
||||
try:
|
||||
from importlib.metadata import distribution
|
||||
except ImportError:
|
||||
try:
|
||||
from importlib_metadata import distribution
|
||||
except ImportError:
|
||||
from pkg_resources import load_entry_point
|
||||
|
||||
|
||||
def importlib_load_entry_point(spec, group, name):
|
||||
dist_name, _, _ = spec.partition('==')
|
||||
matches = (
|
||||
entry_point
|
||||
for entry_point in distribution(dist_name).entry_points
|
||||
if entry_point.group == group and entry_point.name == name
|
||||
)
|
||||
return next(matches).load()
|
||||
|
||||
|
||||
globals().setdefault('load_entry_point', importlib_load_entry_point)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(load_entry_point('arm_sim_gazebo==0.1.0', 'console_scripts', 'gazebo_sim_launcher')())
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
# EASY-INSTALL-ENTRY-SCRIPT: 'arm_sim_gazebo==0.1.0','console_scripts','sim_camera_adapter'
|
||||
import re
|
||||
import sys
|
||||
|
||||
# for compatibility with easy_install; see #2198
|
||||
__requires__ = 'arm_sim_gazebo==0.1.0'
|
||||
|
||||
try:
|
||||
from importlib.metadata import distribution
|
||||
except ImportError:
|
||||
try:
|
||||
from importlib_metadata import distribution
|
||||
except ImportError:
|
||||
from pkg_resources import load_entry_point
|
||||
|
||||
|
||||
def importlib_load_entry_point(spec, group, name):
|
||||
dist_name, _, _ = spec.partition('==')
|
||||
matches = (
|
||||
entry_point
|
||||
for entry_point in distribution(dist_name).entry_points
|
||||
if entry_point.group == group and entry_point.name == name
|
||||
)
|
||||
return next(matches).load()
|
||||
|
||||
|
||||
globals().setdefault('load_entry_point', importlib_load_entry_point)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(load_entry_point('arm_sim_gazebo==0.1.0', 'console_scripts', 'sim_camera_adapter')())
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
# EASY-INSTALL-ENTRY-SCRIPT: 'arm_sim_gazebo==0.1.0','console_scripts','sim_gripper_adapter'
|
||||
import re
|
||||
import sys
|
||||
|
||||
# for compatibility with easy_install; see #2198
|
||||
__requires__ = 'arm_sim_gazebo==0.1.0'
|
||||
|
||||
try:
|
||||
from importlib.metadata import distribution
|
||||
except ImportError:
|
||||
try:
|
||||
from importlib_metadata import distribution
|
||||
except ImportError:
|
||||
from pkg_resources import load_entry_point
|
||||
|
||||
|
||||
def importlib_load_entry_point(spec, group, name):
|
||||
dist_name, _, _ = spec.partition('==')
|
||||
matches = (
|
||||
entry_point
|
||||
for entry_point in distribution(dist_name).entry_points
|
||||
if entry_point.group == group and entry_point.name == name
|
||||
)
|
||||
return next(matches).load()
|
||||
|
||||
|
||||
globals().setdefault('load_entry_point', importlib_load_entry_point)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(load_entry_point('arm_sim_gazebo==0.1.0', 'console_scripts', 'sim_gripper_adapter')())
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
# EASY-INSTALL-ENTRY-SCRIPT: 'arm_sim_gazebo==0.1.0','console_scripts','sim_home_service'
|
||||
import re
|
||||
import sys
|
||||
|
||||
# for compatibility with easy_install; see #2198
|
||||
__requires__ = 'arm_sim_gazebo==0.1.0'
|
||||
|
||||
try:
|
||||
from importlib.metadata import distribution
|
||||
except ImportError:
|
||||
try:
|
||||
from importlib_metadata import distribution
|
||||
except ImportError:
|
||||
from pkg_resources import load_entry_point
|
||||
|
||||
|
||||
def importlib_load_entry_point(spec, group, name):
|
||||
dist_name, _, _ = spec.partition('==')
|
||||
matches = (
|
||||
entry_point
|
||||
for entry_point in distribution(dist_name).entry_points
|
||||
if entry_point.group == group and entry_point.name == name
|
||||
)
|
||||
return next(matches).load()
|
||||
|
||||
|
||||
globals().setdefault('load_entry_point', importlib_load_entry_point)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(load_entry_point('arm_sim_gazebo==0.1.0', 'console_scripts', 'sim_home_service')())
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
# EASY-INSTALL-ENTRY-SCRIPT: 'arm_sim_gazebo==0.1.0','console_scripts','sim_tactile_adapter'
|
||||
import re
|
||||
import sys
|
||||
|
||||
# for compatibility with easy_install; see #2198
|
||||
__requires__ = 'arm_sim_gazebo==0.1.0'
|
||||
|
||||
try:
|
||||
from importlib.metadata import distribution
|
||||
except ImportError:
|
||||
try:
|
||||
from importlib_metadata import distribution
|
||||
except ImportError:
|
||||
from pkg_resources import load_entry_point
|
||||
|
||||
|
||||
def importlib_load_entry_point(spec, group, name):
|
||||
dist_name, _, _ = spec.partition('==')
|
||||
matches = (
|
||||
entry_point
|
||||
for entry_point in distribution(dist_name).entry_points
|
||||
if entry_point.group == group and entry_point.name == name
|
||||
)
|
||||
return next(matches).load()
|
||||
|
||||
|
||||
globals().setdefault('load_entry_point', importlib_load_entry_point)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(load_entry_point('arm_sim_gazebo==0.1.0', 'console_scripts', 'sim_tactile_adapter')())
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
# EASY-INSTALL-ENTRY-SCRIPT: 'arm_sim_gazebo==0.1.0','console_scripts','sim_truth_perception'
|
||||
import re
|
||||
import sys
|
||||
|
||||
# for compatibility with easy_install; see #2198
|
||||
__requires__ = 'arm_sim_gazebo==0.1.0'
|
||||
|
||||
try:
|
||||
from importlib.metadata import distribution
|
||||
except ImportError:
|
||||
try:
|
||||
from importlib_metadata import distribution
|
||||
except ImportError:
|
||||
from pkg_resources import load_entry_point
|
||||
|
||||
|
||||
def importlib_load_entry_point(spec, group, name):
|
||||
dist_name, _, _ = spec.partition('==')
|
||||
matches = (
|
||||
entry_point
|
||||
for entry_point in distribution(dist_name).entry_points
|
||||
if entry_point.group == group and entry_point.name == name
|
||||
)
|
||||
return next(matches).load()
|
||||
|
||||
|
||||
globals().setdefault('load_entry_point', importlib_load_entry_point)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(load_entry_point('arm_sim_gazebo==0.1.0', 'console_scripts', 'sim_truth_perception')())
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
# EASY-INSTALL-ENTRY-SCRIPT: 'arm_sim_gazebo==0.1.0','console_scripts','urdf_normalize'
|
||||
import re
|
||||
import sys
|
||||
|
||||
# for compatibility with easy_install; see #2198
|
||||
__requires__ = 'arm_sim_gazebo==0.1.0'
|
||||
|
||||
try:
|
||||
from importlib.metadata import distribution
|
||||
except ImportError:
|
||||
try:
|
||||
from importlib_metadata import distribution
|
||||
except ImportError:
|
||||
from pkg_resources import load_entry_point
|
||||
|
||||
|
||||
def importlib_load_entry_point(spec, group, name):
|
||||
dist_name, _, _ = spec.partition('==')
|
||||
matches = (
|
||||
entry_point
|
||||
for entry_point in distribution(dist_name).entry_points
|
||||
if entry_point.group == group and entry_point.name == name
|
||||
)
|
||||
return next(matches).load()
|
||||
|
||||
|
||||
globals().setdefault('load_entry_point', importlib_load_entry_point)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(load_entry_point('arm_sim_gazebo==0.1.0', 'console_scripts', 'urdf_normalize')())
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: arm_sim_gazebo
|
||||
Version: 0.1.0
|
||||
Summary: Gazebo 仿真后端:描述归一化 + 启动 Gazebo 与 ros2_control 桥接
|
||||
Maintainer: arm_dev
|
||||
Maintainer-email: dev@example.com
|
||||
License: Apache-2.0
|
||||
Requires-Dist: setuptools
|
||||
Requires-Dist: PyYAML
|
||||
Dynamic: license
|
||||
Dynamic: maintainer
|
||||
Dynamic: maintainer-email
|
||||
Dynamic: requires-dist
|
||||
Dynamic: summary
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package.xml
|
||||
setup.cfg
|
||||
setup.py
|
||||
../../build_sim/arm_sim_gazebo/arm_sim_gazebo.egg-info/PKG-INFO
|
||||
../../build_sim/arm_sim_gazebo/arm_sim_gazebo.egg-info/SOURCES.txt
|
||||
../../build_sim/arm_sim_gazebo/arm_sim_gazebo.egg-info/dependency_links.txt
|
||||
../../build_sim/arm_sim_gazebo/arm_sim_gazebo.egg-info/entry_points.txt
|
||||
../../build_sim/arm_sim_gazebo/arm_sim_gazebo.egg-info/requires.txt
|
||||
../../build_sim/arm_sim_gazebo/arm_sim_gazebo.egg-info/top_level.txt
|
||||
../../build_sim/arm_sim_gazebo/arm_sim_gazebo.egg-info/zip-safe
|
||||
arm_sim_gazebo/__init__.py
|
||||
arm_sim_gazebo/gazebo_sim_launcher.py
|
||||
arm_sim_gazebo/robot_description.py
|
||||
arm_sim_gazebo/sim_camera_adapter.py
|
||||
arm_sim_gazebo/sim_cameras.py
|
||||
arm_sim_gazebo/sim_grasp_lock.py
|
||||
arm_sim_gazebo/sim_gripper.py
|
||||
arm_sim_gazebo/sim_gripper_adapter.py
|
||||
arm_sim_gazebo/sim_gripper_kin.py
|
||||
arm_sim_gazebo/sim_home_service.py
|
||||
arm_sim_gazebo/sim_live_pose.py
|
||||
arm_sim_gazebo/sim_pose_service.py
|
||||
arm_sim_gazebo/sim_scene.py
|
||||
arm_sim_gazebo/sim_site.py
|
||||
arm_sim_gazebo/sim_tactile.py
|
||||
arm_sim_gazebo/sim_tactile_adapter.py
|
||||
arm_sim_gazebo/sim_truth_perception.py
|
||||
arm_sim_gazebo/sim_wrist_ft.py
|
||||
arm_sim_gazebo/truth_holes.py
|
||||
arm_sim_gazebo/urdf_normalize.py
|
||||
launch/gazebo_sim.launch.py
|
||||
resource/arm_sim_gazebo
|
||||
test/test_check_sim_scene.py
|
||||
test/test_repo_root.py
|
||||
test/test_robot_description.py
|
||||
test/test_sim_cameras.py
|
||||
test/test_sim_grasp_lock.py
|
||||
test/test_sim_gripper.py
|
||||
test/test_sim_gripper_kin.py
|
||||
test/test_sim_pose_service.py
|
||||
test/test_sim_scene.py
|
||||
test/test_sim_site.py
|
||||
test/test_sim_tactile.py
|
||||
test/test_sim_truth_perception.py
|
||||
test/test_truth_holes.py
|
||||
test/test_urdf_normalize.py
|
||||
+1
@@ -0,0 +1 @@
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
[console_scripts]
|
||||
gazebo_sim_launcher = arm_sim_gazebo.gazebo_sim_launcher:main
|
||||
sim_camera_adapter = arm_sim_gazebo.sim_camera_adapter:main
|
||||
sim_gripper_adapter = arm_sim_gazebo.sim_gripper_adapter:main
|
||||
sim_home_service = arm_sim_gazebo.sim_home_service:main
|
||||
sim_tactile_adapter = arm_sim_gazebo.sim_tactile_adapter:main
|
||||
sim_truth_perception = arm_sim_gazebo.sim_truth_perception:main
|
||||
urdf_normalize = arm_sim_gazebo.urdf_normalize:main
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
setuptools
|
||||
PyYAML
|
||||
+1
@@ -0,0 +1 @@
|
||||
arm_sim_gazebo
|
||||
+1
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Gazebo 仿真后端。
|
||||
|
||||
只暴露纯函数级别的能力,不在这里 import rclpy —— 这样
|
||||
urdf_normalize 可以被单测直接导入,不需要 ROS 运行时。
|
||||
"""
|
||||
from .urdf_normalize import ( # noqa: F401
|
||||
NormalizeReport,
|
||||
normalize_file,
|
||||
normalize_urdf,
|
||||
)
|
||||
|
||||
__all__ = ["NormalizeReport", "normalize_file", "normalize_urdf"]
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
"""gazebo_sim_launcher —— devices/sim/gazebo/manifest.yaml 声明的那个可执行。
|
||||
|
||||
职责刻意做得很薄:它只负责"准备描述"这件事,然后交给常规的 launch 文件去跑。
|
||||
不把整套启动逻辑塞进可执行文件里,是因为 `ros2 launch` 的日志/退出码/中断处理
|
||||
都更成熟,自己重造一份只会更难调试。
|
||||
|
||||
用法:
|
||||
gazebo_sim_launcher # 准备描述并启动仿真
|
||||
gazebo_sim_launcher --print-urdf # 只看展开+归一化后的 URDF(排查用)
|
||||
gazebo_sim_launcher --check # 只做自检,不启动任何东西
|
||||
gazebo_sim_launcher --headless false # 带 GUI
|
||||
gazebo_sim_launcher --dry-run # 打印将要执行的命令,不执行
|
||||
|
||||
为什么 --print-urdf / --check 值得单独做:仿真跑不起来时,绝大多数问题都在
|
||||
"描述对不对"这一层(link 被丢、关节名对不上)。能把描述单独打出来看,
|
||||
比对着整套仿真的日志找要快一个量级。这两个选项都不需要 Gazebo 在跑。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from .robot_description import (
|
||||
SimDescriptionError,
|
||||
check_base_plugins,
|
||||
default_controllers,
|
||||
default_world,
|
||||
default_xacro,
|
||||
prepare_urdf,
|
||||
repo_root,
|
||||
world_name,
|
||||
)
|
||||
from .sim_site import (
|
||||
SimSiteError,
|
||||
load_sim_site,
|
||||
physics_enabled_in_world,
|
||||
resolve_world,
|
||||
unwired_warnings,
|
||||
)
|
||||
|
||||
LAUNCH_PKG = "arm_sim_gazebo"
|
||||
LAUNCH_FILE = "gazebo_sim.launch.py"
|
||||
|
||||
# 仿真实际使用的关节前缀,用来跟 site.yaml 的 sim.joint_prefix 比对。
|
||||
# 它由 arm.sim.urdf.xacro 的 arm_prefix 默认值决定,并且 controllers.yaml
|
||||
# 里硬编码了 sim_joint1..7,所以暂时**不可配置** —— 改了这里不改那两处会散架。
|
||||
# site.yaml 若写了一个不一样的值,unwired_warnings() 会告警,而不是静默忽略。
|
||||
ACTUAL_JOINT_PREFIX = "sim_"
|
||||
|
||||
|
||||
def _build_launch_cmd(extra: List[str]) -> List[str]:
|
||||
# 用 python -m 调 ros2 而不是 exec "ros2":这样在 ros2 CLI 不在 PATH 的
|
||||
# 环境里也能给出清晰报错,而不是一句 "No such file or directory"。
|
||||
return ["ros2", "launch", LAUNCH_PKG, LAUNCH_FILE, *extra]
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="gazebo_sim_launcher",
|
||||
description="准备仿真描述并启动 Gazebo(arm_sim_gazebo)。",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--print-urdf",
|
||||
action="store_true",
|
||||
help="把展开并归一化后的 URDF 打到 stdout,不启动仿真",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="只做自检(能否找到仓库根、描述能否展开、归一化了什么),不启动",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="打印将要执行的 ros2 launch 命令,不执行",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-gripper",
|
||||
action="store_true",
|
||||
help="不挂夹爪(纯运动学验证时更快)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-sensors",
|
||||
action="store_true",
|
||||
help="不挂相机与触觉",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--world", default="", help="世界文件路径,覆盖默认的 assembly.world"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--headless",
|
||||
default="true",
|
||||
choices=["true", "false"],
|
||||
help="true=只开 Gazebo 服务端(无显示器环境用);false=带 GUI",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
action="store_true",
|
||||
help="打开 Gazebo 详细日志(排查插件加载失败时开)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
# ---- 1) 准备描述(这一步是真正有内容的,其余都是转发)------------------
|
||||
try:
|
||||
root = repo_root()
|
||||
xacro_path = default_xacro(root)
|
||||
controllers = default_controllers(root)
|
||||
# world 的优先级:--world > site.yaml 的 sim.world > 内置默认。
|
||||
# 以前这里只认 --world 和硬编码默认值,而 site.yaml / manifest.yaml
|
||||
# 都宣称改 sim.world 就生效 —— 结果改了毫无反应且不报错。
|
||||
world, world_origin = resolve_world(args.world, root, fallback=default_world(root))
|
||||
world = str(world)
|
||||
site = load_sim_site(root)
|
||||
urdf, report = prepare_urdf(
|
||||
xacro_path=xacro_path,
|
||||
controllers_file=controllers,
|
||||
load_gripper=not args.no_gripper,
|
||||
with_sensors=not args.no_sensors,
|
||||
)
|
||||
wname = world_name(world)
|
||||
missing = check_base_plugins(Path(world))
|
||||
except (SimDescriptionError, SimSiteError) as exc:
|
||||
print(f"[gazebo_sim_launcher] 失败:\n{exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
# site.yaml 里"声明了但没接线"的键:与其静默无效,不如明说。
|
||||
# 见 sim_site.unwired_warnings() —— 那里逐条说明了为什么还没接。
|
||||
for _w in unwired_warnings(
|
||||
site,
|
||||
actual_joint_prefix=ACTUAL_JOINT_PREFIX,
|
||||
physics_in_world=physics_enabled_in_world(Path(world)),
|
||||
):
|
||||
print(f"[gazebo_sim_launcher] ⚠ {_w}", file=sys.stderr)
|
||||
|
||||
if missing:
|
||||
# 这一条能省掉一整个下午:world 漏声明基础系统时,仿真看起来完全正常
|
||||
# (/clock 在走、create 打印成功),但机械臂从未进入 Gazebo。
|
||||
print(
|
||||
"[gazebo_sim_launcher] ⚠ world 缺少基础系统: "
|
||||
+ ", ".join(missing) + "\n"
|
||||
" world 里只要声明了任何一个 <plugin>,Gazebo 就不再加载默认系统列表。\n"
|
||||
" 缺 UserCommands → /world/<名的>/create 服务不存在 → 模型根本不会\n"
|
||||
" 被创建,而 create 仍会打印 'OK creation of entity'。最终表现为\n"
|
||||
" 控制器永远拉不起来。修法:在这个 world 里显式声明\n"
|
||||
" Physics / UserCommands / SceneBroadcaster(见文件里的注释)。",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
if args.print_urdf:
|
||||
# URDF 走 stdout(可重定向),摘要走 stderr(不污染管道)
|
||||
sys.stdout.write(urdf)
|
||||
print(f"\n[gazebo_sim_launcher] {report.summary()}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
if args.check:
|
||||
print("[gazebo_sim_launcher] 自检通过")
|
||||
print(f" 仓库根 : {root}")
|
||||
print(f" 描述 : {xacro_path}")
|
||||
print(f" 控制器配置 : {controllers}")
|
||||
print(f" 世界 : {world} (world 名: {wname})")
|
||||
print(f" 世界来源 : {world_origin}")
|
||||
if site.source is not None:
|
||||
print(f" site.yaml : {site.source}")
|
||||
else:
|
||||
print(" site.yaml : 不存在(用默认值;这不会影响仿真)")
|
||||
print(f" URDF 归一化 : {report.summary()}")
|
||||
if missing:
|
||||
print(
|
||||
f" ⚠ world 缺少基础系统: {', '.join(missing)}\n"
|
||||
" world 里只要声明了任何一个 <plugin>,Gazebo 就不再加载默认系统。\n"
|
||||
" 缺 UserCommands → /world/<名的>/create 服务不存在 → 模型不会被创建,\n"
|
||||
" 而 create 仍打印 'OK creation of entity',控制器永远拉不起来。"
|
||||
)
|
||||
else:
|
||||
print(" world 基础系统 : Physics / UserCommands / SceneBroadcaster 齐备")
|
||||
if not report.changed:
|
||||
print(
|
||||
" ⚠ 归一化没有改动任何内容。若后续 Gazebo 报 link 不存在,\n"
|
||||
" 先确认 flexiv_description 的版本是否已自行修好那两个上游缺陷。"
|
||||
)
|
||||
print("\n 下一步: gazebo_sim_launcher --dry-run # 看会执行什么")
|
||||
return 0
|
||||
|
||||
# ---- 2) 转发给 launch 文件 ---------------------------------------------
|
||||
# 参数名在这里显式映射到 launch 参数,不做通用透传 —— 通用透传会把拼错
|
||||
# 的参数名静默吞掉(ros2 launch 对未知的 key:=value 不报错),
|
||||
# 而"我明明设了 --headless false 却没生效"这种问题极难查。
|
||||
extra = [
|
||||
"headless:=" + args.headless,
|
||||
"verbose:=" + ("true" if args.verbose else "false"),
|
||||
]
|
||||
if args.world:
|
||||
extra.append("world:=" + args.world)
|
||||
if args.no_gripper:
|
||||
extra.append("load_gripper:=false")
|
||||
if args.no_sensors:
|
||||
extra.append("with_sensors:=false")
|
||||
|
||||
cmd = _build_launch_cmd(extra)
|
||||
print(f"[gazebo_sim_launcher] {report.summary()}")
|
||||
print(f"[gazebo_sim_launcher] exec: {' '.join(cmd)}")
|
||||
|
||||
if args.dry_run:
|
||||
return 0
|
||||
|
||||
try:
|
||||
os.execvp(cmd[0], cmd)
|
||||
except FileNotFoundError:
|
||||
print(
|
||||
"[gazebo_sim_launcher] 找不到 ros2 命令。先激活环境:\n"
|
||||
" source scripts/sim_env.sh",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+612
@@ -0,0 +1,612 @@
|
||||
"""组装仿真用的机器人描述:xacro 展开 → 归一化 → 交给 ROS / Gazebo。
|
||||
|
||||
=============================================================================
|
||||
为什么单独一个模块
|
||||
=============================================================================
|
||||
"把 devices/sim/gazebo/urdf 下的描述变成一份可用的 URDF"这件事有两处需要:
|
||||
1. launch 文件(真正启动仿真时)
|
||||
2. gazebo_sim_launcher 的 --print-urdf(只想看看展开+归一化后长什么样)
|
||||
两边各写一份必然漂移,所以实现只放这里,两处都 import 它。
|
||||
|
||||
=============================================================================
|
||||
仓库根怎么找
|
||||
=============================================================================
|
||||
devices/ **不是 ROS 包**,所以 $(find ...) 找不到它,必须用文件系统路径。
|
||||
按可靠性从高到低试三种:
|
||||
1. $ROS2_WS(scripts/sim_env.sh 会导出)—— 最可靠,显式且便宜
|
||||
2. 从当前工作目录向上找标志文件 —— 手工跑脚本时的常见情形
|
||||
3. 从本模块所在位置向上找 —— 兜底(安装到 install_sim/ 后路径较深)
|
||||
三者都失败时给出明确的报错和修复提示,而不是抛一个看不懂的 FileNotFoundError。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from .urdf_normalize import NormalizeReport, normalize_urdf
|
||||
|
||||
# 用它判断"这个目录是不是仓库根"。选它是因为这个文件是仿真描述的唯一入口,
|
||||
# 存在即说明目录结构正确;不像 .git 那样在导出/打包后可能不存在。
|
||||
_MARKER = "devices/sim/gazebo/urdf/arm.sim.urdf.xacro"
|
||||
|
||||
_XACRO_REL = "devices/sim/gazebo/urdf/arm.sim.urdf.xacro"
|
||||
_CONTROLLERS_REL = "devices/sim/gazebo/config/controllers.yaml"
|
||||
_WORLD_REL = "devices/sim/gazebo/worlds/assembly.world"
|
||||
|
||||
|
||||
class SimDescriptionError(RuntimeError):
|
||||
"""描述准备过程中的可预期失败,消息面向使用者而非开发者。"""
|
||||
|
||||
|
||||
def repo_root(start: Optional[Path] = None) -> Path:
|
||||
"""定位仓库根。找不到时抛 SimDescriptionError 并说明怎么修。"""
|
||||
env = os.environ.get("ROS2_WS", "").strip()
|
||||
if env:
|
||||
p = Path(env).expanduser()
|
||||
if (p / _MARKER).is_file():
|
||||
return p
|
||||
|
||||
seeds = []
|
||||
if start is not None:
|
||||
seeds.append(Path(start).expanduser())
|
||||
seeds.append(Path.cwd())
|
||||
seeds.append(Path(__file__).resolve().parent)
|
||||
|
||||
for seed in seeds:
|
||||
cur = seed.resolve()
|
||||
for _ in range(12): # 足够覆盖仓库内的任意深度
|
||||
if (cur / _MARKER).is_file():
|
||||
return cur
|
||||
if cur.parent == cur:
|
||||
break
|
||||
cur = cur.parent
|
||||
|
||||
raise SimDescriptionError(
|
||||
"找不到仓库根(即包含 %s 的目录)。\n"
|
||||
" 修法(任选其一):\n"
|
||||
" 1) source scripts/sim_env.sh # 它会导出 ROS2_WS\n"
|
||||
" 2) 在仓库根目录下执行\n"
|
||||
" 已试过的起点:%s" % (_MARKER, ", ".join(str(s) for s in seeds))
|
||||
)
|
||||
|
||||
|
||||
def default_xacro(root: Optional[Path] = None) -> Path:
|
||||
return (root or repo_root()) / _XACRO_REL
|
||||
|
||||
|
||||
def default_controllers(root: Optional[Path] = None) -> Path:
|
||||
return (root or repo_root()) / _CONTROLLERS_REL
|
||||
|
||||
|
||||
def default_world(root: Optional[Path] = None) -> Path:
|
||||
return (root or repo_root()) / _WORLD_REL
|
||||
|
||||
|
||||
def world_name(world_file: Path) -> str:
|
||||
"""从 world 文件里读出 <world name="...">。
|
||||
|
||||
为什么解析而不是让调用方传:world 名必须和文件内容一致,让它们分别配置
|
||||
就等于制造一个"改了文件名忘了改参数"的机会。create 参数写错 world 名时
|
||||
Gazebo 不会报错,只是实体静默地没被创建。
|
||||
"""
|
||||
try:
|
||||
text = Path(world_file).read_text(encoding="utf-8", errors="replace")
|
||||
except OSError as exc:
|
||||
raise SimDescriptionError(f"读不到 world 文件 {world_file}: {exc}") from exc
|
||||
|
||||
# 同时接受单引号与双引号:手写 world 时两种都会出现,
|
||||
# 早期只认双引号,于是 `<world name='x'>` 会被判成"找不到 world 名"。
|
||||
m = re.search(r"""<world\b[^>]*\bname\s*=\s*["']([^"']+)["']""", text)
|
||||
if not m:
|
||||
raise SimDescriptionError(
|
||||
f"{world_file} 里找不到 <world name=\"...\">,无法确定 world 名。"
|
||||
)
|
||||
return m.group(1)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 从 world 文件里读模型位姿
|
||||
# =============================================================================
|
||||
# 为什么需要:Gazebo 里的 <model>(尤其是 static 的工装/相机支架)**不会**产生
|
||||
# ROS TF —— TF 只来自 robot_state_publisher。但应用要做 tf2 查询,比如
|
||||
# "把 D435 看到的点从相机系转到 world"。少了这条 TF,查询直接报
|
||||
# "frame does not exist",而图像本身看起来完全正常。
|
||||
#
|
||||
# 为什么是"解析"而不是"再配置一遍":位姿的唯一真相就在 world 文件里。
|
||||
# 另配一份等于制造"改了 world 忘了改配置"的机会,而这种不一致不会报错,
|
||||
# 只会让相机看到的东西和它自称的位置对不上 —— 极难查。
|
||||
def _find_block(text: str, tag: str, name: str) -> Optional[str]:
|
||||
"""取出 <tag name="name">...</tag> 的内容(考虑同名标签嵌套)。
|
||||
|
||||
SDF 里 <model> 可以嵌套 <model>(组合体),<link> 里也可能有 <link>(少见)。
|
||||
只找第一个 </tag> 在嵌套时会截断到错误的位置,所以这里数嵌套深度。
|
||||
"""
|
||||
pat = re.compile(
|
||||
r"<%(t)s\b[^>]*\bname\s*=\s*[\"']%(n)s[\"'][^>]*>" % {"t": tag, "n": re.escape(name)}
|
||||
)
|
||||
m = pat.search(text)
|
||||
if not m:
|
||||
return None
|
||||
|
||||
# 自闭合:<link name="l"/> 是完全合法的 SDF(没有子元素)。
|
||||
# 不特判的话会走到下面的"找 </link>"分支、找不到 → 返回 None,
|
||||
# 于是被误报成"这个 link 不存在",而它明明在。
|
||||
if m.group(0).rstrip().endswith("/>"):
|
||||
return ""
|
||||
|
||||
inner_start = m.end()
|
||||
depth = 1
|
||||
pos = inner_start
|
||||
open_re = re.compile(r"<%(t)s\b" % {"t": tag})
|
||||
close_re = re.compile(r"</%(t)s\s*>" % {"t": tag})
|
||||
while depth > 0:
|
||||
o = open_re.search(text, pos)
|
||||
c = close_re.search(text, pos)
|
||||
if c is None:
|
||||
return None # 标签没闭合 → 文件本身有问题,交给别的检查去报
|
||||
if o is not None and o.start() < c.start():
|
||||
depth += 1
|
||||
pos = o.end()
|
||||
else:
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return text[inner_start : c.start()]
|
||||
pos = c.end()
|
||||
return None
|
||||
|
||||
|
||||
def _own_pose(block: str, stop_before: Tuple[str, ...]) -> Tuple[float, float, float, float, float, float]:
|
||||
"""取一段块自己的 <pose>(默认全 0)。
|
||||
|
||||
只认**第一个** <pose>,且必须出现在 stop_before 里任一标签之前 ——
|
||||
这样才不会把子元素的 pose 误当成父元素的。例如 <model> 的 pose 必须在
|
||||
第一个 <link>/<model>/<joint> 之前;<link> 的 pose 必须在第一个
|
||||
<visual>/<collision>/<sensor> 之前。
|
||||
"""
|
||||
cut = len(block)
|
||||
for tag in stop_before:
|
||||
i = block.find("<" + tag)
|
||||
if 0 <= i < cut:
|
||||
cut = i
|
||||
m = re.search(r"<pose(?:\s[^>]*)?>([^<]*)</pose>", block[:cut])
|
||||
if not m:
|
||||
return (0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
vals = m.group(1).split()
|
||||
if len(vals) < 6:
|
||||
return (0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
try:
|
||||
return tuple(float(v) for v in vals[:6]) # type: ignore[return-value]
|
||||
except ValueError:
|
||||
return (0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
|
||||
|
||||
def _rpy_to_quat(r: float, p: float, y: float) -> Tuple[float, float, float, float]:
|
||||
cr, sr = math.cos(r / 2), math.sin(r / 2)
|
||||
cp, sp = math.cos(p / 2), math.sin(p / 2)
|
||||
cy, sy = math.cos(y / 2), math.sin(y / 2)
|
||||
return (
|
||||
sr * cp * cy - cr * sp * sy,
|
||||
cr * sp * cy + sr * cp * sy,
|
||||
cr * cp * sy - sr * sp * cy,
|
||||
cr * cp * cy + sr * sp * sy,
|
||||
)
|
||||
|
||||
|
||||
def _quat_to_rpy(q: Tuple[float, float, float, float]) -> Tuple[float, float, float]:
|
||||
x, y, z, w = q
|
||||
# roll (x 轴)
|
||||
sinr = 2 * (w * x + y * z)
|
||||
cosr = 1 - 2 * (x * x + y * y)
|
||||
roll = math.atan2(sinr, cosr)
|
||||
# pitch (y 轴):用 asin,并夹到 [-1,1] 免得浮点误差导致 domain error
|
||||
sinp = 2 * (w * y - z * x)
|
||||
sinp = max(-1.0, min(1.0, sinp))
|
||||
pitch = math.asin(sinp)
|
||||
# yaw (z 轴)
|
||||
siny = 2 * (w * z + x * y)
|
||||
cosy = 1 - 2 * (y * y + z * z)
|
||||
yaw = math.atan2(siny, cosy)
|
||||
return (roll, pitch, yaw)
|
||||
|
||||
|
||||
def _quat_mul(a, b):
|
||||
ax, ay, az, aw = a
|
||||
bx, by, bz, bw = b
|
||||
return (
|
||||
aw * bx + ax * bw + ay * bz - az * by,
|
||||
aw * by - ax * bz + ay * bw + az * bx,
|
||||
aw * bz + ax * by - ay * bx + az * bw,
|
||||
aw * bw - ax * bx - ay * by - az * bz,
|
||||
)
|
||||
|
||||
|
||||
def _rotate(q, v):
|
||||
"""用四元数旋转向量 v(v 在子系里,返回在父系里的表达)。"""
|
||||
x, y, z, w = q
|
||||
vx, vy, vz = v
|
||||
# t = 2 * (q_vec × v)
|
||||
tx = 2 * (y * vz - z * vy)
|
||||
ty = 2 * (z * vx - x * vz)
|
||||
tz = 2 * (x * vy - y * vx)
|
||||
# v' = v + w*t + q_vec × t
|
||||
return (
|
||||
vx + w * tx + (y * tz - z * ty),
|
||||
vy + w * ty + (z * tx - x * tz),
|
||||
vz + w * tz + (x * ty - y * tx),
|
||||
)
|
||||
|
||||
|
||||
def model_link_pose(
|
||||
world_file: Path, model: str, link: str = ""
|
||||
) -> Tuple[Tuple[float, float, float], Tuple[float, float, float]]:
|
||||
"""返回 world 文件里 <model name=model> 的 <link name=link> 在**世界系**的位姿。
|
||||
|
||||
返回值是 ((x, y, z), (roll, pitch, yaw))。link 留空表示取模型本身的位姿。
|
||||
两者都会做复合(模型位姿 ∘ link 位姿),因为 SDF 的 <pose> 是相对父元素的。
|
||||
|
||||
找不到模型/link 时抛 SimDescriptionError,并提示这里和 cameras.yaml 的
|
||||
world_model 字段必须对应 —— 配错的话相机不会有 TF,而症状是
|
||||
"tf 查询报 frame does not exist",与相机本身无关,容易查错方向。
|
||||
"""
|
||||
text = Path(world_file).read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
mblock = _find_block(text, "model", model)
|
||||
if mblock is None:
|
||||
raise SimDescriptionError(
|
||||
f"{world_file} 里找不到 <model name=\"{model}\">。\n"
|
||||
f" devices/sim/gazebo/config/cameras.yaml 里若有 world_model: {model},"
|
||||
f"就必须在 world 里真的有这个模型 —— 否则该相机没有 TF,"
|
||||
f"应用做 tf2 查询会报 frame does not exist。"
|
||||
)
|
||||
|
||||
mp = _own_pose(mblock, ("link", "model", "joint", "plugin"))
|
||||
mq = _rpy_to_quat(mp[3], mp[4], mp[5])
|
||||
|
||||
if not link:
|
||||
return ((mp[0], mp[1], mp[2]), (mp[3], mp[4], mp[5]))
|
||||
|
||||
lblock = _find_block(mblock, "link", link)
|
||||
if lblock is None:
|
||||
raise SimDescriptionError(
|
||||
f"{world_file} 的 <model name=\"{model}\"> 里找不到 <link name=\"{link}\">。"
|
||||
)
|
||||
|
||||
lp = _own_pose(lblock, ("visual", "collision", "sensor", "light"))
|
||||
lq = _rpy_to_quat(lp[3], lp[4], lp[5])
|
||||
|
||||
# 复合:世界系下的位置 = t_model + R_model * t_link
|
||||
t = _rotate(mq, (lp[0], lp[1], lp[2]))
|
||||
pos = (mp[0] + t[0], mp[1] + t[1], mp[2] + t[2])
|
||||
rpy = _quat_to_rpy(_quat_mul(mq, lq))
|
||||
return (pos, rpy)
|
||||
|
||||
|
||||
# 只要 world 里声明了任何一个 <plugin>,Gazebo 就不再加载 server.config 的默认
|
||||
# 系统列表,这三个基础系统必须由 world 自己声明,否则整个仿真静默失效。
|
||||
# 详细后果见 assembly.world 里那段注释,以及这里的 check_base_plugins()。
|
||||
BASE_SYSTEMS = {
|
||||
"Physics": "ignition::gazebo::systems::Physics",
|
||||
"UserCommands": "ignition::gazebo::systems::UserCommands",
|
||||
"SceneBroadcaster": "ignition::gazebo::systems::SceneBroadcaster",
|
||||
}
|
||||
|
||||
|
||||
def check_base_plugins(world_file: Path) -> List[str]:
|
||||
"""检查 world 是否漏声明基础系统。返回缺失的短名列表(空 = 没问题)。
|
||||
|
||||
背景(实测,本机 RoboStack/conda 的 Fortress 6.16):
|
||||
world 里**只要声明了任何一个 <plugin>**,Gazebo 就跳过 server.config 里
|
||||
的默认系统。于是少了 UserCommands → /world/<w>/create 服务不存在 →
|
||||
ros_gz_sim create 超时后回退发 topic、没人收 → **模型从未被创建**,
|
||||
但 create 节点照样打印 "OK creation of entity"。最终表现是控制器永远
|
||||
拉不起来,而每一步日志看起来都是成功的。
|
||||
|
||||
这个检查是纯文本的、不需要 Gazebo 在跑,所以放进 --check 里,
|
||||
让"改了 world 忘了加基础系统"在启动前就被指出来。
|
||||
"""
|
||||
text = Path(world_file).read_text(encoding="utf-8", errors="replace")
|
||||
if "<plugin" not in text:
|
||||
return [] # 没声明任何 plugin → Gazebo 用默认配置,反而正常
|
||||
|
||||
missing = []
|
||||
for short, fq in BASE_SYSTEMS.items():
|
||||
# 名字里有点号,用 re.escape 免得被当通配
|
||||
if not re.search(re.escape(fq), text):
|
||||
missing.append(short)
|
||||
return missing
|
||||
|
||||
|
||||
def check_tactile_collisions(
|
||||
urdf: str, expected_topics: Optional[Sequence[str]] = None
|
||||
) -> List[str]:
|
||||
"""把展开后的 URDF 转成 SDF,核对触觉传感器的引用是否真的存在。
|
||||
|
||||
返回问题列表(空 = 没问题)。``expected_topics`` 若给出,还会核对这些话题
|
||||
在 SDF 里是否真的被声明了 —— 这是**最有价值的一项**:传感器标签一旦没进
|
||||
SDF(写错位置、被 sdformat 静默丢掉),Gazebo 里就只会表现为"话题不出现、
|
||||
日志空白",而本检查能在启动前直接指出是哪个话题缺了。
|
||||
|
||||
已实测确认能抓到的情形:
|
||||
* <contact><collision> 引用的名字在 SDF 里不存在(sdformat 命名规则变了)
|
||||
* <contact> 里没有 <topic>(sdformat 会补一个占位值 __default_topic__,
|
||||
所以这一项不能只判"空",要连占位值一起判 —— 这是单测逼出来的)
|
||||
* 传感器标签没有产生出任何 force_torque/contact 传感器
|
||||
* 配置里声明的话题在 SDF 里找不到
|
||||
|
||||
=========================================================================
|
||||
为什么必须有这个检查
|
||||
=========================================================================
|
||||
接触传感器的 <collision> 里写的**不是我们起的名字**,而是 sdformat 从
|
||||
URDF 转 SDF 时按规则自动生成的:
|
||||
|
||||
<fixed 关节链的父 link> + "_fixed_joint_lump__" + <本 link> + "_" + collision 名
|
||||
|
||||
本机实测(Fortress 6.16)得到
|
||||
sim_left_finger_mount_fixed_joint_lump__sim_left_finger_tip_collision
|
||||
而 URDF 的 <collision> 不能命名,所以没法给它一个稳定名字。代价是:
|
||||
**换一个 sdformat 版本、命名规则一变,传感器就永远收不到数据 —— 而
|
||||
Gazebo 不会为此报任何错**(话题在、没输出,看起来像"还没接触")。
|
||||
|
||||
同样容易静默失效的两处,这里一并核对:
|
||||
* force_torque 传感器挂的关节是否存在于 SDF(写错关节名 = 永不发布)
|
||||
* 接触传感器的话题是否在 <contact> 里(写在 <sensor> 层会被忽略,
|
||||
gz 转而使用默认的 scoped 话题名,于是"按 /tactile/left/raw 订阅"
|
||||
永远收不到东西)
|
||||
|
||||
纯文本 + 一次 `ign sdf -p`,不需要 Gazebo 在跑,所以放进启动前的检查里。
|
||||
"""
|
||||
try:
|
||||
sdf = _urdf_to_sdf(urdf)
|
||||
except SimDescriptionError as exc:
|
||||
return [f"无法把 URDF 转成 SDF 做触觉检查:{exc}"]
|
||||
|
||||
problems: List[str] = []
|
||||
try:
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
root = ET.fromstring(sdf)
|
||||
except ET.ParseError as exc:
|
||||
return [f"SDF 解析失败,无法核对触觉传感器: {exc}"]
|
||||
|
||||
# SDF 里接触传感器的 <collision> 引用的是 collision 的**限定名**。
|
||||
# ⚠️ 不要再拼一次 link 名前缀:sdformat 为 fixed 关节融合出来的 collision
|
||||
# 名字本身就已经含 link 前缀
|
||||
# (sim_left_finger_mount_fixed_joint_lump__sim_left_finger_tip_collision),
|
||||
# 拼了反而变成一个不存在的名字 —— 于是这个检查会**误报**,
|
||||
# 而误报比不检查更糟:真出问题时没人信它。两种写法都收。
|
||||
collision_names = set()
|
||||
for link in root.iter("link"):
|
||||
lname = link.get("name", "")
|
||||
for col in link.findall("collision"):
|
||||
cname = col.get("name", "")
|
||||
if cname:
|
||||
collision_names.add(cname)
|
||||
collision_names.add(f"{lname}::{cname}")
|
||||
elif lname:
|
||||
collision_names.add(lname)
|
||||
|
||||
joint_names = {j.get("name", "") for j in root.iter("joint")}
|
||||
sdf_topics = {(t.text or "").strip() for t in root.iter("topic")}
|
||||
n_contact = 0
|
||||
n_ft = 0
|
||||
|
||||
for sensor in root.iter("sensor"):
|
||||
stype = sensor.get("type", "")
|
||||
sname = sensor.get("name", "?")
|
||||
if stype == "contact":
|
||||
n_contact += 1
|
||||
contact = sensor.find("contact")
|
||||
if contact is None:
|
||||
problems.append(f"接触传感器 {sname} 没有 <contact> 块")
|
||||
continue
|
||||
ref = (contact.findtext("collision") or "").strip()
|
||||
if not ref or ref == "__default__":
|
||||
problems.append(
|
||||
f"接触传感器 {sname} 的 <contact> 里没有可用的 <collision>"
|
||||
f"(实测值: {ref or '空'})。\n"
|
||||
f" 留空不会「监测全部」:sdformat 会补成 __default__"
|
||||
f"(一个不存在的 collision),症状是永远没有输出。"
|
||||
)
|
||||
elif ref not in collision_names:
|
||||
problems.append(
|
||||
f"接触传感器 {sname} 引用的 collision 在 SDF 里不存在: {ref}\n"
|
||||
f" 这几乎总是 sdformat 的自动命名规则变了。"
|
||||
f"现有 collision: {sorted(collision_names)[:4]} ...\n"
|
||||
f" 用 `ign sdf -p <展开后的 urdf>` 看实际名字,"
|
||||
f"改 devices/sim/gazebo/urdf/arm.sim.sensors.xacro 里的 <collision>。"
|
||||
)
|
||||
# ⚠️ 同样要防 sdformat 的占位值:缺 <topic> 时它会补一个
|
||||
# __default_topic__(实测),于是"有没有 <topic>"这种检查会被骗过去。
|
||||
# __default_topic__ 不是可用话题名 —— gz 实际发的是
|
||||
# /world/<w>/model/<m>/link/<l>/sensor/<s>/contact。
|
||||
ctopic = (contact.findtext("topic") or "").strip()
|
||||
if ctopic in ("", "__default_topic__"):
|
||||
problems.append(
|
||||
f"接触传感器 {sname} 的 <contact> 里没有可用 <topic>"
|
||||
f"(实测值: {ctopic or '空'})。\n"
|
||||
f" 话题必须写在 contact/topic(写在 <sensor> 层会被静默忽略,"
|
||||
f"gz 改用默认 scoped 话题名)。"
|
||||
)
|
||||
elif stype == "force_torque":
|
||||
n_ft += 1
|
||||
parent = _sdf_parent_joint(root, sensor)
|
||||
if parent is None:
|
||||
problems.append(
|
||||
f"力传感器 {sname} 不在任何 <joint> 里。\n"
|
||||
f" force_torque 在 SDF 里只能属于关节;用 "
|
||||
f"<gazebo reference=「关节名」><sensor .../></gazebo> 表达,"
|
||||
f"sdformat 会把它移进对应的 <joint>。"
|
||||
)
|
||||
elif parent not in joint_names:
|
||||
problems.append(f"力传感器 {sname} 挂在未知关节 {parent} 上")
|
||||
if (sensor.findtext("topic") or "").strip() == "":
|
||||
problems.append(
|
||||
f"力传感器 {sname} 没有 <topic>(它不会用默认话题名,"
|
||||
f"而是完全不发布)。"
|
||||
)
|
||||
|
||||
# 传感器是否被整个丢掉 —— 这一项最容易被忽略,也最难现场排查。
|
||||
if n_contact == 0 and n_ft == 0:
|
||||
problems.append(
|
||||
"SDF 里**一个触觉传感器都没有**。\n"
|
||||
" 说明 arm.sim.sensors.xacro 里的 <gazebo reference=...> 名字写错、"
|
||||
"或传感器标签写到了错误的位置 —— sdformat 会把整块**静默丢掉**:\n"
|
||||
" 转换成功、无警告、Gazebo 里话题不出现、日志空白。"
|
||||
)
|
||||
if expected_topics:
|
||||
missing = [t for t in expected_topics if t not in sdf_topics]
|
||||
if missing:
|
||||
problems.append(
|
||||
f"以下触觉话题在 SDF 里不存在: {', '.join(missing)}\n"
|
||||
f" 配置(tactile.yaml)里声明了这些话题,但描述里没有产生它们。\n"
|
||||
f" 常见原因:<gazebo reference=「关节名」> 的关节名不存在"
|
||||
f"(整个传感器被丢掉),或 contact 传感器少写了 <topic>。\n"
|
||||
f" SDF 里实际有的话题: {sorted(t for t in sdf_topics if t)}"
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
def _sdf_parent_joint(root, sensor) -> Optional[str]:
|
||||
"""在 SDF 树里找 sensor 所属的 <joint> 名。"""
|
||||
for joint in root.iter("joint"):
|
||||
for s in joint.findall("sensor"):
|
||||
if s is sensor:
|
||||
return joint.get("name", "")
|
||||
return None
|
||||
|
||||
|
||||
def _urdf_to_sdf(urdf: str) -> str:
|
||||
"""调 `ign sdf -p` 把 URDF 文本转成 SDF 文本。
|
||||
|
||||
走命令行而不是 Python 绑定:sdformat 的 Python 绑定在这个环境里没有装,
|
||||
而 ign CLI 是仿真环境的必备件(sim_env.sh 里就有)。
|
||||
"""
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".urdf", delete=False,
|
||||
encoding="utf-8") as fh:
|
||||
fh.write(urdf)
|
||||
tmp = fh.name
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["ign", "sdf", "-p", tmp], capture_output=True, text=True, timeout=120
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise SimDescriptionError(
|
||||
"找不到 ign 命令,无法做 SDF 转换检查。修法: source scripts/sim_env.sh"
|
||||
) from exc
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
if r.returncode != 0:
|
||||
raise SimDescriptionError(
|
||||
f"ign sdf 转换失败(退出码 {r.returncode}): {(r.stderr or r.stdout).strip()[:600]}"
|
||||
)
|
||||
return r.stdout
|
||||
|
||||
|
||||
def expand_xacro(
|
||||
xacro_path: Path,
|
||||
mappings: Optional[Dict[str, object]] = None,
|
||||
) -> str:
|
||||
"""跑 xacro 展开。
|
||||
|
||||
优先用 Python 模块(避免子进程与 PATH 依赖),失败再退回命令行。
|
||||
两者行为一致,但模块方式能拿到更干净的报错。
|
||||
"""
|
||||
xacro_path = Path(xacro_path)
|
||||
if not xacro_path.is_file():
|
||||
raise SimDescriptionError(f"xacro 文件不存在: {xacro_path}")
|
||||
|
||||
try:
|
||||
import xacro # type: ignore
|
||||
except ImportError:
|
||||
return _expand_via_cli(xacro_path, mappings)
|
||||
|
||||
try:
|
||||
doc = xacro.process_file(str(xacro_path), mappings=mappings or {})
|
||||
except Exception as exc: # xacro 的异常类型很杂,统一转成可读报错
|
||||
raise SimDescriptionError(
|
||||
f"xacro 展开失败: {xacro_path}\n 原因: {exc}\n"
|
||||
f" 单独复现: xacro {xacro_path}"
|
||||
) from exc
|
||||
return doc.toxml()
|
||||
|
||||
|
||||
def _expand_via_cli(xacro_path: Path, mappings: Optional[Dict[str, object]]) -> str:
|
||||
cmd = ["xacro", str(xacro_path)]
|
||||
for k, v in (mappings or {}).items():
|
||||
cmd.append(f"{k}:={v}")
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
except FileNotFoundError as exc:
|
||||
raise SimDescriptionError(
|
||||
"既 import 不到 xacro 模块,也找不到 xacro 命令。\n"
|
||||
" 修法: source scripts/sim_env.sh"
|
||||
) from exc
|
||||
if r.returncode != 0:
|
||||
raise SimDescriptionError(
|
||||
f"xacro 展开失败(退出码 {r.returncode}): {xacro_path}\n"
|
||||
f" {(r.stderr or r.stdout).strip()[:800]}"
|
||||
)
|
||||
return r.stdout
|
||||
|
||||
|
||||
def prepare_urdf(
|
||||
xacro_path: Optional[Path] = None,
|
||||
controllers_file: Optional[Path] = None,
|
||||
load_gripper: bool = True,
|
||||
with_sensors: bool = True,
|
||||
rizon_type: str = "Rizon4",
|
||||
arm_prefix: str = "sim",
|
||||
robot_sn: str = "",
|
||||
wrist_ft_topic: str = "",
|
||||
) -> Tuple[str, NormalizeReport]:
|
||||
"""展开 + 归一化,返回 (URDF 文本, 归一化报告)。
|
||||
|
||||
调用方**必须**把这份 URDF 同时用于两处,否则会出问题:
|
||||
- robot_state_publisher 发布到 /robot_description
|
||||
- ros_gz_sim create 用它 spawn 进 Gazebo
|
||||
gz_ros2_control 的插件是从 /robot_description 读 <ros2_control> 的
|
||||
(SDF 里只存了插件名和参数文件路径,接口声明不在 SDF 里,实测确认)。
|
||||
所以若 RSP 发的是**未归一化**的那份,控制器注册的关节名就会和 Gazebo 里的
|
||||
对不上,症状是 "Joint 'sim_joint1' not found in the robot model"。
|
||||
|
||||
:param wrist_ft_topic: 腕部 FT 的 gz 话题名。**从 wrist_ft.yaml 传进来**,
|
||||
而不是让 xacro 里再写一份 —— 话题名有三处要对上(配置、URDF、
|
||||
应用层按 sn 推出的名字),两处写死迟早漂移。空 = 用 xacro 的默认值。
|
||||
"""
|
||||
root = repo_root()
|
||||
xacro_path = Path(xacro_path) if xacro_path else default_xacro(root)
|
||||
controllers_file = (
|
||||
Path(controllers_file) if controllers_file else default_controllers(root)
|
||||
)
|
||||
|
||||
if not Path(controllers_file).is_file():
|
||||
raise SimDescriptionError(
|
||||
f"控制器配置不存在: {controllers_file}\n"
|
||||
f" 它是 arm.sim.gazebo.xacro 里系统插件的 <parameters> 指向的文件,"
|
||||
f"缺了会导致 Gazebo 报 'Failed to load system plugin'。"
|
||||
)
|
||||
|
||||
xacro_args = {
|
||||
"controllers_file": str(Path(controllers_file).resolve()),
|
||||
"load_gripper": "true" if load_gripper else "false",
|
||||
"with_sensors": "true" if with_sensors else "false",
|
||||
"rizon_type": rizon_type,
|
||||
"arm_prefix": arm_prefix,
|
||||
"robot_sn": robot_sn,
|
||||
}
|
||||
# 只在给了值时才覆盖,否则保持 xacro 里那个有注释、能单独跑 xacro 的默认值。
|
||||
if wrist_ft_topic:
|
||||
xacro_args["wrist_ft_topic"] = wrist_ft_topic
|
||||
|
||||
urdf = expand_xacro(xacro_path, xacro_args)
|
||||
normalized, report = normalize_urdf(urdf)
|
||||
return normalized, report
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
"""sim_camera_adapter —— 把桥接过来的 Gazebo 相机,变成应用能直接吃的话题。
|
||||
|
||||
=============================================================================
|
||||
它到底解决什么问题
|
||||
=============================================================================
|
||||
ros_gz_bridge 只做"传输":把 gz 的消息类型翻成 ROS 的消息类型,话题名照抄。
|
||||
它**改不了**两样东西,而这两样恰恰是仿真里最容易错、也最不报错的:
|
||||
|
||||
1. frame_id
|
||||
gz 盖的是内部路径(实测是 rizon_sim/sim_link7/d405_rgbd),
|
||||
而 TF 里真实存在的坐标系叫 sim_d405_link —— 两者对不上。
|
||||
更麻烦的是:即使改成 sim_d405_link 也还是错的,因为那是"相机机身"系
|
||||
(X 前 / Y 左 / Z 上),而 ROS 的 image/camera_info 必须是光学系
|
||||
(Z 前 / X 右 / Y 下)。tf2 查询两种都能查到、都不报错,
|
||||
但用错的那个把深度反投影成三维点会整体转错。
|
||||
所以这里盖的是 <相机>_color_optical_frame,并同时发布
|
||||
link → optical 的静态 TF。
|
||||
|
||||
2. 深度单位
|
||||
gz 是 32FC1 / 米,真机 realsense 的 aligned_depth 是 16UC1 / 毫米。
|
||||
不转换的话应用按毫米解释会整体差 1000 倍,而且 dtype 不符时
|
||||
numpy 读出来直接是垃圾。
|
||||
|
||||
=============================================================================
|
||||
为什么是"桥接 + 适配器"两层,而不是合成一层
|
||||
=============================================================================
|
||||
桥接器改不了 frame_id,所以无论如何都要有第二层做语义转换。
|
||||
既然必须有,就让第二层把"改名 + 坐标系 + 单位"全都集中处理,
|
||||
而不是让桥接器改一半、适配器补一半 —— 那样出问题时不知道去哪找。
|
||||
副产品:gz 原名的话题(/d405/image 等)在 ROS 图里仍然可见,
|
||||
排查"是桥接断了还是适配器断了"时能一刀切开。
|
||||
|
||||
=============================================================================
|
||||
发布的话题(与真机完全一致,应用无需为仿真改任何代码)
|
||||
=============================================================================
|
||||
/<ros_ns>/color/image_raw rgb8
|
||||
/<ros_ns>/color/camera_info plumb_bob(畸变全 0,gz 不模拟畸变)
|
||||
/<ros_ns>/aligned_depth_to_color/image_raw 16UC1 毫米,0 = 无读数
|
||||
|
||||
QoS 用 sensor data(BEST_EFFORT, keep_last 1),与真机 realsense 发布端、
|
||||
以及应用的订阅端(qos_profile_sensor_data)都对齐。这一点不能想当然:
|
||||
发布端 BEST_EFFORT、订阅端 RELIABLE 是不兼容的,表现是"话题在、echo 不到"。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
import rclpy
|
||||
from geometry_msgs.msg import TransformStamped
|
||||
from rclpy.node import Node
|
||||
from rclpy.qos import (
|
||||
DurabilityPolicy,
|
||||
HistoryPolicy,
|
||||
QoSProfile,
|
||||
ReliabilityPolicy,
|
||||
qos_profile_sensor_data,
|
||||
)
|
||||
from sensor_msgs.msg import CameraInfo, Image
|
||||
from tf2_ros import StaticTransformBroadcaster
|
||||
|
||||
from .sim_cameras import (
|
||||
DEFAULT_CAMERAS_REL,
|
||||
CameraConfigError,
|
||||
CameraSpec,
|
||||
depth_m_to_mm16,
|
||||
intrinsics,
|
||||
load_cameras,
|
||||
optical_rpy,
|
||||
rpy_to_quat,
|
||||
)
|
||||
|
||||
# 图像上盖的坐标系(光学系)相对相机本体系的固定旋转。
|
||||
# 见 sim_cameras.OPTICAL_RPY 的说明 —— 这是"看起来正常但三维全错"的那一项。
|
||||
|
||||
|
||||
class SimCameraAdapter(Node):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("sim_camera_adapter")
|
||||
|
||||
self.declare_parameter("cameras_file", "")
|
||||
path = str(self.get_parameter("cameras_file").value or "").strip()
|
||||
|
||||
try:
|
||||
specs = load_cameras(path or None, root=self._repo_root())
|
||||
except Exception as exc: # 配置错误应当直接说清怎么修,而不是抛栈
|
||||
self.get_logger().error(f"\n仿真相机清单读取失败:\n{exc}")
|
||||
raise
|
||||
|
||||
self._specs: List[CameraSpec] = specs
|
||||
self._subs: List[object] = []
|
||||
self._pubs: Dict[str, object] = {}
|
||||
self._warned: set = set()
|
||||
|
||||
# 相机本体 → 光学系的静态 TF。必须在图像到达**之前**就发出去,
|
||||
# 因为 TF 是 transient_local(迟到的订阅者也能拿到),但若从没发过,
|
||||
# 应用查 tf 会直接报 "frame does not exist"。
|
||||
self._tf = StaticTransformBroadcaster(self)
|
||||
self._publish_optical_tfs()
|
||||
|
||||
for s in specs:
|
||||
self._wire(s)
|
||||
|
||||
names = ", ".join(s.ros_ns for s in specs)
|
||||
self.get_logger().info(
|
||||
f"仿真相机适配器就绪: {names}"
|
||||
+ f"(深度 {sum(1 for s in specs if s.depth)}/台 已做 米→毫米 转换)"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 内部
|
||||
def _repo_root(self):
|
||||
"""复用 robot_description 的仓库根定位(它已经处理了三种情形)。"""
|
||||
from .robot_description import repo_root
|
||||
|
||||
try:
|
||||
return repo_root()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _qos(self) -> QoSProfile:
|
||||
# 深拷贝一份 sensor data QoS:共享的那个对象若被改动会影响其它节点。
|
||||
return QoSProfile(
|
||||
reliability=ReliabilityPolicy.BEST_EFFORT,
|
||||
history=HistoryPolicy.KEEP_LAST,
|
||||
depth=qos_profile_sensor_data.depth,
|
||||
)
|
||||
|
||||
def _publish_optical_tfs(self) -> None:
|
||||
"""发布 link → optical 的静态 TF(每台相机一条,平移为零)。"""
|
||||
msgs = []
|
||||
for s in self._specs:
|
||||
t = TransformStamped()
|
||||
t.header.stamp = self.get_clock().now().to_msg()
|
||||
t.header.frame_id = s.link_frame
|
||||
t.child_frame_id = s.optical_frame
|
||||
t.transform.translation.x = 0.0
|
||||
t.transform.translation.y = 0.0
|
||||
t.transform.translation.z = 0.0
|
||||
qx, qy, qz, qw = rpy_to_quat(optical_rpy())
|
||||
t.transform.rotation.x = qx
|
||||
t.transform.rotation.y = qy
|
||||
t.transform.rotation.z = qz
|
||||
t.transform.rotation.w = qw
|
||||
msgs.append(t)
|
||||
self._tf.sendTransform(msgs)
|
||||
|
||||
def _wire(self, s: CameraSpec) -> None:
|
||||
qos = self._qos()
|
||||
|
||||
self._pubs[s.ros_color] = self.create_publisher(Image, s.ros_color, qos)
|
||||
self._pubs[s.ros_info] = self.create_publisher(CameraInfo, s.ros_info, qos)
|
||||
if s.depth:
|
||||
self._pubs[s.ros_depth] = self.create_publisher(Image, s.ros_depth, qos)
|
||||
|
||||
self._subs.append(
|
||||
self.create_subscription(
|
||||
Image, s.gz_image, lambda m, sp=s: self._on_color(sp, m), qos
|
||||
)
|
||||
)
|
||||
self._subs.append(
|
||||
self.create_subscription(
|
||||
CameraInfo, s.gz_info, lambda m, sp=s: self._on_info(sp, m), qos
|
||||
)
|
||||
)
|
||||
if s.depth:
|
||||
self._subs.append(
|
||||
self.create_subscription(
|
||||
Image, s.gz_depth, lambda m, sp=s: self._on_depth(sp, m), qos
|
||||
)
|
||||
)
|
||||
|
||||
def _restamp(self, msg, frame_id: str) -> None:
|
||||
"""把坐标系换掉。就地改,避免每帧多一次消息构造与一次大数组拷贝。"""
|
||||
if msg.header.frame_id != frame_id:
|
||||
msg.header.frame_id = frame_id
|
||||
|
||||
def _on_color(self, s: CameraSpec, msg: Image) -> None:
|
||||
self._note_encoding(s, msg)
|
||||
self._restamp(msg, s.optical_frame)
|
||||
self._pubs[s.ros_color].publish(msg)
|
||||
|
||||
def _on_info(self, s: CameraSpec, msg: CameraInfo) -> None:
|
||||
"""重算并覆盖内参 —— gz 发的那份是错的,不能透传。
|
||||
|
||||
本机实测:gz 恒发 fx=fy=277, cx=160, cy=120(等于它的默认 60° 视场 +
|
||||
半分辨率主点),而渲染用的是配置的 87° 视场与 640x480。
|
||||
照抄它做三维反投影会错半米量级,且单目预览里完全看不出来。
|
||||
详见 sim_cameras.intrinsics() 的说明。
|
||||
"""
|
||||
w = int(msg.width) or int(s.width)
|
||||
h = int(msg.height) or int(s.height)
|
||||
try:
|
||||
k, p = intrinsics(s, w, h)
|
||||
except CameraConfigError as exc:
|
||||
key = ("info", s.ros_ns)
|
||||
if key not in self._warned:
|
||||
self._warned.add(key)
|
||||
self.get_logger().error(f"{s.ros_ns}: 内参算不出来,{s.ros_info} 无输出。{exc}")
|
||||
return
|
||||
|
||||
key = ("info", s.ros_ns)
|
||||
if key not in self._warned:
|
||||
self._warned.add(key)
|
||||
gz_k = list(msg.k) if len(msg.k) == 9 else []
|
||||
self.get_logger().info(
|
||||
f"{s.ros_ns}: 内参已按视场 {s.horizontal_fov:.4f} rad "
|
||||
f"({math.degrees(s.horizontal_fov):.1f}°) 与 {w}x{h} 重算 —— "
|
||||
f"fx={k[0]:.1f} cx={k[2]:.1f} cy={k[5]:.1f}"
|
||||
+ (
|
||||
f"(覆盖 gz 的 fx={gz_k[0]:.1f} cx={gz_k[2]:.1f} cy={gz_k[5]:.1f})"
|
||||
if gz_k
|
||||
else ""
|
||||
)
|
||||
)
|
||||
|
||||
out = CameraInfo()
|
||||
out.header = msg.header
|
||||
out.header.frame_id = s.optical_frame
|
||||
out.height = h
|
||||
out.width = w
|
||||
out.distortion_model = "plumb_bob"
|
||||
# gz 不模拟镜头畸变,所以畸变系数全 0、R 为单位阵。
|
||||
# 这意味着"仿真里用畸变矫正调出来的参数"在真机上仍需重调 —— 已在
|
||||
# devices/sim/gazebo/README.md 里说明。
|
||||
out.d = [0.0] * 5
|
||||
out.k = list(k)
|
||||
out.r = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]
|
||||
out.p = list(p)
|
||||
self._pubs[s.ros_info].publish(out)
|
||||
|
||||
def _on_depth(self, s: CameraSpec, msg: Image) -> None:
|
||||
"""32FC1 米 → 16UC1 毫米。
|
||||
|
||||
用 frombuffer 而不是 array(...):后者会把整个 buffer 再拷一遍。
|
||||
这一步每帧都在跑(30Hz × 640×480×4B),能省则省。
|
||||
"""
|
||||
if msg.encoding != "32FC1":
|
||||
# 只提醒一次,避免刷屏
|
||||
key = (s.ros_ns, msg.encoding)
|
||||
if key not in self._warned:
|
||||
self._warned.add(key)
|
||||
self.get_logger().warn(
|
||||
f"{s.gz_depth} 的编码是 {msg.encoding},不是预期的 32FC1。"
|
||||
f"已跳过本次转换 —— 深度话题会没有输出。"
|
||||
f"若 Gazebo 版本改动了深度编码,需要同步改 sim_cameras."
|
||||
f"depth_m_to_mm16 的调用处。"
|
||||
)
|
||||
return
|
||||
|
||||
arr = np.frombuffer(msg.data, dtype=np.float32).reshape(msg.height, msg.width)
|
||||
mm = depth_m_to_mm16(arr)
|
||||
|
||||
out = Image()
|
||||
out.header = msg.header
|
||||
out.header.frame_id = s.optical_frame
|
||||
out.height = msg.height
|
||||
out.width = msg.width
|
||||
out.encoding = "16UC1"
|
||||
# gz 给的是连续 buffer;转成 uint16 后每像素 2 字节,step 必须同步改,
|
||||
# 否则下游按 RGB 的 stride 解读会错位。
|
||||
out.is_bigendian = 0
|
||||
out.step = msg.width * 2
|
||||
out.data = mm.tobytes()
|
||||
self._pubs[s.ros_depth].publish(out)
|
||||
|
||||
def _note_encoding(self, s: CameraSpec, msg: Image) -> None:
|
||||
"""第一次收到彩色帧时把编码打出来。
|
||||
|
||||
为什么值得单独打印:gz 给的是 rgb8(实测),而真机 realsense 的
|
||||
color/image_raw 也是 rgb8 —— 一致,所以不需要转换。但这一点是实测
|
||||
结论,不是保证;换个 Gazebo 版本或传感器配置可能变成 bgr8。
|
||||
打一行日志,使"颜色不对"时能立刻定位到是不是编码变了。
|
||||
"""
|
||||
key = ("enc", s.ros_ns, msg.encoding)
|
||||
if key not in self._warned:
|
||||
self._warned.add(key)
|
||||
self.get_logger().info(
|
||||
f"{s.ros_ns}: 彩色 {msg.width}x{msg.height} encoding={msg.encoding} "
|
||||
f"→ {s.ros_color} (frame={s.optical_frame})"
|
||||
)
|
||||
if msg.encoding not in ("rgb8", "bgr8"):
|
||||
self.get_logger().warn(
|
||||
f"{s.ros_ns} 的彩色编码是 {msg.encoding},既不是 rgb8 也不是 "
|
||||
f"bgr8。应用按 rgb8 解释会得到错误的颜色。"
|
||||
)
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
rclpy.init(args=argv)
|
||||
try:
|
||||
node = SimCameraAdapter()
|
||||
except Exception:
|
||||
# 参数/配置错误时 rclpy 已经打印了原因;这里只需保证退出码非零,
|
||||
# 让 launch 能把它当成失败(否则会留下一个没有相机的"成功"启动)。
|
||||
rclpy.try_shutdown()
|
||||
return 2
|
||||
try:
|
||||
rclpy.spin(node)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
node.destroy_node()
|
||||
rclpy.try_shutdown()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+532
@@ -0,0 +1,532 @@
|
||||
"""仿真相机的配置解析与格式换算。
|
||||
|
||||
=============================================================================
|
||||
为什么单独一个模块
|
||||
=============================================================================
|
||||
"把 Gazebo 的相机输出变成应用能直接吃的话题"这件事里,真正有内容的是三件事:
|
||||
|
||||
1. 深度单位换算 32FC1 米 → 16UC1 毫米(且 0 表示无读数)
|
||||
2. 坐标系盖章 图像要盖**光学系**,不能盖相机本体系
|
||||
3. 话题改名 /d405/image → /d405/color/image_raw 等
|
||||
|
||||
这三件事全是纯数据变换,不需要 ROS 跑起来就能验。所以实现放在这里、
|
||||
用普通函数表达,节点(sim_camera_adapter.py)只做订阅/发布这层壳。
|
||||
好处是换算逻辑可以被单测钉死 —— 深度单位差 1000 倍这种错误,
|
||||
在单目预览里肉眼看不出任何异常,只能靠测试兜住。
|
||||
|
||||
另外,启动文件里生成 ros_gz_bridge 参数也要用同一份相机清单,
|
||||
所以清单的解析也放在这里,避免"桥接用的清单"和"适配器用的清单"各写一份。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
import numpy as np
|
||||
import yaml
|
||||
|
||||
# 默认配置位置(相对仓库根)。与其它 devices/ 下的配置一样,是**物料**而非代码。
|
||||
DEFAULT_CAMERAS_REL = "devices/sim/gazebo/config/cameras.yaml"
|
||||
|
||||
# 相机本体系(X 前 / Y 左 / Z 上)→ 光学系(Z 前 / X 右 / Y 下)的固定旋转。
|
||||
# 这就是真机 realsense 的 <相机>_color_optical_frame 相对其 link 用的一组角,
|
||||
# 且是 ROS 的惯例(REP-103 / REP-105)。
|
||||
#
|
||||
# ⚠️ 这一步很容易被省掉,而且省掉之后**什么都不会报错**:
|
||||
# 应用拿 image 的 frame_id 去做 tf2 查询,本体系是存在的,查询成功;
|
||||
# 但那是"相机机身"的朝向,用它把深度反投影成三维点会整体转错。
|
||||
# 单目预览看起来完全正常,只有把点投回世界坐标时才露馅 —— 所以
|
||||
# 专门有一个端到端测试把"深度反投影后应落在已知平面"作为断言。
|
||||
OPTICAL_RPY: Tuple[float, float, float] = (-1.5708, 0.0, -1.5708)
|
||||
|
||||
# uint16 能表示的最大毫米数(65535 mm = 65.535 m)。
|
||||
# 更远的读数按"无读数"处理(0),而不是饱和到 65535 ——
|
||||
# 饱和会让远处凭空出现一个 65.5m 的平面,比"无效"更有害。
|
||||
MAX_MM = 65535
|
||||
|
||||
|
||||
class CameraConfigError(RuntimeError):
|
||||
"""配置有问题时抛出,消息面向使用者。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CameraSpec:
|
||||
"""一台仿真相机的声明。"""
|
||||
|
||||
gz_ns: str
|
||||
ros_ns: str
|
||||
link_frame: str
|
||||
optical_frame: str
|
||||
depth: bool = True
|
||||
horizontal_fov: float = 0.0
|
||||
world_model: str = ""
|
||||
world_link: str = "link"
|
||||
width: int = 0
|
||||
height: int = 0
|
||||
|
||||
# --- Gazebo 侧(ros_gz_bridge 的输入)-----------------------------------
|
||||
@property
|
||||
def gz_image(self) -> str:
|
||||
return f"{self.gz_ns}/image"
|
||||
|
||||
@property
|
||||
def gz_depth(self) -> str:
|
||||
return f"{self.gz_ns}/depth_image"
|
||||
|
||||
@property
|
||||
def gz_info(self) -> str:
|
||||
return f"{self.gz_ns}/camera_info"
|
||||
|
||||
# --- ROS 侧(应用按这些名字订阅,与真机 realsense 一致)------------------
|
||||
@property
|
||||
def ros_color(self) -> str:
|
||||
return f"/{self.ros_ns}/color/image_raw"
|
||||
|
||||
@property
|
||||
def ros_info(self) -> str:
|
||||
return f"/{self.ros_ns}/color/camera_info"
|
||||
|
||||
@property
|
||||
def ros_depth(self) -> str:
|
||||
return f"/{self.ros_ns}/aligned_depth_to_color/image_raw"
|
||||
|
||||
@property
|
||||
def is_world_mounted(self) -> bool:
|
||||
"""挂在世界模型上(而非机器人本体)→ 需要额外补一条静态 TF。"""
|
||||
return bool(self.world_model)
|
||||
|
||||
|
||||
def load_cameras(path: Optional[Path] = None, root: Optional[Path] = None) -> List[CameraSpec]:
|
||||
"""读相机清单。找不到或写错时抛 CameraConfigError,消息里给出修法。"""
|
||||
if path is None:
|
||||
base = root if root is not None else Path.cwd()
|
||||
path = Path(base) / DEFAULT_CAMERAS_REL
|
||||
path = Path(path)
|
||||
|
||||
if not path.is_file():
|
||||
raise CameraConfigError(
|
||||
f"相机清单不存在: {path}\n"
|
||||
f" 它是仿真相机的话题/坐标系唯一来源(桥接与适配器都从它派生)。\n"
|
||||
f" 缺了它仿真里的相机就完全没输出。确认仓库根下存在 "
|
||||
f"{DEFAULT_CAMERAS_REL}。"
|
||||
)
|
||||
|
||||
try:
|
||||
doc = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
raise CameraConfigError(f"相机清单 YAML 解析失败: {path}\n 原因: {exc}") from exc
|
||||
|
||||
raw = doc.get("cameras")
|
||||
if not isinstance(raw, list) or not raw:
|
||||
raise CameraConfigError(
|
||||
f"{path} 里没有 cameras 列表(或为空)。至少写一台相机,否则仿真里没有图像。"
|
||||
)
|
||||
|
||||
specs: List[CameraSpec] = []
|
||||
seen: Dict[str, str] = {}
|
||||
for i, item in enumerate(raw):
|
||||
if not isinstance(item, dict):
|
||||
raise CameraConfigError(f"{path} 的 cameras[{i}] 不是映射(键值对)。")
|
||||
|
||||
def need(key: str) -> str:
|
||||
v = str(item.get(key, "") or "").strip()
|
||||
if not v:
|
||||
raise CameraConfigError(
|
||||
f"{path} 的 cameras[{i}] 缺少 {key}。"
|
||||
f"(gz_ns / ros_ns / link_frame / optical_frame 都必填)"
|
||||
)
|
||||
return v
|
||||
|
||||
# 必填键先查完,再查取值是否合理 —— 否则一份"缺键且键值也不对"的配置
|
||||
# 会先报一个后者的错,让人以为只是值填错,实际键都还没写全。
|
||||
gz_ns = "/" + need("gz_ns").strip("/")
|
||||
ros_ns = need("ros_ns").strip("/")
|
||||
link_frame = need("link_frame")
|
||||
optical_frame = need("optical_frame")
|
||||
|
||||
# 同一个 ROS 命名空间出现两次 → 两台相机会互相覆盖话题,
|
||||
# 表现为"只有一台出图"或图像在两个相机之间跳,很难查。直接拒绝。
|
||||
if ros_ns in seen:
|
||||
raise CameraConfigError(
|
||||
f"{path}: ros_ns '{ros_ns}' 重复(cameras[{i}] 与 {seen[ros_ns]})。\n"
|
||||
f" 两台相机用同一个 ros_ns 会互相覆盖话题,必须区分开。"
|
||||
)
|
||||
seen[ros_ns] = f"cameras[{i}]"
|
||||
|
||||
try:
|
||||
fov = float(item.get("horizontal_fov", 0.0) or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
raise CameraConfigError(
|
||||
f"{path} 的 cameras[{i}].horizontal_fov 不是数字。"
|
||||
) from None
|
||||
# 视场是内参的唯一来源(gz 自己发的 camera_info 是错的,见文件头),
|
||||
# 所以它必须存在且合理。填错的话三维点位会整体错,而且看不出来。
|
||||
if not (0.0 < fov < math.pi):
|
||||
raise CameraConfigError(
|
||||
f"{path} 的 cameras[{i}].horizontal_fov = {fov} 不合理。\n"
|
||||
f" 必须是 (0, π) 弧度之间的值,例如真机 D405 约 87° = 1.5184。\n"
|
||||
f" 它决定内参 fx = fy = W/(2*tan(hfov/2)),填错会让反投影出的"
|
||||
f"三维点整体偏移,而单目预览里完全看不出来。"
|
||||
)
|
||||
|
||||
specs.append(
|
||||
CameraSpec(
|
||||
gz_ns=gz_ns,
|
||||
ros_ns=ros_ns,
|
||||
link_frame=link_frame,
|
||||
optical_frame=optical_frame,
|
||||
depth=bool(item.get("depth", True)),
|
||||
horizontal_fov=fov,
|
||||
world_model=str(item.get("world_model", "") or "").strip(),
|
||||
world_link=str(item.get("world_link", "") or "link").strip() or "link",
|
||||
width=int(item.get("width", 0) or 0),
|
||||
height=int(item.get("height", 0) or 0),
|
||||
)
|
||||
)
|
||||
return specs
|
||||
|
||||
|
||||
def intrinsics(spec: CameraSpec, width: int, height: int) -> Tuple[List[float], List[float]]:
|
||||
"""由视场与图像尺寸算出 (K, P)。返回扁平列表,可直接塞进 CameraInfo。
|
||||
|
||||
=========================================================================
|
||||
为什么必须自己算,而不是用 gz 发的那份 camera_info
|
||||
=========================================================================
|
||||
本机实测(Fortress 6.16 / RoboStack):gz 给 rgbd_camera 发的 camera_info
|
||||
**恒为** fx=fy=277, cx=160, cy=120 —— 无论 SDF 里怎么配。这组值对应 gz 的
|
||||
默认参数:60° 视场 + 320x240 图幅。
|
||||
|
||||
而它**渲染**用的却是 SDF 里配的 87° 视场与 640x480。证据是:用已知地面平面
|
||||
对深度反投影做最小二乘,解出 fy=337.2、cy=239.5,与
|
||||
640 / (2*tan(1.5184/2)) = 337.2 480/2 = 240
|
||||
完全吻合(残差中位 0.2mm)。也就是说渲染是对的,只有 camera_info 是默认值。
|
||||
|
||||
后果有多严重:照抄那份 camera_info 反投影,D435 看到的地面会被算到
|
||||
z ≈ -0.49m(真实是 0),偏差半米量级。而单目预览一切正常 ——
|
||||
这类错误只有在用三维点位时才暴露,恰好是本项目装配任务的核心输入。
|
||||
|
||||
因此这里按"渲染实际用的视场"重算,并覆盖 gz 的 camera_info。
|
||||
像素是方的(gz 由同一视场与宽高比推出两个方向),所以 fx = fy。
|
||||
|
||||
⚠️ 相应地,horizontal_fov 必须与 SDF 里真正渲染用的值一致。
|
||||
它有两个来源,启动时会自动比对并告警(check_fov_consistency)。
|
||||
"""
|
||||
if not (0.0 < spec.horizontal_fov < math.pi):
|
||||
raise CameraConfigError(
|
||||
f"相机 {spec.ros_ns} 的 horizontal_fov={spec.horizontal_fov} 不合理,"
|
||||
f"无法算内参。"
|
||||
)
|
||||
if width <= 0 or height <= 0:
|
||||
raise CameraConfigError(
|
||||
f"相机 {spec.ros_ns} 的图像尺寸非法({width}x{height}),无法算内参。"
|
||||
)
|
||||
|
||||
fx = width / (2.0 * math.tan(spec.horizontal_fov / 2.0))
|
||||
fy = fx # 方形像素
|
||||
cx = width / 2.0
|
||||
cy = height / 2.0
|
||||
|
||||
k = [fx, 0.0, cx,
|
||||
0.0, fy, cy,
|
||||
0.0, 0.0, 1.0]
|
||||
# P 是 3x4 的投影矩阵。gz 不模拟畸变,所以 R 是单位阵、最后一列为 0。
|
||||
p = [fx, 0.0, cx, 0.0,
|
||||
0.0, fy, cy, 0.0,
|
||||
0.0, 0.0, 1.0, 0.0]
|
||||
return k, p
|
||||
|
||||
|
||||
def check_camera_consistency(
|
||||
specs: Sequence[CameraSpec],
|
||||
world_file: Optional[Path] = None,
|
||||
sensors_xacro: Optional[Path] = None,
|
||||
) -> List[str]:
|
||||
"""比对 cameras.yaml 的声明与 SDF 里真正渲染用的值(视场 + 图幅)。
|
||||
|
||||
为什么值得做这个检查:内参由 cameras.yaml 的 horizontal_fov 与 width/height
|
||||
算出,而**渲染**用的是 SDF 里的值。两者一旦不一致,图像与内参就对不上了 ——
|
||||
症状仍然是"三维点整体错",而单目预览看不出来。这正是这个模块开头
|
||||
那一长串说明所警告的那类错误,所以要主动比对。
|
||||
|
||||
⚠️ 图幅(width/height)为什么必须一起比:它决定 fx = W/(2·tan(hfov/2)),
|
||||
也就是"每毫米多少像素"。阶段二的孔检测在原始分辨率图上跑 HoughCircles,
|
||||
hole_detect.yaml 的像素门槛(hole_distance_min 等)只在图幅与真机相同时
|
||||
才有意义。图幅写错不会报任何错,只会让孔检测"莫名其妙不认孔" ——
|
||||
比视场写错更难查(视场错还会让点位整体偏,图幅错只是尺度不对)。
|
||||
|
||||
返回人类可读的告警列表(空 = 没问题)。
|
||||
|
||||
精度说明(有意为之,不做过度解析):
|
||||
- 世界相机:按 <sensor> 里的 <topic> 精确匹配到对应相机,读到的是精确值。
|
||||
- 腕上相机:它在 xacro 宏里、值由参数传入,展开前无法可靠地归属到具体
|
||||
相机。所以视场退化成"文件里出现过这个值就算过",图幅则读宏的
|
||||
**参数默认值**(image_width:=/image_height:=,launch 不会覆盖它)——
|
||||
偏宽松,但足以抓住"改了 SDF 忘了改 cameras.yaml"这种最常见的漂移。
|
||||
"""
|
||||
warnings: List[str] = []
|
||||
|
||||
if world_file is not None and Path(world_file).is_file():
|
||||
text = Path(world_file).read_text(encoding="utf-8", errors="replace")
|
||||
for s in specs:
|
||||
if not s.is_world_mounted:
|
||||
continue
|
||||
# 找到 <topic> 等于本相机 gz_ns 的那个 <sensor> 块
|
||||
fov = None
|
||||
size = (None, None)
|
||||
for m in re.finditer(r"<sensor\b[^>]*>(.*?)</sensor>", text, re.S):
|
||||
body = m.group(1)
|
||||
tm = re.search(r"<topic>\s*([^<\s]+)\s*</topic>", body)
|
||||
if not tm or "/" + tm.group(1).strip().lstrip("/") != s.gz_ns:
|
||||
continue
|
||||
fm = re.search(r"<horizontal_fov>\s*([0-9.eE+-]+)\s*</horizontal_fov>", body)
|
||||
if fm:
|
||||
fov = float(fm.group(1))
|
||||
wm = re.search(r"<width>\s*(\d+)\s*</width>", body)
|
||||
hm = re.search(r"<height>\s*(\d+)\s*</height>", body)
|
||||
size = (
|
||||
int(wm.group(1)) if wm else None,
|
||||
int(hm.group(1)) if hm else None,
|
||||
)
|
||||
break
|
||||
if fov is None:
|
||||
warnings.append(
|
||||
f"相机 {s.ros_ns}: 在 {Path(world_file).name} 里找不到 topic 为 "
|
||||
f"{s.gz_ns} 的 <sensor> 的 <horizontal_fov>,无法比对。"
|
||||
f"内参将按 cameras.yaml 的 {s.horizontal_fov} 计算 —— "
|
||||
f"若与渲染实际用的视场不符,三维点位会整体错。"
|
||||
)
|
||||
elif abs(fov - s.horizontal_fov) > 1e-4:
|
||||
warnings.append(
|
||||
f"相机 {s.ros_ns}: 视场不一致!cameras.yaml 写 {s.horizontal_fov},"
|
||||
f"而 {Path(world_file).name} 里渲染用的是 {fov}。\n"
|
||||
f" 内参按前者算、图像按后者渲染 → 三维点位会整体错,"
|
||||
f"而单目预览看不出来。请把两处改成一致。"
|
||||
)
|
||||
_check_size(s, size[0], size[1], Path(world_file).name, warnings)
|
||||
|
||||
if sensors_xacro is not None and Path(sensors_xacro).is_file():
|
||||
text = Path(sensors_xacro).read_text(encoding="utf-8", errors="replace")
|
||||
found = [float(v) for v in re.findall(r"<horizontal_fov>\s*([0-9.eE+-]+)", text)]
|
||||
for s in specs:
|
||||
if s.is_world_mounted:
|
||||
continue
|
||||
if not found:
|
||||
warnings.append(
|
||||
f"相机 {s.ros_ns}: 在 {Path(sensors_xacro).name} 里没找到 "
|
||||
f"<horizontal_fov>,无法比对(内参按 cameras.yaml 的 "
|
||||
f"{s.horizontal_fov} 计算)。"
|
||||
)
|
||||
elif not any(abs(v - s.horizontal_fov) <= 1e-4 for v in found):
|
||||
warnings.append(
|
||||
f"相机 {s.ros_ns}: 视场可能不一致。cameras.yaml 写 "
|
||||
f"{s.horizontal_fov},而 {Path(sensors_xacro).name} 里出现的值是 "
|
||||
f"{found}。若渲染用的不是 {s.horizontal_fov},三维点位会整体错。"
|
||||
)
|
||||
# 腕上相机的图幅:读宏的参数默认值(launch 不覆盖它们)
|
||||
mw = re.search(r"image_width:=(\d+)", text)
|
||||
mh = re.search(r"image_height:=(\d+)", text)
|
||||
for s in specs:
|
||||
if s.is_world_mounted:
|
||||
continue
|
||||
_check_size(
|
||||
s,
|
||||
int(mw.group(1)) if mw else None,
|
||||
int(mh.group(1)) if mh else None,
|
||||
Path(sensors_xacro).name,
|
||||
warnings,
|
||||
)
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
def _check_size(
|
||||
spec: "CameraSpec",
|
||||
width: Optional[int],
|
||||
height: Optional[int],
|
||||
where: str,
|
||||
warnings: List[str],
|
||||
) -> None:
|
||||
"""比对声明图幅与实际渲染图幅,不一致就追加一条告警。
|
||||
|
||||
单独抽出来是因为世界相机与腕上相机的"值从哪读"完全不同(前者读 <sensor>,
|
||||
后者读 xacro 宏默认值),但**比对逻辑与文案必须一致** —— 两处各写一遍
|
||||
迟早会漂移成两套说法。
|
||||
|
||||
``spec`` 未声明图幅(width/height <= 0)时直接跳过:那表示"这份清单不管尺寸",
|
||||
此时 intrinsics() 会退回 640x480。没有声明就没有"不一致"可言,
|
||||
报出来只会变成噪声,反而让人忽略真正的告警。
|
||||
"""
|
||||
if spec.width <= 0 or spec.height <= 0:
|
||||
return
|
||||
if width is None or height is None:
|
||||
warnings.append(
|
||||
f"相机 {spec.ros_ns}: 在 {where} 里读不到图像尺寸(<width>/<height> 或 "
|
||||
f"image_width:=),无法比对。cameras.yaml 声明 "
|
||||
f"{spec.width}x{spec.height} —— 若渲染的实际尺寸与它不同,"
|
||||
f"内参算出的每毫米像素数就是错的,孔检测的像素门槛会静默失效。"
|
||||
)
|
||||
return
|
||||
if (width, height) != (spec.width, spec.height):
|
||||
warnings.append(
|
||||
f"相机 {spec.ros_ns}: 图像尺寸不一致!cameras.yaml 写 "
|
||||
f"{spec.width}x{spec.height},而 {where} 里渲染用的是 {width}x{height}。\n"
|
||||
f" 内参按前者算、图像按后者渲染 → fx 的尺度不对(差 "
|
||||
f"{width / max(spec.width, 1):.3f} 倍),"
|
||||
f"所有以像素为单位的门槛(如 hole_distance_min)都会静默失效。\n"
|
||||
f" 注意:真机 D405 是 848x480、D435 是 1280x720;仿真侧要对齐 D405。"
|
||||
)
|
||||
|
||||
|
||||
def bridge_args(specs: Sequence[CameraSpec]) -> List[str]:
|
||||
"""生成 ros_gz_bridge parameter_bridge 的话题规则。
|
||||
|
||||
只做**传输**:gz 消息类型 → ROS 消息类型,话题名保持 gz 侧的原样。
|
||||
改名与格式换算交给适配器 —— 分开的原因是桥接器改不了 frame_id,
|
||||
而 frame_id 恰恰是最容易错又最不报错的那一项。让适配器统一处理
|
||||
"名字 + 坐标系 + 单位",逻辑集中在一处。
|
||||
|
||||
注:/d405/image 这类"gz 原名"话题在 ROS 图里也会可见。
|
||||
这是有意保留的:排查"到底是桥接断了还是适配器断了"时,
|
||||
直接 echo 原名话题能一刀切开。
|
||||
"""
|
||||
args: List[str] = []
|
||||
for s in specs:
|
||||
args.append(f"{s.gz_image}@sensor_msgs/msg/Image[gz.msgs.Image")
|
||||
args.append(f"{s.gz_info}@sensor_msgs/msg/CameraInfo[gz.msgs.CameraInfo")
|
||||
if s.depth:
|
||||
# gz 的深度是 float32(本机实测 4 字节/像素),桥接器支持为 32FC1。
|
||||
# 若哪天桥接器不再支持,这里会静默少一个话题 —— 验收时用
|
||||
# `ros2 topic list | grep aligned_depth` 确认它在。
|
||||
args.append(f"{s.gz_depth}@sensor_msgs/msg/Image[gz.msgs.Image")
|
||||
return args
|
||||
|
||||
|
||||
def depth_m_to_mm16(depth_m: np.ndarray, max_mm: int = MAX_MM) -> np.ndarray:
|
||||
"""深度:32FC1 米 → 16UC1 毫米。0 表示"无读数"(与真机 realsense 一致)。
|
||||
|
||||
规则(每一条都对应一种真机会出现的情况):
|
||||
非有限值(nan / inf) → 0。gz 在没命中时可能给 inf,
|
||||
真机 realsense 给 0;统一成 0。
|
||||
<= 0 → 0。真机对无效像素就是给 0,
|
||||
负深度没有物理意义。
|
||||
0 < d <= max_mm/1000 → round(d*1000)
|
||||
> max_mm/1000(65.535m) → 0(见 MAX_MM 的说明:不饱和)
|
||||
舍入后为 0 的极小正数 → 0(例如 0.0001m,落不进 uint16)
|
||||
|
||||
返回与输入同形状的 uint16 数组。
|
||||
"""
|
||||
a = np.asarray(depth_m)
|
||||
if a.dtype != np.float32:
|
||||
a = a.astype(np.float32, copy=False)
|
||||
|
||||
out = np.zeros(a.shape, dtype=np.uint16)
|
||||
|
||||
finite = np.isfinite(a)
|
||||
if not finite.any():
|
||||
return out
|
||||
|
||||
# 只对有限且为正的像素做换算;先把它们挑出来,避免对 nan 做比较
|
||||
# (nan 参与的比较全是 False,但显式挑出来意图更清楚,也少一遍遍历)
|
||||
cand = finite & (a > 0.0)
|
||||
if not cand.any():
|
||||
return out
|
||||
|
||||
mm = np.rint(a[cand].astype(np.float64) * 1000.0)
|
||||
ok = (mm > 0.0) & (mm <= float(max_mm))
|
||||
if ok.any():
|
||||
out[cand] = np.where(ok, mm, 0.0).astype(np.uint16)
|
||||
return out
|
||||
|
||||
|
||||
#: 腕部相机的视轴与法兰轴线的允许夹角 [度]。
|
||||
#: 真机装支架时也不会完全正装,但"看得到工件"要求它基本沿法兰轴;
|
||||
#: 20° 是给手工装配留的余量,同时足以把"少转 90°"这类错误拦下来。
|
||||
MOUNT_TOL_DEG = 20.0
|
||||
|
||||
#: 手外相机的视轴在世界系里的 z 分量上限(-1 = 正朝下)。松于 -0.5 就算没在朝下看。
|
||||
WORLD_LOOK_DOWN_COS = -0.5
|
||||
|
||||
|
||||
def flange_frame_for(spec: CameraSpec) -> Optional[str]:
|
||||
"""从相机的 link_frame 反推它所在臂的法兰 frame:sim_d405_link → sim_flange。
|
||||
|
||||
只对**挂在臂上**的相机有意义(``world_model`` 非空的是世界相机,返回 None)。
|
||||
为什么要反推而不是读配置:``site.yaml`` 的 ``sim.joint_prefix`` 自己都写着
|
||||
"✘ 未接线",拿一个没接线的值当路径判据会静默失效。
|
||||
link_frame 里的前缀才是真正生效的那个(由 xacro 的 arm_prefix 生成)。
|
||||
"""
|
||||
if spec.world_model:
|
||||
return None
|
||||
model = spec.ros_ns.rsplit("_", 1)[-1] # sim_d405 → d405
|
||||
suffix = f"{model}_link"
|
||||
if not spec.link_frame.endswith(suffix):
|
||||
return None
|
||||
return spec.link_frame[: -len(suffix)] + "flange"
|
||||
|
||||
|
||||
def mount_axis_deg(R_flange_optical: np.ndarray) -> float:
|
||||
"""光学系视轴(+Z)与**法兰 +Z**的夹角 [度]。0 = 正装在法兰轴线上。
|
||||
|
||||
这是个可以**脱离姿态**核对的量,所以特别有用:不管机械臂当时摆成什么样,
|
||||
"相机有没有沿法兰轴线装"都只在安装处决定。
|
||||
|
||||
为什么它必须接近 0(而不是"调多少都行"):Gz 的相机视轴是模型/连杆的 +X,
|
||||
而 OPTICAL_RPY(-90°,0,-90°) 把"机身系(X 前/Y 左/Z 上)"转成光学系。
|
||||
两者串起来 ⇒ 相机沿法兰轴线装 ⇔ 光学 +Z ∥ 法兰 +Z。
|
||||
少转那 90° 时:话题有数据、预览有图像、内参也对,只是整个视野搬到了侧面,
|
||||
应用侧表现成"目标永远不在相机前方"(曾真实踩过,见 sensors.xacro 的说明)。
|
||||
"""
|
||||
v = np.asarray(R_flange_optical, dtype=float) @ np.array([0.0, 0.0, 1.0])
|
||||
return float(np.degrees(np.arccos(float(np.clip(v[2], -1.0, 1.0)))))
|
||||
|
||||
|
||||
def world_look_down_cos(R_world_optical: np.ndarray) -> float:
|
||||
"""光学系视轴在世界系里的 z 分量(-1 = 正朝下看,0 = 平视)。"""
|
||||
return float((np.asarray(R_world_optical, dtype=float) @ np.array([0.0, 0.0, 1.0]))[2])
|
||||
|
||||
|
||||
def rpy_to_R(rpy: Sequence[float]) -> np.ndarray:
|
||||
"""URDF/SDF 的 (roll, pitch, yaw) → 3x3 旋转矩阵。
|
||||
|
||||
复合顺序是 ``R = Rz(yaw)·Ry(pitch)·Rx(roll)``(URDF 规范)—— 顺序不能想当然,
|
||||
反了在"只有一个角非零"时看不出来,两个角同时非零就会错得莫名其妙。
|
||||
"""
|
||||
r, p, y = (float(v) for v in rpy)
|
||||
cr, sr = math.cos(r), math.sin(r)
|
||||
cp, sp = math.cos(p), math.sin(p)
|
||||
cy, sy = math.cos(y), math.sin(y)
|
||||
Rx = np.array([[1.0, 0.0, 0.0], [0.0, cr, -sr], [0.0, sr, cr]])
|
||||
Ry = np.array([[cp, 0.0, sp], [0.0, 1.0, 0.0], [-sp, 0.0, cp]])
|
||||
Rz = np.array([[cy, -sy, 0.0], [sy, cy, 0.0], [0.0, 0.0, 1.0]])
|
||||
return Rz @ Ry @ Rx
|
||||
|
||||
|
||||
def mount_rpy_axis_deg(rpy: Sequence[float]) -> float:
|
||||
"""腕相机的**安装 rpy**(URDF 里那个 fixed joint 的 rpy)→ 视轴与法兰 +Z 的夹角。
|
||||
|
||||
与运行期查 TF 的 :func:`mount_axis_deg` 是同一件事,只是输入不同:
|
||||
没接 TF 时(比如核对 xacro 里的默认值)用这个算。
|
||||
"""
|
||||
return mount_axis_deg(rpy_to_R(rpy) @ rpy_to_R(OPTICAL_RPY))
|
||||
|
||||
|
||||
def optical_rpy() -> Tuple[float, float, float]:
|
||||
"""本体 → 光学系的固定旋转,供静态 TF 使用。"""
|
||||
return OPTICAL_RPY
|
||||
|
||||
|
||||
def rpy_to_quat(rpy: Sequence[float]) -> Tuple[float, float, float, float]:
|
||||
"""(roll, pitch, yaw) → (x, y, z, w)。只用于发布静态 TF。"""
|
||||
r, p, y = (float(v) for v in rpy)
|
||||
cr, sr = math.cos(r / 2.0), math.sin(r / 2.0)
|
||||
cp, sp = math.cos(p / 2.0), math.sin(p / 2.0)
|
||||
cy, sy = math.cos(y / 2.0), math.sin(y / 2.0)
|
||||
return (
|
||||
sr * cp * cy - cr * sp * sy,
|
||||
cr * sp * cy + sr * cp * sy,
|
||||
cr * cp * sy - sr * sp * cy,
|
||||
cr * cp * cy + sr * sp * sy,
|
||||
)
|
||||
+329
@@ -0,0 +1,329 @@
|
||||
"""仿真"抓取锁"(grasp lock):让夹爪夹住的物体在仿真里真的不会滑掉。
|
||||
|
||||
=============================================================================
|
||||
它在链路上的位置
|
||||
=============================================================================
|
||||
真机:夹爪 46N 握力 + 指垫摩擦 → 工件被"抱住",机械臂怎么动都不掉。
|
||||
仿真:夹持力 = 位置误差 × 接触刚度(见 gauge/model.sdf 的 kp 说明)。
|
||||
规站着不动时够用;一旦 S01 绕工具轴快转 150°、或 pre_lift 走 60~100mm
|
||||
的连续轨迹,规就在指间打滑(实测工具长度 0.243m → 0.285m、歪 62°),
|
||||
S07 随后拿到的几何全错 —— 现象是"判不到接触"或"规掉了(E1)"。
|
||||
|
||||
本模块做的唯一一件事:**在夹爪确认夹住时,用 DetachableJoint 插件把物体
|
||||
与手指用一个固定关节锁起来;夹爪张开时解锁。**
|
||||
|
||||
这不是"作弊绕过物理":刚性接触求解器给不出静摩擦迟滞,也不模拟指垫橡胶
|
||||
的贴合,而真实夹持恰恰靠这两件事。锁补的就是这一块。物体与指垫之间的
|
||||
**接触对仍然存在**,所以触觉、腕部 FT 的读数照旧是真实的接触力。
|
||||
|
||||
=============================================================================
|
||||
为什么用 `ign topic` 命令行而不是 ros_gz_bridge
|
||||
=============================================================================
|
||||
触发消息是 `ignition.msgs.Empty`。bridging 一个"空消息"要走
|
||||
ros_gz_bridge 的 Empty 映射,而这条链路上**没有任何东西需要它出现在 ROS
|
||||
侧** —— 唯一的目的就是把 gz 的那个插件叫一下。多接一个 bridge 就多一个
|
||||
"配错了不报错"的点(bridge 类型名对不上时它只是安静地什么也不桥)。
|
||||
|
||||
代价是每次触发起一个短命进程(几十毫秒)。触发频率是"每次夹/放一次",
|
||||
不是每个控制周期 —— 这个代价可以忽略。
|
||||
|
||||
=============================================================================
|
||||
静默失效的四个点(改配置时全都要对上)
|
||||
=============================================================================
|
||||
① <child_model> = 机器人 spawn 的 -name(launch 里 rizon_sim)
|
||||
② <child_link> = Gazebo 里**真实存在**的连杆名(URDF 里与固定关节相连的
|
||||
连杆会被 sdformat 合并,手指只剩 sim_left_finger_mount)
|
||||
③ attach/detach 话题名与 gauge/model.sdf 里的插件一致
|
||||
④ 插件确实写进了 gauge 的 SDF
|
||||
|
||||
①②③④ 全都不会自己报错:锁不触发时,现象只是"规莫名掉件"。
|
||||
所以 :func:`check_grasp_lock_sdf` 把它变成启动时的一条告警/报错。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
#: 相对仓库根的默认配置路径。
|
||||
DEFAULT_GRASP_LOCK_REL = "devices/sim/gazebo/config/grasp_lock.yaml"
|
||||
|
||||
#: 被 lock 的物体模型 SDF 的默认位置(用来做静态核对)。
|
||||
DEFAULT_OBJECT_SDF_TMPL = "devices/sim/gazebo/models/{object}/model.sdf"
|
||||
|
||||
|
||||
class GraspLockError(RuntimeError):
|
||||
"""抓取锁配置缺失或写错。消息里必须给出修法(现场就靠这句话)。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GraspLockSpec:
|
||||
"""抓取锁的一份完整描述。"""
|
||||
|
||||
enabled: bool
|
||||
object: str
|
||||
object_link: str
|
||||
robot_model: str
|
||||
robot_link: str
|
||||
attach_topic: str
|
||||
detach_topic: str
|
||||
state_topic: str
|
||||
publish_timeout_ms: int
|
||||
|
||||
def describe(self) -> str:
|
||||
"""给启动日志用的一句话。"""
|
||||
if not self.enabled:
|
||||
return "抓取锁:关闭(仿真靠摩擦夹持,机械臂快速运动时可能滑件)"
|
||||
return (
|
||||
f"抓取锁:开 → {self.object}.{self.object_link} 锁到 "
|
||||
f"{self.robot_model}.{self.robot_link}"
|
||||
f"(attach={self.attach_topic} detach={self.detach_topic})"
|
||||
)
|
||||
|
||||
|
||||
def load_grasp_lock(
|
||||
path: Optional[Path] = None, root: Optional[Path] = None
|
||||
) -> GraspLockSpec:
|
||||
"""读抓取锁配置。找不到就返回一份"关闭"的默认值(仿真仍能跑)。
|
||||
|
||||
**为什么文件缺失不报错**:抓取锁是仿真里的**补救措施**,不是必需品 ——
|
||||
关掉它只是回到"靠摩擦夹持",多数轻动作仍然能跑。让仿真因为一个可选补救
|
||||
措施起不来,是拿主流程给可选项陪葬。
|
||||
但**文件存在而字段写错**时必须报错:那说明有人动过它、而且写错了。
|
||||
"""
|
||||
if path is None:
|
||||
base = root if root is not None else Path.cwd()
|
||||
path = Path(base) / DEFAULT_GRASP_LOCK_REL
|
||||
path = Path(path)
|
||||
|
||||
if not path.is_file():
|
||||
return GraspLockSpec(
|
||||
enabled=False,
|
||||
object="gauge",
|
||||
object_link="body",
|
||||
robot_model="",
|
||||
robot_link="",
|
||||
attach_topic="/sim/grasp_lock/attach",
|
||||
detach_topic="/sim/grasp_lock/detach",
|
||||
state_topic="/sim/grasp_lock/state",
|
||||
publish_timeout_ms=3000,
|
||||
)
|
||||
|
||||
try:
|
||||
doc = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
raise GraspLockError(
|
||||
f"抓取锁配置 YAML 解析失败: {path}\n 原因: {exc}"
|
||||
) from exc
|
||||
|
||||
if not isinstance(doc, dict):
|
||||
raise GraspLockError(f"{path} 的顶层应该是映射(key: value 形式)。")
|
||||
|
||||
enabled = bool(doc.get("enabled", True))
|
||||
|
||||
def _text(key: str, default: str = "") -> str:
|
||||
return str(doc.get(key, default) or "").strip()
|
||||
|
||||
spec = GraspLockSpec(
|
||||
enabled=enabled,
|
||||
object=_text("object", "gauge") or "gauge",
|
||||
object_link=_text("object_link", "body") or "body",
|
||||
robot_model=_text("robot_model"),
|
||||
robot_link=_text("robot_link"),
|
||||
attach_topic=_text("attach_topic", "/sim/grasp_lock/attach"),
|
||||
detach_topic=_text("detach_topic", "/sim/grasp_lock/detach"),
|
||||
state_topic=_text("state_topic", "/sim/grasp_lock/state"),
|
||||
publish_timeout_ms=int(doc.get("publish_timeout_ms", 3000)),
|
||||
)
|
||||
|
||||
if not enabled:
|
||||
return spec
|
||||
|
||||
missing = [
|
||||
k
|
||||
for k in ("robot_model", "robot_link", "attach_topic", "detach_topic")
|
||||
if not getattr(spec, k)
|
||||
]
|
||||
if missing:
|
||||
raise GraspLockError(
|
||||
f"{path} 开着(enabled: true)但缺字段: {', '.join(missing)}。\n"
|
||||
f" robot_model 必须等于 launch 里 spawn 机器人的 -name(当前 rizon_sim);\n"
|
||||
f" robot_link 必须是 Gazebo 里的**真名**(与固定关节相连的连杆会被\n"
|
||||
f" sdformat 合并,手指只剩 sim_left_finger_mount)。真名查法:\n"
|
||||
f" ros2 topic echo /sim/world_pose_info ← 看全部 child_frame_id\n"
|
||||
f" 这两个名字写错的后果是**静默失效**:锁不触发、规照样滑掉,\n"
|
||||
f" 而日志里只看得到「规掉了」。"
|
||||
)
|
||||
if spec.attach_topic == spec.detach_topic:
|
||||
raise GraspLockError(
|
||||
f"{path} 的 attach_topic 与 detach_topic 相同({spec.attach_topic})——\n"
|
||||
f" 那样夹住和放开会走同一条消息,锁的状态完全随机。两个必须不同。"
|
||||
)
|
||||
# 锚点不能是"会跟着张爪动的"那根手指 —— 这是 2026-09-24 用一条假 E1 换来的。
|
||||
if "finger" in spec.robot_link.lower():
|
||||
raise GraspLockError(
|
||||
f"{path} 的 robot_link 是 {spec.robot_link} —— **手指**。\n"
|
||||
f" 被锁的工件会被刚性固定在手指上,而手指会随指令张/合:\n"
|
||||
f" · S08 棘轮每轮都要\"松爪 → 回转 → 夹紧\",张爪时手指外移几十毫米,\n"
|
||||
f" 而工件(规)还插在孔里 —— 两处约束打架,物理解算会把工件\n"
|
||||
f" **从孔里拽出来**(实测:规尖跳 +17.5mm、偏摆 64°),下一步掉地。\n"
|
||||
f" 日志里只看得到「通止规掉了(E1)」,看不出真因。\n"
|
||||
f" 修法:填**不跟着张爪动**的连杆。仿真是法兰 `sim_link7`\n"
|
||||
f" (夹爪本体 sim_grav_base_link 在 sdformat 里被合并进它了)。\n"
|
||||
f" 真名查法:ros2 topic echo /sim/world_pose_info ← 列全部 child_frame_id"
|
||||
)
|
||||
if spec.publish_timeout_ms <= 0:
|
||||
raise GraspLockError(
|
||||
f"{path} 的 publish_timeout_ms 必须为正,收到 {spec.publish_timeout_ms}"
|
||||
)
|
||||
return spec
|
||||
|
||||
|
||||
def bridge_target_links(spec: GraspLockSpec) -> List[str]:
|
||||
"""把"锁的两端"整理成一条便于核对/记录的清单(给日志与自检用)。"""
|
||||
return [f"{spec.object}.{spec.object_link}", f"{spec.robot_model}.{spec.robot_link}"]
|
||||
|
||||
|
||||
def check_grasp_lock_sdf(
|
||||
sdf_text: str, spec: GraspLockSpec, sdf_label: str = ""
|
||||
) -> List[str]:
|
||||
"""静态核对:物体 SDF 里的 DetachableJoint 插件是否与本配置一致。
|
||||
|
||||
为什么值得一查(与腕部 FT 的 check 同一个理由):
|
||||
插件参数写错时 Gazebo **什么都不说** —— 插件照样加载(只要 child_model
|
||||
找得到),assess 话题照样发,只是关节连不上。现象只有一个:
|
||||
"规莫名其妙掉了",而那要等到 S07/S08 才显形、且看不到真因。
|
||||
这里在**启动时**把它变成一句话。
|
||||
|
||||
返回告警列表(空 = 没问题);enabled=false 时永远返回空。
|
||||
"""
|
||||
if not spec.enabled:
|
||||
return []
|
||||
where = f"({sdf_label})" if sdf_label else ""
|
||||
warnings: List[str] = []
|
||||
if not sdf_text:
|
||||
return [f"读不到物体 SDF{where},无法核对抓取锁插件"]
|
||||
|
||||
if "DetachableJoint" not in sdf_text:
|
||||
warnings.append(
|
||||
f"物体 SDF{where} 里没有 DetachableJoint 插件 —— 抓取锁不会生效。\n"
|
||||
f" 配置说 enabled: true,但模型里没有插件时**不会报错**,\n"
|
||||
f" 只是夹住的物体依旧会滑(现象=「规莫名掉了」,看不到真因)。\n"
|
||||
f" 修法:把插件块补进 {spec.object}/model.sdf,参数照 grasp_lock.yaml。"
|
||||
)
|
||||
return warnings
|
||||
|
||||
if spec.robot_model and f"<child_model>{spec.robot_model}</child_model>" not in sdf_text:
|
||||
warnings.append(
|
||||
f"插件里的 <child_model> 不是 {spec.robot_model} —— "
|
||||
f"它必须等于 launch 里 spawn 机器人的 -name。\n"
|
||||
f" 对不上时 DetachableJoint 在 Configure 阶段直接失败,"
|
||||
f"插件被卸载、{spec.state_topic} 也不会出现(这是唯一的线索)。"
|
||||
)
|
||||
|
||||
if spec.robot_link and f"<child_link>{spec.robot_link}</child_link>" not in sdf_text:
|
||||
warnings.append(
|
||||
f"插件里的 <child_link> 不是 {spec.robot_link}。\n"
|
||||
f" 注意这里要填 Gazebo 的**真名**:URDF 里与固定关节相连的连杆会被\n"
|
||||
f" sdformat 合并(lump),例如手指只剩 sim_left_finger_mount。\n"
|
||||
f" 名字写错时插件照样加载、attach 照样「成功」,但关节连到不存在的\n"
|
||||
f" 连杆上 → 锁不生效。真名查法:ros2 topic echo /sim/world_pose_info"
|
||||
)
|
||||
|
||||
for topic, key in ((spec.attach_topic, "attach_topic"), (spec.detach_topic, "detach_topic")):
|
||||
if topic and f"<{key}>{topic}</{key}>" not in sdf_text:
|
||||
warnings.append(
|
||||
f"插件里的 <{key}> 不是 {topic}。\n"
|
||||
f" 两侧话题名不一致时,锁的触发消息发到一个没人听的地址上 ——\n"
|
||||
f" 不报错,只是永远不锁。"
|
||||
)
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
def _ign_topic_cmd(exe: str, topic: str, timeout_ms: int) -> List[str]:
|
||||
"""拼一条 `ign topic` 发布命令(空消息)。
|
||||
|
||||
⚠️ ``-d``(duration 秒)不是可选的装饰 —— 少了它**大概率丢消息**:
|
||||
CLI 建立 publisher 与网络里发现订阅者需要一点时间,一条 fire-and-forget
|
||||
的消息在发现完成前就发完了。曾经写成 ``-w <秒>``(以为它等订阅者),
|
||||
而这个版本的 ``ign topic`` **没有 -w** —— 参数被当成多余的位置参数:
|
||||
"The following arguments were not expected: 3"
|
||||
于是"上锁/解锁"永远失败,而现象和"锁没生效"完全一样(规照样掉)。
|
||||
"""
|
||||
return [
|
||||
exe,
|
||||
"topic",
|
||||
"-t", topic,
|
||||
"-m", "ignition.msgs.Empty",
|
||||
"-p", "",
|
||||
"-d", f"{max(0.5, timeout_ms / 1000.0):.2f}",
|
||||
]
|
||||
|
||||
|
||||
def _find_ign_exe(explicit: Optional[str] = None) -> Optional[str]:
|
||||
"""找到 ign / gz 命令行。找不到返回 None(调用方决定是告警还是忽略)。"""
|
||||
import shutil
|
||||
|
||||
if explicit:
|
||||
return explicit
|
||||
for name in ("ign", "gz"):
|
||||
found = shutil.which(name)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def publish_lock_event(
|
||||
spec: GraspLockSpec,
|
||||
attach: bool,
|
||||
exe: Optional[str] = None,
|
||||
logger=None,
|
||||
) -> bool:
|
||||
"""发一条"上锁/解锁"消息。返回是否成功(失败只记日志,不抛)。
|
||||
|
||||
**为什么不抛异常**:这不是主流程的一步 —— 它是仿真里的补救措施。
|
||||
发不出去时正确行为是"继续跑,但明确告警",而不是把整条装配流程打断:
|
||||
现场宁可看到"锁没生效、规可能滑"的提示,也不要看到"因为锁发不出去,
|
||||
装配根本没做"。
|
||||
"""
|
||||
if not spec.enabled:
|
||||
return False
|
||||
topic = spec.attach_topic if attach else spec.detach_topic
|
||||
what = "上锁" if attach else "解锁"
|
||||
exe_path = _find_ign_exe(exe)
|
||||
if exe_path is None:
|
||||
if logger is not None:
|
||||
logger.warn(
|
||||
f"抓取锁{what}失败:找不到 ign/gz 命令行(PATH 里没有)。\n"
|
||||
f" 后果:夹住的物体在机械臂连续运动时可能滑掉(现象=「规掉了」)。"
|
||||
)
|
||||
return False
|
||||
|
||||
cmd = _ign_topic_cmd(exe_path, topic, spec.publish_timeout_ms)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
timeout=max(2.0, spec.publish_timeout_ms / 1000.0 + 2.0),
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
if logger is not None:
|
||||
logger.warn(f"抓取锁{what}失败({topic}):{exc}")
|
||||
return False
|
||||
|
||||
if proc.returncode != 0:
|
||||
out = (proc.stdout or b"").decode("utf-8", "replace").strip()
|
||||
if logger is not None:
|
||||
logger.warn(
|
||||
f"抓取锁{what}失败({topic},退出码 {proc.returncode}):{out[:200]}"
|
||||
)
|
||||
return False
|
||||
if logger is not None:
|
||||
logger.info(f"抓取锁{what}:{topic}")
|
||||
return True
|
||||
+618
@@ -0,0 +1,618 @@
|
||||
"""仿真夹爪的耦合关系、行程换算与命令向量拼装(纯逻辑,不起 ROS)。
|
||||
|
||||
=============================================================================
|
||||
为什么单独一个模块
|
||||
=============================================================================
|
||||
把"夹爪服务(mm)"变成"7 个关节的目标角(弧度/米)",真正有内容的是四件事:
|
||||
|
||||
1. 耦合关系 从动关节 = multiplier × 主关节 + offset
|
||||
(上游 GN01 描述里主关节是 ``finger_width_joint``,
|
||||
6 个从动关节按各自的 multiplier 跟随)
|
||||
2. 行程换算 应用层说的是**开口毫米**,Gazebo 要的是**关节值**(米)
|
||||
3. 命令顺序 JointGroupPositionController 收到的数组顺序必须与
|
||||
controllers.yaml 里 ``joints:`` 完全一致
|
||||
4. 夹住判据 真机有状态字,仿真只能靠"指令到了下限、实际值没跟上"推断
|
||||
|
||||
四件都是纯数据变换,不需要 ROS 跑起来就能验。所以实现放这里、用普通函数表达,
|
||||
节点(sim_gripper_adapter.py)只做订阅/发布这层壳。这样"multiplier 用错"
|
||||
这种错误才能被单测钉住,而不是靠"看起来夹住了"。
|
||||
|
||||
=============================================================================
|
||||
为什么 Gazebo 里必须由适配器驱动 7 个关节
|
||||
=============================================================================
|
||||
sdformat 把 URDF 的 ``<mimic>`` **整个丢掉**,gz-sim 也不实现 mimic。
|
||||
所以仿真里 6 个从动关节不会跟随主关节,必须由适配器算出角度后**一起下发**。
|
||||
``urdf_normalize`` 在归一化时把 ``<mimic>`` 删掉(避免 robot_state_publisher
|
||||
和 Gazebo 各说各话),删掉的信息记在 ``NormalizeReport.stripped_mimics`` 里,
|
||||
launch 再把那张表作为参数交给本适配器 —— 这样"耦合关系"只有一个来源。
|
||||
|
||||
=============================================================================
|
||||
毫米 ↔ 关节值的口径(最容易搞错的一处)
|
||||
=============================================================================
|
||||
``finger_width_joint`` 是上游描述里的**虚拟宽度关节**(注释说它"本应是
|
||||
prismatic,但为了配合 mimic 只能是 revolute"),单位是**米**,限位 **0~0.100**,
|
||||
语义就是开口宽度:0 = 两指合拢,0.100 = 开口 100 mm。
|
||||
|
||||
本适配器采用**绝对(物理)映射**:命令"开口 8 mm",仿真里两指就真的相距 8 mm。
|
||||
(而不是归一化映射 —— 那种把"100% 行程"当成全开,会让中间开口全部失真,
|
||||
仿真就失去"拿真几何试算法"的意义。)
|
||||
|
||||
⚠️ **但这里有个坑:不能简单地写成 `关节值 = 开口_mm / 1000`。**
|
||||
上游的耦合是**线性近似**,而机构本身是非线性的(手指是"转 + 再转",指垫
|
||||
既平移又旋转)。按线性口径命令 20 mm,Gazebo 实测两指相距 **21.75 mm** ——
|
||||
抓 20 mm 的工件会空出 1.75 mm,没有接触力,触觉那一步在仿真里直接废掉。
|
||||
|
||||
所以开口↔关节值走**URDF 正运动学反解**(见 sim_gripper_kin.py):
|
||||
|
||||
aperture_to_source(20.0) → 令两指间距 − 合拢间距 == 20.0 的关节值
|
||||
|
||||
这个正运动学与 Gazebo 的实测吻合到 0.01 mm(0~100 mm 全程),两边完全独立
|
||||
(一个是矩阵乘法,一个是物理引擎),所以"开口 20 mm"在仿真里是物理上真的 20 mm。
|
||||
顺带一个推论:GN01 的**实测**行程是 99.93 mm,不是名义的 100.0 ——
|
||||
启动日志里报的是实测值。
|
||||
|
||||
万一某个夹爪的指尖 link 命名不符合约定,正运动学建不出来,会**退回线性近似**
|
||||
并在启动日志里明确写出口径变了(中段偏差可达 4 mm/100mm 行程)。
|
||||
别在那种情况下拿仿真去标触觉阈值。
|
||||
|
||||
⚠️ 还要注意**仿真夹爪和真机夹爪不是同一个夹爪**:仿真是官方 GN01(行程
|
||||
100 mm),现场是 DH PGEA-100-20-W(``site.yaml`` 的 ``gripper.stroke_mm``
|
||||
= 20 mm)。同一个"8 mm 开口"在两者上都物理正确,但"全开"对应的开口
|
||||
完全不同。适配器启动时会把两者的行程打出来,避免有人默认它们一样。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
from .sim_gripper_kin import ApertureCurve, KinematicsError, UrdfTree, find_tip_pair
|
||||
|
||||
import yaml
|
||||
|
||||
# 默认控制器配置位置(相对仓库根)。与真机那份同名不同目录,别搞混。
|
||||
DEFAULT_CONTROLLERS_REL = "devices/sim/gazebo/config/controllers.yaml"
|
||||
|
||||
#: 适配器负责的控制器名。改这里要同步改 launch 与 controllers.yaml。
|
||||
DEFAULT_CONTROLLER = "gripper_controller"
|
||||
|
||||
#: 关节值(米)与开口(毫米)的换算系数。
|
||||
MM_PER_M = 1000.0
|
||||
|
||||
#: 参数里 mimic 表的序列化分隔符。关节名里不会有 ':',所以安全。
|
||||
_MIMIC_SEP = ":"
|
||||
|
||||
|
||||
class GripperConfigError(RuntimeError):
|
||||
"""夹爪配置/描述有问题时抛出,消息面向使用者。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MimicRelation:
|
||||
"""一条 ``<mimic>`` 耦合关系:从动关节 = multiplier × 主关节 + offset。"""
|
||||
|
||||
joint: str
|
||||
source: str
|
||||
multiplier: float
|
||||
offset: float
|
||||
|
||||
def apply(self, source_value: float) -> float:
|
||||
"""按这条关系算出从动关节的值。"""
|
||||
return self.multiplier * source_value + self.offset
|
||||
|
||||
def to_param(self) -> str:
|
||||
"""序列化成 ROS 参数用的字符串(launch 与本模块必须用同一格式)。"""
|
||||
return format_mimic_param(self.joint, self.source, self.multiplier, self.offset)
|
||||
|
||||
|
||||
def format_mimic_param(joint: str, source: str, multiplier: object, offset: object) -> str:
|
||||
"""把一条耦合关系序列化成 ROS 参数用的字符串。
|
||||
|
||||
launch 从 ``NormalizeReport.stripped_mimics`` 拿到的是**四个字符串**,
|
||||
本模块解析的也是这个格式,所以格式化只留这一个实现 ——
|
||||
两边各写一份的话,改了格式就会一边发一边收不到(而且不报错)。
|
||||
"""
|
||||
return _MIMIC_SEP.join(
|
||||
(str(joint).strip(), str(source).strip(), repr(float(multiplier)), repr(float(offset)))
|
||||
)
|
||||
|
||||
|
||||
class GripperLayout:
|
||||
"""夹爪在仿真里的完整描述:关节顺序 + 耦合关系 + 主关节限位。
|
||||
|
||||
由 :func:`layout_from_parts` 构造(它做全部校验);不要直接 new。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
order: Sequence[str],
|
||||
relations: Mapping[str, MimicRelation],
|
||||
source: str,
|
||||
lower: float,
|
||||
upper: float,
|
||||
curve: Optional[ApertureCurve] = None,
|
||||
kin_note: str = "",
|
||||
) -> None:
|
||||
self.order: Tuple[str, ...] = tuple(order)
|
||||
self.relations: Dict[str, MimicRelation] = dict(relations)
|
||||
self.source = source
|
||||
self.lower = float(lower)
|
||||
self.upper = float(upper)
|
||||
self.feedback = self._pick_feedback()
|
||||
# 由 URDF 正运动学给出的"真实开口曲线";None = 退回线性近似。
|
||||
self.curve: Optional[ApertureCurve] = curve
|
||||
#: 口径说明(给日志用):是 FK 精确口径还是线性近似。
|
||||
self.kin_note: str = kin_note
|
||||
|
||||
# --- 反馈关节 ---------------------------------------------------------
|
||||
def _pick_feedback(self) -> str:
|
||||
"""挑一个**真的会动**的关节来当开口反馈源。
|
||||
|
||||
⚠️ 主关节 ``finger_width_joint`` 修不出来:上游描述里它挂在一个叫
|
||||
``unused_finger_width_link`` 的**孤立假 link** 上(注释原话:
|
||||
"The unused link cannot have 0 mass"),和手指没有任何机械连接。
|
||||
实测(2026-09-24,Gazebo Fortress + gz_ros2_control):命令它到 0.100,
|
||||
它的状态**始终停在 0**,而 6 个从动关节准确到位。
|
||||
所以拿主关节当反馈源的话,``status`` 会永远报"开口 0 mm",
|
||||
而夹爪明明开着 —— 这正是"看起来在工作"的静默失效。
|
||||
|
||||
于是改从**与主关节直接相连的那个从动关节**反推开口:
|
||||
state = m × source + o → source = (state - o) / m
|
||||
选 |m| 最大的那个(分辨率最好;m 为 0 的不能用)。
|
||||
|
||||
单关节夹爪(没有耦合关系)时退回到主关节自己。
|
||||
"""
|
||||
candidates = [
|
||||
(abs(rel.multiplier), joint)
|
||||
for joint, rel in self.relations.items()
|
||||
if rel.source == self.source and abs(rel.multiplier) > 1e-9
|
||||
]
|
||||
if not candidates:
|
||||
return self.source
|
||||
candidates.sort(reverse=True)
|
||||
return candidates[0][1]
|
||||
|
||||
def feedback_value(self, source_value: float) -> float:
|
||||
"""反馈关节在"主关节 = source_value"时应有的值(用于判"没跟上")。"""
|
||||
if self.feedback == self.source:
|
||||
return float(source_value)
|
||||
return self.value_for(self.feedback, source_value)
|
||||
|
||||
def source_from_feedback(self, feedback_state: float) -> float:
|
||||
"""反馈关节实测值 → 等价的主关节值(米)。"""
|
||||
if self.feedback == self.source:
|
||||
return float(feedback_state)
|
||||
rel = self.relations[self.feedback]
|
||||
if abs(rel.multiplier) < 1e-9:
|
||||
raise GripperConfigError(
|
||||
f"反馈关节 {self.feedback!r} 的 multiplier 为 0,无法反推主关节值。"
|
||||
)
|
||||
return (float(feedback_state) - rel.offset) / rel.multiplier
|
||||
|
||||
def aperture_from_feedback(self, feedback_state: float) -> float:
|
||||
"""反馈关节的实测值 → 开口(毫米)。"""
|
||||
return self.source_to_aperture(self.source_from_feedback(feedback_state))
|
||||
|
||||
# --- 行程口径 ---------------------------------------------------------
|
||||
@property
|
||||
def stroke_mm(self) -> float:
|
||||
"""这个夹爪在仿真里**实际**能开的宽度(毫米)。
|
||||
|
||||
有 FK 曲线时用实测行程(GN01 ≈ 99.93 mm,不是名义的 100.0 ——
|
||||
上游把宽度关节限位定在 0~0.100 m,而线性耦合让指尖只走了 99.93,
|
||||
报名义值会让"全开时开口读数与几何对不上"变成一个查不出的矛盾)。
|
||||
没有 FK 曲线时只能按限位换算,并在 :attr:`kin_note` 里说明是近似。
|
||||
|
||||
真机那份行程在 ``site.yaml`` 的 ``gripper.stroke_mm``;两者不同是正常的
|
||||
(不同夹爪),但要显式打出来,见 :func:`stroke_note`。
|
||||
"""
|
||||
if self.curve is not None:
|
||||
return self.curve.stroke_mm
|
||||
return (self.upper - self.lower) * MM_PER_M
|
||||
|
||||
@property
|
||||
def closed_separation_mm(self) -> Optional[float]:
|
||||
"""合拢时两侧 tip frame 的固有间距(GN01 ≈ 27 mm);无 FK 时为 None。
|
||||
|
||||
验收脚本用它把"指尖间距"折成"开口"(指垫合拢时间距不为 0,
|
||||
因为 tip frame 不在指垫表面)。见 scripts/check_sim_gripper.py。
|
||||
"""
|
||||
return None if self.curve is None else self.curve.closed_separation_mm
|
||||
|
||||
def aperture_to_source(self, aperture_mm: float) -> float:
|
||||
"""开口(毫米)→ 主关节值(米),并按限位截断。
|
||||
|
||||
**绝对物理映射**:夹爪真的开这么宽。越界不报错而是截断(撞到限位是
|
||||
正常操作),但调用方可以从 :meth:`is_clamped` 知道发生了截断。
|
||||
|
||||
⚠️ 有 FK 曲线时走**反解**,不是线性换算。因为上游的耦合是线性近似、
|
||||
而机构是非线性的:线性口径下"命令 20 mm"实际是 21.75 mm 的指垫间距,
|
||||
抓 20 mm 的工件会空出 1.75 mm(没有接触力),
|
||||
触觉那一步在仿真里就废了。细节见 sim_gripper_kin.py 文件头。
|
||||
"""
|
||||
if self.curve is not None:
|
||||
return self.curve.joint_value(aperture_mm)
|
||||
raw = self.lower + float(aperture_mm) / MM_PER_M
|
||||
return min(max(raw, self.lower), self.upper)
|
||||
|
||||
def source_to_aperture(self, source_value: float) -> float:
|
||||
"""主关节值(米)→ 开口(毫米)。与 :meth:`aperture_to_source` 互逆。"""
|
||||
if self.curve is not None:
|
||||
return self.curve.aperture_mm(source_value)
|
||||
return (float(source_value) - self.lower) * MM_PER_M
|
||||
|
||||
def is_clamped(self, aperture_mm: float) -> bool:
|
||||
"""这个开口请求是否会被限位截断(用来给调用方一句实话)。"""
|
||||
if self.curve is not None:
|
||||
return self.curve.is_clamped(aperture_mm)
|
||||
raw = self.lower + float(aperture_mm) / MM_PER_M
|
||||
return not (self.lower <= raw <= self.upper)
|
||||
|
||||
# --- 命令向量 ---------------------------------------------------------
|
||||
def command_vector(self, aperture_mm: float) -> List[float]:
|
||||
"""按 ``order`` 拼出一整组关节目标(主关节 + 从动关节)。
|
||||
|
||||
顺序必须与 controllers.yaml 的 ``joints:`` 一致 —— 顺序错了,
|
||||
JointGroupPositionController 不会报错,只会把角度发给**别的关节**,
|
||||
表现为"夹爪动得很奇怪"。
|
||||
"""
|
||||
return self.command_vector_from_source(self.aperture_to_source(aperture_mm))
|
||||
|
||||
def command_vector_from_source(self, source_value: float) -> List[float]:
|
||||
"""同上,但直接给主关节值(米),不经过"毫米"这一层来回换算。
|
||||
|
||||
``stop``(保持当前位置)这类命令手里只有关节状态值,走毫米会在
|
||||
两次换算里引入舍入误差,所以单独留这个入口。
|
||||
"""
|
||||
return [self.value_for(name, source_value) for name in self.order]
|
||||
|
||||
def value_for(self, joint: str, source_value: float) -> float:
|
||||
"""某个关节在"主关节 = source_value"时应有的值。
|
||||
|
||||
主关节自己就是 ``source_value``;从动关节沿**耦合链**逐级算出来。
|
||||
|
||||
⚠️ GN01 的耦合不是"6 个手指关节全部直挂主关节",而是一条链:
|
||||
|
||||
finger_width_joint ─9.404,-0.155→ left_outer_knuckle_joint
|
||||
├─ 1, 0 → left_inner_knuckle
|
||||
├─ -1,0 → left_inner_finger
|
||||
├─ 1, 0 → right_outer_knuckle
|
||||
├─ 1, 0 → right_inner_knuckle
|
||||
└─ -1,0 → right_inner_finger
|
||||
|
||||
所以必须**逐级**套用 multiplier/offset(先套靠近根的、再套靠近叶的),
|
||||
不能只套一层。只套一层的话,指尖角度会错得不多但一直不对 ——
|
||||
表现为"夹爪动作看着像那么回事,就是夹不准"。
|
||||
"""
|
||||
if joint == self.source:
|
||||
return float(source_value)
|
||||
|
||||
# 从 joint 往上爬到根,把沿途的关系按"从根到叶"的顺序套用。
|
||||
chain: List[MimicRelation] = []
|
||||
seen = set()
|
||||
cur = joint
|
||||
while True:
|
||||
rel = self.relations.get(cur)
|
||||
if rel is None:
|
||||
break
|
||||
if cur in seen:
|
||||
raise GripperConfigError(
|
||||
f"耦合链成环:{cur!r} 又回到了自己({' → '.join(sorted(seen))})"
|
||||
)
|
||||
seen.add(cur)
|
||||
chain.append(rel)
|
||||
cur = rel.source
|
||||
if cur != self.source:
|
||||
raise GripperConfigError(
|
||||
f"关节 {joint!r} 的耦合链终止在 {cur!r},而不是主关节 "
|
||||
f"{self.source!r} —— 描述里的耦合表不完整。"
|
||||
)
|
||||
value = float(source_value)
|
||||
for rel in reversed(chain):
|
||||
value = rel.apply(value)
|
||||
return value
|
||||
|
||||
def source_index(self) -> int:
|
||||
"""主关节在命令数组里的下标。"""
|
||||
return self.order.index(self.source)
|
||||
|
||||
def _source_limits_note(self) -> str:
|
||||
return f"限位 [{self.lower:.4f}, {self.upper:.4f}] m"
|
||||
|
||||
def describe(self) -> str:
|
||||
"""启动时打给操作者看的一行摘要(含必须知道的口径)。"""
|
||||
note = f";{self.kin_note}" if self.kin_note else ""
|
||||
return (
|
||||
f"主关节={self.source} {self._source_limits_note()} "
|
||||
f"→ 仿真行程 {self.stroke_mm:.2f} mm;"
|
||||
f"共 {len(self.order)} 个关节,{len(self.relations)} 条耦合{note}"
|
||||
)
|
||||
|
||||
def stroke_note(self, real_stroke_mm: Optional[float]) -> str:
|
||||
"""仿真行程与真机行程的对比说明(两者不同是正常的,但必须说出来)。"""
|
||||
if real_stroke_mm is None or real_stroke_mm <= 0:
|
||||
return (
|
||||
f"仿真夹爪行程 {self.stroke_mm:.1f} mm(site.yaml 里没读到真机行程,"
|
||||
f"无法对比)"
|
||||
)
|
||||
same = abs(real_stroke_mm - self.stroke_mm) < 0.5
|
||||
head = f"仿真行程 {self.stroke_mm:.1f} mm vs 真机 {real_stroke_mm:.1f} mm"
|
||||
if same:
|
||||
return head + ":一致。"
|
||||
return (
|
||||
head + ":**不一致**(不同型号的夹爪,这是正常的)。\n"
|
||||
" 开口毫米采用绝对物理映射,所以同一个开口在两边几何上都对;\n"
|
||||
" 但\"全开\"对应的开口不同,别把仿真里的全开当成真机的全开。\n"
|
||||
" 力/触觉阈值也要按各自夹爪重新标定(见 R3.5)。"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 解析与校验
|
||||
# ---------------------------------------------------------------------------
|
||||
def parse_mimics(entries: Sequence[str]) -> Dict[str, MimicRelation]:
|
||||
"""解析参数里的 mimic 表(每条 ``joint:source:multiplier:offset``)。
|
||||
|
||||
格式错就报错,不做"跳过这一条"的宽容处理:静默丢一条耦合关系,
|
||||
结果是某个手指关节永远不动,而 Gazebo 不会抱怨。
|
||||
"""
|
||||
out: Dict[str, MimicRelation] = {}
|
||||
for raw in entries:
|
||||
parts = str(raw).split(_MIMIC_SEP)
|
||||
if len(parts) != 4:
|
||||
raise GripperConfigError(
|
||||
f"mimic 参数格式不对:{raw!r}\n"
|
||||
f" 应为 '从动关节:主关节:multiplier:offset' 四段。"
|
||||
)
|
||||
joint, source, mult_s, off_s = (p.strip() for p in parts)
|
||||
if not joint or not source:
|
||||
raise GripperConfigError(f"mimic 参数里关节名为空:{raw!r}")
|
||||
try:
|
||||
mult, off = float(mult_s), float(off_s)
|
||||
except ValueError as exc:
|
||||
raise GripperConfigError(
|
||||
f"mimic 参数里的 multiplier/offset 不是数字:{raw!r}"
|
||||
) from exc
|
||||
if joint == source:
|
||||
raise GripperConfigError(
|
||||
f"mimic 参数里从动关节与主关节同名({joint})—— 描述有问题。"
|
||||
)
|
||||
if joint in out:
|
||||
raise GripperConfigError(
|
||||
f"mimic 参数里关节 {joint!r} 出现了两次,无法确定用哪条。"
|
||||
)
|
||||
out[joint] = MimicRelation(joint, source, mult, off)
|
||||
return out
|
||||
|
||||
|
||||
def gripper_joint_order(
|
||||
controllers_file: Path, controller: str = DEFAULT_CONTROLLER
|
||||
) -> List[str]:
|
||||
"""从 controllers.yaml 里读 ``<controller>.ros__parameters.joints``。
|
||||
|
||||
为什么解析而不是让调用方再配一份顺序:顺序是
|
||||
``JointGroupPositionController`` 的硬契约,配两份必然漂移,
|
||||
而漂移的症状是"角度发给了别的关节"、不报错。
|
||||
"""
|
||||
path = Path(controllers_file)
|
||||
try:
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
except OSError as exc:
|
||||
raise GripperConfigError(f"读不到控制器配置 {path}:{exc}") from exc
|
||||
except yaml.YAMLError as exc:
|
||||
raise GripperConfigError(f"控制器配置 {path} 不是合法 YAML:{exc}") from exc
|
||||
|
||||
section = (data.get(controller) or {}).get("ros__parameters")
|
||||
if not isinstance(section, dict):
|
||||
raise GripperConfigError(
|
||||
f"{path} 里没有 {controller}.ros__parameters 段。\n"
|
||||
f" 仿真夹爪要靠这个控制器的 joints 顺序下发命令,缺了就无法工作。"
|
||||
)
|
||||
joints = section.get("joints")
|
||||
if not isinstance(joints, list) or not joints:
|
||||
raise GripperConfigError(
|
||||
f"{path} 的 {controller}.ros__parameters 里 joints 为空。"
|
||||
)
|
||||
names = [str(j).strip() for j in joints]
|
||||
if len(set(names)) != len(names):
|
||||
raise GripperConfigError(
|
||||
f"{path} 的 {controller} joints 里有重复项:{names}"
|
||||
)
|
||||
return names
|
||||
|
||||
|
||||
def joint_limits(urdf_xml: str, joint_name: str) -> Tuple[float, float]:
|
||||
"""从 URDF 文本里取某个关节的 (lower, upper)。取不到就报错。
|
||||
|
||||
为什么不从参数再传一遍限位:限位是描述里的**唯一真相**(也就是 Gazebo
|
||||
里真实的行程)。再配一份等于给自己一个"改了描述没改配置"的机会,
|
||||
而症状是开口悄悄不按预期走。
|
||||
"""
|
||||
# 只认 <joint name="..."> 那一段,避免匹配到别的关节。
|
||||
pat = re.compile(
|
||||
r"<joint\b[^>]*\bname\s*=\s*[\"']%s[\"'][^>]*>(.*?)</joint>"
|
||||
% re.escape(joint_name),
|
||||
re.DOTALL,
|
||||
)
|
||||
m = pat.search(urdf_xml or "")
|
||||
if not m:
|
||||
raise GripperConfigError(
|
||||
f"在 robot_description 里找不到关节 {joint_name!r}。\n"
|
||||
f" 常见原因:描述没带夹爪(load_gripper:=false),或关节名前缀不对"
|
||||
f"(仿真是 sim_ 前缀)。"
|
||||
)
|
||||
body = m.group(1)
|
||||
if re.search(r"<mimic\b", body):
|
||||
raise GripperConfigError(
|
||||
f"关节 {joint_name!r} 自己是从动关节(带 <mimic>),不能当主关节。"
|
||||
)
|
||||
lm = re.search(r"<limit\b[^>]*>", body)
|
||||
if not lm:
|
||||
raise GripperConfigError(f"关节 {joint_name!r} 的 <limit> 标签缺失。")
|
||||
|
||||
def _attr(name: str) -> Optional[float]:
|
||||
am = re.search(r"\b%s\s*=\s*[\"']([^\"']+)[\"']" % name, lm.group(0))
|
||||
if not am:
|
||||
return None
|
||||
try:
|
||||
return float(am.group(1))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
lower, upper = _attr("lower"), _attr("upper")
|
||||
if lower is None or upper is None:
|
||||
# URDF 允许省略 limit 的上下限(continuous 关节就没有)。夹爪的宽度
|
||||
# 关节必须有,否则我们无从知道"全开"是多少 —— 这时报错比猜好。
|
||||
raise GripperConfigError(
|
||||
f"关节 {joint_name!r} 的 <limit> 缺 lower/upper,"
|
||||
f"无法确定开口行程。原始标签:{lm.group(0)}"
|
||||
)
|
||||
if upper <= lower:
|
||||
raise GripperConfigError(
|
||||
f"关节 {joint_name!r} 的限位不合法:lower={lower} upper={upper}"
|
||||
)
|
||||
return lower, upper
|
||||
|
||||
|
||||
def layout_from_parts(
|
||||
order: Sequence[str],
|
||||
mimics: Mapping[str, MimicRelation],
|
||||
urdf_xml: str,
|
||||
) -> GripperLayout:
|
||||
"""把三份数据拼成 :class:`GripperLayout`,并做全部一致性校验。
|
||||
|
||||
校验点(每一条都对应一种"不报错但行为错"的故障):
|
||||
|
||||
* ``mimics`` 里的从动关节必须在 ``order`` 里 —— 否则控制器根本没声明它,
|
||||
我们发出去的这一路会被忽略;
|
||||
* 从动关节引用的 ``source`` 也必须在 ``order`` 里 —— 链上有个关节没被
|
||||
控制器声明的话,那一级的角度就没人驱动;
|
||||
* 主关节(不带 mimic 的那个)必须**唯一** —— 多个候选说明推断逻辑不成立,
|
||||
这时静默挑第一个会挑错;
|
||||
* 每条耦合链都必须能走到主关节(不终止、成环都报错)—— 只校验开头几层
|
||||
的话,链尾错一处只表现为"某个指尖角度一直不对"。
|
||||
"""
|
||||
order = [str(j) for j in order]
|
||||
if not order:
|
||||
raise GripperConfigError("夹爪控制器没有声明任何关节。")
|
||||
|
||||
followers = dict(mimics)
|
||||
unknown = sorted(set(followers) - set(order))
|
||||
if unknown:
|
||||
raise GripperConfigError(
|
||||
f"mimic 表里的关节不在控制器 joints 列表里:{unknown}\n"
|
||||
f" 控制器声明的是:{order}\n"
|
||||
f" 两边必须一致,否则我们算出来的角度会被控制器忽略。"
|
||||
)
|
||||
missing_src = sorted({r.source for r in followers.values()} - set(order))
|
||||
if missing_src:
|
||||
raise GripperConfigError(
|
||||
f"mimic 表里有关节的**主关节**不在控制器 joints 列表里:{missing_src}\n"
|
||||
f" 控制器声明的是:{order}\n"
|
||||
f" 耦合链上少一环,下面所有关节的角度都会算错。"
|
||||
)
|
||||
|
||||
candidates = [j for j in order if j not in followers]
|
||||
if len(candidates) != 1:
|
||||
raise GripperConfigError(
|
||||
f"无法唯一确定主关节:控制器里有 {len(candidates)} 个关节不带 mimic "
|
||||
f"({candidates}),期望恰好 1 个。\n"
|
||||
f" 通常是描述里夹爪的 mimic 表没传进来(load_gripper 关了?),"
|
||||
f"或者上游描述换了耦合方式。"
|
||||
)
|
||||
source = candidates[0]
|
||||
|
||||
lower, upper = joint_limits(urdf_xml, source)
|
||||
layout = GripperLayout(order, followers, source, lower, upper)
|
||||
|
||||
# 逐条把耦合链走一遍:成环 / 不终止在这里就报出来,别等到运行期。
|
||||
for joint in followers:
|
||||
layout.value_for(joint, 0.0)
|
||||
|
||||
# 开口曲线要在 layout 之后建:它调 layout.value_for 拿每个关节的角度,
|
||||
# 而 value_for 是耦合链的**唯一**权威实现 —— 曲线里再抄一份迟早会漂移。
|
||||
# (所以是"先建后回填",不是构造参数。)
|
||||
layout.curve, layout.kin_note = _build_aperture_curve(layout, urdf_xml)
|
||||
return layout
|
||||
|
||||
|
||||
def _build_aperture_curve(
|
||||
layout: GripperLayout, urdf_xml: str
|
||||
) -> Tuple[Optional[ApertureCurve], str]:
|
||||
"""建"开口毫米 ↔ 主关节值"的精确曲线;建不出来就退回线性并**说明白**。
|
||||
|
||||
返回 ``(曲线或 None, 给日志的一句话)``。
|
||||
|
||||
为什么允许退回:换一个夹爪时,如果它的指尖 link 命名不符合约定,
|
||||
硬报错会让整个仿真起不来;而线性近似虽然中段有几毫米偏差,但
|
||||
"能不能动、方向对不对"仍然可验。所以退回,但**必须在日志里说清楚
|
||||
口径变成了近似** —— 否则有人会拿它当毫米精度的夹爪去标定触觉阈值。
|
||||
"""
|
||||
try:
|
||||
tree = UrdfTree(urdf_xml)
|
||||
tips = find_tip_pair(tree)
|
||||
if tips is None:
|
||||
return None, (
|
||||
"⚠ 没在 URDF 里认出左右指尖 link(按 *left*finger*tip* / "
|
||||
"*right*finger*tip* 约定找)→ 开口退回**线性近似**"
|
||||
"(中段偏差可达 4 mm/100mm 行程,别拿它标触觉阈值)"
|
||||
)
|
||||
curve = ApertureCurve(
|
||||
tree,
|
||||
tips,
|
||||
angles_of=lambda v: {j: layout.value_for(j, v) for j in layout.order},
|
||||
lower=layout.lower,
|
||||
upper=layout.upper,
|
||||
)
|
||||
return curve, (
|
||||
f"开口由 URDF 正运动学反解({tips[0]} / {tips[1]};"
|
||||
f"合拢时间距 {curve.closed_separation_mm:.2f} mm)"
|
||||
)
|
||||
except KinematicsError as exc:
|
||||
return None, (
|
||||
f"⚠ 正运动学不可用({exc})→ 开口退回**线性近似**"
|
||||
f"(中段偏差可达 4 mm/100mm 行程)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 夹住判据
|
||||
# ---------------------------------------------------------------------------
|
||||
#: 判"夹住了"的默认容差(**毫米开口**)。GN01 全行程 100 mm,2 mm 的开口差
|
||||
#: 只可能是被挡住了,不可能是控制精度问题。
|
||||
DEFAULT_GRASP_TOLERANCE_MM = 2.0
|
||||
|
||||
#: 判"还在动"的默认容差(毫米开口)。比上面小:它只是用来区分 MOVING / IDLE。
|
||||
DEFAULT_MOVING_TOLERANCE_MM = 0.5
|
||||
|
||||
|
||||
def is_grasping(
|
||||
target_aperture_mm: float,
|
||||
actual_aperture_mm: float,
|
||||
tolerance_mm: float = DEFAULT_GRASP_TOLERANCE_MM,
|
||||
) -> bool:
|
||||
"""由"指令到了合拢位、实际开口却更大"推断夹住了东西。
|
||||
|
||||
真机(DH)有状态字可以问 "object caught",仿真没有。但仿真里有一个等价
|
||||
且更本质的信号:位置控制器把目标压到合拢位,若两指之间夹着东西,
|
||||
关节就**到不了**那个位置,稳态开口会停在工件宽度附近。
|
||||
|
||||
两个条件同时成立才算夹住:
|
||||
|
||||
1. 目标在合拢侧(``target_aperture_mm`` 小于容差)—— 否则"有误差"只是没走到;
|
||||
2. 实际开口比目标大出容差 —— 被挡住了。
|
||||
|
||||
⚠️ 口径统一用**毫米开口**,不是主关节值(米)。因为开口↔关节值在
|
||||
FK 口径下是非线性的(见 sim_gripper_kin.py),拿米当口径的话"2 mm 容差"
|
||||
在不同开度下含义不同,标定出来的阈值换台夹爪就废了。
|
||||
|
||||
这是**推断**,不是测量:工件被夹在两指之间、但关节仍走到位的特殊情况
|
||||
(例如工件很薄且被压进夹爪根部)会漏判。所以 P6 的最终判据仍然用触觉
|
||||
力(见 R3.1),这里只提供 ``GripperStatus.is_gripped`` 这个粗信号。
|
||||
"""
|
||||
near_closed = float(target_aperture_mm) <= float(tolerance_mm)
|
||||
blocked = (
|
||||
float(actual_aperture_mm) - float(target_aperture_mm)
|
||||
) > float(tolerance_mm)
|
||||
return bool(near_closed and blocked)
|
||||
+515
@@ -0,0 +1,515 @@
|
||||
"""仿真夹爪适配器:``GripperExecute`` 服务 → ``JointGroupPositionController``。
|
||||
|
||||
=============================================================================
|
||||
它在链路上的位置
|
||||
=============================================================================
|
||||
应用层(P6 状态机 / 现场脚本)
|
||||
│ 服务 <ns>/open | <ns>/close | <ns>/move | <ns>/rotate
|
||||
│ (arm_msgs/srv/GripperExecute,与真机**同名同类型**)
|
||||
▼
|
||||
本节点
|
||||
│ ① 开口毫米 → 主关节值(绝对物理映射,见 sim_gripper.py 文件头)
|
||||
│ ② 按 mimic 耦合算出 6 个从动关节的目标角
|
||||
│ ③ 按 controllers.yaml 的 joints 顺序拼成数组
|
||||
▼
|
||||
话题 <controller>/commands (std_msgs/Float64MultiArray)
|
||||
▼
|
||||
JointGroupPositionController(gz_ros2_control,在 Gazebo 进程里)
|
||||
▼
|
||||
Gazebo 里的 7 个 sim_* 夹爪关节
|
||||
|
||||
同时订阅 /joint_states,把**反馈关节**的实际值换算回开口毫米,
|
||||
发布到 <ns>/status(arm_msgs/GripperStatus)。
|
||||
注意不是主关节 —— 主关节在 Gazebo 里压根不动,见下面第 4 个坑。
|
||||
|
||||
=============================================================================
|
||||
为什么必须由适配器驱动全部 7 个关节
|
||||
=============================================================================
|
||||
sdformat 把 URDF 的 ``<mimic>`` 整个丢掉,gz-sim 也不实现 mimic ——
|
||||
6 个从动关节在仿真里不会跟随主关节。所以适配器必须自己算。
|
||||
这件事同时解决了一个 TF 隐患:``robot_state_publisher`` 是按 /joint_states
|
||||
里的**真实值**发布 TF 的,只要 7 个关节都被驱动、且值符合耦合公式,
|
||||
RViz/TF 与 Gazebo 就一致(详见 urdf_normalize.py 里那段说明)。
|
||||
|
||||
=============================================================================
|
||||
⚠️ 仿真夹爪与真机夹爪不是同一个夹爪
|
||||
=============================================================================
|
||||
仿真是官方 Flexiv-GN01(行程 100 mm),现场是 DH PGEA-100-20-W
|
||||
(``site.yaml`` 的 ``gripper.stroke_mm`` = 20 mm)。开口用绝对物理映射,
|
||||
所以同一句"开 8 mm"两边都对;但"全开"对应的开口不同。启动时会把两者
|
||||
都打出来,差异大时会明确提示(见 :meth:`GripperLayout.stroke_note`)。
|
||||
|
||||
=============================================================================
|
||||
三个必须知道的坑
|
||||
=============================================================================
|
||||
1) **命令数组的顺序**取自 controllers.yaml,不是本节点里的常量。
|
||||
顺序错不会报错,只会把角度发给别的关节 —— 表现成"夹爪动得很奇怪"。
|
||||
所以本节点启动时先把顺序读出来并校验(从动关节必须在列表里)。
|
||||
|
||||
2) **``rotate`` 在平行夹爪上没有对应自由度**。真机那边 RGI 才有。这里明确
|
||||
返回 ``success=false`` 并说明原因,而不是假装成功 —— 假装成功会让上层
|
||||
的"拧入"流程在仿真里走到一个永远不动的分支上。
|
||||
|
||||
3) **``status`` 的 ``is_gripped`` 是推断值**(指令到合拢位但实际值没跟上),
|
||||
不是测量值。真机有 DH 状态字,仿真没有。最终夹持判据仍应看触觉力,
|
||||
见 sim_gripper.is_grasping 的说明。
|
||||
|
||||
4) **主关节 ``finger_width_joint`` 在 Gazebo 里不会动**。上游描述把它挂在
|
||||
孤立的 ``unused_finger_width_link`` 上(那个 link 的注释写着 "cannot have
|
||||
0 mass",就是为了让 URDF 合法),它和手指没有任何机械连接。
|
||||
实测:命令到 0.100,它的 /joint_states 值恒为 ~0,而 6 个从动关节准确到位。
|
||||
所以:
|
||||
- 命令数组里**仍然要带上它**(controllers.yaml 的 joints 含它,少一个会错位);
|
||||
- 但**反馈绝不能取它**,否则 status 永远报"开口 0 mm"而夹爪明明开着。
|
||||
适配器改用"与主关节直接相连、|multiplier| 最大的那个从动关节"反推开口
|
||||
(见 GripperLayout._pick_feedback / aperture_from_feedback)。
|
||||
|
||||
5) **仿真里的夹持靠不住,需要一把"抓取锁"**。仿真的夹持力不是设出来的,
|
||||
而是位置误差 × 接触刚度:规站着不动够用,机械臂一走连续轨迹(S01 快转
|
||||
150°、pre_lift 60~100mm)就在指间打滑 —— 实测工具长度 0.243m → 0.285m、
|
||||
规歪 62°,S07 随后拿到的几何全错。真机不会(46N 握力 + µ≈0.8 + 0.27kg)。
|
||||
所以本节点在**确认夹住之后**用 DetachableJoint 把物体固定到手指上,
|
||||
**指令要求张开时**解锁(详见 sim_grasp_lock.py;为什么不拿 is_gripped
|
||||
当解锁条件,见 :meth:`_sync_grasp_lock`)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
from std_msgs.msg import Float64MultiArray
|
||||
|
||||
from arm_msgs.msg import GripperStatus
|
||||
from arm_msgs.srv import GripperExecute
|
||||
from sensor_msgs.msg import JointState
|
||||
|
||||
from .sim_grasp_lock import GraspLockSpec, publish_lock_event
|
||||
from .sim_gripper import (
|
||||
DEFAULT_CONTROLLER,
|
||||
DEFAULT_CONTROLLERS_REL,
|
||||
DEFAULT_GRASP_TOLERANCE_MM,
|
||||
DEFAULT_MOVING_TOLERANCE_MM,
|
||||
GripperConfigError,
|
||||
GripperLayout,
|
||||
gripper_joint_order,
|
||||
is_grasping,
|
||||
layout_from_parts,
|
||||
parse_mimics,
|
||||
)
|
||||
|
||||
#: 主关节状态多少秒没更新就算"数据不新鲜"(仿真时间,不是墙钟)。
|
||||
#: 控制器 100Hz、JSB 也在这个量级,1 秒足够宽松。
|
||||
STATE_STALE_S = 1.0
|
||||
|
||||
#: 服务后缀 → GripperCommand.command 的映射。前四个就是 capabilities.py
|
||||
#: 里声明的接口名;execute 是通用入口(与 dual_gripper_driver 对齐)。
|
||||
_SERVICE_COMMANDS = ("open", "close", "move", "rotate")
|
||||
|
||||
|
||||
def _site_stroke_mm() -> Optional[float]:
|
||||
"""从 site.yaml 读真机夹爪行程(只为启动时打一句对比,读不到返回 None)。
|
||||
|
||||
读不到不影响工作 —— 它只是提示信息,不该让仿真起不来。
|
||||
"""
|
||||
try:
|
||||
from arm_interfaces import load_site # 延迟导入:只需要它一处
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
data = load_site()
|
||||
except Exception:
|
||||
return None
|
||||
grip = data.get("gripper") if isinstance(data, dict) else None
|
||||
if not isinstance(grip, dict):
|
||||
return None
|
||||
for key in ("stroke_mm", "stroke"):
|
||||
try:
|
||||
val = float(grip.get(key))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if val > 0:
|
||||
return val
|
||||
return None
|
||||
|
||||
|
||||
class SimGripperAdapter(Node):
|
||||
"""把 ``GripperExecute`` 服务翻译成 Gazebo 里 7 个夹爪关节的目标位置。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("sim_gripper_adapter")
|
||||
|
||||
# 参数:全部由 launch 从**同一份** prepare_urdf 结果与 controllers.yaml
|
||||
# 派生,避免这里自己再展开一次 xacro 造成两边不一致。
|
||||
self.declare_parameter("controllers_file", "")
|
||||
self.declare_parameter("controller", DEFAULT_CONTROLLER)
|
||||
self.declare_parameter("mimics", [""])
|
||||
# ⚠️ 不要 declare_parameter("use_sim_time", ...):launch 会作为参数文件
|
||||
# 传进来,rclpy 那时已经自动声明过,再声明会抛异常让节点起不来。
|
||||
self.declare_parameter("namespace", "/gripper")
|
||||
self.declare_parameter("tolerance_mm", DEFAULT_GRASP_TOLERANCE_MM) # 夹住判据容差(毫米开口)
|
||||
self.declare_parameter("default_speed_pct", 50.0)
|
||||
self.declare_parameter("default_force_pct", 20.0)
|
||||
# 仿真抓取锁(见 sim_grasp_lock.py)。默认**关**:参数化之后,launch 才是
|
||||
# 唯一决定它开不开的地方 —— 单跑本节点做调试时不该莫名其妙去动 Gazebo。
|
||||
self.declare_parameter("grasp_lock_enabled", False)
|
||||
self.declare_parameter("grasp_lock_object", "gauge")
|
||||
self.declare_parameter("grasp_lock_attach_topic", "/sim/grasp_lock/attach")
|
||||
self.declare_parameter("grasp_lock_detach_topic", "/sim/grasp_lock/detach")
|
||||
self.declare_parameter("grasp_lock_timeout_ms", 3000)
|
||||
|
||||
controller = str(self.get_parameter("controller").value or DEFAULT_CONTROLLER)
|
||||
raw_ctrl = str(self.get_parameter("controllers_file").value or "")
|
||||
controllers_file = Path(raw_ctrl) if raw_ctrl else Path.cwd() / DEFAULT_CONTROLLERS_REL
|
||||
|
||||
# mimics 参数传进来的是字符串数组;空字符串要滤掉(ROS 对空数组不友好)。
|
||||
raw_mimics = [
|
||||
str(m).strip()
|
||||
for m in (self.get_parameter("mimics").value or [])
|
||||
if str(m).strip()
|
||||
]
|
||||
|
||||
urdf_xml = self._robot_description()
|
||||
try:
|
||||
order = gripper_joint_order(controllers_file, controller)
|
||||
relations = parse_mimics(raw_mimics)
|
||||
self.layout: GripperLayout = layout_from_parts(order, relations, urdf_xml)
|
||||
except GripperConfigError as exc:
|
||||
# 配置错就让节点直接失败:一个"什么都不动"的夹爪适配器比没有更糟 ——
|
||||
# 它看起来在工作,而上层会一直等一个永远不来的状态变化。
|
||||
self.get_logger().error(f"仿真夹爪配置有问题:\n{exc}")
|
||||
raise
|
||||
|
||||
self._controller = controller
|
||||
self._ns = str(self.get_parameter("namespace").value or "/gripper").rstrip("/")
|
||||
self._tolerance_mm = float(self.get_parameter("tolerance_mm").value)
|
||||
self._speed = float(self.get_parameter("default_speed_pct").value)
|
||||
self._force = float(self.get_parameter("default_force_pct").value)
|
||||
|
||||
# 当前目标(主关节值,米)与"最近一次是否有状态"。
|
||||
self._target = self.layout.aperture_to_source(self.layout.stroke_mm) # 默认全开
|
||||
self._feedback_state: Optional[float] = None
|
||||
self._state_stamp: Optional[float] = None
|
||||
# ⚠️ 反馈取 layout.feedback,**不是**主关节:上游的 finger_width_joint
|
||||
# 挂在孤立的 unused_finger_width_link 上,在 Gazebo 里根本不动
|
||||
# (实测:命令 0.100,状态恒为 0)。拿它当反馈会永远报"开口 0 mm"。
|
||||
# 详见 sim_gripper.GripperLayout._pick_feedback 的说明。
|
||||
self._state_name = f"{self.layout.feedback}"
|
||||
|
||||
self._pub_cmd = self.create_publisher(
|
||||
Float64MultiArray, f"/{controller}/commands", 10
|
||||
)
|
||||
self._pub_status = self.create_publisher(GripperStatus, f"{self._ns}/status", 10)
|
||||
self.create_subscription(JointState, "/joint_states", self._on_joint_state, 10)
|
||||
|
||||
self._srv: List[object] = []
|
||||
for name in _SERVICE_COMMANDS:
|
||||
self._srv.append(
|
||||
self.create_service(
|
||||
GripperExecute, f"{self._ns}/{name}", self._make_handler(name)
|
||||
)
|
||||
)
|
||||
self._srv.append(
|
||||
self.create_service(GripperExecute, f"{self._ns}/execute", self._on_execute)
|
||||
)
|
||||
|
||||
self.get_logger().info(f"夹爪:{self.layout.describe()}")
|
||||
if self.layout.feedback != self.layout.source:
|
||||
self.get_logger().info(
|
||||
f"夹爪开口反馈取自 {self.layout.feedback}(反推主关节 "
|
||||
f"{self.layout.source};主关节本身在 Gazebo 里不跟随命令)"
|
||||
)
|
||||
self.get_logger().info(
|
||||
f"夹爪行程口径:{self.layout.stroke_note(_site_stroke_mm())}"
|
||||
)
|
||||
self.get_logger().info(
|
||||
f"服务 {self._ns}/{{{'|'.join(_SERVICE_COMMANDS)}|execute}} → "
|
||||
f"话题 /{controller}/commands"
|
||||
)
|
||||
|
||||
# 先把夹爪摆到默认全开,免得机器人一出场就是捏着的状态。
|
||||
self._publish_target(self._target)
|
||||
|
||||
# 抓取锁:规格从参数来(launch 从 grasp_lock.yaml 派生),状态用三态:
|
||||
# None = 不知道 / False = 已解锁 / True = 已上锁
|
||||
# 之所以要 None 而不是 bool + 计数器:发布失败时要能**重试**,
|
||||
# 而"发布失败"恰恰表现为"发出去没人听"(话题名错、进程起不来),
|
||||
# 只有把"未确认"单独表示出来才可能重试。
|
||||
self._lock = GraspLockSpec(
|
||||
enabled=bool(self.get_parameter("grasp_lock_enabled").value),
|
||||
object=str(self.get_parameter("grasp_lock_object").value or "gauge"),
|
||||
object_link="",
|
||||
robot_model="",
|
||||
robot_link="",
|
||||
attach_topic=str(self.get_parameter("grasp_lock_attach_topic").value or ""),
|
||||
detach_topic=str(self.get_parameter("grasp_lock_detach_topic").value or ""),
|
||||
state_topic="",
|
||||
publish_timeout_ms=int(self.get_parameter("grasp_lock_timeout_ms").value),
|
||||
)
|
||||
self._locked: Optional[bool] = None
|
||||
if self._lock.enabled:
|
||||
self.get_logger().info(
|
||||
f"抓取锁已启用:夹住后把 {self._lock.object} 用固定关节锁到手指上,"
|
||||
f"张开时解锁(attach={self._lock.attach_topic})。\n"
|
||||
" 它补的是 Gazebo 缺的那块物理(刚性接触没有静摩擦迟滞、也不模拟"
|
||||
"指垫贴合)——\n"
|
||||
" 真机 46N 握力 + µ≈0.8 本来就夹得住,仿真的位置控制夹持则会在"
|
||||
"连续运动时打滑。"
|
||||
)
|
||||
|
||||
self.create_timer(0.1, self._tick_status)
|
||||
|
||||
# --- 参数与描述 -------------------------------------------------------
|
||||
def _robot_description(self) -> str:
|
||||
"""取归一化后的 robot_description(launch 与 RSP 用的是同一份)。"""
|
||||
if not self.has_parameter("robot_description"):
|
||||
self.declare_parameter("robot_description", "")
|
||||
xml = str(self.get_parameter("robot_description").value or "")
|
||||
if not xml.strip():
|
||||
raise GripperConfigError(
|
||||
"没拿到 robot_description 参数,无法确定夹爪关节的限位。\n"
|
||||
" 正常由 gazebo_sim.launch.py 传入(它与发给 robot_state_publisher "
|
||||
"的是同一份归一化描述)。单独手跑本节点时要显式给:\n"
|
||||
" ros2 run arm_sim_gazebo sim_gripper_adapter "
|
||||
"--ros-args -p robot_description:=\"$(xacro ...)\""
|
||||
)
|
||||
return xml
|
||||
|
||||
# --- 下发 -------------------------------------------------------------
|
||||
def _publish_target(self, source_value: float) -> None:
|
||||
"""把一整组关节目标(主 + 从动)按顺序发出去。"""
|
||||
self._target = source_value
|
||||
msg = Float64MultiArray()
|
||||
msg.data = [float(v) for v in self.layout.command_vector_from_source(source_value)]
|
||||
self._pub_cmd.publish(msg)
|
||||
|
||||
def _move_to_aperture(self, aperture_mm: float) -> None:
|
||||
self._publish_target(self.layout.aperture_to_source(aperture_mm))
|
||||
|
||||
# --- 状态 -------------------------------------------------------------
|
||||
def _on_joint_state(self, msg: JointState) -> None:
|
||||
for name, pos in zip(msg.name, msg.position):
|
||||
if name == self._state_name:
|
||||
self._feedback_state = float(pos)
|
||||
self._state_stamp = self._now()
|
||||
return
|
||||
|
||||
def _now(self) -> float:
|
||||
return self.get_clock().now().nanoseconds * 1e-9
|
||||
|
||||
def _fresh_state(self) -> Optional[float]:
|
||||
"""反馈关节的当前值;太久没更新或从没收到过就返回 None。"""
|
||||
if self._feedback_state is None or self._state_stamp is None:
|
||||
return None
|
||||
if (self._now() - self._state_stamp) > STATE_STALE_S:
|
||||
return None
|
||||
return self._feedback_state
|
||||
|
||||
def _status_msg(self) -> GripperStatus:
|
||||
state = self._fresh_state()
|
||||
msg = GripperStatus()
|
||||
msg.header.stamp = self.get_clock().now().to_msg()
|
||||
# frame_id 留空:真机那边填的是 "<side>_gripper",而仿真里没有任何东西
|
||||
# 发布那个坐标系(TF 只来自 robot_state_publisher)。
|
||||
# 填一个查不到的 frame 比留空更糟 —— tf2 会一直报 frame 不存在。
|
||||
msg.header.frame_id = ""
|
||||
msg.name = "gn01_sim"
|
||||
msg.gripper_type = "parallel"
|
||||
msg.connected = state is not None
|
||||
msg.initialized = state is not None
|
||||
msg.velocity = self._speed
|
||||
msg.force = self._force
|
||||
|
||||
if state is None:
|
||||
# 没状态就别编:position 保持 0 会让上层以为夹爪是合拢的。
|
||||
msg.state = "ERROR"
|
||||
msg.error_msg = (
|
||||
f"{STATE_STALE_S:.1f}s 内没收到 {self._state_name} 的 /joint_states,"
|
||||
f"无法反馈开口。检查 joint_state_broadcaster 是否 active。"
|
||||
)
|
||||
return msg
|
||||
|
||||
aperture = self.layout.aperture_from_feedback(state)
|
||||
target_aperture = self.layout.source_to_aperture(self._target)
|
||||
# 判"还在动"用**开口口径**(毫米),不用主关节值:开口↔关节值在 FK
|
||||
# 口径下是非线性的,拿米当口径的话同一个容差在不同开度下含义不同。
|
||||
moving = abs(aperture - target_aperture) > DEFAULT_MOVING_TOLERANCE_MM
|
||||
msg.position = float(aperture)
|
||||
# 真机这里填 DH 的千分比;仿真的"宽度关节"本身就是千分比语义
|
||||
#(0.100 m ≙ 1000‰),所以直接换算,口径与真机一致。
|
||||
msg.finger_position_per_mille = float(
|
||||
max(0.0, min(1000.0, aperture / max(1e-9, self.layout.stroke_mm) * 1000.0))
|
||||
)
|
||||
msg.is_moving = bool(moving)
|
||||
# 判据:指令在合拢侧 + 实际开口比指令大出容差(被工件挡住)。
|
||||
msg.is_gripped = is_grasping(
|
||||
target_aperture, aperture, self._tolerance_mm
|
||||
)
|
||||
if msg.is_gripped:
|
||||
msg.state = "GRIPPING"
|
||||
elif moving:
|
||||
msg.state = "MOVING"
|
||||
else:
|
||||
msg.state = "IDLE"
|
||||
return msg
|
||||
|
||||
def _tick_status(self) -> None:
|
||||
msg = self._status_msg()
|
||||
self._pub_status.publish(msg)
|
||||
self._sync_grasp_lock(bool(msg.is_gripped))
|
||||
|
||||
# --- 抓取锁(仿真专用)-------------------------------------------------
|
||||
def _sync_grasp_lock(self, gripped: bool) -> None:
|
||||
"""夹持状态 → 抓取锁。只在"确认夹住"时上锁,绝不自动解锁。
|
||||
|
||||
**为什么不在这里因为"判到没夹住"而解锁**:
|
||||
本适配器的 ``is_gripped`` 是**推断值**(指令到合拢位 + 实际开口被顶住),
|
||||
抓取锁要解决的恰恰是"规在指间打了滑"的情形 —— 打滑会让触觉接触
|
||||
闪断、``is_gripped`` 跟着抖。若拿它当解锁条件,就会得到
|
||||
"一抖就解锁 → 规真掉下去 → 更读不到夹持"的自毁循环。
|
||||
解锁只认**指令**(open / 开口变大的 move),那才是松手的真实原因。
|
||||
"""
|
||||
spec = self._lock
|
||||
if not spec.enabled or not gripped or self._locked is True:
|
||||
return
|
||||
if publish_lock_event(spec, True, logger=self.get_logger()):
|
||||
self._locked = True
|
||||
self.get_logger().info(
|
||||
f"抓取锁:已把 {spec.object} 锁到手指上(此后机械臂怎么动都不会滑)。"
|
||||
)
|
||||
else:
|
||||
# 失败不抛异常(锁只是补救措施),但要留痕:现象是"规掉了"而看不到真因。
|
||||
self._locked = None
|
||||
|
||||
def _release_grasp_lock(self, why: str) -> None:
|
||||
"""松开抓取锁。只在**指令明确要张开**时调用。"""
|
||||
spec = self._lock
|
||||
if not spec.enabled or self._locked is False:
|
||||
return
|
||||
if publish_lock_event(spec, False, logger=self.get_logger()):
|
||||
self._locked = False
|
||||
self.get_logger().info(f"抓取锁:已解锁({why})")
|
||||
else:
|
||||
self._locked = None
|
||||
|
||||
# --- 服务 -------------------------------------------------------------
|
||||
def _make_handler(self, verb: str):
|
||||
def handler(request: GripperExecute.Request, response: GripperExecute.Response):
|
||||
# 服务名本身就是命令;但请求里也可能带 command(通用入口风格)。
|
||||
# 若两者冲突,以**请求里的**为准并记一条 info —— 免得上层以为
|
||||
# 服务名能覆盖请求内容。
|
||||
req_verb = str(getattr(request.command, "command", "") or "").strip()
|
||||
if req_verb and req_verb != verb:
|
||||
self.get_logger().info(
|
||||
f"{self._ns}/{verb} 收到 command={req_verb!r},按请求执行。"
|
||||
)
|
||||
return self._execute_verb(req_verb, request, response)
|
||||
return self._execute_verb(verb, request, response)
|
||||
|
||||
return handler
|
||||
|
||||
def _on_execute(
|
||||
self, request: GripperExecute.Request, response: GripperExecute.Response
|
||||
):
|
||||
verb = str(getattr(request.command, "command", "") or "").strip()
|
||||
if not verb:
|
||||
return self._fail(response, "execute 需要 request.command.command(如 open/close/move)")
|
||||
return self._execute_verb(verb, request, response)
|
||||
|
||||
def _execute_verb(
|
||||
self,
|
||||
verb: str,
|
||||
request: GripperExecute.Request,
|
||||
response: GripperExecute.Response,
|
||||
):
|
||||
cmd = request.command
|
||||
try:
|
||||
if verb == "open":
|
||||
self._release_grasp_lock("收到 open 指令")
|
||||
self._move_to_aperture(self.layout.stroke_mm)
|
||||
note = f"全开({self.layout.stroke_mm:.1f} mm)"
|
||||
elif verb == "close":
|
||||
self._move_to_aperture(0.0)
|
||||
note = "全闭合(开口 0 mm)"
|
||||
elif verb == "move":
|
||||
want = float(cmd.position)
|
||||
clamped = self.layout.is_clamped(want)
|
||||
# 「开口变大」才叫松手。判据用**实测开口**而不是上一次指令:
|
||||
# 指令可以是"先夹到 30 再挪到 32"这种,比指令会误判成开爪。
|
||||
state = self._fresh_state()
|
||||
now_mm = (
|
||||
self.layout.aperture_from_feedback(state)
|
||||
if state is not None
|
||||
else None
|
||||
)
|
||||
if now_mm is not None and want > now_mm + self._tolerance_mm:
|
||||
self._release_grasp_lock(f"开口 {now_mm:.1f} → {want:.1f} mm")
|
||||
self._move_to_aperture(want)
|
||||
note = f"开口 → {want:.2f} mm"
|
||||
if clamped:
|
||||
note += (
|
||||
f"(**被限位截断**到 "
|
||||
f"{min(max(want, 0.0), self.layout.stroke_mm):.2f} mm)"
|
||||
)
|
||||
elif verb == "rotate":
|
||||
return self._fail(
|
||||
response,
|
||||
"仿真夹爪(GN01)是平行夹爪,没有旋转自由度;rotate 只在真机 RGI 上有效。",
|
||||
)
|
||||
elif verb == "stop":
|
||||
# 停在当前位置:把"当前实际值"当目标发回去。
|
||||
state = self._fresh_state()
|
||||
if state is None:
|
||||
return self._fail(response, "没有关节状态,stop 无法保持位置")
|
||||
# state 是**反馈关节**的值,得先换算回主关节口径再下发。
|
||||
self._publish_target(self.layout.source_from_feedback(state))
|
||||
note = (
|
||||
"保持当前位置("
|
||||
f"{self.layout.aperture_from_feedback(state):.2f} mm)"
|
||||
)
|
||||
else:
|
||||
return self._fail(
|
||||
response,
|
||||
f"仿真夹爪不支持命令 {verb!r};支持 "
|
||||
f"{list(_SERVICE_COMMANDS) + ['stop']}。",
|
||||
)
|
||||
except GripperConfigError as exc: # pragma: no cover - 构造期已挡住
|
||||
return self._fail(response, str(exc))
|
||||
|
||||
response.success = True
|
||||
response.message = note
|
||||
response.status = self._status_msg()
|
||||
self.get_logger().info(f"{self._ns}/{verb}:{note}")
|
||||
return response
|
||||
|
||||
def _fail(self, response: GripperExecute.Response, why: str):
|
||||
response.success = False
|
||||
response.message = why
|
||||
# status 仍然填:调用方拿到 success=false 时也需要知道当前开口,
|
||||
# 否则只能靠猜(真机的返回结构也是这个语义)。
|
||||
response.status = self._status_msg()
|
||||
self.get_logger().warn(f"{self._ns}:{why}")
|
||||
return response
|
||||
|
||||
|
||||
def main(args=None) -> None:
|
||||
rclpy.init(args=args)
|
||||
node = None
|
||||
try:
|
||||
node = SimGripperAdapter()
|
||||
rclpy.spin(node)
|
||||
except GripperConfigError:
|
||||
rclpy.shutdown()
|
||||
raise SystemExit(2)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
if node is not None:
|
||||
node.destroy_node()
|
||||
if rclpy.ok():
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
"""由 URDF 做夹爪正运动学,把"开口毫米"换算成**真的**关节角(纯逻辑,不起 ROS)。
|
||||
|
||||
=============================================================================
|
||||
为什么需要它:上游的 <mimic> 是线性近似
|
||||
=============================================================================
|
||||
GN01 的描述用一条线性耦合把 6 个从动关节挂在宽度关节上:
|
||||
|
||||
从动关节 = 9.404 × 主关节 − 0.155
|
||||
|
||||
系数 9.404 是按**两端**拟合出来的,所以两端准、中段不准 —— 因为真实的
|
||||
手指不是"平移",而是"绕着 knuckle 转 + 指掌再转",指垫的间距
|
||||
``|tip_L(θ) − tip_R(θ)|`` 是 θ 的非线性函数。
|
||||
|
||||
实测(2026-09-24,Gazebo Fortress,见 test_sim_gripper_kin.py 里的黄金表):
|
||||
|
||||
命令开口 指尖间距实测 与命令之差
|
||||
0 mm 27.09 mm (固有间距,指垫此时接触)
|
||||
8 mm 35.76 mm +0.67 mm
|
||||
20 mm 48.84 mm +1.75 mm
|
||||
50 mm 80.96 mm +3.87 mm
|
||||
100 mm 127.02 mm −0.07 mm
|
||||
|
||||
也就是说:**命令"开 20 mm",两指实际相距 21.75 mm**。抓一个 20 mm 宽的
|
||||
通止规时,指垫之间会空出 1.75 mm —— 没有接触、没有接触力,触觉那一步
|
||||
在仿真里直接失效。这和真机(DH,毫米制、开多少就是多少)的口径不一致,
|
||||
仿真就失去了"拿真几何试算法"的意义。
|
||||
|
||||
所以本模块用 URDF 自己算正运动学,把 mm 反解成关节角:
|
||||
|
||||
aperture_to_source(20.0) → 令 |tip_L − tip_R| − 闭合间距 == 20.0 的关节值
|
||||
|
||||
这样"开口 20 mm"在仿真里就是**物理上真的 20 mm**,与真机口径一致。
|
||||
|
||||
=============================================================================
|
||||
为什么可靠
|
||||
=============================================================================
|
||||
本文件的正运动学与 Gazebo(DART 物理 + sdformat 转换后的模型)在
|
||||
0~100 mm 全程 **吻合到 0.01 mm**(上面那张表就是两边对比的结果)。
|
||||
两者完全独立:一个是我们的矩阵乘法,一个是物理引擎。这个一致性同时证明:
|
||||
|
||||
- URDF → SDF 的转换没有丢掉这些 link/joint 的偏移;
|
||||
- 我们对耦合公式的理解与 Gazebo 实际执行的一致。
|
||||
|
||||
=============================================================================
|
||||
通用性(换夹爪时)
|
||||
=============================================================================
|
||||
本模块不写死 GN01 的名字:
|
||||
- 树结构完全从 URDF 里读;
|
||||
- 两个"指尖"link 按命名约定找(``*left*finger*tip*`` / ``*right*...``),
|
||||
找不到就让调用方退回线性近似(并在日志里**明确说出口径变了**);
|
||||
- 计算指尖间距用的参考系取两者的**最近公共祖先**,不需要知道夹爪 base
|
||||
叫什么名字(GN01 是 ``grav_base_link``,别的夹爪叫别的)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Dict, List, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
Mat3 = Tuple[Tuple[float, float, float], ...]
|
||||
Vec3 = Tuple[float, float, float]
|
||||
|
||||
|
||||
class KinematicsError(ValueError):
|
||||
"""URDF 结构与预期不符(缺 link、链断开、不是树等)。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JointNode:
|
||||
"""URDF 里的一条 joint(父 → 子),带固定偏移与转动轴。"""
|
||||
|
||||
name: str
|
||||
type: str
|
||||
parent: str
|
||||
child: str
|
||||
xyz: Vec3
|
||||
rpy: Vec3
|
||||
axis: Vec3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3×3 矩阵小工具(只有旋转,位置单独带着走 —— 比 4×4 好读)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _rot_rpy(rpy: Vec3) -> Mat3:
|
||||
r, p, y = rpy
|
||||
cr, sr = math.cos(r), math.sin(r)
|
||||
cp, sp = math.cos(p), math.sin(p)
|
||||
cy, sy = math.cos(y), math.sin(y)
|
||||
return (
|
||||
(cy * cp, cy * sp * sr - sy * cr, cy * sp * cr + sy * sr),
|
||||
(sy * cp, sy * sp * sr + cy * cr, sy * sp * cr - cy * sr),
|
||||
(-sp, cp * sr, cp * cr),
|
||||
)
|
||||
|
||||
|
||||
def _rot_axis(axis: Vec3, angle: float) -> Mat3:
|
||||
x, y, z = axis
|
||||
norm = math.sqrt(x * x + y * y + z * z)
|
||||
if norm < 1e-12:
|
||||
raise KinematicsError("关节 axis 是零向量,无法确定转动轴")
|
||||
x, y, z = x / norm, y / norm, z / norm
|
||||
c, s, k = math.cos(angle), math.sin(angle), 1.0 - math.cos(angle)
|
||||
return (
|
||||
(x * x * k + c, x * y * k - z * s, x * z * k + y * s),
|
||||
(y * x * k + z * s, y * y * k + c, y * z * k - x * s),
|
||||
(z * x * k - y * s, z * y * k + x * s, z * z * k + c),
|
||||
)
|
||||
|
||||
|
||||
def _mat_mul(a: Mat3, b: Mat3) -> Mat3:
|
||||
return tuple(
|
||||
tuple(sum(a[i][k] * b[k][j] for k in range(3)) for j in range(3))
|
||||
for i in range(3)
|
||||
)
|
||||
|
||||
|
||||
def _mat_vec(a: Mat3, v: Vec3) -> Vec3:
|
||||
return tuple(sum(a[i][k] * v[k] for k in range(3)) for i in range(3)) # type: ignore[return-value]
|
||||
|
||||
|
||||
def _dist(a: Vec3, b: Vec3) -> float:
|
||||
return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b)))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# URDF 树
|
||||
# ---------------------------------------------------------------------------
|
||||
def _floats(text: Optional[str], default: Sequence[float]) -> Vec3:
|
||||
if not text:
|
||||
return tuple(default) # type: ignore[return-value]
|
||||
parts = text.split()
|
||||
if len(parts) != 3:
|
||||
raise KinematicsError(f"URDF 里的三元组长度不对: {text!r}")
|
||||
return (float(parts[0]), float(parts[1]), float(parts[2]))
|
||||
|
||||
|
||||
class UrdfTree:
|
||||
"""URDF 的 joint 树;只关心运动学(不碰 inertial / visual / collision)。"""
|
||||
|
||||
def __init__(self, xml: str) -> None:
|
||||
root = ET.fromstring(xml)
|
||||
self.by_child: Dict[str, JointNode] = {}
|
||||
self.parent_of: Dict[str, str] = {}
|
||||
for j in root.findall("joint"):
|
||||
origin = j.find("origin")
|
||||
axis_el = j.find("axis")
|
||||
parent_el, child_el = j.find("parent"), j.find("child")
|
||||
if parent_el is None or child_el is None or j.get("name") is None:
|
||||
raise KinematicsError("URDF 里有缺 name/parent/child 的 joint")
|
||||
node = JointNode(
|
||||
name=j.get("name", ""),
|
||||
type=j.get("type", "fixed"),
|
||||
parent=parent_el.get("link", ""),
|
||||
child=child_el.get("link", ""),
|
||||
xyz=_floats(origin.get("xyz") if origin is not None else None, (0, 0, 0)),
|
||||
rpy=_floats(origin.get("rpy") if origin is not None else None, (0, 0, 0)),
|
||||
axis=_floats(axis_el.get("xyz") if axis_el is not None else None, (1, 0, 0)),
|
||||
)
|
||||
if node.child in self.by_child:
|
||||
raise KinematicsError(f"link {node.child!r} 有两条父关节,不是树")
|
||||
self.by_child[node.child] = node
|
||||
self.parent_of[node.child] = node.parent
|
||||
|
||||
# --- 结构查询 ---------------------------------------------------------
|
||||
def ancestors(self, link: str) -> List[str]:
|
||||
"""从 link 一路向上到根(含自身)。"""
|
||||
out: List[str] = []
|
||||
seen = set()
|
||||
cur = link
|
||||
while True:
|
||||
out.append(cur)
|
||||
if cur not in self.parent_of:
|
||||
return out
|
||||
if cur in seen:
|
||||
raise KinematicsError(f"URDF 的 link 链里有环({cur})")
|
||||
seen.add(cur)
|
||||
cur = self.parent_of[cur]
|
||||
|
||||
def common_ancestor(self, a: str, b: str) -> str:
|
||||
"""最近公共祖先 link(用 A 的祖先链去查 B 的,第一个命中的就是)。"""
|
||||
b_chain = set(self.ancestors(b))
|
||||
for link in self.ancestors(a):
|
||||
if link in b_chain:
|
||||
return link
|
||||
raise KinematicsError(f"{a!r} 与 {b!r} 没有公共祖先(URDF 断了?)")
|
||||
|
||||
def chain(self, link: str, root: str) -> List[JointNode]:
|
||||
"""root → link 的关节序列。"""
|
||||
out: List[JointNode] = []
|
||||
cur = link
|
||||
while cur != root:
|
||||
if cur not in self.by_child:
|
||||
raise KinematicsError(f"从 {link!r} 上溯不到 {root!r}")
|
||||
node = self.by_child[cur]
|
||||
out.append(node)
|
||||
cur = node.parent
|
||||
return list(reversed(out))
|
||||
|
||||
# --- 正运动学 ---------------------------------------------------------
|
||||
def position(self, link: str, root: str, angles: Mapping[str, float]) -> Vec3:
|
||||
"""link 原点在 root 坐标系里的位置。angles 缺省视为 0。"""
|
||||
rot: Mat3 = ((1.0, 0, 0), (0, 1.0, 0), (0, 0, 1.0))
|
||||
pos: Vec3 = (0.0, 0.0, 0.0)
|
||||
for node in self.chain(link, root):
|
||||
offset = _mat_vec(rot, node.xyz)
|
||||
pos = (pos[0] + offset[0], pos[1] + offset[1], pos[2] + offset[2])
|
||||
rot = _mat_mul(rot, _rot_rpy(node.rpy))
|
||||
if node.type != "fixed":
|
||||
rot = _mat_mul(rot, _rot_axis(node.axis, float(angles.get(node.name, 0.0))))
|
||||
return pos
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 找指尖 link(按命名约定,不写死 GN01)
|
||||
# ---------------------------------------------------------------------------
|
||||
_LEFT_TIP_PATTERNS = (
|
||||
r".*left.*finger.*tip$",
|
||||
r".*left.*tip$",
|
||||
r".*left.*tcp$",
|
||||
)
|
||||
_RIGHT_TIP_PATTERNS = (
|
||||
r".*right.*finger.*tip$",
|
||||
r".*right.*tip$",
|
||||
r".*right.*tcp$",
|
||||
)
|
||||
|
||||
|
||||
def _pick(links: Sequence[str], patterns: Sequence[str]) -> Optional[str]:
|
||||
for pat in patterns:
|
||||
rx = re.compile(pat) # 大小写敏感:URDF 里普遍是小写
|
||||
hits = sorted(link for link in links if rx.match(link))
|
||||
if hits:
|
||||
# 多个命中时取名字最长的(``sim_left_finger_tip`` 比 ``left_tip``
|
||||
# 更可能是真正的指垫),并保持确定性(已排序)。
|
||||
return max(hits, key=len)
|
||||
return None
|
||||
|
||||
|
||||
def find_tip_pair(tree: UrdfTree) -> Optional[Tuple[str, str]]:
|
||||
"""按命名约定找左右指垫 link;找不到返回 None(调用方退回线性近似)。"""
|
||||
links = sorted(set(tree.by_child))
|
||||
left = _pick(links, _LEFT_TIP_PATTERNS)
|
||||
right = _pick(links, _RIGHT_TIP_PATTERNS)
|
||||
if left is None or right is None or left == right:
|
||||
return None
|
||||
return (left, right)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 开口曲线:mm ↔ 关节值
|
||||
# ---------------------------------------------------------------------------
|
||||
class ApertureCurve:
|
||||
"""由正运动学给出的"关节值 → 两指间距(mm)"曲线,以及它的反解。
|
||||
|
||||
``joint_value`` 是**主关节**的值(GN01 就是宽度关节,单位米)。
|
||||
调用方通过 ``angles_of(joint_value)`` 告诉本类"主关节取这个值时,
|
||||
每个关节分别是多少" —— 耦合关系归调用方(sim_gripper.py)管,
|
||||
本类只管几何,这样换夹爪/换耦合方式都不用动几何代码。
|
||||
"""
|
||||
|
||||
#: 反解用的二分次数(40 次足够把 100 mm 行程收敛到 1e-9 mm 以内)。
|
||||
BISECT_ITERS = 60
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tree: UrdfTree,
|
||||
tips: Tuple[str, str],
|
||||
angles_of: Callable[[float], Mapping[str, float]],
|
||||
lower: float,
|
||||
upper: float,
|
||||
) -> None:
|
||||
self.tree = tree
|
||||
self.tips = tips
|
||||
self.angles_of = angles_of
|
||||
self.lower = float(lower)
|
||||
self.upper = float(upper)
|
||||
self.root = tree.common_ancestor(tips[0], tips[1])
|
||||
# 闭合位置(主关节到下限)的指尖间距 —— 指垫此时贴在一起,
|
||||
# 所以"开口"一律相对它算,这样开口 0 就是"真的合拢"。
|
||||
self._closed = self._raw_separation(self.lower)
|
||||
self._stroke = self._raw_separation(self.upper) - self._closed
|
||||
# ⚠️ 这里不能只判 `stroke <= 0`。实测过一次:把右侧 knuckle 的符号写反时,
|
||||
# 两指**同步**摆动,间距始终 45.0000000000 mm —— 差值是 4e-13,
|
||||
# 一个裸的 `<= 0` 会放过它。而放过的后果是**静默失效**:开口曲线是
|
||||
# 水平的,`joint_value()` 的二分会一路收敛到下限,
|
||||
# 于是"开 20 mm"变成"不动",且没有任何报错。
|
||||
# 所以阈值取"名义行程的 1%":真实夹爪不可能只有这么点行程,
|
||||
# 而浮点噪声远小于它。
|
||||
nominal_mm = abs(self.upper - self.lower) * 1000.0
|
||||
min_stroke = max(1e-6, 0.01 * nominal_mm)
|
||||
if self._stroke <= min_stroke:
|
||||
raise KinematicsError(
|
||||
f"正运动学算出两指行程只有 {self._stroke:.6f} mm"
|
||||
f"(名义 {nominal_mm:.1f} mm)—— 耦合方向或 tip link 选错了:"
|
||||
f"关节在动,但两指间距几乎不变"
|
||||
)
|
||||
self._check_monotonic()
|
||||
|
||||
# --- 基础量 -----------------------------------------------------------
|
||||
def _raw_separation(self, joint_value: float) -> float:
|
||||
angles = self.angles_of(joint_value)
|
||||
a = self.tree.position(self.tips[0], self.root, angles)
|
||||
b = self.tree.position(self.tips[1], self.root, angles)
|
||||
return 1000.0 * _dist(a, b)
|
||||
|
||||
def _check_monotonic(self, samples: int = 24) -> None:
|
||||
"""间距必须随主关节单调增 —— 否则反解会给出"看起来对但跳变"的结果。
|
||||
|
||||
真实夹爪(含 GN01)都满足;不满足说明耦合表填错了(比如某条
|
||||
multiplier 正负号写反),这时**必须报错**而不是继续,
|
||||
否则抓取时开口会忽大忽小。
|
||||
"""
|
||||
prev = None
|
||||
for i in range(samples + 1):
|
||||
v = self.lower + (self.upper - self.lower) * i / samples
|
||||
cur = self._raw_separation(v)
|
||||
if prev is not None and cur < prev - 1e-6:
|
||||
raise KinematicsError(
|
||||
f"指尖间距不随主关节单调变化({v:.4f} 处 {cur:.3f} mm < "
|
||||
f"前一点 {prev:.3f} mm)—— 检查耦合表的正负号"
|
||||
)
|
||||
prev = cur
|
||||
|
||||
# --- 对外 -------------------------------------------------------------
|
||||
@property
|
||||
def closed_separation_mm(self) -> float:
|
||||
"""合拢时两侧 tip frame 之间的固有间距(GN01 约 27 mm)。
|
||||
|
||||
注意它不是开口:指垫此时贴在一起。验收脚本要拿它把"间距"换成
|
||||
"开口",见 scripts/check_sim_gripper.py。
|
||||
"""
|
||||
return self._closed
|
||||
|
||||
@property
|
||||
def stroke_mm(self) -> float:
|
||||
"""两指的**实测**行程(毫米)。GN01 ≈ 99.93,而不是名义的 100.0。
|
||||
|
||||
这个差不是误差:上游把宽度关节的限位定在 0~0.100 m,而线性耦合
|
||||
让指尖实际只走了 99.93 mm。启动日志里报的是这个实测值 ——
|
||||
报名义值会让"全开时开口读数与几何对不上"变成一个查不出的矛盾。
|
||||
"""
|
||||
return self._stroke
|
||||
|
||||
def aperture_mm(self, joint_value: float) -> float:
|
||||
"""主关节值 → 开口(毫米)。
|
||||
|
||||
超出限位时按限位截断(位置控制器本来也会截断,这里提前对齐口径)。
|
||||
"""
|
||||
v = min(max(float(joint_value), self.lower), self.upper)
|
||||
return self._raw_separation(v) - self._closed
|
||||
|
||||
def joint_value(self, aperture_mm: float) -> float:
|
||||
"""开口(毫米)→ 主关节值。二分反解,超出行程按端点截断。"""
|
||||
want = min(max(float(aperture_mm), 0.0), self._stroke)
|
||||
lo, hi = self.lower, self.upper
|
||||
for _ in range(self.BISECT_ITERS):
|
||||
mid = 0.5 * (lo + hi)
|
||||
if self.aperture_mm(mid) < want:
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid
|
||||
return 0.5 * (lo + hi)
|
||||
|
||||
def is_clamped(self, aperture_mm: float) -> bool:
|
||||
"""请求的开口是否超出这个夹爪能开的范围(用来提醒上层)。"""
|
||||
return not (0.0 <= float(aperture_mm) <= self._stroke)
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env python3
|
||||
"""仿真里的 ``/home`` —— 把真机 Flexiv 驱动的**同名服务**补齐,契约一致。
|
||||
|
||||
为什么需要它
|
||||
------------
|
||||
真机上 ``/home``(``std_srvs/Trigger``)由 ``arm_driver_flexiv`` 提供,
|
||||
它的实现就是"切工具系(可选)+ 转发给 ``/set_named_pose``"。
|
||||
仿真里没有 Flexiv 驱动,于是通止规装配(S01 回 home、S11 复位)在 Gazebo 里
|
||||
第一步就过不了自检 —— 而 S01 本身在两种环境下**没有区别**。
|
||||
|
||||
所以这里的做法不是"让上层代码判断自己在不在仿真",而是**补一份同样的契约**:
|
||||
``/home`` → ``/set_named_pose``(``arm_msgs/srv/SetNamedPose``)。
|
||||
|
||||
有意保留的差异(写在这里,免得有人以为两边完全一样):
|
||||
|
||||
* 真机的 ``/home`` 会顺带切工具系(``switch_tool_on_home``);
|
||||
仿真里没有工具坐标系的概念,**不切**(S00 也把 ``/switch_tool`` 列为选检)。
|
||||
* 真机 ``/home`` 转发时用的 ``home_pose`` / ``home_speed`` 来自 ``site.yaml``
|
||||
(``go_home.pose`` / ``go_home.speed``);这里用同名参数,默认值与 site 一致,
|
||||
launch 不必再传。
|
||||
|
||||
用法(由 ``gazebo_sim.launch.py`` 拉起)::
|
||||
|
||||
ros2 service call /home std_srvs/srv/Trigger '{}'
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import rclpy
|
||||
from rclpy.callback_groups import ReentrantCallbackGroup
|
||||
from rclpy.executors import MultiThreadedExecutor
|
||||
from rclpy.node import Node
|
||||
from std_srvs.srv import Trigger
|
||||
|
||||
from arm_msgs.srv import SetNamedPose
|
||||
|
||||
|
||||
class SimHomeService(Node):
|
||||
"""``/home`` → ``/set_named_pose``。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("sim_home_service")
|
||||
# 与 site.yaml 的 go_home 段保持一致:仿真与真机用**同一份默认值**,
|
||||
# 免得"仿真里回零是 4 秒、真机是 1 秒"这种口径差异。
|
||||
self.declare_parameter("home_pose", "home")
|
||||
self.declare_parameter("home_speed", 0.25)
|
||||
self.declare_parameter("set_named_pose_service", "/set_named_pose")
|
||||
#: 等 ``/set_named_pose`` 出现的超时 [s]。仿真栈刚起来时它可能还没就绪,
|
||||
#: 直接失败会让人误以为"服务没接线"。
|
||||
self.declare_parameter("wait_service_s", 5.0)
|
||||
#: 等 `</set_named_pose` 回应的超时 [s]。回零是慢动作,别设太小。
|
||||
self.declare_parameter("call_timeout_s", 60.0)
|
||||
|
||||
self._pose = str(self.get_parameter("home_pose").value or "home")
|
||||
self._speed = float(self.get_parameter("home_speed").value or 0.25)
|
||||
self._timeout = float(self.get_parameter("call_timeout_s").value)
|
||||
# Reentrant + 多线程:本节点的回调里要**同步等**另一个服务的应答,
|
||||
# 非重入的互斥组会自锁(真机驱动里踩过,注释同样留着)。
|
||||
self._cb = ReentrantCallbackGroup()
|
||||
self._named = self.create_client(
|
||||
SetNamedPose,
|
||||
str(self.get_parameter("set_named_pose_service").value),
|
||||
callback_group=self._cb,
|
||||
)
|
||||
self.create_service(Trigger, "/home", self._on_home, callback_group=self._cb)
|
||||
self.get_logger().info(
|
||||
f"仿真 /home 就绪 → {self._named.srv_name} 位姿[{self._pose}] "
|
||||
f"速度 {self._speed}(真机由 Flexiv 驱动提供同名服务)"
|
||||
)
|
||||
|
||||
def _on_home(self, request: Trigger.Request, response: Trigger.Response):
|
||||
wait_s = float(self.get_parameter("wait_service_s").value)
|
||||
if not self._named.wait_for_service(timeout_sec=wait_s):
|
||||
response.success = False
|
||||
response.message = (
|
||||
f"{self._named.srv_name} 不在 —— 命名位姿服务没起来。"
|
||||
"仿真是 gazebo_sim.launch.py 里的 named_pose_server,"
|
||||
"真机是 arm_bringup/stack.launch.py"
|
||||
)
|
||||
self.get_logger().error(response.message)
|
||||
return response
|
||||
|
||||
req = SetNamedPose.Request()
|
||||
req.pose_name = self._pose
|
||||
req.velocity_scale = self._speed
|
||||
fut = self._named.call_async(req)
|
||||
deadline = time.monotonic() + self._timeout
|
||||
while not fut.done():
|
||||
if time.monotonic() > deadline:
|
||||
response.success = False
|
||||
response.message = f"回位姿[{self._pose}]跟丢:{self._timeout:.0f}s 内没结果"
|
||||
self.get_logger().error(response.message)
|
||||
return response
|
||||
# 这里是**回调线程**(MultiThreadedExecutor),可以 sleep;
|
||||
# 不能 spin(会与执行器抢同一节点的回调)。
|
||||
time.sleep(0.02)
|
||||
res = fut.result()
|
||||
response.success = bool(res.success)
|
||||
response.message = res.message or f"已回位姿[{self._pose}]"
|
||||
(self.get_logger().info if response.success else self.get_logger().error)(
|
||||
f"/home → {response.message}"
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def main(args=None) -> None:
|
||||
rclpy.init(args=args)
|
||||
node = SimHomeService()
|
||||
executor = MultiThreadedExecutor(num_threads=4)
|
||||
executor.add_node(node)
|
||||
try:
|
||||
executor.spin()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
executor.shutdown()
|
||||
node.destroy_node()
|
||||
if rclpy.ok():
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""仿真里"物体**现在到底在哪**"的唯一来源:``/world/<world>/pose/info``。
|
||||
|
||||
为什么要有这个模块(2026-09-24 的教训,别再让真值按名义位姿算)
|
||||
----------------------------------------------------------------
|
||||
真值路径(``perception.source=truth``)最初是**按 scene.yaml 的名义位姿**算目标点
|
||||
与孔位的 —— 也就是它假设"物体没动过"。实测这个假设站不住:
|
||||
|
||||
1. 物体是"在名义位姿上方 spawn、再落下来"的,**落地就偏 2~3mm**(实测 2.6mm);
|
||||
2. 工件骑在台面那 36mm 通槽上,静置中还会缓慢蠕动(实测十几分钟里又挪了 1.9mm)。
|
||||
|
||||
而 2mm 的位姿误差在 S05 的像素上约 5px,在 S06 对孔上就是**规尖(φ6)进不了
|
||||
φ7 的孔** —— 现象是"S07 压到工件面、但孔没进去",接着 S08 一转就掉件。
|
||||
现场看起来像"柔顺参数不对/夹持力不够",其实几何从一开始就指错了两毫米。
|
||||
|
||||
所以:**真值就该用实测位姿**,名义位姿只用来做"物体是不是被搬走了/掉下去了"的
|
||||
告警。这件事被两个地方需要(真值感知的目标点、二阶段定位的孔位),
|
||||
所以实现只放这一份(R5.8)。
|
||||
|
||||
⚠️ 实测位姿是 Gazebo 的**真值**(不是测量),所以"按实测位姿算"比"按配置算"
|
||||
更接近真机上的视觉:真机也是量到哪就是哪。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import replace
|
||||
from typing import Any, Dict, Iterable, Mapping, Optional, Tuple
|
||||
|
||||
from arm_sim_gazebo import sim_scene
|
||||
|
||||
#: 位姿(名 → ``(x, y, z, yaw)``,单位米/弧度)。缺的物体不在字典里。
|
||||
LivePoses = Dict[str, Tuple[float, float, float, float]]
|
||||
|
||||
|
||||
def yaw_from_quat_xyzw(quat: Iterable[float]) -> float:
|
||||
"""四元数 ``(x, y, z, w)`` → 绕 Z 的 yaw [rad]。
|
||||
|
||||
四元数→矩阵**不在这里实现**:复用全仓库唯一那份 ``handeye_lib.quat_xyzw_to_R``
|
||||
(它有回环测试钉着)。重复实现的下场见 ``sim_truth_perception`` 的模块 docstring。
|
||||
"""
|
||||
from arm_application.handeye_lib import quat_xyzw_to_R
|
||||
|
||||
R = quat_xyzw_to_R(list(quat))
|
||||
return math.atan2(float(R[1, 0]), float(R[0, 0]))
|
||||
|
||||
|
||||
def poses_from_tf(msg: Any) -> LivePoses:
|
||||
"""``/world/<world>/pose/info``(``tf2_msgs/TFMessage``)→ 名 → ``(x,y,z,yaw)``。
|
||||
|
||||
每个模型的 ``child_frame_id`` 就是模型名(ros_gz_bridge 的约定)。
|
||||
|
||||
**纯函数**(只要求对象有 ``transforms``,不依赖 ROS 类型):这条解析错的表现是
|
||||
"实测位姿永远是空的",于是真值退回名义位姿 —— 又是那个两毫米的错,
|
||||
没有任何一处日志会提。所以它必须能脱离节点被钉住。
|
||||
"""
|
||||
out: LivePoses = {}
|
||||
for tr in getattr(msg, "transforms", None) or []:
|
||||
name = str(getattr(tr, "child_frame_id", "") or "").strip().lstrip("/")
|
||||
if not name:
|
||||
continue
|
||||
t = tr.transform.translation
|
||||
q = tr.transform.rotation
|
||||
out[name] = (
|
||||
float(t.x),
|
||||
float(t.y),
|
||||
float(t.z),
|
||||
yaw_from_quat_xyzw((float(q.x), float(q.y), float(q.z), float(q.w))),
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def spec_with_live_poses(
|
||||
spec: sim_scene.SceneSpec,
|
||||
poses: Mapping[str, Tuple[float, float, float, float]],
|
||||
names: Optional[Iterable[str]] = None,
|
||||
) -> sim_scene.SceneSpec:
|
||||
"""把场景**副本**里物体的位姿换成实测值;查不到实测位姿的物体保持原样。
|
||||
|
||||
为什么只换位姿、不换别的:``SceneSpec`` 里的几何(模型形状、孔环位置、
|
||||
台面)都是配置里的事实,只有"物体摆在哪"是会变的。
|
||||
|
||||
``names`` 给定时只换这几个(真值只依赖工件,没必要动别的)。
|
||||
返回**副本**(``dataclasses.replace``):调用方别把"实测位姿"写回配置,
|
||||
否则名义位姿就永远消失了,而它正是判断"有没有被搬走"的基准。
|
||||
"""
|
||||
want = None if names is None else {str(n) for n in names}
|
||||
out = []
|
||||
changed = 0
|
||||
for obj in spec.objects:
|
||||
p = poses.get(obj.name)
|
||||
if p is None or (want is not None and obj.name not in want):
|
||||
out.append(obj)
|
||||
continue
|
||||
out.append(replace(obj, x=float(p[0]), y=float(p[1]), z=float(p[2]), yaw=float(p[3])))
|
||||
changed += 1
|
||||
if changed == 0:
|
||||
return spec
|
||||
return replace(spec, objects=out)
|
||||
|
||||
|
||||
class LiveScenePose:
|
||||
"""订阅世界模型位姿,随时给出"物体现在在哪"。仿真专有。
|
||||
|
||||
用法(两个节点都一样,所以放在这里而不是各写一份)::
|
||||
|
||||
live = LiveScenePose(self, topic) # topic 来自 scene.yaml 的 pose_info
|
||||
spec_live = live.apply(spec, [obj_name]) # 换上位姿的副本
|
||||
if live.ready: ... # 收到过帧没有
|
||||
|
||||
没配话题 / 没收到帧时 ``apply`` 原样返回 —— 调用方仍能用名义位姿干活,
|
||||
但必须**说清楚**(``LiveScenePose.why_not``),否则"退回名义位姿"这件事
|
||||
又会变成没有人知道的静默行为。
|
||||
"""
|
||||
|
||||
def __init__(self, node: Any, topic: str, qos_depth: int = 10) -> None:
|
||||
self._node = node
|
||||
self._topic = str(topic or "").strip()
|
||||
self._poses: LivePoses = {}
|
||||
self._seen = 0
|
||||
self._sub = None
|
||||
if not self._topic:
|
||||
return
|
||||
from tf2_msgs.msg import TFMessage
|
||||
|
||||
self._sub = node.create_subscription(TFMessage, self._topic, self._on_msg, qos_depth)
|
||||
|
||||
def _on_msg(self, msg: Any) -> None:
|
||||
got = poses_from_tf(msg)
|
||||
if got:
|
||||
self._seen += 1
|
||||
self._poses.update(got)
|
||||
|
||||
@property
|
||||
def topic(self) -> str:
|
||||
return self._topic
|
||||
|
||||
@property
|
||||
def ready(self) -> bool:
|
||||
"""收到过至少一帧(一帧都没有说明话题名/桥接有问题,不是"物体没动")。"""
|
||||
return self._seen > 0
|
||||
|
||||
def pose(self, name: str) -> Optional[Tuple[float, float, float, float]]:
|
||||
return self._poses.get(name)
|
||||
|
||||
def apply(
|
||||
self,
|
||||
spec: sim_scene.SceneSpec,
|
||||
names: Optional[Iterable[str]] = None,
|
||||
) -> sim_scene.SceneSpec:
|
||||
return spec_with_live_poses(spec, self._poses, names)
|
||||
|
||||
def why_not(self, name: str) -> str:
|
||||
"""给"为什么还在用名义位姿"一个能直接贴进日志的句子。"""
|
||||
if not self._topic:
|
||||
return "scene.yaml 的 pose_info 未配(没有世界模型位姿话题)"
|
||||
if not self.ready:
|
||||
return f"话题 {self._topic} 一帧都没收到(桥接没起来?话题名/世界名对不上?)"
|
||||
return f"实测位姿里没有物体 {name!r}(模型名与 scene.yaml 的 name 不一致?)"
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""把 Gazebo 里的模型**搬到指定位姿**(`/world/<world>/set_pose`)。
|
||||
|
||||
谁在用
|
||||
------
|
||||
* ``scripts/sim_load_gauge.py``:加载通止规前把场景物体复位;
|
||||
* ``arm_sim_gazebo/sim_truth_perception.py``:启动时把真值依赖的工件复位
|
||||
(见那里的 `_maybe_startup_reset`)。
|
||||
|
||||
**实现只有这一份**:这个请求字符串以前抄过一遍,而它有一个很坑的细节
|
||||
(下面 `--reqtype` 的注释),第二份抄错的人不会知道。
|
||||
|
||||
为什么走命令行 `ign service` 而不是 ROS 服务桥
|
||||
--------------------------------------------
|
||||
仿真的场景加载本来就是用命令行 `ros_gz_sim create` 做的(见 `sim_scene.py`),
|
||||
口径一致、少一个需要维护的服务桥;而且 `ros_gz_bridge` 对**服务**的桥接需要
|
||||
额外配置,桥不上时的表现是"服务一直在、调用永远超时"。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Optional, Sequence, Tuple
|
||||
|
||||
#: Gazebo 侧的服务等待超时 [ms]。
|
||||
SERVICE_TIMEOUT_MS = 3000
|
||||
#: 命令行整体超时 [s](要大于上面那个,否则会被外面先掐掉)。
|
||||
CMD_TIMEOUT_S = 10.0
|
||||
|
||||
|
||||
def world_from_gz_topic(topic: str) -> Optional[str]:
|
||||
"""从 ``/world/<world>/pose/info`` 里取出 world 名;取不到返回 ``None``。
|
||||
|
||||
world 名必须与 ``worlds/assembly.world`` 的文件名一致,而 scene.yaml 里
|
||||
已经写着那条话题了 —— 所以**不要**再单独配一个 world 名参数:
|
||||
两份写法迟早不一致,而症状是"服务调用超时"(看起来像 Gazebo 没起来)。
|
||||
"""
|
||||
m = re.fullmatch(r"/world/([^/]+)/pose(?:/info)?/?", (topic or "").strip())
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def yaw_to_quat(yaw_rad: float) -> Tuple[float, float, float, float]:
|
||||
"""绕 Z 的 yaw [rad] → 四元数 ``(x, y, z, w)``。
|
||||
|
||||
只支持绕 Z:场景里的物体位姿本来就是"平面位姿 + yaw"(``SceneObject``
|
||||
也只有 yaw)—— 这样写反而不会被"顺手支持一下全姿态"拖进来一堆没测过的路径。
|
||||
"""
|
||||
return (0.0, 0.0, math.sin(float(yaw_rad) / 2.0), math.cos(float(yaw_rad) / 2.0))
|
||||
|
||||
|
||||
def _run(cmd: Sequence[str], timeout: float = CMD_TIMEOUT_S) -> Tuple[int, str]:
|
||||
"""跑一条外部命令,返回 ``(退出码, 合并输出)``。**不抛异常**(调用方自己判)。"""
|
||||
try:
|
||||
p = subprocess.run(list(cmd), capture_output=True, text=True, timeout=timeout, check=False)
|
||||
return p.returncode, (p.stdout or "") + (p.stderr or "")
|
||||
except FileNotFoundError:
|
||||
return 127, f"找不到可执行文件:{cmd[0]}"
|
||||
except subprocess.TimeoutExpired:
|
||||
return 124, f"命令超时({timeout}s):{' '.join(cmd)}"
|
||||
|
||||
|
||||
def set_model_pose(
|
||||
world: str,
|
||||
name: str,
|
||||
pos: Sequence[float],
|
||||
quat: Sequence[float],
|
||||
timeout_s: float = CMD_TIMEOUT_S,
|
||||
) -> Tuple[bool, str]:
|
||||
"""把模型 ``name`` 搬到世界位姿 ``pos`` / ``quat``(四元数 xyzw)。
|
||||
|
||||
返回 ``(成功?, 输出)``。**失败只返回 False,不抛异常**:调用方拿到的场景
|
||||
可能只是"少了一个可选物体",因为一个失败的复位让整个流程起不来不值得。
|
||||
|
||||
注意 ``--reqtype`` 必须写 ``ignition.msgs.Pose``:本环境实测写
|
||||
``gz.msgs.Pose`` 会报 "Unable to create request",一个字母的差别、
|
||||
报错信息完全没提原因。
|
||||
"""
|
||||
req = (
|
||||
f'name: "{name}", '
|
||||
f"position: {{x: {float(pos[0]):.6f}, y: {float(pos[1]):.6f}, z: {float(pos[2]):.6f}}}, "
|
||||
"orientation: {"
|
||||
f"x: {float(quat[0]):.8f}, y: {float(quat[1]):.8f}, "
|
||||
f"z: {float(quat[2]):.8f}, w: {float(quat[3]):.8f}"
|
||||
"}"
|
||||
)
|
||||
code, out = _run(
|
||||
[
|
||||
"ign", "service",
|
||||
"-s", f"/world/{world}/set_pose",
|
||||
"--reqtype", "ignition.msgs.Pose",
|
||||
"--reptype", "ignition.msgs.Boolean",
|
||||
"--timeout", str(int(SERVICE_TIMEOUT_MS)),
|
||||
"--req", req,
|
||||
],
|
||||
timeout=timeout_s,
|
||||
)
|
||||
ok = code == 0 and "data: true" in out
|
||||
return ok, out.strip()
|
||||
|
||||
|
||||
def set_model_pose_yaw(
|
||||
world: str,
|
||||
name: str,
|
||||
pos: Sequence[float],
|
||||
yaw_rad: float,
|
||||
timeout_s: float = CMD_TIMEOUT_S,
|
||||
) -> Tuple[bool, str]:
|
||||
"""``set_model_pose`` 的平面版:只有 yaw。"""
|
||||
return set_model_pose(world, name, pos, yaw_to_quat(yaw_rad), timeout_s)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,220 @@
|
||||
"""读 `config/site.yaml` 的 `sim.*` 段。
|
||||
|
||||
=============================================================================
|
||||
为什么需要这个模块
|
||||
=============================================================================
|
||||
`config/site.yaml` 的 `sim` 段(world / physics_enable / joint_prefix)与
|
||||
`devices/sim/gazebo/manifest.yaml` 的 `config_schema` 一直宣称"改这里就生效",
|
||||
但**没有任何代码读它**:world 路径硬编码在 robot_description._WORLD_REL 里。
|
||||
|
||||
于是产生最难受的一类 bug:用户按文档改了 `site.yaml`,启动后行为毫无变化,
|
||||
也不报错。既然文档与 manifest 都把它当契约,就必须真的读它。
|
||||
|
||||
要读,但也不能假装所有键都接线了 —— 有些键(physics_enable / joint_prefix)
|
||||
目前确实还没接。所以本模块除了读值,还提供 unwired_warnings():
|
||||
把"声明了、但当前实现并不消费"的差异显式报出来,
|
||||
把静默无效变成一条看得见的告警。等哪天真的接线了,告警自然消失。
|
||||
=============================================================================
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
SITE_REL = "config/site.yaml"
|
||||
SITE_EXAMPLE_REL = "config/site.example.yaml"
|
||||
|
||||
# 与 manifest.yaml 的 config_schema 默认值保持一致。
|
||||
# 三处(site.yaml 注释 / manifest / 这里)都写了默认值,改动时必须同步 ——
|
||||
# 所以下面有用例盯着这里和 manifest 的一致性。
|
||||
DEFAULT_WORLD = ""
|
||||
DEFAULT_PHYSICS_ENABLE = True
|
||||
DEFAULT_JOINT_PREFIX = "sim_"
|
||||
|
||||
|
||||
class SimSiteError(RuntimeError):
|
||||
"""site.yaml 读不了或内容不合法。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimSite:
|
||||
"""`sim.*` 段的解析结果。"""
|
||||
|
||||
world: str = DEFAULT_WORLD
|
||||
physics_enable: bool = DEFAULT_PHYSICS_ENABLE
|
||||
joint_prefix: str = DEFAULT_JOINT_PREFIX
|
||||
# 这些键在 site.yaml 里**显式**出现过吗?
|
||||
# 用来区分"用户说了要关物理"和"用户没提,所以用默认值"——
|
||||
# 前者值得告警,后者没啥可说。
|
||||
present: set = field(default_factory=set)
|
||||
source: Optional[Path] = None
|
||||
|
||||
|
||||
def load_sim_site(root: Optional[Path] = None) -> SimSite:
|
||||
"""读 `config/site.yaml` 的 sim 段。文件不存在则返回全默认值。
|
||||
|
||||
故意**不**在文件缺失时报错:仿真要能在没有站点的机器上跑起来
|
||||
(site.yaml 里含真实设备 ID,本来就不该提交,见 README 的"要提交什么")。
|
||||
"""
|
||||
base = Path(root) if root is not None else repo_root()
|
||||
path = base / SITE_REL
|
||||
|
||||
if not path.is_file():
|
||||
return SimSite()
|
||||
|
||||
try:
|
||||
doc = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
raise SimSiteError(
|
||||
f"{path} 解析失败: {exc}\n"
|
||||
f" 这一行会让仿真启动不了。修好它,或临时改名为 site.yaml.bak 忽略。"
|
||||
) from exc
|
||||
|
||||
if not isinstance(doc, dict):
|
||||
raise SimSiteError(f"{path} 的顶层不是映射(键值对)。")
|
||||
|
||||
raw = doc.get("sim")
|
||||
if raw is None:
|
||||
return SimSite(source=path)
|
||||
if not isinstance(raw, dict):
|
||||
raise SimSiteError(
|
||||
f"{path} 的 sim 段不是映射(键值对),而是 {type(raw).__name__}。"
|
||||
f"它应该像这样:\n sim:\n world: devices/sim/gazebo/worlds/assembly.world"
|
||||
)
|
||||
|
||||
site = SimSite(source=path)
|
||||
if "world" in raw:
|
||||
site.world = str(raw.get("world") or "").strip()
|
||||
site.present.add("world")
|
||||
if "physics_enable" in raw:
|
||||
site.physics_enable = bool(raw.get("physics_enable"))
|
||||
site.present.add("physics_enable")
|
||||
if "joint_prefix" in raw:
|
||||
site.joint_prefix = str(raw.get("joint_prefix") or "").strip()
|
||||
site.present.add("joint_prefix")
|
||||
return site
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
"""仓库根。**实现只有一份**:``robot_description.repo_root()``。
|
||||
|
||||
这里曾经也是 ``parents[3]`` —— 源码树里对、安装后错(install_sim 是拷贝)。
|
||||
两处各写一份还会各自漂移,所以都改成委托(见 sim_scene.repo_root 的注释)。
|
||||
"""
|
||||
from arm_sim_gazebo.robot_description import repo_root as _robust_root
|
||||
|
||||
return _robust_root()
|
||||
|
||||
|
||||
def resolve_world(
|
||||
cli_world: str = "",
|
||||
root: Optional[Path] = None,
|
||||
fallback: Optional[Path] = None,
|
||||
) -> tuple[Path, str]:
|
||||
"""决定用哪个 world 文件。返回 (路径, 来源说明)。
|
||||
|
||||
优先级:命令行 --world > site.yaml 的 sim.world > 内置默认。
|
||||
|
||||
为什么要有优先级而不是"只认 site.yaml":site.yaml 是站点级的,而调试时
|
||||
经常要临时换一个世界(比如 in.sdf 里复制一份改着试)。命令行必须能压过它。
|
||||
"""
|
||||
base = Path(root) if root is not None else repo_root()
|
||||
|
||||
if cli_world and cli_world.strip():
|
||||
p = Path(cli_world.strip())
|
||||
# 相对路径按仓库根解析:用户从任意目录启动,写相对路径的直觉都是"相对仓库"。
|
||||
if not p.is_absolute():
|
||||
p = base / p
|
||||
return p, "命令行 --world"
|
||||
|
||||
site = load_sim_site(base)
|
||||
if site.world:
|
||||
p = Path(site.world)
|
||||
if not p.is_absolute():
|
||||
p = base / p
|
||||
return p, f"{SITE_REL} 的 sim.world"
|
||||
|
||||
if fallback is None:
|
||||
raise SimSiteError(
|
||||
"没能确定 world 文件:site.yaml 里 sim.world 为空,也没给内置默认。"
|
||||
)
|
||||
fb = Path(fallback)
|
||||
# 内置默认也要跟前面两条一样解析成绝对路径:调用方(启动器 / launch)
|
||||
# 拿到的必须是绝对路径,否则从别的目录启动时会被解析到错误的位置,
|
||||
# 而那会表现成"找不到 world 文件",看起来像配置写错了。
|
||||
if not fb.is_absolute():
|
||||
fb = base / fb
|
||||
return fb, "内置默认(site.yaml 未指定 sim.world)"
|
||||
|
||||
|
||||
def unwired_warnings(
|
||||
site: SimSite,
|
||||
actual_joint_prefix: Optional[str] = None,
|
||||
physics_in_world: Optional[bool] = None,
|
||||
) -> List[str]:
|
||||
"""把"site.yaml 里声明了、但当前实现并不消费"的配置报出来。
|
||||
|
||||
这是本模块存在的一半理由:与其让 `physics_enable: false` 静默无效,
|
||||
不如明说"这个键还没接线"。下面两个键目前确实没接:
|
||||
|
||||
physics_enable 世界文件里的重力/步长是写死的,没有按它开关。
|
||||
joint_prefix 仿真关节前缀由 arm.sim.urdf.xacro 的 arm_prefix 决定,
|
||||
且 controllers.yaml 里硬编码了 sim_joint1..7;
|
||||
改 site.yaml 不会传导过去。
|
||||
|
||||
等它们真的接线了,从这里删掉对应分支,告警就自然消失。
|
||||
"""
|
||||
out: List[str] = []
|
||||
|
||||
if "physics_enable" in site.present and physics_in_world is not None:
|
||||
want_on = bool(site.physics_enable)
|
||||
if want_on != bool(physics_in_world):
|
||||
out.append(
|
||||
f"site.yaml 写了 sim.physics_enable={site.physics_enable},"
|
||||
f"但世界文件当前是物理{'开启' if physics_in_world else '关闭'}状态,"
|
||||
f"而这个键**还没有接线** —— 世界文件里的重力/步长是写死的。\n"
|
||||
f" 想让物理开关生效,得改 world 文件里的 <physics>,"
|
||||
f"或者先把这个键接进启动流程(见 sim_site.py 顶部说明)。"
|
||||
)
|
||||
|
||||
if "joint_prefix" in site.present and actual_joint_prefix is not None:
|
||||
want = site.joint_prefix
|
||||
if want and want != actual_joint_prefix:
|
||||
out.append(
|
||||
f"site.yaml 写了 sim.joint_prefix={want!r},"
|
||||
f"但实际用的前缀是 {actual_joint_prefix!r},"
|
||||
f"而这个键**还没有接线**。\n"
|
||||
f" 前缀来自 arm.sim.urdf.xacro 的 arm_prefix,"
|
||||
f"且 config/controllers.yaml 里硬编码了 {actual_joint_prefix}joint1..7 —— "
|
||||
f"三处必须一致,改一处不够。"
|
||||
)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def physics_enabled_in_world(world_file: Path) -> Optional[bool]:
|
||||
"""世界文件里的物理到底开没开。判据是重力是不是零向量。
|
||||
|
||||
gz 里"关物理"的常见做法就是把 <gravity> 置零(比删掉整个 <physics>
|
||||
更常用,因为 <physics> 还带着步长等其它必需项)。没有 <physics> /
|
||||
<gravity> 时按"开着"算 —— gz 的默认就是开。
|
||||
"""
|
||||
try:
|
||||
text = Path(world_file).read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return None
|
||||
m = re.search(r"<gravity>\s*([^<]+)\s*</gravity>", text)
|
||||
if not m:
|
||||
return True
|
||||
try:
|
||||
vals = [float(x) for x in m.group(1).split()]
|
||||
except ValueError:
|
||||
return None
|
||||
if len(vals) != 3:
|
||||
return None
|
||||
return any(abs(v) > 1e-12 for v in vals)
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
"""仿真触觉的配置解析、轴向投影与基线跟踪。
|
||||
|
||||
=============================================================================
|
||||
为什么单独一个模块
|
||||
=============================================================================
|
||||
把 Gazebo 的关节力传感器变成 arm_msgs/TactileStatus,真正有内容的是三件事:
|
||||
|
||||
1. 轴向投影 关节坐标系的三向力 → (fnormal, fshearx, fsheary)
|
||||
2. 基线相减 静止时关节要承担手指自重,不减掉就是一个恒定偏置
|
||||
3. 接触判据 接触传感器是**事件式**发布,必须靠超时判"已离开"
|
||||
|
||||
这三件事都是纯数据变换,不需要 ROS 跑起来就能验。所以实现放在这里、用普通
|
||||
函数/类表达,节点(sim_tactile_adapter.py)只做订阅/发布这层壳。
|
||||
好处是它们能被单测钉死 —— 轴向映射搞反、基线算错这类错误,
|
||||
在"力有数值变化"的直观观察下完全看不出异常,只能靠测试兜住。
|
||||
|
||||
轴映射的实测依据与口径见 devices/sim/gazebo/config/tactile.yaml 的注释。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
import yaml
|
||||
|
||||
# 默认配置位置(相对仓库根)。与 cameras.yaml 同目录,属"物料而非代码"。
|
||||
DEFAULT_TACTILE_REL = "devices/sim/gazebo/config/tactile.yaml"
|
||||
|
||||
# 轴向只认这三根轴。写成别的一律报错 —— 静默地把 'Y' 当成未知轴处理,
|
||||
# 会让力的语义整体错位而没有任何提示。
|
||||
_VALID_AXES = ("x", "y", "z")
|
||||
|
||||
|
||||
class TactileConfigError(RuntimeError):
|
||||
"""触觉配置有问题时抛出,消息面向使用者。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AxisSpec:
|
||||
"""把关节力传感器的三根轴映射成 Orisys 的三路力。
|
||||
|
||||
``normal`` 是**指尖法向**(压下去的方向);另两根是切向。
|
||||
符号单独给:实测确认两指压同一个物体读数同号,但保留符号项是为了
|
||||
换夹爪/换传感器时不必改代码。
|
||||
"""
|
||||
|
||||
normal: str = "y"
|
||||
shear_x: str = "z"
|
||||
shear_y: str = "x"
|
||||
sign_normal: float = 1.0
|
||||
sign_shear_x: float = 1.0
|
||||
sign_shear_y: float = 1.0
|
||||
|
||||
def project(self, force: Sequence[float], scale: float = 1.0) -> Tuple[float, float, float]:
|
||||
"""三向力(关节坐标系,牛顿)→ (fnormal, fshearx, fsheary)。
|
||||
|
||||
``scale`` 是 config 里的 force_scale,见该文件头的「单位口径」。
|
||||
"""
|
||||
idx = {a: i for i, a in enumerate(_VALID_AXES)}
|
||||
fx, fy, fz = (float(v) for v in force)
|
||||
raw = {"x": fx, "y": fy, "z": fz}
|
||||
return (
|
||||
self.sign_normal * raw[self.normal] * scale,
|
||||
self.sign_shear_x * raw[self.shear_x] * scale,
|
||||
self.sign_shear_y * raw[self.shear_y] * scale,
|
||||
)
|
||||
|
||||
def shear_indices(self) -> Tuple[int, int]:
|
||||
"""两个切向轴在 (x,y,z) 里的下标,用于把接触点转成 centroid_u/v。"""
|
||||
idx = {a: i for i, a in enumerate(_VALID_AXES)}
|
||||
return idx[self.shear_x], idx[self.shear_y]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TactileSide:
|
||||
"""一"指"的声明。"""
|
||||
|
||||
side: str
|
||||
wrench_topic: str
|
||||
contact_topic: str
|
||||
frame: str
|
||||
|
||||
# --- ROS 侧话题名:与真机 tactile_node 完全一致,应用层不用改 -----------
|
||||
@property
|
||||
def status_topic(self) -> str:
|
||||
return f"/tactile/{self.side}/status"
|
||||
|
||||
@property
|
||||
def image_topic(self) -> str:
|
||||
return f"/tactile/{self.side}/image"
|
||||
|
||||
@property
|
||||
def vector_topic(self) -> str:
|
||||
return f"/tactile/{self.side}/vector"
|
||||
|
||||
# --- Gazebo 侧:桥接的输入 ---------------------------------------------
|
||||
@property
|
||||
def gz_wrench_msg(self) -> str:
|
||||
return "gz.msgs.Wrench"
|
||||
|
||||
@property
|
||||
def gz_contact_msg(self) -> str:
|
||||
return "gz.msgs.Contacts"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TactileSpec:
|
||||
"""整个仿真触觉的声明。"""
|
||||
|
||||
sides: Tuple[TactileSide, ...]
|
||||
axis: AxisSpec
|
||||
publish_hz: float
|
||||
baseline_seconds: float
|
||||
rebaseline_when_idle: bool
|
||||
rebaseline_tau_s: float
|
||||
contact_timeout_s: float
|
||||
min_contact_points: int
|
||||
force_scale: float
|
||||
image_hz: float
|
||||
image_width: int
|
||||
image_height: int
|
||||
unit: str = "N"
|
||||
|
||||
def by_side(self, side: str) -> Optional[TactileSide]:
|
||||
for s in self.sides:
|
||||
if s.side == side:
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
def load_tactile(path: Optional[Path] = None, root: Optional[Path] = None) -> TactileSpec:
|
||||
"""读触觉配置。找不到或写错时抛 TactileConfigError,消息里给出修法。"""
|
||||
if path is None:
|
||||
base = root if root is not None else Path.cwd()
|
||||
path = Path(base) / DEFAULT_TACTILE_REL
|
||||
path = Path(path)
|
||||
|
||||
if not path.is_file():
|
||||
raise TactileConfigError(
|
||||
f"触觉配置不存在: {path}\n"
|
||||
f" 它是仿真相机之外另一份「物料配置」:话题名、轴向映射、force_scale、\n"
|
||||
f" 基线口径全在这里(桥接与适配器都从它派生)。缺了它仿真里的触觉\n"
|
||||
f" 完全没有输出。确认仓库根下存在 {DEFAULT_TACTILE_REL}。"
|
||||
)
|
||||
|
||||
try:
|
||||
doc = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
raise TactileConfigError(f"触觉配置 YAML 解析失败: {path}\n 原因: {exc}") from exc
|
||||
|
||||
raw_sides = doc.get("sides")
|
||||
if not isinstance(raw_sides, list) or not raw_sides:
|
||||
raise TactileConfigError(
|
||||
f"{path} 里没有 sides 列表(或为空)。双指方案要写 left 与 right 两项 ——\n"
|
||||
f" 单指无法复现「旋拧时切向力从哪一侧来」这个判据。"
|
||||
)
|
||||
|
||||
sides: List[TactileSide] = []
|
||||
seen: Dict[str, str] = {}
|
||||
for i, item in enumerate(raw_sides):
|
||||
where = f"{path} 的 sides[{i}]"
|
||||
if not isinstance(item, dict):
|
||||
raise TactileConfigError(f"{where} 不是映射(key: value 形式)。")
|
||||
missing = [
|
||||
k for k in ("side", "wrench_topic", "contact_topic", "frame")
|
||||
if not str(item.get(k, "")).strip()
|
||||
]
|
||||
if missing:
|
||||
raise TactileConfigError(
|
||||
f"{where} 缺字段: {', '.join(missing)}。\n"
|
||||
f" wrench_topic/contact_topic 必须与 arm.sim.sensors.xacro 里写的\n"
|
||||
f" <topic> 完全一致 —— 对不上时适配器只是订阅了一个不存在的话题,\n"
|
||||
f" 不报错、status 永远是零。"
|
||||
)
|
||||
side = str(item["side"]).strip()
|
||||
if side in seen:
|
||||
raise TactileConfigError(f"{where} 的 side 重复: {side}")
|
||||
seen[side] = where
|
||||
sides.append(
|
||||
TactileSide(
|
||||
side=side,
|
||||
wrench_topic=str(item["wrench_topic"]).strip(),
|
||||
contact_topic=str(item["contact_topic"]).strip(),
|
||||
frame=str(item["frame"]).strip(),
|
||||
)
|
||||
)
|
||||
|
||||
raw_axis = doc.get("axis") or {}
|
||||
raw_sign = doc.get("sign") or {}
|
||||
if not isinstance(raw_axis, dict) or not isinstance(raw_sign, dict):
|
||||
raise TactileConfigError(f"{path} 的 axis / sign 必须是映射。")
|
||||
|
||||
axis = AxisSpec(
|
||||
normal=_axis(raw_axis.get("normal", "y"), path, "axis.normal"),
|
||||
shear_x=_axis(raw_axis.get("shear_x", "z"), path, "axis.shear_x"),
|
||||
shear_y=_axis(raw_axis.get("shear_y", "x"), path, "axis.shear_y"),
|
||||
sign_normal=_num(raw_sign.get("normal", 1.0), path, "sign.normal"),
|
||||
sign_shear_x=_num(raw_sign.get("shear_x", 1.0), path, "sign.shear_x"),
|
||||
sign_shear_y=_num(raw_sign.get("shear_y", 1.0), path, "sign.shear_y"),
|
||||
)
|
||||
if len({axis.normal, axis.shear_x, axis.shear_y}) != 3:
|
||||
raise TactileConfigError(
|
||||
f"{path} 的 axis 里三根轴必须互不相同(当前 normal={axis.normal}, "
|
||||
f"shear_x={axis.shear_x}, shear_y={axis.shear_y})。\n"
|
||||
f" 重复意味着有两个分量取自同一根轴,另有一根轴被丢掉 —— "
|
||||
f"力的语义会静默错位。"
|
||||
)
|
||||
|
||||
spec = TactileSpec(
|
||||
sides=tuple(sides),
|
||||
axis=axis,
|
||||
publish_hz=_num(doc.get("publish_hz", 20.0), path, "publish_hz", positive=True),
|
||||
baseline_seconds=_num(doc.get("baseline_seconds", 2.0), path, "baseline_seconds"),
|
||||
rebaseline_when_idle=bool(doc.get("rebaseline_when_idle", True)),
|
||||
rebaseline_tau_s=_num(doc.get("rebaseline_tau_s", 3.0), path, "rebaseline_tau_s"),
|
||||
contact_timeout_s=_num(
|
||||
doc.get("contact_timeout_s", 0.25), path, "contact_timeout_s", positive=True
|
||||
),
|
||||
min_contact_points=int(doc.get("min_contact_points", 1)),
|
||||
force_scale=_num(doc.get("force_scale", 1.0), path, "force_scale"),
|
||||
image_hz=_num(doc.get("image_hz", 0.0), path, "image_hz"),
|
||||
image_width=int(doc.get("image_width", 64)),
|
||||
image_height=int(doc.get("image_height", 64)),
|
||||
unit=str(doc.get("unit", "N")),
|
||||
)
|
||||
return spec
|
||||
|
||||
|
||||
def _axis(value: object, path: Path, key: str) -> str:
|
||||
axis = str(value).strip().lower()
|
||||
if axis not in _VALID_AXES:
|
||||
raise TactileConfigError(
|
||||
f"{path} 的 {key} 必须是 x/y/z 之一,当前是 {value!r}。"
|
||||
)
|
||||
return axis
|
||||
|
||||
|
||||
def _num(value: object, path: Path, key: str, positive: bool = False) -> float:
|
||||
try:
|
||||
out = float(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise TactileConfigError(f"{path} 的 {key} 不是数字: {value!r}") from exc
|
||||
if positive and out <= 0.0:
|
||||
raise TactileConfigError(f"{path} 的 {key} 必须为正,当前是 {out}")
|
||||
return out
|
||||
|
||||
|
||||
def bridge_args(spec: TactileSpec) -> List[str]:
|
||||
"""生成 ros_gz_bridge parameter_bridge 的话题规则。
|
||||
|
||||
只做**传输**:gz 消息类型 → ROS 消息类型,话题名保持 gz 侧原样。
|
||||
改名与语义(轴向、基线、单位)全在适配器里做 —— 桥接器改不了 frame_id,
|
||||
也做不了基线相减,越权只会把"在哪一层改"这件事弄乱。
|
||||
"""
|
||||
args: List[str] = []
|
||||
for s in spec.sides:
|
||||
args.append(
|
||||
f"{s.wrench_topic}@geometry_msgs/msg/Wrench[{s.gz_wrench_msg}"
|
||||
)
|
||||
args.append(
|
||||
f"{s.contact_topic}@ros_gz_interfaces/msg/Contacts[{s.gz_contact_msg}"
|
||||
)
|
||||
return args
|
||||
|
||||
|
||||
class BaselineTracker:
|
||||
"""按"相对静止基线取增量"的口径跟踪零力读数。
|
||||
|
||||
两段式:
|
||||
预热期(前 baseline_seconds 秒):等权均值。用均值而不是 EMA 是因为
|
||||
一开始没有任何先验,EMA 从 0 爬升需要很久。
|
||||
稳态期:仅在**确认无接触**时用时间常数 tau 做慢跟踪。
|
||||
为什么需要慢跟踪而不是"一次性基线":force_torque 读的是关节传递力,
|
||||
静止时它等于远端那段手指的自重,而**这个自重项随机械臂姿态变化** ——
|
||||
一次性基线会在换姿态后留下残余。只在无接触时更新,是为了不把真实
|
||||
接触当成新基线吃掉(那会让接触力永远回到零,症状是"夹住了但力为 0")。
|
||||
"""
|
||||
|
||||
def __init__(self, baseline_seconds: float, tau_s: float, enabled: bool = True) -> None:
|
||||
self._warmup = max(0.0, float(baseline_seconds))
|
||||
self._tau = max(0.0, float(tau_s))
|
||||
self._enabled = bool(enabled)
|
||||
self._sum: Dict[str, float] = {}
|
||||
self._count: Dict[str, int] = {}
|
||||
self._value: Dict[str, float] = {}
|
||||
self._elapsed = 0.0
|
||||
|
||||
@property
|
||||
def warmed_up(self) -> bool:
|
||||
return self._elapsed >= self._warmup
|
||||
|
||||
def add(self, key: str, raw: float, dt: float, in_contact: bool) -> None:
|
||||
"""喂一个原始读数。``dt`` 是距上次的秒数。"""
|
||||
if in_contact:
|
||||
# 接触期间既不预热也不跟踪:接触力绝不能进基线。
|
||||
return
|
||||
if not self.warmed_up:
|
||||
self._sum[key] = self._sum.get(key, 0.0) + float(raw)
|
||||
self._count[key] = self._count.get(key, 0) + 1
|
||||
self._value[key] = self._sum[key] / max(1, self._count[key])
|
||||
return
|
||||
if not self._enabled or self._tau <= 0.0 or key not in self._value:
|
||||
return
|
||||
alpha = min(1.0, max(0.0, dt) / self._tau)
|
||||
self._value[key] += (float(raw) - self._value[key]) * alpha
|
||||
|
||||
def advance(self, dt: float) -> None:
|
||||
"""推进预热计时。必须在每轮循环调用一次(即使这一轮没有读数)。"""
|
||||
self._elapsed += max(0.0, float(dt))
|
||||
|
||||
def value(self, key: str) -> float:
|
||||
return self._value.get(key, 0.0)
|
||||
|
||||
def delta(self, key: str, raw: float) -> float:
|
||||
"""原始读数 → 相对基线的增量。"""
|
||||
return float(raw) - self.value(key)
|
||||
+389
@@ -0,0 +1,389 @@
|
||||
"""仿真触觉适配器:Gazebo 的关节力/接触传感器 → arm_msgs/TactileStatus。
|
||||
|
||||
=============================================================================
|
||||
它在链路上的位置
|
||||
=============================================================================
|
||||
gz /tactile/<side>/wrench (gz.msgs.Wrench, 持续 ~100Hz)
|
||||
gz /tactile/<side>/raw (gz.msgs.Contacts, 只在接触时)
|
||||
│
|
||||
├─ ros_gz_bridge parameter_bridge(只搬类型,不改语义)
|
||||
│
|
||||
└─ 本节点
|
||||
├ 轴向投影 三向力 → fnormal/fshearx/fsheary(映射实测,见 config)
|
||||
├ 基线相减 静止时关节要承担手指自重,不减就是一个恒定偏置
|
||||
├ 接触判据 超时判"已离开"(接触传感器是事件式发布)
|
||||
└ 变送 /tactile/<side>/status (arm_msgs/TactileStatus)
|
||||
|
||||
话题名、消息类型、字段口径全部与真机 tactile_node 对齐,所以下游
|
||||
(compliant_descent、stage3_search_node)**一行都不用改**就能吃仿真的数据,
|
||||
这正是"同一套接口、换后端"的契约。
|
||||
|
||||
=============================================================================
|
||||
三个必须知道的坑(都会静默)
|
||||
=============================================================================
|
||||
1) 接触传感器的消息是**事件式**的:不接触时一条都不发。
|
||||
若适配器只在收到消息时才发布 status,那么"离开工件"期间下游会认为
|
||||
话题断流(stage3_search_node 的 _tac_fresh 会直接 SearchAbort)。
|
||||
所以本节点**按 publish_hz 恒定发布**,接触状态由"最近是否收到过"决定。
|
||||
真机 Orisys 也是恒定发布的,这里必须与真机一致。
|
||||
|
||||
2) 力的话题来自 force_torque 传感器,而它依赖 world 里的
|
||||
forcetorque-system 插件被显式声明(只要 world 声明了任何 <plugin>,
|
||||
gz 就不再加载默认系统列表)。插件漏了的话**话题根本不出现**、
|
||||
日志一片空白。所以本节点在启动几秒后若一个 wrench 都没收到,
|
||||
会明确报出来并指出该查哪里。
|
||||
|
||||
3) 单位不是牛顿等价值就可能是错的:Gazebo 给的是牛顿,真机给的是形变
|
||||
任意单位。换算靠 config 里的 force_scale,见 tactile.yaml 文件头的
|
||||
「单位口径」。选错不报错,只表现为阈值永远不触发或一碰就炸。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
from rclpy.time import Time
|
||||
|
||||
from arm_msgs.msg import TactileStatus
|
||||
from geometry_msgs.msg import Wrench
|
||||
from sensor_msgs.msg import Image
|
||||
from tf2_ros import Buffer, TransformListener
|
||||
|
||||
# ros_gz_interfaces 只在仿真里用得到,而且是 conda 装的(不在本工作区)。
|
||||
# 万一它的 typesupport 加载不了(见下),我们只丢掉"接触点"这一路信息 ——
|
||||
# 力本身走 geometry_msgs/Wrench,不受影响。所以这里**容错导入**:
|
||||
# 宁可用退化模式跑,也不要整个适配器起不来。
|
||||
_contacts_err: Optional[str] = None
|
||||
try:
|
||||
from ros_gz_interfaces.msg import Contacts
|
||||
except Exception as _exc: # pragma: no cover - 取决于运行环境
|
||||
Contacts = None # type: ignore[assignment]
|
||||
_contacts_err = f"{type(_exc).__name__}: {_exc}"
|
||||
|
||||
from .sim_tactile import (
|
||||
DEFAULT_TACTILE_REL,
|
||||
BaselineTracker,
|
||||
TactileConfigError,
|
||||
TactileSide,
|
||||
TactileSpec,
|
||||
load_tactile,
|
||||
)
|
||||
|
||||
# 启动后多久检查"到底有没有收到力数据"。要给桥接器与 gz 一点时间。
|
||||
TOPIC_CHECK_DELAY_S = 8.0
|
||||
|
||||
|
||||
def _depth_placeholder(width: int, height: int) -> np.ndarray:
|
||||
"""占位深度图:32FC1、全 NaN。
|
||||
|
||||
为什么是 NaN 而不是全 0:全 0 在深度语义里是"距离 0 米",会被下游当成
|
||||
一个贴脸的平面;NaN 是"无读数",不会伪装成有效数据。
|
||||
"""
|
||||
return np.full((height, width), np.nan, dtype=np.float32)
|
||||
|
||||
|
||||
def _vector_placeholder(width: int, height: int) -> np.ndarray:
|
||||
"""占位力矢量图:32FC2(每像素 dx, dy),全 NaN。"""
|
||||
return np.full((height, width, 2), np.nan, dtype=np.float32)
|
||||
|
||||
|
||||
class SimTactileAdapter(Node):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("sim_tactile_adapter")
|
||||
|
||||
self.declare_parameter("tactile_file", "")
|
||||
# ⚠️ 不要 declare_parameter("use_sim_time", ...):launch 会把它作为
|
||||
# 参数文件传进来,rclpy 那时**已经自动声明过**,再声明一次会直接抛
|
||||
# ParameterAlreadyDeclaredException 让节点起不来(实测踩过)。
|
||||
# 相机适配器同样只声明自己的参数。
|
||||
|
||||
raw_path = str(self.get_parameter("tactile_file").value or "")
|
||||
path = Path(raw_path) if raw_path else Path.cwd() / DEFAULT_TACTILE_REL
|
||||
try:
|
||||
self.spec: TactileSpec = load_tactile(path)
|
||||
except TactileConfigError as exc:
|
||||
# 配置错误必须让节点直接失败:静默启动一个"永远零力"的适配器
|
||||
# 比不启动更糟 —— 它看起来在工作。
|
||||
self.get_logger().error(f"触觉配置有问题:\n{exc}")
|
||||
raise
|
||||
|
||||
self.get_logger().info(
|
||||
f"触觉:单位={self.spec.unit} force_scale={self.spec.force_scale} "
|
||||
f"发布率={self.spec.publish_hz}Hz 基线={self.spec.baseline_seconds}s"
|
||||
f" 轴向 normal={self.spec.axis.normal} "
|
||||
f"shear_x={self.spec.axis.shear_x} shear_y={self.spec.axis.shear_y}"
|
||||
)
|
||||
|
||||
self._tf_buffer = Buffer()
|
||||
self._tf_listener = TransformListener(self._tf_buffer, self)
|
||||
|
||||
# 每指的状态。键都是 side。
|
||||
self._wrench: Dict[str, Tuple[float, float, float]] = {}
|
||||
self._wrench_stamp: Dict[str, float] = {}
|
||||
self._contact_point: Dict[str, Optional[Tuple[float, float, float]]] = {}
|
||||
self._contact_stamp: Dict[str, float] = {}
|
||||
self._got_wrench: Dict[str, bool] = {}
|
||||
self._got_contact: Dict[str, bool] = {}
|
||||
|
||||
self._baseline = BaselineTracker(
|
||||
self.spec.baseline_seconds,
|
||||
self.spec.rebaseline_tau_s,
|
||||
enabled=self.spec.rebaseline_when_idle,
|
||||
)
|
||||
self._last_tick = time.monotonic()
|
||||
|
||||
self._pub_status = {}
|
||||
self._pub_image: Dict[str, Tuple[object, object, float, float]] = {}
|
||||
self._contact_unavailable = False
|
||||
for s in self.spec.sides:
|
||||
self._got_wrench[s.side] = False
|
||||
self._got_contact[s.side] = False
|
||||
self._pub_status[s.side] = self.create_publisher(
|
||||
TactileStatus, s.status_topic, 10
|
||||
)
|
||||
self.create_subscription(
|
||||
Wrench,
|
||||
s.wrench_topic,
|
||||
self._make_wrench_cb(s.side),
|
||||
10,
|
||||
)
|
||||
if Contacts is not None:
|
||||
self.create_subscription(
|
||||
Contacts,
|
||||
s.contact_topic,
|
||||
self._make_contact_cb(s.side),
|
||||
10,
|
||||
)
|
||||
else:
|
||||
self._contact_unavailable = True
|
||||
if self.spec.image_hz > 0.0:
|
||||
self._pub_image[s.side] = (
|
||||
self.create_publisher(Image, s.image_topic, 3),
|
||||
self.create_publisher(Image, s.vector_topic, 3),
|
||||
self.spec.image_hz,
|
||||
0.0,
|
||||
)
|
||||
self.get_logger().warn(
|
||||
f"仿真里没有弹性体成像:{s.image_topic} 与 {s.vector_topic} "
|
||||
f"发布的是**占位图**(32FC1 / 32FC2 全 NaN),话题与消息结构"
|
||||
f"与真机一致,但内容没有物理含义。需要真实图像时请关掉 "
|
||||
f"image_hz(设为 0)。"
|
||||
)
|
||||
|
||||
period = 1.0 / max(1e-3, self.spec.publish_hz)
|
||||
self.create_timer(period, self._tick)
|
||||
self._check_at = time.monotonic() + TOPIC_CHECK_DELAY_S
|
||||
self._checked = False
|
||||
|
||||
if self._contact_unavailable:
|
||||
detail = f"(导入失败:{_contacts_err})" if _contacts_err else ""
|
||||
self.get_logger().warn(
|
||||
f"ros_gz_interfaces/msg/Contacts 用不了{detail},"
|
||||
f"本节点进入退化模式:\n"
|
||||
f" 仍会发 TactileStatus(fnormal/fshearx/fsheary、connected、"
|
||||
f"接触去基线都正常),\n"
|
||||
f" 但 contact 恒为 False、centroid_u/v 恒为 0"
|
||||
f"(这两项依赖接触点消息)。\n"
|
||||
f" 三力判据(本项目 S07 用的那个)不受影响。\n"
|
||||
f" 要修:在**干净 shell** 里 source scripts/sim_env.sh(它会清掉真机"
|
||||
f"环境残留的 PYTHONPATH/LD_LIBRARY_PATH 并补上 $CONDA_PREFIX/lib)。"
|
||||
)
|
||||
|
||||
# --- 订阅回调 ---------------------------------------------------------
|
||||
def _make_wrench_cb(self, side: str):
|
||||
def cb(msg: Wrench) -> None:
|
||||
self._got_wrench[side] = True
|
||||
self._wrench[side] = (
|
||||
float(msg.force.x),
|
||||
float(msg.force.y),
|
||||
float(msg.force.z),
|
||||
)
|
||||
self._wrench_stamp[side] = time.monotonic()
|
||||
|
||||
return cb
|
||||
|
||||
def _make_contact_cb(self, side: str):
|
||||
def cb(msg: Contacts) -> None:
|
||||
pts = []
|
||||
for c in msg.contacts:
|
||||
for p in c.positions:
|
||||
pts.append((float(p.x), float(p.y), float(p.z)))
|
||||
if len(pts) < max(1, self.spec.min_contact_points):
|
||||
return
|
||||
n = float(len(pts))
|
||||
self._got_contact[side] = True
|
||||
self._contact_point[side] = (
|
||||
sum(p[0] for p in pts) / n,
|
||||
sum(p[1] for p in pts) / n,
|
||||
sum(p[2] for p in pts) / n,
|
||||
)
|
||||
self._contact_stamp[side] = time.monotonic()
|
||||
|
||||
return cb
|
||||
|
||||
# --- 主循环 -----------------------------------------------------------
|
||||
def _tick(self) -> None:
|
||||
now = time.monotonic()
|
||||
dt = now - self._last_tick
|
||||
self._last_tick = now
|
||||
self._baseline.advance(dt)
|
||||
|
||||
for s in self.spec.sides:
|
||||
raw = self._wrench.get(s.side)
|
||||
in_contact = self._in_contact(s, now)
|
||||
|
||||
if raw is None:
|
||||
# 还没收到过力数据。仍然发布(下游靠"有没有帧"判活),
|
||||
# 但发的是零 + connected=False,让下游能区分"没数据"与"零力"。
|
||||
self._publish(s.side, connected=False, contact=False,
|
||||
forces=(0.0, 0.0, 0.0), stamp=now)
|
||||
continue
|
||||
|
||||
for i, axis in enumerate(("x", "y", "z")):
|
||||
self._baseline.add(f"{s.side}_{axis}", raw[i], dt, in_contact)
|
||||
|
||||
delta = tuple(
|
||||
self._baseline.delta(f"{s.side}_{axis}", raw[i]) for i, axis in enumerate(("x", "y", "z"))
|
||||
)
|
||||
fn, fsx, fsy = self.spec.axis.project(delta, self.spec.force_scale)
|
||||
# 真机 fnormal 也是非负的(法向只能压不能拉),这里保持一致。
|
||||
fn = max(0.0, fn)
|
||||
self._publish(s.side, connected=True, contact=in_contact,
|
||||
forces=(fn, fsx, fsy), stamp=now, side_spec=s)
|
||||
|
||||
self._maybe_check_topics(now)
|
||||
|
||||
def _in_contact(self, s: TactileSide, now: float) -> bool:
|
||||
"""接触 = 最近 contact_timeout_s 内收到过接触消息。
|
||||
|
||||
⚠️ 不能写成"收到消息就一直算接触":接触传感器是事件式的,
|
||||
离开工件后不会发"取消接触"的消息,那样 contact 会永远停在 True。
|
||||
"""
|
||||
stamp = self._contact_stamp.get(s.side)
|
||||
return stamp is not None and (now - stamp) <= self.spec.contact_timeout_s
|
||||
|
||||
def _centroid(self, s: TactileSide) -> Tuple[float, float]:
|
||||
"""接触点在指尖坐标系里沿两个切向轴的偏移(毫米)→ (u, v)。
|
||||
|
||||
真机的 centroid_u/v 是形变图的像素坐标;仿真没有成像,用"接触点在指腹
|
||||
平面上的位置"表达同一件事 —— 同样是"接触发生在指腹的哪里"。
|
||||
下游目前不消费这两个字段,所以量纲不同不会造成影响;
|
||||
真要跨仿真/真机共用,这里是最需要先统一的一处。
|
||||
"""
|
||||
pt = self._contact_point.get(s.side)
|
||||
if pt is None:
|
||||
return (0.0, 0.0)
|
||||
try:
|
||||
tr = self._tf_buffer.lookup_transform(s.frame, "world", Time())
|
||||
except Exception:
|
||||
return (0.0, 0.0)
|
||||
t = tr.transform.translation
|
||||
q = tr.transform.rotation
|
||||
# world → frame:位置平移 + 用共轭四元数旋转
|
||||
px, py, pz = pt[0] - t.x, pt[1] - t.y, pt[2] - t.z
|
||||
# 逆旋转:q* v q
|
||||
qx, qy, qz, qw = -q.x, -q.y, -q.z, q.w
|
||||
ix = qw * px + qy * pz - qz * py
|
||||
iy = qw * py + qz * px - qx * pz
|
||||
iz = qw * pz + qx * py - qy * px
|
||||
iw = -qx * px - qy * py - qz * pz
|
||||
vx = ix * qw + iw * -qx + iy * -qz - iz * -qy
|
||||
vy = iy * qw + iw * -qy + iz * -qx - ix * -qz
|
||||
vz = iz * qw + iw * -qz + ix * -qy - iy * -qx
|
||||
i_sx, i_sy = self.spec.axis.shear_indices()
|
||||
comp = (vx, vy, vz)
|
||||
return (comp[i_sx] * 1000.0, comp[i_sy] * 1000.0)
|
||||
|
||||
def _publish(self, side: str, connected: bool, contact: bool,
|
||||
forces: Tuple[float, float, float], stamp: float,
|
||||
side_spec: Optional[TactileSide] = None) -> None:
|
||||
msg = TactileStatus()
|
||||
msg.header.stamp = self.get_clock().now().to_msg()
|
||||
msg.header.frame_id = f"{side}_tactile"
|
||||
msg.side = side
|
||||
msg.connected = connected
|
||||
msg.contact = contact
|
||||
msg.fnormal = float(forces[0])
|
||||
msg.fshearx = float(forces[1])
|
||||
msg.fsheary = float(forces[2])
|
||||
# 仿真没有光学帧率。填 0 而不是编一个 30 —— 假数据比缺数据更难查。
|
||||
msg.fps = 0.0
|
||||
if contact and side_spec is not None:
|
||||
msg.centroid_u, msg.centroid_v = (float(v) for v in self._centroid(side_spec))
|
||||
else:
|
||||
msg.centroid_u = 0.0
|
||||
msg.centroid_v = 0.0
|
||||
msg.error_msg = ""
|
||||
self._pub_status[side].publish(msg)
|
||||
|
||||
pub = self._pub_image.get(side)
|
||||
if pub is not None:
|
||||
p_img, p_vec, hz, last = pub
|
||||
now = time.monotonic()
|
||||
if now - last >= 1.0 / max(1e-3, hz):
|
||||
self._pub_image[side] = (p_img, p_vec, hz, now)
|
||||
self._publish_placeholders(side, p_img, p_vec)
|
||||
|
||||
def _publish_placeholders(self, side: str, p_img, p_vec) -> None:
|
||||
stamp = self.get_clock().now().to_msg()
|
||||
w, h = self.spec.image_width, self.spec.image_height
|
||||
|
||||
depth = _depth_placeholder(w, h)
|
||||
img = Image()
|
||||
img.header.stamp = stamp
|
||||
img.header.frame_id = f"{side}_tactile"
|
||||
img.height, img.width = h, w
|
||||
img.encoding = "32FC1"
|
||||
img.is_bigendian = False
|
||||
img.step = w * 4
|
||||
img.data = depth.tobytes()
|
||||
p_img.publish(img)
|
||||
|
||||
vec = _vector_placeholder(w, h)
|
||||
vimg = Image()
|
||||
vimg.header.stamp = stamp
|
||||
vimg.header.frame_id = f"{side}_tactile"
|
||||
vimg.height, vimg.width = h, w
|
||||
vimg.encoding = "32FC2"
|
||||
vimg.is_bigendian = False
|
||||
vimg.step = w * 8
|
||||
vimg.data = vec.tobytes()
|
||||
p_vec.publish(vimg)
|
||||
|
||||
def _maybe_check_topics(self, now: float) -> None:
|
||||
"""启动几秒后确认力话题真的有数据。
|
||||
|
||||
这一步存在的唯一理由:漏声明 forcetorque-system 插件时,gz 不报错、
|
||||
话题不出现、本节点也照常发零力 —— 整条链路"看起来在工作"。
|
||||
主动检查并指出可能原因是唯一能打破这种静默的地方。
|
||||
"""
|
||||
if self._checked or now < self._check_at:
|
||||
return
|
||||
self._checked = True
|
||||
missing = [s.side for s in self.spec.sides if not self._got_wrench[s.side]]
|
||||
if not missing:
|
||||
return
|
||||
self.get_logger().error(
|
||||
f"启动 {TOPIC_CHECK_DELAY_S:.0f}s 后仍未收到力数据({'/'.join(missing)})。\n"
|
||||
f" 最可能的原因:world 文件里没有声明 forcetorque-system 插件。\n"
|
||||
f" 只要 world 声明了任何 <plugin>,gz 就不再加载默认系统列表,于是\n"
|
||||
f" force_torque 传感器**静默失效**:话题不出现、日志空白。\n"
|
||||
f" 检查 devices/sim/gazebo/worlds/assembly.world 里是否有\n"
|
||||
f" libignition-gazebo6-forcetorque-system.so 那一项。"
|
||||
)
|
||||
|
||||
|
||||
def main(args=None) -> None:
|
||||
rclpy.init(args=args)
|
||||
node = SimTactileAdapter()
|
||||
try:
|
||||
rclpy.spin(node)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
node.destroy_node()
|
||||
rclpy.try_shutdown()
|
||||
+706
@@ -0,0 +1,706 @@
|
||||
#!/usr/bin/env python3
|
||||
"""仿真"真值感知":把 Gazebo 里**已知的真实位姿**当成视觉测量发给伺服。
|
||||
|
||||
为什么要这个节点
|
||||
----------------
|
||||
真机的视觉链是 ``perception_node``(YOLO/ONNX)→ 话题
|
||||
``visual_servo/filtered_point``。仿真环境(ros_gz_sim conda)里没有
|
||||
torch/ultralytics,而且这台机器**离线**装不上,于是 perception_node 一启动就死。
|
||||
|
||||
死法还很误导人:S00 只看"权重文件在不在"(在),S04 一阶段则一直等
|
||||
``/visual_servo/locked``,等满 150s 报"没锁定(视野里没工件?)"——
|
||||
现场看到的是"算法不收敛/标定不准",真因是"仿真里根本没有推理后端"。
|
||||
|
||||
仿真里其实**不需要推理**:工件摆在哪、长什么样都是已知的
|
||||
(``scene.yaml`` 的绝对位姿 + 模型自己的几何)。伺服要的那个"相机系下的
|
||||
目标点"可以直接算出来:
|
||||
|
||||
世界系目标点 = 物体世界 XY 轮廓中心 + 高度(模型顶面) + 可选局部偏移
|
||||
相机系目标点 = T_cam_world · 世界系目标点 (T 由 TF 查,不自己算)
|
||||
|
||||
发布 → ``PointStamped(visual_servo/filtered_point)``
|
||||
与 perception_node **同话题、同类型**,所以下游(servo_node、
|
||||
整条装配流程)一个字都不用改。
|
||||
|
||||
⚠️ 它验证的是**控制那一半**(坐标变换 / 手眼 / 伺服收敛 / 流程编排),
|
||||
不是感知那一半。真值箱体没有像素噪声、没有遮挡、没有光照变化,也没有
|
||||
"算法把孔认成别的"这类错。所以:
|
||||
|
||||
能跑通 = "控制侧与接口是对的"
|
||||
不能跑通 ≠ "真机上也能跑通" ← 规律写在
|
||||
devices/sim/gazebo/README.md 的「仿真里哪些模块用真值替代」一节
|
||||
|
||||
绝不做的事
|
||||
----------
|
||||
* 目标在相机背后 / 相机查不到 TF 时**不发假点**(和 perception_node 的
|
||||
"没有可用深度就不发点"同一条规矩)。宁可让伺服看门狗超时,也不给它一个
|
||||
幻影目标 —— 后者会让机械臂朝错误方向走,而且看起来像"标定坏了"。
|
||||
* **物体被撞走了就不再发点**。真值感知的整个前提是"物体就在 scene.yaml 写的位置",
|
||||
前提破了,算出来的就是**假测量**。所以本节点自己订阅 ``/world/<world>/pose/info``
|
||||
(scene.yaml 的 ``pose_info`` 段)盯着部件的实际位姿,偏离名义位姿超过容差就停下
|
||||
并**指名报错**。
|
||||
为什么必须由这一侧盯(2026-09-24 的教训):``perception.source=truth`` 是纯几何的,
|
||||
它从不问 Gazebo 物体现在在哪。实测工件掉进台面通槽、落到地面之后,"真值"开始撒谎,
|
||||
而现象全都指向别处(二阶段报"两孔间距 169mm 不像 k1/k2"、S07 一路下压不接触),
|
||||
看起来像检测/标定/阈值坏了 —— 真正的原因只有一个,却没有任何一处日志说出来。
|
||||
**谁假设的,谁负责查。**
|
||||
* **开局那一次例外**(scene.yaml 的 ``pose_info.reset_on_start``):物体是"在名义位姿
|
||||
上方 spawn、再落下来"的,落稳后实测偏 2.6mm(越过 2mm 容差)—— 那不是"被撞走",
|
||||
但看门狗分不出来,于是每轮开局都可能被这条假故障拦死。所以在**开局窗口内**
|
||||
(``STARTUP_RESET_WINDOW_S``)把它 set_pose 回名义位姿,只做一次;
|
||||
过了窗口再漂就是运行中被撞,必须报错停下、**绝不**搬(见
|
||||
``startup_reset_decision`` 与 ``_maybe_startup_reset``)。
|
||||
* 不自己算四元数→旋转矩阵(复用 ``handeye_lib.quat_xyzw_to_R``)。
|
||||
这段换算以前在 4 个 launch 文件里各抄了一遍、错了 3 处,症状是
|
||||
"总能到附近、永远差一点"。数学实现只能有一份。
|
||||
* 不自己算四元数→旋转矩阵(复用 ``handeye_lib.quat_xyzw_to_R``)。
|
||||
这段换算以前在 4 个 launch 文件里各抄了一遍、错了 3 处,症状是
|
||||
"总能到附近、永远差一点"。数学实现只能有一份。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Sequence, Tuple
|
||||
|
||||
import numpy as np
|
||||
import rclpy
|
||||
from geometry_msgs.msg import PointStamped
|
||||
from rclpy.node import Node
|
||||
from rclpy.qos import HistoryPolicy, QoSProfile, ReliabilityPolicy
|
||||
from rclpy.time import Time
|
||||
from tf2_ros import Buffer, TransformListener
|
||||
from tf2_msgs.msg import TFMessage
|
||||
|
||||
from arm_sim_gazebo import sim_live_pose, sim_pose_service, sim_scene
|
||||
|
||||
#: 与 perception_node / servo_node 一致:点话题用 BEST_EFFORT + KEEP_LAST(5)。
|
||||
#: 匹配不上 QoS 的后果是"话题在、消息发了、订阅方一条也收不到",
|
||||
#: 而两边单独看都正常 —— 这类问题排查成本极高,所以直接抄同一份配置。
|
||||
POINT_QOS = QoSProfile(
|
||||
history=HistoryPolicy.KEEP_LAST,
|
||||
depth=5,
|
||||
reliability=ReliabilityPolicy.BEST_EFFORT,
|
||||
)
|
||||
|
||||
#: 目标点离相机光心太近(或跑到背后)就不发:z<=0 在相机系里是"背后"。
|
||||
MIN_CAM_Z_M = 0.01
|
||||
|
||||
#: 启动复位的**时间窗口** [s]:只在这段时间内把工件搬回名义位姿。
|
||||
#: 过了这段时间还漂 = 运行中被撞走/被压走,那时**绝不能**悄悄搬回去
|
||||
#: (机器人可能正握着它,搬回去等于把现场证据抹掉并让真值开始撒谎),
|
||||
#: 必须照旧报错停下。整个理由见 ``_maybe_startup_reset``。
|
||||
STARTUP_RESET_WINDOW_S = 20.0
|
||||
|
||||
|
||||
class TruthError(RuntimeError):
|
||||
"""真值目标解不出来(场景 / 模型 / 参数有问题)。
|
||||
|
||||
单独一个异常类型是为了让"配置错了"和"TF 还没到"分开处理:
|
||||
前者要直接起不来(早报),后者只该刷限流告警(等机器人动起来就有了)。
|
||||
"""
|
||||
|
||||
|
||||
#: ``startup_reset_decision`` 的决定:不动 / 再等等 / 现在搬 / 放弃。
|
||||
RESET_SKIP = "skip"
|
||||
RESET_WAIT = "wait"
|
||||
RESET_NOW = "reset"
|
||||
RESET_GIVE_UP = "give_up"
|
||||
|
||||
|
||||
def startup_reset_decision(
|
||||
now_s: float,
|
||||
deadline_s: Optional[float],
|
||||
window_s: float,
|
||||
pose_known: bool,
|
||||
any_drift: bool,
|
||||
) -> Tuple[str, Optional[float]]:
|
||||
"""开局"要不要把物体搬回名义位姿"的判据 → ``(决定, 新的截止时刻)``。
|
||||
|
||||
**纯函数**:写错的表现两头都很糟 ——
|
||||
* 判成"运行中也搬":把"谁把它撞歪了"的现场抹掉,甚至把工件从爪里拽走,
|
||||
现象会变成"孔位对不上/下压不接触",比原故障更难查;
|
||||
* 判成"永远等":开局那 2.6mm 落地偏差每轮都把真值点拦死,
|
||||
S04 报「一个视觉目标点都没收到」,看起来像真值节点根本没起来。
|
||||
|
||||
所以它必须能脱离节点被钉住(``test_sim_truth_perception.py``)。
|
||||
"""
|
||||
dl = float(deadline_s) if deadline_s is not None else float(now_s) + float(window_s)
|
||||
if now_s > dl:
|
||||
return RESET_GIVE_UP, dl
|
||||
if not pose_known:
|
||||
# 还没收到 pose/info 帧:此时"有没有漂移"根本无从判断,
|
||||
# 等下去(窗口到点会自己变成 give_up)。
|
||||
return RESET_WAIT, dl
|
||||
if not any_drift:
|
||||
return RESET_SKIP, dl
|
||||
return RESET_NOW, dl
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TruthTarget:
|
||||
"""真值目标:一个**世界系**点,以及它是怎么算出来的(给人看)。"""
|
||||
|
||||
object_name: str
|
||||
world_xyz: Tuple[float, float, float]
|
||||
note: str
|
||||
|
||||
|
||||
def target_point_world(
|
||||
spec: sim_scene.SceneSpec,
|
||||
obj: sim_scene.SceneObject,
|
||||
offset_m: Sequence[float] = (0.0, 0.0, 0.0),
|
||||
root: Optional[Path] = None,
|
||||
) -> TruthTarget:
|
||||
"""物体顶面中心(世界系)+ 物体局部系偏移。
|
||||
|
||||
为什么取"顶面中心":真机 perception_node 默认 ``feature_mode: plane``,
|
||||
发的是检测框在**工件上表面**上的几何中心。真值要想替得住它,就得发同一个
|
||||
物理点;差一个板厚(12mm)会让对比结论完全反过来。
|
||||
|
||||
为什么用 ``sim_scene.footprint`` 而不是自己按 yaw 算:
|
||||
轮廓换算里已经含了 yaw(``world_boxes`` 里有且只有一份实现),
|
||||
自己再写一遍迟早不一致,而"哪个才是对的"很难判断。
|
||||
|
||||
``offset_m`` 是**物体局部系**里的偏移(会跟着 yaw 转),用途例如:
|
||||
把目标从"板中心"挪到 k1 孔 ``[0, -0.029, 0]``,用来单独验证二阶段对孔。
|
||||
"""
|
||||
fp = sim_scene.footprint(spec, obj, root)
|
||||
height = sim_scene.model_height(spec, obj, root)
|
||||
if fp is None or height is None:
|
||||
raise TruthError(
|
||||
f"读不出 {obj.name!r} 的几何(模型 {obj.model})。\n"
|
||||
f" 期望:{sim_scene.model_sdf_path(spec, obj, root)} 里有 <collision>\n"
|
||||
f" 修法:确认 models_dir 指对了、模型目录里有 model.sdf 且至少一个碰撞体。"
|
||||
)
|
||||
vals = [float(v) for v in offset_m]
|
||||
if len(vals) != 3:
|
||||
# 长度不对必须报错,不能"缺的当 0":把 [0.03] 当成 (0.03,0,0) 还是
|
||||
# (0,0.03,0) 全凭猜,而猜错的表现是"偏了一点",很难归因。
|
||||
raise TruthError(
|
||||
f"offset_m 要给 3 个数(物体局部系 xyz,单位米),现在给的是 {list(offset_m)!r}。"
|
||||
)
|
||||
ox, oy, oz = vals
|
||||
cx = 0.5 * (fp[0] + fp[1])
|
||||
cy = 0.5 * (fp[2] + fp[3])
|
||||
c, s = math.cos(float(obj.yaw)), math.sin(float(obj.yaw))
|
||||
x = cx + c * ox - s * oy
|
||||
y = cy + s * ox + c * oy
|
||||
z = float(obj.z) + float(height) + oz
|
||||
note = (
|
||||
f"{obj.name}(模型 {obj.model})"
|
||||
f"轮廓中心 ({cx:.3f},{cy:.3f}) + 高 {height * 1000:.1f}mm"
|
||||
f" + 局部偏移 ({ox * 1000:+.0f},{oy * 1000:+.0f},{oz * 1000:+.0f})mm"
|
||||
f" yaw={math.degrees(float(obj.yaw)):.1f}°"
|
||||
)
|
||||
return TruthTarget(object_name=obj.name, world_xyz=(x, y, z), note=note)
|
||||
|
||||
|
||||
def pick_object(spec: sim_scene.SceneSpec, name: str) -> sim_scene.SceneObject:
|
||||
"""按名字找物体;找不到时把**现有名字**列出来(改配置时不用再翻文件)。"""
|
||||
want = (name or "").strip() or spec.operating.part_object
|
||||
for obj in spec.objects:
|
||||
if obj.name == want:
|
||||
return obj
|
||||
raise TruthError(
|
||||
f"场景 {spec.source} 里没有名为 {want!r} 的物体。\n"
|
||||
f" 现有物体:{', '.join(o.name for o in spec.objects) or '(空)'}\n"
|
||||
f" 修法:改 site.yaml 的 perception.truth.object,"
|
||||
f"或确认 scene.yaml 的 objects 里确实有它。"
|
||||
)
|
||||
|
||||
|
||||
def load_truth_target(
|
||||
scene_file: Optional[Path],
|
||||
object_name: str,
|
||||
offset_m: Sequence[float] = (0.0, 0.0, 0.0),
|
||||
root: Optional[Path] = None,
|
||||
) -> Tuple[sim_scene.SceneSpec, TruthTarget]:
|
||||
"""读场景 + 解出真值目标。返回 ``(场景, 目标)``,场景用于启动日志。
|
||||
|
||||
``object_name`` 空 = 用场景里的 ``operating.part_object``:**"哪个是工件"
|
||||
是场景的知识,不是流程的知识**,所以默认跟着场景走,换场景不用改流程参数。
|
||||
"""
|
||||
spec = sim_scene.load_scene(scene_file, root)
|
||||
return spec, target_point_world(spec, pick_object(spec, object_name), offset_m, root)
|
||||
|
||||
|
||||
def apply_transform(T: np.ndarray, p: Sequence[float]) -> Tuple[float, float, float]:
|
||||
"""4×4 齐次变换作用于点。"""
|
||||
q = np.asarray(T, dtype=float) @ np.array([p[0], p[1], p[2], 1.0], dtype=float)
|
||||
return (float(q[0]), float(q[1]), float(q[2]))
|
||||
|
||||
|
||||
def matrix_from_transform(tr) -> np.ndarray:
|
||||
"""``TransformStamped`` → 4×4 齐次矩阵。
|
||||
|
||||
四元数→旋转矩阵**不在这里实现**:复用 ``handeye_lib.quat_xyzw_to_R``
|
||||
(那是全仓库唯一一份,且有回环测试钉着)。重复实现的下场见模块 docstring。
|
||||
"""
|
||||
from arm_application.handeye_lib import quat_xyzw_to_R
|
||||
|
||||
t = tr.transform.translation
|
||||
q = tr.transform.rotation
|
||||
T = np.eye(4, dtype=float)
|
||||
T[:3, :3] = quat_xyzw_to_R([q.x, q.y, q.z, q.w])
|
||||
T[0, 3] = float(t.x)
|
||||
T[1, 3] = float(t.y)
|
||||
T[2, 3] = float(t.z)
|
||||
return T
|
||||
|
||||
|
||||
def pose_deviation(
|
||||
obj: sim_scene.SceneObject,
|
||||
p: Sequence[float],
|
||||
quat: Sequence[float],
|
||||
) -> Tuple[float, float, float]:
|
||||
"""实际位姿相对名义位姿的偏差 → ``(平移 [m], 姿态夹角 [deg], 实测 yaw [rad])``。
|
||||
|
||||
**纯函数**:这条判据搞错了的表现是"监视器静默失声" ——
|
||||
要么永远不报(物体明明被撞走了却没人说),要么天天误报(现场学会忽略它)。
|
||||
两种情况都比没有监视更糟,所以它必须能脱离节点被钉住。
|
||||
"""
|
||||
from arm_application.handeye_lib import quat_xyzw_to_R
|
||||
|
||||
d = math.dist((float(obj.x), float(obj.y), float(obj.z)), [float(v) for v in p[:3]])
|
||||
R_now = quat_xyzw_to_R([float(v) for v in quat])
|
||||
yaw_now = math.atan2(float(R_now[1, 0]), float(R_now[0, 0]))
|
||||
# 姿态用**整段旋转差**的夹角,不只比 yaw:板被碰一下往往是"翘起来夹着转了一点",
|
||||
# 只比 yaw 会漏掉翘起,而翘起恰恰最影响孔的成像位置。
|
||||
c, s = math.cos(float(obj.yaw)), math.sin(float(obj.yaw))
|
||||
R_ref = np.array([[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]])
|
||||
cosang = (float(np.trace(R_ref.T @ R_now)) - 1.0) / 2.0
|
||||
ang = math.degrees(math.acos(max(-1.0, min(1.0, cosang))))
|
||||
return (d, ang, yaw_now)
|
||||
|
||||
|
||||
def pose_drift(
|
||||
obj: sim_scene.SceneObject,
|
||||
p: Sequence[float],
|
||||
quat: Sequence[float],
|
||||
tol_m: float,
|
||||
tol_deg: float,
|
||||
) -> Optional[str]:
|
||||
"""偏差超过容差时的**说明文字**;在容差内返回 ``None``(= 没漂移)。
|
||||
|
||||
给日志/告警用的人话版;数值判据在 :func:`pose_deviation`(同一个实现,
|
||||
免得两处各算一遍距离)。
|
||||
"""
|
||||
d, ang, yaw_now = pose_deviation(obj, p, quat)
|
||||
if d <= tol_m and ang <= tol_deg:
|
||||
return None
|
||||
return (
|
||||
f"名义 (x,y,z)=({obj.x:.4f},{obj.y:.4f},{obj.z:.4f}) "
|
||||
f"yaw={math.degrees(float(obj.yaw)):.1f}°\n"
|
||||
f" 实际 (x,y,z)=({p[0]:.4f},{p[1]:.4f},{p[2]:.4f}) "
|
||||
f"yaw={math.degrees(yaw_now):.1f}°\n"
|
||||
f" 偏差 |Δp|={d * 1000:.1f}mm、姿态差 {ang:.1f}°"
|
||||
f"(容差 {tol_m * 1000:.1f}mm / {tol_deg:.1f}°)"
|
||||
)
|
||||
|
||||
|
||||
class SimTruthPerception(Node):
|
||||
"""把真值目标点按固定频率发到 ``visual_servo/filtered_point``。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("sim_truth_perception")
|
||||
self.declare_parameter("scene_file", "")
|
||||
"""场景文件(空 = 仓库根下 devices/sim/gazebo/config/scene.yaml)。"""
|
||||
|
||||
self.declare_parameter("target_object", "")
|
||||
"""目标物体名(空 = 场景里的 operating.part_object)。"""
|
||||
|
||||
self.declare_parameter("offset_m", [0.0, 0.0, 0.0])
|
||||
"""物体局部系偏移 [m],例如挪到某个孔:``[0.0, -0.029, 0.0]``。"""
|
||||
|
||||
self.declare_parameter("camera_frame", "")
|
||||
"""相机光学系(由 launch 按当前臂/相机传进来,不写死)。"""
|
||||
|
||||
self.declare_parameter("world_frame", "world")
|
||||
"""场景的世界系。Gazebo 里模型位姿就是相对它,TF 树上也有。"""
|
||||
|
||||
self.declare_parameter("point_topic", "visual_servo/filtered_point")
|
||||
self.declare_parameter("publish_hz", 20.0)
|
||||
"""发布频率:不必跟着相机帧率,伺服 100Hz 控制会自己插值/外推。"""
|
||||
|
||||
self.declare_parameter("pose_info_topic", "")
|
||||
"""世界模型位姿话题(``tf2_msgs/TFMessage``)。
|
||||
|
||||
空 = 用 scene.yaml 的 ``pose_info.ros_topic``;场景里也没配 = 真值**按名义
|
||||
位姿**算(几何会偏,且没有"物体被搬走"的告警)。"""
|
||||
|
||||
scene_file = str(self.get_parameter("scene_file").value or "").strip()
|
||||
self._scene_file = Path(scene_file) if scene_file else None
|
||||
self._offset = offset = self.get_parameter("offset_m").value or [0.0, 0.0, 0.0]
|
||||
# 长度的校验在 target_point_world 里(纯函数,有测试钉着),这里不重复。
|
||||
self._camera_frame = str(self.get_parameter("camera_frame").value or "").strip()
|
||||
if not self._camera_frame:
|
||||
raise TruthError(
|
||||
"camera_frame 没给。它由 visual_servo.launch.py 按当前臂的相机传进来"
|
||||
"(site.yaml → cameras/ perception.camera_frame)。"
|
||||
"手工跑这个节点时必须显式指定,例如 "
|
||||
"--ros-args -p camera_frame:=sim_d405_color_optical_frame"
|
||||
)
|
||||
self._world_frame = str(self.get_parameter("world_frame").value or "world").strip()
|
||||
|
||||
# 目标点在启动时算一次就够:场景是静态的(工件被推动后位置会变,
|
||||
# 但那种情况下"真值"本身也就失效了 —— 此时应该重跑 gen_sim_handeye
|
||||
# 或重启,而不是悄悄跟着一个被撞歪的位姿走)。
|
||||
# ⚠️ 这句话在 2026-09-24 之前只是**注释**:工件真的被碰走了,没有任何人
|
||||
# 发现(详见 scene.yaml 的 pose_info 段)。现在由 _setup_drift_watch
|
||||
# 把它变成**运行时的判据**:一旦对上不,立刻停发并指名报错。
|
||||
self._spec, self._target = load_truth_target(
|
||||
self._scene_file,
|
||||
str(self.get_parameter("target_object").value or ""),
|
||||
offset,
|
||||
)
|
||||
|
||||
self._pub = self.create_publisher(
|
||||
PointStamped, str(self.get_parameter("point_topic").value), POINT_QOS
|
||||
)
|
||||
self._tf = Buffer()
|
||||
TransformListener(self._tf, self, spin_thread=True)
|
||||
self._sent = 0
|
||||
self._warned_tf = 0
|
||||
self._warned_drift = 0
|
||||
self._t_start: Optional[float] = None
|
||||
# 目标点按**实测**位姿算(见模块 docstring 与 sim_live_pose),
|
||||
# 位姿没变就不重算(算一次要读模型 SDF,20Hz 下不能每拍都读)。
|
||||
self._target_live: Optional[TruthTarget] = None
|
||||
self._target_key: Optional[Tuple[float, ...]] = None
|
||||
self._warned_nominal = 0
|
||||
# 启动复位(scene.yaml 的 pose_info.reset_on_start):见 _maybe_startup_reset。
|
||||
self._reset_done = False
|
||||
self._reset_deadline: Optional[float] = None
|
||||
self._setup_drift_watch()
|
||||
|
||||
hz = max(1.0, min(100.0, float(self.get_parameter("publish_hz").value)))
|
||||
self.create_timer(1.0 / hz, self._tick)
|
||||
self.get_logger().warn(
|
||||
"真值感知已启动(仿真专用):不跑任何推理,直接把已知位姿当测量发出去。\n"
|
||||
f" 场景 {self._spec.source}\n"
|
||||
f" 目标 {self._target.note}\n"
|
||||
f" 世界系点 ({self._target.world_xyz[0]:.3f}, "
|
||||
f"{self._target.world_xyz[1]:.3f}, {self._target.world_xyz[2]:.3f}) "
|
||||
f"→ 相机系 {self._camera_frame}(TF: {self._world_frame} → "
|
||||
f"{self._camera_frame}) 频率 {hz:.0f}Hz\n"
|
||||
f" ⚠️ 它验证的是控制与接口,不是检测算法 —— 见 "
|
||||
f"devices/sim/gazebo/README.md「仿真里哪些模块用真值替代」"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 漂移监视:真值感知的"前提自检"
|
||||
# ------------------------------------------------------------------
|
||||
def _setup_drift_watch(self) -> None:
|
||||
"""订阅世界模型位姿,盯住"被真值当作几何基准"的物体现在在哪。
|
||||
|
||||
两件事一起做,因为它们是同一份数据:
|
||||
* **量测**:给真值目标/孔位提供**实测**位姿(``sim_live_pose``)——
|
||||
这才是真值该用的几何(见模块 docstring);
|
||||
* **告警**:实测位姿相对 ``scene.yaml`` 的名义位姿偏了多少。
|
||||
超过 ``drift_tol_m`` **告警但继续发**(几何已经跟着实测走,
|
||||
没有幻影目标);超过 ``drift_stop_m`` 才**停发**并指名报错
|
||||
(那个量级说明物体掉了/被搬走了,场景前提没了)。
|
||||
|
||||
为什么告警与停发要分开(2026-09-24 实测):
|
||||
物体 spawn 落地就偏 2.6mm、骑在通槽上还会缓慢再挪 1.9mm ——
|
||||
这个量级**天天都会出现**,而它正好压在"2mm"这条线上。用同一条阈值
|
||||
既停发又告警,结果是"每轮开局都可能被假故障拦死",
|
||||
而现场的正确反应是"忽略这条告警" —— 监视器一旦被学会忽略就废了。
|
||||
|
||||
只盯**工件**:它是二阶段孔位与伺服目标的几何基准。
|
||||
通止规刻意不盯 —— 它本来就要被夹走、被搬动,那正是流程的一部分。
|
||||
"""
|
||||
pi = self._spec.pose_info
|
||||
topic = str(self.get_parameter("pose_info_topic").value or "").strip()
|
||||
if not topic and pi is not None:
|
||||
topic = pi.ros_topic
|
||||
# 只盯工件:它是二阶段孔位与伺服目标的几何基准。
|
||||
want = self._spec.operating.part_object
|
||||
self._nominal_obj = next((o for o in self._spec.objects if o.name == want), None)
|
||||
self._tol_m = float(pi.drift_tol_m if pi is not None else 0.002)
|
||||
self._tol_deg = float(pi.drift_tol_deg if pi is not None else 2.0)
|
||||
self._stop_m = float(getattr(pi, "drift_stop_m", 0.02) if pi is not None else 0.02)
|
||||
# 启动复位要用 world 名(服务路径里),而 world 名从 gz_topic 推出来 ——
|
||||
# 不另配一个参数:两份写法迟早不一致,症状是"服务调用超时"。
|
||||
self._reset_world = (
|
||||
sim_pose_service.world_from_gz_topic(pi.gz_topic) if pi is not None else None
|
||||
)
|
||||
self._reset_on_start = bool(pi.reset_on_start) if pi is not None else False
|
||||
self._live = sim_live_pose.LiveScenePose(self, topic)
|
||||
|
||||
if self._nominal_obj is None:
|
||||
self.get_logger().warn(
|
||||
f"scene.yaml 的 operating.part_object={want!r} 不在 objects 里 —— "
|
||||
f"实测位姿无从取得(只能按名义位姿算,几何会偏)。"
|
||||
)
|
||||
if not topic:
|
||||
self.get_logger().info(
|
||||
"没有世界模型位姿话题(scene.yaml 的 pose_info 未配)—— "
|
||||
"真值将**按名义位姿**算目标点与孔位(物体一动就偏,实测偏 2~3mm "
|
||||
"就会让规尖进不了孔),而且没有「物体被搬走」的告警。"
|
||||
)
|
||||
return
|
||||
self.get_logger().info(
|
||||
f"已订阅 {topic}:真值目标按**实测**位姿算;与该物体在 scene.yaml 的"
|
||||
f"名义位姿相差超过 {self._tol_m * 1000:.1f}mm / {self._tol_deg:.1f}° 告警,"
|
||||
f"超过 {self._stop_m * 1000:.0f}mm 停发。"
|
||||
)
|
||||
if self._reset_on_start:
|
||||
if self._reset_world is None:
|
||||
self.get_logger().error(
|
||||
f"pose_info.reset_on_start=true,但从 gz_topic "
|
||||
f"{pi.gz_topic!r} 里认不出 world 名 —— **启动复位不会执行**。\n"
|
||||
f" 期望形如 /world/<world>/pose/info,world 名 = "
|
||||
f"worlds/assembly.world 的文件名。\n"
|
||||
f" 后果:spawn 落地的 2~3mm 偏差会被当成"
|
||||
f"「场景被动过」,告警一直刷。"
|
||||
)
|
||||
else:
|
||||
self.get_logger().info(
|
||||
f"启动复位已开:开局({STARTUP_RESET_WINDOW_S:.0f}s 内)会把被盯的物体"
|
||||
f"搬回名义位姿(set_pose /world/{self._reset_world}/set_pose)。"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 实测位姿 → 目标点
|
||||
# ------------------------------------------------------------------
|
||||
def _live_deviation(self) -> Optional[Tuple[float, float, str]]:
|
||||
"""实测位姿相对名义位姿的偏差 ``(d [m], ang [deg], 说明)``;没有实测返回 None。"""
|
||||
obj = getattr(self, "_nominal_obj", None)
|
||||
if obj is None:
|
||||
return None
|
||||
p = self._live.pose(obj.name)
|
||||
if p is None:
|
||||
return None
|
||||
d, ang, _ = pose_deviation(obj, p[:3], sim_pose_service.yaw_to_quat(p[3]))
|
||||
reason = (
|
||||
pose_drift(obj, p[:3], sim_pose_service.yaw_to_quat(p[3]), self._tol_m, self._tol_deg)
|
||||
or ""
|
||||
)
|
||||
return (d, ang, reason)
|
||||
|
||||
def _current_target(self) -> Tuple[TruthTarget, bool]:
|
||||
"""按实测位姿算目标点(位姿没变就复用)。返回 ``(目标, 是否按实测算的)``。
|
||||
|
||||
实测位姿拿不到时**退回名义位姿**并把这个事实报给调用方 ——
|
||||
静默退回是这条链上最贵的错(几何偏两毫米、没有任何日志提起)。
|
||||
"""
|
||||
obj = getattr(self, "_nominal_obj", None)
|
||||
p = self._live.pose(obj.name) if obj is not None else None
|
||||
if obj is None or p is None:
|
||||
return self._target, False
|
||||
key = tuple(round(float(v), 7) for v in p)
|
||||
if self._target_key == key and self._target_live is not None:
|
||||
return self._target_live, True
|
||||
spec_live = self._live.apply(self._spec, [obj.name])
|
||||
live_obj = pick_object(spec_live, obj.name)
|
||||
t = target_point_world(spec_live, live_obj, self._offset)
|
||||
self._target_key = key
|
||||
self._target_live = t
|
||||
return t, True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 启动复位:把"落地的 2~3mm"与"被撞走"分开
|
||||
# ------------------------------------------------------------------
|
||||
def _maybe_startup_reset(self) -> None:
|
||||
"""开局把被盯的物体**实际搬到**名义位姿(只做一次,且有时间窗)。
|
||||
|
||||
为什么需要(2026-09-24 实测,别再当玄学):
|
||||
物体是"在名义位姿上方 spawn、再落下来"的,落稳后实测偏 **2.6mm**
|
||||
((0.5175, 0.0008) vs 名义 (0.5200, 0.0000)),正好越过 2mm 容差 ——
|
||||
它不是"被撞走",但看门狗**分不出来**,于是每一轮开局都可能被这条
|
||||
假故障拦死:S04 等不到目标点、报「一个视觉目标点都没收到」。
|
||||
而把它 set_pose 回名义位姿之后,它**停在那儿不动**(实测)——
|
||||
说明名义位姿本身就是稳定平衡点,差的只是 spawn 那一下的落点。
|
||||
|
||||
为什么只在窗口内做、且**只做一次**:
|
||||
运行中漂移的唯一合法解释是"被机器人撞了/压了"。那时悄悄搬回去等于
|
||||
抹掉现场证据(而现象会变成"孔位对不上/下压不接触",更难查),
|
||||
还可能把工件从爪里拽出来。所以过了窗口就照旧报错停下。
|
||||
|
||||
判据本身在纯函数 ``startup_reset_decision`` 里(有单测钉着),
|
||||
这里只负责"按决定去调服务、并把话说明白"。
|
||||
"""
|
||||
if self._reset_done or not self._reset_on_start or self._reset_world is None:
|
||||
return
|
||||
now = self.get_clock().now().nanoseconds * 1e-9
|
||||
dev = self._live_deviation()
|
||||
decision, deadline = startup_reset_decision(
|
||||
now_s=now,
|
||||
deadline_s=self._reset_deadline,
|
||||
window_s=STARTUP_RESET_WINDOW_S,
|
||||
pose_known=self._live.ready,
|
||||
any_drift=bool(dev is not None and (dev[2] or dev[0] > self._tol_m)),
|
||||
)
|
||||
self._reset_deadline = deadline
|
||||
if decision == "wait":
|
||||
return
|
||||
if decision == "skip":
|
||||
self._reset_done = True # 本来就在位(或还没量到足够信息):不必搬
|
||||
return
|
||||
if decision == "give_up":
|
||||
# 窗口内没成功(比如 set_pose 服务不通)→ 放弃并说清楚,
|
||||
# 之后就是告警/停发的正常行为(真值几何已经跟着实测算,不影响发点)。
|
||||
self._reset_done = True
|
||||
self.get_logger().warn(
|
||||
f"启动复位窗口({STARTUP_RESET_WINDOW_S:.0f}s)内没能把物体搬回名义位姿 —— "
|
||||
f"放弃复位;真值几何按实测位姿算(不受影响),"
|
||||
f"但告警会一直刷「与名义位姿不一致」。"
|
||||
)
|
||||
return
|
||||
|
||||
self._reset_done = True
|
||||
obj = getattr(self, "_nominal_obj", None)
|
||||
if obj is None:
|
||||
return
|
||||
reason = (dev[2] if dev else "") or ""
|
||||
ok, out = sim_pose_service.set_model_pose_yaw(
|
||||
self._reset_world, obj.name, (obj.x, obj.y, obj.z), float(obj.yaw)
|
||||
)
|
||||
if ok:
|
||||
self.get_logger().warn(
|
||||
f"启动复位:{obj.name!r} 不在名义位姿"
|
||||
f"({reason.splitlines()[-1].strip() if reason else ''})"
|
||||
f"—— 已 set_pose 回 ({obj.x:.4f},{obj.y:.4f},{obj.z:.4f}) "
|
||||
f"yaw={math.degrees(float(obj.yaw)):.1f}°。\n"
|
||||
f" 这是**开局**的正常现象(spawn 落地会偏 2~3mm),不是故障;"
|
||||
f"复位后实测位姿等于名义位姿。"
|
||||
)
|
||||
else:
|
||||
self.get_logger().error(
|
||||
f"启动复位失败:{obj.name!r} 没能 set_pose 回名义位姿 —— {out[:200]}\n"
|
||||
f" 影响有限:真值几何按实测位姿算,仍能发点;但告警会一直刷。"
|
||||
)
|
||||
|
||||
def _tick(self) -> None:
|
||||
# 前提自检的**活性检查**:订阅了话题却一帧都没收到,等于没有监视 ——
|
||||
# 而那正是"以为有保护、实际裸奔"。所以这里必须把这种情况说出来。
|
||||
if self._live.topic and not self._live.ready:
|
||||
if self._t_start is None:
|
||||
self._t_start = self.get_clock().now().nanoseconds * 1e-9
|
||||
elif self.get_clock().now().nanoseconds * 1e-9 - self._t_start > 8.0:
|
||||
self.get_logger().warn(
|
||||
"盯物体位姿的话题**一帧都没收到** —— 真值只能退回**名义位姿**"
|
||||
"(几何会偏,实测偏 2~3mm 就能让规尖进不了孔)。\n"
|
||||
" 常见原因:gazebo_sim.launch.py 里的 pose_info_bridge 没起来,"
|
||||
"或 scene.yaml 的 pose_info.gz_topic 与 world 名对不上\n"
|
||||
" (必须等于 /world/<world>/pose/info,world 名 = "
|
||||
"worlds/assembly.world 的文件名)。",
|
||||
throttle_duration_sec=10.0,
|
||||
)
|
||||
# 先给"开局落地偏差"一次纠偏机会(见 _maybe_startup_reset)。
|
||||
self._maybe_startup_reset()
|
||||
|
||||
dev = self._live_deviation()
|
||||
if dev is None and self._nominal_obj is not None and self._live.ready:
|
||||
# 话题有帧,就是没有这个模型 → 名字对不上,说出来。
|
||||
self.get_logger().warn(
|
||||
f"实测位姿里没有物体 {self._nominal_obj.name!r}:"
|
||||
f"{self._live.why_not(self._nominal_obj.name)}",
|
||||
throttle_duration_sec=10.0,
|
||||
)
|
||||
elif dev is not None:
|
||||
d, ang, reason = dev
|
||||
if d > self._stop_m:
|
||||
# 这个量级说明物体掉了/被搬走了:场景前提没了,**不发点**。
|
||||
self._warned_drift += 1
|
||||
if self._warned_drift in (1, 5, 30) or self._warned_drift % 300 == 0:
|
||||
self.get_logger().error(
|
||||
f"仿真物体 {self._nominal_obj.name!r} 已经不在 scene.yaml 说的地方了 —— "
|
||||
f"**停止发真值点**(偏了 {d * 1000:.0f}mm,超过 "
|
||||
f"{self._stop_m * 1000:.0f}mm 这条线)。\n"
|
||||
f" {reason}\n"
|
||||
f" ⚠ 为什么停:物体掉了/被搬走之后,装配就没有意义了 ——"
|
||||
f"再发点等于指挥机械臂去装一个不存在的工件。\n"
|
||||
f" 修法:把 {self._nominal_obj.name!r} 放回 scene.yaml 的位置"
|
||||
f"(真机现场就改 scene.yaml),或重启仿真让它按场景重新 spawn。\n"
|
||||
f" 排查提示:台面沿 X 有一道 36mm 通槽(|y| ≤ 18mm);"
|
||||
f"工件若跨槽时被压翻就会掉下去。"
|
||||
)
|
||||
return
|
||||
if reason:
|
||||
# 2~3mm 这个量级**天天都有**(spawn 落地 + 通槽上缓慢蠕动),
|
||||
# 而几何已经跟着实测走,所以只告警、**继续发点**。
|
||||
self.get_logger().warn(
|
||||
f"物体 {self._nominal_obj.name!r} 与 scene.yaml 的名义位姿不一致 —— "
|
||||
f"真值几何已按**实测**位姿算,不受影响;只是提醒你场景被动过。\n"
|
||||
f" {reason}\n"
|
||||
f" (超过 {self._stop_m * 1000:.0f}mm 才停发)",
|
||||
throttle_duration_sec=30.0,
|
||||
)
|
||||
|
||||
target, live = self._current_target()
|
||||
if not live and self._warned_nominal < 3:
|
||||
self._warned_nominal += 1
|
||||
self.get_logger().warn(
|
||||
f"真值目标暂时按**名义位姿**算({self._live.why_not(target.object_name)})—— "
|
||||
f"物体一动几何就偏,实测偏 2~3mm 就会让规尖进不了孔。"
|
||||
)
|
||||
try:
|
||||
tr = self._tf.lookup_transform(self._camera_frame, self._world_frame, Time())
|
||||
except Exception as exc: # noqa: BLE001 —— tf2 的异常类型很杂,统一当"还没有"
|
||||
# 前几拍必然查不到(TF 要等 robot_state_publisher 起来),
|
||||
# 所以只刷限流告警,不报错。
|
||||
self._warned_tf += 1
|
||||
if self._warned_tf in (1, 20, 100) or self._warned_tf % 200 == 0:
|
||||
self.get_logger().warn(
|
||||
f"还查不到 TF {self._world_frame} → {self._camera_frame}"
|
||||
f"(已试 {self._warned_tf} 次):{exc}",
|
||||
throttle_duration_sec=5.0,
|
||||
)
|
||||
return
|
||||
p = apply_transform(matrix_from_transform(tr), target.world_xyz)
|
||||
if p[2] < MIN_CAM_Z_M:
|
||||
# 目标在相机背后或贴到镜头上:不发!发出去伺服会朝反方向走。
|
||||
self.get_logger().warn(
|
||||
f"真值目标不在相机前方(相机系 z={p[2] * 1000:.0f}mm < "
|
||||
f"{MIN_CAM_Z_M * 1000:.0f}mm)—— 不发点。"
|
||||
f"世界系点 ({target.world_xyz[0]:.3f}, "
|
||||
f"{target.world_xyz[1]:.3f}, {target.world_xyz[2]:.3f})"
|
||||
f" 不在 {self._camera_frame} 视野里;先让臂摆到能看到它的位置。",
|
||||
throttle_duration_sec=5.0,
|
||||
)
|
||||
return
|
||||
|
||||
msg = PointStamped()
|
||||
msg.header.stamp = self.get_clock().now().to_msg()
|
||||
msg.header.frame_id = self._camera_frame
|
||||
msg.point.x, msg.point.y, msg.point.z = p
|
||||
self._pub.publish(msg)
|
||||
self._sent += 1
|
||||
if self._sent == 1:
|
||||
self.get_logger().info(
|
||||
f"首个真值目标点已发出:相机系 ({p[0]:.4f}, {p[1]:.4f}, {p[2]:.4f}) m"
|
||||
)
|
||||
elif self._sent % 200 == 0:
|
||||
self.get_logger().debug(f"已发 {self._sent} 个真值点")
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
import traceback
|
||||
|
||||
rclpy.init(args=argv)
|
||||
node = None
|
||||
try:
|
||||
node = SimTruthPerception()
|
||||
rclpy.spin(node)
|
||||
return 0
|
||||
except TruthError as exc:
|
||||
# 配置错:把原因原样打出来(它是给人看的),别让 ROS 的异常栈盖住。
|
||||
print(f"真值感知起不来:{exc}", flush=True)
|
||||
return 2
|
||||
except Exception: # noqa: BLE001
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
finally:
|
||||
if node is not None:
|
||||
node.destroy_node()
|
||||
if rclpy.ok():
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
"""仿真腕部力扭矩(wrist force/torque)的配置与桥接规则。
|
||||
|
||||
这一层只做两件事,别的都不做:
|
||||
|
||||
1. 从 ``devices/sim/gazebo/config/wrist_ft.yaml`` 读话题与关节名
|
||||
2. 生成 ros_gz_bridge 的参数(gz 消息 → ROS 消息)
|
||||
|
||||
**为什么不需要适配器节点**(与触觉不同):
|
||||
触觉必须有 ``sim_tactile_adapter``,因为要做的语义工作很多 —— 轴向映射、
|
||||
基线相减、单位换算、接触超时、把 ``Contacts`` 折算成 contact 标志。
|
||||
腕部 FT 不需要任何这些:Gazebo 的 force_torque 直接给的就是**牛顿**,
|
||||
坐标系是 ``child`` 连杆系(工具竖直时 z 即轴向),而 ros_gz_bridge
|
||||
**原生支持** gz.msgs.Wrench → geometry_msgs/msg/WrenchStamped(实测确认)。
|
||||
于是仿真的输出与真机 ``/<sn>/ft_sensor_wrench`` **同名同类型同单位**,
|
||||
应用层一行分支都不用写。
|
||||
|
||||
少一层就少一个"配错了不报错"的地方,所以这里刻意不加节点。
|
||||
|
||||
与真机的对应:
|
||||
真机 /<sn>/ft_sensor_wrench geometry_msgs/WrenchStamped(法兰系,牛顿)
|
||||
仿真 /sim/ft_sensor_wrench geometry_msgs/WrenchStamped(link7 系,牛顿)
|
||||
← 名字由 site.yaml 的 robot.arms[sim].sn 决定
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
#: 相对仓库根的默认配置路径。
|
||||
DEFAULT_WRIST_FT_REL = "devices/sim/gazebo/config/wrist_ft.yaml"
|
||||
|
||||
|
||||
class WristFtConfigError(RuntimeError):
|
||||
"""腕部 FT 配置缺失或写错。消息里必须给出修法(现场就靠这句话)。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WristFtSpec:
|
||||
"""腕部 FT 的一份完整描述。"""
|
||||
|
||||
topic: str
|
||||
joint: str
|
||||
ros_type: str
|
||||
gz_type: str
|
||||
publish_hz: float
|
||||
|
||||
|
||||
def load_wrist_ft(
|
||||
path: Optional[Path] = None, root: Optional[Path] = None
|
||||
) -> WristFtSpec:
|
||||
"""读腕部 FT 配置。找不到或写错时抛 WristFtConfigError,消息里给出修法。"""
|
||||
if path is None:
|
||||
base = root if root is not None else Path.cwd()
|
||||
path = Path(base) / DEFAULT_WRIST_FT_REL
|
||||
path = Path(path)
|
||||
|
||||
if not path.is_file():
|
||||
raise WristFtConfigError(
|
||||
f"腕部 FT 配置不存在: {path}\n"
|
||||
f" 它声明了仿真腕部力扭矩的话题名与挂载关节(桥接从它派生)。\n"
|
||||
f" 缺了它,S07 的接触判据在仿真里永远没有数据源。\n"
|
||||
f" 确认仓库根下存在 {DEFAULT_WRIST_FT_REL}。"
|
||||
)
|
||||
|
||||
try:
|
||||
doc = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
raise WristFtConfigError(
|
||||
f"腕部 FT 配置 YAML 解析失败: {path}\n 原因: {exc}"
|
||||
) from exc
|
||||
|
||||
if not isinstance(doc, dict):
|
||||
raise WristFtConfigError(f"{path} 的顶层应该是映射(key: value 形式)。")
|
||||
|
||||
missing = [
|
||||
k for k in ("topic", "joint", "ros_type", "gz_type") if not str(doc.get(k, "")).strip()
|
||||
]
|
||||
if missing:
|
||||
raise WristFtConfigError(
|
||||
f"{path} 缺字段: {', '.join(missing)}。\n"
|
||||
f" topic 必须与 arm.sim.sensors.xacro 里 wrist_ft_topic 的默认值\n"
|
||||
f" 完全一致,也要与应用层按 sn 推出的名字一致 —— 对不上时桥接器只是\n"
|
||||
f" 订阅了一个不存在的话题,不报错,而 S07 会一直判不到接触。"
|
||||
)
|
||||
|
||||
try:
|
||||
hz = float(doc.get("publish_hz", 100.0))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise WristFtConfigError(
|
||||
f"{path} 的 publish_hz 不是数字: {doc.get('publish_hz')!r}"
|
||||
) from exc
|
||||
if hz <= 0.0:
|
||||
raise WristFtConfigError(f"{path} 的 publish_hz 必须为正,收到 {hz}")
|
||||
|
||||
return WristFtSpec(
|
||||
topic=str(doc["topic"]).strip(),
|
||||
joint=str(doc["joint"]).strip(),
|
||||
ros_type=str(doc["ros_type"]).strip(),
|
||||
gz_type=str(doc["gz_type"]).strip(),
|
||||
publish_hz=hz,
|
||||
)
|
||||
|
||||
|
||||
def bridge_args(spec: WristFtSpec) -> List[str]:
|
||||
"""生成 ros_gz_bridge parameter_bridge 的话题规则。
|
||||
|
||||
只做**传输**:gz 类型 → ROS 类型,话题名两侧保持同名。
|
||||
这条规则的终点就是应用层真正订阅的那个话题,所以话题名必须一次写对 ——
|
||||
桥接器没有"改名"能力,写错只会得到"话题上没数据"。
|
||||
"""
|
||||
return [f"{spec.topic}@{spec.ros_type}[{spec.gz_type}"]
|
||||
|
||||
|
||||
def check_wrist_ft_sensor(
|
||||
urdf_text: str, spec: WristFtSpec
|
||||
) -> List[str]:
|
||||
"""描述级静态检查:URDF 里到底有没有那个腕部 FT 传感器。
|
||||
|
||||
为什么需要(与 ``check_tactile_collisions`` 同一个理由):
|
||||
Gazebo 对**挂错关节**的传感器不报错 —— 传感器会被静默丢弃,
|
||||
话题永远没有数据,现象是"S07 判不到接触"。光看 launch 日志完全看不出来
|
||||
(桥接器和 Gazebo 都说一切正常)。这里在启动时就把它指出来。
|
||||
|
||||
返回告警列表(空 = 没问题);**不抛异常** —— 缺了它仿真仍能跑,
|
||||
只是 S07 的判据要退回触觉兜底,那是降级不是崩溃。
|
||||
"""
|
||||
warnings: List[str] = []
|
||||
if not urdf_text:
|
||||
return ["URDF 文本为空,无法核对腕部 FT 传感器"]
|
||||
|
||||
if f'reference="{spec.joint}"' not in urdf_text:
|
||||
near = [ln for ln in urdf_text.splitlines() if "gazebo reference=" in ln and "joint" in ln]
|
||||
warnings.append(
|
||||
f"URDF 里没有 <gazebo reference=\"{spec.joint}\"> —— "
|
||||
f"腕部 FT 传感器挂错关节了,它会被 Gazebo 静默丢弃、话题永远没数据。\n"
|
||||
f" 期望挂在 {spec.joint}(最后一个 revolute 关节:它下游挂着整个腕部以下"
|
||||
f" 的载荷,这才是工程意义上的腕部 FT)。\n"
|
||||
f" 注意不能挂 fixed 关节(如 flange_joint)——物理引擎不把它当独立关节"
|
||||
f" 求解,传感器永远没输出且不报错。\n"
|
||||
f" 当前 URDF 里的 <gazebo reference> 关节有: {', '.join(sorted(set(near))) or '(无)'}"
|
||||
)
|
||||
|
||||
if spec.topic not in urdf_text:
|
||||
warnings.append(
|
||||
f"URDF 里找不到腕部 FT 的话题 {spec.topic} —— 传感器的话题名与配置不一致。\n"
|
||||
f" 两侧必须一致:config 的 topic、arm.sim.sensors.xacro 的 wrist_ft_topic、\n"
|
||||
f" 以及应用层按 sn 推出的 /{{sn}}/ft_sensor_wrench(仿真 sn = sim)。"
|
||||
)
|
||||
|
||||
if "force_torque" not in urdf_text:
|
||||
warnings.append(
|
||||
"URDF 里没有任何 type=\"force_torque\" 传感器 —— 腕部 FT 不会被创建。"
|
||||
)
|
||||
|
||||
return warnings
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
"""真值孔位:把场景里**已知的孔几何**投影成二阶段要的那两个像素点。
|
||||
|
||||
为什么需要它
|
||||
------------
|
||||
二阶段(S05 精拍 → 对准)原本的输入是 ``hole_detect_hough`` 在图上找**亮环**。
|
||||
那条路要求工件检测模型先给出工件框(ROI),否则连"找哪儿"都不知道。
|
||||
|
||||
仿真的处境是:合成图过不了真机训练出来的工件模型(实测最高分 0.017),
|
||||
于是 S05 只能退回"整幅画面当 ROI"—— 而整幅画面里有**夹爪、通止规、台面**,
|
||||
HoughCircles 会在那儿找到"看起来像一对孔"的东西(实测两孔间距 108mm,
|
||||
真值是 58mm),随后被间距门槛丢掉,每拍都丢,最后超时。
|
||||
|
||||
真值路径直接把"孔在图上应该在哪"算出来:
|
||||
|
||||
孔世界点 = 物体世界位姿(scene.yaml)+ 环在物体局部系的位置(模型 <visual>)
|
||||
孔像素 = K · T_cam_world · 孔世界点
|
||||
|
||||
它验证的是**控制那一半**(坐标变换 / 手眼 / 伺服收敛 / 流程编排),
|
||||
不是感知那一半 —— 与 ``sim_truth_perception`` 同一条设计(见
|
||||
``devices/sim/gazebo/README.md`` 的「仿真里哪些模块用真值替代」)。
|
||||
|
||||
绝不做的事
|
||||
----------
|
||||
* 孔在相机背后(z<=0)时**不返回**结果。给伺服一个幻影目标会让机械臂朝错误
|
||||
方向走,而且看起来像"标定坏了" —— 和 ``sim_truth_perception`` 同一条规矩。
|
||||
* 不自己算四元数→旋转矩阵:T 由调用方(节点)用 TF 查好传进来。
|
||||
本模块只做**纯几何**,因此在没有 ROS 的环境里也能测。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Sequence, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from arm_sim_gazebo import sim_scene
|
||||
|
||||
#: 孔(环)离相机光心太近或跑到背后就不返回:z 太小除法会放大误差,
|
||||
#: z<=0 在相机系里就是"背后"。取 2cm,与真值感知节点的取值同量级。
|
||||
MIN_CAM_Z_M = 0.02
|
||||
|
||||
#: 两种孔排序的判据:图像上 |dx| 大于这个比例倍数就按 x 排,否则按 y 排。
|
||||
#: 与 ``hole_detect_hough._detect_insert_pair`` 的规则一致(k1=左/上,
|
||||
#: k2=右/下)—— 换后端不该换语义,否则"哪边是 k1"会随环境变。
|
||||
_PAIR_HORIZONTAL_RATIO = 1.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HoleTruth:
|
||||
"""一次真值定位的结果(``ok=False`` 时只有 ``why`` 有意义)。"""
|
||||
|
||||
ok: bool
|
||||
why: str = ""
|
||||
hole_points: List[Tuple[float, float]] = field(default_factory=list)
|
||||
#: 两孔的像素半径(环的物理半径按透视缩放;Hough 路径给的是它自己的估计,
|
||||
#: 真值路径给它一个**可信的**值 —— 上下游只把它当"尺度参考"用)
|
||||
hole_radii: List[float] = field(default_factory=list)
|
||||
#: 两孔**环心的世界坐标**(米),与 ``hole_points`` 一一对应、同序。
|
||||
#:
|
||||
#: 为什么真值路径要把三维也带出来(2026-09-24 的教训):
|
||||
#: 原来只给像素,调用方(stage2)再拿**深度图**反投影回三维。仿真里
|
||||
#: 那一步是错的,而且错法很隐蔽:孔是**贯通孔**,孔位正下方就是台面
|
||||
#: 通槽 —— 深度相机在孔位看到的是**槽底/地面**(实测 0.62m),
|
||||
#: 而工件面其实只有 0.21m。拿 0.21m 的像素尺度去乘 0.62m 的深度,
|
||||
#: 三角化出的 k1/k2 间距变成 169mm(真值 58mm),被间距门槛丢掉。
|
||||
#: 真值路径**本来就知道**孔在世界系的精确位置,没有理由再绕一圈深度图。
|
||||
hole_world: List[Tuple[float, float, float]] = field(default_factory=list)
|
||||
#: 工件**上表面中心**的世界坐标(粗居中用;同样是"真值就该直接用")。
|
||||
part_center_world: Optional[Tuple[float, float, float]] = None
|
||||
#: 工件上表面的世界 z(= 二阶段的对孔基准面)。
|
||||
part_top_z: Optional[float] = None
|
||||
#: 工件在画面里占的框(孔检测的 ROI 用;也是"工件中心"的来源)
|
||||
bbox: Tuple[int, int, int, int] = (0, 0, 0, 0)
|
||||
#: 给人看的说明(进了日志才知道这个数是哪来的)
|
||||
note: str = ""
|
||||
|
||||
|
||||
def _object_world_xy(obj: sim_scene.SceneObject, lx: float, ly: float) -> Tuple[float, float]:
|
||||
"""物体局部 (x,y) → 世界 (x,y)(含物体自身 yaw)。"""
|
||||
c, s = math.cos(float(obj.yaw)), math.sin(float(obj.yaw))
|
||||
return (float(obj.x) + c * lx - s * ly, float(obj.y) + s * lx + c * ly)
|
||||
|
||||
|
||||
def project_point(
|
||||
T_cam_world: np.ndarray, intr: Sequence[float], xyz: Sequence[float]
|
||||
) -> Optional[Tuple[float, float, float]]:
|
||||
"""世界点 → ``(u, v, z_cam)``;在相机背后/太近时返回 None。"""
|
||||
T = np.asarray(T_cam_world, dtype=float)
|
||||
p = T @ np.array([float(xyz[0]), float(xyz[1]), float(xyz[2]), 1.0], dtype=float)
|
||||
z = float(p[2])
|
||||
if not math.isfinite(z) or z <= MIN_CAM_Z_M:
|
||||
return None
|
||||
fx, fy, cx, cy = (float(intr[0]), float(intr[1]), float(intr[2]), float(intr[3]))
|
||||
return (fx * float(p[0]) / z + cx, fy * float(p[1]) / z + cy, z)
|
||||
|
||||
|
||||
def object_bbox_px(
|
||||
T_cam_world: np.ndarray,
|
||||
intr: Sequence[float],
|
||||
boxes: Sequence[Sequence[float]],
|
||||
image_shape: Tuple[int, int],
|
||||
) -> Optional[Tuple[int, int, int, int]]:
|
||||
"""物体各碰撞体世界 AABB 的角点投影 → 像素包围框(裁剪到画面内)。
|
||||
|
||||
返回 None = 一个角点都投不出来(跑到背后去了),调用方应当放弃这一拍。
|
||||
"""
|
||||
h, w = int(image_shape[0]), int(image_shape[1])
|
||||
us: List[float] = []
|
||||
vs: List[float] = []
|
||||
for b in boxes:
|
||||
for x in (b[0], b[1]):
|
||||
for y in (b[2], b[3]):
|
||||
for z in (b[4], b[5]):
|
||||
got = project_point(T_cam_world, intr, (x, y, z))
|
||||
if got is None:
|
||||
continue
|
||||
us.append(got[0])
|
||||
vs.append(got[1])
|
||||
if not us:
|
||||
return None
|
||||
x1 = max(0, min(w - 1, int(math.floor(min(us)))))
|
||||
y1 = max(0, min(h - 1, int(math.floor(min(vs)))))
|
||||
x2 = max(0, min(w - 1, int(math.ceil(max(us)))))
|
||||
y2 = max(0, min(h - 1, int(math.ceil(max(vs)))))
|
||||
if x2 <= x1 or y2 <= y1:
|
||||
return None
|
||||
return (x1, y1, x2, y2)
|
||||
|
||||
|
||||
def locate_holes(
|
||||
T_cam_world: Optional[np.ndarray],
|
||||
intr: Optional[Sequence[float]],
|
||||
image_shape: Tuple[int, int],
|
||||
*,
|
||||
spec: Optional[sim_scene.SceneSpec] = None,
|
||||
object_name: str = "",
|
||||
root: Optional[Path] = None,
|
||||
) -> HoleTruth:
|
||||
"""算这一拍的两个孔(像素 + ROI 框)。
|
||||
|
||||
``T_cam_world`` 为 None(TF 还没到)时返回 ``ok=False``,由调用方决定是
|
||||
继续用检测器还是报错 —— 本函数不猜、不兜底。
|
||||
"""
|
||||
if T_cam_world is None or intr is None:
|
||||
return HoleTruth(ok=False, why="还没有 T_cam_world / 相机内参(TF 或 camera_info 未到)")
|
||||
try:
|
||||
spec = spec if spec is not None else sim_scene.load_scene(None, root)
|
||||
except Exception as exc: # noqa: BLE001 —— 场景读不出来要说清是场景的问题
|
||||
return HoleTruth(ok=False, why=f"读不到场景配置:{exc}")
|
||||
want = (object_name or "").strip() or spec.operating.part_object
|
||||
obj = next((o for o in spec.objects if o.name == want), None)
|
||||
if obj is None:
|
||||
names = ", ".join(o.name for o in spec.objects) or "(空)"
|
||||
return HoleTruth(ok=False, why=f"场景里没有物体 {want!r}(现有:{names})")
|
||||
rings = sim_scene.rings_local(spec, obj, root)
|
||||
if len(rings) != 2:
|
||||
return HoleTruth(
|
||||
ok=False,
|
||||
why=(
|
||||
f"工件 {obj.name!r} 的模型里有 {len(rings)} 个嵌件亮环,"
|
||||
f"真值孔位要求正好 2 个(模型 <visual name=\"*_ring\"> 的名字约定)"
|
||||
),
|
||||
)
|
||||
|
||||
pts: List[Tuple[float, float]] = []
|
||||
rad: List[float] = []
|
||||
holes_world: List[Tuple[float, float, float]] = []
|
||||
for ring in rings:
|
||||
wx, wy = _object_world_xy(obj, ring.x, ring.y)
|
||||
wz = float(obj.z) + float(ring.z)
|
||||
got = project_point(T_cam_world, intr, (wx, wy, wz))
|
||||
if got is None:
|
||||
return HoleTruth(
|
||||
ok=False,
|
||||
why=f"孔 {ring.name} 投影到相机背后(z<=0)—— 相机没对着工件时不该出结果",
|
||||
)
|
||||
u, v, z = got
|
||||
pts.append((u, v))
|
||||
rad.append(float(intr[0]) * float(ring.radius) / z)
|
||||
holes_world.append((wx, wy, wz))
|
||||
|
||||
# k1/k2 的左右(上下)规则与 Hough 路径一致:水平对按 x,竖直对按 y。
|
||||
(p, q) = pts
|
||||
horizontal = abs(p[0] - q[0]) >= _PAIR_HORIZONTAL_RATIO * abs(p[1] - q[1])
|
||||
key = 0 if horizontal else 1
|
||||
if pts[0][key] > pts[1][key]:
|
||||
pts = [pts[1], pts[0]]
|
||||
rad = [rad[1], rad[0]]
|
||||
holes_world = [holes_world[1], holes_world[0]]
|
||||
|
||||
boxes = sim_scene.world_boxes(spec, obj, root)
|
||||
bbox = object_bbox_px(T_cam_world, intr, boxes, image_shape)
|
||||
if bbox is None:
|
||||
return HoleTruth(ok=False, why="工件整体投影不出画面框(角点都在相机背后)")
|
||||
|
||||
# 工件上表面中心与上表面 z:从各碰撞体的世界 AABB 取(AABB 是轴对齐的,
|
||||
# 只有 yaw 的物体上它正好等于"外接矩形",对"粗居中"这个用途是合适的)。
|
||||
part_center: Optional[Tuple[float, float, float]] = None
|
||||
part_top: Optional[float] = None
|
||||
if boxes:
|
||||
xs = [float(v) for b in boxes for v in (b[0], b[1])]
|
||||
ys = [float(v) for b in boxes for v in (b[2], b[3])]
|
||||
zs = [float(v) for b in boxes for v in (b[4], b[5])]
|
||||
part_top = max(zs)
|
||||
part_center = (0.5 * (min(xs) + max(xs)), 0.5 * (min(ys) + max(ys)), part_top)
|
||||
|
||||
dist_px = math.hypot(pts[0][0] - pts[1][0], pts[0][1] - pts[1][1])
|
||||
note = (
|
||||
f"{obj.name}(模型 {obj.model}) 环心距 {dist_px:.1f}px "
|
||||
f"环半径 {rad[0]:.1f}/{rad[1]:.1f}px 框 {bbox}"
|
||||
)
|
||||
return HoleTruth(
|
||||
ok=True,
|
||||
why="",
|
||||
hole_points=[(float(pts[0][0]), float(pts[0][1])), (float(pts[1][0]), float(pts[1][1]))],
|
||||
hole_radii=[float(rad[0]), float(rad[1])],
|
||||
hole_world=holes_world,
|
||||
part_center_world=part_center,
|
||||
part_top_z=part_top,
|
||||
bbox=bbox,
|
||||
note=note,
|
||||
)
|
||||
|
||||
|
||||
def draw(bgr: np.ndarray, res: HoleTruth) -> np.ndarray:
|
||||
"""把真值结果画在图上(叠图/日志用)。失败时只写一行原因。"""
|
||||
import cv2
|
||||
|
||||
img = bgr.copy()
|
||||
h, w = img.shape[:2]
|
||||
if not res.ok:
|
||||
cv2.putText(img, "TRUTH: n/a", (12, 28), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
|
||||
return img
|
||||
x1, y1, x2, y2 = res.bbox
|
||||
cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 255), 2)
|
||||
for i, (u, v) in enumerate(res.hole_points):
|
||||
r = int(round(res.hole_radii[i])) if i < len(res.hole_radii) else 8
|
||||
cv2.circle(img, (int(round(u)), int(round(v))), max(3, r), (0, 0, 255), 2)
|
||||
cv2.putText(
|
||||
img, f"T{i + 1}", (int(u) + 8, int(v)), cv2.FONT_HERSHEY_SIMPLEX, 0.60, (0, 0, 255), 2
|
||||
)
|
||||
cv2.putText(img, "TRUTH", (12, min(h - 8, 28)), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 255), 2)
|
||||
return img
|
||||
+501
@@ -0,0 +1,501 @@
|
||||
"""把厂商给的 URDF 归一化成"Gazebo 能接受"的形态。
|
||||
|
||||
=============================================================================
|
||||
为什么需要这一步(全是实测踩出来的)
|
||||
=============================================================================
|
||||
官方 flexiv_description 是给 RViz / MoveIt 用的,直接喂 Gazebo 会被
|
||||
sdformat 拒掉。两类问题,都属于"RViz 容忍、Gazebo 严格"的差异,
|
||||
所以这个框架在有 Gazebo 之前一直没暴露:
|
||||
|
||||
问题一:7 轴本体的质量/惯量是"孤儿标签"
|
||||
rizon_arm.xacro 里这样调:
|
||||
<link name="${prefix}base_link">
|
||||
<xacro:get_inertias name="base_link" />
|
||||
get_inertias 宏只吐 <origin> / <mass> / <inertia> 三个碎片,**外面没有
|
||||
<inertial> 包裹**。URDF 规范里 <link> 不能直接有这三个子元素。
|
||||
RViz/MoveIt 忽略不认识的子元素 → 看起来一切正常
|
||||
sdformat 判定 "link has no <inertial> block",
|
||||
然后把 **link 和它的父关节一起丢掉**
|
||||
症状很误导:机械臂凭空消失或只剩夹爪,报错只有一句
|
||||
"link[sim_link1] is not modeled in sdf",看不出根因在上游描述文件里。
|
||||
|
||||
问题二:夹爪有零质量 link,导致 sdformat 惯性叠加出 NaN
|
||||
grav_macro.xacro 给 left_finger_tip / left_finger_tcp / grav_tcp 等
|
||||
六个纯坐标系 dummy link 写了 mass=0、惯量全 0。
|
||||
sdformat 对 fixed 关节会把子 link 的惯性"叠加(lump)"到父 link,
|
||||
这个叠加要除以总质量 → 质量 0 就出 NaN。而报错指向的是**父** link:
|
||||
Pose values must be finite.
|
||||
link[sim_left_finger_mount]/inertial/pose
|
||||
完全看不出是子 link 质量的问题。
|
||||
|
||||
问题三:被 mimic 的关节 effort="0",在 Gazebo 里出不了力
|
||||
sdformat 转 SDF 时会**直接丢掉 <mimic>**(实测生成的 SDF 里
|
||||
"mimic" 出现 0 次),gz-sim 也没有任何 mimic 实现,SDF 规范里
|
||||
根本没有这个元素。于是夹爪那 6 个"靠机械耦合跟随"的手指关节
|
||||
在 Gazebo 里变成互相独立的关节,谁都不跟随谁。
|
||||
更麻烦的是它们的 effort="0"(上游语义:不由电机驱动)——
|
||||
而 gz_ros2_control 的 position 接口是"位置误差 → 力",
|
||||
力被 effort 上限夹到 0,于是**永远出不了力**:
|
||||
接口 [claimed]、控制器 active、话题有订阅、命令也发得出去,
|
||||
但手指一动不动,只是自由地被重力拽着漂。
|
||||
详见 MIMIC_EFFORT_FIX 处的注释。
|
||||
|
||||
=============================================================================
|
||||
修法原则
|
||||
=============================================================================
|
||||
只做"重新分组 + 补极小量",不发明任何物理数据,也不掩盖真问题:
|
||||
- 孤儿三件套 → 只是把已有元素包进 <inertial>,一个数字都不改
|
||||
- 零质量 → 补成 1e-4 kg(与夹爪其他配件同量级,0.1g 对动力学无影响),
|
||||
惯量如果是全 0 也补成极小的对角值,避免张量退化
|
||||
若某个 link 上游真没给质量信息,问题一这里什么都不会包,
|
||||
sdformat 会照实报错 —— 这是我们想要的:归一化不是止错器。
|
||||
|
||||
=============================================================================
|
||||
为什么在启动时做,而不是改上游文件
|
||||
=============================================================================
|
||||
1. 上游文件由 scripts/fetch_flexiv.sh 从 GitHub 获取,改了会被下次 fetch 覆盖
|
||||
2. 这个框架要支持"换成别的臂" —— 别的厂商描述大概率有别的坑,
|
||||
在启动时统一过一道"归一化",比逐个去改厂商文件更可持续
|
||||
3. 纯字符串→字符串的变换,可以脱开 ROS 单测(见 test/test_urdf_normalize.py)
|
||||
=============================================================================
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Tuple
|
||||
|
||||
# 零质量 link 补成的质量。取 1e-4 kg:与夹爪上 other 配件(内外杆、指座)
|
||||
# 同量级,0.1g 在动力学上完全可忽略,但足以让惯性叠加不除零。
|
||||
ZERO_MASS_FIX_KG = "1e-4"
|
||||
|
||||
# 惯量全 0 时补的对角值。极致小值只为让张量非退化,不引入可观测的转动惯量。
|
||||
ZERO_INERTIA_FIX = "1e-9"
|
||||
|
||||
# 被 mimic 的关节在 Gazebo 里失去机械耦合,必须能自己出力,所以把 effort="0"
|
||||
# 补成这个值。取 80 Nm:与夹爪那个真正被驱动的 finger_width_joint 的 effort 一致
|
||||
# (grav_macro.xacro 里写的就是 80),所以不引入任何新的"凭空数据",
|
||||
# 只是把"上游说这关节不由电机驱动"翻译成"仿真里它必须自己出力"。
|
||||
#
|
||||
# 为什么需要这条修复(实测,2026-09-23):
|
||||
# 夹爪只有 finger_width_joint 是真关节(effort=80),其余 6 个手指关节
|
||||
# 全是 mimic、effort="0"(上游语义:它们靠机械耦合跟随,不由电机驱动)。
|
||||
# 而 sdformat **直接丢弃 <mimic>**,gz-sim 也不支持 mimic ——
|
||||
# 于是在 Gazebo 里它们是 7 个互相独立的关节,谁都不跟随谁。
|
||||
#
|
||||
# 症状极具误导性:命令下发成功(接口 [claimed]、控制器 active、话题有订阅),
|
||||
# 但手指一动不动,只是自由地被重力拽着漂。原因是 gz_ros2_control 的 position
|
||||
# 接口实现是"位置误差 → 力"(实测 position_proportional_gain=0.1),
|
||||
# 力被 effort 上限夹到 0,于是永远出不了力。
|
||||
#
|
||||
# 对照实验:同一套链路下 sim_joint1 收到 0.2 rad 的目标后精确到位(0.19998),
|
||||
# 所以控制链路本身没问题,差别只在这 6 个关节的 effort=0。
|
||||
MIMIC_EFFORT_FIX = "80"
|
||||
|
||||
# =============================================================================
|
||||
# 夹爪出力上限:把 gz_ros2_control 的"位置控制"夹成"力控夹爪"
|
||||
# =============================================================================
|
||||
# 为什么需要(实测,2026-09-24):
|
||||
# gz_ros2_control 的 position 接口是"位置误差 → 力",力只被 effort 上限夹。
|
||||
# grav_macro 给手指关节写的 effort=80(单位在这里是 Nm),而指腹到指节的
|
||||
# 力臂只有 ~0.1m —— 于是"合爪"能压出**上千牛**:仿真里 30mm 的规被两指
|
||||
# 直接挤穿(实测关节走到 0 位=开口 27mm < 规径 30mm),规被弹出去、掉回规座,
|
||||
# 触觉却报着上百牛的力。表现像"夹爪抓不住",根因是出力没有上限。
|
||||
#
|
||||
# 取值怎么来的(不用猜:从 URDF 自己的行程推):
|
||||
# 夹爪是平行四边形连杆,指腹平动 100mm 时手指关节转 0.9404 rad
|
||||
# (inner/outer_finger 的 limits:-0.7854..0.155),所以
|
||||
# 力臂 ≈ 行程 / 角行程 = 0.100 / 0.9404 ≈ 0.106 m
|
||||
# 真机 Grav 的握力档位是 20~50N,取 45N(覆盖应用层常用的 force=30):
|
||||
# 手指关节 effort = 45 × 0.106 ≈ 4.8 Nm
|
||||
# 而 finger_width_joint 是 1:1 的"行程关节"(0..0.1m),它的 effort 就是握力
|
||||
# 本身,给 45(N)。
|
||||
#
|
||||
# ⚠ 这一条是**仿真专用**的物理量:真机的握力由 GripperExecute.force 走力控闭环,
|
||||
# 不靠关节 effort 上限。归一化只服务于 Gazebo,所以写在这里、不去动上游描述。
|
||||
GRIP_FORCE_N = 45.0
|
||||
GRIP_STROKE_M = 0.100
|
||||
GRIP_FINGER_TRAVEL_RAD = 0.9404
|
||||
GRIP_PAD_LEVER_M = GRIP_STROKE_M / GRIP_FINGER_TRAVEL_RAD # ≈ 0.1064 m
|
||||
GRIP_FINGER_EFFORT_NM = round(GRIP_FORCE_N * GRIP_PAD_LEVER_M, 2) # ≈ 4.79 Nm
|
||||
GRIP_WIDTH_EFFORT_N = GRIP_FORCE_N
|
||||
|
||||
#: 名字里带这些字样的关节 = 夹爪的指节/指腹/行程关节(仿真里统一带 sim_ 前缀)
|
||||
_GRIP_JOINT_HINTS = ("finger", "knuckle")
|
||||
#: 行程关节(1:1,effort 单位就是 N)
|
||||
_GRIP_WIDTH_HINT = "finger_width"
|
||||
|
||||
# =============================================================================
|
||||
# 为什么要把 <mimic> 整个从描述里删掉
|
||||
# =============================================================================
|
||||
# 除了上面的 effort 问题,<mimic> 留在描述里还有第二个隐患:
|
||||
#
|
||||
# robot_state_publisher **支持** mimic —— 它会用"主关节的值 × multiplier + offset"
|
||||
# 覆盖从动关节的位置,而不是用 /joint_states 里广播的真实值。
|
||||
# 而 Gazebo 里这些关节是独立被我们驱动的,真实值与 mimic 公式算出来的值
|
||||
# 在**我们没按公式驱动时**(或主关节没跟上时)就不一样。
|
||||
# 后果是 TF 与 Gazebo 各说各话,而且不报错。
|
||||
#
|
||||
# 实测(2026-09-23):当前版本的 RSP **没有**应用 mimic(TF 跟随真实关节值),
|
||||
# 所以现在两边碰巧一致。但这是 RSP 的实现细节,不是契约 —— 它一变,
|
||||
# TF 就会静默退回闭合位。
|
||||
#
|
||||
# 因此归一化时把 <mimic> 删掉,让描述与 Gazebo 的事实一致:
|
||||
# "7 个独立关节,全部由我们显式驱动"。
|
||||
# 耦合关系(谁跟随谁、乘多少)由 sim_gripper_adapter 显式负责,
|
||||
# 它从**未归一化**的展开结果里读(见 robot_description.mimic_relations)。
|
||||
#
|
||||
# 删掉的信息不会丢:会记进 NormalizeReport.stripped_mimics 并被打印出来。
|
||||
# =============================================================================
|
||||
|
||||
# 裸露的 origin + mass + inertia 三件套。
|
||||
#
|
||||
# ⚠️ 标签之间的空白必须是**可选**的(\s*),不能要求有换行 —— 这是个实测踩到的坑:
|
||||
# xacro 的输出格式在不同环境里不一样。真机环境的 xacro 会缩进成多行:
|
||||
# <origin .../>
|
||||
# <mass .../>
|
||||
# <inertia .../>
|
||||
# 而仿真环境(另一份 xacro 版本)走 xacro.process_file().toxml() 时**全部挤在一行**:
|
||||
# <link ...><origin .../><mass .../><inertia .../><visual ...>
|
||||
# 早期这里的正则要求标签之间必须有 \n,于是 Python 路径下归一化静默地什么都没做
|
||||
# (报告"包裹 0 处"),而 CLI 路径下却正常 —— 因为命令行会缩进。
|
||||
# 后果极严重:Gazebo 会丢掉整条机械臂,正是这个归一化本来要防的事。
|
||||
# 教训:这段代码不能对空白格式有任何假设。
|
||||
#
|
||||
# 夹爪的 <inertial> 内部也是这三个标签,所以必须按 link 块处理并跳过已含
|
||||
# <inertial> 的块,否则会二次包裹 —— sdformat 会报
|
||||
# "Inertial element must have a mass element"(已踩过)。
|
||||
_TRIPLE = re.compile(
|
||||
r"(?P<ind>[ \t]*)<origin(?P<o>[^>]*?)/>"
|
||||
r"\s*<mass(?P<m>[^>]*?)/>"
|
||||
r"\s*<inertia(?P<i>[^>]*?)/>"
|
||||
)
|
||||
|
||||
_LINK_BLOCK = re.compile(r"<link\b[^>]*>.*?</link>", re.S)
|
||||
|
||||
_JOINT_BLOCK = re.compile(r"<joint\b[^>]*>.*?</joint>", re.S)
|
||||
_MIMIC_TAG = re.compile(r"<mimic\b")
|
||||
|
||||
# <limit effort="0" .../>:只认数值 0(或 0.0 / 0.00…),不碰其它值。
|
||||
_LIMIT_ZERO_EFFORT = re.compile(r"<limit\b(?P<attrs>[^>]*?)/>")
|
||||
|
||||
_MASS_TAG = re.compile(r"<mass(?P<attrs>[^>]*?)/>")
|
||||
_INERTIA_TAG = re.compile(r"<inertia(?P<attrs>[^>]*?)/>")
|
||||
_INERTIA_COMPONENTS = ("ixx", "ixy", "ixz", "iyy", "iyz", "izz")
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormalizeReport:
|
||||
"""归一化做了什么。启动器会打印它 —— 静默修改用户描述是不可接受的,
|
||||
必须让人一眼看到"我改了你给的文件里的哪几处、改成什么"。"""
|
||||
|
||||
wrapped_inertials: int = 0
|
||||
fixed_zero_mass: List[str] = field(default_factory=list)
|
||||
fixed_zero_inertia: List[str] = field(default_factory=list)
|
||||
fixed_zero_effort: List[str] = field(default_factory=list)
|
||||
#: 被删掉的 mimic 关系 (关节, 源关节, multiplier, offset)
|
||||
stripped_mimics: List[Tuple[str, str, str, str]] = field(default_factory=list)
|
||||
#: 被压到真机握力量级的夹爪关节 (关节, 原值, 新值)
|
||||
clamped_grip_effort: List[Tuple[str, str, str]] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def changed(self) -> bool:
|
||||
return bool(
|
||||
self.wrapped_inertials
|
||||
or self.fixed_zero_mass
|
||||
or self.fixed_zero_inertia
|
||||
or self.fixed_zero_effort
|
||||
or self.stripped_mimics
|
||||
or self.clamped_grip_effort
|
||||
)
|
||||
|
||||
def summary(self) -> str:
|
||||
if not self.changed:
|
||||
return "URDF 无需归一化"
|
||||
bits = []
|
||||
if self.wrapped_inertials:
|
||||
bits.append(f"补 <inertial> 包裹 {self.wrapped_inertials} 处")
|
||||
if self.fixed_zero_mass:
|
||||
bits.append(
|
||||
f"零质量 link 补 {ZERO_MASS_FIX_KG}kg {len(self.fixed_zero_mass)} 个"
|
||||
f"({', '.join(self.fixed_zero_mass)})"
|
||||
)
|
||||
if self.fixed_zero_inertia:
|
||||
bits.append(f"零惯量张量补极小对角 {len(self.fixed_zero_inertia)} 个")
|
||||
if self.fixed_zero_effort:
|
||||
bits.append(
|
||||
f"被 mimic 关节补 effort={MIMIC_EFFORT_FIX} {len(self.fixed_zero_effort)} 个"
|
||||
f"({', '.join(self.fixed_zero_effort)})"
|
||||
)
|
||||
if self.stripped_mimics:
|
||||
bits.append(
|
||||
f"删除 mimic 关系 {len(self.stripped_mimics)} 条"
|
||||
f"(Gazebo 不支持 mimic,改由 sim_gripper_adapter 显式驱动)"
|
||||
)
|
||||
if self.clamped_grip_effort:
|
||||
bits.append(
|
||||
f"夹爪出力压到握力 {GRIP_FORCE_N:g}N 量级 {len(self.clamped_grip_effort)} 个"
|
||||
f"({', '.join(f'{n}:{a}→{b}' for n, a, b in self.clamped_grip_effort)})"
|
||||
)
|
||||
return ";".join(bits)
|
||||
|
||||
|
||||
def _num(attr: str) -> float:
|
||||
"""把 URDF 里的数值属性转 float,失败返回 nan(调用方据此跳过)。"""
|
||||
try:
|
||||
return float(attr)
|
||||
except (TypeError, ValueError):
|
||||
return float("nan")
|
||||
|
||||
|
||||
def _attrs_of(tag: str) -> dict:
|
||||
"""从标签文本里取出 name="value" 形式的属性。"""
|
||||
return dict(re.findall(r'([A-Za-z_][\w.-]*)\s*=\s*"([^"]*)"', tag))
|
||||
|
||||
|
||||
def _fix_zero_mass(block: str, report: NormalizeReport, link_name: str) -> str:
|
||||
"""把 <mass value="0"/> 补成极小正值。
|
||||
|
||||
两个前提:这个 link 有 <inertial>(否则不是"质量为零"而是"没有质量信息",
|
||||
不归这里管),且质量为数值 0。
|
||||
"""
|
||||
if "<inertial>" not in block:
|
||||
return block
|
||||
|
||||
m = _MASS_TAG.search(block)
|
||||
if m is None:
|
||||
return block
|
||||
attrs = _attrs_of(m.group("attrs"))
|
||||
if _num(attrs.get("value", "")) != 0.0:
|
||||
return block
|
||||
|
||||
report.fixed_zero_mass.append(link_name)
|
||||
fixed = f'<mass value="{ZERO_MASS_FIX_KG}"/>'
|
||||
block = block[: m.start()] + fixed + block[m.end():]
|
||||
|
||||
# 惯量张量若也全是 0,补极小对角值,避免退化
|
||||
im = _INERTIA_TAG.search(block)
|
||||
if im is not None:
|
||||
iattrs = _attrs_of(im.group("attrs"))
|
||||
if all(_num(iattrs.get(c, "")) == 0.0 for c in _INERTIA_COMPONENTS):
|
||||
report.fixed_zero_inertia.append(link_name)
|
||||
new_attrs = " ".join(
|
||||
f'{c}="{ZERO_INERTIA_FIX if c in ("ixx", "iyy", "izz") else "0"}"'
|
||||
for c in _INERTIA_COMPONENTS
|
||||
)
|
||||
block = block[: im.start()] + f"<inertia {new_attrs}/>" + block[im.end():]
|
||||
|
||||
return block
|
||||
|
||||
|
||||
def _fix_mimic_joint(block: str, report: NormalizeReport, joint_name: str) -> str:
|
||||
"""处理带 <mimic> 的关节:记下耦合关系、补 effort、删掉 mimic 标签。
|
||||
|
||||
为什么三件事一起做:它们都是同一个事实("Gazebo 不支持 mimic")的直接后果,
|
||||
分散在三处代码里迟早会有人只改一半。
|
||||
- 记下关系 → 适配器要知道谁跟随谁(信息不能丢,只是搬家)
|
||||
- 补 effort → 关节要能自己出力(见 MIMIC_EFFORT_FIX)
|
||||
- 删 mimic → 让描述与 Gazebo 的事实一致(见文件头那段说明)
|
||||
"""
|
||||
m = _MIMIC_TAG.search(block)
|
||||
if m is None:
|
||||
return block
|
||||
|
||||
# 1) 记录耦合关系(从 <mimic .../> 标签上抠出来)
|
||||
tag_end = block.find(">", m.start())
|
||||
tag = block[m.start() : tag_end + 1] if tag_end != -1 else ""
|
||||
attrs = _attrs_of(tag)
|
||||
report.stripped_mimics.append(
|
||||
(
|
||||
joint_name,
|
||||
attrs.get("joint", "?"),
|
||||
attrs.get("multiplier", "1"),
|
||||
attrs.get("offset", "0"),
|
||||
)
|
||||
)
|
||||
|
||||
# 2) 删掉 <mimic .../>(含它前面的缩进,免得留下孤零零的空白行)
|
||||
line_start = block.rfind("\n", 0, m.start()) + 1
|
||||
if block[line_start : m.start()].strip() == "":
|
||||
block = block[:line_start] + block[tag_end + 1 :].lstrip("\n")
|
||||
else:
|
||||
block = block[: m.start()] + block[tag_end + 1 :]
|
||||
|
||||
# 3) effort="0" → 可用值(只动这一个属性,限位是上游的物理事实,不碰)
|
||||
lm = _LIMIT_ZERO_EFFORT.search(block)
|
||||
if lm is not None:
|
||||
lattrs = _attrs_of(lm.group("attrs"))
|
||||
if _num(lattrs.get("effort", "")) == 0.0:
|
||||
report.fixed_zero_effort.append(joint_name)
|
||||
lattrs["effort"] = MIMIC_EFFORT_FIX
|
||||
new = "<limit " + " ".join(f'{k}="{v}"' for k, v in lattrs.items()) + "/>"
|
||||
block = block[: lm.start()] + new + block[lm.end() :]
|
||||
|
||||
return block
|
||||
|
||||
|
||||
def _clamp_grip_effort(block: str, report: NormalizeReport, joint_name: str) -> str:
|
||||
"""把夹爪关节的 effort 上限压到"真机握力量级"(见 GRIP_FORCE_N 的推导)。
|
||||
|
||||
为什么必须做:不做的话"合爪"能压出上千牛,把工件挤穿、弹飞,
|
||||
看起来像"夹爪抓不住",实际是出力没有上限(详见 GRIP_FORCE_N 处注释)。
|
||||
|
||||
只改 effort 一个属性;限位、速度是上游的物理事实,一律不碰。
|
||||
"""
|
||||
low = joint_name.lower()
|
||||
if not any(h in low for h in _GRIP_JOINT_HINTS):
|
||||
return block
|
||||
want = GRIP_WIDTH_EFFORT_N if _GRIP_WIDTH_HINT in low else GRIP_FINGER_EFFORT_NM
|
||||
lm = _LIMIT_ZERO_EFFORT.search(block)
|
||||
if lm is None:
|
||||
return block
|
||||
lattrs = _attrs_of(lm.group("attrs"))
|
||||
cur = _num(lattrs.get("effort", ""))
|
||||
if cur <= want:
|
||||
return block # 上游本来就比我们更保守:尊重它,不往上调
|
||||
lattrs["effort"] = f"{want:g}"
|
||||
report.clamped_grip_effort.append((joint_name, f"{cur:g}", f"{want:g}"))
|
||||
new = "<limit " + " ".join(f'{k}="{v}"' for k, v in lattrs.items()) + "/>"
|
||||
return block[: lm.start()] + new + block[lm.end() :]
|
||||
|
||||
|
||||
def normalize_urdf(urdf: str) -> Tuple[str, NormalizeReport]:
|
||||
"""归一化入口。返回 (新 URDF 文本, 报告)。
|
||||
|
||||
纯文本替换,不改动输入里其它任何字节 —— 重写整份 XML(如用
|
||||
ElementTree 再序列化)会顺带改变空白、属性顺序甚至语义,风险不值得担。
|
||||
"""
|
||||
report = NormalizeReport()
|
||||
parts: List[str] = []
|
||||
pos = 0
|
||||
|
||||
for m in _LINK_BLOCK.finditer(urdf):
|
||||
parts.append(urdf[pos:m.start()])
|
||||
block = m.group(0)
|
||||
link_name = "?"
|
||||
|
||||
# --- 问题一:孤儿 inertial 三件套 ---
|
||||
if "<inertial>" not in block:
|
||||
def repl(mm: re.Match) -> str:
|
||||
ind = mm.group("ind")
|
||||
o, m_, i = mm.group("o"), mm.group("m"), mm.group("i")
|
||||
# 原样保留输入的排版风格:多行输入就给多行输出,单行就给单行输出。
|
||||
# 凭空插入或删除换行会让 diff 里全是噪声,也不必要。
|
||||
if "\n" in mm.group(0):
|
||||
return (
|
||||
f"{ind}<inertial>\n"
|
||||
f"{ind} <origin{o}/>\n"
|
||||
f"{ind} <mass{m_}/>\n"
|
||||
f"{ind} <inertia{i}/>\n"
|
||||
f"{ind}</inertial>"
|
||||
)
|
||||
return f"{ind}<inertial><origin{o}/><mass{m_}/><inertia{i}/></inertial>"
|
||||
|
||||
block, n = _TRIPLE.subn(repl, block)
|
||||
report.wrapped_inertials += n
|
||||
|
||||
# --- 问题二:零质量 link ---
|
||||
lm = re.match(r'<link\b[^>]*name\s*=\s*"([^"]*)"', block)
|
||||
if lm:
|
||||
link_name = lm.group(1)
|
||||
block = _fix_zero_mass(block, report, link_name)
|
||||
|
||||
parts.append(block)
|
||||
pos = m.end()
|
||||
|
||||
parts.append(urdf[pos:])
|
||||
out = "".join(parts)
|
||||
|
||||
# --- 问题三:被 mimic 的关节 effort="0" ---
|
||||
# 放在 link 处理之后单独一遍:<joint> 不会嵌套在 <link> 里,顺序无关,
|
||||
# 而且这样不必把两套正则揉进同一个循环。
|
||||
def joint_repl(mm: re.Match) -> str:
|
||||
block = mm.group(0)
|
||||
jm = re.match(r'<joint\b[^>]*name\s*=\s*"([^"]*)"', block)
|
||||
name = jm.group(1) if jm else "?"
|
||||
if _MIMIC_TAG.search(block):
|
||||
block = _fix_mimic_joint(block, report, name)
|
||||
# 夹爪出力上限**放在 mimic 修复之后**:mimic 那条会把 effort=0 补成 80,
|
||||
# 而这里要把它压到真机握力的量级。顺序反了就等于没夹。
|
||||
return _clamp_grip_effort(block, report, name)
|
||||
|
||||
out = _JOINT_BLOCK.sub(joint_repl, out)
|
||||
return out, report
|
||||
|
||||
|
||||
def mimic_relations(urdf: str) -> List[Tuple[str, str, str, str]]:
|
||||
"""从(**未归一化的**)URDF 文本里读出所有 mimic 关系。
|
||||
|
||||
返回 [(关节, 源关节, multiplier, offset)],数值以字符串给出(原样保留,
|
||||
由调用方决定怎么解释)。归一化会删掉 <mimic>,所以必须在归一化**之前**读 ——
|
||||
这也是这个函数放在本模块而不是适配器里的原因:谁删的谁负责提供读取入口。
|
||||
"""
|
||||
out: List[Tuple[str, str, str, str]] = []
|
||||
for m in _JOINT_BLOCK.finditer(urdf):
|
||||
block = m.group(0)
|
||||
mm_ = _MIMIC_TAG.search(block)
|
||||
if mm_ is None:
|
||||
continue
|
||||
jm = re.match(r'<joint\b[^>]*name\s*=\s*"([^"]*)"', block)
|
||||
tag_end = block.find(">", mm_.start())
|
||||
attrs = _attrs_of(block[mm_.start() : tag_end + 1] if tag_end != -1 else "")
|
||||
out.append(
|
||||
(
|
||||
jm.group(1) if jm else "?",
|
||||
attrs.get("joint", "?"),
|
||||
attrs.get("multiplier", "1"),
|
||||
attrs.get("offset", "0"),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def normalize_file(src: str, dst: str) -> NormalizeReport:
|
||||
"""读文件 → 归一化 → 写文件。"""
|
||||
with open(src, "r", encoding="utf-8") as fh:
|
||||
text = fh.read()
|
||||
out, report = normalize_urdf(text)
|
||||
with open(dst, "w", encoding="utf-8") as fh:
|
||||
fh.write(out)
|
||||
return report
|
||||
|
||||
|
||||
def main(argv: List[str] | None = None) -> int:
|
||||
"""命令行入口:只看归一化做了什么,不启动 Gazebo。
|
||||
|
||||
排查"Gazebo 说我的 link 不存在"时,先跑这个比直接起仿真快得多:
|
||||
ros2 run arm_sim_gazebo urdf_normalize <urdf> [输出路径]
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="urdf_normalize",
|
||||
description="把 URDF 归一化成 Gazebo 可接受的形态(补 <inertial> 包裹、补零质量)。",
|
||||
)
|
||||
parser.add_argument("src", help="输入 URDF 路径")
|
||||
parser.add_argument(
|
||||
"dst", nargs="?", help="输出路径(默认写到 stdout,便于直接 grep)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--quiet", action="store_true", help="不打印归一化摘要"
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
with open(args.src, "r", encoding="utf-8") as fh:
|
||||
text = fh.read()
|
||||
out, report = normalize_urdf(text)
|
||||
|
||||
if not args.quiet:
|
||||
print(f"[urdf_normalize] {report.summary()}", file=sys.stderr)
|
||||
|
||||
if args.dst:
|
||||
with open(args.dst, "w", encoding="utf-8") as fh:
|
||||
fh.write(out)
|
||||
else:
|
||||
sys.stdout.write(out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1 @@
|
||||
prepend-non-duplicate;AMENT_PREFIX_PATH;
|
||||
@@ -0,0 +1,3 @@
|
||||
# generated from colcon_powershell/shell/template/hook_prepend_value.ps1.em
|
||||
|
||||
colcon_prepend_unique_value AMENT_PREFIX_PATH "$env:COLCON_CURRENT_PREFIX"
|
||||
@@ -0,0 +1,3 @@
|
||||
# generated from colcon_core/shell/template/hook_prepend_value.sh.em
|
||||
|
||||
_colcon_prepend_unique_value AMENT_PREFIX_PATH "$COLCON_CURRENT_PREFIX"
|
||||
@@ -0,0 +1 @@
|
||||
prepend-non-duplicate;PYTHONPATH;lib/python3.11/site-packages
|
||||
@@ -0,0 +1,3 @@
|
||||
# generated from colcon_powershell/shell/template/hook_prepend_value.ps1.em
|
||||
|
||||
colcon_prepend_unique_value PYTHONPATH "$env:COLCON_CURRENT_PREFIX/lib/python3.11/site-packages"
|
||||
@@ -0,0 +1,3 @@
|
||||
# generated from colcon_core/shell/template/hook_prepend_value.sh.em
|
||||
|
||||
_colcon_prepend_unique_value PYTHONPATH "$COLCON_CURRENT_PREFIX/lib/python3.11/site-packages"
|
||||
@@ -0,0 +1,781 @@
|
||||
"""启动 Gazebo 仿真:世界 + 机器人 + 控制器。
|
||||
|
||||
用法(推荐走 scripts/run_sim_gz.sh,它负责先激活仿真环境):
|
||||
ros2 launch arm_sim_gazebo gazebo_sim.launch.py
|
||||
ros2 launch arm_sim_gazebo gazebo_sim.launch.py world:=/abs/other.world headless:=false
|
||||
|
||||
=============================================================================
|
||||
启动顺序为什么是这样(每一环都踩过)
|
||||
=============================================================================
|
||||
1. robot_state_publisher 发布归一化后的 URDF 到 /robot_description
|
||||
2. Gazebo(gz_sim) 加载世界
|
||||
3. ros_gz_sim create 从 /robot_description spawn 机器人
|
||||
4. controller spawner 拉起 joint_state_broadcaster + rizon_arm_controller
|
||||
|
||||
两处顺序是硬要求,颠倒了只表现为"静默不工作":
|
||||
|
||||
a) RSP 必须在 create 之前就绪
|
||||
create 用 -topic robot_description 取 URDF。RSP 以 transient_local 发布
|
||||
(latched),迟到的订阅者也能拿到,所以这里主要是容忍,不是硬依赖。
|
||||
|
||||
b) controller spawner 必须在 Gazebo 起来之后
|
||||
controller_manager **跑在 Gazebo 进程里**(由 gz_ros2_control 的系统插件
|
||||
拉起),不是独立的 ros2_control_node。Gazebo 没起来时 spawner 找不到
|
||||
/controller_manager 服务,会一直重试直到 timeout。
|
||||
|
||||
c) 归一化后的 URDF 必须同时喂给 RSP 和 create
|
||||
gz_ros2_control 是从 /robot_description 读 <ros2_control> 段落的
|
||||
(SDF 里只存插件名和参数路径,接口声明不在 SDF —— 实测确认)。
|
||||
两边不是同一份时,控制器注册的关节名与 Gazebo 里的对不上,
|
||||
症状是 "Joint 'sim_joint1' not found in the robot model"。
|
||||
|
||||
=============================================================================
|
||||
use_sim_time
|
||||
=============================================================================
|
||||
仿真里所有节点都要 use_sim_time=True,时间来自 /clock(gz → ROS 桥接)。
|
||||
不设的话 TF 时间戳会用墙上时钟,与 Gazebo 的仿真时间不同源,
|
||||
表现为 RViz 里模型抖动、tf2 报 "Lookup would require extrapolation"。
|
||||
/clock 的桥接在下面 clock_bridge 里,故意排在最前面。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from launch import LaunchDescription
|
||||
from launch.actions import (
|
||||
DeclareLaunchArgument,
|
||||
IncludeLaunchDescription,
|
||||
LogInfo,
|
||||
OpaqueFunction,
|
||||
TimerAction,
|
||||
)
|
||||
from launch.launch_description_sources import PythonLaunchDescriptionSource
|
||||
from launch.substitutions import LaunchConfiguration
|
||||
from launch_ros.actions import Node
|
||||
from launch_ros.substitutions import FindPackageShare
|
||||
|
||||
# 启动延迟(秒)。都是为了避开"服务还没起来就去调"的竞态。
|
||||
# 数值偏保守:多等几秒远好过让用户对着一个静默失败的界面调试。
|
||||
SPAWN_DELAY = 4.0
|
||||
CONTROLLER_DELAY = 8.0
|
||||
|
||||
CONTROLLER_NAMES = ("joint_state_broadcaster", "rizon_arm_controller", "gripper_controller")
|
||||
|
||||
|
||||
def _prepare(context, *args, **kwargs):
|
||||
"""展开 + 归一化描述,并组装所有动作。
|
||||
|
||||
放在 OpaqueFunction 里做,因为需要先算出 URDF 文本才能构造
|
||||
robot_state_publisher 的参数 —— 而 launch 的 substitution 机制
|
||||
没法在 Python 里"看到"中间结果。
|
||||
"""
|
||||
from arm_sim_gazebo.robot_description import (
|
||||
SimDescriptionError,
|
||||
check_base_plugins,
|
||||
default_controllers,
|
||||
default_world,
|
||||
default_xacro,
|
||||
check_tactile_collisions,
|
||||
model_link_pose,
|
||||
prepare_urdf,
|
||||
repo_root,
|
||||
world_name,
|
||||
)
|
||||
from arm_sim_gazebo.sim_site import SimSiteError, resolve_world
|
||||
|
||||
world = LaunchConfiguration("world").perform(context).strip()
|
||||
load_gripper = LaunchConfiguration("load_gripper").perform(context).strip().lower()
|
||||
with_sensors = LaunchConfiguration("with_sensors").perform(context).strip().lower()
|
||||
with_scene = LaunchConfiguration("with_scene").perform(context).strip().lower()
|
||||
scene_file = LaunchConfiguration("scene").perform(context).strip()
|
||||
headless = LaunchConfiguration("headless").perform(context).strip().lower()
|
||||
verbose = LaunchConfiguration("verbose").perform(context).strip().lower()
|
||||
|
||||
# 相机清单在下面的传感器分支里才读,但场景分支(成像几何自检)也要用它。
|
||||
# 先置空再赋值,避免"因为分支没进而在别处 NameError"这种低级但难查的错误。
|
||||
specs = []
|
||||
|
||||
try:
|
||||
root = repo_root()
|
||||
# 与 gazebo_sim_launcher 用同一套优先级,直接 `ros2 launch` 的人也不会
|
||||
# 遇到"改了 site.yaml 没反应":--world > site.yaml 的 sim.world > 内置默认。
|
||||
world_path, _world_origin = resolve_world(world, root, fallback=default_world(root))
|
||||
world_path = str(world_path)
|
||||
|
||||
# 腕部 FT 的话题名先读出来,喂给 xacro —— 这个话题名有三处要对上
|
||||
# (配置、URDF 里的 <topic>、应用层按 sn 推出的 /{sn}/ft_sensor_wrench),
|
||||
# 让配置当唯一源头,xacro 不再自己写一份。
|
||||
from arm_sim_gazebo.sim_wrist_ft import (
|
||||
WristFtConfigError,
|
||||
bridge_args as wrist_ft_bridge_args,
|
||||
check_wrist_ft_sensor,
|
||||
)
|
||||
from arm_sim_gazebo.sim_wrist_ft import load_wrist_ft
|
||||
|
||||
try:
|
||||
wft = load_wrist_ft(root=root)
|
||||
except WristFtConfigError as exc:
|
||||
raise RuntimeError(f"\n[gazebo_sim] 仿真腕部 FT 配置有问题:\n{exc}\n") from exc
|
||||
|
||||
# 抓取锁:仿真里唯一能保证"夹住的东西不会在机械臂连续运动时滑掉"的机制,
|
||||
# 说明见 devices/sim/gazebo/config/grasp_lock.yaml 与 sim_grasp_lock.py。
|
||||
from arm_sim_gazebo.sim_grasp_lock import (
|
||||
DEFAULT_OBJECT_SDF_TMPL,
|
||||
GraspLockError,
|
||||
check_grasp_lock_sdf,
|
||||
)
|
||||
from arm_sim_gazebo.sim_grasp_lock import load_grasp_lock
|
||||
|
||||
try:
|
||||
glock = load_grasp_lock(root=root)
|
||||
except GraspLockError as exc:
|
||||
raise RuntimeError(
|
||||
f"\n[gazebo_sim] 仿真抓取锁配置有问题:\n{exc}\n"
|
||||
f" (只想先跑起来的话,把 grasp_lock.yaml 的 enabled 改成 false)\n"
|
||||
) from exc
|
||||
|
||||
# 静态核对:物体 SDF 里到底有没有那个插件、参数对不对得上。
|
||||
# 差一处就**静默失效**(现象只是"规莫名掉了"),所以必须在这里说清楚。
|
||||
glock_warnings: List[str] = []
|
||||
if glock.enabled:
|
||||
_obj_sdf = root / DEFAULT_OBJECT_SDF_TMPL.format(object=glock.object)
|
||||
try:
|
||||
_sdf_text = _obj_sdf.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
_sdf_text = ""
|
||||
glock_warnings = check_grasp_lock_sdf(
|
||||
_sdf_text, glock, sdf_label=str(_obj_sdf)
|
||||
)
|
||||
|
||||
urdf, report = prepare_urdf(
|
||||
xacro_path=default_xacro(root),
|
||||
controllers_file=default_controllers(root),
|
||||
load_gripper=load_gripper != "false",
|
||||
with_sensors=with_sensors != "false",
|
||||
wrist_ft_topic=wft.topic,
|
||||
)
|
||||
wname = world_name(world_path)
|
||||
missing_base = check_base_plugins(Path(world_path))
|
||||
except (SimDescriptionError, SimSiteError) as exc:
|
||||
# 描述准备失败是最常见的上手障碍(环境没 source、路径不对)。
|
||||
# 直接把可读原因打出来,而不是让它变成一个看不懂的 launch traceback。
|
||||
raise RuntimeError(f"\n[gazebo_sim] 仿真描述准备失败:\n{exc}\n") from exc
|
||||
|
||||
actions = [
|
||||
LogInfo(msg=f"[gazebo_sim] 仓库根: {root}"),
|
||||
LogInfo(msg=f"[gazebo_sim] 世界: {world_path} (world 名: {wname})"),
|
||||
LogInfo(msg=f"[gazebo_sim] URDF 归一化: {report.summary()}"),
|
||||
LogInfo(msg=f"[gazebo_sim] {glock.describe()}"),
|
||||
]
|
||||
for _w in glock_warnings:
|
||||
actions.append(LogInfo(msg=f"[gazebo_sim] ⚠ 抓取锁检查: {_w}"))
|
||||
if missing_base:
|
||||
actions.append(
|
||||
LogInfo(
|
||||
msg=(
|
||||
"[gazebo_sim] ⚠ world 缺少基础系统: "
|
||||
+ ", ".join(missing_base)
|
||||
+ "。world 里只要声明了任何一个 <plugin>,Gazebo 就不再加载默认"
|
||||
"系统列表。缺 UserCommands 会让 /world/<名>/create 服务不存在,"
|
||||
"模型根本不会被创建,而 create 仍打印 'OK creation of entity' ——"
|
||||
"最终表现是控制器永远拉不起来。修法见 assembly.world 里的注释。"
|
||||
)
|
||||
)
|
||||
)
|
||||
if not report.changed:
|
||||
actions.append(
|
||||
LogInfo(
|
||||
msg="[gazebo_sim] 注意:归一化没有改动任何内容。若 Gazebo 仍报 link 不存在,"
|
||||
"先确认 flexiv_description 的版本是否已自行修好这两个上游缺陷。"
|
||||
)
|
||||
)
|
||||
|
||||
# --- 1) 机器人描述 -------------------------------------------------------
|
||||
# 显式传 URDF 字符串(而不是 Command(["xacro ..."])),因为要发的是
|
||||
# **归一化后**的那一份。这是 RSP 与 create 必须一致的东西,见文件头 (c)。
|
||||
actions.append(
|
||||
Node(
|
||||
package="robot_state_publisher",
|
||||
executable="robot_state_publisher",
|
||||
name="robot_state_publisher",
|
||||
output="screen",
|
||||
parameters=[
|
||||
{"robot_description": urdf},
|
||||
{"use_sim_time": True},
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
# --- 2) Gazebo -----------------------------------------------------------
|
||||
gz_args = ["-r"] # 立刻开始推进仿真(不等 GUI 里的播放按钮)
|
||||
if verbose == "true":
|
||||
gz_args += ["-v", "4"] # 打开 Gazebo 自身日志,排查插件加载失败时必需
|
||||
if headless == "true":
|
||||
# -s = 只跑服务端,不开 GUI。无显示器环境(本机就是)必须用这个。
|
||||
gz_args.append("-s")
|
||||
gz_args.append(world_path)
|
||||
|
||||
actions.append(
|
||||
IncludeLaunchDescription(
|
||||
PythonLaunchDescriptionSource(
|
||||
os.path.join(
|
||||
FindPackageShare("ros_gz_sim").perform(context),
|
||||
"launch",
|
||||
"gz_sim.launch.py",
|
||||
)
|
||||
),
|
||||
launch_arguments={"gz_args": " ".join(gz_args)}.items(),
|
||||
)
|
||||
)
|
||||
|
||||
# --- 3) /clock 桥接(sim time 的来源,排在最前)-------------------------
|
||||
actions.append(
|
||||
Node(
|
||||
package="ros_gz_bridge",
|
||||
executable="parameter_bridge",
|
||||
name="clock_bridge",
|
||||
arguments=["/clock@rosgraph_msgs/msg/Clock[gz.msgs.Clock"],
|
||||
output="screen",
|
||||
)
|
||||
)
|
||||
|
||||
# --- 3b) 世界模型位姿回读:物体被撞走时唯一能指名报错的数据源 ------------
|
||||
# 为什么值得一条独立桥接:`perception.source=truth` 是**纯几何**的,
|
||||
# 它按 scene.yaml 的**名义位姿**算孔位,从不问 Gazebo 物体现在在哪。
|
||||
# 工件被碰走(实测:掉进台面通槽落到地面)之后,"真值"开始撒谎,
|
||||
# 而现象全都指向别处(S05 报"两孔间距 169mm"、S07 一路下压不接触)。
|
||||
# 这一条把 gz 的"每个模型实际位姿"搬进 ROS,让 sim_truth_perception
|
||||
# 能把它和 scene.yaml 对上、指名报"工件不在它该在的地方"。
|
||||
# 具体参数(话题名/类型/漂移容差)在 scene.yaml 的 pose_info 段。
|
||||
from arm_sim_gazebo.sim_scene import load_scene as _load_scene
|
||||
|
||||
try:
|
||||
_spec = _load_scene(None, root)
|
||||
except Exception as exc: # noqa: BLE001 —— 场景读不出来要说清是场景的问题
|
||||
raise RuntimeError(f"\n[gazebo_sim] 场景配置有问题:\n{exc}\n") from exc
|
||||
if _spec.pose_info is not None:
|
||||
_pi = _spec.pose_info
|
||||
actions.append(
|
||||
Node(
|
||||
package="ros_gz_bridge",
|
||||
executable="parameter_bridge",
|
||||
name="pose_info_bridge",
|
||||
output="screen",
|
||||
arguments=[f"{_pi.gz_topic}@{_pi.ros_type}[{_pi.gz_type}"],
|
||||
# gz 的话题名固定是 /world/<world>/pose/info(命名空间由 gz 决定,
|
||||
# 应用侧改不了),ros_topic 只是"到 ROS 这一侧叫什么"。
|
||||
# ros_gz_bridge 没有改名能力,所以这里用 remap 把名字对到
|
||||
# scene.yaml 声明的那一个 —— 两侧对不上的表现是
|
||||
# "桥接器起来了但一个消息都没有",那是个假故障。
|
||||
remappings=[(_pi.gz_topic, _pi.ros_topic)],
|
||||
)
|
||||
)
|
||||
actions.append(
|
||||
LogInfo(
|
||||
msg=f"[gazebo_sim] 世界模型位姿回读: {_pi.gz_topic} → {_pi.ros_topic}"
|
||||
)
|
||||
)
|
||||
|
||||
# --- 4) 相机:桥接 → 适配器 → (世界相机)补 TF --------------------------
|
||||
# 只在挂了传感器时才做。--no-sensors 时跳过是必要的:适配器会去订阅
|
||||
# 永远不存在的 gz 话题,它**不会报错**,只是静默没输出 —— 而"相机没图"
|
||||
# 正是最需要快速定位的问题,不该被一个假故障混淆。
|
||||
if with_sensors != "false":
|
||||
from arm_sim_gazebo.sim_cameras import (
|
||||
DEFAULT_CAMERAS_REL,
|
||||
CameraConfigError,
|
||||
bridge_args,
|
||||
check_camera_consistency,
|
||||
load_cameras,
|
||||
rpy_to_quat,
|
||||
)
|
||||
|
||||
try:
|
||||
specs = load_cameras(root=root)
|
||||
except CameraConfigError as exc:
|
||||
raise RuntimeError(f"\n[gazebo_sim] 仿真相机清单有问题:\n{exc}\n") from exc
|
||||
|
||||
# 一致性:内参由 cameras.yaml 的 horizontal_fov 与 width/height 算,
|
||||
# 而**渲染**用的是 SDF 里的值。两者不一致时图像与内参对不上,
|
||||
# 三维点位会整体错 —— 而单目预览完全看不出来(gz 自己发的
|
||||
# camera_info 本来就是错的,所以这份内参是"唯一真相",更要保证它与渲染一致)。
|
||||
# 图幅尤其要查:它决定 fx 的尺度,写错会让孔检测的像素门槛静默失效。
|
||||
# 这里只告警不拦截:真有偏差时,仍应能起仿真去现场比对。
|
||||
_cam_issues = check_camera_consistency(
|
||||
specs,
|
||||
world_file=Path(world_path),
|
||||
sensors_xacro=Path(root)
|
||||
/ "devices/sim/gazebo/urdf/arm.sim.sensors.xacro",
|
||||
)
|
||||
# ⚠ 这里用 LogInfo 而不是 LogWarn:Humble 的 launch.actions 里没有
|
||||
# LogWarn(Iron 才加),照抄新版本会直接 ImportError 让整个 launch
|
||||
# 加载失败 —— 一个"只是想说句话"的动作把仿真起不来,代价太大。
|
||||
# 前缀里带 ⚠ 保证它在满屏日志里仍然显眼。
|
||||
for _w in _cam_issues:
|
||||
actions.append(LogInfo(msg=f"[gazebo_sim] ⚠ {_w}"))
|
||||
|
||||
# 4a) 世界相机的 TF 兜底
|
||||
#
|
||||
# Gazebo **不会**为 world 里的 <model> 产生 ROS TF —— TF 只来自
|
||||
# robot_state_publisher(也就是只覆盖 URDF 里的东西)。
|
||||
# D435 正属于这一类:它的物理归属是"世界"(固定支架),不在 URDF 里
|
||||
# (早期挂在 URDF 的 world link 上时,被 sdformat 连整棵子树静默丢掉)。
|
||||
# 少了这条 TF,应用 tf2 查询直接报 "frame does not exist" ——
|
||||
# 而图像本身完全正常,很容易误判成相机坏了。
|
||||
#
|
||||
# 位姿从 world 文件**解析**,不在这里重复配置:唯一真相就是 world 文件。
|
||||
# 另配一份等于制造"改了 world 忘了改这里",而症状是相机看到的东西
|
||||
# 与它自称的位置对不上,极难查。
|
||||
world_tf = []
|
||||
for s in specs:
|
||||
if not s.is_world_mounted:
|
||||
continue
|
||||
try:
|
||||
(px, py, pz), (rr, rp, ry) = model_link_pose(
|
||||
Path(world_path), s.world_model, s.world_link
|
||||
)
|
||||
except SimDescriptionError as exc:
|
||||
raise RuntimeError(
|
||||
f"\n[gazebo_sim] 世界相机 {s.ros_ns} 的 TF 补不出来:\n{exc}\n"
|
||||
) from exc
|
||||
qx, qy, qz, qw = rpy_to_quat((rr, rp, ry))
|
||||
world_tf.append(
|
||||
Node(
|
||||
package="tf2_ros",
|
||||
executable="static_transform_publisher",
|
||||
name=f"tf_{s.ros_ns}",
|
||||
output="screen",
|
||||
arguments=[
|
||||
"--x", f"{px}", "--y", f"{py}", "--z", f"{pz}",
|
||||
"--qx", f"{qx}", "--qy", f"{qy}", "--qz", f"{qz}", "--qw", f"{qw}",
|
||||
"--frame-id", "world",
|
||||
"--child-frame-id", s.link_frame,
|
||||
],
|
||||
parameters=[{"use_sim_time": True}],
|
||||
)
|
||||
)
|
||||
actions.append(
|
||||
LogInfo(
|
||||
msg=f"[gazebo_sim] 世界相机 {s.ros_ns}: world → {s.link_frame} "
|
||||
f"@ ({px:.3f}, {py:.3f}, {pz:.3f}) 由 world 文件解析得到"
|
||||
)
|
||||
)
|
||||
if world_tf:
|
||||
# 延后一点再发:/clock 还没起来时发布静态 TF 可能被 tf2 判成
|
||||
# "未来时间戳",而这一类问题只在很后面才以 extrapolation 报出来。
|
||||
actions.append(TimerAction(period=2.0, actions=world_tf))
|
||||
|
||||
# 4b) 桥接:只做传输(gz 消息类型 → ROS 消息类型,话题名照抄)。
|
||||
# 改名、坐标系、深度单位都交给 4c 的适配器 —— 桥接器改不了 frame_id,
|
||||
# 而 frame_id 恰恰是最容易错又最不报错的那一项。
|
||||
actions.append(
|
||||
Node(
|
||||
package="ros_gz_bridge",
|
||||
executable="parameter_bridge",
|
||||
name="camera_bridge",
|
||||
output="screen",
|
||||
arguments=bridge_args(specs),
|
||||
)
|
||||
)
|
||||
|
||||
# 4c) 适配器:盖光学系 frame_id + 深度 32FC1 米 → 16UC1 毫米 + 改成
|
||||
# 应用期望的话题名。详见 sim_camera_adapter.py 顶部。
|
||||
actions.append(
|
||||
Node(
|
||||
package="arm_sim_gazebo",
|
||||
executable="sim_camera_adapter",
|
||||
name="sim_camera_adapter",
|
||||
output="screen",
|
||||
parameters=[
|
||||
{"cameras_file": str(Path(root) / DEFAULT_CAMERAS_REL)},
|
||||
{"use_sim_time": True},
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
# 4d) 触觉:桥接 + 适配器 -------------------------------------------
|
||||
# 链路与相机同构:gz 只负责"有数据",语义(轴向、基线、单位、接触超时)
|
||||
# 全在适配器里做。见 sim_tactile.py / sim_tactile_adapter.py 顶部。
|
||||
from arm_sim_gazebo.sim_tactile import (
|
||||
DEFAULT_TACTILE_REL,
|
||||
TactileConfigError,
|
||||
)
|
||||
from arm_sim_gazebo.sim_tactile import bridge_args as tactile_bridge_args
|
||||
from arm_sim_gazebo.sim_tactile import load_tactile
|
||||
|
||||
try:
|
||||
tac = load_tactile(root=root)
|
||||
except TactileConfigError as exc:
|
||||
raise RuntimeError(f"\n[gazebo_sim] 仿真触觉配置有问题:\n{exc}\n") from exc
|
||||
|
||||
# 描述级静态检查:接触传感器引用的 collision 名是 sdformat **自动生成**的,
|
||||
# 换个 sdformat 版本就可能变,而变了之后传感器只是"永远没输出"、不报错。
|
||||
# 这里把它和"传感器挂错关节""话题写在错误的层级""传感器被整个丢掉"
|
||||
# 一起在启动时报出来。expected_topics 用配置里声明的话题做对照 ——
|
||||
# 这是唯一能发现"传感器被静默丢弃"的办法。
|
||||
for _w in check_tactile_collisions(
|
||||
urdf,
|
||||
expected_topics=[s.wrench_topic for s in tac.sides]
|
||||
+ [s.contact_topic for s in tac.sides],
|
||||
):
|
||||
actions.append(LogInfo(msg=f"[gazebo_sim] ⚠ 触觉描述检查: {_w}"))
|
||||
|
||||
actions.append(
|
||||
Node(
|
||||
package="ros_gz_bridge",
|
||||
executable="parameter_bridge",
|
||||
name="tactile_bridge",
|
||||
output="screen",
|
||||
arguments=tactile_bridge_args(tac),
|
||||
)
|
||||
)
|
||||
actions.append(
|
||||
Node(
|
||||
package="arm_sim_gazebo",
|
||||
executable="sim_tactile_adapter",
|
||||
name="sim_tactile_adapter",
|
||||
output="screen",
|
||||
parameters=[
|
||||
{"tactile_file": str(Path(root) / DEFAULT_TACTILE_REL)},
|
||||
{"use_sim_time": True},
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
# 4e) 腕部 FT:只需桥接,**不需要适配器** ------------------------------
|
||||
# 与触觉不同:触觉要轴向映射/基线/单位换算/contact 折算,所以有适配器;
|
||||
# 腕部 FT 的 gz 输出本来就是牛顿、坐标系就是 child(link7) 系,而
|
||||
# ros_gz_bridge **原生支持** gz.msgs.Wrench → WrenchStamped(实测确认)。
|
||||
# 于是仿真产出的就是真机同名的 /<sn>/ft_sensor_wrench(同名同类型同单位),
|
||||
# 应用层一行分支都不用写。少一层节点就少一个"配错了不报错"的地方。
|
||||
if with_sensors != "false":
|
||||
for _w in check_wrist_ft_sensor(urdf, wft):
|
||||
actions.append(LogInfo(msg=f"[gazebo_sim] ⚠ 腕部 FT 检查: {_w}"))
|
||||
|
||||
actions.append(
|
||||
Node(
|
||||
package="ros_gz_bridge",
|
||||
executable="parameter_bridge",
|
||||
name="wrist_ft_bridge",
|
||||
output="screen",
|
||||
arguments=wrist_ft_bridge_args(wft),
|
||||
)
|
||||
)
|
||||
|
||||
# --- 5) spawn 机器人 -----------------------------------------------------
|
||||
# -world 必须给对:world 名写错时 Gazebo **不报错**,只是实体没被创建,
|
||||
# 表现成"launch 一切正常但 RViz 里什么都没有"。
|
||||
actions.append(
|
||||
TimerAction(
|
||||
period=SPAWN_DELAY,
|
||||
actions=[
|
||||
Node(
|
||||
package="ros_gz_sim",
|
||||
executable="create",
|
||||
name="spawn_robot",
|
||||
output="screen",
|
||||
arguments=[
|
||||
"-world", wname,
|
||||
"-name", "rizon_sim",
|
||||
"-topic", "robot_description",
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
# --- 5b) spawn 场景物体(工件 / 通止规)---------------------------------
|
||||
# 场景物体**不进 world 文件**,而是在这里 spawn:
|
||||
# world 放"固定不动"的东西(地面、台子、支架相机),
|
||||
# 物料(工件、规)会按站点/工序换位置,写进 world 就等于每次换料都改 world,
|
||||
# 而 world 里还有物理参数与插件,动它的风险面太大(见 assembly.world 的注释)。
|
||||
#
|
||||
# ⚠️ 必须**等机器人 spawn 完**再 spawn 场景(这里再延后 2s):
|
||||
# 两者都走 /world/<名>/create 服务,同时打会争同一个服务,
|
||||
# 重试窗口内失败的那个**不报错**(create 打印 OK 但实体没进),
|
||||
# 表现成"工件时有时无",非常难查。
|
||||
if with_scene != "false":
|
||||
from arm_sim_gazebo.sim_cameras import intrinsics as _intrinsics
|
||||
from arm_sim_gazebo.sim_scene import (
|
||||
SceneError,
|
||||
check_pixel_gates,
|
||||
load_scene,
|
||||
operating_flange_z,
|
||||
operating_pixel_report,
|
||||
spawn_commands,
|
||||
validate_scene,
|
||||
)
|
||||
|
||||
try:
|
||||
scene = load_scene(
|
||||
Path(scene_file) if scene_file else None, root=root
|
||||
)
|
||||
except SceneError as exc:
|
||||
raise RuntimeError(f"\n[gazebo_sim] 场景配置有问题:\n{exc}\n") from exc
|
||||
|
||||
actions.append(LogInfo(msg=f"[gazebo_sim] 场景: {scene.source}"))
|
||||
|
||||
# 摆放校验:悬空 / 穿模 / 出界 / 互相重叠 —— 全是"看起来正常、跑起来不对"
|
||||
# 的那一类。这里只告警不拦截:真有问题时仍需能起仿真去看现场。
|
||||
for _p in validate_scene(scene, root):
|
||||
actions.append(LogInfo(msg=f"[gazebo_sim] ⚠ 场景摆放检查: {_p}"))
|
||||
|
||||
scene_nodes = []
|
||||
for args in spawn_commands(scene, root):
|
||||
name = args[args.index("-name") + 1]
|
||||
scene_nodes.append(
|
||||
Node(
|
||||
package="ros_gz_sim",
|
||||
executable="create",
|
||||
name=f"spawn_{name}",
|
||||
output="screen",
|
||||
arguments=["-world", wname] + args,
|
||||
)
|
||||
)
|
||||
if scene_nodes:
|
||||
actions.append(TimerAction(period=SPAWN_DELAY + 2.0, actions=scene_nodes))
|
||||
|
||||
# 成像几何自检:把"该被检测到的特征"在这台相机下换算成像素,
|
||||
# 与门槛比一遍。这是**唯一**能在上真机之前发现"孔间距不够像素门槛"
|
||||
# 的办法 —— 等真机上才发现要贵得多。
|
||||
cam = next((s for s in specs if s.ros_ns == scene.operating.camera), None) if with_sensors != "false" else None
|
||||
if cam is not None:
|
||||
_fx = _intrinsics(cam, cam.width, cam.height)[0][0]
|
||||
px = operating_pixel_report(scene, _fx, root)
|
||||
if px:
|
||||
actions.append(
|
||||
LogInfo(
|
||||
msg="[gazebo_sim] 孔检测几何自检(相机 {},到工件 {:.2f}m,fx={:.1f}px):\n".format(
|
||||
scene.operating.camera, scene.operating.camera_to_part_m, _fx
|
||||
)
|
||||
+ "\n".join(f" {k} = {v:.1f}px" for k, v in sorted(px.items()))
|
||||
)
|
||||
)
|
||||
for _p in check_pixel_gates(px, scene.gates_px):
|
||||
actions.append(LogInfo(msg=f"[gazebo_sim] ⚠ 孔检测几何: {_p}"))
|
||||
_fz = operating_flange_z(scene, root)
|
||||
if _fz is not None:
|
||||
actions.append(
|
||||
LogInfo(
|
||||
msg=f"[gazebo_sim] 二阶段在仿真里应悬停到法兰 z≈{_fz:.3f}m"
|
||||
f"(真机 site.yaml 写的是 0.23 —— 两者不同是**已知**的,"
|
||||
f"原因是两边「基座与台面的相对高度」不同;"
|
||||
f"决定成像的是相机到工件的距离,见 scene.yaml 的 operating 段)"
|
||||
)
|
||||
)
|
||||
else:
|
||||
actions.append(
|
||||
LogInfo(
|
||||
msg="[gazebo_sim] 未做孔检测几何自检(缺相机清单,或 --no-sensors)"
|
||||
)
|
||||
)
|
||||
|
||||
# --- 6) 控制器 -----------------------------------------------------------
|
||||
# 用 joint_trajectory_controller 而不是直接给关节发位置:应用层统一按
|
||||
# rizon_arm_controller/follow_joint_trajectory 发轨迹,这个接口名与真机一致。
|
||||
controllers = [
|
||||
TimerAction(
|
||||
period=CONTROLLER_DELAY + i * 2.0,
|
||||
actions=[
|
||||
Node(
|
||||
package="controller_manager",
|
||||
executable="spawner",
|
||||
name=f"spawn_{name}",
|
||||
output="screen",
|
||||
arguments=[
|
||||
name,
|
||||
"--controller-manager", "/controller_manager",
|
||||
# controller_manager 在 Gazebo 进程里,它是 sim time;
|
||||
# spawner 也要用 sim time,否则两者对时不同源。
|
||||
"--controller-manager-timeout", "120",
|
||||
],
|
||||
parameters=[{"use_sim_time": True}],
|
||||
)
|
||||
],
|
||||
)
|
||||
for i, name in enumerate(CONTROLLER_NAMES)
|
||||
]
|
||||
actions.extend(controllers)
|
||||
|
||||
# --- 6b) 仿真夹爪适配器 --------------------------------------------------
|
||||
# 为什么必须有它:sdformat 把 URDF 的 <mimic> 整个丢掉,gz-sim 也不实现,
|
||||
# 所以 6 个从动关节不会跟随主关节。适配器按耦合公式算出 7 个关节的目标值
|
||||
# 一起下发,并对外提供与真机**同名同类型**的 GripperExecute 服务
|
||||
#(<ns>/open|close|move|rotate + status),上层代码一行都不用改。
|
||||
#
|
||||
# 耦合表来自 report.stripped_mimics —— 也就是 urdf_normalize 删掉 <mimic>
|
||||
# 时记下来的那几张表。让 launch 传给适配器,而不是让适配器自己再展开一次
|
||||
# xacro:两次展开在参数不同时会静默不一致,而症状是"手指角度不对"。
|
||||
if load_gripper != "false":
|
||||
from arm_sim_gazebo.sim_gripper import format_mimic_param
|
||||
|
||||
mimics = [format_mimic_param(*t) for t in report.stripped_mimics]
|
||||
if not mimics:
|
||||
# 这里必须出声:拿不到耦合表说明夹爪没被加载(或上游改了耦合方式),
|
||||
# 而适配器会因此拒绝启动 —— 早知道比晚知道好。
|
||||
actions.append(
|
||||
LogInfo(
|
||||
msg="[gazebo_sim] ⚠ 归一化报告里没有 mimic 关系,"
|
||||
"仿真夹爪适配器将无法启动(它会明确报错)。"
|
||||
"检查 load_gripper 与 flexiv_description 的 grav 宏。"
|
||||
)
|
||||
)
|
||||
actions.append(
|
||||
TimerAction(
|
||||
# 等 gripper_controller 起来再发命令:控制器还没 active 时
|
||||
# 发出去的 /commands 会被丢弃(不报错),夹爪就一直停在初始位。
|
||||
period=CONTROLLER_DELAY + len(CONTROLLER_NAMES) * 2.0 + 1.0,
|
||||
actions=[
|
||||
Node(
|
||||
package="arm_sim_gazebo",
|
||||
executable="sim_gripper_adapter",
|
||||
name="sim_gripper_adapter",
|
||||
output="screen",
|
||||
parameters=[
|
||||
{
|
||||
"controllers_file": str(default_controllers(root)),
|
||||
"controller": "gripper_controller",
|
||||
"mimics": mimics,
|
||||
"robot_description": urdf,
|
||||
"namespace": "/gripper",
|
||||
# 抓取锁(仿真专用,见 sim_grasp_lock.py):
|
||||
# 夹住后把物体固定到手指上,避免"位置控制夹持 +
|
||||
# 连续轨迹"导致的打滑掉件。
|
||||
"grasp_lock_enabled": bool(glock.enabled),
|
||||
"grasp_lock_object": str(glock.object),
|
||||
"grasp_lock_attach_topic": str(glock.attach_topic),
|
||||
"grasp_lock_detach_topic": str(glock.detach_topic),
|
||||
"grasp_lock_timeout_ms": int(glock.publish_timeout_ms),
|
||||
},
|
||||
{"use_sim_time": True},
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
# --- 6c) 机械臂的"应用接口"对齐真机 ---------------------------------------
|
||||
# 仿真里唯一的控制通路是 ros2_control 的 JTC(control_msgs)。但应用层在
|
||||
# 真机上用的是另一层接口 —— 这里把它们**按真机的名字与类型**补齐,
|
||||
# 于是通止规装配(以及 stage2/stage3)在仿真里能不改一行代码就跑:
|
||||
#
|
||||
# /follow_joint_trajectory arm_msgs/FollowJointTrajectory → JTC
|
||||
# 真机由 real_trajectory_adapter 提供(名字带 real,但实现只做 action
|
||||
# 桥接、与硬件无关;这里**复用同一个节点**而不是再写一份 ——
|
||||
# 两份实现迟早会不一致)。
|
||||
# /set_named_pose arm_msgs/SetNamedPose(命名位姿)→ 上面那个
|
||||
# 真机由 arm_bringup/stack.launch.py 起,仿真起同一份,只是把
|
||||
# joint_prefix 换成 sim_(关节名不同,点位数值是同一台机器人)。
|
||||
# /home std_srvs/Trigger
|
||||
# 真机由 flexiv_arm_driver 提供(= 切工具系 + 转 /set_named_pose)。
|
||||
# 仿真没有 Flexiv 驱动,用 sim_home_service 补同一份契约(不切工具系)。
|
||||
actions.append(
|
||||
Node(
|
||||
package="arm_control",
|
||||
executable="real_trajectory_adapter",
|
||||
name="real_trajectory_adapter",
|
||||
output="screen",
|
||||
parameters=[{"use_sim_time": True}],
|
||||
)
|
||||
)
|
||||
actions.append(
|
||||
Node(
|
||||
package="arm_control",
|
||||
executable="named_pose_server",
|
||||
name="named_pose_server",
|
||||
output="screen",
|
||||
parameters=[
|
||||
# 命名位姿用**包内默认那份**(src/arm_control/config/named_poses.yaml):
|
||||
# 它只有 home/ready/inspect 这几个点,与"仿真里是哪台臂"无关;
|
||||
# 真机走 site.yaml 的 robots[].paths.named_poses_file(那份带该臂的点位)。
|
||||
str(root / "src" / "arm_control" / "config" / "named_poses.yaml"),
|
||||
# 关节名:仿真里是 sim_joint1..7(前缀来自 urdf 的 arm_prefix,
|
||||
# site.yaml 的 sim.joint_prefix 目前是"声明了但没接线",见那一段注释)。
|
||||
{"joint_prefix": "sim_"},
|
||||
{"use_sim_time": True},
|
||||
],
|
||||
)
|
||||
)
|
||||
actions.append(
|
||||
Node(
|
||||
package="arm_sim_gazebo",
|
||||
executable="sim_home_service",
|
||||
name="sim_home_service",
|
||||
output="screen",
|
||||
parameters=[{"use_sim_time": True}],
|
||||
)
|
||||
)
|
||||
|
||||
actions.append(
|
||||
LogInfo(
|
||||
msg=(
|
||||
"[gazebo_sim] 启动中。验收命令:\n"
|
||||
" ros2 topic echo /clock --once # 仿真时间在走\n"
|
||||
" ros2 control list_controllers # 两个控制器 active\n"
|
||||
" ros2 topic echo /joint_states --once # 7 个 sim_joint\n"
|
||||
" ros2 run tf2_ros tf2_echo world sim_tcp # 末端位姿可查\n"
|
||||
" ros2 topic echo /tactile/left/status --once # 触觉三力在发\n"
|
||||
" ign model -m workpiece -p # 工件在台面上(核对摆放)\n"
|
||||
" ign model -m gauge -p # 通止规立在台面上\n"
|
||||
" python3 scripts/check_sim_cameras.py # ★ 相机的真正验收\n"
|
||||
" python3 scripts/check_sim_scene.py # ★ 场景摆放 + 孔检测几何验收\n"
|
||||
" 机械臂应在重力下**保持站住**。若它瘫下去或消失,说明描述有问题,"
|
||||
"参考 arm_sim_gazebo/urdf_normalize.py 顶部列的两个上游缺陷。\n"
|
||||
" ★ 别用 'ros2 topic hz' 或肉眼预览来验收相机 —— 那些只能证明'有图像'。\n"
|
||||
" 仿真相机的错误几乎全是静默的(深度单位差 1000 倍、frame_id 盖成\n"
|
||||
" 本体系、gz 发的内参是错的、位姿 rpy 写错导致相机背对目标),\n"
|
||||
" 它们全都能出图、能查到 TF。必须用 check_sim_cameras.py 反投影\n"
|
||||
" 到 world、检查是否命中地面/台面,才算通过。\n"
|
||||
" ⚠ 深度话题是 16UC1 毫米(已从 gz 的 32FC1 米转换过)。\n"
|
||||
" 内参也不是 gz 发的那份:gz 恒发 fx=277/cx=160/cy=120(它的默认\n"
|
||||
" 60° 视场 + 半分辨率主点),适配器已按 cameras.yaml 的视场重算覆盖。\n"
|
||||
" 所以改相机视场要同时改 cameras.yaml 和 SDF,启动时会自动比对告警。"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return actions
|
||||
|
||||
|
||||
def generate_launch_description():
|
||||
return LaunchDescription(
|
||||
[
|
||||
DeclareLaunchArgument(
|
||||
"world",
|
||||
default_value="",
|
||||
description="世界文件绝对路径。留空=用仓库里默认的 assembly.world",
|
||||
),
|
||||
DeclareLaunchArgument(
|
||||
"load_gripper",
|
||||
default_value="true",
|
||||
description="是否挂 GN01 夹爪。装配任务需要 true",
|
||||
),
|
||||
DeclareLaunchArgument(
|
||||
"with_sensors",
|
||||
default_value="true",
|
||||
description="是否挂 D405 与触觉。关掉跑得更快,用于纯运动学验证",
|
||||
),
|
||||
DeclareLaunchArgument(
|
||||
"with_scene",
|
||||
default_value="true",
|
||||
description="是否 spawn 场景物料(工件/通止规)。false=只用空台面",
|
||||
),
|
||||
DeclareLaunchArgument(
|
||||
"scene",
|
||||
default_value="",
|
||||
description="场景配置文件路径。留空=用 devices/sim/gazebo/config/scene.yaml",
|
||||
),
|
||||
DeclareLaunchArgument(
|
||||
"headless",
|
||||
default_value="true",
|
||||
description="true=只开 Gazebo 服务端(无显示器环境用);false=带 GUI",
|
||||
),
|
||||
DeclareLaunchArgument(
|
||||
"verbose",
|
||||
default_value="false",
|
||||
description="true=打开 Gazebo 详细日志(排查插件加载失败时开)",
|
||||
),
|
||||
OpaqueFunction(function=_prepare),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
# generated from colcon_bash/shell/template/package.bash.em
|
||||
|
||||
# This script extends the environment for this package.
|
||||
|
||||
# a bash script is able to determine its own path if necessary
|
||||
if [ -z "$COLCON_CURRENT_PREFIX" ]; then
|
||||
# the prefix is two levels up from the package specific share directory
|
||||
_colcon_package_bash_COLCON_CURRENT_PREFIX="$(builtin cd "`dirname "${BASH_SOURCE[0]}"`/../.." > /dev/null && pwd)"
|
||||
else
|
||||
_colcon_package_bash_COLCON_CURRENT_PREFIX="$COLCON_CURRENT_PREFIX"
|
||||
fi
|
||||
|
||||
# function to source another script with conditional trace output
|
||||
# first argument: the path of the script
|
||||
# additional arguments: arguments to the script
|
||||
_colcon_package_bash_source_script() {
|
||||
if [ -f "$1" ]; then
|
||||
if [ -n "$COLCON_TRACE" ]; then
|
||||
echo "# . \"$1\""
|
||||
fi
|
||||
. "$@"
|
||||
else
|
||||
echo "not found: \"$1\"" 1>&2
|
||||
fi
|
||||
}
|
||||
|
||||
# source sh script of this package
|
||||
_colcon_package_bash_source_script "$_colcon_package_bash_COLCON_CURRENT_PREFIX/share/arm_sim_gazebo/package.sh"
|
||||
|
||||
unset _colcon_package_bash_source_script
|
||||
unset _colcon_package_bash_COLCON_CURRENT_PREFIX
|
||||
@@ -0,0 +1,6 @@
|
||||
source;share/arm_sim_gazebo/hook/pythonpath.ps1
|
||||
source;share/arm_sim_gazebo/hook/pythonpath.dsv
|
||||
source;share/arm_sim_gazebo/hook/pythonpath.sh
|
||||
source;share/arm_sim_gazebo/hook/ament_prefix_path.ps1
|
||||
source;share/arm_sim_gazebo/hook/ament_prefix_path.dsv
|
||||
source;share/arm_sim_gazebo/hook/ament_prefix_path.sh
|
||||
@@ -0,0 +1,116 @@
|
||||
# generated from colcon_powershell/shell/template/package.ps1.em
|
||||
|
||||
# function to append a value to a variable
|
||||
# which uses colons as separators
|
||||
# duplicates as well as leading separators are avoided
|
||||
# first argument: the name of the result variable
|
||||
# second argument: the value to be prepended
|
||||
function colcon_append_unique_value {
|
||||
param (
|
||||
$_listname,
|
||||
$_value
|
||||
)
|
||||
|
||||
# get values from variable
|
||||
if (Test-Path Env:$_listname) {
|
||||
$_values=(Get-Item env:$_listname).Value
|
||||
} else {
|
||||
$_values=""
|
||||
}
|
||||
$_duplicate=""
|
||||
# start with no values
|
||||
$_all_values=""
|
||||
# iterate over existing values in the variable
|
||||
if ($_values) {
|
||||
$_values.Split(":") | ForEach {
|
||||
# not an empty string
|
||||
if ($_) {
|
||||
# not a duplicate of _value
|
||||
if ($_ -eq $_value) {
|
||||
$_duplicate="1"
|
||||
}
|
||||
if ($_all_values) {
|
||||
$_all_values="${_all_values}:$_"
|
||||
} else {
|
||||
$_all_values="$_"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# append only non-duplicates
|
||||
if (!$_duplicate) {
|
||||
# avoid leading separator
|
||||
if ($_all_values) {
|
||||
$_all_values="${_all_values}:${_value}"
|
||||
} else {
|
||||
$_all_values="${_value}"
|
||||
}
|
||||
}
|
||||
|
||||
# export the updated variable
|
||||
Set-Item env:\$_listname -Value "$_all_values"
|
||||
}
|
||||
|
||||
# function to prepend a value to a variable
|
||||
# which uses colons as separators
|
||||
# duplicates as well as trailing separators are avoided
|
||||
# first argument: the name of the result variable
|
||||
# second argument: the value to be prepended
|
||||
function colcon_prepend_unique_value {
|
||||
param (
|
||||
$_listname,
|
||||
$_value
|
||||
)
|
||||
|
||||
# get values from variable
|
||||
if (Test-Path Env:$_listname) {
|
||||
$_values=(Get-Item env:$_listname).Value
|
||||
} else {
|
||||
$_values=""
|
||||
}
|
||||
# start with the new value
|
||||
$_all_values="$_value"
|
||||
# iterate over existing values in the variable
|
||||
if ($_values) {
|
||||
$_values.Split(":") | ForEach {
|
||||
# not an empty string
|
||||
if ($_) {
|
||||
# not a duplicate of _value
|
||||
if ($_ -ne $_value) {
|
||||
# keep non-duplicate values
|
||||
$_all_values="${_all_values}:$_"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# export the updated variable
|
||||
Set-Item env:\$_listname -Value "$_all_values"
|
||||
}
|
||||
|
||||
# function to source another script with conditional trace output
|
||||
# first argument: the path of the script
|
||||
# additional arguments: arguments to the script
|
||||
function colcon_package_source_powershell_script {
|
||||
param (
|
||||
$_colcon_package_source_powershell_script
|
||||
)
|
||||
# source script with conditional trace output
|
||||
if (Test-Path $_colcon_package_source_powershell_script) {
|
||||
if ($env:COLCON_TRACE) {
|
||||
echo ". '$_colcon_package_source_powershell_script'"
|
||||
}
|
||||
. "$_colcon_package_source_powershell_script"
|
||||
} else {
|
||||
Write-Error "not found: '$_colcon_package_source_powershell_script'"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# a powershell script is able to determine its own path
|
||||
# the prefix is two levels up from the package specific share directory
|
||||
$env:COLCON_CURRENT_PREFIX=(Get-Item $PSCommandPath).Directory.Parent.Parent.FullName
|
||||
|
||||
colcon_package_source_powershell_script "$env:COLCON_CURRENT_PREFIX/share/arm_sim_gazebo/hook/pythonpath.ps1"
|
||||
colcon_package_source_powershell_script "$env:COLCON_CURRENT_PREFIX/share/arm_sim_gazebo/hook/ament_prefix_path.ps1"
|
||||
|
||||
Remove-Item Env:\COLCON_CURRENT_PREFIX
|
||||
@@ -0,0 +1,87 @@
|
||||
# generated from colcon_core/shell/template/package.sh.em
|
||||
|
||||
# This script extends the environment for this package.
|
||||
|
||||
# function to prepend a value to a variable
|
||||
# which uses colons as separators
|
||||
# duplicates as well as trailing separators are avoided
|
||||
# first argument: the name of the result variable
|
||||
# second argument: the value to be prepended
|
||||
_colcon_prepend_unique_value() {
|
||||
# arguments
|
||||
_listname="$1"
|
||||
_value="$2"
|
||||
|
||||
# get values from variable
|
||||
eval _values=\"\$$_listname\"
|
||||
# backup the field separator
|
||||
_colcon_prepend_unique_value_IFS=$IFS
|
||||
IFS=":"
|
||||
# start with the new value
|
||||
_all_values="$_value"
|
||||
# workaround SH_WORD_SPLIT not being set in zsh
|
||||
if [ "$(command -v colcon_zsh_convert_to_array)" ]; then
|
||||
colcon_zsh_convert_to_array _values
|
||||
fi
|
||||
# iterate over existing values in the variable
|
||||
for _item in $_values; do
|
||||
# ignore empty strings
|
||||
if [ -z "$_item" ]; then
|
||||
continue
|
||||
fi
|
||||
# ignore duplicates of _value
|
||||
if [ "$_item" = "$_value" ]; then
|
||||
continue
|
||||
fi
|
||||
# keep non-duplicate values
|
||||
_all_values="$_all_values:$_item"
|
||||
done
|
||||
unset _item
|
||||
# restore the field separator
|
||||
IFS=$_colcon_prepend_unique_value_IFS
|
||||
unset _colcon_prepend_unique_value_IFS
|
||||
# export the updated variable
|
||||
eval export $_listname=\"$_all_values\"
|
||||
unset _all_values
|
||||
unset _values
|
||||
|
||||
unset _value
|
||||
unset _listname
|
||||
}
|
||||
|
||||
# since a plain shell script can't determine its own path when being sourced
|
||||
# either use the provided COLCON_CURRENT_PREFIX
|
||||
# or fall back to the build time prefix (if it exists)
|
||||
_colcon_package_sh_COLCON_CURRENT_PREFIX="/home/cc/ros2/install_sim/arm_sim_gazebo"
|
||||
if [ -z "$COLCON_CURRENT_PREFIX" ]; then
|
||||
if [ ! -d "$_colcon_package_sh_COLCON_CURRENT_PREFIX" ]; then
|
||||
echo "The build time path \"$_colcon_package_sh_COLCON_CURRENT_PREFIX\" doesn't exist. Either source a script for a different shell or set the environment variable \"COLCON_CURRENT_PREFIX\" explicitly." 1>&2
|
||||
unset _colcon_package_sh_COLCON_CURRENT_PREFIX
|
||||
return 1
|
||||
fi
|
||||
COLCON_CURRENT_PREFIX="$_colcon_package_sh_COLCON_CURRENT_PREFIX"
|
||||
fi
|
||||
unset _colcon_package_sh_COLCON_CURRENT_PREFIX
|
||||
|
||||
# function to source another script with conditional trace output
|
||||
# first argument: the path of the script
|
||||
# additional arguments: arguments to the script
|
||||
_colcon_package_sh_source_script() {
|
||||
if [ -f "$1" ]; then
|
||||
if [ -n "$COLCON_TRACE" ]; then
|
||||
echo "# . \"$1\""
|
||||
fi
|
||||
. "$@"
|
||||
else
|
||||
echo "not found: \"$1\"" 1>&2
|
||||
fi
|
||||
}
|
||||
|
||||
# source sh hooks
|
||||
_colcon_package_sh_source_script "$COLCON_CURRENT_PREFIX/share/arm_sim_gazebo/hook/pythonpath.sh"
|
||||
_colcon_package_sh_source_script "$COLCON_CURRENT_PREFIX/share/arm_sim_gazebo/hook/ament_prefix_path.sh"
|
||||
|
||||
unset _colcon_package_sh_source_script
|
||||
unset COLCON_CURRENT_PREFIX
|
||||
|
||||
# do not unset _colcon_prepend_unique_value since it might be used by non-primary shell hooks
|
||||
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0"?>
|
||||
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||
<package format="3">
|
||||
<name>arm_sim_gazebo</name>
|
||||
<version>0.1.0</version>
|
||||
<description>
|
||||
Gazebo 仿真后端。兑现 devices/sim/gazebo/manifest.yaml 里声明的
|
||||
package: arm_sim_gazebo / executable: gazebo_sim_launcher。
|
||||
|
||||
职责:
|
||||
1. 把 devices/sim/gazebo/urdf 下的仿真描述展开成 URDF,
|
||||
并归一化成 Gazebo 能接受的形态(见 urdf_normalize.py 的长注释)
|
||||
2. 起 Gazebo、spawn 机械臂、拉控制器、拉 ros_gz 桥
|
||||
3. 把应用层需要的接口对齐成与真机/mock 完全一致
|
||||
(控制器名 rizon_arm_controller、服务与话题名不变),
|
||||
这样应用层代码不需要知道后端是谁
|
||||
|
||||
与真机环境严格隔离:本包只在 ros_gz_sim 环境构建(scripts/build_sim.sh),
|
||||
真机环境不装它。反过来它也不依赖任何真机专属包。
|
||||
</description>
|
||||
<maintainer email="dev@example.com">arm_dev</maintainer>
|
||||
<license>Apache-2.0</license>
|
||||
|
||||
<exec_depend>rclpy</exec_depend>
|
||||
<exec_depend>launch</exec_depend>
|
||||
<exec_depend>launch_ros</exec_depend>
|
||||
<exec_depend>ament_index_python</exec_depend>
|
||||
<exec_depend>robot_state_publisher</exec_depend>
|
||||
<exec_depend>xacro</exec_depend>
|
||||
<!-- Gazebo 侧(仅仿真环境有) -->
|
||||
<exec_depend>ros_gz_sim</exec_depend>
|
||||
<exec_depend>ros_gz_bridge</exec_depend>
|
||||
<!-- 触觉适配器订阅 ros_gz_interfaces/msg/Contacts(接触传感器的原始消息),
|
||||
发布 arm_msgs/msg/TactileStatus。两者都是运行期硬依赖。 -->
|
||||
<exec_depend>ros_gz_interfaces</exec_depend>
|
||||
<exec_depend>arm_msgs</exec_depend>
|
||||
<exec_depend>tf2_ros</exec_depend>
|
||||
<exec_depend>gz_ros2_control</exec_depend>
|
||||
<exec_depend>controller_manager</exec_depend>
|
||||
<exec_depend>joint_state_broadcaster</exec_depend>
|
||||
<exec_depend>joint_trajectory_controller</exec_depend>
|
||||
<!-- 描述与参数 -->
|
||||
<exec_depend>flexiv_description</exec_depend>
|
||||
<exec_depend>arm_control</exec_depend>
|
||||
<exec_depend>arm_application</exec_depend>
|
||||
|
||||
<test_depend>python3-pytest</test_depend>
|
||||
<test_depend>python3-yaml</test_depend>
|
||||
|
||||
<export>
|
||||
<build_type>ament_python</build_type>
|
||||
</export>
|
||||
</package>
|
||||
@@ -0,0 +1,42 @@
|
||||
# generated from colcon_zsh/shell/template/package.zsh.em
|
||||
|
||||
# This script extends the environment for this package.
|
||||
|
||||
# a zsh script is able to determine its own path if necessary
|
||||
if [ -z "$COLCON_CURRENT_PREFIX" ]; then
|
||||
# the prefix is two levels up from the package specific share directory
|
||||
_colcon_package_zsh_COLCON_CURRENT_PREFIX="$(builtin cd -q "`dirname "${(%):-%N}"`/../.." > /dev/null && pwd)"
|
||||
else
|
||||
_colcon_package_zsh_COLCON_CURRENT_PREFIX="$COLCON_CURRENT_PREFIX"
|
||||
fi
|
||||
|
||||
# function to source another script with conditional trace output
|
||||
# first argument: the path of the script
|
||||
# additional arguments: arguments to the script
|
||||
_colcon_package_zsh_source_script() {
|
||||
if [ -f "$1" ]; then
|
||||
if [ -n "$COLCON_TRACE" ]; then
|
||||
echo "# . \"$1\""
|
||||
fi
|
||||
. "$@"
|
||||
else
|
||||
echo "not found: \"$1\"" 1>&2
|
||||
fi
|
||||
}
|
||||
|
||||
# function to convert array-like strings into arrays
|
||||
# to workaround SH_WORD_SPLIT not being set
|
||||
colcon_zsh_convert_to_array() {
|
||||
local _listname=$1
|
||||
local _dollar="$"
|
||||
local _split="{="
|
||||
local _to_array="(\"$_dollar$_split$_listname}\")"
|
||||
eval $_listname=$_to_array
|
||||
}
|
||||
|
||||
# source sh script of this package
|
||||
_colcon_package_zsh_source_script "$_colcon_package_zsh_COLCON_CURRENT_PREFIX/share/arm_sim_gazebo/package.sh"
|
||||
unset convert_zsh_to_array
|
||||
|
||||
unset _colcon_package_zsh_source_script
|
||||
unset _colcon_package_zsh_COLCON_CURRENT_PREFIX
|
||||
@@ -0,0 +1 @@
|
||||
ament_index_python:arm_application:arm_control:arm_msgs:controller_manager:flexiv_description:gz_ros2_control:joint_state_broadcaster:joint_trajectory_controller:launch:launch_ros:rclpy:robot_state_publisher:ros_gz_bridge:ros_gz_interfaces:ros_gz_sim:tf2_ros:xacro
|
||||
Reference in New Issue
Block a user