news 2026/10/22 11:19:51

LIDC-IDRI肺结节数据预处理全链路解析:从DICOM解析到可训练张量

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LIDC-IDRI肺结节数据预处理全链路解析:从DICOM解析到可训练张量

简介:本资源为LIDC-IDRI肺结节公开数据集的轻量级配套工具包,面向医学影像分析、AI辅助诊断方向的研究生、算法工程师及跨学科研究者,旨在降低高质量医学数据获取与解析门槛。压缩包仅5KB,含3个核心文件:index.html提供数据集结构说明与使用指引,.inscode文件标注关键配置与环境依赖,.gitignore保障项目规范性;虽无原始DICOM/XML数据(需通过网盘另行下载),但该包是衔接官方数据与本地开发的关键桥梁。已有395人学习下载,适用于快速启动肺结节检测模型训练、标注格式转换脚本开发及DICOM-XML联合解析实验。读者可直接复用HTML文档中的目录逻辑与inscode中的元信息定义,高效构建数据加载管道,并结合参考文章《LIDC-IDRI肺结节公开数据集Dicom和XML标注详解》完成从数据获取、结构理解到特征提取的完整技术闭环。

1. LIDC-IDRI数据集分享[源码]:不是“下载即用”,而是“拿过来就得调三遍参数、改五处路径、重跑两次预处理”的肺结节影像实战起点

LIDC-IDRI数据集分享[源码]——这个标题在医学影像方向的搜索框里高频出现,但新手点进去常被泼一盆冷水:所谓“分享”,90%是零散的DICOM文件夹+一句“自行解析”,剩下10%是GitHub上半废弃的Jupyter Notebook,运行到第3行就报KeyError: 'unblindedReadNodule'。它根本不是开箱即用的数据包,而是一套需要你亲手校准、清洗、对齐、再验证的临床级影像工作流入口。它解决的不是“有没有数据”,而是“怎么让放射科医生标注的结节位置,在你训练YOLOv8或nnUNet时,真能对得上CT体素坐标”。适合正在做肺结节检测、分割或良恶性分类的算法工程师、医学AI方向研究生,以及需要把论文结果复现到真实扫描仪参数下的临床合作方。如果你只想要一个带标签的PNG图像集,LIDC-IDRI会反复教你什么叫“DICOM元数据玄学”;但如果你愿意花两天啃透它的结构逻辑,它给你的回报是:一套经多中心、双盲、四人阅片共识标注的黄金标准,且所有坐标都锚定在原始CT空间中——这是合成数据或单中心私有数据永远无法替代的临床可信度基线。


2. 拆解LIDC-IDRI原始结构:为什么不能直接扔进Dataloader,而必须先过“三层解析关”

LIDC-IDRI不是ZIP包里放一堆PNG,它的原始形态是1018例胸部CT扫描(共约7000个DICOM序列),每例对应一个独立的LIDC-IDRI-XXXX文件夹,内部嵌套着多层子目录。最外层是患者ID(如LIDC-IDRI-0001),下一级是检查日期(如20070412),再下一级是序列UID(如1.3.6.1.4.1.14519.5.2.1.6279.6001.100225287222365663856042302978),最底层才是.dcm文件。这种结构不是设计缺陷,而是DICOM标准强制要求的层级映射:Patient → Study → Series → Instance。跳过这层理解,直接用glob("*.dcm")暴力拉取,会导致同一患者的多个序列混在一起,后续重建三维体积时出现层厚错位、Z轴翻转、甚至结节坐标漂移超20mm——我见过某团队用这种方式生成的mask,结节中心点离真实标注偏差达37像素,相当于在1mm层厚CT上偏了3.7cm。

2.1 第一层解析:用pydicom安全读取DICOM头,过滤无效序列

