Files
Voxelmorph_Head_CT/model_and_train.py

401 lines
14 KiB
Python
Raw 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.
"""官方 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()