简介:面向毕业设计场景,基于 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)): # 大文件用 mmap=True 按需加载,避免一次性读进内存 img = nib.load(path, mmap=True) data = np.asarray(img.get_fdata(), dtype=np.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(axis=0) maxs = coords.max(axis=0) + 1 data = data[mins[0]:maxs[0], mins[1]:maxs[1], mins[2]:maxs[2]] # 缩放成统一尺寸,order=1 是三线性插值 zoom_factors = [target_shape[i] / data.shape[i] for i in range(3)] data = zoom(data, zoom_factors, order=1) # 返回 (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.py
3.1 模型结构:三维卷积堆叠与全局池化
既然数据是五维的,模型主体自然要用 Conv3d。下面是一个极简但可用的 3D CNN,结构类似简化版 VGG,在小型脑影像数据集上表现稳定:
import torch.nn as nn class Simple3DCNN(nn.Module): def __init__(self, in_channels=1, num_classes=3): super().__init__() self.features = nn.Sequential( nn.Conv3d(in_channels, 32, kernel_size=3, padding=1), nn.BatchNorm3d(32), nn.ReLU(inplace=True), nn.MaxPool3d(2), nn.Conv3d(32, 64, kernel_size=3, padding=1), nn.BatchNorm3d(64), nn.ReLU(inplace=True), nn.MaxPool3d(2), nn.Conv3d(64, 128, kernel_size=3, padding=1), nn.BatchNorm3d(128), nn.ReLU(inplace=True), 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(lr=1e-4, weight_decay=1e-5) | 相对 SGD,Adam 对 3D 小数据更稳定 |
| 损失函数 | CrossEntropyLoss | AD/MCI/NC 三类互斥分类 |
| batch size | 8 | 64³ 输入在 12GB 显存上比较宽裕 |
| 训练 epoch | 100~150 | 配 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, f"myModel_{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 的shuffle=True打乱。否则训练 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_channels=1, num_classes=3) ckpt = torch.load("myModel_109.pth", map_location=device) 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, dim=1)[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(host="0.0.0.0", port=5000, threaded=True)这段代码已经能支撑一个在线诊断流程。MAX_CONTENT_LENGTH限制上传体积,防止超大 NIfTI 把进程内存打爆;ckpt兼容裸 state_dict 和带 model_state 的 checkpoint 字典,对应前面训练时的保存逻辑;softmax(dim=1)在类别维度上做归一化,输出三元概率;返回的 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限制,再配合前端限制类型 |
| 并发请求 | 开发服务器threaded=True;生产环境换 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 "file=@demodata/demo.nii" http://127.0.0.1:5000/predict正常返回是 JSON,类似{"AD": 0.82, "MCI": 0.12, "NC": 0.06, "model_version": "myModel_109"}。三个概率和为 1,AD 分数最高,说明权重和 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(axis=1, keepdims=True)) prob = exp_pred / exp_pred.sum(axis=1, keepdims=True) f1 = f1_score(label_all, prob.argmax(axis=1), average="macro") auc = roc_auc_score(label_all, prob, multi_class="ovr") print(f"macro F1 {f1:.4f}, AUC {auc:.4f}")这个验收动作的价值在于确认 myModel_109.pth 不只是“能跑”,而是有真实的区分能力。最后提一个优化技巧:把模型导出成 ONNX,Web 端推理延迟能明显降下来。做法是准备一个 dummy 输入,调用torch.onnx.export(model, dummy, "model.onnx", opset_version=11),之后用 onnxruntime 加载,推理时不再依赖 PyTorch 的 Python 开销。导出的模型也可以挂到生产环境的 GPU 推理服务里,和 Flask 解耦。单次推理耗时应成为你 README 里记录的一个指标,后续换预处理、换模型时就有了比较基线。
本文还有配套的精品资源,点击获取