拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PyQt5+深度学习骨龄识别系统:从模型训练到GUI部署实践

PyQt5+深度学习骨龄识别系统:从模型训练到GUI部署实践

简介:一套基于PyQt5框架与深度学习技术打造的骨龄识别检测系统,面向本科毕业设计、课程设计及期末大作业场景,也适合想掌握完整桌面端AI项目的新手。项目自带图形界面、推理模块、预训练模型和说明文档,源码含详细注释,下载后稍作配置即可演示,上手门槛较低。整个资源压缩包共两百个文件,大小约七百余兆;其中py文件为项目核心源码,pth文件为已训练好的模型权重,yaml与yml为训练推理配置,ipynb与md用于代码演示和项目说明,sh及Dockerfile等文件则辅助一键部署与容器化运行。该资源已被306人学习,侧面说明其实战价值;项目方注明为高分毕业设计并获导师认可。资源内附训练日志、Dockerfile等部署配置文件,能从环境配置、模型加载到界面展示串起完整链路,适合作为毕设答辩、功能演示和二次开发的直接参考。

1. 骨龄识别检测项目:pyqt5 + 深度学习为什么是毕设稳妥选型

答辩现场最容易出现的尴尬,不是模型精度不够,而是导师随手拖了一张X光片进你的系统,界面直接卡死,转了十几秒后还弹出一个红色报错。你站在台上解释“数据预处理有点慢”,但心里清楚是线程模型写崩了。这个“基于pyqt5深度学习的骨龄识别检测PYTHON实现代码+GUI+模型”的题目,表面上是训练一个CNN预测骨龄,实际上考验的是把深度学习模型封装成可用桌面软件的综合能力。它同时覆盖了模型训练、PyQt5界面、线程调度、文件IO、模型部署几个模块,适合毕设拿高分,也适合作为了解医学影像AI落地的入门项目。适合谁做?有一定Python基础,会用PyTorch或TensorFlow训练图像模型,但对GUI开发完全陌生的学生。核心难点不在算法创新,而在把散落的代码整合成一个能演示、能交互、能出报告的完整系统。

2. 骨龄识别算法选型:从CNN回归到多任务分类,先搞清任务边界

2.1 骨龄识别是回归不是分类:关键点与评估指标

如果你把骨龄检测做成“骨龄年龄分类模型”,比如输出正常、提前、延迟三档,答辩时很容易被追问:“连续月龄怎么处理?分档边界谁定义的?”骨龄识别的标准做法是回归问题,输入左手X光片,输出一个浮点数,单位通常是月。常用的评估指标是平均绝对误差MAE,比如预测骨龄120个月,实际骨龄118个月,误差2个月。RSNA骨龄数据集就是按月标注的,很多开源代码直接以“月龄”作为学习目标。

为什么不能直接用分类?一是因为骨龄本身是连续变量,二是因为临床报告需要具体月龄,而不是一个“接近正常”的模糊结论。回归头的设计也简单:在CNN特征提取器后面接一个全连接层,输出一个数值。也可以用多任务学习,同时预测骨龄和性别,性别对骨龄评估影响很大,男孩和女孩的骨骺闭合速度不同。多任务输出时,骨龄和性别共享特征提取层,再分两个头分别回归和分类。性别分类头其实能在训练时帮骨干网络学到更有区分度的特征,这也是提分的小技巧。

我一般先跑一个最简单的单回归头基线,再看有没有必要加性别分支。如果数据集本来就带性别字段,多任务比把性别拼到全连接层更稳,因为性别特征会在训练中与图像特征相互强化。

2.2 用PyQt5做GUI前的模型推理接口设计

PyQt5只是壳,模型才是核心。如果你把数据预处理、模型推理、结果后处理全部塞进界面文件里,后期换模型结构、调整输入尺寸会非常痛苦。更常见的做法是把模型封装成一个独立类,叫BoneAgeInferencer,它只负责加载权重、预处理图像、推断、后处理四个动作,GUI那边只调用一个predict(image_path)方法,拿到骨龄和置信度。

