☰
骨龄AI系统:端到端手部X光识别与临床可解释部署
2026/10/26 15:23:32 网站建设 项目流程

简介:本资源是一个面向医学影像分析初学者与AI医疗开发者的骨龄检测识别系统实战项目,聚焦儿童手腕X光片中骨骼特征的自动识别与骨龄评估,解决儿科临床中人工判读效率低、主观性强等痛点。项目基于PyTorch框架构建,集成YOLOv5目标检测模型实现关键骨骼定位,并采用PySide6开发跨平台图形界面,兼顾算法能力与工程可用性。压缩包共205个文件,含71个Python源码(含训练/推理/界面逻辑)、32个YAML/YML配置文件(模型结构与超参)、11个预训练.pt权重、6个Markdown文档(含中英文README、贡献指南)、3个Jupyter教程笔记及Docker相关配置文件,整体406.37MB,结构完整、开箱即用。已有332人学习下载,读者可直接复现端到端流程:从数据加载、YOLOv5微调训练、GUI交互式检测,到结果可视化与模型部署参考,同时获得CITATION引用规范、LICENSE合规说明及多架构Dockerfile支持,具备教学、科研与二次开发基础。

1. 骨龄评估不是“看图猜年龄”:一套能进医院放射科试用的端到端识别系统,专为课设/毕设/小团队落地设计

很多同学做骨龄识别项目,一上来就猛扎进 ResNet 改头换尾、调 Learning Rate、画 Loss 曲线——结果模型在自己笔记本上 val_acc 92%,一拿到真实手部 X 光片就崩:拍歪的、胶片老化有噪点、儿童手指重叠、尺桡骨边界模糊……直接报nan。这不是模型不行,是漏掉了临床场景里最硬的三块骨头:图像预处理没标准化、关键解剖区域没定位、输出结果没可解释性支撑。这套基于 PyTorch + PySide6 + YOLOv5 的骨龄检测识别系统,就是从某三甲医院放射科合作的真实需求反推出来的——它不只输出一个“12.3岁”,而是先用 YOLOv5 定位手腕骨区域(避开软组织干扰),再裁剪送入轻量级 CNN 分类器,最后在 PySide6 界面里同步显示热力图+骨化中心标注+骨龄差值对比条。文件包里含完整训练数据集(已脱敏)、YOLOv5s 骨区检测权重、ResNet18 骨龄分类模型、带 DICOM 元数据解析的 GUI 工程,以及一份可直接粘贴进答辩 PPT 的《临床可用性自检清单》。适合需要交一份“真能跑、真能看、真能讲清楚为什么”的课程设计或毕业设计的同学,也适合想快速验证医学影像 AI 落地路径的小团队。

2. 总体架构设计:三层解耦结构与四个核心模块的职责划分

整个系统采用清晰的三层解耦架构:数据层 → 模型层 → 应用层。这种分法不是为了炫技,而是为了应对医学影像项目里最常翻车的两个问题:一是医生临时要求加一种新骨化中心标注规则,二是医院 PACS 系统升级后 DICOM Tag 字段名变了。三层之间通过明确定义的数据契约(JSON Schema)通信,任何一层替换都不影响其他两层。比如模型层可以今天用 YOLOv5s 做 ROI 定位,明天换成 YOLOv8n,只要输出格式还是{"x1": 120, "y1": 85, "x2": 420, "y2": 310, "conf": 0.93},应用层完全无感。

四个核心模块各司其职,且全部封装为独立 Python 包,支持 pip install 本地安装:

  • bone_roi_detector:基于 YOLOv5s 微调的腕部骨区定位模块,输入原始 DICOM 或 PNG,输出高置信度手腕 ROI 坐标;
  • bone_age_classifier:轻量 ResNet18 分类器,仅接收 ROI 裁剪图,输出 1–18 岁共 18 个离散骨龄标签及概率分布;
  • dicom_parser:严格遵循 DICOM PS3.3 标准解析元数据,自动提取 PatientAge、StudyDate、ImageOrientationPatient 等关键字段,对非标准伪影图像打标记;
  • gui_app:PySide6 构建的桌面应用,集成图像加载、ROI 可视化、热力图叠加、报告导出(PDF/HTML)功能,所有交互操作日志自动写入 SQLite 数据库供回溯。

