简介本资源面向计算机视觉方向的本科与研究生毕业设计需求提供一套基于YOLOv5与ResNet18联合实现的骨龄检测完整工程适合需要完成高分毕设、课程设计或医学影像入门实践的同学。项目将目标检测与图像分类串联先定位手骨关键区域再回归骨龄等级可作为深度学习落地案例参考。压缩包共200个文件约717.06MB包含69个Python源码、53个YAML配置、18个PTH权重文件以及Dockerfile、Shell脚本、Markdown说明与Jupyter笔记等覆盖训练、推理、部署与文档环节。目前已有438人学习下载。资源内含已调试通过的代码、预训练模型与配套数据集读者可据此复现实验流程、理解网络结构与数据组织方式并在此基础上调整超参或替换骨干网络快速形成可展示、可答辩的毕设成果。1. 骨龄检测这套双模型方案到底解决了什么实际问题拍一张左手腕 X 光片医生要对着 TW3 或 CHN 骨龄标准逐块腕骨比对数出 20 多个骨化中心的发育等级再查表折算成骨龄。一个有经验的放射科医生看一张片子要 3 到 5 分钟基层医院一天积压几十张就是灾难。更麻烦的是不同医生判读一致性差同一张片子两个人给出的骨龄可能差半岁到一岁。基于 yolov5 ResNet18 实现的骨龄检测方案思路就是把这件事拆成两段yolov5 负责在整张手腕片里把感兴趣区域ROI框出来ResNet18 负责对每个区域做分类或回归最后把各区域结果融合成骨龄值。这套组合之所以在毕设和工程落地里反复出现是因为它把目标检测和图像分类两个成熟模块拼在一起数据集要求比端到端回归低训练也更容易收敛。适合谁手里有骨龄 X 光数据集、想快速跑通一条检测加分类流水线的同学和工程师尤其是需要一份能复现、能改、能写进论文的完整代码加模型加数据集的场景。下面按数据准备、yolov5 检测、ResNet18 分类、融合出骨龄、避坑、进阶验证的顺序讲透。2. 数据集怎么整理从原始 X 光片到 yolov5 与 ResNet18 双输入2.1 骨龄数据集的两套标注体系骨龄数据集的标注和普通目标检测数据集不一样。普通检测只要框出物体骨龄还要给每个骨化中心打发育等级。常见做法是两套标注并行一套是 yolov5 用的边界框标注每个腕骨、掌骨、指骨一个框类别可以按骨骼名称分也可以统一成“骨化中心”一类另一套是 ResNet18 用的分类标签每个框裁剪出来的小图对应一个发育等级比如 0 到 8 级或者对应的骨龄贡献值。如果原始数据集只有整片骨龄值没有逐骨标注那就只能走整图回归路线ResNet18 直接吃整张片但精度和可解释性都会掉一截。我一般会先确认数据集里有没有逐骨标注文件有的话优先做双模型没有就退化成单模型回归。整理时先把所有 X 光片统一成灰度图位深 8 位或 16 位都行但训练前要归一化到 0 到 1。分辨率不要无脑放大手腕片本身细节有限短边保持在 512 到 768 之间比较稳太大显存吃不消太小骨化中心糊成一团。文件名建议保留患者 ID 和左右手信息后面做数据划分时按患者划分不能按图片随机划分否则同一个人不同角度的片子会同时出现在训练集和验证集指标虚高。2.2 用脚本把标注转成 yolov5 和 ResNet18 都能吃的格式假设原始标注是 CSV每行是image_name, x_min, y_min, x_max, y_max, class_id, grade下面这个脚本把它拆成 yolov5 的 txt 标签和 ResNet18 的分类文件夹结构。import os import csv import cv2 import numpy as np from sklearn.model_selection import train_test_split # 原始数据路径 img_dir raw_images csv_path annotations.csv out_yolo yolo_dataset out_cls cls_dataset os.makedirs(f{out_yolo}/images/train, exist_okTrue) os.makedirs(f{out_yolo}/images/val, exist_okTrue) os.makedirs(f{out_yolo}/labels/train, exist_okTrue) os.makedirs(f{out_yolo}/labels/val, exist_okTrue) records [] with open(csv_path, r) as f: reader csv.DictReader(f) for row in reader: records.append(row) # 按患者 ID 划分这里假设 image_name 前缀是患者 ID patient_ids list(set([r[image_name].split(_)[0] for r in records])) train_ids, val_ids train_test_split(patient_ids, test_size0.2, random_state42) def convert(size, box): dw, dh 1.0 / size[0], 1.0 / size[1] x (box[0] box[1]) / 2.0 y (box[2] box[3]) / 2.0 w box[1] - box[0] h box[3] - box[2] return x * dw, y * dh, w * dw, h * dh for r in records: img_name r[image_name] pid img_name.split(_)[0] split train if pid in train_ids else val img_path os.path.join(img_dir, img_name) img cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) if img is None: continue h, w img.shape # 写 yolov5 标签 x, y, bw, bh convert((w, h), (float(r[x_min]), float(r[x_max]), float(r[y_min]), float(r[y_max]))) label_path os.path.join(out_yolo, labels, split, img_name.replace(.png, .txt)) with open(label_path, a) as lf: lf.write(f{r[class_id]} {x:.6f} {y:.6f} {bw:.6f} {bh:.6f}\n) # 复制图片到 yolo 目录 cv2.imwrite(os.path.join(out_yolo, images, split, img_name), img) # 裁剪 ROI 到分类数据集 roi img[int(r[y_min]):int(r[y_max]), int(r[x_min]):int(r[x_max])] if roi.size 0: continue cls_dir os.path.join(out_cls, split, str(r[grade])) os.makedirs(cls_dir, exist_okTrue) cv2.imwrite(os.path.join(cls_dir, img_name.replace(.png, f_{r[class_id]}.png)), roi)这段脚本做了三件事按患者划分训练验证集、生成 yolov5 需要的归一化中心点加宽高标签、把每个标注框裁剪成小图按发育等级存到分类文件夹。参数上注意test_size0.2是验证集比例患者数量少的时候可以调到 0.15 到 0.25 之间class_id如果统一成 0yolov5 就只学“骨化中心”一个类后面 ResNet18 再细分等级这样检测任务更简单。裁剪 ROI 时如果框贴边roi.size可能为 0脚本里加了跳过实际跑的时候要检查有多少被跳过超过 5% 就得回头看标注是不是越界了。2.3 数据增强的边界骨龄片不能乱翻转骨龄片有左右手之分也有解剖方向。水平翻转会把左手变成右手如果模型没见过对侧手翻转后语义就错了。垂直翻转更不行手腕倒过来解剖结构完全不对。我一般只做小角度旋转正负 10 度以内、亮度对比度微调、轻微缩放和平移。yolov5 自带的 mosaic 增强在骨龄片上要慎用四张图拼一起会让骨化中心上下文混乱mosaic 概率建议从默认 1.0 降到 0.3 到 0.5。ResNet18 那边的分类增强同样只做温和的仿射变换不要用 RandAugment 里那些剪切和颜色抖动灰度片颜色抖动没意义。3. yolov5 检测骨化中心环境配置与训练参数怎么定3.1 环境配置与最小可跑命令yolov5 对环境不算挑剔但版本要对齐。常见做法是 conda 建一个 Python 3.8 到 3.10 的环境PyTorch 选 1.12 到 2.0 之间CUDA 版本跟显卡驱动匹配。下面是一套我常用的安装流程。conda create -n bone_age python3.9 -y conda activate bone_age # 按自己 CUDA 版本装 torch这里以 cu118 为例 pip install torch2.0.1 torchvision0.15.2 --index-url https://download.pytorch.org/whl/cu118 git clone https://github.com/ultralytics/yolov5.git cd yolov5 pip install -r requirements.txt装完先跑一次python detect.py --source data/images --weights yolov5s.pt确认环境没问题。然后准备数据配置文件bone_age.yamlpath: ../yolo_dataset train: images/train val: images/val nc: 1 names: [ossification_center]nc是类别数如果按骨骼名称分类就改成实际类别数names对应写全。数据路径用相对路径时注意path是相对于 yolov5 根目录还是 yaml 文件所在目录不同版本行为有差异跑之前用python train.py --data bone_age.yaml --dry-run之类的检查一下或者直接看打印出来的数据集统计。3.2 训练参数骨龄检测的 batch、学习率和 anchor骨龄片数量通常不大几千张算多的。yolov5s 或 yolov5m 足够n 太小精度不够l 和 x 容易过拟合。启动训练python train.py \ --data bone_age.yaml \ --weights yolov5s.pt \ --img 640 \ --batch 16 \ --epochs 150 \ --lr0 0.01 \ --lrf 0.01 \ --mosaic 0.3 \ --degrees 10 \ --flipud 0.0 \ --fliplr 0.0 \ --project runs/bone_age \ --name exp1参数逐个说--img 640是输入分辨率骨化中心小的话可以提到 768但显存翻倍--batch 16在 8G 显存上跑 640 分辨率差不多显存小就降到 8 并开--accumulate--lr0 0.01是初始学习率小数据集可以降到 0.005 到 0.008--lrf 0.01是最终学习率系数配合余弦退火--mosaic 0.3降低拼接概率--degrees 10限制旋转角度--flipud 0.0和--fliplr 0.0关掉上下和左右翻转骨龄片不能翻。anchor 方面yolov5 默认 anchor 是基于 COCO 的骨化中心尺寸偏小且集中建议用python utils/autanchor.py在骨龄数据集上重新聚类一遍把 anchor 换成适合小目标的尺寸这一步对召回率提升很明显。训练过程中重点看metrics/mAP_0.5和metrics/mAP_0.5:0.95骨龄检测更关心高 IoU 下的精度因为框不准后面分类裁剪就会带进背景。如果 mAP 卡在 0.6 上不去先检查标注框是不是把整个手腕都框进去了骨化中心应该只框骨化中心本身不要框太大。另外val集 loss 如果比train高很多说明过拟合加 dropout 或者减模型规模。3.3 推理与 ROI 导出训练完用best.pt跑推理把每个框裁剪出来给 ResNet18。下面这段代码批量导出 ROI 并保留坐标信息。import torch import cv2 import os import numpy as np model torch.hub.load(ultralytics/yolov5, custom, pathruns/bone_age/exp1/weights/best.pt) model.conf 0.25 # 置信度阈值 model.iou 0.45 # NMS IoU 阈值 img_dir yolo_dataset/images/val save_dir roi_output os.makedirs(save_dir, exist_okTrue) for img_name in os.listdir(img_dir): img_path os.path.join(img_dir, img_name) img cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) results model(img_path, size640) boxes results.xyxy[0].cpu().numpy() # x1,y1,x2,y2,conf,cls for i, box in enumerate(boxes): x1, y1, x2, y2 map(int, box[:4]) roi img[max(0, y1):y2, max(0, x1):x2] if roi.size 0: continue cv2.imwrite(os.path.join(save_dir, f{img_name}_{i}.png), roi) # 保存坐标供后续融合使用 np.save(os.path.join(save_dir, f{img_name}_boxes.npy), boxes)conf0.25是置信度阈值骨龄检测宁可多留几个框也别漏后面 ResNet18 可以靠分类置信度再筛。iou0.45是 NMS 阈值骨化中心挨得近的时候可以调到 0.5 到 0.6避免把相邻骨骼框合并掉。导出的 ROI 文件名带原图名和序号坐标存成 npy后面融合骨龄时要用坐标还原位置关系。4. ResNet18 分类发育等级改造、训练与骨龄融合4.1 把 ResNet18 改成骨龄等级分类器ResNet18 原版是 1000 类 ImageNet 分类骨龄等级通常 8 到 10 类改最后一层全连接就行。输入通道如果是灰度图第一层卷积也要改成单通道。下面是用 torchvision 改模型的代码。import torch import torch.nn as nn from torchvision import models def build_resnet18(num_classes9, in_channels1): model models.resnet18(pretrainedTrue) # 改第一层卷积为单通道 if in_channels ! 3: old_conv model.conv1 model.conv1 nn.Conv2d(in_channels, old_conv.out_channels, kernel_sizeold_conv.kernel_size, strideold_conv.stride, paddingold_conv.padding, biasFalse) # 用原三通道权重均值初始化单通道 with torch.no_grad(): model.conv1.weight[:] old_conv.weight.mean(dim1, keepdimTrue) # 改全连接层 model.fc nn.Linear(model.fc.in_features, num_classes) return model model build_resnet18(num_classes9, in_channels1)num_classes9对应发育等级 0 到 8实际按数据集等级数改。第一层卷积用原三通道权重均值初始化比随机初始化收敛快。如果数据集够大也可以把pretrained设成 False 从头训但骨龄数据通常没那么多还是用预训练权重稳。4.2 分类训练学习率、类别不平衡和早停骨龄等级分布天然不平衡低等级和高等级样本少中间等级多。训练时用带权重的交叉熵权重按类别频率倒数算。下面是一段训练循环。import torch from torch.utils.data import DataLoader from torchvision import transforms, datasets from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR train_tf transforms.Compose([ transforms.Grayscale(num_output_channels1), transforms.Resize((224, 224)), transforms.RandomRotation(10), transforms.RandomAffine(degrees0, translate(0.05, 0.05)), transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]) ]) val_tf transforms.Compose([ transforms.Grayscale(num_output_channels1), transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]) ]) train_ds datasets.ImageFolder(cls_dataset/train, transformtrain_tf) val_ds datasets.ImageFolder(cls_dataset/val, transformval_tf) train_loader DataLoader(train_ds, batch_size32, shuffleTrue, num_workers4) val_loader DataLoader(val_ds, batch_size32, shuffleFalse, num_workers4) # 类别权重 class_counts [0] * len(train_ds.classes) for _, label in train_ds.samples: class_counts[label] 1 weights torch.tensor([1.0 / c for c in class_counts], dtypetorch.float32) weights weights / weights.sum() * len(class_counts) criterion torch.nn.CrossEntropyLoss(weightweights) device torch.device(cuda if torch.cuda.is_available() else cpu) model build_resnet18(num_classeslen(train_ds.classes), in_channels1).to(device) optimizer AdamW(model.parameters(), lr1e-4, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max50) best_acc 0.0 patience 10 no_improve 0 for epoch in range(80): model.train() for imgs, labels in train_loader: imgs, labels imgs.to(device), labels.to(device) optimizer.zero_grad() loss criterion(model(imgs), labels) loss.backward() optimizer.step() scheduler.step() # 验证 model.eval() correct, total 0, 0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels imgs.to(device), labels.to(device) pred model(imgs).argmax(dim1) correct (pred labels).sum().item() total labels.size(0) acc correct / total if acc best_acc: best_acc acc torch.save(model.state_dict(), resnet18_bone_best.pth) no_improve 0 else: no_improve 1 if no_improve patience: breaklr1e-4是微调预训练模型的常用值从头训可以到 1e-3。weight_decay1e-4防过拟合。类别权重那段把每个类权重设成频率倒数再归一化缓解不平衡。早停patience10表示验证精度 10 轮不升就停。验证指标除了 accuracy最好再看混淆矩阵骨龄等级相邻的类容易混比如 3 级和 4 级如果混淆严重说明 ROI 裁剪质量不够或者等级定义本身边界模糊。4.3 从等级到骨龄融合策略与坐标还原ResNet18 输出的是每个 ROI 的等级概率最终骨龄要把这些等级按骨骼权重和位置关系融合。常见做法有两种一种是查表法每个等级对应一个骨龄贡献分所有 ROI 的贡献分求和再映射到骨龄另一种是回归法把 ResNet18 最后一层改成回归输出骨龄值直接对每个 ROI 预测骨龄再平均。查表法可解释性强适合毕设写论文回归法端到端简单但需要逐 ROI 的骨龄标签很多数据集没有。我一般先用查表法。import numpy as np import torch # 假设每个等级对应的骨龄贡献分按 TW3 简化表 grade_to_score {0: 0.0, 1: 0.3, 2: 0.6, 3: 0.9, 4: 1.2, 5: 1.5, 6: 1.8, 7: 2.1, 8: 2.4} def fuse_bone_age(roi_probs, boxes, grade_to_score): total_score 0.0 count 0 for probs, box in zip(roi_probs, boxes): grade int(np.argmax(probs)) conf float(np.max(probs)) if conf 0.5: # 低置信度 ROI 不参与融合 continue total_score grade_to_score.get(grade, 0.0) count 1 if count 0: return None # 简单线性映射实际要用标准表拟合 bone_age total_score * 1.5 2.0 return bone_ageconf 0.5过滤掉分类没把握的 ROI避免拉偏结果。grade_to_score和最后的线性映射系数要用训练集上的真实骨龄拟合不能拍脑袋定。拟合方法可以用最小二乘把每个样本的total_score和真实骨龄做线性回归得到斜率和截距。如果数据集有逐骨骨龄标准直接查表更准。5. 避坑与排查骨龄双模型落地最容易翻车的五件事5.1 检测框把整个手腕框进去分类全乱现象yolov5 训练 mAP 看着不低但 ResNet18 分类精度死活上不去混淆矩阵里所有等级混在一起。原因标注时图省事把整个手腕或者一大片区域框成一个目标ROI 裁剪出来包含大量背景和无关骨骼分类器学不到局部特征。解决重新检查标注骨化中心框应该紧贴骨化中心边缘框的面积不超过骨化中心本身的两倍。用脚本统计每个框的宽高比和面积分布异常大的框挑出来人工复核。5.2 按图片随机划分导致指标虚高现象验证集准确率 95%换一批新数据掉到 60%。原因同一个患者的多张片子被分到训练和验证两边模型记住了患者特征而不是骨龄特征。解决按患者 ID 划分确保同一个人的所有片子只出现在一边。如果数据集没给患者 ID用图片文件名前缀或者采集时间聚类来近似划分。5.3 灰度图送进三通道 ResNet18 导致第一层学废现象ResNet18 训练 loss 不降准确率随机水平。原因直接把单通道灰度图复制成三通道送进预训练模型第一层卷积权重是按 RGB 自然图像学的灰度复制后三通道完全相同梯度更新方向不对。解决按 4.1 节把第一层改成单通道并用原权重均值初始化或者把灰度图用伪彩色映射成三通道再送进去但后者多一步预处理不如改网络直接。5.4 骨龄融合系数拍脑袋定结果整体偏移现象每个 ROI 分类都对但最终骨龄比真实值系统性高或低。原因grade_to_score和线性映射系数没有用真实骨龄拟合直接抄了别的数据集或者凭感觉设。解决在训练集上收集每个样本的total_score和真实骨龄用numpy.polyfit做一次线性回归把斜率和截距写回融合函数。验证集上再看 MAE如果 MAE 大于 0.8 岁检查是不是某些等级样本太少导致分类器偏。5.5 推理时 ROI 坐标越界导致裁剪为空现象推理脚本报错或者某些图片没有输出 ROI。原因yolov5 输出的框坐标可能超出图片边界尤其是图片边缘的骨化中心x2大于图片宽度或者y1小于 0。解决裁剪前做坐标裁剪x1 max(0, x1)x2 min(w, x2)y1 max(0, y1)y2 min(h, y2)并且判断x2 x1和y2 y1再裁剪。这个坑很小但很常见血泪经验是每次换数据集都要重新检查一遍。6. 进阶验证用 MAE 和 Bland-Altman 图判断这套方案能不能真用训练完模型准确率、mAP 这些指标只能说明分类和检测本身还行骨龄检测最终要看预测骨龄和真实骨龄的偏差。我一般会算三个指标MAE平均绝对误差、RMSE均方根误差、以及 Bland-Altman 一致性分析。MAE 反映平均偏差RMSE 对大误差更敏感Bland-Altman 图能看出系统偏差和一致性界限。骨龄临床上通常要求 MAE 在 0.5 岁以内算可用0.5 到 0.8 岁之间算参考超过 0.8 岁基本不能直接用于诊断。验证代码不复杂把验证集每个样本的预测骨龄和真实骨龄收集起来import numpy as np import matplotlib.pyplot as plt true_ages np.array([...]) # 真实骨龄 pred_ages np.array([...]) # 模型预测骨龄 mae np.mean(np.abs(pred_ages - true_ages)) rmse np.sqrt(np.mean((pred_ages - true_ages) ** 2)) print(fMAE: {mae:.3f}, RMSE: {rmse:.3f}) # Bland-Altman mean_vals (true_ages pred_ages) / 2 diff_vals pred_ages - true_ages mean_diff np.mean(diff_vals) std_diff np.std(diff_vals) loa_upper mean_diff 1.96 * std_diff loa_lower mean_diff - 1.96 * std_diff plt.scatter(mean_vals, diff_vals, alpha0.5) plt.axhline(mean_diff, colorr, linestyle--, labelfMean diff: {mean_diff:.2f}) plt.axhline(loa_upper, colorg, linestyle--, labelf1.96SD: {loa_upper:.2f}) plt.axhline(loa_lower, colorg, linestyle--, labelf-1.96SD: {loa_lower:.2f}) plt.xlabel(Mean of true and predicted bone age) plt.ylabel(Predicted - True bone age) plt.legend() plt.savefig(bland_altman.png)如果 Bland-Altman 图里散点围绕 0 线均匀分布说明没有系统偏差如果整体偏上或偏下说明融合系数需要重新拟合。如果散点呈漏斗形说明骨龄大时误差也大可能需要分年龄段建模。另外建议按性别、年龄段分组算 MAE骨龄检测在青春期前后误差通常更大因为骨化中心变化快模型在样本少的年龄段容易偏。这套 yolov5 加 ResNet18 的方案我自己的习惯是先把检测和分类分开调检测 mAP 到 0.8 以上再动分类分类混淆矩阵里相邻等级混得厉害就回去看 ROI 裁剪质量最后融合系数一定用训练集拟合、验证集验证不要用测试集调参。骨龄检测没有后悔药数据标注和划分错了后面怎么调模型都救不回来。希望帮到你。本文还有配套的精品资源点击获取