☰
小样本图像识别实战:240张火焰烟雾数据训练三分类模型
2026/10/5 16:26:21 网站建设 项目流程

简介:这份图像识别数据集面向深度学习初学者与计算机视觉入门项目,共标注约240张图片,覆盖火焰、烟雾、正常三类场景,适合用于火灾预警、安全监控等分类任务的前期实验与模型训练。资源共243个文件,主体为240张jpg样本图片,并附带类别划分json文件、可运行的可视化脚本以及说明图,整个7z压缩包仅504KB,轻量易获取。数据已按训练集与测试集划分,同类图片集中存放,目录结构直观;运行配套show脚本即可查看样本,json文件则记录了各类别标签,便于直接对接CNN或YOLOv5分类项目进行训练验证。目前已有112人学习下载,适合作为分类网络入门练习或快速搭建火情识别方案的数据基础。

1. 240张的火焰、烟雾、正常图像识别数据集,到底能做出什么东西

收到一个火焰、烟雾、正常图像识别数据集,一看标注数量只有约240张,很多人第一反应是这能训出什么来?但我的结论是:如果标注规范、类别覆盖到位,这批数据足以让你在一个晚上跑通完整的图像识别流程,拿到一个可靠的指标基线,而不是让项目卡在“不知道有没有戏”的评估阶段。对于正在做消防预警demo、毕业设计选型或刚转视觉算法的人来说,这类已标注数据集的价值在于省掉了整个领域里最贵的环节——数据标注。这里的三类目标要理解成:火焰样本必须含明火区域,烟雾样本是有烟但未必有明火,正常样本则是无可疑目标的工作、生活场景,也就是负样本。它的边界同样明确:240张适合做分类验证和流程验证,距离经过消防验收的生产系统还有一段路要走。

2. 训练前先做数据体检:一个脚本看清目录、标注与划分

很多人拿到图像识别数据集,第一件事就是开训练脚本直接跑,但我一般会先花十分钟做数据体检。240张的体量经不起折腾,如果标注里有漏框、错类、损坏文件,训练跑得越久浪费的时间越多。下面这套流程能帮你把目录结构、标注格式和划分策略一次摸清。

2.1 目录体检脚本:一次性看清类别分布与损坏文件

如果数据集按“火焰 / 烟雾 / 正常”三个文件夹组织,那它就是分类格式,PyTorch的ImageFolder可以直接读。不管哪种组织方式,先跑下面的脚本统计类别张数、分辨率范围和损坏文件。

import os from collections import Counter from PIL import Image def inspect_data(root_dir): counts = Counter() widths, heights = [], [] broken = [] for cls in sorted(os.listdir(root_dir)): cls_dir = os.path.join(root_dir, cls) if not os.path.isdir(cls_dir): continue for fname in os.listdir(cls_dir): if not fname.lower().endswith(('.jpg', '.jpeg', '.png', '.bmp')): continue path = os.path.join(cls_dir, fname) counts[cls] += 1 try: w, h = Image.open(path).size widths.append(w) heights.append(h) except Exception as e: broken.append((fname, str(e))) print('类别分布:', dict(counts)) if widths: print('宽度范围:', min(widths), '-', max(widths)) print('高度范围:', min(heights), '-', max(heights)) if broken: print('损坏文件:') for fname, err in broken: print(' ', fname, err) if __name__ == '__main__': inspect_data('/path/to/dataset')

这段脚本假定一级目录就是类别名,240张数据通常不需要深层遍历,这个假设在大部分已标注数据集里成立。输出里先看类别比例:如果火焰有150张、烟雾只有30张,说明类别不均衡,后面训练要在损失函数里做类别加权,或者对烟雾类单独加强数据增强。再看分辨率范围,火焰和烟雾目标尺度差异很大,如果所有图都是1920x1080的监控截图,训练时的Resize策略需要格外注意,缩小后小火焰区域的细节会丢得很快。损坏文件则必须处理,DataLoader读到坏图时往往是在几十个epoch之后才抛错,排查成本比现在高得多。

