Safely changing header with nibabel

A bug in an FSL function we are using has stripped our resulting NIfTIs of the correct repetition time information (TR, entry [3] in the header). It says 1.0 but should be 1.5.

We have confirmation from the FSL developers that manually changing the TR inside the NIfTIs is fine.

To do this in nibabel, I did:

def set_tr(img, tr):
    header = img.header.copy()
    zooms = header.get_zooms()[:3] + (tr,)
    header.set_zooms(zooms)
    return img.__class__(img.get_fdata().copy(), img.affine, header)

niifiles = glob.glob('*.nii.gz')
for niifile in niifiles:
    img = nb.load(niifile)
    fixed_img = set_tr(img, 1.5)
    fixed_img.to_filename(niifile)

This correctly sets the repetition time in the header. This is adapted from the response by @effigies here: BIDS Validator error: TR mismatch between NIFTI header and JSON file, and BIDS Validator is somehow finding TR=0. Best solution?

I’ve got two questions:

  1. Can you confirm that this is 100% correct? I admit that I’m not sure I understand the differences between get_data() and get_fdata() well enough to be confident about the usage.
  2. The rewritten files have different file sizes (e.g. one went from 624.1MB to 692.9MB). What is going on here? Could there be a problem?
  1. This will definitely work. The difference between get_data() and get_fdata() is that get_fdata() always returns floating point data, while get_data() depends on the on-disk data type and whether or not scaling is applied. get_data() was deprecated due to this ambiguity. You actually don’t need the .copy() after .get_fdata() if there’s no chance of modifying the data itself, so you can save yourself some memory.

  2. There are two possible sources of changed file size. A different compression level might be used (quite likely). Another possibility is if your data is saved as int16 or similar where it would need to be rescaled, then the specific scale factors chosen may not be the same. You can always verify that the data and affine are (nearly enough) the same with:

    reload = nb.load(niifile)
    assert np.allclose(reload.get_fdata(), img.get_fdata())
    assert np.allclose(reload.affine, img.affine)
    

For more:

1 Like

Thanks for the immediate response, great community!

Unfortunately the assertion assert np.allclose(reload.get_fdata(), img.get_fdata()) fails, so the code snippet I added up there could be unsafe.

Can you recommend any way around this? I only want to change this one number in the NIfTI header. Any change to the data is unacceptable here.

Ah, sorry, I’ve been slow to get back to this.

To ensure the data block is identical, you need to give nibabel direct instructions about how to scale the data.

import nibabel as nb

def strict_load(fname):
    """ Load an image that will write out the same as the input image.
    """
    orig = nb.load(fname)
    strict_img = orig.__class__(np.array(orig.dataobj.get_unscaled()), orig.affine, orig.header)
    strict_img.header.set_slope_inter(orig.dataobj.slope, orig.dataobj.inter)
    return strict_img

This should (read: I haven’t tested) produce an image that will write the exact same values and scale factors back to disk. Note that calling get_fdata() on an image returned by this function will not return scaled data.

1 Like

Thanks for the response. Sorry for being slow to return here.

Would it be possible to explain what you are doing in the strict_load() function?

.get_fdata() does indeed not result in the same matrix, so the assert is not working when I am only replacing the nb.load functions by the strict_load function. I do expect .get_fdata() to result in the same values. The assert does work when I use the deprecated .get_data(), which is confusing because (as far as I understand) .get_fdata() should do nothing more than lead to strict float return types.

Could it be that the set_tr function should actually use the .get_unscaled() function?:

def set_tr(img, tr):
    header = img.header.copy()
    zooms = header.get_zooms()[:3] + (tr,)
    header.set_zooms(zooms)
    return img.__class__(np.array(img.dataobj.get_unscaled()), img.affine, header)

Btw, this part: np.array(img.dataobj.get_unscaled()) does not work because dataobj is a numpy array and Nifti1Image does not have the function .get_unscaled() (version 3.2.1 of nibabel).

Where is my mistake in understanding?

I’ve done these header changes manually with FSL 6.0 fsledithd now. There is a very old entry on the FSL mailing list where a similar issue (writing the wrong TR / dt) is mentioned, and where using fsledithd to correct it if necessary is recommended.

While the nifti appears to be rewritten in the process too, it seems safer to go this route as there are less vectors of failure than when I’m coming up with my own script (although it would have been best to automate this).

Ah, sorry for failing to get back to you on this… I think I had something half-written that I was wanting to test before posting, but now I can’t find it.

1 Like