肺病辅助诊断平台实现:迁移学习、Grad-CAM与FastAPI部署

发布时间:2026/9/15 14:07:01
肺病辅助诊断平台实现:迁移学习、Grad-CAM与FastAPI部署
简介这是一份基于深度学习的肺病辅助诊断平台完整毕设/课设项目面向计算机、电子信息、数学等专业学生帮助理解CNN在肺部CT影像分析中的应用覆盖数据预处理、模型训练验证、结果评估等关键环节。资源共201个文件压缩包约10.01MB以Java源码、HTML/CSS/JS前端页面、GIF演示动图、PNG截图等为主同时包含配置文件和说明文档较为清晰地呈现了项目的目录结构与运行方式。目前已有50人学习下载。借助其中的源码与演示素材可以快速掌握从医学图像处理到模型搭建的完整流程也可参考其结构完成类似诊断平台的设计与实现对毕业设计和课程实践具有直接的借鉴价值。1. 基于深度学习的肺病辅助诊断平台一头是课程答辩一头是落地边界把这个标题拆开看真正要交付的东西不止是一个能跑的模型圆片上传、模型推理、结果返回、判定依据可视化这四件事串起来才叫辅助诊断平台。只交一个训练好的权重文件给答辩老师基本撑不过第二个问题反过来把前端界面做得再花哨模型却只会对训练集“背诵”也一样站不住。对这个题目我的通用做法是把系统拆成数据准备、模型训练、可解释性输出、后端服务四段每一段都有可以单独演示和提问的产出点。整套方案在普通笔记本上就能复现不需要多卡集群也不需要去抢云上A100。适合正在选题的本科生也适合想快速搭一套完整兜底方案的从业者重点不是“我训了个多牛的模型”而是你怎么证明这个流程在真实边界条件下依然稳定、可解释、可部署。2. 数据先于模型把X光目录变成可训练的Loader顺便避开zip解压的坑2.1 选定任务类型分类、检测还是分割拿到肺病辅助诊断的题目第一件事是定任务粒度。常见路线有三条肺炎X光片做多分类、CT影像做肺结节检测、肺部区域做分割。做毕设或课设最稳妥的是第一条因为公开数据集最成熟、标注质量相对可控、单卡训练时间短而且后端演示时交互最直观。COVID-19 Radiography数据集、ChestX-ray2017等都可以用包含正常、病毒性肺炎、细菌性肺炎、新冠等类别按文件夹存放。先用图像分类把“辅助诊断”的价值讲清楚再用热力图回答“模型凭什么这么判断”这已经能构成一个完整的答辩故事。目标检测适合数据量更充裕、想深挖的同学分割的标注成本高两周内做完数值指标容易做出演示效果难。2.2 解压与文件名乱码zip作为数据载体的第一个坑课程设计的题目明确挂着 .zip数据包大概率也是zip压缩包。冷门但高频的问题是Windows 上压缩、Linux 上解压后中文文件名乱码。zip 的编码标识不统一有的包用 GBK有的用 UTF-8系统 locale 不对就会解出一堆乱码文件名。常见做法是解压后先ls看一眼乱码就强制指定编码解压unzip -O gbk dataset.zip -d ./data-O gbk的作用是让 unzip 按 GBK 解释压缩包内的文件名适用于从 Windows 传过来的中文素材包。如果压缩包本身是 UTF-8 编码就不要加这个参数加了反而会把中文名解成乱码。还有一类包是分卷 zip解压前要把分卷放在同一目录直接解压第一个文件即可。2.3 把文件夹映射为Dataset并做五类划分数据规范化的第一步是把所有类别统一复制到一个父目录下结构长这样data/ covid/ normal/ viral_pneumonia/ bacterial_pneumonia/ lung_opacity/然后按 7:2:1 划分训练、验证、测试。划分的时候按病人而不是按图片划分如果同一病人的多张片子既在训练集又在测试集模型会通过背景特征“作弊”指标虚高。没有病人ID时退而求其次按文件名前缀聚合后再分。划分脚本直接用 shutil 移动文件加一个随机种子保证可复现import os import random import shutil random.seed(42) src_root data train_root, val_root, test_root split/train, split/val, split/test for cls in os.listdir(src_root): cls_path os.path.join(src_root, cls) if not os.path.isdir(cls_path): continue imgs os.listdir(cls_path) random.shuffle(imgs) n len(imgs) tr, va int(n * 0.7), int(n * 0.9) for split_root, part in [ (train_root, imgs[:tr]), (val_root, imgs[tr:va]), (test_root, imgs[va:]) ]: os.makedirs(os.path.join(split_root, cls), exist_okTrue) for f in part: shutil.copy( os.path.join(cls_path, f), os.path.join(split_root, cls, f) )这里tr和va分别代表训练集与验证集的截止下标imgs[:tr]取前70%imgs[tr:va]取接下来的20%imgs[va:]就是最后10%。复制而不是移动可以在划分出错时保留原始数据。划分完成后做一个类别计数确认每个split下都有全部类别。2.4 用ImageFolder与数据增强两种方式喂给模型划分好的目录直接用torchvision.datasets.ImageFolder读取不需要手写Dataset类。训练与验证阶段采用不同的transform训练做随机旋转、水平翻转、随机亮度对比度扰动验证与测试只做resize和标准化from torchvision import datasets, transforms train_tf transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomRotation(10), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) val_tf transforms.Compose([ transforms.Resize((256, 256)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) train_ds datasets.ImageFolder(split/train, transformtrain_tf) val_ds datasets.ImageFolder(split/val, transformval_tf)RandomRotation(10)控制在正负10度内旋转过大容易把肺纹理转成不自然的形态ColorJitter的两个参数分别表示亮度和对比度的扰动幅度0.2 对X光片是安全范围。标准化用的均值与方差来自 ImageNet因为后面要用 ImageNet 预训练权重输入分布保持一致是迁移学习生效的前提。类别不均衡在这个任务里几乎必然出现正常类图片数量往往远大于肺炎类。可以给DataLoader的采样器加上类别权重先统计每个类别的样本数然后给样本数较少的类别赋予更大的采样概率。这一步对最终准确率的影响比调模型结构更明显。3. 训练一个能答辩的模型DenseNet迁移学习与三个关键参数3.1 为什么选DenseNet而不是从头训ResNet对肺病X光分类我一般推荐以 DenseNet121 或 ResNet50 为主干网络加载在 ImageNet 上预训练好的权重然后替换最后的全连接层。ImageNet 预训练提供的是通用纹理与边缘特征肺病X光片上的病灶边界、纹理模糊区域正好可以复用这些底层特征。如果从头训练在几万张图的数据量级下模型收敛速度会明显变慢验证集波动范围也大演示时很难展示一条漂亮的loss下降曲线。DenseNet121相比ResNet50的优势是参数利用率高同样的参数量下梯度传导更顺畅适合小样本医学影像。缺点是在推理时需要更多显存来存放中间特征但256分辨率下的单张推理没有压力。3.2 迁移学习的正确姿势冻结还是全量微调常规做法是先冻结backbone只训练新加的全连接层跑几个epoch然后解冻所有层用很小的学习率全量微调。直接全量微调容易让预训练权重被破坏尤其当数据量只有几千张时过拟合会来得非常快。import torch import torch.nn as nn import torchvision.models as models model models.densenet121(weightsmodels.DenseNet121_Weights.IMAGENET1K_V1) num_classes 5 for param in model.parameters(): param.requires_grad False model.classifier nn.Sequential( nn.Linear(model.classifier.in_features, 512), nn.ReLU(inplaceTrue), nn.Dropout(0.3), nn.Linear(512, num_classes), ) for param in model.classifier.parameters(): param.requires_grad True第一步的requires_grad False把backbone所有参数冻结只允许最后的classifier更新这样可以先用较少epoch把新分类头训练到合理水平。Dropout(0.3)在全连接层前随机丢弃30%的神经元能缓解小数据下的过拟合。第二阶段把前面几层block也放开学习率降到原来的十分之一。3.3 训练循环里的三个要点加权损失、学习率衰减、保存最优模型损失函数用带类别权重的交叉熵。类别权重设置成样本数的反比让小类别的梯度贡献变大避免模型把所有样本都预测成大类from sklearn.utils.class_weight import compute_class_weight import numpy as np labels train_ds.targets class_weight compute_class_weight( class_weightbalanced, classesnp.unique(labels), ylabels ) class_weight torch.tensor(class_weight, dtypetorch.float).to(device) criterion nn.CrossEntropyLoss(weightclass_weight)compute_class_weight的balanced模式会自动计算权重权重值等于样本总数除以类别数与类样本数的乘积。把权重传给CrossEntropyLoss后每批次loss都会按类别放大或缩小。不传给验证集使用验证集仍然用普通交叉熵这样指标更接近真实场景。优化器采用 AdamW 而不是 Adamweight decay 设 1e-4。学习率初始设为 1e-4每一步验证loss不再下降时用ReduceLROnPlateau将学习率乘以 0.5连续三次验证loss不降就停止训练。模型保存只看验证集AUC或准确率每个epoch结束后在验证集上跑一次指标更好就覆盖保存from torch.optim.lr_scheduler import ReduceLROnPlateau optimizer torch.optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-4) scheduler ReduceLROnPlateau(optimizer, modemin, factor0.5, patience2) best_acc 0.0 for epoch in range(30): model.train() for imgs, targets in train_loader: imgs, targets imgs.to(device), targets.to(device) optimizer.zero_grad() outputs model(imgs) loss criterion(outputs, targets) loss.backward() optimizer.step() val_loss, val_acc evaluate(model, val_loader, device) scheduler.step(val_loss) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pth)ReduceLROnPlateau的patience2表示连续两个epoch验证loss都没有低于当前最低值时学习率减半factor0.5控制衰减比例。用“验证loss”驱动学习率、用“验证准确率”驱动模型保存这两个标准可以不同实践中分开更容错。学习率减半后模型通常会在局部极小点附近继续震荡下降直接设置固定epoch数反而容易停在次优点。3.4 训练结束必须看的指标不止Accuracy肺病分类数据里正常样本占比可能过半整体准确率会被大类拉高单看Accuracy会掩盖小类的低召回。至少要输出每个类别的Precision、Recall、F1画混淆矩阵确认新冠、细菌性肺炎这类小类别没有被“抹平”。另一个需要检查的点是验证集与测试集指标是否接近。如果验证集90%而测试集只有75%说明划分时泄漏了或者验证集参与了很多次早停选择这种情况要回到数据划分重新处理。测试集干脆只在最终评估时使用一次中间一切调参和早停都不看测试集结果。4. 让诊断结论可解释用Grad-CAM热力图回答“模型在看哪里”4.1 辅助诊断平台不能只输出一个“阳性”单纯给出“肺炎 87%”的结论在答辩现场和真实落地场景中都缺乏说服力。辅助诊断的语义是辅助医生需要看到模型决策背后的依据。对图像分类来说最直观的依据是热力图把模型注意力集中的区域高亮叠加到原图上观察是否聚焦在肺部纹理异常的位置。Grad-CAM 是适用范围最广的方案不需要重新训练模型。它的思路是取最后一个卷积层的特征图用针对目标类别的梯度做加权求和得到一个与输入分辨率相关的空间注意力图再上采样到原图尺寸。4.2 用Pytorch Hooks实现Grad-CAM推理实现时需要拿到某个中间层的输出并计算输出对中间层的梯度。以 DenseNet121 为例取features模块最后一个卷积特征提取层的输出来做用forward hook保存该层输出用backward hook迭代改变模型输出后反向传播得到梯度然后归一化成热力图。import cv2 import numpy as np import torch from torchvision import transforms class GradCAM: def __init__(self, model, target_layer): self.model model self.gradients None self.activations None target_layer.register_forward_hook(self.save_activation) target_layer.register_full_backward_hook(self.save_gradient) def save_activation(self, module, inp, out): self.activations out def save_gradient(self, module, grad_in, grad_out): self.gradients grad_out[0] def generate(self, input_tensor, class_idxNone): output self.model(input_tensor) if class_idx is None: class_idx output.argmax(dim1).item() self.model.zero_grad() one_hot torch.zeros_like(output) one_hot[0, class_idx] 1.0 output.backward(gradientone_hot) weights self.gradients.mean(dim(2, 3), keepdimTrue) cam torch.relu((weights * self.activations).sum(dim1, keepdimTrue)) cam torch.nn.functional.interpolate( cam, sizeinput_tensor.shape[2:], modebilinear, align_cornersFalse ) cam cam.squeeze().cpu().numpy() cam (cam - cam.min()) / (cam.max() - cam.min() 1e-8) return camregister_full_backward_hook负责在反向传播时拿到目标层的梯度grad_out[0]是梯度张量。one_hot只保留目标类别位置的梯度意思是只关心“什么输入让模型判成这一类”而不是整个输出向量的整体变化。interpolate把特征图大小还原到输入尺寸。后面的归一化把热力图压到0到1之间便于叠加。4.3 在原图上叠加并输出结果拿到cam后与原始图像做叠加。先将原始图缩放到与热力图一致尺寸再把热力图应用颜色映射与图像加权融合def overlay_cam(img_bgr, cam_norm): cam_resized cv2.resize(cam_norm, (img_bgr.shape[1], img_bgr.shape[0])) heatmap cv2.applyColorMap( (cam_resized * 255).astype(np.uint8), cv2.COLORMAP_JET ) overlay cv2.addWeighted(img_bgr, 0.6, heatmap, 0.4, 0) return overlayaddWeighted的系数组合表示原图占60%、热力图占40%0.4的透明度既保留病灶区域可见又不会完全遮住X光片的原始纹理。输出的叠加图就是平台后端的“依据文件”和预测标签、置信度一起返回给前端展示。4.4 什么情况下热力图不可信如果热力图的高亮区域集中在图片角落、胸腔边缘或类似字母的标记处大概率是模型学到了无关特征。出现此类情况时优先检查数据预处理是否在resize时引入了伪影以及划分数据时是否泄漏了同一病人的重复图。Grad-CAM 的分辨率受最后一个卷积层的空间尺寸限制DenseNet121 在256输入下最后一层特征图是8x8。热力图会显得粗糙这是正常现象不代表模型差。如果确实想要更细的定位可以改用Layer-CAM或用Eigen-CAM做平滑但课设答辩中Grad-CAM的演示效果已经足够。5. 封装成平台并处理三个部署边界接口、打包与置信度策略5.1 用FastAPI搭建最小推理服务平台后端推荐 FastAPI自带OpenAPI文档前端联调方便。接口接收上传的图片文件加载训练好的模型权重返回预测类别、各类置信度、热力图base64三件套from fastapi import FastAPI, UploadFile, File from PIL import Image import io, base64 app FastAPI() model load_model(best_model.pth) app.post(/predict) async def predict(file: UploadFile File(...)): image Image.open(io.BytesIO(await file.read())).convert(RGB) tensor val_tf(image).unsqueeze(0).to(device) with torch.no_grad(): probs torch.softmax(model(tensor), dim1)[0] cam gradcam.generate(tensor) overlay overlay_cam(np.array(image)[:, :, ::-1], cam) _, buffer cv2.imencode(.jpg, overlay) overlay_b64 base64.b64encode(buffer).decode(utf-8) result { prediction: class_names[probs.argmax().item()], confidence: round(probs.max().item(), 4), probabilities: {n: round(p, 4) for n, p in zip(class_names, probs.tolist())}, overlay: overlay_b64, } return resultsoftmax将模型输出的logits转换成概率probs.max()为最高概率类别的置信度返回给前端用于阈值判断。overlay字段是热力图叠加图的base64编码前端直接放进img标签的src里就能展示。5.2 模型权重与代码分离平台可交付的目录结构打包交付时要区分“源码”和“运行环境”。毕设答辩现场要当场演示依赖环境很容易出问题我的做法是提供一个目录结构lung_diagnosis_platform/ app.py models/ best_model.pth requirements.txt README.mdrequirements.txt用pip freeze requirements.txt生成但不能直接原样提交里面会混入大量与项目无关的包。应该只包含torch、torchvision、fastapi、uvicorn、opencv-python-headless、numpy、Pillow这些必要依赖。权重文件如果太大DenseNet121约30MB按规定单独的压缩包提供README里注明解压位置。5.3 置信度过低时给“拒绝判断”结果辅助诊断平台与普通分类Demo的一个重要区别是必须设计“不知道”的分支。当最高置信度低于0.6或各类别概率分布平坦时返回结果应为“图像质量不佳建议专科复查”而不是强行给出一个类别。这个策略在论文里可以写成“基于置信度阈值的拒绝机制”在答辩中是加分点。阈值要由验证集统计得出跑一遍验证集把每个样本的最高置信度记录下来观察分类正确样本与错误样本在置信度分布上的重叠区取重叠区偏右的位置作为阈值。5.4 三个最常见部署报错与对应处理第一个是CUDA out of memory多数情况下不是显存不够而是torch.load默认把权重加载到GPU后没有及时释放旧模型。部署时直接指定map_locationmodel.load_state_dict( torch.load(models/best_model.pth, map_locationcpu) )这句让权重先加载到CPU再拷贝到当前设备避免权重与当前设备类型不匹配。第二个是OpenCV在无GUI服务器上崩溃安装opencv-python-headless而不是opencv-python可以绕开。第三个是中文类名在JSON响应中乱码FastAPI返回的JSON默认UTF-8不会有问题但如果前端写入Excel或CSV就要显式编码。5.5 答辩前的最终验证流程给定一张测试集图片调用HTTP接口确认返回的overlay热力图叠加位置与病变区域吻合再准备一张单通道灰度图和一张带黑边的扫描图验证预处理容错能力。整理输出一份带着接口返回结果的截图比对同一图片走模型脚本和走HTTP接口的两份结果是否完全一致。模型脚本与接口服务如果存在预处理差异例如脚本里用的是PIL加载而接口里用的是OpenCV颜色通道顺序不同会直接导致结果不同这个细节值得单独检查一遍。本文还有配套的精品资源点击获取