news 2026/10/1 13:58:23

基于YOLOv5与TT100K的交通标志识别实战:从数据转换到部署

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于YOLOv5与TT100K的交通标志识别实战:从数据转换到部署

简介:这是一套基于YOLOv5与TT100K数据集的交通标志牌识别完整项目,面向人工智能、计算机视觉方向学生及开发者,可直接用于毕业设计、课程设计或目标检测入门进阶。项目源码为高分开源成果,代码经过运行验证,并配有详细文档和训练配置,覆盖从数据准备、模型训练到检测推理的主要环节。资源包共149个文件,压缩后约1.12MB,其中包含56个Python脚本(训练与检测逻辑)、49个YAML和12个YML配置文件、6个Shell脚本、6个Markdown说明文档、3个Jupyter Notebook演示,以及Dockerfile等容器化部署文件,目录结构清晰,便于按需查阅。目前已有81人浏览学习。使用者可基于该代码快速复现交通标志牌检测效果,也可修改类别与数据以适配其他目标检测任务;对于正在准备毕设或课设的同学,这套源码、文档、配置文件一体化的资源,既能支撑项目答辩,也能作为学习YOLOv5实战的起步材料。

1. 交通标志牌识别为什么要选 YOLOv5 + TT100K:先说结论再做项目

很多人搜“基于yolov5的交通标志牌识别项目”时,心态很明确:要么是毕业设计需要一个能跑通的目标检测系统,要么是想快速上手真实数据集。标题里的组合很经典:yolov5 负责检测,tt100k 提供中国真实街景交通标志图,加上源码和文档,覆盖了数据清洗、训练、推理、部署的完整闭环。它能解决的实际问题很具体:输入一张街景图或一段视频,输出交通标志的位置框和类别。但它绝不是开箱即用:TT100K 标注是 JSON,YOLOv5 要 txt;小目标多、类别长尾,环境配置和标签转换才是真门槛。我会按实操顺序展开,适合有 Python 基础、想亲手复现的人。

2. 从环境到数据:TT100K 标注转 YOLO 格式是第一个深坑

2.1 yolov5 环境配置:用 conda 锁住 Python 和依赖版本

我见过太多人拿到项目包第一件事就是双击 train.py,结果报错一屏接一屏。yolov5 环境配置本身不难,难在 PyTorch 和 CUDA 版本互相打架。我的习惯是新建独立 conda 环境,把 Python、PyTorch、CUDA 工具链一次锁死,避免把系统 Python 弄得一团糟。

conda create -n yolo_tt100k python=3.9 -y conda activate yolo_tt100k conda install pytorch=1.13.1 torchvision=0.14.1 cudatoolkit=11.7 -c pytorch -y git clone https://github.com/ultralytics/yolov5.git cd yolov5 pip install -r requirements.txt

逻辑说明:Python 3.9 对 yolov5 各版本兼容性都比较好,不建议追新。PyTorch 版本要和你显卡驱动匹配,conda 安装的 cudatoolkit 不需要你手动装驱动。克隆官方仓库后,pip 会把 numpy、opencv、matplotlib 等依赖一起装上。

参数说明:pytorch=1.13.1是常见稳定组合之一,你可以在 PyTorch 官网找到对应 CUDA 11.7 的安装命令;如果装的是 CPU 版,后面训练会慢到你怀疑人生。装完先做一次环境检查:

python -c "import torch; print(torch.__version__, torch.cuda.is_available())"

输出True说明 GPU 可用。如果输出False,说明 torch 不是 CUDA 版,需要卸载重装。还有一个高频坑:pip install -r requirements.txt时 opencv 依赖 libGL,Ubuntu 服务器上常常报ImportError: libGL.so.1: cannot open shared object file,执行sudo apt install libgl1 libglib2.0-0再装就能解决。这些环境问题一次梳理清楚,后边能省出大量时间。

2.2 TT100K 数据集的目录结构和 json 标注:先看再动手

TT100K 下载解压后,通常有 train 和 test 两个图片目录,外加一个 annotations.json。如果你不知道里面长什么样就急着转换,后面大概率要返工。我的做法是写个几行脚本,先看前两条标注结构:

