☰
CNN火灾识别实战:从数据集整理到模型训练与部署
2026/10/31 21:37:14 网站建设 项目流程

简介:基于CNN深度学习的火灾识别资源包,面向入门深度学习的开发者与学生,覆盖图像分类任务中数据预处理、模型训练与可视化界面完整流程。代码基于PyTorch实现,数据集图片经过短边添加灰边转正方形与随机旋转增强,可提升模型泛化能力,适用于火灾检测相关课程设计或项目演练。压缩包共250个文件,以200张png、44张jpg图片构成训练数据,3个python脚本分别完成数据集文本生成、模型训练与PyQt界面展示,3个txt文件记录环境依赖与数据划分信息。整包约173.55MB,资源目录结构清晰,已有172人浏览学习。通过依次运行三个脚本,可获得本地保存的模型权重及每个epoch的验证集损失与准确率日志,并能利用可视化界面上传图片进行识别,便于对照理解CNN训练全流程与调参思路。

1. 拿到“基于CNN深度学习的火灾识别-含数据集.zip”,先别急着解压跑训练

这个压缩包几乎是火灾识别方向最经典的落地形态:一个CNN卷积神经网络分类项目,附上一个可以直接开练的数据集。它的核心任务很直接——给定一张图片,模型输出“有火”还是“没火”,难点却在数据集质量、类别平衡和真实场景误报上。我接过不少类似的包,多数人第一步就双击打开README找训练命令,结果往往卡在数据目录混乱、标签对不上、训练完精度虚高但一测就翻车。这篇文章就按我自己的操作顺序,把数据集整理、模型选型、训练调参和排障讲清楚。适合正在做毕设、课程设计或消防监控原型验证的人,照着做能把跑通时间压缩到一晚上。

2. 数据集解压与目录整理:把zip里的零散图片变成CNN能吃的标准结构

2.1 zip包解压与数据分布摸底:文件数量、类别比例怎么查

拿到压缩包先做两件事:解压、摸底。别一上来就开Notebook写模型,火灾识别这类项目的数据集往往是从多个来源凑出来的,目录名可能是中文、类别可能叫fire/not_fire也可能叫smoke/normal,先看清真实结构比什么都重要。

unzip 基于CNN深度学习的火灾识别-含数据集.zip -d fire_dataset cd fire_dataset find . -type f | wc -l

第一条命令把压缩包解压到fire_dataset目录,-d指定输出目录;find . -type f | wc -l统计一共多少个文件,先对数据规模有个数。然后看目录结构:find . -maxdepth 2 -type d只看两层目录,一般就能发现是“按类别分文件夹”还是“所有图片混在一起带标签文件”。如果解压出来乱码,多半是压缩包用GBK编码,在macOS或Linux上解压时可以用unzip -O gbk,Windows下一般直接右键解压没问题。

类别数量一定要量化,不要靠肉眼。用Python统计比一条条shell命令更直观,尤其是文件名不规律的情况:

import os from collections import Counter base = "fire_dataset" exts = (".jpg", ".jpeg", ".png", ".bmp") counts = Counter() for root, _, files in os.walk(base): for f in files: if f.lower().endswith(exts): cls = os.path.basename(root) counts[cls] += 1 for cls, num in counts.most_common(): print(f"{cls}: {num}张")

这段代码按“图片所在文件夹名”作为类别标签,exts限定只统计图片格式,避免把README或标注json计入。输出后你就知道正负样本大概什么比例。火灾识别数据集最常见的坑是类别极度不平衡,比如fire类2000张、no_fire类8000张,这种数据直接训练会让模型倾向于把所有图都判成no_fire,因为这样准确率也很高。

2.2 把目录结构统一成标准数据集格式:ImageFolder要求与数据划分脚本

CNN训练最省事的组织方式是PyTorch的ImageFolder格式,它要求目录严格分层:train/类别名/图片、val/类别名/图片。很多zip包里已经是“fire”和“no_fire”两个文件夹,但train、val、test没分开,或者测试集和训练集混在一起。这时候我一般写一个划分脚本,固定随机种子保证结果可复现,并按8:1:1切成训练、验证、测试三份。