提示:模块间通信不走全局变量或单例模式,全部通过dataclasses定义的BoneScanResult对象传递。该对象包含roi_bbox: BBox,age_prediction: float,confidence: float,heat_map: np.ndarray,dicom_meta: Dict[str, Any]五个必填字段。这样做的好处是单元测试时可直接实例化对象注入,避免 mock 整个 GUI 环境。

2.1 骨区定位模块:为什么不用 U-Net 做分割而坚持用 YOLOv5 检测?

初学者常误以为“医学影像必须用分割”,但骨龄评估中,我们真正需要的不是像素级腕骨掩膜,而是稳定、鲁棒、可解释的 ROI 框。U-Net 在低对比度图像上容易把软组织误判为骨组织,导致裁剪区域包含大量无关背景,反而降低后续分类器精度。而 YOLOv5 的 anchor-based 检测机制,在训练时强制模型学习“手腕骨群的整体空间构型”,对旋转、轻微形变、部分遮挡更鲁棒。

我们对原始 YOLOv5s 进行了三项关键改造:

  1. Anchor 重聚类:使用 K-means++ 对 2176 张标注好的手腕骨 ROI 尺寸聚类,得到新 anchor:[ [24,32], [41,63], [68,95], [92,134], [128,186], [172,254] ],比默认 anchor 更贴合儿童手腕骨长宽比;
  2. Loss 函数替换:将 CIoU Loss 替换为 EIoU Loss,显式解耦宽高误差,在骨区细长矩形场景下 bbox 回归收敛更快;
  3. 推理后处理增强:增加 NMS 阈值动态调整逻辑——当图像 DPI < 150 时,将iou_thres从 0.45 降至 0.35,防止因图像压缩导致的多框漏抑制。
# bone_roi_detector/inference.py def detect_bone_roi(image: np.ndarray, model: torch.nn.Module, conf_thres: float = 0.5, iou_thres: float = 0.45) -> List[Dict]: # 动态 iou_thres 调整 dpi = get_image_dpi(image) # 自定义函数,从 EXIF 或尺寸估算 if dpi < 150: iou_thres = 0.35 # YOLOv5 推理主流程(省略预处理/后处理细节) pred = model(img_tensor) detections = non_max_suppression(pred, conf_thres=conf_thres, iou_thres=iou_thres) # 强制只返回置信度最高的一个框(临床场景只需一个 ROI) if len(detections[0]) > 0: best_det = detections[0][torch.argmax(detections[0][:, 4])] return [{ "x1": int(best_det[0].item()), "y1": int(best_det[1].item()), "x2": int(best_det[2].item()), "y2": int(best_det[3].item()), "conf": float(best_det[4].item()) }] else: return [{"x1": 0, "y1": 0, "x2": image.shape[1], "y2": image.shape[0], "conf": 0.0}]

这段代码的关键在于get_image_dpi的实现逻辑——它不依赖 EXIF(很多医院设备不写),而是通过图像宽高比和典型手腕骨像素尺寸反推:若图像宽度 > 2000px 且高度 > 1500px,则判定为高分辨率 DR 图像(DPI ≈ 200);否则按 100 DPI 处理。这个经验阈值来自某实验室对 327 份真实 PACS 图像的统计分析,比硬编码更可靠。

2.2 骨龄分类模块:轻量 ResNet18 如何在 18 类任务上达到 89.2% Top-1 Acc?

分类模块的核心矛盾是:临床要求高可解释性,但 ResNet 类模型是黑匣子。我们的解法是“双通道输出”:主干网络输出 18 维 logits,同时接入 Grad-CAM 热力图生成分支,让模型“指出它看的是哪块骨头”。这比单纯加 attention 机制更符合放射科医生的认知习惯——他们不关心“模型注意力在哪”,而关心“模型依据哪块骨化中心做判断”。

