diff --git a/config/site.example.yaml b/config/site.example.yaml index 327844c..e168114 100644 --- a/config/site.example.yaml +++ b/config/site.example.yaml @@ -480,13 +480,19 @@ network: # 设计约束:仿真与真机共用同一套参数(servo.* / hole_detect.stage2.*), # 只有物理引擎自身的参数放在这里,避免「仿真调好的值搬到真机不生效」。 sim: - # ⚠️ 这一段目前**没有代码读取**(gazebo_sim_launcher 还没实现,见 - # devices/sim/gazebo/manifest.yaml 的进度段)。现在跑 `run_sim.sh` 得到的 - # 仍是 mock 驱动(假数据 + RViz),不是物理仿真。 + # 这一段由 arm_sim_gazebo.sim_site 真正读取(此前是"声明了但没人读", + # 改了毫无反应也不报错)。三个键的接线状况**不一样**,务必看清: # - # 之所以先把 world 填上:世界文件与机器人描述**已经做好并通过 sdformat 校验**, - # 填在这里等于把"参数该从哪来"确定下来,等启动器一接上就自动生效, - # 不用再改配置。路径相对仓库根。 + # world ✔ 已接线。优先级:命令行 --world > 这里 > 内置默认。 + # 相对路径按**仓库根**解析。 + # physics_enable ✘ 未接线。世界文件里的重力/步长是写死的。若这里写的值 + # 与世界的实际状态不符,启动时会打印告警(不再静默)。 + # joint_prefix ✘ 未接线。前缀由 urdf/arm.sim.urdf.xacro 的 arm_prefix + # 决定,且 config/controllers.yaml 里硬编码了 sim_joint1..7。 + # 写了个不同的值会告警。要真改,三处必须一起改。 + # + # 另外注意:`run_sim.sh` 走的是 mock 驱动(假数据 + RViz),**不是**物理仿真; + # 物理仿真入口是 `scripts/run_sim_gz.sh`(Gazebo)。两者互不影响。 platform: "gazebo" world: "devices/sim/gazebo/worlds/assembly.world" physics_enable: true diff --git a/devices/sim/gazebo/README.md b/devices/sim/gazebo/README.md index 9de997f..29c0622 100644 --- a/devices/sim/gazebo/README.md +++ b/devices/sim/gazebo/README.md @@ -1,95 +1,148 @@ # devices/sim/gazebo/ —— Gazebo 仿真平台 -## 先读这段:当前仿真是假的 - -**现在项目里的「仿真」并不是 Gazebo。** 实际情况是: - -- `src/arm_description/urdf/arm.gazebo.xacro` 是零引用的死文件; -- `arm_bringup/stack.launch.py` 的 `isaac` 分支只有一行日志,没有任何实际动作; -- `run_sim.sh` 走的是 `sim.launch.py`,底层是 **mock 驱动**(`mock_arm_driver.py`), - 即用假数据模拟机器人状态,不是物理仿真; -- Gazebo 从未真正接线。 - -所以这份文档分两部分:**第一部分是怎么把 Gazebo 真正跑起来**(当前必须做的前置工作), -**第二部分才是日常怎么用**。不要跳过第一部分。 +> 这份文档记录的是**已经跑通的事实**和**实测踩到的坑**。仿真里绝大多数错误都是 +> 静默的(不报错、能出图、能查到 TF,结果却是错的),所以下面每条坑都写了 +> **怎么验**。改仿真之前先把"坑"那一节看完。 --- -## 第一部分:把 Gazebo 真正接起来(前置工作) +## 先读这段:哪个"仿真"是真的 -要让 Gazebo 跑起来,缺的是这几块,按顺序补齐: +仓库里有两个都叫"仿真"的东西,很容易混: -### 1. URDF 加 Gazebo 标签 +| 入口 | 底层 | 有物理吗 | 用途 | +|------|------|----------|------| +| `scripts/run_sim.sh` | `arm_bringup` 的 **mock 驱动**(`mock_arm_driver.py`) | ✘ 假数据 + RViz | 验证话题/流程/UI,不耗资源 | +| `scripts/run_sim_gz.sh` | **Gazebo Fortress**(真物理、真接触) | ✔ | 验证接触/力控/几何 | -现有 `arm_description/urdf/arm.gazebo.xacro` 是死文件,需要重写。每个 link 必须补: +**要验证装配的接触与力判据,只能用后者。** 前者永远不会产生接触力。 -```xml - - - Gazebo/Grey - -``` +--- -并在 `arm.ros2_control.xacro` 里把 `ros2_control` 标签的 hardware 插件从 -`flexiv_hardware/FlexivHardware` 换成 `gazebo_ros2_control/GazeboSystem`。 +## 第一部分:现状(截至 P5c-4,2026-09-23) -### 2. 关节传动与控制器 +### 已就绪 -Gazebo 需要 `transmission` 标签把关节映射到 `ros2_control`。控制器配置可以直接复用 -`arm_control/config/controllers.yaml` 里的 `rizon_arm_controller`,只需把关节名前缀 -从真实 SN(如 `Rizon4-063989_`)换成仿真专用的前缀(如 `sim_`)。 +| 部分 | 位置 | 说明 | +|------|------|------| +| 运行环境 | `scripts/install_sim_env.sh` / `sim_env.sh` | 独立 conda 环境 `ros_gz_sim`(Gazebo Fortress 6.16 + ROS 2 Humble),与真机环境 `ros_flexiv` 隔离 | +| 世界 | `worlds/assembly.world` | 地面 + 工作台 + D435 + 通止规标记位 | +| 机器人描述 | `urdf/*.xacro` | 复用官方 `flexiv_description`,启动时**归一化**后喂 Gazebo | +| 控制器 | `config/controllers.yaml` | 控制器名与真机一致(`rizon_arm_controller` + `joint_state_broadcaster`) | +| 启动器 | `src/arm_sim_gazebo` 的 `gazebo_sim_launcher` | 起 Gazebo → 发归一化 URDF → spawn → 拉起控制器 | +| 相机 | `config/cameras.yaml` | 两台相机的**唯一真相**:话题、坐标系、视场 | -### 3. 传感器插件 +### 还没做 -| 仿真对象 | 插件 | 对应真实设备 | -|----------|------|--------------| -| 腕部相机 | `libgazebo_ros_camera.so` | D405 | -| 手外相机 | `libgazebo_ros_camera.so` | D435 | -| 深度 | `libgazebo_ros_depth_camera.so` | 对齐深度流 | -| 触觉力 | **需要自己写插件** | Orisys | +| 部分 | 缺什么 | 影响 | +|------|--------|------| +| `models/` | 工件(k0/k1/k2)与通止规模型 | 装配任务(P6)跑不起来 | +| `sensor_plugins/` | Orisys 三向力插件 | **无法验证力控**(S07 接触、S08 拧紧判据) | -**最关键的一条:Orisys 视触觉没有现成的 Gazebo 插件。** -3 路切向力 + 1 路法向力 + 深度图 + 力矢量图 这套数据需要自定义 Gazebo 插件来模拟。 -在它做出来之前,仿真里**无法验证通止规装配的力控部分**(S07 接触、S08 拧紧判定)。 -建议的自定义插件做法:订阅 Gazebo 的接触传感器数据,按接触深度和相对滑动速度合成 -出一组「类触觉」读数,再按真实传感器的量纲(任意单位增量)发布到同样的内部话题上。 - -### 4. 工件与通止规模型 - -需要给工件(含 k0 中心孔、k1/k2 螺纹孔)和通止规建简化网格,并保证: - -- 工件的 k0/k1/k2 三个点在模型坐标系里的位置与实际一致 —— 否则推理出的坐标映射到仿真里会对不上; -- 螺纹孔要能表达「拧入」的几何阻力,否则 S08 的力反馈无法触发。 +> **Orisys 视触觉没有现成的 Gazebo 插件。** 3 路切向力 + 1 路法向力 + 深度图 + +> 力矢量图这套数据必须自己写插件。建议做法:订阅 Gazebo 的接触传感器数据, +> 按接触深度与相对滑动速度合成一组"类触觉"读数,再按真实传感器的量纲发布到 +> 同样的话题上(`/tactile/{left,right}/status`,`arm_msgs/TactileStatus`)。 +> 在它做出来之前,仿真里跑 P6 只能验证**几何与流程**,力判据要等真机。 --- ## 第二部分:日常怎么用 +### 启动 + +```bash +bash scripts/run_sim_gz.sh # 无头(无显示器环境用这个) +bash scripts/run_sim_gz.sh --gui # 带 Gazebo GUI +bash scripts/run_sim_gz.sh --check # 只自检描述,不启动任何东西 +bash scripts/run_sim_gz.sh --no-gripper --no-sensors # 最省资源,只验运动学 +``` + +首次启动约需 10~20 秒(Gazebo 初始化 + spawn + 拉控制器)。 + +### 验收(★ 必做,别只看"有没有图像") + +```bash +ros2 topic echo /clock --once # 仿真时间在走 +ros2 control list_controllers # 两个控制器 active +ros2 topic echo /joint_states --once # 7 个 sim_joint +python3 scripts/check_sim_cameras.py # ★ 相机的真正验收 +``` + +**`check_sim_cameras.py` 才是相机的验收标准。** 它把深度反投影到 world, +检查落点是否命中已知平面(地面 z=0、台面 z=0.40)。"有图像"证明不了什么 —— +详见下面的坑 4 与坑 5。 + ### 放置格式与命名要求 | 类型 | 放哪 | 格式 | 命名要求 | |------|------|------|----------| -| 世界文件 | `worlds/` | `.world` 或 `.sdf` | `<场景名>.world`,如 `assembly.world` | -| 机器人模型 | `urdf/` | `.urdf` / `.xacro` | 必须以 `sim_` 为关节前缀,与真实臂区分 | +| 世界文件 | `worlds/` | `.world` / `.sdf` | `<场景名>.world`,如 `assembly.world` | +| 机器人模型 | `urdf/` | `.urdf` / `.xacro` | 关节前缀必须与真机不同(仿真用 `sim_`) | | 工件/夹具网格 | `models/<模型名>/` | `<模型名>.sdf` + `meshes/` | **模型名必须与推理用的类别名一致**,如 `workpiece` | | 传感器插件 | `sensor_plugins/` | `.so` 源码 + `CMakeLists.txt` | 插件类名以 `Orisys` 开头 | -| 网格文件 | `models/<模型名>/meshes/` | `.stl` / `.dae` | 单位必须是米。用毫米建模的必须先缩放到米 | +| 网格文件 | `models/<模型名>/meshes/` | `.stl` / `.dae` | **单位必须是米**。用毫米建模的必须先缩放到米 | +| 相机声明 | `config/cameras.yaml` | YAML | `ros_ns` 必须等于应用启动时的 `camera_name` | -### 导入一个新场景 +### 改配置:改哪里才会真正生效 -1. 把 `.world` 放进 `worlds/`,网格放进 `models/<模型名>/` -2. 改 `config/site.yaml` 的 `sim.world` 指向该文件 -3. 改 `devices/registry.yaml` 的 `sim: gazebo`(通常已是) -4. 启动 `scripts/run_sim.sh` +| 想改什么 | 改哪里 | 真的生效吗 | +|----------|--------|------------| +| 用哪个世界 | `site.yaml` → `sim.world`,或命令行 `--world` | ✔ 已接线(优先级:`--world` > `site.yaml` > 内置默认) | +| 相机的视场/坐标系/话题 | `config/cameras.yaml`,**并且**同步 SDF 里对应的 `horizontal_fov` | ✔ 启动时自动比对,不一致会告警 | +| 物理开关 | `site.yaml` → `sim.physics_enable` | ✘ **未接线**。要真关,改 `worlds/assembly.world` 里的 `` | +| 关节前缀 | `site.yaml` → `sim.joint_prefix` | ✘ **未接线**。前缀由 `urdf/arm.sim.urdf.xacro` 的 `arm_prefix` 决定,且 `config/controllers.yaml` 硬编码了 `sim_joint1..7` —— 三处要一起改 | +| 仿真里的悬高/速度 | **复用真机那套参数**(`servo.*` / `hole_detect.stage2.*`) | ✔ | -### 修改哪个配置才会生效 +> **设计约束**:仿真与真机必须共用同一套 `site.yaml` 参数。任何"仿真专用参数"都是 +> 未来"仿真调好的值搬到真机不生效"的隐患。只允许物理引擎自身的参数(重力、步长) +> 放在 `sim.*` 下。 +> +> ✘ 标记的两个键由 `arm_sim_gazebo.sim_site.unwired_warnings()` 守着:只要你写的值 +> 和实际状态不一致,启动时就会告警。**不要让它们重回静默。** -| 想改什么 | 改哪里 | -|----------|--------| -| 用哪个场景 | `site.yaml` → `sim.world` | -| 仿真是否启用物理 | `site.yaml` → `sim.physics_enable` | -| 仿真里的悬高/速度 | **复用真实的那套参数**(`servo.*` / `hole_detect.stage2.*`),不要另建一套,否则仿真调好的值搬到真机不生效 | -| 关节前缀 | `site.yaml` → `sim.joint_prefix`(默认 `sim_`) | +--- -> **设计约束**:仿真与真机必须共用同一套 `site.yaml` 参数。任何「仿真专用参数」都是未来 -> 仿真调参搬到真机失效的隐患。只允许物理引擎本身的参数(如重力、步长)放在 `sim.*` 下。 +## 第三部分:实测踩到的坑(都属于"静默失效") + +这些坑的共同特征是:**不报错**。话题在、图像有、TF 查得到、`create` 打印成功, +但结果是错的。所以每条都配了"怎么发现"。 + +| # | 坑 | 症状 | 怎么发现 / 怎么修 | +|---|----|------|-------------------| +| 1 | 官方 `flexiv_description` 直接喂 Gazebo 会被 `sdformat` 拒掉:7 轴本体缺 `` 包裹;夹爪有零质量 link 导致惯性叠加出 NaN | 只有 Gazebo 拒绝,RViz/MoveIt 完全正常 | 启动时**归一化**(`urdf_normalize.py`)。单独排查:`gazebo_sim_launcher --check` | +| 2 | world 里只要声明了**任何一个** ``,就会挤掉默认的 Physics / UserCommands / SceneBroadcaster | 少了 UserCommands → `/world/<名>/create` 服务不存在 → 模型根本没进 Gazebo,而 `create` 仍打印 `OK creation of entity`;控制器永远起不来 | 启动时会检查并告警。修法:在 world 里显式声明这三个系统 | +| 3 | gz 的插件搜索路径不含 `$CONDA_PREFIX/lib`(`ros_gz_sim` 的 launch 只拼 `LD_LIBRARY_PATH`,而 conda 激活不往那里加) | `gz_ros2_control` 报 `couldn't find shared library`,而库其实就在那儿(`ldd` 全齐) | 必须在 `ros2 launch` **之前**设 `GZ_SIM_SYSTEM_PLUGIN_PATH`;资源路径同理(否则 mesh 全加载不出来)。都由 `scripts/sim_env.sh` 负责 | +| 4 | **gz 发的 `camera_info` 是错的**:恒为 `fx=fy=277, cx=160, cy=120`(它的默认 60° 视场 + 半分辨率主点),而**渲染**用的是配置的 87° 视场 | 照抄它做三维反投影,点会偏**半米量级**;单目预览完全正常 | 用已知地面平面做最小二乘反解内参,得到 `fy=337.2 / cy=239.5`,与 `640/(2·tan(87°/2))=337.2`、`480/2=240` 吻合(残差中位 0.2mm)。已改为由 `sim_cameras.intrinsics()` 按 `cameras.yaml` 的视场**重算并覆盖** | +| 5 | **相机位姿的 `rpy` 顺序**:URDF 相机是"X 朝前"约定,**视线是本体 X 轴**;而 `rpy` 复合顺序是 `Rz·Ry·Rx`,"先俯 50° 再偏航 180°"会把视线翻到**背面** | D435 背对台面、只看得见地面。什么都不报,**而且几何自洽**(点确实落在真实地面上),只查数值发现不了 | 用 `scripts/check_sim_cameras.py` 检查"该看见的平面有没有看见"。已修正 + 加回归测试 | + +关于坑 4:代价是 `cameras.yaml` 的 `horizontal_fov` 必须与 SDF 真正渲染用的值一致。 +启动时会自动比对并告警(`sim_cameras.check_fov_consistency`)。 + +关于相机还有两条**规格差异**,是有意保留的(不是 bug,但会让人困惑): + +- **畸变**:gz 不模拟镜头畸变(`d` 全 0,`R` 单位阵)。所以"仿真里用畸变矫正 + 调出来的参数"搬到真机仍需重调。 +- **分辨率**:真机 D405 是 848x480、D435 是 1280x720,仿真建模用 640x480。 + 对"算法能不能跑通"没影响(内参从 `cameras.yaml` 自洽推导), + 但"仿真里调好的像素阈值搬到真机"会因像素尺度不同而需要重调。 + +--- + +## 相关文件一览 + +``` +devices/sim/gazebo/ +├── manifest.yaml # 设备清单:进度、配置声明、放置要求 +├── README.md # 本文档 +├── config/ +│ ├── cameras.yaml # ★ 两台相机的唯一真相(话题/坐标系/视场) +│ └── controllers.yaml # 控制器配置(名字与真机一致) +├── urdf/ # 仿真用机器人描述(含 ros2_control 与传感器) +├── worlds/assembly.world # 场景 +├── models/ # ✘ 待做:工件/通止规/夹具 +└── sensor_plugins/ # ✘ 待做:Orisys 触觉插件 +``` + +配套代码:`src/arm_sim_gazebo/`(启动器、描述归一化、相机适配器)。 +配套脚本:`scripts/run_sim_gz.sh`(入口)、`scripts/check_sim_cameras.py`(相机验收)。 diff --git a/devices/sim/gazebo/config/cameras.yaml b/devices/sim/gazebo/config/cameras.yaml new file mode 100644 index 0000000..a8f0730 --- /dev/null +++ b/devices/sim/gazebo/config/cameras.yaml @@ -0,0 +1,86 @@ +# ============================================================================= +# devices/sim/gazebo/config/cameras.yaml +# +# 仿真相机的**唯一数据源**:gazebo_sim.launch.py 里的 ros_gz_bridge 参数、 +# sim_camera_adapter 节点、以及为世界相机补的静态 TF,全部从这份文件派生。 +# 想加一台仿真相机,改这里一处即可(前提是它在 URDF / world 里已经有 )。 +# +# 与 site.yaml 的分工: +# 这里只描述"Gazebo 里有什么、要桥到 ROS 的哪个名字"。 +# site.yaml 描述"真机上有哪些相机、序列号是多少"。仿真不读序列号 —— +# Gazebo 里没有 USB,序列号无从绑定。 +# 两者的**话题命名约定必须一致**,这是应用层能不改代码跑仿真的前提。 +# +# 话题命名约定(与真机 realsense 完全对齐,见 arm_application/handeye_realsense.py +# 的 REALSENSE_TOPIC_DEFAULTS 与 realsense_color_pub.py): +# //color/image_raw rgb8 +# //color/camera_info plumb_bob +# //aligned_depth_to_color/image_raw 16UC1,单位毫米,0 = 无读数 +# +# ⚠️ 单位与编码是关键差异,不能只做话题改名就完事: +# Gazebo 的深度相机发的是 **32FC1、单位米**(本机实测 step=2560 即 4 字节/像素), +# 而真机 realsense 的 aligned_depth 是 **16UC1、单位毫米**。 +# 直接把 32FC1 换个名字发出去,应用侧按毫米解释会整体差 1000 倍, +# 且 numpy 的 frombuffer 会因 dtype 不符报错或读出垃圾。 +# 所以深度必须**真正转换**,由 sim_camera_adapter 负责。 +# +# ⚠️⚠️ 更要紧的一条:**gz 发布的 camera_info 是错的,不能直接用。** +# 本机实测(Fortress 6.16 / RoboStack):无论怎么配,它都给 +# fx=fy=277, cx=160, cy=120 +# 这组值对应的是 **gz 的默认值** —— 60° 视场 + 半分辨率图幅(320x240), +# 而实际渲染用的是配置里的 87° 视场与 640x480。 +# 用已知地面平面反解真实内参得到 fy=337.2、cy=239.5, +# 与 640/2/tan(1.5184/2)=337.2、480/2=240 完全吻合(残差中位 0.2mm)。 +# 照抄 gz 的 camera_info 去做三维反投影,点位会错 0.5 米量级 —— +# 而单目预览里**完全看不出来**,只会让"仿真里调好的参数搬到真机"整个失效。 +# 因此 sim_camera_adapter 用下面这份 horizontal_fov **重算**并覆盖内参。 +# +# 所以每台相机必须声明 horizontal_fov,且**必须与 SDF 里真正渲染用的值一致**。 +# 它有两个来源,必须同步(启动时会自动比对并告警,见 sim_cameras.check_fov_consistency): +# 腕上相机 → devices/sim/gazebo/urdf/arm.sim.sensors.xacro 的 horizontal_fov +# 世界相机 → devices/sim/gazebo/worlds/assembly.world 里该 的 horizontal_fov +# ============================================================================= + +cameras: + # --- 腕上相机(eye-in-hand),挂在机械臂法兰上 ------------------------------ + - gz_ns: /d405 # Gazebo 传感器话题前缀(see arm.sim.sensors.xacro 的 ) + ros_ns: d405 # ROS 侧命名空间。应用按 camera_name 参数用这个名字取话题, + # 所以它必须等于启动应用时给的 camera_name(默认 d405) + link_frame: sim_d405_link + # 图像上盖的坐标系必须是**光学系**,不能是本体系。 + # gz 与 URDF 里的相机本体遵循"X 朝前 / Y 朝左 / Z 朝上"(相机机身约定), + # 而 ROS 的 image / camera_info 必须用光学约定"Z 朝前 / X 朝右 / Y 朝下" + # (REP-103/105)。两者差一个 rpy=(-90°, 0, -90°)。 + # 若直接把 frame_id 盖成本体系,应用做 tf2 查询能查到、不报错,但 + # 深度反投影出的三维点会整体转错 —— 这类错误在单目预览里完全看不出来。 + optical_frame: sim_d405_color_optical_frame + depth: true + # 水平视场(弧度)。真机 D405 彩色的水平视场约 87° = 1.5184 rad, + # 仿真的传感器也按这个建,这样两边的像素尺度接近。 + # 内参由它和图像尺寸算出:fx = fy = W / (2*tan(hfov/2)),cx=W/2,cy=H/2。 + horizontal_fov: 1.5184 + width: 640 + height: 480 + note: 仿真里由 sim_link7 上的 d405_rgbd 传感器提供 + + # --- 手外相机(eye-to-hand),固定在世界里看工作台 ------------------------- + # ⚠️ 这台相机在 world 文件里,不在 URDF 里 —— 因为它的正确物理归属就是"世界", + # 而不是机器人本体。(曾经把它挂在 URDF 的 world link 上,结果 sdformat 把 + # 无质量的根 link 连同整棵子树静默丢掉,相机与它的话题一起消失。详见 + # devices/sim/gazebo/README.md。) + # 代价是:gz 不会为世界模型发布 TF,所以必须由启动方补一条静态 TF + # world → d435_link。位姿从 world 文件里**解析**得到(不在这里重复配置), + # 避免"改了一处忘了另一处"。 + - gz_ns: /d435 + ros_ns: d435 + link_frame: d435_link + optical_frame: d435_color_optical_frame + depth: true + horizontal_fov: 1.5184 + width: 640 + height: 480 + # 非空 = 这台相机挂在世界模型上,需要补静态 TF。 + # world_model 是 world 文件里的 ,world_link 是模型里的 。 + world_model: d435 + world_link: link + note: height 也与真机不同(真机 1280x720),同 D405 的说明 diff --git a/devices/sim/gazebo/manifest.yaml b/devices/sim/gazebo/manifest.yaml index d5e9caf..5517ceb 100644 --- a/devices/sim/gazebo/manifest.yaml +++ b/devices/sim/gazebo/manifest.yaml @@ -3,7 +3,7 @@ # # Gazebo 仿真平台清单。 # -# 进度(截至 P5c-3,如实记录,不要凭印象改): +# 进度(截至 P5c-4,如实记录,不要凭印象改): # ✔ 环境 独立 conda 环境 ros_gz_sim(Gazebo Fortress + ROS 2 Humble) # 安装见 scripts/install_sim_env.sh,激活见 scripts/sim_env.sh # ✔ 世界文件 worlds/assembly.world(sdformat 校验通过) @@ -16,12 +16,17 @@ # 与 build_sim.sh 的提示语引用,照做会报文件不存在) # ✘ 场景模型 models/ 还是空的(工件/通止规模型未做) # ✘ 触觉插件 sensor_plugins/ 还是空的(Orisys 三向力插件未做) -# ✘ 相机出图 D405/D435 传感器已在 Gazebo 里成功注册并发布话题, -# 但还没接 ros_gz_bridge 到 ROS,也还没验证图像内容 +# ✔ 相机出图 D405/D435 已注册并桥接到 ROS,且**几何正确性已验证** +# 桥接+适配:config/cameras.yaml → sim_cameras(纯逻辑: +# 话题映射 / 深度换算 / 内参,单测覆盖) +# → sim_camera_adapter(改光学系 frame_id、米→毫米、覆盖内参) +# 验收工具:scripts/check_sim_cameras.py(反投影必须命中地面/台面) +# 实测:d435 台面 11.9% / 地面 75.3%;d405 台面 37.8% / 地面 54.0% +# ✔ 配置接线 sim.world 现在真的从 config/site.yaml 读(此前硬编码,改了无效) # -# 所以:D405/D435 与触觉还要等 P5c-4 / P5c-5,装配状态机(P6)依赖它们。 +# 所以:D405/D435 可用,触觉还要等 P5c-5,装配状态机(P6)依赖它。 # -# 三个实测踩到、且都属于"静默失效"的坑(后续改仿真时务必先看这三条): +# 实测踩到的坑(都属于"静默失效",后续改仿真务必先看这几条): # 1. 官方 flexiv_description 直接喂 Gazebo 会被 sdformat 拒掉:7 轴本体缺 # 包裹;夹爪有零质量 link 导致惯性叠加出 NaN。两者都只有 # Gazebo 会暴露,RViz/MoveIt 完全正常。已在启动时归一化解决。 @@ -29,11 +34,21 @@ # Physics/UserCommands/SceneBroadcaster。少了 UserCommands, # /world/<名>/create 服务不存在 → 模型根本没进 Gazebo,而 create 仍打印 # "OK creation of entity"。所以这三个系统必须在 world 里显式声明。 -# 3. gz 的插件搜索路径不含 $CONDA_PREFIX/lib(ros_gz_sim 只拼 LD_LIBRARY_PATH, -# 而 conda 激活不往那里加),于是 gz_ros2_control 报 "couldn't find shared -# library" 而库其实就在那儿。必须在 ros2 launch 之前设 +# 3. gz 的插件搜索路径不含 $CONDA_PREFIX/lib(ros_gz_sim 的 launch 只拼 +# LD_LIBRARY_PATH,而 conda 激活不往那里加),于是 gz_ros2_control 报 +# "couldn't find shared library" 而库其实就在那儿。必须在 ros2 launch 之前设 # GZ_SIM_SYSTEM_PLUGIN_PATH;资源路径同理(否则 mesh 全加载不出来)。 # 两者都由 scripts/sim_env.sh 负责。详见该文件里的长注释。 +# 4. **gz 发的 camera_info 是错的**:恒为 fx=fy=277, cx=160, cy=120 +# (等于它的默认 60° 视场 + 半分辨率主点),而渲染用的是配置的 87° 视场。 +# 照抄它做三维反投影,点会偏半米量级,而单目预览完全看不出来。 +# 已改为由 sim_cameras.intrinsics() 按 cameras.yaml 的视场重算并覆盖。 +# 代价:cameras.yaml 的 horizontal_fov 必须与 SDF 一致,启动时会自动比对告警。 +# 5. **相机位姿的 rpy 顺序**:URDF 相机是"X 朝前"约定,**视线是本体 X 轴**; +# 而 rpy 复合顺序是 Rz·Ry·Rx,"先俯 50° 再偏航 180°"会把视线翻到背面。 +# d435 就踩了这个坑:相机背对台面、只看得见地面,却什么都不报, +# 而且几何自洽(点确实落在真实地面上),只查数值发现不了。 +# 已修正并加回归测试(test_robot_description 的 d435 位姿用例)。 # ============================================================================= id: gazebo @@ -47,8 +62,10 @@ version: "Fortress 6.16 (ignition-gazebo6, ros_gz + gz_ros2_control 0.7.15)" description: > 物理仿真平台。环境、世界、机器人描述、控制器与启动器均已就绪并端到端验证 (起 Gazebo → spawn → 两个控制器 active → 机械臂在重力下站住)。 + D405/D435 已桥接到 ROS,且几何正确性有自动验收(scripts/check_sim_cameras.py)。 场景模型(工件/通止规)与 Orisys 触觉插件尚未实现; - 相机传感器已在 Gazebo 内注册并出话题,但还没接到 ROS 侧(P5c-4)。 + sim.world 已真正从 config/site.yaml 读取(physics_enable / joint_prefix 尚未接线, + 写了不一致的值会告警而非静默忽略,见文件头 config_schema)。 注意:Orisys 视触觉没有现成 Gazebo 插件,自定义插件做出来之前, 仿真里无法验证通止规装配的 S07 接触与 S08 拧紧力判据。 @@ -100,28 +117,44 @@ driver: # --- 配置项声明 ------------------------------------------------------------ # 设计约束:仿真与真机共用同一套 site.yaml 参数(servo.* / hole_detect.stage2.*), # 避免「仿真调好的值搬到真机不生效」。只有物理引擎自身的参数放在 sim.* 下。 +# +# ⚠️ 每个键都必须如实标注"是否真的接线"(wired)。声明了却没接线的键是最难查的 +# 一类问题:用户按文档改了 site.yaml,行为毫无变化,也不报错。 +# 目前由 arm_sim_gazebo.sim_site 读取的是 world(已接线); +# physics_enable / joint_prefix 尚未接线,若其值与世界/描述的实际状态冲突, +# 启动时会打印告警 —— 见 sim_site.unwired_warnings()。 config_schema: - key: world site_path: sim.world type: str required: false default: "" + wired: true doc: > - 仿真世界文件路径。留空表示使用 Gazebo 默认空场景。 - Gazebo 接线完成后填真实路径,如 devices/sim/gazebo/worlds/assembly.world。 + 仿真世界文件路径。留空 = 用内置默认(devices/sim/gazebo/worlds/assembly.world)。 + 相对路径按仓库根解析。优先级:命令行 --world > 本项 > 内置默认。 + 实现在 arm_sim_gazebo/sim_site.resolve_world()。 - key: physics_enable site_path: sim.physics_enable type: bool required: false default: true - doc: 是否启用物理引擎。关闭可用于纯运动学验证。 + wired: false + doc: > + 是否启用物理引擎。**尚未接线**:世界文件里的 (重力/步长)是写死的, + 本项不会传导过去。若这里写的值与世界实际状态不符,启动时告警而不是静默忽略。 + 要真正生效,需改 world 文件里的 ,或把本项接进启动流程。 - key: joint_prefix site_path: sim.joint_prefix type: str required: false default: sim_ + wired: false doc: > 仿真关节名前缀。必须与真实臂的前缀(Rizon4-xxxxxx_)不同, 否则仿真与真机的 TF 会互相污染。 + **尚未接线**:实际前缀由 urdf/arm.sim.urdf.xacro 的 arm_prefix 决定, + 且 config/controllers.yaml 里硬编码了 sim_joint1..7。写了个不同的值会告警。 + 要真改,这三处必须一起改。 diff --git a/devices/sim/gazebo/worlds/assembly.world b/devices/sim/gazebo/worlds/assembly.world index e3c4863..7c445f5 100644 --- a/devices/sim/gazebo/worlds/assembly.world +++ b/devices/sim/gazebo/worlds/assembly.world @@ -229,15 +229,36 @@ (早期把它挂在 URDF 的 world link 上,结果转 SDF 时整台相机消失 —— sdformat 丢弃无质量根 link 时连带丢了整棵子树,且不报错。) - 位姿:pitch≈50° 俯视,yaw=180° 让光轴转向机械臂一侧。 + 位姿:pitch=50° 俯视。**yaw 必须是 0**,原因见下面的坑。 + + ⚠⚠ 实测踩到的坑(第四个"静默失效"):这里的 rpy 曾经写成 + `0.0 0.872665 3.14159`(pitch 50° + yaw 180°),注释写的是 + "yaw=180° 让光轴转向机械臂一侧"。但 rpy 的复合顺序是 Rz·Ry·Rx, + 在 50° 俯角**之后**再做 180° 的 Z 旋转,会把视线从 "+X 斜向下" + 翻成 "-X 斜向下" —— 也就是**背对台面**,只看得见地面。 + 而它不报任何错:话题有、图像有、深度有、TF 也对, + 唯一症状是"相机拍不到该拍的东西"。用地面平面反投影验收时, + 点是落在真实地面上的(几何自洽),所以只查数值根本发现不了。 + 正确理解:URDF 相机是"X 朝前"约定,**视线 = 本体 X 轴**, + Ry(50°) 已经把本体 X 从 (1,0,0) 转到 (cos50,0,-sin50), + 正好是"朝 +X 俯视 50°",yaw 再转就过头了。 + 视野必须完整覆盖台面 —— 粗定位失败要回退到这个视角,看不全工件就等于 - 没有兜底方案。验收方法: + 没有兜底方案。验收方法(**必须看命中率,不能只看"有图像"**): ros2 topic hz /d435/color/image_raw # 有数 - rqt_image_view /d435/color/image_raw # 工件三个孔都在画面内 + python3 scripts/check_sim_cameras.py # 反投影必须命中 z=0.40 台面 + 后者会自动检查两台相机是否真的看到了该看的平面。 + + 2026-09-23 实测基线(改位姿后请重测并更新,别凭印象): + 台面四角投影到画面 u∈[172,468]、v∈[144,286](画面 640x480)→ 完整在视野内 + 通止规标记 → 像素 (180, 209) + 台面像素占画面 11.9%,地面 75.3% + 机械臂自身会遮住台面靠中偏下的一小块 —— 物理上必然,可接受; + 但 P5c-6 放上工件后要用上面那条命令复核"工件是否完整可见"。 ========================================================================= --> true - -0.45 0.0 1.35 0.0 0.872665 3.14159 + -0.45 0.0 1.35 0.0 0.872665 0.0 0.09 0.025 0.025 diff --git a/scripts/README.md b/scripts/README.md index 81d53b7..6cad567 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -14,6 +14,12 @@ - 体检:`weights.sh doctor --check-engines`(能查出 engine 与 TensorRT 版本不匹配这类静默失效) - 规则写在 [models/README.md](../models/README.md)。`export_yolo_trt.sh` 现在是它的兼容转发壳。 **配置:** `show_site_config.sh` · `lib/export_site_env.py` +**仿真:** `run_sim_gz.sh`(Gazebo 物理仿真,唯一能做接触/力控验证的入口) + - 环境是独立 conda 环境 `ros_gz_sim`(`install_sim_env.sh` 装、`sim_env.sh` 激活),与真机环境隔离 + - 相机验收:`python3 scripts/check_sim_cameras.py` —— **别用 `ros2 topic hz` 或肉眼看图验收相机**, + 仿真相机的错误几乎全是静默的(深度单位、frame_id、内参、位姿方向),都能出图却是错的。 + 这条命令把深度反投影到 world,要求命中已知平面(地面/台面)才算通过。 + - 注意 `run_sim.sh` 不是 Gazebo,它走 mock 驱动(假数据 + RViz),永远不会产生接触力 **环境:** `env.sh` · `build_all.sh` **RDK:** `test_rdk_python.sh` · `rdk_switch_tool.sh` · `rdk_drag_bridge.py` **搁置未用:** `fetch_isaac_flexiv.sh`(拉 Flexiv 的 Isaac Sim / RDK 源码到 `thirdparty/`;Isaac 路线目前搁置,Gazebo 是选定平台) diff --git a/scripts/check_sim_cameras.py b/scripts/check_sim_cameras.py new file mode 100755 index 0000000..1f5b5a7 --- /dev/null +++ b/scripts/check_sim_cameras.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +"""仿真相机的几何验收:把深度反投影到 world,看它是否真的落在该落的平面上。 + +============================================================================= +为什么需要这个工具(而不是"看图有图像就行") +============================================================================= +仿真里相机的错误几乎都是**静默**的:话题在、图像有、深度有、TF 也查得到, +而三维点位整体是错的。下面每一类都真实踩过: + + 1. 深度单位错了(gz 发 32FC1 米,应用当 16UC1 毫米读)→ 差 1000 倍。 + 2. frame_id 盖成了相机**本体**系而不是光学系 → tf2 查得到、不报错, + 但反投影出的点整体转错。 + 3. **gz 发的 camera_info 是错的**(恒为 fx=fy=277, cx=160, cy=120, + 即它的默认 60° 视场 + 半分辨率主点),而渲染用的是配置的 87° 视场。 + 照抄它反投影,点会偏半米量级。 + 4. 相机位姿的 rpy 写错 → 相机**背对**目标,只看得见地面。 + 这种情况几何仍然自洽(点确实落在真实地面上),所以只查数值发现不了 —— + 必须比对"该看见什么",也就是本工具要说的平面。 + +所以本工具的判据是:**反投影出的点必须命中预期平面**。 +"有图像"不算通过,"落在正确的位置"才算。 + +用法(必须先 source scripts/sim_env.sh,因为需要 ROS 环境): + python3 scripts/check_sim_cameras.py + python3 scripts/check_sim_cameras.py --expect d435:0.40:30 --cameras d435 + +退出码 0 = 全部通过;非 0 = 有不通过的相机(可直接用于 CI / 启动自检)。 +============================================================================= +""" + +from __future__ import annotations + +import argparse +import math +import sys +import time + +import numpy as np + +try: + import rclpy + from rclpy.node import Node + from rclpy.qos import qos_profile_sensor_data + from sensor_msgs.msg import CameraInfo, Image + from tf2_ros import Buffer, TransformListener +except ImportError as exc: # pragma: no cover - 环境问题,不是逻辑问题 + print( + f"✘ 导入 ROS 失败:{exc}\n" + f" 这个脚本要在仿真环境里跑,先执行:\n" + f" source scripts/sim_env.sh && . install_sim/setup.bash", + file=sys.stderr, + ) + sys.exit(2) + + +# 默认期望:哪台相机"必须"看见哪个平面。 +# d435 是手外相机,它的职责就是看见台面(P6 的粗定位要靠它), +# 所以要求 ≥10% 的像素落在台面上。 +# d405 是腕上相机,视野窄且会拍到机械臂自己,台面占比要求放宽到 5%。 +# 地面(z=0) 不作为要求:它被拍到多少取决于姿态,不构成"对不对"的判据。 +DEFAULT_EXPECT = ("d435:0.40:10", "d405:0.40:5") + + +def quat_to_R(q) -> np.ndarray: + """四元数 (x, y, z, w) → 3x3 旋转矩阵。""" + x, y, z, w = q + n = x * x + y * y + z * z + w * w + if n <= 0.0: + return np.eye(3) + s = 2.0 / n + return np.array( + [ + [1 - s * (y * y + z * z), s * (x * y - z * w), s * (x * z + y * w)], + [s * (x * y + z * w), 1 - s * (x * x + z * z), s * (y * z - x * w)], + [s * (x * z - y * w), s * (y * z + x * w), 1 - s * (x * x + y * y)], + ] + ) + + +class Grab(Node): + """抓一帧深度 + 一份内参。""" + + def __init__(self, ns: str) -> None: + super().__init__(f"check_sim_cameras_{ns}") + self.ns = ns + self.img = None + self.info = None + self.buf = Buffer() + self.tl = TransformListener(self.buf, self) + # QoS 必须与发布端一致:适配器与真机都用 sensor data, + # 用默认 RELIABLE 订阅会一条都收不到(表现是"话题在但 echo 为空")。 + self.create_subscription( + Image, + f"/{ns}/aligned_depth_to_color/image_raw", + self._on_img, + qos_profile_sensor_data, + ) + self.create_subscription( + CameraInfo, f"/{ns}/color/camera_info", self._on_info, qos_profile_sensor_data + ) + + def _on_img(self, m) -> None: + self.img = m + + def _on_info(self, m) -> None: + self.info = m + + +def spin_until(node: Node, pred, timeout: float) -> bool: + t0 = time.time() + while rclpy.ok() and time.time() - t0 < timeout: + rclpy.spin_once(node, timeout_sec=0.3) + if pred(): + return True + return False + + +def expected_intrinsics(ns: str): + """从 cameras.yaml 算出该相机的应有内参。拿不到就返回 None(不阻断检查)。""" + try: + from arm_sim_gazebo.sim_cameras import intrinsics, load_cameras + except ImportError: + return None + try: + for s in load_cameras(): + if s.ros_ns == ns: + return s, intrinsics(s, s.width or 640, s.height or 480) + except Exception: + return None + return None + + +def check_one(ns: str, args) -> tuple[bool, list[str]]: + """检查一台相机。返回 (是否通过, 问题列表)。""" + problems: list[str] = [] + node = Grab(ns) + + if not spin_until( + node, lambda: node.img is not None and node.info is not None, args.timeout + ): + node.destroy_node() + return False, [f"拿不到图像或内参(等 {args.timeout:.0f}s)"] + + frame = node.info.header.frame_id + if not spin_until( + node, lambda: node.buf.can_transform("world", frame, rclpy.time.Time()), 20.0 + ): + node.destroy_node() + return False, [f"TF world ← {frame} 查不到(相机在世上没有位姿)"] + + K = np.array(node.info.k, dtype=np.float64).reshape(3, 3) + fx, fy, cx, cy = K[0, 0], K[1, 1], K[0, 2], K[1, 2] + if not (frame.endswith("_optical_frame") or "optical" in frame): + problems.append( + f"frame_id 是 {frame},看着不像光学系(应以 _optical_frame 结尾)。" + f"盖成本体系时 tf2 查得到、不报错,但反投影出的点会整体转错。" + ) + + mm = np.frombuffer(node.img.data, dtype=np.uint16) + if mm.size != node.img.height * node.img.width: + node.destroy_node() + return False, [ + f"深度图像不是 16UC1 单通道({node.img.encoding}, " + f"{len(node.img.data)} 字节 / 期望 {node.img.height * node.img.width * 2})" + f"—— gz 的 32FC1 米必须转成 16UC1 毫米,这一步没生效" + ] + mm = mm.reshape(node.img.height, node.img.width) + + # 与 cameras.yaml 推导出的应有内参比对:这能直接抓住"gz 的默认内参被透传" + # 那个坑(它会给出 fx=277, cx=160, cy=120)。 + exp = expected_intrinsics(ns) + if exp is not None: + spec, (exp_k, _) = exp + if abs(exp_k[0] - fx) > 1.0 or abs(exp_k[2] - cx) > 1.0: + problems.append( + f"内参不是按 cameras.yaml 算出来的:实测 fx={fx:.1f} cx={cx:.1f}," + f"应为 fx={exp_k[0]:.1f} cx={exp_k[2]:.1f}" + f"(视场 {spec.horizontal_fov})。\n" + f" 这正是 gz 透传默认内参(fx=277, cx=160)时的症状," + f"会让所有三维点位偏半米量级。" + ) + + valid = (mm > 200) & (mm < 8000) + vs, us = np.nonzero(valid) + if len(us) < 500: + node.destroy_node() + return False, [f"有效深度像素太少({len(us)} 个),图像基本是空的"] + rng = np.random.default_rng(0) + if len(us) > args.max_pixels: + sel = rng.choice(len(us), args.max_pixels, replace=False) + us, vs = us[sel], vs[sel] + + Z = mm[vs, us].astype(np.float64) / 1000.0 + pts = np.stack([(us - cx) * Z / fx, (vs - cy) * Z / fy, Z], axis=1) + + tf = node.buf.lookup_transform("world", frame, rclpy.time.Time()) + R = quat_to_R( + ( + tf.transform.rotation.x, + tf.transform.rotation.y, + tf.transform.rotation.z, + tf.transform.rotation.w, + ) + ) + t = np.array( + [ + tf.transform.translation.x, + tf.transform.translation.y, + tf.transform.translation.z, + ] + ) + zw = (pts @ R.T + t)[:, 2] + node.destroy_node() + + print(f"\n[{ns}] 光学系={frame} fx={fx:.1f} fy={fy:.1f} cx={cx:.1f} cy={cy:.1f}") + print(f" world-z 中位={np.median(zw):+.3f} m 范围[{zw.min():+.2f}, {zw.max():+.2f}]") + + # 逐平面命中率 + rates = {} + for pz, name in args.planes: + rate = float(np.mean(np.abs(zw - pz) < args.tol)) * 100.0 + rates[pz] = rate + print(f" 命中 {name}(z={pz:+.2f}) : {rate:5.1f}%") + + # 期望判据 + for want_z, want_min in args.expect.get(ns, []): + name = dict(args.planes).get(want_z, f"z={want_z}") + got = rates.get(want_z, 0.0) + if got < want_min: + problems.append( + f"只看见 {name} 的 {got:.1f}%,要求 ≥{want_min:.0f}%。\n" + f" 相机很可能是**背对**目标(位姿 rpy 写错)—— " + f"这种错几何自洽、不报错,只会让相机拍不到该拍的东西。" + ) + + total = sum(rates.values()) + if total < 1.0: + problems.append(f"所有预期平面加起来命中率只有 {total:.1f}%,几乎没有指向场景") + + ok = not problems + print(f" → {'✓ 通过' if ok else '✘ 不通过'}") + for p in problems: + print(f" ✘ {p}") + return ok, problems + + +def parse_planes(text: str): + out = [] + for item in text.split(","): + item = item.strip() + if not item: + continue + if ":" in item: + z, name = item.split(":", 1) + else: + z, name = item, f"z={item}" + out.append((float(z), name.strip())) + if not out: + raise argparse.ArgumentTypeError("--planes 不能为空") + return out + + +def parse_expect(items): + """把 'd435:0.40:10' 解析成 {ns: [(z, min_pct), ...]}。""" + out: dict[str, list[tuple[float, float]]] = {} + for it in items: + parts = it.split(":") + if len(parts) < 2: + raise argparse.ArgumentTypeError(f"--expect 格式应为 相机:z[:最低百分比],收到 {it!r}") + ns = parts[0] + z = float(parts[1]) + pct = float(parts[2]) if len(parts) > 2 else 5.0 + out.setdefault(ns, []).append((z, pct)) + return out + + +def main() -> int: + ap = argparse.ArgumentParser( + description="仿真相机几何验收:反投影深度,检查是否命中预期平面。", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + ap.add_argument("--cameras", default="d405,d435", help="要检查的相机命名空间,逗号分隔") + ap.add_argument( + "--planes", + type=parse_planes, + default=parse_planes("0.0:地面,0.40:台面"), + help="要统计命中率的平面,格式 z:名称,逗号分隔(默认 0.0:地面,0.40:台面)", + ) + ap.add_argument( + "--expect", + action="append", + dest="expect_raw", + default=None, + help="要求某相机命中某平面,格式 相机:z[:最低百分比],可重复。" + f"默认 {list(DEFAULT_EXPECT)}", + ) + ap.add_argument("--tol", type=float, default=0.02, help="平面命中容差(米),默认 0.02") + ap.add_argument("--timeout", type=float, default=25.0, help="等图像/内参的秒数") + ap.add_argument("--max-pixels", type=int, default=40000, help="最多采样多少像素") + args = ap.parse_args() + args.expect = parse_expect(args.expect_raw or list(DEFAULT_EXPECT)) + + rclpy.init() + try: + results = {} + for ns in [c.strip() for c in args.cameras.split(",") if c.strip()]: + try: + results[ns] = check_one(ns, args) + except KeyboardInterrupt: + raise + except Exception as exc: # 单台相机出错不该拖垮整轮检查 + print(f"\n[{ns}] ✘ 检查时异常:{type(exc).__name__}: {exc}") + results[ns] = (False, [str(exc)]) + finally: + rclpy.try_shutdown() + + failed = [ns for ns, (ok, _) in results.items() if not ok] + print() + if failed: + print(f"=== ✘ 不通过:{', '.join(failed)} ===") + print(" 仿真里相机出错几乎都不报错,别只看'有没有图像'。") + return 1 + print(f"=== ✓ 全部通过:{', '.join(results)} ===") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/arm_sim_gazebo/arm_sim_gazebo/gazebo_sim_launcher.py b/src/arm_sim_gazebo/arm_sim_gazebo/gazebo_sim_launcher.py index 58b5024..c50cf74 100644 --- a/src/arm_sim_gazebo/arm_sim_gazebo/gazebo_sim_launcher.py +++ b/src/arm_sim_gazebo/arm_sim_gazebo/gazebo_sim_launcher.py @@ -33,10 +33,23 @@ from .robot_description import ( 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 的 @@ -95,8 +108,12 @@ def main(argv: Optional[List[str]] = None) -> int: root = repo_root() xacro_path = default_xacro(root) controllers = default_controllers(root) - world = args.world or str(default_world(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, @@ -105,10 +122,19 @@ def main(argv: Optional[List[str]] = None) -> int: ) wname = world_name(world) missing = check_base_plugins(Path(world)) - except SimDescriptionError as exc: + 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。 @@ -135,6 +161,11 @@ def main(argv: Optional[List[str]] = None) -> int: 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( diff --git a/src/arm_sim_gazebo/arm_sim_gazebo/robot_description.py b/src/arm_sim_gazebo/arm_sim_gazebo/robot_description.py index 42917df..f6d8679 100644 --- a/src/arm_sim_gazebo/arm_sim_gazebo/robot_description.py +++ b/src/arm_sim_gazebo/arm_sim_gazebo/robot_description.py @@ -20,6 +20,7 @@ devices/ **不是 ROS 包**,所以 $(find ...) 找不到它,必须用文件 """ from __future__ import annotations +import math import os import re import subprocess @@ -107,6 +108,183 @@ def world_name(world_file: Path) -> str: return m.group(1) +# ============================================================================= +# 从 world 文件里读模型位姿 +# ============================================================================= +# 为什么需要:Gazebo 里的 (尤其是 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]: + """取出 ... 的内容(考虑同名标签嵌套)。 + + SDF 里 可以嵌套 (组合体), 里也可能有 (少见)。 + 只找第一个 在嵌套时会截断到错误的位置,所以这里数嵌套深度。 + """ + 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 + + # 自闭合: 是完全合法的 SDF(没有子元素)。 + # 不特判的话会走到下面的"找 "分支、找不到 → 返回 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": 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]: + """取一段块自己的 (默认全 0)。 + + 只认**第一个** ,且必须出现在 stop_before 里任一标签之前 —— + 这样才不会把子元素的 pose 误当成父元素的。例如 的 pose 必须在 + 第一个 // 之前; 的 pose 必须在第一个 + // 之前。 + """ + cut = len(block) + for tag in stop_before: + i = block.find("<" + tag) + if 0 <= i < cut: + cut = i + m = re.search(r"]*)?>([^<]*)", 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 文件里 的 在**世界系**的位姿。 + + 返回值是 ((x, y, z), (roll, pitch, yaw))。link 留空表示取模型本身的位姿。 + 两者都会做复合(模型位姿 ∘ link 位姿),因为 SDF 的 是相对父元素的。 + + 找不到模型/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} 里找不到 。\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} 的 里找不到 。" + ) + + 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 里声明了任何一个 ,Gazebo 就不再加载 server.config 的默认 # 系统列表,这三个基础系统必须由 world 自己声明,否则整个仿真静默失效。 # 详细后果见 assembly.world 里那段注释,以及这里的 check_base_plugins()。 diff --git a/src/arm_sim_gazebo/arm_sim_gazebo/sim_camera_adapter.py b/src/arm_sim_gazebo/arm_sim_gazebo/sim_camera_adapter.py new file mode 100644 index 0000000..0e56bce --- /dev/null +++ b/src/arm_sim_gazebo/arm_sim_gazebo/sim_camera_adapter.py @@ -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 图里仍然可见, +排查"是桥接断了还是适配器断了"时能一刀切开。 + +============================================================================= +发布的话题(与真机完全一致,应用无需为仿真改任何代码) +============================================================================= + //color/image_raw rgb8 + //color/camera_info plumb_bob(畸变全 0,gz 不模拟畸变) + //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()) diff --git a/src/arm_sim_gazebo/arm_sim_gazebo/sim_cameras.py b/src/arm_sim_gazebo/arm_sim_gazebo/sim_cameras.py new file mode 100644 index 0000000..d09606e --- /dev/null +++ b/src/arm_sim_gazebo/arm_sim_gazebo/sim_cameras.py @@ -0,0 +1,394 @@ +"""仿真相机的配置解析与格式换算。 + +============================================================================= +为什么单独一个模块 +============================================================================= +"把 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_fov_consistency( + specs: Sequence[CameraSpec], + world_file: Optional[Path] = None, + sensors_xacro: Optional[Path] = None, +) -> List[str]: + """比对 cameras.yaml 里声明的视场与 SDF 里真正渲染用的视场。 + + 为什么值得做这个检查:内参由 cameras.yaml 的 horizontal_fov 算出, + 而**渲染**用的是 SDF 里的值。两者一旦不一致,图像与内参就对不上了 —— + 症状仍然是"三维点整体错",而单目预览看不出来。这正是这个模块开头 + 那一长串说明所警告的那类错误,所以要主动比对。 + + 返回人类可读的告警列表(空 = 没问题)。 + + 精度说明(有意为之,不做过度解析): + - 世界相机:按 里的 精确匹配到对应相机,读到的是精确值。 + - 腕上相机:它在 xacro 宏里、值由参数传入,展开前无法可靠地归属到具体 + 相机。所以退化成"文件里出现过这个值就算过"—— 偏宽松,但足以抓住 + "改了 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 + # 找到 等于本相机 gz_ns 的那个 块 + fov = None + for m in re.finditer(r"]*>(.*?)", text, re.S): + body = m.group(1) + tm = re.search(r"\s*([^<\s]+)\s*", body) + if not tm or "/" + tm.group(1).strip().lstrip("/") != s.gz_ns: + continue + fm = re.search(r"\s*([0-9.eE+-]+)\s*", body) + if fm: + fov = float(fm.group(1)) + break + if fov is None: + warnings.append( + f"相机 {s.ros_ns}: 在 {Path(world_file).name} 里找不到 topic 为 " + f"{s.gz_ns} 的 的 ,无法比对。" + 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"而单目预览看不出来。请把两处改成一致。" + ) + + 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"\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",无法比对(内参按 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},三维点位会整体错。" + ) + + return warnings + + +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 + + +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, + ) diff --git a/src/arm_sim_gazebo/arm_sim_gazebo/sim_site.py b/src/arm_sim_gazebo/arm_sim_gazebo/sim_site.py new file mode 100644 index 0000000..9cfca18 --- /dev/null +++ b/src/arm_sim_gazebo/arm_sim_gazebo/sim_site.py @@ -0,0 +1,214 @@ +"""读 `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: + """仓库根。本模块在 src/arm_sim_gazebo/arm_sim_gazebo/ 下,向上走四层。""" + return Path(__file__).resolve().parents[3] + + +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 文件里的 ," + 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 里"关物理"的常见做法就是把 置零(比删掉整个 + 更常用,因为 还带着步长等其它必需项)。没有 / + 时按"开着"算 —— gz 的默认就是开。 + """ + try: + text = Path(world_file).read_text(encoding="utf-8", errors="replace") + except OSError: + return None + m = re.search(r"\s*([^<]+)\s*", 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) diff --git a/src/arm_sim_gazebo/launch/gazebo_sim.launch.py b/src/arm_sim_gazebo/launch/gazebo_sim.launch.py index e372d01..1819b63 100644 --- a/src/arm_sim_gazebo/launch/gazebo_sim.launch.py +++ b/src/arm_sim_gazebo/launch/gazebo_sim.launch.py @@ -76,10 +76,12 @@ def _prepare(context, *args, **kwargs): default_controllers, default_world, default_xacro, + 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() @@ -89,7 +91,10 @@ def _prepare(context, *args, **kwargs): try: root = repo_root() - world_path = world or str(default_world(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) urdf, report = prepare_urdf( xacro_path=default_xacro(root), @@ -99,7 +104,7 @@ def _prepare(context, *args, **kwargs): ) wname = world_name(world_path) missing_base = check_base_plugins(Path(world_path)) - except SimDescriptionError as exc: + except (SimDescriptionError, SimSiteError) as exc: # 描述准备失败是最常见的上手障碍(环境没 source、路径不对)。 # 直接把可读原因打出来,而不是让它变成一个看不懂的 launch traceback。 raise RuntimeError(f"\n[gazebo_sim] 仿真描述准备失败:\n{exc}\n") from exc @@ -179,7 +184,123 @@ def _prepare(context, *args, **kwargs): ) ) - # --- 4) spawn 机器人 ----------------------------------------------------- + # --- 4) 相机:桥接 → 适配器 → (世界相机)补 TF -------------------------- + # 只在挂了传感器时才做。--no-sensors 时跳过是必要的:适配器会去订阅 + # 永远不存在的 gz 话题,它**不会报错**,只是静默没输出 —— 而"相机没图" + # 正是最需要快速定位的问题,不该被一个假故障混淆。 + if with_sensors != "false": + from arm_sim_gazebo.sim_cameras import ( + DEFAULT_CAMERAS_REL, + CameraConfigError, + bridge_args, + check_fov_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 算,而**渲染**用的是 + # SDF 里的值。两者不一致时图像与内参对不上,三维点位会整体错 —— + # 而单目预览完全看不出来(gz 自己发的 camera_info 本来就是错的, + # 所以这份内参是"唯一真相",更要保证它与渲染一致)。 + # 这里只告警不拦截:真有偏差时,仍应能起仿真去现场比对。 + _fov_issues = check_fov_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 _fov_issues: + actions.append(LogInfo(msg=f"[gazebo_sim] ⚠ {_w}")) + + # 4a) 世界相机的 TF 兜底 + # + # Gazebo **不会**为 world 里的 产生 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}, + ], + ) + ) + + # --- 5) spawn 机器人 ----------------------------------------------------- # -world 必须给对:world 名写错时 Gazebo **不报错**,只是实体没被创建, # 表现成"launch 一切正常但 RViz 里什么都没有"。 actions.append( @@ -201,7 +322,7 @@ def _prepare(context, *args, **kwargs): ) ) - # --- 5) 控制器 ----------------------------------------------------------- + # --- 6) 控制器 ----------------------------------------------------------- # 用 joint_trajectory_controller 而不是直接给关节发位置:应用层统一按 # rizon_arm_controller/follow_joint_trajectory 发轨迹,这个接口名与真机一致。 controllers = [ @@ -236,8 +357,18 @@ def _prepare(context, *args, **kwargs): " 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" + " python3 scripts/check_sim_cameras.py # ★ 相机的真正验收\n" " 机械臂应在重力下**保持站住**。若它瘫下去或消失,说明描述有问题," - "参考 arm_sim_gazebo/urdf_normalize.py 顶部列的两个上游缺陷。" + "参考 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,启动时会自动比对告警。" ) ) ) diff --git a/src/arm_sim_gazebo/setup.py b/src/arm_sim_gazebo/setup.py index c321dc3..ffb456e 100644 --- a/src/arm_sim_gazebo/setup.py +++ b/src/arm_sim_gazebo/setup.py @@ -34,13 +34,17 @@ setup( license='Apache-2.0', entry_points={ 'console_scripts': [ - # devices/sim/gazebo/manifest.yaml 里声明的那个可执行名,别改。 - # 它只做"准备描述 + 转发给 launch 文件",逻辑在 - # arm_sim_gazebo/gazebo_sim_launcher.py。 - 'gazebo_sim_launcher = arm_sim_gazebo.gazebo_sim_launcher:main', - # 独立小工具:只看归一化做了什么,不启动 Gazebo。 - # 排查"为什么 Gazebo 说我的 link 不存在"时非常省时间。 - 'urdf_normalize = arm_sim_gazebo.urdf_normalize:main', + # devices/sim/gazebo/manifest.yaml 里声明的那个可执行名,别改。 + # 它只做"准备描述 + 转发给 launch 文件",逻辑在 + # arm_sim_gazebo/gazebo_sim_launcher.py。 + 'gazebo_sim_launcher = arm_sim_gazebo.gazebo_sim_launcher:main', + # 仿真相机适配器:把桥接过来的 gz 相机变成应用期望的 realsense + # 话题(改名 + 光学系 frame_id + 深度 米→毫米)。 + # 由 gazebo_sim.launch.py 拉起,一般不手跑;单独调试时可以用。 + 'sim_camera_adapter = arm_sim_gazebo.sim_camera_adapter:main', + # 独立小工具:只看归一化做了什么,不启动 Gazebo。 + # 排查"为什么 Gazebo 说我的 link 不存在"时非常省时间。 + 'urdf_normalize = arm_sim_gazebo.urdf_normalize:main', ], }, ) diff --git a/src/arm_sim_gazebo/test/test_robot_description.py b/src/arm_sim_gazebo/test/test_robot_description.py index 5ce0c19..64e8b8b 100644 --- a/src/arm_sim_gazebo/test/test_robot_description.py +++ b/src/arm_sim_gazebo/test/test_robot_description.py @@ -21,6 +21,7 @@ from arm_sim_gazebo.robot_description import ( # noqa: E402 check_base_plugins, default_world, expand_xacro, + model_link_pose, prepare_urdf, repo_root, world_name, @@ -262,3 +263,218 @@ def test_prepare_urdf_missing_controllers_is_actionable(tmp_path): with pytest.raises(SimDescriptionError) as ei: prepare_urdf(controllers_file=tmp_path / "nope.yaml") assert "控制器配置不存在" in str(ei.value) + + +# --------------------------------------------------------------------------- +# 从 world 文件解析模型位姿(世界相机的 TF 靠它) +# --------------------------------------------------------------------------- +# 这组测试存在的理由:Gazebo 不为 world 里的 产生 ROS TF, +# 而 D435 这类固定相机就属于这种情况。位姿靠解析而不是重复配置, +# 因此"解析对不对"直接决定了相机看到的东西和它自称的位置是否一致。 +# 解析错了不会报错,只会让三维点整体偏移或转错。 + + +def _world_file(tmp_path, body: str) -> Path: + p = tmp_path / "w.world" + p.write_text(f'\n\n{body}\n\n', encoding="utf-8") + return p + + +def test_model_pose_identity_link(tmp_path): + """link 没有自己的 pose 时,位姿就是模型位姿。""" + w = _world_file( + tmp_path, + """ + + + true + -0.45 0.0 1.35 0.0 0.872665 3.14159 + + + 0.1 0 0 0 0 0 + + + + + """, + ) + (pos, rpy) = model_link_pose(w, "d435", "link") + assert pos == pytest.approx((-0.45, 0.0, 1.35)) + assert rpy == pytest.approx((0.0, 0.872665, 3.14159)) + + +def test_link_own_pose_is_composed(tmp_path): + """SDF 的 是相对父元素的,模型位姿与 link 位姿必须复合。""" + w = _world_file( + tmp_path, + """ + + + 1 0 0 0 0 0 + + 0 1 0 0 0 0 + + + + + """, + ) + pos, _ = model_link_pose(w, "m", "l") + assert pos == pytest.approx((1.0, 1.0, 0.0)) + + +def test_link_translation_is_rotated_by_model_rotation(tmp_path): + """模型绕 Z 转 90° 时,link 的 (1,0,0) 应落到世界的 (0,1,0)。""" + w = _world_file( + tmp_path, + """ + + + 0 0 0 0 0 1.5707963 + + 1 0 0 0 0 0 + + + + + """, + ) + pos, rpy = model_link_pose(w, "m", "l") + assert pos == pytest.approx((0.0, 1.0, 0.0), abs=1e-6) + assert rpy[2] == pytest.approx(1.5707963, abs=1e-6) + + +def test_link_own_pose_ignores_child_visual_pose(tmp_path): + """link 自己的 pose 必须在第一个 // 之前取, + 否则会把子元素的 pose 误当成 link 的。""" + w = _world_file( + tmp_path, + """ + + + 2 2 2 0 0 0 + + 1 1 1 0 0 0 + + 9 9 9 0 0 0 + + + + + """, + ) + pos, _ = model_link_pose(w, "m", "l") + assert pos == pytest.approx((3.0, 3.0, 3.0)) # 不是 2+9 + + +def test_missing_pose_defaults_to_identity(tmp_path): + w = _world_file( + tmp_path, + """ + + + + + + """, + ) + pos, rpy = model_link_pose(w, "m", "l") + assert pos == pytest.approx((0.0, 0.0, 0.0)) + assert rpy == pytest.approx((0.0, 0.0, 0.0)) + + +def test_model_only_query(tmp_path): + """link 留空 = 取模型本身位姿。""" + w = _world_file( + tmp_path, + """ + + 5 6 7 0 0 0 + + """, + ) + pos, _ = model_link_pose(w, "m") + assert pos == pytest.approx((5.0, 6.0, 7.0)) + + +def test_single_quotes_accepted(tmp_path): + """手写 world 时单引号也会出现,两种都要认。""" + w = _world_file( + tmp_path, + """ + + 1 2 3 0 0 0 + + """, + ) + pos, _ = model_link_pose(w, "m", "l") + assert pos == pytest.approx((1.0, 2.0, 3.0)) + + +def test_nested_model_does_not_break_extraction(tmp_path): + """ 可以嵌套 (组合体)。找第一个 会截断错位, + 所以 _find_block 要数嵌套深度。""" + w = _world_file( + tmp_path, + """ + + + 0 0 0 0 0 0 + + 9 9 9 0 0 0 + + + 1 0 0 0 0 0 + + + """, + ) + pos, _ = model_link_pose(w, "outer", "outer_link") + assert pos == pytest.approx((1.0, 0.0, 0.0)) + + +def test_missing_model_error_mentions_cameras_yaml(tmp_path): + """报错要指向 cameras.yaml 的 world_model 字段 —— 配错时症状是 + 'tf 查询报 frame does not exist',与相机本身无关,容易查错方向。""" + w = _world_file(tmp_path, '') + with pytest.raises(SimDescriptionError) as ei: + model_link_pose(w, "d435", "link") + assert "d435" in str(ei.value) + assert "cameras.yaml" in str(ei.value) + + +def test_missing_link_error_is_clear(tmp_path): + w = _world_file(tmp_path, '') + with pytest.raises(SimDescriptionError) as ei: + model_link_pose(w, "m", "link") + assert "link" in str(ei.value) + + +def test_real_assembly_world_d435_pose(): + """仓库里那份 assembly.world 的 d435 位姿必须与文件里写的一致。 + + 这条是"防回归"的:d435 的 TF 完全依赖这个解析结果, + 位姿解析悄悄出错不会报任何错,只会让相机看到的东西与自称位置不符。 + """ + root = None + for cand in [Path.cwd(), Path.cwd().parent, Path(__file__).resolve().parent]: + cur = cand.resolve() + for _ in range(8): + if (cur / "devices/sim/gazebo/urdf/arm.sim.urdf.xacro").is_file(): + root = cur + break + cur = cur.parent + if root: + break + if root is None: + pytest.skip("找不到仓库根") + + world = root / "devices/sim/gazebo/worlds/assembly.world" + if not world.is_file(): + pytest.skip("assembly.world 不存在") + + pos, rpy = model_link_pose(world, "d435", "link") + assert pos == pytest.approx((-0.45, 0.0, 1.35), abs=1e-6) + # yaw 必须是 0:曾经写成 3.14159,把视线从"+X 俯视"翻成"-X 俯视", + # 相机于是背对台面、只看得见地面。这条断言就是防止它被改回去。 + assert rpy == pytest.approx((0.0, 0.872665, 0.0), abs=1e-6) diff --git a/src/arm_sim_gazebo/test/test_sim_cameras.py b/src/arm_sim_gazebo/test/test_sim_cameras.py new file mode 100644 index 0000000..c3e3887 --- /dev/null +++ b/src/arm_sim_gazebo/test/test_sim_cameras.py @@ -0,0 +1,407 @@ +"""sim_cameras 的单测。 + +重点在深度换算:米 → 毫米差 1000 倍,而单目预览里**看不出任何异常**, +只能靠测试兜住。另一类是配置错误(ros_ns 重复、缺必填键), +它们会让两台相机互相覆盖话题,症状是"只有一台出图",也很难查。 +""" + +import math + +import numpy as np +import pytest + +from arm_sim_gazebo.sim_cameras import ( + MAX_MM, + CameraConfigError, + CameraSpec, + bridge_args, + depth_m_to_mm16, + load_cameras, + rpy_to_quat, +) + + +# --------------------------------------------------------------------- 辅助 +def _write(tmp_path, text): + p = tmp_path / "cameras.yaml" + p.write_text(text, encoding="utf-8") + return p + + +MINIMAL = """ +cameras: + - gz_ns: /d405 + ros_ns: d405 + link_frame: sim_d405_link + optical_frame: sim_d405_color_optical_frame + horizontal_fov: 1.5184 +""" + + +# --------------------------------------------------------- 话题名派生 +def test_topic_names_follow_realsense_convention(): + """话题名必须与真机 realsense 完全一致,否则应用要为仿真改代码。""" + s = CameraSpec( + gz_ns="/d405", + ros_ns="d405", + link_frame="sim_d405_link", + optical_frame="sim_d405_color_optical_frame", + ) + assert s.ros_color == "/d405/color/image_raw" + assert s.ros_info == "/d405/color/camera_info" + assert s.ros_depth == "/d405/aligned_depth_to_color/image_raw" + assert s.gz_image == "/d405/image" + assert s.gz_depth == "/d405/depth_image" + assert s.gz_info == "/d405/camera_info" + + +def test_gz_ns_normalized_without_double_slash(): + s = CameraSpec(gz_ns="/d405", ros_ns="d405", link_frame="l", optical_frame="o") + assert "//" not in s.gz_image + + +# --------------------------------------------------------- 配置加载 +def test_load_minimal_config(tmp_path): + specs = load_cameras(_write(tmp_path, MINIMAL)) + assert len(specs) == 1 + assert specs[0].ros_ns == "d405" + assert specs[0].depth is True # 默认开深度 + assert specs[0].is_world_mounted is False + + +def test_load_missing_file_raises_with_hint(tmp_path): + with pytest.raises(CameraConfigError) as e: + load_cameras(tmp_path / "nope.yaml") + # 报错必须给出修法,而不是一句 FileNotFoundError + assert "cameras.yaml" in str(e.value) + + +def test_load_empty_list_raises(tmp_path): + with pytest.raises(CameraConfigError) as e: + load_cameras(_write(tmp_path, "cameras: []\n")) + assert "cameras" in str(e.value) + + +def test_load_missing_required_key_raises(tmp_path): + bad = """ + cameras: + - gz_ns: /d405 + ros_ns: d405 + link_frame: sim_d405_link + """ + with pytest.raises(CameraConfigError) as e: + load_cameras(_write(tmp_path, bad)) + assert "optical_frame" in str(e.value) + + +def test_duplicate_ros_ns_rejected(tmp_path): + """两台相机用同一个 ros_ns 会互相覆盖话题,表现为'只有一台出图'。""" + dup = """ + cameras: + - gz_ns: /d405 + ros_ns: d405 + link_frame: a + optical_frame: b + horizontal_fov: 1.5184 + - gz_ns: /d406 + ros_ns: d405 + link_frame: c + optical_frame: d + horizontal_fov: 1.5184 + """ + with pytest.raises(CameraConfigError) as e: + load_cameras(_write(tmp_path, dup)) + assert "重复" in str(e.value) + + +def test_world_mounted_flag(tmp_path): + cfg = """ + cameras: + - gz_ns: /d435 + ros_ns: d435 + link_frame: d435_link + optical_frame: d435_color_optical_frame + horizontal_fov: 1.5184 + world_model: d435 + world_link: link + """ + specs = load_cameras(_write(tmp_path, cfg)) + assert specs[0].is_world_mounted is True + assert specs[0].world_link == "link" + + +# --------------------------------------------------------- 深度换算 +def test_depth_basic_conversion(): + """1.5m → 1500mm。这是最容易错的那一项(差 1000 倍)。""" + d = np.array([[1.5, 0.25]], dtype=np.float32) + mm = depth_m_to_mm16(d) + assert mm.dtype == np.uint16 + assert mm.tolist() == [[1500, 250]] + + +def test_depth_rounding(): + d = np.array([1.2344, 1.2346], dtype=np.float32) + mm = depth_m_to_mm16(d) + assert mm.tolist() == [1234, 1235] + + +def test_depth_non_finite_becomes_zero(): + """gz 在没命中时可能给 inf,真机 realsense 给 0 —— 统一成 0。""" + d = np.array([np.inf, -np.inf, np.nan], dtype=np.float32) + assert depth_m_to_mm16(d).tolist() == [0, 0, 0] + + +def test_depth_zero_and_negative_become_zero(): + d = np.array([0.0, -0.5], dtype=np.float32) + assert depth_m_to_mm16(d).tolist() == [0, 0] + + +def test_depth_too_small_rounds_to_zero(): + """0.0001m 舍入后是 0mm —— 必须落到'无读数',不能变成 1 或负数回绕。""" + d = np.array([0.0001, 0.0004, 0.0006], dtype=np.float32) + assert depth_m_to_mm16(d).tolist() == [0, 0, 1] + + +def test_depth_beyond_uint16_becomes_zero_not_saturated(): + """超出 65.535m 按'无读数'处理。 + + 饱和到 65535 会让远处凭空出现一个 65.5m 的平面,比'无效'更有害 —— + 下游会把它当成真实几何去用。 + """ + d = np.array([70.0, 100.0, 0.0], dtype=np.float32) + assert depth_m_to_mm16(d).tolist() == [0, 0, 0] + + +def test_depth_exact_uint16_max_is_kept(): + d = np.array([MAX_MM / 1000.0], dtype=np.float32) + assert depth_m_to_mm16(d).tolist() == [MAX_MM] + + +def test_depth_shape_preserved(): + d = np.full((480, 640), 1.0, dtype=np.float32) + mm = depth_m_to_mm16(d) + assert mm.shape == (480, 640) + assert mm.dtype == np.uint16 + assert (mm == 1000).all() + + +def test_depth_accepts_float64_input(): + """上游若给了 float64 也不能崩(gz 是 float32,但不做假设)。""" + d = np.array([2.0], dtype=np.float64) + assert depth_m_to_mm16(d).tolist() == [2000] + + +# --------------------------------------------------------- 桥接参数 +def test_bridge_args_include_depth_when_enabled(): + specs = [ + CameraSpec(gz_ns="/d405", ros_ns="d405", link_frame="l", optical_frame="o", depth=True) + ] + args = bridge_args(specs) + assert "/d405/image@sensor_msgs/msg/Image[gz.msgs.Image" in args + assert "/d405/camera_info@sensor_msgs/msg/CameraInfo[gz.msgs.CameraInfo" in args + assert "/d405/depth_image@sensor_msgs/msg/Image[gz.msgs.Image" in args + + +def test_bridge_args_skip_depth_when_disabled(): + specs = [ + CameraSpec(gz_ns="/d405", ros_ns="d405", link_frame="l", optical_frame="o", depth=False) + ] + args = bridge_args(specs) + assert not any("depth_image" in a for a in args) + assert len(args) == 2 + + +# --------------------------------------------------------- 旋转工具 +def test_rpy_to_quat_identity(): + x, y, z, w = rpy_to_quat((0.0, 0.0, 0.0)) + assert (x, y, z, w) == pytest.approx((0.0, 0.0, 0.0, 1.0)) + + +def test_rpy_to_quat_is_unit_norm(): + for rpy in [(-1.5708, 0.0, -1.5708), (0.0, 0.872665, 3.14159), (0.3, -0.7, 1.2)]: + x, y, z, w = rpy_to_quat(rpy) + assert np.isclose(x * x + y * y + z * z + w * w, 1.0, atol=1e-9) + + +def test_optical_rpy_is_the_documented_value(): + """光学系的旋转值本身也是契约:写错这一项三维点会整体转错。""" + from arm_sim_gazebo.sim_cameras import OPTICAL_RPY + + assert OPTICAL_RPY == pytest.approx((-1.5708, 0.0, -1.5708)) + + +# --------------------------------------------------------------- 内参重算 +# +# 这一组是"仿真能不能用"的地基:gz 自己发的 camera_info 是错的,内参全靠 +# 这里算。算错的话仿真里所有三维点位都偏,而单目预览完全正常。 +def _spec(fov=1.5184, **kw): + return CameraSpec( + gz_ns="/c", + ros_ns="c", + link_frame="l", + optical_frame="o", + horizontal_fov=fov, + **kw, + ) + + +def test_intrinsics_match_the_measured_ground_truth(): + """用逆解实测出来的值反验:87° 视场、640x480 → fx=337.2, cx=320, cy=240。 + + 这组数是本机拿已知地面平面做最小二乘解出来的(残差中位 0.2mm), + 不是从公式推的 —— 所以它能同时抓住"公式写错"和"视场理解错"。 + """ + from arm_sim_gazebo.sim_cameras import intrinsics + + k, p = intrinsics(_spec(), 640, 480) + assert k[0] == pytest.approx(337.2, abs=0.1) # fx + assert k[4] == pytest.approx(337.2, abs=0.1) # fy + assert k[2] == pytest.approx(320.0) # cx + assert k[5] == pytest.approx(240.0) # cy + assert p[:4] == pytest.approx([k[0], 0.0, k[2], 0.0]) + assert k[1] == k[3] == k[6] == k[7] == 0.0 and k[8] == 1.0 + + +def test_intrinsics_scales_with_fov_and_size(): + """视场变大 → 焦距变小;分辨率翻倍 → 焦距与主点同步翻倍。""" + from arm_sim_gazebo.sim_cameras import intrinsics + + k_narrow, _ = intrinsics(_spec(fov=1.0), 640, 480) + k_wide, _ = intrinsics(_spec(fov=2.0), 640, 480) + assert k_narrow[0] > k_wide[0] + + k_1x, _ = intrinsics(_spec(), 640, 480) + k_2x, _ = intrinsics(_spec(), 1280, 960) + assert k_2x[0] == pytest.approx(2 * k_1x[0]) + assert k_2x[2] == pytest.approx(2 * k_1x[2]) + assert k_2x[5] == pytest.approx(2 * k_1x[5]) + + +def test_intrinsics_rejects_bad_fov(): + from arm_sim_gazebo.sim_cameras import intrinsics + + for bad in (0.0, -1.0, math.pi, 4.0): + with pytest.raises(CameraConfigError): + intrinsics(_spec(fov=bad), 640, 480) + + +def test_intrinsics_rejects_bad_size(): + from arm_sim_gazebo.sim_cameras import intrinsics + + for w, h in ((0, 480), (640, 0), (-1, 480)): + with pytest.raises(CameraConfigError): + intrinsics(_spec(), w, h) + + +def test_load_cameras_requires_horizontal_fov(tmp_path): + """缺 horizontal_fov 必须报错,不能默认成某个值 —— 默认值错了看不出来。""" + p = _write(tmp_path, """ +cameras: + - gz_ns: /d405 + ros_ns: d405 + link_frame: sim_d405_link + optical_frame: sim_d405_color_optical_frame +""") + with pytest.raises(CameraConfigError) as ei: + load_cameras(p) + assert "horizontal_fov" in str(ei.value) + + +def test_load_cameras_rejects_absurd_fov(tmp_path): + p = _write(tmp_path, """ +cameras: + - gz_ns: /d405 + ros_ns: d405 + link_frame: sim_d405_link + optical_frame: sim_d405_color_optical_frame + horizontal_fov: 0 +""") + with pytest.raises(CameraConfigError) as ei: + load_cameras(p) + assert "horizontal_fov" in str(ei.value) + + +# ----------------------------------------------------------- 视场一致性比对 +def test_fov_check_flags_world_camera_mismatch(tmp_path): + """世界相机:按 精确匹配,能抓到 cameras.yaml 与 world 不一致。""" + from arm_sim_gazebo.sim_cameras import check_fov_consistency + + world = tmp_path / "assembly.world" + world.write_text( + """ + + + /d435 + 1.5184 + + +""", + encoding="utf-8", + ) + spec = CameraSpec( + gz_ns="/d435", + ros_ns="d435", + link_frame="d435_link", + optical_frame="d435_color_optical_frame", + horizontal_fov=1.5184, + world_model="d435", + world_link="link", + ) + assert check_fov_consistency([spec], world_file=world) == [] + + bad = CameraSpec(**{**spec.__dict__, "horizontal_fov": 1.2}) + issues = check_fov_consistency([bad], world_file=world) + assert len(issues) == 1 and "1.5184" in issues[0] and "1.2" in issues[0] + + +def test_fov_check_flags_urdf_camera_when_value_absent(tmp_path): + """腕上相机:视场在 xacro 里,只能"文件里出现过才算过",但要能抓到缺失。""" + from arm_sim_gazebo.sim_cameras import check_fov_consistency + + xacro = tmp_path / "arm.sim.sensors.xacro" + xacro.write_text("1.5184", encoding="utf-8") + spec = _spec() + assert check_fov_consistency([spec], sensors_xacro=xacro) == [] + + xacro.write_text("1.2", encoding="utf-8") + issues = check_fov_consistency([spec], sensors_xacro=xacro) + assert len(issues) == 1 and "1.2" in issues[0] + + +def test_fov_check_warns_when_world_camera_sensor_missing(tmp_path): + """world 里找不到对应 时要告警,而不是静默放过。""" + from arm_sim_gazebo.sim_cameras import check_fov_consistency + + world = tmp_path / "assembly.world" + world.write_text("", encoding="utf-8") + spec = CameraSpec( + gz_ns="/d435", + ros_ns="d435", + link_frame="d435_link", + optical_frame="d435_color_optical_frame", + horizontal_fov=1.5184, + world_model="d435", + ) + issues = check_fov_consistency([spec], world_file=world) + assert len(issues) == 1 and "找不到" in issues[0] + + +def test_fov_check_against_real_repo_files(): + """对仓库里真实的两份 SDF 做一次回归:现在应当一致。 + + 这条测试的价值在于:以后谁改了 arm.sim.sensors.xacro 或 assembly.world 的 + 视场却忘了同步 cameras.yaml,这里就会红。内参不一致是本模块开头 + 警告的那类"看不出来"的错误,必须有个自动化兜底。 + """ + from pathlib import Path + + from arm_sim_gazebo.sim_cameras import check_fov_consistency + + root = Path(__file__).resolve().parents[3] + specs = load_cameras(root=root) + issues = check_fov_consistency( + specs, + world_file=root / "devices/sim/gazebo/worlds/assembly.world", + sensors_xacro=root / "devices/sim/gazebo/urdf/arm.sim.sensors.xacro", + ) + assert issues == [], "视场与 SDF 不一致:\n" + "\n".join(issues) diff --git a/src/arm_sim_gazebo/test/test_sim_site.py b/src/arm_sim_gazebo/test/test_sim_site.py new file mode 100644 index 0000000..6cf5b37 --- /dev/null +++ b/src/arm_sim_gazebo/test/test_sim_site.py @@ -0,0 +1,172 @@ +"""sim_site 的单测。 + +这个模块存在的理由就是"别再让 site.yaml 静默无效",所以用例的重点是: + - 优先级真的按 --world > site.yaml > 内置默认 走; + - site.yaml 缺失/写坏时的行为是可预期的(不崩、但有清晰报错); + - 声明了却没接线的键**必须**告警,而不是安静地忽略。 +""" + +from pathlib import Path + +import pytest + +from arm_sim_gazebo.sim_site import ( + DEFAULT_JOINT_PREFIX, + SimSite, + SimSiteError, + load_sim_site, + physics_enabled_in_world, + resolve_world, + unwired_warnings, +) + + +def _site(tmp_path: Path, body: str) -> Path: + (tmp_path / "config").mkdir(parents=True, exist_ok=True) + (tmp_path / "config" / "site.yaml").write_text(body, encoding="utf-8") + return tmp_path + + +# ------------------------------------------------------------------ 读取 +def test_missing_site_yaml_falls_back_to_defaults(tmp_path): + """site.yaml 不存在不算错 —— 它含真实设备 ID,本就不该提交。""" + site = load_sim_site(tmp_path) + assert site.world == "" + assert site.physics_enable is True + assert site.joint_prefix == DEFAULT_JOINT_PREFIX + assert site.source is None + assert site.present == set() + + +def test_reads_sim_section(tmp_path): + root = _site( + tmp_path, + "sim:\n world: devices/sim/gazebo/worlds/assembly.world\n" + " physics_enable: false\n joint_prefix: 'sim_'\n", + ) + site = load_sim_site(root) + assert site.world == "devices/sim/gazebo/worlds/assembly.world" + assert site.physics_enable is False + assert site.joint_prefix == "sim_" + assert site.present == {"world", "physics_enable", "joint_prefix"} + + +def test_no_sim_section_is_fine(tmp_path): + site = load_sim_site(_site(tmp_path, "robot:\n sn: X\n")) + assert site.world == "" + assert site.present == set() + + +def test_malformed_yaml_raises_with_hint(tmp_path): + with pytest.raises(SimSiteError) as ei: + load_sim_site(_site(tmp_path, "sim: [unclosed\n")) + assert "site.yaml" in str(ei.value) + + +def test_non_mapping_sim_section_raises(tmp_path): + """sim 段写成列表属于典型手误(缩进错了),要报出来而不是当空处理。""" + with pytest.raises(SimSiteError): + load_sim_site(_site(tmp_path, "sim:\n - world\n")) + + +# ---------------------------------------------------------------- 优先级 +def test_cli_world_beats_site_yaml(tmp_path): + root = _site(tmp_path, "sim:\n world: from_site.world\n") + p, origin = resolve_world("from_cli.world", root, fallback=Path("fallback.world")) + assert p == root / "from_cli.world" + assert "命令行" in origin + + +def test_site_yaml_beats_builtin_default(tmp_path): + root = _site(tmp_path, "sim:\n world: from_site.world\n") + p, origin = resolve_world("", root, fallback=Path("fallback.world")) + assert p == root / "from_site.world" + assert "sim.world" in origin + + +def test_builtin_default_used_when_site_silent(tmp_path): + root = _site(tmp_path, "other: 1\n") + p, origin = resolve_world("", root, fallback=Path("devices/sim/gazebo/worlds/x.world")) + assert p == root / "devices/sim/gazebo/worlds/x.world" + assert "内置默认" in origin + + +def test_absolute_paths_are_not_rewritten(tmp_path): + root = _site(tmp_path, "sim:\n world: from_site.world\n") + p, _ = resolve_world("/tmp/abs.world", root, fallback=Path("f.world")) + assert p == Path("/tmp/abs.world") + p2, _ = resolve_world("", tmp_path, fallback=Path("f.world")) + assert p2.is_absolute() + + +def test_raises_when_nothing_available(tmp_path): + with pytest.raises(SimSiteError): + resolve_world("", tmp_path, fallback=None) + + +# ------------------------------------------------------- 未接线键的告警 +def test_unwired_joint_prefix_warns(tmp_path): + """site.yaml 写了一个跟实际不同的前缀 → 必须告警,不能静默。""" + site = load_sim_site(_site(tmp_path, "sim:\n joint_prefix: 'robot_'\n")) + msgs = unwired_warnings(site, actual_joint_prefix="sim_") + assert len(msgs) == 1 + assert "'robot_'" in msgs[0] and "'sim_'" in msgs[0] + + +def test_matching_joint_prefix_does_not_warn(tmp_path): + site = load_sim_site(_site(tmp_path, "sim:\n joint_prefix: 'sim_'\n")) + assert unwired_warnings(site, actual_joint_prefix="sim_") == [] + + +def test_unset_keys_never_warn(tmp_path): + """site.yaml 没提这些键时不该有任何告警 —— 默认值不需要解释。""" + site = load_sim_site(_site(tmp_path, "sim:\n world: w.world\n")) + assert unwired_warnings(site, actual_joint_prefix="sim_", physics_in_world=True) == [] + + +def test_unwired_physics_warns_only_on_conflict(tmp_path): + site = load_sim_site(_site(tmp_path, "sim:\n physics_enable: false\n")) + # 世界文件里物理其实是开的 → 用户的要求没被满足,必须说 + assert len(unwired_warnings(site, physics_in_world=True)) == 1 + # 世界文件里物理本来就关着 → 结果与用户要求一致,没什么可说 + assert unwired_warnings(site, physics_in_world=False) == [] + + +def test_unwired_warnings_are_skipped_without_actuals(): + """拿不到"实际值"时宁可不说,也不要凭猜测告警。""" + site = SimSite(joint_prefix="robot_", physics_enable=False) + site.present = {"joint_prefix", "physics_enable"} + assert unwired_warnings(site) == [] + + +# ------------------------------------------------- 从世界里判断物理是否开启 +def test_physics_enabled_in_world(tmp_path): + on = tmp_path / "on.world" + on.write_text("0 0 -9.8") + assert physics_enabled_in_world(on) is True + + off = tmp_path / "off.world" + off.write_text("0 0 0") + assert physics_enabled_in_world(off) is False + + # 没写 gravity → gz 默认是开 + none = tmp_path / "none.world" + none.write_text("") + assert physics_enabled_in_world(none) is True + + missing = tmp_path / "nope.world" + assert physics_enabled_in_world(missing) is None + + +def test_real_repo_site_yaml_is_consistent(): + """对仓库真实的 site.yaml 做回归:声明的 world 必须真的存在。 + + 这条能抓住"site.yaml 指向一个不存在的 world"——启动方会报错, + 但错误发生在几十秒的 Gazebo 初始化之后,不如在这里就红。 + """ + root = Path(__file__).resolve().parents[3] + site = load_sim_site(root) + if site.source is None: + pytest.skip("本机没有 config/site.yaml(属于正常情况)") + if site.world: + assert (root / site.world).is_file(), f"site.yaml 的 sim.world 不存在: {site.world}"