简介:基于YOLOv8的交通标志检测设计压缩包,面向深度学习、图像识别方向的毕业设计、课程设计及期末大作业,提供一套可直接参考的项目实现方案。内容围绕YOLOv8单阶段检测机制展开,覆盖速度限制、禁令、指示等多种交通标志的识别与定位,并兼顾实时性与模型泛化能力,适用于自动驾驶辅助、智能交通监控等实际场景。压缩包共含4个文件,包括Jupyter Notebook分步笔记、Python主程序、训练好的权重文件以及说明文档,整体大小6.27MB,结构精简、模块划分清晰,便于从数据准备到模型训练、评估与部署进行全流程学习。目前已有54人学习下载。借助这些文件,读者可掌握YOLOv8的网络结构、训练与推理逻辑,获得能直接运行的检测脚本和预训练权重,结合文档还能复现实验过程,为相关课题提供扎实的代码基础与实践参考。
1. YOLOv8 交通标志检测:毕设和课程设计最怕的不是模型,是数据
交通标志检测是深度学习和图像识别方向里少有的「场景明确、指标可量化、数据集现成」的题目,所以毕设、课程设计和期末大作业都爱选它。但真正动手做过的都知道,YOLOv8 训练本身不难,环境一配、命令一跑,两小时就能看到 loss 在降。难的是数据标注格式转换、类别不均衡、小目标漏检,以及训练完不知道怎么看结果好不好。
这份资源的核心价值不在「教你用 YOLOv8」,而在「把交通标志检测这条链路完整打通」——从数据集整理、标签格式转换,到训练参数设置、损失函数曲线解读,再到 RK3588 这类边缘设备上的部署适配,每一环都给了可直接用的脚本和配置。适合三类人:做毕设需要完整技术路线的本科生,课程设计想直接跑通模型交作业的在读生,以及想快速验证 YOLOv8 在交通标志场景下效果、不想从零写数据处理代码的从业者。
2. 数据集和标注格式:VOC 转 YOLO 的脚本才是真正省时间的地方
交通标志检测绕不开数据集。公开数据集里 CCTSDB、TT100K 用得最多,前者是国产数据集,场景贴近国内道路,后者包含 45 类标志但类别分布极不均衡。无论你用哪个,拿到的标注大概率是 VOC 格式(XML 文件),而 YOLOv8 要的是 TXT 格式的归一化坐标。这一步不搞定,后面训练全是空的。
2.1 标注格式的差异:YOLOv8 为什么只认 TXT
VOC 格式把每个目标的坐标存在 XML 里,是绝对像素值,比如<xmin>187</xmin>。YOLO 格式要求每张图一个同名 TXT,每行一个目标,格式是class_id x_center y_center width height,后四个值全部除以图片宽高做归一化。归一化的原因很简单:YOLO 训练时会把图片缩放到统一尺寸,如果坐标是绝对值,缩放后就全错了。
所以转换脚本要做的核心就三件事:读 XML 解析边界框坐标、除以图片宽高得到 0-1 之间的浮点数、按图片名.txt写入到 labels 目录。这里最容易翻车的点有两个,一个是 XML 的坐标可能是 0 或负数,直接用会导致 loss 变成 NaN;另一个是类别 ID 必须从 0 开始连续编号,如果数据集里有 20 类但你写了 ID 25,训练时根本不会报错,只是这个类永远学不出来。
2.2 VOC 转 YOLO 的 Python 脚本及参数说明
以下是我在毕设里常用的转换脚本,兼容 CCTSDB 和 TT100K 的 XML 结构:
import os import xml.etree.ElementTree as ET from tqdm import tqdm def convert_voc_to_yolo(xml_path, img_width, img_height): """ 解析单个 VOC XML,返回 YOLO 格式的标注行列表 xml_path: XML 文件路径 img_width, img_height: 图片原始宽高,用于归一化 """ tree = ET.parse(xml_path) root = tree.getroot() # 从 XML 里读图片尺寸,有些 XML 的 size 节点可能缺失 size = root.find('size') if size is not None: img_width = int(size.find('width').text) img_height = int(size.find('height').text) yolo_lines = [] for obj in root.iter('object'): class_name = obj.find('name').text # 类别名到 ID 的映射,按你的数据集实际情况改 class_id = class_mapping.get(class_name, 0) bndbox = obj.find('bndbox') xmin = float(bndbox.find('xmin').text) ymin = float(bndbox.find('ymin').text) xmax = float(bndbox.find('xmax').text) ymax = float(bndbox.find('ymax').text) # 坐标合法性检查,保护性修正 xmin = max(0, xmin) ymin = max(0, ymin) xmax = min(img_width, xmax) ymax = min(img_height, ymax) # 计算 YOLO 格式的归一化中心点和宽高 box_width = xmax - xmin box_height = ymax - ymin if box_width <= 0 or box_height <= 0: print(f'跳过非法框: {xml_path}, {class_name}, {xmin}, {ymin}, {xmax}, {ymax}') continue x_center = (xmin + xmax) / 2 / img_width y_center = (ymin + ymax) / 2 / img_height norm_width = box_width / img_width norm_height = box_height / img_height line = f'{class_id} {x_center:.6f} {y_center:.6f} {norm_width:.6f} {norm_height:.6f}' yolo_lines.append(line) return yolo_lines # 类别映射表,CCTSDB 是 3 类,TT100K 是 45 类 class_mapping = { 'warning': 0, # 警告标志 'prohibitory': 1, # 禁令标志 'mandatory': 2, # 指示标志 } # 批量转换主流程 def convert_dataset(xml_dir, img_dir, output_label_dir): os.makedirs(output_label_dir, exist_ok=True) xml_files = [f for f in os.listdir(xml_dir) if f.endswith('.xml')] for xml_file in tqdm(xml_files, desc='转换中'): xml_path = os.path.join(xml_dir, xml_file) # 读取同名的图片获取实际宽高,比依赖 XML 里的 size 更可靠 img_name = xml_file.replace('.xml', '.jpg') img_path = os.path.join(img_dir, img_name) from PIL import Image with Image.open(img_path) as img: img_width, img_height = img.size yolo_lines = convert_voc_to_yolo(xml_path, img_width, img_height) # 输出 TXT 文件名必须和图片文件名一致,YOLO 官方是按前缀匹配的 label_path = os.path.join(output_label_dir, xml_file.replace('.xml', '.txt')) with open(label_path, 'w') as f: f.write('\n'.join(yolo_lines)) if __name__ == '__main__': convert_dataset( xml_dir='data/annotations', img_dir='data/images', output_label_dir='data/labels' )这段脚本的思路是:先用 PIL 打开同名图片拿真实宽高,而不是完全信 XML 里的 size 节点,因为有些数据集的 XML 是从旧工具导出的,size 和图片实际尺寸对不上。然后逐行解析<bndbox>节点,计算中心点坐标和宽高后除以图片尺寸。最后有个保护性修正:把 xmin、ymin 钳到 0,把 xmax、ymax 钳到图片边界,防止标注越界。
参数说明里最需要注意的是class_mapping这份字典,你换了数据集就必须改。比如 TT100K 的类别名是i4、i5这种编号,不写映射的话脚本会默认塞到 ID 0,训练出来的模型等于只有一个类。另外输出的 TXT 文件名要和图片名完全一致,YOLO 的训练逻辑是按前缀去找 labels 目录里同名文件,对不上就静默跳过。
2.3 数据集划分和目录结构:train/val 分开不是走形式
数据划分这事看起来简单,但很多课程设计的翻车点就在这。YOLOv8 默认按train和val两个目录读数据,如果你把所有图片都塞进 train,val 目录为空,训练时 loss 曲线会很奇怪——训练正常下降,但验证指标全是 0。
我的划分策略是:先把所有图片按 8:2 分成 train 和 val,再复制对应的 txt 标注文件。注意不要用随机抽样后直接移动文件,因为标注文件的名字和图片是一一对应的,移动时前缀对不上就全废了。用以下方式做最简单:
import os import random import shutil def split_dataset(image_dir, label_dir, train_ratio=0.8, seed=42): random.seed(seed) # 固定随机种子,保证多次运行结果一致 images = [f for f in os.listdir(image_dir) if f.endswith('.jpg')] random.shuffle(images) split_idx = int(len(images) * train_ratio) train_images = images[:split_idx] val_images = images[split_idx:] for split_name, split_list in [('train', train_images), ('val', val_images)]: os.makedirs(f'dataset/{split_name}/images', exist_ok=True) os.makedirs(f'dataset/{split_name}/labels', exist_ok=True) for img_name in split_list: shutil.copy( os.path.join(image_dir, img_name), f'dataset/{split_name}/images/{img_name}' ) label_name = img_name.replace('.jpg', '.txt') label_path = os.path.join(label_dir, label_name) if os.path.exists(label_path): shutil.copy(label_path, f'dataset/{split_name}/labels/{label_name}') split_dataset('data/images', 'data/labels')seed=42这句是有讲究的。课程设计和毕设要复现实验结果,随机数固定住了,每次划分出来的训练集完全一样,和别人对比指标时不会被「划分不同」这个变量干扰。如果之后你想做 5 折交叉验证,也只需要循环改 seed 值。
3. 训练环境和参数配置:GTX 1660 Ti 能跑,但别照搬默认 batch size
训练环境是很多人的第一道坎。YOLOv8 官方要求 Python 3.8+、PyTorch 1.8+,但实际跑下来版本匹配的坑比想象的深。我自己在 1660 Ti 上训练的经验是:CUDA 11.8 + PyTorch 2.0.1 + Ultralytics 8.0.x 这套组合最稳,换到 PyTorch 2.1 之后偶尔会出现莫名其妙的显存泄漏。
3.1 环境配置:conda 创建虚拟环境是保命操作
不要直接用系统 Python 装 Ultralytics,不然你迟早会被包冲突折磨到重装系统。以下是我每次新机器上都会走的流程:
# 创建 Python 3.10 的独立环境 conda create -n yolov8 python=3.10 -y conda activate yolov8 # 安装 PyTorch,一定要用 CUDA 版本 pip install torch==2.0.1 torchvision==0.15.2 --index-url https://download.pytorch.org/whl/cu118 # 安装 YOLOv8 及依赖 pip install ultralytics==8.0.150 pip install pandas matplotlib seaborn tqdm # 验证 GPU 可用性 python -c "import torch; print(torch.cuda.is_available(), torch.cuda.get_device_name(0))"最后那句验证命令值得单独说。如果输出是True NVIDIA GeForce GTX 1660 Ti,说明环境通了;如果输出是False,那问题几乎都出在 torch 版本和 CUDA 驱动不匹配上。常见情况是驱动版本太老,支撑不了 cu118,降到 cu113 的 torch 版本就能解决。
3.2 训练参数怎么设:6GB 显存的边界
GTX 1660 Ti 是 6GB 显存,YOLOv8 默认的 batch size 是 16,直接跑会被 OOM 杀死。我的经验是把 batch 压到 8,图像尺寸设 640,再用 AMP 混合精度,稳定不炸。
from ultralytics import YOLO # 加载预训练权重,用 COCO 预训练做迁移学习 model = YOLO('yolov8n.pt') # n 是最小的模型,显存不够就坚决别碰 s/m/l # 训练配置 # 关键参数:imgsz=640 是速度和精度的平衡点,搞到 1280 就是 4 倍计算量 results = model.train( data='dataset/data.yaml', # 数据集配置文件 epochs=100, # 训练轮数,毕设建议 100 起步 batch=8, # 6GB 显存的安全上限 imgsz=640, # 输入图像尺寸 device=0, # 指定 GPU,0 表示第一块卡 workers=4, # 数据加载线程数,Windows 上别设太高 amp=True, # 混合精度训练,1610 显存全靠它省 project='runs/traffic_sign', # 输出目录 name='exp1', # 实验名,方便区分多轮训练 patience=20, # 早停轮数,验证指标不升就停 )这里的参数选择是基于 1660 Ti 的显存硬约束。batch=8再配上amp=True,训练时显存占用大概在 4.5GB 左右,还有余量给验证环节。patience=20是早停机制,如果连续 20 轮验证集 mAP 没提升就自动停止,省时间。但你如果做毕设,我建议取消早停,让 100 轮跑满,因为毕设需要完整的训练曲线图表,早停会把曲线截断。
3.3 data.yaml 结构:classes 数量写错会让一切白搭
训练前要准备一个 YAML 文件,YOLOv8 靠它知道数据在哪、分类是什么。最容易错的地方是nc写错数量,比如 3 类你写成 5,训练时每个 batch 的 loss 都会乱掉,而且不报错。
# dataset/data.yaml path: dataset # 数据集根目录 train: train/images # 训练图片相对路径 val: val/images # 验证图片相对路径 nc: 3 # 类别数量,和 class_mapping 里的键值对数量一致 names: 0: warning # 警告标志 1: prohibitory # 禁令标志 2: mandatory # 指示标志路径这块有个很隐蔽的坑:path是相对当前工作目录的。如果 train.py 不在 dataset 的上级目录,路径就找不到了。我一般会在训练脚本开头加os.chdir()把当前目录切到固定位置,彻底绕开相对路径的玄学问题。
3.4 损失函数曲线和网络结构:怎么判断训练真的在收敛
很多人训练结束只看一个 mAP 就交差了,这是不对的。毕设答辩时老师最常问的就是「你怎么证明模型收敛了」。YOLOv8 训练完会自动生成 results.png,里面包含了train/box_loss、train/cls_loss、train/dfl_loss三条训练损失曲线和对应的验证损失曲线。
判断收敛的标准很简单:训练损失从高处下降,然后趋于平缓不再大幅波动,验证损失和训练损失的差距不要越拉越大。如果验证损失下降到某个点后开始回升,说明过拟合了,这时候需要加数据增强或者早停。
想自己画更精细的损失曲线,可以从训练日志里提取数据:
import pandas as pd import matplotlib.pyplot as plt # YOLOv8 训练日志在 project/name 目录下的 results.csv 里 df = pd.read_csv('runs/traffic_sign/exp1/results.csv') plt.figure(figsize=(12, 4)) plt.subplot(1, 2, 1) plt.plot(df['epoch'], df['train/box_loss'], label='train_box_loss') plt.plot(df['epoch'], df['val/box_loss'], label='val_box_loss') plt.xlabel('epoch') plt.ylabel('box_loss') plt.legend() plt.title('Box Loss 收敛曲线') plt.subplot(1, 2, 2) plt.plot(df['epoch'], df['metrics/mAP50(B)'], label='mAP50') plt.plot(df['epoch'], df['metrics/mAP50-95(B)'], label='mAP50-95') plt.xlabel('epoch') plt.ylabel('mAP') plt.legend() plt.title('mAP 提升曲线') plt.tight_layout() plt.savefig('loss_curves.png', dpi=150)results.csv里每一列对应一个指标,epoch是训练轮数。如果损失曲线像心跳一样剧烈震荡而且没有下降趋势,优先怀疑学习率太高或者类别映射混乱导致了梯度冲突。
4. 交通标志检测的避坑手册:五条血泪经验
训练和部署过程中踩过的坑写出来,每条都是花了时间换来的教训。
4.1 类别 ID 从 0 开始,不是从 1
现象:训练正常跑完,但预测时某些类别永远检测不出来,而且 mAP 看起来还行。 原因:数据集标注文件里的类别号从 1 开始写的,YOLOv8 的类别索引是 0-based,第 0 类对应第一个名字。后面所有类的预测结果整体错位,比如真正的「warning」被当成「prohibitory」训练。 解决:转换脚本里把类别号减 1,或者重新看一遍所有 TXT 标注文件的第一列数字,确保最大值等于nc - 1。检查命令用awk '{print $1}' labels/*.txt | sort -n │ uniq就能一次看清。
4.2 小目标漏检:交通标志的尺寸比你想的小得多
现象:训练完测试视频,远处的指示牌完全不检测,近处的正常。 原因:交通标志在自然场景里占比很小。TT100K 数据集中大量目标的边框宽度不到 32 像素,而 YOLOv8 默认下采样 32 倍,小目标在特征图里只剩一个像素点,根本没法分类。 解决:最直接的办法是调高输入分辨率到 1280,代价是推理速度减半;另一个思路是把数据集里小于 16x16 像素的标注过滤掉,让模型聚焦在可学习的样本上。我做毕设时直接用了imgsz=960作为折中,mAP50 比 640 时涨了 7 个点。
4.3 验证集损失上升但 mAP 也在涨,不是模型坏了
现象:画损失曲线时发现 val/cls_loss 在第 60 轮开始反弹,以为过拟合了,但 mAP 继续涨。 原因:验证损失和 mAP 不完全单调对应。YOLOv8 的 cls_loss 是 BCEWithLogitsLoss,改动置信度阈值时 loss 的绝对值会变,而 mAP 是排序指标。损失微升但排名不变,mAP 就稳。 解决:看模型好坏以 mAP 为准,损失曲线只用来观察是否发散,不是看到反弹就停训练。真正要警惕的是损失急剧上窜到几十,那才是梯度爆炸。
4.4 前背景类别不平衡导致精度虚高
现象:validation 的 mAP50 高达 0.9,但实际跑视频时大量误检。 原因:CCTSDB 这类数据集的图片里框内目标占比大,背景区域简单(天空、路面),模型学成了「看到简单背景就输出 low confidence 的框」也能刷高 mAP。 解决:数据增强里加大hsv_h、hsv_s的随机值,让模型不能靠色彩猜答案;推理时把置信度阈值调到 0.35 以上,过滤低质量预测框。
4.5 CPU 推理慢到怀疑人生,代码里一句话解决
现象:训练完用 CPU 跑单张图片推理要 800 毫秒以上,以为模型太大了。 原因:没把模型切到半精度或没关掉梯度计算,推理时还在做反向传播的上下文管理。 解决:推理代码加model = YOLO('best.pt')之后直接调model.predict(source=..., half=True),Ultralytics 内部会优化计算图。如果还慢,就把输入尺寸降到 416,精度损失不大但速度翻倍。
5. 部署到 RK3588:ONNX 导出和 RKNN 转换的完整路径
训练完模型只是开始,课程设计和毕设如果能把部署链路走通,答辩时是完全不同的含金量。RK3588 是瑞芯微的 8nm 芯片,NPU 算力 6 TOPS,能跑轻量级 YOLO 模型,最近在边缘计算项目里很火。从 PyTorch 到 RK3588 要过两道转换关卡。
5.1 导出 ONNX:固定 batch 和尺寸是省事的关键
RKNN-Toolkit2 对动态 shape 支持有限,导出 ONNX 时直接把 batch 和尺寸写死,后续转换省掉一堆报错。这一步在设备上做还是 PC 上做都行,没有 GPU 参与的模型可直接运行。
yolo export model=runs/traffic_sign/exp1/weights/best.pt format=onnx imgsz=640 batch=1导出时batch=1是给 RKNN 转换准备的。如果batch不写,默认是 1,但官方工具链有时会解析出动态维度,导致 RKNN 端推理报错「shape mismatch」。导出完成后用onnxsim做一次图优化:
# 安装 onnxsim 依赖 pip install onnxsim onnxruntime # 简化计算图,去掉无意义的 trans 节点 onnxsim runs/traffic_sign/exp1/weights/best.onnx runs/traffic_sign/exp1/weights/best_sim.onnxONNX 简化后计算图规模通常能减小 15%-25%,在 RKNN 转换时成功率和推理速度都有改善。
5.2 RKNN-Toolkit2 量化转换:权重校准决定部署精度
RK3588 的 NPU 默认跑 INT8 量化模型,浮点转整型的精度损失全靠校准集来补。校准集一般取 100-200 张有代表性的训练图片,覆盖各类标志、不同光照环境。
from rknn.api import RKNN rknn = RKNN() # 配置目标平台为 RK3588,打开量化 rknn.config( target_platform='rk3588', mean_values=[[0, 0, 0]], # 输入归一化均值,要和训练时的设置一致 std_values=[[255, 255, 255]], # 输入归一化标准差 quantized_algorithm='normal', quantized_method='channel', # 按通道量化,精度通常比不对称量化稳 ) # 加载 ONNX 模型 ret = rknn.load_onnx(model='runs/traffic_sign/exp1/weights/best_sim.onnx') # 构建 RKNN 模型 ret = rknn.build(do_quantization=True, dataset='calib.txt') # calib.txt 的每一行是校准图片的绝对路径, 推荐 200 张 # 导出 RKNN 文件 rknn.export_rknn('traffic_sign.rknn') # 顺便跑一次推理验证, 确认输出正常 ret = rknn.init_runtime(target='rk3588') outputs = rknn.inference(inputs=['test.jpg']) print(outputs)这段脚本里有三个参数值得关注。mean_values和std_values必须和 YOLOv8 预处理一致——YOLOv8 的预处理是 BGR 图像除以 255,所以均值是 0、标准差是 255,如果你在这里配了 ImageNet 的均值和标准差,部署出来边界框坐标会偏到画面边缘。quantized_method='channel'是按每个通道独立算缩放系数,比逐层量化保留更多细节,对交通标志这种颜色特征明显的场景很重要。校准图数量宁可多不要少,我用 100 张时 mAP50 下降 2 个百分点,换到 250 张后基本追平浮点精度。
5.3 部署后的后处理和性能验证
RK3588 上跑 NPU 推理拿到的输出和 PyTorch 里的是一回事,需要自己做 NMS。常见的做法是在 RKNN 输出后接一段 Python NMS,或直接在 C 代码里用自带的 NMS 函数。我在 RK3588 上实测的结论是:
输入 640x640、batch 1 的情况下,INT8 量化后的 YOLOv8n 推理单帧耗时在 18-25 毫秒之间,稳定跑到 40 FPS。相比在 CPU 上跑浮点模型已经提升了近 30 倍。
如果发现部署后检测效果差了,优先检查预处理是否一致,其次检查校准集是否覆盖了实际使用场景的光照环境。交通标志检测在白天和夜间差异很大,校准集里一定要混入夜间图片,不然夜间部署时连带出一堆误检。
5.4 最后的建议:完整实验记录是毕业设计的隐形加分项
训练时的超参数、数据集版本、标注转换脚本、部署配置这些最好全部放到项目文件里,我的习惯是每跑一轮实验就在项目里新建一个experiment_log.md,记下当次的batch、imgsz、lr、数据集划分情况和最终 mAP。答辩时评委问「你怎么调参的」,直接翻开日志讲,比现场翻代码找历史记录强得多。
从那以后我每次做 YOLO 项目都会强制走一遍「标注检查 → 固定随机种子 → 先跑 10 轮验证配置 → 全量训练 → ONNX 导出 → 量化验证」的完整流程,不在任何一步偷懒,希望帮到你。
本文还有配套的精品资源,点击获取