Skip to content

Commit 1648d13

Browse files
authored
Add draft tutorial for blocks APIs (#1644)
1 parent 5296ea3 commit 1648d13

3 files changed

Lines changed: 328 additions & 0 deletions

File tree

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: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
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 DecodedFrame 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 ``flush()`` 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.
69+
from torchcodec.decoders._blocks import ColorConverter, Demuxer, PacketDecoder
70+
71+
demuxer = Demuxer(video_path)
72+
packet_decoder = PacketDecoder(demuxer, device=device)
73+
color_converter = ColorConverter(device=device)
74+
75+
frames = []
76+
for packet in demuxer:
77+
for decoded_frame in packet_decoder.decode(packet):
78+
frames.append(color_converter.convert(decoded_frame))
79+
for decoded_frame in packet_decoder.flush():
80+
frames.append(color_converter.convert(decoded_frame))
81+
82+
print(f"{len(frames)} frames, {frames[0].data.shape = }, "
83+
f"{frames[0].pts_seconds = }, {frames[0].data.device = }")
84+
85+
# %%
86+
# Threading: overlapping the stages
87+
# ---------------------------------
88+
#
89+
# Each stage is a generator, so a pipeline is a chain of generators. Inserting
90+
# ``prefetch()`` between two of them puts everything upstream on its own
91+
# thread: the stages then run concurrently, and since the blocks release the
92+
# GIL, that's real parallelism.
93+
import queue
94+
import threading
95+
96+
97+
def demux(demuxer):
98+
yield from demuxer
99+
100+
101+
def decode(packet_decoder, packets):
102+
for packet in packets:
103+
yield from packet_decoder.decode(packet)
104+
yield from packet_decoder.flush()
105+
106+
107+
def color_convert(color_converter, decoded_frames):
108+
for decoded_frame in decoded_frames:
109+
yield color_converter.convert(decoded_frame)
110+
111+
112+
def prefetch(upstream, buffer_size=8):
113+
# Run `upstream` on a background thread, yielding its items through a
114+
# bounded queue. The queue applies backpressure: the worker blocks in
115+
# put() when the buffer is full, so it stays at most buffer_size ahead.
116+
q = queue.Queue(maxsize=buffer_size)
117+
eof = object()
118+
119+
def worker():
120+
for item in upstream:
121+
q.put(item)
122+
q.put(eof)
123+
124+
threading.Thread(target=worker, daemon=True).start()
125+
126+
def drain():
127+
while (item := q.get()) is not eof:
128+
yield item
129+
130+
return drain()
131+
132+
133+
def sequential():
134+
# demux -> decode -> color-convert, all on the calling thread.
135+
demuxer = Demuxer(video_path)
136+
packet_decoder = PacketDecoder(demuxer, device=device)
137+
color_converter = ColorConverter(device=device)
138+
return color_convert(color_converter, decode(packet_decoder, demux(demuxer)))
139+
140+
141+
def convert_on_own_thread():
142+
# [demux + decode] on one thread || [color-convert] on another.
143+
demuxer = Demuxer(video_path)
144+
packet_decoder = PacketDecoder(demuxer, device=device)
145+
color_converter = ColorConverter(device=device)
146+
decoded_frames = prefetch(decode(packet_decoder, demux(demuxer)))
147+
return color_convert(color_converter, decoded_frames)
148+
149+
150+
def demux_on_own_thread():
151+
# [demux] on one thread || [decode + color-convert] on another. This is the
152+
# natural split on CUDA: demuxing is CPU and I/O work, while decoding and
153+
# color conversion both happen on the GPU, so they belong together.
154+
demuxer = Demuxer(video_path)
155+
packet_decoder = PacketDecoder(demuxer, device=device)
156+
color_converter = ColorConverter(device=device)
157+
packets = prefetch(demux(demuxer))
158+
return color_convert(color_converter, decode(packet_decoder, packets))
159+
160+
161+
for pipeline in (sequential, convert_on_own_thread, demux_on_own_thread):
162+
frames = list(pipeline())
163+
print(f"{pipeline.__name__}: {len(frames)} frames on {frames[0].data.device}")
164+
165+
# %%
166+
# Where you insert the thread boundaries is up to you, and so is everything
167+
# else: nothing stops you from running one pipeline per file, decoding on the
168+
# CPU while color-converting on the GPU, or feeding frames into your own
169+
# pre-fetching data loader.
170+
171+
# %%
172+
# Raw frames
173+
# ----------
174+
#
175+
# Color conversion is optional. A ``DecodedFrame`` can hand out the decoder's
176+
# own planes as tensor views, with no copy and no conversion.
177+
demuxer = Demuxer(video_path)
178+
packet_decoder = PacketDecoder(demuxer, device=device)
179+
decoded_frame = next(decode(packet_decoder, demux(demuxer)))
180+
181+
raw_frame = decoded_frame.materialize()
182+
Y, U, V = raw_frame.planes
183+
print(f"{raw_frame.pix_fmt = }, {raw_frame.bit_depth = }, "
184+
f"{raw_frame.colorspace = }, {raw_frame.color_range = }")
185+
print(f"{Y.shape = }, {U.shape = }, {Y.dtype = }, {Y.stride() = }")
186+
187+
# %%
188+
# These are views into the frame's memory: the row stride is the decoder's own
189+
# line size, and the chroma planes of an NVDEC surface are two interleaved
190+
# views over a single plane. Writing through them is visible downstream.
191+
#
192+
# So we can do the color conversion ourselves. Here it's plain PyTorch ops -
193+
# it could just as well be a Triton or CUDA kernel, fused with whatever your
194+
# model needs next.
195+
assert raw_frame.pix_fmt in ("yuv420p", "nv12") # 8-bit 4:2:0, on CPU and CUDA
196+
assert raw_frame.colorspace == "bt709" and raw_frame.color_range == "tv"
197+
198+
199+
def yuv420_to_rgb(Y, U, V):
200+
# BT.709, limited range. Chroma is upsampled by nearest neighbour.
201+
height, width = Y.shape
202+
203+
def upsample(plane):
204+
plane = (plane.float() - 128) * (255 / 224)
205+
return plane.repeat_interleave(2, 0).repeat_interleave(2, 1)[:height, :width]
206+
207+
y = (Y.float() - 16) * (255 / 219)
208+
u, v = upsample(U), upsample(V)
209+
rgb = torch.stack(
210+
[
211+
y + 1.5748 * v,
212+
y - 0.1873 * u - 0.4681 * v,
213+
y + 1.8556 * u,
214+
]
215+
)
216+
return rgb.round_().clamp_(0, 255).to(torch.uint8)
217+
218+
219+
ours = yuv420_to_rgb(Y, U, V)
220+
reference = ColorConverter(device=device).convert(decoded_frame).data
221+
print(f"{ours.shape = }, mean abs diff vs ColorConverter: "
222+
f"{(ours.float() - reference.float()).abs().mean():.2f}")
223+
224+
# %%
225+
# Raw HDR frames
226+
# ~~~~~~~~~~~~~~
227+
#
228+
# Raw planes come at the source's own precision, so a 10-bit HDR video gives
229+
# ``uint16`` planes with all 10 bits intact - no clipping to 8 bits, and no
230+
# tone mapping.
231+
hdr_video_path = temp_dir / "hdr.mp4"
232+
subprocess.run(
233+
[
234+
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
235+
"-f", "lavfi", "-i", "testsrc2=size=1280x720:rate=30:duration=1",
236+
"-c:v", "libx265", "-pix_fmt", "yuv420p10le", "-preset", "ultrafast",
237+
"-x265-params",
238+
"colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc:range=limited",
239+
str(hdr_video_path),
240+
],
241+
check=True,
242+
capture_output=True, # x265 logs its banner to stderr no matter what
243+
)
244+
245+
hdr_demuxer = Demuxer(hdr_video_path)
246+
hdr_packet_decoder = PacketDecoder(hdr_demuxer, device=device)
247+
hdr_frame = next(decode(hdr_packet_decoder, demux(hdr_demuxer)))
248+
249+
hdr_raw = hdr_frame.materialize()
250+
hdr_Y = hdr_raw.planes[0]
251+
print(f"{hdr_raw.pix_fmt = }, {hdr_raw.bit_depth = }, "
252+
f"{hdr_raw.colorspace = }, {hdr_Y.dtype = }")
253+
254+
# NVDEC surfaces are 16-bit containers holding the samples msb-aligned, so the
255+
# 10 bits sit at the top and the low 6 are zero. Shift them back down to read
256+
# the sample values.
257+
shift = 16 - hdr_raw.bit_depth if device == "cuda" else 0
258+
samples = hdr_Y.to(torch.int32) >> shift
259+
print(f"luma range: [{samples.min()}, {samples.max()}], "
260+
f"{2 ** hdr_raw.bit_depth} levels available")
261+
262+
# %%
263+
# Streams of unknown length
264+
# -------------------------
265+
#
266+
# :class:`~torchcodec.decoders.VideoDecoder` needs a finite, seekable source:
267+
# it relies on the stream's duration and frame count, and in its default
268+
# ``seek_mode="exact"`` it scans the entire file up-front. The blocks never do
269+
# that - they consume packets as they arrive - so they can decode a source
270+
# that has no duration, no frame count, and no end.
271+
#
272+
# Let's make one: FFmpeg generating frames forever into a named pipe.
273+
import os
274+
275+
fifo_path = temp_dir / "live.ts"
276+
os.mkfifo(fifo_path)
277+
278+
279+
def start_live_stream():
280+
return subprocess.Popen(
281+
[
282+
"ffmpeg", "-hide_banner", "-loglevel", "error",
283+
"-f", "lavfi", "-i", "testsrc2=size=640x480:rate=30", # no duration!
284+
"-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency",
285+
"-g", "30", "-f", "mpegts", "-y", str(fifo_path),
286+
],
287+
)
288+
289+
290+
# %%
291+
# ``VideoDecoder`` can't do anything with that (we ask for the approximate
292+
# seek mode; the exact one would scan the stream forever):
293+
from torchcodec.decoders import VideoDecoder
294+
295+
ffmpeg = start_live_stream()
296+
try:
297+
VideoDecoder(fifo_path, seek_mode="approximate")
298+
except Exception as e:
299+
print(f"{type(e).__name__}: {str(e).splitlines()[0]}")
300+
ffmpeg.kill()
301+
ffmpeg.wait()
302+
303+
# %%
304+
# The blocks just stream it, and we stop whenever we want:
305+
ffmpeg = start_live_stream()
306+
demuxer = Demuxer(fifo_path)
307+
packet_decoder = PacketDecoder(demuxer, device=device)
308+
color_converter = ColorConverter(device=device)
309+
310+
frames = []
311+
for frame in color_convert(color_converter, decode(packet_decoder, demux(demuxer))):
312+
frames.append(frame)
313+
if len(frames) == 100:
314+
break # the stream is still going; we're the ones walking away
315+
316+
print(f"{len(frames)} frames, from pts {frames[0].pts_seconds:.2f}s to "
317+
f"{frames[-1].pts_seconds:.2f}s, {frames[0].data.shape = }")
318+
319+
ffmpeg.kill()
320+
ffmpeg.wait()

0 commit comments

Comments
 (0)