首页 / 资讯中心 / 文章详情

基于深度学习的骨龄检测识别系统:YOLOv5+PyTorch+PySide6实战解析

基于深度学习的骨龄检测识别系统:YOLOv5+PyTorch+PySide6实战解析 ★ FEATURED ARTICLE
简介基于深度学习的骨龄检测识别系统将PyTorch、Pyside6与YOLOv5模型整合构建了从儿童手腕X光图像中检测骨骼区域并推断骨龄的完整解决方案适用于医学影像分析、目标检测及AI辅助诊断等场景也适合具备一定Python基础的中高级开发者学习工程化项目组织。资源包共205个文件压缩后约406.37MB内容涵盖Python源码、编译生成的pyc文件、YOLOv5模型配置yaml/yml和预训练权重pt、界面UI定义、Docker镜像构建文件、Git忽略规则、开源许可证及中英文README文档另有tutorial.ipynb交互式教程、示例图片与项目依赖配置文件目录层次分明便于按需查阅和二次开发。目前已有332人学习下载。通过这套资源读者可以掌握YOLOv5模型的训练与推理流程、PySide6图形界面搭建技巧、医学图像预处理与检测结果可视化方法同时理解开源项目中版本控制、容器化部署和文档规范等工程实践可作为课题研究、毕业设计或竞赛项目的直接参考。1. 骨龄检测不是“检测”这份资源到底在解决什么问题拿到“基于深度学习的骨龄检测识别系统PyTorchPyside6YOLOv5模型”这个项目时我原本以为核心难点在 YOLOv5 目标检测上直到把整套代码跑通才发现一个反直觉的结论YOLOv5 在这里只负责定位手骨区域真正给骨龄打分的是另一个基于 PyTorch 的回归模型两者通过一条“检测→裁剪→回归”的流水线串联最后由 Pyside6 包装成桌面应用。也就是说图像识别领域的“检测”和医学影像里的“检测”是两回事前者是框出目标后者是估算一个连续年龄值。如果你正打算用 Python 做医学图像识别方向的学习或毕设或者想把 YOLOv5 模型从“框目标”迁移到“辅助诊断”场景这套项目提供了一个非常完整的落地方案。2. 技术选型与整体架构两个模型一条流水线怎么分工2.1 流水线设计检测-裁剪-回归三段式骨龄评估在临床上通常看左手腕关节 X 光片的骨骺发育程度。实际落地时如果直接把一张分辨率为 3000×2000 的大图扔给回归模型模型会被背景、胶片标记、软组织干扰误差会明显变大。所以这套系统采用了业界比较通用的三段式设计第一步YOLOv5 模型在原始 X 光片上检测出手骨区域返回一个包含 x、y、width、height 的边界框第二步根据边界框裁剪出 ROIRegion of Interest再做等比缩放和标准化第三步PyTorch 训练的骨龄回归模型对 ROI 输出一个浮点数这个浮点数就是预测的骨龄。三段式的好处是每个环节可以独立替换和调优检测器框不准就单独换检测器或调阈值回归模型误差大就单独换网络结构或加数据增强互不干扰。这套设计还有一个容易被忽略的细节YOLOv5 的输入尺寸和回归模型的输入尺寸是分开配置的。检测器用 640×640兼顾速度和召回回归模型用 224×224ResNet 系的标准输入。前者在全图上做稠密推理后者只对裁剪出来的小图做深度特征提取两者对 GPU 显存的压力差异很大分开配置后低端显卡也能跑得动。2.2 目录结构与模块职责整个项目按功能拆分目录结构大致如下bone_age_system/ ├── main.py # Pyside6 应用入口 ├── app/ │ ├── main_window.py # 主窗口、菜单、拖拽文件事件 │ ├── worker.py # QThread 推理线程信号槽上报结果 │ └── result_panel.py # 结果显示面板原图、检测框、骨龄值 ├── detectors/ │ ├── yolo_detector.py # YOLOv5 模型封装输出归一化边界框 │ └── config.py # 检测器参数conf_thres、iou_thres、imgsz ├── regressors/ │ ├── bone_regressor.py # 骨龄回归模型封装 │ └── transforms.py # 裁剪、缩放、归一化、Tensor 转换 ├── models/ │ ├── yolo_bone.pt # 检测手骨区域的 YOLOv5 权重 │ └── bone_regressor.pth # 骨龄回归权重 ├── utils/ │ ├── image_loader.py # 读取 jpg/png处理 EXIF 旋转 │ └── visualizer.py # OpenCV 画框、叠加骨龄文本 └── tests/ ├── test_detector.py └── test_regressor.py从目录可以读出作者的工程习惯detectors 和 regressors 是两个相互独立的模块都实现了“load 权重 / preprocess / predict / postprocess”四个接口。无论你后续把 YOLOv5 换成 YOLOv8还是把回归模型从 ResNet 换成 EfficientNet改动的范围都被限制在对应模块内app 层几乎不用动。这是我在实际项目里比较欣赏的一点也是后面二次开发章节能顺利展开的前提。2.3 推理配置与参数约定模型推理涉及一组关键参数项目里集中放在配置文件中而不是散落在代码各处。这里把最核心的参数整理成表参数默认值作用说明imgsz640YOLOv5 输入尺寸越大越准但显存占用越高conf_thres0.25检测置信度阈值调高可减少误检但可能漏检iou_thres0.45NMS 的 IoU 阈值控制重叠框的合并力度reg_input_size224骨龄回归模型输入尺寸devicecuda:0或cpu自动检测CPU 也能跑但单张图耗时约 2~5 秒use_fp16True半精度推理仅对 CUDA 生效参数之间是联动的比较典型的例子如果原始 X 光片分辨率很高且手骨区域占比很小YOLOv5 的 imgsz 设置太小会导致小目标漏检。常见做法是保持 imgsz640 不变把原始图按长边缩放后再送入检测器而不是把整张大图直接 resize 成 640否则手骨会被压缩到几乎看不清。这个坑后面避坑章节还会详细展开。3. 核心实现拆解YOLOv5 定位与回归模型的推理链路3.1 YOLOv5 检测器封装先看检测器封装。项目里没有直接调 YOLOv5 的命令行工具而是把它封装成一个类方便 Pyside6 界面在子线程里调用# detectors/yolo_detector.py import cv2 import torch from pathlib import Path class YoloBoneDetector: def __init__(self, weightsmodels/yolo_bone.pt, conf_thres0.25, iou_thres0.45, imgsz640, deviceNone): # 自动选择 CUDA 或 CPU self.device torch.device( cuda:0 if torch.cuda.is_available() else cpu) self.conf_thres conf_thres self.iou_thres iou_thres self.imgsz imgsz # 加载 YOLOv5 权重attempt_load 兼容官方训练产出的权重格式 self.model torch.hub.load( yolov5, custom, pathweights, sourcelocal ).to(self.device).eval() if self.device.type cuda: self.model.half() # 半精度推理减少显存占用 torch.no_grad() def detect(self, image_bgr): 输入 BGR 图像返回归一化边界框 [x_center, y_center, w, h] # 按长边等比缩放避免直接 resize 导致手骨形变 h, w image_bgr.shape[:2] scale self.imgsz / max(h, w) new_w, new_h int(w * scale), int(h * scale) resized cv2.resize(image_bgr, (new_w, new_h)) # YOLOv5 要求输入 RGB且归一化到 [0,1] rgb cv2.cvtColor(resized, cv2.COLOR_BGR2RGB) results self.model(rgb, sizeself.imgsz) # 取第一个目标的框骨龄场景一般只有一只手 if len(results.xyxy[0]) 0: return None x1, y1, x2, y2, conf results.xyxy[0][0].tolist() # 将框坐标映射回原图坐标系 x1, x2 x1 / scale, x2 / scale y1, y2 y1 / scale, y2 / scale return {box: [x1, y1, x2, y2], conf: conf}这段代码有四个关键点值得细看。第一torch.hub.load的sourcelocal参数表示使用本地已下载的 YOLOv5 仓库这样避免了每次运行都从网络上拉取依赖。第二输入图像转换为 RGB 是因为 YOLOv5 在 COCO 上训练时用的是 RGB 三通道如果直接用 OpenCV 的 BGR 图像检测框可能会出现轻微偏移。第三检测结果results.xyxy[0][0]返回的是 [x1, y1, x2, y2, confidence] 格式这里取第一个目标是因为骨龄检测场景假设图像中只有一只左手。第四归一化坐标映射回原图时用scale反向换算这一步漏掉的话画框位置会错位到左上角。3.2 骨龄回归模型结构接着是骨龄回归模型。它本质上是一个把分类网络改成单输出回归的任务# regressors/bone_regressor.py import torch import torch.nn as nn class BoneAgeRegressor(nn.Module): def __init__(self, backboneresnet34, pretrainedTrue): super().__init__() # 使用 torchvision 自带的 ResNet34 作为特征提取器 from torchvision import models if backbone resnet34: self.encoder models.resnet34(pretrainedpretrained) # 把最后一层全连接替换成单值回归头 in_features self.encoder.fc.in_features self.encoder.fc nn.Sequential( nn.Dropout(0.3), nn.Linear(in_features, 256), nn.ReLU(inplaceTrue), nn.Linear(256, 1) # 输出骨龄浮点值 ) def forward(self, x): # x: [B, 3, 224, 224] 归一化后的 ROI 图 return self.encoder(x).squeeze(1) # 输出 [B]为什么不直接用 ResNet 原始的 1000 分类头因为骨龄是一个连续值分类头会丢失年龄之间的顺序关系——比如 12 岁和 13 岁的差距不应该和 3 岁与 10 岁的差距同等对待。改成单输出回归后常用损失函数是MAE平均绝对误差评估指标也是 MAE医生更容易理解“平均误差在 ±1.2 岁以内”这样的表述。训练时一般把年龄做标准化比如减均值除标准差推理时再反变换回实际年龄。3.3 推理管线串联完整链路最后把两个模型串成完整推理函数# utils/pipeline.py import cv2 import torch import numpy as np def predict_bone_age(image_bgr, detector, regressor, reg_mean12.5, reg_std3.2): 完整推理链路检测 - 裁剪 - 预处理 - 回归 # 1. YOLOv5 检测手骨区域 det detector.detect(image_bgr) if det is None: raise ValueError(未检测到手骨区域请调整 conf_thres 或检查图像质量) x1, y1, x2, y2 [int(v) for v in det[box]] # 2. 边界约束防止裁剪越界 h, w image_bgr.shape[:2] x1, y1 max(0, x1), max(0, y1) x2, y2 min(w, x2), min(h, y2) roi image_bgr[y1:y2, x1:x2] # 3. 等比缩放到 224x224并做归一化 roi_resized cv2.resize(roi, (224, 224)) roi_rgb cv2.cvtColor(roi_resized, cv2.COLOR_BGR2RGB) tensor torch.from_numpy(roi_rgb).permute(2, 0, 1).float() tensor tensor / 255.0 # 使用 ImageNet 均值方差做标准化迁移学习时的标准做法 mean torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1) std torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1) tensor (tensor.unsqueeze(0) - mean) / std # 4. 回归预测并反标准化 with torch.no_grad(): pred_norm regressor(tensor).item() bone_age pred_norm * reg_std reg_mean return {bone_age: round(bone_age, 1), box: [x1, y1, x2, y2], confidence: det[conf]}这里需要解释三个细节。裁剪边界约束是必要的YOLOv5 返回的框如果在图像边缘int 转换后可能出现 x2 ≤ x1 或越界的情况OpenCV 裁剪会直接报错这在处理一批不同来源的 X 光片时经常发生。归一化采用 ImageNet 的均值方差因为回归模型是基于 ImageNet 预训练权重微调的使用相同的统计量才能让预训练特征生效。反标准化时reg_mean和reg_std是训练时统计的训练集骨龄均值和标准差这两个值保存在模型配置里不能随意修改否则预测结果会系统性偏移。4. 桌面端整合Pyside6 界面与线程模型的落地写法4.1 主窗口与文件拖拽交互Pyside6 部分的设计目标是“让医生或测试人员不写一行命令就能用模型”。主窗口用 QMainWindow 承载左侧是图像预览区右侧是结果面板顶部拖入图片即可开始检测# app/main_window.py from PySide6.QtWidgets import QMainWindow, QLabel, QVBoxLayout, QWidget from PySide6.QtCore import Qt from PySide6.QtGui import QPixmap, QImage import cv2 import numpy as np class MainWindow(QMainWindow): def __init__(self, detector, regressor): super().__init__() self.setWindowTitle(骨龄检测识别系统) self.detector detector self.regressor regressor # 状态标签显示当前推断状态 self.status_label QLabel(拖入 X 光图片开始检测) self.setCentralWidget(self.status_label) self.setAcceptDrops(True) # 允许从文件管理器拖入图片 def dragEnterEvent(self, event): if event.mimeData().hasUrls(): event.acceptProposedAction() # 只接受文件拖入 def dropEvent(self, event): # 取第一个拖入的文件路径交给推理线程处理 path event.mimeData().urls()[0].toLocalFile() self.start_inference(path) def start_inference(self, path): # 这里应当交给 Worker 线程执行下一节展开 pass拖拽交互的实现在临床工具里比按钮上传更实用因为读片医生往往有一堆图像要批量看。值得注意的是setAcceptDrops(True)必须在主窗口上开启同时dragEnterEvent要显式acceptProposedAction()否则 drop 事件不会被触发——这是 Pyside6 里比较容易漏的一步。4.2 QThread 异步推理不卡界面GUI 开发的铁律是不要在主线程里跑模型推理。一张 3000×2000 的 X 光片在 CPU 上走完 YOLOv5 回归大概要 2~5 秒如果直接写在按钮回调里界面会变成“未响应”状态在 Windows 上甚至会弹窗提示强制关闭。项目用 QThread 信号槽解决# app/worker.py from PySide6.QtCore import QThread, Signal class InferenceWorker(QThread): # 定义两个信号成功时发出结果失败时发出错误信息 finished Signal(dict) # 推理成功 failed Signal(str) # 推理失败 def __init__(self, path, detector, regressor): super().__init__() self.path path self.detector detector self.regressor regressor def run(self): try: import cv2 image cv2.imread(self.path) if image is None: self.failed.emit(无法读取图像请确认文件格式) return from utils.pipeline import predict_bone_age result predict_bone_age( image, self.detector, self.regressor) result[image_path] self.path self.finished.emit(result) except Exception as e: self.failed.emit(str(e))这里的执行流程是主窗口收到拖拽文件后创建InferenceWorker并连接信号到界面更新函数然后worker.start()启动子线程。每个任务创建新的 QThread 实例用完即弃避免线程复用带来的状态残留问题。我一般会在failed信号里用 QMessageBox 弹窗提示finished信号里刷新图像和文本这样即使用户连续拖入多张图界面也不会出现卡顿。4.3 结果可视化与标注叠加结果显示部分用 OpenCV 画框和文字再转成 QImage 显示# utils/visualizer.py import cv2 import numpy as np def draw_result(image_bgr, box, bone_age, confidence): vis image_bgr.copy() x1, y1, x2, y2 box # 画检测框绿色线宽 3 cv2.rectangle(vis, (x1, y1), (x2, y2), (0, 255, 0), 3) # 在框上方画背景条避免文字与图像内容重叠看不清 label fBone Age: {bone_age} y conf: {confidence:.2f} (tw, th), base cv2.getTextSize( label, cv2.FONT_HERSHEY_SIMPLEX, 0.8, 2) top max(y1 - th - 10, 0) cv2.rectangle(vis, (x1, top), (x1 tw 10, top th 10), (0, 255, 0), -1) cv2.putText(vis, label, (x1 5, top th 5), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 0), 2) # BGR 转 RGB再转 QImage rgb cv2.cvtColor(vis, cv2.COLOR_BGR2RGB) h, w, ch rgb.shape bytes_per_line ch * w qimg QImage(rgb.data, w, h, bytes_per_line, QImage.Format_RGB888) return QPixmap.fromImage(qimg.copy())qimg.copy()这行必须写。原因是QImage构造时引用的是rgb.data的底层缓冲区而函数返回后rgb会被回收不 copy 的话界面显示的图像可能会花屏或直接崩溃。这个问题比较隐蔽属于典型的“换个环境就翻车”类型。画背景条用的是负填充矩形颜色设为绿色反色再写黑色文字这样在亮度较高的 X 光片上文字依然可读。5. 实战避坑五个我在跑通这套系统时踩过的坑5.1 CPU 推理慢到没法用现象在只有 CPU 的笔记本上跑单张图YOLOv5 用了将近 8 秒回归模型又用了 3 秒整个流程跑完毫无交互体验。原因YOLOv5 默认启动时加载的是 FP32 权重CPU 上 FP32 卷积计算量大。更隐蔽的问题是代码里没有显式把输入 tensor 转成连续内存布局一些底层操作会触发额外的拷贝。解决CPU 推理时把模型转换为 FP16 不一定更快部分 CPU 不支持 FP16 加速效果更明显的做法有两个一是把imgsz从 640 降到 480检测耗时会显著下降代价是极小手骨可能漏检二是给 OpenCV 开启并行计算cv2.setUseOptimized(True)并在 pipeline 里复用已加载的模型实例不做重复初始化。若仍然慢最终方案是改用 openvino 做推理后端在同型号 CPU 上通常能拿到 2~3 倍加速。5.2 检测框在整张图上漂移错位现象YOLOv5 返回的边界框能正确框住原图里的手骨但画到原图上时框整体偏到左上角且大小明显缩小。原因这是坐标映射出错的典型症状。检测器内部把原图缩放到 640×640 后推理输出的坐标是缩放后图像坐标系里的值代码里没有按缩放比例映射回原图坐标。项目中 3.1 节detect方法里x1 / scale这一步被漏掉了。解决在检测器封装里统一用一个 scale 变量记录缩放系数框输出前做一次反向映射。经验做法是让detect方法始终返回原图坐标系下的绝对像素坐标后续所有模块都基于这个约定避免“有的模块传归一化坐标、有的传缩放坐标”这种混乱。5.3 同一张图连续检测两次结果不一样现象同一张 X 光片跑两遍推理得到的骨龄值有细微波动有时甚至相差 0.5 岁以上。原因这多半不是模型问题而是随机失活 Dropout 层在推理时没有关闭。如果回归模型微调时沿用了 nn.Dropout 且权重保存方式不是 eval 模式训练时的随机失活会被带到推理阶段。另一个可能原因是 OpenCV 的resize默认插值算法在高版本中可能有平台差异。解决推理前强制显式调用model.eval()同时用torch.no_grad()包裹推理逻辑这两步缺一不可。我在代码里也把eval()和no_grad()放进模型封装而不是依赖调用方自觉。为了让结果可复现可以再加一句torch.backends.cudnn.deterministic True代价是推理速度略降。5.4 不同设备采集的 X 光片亮度差异导致结果波动现象在不同医院设备采集的同类 X 光片上测试模型在部分图像上骨龄预测误差达到 2 岁以上而在训练集来源上误差只有 1 岁左右。原因X 光片没有统一的亮度/对比度标准不同设备输出的灰度分布差异很大。模型训练时见过的图像分布比较单一遇到灰度分布偏移大的图像输入分布已经不在模型可泛化范围内了。解决在预处理阶段加入对比度标准化。常见做法是先用 CLAHE对比度受限自适应直方图均衡化增强局部对比度再把灰度图像转换为伪 RGB 三通道送入模型。注意 CLAHE 的clipLimit值不宜过大一般 2.0~3.0 之间比较合适过大容易出现噪声放大、骨骼纹理被“洗白”的情况。5.5 界面拖入图片后直接闪退现象Pyside6 窗口拖入图片时程序崩溃退出终端报出TypeError或RuntimeError。原因常见根因有两个。一是dropEvent里没有判断多文件情况urls()[0]在一个空列表上取值会抛IndexError二是推理线程在窗口关闭时仍在运行程序退出瞬间线程访问已释放的模型对象导致段错误。解决dropEvent里先判空再取值并在MainWindow.closeEvent中等待 worker 线程结束或直接终止self.worker.wait(3000)。另外记住一个习惯QThread 对象要保存为成员变量如果写成局部变量Python 的垃圾回收会在函数返回后销毁线程运行到一半崩溃是必然的。6. 二次开发把检测器换掉、接上 DICOM、输出结构化报告6.1 抽象检测器接口随时替换新版本这套项目里最容易做的改造就是把 YOLOv5 换成新版检测器。建议先给检测器定义一个基类统一返回格式# detectors/base.py class BaseDetector: def detect(self, image_bgr) - dict: 必须返回 {box: [x1, y1, x2, y2], conf: float} raise NotImplementedError然后让 YOLOv5 的实现直接继承BaseDetector新接入 YOLOv8 时只需要写一个适配类把新模型的输出列表转换为同样的 dict 格式。这样 app 层和回归模型的代码完全不需要改界面上甚至不用动一行。我一般还会在接口里加一个preprocess钩子方便未来接 DICOM 时做窗宽窗位调整。6.2 接 DICOM 文件与结构化报告输出医院的 X 光片通常是 DICOM 格式不是 jpg。二次开发时可以引入 pydicom 库做解析再转成 numpy 数组送入现有 pipelineimport pydicom def load_dicom_as_bgr(path): 读取 DICOM转换为 OpenCV BGR 格式 ds pydicom.dcmread(path) # 处理单声道图像扩展为三通道伪彩图 arr ds.pixel_array arr (arr - arr.min()) / (arr.ptp() 1e-6) * 255.0 arr arr.astype(uint8) return cv2.cvtColor(arr, cv2.COLOR_GRAY2BGR)这个转换函数可以直接替换cv2.imread因为后续流程只依赖 BGR 图像。需要留意的是 DICOM 的pixel_array可能是有符号整数、也可能是浮点类型必须先看ds.pixel_array.dtype再决定是否做数据类型转换否则与cv2.cvtColor接口不匹配会直接报错。输出报告方面我通常会在result_panel里增加一个“导出报告”按钮把骨龄值、置信度、检测框坐标写进 JSON 文件再用 reportlab 生成 PDF。这样整个系统就从“看图的 demo”升级成了“能归档的辅助工具”。6.3 回归模型微调与验证习惯如果你手头有新的骨龄数据集想在这套系统上微调回归模型不要只换权重就完事。训练和推理的数据预处理必须保持一致包括缩放方式、均值方差、是否使用 CLAHE。我通常会在regressors/transforms.py里保留一份denormalize反向逻辑每次训练完都跑一遍同一个验证集对比微调前后的 MAE 和年龄段误差分布而不是只记一个总误差——骨龄预测在 3~6 岁和 12~16 岁的误差表现往往差异很大总误差掩盖了这一点。做完以上任何一个改造都建议回头看一下tests/目录里的两个测试脚本。我会把测试脚本设计成“固定输入图像、固定预期骨龄区间”的回归测试每次改造后先跑测试再交给使用者避免界面用起来正常但结果悄悄变差的情况。曾经有一次我换了检测器版本后手骨框精度提升了但因为坐标映射没适配骨龄误差反而从 1.1 岁涨到 1.6 岁就是靠这套回归测试抓出来的。从那以后我每次改模型都会强制走一遍同一份测试集、对比同一份误差表格确认没有精度的“暗降”才放心交付。希望这些思路能帮你的二次开发少走几趟弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站