import json with open("annotations.json", "r", encoding="utf-8") as f: ann = json.load(f) for img_name, info in list(ann.items())[:2]: objs = info.get("objects", []) print(img_name, len(objs)) if objs: print(objs[0]["category"], objs[0]["bbox"])

逻辑说明:tt100k 的标注是按图片名索引的,每张图名下挂着 objects 列表。每个 object 包含 category、bbox 等字段,bbox 是xmin, ymin, xmax, ymax的字典格式,用的是原图像素坐标。

特别提醒:这个格式和 COCO 的 bbox 不一样。COCO 给的是x, y, width, height,TT100K 给的是左上角和右下角两个点。很多人转换时直接拿 xmax-xmin 当 x 坐标用,结果训练出来的框全偏了,这是最容易踩的标签坑。类别字段是p100、i5、w1这类字符串,分别对应禁令、指示、警告等不同组别的标志。看起来统一,实际含义要靠官方表格去查,不建议自己猜。

还有一点值得注意:TT100K 里大量图片中的交通标志非常小,有的只有十几像素;类别分布也很不均匀,一小部分类别占了绝大多数样本,长尾效应明显。如果直接把全类别丢给 YOLOv5 训练,很多罕见类根本学不到。常见的做法是先统计类别频率,再决定保留哪些类别或者做类别合并,这一步会直接影响 mAP。

2.3 把 JSON 转成 YOLO txt 标注:完整的转换脚本

yolov5 训练自己的数据集时,标签目录默认叫 labels,与图片目录对应,每个图片一个 txt,每行格式是class_id x_center y_center width height,所有值都归一化到 0-1。我一般写一个独立转换脚本,顺便做类别过滤和坐标保护:

import json import os import cv2 from collections import Counter IMG_DIR = "data/tt100k/train" JSON_FILE = "data/tt100k/annotations.json" LABEL_DIR = "data/tt100k/labels/train" CLASS_FILE = "data/tt100k/classes.txt" with open(JSON_FILE, "r", encoding="utf-8") as f: ann = json.load(f) counter = Counter() for img_name, info in ann.items(): for obj in info.get("objects", []): counter[obj["category"]] += 1 common = [c for c, _ in counter.most_common(45)] with open(CLASS_FILE, "w", encoding="utf-8") as f: f.write("\n".join(common) + "\n") cls2idx = {c: i for i, c in enumerate(common)} os.makedirs(LABEL_DIR, exist_ok=True) for img_name, info in ann.items(): img_path = os.path.join(IMG_DIR, os.path.basename(img_name)) if not os.path.exists(img_path): continue img = cv2.imread(img_path) if img is None: print("skip broken image:", img_path) continue h, w = img.shape[:2] lines = [] for obj in info.get("objects", []): cat = obj["category"] if cat not in cls2idx: continue b = obj["bbox"] x1 = max(0, min(int(b["xmin"]), w - 1)) y1 = max(0, min(int(b["ymin"]), h - 1)) x2 = max(0, min(int(b["xmax"]), w - 1)) y2 = max(0, min(int(b["ymax"]), h - 1)) if x2 - x1 < 1 or y2 - y1 < 1: continue xc = (x1 + x2) / 2 / w yc = (y1 + y2) / 2 / h bw = (x2 - x1) / w bh = (y2 - y1) / h lines.append(f"{cls2idx[cat]} {xc:.6f} {yc:.6f} {bw:.6f} {bh:.6f}") if lines: txt_path = os.path.join(LABEL_DIR, os.path.basename(img_name).replace(".jpg", ".txt")) with open(txt_path, "w", encoding="utf-8") as f: f.write("\n".join(lines))

逻辑说明:common取出现频率最高的 45 个类别,是为了避开长尾噪声。如果你想要更细的类别或减少类别数,可以改成自己维护一个 category 白名单,然后按白名单映射。cls2idx 的字典顺序会写进 classes.txt,后续 data.yaml 里的 names 顺序必须和它完全一致,否则预测出来的标签全是错位。

