简介:基于深度学习的机场安检危险品识别项目包,面向深度学习课程设计或毕业设计场景,围绕YOLO与Faster R-CNN目标检测算法,提供从数据集整理、模型构建、训练调优到效果评估的完整工程思路。压缩包共含180个文件,其中63张jpg图像构成可视化的安检样本集,37个py脚本与53个pyc文件承载核心检测逻辑和编译依赖,另有xml标注文件、ui界面定义、bat启动脚本、gif演示录屏、说明文档等辅助内容,整体大小约9.72MB,目录结构合理,便于按需查找和快速部署。项目中包含图像预处理工具、Cython加速模块、网络配置文件及训练测试脚本,可支撑复现危险品识别流程,同时配套的标注数据与演示资料有助于理解CNN特征提取、边界框回归等关键概念,为课程报告或毕业论文提供实操依据。目前已有45人浏览学习,适合具备基础Python与深度学习知识、需要完整项目参考的入门及进阶学习者。
1. 机场安检危险品识别:为什么最后选了深度学习这条路线
一条安检传送带每分钟过包数量是固定的,图像留给算法的时间往往不到 0.5 秒。基于深度学习的机场安检危险品识别,并不是要做一个能识别所有违禁品的万金油模型,而是要在有限的算力里,把打火机、刀具、压力瓶从包裹 X 光图里稳定框出来并给到置信度。这个标题里的 zip 包,本质是数据、训练脚本和部署配置的组合,真正值钱的部分不在模型结构,而在数据怎么标、参数怎么调、推理怎么放到工控机上。
我最初做刀具检测用的是传统图像处理,靠轮廓和灰度阈值,结果遇到方向变化、遮挡和瓶罐重叠就翻车。换成深度学习之后,问题从“特征写不写得完”变成“样本够不够、标注对不对、部署端是不是和训练时一个管线”。这套方案适合两类人:一类是做安检设备软件、需要把识别算法接到现有传送带系统的工程师;另一类是刚接触工业视觉的开发者,想找一个能落地的目标检测练手项目。
2. 数据准备里的特殊门槛:安检图像标注格式与难例挖掘
2.1 为什么通用检测数据不能直接拿来训安检模型
很多人拿到代码包之后,第一反应是找一套公开的自然图像数据集,跑通了再换数据。这个思路在普通物体检测上没问题,放到安检场景就会踩坑。X 光安检图像是单通道灰度或伪彩色,物品之间有大量遮挡,透视关系也和自然图像完全不同;一把刀横放和竖放,在包里的灰度值差异极大,甚至和金属骨架、充电宝外壳长得几乎一样。通用检测数据里学到的纹理和颜色特征,在这里基本用不上。
所以我会坚持一个原则:先花时间整理安检领域的原始图像,而不是直接套用通用目标检测数据集。安检危险品识别的核心难点是类别分布极度不均衡——打火机、充电宝这类常见物品样本多,枪支、爆竹这类样本可能只有几百张。如果按随机顺序喂数据,模型会快速收敛到多数学类别,少数学类别干脆不输出。常见做法是做一个类别重采样器,让每个 epoch 都能看到足够比例的稀有类别。
另外,安检场景的评估指标和通用检测不一样。自然图像检测看 mAP,漏检一个行人可能无所谓;安检场景漏检一把刀就是事故。所以我会在验证阶段重点看两个指标:一个是各类别的召回率(Recall),另一个是误报率。调参时先保证召回,再想办法压低误报。这也是后面部署时单独设阈值的原因之一。
2.2 把标注转成 YOLO 格式:最小转换脚本与四个坐标边界
这类代码包里的数据标注格式,最常见的是 VOC 的 XML 或者 JSON。而训练脚本读取的格式一般是 YOLO 的归一化 TXT,也就是每行一个“类别编号 cx cy w h”。我这里给一个最小转换脚本,只处理单标签框,多标签场景需要自己扩展。
import os import xml.etree.ElementTree as ET # 这个类别列表必须和训练配置里的 names 顺序完全一致 classes = [ "knife", "lighter", "bottle", "power_bank", "gun" ] def voc_to_yolo(xml_path, out_dir): tree = ET.parse(xml_path) root = tree.getroot() size = root.find("size") img_width = float(size.find("width").text) img_height = float(size.find("height").text) image_name = root.find("filename").text label_name = image_name.replace(".jpg", ".txt") with open(os.path.join(out_dir, label_name), "w") as out_file: for obj in root.iter("object"): cls_name = obj.find("name").text if cls_name not in classes: continue # 过滤掉配置之外的类别 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) x_center = (x1 + x2) / 2.0 / img_width y_center = (y1 + y2) / 2.0 / img_height box_width = (x2 - x1) / img_width box_height = (y2 - y1) / img_height # 防止标注越过图幅边界,导致训练时出 NaN x_center = min(max(x_center, 0.0), 1.0) y_center = min(max(y_center, 0.0), 1.0) box_width = min(box_width, 1.0 - x_center) box_height = min(box_height, 1.0 - y_center) out_file.write( f"{classes.index(cls_name)} {x_center:.6f} " f"{y_center:.6f} {box_width:.6f} {box_height:.6f}\n" ) if __name__ == "__main__": # 第一个参数是 XML 目录,第二个是输出 TXT 目录 voc_to_yolo("xmls", "labels")这个脚本的逻辑很简单,但有两个地方容易出错。第一,尺寸字段必须转成 float 再除,很多标注文件把 width 写成整数,直接除会把中心点算成 0 或 1。第二,clip 这一步不能省,安检图像的标注里偶尔会出现 xmin 为负、xmax 超过图宽的情况,如果不限制范围,训练 loss 会莫名跳高。类别编号必须从 0 开始,和训练配置文件的 names 顺序一致,否则全部串类。
2.3 难例挖掘的参数策略:类别重采样与背景负样本
我先给一个简易的类别重采样脚本,它的思路是统计每个类别出现的图像数,然后让包含稀有类别的图像有更高概率被选中进入每个 epoch。
import random # 假设 classes 顺序与训练配置一致 classes = ["knife", "lighter", "bottle", "power_bank", "gun"] class_count = {name: 0 for name in classes} all_images = [] # 所有图像路径 all_labels = [] # 对应的标注路径 for label_file in all_labels: seen_in_image = set() for line in open(label_file): cls_id = int(line.split()[0]) class_count[classes[cls_id]] += 0 seen_in_image.add(classes[cls_id]) # 一张图同时含多个类别时,每个类别都计一次出现 for cls_name in seen_in_image: class_count[cls_name] += 1 # 每个类别期望最少出现 1500 次,样本多的类别不再提高 target_count = 1500 sample_ratio = { name: min(1.0, target_count / class_count[name]) for name in classes } selected = [] for img_file, label_file in zip(all_images, all_labels): max_ratio = 0.0 for line in open(label_file): cls_id = int(line.split()[0]) max_ratio = max(max_ratio, sample_ratio[classes[cls_id]]) # 如果这张图含稀有类,max_ratio 会接近 1.0,几乎必选 if random.random() < max_ratio: selected.append((img_file, label_file))这个采样器不是精确控制数量,而是权重式筛选,适合代码包里的快速迭代。target_count 可以按你的实际样本量调整,建议不低于 1000。还要注意负样本,也就是没有违禁品的普通包裹图像。训练集里至少要留 10% 到 15% 的纯背景图,否则模型会倾向于把所有金属物体都当成危险品,部署时误报率高到你不敢开。
3. 训练与调参:从预训练权重到收敛判断
3.1 模型选型:分辨率、检测头和小目标的关系
安检图像普遍分辨率高,但危险品在整张图里的占比很小。一个打火机可能只有 20×10 像素,如果按默认 640 输入分辨率训练,这个目标在特征图上只剩一两个像素点,基本学不到。我一般会把输入分辨率调到 960 或 1280,具体看显卡显存。若显存不够,优先保分辨率而不是加大 batch,因为小目标的召回率对分辨率更敏感。
模型结构方面,常见做法是选用带 4 个检测头的轻量网络。普通目标检测模型通常只有 3 个检测头,最小 stride 是 8,对 20 像素的目标已经不敏感。安检场景需要在 stride 4 的浅层特征图上多接一个检测头。代价是计算量增加,但换来的小目标召回提升非常明显。如果代码包里的模型没有 P2 检测头,我会建议先把输入分辨率拉高,再用泛化能力强的预训练权重。
选型还要考虑部署硬件。如果安检机工控机只有 CPU,就不适合跑复杂的可变形卷积,最好选一个轻量主干加简单检测头;坚持用重型模型会导致帧率太低,现场根本没法用。我个人的顺序是:先定部署硬件,再倒推模型结构,最后再谈精度。
3.2 训练命令与关键超参说明
我用一个典型的训练命令来演示参数设置。不同代码包的命令格式略有差异,但核心超参是通用的。
python train.py \ --model safety_net_base \ --data data/safety.yaml \ --pretrained weights/pretrained_backbone.pth \ --imgsz 1280 \ --batch 64 \ --epochs 160 \ --optimizer AdamW \ --lr0 0.001 \ --lrf 0.01 \ --warmup_epochs 3 \ --patience 20 \ --save_dir runs/security这里几个参数需要重点说明。imgsz 是输入图像边长,我习惯设 1280,显存小的改成 960 也可以。batch 64 是在多卡或大显存场景下的值,单卡建议从 16 试起,梯度不稳定时降低 batch。lr0 是初始学习率,AdamW 下用 0.001 比较稳;如果 loss 一开始就炸,把 lr0 降到 0.0005。
warmup_epochs 3 表示前三个 epoch 用较小学习率热身,避免预训练权重被大步长破坏。lrf 0.01 是学习率衰减到初始值的 1%,配合余弦退火用。patience 20 是早停策略:验证集指标连续 20 个 epoch 不提升就停止训练。我建议不要关掉早停,安检数据集规模不大,训练到 100 个 epoch 之后很容易过拟合。
3.3 怎么判断训练收敛而不是过拟合
很多人只盯着训练 loss,看到 loss 降到 0.05 就觉得训练结束了。实际上训练 loss 低不代表部署能用,尤其是安检这种类别不均衡场景,模型很可能记住了背景纹理而不是危险品特征。我会用一个验证命令来输出每个类别的召回率。
python val.py \ --model runs/security/weights/best.pt \ --data data/safety.yaml \ --imgsz 1280 \ --conf 0.25 \ --iou 0.5 \ --save_json results/security_val.json验证完成后,打开 JSON 看每个类别的 recall@0.5。如果 knife 的召回率 0.7,lighter 的召回率 0.3,说明模型对小目标类别根本没学会。此时不要再调大 epoch,而应该看训练过程中的 PR 曲线,把当前置信度阈值降到 0.2 重新评估,看召回是否明显上升。
如果训练 loss 下降但验证 mAP 一直不动,大概率是验证集分布和训练集不一致,比如训练集里全是单件物品特写,验证集里全是复杂包裹。这种情况要回到数据准备阶段重新切分。另一个常见问题是模型过拟合,表现为训练 loss 极低、验证 loss 回升,这时候需要增强数据增强、缩小学习率,或者把训练集里的重复样本去重。
4. 部署到安检机工控机的流程:模型压缩与推理加速
4.1 先摸清目标硬件,再决定推理框架
训练可以在服务器上慢慢跑,部署必须放在安检机旁边的工控机里。常见部署环境有两种:只有 CPU 的旧工控机,和带一张低功耗独立显卡的新设备。CPU 环境我会把模型导出成 ONNX,再转 OpenVINO 或 NCNN;有显卡则用 TensorRT 做 FP16 推理。不要一上来就用 PyTorch 直接跑推理,那在 CPU 上基本没法用。
| 部署环境 | 推荐路线 | 目标单帧耗时 |
|---|---|---|
| 只有 CPU 的工控机 | PyTorch → ONNX → OpenVINO | 300ms 内 |
| 低功耗独立显卡 | PyTorch → ONNX → TensorRT FP16 | 80ms 内 |
| 嵌入式板卡 | TensorRT INT8 或 PTQ 量化 | 150ms 内 |
上面只是参考值,实际耗时取决于分辨率和检测头数量。我见过有人为了省事直接用原始模型加多线程跑,结果 CPU 占用拉满、每帧 1 秒,这种部署方案上线即翻车。正确做法是先确认硬件支持哪些推理库,再决定量化方式。
4.2 导出 ONNX 与动态维度配置
以 PyTorch 为例,导出 ONNX 时要特别注意动态维度。安检机推理时批大小固定为 1,但每帧检出的目标数量是变化的,所以输出维度的 num_dets 必须设成动态。
import torch model = load_model("runs/security/weights/best.pt") model.eval() dummy_input = torch.randn(1, 3, 1280, 1280) torch.onnx.export( model, dummy_input, "safety_best.onnx", input_names=["images"], output_names=["boxes", "scores", "labels"], dynamic_axes={ "images": {0: "batch"}, "boxes": {0: "batch", 1: "num_dets"}, "scores": {0: "batch", 1: "num_dets"}, "labels": {0: "batch", 1: "num_dets"}, }, opset_version=12 )这里我把 images 的 batch、boxes 和 scores 的 num_dets 都设成了动态轴,适应不同帧的目标数量。opset_version 用 12 而不用太高,是为了兼容老版本推理引擎;如果你用最新 TensorRT,保持默认即可。导出后一定要验证输出名字和模型实际输出一致,很多代码包里的模型有额外输出层,名字对不上会导致部署端解析失败。
4.3 推理服务的预处理与后处理参数
部署端最容易翻车的地方不是模型,而是预处理和后处理。训练时图像经过随机缩放和 padding,部署时如果跳过这一步,模型的输入分布直接改变,精度立刻掉一截。我习惯把 letterbox 的 pad 值、缩放比例保存下来,推理完再映射回原图坐标。
def postprocess(pred_boxes, pred_scores, pred_labels, conf_threshold=0.30, iou_threshold=0.45, pad_x=0.0, pad_y=0.0, scale_x=1.0, scale_y=1.0): keep = pred_scores >= conf_threshold boxes = pred_boxes[keep] scores = pred_scores[keep] labels = pred_labels[keep] # 模型输出是 letterbox 后坐标系,需要减去 pad 再除以缩放比 boxes[:, [0, 2]] = (boxes[:, [0, 2]] - pad_x) / scale_x boxes[:, [1, 3]] = (boxes[:, [1, 3]] - pad_y) / scale_y return boxes, scores, labels这个函数里,conf_threshold 我先设 0.30,现场误检多就往 0.4 调,漏检多就往 0.2 调。iou_threshold 用 0.45 左右,不能调太低,因为安检包裹里刀具和金属骨架经常紧贴着,NMS 太激进会把两个目标合成一个。还有一点,不要在部署端做额外的图像增强,比如锐化、对比度拉伸,训练时没有做过这些操作,加了只会让预测结果失真。
5. 落地中最容易踩的五个坑与排查方法
5.1 训练时 mAP 很高,现场误检一堆
现象:验证集 mAP 达到 0.9,但接到安检机实时图像后,每个包裹都报警,根本没法用。原因:训练和部署的预处理不一致,现场图像没有经过训练时的归一化流程,对比度分布差异太大。另外,验证集里可能混入了和训练集同批次的连续帧,存在数据泄漏。
解决:先把训练脚本里的预处理函数完整复制到部署服务,包括灰度归一化的均值和方差,不能只在部署端做减 127 除 255。然后在部署前跑一次“回放测试”:用现场采集的 500 张非训练图输入模型,统计误报率。如果误报依然高,就去查是不是验证集切分出了问题,按包裹维度而不是按图像帧维度切分。
5.2 打火机这类小目标一直漏检
现象:大件刀具能稳定检出,打火机、小刀片这类小目标几乎全部漏检。原因:输入分辨率太低,模型在小目标上的特征已经丢失;或者置信度阈值偏高,小目标的分数普遍在 0.2 到 0.3 之间。
解决:先把 imgsz 从 640 调到 1280,再看小目标召回有没有变化。如果显存不够,就换一个带额外检测头的轻量模型。最后把 conf_threshold 单独给小目标类别设低一点,比如打火机用 0.2,刀具用 0.35,通过部署端的后处理按标签分别处理。
5.3 类别分布不均衡,模型只认识高频类
现象:训练 loss 正常下降,但 gun 类别的召回率接近于零,查看验证结果时发现模型根本没有预测出这个类别。原因:数据集中稀有类别样本太少,模型在训练时只见过几次,梯度被高频类别淹没,最后把稀有类别全部判定为背景。
解决:回到数据准备阶段,对稀有类别做图像级重复采样,让每个 epoch 都能看到 300 张以上。分类损失里加上类别权重,例如数量最少的类别权重设 5.0,打火机这类最多的类别权重设 1.0。如果代码包支持 focal loss,把 alpha 设置为类别比例的反比。
5.4 导出 ONNX 后输出 shape 不对,部署端崩溃
现象:TensorRT 构建时报错,或者推理时输出数组长度为零。原因:动态轴设置不一致,模型导出时有的输出是原始检测头输出,有的已经被封装成了后处理结果;也可能是模型里的某些算子在推理框架里不支持,比如可变形卷积。
解决:导出前先把模型的后处理拆掉,只保留 backbone 和检测头的原始输出,把 NMS 放到部署端自己实现。动态轴只保留 batch 和 num_dets,不要动通道维度。如果某个推理框架仍然报算子不支持,优先把可变形卷积换成普通卷积,代价是几个 mAP 点,但换来可部署性。
5.5 一个阈值打天下,调高误检多、调低漏检多
现象:所有类别共用一个置信度阈值,阈值高了漏掉打火机,低了误检爆炸。原因:不同类别的置信度分布完全不同,安检场景里有些类别表现强,有些类别天然分数低。
解决:在部署后处理里按类别设定独立阈值。例如 gun 类样本少、误检代价高,用 0.5;打火机类要求高召回,用 0.2;bottle 类介于中间,用 0.3。这样整体误报可控,同时保证关键类别不漏。这个策略要在后端配置里写成可调整的白名单,方便安检员现场改。
6. 最后一个技巧:用现场的误报闭环做增量训练
最后一招,也是我能稳定跑下去的救命招:把现场每一次误报和漏报都收集起来,定期合并进训练集做增量训练。具体做法是,在部署服务的日志里记录每一帧的检测框、置信度和原始图像,安检员确认是误报的样本单独归档。每周挑几十张代表性误报图,重新标注并合并到训练集。
我给自己定了一个验证清单,每次重新训练前必须过一遍:新样本要覆盖至少 20 张每类;训练完成后跑历史误报库全量回放,误报率必须下降而不是转移;再跑一遍旧的测试集,mAP 下降不能超过 0.5%。这一步很关键,因为只加新样本往往会“纠正”一类误报、又引入另一类新误报。
| 步骤 | 操作 | 通过标准 |
|---|---|---|
| 1 | 从现场日志抽取误报图并标注 | 新增样本每类不少于 20 张 |
| 2 | 合并进训练集重新训练 | 新模型召回不低于旧模型 |
| 3 | 历史误报库全量回放 | 误报率下降 30% 以上 |
| 4 | 部署端灰度运行 24 小时 | 单帧耗时不超标、内存稳定 |
我现在每次改模型,都会先跑一遍历史误报库,而不是只盯新数据。有一次就是只测新采集的样本,把旧版本已经处理好的水瓶误检给练了回来,现场直接翻车。从那之后,这个回放习惯救了我很多次。它不玄学,也不需要多大的工程成本,就是一个带时间戳的文件夹、一个验证脚本、一条“先回放再上线”的纪律。希望帮到你。
本文还有配套的精品资源,点击获取