From 9f92c6823144330abbaedb4ad304364f3adb0e73 Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Fri, 27 Mar 2026 13:13:27 -0400 Subject: [PATCH 01/10] Draft MifImage class. --- src/modelarrayio/utils/fixels.py | 571 +++++++++++++++++++++++++++++++ 1 file changed, 571 insertions(+) diff --git a/src/modelarrayio/utils/fixels.py b/src/modelarrayio/utils/fixels.py index 38e650e..7b52488 100644 --- a/src/modelarrayio/utils/fixels.py +++ b/src/modelarrayio/utils/fixels.py @@ -1,13 +1,19 @@ """Utility functions for fixel-wise data.""" +from __future__ import annotations + import shutil import subprocess +import sys import tempfile +from copy import deepcopy from pathlib import Path import nibabel as nb import numpy as np import pandas as pd +from nibabel.filebasedimages import FileBasedHeader +from nibabel.spatialimages import SpatialImage def find_mrconvert(): @@ -91,6 +97,571 @@ def mif_to_nifti2(mif_file): return nifti2_img, data +# ============================================================ +# MIF (MRtrix Image Format) reader/writer +# ============================================================ + + +def _readline(fileobj) -> bytes: + """Read one newline-terminated line from *fileobj* using only ``read(1)``. + + This works with any object that implements ``read(n)``, including + nibabel's ``ImageOpener`` and gzip file objects that lack ``readline``. + """ + buf = bytearray() + while True: + ch = fileobj.read(1) + if not ch: + break + buf.extend(ch if isinstance(ch, (bytes, bytearray)) else ch.encode('latin-1')) + if buf[-1:] == b'\n': + break + return bytes(buf) + +_MIF_DTYPE_MAP: dict[str, str] = { + 'Int8': 'i1', + 'UInt8': 'u1', + 'Int16': 'i2', + 'UInt16': 'u2', + 'Int32': 'i4', + 'UInt32': 'u4', + 'Int64': 'i8', + 'UInt64': 'u8', + 'Float32': 'f4', + 'Float64': 'f8', + 'CFloat32': 'c8', + 'CFloat64': 'c16', +} + +_NUMPY_TO_MIF_BASE: dict[tuple[str, int], str] = { + ('i', 1): 'Int8', + ('u', 1): 'UInt8', + ('i', 2): 'Int16', + ('u', 2): 'UInt16', + ('i', 4): 'Int32', + ('u', 4): 'UInt32', + ('i', 8): 'Int64', + ('u', 8): 'UInt64', + ('f', 4): 'Float32', + ('f', 8): 'Float64', + ('c', 8): 'CFloat32', + ('c', 16): 'CFloat64', +} + + +def _mif_parse_dtype(dtype_str: str) -> np.dtype: + """Convert a MIF datatype string (e.g. ``'Float32LE'``) to a numpy dtype.""" + dtype_str = dtype_str.strip() + if dtype_str.endswith('LE'): + endian, base = '<', dtype_str[:-2] + elif dtype_str.endswith('BE'): + endian, base = '>', dtype_str[:-2] + else: + endian = '<' if sys.byteorder == 'little' else '>' + base = dtype_str + + if base not in _MIF_DTYPE_MAP: + raise ValueError(f'Unknown MIF datatype: {dtype_str!r}') + + type_char = _MIF_DTYPE_MAP[base] + if type_char in ('i1', 'u1'): # single-byte types have no endianness + return np.dtype(type_char) + return np.dtype(endian + type_char) + + +def _mif_dtype_to_str(dtype: np.dtype) -> str: + """Convert a numpy dtype to a MIF datatype string.""" + dtype = np.dtype(dtype) + base_name = _NUMPY_TO_MIF_BASE.get((dtype.kind, dtype.itemsize)) + if base_name is None: + raise ValueError(f'Cannot represent numpy dtype {dtype!r} in MIF format') + if dtype.itemsize == 1: + return base_name + + byte_order = dtype.byteorder + if byte_order == '=': + byte_order = '<' if sys.byteorder == 'little' else '>' + elif byte_order == '|': + return base_name + return base_name + ('LE' if byte_order == '<' else 'BE') + + +def _mif_parse_layout(layout_str: str, ndim: int) -> list[int]: + """Parse a MIF layout string to a list of symbolic strides (1-indexed, signed). + + For example ``'-0,-1,+2'`` becomes ``[-1, -2, 3]``. The absolute value + encodes ordering (1 = fastest-varying axis) and the sign encodes direction. + """ + strides = [] + for token in layout_str.strip().split(','): + token = token.strip() + if token.startswith('+'): + sign, val = 1, int(token[1:]) + elif token.startswith('-'): + sign, val = -1, int(token[1:]) + else: + sign, val = 1, int(token) + strides.append(sign * (val + 1)) # convert 0-indexed to 1-indexed + if len(strides) != ndim: + raise ValueError( + f'Layout has {len(strides)} axes but dim has {ndim}: {layout_str!r}' + ) + return strides + + +def _mif_layout_to_str(layout: list[int]) -> str: + """Convert symbolic strides list to a MIF layout string.""" + tokens = [] + for s in layout: + sign = '+' if s >= 0 else '-' + val = abs(s) - 1 # convert 1-indexed back to 0-indexed + tokens.append(f'{sign}{val}') + return ','.join(tokens) + + +def _mif_apply_layout(raw_flat: np.ndarray, shape: tuple, layout: list[int]) -> np.ndarray: + """Reorder flat MIF disk data into a numpy array matching mrconvert's convention. + + MIF stores data with the axis whose ``|layout[i]|`` equals 1 varying + fastest on disk. This function reorders axes only — it does **not** flip + axes for negative strides. Instead, negative strides are encoded in the + affine returned by :meth:`MifHeader.get_best_affine`, exactly as mrconvert + does when writing NIfTI output. This ensures that ``MifImage.get_fdata()`` + matches the data you would get from ``mrconvert file.mif file.nii`` followed + by ``nibabel.load(file.nii).get_fdata()``. + """ + ndim = len(shape) + # Sort axes from fastest (|layout|=1) to slowest + order = sorted(range(ndim), key=lambda i: abs(layout[i])) + # Disk layout in C-order: [slowest, ..., fastest] + disk_axes = list(reversed(order)) + disk_shape = tuple(shape[i] for i in disk_axes) + + data = raw_flat.reshape(disk_shape) + + # Transpose: output axis i came from disk position inv_perm[i] + inv_perm = [0] * ndim + for disk_pos, orig_axis in enumerate(disk_axes): + inv_perm[orig_axis] = disk_pos + data = data.transpose(inv_perm) + + return np.ascontiguousarray(data) + + +def _mif_apply_layout_for_write(data: np.ndarray, layout: list[int]) -> np.ndarray: + """Reorder a numpy array into MIF disk layout for writing (axis ordering only).""" + ndim = len(data.shape) + + # Transpose to disk order: [slowest, ..., fastest] in C-order + order = sorted(range(ndim), key=lambda i: abs(layout[i])) + disk_axes = list(reversed(order)) + data = data.transpose(disk_axes) + return np.ascontiguousarray(data) + + +class MifHeader(FileBasedHeader): + """Header for MIF (.mif / .mif.gz) image files. + + The MIF format uses a text header with ``key: value`` pairs followed by + ``END``, then binary image data at the byte offset given by the + ``file: . `` entry. + + The transform stored in the file contains *unit direction cosines* for + each voxel axis; voxel sizes are stored separately in the ``vox`` field. + The nibabel 4x4 affine is reconstructed as:: + + affine[:3, :3] = transform[:3, :3] * zooms # column-wise scaling + affine[:3, 3] = transform[:3, 3] # translation unchanged + """ + + def __init__( + self, + shape: tuple = (1,), + zooms: tuple | None = None, + layout: list[int] | None = None, + dtype: np.dtype | None = None, + transform: np.ndarray | None = None, + intensity_offset: float = 0.0, + intensity_scale: float = 1.0, + keyval: dict | None = None, + ) -> None: + self._shape = tuple(int(s) for s in shape) + ndim = len(self._shape) + self._zooms = tuple(float(z) for z in zooms) if zooms is not None else (1.0,) * ndim + self._layout = list(layout) if layout is not None else list(range(1, ndim + 1)) + self._dtype = np.dtype(dtype) if dtype is not None else np.dtype('f4') + if transform is not None: + self._transform = np.array(transform, dtype=np.float64).reshape(3, 4) + else: + self._transform = np.eye(3, 4, dtype=np.float64) + self._intensity_offset = float(intensity_offset) + self._intensity_scale = float(intensity_scale) + self._keyval: dict[str, str] = dict(keyval) if keyval is not None else {} + self._data_offset: int | None = None # populated by from_fileobj + + @classmethod + def from_header(cls, header=None): + if header is None: + return cls() + if type(header) is cls: + return header.copy() + raise NotImplementedError(f'Cannot convert {type(header)} to {cls}') + + @classmethod + def from_fileobj(cls, fileobj) -> MifHeader: + """Read a MIF header from a binary file-like object. + + Uses only ``read(1)`` internally so it works with nibabel's + ``ImageOpener`` and gzip streams as well as regular file objects. + """ + first_line = _readline(fileobj).decode('latin-1').rstrip('\n\r') + if first_line != 'mrtrix image': + raise ValueError( + f'Not a MIF file (expected "mrtrix image", got {first_line!r})' + ) + + shape = None + zooms = None + layout_str = None + dtype_str = None + transform_rows: list[list[float]] = [] + scaling = None + keyval: dict[str, str] = {} + file_entry = None + + while True: + line = _readline(fileobj).decode('latin-1') + line = line.rstrip('\n\r') + if line == 'END' or not line: + break + comment_pos = line.find('#') + if comment_pos >= 0: + line = line[:comment_pos] + line = line.strip() + if not line or ':' not in line: + continue + + colon = line.index(':') + key = line[:colon].strip() + value = line[colon + 1:].strip() + if not key or not value: + continue + + lkey = key.lower() + if lkey == 'dim': + shape = tuple(int(x.strip()) for x in value.split(',')) + elif lkey == 'vox': + zooms = tuple(float(x.strip()) for x in value.split(',')) + elif lkey == 'layout': + layout_str = value + elif lkey == 'datatype': + dtype_str = value + elif lkey == 'transform': + transform_rows.append([float(x.strip()) for x in value.split(',')]) + elif lkey == 'scaling': + scaling = [float(x.strip()) for x in value.split(',')] + elif lkey == 'file': + file_entry = value + else: + # Preserve case and accumulate multi-line values + keyval[key] = (keyval[key] + '\n' + value) if key in keyval else value + + if shape is None: + raise ValueError('Missing "dim" in MIF header') + if zooms is None: + raise ValueError('Missing "vox" in MIF header') + if dtype_str is None: + raise ValueError('Missing "datatype" in MIF header') + if layout_str is None: + raise ValueError('Missing "layout" in MIF header') + + dtype = _mif_parse_dtype(dtype_str) + layout = _mif_parse_layout(layout_str, len(shape)) + + transform = np.eye(3, 4, dtype=np.float64) + if len(transform_rows) >= 3: + for r in range(3): + for c in range(min(4, len(transform_rows[r]))): + transform[r, c] = transform_rows[r][c] + + intensity_offset, intensity_scale = 0.0, 1.0 + if scaling is not None and len(scaling) == 2: + intensity_offset, intensity_scale = scaling[0], scaling[1] + + hdr = cls( + shape=shape, + zooms=zooms, + layout=layout, + dtype=dtype, + transform=transform, + intensity_offset=intensity_offset, + intensity_scale=intensity_scale, + keyval=keyval, + ) + + if file_entry is not None: + parts = file_entry.split() + if len(parts) >= 2: + hdr._data_offset = int(parts[1]) + elif len(parts) == 1 and parts[0] != '.': + hdr._data_offset = 0 # external data file (MIH format) + + return hdr + + def write_to(self, fileobj, data_offset: int | None = None) -> int: + """Write the MIF header to *fileobj*, returning the data byte offset. + + The ``file: . \\nEND\\n`` footer and any alignment padding are + written so that the caller can immediately append the binary data. + """ + lines = ['mrtrix image'] + lines.append(f'dim: {",".join(str(s) for s in self._shape)}') + lines.append(f'vox: {",".join(str(float(z)) for z in self._zooms)}') + lines.append(f'layout: {_mif_layout_to_str(self._layout)}') + lines.append(f'datatype: {_mif_dtype_to_str(self._dtype)}') + + for row in range(3): + row_vals = ', '.join(repr(float(self._transform[row, col])) for col in range(4)) + lines.append(f'transform: {row_vals}') + + if self._intensity_offset != 0.0 or self._intensity_scale != 1.0: + lines.append(f'scaling: {self._intensity_offset},{self._intensity_scale}') + + for key, value in self._keyval.items(): + lines.extend(f'{key}: {line_val}' for line_val in value.split('\n')) + + pre_file_bytes = ('\n'.join(lines) + '\n').encode('latin-1') + pre_file_pos = len(pre_file_bytes) + + if data_offset is None: + # Iteratively compute the offset so that the file: line fits exactly. + file_prefix = b'file: . ' + end_suffix = b'\nEND\n' + offset = pre_file_pos + len(file_prefix) + 5 + len(end_suffix) + offset += (4 - offset % 4) % 4 + for _ in range(5): + file_line = file_prefix + str(offset).encode() + end_suffix + total = pre_file_pos + len(file_line) + new_offset = total + (4 - total % 4) % 4 + if new_offset == offset: + break + offset = new_offset + data_offset = offset + + file_line = f'file: . {data_offset}\nEND\n'.encode('latin-1') + fileobj.write(pre_file_bytes) + fileobj.write(file_line) + + current_pos = pre_file_pos + len(file_line) + padding = data_offset - current_pos + if padding > 0: + fileobj.write(b'\x00' * padding) + + return data_offset + + def copy(self) -> MifHeader: + return deepcopy(self) + + # ------------------------------------------------------------------ + # Nibabel SpatialHeader protocol + # ------------------------------------------------------------------ + + def get_data_shape(self) -> tuple: + return self._shape + + def set_data_shape(self, shape) -> None: + self._shape = tuple(int(s) for s in shape) + + def get_zooms(self) -> tuple: + return self._zooms + + def set_zooms(self, zooms) -> None: + self._zooms = tuple(float(z) for z in zooms) + + def get_data_dtype(self) -> np.dtype: + return self._dtype + + def set_data_dtype(self, dtype) -> None: + self._dtype = np.dtype(dtype) + + def get_layout(self) -> list[int]: + return list(self._layout) + + def get_transform(self) -> np.ndarray: + """Return a copy of the 3x4 direction-cosine + translation matrix.""" + return self._transform.copy() + + def get_best_affine(self) -> np.ndarray: + """Return the 4x4 affine mapping *disk* voxel indices to scanner space (mm). + + This follows mrconvert's NIfTI output convention: data is kept in the + on-disk byte order (no axis flips are applied), and the affine is + adjusted so that voxel ``(0, 0, 0, …)`` maps to the scanner position + of the first element on disk. + + For axes with a positive layout stride the column equals:: + + transform_col * vox + + For axes with a **negative** layout stride (stored reversed on disk) the + column is **negated** and the origin is shifted by + ``(dim - 1) * transform_col * vox`` so that disk voxel ``(0, …)`` + maps to the scanner position of the last mrtrix voxel along that axis:: + + new_col = -transform_col * vox + new_origin = origin + transform_col * vox * (dim - 1) + """ + affine = np.eye(4, dtype=np.float64) + n_spatial = min(3, len(self._zooms), len(self._shape)) + zooms = np.ones(3, dtype=np.float64) + zooms[:n_spatial] = self._zooms[:n_spatial] + + rotation_cols = self._transform[:, :3] * zooms # shape (3, 3) + origin = self._transform[:, 3].copy() + + for i, s in enumerate(self._layout): + if i < 3 and s < 0: + # disk voxel 0 on this axis = mrtrix voxel (dim_i - 1) + origin += rotation_cols[:, i] * (self._shape[i] - 1) + rotation_cols[:, i] = -rotation_cols[:, i] + + affine[:3, :3] = rotation_cols + affine[:3, 3] = origin + return affine + + def get_intensity_scaling(self) -> tuple[float, float]: + """Return ``(offset, scale)`` for intensity rescaling.""" + return self._intensity_offset, self._intensity_scale + + def get_keyval(self) -> dict[str, str]: + return dict(self._keyval) + + __hash__ = None # required because __eq__ is defined + + def __eq__(self, other: object) -> bool: + if not isinstance(other, MifHeader): + return NotImplemented + return ( + self._shape == other._shape + and self._zooms == other._zooms + and self._layout == other._layout + and self._dtype == other._dtype + and np.allclose(self._transform, other._transform) + and self._intensity_offset == other._intensity_offset + and self._intensity_scale == other._intensity_scale + and self._keyval == other._keyval + ) + + +class MifImage(SpatialImage): + """Nibabel-style image class for MIF (.mif / .mif.gz) files. + + Supports reading and writing the MRtrix Image Format, including gzip + compression. The public API mirrors standard nibabel images:: + + img = MifImage.load('image.mif') + data = img.get_fdata() + affine = img.affine + + new_img = MifImage(data, affine) + new_img.to_filename('output.mif') + new_img.to_filename('output.mif.gz') + + The MIF *layout* field (e.g. ``-0,-1,+2``) describes which axis varies + fastest on disk and in which direction. :meth:`get_fdata` always returns + a C-contiguous array indexed as ``data[x, y, z, ...]`` regardless of the + on-disk layout. + """ + + header_class = MifHeader + files_types = (('image', '.mif'),) + valid_exts = ('.mif',) + _compressed_suffixes = ('.gz',) + + def __init__(self, dataobj, affine, header=None, extra=None, file_map=None): + super().__init__(dataobj, affine, header=header, extra=extra, file_map=file_map) + # Ensure layout has the right number of axes for freshly created images + if header is None and hasattr(dataobj, 'shape') and len(dataobj.shape) > 0: + ndim = len(dataobj.shape) + if len(self._header._layout) != ndim: + self._header._layout = list(range(1, ndim + 1)) + self._header.set_data_dtype(np.asarray(dataobj).dtype) + + @classmethod + def from_file_map(cls, file_map, *, mmap=False, keep_file_open=None): + """Load a MIF image from a nibabel *file_map* dict.""" + img_fh = file_map['image'] + with img_fh.get_prepare_fileobj(mode='rb') as fileobj: + header = cls.header_class.from_fileobj(fileobj) + data_offset = header._data_offset + if data_offset is None: + raise ValueError('Could not determine data offset from MIF header') + + current_pos = fileobj.tell() + skip = data_offset - current_pos + if skip > 0: + fileobj.read(skip) # works for both seekable and gzip streams + elif skip < 0: + fileobj.seek(data_offset) + + shape = header.get_data_shape() + dtype = header.get_data_dtype() + n_bytes = int(np.prod(shape)) * dtype.itemsize + raw = np.frombuffer(fileobj.read(n_bytes), dtype=dtype) + + data = _mif_apply_layout(raw, shape, header.get_layout()) + + affine = header.get_best_affine() + off, scale = header.get_intensity_scaling() + if scale != 1.0 or off != 0.0: + data = data.astype(np.float64) * scale + off + + img = cls(data, affine, header=header, file_map=file_map) + img._affine = affine # keep the exact affine from the header + return img + + def to_file_map(self, file_map=None, dtype=None): + """Save the image to the files described by *file_map*.""" + if file_map is None: + file_map = self.file_map + + self.update_header() + header = self._header + + if dtype is not None: + header.set_data_dtype(np.dtype(dtype)) + + data = np.asanyarray(self._dataobj) + + img_fh = file_map['image'] + with img_fh.get_prepare_fileobj(mode='wb') as fileobj: + header.write_to(fileobj) + disk_data = _mif_apply_layout_for_write(data, header.get_layout()) + fileobj.write(disk_data.astype(header.get_data_dtype()).tobytes()) + + def _affine2header(self): + """Sync the nibabel affine back into the MIF header fields.""" + if self._affine is None: + return + hdr = self._header + affine = self._affine + # Extract voxel sizes as column norms of the rotation+scale part + zooms = np.sqrt(np.sum(affine[:3, :3] ** 2, axis=0)) + ndim = len(hdr.get_data_shape()) + + zooms_list = list(hdr.get_zooms()) + n_spatial = min(3, ndim) + zooms_list[:n_spatial] = zooms[:n_spatial].tolist() + hdr.set_zooms(zooms_list) + + # Store unit direction cosines and translation + transform = np.zeros((3, 4), dtype=np.float64) + safe_zooms = np.where(zooms > 0, zooms, 1.0) + transform[:, :3] = affine[:3, :3] / safe_zooms + transform[:, 3] = affine[:3, 3] + hdr._transform = transform + + def gather_fixels(index_file, directions_file): """Load the index and directions files to get lookup tables. From cd8a8e13168a6584ffeb44a9a025de2841926e3e Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Fri, 27 Mar 2026 16:26:59 -0400 Subject: [PATCH 02/10] Use MifImage and add test. --- pyproject.toml | 1 + src/modelarrayio/cli/h5_to_mif.py | 46 ++++++++----- src/modelarrayio/cli/mif_to_h5.py | 4 +- src/modelarrayio/utils/fixels.py | 100 ++++++++++----------------- test/conftest.py | 44 ++++++++++++ test/test_fixels_utils.py | 111 +++++++++++++++++++++++++++--- tox.ini | 7 +- 7 files changed, 216 insertions(+), 97 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6771069..6ccb90c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -182,6 +182,7 @@ exclude_lines = [ [tool.pytest.ini_options] markers = [ "s3: tests that require network access to public S3 (deselect with '-m not s3')", + "downloaded_data: tests that download external fixture data before running", ] log_cli = true diff --git a/src/modelarrayio/cli/h5_to_mif.py b/src/modelarrayio/cli/h5_to_mif.py index bb6bfed..3babaad 100644 --- a/src/modelarrayio/cli/h5_to_mif.py +++ b/src/modelarrayio/cli/h5_to_mif.py @@ -9,12 +9,12 @@ from pathlib import Path import h5py -import nibabel as nb +import numpy as np import pandas as pd from modelarrayio.cli import utils as cli_utils from modelarrayio.cli.parser_utils import _is_file, add_from_modelarray_args, add_log_level_arg -from modelarrayio.utils.fixels import mif_to_nifti2, nifti2_to_mif +from modelarrayio.utils.fixels import MifImage, image_to_mif, mif_to_image logger = logging.getLogger(__name__) @@ -29,10 +29,9 @@ def h5_to_mif(example_mif, in_file, analysis_name, output_dir): named ``results/has_names``. This data can be of any type and does not need to contain more than a single row of data. Instead, its attributes are read to get column names for the data represented in ``results/results_matrix``. - The function takes the example mif file and converts it to Nifti2 to get a header. - Then each column in ``results/results_matrix`` is extracted to fill the data of a - new Nifti2 file that gets converted to mif and named according to the corresponding - item in ``results/has_names``. + The function takes the example mif file as a template header. Then each column in + ``results/results_matrix`` is extracted to fill the data of a new ``MifImage`` and + named according to the corresponding item in ``results/has_names``. Parameters ========== @@ -50,8 +49,9 @@ def h5_to_mif(example_mif, in_file, analysis_name, output_dir): ======= None """ - # Get a template nifti image. - nifti2_img, _ = mif_to_nifti2(example_mif) + # Use the example MIF as the template so layout and metadata stay native to MIF. + template_img, _ = mif_to_image(example_mif) + template_shape = template_img.shape output_path = Path(output_dir) with h5py.File(in_file, 'r') as h5_data: results_matrix = h5_data[f'results/{analysis_name}/results_matrix'] @@ -62,25 +62,33 @@ def h5_to_mif(example_mif, in_file, analysis_name, output_dir): for result_col, result_name in enumerate(results_names): valid_result_name = cli_utils.sanitize_result_name(result_name) out_mif = output_path / f'{analysis_name}_{valid_result_name}.mif' - temp_nifti2 = nb.Nifti2Image( - results_matrix[result_col, :].reshape(-1, 1, 1), - nifti2_img.affine, - header=nifti2_img.header, + result_data = np.asarray(results_matrix[result_col, :], dtype=np.float32).reshape( + template_shape ) - nifti2_to_mif(temp_nifti2, out_mif) + result_header = template_img.header.copy() + result_header.set_data_shape(result_data.shape) + result_header.set_data_dtype(result_data.dtype) + result_img = MifImage(result_data, template_img.affine, header=result_header) + image_to_mif(result_img, out_mif) if 'p.value' not in valid_result_name: continue valid_result_name_1mpvalue = valid_result_name.replace('p.value', '1m.p.value') out_mif_1mpvalue = output_path / f'{analysis_name}_{valid_result_name_1mpvalue}.mif' - output_mifvalues_1mpvalue = 1 - results_matrix[result_col, :] - temp_nifti2_1mpvalue = nb.Nifti2Image( - output_mifvalues_1mpvalue.reshape(-1, 1, 1), - nifti2_img.affine, - header=nifti2_img.header, + output_mifvalues_1mpvalue = np.asarray( + 1 - results_matrix[result_col, :], + dtype=np.float32, + ).reshape(template_shape) + output_header_1mpvalue = template_img.header.copy() + output_header_1mpvalue.set_data_shape(output_mifvalues_1mpvalue.shape) + output_header_1mpvalue.set_data_dtype(output_mifvalues_1mpvalue.dtype) + output_img_1mpvalue = MifImage( + output_mifvalues_1mpvalue, + template_img.affine, + header=output_header_1mpvalue, ) - nifti2_to_mif(temp_nifti2_1mpvalue, out_mif_1mpvalue) + image_to_mif(output_img_1mpvalue, out_mif_1mpvalue) def h5_to_mif_main( diff --git a/src/modelarrayio/cli/mif_to_h5.py b/src/modelarrayio/cli/mif_to_h5.py index 38ed1cc..cc92311 100644 --- a/src/modelarrayio/cli/mif_to_h5.py +++ b/src/modelarrayio/cli/mif_to_h5.py @@ -14,7 +14,7 @@ from modelarrayio.cli import utils as cli_utils from modelarrayio.cli.parser_utils import _is_file, add_to_modelarray_args -from modelarrayio.utils.fixels import gather_fixels, mif_to_nifti2 +from modelarrayio.utils.fixels import gather_fixels, mif_to_image logger = logging.getLogger(__name__) @@ -86,7 +86,7 @@ def mif_to_h5( logger.info('Extracting .mif data...') for row in tqdm(cohort_df.itertuples(index=False), total=cohort_df.shape[0]): scalar_file = row.source_file - _scalar_img, scalar_data = mif_to_nifti2(scalar_file) + _scalar_img, scalar_data = mif_to_image(scalar_file) scalars[row.scalar_name].append(scalar_data) sources_lists[row.scalar_name].append(row.source_file) diff --git a/src/modelarrayio/utils/fixels.py b/src/modelarrayio/utils/fixels.py index 7b52488..b0ffdf7 100644 --- a/src/modelarrayio/utils/fixels.py +++ b/src/modelarrayio/utils/fixels.py @@ -2,68 +2,51 @@ from __future__ import annotations -import shutil -import subprocess import sys -import tempfile from copy import deepcopy from pathlib import Path -import nibabel as nb import numpy as np import pandas as pd from nibabel.filebasedimages import FileBasedHeader from nibabel.spatialimages import SpatialImage -def find_mrconvert(): - return shutil.which('mrconvert') - - -def _require_mrconvert() -> str: - mrconvert = find_mrconvert() - if mrconvert is None: - raise FileNotFoundError('The mrconvert executable could not be found on $PATH.') - return mrconvert - - -def _run_mrconvert(source_file: Path, output_file: Path) -> None: - try: - subprocess.run( - [_require_mrconvert(), str(source_file), str(output_file)], - check=True, - capture_output=True, - text=True, - ) - except subprocess.CalledProcessError as exc: - message = exc.stderr.strip() or exc.stdout.strip() or 'mrconvert failed.' - raise RuntimeError( - f'mrconvert failed while converting {source_file} to {output_file}: {message}' - ) from exc - - -def nifti2_to_mif(nifti2_image, mif_file): - """Convert a .nii file to a .mif file. +def image_to_mif(image: SpatialImage, mif_file): + """Write a nibabel image to a `.mif` file. Parameters ---------- - nifti2_image : :obj:`nibabel.Nifti2Image` - Nifti2 image + image : :obj:`nibabel.spatialimages.SpatialImage` + In-memory image to write. mif_file : :obj:`str` Path to a .mif file """ output_path = Path(mif_file) - with tempfile.TemporaryDirectory() as temp_dir: - temp_nii = Path(temp_dir) / 'mrconvert_input.nii' - nifti2_image.to_filename(temp_nii) - _run_mrconvert(temp_nii, output_path) + if isinstance(image, MifImage): + mif_image = image + else: + source_header = image.header if isinstance(image.header, MifHeader) else None + header = source_header.copy() if source_header is not None else None + mif_image = MifImage(np.asanyarray(image.dataobj), image.affine, header=header) + + mif_image.to_filename(output_path) if not output_path.exists(): - raise RuntimeError(f'mrconvert did not create expected output file: {output_path}') + raise RuntimeError(f'Failed to create expected output file: {output_path}') + +def nifti2_to_mif(nifti2_image, mif_file): + """Convert an in-memory nibabel image to a `.mif` file. + + This compatibility wrapper now writes the data directly with + :class:`MifImage` instead of shelling out through a temporary NIfTI file. + """ + image_to_mif(nifti2_image, mif_file) -def mif_to_nifti2(mif_file): - """Convert a .mif file to a .nii file. + +def mif_to_image(mif_file): + """Load a `.mif` file into a :class:`MifImage`. Parameters ---------- @@ -72,29 +55,15 @@ def mif_to_nifti2(mif_file): Returns ------- - nifti2_img : :obj:`nibabel.Nifti2Image` - Nifti2 image + mif_img : :obj:`MifImage` + Loaded MIF image data : :obj:`numpy.ndarray` - Data from the nifti2 image + Data from the MIF image """ input_path = Path(mif_file) - if input_path.suffix == '.nii': - nifti2_img = nb.load(input_path) - data = nifti2_img.get_fdata(dtype=np.float32).squeeze() - return nifti2_img, data - - with tempfile.TemporaryDirectory() as temp_dir: - nii_path = Path(temp_dir) / 'mif.nii' - _run_mrconvert(input_path, nii_path) - if not nii_path.exists(): - raise RuntimeError(f'mrconvert did not create expected output file: {nii_path}') - - loaded_img = nb.load(nii_path) - in_memory_data = np.asanyarray(loaded_img.dataobj) - nifti2_img = nb.Nifti2Image(in_memory_data, loaded_img.affine, header=loaded_img.header) - data = loaded_img.get_fdata(dtype=np.float32).squeeze() - - return nifti2_img, data + mif_img = MifImage.from_filename(str(input_path)) + data = mif_img.get_fdata(dtype=np.float32).squeeze() + return mif_img, data # ============================================================ @@ -679,7 +648,7 @@ def gather_fixels(index_file, directions_file): voxel_table : :obj:`pandas.DataFrame` DataFrame with voxel_id, i, j, k """ - _index_img, index_data = mif_to_nifti2(index_file) + _index_img, index_data = mif_to_image(index_file) # number of fixels in each voxel; by index.mif definition count_vol = index_data[..., 0].astype(np.uint32) # index of the first fixel in this voxel, in the list of all fixels @@ -687,7 +656,10 @@ def gather_fixels(index_file, directions_file): id_vol = index_data[..., 1] max_id = id_vol.max() # = the maximum id of fixels + 1 = # of fixels in entire image - max_fixel_id = max_id + int(count_vol[id_vol == max_id]) + terminal_counts = count_vol[id_vol == max_id] + if terminal_counts.size == 0: + raise ValueError('Could not determine the final fixel count from the index image.') + max_fixel_id = int(max_id) + int(terminal_counts.max()) voxel_mask = count_vol > 0 # voxels that contains fixel(s), =1 masked_ids = id_vol[voxel_mask] # 1D array, len = # of voxels with fixel(s), value see id_vol masked_counts = count_vol[voxel_mask] # dim as masked_ids; value see count_vol @@ -719,7 +691,7 @@ def gather_fixels(index_file, directions_file): } ) - _directions_img, directions_data = mif_to_nifti2(directions_file) + _directions_img, directions_data = mif_to_image(directions_file) fixel_table = pd.DataFrame( { 'fixel_id': fixel_ids, diff --git a/test/conftest.py b/test/conftest.py index 69c4ff5..ec4a05c 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -3,7 +3,12 @@ from __future__ import annotations import os +import tarfile from pathlib import Path +from urllib.error import URLError +from urllib.request import urlretrieve + +import pytest # CLI tests run subprocesses with cwd under tmp_path (outside the repo). Coverage # discovers [tool.coverage] from the process cwd, so those children would default to @@ -11,3 +16,42 @@ # the parent. Point subprocess coverage at the project config explicitly. _ROOT = Path(__file__).resolve().parents[1] os.environ.setdefault('COVERAGE_RCFILE', str(_ROOT / 'pyproject.toml')) + +_FIXEL_DATA_URL = 'https://upenn.box.com/s/aaauvwrsrvsj3yvdkl1b899wx1s8ih2f.tar.gz' +_FIXEL_DATA_URL_ENV = 'MODELARRAYIO_FIXEL_TEST_DATA_URL' +_FIXEL_DATA_DIR_ENV = 'MODELARRAYIO_FIXEL_TEST_DATA_DIR' + + +def _download_and_extract_fixel_test_data(destination_dir: Path) -> Path: + """Download and extract the fixel test dataset archive.""" + source_dir = os.environ.get(_FIXEL_DATA_DIR_ENV) + if source_dir: + source_path = Path(source_dir).expanduser().resolve() + if not source_path.exists(): + raise FileNotFoundError( + f'{_FIXEL_DATA_DIR_ENV} points to a missing path: {source_path}' + ) + return source_path + + destination_dir.mkdir(parents=True, exist_ok=True) + archive_path = destination_dir / 'mif_test_data.tar.gz' + data_url = os.environ.get(_FIXEL_DATA_URL_ENV, _FIXEL_DATA_URL) + urlretrieve(data_url, archive_path) # noqa: S310 + + with tarfile.open(archive_path, mode='r:gz') as archive: + archive.extractall(destination_dir) # noqa: S202 + + extracted_dir = destination_dir / 'mif_test_data' + if not extracted_dir.exists(): + raise FileNotFoundError(f'Expected extracted dataset at {extracted_dir}') + return extracted_dir + + +@pytest.fixture(scope='session') +def downloaded_fixel_data_dir(tmp_path_factory: pytest.TempPathFactory) -> Path: + """Provide the downloaded fixel dataset directory for tests.""" + destination_dir = tmp_path_factory.mktemp('downloaded_fixel_data') + try: + return _download_and_extract_fixel_test_data(destination_dir) + except (FileNotFoundError, OSError, URLError, tarfile.TarError) as exc: + pytest.skip(f'Downloaded fixel test data unavailable: {exc}') diff --git a/test/test_fixels_utils.py b/test/test_fixels_utils.py index 179f5bf..7c51551 100644 --- a/test/test_fixels_utils.py +++ b/test/test_fixels_utils.py @@ -1,28 +1,117 @@ -"""Unit tests for fixel utility error handling.""" +"""Unit tests for fixel utility conversions.""" from __future__ import annotations +from pathlib import Path + +import h5py import nibabel as nb import numpy as np +import pandas as pd import pytest +from modelarrayio.cli.h5_to_mif import h5_to_mif_main +from modelarrayio.cli.mif_to_h5 import mif_to_h5 from modelarrayio.utils import fixels -def _make_nifti2(shape=(2, 1, 1)) -> nb.Nifti2Image: +def _make_nifti2(shape=(2, 1, 1), affine=None) -> nb.Nifti2Image: data = np.zeros(shape, dtype=np.float32) - return nb.Nifti2Image(data, affine=np.eye(4)) + return nb.Nifti2Image(data, affine=np.eye(4) if affine is None else affine) + + +def test_nifti2_to_mif_round_trip_uses_direct_mif_io(tmp_path) -> None: + affine = np.array( + [ + [1.5, 0.0, 0.0, 10.0], + [0.0, 2.0, 0.0, -5.0], + [0.0, 0.0, 2.5, 3.0], + [0.0, 0.0, 0.0, 1.0], + ] + ) + nifti_img = _make_nifti2(shape=(4, 1, 1), affine=affine) + nifti_img.dataobj[:] = np.arange(4, dtype=np.float32).reshape(4, 1, 1) + + out_file = tmp_path / 'out.mif' + fixels.nifti2_to_mif(nifti_img, out_file) + + mif_img, mif_data = fixels.mif_to_image(out_file) + + assert isinstance(mif_img, fixels.MifImage) + np.testing.assert_allclose(mif_img.affine, affine) + np.testing.assert_allclose(mif_data, np.arange(4, dtype=np.float32)) + + +@pytest.mark.downloaded_data +def test_mif_to_h5_results( + tmp_path_factory: pytest.TempPathFactory, downloaded_fixel_data_dir: Path +) -> None: + """Test mif-to-h5 and h5-to-mif conversion, mimicking a ModelArray analysis.""" + # Step 1: Prepare inputs for conversion + out_dir = tmp_path_factory.mktemp('data_fixel_toy') + in_dir = downloaded_fixel_data_dir + index_file = in_dir / 'index.mif' + directions_file = in_dir / 'directions.mif' + cohort_file = in_dir / 'stat-alpha_cohort.csv' + + # Prepend absolute path to source files in cohort file + cohort_df = pd.read_csv(cohort_file) + cohort_df['source_file'] = cohort_df['source_file'].map(lambda path: str(in_dir / path)) + temp_cohort_file = out_dir / 'stat-alpha_cohort.csv' + cohort_df.to_csv(temp_cohort_file, index=False) + + # Step 2: Convert MIF to HDF5 + h5_file = out_dir / 'stat-alpha.h5' + assert ( + mif_to_h5( + index_file=index_file, + directions_file=directions_file, + cohort_file=temp_cohort_file, + output=h5_file, + ) + == 0 + ) + # Step 3: Add a result (element-wise mean across files) to the HDF5 file + with h5py.File(h5_file, 'a') as h5: + alpha_values = h5['scalars/alpha/values'][...] + mean_values = np.mean(alpha_values, axis=0, dtype=np.float32) + results_group = h5.require_group('results/lm') + results_group.create_dataset('results_matrix', data=mean_values[np.newaxis, :]) + results_group.create_dataset( + 'column_names', + data=np.array(['mean'], dtype=h5py.string_dtype('utf-8')), + ) -def test_nifti2_to_mif_raises_when_mrconvert_missing(tmp_path, monkeypatch) -> None: - monkeypatch.setattr(fixels, 'find_mrconvert', lambda: None) + # Step 4: Convert HDF5 to MIF + output_dir = out_dir / 'mif_results' + assert ( + h5_to_mif_main( + index_file=index_file, + directions_file=directions_file, + analysis_name='lm', + in_file=h5_file, + output_dir=output_dir, + example_mif=cohort_df['source_file'].iloc[0], + log_level='INFO', + ) + == 0 + ) - with pytest.raises(FileNotFoundError, match='mrconvert'): - fixels.nifti2_to_mif(_make_nifti2(), tmp_path / 'out.mif') + # Step 5: Calculate mean directly from MIF files + source_arrays = [ + fixels.mif_to_image(source_file)[1].astype(np.float32) + for source_file in cohort_df['source_file'] + ] + direct_mean = np.mean(np.stack(source_arrays, axis=0), axis=0, dtype=np.float32) + # Step 6: Compare the mean from the HDF5 file to the mean from the MIF files + output_mif = output_dir / 'lm_mean.mif' + assert output_mif.exists() -def test_mif_to_nifti2_raises_when_mrconvert_missing(monkeypatch) -> None: - monkeypatch.setattr(fixels, 'find_mrconvert', lambda: None) + result_img, result_data = fixels.mif_to_image(output_mif) + template_img, _ = fixels.mif_to_image(cohort_df['source_file'].iloc[0]) - with pytest.raises(FileNotFoundError, match='mrconvert'): - fixels.mif_to_nifti2('missing_input.mif') + assert isinstance(result_img, fixels.MifImage) + np.testing.assert_allclose(result_img.affine, template_img.affine) + np.testing.assert_allclose(result_data, direct_mean) diff --git a/tox.ini b/tox.ini index e422b2f..a6e4de2 100644 --- a/tox.ini +++ b/tox.ini @@ -20,7 +20,7 @@ DEPENDS = latest: latest [testenv] -description = Pytest with coverage (excludes network S3 tests) +description = Pytest with coverage (excludes network S3 and downloaded-data tests) labels = test setenv = COVERAGE_FILE = {toxinidir}/.tox/.coverage.{envname} @@ -44,6 +44,11 @@ runner = uv_resolution = min: lowest-direct +commands = + pytest -m "not s3 and not downloaded_data" --cov=modelarrayio --cov-config={toxinidir}/pyproject.toml --cov-report=term-missing --cov-report=xml {posargs:test} + +[testenv:py312-latest] +description = Pytest with coverage including downloaded-data tests commands = pytest -m "not s3" --cov=modelarrayio --cov-config={toxinidir}/pyproject.toml --cov-report=term-missing --cov-report=xml {posargs:test} From 18c19b397050258ea5364f2eaff772ae61722006 Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Fri, 27 Mar 2026 16:29:21 -0400 Subject: [PATCH 03/10] Run ruff. --- src/modelarrayio/utils/fixels.py | 11 ++++------- test/test_fixels_utils.py | 10 +++++----- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/modelarrayio/utils/fixels.py b/src/modelarrayio/utils/fixels.py index b0ffdf7..25a6f89 100644 --- a/src/modelarrayio/utils/fixels.py +++ b/src/modelarrayio/utils/fixels.py @@ -87,6 +87,7 @@ def _readline(fileobj) -> bytes: break return bytes(buf) + _MIF_DTYPE_MAP: dict[str, str] = { 'Int8': 'i1', 'UInt8': 'u1', @@ -172,9 +173,7 @@ def _mif_parse_layout(layout_str: str, ndim: int) -> list[int]: sign, val = 1, int(token) strides.append(sign * (val + 1)) # convert 0-indexed to 1-indexed if len(strides) != ndim: - raise ValueError( - f'Layout has {len(strides)} axes but dim has {ndim}: {layout_str!r}' - ) + raise ValueError(f'Layout has {len(strides)} axes but dim has {ndim}: {layout_str!r}') return strides @@ -285,9 +284,7 @@ def from_fileobj(cls, fileobj) -> MifHeader: """ first_line = _readline(fileobj).decode('latin-1').rstrip('\n\r') if first_line != 'mrtrix image': - raise ValueError( - f'Not a MIF file (expected "mrtrix image", got {first_line!r})' - ) + raise ValueError(f'Not a MIF file (expected "mrtrix image", got {first_line!r})') shape = None zooms = None @@ -312,7 +309,7 @@ def from_fileobj(cls, fileobj) -> MifHeader: colon = line.index(':') key = line[:colon].strip() - value = line[colon + 1:].strip() + value = line[colon + 1 :].strip() if not key or not value: continue diff --git a/test/test_fixels_utils.py b/test/test_fixels_utils.py index 7c51551..670a28b 100644 --- a/test/test_fixels_utils.py +++ b/test/test_fixels_utils.py @@ -64,11 +64,11 @@ def test_mif_to_h5_results( h5_file = out_dir / 'stat-alpha.h5' assert ( mif_to_h5( - index_file=index_file, - directions_file=directions_file, - cohort_file=temp_cohort_file, - output=h5_file, - ) + index_file=index_file, + directions_file=directions_file, + cohort_file=temp_cohort_file, + output=h5_file, + ) == 0 ) From f6656283e24d728122120fff6b2cbbb6dd2ba22d Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Fri, 27 Mar 2026 16:35:16 -0400 Subject: [PATCH 04/10] Update conftest.py --- test/conftest.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/test/conftest.py b/test/conftest.py index ec4a05c..aab127f 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -22,6 +22,23 @@ _FIXEL_DATA_DIR_ENV = 'MODELARRAYIO_FIXEL_TEST_DATA_DIR' +def _find_extracted_fixel_data_dir(destination_dir: Path) -> Path: + """Locate the extracted fixel dataset directory after unpacking.""" + preferred_names = ('data_fixel_toy', 'mif_test_data') + for name in preferred_names: + candidate = destination_dir / name + if candidate.exists(): + return candidate + + child_dirs = sorted(path for path in destination_dir.iterdir() if path.is_dir()) + if len(child_dirs) == 1: + return child_dirs[0] + + raise FileNotFoundError( + f'Could not determine extracted fixel dataset directory in {destination_dir}' + ) + + def _download_and_extract_fixel_test_data(destination_dir: Path) -> Path: """Download and extract the fixel test dataset archive.""" source_dir = os.environ.get(_FIXEL_DATA_DIR_ENV) @@ -41,10 +58,7 @@ def _download_and_extract_fixel_test_data(destination_dir: Path) -> Path: with tarfile.open(archive_path, mode='r:gz') as archive: archive.extractall(destination_dir) # noqa: S202 - extracted_dir = destination_dir / 'mif_test_data' - if not extracted_dir.exists(): - raise FileNotFoundError(f'Expected extracted dataset at {extracted_dir}') - return extracted_dir + return _find_extracted_fixel_data_dir(destination_dir) @pytest.fixture(scope='session') @@ -54,4 +68,4 @@ def downloaded_fixel_data_dir(tmp_path_factory: pytest.TempPathFactory) -> Path: try: return _download_and_extract_fixel_test_data(destination_dir) except (FileNotFoundError, OSError, URLError, tarfile.TarError) as exc: - pytest.skip(f'Downloaded fixel test data unavailable: {exc}') + raise RuntimeError(f'Downloaded fixel test data unavailable: {exc}') from exc From c44c5aec08b55a556684d38728040e3991624735 Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Fri, 27 Mar 2026 16:36:31 -0400 Subject: [PATCH 05/10] Update conftest.py --- test/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/conftest.py b/test/conftest.py index aab127f..e6a5d5d 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -17,7 +17,7 @@ _ROOT = Path(__file__).resolve().parents[1] os.environ.setdefault('COVERAGE_RCFILE', str(_ROOT / 'pyproject.toml')) -_FIXEL_DATA_URL = 'https://upenn.box.com/s/aaauvwrsrvsj3yvdkl1b899wx1s8ih2f.tar.gz' +_FIXEL_DATA_URL = 'https://upenn.box.com/shared/static/aaauvwrsrvsj3yvdkl1b899wx1s8ih2f.tar.gz' _FIXEL_DATA_URL_ENV = 'MODELARRAYIO_FIXEL_TEST_DATA_URL' _FIXEL_DATA_DIR_ENV = 'MODELARRAYIO_FIXEL_TEST_DATA_DIR' From cd0978364137500217d0e8cba53944abfc9f191c Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Fri, 27 Mar 2026 16:39:34 -0400 Subject: [PATCH 06/10] Update tox.yml --- .github/workflows/tox.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml index 2ac5cc6..ac46098 100644 --- a/.github/workflows/tox.yml +++ b/.github/workflows/tox.yml @@ -3,7 +3,7 @@ name: Tox on: push: branches: - - "*" + - main pull_request: branches: - main From a9201d167b484a870e080d86961aab5e440b1baa Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Fri, 27 Mar 2026 16:40:29 -0400 Subject: [PATCH 07/10] Update test_fixels_utils.py --- test/test_fixels_utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/test_fixels_utils.py b/test/test_fixels_utils.py index 670a28b..28f63ac 100644 --- a/test/test_fixels_utils.py +++ b/test/test_fixels_utils.py @@ -47,12 +47,16 @@ def test_mif_to_h5_results( tmp_path_factory: pytest.TempPathFactory, downloaded_fixel_data_dir: Path ) -> None: """Test mif-to-h5 and h5-to-mif conversion, mimicking a ModelArray analysis.""" + import os + # Step 1: Prepare inputs for conversion out_dir = tmp_path_factory.mktemp('data_fixel_toy') in_dir = downloaded_fixel_data_dir index_file = in_dir / 'index.mif' directions_file = in_dir / 'directions.mif' cohort_file = in_dir / 'stat-alpha_cohort.csv' + if not index_file.exists(): + raise FileNotFoundError(f'Contents of {out_dir}:\n{os.listdir(out_dir)}') # Prepend absolute path to source files in cohort file cohort_df = pd.read_csv(cohort_file) From ca97da7cdd86b198adfa032c336e812a99f20fd9 Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Fri, 27 Mar 2026 16:42:27 -0400 Subject: [PATCH 08/10] Update test_fixels_utils.py --- test/test_fixels_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_fixels_utils.py b/test/test_fixels_utils.py index 28f63ac..84b6ddf 100644 --- a/test/test_fixels_utils.py +++ b/test/test_fixels_utils.py @@ -56,7 +56,7 @@ def test_mif_to_h5_results( directions_file = in_dir / 'directions.mif' cohort_file = in_dir / 'stat-alpha_cohort.csv' if not index_file.exists(): - raise FileNotFoundError(f'Contents of {out_dir}:\n{os.listdir(out_dir)}') + raise FileNotFoundError(f'Contents of {in_dir}:\n{os.listdir(in_dir)}') # Prepend absolute path to source files in cohort file cohort_df = pd.read_csv(cohort_file) From cbb12ebf4e931657ec2ca408d75f55b14a3c83df Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Fri, 27 Mar 2026 16:44:43 -0400 Subject: [PATCH 09/10] Update conftest.py --- test/conftest.py | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/test/conftest.py b/test/conftest.py index e6a5d5d..7b0c290 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -22,23 +22,6 @@ _FIXEL_DATA_DIR_ENV = 'MODELARRAYIO_FIXEL_TEST_DATA_DIR' -def _find_extracted_fixel_data_dir(destination_dir: Path) -> Path: - """Locate the extracted fixel dataset directory after unpacking.""" - preferred_names = ('data_fixel_toy', 'mif_test_data') - for name in preferred_names: - candidate = destination_dir / name - if candidate.exists(): - return candidate - - child_dirs = sorted(path for path in destination_dir.iterdir() if path.is_dir()) - if len(child_dirs) == 1: - return child_dirs[0] - - raise FileNotFoundError( - f'Could not determine extracted fixel dataset directory in {destination_dir}' - ) - - def _download_and_extract_fixel_test_data(destination_dir: Path) -> Path: """Download and extract the fixel test dataset archive.""" source_dir = os.environ.get(_FIXEL_DATA_DIR_ENV) @@ -58,7 +41,7 @@ def _download_and_extract_fixel_test_data(destination_dir: Path) -> Path: with tarfile.open(archive_path, mode='r:gz') as archive: archive.extractall(destination_dir) # noqa: S202 - return _find_extracted_fixel_data_dir(destination_dir) + return destination_dir @pytest.fixture(scope='session') From 1febcfa0b8f8092327f9e9cb2dee117dcb821989 Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Fri, 27 Mar 2026 17:21:06 -0400 Subject: [PATCH 10/10] Fix up. --- src/modelarrayio/utils/fixels.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modelarrayio/utils/fixels.py b/src/modelarrayio/utils/fixels.py index 25a6f89..0400b9c 100644 --- a/src/modelarrayio/utils/fixels.py +++ b/src/modelarrayio/utils/fixels.py @@ -486,7 +486,7 @@ def get_best_affine(self) -> np.ndarray: origin = self._transform[:, 3].copy() for i, s in enumerate(self._layout): - if i < 3 and s < 0: + if i < 3 and s < 0 and self._shape[i] > 1: # disk voxel 0 on this axis = mrtrix voxel (dim_i - 1) origin += rotation_cols[:, i] * (self._shape[i] - 1) rotation_cols[:, i] = -rotation_cols[:, i]