Skip to content

Commit 6399076

Browse files
author
pytorchbot
committed
2026-08-21 nightly release (d4b6dfd)
1 parent 6809ce3 commit 6399076

46 files changed

Lines changed: 3226 additions & 785 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

benchmarks/bench_blocks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ def _demux(demuxer):
6767
def _decode(decoder, packets):
6868
for packet in packets:
6969
yield from decoder.decode(packet)
70-
yield from decoder.flush()
70+
yield from decoder.drain()
7171

7272

7373
def _convert(converter, frames):

docs/source/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ def __call__(self, filename):
8181
"custom_frame_mappings.py",
8282
"transforms.py",
8383
"hdr_decoding.py",
84+
"blocks.py",
8485
]
8586
elif "examples/encoding" in self.src_dir:
8687
order = [

docs/source/index.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,13 @@ Decoding
106106

107107
How to decode HDR videos with the ``output_dtype`` parameter
108108

109+
.. grid-item-card:: :octicon:`file-code;1em`
110+
Blocks (experimental)
111+
:link: generated_examples/decoding/blocks.html
112+
:link-type: url
113+
114+
A preview of the unreleased building-block decoding APIs
115+
109116

110117
Encoding
111118
^^^^^^^^

examples/decoding/blocks.py

Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
"""
8+
===============================================
9+
Blocks: build your own decoding pipeline
10+
===============================================
11+
12+
.. warning::
13+
14+
**The Blocks APIs are under active construction.** They are private
15+
and unreleased. Signatures and semantics may change without notice. This
16+
tutorial only exists to show what they will eventually make possible.
17+
18+
:class:`~torchcodec.decoders.VideoDecoder` is a single box that does demuxing,
19+
decoding and color conversion for you. The Blocks APIs expose those three
20+
stages separately:
21+
22+
.. code-block::
23+
24+
Demuxer -> PacketDecoder -> ColorConverter
25+
Packet RawFrame RGB Frame
26+
27+
The blocks are passive: they never create threads, and they release the GIL.
28+
You decide how they are composed, on which threads, and where to stop. Below
29+
we illustrate a few things this enables: overlapping stages on multiple
30+
threads, accessing raw (YUV) frames, and decoding streams of unknown -
31+
possibly infinite - length.
32+
"""
33+
34+
# %%
35+
# Boilerplate: a test video, and the device we'll run on.
36+
import subprocess
37+
import tempfile
38+
from pathlib import Path
39+
40+
import torch
41+
42+
device = "cuda" if torch.cuda.is_available() else "cpu"
43+
print(f"{device = }")
44+
45+
temp_dir = Path(tempfile.mkdtemp())
46+
video_path = temp_dir / "video.mp4"
47+
subprocess.run(
48+
[
49+
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
50+
"-f", "lavfi", "-i", "testsrc2=size=1280x720:rate=30:duration=5",
51+
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-g", "30",
52+
"-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709",
53+
str(video_path),
54+
],
55+
check=True,
56+
)
57+
58+
# %%
59+
# The three blocks
60+
# ----------------
61+
#
62+
# A pipeline is just a loop. The decoder may need more than one packet before
63+
# it can output a frame, and it buffers a few frames that ``drain()`` returns
64+
# at the end.
65+
#
66+
# ``PacketDecoder`` and ``ColorConverter`` both accept ``device="cuda"``:
67+
# decoding then runs on NVDEC and the color conversion on the GPU, and the
68+
# frames never leave the device. Demuxing always happens on the CPU. Left
69+
# unspecified, ``device`` is the current default device.
70+
from torchcodec.decoders._blocks import ColorConverter, Demuxer, PacketDecoder
71+
72+
demuxer = Demuxer(video_path)
73+
packet_decoder = PacketDecoder(demuxer, device=device)
74+
color_converter = ColorConverter(device=device)
75+
76+
frames = []
77+
for packet in demuxer:
78+
for raw_frame in packet_decoder.decode(packet):
79+
frames.append(color_converter.convert(raw_frame))
80+
for raw_frame in packet_decoder.drain():
81+
frames.append(color_converter.convert(raw_frame))
82+
83+
print(f"{len(frames)} frames, {frames[0].data.shape = }, "
84+
f"{frames[0].pts_seconds = }, {frames[0].data.device = }")
85+
86+
# %%
87+
# Threading: overlapping the stages
88+
# ---------------------------------
89+
#
90+
# Each stage is a generator, so a pipeline is a chain of generators. Inserting
91+
# ``prefetch()`` between two of them puts everything upstream on its own
92+
# thread: the stages then run concurrently, and since the blocks release the
93+
# GIL, that's real parallelism.
94+
import queue
95+
import threading
96+
97+
98+
def demux(demuxer):
99+
yield from demuxer
100+
101+
102+
def decode(packet_decoder, packets):
103+
for packet in packets:
104+
yield from packet_decoder.decode(packet)
105+
yield from packet_decoder.drain()
106+
107+
108+
def color_convert(color_converter, raw_frames):
109+
for raw_frame in raw_frames:
110+
yield color_converter.convert(raw_frame)
111+
112+
113+
def prefetch(upstream, buffer_size=8):
114+
# Run `upstream` on a background thread, yielding its items through a
115+
# bounded queue. The queue applies backpressure: the worker blocks in
116+
# put() when the buffer is full, so it stays at most buffer_size ahead.
117+
q = queue.Queue(maxsize=buffer_size)
118+
eof = object()
119+
120+
def worker():
121+
for item in upstream:
122+
q.put(item)
123+
q.put(eof)
124+
125+
threading.Thread(target=worker, daemon=True).start()
126+
127+
def drain():
128+
while (item := q.get()) is not eof:
129+
yield item
130+
131+
return drain()
132+
133+
134+
def sequential():
135+
# demux -> decode -> color-convert, all on the calling thread.
136+
demuxer = Demuxer(video_path)
137+
packet_decoder = PacketDecoder(demuxer, device=device)
138+
color_converter = ColorConverter(device=device)
139+
return color_convert(color_converter, decode(packet_decoder, demux(demuxer)))
140+
141+
142+
def convert_on_own_thread():
143+
# [demux + decode] on one thread || [color-convert] on another.
144+
demuxer = Demuxer(video_path)
145+
packet_decoder = PacketDecoder(demuxer, device=device)
146+
color_converter = ColorConverter(device=device)
147+
raw_frames = prefetch(decode(packet_decoder, demux(demuxer)))
148+
return color_convert(color_converter, raw_frames)
149+
150+
151+
def demux_on_own_thread():
152+
# [demux] on one thread || [decode + color-convert] on another. This is the
153+
# natural split on CUDA: demuxing is CPU and I/O work, while decoding and
154+
# color conversion both happen on the GPU, so they belong together.
155+
demuxer = Demuxer(video_path)
156+
packet_decoder = PacketDecoder(demuxer, device=device)
157+
color_converter = ColorConverter(device=device)
158+
packets = prefetch(demux(demuxer))
159+
return color_convert(color_converter, decode(packet_decoder, packets))
160+
161+
162+
for pipeline in (sequential, convert_on_own_thread, demux_on_own_thread):
163+
frames = list(pipeline())
164+
print(f"{pipeline.__name__}: {len(frames)} frames on {frames[0].data.device}")
165+
166+
# %%
167+
# Where you insert the thread boundaries is up to you, and so is everything
168+
# else: nothing stops you from running one pipeline per file, decoding on the
169+
# CPU while color-converting on the GPU, or feeding frames into your own
170+
# pre-fetching data loader.
171+
172+
# %%
173+
# Seeking
174+
# -------
175+
#
176+
# ``Demuxer.seek()`` moves the demuxer to a timestamp. A decoder can only start
177+
# on a keyframe, so the seek lands on the keyframe at or before the target, and
178+
# the first frames that come out usually precede it: keep decoding forward and
179+
# drop them until you reach the timestamp you asked for.
180+
#
181+
# The seek also invalidates the frames the decoder is holding on to, so the
182+
# ``PacketDecoder`` must be ``reset()``.
183+
demuxer = Demuxer(video_path)
184+
packet_decoder = PacketDecoder(demuxer, device=device)
185+
color_converter = ColorConverter(device=device)
186+
187+
seconds = 2.5
188+
demuxer.seek(seconds)
189+
packet_decoder.reset()
190+
191+
frames = color_convert(color_converter, decode(packet_decoder, demux(demuxer)))
192+
landed_on = next(frames)
193+
target = next(frame for frame in frames if frame.pts_seconds >= seconds)
194+
print(f"asked for {seconds}s, landed on {landed_on.pts_seconds:.3f}s, "
195+
f"target frame at {target.pts_seconds:.3f}s")
196+
197+
# %%
198+
# Raw frames
199+
# ----------
200+
#
201+
# Color conversion is optional. A ``RawFrame`` can hand out the decoder's own
202+
# planes as tensor views, with no copy and no conversion.
203+
demuxer = Demuxer(video_path)
204+
packet_decoder = PacketDecoder(demuxer, device=device)
205+
raw_frame = next(decode(packet_decoder, demux(demuxer)))
206+
207+
Y, U, V = raw_frame.planes
208+
print(f"{raw_frame.pix_fmt = }, {raw_frame.bit_depth = }, "
209+
f"{raw_frame.colorspace = }, {raw_frame.color_range = }")
210+
print(f"{Y.shape = }, {U.shape = }, {Y.dtype = }, {Y.stride() = }")
211+
212+
# %%
213+
# These are views into the frame's memory: the row stride is the decoder's own
214+
# line size, and the chroma planes of an NVDEC surface are two interleaved
215+
# views over a single plane. Writing through them is visible downstream.
216+
#
217+
# Being the decoder's own planes, they are also never rotated - a video whose
218+
# container asks for a rotation gives you the samples as they were encoded, and
219+
# ``raw_frame.rotation_degrees`` tells you what to apply. ``ColorConverter``
220+
# applies it for you.
221+
#
222+
# So we can do the color conversion ourselves. Here it's plain PyTorch ops -
223+
# it could just as well be a Triton or CUDA kernel, fused with whatever your
224+
# model needs next.
225+
assert raw_frame.pix_fmt in ("yuv420p", "nv12") # 8-bit 4:2:0, on CPU and CUDA
226+
assert raw_frame.colorspace == "bt709" and raw_frame.color_range == "tv"
227+
228+
229+
def yuv420_to_rgb(Y, U, V):
230+
# BT.709, limited range. Chroma is upsampled by nearest neighbour.
231+
height, width = Y.shape
232+
233+
def upsample(plane):
234+
plane = (plane.float() - 128) * (255 / 224)
235+
return plane.repeat_interleave(2, 0).repeat_interleave(2, 1)[:height, :width]
236+
237+
y = (Y.float() - 16) * (255 / 219)
238+
u, v = upsample(U), upsample(V)
239+
rgb = torch.stack(
240+
[
241+
y + 1.5748 * v,
242+
y - 0.1873 * u - 0.4681 * v,
243+
y + 1.8556 * u,
244+
]
245+
)
246+
return rgb.round_().clamp_(0, 255).to(torch.uint8)
247+
248+
249+
ours = yuv420_to_rgb(Y, U, V)
250+
reference = ColorConverter(device=device).convert(raw_frame).data
251+
print(f"{ours.shape = }, mean abs diff vs ColorConverter: "
252+
f"{(ours.float() - reference.float()).abs().mean():.2f}")
253+
254+
# %%
255+
# Raw HDR frames
256+
# ~~~~~~~~~~~~~~
257+
#
258+
# Raw planes come at the source's own precision, so a 10-bit HDR video gives
259+
# ``uint16`` planes with all 10 bits intact - no clipping to 8 bits, and no
260+
# tone mapping.
261+
hdr_video_path = temp_dir / "hdr.mp4"
262+
subprocess.run(
263+
[
264+
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
265+
"-f", "lavfi", "-i", "testsrc2=size=1280x720:rate=30:duration=1",
266+
"-c:v", "libx265", "-pix_fmt", "yuv420p10le", "-preset", "ultrafast",
267+
"-x265-params",
268+
"colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc:range=limited",
269+
str(hdr_video_path),
270+
],
271+
check=True,
272+
capture_output=True, # x265 logs its banner to stderr no matter what
273+
)
274+
275+
hdr_demuxer = Demuxer(hdr_video_path)
276+
hdr_packet_decoder = PacketDecoder(hdr_demuxer, device=device)
277+
hdr_raw = next(decode(hdr_packet_decoder, demux(hdr_demuxer)))
278+
279+
hdr_Y = hdr_raw.planes[0]
280+
print(f"{hdr_raw.pix_fmt = }, {hdr_raw.bit_depth = }, "
281+
f"{hdr_raw.colorspace = }, {hdr_Y.dtype = }")
282+
283+
# NVDEC surfaces are 16-bit containers holding the samples msb-aligned, so the
284+
# 10 bits sit at the top and the low 6 are zero. Shift them back down to read
285+
# the sample values.
286+
shift = 16 - hdr_raw.bit_depth if device == "cuda" else 0
287+
samples = hdr_Y.to(torch.int32) >> shift
288+
print(f"luma range: [{samples.min()}, {samples.max()}], "
289+
f"{2 ** hdr_raw.bit_depth} levels available")
290+
291+
# %%
292+
# Streams of unknown length
293+
# -------------------------
294+
#
295+
# :class:`~torchcodec.decoders.VideoDecoder` needs a finite, seekable source:
296+
# it relies on the stream's duration and frame count, and in its default
297+
# ``seek_mode="exact"`` it scans the entire file up-front. The blocks never do
298+
# that - they consume packets as they arrive - so they can decode a source
299+
# that has no duration, no frame count, and no end.
300+
#
301+
# Let's make one: FFmpeg generating frames forever into a named pipe.
302+
import os
303+
304+
fifo_path = temp_dir / "live.ts"
305+
os.mkfifo(fifo_path)
306+
307+
308+
def start_live_stream():
309+
return subprocess.Popen(
310+
[
311+
"ffmpeg", "-hide_banner", "-loglevel", "error",
312+
"-f", "lavfi", "-i", "testsrc2=size=640x480:rate=30", # no duration!
313+
"-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency",
314+
"-g", "30", "-f", "mpegts", "-y", str(fifo_path),
315+
],
316+
)
317+
318+
319+
# %%
320+
# ``VideoDecoder`` can't do anything with that (we ask for the approximate
321+
# seek mode; the exact one would scan the stream forever):
322+
from torchcodec.decoders import VideoDecoder
323+
324+
ffmpeg = start_live_stream()
325+
try:
326+
VideoDecoder(fifo_path, seek_mode="approximate")
327+
except Exception as e:
328+
print(f"{type(e).__name__}: {str(e).splitlines()[0]}")
329+
ffmpeg.kill()
330+
ffmpeg.wait()
331+
332+
# %%
333+
# The blocks just stream it, and we stop whenever we want:
334+
ffmpeg = start_live_stream()
335+
demuxer = Demuxer(fifo_path)
336+
packet_decoder = PacketDecoder(demuxer, device=device)
337+
color_converter = ColorConverter(device=device)
338+
339+
frames = []
340+
for frame in color_convert(color_converter, decode(packet_decoder, demux(demuxer))):
341+
frames.append(frame)
342+
if len(frames) == 100:
343+
break # the stream is still going; we're the ones walking away
344+
345+
print(f"{len(frames)} frames, from pts {frames[0].pts_seconds:.2f}s to "
346+
f"{frames[-1].pts_seconds:.2f}s, {frames[0].data.shape = }")
347+
348+
ffmpeg.kill()
349+
ffmpeg.wait()

0 commit comments

Comments
 (0)