参数说明:bbox 做了越界钳制,避免标注边界超出图像宽高导致归一化数值小于 0 或大于 1;空标注图片不生成 txt,YOLOv5 训练时会自动跳过这些图片。如果某些图片本身损坏,cv2.imread 会返回 None,脚本会跳过并打印提示,不会中断整个转换。

转换完不能直接开训,我习惯随机抽出几张图,把 txt 里的坐标画回去验证。这一步能发现绝大多数低级错误:

import cv2 import os import random LABEL_DIR = "data/tt100k/labels/train" IMG_DIR = "data/tt100k/train" with open("data/tt100k/classes.txt", "r") as f: names = [x.strip() for x in f] txt_list = [x for x in os.listdir(LABEL_DIR) if x.endswith(".txt")] txt_path = os.path.join(LABEL_DIR, random.choice(txt_list)) img = cv2.imread(os.path.join(IMG_DIR, txt_path.replace(".txt", ".jpg"))) for line in open(txt_path): cid, xc, yc, bw, bh = map(float, line.split()) h, w = img.shape[:2] x1 = int((xc - bw / 2) * w) y1 = int((yc - bh / 2) * h) x2 = int((xc + bw / 2) * w) y2 = int((yc + bh / 2) * h) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(img, names[int(cid)], (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1) cv2.imwrite("check.jpg", img)

看到框和标志贴合,说明转换脚本正确。如果框偏移、大小不对,问题基本都出在坐标归一化计算上,优先检查宽度高度有没有除以原图尺寸。

3. 模型训练:yolov5 超参数和 TT100K 类别不平衡问题

3.1 数据配置文件和类别名:data.yaml 是训练的入口

数据转换完成后,下一步是写 data.yaml。它是训练脚本读取数据集的唯一入口,路径、类别数量、类别名称全在这里。很多从 yolov5 源码包白嫖来的项目跑不起来,一半是因为 yaml 里 paths 写的是别人电脑的绝对路径。

# data/traffic.yaml train: data/tt100k/train.txt val: data/tt100k/val.txt nc: 45 names: 0: p100 1: i5 2: p11 3: p26 4: p27 # 后续类别必须与 classes.txt 顺序一致

逻辑说明:train 和 val 指向的是图片路径清单文件。yolov5 会读取 train.txt 里的每一行图片路径,然后自动去同级的 labels 目录里找同名 txt。所以 train.txt 必须写成真实存在的图片路径,不能只写目录名。

参数说明:nc 是类别总数,必须和 names 的长度一致,也要和 classes.txt 里的行数一致。names 的索引从 0 开始,顺序就是转换脚本里 cls2idx 的顺序。这里错一个标点,训练出来的模型标注就全乱。

生成 train.txt 我一般用:

find $PWD/data/tt100k/train -name "*.jpg" > data/tt100k/train.txt find $PWD/data/tt100k/val -name "*.jpg" > data/tt100k/val.txt

如果你没有单独的 val 数据集,可以按 9:1 从 train 里划分,再分别生成两个 txt。注意划分之前最好先按类别分层抽样,否则某些类别只在训练集出现,验证集上 AP 恒为 0,最后 mAP 看起来很难看。

还有一个方向是类别合并。TT100K 里很多类只是限速数字不同,比如限速 30、限速 50,如果它们样本不均衡,模型很容易把样本少的那一类学丢。做实训项目时,我一般会把所有限速牌合并成一个 speed_limit 类,把警告牌合并成 warning,类别数能从几十压到十以内,mAP 会好看很多,也更贴近真实工程上的使用习惯。

3.2 训练命令与关键超参数:epochs、batch-size、imgsz、mosaic

数据配置就绪后,训练命令反而很简单。常见做法是用 yolov5s 做预训练权重,它体积小、速度快,交通标志这种类别不算复杂的任务,s 模型已经足够。

python train.py \ --data data/traffic.yaml \ --weights yolov5s.pt \ --epochs 100 \ --batch-size 16 \ --imgsz 640 \ --device 0 \ --workers 4

逻辑说明:--weights yolov5s.pt会下载官方 COCO 预训练权重,用它能显著加快收敛。--imgsz 640是训练输入尺寸,TT100K 的小目标多,我不是很建议再往下调。--device 0指定 GPU 编号;CPU 训练也能跑,但 100 epoch 可能要跑十几个小时。

参数说明:如果显存不够,先减--batch-size,不要优先减 imgsz。TT100K 的目标本身就小,输入分辨率再低,小标志几乎等于消失。--workers 4可以根据 CPU 核数适当调大,但 Windows 下 worker 过大可能报 DataLoader 相关错误,一般 2 到 4 够用。

训练中断了不用从头再来,yolov5 支持断点续训:

python train.py --resume runs/train/exp/weights/last.pt

另外,yolov5 的超参数都写在 data/hyps 下面的 yaml 文件里,比如hyp.scratch-low.yaml。里面能看到lr0、mosaic、mixup这些项。第一次跑我建议用一个默认 hyp 文件跑通,后续再按照训练曲线微调。如果 loss 一直上下震荡,把lr0从 0.01 调到 0.001 立竿见影;如果类别不平衡明显,把mixup从 0 调到 0.2 可以在训练时做样本混合,缓解少数类过拟合。不要上来就大改,先跑通再调参。

3.3 训练日志和结果文件:如何判断模型真的收敛了

yolov5 会把每次训练输出到runs/train/exp,里面最重要的是weights/best.pt和last.pt。best.pt 是验证集 mAP 最高时保存的权重,last.pt 是最后一个 epoch 的权重。推理部署一定要用 best.pt,这是很多新手的误区。

结果文件results.csv记录了每个 epoch 的 train loss、val loss、mAP50 等指标。我一般用一段小脚本直接看最后一行:

import csv with open("runs/train/exp/results.csv", "r") as f: rows = list(csv.DictReader(f)) last = rows[-1] print("epoch:", last["epoch"]) print("mAP50:", last["metrics/mAP_0.5"]) print("mAP50-95:", last["metrics/mAP_0.5:0.95"])

逻辑说明:mAP50 是 IoU 阈值 0.5 时的平均精度,mAP50-95 是多个 IoU 阈值的平均值,后者更严格。交通标志这种单目标检测任务,mAP50 更容易刷高,但真正能说明问题的是 mAP50-95。

判断模型是否收敛不能只看 train loss。如果 train loss 持续下降但 val loss 不再下降甚至回升,说明已经开始过拟合。这个时候要么减少 epoch,要么增强数据增强,要么换更轻的模型。还有一个容易被忽视的点:训练启动时 yolov5 会自动计算数据集的 anchor,日志里会打印每个尺度的 anchor 大小。如果 TT100K 的目标普遍很小,但生成的 anchor 很大,可以尝试调大--imgsz到 960。不要手动乱改 yaml 里的 anchor,先看日志里的 autoanchor 输出再做判断。

4. 推理与部署:从 detect.py 到 ONNX 导出

4.1 用 detect.py 跑图片和视频:参数与输出目录

训练完成后,最简单的验证方式是直接用 detect.py 跑测试集。它的 input 很灵活,可以是图片、文件夹、视频文件,甚至摄像头序号。

python detect.py \ --weights runs/train/exp/weights/best.pt \ --source data/tt100k/test \ --conf-thres 0.5 \ --iou-thres 0.45 \ --save-txt \ --save-conf \ --project runs/detect

逻辑说明:--source指向测试图片目录,detect.py 会扫描目录下所有图片逐个推理。--save-txt会把每个检测框保存成 YOLO 格式的 txt,--save-conf会在 txt 里额外追加一个置信度字段。--project指定输出根目录,结果默认放在runs/detect/exp下面,如果重复运行会生成 exp2、exp3。

参数说明:conf-thres是置信度阈值,默认 0.25,我习惯在测试时设 0.5,避免输出一堆低置信度误检。iou-thres是 NMS 的 IoU 阈值,0.45 是常见值;如果检测目标彼此靠得很近,可以适当调低到 0.3。

除了命令行,我偶尔也会在 Jupyter 里直接加载模型看单张图效果:

import torch model = torch.hub.load("ultralytics/yolov5", "custom", path="runs/train/exp/weights/best.pt", force_reload=True) model.conf = 0.4 model.iou = 0.45 results = model("data/tt100k/test/320.jpg") results.print() results.save("runs/detect/hub")

参数说明:force_reload=True会忽略缓存重新加载权重,换模型时一定要加。model.conf和model.iou可以直接赋值,这比每次推理传参更省事。注意 torch.hub 第一次会从 GitHub 拉取模型结构代码,网络受限时会失败,所以离线环境我更推荐直接调 detect.py。

4.2 把权重导出成 ONNX 并用 onnxruntime 推理

如果要把模型集成到 C++、Java 或者边缘设备上,第一步通常是导出 ONNX。yolov5 官方提供了 export.py。

python export.py \ --weights runs/train/exp/weights/best.pt \ --include onnx \ --imgsz 640 \ --opset 11

逻辑说明:导出成功的best.onnx文件会出现在权重同目录。--opset 11是 ONNX 算子集版本,兼容性较好;如果部署环境要求新算子集,可以改成 13 或 17。

导出后用 onnxruntime 跑推理,输入是一张标准化到 0-1 的 RGB 图,shape 是 NCHW。以下是一段最小推理代码:

import cv2 import numpy as np import onnxruntime as ort sess = ort.InferenceSession("runs/train/exp/weights/best.onnx") img = cv2.imread("test.jpg") img_resized = cv2.resize(img, (640, 640)) x = img_resized[:, :, ::-1].transpose(2, 0, 1)[None].astype(np.float32) / 255.0 out = sess.run(None, {sess.get_inputs()[0].name: x})[0] print(out.shape) # 1, 25200, 5 + nc

逻辑说明:img_resized[:, :, ::-1]是把 BGR 转成 RGB,transpose(2, 0, 1)是把 HWC 转成 CHW,/ 255.0是归一化。输出 shape 里 25200 是根据输入 640x640 和 anchor 组合算出来的候选框数量;第三维是 5 加类别数,如果 nc=45,就是 50。

参数说明:这里的输入尺寸必须和导出时的--imgsz一致,否则输出形状对不上。如果是动态 batch 导出,sess.run的输入还需要严格用 numpy 的 uint8 或 float32,不要用 list。

4.3 yolov5 后处理:解码、NMS 与置信度过滤是怎么配合的

很多人第一次拿 ONNX 输出时会懵:一堆浮点数,不知道该怎么变成检测框。YOLOv5 的输出每个候选框是[x, y, w, h, objectness, class_scores...],这里的 x、y、w、h 已经解码到输入图坐标,不再需要 anchor 解码,但还需要做三件事:置信度过滤、坐标转换、NMS。

先看置信度过滤和坐标转换:

def decode_pred(pred, num_classes=45, conf_thres=0.3): obj_conf = pred[:, 4] cls_conf = pred[:, 5:5 + num_classes].max(axis=1) scores = obj_conf * cls_conf mask = scores > conf_thres boxes = pred[mask, :4] scores = scores[mask] cls_ids = pred[mask, 5:5 + num_classes].argmax(axis=1) x, y, w, h = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3] x1, y1 = x - w / 2, y - h / 2 x2, y2 = x + w / 2, y + h / 2 boxes = np.stack([x1, y1, x2, y2], axis=1) return boxes, scores, cls_ids