体检完成后,把类别比例、分辨率范围、损坏列表三个结果存下来,这就是这份小数据集的基线档案。后续每次调整数据划分,都拿它做对比,能避免误删样本或合并场景。

2.2 标注格式读取:分类目录与检测框的不同处理路径

如果数据集的标注不是文件夹分类,而是带坐标的检测框,比如VOC格式的XML或JSON,读取方式要换一套。常见做法是把XML全部解析出来,逐张核对每张图片里有哪些目标、框坐标是否越界、有没有空标注。

import glob import xml.etree.ElementTree as ET def parse_voc_xml(xml_path): tree = ET.parse(xml_path) root = tree.getroot() objects = [] for obj in root.findall('object'): name = obj.findtext('name').strip() box = obj.find('bndbox') xmin = int(float(box.findtext('xmin'))) ymin = int(float(box.findtext('ymin'))) xmax = int(float(box.findtext('xmax'))) ymax = int(float(box.findtext('ymax'))) objects.append({'name': name, 'bbox': (xmin, ymin, xmax, ymax)}) return root.findtext('filename'), objects for xml_path in glob.glob('/path/to/annotations/*.xml'): filename, objects = parse_voc_xml(xml_path) print(filename, len(objects), [obj['name'] for obj in objects])

这个脚本的价值不在于把标注搬到某个训练框架里,而在于做标注一致性检查。解析后要关注三件事:第一,有没有图片对应的XML里没有任何object,这种空标注会让检测训练出现空正样本,损失直接异常;第二,bbox坐标是否超出图像边界,xmax不能大于图片宽度,ymax不能大于图片高度,越界框会让模型学习到错误的定位信号;第三,类别名是否统一,常见问题是在标注过程中“火焰”被写成fire、flame、明火等多个版本,训练时会被当成不同类别,样本量本来就不多,经不起这种分裂。

如果发现数据集是分类目录而不是检测框,也有一个常规套路:跑完2.1的统计后,再随机抽几张正常、火焰、烟雾图人工看一眼,确认正常类里没有混入小火苗。对图像识别数据集来说,负样本的干净程度往往比正样本数量更影响模型表现。

2.3 240张样本的划分策略:按场景切而不是按文件名随机切

很多人拿到240张数据,习惯用sklearn的train_test_split按文件名随机切,但小样本下这个做法容易翻车。同一批监控视频里抽出的连续帧,背景和光照几乎一样,随机切会让训练集和验证集里出现大量“长得一样”的图,验证指标虚高,模型到了现场换成新场景立刻打回原形。

正确做法是先识别数据里的场景结构再划分。如果文件名带摄像头编号、日期或场景ID,直接按场景ID分组;没有ID时可以用感知哈希对图片做相似度聚类,把互相接近的图片放在同一组,再按组分配。这是小数据集划分里最值得花时间的一步。

import os from PIL import Image import imagehash # 需要 pip install imagehash def group_by_phash(root_dir, hash_size=8): groups = {} for cls in sorted(os.listdir(root_dir)): cls_dir = os.path.join(root_dir, cls) if not os.path.isdir(cls_dir): continue for fname in os.listdir(cls_dir): if not fname.lower().endswith(('.jpg', '.jpeg', '.png')): continue path = os.path.join(cls_dir, fname) h = imagehash.phash(Image.open(path), hash_size=hash_size) key = (cls, h) groups.setdefault(key, []).append(path) return groups

这个脚本把每张图转成一个64位的感知哈希值,完全相同或极度相似的图会落到同一个key,得到的groups就是“场景候选组”。划分时让同一个组只出现在训练集或验证集中,而不是拆散到两边。hash_size=8生成64bit哈希,默认值即可,不用刻意调大,否则会把同一场景的不同亮度帧拆成多个组,失去合并的意义。

240张数据通常能分出几十个场景组,按8:1:1切到train、val、test,每个集合里仍保留火焰、烟雾、正常三个类别的相对比例。最后test集合不要反复使用,否则它慢慢就变成了第二个验证集,指标会失去参考价值。

3. 迁移学习训练三分类模型:240张数据也能跑出可用基线

