资讯动态

Apollo红绿灯检测实战:从ROI投影到颜色分类的工程细节

发布时间:2026/10/19 8:41:11 来源:尧图企业网站定制
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::vectorBBox boxes, float nms_thresh) { std::sort(boxes.begin(), boxes.end(), [](const BBox a, const BBox b) { return a.score b.score; }); std::vectorBBox keep; std::vectorbool 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::vectorfloat new_prob, std::vectorfloat 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.8Apollo的配置文件和消息传递用。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_dim384网络输入尺寸增大到512可提升小目标召回但延迟增加约30%nms_thresh0.45NMS阈值红绿灯密集路口可降到0.35避免漏检confidence_thresh0.3检测置信度阈值夜间可降到0.2白天保持0.3anchors多组锚框尺寸根据实际红绿灯像素尺寸统计调整batch_size1推理批大小车载端保持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::vectorfloat 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.atcv::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设得太大。如果alpha0.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 FP16ROI不含红绿灯定位漂移对比投影和实际加大ROI扩展系数检查定位5. 从代码到部署的几点个人体会红绿灯检测这个模块代码量不算大但调参和场景适配的工作量远超预期。我最初以为把Apollo的代码跑通就完事了结果实车测试时发现白天表现很好的模型到了黄昏和雨天就各种问题。后来才明白红绿灯检测的难点不在网络结构而在数据分布和场景覆盖。如果你要自己复现或改进这个模块我的建议是先把Apollo的默认配置跑通用公开数据集比如Bosch的红绿灯数据集做基线测试然后针对你的目标场景采集数据重点补充极端光照和天气的样本。颜色分类的阈值不要照搬Apollo的默认值一定要用你自己的数据重新统计。跟踪器的参数也是不同帧率和车速下最优参数不一样。另外Apollo的红绿灯检测代码在不同版本间重构过好几次网上很多解析文章对应的是老版本。如果你发现代码结构和文章对不上先去GitHub看对应版本的源码别硬套。我踩过这个坑浪费了两天时间在一个已经删掉的函数上找问题。最后分享一个小技巧调试颜色分类时可以把HSV三个通道的可视化图像保存下来和原图并排看。很多时候你以为是阈值问题其实是图像预处理阶段的白平衡或伽马校正把颜色搞偏了。把预处理链路理清楚颜色分类的问题能少一半。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