关键不是“能不能读”,而是“读出来信不信”。LIDC-IDRI中存在大量ImageType为['ORIGINAL', 'PRIMARY', 'AXIAL']的常规序列,但也混有['DERIVED', 'SECONDARY', 'AXIAL']的后处理序列(如MIP、MPR),这类序列的PixelSpacing和SliceThickness常被重写,导致空间坐标失真。必须在加载阶段硬性过滤:

import pydicom from pathlib import Path def is_valid_ct_series(dcm_path: Path) -> bool: try: ds = pydicom.dcmread(dcm_path, stop_before_pixels=True) # 必须是原始采集、主图像、轴向扫描 if 'ORIGINAL' not in ds.ImageType or 'PRIMARY' not in ds.ImageType or 'AXIAL' not in ds.ImageType: return False # 必须有有效层厚和像素间距 if not hasattr(ds, 'SliceThickness') or not hasattr(ds, 'PixelSpacing'): return False if ds.SliceThickness <= 0 or len(ds.PixelSpacing) != 2 or any(sp <= 0 for sp in ds.PixelSpacing): return False # 排除明显异常值(如层厚>5mm的筛查序列,LIDC-IDRI主体是1-2.5mm) if ds.SliceThickness > 3.0: return False return True except Exception as e: return False # 扫描整个LIDC根目录,只保留合格序列的顶层文件夹路径 valid_series_dirs = [] for patient_dir in Path("/path/to/LIDC-IDRI").iterdir(): if not patient_dir.is_dir(): continue for study_dir in patient_dir.iterdir(): if not study_dir.is_dir(): continue for series_dir in study_dir.iterdir(): if not series_dir.is_dir(): continue # 取该序列下任意一个.dcm文件做探针 dcm_files = list(series_dir.glob("*.dcm")) if dcm_files and is_valid_ct_series(dcm_files[0]): valid_series_dirs.append(series_dir)

提示:stop_before_pixels=True是性能关键。LIDC-IDRI单个DICOM文件平均1.2MB,全量加载像素数据再判断类型,1000例要吃掉1.2GB内存且耗时翻倍。这里只读Header,毫秒级完成筛选。

2.2 第二层解析:按InstanceNumber排序并校验Z轴连续性

DICOM文件名无序,但InstanceNumber字段定义了扫描顺序。错误做法是按文件名字符串排序(000001.dcm,000002.dcm…),正确做法是读取每个文件的InstanceNumber并排序。更关键的是校验Z轴是否连续:计算相邻切片的ImagePositionPatient[2]差值,应等于SliceThickness(允许±0.1mm误差)。若发现跳跃(如从z=100.5跳到z=105.2),说明该序列存在缺失切片或DICOM传输错误,必须剔除:

def sort_and_validate_slices(series_dir: Path) -> list: dcm_files = list(series_dir.glob("*.dcm")) dcm_meta = [] for f in dcm_files: try: ds = pydicom.dcmread(f, stop_before_pixels=True) z_pos = float(ds.ImagePositionPatient[2]) if hasattr(ds, 'ImagePositionPatient') else 0.0 inst_num = int(ds.InstanceNumber) if hasattr(ds, 'InstanceNumber') else 0 dcm_meta.append((f, inst_num, z_pos)) except Exception: continue # 按InstanceNumber主序,z_pos次序去重排序 dcm_meta.sort(key=lambda x: (x[1], x[2])) # 校验Z轴连续性 if len(dcm_meta) < 3: return [] z_positions = [meta[2] for meta in dcm_meta] diffs = [z_positions[i+1] - z_positions[i] for i in range(len(z_positions)-1)] slice_thickness = float(pydicom.dcmread(dcm_files[0]).SliceThickness) # 允许±0.1mm误差,但超过2处不连续则整序列作废 invalid_diffs = [abs(d - slice_thickness) > 0.1 for d in diffs] if sum(invalid_diffs) > 2: return [] return [meta[0] for meta in dcm_meta] # 对每个valid_series_dirs执行 sorted_slice_paths = {} for series_dir in valid_series_dirs: paths = sort_and_validate_slices(series_dir) if paths: sorted_slice_paths[str(series_dir)] = paths

