简介:面向毕业设计、课程设计与项目开发场景的手骨骨龄检测完整项目包,基于Python与YOLOv5实现,覆盖数据处理、模型训练到结果展示全流程。压缩包内含189个文件,大小约436.79MB,主要包含Python训练与预处理脚本、YAML/YML配置文件、PyTorch模型权重(.pt)、Shell辅助脚本、Markdown项目文档及Jupyter示例等,结构分层清晰,便于按需查阅和二次开发。项目针对手骨及关节图像进行了自适应直方图均衡化(CLAHE)去雾处理,按比例划分训练集和测试集,并通过图像增强将样本扩充至每份1800张;检测端基于YOLOv5配置bone.yaml与yolov5s.yaml训练目标检测模型,分类端采用ResNet18对DIP、MCP、PIP、Radius、Ulna等关节部位进行识别,测试准确率达90%以上。附带的视频演示、源码、模型与文档可帮助快速掌握骨龄检测实现思路,适合在此基础上进行毕业设计或课程项目延展。已有198人学习/浏览,是计算机视觉方向较为完整的参考项目。
1. 基于 Python 与 YOLOv5 的手骨骨龄检测:先搞清楚它在解决什么问题
基于 Python 与 YOLOv5 的手骨骨龄检测,核心是「YOLOv5 定位骨骺 + 回归网络给年龄」的两阶段方案。骨龄是通过左手腕 X 光片观察骨骺发育程度推算出的生理年龄,儿科用它评估儿童的生长发育;这个任务够专业,RSNA 公开数据集又让它够可复现,因此特别适合当毕业设计、课程设计和项目开发的完整案例。
很多新手拿到题目就想跳过检测,直接拿整张片子端到端回归骨龄。这条路训练能收敛,但模型是个黑匣子,依据什么给分说不清,答辩一追问就翻车。两阶段方案每一步都有中间结果——YOLOv5 先把桡骨远端、尺骨远端、掌骨等关键骨骺框出来,回归网络再对裁剪好的 ROI 打分,既符合临床读片逻辑,又方便调参和写文档。下面的内容从数据准备、模型训练、骨龄回归讲到避坑清单和端到端演示,新手能跟着走通,熟手可以直接拿参数去改。
2. 先定系统结构:YOLOv5 检测骨骺,回归网络算骨龄
标题里的「源码、项目文档、模型训练、数据集」四件套,落到工程上其实是一条清晰的两阶段流水线:数据准备 → YOLOv5 定位 → ROI 裁剪 → 骨龄回归 → 可视化输出。拿到题目先别急着 clone 源码跑 demo,先把架构在纸上画明白。这个方向的翻车大多发生在阶段衔接上:要么检测框和回归输入对不上,要么数据划分不干净导致验证指标虚高,这些都比模型本身更难排查。
2.1 两阶段方案拆解:检测对应定位,回归对应评分
骨龄判读在临床上有 GP 图谱法和 TW3 评分法两套主流标准,国内还常用 CHN 法。GP 法强调整张片子的整体成熟度比对,TW3 则是挑若干块骨骺逐块打分再汇总。两阶段方案的结构和 TW3 的思路天然接近:YOLOv5 把桡骨远端、尺骨远端、掌骨、指骨这些区域框出来,相当于「挑出要评分的骨骼」;回归网络对每个框输出成熟度分数,相当于「逐块打分」。答辩时一句「检测对应定位,回归对应评分」,方案合理性就立住了。
有人会问,为什么不把骨龄当成 240 档分类直接做端到端?纯分类的问题是模型没有告诉你在看哪块骨头,也没有任何中间产出;而目标检测天然输出框、置信度和可视化图,课程设计和答辩展示都好演。一阶段端到端回归也不是不能跑,只是可解释性差,我对比过两版之后还是选了检测加回归,理由很现实:项目要能讲、能调、能截图。
ROI 裁剪不是给回归网络省事,它真正解决的是注意力聚焦。整张 X 光片分辨率很高,但骨骺只占画面很小一块,直接喂全图会给网络塞进大量与年龄无关的软组织信息;裁剪后统一尺寸,回归网络看到的是一块骨头而不是一整条手臂。每个 ROI 还能单独输出一个年龄分,既做整体平均,又能观察哪块骨骺偏差大,写项目文档时这是很好的分析素材。
2.2 数据集选型与清洗:RSNA 手骨数据集与三个必做动作
公开数据集首选 RSNA 2017 Pediatric Bone Age:训练集 12611 张左手 X 光片,每张带骨龄(月)和性别标注。它适合毕设的原因很直接,数据量足够两阶段方案吃,不用申请授权。要注意它给的是整图标签,没有目标框,两阶段需要自己补标注;公开的已标注子集也能找到,但规模参差,我更推荐自己标一部分,标注过程反而能帮你理解骨骺到底长在哪。
清洗第一件事是按病人 ID 去重:同一病人的多张随访片不能同时落在训练集和验证集,否则验证集里藏着训练见过的「同一只手」,指标虚高,真实场景立刻现原形。第二是剔掉体位明显不正、对比度极端的图,这类图会让检测阶段学到错误的尺度感。第三是检查年龄分布,儿童骨龄数据天然偏向学龄期,5 岁以下样本少,后面低龄漏检的问题基本就是这里埋的雷。
标注工具用开源的 labelImg 就行。不需要标全 14 块骨头,标 3 到 6 个关键区域足矣:桡骨远端、尺骨远端、第一掌骨、第三掌骨、第五掌骨、中指近节指骨。类别定义越少,标注一致性越好,模型漏检率越低,这是我在两个数据集上对比后的实际感受。
2.3 把标注转成 YOLOv5 格式:坐标转换脚本与三个要盯的参数
labelImg 默认导出的是 VOC 风格的 XML 或 JSON,YOLOv5 需要的是每张图一个同名 txt、每行一个「类别 中心x 中心y 宽 高」的归一化记录。转换脚本的关键是四个字段全部除以原图宽高,顺序不能换,这是 YOLO 格式最容易写错的地方。
# 把 labelImg 的 JSON 标注转成 YOLOv5 的 txt 格式 import os, glob, json def voc_to_yolo(json_dir, txt_dir, img_w, img_h): os.makedirs(txt_dir, exist_ok=True) for f in glob.glob(os.path.join(json_dir, "*.json")): with open(f, encoding="utf-8") as fp: ann = json.load(fp) name = os.path.splitext(os.path.basename(f))[0] lines = [] for obj in ann["objects"]: # 一张图里可能有多个骨骺框 x1, y1 = obj["x1"], obj["y1"] x2, y2 = obj["x2"], obj["y2"] # YOLO 用归一化的中心坐标和宽高 cx = (x1 + x2) / 2.0 / img_w cy = (y1 + y2) / 2.0 / img_h w = (x2 - x1) / img_w h = (y2 - y1) / img_h lines.append(f"0 {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}") # 0 是类别 id with open(os.path.join(txt_dir, name + ".txt"), "w") as fp: fp.write("\n".join(lines) + "\n")逻辑说明:脚本把左上右下角坐标转成中心点加宽高,再分别除以原图宽高完成归一化。YOLOv5 的 dataloader 按 txt 的行数判断目标数量,一行就是一个目标,坐标必须是 0 到 1 之间的小数。
三个要盯的参数:一是 img_w 和 img_h 必须是转换时原图的真实宽高,方向写反了框全部错位;二是类别 id 必须和后面 data.yaml 的 names 顺序一致,names 里第一个类就是 0,顺序写反的后果是检测到了但语义全错;三是宽或高小于 3 像素的退化框直接过滤,这种框训练时只会贡献噪声梯度。转换完把 txt 画回原图抽查三五张,确认框贴住骨骺再开训练。
提示:转换完标注后,把框画回原图检查一遍再开训练。这步花十分钟,能省掉一整晚的排错时间。
3. 用 YOLOv5 训练自己的手骨模型:目录、超参数与日志
YOLOv5 的源码结构、训练入口和文档都很成熟,不需要自己重写训练循环,要改的只有三样:数据集目录、data.yaml、以及一条训练命令。这一章按「先跑通、再调参、最后导出」的顺序讲,每一步都对应实际会看到的现象。
3.1 dataset.yaml 与最小目录结构:先跑通再谈精度
datasets/bone/ ├── images/ │ ├── train/ # 800 张,如 001.jpg │ └── val/ # 200 张 ├── labels/ │ ├── train/ # 001.txt,与图片同名 │ └── val/ └── bone.yamlYOLOv5 对目录名没有硬性要求,但通用做法是 images 和 labels 平级,train 和 val 各自配对。labels 里的 txt 必须和 images 里的图片同名,后缀不同;一张图没有任何目标时,txt 可以留空,但不要缺文件,否则部分版本会把「缺 label」当成「整图无效」过滤掉。
# 数据集配置,路径相对 YOLOv5 仓库根目录写 train: datasets/bone/images/train val: datasets/bone/images/val nc: 1 # 类别总数 names: ['epiphysis'] # 类别名单,顺序对应标注里的 iddata.yaml 里 train 和 val 推荐用相对 YOLOv5 根目录的路径,换机器不用改。nc 是类别总数,names 的顺序就是 2.3 节里类别 id 的映射表。第一轮训练建议先用完整数据跑 1 个 epoch 验证加载无误,再回头调超参,避免浪费时间在「数据没读进来但训练还在跑」的错觉上。
3.2 训练命令与关键超参数:img、batch、hyp 怎么调
python train.py \ --data bone.yaml \ --weights yolov5s.pt \ --epochs 150 \ --batch-size 16 \ --img 640 \ --hyp hyp.scratch-low.yaml \ --project runs/bone_train--weights 用 yolov5s.pt 的 COCO 预训练权重,不是因为它认识骨骺,而是因为它已经学会通用的边缘和纹理特征,手骨数据量不足时这层迁移价值很大。显存 8 GB 选 s,12 GB 以上可以试 m;手骨目标小、纹理结构简单,s 的性价比通常最高,m 带来的 mAP 增益往往不到两个点。
--img 是输入分辨率。640 能跑通,1280 对 X 光片细节更友好,但显存占用和训练时长直接翻倍。用 Colab 的免费 GPU 也能训练,session 断开前把 weights 下载回本地就行,别全部指望云端持久化。--batch-size 按显存来,OOM 就减半,s 加 640 分辨率跑 16 在 8 GB 同级别显卡上基本安全。--hyp 指定 YOLOv5 内置超参数文件,默认的 hyp.scratch-low.yaml 对大多数项目都够;如果后面过拟合,优先改正则项而不是盲目堆 epoch。
容易被忽略的一点:YOLOv5 训练开始时会用 autoanchor 根据你的数据集重新算锚框,日志里出现 autoanchor 开头的一行就说明它在做这件事。手骨骨骼的长宽比和 COCO 目标差很多,千万别在 hyp 里锁死 COCO 锚框,否则小目标检测会无端翻车。
3.3 怎么从日志判断训练在正常收敛
Epoch gpu_mem box_loss obj_loss cls_loss mAP@0.5 mAP@0.5:0.95 60 4.21G 0.0312 0.0187 0.0000 0.982 0.877单类或少类场景下,cls_loss 趋近于 0 是正常现象,别看到 cls_loss 小就以为模型没在学;真正要盯的是 box_loss 和 mAP@0.5。训练前 30 到 50 个 epoch,mAP 在零点几之间大幅震荡是正常的,过了震荡期再看曲线是否平滑上升。连续 10 个 epoch mAP@0.5:0.95 不涨就可以手动停掉;YOLOv5 默认的 patience 是 100,对课程设计的时间不友好,我一般直接设 --patience 20。
过拟合的典型信号是 train loss 一直降、val mAP 涨到峰值后回落,这时候回退到 best.pt 而不是继续等。best.pt 和 last.pt 都建议备份:last.pt 是训练中断或 NaN 事故里唯一的后悔药。
3.4 权重导出与跨数据集验证:别只在训练集上自嗨
python export.py \ --weights runs/bone_train/exp/best.pt \ --include torchscript onnx导出推荐用 best.pt 而不是 last.pt。TorchScript 方便在 C++ 或嵌入式侧复用,ONNX 是后面转 RKNN、TensorRT 的中间格式。导出完并不代表检测器能用,要做一次和训练集无关的跨数据集验证:准备 30 张左右没进过训练集的公开手骨片,跑一遍检测看框的稳定性和漏检。如果框在边界上飘,多半是预处理不统一或训练集对比度单一,回来做 CLAHE 增强比继续加 epoch 有效得多。做完这一步,项目文档里直接多出一块「泛化性验证」的内容。
4. 骨龄回归阶段:从检测框到年龄的最后一公里
检测框告诉你骨骺在哪,不代表你知道发育到几岁。骨龄的最终值由回归阶段给出,这一章把网络设计、训练代码和数据划分讲透。
4.1 为什么检测框不能直接当骨龄:回归网络在学什么
临床判读骨龄看的是成熟度标志:骨骺相对于干骺端的宽度比例、骨骺与干骺端之间间隙的宽窄、骨骺是否开始与骨干融合。这些特征要求对着局部区域看纹理和形状,检测网络的特征图是为定位和分类优化的,没有专门表达「发育程度」的信息,拿检测输出硬凑年龄误差会很大。
所以第二阶段要单独用一个回归网络:输入裁剪好的 ROI,输出月龄。评估骨龄回归最直接的指标是平均绝对误差 MAE,一般做到 12 个月以内就能在毕设里站住脚;临床级方案会再看误差小于 6 个月的样本占比。两阶段结构的另一个好处是工程维护:检测和回归可以单独换、单独调,互不干扰。
4.2 用 ResNet 预训练模型做回归:网络改造与训练代码
# 用 ResNet34 预训练权重改造为骨龄回归网络 import torch import torch.nn as nn import torchvision.models as models class BoneAgeHead(nn.Module): def __init__(self, num_bins=240): super().__init__() # 加载 ImageNet 预训练权重,低层纹理特征直接迁移 self.backbone = models.resnet34(weights=models.ResNet34_Weights.IMAGENET1K_V1) # 把 1000 类分类头替换成 240 个月的分桶 in_features = self.backbone.fc.in_features self.backbone.fc = nn.Sequential( nn.Linear(in_features, 256), nn.ReLU(inplace=True), nn.Dropout(0.3), # 样本量不大时防止过拟合 nn.Linear(256, num_bins) ) def forward(self, x): return self.backbone(x)逻辑说明:backbone 用 ResNet34 而不是更深的 50,因为输入只是 224×224 的局部骨骼 ROI,数据量不大,更深反而容易在 ROI 级过拟合。预训练权重的价值在低层纹理特征,全连接头全部重训。240 档对应 0 到 240 个月,一个月一档,粒度对骨龄够用。
# 训练时用分类损失,推理时用期望值得到连续年龄 age_logits = model(roi_batch) # 输出形状 [B, 240] loss = nn.CrossEntropyLoss()(age_logits, age_label) # age_label 是月龄整数档 probs = torch.softmax(age_logits, dim=1) # 转概率分布 age_months = (probs * torch.arange(240, device=probs.device)).sum(dim=1)推理时不取 argmax,而是对所有桶的概率求期望。这样就算模型在 48 个月和 49 个月两个桶之间犹豫,输出也是 48.x 个月的连续值,不会跳变。Dropout 取 0.3 是手骨数据量级下的常见经验值,不要因为用了预训练就删掉它。
4.3 损失函数、数据划分与 ROI 离线预提取
三个容易被忽略的细节。第一,分类损失会把相邻月份的惩罚看成一样大,如果觉得不够平滑,可以给标签做 label smoothing,或者像上面那样用软期望值绕开。第二,数据划分必须按病人 ID 分组,RSNA 数据里同一个人可能有多张片子,漏掉这一步验证指标会系统性虚高。第三,回归训练前先把检测跑一遍,把所有 ROI 裁剪结果离线存成图片,dataloader 直接读裁剪图,而不是每次迭代重新过检测网络,训练速度能快一个量级。
性别对骨龄有明显影响,RSNA 标签自带 male/female,常见做法是把性别编码后和 backbone 输出的特征拼接在一起,再进最后的分类头。加上这一路输入,通常能把误差压下去两三个月,属于性价比最高的改进,值得写进实验对比里。
5. 手骨骨龄检测训练避坑清单:5 个让我白跑一晚的问题
这一章按「现象 → 原因 → 解决」写,五条都是这个方向上出现频率最高的翻车点,每一条都是我实际踩过或者帮人排查过的问题。
5.1 训练一启动就报 no labels found,或 loss 全程不动
现象:train.py 跑起来立刻提示找不到 label,或者每个 epoch 的 box_loss 几乎不变化。原因:data.yaml 里 train/val 的路径和 labels 目录对不上;txt 文件名和图片名不同步;txt 里的坐标写到 0 到 1 范围外,被数据加载器当作无效目标整行丢弃。解决:先核对 images 与 labels 两棵目录树的文件名差异;再抽查三个 txt 的数值范围;最后把 txt 画回原图确认框的位置。定位数据问题比重下权重重要得多。
5.2 loss 中途变 NaN,或某个 epoch 突然爆高
现象:训练到 60 轮都正常,第 61 轮 loss 变成 inf/nan,之后权重像失忆一样乱跳。原因:数据里混入了宽或高为 0 的退化框,归一化坐标出现除零;也可能是批次里偶然出现全黑图,学习率偏大导致梯度直接冲飞。解决:在转换脚本里加一道过滤,宽或高小于 3 像素的框直接丢弃;把 lr0 临时降到原来的十分之一跑一个 epoch 看是否复现;确认数据增强链路没有把图像缩放成 0 尺寸。平时保留一份 loss 正常的 last.pt 备份,是这类事故里唯一的后悔药。
5.3 检测 mAP 很高,但骨龄误差大得离谱
现象:mAP@0.5 和 mAP@0.5:0.95 都很好看,端到端跑出来的年龄却比临床标注差两年以上。原因:两阶段误差叠加,检测框偏移几个像素,裁剪出来的 ROI 内容就变了,回归网络对同一块骨骺打出完全不同的分;回归训练和检测推理的预处理不一致也会放大误差。解决:把检测推理默认的置信度阈值从 0.25 提到 0.5 或更高,只留高置信度框进回归;所有 ROI 的预测年龄按置信度加权平均,不依赖单框;回归输入统一 224×224,和训练时完全一致。别被 mAP 蒙蔽,骨龄 MAE 才是这个项目的验收指标。
5.4 换一批测试图,检测框开始飘移
现象:自己手里的片子跑得很好,换一批不同设备拍的片子后,同一个骨骺有时框偏半个骨头。原因:不同设备的 X 光片在亮度、对比度和骨骼比例上差异很大;训练与推理的预处理不一致,比如训练用了长边等比缩放加 pad,推理却直接强制 resize 成方形。解决:把灰度化、CLAHE 对比度增强、等比缩放 pad 全部封装成同一个函数,检测阶段和回归阶段共用,训练集和测试集走同一个入口。这个改动在数据预处理里看起来不起眼,实际解决了我实战中六成以上的「玄学」漂移问题。
5.5 低龄样本漏检,把整体指标拉高
现象:整体 mAP 不错,但单独统计 5 岁以下样本时,桡骨远端漏检率明显偏高。原因:数据集年龄分布不均,低龄样本数量少;骨骺在低龄阶段体积小,640 分辨率下特征更弱。解决:按年龄段分层抽样补足低龄样本;显存允许时把 --img 提到 1280;确认 autoanchor 确实在跑。如果还漏,把多个类别合并成单类 epiphysis,让模型只负责「找到所有骨骺」,类别负担降低后小目标召回通常会明显改善。做课设时把这五条的排查过程和截图写进项目文档,比贴一张满分训练曲线更能让评委信服。
6. 端到端验证:把检测和回归串成一条可演示的命令
6.1 单命令跑通推理:检测 → 裁剪 → 回归 → 标注
# 端到端推理脚本的核心流程 img = preprocess(raw_path) # 灰度、CLAHE、等比 pad boxes = detect_yolo(img, conf=0.5) # 返回 N×5: x1,y1,x2,y2,score rois = crop_rois(img, boxes, out_size=224) # 加 10% 外扩后裁剪缩放 age_months = regress_avg(rois, gender) # 各 ROI 期望值加权平均 draw_boxes(img, boxes, f"bone age: {age_months:.1f} months") save_result(raw_path, age_months) # 保存标注图和年龄逻辑说明:第一行预处理必须和训练时完全一致,这是 5.4 节那个坑的答案;第二行置信度阈值就用 0.5,过滤低质量框;第三行裁剪时加 10% 外扩,保留骨骺周围的软组织信息;第四行把性别一起传入回归网络;最后保存标注图和年龄数据,方便和临床标注做差值对比。保存时把框坐标、置信度、每块骨骺的年龄分一起落盘,项目文档里直接能附三张图:检测结果、裁剪 ROI、最终标注。
6.2 演示视频录制:三步准备和一条剪辑原则
演示视频是标题里的标配交付物。准备 5 到 8 张覆盖不同年龄和性别的样本,第一步录纯检测,展示 YOLOv5 把骨骺框出来,说明每个框对应哪块骨骼;第二步切到端到端脚本,展示年龄输出和误差对比;第三步贴训练曲线和 MAE 数据,再放一到两张失败案例,比只报高精度更可信。录制用系统自带录屏或 ffmpeg 抓窗口都可以,时长控制在三分钟以内。
想再走一步工程化的话,可以在结尾提一句树莓派 4B 或 RK 系列板子上的 yolov5 量化部署思路,但毕设演示现场别讲太深,点到即可。我现在做这类项目的顺序永远是:先把「能演示的最小链路」跑通,再回头补实验和调参。顺序反过来的十有八九会把时间耗在一个说不清的黑匣子里。希望帮到你。
本文还有配套的精品资源,点击获取