Description
Opening a FITS file that contains a compressed image HDU carrying BSCALE/BZERO with fits.open(..., mode="update"), and modifying only the header — without ever accessing .data — rescales the stored data. There is no exception, no warning, and the process exits with status 0. The corruption is cumulative: every further header-only update applies the scale factor again, and with a non-zero BZERO the values can change sign.
The trigger is precisely that the data are not loaded. If .data has been accessed before the header is modified, the file is written correctly. This is why the defect survives interactive use and strikes scripts, where nobody reads an array they do not need. In our case a script that added one keyword to the header of 464 compressed frames halved the data of all of them; it was found by an invariance check on a downstream quality indicator, not by any error.
Cause. It is an ordering problem in CompImageHDU._prewriteto (astropy/io/fits/hdu/compressed/compressed.py). Accessing .data returns the array already scaled and, as a side effect, strips BSCALE/BZERO from the image header — that bookkeeping is correct. But _get_bintable_without_data() snapshots the image header into the binary-table header before _add_data_to_bintable() triggers that access, so the table header keeps a BSCALE that the reader applies a second time on the next open. The method is unchanged on main at the time of writing.
Related. #15255 (BSCALE disappears when copying ImageHDUs) concerns the uncompressed ImageHDU, drops keywords and leaves the values intact; here the class is CompImageHDU, the keywords survive and the values change.
Workaround on an unpatched installation: touch hdu.data before modifying the header, or open with do_not_scale_image_data=True.
A fix and a regression test exist and can be submitted as a pull request. The fix materialises the data before the header is copied and restores the integer representation the file was written with. A first version that only accessed .data preserved the values but turned the on-disk int16 + BSCALE into float32 (+133% file size); the adopted version keeps the file unchanged after repeated updates. The proposed regression test (7 parametrised cases) fails 3/7 without the fix and passes 7/7 with it. Astropy's own tests in io/fits/hdu/compressed/, test_image.py, test_uint.py and test_core.py — 723 passed, 6 skipped, 100 xfailed — give the identical result with and without the fix, i.e. no existing test covers this case.
Expected behavior
A header-only update in mode="update" must leave the stored data unchanged and must keep BSCALE/BZERO (and the integer representation) exactly as written. Reading the file back after any number of header edits must return the same values as before them.
How to Reproduce
- Write a compressed image HDU with integer data and
BSCALE = 0.5.
- Open it in
mode="update" and add one header keyword, without touching .data. Repeat.
- Read the data back after each update.
import numpy as np
from astropy.io import fits
path = "scaled.fits"
hdu = fits.CompImageHDU(np.arange(1, 101, dtype=np.int16).reshape(10, 10))
hdu.header["BSCALE"] = 0.5
fits.HDUList([fits.PrimaryHDU(), hdu]).writeto(path, overwrite=True)
print(fits.getdata(path).sum()) # 2525.0 = 0.5 * sum(1..100), the true value
for i in range(3):
with fits.open(path, mode="update") as hdul:
hdul[1].header["COMMENT"] = f"header-only update {i}" # no access to .data
print(fits.getdata(path).sum())
Output on astropy 8.0.1:
2525.0
1250.0
612.5
294.0
The same loop with _ = hdul[1].data inserted before the header edit prints 2525.0 four times.
Versions
platform
--------
platform.platform() = 'Windows-10-10.0.19045-SP0'
platform.version() = '10.0.19045'
platform.python_version() = '3.14.6'
packages
--------
astropy 8.0.1
numpy 2.5.2
scipy 1.18.0
matplotlib 3.11.1
pandas 3.0.5
pyerfa 2.0.1.5
Description
Opening a FITS file that contains a compressed image HDU carrying
BSCALE/BZEROwithfits.open(..., mode="update"), and modifying only the header — without ever accessing.data— rescales the stored data. There is no exception, no warning, and the process exits with status 0. The corruption is cumulative: every further header-only update applies the scale factor again, and with a non-zeroBZEROthe values can change sign.The trigger is precisely that the data are not loaded. If
.datahas been accessed before the header is modified, the file is written correctly. This is why the defect survives interactive use and strikes scripts, where nobody reads an array they do not need. In our case a script that added one keyword to the header of 464 compressed frames halved the data of all of them; it was found by an invariance check on a downstream quality indicator, not by any error.Cause. It is an ordering problem in
CompImageHDU._prewriteto(astropy/io/fits/hdu/compressed/compressed.py). Accessing.datareturns the array already scaled and, as a side effect, stripsBSCALE/BZEROfrom the image header — that bookkeeping is correct. But_get_bintable_without_data()snapshots the image header into the binary-table header before_add_data_to_bintable()triggers that access, so the table header keeps aBSCALEthat the reader applies a second time on the next open. The method is unchanged onmainat the time of writing.Related. #15255 (BSCALE disappears when copying
ImageHDUs) concerns the uncompressedImageHDU, drops keywords and leaves the values intact; here the class isCompImageHDU, the keywords survive and the values change.Workaround on an unpatched installation: touch
hdu.databefore modifying the header, or open withdo_not_scale_image_data=True.A fix and a regression test exist and can be submitted as a pull request. The fix materialises the data before the header is copied and restores the integer representation the file was written with. A first version that only accessed
.datapreserved the values but turned the on-diskint16+BSCALEintofloat32(+133% file size); the adopted version keeps the file unchanged after repeated updates. The proposed regression test (7 parametrised cases) fails 3/7 without the fix and passes 7/7 with it. Astropy's own tests inio/fits/hdu/compressed/,test_image.py,test_uint.pyandtest_core.py— 723 passed, 6 skipped, 100 xfailed — give the identical result with and without the fix, i.e. no existing test covers this case.Expected behavior
A header-only update in
mode="update"must leave the stored data unchanged and must keepBSCALE/BZERO(and the integer representation) exactly as written. Reading the file back after any number of header edits must return the same values as before them.How to Reproduce
BSCALE = 0.5.mode="update"and add one header keyword, without touching.data. Repeat.Output on astropy 8.0.1:
The same loop with
_ = hdul[1].datainserted before the header edit prints2525.0four times.Versions