import os, random, shutil from collections import defaultdict random.seed(42) # 固定种子,保证每次划分结果一致 base = "fire_dataset" # 解压后的原始目录 target = "dataset" # 整理后的标准目录 exts = (".jpg", ".jpeg", ".png", ".bmp") ratios = {"train": 0.8, "val": 0.1, "test": 0.1} images = defaultdict(list) for root, _, files in os.walk(base): cls = os.path.basename(root) if cls in ("fire", "no_fire"): # 只处理真正有意义的类别目录 for f in files: if f.lower().endswith(exts): images[cls].append(os.path.join(root, f)) for cls, paths in images.items(): random.shuffle(paths) # 打乱顺序,避免同类图片扎堆 n_train = int(len(paths) * ratios["train"]) n_val = int(len(paths) * ratios["val"]) for phase, part in [ ("train", paths[:n_train]), ("val", paths[n_train:n_train + n_val]), ("test", paths[n_train + n_val:]) ]: for idx, src in enumerate(part): ext = os.path.splitext(src)[1] dst_dir = os.path.join(target, phase, cls) os.makedirs(dst_dir, exist_ok=True) dst = os.path.join(dst_dir, f"{cls}_{idx:05d}{ext}") shutil.copy2(src, dst)

这里有两个细节值得注意:一是重命名了图片,避免不同目录里的001.jpg互相覆盖;二是copy2保留原始文件元信息,如果你的磁盘比较紧张,也可以改成shutil.move,但那样原始目录就被拆掉了,做数据回查时不太方便。划分完之后,用find dataset -type d确认目录层级是不是预期的样子。

提示:如果zip里已经存在train/val/test三套目录,就别套这个脚本了。直接检查每个split里的类别文件夹是否齐全,缺哪类补哪类,这才是最快的路。

3. 搭建一个能真正收敛的CNN:模型结构与训练代码

3.1 自己写CNN还是用预训练模型:火灾数据集大小决定选型

CNN之所以适合火灾识别,是因为火焰在图像里呈现的是局部纹理特征——橙红色区域、高频边缘、烟雾的半透明渐变,这些都能被卷积核提取出来。全连接网络看的是全局像素关系,没有空间局部性先验,数据量一大就容易学偏。

选型先看数据量。压缩包里的数据集规模通常在几千到一两万张,这种情况下我的建议很明确:优先用预训练模型做迁移学习,而不是从零训练一个深层网络。数据量小时,ResNet18、MobileNetV3这类模型用ImageNet预训练权重初始化,再冻结前几层只训练分类头,效果远好于自己搭的网络;数据量只有几百张时,自己搭一个3层小CNN反而更稳,迁移学习容易过拟合。

数据规模推荐方案理由
每类少于500张自建轻量CNN + 强数据增强模型小,不容易直接把噪声背下来
每类500~5000张ResNet18/MobileNetV3迁移学习预训练特征泛化能力强,收敛快
每类超过5000张ResNet50/EfficientNet微调数据够多,深层模型能学到细粒度火焰特征

如果后续要部署到监控摄像头,常见做法是先用分类模型做帧级判断,再在视频流里做时序平滑。标题这个项目是CNN分类,但实际场景里如果要做目标定位,就该换YOLOv8这类检测模型,训练自己数据集的流程又不一样了。

3.2 最小可运行的CNN训练脚本:Dataset、模型、训练循环一次跑通

先写数据加载。这里用ImageFolder,所以第2章整理的目录结构必须严格正确。

from torchvision import datasets, transforms from torch.utils.data import DataLoader 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_ds = datasets.ImageFolder("dataset/train", transform=transform) val_ds = datasets.ImageFolder("dataset/val", transform=transform) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_ds, batch_size=32, shuffle=False, num_workers=4, pin_memory=True) print(train_ds.classes) print(len(train_ds), len(val_ds))

Resize((224, 224))先把尺寸统一,Normalize里的mean和std用的是ImageNet统计值,已经训练好的预训练模型要求输入分布一致,迁移学习时一定要保持这套参数。num_workers=4是数据加载的并行进程数,Windows上如果卡死就在主程序入口加if __name__ == "__main__":保护。打印train_ds.classes非常重要,ImageFolder会按文件夹名的字典序给类别编号,比如fire是0、no_fire是1,后面分析混淆矩阵时要对上。

模型部分为了演示完整逻辑,先写一个自建的轻量CNN,代码结构清楚,每层都能看到尺寸变化:

