From 029db53dfb7be4a07d692152d3e36dc2921e9b99 Mon Sep 17 00:00:00 2001 From: MilkClouds Date: Mon, 5 Oct 2026 10:40:45 +0900 Subject: [PATCH] Allow OpenCV 4.11 and NumPy 1.26; GIF/AVIF still need 4.12; bump to 0.4.1 Every opencv-python-headless 4.12+ wheel requires NumPy 2, so the 4.12 floor forced NumPy 2 on all users. Measured on Python 3.10 and 3.12 with NumPy 1.26.4, 2.0.2 and latest 2.x: 4.11.0.86 decodes JPEG, PNG, BMP and still and animated WebP and encodes JPEG/PNG, matching TorchCodec 0.17.0 as 4.12+ does; only GIF and AVIF are missing. 4.10.0.84 also cannot decode animated WebP, so 4.11 is the floor. The version check is now per codec: below 4.11 image codecs raise ImportError; GIF and AVIF raise RuntimeError naming OpenCV 4.12 and its NumPy 2 requirement. CI adds an OpenCV 4.11 + NumPy 1.26.4 job, and glibc 2.17 validation again installs NumPy 1.26.4 (with OpenCV 4.11 so pip keeps it). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YAijSnAE4aAjuS1EcCTwqS --- .github/workflows/build-av.yml | 2 +- .github/workflows/build-python.yml | 8 ++++++-- av/Cargo.lock | 2 +- av/Cargo.toml | 2 +- docs/images.md | 8 +++++--- docs/releasing.md | 4 ++-- docs/system_ffmpeg.md | 2 +- pyproject.toml | 10 ++++----- src/tensorcodec/__init__.py | 2 +- src/tensorcodec/_opencv.py | 21 +++++++++++++++---- src/tensorcodec/decoders/_images.py | 6 +++--- tests/test_images.py | 32 +++++++++++++++++++++++++++++ 12 files changed, 75 insertions(+), 24 deletions(-) diff --git a/.github/workflows/build-av.yml b/.github/workflows/build-av.yml index 9e25234..cea8400 100644 --- a/.github/workflows/build-av.yml +++ b/.github/workflows/build-av.yml @@ -89,7 +89,7 @@ jobs: python="/opt/python/$abi/bin/python" "$python" -m venv "/tmp/$abi" runtime="/tmp/$abi/bin/python" - if [ "$abi" = cp310-cp310 ]; then "$runtime" -m pip install --only-binary=:all: numpy==2.0.2; fi + if [ "$abi" = cp310-cp310 ]; then "$runtime" -m pip install --only-binary=:all: numpy==1.26.4 opencv-python-headless==4.11.0.86; fi "$runtime" -m pip install --only-binary=:all: /io/dist/*.whl /io/python-dist/*.whl env -u LD_LIBRARY_PATH "$runtime" /io/scripts/check_wheel_runtime.py check /io/.runtime-fixtures done diff --git a/.github/workflows/build-python.yml b/.github/workflows/build-python.yml index 41c6fb5..a4340fa 100644 --- a/.github/workflows/build-python.yml +++ b/.github/workflows/build-python.yml @@ -32,7 +32,11 @@ jobs: opencv: 'opencv-python-headless>=4.12' - runner: macos-15-intel python: '3.10' - opencv: 'opencv-python-headless==4.12.0.88' # lower bound + opencv: 'opencv-python-headless==4.12.0.88' # lower bound for GIF and AVIF + - runner: macos-15-intel + python: '3.10' + opencv: 'opencv-python-headless==4.11.0.86' # lower bound + numpy: 'numpy==1.26.4' runs-on: ${{ matrix.runner }} defaults: run: @@ -61,7 +65,7 @@ jobs: run: echo "$FFMPEG_BIN" >> "$GITHUB_PATH" - name: Install tensorcodec without tensorcodec-av run: | - python -m pip install pytest 'Pillow>=11.3' '${{ matrix.opencv }}' + python -m pip install pytest 'Pillow>=11.3' '${{ matrix.opencv }}' '${{ matrix.numpy || 'numpy' }}' python -m pip install --no-index --find-links dist tensorcodec python -c "import importlib.util as u; assert u.find_spec('tensorcodec_av') is None" - name: Test image codecs diff --git a/av/Cargo.lock b/av/Cargo.lock index ef67f69..5ae0aee 100644 --- a/av/Cargo.lock +++ b/av/Cargo.lock @@ -449,7 +449,7 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tensorcodec-av" -version = "0.4.0" +version = "0.4.1" dependencies = [ "ffmpeg-sys-next", "libc", diff --git a/av/Cargo.toml b/av/Cargo.toml index 57a5e2f..f6656af 100644 --- a/av/Cargo.toml +++ b/av/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tensorcodec-av" -version = "0.4.0" +version = "0.4.1" edition = "2021" rust-version = "1.88" # ffmpeg-sys-next 9 builds with let chains license = "Apache-2.0" diff --git a/docs/images.md b/docs/images.md index f7d0981..8a33665 100644 --- a/docs/images.md +++ b/docs/images.md @@ -16,9 +16,11 @@ encoded = JpegEncoder(rgb).to_tensor(quality=90) # 1-D uint8 NumPy array ## Installation -`uv pip install tensorcodec` includes the image backend: `opencv-python-headless` 4.12+ -(tested with 4.12, 4.13 and 5.0), imported only when an image codec is first used. Earlier -wheels lack the GIF and AVIF decoders. Use only one OpenCV wheel variant per environment: +`uv pip install tensorcodec` includes the image backend: `opencv-python-headless` 4.11+ +(tested with 4.11, 4.12, 4.13 and 5.0), imported only when an image codec is first used. +GIF and AVIF decoding needs 4.12+, which requires NumPy 2; 4.11 wheels lack those decoders +and raise an error naming the required version. 4.11 works with NumPy 1.26 and 2.x. +Use only one OpenCV wheel variant per environment: another variant such as `opencv-python` also provides `cv2` and conflicts with it. Pillow is used only in tests. diff --git a/docs/releasing.md b/docs/releasing.md index 3951c68..4907320 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,6 +1,6 @@ # Publishing TensorCodec -Release version: `0.4.0`. Each release publishes two PyPI projects at the same version: +Release version: `0.4.1`. Each release publishes two PyPI projects at the same version: `tensorcodec` (pure Python, built by hatchling) and `tensorcodec-av` (the FFmpeg extension in `av/`, built by maturin). `tensorcodec` pins `tensorcodec-av==` behind a platform marker; bump the version in `pyproject.toml` (twice), `av/Cargo.toml`, @@ -65,7 +65,7 @@ and source links are recorded in `av/licenses/README.md`. conda-forge dependency graph. - Release validation installs each repaired wheel with the `tensorcodec` wheel on glibc 2.17 with Python 3.10 and 3.13 and decodes video/audio without Torch, PyAV or a system FFmpeg. Python - 3.10 also checks the minimum NumPy line (2.0.2). Native + 3.10 also checks the minimum NumPy line (1.26.4, with OpenCV 4.11). Native x86_64 and ARM64 runners also run the full pinned playback oracle comparison. - Release validation still tests the installed repaired wheel. The fixture CLI can be FFmpeg 6 or 7; fixtures explicitly remove auxiliary sentinel packets. diff --git a/docs/system_ffmpeg.md b/docs/system_ffmpeg.md index a023642..c6e7862 100644 --- a/docs/system_ffmpeg.md +++ b/docs/system_ffmpeg.md @@ -37,7 +37,7 @@ test -f "$FFMPEG_DIR/include/libavcodec/avcodec.h" test -f "$FFMPEG_DIR/lib/libavcodec.so.61" uv venv -uv pip install 'tensorcodec==0.4.0' 'tensorcodec-av==0.4.0' --no-binary tensorcodec-av +uv pip install 'tensorcodec==0.4.1' 'tensorcodec-av==0.4.1' --no-binary tensorcodec-av ``` Only `tensorcodec-av` is built from source; naming it also covers platforms diff --git a/pyproject.toml b/pyproject.toml index 2ef0764..585726f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tensorcodec" -version = "0.4.0" +version = "0.4.1" description = "Video, audio and image codecs with TorchCodec-style APIs and NumPy arrays" readme = "README.md" license = "Apache-2.0" @@ -9,11 +9,11 @@ authors = [{ name = "Suhwan Choi", email = "milkclouds00@gmail.com" }] requires-python = ">=3.10" # Exact pin; the marker follows the tensorcodec-av wheel tags. No platform_release check (macOS 14): # pip's packaging 22-25 raises on Linux kernel releases that are not PEP 440 versions. -# opencv-python-headless 4.12+ requires NumPy 2. +# GIF and AVIF need opencv-python-headless 4.12+, which requires NumPy 2. dependencies = [ - "numpy>=2", - "opencv-python-headless>=4.12", - "tensorcodec-av==0.4.0; platform_python_implementation == 'CPython' and ((sys_platform == 'linux' and (platform_machine == 'x86_64' or platform_machine == 'aarch64')) or (sys_platform == 'darwin' and platform_machine == 'arm64'))", + "numpy>=1.26", + "opencv-python-headless>=4.11", + "tensorcodec-av==0.4.1; platform_python_implementation == 'CPython' and ((sys_platform == 'linux' and (platform_machine == 'x86_64' or platform_machine == 'aarch64')) or (sys_platform == 'darwin' and platform_machine == 'arm64'))", ] classifiers = [ "Development Status :: 3 - Alpha", diff --git a/src/tensorcodec/__init__.py b/src/tensorcodec/__init__.py index 9b884ef..6fc9328 100644 --- a/src/tensorcodec/__init__.py +++ b/src/tensorcodec/__init__.py @@ -3,5 +3,5 @@ from tensorcodec import decoders, transforms from tensorcodec._frame import AudioSamples, Frame, FrameBatch -__version__ = "0.4.0" +__version__ = "0.4.1" __all__ = ["AudioSamples", "Frame", "FrameBatch", "decoders", "transforms"] diff --git a/src/tensorcodec/_opencv.py b/src/tensorcodec/_opencv.py index 6620b59..0d0a57c 100644 --- a/src/tensorcodec/_opencv.py +++ b/src/tensorcodec/_opencv.py @@ -1,10 +1,23 @@ """Lazy access to OpenCV, which is imported only when an image codec is used.""" +# First PyPI wheels with GIF and AVIF decoders; they also require NumPy 2 (docs/images.md). +CODEC_MIN_VERSION = {"gif": (4, 12), "avif": (4, 12)} -def opencv(): + +def version(cv2): + return tuple(int(part) for part in cv2.__version__.split(".")[:2]) + + +def opencv(codec=None): import cv2 - # 4.12: first PyPI wheels with GIF and AVIF decoders; 4.10/4.11 fail only those tests (docs/images.md). - if tuple(int(part) for part in cv2.__version__.split(".")[:2]) < (4, 12): - raise ImportError(f"Image codecs require OpenCV >= 4.12, found {cv2.__version__}") + # 4.10 wheels cannot decode animated WebP. + if version(cv2) < (4, 11): + raise ImportError(f"Image codecs require OpenCV >= 4.11, found {cv2.__version__}") + if codec in CODEC_MIN_VERSION and version(cv2) < CODEC_MIN_VERSION[codec]: + required = ".".join(map(str, CODEC_MIN_VERSION[codec])) + raise RuntimeError( + f"{codec.upper()} decoding requires OpenCV >= {required} (which requires NumPy >= 2), " + f"found {cv2.__version__}" + ) return cv2 diff --git a/src/tensorcodec/decoders/_images.py b/src/tensorcodec/decoders/_images.py index 88911f9..f36398c 100644 --- a/src/tensorcodec/decoders/_images.py +++ b/src/tensorcodec/decoders/_images.py @@ -193,7 +193,7 @@ def _image(source, codec, mode, output_dtype): channels = _png_channels(data) if codec == "png" else None if codec == "heic": raise NotImplementedError("HEIC decoding is not supported by the OpenCV image adapter") - cv = opencv() + cv = opencv(codec) flags = cv.IMREAD_UNCHANGED if codec == "jpeg": if _jpeg_components(data) == 4 and mode is ImageReadMode.UNCHANGED: @@ -271,8 +271,8 @@ def decode_image(source, *, mode="RGB", output_dtype=np.uint8): Sources are paths, bytes or 1-D uint8 arrays. Modes: UNCHANGED, GRAY, GRAY_ALPHA, RGB, RGB_ALPHA (case-insensitive strings or ImageReadMode). output_dtype is uint8, uint16 or 'auto'; integer conversion scales the range. - BMP is detected too. Requires OpenCV >= 4.12. HEIC and animated PNG - are unsupported. + BMP is detected too. GIF and AVIF require OpenCV >= 4.12. HEIC and + animated PNG are unsupported. """ return _image(source, None, mode, output_dtype) diff --git a/tests/test_images.py b/tests/test_images.py index f9e8096..2368098 100644 --- a/tests/test_images.py +++ b/tests/test_images.py @@ -61,6 +61,17 @@ def encoded_images(tmp_path_factory): return root +def opencv_lacks(backend, codec): + """GIF/AVIF need OpenCV 4.12; older wheels must raise rather than decode.""" + if backend.__name__.startswith("torchcodec"): + return False + import cv2 + + from tensorcodec._opencv import CODEC_MIN_VERSION, version + + return version(cv2) < CODEC_MIN_VERSION.get(codec, (0,)) + + @pytest.mark.parametrize("channels", [1, 2, 3, 4]) @pytest.mark.parametrize("depth", [8, 16]) def test_png_native_samples(backend, channels, depth): @@ -117,6 +128,10 @@ def test_image_sources_and_content_detection(backend, tmp_path, kind): @pytest.mark.parametrize("codec", ["jpeg", "webp", "gif", "avif"]) def test_format_functions_and_dispatch(backend, encoded_images, codec): path = encoded_images / f"image.{codec}" + if opencv_lacks(backend, codec): + with pytest.raises(RuntimeError, match=r"OpenCV >= 4\.12 \(which requires NumPy >= 2\)"): + getattr(backend, f"decode_{codec}")(path) + return if codec == "avif" and not backend.__name__.startswith("torchcodec"): import cv2 @@ -142,6 +157,8 @@ def test_jpeg_batch(backend, encoded_images): def test_gif_animation(backend, encoded_images): + if opencv_lacks(backend, "gif"): + pytest.skip("GIF needs OpenCV 4.12") frames = as_numpy(backend.decode_gif(encoded_images / "animated.gif")) assert frames.shape == (2, 3, 16, 24) assert not np.array_equal(frames[0], frames[1]) @@ -183,6 +200,21 @@ def test_image_errors_and_cpu_boundary(): decode_image(Path("/nonexistent/image.png")) +@pytest.mark.parametrize("codec", ["gif", "avif"]) +def test_codec_version_floor(monkeypatch, encoded_images, codec): + import cv2 + + from tensorcodec import decoders + + monkeypatch.setattr(cv2, "__version__", "4.11.0") + with pytest.raises(RuntimeError, match=rf"{codec.upper()} decoding requires OpenCV >= 4\.12 .*NumPy >= 2"): + getattr(decoders, f"decode_{codec}")(encoded_images / f"image.{codec}") + assert decoders.decode_image(encoded_images / "image.webp").shape == (3, 16, 24) + monkeypatch.setattr(cv2, "__version__", "4.10.0") + with pytest.raises(ImportError, match=r"OpenCV >= 4\.11"): + decoders.decode_image(encoded_images / "image.webp") + + def test_heic_is_explicitly_unsupported(): from tensorcodec.decoders import decode_image