P5c-4 相机出图联调:桥接 d405/d435 到 ROS,并修掉两处静默的几何错误

相机链路(devices/sim/gazebo/config/cameras.yaml 是唯一真相)
  - sim_cameras.py        纯逻辑:话题映射 / 深度换算 / 内参 / 视场一致性比对
  - sim_camera_adapter.py 改光学系 frame_id、32FC1 米 → 16UC1 毫米、覆盖内参
  - gazebo_sim.launch.py  桥接 + 适配器 + 世界相机的静态 TF(位姿从 world 解析)

本轮修掉两个**静默**的几何错误(都能出图、都能查到 TF,但点位是错的)

  1. 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 的视场重算并覆盖。
     代价:cameras.yaml 的 horizontal_fov 必须与 SDF 一致,启动时自动比对告警。

  2. assembly.world 里 D435 的 rpy 写成 (0, 50°, 180°),
     而 URDF 相机的视线是**本体 X 轴**、rpy 复合顺序又是 Rz·Ry·Rx,
     于是"先俯 50° 再偏航 180°"把视线翻到了背面:相机背对台面、只看地面。
     它不报错,而且几何自洽(点确实落在真实地面上),只查数值发现不了。
     改为 (0, 50°, 0),并加回归测试锁住。

新增验收工具 + 测试
  - scripts/check_sim_cameras.py  反投影深度、要求命中地面/台面才算通过。
    "有图像"不是验收标准;实测 d435 台面 11.9%/地面 75.3%、d405 台面 37.8%。
  - 97 项单测(新增 sim_cameras 22 项、sim_site 16 项)

顺带修掉一处"改了配置静默无效"
  - sim.world 此前硬编码在 robot_description._WORLD_REL,而 site.yaml 与
    manifest.yaml 都宣称改它就生效。现在真的读了:
    优先级 --world > site.yaml:sim.world > 内置默认(相对路径按仓库根解析)。
  - physics_enable / joint_prefix 仍未接线,但若其值与实际状态冲突,
    启动时会告警(sim_site.unwired_warnings),不再静默忽略。

文档
  - devices/sim/gazebo/README.md 重写:原文还写着"仿真不是 Gazebo"、
    相机插件用 libgazebo_ros_camera.so,与实现早已不符。现分三部分:
    哪个"仿真"是真的 / 现状与缺什么 / 五条静默失效的坑及怎么验。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