import torch import torch.nn as nn class FireCNN(nn.Module): def __init__(self, num_classes=2): super(FireCNN, self).__init__() self.features = nn.Sequential( nn.Conv2d(3, 16, kernel_size=3, stride=1, padding=1), nn.BatchNorm2d(16), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(16, 32, kernel_size=3, stride=1, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, stride=1, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.AdaptiveAvgPool2d((1, 1)), nn.Flatten(), nn.Linear(64, 128), nn.ReLU(inplace=True), nn.Dropout(0.3), nn.Linear(128, num_classes) ) def forward(self, x): return self.classifier(self.features(x))

kernel_size=3, padding=1让卷积不改变特征图尺寸,真正缩小尺寸的是MaxPool2d(2),每经过一次池化,宽高减半。BatchNorm2d必须加在卷积和ReLU之间,它能让深层网络训练更稳定,不用手动调学习率也能快速收敛。AdaptiveAvgPool2d((1,1))把最后一层特征图压成64x1x1,好处是不管输入尺寸怎么变,全连接层的输入维度都不变。Dropout(0.3)是0.3概率随机失活神经元,防止小数据集过拟合。

训练循环就是一个标准的监督学习流程,但有几个细节会影响成败:

import torch.optim as optim device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = FireCNN().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=20) epochs = 20 for epoch in range(1, epochs + 1): model.train() total_loss, correct, total = 0.0, 0, 0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) preds = outputs.argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) train_acc = correct / total avg_loss = total_loss / total model.eval() val_correct, val_total = 0, 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) preds = outputs.argmax(dim=1) val_correct += (preds == labels).sum().item() val_total += labels.size(0) val_acc = val_correct / val_total print(f"epoch {epoch:2d} | loss {avg_loss:.4f} | " f"train_acc {train_acc:.4f} | val_acc {val_acc:.4f}") scheduler.step()

AdamW相比Adam多了解耦的权重衰减,weight_decay=1e-4是L2正则,对小数据集尤其有用。CosineAnnealingLR让学习率在20个epoch里从1e-3余弦下降到接近0,前5个epoch学习率较高用来快速收敛,后面慢慢精细调整。验证阶段必须写model.eval()和torch.no_grad(),前者关闭Dropout和BatchNorm的统计更新,后者关闭梯度计算,省显存也防止验证结果被随机化干扰。outputs.argmax(dim=1)取每个样本概率最大的类作为预测结果。

4. 训练参数怎么设:从学习率、batch size到数据增强,全是细节

4.1 一套安全起步的超参数值,以及怎么判断该往哪个方向调

训练CNN最让新手头疼的是超参数组合太多。我一般给一个保底的起步配置,先把模型跑通,再根据损失曲线微调:

参数建议值调整方向
输入尺寸224x224太小丢特征,太大显存吃紧
batch size32显存不够就降到16或8
学习率1e-3损失震荡就降,收敛太慢就升
epochs20~50以early stopping为准
优化器AdamW最终调优可换SGD+momentum
损失函数CrossEntropyLoss类别不平衡时给class_weight

如果训练损失在3~5个epoch后还是纹丝不动,先检查是不是学习率太小;如果损失直接变成nan或者一步从2.0跳到0.5再跳回2.0,那就是学习率太大,降到1e-4再看。学习率这个东西在黑匣子里占一半的权重,与其猜,不如拿出训练日志画图。

4.2 数据增强策略:火焰识别的增强和一般分类不一样

火灾识别里的数据增强有个特殊约束:火焰的颜色和纹理是核心特征,增强太猛会把橙红色区域扭曲成无法识别的样子。比如ColorJitter的hue参数如果设成0.2,火焰可能偏成紫色,模型学到的就是“紫红色块是火”,真实场景一测就废。

train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.8, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(degrees=10), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.05), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])

RandomResizedCrop的scale=(0.8, 1.0)表示缩放比例不低于80%,相当于轻微裁切,保留大部分火焰区域;RandomRotation(degrees=10)只做小角度旋转,火焰形状不像普通物体那样有固定朝向,转个10度完全合理;hue=0.05是保守值,只让色相轻微偏移。验证集和测试集不要做任何随机增强,只用Resize和Normalize,保证指标可比。

