Skip to content
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

Add AVIF plugin (decoder + encoder using libavif) #5201

Open
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

fdintino
Copy link

@fdintino fdintino commented Jan 11, 2021

Resolves #7983

This adds support for AVIF encoding and decoding, including AVIF image sequences.

I've added tests, and integrated libavif into the windows, linux, and mac CI builds. I haven't done anything to integrate with the docker-images repo.

I chose libavif rather than libheif because the former has been embraced by AOMedia and it's what Chromium uses. Packaging support is spotty at the moment, but I expect that to change soon (currently it's in Debian testing, Fedora rawhide, Ubuntu hirsute, and Alpine edge).

A few notes on the implementation here:

  • The plugin currently only supports encoding 8-bit images, and all images are decoded to 8-bit RGB(A). I wasn't totally clear on how best to deal with higher bit depths (somewhat related issue: Add support for high bit depth multichannel images #1888)
  • The RGB to YUV conversion isn't exposed to python at all. Chroma subsampling and the presence of an alpha channel make it non-trivial to return decoded images as YCbCr. It would not be difficult to permit encoding from a YCbCr source.
  • Since there isn't a way to pass parameters to Image.open (Parameters for Image.open() #569), I'm using module globals in AvifImagePlugin.py to make decoder codec choice and chroma upsampling configurable. I suspect there's a better way to do this.

The star.avifs test file is licensed as CC-BY

I linted the C code with the new clang-format settings, but made the following change so that it didn't make PyObject_HEAD and the threading macros look wonky:

diff --git a/.clang-format b/.clang-format
index be32e6d1..300f8e54 100644
--- a/.clang-format
+++ b/.clang-format
@@ -18,3 +18,7 @@ SpaceBeforeParens: ControlStatements
 SpacesInParentheses: false
 TabWidth: 4
 UseTab: Never
+StatementMacros:
+  - PyObject_HEAD
+  - Py_BEGIN_ALLOW_THREADS
+  - Py_END_ALLOW_THREADS

Tests/helper.py Outdated
@@ -206,6 +207,7 @@ def _test_leak(self, core):
start_mem = self._get_mem_usage()
for cycle in range(self.iterations):
core()
gc.collect()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you want to talk about why you added this?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I accidentally left this in here while I was debugging. I'll remove it.

Copy link
Author

@fdintino fdintino Jan 12, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I realized now why I added this: without it the leak tests are non-deterministic. I could pad the memory limit to counteract the fact that it may not have hit the gc generation threshold before it checks the memory, but forcing garbage collection after each iteration ensures that the test is deterministic.

src/_avif.c Outdated
}

avifRGBImageAllocatePixels(&rgb);
memcpy(rgb.pixels, rgb_bytes, size);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please document in a comment that this is safe for r/w, and potentially add an explict check that the rgb_bytes/rgb.pixels is large enough.

src/_avif.c Outdated
return NULL;
}

memcpy(self->data, avif_bytes, size);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Document here as well.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't entirely sure what you wanted documented for this line. I added this, let me know if it's what you had in mind:

Pillow/src/_avif.c

Lines 484 to 485 in b84a8e0

// We need to allocate storage for the decoder for the lifetime of the object
// (avifDecoderSetIOMemory does not copy the data passed into it)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was able to avoid a memcpy here by having PyArg_ParseTuple pass in a PyBytesObject and incrementing the reference in the new / decrementing in the dealloc. That also avoids an unnecessary malloc during decoding.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Realized it would probably be better to have you resolve these conversations, to confirm that the feedback has indeed been addressed.

return NULL;
}

size = rgb.rowBytes * rgb.height;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this guaranteed to not overflow, even in the face of invalid input?

Copy link
Author

@fdintino fdintino Jan 12, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

libavif currently restricts images to a maximum of 2^28 pixels. If the dimensions are larger than 16384x16384 then the function that sets decoder->image->width and decoder->image->height fails. So I suppose that a 4-channel 16384x16384 8-bit image could overflow on a 32-bit platform. I'm not certain because the codecs used by libavif have their own overflow limit checks. For instance, dav1d enforces a maximum of 2^26 pixels on 32-bit systems. Should I add a check against PY_SSIZE_T_MAX to be sure? (edit: answering my own question and adding this check)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added here