# inference.py - 模型推理封装类 import torch from torchvision import transforms from PIL import Image class BoneAgeInferencer: def __init__(self, model_path, device="cuda"): # 这里只实现加载逻辑,具体网络定义后文给出 self.device = device if torch.cuda.is_available() else "cpu" self.model = self._build_model() self.model.load_state_dict(torch.load(model_path, map_location=self.device)) self.model.to(self.device) self.model.eval() # 训练时用的预处理 transform 必须保证和推理时一致 self.transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) def _build_model(self): # 后期替换成 ResNet、EfficientNet 都在这里改 raise NotImplementedError def predict(self, image_path): img = Image.open(image_path).convert("RGB") tensor = self.transform(img).unsqueeze(0).to(self.device) with torch.no_grad(): bone_age_month = self.model(tensor).item() return {"bone_age_month": round(bone_age_month, 2), "bone_age_year": round(bone_age_month / 12.0, 2), "device": self.device}

这段代码的逻辑很简单:构建模型,加载权重,将PIL图像转为张量,归一化后前向传播。normalize参数用的是ImageNet的统计值,因为预训练模型是在ImageNet上训练过的。如果你的训练数据不是ImageNet分布的X光片,最好在训练阶段重新统计均值和方差,否则预测结果会整体偏移。predict返回月龄和岁数两个字段,GUI显示时直接用,不需要界面再算一遍。

2.3 最少必要代码:加载模型对单张X光片推理

为了让上面的封装类不报错,还需要一个具体的网络实现。简单又稳的方案是用ResNet18做特征提取器,把最后一层全连接替换成输出1个值的全连接层。手写残差网络没必要,直接用torchvision预训练模型,迁移学习能在小数据上拿到更好效果。下面是一个最小实现:

import torch.nn as nn from torchvision import models class BoneAgeRegressor(nn.Module): def __init__(self, pretrained=True): super().__init__() # 预训练 ResNet18,去掉原始全连接层 resnet = models.resnet18(pretrained=pretrained) self.features = nn.Sequential(*list(resnet.children())[:-1]) # 将 512 维特征映射到骨龄月龄 self.regressor = nn.Sequential( nn.Flatten(), nn.Linear(512, 128), nn.ReLU(), nn.Dropout(p=0.2), nn.Linear(128, 1) ) def forward(self, x): features = self.features(x) # 输出形状 [B, 512, 1, 1] return self.regressor(features).squeeze(-1) # [B]

这里用nn.Sequential(*list(resnet.children())[:-1])把ResNet18最后一个全连接层丢掉,只保留前面的卷积层。features输出的空间维度是1x1,所以后面直接Flatten成512维向量。回归头用了两层全连接加Dropout,Dropout能减少过拟合,尤其在只有几千张X光片的小数据集上很值得加。

推理时调用:

model = BoneAgeRegressor(pretrained=False) model.load_state_dict(torch.load("bone_age_model.pth", map_location="cpu")) model.eval()

这里必须设置pretrained=False,因为你是在加载自己的训练权重,不是重新初始化预训练模型。很多初学者会在预测时忘记写model.eval(),导致BatchNorm和Dropout行为变成训练模式,预测结果抖得厉害。另一个坑是load_state_dict的路径,如果你训练时用了DataParallel,权重键名会多出module.前缀,加载后要做一个键名清理。

3. 数据准备与模型训练:构建可复现的骨龄训练pipeline

3.1 数据来源与预处理:从RSNA骨龄数据集到本地目录

骨龄识别领域最常用的公开数据集是RSNA 2017 Pediatric Bone Age Challenge,包含大概一万多张左手X光片和对应的年龄月、性别标注。图像是DICOM或PNG格式,文件有的大有几MB,需要统一转成RGB图片并缩放到模型输入尺寸。做毕设时,你不需要复现整个比赛,用一部分数据训练也能跑通流程。

常见做法是把csv标注文件读进来,按比例划分训练集和验证集,然后把图像路径和标签写入一个文本文件或直接生成内存数据集。下面的脚本演示如何划分:

