Skip to content

Refactor(io): AFNI and MRtrix images hold a record and a lazy raw array (#415, pass 3b) - #425

Merged
balbasty merged 24 commits into
mainfrom
claude/feat/415-afni-mrtrix
Oct 12, 2026
Merged

balbasty merged 24 commits into
mainfrom
claude/feat/415-afni-mrtrix

Conversation

@balbasty

@balbasty balbasty commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Pass 3b of #415. It is stacked on #424 (pass 3a, MGH) → #423 → #422 → #421 → #419 → #418 → #417. Design: #416 (docs/design/format-records.md).

AFNI (.HEAD/.BRIK) and MRtrix (.mif/.mih) images now hold a record of their header and a lazy array, instead of inheriting AfniReaderWriter and MrtrixReaderWriter.

What changes

  • AFNI (io.common.afni):
    • AfniRaw, formerly AfniHeader, holds the attributes of the .HEAD file and the text they were read from.
    • AfniMetadata(MetadataFormat) is new and registered.
    • AfniFormat is the shared file-name logic.
    • The BRIK is read by a lazy proxy, .BRIK or .BRIK.gz, that holds no open file.
  • MRtrix (io.common.mrtrix):
    • MrtrixRaw, formerly MrtrixHeader, holds the keys and the bytes they were read from.
    • MrtrixMetadata(MetadataFormat) is new and registered.
    • The data is read by a lazy proxy that applies the stored layout.
  • AfniImage and MrtrixImage:
    • Each holds raw and metadata.
    • data is a cached view of raw. It follows the array backend, is lazy with Dask, and is read-only with NumPy.
    • shape and ndim come from raw without reading the voxels. system is read-only.
    • The axis changes that depend on the record stay inside the proxies: AFNI drops the axis of a single sub-brick, and MRtrix applies its layout.
  • Stored header text is written back as read for as long as a re-parse shows it still describes the record. This keeps AFNI's spacing and MRtrix's comments and padding.

Write precedence

  • AFNI, to_raw(view, datatype, attributes):
    1. The attributes that describe how the BRIK stores the voxels come from the data. An untouched BRIK keeps its own.
    2. The geometry is kept as stored when the voxel-to-DICOM matrix and the shape are exactly unchanged. Otherwise it is encoded again.
    3. The view comes from view=, then the file name, then the world space, then the record.
    4. attributes= is applied last. It refuses the storage attributes and the geometry attributes (ORIGIN, DELTA, ORIENT_SPECIFIC, IJK_TO_DICOM, IJK_TO_DICOM_REAL, SCENE_DATA) with WriterError.
    5. If nothing changed, the held record is returned. Otherwise the dataset gets a new identity code.
  • MRtrix, to_raw(layout, datatype, scaling, keyval):
    1. The storage keys come from the lazy array when the options match what it stores. Otherwise they come from the options.
    2. vox and transform are kept when the geometry and the shape are exactly unchanged.
    3. keyval= is merged in. It refuses dim, vox, layout, datatype, transform, scaling and file.
    4. If the header is unchanged, the held record is returned.

Visible behaviour changes

  • Removed: AfniReaderWriter, AfniHeader, MrtrixReaderWriter, MrtrixHeader, and the .header, ._header and .dataobj attributes of both image classes. The record is image.metadata.raw. MrtrixRaw.from_fileobj returns the record instead of a (header, nbytes) tuple.
  • data is cached, read-only and follows the array backend. Before, it was always a NumPy array or a memory map.
  • An untouched dataset is written byte for byte. This includes both AFNI files and nibabel's AFNI test files; compressed files are identical after decompression. AFNI keeps IDCODE_STRING and IDCODE_DATE when untouched; before, every save gave a new identity.
  • Overrides that the data or the geometry decide are refused with WriterError. Before, MRtrix dropped them silently.
  • Loading a compressed file no longer reads the voxels.
  • Saving under another BRIK compression keeps the image readable. A save that removes the BRIK the image reads points the image at the new BRIK.
  • An AFNI dataset can be saved over its own files.

Tests

  • AFNI is a conformance exemplar as .HEAD, with .BRIK and .BRIK.gz variants, and MRtrix as .mif, with a .mif.gz variant. All 12 checks pass.
    • A new harness field, Exemplar.header_file, serves formats made of two files.
    • AFNI refuses from_bytes, because one stream cannot hold a dataset.
    • .mih is tested separately, because its header names its data file.
  • New tests count voxel reads. Load, io.load, shape, ndim, repr, transformations, system, to_raw and pickling read none. Accessing data under Dask reads none, and computing it reads the file once.
  • Other new tests cover:
    • each write option and each refusal;
    • NaN attributes;
    • saves that remove the BRIK the image reads.

Review

Fable reviewed this pass with about 240 probes. It required two fixes:

  • Data loss on save: a save under another compression removed the BRIK that the loaded image still read.
  • Double read under Dask: Dask read the whole file twice, because the proxies decoded everything for its empty probe.

Both are fixed in c7e2a4e, with its nits. The tensions are logged in #416. One is open and shared with MGH: replace turns the proxy into an array.

Checks

  • pytest (from /tmp, Python 3.8): 5968 passed, 95 skipped, 1 xpassed.
  • ruff check, ruff format --check and codespell are clean.

Part of #415.

Changes after review

After the first review, the stack gained these changes. They are merged into every PR from #417 up, and the design note (#416) is updated to match.

  • Review comments on Feat(io): metadata records and the format conformance tests (#415, pass 1a) #417–Refactor(io): NIfTI transformations hold a record and a lazy raw array (#415, pass 1c) #419.
    • smartproperty is hardened: every setter drops its own cache, and invalidates= takes names or invalidators.
    • The view modules moved next to the formats that use them.
    • from_any is defined once on TransformationFormat. One relabelling converter is registered explicitly for each format family; there is no converter priority.
    • The redundant KwOnly markers on transformations are removed.
  • bagof-magic 0.3.dev3.
    • A format that decodes its transformations takes them over privately: _transformations = Inherit("transformations", default=None). None decodes from the record, and [] is honoured.
    • Forget replaces the ClassVar workarounds, inherited defaults use x: ... = v, and unset="empty" is gone.
  • Pending data and consolidate().
    • Assigning data or transformations never touches raw or metadata.
    • consolidate(*, inplace=False, **options) is the only public encoding API. to_raw() is removed.
    • save, to_bytes and to_fileobj write a consolidated copy and leave the object unchanged. The exception is a save over the file the object itself reads (Saving an uncompressed NIfTI or MGH image over its own file destroys its data #426), after which the object equals load(path).
    • Transformations are compared with a fresh decode at consolidation, so reading them alone keeps the file byte-identical, and in-place edits are written.
    • A copy consolidated with a storage override holds exactly what the file holds.

The conformance harness gained checks 15–19, which cover consolidation, saving over the object's own file, storage overrides, and the transformations list.

🤖 Generated with Claude Code

https://claude.ai/code/session_01M3KmPTJ2CihFMCFaJ4twx8


Generated by Claude Code


Generated by Claude Code

…#415)

AfniReaderWriter is removed. AfniHeader becomes the AfniRaw record, which
keeps the text of the .HEAD file so that an untouched dataset keeps the
bytes of both files, and AfniMetadata reads and writes it. AfniImage
holds a lazy _BrikProxy in raw and the metadata; to_raw returns the held
record when nothing changed, keeps stored geometry when it is exactly
unchanged, takes the BRIK layout from the data, and refuses layout
attributes. The conformance harness covers AFNI with .BRIK variants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3KmPTJ2CihFMCFaJ4twx8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3KmPTJ2CihFMCFaJ4twx8
claude added 19 commits October 11, 2026 14:08
…tore_data_argument (#415)

The data of AfniImage and MrtrixImage is a lazyproperty whose setter
drops its own cache, the metadata invalidates the transformations by a
single name, and the constructors store data= with store_data_argument.
The AFNI writer drops the data cached from a removed BRIK with
drop_cache. Each pair of views moves beside its image format, under
io/images, while the lazy arrays that they read stay in io/common. The
public methods of the formats and of their metadata document their
parameters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3KmPTJ2CihFMCFaJ4twx8
… encoders (#415)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3KmPTJ2CihFMCFaJ4twx8
…vately; None decodes from the record (#415)

`AfniImage` and `MrtrixImage` follow `NiftiImage`: each takes the
`transformations` field over with `Inherit("transformations",
default=None)`, and its decoding property no longer reads an empty list
as unset. `None` decodes the record, and an empty list is honoured: the
image is written as an image built from data alone (AFNI: the identity
to its DICOM coordinates; MRtrix: the identity transform).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3KmPTJ2CihFMCFaJ4twx8
…_raw is gone (#415)

`AfniImage` and `MrtrixImage` take `data` over as `_data`, so assigned
data is pending and `raw` keeps the lazy array; `store_data_argument`
and their `__post_init__` are gone. `AfniImage.to_raw(view, datatype,
attributes)`, `MrtrixImage.to_raw(layout, datatype, scaling, keyval)`
and the `to_raw()` of their metadata are replaced by
`consolidate(inplace=False, **options)` with the same options, which
encodes the pending data, geometry and options into a copy with a new
`raw` and record, and keeps the metadata when the encoding changes
nothing. The writers write the consolidated copy and leave the image as
it was; the MRtrix writer sets the `file` entry of the file it writes.

An AFNI save that removes the BRIK that the image reads writes over the
image's own dataset (#426): the image is consolidated in place with the
options, and its lazy array then reads the new BRIK. The encoders and
`shape` read pending data before `raw`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3KmPTJ2CihFMCFaJ4twx8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3KmPTJ2CihFMCFaJ4twx8

# Conflicts:
#	tests/test_io_conformance.py
…hold what their file holds (#415)

An override of the stored type kept the unconverted array as `raw`, so
the copy held values that the file does not hold, since the writers
round values stored as integers. AFNI now encodes such voxels with
`encode_bricks` and decodes them back. MRtrix encodes them with the
payload of the writer, through the intensity scaling, and `raw` is then
a lazy array of those bytes, which reads them as a file is read. Voxels
of the stored type, and lazy arrays of a file, are kept as they are.
Both exemplars gain the `datatype` storage case, and `consolidate`
takes `inplace` by keyword only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3KmPTJ2CihFMCFaJ4twx8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3KmPTJ2CihFMCFaJ4twx8

# Conflicts:
#	tests/test_io_conformance.py
…onsolidate (#415)

Each image stores the transformations that it decodes from its record
on first access, so a change made to the list in place is kept, and
`consolidate` compares the list that it holds with a fresh decode of
the record, exactly. When they are the same, or were never read, an
image with nothing else pending is left as it is, and the encoders
already keep the geometry of the record when the matrix is the one
that the record decodes to. Assigning metadata drops the list, and the
encoders read the list without storing it, so that a write leaves the
image as it was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3KmPTJ2CihFMCFaJ4twx8
`AfniImage` and `MrtrixImage` keep the base lists of this branch,
which no longer start with a file reader: their first bagof base is
`ImageFormat`, which is keyword-only, so the repeated `kw_only=True`
and its comment are dropped, and so are the `KwOnly[...]` wrappers of
`raw` and `_metadata`. Both signatures stay `(data, transformations,
*, raw, metadata)`, which the `IMAGES` table now pins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3KmPTJ2CihFMCFaJ4twx8
Base automatically changed from claude/feat/415-mgh to main October 12, 2026 21:32
@balbasty
balbasty merged commit c579936 into main Oct 12, 2026
5 checks passed
@balbasty
balbasty deleted the claude/feat/415-afni-mrtrix branch October 12, 2026 21:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants