"""颈部基准的 CT 预配准。 这一阶段发生在 VoxelMorph 训练/推理之前: 1. Fixed 与 Moving 原始 NIfTI 先重采样到同一 spacing。 2. 基于 CT foreground mask 估计颈部/身体区域的稳健中心。 3. 将 Moving 平移到 Fixed 的颈部中心。 4. 在同一个物理网格中裁剪/填充并归一化,得到后续 VoxelMorph 输入。 当前实现是确定性的平移预配准,目标是先解决层数不同、扫描范围不同导致的 初始空间不一致;细致非线性变形仍交给官方 VoxelMorph。 """ from __future__ import annotations import argparse import json from dataclasses import asdict, dataclass from pathlib import Path from typing import Iterable, Sequence, Tuple import numpy as np from scipy import ndimage from config import ( DEFAULT_FIXED_NIFTI, DEFAULT_MOVING_NIFTI, DEFAULT_TARGET_SHAPE, DEFAULT_TARGET_SPACING, DEFAULT_WINDOW_LEVEL, DEFAULT_WINDOW_WIDTH, PREPROCESSED_DIR, ) from preprocess import ( affine_with_spacing, ensure_multiple_of_16, load_nifti, resample_to_spacing, save_nifti, window_and_normalize, ) @dataclass class NeckAlignmentMeta: fixed_input_path: str moving_input_path: str fixed_output_path: str moving_output_path: str fixed_resampled_shape_xyz: Tuple[int, int, int] moving_resampled_shape_xyz: Tuple[int, int, int] target_shape_xyz: Tuple[int, int, int] target_spacing_xyz: Tuple[float, float, float] fixed_neck_center_index_xyz: Tuple[float, float, float] moving_neck_center_index_xyz: Tuple[float, float, float] fixed_neck_center_world_mm: Tuple[float, float, float] moving_neck_center_world_mm: Tuple[float, float, float] moving_translation_mm: Tuple[float, float, float] body_threshold_hu: float foreground_percentiles: Tuple[float, float] method: str = "neck-mask-centroid-translation" def _tuple_float(values: Sequence[float]) -> Tuple[float, float, float]: return tuple(float(v) for v in values) # type: ignore[return-value] def _tuple_int(values: Sequence[int]) -> Tuple[int, int, int]: return tuple(int(v) for v in values) # type: ignore[return-value] def index_to_world(affine: np.ndarray, index_xyz: Sequence[float]) -> np.ndarray: index = np.asarray(index_xyz, dtype=np.float64) return affine[:3, :3] @ index + affine[:3, 3] def largest_foreground_component(mask: np.ndarray) -> np.ndarray: structure = ndimage.generate_binary_structure(rank=3, connectivity=1) labeled, count = ndimage.label(mask, structure=structure) if count < 1: raise ValueError("未能从 CT 中提取颈部 foreground mask。") sizes = np.bincount(labeled.ravel()) sizes[0] = 0 return labeled == int(np.argmax(sizes)) def estimate_neck_center_index( data_hu: np.ndarray, body_threshold_hu: float = -500.0, foreground_percentiles: Sequence[float] = (2.0, 98.0), ) -> Tuple[np.ndarray, Tuple[np.ndarray, np.ndarray]]: """估计颈部 foreground 的稳健中心。 先用 HU 阈值去掉空气,再保留最大连通域,最后用坐标百分位框的中心而不是 简单均值,减少局部噪声、少量外部物体和扫描边界的影响。 """ data = np.asarray(data_hu, dtype=np.float32) mask = np.isfinite(data) & (data > float(body_threshold_hu)) & (data < 3000.0) mask = largest_foreground_component(mask) coords = np.where(mask) if not coords or coords[0].size == 0: raise ValueError("颈部 foreground mask 为空。") p_low, p_high = (float(v) for v in foreground_percentiles) lows = np.asarray([np.percentile(axis_coords, p_low) for axis_coords in coords], dtype=np.float64) highs = np.asarray([np.percentile(axis_coords, p_high) for axis_coords in coords], dtype=np.float64) center = (lows + highs) / 2.0 return center, (lows, highs) def target_affine_from_center( reference_affine: np.ndarray, center_world_mm: Sequence[float], target_shape: Sequence[int], target_spacing: Sequence[float], ) -> np.ndarray: output_affine = affine_with_spacing(reference_affine, target_spacing) center_index = (np.asarray(target_shape, dtype=np.float64) - 1.0) / 2.0 center_world = np.asarray(center_world_mm, dtype=np.float64) output_affine[:3, 3] = center_world - output_affine[:3, :3] @ center_index return output_affine def resample_to_reference_grid( data: np.ndarray, input_affine: np.ndarray, output_affine: np.ndarray, output_shape: Sequence[int], order: int = 1, cval: float = -1000.0, ) -> np.ndarray: input_to_output = np.linalg.inv(input_affine) @ output_affine matrix = input_to_output[:3, :3] offset = input_to_output[:3, 3] output = ndimage.affine_transform( data.astype(np.float32, copy=False), matrix=matrix, offset=offset, output_shape=tuple(int(v) for v in output_shape), order=order, mode="constant", cval=float(cval), prefilter=(order > 1), output=np.float32, ) return output.astype(np.float32, copy=False) def prealign_pair( fixed_input_path: str | Path, moving_input_path: str | Path, fixed_output_path: str | Path = DEFAULT_FIXED_NIFTI, moving_output_path: str | Path = DEFAULT_MOVING_NIFTI, target_spacing: Sequence[float] = DEFAULT_TARGET_SPACING, target_shape: Sequence[int] = DEFAULT_TARGET_SHAPE, window_width: float = DEFAULT_WINDOW_WIDTH, window_level: float = DEFAULT_WINDOW_LEVEL, body_threshold_hu: float = -500.0, foreground_percentiles: Sequence[float] = (2.0, 98.0), max_memory_mb: int = 8192, metadata_json: str | Path | None = None, ) -> NeckAlignmentMeta: target_shape = ensure_multiple_of_16(target_shape) fixed_input_path = Path(fixed_input_path) moving_input_path = Path(moving_input_path) fixed_output_path = Path(fixed_output_path) moving_output_path = Path(moving_output_path) fixed_hu, fixed_affine, fixed_spacing = load_nifti(fixed_input_path) moving_hu, moving_affine, moving_spacing = load_nifti(moving_input_path) fixed_resampled, fixed_resampled_affine = resample_to_spacing( fixed_hu, fixed_affine, current_spacing=fixed_spacing, target_spacing=target_spacing, order=1, max_memory_mb=max_memory_mb, ) moving_resampled, moving_resampled_affine = resample_to_spacing( moving_hu, moving_affine, current_spacing=moving_spacing, target_spacing=target_spacing, order=1, max_memory_mb=max_memory_mb, ) fixed_center_idx, _ = estimate_neck_center_index( fixed_resampled, body_threshold_hu=body_threshold_hu, foreground_percentiles=foreground_percentiles, ) moving_center_idx, _ = estimate_neck_center_index( moving_resampled, body_threshold_hu=body_threshold_hu, foreground_percentiles=foreground_percentiles, ) fixed_center_world = index_to_world(fixed_resampled_affine, fixed_center_idx) moving_center_world = index_to_world(moving_resampled_affine, moving_center_idx) moving_translation = fixed_center_world - moving_center_world moving_aligned_affine = moving_resampled_affine.copy() moving_aligned_affine[:3, 3] = moving_aligned_affine[:3, 3] + moving_translation output_affine = target_affine_from_center( fixed_resampled_affine, center_world_mm=fixed_center_world, target_shape=target_shape, target_spacing=target_spacing, ) fixed_grid_hu = resample_to_reference_grid( fixed_resampled, input_affine=fixed_resampled_affine, output_affine=output_affine, output_shape=target_shape, order=1, cval=-1000.0, ) moving_grid_hu = resample_to_reference_grid( moving_resampled, input_affine=moving_aligned_affine, output_affine=output_affine, output_shape=target_shape, order=1, cval=-1000.0, ) fixed_norm = window_and_normalize(fixed_grid_hu, window_width=window_width, window_level=window_level) moving_norm = window_and_normalize(moving_grid_hu, window_width=window_width, window_level=window_level) save_nifti(fixed_norm, output_affine, fixed_output_path) save_nifti(moving_norm, output_affine, moving_output_path) meta = NeckAlignmentMeta( fixed_input_path=str(fixed_input_path), moving_input_path=str(moving_input_path), fixed_output_path=str(fixed_output_path), moving_output_path=str(moving_output_path), fixed_resampled_shape_xyz=_tuple_int(fixed_resampled.shape), moving_resampled_shape_xyz=_tuple_int(moving_resampled.shape), target_shape_xyz=_tuple_int(target_shape), target_spacing_xyz=_tuple_float(target_spacing), fixed_neck_center_index_xyz=_tuple_float(fixed_center_idx), moving_neck_center_index_xyz=_tuple_float(moving_center_idx), fixed_neck_center_world_mm=_tuple_float(fixed_center_world), moving_neck_center_world_mm=_tuple_float(moving_center_world), moving_translation_mm=_tuple_float(moving_translation), body_threshold_hu=float(body_threshold_hu), foreground_percentiles=(float(foreground_percentiles[0]), float(foreground_percentiles[1])), ) if metadata_json is None: metadata_json = PREPROCESSED_DIR / "patient1_neck_alignment.json" metadata_path = Path(metadata_json) metadata_path.parent.mkdir(parents=True, exist_ok=True) metadata_path.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="以颈部 foreground 为基准的 CT 平移预配准。") parser.add_argument("--fixed", required=True, help="Fixed 原始 NIfTI。") parser.add_argument("--moving", required=True, help="Moving 原始 NIfTI。") parser.add_argument("--fixed-output", default=str(DEFAULT_FIXED_NIFTI)) parser.add_argument("--moving-output", default=str(DEFAULT_MOVING_NIFTI)) parser.add_argument("--target-spacing", type=float, nargs=3, default=DEFAULT_TARGET_SPACING, metavar=("X", "Y", "Z")) parser.add_argument("--target-shape", type=int, nargs=3, default=DEFAULT_TARGET_SHAPE, metavar=("X", "Y", "Z")) parser.add_argument("--window-width", type=float, default=DEFAULT_WINDOW_WIDTH) parser.add_argument("--window-level", type=float, default=DEFAULT_WINDOW_LEVEL) parser.add_argument("--body-threshold-hu", type=float, default=-500.0) parser.add_argument("--foreground-percentiles", type=float, nargs=2, default=(2.0, 98.0)) parser.add_argument("--metadata-json", default=str(PREPROCESSED_DIR / "patient1_neck_alignment.json")) parser.add_argument("--max-memory-mb", type=int, default=8192) return parser def main(argv: Iterable[str] | None = None) -> None: args = build_arg_parser().parse_args(argv) meta = prealign_pair( fixed_input_path=args.fixed, moving_input_path=args.moving, fixed_output_path=args.fixed_output, moving_output_path=args.moving_output, target_spacing=args.target_spacing, target_shape=args.target_shape, window_width=args.window_width, window_level=args.window_level, body_threshold_hu=args.body_threshold_hu, foreground_percentiles=args.foreground_percentiles, 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()