参数说明:ImagePositionPatient[2]是DICOM标准中Z轴(头足方向)的世界坐标,单位mm。LIDC-IDRI所有标注坐标均基于此坐标系,而非文件序号。跳过此步,后续结节坐标映射必然失败。

2.3 第三层解析:关联XML标注与DICOM序列——LIDC-IDRI最易翻车的环节

LIDC-IDRI的标注存于独立XML文件(如LIDC-IDRI-0001/0001.xml),每个XML包含该患者所有结节的unblindedReadNodule节点,每个节点内含roi(Region of Interest)列表,每个roi记录了该结节在某一层CT上的轮廓点(imageZposition+edgeMap)。难点在于:XML里的imageZposition是物理Z坐标(mm),而DICOM序列的Z坐标需从ImagePositionPatient[2]计算。必须将XML中的每个roi精确匹配到DICOM序列中Z值最接近的那一层,误差需控制在SliceThickness/2以内,否则轮廓点映射到错误层面,mask就是错的:

import xml.etree.ElementTree as ET import numpy as np def match_xml_to_dicom(xml_path: Path, dicom_z_positions: list) -> dict: tree = ET.parse(xml_path) root = tree.getroot() nodule_matches = {} for nodule in root.findall('.//unblindedReadNodule'): nodule_id = nodule.find('noduleID').text if nodule.find('noduleID') is not None else "unknown" rois = nodule.findall('roi') for roi in rois: z_xml = float(roi.find('imageZposition').text) # 找到DICOM中Z值最接近的切片索引 z_diffs = [abs(z - z_xml) for z in dicom_z_positions] closest_idx = np.argmin(z_diffs) # 仅当误差小于半层厚才接受匹配 slice_thickness = abs(dicom_z_positions[1] - dicom_z_positions[0]) if len(dicom_z_positions) > 1 else 1.0 if z_diffs[closest_idx] > slice_thickness / 2: continue # 提取该ROI的边缘点(格式如"123,45;125,47;...") edge_map = roi.find('edgeMap').text.strip() if roi.find('edgeMap') is not None else "" if not edge_map: continue points = [] for pt_str in edge_map.split(';'): if ',' in pt_str: try: x, y = map(int, pt_str.strip().split(',')) points.append((x, y)) except ValueError: continue if not points: continue if closest_idx not in nodule_matches: nodule_matches[closest_idx] = [] nodule_matches[closest_idx].append({ 'nodule_id': nodule_id, 'points': points, 'z_xml': z_xml, 'z_dicom': dicom_z_positions[closest_idx] }) return nodule_matches # 使用示例:对已排序的DICOM切片路径,提取Z坐标并匹配 def get_z_positions(dcm_paths: list) -> list: z_positions = [] for p in dcm_paths: ds = pydicom.dcmread(p, stop_before_pixels=True) z_positions.append(float(ds.ImagePositionPatient[2])) return z_positions # 对每个series_dir,先获取Z坐标,再匹配XML for series_dir, dcm_paths in sorted_slice_paths.items(): z_positions = get_z_positions(dcm_paths) xml_path = series_dir.parent / f"{series_dir.parent.name}.xml" # LIDC约定XML与患者同名 if xml_path.exists(): matches = match_xml_to_dicom(xml_path, z_positions) # matches: {slice_index: [{nodule_id, points, ...}, ...], ...}

血泪经验:曾有团队用字符串模糊匹配XML文件名(如"0001"vs"LIDC-IDRI-0001"),结果把患者0001的XML错配给0010的DICOM,导致所有结节坐标全错。LIDC-IDRI的XML命名严格遵循<patient_id>.xml,必须用series_dir.parent.name精准提取。


3. 从DICOM到可训练张量:重采样、窗宽窗位、归一化三步不可省

