简介:面向肺炎影像识别与目标检测任务,这份X光片肺病数据集提供800张原始胸部X光图片,并使用YOLOv7格式完成标注,覆盖细菌性肺炎、新冠病毒、正常肺、结核和病毒性肺炎五种类别,适合医疗影像AI研究者、算法工程师以及相关课程实践。资源共1601个文件,包含800个jpg图像、800个对应txt标注文件以及1个yaml配置文件;其中txt为YOLO标签格式,yaml定义类别名称与路径,解压后即可接入YOLOv7训练流程。整包体积约25.51MB,轻量紧凑,便于下载与快速迭代。已有410人学习下载,说明具备一定参考价值。通过该数据集,用户可直接获得已标注的目标框信息与规范的数据目录结构,省去人工标注环节,可用于模型训练、效果评估或做数据增强、迁移学习等二次开发。
1. 800张YOLOv7格式的X光片肺病数据集:能跑,但别把它当成商用模型
一套X光片肺病数据集,800张原始图片,用YOLOv7标记,可识别细菌性肺炎、新冠病毒、正常肺、结核、病毒性肺炎五类——这样的zip出现在算法工程师手里,通常意味着一个研发起点,而不是一个能直接上线的成品。800张对有监督的目标检测来说并不多,五类分摊后每类平均只有一百多张,它的实际价值是:让训练YOLOv7检测肺部病灶的完整流程在半天内先跑起来。适合三类人:准备做医学影像识别课题的学生、想拿公开小数据验证模型选型的研究者、手上有本地数据但缺少预训练底座的团队。用它之前请先接受两个事实:标签格式未必统一,五类之间在X光片上天然有混淆,后面的每一个坑都从这里长出来。
2. 先校验再训练:YOLOv7标签格式与类别统计的检查脚本
2.1 YOLOv7标注规则:一张图对应一个同名txt,五个数字全是相对坐标
YOLOv7标注不是把框存成xml或json,而是每个目标一行,五列数字:
class_id x_center y_center width heightx_center、y_center是目标框中心的坐标,width、height是框宽和框高,四个值全部除以图片宽高做了归一化,所以都是0到1之间的浮点数。一张X光片对应一个同名txt,CXR_001.jpg对应CXR_001.txt,文件名错一个字符模型就找不到标签。
解压这个zip后,先看目录结构。如果作者是按标准布局给的,通常有images/和labels/两个平级目录,外加一个classes.txt。classes.txt里就是类别顺序,训练时对类别id的映射完全依赖这个文件的顺序,顺序错了,细菌性肺炎和新冠的标签会全部对调,且训练不会报任何错。
这中间有一个很容易看走眼的地方:正常肺这一类的标注策略。YOLOv7是目标检测,每一行代表一个有界框。正常肺如果不打框,那它的txt就是空文件;如果作者给正常肺打了双肺区域的大框,那normal就会作为一个显式类别出现在classes.txt里。两种做法都能训练,但评估口径完全不同。先确认作者选了哪一种,再看下面的脚本。
2.2 标签合法性检查脚本:越界、空txt、类别ID越界一次扫完
拿到zip后先别急着配环境,第一步永远是跑一遍标签检查。我习惯用一个脚本把所有labels文件夹里的txt扫一遍,统计每类框数量,同时抓出坐标越界、宽高为负、字段数不对、类别id越界这几类常见问题。
import os import glob from collections import Counter labels_dir = "labels" class_names = [ "bacterial_pneumonia", "covid19", "normal", "tuberculosis", "viral_pneumonia", ] # 顺序必须以解压目录里的 classes.txt 为准,这里只是最常见的一种排列 bad_files = [] # 记录格式/数值异常的文件 empty_files = [] # 记录空标签文件 cls_box_counter = Counter() total_boxes = 0 tiny_boxes = 0 for txt_path in sorted(glob.glob(os.path.join(labels_dir, "*.txt"))): with open(txt_path, "r", encoding="utf-8") as f: lines = [line.strip() for line in f if line.strip()] if not lines: empty_files.append(os.path.basename(txt_path)) continue for line in lines: parts = line.split() if len(parts) != 5: bad_files.append((os.path.basename(txt_path), f"字段数={len(parts)}")) continue try: cls_id = int(parts[0]) x_c, y_c, w, h = (float(v) for v in parts[1:]) except ValueError: bad_files.append((os.path.basename(txt_path), "数值解析失败")) continue if cls_id < 0 or cls_id >= len(class_names): bad_files.append((os.path.basename(txt_path), f"类别id={cls_id}越界")) continue if not (0.0 <= x_c <= 1.0 and 0.0 <= y_c <= 1.0): bad_files.append((os.path.basename(txt_path), "中心坐标不在[0,1]")) continue if not (0.0 < w <= 1.0 and 0.0 < h <= 1.0): bad_files.append((os.path.basename(txt_path), "宽高非法")) continue cls_box_counter[cls_id] += 1 total_boxes += 1 if w * h < 0.0005: tiny_boxes += 1 print("总框数:", total_boxes) for cls_id in range(len(class_names)): print(f"类 {cls_id} ({class_names[cls_id]}): {cls_box_counter[cls_id]} 个框") print("面积过小的框:", tiny_boxes) print("异常文件:", bad_files if bad_files else "无") print("空标签txt:", len(empty_files))逻辑说明:先按行拆标签,再逐个字段校验。中心点坐标必须落在[0,1]区间,因为YOLO归一化后中心点不会超出图像范围;宽高也必须在(0,1]之间,等于0的框没有意义,大于1说明归一化时用错了图片尺寸。面积阈值0.0005是按640×640输入图估算的,相当于16×16像素,在X光片病灶检测里这类小框绝大多数是标注噪声或误标,需要抽查。
参数说明:class_names的顺序必须和作者给的classes.txt完全一致。如果不一致,类别id对应的名字全错,后面训练的混淆矩阵也没法看。建议把脚本里的class_names替换成classes.txt的实际内容,而不是照抄我这里的猜测顺序。
2.3 图片与标签配对检查:不要漏看“有图无标签”的无框X光片
标签内容合法不代表文件配对正确。如果某张图没有同名txt,YOLOv7训练时会跳过这张图,但你自己可能毫无感知,直到发现800张图实际只训了750张。
from pathlib import Path from PIL import Image images_dir = Path("images") labels_dir = Path("labels") image_files = sorted(list(images_dir.glob("*.jpg")) + list(images_dir.glob("*.png"))) missing_label = [] for img in image_files: txt = labels_dir / (img.stem + ".txt") if not txt.exists(): missing_label.append(img.name) print("有图无标签:", len(missing_label), missing_label[:10]) sizes = [] for img in image_files[:200]: with Image.open(img) as im: sizes.append(im.size) widths = [s[0] for s in sizes] heights = [s[1] for s in sizes] print("图片宽度范围:", min(widths), "-", max(widths)) print("图片高度范围:", min(heights), "-", max(heights))逻辑说明:这一段遍历图片目录,检查每个jpg/png是否有同名txt文件。如果缺失数量很多,先判断是不是作者把正常肺统一留空,还是真的漏标。医学X光片数据集里,正常肺图片的txt为空是常见做法,不代表错误。
参数说明:图片尺寸范围统计我一般只看前200张,而不是全量遍历。X光片分辨率差异很大,有些是1920×1280的高清胸片,有些是512×512的缩略图。如果尺寸跨度超过四倍,建议先统一把长边压到1600再训练,否则dataloader每次做letterbox的缩放成本很高,训练速度会被明显拖慢。
3. 按患者拆train/val/test:分层划分脚本和data.yaml配置
3.1 为什么划分必须按患者而不是按图片:验证集虚高的翻车内幕
目标检测划分有两种做法:按图片随机打散,或按患者分组。很多数据集同一患者会有正位和侧位两张胸片,如果这两张被随机分进train和val,模型等于在验证集上做了开卷考试——它记住了患者特征,而不是病灶特征。训练时val mAP能到0.85,换到外部数据直接掉到0.5,这种翻车基本就是这个原因。
如果文件名里有患者ID,比如patient_001_left.jpg和patient_001_right.jpg,就要按patient_001这个前缀归组。800张的医学小数据集尤其要守这条底线。就算你这个数据集确实一人一张,也按这个逻辑写一遍,至少不会漏。
3.2 按主导类别分层的患者级划分脚本
只按患者分组还不够。五类分布很可能不均匀,新冠可能只有40张,如果随机划分,测试集里可能一张新冠都没有。所以我习惯按每个患者的主导类别做分层,再用sklearn的StratifiedShuffleSplit两次拆分,达到约70% train、15% val、15% test。
from pathlib import Path from collections import Counter, defaultdict import numpy as np from sklearn.model_selection import StratifiedShuffleSplit images_dir = Path("images") labels_dir = Path("labels") image_files = sorted(list(images_dir.glob("*.jpg")) + list(images_dir.glob("*.png"))) def patient_id_of(filename: str) -> str: # 如果文件名是 patient_001_left.jpg,取 patient_001 这一段 return "_".join(filename.split("_")[:2]) patient_to_images = defaultdict(list) for img in image_files: pid = patient_id_of(img.stem) patient_to_images[pid].append(img) # 给每个患者指定一个主导类别:该患者所有标签中出现次数最多的类 patient_dominant = {} for pid, imgs in patient_to_images.items(): cnt = Counter() for img in imgs: txt = labels_dir / (img.stem + ".txt") if not txt.exists(): continue for line in txt.read_text().splitlines(): if line.strip(): cnt[int(line.split()[0])] += 1 patient_dominant[pid] = cnt.most_common(1)[0][0] if cnt else -1 pids = np.array(list(patient_to_images.keys())) dominant = np.array([patient_dominant[pid] for pid in pids]) # 先分出 70% train,剩下 30% 是 val+test 的临时池 outer_split = StratifiedShuffleSplit(n_splits=1, test_size=0.3, random_state=42) train_idx, temp_idx = next(outer_split.split(pids, dominant)) # 再从临时池里平分 val 和 test inner_split = StratifiedShuffleSplit(n_splits=1, test_size=0.5, random_state=42) val_idx, test_idx = next(inner_split.split(pids[temp_idx], dominant[temp_idx])) train_pids = set(pids[train_idx]) val_pids = set(pids[temp_idx][val_idx]) test_pids = set(pids[temp_idx][test_idx]) def save_split(split_name, pid_set): out = [] for pid in pid_set: out.extend(str(p) for p in patient_to_images[pid]) Path(f"{split_name}.txt").write_text("\n".join(out) + "\n") save_split("train", train_pids) save_split("val", val_pids) save_split("test", test_pids) print("train患者数:", len(train_pids), "val:", len(val_pids), "test:", len(test_pids))逻辑说明:先按文件名前缀归组,再统计每个患者所有标签里的类别分布,取最多的类作为该患者的分层标签。StratifiedShuffleSplit会按这个主导类别保持比例,避免小类别在某个集合里消失。第一次拆分拿走30%作为临时池,第二次把临时池一分为二,最终train/val/test接近70/15/15。
需要留意一个边界:如果某个类别只有一个患者,sklearn会在这里报错,因为它无法在一个样本上同时构造出train、val、test。这种情况合理处理方式是把这个患者并入train集合,val和test里放弃这个类别,而不是强行拆分。遇到报错时按这个思路降级。
3.3 划分后目录结构与data.yaml:train/val/test三处路径别写错
划分完成后有三种组织方式。一种是直接在原目录上建子目录,把图片搬进images/train、images/val、images/test,标签同步搬进labels/train等。另一种是把上面生成的train.txt、val.txt、test.txt直接作为YOLOv7的train/val参数,每行一个图片路径。我推荐用txt列表方式,因为不用挪文件,后面换划分时重跑一次脚本就行。
对应的data.yaml模板如下:
train: /absolute/path/to/train.txt val: /absolute/path/to/val.txt test: /absolute/path/to/test.txt nc: 5 names: - bacterial_pneumonia - covid19 - normal - tuberculosis - viral_pneumonia参数说明:train和val必须填绝对路径,YOLOv7的dataloader对相对路径处理不统一,容易在换目录后找不到图片。nc是类别数,必须和names列表长度一致。names顺序必须再对一遍classes.txt,这一步错了,整个训练白跑一小时。
4. 跑通YOLOv7肺病识别训练:最小命令、小数据参数和第一个mAP
4.1 最小训练命令:从官方YOLOv7仓库直接起训
到这一步,标签检查完、数据也划分好了,接下来的流程就是标准的YOLOv7训练自己的数据集。先确认官方YOLOv7环境能正常跑起来,再跑最小训练命令。
python train.py \ --data xray.yaml \ --weights yolov7.pt \ --batch-size 8 \ --epochs 100 \ --img 640 640 \ --workers 4 \ --project runs/train \ --name xray逻辑说明:--weights yolov7.pt表示用COCO预训练权重初始化,而不是从零开始。800张X光片从零训练必过拟合,收敛也慢,迁移学习是唯一务实起点。--img 640 640是训练和验证的统一输入尺寸,YOLOv7在训练时会对原图做letterbox再缩放。
参数说明:batch-size设8是因为X光片虽被缩到640,但原图分辨率大,dataloader的解码开销高于普通自然图像,8G显存的卡上batch 8比较稳妥。如果显存够大可以到16,但不要为了追求batch去压图片尺寸,病灶检测对细节敏感,640×640已经是下限。如果你的YOLOv7版本对--img这个参数不识别,换成--img-size再看python train.py -h确认。
4.2 针对800张小数据集的五个必要调整:freeze、mosaic、rect与batch
800张图属于小样本,直接按默认参数跑能出结果,但mAP大概率不理想。我一般会做五个调整。
第一是冻结backbone前几十层。用预训练权重起步后,前面几个epoch让特征提取层学会的是COCO里的通用纹理,而不是肺部结构。在小数据集上直接全量微调容易让backbone被少数样本带偏。YOLOv7的train.py支持--freeze 50,含义是冻结前50层,先训练检测头;跑20个epoch后再去掉这个参数继续微调全部层。
第二是保持mosaic增强开启。800张样本太少,mosaic每次把四张图拼成一张训练,相当于扩大了单次看到的上下文。但mosaic对胸片有一个副作用:四张X光片拼接后,病灶区域会被压缩,小病灶更难学。如果训练到一半发现val mAP停滞,可以关掉mosaic再续训几次,有时反而涨点。
第三是按需开启rect训练。X光片长宽比很夸张,letterbox到640×640会留下大量灰边,浪费计算量。YOLOv7训练时加--rect会在每个batch内按相近长宽比分组,减少灰边。如果你的数据集图片尺寸差异大,这个参数对训练速度和收敛都有帮助;如果图片本身就比较统一,开不开无所谓。
第四是把epochs放到200到300。100个epoch在800张图上看,每张图平均只被训练100次,加上mosaic的裁剪,实际有效迭代不够。我倾向先跑100个epoch看趋势,再决定续训到多少,而不是一次设满。
第五是降低预期。五类肺病检测里,新冠和病毒性肺炎在X光片上本来就难分,正常肺的框法又可能不统一,不要盯着总mAP看,分开看每个类别的AP。
4.3 训练完先看这四张图:混淆矩阵、PR曲线、loss曲线和bad case
训练结束不要只看最后一行mAP。打开runs/train/xray/目录,里面有YOLOv7自动保存的结果图,按下面顺序看。
先看混淆矩阵。重点关注covid19和viral_pneumonia这两个类之间的互相串门比例。如果模型把新冠大片区域标成病毒性肺炎,不是模型坏了,而是X光片本来提供不了足够信息。再看normal类:如果normal被识别成各种肺炎的比例很高,大概率是正常肺的标签框法有问题,回头看第2章的检查。
再看PR曲线。每个类单独有一条曲线,曲线越贴近右上角越好。正常肺如果曲线形状怪异,先检查一下是不是标签里“正常肺”的框既包含双肺又包含心脏区域,导致模型没学到稳定特征。
loss曲线要看train和val两条线的开口方向。train loss持续下降而val loss在某个epoch后回升,是典型过拟合。800张数据过拟合出现在50-80个epoch之间很常见,这时候最优checkpoint往往不是最后一个,而是val loss最低的那个。YOLOv7会在训练中保留best.pt,但如果best是基于val loss选的,留意它对应的epoch是否太早。
最后挑几张bad case画框看。val批次里预测置信度低于0.3但实际有GT框的图,用detect.py重新推理一次,看看模型是漏检了还是框偏移了。这一步能直接暴露出标签里没发现的错误,比如某张图标注框只框住了病灶的一半,模型学到的自然也是“半框”。
5. 五类肺病数据集绕不开的五个坑:现象、原因与排查
5.1 正常肺的“框法”没有统一:为什么normal类mAP忽高忽低
现象:训练过程一切正常,但normal类的precision和recall始终有一个偏低,PR曲线抖动剧烈,val mAP在不同seed下变化很大。
原因:正常肺在X光片里没有病灶可框,作者的标注策略不一致。一部分图打了双肺大框,一部分图是空txt,还有一部分可能只框了一个肺。模型在“正常肺到底框哪里”这件事上接收到的信号是矛盾的。
解决:二选一,然后全数据集统一。如果你想显式输出“正常肺”检测框,就把每张正常图的双肺区域都标上,两个单肺框或一个包含双肺的大框都可以,但必须统一;如果不想标框,就把正常图对应的txt全部清空,训练目标变成“异常区域检测”,推理时无输出即视为正常。两种方案都能跑,最怕的是混着来。
5.2 新冠与病毒性肺炎在X光片上天然长得像
现象:混淆矩阵里covid19和viral_pneumonia互相串门,两者的AP都低;拆开bad case看,模型把新冠的磨玻璃影标成了病毒性肺炎,把病毒性肺炎的实变标成了细菌性肺炎。
原因:这是医学影像本身的边界,不是模型问题。X光片上新冠早期表现就是磨玻璃影,和普通病毒性肺炎几乎没有差异;实变影在细菌性和病毒性肺炎里也都会出现。800张X光片样本量扛不起这种病原体级别的区分。
解决:降级任务。把它当成“正常、细菌性肺炎、结核、其他非细菌性肺炎”四类来评估,新冠并入病毒性肺炎。如果一定要区分新冠,需要CT切片或临床病史参与,单靠胸部X光片的检测模型不现实。强行训练五个类只会得到一个看起来有五个输出、实际两个类靠猜的模型。
5.3 标签坐标越界与异常框:训练loss震荡的老油条式排查
现象:训练loss整体下降但震荡幅度大,val集上预测框经常扁成一字形,或者贴在图边缘。
原因:标签坐标里混入了越界数据。典型情况是标注工具导出时把像素值当成归一化值,或者宽高写成了除以图宽但没有除以图高。
解决:别急着调超参数,先跑第2章的检查脚本。找出所有x_c + w > 1或y_c + h > 1的框,clip到边界并记录。如果异常框超过5%,直接开训会把模型带偏,最稳的办法是把这些标签导出成图片画框人工检查一轮。健康的数据集里这类问题应该一个都没有。
5.4 同设备、同患者的“数据泄漏”让验证集虚高
现象:随机划分后val mAP能到0.85,拿外部X光片一测只有0.5,差距大到不正常。
原因:划分时没有按患者分组,同一患者的正位片和侧位片分别进了train和val;更隐蔽的是,整个数据集如果来自同一台设备,设备自带的影像后处理算法会形成一种“设备伪影指纹”,模型学到的可能不是病灶,而是这台设备产出的图像风格。
解决:先按第3章的脚本按患者ID重新划分。如果全部图片来自同一台设备,那跨设备的泛化问题是无解的,这时模型的真实能力要以外部数据为准,而不是以val mAP为准。这个限制要写进项目结论里,别等上线再发现。
5.5 把检测框当成确诊结论
现象:demo图上检测框画得很漂亮,五类标签齐全,于是想直接接进临床系统或体检报告。
原因:800张图的样本量只能覆盖有限设备、有限人群、有限疾病阶段;标注的框边界是标注者的主观判断,模型学到的是“标注者认为哪里像病灶”,不是病理诊断。
解决:把模型定位成辅助筛查或科研原型。如果它的输出要进临床流程,需要拿本地多设备数据重新验证,并走完整的医学软件合规路径。这一步不能省,也别想用公开数据集绕过。
6. 用best.pt对本地X光片推理:一句话命令和两个置信度经验
6.1 最小推理命令:detect.py与两个阈值参数
训练完拿到runs/train/xray/weights/best.pt之后,真正的yolov7部署环节反而简单。官方仓库自带的detect.py就是最小推理入口:
python detect.py \ --weights runs/train/xray/weights/best.pt \ --source ./demo.jpg \ --conf-thres 0.30 \ --iou-thres 0.45 \ --project runs/detect \ --name xray_demo参数说明:--conf-thres是置信度阈值,YOLOv7默认0.25。医学检测场景我习惯提到0.30以上,因为漏检比误检更危险,宁可多给一个低置信度框让医生看,也不要悄悄丢掉可能是病灶的区域。--iou-thres是NMS的IoU阈值,默认0.45。X光片病灶框相对稀疏,一般不用动;如果你发现同一个病灶总被拆成两个相邻框,可以适度提到0.5。
6.2 批量推理后先做类别计数,而不是只看效果图
推理后多数人直接翻开带框的效果图,但效果图看不出类别分布是否合理。我遇到过一个场景:模型把整批测试图全预测成了“正常”,看效果图才发现所有图上根本没有任何框,而这批图里有大量肺炎病例。这类问题在类别不平衡时特别容易出现,所以批量推理时加上--save-txt再统计一下输出:
python detect.py \ --weights runs/train/xray/weights/best.pt \ --source ./test_images \ --conf-thres 0.30 \ --save-txt \ --project runs/detect \ --name batch_test统计脚本很短:
from collections import Counter from pathlib import Path result_labels = Path("runs/detect/batch_test/labels") cnt = Counter() for txt in result_labels.glob("*.txt"): for line in txt.read_text().splitlines(): if line.strip(): cls_id = int(line.split()[0]) cnt[cls_id] += 1 print(cnt)逻辑说明:detect.py会把每个检测结果写到与图片同名的txt里,行格式和训练标签一致。统计这些txt里的类别id数量,就能快速判断模型是不是在偷懒——比如把一切都预测成正常,或者某个类完全没输出。类别id对应关系必须与训练时的classes.txt一致。
做医学影像检测这几年,我吃过的最大亏都在代码跑通之后:没有按患者划分导致验证集虚高、没检查标签导致类别错位、没做类别计数导致模型“空场”还不自知。现在拿到任何数据集,我的第一个动作永远是先跑标签统计脚本,而不是急着吹环境。这个习惯救过我很多次,希望你也能在开工前先踩一遍这套校验流程,而不是在训练到一半时才发现数据集的标签黑匣子里藏着一堆坑。希望帮到你。
本文还有配套的精品资源,点击获取