Skip to content

Commit 2e73ee9

Browse files
authored
Merge pull request #241 from nipy/codex/create-x5transform-dataclass-in-nitransforms.io.x5
ENH: Implement X5 representation and output to filesystem
2 parents eff0b71 + ce1083c commit 2e73ee9

File tree

6 files changed

+332
-98
lines changed

6 files changed

+332
-98
lines changed

docs/_api/io.rst

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,18 @@ AFNI
2020
.. automodule:: nitransforms.io.afni
2121
:members:
2222

23+
^^^^^^^^
24+
BIDS' X5
25+
^^^^^^^^
26+
.. automodule:: nitransforms.io.x5
27+
:members:
28+
29+
^^^^^^^^^^^^^^
30+
FreeSurfer/LTA
31+
^^^^^^^^^^^^^^
32+
.. automodule:: nitransforms.io.lta
33+
:members:
34+
2335
^^^
2436
FSL
2537
^^^
@@ -31,9 +43,3 @@ ITK
3143
^^^
3244
.. automodule:: nitransforms.io.itk
3345
:members:
34-
35-
^^^^^^^^^^^^^^
36-
FreeSurfer/LTA
37-
^^^^^^^^^^^^^^
38-
.. automodule:: nitransforms.io.lta
39-
:members:

nitransforms/io/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
# emacs: -*- mode: python-mode; py-indent-offset: 4; indent-tabs-mode: nil -*-
22
# vi: set ft=python sts=4 ts=4 sw=4 et:
33
"""Read and write transforms."""
4-
from nitransforms.io import afni, fsl, itk, lta
4+
from nitransforms.io import afni, fsl, itk, lta, x5
55
from nitransforms.io.base import TransformIOError, TransformFileError
66

77
__all__ = [
88
"afni",
99
"fsl",
1010
"itk",
1111
"lta",
12+
"x5",
1213
"get_linear_factory",
1314
"TransformFileError",
1415
"TransformIOError",

nitransforms/io/x5.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# emacs: -*- mode: python-mode; py-indent-offset: 4; indent-tabs-mode: nil -*-
2+
# vi: set ft=python sts=4 ts=4 sw=4 et:
3+
### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
4+
#
5+
# See COPYING file distributed along with the NiBabel package for the
6+
# copyright and license terms.
7+
#
8+
### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
9+
"""
10+
Data structures for the X5 transform format.
11+
12+
Implements what's drafted in the `BIDS X5 specification draft
13+
<https://docs.google.com/document/d/1yk5O0QTAOXLdP9iSG3W8ta7IcMFypu2106c3Pnjfi-4/edit>`__.
14+
15+
"""
16+
17+
from __future__ import annotations
18+
19+
from dataclasses import dataclass
20+
from pathlib import Path
21+
from typing import Any, Dict, Optional, Sequence, List
22+
23+
import json
24+
import h5py
25+
26+
import numpy as np
27+
28+
29+
@dataclass
30+
class X5Domain:
31+
"""Domain information of a transform representing reference/moving spaces."""
32+
33+
grid: bool
34+
"""Whether sampling locations in the manifold are located regularly."""
35+
size: Sequence[int]
36+
"""The number of sampling locations per dimension (or total if not a grid)."""
37+
mapping: Optional[np.ndarray]
38+
"""A mapping to go from samples (pixel/voxel coordinates, indices) to space coordinates."""
39+
coordinates: Optional[str] = None
40+
"""Indexing type of the Mapping field (for example, "cartesian", "spherical" or "index")."""
41+
42+
43+
@dataclass
44+
class X5Transform:
45+
"""Represent one transform entry of an X5 file."""
46+
47+
type: str
48+
"""A REQUIRED unicode string with possible values: "linear", "nonlinear", "composite"."""
49+
transform: np.ndarray
50+
"""A REQUIRED array of parameters (e.g., affine matrix, or dense displacements field)."""
51+
subtype: Optional[str] = None
52+
"""An OPTIONAL extension of type to drive the interpretation of AdditionalParameters."""
53+
representation: Optional[str] = None
54+
"""
55+
A string specifiying the transform representation or model, REQUIRED only for nonlinear Type.
56+
"""
57+
metadata: Optional[Dict[str, Any]] = None
58+
"""An OPTIONAL string (JSON) to embed metadata."""
59+
dimension_kinds: Optional[Sequence[str]] = None
60+
"""Identifies what "kind" of information is represented by the samples along each axis."""
61+
domain: Optional[X5Domain] = None
62+
"""
63+
A dataset specifying the reference manifold for the transform (either
64+
a regularly gridded 3D space or a surface/sphere).
65+
REQUIRED for nonlinear Type, RECOMMENDED for linear Type.
66+
"""
67+
inverse: Optional[np.ndarray] = None
68+
"""Placeholder to pre-calculated inverses."""
69+
jacobian: Optional[np.ndarray] = None
70+
"""
71+
A RECOMMENDED data array to keep cached the determinant of Jacobian of the transform
72+
in case tools have calculated it.
73+
For parametric models it is generally possible to obtain it analytically, so this dataset
74+
could not be as useful in that case.
75+
"""
76+
# additional_parameters: Optional[np.ndarray] = None
77+
# AdditionalParameters is empty in the draft spec - ignore for now.
78+
# Only documentation ATM is for SubType:
79+
# The SubType setting enables setting the additional parameters on a dataset called
80+
# "AdditionalParameters" that hangs directly from this transform node.
81+
array_length: int = 1
82+
"""Undocumented field in the draft to enable a single transform group for 4D transforms."""
83+
84+
85+
def to_filename(fname: str | Path, x5_list: List[X5Transform]):
86+
"""
87+
Write a list of :class:`X5Transform` objects to an X5 HDF5 file.
88+
89+
Parameters
90+
----------
91+
fname : :obj:`os.pathlike`
92+
The file name (preferably with the ".x5" extension) in which transforms will be stored.
93+
x5_list : :obj:`list`
94+
The list of transforms to be stored in the output dataset.
95+
96+
Returns
97+
-------
98+
fname : :obj:`os.pathlike`
99+
File containing the transform(s).
100+
101+
"""
102+
with h5py.File(str(fname), "w") as out_file:
103+
out_file.attrs["Format"] = "X5"
104+
out_file.attrs["Version"] = np.uint16(1)
105+
tg = out_file.create_group("TransformGroup")
106+
for i, node in enumerate(x5_list):
107+
g = tg.create_group(str(i))
108+
g.attrs["Type"] = node.type
109+
g.attrs["ArrayLength"] = node.array_length
110+
if node.subtype is not None:
111+
g.attrs["SubType"] = node.subtype
112+
if node.representation is not None:
113+
g.attrs["Representation"] = node.representation
114+
if node.metadata is not None:
115+
g.attrs["Metadata"] = json.dumps(node.metadata)
116+
g.create_dataset("Transform", data=node.transform)
117+
g.create_dataset(
118+
"DimensionKinds",
119+
data=np.asarray(node.dimension_kinds, dtype="S"),
120+
)
121+
if node.domain is not None:
122+
dgrp = g.create_group("Domain")
123+
dgrp.create_dataset("Grid", data=np.uint8(1 if node.domain.grid else 0))
124+
dgrp.create_dataset("Size", data=np.asarray(node.domain.size))
125+
dgrp.create_dataset("Mapping", data=node.domain.mapping)
126+
if node.domain.coordinates is not None:
127+
dgrp.attrs["Coordinates"] = node.domain.coordinates
128+
129+
if node.inverse is not None:
130+
g.create_dataset("Inverse", data=node.inverse)
131+
if node.jacobian is not None:
132+
g.create_dataset("Jacobian", data=node.jacobian)
133+
# Disabled until we need SubType and AdditionalParameters
134+
# if node.additional_parameters is not None:
135+
# g.create_dataset(
136+
# "AdditionalParameters", data=node.additional_parameters
137+
# )
138+
return fname

0 commit comments

Comments
 (0)