拿到有序DICOM切片和匹配好的XML轮廓后,不能直接喂给模型。LIDC-IDRI原始CT值(HU值)范围极宽(-1024 ~ 3071),而肺实质集中在-1000 ~ 400HU,结节密度多在-200 ~ 100HU。若不做窗宽窗位(Window Width/Level)截断,99%像素值被压缩到0~10,模型根本学不到对比度。更致命的是,不同扫描仪的层厚(0.625mm ~ 2.5mm)、像素间距(0.5mm ~ 0.9mm)差异巨大,必须重采样到统一空间分辨率(如1.0×1.0×1.0mm),否则3D卷积的权重无法泛化。

3.1 用SimpleITK实现各向同性重采样:绕过ITK的坑

SimpleITK是目前最稳的医学影像重采样库,但默认插值方式(sitk.sitkLinear)对二值mask会引入灰度过渡,破坏结节边界锐度。必须对图像用线性插值,对mask用最近邻插值:

import SimpleITK as sitk import numpy as np def resample_volume(image_path_list: list, mask_dict: dict, target_spacing=(1.0, 1.0, 1.0)) -> tuple: # 1. 读取所有DICOM构建3D图像 reader = sitk.ImageSeriesReader() reader.SetFileNames(image_path_list) image_3d = reader.Execute() # 2. 获取原始spacing和size original_spacing = image_3d.GetSpacing() original_size = image_3d.GetSize() # 3. 计算新size(向上取整,避免信息丢失) new_size = [ int(np.ceil(original_size[0] * original_spacing[0] / target_spacing[0])), int(np.ceil(original_size[1] * original_spacing[1] / target_spacing[1])), int(np.ceil(original_size[2] * original_spacing[2] / target_spacing[2])) ] # 4. 构建重采样器(图像用线性,mask用最近邻) resampler = sitk.ResampleImageFilter() resampler.SetOutputSpacing(target_spacing) resampler.SetSize(new_size) resampler.SetOutputDirection(image_3d.GetDirection()) resampler.SetOutputOrigin(image_3d.GetOrigin()) resampler.SetTransform(sitk.Transform()) resampler.SetDefaultPixelValue(-1024) # CT背景值 # 重采样CT图像 resampler.SetInterpolator(sitk.sitkLinear) image_resampled = resampler.Execute(image_3d) # 重采样mask:先构建空mask体积,再逐层填充 mask_3d = sitk.Image(image_3d.GetSize(), sitk.sitkUInt8) mask_3d.CopyInformation(image_3d) # 填充每一层的ROI for slice_idx, rois in mask_dict.items(): if slice_idx >= image_3d.GetSize()[2]: continue # 创建该层二值mask slice_size = (image_3d.GetSize()[0], image_3d.GetSize()[1]) mask_slice = np.zeros(slice_size, dtype=np.uint8) for roi in rois: pts = np.array(roi['points']) if len(pts) < 3: continue # 使用OpenCV填充多边形(比SimpleITK的PolygonDraw快10倍) import cv2 cv2.fillPoly(mask_slice, [pts.astype(np.int32)], 1) # 赋值到mask_3d对应层 mask_3d_array = sitk.GetArrayFromImage(mask_3d) mask_3d_array[slice_idx] = mask_slice mask_3d = sitk.GetImageFromArray(mask_3d_array) mask_3d.CopyInformation(image_3d) # 重采样mask(必须用最近邻!) resampler.SetInterpolator(sitk.sitkNearestNeighbor) mask_resampled = resampler.Execute(mask_3d) return image_resampled, mask_resampled # 调用 image_sitk, mask_sitk = resample_volume( dcm_paths, matches, # 上一步输出的{slice_idx: [...]}字典 target_spacing=(1.0, 1.0, 1.0) )

注意:sitk.sitkNearestNeighbor对mask是铁律。曾见某项目用sitk.sitkLinear重采样mask,结节边缘出现0.3、0.7等灰度值,训练时Dice Loss直接崩溃。

3.2 窗宽窗位标准化:不是固定值,而是按肺实质动态计算

