简介:针对城市智能交通场景中红绿灯倒计时数字的实时检测需求,这份源码包提供了一套基于改进YOLOv8的完整解决方案,面向计算机视觉开发者、交通系统研究人员及算法落地工程师。资源共包含26个文件,压缩包大小约2.97MB,主要类型有4个Python脚本(对应训练、验证、预测及Web交互界面)、19张用于数据集展示与检测效果验证的PNG图片,以及txt、md、docx格式的说明文档,可清晰呈现从数据标注、模型训练到前端可视化的整体流程。
在技术实现上,项目对YOLOv8模型进行了70余项创新优化,涵盖网络结构调整、特征提取增强与算法策略改进等方面,并配有完整的数据集标注与训练流程说明。通过Web前端可视化界面,用户可以直接观察红绿灯状态及倒计时数字识别结果。目前已有88人学习该资源,适合希望快速复现智能交通视觉系统、或深入研究YOLO改进方法的读者,借助文档与脚本可快速搭建实验环境,减少重复开发成本。
1. 红绿灯倒计时数字检测:为什么不能只靠YOLOv8原版
在路口场景里,红绿灯倒计时数字通常是LED点阵或数码管显示,目标小、亮度高、还带闪烁和反光,拿原版YOLOv8直接去检测,会出现漏检和误检交替出现的情况。很多毕业设计或者路侧感知项目卡在这一步,误以为换个大模型就能解决,实际上一路排查下来,发现瓶颈在数据集标注粒度、网络头部对小目标的感知能力,以及前后帧数字识别的时序一致性。这个标题里给到的方案,核心思路不是简单跑通YOLOv8,而是围绕红绿灯倒计时数字这类小目标做针对性改进,再配上标注、训练、Web端可视化的完整链路。适合谁看?准备用YOLOv8做目标检测落地项目、尤其对交通场景感兴趣的学生或测试工程师。
2. 搭出能跑的YOLOv8底座:环境配置与数据集标注
2.1 本地环境怎么选:CPU也能跑,但推荐CUDA
先把环境踩实。YOLOv8的官方实现基于ultralytics库,一行pip install ultralytics就能装上,但模型训练依赖PyTorch,所以第一步是确认PyTorch能不能识到你的GPU。我一般会先跑一段快速诊断代码:
import torch print(torch.__version__) print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0) if torch.cuda.is_available() else "No GPU")这里torch.cuda.is_available()返回True,说明CUDA链路没问题。很多人的翻车点是直接pip install torch装的是CPU版本,GPU明明存在却用不了。解决方法是按照PyTorch官网给的CUDA版本命令安装,比如pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118。
如果你只有CPU,YOLOv8训练也能跑,但速度只有GPU的几十分之一。我之前用一台i5的笔记本跑一个2000张的红绿灯数字数据集,一个Epoch要10分钟,100个Epoch就得十几个小时。这种场景下建议先去搞一个免费GPU环境跑通验证,或者把输入尺寸从640降到320先做冒烟测试,确认代码和标注没问题时再上完整训练。另外,只用CPU推理的话,用ONNX导出再跑也不慢,后面Web端展示时我会选择导出成ONNX格式。
2.2 用labelme标注红绿灯倒计时数字:类别定义与json转txt
YOLOv8训练数据用的是txt格式的标签,每行是class_id x_center y_center width height,但标注时用labelme画多边形最方便,因为它能处理细长条的数字区域。常见做法是先把红绿灯倒计时数字定义成单类别countdown_digit,因为无论是红灯倒计时还是绿灯倒计时,模型只需要识别出数字区域,具体是哪个颜色由交通灯检测模块去关联。
标注完一张图,labelme会保存一个同名json文件,里面是点和形状信息。YOLOv8不认识这种格式,需要写一个转换脚本。我常用的转换逻辑如下:
import json import os from PIL import Image def labelme_to_yolo(json_path, save_path, class_list): with open(json_path, 'r', encoding='utf-8') as f: data = json.load(f) img_w = data['imageWidth'] img_h = data['imageHeight'] yolo_lines = [] for shape in data['shapes']: label = shape['label'] if label not in class_list: continue cls_id = class_list.index(label) points = shape['points'] # [[x1,y1],[x2,y2],...] xs = [p[0] for p in points] ys = [p[1] for p in points] x_min, x_max = min(xs), max(xs) y_min, y_max = min(ys), max(ys) x_center = (x_min + x_max) / 2 / img_w y_center = (y_min + y_max) / 2 / img_h width = (x_max - x_min) / img_w height = (y_max - y_min) / img_h yolo_lines.append(f"{cls_id} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}") with open(save_path, 'w') as f: f.write('\n'.join(yolo_lines)) class_list = ['countdown_digit'] # 对images目录下每张图对应的json调用函数即可这段代码的关键在于坐标归一化。YOLO要求中心点坐标和宽高都是相对于图片尺寸的比例,而不是像素绝对值。x_center = (x_min + x_max) / 2 / img_w这一步就是把像素中心点换算到0~1之间。你需要注意的是,labelme的shape类型如果画的是多边形,points可能有很多点,但只要取最小外接矩形就够了,不需要拟合旋转框。
我踩过的坑是:有些人标注的习惯是只框数字本身,比如单个数字3、5,但我建议直接框整个倒计时区域。因为红绿灯倒计时经常是两位数,把区域整体框住能让模型学到“两位数字”的整体特征,识别阶段再配合OCR模型去做数值解析,比单独框一个数字再拼接要稳定得多。
2.3 数据集划分与训练前检查
标注完成之后,把图片和txt标签按ImageNet的目录习惯放好:
dataset/ images/ train/ val/ labels/ train/ val/然后写一个划分脚本,按比例随机分配文件。这里有一个很容易漏掉的细节:划分时必须保证图片和标签同名,不然训练时报错说找不到标签。我一般会在划分脚本里做一次文件名交集校验:
import os import random from shutil import copy2 img_dir = 'dataset/images/all' label_dir = 'dataset/labels/all' train_img_dir = 'dataset/images/train' train_label_dir = 'dataset/labels/train' random.seed(42) img_files = [f for f in os.listdir(img_dir) if f.endswith('.jpg')] random.shuffle(img_files) train_files = img_files[:int(len(img_files) * 0.8)] for f in train_files: base = os.path.splitext(f)[0] label_file = os.path.join(label_dir, base + '.txt') if not os.path.exists(label_file): print(f"缺失标签: {label_file}") continue copy2(os.path.join(img_dir, f), train_img_dir) copy2(label_file, train_label_dir)这个脚本里random.seed(42)保证每次运行划分结果一致,避免之后改参数复训时因为数据划分布不同导致结果对不上。另外建议在训练前用脚本统计一下标注框的尺寸分布,如果大部分框的宽度或高度小于32像素,就说明这是典型的小目标问题。YOLOv8原版在P3层(8倍下采样)做检测,对这类小目标天然不敏感,后面就得改网络结构。
3. 改进YOLOv8:70余项创新点里真正有用的三处
标题里写了70余项创新点,说实话源码包里真正能稳定提点的通常就那么几个。我不建议把所有改进都堆上去,会增加调参难度和推理延迟。按我的实测,对红绿灯倒计时数字这类场景,以下三处改动最划算。
3.1 头部改进:增加浅层小目标检测头
YOLOv8的检测头默认从三个尺度输出特征图,分别是8倍、16倍、32倍下采样。8倍下采样特征图已经丢失了很多细节,而红绿灯倒计时数字在路侧摄像头画面里可能只有10x30像素,占特征图不足一个格子。常见的做法是在原本三个检测头之上再加一个4倍下采样的P2检测头。具体在ultralytics代码里,是通过修改yaml中的head结构实现的:
# yolov8n-smart.yaml 节选 head: - [-1, 4, [64, 128]] # P2/4 下采样 ...但直接改yaml不生效,因为ultralytics的backbone输出节点和head层数是对应死的,真正动手时你要在ultralytics/nn/modules/head.py里增加一个检测层,并把Detect的ch参数加上P2特征图的通道数。很多源码包采用的是重写一个自定义模型文件的方式,核心逻辑是让backbone输出更浅层的特征图给检测头。
调参说明:增加P2头后,计算量大概上涨20%,如果你的设备是Jetson或者嵌入式板卡,需要考虑推理延迟。我的做法是保留P2头做训练,导出ONNX时做一个分支选择,部署端用传统图像处理定位数字区域,再用一个小分类网络识别数字,这样能把延迟压回30ms以内。
3.2 注意力机制:给数字区域更多权重
红绿灯倒计时数字和背景颜色相近,比如红灯倒计时是红色数字衬在黑色底板上,图像上对比度和纹理都有限。给backbone最后两层加注意力机制能明显提升特征响应。源码包里最常见的实现是加CBAM或者SE注意力。我复现过CBAM,效果比SE稳定:
class CBAM(nn.Module): def __init__(self, c1, reduction=16): super().__init__() self.channel_att = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(c1, c1 // reduction, 1), nn.ReLU(inplace=True), nn.Conv2d(c1 // reduction, c1, 1), nn.Sigmoid() ) self.spatial_att = nn.Sequential( nn.Conv2d(2, 1, kernel_size=7, padding=3), nn.Sigmoid() ) def forward(self, x): x_out = x * self.channel_att(x) avg_out = torch.mean(x_out, dim=1, keepdim=True) max_out, _ = torch.max(x_out, dim=1, keepdim=True) spatial = torch.cat([avg_out, max_out], dim=1) x_out = x_out * self.spatial_att(spatial) return x_out这段代码是CBAM的压缩版。通道注意力用全局平均池化生成每个通道的权重,空间注意力则把通道信息压缩成2个特征图再卷积生成空间权重。接入位置我建议放在backbone第4层和第5层输出后,而不是所有层都加。原因是倒计时数字是高层语义和低层纹理的结合,中高层特征既包含纹理又保留了一定的空间分辨率,放太前面会把计算浪费在背景上,放太后面又丢失位置信息。
我在实验中发现一个很有意思的现象:加了CBAM之后,mAP提了大概2个百分点,但是推理时间增加了近10ms。如果你的硬件很吃紧,可以考虑只在训练时开CBAM,部署时用一份移除了CBAM的简化权重做蒸馏推理,不过这就太折腾了,一般项目不这么搞。
3.3 训练策略与损失函数改进
网络结构改了,损失函数也可以调。YOLOv8默认用了分类损失BCE、回归损失CIoU和DFL。对数字检测来说,误检的代价比漏检高,因为交通系统宁愿没检测到也不愿意报一个错误的数字。我通常会把分类损失改成focal loss,把难样本的梯度放大:
# ultralytics/models/loss.py 中修改分类损失部分 import torch.nn.functional as F class FocalLoss(nn.Module): def __init__(self, gamma=2.0, alpha=0.25): super().__init__() self.gamma = gamma self.alpha = alpha def forward(self, pred, target): bce = F.binary_cross_entropy_with_logits(pred, target, reduction='none') p = torch.sigmoid(pred) p_t = p * target + (1 - p) * (1 - target) focal_weight = (1 - p_t) ** self.gamma focal_weight = focal_weight * (self.alpha * target + (1 - self.alpha) * (1 - target)) loss = focal_weight * bce return loss.mean()这里的alpha控制正负样本权重,gamma控制难易样本权重。红绿灯倒计时数字的样本量在各帧中相对均匀,但负样本(背景中被误认为是数字的区域)数量远大于正样本,所以把alpha设成0.25做正样本加权比较合适。改了损失函数后要同步调整训练超参数,比如lr和weight_decay,否则容易不收敛。
另外我也常看到有人把训练时的mosaic增强关闭,原因是在交通路口这种场景,mosaic会把多个图的数字区域截断拼在一起,导致标注框丢失,反而降低精度。我建议mosaic设为0.5而不是全关,保留一部分让它增加多样性。
4. 训练自己的数据集:参数含义与loss曲线判读
4.1 yolo训练命令与参数解释
一切准备就绪后,用ultralytics的命令行训练最省事。这是我的常用命令:
yolo detect train \ model=models/yolov8n-smart.yaml \ data=dataset/smart_traffic.yaml \ epochs=150 \ batch=16 \ imgsz=640 \ lr0=0.01 \ lrf=0.01 \ optimizer=SGD \ device=0 \ # 指定GPU序号,CPU则用device=cpu workers=4 \ seed=42 \ cache=True逐个参数说:model是指向你自己定义的yaml文件,这个yaml里包含了模型结构和改进后的head定义;data是数据集的yaml,内容大概是train: 路径,val: 路径,nc: 1,names: ['countdown_digit'];batch在GPU显存不够时会报CUDA out of memory,我就把16改成8,或者在配置里加amp=False关闭混合精度训练再试;imgsz根据原图分辨率来,路侧摄像头原始分辨率1080p,直接缩放到640会导致数字更小,所以我一般用imgsz=960,代价是训练时间翻倍;optimizer=SGD比Adam在目标检测里更稳,但收敛速度慢,想要快速出效果可以换成AdamW。
4.2 画损失函数曲线图:看模型有没有收敛
训练过程中,ultralytics会在runs/detect/train/目录下生成results.png和results.csv。我一般不看results.png,因为目前版本的ultralytics在训练完成后才绘制,训练中看不到实时曲线。想看实时loss曲线,需要单独写个回调,或者用tensorboard。推荐一个很轻量的做法:训练命令里加上project=mydetect name=exp_smart这样脚本轮询csv:
import pandas as pd import matplotlib.pyplot as plt df = pd.read_csv('runs/detect/exp_smart/results.csv') plt.figure(figsize=(10, 6)) plt.plot(df['epoch'], df['train/box_loss'], label='box_loss') plt.plot(df['epoch'], df['train/cls_loss'], label='cls_loss') plt.plot(df['epoch'], df['val/box_loss'], label='val_box_loss') plt.xlabel('epoch') plt.ylabel('loss') plt.legend() plt.title('Loss Curves') plt.savefig('loss_curve.png')这里的train/box_loss是回归框损失,train/cls_loss是分类损失。一个常见规律是cls_loss下降快,因为只有一个类别好学;box_loss下降慢,因为小目标的中心点偏差比例比较大,即使只差几个像素,相对于不足20像素宽的目标来说损失就已经很大了。看曲线时重点看val损失有没有在30个epoch后开始反弹,反弹就是过拟合信号,要调大weight_decay或者加早停。
4.3 断点续训与模型验证
长训练任务最怕跑到一半断电或报错。ultralytics支持断点续训,命令是yolo detect train resume=True model=runs/detect/exp_smart/weights/last.pt。last.pt存的是最近一个epoch结束的状态,包含优化器参数和epoch数,续训时不需要重新指定数据集配置。我一般是每跑20个epoch就把best.pt复制一份到带时间戳的目录,防止后面改了代码把旧的best覆盖掉。
验证阶段跑:
yolo detect val \ model=runs/detect/exp_smart/weights/best.pt \ data=dataset/smart_traffic.yaml \ conf=0.25 \ iou=0.5 \ split=testconf是置信度阈值,交通场景建议设0.4以上,宁可漏检也不能误检——误检会把7识别成1,影响后面的倒计时逻辑。iou是NMS的IoU阈值,0.5是常规值,对密集数字区域可以降到0.45,防止重复框。
验证输出的txt里会有每类的precision、recall和mAP50。对红绿灯数字,我关注的不是mAP,而是recall在低置信度下是否足够高,如果recall低,那很可能是标注框太小,需要回看3.1节是否加了P2头;如果precision低,那就是背景误检多,要回看focal loss的alpha设置。
5. 踩坑记录:从标注到推理的5个常见问题
现象1:训练时报错“unable to find dataset label file”原因:data yaml里的路径写的是相对路径,启动命令时工作目录不在项目根目录。解决:把data yaml里的路径全部改成绝对路径,或者用yolo detect train前先cd到项目根目录。我当时卡了半小时,最后发现是Windows下路径分隔符\被当成转义字符了,统一改成/就正常了。
现象2:Loss曲线震荡严重,val_loss不下降原因:这是我在用改进后的模型时遇到的。新加的检测头初始化权重和原来不匹配,前20个epoch回归梯度特别大。解决:不要一下子改所有结构,先加P2头,保持其他部分用原版,训练50个epoch确认曲线平滑了,再加CBAM。另外把warmup_epochs从3调到5,让学习率慢慢爬升,避免开局就把权重冲飞。
现象3:推理时漏检特别多,尤其是距离远的数字原因:视频里红绿灯数字往往在画面边缘且透视变形严重,训练集里这样的样本太少。解决:做数据增强时,把translate、scale调大,fliplr=0.0——因为数字7翻转后会变成别的形态?其实数字翻转不影响类别,但红绿灯的位置固定,翻转后可能和另一边信号灯冲突。更有效的做法是收集跨路口的素材,不同安装角度和镜头高度的截图,然后按镜头高度分bucket训练。
现象4:CPU推理速度太慢,达不到实时原因:模型参数和输入分辨率太高。解决:导出成TensorRT。即使没有GPU,也可以在普通PC上先用onnxruntime-gpu跑,但真正要实时,还是要走engine格式。具体做法是先转onnx,再用trtexec转engine。如果没有NVIDIA GPU,就退而求其次用yolov8n权重,并把推理尺寸设成416,配合torch.no_grad()和torch.cuda.synchronize()省掉同步开销。
现象5:Web端显示检测框和原画错位原因:前端展示的视频流分辨率如果和模型推理分辨率不一致,没有做坐标映射。解决:后端推理时记录原始图像的宽高,把模型输出的归一化坐标乘回原始宽高,再传给前端。如果是用Flask的VideoFeed模式,前端img标签显示的是JPEG流,框是画在图片上的,就不会错位;但如果是用canvas叠加,就要确保视频分辨率固定,不能自适应屏幕。
6. 接上Web前端可视化:把检测结果变成实时交互界面
训练好的模型要变成能演示的东西,最直接的做法是搭一个轻量级的Web服务。我用Flask加一个简单的HTML页面就能搞定,不引入React/Vue这些重框架。后端启动一个线程读摄像头或视频文件,把帧送入模型推理,把检测框和识别到的数字用OpenCV画在帧上,然后压缩成JPEG传给前端。
from flask import Flask, Response import cv2, torch, numpy as np from ultralytics import YOLO app = Flask(__name__) model = YOLO('best.pt') def generate(): cap = cv2.VideoCapture('traffic_sample.mp4') while cap.isOpened(): ret, frame = cap.read() if not ret: break results = model(frame, conf=0.4, verbose=False) annotated = results[0].plot() ret, jpeg = cv2.imencode('.jpg', annotated) yield (b'--frame\r\n' b'Content-Type: image/jpeg\r\n\r\n' + jpeg.tobytes() + b'\r\n') @app.route('/video_feed') def video_feed(): return Response(generate(), mimetype='multipart/x-mixed-replace; boundary=frame') if __name__ == '__main__': app.run(host='0.0.0.0', port=5000)这段代码里results[0].plot()直接返回带标注的帧。为了让页面更智能,可以再增加一个阈值判断:当连续3帧检测到倒计时数字变化时,再通过WebSocket把新的倒计时值推给前端页面同时更新文字提示,而不是每一帧都刷新大数字,这样能减少闪烁。
我自己的教训是:别把模型加载和推理放在Flask的请求线程里跑。因为Flask默认单进程,多个客户端同时打开视频流会导致推理队列堵塞,表现为页面卡顿。解决方法是用multiprocessing开一个独立推理进程,把检测结果放到共享队列,Flask只负责读取队列中的最新帧。另外,视频流是阻塞式的,如果客户端断开连接,generate()不会立刻退出,要加cap.release()和超时判断,否则后台会堆积僵尸线程。
最后说到这个Web界面,其实演示效果好坏往往在细节:前端加一个“当前倒计时数字”的大字号显示,配一个红绿状态图标,比单纯显示坐标框要直观得多。这一套做下来,整个项目从模型训练到工程展示就算闭环了。希望帮到你。
本文还有配套的精品资源,点击获取