数据体检做完,接下来进入正式训练。小样本图像识别最稳的路线是迁移学习,用ResNet18做骨干网络,替换最后一层全连接为三类输出。这一章把选型理由和完整训练代码一次讲透。

3.1 为什么用ResNet18迁移学习而不是从零训练

图像识别里有个直觉:数据量少就换小模型。这句话方向对,但执行上有个更关键的前提——别从零训练。从零初始化的卷积网络在240张图上很难学到有判别力的边缘和纹理组合,训练集loss会降得很快,验证集却一动不动,这是典型的欠拟合加过拟合同时发生。预训练模型在ImageNet上已经学会通用视觉特征,包括边缘、纹理和物体部件的组合,我们只需要替换分类层,让模型把“已经学会的特征”映射到火焰、烟雾、正常这三个类别上。

模型选型上,我一般先用ResNet18。理由有三:参数量适中,CPU也能完成验证,不需要一上来就折腾GPU环境;PyTorch官方权重可以直接加载,省去很多版本适配问题;层结构规整,方便后面做热力图和分段微调。如果显存紧张或者想部署到边缘盒子,再换EfficientNet-B0或MobileNetV3也不迟,但第一版跑通流程时不必追求最轻的模型,先拿ResNet18把baseline立住。

3.2 三分类训练代码:数据加载、模型替换与训练循环

下面这份训练脚本是图像识别数据集训练的常用骨架,把路径换成自己的目录就能跑。它一次性解决数据增强、迁移学习和三分类输出的问题。

import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import datasets, models, transforms train_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(10), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) train_dataset = datasets.ImageFolder('/path/to/train', transform=train_transform) val_dataset = datasets.ImageFolder('/path/to/val', transform=val_transform) train_loader = DataLoader(train_dataset, batch_size=16, shuffle=True, num_workers=2) val_loader = DataLoader(val_dataset, batch_size=16, shuffle=False, num_workers=2) model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) in_features = model.fc.in_features model.fc = nn.Linear(in_features, 3) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-4) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='max', patience=2, factor=0.5) epochs = 15 best_val_acc = 0.0 for epoch in range(epochs): model.train() running_loss = 0.0 for images, labels in train_loader: optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * images.size(0) # 验证阶段 model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in val_loader: outputs = model(images) _, predicted = torch.max(outputs, 1) correct += (predicted == labels).sum().item() total += labels.size(0) val_acc = correct / total train_loss_avg = running_loss / len(train_dataset) scheduler.step(val_acc) print(f'Epoch {epoch+1}: train_loss={train_loss_avg:.4f}, val_acc={val_acc:.4f}') if val_acc > best_val_acc: best_val_acc = val_acc torch.save(model.state_dict(), 'fire_smoke_normal_resnet18.pth')

这段代码有四处值得细看。第一,ImageFolder要求一级目录就是类别名,所以目录要整理成train/火焰、train/烟雾、train/正常这种结构,建议用英文目录名,中文名在部分服务器和容器里容易出现编码问题。第二,ResNet18的fc层原来输出1000类,这里替换成3类,网络其余部分全部沿用ImageNet预训练权重。第三,学习率用1e-4而不是Adam默认的1e-3,预训练权重已经处于一个较优的特征空间,学习率过大会把这些特征直接破坏掉。第四,ReduceLROnPlateau监控验证准确率,两个epoch不涨就把学习率减半,这是小样本训练里最省心的调度方式。

epochs设15只是一个起点。判断标准以val_acc为准:如果模型在第10轮还在明显上升,就说明训练没结束;如果连续5个epoch没有改善,直接停掉。batch_size=16对应224分辨率,单卡内存占用不高,显存紧张可以降到8,但不要为了速度开64,小数据集上大batch会加剧过拟合,因为同一个类别的图片很容易被塞进同一批迭代里。想保持宽高比的话,把Resize改成Resize(256)加CenterCrop(224)也可以,代价是保留更多画面裁剪。

3.3 三个必调参数:分辨率、微调粒度与保存策略