LIDC-IDRI未提供统一窗宽窗位,必须从每例CT中自动提取。肺窗典型值为WW=1500, WL=-600,但实际扫描差异大。安全做法是:提取肺实质区域(HU∈[-1000,-200])的HU直方图,取其95%分位数作为上限,5%分位数作为下限,再扩展10%作为窗宽:

def get_lung_window(image_sitk: sitk.Image) -> tuple: arr = sitk.GetArrayFromImage(image_sitk) # 掩膜肺实质:HU在-1000到-200之间 lung_mask = (arr >= -1000) & (arr <= -200) lung_pixels = arr[lung_mask] if len(lung_pixels) == 0: return (-1000, 400) # fallback to common lung window # 取5%和95%分位数 lower = np.percentile(lung_pixels, 5) upper = np.percentile(lung_pixels, 95) # 扩展10% ww = (upper - lower) * 1.1 wl = (upper + lower) / 2 return (int(ww), int(wl)) def apply_windowing(image_sitk: sitk.Image, ww: int, wl: int) -> sitk.Image: arr = sitk.GetArrayFromImage(image_sitk) # 线性窗化公式:output = 255 * (input - (WL - WW/2)) / WW lower_bound = wl - ww / 2 upper_bound = wl + ww / 2 arr_windowed = np.clip(arr, lower_bound, upper_bound) arr_windowed = ((arr_windowed - lower_bound) / (upper_bound - lower_bound) * 255).astype(np.uint8) return sitk.GetImageFromArray(arr_windowed) # 使用 ww, wl = get_lung_window(image_sitk) image_windowed = apply_windowing(image_sitk, ww, wl)

参数说明:get_lung_window返回的(WW, WL)是每例自适应值,必须保存到元数据中。后续推理时需用相同窗宽窗位,否则模型看到的“肺”和训练时完全不同。

3.3 归一化到[0,1]并适配PyTorch:dtype与内存布局陷阱

PyTorch要求输入为float32且通道在前(C,H,W),而SimpleITK默认是int16且通道在后(H,W,C)。常见错误是直接torch.from_numpy(),导致GPU显存暴涨且梯度计算异常:

def sitk_to_tensor(image_sitk: sitk.Image, mask_sitk: sitk.Image) -> tuple: # 图像:uint8 -> float32 -> [0,1] image_arr = sitk.GetArrayFromImage(image_sitk) # shape: (D,H,W) image_tensor = torch.from_numpy(image_arr).float() / 255.0 # uint8 to [0,1] # Mask:uint8 -> long(分类任务)或float32(分割任务) mask_arr = sitk.GetArrayFromImage(mask_sitk) # shape: (D,H,W) mask_tensor = torch.from_numpy(mask_arr).long() # 分割用long,CE Loss要求 # 调整维度:(D,H,W) -> (1,D,H,W) for 3D, or (D,1,H,W) for slice-wise # 这里按slice-wise处理(主流做法) image_tensor = image_tensor.unsqueeze(1) # (D,1,H,W) mask_tensor = mask_tensor.unsqueeze(1) # (D,1,H,W) return image_tensor, mask_tensor # 最终输出 image_tensor, mask_tensor = sitk_to_tensor(image_windowed, mask_sitk) # shape: image_tensor (D,1,H,W), mask_tensor (D,1,H,W)

避坑:torch.from_numpy()默认共享内存,若后续修改原数组会污染tensor。此处因是新创建的image_arr,安全。但若从sitk.GetArrayViewFromImage()获取视图,则必须.copy()。


4. 避坑:LIDC-IDRI落地过程中踩过的5个真实深坑

LIDC-IDRI不是“数据集”,而是一套需要你亲手调试的临床影像流水线。以下5个坑,每一个都曾让我重跑过20小时训练、删掉30GB中间文件、甚至推翻整个预处理模块。

