简介:这套基于Python与YOLOv5的手骨骨龄检测项目,面向毕业设计、课程设计及项目开发场景,提供从数据处理、模型训练到结果演示的完整工程实践。资源共189个文件,整体约436.79MB,涵盖Python脚本(py)、YOLOv5配置与训练文件(yaml/yml)、预训练权重(pt)、数据集图片(png/jpg)、Shell脚本(sh)、项目文档(md)及Docker环境配置等,便于读者按模块复用与二次开发。项目针对骨龄影像的雾感问题引入直方图均衡化,并分别对手骨和关节区域做增强与数据集划分;检测模型以YOLOv5为基础,分类网络采用ResNet18,对DIP、MCP、PIP等部位实现90%以上测试准确率。资源内附项目文档、训练脚本、数据集划分工具与视频演示,已有197人学习下载,适合希望快速上手深度学习检测与分类完整流程的开发者参考,并可在源码基础上延展改进。
1. 从手骨X光片到骨龄:为什么选yolov5+分类网络
手骨骨龄检测的本质不是对整张X光片做端到端回归,而是先找到那些决定骨骼成熟度的关键区域,再对每个区域给出成熟度等级。用python+yolov5做目标检测,后面接一个ResNet18分类网络,是这个项目里最直接有效的两段式结构。它有两个好处:一是yolov5的泛化能力强,在医学影像样本量不大时,用COCO预训练权重做迁移学习,也能得到稳定的手骨区域检测框;二是检测头更适合输出位置,成熟度等级这种细粒度结果交给轻量分类网络更稳。项目源码把数据集预处理、目标检测模型训练、关节分类模型训练和完整评测串在一起,适合毕业设计、课程设计,也适合想把手骨骨龄检测做成原型的开发者参考。
2. 数据集预处理:CLAHE去雾感与split_dataset切分
手骨X光片的成像质量并不稳定,很多图曝光不足或过度,像素值被压缩在一个窄区间里,视觉上像是蒙了一层雾。模型直接吃这种图,骨骼边缘和骨小梁纹理都不清楚,目标检测框的定位和后续分类准确度都会下降。项目里针对这个问题做了两套预处理:bone_createCLAHE.py负责手骨整体图像,bone9_createCLAHE.py负责关节区域局部增强,两者都采用CLAHE自适应直方图均衡化,而不是普通直方图均衡化。
普通全局直方图均衡化会把暗部噪声一起放大,原因在于它只统计整张图的灰度分布,一个高亮的金属伪影就能把整幅图的灰度映射拉偏。CLAHE则把图像划分成小块,每块单独做直方图均衡,并对裁剪后的灰度增益做限制,从而避免骨纹理被噪声吞掉。对于手骨X光片来说,这种局部增强方式更贴近医学影像的观察习惯:骨骼边缘清晰,软组织背景不过曝。
2.1 bone_createCLAHE.py:自适应直方图均衡化怎么调参
以下是一个可独立运行的CLAHE处理脚本,核心逻辑和项目源码中bone_createCLAHE.py一致,只是把输入输出路径简化了:
import cv2 import glob import os CLIP_LIMIT = 2.0 TILE_GRID = (8, 8) INPUT_DIR = "datasets/bone_raw" OUTPUT_DIR = "datasets/bone_clahe" os.makedirs(OUTPUT_DIR, exist_ok=True) clahe = cv2.createCLAHE(clipLimit=CLIP_LIMIT, tileGridSize=TILE_GRID) for img_path in glob.glob(os.path.join(INPUT_DIR, "*.png")): gray = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) enhanced = clahe.apply(gray) out_path = os.path.join(OUTPUT_DIR, os.path.basename(img_path)) cv2.imwrite(out_path, enhanced)这段代码里最关键的两个参数是clipLimit和tileGridSize。clipLimit限制直方图裁剪高度,值越大越接近全局直方图均衡,2.0是一个比较稳妥的起点;如果整张图出现过曝,就降到1.5。tileGridSize决定局部统计用的窗口大小,手骨X光片通常用8x8,窗口太细会出现网格状伪影,太粗又起不到局部增强的作用。
医学X光片一般是8bit或12bit灰度,这里用IMREAD_GRAYSCALE读入后,再以8bit PNG保存,是为了让yolov5训练时不必处理高位图带来的额外内存开销。整个预处理阶段不要改变图像尺寸,先保持原图输出,等进入检测训练时再统一缩放,否则ROI标注坐标会很难对齐。
| CLAHE参数 | 推荐范围 | 项目里使用 | 效果说明 |
|---|---|---|---|
| clipLimit | 1.5~3.0 | 2.0 | 骨边缘清晰,背景不过曝 |
| tileGridSize | 4x4~16x16 | 8x8 | 保留骨小梁纹理 |
| 输入格式 | 单通道灰度 | 单通道灰度 | 避免颜色空间转换引入伪影 |
| 输出格式 | 8bit PNG | 8bit PNG | 可直接进入训练管线 |
2.2 split_dataset.py:按比例切分训练集与测试集
CLAHE处理完之后,需要把图片和标注数据按比例切分成训练集和测试集。项目里split_dataset.py做的是随机切分,同时对缺失label做检查,避免训练到中途才发现标注文件没跟上。
import os import random import shutil SEED = 42 RATIO = 0.8 random.seed(SEED) images = [f for f in os.listdir("datasets/bone_clahe/images") if f.endswith(".png")] random.shuffle(images) train_n = int(len(images) * RATIO) for split_name, split_files in [("train", images[:train_n]), ("test", images[train_n:])]: for img_name in split_files: src_img = os.path.join("datasets/bone_clahe/images", img_name) dst_img = os.path.join(f"datasets/bone/{split_name}/images", img_name) label_name = img_name.replace(".png", ".txt") src_lbl = os.path.join("datasets/bone_clahe/labels", label_name) dst_lbl = os.path.join(f"datasets/bone/{split_name}/labels", label_name) os.makedirs(os.path.dirname(dst_img), exist_ok=True) shutil.copy(src_img, dst_img) if os.path.exists(src_lbl): shutil.copy(src_lbl, dst_lbl) else: print(f"missing label: {label_name}")这段代码把全局随机种子固定为42,便于复现实验。训练集和测试集的比例不是死的,手骨类别少于50张时建议改成7:3,给测试集多留一些样本。更重要的是,测试集不能做数据增强,否则评估出的准确率会虚高,部署到真实X光片上才会现原形。
如果你的样本本身不均衡,全局随机切分会让某些关节类别在测试集里数量过少。常见做法是先按标注的class_id分层,再在每层内部做随机切分,确保DIP、MIP、Radius、Ulna这些类别在训练集和测试集中的比例基本一致。切割完成后,可以用下面这条命令快速统计每个类别在train和test中的图片数量:
for f in datasets/bone/train/labels/*.txt; do awk '{print $1}' "$f"; done | sort | uniq -c3. YOLOv5检测模型训练:bone.yaml、yolov5s.yaml与train.py参数设置
目标检测阶段的任务是找到手骨X光片上的关键区域。项目里将yolov5的类别数改成7,也就是说检测侧先把相邻或容易混淆的关节合并成较粗的区域,不做太细的成熟度分级;真正的九个成熟度标签放到后面的分类网络里处理。这样做能减少边界框重叠和类别混淆,也让yolov5在数量有限的数据集中更容易收敛。
第2章里预处理好的图片需要转成YOLO标注格式。每行txt的内容是class_id x_center y_center width height,所有坐标都归一化到0~1。手骨X光片通常是长条状,如果长边超过训练尺寸,yolov5的letterbox操作会引入黑边,黑边越多,x_center和y_center的换算越容易出现偏差。我一般会在切分前先统一做一个等比例缩放,把长边限制在1000px以内,再生成标注文件。
3.1 修改bone.yaml:数据路径、类别数与类别名
在克隆下来的yolov5目录下新建bone.yaml,把训练集、验证集、测试集路径指到上一章生成的目录,同时设置7个检测类别。项目里的类别顺序要根据标注工具导出的顺序来写,这里列出的names是一组常用顺序,实际使用时要和你自己的标注保持一致。
train: datasets/bone/train/images val: datasets/bone/val/images test: datasets/bone/test/images nc: 7 names: 0: Radius 1: Ulna 2: MCP 3: MCPFirst 4: PIP 5: DIP 6: MIPyolov5训练时需要val集是为了在训练过程中评估模型,test集只在全部训练结束后跑一次。很多人图省事把train images复制一份给val,短期没问题,但调参次数一多,模型会间接在val上过拟合,最后test的参考价值就打了折扣。
3.2 修改yolov5s.yaml与train.py:把网络结构里的nc改成7
yolov5s.yaml定义的是整个yolov5网络结构。CSPDarknet骨干加PANet颈部是yolov5的基本框架,depth_multiple和width_multiple分别控制骨干模块的重复次数和卷积通道数。yolov5s把depth_multiple设为0.33,width_multiple设为0.50,所以它比yolov5l和yolov5x轻很多,适合手骨目标检测这类数据量不大的医学场景。
nc: 7 depth_multiple: 0.33 width_multiple: 0.50显存不足时可以把cfg改成yolov5n.yaml,depth_multiple降到0.25;但手骨区域在X光片上占比并不小,yolov5s的检测头已经足够,不需要把网络结构压得太狠。训练时用下面的命令启动:
cd yolov5 python train.py \ --data bone.yaml \ --cfg yolov5s.yaml \ --weights yolov5s.pt \ --epochs 150 \ --batch-size 16 \ --imgsz 640 \ --workers 4 \ --device 0 \ --name bone_det| train.py参数 | 项目常用值 | 说明 |
|---|---|---|
| --weights | yolov5s.pt | 使用COCO预训练权重做迁移学习 |
| --epochs | 150 | 手骨数据量小,过长会过拟合 |
| --batch-size | 16 | 8G显存以下建议降到8 |
| --imgsz | 640 | 原图过大时可先缩到640再训练 |
| --workers | 4 | Windows上过大会卡数据加载 |
| --name | bone_det | 输出目录名,方便对比不同实验 |
3.3 训练过程中的损失观察与权重选择
训练开始后,重点看runs/train/bone_det/weights/目录下的best.pt和last.pt。best.pt是根据验证集P/R/mAP指标选出来的。手骨检测的训练损失下降速度一般在前50个epoch很快,后50个epoch逐渐变平。如果训练集loss一直降而val loss反弹,说明过拟合,优先减少epoch,或者加一些mosaic和mixup增强比例。
如果你的X光片里有大量小目标,比如儿童手骨骨化中心很小,需要把imgsz从640调到960,同时把batch-size降到8。显存不够时不要直接缩小imgsz,而是用更轻的yolov5n。分类数量已经改成7的情况下,anchor参数不用手工调,yolov5会在训练前自动使用k-means重新计算anchor。
4. ResNet18分类模型:my_net.py里的9个成熟度标签
检测模型拿到的是“手骨上第几个位置有哪个区域”,但骨龄评分需要的是成熟度等级。项目里把这些细粒度标签分成了九个:DIP、DIPFirst、MCP、MCPFirst、MIP、PIP、PIPFirst、Radius、Ulna。检测头的回归特性更适合输出连续位置,而成熟度分级本质是离散类别,所以用ResNet18接在检测框后面做分类,这样两段式结构更容易调优。
4.1 为什么选择ResNet18
成熟的骨龄分类输入是一小块关节ROI,不是整张大图,ResNet18的残差结构在层数不深的情况下就能拟合这种局部纹理差异。ResNet50在这个任务上提升有限,训练速度却慢了近一倍,而轻量网络又容易在边缘骨化中心上欠拟合。项目里用ResNet18在九个成熟度标签上都达到90%以上的测试准确度,和CLAHE预处理保持同一套管线密不可分。
分类输入统一改成224x224,这和检测训练用的640x640没有关系。检测阶段先定位,分类阶段再把原图ROI裁剪出来重新resize,不能用yolov5训练时letterbox后的小patch直接喂给分类网络,不然细节丢失严重。
4.2 my_net.py:用pre-trained ResNet18改出Gray输入分类网络
项目里的my_net.py定义了自己的分类网络,核心是基于torchvision的resnet18改造输入通道。手骨X光片是灰度图,不能直接把单通道数据送进预训练好的三通道ResNet,所以要把第一层卷积从3通道改成1通道,并保留预训练权重中学习到的边缘特征。
import torch import torch.nn as nn from torchvision import models class Bone9Classifier(nn.Module): def __init__(self, num_classes=9): super().__init__() self.backbone = models.resnet18(weights=models.ResNet18_Weights.DEFAULT) # 将预训练conv1的3通道权重取均值,变成1通道 old_weight = self.backbone.conv1.weight.detach().mean(dim=1, keepdim=True) self.backbone.conv1 = nn.Conv2d( 1, 64, kernel_size=7, stride=2, padding=3, bias=False ) self.backbone.conv1.weight.data = old_weight in_features = self.backbone.fc.in_features self.backbone.fc = nn.Linear(in_features, num_classes) def forward(self, x): return self.backbone(x)这段代码里最容易踩坑的是直接新建一个随机初始化的nn.Conv2d(1, 64, 7),那样第一层失去了ImageNet预训练的特征表达能力,后面所有层的gradient更新也会跟着变乱。用R通道权重、G通道权重、B通道权重的均值初始化,相当于把彩色预训练知识压缩到单通道,这是灰度迁移学习里比较常见的做法。如果你的输入仍然保留三通道,比如把灰度图复制成三份,就不需要改conv1。
训练入口可以写在my_net.py的main函数里,命令大概长这样:
python my_net.py \ --mode train \ --data datasets/joint_clahe \ --input-size 224 \ --epochs 50 \ --batch-size 32 \ --lr 1e-4 \ --num-classes 9lr设1e-4是因为分类器是在预训练权重上微调,lr太大会把底层特征破坏掉。如果是从零训练,lr可以放高到1e-3,但需要更多epoch才能收敛。数据量充足时,50个epoch足够看到验证准确率明显平台期。
4.3 pic_power.py:把关节ROI增强到每类1800张
项目里使用pic_power.py做图像增强,目的是把每个类别的样本量拉到1800张,避免某些标注数量较少的关节类别在分类训练中直接欠拟合。增强不能只在原图上做亮度变换,还要考虑医学X光片本身的曝光差异。
import cv2 import numpy as np def gamma_power(img, gamma): table = np.array( [((i / 255.0) ** gamma) * 255.0 for i in range(256)] ).astype("uint8") return cv2.LUT(img, table) def augment(img): imgs = [img] for gamma in [0.8, 1.0, 1.2]: imgs.append(gamma_power(img, gamma)) flip = cv2.flip(img, 1) rotate = cv2.warpAffine( img, cv2.getRotationMatrix2D((img.shape[1] // 2, img.shape[0] // 2), 5, 1.0), (img.shape[1], img.shape[0]), ) imgs += [flip, rotate] return imgsgamma小于1会提亮暗部,gamma大于1会压暗整体,能模拟X光曝光条件的波动;水平翻转和5度以内的旋转对骨关节形态影响很小,适合医学目标。增强完毕后再统一resize到224x224,并按比例随机抽样到test目录。需要记住的是,增强只在训练集上做,测试集要保持原始分布。
下面的表格是项目里九个分类标签对应的大致含义,实际标注时可能按不同的骨骼编码规则命名,但检测区域定位思路是通用的。
| 标签 | 含义 |
|---|---|
| DIP | 远端指间关节 |
| DIPFirst | 第一指远端指间关节 |
| MCP | 掌指关节 |
| MCPFirst | 第一掌指关节 |
| MIP | 中节指骨区域 |
| PIP | 近端指间关节 |
| PIPFirst | 第一指近端指间关节 |
| Radius | 桡骨远端 |
| Ulna | 尺骨远端 |
检测模型输出7类,分类模型输出9类,中间并没有冲突。检测阶段把DIPFirst、PIPFirst这类容易混淆的位置先并到DIP、PIP大类中,分类阶段再根据裁剪后的纹理细节给出具体的成熟度等级标签。这样做的好处是把“找位置”和“判等级”明确分开,任一个环节出问题都能独立调试。
5. 把检测和分类串成推理流水线:阈值与预处理一致性
5.1 用原图ROI代替letterbox小patch
训练完成后,最常见的错误是直接把yolov5推理得到的640x640图裁剪下来送给ResNet18,这样骨小梁细节已经丢失。正确做法是先让检测模型在原图上输出框坐标,再回到原图裁剪ROI,最后resize到224x224。下面这段推理逻辑可以直接应用到项目源码里,配合yolov5的torch.hub接口使用。
import cv2 import torch from my_net import Bone9Classifier det_model = torch.hub.load( "ultralytics/yolov5", "custom", path="runs/train/bone_det/weights/best.pt", force_reload=True, ) cls_model = Bone9Classifier(num_classes=9) cls_model.load_state_dict(torch.load("best_cls9.pth", map_location="cpu")) cls_model.eval() img0 = cv2.imread("xray.png") detections = det_model(img0).xyxy[0].tolist() for det in detections: x1, y1, x2, y2 = [int(v) for v in det[:4]] conf = det[4] if conf < 0.15: continue roi = img0[y1:y2, x1:x2] roi = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) roi = cv2.resize(roi, (224, 224)) roi = torch.from_numpy(roi / 255.0).float().unsqueeze(0).unsqueeze(0) with torch.no_grad(): logits = cls_model(roi) maturity_id = torch.argmax(logits, dim=1).item()检测阶段的conf阈值我建议设到0.15~0.25,比自然图像检测的0.4低很多。手骨骨化中心在X光片上对比度偏低,阈值太高会漏掉很多真实ROI,而分类网络对位置不敏感,只要框大致覆盖关节区域,成熟度判断依然可靠。分类阶段则反过来,softmax概率低于0.6的直接丢弃,不让低置信度结果污染最终骨龄分布。
5.2 验证时按部位看混淆矩阵,不要只看总准确率
总准确率90%很可能是由Radius和Ulna的大样本拉高的,真正容易错的是DIPFirst和PIPFirst这类相邻部位。建议把每个部位的预测结果分别统计成混淆矩阵,检查误判集中在哪一对标签。如果MIP和MCP经常混淆,说明检测阶段把这两个区域分成两类不是好主意,可以把两者合并后再交给分类网络。
预处理一致性是最后一道关卡。训练时用什么clipLimit、什么gamma值、什么resize方式,推理时就必须原样使用。把这些参数集中写进一个config字典或yaml文件,防止代码改动后训练和推理走两套逻辑。我一般还会把所有ROI坐标限制回图像边界,避免出现负坐标或超界裁剪,然后在批量推理时逐张打印失效ROI,一旦发现检测框数量明显少于预期,优先回查CLAHE参数和conf阈值。
本文还有配套的精品资源,点击获取