基于YOLOv5的骨龄检测项目实战:从手腕骨检测到TW3评分
简介基于Python和YOLOv5的骨龄检测项目面向毕业设计、课程设计以及目标检测方向开发者尤其适合医学影像智能分析相关课题提供从模型训练到推理的完整代码框架。项目源码经过严格测试可以放心参考并在其基础上扩展二次开发。压缩包共25个文件其中20个Python脚本为核心覆盖数据加载、模型配置、训练验证、推理导出等流程另有YAML、TOML、TXT等配置和依赖文件便于还原运行环境Markdown文档则说明项目结构及使用方式。整体大小约95KB轻量精简便于快速阅读与上手目前已有107人学习资源涵盖源码、项目文档、数据模型、数据集与权重并采用工具模块和模型模块等目录化组织方便定位功能代码数据模型与权重可用于直接推理数据集也便于重新训练与验证。读者可根据说明文档和配置文件构建环境在骨龄检测场景中替换或微调模型也可借鉴其工程结构完成其他目标检测任务。1. 骨龄检测项目不玄乎yolov5检测手腕骨权重数据集一次到位骨龄检测在儿童生长发育评估里是刚需但传统TW3法要医生逐块读片一个医生一天看几十张片子眼睛和判断力都到极限。这份基于pythonyolov5的骨龄检测项目把目标检测用在左手腕X光片上先检测桡骨、尺骨、指骨等骨骺区域再按区域成熟度映射骨龄。它自带训练好的权重、划分好的数据集、预处理脚本和项目文档适合毕业设计、课程设计和想在项目里快速加检测能力的开发者。拿到手先跑通推理再改数据训练自己的模型。我建议上手前先花二十分钟把yolov5的检测流程捋一遍后面改哪里、怎么改心里才有数这也是我这次拆包最花时间的地方。2. 骨龄检测的任务拆解为什么用yolov5而不是一个分类网络2.1 骨龄评估的本质是先找区域、再评等级Tanner-Whitehouse法TW3法的核心是读左手腕X光片对桡骨远端、尺骨远端、第一到第五指骨、腕骨等骨骺区域逐块对照图谱把成熟度分成A到I等级每个等级对应分数最后加权算骨龄。传统做法里医生干的两件事就是找区域、定等级。找区域这件事对神经网络来说正是目标检测的活儿。如果直接把整张X光片丢给ResNet回归出一个年龄模型确实能“学出来”但你看不到它依据什么它可能同时把图像里的金属伪影、拍摄条件当成特征换一台DR设备表现就崩。yolov5至少先把“哪些区域参与了判断”画出来——框住骨骺区域医生看到框就知道模型在看哪后面再挂评分模块。这种先检测后评分的两段式结构是骨龄方向的主流做法答辩时也站得住。再说网络结构yolov5的backbone用CSP结构提取特征neck用PANet做多尺度融合head输出三个尺度的检测结果。手腕骨里指骨很小、桡骨很大这种尺度差异正好需要多尺度head去覆盖小目标分支负责近节指骨大目标分支负责桡骨远端。再加上yolov5生态成熟资料多改起来有据可依这就是我推荐直接用yolov5而不是YOLOX或纯分割网络的原因。还有一点值得注意yolov5自带的anchor先验是从COCO数据集聚类出来的COCO里大多数目标是日常物体长宽比和骨骺差别很大。不过这个项目的训练流程里用到了autoanchor也就是train.py启动时会重新对训练集标签做k-means聚类生成更适合骨骺尺度的锚框后面第四章我再展开讲。2.2 项目文件逐一拆解这份yolov5改了什么拿到压缩包后先别急着跑按下面这张表把文件对应到流程里后面出问题才知道去哪翻。文件/目录在流程里干什么什么时候需要改utils/loss.py定义损失计算yolov5的boxobjcls三项loss训练时最常改utils/autoanchor.pyk-means聚类锚框换数据集自动运行utils/augmentations.py在线数据增强灰度图要关色彩抖动utils/dataloaders.py加载数据集、缓存图片报错高发区models/yolo.pyDetectionModel定义、前向推理组装一般不动models/common.pyC3、SPPF等基础模块改backbone时才动Bone-pre.py数据预处理入口转标注格式用export.py导出ONNX/TorchScript部署用.streamlit/config.tomlstreamlit界面主题配置不影响检测逻辑utils里最值得先看的是loss.py和augmentations.py。loss.py决定梯度怎么回传如果你觉得模型对某个区域总学不好多半要动这里的类别权重augmentations.py决定数据增强是否适合医学灰度图X光片没有颜色信息默认带HSV色彩抖动的增强方案就要关掉。models/yolo.py的ModuleDict里可以看到模型的组装顺序配合官方yolov5网络结构图对照看能快速定位你想改的模块在哪一层。Bone-pre.py是作者写的预处理入口功能是把原始X光数据整理成yolov5能吃的目录格式常见做法是统一改名、裁剪、归一化。还有一个容易被忽略的文件是.github/workflows很多派生仓库保留CI工作流文件内容一般是自动化测试和release构建和本地跑项目没关系删掉也行。README.md里写了数据集来源、权重复现说明、使用方法遇到问题先翻它别急着改代码。2.3 从X光片到骨龄分数整条数据流整个流程可以拆成四段。第一段是输入左手腕X光片灰度图分辨率通常在一千五百乘两千以上。第二段是预处理Bone-pre.py把图片统一尺寸、裁剪、归一化尽量让腕骨区域在画面中央否则原图直接进网络显存根本扛不住。第三段是检测yolov5模型输出多个框每个框带类别、置信度、坐标类别代表这个框属于哪个骨骺区域。第四段是评分后处理脚本把检测结果按区域类别聚合查TW3评分表或加权算出骨龄估计值。yolov5本身不负责“年纪”预测负责的是第三段。很多初次接触的人以为换个训练数据集、把标注改成“年龄”模型就能回归出年龄不对。目标检测的数据集标注是“框类别”类别是区域编号不是数值标签骨龄数字来自评分层。理解这一点你就明白为什么权重文件里没有“年龄回归头”也明白换数据集时该保留什么标注。这个思路也解释了第四章的一个建议类别别用年龄。如果指导老师说“你加一个回归分支直接出年龄”技术上可以做在head后面加回归头但你得同时准备数值标签数据标注量完全不同。这个项目里用的是先检测后查表的方式改动最小、解释性最好也是我建议你沿用的方案。3. 把项目跑起来环境、权重、数据三步到位3.1 conda创建虚拟环境与依赖安装先用conda隔离环境避免污染系统Python。yolov5这套代码是在torch 1.10到1.13时代写的python 3.8和3.9对应最省心直接用系统python 3.11往上torchvision版本对不上import就报错。这里是我反复踩过的血泪经验不要在这上面浪费时间。# 创建独立Python环境并激活 conda create -n bone python3.8 -y conda activate bone # 进入项目根目录后安装依赖 cd Bone_Age_Predict pip install -r requirements.txt依赖装完先验证torch能不能调用GPU这一步能省掉后面一半的玄学问题python -c import torch; print(torch.__version__, torch.cuda.is_available())如果输出里cuda.is_available()是False先别怀疑代码大概率是torch版本和显卡驱动不匹配。常见做法是到pytorch官网按你的CUDA版本重新装torch再回来跑上面的命令。如果你的机器没有NVIDIA显卡CPU跑推理也够用只是训练会慢很多batch-size要调小。3.2 确认目录和权重、数据集的落位跑通项目之前先确认三样东西在不在权重文件、数据集目录、入口脚本。用ls检查根目录结构重点看权重文件放在哪个目录通常叫best.pt或yolov5s.pt可能放在weights/下也可能直接在根目录。入口脚本里weights参数写的是什么路径就得让文件待在哪个路径这是最蠢也最常见的启动失败原因。ls -la # 确认是否存在 weights/best.pt 或根目录下的 .pt 文件 find . -name *.pt -maxdepth 3查数据集的路径同理。默认训练好的数据集一般放在datasets/下用find检查images和labels目录是否存在。best.pt是训练过程中val loss最低的那个checkpoint推理优先选它last.pt是最后一个epoch的存档复现训练过程才用到。如果你只想跑演示认准best.pt就够了。很多人在这一步拿着只有模型没有数据的压缩包折腾半天结果发现是文件没放全。3.3 用streamlit打开Web检测界面这个项目根目录里放了一个.streamlit/config.toml说明作者用streamlit做了Web展示层。streamlit的好处是不用写前端页面自动生成毕设演示时老师不用敲命令直接浏览器里传图看结果。启动命令的一般写法是streamlit run app.py --server.port 8501根目录的入口脚本名以实际仓库为准常见叫app.py或web.py找到带streamlit调用和title配置的那个.py文件就是入口。启动后浏览器打开http://localhost:8501上传一张左手腕X光片就能看到检测结果页面会把检测框画在图上附带置信度和骨龄估计。config.toml里一般是主题配置类似[theme] primaryColor #4CAF50 font sans serif这两行只影响按钮、背景的主题色不参与任何检测逻辑。如果你改了不生效刷新页面如果页面布局乱了八成是streamlit版本和config.toml里写的theme字段不兼容升级或降级streamlit到对应版本就行不用改代码。3.4 走一次命令行推理Web界面方便演示但调试问题还是命令行直接日志清晰。yolov5派生仓库一般保留官方detect.py入口这个包对官方代码做了瘦身如果根目录没有detect.py就用作者自定义的推理脚本参数传法一致python detect.py --weights weights/best.pt --source ./data/test/001.png --img 640 --conf-thres 0.25--weights指定权重路径--source可以指向单张图、一个文件夹或摄像头设备号--img要和你训练时一致yolov5会在输入时把图缩放到这个分辨率--conf-thres是置信度阈值调低会出很多假框调高容易漏掉小骨骺。第一次跑通时控制台会逐张打印检测结果和耗时save目录下生成带框的图。如果一张X光片上只检测出一两个框或者完全没有框先不要怀疑模型坏了按这个顺序排查权重路径有没有写对、图片有没有被resize到正常范围、置信度阈值是不是太高。这三条查完还没解决直接看第五章的坑前两个坑就是针对这类现象写的。4. 训练自己的数据集换骨龄库、换部位的关键参数4.1 标注格式与目录组织yolov5要求的数据集目录结构固定images和labels一一对应同名文件匹配不要手动复制粘贴这个结构决定了dataloaders.py能不能正常找到每一张图dataset/ images/ train/ val/ labels/ train/ val/images里放一张left_001.pnglabels里就要有对应的left_001.txttxt每行格式为class x_center y_center width height五个值都是相对坐标范围0到1。之所以要求相对坐标是因为输入图缩放后坐标不用重算。很多拿公开骨龄数据集的人原始标注是CSV或XML里面只有骨龄值没有框这时就要用Bone-pre.py把标注转成txt。一个常见的做法是先用预训练模型出一批伪标签再人工修正把框转成txt的脚本逻辑就在Bone-pre.py里转换前先打印几行看看格式别直接跑全量。4.2 写data.yaml之前先理清目标和类别data.yaml是训练配置的入口路径错了训练直接报错内容不多但每个字段都有讲究path: ./dataset train: images/train val: images/val nc: 5 names: [radius, ulna, metacarpal, proximal_phalanx, carpal]path是数据集根目录train和val是相对path的子目录nc是类别数names列表的顺序必须和标注txt里的class id一一对应顺序错位会导致模型把桡骨当成尺骨训练。类别应该按照骨骺区域定义不要图省事把class直接设为“年龄”。如果把类别设成年龄模型会把不同年龄但形态相近的骨头混成一类框都学不准。骨龄检测的类别就是区域名年龄信息放在后处理评分阶段这是我最想强调的一点。4.3 训练命令与超参数调优训练时用COCO预训练权重做迁移学习收敛速度比随机初始化快很多。基础训练命令python train.py --data dataset/data.yaml --weights yolov5s.pt \ --img 640 --batch-size 16 --epochs 100 \ --hyp data/hyps/hyp.scratch-low.yaml--img是输入分辨率骨龄X光片细节多有条件可以提到1280但显存占用会翻好几倍8G显存建议640起。--batch-size按显存调8G卡建议8到16之间。--hyp指定超参数文件yolov5把超参数收敛在hyp文件里而不是散落在代码里方便复盘。骨龄场景建议改两个地方一是把hsv_h和hsv_s这两项色彩增强降到最低X光是灰度图颜色抖动只会引入噪声二是degrees旋转幅度别开大骨骺旋转后形态会失真影响等级判断。这两个参数就在hyp文件里改完重跑训练即可。训练启动时留意日志里有没有AutoAnchor的k-means聚类输出如果数据里指骨很小、桡骨很大自动聚类能明显提升召回率想关掉就加--noautoanchor参数。4.4 从训练日志判断过拟合与欠拟合训练时盯着两个东西看train和val的loss曲线以及val的mAP0.5。如果train loss一直降、val loss开始回升就是过拟合骨龄公开数据集往往只有几千张图过拟合非常常见。缓解手段按优先级排列调大数据增强、降低模型复杂度yolov5s换成yolov5n、早停参数patience调小。如果mAP0.5一直上不去先看是不是类别不平衡指骨样本远多于桡骨可以在loss.py里调整cls损失权重给样本少的区域更高权重。训练完不要只看总mAP用val.py跑一遍验证集看per-class的mAP哪个区域最差就针对性补哪个区域的数据。这一步很多人跳过结果答辩时被问“你这个模型哪类检测最差”答不上来。per-class结果是一张表打印出来存好写进论文里也是一个像样的实验分析。5. 避坑与排查骨龄检测最容易翻车的五个坑5.1 现象训练loss正常下降但模型一个骨骺框都检测不到loss曲线很漂亮mAP却一直是0检测结果全空白。这个现象我第一次遇到时也懵了后来发现原因在于loss是box、obj、cls三个分支的总和总loss下降不代表每个分支都收敛了。obj分支没收敛说明锚框尺寸和目标的真实尺寸严重不匹配先验框还是COCO的通用尺度对细长的骨骺完全不适用。解决方法是确认训练日志里有没有AutoAnchor的输出。如果日志里没有k-means重聚类的结果说明autoanchor被关掉了或者没跑成去掉--noautoanchor参数重新跑。要是聚类结果还是不理想手动在hyp文件里按数据集的框尺寸分布设置初始锚框。这个问题排查路径很固定先看日志再改参数不用动网络结构。5.2 现象训练或推理时显存OOM程序直接崩X光原图分辨率动辄2000以上--img默认640没问题一旦设成12808G显存直接爆。这种情况的典型报错是CUDA out of memory有时还会把机器整个卡死只能重启。原因是模型输入尺寸乘了平方关系640变1280显存占用翻四倍不是翻一倍。解决思路有两个方向。一是把--img降到640batch-size降到4同时启用amp混合精度训练显存能省将近一半。二是从源头处理用Bone-pre.py先把X光长边压到1280以内再做数据增强而不是让yolov5在dataloader里直接缩放。我一般两个方向一起做长边压到1280--img设640这样既保留细节又不爆显存。5.3 现象streamlit页面能打开但上传X光片后一直转圈不出结果Web界面正常显示一上传图片就卡住控制台也没有报错看起来像死循环。这个问题的原因一般是两层一是Web入口调用的推理函数里写的权重路径是相对路径streamlit运行时的工作目录和命令行不一样找不到权重文件二是入口脚本里模型输入尺寸和权重训练尺寸不一致模型前向推理时静默失败。解决时先用命令行跑一遍同一张图确认权重本身没问题再改Web层。把权重路径换成绝对路径推理函数里先打印输入张量的shape确认和训练时一致。凡是streamlit这类Web框架包检测代码的我都建议把推理逻辑单独封装成一个函数Web只做调用这样排查时直接在命令行调函数不用反复开页面。5.4 现象val集mAP很高实际对正常X光片预测的骨龄误差却很大验证集指标漂亮得不行一上真实图片就露馅。这个坑我见过不止一次原因是数据划分时按文件名单纯随机切分同一个病人的多张片子同时出现在训练集和验证集里模型记住的是病人特征而不是骨龄特征这在医学影像里叫数据泄漏。公共数据集经常一个病人拍了两三次文件名只按顺序编号不按病人分组。解决方法是按病人ID分组划分数据集保证同一个人的所有片子只出现在一个集合里。拿到数据先看有没有病人ID这一列有就先groupby再切分。这个细节在答辩时主动讲出来老师会认为你真处理过医疗数据而不是只会跑通一个demo。5.5 现象Bone-pre.py转换标注时报KeyError或转换后某些类别的txt是空的预处理脚本跑到一半报KeyError检查生成结果发现有些类别的txt文件一个框都没有。原因基本可以锁定在标注名称没有完全映射到类别字典上常见的情况有几种左右手没区分原始数据里left和right的标注都在腕骨分块过细一个腕骨有多个骨化中心每个都被单独标注了。这些名称没有进到你的类别映射表脚本就报KeyError或者直接跳过。解决方法是转换前先写一段代码把所有标注名称去重打印出来逐一核对映射字典把不需要参与评分的区域注释掉。这个步骤不能省我一般会先跑一遍统计脚本输出每个标注名的样本数量分布再决定哪些保留、哪些合并。还有一个习惯转换完成后随机抽三张图把txt标注可视化画在图上人工检查一遍这一步能挡掉绝大多数标注错位问题。6. 进阶用法导出ONNX模型用CPU跑批量推理6.1 用export.py导出ONNX项目根目录里有export.py这是yolov5官方的导出脚本把PyTorch权重转成ONNX格式导出后可以不依赖PyTorch环境推理python export.py --weights weights/best.pt --img 640 --include onnx导出时--img必须和训练时一致否则模型输入输出尺寸对不上推理结果会离谱。ONNX模型可以用onnxruntime加载纯CPU也能跑单张图在几百毫秒级别对一个检测任务来说完全够用。导出成功后根目录会生成best.onnx文件比.pt小不少发给别人也方便。6.2 检测结果到骨龄分数的映射ONNX输出的原始结果是检测框坐标加上置信度和类别要得到骨龄分数还得做一层映射。下面这段示意代码展示了yolo输出和骨龄评分的衔接逻辑def bone_age_from_boxes(boxes, region_weights): total_score 0.0 regions_used 0 for box in boxes: cls_id int(box[5]) conf float(box[4]) if conf 0.5: continue # 每个区域按成熟等级加权权重来自TW3评分表 total_score region_weights.get(cls_id, 0) regions_used 1 if regions_used 0: return None return total_score / regions_usedyolo的每个框是一个数组第4列是置信度第5列是类别id这个切片顺序很多人写错导致把类别当置信度过滤。实际项目里region_weights会单独存在一个py或json文件里按区域类别编号查表这里只展示思路。6.3 我的工程习惯自从有一次交付后对方问我“为什么这次跑出来的结果和上次不一样”我开始意识到yolov5推理本身是确定的不确定的是预处理。从那以后我每次交付检测类项目都强制自己走一遍固定流程干净环境里跑通一次、导出ONNX、固定输入尺寸和归一化参数再把推理结果落盘成CSV方便回看分析。骨龄检测这种医疗场景更要留痕每个框的坐标、置信度、类别都记录下来回查时有据可依。你自己上手时也建议从第一天就养成这个习惯后面省的事远不止翻半天日志。希望帮到你。本文还有配套的精品资源点击获取