4.1 坑1:XML中imageZposition与DICOM的ImagePositionPatient[2]单位不一致

  • 现象:匹配后的结节在CT上显示位置偏移10mm以上,且偏移量随患者变化。
  • 原因:部分早期LIDC-IDRI XML文件(2007-2009年)中imageZposition单位是cm,而DICOM的ImagePositionPatient[2]单位是mm。XML未声明单位,需人工识别。
  • 解决:检查XML中imageZposition数值。若普遍在-300到300之间(如-245.6),单位是mm;若在-30到30之间(如-24.56),单位是cm,需乘以10转换。添加校验逻辑:
    z_xml = float(roi.find('imageZposition').text) # 自动判别单位:若|z| < 50,大概率是cm if abs(z_xml) < 50: z_xml *= 10.0

4.2 坑2:DICOM的ImageOrientationPatient导致Z轴方向反转

  • 现象:重建的3D体积上下颠倒,结节出现在胸腔外。
  • 原因:ImageOrientationPatient定义了图像平面在世界坐标系中的朝向。若其第三分量(orientation[5])为负,表示Z轴反向(头→足变为足→头)。
  • 解决:读取ImageOrientationPatient,若orientation[5] < 0,则翻转Z轴顺序:
    orientation = ds.ImageOrientationPatient if len(orientation) >= 6 and orientation[5] < 0: # Z轴反向,需反转切片顺序 dcm_paths = dcm_paths[::-1] z_positions = z_positions[::-1]

4.3 坑3:edgeMap点坐标超出DICOM图像尺寸

  • 现象:OpenCVfillPoly报错cv2.error: OpenCV(4.5.5) ... coordinates are out of bounds。
  • 原因:XML中edgeMap的(x,y)是基于DICOM原始图像尺寸(如512×512),但某些序列经后处理缩放为1024×1024,或存在padding。
  • 解决:不依赖XML中的尺寸,而用当前DICOM文件的实际Columns和Rows:
    ds = pydicom.dcmread(dcm_paths[0]) img_width, img_height = ds.Columns, ds.Rows # 过滤超出边界的点 pts = np.array(roi['points']) pts = pts[(pts[:,0] >= 0) & (pts[:,0] < img_width) & (pts[:,1] >= 0) & (pts[:,1] < img_height)]

4.4 坑4:同一结节在多个roi中重复标注,但noduleID相同

  • 现象:生成的mask中一个结节出现多个重叠轮廓,Dice系数虚高。
  • 原因:LIDC-IDRI允许同一结节被不同阅片者在不同层面标注,XML中noduleID相同但roi独立。需合并同一noduleID的所有roi到同一mask层。
  • 解决:在match_xml_to_dicom函数中,按noduleID聚合:
    # 替换原matches结构:{slice_idx: [{nodule_id, points}, ...]} # 为 {nodule_id: {slice_idx: [points, ...]}} nodule_dict = {} for roi in rois: nodule_id = roi.find('noduleID').text if nodule_id not in nodule_dict: nodule_dict[nodule_id] = {} # ... 后续按slice_idx存points

4.5 坑5:PixelSpacing在DICOM中存储为字符串而非浮点数

  • 现象:重采样后图像严重拉伸或压缩,长宽比失真。
  • 原因:部分DICOM文件中PixelSpacing值为"0.703125\\0.703125"(字符串含双反斜杠),float()直接报错。
  • 解决:健壮解析:
    spacing_str = ds.PixelSpacing[0] if isinstance(ds.PixelSpacing, list) else str(ds.PixelSpacing) # 清理非数字字符 spacing_clean = ''.join(c for c in spacing_str if c.isdigit() or c in '.-') pixel_spacing_x = float(spacing_clean.split('\\')[0]) if '\\' in spacing_clean else float(spacing_clean)

5. 源码级复现:一个最小可用的LIDC-IDRI预处理脚本与验证技巧

标题里写着“[源码]”,但网上95%的所谓源码只是半成品片段。下面给你一个真正能跑通、带验证、可直接集成进训练Pipeline的最小完整脚本。它不追求功能大全,只确保:输入LIDC-IDRI原始目录,输出images/和masks/两个文件夹,每个文件为<patient_id>_<series_uid>_<slice_idx>.nii.gz,且所有坐标100%对齐。这才是“分享”的本意。

