Skip to content

Add JPEG XL Open/Read support via libjxl #7848

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 52 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
8e0c5db
add tests
olokelo Mar 1, 2024
a57ebea
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 2, 2024
23fb57d
minor fixes, linting corrections
olokelo Mar 2, 2024
2eb5987
Added type hints
radarhere Mar 6, 2024
37b58f3
Removed feature
radarhere Mar 6, 2024
eeaecb4
Merge pull request #1 from radarhere/jxl-support2
olokelo Mar 11, 2024
24b63ad
fix goto labels for clang
olokelo Mar 11, 2024
0b50410
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 11, 2024
f403672
Merge branch 'main' into jxl-support2
hugovk Mar 19, 2024
f6086d4
modify leak test
olokelo Mar 19, 2024
5320450
fix _jxl_decoder_count_frames
olokelo Mar 19, 2024
1b049ab
minor plugin code tweaks
olokelo Mar 19, 2024
6048520
add type hints
olokelo Mar 19, 2024
8fa280f
rename jxl -> jpegxl
olokelo Mar 19, 2024
58c37bf
add test case for seeking to the same frame
olokelo Mar 19, 2024
48bbc2e
flip cases in metadata test
olokelo Mar 19, 2024
443a352
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 19, 2024
62c58c2
fix some type hinting mistakes
olokelo Mar 19, 2024
8cab1c1
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 19, 2024
e5003ff
change Optional to python 3.10+ syntax
olokelo Mar 19, 2024
fa5bfac
add more metadata test cases
olokelo Mar 20, 2024
0b71605
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 20, 2024
1f00fb8
add 16-bits grayscale support for jpeg xl images
olokelo May 18, 2024
08270a7
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 18, 2024
9313587
Merge branch 'main' into jxl-support2
radarhere May 22, 2024
4256b2a
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 22, 2024
8a1c03e
Merge branch 'main' into jxl-support2
radarhere Aug 14, 2024
13944d5
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 14, 2024
bb06057
Merge branch 'main' into jxl-support2
radarhere Sep 11, 2024
bc4a794
Merge branch 'main' into jxl-support2
radarhere Apr 26, 2025
ff269ab
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Apr 26, 2025
661c0d8
Lint fixes
radarhere Apr 26, 2025
ade1db0
Replace slice and comparison with startswith
radarhere May 7, 2025
ceec3f9
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 7, 2025
5e0457a
Removed getxmp()
radarhere May 7, 2025
9266318
Do not set info["exif"] to None
radarhere May 7, 2025
29e4b55
Do not add _getexif to new plugin
radarhere May 7, 2025
3cd3848
Added type hint
radarhere May 7, 2025
aa6510f
Removed self.rawmode
radarhere May 7, 2025
36640de
tile is already empty list
radarhere May 7, 2025
7125fe4
Fixed type hints
radarhere May 7, 2025
05aee33
Use monkeypatch
radarhere May 7, 2025
6c3f0b5
Use member names to initialize module
radarhere May 9, 2025
80e9963
Use member names to initialize PyTypeObject
radarhere May 9, 2025
29c1e4c
Removed C method unused by Python
radarhere May 9, 2025
c8409e0
Simplified code
radarhere May 9, 2025
61ce5c2
is_animated should be a bool
radarhere May 10, 2025
bf0cdb2
Test on Linux
radarhere May 12, 2025
79f941d
Added argument that was removed in 0.9.0
radarhere May 12, 2025
b5d64e8
Removed specific leak check
radarhere Jun 15, 2025
ccca015
Use multi-phase initialization
radarhere Jun 25, 2025
ece4065
Merge branch 'main' into jxl-support2
radarhere Jun 25, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .ci/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ set -e

if [[ $(uname) != CYGWIN* ]]; then
sudo apt-get -qq install libfreetype6-dev liblcms2-dev libtiff-dev python3-tk\
ghostscript libjpeg-turbo8-dev libopenjp2-7-dev\
ghostscript libjpeg-turbo8-dev libjxl-dev libopenjp2-7-dev\
cmake meson imagemagick libharfbuzz-dev libfribidi-dev\
sway wl-clipboard libopenblas-dev nasm
fi
Expand Down
Binary file added Tests/images/flower.jxl
Binary file not shown.
Binary file added Tests/images/flower2.jxl
Binary file not shown.
Binary file added Tests/images/hopper.jxl
Binary file not shown.
Binary file added Tests/images/hopper_jxl_bits.ppm
Binary file not shown.
Binary file added Tests/images/iss634.jxl
Binary file not shown.
Binary file added Tests/images/jxl/16bit_subcutaneous.cropped.jxl
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Tests/images/jxl/traffic_light.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Tests/images/jxl/traffic_light.jxl
Binary file not shown.
Binary file added Tests/images/transparent.jxl
Binary file not shown.
76 changes: 76 additions & 0 deletions Tests/test_file_jxl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from __future__ import annotations

import re

import pytest

from PIL import Image, JpegXlImagePlugin, features