训练跑通后,真正决定模型最终分数的往往不是网络结构,而是三个参数。

第一个是输入分辨率。224x224是常见默认值,但烟雾是弥散的、边界模糊的目标,火焰边缘又往往偏小,224下很容易把小火苗压缩成十几个像素。如果显存允许,把Resize改成320或448,通常比调任何超参数都有效。改法就是直接动train_transform和val_transform里的Resize,注意ColorJitter等增强在Resize之后执行,顺序不要乱。

第二个是微调粒度。第一版全量微调能跑通流程,但火焰、烟雾特征和ImageNet里的通用物体差异较大,只训练最后的fc层可能学不到火烧边缘的特殊纹理。常见做法是分两阶段:第一阶段把backbone的requires_grad设为False,只训fc层几个epoch,找到合适学习率;第二阶段解冻layer4参与微调,学习率降到1e-5,避免破坏底层特征。用代码表达就是把model.layer4.requires_grad置为True,优化器里只传入需要更新的参数。

第三个是保存策略。保存时用model.state_dict()而不是torch.save(model),后者会把网络结构和训练状态一起存下来,换机器时要求代码环境完全一致,很麻烦。加载时先实例化ResNet18并替换fc层,再load_state_dict,这个习惯能少踩很多部署的坑。

4. 小样本踩坑记录:240张数据训练火焰烟雾识别的高频问题

训练过程看着顺利,不代表模型真的学到了火焰和烟雾的本质。下面这五条踩坑记录按“现象→原因→解决”列出,每一条都是小样本火焰烟雾识别项目里真正会遇到的。

4.1 训练集准确率冲上98%,验证集一直在60%徘徊

现象:第一轮训练结束,训练准确率已经比验证集高一截,到第五轮训练集接近100%,验证集却卡在60%左右,两条曲线像拉不开的剪刀。

原因:最常见的不是模型问题,而是数据划分问题。如果训练集和验证集里有大量来自同一视频流的连续帧,模型只需记住背景就能拿到高分,验证时遇到真正的新场景立刻失效。其次是数据增强强度不够,ColorJitter只给到0.1,几乎等于没加。

解决:先回看2.3,把同源图片按场景组切分,让验证集和训练集彻底分开。然后把ColorJitter的brightness、contrast提到0.3,RandomRotation提到15度,必要时加MixUp。对240张的小数据集,验证集准确率能稳定在75%以上已经是合格基线,不必追求90%。

4.2 烟雾大量误判为正常

现象:火焰的召回率能做到95%左右,烟雾的召回率可能只有五成,模型宁可把淡烟判成正常也不愿意给出预警。

原因:烟雾的单帧特征太弱。静态图里烟雾接近半透明,和天空、白色墙面很容易混淆,而且烟雾样本往往浓烟占多数,淡烟的分布几乎没有覆盖到。

解决:训练阶段对烟雾类单独加强颜色扰动,适当调大色相和饱和度扰动,让网络不能只靠灰白色块判断。划分时按“浓烟、淡烟、夜间烟”分组检查,保证每组的样本都被验证集覆盖到。如果数据来自视频,更救命的做法是取连续两帧做差分,把静止背景去掉,拿运动区域进分类器,这种帧间特征比单帧稳定得多。

4.3 白天一切正常,晚上路灯把正常场景误报成火焰

现象:白天验证集表现不错,一到夜间场景,红色车灯、路灯、霓虹灯频繁触发火焰预警,误报率直接拉满。

原因:颜色空间上火焰和红色光源高度重叠,模型学到的可能只是“一块红色区域”,而不是火焰特有的分布、边缘和亮度关系。白天正常样本里没有这种红色干扰,晚上自然崩。

解决:把正常类里的硬负样本补进来,从现场或常见素材里找出红色卡车、夕阳、红色警示灯,加入正常类一起训练。这是比调任何参数都有效的方案。同时在模型外加一道颜色前置规则:完全不含高亮红色像素的图直接判为正常,不用进模型;有可疑红色区域的图再交给分类器。别把希望全押在模型上,图像识别在工业场景里常常需要规则和模型并行。