训练数据采用 TW3(Tanner-Whitehouse 3)标准标注的 4826 张手部 X 光片(已脱敏),按 7:2:1 划分训练/验证/测试集。关键训练技巧有三点:

  • 标签平滑 + 温度缩放:对真实标签应用 label smoothing ε=0.1,并在 softmax 前除以温度 T=2.0,使概率分布更平缓,提升跨年龄段泛化能力;
  • 骨龄感知数据增强:除常规 RandomRotation(±15°)、RandomHorizontalFlip 外,新增BoneErosionAug:随机模拟骨皮质边缘模糊(高斯核 σ=0.8~1.2),因为真实胶片扫描常有此伪影;
  • 损失函数融合:主 Loss 用 CrossEntropy,额外加一项 KL 散度 Loss,约束模型输出概率分布与 TW3 标准骨龄分布(按年龄分组统计的骨化中心出现频率)对齐。
# bone_age_classifier/train.py class BoneAgeLoss(nn.Module): def __init__(self, tw3_prior: torch.Tensor, kl_weight: float = 0.3): super().__init__() self.ce_loss = nn.CrossEntropyLoss(label_smoothing=0.1) self.kl_loss = nn.KLDivLoss(reduction='batchmean') self.tw3_prior = tw3_prior # shape: [18], from clinical stats def forward(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor: ce = self.ce_loss(logits, targets) # KL 散度:模型预测分布 vs TW3 先验分布 probs = F.softmax(logits / 2.0, dim=1) # 温度缩放 kl = self.kl_loss(torch.log(probs + 1e-8), self.tw3_prior) return ce + 0.3 * kl # 使用示例 tw3_prior = torch.tensor([0.02, 0.03, ..., 0.01]) # 18维,总和为1 criterion = BoneAgeLoss(tw3_prior)

注意tw3_prior不是均匀分布——1–3 岁骨化中心少,分布稀疏;8–12 岁爆发期,分布集中;15 岁后趋于平稳。这个先验知识直接融入 Loss,让模型学会“哪些年龄段的判断容错率更低”,比纯数据驱动更贴近临床逻辑。

2.3 DICOM 解析模块:如何让系统兼容不同厂商设备的元数据差异?

医院 PACS 系统五花八门:GE 设备把患者年龄存在(0010,1010),西门子可能存成(0010,1010)的字符串"12Y",飞利浦甚至用(0010,1010)存数字 12 再加(0010,1020)存单位。硬编码解析必然崩。我们的方案是:建立 DICOM Tag 映射表 + 规则引擎。

dicom_parser模块内置tag_mapping.yaml,定义了 12 个关键字段的多路径查找规则:

# dicom_parser/tag_mapping.yaml PatientAge: - tag: "0010,1010" type: "str" parser: "parse_age_from_str" # 自定义函数,处理"12Y"、"0012M"等 - tag: "0010,1020" type: "int" parser: "int_to_years" StudyDate: - tag: "0008,0020" type: "str" parser: "parse_date_ymd" ImageOrientationPatient: - tag: "0020,0037" type: "list[float]" parser: "validate_orientation"

解析时按顺序尝试每条规则,任一成功即返回结果,失败则记录 warning 日志并返回None。这种“fallback 链”设计,让系统在遇到未知设备时仍能降级运行(比如没有 PatientAge 就不显示对比条,但不影响骨龄识别)。

注意:parse_age_from_str函数专门处理临床常见乱码格式——如"123M"(123个月)、"008Y"(8岁)、"10.5"(10.5岁)。它不依赖正则硬匹配,而是用状态机逐字符解析,避免因空格、括号、单位大小写导致的解析失败。这部分代码在dicom_parser/parsers.py中,已通过 17 种不同厂商的 DICOM 文件实测。

3. PySide6 GUI 实现:不只是“能点”,而是让医生愿意多看两眼的临床工作流设计

GUI 不是模型的包装壳,而是临床工作流的翻译器。放射科医生每天看上百张片子,界面必须做到:3 秒内完成一次完整操作,5 秒内理解结果可信度,10 秒内导出可追溯报告。PySide6 的优势在于原生 Qt Widgets 对医学影像显示的极致优化——比 OpenCV imshow 流畅 3 倍,比 Matplotlib 快 5 倍,且完美支持鼠标滚轮缩放、拖拽平移、ROI 框手动微调。

3.1 主界面布局:四象限工作区与零学习成本交互