# prepare_data.py import pandas as pd from sklearn.model_selection import train_test_split df = pd.read_csv("boneage_dataset.csv") # 假设字段: image_path, bone_age_month, male train_df, val_df = train_test_split( df, test_size=0.2, random_state=42, stratify=pd.cut(df["bone_age_month"], 10) ) # 写成训练与验证清单 train_df.to_csv("train.csv", index=False) val_df.to_csv("val.csv", index=False) print(f"训练集 {len(train_df)} 张,验证集 {len(val_df)} 张")

stratify参数非常关键。骨龄标签分布通常是不均匀的,青春期附近样本多,新生儿少。如果不分层抽样,验证集的骨龄分布可能和训练集差很多,MAE看起来很高,其实只是抽样偏差。test_size=0.2是常见比例,如果数据量很少,改成0.15也行。

3.2 训练脚本:PyTorch实现骨龄回归网络

训练脚本是整套代码的中枢。你需要一个自定义Dataset,一个训练循环,一个验证循环,以及模型保存逻辑。这里给出最核心的Dataset和训练循环,避免写成几百行没人看的脚本。

# train.py import torch from torch.utils.data import Dataset, DataLoader from PIL import Image from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p=0.3), transforms.RandomAffine(degrees=5, translate=(0.02, 0.02)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) class BoneAgeDataset(Dataset): def __init__(self, csv_path, transform=None): import pandas as pd self.df = pd.read_csv(csv_path) self.transform = transform def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] img = Image.open(row["image_path"]).convert("RGB") label = torch.tensor(float(row["bone_age_month"]), dtype=torch.float32) if self.transform: img = self.transform(img) return img, label

数据增强里RandomAffine的旋转角度只给5度,这是故意的。X光片本身有标准摆位,旋转太大会破坏骨骼结构信息,也容易引入错误边界。水平翻转可以加,因为左右手在数据集里已经统一为左手,翻转相当于模拟镜像,不影响骨龄特征。随机亮度对比度增强其实可以加,但要用小幅度,避免骨骼软组织对比失真。

训练循环用Smooth L1损失,也就是Huber Loss。相比MSE,它对离群点的梯度更温和,骨龄预测中偶尔会有标注噪声,Smooth L1不容易被个别脏数据带偏。

# 训练循环核心 model = BoneAgeRegressor(pretrained=True) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-5) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30) criterion = torch.nn.SmoothL1Loss() for epoch in range(30): model.train() epoch_loss = 0.0 for images, labels in train_loader: images = images.to(device) labels = labels.to(device) preds = model(images).squeeze(-1) loss = criterion(preds, labels) optimizer.zero_grad() loss.backward() optimizer.step() epoch_loss += loss.item() * images.size(0) scheduler.step() # 验证时用MAE model.eval() val_mae = 0.0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) preds = model(images).squeeze(-1) val_mae += torch.abs(preds - labels).sum().item() val_mae /= len(val_df) print(f"Epoch {epoch+1} | loss {epoch_loss/len(train_df):.3f} | val MAE {val_mae:.2f} 月")

AdamW比Adam多了正确的权重衰减实现,配合CosineAnnealingLR在训练后期平滑降低学习率。T_max设置为总epoch数,让学习率在最后一个epoch降到最低。优化器学习率用1e-4,迁移学习时对新加的回归头来说可以稍高,但对微调预训练骨干层来说,1e-4是一个安全起点。如果你的显卡显存小,batch size可以降到8,同时学习率也降到5e-5,因为batch变小后梯度噪声变大。

3.3 参数设置与训练过程监控

训练参数直接决定你能不能顺利毕业。下面这张表我每次做图像回归项目都会先按它初始化,再根据显卡和数据集微调。不要小看这些数值,很多翻车都源于参数拍脑袋。

参数推荐值说明
输入尺寸224x224与ImageNet预训练对齐
batch size166GB显存可用
优化器AdamW比SGD收敛快,比Adam训练更稳
初始学习率1e-4如果loss不降,先看看是不是lr太大
损失函数SmoothL1Loss对标注噪声宽容
数据增强水平翻转、5度旋转、轻微平移X光片不能重缩放
训练轮数30-50小数据提前做早停
早停patience5验证集MAE连续5轮不降则停止

