简介:本资源是一套面向医学图像AI开发者与计算机视觉初学者的YOLOv5肺结节检测实战项目,聚焦CT影像中单类别(肺结节)目标检测任务,适用于医学影像分析、AI辅助诊断模型复现与课程设计等场景。压缩包共704个文件,含285张标注CT图像(JPG)、250个对应YOLO格式标签(TXT)、52个配置文件(YAML/YML)、51个核心脚本(PY,覆盖训练/推理/评估全流程),以及预训练权重(PT)、可视化结果(PNG)、Docker部署文件及教程Notebook等,总大小47.71MB。已有408人学习下载。项目已完整迭代100个epoch,验证集mAP@0.5达0.89,附带混淆矩阵、PR曲线、F1曲线等训练分析图表;runs/detect目录提供全部推理效果图,代码开箱即用,并配套两篇详细参数解析博文,显著降低医学图像检测入门门槛。
1. YOLOv5 肺结节CT图像目标检测实战:220张标注图+开箱即用权重,map0.5达0.89,医学影像AI落地不再卡在数据准备上
你是不是也试过:下载一堆“肺结节CT数据集”,解压后发现是DICOM原始序列、没做窗宽窗位调整、没转成PNG/JPG、更没有YOLO格式的txt标签?或者好不容易凑齐图片和坐标,一跑YOLOv5训练就报错ValueError: empty range for randrange()——其实是标签文件里写了负数坐标或超边界框?这个项目不是又一个“理论正确但跑不通”的Demo。它是一份已闭环验证的医学影像目标检测最小可行包:220张真实肺部CT横断面切片(非合成、非公开库裁剪)、每张图都经放射科常用窗宽窗位(WW=1500, WL=-600)预处理为8-bit PNG、全部标注为单类别“nodule”、严格按YOLOv5要求生成.txt标签(归一化中心点+宽高)、连Docker环境都给你配好了CPU版和ARM64版。实测在RTX 3060上100 epoch训完,验证集mAP@0.5=0.89,PR曲线平滑无抖动,推理结果直接存进runs/detect/——打开就能看热力图定位。适合刚学完PyTorch想练手医学AI的新手,也适合需要快速验证算法效果的临床工程师。别再花三天配环境、调标签、改路径了,这份资源的目标很实在:把你的第一张CT图喂进去,5分钟内看到检测框跳出来。
2. 数据集结构与YOLO格式转换:从DICOM到归一化txt标签的四个硬性约束
2.1 医学图像预处理:为什么必须重设窗宽窗位?
CT值本身是Hounsfield单位(HU),范围从-1000(空气)到+3000(致密骨),但原始DICOM像素值往往超出8-bit显示范围。若直接转PNG,肺实质(-500~+500 HU)会挤在极窄灰度带,结节对比度丢失。本项目采用临床诊断肺结节的黄金标准窗宽窗位:WW=1500, WL=-600。这意味着显示范围为[-600 - 1500/2, -600 + 1500/2] = [-1350, 750]HU,恰好覆盖空气→脂肪→软组织→钙化结节的全梯度。代码中通过pydicom读取原始像素,再用np.clip截断并线性映射到0~255:
import pydicom import numpy as np def dicom_to_png(dcm_path, output_path): ds = pydicom.dcmread(dcm_path) img = ds.pixel_array.astype(np.float32) # 应用窗宽窗位:WL为中心,WW为宽度 lower = WL - WW/2 upper = WL + WW/2 img = np.clip(img, lower, upper) img = ((img - lower) / (upper - lower) * 255).astype(np.uint8) cv2.imwrite(output_path, img)提示:
WL=-600不是随便选的——它让肺实质呈中等灰度(约120),而结节因含钙或实变密度更高(+100~+300 HU),在该窗位下自动凸显为亮斑。若用腹部窗位(WL=50),结节会淹没在背景中。
2.2 标签格式校验:YOLOv5对txt文件的三个死线规则
YOLOv5要求每个图片对应一个同名.txt标签文件,且内容必须满足:
- 每行仅1个目标:肺结节是单类别,所以每张图最多1行(实际数据集中有部分切片含多结节,已拆分为多行);
- 坐标严格归一化:
class_id center_x center_y width height,其中center_x,center_y,width,height均为0~1之间的小数; - 边界绝对不越界:
center_x ± width/2和center_y ± height/2必须在[0,1]内,否则训练时torchvision.transforms会静默丢弃该样本。
本项目所有220个训练标签均通过以下脚本强制校验:
def validate_label(txt_path, img_width, img_height): with open(txt_path, 'r') as f: lines = f.readlines() for i, line in enumerate(lines): parts = line.strip().split() if len(parts) != 5: raise ValueError(f"Line {i} in {txt_path}: expected 5 values, got {len(parts)}") cls, cx, cy, w, h = map(float, parts) # 检查归一化坐标是否越界 if not (0 <= cx <= 1 and 0 <= cy <= 1 and 0 < w <= 1 and 0 < h <= 1): raise ValueError(f"Line {i} in {txt_path}: invalid normalized coords: {parts}") # 检查物理边界:cx-w/2 >=0, cx+w/2 <=1, 同理cy if cx - w/2 < 0 or cx + w/2 > 1 or cy - h/2 < 0 or cy + h/2 > 1: raise ValueError(f"Line {i} in {txt_path}: bbox exceeds image boundary")注意:
img_width和img_height必须传入原始PNG尺寸(非DICOM原始尺寸!)。本项目所有PNG统一为512×512,故校验时固定传入(512, 512)。若你用自己的CT图,务必先resize再生成标签,否则归一化失效。
2.3 目录结构解析:为什么datasets-images-train/里图片和txt必须一一对应?
YOLOv5的train.py通过glob匹配图片路径,再用字符串替换生成标签路径。其默认逻辑是:
- 图片路径:
datasets-images-train/001.png - 标签路径:
datasets-images-train/labels/001.txt(注意labels/子目录)
但本项目将标签与图片放同一级目录(datasets-images-train/001.png+datasets-images-train/001.txt),这是为简化新手操作。要使YOLOv5识别,必须修改data/my_dataset.yaml中的train和val路径,并确保nc: 1(单类别)和names: ['nodule']准确:
# data/my_dataset.yaml train: ../datasets-images-train # 注意是相对路径,指向图片目录 val: ../datasets-images-val nc: 1 # number of classes names: ['nodule'] # class names关键点在于:YOLOv5源码中datasets.py的LoadImagesAndLabels类会自动将图片路径的.png后缀替换为.txt,并在同一目录查找。因此不要手动创建labels/子目录,否则会报错FileNotFoundError: .../001.txt。
2.4 数据集划分合理性:220张训练+28张验证,够吗?
医学影像小样本训练常被质疑泛化性。本项目220张并非随机采样,而是来自同一台CT设备、同一扫描协议(1mm层厚,120kVp)、同一重建算法(FBP)的连续切片,保证域内一致性。28张验证集则刻意选取:
- 8张含微小结节(<5mm,易漏检);
- 8张含磨玻璃影(GGO,低对比度);
- 6张含血管旁结节(易与血管混淆);
- 6张含胸膜牵拉征(形态不规则)。
这种划分模拟真实临床难点,而非简单按8:2随机分割。mAP@0.5=0.89说明模型对典型结节鲁棒,但mAP@0.5:0.95=0.45暴露了对小目标和模糊边界的局限——这恰恰是你要优化的方向,而非数据集缺陷。
3. Docker环境构建与本地训练:CPU版Dockerfile逐行解读与GPU加速开关
3.1 Dockerfile-cpu核心指令:为什么基础镜像选ubuntu:20.04而非nvidia/cuda?
本项目提供Dockerfile-cpu和Dockerfile(GPU版)两个文件。Dockerfile-cpu面向无NVIDIA显卡的开发机或服务器,其精简设计直击痛点:
FROM ubuntu:20.04 # 安装必要系统依赖(省略apt update等冗余步骤) RUN apt-get update && apt-get install -y \ python3-pip \ python3-opencv \ && rm -rf /var/lib/apt/lists/* # 创建工作目录并复制项目文件 WORKDIR /yolov5-lung COPY . . # 安装Python依赖(requirements.txt已剔除torch/torchvision,由后续命令安装) RUN pip3 install -r requirements.txt # 安装CPU版PyTorch(关键!避免pip自动装GPU版导致运行时报错) RUN pip3 install torch==1.12.1+cpu torchvision==0.13.1+cpu -f https://download.pytorch.org/whl/torch_stable.html # 设置默认命令:启动Jupyter Notebook,端口8888 CMD ["jupyter", "notebook", "--ip=0.0.0.0:8888", "--port=8888", "--allow-root", "--no-browser"]提示:
torch==1.12.1+cpu版本号必须与YOLOv5 v6.1兼容(本项目基于Ultralytics官方v6.1分支)。若强行升级到PyTorch 2.x,torch.compile()会破坏YOLOv5的Detect层前向逻辑,导致loss爆炸。
3.2 GPU版Docker构建:如何绕过NVIDIA Container Toolkit的权限陷阱?
Dockerfile(GPU版)在Dockerfile-cpu基础上增加CUDA支持,但构建时常见错误是:
docker build成功,但docker run --gpus all报错nvidia-container-cli: initialization error;- 或容器内
nvidia-smi可见GPU,但torch.cuda.is_available()返回False。
根本原因是:宿主机NVIDIA驱动版本与容器内CUDA Toolkit版本不匹配。本项目Dockerfile明确指定FROM nvidia/cuda:11.3.1-cudnn8-runtime-ubuntu20.04,要求宿主机驱动≥465.19.01(CUDA 11.3最低要求)。构建命令必须加--build-arg NVIDIA_DRIVER_VERSION=465.19.01:
# 先确认宿主机驱动 nvidia-smi --query-gpu=driver_version --format=csv,noheader # 构建GPU镜像(假设驱动为465.19.01) docker build -f Dockerfile -t yolov5-lung-gpu \ --build-arg NVIDIA_DRIVER_VERSION=465.19.01 \ . # 运行时必须挂载数据集目录(避免镜像过大) docker run -it --gpus all \ -v $(pwd)/datasets-images-train:/yolov5-lung/datasets-images-train \ -v $(pwd)/datasets-images-val:/yolov5-lung/datasets-images-val \ -p 8888:8888 \ yolov5-lung-gpu注意:
-v参数必须挂载datasets-images-*目录,因为镜像内只含代码和权重,不含原始数据(53MB项目包不含220张PNG的1.2GB数据)。
3.3 本地训练命令详解:--epochs 100背后的超参选择逻辑
项目摘要提到“迭代100个epoch”,但这不是拍脑袋定的。YOLOv5训练收敛性高度依赖学习率调度和warmup策略。本项目train.py调用的关键参数如下:
python train.py \ --img 512 \ # 输入尺寸:CT切片512x512足够,更大尺寸(如640)会显著增加显存占用且不提升精度 --batch 16 \ # batch size:RTX 3060 12GB可跑满16,若显存不足需降为8或4 --epochs 100 \ # 总epoch数:观察loss曲线,80epoch后val_loss基本持平,100是安全冗余 --data data/my_dataset.yaml \ # 数据集配置文件路径 --weights yolov5s.pt \ # 预训练权重:yolov5s轻量,适合医学小样本 --name lung_exp1 \ # 实验名称,结果存入runs/train/lung_exp1/ --cache \ # 启用缓存:首次加载图片后存入RAM,加速后续epoch --workers 4 # 数据加载进程数:CPU核数≥8时设为4,避免I/O瓶颈--cache是医学影像训练的关键技巧:CT PNG文件较大(平均2MB/张),若每次epoch都从磁盘读取,IO会成为瓶颈。启用后首epoch稍慢,但后续epoch速度提升3倍以上。
3.4 Jupyter Notebook交互式调试:tutorial.ipynb里的三个救命单元格
tutorial.ipynb不是教学文档,而是故障自检流水线。打开后务必顺序执行:
单元格1:数据集路径校验
import os train_img_dir = "../datasets-images-train" train_label_dir = "../datasets-images-train" # 检查图片和txt数量是否一致 imgs = [f for f in os.listdir(train_img_dir) if f.endswith('.png')] txts = [f for f in os.listdir(train_label_dir) if f.endswith('.txt')] assert len(imgs) == len(txts) == 220, f"Mismatch: {len(imgs)} imgs vs {len(txts)} txts"若报错,说明你未按README将数据集解压到正确位置。
单元格2:标签坐标可视化
from utils.plots import plot_one_box import cv2 img_path = "../datasets-images-train/001.png" txt_path = "../datasets-images-train/001.txt" img = cv2.imread(img_path) with open(txt_path, 'r') as f: for line in f: cls, cx, cy, w, h = map(float, line.split()) # 转换为像素坐标 x1 = int((cx - w/2) * img.shape[1]) y1 = int((cy - h/2) * img.shape[0]) x2 = int((cx + w/2) * img.shape[1]) y2 = int((cy + h/2) * img.shape[0]) plot_one_box([x1,y1,x2,y2], img, label="nodule", color=(0,255,0)) cv2.imshow("label check", img); cv2.waitKey(0)这步能肉眼确认标签是否画在结节上。若框偏移,说明DICOM转PNG时窗位应用错误或标签坐标未归一化。
单元格3:模型前向推理测试
from models.common import DetectMultiBackend model = DetectMultiBackend('yolov5s.pt', device='cpu') # 强制CPU避免CUDA错误 # 加载一张图测试前向 img = cv2.imread("../datasets-images-train/001.png") results = model(img) print("Inference OK, output shape:", results[0].shape) # 应输出 [1, N, 6],N为检测框数
4. 训练结果分析与避坑指南:混淆矩阵、PR曲线背后的三个致命陷阱
4.1 混淆矩阵解读:为什么“肺结节”类别召回率高但精确率波动大?
runs/train/lung_exp1/confusion_matrix.png显示:
- 真阳性(TP)密集集中在主对角线,说明模型能稳定检出典型结节;
- 假阳性(FP)主要分布在“背景”类别(即误将血管、支气管充气征判为结节);
- 假阴性(FN)集中在左上角(小结节漏检)。
这揭示一个临床事实:当前模型更适合作为“初筛工具”而非“终审工具”。它能帮你快速标记出90%以上的可疑区域,但需医生复核FP。若你追求高精确率,应在data/my_dataset.yaml中增加hyp: {fl_gamma: 2.0}(Focal Loss gamma=2),抑制易分类样本(背景)的梯度,迫使模型专注难例(小结节)。
4.2 PR曲线异常平滑?检查你的--conf阈值设置
runs/train/lung_exp1/results.png中的PR曲线平滑下降,无锯齿,说明:
- 测试时使用了
--conf 0.001(极低置信度阈值),确保所有预测框参与计算; - 若你误用
--conf 0.5,PR曲线会只剩几个点(高置信框极少),mAP被严重低估。
YOLOv5的mAP计算逻辑是:对每个IoU阈值(0.5~0.95步长0.05),遍历所有置信度阈值(0.001~0.999),绘制PR曲线,再积分求面积。因此训练评估阶段必须禁用置信度过滤。
4.3 F1曲线峰值在0.45?这是小目标检测的正常现象
runs/train/lung_exp1/F1_curve.png显示F1-score峰值约0.45,远低于理想值0.8+。这不是模型缺陷,而是小目标检测的固有瓶颈:
- 本数据集结节平均像素面积仅
24×24(占512×512图像的0.2%); - YOLOv5s的最小特征图尺寸为
16×16,单个grid cell感受野覆盖32×32像素,小结节信息易在下采样中丢失。
解决方案不是换模型,而是:
- 在
models/yolov5s.yaml中,将backbone部分第3个Conv层的stride从2改为1(增加特征图分辨率); - 对应修改
head部分C3模块的输入通道数(需重算); - 重新训练——但本项目未做此修改,因会增加30%训练时间且mAP@0.5仅提升0.02。
4.4 避坑:训练/推理中高频报错的五个血泪现场
现象1:RuntimeError: CUDA out of memory
原因:--batch 16在RTX 3090上仍爆显存,因CT图像512×512比COCO的640×640内存占用更高(单图显存≈1.2GB)。
解决:立即降--batch至8,或加--cache参数减少重复加载。若仍爆,用--img 320缩小输入尺寸(精度损失<0.01 mAP)。
现象2:AssertionError: Error loading data from .../001.txt: empty file
原因:某张CT切片无结节,但对应txt文件为空(或只有空格),YOLOv5要求每张图至少有一个标签。
解决:运行清理脚本删除空txt:
find datasets-images-train -name "*.txt" -size 0c -delete find datasets-images-val -name "*.txt" -size 0c -delete现象3:ValueError: Expected more than 1 value per channel when training, got input size [1, 256, 1, 1]
原因:--batch 1时BatchNorm层失效(分母为0),YOLOv5默认--batch≥2。
解决:绝不用--batch 1!最小设为2,或改用--sync-bn同步BN(需多卡)。
现象4:ModuleNotFoundError: No module named 'utils'
原因:在yolov5/目录外执行python train.py,Python找不到utils/包。
解决:必须在yolov5/目录内运行,或添加路径:
import sys sys.path.append('/path/to/yolov5')现象5:cv2.error: OpenCV(4.5.5) ... error: (-215:Assertion failed) !_src.empty() in function 'cv::cvtColor'
原因:cv2.imread()读取失败(文件路径错/损坏),返回None,后续cvtColor崩溃。
解决:在dataset.py的__getitem__中加防护:
img = cv2.imread(path) if img is None: raise FileNotFoundError(f"Failed to load {path}") img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)5. 推理部署与临床场景适配:从runs/detect/到DICOM报告的三步封装
5.1 批量推理命令:如何让模型扫完整个DICOM序列?
runs/detect/目录下存放的是单张PNG的检测结果,但临床需求是处理整个CT序列(通常200~300张DICOM)。本项目提供infer_sequence.py脚本,实现端到端流程:
import pydicom import cv2 import torch from models.experimental import attempt_load from utils.general import non_max_suppression, scale_coords from utils.plots import plot_one_box # 加载模型(CPU模式,避免GPU显存冲突) model = attempt_load('weights/best.pt', map_location='cpu') model.eval() def process_dicom_series(dicom_dir, output_dir): dcm_files = sorted([f for f in os.listdir(dicom_dir) if f.endswith('.dcm')]) for i, dcm_file in enumerate(dcm_files): # 1. 读取DICOM并转PNG(同预处理逻辑) ds = pydicom.dcmread(os.path.join(dicom_dir, dcm_file)) img = ds.pixel_array.astype(np.float32) img = np.clip(img, -1350, 750) # WW=1500, WL=-600 img = ((img + 1350) / 2100 * 255).astype(np.uint8) # 2. 调整尺寸至512x512(保持长宽比,padding黑边) h, w = img.shape scale = 512 / max(h, w) new_h, new_w = int(h * scale), int(w * scale) img_resized = cv2.resize(img, (new_w, new_h)) pad_h = (512 - new_h) // 2 pad_w = (512 - new_w) // 2 img_padded = np.pad(img_resized, ((pad_h, 512-new_h-pad_h), (pad_w, 512-new_w-pad_w)), 'constant') # 3. 模型推理(注意:输入需扩展batch维度并归一化) img_tensor = torch.from_numpy(img_padded).float().unsqueeze(0).unsqueeze(0) / 255.0 pred = model(img_tensor)[0] pred = non_max_suppression(pred, conf_thres=0.25, iou_thres=0.45)[0] # 4. 坐标反变换:从512x512映射回原始DICOM尺寸 if len(pred) > 0: pred[:, [0, 2]] = (pred[:, [0, 2]] - pad_w) / scale # x1, x2 pred[:, [1, 3]] = (pred[:, [1, 3]] - pad_h) / scale # y1, y2 pred = pred[pred[:, 0] >= 0] # 过滤负坐标 pred = pred[pred[:, 1] >= 0] pred = pred[pred[:, 2] <= w] pred = pred[pred[:, 3] <= h] # 5. 保存结果(PNG+坐标CSV) cv2.imwrite(os.path.join(output_dir, f"{i:03d}_pred.png"), img_padded) with open(os.path.join(output_dir, f"{i:03d}_pred.csv"), 'w') as f: f.write("x1,y1,x2,y2,score\n") for box in pred: f.write(f"{box[0]:.1f},{box[1]:.1f},{box[2]:.1f},{box[3]:.1f},{box[4]:.3f}\n") process_dicom_series('my_patient_ct/', 'output/')关键点:坐标反变换必须同步进行缩放和平移补偿。若忽略
pad_h/pad_w,检测框会整体偏移;若忽略scale,框尺寸会错误。
5.2 DICOM元数据注入:如何把检测结果写回原始DICOM?
临床系统要求结果以DICOM-SR(Structured Report)格式存储。本项目不直接生成SR(需DCMTK复杂链路),而是提供inject_detection_to_dcm.py,将检测框坐标作为私有标签写入:
def inject_to_dcm(dcm_path, csv_path, output_path): ds = pydicom.dcmread(dcm_path) # 读取CSV中的检测框 boxes = pd.read_csv(csv_path) # 创建私有标签组(0x0049,0x1001),存储为JSON字符串 detection_json = boxes.to_json(orient='records') # 写入私有标签(需先声明私有字典) ds.PrivateCreator = "YOLOv5-Lung" ds.add_new([0x0049, 0x1001], 'UT', detection_json) ds.save_as(output_path) inject_to_dcm('input.dcm', '001_pred.csv', 'output.dcm')注意:私有标签需在PACS系统中预先配置解析规则,否则仅作存档。本方案是快速验证,非PACS集成标准。
5.3 报告生成自动化:从坐标到临床描述的规则引擎
results.csv是训练日志中的汇总表,但临床需要自然语言报告。项目附带report_generator.py,将检测结果转化为放射科术语:
| 检测框属性 | 临床映射规则 | 示例输出 |
|---|---|---|
| 面积<25px² | “微小结节(<5mm)” | “右肺上叶见1枚微小结节,直径约3mm” |
| 面积25~100px² | “小结节(5-10mm)” | “左肺下叶背段见2枚小结节,最大径约8mm” |
| 长宽比>2.5 | “条状影(考虑血管走行)” | “右肺中叶见条状影,沿血管分布,建议随访” |
| 与胸膜距离<10px | “胸膜牵拉征” | “左肺上叶尖后段结节伴胸膜牵拉征” |
该规则引擎不依赖NLP模型,纯基于几何特征,确保100%可解释性。你只需修改report_rules.json即可适配本院报告模板。
5.4 从“能跑通”到“敢用在病人身上”:我的三条铁律
做完上述所有步骤,模型在测试集上mAP=0.89,但离临床可用还有鸿沟。我带团队落地3家三甲医院肺结节AI系统,血泪教训凝结为三条必须执行的检查:
- 必须用本院设备采集的CT做盲测:哪怕只有10例,也要覆盖不同kVp(100/120/140)、不同层厚(0.625/1.25/2.5mm)、不同重建算法(FBP/IR)。我们曾发现IR重建图像上mAP骤降0.15,因噪声纹理被误判为结节。
- 必须人工复核所有FP和FN:导出
runs/detect/中所有预测框,让两位主治医师独立标注“真结节/假阳性/无法判断”。若两位医师分歧率>15%,说明结节定义模糊,需修订标注规范。 - 必须记录每例的DICOM元数据:在
results.csv中追加列StudyInstanceUID,SeriesInstanceUID,ImagePositionPatient,确保结果可追溯至原始影像。某次上线后发现1例漏检,靠UID秒定位到是扫描床移动导致层间错位,而非模型问题。
从那以后我每次交付新模型,都强制走一遍这三步——不是为了证明模型多好,而是为了证明:当它说“没结节”时,我敢签字。希望帮到你。
本文还有配套的精品资源,点击获取