diff --git a/Tests/images/hopper.jxl b/Tests/images/hopper.jxl deleted file mode 100644 index d89d3c267..000000000 Binary files a/Tests/images/hopper.jxl and /dev/null differ diff --git a/Tests/images/hopper_jxl_bits.ppm b/Tests/images/hopper_jxl_bits.ppm deleted file mode 100644 index 881aca333..000000000 Binary files a/Tests/images/hopper_jxl_bits.ppm and /dev/null differ diff --git a/Tests/images/flower.jxl b/Tests/images/jxl/flower.jxl similarity index 100% rename from Tests/images/flower.jxl rename to Tests/images/jxl/flower.jxl diff --git a/Tests/images/flower2.jxl b/Tests/images/jxl/flower2.jxl similarity index 100% rename from Tests/images/flower2.jxl rename to Tests/images/jxl/flower2.jxl diff --git a/Tests/images/jxl/hopper.jxl b/Tests/images/jxl/hopper.jxl new file mode 100644 index 000000000..3be85de64 Binary files /dev/null and b/Tests/images/jxl/hopper.jxl differ diff --git a/Tests/images/jxl/hopper_bw_500.jxl b/Tests/images/jxl/hopper_bw_500.jxl new file mode 100644 index 000000000..79ad8883f Binary files /dev/null and b/Tests/images/jxl/hopper_bw_500.jxl differ diff --git a/Tests/images/jxl/hopper_gray.jxl b/Tests/images/jxl/hopper_gray.jxl new file mode 100644 index 000000000..2f50436da Binary files /dev/null and b/Tests/images/jxl/hopper_gray.jxl differ diff --git a/Tests/images/iss634.jxl b/Tests/images/jxl/iss634.jxl similarity index 100% rename from Tests/images/iss634.jxl rename to Tests/images/jxl/iss634.jxl diff --git a/Tests/images/transparent.jxl b/Tests/images/jxl/transparent.jxl similarity index 100% rename from Tests/images/transparent.jxl rename to Tests/images/jxl/transparent.jxl diff --git a/Tests/images/jxl/unknown_mode.jxl b/Tests/images/jxl/unknown_mode.jxl new file mode 100644 index 000000000..8e8f8ede3 Binary files /dev/null and b/Tests/images/jxl/unknown_mode.jxl differ diff --git a/Tests/test_file_jxl.py b/Tests/test_file_jxl.py index faefed00c..86e718a05 100644 --- a/Tests/test_file_jxl.py +++ b/Tests/test_file_jxl.py @@ -1,10 +1,11 @@ from __future__ import annotations +import os import re import pytest -from PIL import Image, JpegXlImagePlugin, features +from PIL import Image, JpegXlImagePlugin, UnidentifiedImageError, features from .helper import assert_image_similar_tofile, skip_unless_feature @@ -13,10 +14,6 @@ try: except ImportError: pass -# cjxl v0.9.2 41b8cdab -# hopper.jxl: cjxl hopper.png hopper.jxl -q 75 -e 8 -# 16_bit_binary.jxl: cjxl 16_bit_binary.pgm 16_bit_binary.jxl -q 100 -e 9 - class TestUnsupportedJpegXl: def test_unsupported(self, monkeypatch: pytest.MonkeyPatch) -> None: @@ -24,7 +21,7 @@ class TestUnsupportedJpegXl: with pytest.raises(OSError): with pytest.warns(UserWarning, match="JXL support not installed"): - Image.open("Tests/images/hopper.jxl") + Image.open("Tests/images/jxl/hopper.jxl") @skip_unless_feature("jpegxl") @@ -34,53 +31,34 @@ class TestFileJpegXl: assert version is not None assert re.search(r"\d+\.\d+\.\d+$", version) - def test_read_rgb(self) -> None: - """ - Can we read an RGB mode JPEG XL file without error? - Does it have the bits we expect? - """ - - with Image.open("Tests/images/hopper.jxl") as im: - assert im.mode == "RGB" - assert im.size == (128, 128) + @pytest.mark.parametrize( + "mode, test_file", + ( + ("1", "hopper_bw_500.png"), + ("L", "hopper_gray.jpg"), + ("I;16", "jxl/16bit_subcutaneous.cropped.png"), + ("RGB", "hopper.jpg"), + ("RGBA", "transparent.png"), + ), + ) + def test_read(self, mode: str, test_file: str) -> None: + with Image.open( + "Tests/images/jxl/" + + os.path.splitext(os.path.basename(test_file))[0] + + ".jxl" + ) as im: assert im.format == "JPEG XL" - im.getdata() + assert im.mode == mode - # generated with: - # djxl hopper.jxl hopper_jxl_bits.ppm - assert_image_similar_tofile(im, "Tests/images/hopper_jxl_bits.ppm", 1) + assert_image_similar_tofile(im, "Tests/images/" + test_file, 1.9) - def test_read_rgba(self) -> None: - # Generated with `cjxl transparent.png transparent.jxl -q 100 -e 8` - with Image.open("Tests/images/transparent.jxl") as im: - assert im.mode == "RGBA" - assert im.size == (200, 150) - assert im.format == "JPEG XL" - im.getdata() - - im.tobytes() - - assert_image_similar_tofile(im, "Tests/images/transparent.png", 1) - - def test_read_i16(self) -> None: - """ - Can we read 16-bit Grayscale JPEG XL image? - """ - - with Image.open("Tests/images/jxl/16bit_subcutaneous.cropped.jxl") as im: - assert im.mode == "I;16" - assert im.size == (128, 64) - assert im.format == "JPEG XL" - im.getdata() - - assert_image_similar_tofile( - im, "Tests/images/jxl/16bit_subcutaneous.cropped.png", 1 - ) + def test_unknown_mode(self) -> None: + with pytest.raises(UnidentifiedImageError): + Image.open("Tests/images/jxl/unknown_mode.jxl") def test_JpegXlDecode_with_invalid_args(self) -> None: """ Calling decoder functions with no arguments should result in an error. """ - with pytest.raises(TypeError): _jpegxl.JpegXlDecoder() diff --git a/Tests/test_file_jxl_animated.py b/Tests/test_file_jxl_animated.py index ebb342d3e..f4283a71e 100644 --- a/Tests/test_file_jxl_animated.py +++ b/Tests/test_file_jxl_animated.py @@ -12,17 +12,17 @@ pytestmark = skip_unless_feature("jpegxl") def test_n_frames() -> None: """Ensure that jxl format sets n_frames and is_animated attributes correctly.""" - with Image.open("Tests/images/hopper.jxl") as im: + with Image.open("Tests/images/jxl/hopper.jxl") as im: assert im.n_frames == 1 assert not im.is_animated - with Image.open("Tests/images/iss634.jxl") as im: + with Image.open("Tests/images/jxl/iss634.jxl") as im: assert im.n_frames == 41 assert im.is_animated def test_duration() -> None: - with Image.open("Tests/images/iss634.jxl") as im: + with Image.open("Tests/images/jxl/iss634.jxl") as im: assert im.info["duration"] == 70 assert im.info["timestamp"] == 0 @@ -43,17 +43,17 @@ def test_seek() -> None: assert im1.is_animated # Traverse frames in reverse, checking timestamps and durations - total_dur = 0 + total_duration = 0 for frame in reversed(range(im1.n_frames)): im1.seek(frame) im2.seek(frame) assert_image_equal(im1.convert("RGB"), im2.convert("RGB")) - total_dur += im1.info["duration"] + total_duration += im1.info["duration"] assert im1.info["duration"] == im2.info["duration"] assert im1.info["timestamp"] == im1.info["timestamp"] - assert total_dur == 8000 + assert total_duration == 8000 assert im1.tell() == 0 assert im2.tell() == 0 @@ -65,7 +65,7 @@ def test_seek() -> None: def test_seek_errors() -> None: - with Image.open("Tests/images/iss634.jxl") as im: + with Image.open("Tests/images/jxl/iss634.jxl") as im: with pytest.raises(EOFError, match="attempt to seek outside sequence"): im.seek(-1) diff --git a/Tests/test_file_jxl_metadata.py b/Tests/test_file_jxl_metadata.py index c88483518..e9f25c3ee 100644 --- a/Tests/test_file_jxl_metadata.py +++ b/Tests/test_file_jxl_metadata.py @@ -27,7 +27,7 @@ except ImportError: def test_read_exif_metadata() -> None: - with Image.open("Tests/images/flower.jxl") as im: + with Image.open("Tests/images/jxl/flower.jxl") as im: assert im.format == "JPEG XL" exif_data = im.info["exif"] @@ -44,7 +44,7 @@ def test_read_exif_metadata() -> None: def test_read_exif_metadata_without_prefix() -> None: - with Image.open("Tests/images/flower2.jxl") as im: + with Image.open("Tests/images/jxl/flower2.jxl") as im: # Assert prefix is not present assert im.info["exif"][:6] != b"Exif\x00\x00" @@ -53,12 +53,12 @@ def test_read_exif_metadata_without_prefix() -> None: def test_read_icc_profile() -> None: - with Image.open("Tests/images/flower.jxl") as im: + with Image.open("Tests/images/jxl/flower.jxl") as im: assert "icc_profile" in im.info def test_getxmp() -> None: - with Image.open("Tests/images/flower.jxl") as im: + with Image.open("Tests/images/jxl/flower.jxl") as im: assert "xmp" not in im.info if ElementTree is None: with pytest.warns( @@ -70,7 +70,7 @@ def test_getxmp() -> None: xmp = im.getxmp() assert xmp == {} - with Image.open("Tests/images/flower2.jxl") as im: + with Image.open("Tests/images/jxl/flower2.jxl") as im: if ElementTree is None: with pytest.warns( UserWarning, @@ -105,10 +105,10 @@ def test_4_byte_exif(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(JpegXlImagePlugin, "_jpegxl", _mock_jpegxl) - with Image.open("Tests/images/hopper.jxl") as im: + with Image.open("Tests/images/jxl/hopper.jxl") as im: assert "exif" not in im.info def test_read_exif_metadata_empty() -> None: - with Image.open("Tests/images/hopper.jxl") as im: + with Image.open("Tests/images/jxl/hopper.jxl") as im: assert im.getexif() == {} diff --git a/src/PIL/JpegXlImagePlugin.py b/src/PIL/JpegXlImagePlugin.py index 62a6f4fa5..c4c2adac9 100644 --- a/src/PIL/JpegXlImagePlugin.py +++ b/src/PIL/JpegXlImagePlugin.py @@ -55,7 +55,8 @@ class JpegXlImageFile(ImageFile.ImageFile): self.info["exif"] = exif[exif_start_offset + 4 :] if xmp := self._decoder.get_xmp(): self.info["xmp"] = xmp - self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, self.mode)] + rawmode = "L" if self.mode == "1" else self.mode + self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, rawmode)] @property def n_frames(self) -> int: diff --git a/src/_jpegxl.c b/src/_jpegxl.c index e9c6284d4..a91a8c34e 100644 --- a/src/_jpegxl.c +++ b/src/_jpegxl.c @@ -14,21 +14,16 @@ void _jxl_get_pixel_format(JxlPixelFormat *pf, const JxlBasicInfo *bi) { pf->num_channels = bi->num_color_channels + bi->num_extra_channels; - - if (bi->exponent_bits_per_sample) { - pf->data_type = JXL_TYPE_FLOAT; - } else if (bi->bits_per_sample > 8) { - pf->data_type = JXL_TYPE_UINT16; - } else { - pf->data_type = JXL_TYPE_UINT8; - } - + pf->data_type = bi->bits_per_sample > 8 ? JXL_TYPE_UINT16 : JXL_TYPE_UINT8; pf->align = 0; } char * _jxl_get_mode(const JxlBasicInfo *bi) { if (bi->num_color_channels == 1 && !bi->alpha_bits) { + if (bi->bits_per_sample == 1) { + return "1"; + } if (bi->bits_per_sample == 16) { return "I;16"; } @@ -40,9 +35,6 @@ _jxl_get_mode(const JxlBasicInfo *bi) { if (bi->num_color_channels == 3) { return bi->alpha_premultiplied ? "RGBa" : "RGBA"; } - if (bi->num_color_channels == 1) { - return bi->alpha_premultiplied ? "La" : "LA"; - } } else { // image has no transparency if (bi->num_color_channels == 3) { @@ -290,7 +282,7 @@ decoder_loop_skip_process: realloc(final_jxl_buf, final_jxl_buf_len + compressed_box_size); if (!_new_jxl_buf) { PyErr_SetString(PyExc_OSError, "failed to allocate final_jxl_buf"); - goto end; + goto end_with_custom_error; } final_jxl_buf = _new_jxl_buf; diff --git a/src/libImaging/Unpack.c b/src/libImaging/Unpack.c index 203bcac2c..7456168b3 100644 --- a/src/libImaging/Unpack.c +++ b/src/libImaging/Unpack.c @@ -256,6 +256,14 @@ unpack18(UINT8 *out, const UINT8 *in, int pixels) { } } +static void +unpack1L(UINT8 *out, const UINT8 *in, int pixels) { + int i; + for (i = 0; i < pixels; i++) { + out[i] = in[i] > 128 ? 255 : 0; + } +} + /* Unpack to "L" image */ static void @@ -1564,6 +1572,7 @@ static struct { {IMAGING_MODE_1, IMAGING_RAWMODE_1_R, 1, unpack1R}, {IMAGING_MODE_1, IMAGING_RAWMODE_1_IR, 1, unpack1IR}, {IMAGING_MODE_1, IMAGING_RAWMODE_1_8, 8, unpack18}, + {IMAGING_MODE_1, IMAGING_RAWMODE_L, 8, unpack1L}, /* grayscale */ {IMAGING_MODE_L, IMAGING_RAWMODE_L_2, 2, unpackL2},