Source code for neuroreg.transforms.xfm

from __future__ import annotations

import re
from dataclasses import dataclass, field
from pathlib import Path

import numpy as np

from .lta import LTA, _AnyHeader, _header_info, _header_to_vol_info, _invalid_vol_info


def _normalize_comment(line: str) -> str:
    """Normalize a comment line to MINC/XFM comment syntax.

    Parameters
    ----------
    line : str
        Raw comment text.

    Returns
    -------
    str
        Comment line guaranteed to start with ``%`` and to omit the trailing
        newline.
    """
    line = line.rstrip("\n")
    return line if line.startswith("%") else f"%{line}"


def _infer_paths(comments: list[str]) -> tuple[str | None, str | None]:
    """Infer source and destination paths from preserved XFM comments.

    Parameters
    ----------
    comments : list[str]
        Comment lines stored alongside the transform.

    Returns
    -------
    tuple[str or None, str or None]
        Inferred ``(src_path, dst_path)`` pair. Missing values are returned as
        ``None``.
    """
    src = None
    dst = None
    joined = "\n".join(comments)

    match = re.search(r"\bsrc\s+(\S+)\s+dst\s+(\S+)", joined)
    if match:
        return match.group(1), match.group(2)

    volumes = []
    for line in comments:
        stripped = line.lstrip("% ")
        if stripped.startswith("Volume:"):
            parts = stripped.split(None, 1)
            if len(parts) == 2:
                volumes.append(parts[1].strip())
    if len(volumes) >= 2:
        dst = volumes[0]
        src = volumes[1]

    return src, dst


[docs] @dataclass(slots=True) class XFM: """MNI/MINC linear transform file. The stored matrix is always the 4x4 scanner-RAS / RAS-to-RAS transform. Comment lines are preserved for round-tripping because FreeSurfer sometimes stores useful provenance there. """ matrix: np.ndarray comments: list[str] = field(default_factory=list) src_path: str | None = None dst_path: str | None = None def __post_init__(self) -> None: self.matrix = np.asarray(self.matrix, dtype=float).reshape(4, 4) if self.comments: self.comments = [_normalize_comment(line) for line in self.comments] if self.src_path is None or self.dst_path is None: inferred_src, inferred_dst = _infer_paths(self.comments) if self.src_path is None: self.src_path = inferred_src if self.dst_path is None: self.dst_path = inferred_dst
[docs] @classmethod def read(cls, filename: str | Path) -> XFM: """Read an MNI/MINC XFM file from disk. Parameters ---------- filename : str or Path Path to a linear ``.xfm`` transform file. Returns ------- XFM Parsed XFM wrapper with preserved comment lines. Raises ------ ValueError If the file header is invalid or the linear transform block cannot be parsed. """ path = Path(filename) lines = path.read_text().splitlines() if not lines or not lines[0].startswith("MNI Transform File"): raise ValueError(f"{path}: not an MNI Transform File") comments: list[str] = [] linear_idx = None for idx, line in enumerate(lines[1:], start=1): stripped = line.strip() if stripped.startswith("%"): comments.append(stripped) if stripped.lower().startswith("linear_transform"): linear_idx = idx break if linear_idx is None: raise ValueError(f"{path}: missing Linear_Transform block") rows: list[list[float]] = [] for line in lines[linear_idx + 1:]: stripped = line.strip() if not stripped: continue stripped = stripped.replace(";", " ") values = [float(v) for v in stripped.split()] if len(values) != 4: continue rows.append(values) if len(rows) == 3: break if len(rows) != 3: raise ValueError(f"{path}: could not parse 3x4 Linear_Transform") matrix = np.eye(4, dtype=float) matrix[:3, :] = np.asarray(rows, dtype=float) return cls(matrix=matrix, comments=comments)
[docs] @classmethod def from_lta(cls, lta: LTA, comments: list[str] | None = None) -> XFM: """Create an XFM wrapper from a canonical LTA. Parameters ---------- lta : LTA Canonical scanner-RAS transform mapping moving to reference space. comments : list[str] or None, optional Comment lines to preserve in the output wrapper. When omitted, a simple provenance comment is generated. Returns ------- XFM Wrapper containing the same RAS-to-RAS matrix and inferred source / destination paths. """ src = lta.src.get("filename", "") dst = lta.dst.get("filename", "") if comments is None: comments = [f"%Generated by neuroreg src {src} dst {dst}"] return cls(matrix=lta.r2r(), comments=comments, src_path=src or None, dst_path=dst or None)
[docs] def to_lta( self, src_fname: str | None = None, src_img: _AnyHeader | None = None, dst_fname: str | None = None, dst_img: _AnyHeader | None = None, ) -> LTA: """Convert the XFM wrapper to canonical scanner-RAS LTA form. Parameters ---------- src_fname, dst_fname : str or None, optional Optional filenames to store in the output LTA metadata. When not provided, inferred paths from XFM comments are used if available. src_img, dst_img : header-like or None, optional Optional source and destination image headers used to populate LTA volume information. Returns ------- LTA Canonical RAS-to-RAS transform wrapper. """ src_fname = src_fname if src_fname is not None else (self.src_path or "") dst_fname = dst_fname if dst_fname is not None else (self.dst_path or "") src = _invalid_vol_info(src_fname) if src_img is None else _header_to_vol_info(_header_info(src_img), src_fname) dst = _invalid_vol_info(dst_fname) if dst_img is None else _header_to_vol_info(_header_info(dst_img), dst_fname) return LTA(self.matrix, 1, src, dst)
[docs] def write(self, filename: str | Path) -> None: """Write the transform in MNI/MINC XFM text format. Parameters ---------- filename : str or Path Output transform path. Returns ------- None Writes the transform to ``filename``. """ path = Path(filename) comments = self.comments or [ _normalize_comment(f"Generated by neuroreg src {self.src_path or ''} dst {self.dst_path or ''}") ] with path.open("w") as f: f.write("MNI Transform File\n") for line in comments: f.write(f"{_normalize_comment(line)}\n") f.write("\n") f.write("Transform_Type = Linear;\n") f.write("Linear_Transform =\n") for row_idx, row in enumerate(self.matrix[:3, :], start=1): suffix = ";" if row_idx == 3 else "" f.write(" ".join(f"{float(v):.13g}" for v in row) + suffix + "\n")