|
| 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