Files
Voxelmorph_Head_CT/data_loader.py

354 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""DICOM 序列读取与 NIfTI 转换模块。
核心职责:
1. 读取同一序列目录下的 DICOM 文件。
2. 严格按照 InstanceNumber 或 SliceLocation 排序,防止 Z 轴切片错乱。
3. 提取 PixelSpacing、SliceThickness 等空间信息。
4. 应用 RescaleSlope/RescaleIntercept把原始像素转换为 HU。
5. 保存为 NIfTI供预处理、训练和 Web 可视化使用。
"""
from __future__ import annotations
import argparse
import json
import math
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Iterable, List, Sequence, Tuple
import numpy as np
import pydicom
@dataclass
class DicomSeriesMeta:
"""记录 DICOM 到 NIfTI 转换时的重要元数据。"""
dicom_dir: str
output_path: str
num_slices: int
rows: int
columns: int
pixel_spacing_row_col: Tuple[float, float]
slice_thickness: float
spacing_xyz: Tuple[float, float, float]
sort_key: str
intensity_unit: str = "HU"
def _require_nibabel():
try:
import nibabel as nib # type: ignore
except Exception as exc: # pragma: no cover - 依赖缺失时给用户明确提示
raise RuntimeError(
"缺少 nibabel无法保存 NIfTI。请先运行: pip install -r requirements.txt"
) from exc
return nib
def _to_float(value, default: float = math.nan) -> float:
try:
return float(value)
except Exception:
return default
def _candidate_dicom_files(dicom_dir: Path) -> List[Path]:
"""列出可能的 DICOM 文件。
有些设备导出的 DICOM 没有 .dcm 后缀,所以这里不只依赖扩展名,而是
后续通过 pydicom 读取头信息判断是否可用。
"""
if not dicom_dir.exists():
raise FileNotFoundError(f"DICOM 目录不存在: {dicom_dir}")
if not dicom_dir.is_dir():
raise NotADirectoryError(f"不是目录: {dicom_dir}")
return sorted(path for path in dicom_dir.iterdir() if path.is_file() and not path.name.startswith("."))
def _read_headers(dicom_dir: Path) -> List[Tuple[Path, pydicom.dataset.FileDataset]]:
headers: List[Tuple[Path, pydicom.dataset.FileDataset]] = []
skipped: List[str] = []
for path in _candidate_dicom_files(dicom_dir):
try:
ds = pydicom.dcmread(path, stop_before_pixels=True, force=True)
if not hasattr(ds, "Rows") or not hasattr(ds, "Columns"):
skipped.append(path.name)
continue
headers.append((path, ds))
except Exception:
skipped.append(path.name)
if not headers:
raise RuntimeError(f"目录中没有可读取的 DICOM 切片: {dicom_dir}")
if skipped:
print(f"[data_loader] 跳过 {len(skipped)} 个非 DICOM/无像素头文件。")
return headers
def _unique_numeric_values(
headers: Sequence[Tuple[Path, pydicom.dataset.FileDataset]],
attr: str,
) -> List[float] | None:
values: List[float] = []
for _, ds in headers:
if not hasattr(ds, attr):
return None
value = _to_float(getattr(ds, attr))
if math.isnan(value):
return None
values.append(value)
if len(set(values)) != len(values):
return None
return values
def sort_dicom_headers(
headers: Sequence[Tuple[Path, pydicom.dataset.FileDataset]],
) -> Tuple[List[Tuple[Path, pydicom.dataset.FileDataset]], str]:
"""按可靠的 Z 轴顺序排列切片。
优先级:
1. InstanceNumber多数 CT 序列最稳定、最直观。
2. SliceLocation用户明确要求的第二排序依据。
3. ImagePositionPatient 投影:当上面两个标签缺失时作为医学影像常见兜底。
4. 文件名:最后兜底,并给出明显标记,提醒用户检查。
"""
instance_numbers = _unique_numeric_values(headers, "InstanceNumber")
if instance_numbers is not None:
return sorted(headers, key=lambda item: float(item[1].InstanceNumber)), "InstanceNumber"
slice_locations = _unique_numeric_values(headers, "SliceLocation")
if slice_locations is not None:
return sorted(headers, key=lambda item: float(item[1].SliceLocation)), "SliceLocation"
if all(hasattr(ds, "ImagePositionPatient") for _, ds in headers):
try:
first = headers[0][1]
orientation = [float(v) for v in getattr(first, "ImageOrientationPatient", [])]
row_cos = np.array(orientation[:3], dtype=np.float64)
col_cos = np.array(orientation[3:], dtype=np.float64)
normal = np.cross(row_cos, col_cos)
if np.linalg.norm(normal) > 0:
normal = normal / np.linalg.norm(normal)
return (
sorted(
headers,
key=lambda item: float(np.dot(np.asarray(item[1].ImagePositionPatient, dtype=np.float64), normal)),
),
"ImagePositionPatient",
)
except Exception:
pass
print("[data_loader] 警告:未找到可靠 Z 轴标签,退回文件名排序,请人工确认切片顺序。")
return sorted(headers, key=lambda item: item[0].name), "Filename"
def _infer_slice_thickness(
sorted_headers: Sequence[Tuple[Path, pydicom.dataset.FileDataset]],
) -> float:
first = sorted_headers[0][1]
thickness = _to_float(getattr(first, "SliceThickness", math.nan))
if not math.isnan(thickness) and thickness > 0:
return thickness
locations = _unique_numeric_values(sorted_headers, "SliceLocation")
if locations and len(locations) > 1:
diffs = np.diff(sorted(locations))
diffs = np.abs(diffs[diffs != 0])
if diffs.size:
return float(np.median(diffs))
positions = []
for _, ds in sorted_headers:
if hasattr(ds, "ImagePositionPatient"):
positions.append(np.asarray(ds.ImagePositionPatient, dtype=np.float64))
if len(positions) > 1:
distances = [np.linalg.norm(positions[i + 1] - positions[i]) for i in range(len(positions) - 1)]
distances = [d for d in distances if d > 0]
if distances:
return float(np.median(distances))
raise RuntimeError("无法从 DICOM 中提取 SliceThickness也无法根据切片位置估计层厚。")
def _make_affine(
sorted_headers: Sequence[Tuple[Path, pydicom.dataset.FileDataset]],
row_spacing: float,
col_spacing: float,
slice_thickness: float,
) -> np.ndarray:
"""构建 NIfTI affine。
NIfTI 保存的数据轴为 (X, Y, Z),由 DICOM 的 (Z, Rows, Columns)
转置而来:
- X 轴对应 DICOM Columns物理间距为 PixelSpacing[1]
- Y 轴对应 DICOM Rows物理间距为 PixelSpacing[0]
- Z 轴对应切片方向,间距为 SliceThickness 或切片位置差
"""
affine = np.eye(4, dtype=np.float64)
first = sorted_headers[0][1]
try:
orientation = [float(v) for v in first.ImageOrientationPatient]
origin = np.asarray(first.ImagePositionPatient, dtype=np.float64)
row_cos = np.asarray(orientation[:3], dtype=np.float64)
col_cos = np.asarray(orientation[3:], dtype=np.float64)
normal = np.cross(row_cos, col_cos)
affine[:3, 0] = col_cos * col_spacing
affine[:3, 1] = row_cos * row_spacing
if len(sorted_headers) > 1 and hasattr(sorted_headers[-1][1], "ImagePositionPatient"):
last_origin = np.asarray(sorted_headers[-1][1].ImagePositionPatient, dtype=np.float64)
step = (last_origin - origin) / max(len(sorted_headers) - 1, 1)
if np.linalg.norm(step) > 0:
affine[:3, 2] = step
else:
affine[:3, 2] = normal * slice_thickness
else:
affine[:3, 2] = normal * slice_thickness
affine[:3, 3] = origin
except Exception:
affine[0, 0] = col_spacing
affine[1, 1] = row_spacing
affine[2, 2] = slice_thickness
return affine
def load_dicom_series(
dicom_dir: str | Path,
max_memory_mb: int = 4096,
) -> Tuple[np.ndarray, DicomSeriesMeta, np.ndarray]:
"""读取 DICOM 序列为 3D NumPy 数组。
返回:
- volume_zyx: shape = (Z, Y, X),数值单位为 HU。
- meta: 关键元数据。
- affine: 与转置后的 NIfTI 数据 (X, Y, Z) 匹配的 affine。
"""
dicom_dir = Path(dicom_dir)
headers = _read_headers(dicom_dir)
sorted_headers, sort_key = sort_dicom_headers(headers)
first = sorted_headers[0][1]
rows = int(first.Rows)
columns = int(first.Columns)
pixel_spacing = [float(v) for v in first.PixelSpacing]
row_spacing, col_spacing = pixel_spacing[0], pixel_spacing[1]
slice_thickness = _infer_slice_thickness(sorted_headers)
expected_mb = len(sorted_headers) * rows * columns * np.dtype(np.float32).itemsize / (1024**2)
if expected_mb > max_memory_mb:
raise MemoryError(
f"预计载入体数据约 {expected_mb:.1f} MB超过限制 {max_memory_mb} MB。"
"可增大 --max-memory-mb或先减少序列范围。"
)
volume = np.empty((len(sorted_headers), rows, columns), dtype=np.float32)
for index, (path, _) in enumerate(sorted_headers):
ds = pydicom.dcmread(path, force=True)
pixel_array = ds.pixel_array
if pixel_array.ndim == 3 and pixel_array.shape[0] == 1:
pixel_array = pixel_array[0]
if pixel_array.ndim != 2:
raise RuntimeError(f"暂不支持多帧或非 2D 切片: {path}")
if pixel_array.shape != (rows, columns):
raise RuntimeError(f"切片尺寸不一致: {path}, {pixel_array.shape} != {(rows, columns)}")
slope = _to_float(getattr(ds, "RescaleSlope", 1.0), default=1.0)
intercept = _to_float(getattr(ds, "RescaleIntercept", 0.0), default=0.0)
volume[index] = pixel_array.astype(np.float32) * slope + intercept
affine = _make_affine(sorted_headers, row_spacing, col_spacing, slice_thickness)
meta = DicomSeriesMeta(
dicom_dir=str(dicom_dir),
output_path="",
num_slices=len(sorted_headers),
rows=rows,
columns=columns,
pixel_spacing_row_col=(row_spacing, col_spacing),
slice_thickness=slice_thickness,
spacing_xyz=(col_spacing, row_spacing, float(np.linalg.norm(affine[:3, 2]))),
sort_key=sort_key,
)
return volume, meta, affine
def save_volume_as_nifti(
volume_zyx: np.ndarray,
affine: np.ndarray,
output_path: str | Path,
) -> None:
"""保存 NIfTI。
DICOM 读取后是 (Z, Y, X)NIfTI 这里统一保存为 (X, Y, Z)。
这样后续 preprocess/infer/app 都能用同一套轴约定。
"""
nib = _require_nibabel()
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
volume_xyz = np.transpose(volume_zyx, (2, 1, 0)).astype(np.float32, copy=False)
img = nib.Nifti1Image(volume_xyz, affine)
img.header.set_xyzt_units("mm")
nib.save(img, str(output_path))
def convert_dicom_series_to_nifti(
dicom_dir: str | Path,
output_path: str | Path,
metadata_json: str | Path | None = None,
max_memory_mb: int = 4096,
) -> DicomSeriesMeta:
"""从 DICOM 目录生成 .nii.gz并写出元数据 JSON。"""
volume, meta, affine = load_dicom_series(dicom_dir, max_memory_mb=max_memory_mb)
save_volume_as_nifti(volume, affine, output_path)
meta.output_path = str(output_path)
if metadata_json is None:
metadata_json = Path(str(output_path).replace(".nii.gz", ".json").replace(".nii", ".json"))
metadata_json = Path(metadata_json)
metadata_json.parent.mkdir(parents=True, exist_ok=True)
metadata_json.write_text(json.dumps(asdict(meta), ensure_ascii=False, indent=2), encoding="utf-8")
return meta
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="读取 DICOM 序列并转换为 NIfTI。")
parser.add_argument("--dicom-dir", required=True, help="DICOM 序列目录。")
parser.add_argument("--output", required=True, help="输出 .nii.gz 路径。")
parser.add_argument("--metadata-json", default=None, help="可选:元数据 JSON 输出路径。")
parser.add_argument("--max-memory-mb", type=int, default=4096, help="载入体数据允许使用的最大内存估计值。")
return parser
def main(argv: Iterable[str] | None = None) -> None:
args = build_arg_parser().parse_args(argv)
meta = convert_dicom_series_to_nifti(
dicom_dir=args.dicom_dir,
output_path=args.output,
metadata_json=args.metadata_json,
max_memory_mb=args.max_memory_mb,
)
print(json.dumps(asdict(meta), ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()