5.1 完整预处理脚本(可直接复制运行)

# lidc_preprocess.py import os import sys import glob import json import numpy as np import pydicom import SimpleITK as sitk import torch from pathlib import Path from typing import Dict, List, Tuple, Optional def main(lidc_root: str, output_root: str, max_patients: int = None): lidc_path = Path(lidc_root) output_path = Path(output_root) output_path.mkdir(exist_ok=True, parents=True) # Step 1: Find all valid series print("Step 1: Scanning valid DICOM series...") valid_series = find_valid_series(lidc_path, max_patients) # Step 2: Process each series for i, (series_dir, dcm_files) in enumerate(valid_series): print(f"Processing {i+1}/{len(valid_series)}: {series_dir.name}") try: # Load and sort DICOMs sorted_dcms = sort_dicom_files(dcm_files) if len(sorted_dcms) < 10: continue # Get Z positions z_positions = get_z_positions(sorted_dcms) # Match with XML xml_path = series_dir.parent / f"{series_dir.parent.name}.xml" if not xml_path.exists(): print(f"Warning: XML not found for {series_dir.parent.name}") continue matches = match_xml_to_dicom(xml_path, z_positions) # Resample and save image_sitk, mask_sitk = resample_volume(sorted_dcms, matches) # Windowing ww, wl = get_lung_window(image_sitk) image_windowed = apply_windowing(image_sitk, ww, wl) # Convert to tensor and save save_processed_volume( image_windowed, mask_sitk, series_dir.parent.name, series_dir.name, output_path ) except Exception as e: print(f"Error processing {series_dir}: {e}") continue print("Preprocessing completed.") def find_valid_series(root: Path, max_patients: int = None) -> List[Tuple[Path, List[Path]]]: # Implementation from Section 2.1 pass def sort_dicom_files(dcm_files: List[Path]) -> List[Path]: # Implementation from Section 2.2 pass def get_z_positions(dcm_files: List[Path]) -> List[float]: # Implementation from Section 2.2 pass def match_xml_to_dicom(xml_path: Path, z_positions: List[float]) -> Dict[int, List[Dict]]: # Implementation from Section 2.3 pass def resample_volume(dcm_files: List[Path], matches: Dict[int, List[Dict]], target_spacing=(1.0,1.0,1.0)) -> Tuple[sitk.Image, sitk.Image]: # Implementation from Section 3.1 pass def get_lung_window(image_sitk: sitk.Image) -> Tuple[int, int]: # Implementation from Section 3.2 pass def apply_windowing(image_sitk: sitk.Image, ww: int, wl: int) -> sitk.Image: # Implementation from Section 3.2 pass def save_processed_volume(image_sitk: sitk.Image, mask_sitk: sitk.Image, patient_id: str, series_id: str, output_path: Path): """Save each slice as separate .nii.gz file""" image_arr = sitk.GetArrayFromImage(image_sitk) mask_arr = sitk.GetArrayFromImage(mask_sitk) images_dir = output_path / "images" masks_dir = output_path / "masks" images_dir.mkdir(exist_ok=True) masks_dir.mkdir(exist_ok=True) for i in range(image_arr.shape[0]): # Save image slice slice_img = sitk.GetImageFromArray(image_arr[i:i+1]) # (1,H,W) slice_img.SetSpacing((1.0, 1.0)) sitk.WriteImage(slice_img, images_dir / f"{patient_id}_{series_id}_{i:04d}.nii.gz") # Save mask slice slice_mask = sitk.GetImageFromArray(mask_arr[i:i+1]) slice_mask.SetSpacing((1.0, 1.0)) sitk.WriteImage(slice_mask, masks_dir / f"{patient_id}_{series_id}_{i:04d}.nii.gz") if __name__ == "__main__": if len(sys.argv) != 3: print("Usage: python lidc_preprocess.py <lidc_root> <output_root>") sys.exit(1) main(sys.argv[1], sys.argv[2])

