From eae5c493c7d74666eb7c19dc7a328606cee7607f Mon Sep 17 00:00:00 2001 From: Karan Dhaodiyal <256503836+karandhaodiyal28-hash@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:21:42 +0530 Subject: [PATCH 1/6] Read FITS 16-bit integers as signed and expose BZERO/BSCALE in im.info FITS integers are signed big-endian, but BITPIX=16 data was unpacked with the unsigned I;16B rawmode, so negative values wrapped around and unsigned camera data (stored as signed with BZERO=32768) came out with its top bit flipped. Read 16-bit data as signed I;16BS into mode I, matching the TIFF plugin, and extend the gzip decoder the same way. The BZERO/BSCALE scaling keywords are now exposed in im.info instead of being silently dropped; applying the scaling is left to the caller. Fixes #10309 --- Tests/test_file_fits.py | 45 ++++++++++++++++++++++++++++++++++++++ src/PIL/FitsImagePlugin.py | 32 ++++++++++++++++++++++----- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/Tests/test_file_fits.py b/Tests/test_file_fits.py index 4ae19b5b22c..9a4d11ab73f 100644 --- a/Tests/test_file_fits.py +++ b/Tests/test_file_fits.py @@ -1,5 +1,6 @@ from __future__ import annotations +import struct from io import BytesIO import pytest @@ -93,3 +94,47 @@ def test_comment() -> None: image_data = b"SIMPLE = T / comment string" with pytest.raises(OSError): FitsImagePlugin.FitsImageFile(BytesIO(image_data)) + + +def fits_image(bitpix: int, values: list[int], extra: list[str] = []) -> bytes: + 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) + + +def test_16bit_is_signed() -> None: + # FITS 16-bit integers are signed + with Image.open(BytesIO(fits_image(16, [-32768, -1, 0, 1, 32767]))) as im: + assert im.mode == "I" + assert [im.getpixel((x, 0)) for x in range(im.width)] == [-32768, -1, 0, 1, 32767] + + +def test_16bit_scaling_keywords_in_info() -> None: + # Unsigned 16-bit data is stored as signed with BZERO=32768; + # the scaling keywords are exposed without being applied + with Image.open( + BytesIO(fits_image(16, [-32768, -32767, -1, 0, 32767], ["BZERO = 32768"])) + ) as im: + assert im.mode == "I" + assert im.info["BZERO"] == 32768 + assert [im.getpixel((x, 0)) for x in range(im.width)] == [-32768, -32767, -1, 0, 32767] + + +def test_bscale_keyword_in_info() -> None: + with Image.open( + BytesIO( + fits_image(16, [-32768, -1, 0, 1, 32767], ["BSCALE = 0.5", "BZERO = 100"]) + ) + ) as im: + assert im.info["BZERO"] == 100 + assert im.info["BSCALE"] == 0.5 diff --git a/src/PIL/FitsImagePlugin.py b/src/PIL/FitsImagePlugin.py index ee8f2f8a517..e1d0703b9ae 100644 --- a/src/PIL/FitsImagePlugin.py +++ b/src/PIL/FitsImagePlugin.py @@ -112,8 +112,10 @@ def _parse_headers( if number_of_bits == 8: self._mode = rawmode = "L" elif number_of_bits == 16: - self._mode = "I;16" - rawmode = "I;16B" + # FITS 16-bit integers are signed (FITS Standard 4.0, Section 5.2); + # unsigned data is stored as signed with BZERO=32768 + self._mode = "I" + rawmode = "I;16BS" elif number_of_bits == 32: self._mode = "I" rawmode = "I;32B" @@ -124,6 +126,19 @@ def _parse_headers( msg = "Unsupported number of bits" raise OSError(msg) + # The physical value is BZERO + BSCALE * array value (FITS Standard 4.0, + # Section 4.4.2.5). Scaling is left to the caller; the keywords are + # exposed in im.info. + for keyword in (b"BZERO", b"BSCALE"): + value = headers.get(keyword) + if value is None: + continue + try: + number = float(value.replace(b"D", b"E")) + except ValueError: + continue + self.info[keyword.decode("ascii")] = int(number) if number.is_integer() else number + args: tuple[str | int, ...] if decoder_name == "raw": args = (rawmode, 0, -1) @@ -153,9 +168,16 @@ def decode(self, buffer: Image.DecoderInput) -> tuple[int, int]: for y in range(self.state.ysize): row = bytearray() for x in range(self.state.xsize): - row += value[ - offset + 4 - 1 : offset + (4 - number_of_bytes) - 1 : -1 - ] + if number_of_bytes == 2: + # 16-bit integers are signed; expand to the 4-byte "I" rawmode + pixel = int.from_bytes( + value[offset + 2 : offset + 4], "big", signed=True + ) + row += pixel.to_bytes(4, "little", signed=True) + else: + row += value[ + offset + 4 - 1 : offset + (4 - number_of_bytes) - 1 : -1 + ] offset += 4 rows.append(row) self.set_as_raw(bytes([pixel for row in rows[::-1] for pixel in row])) From 34c662005ab8f0dd33654f03f83f4411b83dc069 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:57:09 +0000 Subject: [PATCH 2/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- Tests/test_file_fits.py | 32 +++++++++++++++++++++++++++----- src/PIL/FitsImagePlugin.py | 4 +++- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/Tests/test_file_fits.py b/Tests/test_file_fits.py index 9a4d11ab73f..2e059e34048 100644 --- a/Tests/test_file_fits.py +++ b/Tests/test_file_fits.py @@ -108,7 +108,9 @@ def fits_image(bitpix: int, values: list[int], extra: list[str] = []) -> bytes: ] 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) + data = struct.pack( + ">%d%s" % (len(values), {8: "B", 16: "h", 32: "i"}[bitpix]), *values + ) return header + data + b"\0" * (-len(data) % 2880) @@ -116,24 +118,44 @@ def test_16bit_is_signed() -> None: # FITS 16-bit integers are signed with Image.open(BytesIO(fits_image(16, [-32768, -1, 0, 1, 32767]))) as im: assert im.mode == "I" - assert [im.getpixel((x, 0)) for x in range(im.width)] == [-32768, -1, 0, 1, 32767] + assert [im.getpixel((x, 0)) for x in range(im.width)] == [ + -32768, + -1, + 0, + 1, + 32767, + ] def test_16bit_scaling_keywords_in_info() -> None: # Unsigned 16-bit data is stored as signed with BZERO=32768; # the scaling keywords are exposed without being applied with Image.open( - BytesIO(fits_image(16, [-32768, -32767, -1, 0, 32767], ["BZERO = 32768"])) + BytesIO( + fits_image( + 16, [-32768, -32767, -1, 0, 32767], ["BZERO = 32768"] + ) + ) ) as im: assert im.mode == "I" assert im.info["BZERO"] == 32768 - assert [im.getpixel((x, 0)) for x in range(im.width)] == [-32768, -32767, -1, 0, 32767] + assert [im.getpixel((x, 0)) for x in range(im.width)] == [ + -32768, + -32767, + -1, + 0, + 32767, + ] def test_bscale_keyword_in_info() -> None: with Image.open( BytesIO( - fits_image(16, [-32768, -1, 0, 1, 32767], ["BSCALE = 0.5", "BZERO = 100"]) + fits_image( + 16, + [-32768, -1, 0, 1, 32767], + ["BSCALE = 0.5", "BZERO = 100"], + ) ) ) as im: assert im.info["BZERO"] == 100 diff --git a/src/PIL/FitsImagePlugin.py b/src/PIL/FitsImagePlugin.py index e1d0703b9ae..dfdce28c65d 100644 --- a/src/PIL/FitsImagePlugin.py +++ b/src/PIL/FitsImagePlugin.py @@ -137,7 +137,9 @@ def _parse_headers( number = float(value.replace(b"D", b"E")) except ValueError: continue - self.info[keyword.decode("ascii")] = int(number) if number.is_integer() else number + self.info[keyword.decode("ascii")] = ( + int(number) if number.is_integer() else number + ) args: tuple[str | int, ...] if decoder_name == "raw": From 2c2333b2e81f9300e93614e3a682892cd4454c15 Mon Sep 17 00:00:00 2001 From: Karan Dhaodiyal <256503836+karandhaodiyal28-hash@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:49:42 +0530 Subject: [PATCH 3/6] Use format specifiers in FITS test helper --- Tests/test_file_fits.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Tests/test_file_fits.py b/Tests/test_file_fits.py index 2e059e34048..cf84d4bb8a4 100644 --- a/Tests/test_file_fits.py +++ b/Tests/test_file_fits.py @@ -99,18 +99,17 @@ def test_comment() -> None: def fits_image(bitpix: int, values: list[int], extra: list[str] = []) -> bytes: cards = [ "SIMPLE = T", - "BITPIX = %20d" % bitpix, + f"BITPIX = {bitpix:20d}", "NAXIS = 2", - "NAXIS1 = %20d" % len(values), + f"NAXIS1 = {len(values):20d}", "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 - ) + data_fmt = {8: "B", 16: "h", 32: "i"}[bitpix] + data = struct.pack(f">{len(values)}{data_fmt}", *values) return header + data + b"\0" * (-len(data) % 2880) From 8ed26bb2829c4417d1e243d0fc484db371db6169 Mon Sep 17 00:00:00 2001 From: Karan Dhaodiyal <256503836+karandhaodiyal28-hash@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:51:15 +0530 Subject: [PATCH 4/6] Handle consecutive-sample payloads in FitsGzipDecoder The decompressed GZIP_1 payload holds consecutive samples, while the existing fixture uses groups widened to 4 bytes. Distinguish the two by the decompressed buffer length, like astropy does, and read 16-bit samples as signed in both layouts. This also replaces the reversed slices, which read past the start of the buffer for 32-bit data. Fixes the case reported in #10312 --- Tests/images/gzip_1_16bit.fits | Bin 0 -> 8640 bytes Tests/test_file_fits.py | 14 ++++++++++++++ src/PIL/FitsImagePlugin.py | 18 ++++++++++++------ 3 files changed, 26 insertions(+), 6 deletions(-) create mode 100644 Tests/images/gzip_1_16bit.fits diff --git a/Tests/images/gzip_1_16bit.fits b/Tests/images/gzip_1_16bit.fits new file mode 100644 index 0000000000000000000000000000000000000000..91f44a1afb3ab6d6e2b4d3842088d024d91c380a GIT binary patch literal 8640 zcmeI0&5qMB5XU1S+7zB9oKZu}AD5KjJ( znNNd=g+Ww6N-blE4Xd+-hf5s~Y^JaS`>JkA0i}RaKq;UUPzopolmh?10!cdQpn-J> zwHC6l-s&5*l2>&Yv4HR;#AGz5K=0x#IW2XmHvehdw)p}vd!b59#9L0T*jERXzEM#3c;p}gD>fIal-T4L z(eeb`d@JScTCa}fwM{z-Ve$XyxyZg2Lr{*lWd9`#2C<_7WIhc!i)a3(RmHW~->`Jk z5aU~357JOcxMIIVtahotGZr22b5i>og|@1t6i^EMT?I5vdn``v$;XG<_b)%*J-C1W s_UHZQ)oS(i*DLMmw@=MS$zydW1(X6x0i}RaKq;UUPzopolmbG5- None: assert_image_equal_tofile(im, "Tests/images/m13.fits") +def test_gzip_16bit_consecutive_samples() -> None: + # The decompressed payload of a GZIP_1 image may hold consecutive 2-byte + # samples rather than the widened 4-byte groups of Tests/images/m13_gzip.fits + with Image.open("Tests/images/gzip_1_16bit.fits") as im: + assert im.mode == "I" + assert [im.getpixel((x, 0)) for x in range(im.width)] == [ + -32768, + -1, + 0, + 1, + 32767, + ] + + def test_gzip_decode() -> None: with open("Tests/images/m13_gzip.fits", "rb") as fp: data = bytearray(fp.read()) diff --git a/src/PIL/FitsImagePlugin.py b/src/PIL/FitsImagePlugin.py index dfdce28c65d..2891766ad0f 100644 --- a/src/PIL/FitsImagePlugin.py +++ b/src/PIL/FitsImagePlugin.py @@ -167,20 +167,26 @@ def decode(self, buffer: Image.DecoderInput) -> tuple[int, int]: rows = [] offset = 0 number_of_bytes = abs(self.args[0]) // 8 + # The decompressed payload is either widened to 4-byte groups, as in + # existing files, or holds consecutive samples (see #10309) + widened = len(value) == self.state.xsize * self.state.ysize * 4 for y in range(self.state.ysize): row = bytearray() for x in range(self.state.xsize): if number_of_bytes == 2: # 16-bit integers are signed; expand to the 4-byte "I" rawmode - pixel = int.from_bytes( - value[offset + 2 : offset + 4], "big", signed=True + sample = ( + value[offset + 2 : offset + 4] + if widened + else value[offset : offset + 2] ) + pixel = int.from_bytes(sample, "big", signed=True) row += pixel.to_bytes(4, "little", signed=True) + elif widened: + row += value[offset + 4 - number_of_bytes : offset + 4][::-1] else: - row += value[ - offset + 4 - 1 : offset + (4 - number_of_bytes) - 1 : -1 - ] - offset += 4 + row += value[offset : offset + number_of_bytes][::-1] + offset += 4 if widened else number_of_bytes rows.append(row) self.set_as_raw(bytes([pixel for row in rows[::-1] for pixel in row])) return -1, 0 From ce9d59810ae1f455d0f5d13886c105e05ded80b1 Mon Sep 17 00:00:00 2001 From: Karan Dhaodiyal <256503836+karandhaodiyal28-hash@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:12:58 +0530 Subject: [PATCH 5/6] Use explicit little-endian rawmodes in FitsGzipDecoder The default rawmodes for the I and F modes are native-endian, so the little-endian rows produced for 32-bit data and expanded 16-bit samples were byte-swapped on big-endian hosts. Pass I;32S and F;32F explicitly so the decoder output is host-independent. --- src/PIL/FitsImagePlugin.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/PIL/FitsImagePlugin.py b/src/PIL/FitsImagePlugin.py index 2891766ad0f..50b9079a0f7 100644 --- a/src/PIL/FitsImagePlugin.py +++ b/src/PIL/FitsImagePlugin.py @@ -188,7 +188,16 @@ def decode(self, buffer: Image.DecoderInput) -> tuple[int, int]: row += value[offset : offset + number_of_bytes][::-1] offset += 4 if widened else number_of_bytes rows.append(row) - self.set_as_raw(bytes([pixel for row in rows[::-1] for pixel in row])) + data = bytes([pixel for row in rows[::-1] for pixel in row]) + # The 4-byte rows are little-endian; the default rawmodes for "I" and "F" + # are native-endian, so pass explicit little-endian ones for big-endian hosts + if number_of_bytes == 2: + # 16-bit samples are expanded to signed 32-bit + self.set_as_raw(data, "I;32S") + elif self.mode == "F": + self.set_as_raw(data, "F;32F") + else: + self.set_as_raw(data) return -1, 0 From 0af16d707c013b227d8cad53b8db0237e688cb2d Mon Sep 17 00:00:00 2001 From: Karan Dhaodiyal <256503836+karandhaodiyal28-hash@users.noreply.github.com> Date: Fri, 30 Oct 2026 06:33:05 +0530 Subject: [PATCH 6/6] Use I;32S for 32-bit gzip data and parse BZERO/BSCALE as integers 32-bit integer rows fell through to the native-endian default rawmode, which byte-swaps the little-endian buffer on big-endian hosts; select I;32S for the whole I mode instead. BZERO and BSCALE are now parsed as integers first, so values beyond float precision are preserved exactly. Fixes the points raised in #10312 --- src/PIL/FitsImagePlugin.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/PIL/FitsImagePlugin.py b/src/PIL/FitsImagePlugin.py index 50b9079a0f7..daae26b2986 100644 --- a/src/PIL/FitsImagePlugin.py +++ b/src/PIL/FitsImagePlugin.py @@ -134,12 +134,14 @@ def _parse_headers( if value is None: continue try: - number = float(value.replace(b"D", b"E")) + # parse integers directly, so values beyond float precision survive + number: int | float = int(value) except ValueError: - continue - self.info[keyword.decode("ascii")] = ( - int(number) if number.is_integer() else number - ) + try: + number = float(value.replace(b"D", b"E")) + except ValueError: + continue + self.info[keyword.decode("ascii")] = number args: tuple[str | int, ...] if decoder_name == "raw": @@ -189,10 +191,9 @@ def decode(self, buffer: Image.DecoderInput) -> tuple[int, int]: offset += 4 if widened else number_of_bytes rows.append(row) data = bytes([pixel for row in rows[::-1] for pixel in row]) - # The 4-byte rows are little-endian; the default rawmodes for "I" and "F" + # The rows are little-endian; the default rawmodes for "I" and "F" # are native-endian, so pass explicit little-endian ones for big-endian hosts - if number_of_bytes == 2: - # 16-bit samples are expanded to signed 32-bit + if self.mode == "I": self.set_as_raw(data, "I;32S") elif self.mode == "F": self.set_as_raw(data, "F;32F")