做两轮车头盔检测这类项目,最容易被低估的不是模型,而是数据。标题里那句“基于YOLOv8的两轮车头盔检测算法 附训练数据集-源码”,把三件事捆在了一起:YOLOv8 训练流程、头盔检测这个具体任务、以及一套能直接跑起来的数据集和代码。我在园区出入口和配送站点做过几轮类似的项目,最深的一条经验是:模型换一版收益有限,标注规范改一版收益立竿见影。这篇就按我自己的落地路径拆开讲,从需求定义、数据集体检、标注规范、训练配置、精度调优,一直讲到导出权重和在边缘盒子上跑起来。适合刚接触目标检测、想拿一个完整小项目练手的同学,也适合已经跑通 demo、但上线后疯狂误报、想找问题根源的同行。代码和参数我都会给到位,你可以直接照着抄,但顺序别跳,尤其是数据那一段。
1. 任务定义与方案选型:先把问题问对
很多人拿到标题就直奔yolo detect train,跑完发现 mAP 挺高,现场却一团糟。原因多半不在模型,在于一开始把“检测什么”这件事定义错了。
1.1 场景里真正要判定的不是“有没有头盔”
新手最容易犯的错是:把“头盔”当成一个检测类别,图上框出一个头盔就算戴了。摄像头架在出入口,画面里全是骑行的人,头盔可能挂在车把上、放在车筐里、拎在手上、挂在后视镜上,甚至搭在坐垫上。如果模型只学“头盔长什么样”,这些东西一样会被框出来,后处理一叠加,系统就认为这个人戴好了。我见过一个 Demo,误报率高得离谱,排查到最后一帧一帧看,十次里有六七次是把挂在车头的头盔当成了佩戴状态。
正确的建模方式是把判定对象锚定在人的头部,而不是头盔这个物体。我最终采用的两类设计是:
head_helmet:头部被头盔覆盖head_bare:头部裸露,未佩戴
另外再补一个辅助类rider(骑车人)或者two_wheeler(两轮车),用于圈定判定区域、做人数统计和后续的关联。
注意:类别命名尽量带上语义前缀(
head_),后面加类别、换模型、做后处理时不容易搞混。命名混乱是多人协作里最常见的返工来源。
为什么这个改动这么关键?因为“头部”是一个几何上稳定的目标,位置永远在人身上半部分,且尺寸相对固定;而“头盔”可以出现在画面任何位置。检测头部的状态,等于把“物体检测”变成了“属性判定”,业务语义和数据分布都干净得多。
1.2 为什么不拆成“车辆检测 + 二级分类”
另一个常被讨论的方案是两阶段:YOLOv8 先检测两轮车,裁出车辆区域,再送一个分类网络判断骑手有没有戴头盔。这个方案在学术界很常见,工程上我一般不用,理由有三条:
第一是延迟:两阶段意味着两次前向,单阶段端到端在 1080p、15 路视频的盒子上已经很吃力,两阶段直接毙掉。第二是裁剪的鲁棒性:车辆检测框随着角度、遮挡、远近变化很大,裁出来的区域经常把骑手头部切掉一半,或者框进旁边的人。第三是多人骑行:一辆电动车上带两个甚至三个人,车辆级分类根本无法表达。
只有在一种情况下我会用两阶段:远景、超大分辨率、单车单人、且允许离线跑批。比如事后稽核视频,不上实时,那切图+分类的召回确实更稳。
1.3 一套能落地的方案由哪几块组成
我习惯在动手前先把整条链路画清楚,避免训完模型才发现没法部署:
| 环节 | 关键产出 | 容易踩的坑 |
|---|---|---|
| 数据采集 | 原始视频/图片 | 场景单一、光照单一 |
| 标注 | YOLO 格式 txt | 类别定义反复改,返工 |
| 训练 | best.pt | 验证集与训练集同源,指标虚高 |
| 评估 | 混淆矩阵、PR 曲线 | 只看 mAP,不看漏检分布 |
| 导出 | ONNX / TensorRT engine | 动态尺寸导致速度暴跌 |
| 部署 | 推理服务 + 业务后处理 | 未做帧级去重,同一个人的告警刷屏 |
| 运维 | 误报样本回流 | 没有闭环,模型半年就废了 |
表格里最右列那几项,才是决定项目成败的部分。模型本身反而最好解决,YOLOv8 的默认配置在大部分场景已经够用。
2. 数据集:天花板在这里,不在模型里
标题里写了“附训练数据集”,这是好事,省掉大量采集成本。但现成数据集有它自己的问题,直接拿来训,往往在你自己场地上掉 20 个点以上。
2.1 拿到现成数据集先做四项体检
我的习惯是拿到数据先不训,花半天做体检,比盲训三天划算:
第一项,类别定义对不对。打开标注文件看类别索引,如果只有helmet一个类,那这套数据基本只能做“头盔物体检测”,跟你想要的“佩戴状态判定”不是一回事。这类数据不是不能用,但要重新标注或者用规则转换,工作量不小。
第二项,看图像分辨率和目标尺寸分布。写个小脚本统计每个标注框的宽高,画个直方图。如果 80% 的框都小于 32 像素,而你的实际场景是出入口近距离抓拍,那就存在严重的尺度不匹配。
第三项,看场景多样性。抽查 200 张,统计白天/夜间、晴天/雨天、正面/背面/侧面的比例。公开数据集绝大多数是白天、正面、单人、干净背景,跟你现场的逆光、遮挡、密集人流差得很远。
第四项,看标注一致性。同一张图里,远处的小目标有的标有的不标,说明标注规则没统一。这种“选择性标注”对训练伤害极大,模型会学到“远处的东西可以忽略”这个错误先验。
体检完再决定:是直接微调、还是只把它当预训练数据、还是干脆自己重标。我做过一个项目,公开数据只用来做预训练预热,真正参与最终训练的是自己采的 4000 张,效果比直接混训好一大截。
2.2 采集环节的拍摄清单与实操心法
自己采数据时,别漫无目的地录。我一般按下面这张清单去凑场景,凑满再收工:
- 时段:早高峰、午间强光、傍晚逆光、夜间补光、夜间无补光(这一项最容易被漏,也最容易崩)
- 角度:正对、侧 45 度、侧 90 度、俯视(装在杆顶的机位一定是俯视,务必单独采)
- 距离:3 米、5 米、10 米、15 米,各抓一批,覆盖目标尺寸跨度
- 遮挡:雨衣、口罩、围巾、货箱挡脸、前后车重叠
- 人数:单人、双人、三人同乘
- 负样本:空场景、只有车没有人、行人、自行车、三轮车、戴帽子的人
负样本这块我要多说两句。很多人只采正样本,结果模型在空场景上到处框。负样本建议占到总量的 10%~15%,尤其要包含“推着车走的行人”“站在车边聊天的人”这类边界情况。
采集方式我倾向从视频里抽帧,而不是一张张拍。因为实际输入就是视频流,抽帧得到的运动模糊、压缩块效应才真实。抽帧间隔别太密,连续帧几乎一样,按每秒 2~3 帧抽,再按场景去重。
2.3 标注规范:写得越细,返工越少
标注规范一定要在开工前写成文档,哪怕只有半页。我踩过最大的坑是三个标注员对“头盔边缘”的理解不一致,一个画到下巴,一个画到额头,训出来的框忽大忽小,密集场景里 NMS 一顿乱杀。
我用的规范大致是这样:
- 框住整个头部区域,包括头盔和露出的面部,不要把头盔单独框出来,也不要把肩膀框进去
- 头部被遮挡超过 60%,标为
ignore(用单独的 txt 文件或者特殊类别处理),不要当负样本 - 远处小于 12×12 像素的头部直接不标,同时在训练时把它们归为忽略区域
- 头盔戴在下巴上、没扣带子、反戴,统一算
head_helmet——业务上不纠结佩戴正确性,那是另一个任务 - 雨衣帽子、棒球帽、安全帽、工地安全帽,全部算
head_bare,因为业务规则是“必须戴骑行头盔”
提示:规范里一定要写“什么情况不标”,比写“什么情况要标”更重要。边界样本的处理方式不统一,是模型震荡的第一大来源。
标注工具不限,LabelImg、Labelme、CVAT 都行。团队协作我推荐 CVAT,能多人同时标、能审阅打回。标完做一致性抽查:随机抽 5% 的图,让第二个人重标一遍,算 IoU 一致性,低于 0.7 就说明规范有歧义,回去改规范重新对齐,别硬着头皮往下走。
2.4 数据划分的一个致命误区
这是我最想强调的一条:不要随机划分训练集和验证集。
如果你的图是从视频抽帧来的,随机划分会把同一秒的相邻帧分到训练和验证两边,两张图几乎像素级相同。结果就是验证集 mAP 冲到 0.95,现场一跑掉到 0.6。这种“虚高”会误导你做出错误的调参决策。
正确做法是按视频片段或采集日期划分。同一个视频的全部帧,要么全在训练集,要么全在验证集。我一般按 8:1:1 分,并且验证集和测试集要特意包含训练集里没有的场景,比如训练集全是白天,验证集就必须塞一批夜间。这样得到的指标才有参考价值。
2.5 目录结构与配置文件
YOLOv8 的目录约定很固定,按这个来就行:
helmet_dataset/ ├── images/ │ ├── train/ │ ├── val/ │ └── test/ ├── labels/ │ ├── train/ │ ├── val/ │ └── test/ └── helmet.yaml对应的helmet.yaml:
path: /data/helmet_dataset train: images/train val: images/val test: images/test names: 0: rider 1: head_helmet 2: head_bare一个必须注意的点:images和labels下的文件名要一一对应,只是扩展名不同(.jpg→.txt)。写个校验脚本跑一遍,统计“有图无标签”和“有标签无图”的数量,这两类数据会让训练脚本静默跳过或者报莫名其妙的错。我第一次跑就遇到过 labels 里混进了.JPG大写扩展名的图,找了两小时。
3. YOLOv8 训练实操:配置、超参与收敛判断
环境这块不复杂,但版本必须锁死,否则导出和部署阶段会被坑。
3.1 环境准备与一个坑
conda create -n helmet python=3.10 -y conda activate helmet pip install ultralytics==8.2.0 pip install onnx onnxruntime onnxsim版本锁定的理由很实在:ultralytics 的小版本之间,导出 ONNX 的默认 opset 和后处理节点结构会变,你本地跑得好好的 engine,换到另一个环境重新导出就可能对不上。团队里统一一个版本号,写进 README。
另一个坑是torch和 CUDA 的匹配。用官方源装ultralytics时会自动拉 torch,但不一定是对应你显卡驱动的版本。先按显卡驱动确定可用的 CUDA 版本,再单独装 torch,最后装 ultralytics,能省掉大量CUDA error: no kernel image is available的排查时间。
3.2 模型尺寸怎么选:算一笔账
YOLOv8 有 n/s/m/l/x 五档。选型不是越大越好,要算延迟预算。
假设你的相机是 1080p、25fps,单路每帧总预算 40ms。其中解码+缩放 5ms、后处理 8ms,留给推理的只有 27ms 左右。在常见的边缘加速盒上,FP16 精度下:
| 模型 | 参数量 | 640 输入 GFLOPs | 边缘盒 FP16 单帧耗时(参考) |
|---|---|---|---|
| yolov8n | 3.2M | 8.7 | 约 8~12ms |
| yolov8s | 11.2M | 28.6 | 约 15~20ms |
| yolov8m | 25.9M | 78.9 | 约 30~40ms |
| yolov8l | 43.7M | 165.2 | 超过预算 |
这张表里的耗时只作量级参考,具体取决于芯片、内存带宽、TensorRT 版本。但它说明一个事实:多路视频场景下,你基本只能在 n 和 s 之间选。我默认起步用yolov8s,如果精度不够再想办法,而不是直接上 m。
如果只有单路、且允许离线分析,那 m 甚至 l 都可以考虑,精度确实更好。
3.3 训练命令与关键超参解释
yolo detect train \ data=/data/helmet_dataset/helmet.yaml \ model=yolov8s.pt \ epochs=150 \ imgsz=640 \ batch=32 \ device=0 \ workers=8 \ patience=40 \ optimizer=SGD \ lr0=0.01 \ lrf=0.01 \ warmup_epochs=3 \ cos_lr=True \ close_mosaic=15 \ cache=ram \ project=runs/helmet \ name=v8s_640_baseline几个参数值的说明,都是踩过坑之后定下来的:
imgsz=640是默认值,但如果你的目标是远景小目标,应该提到 960 或 1280。这里有个换算:1080p 图像缩到 640,缩放比约 1.69,原本 40 像素的头部会变成 24 像素左右。YOLOv8 的 P3 层 stride 是 8,24/8=3,也就是只有 3×3 个特征点描述这个目标,特征表达能力已经吃紧了。所以远景场景提到 960 是必要的,代价是推理耗时增加约一倍。
close_mosaic=15表示最后 15 个 epoch 关闭 Mosaic 增强。Mosaic 把四张图拼一张,对提升鲁棒性很有效,但它会人为制造大量小目标和奇怪的拼接边界,训练最后阶段关掉,让模型收敛到真实分布上,mAP 通常能涨 1~2 个点。
patience=40配合epochs=150是留了余量的。实际观察到 60~80 个 epoch 就基本收敛了,后面是在磨细节。
cache=ram在数据集小于 8GB、内存大于 32GB 时开,能明显加快数据加载。数据集大的话别开,会 OOM。
optimizer=SGD是我在检测任务上的偏好,AdamW 收敛快但最终精度常常差一点。数据量小时(低于 3000 张)我会改用 AdamW,配合更小的lr0=0.001。
3.4 训练过程看什么:别只盯着 loss
训练起来之后,runs/helmet/v8s_640_baseline/下有results.csv和一堆曲线图。我的看法是:
box_loss平稳下降就正常,轻微波动不用管cls_loss如果长期在同一个量级不动,八成是类别不平衡或者标注有噪声- 重点看验证集的 precision/recall 曲线,两条线如果相距很远,说明阈值处在一个尴尬的位置
- 混淆矩阵里,
head_bare被判成head_helmet的数量要单独盯,这是业务上最不可接受的一类错误
我一般会在第 30、60、100 个 epoch 各抽一批验证图做可视化,看框的位置对不对、有没有漏。光看数字看不出“框偏移到肩膀上”这种系统性错误。
3.5 评估指标怎么读才算读懂了
yolo detect val输出的mAP50和mAP50-95,对业务的意义要分开理解:
mAP50衡量“大概框住了”,对定位精度要求松mAP50-95要求框很准,密集场景里这个指标会明显掉
业务上真正重要的是分类正确率,尤其head_bare的召回。我一般要求:
| 指标 | 目标值 | 理由 |
|---|---|---|
head_bare召回率 | ≥ 0.90 | 漏掉违规比误报代价高 |
head_bare精确率 | ≥ 0.75 | 太高召回会带来大量误报,运维扛不住 |
head_helmet召回率 | ≥ 0.85 | 保证统计口径不失真 |
整体mAP50 | ≥ 0.85 | 综合参考 |
注意这里的取舍:head_bare的召回优先于精确。漏掉一个违规,管理上只是少发现一次;误报一个,运营要去核实、要联系当事人,成本高得多,但还可以靠人工复核消化。所以阈值调整时,我倾向把head_bare的置信度阈值压低一点,用复核流程兜住精确率。
4. 精度上不去的四个典型原因与应对
训完发现指标不够,别急着换更大的模型。按经验,问题八成出在下面四处。
4.1 小目标与密集场景:切图推理是最划算的补丁
远景小目标的问题,除了提高imgsz,还有个工程手段是切片推理。思路是把一张大图切成若干带重叠的子图,分别推理,再按 NMS 合并结果。这样每个小目标在子图里都会被放大,特征更充分。
代价是耗时会随切片数量线性增长,所以只在两种情况下用:一是离线稽核,二是有明确 ROI(比如只对画面下半部分做切片)。
另一种更便宜的做法是只对检测到的车辆区域做二次放大推理。先整图跑一次拿到rider框,把每个骑手区域裁剪放大到 640 再跑一次,专门判定头部状态。这条路比全图切片省,实测在远景场景能拉回 8~10 个点的召回。
4.2 类别不平衡:从采样和损失两个方向同时补
典型的数据分布是head_helmet占七成,head_bare占两成,rider占一成。不平衡会让模型偏向多数类,也就是把没戴的判成戴了——方向刚好反了。
我的处理顺序是:先过采样(把head_bare多的图复制几份,但注意不要复制完全相同的图,要做不同的增强),再看损失。YOLOv8 默认用 BCE,可以给head_bare一个更大的正样本权重。实测过采样比调损失简单有效,一般过采样到接近 1:1.5 就够了,过采样太多会带来过拟合。
4.3 难例挖掘:让误报自己找上门
上线前的精度和上线后的体感,差距主要来自难例。我的做法是搭一个简单的回流机制:
- 部署版本加一个开关,把所有置信度在 0.25~0.55 之间的检测结果连同原图存下来
- 每周导出一次,人工快速筛一遍,把明显错的挑出来
- 挑出来的图重新标注,加进训练集
- 每月重训一版
这套流程不复杂,但它是唯一能让模型持续贴合现场的机制。没有回流,模型半年后就明显不行了,因为场景在变(换了机位、加了遮阳棚、季节光照变了)。
4.4 后处理规则:一半的误报能在这里消掉
模型输出不是终点。我常用的几条业务规则,效果比再训一个模型明显:
关联规则:只有head_bare框落在某个rider框的上半部分区域(比如上半 40%)内,才判定为违规。这一条能干掉“路边站着没戴头盔的行人”“墙上贴的海报”这类误报。
尺寸过滤:小于某个像素阈值的头部检测直接丢弃,因为那种尺寸下模型判断本来就不可靠。
时序平滑:同一位置连续 N 帧(比如 5 帧中的 4 帧)都判定为head_bare才触发告警。单帧抖动带来的误报会被大量过滤。代价是告警延迟,但业务上通常可以接受。
区域掩码:画面上半部分是天空、下半部分是地面的兴趣区,掩掉非道路区域。这个对减少误报特别有效,且几乎零成本。
5. 从权重到服务:导出与部署的实操细节
模型训完只是半成品,能稳定跑起来才算交付。
5.1 导出 ONNX 与 TensorRT
yolo export model=runs/helmet/v8s_640_baseline/weights/best.pt \ format=onnx opset=12 simplify=True dynamic=False imgsz=640,640dynamic=False是刻意的。固定输入尺寸导出的图,推理引擎能做更好的内存规划和算子融合,实测比动态尺寸快 15%~25%。代价是输入必须严格 resize 到 640×640,前处理要多做一步 letterbox,注意保持长宽比并用灰边填充,不要把图拉伸变形,那会直接毁掉精度。
TensorRT 引擎的构建要在目标硬件上做,不能跨机器拷贝:
trtexec --onnx=best.onnx \ --saveEngine=best_fp16.engine \ --fp16 \ --minShapes=images:1x3x640x640 \ --optShapes=images:1x3x640x640 \ --maxShapes=images:1x3x640x640INT8 量化能再快一倍左右,但必须有校准集,且校准集要是真实场景的图(随机抽 300~500 张)。如果校准集和实际输入分布不一致,INT8 的精度掉得会比预期多,这时候老老实实回退 FP16。
注意:量化的坑在于“训练时很准、INT8 后突然把小目标全丢了”。这类问题很难从指标上直接看出来,建议量化后专门跑一遍夜间和小目标子集。
5.2 推理服务的代码骨架
import cv2 import numpy as np from ultralytics import YOLO model = YOLO("best.pt") # 或 best.engine def letterbox(img, size=640): h, w = img.shape[:2] scale = min(size / h, size / w) nh, nw = int(h * scale), int(w * scale) resized = cv2.resize(img, (nw, nh)) canvas = np.full((size, size, 3), 114, dtype=np.uint8) top, left = (size - nh) // 2, (size - nw) // 2 canvas[top:top+nh, left:left+nw] = resized return canvas, scale, left, top def is_violation(dets, iou_thr=0.1): riders = [d for d in dets if d["cls"] == 0] bares = [d for d in dets if d["cls"] == 2] hits = [] for b in bares: for r in riders: if in_upper_region(b["box"], r["box"]) and iou(b["box"], r["box"]) > iou_thr: hits.append(b) break return hits关键点在于in_upper_region:判断裸露头部的框中心是否落在骑车人框的顶部区域。这个函数比我原来想的要重要,它决定了“车后座放的西瓜”“车筐里的杂物”会不会被误判。
5.3 参数换算与性能核算
后处理里所有坐标都要还原回原图尺度,公式是x_orig = (x_letterbox - left) / scale。这一步写错,框会整体偏移,而且偏移量随分辨率变化,特别难查。我第一次部署就漏了这一步,画面里框全都往右下角飘,调了一下午。
性能预算这样算:假设 4 路 1080p 视频,每路抽帧到 12fps 处理(不需要每帧都跑),总输入是 48 帧/秒。若单帧推理 18ms,理论需要 0.86 个核心,看起来有余量,但要注意 GPU 是串行处理的,实际排队加上前处理拷贝,4 路并发下平均延迟会到 30ms 以上。这时候要么降帧率,要么降到yolov8n,要么上批处理(batch=4)提升吞吐。
批处理是个好办法,但会引入额外延迟。实时告警场景下,我一般选 batch=1、多进程解码、GPU 侧队列化,宁可降一点帧率也别让延迟堆积。
5.4 与业务系统对接的几个约定
推理服务输出给上层时,建议统一成这样的结构:
{ "camera_id": "gate_03", "ts": 1710000000, "event": "no_helmet", "track_id": 1287, "bbox": [412, 208, 468, 274], "score": 0.81, "snapshot": "oss://.../1287.jpg" }track_id这个字段很关键。如果没有跟踪,同一个人走过画面会触发几十条告警,运营会疯。最简单的实现是加一个轻量的多目标跟踪(比如基于 IoU 的匈牙利匹配),同一个track_id在时间窗口内只上报一次。这一条改动,能把告警量降低一个数量级。
6. 常见问题排查速查表
这部分的每一条都是我真遇到过或者帮别人看过的,按现象归类。
6.1 训练侧问题
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| loss 不降,一直高 | 学习率太大 / 标签路径错 | 降到 0.001 试;用脚本校验 images 与 labels 配对 |
| loss 降到很低但 val mAP 也低 | 过拟合训练集 / 划分泄漏 | 检查是否随机划分;加增强;减模型 |
| 只有某一类学不会 | 该类样本太少或标注不一致 | 统计类别分布;抽查该类标注 |
| 训练中途 loss 突然爆炸 | batch 太大 / 数据里有坏图 | 降 batch;写脚本检查损坏文件 |
| 显存 OOM | imgsz 太大 / cache 开了 | 降 imgsz 或关 cache |
关于“某一类学不会”,我遇到最典型的一次是head_bare的召回死活上不去。最后发现标注员把逆光下的头部统一标成了ignore,导致这类样本在训练里几乎没参与。改回正常标注后,两周就追上了。
6.2 推理侧问题
框位置整体偏移:几乎一定是 letterbox 的反变换写错了,检查left/top和scale是否用了同一套参数。
GPU 利用率只有 20%:瓶颈在前处理或解码。把cv2.resize换成 GPU 侧的 resize,或者用硬解码。这个优化通常能让吞吐翻倍。
首次推理特别慢:TensorRT 引擎有预热过程。服务启动时先跑 10 次空推理,把显存分配和 kernel 选择做掉。
同一模型在不同机器上结果不一致:检查是否用的 FP16/INT8 引擎,以及 NMS 是在 CPU 还是 GPU 上做的。NMS 阈值哪怕差 0.05,密集场景的结果都会明显不同。
6.3 上线后的误报治理
现场反馈“误报多”时,我一般按这个顺序查,成本从低到高:
- 看误报图,先分类。是空场景乱框?还是把头盔挂件当成佩戴?还是把路人当骑手?三类问题的解法完全不同。
- 空场景乱框:加负样本,加区域掩码,提置信度阈值。
- 头盔挂件误判:检查类别设计,确认标注时用的是“头部+头盔整体”而不是“头盔物体”。
- 路人误判:补上
rider关联规则,只判骑车人。 - 密集场景互相干扰:调 NMS 的 IoU 阈值,或者上软 NMS。
提示:阈值不要一次性大改。每次只动一个参数,改完在固定的验证集(最好就是误报那批图)上回归一次。我在现场见过有人把阈值从 0.25 直接调到 0.6,误报是没了,召回也没了,最后还得回滚。
还有一个容易被忽略的点:摄像头本身的变化。镜头脏了、角度被碰歪了、雨棚加了、补光灯换了色温,都会让模型表现变差。我的做法是每周固定跑一次“健康检查”,用一批固定场景的样本跑推理,看指标有没有异常下降,比等运营投诉要早得多。
最后分享一个我自己在用的做法:每训一版模型,把验证集里所有判错的样本拼成一张大图,放在项目 README 里。下一次调优前先看这张图,比看任何指标都直观——你会发现错误是高度聚集的,往往集中在两三个具体场景上,改掉这两三个场景,比盲目加数据有效得多。数据集和源码是起点,能不能落地,取决于你愿不愿意盯着这些错图一直改下去。