如果是正负样本不平衡,光靠增强不够,还要在采样器上做文章:

from torch.utils.data import WeightedRandomSampler labels = [s[1] for s in train_ds.samples] class_counts = torch.bincount(torch.tensor(labels)) weights = 1.0 / class_counts[labels].float() sampler = WeightedRandomSampler(weights, num_samples=len(labels), replacement=True) train_loader = DataLoader(train_ds, batch_size=32, sampler=sampler)

weights计算方式很直白:样本属于类别0就除以类别0的总数,属于类别1就除以类别1的总数。少的类别每个样本被抽到的概率变大,多的类别被降权。replacement=True允许重复抽样,这样每个epoch里样本数是均衡的,但代价是同一张图可能在一个epoch里出现多次。

4.3 训练日志与早停:损失曲线就像心电图,要学会看异常

训练代码里那只print还不够,我习惯把每个epoch的损失和精度存下来,最后画成曲线。训练完画图也能网上复盘,但训练过程里就要早停,不然最后几个epoch在过拟合。

history = {"train_loss": [], "val_loss": [], "val_acc": []} best_val_acc = 0 patience = 5 bad_epochs = 0 # 在训练循环内部记录 history["train_loss"].append(avg_loss) # 验证阶段结束后 history["val_loss"].append(val_loss) history["val_acc"].append(val_acc) if val_acc > best_val_acc: best_val_acc = val_acc torch.save(model.state_dict(), "best_fire_cnn.pth") bad_epochs = 0 else: bad_epochs += 1 if bad_epochs >= patience: print("early stop at epoch", epoch) break

best_fire_cnn.pth只保存验证集最优的模型权重,这是防止过拟合的后悔药。判断过拟合有个简单规律:训练损失持续下降,验证损失先降后升,两线逐渐拉开,典型的“把训练集背下来了”。这时候回退到验证损失最低的那个epoch,同时加大Dropout或数据增强强度。

5. 火灾识别训练常见问题与避坑:现象、原因、解决

5.1 验证准确率很高,但拿真实场景测试完全不行

现象是最典型的“翻车现场”:训练完准确率96%,开心地拿小区监控截图一测,识别出大量误报。

原因有两层,第一是数据集划分出问题,train和val来自同源场景,模型看到的背景都差不多,等于提前知道了答案;第二是数据增强太弱或太强,导致模型没学会火焰的本质特征。解决方法是先用原始压缩包里的图片按场景分组,不同摄像机、不同光照条件各拿一部分进验证集,保证val里的场景和train没有明显重合。如果压缩包里的图本身就来自同一个地方,那测试准。更实际的做法是:训练完成后,额外收集10~20张真实火灾新闻图,当冒烟测试集,一条条看置信度。

5.2 白天效果好,夜间和人工光源干扰下误报率飙升

这是火灾识别最典型的问题,夜间场景的火焰和路灯、车灯、手机闪光灯在CNN眼里颜色几乎一样。

原因是训练集里缺乏夜间负样本,模型没学过“亮橙红色但不一定是火”这个边界。解决分两步:第一步去收集夜间街景、篝火晚会、灯光秀这类hard negatives,补充到no_fire类里重新训练;第二步在后处理层加条件判断,比如单帧“火”置信度必须超过0.7,且连续3帧都判火才报警,这个时序平滑能过滤掉绝大多数灯光闪烁。很多实际工程的分类模型都能达到90%以上准确率,但真正能用靠的都是阈值和后处理。

5.3 显存不够报OOM,或者训练速度慢到没法忍

现象是刚开始训练就报CUDA out of memory,或者一个epoch跑十几分钟。

先看batch size和输入尺寸,这两个对显存影响最大。batch_size=32在6GB显存用不下是正常的,降到8或16再跑。还有一种情况是num_workers=4在Windows上因为多进程问题卡死或非常慢,可以先改成num_workers=0,牺牲一点数据加载速度,排除进程阻塞问题。如果显存还是不够,用混合精度训练能省将近一半显存:

scaler = torch.cuda.amp.GradScaler() for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() with torch.cuda.amp.autocast(): outputs = model(images) loss = criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()

autocast把前向计算的精度降到FP16,反向传播依然用FP32,GradScaler防止梯度下溢。这个改动对2G小显存机器是救命级别的,而且对精度的影响基本可以忽略。