训练时我习惯把每轮的loss、MAE、学习率写入csv日志。没有日志,你根本看不出第15轮开始过拟合。另外,模型保存要同时存权重和优化器状态,最好是整个checkpoint字典。只存权重的话,中断训练后没法恢复优化器状态,只能重新训练。

4. PyQt5 GUI搭建:从界面布局到模型推理线程

4.1 主界面设计:图片选择、结果显示、报告导出

PyQt5的界面设计有两种路线:纯代码写界面,或用Qt Designer画好.ui文件再转成.py。毕设答辩时,一个拖拽文件就出结果、带按钮和状态栏的界面,比命令行输出有说服力得多。我推荐用Qt Designer画,然后通过pyuic5工具把.ui转成Python类,这样界面调整布局不会影响业务逻辑。

主界面至少要有五个区域:左侧是图片预览区,用QLabel显示X光片;右侧上方显示骨龄结果,包括月龄、岁龄、性别;右侧下方是“开始识别”“选择图片”“保存报告”三个按钮;底部是一个QStatusBar用于提示状态;顶部菜单栏可以放“关于”和“打开文件夹”。整体布局用QHBoxLayout和QVBoxLayout嵌套,窗口默认大小900x600。

用Qt Designer生成代码后,你需要在主窗口初始化时把推理类挂上来,而不是直接在主窗口类里写模型加载逻辑。

4.2 关键代码:QThread后台推理避免界面卡死

这是整个GUI项目最容易翻车的地方。如果你在按钮点击槽函数里直接调用模型推理,PyQt5的界面线程会被阻塞,按钮无法点击,窗口标题栏会出现“未响应”。推理一张224x224的X光片在CPU上可能耗时0.2秒,但在GPU不存在的电脑上,加上加载大图、预处理,有可能卡好几秒。更严重的是,如果你加载一个上百MB的模型,界面会直接黑掉几秒钟。

正确方案是把推理放进QThread工作线程,通过信号把结果传回主线程。下面是一个线程封装:

# worker.py from PyQt5.QtCore import QThread, pyqtSignal class InferenceWorker(QThread): result_ready = pyqtSignal(dict) error_occurred = pyqtSignal(str) def __init__(self, inferencer, image_path): super().__init__() self.inferencer = inferencer self.image_path = image_path def run(self): try: result = self.inferencer.predict(self.image_path) self.result_ready.emit(result) except Exception as e: self.error_occurred.emit(str(e))

在主窗口里,每次点击“开始识别”,都新建一个worker线程,连接信号后启动。注意不能把worker存成局部变量,Python的垃圾回收可能把线程对象回收掉,导致程序崩掉。常见做法是保存到self.worker,确保线程生命周期和窗口同步。

# 主窗口槽函数 def on_start_clicked(self): if not self.image_path: self.statusBar().showMessage("请先选择图片") return self.statusBar().showMessage("正在识别...") self.worker = InferenceWorker(self.inferencer, self.image_path) self.worker.result_ready.connect(self.show_result) self.worker.error_occurred.connect(self.handle_error) self.worker.start()

show_result里更新QLabel文本和图片预览,handle_error里弹QMessageBox并恢复状态栏。这里有个细节:在worker运行期间应该禁用“开始识别”按钮,等结果回来再启用,避免用户连续点击创建多个线程。否则多个线程同时推理,会有两个结果竞争写同一个标签,虽然不会崩溃,但显示结果可能被覆盖。

4.3 模型与GUI解耦:C/S设计思路

很多毕设代码把模型类、数据类、界面类全写在一个文件里,第一眼看上去很猛,代码一千行,但答辩老师问到“如果换个模型,你要改哪些地方”就会支支吾吾。更优雅的做法是把推理封装成独立的“服务”,GUI只是客户端。你可以再进一步,把推理服务写成REST API,GUI用requests调用本地端口,但这会引入启动服务额外成本,对U盘答辩环境不友好。折中方案是模块分离,至少保证模型相关代码不引用任何PyQt5的东西。

