3D CNN实现阿尔茨海默病AI筛查:从NIfTI到Web部署

3D CNN实现阿尔茨海默病AI筛查:从NIfTI到Web部署 简介面向毕业设计场景基于 3D 卷积神经网络的阿尔兹海默智能诊断 Web 应用完整工程适合计算机、医学信息相关专业学生参考与二次开发。项目针对阿尔兹海默病早期筛查围绕脑部 MRI/CT 图像展开通过三维卷积核自动提取大脑结构变化特征覆盖图像预处理、模型训练与评估以及网页端上传图像并返回诊断结果的完整链路代码内含演示数据 demo.nii 和预训练权重 myModel_109.pth拿到后可直接启动验证。资源共 21 个文件主要包含 Python 源码、Markdown 说明文档、模型权重、示例医学图像、界面结构图与配置文件压缩包整体约 16.5MB。已有 423 人学习下载。配套中英文 README 与开源许可证便于理解数据预处理、3D CNN 结构设计及准确率、召回率等评估指标既适合毕业设计论证也能快速搭建医疗影像诊断演示系统。1. 用 3D CNN 在浏览器里筛查阿尔茨海默病这个毕业设计项目像什么下载解压后你能看到 model.py、train.py、zlzheimer-diagnostic-system.py、myModel_109.pth 和 demo.nii这已经构成一套完整链路NIfTI 脑影像 → 3D CNN 训练 → Web 诊断接口。阿尔茨海默病在 MRI 上表现为海马体萎缩这类跨层面体积变化2D 切片容易丢失空间连续信息3D CNN 用三维卷积核在体素空间滑动天然适合捕捉这种变化。项目覆盖数据预处理、三维卷积、模型训练、Web API 和部署验收适合做毕业设计、医学影像入门以及想快速把 PyTorch 模型包成在线服务的工程人员。下面这些步骤全程可以用自带权重复现。2. 从 NIfTI 到训练张量3D CNN 的医学图像数据链路2.1 为什么 AD 诊断用三维卷积而不是二维切片很多医疗影像 AI 教程都会先让你看 2D 数据把 MRI 切成一张张切片丢给 ResNet。这个方案能跑但有一个结构性问题设备扫描得到的是体素网格一个病人的大脑是 D×H×W 的三维体块切层以后相邻切片之间的空间连续性完全丢掉了。AD 早期的灰质萎缩、白质纤维改变往往发生在连续几个层面里单看某一层很难和正常老化区分。3D CNN 的卷积核是 (kD, kH, kW)沿三个方向滑动。它的输入张量通常写成 (batch, channels, depth, height, width)第三维 depth 是层数方向。这样卷积核能同时看到局部体素块学到“海马体在三维上是否明显变小”这类抽象特征。代价是参数量和显存开销都上去了所以项目把输入分辨率控制在 64³而不是直接把原始 512×512×300 的图像喂进去。2.2 用 nibabel 读取 demo.nii 并完成体素预处理NIfTI 是医学影像最常见的文件格式后缀 .nii 或 .nii.gz里面除了体素数据还包含方向矩阵和体素尺寸。处理它的标准库是 nibabel。我给的预处理函数是照着这个项目场景写的训练和 Web 推理都能复用同一份代码import numpy as np import nibabel as nib from scipy.ndimage import zoom def load_nii_and_preprocess(path, target_shape(64, 64, 64)): # 大文件用 mmapTrue 按需加载避免一次性读进内存 img nib.load(path, mmapTrue) data np.asarray(img.get_fdata(), dtypenp.float32) print(原始 shape:, data.shape, voxel size:, img.header.get_zooms()) # 个别 MRI 会带时间维这里取第一个三维块 if data.ndim 4: data data[..., 0] # 去掉头骨、空气等极端值再做 0-1 归一化 lo, hi np.percentile(data, [0.1, 99.9]) data np.clip(data, lo, hi) data (data - lo) / (hi - lo 1e-6) # 裁掉全零背景留下大脑有效区域 coords np.argwhere(data 0) if len(coords) 0: mins coords.min(axis0) maxs coords.max(axis0) 1 data data[mins[0]:maxs[0], mins[1]:maxs[1], mins[2]:maxs[2]] # 缩放成统一尺寸order1 是三线性插值 zoom_factors [target_shape[i] / data.shape[i] for i in range(3)] data zoom(data, zoom_factors, order1) # 返回 (1,1,D,H,W)依次是 batch、channel、depth、height、width return data[None, None, ...].astype(np.float32)说明三个关键点。get_fdata()是 nibabel 3.x 之后的推荐接口旧代码里的get_data()已经弃用header.get_zooms()返回每个轴的体素尺寸比如 (1.0, 1.0, 3.0)表示层面间隔 3mm这一步可以帮助判断是否要做重采样。裁剪时用np.argwhere(data 0)找到非零体素的最小包围盒能去掉大部分背景减少缩放时背景对插值的影响。最后返回五维张量是因为 PyTorch 的 Conv3d 必须接收 batch 和 channel 维度。2.3 预处理参数怎么设以及最容易踩的坑下面是这个项目里比较典型的参数组合也是训练 3D CNN 时最常用的初始值参数项典型值说明输入分辨率64×64×64全脑低分辨率版显存占用友好体素重采样2mm×2mm×2mm如果原始数据层厚不同先统一再缩放归一化0.1%99.9% 分位数裁剪避免个别高亮噪声主导损失背景裁剪去掉全部值为 0 的体素脑部原始图里背景能占到 40% 以上数据增强随机翻转、小角度旋转、gamma 变换3D 数据做旋转要小心解剖方向错误“图像增强给 CNN 用的算法”常被人理解成给网络加什么特殊层其实在医学图像里最有效的增强是几何变换和灰度扰动。对于 MR 图像左右翻转是合理的但不要做上下翻转脑部解剖先验不允许。旋转角度通常控制在 10° 以内过大会产生解剖上不可能的形态。一个大坑是归一化参数应该从训练集统计而不是每张图单独统计。如果每张图都做 min-max画出来对比度不一致推理时遇到新图像分布也会有偏移。正确做法是在训练前把所有训练样本的体素分布统计出来保存一个全局 (lo, hi)训练和推理都复用这一对值。另一个容易忽略的是类别不均衡数据集里正常老化样本通常远多于 AD 患者训练时最好配合 WeightedRandomSampler下面会提到。3. 3D CNN 模型实现与训练拆解 model.py 和 train.py3.1 模型结构三维卷积堆叠与全局池化既然数据是五维的模型主体自然要用 Conv3d。下面是一个极简但可用的 3D CNN结构类似简化版 VGG在小型脑影像数据集上表现稳定import torch.nn as nn class Simple3DCNN(nn.Module): def __init__(self, in_channels1, num_classes3): super().__init__() self.features nn.Sequential( nn.Conv3d(in_channels, 32, kernel_size3, padding1), nn.BatchNorm3d(32), nn.ReLU(inplaceTrue), nn.MaxPool3d(2), nn.Conv3d(32, 64, kernel_size3, padding1), nn.BatchNorm3d(64), nn.ReLU(inplaceTrue), nn.MaxPool3d(2), nn.Conv3d(64, 128, kernel_size3, padding1), nn.BatchNorm3d(128), nn.ReLU(inplaceTrue), nn.AdaptiveAvgPool3d(1), ) self.classifier nn.Sequential( nn.Dropout(0.5), nn.Linear(128, num_classes), ) def forward(self, x): x self.features(x) x x.view(x.size(0), -1) return self.classifier(x)这里最值得说的是AdaptiveAvgPool3d(1)。它把最后一层特征图无论多大都池化成 1×1×1而不是写成x x.flatten(1)后直接接全连接。这样模型对输入尺寸的容忍度高很多就算预处理时把分辨率从 64³ 改成 96³只要不改变通道数模型都能跑最后进入分类器的还是 128 维特征。医学图像项目经常要调整体素分辨率用自适应池化能让调试更方便。三个卷积块都是“Conv3d BatchNorm3d ReLU”的标准组合通道数 32→64→128。BN 在 3D 模型里尤其重要因为医疗数据集小没有 BN 很容易出现梯度震荡。池化用 MaxPool3d每次把 spatial 维度减半64³ 经过两层池化变成 16³信息依然密集。分类器故意做得很简单一个 Dropout 加一个 Linear防止全连接层学偏。3.2 训练超参数Adam 还是 SGD学习率怎么调train.py 里常见的配置我整理成了一张表。这不是标准答案而是在这个项目场景下最不容易翻车的组合超参数推荐配置为什么这样选优化器Adam(lr1e-4, weight_decay1e-5)相对 SGDAdam 对 3D 小数据更稳定损失函数CrossEntropyLossAD/MCI/NC 三类互斥分类batch size864³ 输入在 12GB 显存上比较宽裕训练 epoch100150配 early stopping防止过拟合学习率策略每 30 epoch 乘 0.1后期缩小步长让 loss 平稳类别权重WeightedRandomSampler缓解正常样本过多的问题训练循环的核心逻辑不复杂关键是要保存 checkpoint。一个典型的训练循环片段for epoch in range(1, 101): model.train() for x, y in train_loader: x x.to(device) y y.to(device) optimizer.zero_grad() loss criterion(model(x), y) loss.backward() optimizer.step()一个完整的 checkpoint 保存代码以及加载时容易忽略的点checkpoint { epoch: epoch, model_state: model.state_dict(), optimizer_state: optimizer.state_dict(), best_val_loss: best_val_loss, } torch.save(checkpoint, fmyModel_{epoch}.pth)很多新手加载的时候直接model.load_state_dict(torch.load(myModel_109.pth))得到 missing keys 报错。原因就在 checkpoint 是字典不是裸权重。判断方式很简单如果ckpt里存在model_state键就取它否则说明保存的是state_dict本身。第 5 章验收时会用到这个技巧。训练到后期要加 early stopping验证 loss 连续 10 个 epoch 不降就停保存验证 loss 最低的那一份权重而不是保存最后一轮。3.3 训练中三个容易翻车的细节显存不足是最先遇到的。64³ 输入、通道 128 时batch 8 在 12GB 显卡上压力不大但如果把分辨率提到 128³显存会翻好几倍。常见做法是先把 batch size 降成 2第二选择是减小第一层卷积核数量第三选择是混合精度训练。PyTorch 的torch.cuda.amp.autocast可以把 float32 计算放到 float16 上显存能省将近一半。第二个坑是文件读取顺序和标签顺序对不上。假设目录里 AD、CN、MCI 三个子文件夹直接用glob.glob(data/*/*.nii)拿到的文件顺序不稳定与标签列表不对应。正确做法是遍历目录时把类别名排序构造 (样本路径, 标签索引) 配对列表再用 DataLoader 的shuffleTrue打乱。否则训练 acc 一直在 50% 上下大概率是标签错位。第三个坑出现在数据增强代码里。如果用 scipy 的rotate对三维数组做旋转默认axes(1, 0)会把 D 和 H 维度交换导致解剖方向错误。安全写法是用axes(1, 2)只旋转横断面或者在预处理器里根据 MRI 的方向矩阵确认轴语义。图像增强算法并不是越多越好加太多反而会制造现实中不存在的形态让模型学不到真实病变特征。4. Flask Web 应用把 .pth 模型变成可上传的在线诊断服务4.1 为什么用 Flask 而不是 Django解压以后你会看到 zlzheimer-diagnostic-system.py 这个入口文件从命名和结构看就是典型的 Flask 单体应用。选 Flask 不只是个人偏好更直接的原因是项目要暴露的只有一个“上传文件并返回概率”的接口没有必要拉起 Django 的 ORM、Admin 后台和中间件体系。Flask 可以用不到一百行代码把模型加载、预处理和 API 封装在一起对 PyTorch 生态也更亲和。工程上要注意不要为了省事把模型加载写在请求处理函数里否则每次请求都会重新 load 一次权重。正确做法是在模块加载时只 load 一次模型后续请求直接model.eval()推理。Flask 开发服务器默认多线程单模型多线程推理是安全的因为 PyTorch 在 no_grad 模式下不持有全局锁。4.2 上传、预处理、推理、返回 JSON 的完整实现下面是可以直接放进项目的 Flask 接口代码为了好读我把预处理函数放在独立模块里import torch from flask import Flask, request, jsonify from model import Simple3DCNN from preprocess import load_nii_and_preprocess app Flask(__name__) app.config[MAX_CONTENT_LENGTH] 200 * 1024 * 1024 # 200MB device torch.device(cuda if torch.cuda.is_available() else cpu) model Simple3DCNN(in_channels1, num_classes3) ckpt torch.load(myModel_109.pth, map_locationdevice) model.load_state_dict(ckpt[model_state] if model_state in ckpt else ckpt) model.to(device).eval() CLASS_NAMES [AD, MCI, NC] app.route(/predict, methods[POST]) def predict(): f request.files.get(file) if f is None: return jsonify({error: must upload a .nii file}), 400 # f.stream 此时指向文件开头nibabel 可以直接读取 data load_nii_and_preprocess(f.stream) tensor torch.from_numpy(data).to(device) with torch.no_grad(): logits model(tensor) prob torch.softmax(logits, dim1)[0] result {name: round(float(p), 4) for name, p in zip(CLASS_NAMES, prob)} result[model_version] myModel_109 return jsonify(result) app.errorhandler(413) def file_too_large(e): return jsonify({error: file too large, max 200MB}), 413 if __name__ __main__: app.run(host0.0.0.0, port5000, threadedTrue)这段代码已经能支撑一个在线诊断流程。MAX_CONTENT_LENGTH限制上传体积防止超大 NIfTI 把进程内存打爆ckpt兼容裸 state_dict 和带 model_state 的 checkpoint 字典对应前面训练时的保存逻辑softmax(dim1)在类别维度上做归一化输出三元概率返回的 JSON 里带model_version是为了以后模型更新时调用方能够比对结果版本。一个容易忽略的细节是load_nii_and_preprocess如果传的是文件对象而不是路径nibabel 对句柄要求比较严格。代码里用f.stream它在请求刚到达时位于文件头可以直接读。如果读取前调用过.read()或其他操作要记得f.stream.seek(0)否则会遇到 “not a gzipped file” 或 EOF 错误。4.3 并发、超时与隐私处理关注点处理方式大文件超时用MAX_CONTENT_LENGTH限制再配合前端限制类型并发请求开发服务器threadedTrue生产环境换 gunicorn隐私保护不写日志文件内容推理完后删除临时文件返回结构返回概率、模型版本、单次推理耗时方便定位问题这个项目既然涉及医学数据隐私就绕不开。开发模式可以简单把上传文件保存到 uploaded_img 目录但对外网提供服务时一定要加上鉴权和 HTTPS。更稳妥的写法是文件在内存中完成预处理后立即释放不在硬盘落盘即使要落盘也在返回响应后立刻os.remove。还有一个细节是不要用request.files返回的原始文件名直接当作磁盘路径拼接路径会产生路径穿越至少要用os.path.basename处理一遍。生产环境建议用 gunicorn 启动gunicorn -w 1 -b 0.0.0.0:5000 zlzheimer-diagnostic-system:app注意这里-w 1是故意的单 worker 能避免模型被加载多份内存更可控后续要提升吞吐优先在模型推理前加队列而不是增加 worker。5. 用自带的 myModel_109.pth 和 demo.nii 做一整套验收拿到项目后我建议先跑通验收再改代码。步骤非常简单先安装依赖然后启动 Web 应用再用 demo.nii 打一次请求。目录里有 requirements.txt直接执行pip install -r requirements.txt python zlzheimer-diagnostic-system.py服务默认监听 5000 端口。重新开一个终端用 curl 模拟浏览器上传文件curl -X POST -F filedemodata/demo.nii http://127.0.0.1:5000/predict正常返回是 JSON类似{AD: 0.82, MCI: 0.12, NC: 0.06, model_version: myModel_109}。三个概率和为 1AD 分数最高说明权重和 demo 样本匹配。如果你拿到的结果不是这个样子先从预处理函数查起demo.nii 如果方向是横断面按全脑 bbox 裁剪后得到的数据布局必须和训练时一致否则模型会认为输入是一堆随机噪声。再往下做一层定量验收。把验证集所有样本都过一遍推理收集预测概率和真实标签再计算 macro F1 和 AUC-ROC。代码只有几行import numpy as np from sklearn.metrics import f1_score, roc_auc_score pred np.array(logits_all) exp_pred np.exp(pred - pred.max(axis1, keepdimsTrue)) prob exp_pred / exp_pred.sum(axis1, keepdimsTrue) f1 f1_score(label_all, prob.argmax(axis1), averagemacro) auc roc_auc_score(label_all, prob, multi_classovr) print(fmacro F1 {f1:.4f}, AUC {auc:.4f})这个验收动作的价值在于确认 myModel_109.pth 不只是“能跑”而是有真实的区分能力。最后提一个优化技巧把模型导出成 ONNXWeb 端推理延迟能明显降下来。做法是准备一个 dummy 输入调用torch.onnx.export(model, dummy, model.onnx, opset_version11)之后用 onnxruntime 加载推理时不再依赖 PyTorch 的 Python 开销。导出的模型也可以挂到生产环境的 GPU 推理服务里和 Flask 解耦。单次推理耗时应成为你 README 里记录的一个指标后续换预处理、换模型时就有了比较基线。本文还有配套的精品资源点击获取