想啃自动驾驶的激光雷达感知,Apollo 6.0 的开源代码几乎是绕不开的一手资料。这套系统把“从原始激光点云到结构化障碍物列表”的完整链路都打通了,而且每个环节都模块化拆开,适合拿来学习,也适合直接改。这篇笔记是我自己边读源码边跑数据包的经验记录,目标是把感知模块的整体结构、数据流、关键算法和实用配置一次讲清楚,给正在啃 Apollo 6.0 激光雷达感知代码的同学一个相对完整的地图。
我默认你已经有基础的 C++ 和点云概念,知道 PCL、ROS 或 CyberRT 其中之一。如果你刚接触,我也尽量把每个环节“为什么这么做”讲明白,而不是只贴代码路径。
1. 先搞清楚整套感知链路是干嘛的
1.1 激光雷达感知在 Apollo 里的定位
Apollo 6.0 的感知模块接收的不只是激光点云,而是“点云 + 定位 Pose + 高精地图”三份信息的组合。定位告诉你车在哪、朝向哪,高精地图提供了车道线和路口区域,激光点云告诉你周围真实存在什么。三者合在一起,经过感知模块处理后,输出一份结构化的障碍物列表,里面每辆车、每个行人都有编号、位置、速度、朝向、类别和置信度。
这份列表是下游预测和规划的输入。预测模块基于它猜测障碍物未来几秒的运动轨迹,规划模块再基于这些轨迹做避让决策。所以如果感知有偏差,比如把静止的栏杆当成了车,或者漏掉了侧后方来的外卖骑手,后面所有环节都会跟着错。这也是为什么每次改动感知参数或算法,都必须看完整链路效果,而不是只看单帧输出。
从代码结构上看,激光雷达感知主要集中在modules/perception/lidar目录,入口和子模块都拆得很干净。我先给你一个目录感,再讲代码里真正的数据流。
modules/perception/lidar/ ├── app/ # 模块入口,组件注册与通道绑定 │ ├── lidar_obstacle_perception.cc │ └── ... ├── lib/ │ ├── point_cloud_preprocess/ # 点云预处理、过滤 │ ├── roi_filter/ # 基于高精地图的感兴趣区域过滤 │ ├── obstacle_detection/ # 检测算法实现 │ │ ├── cnn_segmentation/ │ │ └── point_pillars/ │ ├── obstacle_tracking/ # 多帧跟踪 │ └── interface/ # 统一接口定义 └── common/1.2 我建议按这个顺序啃源码
很多人一上来就钻进 CNN 分割或者 PointPillars 的网络结构里,结果被各种公式绕晕,还没搞明白整个模块是怎么串起来的。我的建议是反着来,先跑通、再看数据、最后读算法。
第一步,先跑通官方 demo 数据包。在 DreamView 或者 CyberVisualizer 里把感知模块打开,让障碍物框“肉眼可见”。任何算法,先看到现象比先读懂公式重要得多。第二步,把整个数据流的中间结果拉出来看。点云经过 ROI 过滤后还剩多少点,检测环节输出的框长什么样,跟踪环节 ID 是否稳定,每个阶段都值得停下来看输出。第三步,才是回到代码里读每个子模块的接口和参数。按这个顺序,你在读obstacle_detection那段 C++ 代码时,脑子里会始终有画面,不会觉得是在看天书。
我下面几个章节,就按这个思路来组织。
2. 从原始点云到障碍物列表,四个关键环节
2.1 ROI 过滤:先决定“看哪里”
激光雷达一帧数据在路口场景下可能包含十几万个点,但真正有意义的障碍物只占很小一部分。如果直接对全场景点云做检测,计算量会非常夸张,而且远处一堆树、房子、栏杆还容易产生大量误检。所以 Apollo 在检测之前先做一步 ROI(Region of Interest)过滤,核心思路是:先把可行驶区域找出来,只留区域内的点。
Apollo 里的HDMapROIFilter会从高精地图中提取当前车附近的参考道路线,生成一块多边形 ROI。点云中的点会被投影到平面,判断是否落在这个多边形内部,落在内部的保留,外部的丢弃。这个过程用到了射线法之类的几何判断,思路很朴素,但效果立竿见影。实测中,ROI 过滤经常能消掉一半以上的无效点,后续检测的帧率能快不少。
这个环节最大的坑是“过滤太狠”。ROI 多边形的一条边可能紧贴着护栏,但实际场景里行人的手、伸出来的后视镜是超出道路边界的。如果 ROI 收缩得太紧,这些点会被切掉,导致障碍物框缺损、跟车距离判断不准。我曾见过一个配置把 ROI 横向边界设得特别窄,结果旁边车道的大货车只检测到半个车身,后面跟踪器也没法稳定跟踪。所以调 ROI 参数时,宁可稍微留一点余量,也别为了帧率把边界收得太极端。
预处理里还有一步更基础的过滤,把太近的、太远的、强度异常的点去掉。距离太近的点往往被车身结构反射,容易形成噪点;太远的点稀疏且精度差,对检测反而是干扰。这步看似不起眼,但对后续检测器输入质量影响很大。
2.2 障碍物检测:CNN 分割还是 PointPillars
检测是整个链路的核心。Apollo 6.0 里激光雷达检测主要有两套可用方案:CNNSegmentation 和 PointPillars。两者思路完全不同,我这里从原理层面讲清楚差异。
CNNSegmentation 走的是“先分割,再聚类”的路子。点云先被转成鸟瞰视角(BEV)的多通道特征图,每个像素网格统计高度、强度、密度等信息,喂给一个卷积网络做像素级语义分割,判断每个网格属于背景还是障碍物。分割完后,再对前景网格做聚类,生成一个个障碍物候选框,最后用几何尺寸和属性模型来判断是车、行人还是其他。这套方案的好处是工程上非常成熟,处理近距离密集场景表现稳定,但在远处小目标上偶尔会丢。
PointPillars 则是典型的“纯端到端 3D 检测”。它把点云按 X-Y 平面划分成一个个柱子(Pillar),每个柱子内的点在高度方向做特征编码,整片柱子被组合成一个“伪图像”,再用类似 2D 检测的卷积网络直接回归出带朝向的 3D 框。不需要单独聚类,一步到位输出障碍物位置和大小。
我整理了一个对比表,供你选型时参考:
| 维度 | CNNSegmentation | PointPillars |
|---|---|---|
| 实现思路 | BEV分割 + 聚类 | 点云转伪图像,端到端回归 |
| 输出形式 | 障碍物分割块聚类框 | 带朝向的3D包围框 |
| 小目标表现 | 近距离稳定、远处偏弱 | 对稀疏点更鲁棒 |
| 工程成熟度 | 历史久、调参经验多 | 新一些,模型更紧凑 |
| 硬件要求 | 相对较低 | 推理时显存占用略高,可开TensorRT |
刚上手时,我建议先用默认的 CNNSegmentation 把整条链路跑通,因为它涉及的中间结果更多,更容易理解“检测”到底是什么样的一个过程。等你把分割模块的输出、聚类参数都看明白了,再切到 PointPillars 对比效果,会更有感觉。
值得多说一句的是,Apollo 的检测器是插件式设计,两种算法都通过统一的BaseObstacleDetector接口加载。这意味着你不一定非要改上层逻辑,只要按接口实现自己的检测器,替换配置文件里的检测器类型就行。后来不少自动驾驶团队在这个接口上接自己的模型,说明这套抽象设计确实留足了扩展空间。
2.3 障碍物跟踪:让框不抖、ID 不断
单帧检测结果直接拿给下游用会很糟糕,因为每隔几帧检测框就会抖动一下,一辆车可能这次检测成一堆,下次又断成两半,ID 也会乱跳。下游规划需要的是连续、稳定的障碍物轨迹,所以 Apollo 专门有一个LidarObstacleTracking模块来做多帧关联与追踪。
跟踪器的核心逻辑分成四步:预测、关联、更新、生命周期管理。预测阶段用卡尔曼滤波等模型预测每个已知障碍物在当前帧的位置;关联阶段把新检测到的障碍物与已有轨迹做匹配,找最优对应关系,常用匈牙利算法;匹配成功后更新轨迹状态和速度估计;同时管理每条轨迹的“生老病死”,没被匹配上的新检测会创建新轨迹,连续丢失的旧轨迹会被删除。
ID 切换这个现象特别值得留心。在拥堵路口,如果两辆车贴得很近,跟踪器可能把它们的轨迹 ID 搞混,或者跟到一半跟丢了又给一个全新 ID,体现为下游看到同一个目标“凭空换号”。这往往不是检测精度导致的,而是关联阶段的匹配距离阈值不合适。我曾把最大关联距离调小,结果一帧漏检,跟踪就断了,ID 变化率显著上升。这类问题排查时要和检测模块配合着看,别只盯着跟踪器。
跟踪器还会顺便平滑障碍物的位置和速度。因为雷达帧率一般是 10Hz,单帧位置误差较大,平滑之后速度曲线会好看得多,规划模块用起来也更舒服。
3. 配置和数据流:想调参先看懂这些
3.1 proto 配置与关键参数
Apollo 6.0 里感知模块的配置基本不走硬编码,全部放在文本 proto 文件里,方便线上动态调整。激光雷达感知的配置主要在modules/perception/production/data/lidar目录下。其中lidar_obstacle_detection_config.pb.txt对应检测链路,lidar_obstacle_tracking_config.pb.txt对应跟踪链路。
打开检测配置文件,你会发现里面是一层一层的嵌套结构:ROI filter 的配置段里写了几种 filter 类型,detector 配置段里指定了使用DeterministicFusion还是PointPillars,每个算法下面还有各自的参数。比如 CNN 分割里常见到的prob_threshold,表示分割网络中每个点作为前景的概率阈值,调低会召回更多障碍物但误检也会变多,调高则相反。这类阈值参数没有一个万能值,必须结合你的实际场景和数据分布来标定。
值得注意的是,很多参数并不是所有算法都通用。你换了检测器,原来的聚类半径、类别阈值可能就失效了。我踩过的一个坑是:从 CNN 切到 PointPillars 后,忘了改 tracking 部分的匹配距离,结果跟踪效果反而变差了。换算法时一定要把整份配置一起审一遍,而不是只改 detector 名称。
3.2 时间戳和数据对齐:眼睛麻了,但必须对齐
激光雷达一秒产生 10 帧点云,但定位 Pose 可能来自惯性导航的独立频率,高精地图也是另一套异步数据流。感知模块内部,每个点云帧都要拿到当前时刻对应的一组 Pose 信息用于坐标变换,否则点云会整体“飘”在错误的位置上。
Apollo 里这套工作主要靠组件的数据同步机制完成。输入消息会带时间戳,感知框架按时间戳配准对应的 Pose 和地图查询结果,然后把点云从雷达坐标系变换到世界坐标系或车辆坐标系再送入算法。这个环节一旦时间戳错位,最典型的症状是障碍物框始终偏移到点云位置的一侧,或者转弯时框明显滞后。
排查时间同步问题时,最有效的手段是看点云的可视化。在 CyberVisualizer 里同时打开点云和定位历史轨迹,如果点云相对地图位置的偏移量不随时间变化,那大概率是外参或坐标变换问题;如果偏移量忽大忽小、转弯时特别明显,就要怀疑时间同步配置了。
对了,还有个小细节。如果你回放数据包时发现障碍物框比真实位置慢一拍,别急着怀疑算法,先确认是否开了数据回放加速。回放倍率太高时,如果感知模块跑到 CPU 性能上限,处理不过来就会丢帧,表现就是“框跟着点云跑但永远差一点”。
3.3 可视化:感知效果的“照妖镜”
调试感知算法,光看日志是远远不够的。Apollo 提供了好几个可视化途径,我最常用的是 CyberVisualizer。它能直接订阅点云、障碍物列表和预测轨迹等通道,将点云和 3D 框渲染在一起。当你改了 ROI 范围或检测阈值,立刻就能看到框的变化,比手算准确率高很多。
还有一个技巧是看中间结果通道。Apollo 里很多子模块会把中间产物发布到独立 Channel,比如分割结果图、ROI 后的点云。利用这些通道,你可以精确判断问题出在哪个环节:是 ROI 把点削掉了,还是检测器没识别出来,还是跟踪器把框拉飞了。这个能力在排查问题时的价值极大,千万不要只盯最终输出。
实际操作里,我会同时开点云、障碍物框和一个手机导航视角的背景图(如果手头有 GNSS 定位可视化),用来对照真实路口情况。障碍物框出现、消失、抖动、错误分类,这些现象都能第一时间定位到具体环节,然后再去改对应配置。
4. 编译、运行与数据入口,手把手跑通
4.1 环境准备:不要在裸机上硬折腾
Apollo 对依赖版本很敏感,尤其是 CUDA、TensorRT 和内部定制的基础库,我建议直接使用官方发布时的开发容器环境,而不是在自己的宿主机上一个个装依赖。容器环境已经把这些版本的坑填好了,能省掉很多无意义的排错时间。
进入环境后,第一个要跑的是整体编译。项目目录下一般有apollo.sh脚本,执行bash apollo.sh build即可。这一步在大机器上也要等很久,因为要编译的模块非常多,所以建议让编译在后台跑,同时仔细看依赖库有没有下载失败。国内网络环境下部分第三方依赖可能拉取慢,如果编译卡住,优先检查网络和缓存,而不是盲目重来。
如果你只关心激光雷达感知,也可以单独编译 perception 相关目标,能省不少时间。不过第一次跑建议还是整体编译一次,确保整个系统依赖齐全,后面再增量编译就很快了。
另外提一个硬件问题。检测算法默认要求 GPU 推理,显存不足时程序大概率直接崩溃或报错。如果你的机器只有集显,要么把检测器切到纯 CPU 版(部分算法支持),要么提前把模型输入尺寸调小省显存,否则会反复在运行阶段遇到黑屏。这条建议值得你放进备忘录。
4.2 实际跑通的流程
环境准备好之后,我一般按下面这套流程把感知模块跑起来:
- 启动 DreamView 或直接启动 Cyber 可视化服务。
- 播放官方 demo 数据包:
cyber_recorder play -f /path/to/demo.record。 - 在 DreamView 的 Module Controller 里打开激光雷达感知模块,或者在命令行用
cyber_launch启动感知组件。 - 打开 CyberVisualizer,确认点云已经在刷新。
- 在可视化界面里开启障碍物通道,观察 3D 框是否出现。
这套流程里最容易出问题的是第 3 步,模块没有正确启动或者依赖的服务没起来,感知就不会出结果。遇到这种情况,先看日志确认组件是否正常加载,再去cyber_monitor里查看感知输出的通道有没有数据。只要通道有输出,说明链路是通的,问题大概率出在配置或可视化订阅上了。
真正运行后,试着切一下检测器和阈值,你会立刻看到感知结果的变化。多试几次,比读十篇原理文章都有用。我第一次跑通时,看到障碍物框稳稳跟着小车移动,才真正理解了整条数据流是怎么协同的。
我用一个表格把运行中常见的状态和对应检查点列出来:
| 现象 | 可能原因 | 检查方式 |
|---|---|---|
| 无点云显示 | 数据包未播放或点云通道未连接 | 用 cyber_monitor 查点云通道 |
| 点云有但无框 | 感知模块未启动或检测配置未加载 | 查感知组件日志和配置路径 |
| 框的位置明显偏移 | 时间同步错误或外参问题 | 查看 ROIPolygon 与点云位置关系 |
| 框跳变、ID 抖动 | 跟踪关联阈值不合适 | 加大最大关联距离并观察 |
| 运行一段时间后卡死 | 显存不足或内存泄漏 | 查看 GPU 占用和日志报错 |
5. 我踩过的坑,还有排查的思路
5.1 编译与依赖问题
我遇到过最头疼的问题是编译到一半报第三方库版本冲突。单独看报错看不出端倪,最后是从全量日志里发现某两个模块依赖了不同版本的同一个库。所以我的教训是:当你怀疑依赖问题时,放弃“只重编失败模块”的念头,先在干净容器里全量重编一次。
另外,编译过程中如果内存不足,不要死扛。最实用的办法就是加大 swap,或者减少并行编译进程数,比如在build脚本里限制并发数。虽然编译慢一些,但不会中途爆内存导致整个编译失败。很多人卡在这一步其实是机器配置问题,不是代码问题。
另一个常见坑是显存相关的编译报错,尤其是 TensorRT 版本不匹配时,日志一般会提示找不到某个头文件。此时不要自己去改系统依赖,检查容器内环境变量和 TensorRT 目录是否在LD_LIBRARY_PATH里。
5.2 点云在动,但障碍物一直是空
运行时最让人崩溃的是:点云清清楚楚,框一个不见。这种情况我把排查顺序大概分为三路:
第一路,检查感知模块是否真的在消费点云。有些时候组件启动失败但进程没退出,通道上没有输出,要通过日志确认。第二路,确认检测配置是否成功加载。配置文件写错路径或者 proto 解析失败时,模块会直接静默跳过检测环节,我遇到过加载失败但日志不明显的版本。第三路,检查 ROI 过滤后点云是否还有内容。如果 ROI 外参或地图区域设置有问题,可能把所有点都滤没了,检测自然没有输入。
这三路按顺序检查,能解决九成以上“零框”问题。实在不行,再打开中间结果通道,从预处理开始一级一级定位。
5.3 性能与显存问题的优化方向
激光感知实时性要求非常高,10Hz 的雷达数据必须在一帧周期 100 毫秒内处理完,否则就丢帧。如果你发现模块 CPU 占满或者 GPU 吞吐跟不上,优化方向主要有几个:
一是调小检测输入尺寸。BEV 特征图太大是显存压力的主因,缩小特征图分辨率能显著提帧率,但会损失小目标召回率,需要做平衡。二是开启 TensorRT 加速。Apollo 里很多检测模型都内置了 TensorRT 的支持,开启后推理延迟能低不少,代价是第一次构建缓存时间较长。三是精简 ROI 范围,减少送入检测器的点云量。
我实际试过把 ROI 从 80 米收缩到 50 米,检测帧率直接涨了快两倍,对小区测试路况来说完全够用。这说明很多时候瓶颈不在模型本身,而在“喂给模型多少无意义的点”。
5.4 一些容易被忽略的小细节
最后聊几个我在配置和调试中踩过的小坑,看起来不起眼,但很耽误时间:
- 时间戳单位不一致。点云、Pose、地图数据有时用的时间基准不同,如果不做对齐,会出现帧率正常但位置错乱的情况。这个问题排查起来很隐蔽,需要逐一打印各通道时间戳对比。
- 外参初始化错误。换个雷达型号忘了改外参矩阵,哪怕只偏移几厘米,在 50 米外放大会差出好几米。感知效果有问题时,外参永远优先怀疑。
- 回放数据时没关闭车辆运动模拟。有些场景下 DreamView 会模拟车辆运动状态,干扰可视化判断,建议关闭后再评价感知效果。
这些细节单拎出来都很小,但组合在一起可能耗费一整天时间。记下来,对你排查问题有实打实的帮助。
我个人在实际操作中的体会是,Apollo 6.0 激光雷达感知模块的最大学习价值不是那些深度学习模型,而是整套工程化的数据流设计。从 ROI 过滤、双检测器接口,到基于跟踪器的 ID 管理,每一层都在回答一个很具体的工程问题:如何让单帧的不完美结果,变成稳定可用的障碍物轨迹。建议你按照数据流向从头到尾跑一遍,亲手改几个参数,观察一下对最终框的影响,这套代码才算真正吸收了。