Background

Requirements

Images processed and generated in Python are usually stored as NumPy arrays, and writing these arrays out to files is the final step of the pipeline. In the past, the most common approach was using PIL to encode them as JPEG. As needs around HDR, high bit-depth, and higher compression ratios have grown, I started looking for ways to encode numerical data directly into AVIF images.

libavif and libheif

Almost all related libraries are Python bindings for libavif or libheif. In practice, you can absolutely export a JPEG or a high bit-depth TIFF first, then call a compiled libavif directly for encoding, passing in all parameters (quality, speed, colour configuration). If all you need is image compression, there’s really no need to route back through Python.

So the core requirement is actually to find a good Python binding for libavif that supports passing various parameters, and if the API is elegant, even better.

Limited options

AVIF encoding in Python has seen some progress. PIL added official AVIF support in version 11.3.0, but both reading and writing are limited to 8-bit, and there is no interface for passing parameters. The following two plugins can read/write high bit-depth images and accept parameters.

Before PIL 11.3.0, the way to use PIL for AVIF encoding was the pillow-avif-plugin plugin, where advanced parameters could be passed in via keyword arguments.

Another choice with PIL is pillow-heif, which is a binding for libheif. After version 1.0.0 it removed AVIF support, with the developer’s reasoning being that PIL already has native AVIF support. The last version with AVIF support is 0.22.0, and advanced parameters also need to be passed via keyword arguments.

ImageIO and OpenImageIO both provide read/write support for various image formats, but they don’t seem to support high bit-depth and advanced parameters (or more accurately, their APIs are a bit complex and I haven’t fully figured them out yet, but it’s either unsupported or a hassle).

A new choice: ImageCodecs

ImageCodecs is another library that provides read/write support for various image formats, using libavif as the backend for AVIF.

Its strengths lie in its very clean API design. It previously did not support passing colour space parameters, but support was added in the 2025.11.11 release. (If you need something, feel free to ā€œreach outā€ reasonably in the Issue.)

Note: Please manually specify the encoding speed and thread count. You can use the default value 6 from libavif’s sample routine avifenc as a reference for speed. Otherwise, the default parameters may result in single-threaded encoding at the slowest speed, causing a 4K resolution image to take several minutes.

The library wraps some parameters, and you can also pass integer values directly. Definitions for colour primaries and transfer functions can be found in H.273.

Additionally, when using 10 or 12-bit depth, the uint16 data type should be employed without value stretching. For example, the maximum value for a 10-bit image should be 1023, while 12-bit images should use 4095.

from imagecodecs import avif_version, avif_encode, AVIF
import numpy as np

print(AVIF.available)
print(avif_version())

array = np.ones((100, 100), dtype=np.uint16) * 1023
# 10-bit, BT.2020 with BT.2100 PQ transfer function
encoded: bytes = avif_encode(
    array,
    level=AVIF.QUALITY.DEFAULT, # 0-100
    speed=AVIF.SPEED.FASTEST,   # 0-10
    bitspersample=10,
    primaries=AVIF.COLOR_PRIMARIES.BT2020,
    transfer=AVIF.TRANSFER_CHARACTERISTICS.PQ,
    numthreads=8,
)
with open("output.avif", "wb") as f:
    f.write(encoded)

All of its image formats follow similar function naming. You just need to check availability and version, then call encode or decode.

Watch out for the flashbang (sorry, might be too late).

bt2020 pq test by imagecodecs

Peak RGB and white in BT.2020 primaries with the BT.2100 PQ transfer function, 10-bit, encoded with ImageCodecs.