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")