简介:面向红外小目标检测场景的飞机数据集,适合计算机视觉学习者与研究者用于模型训练和算法验证。数据采用YOLO格式组织,train与val分别包含16551张和4952张样本,类别仅飞机一种(nc=1),可配合YOLO系列直接开展训练与评估。压缩包内共2000个文件,大小37.4MB,以926个xml标注、926个txt标签、147个bmp红外图像为主,另含1个cache缓存文件;xml与txt分别为VOC和YOLO格式的标注信息,bmp为原始红外图像,整体目录结构清晰,便于按需拆分使用。目前已有169人学习,数据量适中,既能用于快速验证小目标检测思路,也可作为算法对比的基准数据集。使用者可直接获得带双格式标注的红外飞机样本集,省去自行采集与标注的流程,专注网络结构改进与训练策略调优。
1. 红外小目标飞机检测数据集:train 前先明白为什么普通数据集救不了你
我第一次拿红外场景去训练飞机检测时,还在用可见光下的航拍飞机照片拼数据集。模型在验证集上的 mAP 很好看,可一接到红外机芯输出的实时流,目标一出现就被当成噪声滤掉了。后来我才反应过来:红外小目标飞机检测数据集里的“小”和“红外”两个字,直接把可见光目标检测的整套假设推翻了一半。目标可能只有 2 到 9 个像素,没有纹理、没有颜色,只有比背景高一点点的灰度响应,而背景里的云边缘、地物亮带和传感器噪声随时比目标更显眼。这个 train 环节真正要解决的,不是你跑通一套 YOLO 训练流程,而是先让数据本身匹配“红外小目标”的物理特性,再谈网络能不能收敛。这篇笔记就按我实际趟过的路,把数据集怎么选、怎么造、怎么打标签、怎么训练和怎么验收一次讲清楚,适合刚拿到红外视频不知道从哪下手的人,也适合正在被 loss 不降和虚警率折磨的熟手。
2. 红外小目标数据集 train 的特殊性:小、低信噪比、强背景杂波
2.1 为什么“小”和“红外”两个字直接改变数据集设计
普通飞机检测数据集里,飞机一般都占几十到几百像素,你甚至能看清机翼、尾翼和机身轮廓。红外小目标场景里,目标在 640×512 的探测器画面上往往只占 3×3 或 5×5 像素,中波红外下几百米外的无人机可能只有 1 到 2 个像素。这么小的目标,检测框退化成对中心点的定位,网络学不到任何形状信息,能学到的就是目标相对周围背景的灰度对比度。这带来一个直接后果:数据集的标注方式、增强方式和损失函数设计都要跟着变,不能照搬可见光大目标那一套。
第二层特殊性是信噪比。红外图像是单通道灰度图,目标是叠在背景杂波上的一个小起伏。云层边缘、地面高温物体、海面耀光都可能产生和目标相似甚至更强的局部响应。一个优秀的数据集必须具备足够的“难例”:背景里有大量类目标干扰,模型才会学会区分真实的航迹特性和静止亮斑。如果数据只挑晴朗天空下清晰目标来采,训练出来的模型一到复杂背景就虚警爆炸。
第三层是时间维度。红外小目标检测在工程落地时很少处理单帧,更多是处理连续视频帧,靠目标的运动连续性和帧间一致性滤掉随机噪声。所以数据集的采集单元最好按视频片段组织,而不是单张图片。这样你在训练时既可以做单帧检测,也可以给后续时序模型留出余量。
2.2 可用公开数据集与自建数据的选型标准
圈内常被拿来训练红外小目标的公开集包括 IRDST、NUDT-SIRST、SIRST 等,它们的共同点是目标尺寸小、背景类型覆盖天空/地面/海面。你可以优先找这样的公开集做 baseline 验证,但真实工程项目最终基本都要自建数据,因为公开集的相机波段、分辨率、目标类型和你的设备不一定匹配。选型时我从三个维度过滤:第一,保存格式必须是 16 位单通道 TIFF、PNG 或原始 raw,不能是已经压缩过的 8 位 JPG;第二,数据里要标出真值框或真值点,最好附带目标在帧间的运动轨迹;第三,背景必须包含复杂杂波样本,不能全是“干净蓝天”,否则模型花两天训完也过不了实测。
如果连合适公开集都找不到,自建数据的第一步是从设备厂家拿原始视频流。我一般会要求现场采集三种场景:远距离高空目标、低空复杂地物背景目标、近距离小目标。每种场景录 5 到 10 分钟,覆盖不同气象和不同时间段。录制时尽量让目标从画面边缘进入,这样能保留目标从无到有的完整响应过程,对后续数据增强和真值标注都友好。
2.3 用脚本检查数据是否可用
拿到红外视频后别急着抽帧,我习惯先写一个极短脚本检查帧的位深和动态范围,判断这份数据到底能不能训。红外原图经常是 14 位或 16 位,直接按 8 位读入会把弱目标压没。下面是检查单个文件的代码:
import cv2 import numpy as np path = "sample_raw_0001.tiff" img = cv2.imread(path, cv2.IMREAD_UNCHANGED) print("shape:", img.shape, "dtype:", img.dtype) print("min:", img.min(), "max:", img.max(), "mean:", img.mean())逻辑不复杂,关键在于cv2.IMREAD_UNCHANGED必须带上,否则 OpenCV 默认按 8 位读,16 位图的高灰度信息和弱目标细节全被截断。如果输出 dtype 是 uint8,说明源数据已经被转成 8 位或保存时被压缩,这种数据对小目标检测基本是废的。灰度范围方面,min 和 max 如果相差非常小,说明该帧要么没有目标,要么动态范围被压缩过;mean 能帮你快速判断图像整体亮度分布。一段红外视频里如果大部分帧的动态范围只有 20 到 30 个灰度级,先不要判断为“无目标”,因为红外系统可能做了自动增益控制,目标只占其中几个灰度级的起伏,需要用局部对比度的方式再看。
3. 从原始红外视频到可训练数据集:抽帧、筛选与预处理脚本
3.1 按运动片段抽帧而不是均匀抽帧
自建数据集最常见错误是每隔 N 帧均匀采一张,结果就是视频里前 1000 帧目标都在画面中央,后 1000 帧全是纯背景。均衡性差导致模型学到的是位置偏差而不是目标特征。我的做法是先抽运动片段:把视频按目标出现、运动、消失的过程切成片段,再在每个片段里抽帧。这样既能控制目标尺度变化,也能确保背景多样性。
import cv2 import os video_path = "ir_aircraft_01.avi" cap = cv2.VideoCapture(video_path) fps = cap.get(cv2.CAP_PROP_FPS) frame_id = 0 save_dir = "frames_raw" os.makedirs(save_dir, exist_ok=True) while True: ret, frame = cap.read() if not ret: break if frame_id % max(1, int(fps / 3)) == 0: out_path = f"{save_dir}/seq01_{frame_id:06d}.tiff" cv2.imwrite(out_path, frame) frame_id += 1 cap.release()这里的抽取频率不是固定每隔多少帧,而是跟着目标运动速度走。目标是慢速移动的远距飞机时,我一般按 3 到 5 帧间隔抽,保证相邻帧目标位移不超过目标自身尺寸;目标快速掠过时则提高到每秒 10 帧以上,否则相邻帧之间目标跳变太大,时序一致性丢失。保存格式直接用 TIFF 或 PNG,不要用 JPG,这是给后面训练留的后悔药。
3.2 初筛:把完全无目标的背景帧排除掉
红外视频里大量帧是纯背景,把它们全部送进训练集只会拉高负样本比例。简单粗暴的筛选方式是做帧间差分或局部最大响应急诊检测。我的经验是先用差分法粗略判断帧里是否有运动目标,再用人工确认保留真正有用的帧。
import cv2 import numpy as np prev = None for fname in sorted(os.listdir("frames_raw")): path = os.path.join("frames_raw", fname) img = cv2.imread(path, cv2.IMREAD_UNCHANGED) img = cv2.normalize(img, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8) if prev is None: prev = img continue diff = cv2.absdiff(img, prev) score = float(np.max(cv2.GaussianBlur(diff, (5, 5), 0))) if score > 8.0: print(f"{fname}: motion score {score:.1f}, keep") prev = img这里的思路是目标运动会在差分图里形成局部高响应,而慢变的背景杂波在相邻帧之间几乎不动。阈值 8.0 是我在 640×512 尺寸、3 帧间隔下的经验值,它不是一个通用常数。你拿到自己的数据后,先跑一遍输出 score 分布,再根据直方图选阈值。阈值选太低会把带噪声的空背景帧放进来,选太高会漏掉慢速小目标,所以要花十分钟调。初筛通过的帧只是候选集,后续仍需要人工看着标注。
3.3 预处理:分位数拉伸与局部背景抑制
红外原图动态范围往往集中在很窄的灰度区间,直接暴力 min-max 拉伸会把噪声抬得很高。我一般用分位数拉伸:把灰度 1% 和 99% 分位作为映射上下界,中间主要信息保留,两端的极亮噪声被压掉。这一步对检测模型收敛速度影响很大。
def quantile_stretch(img, low=0.01, high=0.99): lo = np.quantile(img, low) hi = np.quantile(img, high) out = (img - lo) / max(hi - lo, 1e-3) * 255.0 return np.clip(out, 0, 255).astype(np.uint8)预处理后还要做一层局部背景抑制。红外小目标的本质是局部对比度异常,用一个稍大尺度的均值滤波估计背景,再把原始图和背景图相减,能明显突出目标。这个步骤在训练时可以以数据增强形式加入,也可以提前做成离线的“去背景图”。但我提醒一句:不要对所有帧都做背景抑制后再训练,否则网络会依赖“目标永远在干净背景上”的假象,到了真实场景反而不适应。正确做法是保留一部分原始灰度图、一部分背景抑制图混合训练。
4. 把飞机检测数据打成 YOLO 能吃的标注:掩膜转 txt 与坐标精度坑
4.1 标注格式选择:点、框还是掩膜
红外小目标因为尺寸太小,标注格式的选择直接影响训练上限。对 3×3 像素的目标来说,紧密包围框和中心点几乎没区别,所以我通常建议用“中心点 + 目标尺寸”的记录方式,或直接标 5×5 的固定小框。这么做的好处是避免标注员手抖带来的框偏移被放大。但 YOLO 系需要的是归一化的矩形框,所以即使目标只有 3 像素,也必须算成 cx、cy、w、h 四值。
如果数据源已经给了二值掩膜,可以用连通域分析自动生成框,再人工校对。下面是掩膜转 YOLO txt 的脚本,这是把现有公开集转成 YOLO 格式时最常用的手段:
import cv2 import numpy as np import os mask_dir = "masks" save_dir = "labels_yolo" os.makedirs(save_dir, exist_ok=True) for mask_name in os.listdir(mask_dir): mask = cv2.imread(os.path.join(mask_dir, mask_name), cv2.IMREAD_GRAYSCALE) mask = (mask > 127).astype(np.uint8) n, labels, stats, centroids = cv2.connectedComponentsWithStats(mask, connectivity=8) h, w = mask.shape lines = [] for i in range(1, n): x, y, bw, bh, area = stats[i] if area < 2: continue cx = (x + bw / 2.0) / w cy = (y + bh / 2.0) / h nw = bw / w nh = bh / h # 防止归一化后宽度为 0,这是小目标最常见的坑 nw = max(nw, 1e-6) nh = max(nh, 1e-6) lines.append(f"0 {cx:.6f} {cy:.6f} {nw:.6f} {nh:.6f}") base = os.path.splitext(mask_name)[0] with open(os.path.join(save_dir, base + ".txt"), "w") as f: f.write("\n".join(lines))这段代码里有两处要特别说明。第一处是connectedComponentsWithStats会把每个连通域都当成候选目标,如果掩膜里有噪声残留,这里就会多生成伪目标框,所以转之前先把掩膜做一次形态学开运算。第二处是归一化后保留 6 位小数。4×4 像素的目标在 512×512 图像里宽度归一化后是 0.0078125,如果文件里四舍五入成 0.008,看着误差不大,但配合某些数据加载器在 resize 时做取整,目标可能整个偏移出有效区域。低于1e-6的宽度直接丢弃,因为那已经是亚像素目标,训练没有意义。
4.2 可视化验证是必须做的一步
标注转完之后,我会强制自己跑一遍可视化脚本,把标签叠加到原图上保存成视频或图片。这一步能发现大量“看着没毛病,训练时全丢”的问题。
import cv2 import os img_dir = "images_train" lab_dir = "labels_yolo" out_dir = "check_vis" os.makedirs(out_dir, exist_ok=True) colors = [(0, 0, 255), (0, 255, 0)] for img_name in os.listdir(img_dir): img = cv2.imread(os.path.join(img_dir, img_name)) h, w = img.shape[:2] base = os.path.splitext(img_name)[0] lab_path = os.path.join(lab_dir, base + ".txt") if not os.path.exists(lab_path): continue with open(lab_path) as f: lines = f.readlines() for line in lines: parts = line.strip().split() if len(parts) < 5: continue cx, cy, bw, bh = map(float, parts[1:5]) 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), colors[0], 2) out_path = os.path.join(out_dir, img_name) cv2.imwrite(out_path, img)可视化时特别留意两点:一是框是否真的套在目标上,小目标出现一两个像素偏移在放大后特别明显;二是那些面积小于 1×1 像素的框会在绘制时变成一条线,一眼就能看出来。我见过有人把 1000 张标注转完,不检查直接开训练,跑了两天发现 loss 一直很高,拉出来可视化发现有一半框中心落在目标旁边 3 个像素处,导致网络始终学不到准确中心点。这一小时检查时间绝对省不得。
4.3 数据集目录结构与 train 划分
工程实践中我习惯把目录固定成下面这样,方便后续接不同训练框架:
ir_dataset/ images/ train/ # 用于训练的原始红外灰度图 val/ # 验证集 test/ # 最终测试集,不参与调参 labels/ train/ val/ test/ sequences/ # 保存原始视频片段索引,便于追溯划分时不能随机打散,要根据视频片段划分。同一个视频片段里的相邻帧内容高度相似,如果既出现在 train 又出现在 val,验证指标会虚高,真实场景表现却一塌糊涂。按“整段片段”划分是红外小目标数据集的铁律。
5. 红外小目标飞机检测训练避坑:四个高频翻车现场
5.1 翻车现场一:帧存成 8 位 JPG,目标在增强阶段直接消失
现象:训练 loss 正常下降,但验证时所有目标的置信度都不超过 0.1,漏检率超过 90%。
原因:数据预处理阶段把 16 位红外帧转成了 8 位 JPG。小目标本身占的灰度对比度往往只有 5 到 10 个灰度级,JPG 的有损压缩会把这种弱对比吃掉,加上 8 位动态范围本身不足以表达红外机芯的 14 位原始响应,目标等于从源头上被删掉了。
解决:所有红外训练图统一保存为 TIFF 或 PNG 16 位。在前面的预处理脚本里把输出格式固定为 .tiff,并在训练配置里使用cv2.IMREAD_UNCHANGED或专门的红外读取器。如果原始数据已经是 8 位 JPEG,没有后悔药,只能回采集现场要 16 位原始视频。
5.2 翻车现场二:YOLO 预训练权重开局,模型死磕纹理特征
现象:用 COCO 预训练的 YOLOv8 权重直接跑红外数据集,前几个 epoch loss 下降极快,但随后验证集 mAP 停滞在 0.2 左右,测试时把大片云层边缘当成目标。
原因:预训练权重学的是可见光图像的纹理、颜色和形状特征,而红外小目标几乎没有纹理,只有弱灰度对比。网络前几层卷积核仍然在看“飞机长什么样”,而不是看“这个点和周围背景的区别有多大”,于是把注意力分给了背景杂波。
解决:常见做法是修改预训练权重第一层 Conv 的输入通道。把 3 通道红外灰度图复制到 RGB 三个通道喂进去,或直接把第一个卷积层的权重复制三份取平均。我一般倾向于后者:加载权重后把model.model[0].conv.weight的通道维度从 3 变 1,用三个通道的均值初始化。这样既保留了一部分底层特征提取能力,又避免模型在无用纹理上过度拟合。
5.3 翻车现场三:Mosaic 增强把小目标拼成“幽灵”
现象:训练过程中 redis 曲线在 20 个 epoch 后开始震荡,验证集小目标召回率忽高忽低,loss 波动明显。
原因:YOLO 系列默认开 Mosaic,把四张图缩放拼接成一张。本来是提升可见光大目标泛化的手段,但小目标被缩放后可能小于 1 像素,增强图里的目标直接消失,而模型在增强后的图上计算 loss 时,会因为目标过小产生极高的正负样本不平衡。
解决:红外小目标训练时我通常关闭 Mosaic,或者把它的使用概率降到 0.2 以下。copy_paste增强也要关掉,因为复制粘贴一个 3×3 的小块到背景上,只会在粘贴边界制造伪目标。保留的增强是轻微随机平移(平移范围不超过目标尺寸)、随机翻转和模拟红外噪声叠加。这些增强要写在配置文件里,例如 YOLOv8 的augment参数里把mosaic=0.0,然后单独叠加一层高斯噪声作为红外传感器噪声模拟。
5.4 翻车现场四:负样本太多,loss 被背景“平均”掉
现象:训练很多个 epoch 后 loss 稳定在某一数值不再下降,看起来已经收敛,但对目标的置信度普遍偏低。
原因:红外数据集里真实目标太稀疏,一张图往往只有一到两个目标,而背景区域有几十万像素。模型简单地把所有区域都预测为背景,loss 也不会太高。尤其当负样本中掺杂着大量云层边缘、地物亮斑时,模型学会了“都不是目标”,但没学会“什么是目标”。
解决:第一,训练时裁剪 patch,把 640×640 原图切成 4 到 8 个 256×256 的 patch,保证目标在每个 patch 里的相对占比提高,同时增加包含目标的 patch 在训练中的采样权重。第二,使用 focal loss 或给分类损失增加正样本权重,让网络把注意力偏向少数正样本。第三,在数据集中额外构建一批“复杂背景难例”,只包含云层边缘、亮斑但没有真实目标,单独作为负样本加入训练。让网络被迫学习区分“看起来像目标”和“真实目标”。
6. 用连续帧和信噪比验证模型:一个十分钟脚本化评测
训练结束后的验证不能只看 mAP。红外小目标检测部署时,真正让你头疼的是虚警率和漏检率在连续帧上的表现。我固定用一个简单脚本来做“连续帧稳定性评测”,把测试视频逐帧跑模型,记录每帧的检测框和置信度,然后统计目标的检出率和不存在目标的帧里出现的虚警框数。
import cv2 import numpy as np from ultralytics import YOLO model = YOLO("best_ir.pt") cap = cv2.VideoCapture("test_sequence.avi") total_frames = 0 true_positives = 0 false_alarms = 0 while True: ret, frame = cap.read() if not ret: break total_frames += 1 results = model.predict(frame, conf=0.25, imgsz=640, verbose=False) for r in results: boxes = r.boxes # 实际项目中你会在这里跟真值轨迹做匹配 # 简化为:置信度超过阈值且位置落在真值框附近视为正确检出 if len(boxes) == 0: false_alarms += 1 cap.release()这个验证的价值在于:单帧 mAP 高的模型,在连续帧上可能因为目标偶发丢失被下游跟踪器直接杀死。真实部署时我还会搭配局部信噪比指标——把模型检出的每个框位置取出来,计算框内灰度峰值与周围背景均值的比值。如果模型检出的虚警点信噪比普遍低于 3 dB,说明它在找“噪声尖峰”,而不是真实目标。这两步一共只花十分钟,但能帮你在上线前判断这个模型是“能用”还是“只能发论文”。
我还保留一个习惯:每次训练完,把测试序列里模型漏检最严重的前十帧截图归档。下次调参时先看这些截图,优先解决它们,而不是盲改网络结构。红外小目标这条路的坑基本都是脏数据、弱对比和参数失衡造成的,模型结构本身的提升空间远没有你想的大。希望帮到你。
本文还有配套的精品资源,点击获取