红绿灯检测这个题目看起来像是老生常谈但真要把整套系统跑通、跑稳中间要填的坑比想象中多得多。我前后用YOLOv11搭过三套不同规模的红绿灯识别系统从最初只能跑单张图片的demo到后来带PyQt界面、支持图片/视频/摄像头三种输入源的完整工具中间踩过的坑包括但不限于模型把圆形路灯认成红灯、视频推理时帧率掉到个位数、摄像头切换分辨率后直接崩溃、PyQt界面在推理线程里卡死主线程等等。这套系统最终落地后的效果是单张图片推理在普通消费级显卡上稳定在30ms以内视频文件推理能保持实时帧率摄像头实时检测延迟控制在100ms左右PyQt界面在连续运行两小时以上不出现内存泄漏。这篇文章我会把整套系统的搭建过程拆开来讲从YOLOv11模型选型和红绿灯数据集的特殊性到PyQt界面的线程模型设计再到三种推理模式的实现细节和性能优化。适合已经了解YOLO基本用法、想做一个完整可交付系统的开发者也适合正在做智能交通相关项目、需要快速搭建原型的朋友。我不会只贴代码重点讲清楚每个设计决策背后的原因以及那些文档里不会写的实操经验。1. 为什么红绿灯检测比通用目标检测更棘手1.1 红绿灯目标的特殊性分析红绿灯检测和常规的COCO类别检测有本质区别。通用目标检测里一个人、一辆车它们的形态相对固定尺度变化虽然存在但不会太极端。红绿灯不一样它的挑战来自多个维度。首先是极端尺度变化。同一个红绿灯在距离摄像头5米时可能占据画面1/4的面积在50米外可能只有十几个像素。这种尺度跨度要求模型同时具备大目标和小目标的检测能力。YOLOv11本身的多尺度检测头P3到P5能覆盖一部分但对于远距离的小红绿灯P3特征图下采样8倍有时候还是不够用。我在实际项目里遇到过最极端的情况一个1080P画面里远处的红绿灯只有8×12像素模型直接漏检。其次是类别定义的模糊性。红绿灯不是一个单一类别它至少包含红灯、绿灯、黄灯三种状态有时候还要区分箭头灯和圆形灯。更麻烦的是不同国家和地区的红绿灯形态差异巨大。国内常见的横排三灯、竖排三灯欧洲有些地方是单灯变色还有一些临时信号灯完全是另一种形态。如果你的应用场景跨区域类别定义就需要仔细斟酌。第三个挑战是背景干扰。红绿灯通常出现在复杂城市背景中周围有大量发光物体车尾灯、霓虹灯招牌、路灯、甚至反射在湿滑路面上的光斑。这些干扰物在颜色和形状上都可能和红绿灯相似。我训练的第一个模型就经常把红色车尾灯误判为红灯尤其是在夜间场景。1.2 YOLOv11在红绿灯场景下的选型考量YOLOv11提供了n/s/m/l/x五个规格。做红绿灯检测选哪个不能拍脑袋决定。我的建议是先用YOLOv11s跑通全流程再根据实际精度需求决定是否升级。原因在于红绿灯检测的瓶颈往往不在模型容量而在数据质量和后处理逻辑。我做过对比实验在同一个红绿灯数据集上YOLOv11n的mAP50是0.82YOLOv11s是0.87YOLOv11m是0.89YOLOv11l是0.90。从s到m只提升了2个百分点但推理时间增加了近一倍。对于需要实时推理的场景这个 trade-off 不划算。但有一个例外如果你的场景中小目标特别多比如高空俯拍或者远距离监控YOLOv11m以上的规格在P3特征图上的表现会明显更好。这时候可以考虑用YOLOv11m同时配合输入分辨率提升比如从640提升到960或1280来增强小目标检测能力。还有一个容易被忽略的点YOLOv11的anchor-free设计对红绿灯这种长宽比变化大的目标更友好。YOLOv5时代用的是anchor-based需要针对红绿灯的宽高比聚类anchor。YOLOv11不需要这一步省了不少事而且在极端宽高比下的表现更稳定。1.3 数据集构建中最容易踩的坑红绿灯数据集的质量直接决定模型上限。我见过太多人随便找几千张图片标注一下就开训结果模型在实际场景中完全不能用。这里说几个关键点。标注规范要统一。红绿灯的边界框到底怎么画是只框亮着的灯还是把整个灯箱都框进去我的经验是只框发光部分。因为在实际推理时你关心的是当前亮的是什么灯而不是灯箱的物理尺寸。如果框了整个灯箱模型会学到灯箱的纹理特征而不是发光状态这在夜间和白天之间的泛化能力会很差。类别设计要克制。不要一上来就搞十几个类别红灯、绿灯、黄灯、红箭头、绿箭头、黄箭头、红倒计时、绿倒计时……类别越多每个类别的样本就越少模型越难学好。我的建议是先从三个类别开始red、green、yellow。如果箭头灯在你的场景中很重要再加arrow_red、arrow_green。倒计时数字单独作为一个类别不要和灯的状态混在一起。夜间和白天样本要均衡。很多公开数据集以白天为主夜间样本很少。但红绿灯检测在夜间的难度远高于白天因为发光物体的对比度、光晕、反射都会干扰检测。我通常会把夜间样本的比例控制在40%左右如果实际场景是夜间为主这个比例还要提高。负样本不能少。所谓负样本就是画面中有类似红绿灯的干扰物但没有真实红绿灯的图片。比如车尾灯特写、霓虹灯招牌、路灯等。加入这些负样本能显著降低误检率。我一般会在训练集中加入10%到15%的纯负样本。2. 从零搭建YOLOv11红绿灯检测模型2.1 环境配置与依赖安装环境配置这一步看似简单但版本不兼容的问题能让人折腾半天。我推荐用conda创建一个独立环境避免和系统Python冲突。conda create -n yolov11_traffic python3.10 conda activate yolov11_trafficPython版本选3.10是因为它在兼容性和性能之间比较平衡。3.8太老3.12有些库还没跟上。接下来安装PyTorch。这里要注意CUDA版本和PyTorch版本的对应关系。如果你的显卡驱动支持CUDA 11.8可以这样装pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118如果只跑CPU推理不推荐但有些部署场景确实没有GPU直接pip install torch torchvision然后安装Ultralytics包pip install ultralyticsPyQt5的安装pip install PyQt5OpenCVpip install opencv-python这里有个坑opencv-python和opencv-python-headless不要同时装否则会出现奇怪的GUI冲突。如果你只用PyQt显示画面不需要OpenCV的窗口功能可以装headless版本体积更小。注意Ultralytics的版本更新很快不同版本之间API可能有细微差异。建议锁定一个稳定版本比如pip install ultralytics8.3.0避免今天跑通的代码明天就报错。2.2 红绿灯数据集的准备与格式转换YOLOv11用的是YOLO格式的标注每张图片对应一个txt文件每行是class_id x_center y_center width height坐标都是归一化到0到1之间的。如果你手头的数据是VOC格式XML或者COCO格式JSON需要转换。我写了一个通用的转换脚本这里以VOC转YOLO为例import xml.etree.ElementTree as ET import os def voc_to_yolo(xml_dir, output_dir, classes): if not os.path.exists(output_dir): os.makedirs(output_dir) for xml_file in os.listdir(xml_dir): if not xml_file.endswith(.xml): continue tree ET.parse(os.path.join(xml_dir, xml_file)) root tree.getroot() size root.find(size) img_w int(size.find(width).text) img_h int(size.find(height).text) lines [] for obj in root.findall(object): cls_name obj.find(name).text if cls_name not in classes: continue cls_id classes.index(cls_name) bbox obj.find(bndbox) xmin float(bbox.find(xmin).text) ymin float(bbox.find(ymin).text) xmax float(bbox.find(xmax).text) ymax float(bbox.find(ymax).text) x_center (xmin xmax) / 2.0 / img_w y_center (ymin ymax) / 2.0 / img_h width (xmax - xmin) / img_w height (ymax - ymin) / img_h lines.append(f{cls_id} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}) txt_name xml_file.replace(.xml, .txt) with open(os.path.join(output_dir, txt_name), w) as f: f.write(\n.join(lines))转换完成后需要按照YOLO的数据集结构组织文件dataset/ ├── images/ │ ├── train/ │ ├── val/ │ └── test/ ├── labels/ │ ├── train/ │ ├── val/ │ └── test/ └── traffic_light.yamltraffic_light.yaml的内容path: ./dataset train: images/train val: images/val test: images/test names: 0: red 1: green 2: yellow2.3 训练参数调优与常见问题处理训练命令本身很简单yolo detect train datatraffic_light.yaml modelyolov11s.pt epochs100 imgsz640 batch16但参数怎么设直接决定训练效果。这里说几个关键参数。imgsz输入分辨率。640是默认值但如果你的场景中小目标多建议提到960甚至1280。代价是显存占用和推理时间增加。我的经验是红绿灯检测用960比640的mAP50能提升3到5个百分点尤其是远距离小目标。batch批次大小。在显存允许的前提下越大越好但不要超过显存的80%。如果出现OOM显存不足先把batch减半试试。epochs训练轮数。100轮是个安全的起点。如果验证集loss还在下降可以继续训到150或200轮。但要注意过拟合如果训练集mAP持续上升而验证集mAP开始下降就该停了。lr0初始学习率。默认0.01对红绿灯这种中等规模数据集通常够用。如果训练loss震荡厉害可以降到0.005。数据增强YOLOv11默认开启了mosaic、mixup、HSV增强等。对于红绿灯检测我建议关闭mixup因为mixup会把两张图混合可能导致红绿灯和背景的对应关系混乱。mosaic可以保留但比例不要太高。训练过程中最常见的问题是loss不下降。原因通常有三个学习率太大、数据标注有问题、或者预训练权重不匹配。排查顺序是先用小学习率0.001跑10轮看看loss有没有变化如果还是不动检查标注文件是否有格式错误比如坐标超出0到1范围最后确认用的预训练权重和模型规格一致。另一个常见问题是验证集mAP远低于训练集。这说明过拟合了。解决办法增加数据量、增强数据增强、减小模型规格、或者加dropout。红绿灯检测中过拟合往往是因为夜间样本太少模型记住了白天样本的特征。3. PyQt界面设计的核心逻辑3.1 为什么选择PyQt而不是其他GUI框架做桌面端推理工具GUI框架的选择其实不多PyQt、Tkinter、wxPython、或者用Web技术栈ElectronPython后端。我选PyQt的原因很实际。Tkinter太简陋做个带视频显示、多按钮、进度条的界面布局能把人逼疯。wxPython跨平台一致性差在Windows上跑得好好的到Linux上控件样式全变。Electron方案太重一个简单的推理工具打包出来几百MB而且Python和JavaScript之间的通信延迟在实时视频场景下不可忽略。PyQt的优势在于控件丰富、布局灵活、信号槽机制天然适合多线程。尤其是信号槽它让工作线程和UI线程之间的通信变得非常安全。你不需要手动加锁只需要定义信号在合适的时候emit出去Qt会自动处理线程间的调度。还有一个实际考虑PyQt的QImage和QPixmap对OpenCV的numpy数组支持很好转换只需要几行代码。这在视频推理场景下非常关键因为你需要把每一帧的检测结果实时显示在界面上。3.2 界面布局与功能模块划分整套系统的界面我分成了四个主要区域顶部控制栏包含模型加载按钮、置信度阈值滑块、IoU阈值滑块、输入源选择图片/视频/摄像头。左侧显示区占据界面主要面积用于显示当前帧的检测结果。图片模式下显示静态图视频和摄像头模式下显示实时画面。右侧信息面板显示检测统计信息包括当前帧检测到的红绿灯数量、各类别计数、推理耗时预处理、推理、后处理分别计时。底部状态栏显示当前状态就绪/推理中/已暂停、FPS、以及保存按钮。布局用QHBoxLayout和QVBoxLayout嵌套实现。左侧显示区用QLabel承载设置setScaledContents(False)保持宽高比缩放。from PyQt5.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QSlider, QComboBox, QStatusBar) from PyQt5.QtCore import Qt class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle(红绿灯检测系统 - YOLOv11) self.resize(1280, 800) central QWidget() self.setCentralWidget(central) main_layout QHBoxLayout(central) # 左侧显示区 self.display_label QLabel(等待输入...) self.display_label.setAlignment(Qt.AlignCenter) self.display_label.setMinimumSize(800, 600) self.display_label.setStyleSheet(background-color: #1e1e1e; color: #ffffff;) # 右侧控制面板 right_panel QVBoxLayout() self.btn_load_model QPushButton(加载模型) self.btn_image QPushButton(图片推理) self.btn_video QPushButton(视频推理) self.btn_camera QPushButton(摄像头推理) self.btn_stop QPushButton(停止) self.conf_slider QSlider(Qt.Horizontal) self.conf_slider.setRange(1, 100) self.conf_slider.setValue(25) right_panel.addWidget(self.btn_load_model) right_panel.addWidget(self.btn_image) right_panel.addWidget(self.btn_video) right_panel.addWidget(self.btn_camera) right_panel.addWidget(self.btn_stop) right_panel.addWidget(QLabel(置信度阈值)) right_panel.addWidget(self.conf_slider) right_panel.addStretch() main_layout.addWidget(self.display_label, stretch4) main_layout.addLayout(right_panel, stretch1) self.status_bar QStatusBar() self.setStatusBar(self.status_bar) self.status_bar.showMessage(就绪)3.3 多线程推理避免界面卡死的核心设计这是整个PyQt部分最关键的设计。如果你直接在按钮的点击回调里跑推理循环界面会立刻卡死因为Qt的事件循环被阻塞了。用户点不了停止按钮拖不动滑块甚至窗口都拖不动。正确的做法是把推理逻辑放在QThread里通过信号槽和主线程通信。我设计了一个InferenceWorker类from PyQt5.QtCore import QThread, pyqtSignal import cv2 import numpy as np from ultralytics import YOLO class InferenceWorker(QThread): frame_ready pyqtSignal(np.ndarray) stats_ready pyqtSignal(dict) finished pyqtSignal() def __init__(self, model, source, source_type, conf0.25, iou0.45): super().__init__() self.model model self.source source self.source_type source_type self.conf conf self.iou iou self.running True def run(self): if self.source_type image: self._process_image() elif self.source_type video: self._process_video() elif self.source_type camera: self._process_camera() self.finished.emit() def _process_image(self): frame cv2.imread(self.source) results self.model(frame, confself.conf, iouself.iou) annotated results[0].plot() self.frame_ready.emit(annotated) self.stats_ready.emit(self._extract_stats(results[0])) def _process_video(self): cap cv2.VideoCapture(self.source) while cap.isOpened() and self.running: ret, frame cap.read() if not ret: break results self.model(frame, confself.conf, iouself.iou) annotated results[0].plot() self.frame_ready.emit(annotated) self.stats_ready.emit(self._extract_stats(results[0])) cap.release() def _process_camera(self): cap cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) while cap.isOpened() and self.running: ret, frame cap.read() if not ret: break results self.model(frame, confself.conf, iouself.iou) annotated results[0].plot() self.frame_ready.emit(annotated) self.stats_ready.emit(self._extract_stats(results[0])) cap.release() def _extract_stats(self, result): boxes result.boxes stats {total: len(boxes), red: 0, green: 0, yellow: 0} if boxes is not None: for cls_id in boxes.cls.cpu().numpy().astype(int): if cls_id 0: stats[red] 1 elif cls_id 1: stats[green] 1 elif cls_id 2: stats[yellow] 1 return stats def stop(self): self.running False这里有几个设计细节值得说。frame_ready信号传递的是numpy数组主线程收到后需要转换成QImage再显示。stats_ready传递字典用于更新右侧信息面板。running标志位用于优雅停止而不是强制terminate线程后者可能导致资源泄漏。主线程中连接信号self.worker InferenceWorker(model, source, source_type, conf, iou) self.worker.frame_ready.connect(self.update_display) self.worker.stats_ready.connect(self.update_stats) self.worker.finished.connect(self.on_inference_finished) self.worker.start()update_display方法负责把numpy数组转成QImage并缩放显示def update_display(self, frame): rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) h, w, ch rgb.shape bytes_per_line ch * w qimg QImage(rgb.data, w, h, bytes_per_line, QImage.Format_RGB888) pixmap QPixmap.fromImage(qimg) scaled pixmap.scaled(self.display_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation) self.display_label.setPixmap(scaled)注意QImage构造时传入的numpy数组必须在整个QImage生命周期内保持有效。如果rgb是局部变量函数返回后可能被回收导致显示异常。稳妥的做法是调用.copy()或者把rgb存为实例变量。4. 三种推理模式的实现细节与性能优化4.1 图片推理从单张到批量处理图片推理是最简单的模式但也有一些细节值得优化。最基本的流程是读取图片、推理、绘制结果、显示、可选保存。def process_single_image(model, image_path, conf0.25, iou0.45, saveFalse): frame cv2.imread(image_path) if frame is None: raise ValueError(f无法读取图片: {image_path}) results model(frame, confconf, iouiou) annotated results[0].plot() if save: output_path image_path.replace(., _result.) cv2.imwrite(output_path, annotated) return annotated, results[0]批量处理时不要一张一张循环调用model()而是把图片路径列表直接传给YOLOv11results model([img1.jpg, img2.jpg, img3.jpg], confconf, iouiou)YOLOv11内部会自动做批处理比循环调用快30%到50%。但要注意显存限制如果图片很多需要分批比如每批16张。图片推理中一个常见需求是保存推理结果。results[0].plot()返回的是绘制了边界框和标签的numpy数组直接用cv2.imwrite保存即可。但要注意plot()默认的线宽和字体大小在低分辨率图片上可能不合适。可以通过参数调整annotated results[0].plot(line_width2, font_size12, labelsTrue, confTrue)如果要把检测结果导出为JSON或CSV可以从results[0].boxes中提取boxes results[0].boxes for i in range(len(boxes)): cls_id int(boxes.cls[i]) conf float(boxes.conf[i]) xyxy boxes.xyxy[i].cpu().numpy() print(f类别: {cls_id}, 置信度: {conf:.3f}, 坐标: {xyxy})4.2 视频推理帧率保持与丢帧策略视频推理的核心矛盾是模型推理速度可能跟不上视频帧率。一个1080P、30FPS的视频如果每帧推理需要50ms那实际处理速度只有20FPS视频会变慢。解决策略有三种策略一跳帧处理。不是每一帧都推理而是每隔N帧推理一次中间帧复用上一次的检测结果。N的大小根据推理速度和视频帧率动态调整。这种方法适合红绿灯检测因为红绿灯状态变化不会太频繁跳几帧完全没问题。def process_video_with_skip(model, video_path, skip2, conf0.25): cap cv2.VideoCapture(video_path) fps cap.get(cv2.CAP_PROP_FPS) frame_count 0 last_annotated None while cap.isOpened(): ret, frame cap.read() if not ret: break if frame_count % skip 0: results model(frame, confconf) last_annotated results[0].plot() else: last_annotated frame.copy() if last_annotated is not None: # 可以在这里叠加之前的检测框或者直接显示原帧 pass yield last_annotated frame_count 1 cap.release()策略二降低推理分辨率。YOLOv11的imgsz参数可以在推理时调整。训练时用640推理时可以用416或320速度能提升一倍以上精度损失在可接受范围内。对于红绿灯这种大目标居多的场景320的输入分辨率往往够用。策略三使用半精度推理。如果显卡支持FP16开启半精度能显著加速results model(frame, confconf, halfTrue)在我的测试中RTX 3060上YOLOv11s用FP16比FP32快了约40%精度几乎无损。视频推理还有一个容易忽略的点视频写入。如果你要把检测结果保存为视频文件需要用cv2.VideoWriter并且注意编码器和帧率的设置fourcc cv2.VideoWriter_fourcc(*mp4v) out cv2.VideoWriter(output.mp4, fourcc, fps, (width, height))mp4v编码器兼容性好但压缩率一般。如果追求更小的文件体积可以用avc1H.264但需要系统安装了对应的编码器。4.3 摄像头推理延迟控制与分辨率适配摄像头推理和视频推理最大的区别是摄像头是实时的没有视频总帧数的概念而且延迟直接影响用户体验。延迟的来源有三个摄像头采集延迟、模型推理延迟、显示延迟。采集延迟通常可以忽略USB摄像头在30FPS下约33ms推理延迟是主要瓶颈显示延迟在PyQt中通常很小。控制延迟的关键是不要让采集缓冲区堆积。OpenCV的VideoCapture默认会缓冲几帧如果你推理慢缓冲区会越来越满导致显示的永远是几秒前的画面。解决办法是设置缓冲区大小为1cap cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)但要注意不是所有摄像头驱动都支持这个设置。如果不支持可以在每次read()之前多调用几次grab()来清空缓冲区for _ in range(3): cap.grab() ret, frame cap.retrieve()分辨率适配是另一个坑。不同摄像头的默认分辨率不同有些是640×480有些是1280×720有些是1920×1080。如果你在代码里硬编码了显示尺寸换一个摄像头可能就显示异常。稳妥的做法是读取摄像头的实际分辨率然后动态调整cap cv2.VideoCapture(0) actual_w int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) actual_h int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) print(f摄像头实际分辨率: {actual_w}x{actual_h})如果摄像头支持可以主动设置分辨率cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)但设置后要重新读取确认因为有些摄像头会忽略不支持的分辨率回退到默认值。提示摄像头推理时建议把YOLOv11的imgsz设为416或320而不是训练时的640。这样推理速度能提升一倍以上对于实时预览场景精度损失几乎感知不到。5. 那些文档里不会写的踩坑记录5.1 模型加载失败与路径问题Ultralytics加载模型时如果传入的是相对路径它会相对于当前工作目录查找。在PyQt应用中工作目录可能和你想象的不一样。比如你用PyCharm运行工作目录是项目根目录但如果你打包成exe工作目录可能变成exe所在目录。稳妥的做法是使用绝对路径import os model_path os.path.join(os.path.dirname(os.path.abspath(__file__)), weights, best.pt) model YOLO(model_path)另一个常见问题是模型文件损坏。下载预训练权重时网络中断文件不完整加载时会报各种奇怪的错误。验证方法是检查文件大小YOLOv11s的权重约18MB如果明显小于这个值重新下载。5.2 摄像头被占用与释放OpenCV的VideoCapture在释放不彻底时会导致摄像头被占用下次打开失败。尤其是在程序异常退出时cap.release()可能没被执行。解决办法是用try...finally确保释放cap cv2.VideoCapture(0) try: while True: ret, frame cap.read() if not ret: break # 处理帧 finally: cap.release()在PyQt中还要在窗口关闭事件里停止工作线程并释放摄像头def closeEvent(self, event): if self.worker and self.worker.isRunning(): self.worker.stop() self.worker.wait(3000) event.accept()5.3 内存泄漏的排查与解决长时间运行后内存持续增长这是PyQtOpenCV应用常见的问题。原因通常有几个QImage未释放。每次update_display都创建新的QImage和QPixmap如果旧的对象没有被垃圾回收内存会持续增长。解决办法是显式调用self.display_label.clear()或者确保没有额外的引用持有这些对象。信号槽连接重复。每次开始推理都连接一次信号但没有断开旧的连接导致一个信号触发多个槽函数不仅浪费资源还可能造成逻辑错误。解决办法是在连接前先断开try: self.worker.frame_ready.disconnect() except TypeError: pass self.worker.frame_ready.connect(self.update_display)OpenCV的VideoCapture未释放。前面已经提到用finally确保释放。我实测下来修复这三个问题后程序连续运行4小时内存增长控制在50MB以内基本可以忽略。5.4 推理结果的可视化优化results[0].plot()默认的可视化效果比较基础。如果你想让检测框更醒目可以自定义绘制def draw_custom_boxes(frame, boxes, class_names, colors): for i in range(len(boxes)): xyxy boxes.xyxy[i].cpu().numpy().astype(int) cls_id int(boxes.cls[i]) conf float(boxes.conf[i]) color colors[cls_id] cv2.rectangle(frame, (xyxy[0], xyxy[1]), (xyxy[2], xyxy[3]), color, 2) label f{class_names[cls_id]} {conf:.2f} (tw, th), _ cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 1) cv2.rectangle(frame, (xyxy[0], xyxy[1]-th-10), (xyxy[0]tw, xyxy[1]), color, -1) cv2.putText(frame, label, (xyxy[0], xyxy[1]-5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 1) return frame红绿灯检测中我建议用红色框标红灯、绿色框标绿灯、黄色框标黄灯这样一眼就能看出检测结果。颜色定义colors { 0: (0, 0, 255), # 红灯 - 红色 1: (0, 255, 0), # 绿灯 - 绿色 2: (0, 255, 255), # 黄灯 - 黄色 }注意OpenCV用的是BGR顺序所以红色是(0, 0, 255)而不是(255, 0, 0)。6. 系统集成与打包发布6.1 把模型、界面、推理逻辑串起来整套系统的入口是一个main.py负责初始化QApplication、加载模型、创建主窗口。模型加载放在主窗口初始化时而不是每次推理时避免重复加载浪费时间。import sys from PyQt5.QtWidgets import QApplication from ui.main_window import MainWindow if __name__ __main__: app QApplication(sys.argv) window MainWindow() window.show() sys.exit(app.exec_())主窗口中模型加载按钮触发load_model方法def load_model(self): model_path, _ QFileDialog.getOpenFileName(self, 选择模型文件, , Model Files (*.pt)) if model_path: self.model YOLO(model_path) self.status_bar.showMessage(f模型已加载: {model_path})推理按钮触发时先检查模型是否已加载然后创建InferenceWorker并启动。6.2 用PyInstaller打包成独立可执行文件打包是最后一步也是最容易出问题的一步。PyInstaller的基本命令pyinstaller --onefile --windowed --name TrafficLightDetector main.py但这样打包出来的exe很可能跑不起来因为Ultralytics和PyQt有一些隐藏的依赖和数据文件需要手动包含。推荐用spec文件配置# TrafficLightDetector.spec a Analysis( [main.py], pathex[], binaries[], datas[ (weights/best.pt, weights), ], hiddenimports[ ultralytics, ultralytics.models, ultralytics.nn, cv2, ], ... )打包后的exe体积会比较大通常200MB到400MB因为包含了PyTorch和CUDA运行时。如果目标机器没有GPU可以打包CPU版本的PyTorch体积能小一半。注意打包时如果遇到RecursionError在spec文件中添加import sys; sys.setrecursionlimit(5000)。如果遇到缺少ultralytics的配置文件把ultralytics包目录下的cfg文件夹手动复制到打包输出目录。6.3 实际部署中的性能调优建议部署到实际环境后还有几个调优点。开启TensorRT加速。如果目标机器有NVIDIA显卡把PyTorch模型转成TensorRT引擎推理速度能提升2到3倍from ultralytics import YOLO model YOLO(best.pt) model.export(formatengine, halfTrue, device0)然后加载TensorRT引擎model YOLO(best.engine)使用ONNX Runtime。如果目标机器没有GPU或者GPU不支持CUDA可以转成ONNX用ONNX Runtime推理比纯CPU的PyTorch快不少model.export(formatonnx, simplifyTrue)调整置信度阈值。默认的0.25在某些场景下可能太低导致误检多。红绿灯检测中我通常把置信度阈值设在0.4到0.5之间能有效减少误检同时不会漏掉明显的红绿灯。后处理过滤。对于红绿灯检测可以加一个简单的后处理如果同一个位置同时检测到红灯和绿灯只保留置信度高的那个。因为物理上不可能同时亮红灯和绿灯。def filter_conflicting_detections(boxes, iou_threshold0.5): # 对重叠度高的不同类别检测框只保留置信度最高的 keep [] sorted_idx boxes.conf.argsort(descendingTrue) for i in sorted_idx: conflict False for j in keep: if iou(boxes.xyxy[i], boxes.xyxy[j]) iou_threshold: conflict True break if not conflict: keep.append(i) return keep这套系统从最初的原型到最终稳定运行我大概迭代了十几个版本。最大的体会是模型精度只是基础工程实现的细节才决定系统能不能真正用起来。一个mAP 0.85的模型如果推理线程设计不好界面卡死用户根本没法用而一个mAP 0.80的模型配合流畅的界面和合理的后处理实际体验反而更好。如果你也在做类似的项目建议先把整个流程跑通再逐步优化每个环节不要一开始就追求完美的模型精度。