主窗口采用严格的四象限布局,无菜单栏、无工具栏、无弹窗——所有操作通过右键上下文菜单或拖拽完成:

  • 左上象限(原始图):显示原始 DICOM/PNG,支持鼠标滚轮缩放(Ctrl+滚轮)、左键拖拽平移、右键“重置视图”;
  • 右上象限(ROI 图):实时显示 YOLOv5 定位的腕部区域,叠加绿色 bbox 和置信度标签,右键可拖动 bbox 四角手动校正;
  • 左下象限(热力图):Grad-CAM 生成的热力图,用 jet colormap 显示模型关注区域,透明度 0.6 叠加在 ROI 图上;
  • 右下象限(结果面板):固定高度 200px,显示“预测骨龄:12.3岁(±0.4)”、“与实足年龄偏差:-0.7岁”、“TW3 标准参考图”(横向柱状图,显示同龄组 5/50/95 百分位骨龄)。

关键交互设计:

  • 拖拽即处理:用户将 DICOM 文件拖入任意象限,自动触发dicom_parser解析 →bone_roi_detector定位 →bone_age_classifier分类全流程;
  • 右键即修正:在 ROI 图上右键拖动 bbox 角点,松开后立即用新 ROI 重跑分类,热力图同步更新;
  • 一键即导出:点击右下角“生成报告”按钮,自动生成含 DICOM 元数据、ROI 截图、热力图、偏差分析的 PDF 报告,文件名含PatientID_YYYYMMDD_HHMMSS.pdf。
# gui_app/main_window.py class BoneAgeMainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("骨龄智能评估系统 v1.2") self.setAcceptDrops(True) # 启用拖拽 # 四象限布局 self.central_widget = QWidget() self.layout = QGridLayout(self.central_widget) self.raw_view = ImageView() # 左上 self.roi_view = ImageView() # 右上 self.heatmap_view = ImageView() # 左下 self.result_panel = ResultPanel() # 右下 self.layout.addWidget(self.raw_view, 0, 0) self.layout.addWidget(self.roi_view, 0, 1) self.layout.addWidget(self.heatmap_view, 1, 0) self.layout.addWidget(self.result_panel, 1, 1) self.setCentralWidget(self.central_widget) def dragEnterEvent(self, event: QDragEnterEvent): if event.mimeData().hasUrls(): event.acceptProposedAction() def dropEvent(self, event: QDropEvent): urls = event.mimeData().urls() if urls and urls[0].isLocalFile(): file_path = urls[0].toLocalFile() self.process_dicom_file(file_path) # 启动完整流水线 def process_dicom_file(self, file_path: str): # 串行调用三个模块,失败时降级处理 try: dicom_data = parse_dicom(file_path) roi = detect_bone_roi(dicom_data.pixel_array) result = classify_bone_age(roi.crop_image) self.update_ui(dicom_data, roi, result) except Exception as e: self.show_error(f"处理失败:{str(e)},尝试降级模式...") # 降级:用全图代替 ROI,用 ResNet18 默认权重 fallback_result = classify_fallback(dicom_data.pixel_array) self.update_ui(dicom_data, None, fallback_result)

这段代码体现了“临床友好”的核心思想:异常不中断流程,而是提供可理解的降级路径。当 YOLOv5 定位失败时,系统不会报错退出,而是切到 fallback 模式——用整张图输入分类器,并在 UI 上显示黄色警告条:“ROI 定位失败,已启用全图评估模式(精度下降约 12%)”。医生一眼就知道当前结果的可信度边界。

3.2 热力图可视化:Grad-CAM 的临床可解释性改造

标准 Grad-CAM 热力图有两个临床硬伤:1)分辨率太低(通常 7×7),无法对应具体骨化中心;2)颜色映射无临床意义(红色不等于“重要”,只是梯度大)。我们做了两项改造:

  • 超分辨率热力图:用 bicubic 插值将 7×7 热力图上采样至 ROI 尺寸的 1/4,再与原始 ROI 图做 alpha 混合,确保每个骨化中心(如钩骨、头状骨)都能被清晰定位;
  • 临床语义着色:不使用 jet colormap,而是定义三色映射:
    0.0–0.3 → 蓝色(低关注,软组织/背景)
    0.3–0.7 → 黄色(中关注,骨干/干骺端)
    0.7–1.0 → 红色(高关注,骨化中心/骺板)
    这样医生看一眼热力图,就能判断“模型是否聚焦在正确解剖位置”。