按照这个思路,项目目录结构可以这样:

boneage_project/ ├── main.py # 程序入口 ├── gui/ │ ├── __init__.py │ ├── main_window.py # PyQt5主窗口 │ └── worker.py # QThread工作线程 ├── inference/ │ ├── __init__.py │ ├── regressor.py # 模型定义 │ └── inferencer.py # 推理封装 ├── train/ │ ├── train.py │ └── prepare_data.py └── models/ └── best_model.pth

GUI目录只负责界面和线程,inference目录负责模型加载和推理。这样即使你日后把PyTorch换ONNX Runtime,只需要重写inferencer.py,GUI一行不用动。这个解耦设计在答辩时可以直接说“我参考了软件工程的分层思想”,很加分。

5. 骨龄识别检测里的七个避坑点:从环境依赖到精度玄学

5.1 PyQt5装不上或运行报错:版本与Python版本匹配

现象:在Python 3.9环境里pip install pyqt5成功,但运行程序报ModuleNotFoundError: No module named 'PyQt5.sip',或者QT相关dll找不到。

原因:新版本PyQt5与sip版本冲突,或者你用的是Python 3.12,PyQt5的某些二进制包还没完全适配。尤其是Windows下,Python 3.12对PyQt5的兼容性没有Python 3.10稳定。

解决:创建虚拟环境时固定Python 3.10,然后执行pip install pyqt5==5.15.10,这是大众验证最多的版本。遇到sip缺失,再补一条pip install PyQt5-sip。如果你的系统是macOS,还需要安装XQuartz,否则窗口显示异常。

5.2 模型输入尺寸不匹配导致预测结果异常

现象:训练时用224x224输入,GUI里把原图直接送入模型,报错Expected 4D input to conv2d,或者预测结果全是同一个值。

原因:原图尺寸接近数千像素,没有resize到224。Conv层对任意维度都能算,NCHW的H、W变化后,全连接层输入维度就错了。如果全连接层适应了,结果当然全乱。

解决:在预处理transform里第一项强制Resize((224,224)),检查inferencer.predict是否对路径和PIL图片都走了同样的transform。还有一个隐藏坑:有些网络对输入尺寸敏感,Resize比例不均会导致骨骼形变,所以要么改成等比例缩放加padding,要么统一固定宽高比。对骨龄识别来说,直接resize到正方形效果尚可,因为关键骨骼位置不会因为扭曲而完全消失。

5.3 GUI卡死:把推理放到工作线程

现象:点击识别后窗口变模糊,拖动窗口没反应,几秒后恢复,然后一次性弹出结果。

原因:推理逻辑直接写在槽函数里,阻塞了Qt事件循环。这几乎是新手最常见问题。

解决:参考4.2的InferenceWorker,把推理挪到子线程。注意不要在worker里访问任何界面控件,所有数据通过信号传递。另外,模型实例在worker里每次推理都用同一个,不要每次新建,否则显存和内存会一点点涨上去。

5.4 骨龄预测值偏移:回归头与损失函数的选择

现象:训练时MAE只有25个月,但对一个人的片子预测结果比临床估计低了一岁多,且所有预测都偏向某个范围。

原因:很可能是回归头直接用MSE损失,而目标月龄的方差很大,模型学会了预测均值。比如训练集月龄中位数是120个月,模型对所有输入都输出120附近,MAE反而中等偏低。这是回归任务的典型陷阱。

解决:改用SmoothL1Loss,同时检查预测分布是否比真实分布窄很多。另一种有效做法是把目标标准化,先计算训练集月龄的均值和标准差,把label转为z-score,训练网络输出z-score,预测后再反标准化。这样梯度尺度更平稳,抵消输出层初始化偏差。

5.5 训练和推理预处理不一致:归一化参数要固化

现象:训练时在Dataset里用了transforms.Normalize(mean, std),推理时只做了ToTensor(),结果预测的骨龄忽高忽低,甚至出现负数。

