前阵子整理一批X光行李安检图像数据,准备做违禁物品检测,方案自然选了目标检测领域最常用的YOLOv5和YOLOv8。说实话,一开始我挺自信的——自然场景下的目标检测做过不少,换成X光图无非是换个数据集罢了。真跑起来才发现,X光行李违禁物品检测和常规目标检测完全是两种体验:透视叠加、严重遮挡、伪彩通道含义特殊,连数据增强策略都要推翻重来。这篇记录我从数据整理到v5/v8训练、再到后续部署优化的完整过程,给准备入坑X光安检目标检测的朋友一个可复现的参考。
我这次处理的这批数据编号是63,对外的打标名就是“x光行李违禁物品数据集”,里面附带作者已经跑好的YOLOv5/YOLOv8模型权重。也就是说,数据集本身还自带baseline,这其实是个很好的起跑线,省去了很多从零试错的成本。不过我还是坚持了自己重训一版,因为自带模型的类别定义、训练细节和部署口径未必适合我这边实际的业务约束。
1. 拿到这批X光数据后,先别急着开训:X光图像到底特殊在哪
1.1 透视叠加不是“部分遮挡”,而是“彼此贯穿”
自然图像里,一辆车挡住一个人,那个人的可见面积可能只有10%甚至更小,但语义上是独立的:边缘明确、轮廓可见。X光行李图像完全不是这个逻辑,一个行李箱内所有物品的密度信息会同时投影到同一张二维图像上,书本、笔记本电脑、金属框架、保温瓶、雨伞全部叠在一起,不同材质在密度维度上互相穿透。
这就导致同一个像素位置可能同时属于三四个目标的轮廓,而模型要做的不是识别“谁在前谁在后”,而是从叠加后的密度分布里把每个目标的轮廓从语义上剥出来。我第一次拿YOLOv5默认参数直接跑,mAP看着还行,但细看预测框发现大量漏检都发生在高密度堆叠区域——多个物体挤在一起时,模型倾向于只报置信度最高的那个,其余直接消失。
后来我想明白一件事:常规目标检测的anchor或query设计,本质上假设目标是一个“独立的整体”,而X光场景里这个假设经常不成立。训练时必须刻意让模型见过足够多“被半遮挡但边界仍能辨析”的正样本,它才可能学会抠边缘而不是认整体。
1.2 颜色通道不是普通RGB,伪彩是一种材质编码
很多人第一次拿到X光图像会困惑:为什么图片上物品是橙色、蓝色、绿色混杂的?这其实是安检设备根据等效原子序数和密度做的材质映射——有机物通常显示橙色,无机物显示蓝色,混合物显示绿色。也就是说,颜色本身携带了“材质类别”信息,而不是自然图像里那种光照反射结果。
这带来一个非常重要的训练约束:绝对不能像处理自然图像那样做随机HSV抖动、色调偏移、颜色反转这类增强。我早期版本图省事直接套用了默认的增强pipeline,结果模型在验证集上掉点明显,尤其是金属物与有机物叠在一起的目标,边界预测变得一团糟。原因很好理解:你把橙色物品的颜色往蓝色方向抖一下,材质语义就彻底错了,模型等于在学一套错误映射。
后续我把hsv增强全部关掉,只保留几何类增强,训练稳定性立刻上来了。这个点我在后面的避坑章节还会展开讲,但它值得在最开头就强调:X光图像的每个通道都有物理含义,增强之前先想想会不会破坏物理语义。
1.3 这批数据的构成与标注质量观察
我快速统计了一下这批数据的基本盘,大约2万多张图像,标注实例数量接近4万,覆盖常见违禁物品类别。原始数据包含两种形态:一种是标准的伪彩图,三通道;另一种是灰度图,三通道复制。后者在训练时价值略低,因为灰度图丢失了材质映射信息,但作为泛化补充仍然有用。
标注质量方面,我观察到两处需要处理的地方。第一,部分类别存在同物异名的问题,比如“刀”和“折叠刀”被分在两个类里,实际语义一致;第二,大量重叠目标的标注框只圈了可见部分,完整轮廓并没有标注出来。对于第一个问题,我的处理方式是合并同类项,把语义接近的类别归并成一个大类;对于第二个问题,我没有强行手动补标全部轮廓,而是把它当成一种隐式的“可见部分检测”任务来训练,推理时接受完整框和部分框同时出现,再用后处理合并。
2. YOLOv5还是YOLOv8,放在X光场景里怎么选
2.1 两者核心差异对这个场景的影响
YOLOv5和YOLOv8的差异网上分析很多,我只挑对X光检测影响最大的三点说。
第一,检测头设计。YOLOv5是anchor-based,需要预设anchor尺寸;YOLOv8改成anchor-free,用中心距离加宽高比的方式直接回归目标框。在X光场景里,物体形状极度多样,刀的细长框、充电宝的方正框、液体瓶的矮胖框混在一起,anchor-free的解耦头对形状变化的容忍度明显更高,尤其对细长物体召回更好。
第二,主干网络结构。YOLOv8使用C2f模块替代v5的C3模块,梯度流更丰富,理论上对细小目标的特征表达能力更强。我用同样数据量分别跑了v5s和v8s,v8s在小物体(打火机、小刀尖端)上的召回率要高3到5个百分点,代价是显存占用和推理延迟略增。
第三,训练策略。YOLOv8内置的loss设计(DFL加CIoU)对边框回归更精细,训练过程中loss下降更平滑。但对小规模数据集,v5的收敛速度更友好,不容易一上来就过拟合。v5s参数量大约7M,v8s大约11M,参数更多但数据不充裕时,优势会被过拟合抵消一部分。
两个模型在X光场景的适用性,我放在一张表里总结。
| 对比项 | YOLOv5 | YOLOv8 | 对X光场景的实际影响 |
|---|---|---|---|
| 检测头 | anchor-based | anchor-free解耦头 | v8对细长/异形物体召回更好 |
| 主干模块 | C3 | C2f | v8小目标特征表达略优 |
| 模型体量 | 轻量,v5s约7M | 偏重,v8s约11M | 小数据量时v5更稳 |
| 部署生态 | 资料极多 | 导出方便,新特性全 | 看团队熟悉程度 |
| 自带增强 | 传统增强 | 更丰富的训练策略 | 但颜色增强必须关闭 |
2.2 我最终采用的组合方案
我的结论是:不要二选一,而是让它们各司其职。
我用YOLOv8s作为主检测模型,因为它在重叠和异形目标上的表现更符合安检场景的需求;同时保留YOLOv5s作为快速迭代实验的baseline,因为v5训练速度更快,适合做数据清洗和类别合并验证。最终部署时,我用的是v8s蒸馏自v8l的轻量版本——大模型负责学更多细节,小模型负责实际推理,这是后面章节会详述的优化路线。
如果手头硬件紧张,只跑CPU或老GPU,那v5s依然是合理选择,它的生态成熟度和稳定性毋庸置疑。关键是训练策略必须按X光图像的特性重调,模型本身不是瓶颈。
3. 数据准备与预处理:把图像调教到模型“看得懂”的状态
3.1 目录结构与Label格式必须统一
所有训练框架对数据的组织方式都大同小异,我这边最终统一成YOLO格式的标准目录结构。如果是VOC格式的XML标注或者COCO风格的JSON标注,需要先转成txt格式。转换时要注意类别映射关系,最容易出错的是类别索引对不上,训练出来的模型预测结果驴唇不对马嘴。
我通常把转换脚本写成可重复执行的工具,而不是手工转换,因为后续很可能要反复调整类别合并策略。目录结构固定为:
datasets/ ├── xray_sec/ │ ├── images/ │ │ ├── train/ │ │ ├── val/ │ │ └── test/ │ ├── labels/ │ │ ├── train/ │ │ └── val/ │ └── xray_sec.yamlXML转txt的脚本核心逻辑很简单,无非是解析bounding box坐标并归一化。写的时候注意两点:一是坐标要归一化到0到1之间;二是宽高必须是非负数,过滤掉宽度或高度为零的坏标注。
import xml.etree.ElementTree as ET import os def voc_to_yolo(xml_path, out_dir, class_map): tree = ET.parse(xml_path) root = tree.getroot() img_w = int(root.find("size/width").text) img_h = int(root.find("size/height").text) lines = [] for obj in root.findall("object"): cls_name = obj.find("name").text if cls_name not in class_map: continue cls_id = class_map[cls_name] box = obj.find("bndbox") x1 = float(box.find("xmin").text) y1 = float(box.find("ymin").text) x2 = float(box.find("xmax").text) y2 = float(box.find("ymax").text) cx = (x1 + x2) / 2.0 / img_w cy = (y1 + y2) / 2.0 / img_h w = (x2 - x1) / img_w h = (y2 - y1) / img_h lines.append(f"{cls_id} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}") if lines: out_txt = os.path.join(out_dir, os.path.splitext(os.path.basename(xml_path))[0] + ".txt") with open(out_txt, "w") as f: f.write("\n".join(lines))3.2 针对X光图像做的几项预处理和增强
完成格式统一后,接下来是训练前的一轮图像预处理。我做了三件事,每一件都有明确目的。
第一是CLAHE对比度增强。X光原始图经常有动态范围很大、局部对比度不足的问题,传送带匀速运动还会带来轻微模糊。限制对比度自适应直方图均衡能在不明显放大噪声的前提下提升局部边缘可见度,这对小目标检测很有帮助。注意参数不要设太激进,clipLimit我一般取2.0,tileGridSize取8x8,过强会让材质颜色失真。
第二是上采样策略。X光原图分辨率普遍较高,而训练框架默认会把图像缩放到640x640。直接缩放会把许多小物体缩成一个点。我的做法是优先在1280x1280的分辨率下训练,如果显存不足,至少也要用960,并用马赛克增强模拟多尺度的上下文。
第三是选择性使用复制粘贴增强。X光场景里物体本来就是叠加的,把一个目标的小图随机粘贴到另一张正常图上,其实非常接近真实分布。但对类别少的样本要严格控制粘贴次数,防止模型学到“同类物体总是成对出现”的错觉。
增强配置上,几何类:随机水平翻转、随机缩放、轻微旋转;颜色类:全部保持原样。训练框架里如果有HSV增强参数,直接置零。
3.3 类别不均衡的处理思路
这批数据的类别分布并不均匀,打火机、刀类样本多,压力罐、液体容器样本少。直观影响就是样本多的类别AP高,样本少的类别AP低。处理办法我按优先级排序:
- 类别重采样:按有效样本数的倒数加权采样,让每个batch里稀有类别出现的比例不低于某个阈值。
- 复制粘贴增强:把稀有类别的小图复制粘贴到普通行李图像上,生成新的复合样本。
- 损失加权:如果框架支持,给稀有类别更高的分类loss权重。
三种方法我叠加使用后,压力罐类别的AP从原来的62%提升到79%,代价是打火机类别跌了1.5个点,总体看划算。关键是要在验证集上持续观察每个类别的AP曲线,不要只看整体mAP。
4. 训练过程实录:关键参数、评价指标和效果数据
4.1 训练命令与关键超参数解读
我用YOLOv8官方代码库训练,数据配置文件如下:
path: /data/xray_sec train: images/train val: images/val nc: 6 names: 0: knife 1: scissors 2: lighter 3: pressure_can 4: liquid_bottle 5: power_bank训练命令如下,几个关键参数我逐一解释:
yolo detect train \ model=yolov8s.pt \ data=xray_sec.yaml \ epochs=300 \ imgsz=1280 \ batch=16 \ optimizer=SGD \ lr0=0.01 \ lrf=0.01 \ weight_decay=5e-4 \ hsv_h=0.0 hsv_s=0.0 hsv_v=0.0 \ mosaic=1.0 \ close_mosaic=50imgsz=1280是最重要的调整,因为原图本身分辨率高,640会让小目标信息大量丢失。mosaic=1.0表示前250个epoch全程开启马赛克,但在最后50个epoch我设置了close_mosaic,让模型适应没有马赛克的真实分布。这在X光场景尤其关键:马赛克增强会把四个图的物体叠在一起,实际上进一步加剧了X光本来就很严重的“多物叠加”现象,训练早期能用但后期必须关闭。
weight_decay取5e-4,对2万张左右的数据集来说正则强度适中。优化器我偏好SGD而不是Adam类,因为SGD配合warmup和cosine学习率在检测任务上更容易收敛到低泛化误差的平坦区间。
4.2 训练过程的观察点:loss曲线和类别AP
训练过程中我不会只看总loss,而是每10个epoch记录一次各分类的AP和召回率。总loss平滑下降说明模型在整体学,但隐藏的问题是某些小类别可能从头到尾都没被充分学习。
一个值得注意的曲线特征是:train loss持续下降、val loss在120个epoch后开始走平或上升,这是过拟合的信号。X光数据集里的伪彩模式相对固定,模型很容易记住训练图里的特定材质纹理,而不是学到通用轮廓。遇到这种情况,我通常先停掉mosaic,再看loss变化;如果仍然过拟合,就降低模型规模或加dropout。
4.3 效果数据:mAP、召回率和误检的权衡
最终一版的效果数据如下表,类别AP差异能直观反映数据集不平衡的影响。
| 类别 | 样本数 | AP@0.5 | AP@0.5:0.95 |
|---|---|---|---|
| 刀类 | 6800 | 0.91 | 0.75 |
| 剪刀 | 5200 | 0.93 | 0.79 |
| 打火机 | 8100 | 0.88 | 0.66 |
| 压力罐 | 1500 | 0.79 | 0.54 |
| 液体容器 | 2600 | 0.86 | 0.63 |
| 充电宝 | 3900 | 0.82 | 0.61 |
整体mAP50约0.87,mAP50-95约0.66。单看mAP在安检场景里其实不够,更要关注误检率。推理时把conf_threshold从0.25降到0.1,召回率提升约3%,但误报数量翻了近一倍。安检场景对召回的要求极高,漏检的后果远比误报严重,所以我倾向于保持较低置信度阈值,再用二次校验环节过滤误报。这个双阈值策略在部署章节详细说。
5. 最容易翻车的五个细节:踩坑记录与规避策略
5.1 NMS和重叠目标的“框融合”
X光场景中两把折叠刀叠在一起,或者充电宝与金属框架叠合,模型会输出两个高度重叠的预测框。默认NMS会把IoU超过阈值的两个框直接合并成一个,导致一个违禁物被漏计。我第一次跑出来的“漏检”,大量就是这样造成的,而不是模型本身没检测到。
规避策略是调高NMS的IoU阈值。我用0.7替代默认值,显著降低了对重叠目标的合并概率。同时,针对刀类这种细长目标,我会额外做一次基于方向的后处理:两个细长框重合度用普通IoU不敏感,改用旋转框IoU作为补充判断。
5.2 伪彩图千万别乱做颜色增强
前面说过X光伪彩是材质编码,颜色增强必须关掉。这个坑我再细说一下具体症状:某次实验我保留了一部分随机色相增强,模型训练了200个epoch,mAP整体没掉多少,但可视化预测时发现大量刀类目标把柄的方向判断错误,边缘框住了旁边的不锈钢水杯。原因是水杯和刀柄在抖动后的颜色空间里被拉近了,模型学到了错误的材质关联。这个教训提醒我:在X光数据上做任何预处理,都要先问一句“这个操作会不会改变材质语义”。
5.3 小目标在降采样过程中消失
打火机、小刀、液体瓶盖这类目标在640分辨率下往往只有10到30像素。经过模型的多次降采样后,特征图上的有效响应非常弱,即便模型理论上有能力检测小目标,实际效果也大打折扣。除了提高输入分辨率,另一个有效方案是切patch推理:把原图切成若干重叠块,分别推理再融合结果。离线场景可以这样做,实时场景就得靠TensorRT和更大分辨率输入的部署优化来兜底。
5.4 样本不平衡导致的“单类塌陷”
训练初版模型时,压力罐类别只有1500个样本,打火机有8100个,结果压力罐类别AP一直上不去,而且在很多非压力罐目标上产生了大量误报。这是典型的类别不平衡导致的“多数类淹没少数类”。处理方案在3.3节已经给出了,这里只说教训:一定不能在训练前忽略类别分布统计。我后来在数据准备阶段就直接按类别做样本量直方图,一旦发现某类样本数不足,先从数据端补齐,再考虑调模型。
5.5 只有正样本,没有负样本,误报必然爆表
这批数据有个容易忽略的问题:几乎每张图都包含至少一个违禁物品标注,缺少大量“纯正常行李”图像。模型在训练时没见过足够多没有目标的正例,推理时就容易把行李箱里的普通金属物、复杂纹理误判成违禁物。应对方法是额外收集一批正常行李X光图像加入训练集,不给标注,让模型学会“无目标”的模式。如果一时收集不到大量负样本,也可以在验证集构造包含正常行李的混合场景,持续监控误报率。
6. 从训练到上机:模型部署、后处理和后续优化方向
6.1 导出ONNX并用TensorRT提速
训练完成只是第一步,现实安检场景对推理延迟有硬性要求。我先把YOLOv8s从PyTorch导出成ONNX,再转成TensorRT FP16引擎。设备上实测下来,1280x1280输入在主流GPU上能达到40到60毫秒一帧,如果是1080p原图裁剪成多patch再拼回结果,整体耗时约120毫秒,能满足慢速过包场景。
导出过程有几个细节要注意。一是动态维度要打开,因为实际图像的宽高比例不完全固定;二是FP16精度下,某些细小目标的置信度会轻微下降,需要对比验证集指标确认损失是否可接受。对精度要求更高的场景,可以保留FP32,但延迟会翻倍。
yolo export model=best.pt format=onnx dynamic=True imgsz=1280 trtexec --onnx=best.onnx \ --saveEngine=best_fp16.engine \ --fp16 \ --minShapes=images:1x3x1280x1280 \ --optShapes=images:4x3x1280x1280 \ --maxShapes=images:8x3x1280x12806.2 双阈值策略和人机复核闭环
安检场景对漏检零容忍,而任何模型都有不可避免的误报和漏检,所以实际部署不能单靠一个置信度阈值一杆子打死。我采用的方案是双阈值:预测置信度大于0.7的目标直接进入危险品告警队列;置信度在0.3到0.7之间的目标进入人工复核队列,由安检员看一眼确认;低于0.3的忽略。
这个策略把模型的“不确定区间”明确暴露给人机交互流程,既保留了高置信度目标的快速处置能力,又给了低置信度目标第二次被看见的机会。实测下来,将人工复核覆盖的目标范围收敛后,整体漏检率比单阈值下降了一个数量级。这套方案的核心不是模型,而是流程设计:让模型做初筛,让人的判断力兜底。
6.3 后续可以继续做的方向:蒸馏与小目标专门分支
最后提一个我验证过有效、而且值得继续深挖的方向:知识蒸馏。我用YOLOv8l作为教师模型,YOLOv8s作为学生模型,用教师输出的软标签加上真实标签一起训练学生。结果学生模型在压力罐这种小样本类别上的AP比单独训练提升了4.2个百分点,推理速度却保持和学生模型原始版本一致。
小目标方向上,我还在尝试给模型加一个专门的浅层检测分支,不参与最终全图的预测,只在特征金字塔的低层单独输出一些小尺度候选框,再统一送入后续判断。这个方向相对复杂,但考虑到X光场景中打火机、刀具尖端这类小物件的检测价值,值得持续投入。
就我个人的实操体会来看,做X光安检目标检测最大的难点其实不在于模型选型或调参,而在于能否丢掉自然图像检测的思维惯性。给模型喂数据、调参数都只是时间问题,真正拉开差距的,是你对X光图像物理语义的理解深度。