Pillow/src/_avif.c

Lines 619 to 622 in b84a8e0

if (rgb.height > PY_SSIZE_T_MAX / row_bytes) {
PyErr_SetString(PyExc_MemoryError, "Integer overflow in pixel size");
return NULL;
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Basically, I'm the one who will get a CVE on this if there's a problem, and I'd like really clear guidelines about what the assumptions are for sizes of things and where they come from for dangerous operations like memset, malloc, and pointer reads/writes. This isn't so much for now, but a couple years down the line, things need to be clear. This will be fuzzed, this will be run under valgrind, so hopefully there won't be problems.

I've basically had to reverse engineer how SgiRleDecode works over the last month or so, and I'd like to be preventing that sort of experience in the future.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does raising a MemoryError if rgb.height > PY_SSIZE_T_MAX / row_bytes (as I have in the latest PR push) suffice to address that concern?

.ci/install.sh Outdated Show resolved Hide resolved
.ci/install.sh Outdated
@@ -29,6 +30,7 @@ python3 -m pip install -U pytest
python3 -m pip install -U pytest-cov
python3 -m pip install pyroma
python3 -m pip install test-image-results
python3 -m pip install meson
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does meson need to be added to the requirements.txt?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so. It's a build dependency of dav1d so it's used when building libavif in CI, but it's not otherwise required to run tests.



@skip_unless_feature("avif")
class TestAvifLeaks(PillowLeakTestCase):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer not iterating a leak test in the standard test suite, as that can be expensive from a time POV. It's ok for the initial cut, but I'd rather not have it long term.

@nulano
Copy link
Contributor

nulano commented Jan 11, 2021

Adding libavif to MSYS2 fails to compile due to a few missing defines (AVIF_CHROMA_UPSAMPLING_AUTOMATIC, AVIF_CHROMA_UPSAMPLING_FASTEST, AVIF_CHROMA_UPSAMPLING_BEST_QUALITY): https://github.com/nulano/Pillow/runs/1683386633?check_suite_focus=true#step:5:77

@fdintino
Copy link
Author

@nulano it looks like those defines were only added in libavif 0.8.3. I'll figure some #if version checks around their usage.

@fdintino
Copy link
Author

@nulano Is it okay if I cherry-pick your MSYS commit into this PR?

Copy link
Contributor

@nulano nulano left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nulano Is it okay if I cherry-pick your MSYS commit into this PR?

Of course, cherry-pick away!

I have a few nitpicks for winbuild/build_prepare, I haven't looked at the rest yet.

Comment on lines 326 to 329
'cmd.exe /c "aom.cmd"',
"if errorlevel 1 echo AOM build failed! && exit /B 1",
'cmd.exe /c "dav1d.cmd"',
"if errorlevel 1 echo dav1d build failed! && exit /B 1",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The last line of aom.cmd is cd ../.. which will never fail, so the errorlevel checks have absolutely no effect. The build fails on my system as I don't have git on PATH, yet it continues anyway. However, there should also be a "clean" step, probably cmd_rmdir, so that it is possible to re-run the generated scripts when debugging build_prepare locally.

Suggested change
'cmd.exe /c "aom.cmd"',
"if errorlevel 1 echo AOM build failed! && exit /B 1",
'cmd.exe /c "dav1d.cmd"',
"if errorlevel 1 echo dav1d build failed! && exit /B 1",
cmd_rmdir("aom"),
'cmd.exe /c "aom.cmd"',
cmd_rmdir("dav1d"),
'cmd.exe /c "dav1d.cmd"',

I would prefer to build these as separate steps (similar to libpng and freetype, or raqm and its dependencies), but AFAICT AOM can only be downloaded using git, so it's probably simpler this way. But the extra dependencies (git, meson, ninja) should at least be noted in the winbuild/build.rst and winbuild/readme.md documents as well as maybe docs/installation.rst (Bulding on Windows section). There should also be a way to disable these similar to the --no-imagequant and --no-raqm flags, such as --no-avif.

winbuild/build_prepare.py Outdated Show resolved Hide resolved
winbuild/build_prepare.py Outdated Show resolved Hide resolved
winbuild/build_prepare.py Outdated Show resolved Hide resolved
@fdintino
Copy link
Author

@radarhere @wiredfool @nulano I think I've addressed all feedback (except for the requests for docs on building), but I've left it up to you all to resolve conversations (or not).

Is this PR generally on the right track? I've held off on writing docs until I've gotten a signal one way or the other.

@fdintino
Copy link
Author

fdintino commented Feb 15, 2021

Since it's been a month since I asked my question without response, I'll try to reframe it as more specific questions that might be more answerable.

  1. Is the general structure of this plugin acceptable? I tried to hew closely to the conventions elsewhere in the repo, so I assume so, but would appreciate confirmation.
  2. Is the test coverage sufficient? The gaps are all in the error handling. I'd be happy to try to add test cases for those to make it more complete.
  3. I'd appreciate feedback on the encoder settings. For instance: should yuv_format be renamed subsampling to be consistent with the Jpeg plugin? Should I offer a jpeg-like 0-100 quality setting that maps the min/max quantizer? (The colorist library has such a quality setting that still allows qmin/qmax as an advanced override option). Should I eliminate any options? (My vote would be to get rid of qmin_alpha and qmax_alpha).
  4. What would you like to see, CI-wise, with this pull request? While waiting for feedback I worked on putting this plugin in its own package, which builds manylinux wheels in the same manner as the pillow-wheels repo. I setup builds for all the codecs supported by libavif, on all platforms, and also added cached dependencies (see pillow-avif-plugin-depends). That includes a crate vendor tarball for rav1e, as I've found crates.io to be too unreliable for frequent CI builds. Would you want to wait until this PR is wrapped up and merged before I opened a pull request against pillow-wheels?
    • Note: I think licensing shouldn't be an issue for anything: AOM, rav1e, dav1d, SVT-AV1 and libyuv are all BSD-2 licensed, libgav1 is Apache, and they all are covered under the Alliance for Open Media Patent License.
    • It might be overkill to include all codecs in the manylinux and windows wheels. In particular, SVT-AV1 is far from ready for prime-time. My recommendation would be to include AOM, dav1d, and rav1e by default. AOM, being the reference implementation, is the most complete and has the highest quality, while dav1d and rav1e are the fastest (setting aside SVT-AV1).

@fdintino fdintino force-pushed the libavif-plugin branch 3 times, most recently from b433571 to ff56a9c Compare February 24, 2021 03:54
Tests/test_file_avif.py Outdated Show resolved Hide resolved
Tests/test_file_avif.py Outdated Show resolved Hide resolved
@radarhere
Copy link
Member

radarhere commented Mar 26, 2021

This might be a libavif bug, but I find that if I run this PR, libavif has stopped working for macOS.

https://github.com/radarhere/Pillow/runs/2201531959#step:8:1174

/Users/runner/work/Pillow/Pillow/depends/libavif-0.8.4/ext/libyuv/include/libyuv/row.h:750:5: error: 'LIBYUV_UNLIMITED_DATA' is not defined, evaluates to 0 [-Werror,-Wundef]
#if LIBYUV_UNLIMITED_DATA
^
1 error generated.

LIBYUV_UNLIMITED_DATA was a change introduced in libyuv in the last month - https://chromium.googlesource.com/libyuv/libyuv/+/ba033a11e3948e4b3%5E%21/#F2

@wiredfool
Copy link
Member

We're going to need to add the required libraries to the docker images as well, and we're going to need to add these to the oss-fuzz builder to get fuzzer support.

Might as well make a PR to the Pillow-wheels for whatever needs to happen on build. That will also be potentially helpful for getting the dependencies into oss-fuzz.

@mara004
Copy link

mara004 commented Sep 23, 2023

What is the advantage of supporting AVIF in Pillow in the first place, rather than through an external plugin?

@fdintino
Copy link
Author

fdintino commented Sep 23, 2023

I don't want to overstate the maintenance burden. I'm a single person and it hasn't been terribly difficult for me to stay on top of libavif updates for pillow-avif-plugin. I'll probably continue to do that for a while—at least as long as I'm working at The Atlantic. We use thumbor, and have it configured to auto-transcode images to AVIF for requests that Accept image/avif. But I certainly wouldn't mind help.

What makes libavif unusual compared to the other pillow dependencies is that it doesn't actually have its own AVIF codec. There are several different AVIF encoding and decoding libraries, that offer different features and have varying performance and compression quality. The value of libavif is the ability to have a single unified API to use AOM, or rav1e, or dav1d, depending on the requirements. I've chosen to ship all supported codecs with the pillow-avif-plugin wheel, but I don't think that pillow would have to do the same. If I had to narrow it down, I would recommend rav1e for encoding and dav1d for decoding. This ought to reduce the impact on the wheel size, and make CI maintenance easier.

I think having AVIF in core pillow might give shape to the implementation of features like high bitrate multichannel images (#1888). There are other features (like supporting YCbCr mode with alpha) that would be nice-to-have, and which would be useful in the implementation of support for next-generation image formats like HEIF and JPEG XL.

@Yay295
Copy link
Contributor

Yay295 commented Sep 24, 2023

Would it be possible to check if an AVIF codec is available and use it, instead of bundling any of them?

@fdintino fdintino force-pushed the libavif-plugin branch 3 times, most recently from dec686a to 6dae23a Compare September 24, 2023 03:43
@fdintino
Copy link
Author

@Yay295 That is actually what this pull request does, with the exception of the wheels for windows (which are built in this repo, not in pillow-wheels). The discussions around maintenance all have to do with the pillow-wheels repository. But they're not entirely theoretical; the CI for my project pillow-avif-plugin is based on the one from pillow-wheels, and I publish wheels for all the same platforms and architectures as pillow.

@aclark4life If I could correct one thing: this is not a work-in-progress. This pull request was turned into pillow-avif-plugin, which I consider to be complete, with a full CI workflow. I know of several organizations that use it in production. The only thing I haven't stayed on top of is resolving conflicts in this pull request. I'm disinclined to invest too much time into that until I have a clearer sense of whether or not it will be merged. Even less so for my now very outdated pillow-wheels PR.

@Yay295
Copy link
Contributor

Yay295 commented Sep 24, 2023

#7418 might be relevant then. I'm not sure if that changes anything.

@fdintino fdintino force-pushed the libavif-plugin branch 2 times, most recently from 5e605d1 to 7db6d42 Compare September 25, 2023 02:48
@stalkerg
Copy link

avif.lib(codec_rav1e.c.obj) : error LNK2001: unresolved external symbol _rav1e_packet_unref
avif.lib(codec_rav1e.c.obj) : error LNK2001: unresolved external symbol _rav1e_frame_fill_plane
build\lib.win32-cpython-311\PIL\_avif.cp311-win32.pyd : fatal error LNK1120: 17 unresolved externals

Maybe there is a version mismatch?

@fdintino
Copy link
Author

It is an issue with how the libavif static library is built. I’m working on a pull request to that project that will fix it.

@fdintino fdintino force-pushed the libavif-plugin branch 2 times, most recently from de67346 to e0f5c50 Compare October 2, 2023 16:11
@fdintino
Copy link
Author

fdintino commented Oct 2, 2023

I was mistaken. There were issues with how the libavif dependencies were being linked (the fixes for which were just merged in AOMediaCodec/libavif#1579). But the reason that the appveyor build was failing is that the python 3.11 environment is 32-bit. If we care to support this I can open a pull request to rav1e to add a win32 deploy artifact. For the time being I'm skipping libavif setup in 32-bit windows environments.

@fdintino
Copy link
Author

fdintino commented Oct 4, 2023

I've added the script necessary to build wheels with dav1d for decoding and rav1e for encoding, so that there is something concrete to discuss. It does make the wheel files about 70% larger.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Unable to open AVIF images