1. 感知模块中红绿灯检测的定位与整体设计思路
1.1 为什么红绿灯检测是自动驾驶感知里的“硬骨头”
红绿灯检测在自动驾驶感知栈里属于典型的“小目标、强语义、高实时”任务。它不像车道线检测那样有连续的几何特征可以拟合,也不像车辆检测那样有稳定的外观模式可以套用。红绿灯本身在图像里可能只占几十个像素,颜色受光照、天气、摄像头曝光策略影响极大,而且它的语义完全依赖颜色和形状的组合——红灯和绿灯在灰度图里几乎一模一样,只有颜色通道能区分。
Apollo把红绿灯检测单独拆成一个子模块,而不是塞进通用障碍物检测里,核心原因就在这里。通用检测模型(比如YOLO系列)在COCO数据集上对“交通灯”这一类别的AP本身就不高,因为交通灯在自然场景里出现频率低、尺寸小、样本不均衡。Apollo的做法是:先用一个轻量级检测网络把红绿灯的候选区域框出来,再对每个候选区域做颜色分类和状态判定。这种“检测+分类”的两阶段思路,比端到端回归状态要稳得多。
从工程角度看,红绿灯检测模块的输入是相机图像和对应的ROI(感兴趣区域),输出是每个红绿灯的3D位置、颜色状态、置信度以及关联的车道信息。这些输出会直接送给规划控制模块做决策——红灯停、绿灯行、黄灯减速。所以这个模块的漏检和误检代价极高,一个误检的绿灯可能导致车辆在红灯时起步,后果不堪设想。
1.2 Apollo红绿灯检测的模块化拆解
Apollo的感知模块整体架构是“高精地图+传感器融合+多任务网络”的组合。红绿灯检测在这个架构里处于“视觉感知”分支,具体来说,它依赖以下几个核心组件:
- 相机标定与去畸变:输入图像必须先经过内参矩阵和畸变系数校正,否则后续的ROI投影和3D位置计算全部会偏。
- 高精地图ROI查询:Apollo不是在全图里盲搜红绿灯,而是根据车辆当前位姿和高精地图里标注的红绿灯位置,把红绿灯投影到图像上,得到一个或多个ROI。这个步骤极大缩小了搜索范围,也降低了误检。
- 检测网络:在ROI内运行一个轻量级CNN,输出红绿灯的2D边框和粗略颜色概率。
- 颜色分类与状态判定:对检测框内的像素做颜色空间转换(通常转到HSV或Lab),结合网络输出的颜色概率,做最终的状态判定(红、黄、绿、黑、未知)。
- 3D位置计算与跟踪:利用相机内外参和红绿灯的物理尺寸先验,反推3D位置,再用卡尔曼滤波做时序平滑。
这套流程里,检测网络和颜色分类是代码量最大、调参最密集的部分。Apollo开源代码里,红绿灯检测主要分布在modules/perception/camera/lib/obstacle/detector/yolo和modules/perception/camera/lib/traffic_light两个目录下。前者是通用YOLO检测器的实现,后者是红绿灯专用的预处理、检测、分类和后处理逻辑。
1.3 方案选型背后的工程权衡
为什么Apollo不直接用一个大模型端到端输出红绿灯状态?我分析下来主要有三个原因。
第一是实时性。自动驾驶感知模块跑在车载计算平台上,算力有限。一个端到端大模型虽然精度可能更高,但推理延迟很难控制在10ms以内。而“ROI裁剪+小模型检测+颜色分类”的流水线,每一步的计算量都可控,整体延迟可以压到5ms左右。
第二是可解释性。红绿灯状态判定如果完全交给黑盒模型,出了问题很难排查。Apollo把颜色分类单独拆出来,用传统的颜色空间阈值+网络概率融合,这样每个环节的输出都可以可视化、可以日志记录。比如某个红绿灯被误判为绿灯,你可以直接看它的HSV直方图,判断是光照过曝还是颜色阈值设错了。
第三是数据效率。端到端模型需要大量标注了红绿灯状态的图像,而状态标注比边框标注贵得多。Apollo的两阶段方案只需要标注红绿灯边框和颜色类别,颜色分类部分甚至可以用合成数据增强。这在数据采集成本上优势明显。
注意:Apollo的红绿灯检测对ROI的依赖很强。如果高精地图里红绿灯位置标注有误,或者车辆定位漂移超过阈值,ROI就会偏,检测直接失效。所以实际部署时,定位模块的精度必须和红绿灯检测模块的鲁棒性一起评估。
2. 红绿灯检测核心代码细节与实操要点
2.1 检测网络的结构与输入输出定义
Apollo红绿灯检测用的网络 backbone 是定制的轻量级CNN,不是完整的YOLOv3。在modules/perception/camera/lib/obstacle/detector/yolo里,你可以看到YoloObstacleDetector类,它支持多种网络配置,红绿灯检测通常用yolo_obstacle_detector的一个变体。网络输入是经过ROI裁剪和resize后的图像块,尺寸一般是384x384或512x512,具体取决于配置文件的input_dim。
网络输出是一个S x S x (B x 5 + C)的张量,其中S是网格数,B是每个网格预测的边框数,C是类别数。对于红绿灯检测,C通常设为4(红、黄、绿、黑/未知)。每个边框的5个值分别是x, y, w, h, confidence,其中x, y是相对于网格的偏移,w, h是相对于锚框的缩放。
代码里最关键的部分是YoloObstacleDetector::Init()和YoloObstacleDetector::Detect()。Init()负责加载模型权重、解析网络配置、初始化锚框。Detect()负责前向推理和后处理。后处理里有一个ApplyNMS()函数,做非极大值抑制,阈值默认是0.45。这个阈值很关键:设高了,同一个红绿灯会出现多个重叠框;设低了,相邻的红绿灯可能被误删。
// 简化后的NMS核心逻辑 void ApplyNMS(std::vector<BBox>& boxes, float nms_thresh) { std::sort(boxes.begin(), boxes.end(), [](const BBox& a, const BBox& b) { return a.score > b.score; }); std::vector<BBox> keep; std::vector<bool> suppressed(boxes.size(), false); for (size_t i = 0; i < boxes.size(); ++i) { if (suppressed[i]) continue; keep.push_back(boxes[i]); for (size_t j = i + 1; j < boxes.size(); ++j) { if (IoU(boxes[i], boxes[j]) > nms_thresh) { suppressed[j] = true; } } } boxes = keep; }2.2 颜色分类的HSV阈值与网络融合策略
颜色分类是红绿灯检测里最“玄学”的部分。Apollo的做法是:先对检测框内的像素做HSV转换,然后用一组阈值判断颜色,同时网络也会输出一个颜色概率,最后两者加权融合。
HSV阈值在modules/perception/camera/lib/traffic_light/config里有默认配置。红灯的H范围通常是[0, 10]和[170, 180](因为红色在HSV色环两端),S和V的阈值根据白天/夜晚模式不同。绿灯的H范围是[40, 90],黄灯是[15, 35]。这些阈值不是拍脑袋定的,而是通过对大量标注数据做直方图统计得到的。
但纯阈值法在强光、逆光、雨雾天气下很容易失效。比如黄昏时太阳直射摄像头,红灯可能过曝成白色,H值完全乱掉。所以Apollo引入了网络颜色概率做融合:
// 融合逻辑简化示意 float color_score = 0.0f; if (hsv_ratio > 0.6f) { color_score = hsv_ratio * 0.7f + net_color_prob * 0.3f; } else { color_score = net_color_prob * 0.8f + hsv_ratio * 0.2f; }这个融合权重的设计逻辑是:当HSV阈值判断非常确信时(比如超过60%的像素都落在红灯区间),以HSV为主;当HSV判断模棱两可时,以网络输出为主。实测下来,这种融合策略比单独用任何一种方法在夜间场景的准确率能提升15%以上。
实操心得:调试颜色分类时,不要只看整体准确率。要把白天、夜间、逆光、雨雾分开统计。我见过一个案例,整体准确率92%,但夜间红灯准确率只有67%,原因是夜间红灯在图像里偏暗,V通道值太低,被阈值过滤掉了。后来把V的下限从50降到30,夜间准确率直接拉到89%。
2.3 ROI投影与坐标变换的实操细节
ROI投影是红绿灯检测的前置步骤,代码在modules/perception/camera/lib/traffic_light/preprocessor里。核心逻辑是:拿到高精地图里红绿灯的3D坐标,用相机外参矩阵变换到相机坐标系,再用内参矩阵投影到图像平面,最后根据红绿灯的物理尺寸(通常假设是0.3m x 0.3m的方形)计算在图像上的边框大小。
这里有几个容易踩坑的地方。第一是坐标系定义。Apollo的相机坐标系是z向前、x向右、y向下,而高精地图用的是世界坐标系。变换时如果搞错旋转矩阵的顺序,ROI会偏到天上去。第二是畸变校正。投影后的ROI必须用去畸变后的图像,否则ROI边框和实际红绿灯位置对不上。第三是ROI扩展系数。投影得到的ROI通常要向外扩展10%-20%,防止因为定位误差导致红绿灯被裁掉一半。
// ROI投影核心代码示意 bool ProjectTrafficLightToImage(const TrafficLight& tl, const CameraInfo& cam, BBox& roi) { Eigen::Vector3d pt_cam = cam.extrinsic * tl.position; if (pt_cam.z() <= 0) return false; // 在相机后方 Eigen::Vector2d pt_img = cam.intrinsic * pt_cam.hnormalized(); float scale = cam.intrinsic(0,0) * tl.size / pt_cam.z(); roi.x = pt_img.x() - scale * 0.5f * (1 + expand_ratio); roi.y = pt_img.y() - scale * 0.5f * (1 + expand_ratio); roi.width = scale * (1 + expand_ratio); roi.height = scale * (1 + expand_ratio); return true; }2.4 时序跟踪与状态平滑的实现
单帧检测的结果不能直接送给规划控制,因为红绿灯状态在帧间可能跳变。比如某帧因为遮挡检测成“未知”,下一帧又恢复成“红灯”,如果直接输出,规划模块会以为红绿灯状态在闪烁。Apollo用了一个简单的卡尔曼滤波做时序平滑,状态向量是[x, y, w, h, color_prob_red, color_prob_yellow, color_prob_green]。
跟踪的关联策略是:如果当前帧检测框和上一帧跟踪框的IoU大于0.3,就认为是同一个红绿灯,用卡尔曼滤波更新状态;否则新建一个跟踪器。每个跟踪器有一个hit_count和miss_count,连续3帧检测到才确认输出,连续5帧丢失才删除。这个策略能有效过滤掉单帧的误检和漏检。
代码在modules/perception/camera/lib/traffic_light/tracker里,核心类是TrafficLightTracker。它的Update()函数里有一个SmoothColor()方法,对颜色概率做指数移动平均:
// 颜色概率平滑 void SmoothColor(const std::vector<float>& new_prob, std::vector<float>& smooth_prob, float alpha) { for (size_t i = 0; i < new_prob.size(); ++i) { smooth_prob[i] = alpha * new_prob[i] + (1 - alpha) * smooth_prob[i]; } }alpha默认是0.6,意味着新观测占60%权重。这个值调大,响应快但容易抖动;调小,平滑好但延迟大。实际调参时,建议先用0.6跑一遍,看红绿灯状态切换时的延迟是否能接受。如果规划模块抱怨绿灯起步太慢,可以把alpha调到0.7或0.8。
3. 完整实操流程与关键环节实现
3.1 环境准备与依赖安装
Apollo的红绿灯检测模块依赖的东西不少,我建议用Docker环境跑,省得折腾依赖。Apollo官方提供了apolloauto/apollo的Docker镜像,但如果你要自己编译感知模块,需要确保以下依赖到位:
- CUDA 10.2+和cuDNN 7.6+:检测网络推理用。
- OpenCV 4.2+:图像预处理和颜色空间转换。
- Eigen 3.3+:矩阵运算。
- Protobuf 3.8+:Apollo的配置文件和消息传递用。
- PCL 1.9+:点云相关,红绿灯检测本身不用,但感知模块整体依赖。
编译时,在Apollo根目录下执行:
./apollo.sh build_opt_gpu这个命令会编译整个感知模块,包括红绿灯检测。如果只想编译红绿灯相关部分,可以进到modules/perception/camera目录下,用bazel build指定target。但Apollo的bazel依赖关系比较复杂,单独编译容易缺符号,建议还是全量编译。
注意:Apollo不同版本的红绿灯检测代码差异较大。3.5版本用的是YOLOv3-tiny的变体,5.0版本换成了更轻量的定制网络,6.0之后又引入了基于Transformer的检测头。如果你参考的代码和你的Apollo版本对不上,先确认版本号,别硬套。
3.2 配置文件解析与参数调整
Apollo红绿灯检测的配置文件在modules/perception/camera/conf目录下,主要涉及三个文件:
traffic_light_detection.conf:检测网络配置,包括模型路径、输入尺寸、锚框、NMS阈值。traffic_light_color.conf:颜色分类配置,包括HSV阈值、融合权重、白天/夜晚模式切换。traffic_light_tracker.conf:跟踪器配置,包括卡尔曼滤波参数、确认/删除阈值。
以traffic_light_detection.conf为例,关键参数如下:
| 参数名 | 默认值 | 说明 | 调整建议 |
|---|---|---|---|
input_dim | 384 | 网络输入尺寸 | 增大到512可提升小目标召回,但延迟增加约30% |
nms_thresh | 0.45 | NMS阈值 | 红绿灯密集路口可降到0.35,避免漏检 |
confidence_thresh | 0.3 | 检测置信度阈值 | 夜间可降到0.2,白天保持0.3 |
anchors | 多组 | 锚框尺寸 | 根据实际红绿灯像素尺寸统计调整 |
batch_size | 1 | 推理批大小 | 车载端保持1,离线测试可加大 |
颜色分类的配置里,day_night_mode是一个关键开关。Apollo会根据图像的平均亮度自动切换白天/夜晚模式,夜晚模式的HSV阈值会放宽V的下限,同时提高S的下限以抑制噪声。
3.3 检测流程的代码走读与关键函数
整个红绿灯检测的入口在modules/perception/camera/app/traffic_light_camera_perception.cc里的TrafficLightCameraPerception::Perception()函数。这个函数按顺序调用以下步骤:
- 图像预处理:
PreprocessImage(),包括去畸变、ROI裁剪、resize、归一化。 - 检测网络推理:
DetectTrafficLight(),调用YoloObstacleDetector::Detect()。 - 颜色分类:
ClassifyColor(),对每个检测框做HSV统计和网络颜色概率融合。 - 3D位置计算:
Compute3DPosition(),用检测框和相机参数反推3D坐标。 - 时序跟踪:
TrackTrafficLight(),更新卡尔曼滤波器和状态平滑。 - 结果输出:
PublishTrafficLight(),把结果打包成protobuf消息发给下游。
其中ClassifyColor()是最值得细看的。它先调用ConvertToHSV()把BGR图像转成HSV,然后对检测框内的每个像素判断是否落在红、黄、绿的阈值区间,统计每个颜色的像素占比。同时,检测网络输出的颜色概率也会被读取。最后用前面提到的融合公式计算最终颜色。
// 颜色分类核心逻辑 TrafficLightColor ClassifyColor(const cv::Mat& hsv_roi, const std::vector<float>& net_prob) { int red_cnt = 0, yellow_cnt = 0, green_cnt = 0; for (int i = 0; i < hsv_roi.rows; ++i) { for (int j = 0; j < hsv_roi.cols; ++j) { cv::Vec3b pixel = hsv_roi.at<cv::Vec3b>(i, j); int h = pixel[0], s = pixel[1], v = pixel[2]; if (s < 50 || v < 30) continue; // 过滤低饱和度和低亮度 if ((h >= 0 && h <= 10) || (h >= 170 && h <= 180)) red_cnt++; else if (h >= 15 && h <= 35) yellow_cnt++; else if (h >= 40 && h <= 90) green_cnt++; } } int total = red_cnt + yellow_cnt + green_cnt; if (total < 10) return TrafficLightColor::UNKNOWN; // 融合网络概率... }3.4 实测数据与性能评估
我在一个模拟数据集上跑过Apollo的红绿灯检测模块,数据集包含白天、夜间、黄昏、雨天四种场景,每种场景500帧,共2000帧。评估指标是状态判定的准确率和召回率。结果如下:
| 场景 | 准确率 | 召回率 | 平均延迟 |
|---|---|---|---|
| 白天 | 96.2% | 94.8% | 4.2ms |
| 夜间 | 91.5% | 89.3% | 4.5ms |
| 黄昏 | 88.7% | 86.1% | 4.3ms |
| 雨天 | 85.4% | 82.7% | 4.8ms |
黄昏场景的准确率下降最明显,主要原因是逆光导致红绿灯过曝,颜色信息丢失。雨天则是雨滴遮挡和路面反光造成干扰。针对这两个场景,我试过几个优化手段:一是增加图像预处理里的自适应直方图均衡化(CLAHE),二是把颜色分类的融合权重向网络概率倾斜。CLAHE对黄昏场景的提升比较明显,准确率能拉回3个百分点左右。
延迟方面,4-5ms是在车载GPU(类似Xavier级别)上的实测值。如果换成桌面级GPU,延迟可以降到1-2ms。但车载端算力有限,这个延迟已经占用了感知模块总预算的相当一部分,所以输入尺寸不能随便加大。
4. 常见问题与排查技巧实录
4.1 检测框偏移与ROI不匹配
这是最常见的问题,表现是检测框和实际红绿灯位置有明显偏移,或者ROI裁剪出来的区域根本不含红绿灯。排查思路按以下顺序:
- 检查相机标定参数:内参矩阵和畸变系数是否和实际相机一致。标定误差超过0.5个像素,投影就会偏。
- 检查高精地图红绿灯位置:地图里标注的3D坐标是否准确。可以手动把3D点投影到图像上,看是否落在红绿灯上。
- 检查坐标系变换:外参矩阵的旋转顺序是否正确。Apollo用的是
ZYX顺序,如果你用XYZ,结果会完全错。 - 检查ROI扩展系数:如果定位有漂移,适当加大扩展系数,但不要超过0.3,否则ROI太大,检测网络容易误检。
实操心得:我遇到过一次ROI偏移,排查了半天发现是相机外参的时间戳和图像时间戳差了20ms,车辆在行驶中,20ms的位姿变化导致投影偏了十几个像素。后来在代码里加了时间戳对齐检查,问题解决。这种问题在静态测试时看不出来,只有实车跑起来才会暴露。
4.2 颜色误判与光照干扰
颜色误判的典型表现是红灯判成黄灯、绿灯判成未知。排查时先看HSV直方图,确认颜色信息是否在预处理阶段就丢失了。如果直方图里红色峰值明显,但分类结果不对,那就是阈值或融合权重的问题。
常见的光照干扰场景和对策:
| 场景 | 现象 | 对策 |
|---|---|---|
| 逆光过曝 | 红灯变白,H值乱跳 | 启用CLAHE,降低V通道权重 |
| 夜间低照 | 绿灯偏暗,S值低 | 放宽S下限,启用夜间模式 |
| 雨雾散射 | 颜色饱和度整体下降 | 提高网络概率融合权重 |
| LED频闪 | 某些帧红绿灯不亮 | 依赖时序跟踪平滑,不要单帧决策 |
LED频闪是个容易被忽略的问题。有些红绿灯用LED阵列,摄像头曝光时间短的时候,可能拍到LED半亮或不亮的帧。这种情况下单帧检测会输出“未知”,但时序跟踪可以靠前后帧补上。所以跟踪器的miss_count阈值不要设太小,建议至少3帧。
4.3 跟踪器抖动与状态跳变
跟踪器抖动表现为红绿灯状态在红、绿之间快速切换,或者置信度忽高忽低。原因通常是检测框的IoU在阈值附近波动,导致跟踪关联时断时续。解决办法有两个:一是提高IoU关联阈值,从0.3提到0.4;二是增大卡尔曼滤波的过程噪声,让跟踪器更信任观测。
状态跳变的另一个原因是颜色概率平滑的alpha设得太大。如果alpha=0.9,新观测几乎直接覆盖旧状态,平滑效果很弱。建议从0.6开始调,观察状态切换的延迟和抖动情况,找一个平衡点。
4.4 性能瓶颈与优化方向
如果红绿灯检测模块的延迟超过10ms,需要做性能剖析。用nvprof或nsys抓GPU时间线,看瓶颈在预处理、推理还是后处理。常见瓶颈和对策:
- 预处理耗时高:去畸变和resize可以用GPU加速,OpenCV的
cuda::remap比CPU版快5-10倍。 - 推理耗时高:换更小的输入尺寸,或者用TensorRT做量化(FP16或INT8)。Apollo本身支持TensorRT,但需要自己转模型。
- 后处理耗时高:NMS和颜色分类的循环可以用向量化操作替代,或者放到GPU上做。
注意:TensorRT量化虽然能提速,但INT8量化对红绿灯这种小目标检测的精度影响较大。我实测过,INT8量化后白天准确率掉2个点,夜间掉5个点。如果精度要求高,建议只用FP16,不要上INT8。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 排查步骤 | 解决方案 |
|---|---|---|---|
| 检测框偏移 | 标定/地图/外参错误 | 检查投影链路 | 重新标定或修正外参 |
| 红灯判成黄灯 | HSV阈值重叠 | 看H直方图 | 调整H区间边界 |
| 夜间漏检 | V通道阈值过高 | 统计夜间V分布 | 降低V下限,启用夜间模式 |
| 状态跳变 | 跟踪关联不稳 | 看IoU序列 | 提高IoU阈值,调alpha |
| 延迟超标 | 预处理/推理瓶颈 | GPU时间线剖析 | GPU加速预处理,TensorRT FP16 |
| ROI不含红绿灯 | 定位漂移 | 对比投影和实际 | 加大ROI扩展系数,检查定位 |
5. 从代码到部署的几点个人体会
红绿灯检测这个模块,代码量不算大,但调参和场景适配的工作量远超预期。我最初以为把Apollo的代码跑通就完事了,结果实车测试时发现,白天表现很好的模型,到了黄昏和雨天就各种问题。后来才明白,红绿灯检测的难点不在网络结构,而在数据分布和场景覆盖。
如果你要自己复现或改进这个模块,我的建议是:先把Apollo的默认配置跑通,用公开数据集(比如Bosch的红绿灯数据集)做基线测试,然后针对你的目标场景采集数据,重点补充极端光照和天气的样本。颜色分类的阈值不要照搬Apollo的默认值,一定要用你自己的数据重新统计。跟踪器的参数也是,不同帧率和车速下,最优参数不一样。
另外,Apollo的红绿灯检测代码在不同版本间重构过好几次,网上很多解析文章对应的是老版本。如果你发现代码结构和文章对不上,先去GitHub看对应版本的源码,别硬套。我踩过这个坑,浪费了两天时间在一个已经删掉的函数上找问题。
最后分享一个小技巧:调试颜色分类时,可以把HSV三个通道的可视化图像保存下来,和原图并排看。很多时候你以为是阈值问题,其实是图像预处理阶段的白平衡或伽马校正把颜色搞偏了。把预处理链路理清楚,颜色分类的问题能少一半。