Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
96555eb
Refactor(io): NIfTI transformations hold a record and a lazy raw arra…
claude Oct 9, 2026
7ac5981
Refactor(io): shared NIfTI writer helpers live in io.common.nifti (#415)
claude Oct 10, 2026
01a6565
Fix(io): a NIfTI affine keeps the code of each form when written (#415)
claude Oct 10, 2026
c7eb27a
Refactor(io): move the NIfTI and ITK views beside their formats; nump…
claude Oct 11, 2026
a3abb81
Merge branch claude/feat/415-nifti-image into claude/feat/415-nifti-x…
claude Oct 11, 2026
b033ff6
Refactor(io): NIfTI transformation data views are lazyproperties with…
claude Oct 11, 2026
6838c6c
Refactor(io): NiftiBasedTransformation stores data= with store_data_a…
claude Oct 11, 2026
c7ac849
Refactor(io): drop the redundant KwOnly wrappers of NIfTI transformat…
claude Oct 11, 2026
9f9d7ca
Merge branch claude/feat/415-nifti-image into claude/feat/415-nifti-x…
claude Oct 11, 2026
53d2e9c
WIP(io): one relabelling converter for formats without exact converte…
claude Oct 11, 2026
dd77286
WIP(io): LTA relabels through the shared converter; tests for one con…
claude Oct 11, 2026
8648165
Merge branch claude/feat/415-nifti-image into claude/feat/415-nifti-x…
claude Oct 11, 2026
40e792f
Refactor(io): formats register the relabelling converter explicitly p…
claude Oct 11, 2026
f3a486f
Merge branch claude/feat/415-nifti-image into claude/feat/415-nifti-x…
claude Oct 12, 2026
3c59f9c
Merge branch claude/feat/415-nifti-image into claude/feat/415-nifti-x…
claude Oct 12, 2026
5454bf5
Merge branch claude/feat/415-nifti-image into claude/feat/415-nifti-x…
claude Oct 12, 2026
23b4d46
Merge branch claude/feat/415-nifti-image into claude/feat/415-nifti-x…
claude Oct 12, 2026
7af9c60
Refactor(io): NIfTI transformations keep data pending until consolida…
claude Oct 12, 2026
fb28eeb
Merge branch claude/feat/415-nifti-image into claude/feat/415-nifti-x…
claude Oct 12, 2026
21e18a7
Merge branch claude/feat/415-nifti-image into claude/feat/415-nifti-x…
claude Oct 12, 2026
7c399a3
Fix(io): NIfTI transformations hold what their file holds; FNIRT refu…
claude Oct 12, 2026
751cb57
Merge branch claude/feat/415-nifti-image into claude/feat/415-nifti-x…
claude Oct 12, 2026
93d2c11
Merge branch claude/feat/415-nifti-image into claude/feat/415-nifti-x…
claude Oct 12, 2026
a965b51
Merge branch claude/feat/415-nifti-image into claude/feat/415-nifti-x…
claude Oct 12, 2026
d1a07da
Merge 415-nifti-image (origin/main #428, #416) into 415-nifti-xforms
claude Oct 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/api/io/base/parsers.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ replaced by `FileReader`, `TextFileReader`, and `BinaryFileReader`. Replace
the combined `*FileParserWriter` bases with the corresponding reader and
writer bases explicitly. Format-specific classes use `*Reader` when they only read and
`*ReaderWriter` when they implement both routes. For example, `MincReader`
only reads, while `LtaReaderWriter` and `NiftiReaderWriter` also write.
only reads, while `LtaReaderWriter` and `MghReaderWriter` also write.

The `FileBased*` and `WritableFileBased*` wrappers are removed. Use `Format`,
`ImageFormat`, or `TransformationFormat` and obtain writing from the native
Expand Down
1 change: 1 addition & 0 deletions docs/api/io/transformations/spm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# ::: brainhops.io.transformations.spm
1 change: 0 additions & 1 deletion docs/api/io/transformations/spmy.md

This file was deleted.

13 changes: 9 additions & 4 deletions docs/start/python.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ The format classes can also be used directly:
from brainhops.io.images.nifti import NiftiImage
from brainhops.io.transformations.freesurfer.lta import LtaTransformation
from brainhops.io.transformations.nifti import NiftiRASDisplacementField
from brainhops.io.transformations.spm.y import SpmCoordinatesField
from brainhops.io.transformations.spm import SpmCoordinatesField

src = NiftiImage.load("source.nii.gz")
dst = NiftiImage.load("dest.nii.gz")
Expand Down Expand Up @@ -132,9 +132,14 @@ Every family with an affine form, such as a `Scaling` or a
coordinate systems, such as `VoxelToRAS`, accepts only endpoints that
are compatible with those systems. A transformation between other
systems is therefore refused with a `ConversionError`, rather than given
the class with its parameters unchanged. A format that has no exact
conversion yet, and no systems to check, refuses `t.to(Format)` in the
same way.
the class with its parameters unchanged. A format that reads its
coordinate systems from what it holds, such as LTA, and has no exact
conversion yet (#312) is given the parameters of the transformation
unchanged, which is called relabelling, but only when the coordinate
systems of the transformation are not known or are compatible with
those of the format. `t.to(Format)`, `Format.from_any(t)` and
`Format.from_instance(t)` give the same result, or raise the same
error.

An LTA file states which coordinate systems its affine maps between. A
general `Affine` is therefore written to an LTA file only if its `input`
Expand Down
7 changes: 7 additions & 0 deletions src/brainhops/datamodel/_transformations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ def __post_init__(self, arguments: tx.Any) -> None:
)
_mutually_exclusive(self, arguments, mutex)
self._set_derived_fields(arguments)
# A concrete file format lists the data model before its format
# class, so the format class comes after this class in the method
# resolution order. It finishes the construction here, when it
# defines `__post_init__`.
post_init = getattr(super(), "__post_init__", None)
if post_init is not None:
post_init(arguments)

def _set_derived_fields(self, arguments: tx.Any) -> None:
for name in self.derived_fields:
Expand Down
27 changes: 27 additions & 0 deletions src/brainhops/datamodel/_transformations/compute/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,33 @@ def convert(x: FROM, cls: tx.Type[TO], **kwargs: tx.Any) -> TO:
return result


def find_converter(x: Transformation, cls: type) -> tx.Optional[Converter]:
"""Return the converter that `convert(x, cls)` runs.

The converter is chosen as [`convert`][] chooses it, but it is not
run. A caller can thus tell which kind of conversion a request would
make, for example whether a file format has a converter of its own
for `x`.

Parameters
----------
x : Transformation
Transformation to convert.
cls : type of Transformation
Requested transformation type.

Returns
-------
Converter or None
The converter, or `None` if no converter applies or several
apply equally.
"""
try:
return _convert.dispatch(x, cls).function
except (NoMethodError, AmbiguousMethodError):
return None


def _key_from_func(func: Converter) -> tx.Tuple[type, type]:
"""Read the input and output types from the hints of a converter.

Expand Down
12 changes: 8 additions & 4 deletions src/brainhops/datamodel/_transformations/concrete.py
Original file line number Diff line number Diff line change
Expand Up @@ -1216,17 +1216,21 @@ def _copy_map(
turned back into a tangent with a logarithm.
"""
out_tangent = bool(kwargs.get("log", _is_tangent(cls)))
# The map is read only when the caller did not give it, because reading
# it may decode a whole file.
if out_tangent == bool(getattr(other, "log", False)):
kwargs.setdefault("data", other.data)
if "data" not in kwargs:
kwargs["data"] = other.data
elif out_tangent:
# The output class holds the tangent and `other` holds the map. The
# map is passed through the view, and the output class stores its
# logarithm.
kwargs.setdefault(view, getattr(other, view))
else:
if view not in kwargs:
kwargs[view] = getattr(other, view)
elif "data" not in kwargs:
# The output class holds the map, which the view of a tangent
# already gives.
kwargs.setdefault("data", getattr(other, view))
kwargs["data"] = getattr(other, view)
if "log" in cls.metadata_fields:
kwargs.setdefault("log", out_tangent)

Expand Down
41 changes: 29 additions & 12 deletions src/brainhops/io/base/_save.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from brainhops._core import path
from brainhops.datamodel.base import DataModelBase
from brainhops.datamodel.transformations import Transformation
from brainhops.errors import ConversionError
from brainhops.io.base._base import Format
from brainhops.io.base._dispatch import _match_name, _tiers, _to_filename
from brainhops.io.base.parsers import (
Expand Down Expand Up @@ -69,17 +70,17 @@ def save(obj: tx.Any, file: path.FileLike, **kwargs) -> None:
!!! note "Two passes, one conversion"
Steps 3 and 4 are two passes only until every writable
transformation format has exact converters (#312). Until then,
step 3 writes a transformation to a format without exact
converters by copying it into a file-backed version of its data
model. Step 4 refuses to rebuild a transformation as such a
format with the same parameters, because the result could mean
a different map. For the formats that have exact converters
(`NiftiVoxelToRAS`, `NiftiRASToVoxel`,
`NiftiRASDisplacementField`, `NiftiRASCoordinatesField`,
`SpmCoordinatesField`), `from_instance` is `obj.to(Format)`, so
both passes run the same conversion. `NiftyRegAffine` converts
an affine between world spaces, or between unknown spaces, in
step 4.
a format without an exact converter holds a transformation only
by relabelling it, that is, by being rebuilt from its
parameters, when the coordinate systems allow it. Step 3 writes
a transformation in such a format only if the format is a
file-backed version of its data model. Step 4 converts it only
to the formats that convert it exactly, so that the formats
that would only relabel it do not make the choice ambiguous.
For every transformation format, `from_instance` is
`obj.to(Format)`, so both passes run the same conversion.
`NiftyRegAffine` converts an affine between world spaces, or
between unknown spaces, in step 4.

Parameters
----------
Expand Down Expand Up @@ -163,7 +164,23 @@ def _copy(obj: tx.Any, fmt: type) -> tx.Any:


def _convert(obj: Transformation, fmt: type) -> tx.Any:
"""`obj` converted to `fmt`, as `obj.to(fmt)` converts it."""
"""`obj` converted exactly to `fmt`, as `obj.to(fmt)` converts it.

A format without an exact converter for `obj` may relabel it (#312).
Such a format is not chosen by this pass, so that a format that
converts `obj` exactly is not made ambiguous by the formats that
would only relabel it.
"""
# The transformation formats import this package, so their helper is
# imported when it is used.
from brainhops.io.transformations.base._converters import relabels

if relabels(obj, fmt):
raise ConversionError(
f"{fmt.__name__} has no exact converter for a "
f"{type(obj).__name__} (#312), and a transformation is saved "
f"by relabelling it only in a format of its own data model."
)
return obj.to(fmt)


Expand Down
3 changes: 1 addition & 2 deletions src/brainhops/io/common/mgh/_parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,7 @@ class MghReaderWriter(
"""
The base class of objects encoded as MGH or MGZ files.

As in [`NiftiReaderWriter`][brainhops.io.common.nifti.NiftiReaderWriter],
the object holds a nibabel image or header, together with the raw
The object holds a nibabel image or header, together with the raw
`goodRASFlag` and the trailing tags. It exposes the voxels, their
coordinate system, and the voxel-to-RAS matrices that FreeSurfer
derives from the header.
Expand Down
10 changes: 4 additions & 6 deletions src/brainhops/io/common/nifti/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,18 @@

The header of a NIfTI file is held as a [`NiftiRaw`][] record, and
[`NiftiMetadata`][] reads and writes that header alone, without the
voxels. [`NiftiImage`][brainhops.io.images.nifti.NiftiImage] holds such
metadata and a lazy array of the voxels. The transformation formats
stored as NIfTI files are still built on [`NiftiReaderWriter`][], which
holds a nibabel image and its header.
voxels. [`NiftiImage`][brainhops.io.images.nifti.NiftiImage] and the
transformations of
[`brainhops.io.transformations.nifti`][brainhops.io.transformations.nifti]
hold such metadata and a lazy array of the voxels.
"""

__all__ = [
"NiftiMetadata",
"NiftiRaw",
"NiftiReaderWriter",
"NiftiUnitWarning",
]

from ._metadata import NiftiMetadata
from ._parsers import NiftiReaderWriter
from ._raw import NiftiRaw
from ._units import NiftiUnitWarning
83 changes: 29 additions & 54 deletions src/brainhops/io/common/nifti/_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@
# stdlib
import gzip
import inspect
from io import BytesIO
from urllib.parse import urlsplit

# dependencies
import nibabel as nb
import numpy as np
import typing_extensions as tx
from nibabel.arrayproxy import ArrayProxy

from brainhops._core import path
from brainhops._core.streams import open_compressed
from brainhops._core.typing import ArrayProtocol
from brainhops.backends import get_array_backend

# ----------------------------------------------------------------------
# READING AND WRITING THROUGH A PATH
Expand Down Expand Up @@ -156,37 +159,6 @@ def _nifti_from_stream(
raise


def _load_nifti(
file: path.FilenameLike, **kwargs
) -> tx.Union[nb.Nifti1Image, nb.Nifti2Image]:
"""Load a NIfTI image from a local or remote path.

A local file is opened by nibabel and memory-mapped. A remote file is read
into memory, because the voxels would otherwise be read after the stream is
closed.
"""
if _is_local(file):
filename = str(path.Path(file))
with open(filename, "rb") as f:
image_class = _NIFTI_IMAGES[_nifti_version(f)]
read = image_class.from_filename
return read(filename, **_accepted(read, kwargs))
with path.Path(file).open("rb") as f:
buffer = BytesIO(f.read())
return _nifti_from_stream(buffer, **kwargs)


def _load_nifti_header(
file: path.FilenameLike,
) -> tx.Union[nb.Nifti1Header, nb.Nifti2Header]:
"""Read the header of a NIfTI file without reading the voxels."""
if _is_local(file):
return _load_nifti(file).header
with path.Path(file).open("rb") as f:
header_class = _NIFTI_HEADERS[_nifti_version(f)]
return header_class.from_fileobj(open_compressed(f))


def _save_nifti(
image: tx.Union[nb.Nifti1Image, nb.Nifti2Image], file: path.FilenameLike
) -> None:
Expand All @@ -209,26 +181,29 @@ def _save_nifti(
_image_to_stream(image, f)


def _like_header(like: tx.Any) -> tx.Optional[nb.Nifti1Header]:
"""Return the header to copy from a `like` template, or `None`.

The template is a nibabel header or image, an object with a header, an
object whose metadata holds a NIfTI record, such as a `NiftiImage`, or
the path of a NIfTI file.
# ----------------------------------------------------------------------
# READING THE VOXELS
# ----------------------------------------------------------------------
# The views of the NIfTI-based formats convert between `raw`, the array as
# the file stores it, and `data`, the array that the data model exposes.
# They are defined beside each format, and they all read the stored array
# with the function below.


def _read(raw: ArrayProtocol) -> ArrayProtocol:
"""Return an array from a stored array, which may be a nibabel proxy.

A nibabel proxy, which reads the voxels lazily, is read with the array
backend, which loads it with NumPy and keeps it lazy with Dask. The
NumPy array that it gives is made read-only, because it is a view that
the writer does not read back: the writer writes `raw`, so an edit in
place would be lost. Any other array is returned as it is, so that a
Dask array stays lazy and an array that was set as data stays the
array of `raw`.
"""
if like is None:
return None
if isinstance(like, (nb.Nifti1Header, nb.Nifti2Header)):
return like
if isinstance(like, (nb.Nifti1Image, nb.Nifti2Image)):
return like.header
record = getattr(getattr(like, "metadata", None), "raw", None)
header = getattr(record, "header", None)
if isinstance(header, (nb.Nifti1Header, nb.Nifti2Header)):
return header
header = getattr(like, "header", None)
if header is not None:
return header
if isinstance(like, (str, path.PathLike)):
return _load_nifti_header(like)
return None
if not isinstance(raw, ArrayProxy):
return raw
data = get_array_backend().asarray(raw)
if isinstance(data, np.ndarray):
data.flags.writeable = False
return data
Loading
Loading