简介:这套基于深度学习的骨龄检测识别系统,面向医学影像分析与Python开发者,融合PyTorch、Pyside6与YOLOv5模型,用于从儿童手腕X光图像中识别骨骼特征并推断骨龄,可服务于儿科临床辅助诊断与算法研究。压缩包共205个文件,包含71个Python源码、43个编译后的pyc文件、32个YAML配置、11个模型权重文件以及6个Markdown文档和3个Jupyter Notebook教程,整体大小406MB,覆盖训练、推理、界面交互与部署全流程。资源内附Dockerfile、版本控制与开源许可等工程化配置,便于复现和二次开发。目前已有332人学习,适合希望掌握YOLOv5医学影像落地方法、并构建桌面应用的中高级Python工程师。
1. 骨龄检测识别系统:一个把YOLOv5检测器接进PySide6桌面的完整工程
骨龄检测这个任务,传统做法是放射科医生对着手腕X光片查图谱、数骨骺发育等级,一张片子少说花几分钟。而这个项目把整条链路压到了秒级:PySide6桌面端负责选图和结果展示,YOLOv5模型先定位手骨ROI区域,PyTorch骨龄网络再对裁剪出的ROI做分级,界面最终输出骨龄结果与检测框可视化。我拆这套代码时印象最深的不是准确率数字,而是它把训练、推理、界面三件事串成了一个能直接双击运行的桌面工具,而不是一堆散落的Jupyter实验。适合两类读者:一是想把深度学习模型接到桌面端做落地的Python开发者,二是想在医学图像识别场景里复用YOLO检测流程的同学。
2. 技术选型与整体架构:PyTorch训练端、YOLOv5检测端、PySide6界面端如何各司其职
2.1 为什么用YOLOv5做检测,而不是直接拿分类网络一把梭
骨龄评估表面上看是图像分类或者回归问题——输入X光片,输出年龄。但直接拿ResNet对整张片子做分类,会碰到两个让人头疼的问题。
第一,手腕X光片的非手骨区域占比很高。拍片时经常把部分前臂甚至铅衣边界拍进去,这些区域在灰度分布上与骨骺非常接近,分类网络很容易被背景带偏。第二,可解释性几乎为零。医生判骨龄的依据是特定骨骺的发育形态,如果模型直接说"这张片子的骨龄是12岁",临床上是没人敢信的,得知道它看了哪块骨头、哪个区域。
YOLOv5在这个项目里承担的是"先定位、再判别"的前置角色。模型先把手骨区域或者关键骨骺区域框出来,代码再按检测框裁剪出ROI,后续的骨龄网络只看ROI做分级。这样一来,背景噪声被物理剪掉,网络的注意力被迫集中到医学上真正相关的区域;同时检测框本身也是一种可视化解释,医生能看到系统到底"看"了哪里。
选YOLOv5而不是更早的YOLOv3,主要图两点:一是torch.hub加载权重非常省事,不用自己搭Darknet的解析逻辑;二是它的导出链路成熟,后续要转ONNX或者TensorRT都比较顺。YOLOv5虽然被YOLOv8抢了不少风头,但如果你的任务不是追求极致速度,而是需要文档多、踩坑记录全的稳定检测器,它依然是性价比很高的选择。
2.2 目录结构:一个能跑通的工程长什么样
拿到这份源码后,我先把目录理了一遍。整个项目的组织非常清晰,训练、推理、界面三块代码物理隔离,没有混在一个大包里。核心结构大致是这样:
bone_age_system/ ├── main.py # 应用入口,创建QApplication并启动主窗口 ├── models/ │ ├── yolov5/ # 封装好的YOLOv5检测器 │ │ ├── detector.py # HandDetector类,加载权重并输出检测框 │ │ └── weights/ # 训练好的手骨检测权重 │ └── age_network.py # 骨龄评估网络,基于ResNet18改造 ├── ui/ │ ├── main_window.py # 主窗口,负责文件选择和结果展示 │ └── result_panel.py # 结果面板,绘制检测框和骨龄信息 ├── utils/ │ ├── image_ops.py # 图像读取、ROI裁剪、CLAHE增强 │ └── bone_age_calc.py # 把网络输出的等级分布换算成骨龄 ├── weights/ # 骨龄网络的权重文件 └── data/ # 示例X光片,用于快速验证我一般拿到深度学习项目会先看models和utils,因为这两个目录决定了推理逻辑怎么走。这里models/yolov5/detector.py负责检测,age_network.py负责分级,utils/image_ops.py负责把两者衔接起来,分工相当干脆。
一个细节值得表扬:weights目录和data目录平级,意味着你不需要在代码里翻路径,模型权重放哪、测试图片放哪一目了然。很多开源项目死在路径散落各处,这个项目没有犯这个毛病。
2.3 两套训练链路:检测器与骨龄网络各训各的
训练侧是两条平行的链路,互不干扰。检测器用标注了手骨框的数据训练,骨龄网络用裁剪好的ROI数据训练,只在推理阶段通过坐标接起来。这种设计的好处是:检测效果不好,你只换检测器;分级不准,你只调骨龄网络,耦合成本极低。
检测器部分,最可能的做法是直接用YOLOv5官方仓库的训练脚本。标注格式是YOLO的txt格式,每行一个目标:类别、中心点x、中心点y、宽度、高度,全部归一化到0到1之间。数据集目录按官方约定组织:
datasets/ ├── images/ │ ├── train/ │ └── val/ └── labels/ ├── train/ └── val/训练命令走train.py:
python train.py \ --data bone_age.yaml \ --weights yolov5s.pt \ --img 640 \ --epochs 100 \ --batch-size 16bone_age.yaml里指定train和val路径以及类别名列表,这个场景只有一个类别,所以nc=1。用yolov5s这种小模型起步,训练速度快、显存占用小,如果你的ROI目标不算太小,s版本精度够用的概率很大。
骨龄网络的训练则是标准PyTorch训练循环,优化器用Adam,学习率设1e-4左右,损失函数用交叉熵。如果类别不均衡,可以给每个等级加权重。训练数据不是原始X光片,而是检测器裁剪出来的ROI,而且建议做离线裁剪而不是训练时现裁——训练时调检测器会让数据预处理环节变得不可控,离线裁剪排错也方便。
3. 核心模块拆解:一张X光片从载入到输出骨龄的完整链路
3.1 YOLOv5检测器封装:torch.hub加载权重与ROI提取
这个项目对YOLOv5的封装非常薄,没有在ultralytics源码上做大量改动,只是把加载和推理收拢到一个类里。这种做法我很认同——第三方库的源码尽量别碰,升级的时候才不会翻车。
import cv2 import torch import numpy as np class HandDetector: """封装YOLOv5的手骨检测器,输入BGR图像,输出检测框坐标""" def __init__(self, weights_path: str, conf_thres: float = 0.5): # 通过torch.hub加载自定义权重,custom模式会跳过默认的COCO预训练 self.model = torch.hub.load( 'ultralytics/yolov5', 'custom', path=weights_path, force_reload=False ) self.model.conf = conf_thres # 置信度阈值,低于该值的框会被丢弃 self.model.iou = 0.45 # NMS的IoU阈值,控制重叠框的合并力度 def detect(self, image: np.ndarray) -> np.ndarray: """传入BGR图像,返回形状为(N, 6)的数组:xyxy坐标+置信度+类别""" results = self.model(image) boxes = results.xyxy[0].cpu().numpy() return boxes def extract_roi(self, image: np.ndarray, box: np.ndarray) -> np.ndarray: """按检测框裁剪出手骨区域,加padding保留少量上下文""" x1, y1, x2, y2 = [int(v) for v in box[:4]] # 给坐标加一圈padding,防止骨骼边缘被切到 pad_x = int((x2 - x1) * 0.05) pad_y = int((y2 - y1) * 0.05) x1 = max(0, x1 - pad_x) y1 = max(0, y1 - pad_y) x2 = min(image.shape[1], x2 + pad_x) y2 = min(image.shape[0], y2 + pad_y) return image[y1:y2, x1:x2]torch.hub.load传custom参数时,YOLOv5会读取本地训练好的权重文件,不会覆盖成COCO预训练权重,这是最容易踩错的地方。conf_thres参数实际使用时要根据图像质量微调——X光片对比度通常比自然照片低,默认0.5大概率漏检,一般调到0.3到0.4之间。
extract_roi里加5% padding是有讲究的。如果直接把检测框裁出来训练骨龄网络,框边缘会恰好切在骨骼边界上,丢失上下文信息;稍微留一圈空白,网络能多看到一点周围组织结构,对分级有帮助。
3.2 骨龄评估网络:ResNet18改造与等级分布输出
骨龄网络没有重新设计结构,而是在ResNet18骨干上换分类头。这个选择在场景下是合理的——骨龄数据的样本量通常不大,从头训练深层网络极易过拟合,用ImageNet预训练的ResNet18做迁移,收敛速度快得多。
import torch import torch.nn as nn from torchvision.models import resnet18 class BoneAgeNet(nn.Module): """骨龄分级网络:把ROI图像映射到骨龄等级分布""" def __init__(self, num_classes: int = 18, pretrained: bool = True): super().__init__() self.backbone = resnet18(pretrained=pretrained) # 替换最后全连接层,原先输出1000类,现在输出骨龄等级数 in_features = self.backbone.fc.in_features self.backbone.fc = nn.Sequential( nn.Dropout(p=0.2), # 随机丢弃20%神经元,防止过拟合 nn.Linear(in_features, num_classes) ) self.num_classes = num_classes def forward(self, x: torch.Tensor) -> torch.Tensor: """输入(B, 3, H, W)的归一化图像,输出(B, num_classes)的softmax分布""" logits = self.backbone(x) return torch.softmax(logits, dim=1)输出softmax概率分布而不是直接回归年龄数值,这个设计很巧妙。骨龄判断本身带有主观性,同一张片子不同医生可能给出相邻等级的结论,分布输出比硬分类保留更多信息。后处理时对每个等级乘以对应的年龄段权重再求和,就得到连续的骨龄估计值。
后处理代码在utils/bone_age_calc.py里,逻辑很简单:
def calc_bone_age(prob: torch.Tensor, age_map: torch.Tensor = None) -> float: """将骨龄等级分布加权为具体年龄值 参数说明: - prob: 形状为(num_classes,)的softmax概率向量 - age_map: 每个等级对应的中位年龄,默认按线性区间生成 """ if age_map is None: # 假设18个等级均匀覆盖3岁到18岁 age_map = torch.linspace(3.0, 18.0, steps=prob.shape[0]) return float(torch.dot(prob, age_map))num_classes=18对应骨龄发育的18个等级分段。如果你换数据集,等级数要跟着改,否则加载权重时会出现形状不匹配报错,这是避坑章节要重点说的问题。
3.3 PySide6界面:QThread后台推理与信号槽结果回传
界面部分最值得学习的是它没有偷懒把推理塞在按钮点击回调里。很多初学者写PySide6程序,点击按钮后直接调模型推理,界面立刻卡死转圈。这个项目用标准QThread子类处理了这个问题。
from PySide6.QtCore import QThread, Signal import numpy as np class InferenceThread(QThread): """后台推理线程,避免阻塞Qt主线程导致界面假死""" result_ready = Signal(dict) # 推理完成后向主线程发送结果字典 def __init__(self, image_path, detector, age_net): super().__init__() self.image_path = image_path self.detector = detector self.age_net = age_net def run(self): # 在子线程执行检测和分级,主线程可以继续响应用户操作 image = cv2.imread(self.image_path) rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) boxes = self.detector.detect(rgb) result = {"boxes": [], "bone_age": 0.0} if len(boxes) > 0: # 取置信度最高的检测框 best_box = boxes[0] roi = self.detector.extract_roi(rgb, best_box) roi_resized = cv2.resize(roi, (224, 224)) roi_tensor = torch.from_numpy(roi_resized).permute(2, 0, 1).float() / 255.0 with torch.no_grad(): prob = self.age_net(roi_tensor.unsqueeze(0)).squeeze(0) bone_age = calc_bone_age(prob) result["boxes"] = best_box[:4].tolist() result["bone_age"] = bone_age self.result_ready.emit(result)Signal(dict)是PySide6的信号,跨线程传递Python字典非常方便。注意run方法在子线程执行,而信号连接的槽函数在主线程执行,UI更新是线程安全的。主窗口那边只需在点击事件里创建并启动线程,然后连接信号去更新界面。
class MainWindow(QMainWindow): def open_image(self): path, _ = QFileDialog.getOpenFileName( self, "选择图片", "", "Images (*.png *.jpg)" ) if path: self.thread = InferenceThread(path, detector, age_net) self.thread.result_ready.connect(self.show_result) self.thread.start() def show_result(self, result: dict): # 在主线程更新UI,安全地绘制检测框 ...有一点必须强调:不要在子线程里直接操作任何Qt控件,Qt的UI不是线程安全的,所有界面更新都要通过信号回到主线程做。
4. 运行与避坑:环境搭建、推理验证与5个典型问题排查
4.1 环境准备:照着我这套装不会翻车
建议用conda建一个干净的虚拟环境,Python版本选3.8或3.9最稳,PyTorch和PySide6在这个区间的兼容性最好。PyTorch版本主要看显卡驱动和CUDA版本,CPU机器也能跑只是慢。
conda create -n bone_age python=3.9 -y conda activate bone_age # 深度学习骨架,CPU版直接去掉cuda索引前缀 pip install torch torchvision # 桌面端和图像处理库 pip install pyside6 opencv-python numpy装完先验证环境再继续,这一步能省掉后面排查的半天时间:
python -c "import torch; print(torch.__version__, torch.cuda.is_available())"如果返回True再装PySide6,不然后面报错你根本分不清是代码问题还是环境问题。我当时装的是1.13左右的PyTorch版本,配CUDA 11.7,X光片推理一张大约几百毫秒。
4.2 最小推理示例:不启动界面,先验证模型
拿到项目第一步永远是不碰界面,写一个十几行的脚本把模型跑通。这样能把"模型问题"和"界面问题"隔离,定位Bug快很多。
import cv2 import torch from models.yolov5.detector import HandDetector from models.age_network import BoneAgeNet # 1. 加载检测器和骨龄网络 detector = HandDetector("weights/yolo_hand.pt", conf_thres=0.35) age_net = BoneAgeNet(num_classes=18) age_net.load_state_dict( torch.load("weights/age_net.pth", map_location="cpu") ) age_net.eval() # 2. 读取X光片并检测手骨区域 image = cv2.imread("data/sample_001.png") boxes = detector.detect(image) print(f"检测到 {len(boxes)} 个候选框") # 3. 取最高置信度框,裁剪ROI并做骨龄评估 if len(boxes) > 0: box = boxes[0] roi = detector.extract_roi(image, box) roi_resized = cv2.resize(roi, (224, 224)) roi_tensor = torch.from_numpy(roi_resized).permute(2, 0, 1).float() / 255.0 with torch.no_grad(): prob = age_net(roi_tensor.unsqueeze(0)).squeeze(0) print("骨龄等级分布:", prob.numpy())这里有个容易被忽略的点:cv2.imread读出来是BGR通道顺序,而YOLOv5训练时用的是RGB。如果你直接用BGR图喂进去,检测框位置可能整体偏移。遇到这种情况先检查通道顺序,不要急着质疑模型权重有问题。
如果你有GPU,把骨龄网络切到CUDA上推理。加载权重时用map_location="cpu"只是一个保险,实际推理可以再.to("cuda"),反正权重是按state_dict存的,先在CPU加载再搬设备完全合法。
跑通这个脚本后再启动main.py做端到端验证,你已经排除了最可能出问题的两个环节——权重加载和图像预处理。
4.3 避坑记录:跑这个项目时我踩过的5个坑
第一条:torch.hub.load每次都重新下载权重,网络慢到怀疑人生。现象是每次运行脚本都卡在下载YOLOv5的仓库文件上。原因是force_reload没有设为False,机器访问外网仓库不稳定。解决方法是改成force_reload=False,或者手动把YOLOv5仓库clone到本地后用path参数直接指过去。这个参数调起来有点玄学,不同网络环境的差异表现极大。
第二条:Pyside6点击按钮后窗口直接变成"未响应"。现象是选择图片后界面立刻转圈,几秒后系统提示无响应。原因是推理逻辑直接写在按钮的clicked信号槽里,阻塞了Qt事件循环。解决方法是把推理全部迁移到QThread子线程,只在主线程里通过信号接收结果。排查时可以按一下Ctrl+C看终端有没有输出,如果终端也在等,基本就是主线程被堵死了。
第三条:加载骨龄权重时报"size mismatch for backbone.fc"。现象是load_state_dict时提示全连接层尺寸不一致。原因是拿到的权重是在18类数据上训练的,本地把num_classes改成了别的值。解决方法是检查训练时的类别数,保持num_classes一致,或者禁用pretrained重新初始化最后几层。这类报错通常在权重加载处,看到size mismatch先确认类别数,别急着重训模型。
第四条:OpenCV读取中文路径的图片返回None。现象是图片路径只要带中文,cv2.imread就静默失败,查了半天代码没毛病。原因是OpenCV的imread内部用C的fopen,不支持中文路径编码。解决方法是换成np.fromfile配合cv2.imdecode:
def safe_imread(path: str) -> np.ndarray: data = np.fromfile(path, dtype=np.uint8) return cv2.imdecode(data, cv2.IMREAD_COLOR)这条在外面跑项目几乎必踩,用户给的图片路径永远有你预想不到的字符。
第五条:YOLOv5对X光片漏检率偏高。现象是检测器在自然图像上表现正常,换到X光片上经常一个框都检测不出来。原因是YOLOv5训练数据的分布以自然图像为主,X光片是灰度图,纹理和对比度差异很大。解决思路不是盲目调模型结构,而是先做图像增强——对灰度图做CLAHE(限制对比度自适应直方图均衡化),把骨骼边缘的对比度拉起来再送进检测器,同时把置信度阈值调低到0.25到0.3,漏检率能明显下降。
import cv2 def preprocess_for_detection(image: np.ndarray) -> np.ndarray: """检测前增强对比度,提升灰度X光片的检测召回率""" gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) enhanced = clahe.apply(gray) return cv2.cvtColor(enhanced, cv2.COLOR_GRAY2BGR)5. 二次开发思路:把骨龄检测改造成你自己的影像识别工具
5.1 替换YOLOv5为YOLOv8的迁移路径
这个项目的检测器封装得比较薄,换检测器比想象中容易。核心只需要改HandDetector内部的加载和推理两处代码。YOLOv8的权重加载方式不同,但它同样有conf和iou属性,对外暴露的detect方法签名不需要变,界面和骨龄网络完全不用动。
from ultralytics import YOLO class HandDetectorV8: """YOLOv8版本的检测器,接口与旧版保持一致""" def __init__(self, weights_path: str, conf_thres: float = 0.35): self.model = YOLO(weights_path) self.conf_thres = conf_thres def detect(self, image): results = self.model.predict(image, conf=self.conf_thres) if len(results) == 0 or results[0].boxes is None: return np.empty((0, 6)) return results[0].boxes.xyxy.cpu().numpy()换完之后用第4章的最小推理脚本做回归验证,对比同一张X光片上新旧检测器的检测框坐标。如果坐标差异在可接受范围内,界面端无需任何改动。
5.2 数据增强:解决小样本过拟合
骨龄数据集的标注成本极高,需要经验丰富的医生逐张标注,小样本是这个领域躲不开的难题。我一般从几个方向同时下手:一是几何增强,随机旋转、翻转、轻微缩放,这类增强不改变骨骼结构的医学语义;二是灰度增强,CLAHE、随机亮度偏移、高斯噪声,模拟不同设备的成像差异;三是在骨龄网络的全连接层前加Dropout,这个项目已经做了,但如果你自己改网络,要注意别把这个层删掉。
5.3 打包注意点
把PySide6应用打包给一线人员用,PyInstaller是主流做法,但有几个坑必须处理:一是torch.hub加载YOLOv5时依赖的仓库文件要跟着一起打包,否则没网环境下直接失败;二是模型权重文件加进--add-data而不是放在项目根目录,打包后的路径解析要用sys._MEIPASS做兼容;三是PySide6的插件目录比较大,打包前用--exclude-module剔掉用不到的Qt模块,能省不少体积。
我从这个项目里学到的习惯是:改任何检测类任务,都先写一个不依赖界面的最小验证脚本,把模型跑通了再碰UI。那次我改YOLOv5的输入预处理时,直接改了主窗口的代码,结果界面和数据流搅在一起,一个通道顺序的问题排查了整个下午。从那以后我每次动底层逻辑都强制先跑一遍无界面脚本,确认输出正常再回界面,效率提升不是一点半点。希望帮到你。
本文还有配套的精品资源,点击获取