Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build-av.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/build-python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion av/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion av/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
8 changes: 5 additions & 3 deletions docs/images.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions docs/releasing.md
Original file line number Diff line number Diff line change
@@ -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==<version>`
behind a platform marker; bump the version in `pyproject.toml` (twice), `av/Cargo.toml`,
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/system_ffmpeg.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/tensorcodec/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
21 changes: 17 additions & 4 deletions src/tensorcodec/_opencv.py
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions src/tensorcodec/decoders/_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand Down
32 changes: 32 additions & 0 deletions tests/test_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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

Expand All @@ -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])
Expand Down Expand Up @@ -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

Expand Down
Loading