from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import numpy as np
from ..image.geometry import vox2tkras_from_volume_info
from .lta import LTA, _AnyHeader, _header_info, _header_to_vol_info, affine_from_volume_info
from .regdat import RegisterDat
def _is_nifti_like(path: str) -> bool:
"""Return whether a filename looks like a NIfTI/Analyze image path.
Parameters
----------
path : str
Candidate image filename.
Returns
-------
bool
``True`` when the suffix matches one of the NIfTI/Analyze extensions
that trigger FSL's handedness convention adjustments.
"""
lower = path.lower()
return lower.endswith('.nii') or lower.endswith('.nii.gz') or lower.endswith('.img') or lower.endswith('.hdr')
def _diag_spacing(info: dict) -> np.ndarray:
"""Construct a diagonal voxel-size matrix from volume metadata.
Parameters
----------
info : dict
FreeSurfer-style volume-info dictionary containing ``voxelsize``.
Returns
-------
np.ndarray
``(4, 4)`` diagonal spacing matrix.
"""
vs = np.asarray(info['voxelsize'], dtype=float)
mat = np.eye(4, dtype=float)
mat[0, 0] = vs[0]
mat[1, 1] = vs[1]
mat[2, 2] = vs[2]
return mat
def _apply_fsl_nifti_convention(
ref: dict,
mov: dict,
d_ref: np.ndarray,
d_mov: np.ndarray,
) -> tuple[np.ndarray, np.ndarray]:
"""Apply FSL's NIfTI handedness convention to spacing matrices.
Parameters
----------
ref, mov : dict
Reference and moving FreeSurfer-style volume-info dictionaries.
d_ref, d_mov : np.ndarray
Reference and moving diagonal spacing matrices.
Returns
-------
tuple[np.ndarray, np.ndarray]
Possibly adjusted ``(d_ref, d_mov)`` pair following FSL's NIfTI
convention for positive-determinant affines.
"""
ref_aff = affine_from_volume_info(ref)
mov_aff = affine_from_volume_info(mov)
if np.linalg.det(mov_aff[:3, :3]) > 0:
d_mov = d_mov.copy()
d_mov[0, 0] *= -1.0
d_mov[0, 3] = float(mov['voxelsize'][0]) * (float(mov['volume'][0]) - 1.0)
if np.linalg.det(ref_aff[:3, :3]) > 0:
d_ref = d_ref.copy()
d_ref[0, 0] *= -1.0
d_ref[0, 3] = float(ref['voxelsize'][0]) * (float(ref['volume'][0]) - 1.0)
return d_ref, d_mov
def _fsl_to_tkreg(ref: dict, mov: dict, fsl_matrix: np.ndarray) -> np.ndarray:
"""Convert an FSL voxel-to-voxel matrix to tkregister convention.
Parameters
----------
ref, mov : dict
Reference and moving FreeSurfer-style volume-info dictionaries.
fsl_matrix : np.ndarray
``(4, 4)`` affine matrix in FSL voxel-space convention.
Returns
-------
np.ndarray
Equivalent ``(4, 4)`` affine in tkregister voxel-space convention.
"""
inv_d_mov = np.linalg.inv(_diag_spacing(mov))
d_ref = _diag_spacing(ref)
mov_path = mov.get('filename', '')
ref_path = ref.get('filename', '')
if _is_nifti_like(mov_path) or _is_nifti_like(ref_path):
d_ref, d_mov = _apply_fsl_nifti_convention(ref, mov, d_ref, _diag_spacing(mov))
inv_d_mov = np.linalg.inv(d_mov)
t_mov = vox2tkras_from_volume_info(mov)
t_ref = vox2tkras_from_volume_info(ref)
return t_mov @ inv_d_mov @ np.linalg.inv(fsl_matrix) @ d_ref @ np.linalg.inv(t_ref)
def _tkreg_to_fsl(ref: dict, mov: dict, tkreg_matrix: np.ndarray) -> np.ndarray:
"""Convert a tkregister voxel-to-voxel matrix to FSL convention.
Parameters
----------
ref, mov : dict
Reference and moving FreeSurfer-style volume-info dictionaries.
tkreg_matrix : np.ndarray
``(4, 4)`` affine matrix in tkregister voxel-space convention.
Returns
-------
np.ndarray
Equivalent ``(4, 4)`` affine in FSL voxel-space convention.
"""
d_mov = _diag_spacing(mov)
d_ref = _diag_spacing(ref)
mov_path = mov.get('filename', '')
ref_path = ref.get('filename', '')
if _is_nifti_like(mov_path) or _is_nifti_like(ref_path):
d_ref, d_mov = _apply_fsl_nifti_convention(ref, mov, d_ref, d_mov)
t_mov = vox2tkras_from_volume_info(mov)
t_ref = vox2tkras_from_volume_info(ref)
return np.linalg.inv(d_mov @ np.linalg.inv(t_mov) @ tkreg_matrix @ t_ref @ np.linalg.inv(d_ref))
[docs]
@dataclass(slots=True)
class FSLMat:
"""FSL FLIRT affine matrix file.
The stored matrix maps moving voxels to reference voxels in FSL conventions,
so conversion to canonical scanner-RAS space requires explicit moving and
reference image geometry.
"""
matrix: np.ndarray
def __post_init__(self) -> None:
self.matrix = np.asarray(self.matrix, dtype=float).reshape(4, 4)
[docs]
@classmethod
def read(cls, filename: str | Path) -> FSLMat:
"""Read an FSL FLIRT matrix file.
Parameters
----------
filename : str or Path
Path to a text ``.mat`` file containing a 4x4 FLIRT affine.
Returns
-------
FSLMat
Parsed FSL matrix wrapper.
Raises
------
ValueError
If the file does not contain exactly four rows of four values.
"""
path = Path(filename)
rows = []
for line in path.read_text().splitlines():
stripped = line.strip()
if not stripped:
continue
values = [float(v) for v in stripped.split()]
if len(values) != 4:
raise ValueError(f'{path}: expected 4 columns per row in FSL matrix')
rows.append(values)
if len(rows) != 4:
raise ValueError(f'{path}: expected 4 rows in FSL matrix')
return cls(np.asarray(rows, dtype=float))
[docs]
@classmethod
def from_lta(cls, lta: LTA) -> FSLMat:
"""Create an FSL matrix wrapper from a canonical LTA.
Parameters
----------
lta : LTA
Canonical scanner-RAS transform mapping moving to reference space.
Returns
-------
FSLMat
Wrapper containing the equivalent FLIRT voxel-space affine.
"""
reg = RegisterDat.from_lta(lta)
return cls(_tkreg_to_fsl(lta.dst, lta.src, reg.matrix))
[docs]
def to_lta(
self,
*,
src_fname: str,
src_img: _AnyHeader,
dst_fname: str,
dst_img: _AnyHeader,
) -> LTA:
"""Convert the FSL matrix to canonical scanner-RAS LTA form.
Parameters
----------
src_fname, dst_fname : str
Source and destination filenames stored in the output LTA metadata.
src_img, dst_img : header-like
Source and destination image headers used to resolve the FSL voxel
conventions.
Returns
-------
LTA
Canonical RAS-to-RAS transform wrapper.
"""
src = _header_to_vol_info(_header_info(src_img), src_fname)
dst = _header_to_vol_info(_header_info(dst_img), dst_fname)
reg_matrix = _fsl_to_tkreg(dst, src, self.matrix)
return RegisterDat(reg_matrix).to_lta(
src_fname=src_fname,
src_img=src_img,
dst_fname=dst_fname,
dst_img=dst_img,
)
[docs]
def write(self, filename: str | Path) -> None:
"""Write the matrix in FSL text format.
Parameters
----------
filename : str or Path
Output transform path.
Returns
-------
None
Writes the transform to ``filename``.
"""
path = Path(filename)
with path.open('w') as f:
for row in self.matrix:
f.write(' '.join(f'{float(v):.8f}' for v in row) + '\n')