diff --git a/Tests/test_file_jxl.py b/Tests/test_file_jxl.py new file mode 100644 index 00000000000..35a9460c958 --- /dev/null +++ b/Tests/test_file_jxl.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import io + +import pytest + +from PIL import Image + +imagecodecs = pytest.importorskip("imagecodecs", reason="JPEG XL requires imagecodecs") + + +def test_jxl_roundtrip() -> None: + im = Image.new("RGB", (24, 24), (30, 120, 200)) + im.info["lossless"] = True + buf = io.BytesIO() + im.save(buf, format="JXL") + buf.seek(0) + assert buf.read(2) == b"\xff\x0a" # JPEG XL codestream signature + + buf.seek(0) + with Image.open(buf) as reloaded: + assert reloaded.format == "JXL" + assert reloaded.size == (24, 24) + assert reloaded.mode == "RGB" + assert list(reloaded.getdata()) == list(im.getdata()) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 6dec284d086..ae2b44f2bfe 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -345,6 +345,7 @@ def getmodebands(mode: str) -> int: ".apng": "PngImagePlugin", # Less common formats (init) ".avif": "AvifImagePlugin", + ".jxl": "JxlImagePlugin", ".avifs": "AvifImagePlugin", ".blp": "BlpImagePlugin", ".bufr": "BufrStubImagePlugin", diff --git a/src/PIL/JxlImagePlugin.py b/src/PIL/JxlImagePlugin.py new file mode 100644 index 00000000000..52d2bdef9b1 --- /dev/null +++ b/src/PIL/JxlImagePlugin.py @@ -0,0 +1,91 @@ +# +# The Python Imaging Library. +# +# JPEG XL support for PIL, via the optional ``imagecodecs`` package +# (which bundles libjxl). Without it, opening or saving .jxl files +# raises an error, mirroring how the external pillow-jxl-plugin works. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import io +from typing import IO + +from . import Image, ImageFile + +try: + import imagecodecs + + SUPPORTED = True +except ImportError: + SUPPORTED = False + +_CODESTREAM_MAGIC = b"\xff\x0a" +_CONTAINER_MAGIC = b"\x00\x00\x00\x0cJXL \x0d\x0a\x87\x0a" + + +def _accept(prefix: bytes) -> bool: + return prefix.startswith((_CODESTREAM_MAGIC, _CONTAINER_MAGIC)) + + +class JxlImageFile(ImageFile.ImageFile): + format = "JXL" + format_description = "JPEG XL" + + def _open(self) -> None: + if not SUPPORTED: + msg = "image file could not be opened because JPEG XL support not installed" + raise SyntaxError(msg) + + assert self.fp is not None + data = self.fp.read() + try: + import numpy as np + + arr = np.asarray(imagecodecs.jpegxl_decode(data)) + except Exception as e: + msg = "could not decode JPEG XL image" + raise SyntaxError(msg) from e + + if arr.ndim == 2: + self._mode = "L" + elif arr.shape[2] == 3: + self._mode = "RGB" + elif arr.shape[2] == 4: + self._mode = "RGBA" + else: + msg = f"unsupported JPEG XL channel count: {arr.shape}" + raise SyntaxError(msg) + + self._size = arr.shape[1], arr.shape[0] + # Hand the decoded pixels to Pillow's raw decoder. + self.fp = io.BytesIO(arr.tobytes()) + self.tile = [ImageFile._Tile("raw", (0, 0, *self._size), 0, (self._mode, 0, 1))] + self._fp = self.fp + + +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if not SUPPORTED: + msg = "JPEG XL support not installed" + raise ValueError(msg) + + import numpy as np + + if im.mode not in ("L", "RGB", "RGBA"): + im = im.convert("RGBA" if "A" in im.getbands() else "RGB") + arr = np.asarray(im) + lossless = bool(im.info.get("lossless", False)) + encoded = imagecodecs.jpegxl_encode( + arr, + lossless=lossless, + distance=None if lossless else float(im.info.get("distance", 1.0)), + effort=int(im.info.get("effort", 7)), + ) + fp.write(bytes(encoded)) + + +Image.register_open(JxlImageFile.format, JxlImageFile, _accept) +Image.register_save(JxlImageFile.format, _save) +Image.register_extension(JxlImageFile.format, ".jxl") +Image.register_mime(JxlImageFile.format, "image/jxl") diff --git a/src/PIL/__init__.py b/src/PIL/__init__.py index faf3e76e0ae..7daf09e55f6 100644 --- a/src/PIL/__init__.py +++ b/src/PIL/__init__.py @@ -26,6 +26,7 @@ _plugins = [ "AvifImagePlugin", + "JxlImagePlugin", "BlpImagePlugin", "BmpImagePlugin", "BufrStubImagePlugin",