简介:这套基于深度学习的肺炎检测系统,源自北京印刷学院人工智能大赛深度学习组作品,面向深度学习入门者、医学影像分析开发者及学科竞赛团队。系统以图像分类为核心任务,提供从数据预处理、模型训练到界面演示的完整链路,可帮助理解卷积神经网络在真实医疗场景中的落地方式。资源共72个文件,压缩包仅2.16MB,包含17个Python脚本、36张样张图片、2个HTML页面及模型权重等文件。脚本覆盖CNN、VGG16、VGG19、InceptionNetV3、ResNet等多种主流网络结构,每个模型均配有独立训练脚本,便于横向对比;配套Flask应用能快速启动本地Web界面,上传图片即可查看检测结果。目前已有139人学习该资源。对参赛者而言,这是一套可直接复用的项目范本;对自学者而言,可借助源码与界面进一步拓展数据增强、模型调优等实验,节省从零搭建的时间。
1. 基于深度学习的新冠肺炎检测系统:课程设计级完整工程,五种网络加可视化页面一步到位
提起基于深度学习的新冠肺炎检测系统,很多人第一反应是数据集多大、模型多先进,但真正打算在课程设计或比赛里复现的人更关心另一个问题——压缩包下载下来能不能马上跑通。这个来自北京印刷学院人工智能大赛深度学习组的作品,给我最大的感受不是单个模型有多强,而是训练端到推理端被完整接起来了:五个模型各自的训练脚本、一个能跑的 Flask 检测页面、一套清晰的目录结构。对于想把深度学习图像分类做成可演示项目的学生来说,它比零散的算法片段值钱得多。下面我从文件结构、数据处理、训练到 Web 部署逐步拆给你看。
2. 压缩包里的文件各自在干什么:从模型定义到训练入口再到页面渲染
很多下载完压缩包的人第一件事是双击 README.md,这没错,但 README 往往只写了“怎么跑”,不会写“为什么要这么组织”。我先把整个工程的链路讲透。
2.1 五个模型文件并存不是冗余,而是方便做对照实验
model/目录下放着 CNN.py、VGG16.py、VGG19.py、InceptionNetV3.py 四个网络定义文件,而 ResNet 的训练入口ResNet_Train.py直接放在根目录,这说明作者把五种主流 CNN 结构都做了一份。为什么一个比赛作品要塞五种网络?常见做法是先用小模型把整条链路跑通,再换大模型提精度。
| 网络 | 入口脚本 | 特点 | 运算压力 |
|---|---|---|---|
| 自定义 CNN | CNN_train.py | 结构最简单,适合先验证数据和流程 | 低,CPU 也能跑 |
| VGG16 | VGG16_train.py | 经典结构,预训练权重好找 | 中等 |
| VGG19 | VGG19_train.py | 比 VGG16 更深,特征更强 | 偏高 |
| InceptionNetV3 | InceptionNetV3_train.py | 多尺度卷积加上辅助输出,收敛更稳 | 中等 |
| ResNet | ResNet_Train.py | 残差连接,深度网络首选 | 偏高 |
我的习惯是先用自定义 CNN 跑通一遍完整流程,再切 VGG16、ResNet 拿对比结果。比赛答辩时导师问“你为什么选这个网络”,有五种模型的对比结果,比空口说“ResNet 效果好”有说服力得多。
2.2 训练脚本是入口,模型文件才是真正干活的人
model/里定义的是网络结构,真正调用它的是根目录下的*_train.py系列脚本。这种拆分在 PyTorch 项目里是标准做法:模型定义、训练逻辑、推理逻辑分文件管理,换网络时只需要改训练脚本里一两行引用,不用动整个文件。
logs/目录保存训练过程中的日志和模型权重,runs/目录则是 TensorBoard 这类工具生成的运行记录。拿到工程后我一般先看logs/下有没有现成跑出来的权重文件,如果有,可以跳过训练直接体验推理页面,这也是这个压缩包“能马上跑”的关键。
2.3 app.py 加上 templates 和 static,构成一条完整的可视化推理链路
app.py是 Flask 入口,配合templates/index.html、templates/index_1.html两个页面模板,static/uploads/存用户上传的图片。这就是一个完整的推理 Web 应用:浏览器上传肺 CT 图,后端加载训练好的模型,判断类别并返回置信度。
这里需要特别注意templates下有两个 HTML 文件这一点。常见做法是 index.html 是正式页面,index_1.html 是调试备用页。如果你启动 app.py 后发现页面样式不对,多半是模板引用了另一个 HTML 的变量名,打开两个文件对比一下就知道差异。
2.4pycache这类目录不用管,但 README 一定要从头读
压缩包里出现__pycache__说明作者在打包前实际运行过项目,里边的.pyc文件可以直接忽略。uploads如果带了测试图片,会是很好的推理验证素材。 README 建议从头读完,它一般会写清需要用哪个 Python 版本、PyTorch 版本、装了哪些依赖,这是最容易被新手跳过却最值钱的信息。
3. 数据准备:让 PyTorch 的 ImageFolder 直接读你的肺 CT 图
这个工程里没有单独写dataset.py,说明训练部分大概率用的是torchvision.datasets.ImageFolder。它的规则很简单:按文件夹名分类,train/正常/xxx.jpg里的图片全算正常类。把原始数据整理成这个结构是最关键的一步,我单独写了一个整理脚本。
3.1 用脚本把散装图片归类成 ImageFolder 目录结构
import os import shutil import random # 你的原始数据目录,比如直接堆放了一堆 CT 图片 src_dir = "raw_ct_images" # 整理后的数据集根目录 dst_dir = "dataset_ct" categories = {"normal": 0, "covid": 1, "pneumonia": 2} random.seed(42) # 先建目录骨架,ImageFolder 按文件夹名生成类别标签 for split_name in ["train", "val"]: for cls_name in categories: os.makedirs( os.path.join(dst_dir, split_name, cls_name), exist_ok=True ) # 假设文件名是以类别名开头的,比如 normal_001.jpg for fname in os.listdir(src_dir): if not fname.lower().endswith((".jpg", ".jpeg", ".png")): continue matched_class = None for cls_name in categories: if fname.startswith(cls_name): matched_class = cls_name break if matched_class is None: print(f"跳过未匹配文件: {fname}") continue # 按 8:2 划分训练集和验证集,注意这里的随机种子要固定 split_name = "train" if random.random() < 0.8 else "val" src_path = os.path.join(src_dir, fname) dst_path = os.path.join(dst_dir, split_name, matched_class, fname) shutil.copy(src_path, dst_path) print("数据整理完成,生成目录结构如下:") for split_name in ["train", "val"]: for cls_name in categories: count = len(os.listdir(os.path.join(dst_dir, split_name, cls_name))) print(f"{split_name}/{cls_name}: {count} 张")这段脚本的核心逻辑很简单:按文件名前缀匹配类别文件名,固定随机种子后按比例复制到训练集和验证集目录。random.seed(42)的目的是保证每次运行划分结果一致,不会因重启脚本导致训练集和验证集数据重叠或缺失。如果你的文件名不是这种带前缀的命名方式,把matched_class匹配逻辑改成从一个 CSV 映射表读取即可。
这一步有几个容易翻车的地方,我后面在避坑章节会细讲,这里先说最关键的:目录名一定不要用中文,PyTorch 的 ImageFolder 对中文路径兼容性不好,开发者电脑上没事,换台电脑跑就可能报编码错误。
3.2 预处理参数:尺寸、归一化、数据增强怎么设
拿到数据后,预处理标准是直接影响模型能不能收敛的关键。肺 CT 图像和自然图像有明显区别,我一般会在transform里做如下配置:
from torchvision import transforms transform_train = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(degrees=15), transforms.ColorJitter(brightness=0.2, contrast=0.2), transforms.ToTensor(), transforms.Normalize( mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225] ) ]) transform_val = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize( mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225] ) ])训练集和验证集的预处理必须分开。训练集加了随机翻转、随机旋转和颜色扰动,增强模型泛化能力;验证集只做缩放和归一化,保证评估结果稳定。Resize((224, 224))是兼容 VGG 系列的标准输入尺寸;如果切到 InceptionNetV3,它的原始设计是 299×299,需要确认工程里的预处理到底用的多少,否则模型会报维度不匹配或者效果骤降。
归一化用的 mean 和 std 是 ImageNet 预训练权重对应的统计量,迁移学习场景下用这套参数最省事。如果你的模型是从零开始训练的,换成mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]也完全可以,效果差距一般在可接受范围。不知道用哪套时,先看项目 README 里训练脚本的写的是什么,以工程里的为准。
3.3 类别不平衡是医学影像分类的第一道坎
医学影像数据几乎天然不平衡:正常样本多,病变样本少,新冠这类阳性样本更少。如果直接拿原始分布训练,模型会学到“全部预测为多数类”的偷懒策略,准确率看着很高但实际毫无用处。
常见做法是给损失函数加类别权重。用 PyTorch 做很简单:
import torch # 先统计每个类别的样本数,比如上面脚本打印的 train 目录统计结果 sample_counts = torch.tensor([1500, 300, 400], dtype=torch.float) # 权重 = 总样本数 / (类别数 * 该类样本数),少数类自动获得更高权重 class_weights = sample_counts.sum() / (len(sample_counts) * sample_counts) criterion = torch.nn.CrossEntropyLoss(weight=class_weights)计算方式并不唯一,但思路一致:少数类在损失函数里被放大,模型不敢无视它们。这条规则在这类项目里适用,也适用于其他医学图像分类比赛。
4. 训练脚本实操:从加载数据集到换模型改学习率,每一步怎么调
训练脚本是整个工程的核心。这个压缩包里五种网络各自有独立入口,我先拆通用的训练主循环,再讲换模型时哪些参数必须联动修改。
4.1 通用训练框架:完整抄下来就能用的一段代码
不管哪个*_train.py,核心流程都是下图这段逻辑:
import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision.datasets import ImageFolder from torchvision import transforms # 数据读取:train 和 val 目录下的文件夹名即标签 train_set = ImageFolder("dataset_ct/train", transform=transform_train) val_set = ImageFolder("dataset_ct/val", transform=transform_val) train_loader = DataLoader( train_set, batch_size=32, shuffle=True, num_workers=4, pin_memory=True ) val_loader = DataLoader( val_set, batch_size=32, shuffle=False, num_workers=4, pin_memory=True ) # 换模型时只替换这里的网络类和 num_classes from model.CNN import CNN net = CNN(num_classes=len(train_set.classes)) criterion = nn.CrossEntropyLoss(weight=class_weights) optimizer = torch.optim.Adam(net.parameters(), lr=1e-4) for epoch in range(50): net.train() total_loss = 0.0 for images, labels in train_loader: optimizer.zero_grad() outputs = net(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() # 验证集评估:必须切 eval 模式,否则 BatchNorm 和 Dropout 造成结果不稳定 net.eval() correct = 0 total = 0 val_loss = 0.0 with torch.no_grad(): for images, labels in val_loader: outputs = net(images) loss = criterion(outputs, labels) val_loss += loss.item() _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() acc = 100.0 * correct / total print(f"Epoch {epoch+1}: train_loss={total_loss/len(train_loader):.4f}, " f"val_loss={val_loss/len(val_loader):.4f}, val_acc={acc:.2f}%") # 简单保存策略:每个 epoch 都存一份,跑完挑验证集最高的那个 torch.save(net.state_dict(), f"checkpoints/cnn_epoch{epoch+1}.pth")这段代码的要点有三个。DataLoader里的num_workers=4是用 4 个子进程预读取数据,加快训练;如果机器内存不够,改成 2 或者在 Windows 下改成 0 更稳定。net.train()和net.eval()的切换不能漏,漏了会导致验证结果时好时坏。torch.no_grad()告诉 PyTorch 验证阶段不用计算梯度,省显存也省时间。
num_classes要从train_set.classes的长度读取,ImageFolder会自动按字母序把文件夹名映射为 0、1、2 等索引,训练时用的标签顺序和推理时必须保持一致,否则会出现“训练时正常是 0、推理时正常是 1”的灾难。
4.2 切到 VGG16、ResNet 时,学习率和优化器要跟着改
从自定义 CNN 切到 VGG16 或 ResNet,不是换一行 import 那么简单。大模型参数量暴增,同样的学习率和 batch size 可能直接导致显存溢出或梯度爆炸。我整理了一套可参考的调整组合:
| 网络 | 推荐 batch_size | 推荐初始学习率 | 注意事项 |
|---|---|---|---|
| 自定义 CNN | 64 | 1e-3 | 小模型可直接用 Adam |
| VGG16 | 32 | 1e-4 | 有预训练权重时学习率降到 1e-4 起步 |
| VGG19 | 16 | 1e-4 | 显存吃紧先减 batch |
| InceptionNetV3 | 16 | 1e-4 | 确认输入尺寸是否为 299 |
| ResNet | 16 | 1e-4 | 可从冻结前几层开始迁移 |
如果使用 ImageNet 预训练权重做迁移学习,常见做法是先把模型除全连接层以外的参数冻结,只训练最后一层分类器,跑几个 epoch 后再解冻全部参数,并且把学习率降到 1e-5 量级。这种做法在医学图像的小数据集上尤其管用,因为有限样本不足以重新训练百万级参数。
from model.VGG16 import VGG16 net = VGG16(num_classes=3) # 先冻结所有层 for param in net.parameters(): param.requires_grad = False # 只解冻最后一层分类器,常见做法是遍历命名参数时跳过 features for name, param in net.named_parameters(): if name.startswith("classifier") or name.startswith("fc"): param.requires_grad = True optimizer = torch.optim.Adam( filter(lambda p: p.requires_grad, net.parameters()), lr=1e-4 )这段代码的原理是保留预训练提特征的能力,只让分类头适应新类别。训练的时候注意named_parameters的输出层名前缀取决于模型定义文件里的写法,VGG 一般是classifier,ResNet 一般是fc,要看一眼model/下对应文件确认。
5. Web 推理链路:把训练好的模型装进 Flask,做个能上传图片的检测页面
训练完成只是工程的一半,这个压缩包最有竞争力的地方在app.py——它把模型真正变成了“能演示的东西”。比赛答辩时,评委能亲手上传一张图看到结果,比只看训练曲线有说服力得多。
5.1 Flask 应用初始化和模型加载
import os import torch from flask import Flask, request, render_template from PIL import Image from torchvision import transforms from model.VGG16 import VGG16 app = Flask(__name__) # 上传文件保存目录,必须在 static 下才能被浏览器访问 app.config["UPLOAD_FOLDER"] = "static/uploads" ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "bmp"} # 加载训练好的权重:注意 load_state_dict 而不是 load model = VGG16(num_classes=3) model.load_state_dict( torch.load("checkpoints/vgg16_best.pth", map_location="cpu") ) 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] ) ])torch.load和load_state_dict是两回事,前者读文件,后者把读出的权重灌进模型。如果checkpoints/vgg16_best.pth是用torch.save(model.state_dict(), path)保存的,就需要先构造模型再调用load_state_dict;反过来如果保存的是整个模型,加载方式又不一样。这个坑在医学影像相关的权重管理里几乎一定遇到。
5.2 预测函数和上传路由:图像校验、推理、返回结果
def predict_image(img_path): img = Image.open(img_path).convert("RGB") img_tensor = transform(img).unsqueeze(0) with torch.no_grad(): logits = model(img_tensor) probs = torch.softmax(logits, dim=1) return probs[0].tolist() @app.route("/", methods=["GET", "POST"]) def index(): if request.method == "POST": file = request.files.get("file") if file is None or file.filename == "": return render_template("index.html", error="请选择图片") ext = file.filename.rsplit(".", 1)[-1].lower() if ext not in ALLOWED_EXTENSIONS: return render_template("index.html", error="不支持的图片格式") save_path = os.path.join( app.config["UPLOAD_FOLDER"], file.filename ) file.save(save_path) probs = predict_image(save_path) result = { "class_names": ["covid", "normal", "pneumonia"], "probabilities": probs, "filename": file.filename } return render_template("index.html", result=result) return render_template("index.html") if __name__ == "__main__": # debug=True 只用于本地调试,线上或答辩演示务必关掉 app.run(host="0.0.0.0", port=5000, debug=False)这段代码有几个值得强调的设计。图片格式白名单必须写在前面,避免用户传一个非图片文件进来导致Image.open直接抛异常。.convert("RGB")是为了兼容灰度图或带透明通道的 PNG,用 PIL 统一转成三通道。torch.softmax(logits, dim=1)把输出转成概率分布,三个类的概率加起来等于 1。
模板里的class_names顺序必须和训练时的ImageFolder类别顺序一致。因为这个工程用ImageFolder,类别索引是按文件夹名排序来的,比如covid、normal、pneumonia排出来,这个顺序写死在代码里就是隐患;如果后来数据目录变了,这里要同步改。
5.3 模板页面和上传目录的配合
templates/index.html负责渲染上传按钮和检测结果,里边的表单action一般指向/,enctype必须是multipart/form-data。上传的图片会保存到static/uploads/,这个目录必须已经存在,否则file.save会报错。static目录下的文件可以通过/static/uploads/xxx.jpg直接访问,所以保存图片后浏览器能正常显示。
这个工程里有两个 HTML 文件,如果打开页面发现排版错乱或变量不显示,就把index.html和index_1.html对比一下。常见差异是调用了不同的 CSS 文件路径,或者用了{{ result.probabilities[0] }}这种模板变量,而app.py回传的字典里没有对应键。
6. 避坑与排查:医学影像深度学习项目里真正会卡住你的五个问题
这类项目报错翻车的高发区,我把实际跑过之后遇到最多的五个问题整理如下,每条都是现象、原因、解决三步。
6.1 加载权重时报 unexpected key
现象:load_state_dict抛出Missing key(s)或Unexpected key(s),模型训练白跑。
原因:保存时用的是torch.save(model, path)而不是torch.save(model.state_dict(), path),或者网络结构定义文件被改过,层名对不上。
解决:先确认保存方式。如果保存的是整个模型,直接model = torch.load(path, map_location="cpu");如果保存的是状态字典,就先构造模型再load_state_dict。检查model/下的网络定义和训练时是否一致,尤其是num_classes是否改动。
6.2 显存明明够用,但 VGG19 一跑就 OOM
现象:切到 VGG19 后CUDA out of memory,但用 VGG16 没问题。
原因:VGG19 的参数量和中间特征图比 VGG16 大不少,batch_size=32时中间激活值直接撑爆显存。
解决:先把 batch_size 降到 16 或 8,再加torch.cuda.empty_cache()。还不行就把输入尺寸从 224 降到 192,验证集和训练集同步改。比赛或课题场景里,小 batch 多迭代并不是坏事,反而更容易跳出局部最优。
6.3 训练损失下降但验证准确率一直在 50% 上下
现象:损失值从 2 降到 0.8,但验证集准确率始终在随机水平。
原因:类别不平衡加上预设的类别名顺序和推理时的class_names不一致,或者验证集和训练集存在同样的脏数据。
解决:先打印出来看看train_set.classes的索引顺序,再去app.py核对class_names列表。如果确认顺序没问题,就在训练时打印每轮每个类别的召回率,配合class_weights一起排查。准确率 50% 往往是随机猜的表现,先看类别分布再谈调参。
6.4 上传图片推理时概率永远是 [0.33, 0.33, 0.33]
现象:多头跑完,推理页面输出的概率恒等于 1/类别数。
原因:模型推理前没有调用model.eval(),BatchNorm 层还在用训练状态统计量;或者预测时忘了包torch.no_grad(),更隐蔽的原因是上传图片本身是无效图像,PIL 打开后全是噪声。
解决:加载模型后马上model.eval()。推理函数里确认with torch.no_grad():包住了前向计算。再上传一张已知分类的图片交叉验证,排除图片本身的问题。
6.5 图片文件名带中文上传失败或路径解码报错
现象:本地调试正常,换台电脑上传中文名图片后,后端报 UnicodeDecodeError 或文件找不到。
原因:Windows 下 Flask 保存文件用的是系统默认编码,中文文件名跨平台后编码不一致,部分 Linux 服务器直接拒绝。
解决:保存上传文件时强制改成随机英文名:
import uuid safe_filename = str(uuid.uuid4()) + "." + ext save_path = os.path.join(app.config["UPLOAD_FOLDER"], safe_filename) file.save(save_path)这样还能避免两个用户上传同名文件互相覆盖的问题,上传目录里的历史文件也更容易管理。
7. 进阶技巧:用五模型软投票和水印级约束提升结果可信度
当你把单模型跑顺后,这个工程可以往前再走一步。五套训练脚本的存在不只是为了对比曲线,更是为了做模型集成。常见的做法是让 VGG16、VGG19、InceptionNetV3、ResNet 各出一个概率分布,然后按权重求和:
def ensemble_predict(path_list): final_probs = [0.0, 0.0, 0.0] for prob in path_list: final_probs = [a + b for a, b in zip(final_probs, prob)] final_probs = [p / len(path_list) for p in final_probs] return final_probs软投票的原理很简单:不同网络关注的图像特征侧重点不同,CNN 关注局部纹理,ResNet 关注更深的语义特征,平均后的结果通常比任何单模型更稳。答辩或演示时,我习惯把单模型结果和集成结果同时展示,这本身就是很好的模型分析素材。
更值得做的是验证指标升级。医学影像这种类别不平衡场景,只看 accuracy 会被数据分布欺骗。比赛作品汇报时你应该补上敏感度(sensitivity)和特异度(specificity):
def medical_metrics(preds, labels, target_class=1): tp = sum(1 for p, l in zip(preds, labels) if p == target_class and l == target_class) fn = sum(1 for p, l in zip(preds, labels) if p != target_class and l == target_class) fp = sum(1 for p, l in zip(preds, labels) if p == target_class and l != target_class) sensitivity = tp / (tp + fn) if (tp + fn) else 0 specificity = tp / (tp + fp) if (tp + fp) else 0 # 注意这里的写法通常为负类指标 return sensitivity, specificity严格的特异度计算还要区分负类统计,但思路是明确的:盲猜正常的模型准确率可以很高,敏感度却会暴露它根本没识别出阳性样本。从那以后,我每次跑这类医学影像项目都会先把训练脚本里的指标改成敏感度加特异度再动手调参,验证集上这两项不达标,其他都是虚的。希望这个工程里的目录组织和训练链路能帮你把自己的数据集跑通,少走我当时走过的弯路。
本文还有配套的精品资源,点击获取