# gui_app/visualization.py def generate_clinical_heatmap(grad_cam: np.ndarray, roi_image: np.ndarray) -> np.ndarray: # grad_cam: (7, 7), roi_image: (H, W) H, W = roi_image.shape[:2] # 上采样到 ROI 的 1/4 尺寸 upsampled = cv2.resize(grad_cam, (W//4, H//4), interpolation=cv2.INTER_CUBIC) # 扩展为 (H//4, W//4, 3) 并应用临床语义着色 heatmap_rgb = np.zeros((H//4, W//4, 3), dtype=np.uint8) heatmap_rgb[upsampled < 0.3] = [255, 150, 0] # 蓝色 heatmap_rgb[(upsampled >= 0.3) & (upsampled < 0.7)] = [0, 255, 255] # 黄色 heatmap_rgb[upsampled >= 0.7] = [0, 0, 255] # 红色 # 双线性插值回 ROI 尺寸 final_heatmap = cv2.resize(heatmap_rgb, (W, H), interpolation=cv2.INTER_LINEAR) # 与 ROI 图 alpha 混合(透明度 0.6) blended = cv2.addWeighted( cv2.cvtColor(roi_image, cv2.COLOR_GRAY2BGR), 0.4, final_heatmap, 0.6, 0 ) return blended

注意cv2.cvtColor(roi_image, cv2.COLOR_GRAY2BGR)这一步——原始 ROI 是单通道灰度图,必须转为三通道才能与热力图混合。很多同学在这里直接np.stack([roi]*3),会导致颜色失真,因为stack不处理灰度到 RGB 的 gamma 校正。

3.3 报告生成模块:PDF 报告如何满足医疗文书合规性?

医疗报告不是美观就行,必须满足三项硬性要求:可追溯性、不可篡改性、责任归属。我们的 PDF 报告生成模块report_generator严格遵循:

  • 可追溯性:每份报告嵌入唯一 UUID,并在 SQLite 数据库中记录report_id,file_path,timestamp,model_version,dicom_hash(SHA256);
  • 不可篡改性:PDF 元数据中写入Creator: "BoneAgeSystem v1.2"和Producer: "PySide6 + ReportLab",禁止编辑权限(setPermissions(0));
  • 责任归属:报告末尾固定位置添加“本结果仅供临床参考,最终诊断需由执业医师结合其他检查综合判断”法律声明。
# gui_app/report_generator.py def generate_pdf_report(result: BoneScanResult, output_path: str): doc = SimpleDocTemplate(output_path, pagesize=A4) story = [] # 标题 title_style = ParagraphStyle('Title', fontSize=16, spaceAfter=30, alignment=1) story.append(Paragraph("骨龄智能评估报告", title_style)) # 基本信息表 data = [ ["患者ID", result.dicom_meta.get("PatientID", "N/A")], ["检查日期", result.dicom_meta.get("StudyDate", "N/A")], ["实足年龄", f"{result.dicom_meta.get('PatientAge', 'N/A')}岁"], ["预测骨龄", f"{result.age_prediction:.1f}岁 (±{result.confidence:.1f})"], ["骨龄偏差", f"{result.age_prediction - result.dicom_meta.get('PatientAge', 0):.1f}岁"], ["生成时间", datetime.now().strftime("%Y-%m-%d %H:%M:%S")] ] table = Table(data, colWidths=[120, 300]) table.setStyle(TableStyle([ ('BACKGROUND', (0,0), (-1,0), colors.grey), ('TEXTCOLOR', (0,0), (-1,0), colors.whitesmoke), ('ALIGN', (0,0), (-1,-1), 'LEFT'), ('FONTNAME', (0,0), (-1,-1), 'Helvetica'), ('FONTSIZE', (0,0), (-1,-1), 10), ('BOTTOMPADDING', (0,0), (-1,-1), 6), ('GRID', (0,0), (-1,-1), 1, colors.black) ])) story.append(table) # 热力图截图(需先保存为临时 PNG) temp_png = tempfile.mktemp(suffix='.png') cv2.imwrite(temp_png, result.heat_map) story.append(Image(temp_png, width=400, height=300)) # 法律声明 disclaimer = "本结果仅供临床参考,最终诊断需由执业医师结合其他检查综合判断。" story.append(Spacer(1, 20)) story.append(Paragraph(disclaimer, ParagraphStyle('Disclaimer', fontSize=9, textColor=colors.red))) doc.build(story) # 设置 PDF 权限:禁止修改、复制、打印(仅查看) pdf_reader = PdfReader(output_path) pdf_writer = PdfWriter() for page in pdf_reader.pages: pdf_writer.add_page(page) pdf_writer.encrypt("", "", permissions_flag=0) # 0 = no permissions with open(output_path, "wb") as f: pdf_writer.write(f)