逻辑说明:obj_conf表示这个框含目标的概率,cls_conf表示每个类别里最高的概率,两者相乘才是这个框最终属于某类的置信度。乘出来的分数低于conf_thres的直接丢。

过滤完还需要做 NMS,去除同一目标上的重复框。下面是一个常用的纯 NumPy NMS 函数:

def nms(boxes, scores, iou_thr=0.45): x1 = boxes[:, 0]; y1 = boxes[:, 1] x2 = boxes[:, 2]; y2 = boxes[:, 3] areas = (x2 - x1) * (y2 - y1) order = scores.argsort()[::-1] keep = [] while order.size > 0: i = order[0] keep.append(i) xx1 = np.maximum(x1[i], x1[order[1:]]) yy1 = np.maximum(y1[i], y1[order[1:]]) xx2 = np.minimum(x2[i], x2[order[1:]]) yy2 = np.minimum(y2[i], y2[order[1:]]) inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1) iou = inter / (areas[i] + areas[order[1:]] - inter + 1e-6) order = order[(iou <= iou_thr).nonzero()[0] + 1] return np.array(keep)

参数说明:这个 NMS 是通用写法,加了1e-6防止除零;iou_thr是 0.45。如果你用的是多类别检测,建议对每个类别单独做 NMS,否则两个不同类别的目标重叠较大时,低置信度那个会被误删。

