From 61ade0226665bbddb0238e9738d7e0fde65ea9ac Mon Sep 17 00:00:00 2001 From: pinjie Date: Fri, 7 Aug 2026 13:33:20 +0000 Subject: [PATCH] Improve the unit tests. Signed-off-by: pinjie --- .../temporal_variants/vfr_h264_yuv420p.mp4 | Bin 0 -> 83610 bytes .../src/PyNvBatchAsyncGopDecoder.cpp | 4 +- .../src/PyNvGopDecoder_common.cpp | 4 +- .../src/PyNvVideoReader.cpp | 4 +- .../on_demand_video_decoder/pyproject.toml | 2 +- .../tests/common/__init__.py | 15 + .../tests/common/adapters.py | 558 ++++++++++ .../tests/common/cases.py | 182 ++++ .../tests/common/checker.py | 33 + .../tests/common/conftest.py | 106 ++ .../tests/common/model.py | 110 ++ .../tests/common/test_decode_common.py | 125 +++ .../tests/common/test_decode_invalid.py | 71 ++ .../tests/common/test_decode_resources.py | 80 ++ .../tests/test_batch_async_gop_decoder.py | 147 --- .../tests/test_batch_async_stream_decoder.py | 75 -- .../tests/test_decoder_with_fast_init.py | 88 -- .../tests/test_pix_fmt_detection.py | 88 +- .../tests/test_random_decoder.py | 27 - .../tests/test_separate_decoder.py | 262 +---- .../tests/test_stream_asnyc_decoder.py | 197 ---- .../tests/test_stream_decoder.py | 26 - .../tests/test_stream_resource_free.py | 982 ------------------ .../on_demand_video_decoder/tests/utils.py | 92 -- 24 files changed, 1337 insertions(+), 1941 deletions(-) create mode 100644 packages/on_demand_video_decoder/data/temporal_variants/vfr_h264_yuv420p.mp4 create mode 100644 packages/on_demand_video_decoder/tests/common/__init__.py create mode 100644 packages/on_demand_video_decoder/tests/common/adapters.py create mode 100644 packages/on_demand_video_decoder/tests/common/cases.py create mode 100644 packages/on_demand_video_decoder/tests/common/checker.py create mode 100644 packages/on_demand_video_decoder/tests/common/conftest.py create mode 100644 packages/on_demand_video_decoder/tests/common/model.py create mode 100644 packages/on_demand_video_decoder/tests/common/test_decode_common.py create mode 100644 packages/on_demand_video_decoder/tests/common/test_decode_invalid.py create mode 100644 packages/on_demand_video_decoder/tests/common/test_decode_resources.py delete mode 100644 packages/on_demand_video_decoder/tests/test_decoder_with_fast_init.py delete mode 100644 packages/on_demand_video_decoder/tests/test_stream_resource_free.py diff --git a/packages/on_demand_video_decoder/data/temporal_variants/vfr_h264_yuv420p.mp4 b/packages/on_demand_video_decoder/data/temporal_variants/vfr_h264_yuv420p.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..a3d832f63dc8d3d9dae26faf5c6876563aa69f33 GIT binary patch literal 83610 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 literal 0 HcmV?d00001 diff --git a/packages/on_demand_video_decoder/ext_impl/src/PyNvOnDemandDecoder/src/PyNvBatchAsyncGopDecoder.cpp b/packages/on_demand_video_decoder/ext_impl/src/PyNvOnDemandDecoder/src/PyNvBatchAsyncGopDecoder.cpp index 4b8e0264..a5a80a36 100644 --- a/packages/on_demand_video_decoder/ext_impl/src/PyNvOnDemandDecoder/src/PyNvBatchAsyncGopDecoder.cpp +++ b/packages/on_demand_video_decoder/ext_impl/src/PyNvOnDemandDecoder/src/PyNvBatchAsyncGopDecoder.cpp @@ -266,9 +266,9 @@ void PyNvBatchAsyncGopDecoder::build_yuv_frame(Pixel_Format fmt, size_t H, size_ break; // TODO(P016): LoadDLPack rejects "|u2" typestr, so no DLPack tensor can be built. case Pixel_Format_P016: - out.views.push_back(CAIMemoryView{{H, W, 1}, {W, 1, 1}, "|u2", stream_id, dst_ptr, false}); + out.views.push_back(CAIMemoryView{{H, W, 1}, {W * 2, 2, 2}, "|u2", stream_id, dst_ptr, false}); out.views.push_back(CAIMemoryView{ - {H / 2, W / 2, 2}, {W / 2 * 2, 2, 1}, "|u2", stream_id, dst_ptr + 2 * H * W, false}); + {H / 2, W / 2, 2}, {W * 2, 4, 2}, "|u2", stream_id, dst_ptr + 2 * H * W, false}); break; // TODO(YUV444): needs a flat (H*3, W) DLPack view and extBuf support for 3-plane layouts. case Pixel_Format_YUV444: diff --git a/packages/on_demand_video_decoder/ext_impl/src/PyNvOnDemandDecoder/src/PyNvGopDecoder_common.cpp b/packages/on_demand_video_decoder/ext_impl/src/PyNvOnDemandDecoder/src/PyNvGopDecoder_common.cpp index 66197a24..f14e4444 100644 --- a/packages/on_demand_video_decoder/ext_impl/src/PyNvOnDemandDecoder/src/PyNvGopDecoder_common.cpp +++ b/packages/on_demand_video_decoder/ext_impl/src/PyNvOnDemandDecoder/src/PyNvGopDecoder_common.cpp @@ -141,14 +141,14 @@ int PyNvGopDecoder::GetYUVFromFrame(NvDecoder* decoder, const uint8_t* pFrame, u } break; case Pixel_Format_P016: { decoded_frame.views.push_back(CAIMemoryView{{height, width, 1}, - {width, 1, 1}, + {width * 2, 2, 2}, "|u2", reinterpret_cast(decoder->GetStream()), (CUdeviceptr)(pFrame_buffer), false}); decoded_frame.views.push_back( CAIMemoryView{{height / 2, width / 2, 2}, - {width / 2 * 2, 2, 1}, + {width * 2, 4, 2}, "|u2", reinterpret_cast(decoder->GetStream()), (CUdeviceptr)(pFrame_buffer + 2 * (width * height)), diff --git a/packages/on_demand_video_decoder/ext_impl/src/PyNvOnDemandDecoder/src/PyNvVideoReader.cpp b/packages/on_demand_video_decoder/ext_impl/src/PyNvOnDemandDecoder/src/PyNvVideoReader.cpp index 9b4c0334..9961e651 100644 --- a/packages/on_demand_video_decoder/ext_impl/src/PyNvOnDemandDecoder/src/PyNvVideoReader.cpp +++ b/packages/on_demand_video_decoder/ext_impl/src/PyNvOnDemandDecoder/src/PyNvVideoReader.cpp @@ -721,13 +721,13 @@ DecodedFrameExt PyNvVideoReader::returnYUVFrame(void* pFrame_buffer, void* pFram } break; case Pixel_Format_P016: { frame.views.push_back(CAIMemoryView{{height, width, 1}, - {width, 1, 1}, + {width * 2, 2, 2}, "|u2", reinterpret_cast(this->decoder->GetStream()), (CUdeviceptr)(pFrame_buffer), false}); frame.views.push_back(CAIMemoryView{{height / 2, width / 2, 2}, - {width / 2 * 2, 2, 1}, + {width * 2, 4, 2}, "|u2", reinterpret_cast(this->decoder->GetStream()), (CUdeviceptr)(pFrame_buffer + 2 * (width * height)), diff --git a/packages/on_demand_video_decoder/pyproject.toml b/packages/on_demand_video_decoder/pyproject.toml index 65482734..f229c1d0 100644 --- a/packages/on_demand_video_decoder/pyproject.toml +++ b/packages/on_demand_video_decoder/pyproject.toml @@ -14,7 +14,7 @@ dynamic = ["version"] description = "On-demand video decoder (part of the ACCV-Lab package)." requires-python = ">=3.8" dependencies = [ - "torch>=2.0.0", + "torch>=2.3.0", "numpy>=1.22.2", ] diff --git a/packages/on_demand_video_decoder/tests/common/__init__.py b/packages/on_demand_video_decoder/tests/common/__init__.py new file mode 100644 index 00000000..3a76188c --- /dev/null +++ b/packages/on_demand_video_decoder/tests/common/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Shared decoder conformance tests.""" diff --git a/packages/on_demand_video_decoder/tests/common/adapters.py b/packages/on_demand_video_decoder/tests/common/adapters.py new file mode 100644 index 00000000..d31b3092 --- /dev/null +++ b/packages/on_demand_video_decoder/tests/common/adapters.py @@ -0,0 +1,558 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Thin adapters for APIs that naturally decode video paths and frame indices.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Callable, List, Literal, Sequence, Tuple + +import torch + +from common.model import CanonicalFrame, OutputContract + +OutputFormat = Literal["rgb", "bgr", "yuv"] + + +_PIXEL_FORMAT_NAMES = { + 3: "NV12", + 4: "YUV444", + 5: "P016", + 6: "YUV444_16BIT", +} + + +class DecoderTestAdapter(ABC): + """Minimum interface required by the common decode tests.""" + + name: str + output_formats: Tuple[OutputFormat, ...] + + def __init__(self, decoder: object) -> None: + self.decoder = decoder + + @abstractmethod + def decode( + self, + videos: Sequence[str], + frame_indices: Sequence[int], + output_format: OutputFormat, + ) -> List[object]: + """Decode one requested frame for every video entry.""" + + def normalize(self, frame: object, output_format: OutputFormat) -> CanonicalFrame: + """Take an owned snapshot suitable for exact comparison.""" + + if output_format in {"rgb", "bgr"}: + tensor = torch.as_tensor(frame, device="cuda").clone() + if tensor.ndim != 3 or tensor.shape[-1] != 3: + raise AssertionError(f"RGB/BGR output must have shape (H, W, 3), got {tuple(tensor.shape)}") + return CanonicalFrame( + format=output_format.upper(), + planes=(tensor,), + width=int(tensor.shape[1]), + height=int(tensor.shape[0]), + ) + + planes = [] + for view in frame.cuda(): + plane = torch.as_tensor(view, device="cuda").clone() + if plane.ndim == 3 and plane.shape[-1] == 1: + plane = plane.squeeze(-1) + planes.append(plane) + if not planes: + raise AssertionError("YUV output did not expose any planes") + + format_name = _PIXEL_FORMAT_NAMES.get(int(frame.format), f"UNKNOWN_{int(frame.format)}") + return CanonicalFrame( + format=format_name, + planes=tuple(planes), + width=int(planes[0].shape[1]), + height=int(planes[0].shape[0]), + ) + + def output_contract(self, output_format: OutputFormat) -> OutputContract: + # Native YUV output is a format family: its concrete layout and dtype + # depend on the source pixel format and bit depth. + kind = "YUV" if output_format == "yuv" else output_format.upper() + dtypes = (torch.uint8, torch.uint16) if output_format == "yuv" else (torch.uint8,) + return OutputContract(kind=kind, dtypes=dtypes) + + def close(self) -> None: + self.decoder = None + + +class RandomAdapter(DecoderTestAdapter): + name = "random" + output_formats = ("rgb", "bgr", "yuv") + + def decode( + self, + videos: Sequence[str], + frame_indices: Sequence[int], + output_format: OutputFormat, + ) -> List[object]: + if output_format == "yuv": + return self.decoder.Decode(list(videos), list(frame_indices)) + return self.decoder.DecodeN12ToRGB( + list(videos), + list(frame_indices), + output_format == "bgr", + ) + + +class RandomFastInitAdapter(DecoderTestAdapter): + name = "random_fast_init" + output_formats = ("rgb", "bgr", "yuv") + + def __init__( + self, + decoder: object, + get_fast_init_info: Callable[[Sequence[str]], object], + ) -> None: + super().__init__(decoder) + self.get_fast_init_info = get_fast_init_info + + def decode( + self, + videos: Sequence[str], + frame_indices: Sequence[int], + output_format: OutputFormat, + ) -> List[object]: + videos = list(videos) + frame_indices = list(frame_indices) + stream_infos = self.get_fast_init_info(videos) + if output_format == "yuv": + return self.decoder.Decode( + videos, + frame_indices, + fastStreamInfos=stream_infos, + ) + return self.decoder.DecodeN12ToRGB( + videos, + frame_indices, + output_format == "bgr", + fastStreamInfos=stream_infos, + ) + + +class StreamAdapter(DecoderTestAdapter): + name = "stream" + output_formats = ("rgb", "bgr", "yuv") + + def decode( + self, + videos: Sequence[str], + frame_indices: Sequence[int], + output_format: OutputFormat, + ) -> List[object]: + if output_format == "yuv": + return self.decoder.Decode(list(videos), list(frame_indices)) + return self.decoder.DecodeN12ToRGB( + list(videos), + list(frame_indices), + output_format == "bgr", + ) + + +class StreamAsyncAdapter(DecoderTestAdapter): + name = "stream_async" + output_formats = ("rgb", "bgr") + + def decode( + self, + videos: Sequence[str], + frame_indices: Sequence[int], + output_format: OutputFormat, + ) -> List[object]: + videos = list(videos) + frame_indices = list(frame_indices) + as_bgr = output_format == "bgr" + self.decoder.DecodeN12ToRGBAsync(videos, frame_indices, as_bgr) + return self.decoder.DecodeN12ToRGBAsyncGetBuffer( + videos, + frame_indices, + as_bgr, + ) + + +class BatchStreamAsyncAdapter(DecoderTestAdapter): + name = "batch_stream_async" + output_formats = ("rgb", "bgr") + + def decode( + self, + videos: Sequence[str], + frame_indices: Sequence[int], + output_format: OutputFormat, + ) -> List[object]: + videos = list(videos) + frame_indices_2d = [[frame_index] for frame_index in frame_indices] + as_bgr = output_format == "bgr" + self.decoder.Decode(videos, frame_indices_2d, as_bgr) + frames_2d = self.decoder.GetBuffer(videos, frame_indices_2d, as_bgr) + for video_index, frames in enumerate(frames_2d): + if len(frames) != 1: + raise AssertionError( + f"batch output {video_index} must contain exactly one frame, got {len(frames)}" + ) + return [frames[0] for frames in frames_2d] + + +def _decode_gop_data( + decoder: object, + gop_data: Sequence[object], + videos: Sequence[str], + frame_indices: Sequence[int], + output_format: OutputFormat, +) -> List[object]: + if output_format == "yuv": + return decoder.DecodeFromGOPList( + list(gop_data), + list(videos), + list(frame_indices), + ) + return decoder.DecodeFromGOPListRGB( + list(gop_data), + list(videos), + list(frame_indices), + output_format == "bgr", + ) + + +class BatchGopAsyncAdapter(DecoderTestAdapter): + name = "batch_gop_async" + output_formats = ("rgb", "bgr", "yuv") + + def __init__(self, demuxer: object, decoder: object) -> None: + super().__init__(decoder) + self.demuxer = demuxer + + def decode( + self, + videos: Sequence[str], + frame_indices: Sequence[int], + output_format: OutputFormat, + ) -> List[object]: + videos = list(videos) + frame_indices = list(frame_indices) + gop_list = self.demuxer.GetGOPList( + videos, + frame_indices, + useGOPCache=False, + ) + gop_data_2d = [[bundle[0]] for bundle in gop_list] + frame_indices_2d = [[frame_index] for frame_index in frame_indices] + + if output_format == "yuv": + self.decoder.DecodeFromGOPList( + gop_data_2d, + videos, + frame_indices_2d, + ) + frames_2d = self.decoder.DecodeFromGOPListGetBuffer( + videos, + frame_indices_2d, + ) + else: + as_bgr = output_format == "bgr" + self.decoder.DecodeFromGOPListRGB( + gop_data_2d, + videos, + frame_indices_2d, + as_bgr, + ) + frames_2d = self.decoder.DecodeFromGOPListRGBGetBuffer( + videos, + frame_indices_2d, + as_bgr, + ) + + for video_index, frames in enumerate(frames_2d): + if len(frames) != 1: + raise AssertionError( + f"batch output {video_index} must contain exactly one frame, got {len(frames)}" + ) + return [frames[0] for frames in frames_2d] + + def close(self) -> None: + self.demuxer = None + super().close() + + +class GopListAdapter(DecoderTestAdapter): + name = "gop_list" + output_formats = ("rgb", "bgr", "yuv") + + def __init__(self, demuxer: object, decoder: object) -> None: + super().__init__(decoder) + self.demuxer = demuxer + + def decode( + self, + videos: Sequence[str], + frame_indices: Sequence[int], + output_format: OutputFormat, + ) -> List[object]: + gop_list = self.demuxer.GetGOPList( + list(videos), + list(frame_indices), + useGOPCache=False, + ) + gop_data = [bundle[0] for bundle in gop_list] + return _decode_gop_data( + self.decoder, + gop_data, + videos, + frame_indices, + output_format, + ) + + def close(self) -> None: + self.demuxer = None + super().close() + + +class GopListFastInitAdapter(DecoderTestAdapter): + name = "gop_list_fast_init" + output_formats = ("rgb", "bgr", "yuv") + + def __init__( + self, + demuxer: object, + decoder: object, + get_fast_init_info: Callable[[Sequence[str]], object], + ) -> None: + super().__init__(decoder) + self.demuxer = demuxer + self.get_fast_init_info = get_fast_init_info + + def decode( + self, + videos: Sequence[str], + frame_indices: Sequence[int], + output_format: OutputFormat, + ) -> List[object]: + videos = list(videos) + frame_indices = list(frame_indices) + gop_list = self.demuxer.GetGOPList( + videos, + frame_indices, + fastStreamInfos=self.get_fast_init_info(videos), + useGOPCache=False, + ) + return _decode_gop_data( + self.decoder, + [bundle[0] for bundle in gop_list], + videos, + frame_indices, + output_format, + ) + + def close(self) -> None: + self.demuxer = None + super().close() + + +class GroupAdapter(DecoderTestAdapter): + name = "group" + output_formats = ("rgb", "bgr") + + def __init__(self, demuxer: object, decoder: object) -> None: + super().__init__(decoder) + self.demuxer = demuxer + + def decode( + self, + videos: Sequence[str], + frame_indices: Sequence[int], + output_format: OutputFormat, + ) -> List[object]: + if len(videos) != len(frame_indices): + raise ValueError("videos and frame_indices must have the same length") + requests = [ + {"filepath": video, "frame_ids": [frame_index]} + for video, frame_index in zip(videos, frame_indices) + ] + groups = self.demuxer.GetGOPGroups(requests) + decoded_groups = self.decoder.DecodeFromGOPGroupsRGB( + groups, + output_format == "bgr", + ) + + frames = [None] * len(videos) + for group in decoded_groups: + source_index = group["source_index"] + for frame, positions in zip(group["frames"], group["frame_positions"]): + for position in positions: + if position != 0: + raise AssertionError( + f"source {source_index} returned unexpected frame position {position}" + ) + if frames[source_index] is not None: + raise AssertionError(f"source {source_index} returned more than one frame") + frames[source_index] = frame + + if any(frame is None for frame in frames): + raise AssertionError("group API did not return one frame for every input video") + return frames + + def close(self) -> None: + self.demuxer = None + super().close() + + +class GopFileAdapter(DecoderTestAdapter): + name = "gop_file" + output_formats = ("rgb", "bgr", "yuv") + + def __init__( + self, + demuxer: object, + decoder: object, + save_gop_to_file: Callable[[object, str], object], + ) -> None: + super().__init__(decoder) + self.demuxer = demuxer + self.save_gop_to_file = save_gop_to_file + + def decode( + self, + videos: Sequence[str], + frame_indices: Sequence[int], + output_format: OutputFormat, + ) -> List[object]: + gop_list = self.demuxer.GetGOPList( + list(videos), + list(frame_indices), + useGOPCache=False, + ) + with TemporaryDirectory(prefix="decoder-common-gop-") as temp_dir: + gop_paths = [] + for index, (gop_data, _, _) in enumerate(gop_list): + gop_path = Path(temp_dir) / f"gop-{index}.bin" + self.save_gop_to_file(gop_data, str(gop_path)) + gop_paths.append(str(gop_path)) + loaded_gop_data = self.decoder.LoadGopsToList(gop_paths) + return _decode_gop_data( + self.decoder, + loaded_gop_data, + videos, + frame_indices, + output_format, + ) + + def close(self) -> None: + self.demuxer = None + super().close() + + +@dataclass(frozen=True) +class AdapterFactory: + name: str + output_formats: Tuple[OutputFormat, ...] + create: Callable[[], DecoderTestAdapter] + + +def adapter_factories(nvc: object) -> List[AdapterFactory]: + """Create fresh decoders for each test without adding a registry system.""" + + max_files = 8 + return [ + AdapterFactory( + RandomAdapter.name, + RandomAdapter.output_formats, + lambda: RandomAdapter(nvc.CreateGopDecoder(maxfiles=max_files, iGpu=0)), + ), + AdapterFactory( + RandomFastInitAdapter.name, + RandomFastInitAdapter.output_formats, + lambda: RandomFastInitAdapter( + nvc.CreateGopDecoder(maxfiles=max_files, iGpu=0), + nvc.GetFastInitInfo, + ), + ), + AdapterFactory( + StreamAdapter.name, + StreamAdapter.output_formats, + lambda: StreamAdapter(nvc.CreateSampleReader(num_of_set=1, num_of_file=max_files, iGpu=0)), + ), + AdapterFactory( + StreamAsyncAdapter.name, + StreamAsyncAdapter.output_formats, + lambda: StreamAsyncAdapter(nvc.CreateSampleReader(num_of_set=1, num_of_file=max_files, iGpu=0)), + ), + AdapterFactory( + BatchStreamAsyncAdapter.name, + BatchStreamAsyncAdapter.output_formats, + lambda: BatchStreamAsyncAdapter( + nvc.CreateBatchAsyncStreamReader( + num_of_set=1, + num_of_file=max_files, + max_frames_per_decode_call=1, + iGpu=0, + ) + ), + ), + AdapterFactory( + BatchGopAsyncAdapter.name, + BatchGopAsyncAdapter.output_formats, + lambda: BatchGopAsyncAdapter( + nvc.CreateGopDecoder(maxfiles=max_files, iGpu=0), + nvc.CreateBatchAsyncGopDecoder( + maxfiles=max_files, + max_frames_per_decode_call=1, + iGpu=0, + ), + ), + ), + AdapterFactory( + GopListAdapter.name, + GopListAdapter.output_formats, + lambda: GopListAdapter( + nvc.CreateGopDecoder(maxfiles=max_files, iGpu=0), + nvc.CreateGopDecoder(maxfiles=max_files, iGpu=0), + ), + ), + AdapterFactory( + GopListFastInitAdapter.name, + GopListFastInitAdapter.output_formats, + lambda: GopListFastInitAdapter( + nvc.CreateGopDecoder(maxfiles=max_files, iGpu=0), + nvc.CreateGopDecoder(maxfiles=max_files, iGpu=0), + nvc.GetFastInitInfo, + ), + ), + AdapterFactory( + GroupAdapter.name, + GroupAdapter.output_formats, + lambda: GroupAdapter( + nvc.CreateGopDecoder(maxfiles=max_files, iGpu=0), + nvc.CreateGopDecoder(maxfiles=max_files, iGpu=0), + ), + ), + AdapterFactory( + GopFileAdapter.name, + GopFileAdapter.output_formats, + lambda: GopFileAdapter( + nvc.CreateGopDecoder(maxfiles=max_files, iGpu=0), + nvc.CreateGopDecoder(maxfiles=max_files, iGpu=0), + nvc.SaveGopToFile, + ), + ), + ] diff --git a/packages/on_demand_video_decoder/tests/common/cases.py b/packages/on_demand_video_decoder/tests/common/cases.py new file mode 100644 index 00000000..20f6bdcc --- /dev/null +++ b/packages/on_demand_video_decoder/tests/common/cases.py @@ -0,0 +1,182 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Deterministic requests shared by all file-and-frame decoder APIs.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, List, Tuple + +import torch + +from common.model import DecodeCase, InvalidDecodeCase, ResourceDecodeCase + +DATA_DIR = Path(__file__).resolve().parents[2] / "data" +SAMPLE_CLIP_DIR = DATA_DIR / "sample_clip" +PIX_FMT_VARIANTS_DIR = DATA_DIR / "pix_fmt_variants" +OPEN_GOP_VARIANTS_DIR = DATA_DIR / "open_gop_variant" +TEMPORAL_VARIANTS_DIR = DATA_DIR / "temporal_variants" + + +def sample_videos() -> Tuple[str, ...]: + videos = tuple(str(path) for path in sorted(SAMPLE_CLIP_DIR.glob("*.mp4"))) + if len(videos) < 5: + raise RuntimeError(f"at least five common test videos are required in {SAMPLE_CLIP_DIR}") + return videos + + +def common_cases() -> List[DecodeCase]: + videos = sample_videos() + return [ + DecodeCase("single_first", (videos[0],), (0,)), + DecodeCase("multi_video_mixed", videos[:3], (0, 37, 200)), + DecodeCase( + "same_video_unsorted_duplicate", + (videos[0], videos[0], videos[0], videos[0]), + (60, 0, 60, 29), + ), + DecodeCase( + "boundaries", + (videos[0], videos[0], videos[0]), + (0, 100, 200), + ), + ] + + +def resource_cases() -> Tuple[ResourceDecodeCase, ...]: + """Committed codec fixtures that each need one decode smoke path.""" + + cases = ( + ResourceDecodeCase( + "h264_avc1", + str(PIX_FMT_VARIANTS_DIR / "h264_avc1_yuv420p.mp4"), + 33, + "rgb", + "RGB", + torch.uint8, + ((256, 256, 3),), + ), + ResourceDecodeCase( + "hevc_hev1_10bit", + str(PIX_FMT_VARIANTS_DIR / "hevc_hev1_yuv420p10le.mp4"), + 33, + "yuv", + "P016", + torch.uint16, + ((256, 256), (128, 128, 2)), + ), + ResourceDecodeCase( + "hevc_hvc1_10bit", + str(PIX_FMT_VARIANTS_DIR / "hevc_hvc1_yuv420p10le.mp4"), + 33, + "yuv", + "P016", + torch.uint16, + ((256, 256), (128, 128, 2)), + ), + ResourceDecodeCase( + "vfr_h264", + str(TEMPORAL_VARIANTS_DIR / "vfr_h264_yuv420p.mp4"), + 33, + "rgb", + "RGB", + torch.uint8, + ((256, 256, 3),), + ), + # Display frame 39 is a leading RASL picture associated with CRA 40. + ResourceDecodeCase( + "open_gop", + str(OPEN_GOP_VARIANTS_DIR / "moving_shape_open_gop_h265.mp4"), + 39, + "rgb", + "RGB", + torch.uint8, + ((256, 256, 3),), + ), + ) + for case in cases: + if not Path(case.video).is_file(): + raise RuntimeError(f"common decode resource is missing: {case.video}") + return cases + + +INVALID_CASE_NAMES = ( + "too_few_frame_indices", + "too_many_frame_indices", + "missing_file", + "empty_file", + "non_video_file", + "truncated_video", + "negative_frame_index", + "out_of_range_frame_index", + "none_frame_index", + "string_frame_index", + "float_frame_index", + "too_many_inputs", +) + + +def _insert_middle(values: Tuple[Any, ...], invalid_value: Any) -> Tuple[Any, ...]: + return values[:2] + (invalid_value,) + values[2:] + + +def invalid_case(name: str, temp_dir: Path) -> InvalidDecodeCase: + """Build one malformed request without requiring external media tools.""" + + videos = sample_videos() + valid_videos = videos[:4] + valid_frames = (0, 30, 60, 90, 120) + + if name == "too_few_frame_indices": + return InvalidDecodeCase(name, valid_videos, valid_frames[:3]) + if name == "too_many_frame_indices": + return InvalidDecodeCase(name, valid_videos, valid_frames) + + if name in {"missing_file", "empty_file", "non_video_file", "truncated_video"}: + invalid_path = temp_dir / f"{name}.mp4" + if name == "empty_file": + invalid_path.write_bytes(b"") + elif name == "non_video_file": + invalid_path.write_bytes(b"not a video\n" * 8) + elif name == "truncated_video": + invalid_path.write_bytes(Path(videos[0]).read_bytes()[:64]) + return InvalidDecodeCase( + name, + _insert_middle(valid_videos, str(invalid_path)), + valid_frames, + ) + + invalid_frame_indices = { + "negative_frame_index": -1, + "out_of_range_frame_index": 999999, + "none_frame_index": None, + "string_frame_index": "60", + "float_frame_index": 60.5, + } + if name in invalid_frame_indices: + return InvalidDecodeCase( + name, + videos[:5], + _insert_middle((0, 30, 90, 120), invalid_frame_indices[name]), + ) + + if name == "too_many_inputs": + return InvalidDecodeCase( + name, + tuple(videos[index % len(videos)] for index in range(9)), + tuple(index * 10 for index in range(9)), + ) + + raise ValueError(f"unknown invalid decode case: {name}") diff --git a/packages/on_demand_video_decoder/tests/common/checker.py b/packages/on_demand_video_decoder/tests/common/checker.py new file mode 100644 index 00000000..c70879b8 --- /dev/null +++ b/packages/on_demand_video_decoder/tests/common/checker.py @@ -0,0 +1,33 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Extension point for a future independent binary correctness checker.""" + +from __future__ import annotations + +from typing import Protocol, Sequence + +from common.adapters import OutputFormat +from common.model import CanonicalFrame, DecodeCase + + +class BinaryCorrectnessChecker(Protocol): + """Optional checker supplied by an environment with an approved reference.""" + + def validate( + self, + case: DecodeCase, + output_format: OutputFormat, + frames: Sequence[CanonicalFrame], + ) -> None: ... diff --git a/packages/on_demand_video_decoder/tests/common/conftest.py b/packages/on_demand_video_decoder/tests/common/conftest.py new file mode 100644 index 00000000..7ca257de --- /dev/null +++ b/packages/on_demand_video_decoder/tests/common/conftest.py @@ -0,0 +1,106 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Fixtures for the common decoder contract.""" + +from __future__ import annotations + +from typing import Iterator, Optional, Tuple + +import pytest + +import accvlab.on_demand_video_decoder as nvc +from common.adapters import DecoderTestAdapter, OutputFormat, adapter_factories +from common.cases import INVALID_CASE_NAMES, common_cases, invalid_case, resource_cases +from common.checker import BinaryCorrectnessChecker + +_ADAPTER_FACTORIES = adapter_factories(nvc) +_ADAPTER_INPUTS = tuple( + (factory, output_format) for factory in _ADAPTER_FACTORIES for output_format in factory.output_formats +) +_INVALID_ADAPTER_INPUTS = tuple( + (factory, output_format) + for factory in _ADAPTER_FACTORIES + for output_format in factory.output_formats + if output_format != "bgr" +) +_COMMON_CASES = common_cases() +_RESOURCE_CASES = resource_cases() +_RESOURCE_ADAPTER_INPUTS = tuple( + (factory, resource_case) + for resource_case in _RESOURCE_CASES + for factory in _ADAPTER_FACTORIES + if resource_case.preferred_output_format in factory.output_formats +) + + +@pytest.fixture( + params=_ADAPTER_INPUTS, + ids=lambda value: f"{value[0].name}-{value[1]}", +) +def decoder_input(request) -> Iterator[Tuple[DecoderTestAdapter, OutputFormat]]: + factory, output_format = request.param + adapter = factory.create() + try: + yield adapter, output_format + finally: + adapter.close() + + +@pytest.fixture(params=_COMMON_CASES, ids=lambda case: case.name) +def decode_case(request): + return request.param + + +@pytest.fixture( + params=_INVALID_ADAPTER_INPUTS, + ids=lambda value: f"{value[0].name}-{value[1]}", +) +def invalid_decoder_input(request) -> Iterator[Tuple[DecoderTestAdapter, OutputFormat]]: + factory, output_format = request.param + adapter = factory.create() + try: + yield adapter, output_format + finally: + adapter.close() + + +@pytest.fixture(params=INVALID_CASE_NAMES) +def invalid_decode_case(request, tmp_path): + return invalid_case(request.param, tmp_path) + + +@pytest.fixture( + params=_RESOURCE_ADAPTER_INPUTS, + ids=lambda value: (f"{value[0].name}-{value[1].name}-{value[1].preferred_output_format}"), +) +def resource_decoder_input(request): + factory, resource_case = request.param + adapter = factory.create() + try: + yield adapter, resource_case + finally: + adapter.close() + + +@pytest.fixture(params=_RESOURCE_CASES, ids=lambda case: case.name) +def resource_case(request): + return request.param + + +@pytest.fixture +def binary_correctness_checker() -> Optional[BinaryCorrectnessChecker]: + """Reserved for an approved binary reference checker; intentionally unset.""" + + return None diff --git a/packages/on_demand_video_decoder/tests/common/model.py b/packages/on_demand_video_decoder/tests/common/model.py new file mode 100644 index 00000000..0dfc4d25 --- /dev/null +++ b/packages/on_demand_video_decoder/tests/common/model.py @@ -0,0 +1,110 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Small data model used by the common decoder tests.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal, Tuple + +import torch + + +@dataclass(frozen=True) +class DecodeCase: + """A common decode request: one frame index for every video entry.""" + + name: str + videos: Tuple[str, ...] + frame_indices: Tuple[int, ...] + + def __post_init__(self) -> None: + if len(self.videos) != len(self.frame_indices): + raise ValueError("videos and frame_indices must have the same length") + if not self.videos: + raise ValueError("a common decode case must not be empty") + + +@dataclass(frozen=True) +class InvalidDecodeCase: + """A malformed request that must reach the adapter unchanged.""" + + name: str + videos: Tuple[Any, ...] + frame_indices: Tuple[Any, ...] + + +@dataclass(frozen=True) +class ResourceDecodeCase: + """One committed fixture and its exact normalized output contract.""" + + name: str + video: str + frame_index: int + preferred_output_format: Literal["rgb", "yuv"] + expected_format: str + expected_dtype: torch.dtype + expected_plane_shapes: Tuple[Tuple[int, ...], ...] + + +@dataclass(frozen=True) +class OutputContract: + """Properties shared by every frame returned through one adapter.""" + + kind: str + dtypes: Tuple[torch.dtype, ...] + device_type: str = "cuda" + + +@dataclass(frozen=True) +class CanonicalFrame: + """Owned tensors used to compare results from otherwise different APIs.""" + + format: str + planes: Tuple[torch.Tensor, ...] + width: int + height: int + + @property + def dtype(self) -> torch.dtype: + return self.planes[0].dtype + + @property + def device(self) -> torch.device: + if not self.planes: + raise AssertionError("a canonical frame must expose at least one plane") + devices = {plane.device for plane in self.planes} + if len(devices) != 1: + device_names = sorted(str(device) for device in devices) + raise AssertionError(f"all planes must be on the same device, got {device_names}") + return self.planes[0].device + + @property + def device_type(self) -> str: + return self.device.type + + +def assert_frames_equal(actual: CanonicalFrame, expected: CanonicalFrame) -> None: + """Assert exact equality and provide a useful plane-level failure.""" + + assert actual.format == expected.format + assert actual.width == expected.width + assert actual.height == expected.height + assert len(actual.planes) == len(expected.planes) + for plane_index, (actual_plane, expected_plane) in enumerate(zip(actual.planes, expected.planes)): + assert actual_plane.shape == expected_plane.shape, f"plane {plane_index} shape differs" + assert actual_plane.dtype == expected_plane.dtype, f"plane {plane_index} dtype differs" + assert actual_plane.device == expected_plane.device, f"plane {plane_index} device differs" + assert torch.equal(actual_plane, expected_plane), f"plane {plane_index} pixels differ" diff --git a/packages/on_demand_video_decoder/tests/common/test_decode_common.py b/packages/on_demand_video_decoder/tests/common/test_decode_common.py new file mode 100644 index 00000000..4bcf5e41 --- /dev/null +++ b/packages/on_demand_video_decoder/tests/common/test_decode_common.py @@ -0,0 +1,125 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Minimum decode behavior shared by file-and-frame decoder APIs.""" + +from __future__ import annotations + +from typing import List, Sequence, Tuple + +import torch + +from common.adapters import DecoderTestAdapter, OutputFormat +from common.cases import sample_videos +from common.checker import BinaryCorrectnessChecker +from common.model import CanonicalFrame, DecodeCase, assert_frames_equal + +DecoderInput = Tuple[DecoderTestAdapter, OutputFormat] + + +def _decode_and_normalize( + adapter: DecoderTestAdapter, + output_format: OutputFormat, + videos: Sequence[str], + frame_indices: Sequence[int], +) -> List[CanonicalFrame]: + frames = adapter.decode(videos, frame_indices, output_format) + assert frames is not None + assert len(frames) == len(videos) + assert all(frame is not None for frame in frames) + return [adapter.normalize(frame, output_format) for frame in frames] + + +def _assert_output_contract( + adapter: DecoderTestAdapter, + output_format: OutputFormat, + frame: CanonicalFrame, +) -> None: + contract = adapter.output_contract(output_format) + assert frame.width > 0 + assert frame.height > 0 + assert frame.planes + assert frame.dtype in contract.dtypes + assert frame.device_type == contract.device_type + + if contract.kind in {"RGB", "BGR"}: + assert frame.format == contract.kind + assert len(frame.planes) == 1 + assert frame.planes[0].shape == (frame.height, frame.width, 3) + else: + assert frame.format in {"NV12", "P016", "YUV444", "YUV444_16BIT"} + if frame.format in {"NV12", "P016"}: + assert len(frame.planes) == 2 + assert frame.planes[0].shape == (frame.height, frame.width) + assert frame.planes[1].shape == (frame.height // 2, frame.width // 2, 2) + else: + assert len(frame.planes) == 3 + assert all(plane.shape == (frame.height, frame.width) for plane in frame.planes) + + +def test_common_decode_contract( + decoder_input: DecoderInput, + decode_case: DecodeCase, + binary_correctness_checker: BinaryCorrectnessChecker, +) -> None: + adapter, output_format = decoder_input + frames = _decode_and_normalize( + adapter, + output_format, + decode_case.videos, + decode_case.frame_indices, + ) + for frame in frames: + _assert_output_contract(adapter, output_format, frame) + + if decode_case.name == "same_video_unsorted_duplicate": + assert_frames_equal(frames[0], frames[2]) + + if binary_correctness_checker is not None: + binary_correctness_checker.validate(decode_case, output_format, frames) + + +def test_decode_is_deterministic(decoder_input: DecoderInput) -> None: + adapter, output_format = decoder_input + videos = sample_videos()[:3] + frame_indices = (0, 37, 100) + first = _decode_and_normalize(adapter, output_format, videos, frame_indices) + second = _decode_and_normalize(adapter, output_format, videos, frame_indices) + for actual, expected in zip(second, first): + assert_frames_equal(actual, expected) + + +def test_request_order_is_preserved(decoder_input: DecoderInput) -> None: + adapter, output_format = decoder_input + video = sample_videos()[0] + videos = (video, video, video) + ordered = _decode_and_normalize(adapter, output_format, videos, (0, 30, 60)) + reordered = _decode_and_normalize(adapter, output_format, videos, (60, 0, 30)) + + assert_frames_equal(reordered[0], ordered[2]) + assert_frames_equal(reordered[1], ordered[0]) + assert_frames_equal(reordered[2], ordered[1]) + + +def test_same_frame_is_batch_independent(decoder_input: DecoderInput) -> None: + adapter, output_format = decoder_input + videos = sample_videos() + single = _decode_and_normalize(adapter, output_format, (videos[0],), (30,))[0] + mixed = _decode_and_normalize( + adapter, + output_format, + (videos[1], videos[0], videos[2]), + (10, 30, 50), + )[1] + assert_frames_equal(mixed, single) diff --git a/packages/on_demand_video_decoder/tests/common/test_decode_invalid.py b/packages/on_demand_video_decoder/tests/common/test_decode_invalid.py new file mode 100644 index 00000000..377519ae --- /dev/null +++ b/packages/on_demand_video_decoder/tests/common/test_decode_invalid.py @@ -0,0 +1,71 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Invalid requests shared by all file-and-frame decoder APIs.""" + +from __future__ import annotations + +from typing import Tuple + +import pytest + +from common.adapters import DecoderTestAdapter, OutputFormat +from common.cases import sample_videos +from common.model import InvalidDecodeCase + +DecoderInput = Tuple[DecoderTestAdapter, OutputFormat] +EXPECTED_EXCEPTIONS = (RuntimeError, ValueError, TypeError) + + +def _assert_recovery(adapter: DecoderTestAdapter, output_format: OutputFormat) -> None: + frames = adapter.decode((sample_videos()[0],), (0,), output_format) + assert frames is not None + assert len(frames) == 1 + assert frames[0] is not None + recovered = adapter.normalize(frames[0], output_format) + assert recovered.width > 0 + assert recovered.height > 0 + assert recovered.planes + + +def test_empty_request_is_safe_and_recovers( + invalid_decoder_input: DecoderInput, +) -> None: + adapter, output_format = invalid_decoder_input + + try: + frames = adapter.decode((), (), output_format) + except EXPECTED_EXCEPTIONS as error: + assert str(error).strip(), "decode error must contain an actionable message" + else: + assert frames == [] + + _assert_recovery(adapter, output_format) + + +def test_invalid_request_fails_atomically_and_recovers( + invalid_decoder_input: DecoderInput, + invalid_decode_case: InvalidDecodeCase, +) -> None: + adapter, output_format = invalid_decoder_input + + with pytest.raises(EXPECTED_EXCEPTIONS) as error: + adapter.decode( + invalid_decode_case.videos, + invalid_decode_case.frame_indices, + output_format, + ) + assert str(error.value).strip(), "decode error must contain an actionable message" + + _assert_recovery(adapter, output_format) diff --git a/packages/on_demand_video_decoder/tests/common/test_decode_resources.py b/packages/on_demand_video_decoder/tests/common/test_decode_resources.py new file mode 100644 index 00000000..5f1c668a --- /dev/null +++ b/packages/on_demand_video_decoder/tests/common/test_decode_resources.py @@ -0,0 +1,80 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Format contracts and cross-API equality for committed video fixtures.""" + +from __future__ import annotations + +from fractions import Fraction +from typing import Tuple + +import accvlab.on_demand_video_decoder as nvc +from common.adapters import DecoderTestAdapter, adapter_factories +from common.cases import resource_cases +from common.model import CanonicalFrame, ResourceDecodeCase, assert_frames_equal + +ResourceDecoderInput = Tuple[DecoderTestAdapter, ResourceDecodeCase] + + +def _decode_one(adapter: DecoderTestAdapter, case: ResourceDecodeCase) -> CanonicalFrame: + frames = adapter.decode( + (case.video,), + (case.frame_index,), + case.preferred_output_format, + ) + + assert frames is not None + assert len(frames) == 1 + assert frames[0] is not None + return adapter.normalize(frames[0], case.preferred_output_format) + + +def test_decode_committed_resource(resource_decoder_input: ResourceDecoderInput) -> None: + adapter, case = resource_decoder_input + frame = _decode_one(adapter, case) + + assert frame.format == case.expected_format + assert frame.dtype == case.expected_dtype + assert tuple(tuple(plane.shape) for plane in frame.planes) == case.expected_plane_shapes + + +def test_resource_adapters_are_pixel_identical(resource_case: ResourceDecodeCase) -> None: + baseline = None + compared_adapters = 0 + for factory in adapter_factories(nvc): + if resource_case.preferred_output_format not in factory.output_formats: + continue + adapter = factory.create() + try: + actual = _decode_one(adapter, resource_case) + finally: + adapter.close() + if baseline is None: + baseline = actual + else: + assert_frames_equal(actual, baseline) + compared_adapters += 1 + + assert compared_adapters >= 2 + + +def test_vfr_fixture_reports_variable_timing() -> None: + case = next(case for case in resource_cases() if case.name == "vfr_h264") + info = nvc.GetFastInitInfo([case.video])[0] + average_rate = Fraction(info.avg_frame_rate_num, info.avg_frame_rate_den) + real_rate = Fraction(info.r_frame_rate_num, info.r_frame_rate_den) + + assert (info.width, info.height) == (256, 256) + assert info.duration > 0 + assert average_rate != real_rate diff --git a/packages/on_demand_video_decoder/tests/test_batch_async_gop_decoder.py b/packages/on_demand_video_decoder/tests/test_batch_async_gop_decoder.py index 6e78032e..db115333 100644 --- a/packages/on_demand_video_decoder/tests/test_batch_async_gop_decoder.py +++ b/packages/on_demand_video_decoder/tests/test_batch_async_gop_decoder.py @@ -156,25 +156,6 @@ def test_construct_rejects_invalid_args(kwargs): # =========================================================================== -def test_validate_empty_filepaths(): - """Empty filepaths list is rejected before any decode work.""" - dec = _make_async_dec() - with pytest.raises(RuntimeError, match="filepaths must not be empty"): - dec.DecodeFromGOPListRGB([], [], [], False) - - -def test_validate_too_many_files(): - """More filepaths than num_of_file is rejected.""" - files = _sample_files() - dec = _make_async_dec(V=1) # only room for 1 video - if len(files) <= 1: - pytest.skip("need at least 2 sample videos") - gop_dec = _make_gop_dec() - numpy_datas = _build_numpy_datas(gop_dec, files, [[0]] * len(files)) - with pytest.raises(RuntimeError, match="exceeds maxfiles"): - dec.DecodeFromGOPListRGB(numpy_datas, files, [[0]] * len(files), False) - - def test_validate_too_many_frames(): """More frames than max_frames_per_decode_call is rejected.""" files = _sample_files() @@ -369,27 +350,6 @@ def test_rgb_output_shape(): assert len(out[v]) == F, f"out[{v}] has {len(out[v])} frames, expected {F}" -def test_rgb_output_dtype_and_device(): - """Each RGBFrame converts to a uint8 (H, W, 3) CUDA tensor.""" - files = _sample_files() - V = len(files) - frame_ids_2d = [[0]] * V - - gop_dec = _make_gop_dec() - numpy_datas = _build_numpy_datas(gop_dec, files, frame_ids_2d) - - dec = _make_async_dec(V=V, F=1) - dec.DecodeFromGOPListRGB(numpy_datas, files, frame_ids_2d, False) - out = dec.DecodeFromGOPListRGBGetBuffer(files, frame_ids_2d, False) - - for v in range(V): - t = torch.as_tensor(out[v][0], device="cuda") - assert t.dtype == torch.uint8, f"v={v}: dtype={t.dtype}" - assert t.ndim == 3, f"v={v}: ndim={t.ndim}" - assert t.shape[-1] == 3, f"v={v}: shape={tuple(t.shape)}" - assert t.device.type == "cuda" - - def test_rgb_single_video_multi_frame(): """V=1, F=4 is supported.""" files = _sample_files() @@ -405,39 +365,6 @@ def test_rgb_single_video_multi_frame(): assert len(out) == 1 and len(out[0]) == len(_SAME_GOP_FRAMES) -def test_rgb_single_frame_per_video(): - """F=1 is supported (one frame per video).""" - files = _sample_files() - V = len(files) - frame_ids_2d = [[0]] * V - - gop_dec = _make_gop_dec() - numpy_datas = _build_numpy_datas(gop_dec, files, frame_ids_2d) - - dec = _make_async_dec(V=V, F=1) - dec.DecodeFromGOPListRGB(numpy_datas, files, frame_ids_2d, False) - out = dec.DecodeFromGOPListRGBGetBuffer(files, frame_ids_2d, False) - assert len(out) == V - for v in range(V): - assert len(out[v]) == 1 - - -@pytest.mark.parametrize("as_bgr", [False, True]) -def test_rgb_as_bgr_flag(as_bgr): - """as_bgr=True and as_bgr=False both complete without error.""" - files = _sample_files() - V = len(files) - frame_ids_2d = [[0]] * V - - gop_dec = _make_gop_dec() - numpy_datas = _build_numpy_datas(gop_dec, files, frame_ids_2d) - - dec = _make_async_dec(V=V, F=1) - dec.DecodeFromGOPListRGB(numpy_datas, files, frame_ids_2d, as_bgr) - out = dec.DecodeFromGOPListRGBGetBuffer(files, frame_ids_2d, as_bgr) - assert out[0][0] is not None - - # =========================================================================== # Section E — functional YUV decode (shape, views, format) # =========================================================================== @@ -462,58 +389,6 @@ def test_yuv_output_shape(): assert len(out[v]) == F -def test_yuv_frame_has_views(): - """Each DecodedFrameExt has the correct number of CAIMemoryView plane views.""" - files = _sample_files() - V = len(files) - frame_ids_2d = [[0]] * V - - gop_dec = _make_gop_dec() - numpy_datas = _build_numpy_datas(gop_dec, files, frame_ids_2d) - - dec = _make_async_dec(V=V, F=1) - dec.DecodeFromGOPList(numpy_datas, files, frame_ids_2d) - out = dec.DecodeFromGOPListGetBuffer(files, frame_ids_2d) - - # Raw Pixel_Format enum values (from PyCAIMemoryView.hpp): - # NV12=3 → 2 planes (Y, UV interleaved) - # YUV444=4 → 3 planes (Y, U, V separate) - # P016=5 → 2 planes (Y, UV interleaved 16-bit) - # YUV444_16Bit=6 → 3 planes (Y, U, V separate 16-bit) - EXPECTED_VIEWS = {3: 2, 4: 3, 5: 2, 6: 3} - - for v in range(V): - frame = out[v][0] - views = frame.cuda() - fmt = frame.format - expected = EXPECTED_VIEWS.get(fmt, 2) - assert ( - len(views) == expected - ), f"v={v}: format={fmt}, expected {expected} plane views, got {len(views)}" - # Y-plane shape: (H, W, 1) - assert len(views[0].shape) == 3 - assert views[0].shape[2] == 1 - - -def test_yuv_frame_format_set(): - """DecodedFrameExt.format is not UNDEFINED.""" - files = _sample_files() - V = len(files) - frame_ids_2d = [[0]] * V - - gop_dec = _make_gop_dec() - numpy_datas = _build_numpy_datas(gop_dec, files, frame_ids_2d) - - dec = _make_async_dec(V=V, F=1) - dec.DecodeFromGOPList(numpy_datas, files, frame_ids_2d) - out = dec.DecodeFromGOPListGetBuffer(files, frame_ids_2d) - - for v in range(V): - assert ( - out[v][0].format != nvc.Pixel_Format_UNDEFINED if hasattr(nvc, "Pixel_Format_UNDEFINED") else True - ) - - # =========================================================================== # Section F — precision: 2D RGB output must bit-match 1D GOP reference # =========================================================================== @@ -804,28 +679,6 @@ def test_resubmit_only_latest_result_retrievable(): dec2.DecodeFromGOPListRGBGetBuffer(files, frame_ids_a, False) -def test_invalid_frame_id_propagates_exception(): - """Out-of-range frame_id (beyond video length) is rethrown at GetBuffer. - - The bundle for frame 0 does not cover frame 999999, so the worker throws - "no serialized GOP bundle covers frame 999999". This tests that async - worker errors are stored and re-raised at GetBuffer time. - """ - files = _sample_files() - V = len(files) - # Frame id 999999 is almost certainly out of range for any test video. - frame_ids_2d = [[999999]] * V - gop_dec = _make_gop_dec() - # Bundle obtained for a valid frame; the invalid frame_id causes a decode error. - gop_data = _get_numpy_data(gop_dec, files[0], 0) - numpy_datas = [[gop_data]] * V - - dec = _make_async_dec(V=V, F=1) - dec.DecodeFromGOPListRGB(numpy_datas, files, frame_ids_2d, False) - with pytest.raises(RuntimeError): - dec.DecodeFromGOPListRGBGetBuffer(files, frame_ids_2d, False) - - def test_wrong_gop_bundle_propagates_async_error(): """Valid frame_id but bundle from the wrong GOP is rethrown at GetBuffer. diff --git a/packages/on_demand_video_decoder/tests/test_batch_async_stream_decoder.py b/packages/on_demand_video_decoder/tests/test_batch_async_stream_decoder.py index 1d67a013..13cf5a85 100644 --- a/packages/on_demand_video_decoder/tests/test_batch_async_stream_decoder.py +++ b/packages/on_demand_video_decoder/tests/test_batch_async_stream_decoder.py @@ -125,32 +125,6 @@ def test_rejects_direct_construction(): # =========================================================================== -def test_validate_size_mismatch(): - """filepaths.size() != frame_ids_2d.size() rejected at entry.""" - files = _select_sample_videos() - r = _make_reader(V=len(files)) - bad_frame_ids = [[0]] # length 1, but files has more entries - with pytest.raises(RuntimeError, match=r"filepaths\.size\(\).*frame_ids_2d\.size\(\)"): - r.Decode(files, bad_frame_ids, False) - - -def test_validate_empty_filepaths(): - """Empty filepaths list is rejected.""" - r = _make_reader() - with pytest.raises(RuntimeError, match=r"filepaths must not be empty"): - r.Decode([], [], False) - - -def test_validate_too_many_files(): - """Exceeding num_of_file is rejected at entry.""" - files = _select_sample_videos() - r = _make_reader(V=1) # num_of_file=1, but we'll pass len(files) > 1 - if len(files) <= 1: - pytest.skip("need at least 2 sample videos for this test") - with pytest.raises(RuntimeError, match=r"exceeds num_of_file"): - r.Decode(files, [[0]] * len(files), False) - - def test_validate_too_many_frames(): """Exceeding max_frames_per_decode_call is rejected at entry.""" files = _select_sample_videos() @@ -217,41 +191,6 @@ def test_decode_basic_2d_shape(): assert len(out[v]) == F, f"out[{v}] inner len should be F={F}, got {len(out[v])}" -def test_decode_basic_2d_dtype_and_device(): - """Each frame is a uint8, 3-channel tensor on CUDA.""" - files = _select_sample_videos() - V = len(files) - F = 2 - frame_ids_2d = [[0, 7]] * V - - r = _make_reader(V=V, F=F) - r.Decode(files, frame_ids_2d, as_bgr=False) - out = r.GetBuffer(files, frame_ids_2d, as_bgr=False) - - for v in range(V): - for f in range(F): - t = torch.as_tensor(out[v][f], device="cuda") - assert t.dtype == torch.uint8, f"out[{v}][{f}].dtype = {t.dtype}" - assert t.ndim == 3, f"out[{v}][{f}].ndim = {t.ndim}" - assert t.shape[-1] == 3, f"out[{v}][{f}].shape = {tuple(t.shape)}" - assert t.device.type == "cuda" - - -def test_decode_single_frame_per_video(): - """F=1 is supported (degenerates to behavior similar to 1D API).""" - files = _select_sample_videos() - V = len(files) - frame_ids_2d = [[0]] * V - - r = _make_reader(V=V, F=1) - r.Decode(files, frame_ids_2d, as_bgr=False) - out = r.GetBuffer(files, frame_ids_2d, as_bgr=False) - - assert len(out) == V - for v in range(V): - assert len(out[v]) == 1 - - def test_decode_single_video_multi_frame(): """V=1 with multiple frames is supported.""" files = _select_sample_videos() @@ -408,17 +347,3 @@ def test_resubmit_keeps_only_latest_result(): r2.Decode(files, frame_ids_b, False) with pytest.raises(RuntimeError, match=r"do not match buffered result"): r2.GetBuffer(files, frame_ids_a, False) - - -def test_invalid_file_propagates_exception(): - """A decode error in the worker (e.g. nonexistent file) is rethrown at Get.""" - files = _select_sample_videos() - V = len(files) - bad_files = list(files) - bad_files[0] = "/__definitely_not_a_real_file__.mp4" - frame_ids_2d = [[0]] * V - - r = _make_reader(V=V) - r.Decode(bad_files, frame_ids_2d, False) - with pytest.raises(RuntimeError): - r.GetBuffer(bad_files, frame_ids_2d, False) diff --git a/packages/on_demand_video_decoder/tests/test_decoder_with_fast_init.py b/packages/on_demand_video_decoder/tests/test_decoder_with_fast_init.py deleted file mode 100644 index fa14b086..00000000 --- a/packages/on_demand_video_decoder/tests/test_decoder_with_fast_init.py +++ /dev/null @@ -1,88 +0,0 @@ -# Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import pytest -import sys - -import random - -import utils -import accvlab.on_demand_video_decoder as nvc - - -def test_random_access_fast_init_single(): - max_num_files_to_use = 6 - iter_num = 10 - path_base = utils.get_data_dir() - - nv_gop_dec = nvc.CreateGopDecoder( - maxfiles=max_num_files_to_use, - iGpu=0, - ) - - frame_min = 0 - frame_max = 200 - - files = utils.select_random_clip(path_base) - assert files is not None, f"files is None for select_random_clip, path_base: {path_base}" - fast_stream_infos = nvc.GetFastInitInfo(files) - - for c in range(iter_num): - files = utils.select_random_clip(path_base) - assert files is not None, f"files is None for select_random_clip, path_base: {path_base}" - - frames = [random.randint(frame_min, frame_max) for _ in range(len(files))] - print(f"Comparison: {c}, frames: {frames}") - - gop_decoded = utils.gop_decode_bgr_with_fast_init(nv_gop_dec, files, frames, fast_stream_infos) - assert gop_decoded is not None, f"gop_decoded is None for DecodeN12ToRGB, frames: {frames}" - - -def test_separate_access_fast_init_single(): - max_num_files_to_use = 6 - iter_num = 10 - path_base = utils.get_data_dir() - - nv_gop_dec1 = nvc.CreateGopDecoder( - maxfiles=max_num_files_to_use, - iGpu=0, - ) - - nv_gop_dec2 = nvc.CreateGopDecoder( - maxfiles=max_num_files_to_use, - iGpu=0, - ) - - frame_min = 0 - frame_max = 200 - - files = utils.select_random_clip(path_base) - assert files is not None, f"files is None for select_random_clip, path_base: {path_base}" - fast_stream_infos = nvc.GetFastInitInfo(files) - - for c in range(iter_num): - files = utils.select_random_clip(path_base) - assert files is not None, f"files is None for select_random_clip, path_base: {path_base}" - - frames = [random.randint(frame_min, frame_max) for _ in range(len(files))] - print(f"Comparison: {c}, frames: {frames}") - - gop_decoded = utils.gop_decode_bgr_ddseparate_with_fast_init( - nv_gop_dec1, nv_gop_dec2, files, frames, fast_stream_infos - ) - assert gop_decoded is not None, f"gop_decoded is None for DecodeN12ToRGB, frames: {frames}" - - -if __name__ == "__main__": - sys.exit(pytest.main([__file__, "-v"])) diff --git a/packages/on_demand_video_decoder/tests/test_pix_fmt_detection.py b/packages/on_demand_video_decoder/tests/test_pix_fmt_detection.py index 7f226fa9..2c44a63e 100644 --- a/packages/on_demand_video_decoder/tests/test_pix_fmt_detection.py +++ b/packages/on_demand_video_decoder/tests/test_pix_fmt_detection.py @@ -21,7 +21,7 @@ 10-bit HEVC stream this mis-sized the GPU output buffer by a factor of 2 and crashed the GOP decode path with `CUDA_ERROR_INVALID_VALUE`. -These tests drive `GetGOPList` + `DecodeFromGOPList` over the matrix of stream +This parametrized test drives `GetGOPList` + `DecodeFromGOPList` over the matrix of stream shapes the package supports on NVDEC (HEVC at both 8-bit and 10-bit with both hev1 and hvc1 sample-entry tags, plus H.264 8-bit), and assert the decoded planes carry the dtype implied by the stream's real bit-depth (uint8 for @@ -44,11 +44,11 @@ # package targets — see the support matrix for codec/bit-depth coverage: # https://developer.nvidia.com/video-encode-decode-support-matrix VARIANTS = [ - ("hevc_hev1_yuv420p.mp4", "hev1", 8, "|u1"), - ("hevc_hev1_yuv420p10le.mp4", "hev1", 10, "|u2"), - ("hevc_hvc1_yuv420p.mp4", "hvc1", 8, "|u1"), - ("hevc_hvc1_yuv420p10le.mp4", "hvc1", 10, "|u2"), - ("h264_avc1_yuv420p.mp4", "avc1", 8, "|u1"), + ("hevc_hev1_yuv420p.mp4", "hev1", 8, 3, "|u1", ((256, 256, 1), (128, 128, 2))), + ("hevc_hev1_yuv420p10le.mp4", "hev1", 10, 5, "|u2", ((256, 256, 1), (128, 128, 2))), + ("hevc_hvc1_yuv420p.mp4", "hvc1", 8, 3, "|u1", ((256, 256, 1), (128, 128, 2))), + ("hevc_hvc1_yuv420p10le.mp4", "hvc1", 10, 5, "|u2", ((256, 256, 1), (128, 128, 2))), + ("h264_avc1_yuv420p.mp4", "avc1", 8, 3, "|u1", ((256, 256, 1), (128, 128, 2))), ] @@ -60,11 +60,18 @@ def _video_path(name): @pytest.mark.parametrize( - "filename, codec_tag, bit_depth, expected_dtype", + "filename, codec_tag, bit_depth, expected_format, expected_dtype, expected_shapes", VARIANTS, ids=[v[0] for v in VARIANTS], ) -def test_decode_from_gop_round_trip(filename, codec_tag, bit_depth, expected_dtype): +def test_decode_from_gop_round_trip( + filename, + codec_tag, + bit_depth, + expected_format, + expected_dtype, + expected_shapes, +): """End-to-end: GetGOPList -> DecodeFromGOPList must produce a plane of the correct dtype for the stream's actual bit-depth.""" path = _video_path(filename) @@ -72,49 +79,50 @@ def test_decode_from_gop_round_trip(filename, codec_tag, bit_depth, expected_dty demuxer = nvc.CreateGopDecoder(maxfiles=1, iGpu=0) decoder = nvc.CreateGopDecoder(maxfiles=1, iGpu=0) - gop_list = demuxer.GetGOPList([path], [0], useGOPCache=True) + frame_id = 33 + gop_list = demuxer.GetGOPList([path], [frame_id], useGOPCache=True) assert gop_list, f"GetGOPList returned empty for {filename}" gop_data, first_ids, gop_lens = gop_list[0] assert gop_data.size > 0, f"GOP data is empty for {filename}" - assert first_ids == [0], f"unexpected first_ids={first_ids} for {filename}" + assert first_ids[0] <= frame_id, f"unexpected first_ids={first_ids} for {filename}" assert gop_lens and gop_lens[0] > 0, f"unexpected gop_lens={gop_lens} for {filename}" + assert frame_id < first_ids[0] + gop_lens[0] - frames = decoder.DecodeFromGOPList([gop_data], [path], [0]) + frames = decoder.DecodeFromGOPList([gop_data], [path], [frame_id]) assert len(frames) == 1, f"expected 1 frame, got {len(frames)} for {filename}" + assert frames[0].format == expected_format planes = frames[0].cuda() - assert len(planes) >= 1, f"no planes returned for {filename}" - - cai = planes[0].__cuda_array_interface__ - assert cai["typestr"] == expected_dtype, ( - f"Y plane dtype mismatch for {filename}: got {cai['typestr']!r}, " - f"expected {expected_dtype!r} for {bit_depth}-bit" - ) + assert tuple(tuple(plane.shape) for plane in planes) == expected_shapes expected_bytes_per_sample = 2 if bit_depth >= 10 else 1 - actual_bytes_per_sample = int(cai["typestr"][-1]) + luma_width = expected_shapes[0][1] + expected_strides = ( + ( + luma_width * expected_bytes_per_sample, + expected_bytes_per_sample, + expected_bytes_per_sample, + ), + ( + luma_width * expected_bytes_per_sample, + 2 * expected_bytes_per_sample, + expected_bytes_per_sample, + ), + ) + actual_strides = tuple(tuple(plane.__cuda_array_interface__["strides"]) for plane in planes) + assert ( + actual_strides == expected_strides + ), f"plane strides mismatch for {filename}: got {actual_strides}, expected {expected_strides}" + + for plane_index, plane in enumerate(planes): + cai = plane.__cuda_array_interface__ + assert cai["typestr"] == expected_dtype, ( + f"plane {plane_index} dtype mismatch for {filename}: got {cai['typestr']!r}, " + f"expected {expected_dtype!r} for {bit_depth}-bit" + ) + + actual_bytes_per_sample = int(planes[0].__cuda_array_interface__["typestr"][-1]) assert actual_bytes_per_sample == expected_bytes_per_sample, ( f"Y plane element size mismatch for {filename}: got " f"{actual_bytes_per_sample}B, expected {expected_bytes_per_sample}B" ) - - -@pytest.mark.parametrize( - "filename, codec_tag, bit_depth, expected_dtype", - VARIANTS, - ids=[v[0] for v in VARIANTS], -) -def test_decode_does_not_raise_invalid_value(filename, codec_tag, bit_depth, expected_dtype): - """Focused regression: the specific failure mode we fixed was - `CUDA_ERROR_INVALID_VALUE` thrown from the GOP decode path because the GPU buffer - was half the size NVDEC writes. Reproduce the exact call sequence the - customer used and assert it does not raise that error.""" - path = _video_path(filename) - demuxer = nvc.CreateGopDecoder(maxfiles=1, iGpu=0) - decoder = nvc.CreateGopDecoder(maxfiles=1, iGpu=0) - - gop_data, _, _ = demuxer.GetGOPList([path], [0], useGOPCache=True)[0] - # No prior RGB call to "prime" the GPU pool — exercise the raw YUV path - # directly, which was the broken path before the SPS fallback. - frames = decoder.DecodeFromGOPList([gop_data], [path], [0]) - assert frames, f"DecodeFromGOPList returned no frames for {filename}" diff --git a/packages/on_demand_video_decoder/tests/test_random_decoder.py b/packages/on_demand_video_decoder/tests/test_random_decoder.py index 910a625b..fa287a16 100644 --- a/packages/on_demand_video_decoder/tests/test_random_decoder.py +++ b/packages/on_demand_video_decoder/tests/test_random_decoder.py @@ -15,9 +15,6 @@ import pytest import sys -import random - -import utils import accvlab.on_demand_video_decoder as nvc @@ -29,29 +26,5 @@ def test_pynvgopdecoder_rejects_direct_construction(): nvc.PyNvGopDecoder(1, 0) -def test_random_access_single(): - max_num_files_to_use = 6 - iter_num = 10 - path_base = utils.get_data_dir() - - nv_gop_dec = nvc.CreateGopDecoder( - maxfiles=max_num_files_to_use, - iGpu=0, - ) - - frame_min = 0 - frame_max = 200 - - for c in range(iter_num): - files = utils.select_random_clip(path_base) - assert files is not None, f"files is None for select_random_clip, path_base: {path_base}" - - frames = [random.randint(frame_min, frame_max) for _ in range(len(files))] - print(f"Comparison: {c}, frames: {frames}") - - gop_decoded = utils.gop_decode_bgr(nv_gop_dec, files, frames) - assert gop_decoded is not None, f"gop_decoded is None for DecodeN12ToRGB, frames: {frames}" - - if __name__ == "__main__": sys.exit(pytest.main([__file__, "-v"])) diff --git a/packages/on_demand_video_decoder/tests/test_separate_decoder.py b/packages/on_demand_video_decoder/tests/test_separate_decoder.py index d55fd547..b8842497 100644 --- a/packages/on_demand_video_decoder/tests/test_separate_decoder.py +++ b/packages/on_demand_video_decoder/tests/test_separate_decoder.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,262 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -import pytest -import sys - -import torch -import random -import os - -import utils -import accvlab.on_demand_video_decoder as nvc - - -def test_separate_access_from_gop_file_to_list_api_single(): - """ - Test LoadGopsToList + DecodeFromGOPListRGB API combination with file persistence. - - This test validates the workflow: - 1. Extract GOP data using GetGOPList and save to separate files - 2. Load GOP data from files as a list using LoadGopsToList - 3. Decode using DecodeFromGOPListRGB - 4. Compare with OpenCV baseline - """ - max_num_files_to_use = 6 - iter_num = 10 - path_base = utils.get_data_dir() - - nv_gop_dec1 = nvc.CreateGopDecoder( - maxfiles=max_num_files_to_use, - iGpu=0, - ) - - nv_gop_dec2 = nvc.CreateGopDecoder( - maxfiles=max_num_files_to_use, - iGpu=0, - ) - - frame_min = 0 - frame_max = 200 - - for c in range(iter_num): - files = utils.select_random_clip(path_base) - assert files is not None, f"files is None for select_random_clip, path_base: {path_base}" - - frames = [random.randint(frame_min, frame_max) for _ in range(len(files))] - print(f"Comparison: {c}, frames: {frames}") - - num_frames_to_use = len(frames) - - # Step 1: Extract and save GOP data for each video - packet_files = [] - for i in range(len(files)): - ((numpy_data, first_frame_ids, gop_lens),) = nv_gop_dec1.GetGOPList( - files[i : i + 1], frames[i : i + 1] - ) - packet_file = os.path.join("./", f"packets_list_{c:02d}_{i:02d}.bin") - nvc.SaveGopToFile(numpy_data, packet_file) - packet_files.append(packet_file) - - # Verify file was created - assert os.path.exists(packet_file), f"Packet file not created: {packet_file}" - assert os.path.getsize(packet_file) == numpy_data.size, f"File size mismatch for {packet_file}" - - # Step 2: Load GOP data as a list using LoadGopsToList - gop_data_list = nv_gop_dec2.LoadGopsToList(packet_files) - - # Validate the loaded list - assert isinstance( - gop_data_list, list - ), f"LoadGopsToList should return a list, got {type(gop_data_list)}" - assert len(gop_data_list) == len( - packet_files - ), f"LoadGopsToList returned {len(gop_data_list)} items, expected {len(packet_files)}" - - # Step 3: Decode using DecodeFromGOPListRGB - decoded_frames = nv_gop_dec2.DecodeFromGOPListRGB( - gop_data_list, # List of GOP data arrays - files, # List of file paths - frames, # List of frame IDs - as_bgr=True, - ) - - assert decoded_frames is not None and len(decoded_frames) == num_frames_to_use, ( - f"DecodeFromGOPListRGB returned {len(decoded_frames) if decoded_frames else 0} " - f"frames, expected {num_frames_to_use}" - ) - - # Convert to tensor format for comparison - gop_decoded = [torch.unsqueeze(torch.as_tensor(df), 0) for df in decoded_frames] - assert len(gop_decoded) == num_frames_to_use - - # Cleanup: Remove temporary packet files - for packet_file in packet_files: - os.remove(packet_file) - - -def test_separate_access_from_multi_packets_on_the_fly(): - """ - Test per-video GetGOPList + DecodeFromGOPListRGB with packets collected on the fly. - - Calls GetGOPList once per video (one file at a time), collects the per-video - numpy arrays, then batch-decodes with DecodeFromGOPListRGB in a single call. - This exercises the code path where demux is interleaved with other work before - a single decode call at the end. - """ - max_num_files_to_use = 6 - iter_num = 10 - path_base = utils.get_data_dir() - - nv_gop_dec1 = nvc.CreateGopDecoder( - maxfiles=max_num_files_to_use, - iGpu=0, - ) - - nv_gop_dec2 = nvc.CreateGopDecoder( - maxfiles=max_num_files_to_use, - iGpu=0, - ) - - frame_min = 0 - frame_max = 200 - - for c in range(iter_num): - files = utils.select_random_clip(path_base) - assert files is not None, f"files is None for select_random_clip, path_base: {path_base}" - - frames = [random.randint(frame_min, frame_max) for _ in range(len(files))] - print(f"Comparison: {c}, frames: {frames}") - - packets_list = [] - for i in range(len(files)): - ((numpy_data, first_frame_ids, gop_lens),) = nv_gop_dec1.GetGOPList( - files[i : i + 1], frames[i : i + 1] - ) - packets_list.append(numpy_data) - - gop_decoded = utils.gop_decode_bgr_ddseparate_from_multi_packets( - nv_gop_dec2, files, frames, packets_list - ) - - assert gop_decoded is not None, f"gop_decoded is None for DecodeN12ToRGB, frames: {frames}" - - -def test_separate_access_gop_list_api(): - """ - Test GetGOPList + DecodeFromGOPListRGB API combination. - - This test validates the GetGOPList workflow by: - 1. Extracting separate GOP bundles for each video using GetGOPList - 2. Extracting gop_data from each bundle (ignoring metadata) - 3. Decoding all videos at once using DecodeFromGOPListRGB - 4. Comparing results with OpenCV baseline - - This combination demonstrates the complete workflow for per-video GOP management. - """ - max_num_files_to_use = 6 - iter_num = 10 - path_base = utils.get_data_dir() - - # Stage 1 decoder: Extract per-video GOP data - nv_gop_dec1 = nvc.CreateGopDecoder( - maxfiles=max_num_files_to_use, - iGpu=0, - ) - - # Stage 2 decoder: Decode from individual GOP bundles - nv_gop_dec2 = nvc.CreateGopDecoder( - maxfiles=max_num_files_to_use, - iGpu=0, - ) - - frame_min = 0 - frame_max = 200 - - for c in range(iter_num): - files = utils.select_random_clip(path_base) - assert files is not None, f"files is None for select_random_clip, path_base: {path_base}" - - frames = [random.randint(frame_min, frame_max) for _ in range(len(files))] - print(f"Comparison: {c}, frames: {frames}") - - num_frames_to_use = len(frames) - - # Stage 1: Extract per-video GOP data using GetGOPList - # Returns: [(gop_data1, first_ids1, gop_lens1), (gop_data2, ...), ...] - gop_list = nv_gop_dec1.GetGOPList(files, frames) - - # Validate GetGOPList output - assert gop_list is not None and len(gop_list) == len(files), ( - f"GetGOPList returned invalid data. " - f"Expected {len(files)} bundles, got {len(gop_list) if gop_list else 0}" - ) - - # Validate each bundle structure - for i, bundle in enumerate(gop_list): - assert ( - isinstance(bundle, tuple) and len(bundle) == 3 - ), f"Bundle {i} has invalid structure. Expected tuple of 3 elements, got {type(bundle)}" - gop_data, first_frame_ids, gop_lens = bundle - assert gop_data is not None and len(gop_data) > 0, f"Bundle {i} has empty or None gop_data" - - # Stage 2: Decode all videos at once using DecodeFromGOPListRGB - gop_data_list = [gop_data for gop_data, _, _ in gop_list] - - decoded_frames = nv_gop_dec2.DecodeFromGOPListRGB( - gop_data_list, # List of per-video GOP data from GetGOPList - files, # List of file paths (one per video) - frames, # List of frame IDs (one per video) - as_bgr=True, # Output in BGR format for comparison - ) - - assert decoded_frames is not None and len(decoded_frames) == num_frames_to_use, ( - f"DecodeFromGOPListRGB returned {len(decoded_frames) if decoded_frames else 0} " - f"frames, expected {num_frames_to_use}" - ) - - # Convert to tensor format for comparison - gop_decoded_list = [torch.unsqueeze(torch.as_tensor(df), 0) for df in decoded_frames] - assert len(gop_decoded_list) == num_frames_to_use - - -def test_separate_access_gop_list_raw_api(): - """Test GetGOPList + DecodeFromGOPList raw API (YUV output).""" - clip_dir = os.path.join(utils.get_data_dir(), "sample_clip") - files = [os.path.join(clip_dir, name) for name in sorted(os.listdir(clip_dir))[:3]] - frames = [0, 7, 14] - max_num_files_to_use = len(files) - expected_frame_size = 256 * 256 * 3 // 2 - - nv_gop_dec1 = nvc.CreateGopDecoder( - maxfiles=max_num_files_to_use, - iGpu=0, - ) - nv_gop_dec2 = nvc.CreateGopDecoder( - maxfiles=max_num_files_to_use, - iGpu=0, - ) - - gop_list = nv_gop_dec1.GetGOPList(files, frames) - assert gop_list is not None and len(gop_list) == len(files), ( - f"GetGOPList returned invalid data. " - f"Expected {len(files)} bundles, got {len(gop_list) if gop_list else 0}" - ) - - gop_data_list = [gop_data for gop_data, _, _ in gop_list] - decoded_frames = nv_gop_dec2.DecodeFromGOPList(gop_data_list, files, frames) - - assert decoded_frames is not None and len(decoded_frames) == len(files), ( - f"DecodeFromGOPList returned {len(decoded_frames) if decoded_frames else 0} " - f"frames, expected {len(files)}" - ) - - for file_name, frame_id, decoded_frame in zip(files, frames, decoded_frames): - assert decoded_frame.framesize() == expected_frame_size, ( - f"frame size mismatch for {file_name} frame {frame_id}: " - f"{decoded_frame.framesize()} != {expected_frame_size}" - ) - - -if __name__ == "__main__": - sys.exit(pytest.main([__file__, "-v"])) +"""Separate demux/decode workflows are covered by the common decoder tests.""" diff --git a/packages/on_demand_video_decoder/tests/test_stream_asnyc_decoder.py b/packages/on_demand_video_decoder/tests/test_stream_asnyc_decoder.py index 95257983..d75eb3f6 100644 --- a/packages/on_demand_video_decoder/tests/test_stream_asnyc_decoder.py +++ b/packages/on_demand_video_decoder/tests/test_stream_asnyc_decoder.py @@ -15,49 +15,11 @@ import pytest import sys import torch -import random -import time import utils import accvlab.on_demand_video_decoder as nvc -def test_async_decode_basic_flow(): - """ - Test 1.1: Basic async decode flow - Test that async decode works: start async -> wait -> get result - """ - max_num_files_to_use = 6 - path_base = utils.get_data_dir() - - nv_stream_dec = nvc.CreateSampleReader( - num_of_set=1, - num_of_file=max_num_files_to_use, - iGpu=0, - ) - - files = utils.select_random_clip(path_base) - assert files is not None, f"files is None for select_random_clip, path_base: {path_base}" - - frame_id_list = [0] * len(files) - - # Start async decode - nv_stream_dec.DecodeN12ToRGBAsync(files, frame_id_list, False) - - # Get result (will wait for async decode to complete) - decoded_frames = nv_stream_dec.DecodeN12ToRGBAsyncGetBuffer(files, frame_id_list, False) - - # Verify results - assert decoded_frames is not None, "decoded_frames is None" - assert len(decoded_frames) == len(files), f"Expected {len(files)} frames, got {len(decoded_frames)}" - - # Verify each frame is valid RGBFrame - for i, frame in enumerate(decoded_frames): - assert frame is not None, f"Frame {i} is None" - assert hasattr(frame, 'shape'), f"Frame {i} has no shape attribute" - assert hasattr(frame, 'data'), f"Frame {i} has no data attribute" - - def test_async_decode_prefetch_flow(): """ Test 1.2: Prefetch mechanism @@ -250,31 +212,6 @@ def test_async_decode_getbuffer_on_empty_buffer_throws_error(): nv_stream_dec.DecodeN12ToRGBAsyncGetBuffer(files, frame_0, False) -def test_async_decode_error_handling_invalid_file(): - """ - Test 4.1: Error handling - invalid file path - Test that decoding failure propagates correctly - """ - max_num_files_to_use = 6 - - nv_stream_dec = nvc.CreateSampleReader( - num_of_set=1, - num_of_file=max_num_files_to_use, - iGpu=0, - ) - - # Use invalid file path - invalid_files = ["/nonexistent/path/to/video.mp4"] - frame_id_list = [0] - - # Start async decode with invalid file (should fail in background) - nv_stream_dec.DecodeN12ToRGBAsync(invalid_files, frame_id_list, False) - - # GetBuffer should rethrow the exception - with pytest.raises(RuntimeError): - nv_stream_dec.DecodeN12ToRGBAsyncGetBuffer(invalid_files, frame_id_list, False) - - def test_async_decode_error_message_is_preserved(): """ Test 4.1b: Error handling - exception message is preserved from async thread @@ -330,34 +267,6 @@ def test_async_decode_error_message_is_preserved(): # This could be improved in the future by wrapping exceptions with file context. -def test_async_decode_error_handling_invalid_frame_id(): - """ - Test 4.2: Error handling - invalid frame_id - Test that invalid frame_id causes exception - """ - max_num_files_to_use = 6 - path_base = utils.get_data_dir() - - nv_stream_dec = nvc.CreateSampleReader( - num_of_set=1, - num_of_file=max_num_files_to_use, - iGpu=0, - ) - - files = utils.select_random_clip(path_base) - assert files is not None - - # Use very large frame_id that doesn't exist - invalid_frame_ids = [999999] * len(files) - - # Start async decode with invalid frame_id - nv_stream_dec.DecodeN12ToRGBAsync(files, invalid_frame_ids, False) - - # GetBuffer should rethrow the exception - with pytest.raises(RuntimeError): - nv_stream_dec.DecodeN12ToRGBAsyncGetBuffer(files, invalid_frame_ids, False) - - def test_async_decode_vs_sync_decode_result_comparison(): """ Test 9.2: Compare async decode results with sync decode results @@ -416,60 +325,6 @@ def test_async_decode_vs_sync_decode_result_comparison(): assert max_diff < 1.0, f"Frame {i} pixel values differ: max_diff={max_diff}" -def test_async_decode_multiple_frames_sequential(): - """ - Test: Sequential processing of multiple frames with prefetching - Test the complete workflow: decode frame 0, then prefetch and process frame 3, then frame 6 - """ - max_num_files_to_use = 6 - path_base = utils.get_data_dir() - - nv_stream_dec = nvc.CreateSampleReader( - num_of_set=1, - num_of_file=max_num_files_to_use, - iGpu=0, - ) - - files = utils.select_random_clip(path_base) - assert files is not None - - # Process frame 0 - frame_0 = [0] * len(files) - nv_stream_dec.DecodeN12ToRGBAsync(files, frame_0, False) - frames_0 = nv_stream_dec.DecodeN12ToRGBAsyncGetBuffer(files, frame_0, False) - assert frames_0 is not None - - # Deep copy frame 0 (as required by documentation) - tensor_0 = [torch.as_tensor(frame, device='cuda').clone() for frame in frames_0] - - # Prefetch frame 3 - frame_3 = [3] * len(files) - nv_stream_dec.DecodeN12ToRGBAsync(files, frame_3, False) - - # Process frame 3 - frames_3 = nv_stream_dec.DecodeN12ToRGBAsyncGetBuffer(files, frame_3, False) - assert frames_3 is not None - - # Deep copy frame 3 - tensor_3 = [torch.as_tensor(frame, device='cuda').clone() for frame in frames_3] - - # Prefetch frame 6 - frame_6 = [6] * len(files) - nv_stream_dec.DecodeN12ToRGBAsync(files, frame_6, False) - - # Process frame 6 - frames_6 = nv_stream_dec.DecodeN12ToRGBAsyncGetBuffer(files, frame_6, False) - assert frames_6 is not None - - # Deep copy frame 6 - tensor_6 = [torch.as_tensor(frame, device='cuda').clone() for frame in frames_6] - - # Verify all frames are valid - assert len(tensor_0) == len(files) - assert len(tensor_3) == len(files) - assert len(tensor_6) == len(files) - - @pytest.mark.timeout(30) def test_destructor_with_pending_task(): """ @@ -524,38 +379,6 @@ def test_getbuffer_without_async_call(): nv_stream_dec.DecodeN12ToRGBAsyncGetBuffer(files, frame_id_list, False) -def test_recovery_after_exception(): - """ - Test: Reader should be usable after exception - Test that reader can continue working after an exception occurred - """ - max_num_files_to_use = 6 - path_base = utils.get_data_dir() - - nv_stream_dec = nvc.CreateSampleReader( - num_of_set=1, - num_of_file=max_num_files_to_use, - iGpu=0, - ) - - # Trigger an exception with invalid file - nv_stream_dec.DecodeN12ToRGBAsync(["/invalid/nonexistent/path.mp4"], [0], False) - with pytest.raises(RuntimeError): - nv_stream_dec.DecodeN12ToRGBAsyncGetBuffer(["/invalid/nonexistent/path.mp4"], [0], False) - - # Verify reader can still work after exception - files = utils.select_random_clip(path_base) - assert files is not None - - frame_id_list = [0] * len(files) - - nv_stream_dec.DecodeN12ToRGBAsync(files, frame_id_list, False) - frames = nv_stream_dec.DecodeN12ToRGBAsyncGetBuffer(files, frame_id_list, False) - - assert frames is not None - assert len(frames) == len(files) - - def test_multiple_async_without_getbuffer(): """ Test: Multiple consecutive Async calls without GetBuffer @@ -658,26 +481,6 @@ def test_double_getbuffer_same_params(): nv_stream_dec.DecodeN12ToRGBAsyncGetBuffer(files, frame_id_list, False) -def test_empty_list_params(): - """ - Test: Empty list parameters should be handled gracefully - Test that empty filepaths/frame_ids don't cause crashes - """ - max_num_files_to_use = 6 - - nv_stream_dec = nvc.CreateSampleReader( - num_of_set=1, - num_of_file=max_num_files_to_use, - iGpu=0, - ) - - # Empty lists - return empty - nv_stream_dec.DecodeN12ToRGBAsync([], [], False) - frames = nv_stream_dec.DecodeN12ToRGBAsyncGetBuffer([], [], False) - # If it succeeds, result should be empty - assert len(frames) == 0 - - @pytest.mark.timeout(30) def test_sync_api_waits_for_async(): """ diff --git a/packages/on_demand_video_decoder/tests/test_stream_decoder.py b/packages/on_demand_video_decoder/tests/test_stream_decoder.py index 393142a8..702913a9 100644 --- a/packages/on_demand_video_decoder/tests/test_stream_decoder.py +++ b/packages/on_demand_video_decoder/tests/test_stream_decoder.py @@ -16,7 +16,6 @@ import sys import ctypes -import random import threading import time @@ -32,31 +31,6 @@ def test_pynvsamplereader_rejects_direct_construction(): nvc.PyNvSampleReader(1, 1) -def test_stream_access_single(): - max_num_files_to_use = 6 - iter_num = 10 - path_base = utils.get_data_dir() - - nv_gop_dec = nvc.CreateSampleReader( - num_of_set=10, - num_of_file=max_num_files_to_use, - iGpu=0, - ) - - frame_min = 0 - frame_max = 200 - - for c in range(iter_num): - files = utils.select_random_clip(path_base) - assert files is not None, f"files is None for select_random_clip, path_base: {path_base}" - - frames = [random.randint(frame_min, frame_max) for _ in range(len(files))] - print(f"Comparison: {c}, frames: {frames}") - - gop_decoded = utils.gop_decode_bgr(nv_gop_dec, files, frames) - assert gop_decoded is not None, f"gop_decoded is None for DecodeN12ToRGB, frames: {frames}" - - def _heartbeats_during(call, margin=0.02): """ Run `call` while a background thread records timestamps in a tight pure-Python loop. diff --git a/packages/on_demand_video_decoder/tests/test_stream_resource_free.py b/packages/on_demand_video_decoder/tests/test_stream_resource_free.py deleted file mode 100644 index 638cefe7..00000000 --- a/packages/on_demand_video_decoder/tests/test_stream_resource_free.py +++ /dev/null @@ -1,982 +0,0 @@ -# Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -""" -Resource Management Tests for Stream Decoder (CreateSampleReader) - -This module tests CPU and GPU memory resource release mechanisms for: -- Synchronous decoding: DecodeN12ToRGB() -- Asynchronous decoding: DecodeN12ToRGBAsync() + DecodeN12ToRGBAsyncGetBuffer() - -Key Points: -- NvDecoder uses CUDA Driver API (cuMemAlloc), NOT PyTorch allocator -- torch.cuda.memory_allocated() CANNOT detect NvDecoder memory -- We MUST use pynvml to get accurate GPU memory usage -""" - -import gc -import sys -import time - -import pytest -import torch - -import utils -import accvlab.on_demand_video_decoder as nvc -from utils import CPUMemoryMonitor, GPUMemoryMonitor, force_cleanup, measure_memory_delta - -# ============================================================================ -# Test Fixtures -# ============================================================================ - - -@pytest.fixture(scope="session", autouse=True) -def nvdec_warmup(): - """Pre-initialize NVDEC before any leak tests. - - The first NVDEC decoder creation on a cold GPU allocates ~30-40 MB of - runtime overhead (JIT, internal state) that is never returned to the OS. - Without warmup, the first test to create a decoder sees this overhead as - a 'leak' because the baseline was measured before NVDEC was initialized. - Running a throwaway decode here ensures all subsequent baseline measurements - are taken on a warmed-up NVDEC, so only real leaks show as deltas. - """ - path_base = utils.get_data_dir() - files = utils.select_random_clip(path_base) - if files: - decoder = nvc.CreateSampleReader(num_of_set=1, num_of_file=len(files), iGpu=0) - try: - decoder.DecodeN12ToRGB(files, [0] * len(files), False) - except Exception: - pass - try: - decoder.DecodeN12ToRGBAsync(files, [0] * len(files), False) - decoder.DecodeN12ToRGBAsyncGetBuffer(files, [0] * len(files), False) - except Exception: - pass - del decoder - force_cleanup() - - -@pytest.fixture -def gpu_monitor(): - """Fixture to provide GPU memory monitor.""" - with GPUMemoryMonitor(gpu_id=0) as monitor: - yield monitor - - -@pytest.fixture -def cpu_monitor(): - """Fixture to provide CPU memory monitor.""" - yield CPUMemoryMonitor() - - -@pytest.fixture -def video_files(): - """Fixture to provide test video files.""" - path_base = utils.get_data_dir() - files = utils.select_random_clip(path_base) - assert files is not None, f"No video files found in {path_base}" - return files - - -# ============================================================================ -# P0 Tests - Core Functionality (Must Pass) -# ============================================================================ - - -class TestP0CoreResourceRelease: - """ - P0 Priority Tests - Core resource release functionality. - These tests must pass for the decoder to be considered stable. - """ - - # Memory tolerance in MB - based on actual test results: - # - Set to 10 MB to allow for CUDA runtime overhead and measurement noise - GPU_TOLERANCE_MB = 10.0 - CPU_TOLERANCE_MB = 10.0 - - def test_01_basic_del_releases_gpu_memory(self, gpu_monitor, video_files): - """ - Test 1: Normal usage followed by `del` releases GPU resources. - - Scenario: Create decoder → decode several frames → del decoder - Verify: GPU memory returns to initial level (within tolerance) - """ - force_cleanup() - baseline_gpu = gpu_monitor.get_used_memory_mb() - - # Create decoder and decode - decoder = nvc.CreateSampleReader( - num_of_set=1, - num_of_file=6, - iGpu=0, - ) - - frame_ids = [0] * len(video_files) - _ = decoder.DecodeN12ToRGB(video_files, frame_ids, False) - - # Record memory after decoding - after_decode_gpu = gpu_monitor.get_used_memory_mb() - print( - f"\nGPU memory after decode: {after_decode_gpu:.1f} MB " - f"(baseline: {baseline_gpu:.1f} MB, delta: {after_decode_gpu - baseline_gpu:.1f} MB)" - ) - - # Delete decoder and force cleanup - del decoder - force_cleanup() - - # Check memory released - final_gpu = gpu_monitor.get_used_memory_mb() - is_ok, delta = measure_memory_delta(baseline_gpu, final_gpu, self.GPU_TOLERANCE_MB) - - print( - f"GPU memory after cleanup: {final_gpu:.1f} MB " - f"(baseline: {baseline_gpu:.1f} MB, delta: {delta:.1f} MB)" - ) - - assert is_ok, ( - f"GPU memory leak detected! " - f"Baseline: {baseline_gpu:.1f} MB, Final: {final_gpu:.1f} MB, " - f"Delta: {delta:.1f} MB (tolerance: {self.GPU_TOLERANCE_MB} MB)" - ) - - # def test_02_sync_decode_loop_memory_stable(self, gpu_monitor, video_files): - # """ - # Test 2: Synchronous decode loop - memory should remain stable. - - # Scenario: Create single decoder → decode N times in loop → del decoder - # Verify: Memory doesn't continuously grow during decoding - # """ - # force_cleanup() - # baseline_gpu = gpu_monitor.get_used_memory_mb() - - # decoder = nvc.CreateSampleReader( - # num_of_set=1, - # num_of_file=6, - # iGpu=0, - # ) - - # num_iterations = 50 - # memory_samples = [] - - # for i in range(num_iterations): - # frame_ids = [i % 100] * len(video_files) # Vary frame IDs - # frames = decoder.DecodeN12ToRGB(video_files, frame_ids, False) - - # # Deep copy to tensor and release reference - # tensors = [torch.as_tensor(f, device='cuda').clone() for f in frames] - # del frames, tensors - - # # Sample memory every 10 iterations - # if (i + 1) % 10 == 0: - # mem = gpu_monitor.get_used_memory_mb() - # memory_samples.append(mem) - # print(f"Iteration {i+1}: GPU memory = {mem:.1f} MB") - - # # Check memory stability (no continuous growth) - # # Compare first half average with second half average - # mid = len(memory_samples) // 2 - # first_half_avg = sum(memory_samples[:mid]) / mid if mid > 0 else 0 - # second_half_avg = sum(memory_samples[mid:]) / (len(memory_samples) - mid) - - # growth = second_half_avg - first_half_avg - # print( - # f"\nMemory growth: {growth:.1f} MB " - # f"(first half avg: {first_half_avg:.1f} MB, second half avg: {second_half_avg:.1f} MB)" - # ) - - # # Cleanup - # del decoder - # force_cleanup() - - # final_gpu = gpu_monitor.get_used_memory_mb() - # is_ok, delta = measure_memory_delta(baseline_gpu, final_gpu, self.GPU_TOLERANCE_MB) - - # print(f"Final GPU memory: {final_gpu:.1f} MB (delta from baseline: {delta:.1f} MB)") - - # # Memory should not grow significantly during iteration - # assert growth < self.GPU_TOLERANCE_MB, f"Memory growing during decode loop! Growth: {growth:.1f} MB" - - # # Memory should be released after del - # assert is_ok, f"GPU memory leak after del! Delta: {delta:.1f} MB" - - # TODO: Re-enable this test once a more scientific measurement methodology is in place. - # - # Why this test is currently disabled: - # On some CI environments (e.g. different GPU driver / CUDA version), the first - # DecodeN12ToRGBAsync call leaves a variable amount (~32 MB observed, but - # potentially more on other hardware) of CUDA stream-ordered pool allocations - # (USED_MEM_CURRENT) that persist after `del decoder`. The root cause is unclear - # (likely NVDEC driver-internal async context state or a thread/stream ordering - # effect specific to the background-thread async path), and the residual size is - # not predictable across environments, so no fixed tolerance can be justified. - # Setting a threshold like 64 MB would pass today but could silently let a real - # leak through on a GPU where the one-time overhead is larger. - # The same applies to the pynvml GPU-delta assertion: it reflects device-global - # used memory that also captures driver/context retention unrelated to our code. - # def test_03_async_decode_loop_memory_stable(self, gpu_monitor, video_files): - # """ - # Test 3: Asynchronous decode loop - memory should remain stable. - - # Scenario: Create decoder → loop (Async + GetBuffer) N times → del decoder - # Verify: Memory doesn't continuously grow during async decoding - # """ - # force_cleanup() - # baseline_gpu = gpu_monitor.get_used_memory_mb() - # baseline_pool_used = gpu_monitor.get_pool_used_mb() - - # decoder = nvc.CreateSampleReader( - # num_of_set=1, - # num_of_file=6, - # iGpu=0, - # ) - - # num_iterations = 50 - # memory_samples = [] - - # for i in range(num_iterations): - # frame_ids = [i % 100] * len(video_files) - - # # Async decode - # decoder.DecodeN12ToRGBAsync(video_files, frame_ids, False) - # frames = decoder.DecodeN12ToRGBAsyncGetBuffer(video_files, frame_ids, False) - - # # Deep copy and release - # tensors = [torch.as_tensor(f, device='cuda').clone() for f in frames] - # del frames, tensors - - # if (i + 1) % 10 == 0: - # mem = gpu_monitor.get_used_memory_mb() - # memory_samples.append(mem) - # print(f"Iteration {i+1}: GPU memory = {mem:.1f} MB") - - # # Check memory stability - # mid = len(memory_samples) // 2 - # first_half_avg = sum(memory_samples[:mid]) / mid if mid > 0 else 0 - # second_half_avg = sum(memory_samples[mid:]) / (len(memory_samples) - mid) - # growth = second_half_avg - first_half_avg - - # print(f"\nMemory growth: {growth:.1f} MB") - - # del decoder - # force_cleanup() - - # pool_used_delta = gpu_monitor.get_pool_used_mb() - baseline_pool_used - # final_gpu = gpu_monitor.get_used_memory_mb() - # is_ok, delta = measure_memory_delta(baseline_gpu, final_gpu, self.GPU_TOLERANCE_MB) - - # print( - # f"Final GPU memory: {final_gpu:.1f} MB (delta: {delta:.1f} MB), pool used delta: {pool_used_delta:.1f} MB" - # ) - - # assert growth < self.GPU_TOLERANCE_MB, f"Memory growing during async loop! Growth: {growth:.1f} MB" - # assert pool_used_delta <= 0, ( - # f"CUDA pool leak: {pool_used_delta:.1f} MB more actively allocated after del than before" - # ) - # assert is_ok, f"GPU memory leak after del! Delta: {delta:.1f} MB" - - # TODO: Re-enable this test once a more scientific measurement methodology is in place. - # - # Why this test is currently disabled: - # The current approach evaluates GPU memory leaks by sampling the device's - # used-memory counter (via pynvml) before and after running the scenario, and - # asserting on the delta. That signal mixes together several things that are - # not "a leak in our decoder": - # 1. pynvml/NVML reports device-global used memory -- it includes anything - # the CUDA primary context and driver retain, not just allocations owned - # by our decoder object. - # 2. The CUDA primary context's lifetime is governed by driver-level - # reference counting (cuDevicePrimaryCtxRetain/Release), so context-level - # memory is not guaranteed to be returned on a single destroy cycle. - # 3. PyTorch's caching allocator does not promptly return freed blocks to - # the OS/driver -- see "CUDA semantics: Memory management" in the - # PyTorch docs; torch.cuda.empty_cache() is best-effort, not a guarantee. - # 4. NVDEC has its own driver-level caches that can persist across - # create/destroy cycles in our code. - # The combined effect is that this test can both false-positive (driver/ - # allocator retention is reported as a leak) and false-negative (a real leak - # gets masked by allocator reuse). - # - # A more rigorous replacement should isolate decoder-owned allocations from - # context/driver/allocator retention -- e.g. running the scenario in a fresh - # subprocess and comparing post-teardown memory, instrumenting the C++ - # allocator directly, or using CUDA memory tracking tooling (compute-sanitizer - # leak-check). - # def test_07_repeated_create_destroy_memory_stable(self, gpu_monitor, video_files): - # """ - # Test 7: Repeated create/destroy cycle - no cumulative memory leak. - - # Scenario: Loop M times (create decoder → decode → del decoder) - # Verify: Memory returns to baseline after each cycle, no accumulation - # """ - - # MAX_LEAK_PER_CYCLE_MB = 1.0 - - # force_cleanup() - # baseline_gpu = gpu_monitor.get_used_memory_mb() - - # num_cycles = 50 # Increased from 10 to better detect memory leaks - # memory_after_cycles = [] - - # for cycle in range(num_cycles): - # # Create decoder - # decoder = nvc.CreateSampleReader( - # num_of_set=1, - # num_of_file=6, - # iGpu=0, - # ) - - # # Decode several frames - # for _ in range(5): - # frame_ids = [0] * len(video_files) - # frames = decoder.DecodeN12ToRGB(video_files, frame_ids, False) - # tensors = [torch.as_tensor(f, device='cuda').clone() for f in frames] - # del frames, tensors - - # # Delete decoder - # del decoder - # force_cleanup() - - # mem = gpu_monitor.get_used_memory_mb() - # memory_after_cycles.append(mem) - - # # Print every 10 cycles to reduce output noise - # if (cycle + 1) % 10 == 0 or cycle == 0: - # print( - # f"Cycle {cycle + 1}: GPU memory after cleanup = {mem:.1f} MB " - # f"(delta from baseline: {mem - baseline_gpu:.1f} MB)" - # ) - - # # Check no cumulative leak - # final_gpu = memory_after_cycles[-1] - # is_ok, delta = measure_memory_delta(baseline_gpu, final_gpu, self.GPU_TOLERANCE_MB) - - # # Also check that memory isn't growing across cycles - # growth = memory_after_cycles[-1] - memory_after_cycles[0] - - # # Calculate average growth per cycle to detect linear leak - # avg_growth_per_cycle = growth / (num_cycles - 1) if num_cycles > 1 else 0 - - # print(f"\n=== Memory Leak Analysis ===") - # print(f"Total cycles: {num_cycles}") - # print(f"First cycle memory: {memory_after_cycles[0]:.1f} MB") - # print(f"Final cycle memory: {memory_after_cycles[-1]:.1f} MB") - # print(f"Cumulative growth: {growth:.1f} MB") - # print(f"Average growth per cycle: {avg_growth_per_cycle:.2f} MB") - # print(f"Max allowed leak per cycle: {MAX_LEAK_PER_CYCLE_MB:.2f} MB") - # print(f"Final delta from baseline: {delta:.1f} MB") - - # # For repeated cycle tests, only check per-cycle leak rate - # # (cumulative growth is just: num_cycles × per_cycle_rate, so checking both is redundant) - # # - # # Note: A small per-cycle leak (e.g., 0.78 MB) may be due to: - # # - CUDA/NVDEC driver-level caching - # # - Primary context reference counting overhead - # # This is acceptable as long as the rate is bounded. - # assert avg_growth_per_cycle <= MAX_LEAK_PER_CYCLE_MB, ( - # f"Memory leak detected! Average {avg_growth_per_cycle:.2f} MB per cycle " - # f"(threshold: {MAX_LEAK_PER_CYCLE_MB:.2f} MB). " - # f"Total leak: {growth:.1f} MB over {num_cycles} cycles." - # ) - - def test_09_del_with_pending_async_task_no_deadlock(self, gpu_monitor, video_files): - """ - Test 9: Delete decoder while async task is pending - should not deadlock. - - Scenario: Call DecodeN12ToRGBAsync() then immediately del decoder - Verify: No deadlock, resources properly released - """ - force_cleanup() - - decoder = nvc.CreateSampleReader( - num_of_set=1, - num_of_file=6, - iGpu=0, - ) - - frame_ids = [0] * len(video_files) - - # Start async decode but don't get result - decoder.DecodeN12ToRGBAsync(video_files, frame_ids, False) - - # Immediately delete - this should not deadlock - # (destructor should wait for async task to complete) - start_time = time.time() - del decoder - elapsed = time.time() - start_time - - print(f"\nDestructor completed in {elapsed:.2f} seconds") - - # Should complete within reasonable time (not deadlock) - assert elapsed < 30.0, f"Destructor took too long ({elapsed:.2f}s), possible deadlock!" - - force_cleanup() - - # NOTE: post-del GPU/pool memory assertions are omitted here for the same - # reason test_03 is disabled: the first async decode on some environments - # leaves a variable amount of CUDA pool allocations (USED_MEM_CURRENT) that - # persist after destruction, and no environment-independent threshold can be - # justified. The primary goal of this test is the deadlock check above. - - -# ============================================================================ -# P1 Tests - Important Functionality -# ============================================================================ - - -class TestP1ExplicitResourceRelease: - """ - P1 Priority Tests - Explicit resource release APIs and exception handling. - """ - - GPU_TOLERANCE_MB = 50.0 - - def test_04_release_device_memory_effective(self, gpu_monitor, video_files): - """ - Test 4: release_device_memory() effectively releases GPU memory. - - Scenario: Create decoder → decode → call release_device_memory() - Verify: GPU memory significantly decreases after call - """ - force_cleanup() - baseline_gpu = gpu_monitor.get_used_memory_mb() - - decoder = nvc.CreateSampleReader( - num_of_set=1, - num_of_file=6, - iGpu=0, - ) - - # Decode to allocate GPU memory - frame_ids = [0] * len(video_files) - frames = decoder.DecodeN12ToRGB(video_files, frame_ids, False) - tensors = [torch.as_tensor(f, device='cuda').clone() for f in frames] - del frames, tensors - - after_decode_gpu = gpu_monitor.get_used_memory_mb() - gpu_used_by_decoder = after_decode_gpu - baseline_gpu - print( - f"\nGPU memory after decode: {after_decode_gpu:.1f} MB " - f"(decoder using: {gpu_used_by_decoder:.1f} MB)" - ) - - # Release device memory - decoder.release_device_memory() - torch.cuda.synchronize() - time.sleep(0.2) - - after_release_gpu = gpu_monitor.get_used_memory_mb() - released = after_decode_gpu - after_release_gpu - - print( - f"GPU memory after release_device_memory(): {after_release_gpu:.1f} MB " - f"(released: {released:.1f} MB)" - ) - - # Memory should decrease (at least some should be released) - # Note: Not all memory may be released due to internal state - assert released > 0 or gpu_used_by_decoder < 50, ( - f"release_device_memory() didn't release any memory! " - f"Before: {after_decode_gpu:.1f} MB, After: {after_release_gpu:.1f} MB" - ) - - del decoder - force_cleanup() - - def test_05_clear_all_readers_effective(self, gpu_monitor, video_files): - """ - Test 5: clearAllReaders() effectively releases resources. - - Scenario: Create decoder → decode multiple different videos → call clearAllReaders() - Verify: GPU memory decreases after clearing readers - """ - force_cleanup() - baseline_gpu = gpu_monitor.get_used_memory_mb() - - decoder = nvc.CreateSampleReader( - num_of_set=3, # Multiple sets to hold more reader state - num_of_file=6, - iGpu=0, - ) - - # Decode multiple times with different frame IDs to create reader state - for frame_offset in range(3): - frame_ids = [frame_offset * 10] * len(video_files) - frames = decoder.DecodeN12ToRGB(video_files, frame_ids, False) - tensors = [torch.as_tensor(f, device='cuda').clone() for f in frames] - del frames, tensors - - after_decode_gpu = gpu_monitor.get_used_memory_mb() - print( - f"\nGPU memory after multiple decodes: {after_decode_gpu:.1f} MB " - f"(delta: {after_decode_gpu - baseline_gpu:.1f} MB)" - ) - - # Clear all readers - decoder.clearAllReaders() - torch.cuda.synchronize() - time.sleep(0.2) - - after_clear_gpu = gpu_monitor.get_used_memory_mb() - released = after_decode_gpu - after_clear_gpu - - print( - f"GPU memory after clearAllReaders(): {after_clear_gpu:.1f} MB " f"(released: {released:.1f} MB)" - ) - - del decoder - force_cleanup() - - final_gpu = gpu_monitor.get_used_memory_mb() - is_ok, delta = measure_memory_delta(baseline_gpu, final_gpu, self.GPU_TOLERANCE_MB) - - print(f"Final GPU memory: {final_gpu:.1f} MB (delta: {delta:.1f} MB)") - assert is_ok, f"GPU memory leak after clearAllReaders + del! Delta: {delta:.1f} MB" - - def test_06_release_memory_then_continue_decode(self, gpu_monitor, video_files): - """ - Test 6: Decoder remains usable after release_device_memory(). - - Scenario: Create decoder → decode → release_device_memory() → decode again - Verify: Decoder works correctly after memory release - - Note: This test validates that after calling release_device_memory(), - the decoder can still decode frames correctly. The GPU memory pool - should be re-allocated automatically on the next decode operation. - """ - force_cleanup() - - decoder = nvc.CreateSampleReader( - num_of_set=1, - num_of_file=6, - iGpu=0, - ) - - frame_ids = [0] * len(video_files) - - # First decode - frames_1 = decoder.DecodeN12ToRGB(video_files, frame_ids, False) - tensor_1 = torch.as_tensor(frames_1[0], device='cuda').clone() - del frames_1 - - print(f"\nFirst decode successful, frame shape: {tensor_1.shape}") - - # Release memory - decoder.release_device_memory() - torch.cuda.synchronize() - - # Second decode - should still work (but currently returns empty frames!) - frames_2 = decoder.DecodeN12ToRGB(video_files, frame_ids, False) - tensor_2 = torch.as_tensor(frames_2[0], device='cuda').clone() - del frames_2 - - print(f"Second decode successful, frame shape: {tensor_2.shape}") - - # Verify frames are identical (same content) - assert tensor_2.shape[0] > 0, f"Frame has zero height! Shape: {tensor_2.shape}" - assert ( - tensor_1.shape == tensor_2.shape - ), f"Frame shapes don't match! {tensor_1.shape} vs {tensor_2.shape}" - max_diff = (tensor_1.float() - tensor_2.float()).abs().max().item() - print(f"Max pixel difference between frames: {max_diff}") - assert max_diff < 1.0, f"Frames differ too much! Max diff: {max_diff}" - - del decoder, tensor_1, tensor_2 - force_cleanup() - - def test_10_multiple_async_without_getbuffer_no_leak(self, gpu_monitor, video_files): - """ - Test 10: Multiple Async calls without GetBuffer - no memory leak. - - Scenario: Call Async multiple times (overwriting previous) → del decoder - Verify: Overwritten results don't cause memory leak - """ - force_cleanup() - baseline_gpu = gpu_monitor.get_used_memory_mb() - - decoder = nvc.CreateSampleReader( - num_of_set=1, - num_of_file=6, - iGpu=0, - ) - - # Call Async multiple times without GetBuffer - # Each call should discard previous pending result - for i in range(10): - frame_ids = [i * 5] * len(video_files) - decoder.DecodeN12ToRGBAsync(video_files, frame_ids, False) - # Don't call GetBuffer - let it be overwritten - - # Only get the last one - last_frame_ids = [45] * len(video_files) - frames = decoder.DecodeN12ToRGBAsyncGetBuffer(video_files, last_frame_ids, False) - assert frames is not None and len(frames) == len(video_files) - - tensors = [torch.as_tensor(f, device='cuda').clone() for f in frames] - del frames, tensors - - del decoder - force_cleanup() - - final_gpu = gpu_monitor.get_used_memory_mb() - is_ok, delta = measure_memory_delta(baseline_gpu, final_gpu, self.GPU_TOLERANCE_MB) - - print(f"\nGPU memory delta: {delta:.1f} MB (tolerance: {self.GPU_TOLERANCE_MB} MB)") - - assert is_ok, f"GPU memory leak with multiple Async without GetBuffer! " f"Delta: {delta:.1f} MB" - - def test_08_release_decoder_effective(self, gpu_monitor, video_files): - """ - Test 8: release_decoder() effectively releases GPU memory. - - Scenario: Create decoder → decode → call release_decoder() - Verify: GPU memory significantly decreases after call - - Note: release_decoder() is more thorough than release_device_memory(): - - release_device_memory(): only releases GPU memory pool - - release_decoder(): deletes all readers (NvDecoder, FFmpegDemuxer, etc.) - """ - force_cleanup() - baseline_gpu = gpu_monitor.get_used_memory_mb() - - decoder = nvc.CreateSampleReader( - num_of_set=1, - num_of_file=6, - iGpu=0, - ) - - # Decode multiple frames to allocate more GPU memory - for frame_offset in range(5): - frame_ids = [frame_offset * 10] * len(video_files) - frames = decoder.DecodeN12ToRGB(video_files, frame_ids, False) - tensors = [torch.as_tensor(f, device='cuda').clone() for f in frames] - del frames, tensors - - after_decode_gpu = gpu_monitor.get_used_memory_mb() - gpu_used_by_decoder = after_decode_gpu - baseline_gpu - print( - f"\nGPU memory after decode: {after_decode_gpu:.1f} MB " - f"(decoder using: {gpu_used_by_decoder:.1f} MB)" - ) - - # Release decoder (more thorough than release_device_memory) - decoder.release_decoder() - torch.cuda.synchronize() - time.sleep(0.2) - - after_release_gpu = gpu_monitor.get_used_memory_mb() - released = after_decode_gpu - after_release_gpu - - print( - f"GPU memory after release_decoder(): {after_release_gpu:.1f} MB " - f"(released: {released:.1f} MB)" - ) - - # Memory should decrease significantly - assert released > 0 or gpu_used_by_decoder < 50, ( - f"release_decoder() didn't release any memory! " - f"Before: {after_decode_gpu:.1f} MB, After: {after_release_gpu:.1f} MB" - ) - - del decoder - force_cleanup() - - def test_09_release_decoder_then_continue_decode(self, gpu_monitor, video_files): - """ - Test 9: Decoder remains usable after release_decoder(). - - Scenario: Create decoder → decode → release_decoder() → decode again - Verify: Decoder works correctly after releasing all readers - - Note: After release_decoder(), new readers will be created automatically - on the next decode operation. - """ - force_cleanup() - - decoder = nvc.CreateSampleReader( - num_of_set=1, - num_of_file=6, - iGpu=0, - ) - - frame_ids = [0] * len(video_files) - - # First decode - frames_1 = decoder.DecodeN12ToRGB(video_files, frame_ids, False) - tensor_1 = torch.as_tensor(frames_1[0], device='cuda').clone() - shape_1 = tensor_1.shape - del frames_1 - - print(f"\nFirst decode successful, frame shape: {shape_1}") - - # Release decoder (delete all readers) - decoder.release_decoder() - torch.cuda.synchronize() - - # Second decode - should work (new readers will be created) - frames_2 = decoder.DecodeN12ToRGB(video_files, frame_ids, False) - tensor_2 = torch.as_tensor(frames_2[0], device='cuda').clone() - shape_2 = tensor_2.shape - del frames_2 - - print(f"Second decode after release_decoder(), frame shape: {shape_2}") - - # Verify frame shapes match - assert shape_1 == shape_2, ( - f"Frame shapes don't match after release_decoder()! " f"Before: {shape_1}, After: {shape_2}" - ) - - del decoder - force_cleanup() - - # TODO: Re-enable this test once a more scientific measurement methodology is in place. - # - # Why this test is currently disabled: - # Same root cause as test_07_repeated_create_destroy_memory_stable above: - # we are diffing pynvml's device-global used-memory across cycles, but that - # counter also reflects CUDA primary context retention, PyTorch caching- - # allocator behavior (torch.cuda.empty_cache() is best-effort -- see PyTorch - # "CUDA semantics: Memory management"), and NVDEC driver-level caching. - # The per-cycle "growth" produced by this signal is therefore not a - # trustworthy leak metric for the decoder specifically. - # - # A proper test for repeated release_decoder() cycles needs measurement that - # isolates decoder-owned allocations from context/driver/allocator retention -- - # e.g. subprocess isolation, instrumenting the C++ allocator, or CUDA - # leak-check tooling (compute-sanitizer). - # def test_10_repeated_release_decoder_no_leak(self, gpu_monitor, video_files): - # """ - # Test 10: Repeated release_decoder() cycles don't cause memory leak. - - # Scenario: Repeatedly (decode → release_decoder()) in a loop - # Verify: No cumulative memory growth - # """ - # MAX_LEAK_PER_CYCLE_MB = 1.0 - - # force_cleanup() - # baseline_gpu = gpu_monitor.get_used_memory_mb() - - # decoder = nvc.CreateSampleReader( - # num_of_set=1, - # num_of_file=6, - # iGpu=0, - # ) - - # num_cycles = 20 - # memory_after_cycles = [] - - # print(f"\n=== Running {num_cycles} decode-release_decoder cycles ===") - - # for cycle in range(num_cycles): - # # Decode - # frame_ids = [cycle % 50] * len(video_files) - # frames = decoder.DecodeN12ToRGB(video_files, frame_ids, False) - # tensors = [torch.as_tensor(f, device='cuda').clone() for f in frames] - # del frames, tensors - - # # Release decoder - # decoder.release_decoder() - # torch.cuda.synchronize() - - # current_gpu = gpu_monitor.get_used_memory_mb() - # memory_after_cycles.append(current_gpu) - - # if (cycle + 1) % 5 == 0: - # print( - # f"Cycle {cycle + 1}: {current_gpu:.1f} MB " - # f"(delta from baseline: {current_gpu - baseline_gpu:.1f} MB)" - # ) - - # # Analyze memory growth - # growth = memory_after_cycles[-1] - memory_after_cycles[0] - # avg_growth_per_cycle = growth / (num_cycles - 1) if num_cycles > 1 else 0 - - # print(f"\n=== Memory Analysis ===") - # print(f"First cycle: {memory_after_cycles[0]:.1f} MB") - # print(f"Last cycle: {memory_after_cycles[-1]:.1f} MB") - # print(f"Growth: {growth:.1f} MB") - # print(f"Avg growth per cycle: {avg_growth_per_cycle:.2f} MB") - - # # Should not have significant per-cycle leak - # assert avg_growth_per_cycle < MAX_LEAK_PER_CYCLE_MB, ( - # f"Memory leak in release_decoder() cycles! " f"Avg growth: {avg_growth_per_cycle:.2f} MB/cycle" - # ) - - # del decoder - # force_cleanup() - - def test_11_release_decoder_with_pending_async(self, gpu_monitor, video_files): - """ - Test 11: release_decoder() properly handles pending async task. - - Scenario: Start async decode → immediately call release_decoder() - Verify: No crash, proper cleanup - """ - force_cleanup() - baseline_gpu = gpu_monitor.get_used_memory_mb() - - decoder = nvc.CreateSampleReader( - num_of_set=1, - num_of_file=6, - iGpu=0, - ) - - # Start async decode - frame_ids = [0] * len(video_files) - decoder.DecodeN12ToRGBAsync(video_files, frame_ids, False) - - # Immediately release decoder (should wait for async to complete) - decoder.release_decoder() - torch.cuda.synchronize() - - # Try to decode again (should create new readers) - frames = decoder.DecodeN12ToRGB(video_files, frame_ids, False) - assert frames is not None and len(frames) == len(video_files) - - tensor = torch.as_tensor(frames[0], device='cuda') - assert tensor.numel() > 0, "Got empty frame after release_decoder with pending async!" - - del frames - del decoder - force_cleanup() - - final_gpu = gpu_monitor.get_used_memory_mb() - is_ok, delta = measure_memory_delta(baseline_gpu, final_gpu, self.GPU_TOLERANCE_MB) - - print(f"\nGPU memory delta: {delta:.1f} MB") - assert is_ok, f"Memory leak with release_decoder + pending async! Delta: {delta:.1f} MB" - - def test_12_exception_then_resource_release(self, gpu_monitor, video_files): - """ - Test 12: Resources properly released after exception. - - Scenario: Trigger exception with invalid file → catch exception → del decoder - Verify: Exception doesn't prevent resource release - """ - force_cleanup() - baseline_gpu = gpu_monitor.get_used_memory_mb() - - decoder = nvc.CreateSampleReader( - num_of_set=1, - num_of_file=6, - iGpu=0, - ) - - # First, do some successful decoding - frame_ids = [0] * len(video_files) - frames = decoder.DecodeN12ToRGB(video_files, frame_ids, False) - tensors = [torch.as_tensor(f, device='cuda').clone() for f in frames] - del frames, tensors - - after_decode_gpu = gpu_monitor.get_used_memory_mb() - print(f"\nGPU memory after successful decode: {after_decode_gpu:.1f} MB") - - # Now trigger an exception - invalid_files = ["/nonexistent/invalid/path.mp4"] - invalid_frame_ids = [0] - - with pytest.raises(RuntimeError): - decoder.DecodeN12ToRGB(invalid_files, invalid_frame_ids, False) - - print("Exception caught as expected") - - # Delete decoder - should still release resources - del decoder - force_cleanup() - - final_gpu = gpu_monitor.get_used_memory_mb() - is_ok, delta = measure_memory_delta(baseline_gpu, final_gpu, self.GPU_TOLERANCE_MB) - - print(f"GPU memory after exception + del: {final_gpu:.1f} MB (delta: {delta:.1f} MB)") - - assert is_ok, f"GPU memory leak after exception! Delta: {delta:.1f} MB" - - -# ============================================================================ -# CPU Memory Tests -# ============================================================================ - - -class TestCPUMemoryRelease: - """Tests for CPU memory resource release.""" - - CPU_TOLERANCE_MB = 20.0 - - def test_cpu_memory_stable_sync_loop(self, cpu_monitor, video_files): - """Test CPU memory stability during sync decode loop.""" - force_cleanup() - baseline_cpu = cpu_monitor.get_rss_mb() - - decoder = nvc.CreateSampleReader( - num_of_set=1, - num_of_file=6, - iGpu=0, - ) - - memory_samples = [] - for i in range(30): - frame_ids = [i % 100] * len(video_files) - frames = decoder.DecodeN12ToRGB(video_files, frame_ids, False) - tensors = [torch.as_tensor(f, device='cuda').clone() for f in frames] - del frames, tensors - - if (i + 1) % 10 == 0: - gc.collect() - mem = cpu_monitor.get_rss_mb() - memory_samples.append(mem) - print(f"Iteration {i+1}: CPU RSS = {mem:.1f} MB") - - del decoder - force_cleanup() - - final_cpu = cpu_monitor.get_rss_mb() - delta = final_cpu - baseline_cpu - - print(f"\nCPU RSS delta: {delta:.1f} MB (tolerance: {self.CPU_TOLERANCE_MB} MB)") - - assert delta < self.CPU_TOLERANCE_MB, f"CPU memory leak! Delta: {delta:.1f} MB" - - def test_cpu_memory_stable_async_loop(self, cpu_monitor, video_files): - """Test CPU memory stability during async decode loop.""" - force_cleanup() - baseline_cpu = cpu_monitor.get_rss_mb() - - decoder = nvc.CreateSampleReader( - num_of_set=1, - num_of_file=6, - iGpu=0, - ) - - for i in range(30): - frame_ids = [i % 100] * len(video_files) - decoder.DecodeN12ToRGBAsync(video_files, frame_ids, False) - frames = decoder.DecodeN12ToRGBAsyncGetBuffer(video_files, frame_ids, False) - tensors = [torch.as_tensor(f, device='cuda').clone() for f in frames] - del frames, tensors - - del decoder - force_cleanup() - - final_cpu = cpu_monitor.get_rss_mb() - delta = final_cpu - baseline_cpu - - print(f"\nCPU RSS delta: {delta:.1f} MB (tolerance: {self.CPU_TOLERANCE_MB} MB)") - - assert delta < self.CPU_TOLERANCE_MB, f"CPU memory leak! Delta: {delta:.1f} MB" - - -if __name__ == "__main__": - sys.exit(pytest.main([__file__, "-v"])) diff --git a/packages/on_demand_video_decoder/tests/utils.py b/packages/on_demand_video_decoder/tests/utils.py index 4a3223c9..dc2115c4 100644 --- a/packages/on_demand_video_decoder/tests/utils.py +++ b/packages/on_demand_video_decoder/tests/utils.py @@ -18,103 +18,11 @@ import random import time -import numpy as np import psutil import pynvml import torch -def is_diff_in_range(to_comp_1, to_comp_2, tolerance): - # support input as YUV tuple or single channel - if isinstance(to_comp_1, tuple) and isinstance(to_comp_2, tuple): - results = [is_diff_in_range(c1, c2, tolerance) for c1, c2 in zip(to_comp_1, to_comp_2)] - in_range = all(r[0] for r in results) - max_diff = max(r[1] for r in results) - count_in_range = sum(r[2] for r in results) - return in_range, max_diff, count_in_range - - # support single channel - if torch.is_tensor(to_comp_1): - to_comp_1 = to_comp_1.cpu() - if torch.is_tensor(to_comp_2): - to_comp_2 = to_comp_2.cpu() - to_comp_1 = np.array(to_comp_1) - to_comp_2 = np.array(to_comp_2) - use_int = issubclass(to_comp_1.dtype.type, np.integer) and issubclass(to_comp_2.dtype.type, np.integer) - type_to_use = np.int64 if use_int else np.float64 - diffs = np.abs(np.array(to_comp_1).astype(type_to_use) - np.array(to_comp_2).astype(type_to_use)) - max_diff = diffs.max() - in_range = max_diff <= tolerance - count_in_range = np.sum(diffs > tolerance) - return in_range, max_diff, count_in_range - - -def diff(gop_decoded, opencv_decoded, file_names, frames, num_files, diff_tolerance=21): - for file_name, frame, i in zip(file_names, frames, range(num_files)): - same_with_opencv_dec, max_diff, count_in_range = is_diff_in_range( - gop_decoded[i], opencv_decoded[i], diff_tolerance - ) - print(file_name, " frame_id: ", frame, " pass: ", same_with_opencv_dec, " max_diff: ", max_diff) - if not same_with_opencv_dec: - print(f"Error: {file_name} {frame} is not same with opencv_decoded") - return max_diff - return 0 - - -def gop_decode_bgr(nv_gop_dec, file_path_list, frame_id_list): - try: - decoded_frames = nv_gop_dec.DecodeN12ToRGB(file_path_list, frame_id_list, True) - torch.cuda.nvtx.range_push("unsqueeze_tensor_list") - res = [torch.unsqueeze(torch.as_tensor(df), 0) for df in decoded_frames] - torch.cuda.nvtx.range_pop() - return res - except Exception as e: - print(f"Error: {e}") - return None - - -def gop_decode_bgr_with_fast_init(nv_gop_dec, file_path_list, frame_id_list, fast_stream_infos): - try: - decoded_frames = nv_gop_dec.DecodeN12ToRGB( - file_path_list, frame_id_list, as_bgr=True, fastStreamInfos=fast_stream_infos - ) - torch.cuda.nvtx.range_push("unsqueeze_tensor_list") - res = [torch.unsqueeze(torch.as_tensor(df), 0) for df in decoded_frames] - torch.cuda.nvtx.range_pop() - return res - except Exception as e: - print(f"Error: {e}") - return None - - -def gop_decode_bgr_ddseparate_with_fast_init( - nv_gop_dec1, nv_gop_dec2, file_path_list, frame_id_list, fast_stream_infos -): - try: - gop_list = nv_gop_dec1.GetGOPList(file_path_list, frame_id_list, fastStreamInfos=fast_stream_infos) - gop_data_list = [data for data, _, _ in gop_list] - decoded_frames = nv_gop_dec2.DecodeFromGOPListRGB( - gop_data_list, file_path_list, frame_id_list, as_bgr=True - ) - res = [torch.unsqueeze(torch.as_tensor(df), 0) for df in decoded_frames] - return res - except Exception as e: - print(f"Error: {e}") - return None - - -def gop_decode_bgr_ddseparate_from_multi_packets(nv_gop_dec, file_path_list, frame_id_list, packets_list): - try: - decoded_frames = nv_gop_dec.DecodeFromGOPListRGB( - packets_list, file_path_list, frame_id_list, as_bgr=True - ) - res = [torch.unsqueeze(torch.as_tensor(df), 0) for df in decoded_frames] - return res - except Exception as e: - print(f"Error: {e}") - return None - - def get_data_dir(): """ Return absolute path to the test video data directory.