肺结节CT目标检测:VOC格式xml解析与yolov8训练实践
发布时间:2026/9/16 7:46:10来源:尧图网络
简介面向医学图像目标检测研究者与开发者这是一份可直接投入模型训练的肺结节CT图像目标检测数据集。数据按VOC标注格式提供图像均为512×512分辨率的RGB图片统一为单类别“肺结节”目标省去自行标注与格式转换的繁琐环节。资源包内共499个文件体积约5.14MB包含248张jpg图像与248个xml标注文件并配有1个json类别文件、1张可视化效果示例png以及1个无需修改即可运行的Python可视化脚本。数据集在data目录下已划分train与test两个子集每个子集中images与labels分目录存放便于直接套用常见目标检测框架的训练流程。当前已有813人学习使用。配套脚本支持随机传入图片即可绘制边界框并保存结果方便快速核对标注质量与模型输出。整体尺寸紧凑、标注规范适合入门医疗图像检测研究、开展基准测试或用于课堂教学演示。1. 训练肺结节检测模型的第一关往往是VOC格式的xml拿到一套肺结节CT目标检测数据集真正的门槛不是模型选型而是把标注文件读明白。CT图像和自然图像最直接的差别是坐标体系要从毫米换算成像素窗宽窗位要重新量化结节还可能跨多个切片。再加上VOC标注格式的xml文件里经常带自定义字段解析脚本写得不对轻则框错位一两个像素重则训练时根本没有样本被加载。这篇内容针对“肺结节CT目标检测数据集VOC标注格式的xml文件”这一场景讲清楚xml字段结构、从CT原始数据构造VOC数据集的流程以及在yolov8上加载这套数据时最值得调的参数和最容易踩的坑。适合目标检测算法工程师、医学影像AI方向的研究生以及准备用公开肺结节数据集跑通完整流程的开发者。数据来源的合规性和脱敏处理是前提这里只讨论技术路径。2. VOC标注格式的xml文件结构肺结节目标框与特征字段2.1 一个结节标注xml的标签层级VOC标注格式的xml文件之所以能长期存活核心原因是它足够简单一个object对应图像里的一个目标目标框用bndbox四元组表达。医学图像数据集的作者经常在标准VOC字段之外追加自定义节点用来保存结节的征象信息。下面是一个常见结构annotation folderlung_nodule/folder filenamecase_0001_0018.png/filename path/data/JPEGImages/case_0001_0018.png/path source databaseLIDC-style dataset/database /source size width512/width height512/height depth3/depth /size segmented0/segmented object namenodule/name poseUnspecified/pose truncated0/truncated difficult0/difficult bndbox xmin214/xmin ymin278/ymin xmax243/xmax ymax307/ymax /bndbox nodule_attribute subtlety5/subtlety internalStructure1/internalStructure calcification6/calcification /nodule_attribute /object /annotation解析这套结构时bndbox里的坐标就是像素坐标直接对应到size声明的宽高。difficult在VOC里的语义是“肉眼也难以判定的目标”训练时应丢弃truncated表示目标被图像边界截断医学图像里通常是结节位于扫描视野边缘。提示VOC本身没有结节征象字段LIDR/LIDC等权威数据集在转存时常以nodule_attribute这类自定义节点保存。脚本里读不到这个节点时不要报错应返回默认值。这些扩展字段对模型训练本身没有直接作用但对数据清洗很有价值。比如subtlety代表结节显著性显著性极低的结节框往往不用于训练因为模型学到的是不确定标注。2.2 CT图像坐标像素坐标还是毫米坐标VOC的bndbox严格使用像素坐标而CT的DICOM或NIfTI数据存储的是世界坐标或图像坐标。读取时必须先搞清楚三个量origin世界坐标系原点、spacing像素间距单位mm和direction方向余弦。从世界坐标转像素坐标的通用公式为col (x_mm - origin[0]) / spacing[0] row (y_mm - origin[1]) / spacing[1]这里的陷阱在于numpy数组的下标顺序。SimpleITK读出的数组shape是(z, y, x)如果直接用array[x][y]去取下标会把空间方向搞反。建议统一先把numpy数组的轴顺序换成(x, y, z)或者写一个小工具函数避免每个脚本里重复犯错。另一个容易被忽略的细节是VOC的bndbox采用1-based索引而numpy和yolo都默认0-based。1像素在512×512的图上影响很小但对需要预测精确轮廓的模型来说统一格式很重要。工程上的做法是解析xml时按1-based读入转换成yolo归一化坐标时先减1再除以宽高。2.3 用python解析xml提取结节框与征象字段标准库xml.etree.ElementTree足够处理这类标注文件import xml.etree.ElementTree as ET from pathlib import Path def parse_voc_xml(xml_path): xml_path Path(xml_path) tree ET.parse(xml_path) root tree.getroot() size root.find(size) width int(size.find(width).text) height int(size.find(height).text) filename root.find(filename).text boxes [] labels [] attrs [] for obj in root.iter(object): name obj.find(name).text if name not in (nodule, non_nodule): continue bndbox obj.find(bndbox) xmin float(bndbox.find(xmin).text) ymin float(bndbox.find(ymin).text) xmax float(bndbox.find(xmax).text) ymax float(bndbox.find(ymax).text) box [xmin - 1, ymin - 1, xmax - 1, ymax - 1] boxes.append(box) labels.append(name) attr {} attr_node obj.find(nodule_attribute) if attr_node is not None: for child in attr_node: attr[child.tag] child.text attrs.append(attr) return { filename: filename, width: width, height: height, boxes: boxes, labels: labels, attrs: attrs, }这段代码统一把坐标转成了0-based后续喂给任何模型都不用二次转换。解析nodule_attribute时用if attr_node is not None兜底兼容不含征象字段的简单VOC标注。labels保留字符串而不是整型id因为不同数据集的类别映射不一致字符串在调试时一眼能看出问题训练前再映射成数字。3. 肺结节CT目标检测数据集制作从NIfTI切片到VOC目录3.1 标准VOC目录结构与类别定义VOC标注格式的约定不只是xml本身还包括目录布局。拿到公开数据集时先检查是否满足下面的结构VOCdevkit/ └── VOC2007/ ├── JPEGImages/ │ ├── case_0001_0018.png │ └── case_0001_0019.png ├── Annotations/ │ ├── case_0001_0018.xml │ └── case_0001_0019.xml └── ImageSets/ └── Main/ ├── train.txt ├── val.txt └── test.txttrain.txt每一行只写文件名不带扩展名。这个约定和coco2017数据集结构的json方案形成了两条路线COCO把所有标注塞进一个annotations.json类别多、目标多时查询效率高VOC用xml按图像分文件单张图的目标很少调试时可以单独打开一个xml文件检查不需要加载全量标注。医学影像标注跨医生校对用xml逐文件审查更自然。类别定义放在哪很多公开数据集只有nodule一类就把nc1即可。有的数据集把肺结节和非结节都标了类别就变成两类。这里要特别检查difficult的分布如果difficult1的框占了总量20%以上说明标注者自身对边界也不确认训练时应过滤。3.2 从nii切片生成jpg/png并同步写xml拿到NIfTI或DICOM序列时第一步是把三维体数据转成二维切片同时对每个切片生成对应的VOC xml。下面是用SimpleITK做转换的骨架import SimpleITK as sitk import numpy as np import png import os def nifti_to_voc_slices(nii_path, mask_path, output_img_dir, output_xml_dir, hu_min-1000, hu_max400): img sitk.ReadImage(nii_path) mask sitk.ReadImage(mask_path) if mask_path else None arr sitk.GetArrayFromImage(img) # shape: (z, y, x) spacing img.GetSpacing() origin img.GetOrigin() z_spacing spacing[2] for z_idx in range(arr.shape[0]): slice_hu arr[z_idx, :, :] slice_norm np.clip((slice_hu - hu_min) / (hu_max - hu_min), 0, 1) slice_img (slice_norm * 255).astype(np.uint8) filename fcase_{os.path.basename(nii_path)[:4]}_{z_idx:04d} png_path os.path.join(output_img_dir, filename .png) png_writer png.Writer(widthslice_img.shape[1], heightslice_img.shape[0]) with open(png_path, wb) as f: png_writer.write(f, slice_img.tolist()) if mask is not None: mask_arr sitk.GetArrayFromImage(mask) nodule_idxs np.argwhere(mask_arr[z_idx] 0) write_voc_xml_with_mm_to_pixel(...) # 毫米转像素后写bndbox代码中hu_min和hu_max是窗宽窗位的两个端值。CT值常用范围是[-1000, 400]这个窗口能覆盖从空气到骨组织的全部范围。把HU值线性映射到0到255的8位灰度图是为了满足绝大多数检测模型对输入格式的假设。png.Writer要的是二维列表不能直接传numpy数组这里先用.tolist()转换。切片间距也要注意如果NIfTI的层厚是2mm结节的z方向跨度可能覆盖3到5个切片。常见的做法是对每个包含结节的切片都写一个xml但这会让同一个结节在多个切片上重复出现相当于人为放大采样。另一个选择是只保留结节中心层切片两者都可行但要在训练时保持一致策略。3.3 训练集划分避免患者泄漏医学图像数据集的划分规则和自然图像有个本质区别同一个患者的多个切片不能既出现在训练集又出现在验证集。如果按文件随机划分模型会记住患者的特有纹理验证精度虚高到了外部数据集精度瞬间崩掉。按患者编号分组是基本要求import random from pathlib import Path patient_ids sorted({p.name.split(_)[0] for p in Path(Annotations).glob(*.xml)}) random.Random(42).shuffle(patient_ids) train_patients set(patient_ids[:int(len(patient_ids) * 0.8)]) val_patients set(patient_ids[int(len(patient_ids) * 0.8):]) def split_files(): train_list, val_list [], [] for xml_path in Path(Annotations).glob(*.xml): fileset train_patients if xml_path.name.split(_)[0] in train_patients else val_patients stem xml_path.stem (train_list if fileset else val_list).append(stem) return train_list, val_list划分完成后必须跑一个一致性检查每个xml里filename节点写的文件名要存在xml的width和height要跟实际图像一致ImageSets/Main里的每个条目都得对应一个xml和一个图像。漏了任何一步yolov8加载数据时会直接报文件找不到。4. 用yolov8训练肺结节目标检测加载VOC xml的参数与坑4.1 两种加载路线torch Dataset还是ultralytics内置转换yolo目标检测流程里最影响数据加载效率的一步是标签格式转换。yolov8训练自己的数据集时原生支持VOC格式的xml解析只要在data.yaml里把路径指对path: ./VOCdevkit/VOC2007 train: ImageSets/Main/train.txt val: ImageSets/Main/val.txt nc: 1 names: [nodule]ultralytics会读取ImageSets里的文件名去Annotations找xml再转成yolo需要的归一化txt标签。这条路线快速但中间转换不透明。自写Dataset类的好处是可以控制坐标转换细节适合对数据清洗逻辑有严格要求的场景import torch from torch.utils.data import Dataset from PIL import Image class VOCNoduleDataset(Dataset): def __init__(self, img_dir, xml_dir, id_list, transformsNone): self.img_dir img_dir self.xml_dir xml_dir self.samples [line.strip() for line in open(id_list)] self.transforms transforms def __getitem__(self, idx): stem self.samples[idx] img Image.open(f{self.img_dir}/{stem}.png).convert(RGB) parsed parse_voc_xml(f{self.xml_dir}/{stem}.xml) boxes [] labels [] w, h parsed[width], parsed[height] for box in parsed[boxes]: xmin, ymin, xmax, ymax box cx ((xmin xmax) / 2) / w cy ((ymin ymax) / 2) / h bw (xmax - xmin) / w bh (ymax - ymin) / h boxes.append([cx, cy, bw, bh]) labels.append(0) target { boxes: torch.tensor(boxes, dtypetorch.float32), labels: torch.tensor(labels, dtypetorch.long) } return img, target这个类把xml里的边界框直接转成yolo的cx,cy,w,h归一化格式。注意这里用的是减过1的0-based坐标yolo内部计算IoU时不需要关心坐标系起点但如果喂给faster rcnn这类模型要保留原始像素坐标。自写Dataset在调试坐标问题时最有价值可以打印单个样本的框做可视化。4.2 肺结节小目标检测的三组关键参数肺结节在512×512原始图像上的直径通常只有10到30像素属于典型的小目标检测范畴。yolov8训练时以下参数最值得调参数推荐值说明imgsz512或640输入尺寸太小会抹掉小结节纹理太大则显存压力大hsv_h/hsv_s/hsv_v0CT灰度图没有颜色色彩增强只会增加无用计算fliplr0.5左右翻转对肺结节检测基本无影响可保留flipud0上下翻转违背解剖方向建议关闭mosaic1.0保持默认但医学小目标多时要观察是否产生截断框close_mosaic10训练最后10个epoch关闭mosaic避免噪声影响收敛scale0.5缩放增强过大容易让结节变得模糊图像增强里最容易忽略的是hsv三项。很多医学影像数据集本身就是灰度图但转成RGB后模型输入是3通道ultralytics默认的HSV增强会把同一像素的RGB分量做随机偏移相当于给肺结节加了伪彩色噪声。把三项设为0能减少这类无关扰动。fliplr是否保留要看标注任务。左右翻转后结节相对肺门的位置会镜像但对判定“是不是结节”影响不大可以保留0.5的概率。如果训练曲线震荡明显先关掉所有几何增强试一版baseline。还有一个容易被忽略的参数是anchors。yolov8默认锚框是根据coco的物体尺度预设的肺结节框的宽高比接近1尺寸又小。使用默认锚框时可以在训练开始时加auto_anchorTrue让模型根据数据分布重新聚类锚框大多数情况下能提升3到5个点的mAP。4.3 训练中三个高频错误与排查第一个高频错误是“No labels found in train.txt”。原因通常是ImageSets/Main里的文件名和JPEGImages不一致比如xml文件名带了.png后缀。检查方式很简单遍历一次train.txt逐个确认对应的xml和图像都存在。还要确保xml里的filename与文件名一致否则yolov8内置VOC转换时会用xml节点的filename找图。第二个高频错误是坐标越界。xml里xmax大于width或xmin小于0的情况在跨层切片生成时很常见。这类越界框在归一化后变成大于1或小于0的浮点数yolov8会在loss计算时报警告或直接忽略。处理方式不是裁剪坐标而是检查CT mask生成流程里的原点换算。如果一次只越界1到2像素直接用np.clip裁剪是允许的。第三个坑是同一个结节在相邻切片上重复标注。z方向层厚越薄同一结节被多个切片扫到的概率越高训练集里会出现大量看起来相似但框位置略有差异的样本。我一般会对同一个mask连通域只保留结节中心所在切片的xml其余切片的xml删除或标记为difficult避免训练集过度冗余。5. 用可视化脚本核对xml标注不放过一个越界框5.1 把xml边界框回画到CT切片训练前最具性价比的验证方式是把xml里的框直接画回图像上人工抽查。一张CT切片叠加上预测框后肉眼可以立刻发现坐标偏移方向import cv2 def draw_xml_box(img_path, xml_path, output_path): img cv2.imread(img_path) parsed parse_voc_xml(xml_path) for box in parsed[boxes]: xmin, ymin, xmax, ymax [int(v 1) for v in box] cv2.rectangle(img, (xmin, ymin), (xmax, ymax), (0, 255, 0), 2) cv2.putText(img, nodule, (xmin, ymin - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1) cv2.imwrite(output_path, img)cv2.rectangle的坐标参数是0-based所以这里取回解析结果后加了1与xml原始的1-based约定保持一致。画框后顺便检查肉质纹理结节内部应有高密度影如果框内完全均匀大概率是标注脚本映射错了切片的z轴。5.2 全量xml健壮性检查项逐张看图效率太低批量检查才靠谱。我会对全量xml跑一个自动脚本按下面的规则过滤width/height与图像实际尺寸不一致框的宽或高小于5像素框面积占整图比例低于0.005多为噪声标注xmax xmin或ymax ymin同一个图像内两个框IoU大于0.9疑似重复标注xml里filename在JPEGImages里找不到对应文件每条规则对应一个输出列汇总成csv后就能排序筛选。越界和重复标注这两类问题必须在训练前处理模型不会自己学会忽略脏数据。5.3 评估指标mAP之外医学影像更常用FROC最后提一个和自然图像目标检测不同的习惯医学影像里的肺结节检测评价指标通常不是纯mAP而是FROC曲线横轴是平均每个CT的假阳性个数纵轴是灵敏度常见报告点在1/4、1/2、1、2、4、8这几个假阳性率上。这和红外小目标检测里的评价思路类似弱目标检测更关心“低假阳性下的召回”。如果用了与LIDR竞赛相同的评测口径mAP[.5:.95]只能作为参考最终报告要按FROC的灵敏度中位数来写。对于自己的数据集建议同时保存val集的预测结果跑一次FROC计算脚本再看mAP就心中有数了。把可视化、健壮性检查和指标换算写成三个独立脚本任何一步发现异常先修正xml再进训练后续调参才有真正干净的基准。本文还有配套的精品资源点击获取
网站建设高端定制企业官网