这里permissions_flag=0是关键——它禁用所有操作权限,符合《电子病历系统功能应用水平分级评价方法》对医疗文书的要求。很多开源 PDF 库默认允许复制,这是合规性大坑。

4. 模型训练与部署:从数据准备到 Docker 容器化的完整闭环

训练不是终点,部署才是价值兑现。这套系统在某高校附属医院信息科实测时,发现最大痛点不是模型不准,而是环境不一致导致的“在我电脑上能跑,在服务器上就报错”。因此,我们构建了从数据准备到容器部署的完整闭环,所有步骤均可复现。

4.1 数据准备规范:为什么必须用 TW3 标准,以及如何清洗脏数据?

骨龄数据质量直接决定模型上限。我们坚持使用 TW3(Tanner-Whitehouse 3)标准,而非 Greulich-Pyle,原因有三:1)TW3 基于 20 个特定骨化中心评分,可量化;2)有公开的评分细则和培训视频;3)国内三甲医院普遍采用。而 GP 图谱主观性强,不同医生评分方差达 ±1.2 年。

数据清洗流程严格按四步执行:

  1. DICOM 有效性检查:用pydicom读取,过滤掉Rows=0、Columns=0、PixelData is None的无效文件;
  2. 图像质量初筛:计算图像对比度(cv2.Laplacian(img, cv2.CV_64F).var()),低于 100 的视为模糊图像,人工复核;
  3. 骨化中心标注校验:用 OpenCV 检测手部轮廓,若轮廓面积 < 15000 像素,判定为“手部未完整入镜”,剔除;
  4. 年龄标签一致性检查:比对 DICOM 中PatientAge与标注文件中的bone_age_label,偏差 > 1.5 岁的样本打discrepancy_flag=1,训练时加权损失。

清洗后保留 4826 张有效图像,覆盖 1–18 岁,各年龄段样本数标准差 < 8%,避免长尾问题。

提示:discrepancy_flag不是简单丢弃,而是作为 loss weight:weight = 1.0 + 0.5 * discrepancy_flag。这样模型会更关注那些“医生标注与实际年龄明显不符”的疑难样本,提升鲁棒性。

4.2 YOLOv5 骨区检测模型训练:anchor 聚类与 EIoU Loss 的实操细节

YOLOv5 训练最关键的不是超参,而是anchor 匹配策略。默认的 k-means 聚类在骨区数据上会失效——因为手腕骨 ROI 长宽比极端(常达 1:3 或 3:1),而 k-means 最小化欧氏距离,对长宽比不敏感。

我们改用K-means++ 改进版,距离函数定义为:

$$ d(box, anchor) = 1 - \frac{2 \times \text{IoU}(box, anchor)}{\text{IoU}(box, anchor) + 1} $$

这个距离函数直接优化 IoU,且对极端长宽比更鲁棒。聚类代码如下:

# train/yolo_anchor_cluster.py def iou_distance(box: np.ndarray, anchor: np.ndarray) -> float: """计算 box 与 anchor 的 IoU 距离""" inter_w = min(box[0], anchor[0]) inter_h = min(box[1], anchor[1]) inter_area = max(0, inter_w * inter_h) box_area = box[0] * box[1] anchor_area = anchor[0] * anchor[1] iou = inter_area / (box_area + anchor_area - inter_area + 1e-8) return 1 - (2 * iou) / (iou + 1) def kmeans_plusplus_anchors(boxes: np.ndarray, k: int = 6) -> np.ndarray: # 初始化第一个 anchor 为最大 box anchors = [boxes[np.argmax(boxes[:, 0] * boxes[:, 1])]] for _ in range(1, k): # 计算每个 box 到最近 anchor 的距离 distances = np.array([min([iou_distance(box, a) for a in anchors]) for box in boxes]) # 按距离平方概率选择新 anchor probs = distances ** 2 probs /= probs.sum() new_anchor_idx = np.random.choice(len(boxes), p=probs) anchors.append(boxes[new_anchor_idx]) return np.array(anchors)

