PyQt5+深度学习骨龄识别系统:从模型训练到GUI部署
发布时间:2026/10/1 18:20:38来源:尧图网络
简介基于PyQt5与深度学习技术的骨龄识别检测项目是一份完整可运行的高分Python实现面向毕业论文、期末大作业或课程设计。代码注释详细模型权重齐全适合具备基础Python知识、希望快速上手深度学习图像识别与桌面应用开发的学生。资源压缩包共200个文件总大小约717MB包含69个Python源码文件、53个YAML配置、18个PTH训练权重辅以XML界面布局、Dockerfile、Shell脚本及Markdown说明文档其中Python文件覆盖数据加载、模型推理与界面逻辑权重文件可直接完成预测。已有306人学习下载。该成果为个人手打98分、导师认可的高分毕设内部注释与文件组织便于二次开发可直接作为骨龄识别检测的原型系统也可迁移至医学影像分析、课堂展示或竞赛项目是一款实用且完整的高分参考方案。1. 骨龄识别不是“看图猜年龄”这个毕设到底在解决什么骨龄识别检测听起来是拿一张手腕X光片让AI报个数字但真正落地时它横跨了图像预处理、回归模型、GUI框架三件事这也是它成为“高分毕设”热门选题的原因。传统读片靠医生逐块观察骨骺闭合程度主观性强、耗时长而基于PyQt5的深度学习骨龄识别系统能把训练好的模型封装成带界面、可复现、能演示的桌面应用恰好满足毕业设计里“有算法、有工程、有展示”的要求。本文用的技术栈是Python PyTorch PyQt5路线很直接先讲模型选型和训练再落到GUI集成最后把参数设置和踩坑经验一次讲清。适合想快速跑通全流程的学生也适合医学影像从业者做原型验证。2. 模型选型与数据预处理先定回归头再管好手骨X光片2.1 为什么用回归而不是分类骨龄是个连续量很多同学看到“识别”就默认走分类路线但骨龄的标签是一周岁、两周岁的连续数值甚至按月份算。把骨龄当分类会踩两个坑一是类别之间有顺序关系模型把1岁判成2岁和判成10岁的严重程度完全不同普通交叉熵损失不区分这种差异二是类别数太多从0到18岁按月划分就是216类样本会稀疏到每个类别只有几十张图。所以常见做法是回归网络输出一个标量训练用MSE或L1损失评价用MAE。RSNA骨龄数据集是个很常见的起点它提供左手X光片和以“月”为单位的骨龄标签。使用这个数据集时要注意男女样本比例不是1:1而且不同年龄段样本分布差异很大如果直接训练回归头模型容易偏向样本多的年龄段。我一般会在数据加载时记录性别字段后面把它作为额外特征输入回归头而不是丢给网络自己猜。骨龄问题里性别属于强先验信息不人工喂给网络模型很难自己从图像中学到性别差异。2.2 数据集组织从RSNA原始表到本地目录先把数据集整理成固定目录训练和GUI推理都用同一套路径规则避免后面维护混乱。我习惯这样组织boneage/ ├── images/ │ ├── 001.png │ └── 002.png ├── train.csv ├── model.py ├── train.py └── config.yamltrain.csv 的内容至少有这三列file_id、bone_age_month、male。其中male用0表示女性、1表示男性。需要注意RSNA原始标签单位是月龄很多人训练时没看单位直接当成岁数用预测结果就会大12倍。这个错误在GUI阶段尤其隐蔽因为界面显示时若不标注单位很难一眼发现。2.3 数据加载与预处理图像归一化是第一步X光片是单通道灰度图但torchvision预训练模型默认吃三通道。常见做法是把灰度图复制成三通道而不是用复杂伪彩色方案。OpenCV读取后用np.stack复制即可。resize统一到(224, 224)除以255归一化这三步在训练和推理阶段必须保持一致否则会出现“训练时表现不错GUI里预测全偏”的灵异问题。import cv2 import numpy as np import torch from torch.utils.data import Dataset class BoneAgeDataset(Dataset): def __init__(self, df, image_dir, size224): self.df df self.image_dir image_dir self.size size def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] img cv2.imread(f{self.image_dir}/{row[file_id]}.png, cv2.IMREAD_GRAYSCALE) img cv2.resize(img, (self.size, self.size)) img np.stack([img] * 3, axis2) img img.astype(np.float32) / 255.0 img torch.from_numpy(img).permute(2, 0, 1) male torch.tensor(float(row[male])).float().unsqueeze(0) age_month torch.tensor(float(row[bone_age_month])).float().unsqueeze(0) return {img: img, male: male, age: age_month}逻辑说明每次迭代都读图并resize训练时用DataLoader的num_workers并行可以掩盖一部分IO开销。male单独作为特征向量不混入图像通道是为了在模型全连接层区分性别。参数说明size224来源于ResNet输入尺寸如果你换成EfficientNetsize可以改成240或288灰度转三通道用复制通道虽然信息冗余但对预训练模型友好。2.4 模型结构ResNet34骨干 性别拼接 单输出回归头骨干网络我推荐ResNet34不是因为它最强而是因为预训练权重好找、显存占用低、训练稳定。把最后的分类层替换成自定义回归头中间加一层全连接融合性别特征。import torch import torch.nn as nn from torchvision import models class BoneAgeModel(nn.Module): def __init__(self, backboneresnet34): super().__init__() if backbone resnet34: self.backbone models.resnet34(weightsmodels.ResNet34_Weights.IMAGENET1K_V1) feat_dim 512 else: raise ValueError(backbone not supported) self.backbone.fc nn.Identity() self.fc nn.Sequential( nn.Linear(feat_dim 1, 128), nn.ReLU(), nn.Dropout(0.2), nn.Linear(128, 1) ) def forward(self, img, male): feat self.backbone(img) feat torch.cat([feat, male], dim1) out self.fc(feat) return out.squeeze(1)逻辑说明ResNet34最后池化得到的特征维度是512把性别标量拼在后面全连接层输入变成513维。如果不拼接性别模型会把男女骨龄差异混在同一个特征空间里。参数说明Dropout加在全连接层之间能缓解标注噪声bone_age标签是0到18岁输出层不加Sigmoid时网络可能预测负值后面训练时会用归一化目标来限制范围。2.5 训练脚本冻结骨干、调学习率、保存权重训练分两个阶段走。第一阶段冻结骨干只训练回归头用较大学习率快速收敛第二阶段解冻骨干最后几层用小学习率微调。这个做法能避免随机初始化回归头把骨干的预训练特征破坏掉。from torch.utils.data import DataLoader import torch.optim as optim train_df load_csv(train.csv) dataset BoneAgeDataset(train_df, images) loader DataLoader(dataset, batch_size16, shuffleTrue, num_workers4) model BoneAgeModel() for p in model.backbone.parameters(): p.requires_grad False opt optim.Adam(model.fc.parameters(), lr1e-3) criterion nn.L1Loss() for epoch in range(20): model.train() for batch in loader: img batch[img] male batch[male] age batch[age] / 216.0 # 归一化到0-1 pred model(img, male) loss criterion(pred, age) opt.zero_grad() loss.backward() opt.step() print(fepoch {epoch} loss {loss.item():.4f}) torch.save(model.state_dict(), best_model.pt)逻辑说明这里用216个月作为归一化分母把骨龄压到0到1之间。直接回归原始月龄会导致损失值很大、收敛慢输出还可能出现不合理的负值。推理阶段拿到模型输出后乘回216就是预测月龄。参数说明batch_size16在显存低于6GB的GPU上比较保险L1Loss直接对应MAE比MSE更贴近“预测误差几个月”的临床表达。训练结束后最好把BoneAgeModel类单独放到model.py。GUI推理时要import这个类否则torch.load只会拿到一堆没有结构解释的权重张量load_state_dict会报missing keys。3. 把模型塞进PyQt5界面从加载图片到显示骨龄的完整链路3.1 PyQt5界面设计三块区域和两种布局PyQt5界面设计不需要复杂我一般用左右结构左侧放图片选择按钮、性别单选和缩略图右侧放X光片大图、骨龄结果和错误日志。布局用QHBoxLayout和QVBoxLayout嵌套手写代码比Qt Designer生成的ui文件更好维护尤其当你需要在推理完成后动态更新控件时。如果是在PyCharm里开发建议装好PyQt5后直接手写窗口类不要过度依赖可视化拖拽因为毕设答辩时老师更关心你能否讲清楚信号槽和数据流。核心窗口框架如下import sys from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QPushButton, QLabel, QVBoxLayout, QHBoxLayout, QFileDialog, QRadioButton) from PyQt5.QtCore import Qt, QThread, pyqtSignal from PyQt5.QtGui import QPixmap from model import BoneAgeModel class MainWindow(QMainWindow): def __init__(self): super().__init__() self.model BoneAgeModel() self.model.load_state_dict( torch.load(best_model.pt, map_locationcpu)) self.model.eval() self.male 1 self.init_ui() def init_ui(self): self.setWindowTitle(骨龄识别检测系统 - PyQt5) self.btn_open QPushButton(选择X光片) self.btn_open.clicked.connect(self.open_image) self.image_label QLabel(未选择图片) self.image_label.setFixedSize(300, 300) self.image_label.setAlignment(Qt.AlignCenter) self.rb_male QRadioButton(男) self.rb_female QRadioButton(女) self.rb_male.setChecked(True) self.result_label QLabel(骨龄) self.result_label.setWordWrap(True) layout QVBoxLayout() layout.addWidget(self.btn_open) layout.addWidget(self.image_label) layout.addWidget(self.rb_male) layout.addWidget(self.rb_female) layout.addWidget(self.result_label) container QWidget() container.setLayout(layout) self.setCentralWidget(container)逻辑说明这个窗口先加载模型再初始化控件。加载模型放在init_ui之前可以避免窗口弹出来后模型才缓慢加载导致的“假死”体验。result_label用setWordWrap便于显示多行骨骼发育描述。参数说明setFixedSize限制了缩略图大小图片太大时界面会变形使用QRadioButton而不是QComboBox是为了减少点击次数。3.2 图片选择与预览OpenCV读图、QPixmap显示打开图片要用QFileDialog预览要用QPixmap。这里有个常见误区很多人把OpenCV读取后的图像直接转QPixmap结果颜色通道混乱X光片看起来偏蓝。更好的做法是直接用QPixmap(path)加载原始文件用于预览然后用OpenCV单独读取灰度图做模型推理。def open_image(self): path, _ QFileDialog.getOpenFileName( self, 选择X光片, , Images (*.png *.jpg *.jpeg)) if not path: return self.current_path path pixmap QPixmap(path) self.image_label.setPixmap( pixmap.scaled(self.image_label.width(), self.image_label.height(), Qt.KeepAspectRatio))参数说明文件过滤写清楚png/jpg/jpeg可以防止误选DICOM医学影像格式。setPixmap之前没有清空label内容新图片会自动覆盖。这里没有保存pixmap引用但QPixmap在Qt内部会维护一份不需要手动管理内存。3.3 异步推理把模型前向放到QThread里PyQt5里最常见的翻车写法是在按钮槽函数里直接调model(img_tensor)窗口马上变成“未响应”。X光片推理虽然只有几百毫秒到一两秒但已经足够让用户觉得程序死了。必须把推理放到独立线程。class InferenceThread(QThread): result_ready pyqtSignal(float, float) def __init__(self, model, img_path, male): super().__init__() self.model model self.img_path img_path self.male male def run(self): import cv2 import numpy as np import torch img cv2.imread(self.img_path, cv2.IMREAD_GRAYSCALE) img cv2.resize(img, (224, 224)) img np.stack([img] * 3, axis2).astype(np.float32) / 255.0 img_tensor torch.from_numpy(img).permute(2, 0, 1).unsqueeze(0) male_tensor torch.tensor([[float(self.male)]]) with torch.no_grad(): pred self.model(img_tensor, male_tensor) age_month pred.item() * 216.0 self.result_ready.emit(age_month, 0.0)逻辑说明run方法里重新import依赖是为了让线程环境更干净实际项目可以把import放到文件顶部。模型前向放在with torch.no_grad()中不构建计算图推理速度更快。参数说明male_tensor必须与训练时维度一致训练时为(batch, 1)这里构造(1, 1)age_month乘回216把归一化输出还原成月龄。3.4 信号槽把结果送回界面在MainWindow里启动线程并连接result_ready信号。def open_image(self): # 上面已经保存了 self.current_path self.male 1 if self.rb_male.isChecked() else 0 self.thread InferenceThread( self.model, self.current_path, self.male) self.thread.result_ready.connect(self.show_result) self.thread.start() def show_result(self, age_month, _err): age_year age_month / 12.0 self.result_label.setText( f骨龄{age_year:.1f} 岁{age_month:.0f} 月\n f性别{男 if self.male 1 else 女})逻辑说明每次点击打开图片就新建一个InferenceThread线程结束后交给Python垃圾回收。因为线程对象被self.thread引用不会中途被回收。show_result在主线程回调可以直接操作控件。参数说明age_year保留一位小数答辩展示时更易读如果想要误差范围可以在训练集验证集上统计MAE把那个值作为_err传入。3.5 用QTextBrowser显示HTML报告PyQt5支持显示HTML这让结果展示的质感提升很多。把result_label换成QTextBrowsersetHtml就可以输出带加粗、表格、分行的报告。from PyQt5.QtWidgets import QTextBrowser self.result_browser QTextBrowser() self.result_browser.setHtml( h3骨龄评估报告/h3 pb性别/b男/p pb预测骨龄/b12.3岁/p pb对应月龄/b148个月/p )参数说明QTextBrowser只支持局部HTML语法不要塞复杂的CSS。显示本地图片需要加file:///前缀比如 不能直接写相对路径。这段代码放到show_result里时需要注意HTML字符串拼接时要处理浮点数格式化用f-string会更直观。4. 骨龄评估的关键参数从网络输出到临床可接受误差4.1 归一化因子为什么是216而不是240前面训练时把骨龄除216做归一化这个值不是拍脑袋定的。RSNA数据集的骨龄标注范围是1到228个月但超过216个月的样本极少取216能覆盖绝大多数样本同时让网络输出稳定在0.3到0.95之间。如果只用数据里的最大值做归一化推理时的归一化因子就得每次从训练集重新算很别扭。下面这张表列了训练和推理阶段必须保持一致的参数我建议把它们写进config.yaml避免换机器后靠记忆力填参数参数推荐值说明input_size224x224与ResNet输入对齐normalize_factor216骨龄月龄除以216gray_to_rgb复制通道灰度图转三通道batch_size166GB显存以下推荐lr_finetune1e-5解冻骨干后的学习率lr_header1e-3只训练回归头时的学习率参数说明灰度转三通道这个参数容易在训练和GUI里写反。训练时是先np.stack再permute推理时也要一样顺序错一步图像通道就会变成高度和宽度维度互换模型几乎必然预测失败。4.2 性别特征如何参与计算骨龄发育曲线男女差异非常大女孩青春期发育比男孩早同一张X光片对男女的骨龄解读完全不同。所以性别不能只作为界面展示信息必须真正喂给模型。我在第2章的模型里把male特征拼在全连接层前面这是一条最简单的路径。如果你想把性别融合做得更细可以把性别特征在backbone的多个特征层上广播拼接。但代价是代码复杂度上升。对毕业设计来说拼接在全连接层已经完全够用而且效果可解释训练时模型会学到性别偏移推理时切换男女性别同一张图会得到两个不同结果这个差异正好可以写进毕设论文。4.3 模型评估指标MAE、R2和±1岁比例训练收敛后不能只看loss降得多低。骨龄识别在临床语境下一般用三个指标指标含义可接受值MAE平均绝对误差月 12个月R2预测值与真实值的相关性 0.85±1岁准确率预测误差在1岁内的样本占比 85%MAE是核心指标它直接反映平均误差多少个月。R2能看出模型是否只是把样本按照性别和年龄粗略分段。±1岁准确率则是临床更关心的口径对医生来说误差在12个月内通常不影响生长发育评估。我在训练时会每轮保存当前MAE最低的权重而不是最后一个epoch的权重。最后阶段解冻骨干如果训练集MAE持续下降但验证集MAE回升就说明过拟合应该早停。这个小习惯能帮你稳定拿到一个能应付答辩的模型。4.4 校准曲线系统低估或高估时的补救有时候模型训练完MAE看起来还行但画散点图会发现预测值系统性偏低尤其在骨龄大于15岁的样本上。这通常是因为高年龄段样本少回归头为了降低整体损失把预测值往样本集中的中间年龄段压缩。常见做法是对预测值做一次一元线性校准# 在验证集上计算校准系数 # pred_cal a * pred_raw b校准系数可以用最小二乘在验证集上拟合。注意这种行为有点像“后悔药”它确实能改善数字但不改变模型结构也不代表网络真正学会高年龄段特征。更好的做法是高年龄段样本过采样但时间紧的话线性校准是性价比最高的补救手段。5. 避坑清单PyQt5安装、GPU推理和标注工具的三类常见问题5.1 PyQt5安装后import报错labelme又把环境搞乱了现象pip install pyqt5成功后在PyCharm里import PyQt5直接报错显示DLL load failed或找不到PyQt5.sip。还有同学想用labelme标注X光片装labelme时自动把PyQt5版本升级结果原项目跑不起来。原因Python版本与PyQt5 wheel不匹配常见于Python 3.9以上装老版本PyQt5或者环境里同时存在PyQt5和PyQt6Qt的QtCore插件加载冲突。labelme安装时如果没指定版本pip会拉取最新PyQt5破坏已有环境。解决我建议先创建一个干净的虚拟环境然后明确安装PyQt5 5.15.x这个版本兼容性和稳定性最好。用python -m pip install pyqt55.15.2装完立刻在终端跑python -c from PyQt5.QtCore import Qt; print(Qt.QT_VERSION_STR)验证。labelme单独放另一个环境不要和GUI主环境混用。5.2 图片resize宽高顺序翻了预测结果全是同一个值现象训练集上用cv2.resize(img, (224, 224))正常GUI里换了一批图片后预测值全部集中在某个固定数字附近几乎没有波动。原因opencv的resize第一个参数是(width, height)也就是(width224, height224)。当图片本身是长方形时这两个顺序不一致会导致实际缩放比例错位。更隐蔽的问题是如果医学图像有元信息翻转你读图时可能读了带旋转标记的原始方向而训练集里全是某一种方向。解决统一用cv2.resize(img, (img_size, img_size))正方形尺寸避免width和height混淆。对于方向翻转在读图后用cv2.rotate做一下方向校验或者干脆在数据预处理代码里固定只按竖版处理。检查方法很简单在GUI推理时打印img.shape确认是(224, 224, 3)而不是(3, 224, 224)。5.3 GUI卡死模型推理跑在Qt主线程现象点击“选择X光片”后窗口无响应鼠标转圈过几秒才弹出结果。原因这是PyQt5最典型的翻车。按钮点击事件默认在GUI主线程执行直接在里面做cv2.imread、resize、torch前向传播所有耗时操作都会阻塞事件循环。如果模型还在GPU上做batch推理卡顿会更久。解决在第3章已经给出InferenceThread写法。这里再多说一句不要在启动线程后又用thread.wait()等待那仍然会堵住主线程。正确的做法是只调用start()处理结果靠信号槽回调。5.4 load_state_dict报missing keys训练和推理模型结构不一致现象训练时保存了best_model.ptGUI里torch.load后load_state_dict报错说缺少很多key。原因训练脚本里用的BoneAgeModel类和GUI项目里的model.py类定义不一致比如分类层构造参数不同或者backbone名字写错。PyTorch保存的state_dict里的key是按模块路径生成的比如backbone.layer1.0.conv1.weight一旦模块路径对不上全部无法加载。解决把训练脚本里的model.py原封不动复制到GUI项目里不要“顺手优化”结构。如果确实要改进模型结构应该把旧权重移除而不是期望兼容。加载时加严格True会让问题尽早暴露别用strictFalse掩盖不一致。5.5 X光片预览和推理显示不一致现象界面缩略图看起来清晰正常但预测结果和医生判断差很远仔细看发现推理用的图与预览的图亮度完全不同。原因预览用QPixmap直接加载原图推理用OpenCV灰度图复制三通道两者读取文件的通道顺序和亮度映射不同。如果图片本身带有ICC色彩文件不同库解析出的RGB值也会有差异。解决让预览和推理走同一套读图逻辑。先cv2.imread灰度图再用cv2.cvtColor转成RGB转成QImage显示。这样保证用户看到的和模型看到的至少来自同一份数据。灰度转三通道仍然保留是喂给模型的格式和界面显示的RGB不冲突。6. 从能跑到答辩加分分层验证、热力图与HTML报表模型和GUI跑通只是第一步。答辩时老师最常问的是“你这模型对不同性别、不同年龄段的误差差多少”如果你只回答整体MAE很容易显得没做深入验证。我习惯把验证集按性别和年龄段分层统计每组的MAE并画成表格这样能快速发现模型在哪个区间表现弱。分层验证代码很轻量不用改模型。把验证集按年龄分成0-6岁、7-12岁、13-18岁三组按性别再分男女分别计算MAE。如果发现13-18岁女生组MAE明显偏高就可以在论文里讨论“青春期骨骺形态变化大样本量少”这些真实存在的限制。这种分析比堆一堆训练曲线更能体现你对问题的理解。进阶展示可以加Grad-CAM热力图把模型关注区域叠加到原图上。这个模块不需要很严密的完整实现只需用PyTorch中的Hook提取最后一层卷积梯度计算热力值并映射成颜色输出为一张叠加图。Qt界面里新增一个“显示热力图”按钮点击后在右侧QLabel显示叠加结果。答辩时老师看到红色区域集中在骨骺线附近比任何文字说服力都强。最后是HTML报表。PyQt5的QTextBrowser可以动态生成评估结果表我在上一章已经给了基础写法。建议在show_result里把图片路径也拼进HTML这样报告里能看到缩略图、性别、年龄、误差范围和检测时间。这个功能几乎是现成的不需要额外依赖库也能让GUI看起来完整度更高。我从这个项目里最大的教训是数据预处理和模型结构之间的隐藏依赖一定要写成配置文件。当时我图省事把resize尺寸和归一化因子硬编码在训练脚本里后面在GUI联调时花了两个晚上才发现推理代码里把216写成了240。希望这组避坑清单能帮你省掉这些重复调试时间。如果选型不变按这套路线走一个可演示的骨龄识别GUI很有希望在两周内跑通。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网