Skip to content

Linux and macOS wheels build OpenJPEG without optimisation, making JPEG 2000 decoding 2-3x slower #10057

Description

@sfarestam-iproov

What did you do?

I decoded JPEG 2000 images with the Pillow 12.3.0 wheels on Linux (manylinux, aarch64) and macOS (arm64), then replaced only the bundled libopenjp2 with the same OpenJPEG version (2.5.4) built two ways: exactly as the wheel build does it, and with -DCMAKE_BUILD_TYPE=Release. Pillow itself was unchanged.

What did you expect to happen?

The bundled library to perform like an optimised build of the same version.

What actually happened?

The bundled library performs like an unoptimised (-O0) build. The same OpenJPEG built as Release decodes 2 to 3 times faster:

Decode, best of 5 (portrait: best of 40) Wheel as shipped 2.5.4, no build type 2.5.4, Release
Linux aarch64, 4000x3000 lossy 1043 ms 1037 ms 365 ms
Linux aarch64, 4000x3000 lossless 6742 ms 6702 ms 3460 ms
Linux aarch64, 480x640 lossy 25.6 ms 25.4 ms 8.4 ms
macOS arm64, 4000x3000 lossy 1070 ms 1095 ms 347 ms
macOS arm64, 480x640 lossy 27.0 ms 28.0 ms 8.4 ms

The cause is that the Linux and macOS wheels build OpenJPEG through multibuild's build_openjpeg, which runs CMake without CMAKE_BUILD_TYPE:

OpenJPEG's CMakeLists.txt doesn't set a default build type, and the wheel build's CFLAGS contain no -O level, so the library is compiled with no optimisation. Built that way, the compile flags for openjp2 contain neither -O nor -DNDEBUG; with Release they are -O3 -DNDEBUG.

The Windows wheels are not affected: winbuild/build_prepare.py passes -DCMAKE_BUILD_TYPE=Release by default. The same script already adds -O3 -DNDEBUG by hand for libwebp (wheels-dependencies.sh line 309) and builds libavif as MinSizeRel.

build_openjpeg passes $HOST_CMAKE_FLAGS through to CMake, so one possible fix, following the libjpeg-turbo line above it, is:

HOST_CMAKE_FLAGS="-DCMAKE_BUILD_TYPE=Release $HOST_CMAKE_FLAGS" build_openjpeg

Release also defines NDEBUG, which disables OpenJPEG's assert() checks. That matches the Windows wheels and distribution packages. If you'd rather keep the asserts, -DCMAKE_C_FLAGS=-O2 gives the optimisation without NDEBUG.

What are your OS, Python and Pillow versions?

  • OS: Linux aarch64 (Docker python:3.14, manylinux wheel) and macOS 27.0 arm64
  • Python: 3.14.7
  • Pillow: 12.3.0 (wheel from PyPI), OpenJPEG 2.5.4
Pillow 12.3.0
Python 3.14.7
--- OPENJPEG (JPEG2000) support ok, loaded 2.5.4

To reproduce on Linux, inside the python:3.14 image:

apt-get update && apt-get install -y cmake
pip install pillow==12.3.0
git clone --depth 1 -b v2.5.4 https://github.com/uclouvain/openjpeg.git
cmake -S openjpeg -B default -DBUILD_CODEC=OFF && cmake --build default -j8
cmake -S openjpeg -B release -DBUILD_CODEC=OFF -DCMAKE_BUILD_TYPE=Release && cmake --build release -j8
LIB=$(python -c "import PIL, glob, os; print(glob.glob(os.path.join(os.path.dirname(PIL.__file__), '..', 'pillow.libs', 'libopenjp2*'))[0])")
cp "$LIB" wheel.so
import time
from io import BytesIO
from PIL import Image

im = Image.effect_noise((4000, 3000), 40).convert("RGB")
buf = BytesIO()
im.save(buf, "JPEG2000", irreversible=True, quality_mode="rates", quality_layers=[20])
open("test.jp2", "wb").write(buf.getvalue())

times = []
for _ in range(5):
    start = time.perf_counter()
    Image.open("test.jp2").load()
    times.append(time.perf_counter() - start)
print(f"{min(times) * 1000:.0f} ms")

Run the script with the shipped library, then after cp default/bin/libopenjp2.so.2.5.4 "$LIB", then after cp release/bin/libopenjp2.so.2.5.4 "$LIB". On macOS, the swapped .dylib needs codesign -f -s - before it will load.

🤖 Generated with Claude Code

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions