Skip to content

Write .mgz files with the right data type - #874

Merged
m-reuter merged 15 commits into
Deep-MI:devfrom
m-reuter:fix-dtype
Sep 7, 2026
Merged

Write .mgz files with the right data type#874
m-reuter merged 15 commits into
Deep-MI:devfrom
m-reuter:fix-dtype

Conversation

@m-reuter

@m-reuter m-reuter commented Sep 5, 2026

Copy link
Copy Markdown
Member

Decide the data type once, and refuse one that cannot be honoured

Four outputs came out with the wrong data type. Found by comparing a released v2.5.4 run against the
v2.3.3 reference and against what FreeSurfer writes, then tracing each one to the writer.

The fixes are small. Most of this branch is the rule that replaces them, because every one of the
four was a different symptom of the same missing decision: nothing owned the question "what type
does this file get".

The rule

save_image and as_mgh_image now decide the type in one place, choose_dtype:

  1. The type is what the caller passed as dtype, or else what the header carries.
  2. That type is used or nothing is. If it would round or clip the data, the write raises.
    Narrowing is the caller's to do, deliberately and outside, because only the caller knows whether
    to cast, to rescale or to refuse.
  3. Whether the type can be stored at all is the output format's answer, not ours. It is asked when
    the type is applied, so a wrong list on our side cannot cause a wrong refusal.

Nothing is substituted and nothing is guessed. The one output whose type is genuinely not ours to
choose, the archival copy of the input in mri/orig/001.mgz, asks for storable_dtype explicitly.

The rule is the same for every format. The file name only decides which types are available at all:
MGH has no int64 and no float64, NIfTI has both.

data, .mgz target requested uint8 int16 int32 int64 float32 float64
uint8 uint8 int16 int32 raise float32 raise
int16 holding 2035 raise int16 int32 raise float32 raise
int64 holding 2²⁰ raise raise int32 raise float32 raise
float32 raise raise raise raise float32 raise

The same table for a .nii.gz target has the int64 and float64 columns filled in rather than
refusing. Every cell is either the requested type or a refusal; nothing warns.

What was wrong

A header from a NIfTI lost the type

MGHHeader.from_header returns float32 whatever the source header says, so the written type
depended on which container the header came from:

data header before after
uint8 NIfTI uint8 >f4 uint8
uint8 MGH uint8 uint8 uint8

The 1 mm copy CerebNet conforms for a high-res subject, orig.10mm.mgz, was stored as float32 at
four times the size holding only integers, although uint8 was asked for and honoured all the way
down to the write. This is the same nibabel behaviour as the fov=0 bug in #873 on a different
field, so the fix sits in the same function.

The aseg files were int16 instead of uchar

reduce_to_aseg_and_save kept the type of the segmentation it reduces:

file FreeSurfer v2.3.3 v2.5.4 after
aseg.auto_noCCseg.mgz uint8 uint8 uint8 uint8
aseg.auto.mgz uint8 uint8 >i2 uint8
aseg.presurf.mgz uint8 uint8 >i2 follows aseg.auto.mgz
aseg.presurf.hypos.mgz uint8 uint8 >i2 follows aseg.auto.mgz

The module's own __main__ path already narrowed, which is why aseg.auto_noCCseg.mgz stayed
uint8; the library path used by paint_cc_into_pred did not. The two presurf files are derived
from aseg.auto.mgz by FreeSurfer, so they are expected to follow rather than measured to; the
refreshed quicktest reference will confirm it.

Both call sites now ask for uchar explicitly. An aseg has no label outside 0 to 255, so a label that
does not fit means something went wrong upstream and now says so instead of being written as a type
FreeSurfer does not expect.

Probabilities were stored as uchar

The corpus callosum soft labels are softmax outputs written with the header of the conformed image.
That header is uchar, so every value below 0.5 became 0 and the probability map came back as a
binary mask. callosum.CC.soft.mgz and its two siblings now carry real probabilities.

The call site says dtype=np.float32, which is where that decision belongs. Any writer that hands
float data to an integer type now raises rather than rounding it away.

The hypothalamus mask was float32 instead of uchar

file v2.3.3 v2.5.4 after
hypothalamus_mask.HypVINN.nii.gz uint8 float32 uint8

A copy-paste slip in the reorientation rework: the mask block set uchar on its header and then
overwrote it with the float32 line from the logits block two functions down. The prediction block
next to it got the right type, which is why only the mask was affected.

Also in this branch

  • save_image returns the image it wrote. load_maybe_conform built a second one, so the type
    was decided twice and a .nii.gz conf_name was handed back as an MGHImage that did not match
    the file on disk.
  • Integer NIfTI outputs carry an explicit scale of 1. Left free, nibabel is entitled to add a
    scale factor, and a label read back through one is no longer the integer it was written as. In
    practice this is belt and braces, since the refusal rule prevents the case that would trigger it.
  • HypVINN's three writes go through save_image instead of assembling headers by hand. Two of
    the four bugs above lived in that function. Verified byte-identical to the previous code path on
    the same inputs, so this is a simplification with no output change.
  • save_logits called a method nibabel does not have. Nifti1Header.set_data_type does not
    exist, so the function raised AttributeError on its first line. Kept rather than removed, since
    it is a debugging aid.
  • header is now required in as_mgh_image. Every image we write is derived from one we read,
    and the header is the only carrier of the acquisition parameters and of the type.
  • A redundant assert on the output extension is gone. It could not fire without the else
    branch below also firing, and it was validating input with an assert, which vanishes under
    python -O.

Two commits at the end are unrelated to data types: they write the comment and dash conventions into
CONTRIBUTING.md and fix a code fence that had drifted out of the numbered list.

Impact

Six files per subject change type, four of them back to what v2.3.3 and FreeSurfer write:

file before after
aseg.auto.mgz and the two presurf files >i2 uint8
hypothalamus_mask.HypVINN.nii.gz float32 uint8
orig.10mm.mgz, high-res subjects only >f4 uint8
callosum.CC.soft.mgz and its two siblings uint8 >f4

aseg.mgz, aparc+aseg.mgz and wmparc.mgz already matched FreeSurfer and are untouched.

One measurement changes. The CC soft labels go from a binary mask to real probabilities. That is
the point of the fix, not a side effect. Nothing else changes numerically.

Header bytes and file sizes change on the files above, so the quicktest reference will differ. Every
image the current dev pipeline produces was re-checked against the new rule and none of the 86 is
refused
, so no existing run turns into a failure.

Tests

test/image/test_mgh_dtype.py, 49 cases:

  • the written type across every combination of source container, output container and data type
  • refusal of a type that would round or clip, from the header and from dtype, in both formats
  • refusal of a type the format cannot store, and of one no storable type can hold
  • the exactness boundary where a float stops counting integers, 2²⁴ for float32
  • storable_dtype keeping the kind and the signedness, and writing the archival copy
  • the aseg and mask narrowing, and the labels that must not be narrowed
  • an integer NIfTI carrying no scale factor
  • the rest of the header surviving: a uchar source header with tr, te, ti and flip_angle
    produces a >f4 image that keeps all four fields and the affine, and the caller's header object
    is not modified, which matters because the aseg and the mask are written from one header on two
    threads
  • both dtype lists derived from nibabel rather than trusted, so a stale entry cannot make
    storable_dtype pick something unstorable

478 tests pass in test/image and test/config, 534 including test/shell/test_bash4_lint.py.
Ruff and codespell clean.

Follow-ups, not in this PR

  • mri/orig/001.mgz is not written at all for .nii.gz input, a regression since v2.5.4 that is
    separate from the type question. The plan is to make it a verbatim copy of the input under the
    input's own extension, which loses nothing, needs no type policy, and removes the only caller of
    storable_dtype.
  • rawavg.mgz is currently a link to the conformed orig.mgz rather than being built from the
    input, because the conform step moved out of recon-surf.sh. It should be built from 001.
  • The quicktest reference has to be regenerated after this lands, and the CerebNet output of the
    shipped v2.5.4 image is not reproducible from source, which needs settling first.

