Skip to content

Commit b6d366d

Browse files
tsalomattcieslak
andauthored
Use MifImage class instead of mrconvert (#43)
Co-authored-by: mattcieslak <mattcieslak@gmail.com>
1 parent cc9b983 commit b6d366d

12 files changed

Lines changed: 989 additions & 126 deletions

File tree

.github/workflows/tox.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ jobs:
4444
with:
4545
fetch-depth: 0
4646

47+
- name: Install mrtrix3
48+
run: |
49+
sudo apt-get update
50+
sudo apt-get install -y --no-install-recommends mrtrix3
51+
mrconvert -version | head -1
52+
4753
- name: Install the latest version of uv
4854
uses: astral-sh/setup-uv@v8.0.0
4955

docs/examples/plot_fixel_workflow.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
use the ``modelarrayio to-modelarray`` command to convert the MIF files to the HDF5 format
77
(``.h5``) used by **ModelArray**,
88
and ``modelarrayio export-results`` to export results back to MIF.
9-
This guide assumes **ModelArrayIO** and **MRtrix** are already installed.
9+
This guide assumes **ModelArrayIO** is already installed.
1010
"""
1111

1212
# %%
@@ -132,8 +132,8 @@
132132
#
133133
# .. warning::
134134
#
135-
# **Existing files are not overwritten.** ``modelarrayio export-results`` calls ``mrconvert`` without
136-
# ``-force``, so any ``.mif`` file already present in ``--output-dir`` with the same name
135+
# **Existing files are not overwritten.** With ``modelarrayio export-results``, any ``.mif`` file
136+
# already present in ``--output-dir`` with the same name
137137
# will be left unchanged. If ``--output-dir`` itself already exists you will see a
138138
# ``WARNING: Output directory exists`` message, but no files will be deleted. To start
139139
# fresh, manually remove the output directory before re-running ``modelarrayio export-results``.

docs/installation.rst

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,6 @@ If you want to use the most up-to-date version, you can install from the ``main`
1414
pip install git+https://github.com/PennLINC/ModelArrayIO.git
1515
1616
17-
MRtrix (required for fixel ``.mif`` only)
18-
-----------------------------------------
19-
20-
Fixel-wise ``.mif`` conversion tools use MRtrix ``mrconvert``.
21-
Install MRtrix from `MRtrix's webpage <https://www.mrtrix.org/download/>`_ if needed.
22-
Run ``mrview`` in the terminal to verify the installation.
23-
24-
If your data are in NIfTI or CIFTI format only, you can skip this step.
25-
26-
2717
What Next?
2818
----------
2919

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ exclude_lines = [
182182
[tool.pytest.ini_options]
183183
markers = [
184184
"s3: tests that require network access to public S3 (deselect with '-m not s3')",
185+
"downloaded_data: tests that download external fixture data before running",
185186
]
186187
log_cli = true
187188

src/modelarrayio/cli/h5_to_mif.py

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66
from pathlib import Path
77

88
import h5py
9-
import nibabel as nb
9+
import numpy as np
1010

1111
from modelarrayio.cli import utils as cli_utils
12-
from modelarrayio.utils.mif import mif_to_nifti2, nifti2_to_mif
12+
from modelarrayio.utils.mif import MifImage, mif_to_image
1313

1414
logger = logging.getLogger(__name__)
1515

@@ -24,10 +24,9 @@ def h5_to_mif(example_mif, in_file, analysis_name, compress, output_dir):
2424
named ``results/has_names``. This data can be of any type and does not need to contain
2525
more than a single row of data. Instead, its attributes are read to get column names
2626
for the data represented in ``results/results_matrix``.
27-
The function takes the example mif file and converts it to Nifti2 to get a header.
28-
Then each column in ``results/results_matrix`` is extracted to fill the data of a
29-
new Nifti2 file that gets converted to mif and named according to the corresponding
30-
item in ``results/has_names``.
27+
The function takes the example mif file as a template header. Then each column in
28+
``results/results_matrix`` is extracted to fill the data of a new ``MifImage`` and
29+
named according to the corresponding item in ``results/has_names``.
3130
3231
Parameters
3332
----------
@@ -47,9 +46,10 @@ def h5_to_mif(example_mif, in_file, analysis_name, compress, output_dir):
4746
-------
4847
None
4948
"""
50-
# Get a template nifti image.
51-
nifti2_img, _ = mif_to_nifti2(example_mif)
49+
# Use the example MIF as the template so layout and metadata stay native to MIF.
50+
template_img, _ = mif_to_image(example_mif)
5251
output_path = Path(output_dir)
52+
output_path.mkdir(parents=True, exist_ok=True)
5353
ext = '.mif.gz' if compress else '.mif'
5454
with h5py.File(in_file, 'r') as h5_data:
5555
results_matrix = h5_data[f'results/{analysis_name}/results_matrix']
@@ -60,22 +60,50 @@ def h5_to_mif(example_mif, in_file, analysis_name, compress, output_dir):
6060
for result_col, result_name in enumerate(results_names):
6161
valid_result_name = cli_utils.sanitize_result_name(result_name)
6262
out_mif = output_path / f'{analysis_name}_{valid_result_name}{ext}'
63-
temp_nifti2 = nb.Nifti2Image(
64-
results_matrix[result_col, :].reshape(-1, 1, 1),
65-
nifti2_img.affine,
66-
header=nifti2_img.header,
63+
write_mif(
64+
arr=np.asarray(results_matrix[result_col, :], dtype=np.float32),
65+
template_img=template_img,
66+
out_file=out_mif,
6767
)
68-
nifti2_to_mif(temp_nifti2, out_mif)
6968

7069
if 'p.value' not in valid_result_name:
7170
continue
7271

7372
valid_result_name_1mpvalue = valid_result_name.replace('p.value', '1m.p.value')
7473
out_mif_1mpvalue = output_path / f'{analysis_name}_{valid_result_name_1mpvalue}{ext}'
75-
output_mifvalues_1mpvalue = 1 - results_matrix[result_col, :]
76-
temp_nifti2_1mpvalue = nb.Nifti2Image(
77-
output_mifvalues_1mpvalue.reshape(-1, 1, 1),
78-
nifti2_img.affine,
79-
header=nifti2_img.header,
74+
output_mifvalues_1mpvalue = np.asarray(
75+
1 - results_matrix[result_col, :],
76+
dtype=np.float32,
8077
)
81-
nifti2_to_mif(temp_nifti2_1mpvalue, out_mif_1mpvalue)
78+
write_mif(
79+
arr=output_mifvalues_1mpvalue,
80+
template_img=template_img,
81+
out_file=out_mif_1mpvalue,
82+
)
83+
84+
return 0
85+
86+
87+
def write_mif(arr, template_img, out_file):
88+
"""Write array to MIF file.
89+
90+
Parameters
91+
----------
92+
arr : :obj:`numpy.ndarray`
93+
Array to write to file.
94+
template_img : :obj:`MifImage`
95+
Template MIF image.
96+
out_file : :obj:`pathlib.Path`
97+
Output file to write.
98+
If it already exists, this function will raise a warning and not overwrite.
99+
"""
100+
if out_file.exists():
101+
logger.warning('Output file already exists. Not overwriting. %s', out_file)
102+
return
103+
104+
result_data = arr.reshape(template_img.shape)
105+
result_header = template_img.header.copy()
106+
result_header.set_data_shape(result_data.shape)
107+
result_header.set_data_dtype(result_data.dtype)
108+
result_img = MifImage(result_data, template_img.affine, header=result_header)
109+
result_img.to_filename(out_file)
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
"""Low-level MIF format parsing helpers."""
2+
3+
import sys
4+
5+
import numpy as np
6+
7+
8+
def _readline(fileobj) -> bytes:
9+
"""Read one newline-terminated line from *fileobj* using only ``read(1)``.
10+
11+
This works with any object that implements ``read(n)``, including
12+
nibabel's ``ImageOpener`` and gzip file objects that lack ``readline``.
13+
"""
14+
buf = bytearray()
15+
while True:
16+
ch = fileobj.read(1)
17+
if not ch:
18+
break
19+
buf.extend(ch if isinstance(ch, (bytes, bytearray)) else ch.encode('latin-1'))
20+
if buf[-1:] == b'\n':
21+
break
22+
return bytes(buf)
23+
24+
25+
_MIF_DTYPE_MAP: dict[str, str] = {
26+
'Int8': 'i1',
27+
'UInt8': 'u1',
28+
'Int16': 'i2',
29+
'UInt16': 'u2',
30+
'Int32': 'i4',
31+
'UInt32': 'u4',
32+
'Int64': 'i8',
33+
'UInt64': 'u8',
34+
'Float32': 'f4',
35+
'Float64': 'f8',
36+
'CFloat32': 'c8',
37+
'CFloat64': 'c16',
38+
}
39+
40+
_NUMPY_TO_MIF_BASE: dict[tuple[str, int], str] = {
41+
('i', 1): 'Int8',
42+
('u', 1): 'UInt8',
43+
('i', 2): 'Int16',
44+
('u', 2): 'UInt16',
45+
('i', 4): 'Int32',
46+
('u', 4): 'UInt32',
47+
('i', 8): 'Int64',
48+
('u', 8): 'UInt64',
49+
('f', 4): 'Float32',
50+
('f', 8): 'Float64',
51+
('c', 8): 'CFloat32',
52+
('c', 16): 'CFloat64',
53+
}
54+
55+
56+
def _mif_parse_dtype(dtype_str: str) -> np.dtype:
57+
"""Convert a MIF datatype string (e.g. ``'Float32LE'``) to a numpy dtype."""
58+
dtype_str = dtype_str.strip()
59+
if dtype_str.endswith('LE'):
60+
endian, base = '<', dtype_str[:-2]
61+
elif dtype_str.endswith('BE'):
62+
endian, base = '>', dtype_str[:-2]
63+
else:
64+
endian = '<' if sys.byteorder == 'little' else '>'
65+
base = dtype_str
66+
67+
if base not in _MIF_DTYPE_MAP:
68+
raise ValueError(f'Unknown MIF datatype: {dtype_str!r}')
69+
70+
type_char = _MIF_DTYPE_MAP[base]
71+
if type_char in ('i1', 'u1'): # single-byte types have no endianness
72+
return np.dtype(type_char)
73+
return np.dtype(endian + type_char)
74+
75+
76+
def _mif_dtype_to_str(dtype: np.dtype) -> str:
77+
"""Convert a numpy dtype to a MIF datatype string."""
78+
dtype = np.dtype(dtype)
79+
base_name = _NUMPY_TO_MIF_BASE.get((dtype.kind, dtype.itemsize))
80+
if base_name is None:
81+
raise ValueError(f'Cannot represent numpy dtype {dtype!r} in MIF format')
82+
if dtype.itemsize == 1:
83+
return base_name
84+
85+
byte_order = dtype.byteorder
86+
if byte_order == '=':
87+
byte_order = '<' if sys.byteorder == 'little' else '>'
88+
elif byte_order == '|':
89+
return base_name
90+
return base_name + ('LE' if byte_order == '<' else 'BE')
91+
92+
93+
def _mif_parse_layout(layout_str: str, ndim: int) -> list[int]:
94+
"""Parse a MIF layout string to a list of symbolic strides (1-indexed, signed).
95+
96+
For example ``'-0,-1,+2'`` becomes ``[-1, -2, 3]``. The absolute value
97+
encodes ordering (1 = fastest-varying axis) and the sign encodes direction.
98+
"""
99+
strides = []
100+
for token in layout_str.strip().split(','):
101+
token = token.strip()
102+
if token.startswith('+'):
103+
sign, val = 1, int(token[1:])
104+
elif token.startswith('-'):
105+
sign, val = -1, int(token[1:])
106+
else:
107+
sign, val = 1, int(token)
108+
strides.append(sign * (val + 1)) # convert 0-indexed to 1-indexed
109+
if len(strides) != ndim:
110+
raise ValueError(f'Layout has {len(strides)} axes but dim has {ndim}: {layout_str!r}')
111+
return strides
112+
113+
114+
def _mif_layout_to_str(layout: list[int]) -> str:
115+
"""Convert symbolic strides list to a MIF layout string."""
116+
tokens = []
117+
for s in layout:
118+
sign = '+' if s >= 0 else '-'
119+
val = abs(s) - 1 # convert 1-indexed back to 0-indexed
120+
tokens.append(f'{sign}{val}')
121+
return ','.join(tokens)
122+
123+
124+
def _mif_apply_layout(raw_flat: np.ndarray, shape: tuple, layout: list[int]) -> np.ndarray:
125+
"""Reorder flat MIF disk data into a canonical (positive-stride) numpy array.
126+
127+
MIF stores data with the axis whose ``|layout[i]|`` equals 1 varying
128+
fastest on disk, and axes with a negative ``layout[i]`` stored reversed.
129+
This function returns the array in the logical (mrtrix-canonical) order:
130+
output index ``(i, j, k, ...)`` corresponds to mrtrix image coordinate
131+
``(i, j, k, ...)``, matching what ``mrconvert <in> <out> -strides 1,2,3,...``
132+
would produce and what MRtrix tools (``fixelcfestats``, ``mrstats``, ...)
133+
see when they access the image via the ``Image`` API.
134+
"""
135+
ndim = len(shape)
136+
# Sort axes from fastest (|layout|=1) to slowest
137+
order = sorted(range(ndim), key=lambda i: abs(layout[i]))
138+
# Disk layout in C-order: [slowest, ..., fastest]
139+
disk_axes = list(reversed(order))
140+
disk_shape = tuple(shape[i] for i in disk_axes)
141+
142+
data = raw_flat.reshape(disk_shape)
143+
144+
# Transpose: output axis i came from disk position inv_perm[i]
145+
inv_perm = [0] * ndim
146+
for disk_pos, orig_axis in enumerate(disk_axes):
147+
inv_perm[orig_axis] = disk_pos
148+
data = data.transpose(inv_perm)
149+
150+
# Flip any axis with a negative symbolic stride so the result is in
151+
# mrtrix-canonical positive-stride order.
152+
slicer = tuple(slice(None, None, -1) if layout[i] < 0 else slice(None) for i in range(ndim))
153+
data = data[slicer]
154+
155+
return np.ascontiguousarray(data)
156+
157+
158+
def _mif_apply_layout_for_write(data: np.ndarray, layout: list[int]) -> np.ndarray:
159+
"""Reorder a canonical numpy array into MIF disk layout for writing.
160+
161+
Inverse of :func:`_mif_apply_layout`: first flip each axis whose
162+
symbolic stride is negative, then transpose to the disk axis order
163+
(``[slowest, ..., fastest]`` in C-order).
164+
"""
165+
ndim = len(data.shape)
166+
167+
# Flip axes that will be stored reversed on disk
168+
slicer = tuple(slice(None, None, -1) if layout[i] < 0 else slice(None) for i in range(ndim))
169+
data = data[slicer]
170+
171+
# Transpose to disk order: [slowest, ..., fastest] in C-order
172+
order = sorted(range(ndim), key=lambda i: abs(layout[i]))
173+
disk_axes = list(reversed(order))
174+
data = data.transpose(disk_axes)
175+
return np.ascontiguousarray(data)

0 commit comments

Comments
 (0)