
简介这是一套基于PyQt5与深度学习的骨龄识别检测完整工程面向毕业设计、课程设计及期末大作业场景适合有一定Python基础、希望快速上手完整项目的新手与高年级学生。资源包含检测模型、GUI界面及配套代码注释部署即可运行。压缩包内共200个文件其中69个py源码文件承载核心逻辑与界面实现53个yaml及12个yml为环境配置与依赖定义18个pth为训练好的模型权重其余包括Dockerfile、说明文档、脚本及少量图片等辅助文件整体约717MB。目前已有306人学习项目为作者手打高分作品获得导师认可可帮助读者快速理解骨龄识别流程、PyQt5界面搭建与深度学习模型部署方法节省从零开发的时间。目录结构清晰便于局部修改与二次开发是高分毕设参考的实用选择。1. 骨龄识别为什么值得作为毕设它不止是一个深度学习分类器医生看一张左手腕X光片按TW3法逐块骨头评分一张片子少说要几分钟遇到骨骺边界模糊的还得反复比对图谱。一个训练好的ResNet模型做骨龄回归一次前向推理几十毫秒就能给出结果这就是“基于PyQt5深度学习的骨龄识别检测”这个题目的现实价值。它不像目标检测那样要画框也不像分割那样要逐像素标注本质上是一个带回归头的图像回归任务配合PyQt5把模型封装成桌面GUI正好凑成一套能演示、能打包、能答辩验收的完整系统。适合想用公开数据练手深度学习、又要交付交互界面的本科或硕士阶段学习者。下面从网络设计、GUI搭建、模型对接和部署避坑四条线依次展开。2. 网络设计与数据预处理先让模型学会看手腕骨骨龄识别的监督信号是医生标注的骨龄值单位是“月”。一张13岁半的左腕X光片标签大约是162个月。这个任务的关键点在于模型输出的不是类别而是一个连续数值所以要按回归任务来设计网络头部和损失函数而不是套用分类器模板。数据规模不大公开的骨龄数据集大概在一万张上下属于中等偏小规模网络选型和预处理策略必须围绕这个量级展开。2.1 网络选型ResNet50做骨龄回归为什么不用更深的网络做骨龄回归常见做法是拿ImageNet预训练的ResNet50当骨干网络把最后一层全连接分类头换成回归头。选ResNet50而不是ResNet101或Swin Transformer原因很现实一是数据量不够大一万多张图撑不起超大模型的训练收益预训练权重再强也容易在小数据集上过拟合二是消费级单卡就能跑3080级别跑ResNet50加224输入没有任何压力训练一个baseline用不了一晚上对毕设周期友好。Transformer类模型在医学图像上确实能刷更高精度但调参成本、显存占用和部署体积都上去了不适合作为主方案。输出头设计有两种流派。一种是直接回归把fc换成单输出节点用L1损失MAE监督另一种是把骨龄按月切片成N类用分类头输出softmax分布再按类别索引加权求期望。实际效果上直接回归更直观收敛更稳定评估指标MAE一目了然也是多数开源实现的选择。想给毕设加创新点可以在骨干网络里嵌入SE模块或瓶颈注意力但前提是先跑通一个不带注意力的baseline否则分不清精度变化到底来自注意力还是来自调参运气。import torch.nn as nn from torchvision import models def build_model(num_classes1, pretrainedTrue): model models.resnet50( weightsmodels.ResNet50_Weights.IMAGENET1K_V2 if pretrained else None ) # 把最后的全连接分类头换成单输出回归头 model.fc nn.Sequential( nn.Dropout(0.2), nn.Linear(model.fc.in_features, 128), nn.ReLU(inplaceTrue), nn.Linear(128, num_classes) ) return model这段代码里weights参数用IMAGENET1K_V2而不是老写法pretrainedTrue是为了避免新版torchvision的弃用告警。Dropout加在回归头里做轻度正则0.2是比较稳的值训练中如果发现验证集MAE震荡厉害可以提高到0.3。中间隐藏层128不是必须的直接把2048维特征压到1维也能收敛保留中间层是为了给回归头一点非线性拟合能力尤其适合骨龄和骨骼形态之间不是纯线性关系的场景。2.2 预处理Pipeline灰度图、左右手统一、尺寸缩放、归一化手腕X光片本来就是灰度图而ResNet的输入层按三通道设计所以常见的做法是把灰度图复制成三通道。这里有个容易忽略的细节很多原始X光片是厂商翻拍的分辨率从几百到几千像素不等直接拉伸到224×224会把骨骼比例压变形。更好的做法是先按短边等比缩放再中心裁剪到224或者干脆在缩放前保持宽高比填充到正方形。毕设阶段直接用cv2.resize硬拉也能出结果但碰到手腕位置偏左偏右的图验证集MAE会比等比缩放的方案高几个月。左右手统一是另一个必须处理的点。TW3和CHN骨龄标准都基于左手腕X光片上通常有L/R标记如果数据集里混了右手图而不翻转模型会把左右手当成两种语义训练损失看起来正常实际推理时换一只手结果就漂移。处理方式是在预处理函数里判断图的来源标记右手图做一次水平镜像。归一化策略上主流做法是除以255后用ImageNet均值方差做标准化。医学图像灰度分布和自然图像差别很大有些人干脆只做除以255也会收敛。这个选择不是玄学但训练和推理必须完全一致否则会出现“验证MAE很好GUI里预测结果却离谱”的现象。最好把输入尺寸、mean、std全部写进一个config.json训练脚本和推理脚本都从同一个配置读值。import cv2 import numpy as np import torch IMG_SIZE 224 MEAN (0.485, 0.456, 0.406) STD (0.229, 0.224, 0.225) def load_hand_image(img_path): # 统一灰度读入避免彩色通道干扰 img cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) if img is None: raise ValueError(fcannot read image: {img_path}) # 骨龄标准基于左手遇到右手的图就水平翻转 # if is_right_hand(img_path): # img cv2.flip(img, 1) # 等比缩放到短边224再中心裁剪降低骨骼形变 h, w img.shape[:2] scale IMG_SIZE / min(h, w) img cv2.resize(img, (int(w * scale), int(h * scale))) img img[(img.shape[0] - IMG_SIZE) // 2:(img.shape[0] - IMG_SIZE) // 2 IMG_SIZE, (img.shape[1] - IMG_SIZE) // 2:(img.shape[1] - IMG_SIZE) // 2 IMG_SIZE] # 灰度转三通道值不变 img cv2.cvtColor(img, cv2.COLOR_GRAY2RGB) img img.astype(np.float32) / 255.0 img (img - MEAN) / STD # HWC - CHWpytorch 需要的形状 img img.transpose(2, 0, 1) return torch.from_numpy(img.copy())这段预处理是最容易在训练和推理之间产生不一致的地方。等比缩放加中心裁剪比直接resize多了两步操作但能保住手腕骨骼的长宽比例如果原始图里手腕区域占比很小可以先根据图像灰度做一次简单的前景定位裁剪把手腕区域先框出来再缩放。is_right_hand这个函数需要你根据数据集的实际字段补充RSNA这类公开数据的元数据里带左右标记直接用就行。数据增强只应该放在训练侧推理侧保持完全确定性操作。推荐用随机旋转±10度、随机缩放0.9到1.1、随机亮度和对比度调整。不要做水平翻转增强原因前面说过左手镜像到右手会扭曲骨龄标准语义。用albumentations库实现增强比手写方便而且能保证和预处理组合时的高性能。3. 用PyQt5搭出完整GUI文件选择、预览与推理结果展示PyQt5做桌面端界面对毕设来说够用而且资料多。整个界面只需要三个核心组件QLabel显示图像、QPushButton触发选图和推理、QLabel显示结果再加一个QProgressBar做推理状态提示。不要一上来就设计复杂的多页面先跑通单窗口闭环后面再考虑加历史记录、批量识别这些扩展。3.1 界面骨架QFileDialog选图 QLabel预览的布局写法PyQt5界面设计有两条路一条用Qt Designer拖拽生成.ui文件再转成.py另一条直接用代码写布局。对新手来说Qt Designer上手快但运行时要额外处理uic转换直接用代码布局更轻改起来也直观。我用PyCharm调试这类GUI项目时一般直接写代码布局遇到对齐问题再回头调layout比反复重新生成.ui更省事。from PyQt5.QtWidgets import ( QMainWindow, QWidget, QPushButton, QLabel, QVBoxLayout, QHBoxLayout, QFileDialog, QProgressBar ) from PyQt5.QtGui import QPixmap from PyQt5.QtCore import Qt class BoneAgeWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle(骨龄识别检测系统) self.model None self.current_path None self.infer_thread None self._build_ui() def _build_ui(self): central QWidget() self.setCentralWidget(central) layout QVBoxLayout(central) self.img_label QLabel(请选择手腕X光片) self.img_label.setAlignment(Qt.AlignCenter) self.img_label.setMinimumSize(480, 360) layout.addWidget(self.img_label) btn_row QHBoxLayout() self.open_btn QPushButton(选择图片) self.run_btn QPushButton(开始识别) self.run_btn.setEnabled(False) btn_row.addWidget(self.open_btn) btn_row.addWidget(self.run_btn) layout.addLayout(btn_row) self.result_label QLabel(预测骨龄--) layout.addWidget(self.result_label) self.progress QProgressBar() self.progress.setRange(0, 0) # 忙碌状态显示转圈 self.progress.hide() layout.addWidget(self.progress) self.open_btn.clicked.connect(self.choose_image) self.run_btn.clicked.connect(self.start_predict) def choose_image(self): path, _ QFileDialog.getOpenFileName( self, 选择X光片, , Images (*.png *.jpg *.jpeg *.bmp) ) if path: self.current_path path pixmap QPixmap(path) self.img_label.setPixmap( pixmap.scaled(self.img_label.size(), Qt.KeepAspectRatio) ) self.run_btn.setEnabled(True)这段布局代码里QProgressBar的setRange(0,0)表示不确定进度推理时显示转圈动画推理结束隐藏。img_label的minimumSize保证窗口拖小时预览区不会缩没。choose_image里用QPixmap直接加载图片路径如果包含中文在部分Windows环境下会失败遇到这种情况改用QImage Reader读取再转QPixmap。setEnabled(False)是个小细节防止用户没选图就点推理造成空指针。3.2 推理不卡界面QThread 信号槽把模型放到后台线程直接把模型前向推理写在按钮的clicked回调里点击后窗口会立刻“未响应”因为主线程忙着跑模型没空处理窗口重绘和鼠标事件。PyQt5的解法是继承QThread把耗时操作放进run方法推理完成后通过信号把结果传回主线程。import torch from PyQt5.QtCore import QThread, pyqtSignal class InferThread(QThread): # 信号传回预测骨龄单位月 result_ready pyqtSignal(float) error_happened pyqtSignal(str) def __init__(self, model, img_path, preprocess_fn, parentNone): super().__init__(parent) self.model model self.img_path img_path self.preprocess preprocess_fn def run(self): try: tensor self.preprocess(self.img_path) with torch.no_grad(): out self.model(tensor.unsqueeze(0)) age_month float(out.item()) self.result_ready.emit(age_month) except Exception as e: self.error_happened.emit(str(e)) class BoneAgeWindow(BoneAgeWindow): def start_predict(self): if self.model is None: # 实际项目中模型在init里加载这里只做保护 return self.run_btn.setEnabled(False) self.progress.show() self.infer_thread InferThread( self.model, self.current_path, load_hand_image ) self.infer_thread.result_ready.connect(self.show_result) self.infer_thread.error_happened.connect(self.show_error) self.infer_thread.start() def show_result(self, age_month): year int(age_month // 12) month int(age_month % 12) self.result_label.setText(f预测骨龄{year}岁{month}个月) self.run_btn.setEnabled(True) self.progress.hide()run方法里做了三件事把图片路径交给预处理函数得到张量、用torch.no_grad()包住前向推理、把结果通过信号发回主线程。unsqueeze(0)是把单张图补成batch维度网络需要四维输入。启动推理时禁用按钮和显示进度条防止连点创建多个线程收到结果或错误后恢复界面。关闭窗口时需要让线程退出不然程序会停在等待线程结束的状态。常见做法是在closeEvent里调用self.infer_thread.wait()如果线程还在跑就等它结束再关。模型加载只做一次在窗口初始化时完成不要在每次推理前torch.load那会让单次预测额外增加几秒延迟。4. 训练与推理对齐损失、权重保存与推理参数模型训练得好不好一半看损失函数和微调策略另一半看推理侧的预处理和模型状态是否和训练侧完全一致。很多毕设翻车都是死在“训练用一套预处理推理用另一套”这种问题上。4.1 训练配置MAE损失、Adam和两阶段微调骨龄回归的主流评估指标是平均绝对误差MAE所以损失函数直接选L1Loss最直观。有些实现用SmoothL1Loss对离群点更鲁棒但毕设baseline用L1即可。优化器用Adam学习率需要分两段设先冻结骨干网络只训练回归头用1e-3解冻后全模型微调降到1e-4。import torch import torch.nn as nn model build_model(num_classes1, pretrainedTrue) # 冻结除 fc 以外的全部参数先只训回归头 for param in model.parameters(): param.requires_grad False for param in model.fc.parameters(): param.requires_grad True optimizer torch.optim.Adam( filter(lambda p: p.requires_grad, model.parameters()), lr1e-3 ) criterion nn.L1Loss() # 第一阶段训5个epoch后解冻 # for epoch in range(5): # train_one_epoch(model, dataloader, optimizer, criterion) # 解冻所有参数用更小学习率微调 for param in model.parameters(): param.requires_grad True optimizer torch.optim.Adam(model.parameters(), lr1e-4)这段代码里有两个容易踩的细节。Adam的filter只把requires_grad为True的参数交给优化器避免冻结层被更新解冻后必须重新构造优化器因为原来的优化器只管理了fc层的参数继续调用step不会更新backbone。第一阶段训5个epoch只是个参考值如果你的回归头收敛慢可以延长到8个epoch。batch_size按显存来定ResNet50加224输入8GB显存可以跑32显存小就降到16不要硬撑。数据集划分按8:2随机分成训练和验证验证集的MAE用来做早停和模型选择。patience设10个epoch也就是连续10轮验证MAE不下降就停止训练然后回滚到验证集上表现最好的权重。训练曲线里如果训练MAE降但验证MAE不降优先怀疑过拟合把Dropout调高或者缩小学习率。4.2 保存与加载只存state_dict并把预处理参数写进配置PyTorch模型保存有两种方式整模型保存和只存state_dict。毕设项目里应避免整模型保存因为torch.save(model)会把model类的定义路径也存进去换了项目目录结构或者换了机器就报module not found。只存state_dict再重建网络结构兼容性最好。# 训练完保存 torch.save({ model_state: model.state_dict(), input_size: 224, mean: MEAN, std: STD, val_mae: best_val_mae, }, checkpoints/bone_age.pt) # 推理时加载 ckpt torch.load(checkpoints/bone_age.pt, map_locationcpu) model build_model(num_classes1, pretrainedFalse) model.load_state_dict(ckpt[model_state]) model.eval()注意加载时build_model的pretrained要传False否则会先加载一遍ImageNet预训练权重再被state_dict覆盖白白浪费时间。map_locationcpu保证在没装CUDA的机器上也能加载训练好的权重。把input_size、mean、std一起存进pt文件推理脚本读取这些值而不是重新硬编码是防止预处理不一致最简单的手段。推理前必须调用model.eval()。PyTorch里BN层在train和eval两种模式下的行为不同eval模式会用训练时统计的全局均值方差train模式用当前batch的统计值。如果忘了切eval单张图推理时BN会按batch统计来算结果会和训练时的分布不一致MAE可能直接崩到两岁以上。5. 常见问题避坑PyQt5 深度学习部署的5个翻车现场PyQt5单看不复杂但和torch、opencv组合在一起时会出现各种表面上看不出关联的报错。这些坑几乎是每个做骨龄识别GUI的人都会踩一遍的提前知道能省下好几个下午。5.1 OpenCV与PyQt5的Qt插件冲突现象代码里调用cv2.imshow显示图片PyQt5窗口崩溃或直接卡死有些环境更诡异只要import cv2PyQt5窗口样式就变了。原因OpenCV的highgui模块内置了Qt插件这个插件和PyQt5加载的Qt库不是同一套构建两个库抢同一批资源就想两套驱动同时控制一张显卡一样打架。解决GUI程序里任何图像预览都不要用cv2.imshow全部走QLabel展示。如果做数据处理时确实要弹窗看图像装opencv-python-headless替换完整版opencvheadless版本不包含highgui的GUI依赖就不会和PyQt5冲突。注意改完要重启一次解释器科学环境里经常是旧模块已经被导入了。5.2 labelme装完把PyQt5弄坏现象pip install labelme后PyQt5从较新版本被覆盖成旧版本或import PyQt5直接报错。原因labelme是图像标注工具它把PyQt5作为依赖并且锁定在它测试过的旧版本。你后装labelme时pip会为了满足依赖把已有的PyQt5降级先装好的GUI代码就跟着完蛋。解决数据标注和GUI开发用两个独立环境。数据准备阶段建一个conda环境专门装labelmeGUI运行环境另建一个PyQt5版本固定在你调试通过的版本上。如果已经翻车在GUI环境里重装指定版本的PyQt5即可恢复。这个坑几乎所有人都会撞一次属于PyQt5安装里最经典的版本冲突。5.3 图片颜色和方向不对现象X光片在GUI里显示偏蓝或偏绿有的图上下颠倒打包后在别的电脑上方向也可能变。原因OpenCV读图默认是BGR三通道顺序而QPixmap按RGB解释另外一批用手机或数码相机翻拍的片子自带EXIF旋转信息普通读取不会自动应用旋转。解决显示前用cv2.cvtColor(img, cv2.COLOR_BGR2RGB)转一次通道顺序或者模块里全部用PIL.Image.open读取再转numpy。对于EXIF方向用PIL打开时它会自动应用旋转元数据顺手解决。数据进入训练前最好统一转成png重新保存避免jpg多次压缩在骨骼边缘产生伪影。5.4 点“开始识别”界面就卡死现象点击按钮后窗口标题变成“未响应”过几秒才恢复像个黑匣子一样不知道程序在干什么。原因模型前向推理直接跑在了GUI主线程里。主线程被模型占据没法响应Windows的消息循环系统就判定程序未响应。这个现象在CPU推理时尤其明显一次推理可能耗时好几秒。解决严格按第3.2节的方式推理丢进QThread。不要每次推理都新建模型对象模型在窗口初始化时加载一次推理线程里只做forward。另外在线程里不要访问或者修改任何GUI控件结果通过signal传回主线程再更新界面这是PyQt5线程模型的红线。5.5 打包exe后闪退现象源码运行一切正常PyInstaller打包后双击exe图标闪一下就没窗口了命令行运行能看到FileNotFoundError或ModuleNotFoundError。原因两类问题叠加。一是模型文件、配置文件没有打进包或者路径用了os.getcwd()而双击exe时工作目录不是exe所在目录二是PyInstaller打包torch这种动态导入很多的大库时漏掉部分子模块。解决资源文件路径尽量用sys._MEIPASS处理PyInstaller释放临时文件的目录模型用--add-data命令显式打包进数据文件。打包优先用onedir模式也就是-D参数虽然会生成一堆文件但启动快、报错信息明确。项目里只要带了torch和PyQt5打好包后第一件事是做一个自检脚本把当前路径和环境信息写进日志文件闪退时看日志再定位问题别瞎猜。6. 让毕设更像工程ONNX导出、exe打包与结果验证6.1 ONNX导出与onnxruntime推理把PyTorch模型导出成ONNX格式部署端就不需要再装torch整个依赖体积小很多打包排错也简单。导出时用一个形状固定的dummy输入ONNX会把计算图固化下来。dummy torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy, bone_age.onnx, input_names[input], output_names[output], opset_version11 ) # 推理端 import onnxruntime as ort sess ort.InferenceSession(bone_age.onnx, providers[CPUExecutionProvider]) out sess.run([output], {input: tensor.numpy()})导出前记得先model.eval()ONNX导出的计算图里BN算子会固化成推理模式。onnxruntime的providers参数指定CPUExecutionProvider一是省去CUDA依赖二是内存占用比torch推理低不少。对骨龄识别这种单张推理场景CPU推理速度已经够快。6.2 打包命令与最小验收PyInstaller打包PyQt5加onnxruntime比打包torch轻松很多依赖明确体积也从几百MB降到几十MB。常用命令如下pyinstaller -w -D \ --add-data bone_age.onnx;models \ --add-data config.json;. \ --name bone_age_app main.py-w去掉控制台黑框-D输出目录模式而不是单文件模式。单文件模式启动时需要解压临时目录启动速度慢而且很多环境杀毒软件会拦截PyInstaller生成的单文件exe。打包完成后找一台没装Python的机器双击测一次确认模型加载和推理都正常。6.3 验证方法准备10张没进训练集的片子下载或从医院脱敏数据里留出10张没参与过训练和验证的X光片把医生标注的骨龄作为真值跑一次批量推理列出每张图的预测值和误差。整体MAE在一年以内就说明模型落地可用如果个别片子误差特别大优先查预处理左右手有没有统一、尺寸有没有被拉伸、normalize参数是不是和训练一致。把这张误差表放在毕设论文的测试章节里比贴十张网络结构图更有说服力。我当年第一次把模型接进GUI图省事在按钮回调里直接跑前向推理点完按钮窗口就转圈导师以为程序死机了。后来养成了两个习惯凡是模型推理一律丢后台线程凡是打包后的程序先跑自检脚本确认环境。后面答辩演示再没翻过车。希望帮到你。本文还有配套的精品资源点击获取