简介:面向医学影像AI入门者的天池“数字人体”赛场一肺部CT多病种智能诊断完整方案包,聚焦病灶检测与假阳性衰减两大核心任务。代码基于YOLOv3完成病灶定位,并串联ResNet网络降低误检,整体流程简洁直接,适合作为新手baseline参考。资源共27个文件,以Python脚本(14个py)为主,辅以pyc缓存、txt标注/说明、模型配置cfg及图片示例,压缩包仅147KB,轻量易部署。已有58人学习浏览,可用于快速理解赛题数据组织与YOLOv3+ResNet的训练、测试及锚点聚类思路。包内目录结构清晰,README与代码注释便于对照运行,是参加医疗影像竞赛或入门目标检测的实用素材。
1. 天池“数字人体”赛场一:YOLOv3+ResNet的肺CT多病种baseline长这样
2019年天池全球数据智能大赛“数字人体”赛场一,核心任务是肺部CT多病种智能诊断。这份资源不是花哨的最新架构,而是一套很朴素的组合:YOLOv3负责在CT切片上框出疑似病灶,ResNet负责对框出的区域做二次判断、衰减假阳性。它在初赛B榜的成绩是41/1635,复赛35,准确率不算顶尖,但胜在流程完整、代码能直接拆开来看,特别适合第一次接触医学影像检测的人拿来当baseline跑通全流程。我在Windows上复现过一次,从数据转换、训练到推理出结果,整套链路两三个小时就能走完,这对新人来说是很大的优势。
2. 把天池CSV标注转成YOLO格式:convert.py与数据配对的关键
2.1 标注CSV的字段确认与转换脚本主逻辑
天池的标注文件是chestCT_round1_annotation.csv,不是VOC那种xml格式。YOLOv3需要的是每张图对应一个同名txt,txt每行是 class x_center y_center w h,全部归一化。所以拿到这份资源后的第一件事,是先搞清楚CSV里到底哪几列是坐标、哪几列是类别,而不是急着跑训练。
我复现的时候习惯先做一步侦察:只读前几行,把列名打印出来,确认天池给的坐标是(x1,y1,x2,y2)还是(x,y,w,h)。这一步能避免后面整个转换脚本白写。
import pandas as pd ann = pd.read_csv("data/chestCT_round1_annotation.csv", nrows=5) print(ann.columns.tolist()) print(ann.head())说明:先看列名再写转换,是最稳的做法。不同赛题、不同年份的标注列名经常不一样,写死列名会翻车。
确认列名后,转换逻辑大概是下面这样。注意我这里假设列名是filename、category、x1、y1、x2、y2,如果你的CSV列名不同,改成实际列名即可。
import pandas as pd import os from PIL import Image ann = pd.read_csv("data/chestCT_round1_annotation.csv") label_dir = "data/labels" os.makedirs(label_dir, exist_ok=True) # 按文件名分组,一个文件名一个txt for img_id, group in ann.groupby("filename"): img_path = os.path.join("data/train_part1", img_id) w, h = Image.open(img_path).size # 从原图读取宽高 lines = [] for _, row in group.iterrows(): cls_id = int(row["category"]) - 1 # 天池类别编号从1开始,YOLO从0开始 x1 = float(row["x1"]); y1 = float(row["y1"]) x2 = float(row["x2"]); y2 = float(row["y2"]) cx = (x1 + x2) / 2.0 / w cy = (y1 + y2) / 2.0 / h bw = (x2 - x1) / w bh = (y2 - y1) / h lines.append(f"{cls_id} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}") txt_path = os.path.join(label_dir, img_id.replace(".png", ".txt")) with open(txt_path, "w") as f: f.write("\n".join(lines))说明:坐标必须归一化到0到1之间,否则YOLO训练时loss收敛会很奇怪。宽高我是直接从原图读取的,不要用CSV里可能给出的其他尺寸字段,一旦坐标和图像尺寸不是同一套坐标系,所有框都会偏。
参数说明:cls_id减1这个操作很容易漏掉。YOLOv3的类别索引从0计,如果原CSV里类别从1开始,不减1会导致训练一切正常,但最终预测框的每个类别都错一位。lt_classes.txt里的顺序就是类别索引的依据,写转换脚本前先拿文本编辑器打开它看一遍。
2.2 get_image_and_label.py:只保留有标注的切片
train_part1到train_part5里是完整的CT序列切片,其中很大一部分是正常组织切片,没有任何病灶标注。如果不筛选,YOLO训练列表里会混入大量没有对应txt的图片,模型会一直在学“这张图没有目标”的负样本。
get_image_and_label.py做的就是这件事:遍历数据目录,找到那些在标注CSV里出现过、并且转换出了txt文件的图片,把它们单独复制到一个干净目录,同时生成一份配对的训练列表。
import os import shutil src_img_dir = "data/train_part1" label_dir = "data/labels" out_dir = "data/train_clean" os.makedirs(out_dir, exist_ok=True) valid = [] for name in os.listdir(src_img_dir): if not name.endswith(".png"): continue txt = os.path.join(label_dir, name.replace(".png", ".txt")) if os.path.exists(txt): shutil.copy(os.path.join(src_img_dir, name), os.path.join(out_dir, name)) valid.append(name) # 写出train.txt,供YOLO读取 with open("data/train.txt", "w") as f: for v in valid: f.write(os.path.join(out_dir, v) + "\n")说明:我这里只写了train_part1作为示例,实际使用中要遍历train_part1到train_part5全部目录。有人图省事,直接拿整个train_part的图片列表去训练,最后损失函数能降,但模型会偏向输出“无病灶”,因为背景样本太多了。
这一步本质是在做样本配对。CT切片本身有大量负样本,这个比赛的得分点主要来自正样本的召回,不筛掉那些没标注的切片,模型就学不到“什么是有病灶的区域”。
2.3 训练列表生成与Windows路径适配
关键词“Windows”,在这份代码里是绕不开的话题。README默认是Linux路径写法,放在Windows上跑,os.path.join拼接出来的路径经常混入反斜杠,而YOLO代码里有些地方用字符串拼接而不是join,就会拼出无法识别的路径。
我一般会在Windows上把所有路径统一成绝对路径,并且强制使用正斜杠:
import os def abs_posix(p): return "/".join(os.path.abspath(p).split(os.sep)) train_path = abs_posix("data/train_clean") print(train_path)说明:yolo3目录下的model.py和utils.py里很多地方直接读文件,路径里出现反斜杠在Linux风格的拼接下会报FileNotFoundError,但文件明明就在那。统一成绝对posix风格能省很多事。
另一个Windows专属问题是中文目录名。OpenCV在Windows下读取包含中文的路径时会返回None,整个项目最好放在纯英文路径下,图片路径里也尽量避免中文字符。这是一条血泪经验,我之前因为桌面路径带中文,卡在“图片读不出来”上排查了整整一个小时。
3. 重新算anchor:不替换yolov3.cfg里的默认锚框,检测AP会掉得很难看
3.1 为什么CT病灶必须用kmeans重算anchors
YOLOv3默认的anchors是在VOC或COCO上聚类出来的,目标大多是行人、车、动物,尺寸覆盖和长宽比都很宽。肺部CT里的病灶是结节或炎症的横截面,尺寸集中在几十个像素,长宽比接近1比1。
直接用默认anchor的后果是:小目标召回归零,或者一个病灶被拆成好几个互相重叠的框。YOLO的三个尺度特征图分别负责大、中、小目标,如果anchor没匹配到病灶的尺寸分布,小目标那一层基本等于废的。
kmeans-anchor-boxes-master目录就是干这个的。原理不复杂:把所有标注框的宽和高提取出来,用kmeans聚成9个中心(YOLOv3有3个尺度,每个尺度3个anchor),距离度量用1减去IOU,而不是欧氏距离。用IOU作为距离,聚类结果更贴近检测框的重合需求。
这一步不做,后面练多久都是在跟自己的数据打架。因为标注框的长宽比和尺寸范围决定了anchor初始值,初始值不对,训练时回归的起点就错了。
3.2 提取bbox并聚类,算九个anchor
从第2章转换好的标签txt里,把w和h读出来,整理成kmeans脚本能读的格式。每个txt行格式是 class cx cy w h,只需要取最后两位。
import os import numpy as np label_dir = "data/labels" all_wh = [] for txt in os.listdir(label_dir): if not txt.endswith(".txt"): continue for line in open(os.path.join(label_dir, txt), "r").read().strip().split("\n"): parts = line.split() if len(parts) < 5: continue w = float(parts[3]) h = float(parts[4]) all_wh.append((w, h)) np.savetxt("data/bbox_wh.txt", np.array(all_wh), fmt="%.6f")说明:这一段导出的是归一化后的宽高。如果你的kmeans脚本要求像素尺寸,那就统一乘416再写进去。两种写法最后都能算出anchor,关键是算出来的结果写入cfg时单位要对。
然后跑kmeans:
python kmeans-anchor-boxes-master/kmeans.py data/bbox_wh.txt 9 data/anchors.txt说明:第二个参数是聚类数量,YOLOv3写作9。跑完打开anchors.txt,看到的是9个聚类中心。如果脚本输出的是归一化值,写入cfg前每个值乘416,因为yolov3.cfg里的anchors是相对输入尺寸的像素值。
3.3 把anchor写回cfg和lt_yolo_anchors.txt
yolov3.cfg里一共有3处anchors配置,对应3组不同的mask。要把9个值按从窄到宽的顺序整理好,替换掉原来的默认值。lt_yolo_anchors.txt是这份代码训练时自己读取anchor的文件,也要同步更新。
我习惯用一段小脚本同时改两个地方,避免手改出错:
import re import numpy as np boxes = np.loadtxt("data/anchors.txt") boxes *= 416 # 如果聚类用的是归一化值 # 按面积排序,保证小anchor在前 areas = boxes[:, 0] * boxes[:, 1] boxes = boxes[np.argsort(areas)] anchor_str = ", ".join([f"{int(w)},{int(h)}" for w, h in boxes]) # 改cfg cfg_path = "yolov3.cfg" cfg = open(cfg_path, "r").read() cfg = re.sub(r"anchors = .*", f"anchors = {anchor_str}", cfg) open(cfg_path, "w").write(cfg) # 同步lt_yolo_anchors.txt open("lt_yolo_anchors.txt", "w").write(anchor_str.replace(",", " "))说明:改完之后,务必打印一遍三个mask对应的anchor分组,确认是按尺度从细到粗排的三组,而不是混在一起。YOLO的输入尺寸是416,下采样倍数32,特征图依次是13x13、26x26、52x52。小的anchor对应52x52这层负责小目标,大的anchor对应13x13这层负责大目标。如果顺序乱掉,小目标检测直接失效。
参数说明:输入尺寸416不是死的,显存不够可以换成320或352,但改了输入尺寸后anchor也要按比例缩放。lt_yolo.py里通过--input_shape指定输入大小,默认416,新人不要轻易动这个参数。
4. YOLOv3训练参数与ResNet假阳性衰减:两阶段链路怎么搭
4.1 两阶段训练:冻结backbone解冻,learning rate怎么给
这个比赛的数据量不大,但一次训练到位不现实。我的做法是先冻结backbone的Darknet53权重,只训练head部分,等loss降到平台期,再解冻整个网络用更小的学习率微调。这套策略在Windows下能正常跑,和操作系统无关,只看你的显卡显存。
lt_train.py里把两个阶段分开了,关键参数大概是这样:
FREEZE_EPOCHS = 50 FREEZE_BATCH_SIZE = 8 FREEZE_LEARNING_RATE = 1e-3 UNFREEZE_EPOCHS = 50 UNFREEZE_BATCH_SIZE = 8 UNFREEZE_LEARNING_RATE = 1e-4说明:冻结期batch_size设8,8G显存的卡勉强够用,如果训练时报OOM,降到4。解冻期梯度占用更大,batch_size保持一样比较稳。学习率从1e-3起步,解冻后降到1e-4,这个组合在baseline里很稳。
参数说明:train.py里通常还有一个开关控制是否先训练backbone。如果你发现val loss始终在0.5以上震荡,先检查冻结期学习率是不是设太高了,再检查数据有没有配好对。
4.2 lt_yolo.py的训练入口和windows下的路径坑
lt_yolo.py是训练主入口,常见做法是加载yolov3.weights作为预训练权重。这里有个关键点:官网的yolov3.weights是在COCO上训练的,backbone部分的参数可以直接复用,但head部分的输出维度是根据类别数定的,一旦classes数量不是80,加载时就会报shape不匹配。
解决方案有两种:一种是用脚本把权重截断,只保留前75层的backbone参数;另一种是加载时跳过head层。这份代码里已经预留了处理方式,重点是不要一看到shape报错就以为权重文件坏了。
Windows下训练指令大概是:
python lt_yolo.py --model_path model_data/lt_yolo.h5 --classes_path model_data/lt_classes.txt --anchors_path model_data/lt_yolo_anchors.txt --input_shape 416,416 --batch_size 8 --epochs 50说明:--classes_path和--anchors_path这两个参数经常有人忘记指定,导致程序走了默认的VOC类别和默认anchor,完全跑在你的病灶数据上。启动训练后,观察前10个epoch的loss,单方向下降就正常。如果loss在0.8附近横跳,先检查anchor有没有生效,而不是急着调学习率。
4.3 ResNet衰减假阳性:切图、训练、阈值筛选
YOLO输出的框只能叫“疑似病灶”,里面混了大量正常组织纹理。这个比赛把这种误检称作假阳性,需要在后面衰减。做法是:把每个预测框在原图上裁剪出来,缩放成固定尺寸,训练一个二分类ResNet,判断“这个框里是真病灶还是假阳性”。
get_image_and_label.py和generate_the_image.py干的是同一件事:从训练集标注框裁切图,按阳性、阴性分目录存放。ResNet_train.py读这些目录训练二分类模型。
# 从训练集标注框裁切图,供ResNet二分类使用 def crop_boxes(img, boxes, out_dir, size=(64, 64)): for i, (x1, y1, x2, y2) in enumerate(boxes): crop = img[y1:y2, x1:x2] crop = cv2.resize(crop, size) label = "pos" if is_true_positive(x1, y1, x2, y2) else "neg" cv2.imwrite(f"{out_dir}/{label}/box_{i}.png", crop)说明:负样本不是随便扣一块背景,而是取YOLO自己预测出来的那些假阳性框。这样ResNet学到的就是“YOLO的错长什么样”,比盲采背景有效得多。这个细节决定了FP衰减的效果。
训练完ResNet后会输出一个概率,大于阈值判为病灶。给YOLO的每个框打这个分,低于阈值的直接丢掉。阈值一般从0.3试到0.7,根据验证集的表现来调。
5. 实战避坑:练不到收敛的五个典型场景
5.1 坑一:anchors明明算了却没生效
现象:训练前用kmeans算好了9个anchor,也写进了lt_yolo_anchors.txt,但训练两天后检测结果还是整体偏大,小病灶几乎全丢。
原因:lt_yolo.py初始化时可能直接从cfg解析anchors,而你没有改yolov3.cfg里的那三行;或者训练命令没带--anchors_path,程序fallback到默认anchor。
解决:启动训练前打印一行模型实际读取的anchor值,确认是不是你算的那9个。把cfg和txt同步更新后再跑,两条路径都要改。
5.2 坑二:图片和标签配对错位
现象:loss正常下降,验证集loss也还行,但预测框和病灶完全不搭,甚至每个框都明显偏大。
原因:直接拿train_part原始图片列表做训练,跳过了get_image_and_label.py的筛选步骤,train.txt里混入了大量没有对应txt的图片。
解决:回到第2章,先转换标注,只保留有标注的图片,再生成train.txt。这一步不能省,省了就是后续所有问题的源头。
5.3 坑三:Windows路径反斜杠导致OSError
现象:Windows下跑一半报FileNotFoundError,但文件确实存在。或者OpenCV读图返回None。
原因:代码里某些位置用字符串拼接路径,Windows的反斜杠被解析成了转义符。中文目录名在OpenCV里兼容性很差。
解决:整个项目放在纯英文路径下,训练列表里的路径统一用正斜杠。凡是读文件的地方,先用os.path.exists检查一遍,确认路径真实存在再往下走。
5.4 坑四:loss直接变nan
现象:第一个epoch没走几步loss就成nan,或者训练到中途某个step突然变nan。
原因:学习率太大。训练样本里混入了全黑的CT裁剪图,归一化后像素值全是0。标注框里出现了宽度或高度为0的非法框。
解决:先扫描所有txt标签,删掉w或h为0的行;然后把学习率降到1e-4重启一次;如果还nan,检查预训练权重加载是否正确。顺序不能乱,数据问题永远排在参数问题前面。
5.5 坑五:不训练ResNet,直接提交YOLO结果
现象:检出的框数量巨大,一张CT切片能框出二三十个“病变”,提交上去全被判定为假阳性。
原因:YOLO在召回上越激进,假阳性就越多。CT上正常组织纹理极易被当成病灶,天然需要二次筛选。
解决:把ResNet训练好,对每个yolo框做分类,低分框过滤掉。这个步骤对最终成绩的影响,往往比把YOLO调得更准还要大。有人觉得加一个模型很麻烦,实际ResNet训练很快,二分类几分钟一个epoch,投入产出比非常高。
6. 从testA原图到多病种结果:一条可复现的验证链路
6.1 单张推理脚本lt_yolo_image.py的用法
在提交最终结果前,我建议先用lt_yolo_image.py在testA里挑几张典型的图,把检测框可视化出来看看。推理脚本一般长这样:
python lt_yolo_image.py --image_path data/testA/sample_001.png --output output/result.jpg说明:输出图上会叠加所有未被过滤的框。这一步的价值比任何指标都直观,你第一眼就能看出来模型到底学到了什么——是精准锁定病灶,还是满图乱画框。
6.2 ResNet二次判断把假阳性压下来
对单张图跑完YOLO后,把框切出来送ResNet_test.py过一遍,分数低于阈值直接丢弃,高于阈值才保留。效果在单张图上特别明显:原本十来个框,过滤后剩两三个,保留下来的绝大多数是真病灶。
boxes = yolo_predict(img) for b in boxes: crop = img[b.y1:b.y2, b.x1:b.x2] score = resnet_predict(crop) if score < 0.5: boxes.remove(b)说明:阈值0.5是默认值,实际可以看验证集表现从0.3调到0.7。分数阈值调低,召回高但假阳性也多;调高则反过来。这个取舍就是比赛最后阶段涨点的关键。
6.3 用漏检和误判两个指标判断基线是否合格
验证时不要只看提交分数。我一般手动翻20张testA图,数两件事:一是有没有真病灶没被框出来,二是保留的框里有多少个是错的。漏检比假阳性更致命,因为医疗场景里漏掉一个病灶远比多框几个严重。基线合格的标志是:20张图里漏检不超过两三个,保留框里的假阳性不超过一半。如果漏检多,回头调anchor和置信度阈值;如果假阳性多,优先提升ResNet的训练数据质量。
从那以后,我每次复现这类医学影像检测baseline,都会强制走一遍“原图出框、肉眼检查、假阳性衰减、再验证”的闭环,而不是只盯着训练loss。这套流程看起来笨,但至少能让你知道自己的模型到底学到了什么。希望帮到你。
本文还有配套的精品资源,点击获取