Skip to content

Refactor(io): NRRD and MINC images hold a record and a lazy raw array; saves over a read file keep its data (#415, pass 3c) - #427

Open
balbasty wants to merge 20 commits into
claude/feat/415-afni-mrtrixfrom
claude/feat/415-nrrd-minc
Open

balbasty wants to merge 20 commits into
claude/feat/415-afni-mrtrixfrom
claude/feat/415-nrrd-minc

Conversation

@balbasty

Copy link
Copy Markdown
Contributor

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

NRRD (.nrrd/.nhdr) and MINC (MINC1 and MINC2) images now hold a record of their header and a lazy array, instead of inheriting NrrdReaderWriter and MincReader. This PR also fixes #426 for every image format, where a save over the file that an image reads destroyed its data.

What changes

  • NRRD (io.common.nrrd):
    • NrrdRaw, formerly NrrdHeader, holds the header fields and the text they were read from.
    • NrrdMetadata(MetadataFormat) is new and registered.
    • A lazy proxy reads the samples. It re-reads the header before each read, so it refuses a file whose storage changed.
    • Attached and detached files, the raw, gzip, bzip2 and hex encodings, skips and LIST data files are all supported.
  • MINC (io.common.minc):
    • MincRaw holds the dimensions, the voxel-to-world matrix and the version.
    • MincMetadata(MetadataFormat) is new and registered.
    • MINC1 headers are read by a small NetCDF classic header reader, _netcdf.py. It reads the header only, so the metadata loads without touching the voxels, from gzipped or cut files too.
    • MINC2 arrays are reopened by name.
    • MINC stays read-only. A writer would be a new feature.
  • NrrdImage and MincImage hold raw and metadata. They follow the same rules as the earlier images:
    • data is a cached view of raw, read-only with NumPy and lazy with Dask.
    • shape comes from raw.
    • system is read-only.
    • Nothing but data reads the voxels.
  • Shared file helpers in io/base/_utils_files.py: is_local, same_file, replace_file and write_file. These replace the copies in the NIfTI and NRRD modules.

Saves over the file an image reads (#426)

  • Every image writer now writes to a temporary file in the same directory and moves it over the target: NIfTI, MGH, AFNI, MRtrix and NRRD.
  • After such a save, the image points raw at the new file and drops its cached data, so it stays readable and can be saved again.
  • On main, io.load("x.nii").save("x.nii") wrote zeros, and .mgh/.mgz were left empty. Now every format keeps its data. The new tests/test_io_lazy_saves.py covers this under NumPy and Dask, with new storage options too.

Fixes #426.

Write precedence of NrrdImage.to_raw

  1. The storage fields are copied from the lazy array's record when the options match it. Otherwise they come from the options and the data. The record's spelling is kept when the storage is equivalent, for example short for int16.
  2. The geometry fields are kept as stored while the shape and the preferred transformation are exactly the decoded ones, with NaN counting as equal to NaN. Otherwise they are encoded again. space= on a world without an anatomical orientation raises WriterError.
  3. keyvalue is merged last. Invalid keys raise WriterError.
  4. If the result formats to the same text as the held record, the held record is returned.

Visible behaviour changes

  • Removed: NrrdReaderWriter, NrrdHeader, MincReader, and the .header, ._header and .dataobj attributes. Also removed are MINC's .dimensions, .vox2world and .version, which now live on image.metadata.raw. NrrdRaw.from_fileobj returns the record instead of a tuple.
  • Unknown reader options raise TypeError for every migrated image: NIfTI, MGH, AFNI, MRtrix, NRRD and MINC, and their metadata. Before, they were silently ignored.
  • An untouched NRRD file is written byte for byte. This includes comments, version 3 headers, foreign spellings, and both files of a detached image. Before, the writer reformatted the header.
  • When the samples are re-encoded, min and max are dropped.
  • A detached data file takes the header's name. It is named after the header plus the encoding's extension.
  • A MINC1 integer image without signtype is refused when read, as nibabel does.
  • A replaced file gets the permissions the umask gives a new file. Saves follow symbolic links.

Tests

  • NRRD (.nrrd, .nhdr) and MINC (MINC1, gzipped MINC1, MINC2) are conformance exemplars.
  • MINC asserts that it refuses writes: checks 5, 8 and 10, and the save step of check 9. It uses a new Exemplar.source hook to build its test files.
  • A new renamed field lets check 5 compare detached files saved under the same name.
  • Other new tests cover:
    • the encodings and their byte identity;
    • each write option;
    • the refusals;
    • voxel reads;
    • saves over the image's own file.

Review

Fable reviewed this pass and required four fixes, all done in 281647e:

  • data stayed cached after raw was re-pointed, in NRRD and also in MRtrix and AFNI.
  • space= was silently ignored on a world without an anatomical orientation.
  • MINC1 integers without a signtype were accepted.
  • Unknown reader options were silently ignored.

The review also found #426. Its nits are fixed, and the tensions are logged in #416.

Checks

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

Part of #415.

🤖 Generated with Claude Code

https://claude.ai/code/session_01M3KmPTJ2CihFMCFaJ4twx8


Generated by Claude Code

claude added 15 commits October 10, 2026 05:55
…#415)

NrrdReaderWriter is removed. The header becomes the NrrdRaw record,
which keeps the bytes it was read from, and NrrdMetadata reads and
writes it without the samples. NrrdImage holds `raw`, a lazy array that
reads the samples by name each time and presents them in image order,
and `metadata`. The writer encodes over the record: the storage follows
`raw`, the geometry of the record is kept while the preferred
transformation is the decoded one, and an untouched image is written
byte for byte, both files of a detached image included. Saves are
written to a temporary file and moved over the target, and an image
written over the files that its lazy array reads is pointed at the new
files.

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

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

MincReader is removed. The header becomes the MincRaw record (version,
dimensions and stored type), read without the voxels: brainhops reads
the NetCDF header of MINC1 itself, so that a gzipped or cut file can be
read, and h5py reads the groups and attributes of MINC2. MincMetadata
reads that record. MincImage holds `raw`, a lazy array that reads the
scaled voxels with nibabel by name, or from the bytes of a stream, and
`metadata`; `data` reverses the axes of `raw`. The `dimensions`,
`vox2world` and `version` attributes move to `metadata.raw`. MINC stays
read only, and the conformance checks of writing make sure it refuses.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3KmPTJ2CihFMCFaJ4twx8
…pe; MRtrix drops stale caches (#415)

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3KmPTJ2CihFMCFaJ4twx8
…spaces and MINC signtype (#415, #426)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3KmPTJ2CihFMCFaJ4twx8
… readers refuse unknown options (#415, #426)

Writers of NRRD, MRtrix, AFNI, MGH and NIfTI write each local file
beside its target and move it over the target (`replace_file` and
`write_file` in `io/base/_utils_files.py`, with `is_local` moved there
from the NIfTI files). An image written over a file that its lazy array
reads is pointed at the new file and drops its cached data, and the
writers read a lazy array directly rather than through `data`. This
fixes `.nii` files saved over themselves as zeros (#426).

The readers of the migrated images and their metadata refuse unknown
options with TypeError. NRRD refuses `space=` for a world without an
anatomical orientation, reports unknown types as WriterError and keeps
the spelling of the type of its source. MINC1 integer images without a
signtype are refused when the header is read, and NUL padding is
stripped from NetCDF text attributes.

Fixes #426

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

The data of NrrdImage and MincImage 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 writers that point raw at the file they replaced, for NIfTI images
and transformations, MGH, MRtrix and NRRD, drop the cached views 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
helper that checks the NIfTI reader options moves to the bottom of its
module, and the public methods that this change set touches document
their parameters.

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

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

This branch has not been deployed

No deployments
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