Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
30 changes: 24 additions & 6 deletions docs/start/python.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,29 @@ io.save(img, "copy.nii.gz") # -> NIfTI
io.save(img, "copy.zarr") # -> Zarr
```

An image computed in memory is written in the same way, since NIfTI and
Zarr files both hold a plain image. However, `save` never changes the
meaning of an object to fit a format. A general `Affine`, for instance, is
not written as the voxel-to-RAS affine that a NIfTI file holds, because it
would be read back as something it did not say. When a file should hold
that format, the format is built explicitly:
An image computed in memory is written the same way, since NIfTI and Zarr
both hold a plain image. A transformation is converted to the format the
name asks for, with the same converters as `t.to(Format)`, but only when
the format holds the very same map. Its `input` and `output` are read,
and bridged to the format's: an affine from voxels to LPS is written as
the voxel-to-RAS affine of a NIfTI file, with its first two axes
flipped, and an affine whose systems are not known is taken to map the
format's. A field of displacements is written as a NIfTI displacement
field, between its grid's world-to-voxel affine and its inverse, and a
field of coordinates as a NIfTI (or, with a `y_` prefix, an SPM) field of
coordinates:

```python
io.save(affine, "affine.nii.gz") # -> NiftiVoxelToRAS
io.save(warp, "warp.nii.gz") # -> NiftiRASDisplacementField
io.save(deformation, "y_deformation.nii") # -> SpmCoordinatesField
```

Nothing is approximated to fit a format. An affine between two world
spaces is not a voxel-to-RAS affine, and a field interpolated with cubic
splines is not the linearly interpolated values a NIfTI file holds, so
both are refused, with the reason each format gives. The same conversion
can be asked for explicitly, or the format can be built directly:

```python
import numpy as np
Expand All @@ -107,6 +124,7 @@ from brainhops.io.transformations.nifti import NiftiVoxelToRAS

