longitudinal/src/impact_loss.py
Furen Xiao 77653e4018 refactor(impact): replace KonfAI pip runtime with Docker elastix_impact image
Migrate register_impact from impact-reg-konfai (pip + one-time glibc/LibTorch
shim) to the vboussot/elastix_impact Docker image running a rigid 6-DOF
EulerTransform with MIND + TotalSegmentator static features and a Mattes-MI
secondary metric. Delete scripts/setup_impact_konfai.sh.

Vendor ImpactLoss (vboussot/ImpactLoss) into src/impact_loss.py and add
impact_loss as a per-case semantic scoring metric in the benchmark runner.

Update docs (AGENTS.md, REGISTRATION_BENCHMARK.md) to reflect the
all-rigid pipeline set and the new Docker-based IMPACT runtime.
2026-09-30 21:23:18 +08:00

152 lines
No EOL
6.2 KiB
Python

# Vendored from https://github.com/vboussot/ImpactLoss (IMPACT.py)
# Copyright (c) 2025 Valentin Boussot. Apache-2.0.
#
# IMPACT Reg: A Generic Semantic Loss for Multimodal Image Registration
# (Boussot et al., arXiv:2503.24121). Modification relative to upstream:
# model files are resolved from a local cache dir first (``model_cache`` /
# the ``IMPACT_MODELS_DIR`` env var) before falling back to the Hugging
# Face download, so offline hosts can use the already-cached
# ``data/impact_models`` layout.
import os
import torch
from huggingface_hub import hf_hub_download
IMPACT_HF_REPO = "VBoussot/impact-torchscript-models"
def _resolve_model(model_name, model_cache):
"""Local cache path if present, else the Hugging Face download."""
if model_cache:
p = os.path.join(model_cache, model_name)
if os.path.exists(p):
return p
return hf_hub_download(repo_id=IMPACT_HF_REPO, filename=model_name,
repo_type="model", revision=None) # nosec B615
class IMPACTReg(torch.nn.Module):
"""
IMPACT-Reg Loss Module
------------------
A similarity metric based on pretrained TorchScript feature extractors.
Computes a weighted L1 distance between feature maps of two images.
Parameters
----------
model_name : str
Name of the TorchScript model (e.g. "TS/M730").
shape : list[int]
Expected spatial size. Use [0, 0, 0] for no resample.
in_channels : int
Number of input channels (before adapting to the model).
weights : list[float]
Per-layer feature weights.
model_cache : str, optional
Directory the model files are looked up in before downloading.
"""
def __init__(
self, model_name: str, shape: list[int] = [0, 0], in_channels: int = 1,
weights: list[float] = [1], model_cache: str = None,
) -> None:
super().__init__()
if model_name is None:
return
self.in_channels = in_channels
self.loss = torch.nn.L1Loss()
self.weights = weights
self.nb_layer = len(weights)
# Load the TorchScript model (local cache first, HF download after)
model_cache = model_cache or os.environ.get("IMPACT_MODELS_DIR", "")
self.model_path = _resolve_model(model_name, model_cache)
self.model: torch.nn.Module = torch.jit.load(self.model_path, map_location=torch.device("cpu")) # nosec B614
self.dim = len(shape)
self.shape = shape if all(s > 0 for s in shape) else None
dummy_input = torch.zeros((1, self.in_channels, *(self.shape if self.shape else [128] * self.dim))).to(0)
try:
out = self.model.to(0)(dummy_input, torch.tensor([self.nb_layer]))
if not isinstance(out, (list, tuple)):
raise TypeError(f"Expected model output to be a list or tuple, but got {type(out)}.")
if self.nb_layer != len(out):
raise ValueError(
f"Loss 'L1': mismatch between the number of weights "
f"({self.nb_layer}) and the number of model outputs "
f"({len(out)}). Each output must have a corresponding weight."
)
except Exception as e:
msg = (
f"[Model Sanity Check Failed]\n"
f"Input shape attempted: {dummy_input.shape}\n"
f"Error: {type(e).__name__}: {e}"
)
raise RuntimeError(msg) from e
self.model = None
def preprocessing(self, tensor: torch.Tensor) -> torch.Tensor:
"""
Resize and adjust the tensor channels to match model expectations.
Note:
This method does not perform any intensity normalization.
Input images must be provided in their raw (non-normalized) form,
i.e., in their original intensity space (e.g., HU for CT, native
values for MRI). Intensity normalization / standardization is the
responsibility of the IMPACT models.
"""
if self.shape is not None and not all(
tensor.shape[-i - 1] == size for i, size in enumerate(reversed(self.shape[2:]))
):
if tensor.dtype == torch.uint8:
mode = "nearest"
elif len(tensor.shape) < 4:
mode = "bilinear"
else:
mode = "trilinear"
tensor = torch.nn.functional.interpolate(
tensor, mode=mode, size=tuple(self.shape), align_corners=False
).type(torch.float32)
# Replicate channels if needed (e.g., 1 -> 3)
if tensor.shape[1] != self.in_channels:
tensor = tensor.repeat(tuple([1, self.in_channels] + [1 for _ in range(self.dim)]))
return [tensor, torch.tensor([self.nb_layer]),
torch.tensor([tensor.min(), tensor.max(), tensor.mean(), tensor.std()])]
def _compute(self, output: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
"""Compute the feature-based L1 loss between two tensors."""
loss = torch.zeros(1, device=output.device, dtype=torch.float32)
output = self.preprocessing(output)
target = self.preprocessing(target)
for weight, output_features, target_features in zip(
self.weights, self.model(*output), self.model(*target)
):
if weight == 0:
continue
loss = loss + weight * self.loss(output_features, target_features)
return loss
def forward(self, output: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
"""
Main forward method.
If the model is 2D and inputs are 3D, compute slice-wise average loss.
"""
if self.model is None:
self.model = torch.jit.load(self.model_path) # nosec B614
self.model.to(output.device)
self.model.eval()
loss = torch.zeros(1, device=output.device, dtype=torch.float32)
# 3D -> 2D model case: iterate over slices
if len(output.shape) == 5 and self.dim == 2:
for i in range(output.shape[2]):
loss = loss + self._compute(output[:, :, i, ...], target[:, :, i, ...])
loss = loss / output.shape[2]
else:
loss = self._compute(output, target)
return loss