简介:在医学影像分析场景中,深度学习技术正逐步取代传统人工读片的低效流程。目标检测和图像回归作为计算机视觉的核心任务,能准确定位关键解剖区域并输出量化评估结果。本文以骨龄检测识别为例,利用PyTorch训练的YOLOv5模型对左手腕X光片中的骨骺区域进行自动定位,再通过回归网络将ROI特征映射为骨龄值,同时结合PySide6构建可交互的桌面应用,形成一套从图像输入到报告输出的完整流水线。该系统采用检测+回归的二级架构,兼顾了可解释性与容错能力,适用于医学影像相关专业学生及开发者进行二次开发。通过模块化设计,开发者可灵活替换数据集、调整模型参数,并扩展批量处理与接口对接,从而将算法快速落地为实用工具。该实践为相似图像识别项目提供了清晰的技术路径与工程参考。
1. 骨龄检测识别:YOLOv5与PySide6组成的那套流程画像
体检机构里骨龄评估通常要靠医生手持图谱逐块比对,既费神又容易带主观偏差。这个基于深度学习的骨龄检测识别系统,把 PyTorch 训练的 YOLOv5 模型和 PySide6 界面组合成一条完整流水线:上传一张左手腕 X 光片,程序自动定位骨骺区域,再映射到骨龄数值,最后生成带标注的报告。它对应的是 python 图像识别方向里“检测 + 回归”两件套的典型工程,适合想把算法模型变成桌面工具的开发者,以及医学影像相关专业的同学拿来当二次开发脚手架。本文不涉及复杂临床理论,重点讲它怎么拆解、怎么跑通、怎么改造成自己的项目。
2. 技术选型与架构设计:PyTorch+YOLOv5+Pyside6 三件套的分工与衔接
2.1 为什么用 YOLOv5 而不是直接 CNN 回归
骨龄评估最直接的想法是把整张 X 光片喂进一个回归网络,输出一个骨龄值。但实际踩过坑就知道,X 光片里包含大量非骨骼组织,比如软组织、背景噪声甚至患者信息遮挡,直接回归会让模型把注意力分散到无关区域。常见做法是先做目标检测,把手腕骨的关键 ROI(感兴趣区域)框出来,再针对每个 ROI 做分析。YOLOv5 在中小目标检测上速度快、权重体积适中,而且 PyTorch 生态下的部署资料多,遇到问题容易搜到答案。
这个项目选择 YOLOv5 而不是更重的检测模型,还有一个工程化考虑:骨龄检测需要处理的图像通常是高分辨率灰度图(几千乘几千),YOLOv5 的输入尺寸可以灵活配置,训练和推理速度都能接受。项目实际用的是 YOLOv5s 这一档,在准确率和显存占用之间比较均衡。如果你有更大的显存,可以换成 YOLOv5m,但推理延迟会明显上升,桌面应用场景下不划算。
2.2 整体架构:检测 ROI + 分类 / 回归的二级流水线
整个系统不是让 YOLOv5 直接输出“骨龄多少岁”,那样既不符合临床评估逻辑,也浪费了检测模型的空间定位能力。实际架构分两段:第一段,YOLOv5 在手腕 X 光片上检测出多个骨化区域(比如桡骨远端、尺骨远端、各掌指骨),输出边框坐标和类别;第二段,把裁剪出的 ROI 区域送入一个轻量级回归网络(通常是一个 ResNet 或简单卷积层),输出每个 ROI 对应的成熟度得分,再通过预设的骨龄映射表换算成最终骨龄值。
这样做的好处有两个。一是可解释性:医生能在界面上看到每个检测框对应哪个骨骼区域,而不是只看一个黑匣子数字。二是容错空间:如果某个 ROI 检测置信度很低,系统可以做特定处理,不至于整个骨龄结果崩掉。后端推理部分用 PyTorch 写,前端用 PySide6 做桌面 GUI,两者通过信号槽机制传递图像和结果数据,互不干扰。
2.3 依赖环境与版本组合
项目基于 PyTorch,模型仓库直接用的是 YOLOv5 的官方代码结构。常规环境下推荐 Python 3.8 或 3.9,PyTorch 1.10 以上版本, CUDA 11.x 对应运行时。PySide6 在 Python 3.9 下表现稳定,PySide6 的版本建议选 6.4 以上,对高 DPI 屏幕的支持更好。如果只是用 CPU 推理,也能跑,但一张 X 光片可能要 3~5 秒,体验会受影响。
依赖项里有个容易忽略的点:ultralytics 的 YOLOv5 包会依赖 opencv-contrib-python,但它在某些环境里会与 PySide6 的 QImage 产生冲突,Abort 错误。常见解决方式是先安装 opencv-contrib-python,再安装 PySide6,顺序反了容易出问题。项目的 requirements.txt 里其实已经把固定版本列好了,本地跑的时候按顺序装即可。这套组合对应的是典型的 python 图像识别项目结构,对刚接触深度学习工程化的人比较友好。
3. 核心模块拆解:从 X 光片输入到骨龄数值输出的完整调用链
3.1 检测器封装:detector.py 里的模型加载与推理
骨干代码集中在 detector.py,它负责加载 YOLOv5 权重、预处理图像、执行推理并解析输出。与直接在全局调用torch.hub.load不同,项目封装了一个 Detector 类,把模型的eval模式、半精度开关、设备选择都收敛到一个构造方法里,外部调用方只关心predict()方法返回的结构化结果。这样设计的好处是:GUI 代码不需要理解 YOLOv5 的细节,后面要替换成 YOLOv8 或者其他检测器,只改 Detector 内部实现即可。
import torch import cv2 import numpy as np class BoneDetector: def __init__(self, weights_path, device="cpu", conf_thres=0.45): # 加载YOLOv5模型,使用ultralytics仓库的加载方式 self.model = torch.hub.load("ultralytics/yolov5", "custom", path=weights_path, force_reload=False) self.device = device self.model.to(device) self.model.eval() self.conf_thres = conf_thres def predict(self, img_bgr): # 输入是BGR格式的OpenCV图像,推理后返回检测框列表 results = self.model(img_bgr, size=640) # 自动做letterbox等比缩放 boxes = [] for det in results.xyxy[0].cpu().numpy(): x1, y1, x2, y2, conf, cls = det if conf >= self.conf_thres: boxes.append({ "bbox": [int(x1), int(y1), int(x2), int(y2)], "score": float(conf), "class_id": int(cls) }) return boxes这段代码里,results.xyxy[0]返回的每一行是[x1, y1, x2, y2, 置信度, 类别]。size=640表示输入模型的图像短边缩放至 640,YOLOv5 内部会做 letterbox,保持宽高比不变,多余区域补灰边。注意这里输入的图像是 BGR 格式,因为 OpenCV 读出来就是 BGR,YOLOv5 在预处理时会转换为 RGB。如果你从 PySide6 的 QImage 转 numpy 数组,也要注意通道顺序,否则检测结果会明显变差。
3.2 骨龄评定:ROI 特征到骨龄数值的映射逻辑
检测到 ROI 后,需要把这些区域的图像缩放后送进回归网络。相较于直接让模型输出“骨龄=12.5岁”这种连续值,更稳妥的做法是把骨龄分成阶段(如 0~18 岁按半年一个等级),让回归网络输出一个离散类别概率,再结合每个类别的对应年龄做加权平均。这个设计参考了临床 TW3 法的思想——每个骨骼区域有各自的成熟度,最终取加权值。
class BoneAgeEstimator: def __init__(self, reg_model_path, roi_size=224): self.model = torch.load(reg_model_path, map_location="cpu")["model"] self.model.eval() self.roi_size = roi_size # 每个类别代表的骨龄中心值,按临床经验预设 self.age_centers = [i * 0.5 + 3.0 for i in range(30)] def predict_roi(self, roi_bgr): roi_resized = cv2.resize(roi_bgr, (self.roi_size, self.roi_size)) roi_tensor = torch.from_numpy(roi_resized).permute(2, 0, 1).unsqueeze(0).float() roi_tensor = roi_tensor / 255.0 with torch.no_grad(): logits = self.model(roi_tensor) probs = torch.softmax(logits, dim=1).squeeze() # 概率加权求骨龄 age = (probs * torch.tensor(self.age_centers)).sum().item() return round(age, 1)这里的age_centers要按你自己训练模型的类别定义来调整。如果回归网络用的是连续回归,那么age_centers就不需要了,直接用网络输出值。我一般会保留这个概率加权写法,因为后期如果采集了更多标注数据,可以重新统计各年龄段的分布,直接替换数组,不需要重新训练网络。要注意roi_bgr可能是倾斜的或包含部分软组织,必要时候做一次旋转矫正,但多数标准腕关节正位片不需要额外处理。
3.3 PySide6 主窗口:QGraphicsView 显示 X 光片和检测框
GUI 部分用的是 PySide6。主窗口左侧是图像显示区,右侧是操作按钮和结果面板。显示区使用 QGraphicsView,而不是 QLabel,原因是 QGraphicsView 能轻松实现缩放和拖动,对医学影像浏览场景来说这是刚需。每次检测完成后,把检测框和类别画在原始图像上,再渲染到 scene 里。如果直接用 QLabel 放 png,会丢失交互能力,后面拓展会很痛苦。
from PySide6.QtWidgets import QMainWindow, QGraphicsScene, QGraphicsView from PySide6.QtGui import QPixmap, QImage class MainWindow(QMainWindow): def __init__(self): super().__init__() self.view = QGraphicsView(self) self.scene = QGraphicsScene() self.view.setScene(self.scene) def update_frame(self, img_bgr, boxes): # 在图像上绘制检测框 for box in boxes: x1, y1, x2, y2 = box["bbox"] cv2.rectangle(img_bgr, (x1, y1), (x2, y2), (0, 255, 0), 2) # BGR转RGB后转成QImage rgb_img = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) h, w, ch = rgb_img.shape bytes_per_line = ch * w q_img = QImage(rgb_img.data, w, h, bytes_per_line, QImage.Format_RGB888).copy() pixmap = QPixmap.fromImage(q_img) self.scene.clear() self.scene.addPixmap(pixmap)注意QImage(rgb_img.data, ...)这里必须调用.copy(),否则 numpy 数组被垃圾回收后,底层缓冲失效,显示会花屏。这个坑在 PySide6 和 OpenCV 混用场景非常常见。另外一个细节:检测框坐标是从 640 分辨率的推理结果解析出来的,如果图像被窗体缩放显示,需要把坐标按显示比例换算,否则框会画偏。常见做法是保存原始图像尺寸,在绘制前用x1 * original_w / 640换算回来。
3.4 结果导出:生成带标注和骨龄值的报告
检测和评估完成后,项目用reportlab或 OpenCV 的 putText 生成简易报告。如果是简单场景,直接在图上写文字并保存为 JPG 就够用;如果要正式留档,建议用reportlab生成 PDF。这里简单实现一个 OpenCV 画图的方法:
def draw_report(img_bgr, bone_age, roi_count): cv2.putText(img_bgr, f"Bone Age: {bone_age}", (30, 60), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 0, 0), 2) cv2.putText(img_bgr, f"ROIs: {roi_count}", (30, 110), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 0, 0), 2) return img_bgr保存时用cv2.imwrite("result.jpg", img_bgr)。如果后面想接入医院系统,可以换成输出 JSON 格式,把bone_age、roi_count、每个检测框的坐标和置信度序列化出去,方便其他系统消费。这个导出模块独立成函数,就是为后续接口化做准备。
4. 工程落地:目录结构、模型权重与运行配置
4.1 目录结构逐层说明
拿到项目源码后,先看目录结构。这套骨龄检测系统的代码组织比较清晰,把模型、界面、工具类分开,符合中小型项目的习惯。具体结构如下:
bone-age-system/ ├── main.py # 程序入口,启动PySide6主窗口 ├── detector.py # YOLOv5推理封装 ├── estimator.py # 骨龄回归模型推理 ├── gui/ │ ├── __init__.py │ ├── main_window.py # 主窗口类 │ └── widgets.py # 自定义控件,比如缩放画布 ├── models/ │ ├── yolov5s_bone.pt # YOLOv5检测权重 │ └── bone_regressor.pt # 骨龄回归网络权重 ├── configs/ │ └── config.yaml # 置信度阈值、输入尺寸等参数 └── utils/ ├── img_ops.py # 图像预处理、坐标换算 └── report_writer.py # 报告生成建议把两个权重文件放models/目录,不要散落在根目录。config.yaml里可以统一管理模型路径、阈值、图像尺寸,改参数不用动代码。注意.pt文件是二进制模型权重,不要用 git 管理,一般单独用网盘分发。
4.2 配置文件与模型权重准备
项目运行的关键是拿到两个权重文件:一个用于 YOLOv5 检测骨骺区域,一个用于骨龄回归。如果你是从源码仓库下载的,权重可能因为体积原因不直接包含,需要按说明单独下载。下载后放在models/路径下,然后在config.yaml中指定路径。
detector: weights: models/yolov5s_bone.pt conf_thres: 0.45 iou_thres: 0.45 input_size: 640 estimator: weights: models/bone_regressor.pt roi_size: 224 gui: window_width: 1280 window_height: 800conf_thres是检测置信度阈值。如果阈值设太高,一些弱骨化中心会被漏检,导致骨龄偏低;设太低又会出现大量误检框,干扰后续回归。经验值 0.45 左右比较稳妥。iou_thres是非极大值抑制阈值,一般保持默认 0.45。如果你发现相邻的框频繁重叠,可以把iou_thres调到 0.5,否则不用动。
4.3 运行步骤:从 Python 命令到 GUI 界面
在配置好依赖和权重后,启动并不复杂。先确认 Python 环境,然后按顺序执行安装和启动命令。如下是完整的启动流程:
conda create -n bone python=3.9 conda activate bone pip install torch==1.13.1 torchvision==0.14.1 pip install -r requirements.txt python main.py如果一切正常,会弹出 PySide6 主窗口,点“打开图像”按钮选择一张 X 光片或拖入图像区域,然后点“开始检测”。检测过程有进度提示,完事右侧面板显示骨龄数值和检测框数量。第一次运行可能会触发 torch.hub 加载缓存,速度稍慢,之后再运行就快了。
if __name__ == "__main__": import sys from PySide6.QtWidgets import QApplication from gui.main_window import MainWindow app = QApplication(sys.argv) window = MainWindow() window.show() sys.exit(app.exec())这个入口文件短时间内不要随意改动。初期跑通之前,不建议直接改模型加载逻辑,否则分不清问题是出在前端还是后端。第一次跑通后,再去动detector.py里的参数。
4.4 训练自己的检测模型(可选)
如果你不想用人家训练好的权重,想用自己的 X 光片数据训练检测模型,可以把 YOLOv5 官方仓库拉下来,把自己的数据标注成 YOLO 格式。标注时注意每个骨化中心是一个矩形框,类别按临床命名(比如 radius、ulna、phalanx_middle)。训练命令如下:
git clone https://github.com/ultralytics/yolov5 cd yolov5 python train.py --data bone.yaml --weights yolov5s.pt --epochs 100 --batch-size 16其中bone.yaml要指定你的数据集路径和类别数。训练完成后,把runs/exp/weights/best.pt复制到项目的models/目录下,替换yolov5s_bone.pt,重新运行main.py即可。注意训练时图像最好统一尺寸,不要混着多种分辨率,否则 letterbox 后骨骼比例不一致,检测框偏移明显。这就是为什么临床采集要固定拍摄距离和体位。
5. 踩坑记录:推理结果偏差的排查与解决
5.1 检测框错位导致骨龄偏高
有段时间用测试集验证,骨龄结果整体偏高约 1.5 岁。排查后发现不是模型问题,而是 GUI 里把检测框坐标直接画到了原始图像上,但检测器内部用的是 640 边长的缩放图,没有做反算。现象是框框位置偏差和骨骼边缘缺少贴合感。解决方式是在detector.predict()返回后,统一在utils.img_ops.rescale_boxes里把坐标转换为原图坐标。从那次之后,所有模型输出的坐标都强制经过坐标转换,杜绝了这类乌龙。
5.2 CUDA out of memory
推理时如果显存低于 4G,一张大尺寸 X 光片很容易触发显存溢出。YOLOv5 推理阶段的中间变量在 GPU 上非常吃显存,尤其是把整张图 resize 成 640 后仍保留大图上下文时。我当时的解决方法是先强制 CPU 推理对比,发现能正常跑,确认是显存问题后,把detect阶段的torch.cuda.set_per_process_memory_fraction设为 0.5,或者干脆对输入图像做一次降采样预处理。另外,检查是否同时加载了多个模型实例,有时 debug 时重复调用,模型没被释放,也会累积显存占用。
5.3 PySide6 界面启动后花屏
在 Windows 上第一次运行 GUI,上传图片后图像区域一片花。原因是 OpenCV 图像转QImage时没有正确设置通道数和步长,而且没对数组做连续性检查。现象是图像预览有一半是彩条。解决方式是先np.ascontiguousarray(rgb_img),再构造QImage,并调用.copy()确保数据所有权归 Qt。你可以把这段转换逻辑单独放到utils/img_ops.py里复用,避免每次踩一样的坑。
5.4 模型加载慢到怀疑人生
第一次加载.pt文件耗时十几秒,主要是 torch.hub 在从 GitHub 拉取 YOLOv5 仓库的元数据。如果你处在无法访问外网的环境,可以把ultralytics/yolov5本地 clone 一份,然后用torch.hub.load('本地路径', 'custom', path='models/yolov5s_bone.pt')加载,速度能降到两秒内。另外,force_reload=False要保留,避免每次启动都重新拉取网络信息。
5.5 检测置信度低时骨龄结果起伏大
有些孩子的骨化中心尚不清晰,模型输出的置信度只有 0.3 左右。如果你把所有低于阈值的框都丢弃,骨龄会偏低;如果保留,又容易把软组织误判成骨化中心。我的处理方案是给每个 ROI 的骨龄贡献加权重,置信度低于阈值的 ROI 不参与最终加权,但界面会用红色框提示“低置信度区域”,提醒使用者人工复核。这种“机器初筛 + 人工复核”的机制,比单纯提高阈值更符合实际场景。
6. 二次开发:把模型换成自己的数据集或新增评估指标
6.1 换数据集后要调整的三个地方
如果你打算用这个系统识别其他医学图像(比如手骨骨折检测),需要改三处:config.yaml里的类别数和阈值,detector.py里的类别解析逻辑,以及estimator.py里的age_centers数组。常见做法是先在命令行里跑通检测模型,确认框位置正确后再接入 GUI。我一般会先用一张带标注的图像做单测,打印出所有检测框的坐标和类别,确认无误后再动 UI 代码,这样能缩短调试周期。
6.2 从 demo 到工具:增加批量处理与接口
当前软件一次只能读一张图,二次开发时最值得加的功能是批量处理。用QFileDialog.getExistingDirectory选文件夹,然后遍历目录里所有 png/jpg 文件,调用现有检测与回归流程,把结果累计后导出 CSV。这个功能能帮你快速评估一个数据集,而不是靠肉眼一张张截图。导出 CSV 的代码短小易写,沿用 draw_report 里拿到的 bone_age 数据即可。
import csv with open("bone_age_results.csv", "w", newline="") as f: writer = csv.writer(f) writer.writerow(["filename", "bone_age", "roi_count"]) for img_name, bone_age, roi_count in results: writer.writerow([img_name, bone_age, roi_count])完成这一步后,这套工具就不只是演示,而可以真正投入到样本预筛中。我从那以后每次拿到新的影像数据集,都会先走一遍批量输出流程,确认模型在新数据上的表现,再决定要不要继续调参。这个过程省下的时间远超写代码本身,希望帮到你。
本文还有配套的精品资源,点击获取