最后别忘了坐标还原:如果推理前把原图 resize 到了 640x640,ONNX 输出框是基于 640 坐标系的。要显示到原图或保存 JSON 结果,必须把框坐标乘上原图宽/640和原图高/640。这一步漏掉,检测框看起来会错位。如果在树莓派 5 上部署自己训练的 yolov5 模型,可以把脚本精简到只用 ONNX Runtime 加这组后处理,去掉 torch 依赖,然后考虑是否量化到 FP16。树莓派 CPU 跑 640x640 输入到 s 模型,速度不会太快,建议用轻量的 yolov5n 或降低输出帧率,不要对实时性抱太高期待。

5. 避坑指南:TT100K 训练 YOLOv5 的 5 个翻车现场

5.1 坑一:训练开始 Loss 就变 NaN

现象:训练前几个 epoch 的 loss 直接打出 nan,之后模型输出的框全是乱飞。

原因:最常见的可能是学习率过高,尤其在使用某些预训练权重时,默认lr0=0.01遇到结构性强的小数据集容易震荡;也有可能是数据里混入了全黑图片、损坏图片,或者某个标签的坐标出现 NaN 值。

解决:先跑一遍数据检查脚本,确认每张图 cv2.imread 都能读到,确认 txt 文件里没有 nan 字符。然后把学习率降到 0.001,重新训练。如果还在 nan,检查是不是 CPU/GPU 混合精度在老旧显卡上有问题,在 train.py 里加--no-amp关闭自动混合精度,很多老卡能救回来。

