打通ADTF与ROS:ADAS测试数据采集与回放适配层实践
做 ADAS 测试的兄弟应该都有这种体会车上跑的感知算法栈越来越像是 ROS 的天下而做数据采集、场景回放和整车信号级分析的一套工具体系又绕不开 ADTF。这两套东西一个偏“算法表达”一个偏“工程数据流”平时各干各的可真要把一条测试链路串起来的时候——从实车采集原始 sensor 数据到算法团队拿去跑感知模型再到把结果拉回来和真值逐帧对比——问题就来了ADTF 录出来的 .dat 文件ROS 这边没法直接读ROS 包里塞的东西测试工程师看着也很别扭。这个项目做的就是这件事给 ADTF 和 ROS 之间搭一条标准化的通道让数据能从采集端一路流到算法验证端而且两边都能保留自己最舒服的工作方式。这篇文章不打算放整段整段的代码不想搞成 API 文档重点讲清楚这条链路从架构设计到落地验证过程中我踩过的坑、验证过的方案、以及最后沉淀下来能直接拿去用的步骤。适合正在做 ADAS 测试工具链、或者想给已有数据平台接入 ROS 生态的人参考。1. 为什么 ADAS 测试要拧巴 ADTF 和 ROS现状与卡点1.1 两个生态的“技能树”完全不同ADTFAutomotive Data and Time-Triggered Framework在整车厂和 Tier1 的数据采集工具链里非常常见尤其在德国系供应链里几乎是标配。它的强项是时间触发的数据流处理录制回放单元对时间戳要求极严做信号级分析、CAN 总线数据解析、视频流同步采集非常顺手。比如你要确认“第 12 秒 300 毫秒时前摄像头那一帧和雷达目标列表之间的时间对齐关系”在 ADTF 里有很多现成工具可看。ROS 的强项则完全在另一侧感知节点的开发、激光雷达点云处理、目标检测模型的在线跑推理、仿真环境对接这些用 ROS 几乎是行业默认项。Ubuntu 20.04 时代大家装 Noetic到 Ubuntu 22.04 之后基本奔着 Humble 去了ROS 2 的 DDS 通信模型也让分布式部署变得更容易。问题在于这两个生态之间的数据互操作性极差。ADTF 录制的是 .dat 格式内部有自己的一套媒体描述和流标签体系ROS 这边的数据要么是 rosbag1/rosbag2要么是 MCAP 这类新容器。没有适配层的话两个团队的交付物彼此没法直接消化。1.2 实际项目里钻出来的三个硬需求第一采集端要和 ADTF 无缝对接。实车测试时工控机上运行的是 ADTF 的采集工程传感器流进来以后按要求同步录制。这是测试团队已经验证过稳定性的工作流不能推翻。第二算法团队要能从同一包数据里拿到 ROS 格式输入。感知模型的输入是图像和点云输出是目标列表模型验证脚本跑在 ROS 环境里。它需要按时间戳读取图像帧对应点云和 CAN 信号顺序不能乱。第三验证端要能“复盘”。算法跑完检测结果要和 ADTF 侧记录的真值或者参考输出做逐帧比对只有数据链路两边都能回到同一个时间原点这个比对方才有意义。三个需求合起来以后结论就是不应该强行让 ROS 去读 ADTF 文件也不应该要求 ADTF 去理解 ROS 消息中间需要一个双向适配层。2. 适配层设计我选了“离线转换为主、实时桥接为辅”的中间路线2.1 为什么不直接对着 .dat 文件开发解析器最开始团队里有同事提过能不能自己写个 .dat 解析器直接在 ROS 环境里读出来这个方案的研究成本被我们估过一轮ADTF 的插件体系和流标签在不同版本之间是有差异的自己去解析 .dat 文件不是不行但版本兼容、字段映射、异常容错这些都是时间黑洞。更合理的路线是借用 ADTF 官方 SDK 的读取能力先把它转成标准中间格式再做 ROS 消息转换。这样你维护的只是 A 到 B 的映射规则而不是整套文件格式的底层实现。最终我的选型是混合式主链路离线转换工具把已录制好的 .dat 场景段转成 ROS 2 bag 文件。转换过程可回放、可校验、可并行处理适合批处理大量路采数据。辅助链路实时桥接插件在 ADTF 的图里挂一个输出插件边采边往 ROS 侧发消息。主要用于联调阶段快速看效果不承担正式数据交付。两条链路共用同一套通道映射和类型转换逻辑这样函数只写一遍后续维护成本低很多。2.2 通道映射与消息类型映射通道映射核心是命名规则。ADTF 工程里每个数据流都有自己的名称比如FrontCamera、Lidar_64、VehicleCAN。在 ROS 侧我把它映射成以命名空间为前缀的 topicADTF 数据流映射后 topic 名称ROS 2 消息类型备注FrontCamera/sensor/front_camera/image_rawsensor_msgs/Image原始帧Lidar_64/sensor/lidar/points_rawsensor_msgs/PointCloud2点坐标与强度VehicleCAN/vehicle/can/frame_rawcan_msgs/CanFrame按 DBC 解析后另发一个 topicRadarObjects/sensor/radar/objectsradar_msgs/RadarTracks目标列表也可以自定义这里面有一个比较关键的判断对于 CAN 信号我建议保留一份“原帧直通”的消息同时再解析一份语义级消息。比如车速信号VehicleSpeed这种既可以从 CAN 上按 DBC 规则解析出来作为std_msgs/Float32发出也可以把原始CanFrame整个打包。原因是算法侧有时候需要原始帧做信号级验证有时候又只需要解析后的数值两种都保留最省事。2.3 时间戳两个时钟系统之间的对齐方案做 ADAS 数据链路时间戳对齐永远是最容易翻车的环节。ADTF 体系里用微秒级时间戳通常和采集工控机的系统时钟或某个同步源关联ROS 2 里的时间基准是ros::Time靠节点的时钟或者系统时间。两边的“时间原点”不是天然一致的。我的做法是做一个ClockOffset模块在转换开始时记录第一帧 ADTF 时间戳和 ROS 时间戳的偏移量然后在转换过程中持续校验。校验逻辑并不复杂每处理 1000 帧重新计算偏移量如果偏差超过阈值说明采集过程中时钟发生了跳变这时就输出一条告警而不是默默把错的数据放过去。实际写转换逻辑时代码框架大概长这样// 离线转换核心逻辑伪代码级示意 void ConvertAdtfFile(const std::string dat_path, const ChannelMapConfig cfg, ros2::BagWriter writer) { adtf::IFileReader reader; reader.Open(dat_path); ClockOffset clock_offset; while (auto sample reader.NextSample()) { auto media_type sample.GetMediaType(); if (media_type.Is(video/x-raw)) { ImageMsg msg; msg.header.stamp clock_offset.ToRosTime(sample.GetPts()); msg.header.frame_id cfg.GetFrameId(sample.GetStreamName()); msg.width sample.GetUInt(width); msg.height sample.GetUInt(height); msg.step sample.GetUInt(stride); msg.encoding bgr8; msg.data.assign(sample.GetData(), sample.GetData() sample.GetSize()); writer.Write(cfg.GetTopicName(sample.GetStreamName()), msg); } } }注意一个细节frame_id必须在通道映射配置里写明而不是从 ADTF 流名称里直接套用。因为 ROS 坐标系和底盘坐标系不一样比如前视摄像头一般是camera_front_link算法侧拿到后还要做外参变换如果 frame_id 写得含含糊糊后续坐标转换就乱了。2.4 为什么“离线转换”危险性低反而真能保证稳妥可能有人担心离线转换等于多了一步文件格式迁移丢数据怎么办我的经验是只要在转换过程中保留原始帧访问权而且采样时不跳过任何一帧转换失真的风险很低。真正容易出问题的是压缩和缩放这些“加工步骤”。一开始我在转换时图省事直接把 JPEG 压缩过的图像解出来就当作原始数据用结果回放后发现 ROI 区域的边缘细节和现场看到的偏差挺大。后来改成只做无损剥离也就是保留 ADTF 里记录的压缩格式或者在压缩前就存原始像素其他处理全部放到 ROS 侧去完成。这个改动直接让后续感知模型回归测试的有效性提升了一个档次。3. 数据采集阶段真正下手时这些细节直接决定回放质量3.1 传感器同步与 frame_id 约定采集段规划的第一步是先把 ADTF 工程里的同步域确认干净。ADTF 默认是按时间触发来驱动数据流的但多传感器之间的时间对齐还要看硬件是否接了同步信号。比如摄像头和 LiDAR 之间如果只靠软件打时间戳抖动能有几十毫秒对高速场景的回放验证来说就是灾难。我这边常用的方案是让所有传感器统一走 GPS/PPS 授时的同步源ADTF 侧订阅同步信号作为主时钟摄像头收到 PPS 脉冲后锁定帧触发。这样在录制文件里每一个 sample 的微秒级时间戳都指到同一个时钟域上。另一个容易忽视的是 frame_id 约定。在采集设备日志里把每个传感器的坐标系写成固定字符串转换进 ROS 后统一发布前视摄像头camera_front_link左前毫米波雷达radar_front_left_link主激光雷达lidar_top_link车辆后轴中心base_link这些命名要在转换配置里固化后续做点云投影、目标级融合对齐时才能直接套坐标变换。3.2 存储带宽预算看起来不大的车载数据一天能吃掉 TB 级空间“路上跑一天数据量能有多少”这个问题我建议每个测试工程师都养成先算再跑的習慣。以我常碰到的配置做一个保守估算数据源典型配置单秒数据量8 小时数据量无压缩前视摄像头1920x108030fpsRGB8约 186 MB/s约 5.4 TB侧视摄像头1280x72025fpsRGB8约 69 MB/s约 2 TB激光雷达 64 线10Hz 点云约 4 MB/s约 115 GBCAN 信号500kbps 满载约 0.5 MB/s约 14 GB组合导航 IMU/GNSS100Hz约 0.1 MB/s约 3 GB不做任何压缩保护的话单路前视摄像头一天的数据量都是 TB 级。所以在采集端我会给 ADTF 工程配置有损压缩策略摄像头视频流用硬件编码 H.264 保存其他传感器原样保留。转换为 ROS bag 时图像如果保持编码格式就把编码后的数据放进sensor_msgs/CompressedImage模型需要原始像素就在读取时解码到内存。这里要提醒一句如果感知算法要直接吃原始图不建议拿 H.264 压缩帧反复编解码来做精细回归解码失真会在多次转换中累积。最好是采集端同时保留一路低码率 H.264 用于人工快速预览和场景初筛另一路关键路段再做无损采集或者直接把原始像素录制下来代价是存储成本上去但验证结果可信度高。3.3 录制分段与标定信息挂载长时间录制必然会遇到文件分段。ADTF 支持按文件大小或时长自动切段但我的建议是按“场景段”切而不是按时间切。也就是说测试工程师跑完一个有价值的场景比如前车切入、行人横穿后手动打一个标记工具链按标记把上下文切一个场景包。转换到 ROS bag 时每个场景包就是一个独立 bag 文件或者一个带说明的 MCAP 分区。这个做法的好处是算法团队后续回归时可以按场景名称来选择测试集而不是去一张几百 GB 的大 bag 里去捞数据。标定参数建议以静态 topic 的方式写进 bag 头部比如发一个sensor_msgs/CameraInfo固定重复发几帧或只发一遍并记录在 bag 元信息里。实际项目里我的做法更直接每个场景包附带一个calibration.yaml文件里面记录摄像头内参、外参、畸变系数、安装时间。转换器把CameraInfo以固定时间戳发到 ROS 侧后续算法节点订阅后缓存即可。4. 回放验证把现场搬回工位还要能“翻来覆去”地看4.1 回放工具链的三个核心能力拿 ROS 2 的ros2 bag回放录制数据功能上已经完全够用但工程上还要考虑三个能力时间尺度控制、单 topic 暂停、时间戳保持。时间尺度控制就是能按 0.1 倍速、0.5 倍速、1 倍速回放甚至暂停在某一帧。这个对 ADAS 测试特别重要高速场景逐帧看能看出目标检测的突发丢帧到底发生在哪个时刻。单 topic 暂停是指回放过程中可以直接把某一类数据停掉单独看其他数据。比如我想只看车在 40 km/h 时摄像头和激光雷达的时间对齐情况就可以先暂停 CAN 信号只让图像和点云继续放。时间戳保持是回放模块里最重要的一点ros2 bag play默认按录制时的时间戳进行消息发布中间如果暂停、跳帧重新开始后依然保持原始时间间隔。这对于算法节点接收到消息后做时间插值至关重要。实际回放命令非常简洁# 回放完整场景 ros2 bag play --rate 1.0 scene_front_cut_in # 回放时跳过前 30 秒 ros2 bag play -s 30 scene_front_cut_in # 半速慢放便于观察目标跟踪连续性 ros2 bag play -r 0.5 scene_front_cut_in如果只回放部分 topic可以在--topics参数里指定几个比全量回放快得多。4.2 验证闭环怎么闭合回放验证不只是把数据放一遍关键是验证结果要和真值对齐。这个闭环我分成三级来建第一级数据完好性验证。检测回放 bag 里各 topic 的消息数、时间戳是否连续、有无丢帧。这一级主要靠脚本定期跑不依赖业务逻辑。第二级感知输出对比。把 ROS 算法栈接进来让它消费 bag 里的图像和点云输出目标列表。参考真值可以来自 ADTF 侧记录的标注结果或者在回放阶段手工标注。二者做 IoU 对比、漏检/误检统计形成逐帧报告。第三级场景级回归。把历史场景库里的 bag 全部跑一遍算法对比输出指标。任何代码改动导致指标下降就能自动定位到是哪个场景的问题。这里补充一个实践技巧真值不一定非要曾经导出过的 ADTF 对象列表也可以在回放前把标注软件生成的 JSON 导入成 ROS 消息同步发到对应 topic。这样算法节点感知到的真值流和传感器流保持在同一个时间线上测评节点照常对比。4.3 半实时回放在硬件在环阶段的延伸纯做软件回放的话bag 回放速度可以超过实时。但一旦进入硬件在环HIL测试被测域控制器需要以真实时钟消耗数据这时就不能用“尽量快”的回放方式了必须用半实时回放按真实时间戳驱动数据发送发送两个消息之间等待系统时间。这部分我验证过两种方案一种是用ros2 bag play自带的实时模式保证时间节拍另一种是自己写回放节点用 timer 按固定周期从预加载的消息列表里取消息。第二种更可控因为你可以对某些数值做在线修改比如把当前车速改掉注入故障信号这在测试策略设计中非常有用。5. 一路踩过的坑时间戳跳变、数据缓冲、磁盘 IO5.1 时间戳跳变的完整排查链路有一段时间回放出来的 bag 在算法节点里表现为“目标位置突变”。现象是目标明明在直线行驶感知输出却每隔几秒跳一下坐标。一开始怀疑算法跟踪出问题后来对比时间戳发现图像 topic 的时间戳在某几个时刻突然倒退了 80 多毫秒然后又跳回来。排查链路是这样的先看 ADTF 原文件的 sample 时间戳本身是否连续。用 ADTF 自带的分析视图打开同一段数据发现原始时间戳里确实存在跳变排除转换 bug。再确认跳变是否来自采样源。对比 GPS 时间源和本地系统时间发现有两个 sensor 的时钟源配置不一致一个锁了同步源一个走的是本地时钟。最后锁定根因采集工程里部分插件在同步异常时自动切到了系统时钟导致这一小段时间戳走了另一条时间线。修复动作很简单把所有插件的时间源强制指定为同步主时钟并在转换器里增加时间戳单调性检查转换过程中一旦发现 non-monotonic就中断并给出提示。这条检查后来救了我很多次。5.2 大数据包和 DDS 缓冲参数不匹配ROS 2 的底层通信走 DDS默认配置对大的传感器数据不一定友好。摄像头我从 1920x1080 分辨率、30fps 发出去单个 Image 消息大约 5-6 MB如果 QoS 队列策略设得不对很容易出现消息丢弃或者发布端阻塞。表现就是实时桥接模式下ros2 topic hz 显示频率忽高忽低图像偶尔缺帧。解决办法是双管齐下ROS 2 侧把 QoS 深度调大比如 10并在发布图像时用best_effort通信模式点云和 CAN 用reliable也可以但点云大消息比较多建议同样走best_effort降低阻塞概率。DDS 侧需要调整共享内存传输相关的配置。ROS 2 的 Fast DDS 默认支持 shared memory但如果在同一台机器里跑桥接和算法节点我实测把传输模式改为基于共享内存的效率比跨网 UDP 高不少CPU 占用率也更低。5.3 回放一旦卡顿先从磁盘顺序读入手回放验证阶段最容易遇到的问题是系统配置明明不低但回放一快进或跳转就卡。用 top 看 CPU 不高内存也不满结果卡在下盘 IO。查下来发现bag 文件存储在大容量机械硬盘上跨文件跳转时随机读性能严重不足。尤其一个场景切了多少个 bag 文件快进跳转就要反复抽读不同文件的索引块机械盘根本顶不住。我的解决方案很笨但很有效把回放用的 bag 全部放在 NVMe SSD 上或者直接在回放前把文件和索引加载到内存盘。几 GB 的场景包在内存盘里跑得非常稳而且回放跳转不会引入磁盘抖动。内存盘缺点是重启后消失但回放任务本来就不用长期持久化临时解压进去用最合适。5.4 沉淀下来的日常 SOP经过两轮项目迭代最后我们整个团队跑数据的流程固定成了这么一套采集前确认 ADTF 工程同步源所有传感器时钟源统一自然导航数据服务正常。采集时按场景打标记每个场景单独切段关键场景同时保留原始像素和预览码流。采集后自动跑转换工具生成 ROS bag calibration.yaml 场景元数据索引。回放前跑一遍数据完好性检查脚本看 topic 数量、时间戳连续性和帧率。回放中按需设置速率、跳过时间、单 topic 暂停发现问题立即打点记录。回归验证把场景包交给算法组CI 里自动跑感知栈产出逐帧指标报告。这套 SOP 现在已经完整嵌入我们项目的每日构建流程里。数据从采到用不经过任何人工搬运测试环境和算法环境的边界清晰两边各管各的环节但数据是同一套。最后再分享一个小经验做这种工具链适配永远不要以为“能通就行”。第一版我们跑通的时候也觉得齐活了但随着场景库变大、传感器车型增多类型映射和时钟校验迟早会碰到新问题。如果一开始就把时间戳单调性检查、通道映射配置化、场景元数据独立存储这三件事做扎实后面接新车、新传感器的时候改动量就很可控而且不太会失眠。