4.4 标注有问题但训练不报错,背景区域被当成正样本

现象:训练loss掉得很稳,验证集偶尔出现一张碎片图被识别成火焰,人工看不明白模型依据在哪。

原因:分类格式的数据集可能存在整张图标注过粗的问题,比如一张火焰只占画面很小部分,标注时把整张图都归为火焰类;或者检测框把大片无关背景圈了进去。已标注数据不等于标注零错误。

解决:用2.2节的解析脚本把所有标注信息导出成表格,重点查三件事:空标注、坐标越界、类别名不统一。发现整图类别有问题的样本,宁可直接删掉,不要让它留在训练集里污染loss。我一般会在训练前让脚本每次随机打印20张图的路径,人工扫一遍,这一步用不了十分钟,但对小数据集来说能省下大量排错时间。

4.5 同一张图,重新Resize后预测结果不一致

现象:训练时用224分辨率,demo里却拿原图直接推理,同一张火焰图有时预测为火焰,有时预测为正常,结果还不稳定。

原因:这不算bug,而是训练和推理的预处理没有保持一致。原图1920x1080缩到224后,火焰区域从几百像素变成十几像素,特征被抹平;如果Resize方式不同,比如直接拉伸和等比缩放后填充,又会改变火焰的宽高比和位置分布。

解决:推理时固定使用和验证集一致的预处理流程,Resize、Normalize都要一致。如果现场需要识别画面中的小火焰,把输入分辨率提高到320或448,并且用等比缩放加padding,不要直接拉伸。项目交付前,拿20张训练集外图片分别跑两次推理,确认输出稳定再谈上线。

5. 进阶:用热力图验证模型到底在看什么,再决定扩数据还是部署

模型训练完,先别急着调参数,用热力图看一下它到底在看图片的哪个区域。这是火焰烟雾识别项目里最依赖的验证手段,比任何准确率数字都能说明问题。

5.1 用热力图把模型注意力抽出来看

PyTorch生态里torchcam可以直接从ResNet18的layer4抽取CAM,核心代码很短。

import torch from torchcam.methods import CAM from torchvision import models, transforms from PIL import Image model = models.resnet18(weights=None) model.fc = torch.nn.Linear(model.fc.in_features, 3) model.load_state_dict(torch.load('fire_smoke_normal_resnet18.pth')) model.eval() transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) input_tensor = transform(Image.open('test_fire.jpg')) cam_extractor = CAM(model, 'layer4') out = model(input_tensor.unsqueeze(0)) acts = cam_extractor(0, out)

把acts用双线性插值上采样回原图尺寸,叠加到原图上输出,就能看到模型注意力集中在哪个区域。如果热力图高亮区域集中在火焰的红色边缘和烟雾的弥散区域,说明模型学的是目标本身;如果高亮到背景杂物上,多半是标注噪声或划分泄漏问题。这一张图给评审看最有说服力,省去大量解释。

5.2 从分类往检测和边缘部署走

如果后续要接工业现场,分类只是第一步。数据若带检测框,按归一化坐标把XML转成YOLO的txt格式,就能用YOLOv8训练自己的检测模型,小数据集上先训一个检测baseline再逐步扩充现场数据。如果算力受限制,比如想用ESP32-S3这类设备做前端识别,常见做法是把ResNet18换成MobileNetV3,做int8量化和通道剪枝,再转TFLite Micro,但不要指望240张数据量化后还能保持实验室指标。更稳妥的路线是摄像头采样后把画面送到边缘盒子或服务端推理,前端只做主循环和报警逻辑。

5.3 一个交付习惯

我个人的交付习惯是最后一步拿20张现场照片,用训练好的模型跑一遍,同时把每张图的CAM热力图打印出来,和现场人员一起过一遍。有一次我只看acc指标就交付,结果现场把夕阳误报了一整晚,后来才发现模型注意力全在红色区域上,而不是火焰形状。从那以后,涉及火焰和烟雾识别的项目,我坚持先跑热力图再谈准确率。这个习惯也推荐给你,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询