5.2 坑二:Loss 正常但 mAP 接近 0,数据检查比调模型更优先

现象:训练曲线看着很顺,train loss 稳定下降,但 val mAP 一直在 0.05 左右徘徊,和没学一样。

原因:这个场景十有八九是标签映射错位。比如 classes.txt 里第 0 行是 p100,但 data.yaml 的 names 里第 0 行写成了 i5;模型学到的语义和验证标签对不上,mAP 自然崩。还有一种情况是转换脚本的 txt 文件名写错,导致 YOLOv5 读取标签时拿到的全是空文件。

解决:用前面提到的画框脚本随机画 20 张训练图片,肉眼确认标签框和类别文字是否正确。再打开 data.yaml 核对 names 顺序与 classes.txt 一致。如果还需要精确验证,可以把验证集的预测结果打印成 JSON,看 detected class 名称是不是和目标类名错位。这类问题调模型参数没用,纯数据活。

5.3 坑三:训练启动直接报错 No labels in ... cache

现象:执行 train.py 后,还没有进入训练循环就报AssertionError: train: No labels in ...或类似找不到标签的错误。

原因:yolov5 默认会从图片路径推导标签路径。如果图片放在data/tt100k/train/images,它期望标签在data/tt100k/train/labels;而我们的转换脚本把标签放在data/tt100k/labels/train,目录层级不一样,就会找不到。

解决:调整目录结构,让 images 和 labels 处于同级,且 labels 目录直接对应图片根目录。即图片是train/xxx.jpg,标签是train/xxx.txt。如果不想移动数据,也可以在 yaml 里增加path字段,把路径指到它们的共同父目录。还有一种情况是 train.txt 里写的是.png路径但标签转换时替换的是.jpg,文件后缀不匹配,也要一起检查。

5.4 坑四:远处小标志全部漏检,大标志却好好的

现象:测试集里近处的标志框得很准,远处几十像素大小的标志几乎全部漏掉,甚至很多还被低置信度过滤掉。

原因:TT100K 是从真实街景中截取的高清大图,远处交通标志在原图里只占很小一块。训练时 imgsz 如果只有 640,小标志在缩放后可能只有 10 个像素不到,卷积特征已经把它磨没了。另一个推手是 mosaic 增强,四张图拼成一张后目标平均尺寸进一步缩小。

