From 4c9a3ed622c3b1b76d2e0e677ab5ebebe38779c2 Mon Sep 17 00:00:00 2001 From: Julien Marabotto Date: Tue, 7 May 2024 12:19:30 +0200 Subject: [PATCH 01/18] X5.py created (cherry picked from commit 49bf4c36306e682b3b2889afcdd78b0bf4f9b4a8) --- nitransforms/io/__init__.py | 3 ++- nitransforms/io/x5.py | 53 +++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 nitransforms/io/x5.py diff --git a/nitransforms/io/__init__.py b/nitransforms/io/__init__.py index c38d11c2..f456a6d1 100644 --- a/nitransforms/io/__init__.py +++ b/nitransforms/io/__init__.py @@ -1,7 +1,7 @@ # emacs: -*- mode: python-mode; py-indent-offset: 4; indent-tabs-mode: nil -*- # vi: set ft=python sts=4 ts=4 sw=4 et: """Read and write transforms.""" -from nitransforms.io import afni, fsl, itk, lta +from nitransforms.io import afni, fsl, itk, lta, x5 from nitransforms.io.base import TransformIOError, TransformFileError __all__ = [ @@ -22,6 +22,7 @@ "fs": (lta, "FSLinearTransform"), "fsl": (fsl, "FSLLinearTransform"), "afni": (afni, "AFNILinearTransform"), + "x5": (x5, "X5LinearTransform") } diff --git a/nitransforms/io/x5.py b/nitransforms/io/x5.py new file mode 100644 index 00000000..b38ef9ab --- /dev/null +++ b/nitransforms/io/x5.py @@ -0,0 +1,53 @@ +"""Read/write x5 transforms.""" +import warnings +import numpy as np +from scipy.io import loadmat as _read_mat, savemat as _save_mat +from h5py import File as H5File +from nibabel import Nifti1Header, Nifti1Image +from nibabel.affines import from_matvec +from nitransforms.io.base import ( + BaseLinearTransformList, + DisplacementsField, + LinearParameters, + TransformIOError, + TransformFileError, +) + +LPS = np.diag([-1, -1, 1, 1]) + +class X5LinearTransform(LinearParameters): + """A string-based structure for X5 linear transforms.""" + + template_dtype = np.dtype( + [ + ("type", "i4"), + ("index", "i4"), + ("parameters", "f8", (4, 4)), + ("offset", "f4", 3), # Center of rotation + ] + ) + dtype = template_dtype + + def __init__(self, parameters=None, offset=None): + return + + def __str__(self): + return + + def to_filename(self, filename): + """Store this transform to a file with the appropriate format.""" + sa = self.structarr + affine = np.array( + np.hstack( + (sa["parameters"][:3, :3].reshape(-1), sa["parameters"][:3, 3]) + )[..., np.newaxis], + dtype="f8", + ) + return + + @classmethod + def from_filename(cls, filename): + """Read the struct from a X5 file given its path.""" + if str(filename).endswith(".h5"): + with H5File(str(filename)) as f: + return cls.from_h5obj(f) From fce65f2d30e9c0d4ecff66fe08602d170263b403 Mon Sep 17 00:00:00 2001 From: Julien Marabotto Date: Fri, 10 May 2024 09:32:23 +0200 Subject: [PATCH 02/18] enh: update implementation of X5 (cherry picked from commit 74090ac7a7ea492b5ebba848f67996dcc587ad82) --- nitransforms/io/x5.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/nitransforms/io/x5.py b/nitransforms/io/x5.py index b38ef9ab..11403c92 100644 --- a/nitransforms/io/x5.py +++ b/nitransforms/io/x5.py @@ -51,3 +51,19 @@ def from_filename(cls, filename): if str(filename).endswith(".h5"): with H5File(str(filename)) as f: return cls.from_h5obj(f) + +class X5LinearTransformArray(BaseLinearTransformList): + """A string-based structure for series of X5 linear transforms.""" + + _inner_type = X5LinearTransform + + @property + def xforms(self): + """Get the list of internal ITKLinearTransforms.""" + return self._xforms + + @xforms.setter + def xforms(self, value): + self._xforms = list(value) + for i, val in enumerate(self.xforms): + val["index"] = i \ No newline at end of file From 5b540b7f81565e91703543519bf57c38b375dad2 Mon Sep 17 00:00:00 2001 From: Julien Marabotto Date: Fri, 10 May 2024 14:49:03 +0200 Subject: [PATCH 03/18] enh: update implementation of X5 (cherry picked from commit ba8a389156728c7b300ff0f6b59cfc9a63f50775) --- nitransforms/io/x5.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/nitransforms/io/x5.py b/nitransforms/io/x5.py index 11403c92..264df3d7 100644 --- a/nitransforms/io/x5.py +++ b/nitransforms/io/x5.py @@ -35,14 +35,9 @@ def __str__(self): return def to_filename(self, filename): - """Store this transform to a file with the appropriate format.""" + '''store this transform to a file with the X5 format''' sa = self.structarr - affine = np.array( - np.hstack( - (sa["parameters"][:3, :3].reshape(-1), sa["parameters"][:3, 3]) - )[..., np.newaxis], - dtype="f8", - ) + affine = '''some affine that will return a 4x4 array''' return @classmethod @@ -61,9 +56,3 @@ class X5LinearTransformArray(BaseLinearTransformList): def xforms(self): """Get the list of internal ITKLinearTransforms.""" return self._xforms - - @xforms.setter - def xforms(self, value): - self._xforms = list(value) - for i, val in enumerate(self.xforms): - val["index"] = i \ No newline at end of file From 92e932cef591dd359d27016f74327b0543907794 Mon Sep 17 00:00:00 2001 From: Julien Marabotto Date: Fri, 17 May 2024 16:17:51 +0200 Subject: [PATCH 04/18] enh: x5 implementation (cherry picked from commit e4e3f44c0951704d2a1a5653aedd9ad6dab64958) --- nitransforms/io/__init__.py | 2 +- nitransforms/io/x5.py | 41 +++++++++++++++++-------------------- 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/nitransforms/io/__init__.py b/nitransforms/io/__init__.py index f456a6d1..3627fd34 100644 --- a/nitransforms/io/__init__.py +++ b/nitransforms/io/__init__.py @@ -22,7 +22,7 @@ "fs": (lta, "FSLinearTransform"), "fsl": (fsl, "FSLLinearTransform"), "afni": (afni, "AFNILinearTransform"), - "x5": (x5, "X5LinearTransform") + "x5": (x5, "X5Transform") } diff --git a/nitransforms/io/x5.py b/nitransforms/io/x5.py index 264df3d7..72b08a0f 100644 --- a/nitransforms/io/x5.py +++ b/nitransforms/io/x5.py @@ -13,20 +13,10 @@ TransformFileError, ) -LPS = np.diag([-1, -1, 1, 1]) - -class X5LinearTransform(LinearParameters): +class X5Transform: """A string-based structure for X5 linear transforms.""" - template_dtype = np.dtype( - [ - ("type", "i4"), - ("index", "i4"), - ("parameters", "f8", (4, 4)), - ("offset", "f4", 3), # Center of rotation - ] - ) - dtype = template_dtype + _transform = None def __init__(self, parameters=None, offset=None): return @@ -34,25 +24,32 @@ def __init__(self, parameters=None, offset=None): def __str__(self): return - def to_filename(self, filename): - '''store this transform to a file with the X5 format''' - sa = self.structarr - affine = '''some affine that will return a 4x4 array''' - return - @classmethod def from_filename(cls, filename): """Read the struct from a X5 file given its path.""" if str(filename).endswith(".h5"): - with H5File(str(filename)) as f: - return cls.from_h5obj(f) + with H5File(str(filename), 'r') as hdf: + return cls.from_h5obj(hdf) + + @classmethod + def from_h5obj(cls, h5obj): + """Read the transformations in an X5 file.""" + xfm_list = list(h5obj.keys()) + + xfm = xfm_list["Transform"] + inv = xfm_list["Inverse"] + coords = xfm_list["Size"] + map = xfm_list["Mapping"] + + return xfm, inv, coords, map + class X5LinearTransformArray(BaseLinearTransformList): """A string-based structure for series of X5 linear transforms.""" - _inner_type = X5LinearTransform + _inner_type = X5Transform @property def xforms(self): - """Get the list of internal ITKLinearTransforms.""" + """Get the list of internal X5LinearTransforms.""" return self._xforms From d092ea1bb402d1d07280b0290e6f8c61cb3029b2 Mon Sep 17 00:00:00 2001 From: sgiavasis Date: Thu, 18 Jul 2024 00:58:43 -0400 Subject: [PATCH 05/18] Set up I/O functions to be expandable, deferring some design decisions to the future. Started the beginning of some xfm_utils for quicker testing and validation. (cherry picked from commit 7ba96c3bb559556caab6c5b9fea423a4dc027068) --- nitransforms/io/base.py | 126 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/nitransforms/io/base.py b/nitransforms/io/base.py index 3c923426..78c28827 100644 --- a/nitransforms/io/base.py +++ b/nitransforms/io/base.py @@ -1,11 +1,137 @@ """Read/write linear transforms.""" from pathlib import Path import numpy as np +import nibabel as nb from nibabel import load as loadimg +import h5py + from ..patched import LabeledWrapStruct +def get_xfm_filetype(xfm_file): + path = Path(xfm_file) + ext = path.suffix + if ext == '.gz' and path.name.endswith('.nii.gz'): + return 'nifti' + + file_types = { + '.nii': 'nifti', + '.h5': 'hdf5', + '.x5': 'x5', + '.txt': 'txt', + '.mat': 'txt' + } + return file_types.get(ext, 'unknown') + +def gather_fields(x5=None, hdf5=None, nifti=None, shape=None, affine=None, header=None): + xfm_fields = { + "x5": x5, + "hdf5": hdf5, + "nifti": nifti, + "header": header, + "shape": shape, + "affine": affine + } + return xfm_fields + +def load_nifti(nifti_file): + nifti_xfm = nb.load(nifti_file) + xfm_data = nifti_xfm.get_fdata() + shape = nifti_xfm.shape + affine = nifti_xfm.affine + header = getattr(nifti_xfm, "header", None) + return gather_fields(nifti=xfm_data, shape=shape, affine=affine, header=header) + +def load_hdf5(hdf5_file): + storage = {} + + def get_hdf5_items(name, x5_root): + if isinstance(x5_root, h5py.Dataset): + storage[name] = { + 'type': 'dataset', + 'attrs': dict(x5_root.attrs), + 'shape': x5_root.shape, + 'data': x5_root[()] + } + elif isinstance(x5_root, h5py.Group): + storage[name] = { + 'type': 'group', + 'attrs': dict(x5_root.attrs), + 'members': {} + } + + with h5py.File(hdf5_file, 'r') as f: + f.visititems(get_hdf5_items) + if storage: + hdf5_storage = {'hdf5': storage} + return hdf5_storage + +def load_x5(x5_file): + load_hdf5(x5_file) + +def load_mat(mat_file): + affine_matrix = np.loadtxt(mat_file) + affine = nb.affines.from_matvec(affine_matrix[:,:3], affine_matrix[:,3]) + return gather_fields(affine=affine) + +def xfm_loader(xfm_file): + loaders = { + 'nifti': load_nifti, + 'hdf5': load_hdf5, + 'x5': load_x5, + 'txt': load_mat, + 'mat': load_mat + } + xfm_filetype = get_xfm_filetype(xfm_file) + loader = loaders.get(xfm_filetype) + if loader is None: + raise ValueError(f"Unsupported file type: {xfm_filetype}") + return loader(xfm_file) + +def to_filename(self, filename, fmt="X5"): + """Store the transform in BIDS-Transforms HDF5 file format (.x5).""" + with h5py.File(filename, "w") as out_file: + out_file.attrs["Format"] = "X5" + out_file.attrs["Version"] = np.uint16(1) + root = out_file.create_group("/0") + self._to_hdf5(root) + + return filename + +def _to_hdf5(self, x5_root): + """Serialize this object into the x5 file format.""" + transform_group = x5_root.create_group("TransformGroup") + + """Group '0' containing Affine transform""" + transform_0 = transform_group.create_group("0") + + transform_0.attrs["Type"] = "Affine" + transform_0.create_dataset("Transform", data=self._affine) + transform_0.create_dataset("Inverse", data=np.linalg.inv(self._affine)) + + metadata = {"key": "value"} + transform_0.attrs["Metadata"] = str(metadata) + + """sub-group 'Domain' contained within group '0' """ + domain_group = transform_0.create_group("Domain") + #domain_group.attrs["Grid"] = self._grid + #domain_group.create_dataset("Size", data=_as_homogeneous(self._reference.shape)) + #domain_group.create_dataset("Mapping", data=self.mapping) + +def _from_x5(self, x5_root): + variables = {} + + x5_root.visititems(lambda name, x5_root: loader(name, x5_root, variables)) + + _transform = variables["TransformGroup/0/Transform"] + _inverse = variables["TransformGroup/0/Inverse"] + _size = variables["TransformGroup/0/Domain/Size"] + _mapping = variables["TransformGroup/0/Domain/Mapping"] + + return _transform, _inverse, _size, _map + + class TransformIOError(IOError): """General I/O exception while reading/writing transforms.""" From 988346803873924f21f79b91d008cad0244d6a01 Mon Sep 17 00:00:00 2001 From: Julien Marabotto Date: Tue, 7 May 2024 12:19:30 +0200 Subject: [PATCH 06/18] X5.py created (cherry picked from commit 0e213cb259030a92af81eb54dc0bd792bed4abf2) --- nitransforms/io/x5.py | 52 +++++++++++++++++++++---------------------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/nitransforms/io/x5.py b/nitransforms/io/x5.py index 72b08a0f..b38ef9ab 100644 --- a/nitransforms/io/x5.py +++ b/nitransforms/io/x5.py @@ -13,10 +13,20 @@ TransformFileError, ) -class X5Transform: +LPS = np.diag([-1, -1, 1, 1]) + +class X5LinearTransform(LinearParameters): """A string-based structure for X5 linear transforms.""" - _transform = None + template_dtype = np.dtype( + [ + ("type", "i4"), + ("index", "i4"), + ("parameters", "f8", (4, 4)), + ("offset", "f4", 3), # Center of rotation + ] + ) + dtype = template_dtype def __init__(self, parameters=None, offset=None): return @@ -24,32 +34,20 @@ def __init__(self, parameters=None, offset=None): def __str__(self): return + def to_filename(self, filename): + """Store this transform to a file with the appropriate format.""" + sa = self.structarr + affine = np.array( + np.hstack( + (sa["parameters"][:3, :3].reshape(-1), sa["parameters"][:3, 3]) + )[..., np.newaxis], + dtype="f8", + ) + return + @classmethod def from_filename(cls, filename): """Read the struct from a X5 file given its path.""" if str(filename).endswith(".h5"): - with H5File(str(filename), 'r') as hdf: - return cls.from_h5obj(hdf) - - @classmethod - def from_h5obj(cls, h5obj): - """Read the transformations in an X5 file.""" - xfm_list = list(h5obj.keys()) - - xfm = xfm_list["Transform"] - inv = xfm_list["Inverse"] - coords = xfm_list["Size"] - map = xfm_list["Mapping"] - - return xfm, inv, coords, map - - -class X5LinearTransformArray(BaseLinearTransformList): - """A string-based structure for series of X5 linear transforms.""" - - _inner_type = X5Transform - - @property - def xforms(self): - """Get the list of internal X5LinearTransforms.""" - return self._xforms + with H5File(str(filename)) as f: + return cls.from_h5obj(f) From 5b93bcc2336b7d9e61df13152c6315c5af15c1f7 Mon Sep 17 00:00:00 2001 From: Julien Marabotto Date: Fri, 10 May 2024 09:32:23 +0200 Subject: [PATCH 07/18] enh: update implementation of X5 (cherry picked from commit 3d263bb36d74e21dc461987f4cf055af22f0dab8) --- nitransforms/io/x5.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/nitransforms/io/x5.py b/nitransforms/io/x5.py index b38ef9ab..11403c92 100644 --- a/nitransforms/io/x5.py +++ b/nitransforms/io/x5.py @@ -51,3 +51,19 @@ def from_filename(cls, filename): if str(filename).endswith(".h5"): with H5File(str(filename)) as f: return cls.from_h5obj(f) + +class X5LinearTransformArray(BaseLinearTransformList): + """A string-based structure for series of X5 linear transforms.""" + + _inner_type = X5LinearTransform + + @property + def xforms(self): + """Get the list of internal ITKLinearTransforms.""" + return self._xforms + + @xforms.setter + def xforms(self, value): + self._xforms = list(value) + for i, val in enumerate(self.xforms): + val["index"] = i \ No newline at end of file From b4b5af4b2baf095034a541b0f099f0935c1c43ff Mon Sep 17 00:00:00 2001 From: Julien Marabotto Date: Fri, 10 May 2024 14:49:03 +0200 Subject: [PATCH 08/18] enh: update implementation of X5 (cherry picked from commit a102f1e2bee031c55708dc6dc4e2598c94353a39) --- nitransforms/io/x5.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/nitransforms/io/x5.py b/nitransforms/io/x5.py index 11403c92..264df3d7 100644 --- a/nitransforms/io/x5.py +++ b/nitransforms/io/x5.py @@ -35,14 +35,9 @@ def __str__(self): return def to_filename(self, filename): - """Store this transform to a file with the appropriate format.""" + '''store this transform to a file with the X5 format''' sa = self.structarr - affine = np.array( - np.hstack( - (sa["parameters"][:3, :3].reshape(-1), sa["parameters"][:3, 3]) - )[..., np.newaxis], - dtype="f8", - ) + affine = '''some affine that will return a 4x4 array''' return @classmethod @@ -61,9 +56,3 @@ class X5LinearTransformArray(BaseLinearTransformList): def xforms(self): """Get the list of internal ITKLinearTransforms.""" return self._xforms - - @xforms.setter - def xforms(self, value): - self._xforms = list(value) - for i, val in enumerate(self.xforms): - val["index"] = i \ No newline at end of file From d34a6693408a4eaee8d70e72d77615db023d4e85 Mon Sep 17 00:00:00 2001 From: Julien Marabotto Date: Fri, 17 May 2024 16:17:51 +0200 Subject: [PATCH 09/18] enh: x5 implementation (cherry picked from commit e465332c8ec71b1a05aaa4cbb928b8a287586712) --- nitransforms/io/x5.py | 41 +++++++++++++++++++---------------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/nitransforms/io/x5.py b/nitransforms/io/x5.py index 264df3d7..72b08a0f 100644 --- a/nitransforms/io/x5.py +++ b/nitransforms/io/x5.py @@ -13,20 +13,10 @@ TransformFileError, ) -LPS = np.diag([-1, -1, 1, 1]) - -class X5LinearTransform(LinearParameters): +class X5Transform: """A string-based structure for X5 linear transforms.""" - template_dtype = np.dtype( - [ - ("type", "i4"), - ("index", "i4"), - ("parameters", "f8", (4, 4)), - ("offset", "f4", 3), # Center of rotation - ] - ) - dtype = template_dtype + _transform = None def __init__(self, parameters=None, offset=None): return @@ -34,25 +24,32 @@ def __init__(self, parameters=None, offset=None): def __str__(self): return - def to_filename(self, filename): - '''store this transform to a file with the X5 format''' - sa = self.structarr - affine = '''some affine that will return a 4x4 array''' - return - @classmethod def from_filename(cls, filename): """Read the struct from a X5 file given its path.""" if str(filename).endswith(".h5"): - with H5File(str(filename)) as f: - return cls.from_h5obj(f) + with H5File(str(filename), 'r') as hdf: + return cls.from_h5obj(hdf) + + @classmethod + def from_h5obj(cls, h5obj): + """Read the transformations in an X5 file.""" + xfm_list = list(h5obj.keys()) + + xfm = xfm_list["Transform"] + inv = xfm_list["Inverse"] + coords = xfm_list["Size"] + map = xfm_list["Mapping"] + + return xfm, inv, coords, map + class X5LinearTransformArray(BaseLinearTransformList): """A string-based structure for series of X5 linear transforms.""" - _inner_type = X5LinearTransform + _inner_type = X5Transform @property def xforms(self): - """Get the list of internal ITKLinearTransforms.""" + """Get the list of internal X5LinearTransforms.""" return self._xforms From b008d88c6e06d7fbf0d0ec26c6b3e8bffaa7aada Mon Sep 17 00:00:00 2001 From: Oscar Esteban Date: Thu, 17 Jul 2025 11:21:08 +0200 Subject: [PATCH 10/18] fix: formatting and undefined variables --- nitransforms/io/__init__.py | 3 +- nitransforms/io/base.py | 126 ------------------------------------ nitransforms/io/x5.py | 88 +++++++++++-------------- 3 files changed, 38 insertions(+), 179 deletions(-) diff --git a/nitransforms/io/__init__.py b/nitransforms/io/__init__.py index 3627fd34..857f92f6 100644 --- a/nitransforms/io/__init__.py +++ b/nitransforms/io/__init__.py @@ -1,6 +1,7 @@ # emacs: -*- mode: python-mode; py-indent-offset: 4; indent-tabs-mode: nil -*- # vi: set ft=python sts=4 ts=4 sw=4 et: """Read and write transforms.""" + from nitransforms.io import afni, fsl, itk, lta, x5 from nitransforms.io.base import TransformIOError, TransformFileError @@ -22,7 +23,7 @@ "fs": (lta, "FSLinearTransform"), "fsl": (fsl, "FSLLinearTransform"), "afni": (afni, "AFNILinearTransform"), - "x5": (x5, "X5Transform") + "x5": (x5, "X5LinearTransform"), } diff --git a/nitransforms/io/base.py b/nitransforms/io/base.py index 78c28827..3c923426 100644 --- a/nitransforms/io/base.py +++ b/nitransforms/io/base.py @@ -1,137 +1,11 @@ """Read/write linear transforms.""" from pathlib import Path import numpy as np -import nibabel as nb from nibabel import load as loadimg -import h5py - from ..patched import LabeledWrapStruct -def get_xfm_filetype(xfm_file): - path = Path(xfm_file) - ext = path.suffix - if ext == '.gz' and path.name.endswith('.nii.gz'): - return 'nifti' - - file_types = { - '.nii': 'nifti', - '.h5': 'hdf5', - '.x5': 'x5', - '.txt': 'txt', - '.mat': 'txt' - } - return file_types.get(ext, 'unknown') - -def gather_fields(x5=None, hdf5=None, nifti=None, shape=None, affine=None, header=None): - xfm_fields = { - "x5": x5, - "hdf5": hdf5, - "nifti": nifti, - "header": header, - "shape": shape, - "affine": affine - } - return xfm_fields - -def load_nifti(nifti_file): - nifti_xfm = nb.load(nifti_file) - xfm_data = nifti_xfm.get_fdata() - shape = nifti_xfm.shape - affine = nifti_xfm.affine - header = getattr(nifti_xfm, "header", None) - return gather_fields(nifti=xfm_data, shape=shape, affine=affine, header=header) - -def load_hdf5(hdf5_file): - storage = {} - - def get_hdf5_items(name, x5_root): - if isinstance(x5_root, h5py.Dataset): - storage[name] = { - 'type': 'dataset', - 'attrs': dict(x5_root.attrs), - 'shape': x5_root.shape, - 'data': x5_root[()] - } - elif isinstance(x5_root, h5py.Group): - storage[name] = { - 'type': 'group', - 'attrs': dict(x5_root.attrs), - 'members': {} - } - - with h5py.File(hdf5_file, 'r') as f: - f.visititems(get_hdf5_items) - if storage: - hdf5_storage = {'hdf5': storage} - return hdf5_storage - -def load_x5(x5_file): - load_hdf5(x5_file) - -def load_mat(mat_file): - affine_matrix = np.loadtxt(mat_file) - affine = nb.affines.from_matvec(affine_matrix[:,:3], affine_matrix[:,3]) - return gather_fields(affine=affine) - -def xfm_loader(xfm_file): - loaders = { - 'nifti': load_nifti, - 'hdf5': load_hdf5, - 'x5': load_x5, - 'txt': load_mat, - 'mat': load_mat - } - xfm_filetype = get_xfm_filetype(xfm_file) - loader = loaders.get(xfm_filetype) - if loader is None: - raise ValueError(f"Unsupported file type: {xfm_filetype}") - return loader(xfm_file) - -def to_filename(self, filename, fmt="X5"): - """Store the transform in BIDS-Transforms HDF5 file format (.x5).""" - with h5py.File(filename, "w") as out_file: - out_file.attrs["Format"] = "X5" - out_file.attrs["Version"] = np.uint16(1) - root = out_file.create_group("/0") - self._to_hdf5(root) - - return filename - -def _to_hdf5(self, x5_root): - """Serialize this object into the x5 file format.""" - transform_group = x5_root.create_group("TransformGroup") - - """Group '0' containing Affine transform""" - transform_0 = transform_group.create_group("0") - - transform_0.attrs["Type"] = "Affine" - transform_0.create_dataset("Transform", data=self._affine) - transform_0.create_dataset("Inverse", data=np.linalg.inv(self._affine)) - - metadata = {"key": "value"} - transform_0.attrs["Metadata"] = str(metadata) - - """sub-group 'Domain' contained within group '0' """ - domain_group = transform_0.create_group("Domain") - #domain_group.attrs["Grid"] = self._grid - #domain_group.create_dataset("Size", data=_as_homogeneous(self._reference.shape)) - #domain_group.create_dataset("Mapping", data=self.mapping) - -def _from_x5(self, x5_root): - variables = {} - - x5_root.visititems(lambda name, x5_root: loader(name, x5_root, variables)) - - _transform = variables["TransformGroup/0/Transform"] - _inverse = variables["TransformGroup/0/Inverse"] - _size = variables["TransformGroup/0/Domain/Size"] - _mapping = variables["TransformGroup/0/Domain/Mapping"] - - return _transform, _inverse, _size, _map - - class TransformIOError(IOError): """General I/O exception while reading/writing transforms.""" diff --git a/nitransforms/io/x5.py b/nitransforms/io/x5.py index 72b08a0f..419575f9 100644 --- a/nitransforms/io/x5.py +++ b/nitransforms/io/x5.py @@ -1,55 +1,39 @@ +# emacs: -*- mode: python-mode; py-indent-offset: 4; indent-tabs-mode: nil -*- +# vi: set ft=python sts=4 ts=4 sw=4 et: +### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ## +# +# See COPYING file distributed along with the NiBabel package for the +# copyright and license terms. +# +### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ## """Read/write x5 transforms.""" -import warnings + import numpy as np -from scipy.io import loadmat as _read_mat, savemat as _save_mat from h5py import File as H5File -from nibabel import Nifti1Header, Nifti1Image -from nibabel.affines import from_matvec -from nitransforms.io.base import ( - BaseLinearTransformList, - DisplacementsField, - LinearParameters, - TransformIOError, - TransformFileError, -) - -class X5Transform: - """A string-based structure for X5 linear transforms.""" - - _transform = None - - def __init__(self, parameters=None, offset=None): - return - - def __str__(self): - return - - @classmethod - def from_filename(cls, filename): - """Read the struct from a X5 file given its path.""" - if str(filename).endswith(".h5"): - with H5File(str(filename), 'r') as hdf: - return cls.from_h5obj(hdf) - - @classmethod - def from_h5obj(cls, h5obj): - """Read the transformations in an X5 file.""" - xfm_list = list(h5obj.keys()) - - xfm = xfm_list["Transform"] - inv = xfm_list["Inverse"] - coords = xfm_list["Size"] - map = xfm_list["Mapping"] - - return xfm, inv, coords, map - - -class X5LinearTransformArray(BaseLinearTransformList): - """A string-based structure for series of X5 linear transforms.""" - - _inner_type = X5Transform - - @property - def xforms(self): - """Get the list of internal X5LinearTransforms.""" - return self._xforms + + +def to_filename(fname, xfm, moving=None): + """Store the transform in BIDS-Transforms HDF5 file format (.x5).""" + with H5File(fname, "w") as out_file: + out_file.attrs["Format"] = "X5" + out_file.attrs["Version"] = np.uint16(1) + x5_root = out_file.create_group("/0") + + # Serialize this object into the x5 file format. + transform_group = x5_root.create_group("TransformGroup") + + # Group '0' containing Affine transform + transform_0 = transform_group.create_group("0") + + transform_0.attrs["Type"] = "Affine" + transform_0.create_dataset("Transform", data=xfm.matrix) + transform_0.create_dataset("Inverse", data=~xfm) + + metadata = {"key": "value"} + transform_0.attrs["Metadata"] = str(metadata) + + # sub-group 'Domain' contained within group '0' + transform_0.create_group("Domain") + # domain_group.attrs["Grid"] = self._grid + # domain_group.create_dataset("Size", data=_as_homogeneous(self._reference.shape)) + # domain_group.create_dataset("Mapping", data=self.mapping) From bcbadda336ab0f7c893c2dd4cf31babffd4cf645 Mon Sep 17 00:00:00 2001 From: Oscar Esteban Date: Thu, 17 Jul 2025 09:50:44 +0200 Subject: [PATCH 11/18] Add array_length support for X5 and adjust mapping --- nitransforms/io/__init__.py | 3 +- nitransforms/io/x5.py | 101 +++++++++++++++++++++--------- nitransforms/linear.py | 40 ++++++++++++ nitransforms/tests/test_linear.py | 36 ++++++++--- nitransforms/tests/test_x5.py | 40 ++++++++++++ 5 files changed, 181 insertions(+), 39 deletions(-) create mode 100644 nitransforms/tests/test_x5.py diff --git a/nitransforms/io/__init__.py b/nitransforms/io/__init__.py index 857f92f6..f9030724 100644 --- a/nitransforms/io/__init__.py +++ b/nitransforms/io/__init__.py @@ -1,7 +1,6 @@ # emacs: -*- mode: python-mode; py-indent-offset: 4; indent-tabs-mode: nil -*- # vi: set ft=python sts=4 ts=4 sw=4 et: """Read and write transforms.""" - from nitransforms.io import afni, fsl, itk, lta, x5 from nitransforms.io.base import TransformIOError, TransformFileError @@ -10,6 +9,7 @@ "fsl", "itk", "lta", + "x5", "get_linear_factory", "TransformFileError", "TransformIOError", @@ -23,7 +23,6 @@ "fs": (lta, "FSLinearTransform"), "fsl": (fsl, "FSLLinearTransform"), "afni": (afni, "AFNILinearTransform"), - "x5": (x5, "X5LinearTransform"), } diff --git a/nitransforms/io/x5.py b/nitransforms/io/x5.py index 419575f9..f78eccb4 100644 --- a/nitransforms/io/x5.py +++ b/nitransforms/io/x5.py @@ -1,39 +1,80 @@ -# emacs: -*- mode: python-mode; py-indent-offset: 4; indent-tabs-mode: nil -*- -# vi: set ft=python sts=4 ts=4 sw=4 et: -### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ## -# -# See COPYING file distributed along with the NiBabel package for the -# copyright and license terms. -# -### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ## -"""Read/write x5 transforms.""" +"""Data structures for the X5 transform format.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, Optional, Sequence, List + +import json +import h5py import numpy as np -from h5py import File as H5File -def to_filename(fname, xfm, moving=None): - """Store the transform in BIDS-Transforms HDF5 file format (.x5).""" - with H5File(fname, "w") as out_file: - out_file.attrs["Format"] = "X5" - out_file.attrs["Version"] = np.uint16(1) - x5_root = out_file.create_group("/0") +@dataclass +class X5Domain: + """Domain information of a transform.""" - # Serialize this object into the x5 file format. - transform_group = x5_root.create_group("TransformGroup") + grid: bool + size: Sequence[int] + mapping: np.ndarray + coordinates: Optional[str] = None - # Group '0' containing Affine transform - transform_0 = transform_group.create_group("0") - transform_0.attrs["Type"] = "Affine" - transform_0.create_dataset("Transform", data=xfm.matrix) - transform_0.create_dataset("Inverse", data=~xfm) +@dataclass +class X5Transform: + """Represent one transform entry of an X5 file.""" - metadata = {"key": "value"} - transform_0.attrs["Metadata"] = str(metadata) + type: str + transform: np.ndarray + dimension_kinds: Sequence[str] + array_length: int = 1 + domain: Optional[X5Domain] = None + subtype: Optional[str] = None + representation: Optional[str] = None + metadata: Optional[Dict[str, Any]] = None + inverse: Optional[np.ndarray] = None + jacobian: Optional[np.ndarray] = None + additional_parameters: Optional[np.ndarray] = None + + +def to_filename(fname: str, x5_list: List[X5Transform]): + """Write a list of :class:`X5Transform` objects to an X5 HDF5 file.""" + with h5py.File(str(fname), "w") as out_file: + out_file.attrs["Format"] = "X5" + out_file.attrs["Version"] = np.uint16(1) + tg = out_file.create_group("TransformGroup") + for i, node in enumerate(x5_list): + g = tg.create_group(str(i)) + g.attrs["Type"] = node.type + g.attrs["ArrayLength"] = node.array_length + if node.subtype is not None: + g.attrs["SubType"] = node.subtype + if node.representation is not None: + g.attrs["Representation"] = node.representation + if node.metadata is not None: + g.attrs["Metadata"] = json.dumps(node.metadata) + g.create_dataset("Transform", data=node.transform) + g.create_dataset( + "DimensionKinds", + data=np.asarray(node.dimension_kinds, dtype="S"), + ) + if node.domain is not None: + dgrp = g.create_group("Domain") + dgrp.create_dataset( + "Grid", data=np.uint8(1 if node.domain.grid else 0) + ) + dgrp.create_dataset("Size", data=np.asarray(node.domain.size)) + dgrp.create_dataset("Mapping", data=node.domain.mapping) + if node.domain.coordinates is not None: + dgrp.attrs["Coordinates"] = node.domain.coordinates - # sub-group 'Domain' contained within group '0' - transform_0.create_group("Domain") - # domain_group.attrs["Grid"] = self._grid - # domain_group.create_dataset("Size", data=_as_homogeneous(self._reference.shape)) - # domain_group.create_dataset("Mapping", data=self.mapping) + if node.inverse is not None: + g.create_dataset("Inverse", data=node.inverse) + if node.jacobian is not None: + g.create_dataset("Jacobian", data=node.jacobian) + if node.additional_parameters is not None: + g.create_dataset( + "AdditionalParameters", data=node.additional_parameters + ) + return str(fname) diff --git a/nitransforms/linear.py b/nitransforms/linear.py index 71df6a16..8e2bf38b 100644 --- a/nitransforms/linear.py +++ b/nitransforms/linear.py @@ -20,6 +20,7 @@ EQUALITY_TOL, ) from nitransforms.io import get_linear_factory, TransformFileError +from nitransforms.io.x5 import X5Transform, X5Domain class Affine(TransformBase): @@ -276,6 +277,25 @@ def __repr__(self): """ return repr(self.matrix) + def to_x5(self): + """Return an :class:`~nitransforms.io.x5.X5Transform` representation.""" + domain = None + if self._reference is not None: + domain = X5Domain( + grid=True, + size=self.reference.shape, + mapping=self.reference.affine, + ) + kinds = tuple("space" for _ in range(self.ndim)) + ("vector",) + return X5Transform( + type="linear", + subtype="affine", + transform=self.matrix, + dimension_kinds=kinds, + domain=domain, + inverse=(~self).matrix, + ) + class LinearTransformsMapping(Affine): """Represents a series of linear transforms.""" @@ -330,6 +350,26 @@ def __getitem__(self, i): """Enable indexed access to the series of matrices.""" return Affine(self.matrix[i, ...], reference=self._reference) + def to_x5(self): + """Return an :class:`~nitransforms.io.x5.X5Transform` object.""" + domain = None + if self._reference is not None: + domain = X5Domain( + grid=True, + size=self.reference.shape, + mapping=self.reference.affine, + ) + kinds = tuple("space" for _ in range(self.ndim - 1)) + ("vector",) + return X5Transform( + type="linear", + subtype="affine", + transform=self.matrix, + dimension_kinds=kinds, + domain=domain, + inverse=self._inverse, + array_length=len(self), + ) + def map(self, x, inverse=False): r""" Apply :math:`y = f(x)`. diff --git a/nitransforms/tests/test_linear.py b/nitransforms/tests/test_linear.py index 31627159..0276878f 100644 --- a/nitransforms/tests/test_linear.py +++ b/nitransforms/tests/test_linear.py @@ -6,8 +6,11 @@ import numpy as np import h5py +from pathlib import Path + from nibabel.eulerangles import euler2mat from nibabel.affines import from_matvec +import nibabel as nb from nitransforms import linear as nitl from nitransforms import io from .utils import assert_affines_by_filename @@ -243,16 +246,35 @@ def test_linear_save(tmpdir, data_path, get_testdata, image_orientation, sw_tool assert_affines_by_filename(xfm_fname1, xfm_fname2) -def test_Affine_to_x5(tmpdir, testdata_path): +def test_Affine_to_x5(tmpdir): """Test affine's operations.""" tmpdir.chdir() aff = nitl.Affine() - with h5py.File("xfm.x5", "w") as f: - aff._to_hdf5(f.create_group("Affine")) - - aff.reference = testdata_path / "someones_anatomy.nii.gz" - with h5py.File("withref-xfm.x5", "w") as f: - aff._to_hdf5(f.create_group("Affine")) + node = aff.to_x5() + assert node.type == "linear" + assert node.domain is None + assert node.transform.shape == (4, 4) + assert node.array_length == 1 + + img = nb.Nifti1Image(np.zeros((2, 2, 2), dtype="float32"), np.eye(4)) + img_path = Path(tmpdir) / "ref.nii.gz" + img.to_filename(str(img_path)) + + aff.reference = img_path + node = aff.to_x5() + assert node.domain.grid + assert node.domain.size == aff.reference.shape + + +def test_mapping_to_x5(): + mats = [ + np.eye(4), + np.array([[1, 0, 0, 1], [0, 1, 0, 2], [0, 0, 1, 3], [0, 0, 0, 1]]), + ] + mapping = nitl.LinearTransformsMapping(mats) + node = mapping.to_x5() + assert node.array_length == 2 + assert node.transform.shape == (2, 4, 4) def test_mulmat_operator(testdata_path): diff --git a/nitransforms/tests/test_x5.py b/nitransforms/tests/test_x5.py new file mode 100644 index 00000000..3f2b0a63 --- /dev/null +++ b/nitransforms/tests/test_x5.py @@ -0,0 +1,40 @@ +import numpy as np +from h5py import File as H5File + +from ..io.x5 import X5Transform, X5Domain, to_filename + + +def test_x5_transform_defaults(): + xf = X5Transform( + type="linear", + transform=np.eye(4), + dimension_kinds=("space", "space", "space", "vector"), + ) + assert xf.domain is None + assert xf.subtype is None + assert xf.representation is None + assert xf.metadata is None + assert xf.inverse is None + assert xf.jacobian is None + assert xf.additional_parameters is None + assert xf.array_length == 1 + + +def test_to_filename(tmp_path): + domain = X5Domain(grid=True, size=(10, 10, 10), mapping=np.eye(4)) + node = X5Transform( + type="linear", + transform=np.eye(4), + dimension_kinds=("space", "space", "space", "vector"), + domain=domain, + ) + fname = tmp_path / "test.x5" + to_filename(fname, [node]) + + with H5File(fname, "r") as f: + assert f.attrs["Format"] == "X5" + assert f.attrs["Version"] == 1 + grp = f["TransformGroup"] + assert "0" in grp + assert grp["0"].attrs["Type"] == "linear" + assert grp["0"].attrs["ArrayLength"] == 1 From c34e0dbb4c67d993c9e0e2b1ca5bf3bd0f6779a1 Mon Sep 17 00:00:00 2001 From: Oscar Esteban Date: Thu, 17 Jul 2025 11:58:25 +0200 Subject: [PATCH 12/18] enh: manual revision --- nitransforms/io/x5.py | 6 +- nitransforms/linear.py | 164 +++++++++++++----------------- nitransforms/tests/test_linear.py | 10 +- 3 files changed, 80 insertions(+), 100 deletions(-) diff --git a/nitransforms/io/x5.py b/nitransforms/io/x5.py index f78eccb4..2af11d86 100644 --- a/nitransforms/io/x5.py +++ b/nitransforms/io/x5.py @@ -61,9 +61,7 @@ def to_filename(fname: str, x5_list: List[X5Transform]): ) if node.domain is not None: dgrp = g.create_group("Domain") - dgrp.create_dataset( - "Grid", data=np.uint8(1 if node.domain.grid else 0) - ) + dgrp.create_dataset("Grid", data=np.uint8(1 if node.domain.grid else 0)) dgrp.create_dataset("Size", data=np.asarray(node.domain.size)) dgrp.create_dataset("Mapping", data=node.domain.mapping) if node.domain.coordinates is not None: @@ -77,4 +75,4 @@ def to_filename(fname: str, x5_list: List[X5Transform]): g.create_dataset( "AdditionalParameters", data=node.additional_parameters ) - return str(fname) + return fname diff --git a/nitransforms/linear.py b/nitransforms/linear.py index 8e2bf38b..6b144abc 100644 --- a/nitransforms/linear.py +++ b/nitransforms/linear.py @@ -7,12 +7,14 @@ # ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ## """Linear transforms.""" + import warnings import numpy as np from pathlib import Path from nibabel.affines import from_matvec +from nitransforms import __version__ from nitransforms.base import ( ImageGrid, TransformBase, @@ -80,6 +82,23 @@ def __init__(self, matrix=None, reference=None): self._matrix[3, :] = (0, 0, 0, 1) self._inverse = np.linalg.inv(self._matrix) + def __repr__(self): + """ + Change representation to the internal matrix. + + Example + ------- + >>> Affine([ + ... [1, 0, 0, 4], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1] + ... ]) # doctest: +NORMALIZE_WHITESPACE + array([[1, 0, 0, 4], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1]]) + + """ + return repr(self.matrix) + def __eq__(self, other): """ Overload equals operator. @@ -149,62 +168,6 @@ def ndim(self): """Access the internal representation of this affine.""" return self._matrix.ndim + 1 - def map(self, x, inverse=False): - r""" - Apply :math:`y = f(x)`. - - Parameters - ---------- - x : N x D numpy.ndarray - Input RAS+ coordinates (i.e., physical coordinates). - inverse : bool - If ``True``, apply the inverse transform :math:`x = f^{-1}(y)`. - - Returns - ------- - y : N x D numpy.ndarray - Transformed (mapped) RAS+ coordinates (i.e., physical coordinates). - - Examples - -------- - >>> xfm = Affine([[1, 0, 0, 1], [0, 1, 0, 2], [0, 0, 1, 3], [0, 0, 0, 1]]) - >>> xfm.map((0,0,0)) - array([[1., 2., 3.]]) - - >>> xfm.map((0,0,0), inverse=True) - array([[-1., -2., -3.]]) - - """ - affine = self._matrix - coords = _as_homogeneous(x, dim=affine.shape[0] - 1).T - if inverse is True: - affine = self._inverse - return affine.dot(coords).T[..., :-1] - - def _to_hdf5(self, x5_root): - """Serialize this object into the x5 file format.""" - xform = x5_root.create_dataset("Transform", data=[self._matrix]) - xform.attrs["Type"] = "affine" - x5_root.create_dataset("Inverse", data=[(~self).matrix]) - - if self._reference: - self.reference._to_hdf5(x5_root.create_group("Reference")) - - def to_filename(self, filename, fmt="X5", moving=None): - """Store the transform in the requested output format.""" - writer = get_linear_factory(fmt, is_array=False) - - if fmt.lower() in ("itk", "ants", "elastix"): - writer.from_ras(self.matrix).to_filename(filename) - else: - # Rest of the formats peek into moving and reference image grids - writer.from_ras( - self.matrix, - reference=self.reference, - moving=ImageGrid(moving) if moving is not None else self.reference, - ).to_filename(filename) - return filename - @classmethod def from_filename(cls, filename, fmt=None, reference=None, moving=None): """Create an affine from a transform file.""" @@ -260,40 +223,75 @@ def from_matvec(cls, mat=None, vec=None, reference=None): vec = vec if vec is not None else np.zeros((3,)) return cls(from_matvec(mat, vector=vec), reference=reference) - def __repr__(self): - """ - Change representation to the internal matrix. + def map(self, x, inverse=False): + r""" + Apply :math:`y = f(x)`. - Example + Parameters + ---------- + x : N x D numpy.ndarray + Input RAS+ coordinates (i.e., physical coordinates). + inverse : bool + If ``True``, apply the inverse transform :math:`x = f^{-1}(y)`. + + Returns ------- - >>> Affine([ - ... [1, 0, 0, 4], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1] - ... ]) # doctest: +NORMALIZE_WHITESPACE - array([[1, 0, 0, 4], - [0, 1, 0, 0], - [0, 0, 1, 0], - [0, 0, 0, 1]]) + y : N x D numpy.ndarray + Transformed (mapped) RAS+ coordinates (i.e., physical coordinates). + + Examples + -------- + >>> xfm = Affine([[1, 0, 0, 1], [0, 1, 0, 2], [0, 0, 1, 3], [0, 0, 0, 1]]) + >>> xfm.map((0,0,0)) + array([[1., 2., 3.]]) + + >>> xfm.map((0,0,0), inverse=True) + array([[-1., -2., -3.]]) """ - return repr(self.matrix) + affine = self._matrix + coords = _as_homogeneous(x, dim=affine.shape[0] - 1).T + if inverse is True: + affine = self._inverse + return affine.dot(coords).T[..., :-1] + + def to_filename(self, filename, fmt="X5", moving=None): + """Store the transform in the requested output format.""" + writer = get_linear_factory(fmt, is_array=False) + + if fmt.lower() in ("itk", "ants", "elastix"): + writer.from_ras(self.matrix).to_filename(filename) + else: + # Rest of the formats peek into moving and reference image grids + writer.from_ras( + self.matrix, + reference=self.reference, + moving=ImageGrid(moving) if moving is not None else self.reference, + ).to_filename(filename) + return filename - def to_x5(self): + def to_x5(self, store_inverse=False, metadata=None): """Return an :class:`~nitransforms.io.x5.X5Transform` representation.""" + metadata = {"WrittenBy": f"NiTransforms {__version__}"} | (metadata or {}) + domain = None - if self._reference is not None: + if (reference := self.reference) is not None: domain = X5Domain( grid=True, - size=self.reference.shape, - mapping=self.reference.affine, + size=getattr(reference or {}, "shape", (0, 0, 0)), + mapping=reference.affine, ) kinds = tuple("space" for _ in range(self.ndim)) + ("vector",) return X5Transform( type="linear", subtype="affine", + representation="matrix", + metadata=metadata, transform=self.matrix, dimension_kinds=kinds, domain=domain, - inverse=(~self).matrix, + inverse=(~self).matrix if store_inverse else None, + array_length=len(self), ) @@ -350,26 +348,6 @@ def __getitem__(self, i): """Enable indexed access to the series of matrices.""" return Affine(self.matrix[i, ...], reference=self._reference) - def to_x5(self): - """Return an :class:`~nitransforms.io.x5.X5Transform` object.""" - domain = None - if self._reference is not None: - domain = X5Domain( - grid=True, - size=self.reference.shape, - mapping=self.reference.affine, - ) - kinds = tuple("space" for _ in range(self.ndim - 1)) + ("vector",) - return X5Transform( - type="linear", - subtype="affine", - transform=self.matrix, - dimension_kinds=kinds, - domain=domain, - inverse=self._inverse, - array_length=len(self), - ) - def map(self, x, inverse=False): r""" Apply :math:`y = f(x)`. diff --git a/nitransforms/tests/test_linear.py b/nitransforms/tests/test_linear.py index 0276878f..0ac23904 100644 --- a/nitransforms/tests/test_linear.py +++ b/nitransforms/tests/test_linear.py @@ -4,7 +4,6 @@ import pytest import numpy as np -import h5py from pathlib import Path @@ -246,15 +245,20 @@ def test_linear_save(tmpdir, data_path, get_testdata, image_orientation, sw_tool assert_affines_by_filename(xfm_fname1, xfm_fname2) -def test_Affine_to_x5(tmpdir): +@pytest.mark.parametrize("store_inverse", [True, False]) +def test_Affine_to_x5(tmpdir, store_inverse): """Test affine's operations.""" tmpdir.chdir() aff = nitl.Affine() - node = aff.to_x5() + node = aff.to_x5( + metadata={"GeneratedBy": "FreeSurfer 8"}, store_inverse=store_inverse + ) assert node.type == "linear" + assert node.subtype == "matrix" assert node.domain is None assert node.transform.shape == (4, 4) assert node.array_length == 1 + assert (node.metadata or {}).get("GeneratedBy") == "FreeSurfer 8" img = nb.Nifti1Image(np.zeros((2, 2, 2), dtype="float32"), np.eye(4)) img_path = Path(tmpdir) / "ref.nii.gz" From d3885916ef9991261b5850dcf0bf9eb8bc8d13d0 Mon Sep 17 00:00:00 2001 From: Oscar Esteban Date: Thu, 17 Jul 2025 12:22:52 +0200 Subject: [PATCH 13/18] fix: avoid circular imports --- nitransforms/linear.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/nitransforms/linear.py b/nitransforms/linear.py index 6b144abc..10a278cc 100644 --- a/nitransforms/linear.py +++ b/nitransforms/linear.py @@ -14,7 +14,12 @@ from nibabel.affines import from_matvec -from nitransforms import __version__ +# Avoids circular imports +try: + from nitransforms._version import __version__ +except ModuleNotFoundError: + __version__ = "0+unknown" + from nitransforms.base import ( ImageGrid, TransformBase, From 740a0c4a756723ebbef0cc272ff6c600bda20993 Mon Sep 17 00:00:00 2001 From: Oscar Esteban Date: Thu, 17 Jul 2025 12:28:44 +0200 Subject: [PATCH 14/18] fix: error in test --- nitransforms/tests/test_linear.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nitransforms/tests/test_linear.py b/nitransforms/tests/test_linear.py index 0ac23904..19d7aaa4 100644 --- a/nitransforms/tests/test_linear.py +++ b/nitransforms/tests/test_linear.py @@ -254,7 +254,8 @@ def test_Affine_to_x5(tmpdir, store_inverse): metadata={"GeneratedBy": "FreeSurfer 8"}, store_inverse=store_inverse ) assert node.type == "linear" - assert node.subtype == "matrix" + assert node.subtype == "affine" + assert node.representation == "matrix" assert node.domain is None assert node.transform.shape == (4, 4) assert node.array_length == 1 From 1155337ac2304c6b90165770d36c4a75ef8aa535 Mon Sep 17 00:00:00 2001 From: Oscar Esteban Date: Thu, 17 Jul 2025 13:53:18 +0200 Subject: [PATCH 15/18] tst: exercise ``nitransforms.io.x5.to_filename()` --- nitransforms/linear.py | 1 + nitransforms/tests/test_linear.py | 13 +++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/nitransforms/linear.py b/nitransforms/linear.py index 10a278cc..5a04f3c9 100644 --- a/nitransforms/linear.py +++ b/nitransforms/linear.py @@ -285,6 +285,7 @@ def to_x5(self, store_inverse=False, metadata=None): grid=True, size=getattr(reference or {}, "shape", (0, 0, 0)), mapping=reference.affine, + coordinates="cartesian", ) kinds = tuple("space" for _ in range(self.ndim)) + ("vector",) return X5Transform( diff --git a/nitransforms/tests/test_linear.py b/nitransforms/tests/test_linear.py index 19d7aaa4..5567e9bb 100644 --- a/nitransforms/tests/test_linear.py +++ b/nitransforms/tests/test_linear.py @@ -246,9 +246,11 @@ def test_linear_save(tmpdir, data_path, get_testdata, image_orientation, sw_tool @pytest.mark.parametrize("store_inverse", [True, False]) -def test_Affine_to_x5(tmpdir, store_inverse): +def test_linear_to_x5(tmpdir, store_inverse): """Test affine's operations.""" tmpdir.chdir() + + # Test base operations aff = nitl.Affine() node = aff.to_x5( metadata={"GeneratedBy": "FreeSurfer 8"}, store_inverse=store_inverse @@ -261,14 +263,21 @@ def test_Affine_to_x5(tmpdir, store_inverse): assert node.array_length == 1 assert (node.metadata or {}).get("GeneratedBy") == "FreeSurfer 8" + io.x5.to_filename("export1.x5", [node]) + + # Test with Domain img = nb.Nifti1Image(np.zeros((2, 2, 2), dtype="float32"), np.eye(4)) img_path = Path(tmpdir) / "ref.nii.gz" img.to_filename(str(img_path)) - aff.reference = img_path node = aff.to_x5() assert node.domain.grid assert node.domain.size == aff.reference.shape + io.x5.to_filename("export2.x5", [node]) + + # Test with Jacobian + node.jacobian = np.zeros((2, 2, 2), dtype="float32") + io.x5.to_filename("export3.x5", [node]) def test_mapping_to_x5(): From a3979a2fd8c485625c84a8a9335a214bd21f6956 Mon Sep 17 00:00:00 2001 From: Oscar Esteban Date: Thu, 17 Jul 2025 14:23:03 +0200 Subject: [PATCH 16/18] misc: add nibabel header & exclude line from coverage report --- nitransforms/linear.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nitransforms/linear.py b/nitransforms/linear.py index 5a04f3c9..fe4f0421 100644 --- a/nitransforms/linear.py +++ b/nitransforms/linear.py @@ -17,7 +17,7 @@ # Avoids circular imports try: from nitransforms._version import __version__ -except ModuleNotFoundError: +except ModuleNotFoundError: # pragma: no cover __version__ = "0+unknown" from nitransforms.base import ( From 6f707fcfea46e72ce9bc723bcfc5fc059079c73f Mon Sep 17 00:00:00 2001 From: Oscar Esteban Date: Thu, 17 Jul 2025 14:57:22 +0200 Subject: [PATCH 17/18] doc: finalize documenting new object Resolves: #111. Co-authored-by: Julien Marabotto <166002186+jmarabotto@users.noreply.github.com> Co-authored-by: sgiavasis --- docs/_api/io.rst | 18 +++++--- nitransforms/io/x5.py | 86 +++++++++++++++++++++++++++++------ nitransforms/tests/test_x5.py | 3 +- 3 files changed, 87 insertions(+), 20 deletions(-) diff --git a/docs/_api/io.rst b/docs/_api/io.rst index 7666253b..1a3b6865 100644 --- a/docs/_api/io.rst +++ b/docs/_api/io.rst @@ -20,6 +20,18 @@ AFNI .. automodule:: nitransforms.io.afni :members: +^^^^^^^^ +BIDS' X5 +^^^^^^^^ +.. automodule:: nitransforms.io.x5 + :members: + +^^^^^^^^^^^^^^ +FreeSurfer/LTA +^^^^^^^^^^^^^^ +.. automodule:: nitransforms.io.lta + :members: + ^^^ FSL ^^^ @@ -31,9 +43,3 @@ ITK ^^^ .. automodule:: nitransforms.io.itk :members: - -^^^^^^^^^^^^^^ -FreeSurfer/LTA -^^^^^^^^^^^^^^ -.. automodule:: nitransforms.io.lta - :members: diff --git a/nitransforms/io/x5.py b/nitransforms/io/x5.py index 2af11d86..f4c14113 100644 --- a/nitransforms/io/x5.py +++ b/nitransforms/io/x5.py @@ -1,8 +1,23 @@ -"""Data structures for the X5 transform format.""" +# emacs: -*- mode: python-mode; py-indent-offset: 4; indent-tabs-mode: nil -*- +# vi: set ft=python sts=4 ts=4 sw=4 et: +### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ## +# +# See COPYING file distributed along with the NiBabel package for the +# copyright and license terms. +# +### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ## +""" +Data structures for the X5 transform format. + +Implements what's drafted in the `BIDS X5 specification draft +`__. + +""" from __future__ import annotations from dataclasses import dataclass +from pathlib import Path from typing import Any, Dict, Optional, Sequence, List import json @@ -13,12 +28,16 @@ @dataclass class X5Domain: - """Domain information of a transform.""" + """Domain information of a transform representing reference/moving spaces.""" grid: bool + """Whether sampling locations in the manifold are located regularly.""" size: Sequence[int] - mapping: np.ndarray + """The number of sampling locations per dimension (or total if not a grid).""" + mapping: Optional[np.ndarray] + """A mapping to go from samples (pixel/voxel coordinates, indices) to space coordinates.""" coordinates: Optional[str] = None + """Indexing type of the Mapping field (for example, "cartesian", "spherical" or "index").""" @dataclass @@ -26,20 +45,60 @@ class X5Transform: """Represent one transform entry of an X5 file.""" type: str + """A REQUIRED unicode string with possible values: "linear", "nonlinear", "composite".""" transform: np.ndarray - dimension_kinds: Sequence[str] - array_length: int = 1 - domain: Optional[X5Domain] = None + """A REQUIRED array of parameters (e.g., affine matrix, or dense displacements field).""" subtype: Optional[str] = None + """An OPTIONAL extension of type to drive the interpretation of AdditionalParameters.""" representation: Optional[str] = None + """ + A string specifiying the transform representation or model, REQUIRED only for nonlinear Type. + """ metadata: Optional[Dict[str, Any]] = None + """An OPTIONAL string (JSON) to embed metadata.""" + dimension_kinds: Optional[Sequence[str]] = None + """Identifies what "kind" of information is represented by the samples along each axis.""" + domain: Optional[X5Domain] = None + """ + A dataset specifying the reference manifold for the transform (either + a regularly gridded 3D space or a surface/sphere). + REQUIRED for nonlinear Type, RECOMMENDED for linear Type. + """ inverse: Optional[np.ndarray] = None + """Placeholder to pre-calculated inverses.""" jacobian: Optional[np.ndarray] = None - additional_parameters: Optional[np.ndarray] = None + """ + A RECOMMENDED data array to keep cached the determinant of Jacobian of the transform + in case tools have calculated it. + For parametric models it is generally possible to obtain it analytically, so this dataset + could not be as useful in that case. + """ + # additional_parameters: Optional[np.ndarray] = None + # AdditionalParameters is empty in the draft spec - ignore for now. + # Only documentation ATM is for SubType: + # The SubType setting enables setting the additional parameters on a dataset called + # "AdditionalParameters" that hangs directly from this transform node. + array_length: int = 1 + """Undocumented field in the draft to enable a single transform group for 4D transforms.""" + + +def to_filename(fname: str | Path, x5_list: List[X5Transform]): + """ + Write a list of :class:`X5Transform` objects to an X5 HDF5 file. + + Parameters + ---------- + fname : :obj:`os.pathlike` + The file name (preferably with the ".x5" extension) in which transforms will be stored. + x5_list : :obj:`list` + The list of transforms to be stored in the output dataset. + Returns + ------- + fname : :obj:`os.pathlike` + File containing the transform(s). -def to_filename(fname: str, x5_list: List[X5Transform]): - """Write a list of :class:`X5Transform` objects to an X5 HDF5 file.""" + """ with h5py.File(str(fname), "w") as out_file: out_file.attrs["Format"] = "X5" out_file.attrs["Version"] = np.uint16(1) @@ -71,8 +130,9 @@ def to_filename(fname: str, x5_list: List[X5Transform]): g.create_dataset("Inverse", data=node.inverse) if node.jacobian is not None: g.create_dataset("Jacobian", data=node.jacobian) - if node.additional_parameters is not None: - g.create_dataset( - "AdditionalParameters", data=node.additional_parameters - ) + # Disabled until we need SubType and AdditionalParameters + # if node.additional_parameters is not None: + # g.create_dataset( + # "AdditionalParameters", data=node.additional_parameters + # ) return fname diff --git a/nitransforms/tests/test_x5.py b/nitransforms/tests/test_x5.py index 3f2b0a63..8502a387 100644 --- a/nitransforms/tests/test_x5.py +++ b/nitransforms/tests/test_x5.py @@ -16,8 +16,9 @@ def test_x5_transform_defaults(): assert xf.metadata is None assert xf.inverse is None assert xf.jacobian is None - assert xf.additional_parameters is None assert xf.array_length == 1 + # Disabled for now + # assert xf.additional_parameters is None def test_to_filename(tmp_path): From ce1083c773264746b36831a41031efb7fb61c174 Mon Sep 17 00:00:00 2001 From: Oscar Esteban Date: Thu, 17 Jul 2025 15:19:37 +0200 Subject: [PATCH 18/18] enh: finalize storing affines in X5 Co-authored-by: Julien Marabotto <166002186+jmarabotto@users.noreply.github.com> Co-authored-by: sgiavasis --- nitransforms/linear.py | 24 ++++++------------------ nitransforms/tests/test_linear.py | 4 ++-- 2 files changed, 8 insertions(+), 20 deletions(-) diff --git a/nitransforms/linear.py b/nitransforms/linear.py index fe4f0421..cf8f8465 100644 --- a/nitransforms/linear.py +++ b/nitransforms/linear.py @@ -27,7 +27,7 @@ EQUALITY_TOL, ) from nitransforms.io import get_linear_factory, TransformFileError -from nitransforms.io.x5 import X5Transform, X5Domain +from nitransforms.io.x5 import X5Transform, X5Domain, to_filename as save_x5 class Affine(TransformBase): @@ -260,9 +260,12 @@ def map(self, x, inverse=False): affine = self._inverse return affine.dot(coords).T[..., :-1] - def to_filename(self, filename, fmt="X5", moving=None): + def to_filename(self, filename, fmt="X5", moving=None, x5_inverse=False): """Store the transform in the requested output format.""" - writer = get_linear_factory(fmt, is_array=False) + if fmt.upper() == "X5": + return save_x5(filename, [self.to_x5(store_inverse=x5_inverse)]) + + writer = get_linear_factory(fmt, is_array=isinstance(self, LinearTransformsMapping)) if fmt.lower() in ("itk", "ants", "elastix"): writer.from_ras(self.matrix).to_filename(filename) @@ -407,21 +410,6 @@ def map(self, x, inverse=False): affine = self._inverse return np.swapaxes(affine.dot(coords), 1, 2) - def to_filename(self, filename, fmt="X5", moving=None): - """Store the transform in the requested output format.""" - writer = get_linear_factory(fmt, is_array=True) - - if fmt.lower() in ("itk", "ants", "elastix"): - writer.from_ras(self.matrix).to_filename(filename) - else: - # Rest of the formats peek into moving and reference image grids - writer.from_ras( - self.matrix, - reference=self.reference, - moving=ImageGrid(moving) if moving is not None else self.reference, - ).to_filename(filename) - return filename - def load(filename, fmt=None, reference=None, moving=None): """ diff --git a/nitransforms/tests/test_linear.py b/nitransforms/tests/test_linear.py index 5567e9bb..32634c61 100644 --- a/nitransforms/tests/test_linear.py +++ b/nitransforms/tests/test_linear.py @@ -263,7 +263,7 @@ def test_linear_to_x5(tmpdir, store_inverse): assert node.array_length == 1 assert (node.metadata or {}).get("GeneratedBy") == "FreeSurfer 8" - io.x5.to_filename("export1.x5", [node]) + aff.to_filename("export1.x5", x5_inverse=store_inverse) # Test with Domain img = nb.Nifti1Image(np.zeros((2, 2, 2), dtype="float32"), np.eye(4)) @@ -273,7 +273,7 @@ def test_linear_to_x5(tmpdir, store_inverse): node = aff.to_x5() assert node.domain.grid assert node.domain.size == aff.reference.shape - io.x5.to_filename("export2.x5", [node]) + aff.to_filename("export2.x5", x5_inverse=store_inverse) # Test with Jacobian node.jacobian = np.zeros((2, 2, 2), dtype="float32")