原因:训练时归一化到标准正态,推理时却用原始0-1范围,模型输入分布完全不同。这种错误不会直接报错,但结果完全不可信。

解决:把transform定义为全局常量,放在一个单独preprocess.py里,训练和推理都从这个模块导入。不要在两处各写一份。归一化mean和std建议用训练集X光片的实际统计值替换ImageNet默认值,因为骨龄X光片以灰白为主,RGB通道均值与自然图像差异很大。你可以用torchvision.transforms.ToTensor()后对所有训练图片的每个通道算均值和标准差,这个小脚本值得写,效果提升是实打实的。

5.6 模型加载慢且内存爆掉:设备选择与模型轻量化

现象:程序启动后加载一个2GB的模型文件,内存占用暴涨,旧电脑直接卡死。

原因:毕设一般没有GPU服务器,也用不起大模型。有些同学直接拿EfficientNetV2-L、ResNet152训练,推理时CPU承受不住。

解决:先确认推理设备。最省心的组合是ResNet18或EfficientNetV2-S,单张图片CPU推理不到1秒。模型文件用torch.save(model.state_dict(), "best_model.pth")保存,不要保存整个model对象,文件体积会包含模型结构值和优化状态。加载时用map_location="cpu"。如果还嫌慢,可以转成ONNX并使用ONNX Runtime推理,能提速不少。答辩时能拿出“我把模型从2.1GB压缩到180MB”这种数据,本身就很有亮点。

5.7 ONNX导出失败:动态轴与opset版本

现象:模型在PyTorch里跑得好好的,用torch.onnx.export导出后,在ONNX Runtime里报错Resize operation failed。

原因:导出时没有指定动态轴,模型被固定成单一batch size;或者opset版本和推理框架不匹配。

解决:导出时指定opset_version=13,并设置dynamic_axes={"input": {0: "batch_size"}, "output": {0: "batch_size"}}。同时检查onnxruntime版本,旧版可能不支持新opset。如果导出成功但预测结果偏差很大,多半是torch和onnx的算子实现精度差异,可以改用torch.onnx.export的dynamic_axes并导出后先用onnxruntime跑同一张图对比输出。如果你不打算用ONNX,PyTorch原生加载也能用,但ONNX这个技能写在简历上是加分项。

6. 把项目升级成“高分毕设”:实验对比、可视化与交付清单

到了这一步,你的系统已经能跑了,但如果想从“能用”变成“高分”,还需要做三件事:实验对比、结果可视化和完整交付文档。

实验对比不是简单贴一个表说MAE是多少。你要做的是消融实验:基线ResNet18不加数据增强 vs 加了增强 vs 多任务性别预测 vs 换用EfficientNet。每组实验用相同的数据划分和随机种子,记录训练loss下降曲线和验证MAE。画成折线图放进论文里,比任何文字都直观。答辩时可以放一张图片,显示同一个X光片的预测骨龄与真实骨龄的差距,再用热力图或Grad-CAM高亮模型关注区域。骨龄识别模型通常关注骨骺区域,热力图出现在手腕远端骨骼附近,说明模型学到了合理特征,这会给评委留下深刻印象。

我还会在GUI里加一个“保存报告”功能,生成一份包含患者ID、图片路径、预测骨龄月龄、岁龄、预测时间、模型版本的报告,格式用HTML或CSV。HTML可以用浏览器直接打开,后续也能改写成PDF。这一项被很多同学忽略,但它让系统看起来像一个完整产品。

最后说一个我自己的教训:把所有代码和数据集文件打成一个压缩包时,别忘记把requirements.txt和README写清楚。README至少包括环境版本、数据集目录结构、训练命令、推理命令、模型路径。很多同学debug了两周,最后答辩前夜发现自己当时写了一个只在绝对路径下能跑的脚本,评委换台机器就崩了。相对路径和os.path.join处理要提前检查。

这个方向做完,你不仅能完成一个高分毕设,还能真正理解深度学习从训练到落地的全过程。如果你时间紧张,优先保证训练脚本、推理封装和GUI线程这三块是对的,其他功能都属于锦上添花。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表