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.
152 lines
No EOL
6.2 KiB
Python
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 |