
简介面向计算机相关专业毕业设计开发的阿尔兹海默智能诊断系统完整项目包基于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_zoom1.0, target_shape(96, 96, 96)): # 读取 NIfTI 文件保留仿射信息 img nib.load(nii_path) # 线性重采样到 1mm 各向同性这一步统一体素间距 img_resampled resample_img(img, target_affineimg.affine, target_shapetarget_shape, interpolationlinear) 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, dtypeint) // 2 half np.array(target_shape, dtypeint) // 2 data_cropped data[cx-half[0]:cxhalf[0], cy-half[1]:cyhalf[1], cz-half[2]:czhalf[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, axis0))逻辑拆解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_classes2, pretrainTrue, freeze_backboneTrue): backbone resnet18(pretrainedpretrain) # 把 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), stride1, padding1, biasFalse ) 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_classes2, pretrainFalse, freeze_backboneFalse)逻辑说明replace_conv2d_to_conv3d是递归替换核心是把所有nn.Conv2d换成nn.Conv3d同时把BatchNorm2d也换成BatchNorm3d。之所以不用torchvision原生 3D ResNetr3d_18是因为那个权重是视频分类上预训练的输入分布和 MRI 差异更大。如果你要直接用也完全可以torchvision.models.video.r3d_18(pretrainedTrue)但输入层是(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×33D 感受野大小堆两层比用 5×5 稳定初始通道数323D 网络参数爆炸快通道少一半参数少 8 倍优化器AdamWlr1e-4weight_decay1e-5Batch Size4~896³ 体积下 8 已经能占满 12GB 显存早停patience10监控验证集 Loss不降就砍数据增强旋转 ±10°、翻转、高斯噪声在线增强不做离线存储训练代码里我一般这么组织 epoch 循环criterion nn.CrossEntropyLoss(weighttorch.tensor([1.0, 2.0]).cuda()) optimizer torch.optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-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(fEpoch {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 而不是 FastAPIFastAPI 的异步性能和自动文档确实更好但 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_classes2, pretrainFalse) model.load_state_dict(torch.load(best_ad_model.pt, map_locationdevice)) 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, dtypeint) // 2 half np.array(target_shape, dtypeint) // 2 data data[cx-half[0]:cxhalf[0], cy-half[1]:cyhalf[1], cz-half[2]:czhalf[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, dim1).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(host0.0.0.0, port5000, debugFalse)逻辑说明模型加载放在模块导入时执行属于“懒加载”的反面——启动后第一次请求不会卡顿代价是启动慢几秒。preprocess_upload里没有做重采样这里是个坑如果用户上传的 NIfTI 体素尺寸不是1mm各向同性直接裁剪会变形或信息丢失。稳一点的做法是把resample_img也塞进这个函数但为了简短我这里直接裁剪。注意.nii.gz的文件名以.gz结尾判断后缀的条件要写完整否则真实文件会被拒绝。4.3 前端页面与 fetch 调用后端接口写完后前端只需要一个input typefile加上一个fetch调用。!DOCTYPE html html body input typefile idmri_file accept.nii,.gz button onclickupload()开始诊断/button pre idresult/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 /html4.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_classes2, pretrainFalse) model.load_state_dict(torch.load(best_ad_model.pt, map_locationcpu)) 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_version13 ) 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_pathad_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, keepdimsTrue) return prob[0] # 在 Flask 接口里替换 model 推理的部分 infer ADONNXInfer() result infer.predict(volume_array)逻辑说明providers的设置很关键。如果你有一个 GPU 但在启动时没指定CUDAExecutionProvideronnxruntime 默认会跑在 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 就别做 int8FP16 模型精度损失小且更持显存。最后把这些写进README.md把启动命令、依赖清单和测试数据文件路径都列清楚毕设评阅时这一段很加分。本文还有配套的精品资源点击获取