from .helper import assert_image_similar_tofile, skip_unless_feature

try:
from PIL import _jpegxl
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:
monkeypatch.setattr(JpegXlImagePlugin, "SUPPORTED", False)

with pytest.raises(OSError):
with Image.open("Tests/images/hopper.jxl"):
pass


@skip_unless_feature("jpegxl")
class TestFileJpegXl:
def test_version(self) -> None:
version = features.version_module("jpegxl")
assert version is not None
assert re.search(r"\d+\.\d+\.\d+$", version)

def test_read_rgb(self) -> None:
"""
Can we read a 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)
assert im.format == "JPEG XL"
im.load()
im.getdata()

# generated with:
# djxl hopper.jxl hopper_jxl_bits.ppm
assert_image_similar_tofile(im, "Tests/images/hopper_jxl_bits.ppm", 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.load()
im.getdata()

assert_image_similar_tofile(
im, "Tests/images/jxl/16bit_subcutaneous.cropped.png", 1
)

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()
26 changes: 26 additions & 0 deletions Tests/test_file_jxl_alpha.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from __future__ import annotations

from PIL import Image

from .helper import assert_image_similar_tofile, skip_unless_feature

pytestmark = [skip_unless_feature("jpegxl")]


def test_read_rgba() -> None:
"""
Can we read an RGBA mode file without error?
Does it have the bits we expect?
"""

# 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.load()
im.getdata()

im.tobytes()

assert_image_similar_tofile(im, "Tests/images/transparent.png", 1)
71 changes: 71 additions & 0 deletions Tests/test_file_jxl_animated.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from __future__ import annotations

import pytest

from PIL import Image

from .helper import assert_image_equal, skip_unless_feature

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:
assert im.n_frames == 1
assert not im.is_animated

with Image.open("Tests/images/iss634.jxl") as im:
assert im.n_frames == 41
assert im.is_animated


def test_float_duration() -> None:
with Image.open("Tests/images/iss634.jxl") as im:
im.load()
assert im.info["duration"] == 70


def test_seeking() -> None:
"""
Open an animated jxl file, and then try seeking through frames in reverse-order,
verifying the durations are correct.
"""

with Image.open("Tests/images/jxl/traffic_light.jxl") as im1:
with Image.open("Tests/images/jxl/traffic_light.gif") as im2:
assert im1.n_frames == im2.n_frames
assert im1.is_animated

# Traverse frames in reverse, checking timestamps and durations
total_dur = 0
for frame in reversed(range(im1.n_frames)):
im1.seek(frame)
im1.load()
im2.seek(frame)
im2.load()

assert_image_equal(im1.convert("RGB"), im2.convert("RGB"))

total_dur += im1.info["duration"]
assert im1.info["duration"] == im2.info["duration"]
assert im1.info["timestamp"] == im1.info["timestamp"]
assert total_dur == 8000

assert im1.tell() == 0
assert im2.tell() == 0

im1.seek(0)
im1.load()
im2.seek(0)
im2.load()


def test_seek_errors() -> None:
with Image.open("Tests/images/iss634.jxl") as im:
with pytest.raises(EOFError):
im.seek(-1)

with pytest.raises(EOFError):
im.seek(47)
120 changes: 120 additions & 0 deletions Tests/test_file_jxl_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
from __future__ import annotations

from types import ModuleType

import pytest

from PIL import Image, JpegXlImagePlugin

from .helper import skip_unless_feature

pytestmark = [skip_unless_feature("jpegxl")]

ElementTree: ModuleType | None
try:
from defusedxml import ElementTree
except ImportError:
ElementTree = None


# cjxl flower.jpg flower.jxl --lossless_jpeg=0 -q 75 -e 8

# >>> from PIL import Image
# >>> with Image.open('Tests/images/flower2.webp') as im:
# >>> with open('/tmp/xmp.xml', 'wb') as f:
# >>> f.write(im.info['xmp'])
# cjxl flower2.jpg flower2.jxl --lossless_jpeg=0 -q 75 -e 8 -x xmp=/tmp/xmp.xml


def test_read_exif_metadata() -> None:
with Image.open("Tests/images/flower.jxl") as im:
assert im.format == "JPEG XL"
exif_data = im.info["exif"]

exif = im.getexif()

# Camera make
assert exif[271] == "Canon"

with Image.open("Tests/images/flower.jpg") as im_jpeg:
expected_exif = im_jpeg.info["exif"]

# jpeg xl always returns exif without 'Exif\0\0' prefix
assert exif_data == expected_exif[6:]


def test_read_exif_metadata_without_prefix() -> None:
with Image.open("Tests/images/flower2.jxl") as im:
# Assert prefix is not present
assert im.info["exif"][:6] != b"Exif\x00\x00"

exif = im.getexif()
assert exif[305] == "Adobe Photoshop CS6 (Macintosh)"


def test_read_icc_profile() -> None:
with Image.open("Tests/images/flower2.jxl") as im:
assert im.format == "JPEG XL"
icc = im.info["icc_profile"]

with Image.open("Tests/images/flower2.jxl") as im_jpeg:
expected_icc = im_jpeg.info["icc_profile"]

assert icc == expected_icc


def test_getxmp() -> None:
with Image.open("Tests/images/flower.jxl") as im:
assert "xmp" not in im.info
if ElementTree is None:
with pytest.warns(

Check warning on line 70 in Tests/test_file_jxl_metadata.py

View check run for this annotation

Codecov / codecov/patch

Tests/test_file_jxl_metadata.py#L70

Added line #L70 was not covered by tests
UserWarning,
match="XMP data cannot be read without defusedxml dependency",
):
xmp = im.getxmp()

Check warning on line 74 in Tests/test_file_jxl_metadata.py

View check run for this annotation

Codecov / codecov/patch

Tests/test_file_jxl_metadata.py#L74

Added line #L74 was not covered by tests
else:
xmp = im.getxmp()
assert xmp == {}

with Image.open("Tests/images/flower2.jxl") as im:
if ElementTree is None:
with pytest.warns(

Check warning on line 81 in Tests/test_file_jxl_metadata.py

View check run for this annotation

Codecov / codecov/patch

Tests/test_file_jxl_metadata.py#L81

Added line #L81 was not covered by tests
UserWarning,
match="XMP data cannot be read without defusedxml dependency",
):
assert im.getxmp() == {}
else:
assert "xmp" in im.info

Check warning on line 87 in Tests/test_file_jxl_metadata.py

View check run for this annotation

Codecov / codecov/patch

Tests/test_file_jxl_metadata.py#L85-L87

Added lines #L85 - L87 were not covered by tests
assert (
im.getxmp()["xmpmeta"]["xmptk"]
== "Adobe XMP Core 5.3-c011 66.145661, 2012/02/06-14:56:27 "
)


def test_4_byte_exif(monkeypatch: pytest.MonkeyPatch) -> None:
class _mock_jpegxl:
class JpegXlDecoder:
def __init__(self, b: bytes) -> None:
pass

def get_info(self) -> tuple[tuple[int, int], str, int, int, int, int, int]:
return ((1, 1), "L", 0, 0, 0, 0, 0)

def get_icc(self) -> None:
pass

def get_exif(self) -> bytes:
return b"\0\0\0\0"

def get_xmp(self) -> None:
pass

monkeypatch.setattr(JpegXlImagePlugin, "_jpegxl", _mock_jpegxl)

with Image.open("Tests/images/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:
assert im.getexif() == {}
19 changes: 19 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ class ext_feature:
features = [
"zlib",
"jpeg",
"jpegxl",
"tiff",
"freetype",
"raqm",
Expand Down Expand Up @@ -742,6 +743,14 @@ def build_extensions(self) -> None:
feature.set("jpeg2000", "openjp2")
feature.set("openjpeg_version", ".".join(str(x) for x in best_version))

if feature.want("jpegxl"):
_dbg("Looking for jpegxl")
if _find_include_file(self, "jxl/encode.h") and _find_include_file(
self, "jxl/decode.h"
):
if _find_library_file(self, "jxl"):
feature.set("jpegxl", "jxl")

if feature.want("imagequant"):
_dbg("Looking for imagequant")
if _find_include_file(self, "libimagequant.h"):
Expand Down Expand Up @@ -952,6 +961,14 @@ def build_extensions(self) -> None:
else:
self._remove_extension("PIL._avif")

jpegxl = feature.get("jpegxl")
if isinstance(jpegxl, str):
# jxl and jxl_threads are required
libs = [jpegxl, jpegxl + "_threads"]
self._update_extension("PIL._jpegxl", libs)
else:
self._remove_extension("PIL._jpegxl")

tk_libs = ["psapi"] if sys.platform in ("win32", "cygwin") else []
self._update_extension("PIL._imagingtk", tk_libs)

Expand Down Expand Up @@ -992,6 +1009,7 @@ def summary_report(self, feature: ext_feature) -> None:
(feature.get("freetype"), "FREETYPE2"),
(feature.get("raqm"), "RAQM (Text shaping)", raqm_extra_info),
(feature.get("lcms"), "LITTLECMS2"),
(feature.get("jpegxl"), "JPEG XL"),
(feature.get("webp"), "WEBP"),
(feature.get("xcb"), "XCB (X protocol)"),
(feature.get("avif"), "LIBAVIF"),
Expand Down Expand Up @@ -1036,6 +1054,7 @@ def debug_build() -> bool:
Extension("PIL._imaging", files),
Extension("PIL._imagingft", ["src/_imagingft.c"]),
Extension("PIL._imagingcms", ["src/_imagingcms.c"]),
Extension("PIL._jpegxl", ["src/_jpegxl.c"]),
Extension("PIL._webp", ["src/_webp.c"]),
Extension("PIL._avif", ["src/_avif.c"]),
Extension("PIL._imagingtk", ["src/_imagingtk.c", "src/Tk/tkImaging.c"]),
Expand Down
Loading
Loading