Skip to content

Commit cefe49c

Browse files
authored
Rework public API of image encoders (#1586)
1 parent 8e5713b commit cefe49c

5 files changed

Lines changed: 180 additions & 198 deletions

File tree

benchmarks/encoders/benchmark_image_encoders.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@
4242

4343
torch.set_num_threads(1)
4444

45-
from torchcodec.encoders._image_encoders import ( # noqa: E402
46-
encode_jpeg as tc_encode_jpeg,
47-
encode_png as tc_encode_png,
45+
from torchcodec.encoders import ( # noqa: E402
46+
JpegEncoder as TCJpegEncoder,
47+
PngEncoder as TCPngEncoder,
4848
)
4949
from torchvision.io import ( # noqa: E402
5050
encode_jpeg as tv_encode_jpeg,
@@ -111,18 +111,18 @@ def load_source_image(override: Path | None) -> Tensor:
111111
def make_encode_fn(backend, fmt, dest, img, path, param):
112112
"""Return a zero-arg callable that encodes `img` (in `fmt`) to `dest`, or
113113
None if the backend doesn't support that destination. `param` is the quality
114-
(jpeg) or compression_level (png). torchcodec/torchvision take the encode
115-
param positionally so this stays format-agnostic."""
114+
(jpeg) or compression_level (png)."""
116115
is_png = fmt == "png"
117116
if backend == "torchcodec":
118-
tc_encode = tc_encode_png if is_png else tc_encode_jpeg
117+
Encoder = TCPngEncoder if is_png else TCJpegEncoder
118+
kwargs = {"compression_level" if is_png else "quality": param}
119119
if dest == "file":
120-
return lambda: tc_encode(img, path, param)
120+
return lambda: Encoder(img).to_file(path, **kwargs)
121121
if dest == "file_like":
122-
return lambda: tc_encode(img, io.BytesIO(), param)
123-
# tensor: native dest=None. On CUDA it returns a device tensor
122+
return lambda: Encoder(img).to_file_like(io.BytesIO(), **kwargs)
123+
# tensor: native to_tensor(). On CUDA it returns a device tensor
124124
# (zero-copy); .cpu() brings it to host to match torchvision's path.
125-
return lambda: tc_encode(img, None, param).cpu()
125+
return lambda: Encoder(img).to_tensor(**kwargs).cpu()
126126

127127
if backend == "torchvision":
128128
if is_png:
@@ -177,7 +177,9 @@ def make_batch_encode_fn(backend, imgs, quality):
177177
return the encoded bytes on the host (torchvision's live on the input
178178
device, so we .cpu() them)."""
179179
if backend == "torchcodec":
180-
return lambda: [tc_encode_jpeg(img, quality=quality).cpu() for img in imgs]
180+
return lambda: [
181+
TCJpegEncoder(img).to_tensor(quality=quality).cpu() for img in imgs
182+
]
181183

182184
if backend == "torchvision":
183185
return lambda: [t.cpu() for t in tv_encode_jpeg(list(imgs), quality=quality)]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
from ._audio_encoder import AudioEncoder # noqa
2+
from ._image_encoders import JpegEncoder, PngEncoder # noqa
23
from ._multi_stream_encoder import AudioStream, Encoder, VideoStream # noqa
34
from ._video_encoder import VideoEncoder # noqa

src/torchcodec/encoders/_image_encoders.py

Lines changed: 49 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from pathlib import Path
99

1010
import torch
11+
from torch import Tensor
1112

1213
from torchcodec._core.ops import (
1314
create_file_like_context,
@@ -19,112 +20,65 @@
1920
)
2021

2122

22-
def _encode_to_dest(input, dest, param, *, to_file, to_file_like) -> None:
23-
if isinstance(dest, (str, Path)):
24-
to_file(input, str(dest), param)
25-
else:
26-
# Assume file-like, it gets validated in C++ (it's tested).
27-
to_file_like(input, create_file_like_context(dest, True), param)
28-
29-
30-
def _encode_to_tensor_through_bytesio(input, param, to_file_like) -> torch.Tensor:
23+
def _encode_to_tensor_through_bytesio(img, param, to_file_like) -> Tensor:
3124
# Encode into an in-memory BytesIO and wrap its buffer as a 1-D uint8 tensor.
3225
# getbuffer() (unlike getvalue()) exposes the buffer without copying. We
3326
# could have native C++ implementation for that in each encoder, but it's
3427
# not always worth it (based on benchmarks). Currently, the only encoder
3528
# that really needs a dedicated C++ path is JPEG on CUDA.
3629
buf = io.BytesIO()
37-
to_file_like(input, create_file_like_context(buf, True), param)
30+
to_file_like(img, create_file_like_context(buf, True), param)
3831
return torch.frombuffer(buf.getbuffer(), dtype=torch.uint8)
3932

4033

41-
def encode_png(
42-
input: torch.Tensor,
43-
dest: str | Path | None = None,
44-
compression_level: int = 6,
45-
) -> torch.Tensor | None:
46-
"""Encode a CHW uint8 image tensor into a PNG.
47-
48-
Args:
49-
input (``torch.Tensor``): The image to encode, a 3-dimensional uint8
50-
tensor in CHW layout with 1 (grayscale) or 3 (RGB) channels.
51-
dest (str, ``pathlib.Path``, file-like object, or ``None``): The
52-
destination to write the encoded PNG to. Either a path to the output
53-
file, or a file-like object that supports
54-
``write(data: bytes) -> int`` and
55-
``seek(offset: int, whence: int = 0) -> int``, such as
56-
``io.BytesIO()`` or an open file in binary write mode. If ``None``
57-
(the default), the encoded bytes are returned as a 1-D uint8 tensor
58-
instead of being written anywhere.
59-
compression_level (int): zlib compression level between 0 (no
60-
compression, fastest) and 9 (max compression, slowest). Default: 6.
61-
62-
Returns:
63-
``None`` if ``dest`` is a path or file-like object, otherwise a 1-D uint8
64-
tensor of the encoded bytes.
65-
"""
66-
if dest is None:
67-
return _encode_to_tensor_through_bytesio(
68-
input, compression_level, _encode_png_to_file_like
69-
)
70-
else:
71-
_encode_to_dest(
72-
input,
73-
dest,
74-
compression_level,
75-
to_file=_encode_png_to_file,
76-
to_file_like=_encode_png_to_file_like,
34+
class JpegEncoder:
35+
def __init__(self, img: Tensor) -> None:
36+
self._img = img
37+
38+
def to_file(self, dest: str | Path, *, quality: int = 75) -> None:
39+
self._validate_quality(quality)
40+
_encode_jpeg_to_file(self._img, str(dest), quality)
41+
42+
def to_file_like(
43+
self, dest: io.RawIOBase | io.BufferedIOBase, *, quality: int = 75
44+
) -> None:
45+
self._validate_quality(quality)
46+
_encode_jpeg_to_file_like(
47+
self._img, create_file_like_context(dest, True), quality
7748
)
78-
return None
79-
80-
81-
def encode_jpeg(
82-
input: torch.Tensor,
83-
dest: str | Path | None = None,
84-
quality: int = 75,
85-
) -> torch.Tensor | None:
86-
"""Encode a CHW uint8 image tensor into a JPEG.
87-
88-
Args:
89-
input (``torch.Tensor``): The image to encode, a 3-dimensional uint8
90-
tensor in CHW layout with 1 (grayscale) or 3 (RGB) channels.
91-
dest (str, ``pathlib.Path``, file-like object, or ``None``): The
92-
destination to write the encoded JPEG to. Either a path to the output
93-
file, or a file-like object that supports
94-
``write(data: bytes) -> int`` and
95-
``seek(offset: int, whence: int = 0) -> int``, such as
96-
``io.BytesIO()`` or an open file in binary write mode. If ``None``
97-
(the default), the encoded bytes are returned as a 1-D uint8 tensor
98-
instead of being written anywhere.
99-
quality (int): Quality of the resulting JPEG, between 1 and 100. Higher
100-
means better quality and larger file size. Default: 75.
101-
102-
Returns:
103-
``None`` if ``dest`` is a path or file-like object. If ``dest`` is
104-
``None``, a 1-D uint8 tensor of the encoded bytes, on the same device as
105-
``input`` (a CUDA input yields a CUDA tensor; call ``.cpu()`` for host
106-
bytes).
107-
108-
If ``input`` is on a CUDA device, encoding is performed on the GPU with
109-
nvJPEG. Only 3-channel RGB tensors are supported on CUDA (grayscale must be
110-
encoded on the CPU).
111-
"""
112-
if quality < 1 or quality > 100:
113-
raise ValueError("Image quality should be a positive number between 1 and 100")
114-
115-
if dest is None:
116-
if input.is_cuda:
117-
return _encode_jpeg_to_tensor_cuda(input, quality)
49+
50+
def to_tensor(self, *, quality: int = 75) -> Tensor:
51+
self._validate_quality(quality)
52+
if self._img.is_cuda:
53+
return _encode_jpeg_to_tensor_cuda(self._img, quality)
11854
else:
11955
return _encode_to_tensor_through_bytesio(
120-
input, quality, _encode_jpeg_to_file_like
56+
self._img, quality, _encode_jpeg_to_file_like
57+
)
58+
59+
@staticmethod
60+
def _validate_quality(quality: int) -> None:
61+
if quality < 1 or quality > 100:
62+
raise ValueError(
63+
"Image quality should be a positive number between 1 and 100"
12164
)
122-
else:
123-
_encode_to_dest(
124-
input,
125-
dest,
126-
quality,
127-
to_file=_encode_jpeg_to_file,
128-
to_file_like=_encode_jpeg_to_file_like,
65+
66+
67+
class PngEncoder:
68+
def __init__(self, img: Tensor) -> None:
69+
self._img = img
70+
71+
def to_file(self, dest: str | Path, *, compression_level: int = 6) -> None:
72+
_encode_png_to_file(self._img, str(dest), compression_level)
73+
74+
def to_file_like(
75+
self, dest: io.RawIOBase | io.BufferedIOBase, *, compression_level: int = 6
76+
) -> None:
77+
_encode_png_to_file_like(
78+
self._img, create_file_like_context(dest, True), compression_level
79+
)
80+
81+
def to_tensor(self, *, compression_level: int = 6) -> Tensor:
82+
return _encode_to_tensor_through_bytesio(
83+
self._img, compression_level, _encode_png_to_file_like
12984
)
130-
return None

test/smoke_test.py

Lines changed: 44 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,13 @@
3434
decode_webp,
3535
VideoDecoder,
3636
)
37-
from torchcodec.encoders import AudioEncoder, Encoder, VideoEncoder
38-
from torchcodec.encoders._image_encoders import encode_jpeg, encode_png
37+
from torchcodec.encoders import (
38+
AudioEncoder,
39+
Encoder,
40+
JpegEncoder,
41+
PngEncoder,
42+
VideoEncoder,
43+
)
3944

4045

4146
@pytest.fixture(autouse=True)
@@ -336,22 +341,30 @@ def test_decode_heic(self):
336341
assert img.shape == (3, h, w)
337342

338343

339-
# CUDA JPEG encoding is triggered by the input tensor's device, so we wrap
340-
# encode_jpeg to move the input to the GPU.
341-
def _encode_jpeg_cuda(input, dest):
342-
return encode_jpeg(input.cuda(), dest)
343-
344-
345-
# Each backend: (encode_fn, decode_fn, suffix, lossless).
344+
# Each backend: (Encoder, device, decode_fn, suffix, lossless). CUDA JPEG
345+
# encoding is triggered by the input tensor's device.
346346
_IMAGE_ENCODERS = (
347347
pytest.param(
348-
encode_png, decode_png, "png", True, marks=pytest.mark.needs_png, id="png"
348+
PngEncoder,
349+
"cpu",
350+
decode_png,
351+
"png",
352+
True,
353+
marks=pytest.mark.needs_png,
354+
id="png",
349355
),
350356
pytest.param(
351-
encode_jpeg, decode_jpeg, "jpg", False, marks=pytest.mark.needs_jpeg, id="jpeg"
357+
JpegEncoder,
358+
"cpu",
359+
decode_jpeg,
360+
"jpg",
361+
False,
362+
marks=pytest.mark.needs_jpeg,
363+
id="jpeg",
352364
),
353365
pytest.param(
354-
_encode_jpeg_cuda,
366+
JpegEncoder,
367+
"cuda",
355368
decode_jpeg,
356369
"jpg",
357370
False,
@@ -362,26 +375,34 @@ def _encode_jpeg_cuda(input, dest):
362375

363376

364377
class TestImageEncoder:
365-
def _make_image(self):
366-
return torch.randint(0, 256, (3, HEIGHT, WIDTH), dtype=torch.uint8)
378+
def _make_image(self, device):
379+
return torch.randint(
380+
0, 256, (3, HEIGHT, WIDTH), dtype=torch.uint8, device=device
381+
)
367382

368-
@pytest.mark.parametrize("encode_fn,decode_fn,suffix,lossless", _IMAGE_ENCODERS)
369-
def test_encode_to_file(self, tmp_path, encode_fn, decode_fn, suffix, lossless):
370-
img = self._make_image()
383+
@pytest.mark.parametrize(
384+
"Encoder,device,decode_fn,suffix,lossless", _IMAGE_ENCODERS
385+
)
386+
def test_encode_to_file(
387+
self, tmp_path, Encoder, device, decode_fn, suffix, lossless
388+
):
389+
img = self._make_image(device)
371390
path = tmp_path / f"out.{suffix}"
372-
encode_fn(img, path)
391+
Encoder(img).to_file(path)
373392
assert path.stat().st_size > 0
374393

375394
decoded = decode_fn(str(path))
376395
assert decoded.shape == (3, HEIGHT, WIDTH)
377396
if lossless:
378-
torch.testing.assert_close(decoded, img, atol=0, rtol=0)
397+
torch.testing.assert_close(decoded, img.cpu(), atol=0, rtol=0)
379398

380-
@pytest.mark.parametrize("encode_fn,decode_fn,suffix,lossless", _IMAGE_ENCODERS)
381-
def test_encode_to_file_like(self, encode_fn, decode_fn, suffix, lossless):
382-
img = self._make_image()
399+
@pytest.mark.parametrize(
400+
"Encoder,device,decode_fn,suffix,lossless", _IMAGE_ENCODERS
401+
)
402+
def test_encode_to_file_like(self, Encoder, device, decode_fn, suffix, lossless):
403+
img = self._make_image(device)
383404
buf = io.BytesIO()
384-
encode_fn(img, buf)
405+
Encoder(img).to_file_like(buf)
385406
assert buf.getbuffer().nbytes > 0
386407

387408

0 commit comments

Comments
 (0)