解决:先把--imgsz提高到 960,batch-size 相应减半。如果显存不够,可以考虑裁剪图片训练,把大图切块后只保留含标志的 patch。推理阶段也可以做切图推理,把原图按 640x640 滑窗裁开分别检测,再把检测框合并回原坐标做一次全局 NMS。这个方法对小目标非常有效,代价是推理耗时成倍增长。另外不要为了速度把conf-thres设太高,小目标得分普遍偏低,设到 0.25 甚至 0.1 会更稳。

5.5 坑五:TT100K 类别不平衡,罕见类别 AP 一直为 0

现象:统计 mAP 时,样本量大的类别 AP 都有 0.7 以上,但某些低频类别的 AP 始终是 0,甚至从来没被正确预测过。

原因:数据集本身长尾严重,模型在训练时对低频类没见过足够多样的样本,决策面完全被高频类带偏。YOLOv5 默认的 BCE loss 对所有类别一视同仁,低频类在总 loss 中占比太小。

解决:最简单的做法是类别重映射,把所有低频类合并到一个“其他标志”类,或者直接筛掉,让模型专注学习样本量足够的类别。更进阶一点可以在训练时用复制粘贴增强,把低频目标从一张图复制到多张背景图上,人为增加样本数。如果一定要保留细粒度类别,可以尝试在损失函数里给低频类加权重,但改完需要好好验证,容易把高频类的 mAP 拉下来。做实训项目或毕设时,我更推荐先做类别合并,理由很简单:工程落地上“能识别出这是限速标志”比“精确到限速 30 还是 50”更刚需,而且合并之后模型稳定性好很多。

6. 进阶优化:小目标切图推理、训练数据增强与最终验收技巧

6.1 小目标切图推理:用 patch 合并提升 TT100K 召回率

如果测试集里大量小标志漏检,切图推理是我最推荐的手段。思路很简单:推理时用滑动窗口把原图切成多个 patch,每个 patch 分别送入模型,得到局部检测结果后再映射回原图坐标,最后做一次全局 NMS。

def patch_inference(model, img, patch=640, stride=320, conf=0.25): h, w = img.shape[:2] boxes = [] for y in range(0, max(1, h - patch + 1), stride): for x in range(0, max(1, w - patch + 1), stride): crop = img[y:y + patch, x:x + patch] res = model(crop, size=patch).pandas().xyxy[0] for _, r in res.iterrows(): boxes.append((x + r['xmin'], y + r['ymin'], x + r['xmax'], y + r['ymax'], r['confidence'], r['class'])) return boxes

逻辑说明:stride 通常取 patch 的一半,保证相邻 patch 有 50% 重叠,避免目标正好被切在边界上。得到所有候选框后,再调用第 4.3 节的全局 NMS,按置信度排序去重。

参数说明:patch大小可以按数据集目标尺寸调整,TT100K 一般 640 合适。conf在切图推理时可以设低一点,因为小目标在 patch 里被放大,置信度会提高很多。如果显存或内存紧张,可以牺牲一些重叠率,把 stride 调到 patch 的 0.75,速度会更快但漏检率略增。

6.2 训练增强策略:mosaic 与复制粘贴的取舍

yolov5 的 mosaic 增强默认开启,它把四张图拼成一张再训练,对小目标来说是把双刃剑:一方面增加了目标数量和多样性,另一方面目标被缩小。如果你发现训练集的小目标经过 mosaic 后几乎不可见,可以把 hyp 文件里的mosaic从 1.0 降到 0.5,或者只在训练前半段开启。

复制粘贴增强在 TT100K 这种长尾数据集上更有针对性。思路是统计类别频率,找到低频类目标,把它们从原图抠出来,随机粘贴到其他图片的合理位置,再同时生成标签。这个增强方式比单纯翻转、缩放更直接地解决样本不足问题。需要注意的是,粘贴位置不能随意放在天空或者完全无关的区域,否则模型学到的是“低频目标都长在奇怪位置”,泛化会变差。

6.3 最终验收:别只盯着 mAP,画混淆矩阵和典型错误

训练跑完,很多人看一眼总体 mAP 就开始写报告。实际上我更建议先做一轮标准评估,用 val.py 生成细化指标:

