简介:面向计算机相关专业毕业设计开发的阿尔兹海默智能诊断系统完整项目包,基于3D卷积神经网络处理脑部MRI影像,并借助Flask/Django构建Web诊断应用,涵盖数据预处理、模型训练与评估、后端接口设计等关键环节,适合需要快速搭建医学图像深度学习应用的学员参考。压缩包共21个文件,核心为4个Python源码文件(含模型构建、训练、数据集处理及Web入口),配套1个已训练模型权重(.pth)、1个示例MRI数据(.nii),以及8张网络结构/界面截图、3个说明文档(含中文README和开源许可证翻译),整个压缩包仅16.5MB,便于本地下载与查阅,尤其适合课程设计与毕设启动参考。已有423人学习,说明该项目具备实际参考价值。从内容预览看,项目保留了上传图片目录和依赖清单,读者可按文档和代码部署运行,借助模型结构图与界面截图快速理解3D CNN在早期筛查中的落地流程,也可直接利用已训练权重进行推理演示,直观感受模型效果。整体项目代码结构清晰,注释完整,便于二次开发与功能扩展。
1. 3D CNN 在阿尔茨海默诊断里的位置,以及一个 Web 壳子要解决什么
阿尔茨海默病的影像学诊断,核心依据是海马体萎缩、皮层变薄和脑室扩大,这些变化在 MRI 上是细微的三维结构改变。2D 切片分析会把体积信息压扁,层与层之间的解剖连续性被丢掉了,所以这几年基于 3D 卷积神经网络(CNN)的分类模型,成了从 T1 加权 MRI 里找萎缩模式的主流做法。标题里那个.zip里的东西,说白了就是“训练好一个 3D CNN,在浏览器页面上传 MRI 文件,远端返回分类概率”的完整闭环。适合谁读?正在做毕设、要复现基线、或者想把一个深度学习模型从 Jupyter 挪到真实 Web 服务里的工程师。这条链路上最花时间的不是网络结构,而是数据管线。
2. 3D CNN 的输入从哪来:NIfTI 读取、重采样与中心裁剪
2.1 为什么不能把 MRI 直接塞进模型
MRI 原始数据是NIfTI格式,也就是.nii或.nii.gz文件,里面不只有三维数组,还附带一个affine矩阵,记录每个体素对应到真实解剖空间的位置。问题在于,不同设备的采集参数不一样,体素间距可能从0.5mm到2mm不等。如果不做重采样,模型看到的同一片脑区在不同样本里对应的体素数就不同,卷积核的感受野也就乱了。对 3D CNN 来说,输入张量的空间尺寸必须一致,所以第一步是把所有数据统一到各向同性体素,比如1mm × 1mm × 1mm。
常见的做法是用NiBabel读数据,用scipy.ndimage.zoom或nilearn.image.resample_img做重采样,然后中心裁剪到96×96×96。为什么是 96 而不是 128?显存。3D 卷积的中间特征图比 2D 大一个数量级,96³ 在单卡 12GB 上下能跑起来,128³ 直接吃满。以下是我常用的预处理脚本核心部分。
import nibabel as nib import numpy as np import os from nilearn.image import resample_img def preprocess_nifti(nii_path, target_zoom=1.0, target_shape=(96, 96, 96)): # 读取 NIfTI 文件,保留仿射信息 img = nib.load(nii_path) # 线性重采样到 1mm 各向同性,这一步统一体素间距 img_resampled = resample_img(img, target_affine=img.affine, target_shape=target_shape, interpolation='linear') data = img_resampled.get_fdata() data = np.clip(data, np.percentile(data, 0.5), np.percentile(data, 99.5)) # Z-score 归一化,消除不同扫描仪间的强度差异 mean_val, std_val = data.mean(), data.std() data = (data - mean_val) / (std_val + 1e-8) # 中心裁剪,直接取齐 cx, cy, cz = np.array(data.shape, dtype=int) // 2 half = np.array(target_shape, dtype=int) // 2 data_cropped = data[cx-half[0]:cx+half[0], cy-half[1]:cy+half[1], cz-half[2]:cz+half[2]] return data_cropped.astype(np.float32) # 批量预处理示意 out_list = [] for name in ['AD_001.nii.gz', 'NC_002.nii.gz']: vol = preprocess_nifti(name) out_list.append(vol.reshape(1, 1, *vol.shape)) # (B, C, D, H, W) np.save('preprocessed_data.npy', np.concatenate(out_list, axis=0))逻辑拆解:resample_img里我传入了原始affine,意思是在保持空间位置不变的前提下把体素拉成各向同性。np.clip用百分位截断可以去掉血管等极亮伪影。Z-score 归一化对每个样本独立做,避免不同扫描仪的全局强度偏移影响网络收敛。最后 reshape 成 PyTorch 期望的五维输入格式,通道维放在第二维。
2.2 数据增强:在 patch 层面做,不写回磁盘
很多 2D 分类任务会在训练时把增强后的图像存成新文件,但 3D NIfTI 单文件可能几百 MB,磁盘开销太大。正确做法是放在Dataset.__getitem__里做在线增强:随机旋转、随机翻转、加高斯噪声。对医学影像要克制,180 度旋转会破坏解剖先验,左右翻转对脑部是可接受的,因为左右半球的萎缩模式本身不对称,翻转相当于增加了样本多样性。
2.3 体素间距不一致,模型训不动
如果训练损失能降但验证集准确率一直上不去,先检查数据这一环:用nib.load打印所有样本的header.get_zooms(),看看是不是有的样本是1.2mm、有的是1.5mm。我遇到过一次,同一批 ADNI 数据里不同站点的体素间距不一致,模型训练了 80 个 epoch 验证集还是徘徊在 60%。把预处理统一到同一体素之后,直接涨到 78%。这不是模型的问题,是数据预处理的 bug。另外,重采样插值方式默认用linear,如果做的是体积定量分析可以改用nearest,对分类任务线性插值足够,而且更平滑。
3. 3D CNN 网络结构怎么定:从 2D 到 3D 的卷积、池化与残差
3.1 3D 卷积比 2D 多出来的那个维度在卷什么
2D 卷积核在H×W平面上滑动,输出特征图保留平面结构;3D 卷积核在H×W×D三个方向同时滑动,输出是五维张量(N, C, D', H', W')。这个D维捕捉的是解剖结构在空间纵深上的连续性。海马体是一个弯曲的三维结构,萎缩从头部向尾部延伸,2D 切片要么截断这种连续性,要么让同一结构在多个切片里重复出现,反而干扰分类。3D 卷积在参数数量上比 2D 多一个K_d倍(K_d是深度方向的卷积核尺寸),所以 3D CNN 更容易过拟合,这是选择结构时的核心约束。
3.2 残差结构 + 降采样策略
我不会从零写一个大网络,常见做法是从 3D ResNet 骨架起步,因为它有预训练权重,而且残差连接让梯度更稳。这里给出一个可运行的 PyTorch 示例,用torchvision里现成的resnet18改造成 3D 版本,再接一个分类头。
import torch import torch.nn as nn from torchvision.models import resnet18 def load_3d_resnet(num_classes=2, pretrain=True, freeze_backbone=True): backbone = resnet18(pretrained=pretrain) # 把 2D 卷积核替换为 3D,关键:原来 (in_c, out_c, k, k) -> (in_c, out_c, k, k, k) def replace_conv2d_to_conv3d(module): for name, child in module.named_children(): if isinstance(child, nn.Conv2d): new_conv = nn.Conv3d( child.in_channels, child.out_channels, kernel_size=(3, 3, 3), stride=1, padding=1, bias=False ) setattr(module, name, new_conv) elif isinstance(child, nn.BatchNorm2d): setattr(module, name, nn.BatchNorm3d(child.num_features)) else: replace_conv2d_to_conv3d(child) replace_conv2d_to_conv3d(backbone) # 最后一层池化改为 3D 自适应,避免对输入尺寸硬编码 backbone.avgpool = nn.AdaptiveAvgPool3d((1, 1, 1)) # 替换分类头 backbone.fc = nn.Sequential( nn.Dropout(0.5), nn.Linear(backbone.fc.in_features, num_classes) ) return backbone model = load_3d_resnet(num_classes=2, pretrain=False, freeze_backbone=False)逻辑说明:replace_conv2d_to_conv3d是递归替换,核心是把所有nn.Conv2d换成nn.Conv3d,同时把BatchNorm2d也换成BatchNorm3d。之所以不用torchvision原生 3D ResNet(r3d_18),是因为那个权重是视频分类上预训练的,输入分布和 MRI 差异更大。如果你要直接用,也完全可以:torchvision.models.video.r3d_18(pretrained=True),但输入层是(N, 3, D, H, W),单通道 T1 MRI 需要复制三份输入,浪费通道。AdaptiveAvgPool3d的好处是不管输入体积多大,池化后都是(1,1,1),这给推理阶段留了灵活空间——虽然训练时最好保持固定输入.
3.3 训练策略:小学习率、早停、类别权重
分类头只用 2 类(AD vs. NC),但 ADNI 数据集正负样本不一定均衡,计算交叉熵时用torch.weight_cross_entropy传入类别权重。优化器推荐 AdamW,学习率给1e-4,不要给太大,因为医学影像数据量通常只有几百到几千,模型很容易把噪声记住。我这里设置了一个 3D CNN 参数的常用组合表,可以直接搬走。
| 超参数 | 值 | 说明 |
|---|---|---|
| 输入尺寸 | 96×96×96 | 显存与分辨率的折中,再大就 16GB 起步 |
| 卷积核 | 3×3×3 | 3D 感受野大小,堆两层比用 5×5 稳定 |
| 初始通道数 | 32 | 3D 网络参数爆炸快,通道少一半参数少 8 倍 |
| 优化器 | AdamW | lr=1e-4,weight_decay=1e-5 |
| Batch Size | 4~8 | 96³ 体积下 8 已经能占满 12GB 显存 |
| 早停 | patience=10 | 监控验证集 Loss,不降就砍 |
| 数据增强 | 旋转 ±10°、翻转、高斯噪声 | 在线增强,不做离线存储 |
训练代码里我一般这么组织 epoch 循环:
criterion = nn.CrossEntropyLoss(weight=torch.tensor([1.0, 2.0]).cuda()) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-5) for epoch in range(100): model.train() for batch in train_loader: x, y = batch['volume'].cuda(), batch['label'].cuda() pred = model(x) loss = criterion(pred, y) optimizer.zero_grad() loss.backward() optimizer.step() # 验证 model.eval() val_loss, val_acc = validate(model, val_loader, criterion) print(f'Epoch {epoch}: val_loss={val_loss:.4f} val_acc={val_acc:.4f}') # 保存最优 if val_loss < best_loss: torch.save(model.state_dict(), 'best_ad_model.pt') best_loss = val_loss三个要点:权重[1.0, 2.0]表示 AD 样本的 loss 权重是 NC 的两倍,如果 AD 样本少就往上调;validate函数里要记得包torch.no_grad();保存模型用state_dict,不要整个序列化模型对象,否则换了网络结构就加载失败。训练时长方面,1080Ti 或 3090 上跑 60 个 epoch 大约需要 4~6 小时,如果时间紧就把输入尺寸减到64³,但验证集准确率通常会降 3~5 个点.
4. 把模型包成 Web 应用:Flask 推理接口与前后端联调
4.1 为什么用 Flask 而不是 FastAPI
FastAPI 的异步性能和自动文档确实更好,但 3D CNN 推理本身是 CPU/GPU 密集任务,瓶颈不在 Web 框架。Flask 的生态更老也更简单,部署到学校服务器时少踩型号兼容的坑。这里我建一个最小的 Flask 服务,只暴露一个 HTTP 接口、处理 NIfTI 文件上传、返回分类概率,代码可以直接抄.
4.2 单文件推理接口实现
import os import torch import numpy as np import nibabel as nib from flask import Flask, request, jsonify from model import load_3d_resnet app = Flask(__name__) # 全局加载一次,避免每次请求都重新初始化模型 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = load_3d_resnet(num_classes=2, pretrain=False) model.load_state_dict(torch.load('best_ad_model.pt', map_location=device)) model.to(device).eval() def preprocess_upload(file_stream): img = nib.load(file_stream) data = img.get_fdata() # 与训练时完全一致的归一化与裁剪 data = (data - data.mean()) / (data.std() + 1e-8) target_shape = (96, 96, 96) cx, cy, cz = np.array(data.shape, dtype=int) // 2 half = np.array(target_shape, dtype=int) // 2 data = data[cx-half[0]:cx+half[0], cy-half[1]:cy+half[1], cz-half[2]:cz+half[2]] # (1,1,D,H,W) FloatTensor return torch.from_numpy(data.astype(np.float32)).unsqueeze(0).unsqueeze(0) @app.route('/predict', methods=['POST']) def predict(): if 'file' not in request.files: return jsonify({'error': 'no file uploaded'}), 400 f = request.files['file'] if not f.filename.endswith('.nii') and not f.filename.endswith('.nii.gz'): return jsonify({'error': 'format must be .nii or .nii.gz'}), 400 try: vol = preprocess_upload(f) with torch.no_grad(): logits = model(vol.to(device)) prob = torch.softmax(logits, dim=1).cpu().numpy()[0] result = { 'AD_probability': round(float(prob[1]), 4), 'NC_probability': round(float(prob[0]), 4), 'prediction': 'AD' if prob[1] > 0.5 else 'NC' } return jsonify(result) except Exception as e: return jsonify({'error': str(e)}), 500 if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=False)逻辑说明:模型加载放在模块导入时执行,属于“懒加载”的反面——启动后第一次请求不会卡顿,代价是启动慢几秒。preprocess_upload里没有做重采样,这里是个坑:如果用户上传的 NIfTI 体素尺寸不是1mm各向同性,直接裁剪会变形或信息丢失。稳一点的做法是把resample_img也塞进这个函数,但为了简短我这里直接裁剪。注意:.nii.gz的文件名以.gz结尾,判断后缀的条件要写完整,否则真实文件会被拒绝。
4.3 前端页面与 fetch 调用
后端接口写完后,前端只需要一个input type="file"加上一个fetch调用。
<!DOCTYPE html> <html> <body> <input type="file" id="mri_file" accept=".nii,.gz"> <button onclick="upload()">开始诊断</button> <pre id="result"></pre> <script> async function upload() { const file = document.getElementById('mri_file').files[0]; const formData = new FormData(); formData.append('file', file); const resp = await fetch('/predict', { method: 'POST', body: formData }); const data = await resp.json(); document.getElementById('result').innerText = JSON.stringify(data, null, 2); } </script> </body> </html>4.4 本地启动与调试注意
启动前先确认 Python 环境依赖:pip install flask nilearn nibabel torch。用python app.py起服务后,不要直接拿大文件测,先用一个10MB左右的裁剪好的.nii文件验证返回结构是否正确。常见报错有两个:一是上传后 500 报错,多半是nib.load遇到非标准 NIfTI 头,可以用nib.load单独读一次看看是否抛异常;二是torch.load报No module named 'model',如果后端和模型定义文件不在同一目录会这样,把model.py放到同目录或改成相对导入.
4.5 部署:gunicorn + nginx 的前后踩坑
Flask 自带服务器是单进程的,真实并发上来之后会卡住。常见做法是用gunicorn起多 worker,但每个 worker 都会加载一份模型到内存,一块 12GB 显存的卡只能起 1~2 个 worker,多了直接 OOM。我自己一般这么起:gunicorn -w 1 -b 0.0.0.0:5000 --timeout 300 app:app。如果并发需求不高,一个 worker 反而最稳。前端文件用 nginx 托管,静态请求走 nginx,/predict反向代理到127.0.0.1:5000。
5. ONNX 导出与推理提速:把模型放进生产环境前最后一步
前面 Flask 直接用 PyTorch 跑推理,对毕设演示够了,但如果想让 Web 服务在 CPU 机器上也能用,导出成 ONNX 再用onnxruntime推理,是性价比最高的一步。不需要重新训练,只需要把权重参数冻结后 forward 一次,跑通导出流程,后面的加速收益是白拿的.
5.1 导出 ONNX 并固定动态轴
import torch from model import load_3d_resnet model = load_3d_resnet(num_classes=2, pretrain=False) model.load_state_dict(torch.load('best_ad_model.pt', map_location='cpu')) model.eval() dummy_input = torch.randn(1, 1, 96, 96, 96) torch.onnx.export( model, dummy_input, 'ad_model.onnx', input_names=['input_volume'], output_names=['logits'], dynamic_axes={'input_volume': {0: 'batch'}, 'logits': {0: 'batch'}}, opset_version=13 ) print('ONNX export done.')这里dynamic_axes只指定了batch维为动态,空间维度保持固定。如果部署端有处理不同尺寸输入的需求,可以把D/H/W三个维度也标成动态,但AdaptiveAvgPool3d后的全连接层要求输入尺寸固定,改动态维度可能导致权重形状不匹配。所以一般调高opset_version而不是开放空间维度。
5.2 onnxruntime 推理与速度对比
ONNX 模型在同一台 CPU 机器上用onnxruntime静态推理已经比 PyTorch eager 模式快。启用ENABLE_ALL_OPT优化之后速度还有进一步提升。以下是封装了 ONNX 推理的单例类,替换掉 PyTorch 的调用逻辑。
import numpy as np import onnxruntime as ort class ADONNXInfer: def __init__(self, onnx_path='ad_model.onnx'): self.sess = ort.InferenceSession( onnx_path, providers=['CUDAExecutionProvider' if ort.get_device() == 'GPU' else 'CPUExecutionProvider'] ) def predict(self, volume_np): # volume_np shape: (96,96,96) float32 input_tensor = volume_np[np.newaxis, np.newaxis, ...] inputs = {self.sess.get_inputs()[0].name: input_tensor} logits = self.sess.run(None, inputs)[0] # 如果只有一个输出,直接取第0个 prob = np.exp(logits) / np.exp(logits).sum(axis=-1, keepdims=True) return prob[0] # 在 Flask 接口里替换 model 推理的部分 infer = ADONNXInfer() result = infer.predict(volume_array)逻辑说明:providers的设置很关键。如果你有一个 GPU 但在启动时没指定CUDAExecutionProvider,onnxruntime 默认会跑在 CPU 上,速度反而不如 PyTorch GPU。另外,sess.run(None, ...)里的None表示取所有输出,如果模型有多个输出节点,最好显式指定输出名,避免返回顺序错误。我先用 prob 公式替换掉了原来的softmax,用 numpy 实现,省了一个 torch 依赖,部署更轻。
5.3 精度验证:导出前后输出要一致
ONNX 导出后必须做一次“对拍”,防止算子映射导致结果漂移。方法很简单:拿同一个输入体积,分别跑 PyTorch 模型和 ONNX 模型,对比输出的 logits 差值是否在 1e-4 数量级以内。如果差值过大,八成是BatchNorm在 eval 模式下统计量没有冻结,检查导出前有没有调用model.eval()。很多人的坑就在这儿。
import onnxruntime as ort import torch # 用同一个输入 x = torch.randn(1, 1, 96, 96, 96) with torch.no_grad(): torch_out = model(x).numpy() ort_out = infer.sess.run(None, {'input_volume': x.numpy()})[0] print('max diff:', np.abs(torch_out - ort_out).max()) # 如果小于 1e-4,可以放心切换这个绝对差如果超过 1e-3,模型输出概率可能变化几个百分点,对二分类的阈值判断比较危险。另外,量化也是可选项:onnxruntime可以直接加载int8量化模型,体积压缩到四分之一,但准确率通常会下降 1~2 个点。如果 Web 服务的机器是纯 CPU 环境,量化是一个可行的最后手段;如果有 GPU 就别做 int8,FP16 模型精度损失小且更持显存。最后把这些写进README.md,把启动命令、依赖清单和测试数据文件路径都列清楚,毕设评阅时这一段很加分。
本文还有配套的精品资源,点击获取