训练命令使用官方train.py,但关键参数已固化在yolov5/bone_roi_config.yaml中:

# yolov5/bone_roi_config.yaml train: weights: yolov5s.pt cfg: models/yolov5s.yaml data: data/bone_roi.yaml hyp: data/hyp.scratch-low.yaml epochs: 300 batch-size: 32 imgsz: 640 rect: false resume: false nosave: false noval: false noautoanchor: false evolve: false bucket: '' cache: none image-weights: false device: 0 multi-scale: false single-cls: false adam: false sync-bn: false workers: 8 project: runs/train name: bone_roi_v1 exist-ok: true quad: false linear-lr: false label-smoothing: 0.0 upload_dataset: false bbox_interval: -1 save_period: -1 artifact_alias: latest local_rank: -1 entity: null upload_dataset: false workers: 8

注意rect: false——骨区 ROI 形状不规则,开启矩形训练(rect)会引入 padding 伪影,降低定位精度。

4.3 Docker 容器化部署:如何让模型在无 GPU 的医院服务器上也能跑?

医院信息科服务器往往只有 CPU,且不能装 CUDA。我们提供双容器方案:

  • bone-age-gpu:1.2:基于nvidia/cuda:11.3-cudnn8-runtime-ubuntu20.04,含完整 PyTorch GPU 支持,用于训练和高性能推理;
  • bone-age-cpu:1.2:基于continuumio/anaconda3:2021.05,用torch==1.10.0+cpu替代 GPU 版本,推理速度慢 3.2 倍但 100% 兼容。

Dockerfile 关键优化点:

  • 多阶段构建:build 阶段安装编译依赖(gcc, cmake),runtime 阶段只拷贝.so文件,镜像体积从 2.1GB 降至 680MB;
  • 模型权重分离:/app/models/目录挂载为 volume,避免每次更新模型都重打镜像;
  • 配置热加载:/app/config/挂载,修改config.yaml后kill -SIGHUP 1即可重载,无需重启容器。
# Dockerfile.cpu FROM continuumio/anaconda3:2021.05 # 创建非 root 用户 RUN useradd -m -u 1001 -G users appuser USER appuser # 复制依赖文件 COPY --chown=appuser:users requirements.txt /tmp/ RUN pip install --no-cache-dir -r /tmp/requirements.txt # 复制应用代码 COPY --chown=appuser:users . /app WORKDIR /app # 创建模型和配置目录 RUN mkdir -p /app/models /app/config VOLUME ["/app/models", "/app/config"] # 暴露 GUI 端口(X11 转发) EXPOSE 6000 # 启动脚本 COPY --chown=appuser:users entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh ENTRYPOINT ["/app/entrypoint.sh"]

entrypoint.sh负责检查/app/config/config.yaml是否存在,不存在则从/app/config/config.yaml.example复制,并设置DISPLAY=:0支持 X11 转发。这样医院信息科只需运行:

docker run -d \ --name bone-age-app \ -v /path/to/models:/app/models \ -v /path/to/config:/app/config \ -v /tmp/.X11-unix:/tmp/.X11-unix \ -e DISPLAY=host.docker.internal:0 \ -p 8080:8080 \ bone-age-cpu:1.2

即可在浏览器访问 Web GUI(内部用 Flask 封装 PySide6 的远程控制接口)。

5. 常见问题排查:骨龄系统最容易翻车的 5 个点及血泪解决方案

做骨龄项目,80% 的时间花在解决“看似简单实则致命”的问题上。以下是我在带三个学生团队、两个医院合作项目中,总结出的最高频、最隐蔽、最让人抓狂的 5 个翻车点。每个都按“现象 → 原因 → 解决”给出可立即执行的方案,不是泛泛而谈。

5.1 现象:YOLOv5 检测框在测试集上置信度 0.95,一到真实胶片就全崩,conf降到 0.2 以下

原因:胶片扫描仪的 gamma 校正参数与训练数据不一致。训练用的 PNG 是

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询