| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| """Registration as a KonfAI model: the config -> elastix parameter-map mapping + the ``add_module`` graph. |
| |
| ``RegistrationNet`` wires ``ElastixRegistration`` (fixed = branch 0, moving = branch 1, fixed/moving masks = |
| 2/3) and splits its output into ``MovedImage`` / ``DisplacementField`` on the fixed grid. This module owns |
| the MAPPING — the per-resolution model matrix (``resolutions``) turned into IMPACT parameter-map lines, and |
| the config schema (``ModelSpec`` / ``ResolutionSpec``). The elastix RUNTIME (binary install, model download, |
| subprocess, progress) lives in ``elastix_engine.py`` and is imported only when the graph is built. |
| |
| A UI reads the tuning knobs straight from the TYPES below: ``Literal`` (a fixed set), |
| ``Annotated[.., Range]`` (numeric bounds), ``Annotated[str, Choices(...)]`` (a resolver the app owns). |
| |
| NOTE: do NOT add ``from __future__ import annotations`` — KonfAI's config engine reads runtime annotations |
| (``get_origin``); PEP 563 stringized annotations break arg resolution. |
| """ |
|
|
| import json |
| import os |
| import re |
| from dataclasses import dataclass, field |
| from pathlib import Path |
| from typing import Annotated, Literal |
|
|
| import torch |
| from huggingface_hub import hf_hub_download |
| from konfai.network import network |
| from konfai.utils.config import Choices, Range |
|
|
| |
| |
| |
| |
| _IMPACT_MODELS_REGISTRY = "VBoussot/impact-torchscript-models:models.json" |
|
|
| |
| |
| _FOV_RAMP_MAX_LAYER = 6 |
|
|
|
|
| def registry_choices() -> list[str]: |
| """The ``ref`` picker's values — model refs (``repo:path``) from the registry the engine already fetches |
| (offline-first). A user may still point ``ref`` at a local model.""" |
| repo = _IMPACT_MODELS_REGISTRY.split(":", 1)[0] |
| return [f"{repo}:{key}" for key in load_models_registry()] |
|
|
|
|
| def _num(x: object) -> str: |
| """Format a number the elastix way: no trailing '.0' (6.0 -> '6', 0.2 -> '0.2').""" |
| return "%g" % float(x) |
|
|
|
|
| @dataclass |
| class ModelSpec: |
| """One feature model at one resolution (several may share a resolution). ``ref`` picks the model; the |
| rest are its per-(resolution, model) knobs. Dimension / channels / FOV are intrinsic — from the registry |
| (``models.json``) keyed by ``ref`` — never tuned.""" |
|
|
| ref: Annotated[str, Choices(registry_choices)] |
| voxel_size: list[float] = field(default_factory=list) |
| layers_weight: list[float] = field(default_factory=lambda: [1.0]) |
| subset_features: Annotated[int, Range(0, 1000)] = 0 |
| pca: Annotated[int, Range(0, 100)] = 0 |
| distance: Literal["L1", "L2", "Dice", "Cosine", "NCC"] = "L1" |
| layers_mask: str = "" |
|
|
|
|
| @dataclass |
| class ResolutionSpec: |
| """One elastix resolution level: its iteration budget and the (self-configured) models compared there.""" |
|
|
| max_iterations: Annotated[int, Range(1, 100000)] |
| models: dict[str, ModelSpec] |
|
|
|
|
| def _sorted_specs(mapping: dict) -> list: |
| """dict keyed by string indices ('0','1',...) -> values in numeric order.""" |
| return [mapping[k] for k in sorted(mapping, key=lambda key: int(key))] |
|
|
|
|
| def load_models_registry(ref: str = _IMPACT_MODELS_REGISTRY) -> dict: |
| """Load models.json (the fixed params per model) from the model repo on Hugging Face. |
| |
| The registry is NOT bundled with the preset. ``KONFAI_IMPACT_MODELS_REGISTRY`` (a local path) wins for |
| dev/offline; otherwise ``ref`` must be a ``repo:file`` Hugging Face reference. |
| """ |
| local = os.environ.get("KONFAI_IMPACT_MODELS_REGISTRY", "") |
| if local: |
| path = Path(local) |
| elif ":" in ref: |
| repo, filename = ref.split(":", 1) |
| path = Path(hf_hub_download(repo_id=repo, filename=filename, repo_type="model")) |
| else: |
| raise ValueError( |
| f"models_registry '{ref}' must be a 'repo:file' Hugging Face reference (the registry is fetched " |
| f"from HF, not bundled) — or set KONFAI_IMPACT_MODELS_REGISTRY to a local file for offline use." |
| ) |
| return json.loads(path.read_text(encoding="utf-8")) |
|
|
|
|
| def _model_key(ref: str) -> str: |
| """Registry key / staged relative path = the model file within the repo (strip a 'repo:' prefix).""" |
| return ref.split(":", 1)[1] if ":" in ref else ref |
|
|
|
|
| def _deepest_active_layer(layers_mask: str) -> int: |
| """Deepest (largest-FOV) layer selected by ``layers_mask``, as a 0-based index. |
| |
| A model returns its layers shallow->deep; ``layers_mask`` has one char per returned layer, position ``i`` |
| == ``layer_i``, ``'1'`` = selected. In Jacobian the patch must cover the DEEPEST selected layer's |
| receptive field, so the FOV is governed by the rightmost ``'1'``. |
| """ |
| mask = layers_mask.strip().strip('"') |
| active = [i for i, char in enumerate(mask) if char == "1"] |
| if not active: |
| raise ValueError(f"LayersMask '{layers_mask}' selects no layer; cannot derive the model FOV.") |
| return max(active) |
|
|
|
|
| def _fov_value(fov: dict, layers_mask: str) -> int: |
| """Evaluate a model's field-of-view (in voxels) from its registry ``fov`` spec. |
| |
| Formulas (model repo, https://huggingface.co/VBoussot/impact-torchscript-models): |
| ``2*r*d+1`` MIND, from radius ``r`` / dilation ``d`` (R1D2 -> 5); |
| ``2^l+3`` TotalSegmentator / MRSegmentator, ``l`` = deepest layer picked by ``layers_mask``, clamped |
| to the receptive-field plateau ``_FOV_RAMP_MAX_LAYER`` (layers 7-8 -> layer 6); |
| a bare int a fixed FOV (SAM2.1 -> 29, DINOv2 -> 14); |
| ``Global`` Anatomix — whole-image only (Static); no finite Jacobian patch -> error. |
| An explicit ``value`` in the spec is honoured as a precomputed shortcut. |
| """ |
| formula = str(fov.get("formula", "")).strip() |
| key = re.sub(r"\s+", "", formula).lower() |
| if key.isdigit(): |
| return int(key) |
| if key == "2*r*d+1": |
| return 2 * int(fov["r"]) * int(fov["d"]) + 1 |
| if key == "2^l+3": |
| return 2 ** min(_deepest_active_layer(layers_mask), _FOV_RAMP_MAX_LAYER) + 3 |
| if "global" in key: |
| raise ValueError(f"model FOV '{formula}' is whole-image only (Static); it has no Jacobian patch size.") |
| if fov.get("value") is not None: |
| return int(fov["value"]) |
| raise ValueError(f"cannot evaluate model FOV formula '{formula}'.") |
|
|
|
|
| def _patch_size(mode: str, entry: dict, layers_mask: str) -> str: |
| """PatchSize from the model FOV, one token per model axis (2D -> 2 tokens, 3D -> 3): Static -> whole |
| image (all zeros); Jacobian -> the evaluated FOV per axis. A 2D+3D mix at a resolution concatenates, |
| e.g. ``29 29 11 11 11`` (SAM 2D + TS 3D), matching IMPACT.""" |
| dim = int(entry.get("dimension", 3)) |
| if mode.strip().strip('"').lower() != "jacobian": |
| return " ".join(["0"] * dim) |
| fov = _fov_value(entry.get("fov", {}), layers_mask) |
| return " ".join([str(fov)] * dim) |
|
|
|
|
| def generate_impact_parameter_map(template_text: str, resolutions: dict, registry: dict, mode: str = "Static") -> str: |
| """Rewrite the resolution-dependent lines of ``template_text`` from the model matrix ``resolutions``. |
| |
| Regenerated: MaximumNumberOfIterations, NumberOfResolutions, Fixed/MovingImagePyramidRescaleSchedule, |
| ImpactMode, and the whole ImpactXxxK block; every other line is kept verbatim. N (number of resolutions) |
| is deduced from the config. ``mode`` drives PatchSize: Static -> ``0 0 0``; Jacobian -> the per-model FOV |
| from the registry formula and the cell's ``layers_mask``. |
| """ |
| res = _sorted_specs(resolutions) |
| n = len(res) |
| mode_clean = mode.strip().strip('"') or "Static" |
|
|
| impact: list[str] = [] |
| for k, r in enumerate(res): |
| models = _sorted_specs(r.models) |
| entries = [registry[_model_key(m.ref)] for m in models] |
|
|
| def row(stem: str, values: list[str]) -> None: |
| impact.append(f"(Impact{stem}{k} " + " ".join(values) + ")") |
|
|
| |
| |
| row("ModelsPath", [f'"{_model_key(m.ref)}"' for m in models]) |
| row("Dimension", [e["dimension"] for e in entries]) |
| row("NumberOfChannels", [e["numberofchannels"] for e in entries]) |
| row("PatchSize", [_patch_size(mode_clean, e, m.layers_mask) for e, m in zip(entries, models)]) |
| row("VoxelSize", [" ".join(_num(v) for v in m.voxel_size) for m in models]) |
| row("LayersMask", [f'"{m.layers_mask}"' for m in models]) |
| row("SubsetFeatures", [str(m.subset_features) for m in models]) |
| row("PCA", [str(m.pca) for m in models]) |
| row("Distance", [f'"{m.distance}"' for m in models]) |
| row("LayersWeight", [" ".join(_num(w) for w in m.layers_weight) for m in models]) |
| impact.append("") |
|
|
| |
| |
| lines = template_text.splitlines() |
| indexed = [(re.match(r"^\s*\((\S+?)\s+(.*?)\)\s*$", ln), ln) for ln in lines] |
| block_rows = [i for i, (m, _) in enumerate(indexed) if m and re.match(r"^Impact[A-Za-z]+\d+$", m.group(1))] |
| block_lo, block_hi = (block_rows[0], block_rows[-1]) if block_rows else (-1, -2) |
|
|
| out: list[str] = [] |
| for i, (m, line) in enumerate(indexed): |
| key = m.group(1) if m else None |
| if block_lo <= i <= block_hi: |
| if i == block_lo: |
| out.extend(impact[:-1]) |
| elif key == "MaximumNumberOfIterations": |
| out.append("(MaximumNumberOfIterations " + " ".join(_num(r.max_iterations) for r in res) + ")") |
| elif key == "NumberOfResolutions": |
| out.append(f"(NumberOfResolutions {n})") |
| elif key in ("FixedImagePyramidRescaleSchedule", "MovingImagePyramidRescaleSchedule"): |
| out.append(f"({key} " + " ".join(["1"] * 3 * n) + ")") |
| elif key == "ImpactMode": |
| out.append(f'(ImpactMode "{mode_clean}")') |
| else: |
| out.append(line) |
| return "\n".join(out) |
|
|
|
|
| class ChannelSelect(torch.nn.Module): |
| """Select a channel slice ``[start:stop]`` (splits the registration output into moved / DVF).""" |
|
|
| def __init__(self, start: int, stop: int) -> None: |
| super().__init__() |
| self._start = start |
| self._stop = stop |
|
|
| def forward(self, tensor: torch.Tensor) -> torch.Tensor: |
| return tensor[:, self._start : self._stop] |
|
|
|
|
| class RegistrationNet(network.Network): |
| """Pairwise registration as an ``add_module`` graph (fixed = branch 0, moving = branch 1, fixed mask = 2, |
| moving mask = 3; masks restrict the metric, whole-image = no restriction). |
| |
| Outputs (both on the fixed grid): ``MovedImage`` (moving resampled onto fixed) and ``DisplacementField`` |
| (the dim-component displacement field, mm). ``ElastixRegistration`` produces both channel-stacked; two |
| ``ChannelSelect`` modules split them. Output geometry is attached by the predictor via |
| ``same_as_group: Volume_0:Fixed``. |
| """ |
|
|
| def __init__( |
| self, |
| optimizer: network.OptimizerLoader = network.OptimizerLoader(), |
| schedulers: dict[str, network.LRSchedulersLoader] = { |
| "default:ReduceLROnPlateau": network.LRSchedulersLoader(0) |
| }, |
| outputs_criterions: dict[str, network.TargetCriterionsLoader] = {"default": network.TargetCriterionsLoader()}, |
| engine: str = "elastix", |
| parameter_maps: list[str] = [], |
| max_iterations: Annotated[int, Range(0, 100000)] = 0, |
| final_grid_spacing: Annotated[float, Range(0.0, 100.0)] = 0.0, |
| subset_features: Annotated[int, Range(0, 1000)] = 0, |
| spatial_samples: Annotated[int, Range(0, 100000)] = 0, |
| parameter_overrides: list[str] = [], |
| resolutions: dict[str, ResolutionSpec] = {}, |
| mode: Literal["Static", "Jacobian"] = "Static", |
| ) -> None: |
| |
| |
| |
| |
| |
| from elastix_engine import ElastixRegistration |
|
|
| super().__init__( |
| in_channels=1, |
| optimizer=optimizer, |
| schedulers=schedulers, |
| outputs_criterions=outputs_criterions, |
| dim=3, |
| ) |
| self.add_module( |
| "Registration", |
| ElastixRegistration( |
| engine, |
| parameter_maps, |
| max_iterations, |
| final_grid_spacing, |
| subset_features, |
| spatial_samples, |
| parameter_overrides, |
| resolutions, |
| mode, |
| ), |
| in_branch=[0, 1, 2, 3], |
| out_branch=["registration"], |
| ) |
| self.add_module("MovedImage", ChannelSelect(0, 1), in_branch=["registration"], out_branch=["moved"]) |
| self.add_module("DisplacementField", ChannelSelect(1, 4), in_branch=["registration"], out_branch=["dvf"]) |
|
|