python val.py \ --data data/traffic.yaml \ --weights runs/train/exp/weights/best.pt \ --task val \ --conf-thres 0.001 \ --save-json

参数说明:--conf-thres 0.001不是为了让输出好看,而是为了画 PR 曲线时覆盖完整的置信度区间。val.py 会输出每类 AP、混淆矩阵图片和 PR 曲线。

看混淆矩阵时,重点看两个地方:一是对角线上的数值是否足够高,二是哪些类别频繁被预测成背景。TT100K 里的“其他标志”和某些禁令标志长得像,误检会在混淆矩阵里非常直观。看到问题后再回到测试集里挑出几十张典型错误图片,把预测框和真实框一起画出来。很多高分项目的 mAP 是用低 IoU 阈值刷出来的,框实际上偏大偏小都算对,这在答辩时经不起追问。我习惯每次跑完都保存一批对比图,既能验证模型,也能在写文档时直接当素材。这套流程走完,你手里才真正有一套可以复现、有依据、敢拿出手的结果。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 13:58:13

马德拉岛旅行攻略:火山岛徒步、自驾路线与美食全指南

出发之前&#xff0c;我对马德拉&#xff08;Madeira&#xff09;的了解其实很浅&#xff1a;只知道它是葡萄牙的群岛&#xff0c;以马德拉酒出名&#xff0c;外加一条"克里斯蒂亚诺罗纳尔多故乡"的标签。真正落地那一刻&#xff0c;才发现之前所有的想象都太单薄了—…

作者头像 李华
网站建设 2026/10/1 13:58:13

马德拉岛旅行全攻略:从丰沙尔到levada徒步,玩转大西洋花园

我第一次踏足马德拉&#xff08;Madeira&#xff09;是在一个阴晴不定的深秋&#xff0c;飞机降落前横着飞过那座伸向大西洋的海上跑道&#xff0c;机身被海风晃得像晃动的果冻。落地后我自己都惊讶&#xff0c;原来欧洲人嘴里念叨的“大西洋花园”长这样&#xff1a;满山斜坡上…

作者头像 李华
网站建设 2026/10/1 13:58:12

解决invalid target release: 11:JDK版本对齐与Maven编译配置实战

1. 这个报错的真面目&#xff1a;它到底在哪个环节炸的1.1 报错长什么样&#xff08;命令行 IDE两种形态&#xff09;先对号入座&#xff0c;看你是属于下面哪一种情况。第一种&#xff0c;命令行里执行mvn clean package或mvn compile&#xff0c;然后报一段带有invalid targ…

作者头像 李华
网站建设 2026/10/1 13:56:59

大模型API接入实战:从调通到稳用的全链路工程方法论

1. 这不是“调个API”那么简单&#xff1a;为什么90%的AI大模型接入项目卡在上线前夜 你手头刚拿到一个需求&#xff1a;“用大模型生成科研论文摘要”。老板说“快点上&#xff0c;下周要演示”。你打开OpenAI文档&#xff0c;复制curl命令&#xff0c;填上自己的API Key&…

作者头像 李华
网站建设 2026/10/1 13:55:58

AI助教实战:一文讲透教师备课、命题与家校沟通的高效工作流

作为系列的第3篇&#xff0c;我不想再给你讲什么是大模型、怎么注册账号、提示词写三要素这类基础内容了。这篇直接上硬货&#xff1a;把AI嵌进教师每周都要重复的流程里——备课、作业、命题、家长沟通、班级事务&#xff0c;用一条完整的工作流把AI真正变成“第二助教”。前两…

作者头像 李华
网站建设 2026/10/1 13:55:55

马德拉岛深度旅行攻略:徒步路线、自驾环岛与避坑指南

第一次被“Madeira”这个词击中&#xff0c;是在刷到一张悬崖高空缆车和月桂树林同框的照片时。第一反应是“这地方美得不真实”&#xff0c;查了资料才发现&#xff0c;它是离葡萄牙本土约1000公里的一座火山岛&#xff0c;孤悬在大西洋中间&#xff0c;常被人叫“大西洋明珠”…

作者头像 李华