Repository navigation
Description
What did you do?
Opened small FITS images whose headers use BZERO/BSCALE, plus one with signed 16-bit
data, and compared the pixel values with astropy. The files are built in memory, so the
script has no data dependencies:
import io, struct
from PIL import Image
def fits(bitpix, values, extra=()):
cards = ["SIMPLE = T", "BITPIX = %20d" % bitpix,
"NAXIS = 2", "NAXIS1 = %20d" % len(values),
"NAXIS2 = 1", *extra, "END"]
header = b"".join(c.ljust(80).encode() for c in cards)
header += b" " * (-len(header) % 2880)
data = struct.pack(">%d%s" % (len(values), {8: "B", 16: "h", 32: "i"}[bitpix]), *values)
return header + data + b"\0" * (-len(data) % 2880)
unsigned16 = fits(16, [-32768, -32767, -1, 0, 32767], ["BZERO = 32768"])
signed16 = fits(16, [-32768, -1, 0, 1, 32767])
for blob in (unsigned16, signed16):
with Image.open(io.BytesIO(blob)) as im:
print(im.mode, [im.getpixel((x, 0)) for x in range(im.width)])What did you expect to happen?
FITS integers are signed big-endian, and the values an application should see are
BZERO + BSCALE * array value (FITS Standard 4.0, Sect. 4.4.2.5, Eq. 3). Unsigned 16-bit
data, which most cameras produce, is stored as signed integers with BZERO = 32768
(Sect. 5.2.5, Table 11). astropy 8.0.2 (astropy.io.fits.getdata) gives:
| file | astropy |
|---|---|
| BITPIX=16, BZERO=32768 | [0, 1, 32767, 32768, 65535] |
| BITPIX=16, no BZERO | [-32768, -1, 0, 1, 32767] |
| BITPIX=8, BZERO=-128 | [-128, -127, -1, 0, 127] |
| BITPIX=16, BSCALE=0.5, BZERO=100 | [99.0, 100.0, 101.0, 150.0, 600.0] |
| BITPIX=32, no BZERO | [-2147483648, -1, 0, 1, 2147483647] |
What actually happened?
| file | Pillow 13.0.0 (also main 923a595) |
|---|---|
| BITPIX=16, BZERO=32768 | I;16 [32768, 32769, 65535, 0, 32767] |
| BITPIX=16, no BZERO | I;16 [32768, 65535, 0, 1, 32767] |
| BITPIX=8, BZERO=-128 | L [0, 1, 127, 128, 255] |
| BITPIX=16, BSCALE=0.5, BZERO=100 | I;16 [65534, 0, 2, 100, 1000] |
| BITPIX=32, no BZERO | I [-2147483648, -1, 0, 1, 2147483647] (correct) |
16-bit data is unpacked with I;16B, i.e. as unsigned, and BZERO/BSCALE are never
read. For the common unsigned convention every pixel comes out with its top bit flipped,
so a 16-bit camera frame looks like a solarised image. Signed 16-bit data without BZERO
wraps negative values to large positive ones. 32-bit data is already read as signed
(I;32B).
Question for maintainers
Before writing a PR I'd like to know which behaviour you want:
- Physical values. Apply
BZERO/BSCALE: unsigned 16-bit (BZERO=32768) stays
I;16, signed 16-bit and signed bytes becomeI, and non-integer scaling becomesF.
This matches astropy, but the mode of some existing files would change fromI;16toI. - Array values, correct signedness. Read BITPIX=16 as signed (
I;16BStoI, as
the TIFF plugin does), leave the scaling to the caller and exposeBZERO/BSCALEin
im.info. - Keep the raw reading as it is, and document that FITS integers are returned as
unsigned array values without scaling.
For option 1 with BZERO=32768, I don't see an existing rawmode that flips the sign bit
into I;16. It would need a small unpacker or a post-processing step in the plugin, so
tell me if you'd rather avoid that.
What are your OS, Python and Pillow versions?
- OS: Ubuntu 24.04 (x86_64, Docker)
- Python: 3.12.3
- Pillow: 13.0.0 from PyPI, and main at 923a595 built from source
Activity
sunxiuguo commentedon Oct 25, 2026
One constraint worth keeping explicit when choosing between physical and array values: scaled 32-bit data cannot always be represented losslessly by Pillow's
Fmode.For example,
BITPIX=32,BSCALE=1,BZERO=0.5, and stored samples[16777216, 16777217, 16777218]have distinct physical values[16777216.5, 16777217.5, 16777218.5]. Putting those values into anFimage produces[16777216.0, 16777218.0, 16777218.0]:I ran this with the official Pillow 13.0.0 wheel on Linux/Python 3.12.14. This tests the proposed representation, not a patch to FITS decoding. Astropy's dtype selection selects float64 for this scaled 32-bit case, whereas Pillow's
Fmode is float32.So option 1 would need a documented precision boundary rather than a blanket match to Astropy; exposing scaling metadata with correctly signed array values would retain the caller's choice of representation. I have not implemented either option or tested a full main-branch build. Prepared with AI assistance; the example was executed.
radarhere commentedon Oct 26, 2026
Unless you tell me that there are times when someone would want the original array values, Option 1 sounds like the most natural to me. Changing the mode of existing images is not a deal breaker, although I will consider whether data that happens to fit within the I;16 range can be left as I;16. Adding a new unpacker is not a huge deal.
radarhere commentedon Oct 30, 2026
@bacescuandrei do you approve of the approach of #10312?
bacescuandrei commentedon Nov 1, 2026
Partly. The decoding in #10312 looks right and I'd keep it, but I wouldn't merge the approach as it stands. It is option 2, and with the results below I agree with you that option 1 is the better default.
Tested at 0af16d7 on x86_64 with Python 3.12, astropy 8.0.2 and numpy 2.5.4.
Tests/test_file_fits.pypasses (12) and so doesselftest.py(59). Big-endian wasn't run outside CI, but the s390x job passes at this commit.What works. These all match astropy's unscaled arrays: uncompressed BITPIX 8/16/32/-32/-64, and GZIP_1 8/16/32/-32 with consecutive samples. For 16-bit gzip that includes a 2x3 image with one tile and with row tiles, flipped vertically the way Pillow reads FITS.
BZERO/BSCALEparse correctly as integers, decimals,Dexponents and with a trailing comment, and9007199254740993stays exact.What I'd change:
I, including positive data that main already reads correctly. On main that image saves as anI;16PNG. On the PR,im.save(fp, "PNG")raisesOSError: cannot write mode I as PNG.[0, 1000, 20000, 32768, 50000, 65535]stored withBZERO=32768opens as[-32768, -31768, -12768, 0, 17232, 32767], andconvert("L")gives[0, 0, 0, 0, 255, 255]. That is the most common case in the issue, and the caller now has to know to applyim.info["BZERO"].[258, 772, 1286, 1800, 0], becauseint.from_bytes(b"", ...)returns 0. A 16-byte payload is read as consecutive samples,[258, 772, 1286, 1800, 2314], ignoring the last 6 bytes. Main raisesValueError: not enough image datafor both, and 8-bit and 32-bit still do on the PR. Accepting only the two expected payload lengths would fix it.im.infokeys, andcodecov/patchis still red.Your earlier question: would anyone want the original array values? Yes, in the two cases where the physical values don't fit any Pillow mode:
BZERO=2147483648).Iis signed, soim.point()with that offset gives[0, 2147483647, -2147483648, -2147483648]where astropy gives[0, 2147483647, 2147483648, 4294967295]as uint32.Fis float32, so @sunxiuguo's[16777216.5, 16777217.5, 16777218.5]becomes[16777216.0, 16777218.0, 16777218.0]. astropy uses float64 here.What I'd suggest is option 1 where the result is exact, with this PR's behaviour as the fallback:
BZERO=32768,BSCALE=1I;16IIFIIBZERO/BSCALEinim.infoThe first row covers the camera case and keeps the mode those files have today. It only needs the sign bit flipped in the raw bytes. A throwaway prototype of that (XOR on the bytes, then
I;16B) matched astropy on[0, 1, 1000, 20000, 32767, 32768, 50000, 65535]and round-tripped through PNG. It isn't a patch against the plugin, so a proper unpacker is still to be written.One limit remains. The mode has to be chosen at
open(), before the pixels are read, so BITPIX=16 withoutBZEROhas to beI, and saving those as PNG needsim.convert("I;16"). That works for positive data, but it clips negatives to 0 ([-5, 0, 7]becomes[0, 0, 7]).If you'd rather merge option 2 as a first step, I'd want at least 3 and the release note fixed first, and 1 called out in that note.