"""官方 VoxelMorph 训练适配器。 本项目以 https://github.com/voxelmorph/voxelmorph 的 PyTorch 核心为运行核心: - 网络: ``voxelmorph.nn.models.VxmPairwise`` - 空间变换: 官方 ``voxelmorph.nn.modules.SpatialTransformer`` - 平滑正则: 官方推荐的 ``neurite.nn.modules.SpatialGradient`` - 相似度: 默认使用带 epsilon 的本地稳定 NCC;可切换到 ``neurite.nn.modules.NCC`` 输入张量约定: - NIfTI 数组轴顺序为 (X, Y, Z)。 - 官方 PyTorch VoxelMorph 输入为 (B, C, X, Y, Z)。 - DDF 通道顺序与空间轴一致,即 (X, Y, Z)。 """ from __future__ import annotations import argparse from contextlib import nullcontext import json import os from pathlib import Path from typing import Dict, Iterable, List, Sequence, Tuple import numpy as np from config import DEFAULT_CHECKPOINT OFFICIAL_VXM_REPO = "https://github.com/voxelmorph/voxelmorph" OFFICIAL_VXM_COMMIT = "db73f34b910bcefcb520f7f40a1bc4a3e0b6401d" try: # 让缺 torch 的环境仍能 py_compile。 import torch import torch.nn as nn import torch.nn.functional as F except Exception as _torch_exc: # pragma: no cover torch = None # type: ignore nn = None # type: ignore F = None # type: ignore _TORCH_IMPORT_ERROR = _torch_exc else: _TORCH_IMPORT_ERROR = None def configure_voxelmorph_backend() -> None: """显式启用官方 VoxelMorph/Neurite 的 PyTorch 后端。""" os.environ.setdefault("NEURITE_BACKEND", "pytorch") os.environ.setdefault("VXM_BACKEND", "pytorch") def require_torch(): if torch is None: raise RuntimeError( "缺少 PyTorch,无法训练或推理。建议使用 conda 创建 CUDA 环境:" "conda env create -f environment.yml" ) from _TORCH_IMPORT_ERROR return torch def require_official_voxelmorph(): """导入官方 VoxelMorph 与 Neurite,并检查关键 API。""" configure_voxelmorph_backend() require_torch() try: import neurite as ne # type: ignore import voxelmorph as vxm # type: ignore except Exception as exc: # pragma: no cover raise RuntimeError( "缺少官方 VoxelMorph/Neurite。请运行: conda env create -f environment.yml " "或 pip install -r requirements.txt" ) from exc if not hasattr(vxm.nn.models, "VxmPairwise"): raise RuntimeError("当前 voxelmorph 版本缺少 vxm.nn.models.VxmPairwise,请安装官方 dev 版本。") return vxm, ne def _require_nibabel(): try: import nibabel as nib # type: ignore except Exception as exc: # pragma: no cover raise RuntimeError("缺少 nibabel,请先安装项目依赖。") from exc return nib if torch is not None: class LocalNCCLoss(nn.Module): """本地 NCC 兜底。 官方 VoxelMorph 当前把 NCC 放在 Neurite 中;若用户安装的 Neurite 接口短期变动,本类保证训练脚本仍可运行,但网络核心仍是官方 VoxelMorph。 """ def __init__(self, window_size: int = 9, eps: float = 1e-5): super().__init__() self.window_size = int(window_size) self.eps = float(eps) def forward(self, fixed, warped): win = [self.window_size] * 3 padding = self.window_size // 2 filt = torch.ones((1, 1, *win), dtype=fixed.dtype, device=fixed.device) win_volume = float(np.prod(win)) fixed_sum = F.conv3d(fixed, filt, padding=padding) warped_sum = F.conv3d(warped, filt, padding=padding) fixed2_sum = F.conv3d(fixed * fixed, filt, padding=padding) warped2_sum = F.conv3d(warped * warped, filt, padding=padding) cross_sum = F.conv3d(fixed * warped, filt, padding=padding) fixed_mean = fixed_sum / win_volume warped_mean = warped_sum / win_volume cross = cross_sum - warped_mean * fixed_sum - fixed_mean * warped_sum + fixed_mean * warped_mean * win_volume fixed_var = fixed2_sum - 2 * fixed_mean * fixed_sum + fixed_mean * fixed_mean * win_volume warped_var = warped2_sum - 2 * warped_mean * warped_sum + warped_mean * warped_mean * win_volume cc = (cross * cross) / (fixed_var * warped_var + self.eps) return -torch.mean(cc) class NegativeSimilarity(nn.Module): """把 Neurite NCC 分数转换为可最小化的 loss。""" def __init__(self, module: nn.Module): super().__init__() self.module = module def forward(self, fixed, warped): return -torch.mean(self.module(fixed, warped)) else: class LocalNCCLoss: # type: ignore[no-redef] def __init__(self, *args, **kwargs): require_torch() class NegativeSimilarity: # type: ignore[no-redef] def __init__(self, *args, **kwargs): require_torch() def load_nifti_tensor(path: str | Path, device: str | "torch.device" = "cpu") -> Tuple["torch.Tensor", np.ndarray]: """读取 NIfTI 并转换为官方 VoxelMorph 输入张量。 NIfTI data: (X, Y, Z) Torch tensor: (B, C, X, Y, Z) """ require_torch() nib = _require_nibabel() img = nib.load(str(path), mmap=True) data = np.asanyarray(img.dataobj, dtype=np.float32) if data.ndim > 3: data = data[..., 0] data = np.nan_to_num(data.astype(np.float32, copy=False), copy=False) tensor = torch.from_numpy(data.copy())[None, None].to(device=device, dtype=torch.float32) return tensor, img.affine.copy() def resolve_device(device: str): require_torch() if device == "auto": return torch.device("cuda" if torch.cuda.is_available() else "cpu") return torch.device(device) def build_vxm_model( nb_features: Sequence[int], integration_steps: int, device, ): """构建官方 VoxelMorph Pairwise 模型。""" vxm, _ = require_official_voxelmorph() model = vxm.nn.models.VxmPairwise( ndim=3, source_channels=1, target_channels=1, nb_features=list(int(v) for v in nb_features), integration_steps=int(integration_steps), device=str(device), ) return model.to(device) def build_similarity_loss(loss_name: str, ncc_window: int, ncc_impl: str = "local"): """构建相似度 loss。 颈部 CT 预处理后通常有大面积零填充背景,Neurite NCC 在低方差窗口中可能 返回非有限值。因此默认使用带 epsilon 的本地 NCC;模型核心仍为官方 VoxelMorph。需要对照官方依赖时,可传入 ``ncc_impl="neurite"``。 """ _, ne = require_official_voxelmorph() loss_name = loss_name.lower() if loss_name == "ncc": ncc_impl = ncc_impl.lower() if ncc_impl == "neurite": return NegativeSimilarity(ne.nn.modules.NCC(window_size=int(ncc_window))) if ncc_impl == "local": return LocalNCCLoss(window_size=int(ncc_window)) raise ValueError("ncc_impl 只能是 local 或 neurite。") if loss_name == "mse": return ne.nn.modules.MSE() raise ValueError("loss_name 只能是 ncc 或 mse。") def build_gradient_loss(): """官方平滑项:Neurite SpatialGradient。""" _, ne = require_official_voxelmorph() return ne.nn.modules.SpatialGradient(penalty="l2") def train_pair( moving_path: str | Path, fixed_path: str | Path, checkpoint_path: str | Path = DEFAULT_CHECKPOINT, epochs: int = 200, learning_rate: float = 1e-4, smooth_weight: float = 0.01, image_loss: str = "ncc", ncc_window: int = 9, ncc_impl: str = "local", nb_features: Sequence[int] = (16, 16, 16, 16, 16), integration_steps: int = 0, device: str = "auto", use_amp: bool = True, save_every: int = 50, ) -> List[Dict[str, float]]: """使用一对 moving/fixed 进行无监督 VoxelMorph 训练。""" require_official_voxelmorph() device_obj = resolve_device(device) moving, _ = load_nifti_tensor(moving_path, device=device_obj) fixed, _ = load_nifti_tensor(fixed_path, device=device_obj) if moving.shape != fixed.shape: raise ValueError(f"Moving 与 Fixed 形状不一致: {tuple(moving.shape)} vs {tuple(fixed.shape)}") model = build_vxm_model(nb_features=nb_features, integration_steps=integration_steps, device=device_obj) similarity_loss = build_similarity_loss(image_loss, ncc_window=ncc_window, ncc_impl=ncc_impl).to(device_obj) grad_loss_fn = build_gradient_loss().to(device_obj) optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate) amp_enabled = bool(use_amp and device_obj.type == "cuda") scaler = torch.amp.GradScaler("cuda", enabled=amp_enabled) checkpoint_path = Path(checkpoint_path) checkpoint_path.parent.mkdir(parents=True, exist_ok=True) history: List[Dict[str, float]] = [] for epoch in range(1, epochs + 1): model.train() optimizer.zero_grad(set_to_none=True) try: autocast_context = torch.amp.autocast("cuda", enabled=True) if amp_enabled else nullcontext() with autocast_context: displacement, warped = model( moving, fixed, return_warped_source=True, return_field_type="displacement", ) loss_sim = similarity_loss(fixed.float(), warped.float()) loss_smooth = grad_loss_fn(displacement.float()) loss = loss_sim + smooth_weight * loss_smooth if not torch.isfinite(loss): raise RuntimeError( "训练 loss 出现非有限值。可尝试使用 --ncc-impl local、" "--image-loss mse,或检查预处理后是否有大面积常值区域。" ) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() except RuntimeError as exc: if "out of memory" in str(exc).lower(): raise RuntimeError( "GPU/内存不足。可尝试减小 --target-shape,或降低 --nb-features," "再重新预处理与训练。" ) from exc raise row = { "epoch": float(epoch), "loss": float(loss.detach().cpu()), "image_loss": float(loss_sim.detach().cpu()), "smooth_loss": float(loss_smooth.detach().cpu()), } history.append(row) print( f"Epoch {epoch:04d}/{epochs} | " f"loss={row['loss']:.6f} image={row['image_loss']:.6f} smooth={row['smooth_loss']:.6f}" ) if epoch == epochs or (save_every > 0 and epoch % save_every == 0): save_checkpoint( checkpoint_path, model=model, epoch=epoch, input_shape_xyz=tuple(int(v) for v in moving.shape[2:]), nb_features=nb_features, integration_steps=integration_steps, config={ "moving_path": str(moving_path), "fixed_path": str(fixed_path), "learning_rate": learning_rate, "smooth_weight": smooth_weight, "image_loss": image_loss, "ncc_window": ncc_window, "ncc_impl": ncc_impl, }, history=history, ) history_path = checkpoint_path.with_suffix(".history.json") history_path.write_text(json.dumps(history, ensure_ascii=False, indent=2), encoding="utf-8") return history def save_checkpoint( path: str | Path, model, epoch: int, input_shape_xyz: Sequence[int], nb_features: Sequence[int], integration_steps: int, config: Dict, history: List[Dict[str, float]], ) -> None: require_torch() path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) torch.save( { "model_state_dict": model.state_dict(), "epoch": int(epoch), "input_shape_xyz": tuple(int(v) for v in input_shape_xyz), "nb_features": list(int(v) for v in nb_features), "integration_steps": int(integration_steps), "official_core": { "library": "voxelmorph", "repo": OFFICIAL_VXM_REPO, "commit": OFFICIAL_VXM_COMMIT, "model_class": "voxelmorph.nn.models.VxmPairwise", }, "config": config, "history": history, }, path, ) def build_arg_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="用官方 VoxelMorph 训练 3D 颈部 CT 形变配准模型。") parser.add_argument("--moving", required=True, help="预处理后的 moving NIfTI。") parser.add_argument("--fixed", required=True, help="预处理后的 fixed NIfTI。") parser.add_argument("--checkpoint", default=str(DEFAULT_CHECKPOINT), help="模型权重保存路径。") parser.add_argument("--epochs", type=int, default=200) parser.add_argument("--lr", type=float, default=1e-4) parser.add_argument("--smooth-weight", type=float, default=0.01) parser.add_argument("--image-loss", choices=["ncc", "mse"], default="ncc") parser.add_argument("--ncc-window", type=int, default=9) parser.add_argument("--ncc-impl", choices=["local", "neurite"], default="local") parser.add_argument("--nb-features", type=int, nargs="+", default=[16, 16, 16, 16, 16]) parser.add_argument("--integration-steps", type=int, default=0, help="0 为普通 dense flow;>0 使用 scaling-and-squaring。") parser.add_argument("--device", default="auto") parser.add_argument("--no-amp", action="store_true", help="关闭 CUDA AMP 混合精度。") parser.add_argument("--save-every", type=int, default=50) return parser def main(argv: Iterable[str] | None = None) -> None: args = build_arg_parser().parse_args(argv) train_pair( moving_path=args.moving, fixed_path=args.fixed, checkpoint_path=args.checkpoint, epochs=args.epochs, learning_rate=args.lr, smooth_weight=args.smooth_weight, image_loss=args.image_loss, ncc_window=args.ncc_window, ncc_impl=args.ncc_impl, nb_features=args.nb_features, integration_steps=args.integration_steps, device=args.device, use_amp=not args.no_amp, save_every=args.save_every, ) if __name__ == "__main__": main()