MGHHeader.from_header returns float32 whatever the source header says, so the type of an .mgz
depended on whether its header came from a .nii or a .mgz. The 1mm copy CerebNet conforms for a
high-res subject was written as float32 at four times the size, holding only integers, although
uint8 was asked for. as_mgh_image now carries the type across as it already does the fov, and warns
rather than aborting for a type MGH cannot store.
reduce_to_aseg_and_save kept the int16 of the segmentation it reduces, so aseg.auto.mgz and the
aseg.presurf files derived from it became int16, where FreeSurfer and FastSurfer up to v2.3.3 write
uchar. The __main__ path of the same module already narrowed; this one did not. It narrows only
integer labels within 0 to 255, so a float is not rounded and a negative label is not clipped.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Headerless unsupported types can still abort, and inherited integer headers can corrupt floating-point soft-label outputs.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Preserves intended MGZ data types and restores lossless uint8 output for aseg files.

Changes:

  • Carries source-header data types into MGH images.
  • Narrows eligible aseg labels to uint8.
  • Adds dtype regression coverage.
File summaries
File Description
FastSurferCNN/data_loader/data_utils.py Preserves dtype during MGH conversion.
FastSurferCNN/reduce_to_aseg.py Narrows lossless aseg data to uint8.
test/image/test_mgh_dtype.py Tests conversion and narrowing behavior.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread FastSurferCNN/data_loader/data_utils.py Outdated
Comment thread FastSurferCNN/data_loader/data_utils.py Outdated
Carrying the header type across covered a case it should not: an integer header applied to
floating-point data rounded it away, which would have stored the CC soft labels as uchar on the
NIfTI path, where nibabel had left them float32. It is now only carried where nothing is lost.
Headerless images are also built with a default header first, since nibabel raises for a type MGH
cannot store while constructing the image, before the fallback could run.
The CC soft labels are written with the header of the conformed image, which is uchar, so a
probability of 0.37 was stored as 0 and the map came back as a binary mask with two distinct values
instead of 28660. Floating-point data is now never stored as an integer type whatever the header
says, which also covers the MGH case, where nibabel applies the type while constructing the image
rather than afterwards. Only the type is overridden; the rest of the header is carried through.
as_mgh_image applied the header's integer type without checking the data fits it, so int16 labels
written with a uchar header clipped 500 to 255, silently. On the NIfTI path that was a regression:
the type used to be dropped entirely, which preserved the value by accident. The fit test moves into
a shared fits_dtype, so the aseg and mask writers narrow by the same rule instead of each carrying
their own, and reduce_to_aseg no longer mutates the header it borrowed to say the same thing.
The guard against clipping widened to the array's own type, but MGH has no int64, so the fallback
returned to the narrow header and clipped after all: int64 labels holding 500 were written as 255,
after a log line claiming clipping had been avoided. It now picks the narrowest storable type that
fits, uint16 there. The three callers that wanted uchar say so with prefer_dtype instead of each
repeating the fit test, and save_image warns when the type it is told to force loses data.
…r writes

The choice was four inline branches that read the array's range once per candidate type, and it
could land on uint16, which FreeSurfer reads but never writes itself. choose_dtype now holds the
whole decision, reads the range once, and falls back only to uchar, int16 or int32; a header asking
for uint16 is still honoured. The widening test asserts that the values survive and the type is
storable rather than naming the preferred one, which is pinned once in the new unit test.
@m-reuter
m-reuter merged commit 8bbd799 into Deep-MI:dev Sep 7, 2026
5 checks passed
@m-reuter
m-reuter deleted the fix-dtype branch September 7, 2026 13:46
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