5.4 loss变成nan,或者训练损失不降反升

训练时loss直接变nan,或者前几个epoch降了一点,后面一路飞升回2.0以上。

loss变成nan最常见原因是学习率过大导致梯度爆炸,1e-3在某些小模型上也可能太大,先降到1e-4试。第二种常见原因是输入数据里混入了损坏的图片,比如0字节的jpg,ToTensor读不出来直接nan。第三个是数据增强里有除以零的操作,比如RandomResizedCrop配合过小的scale有时候会裁出一张0像素图。排查时先去掉增强只跑几个epoch,如果正常了就是增强的问题;如果还是nan,检查图片是不是都能被正常解码,写个循环单独加载每张图片,报错的那个就是问题样本。

5.5 同一张图每次跑预测结果不一样

模型的Dropout层在推理时还在生效,导致同一个输入每次输出的概率不一样,尤其是阈值刚好在0.5附近时忽正忽负。

原因是PyTorch里只要调用了model.train(),Dropout就随机失活神经元,推理时必须切换回model.eval()。很多人写训练代码时最后一个epoch没把model.eval()放进去,保存的模型状态还算正常,但预测脚本里没有调用,就会看到这个灵异现象。解决方法是预测脚本固定写两行:

model.eval() with torch.no_grad(): outputs = model(images)

另外,保存模型时我不建议只存state_dict,最好把类别名和预处理参数一起存进一个checkpoint:

torch.save({ "model_state": model.state_dict(), "classes": train_ds.classes, "transform": str(transform), }, "best_fire_cnn.ckpt")

这样过一个月回来,还能知道当时的类别顺序和预处理方式,不至于在推理时因为classes顺序反了把fire和no_fire搞混。

6. 模型上线前的验证与导出:一张图一张图确认,再转成能部署的格式

训练完不等于结束,部署前必须做两件事:画混淆矩阵确认是否偏科,转ONNX确认能上推理引擎。

from sklearn.metrics import confusion_matrix, classification_report y_true, y_pred = [], [] model.eval() with torch.no_grad(): for images, labels in val_loader: images = images.to(device) outputs = model(images) preds = outputs.argmax(dim=1).cpu().tolist() y_pred.extend(preds) y_true.extend(labels.tolist()) print(confusion_matrix(y_true, y_pred)) print(classification_report(y_true, y_pred, target_names=train_ds.classes))

如果混淆矩阵显示no_fire被大量判成fire,说明模型偏保守,宁误报不漏报,这在消防场景可以接受,但误报率太高运维会崩溃。我的习惯是把阈值从默认的0.5往上调,比如调到0.7以上,再统计误报与漏报的代价。消防场景里漏报一票否决,实际工程往往做两路:分类模型输出低置信度时告警,高置信度时直接触发,中间地带丢给视频流时序判断。

导出ONNX是部署的关键一步。PyTorch模型在服务端可以直接用,但边缘设备或者用OpenCV的DNN模块加载时,ONNX几乎是最好的中间格式:

import torch.onnx model.eval() dummy = torch.randn(1, 3, 224, 224).to(device) torch.onnx.export( model, dummy, "fire_cnn.onnx", input_names=["input"], output_names=["logits"], dynamic_axes={"input": {0: "batch"}, "logits": {0: "batch"}}, opset_version=11 )

dynamic_axes声明batch维度可变,这样单张图片预测和批量预测都可以用同一个onnx文件。opset_version=11是兼容性比较好的版本,很多推理框架对它的支持最完整。导出后用onnxruntime跑一遍验证输出是否和PyTorch一致,才算真正完成。

我个人做火灾识别项目到最后,一定会留一个“场景冒烟测试集”,放十几张从来没见过的火灾新闻图、路灯夜景、夕阳和厨房煎锅照片。模型在测试集上分数再高,也要在这批图上一张张过一遍,看置信度分布是否合理。这套方法帮我在好几个项目里避免了“交上去指标好看、现场一测就废”的尴尬。

希望这篇笔记能把你的火灾识别项目从“解压zip”推到“训练出可用模型”这一步,少走几个我走过的弯路。数据清理花一小时,后面训练和调参能省一整天,值得。

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

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

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

立即咨询