affine = Affine(np.eye(3, 4))
NiftiVoxelToRAS.from_any(affine).save("affine.nii")
affine.to(NiftiVoxelToRAS) # the same conversion
```

An LTA file states which coordinate systems its affine maps between. A
Expand Down
133 changes: 100 additions & 33 deletions src/brainhops/io/base/_save.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from brainhops._core import path
from brainhops.datamodel.base import DataModelBase
from brainhops.datamodel.transformations import Transformation
from brainhops.io.base._base import WritableFileBasedObject
from brainhops.io.base._dispatch import _match_name, _tiers, _to_filename
from brainhops.io.base.parsers import (
Expand All @@ -27,26 +28,52 @@
def save(obj: tx.Any, file: path.FileLike, **kwargs) -> None:
"""Write an object to a file in the format that the file name calls for.

The format is chosen among the registered writable formats in three steps.

1. The formats declaring the longest extension that matches the file name
are the candidates: `a.ome.zarr` selects OME-Zarr, and `a.zarr` any Zarr
format. A format that requires a prefix is a candidate only if the name
has one.
2. If `obj` is already an instance of a candidate, it is written as it is.
3. Otherwise, `obj` is converted to a candidate that can hold it
losslessly, which is a file-backed version of the very data model that
`obj` instantiates and takes every field of that model. `NiftiImage` and
`ZarrImage`, for example, both hold a `SingleScaleImage`. If several
candidates can, the most specific one is chosen by the rules used for
reading, and equally specific candidates are an ambiguity.

!!! note "No conversion beyond the file format"
The object is never converted to another data model. A `Scaling` is not
turned into an `Affine`, and a general `Affine` is not turned into the
voxel-to-RAS affine of NIfTI, because it would be read back with a
different meaning. To write in such a format, build the format
explicitly, as in `NiftiVoxelToRAS.from_any(affine).save(file)`.
The format is chosen from the registered writable formats, in four
steps.

1. **The file name.** The formats that declare the longest of the
extensions the name ends with are the candidates, so `a.ome.zarr`
asks for OME-Zarr and `a.zarr` for any Zarr format. A format that
requires a prefix is a candidate only when the name has one of
its prefixes.
2. **The object's own format.** If `obj` already is of one of the
candidates, it is written as it is.
3. **A format that can hold the object as it is.** Otherwise, a
candidate can hold `obj` if it is a file-backed version of the
very data model `obj` is an instance of, and takes every field
that data model has (`NiftiImage` and `ZarrImage` are file-backed
`SingleScaleImage`s). `obj` is converted to it, which neither
loses nor changes anything.
4. **A format a transformation converts to.** Otherwise, a
transformation is converted to the candidates, with the same
converters as `obj.to(Format)`, and written in the one it
converts to. A converter returns the very map `obj` is, with
its endpoints bridged to the format's (an affine to LPS is
flipped into RAS), or refuses: a general `Affine` is written as
the voxel-to-RAS affine of a NIfTI file only when that is what it
maps, or when its coordinate systems are not known.

In steps 3 and 4, when several candidates can hold `obj`, the most
specific is used, by the rules reading uses: the longest prefix,
then the narrowest declaration, then `PRIORITY`. Two that are
equally specific are an ambiguity, and nothing is written.

!!! note "Nothing is approximated"
A transformation is written only in a format that holds it
exactly. One that no candidate holds -- a field interpolated
with cubic splines, to be written as NIfTI's linearly
interpolated values, say -- is refused with each candidate's
reason, rather than resampled.

!!! note "Two passes, one conversion"
Steps 3 and 4 are two passes only until every writable
transformation format has converters. Until then, step 3 is what
writes a transformation to a format without them, by copying it
into a file-backed version of its data model. For the formats
that have converters (`NiftiVoxelToRAS`, `NiftiRASToVoxel`,
`NiftiRASDisplacementField`, `NiftiRASCoordinatesField`,
`SpmCoordinatesField`), `from_instance` is `obj.to(Format)`, so
both passes run the same conversion.

Parameters
----------
Expand Down Expand Up @@ -101,29 +128,69 @@ def save(obj: tx.Any, file: path.FileLike, **kwargs) -> None:
holders = [
(fmt, match) for fmt, match in claimed if _holds(fmt, obj, reasons)
]
for tier in _tiers(holders):
writer = _first_writer(name, obj, holders, _copy, reasons)
# The second pass converts a transformation (see "Two passes, one
# conversion" above): the first stays for the formats without
# converters, and for those with them it runs the same conversion.
if writer is None and isinstance(obj, Transformation):
others = [
candidate for candidate in claimed if candidate not in holders
]
writer = _first_writer(name, obj, others, _convert, reasons)
if writer is not None:
writer.save(file, **kwargs)
return

formats = ", ".join(sorted(fmt.__name__ for fmt, _ in claimed))
detail = "".join(f"\n - {reason}" for reason in reasons)
raise WriterError(
f"Cannot write {type(obj).__name__} to {name!r}: none of the "
f"formats registered for it ({formats}) can hold it as it is. "
f"Build the format you want with its `from_any` and save "
f"that.{detail}"
)


def _copy(obj: tx.Any, fmt: type) -> tx.Any:
"""`obj` copied into `fmt`, a file-backed version of its data model."""
return fmt.from_instance(obj)


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


def _first_writer(
name: str,
obj: tx.Any,
candidates: tx.List[tx.Tuple[type, tx.Any]],
build: tx.Callable[[tx.Any, type], tx.Any],
reasons: tx.List[str],
) -> tx.Any:
"""
`obj` built into the most specific candidate format that takes it.

The candidates are tried tier by tier, most specific first, and
`build(obj, fmt)` makes the object to write. The first tier where it
succeeds for one format gives the writer; a tier where it succeeds
for several is an ambiguity. Why it fails for the others is added to
`reasons`. `None` when it fails for every candidate.
"""
for tier in _tiers(candidates):
writers = []
for fmt in tier:
try:
writers.append((fmt, fmt.from_instance(obj)))
writers.append((fmt, build(obj, fmt)))
except Exception as e: # noqa: BLE001
reasons.append(f"{fmt.__name__}: {type(e).__name__}: {e}")
if len(writers) > 1:
raise AmbiguousFormatError(
_ambiguity_message(name, obj, [fmt for fmt, _ in writers])
)
if writers:
writers[0][1].save(file, **kwargs)
return

formats = ", ".join(sorted(fmt.__name__ for fmt, _ in claimed))
detail = "".join(f"\n - {reason}" for reason in reasons)
raise WriterError(
f"Cannot write {type(obj).__name__} to {name!r}: none of the "
f"formats registered for it ({formats}) can hold it as it is. "
f"Build the format you want with its `from_any` and save "
f"that.{detail}"
)
return writers[0][1]
return None


def _model(cls: type) -> tx.Optional[type]:
Expand Down
3 changes: 2 additions & 1 deletion src/brainhops/io/transformations/base/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@
"TransformationFormat",
"AffineTransformationFormat",
"affines",
"conversions",
"fields",
"load",
"sniff",
]

from . import affines, fields
from . import affines, conversions, fields
from ._base import FileBasedTransformation, WritableFileBasedTransformation
from ._formats import AffineTransformationFormat, TransformationFormat
from ._load import load, sniff
Loading
Loading