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
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
libopenjp2with 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 asReleasedecodes 2 to 3 times faster:ReleaseThe cause is that the Linux and macOS wheels build OpenJPEG through multibuild's
build_openjpeg, which runs CMake withoutCMAKE_BUILD_TYPE:build_openjpegis called atPillow/.github/workflows/wheels-dependencies.sh
Line 307 in ad277e2
build_openjpeg: https://github.com/multi-build/multibuild/blob/4546f4d4820ffd0104b872c3b6187260b3e64edc/library_builders.sh#L241-L244OpenJPEG's
CMakeLists.txtdoesn't set a default build type, and the wheel build'sCFLAGScontain no-Olevel, so the library is compiled with no optimisation. Built that way, the compile flags foropenjp2contain neither-Onor-DNDEBUG; withReleasethey are-O3 -DNDEBUG.The Windows wheels are not affected:
winbuild/build_prepare.pypasses-DCMAKE_BUILD_TYPE=Releaseby default. The same script already adds-O3 -DNDEBUGby hand for libwebp (wheels-dependencies.shline 309) and builds libavif asMinSizeRel.build_openjpegpasses$HOST_CMAKE_FLAGSthrough 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_openjpegReleasealso definesNDEBUG, which disables OpenJPEG'sassert()checks. That matches the Windows wheels and distribution packages. If you'd rather keep the asserts,-DCMAKE_C_FLAGS=-O2gives the optimisation withoutNDEBUG.What are your OS, Python and Pillow versions?
python:3.14, manylinux wheel) and macOS 27.0 arm64To reproduce on Linux, inside the
python:3.14image:Run the script with the shipped library, then after
cp default/bin/libopenjp2.so.2.5.4 "$LIB", then aftercp release/bin/libopenjp2.so.2.5.4 "$LIB". On macOS, the swapped.dylibneedscodesign -f -s -before it will load.🤖 Generated with Claude Code