-
-
Notifications
You must be signed in to change notification settings - Fork 2.3k
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
base: main
Are you sure you want to change the base?
Changes from 8 commits
8e0c5db
a57ebea
23fb57d
2eb5987
37b58f3
eeaecb4
24b63ad
0b50410
f403672
f6086d4
5320450
1b049ab
6048520
8fa280f
58c37bf
48bbc2e
443a352
62c58c2
8cab1c1
e5003ff
fa5bfac
0b71605
1f00fb8
08270a7
9313587
4256b2a
8a1c03e
13944d5
bb06057
bc4a794
ff269ab
661c0d8
ade1db0
ceec3f9
5e0457a
9266318
29e4b55
3cd3848
aa6510f
36640de
7125fe4
05aee33
6c3f0b5
80e9963
29c1e4c
c8409e0
61ce5c2
bf0cdb2
79f941d
b5d64e8
ccca015
ece4065
e99989f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import re | ||
|
|
||
| import pytest | ||
|
|
||
| from PIL import Image, JxlImagePlugin, features | ||
|
|
||
| from .helper import ( | ||
| assert_image_similar_tofile, | ||
| skip_unless_feature, | ||
| ) | ||
|
|
||
| try: | ||
| from PIL import _jxl | ||
|
|
||
| HAVE_JXL = True | ||
| except ImportError: | ||
| HAVE_JXL = False | ||
|
|
||
| # cjxl v0.9.2 41b8cdab | ||
| # hopper.jxl: cjxl hopper.png hopper.jxl -q 75 -e 8 | ||
|
|
||
|
|
||
| class TestUnsupportedJxl: | ||
| def test_unsupported(self) -> None: | ||
| if HAVE_JXL: | ||
| JxlImagePlugin.SUPPORTED = False | ||
|
|
||
| file_path = "Tests/images/hopper.jxl" | ||
| with pytest.warns(UserWarning): | ||
| with pytest.raises(OSError): | ||
| with Image.open(file_path): | ||
| pass | ||
|
|
||
| if HAVE_JXL: | ||
| JxlImagePlugin.SUPPORTED = True | ||
|
|
||
|
|
||
| @skip_unless_feature("jxl") | ||
| class TestFileJxl: | ||
| def setup_method(self) -> None: | ||
| self.rgb_mode = "RGB" | ||
|
|
||
| def test_version(self) -> None: | ||
| _jxl.JxlDecoderVersion() | ||
| assert re.search(r"\d+\.\d+\.\d+$", features.version_module("jxl")) | ||
|
|
||
| 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 image: | ||
| assert image.mode == self.rgb_mode | ||
| assert image.size == (128, 128) | ||
| assert image.format == "JPEG XL" | ||
| image.load() | ||
| image.getdata() | ||
|
|
||
| # generated with: | ||
| # djxl hopper.jxl hopper_jxl_bits.ppm | ||
| assert_image_similar_tofile(image, "Tests/images/hopper_jxl_bits.ppm", 1.0) | ||
|
|
||
| def test_JxlDecode_with_invalid_args(self) -> None: | ||
| """ | ||
| Calling decoder functions with no arguments should result in an error. | ||
| """ | ||
|
|
||
| with pytest.raises(TypeError): | ||
| _jxl.PILJxlDecoder() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import pytest | ||
|
|
||
| from PIL import Image | ||
|
|
||
| from .helper import assert_image_similar_tofile | ||
|
|
||
| _webp = pytest.importorskip("PIL._jxl", reason="JXL support not installed") | ||
|
|
||
|
|
||
| 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` | ||
| file_path = "Tests/images/transparent.jxl" | ||
| with Image.open(file_path) as image: | ||
| assert image.mode == "RGBA" | ||
| assert image.size == (200, 150) | ||
| assert image.format == "JPEG XL" | ||
| image.load() | ||
| image.getdata() | ||
|
|
||
| image.tobytes() | ||
|
|
||
| assert_image_similar_tofile(image, "Tests/images/transparent.png", 1.0) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import pytest | ||
|
|
||
| from PIL import Image | ||
|
|
||
| from .helper import ( | ||
| assert_image_equal, | ||
| skip_unless_feature, | ||
| ) | ||
|
|
||
| pytestmark = [ | ||
| skip_unless_feature("jxl"), | ||
| ] | ||
|
|
||
|
|
||
| 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 | ||
|
|
||
|
|
||
| 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) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from types import ModuleType | ||
|
|
||
| import pytest | ||
|
|
||
| from PIL import Image | ||
|
|
||
| from .helper import skip_unless_feature | ||
|
|
||
| pytestmark = [ | ||
| skip_unless_feature("jxl"), | ||
| ] | ||
|
|
||
| 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: | ||
| file_path = "Tests/images/flower.jxl" | ||
| with Image.open(file_path) as image: | ||
| assert image.format == "JPEG XL" | ||
| exif_data = image.info.get("exif", None) | ||
| assert exif_data | ||
|
|
||
| exif = image._getexif() | ||
|
|
||
| # Camera make | ||
| assert exif[271] == "Canon" | ||
|
|
||
| with Image.open("Tests/images/flower.jpg") as jpeg_image: | ||
| expected_exif = jpeg_image.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: | ||
| file_path = "Tests/images/flower2.jxl" | ||
| with Image.open(file_path) as image: | ||
| assert image.format == "JPEG XL" | ||
| assert image.info.get("icc_profile", None) | ||
|
|
||
| icc = image.info["icc_profile"] | ||
|
|
||
| with Image.open("Tests/images/flower2.jxl") as jpeg_image: | ||
| expected_icc = jpeg_image.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 | ||
| assert im.getxmp() == {} | ||
|
|
||
| with Image.open("Tests/images/flower2.jxl") as im: | ||
| if ElementTree is None: | ||
| with pytest.warns( | ||
| UserWarning, | ||
| match="XMP data cannot be read without defusedxml dependency", | ||
| ): | ||
| assert im.getxmp() == {} | ||
| else: | ||
| assert ( | ||
| im.getxmp()["xmpmeta"]["xmptk"] | ||
| == "Adobe XMP Core 5.3-c011 66.145661, 2012/02/06-14:56:27 " | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from io import BytesIO | ||
|
|
||
| from PIL import Image | ||
|
|
||
| from .helper import PillowLeakTestCase, skip_unless_feature | ||
|
|
||
| test_file = "Tests/images/hopper.jxl" | ||
hugovk marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
|
|
||
| @skip_unless_feature("jxl") | ||
| class TestJxlLeaks(PillowLeakTestCase): | ||
| # TODO: lower the limit, I'm not sure what is correct limit | ||
| # since I have libjxl debug system-wide | ||
| mem_limit = 16 * 1024 # kb | ||
|
||
| iterations = 100 | ||
|
|
||
| def test_leak_load(self) -> None: | ||
| with open(test_file, "rb") as f: | ||
hugovk marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| im_data = f.read() | ||
|
|
||
| def core() -> None: | ||
| with Image.open(BytesIO(im_data)) as im: | ||
| im.load() | ||
|
|
||
| self._test_leak(core) | ||
radarhere marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
Uh oh!
There was an error while loading. Please reload this page.