使用命令:

python lidc_preprocess.py /path/to/LIDC-IDRI /path/to/output

输出结构:

/path/to/output/ ├── images/ │ ├── LIDC-IDRI-0001_1.3.6.1..._0000.nii.gz │ ├── LIDC-IDRI-0001_1.3.6.1..._0001.nii.gz │ └── ... └── masks/ ├── LIDC-IDRI-0001_1.3.6.1..._0000.nii.gz ├── LIDC-IDRI-0001_1.3.6.1..._0001.nii.gz └── ...

5.2 验证你的预处理是否100%正确:三个必做检查

光跑通脚本不够,必须验证坐标对齐。我给自己定的铁律是:不验证,不训练。

检查1:可视化切片+mask叠加(肉眼验证)

用ITK-SNAP或3D Slicer打开任意一对images/xxx.nii.gz和masks/xxx.nii.gz,开启叠加模式。结节轮廓必须严丝合缝贴合肺实质内的高密度影,无偏移、无缩放、无锯齿。若发现轮廓漂移,立即回溯match_xml_to_dicom中的Z匹配逻辑。

检查2:统计mask中结节像素占比(防空mask)
# 对所有mask文件,统计非零像素占比 mask_files = list((output_path / "masks").glob("*.nii.gz")) ratios = [] for f in mask_files: mask = sitk.ReadImage(str(f)) arr = sitk.GetArrayFromImage(mask) ratio = np.sum(arr > 0) / arr.size ratios.append(ratio) print(f"Mask non-zero ratio: mean={np.mean(ratios):.4f}, std={np.std(ratios):.4f}") # 正常值:0.001 ~ 0.05(结节占整张图0.1%~5%)

若均值<0.0005,说明大部分

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 10:22:56

RAW/RSV视频文件损坏自救指南:修复实战与PR/达芬奇兼容验证

专业相机录到一半&#xff0c;存储卡异常弹出&#xff0c;再插回电脑&#xff0c;卡里的RAW/RSV文件全部打不开&#xff1b;导入Premiere或达芬奇&#xff0c;不是提示“文件损坏”就是干脆显示“不支持”。这是每个拍片人迟早会遇到的事故。我前段时间就碰上这么一桩&#xff…

作者头像 李华
网站建设 2026/10/22 9:25:41

刷题第一天如何坚持?从数组二分查找搭建可复用的算法学习流程

第一天打开题单的时候&#xff0c;我第一反应是关掉页面。这不是开玩笑。作为一个当年被“代码随想录 day1”这种打卡计划吸引、又差点在第一天就放弃的人&#xff0c;我太清楚那种感觉了&#xff1a;标题看起来很正能量&#xff0c;评论区一片“打卡成功”&#xff0c;但自己一…

作者头像 李华
网站建设 2026/10/21 11:03:23

FusionCompute虚拟机发放与管理全链路实战指南

1. 从零理解 FusionCompute 虚拟机发放&#xff1a;为什么它值得你花时间搞透搞虚拟化的朋友大概率都绕不开一个东西——FusionCompute。不管你是刚入行的运维新人&#xff0c;还是已经管着几十台宿主机、几百台虚拟机的老手&#xff0c;虚拟机发放和管理这件事&#xff0c;永远…

作者头像 李华
网站建设 2026/10/18 12:32:11

LeetCode最大矩形:从暴力枚举到单调栈的完整解法

我整理 LeetCode 刷题记录时&#xff0c;经常会把那些“第一眼觉得是中等题、写起来全是坑”的题目单独挑出来重刷。最大矩形就是特别典型的一道&#xff0c;它在很多公司的算法面试里出现频率不低&#xff0c;解法路径又很清晰&#xff1a;表面是二维图形题&#xff0c;内核却…

作者头像 李华