交通场景视觉检测数据集设计与标注质量控制实践 交通场景的视觉检测这几年被聊得很多但真正上手做过完整项目的人都清楚卡脖子的地方往往不是模型结构而是手里那堆图像资源到底靠不靠谱。一套交通检测数据集说白了就是把路口、路段、停车场这些真实场景里的车、人、骑行者、交通标志等目标用统一的规则框出来、标清楚再整理成模型能直接读取的格式。它解决的核心问题很具体让检测模型学到什么样子算一辆车什么情况下算遮挡远处的小目标该不该标。这套东西适合谁准备做智能交通管理相关项目的算法工程师、需要快速验证想法的学生、以及手上有业务场景但不知道怎么组织数据的团队都能从中找到可复用的部分。下面我按自己实际整理和使用这类数据集的顺序从设计思路一路讲到排查现场中间该给参数的地方给参数该说坑的地方不绕弯子。1. 交通视觉检测这件事难点到底堆在哪很多人第一次拿到交通场景图像第一反应是这不就是标车和行人吗能有多难。真标过两千张之后就不会这么说了。交通场景的复杂度不在类别数量上而在同一类别内部的形态差异极大以及目标所处的环境极其不稳定。搞清楚难点在哪才能反过来决定数据集该怎么设计。1.1 三类典型场景的视觉差异比想象中大我一般把交通检测的图像来源分成三类固定机位的路口俯拍、车载视角的前向拍摄、以及园区/停车场这类低速封闭场景。这三类图像的成像特性完全不同混在一起训练时必须心里有数。路口俯拍的优势是视角稳定、背景固定同一个路口拍上几个月背景几乎不变模型很容易把路面停止线护栏这套组合记成背景检测精度会虚高。坏处是近处目标形变大一辆公交车在画面边缘可能只露出四分之一车身比例失真严重。车载视角则相反尺度变化极为剧烈十米外的车只有几十个像素五米内的车能占掉半个画面而且运动模糊、雨刷遮挡、强逆光都是常态。园区低速场景看起来最干净但恰恰因为它干净用这类数据训出来的模型换到复杂路口往往直接崩掉。所以数据集设计的第一步不是决定标多少张而是决定这几类场景各占多少比例。我的经验配比是路口俯拍约 45%车载视角约 35%园区与停车场约 20%。这个比例不是拍脑袋来的它对应的是实际业务里最常见的部署形态——大部分智能交通管理需求落在路口和路段园区属于补充场景。如果项目明确只做路口那比例当然可以调到 80% 以上但一定要在文档里写清楚适用范围别让后来人拿去做车载场景然后回头骂数据集不好用。1.2 数据集质量是怎么一步步决定模型上限的有个说法叫数据决定上限模型逼近上限听着像老生常谈但在交通检测里特别明显。我做过一次对照实验同一套 YOLO 系列的单阶段检测器超参完全不动只换数据集。第一次用的是从公开渠道随手凑的两千张图标注框比较随意遮挡目标基本没标第二次用按规范重标的一千八百张图遮挡超过一半的目标统一不标、小目标统一标。结果第二次的 mAP 反而高出将近 9 个百分点。这个差距的来源可以拆得很细。标注框松一点紧一点影响的是定位分支的回归目标遮挡目标标不标影响的是分类分支对部分可见这类样本的学习小目标标不标影响的则是模型在高分辨率特征图上的召回。三者叠加最终体现在指标上就是好几个点。更隐蔽的问题是类别定义不一致——比如三轮车到底算机动车还是非机动车如果标注时不同人理解不同模型学到的就是一个模糊边界这类错误在混淆矩阵里表现得特别明显但只看总体 mAP 根本发现不了。我在实际项目里养成一个习惯任何数据集进训练之前先抽出 100 张做一次人工盲标也就是不看已有标注自己重新标一遍然后和原标注比对。如果 IoU 低于 0.75 的框超过 5%这套数据就得回去返工。这个检查只要半天但能省掉后面两周的调参困惑。2. 数据集整体设计与类别体系拆解类别体系是整个数据集的骨架。骨架歪了后面标得再仔细也是白费。我见过太多项目栽在类别定太细或者类别定太粗这两头前者导致每个类别的样本量都撑不起来后者导致模型分不清业务上必须分清的object。2.1 采集方案机位、高度、时段的取舍先说采集。如果条件允许自己拍机位高度我建议控制在 6 到 12 米之间。低于 6 米车辆之间互相遮挡太严重一辆SUV能挡住后面三辆车高于 12 米单辆车在 1080P 画面里可能只剩 40 像素宽标起来痛苦训起来也学不到东西。这个高度区间大致对应常见的路口监控杆位和天桥机位。时段分布是个容易被忽略的点。很多数据集白天样本占九成结果模型一到夜间就废。我的做法是按光照条件分层采集白天顺光、白天逆光、黄昏、夜间有路灯、夜间弱光、雨天、雾天至少覆盖六种。每种不需要很多但必须有。原因很直接检测模型对光照的敏感度远超一般人的预期同一个路口白天和夜间的特征分布差异比两个不同路口的白天差异还大。采集频率也有讲究。同一个机位如果连续抽帧相邻两帧几乎一模一样这样的数据放进训练集等于变相复制样本会让验证指标虚高。我的做法是同一机位同一时段抽帧间隔不小于 2 秒且保证每个目标在连续帧里不要重复出现超过 3 次。2.2 类别定义与标注粒度细到什么程度才够用交通场景的类别清单我一般收敛到十个左右的粗类再加若干细分。粗类包括小汽车、客车/公交、货车、两轮车摩托与电动自行车合并且标注子类、行人、骑行者、交通标志、交通信号灯、锥桶等临时障碍物。这个粒度对绝大多数智能交通管理需求已经够用。要不要把轿车再分成三厢两厢、要不要把货车按吨位分我的答案是除非业务明确需要否则不要。理由很实在——细分会让每个子类的样本量骤降一千张图里如果 SUV 只有 60 辆模型学到的东西还不如它从小汽车这个大类里学到的多。真要细分正确做法是先按大类标注等样本量上来之后再拆分而不是一开始就把类别表列成二十项。标注粒度还有两个必须提前定死的规则。第一是遮挡阈值目标可见面积低于 30% 的一律不标30% 到 70% 之间的标但不参与难度评估70% 以上正常标。这条规则如果不写死不同标注员给出的结果会差出天去。第二是截断规则目标被画面边缘切掉超过一半不标切掉不到一半照常标框要贴到图像边界。2.3 规模与分布配比的计算逻辑规模怎么定我通常用反向推算。假设目标是每类至少 1500 个有效实例而一张典型路口图平均含 12 个目标4 辆车、3 个行人、2 个两轮车、其余零星那么需要大约 1500/12×类别数 的图。按 10 个类粗算大约一万两千张。这个数量级对应的是一个能训出可用模型的规模。但平均数是骗人的。实际分布一定是长尾的——小汽车可能占 60%交通标志可能只占 2%。所以真正要算的是有效实例数而不是总数。我在整理时会做一张分布表把每个类的实例数、占比、以及如果按 8:1:1 划分后验证集里还剩多少都列出来。验证集里某类少于 100 个实例的话这个类的指标基本不可信波动会非常大。下面是我在某次项目中实际用的一张分布对照表数字做了脱敏处理只保留结构类别实例数占比验证集实例数是否可信小汽车4120047.9%4120可信行人1430016.6%1430可信两轮车1180013.7%1180可信货车860010.0%860可信客车52006.0%520可信骑行者31003.6%310勉强可信交通标志12001.4%120存疑信号灯5000.6%50不可信锥桶1800.2%18不可信看到最后三行处理方式就很清楚了锥桶和信号灯这类要么补采补标到 800 以上要么在评估时单独拿出来说不要混进总体 mAP 里自欺欺人。3. 标注规范落地与质量控制规范写在文档里是一回事标注员执行出来是另一回事。质量控制的核心不是要求标得准而是让标不准的地方能被自动发现。3.1 边界框判定标准贴着像素走最基础的规则边界框必须紧贴目标可见部分的外轮廓不留白边也不切掉目标。这句话说起来简单实操中最容易出问题的是三种情况。第一种是后视镜、天线、外挂物这类凸出结构。我的规则是后视镜算进车身框天线不算货车的挂车单独成框。理由是对检测任务而言后视镜对判断车辆朝向有帮助而天线又细又容易断标进去只会引入噪声。第二种是阴影。车底阴影一律不框进去。曾经有批数据标注员习惯把阴影算进去结果训出来的模型在正午强光下框普遍偏大因为阴影面积在不同时段变化极大。第三种是群体目标。一排停着的电动车紧密相邻时是标一个大框还是每辆一个小框答案永远是每辆一个小框哪怕两辆之间有重叠。群体框对检测任务毫无意义只会让模型学到一个含糊的一团东西概念。3.2 一致性校验用交叉复核把偏差揪出来单靠标注员自查是没用的人都觉得自己标得对。我采用的流程是三级校验。第一级是同一个人隔天复查抽取 10%主要抓手滑类的低级错误比如框到一半松手、类别选错。第二级是交叉复核由另一个标注员对同一批图重新标 5%然后跑脚本算 IoU低于 0.7 的挑出来开会讨论。第三级是我自己抽查 100 张重点看边界情况和规范模糊处。这里有个很实用的技巧不要指望靠人眼看出一致性问题一定要写脚本量化。下面这段是我常用的核对脚本输入两套标注输出不一致清单import os, json def load_yolo_labels(path, img_w, img_h): boxes [] if not os.path.exists(path): return boxes with open(path, r) as f: for line in f: parts line.strip().split() if len(parts) 5: continue c, cx, cy, w, h int(parts[0]), *map(float, parts[1:5]) # YOLO 归一化坐标转绝对坐标 x1 (cx - w / 2) * img_w y1 (cy - h / 2) * img_h x2 (cx w / 2) * img_w y2 (cy h / 2) * img_h boxes.append((c, x1, y1, x2, y2)) return boxes def iou(a, b): ax1, ay1, ax2, ay2 a bx1, by1, bx2, by2 b ix1, iy1 max(ax1, bx1), max(ay1, by1) ix2, iy2 min(ax2, bx2), min(ay2, by2) iw, ih max(0, ix2 - ix1), max(0, iy2 - iy1) inter iw * ih if inter 0: return 0.0 area_a (ax2 - ax1) * (ay2 - ay1) area_b (bx2 - bx1) * (by2 - by1) return inter / (area_a area_b - inter) def compare(label_a, label_b, img_w1920, img_h1080, thr0.7): boxes_a load_yolo_labels(label_a, img_w, img_h) boxes_b load_yolo_labels(label_b, img_w, img_h) bad [] for ba in boxes_a: best max((iou(ba[1:], bb[1:]) for bb in boxes_b if bb[0] ba[0]), default0.0) if best thr: bad.append((ba, round(best, 3))) return bad这套脚本跑下来不一致率通常能压到 3% 以内。如果超过 8%说明规范本身有歧义得回头改文档而不是继续骂标注员。3.3 数据清洗与难例挖掘把垃圾剔出去清洗分两块机械清洗和语义清洗。机械清洗靠脚本检查这几种情况图像损坏打不开、分辨率低于 640×480、标签文件缺失或为空、坐标越界x 或 y 超出 0 到 1、框宽高小于 2 像素、同一张图里框完全重合。这些都是一眼能判死的错误脚本一跑就清了。语义清洗更麻烦得靠模型帮忙。具体做法是先用当前已有数据训一个基线模型然后在全量数据上推理把模型很自信地预测出目标但标注里没有的图挑出来。这批图里通常藏着两类东西漏标以及模型真的搞错了的难负例。人工过一遍漏标的补上这是性价比最高的数据增补方式比重新采集快得多。难例挖掘还有个土办法我一直在用按目标框面积从小到大排序取最小的 500 个框出来单独看。小目标标注最容易出问题要么框歪了要么根本标错了位置。把这批修完模型的小目标召回通常能提两三个点。4. 从原始图像到可训练格式的工程化处理数据整理好了接下来是把它变成模型能吃的东西。这一步看着是纯体力活但目录结构和划分策略设计不好后面复现实验会非常痛苦。4.1 目录组织与格式转换我习惯的目录结构是这样简单但足够用traffic_det/ ├── images/ │ ├── train/ # 训练集图像 │ ├── val/ # 验证集图像 │ └── test/ # 测试集图像 ├── labels/ │ ├── train/ # 与图像同名的 .txt │ ├── val/ │ └── test/ ├── classes.txt # 类别名列表顺序即类别 id ├── data.yaml # 训练配置 └── meta/ ├── source.json # 每张图的来源、机位、时段 └── stats.csv # 各类实例数统计meta这个目录很多人不做我强烈建议加上。存下每张图的采集来源、机位编号、光照条件后面做分场景评估时你会感谢自己。有一次线上模型在夜间误检率突然升高我就是靠source.json快速定位到问题集中在某几个机位的逆光场景半天就修好了如果没有这个元信息可能得排查一周。格式转换以 YOLO 格式为主因为它最简洁。如果要做可视化或者和别的框架对接再转一份 COCO 格式。转换时最容易出错的是坐标归一化和类别 id 映射建议转完立刻做一次反向可视化随机抽 20 张把框画回图上看看对不对。import cv2, os def draw_check(img_path, label_path, save_path, names): img cv2.imread(img_path) h, w img.shape[:2] if os.path.exists(label_path): with open(label_path) as f: for line in f: c, cx, cy, bw, bh line.split() cx, cy, bw, bh map(float, (cx, cy, bw, bh)) x1 int((cx - bw / 2) * w) y1 int((cy - bh / 2) * h) x2 int((cx bw / 2) * w) y2 int((cy bh / 2) * h) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(img, names[int(c)], (x1, max(y1 - 5, 12)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1) cv2.imwrite(save_path, img)4.2 数据增强策略与参数设置交通场景的增强不能乱开。我的原则是只做物理上说得通的增强。水平翻转可以但要注意交通场景里左右转向灯、车牌方向这些细节会失真如果业务不关心这些就没问题。颜色抖动幅度要控制色相偏移超过 15 度就容易把白天变成黄昏反而引入错误监督。马赛克增强对小目标有帮助但概率别开太高0.5 左右比较稳开到 1.0 会让模型总是看到拼接图真实分布反而学不好。我常用的增强配置大致如下augment: hsv_h: 0.012 # 色相交通场景不宜过大 hsv_s: 0.6 hsv_v: 0.35 degrees: 0.0 # 不建议旋转交通场景有明确重力方向 translate: 0.1 scale: 0.4 shear: 0.0 perspective: 0.0 flipud: 0.0 # 上下翻转绝对不要开 fliplr: 0.5 mosaic: 0.5 mixup: 0.0 # 检测任务里 mixup 收益不稳定 copy_paste: 0.1 # 对小目标召回有微弱帮助上下翻转这条要单独说。有些通用配置模板默认给个 0.5直接套到交通场景就是灾难因为翻过来的车是倒着的模型会学到一堆不存在的形态。同理大角度旋转也不要开路面上没有横着开的车。4.3 划分训练验证测试集的坑划分看似简单实际上是最容易埋雷的地方。最大的雷是数据泄漏同一个场景的相邻帧被分到了训练集和验证集里验证指标看着漂亮实际部署一塌糊涂。正确做法是按场景时段划分而不是按图像随机划分。我通常按机位编号分组同一机位的所有图像只能落在一个集合里。第二个坑是分布不平衡。随机划分有可能让验证集恰好抽到了大量夜间样本指标就偏低你会误以为是模型问题。我的做法是分层抽样先按光照条件和场景类型分桶每桶内按 8:1:1 切分保证三个集合的分布尽量一致。第三个坑是测试集被反复使用。测试集一旦用于调参它就不再是测试集了。我在项目里会给测试集加个访问记录文件每次评估都记一笔超过三次就说明这个测试集已经污染需要重新划一批出来。5. 训练落地与指标解读数据准备到位接下来是把它用出效果。这一节重点讲参数怎么算出来的以及指标该怎么读——因为指标误读导致的错误决策比参数调错更常见。5.1 输入分辨率与显存的匹配计算分辨率的选择有个粗略的定量方法。假设业务要求能检出 30 米外的一辆小汽车该距离下车辆在原始画面里约占 60 像素宽。如果模型输入把 1920 宽缩到 640缩放比是 1/3那目标就只剩 20 像素低于大多数检测器可靠检出的下限大约是 16 到 24 像素取决于网络下采样倍率。这时候要么提高输入到 960 或 1280要么换更轻的下采样结构。显存估算也有个经验公式单卡显存占用约等于 输入宽 × 输入高 × batch × 3 × 4 字节 × 系数系数通常在 8 到 15 之间含激活值和梯度。以 960×960、batch 8 为例光输入张量就是 960×960×8×3×4 ≈ 88MB乘上系数大致落在 1.2 到 1.5GB 的图像部分加上模型本身和优化器状态一张 16GB 的卡跑到 batch 16 是比较稳的。这些数字不是精确值但足够你在开跑前判断会不会爆显存省得反复试。5.2 训练参数配置与学习率调度学习率的起点我一般按 batch 线性缩放基准是 batch 16 配 0.01batch 翻倍学习率翻倍但上限不超过 0.02超过之后训练很容易发散。交通数据集通常几千到一万多张epoch 设在 100 到 150 之间前 3 个 epoch 做 warmup让模型先稳定下来再上正常学习率。train: epochs: 120 batch: 16 imgsz: 960 optimizer: SGD lr0: 0.01 lrf: 0.01 # 最终学习率为 lr0 * lrf momentum: 0.937 weight_decay: 0.0005 warmup_epochs: 3.0 warmup_bias_lr: 0.1 close_mosaic: 15 # 最后 15 个 epoch 关掉马赛克让模型适应真实分布close_mosaic这个参数值得强调。马赛克增强会让模型习惯拼接图的统计特性如果在最后阶段还开着验证指标会偏低因为验证集是正常图像。关掉之后指标通常会跳升一两个点这是正常的不代表模型突然变强。5.3 指标怎么读才有意义mAP50 是最常被引用的数字但它对交通检测来说远远不够。我实际看四个东西mAP50-95 看整体定位质量各类别 AP 看短板小目标召回率看远处目标以及分场景的误检率。尤其要注意 mAP50-95 和 mAP50 的差距。如果前者明显低于后者的一半说明框的位置不够准问题多半在标注质量或回归损失上而不是分类。这时候回去检查标注框是否过松往往能发现问题。还有一个反直觉的现象验证集 mAP 涨了实际部署效果却变差。常见原因是验证集和训练集同源模型过拟合到了特定机位的背景上。解决办法就是前面说的按机位分组划分再加一个独立的跨场景测试集。我一般会留 200 张完全不同来源的图作为真实测试集只在最后评估用一次。6. 常见问题与排查实录做了这么多次出问题的地方其实高度集中。下面这张表是我自己整理的速查版基本覆盖了八成以上的现场故障。6.1 典型故障速查表现象最可能原因排查动作处理方式训练 loss 不降标签坐标越界或类别 id 错位检查标签文件首列是否超出类别数重跑校验脚本修正 id 映射训练正常但 mAP 极低验证集标签路径不匹配确认 labels/val 与 images/val 文件名一一对应批量重命名或重建目录只检出大目标输入分辨率过低统计验证集目标像素宽高分布提高 imgsz 或改用更高分辨率切片夜间场景误检多夜间样本占比过低按 meta 中的光照标签分组评估补采夜间数据或加亮度增强同一目标重复出框NMS 阈值过高检查 NMS IoU 设置从 0.7 调到 0.45 到 0.5 之间密集场景漏检严重训练集缺少密集样本统计每图目标数分布补密集场景或调整 NMS 为 soft-NMS指标忽高忽低验证集太小看验证集实例总数扩到每类至少 300 个实例部署后性能骤降训练测试同源检查是否按机位分组划分重划数据集加跨场景测试集6.2 几条踩出来的经验第一条也是最值钱的一条永远先怀疑数据再怀疑模型。我统计过自己过去一年的排查记录标称模型效果不行的问题里超过七成最后定位到数据上——要么标签路径错了要么类别分布严重倾斜要么某个类别的框系统性地偏大。养成习惯任何异常先跑一遍数据完整性检查再动模型。第二条类别不平衡不要急着上加权损失。很多人一看长尾分布就加 class weight结果小类别的精确率暴跌。我的做法是先补数据把最小的几类补到 500 个实例以上再考虑损失函数层面的事。数据层面的问题用数据解决代价往往更低。第三条验证集的可视化比指标更重要。我每个 epoch 结束后都会随机抽 16 张验证图把预测框画出来存盘训练完翻一遍。很多问题指标上看不出来图上一眼就能看到——比如所有框都往右下偏了 5 个像素这是典型的坐标转换错误。第四条小目标单独评估。把验证集按目标面积分成小小于 32×32、中、大三档分别算召回。总体召回 85% 听着不错但如果小目标只有 50%实际部署在远景场景就会非常难受。这个拆分评估只要几行代码收益极大。提示整理数据集时凡是感觉有点问题但说不上哪里不对的图直接剔掉。带着疑虑的样本进训练集最后一定会以某种意想不到的方式还回来。7. 关于扩展方向的一点个人判断这套数据集用顺手之后往下走有几个方向我觉得投入产出比不错。一是往时序走把连续帧组织成片段做简单的目标跟踪这对交通流量统计这类需求是刚需而且不需要重新标注直接复用检测框加个 ID 关联就行。二是补充分类头给已有的车辆框加上颜色、朝向、车型细分标签成本不高但能撑起更多业务场景。三是做切片推理的适配把高分辨率原图切块送入模型再合并结果这对远距离小目标的效果提升非常直接代价只是推理耗时增加。不过有件事我得说在前头别急着扩类别、扩场景先把现有数据的一致性做到位。我见过太多项目数据集从一万张扩到五万张类别从十个扩到三十个结果核心类别的小目标召回反而掉了。数据集的复杂度增长是指数级的每加一个类别、一种场景标注规范的边界模糊地带就多一片。我个人的节奏是先把一个场景、十个类别做到标注一致性 95% 以上验证指标稳定可复现再考虑下一步。最后分享一个我一直在用的小习惯。每次项目收尾我都会写一份不超过两页的数据说明里面只回答四个问题这批数据覆盖什么场景、类别定义的关键边界在哪、哪些类别样本不足、已知的局限是什么。这份东西不给别人看是给自己下一次用的。因为隔半年再用同一批数据你一定会忘记当初那些众所周知的约定而恰恰是这些约定决定了数据能不能用好。