arm_dev
2026-09-23 15:19:16 +08:00
parent f67f59f383
commit 47ad9d9a8b
17 changed files with 2690 additions and 101 deletions
+12 -6
View File
@@ -480,13 +480,19 @@ network:
# 设计约束:仿真与真机共用同一套参数(servo.* / hole_detect.stage2.*), # 设计约束:仿真与真机共用同一套参数(servo.* / hole_detect.stage2.*),
# 只有物理引擎自身的参数放在这里,避免「仿真调好的值搬到真机不生效」。 # 只有物理引擎自身的参数放在这里,避免「仿真调好的值搬到真机不生效」。
sim: sim:
# ⚠️ 这一段目前**没有代码读取**(gazebo_sim_launcher 还没实现,见 # 这一段由 arm_sim_gazebo.sim_site 真正读取(此前是"声明了但没人读",
# devices/sim/gazebo/manifest.yaml 的进度段)。现在跑 `run_sim.sh` 得到的 # 改了毫无反应也不报错)。三个键的接线状况**不一样**,务必看清:
# 仍是 mock 驱动(假数据 + RViz),不是物理仿真。
# #
# 之所以先把 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" platform: "gazebo"
world: "devices/sim/gazebo/worlds/assembly.world" world: "devices/sim/gazebo/worlds/assembly.world"
physics_enable: true physics_enable: true
+117 -64
View File
@@ -1,95 +1,148 @@
# devices/sim/gazebo/ —— Gazebo 仿真平台 # devices/sim/gazebo/ —— Gazebo 仿真平台
## 先读这段:当前仿真是假的 > 这份文档记录的是**已经跑通的事实**和**实测踩到的坑**。仿真里绝大多数错误都是
> 静默的(不报错、能出图、能查到 TF,结果却是错的),所以下面每条坑都写了
**现在项目里的「仿真」并不是 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 真正跑起来**(当前必须做的前置工作),
**第二部分才是日常怎么用**。不要跳过第一部分。
--- ---
## 第一部分:把 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 reference="${prefix}link1">
<material>Gazebo/Grey</material>
</gazebo>
```
并在 `arm.ros2_control.xacro` 里把 `ros2_control` 标签的 hardware 插件从 ## 第一部分:现状(截至 P5c-4,2026-09-23)
`flexiv_hardware/FlexivHardware` 换成 `gazebo_ros2_control/GazeboSystem`。
### 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 | | `models/` | 工件(k0/k1/k2)与通止规模型 | 装配任务(P6)跑不起来 |
| 手外相机 | `libgazebo_ros_camera.so` | D435 | | `sensor_plugins/` | Orisys 三向力插件 | **无法验证力控**(S07 接触、S08 拧紧判据) |
| 深度 | `libgazebo_ros_depth_camera.so` | 对齐深度流 |
| 触觉力 | **需要自己写插件** | Orisys |
**最关键的一条:Orisys 视触觉没有现成的 Gazebo 插件。** > **Orisys 视触觉没有现成的 Gazebo 插件。** 3 路切向力 + 1 路法向力 + 深度图 +
3 路切向力 + 1 路法向力 + 深度图 + 力矢量图 这套数据需要自定义 Gazebo 插件来模拟。 > 力矢量图这套数据必须自己写插件。建议做法:订阅 Gazebo 的接触传感器数据,
在它做出来之前,仿真里**无法验证通止规装配的力控部分**(S07 接触、S08 拧紧判定)。 > 按接触深度与相对滑动速度合成一组"类触觉"读数,再按真实传感器的量纲发布到
建议的自定义插件做法:订阅 Gazebo 的接触传感器数据,按接触深度和相对滑动速度合成 > 同样的话题上(`/tactile/{left,right}/status`,`arm_msgs/TactileStatus`)。
出一组「类触觉」读数,再按真实传感器的量纲(任意单位增量)发布到同样的内部话题上。 > 在它做出来之前,仿真里跑 P6 只能验证**几何与流程**,力判据要等真机。
### 4. 工件与通止规模型
需要给工件(含 k0 中心孔、k1/k2 螺纹孔)和通止规建简化网格,并保证:
- 工件的 k0/k1/k2 三个点在模型坐标系里的位置与实际一致 —— 否则推理出的坐标映射到仿真里会对不上;
- 螺纹孔要能表达「拧入」的几何阻力,否则 S08 的力反馈无法触发。
--- ---
## 第二部分:日常怎么用 ## 第二部分:日常怎么用
### 启动
```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` | | 世界文件 | `worlds/` | `.world` / `.sdf` | `<场景名>.world`,如 `assembly.world` |
| 机器人模型 | `urdf/` | `.urdf` / `.xacro` | 必须以 `sim_` 为关节前缀,与真实臂区分 | | 机器人模型 | `urdf/` | `.urdf` / `.xacro` | 关节前缀必须与真机不同(仿真用 `sim_`) |
| 工件/夹具网格 | `models/<模型名>/` | `<模型名>.sdf` + `meshes/` | **模型名必须与推理用的类别名一致**,如 `workpiece` | | 工件/夹具网格 | `models/<模型名>/` | `<模型名>.sdf` + `meshes/` | **模型名必须与推理用的类别名一致**,如 `workpiece` |
| 传感器插件 | `sensor_plugins/` | `.so` 源码 + `CMakeLists.txt` | 插件类名以 `Orisys` 开头 | | 传感器插件 | `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`(通常已是) | 用哪个世界 | `site.yaml` → `sim.world`,或命令行 `--world` | ✔ 已接线(优先级:`--world` > `site.yaml` > 内置默认) |
4. 启动 `scripts/run_sim.sh` | 相机的视场/坐标系/话题 | `config/cameras.yaml`,**并且**同步 SDF 里对应的 `horizontal_fov` | ✔ 启动时自动比对,不一致会告警 |
| 物理开关 | `site.yaml` → `sim.physics_enable` | ✘ **未接线**。要真关,改 `worlds/assembly.world` 里的 `<gravity>` |
| 关节前缀 | `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 轴本体缺 `<inertial>` 包裹;夹爪有零质量 link 导致惯性叠加出 NaN | 只有 Gazebo 拒绝,RViz/MoveIt 完全正常 | 启动时**归一化**(`urdf_normalize.py`)。单独排查:`gazebo_sim_launcher --check` |
| 2 | world 里只要声明了**任何一个** `<plugin>`,就会挤掉默认的 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`(相机验收)。
+86
View File
@@ -0,0 +1,86 @@
# =============================================================================
# devices/sim/gazebo/config/cameras.yaml
#
# 仿真相机的**唯一数据源**:gazebo_sim.launch.py 里的 ros_gz_bridge 参数、
# sim_camera_adapter 节点、以及为世界相机补的静态 TF,全部从这份文件派生。
# 想加一台仿真相机,改这里一处即可(前提是它在 URDF / world 里已经有 <sensor>)。
#
# 与 site.yaml 的分工:
# 这里只描述"Gazebo 里有什么、要桥到 ROS 的哪个名字"。
# site.yaml 描述"真机上有哪些相机、序列号是多少"。仿真不读序列号 ——
# Gazebo 里没有 USB,序列号无从绑定。
# 两者的**话题命名约定必须一致**,这是应用层能不改代码跑仿真的前提。
#
# 话题命名约定(与真机 realsense 完全对齐,见 arm_application/handeye_realsense.py
# 的 REALSENSE_TOPIC_DEFAULTS 与 realsense_color_pub.py):
# /<ros_ns>/color/image_raw rgb8
# /<ros_ns>/color/camera_info plumb_bob
# /<ros_ns>/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 里该 <sensor> 的 horizontal_fov
# =============================================================================
cameras:
# --- 腕上相机(eye-in-hand),挂在机械臂法兰上 ------------------------------
- gz_ns: /d405 # Gazebo 传感器话题前缀(see arm.sim.sensors.xacro 的 <topic>)
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 文件里的 <model name>,world_link 是模型里的 <link name>。
world_model: d435
world_link: link
note: height 也与真机不同(真机 1280x720),同 D405 的说明
+45 -12
View File
@@ -3,7 +3,7 @@
# #
# Gazebo 仿真平台清单。 # Gazebo 仿真平台清单。
# #
# 进度(截至 P5c-3,如实记录,不要凭印象改): # 进度(截至 P5c-4,如实记录,不要凭印象改):
# ✔ 环境 独立 conda 环境 ros_gz_sim(Gazebo Fortress + ROS 2 Humble) # ✔ 环境 独立 conda 环境 ros_gz_sim(Gazebo Fortress + ROS 2 Humble)
# 安装见 scripts/install_sim_env.sh,激活见 scripts/sim_env.sh # 安装见 scripts/install_sim_env.sh,激活见 scripts/sim_env.sh
# ✔ 世界文件 worlds/assembly.world(sdformat 校验通过) # ✔ 世界文件 worlds/assembly.world(sdformat 校验通过)
@@ -16,12 +16,17 @@
# 与 build_sim.sh 的提示语引用,照做会报文件不存在) # 与 build_sim.sh 的提示语引用,照做会报文件不存在)
# ✘ 场景模型 models/ 还是空的(工件/通止规模型未做) # ✘ 场景模型 models/ 还是空的(工件/通止规模型未做)
# ✘ 触觉插件 sensor_plugins/ 还是空的(Orisys 三向力插件未做) # ✘ 触觉插件 sensor_plugins/ 还是空的(Orisys 三向力插件未做)
# ✘ 相机出图 D405/D435 传感器已在 Gazebo 里成功注册并发布话题, # ✔ 相机出图 D405/D435 已注册并桥接到 ROS,且**几何正确性已验证**
# 但还没接 ros_gz_bridge 到 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 轴本体缺 # 1. 官方 flexiv_description 直接喂 Gazebo 会被 sdformat 拒掉:7 轴本体缺
# <inertial> 包裹;夹爪有零质量 link 导致惯性叠加出 NaN。两者都只有 # <inertial> 包裹;夹爪有零质量 link 导致惯性叠加出 NaN。两者都只有
# Gazebo 会暴露,RViz/MoveIt 完全正常。已在启动时归一化解决。 # Gazebo 会暴露,RViz/MoveIt 完全正常。已在启动时归一化解决。
@@ -29,11 +34,21 @@
# Physics/UserCommands/SceneBroadcaster。少了 UserCommands, # Physics/UserCommands/SceneBroadcaster。少了 UserCommands,
# /world/<名>/create 服务不存在 → 模型根本没进 Gazebo,而 create 仍打印 # /world/<名>/create 服务不存在 → 模型根本没进 Gazebo,而 create 仍打印
# "OK creation of entity"。所以这三个系统必须在 world 里显式声明。 # "OK creation of entity"。所以这三个系统必须在 world 里显式声明。
# 3. gz 的插件搜索路径不含 $CONDA_PREFIX/lib(ros_gz_sim 只拼 LD_LIBRARY_PATH, # 3. gz 的插件搜索路径不含 $CONDA_PREFIX/lib(ros_gz_sim 的 launch 只拼
# 而 conda 激活不往那里加),于是 gz_ros2_control 报 "couldn't find shared # LD_LIBRARY_PATH,而 conda 激活不往那里加),于是 gz_ros2_control 报
# library" 而库其实就在那儿。必须在 ros2 launch 之前设 # "couldn't find shared library" 而库其实就在那儿。必须在 ros2 launch 之前设
# GZ_SIM_SYSTEM_PLUGIN_PATH;资源路径同理(否则 mesh 全加载不出来)。 # GZ_SIM_SYSTEM_PLUGIN_PATH;资源路径同理(否则 mesh 全加载不出来)。
# 两者都由 scripts/sim_env.sh 负责。详见该文件里的长注释。 # 两者都由 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 id: gazebo
@@ -47,8 +62,10 @@ version: "Fortress 6.16 (ignition-gazebo6, ros_gz + gz_ros2_control 0.7.15)"
description: > description: >
物理仿真平台。环境、世界、机器人描述、控制器与启动器均已就绪并端到端验证 物理仿真平台。环境、世界、机器人描述、控制器与启动器均已就绪并端到端验证
(起 Gazebo → spawn → 两个控制器 active → 机械臂在重力下站住)。 (起 Gazebo → spawn → 两个控制器 active → 机械臂在重力下站住)。
D405/D435 已桥接到 ROS,且几何正确性有自动验收(scripts/check_sim_cameras.py)。
场景模型(工件/通止规)与 Orisys 触觉插件尚未实现; 场景模型(工件/通止规)与 Orisys 触觉插件尚未实现;
相机传感器已在 Gazebo 内注册并出话题,但还没接到 ROS 侧(P5c-4)。 sim.world 已真正从 config/site.yaml 读取(physics_enable / joint_prefix 尚未接线,
写了不一致的值会告警而非静默忽略,见文件头 config_schema)。
注意:Orisys 视触觉没有现成 Gazebo 插件,自定义插件做出来之前, 注意:Orisys 视触觉没有现成 Gazebo 插件,自定义插件做出来之前,
仿真里无法验证通止规装配的 S07 接触与 S08 拧紧力判据。 仿真里无法验证通止规装配的 S07 接触与 S08 拧紧力判据。
@@ -100,28 +117,44 @@ driver:
# --- 配置项声明 ------------------------------------------------------------ # --- 配置项声明 ------------------------------------------------------------
# 设计约束:仿真与真机共用同一套 site.yaml 参数(servo.* / hole_detect.stage2.*), # 设计约束:仿真与真机共用同一套 site.yaml 参数(servo.* / hole_detect.stage2.*),
# 避免「仿真调好的值搬到真机不生效」。只有物理引擎自身的参数放在 sim.* 下。 # 避免「仿真调好的值搬到真机不生效」。只有物理引擎自身的参数放在 sim.* 下。
#
# ⚠️ 每个键都必须如实标注"是否真的接线"(wired)。声明了却没接线的键是最难查的
# 一类问题:用户按文档改了 site.yaml,行为毫无变化,也不报错。
# 目前由 arm_sim_gazebo.sim_site 读取的是 world(已接线);
# physics_enable / joint_prefix 尚未接线,若其值与世界/描述的实际状态冲突,
# 启动时会打印告警 —— 见 sim_site.unwired_warnings()。
config_schema: config_schema:
- key: world - key: world
site_path: sim.world site_path: sim.world
type: str type: str
required: false required: false
default: "" default: ""
wired: true
doc: > doc: >
仿真世界文件路径。留空表示使用 Gazebo 默认空场景。 仿真世界文件路径。留空 = 用内置默认(devices/sim/gazebo/worlds/assembly.world)。
Gazebo 接线完成后填真实路径,如 devices/sim/gazebo/worlds/assembly.world。 相对路径按仓库根解析。优先级:命令行 --world > 本项 > 内置默认。
实现在 arm_sim_gazebo/sim_site.resolve_world()。
- key: physics_enable - key: physics_enable
site_path: sim.physics_enable site_path: sim.physics_enable
type: bool type: bool
required: false required: false
default: true default: true
doc: 是否启用物理引擎。关闭可用于纯运动学验证。 wired: false
doc: >
是否启用物理引擎。**尚未接线**:世界文件里的 <physics>(重力/步长)是写死的,
本项不会传导过去。若这里写的值与世界实际状态不符,启动时告警而不是静默忽略。
要真正生效,需改 world 文件里的 <gravity>,或把本项接进启动流程。
- key: joint_prefix - key: joint_prefix
site_path: sim.joint_prefix site_path: sim.joint_prefix
type: str type: str
required: false required: false
default: sim_ default: sim_
wired: false
doc: > doc: >
仿真关节名前缀。必须与真实臂的前缀(Rizon4-xxxxxx_)不同, 仿真关节名前缀。必须与真实臂的前缀(Rizon4-xxxxxx_)不同,
否则仿真与真机的 TF 会互相污染。 否则仿真与真机的 TF 会互相污染。
**尚未接线**:实际前缀由 urdf/arm.sim.urdf.xacro 的 arm_prefix 决定,
且 config/controllers.yaml 里硬编码了 sim_joint1..7。写了个不同的值会告警。
要真改,这三处必须一起改。
+25 -4
View File
@@ -229,15 +229,36 @@
(早期把它挂在 URDF 的 world link 上,结果转 SDF 时整台相机消失 —— (早期把它挂在 URDF 的 world link 上,结果转 SDF 时整台相机消失 ——
sdformat 丢弃无质量根 link 时连带丢了整棵子树,且不报错。) 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 # 有数 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 放上工件后要用上面那条命令复核"工件是否完整可见"。
========================================================================= --> ========================================================================= -->
<model name="d435"> <model name="d435">
<static>true</static> <static>true</static>
<pose>-0.45 0.0 1.35 0.0 0.872665 3.14159</pose> <pose>-0.45 0.0 1.35 0.0 0.872665 0.0</pose>
<link name="link"> <link name="link">
<visual name="body"> <visual name="body">
<geometry><box><size>0.09 0.025 0.025</size></box></geometry> <geometry><box><size>0.09 0.025 0.025</size></box></geometry>
+6
View File
@@ -14,6 +14,12 @@
- 体检:`weights.sh doctor --check-engines`(能查出 engine 与 TensorRT 版本不匹配这类静默失效) - 体检:`weights.sh doctor --check-engines`(能查出 engine 与 TensorRT 版本不匹配这类静默失效)
- 规则写在 [models/README.md](../models/README.md)。`export_yolo_trt.sh` 现在是它的兼容转发壳。 - 规则写在 [models/README.md](../models/README.md)。`export_yolo_trt.sh` 现在是它的兼容转发壳。
**配置:** `show_site_config.sh` · `lib/export_site_env.py` **配置:** `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` **环境:** `env.sh` · `build_all.sh`
**RDK:** `test_rdk_python.sh` · `rdk_switch_tool.sh` · `rdk_drag_bridge.py` **RDK:** `test_rdk_python.sh` · `rdk_switch_tool.sh` · `rdk_drag_bridge.py`
**搁置未用:** `fetch_isaac_flexiv.sh`(拉 Flexiv 的 Isaac Sim / RDK 源码到 `thirdparty/`;Isaac 路线目前搁置,Gazebo 是选定平台) **搁置未用:** `fetch_isaac_flexiv.sh`(拉 Flexiv 的 Isaac Sim / RDK 源码到 `thirdparty/`;Isaac 路线目前搁置,Gazebo 是选定平台)
+328
View File
@@ -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())
@@ -33,10 +33,23 @@ from .robot_description import (
repo_root, repo_root,
world_name, world_name,
) )
from .sim_site import (
SimSiteError,
load_sim_site,
physics_enabled_in_world,
resolve_world,
unwired_warnings,
)
LAUNCH_PKG = "arm_sim_gazebo" LAUNCH_PKG = "arm_sim_gazebo"
LAUNCH_FILE = "gazebo_sim.launch.py" 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]: def _build_launch_cmd(extra: List[str]) -> List[str]:
# 用 python -m 调 ros2 而不是 exec "ros2":这样在 ros2 CLI 不在 PATH 的 # 用 python -m 调 ros2 而不是 exec "ros2":这样在 ros2 CLI 不在 PATH 的
@@ -95,8 +108,12 @@ def main(argv: Optional[List[str]] = None) -> int:
root = repo_root() root = repo_root()
xacro_path = default_xacro(root) xacro_path = default_xacro(root)
controllers = default_controllers(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( urdf, report = prepare_urdf(
xacro_path=xacro_path, xacro_path=xacro_path,
controllers_file=controllers, controllers_file=controllers,
@@ -105,10 +122,19 @@ def main(argv: Optional[List[str]] = None) -> int:
) )
wname = world_name(world) wname = world_name(world)
missing = check_base_plugins(Path(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) print(f"[gazebo_sim_launcher] 失败:\n{exc}", file=sys.stderr)
return 2 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: if missing:
# 这一条能省掉一整个下午:world 漏声明基础系统时,仿真看起来完全正常 # 这一条能省掉一整个下午:world 漏声明基础系统时,仿真看起来完全正常
# (/clock 在走、create 打印成功),但机械臂从未进入 Gazebo。 # (/clock 在走、create 打印成功),但机械臂从未进入 Gazebo。
@@ -135,6 +161,11 @@ def main(argv: Optional[List[str]] = None) -> int:
print(f" 描述 : {xacro_path}") print(f" 描述 : {xacro_path}")
print(f" 控制器配置 : {controllers}") print(f" 控制器配置 : {controllers}")
print(f" 世界 : {world} (world 名: {wname})") 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()}") print(f" URDF 归一化 : {report.summary()}")
if missing: if missing:
print( print(
@@ -20,6 +20,7 @@ devices/ **不是 ROS 包**,所以 $(find ...) 找不到它,必须用文件
""" """
from __future__ import annotations from __future__ import annotations
import math
import os import os
import re import re
import subprocess import subprocess
@@ -107,6 +108,183 @@ def world_name(world_file: Path) -> str:
return m.group(1) return m.group(1)
# =============================================================================
# 从 world 文件里读模型位姿
# =============================================================================
# 为什么需要:Gazebo 里的 <model>(尤其是 static 的工装/相机支架)**不会**产生
# ROS TF —— TF 只来自 robot_state_publisher。但应用要做 tf2 查询,比如
# "把 D435 看到的点从相机系转到 world"。少了这条 TF,查询直接报
# "frame does not exist",而图像本身看起来完全正常。
#
# 为什么是"解析"而不是"再配置一遍":位姿的唯一真相就在 world 文件里。
# 另配一份等于制造"改了 world 忘了改配置"的机会,而这种不一致不会报错,
# 只会让相机看到的东西和它自称的位置对不上 —— 极难查。
def _find_block(text: str, tag: str, name: str) -> Optional[str]:
"""取出 <tag name="name">...</tag> 的内容(考虑同名标签嵌套)。
SDF 里 <model> 可以嵌套 <model>(组合体),<link> 里也可能有 <link>(少见)。
只找第一个 </tag> 在嵌套时会截断到错误的位置,所以这里数嵌套深度。
"""
pat = re.compile(
r"<%(t)s\b[^>]*\bname\s*=\s*[\"']%(n)s[\"'][^>]*>" % {"t": tag, "n": re.escape(name)}
)
m = pat.search(text)
if not m:
return None
# 自闭合:<link name="l"/> 是完全合法的 SDF(没有子元素)。
# 不特判的话会走到下面的"找 </link>"分支、找不到 → 返回 None,
# 于是被误报成"这个 link 不存在",而它明明在。
if m.group(0).rstrip().endswith("/>"):
return ""
inner_start = m.end()
depth = 1
pos = inner_start
open_re = re.compile(r"<%(t)s\b" % {"t": tag})
close_re = re.compile(r"</%(t)s\s*>" % {"t": tag})
while depth > 0:
o = open_re.search(text, pos)
c = close_re.search(text, pos)
if c is None:
return None # 标签没闭合 → 文件本身有问题,交给别的检查去报
if o is not None and o.start() < c.start():
depth += 1
pos = o.end()
else:
depth -= 1
if depth == 0:
return text[inner_start : c.start()]
pos = c.end()
return None
def _own_pose(block: str, stop_before: Tuple[str, ...]) -> Tuple[float, float, float, float, float, float]:
"""取一段块自己的 <pose>(默认全 0)。
只认**第一个** <pose>,且必须出现在 stop_before 里任一标签之前 ——
这样才不会把子元素的 pose 误当成父元素的。例如 <model> 的 pose 必须在
第一个 <link>/<model>/<joint> 之前;<link> 的 pose 必须在第一个
<visual>/<collision>/<sensor> 之前。
"""
cut = len(block)
for tag in stop_before:
i = block.find("<" + tag)
if 0 <= i < cut:
cut = i
m = re.search(r"<pose(?:\s[^>]*)?>([^<]*)</pose>", block[:cut])
if not m:
return (0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
vals = m.group(1).split()
if len(vals) < 6:
return (0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
try:
return tuple(float(v) for v in vals[:6]) # type: ignore[return-value]
except ValueError:
return (0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
def _rpy_to_quat(r: float, p: float, y: float) -> Tuple[float, float, float, float]:
cr, sr = math.cos(r / 2), math.sin(r / 2)
cp, sp = math.cos(p / 2), math.sin(p / 2)
cy, sy = math.cos(y / 2), math.sin(y / 2)
return (
sr * cp * cy - cr * sp * sy,
cr * sp * cy + sr * cp * sy,
cr * cp * sy - sr * sp * cy,
cr * cp * cy + sr * sp * sy,
)
def _quat_to_rpy(q: Tuple[float, float, float, float]) -> Tuple[float, float, float]:
x, y, z, w = q
# roll (x 轴)
sinr = 2 * (w * x + y * z)
cosr = 1 - 2 * (x * x + y * y)
roll = math.atan2(sinr, cosr)
# pitch (y 轴):用 asin,并夹到 [-1,1] 免得浮点误差导致 domain error
sinp = 2 * (w * y - z * x)
sinp = max(-1.0, min(1.0, sinp))
pitch = math.asin(sinp)
# yaw (z 轴)
siny = 2 * (w * z + x * y)
cosy = 1 - 2 * (y * y + z * z)
yaw = math.atan2(siny, cosy)
return (roll, pitch, yaw)
def _quat_mul(a, b):
ax, ay, az, aw = a
bx, by, bz, bw = b
return (
aw * bx + ax * bw + ay * bz - az * by,
aw * by - ax * bz + ay * bw + az * bx,
aw * bz + ax * by - ay * bx + az * bw,
aw * bw - ax * bx - ay * by - az * bz,
)
def _rotate(q, v):
"""用四元数旋转向量 v(v 在子系里,返回在父系里的表达)。"""
x, y, z, w = q
vx, vy, vz = v
# t = 2 * (q_vec × v)
tx = 2 * (y * vz - z * vy)
ty = 2 * (z * vx - x * vz)
tz = 2 * (x * vy - y * vx)
# v' = v + w*t + q_vec × t
return (
vx + w * tx + (y * tz - z * ty),
vy + w * ty + (z * tx - x * tz),
vz + w * tz + (x * ty - y * tx),
)
def model_link_pose(
world_file: Path, model: str, link: str = ""
) -> Tuple[Tuple[float, float, float], Tuple[float, float, float]]:
"""返回 world 文件里 <model name=model> 的 <link name=link> 在**世界系**的位姿。
返回值是 ((x, y, z), (roll, pitch, yaw))。link 留空表示取模型本身的位姿。
两者都会做复合(模型位姿 ∘ link 位姿),因为 SDF 的 <pose> 是相对父元素的。
找不到模型/link 时抛 SimDescriptionError,并提示这里和 cameras.yaml 的
world_model 字段必须对应 —— 配错的话相机不会有 TF,而症状是
"tf 查询报 frame does not exist",与相机本身无关,容易查错方向。
"""
text = Path(world_file).read_text(encoding="utf-8", errors="replace")
mblock = _find_block(text, "model", model)
if mblock is None:
raise SimDescriptionError(
f"{world_file} 里找不到 <model name=\"{model}\">。\n"
f" devices/sim/gazebo/config/cameras.yaml 里若有 world_model: {model},"
f"就必须在 world 里真的有这个模型 —— 否则该相机没有 TF,"
f"应用做 tf2 查询会报 frame does not exist。"
)
mp = _own_pose(mblock, ("link", "model", "joint", "plugin"))
mq = _rpy_to_quat(mp[3], mp[4], mp[5])
if not link:
return ((mp[0], mp[1], mp[2]), (mp[3], mp[4], mp[5]))
lblock = _find_block(mblock, "link", link)
if lblock is None:
raise SimDescriptionError(
f"{world_file} 的 <model name=\"{model}\"> 里找不到 <link name=\"{link}\">。"
)
lp = _own_pose(lblock, ("visual", "collision", "sensor", "light"))
lq = _rpy_to_quat(lp[3], lp[4], lp[5])
# 复合:世界系下的位置 = t_model + R_model * t_link
t = _rotate(mq, (lp[0], lp[1], lp[2]))
pos = (mp[0] + t[0], mp[1] + t[1], mp[2] + t[2])
rpy = _quat_to_rpy(_quat_mul(mq, lq))
return (pos, rpy)
# 只要 world 里声明了任何一个 <plugin>,Gazebo 就不再加载 server.config 的默认 # 只要 world 里声明了任何一个 <plugin>,Gazebo 就不再加载 server.config 的默认
# 系统列表,这三个基础系统必须由 world 自己声明,否则整个仿真静默失效。 # 系统列表,这三个基础系统必须由 world 自己声明,否则整个仿真静默失效。
# 详细后果见 assembly.world 里那段注释,以及这里的 check_base_plugins()。 # 详细后果见 assembly.world 里那段注释,以及这里的 check_base_plugins()。
@@ -0,0 +1,309 @@
"""sim_camera_adapter —— 把桥接过来的 Gazebo 相机,变成应用能直接吃的话题。
=============================================================================
它到底解决什么问题
=============================================================================
ros_gz_bridge 只做"传输":把 gz 的消息类型翻成 ROS 的消息类型,话题名照抄。
它**改不了**两样东西,而这两样恰恰是仿真里最容易错、也最不报错的:
1. frame_id
gz 盖的是内部路径(实测是 rizon_sim/sim_link7/d405_rgbd),
而 TF 里真实存在的坐标系叫 sim_d405_link —— 两者对不上。
更麻烦的是:即使改成 sim_d405_link 也还是错的,因为那是"相机机身"系
(X 前 / Y 左 / Z 上),而 ROS 的 image/camera_info 必须是光学系
(Z 前 / X 右 / Y 下)。tf2 查询两种都能查到、都不报错,
但用错的那个把深度反投影成三维点会整体转错。
所以这里盖的是 <相机>_color_optical_frame,并同时发布
link → optical 的静态 TF。
2. 深度单位
gz 是 32FC1 / 米,真机 realsense 的 aligned_depth 是 16UC1 / 毫米。
不转换的话应用按毫米解释会整体差 1000 倍,而且 dtype 不符时
numpy 读出来直接是垃圾。
=============================================================================
为什么是"桥接 + 适配器"两层,而不是合成一层
=============================================================================
桥接器改不了 frame_id,所以无论如何都要有第二层做语义转换。
既然必须有,就让第二层把"改名 + 坐标系 + 单位"全都集中处理,
而不是让桥接器改一半、适配器补一半 —— 那样出问题时不知道去哪找。
副产品:gz 原名的话题(/d405/image 等)在 ROS 图里仍然可见,
排查"是桥接断了还是适配器断了"时能一刀切开。
=============================================================================
发布的话题(与真机完全一致,应用无需为仿真改任何代码)
=============================================================================
/<ros_ns>/color/image_raw rgb8
/<ros_ns>/color/camera_info plumb_bob(畸变全 0,gz 不模拟畸变)
/<ros_ns>/aligned_depth_to_color/image_raw 16UC1 毫米,0 = 无读数
QoS 用 sensor data(BEST_EFFORT, keep_last 1),与真机 realsense 发布端、
以及应用的订阅端(qos_profile_sensor_data)都对齐。这一点不能想当然:
发布端 BEST_EFFORT、订阅端 RELIABLE 是不兼容的,表现是"话题在、echo 不到"。
"""
from __future__ import annotations
import math
from typing import Dict, List, Optional
import numpy as np
import rclpy
from geometry_msgs.msg import TransformStamped
from rclpy.node import Node
from rclpy.qos import (
DurabilityPolicy,
HistoryPolicy,
QoSProfile,
ReliabilityPolicy,
qos_profile_sensor_data,
)
from sensor_msgs.msg import CameraInfo, Image
from tf2_ros import StaticTransformBroadcaster
from .sim_cameras import (
DEFAULT_CAMERAS_REL,
CameraConfigError,
CameraSpec,
depth_m_to_mm16,
intrinsics,
load_cameras,
optical_rpy,
rpy_to_quat,
)
# 图像上盖的坐标系(光学系)相对相机本体系的固定旋转。
# 见 sim_cameras.OPTICAL_RPY 的说明 —— 这是"看起来正常但三维全错"的那一项。
class SimCameraAdapter(Node):
def __init__(self) -> None:
super().__init__("sim_camera_adapter")
self.declare_parameter("cameras_file", "")
path = str(self.get_parameter("cameras_file").value or "").strip()
try:
specs = load_cameras(path or None, root=self._repo_root())
except Exception as exc: # 配置错误应当直接说清怎么修,而不是抛栈
self.get_logger().error(f"\n仿真相机清单读取失败:\n{exc}")
raise
self._specs: List[CameraSpec] = specs
self._subs: List[object] = []
self._pubs: Dict[str, object] = {}
self._warned: set = set()
# 相机本体 → 光学系的静态 TF。必须在图像到达**之前**就发出去,
# 因为 TF 是 transient_local(迟到的订阅者也能拿到),但若从没发过,
# 应用查 tf 会直接报 "frame does not exist"。
self._tf = StaticTransformBroadcaster(self)
self._publish_optical_tfs()
for s in specs:
self._wire(s)
names = ", ".join(s.ros_ns for s in specs)
self.get_logger().info(
f"仿真相机适配器就绪: {names}"
+ f"(深度 {sum(1 for s in specs if s.depth)}/台 已做 米→毫米 转换)"
)
# ------------------------------------------------------------------ 内部
def _repo_root(self):
"""复用 robot_description 的仓库根定位(它已经处理了三种情形)。"""
from .robot_description import repo_root
try:
return repo_root()
except Exception:
return None
def _qos(self) -> QoSProfile:
# 深拷贝一份 sensor data QoS:共享的那个对象若被改动会影响其它节点。
return QoSProfile(
reliability=ReliabilityPolicy.BEST_EFFORT,
history=HistoryPolicy.KEEP_LAST,
depth=qos_profile_sensor_data.depth,
)
def _publish_optical_tfs(self) -> None:
"""发布 link → optical 的静态 TF(每台相机一条,平移为零)。"""
msgs = []
for s in self._specs:
t = TransformStamped()
t.header.stamp = self.get_clock().now().to_msg()
t.header.frame_id = s.link_frame
t.child_frame_id = s.optical_frame
t.transform.translation.x = 0.0
t.transform.translation.y = 0.0
t.transform.translation.z = 0.0
qx, qy, qz, qw = rpy_to_quat(optical_rpy())
t.transform.rotation.x = qx
t.transform.rotation.y = qy
t.transform.rotation.z = qz
t.transform.rotation.w = qw
msgs.append(t)
self._tf.sendTransform(msgs)
def _wire(self, s: CameraSpec) -> None:
qos = self._qos()
self._pubs[s.ros_color] = self.create_publisher(Image, s.ros_color, qos)
self._pubs[s.ros_info] = self.create_publisher(CameraInfo, s.ros_info, qos)
if s.depth:
self._pubs[s.ros_depth] = self.create_publisher(Image, s.ros_depth, qos)
self._subs.append(
self.create_subscription(
Image, s.gz_image, lambda m, sp=s: self._on_color(sp, m), qos
)
)
self._subs.append(
self.create_subscription(
CameraInfo, s.gz_info, lambda m, sp=s: self._on_info(sp, m), qos
)
)
if s.depth:
self._subs.append(
self.create_subscription(
Image, s.gz_depth, lambda m, sp=s: self._on_depth(sp, m), qos
)
)
def _restamp(self, msg, frame_id: str) -> None:
"""把坐标系换掉。就地改,避免每帧多一次消息构造与一次大数组拷贝。"""
if msg.header.frame_id != frame_id:
msg.header.frame_id = frame_id
def _on_color(self, s: CameraSpec, msg: Image) -> None:
self._note_encoding(s, msg)
self._restamp(msg, s.optical_frame)
self._pubs[s.ros_color].publish(msg)
def _on_info(self, s: CameraSpec, msg: CameraInfo) -> None:
"""重算并覆盖内参 —— gz 发的那份是错的,不能透传。
本机实测:gz 恒发 fx=fy=277, cx=160, cy=120(等于它的默认 60° 视场 +
半分辨率主点),而渲染用的是配置的 87° 视场与 640x480。
照抄它做三维反投影会错半米量级,且单目预览里完全看不出来。
详见 sim_cameras.intrinsics() 的说明。
"""
w = int(msg.width) or int(s.width)
h = int(msg.height) or int(s.height)
try:
k, p = intrinsics(s, w, h)
except CameraConfigError as exc:
key = ("info", s.ros_ns)
if key not in self._warned:
self._warned.add(key)
self.get_logger().error(f"{s.ros_ns}: 内参算不出来,{s.ros_info} 无输出。{exc}")
return
key = ("info", s.ros_ns)
if key not in self._warned:
self._warned.add(key)
gz_k = list(msg.k) if len(msg.k) == 9 else []
self.get_logger().info(
f"{s.ros_ns}: 内参已按视场 {s.horizontal_fov:.4f} rad "
f"({math.degrees(s.horizontal_fov):.1f}°) 与 {w}x{h} 重算 —— "
f"fx={k[0]:.1f} cx={k[2]:.1f} cy={k[5]:.1f}"
+ (
f"(覆盖 gz 的 fx={gz_k[0]:.1f} cx={gz_k[2]:.1f} cy={gz_k[5]:.1f})"
if gz_k
else ""
)
)
out = CameraInfo()
out.header = msg.header
out.header.frame_id = s.optical_frame
out.height = h
out.width = w
out.distortion_model = "plumb_bob"
# gz 不模拟镜头畸变,所以畸变系数全 0、R 为单位阵。
# 这意味着"仿真里用畸变矫正调出来的参数"在真机上仍需重调 —— 已在
# devices/sim/gazebo/README.md 里说明。
out.d = [0.0] * 5
out.k = list(k)
out.r = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]
out.p = list(p)
self._pubs[s.ros_info].publish(out)
def _on_depth(self, s: CameraSpec, msg: Image) -> None:
"""32FC1 米 → 16UC1 毫米。
用 frombuffer 而不是 array(...):后者会把整个 buffer 再拷一遍。
这一步每帧都在跑(30Hz × 640×480×4B),能省则省。
"""
if msg.encoding != "32FC1":
# 只提醒一次,避免刷屏
key = (s.ros_ns, msg.encoding)
if key not in self._warned:
self._warned.add(key)
self.get_logger().warn(
f"{s.gz_depth} 的编码是 {msg.encoding},不是预期的 32FC1。"
f"已跳过本次转换 —— 深度话题会没有输出。"
f"若 Gazebo 版本改动了深度编码,需要同步改 sim_cameras."
f"depth_m_to_mm16 的调用处。"
)
return
arr = np.frombuffer(msg.data, dtype=np.float32).reshape(msg.height, msg.width)
mm = depth_m_to_mm16(arr)
out = Image()
out.header = msg.header
out.header.frame_id = s.optical_frame
out.height = msg.height
out.width = msg.width
out.encoding = "16UC1"
# gz 给的是连续 buffer;转成 uint16 后每像素 2 字节,step 必须同步改,
# 否则下游按 RGB 的 stride 解读会错位。
out.is_bigendian = 0
out.step = msg.width * 2
out.data = mm.tobytes()
self._pubs[s.ros_depth].publish(out)
def _note_encoding(self, s: CameraSpec, msg: Image) -> None:
"""第一次收到彩色帧时把编码打出来。
为什么值得单独打印:gz 给的是 rgb8(实测),而真机 realsense 的
color/image_raw 也是 rgb8 —— 一致,所以不需要转换。但这一点是实测
结论,不是保证;换个 Gazebo 版本或传感器配置可能变成 bgr8。
打一行日志,使"颜色不对"时能立刻定位到是不是编码变了。
"""
key = ("enc", s.ros_ns, msg.encoding)
if key not in self._warned:
self._warned.add(key)
self.get_logger().info(
f"{s.ros_ns}: 彩色 {msg.width}x{msg.height} encoding={msg.encoding} "
f"→ {s.ros_color} (frame={s.optical_frame})"
)
if msg.encoding not in ("rgb8", "bgr8"):
self.get_logger().warn(
f"{s.ros_ns} 的彩色编码是 {msg.encoding},既不是 rgb8 也不是 "
f"bgr8。应用按 rgb8 解释会得到错误的颜色。"
)
def main(argv: Optional[List[str]] = None) -> int:
rclpy.init(args=argv)
try:
node = SimCameraAdapter()
except Exception:
# 参数/配置错误时 rclpy 已经打印了原因;这里只需保证退出码非零,
# 让 launch 能把它当成失败(否则会留下一个没有相机的"成功"启动)。
rclpy.try_shutdown()
return 2
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.try_shutdown()
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -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 里的值。两者一旦不一致,图像与内参就对不上了 ——
症状仍然是"三维点整体错",而单目预览看不出来。这正是这个模块开头
那一长串说明所警告的那类错误,所以要主动比对。
返回人类可读的告警列表(空 = 没问题)。
精度说明(有意为之,不做过度解析):
- 世界相机:按 <sensor> 里的 <topic> 精确匹配到对应相机,读到的是精确值。
- 腕上相机:它在 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
# 找到 <topic> 等于本相机 gz_ns 的那个 <sensor> 块
fov = None
for m in re.finditer(r"<sensor\b[^>]*>(.*?)</sensor>", text, re.S):
body = m.group(1)
tm = re.search(r"<topic>\s*([^<\s]+)\s*</topic>", body)
if not tm or "/" + tm.group(1).strip().lstrip("/") != s.gz_ns:
continue
fm = re.search(r"<horizontal_fov>\s*([0-9.eE+-]+)\s*</horizontal_fov>", body)
if fm:
fov = float(fm.group(1))
break
if fov is None:
warnings.append(
f"相机 {s.ros_ns}: 在 {Path(world_file).name} 里找不到 topic 为 "
f"{s.gz_ns} 的 <sensor> 的 <horizontal_fov>,无法比对。"
f"内参将按 cameras.yaml 的 {s.horizontal_fov} 计算 —— "
f"若与渲染实际用的视场不符,三维点位会整体错。"
)
elif abs(fov - s.horizontal_fov) > 1e-4:
warnings.append(
f"相机 {s.ros_ns}: 视场不一致!cameras.yaml 写 {s.horizontal_fov},"
f"而 {Path(world_file).name} 里渲染用的是 {fov}。\n"
f" 内参按前者算、图像按后者渲染 → 三维点位会整体错,"
f"而单目预览看不出来。请把两处改成一致。"
)
if sensors_xacro is not None and Path(sensors_xacro).is_file():
text = Path(sensors_xacro).read_text(encoding="utf-8", errors="replace")
found = [float(v) for v in re.findall(r"<horizontal_fov>\s*([0-9.eE+-]+)", text)]
for s in specs:
if s.is_world_mounted:
continue
if not found:
warnings.append(
f"相机 {s.ros_ns}: 在 {Path(sensors_xacro).name} 里没找到 "
f"<horizontal_fov>,无法比对(内参按 cameras.yaml 的 "
f"{s.horizontal_fov} 计算)。"
)
elif not any(abs(v - s.horizontal_fov) <= 1e-4 for v in found):
warnings.append(
f"相机 {s.ros_ns}: 视场可能不一致。cameras.yaml 写 "
f"{s.horizontal_fov},而 {Path(sensors_xacro).name} 里出现的值是 "
f"{found}。若渲染用的不是 {s.horizontal_fov},三维点位会整体错。"
)
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,
)
@@ -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 文件里的 <physics>,"
f"或者先把这个键接进启动流程(见 sim_site.py 顶部说明)。"
)
if "joint_prefix" in site.present and actual_joint_prefix is not None:
want = site.joint_prefix
if want and want != actual_joint_prefix:
out.append(
f"site.yaml 写了 sim.joint_prefix={want!r},"
f"但实际用的前缀是 {actual_joint_prefix!r},"
f"而这个键**还没有接线**。\n"
f" 前缀来自 arm.sim.urdf.xacro 的 arm_prefix,"
f"且 config/controllers.yaml 里硬编码了 {actual_joint_prefix}joint1..7 —— "
f"三处必须一致,改一处不够。"
)
return out
def physics_enabled_in_world(world_file: Path) -> Optional[bool]:
"""世界文件里的物理到底开没开。判据是重力是不是零向量。
gz 里"关物理"的常见做法就是把 <gravity> 置零(比删掉整个 <physics>
更常用,因为 <physics> 还带着步长等其它必需项)。没有 <physics> /
<gravity> 时按"开着"算 —— gz 的默认就是开。
"""
try:
text = Path(world_file).read_text(encoding="utf-8", errors="replace")
except OSError:
return None
m = re.search(r"<gravity>\s*([^<]+)\s*</gravity>", text)
if not m:
return True
try:
vals = [float(x) for x in m.group(1).split()]
except ValueError:
return None
if len(vals) != 3:
return None
return any(abs(v) > 1e-12 for v in vals)
+136 -5
View File
@@ -76,10 +76,12 @@ def _prepare(context, *args, **kwargs):
default_controllers, default_controllers,
default_world, default_world,
default_xacro, default_xacro,
model_link_pose,
prepare_urdf, prepare_urdf,
repo_root, repo_root,
world_name, world_name,
) )
from arm_sim_gazebo.sim_site import SimSiteError, resolve_world
world = LaunchConfiguration("world").perform(context).strip() world = LaunchConfiguration("world").perform(context).strip()
load_gripper = LaunchConfiguration("load_gripper").perform(context).strip().lower() load_gripper = LaunchConfiguration("load_gripper").perform(context).strip().lower()
@@ -89,7 +91,10 @@ def _prepare(context, *args, **kwargs):
try: try:
root = repo_root() 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( urdf, report = prepare_urdf(
xacro_path=default_xacro(root), xacro_path=default_xacro(root),
@@ -99,7 +104,7 @@ def _prepare(context, *args, **kwargs):
) )
wname = world_name(world_path) wname = world_name(world_path)
missing_base = check_base_plugins(Path(world_path)) missing_base = check_base_plugins(Path(world_path))
except SimDescriptionError as exc: except (SimDescriptionError, SimSiteError) as exc:
# 描述准备失败是最常见的上手障碍(环境没 source、路径不对)。 # 描述准备失败是最常见的上手障碍(环境没 source、路径不对)。
# 直接把可读原因打出来,而不是让它变成一个看不懂的 launch traceback。 # 直接把可读原因打出来,而不是让它变成一个看不懂的 launch traceback。
raise RuntimeError(f"\n[gazebo_sim] 仿真描述准备失败:\n{exc}\n") from exc 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 里的 <model> 产生 ROS TF —— TF 只来自
# robot_state_publisher(也就是只覆盖 URDF 里的东西)。
# D435 正属于这一类:它的物理归属是"世界"(固定支架),不在 URDF 里
# (早期挂在 URDF 的 world link 上时,被 sdformat 连整棵子树静默丢掉)。
# 少了这条 TF,应用 tf2 查询直接报 "frame does not exist" ——
# 而图像本身完全正常,很容易误判成相机坏了。
#
# 位姿从 world 文件**解析**,不在这里重复配置:唯一真相就是 world 文件。
# 另配一份等于制造"改了 world 忘了改这里",而症状是相机看到的东西
# 与它自称的位置对不上,极难查。
world_tf = []
for s in specs:
if not s.is_world_mounted:
continue
try:
(px, py, pz), (rr, rp, ry) = model_link_pose(
Path(world_path), s.world_model, s.world_link
)
except SimDescriptionError as exc:
raise RuntimeError(
f"\n[gazebo_sim] 世界相机 {s.ros_ns} 的 TF 补不出来:\n{exc}\n"
) from exc
qx, qy, qz, qw = rpy_to_quat((rr, rp, ry))
world_tf.append(
Node(
package="tf2_ros",
executable="static_transform_publisher",
name=f"tf_{s.ros_ns}",
output="screen",
arguments=[
"--x", f"{px}", "--y", f"{py}", "--z", f"{pz}",
"--qx", f"{qx}", "--qy", f"{qy}", "--qz", f"{qz}", "--qw", f"{qw}",
"--frame-id", "world",
"--child-frame-id", s.link_frame,
],
parameters=[{"use_sim_time": True}],
)
)
actions.append(
LogInfo(
msg=f"[gazebo_sim] 世界相机 {s.ros_ns}: world → {s.link_frame} "
f"@ ({px:.3f}, {py:.3f}, {pz:.3f}) 由 world 文件解析得到"
)
)
if world_tf:
# 延后一点再发:/clock 还没起来时发布静态 TF 可能被 tf2 判成
# "未来时间戳",而这一类问题只在很后面才以 extrapolation 报出来。
actions.append(TimerAction(period=2.0, actions=world_tf))
# 4b) 桥接:只做传输(gz 消息类型 → ROS 消息类型,话题名照抄)。
# 改名、坐标系、深度单位都交给 4c 的适配器 —— 桥接器改不了 frame_id,
# 而 frame_id 恰恰是最容易错又最不报错的那一项。
actions.append(
Node(
package="ros_gz_bridge",
executable="parameter_bridge",
name="camera_bridge",
output="screen",
arguments=bridge_args(specs),
)
)
# 4c) 适配器:盖光学系 frame_id + 深度 32FC1 米 → 16UC1 毫米 + 改成
# 应用期望的话题名。详见 sim_camera_adapter.py 顶部。
actions.append(
Node(
package="arm_sim_gazebo",
executable="sim_camera_adapter",
name="sim_camera_adapter",
output="screen",
parameters=[
{"cameras_file": str(Path(root) / DEFAULT_CAMERAS_REL)},
{"use_sim_time": True},
],
)
)
# --- 5) spawn 机器人 -----------------------------------------------------
# -world 必须给对:world 名写错时 Gazebo **不报错**,只是实体没被创建, # -world 必须给对:world 名写错时 Gazebo **不报错**,只是实体没被创建,
# 表现成"launch 一切正常但 RViz 里什么都没有"。 # 表现成"launch 一切正常但 RViz 里什么都没有"。
actions.append( actions.append(
@@ -201,7 +322,7 @@ def _prepare(context, *args, **kwargs):
) )
) )
# --- 5) 控制器 ----------------------------------------------------------- # --- 6) 控制器 -----------------------------------------------------------
# 用 joint_trajectory_controller 而不是直接给关节发位置:应用层统一按 # 用 joint_trajectory_controller 而不是直接给关节发位置:应用层统一按
# rizon_arm_controller/follow_joint_trajectory 发轨迹,这个接口名与真机一致。 # rizon_arm_controller/follow_joint_trajectory 发轨迹,这个接口名与真机一致。
controllers = [ controllers = [
@@ -236,8 +357,18 @@ def _prepare(context, *args, **kwargs):
" ros2 control list_controllers # 两个控制器 active\n" " ros2 control list_controllers # 两个控制器 active\n"
" ros2 topic echo /joint_states --once # 7 个 sim_joint\n" " ros2 topic echo /joint_states --once # 7 个 sim_joint\n"
" ros2 run tf2_ros tf2_echo world sim_tcp # 末端位姿可查\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,启动时会自动比对告警。"
) )
) )
) )
+4
View File
@@ -38,6 +38,10 @@ setup(
# 它只做"准备描述 + 转发给 launch 文件",逻辑在 # 它只做"准备描述 + 转发给 launch 文件",逻辑在
# arm_sim_gazebo/gazebo_sim_launcher.py。 # arm_sim_gazebo/gazebo_sim_launcher.py。
'gazebo_sim_launcher = arm_sim_gazebo.gazebo_sim_launcher:main', '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。
# 排查"为什么 Gazebo 说我的 link 不存在"时非常省时间。 # 排查"为什么 Gazebo 说我的 link 不存在"时非常省时间。
'urdf_normalize = arm_sim_gazebo.urdf_normalize:main', 'urdf_normalize = arm_sim_gazebo.urdf_normalize:main',
@@ -21,6 +21,7 @@ from arm_sim_gazebo.robot_description import ( # noqa: E402
check_base_plugins, check_base_plugins,
default_world, default_world,
expand_xacro, expand_xacro,
model_link_pose,
prepare_urdf, prepare_urdf,
repo_root, repo_root,
world_name, world_name,
@@ -262,3 +263,218 @@ def test_prepare_urdf_missing_controllers_is_actionable(tmp_path):
with pytest.raises(SimDescriptionError) as ei: with pytest.raises(SimDescriptionError) as ei:
prepare_urdf(controllers_file=tmp_path / "nope.yaml") prepare_urdf(controllers_file=tmp_path / "nope.yaml")
assert "控制器配置不存在" in str(ei.value) assert "控制器配置不存在" in str(ei.value)
# ---------------------------------------------------------------------------
# 从 world 文件解析模型位姿(世界相机的 TF 靠它)
# ---------------------------------------------------------------------------
# 这组测试存在的理由:Gazebo 不为 world 里的 <model> 产生 ROS TF,
# 而 D435 这类固定相机就属于这种情况。位姿靠解析而不是重复配置,
# 因此"解析对不对"直接决定了相机看到的东西和它自称的位置是否一致。
# 解析错了不会报错,只会让三维点整体偏移或转错。
def _world_file(tmp_path, body: str) -> Path:
p = tmp_path / "w.world"
p.write_text(f'<?xml version="1.0"?>\n<sdf version="1.9">\n{body}\n</sdf>\n', encoding="utf-8")
return p
def test_model_pose_identity_link(tmp_path):
"""link 没有自己的 pose 时,位姿就是模型位姿。"""
w = _world_file(
tmp_path,
"""
<world name="w">
<model name="d435">
<static>true</static>
<pose>-0.45 0.0 1.35 0.0 0.872665 3.14159</pose>
<link name="link">
<sensor name="cam" type="rgbd_camera">
<pose>0.1 0 0 0 0 0</pose>
</sensor>
</link>
</model>
</world>
""",
)
(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 的 <pose> 是相对父元素的,模型位姿与 link 位姿必须复合。"""
w = _world_file(
tmp_path,
"""
<world name="w">
<model name="m">
<pose>1 0 0 0 0 0</pose>
<link name="l">
<pose>0 1 0 0 0 0</pose>
<visual name="v"/>
</link>
</model>
</world>
""",
)
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,
"""
<world name="w">
<model name="m">
<pose>0 0 0 0 0 1.5707963</pose>
<link name="l">
<pose>1 0 0 0 0 0</pose>
<visual name="v"/>
</link>
</model>
</world>
""",
)
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 必须在第一个 <visual>/<collision>/<sensor> 之前取,
否则会把子元素的 pose 误当成 link 的。"""
w = _world_file(
tmp_path,
"""
<world name="w">
<model name="m">
<pose>2 2 2 0 0 0</pose>
<link name="l">
<pose>1 1 1 0 0 0</pose>
<visual name="v">
<pose>9 9 9 0 0 0</pose>
</visual>
</link>
</model>
</world>
""",
)
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,
"""
<world name="w">
<model name="m">
<link name="l"><visual name="v"/></link>
</model>
</world>
""",
)
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,
"""
<world name="w">
<model name="m"><pose>5 6 7 0 0 0</pose><link name="l"/></model>
</world>
""",
)
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,
"""
<world name='w'>
<model name='m'><pose>1 2 3 0 0 0</pose><link name='l'/></model>
</world>
""",
)
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):
"""<model> 可以嵌套 <model>(组合体)。找第一个 </model> 会截断错位,
所以 _find_block 要数嵌套深度。"""
w = _world_file(
tmp_path,
"""
<world name="w">
<model name="outer">
<pose>0 0 0 0 0 0</pose>
<model name="inner">
<pose>9 9 9 0 0 0</pose>
<link name="l"><visual name="v"/></link>
</model>
<link name="outer_link"><pose>1 0 0 0 0 0</pose><visual name="v"/></link>
</model>
</world>
""",
)
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, '<world name="w"><model name="other"/></world>')
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, '<world name="w"><model name="m"><link name="other"/></model></world>')
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)
+407
View File
@@ -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):
"""世界相机:按 <topic> 精确匹配,能抓到 cameras.yaml 与 world 不一致。"""
from arm_sim_gazebo.sim_cameras import check_fov_consistency
world = tmp_path / "assembly.world"
world.write_text(
"""
<sdf><world name="w">
<model name="d435"><link name="link"><sensor name="s" type="rgbd_camera">
<topic>/d435</topic>
<camera><horizontal_fov>1.5184</horizontal_fov></camera>
</sensor></link></model>
</world></sdf>
""",
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("<horizontal_fov>1.5184</horizontal_fov>", encoding="utf-8")
spec = _spec()
assert check_fov_consistency([spec], sensors_xacro=xacro) == []
xacro.write_text("<horizontal_fov>1.2</horizontal_fov>", 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 里找不到对应 <sensor> 时要告警,而不是静默放过。"""
from arm_sim_gazebo.sim_cameras import check_fov_consistency
world = tmp_path / "assembly.world"
world.write_text("<sdf><world name='w'/></sdf>", 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)
+172
View File
@@ -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("<sdf><world><physics><gravity>0 0 -9.8</gravity></physics></world></sdf>")
assert physics_enabled_in_world(on) is True
off = tmp_path / "off.world"
off.write_text("<sdf><world><physics><gravity>0 0 0</gravity></physics></world></sdf>")
assert physics_enabled_in_world(off) is False
# 没写 gravity → gz 默认是开
none = tmp_path / "none.world"
none.write_text("<sdf><world/></sdf>")
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}"