diff --git a/applications/gstreamer/gst_video_recorder/CMakeLists.txt b/applications/gstreamer/gst_video_recorder/CMakeLists.txt index 7fddc59c5e..e4f2c7fb9a 100644 --- a/applications/gstreamer/gst_video_recorder/CMakeLists.txt +++ b/applications/gstreamer/gst_video_recorder/CMakeLists.txt @@ -13,73 +13,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -project(gst_video_recorder CXX) - -find_package(holoscan REQUIRED CONFIG - PATHS "/opt/nvidia/holoscan" "/workspace/holoscan-sdk/install") - -add_executable(gst-video-recorder - ../common/pattern_generator.cpp - gst_video_recorder.cpp -) - -target_include_directories(gst-video-recorder - PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR}/../common -) - -target_link_libraries(gst-video-recorder - PRIVATE - holoscan::core - holoscan::ops::gstreamer_bridge - holoscan::ops::v4l2 - holoscan::ops::format_converter -) - -# Install target -install(TARGETS gst-video-recorder DESTINATION bin/gst_video_recorder) - - -if(BUILD_TESTING) - # Set and create the recording directory - set(RECORDING_DIR ${CMAKE_CURRENT_BINARY_DIR}/recording_output) - file(MAKE_DIRECTORY ${RECORDING_DIR}) - - # Set the output file - set(OUTPUT_FILE ${RECORDING_DIR}/test_output.mp4) - message(STATUS "Output test file: ${OUTPUT_FILE}") - - # Test 1: check if the pipeline runs successfully - add_test( - NAME gst_video_recorder_test - COMMAND $ --source pattern --count 10 -o ${OUTPUT_FILE} - WORKING_DIRECTORY ${CMAKE_BINARY_DIR} - ) - - # Set the test passing if the pipeline runs successfully - set_tests_properties(gst_video_recorder_test PROPERTIES - PASS_REGULAR_EXPRESSION "EOS processed, pipeline finished cleanly" - ) - - # Test 2: check if the output file was created - add_test( - NAME gst_video_recorder_output_file_test - COMMAND bash -c "test -f ${OUTPUT_FILE}" - ) - - # Make the file check test depend on the pipeline test - set_tests_properties(gst_video_recorder_output_file_test PROPERTIES - DEPENDS gst_video_recorder_test - ) - - # Test 3: check if the output file is a valid MPEG video file - add_test( - NAME gst_video_recorder_valid_mpeg_test - COMMAND bash -c "ffprobe -v error ${OUTPUT_FILE}" - ) +if(HOLOHUB_BUILD_CPP) + add_subdirectory(cpp) +endif() - # Make the validation test depend on the file check test - set_tests_properties(gst_video_recorder_valid_mpeg_test PROPERTIES - DEPENDS gst_video_recorder_output_file_test - ) +if(HOLOHUB_BUILD_PYTHON) + add_subdirectory(python) endif() diff --git a/applications/gstreamer/gst_video_recorder/README.md b/applications/gstreamer/gst_video_recorder/README.md index 0954fa13dd..3446643f18 100644 --- a/applications/gstreamer/gst_video_recorder/README.md +++ b/applications/gstreamer/gst_video_recorder/README.md @@ -2,6 +2,10 @@ A Holoscan application that demonstrates video recording using the GStreamer encoding pipeline. +This application is available in both **C++** and **Python** implementations. +Both versions expose the **same command-line interface** and behavior. +The only difference when building or launching the application is the `--language` flag: use `--language cpp` for the C++ version or `--language python` for the Python version. + ![GStreamer Video Recorder Pipeline](docs/pipeline_diagram.png) *Fig. 1: Application architecture showing the integration of Holoscan operators with GStreamer's encoding pipeline* @@ -42,11 +46,19 @@ For more information about this application, refer to: ## Quick Start -To run the application with the default settings, run one of the following commands: +To run the application with the default settings, choose the implementation with `--language cpp` or `--language python` and then use the same runtime arguments. + +Examples with the C++ implementation: + +| Using the V4L2 Camera | Generating Test Patterns | +| --- | --- | +| `./holohub run gst_video_recorder v4l2 --language cpp` | `./holohub run gst_video_recorder pattern --language cpp` | + +Examples with the Python implementation: | Using the V4L2 Camera | Generating Test Patterns | | --- | --- | -| `./holohub run gst_video_recorder v4l2` | `./holohub run gst_video_recorder pattern` | +| `./holohub run gst_video_recorder v4l2 --language python` | `./holohub run gst_video_recorder pattern --language python` | These commands build and run the customized container for this application with all the dependencies installed (defined by `Dockerfile`), and then build and start the application using the default settings. The output video will be saved in the build directory as `output.mp4`. @@ -66,14 +78,20 @@ The `install_deps.sh` script installs: - GStreamer development libraries - All necessary GStreamer plugins for encoding -Choose one of the following options to build the application: +Choose one of the following options to build the application. +Select the implementation with `--language cpp` or `--language python`: | Containerized Build (Recommended) | Local Build | | --- | --- | -| Install the application: | Install dependencies, from the `gst_video_recorder` directory: | -| `./holohub build gst_video_recorder` | `./install_deps.sh` | -| | Then build locally: | -| | `./holohub build --local gst_video_recorder` | +| No manual dependency installation is required. Dependencies are installed in the application container image. | Install dependencies on the host, from the `gst_video_recorder` directory: | +| `./holohub build gst_video_recorder --language cpp` | `./install_deps.sh` | +| `./holohub build gst_video_recorder --language python` | Then build locally: | +| | `./holohub build --local gst_video_recorder --language cpp` | +| | `./holohub build --local gst_video_recorder --language python` | + +For the Python implementation, the pattern source uses device storage by default and requires CuPy. +The containerized build uses the Holoscan base image, which already provides CuPy. +For local builds, install the CuPy wheel matching your CUDA major version before running the Python application or tests, for example `python3 -m pip install cupy-cuda12x` or `python3 -m pip install cupy-cuda13x`. ### Usage Reference @@ -87,7 +105,7 @@ Reference for running `gst_video_recorder` that includes: The recommended way to run the application is through the `holohub` launcher: ```bash -./holohub run gst_video_recorder --run-args="[OPTIONS]" +./holohub run gst_video_recorder --language --run-args="[OPTIONS]" ``` Alternatively, if you know the binary location, you can run it directly: @@ -138,6 +156,9 @@ The command line options include the following main categories: | `--pattern ` | Pattern type: `0` = animated gradient, `1` = animated checkerboard, `2` = color bars (SMPTE style) | `0` | | `--storage ` | Memory storage type: `0` = host memory, `1` = device or CUDA memory | `1` | +The Python implementation requires both NumPy and CuPy. +CuPy is required because the Python implementation uses device storage in the default path, matching the C++ defaults. + ## Testing The application includes integration tests to validate the pipeline execution and recording file creation. diff --git a/applications/gstreamer/gst_video_recorder/cpp/CMakeLists.txt b/applications/gstreamer/gst_video_recorder/cpp/CMakeLists.txt new file mode 100644 index 0000000000..42603c366d --- /dev/null +++ b/applications/gstreamer/gst_video_recorder/cpp/CMakeLists.txt @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# 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. + +project(gst_video_recorder CXX) + +find_package(holoscan REQUIRED CONFIG + PATHS "/opt/nvidia/holoscan" "/workspace/holoscan-sdk/install") + +add_executable(gst-video-recorder + ../../common/pattern_generator.cpp + gst_video_recorder.cpp +) + +target_include_directories(gst-video-recorder + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../../common +) + +target_link_libraries(gst-video-recorder + PRIVATE + holoscan::core + holoscan::ops::gstreamer_bridge + holoscan::ops::v4l2 + holoscan::ops::format_converter +) + +# Install target +install(TARGETS gst-video-recorder DESTINATION bin/gst_video_recorder) + + +if(BUILD_TESTING) + # Set and create the recording directory + set(RECORDING_DIR ${CMAKE_CURRENT_BINARY_DIR}/recording_output) + file(MAKE_DIRECTORY ${RECORDING_DIR}) + + # Set the output file + set(OUTPUT_FILE ${RECORDING_DIR}/test_output.mp4) + message(STATUS "Output test file: ${OUTPUT_FILE}") + + # Test 1: check if the pipeline runs successfully + add_test( + NAME gst_video_recorder_test + COMMAND bash -c "rm -f ${OUTPUT_FILE} && $ --source pattern --count 10 -o ${OUTPUT_FILE}" + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + ) + + # Set the test passing if the pipeline runs successfully + set_tests_properties(gst_video_recorder_test PROPERTIES + PASS_REGULAR_EXPRESSION "EOS processed, pipeline finished cleanly" + ) + + # Test 2: check if the output file was created + add_test( + NAME gst_video_recorder_output_file_test + COMMAND bash -c "test -f ${OUTPUT_FILE}" + ) + + # Make the file check test depend on the pipeline test + set_tests_properties(gst_video_recorder_output_file_test PROPERTIES + DEPENDS gst_video_recorder_test + ) + + # Test 3: check if the output file is a valid MPEG video file + add_test( + NAME gst_video_recorder_valid_mpeg_test + COMMAND bash -c "ffprobe -v error ${OUTPUT_FILE}" + ) + + # Make the validation test depend on the file check test + set_tests_properties(gst_video_recorder_valid_mpeg_test PROPERTIES + DEPENDS gst_video_recorder_output_file_test + ) +endif() diff --git a/applications/gstreamer/gst_video_recorder/gst_video_recorder.cpp b/applications/gstreamer/gst_video_recorder/cpp/gst_video_recorder.cpp similarity index 99% rename from applications/gstreamer/gst_video_recorder/gst_video_recorder.cpp rename to applications/gstreamer/gst_video_recorder/cpp/gst_video_recorder.cpp index 6bdfd5da4a..b85abef360 100644 --- a/applications/gstreamer/gst_video_recorder/gst_video_recorder.cpp +++ b/applications/gstreamer/gst_video_recorder/cpp/gst_video_recorder.cpp @@ -25,8 +25,8 @@ #include #include #include -#include "pattern_generator.hpp" -#include "../common/arg_parser.hpp" +#include "../../common/pattern_generator.hpp" +#include "../../common/arg_parser.hpp" namespace { diff --git a/applications/gstreamer/gst_video_recorder/gst_video_recorder_v4l2.yaml b/applications/gstreamer/gst_video_recorder/cpp/gst_video_recorder_v4l2.yaml similarity index 100% rename from applications/gstreamer/gst_video_recorder/gst_video_recorder_v4l2.yaml rename to applications/gstreamer/gst_video_recorder/cpp/gst_video_recorder_v4l2.yaml diff --git a/applications/gstreamer/gst_video_recorder/metadata.json b/applications/gstreamer/gst_video_recorder/cpp/metadata.json similarity index 84% rename from applications/gstreamer/gst_video_recorder/metadata.json rename to applications/gstreamer/gst_video_recorder/cpp/metadata.json index aa379b2b09..267ec982bd 100644 --- a/applications/gstreamer/gst_video_recorder/metadata.json +++ b/applications/gstreamer/gst_video_recorder/cpp/metadata.json @@ -29,7 +29,7 @@ "holoscan_sdk": { "minimum_required_version": "3.8.0", "tested_versions": [ - "3.8.0" + "4.1.0" ] }, "platforms": [ @@ -44,21 +44,21 @@ "default": { "description": "Record Holoscan video streams to file using GStreamer encoding (V4L2 camera or pattern generator).", "run": { - "command": "applications/gstreamer/gst_video_recorder/gst-video-recorder", + "command": "/gst-video-recorder", "workdir": "holohub_bin" } }, "v4l2": { "description": "Record video from a V4L2 camera", "run": { - "command": "applications/gstreamer/gst_video_recorder/gst-video-recorder --source v4l2", + "command": "/gst-video-recorder --source v4l2", "workdir": "holohub_bin" } }, "pattern": { "description": "Record a pattern generator video", "run": { - "command": "applications/gstreamer/gst_video_recorder/gst-video-recorder --source pattern", + "command": "/gst-video-recorder --source pattern", "workdir": "holohub_bin" } } diff --git a/applications/gstreamer/gst_video_recorder/python/CMakeLists.txt b/applications/gstreamer/gst_video_recorder/python/CMakeLists.txt new file mode 100644 index 0000000000..9d01ba9f6a --- /dev/null +++ b/applications/gstreamer/gst_video_recorder/python/CMakeLists.txt @@ -0,0 +1,88 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, TECNALIA. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# 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. + +# Copy gst_video_recorder application file +add_custom_target(python_gst_video_recorder ALL + COMMAND ${CMAKE_COMMAND} -E copy + "${CMAKE_CURRENT_SOURCE_DIR}/gst_video_recorder.py" + ${CMAKE_CURRENT_BINARY_DIR} + DEPENDS "gst_video_recorder.py" + BYPRODUCTS "gst_video_recorder.py" +) + +# Add testing +if(BUILD_TESTING) + # To get the environment path + find_package(holoscan 1.0 REQUIRED CONFIG PATHS "/opt/nvidia/holoscan" "/workspace/holoscan-sdk/install") + + set(RECORDING_DIR ${CMAKE_CURRENT_BINARY_DIR}/recording_output) + set(OUTPUT_FILE ${RECORDING_DIR}/python_gst_video_recorder_output.mp4) + + file(MAKE_DIRECTORY ${RECORDING_DIR}) + + # Test 1: remove outputfile from previous tests + add_test( + NAME gst_video_recorder_python_cleanup_test + COMMAND ${CMAKE_COMMAND} -E rm -f ${OUTPUT_FILE} + ) + + # Test 2: check if the pipeline runs successfully + add_test( + NAME gst_video_recorder_python_test + COMMAND python3 ${CMAKE_CURRENT_SOURCE_DIR}/gst_video_recorder.py + --source pattern + --count 10 + -o ${OUTPUT_FILE} + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + ) + + set_property( + TEST gst_video_recorder_python_test + PROPERTY ENVIRONMENT + "PYTHONPATH=${GXF_LIB_DIR}/../python/lib:${CMAKE_BINARY_DIR}/python/lib" + ) + + set_tests_properties(gst_video_recorder_python_test PROPERTIES + DEPENDS gst_video_recorder_python_cleanup_test + PASS_REGULAR_EXPRESSION "EOS processed, pipeline finished cleanly" + FAIL_REGULAR_EXPRESSION "[^a-z]Error;ERROR;Failed" + ) + + # Test 3: check if the output file was created + add_test( + NAME gst_video_recorder_python_output_file_test + COMMAND bash -c "test -f ${OUTPUT_FILE}" + ) + + set_tests_properties(gst_video_recorder_python_output_file_test PROPERTIES + DEPENDS gst_video_recorder_python_test + ) + + # Test 4: check if the output file is a valid MPEG video file + add_test( + NAME gst_video_recorder_python_valid_mpeg_test + COMMAND bash -c "ffprobe -v error ${OUTPUT_FILE}" + ) + + set_tests_properties(gst_video_recorder_python_valid_mpeg_test PROPERTIES + DEPENDS gst_video_recorder_python_output_file_test + ) +endif() + +# Install application into the install/ directory for packaging +install( + FILES gst_video_recorder.py + DESTINATION bin/gst_video_recorder/python +) diff --git a/applications/gstreamer/gst_video_recorder/python/gst_video_recorder.py b/applications/gstreamer/gst_video_recorder/python/gst_video_recorder.py new file mode 100644 index 0000000000..18afa9da8f --- /dev/null +++ b/applications/gstreamer/gst_video_recorder/python/gst_video_recorder.py @@ -0,0 +1,397 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, TECNALIA. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# 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. + +from __future__ import annotations + +import argparse +import sys +from typing import Any, Dict + +import numpy as np + +try: + import cupy as cp +except ImportError as exc: + raise ImportError( + "gst_video_recorder Python implementation requires CuPy. " + "Use the containerized build or install the matching CuPy wheel " + "for your CUDA version (for example, cupy-cuda12x or cupy-cuda13x)." + ) from exc + +from holoscan.conditions import CountCondition +from holoscan.core import Application, Operator, OperatorSpec +from holoscan.operators import FormatConverterOp, V4L2VideoCaptureOp +from holoscan.resources import UnboundedAllocator + +from holohub.holoscan_gstreamer_bridge import GstVideoRecorderOp + + +def parse_pattern(value: str) -> int: + try: + pattern = int(str(value).strip()) + except ValueError as exc: + raise argparse.ArgumentTypeError( + "invalid --pattern; use 0 (gradient), 1 (checkerboard), or 2 (color bars)" + ) from exc + + if pattern not in {0, 1, 2}: + raise argparse.ArgumentTypeError( + "invalid --pattern; use 0 (gradient), 1 (checkerboard), or 2 (color bars)" + ) + return pattern + + +RGBA_CHANNELS = 4 +ALPHA_OPAQUE = 255 +CHECKERBOARD_BASE_SIZE = 64 +CHECKERBOARD_VARIATION = 32 +SMPTE_COLOR_BARS = 7 +GRADIENT_TIME_STEP = 0.02 +CHECKERBOARD_TIME_STEP = 0.05 + + +def parse_v4l2_pixel_format(value: str) -> str: + pixel_format = str(value).strip() + if not pixel_format: + raise argparse.ArgumentTypeError("pixel format cannot be empty") + return pixel_format + + +def parse_key_value_properties(items: list[str]) -> Dict[str, Any]: + props: Dict[str, Any] = {} + for item in items: + if "=" not in item: + raise SystemExit(f"invalid --property '{item}', expected KEY=VALUE") + key, value = item.split("=", 1) + key = key.strip() + value = value.strip() + if not key: + raise SystemExit("property key cannot be empty") + if not value: + raise SystemExit("property value cannot be empty") + props[key] = value + return props + + +class PatternGeneratorOp(Operator): + """Emit RGBA frames for pattern-generator mode matching the C++ implementation.""" + + def __init__( + self, + fragment, + *args, + width: int = 1920, + height: int = 1080, + pattern: int = 0, + storage_type: int = 1, + **kwargs, + ): + self.width = int(width) + self.height = int(height) + self.pattern = int(pattern) + self.storage_type = int(storage_type) + self.xp = np if self.storage_type == 0 else cp + + self.time_offset = 0.0 + self.animation_time = 0.0 + self._x = None + self._y = None + super().__init__(fragment, *args, **kwargs) + + def setup(self, spec: OperatorSpec): + spec.output("output") + + def start(self): + self._y, self._x = self.xp.indices((self.height, self.width), dtype=self.xp.float32) + + def _gradient(self): + assert self._x is not None and self._y is not None + self.time_offset += GRADIENT_TIME_STEP + + frame = self.xp.empty((self.height, self.width, RGBA_CHANNELS), dtype=self.xp.uint8) + frame[..., 0] = (128.0 + 127.0 * self.xp.sin(self._x * 0.01 + self.time_offset)).astype( + self.xp.uint8 + ) + frame[..., 1] = (128.0 + 127.0 * self.xp.sin(self._y * 0.01 + self.time_offset)).astype( + self.xp.uint8 + ) + frame[..., 2] = ( + 128.0 + 127.0 * self.xp.cos((self._x + self._y) * 0.005 + self.time_offset) + ).astype(self.xp.uint8) + frame[..., 3] = ALPHA_OPAQUE + return frame + + def _checkerboard(self): + assert self._x is not None and self._y is not None + self.animation_time += CHECKERBOARD_TIME_STEP + + square_size = CHECKERBOARD_BASE_SIZE + int( + CHECKERBOARD_VARIATION * np.sin(self.animation_time) + ) + square_size = max(1, square_size) + + board = ( + (self._x.astype(self.xp.int32) // square_size) + + (self._y.astype(self.xp.int32) // square_size) + ) % 2 == 0 + color = self.xp.where(board, ALPHA_OPAQUE, 0).astype(self.xp.uint8) + + frame = self.xp.empty((self.height, self.width, RGBA_CHANNELS), dtype=self.xp.uint8) + frame[..., 0] = color + frame[..., 1] = color + frame[..., 2] = color + frame[..., 3] = ALPHA_OPAQUE + return frame + + def _colorbars(self): + colors = self.xp.array( + [ + [255, 255, 255, ALPHA_OPAQUE], # White + [255, 255, 0, ALPHA_OPAQUE], # Yellow + [0, 255, 255, ALPHA_OPAQUE], # Cyan + [0, 255, 0, ALPHA_OPAQUE], # Green + [255, 0, 255, ALPHA_OPAQUE], # Magenta + [255, 0, 0, ALPHA_OPAQUE], # Red + [0, 0, 255, ALPHA_OPAQUE], # Blue + ], + dtype=self.xp.uint8, + ) + + frame = self.xp.empty((self.height, self.width, RGBA_CHANNELS), dtype=self.xp.uint8) + bar_width = self.width // SMPTE_COLOR_BARS + bar_width = max(1, bar_width) + + x_coords = self.xp.arange(self.width, dtype=self.xp.int32) + bar_indices = x_coords // bar_width + bar_indices = self.xp.minimum(bar_indices, SMPTE_COLOR_BARS - 1) + + column_colors = colors[bar_indices] + frame[...] = column_colors[self.xp.newaxis, :, :] + + return frame + + def compute(self, op_input, op_output, context): + if self.pattern == 0: + frame = self._gradient() + elif self.pattern == 1: + frame = self._checkerboard() + elif self.pattern == 2: + frame = self._colorbars() + else: + frame = self._gradient() + + op_output.emit({"video_frame": frame}, "output") + + +class GstVideoRecorderApp(Application): + def __init__(self, args: argparse.Namespace): + super().__init__() + self.args = args + + def _source_condition_args(self) -> list[Any]: + if self.args.count > 0: + return [CountCondition(self, self.args.count)] + return [] + + def compose(self): + condition_args = self._source_condition_args() + + recorder = GstVideoRecorderOp( + self, + encoder=self.args.encoder, + framerate=self.args.framerate, + max_buffers=10, + filename=self.args.output, + properties=self.args.properties, + name="gst_video_recorder", + ) + + if self.args.source == "pattern": + source = PatternGeneratorOp( + self, + *condition_args, + width=self.args.width, + height=self.args.height, + pattern=self.args.pattern, + storage_type=self.args.storage, + name="pattern_source", + ) + self.add_flow(source, recorder, {("output", "input")}) + elif self.args.source == "v4l2": + allocator = UnboundedAllocator(self, name="allocator") + + source = V4L2VideoCaptureOp( + self, + *condition_args, + allocator=allocator, + device=self.args.device, + width=self.args.width, + height=self.args.height, + pixel_format=self.args.pixel_format, + name="v4l2_source", + ) + format_converter = FormatConverterOp( + self, + name="format_converter", + in_dtype="rgba8888", + out_dtype="rgba8888", + pool=allocator, + ) + + self.add_flow(source, format_converter, {("signal", "source_video")}) + self.add_flow(format_converter, recorder, {("tensor", "input")}) + + else: + raise RuntimeError(f"unsupported source '{self.args.source}'") + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Holohub Python sample for GstVideoRecorderOp.", + add_help=False, + ) + parser.add_argument( + "--help", + action="help", + help="show this help message and exit", + ) + + # General options + parser.add_argument( + "--source", + choices=("pattern", "v4l2"), + default="pattern", + help="input source type", + ) + parser.add_argument( + "-o", + "--output", + default="output.mp4", + help="output video filename", + ) + parser.add_argument( + "-e", + "--encoder", + default="nvh264", + help="encoder base name (for example: nvh264, nvh265, x264, x265); the 'enc' suffix is added automatically by the recorder operator", + ) + parser.add_argument( + "-f", + "--framerate", + default="30/1", + help='frame rate as fraction or decimal (for example "30/1", "30000/1001", "29.97", "60")', + ) + parser.add_argument( + "-c", + "--count", + dest="count", + type=int, + default=None, + help="number of frames to produce or capture; 0 means unlimited (default: unlimited)", + ) + parser.add_argument( + "--property", + action="append", + default=[], + metavar="KEY=VALUE", + help="extra GStreamer encoder property; may be repeated", + ) + + # Resolution options + parser.add_argument( + "-w", + "--width", + type=int, + default=1920, + help="frame width in pixels", + ) + parser.add_argument( + "-h", + "--height", + type=int, + default=1080, + help="frame height in pixels", + ) + + # V4L2 options via built-in Holoscan operator + parser.add_argument( + "--device", + default="/dev/video0", + help="V4L2 device path", + ) + parser.add_argument( + "--pixel-format", + type=parse_v4l2_pixel_format, + default="auto", + help="V4L2 pixel format (for example: YUYV, MJPEG, auto)", + ) + + # Pattern generator options + parser.add_argument( + "--pattern", + type=parse_pattern, + default=0, + help="pattern type: 0 = animated gradient, 1 = animated checkerboard, 2 = color bars (SMPTE style)", + ) + parser.add_argument( + "--storage", + type=int, + choices=(0, 1), + default=1, + help="memory storage type: 0 = host memory, 1 = device or CUDA memory (default: 1)", + ) + + return parser + + +def validate_args(args: argparse.Namespace) -> None: + if not str(args.output).strip(): + raise SystemExit("--output cannot be empty") + if not str(args.encoder).strip(): + raise SystemExit("--encoder cannot be empty") + if args.source == "v4l2" and not str(args.device).strip(): + raise SystemExit("--device cannot be empty when --source is v4l2") + if not (64 <= args.width <= 8192): + raise SystemExit("--width must be between 64 and 8192") + if not (64 <= args.height <= 8192): + raise SystemExit("--height must be between 64 and 8192") + if not args.framerate or not str(args.framerate).strip(): + raise SystemExit("--framerate cannot be empty") + if args.count is None: + args.count = 0 + elif not (0 <= args.count <= 1_000_000_000): + raise SystemExit("--count must be 0 (unlimited) or between 1 and 1000000000") + args.properties = parse_key_value_properties(args.property) + + +def main() -> int: + parser = build_arg_parser() + args = parser.parse_args() + validate_args(args) + + try: + app = GstVideoRecorderApp(args) + app.run() + except KeyboardInterrupt: + return 130 + except Exception as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/applications/gstreamer/gst_video_recorder/python/metadata.json b/applications/gstreamer/gst_video_recorder/python/metadata.json new file mode 100644 index 0000000000..8bc60b0a35 --- /dev/null +++ b/applications/gstreamer/gst_video_recorder/python/metadata.json @@ -0,0 +1,66 @@ +{ + "application": { + "name": "Python GStreamer Video Recorder", + "authors": [ + { + "name": "Medical Robotics Team", + "affiliation": "TECNALIA" + } + ], + "language": "Python", + "version": "1.1.0", + "changelog": { + "1.0.0": "Initial Python Binding Release" + }, + "requirements": { + "system_packages": [ + "pkg-config", + "libgstreamer1.0-dev", + "libgstreamer-plugins-base1.0-dev", + "gstreamer1.0-plugins-base", + "gstreamer1.0-plugins-bad", + "gstreamer1.0-plugins-good", + "gstreamer1.0-plugins-ugly" + ], + "optional_packages": [ + "gstreamer1.0-cuda (for CUDA support, requires GStreamer 1.24+)" + ] + }, + "holoscan_sdk": { + "minimum_required_version": "3.8.0", + "tested_versions": [ + "4.1.0" + ] + }, + "platforms": [ + "aarch64", + "x86_64" + ], + "tags": ["Video", "GStreamer", "Recording", "Encoding", "File"], + "ranking": 2, + "description": "Record Holoscan video streams to file using GStreamer encoding. Supports multiple codecs (H.264, H.265, VP8, VP9) and both host/device memory for efficient video recording.", + "default_mode": "pattern", + "modes": { + "pattern": { + "description": "Record a pattern generator video", + "build": { + "cmake_options": ["-DHOLOHUB_BUILD_PYTHON=ON"] + }, + "run": { + "command": "python3 /gst_video_recorder.py --source pattern", + "workdir": "holohub_bin" + } + }, + "v4l2": { + "description": "Record video from a V4L2 camera", + "build": { + "cmake_options": ["-DHOLOHUB_BUILD_PYTHON=ON"] + }, + "run": { + "command": "python3 /gst_video_recorder.py --source v4l2", + "workdir": "holohub_bin" + } + } + } + } +} diff --git a/applications/gstreamer/install_deps.sh b/applications/gstreamer/install_deps.sh index 8fd7f0d648..7b0b136d09 100755 --- a/applications/gstreamer/install_deps.sh +++ b/applications/gstreamer/install_deps.sh @@ -45,6 +45,7 @@ $USE_SUDO apt-get install -y pkg-config # Install GStreamer development libraries echo "📦 Installing GStreamer development packages..." $USE_SUDO apt-get install -y \ + gstreamer1.0-tools \ libgstreamer1.0-dev \ libgstreamer-plugins-base1.0-dev \ libgstreamer-plugins-bad1.0-dev diff --git a/operators/gstreamer/CMakeLists.txt b/operators/gstreamer/CMakeLists.txt index bafe05cce4..e72d6ce781 100644 --- a/operators/gstreamer/CMakeLists.txt +++ b/operators/gstreamer/CMakeLists.txt @@ -116,6 +116,11 @@ else() message(STATUS "GStreamer CUDA support: DISABLED (gstreamer-cuda-1.0 not found - requires GStreamer 1.24+)") endif() +# Python bindings for GstVideoRecorderOp +if(HOLOHUB_BUILD_PYTHON) + add_subdirectory(python) +endif() + # Install library install(TARGETS holoscan_gstreamer_bridge DESTINATION lib) diff --git a/operators/gstreamer/README.md b/operators/gstreamer/README.md index 2887fcc768..e37e4687b0 100644 --- a/operators/gstreamer/README.md +++ b/operators/gstreamer/README.md @@ -27,6 +27,9 @@ These components allow you to: Records incoming Holoscan tensors to video files using GStreamer encoding pipelines. +This operator is available in both **C++** and **Python**. +The Python binding mirrors the C++ interface and supports the same constructor keywords. + **Key Features:** - Multiple codec support: H.264 (nvh264, x264), H.265 (nvh265, x265) diff --git a/operators/gstreamer/metadata.json b/operators/gstreamer/metadata.json index 7e696d9518..30c4252d5d 100644 --- a/operators/gstreamer/metadata.json +++ b/operators/gstreamer/metadata.json @@ -12,7 +12,7 @@ "changelog": { "1.0.0": "Initial release with GstVideoRecorderOp and GstSrcBridge" }, - "language": ["C++"], + "language": ["C++", "Python"], "holoscan_sdk": { "minimum_required_version": "3.8.0", "tested_versions": ["3.8.0"] diff --git a/operators/gstreamer/python/CMakeLists.txt b/operators/gstreamer/python/CMakeLists.txt new file mode 100644 index 0000000000..222718a8c3 --- /dev/null +++ b/operators/gstreamer/python/CMakeLists.txt @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, TECNALIA. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# 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. + +include(pybind11_add_holohub_module) +pybind11_add_holohub_module( + CPP_CMAKE_TARGET holoscan_gstreamer_bridge + CLASS_NAME "GstVideoRecorderOp" + SOURCES gst_video_recorder_op.cpp +) + +if(BUILD_TESTING) + find_package(holoscan 1.0 REQUIRED CONFIG PATHS "/opt/nvidia/holoscan" "/workspace/holoscan-sdk/install") + + set(PYTHON_TEST_ENV + "PYTHONPATH=${GXF_LIB_DIR}/../python/lib:${CMAKE_BINARY_DIR}/python/lib:$ENV{PYTHONPATH}" + ) + + add_test( + NAME holoscan_gstreamer_bridge_python_import_test + COMMAND python3 -c + "from holohub.holoscan_gstreamer_bridge import GstVideoRecorderOp; assert GstVideoRecorderOp.__name__ == 'GstVideoRecorderOp'; assert GstVideoRecorderOp.__doc__" + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + ) + + add_test( + NAME gst_video_recorder_op_python_pytest + COMMAND ${Python3_EXECUTABLE} -m pytest ${CMAKE_CURRENT_SOURCE_DIR}/tests + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} + ) + + set_tests_properties( + holoscan_gstreamer_bridge_python_import_test + gst_video_recorder_op_python_pytest + PROPERTIES + ENVIRONMENT "${PYTHON_TEST_ENV}" + ) +endif() diff --git a/operators/gstreamer/python/gst_video_recorder_op.cpp b/operators/gstreamer/python/gst_video_recorder_op.cpp new file mode 100644 index 0000000000..14cbccf58a --- /dev/null +++ b/operators/gstreamer/python/gst_video_recorder_op.cpp @@ -0,0 +1,119 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2025-2026, TECNALIA. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#include +#include + +#include "gst_video_recorder_op_pydoc.hpp" + +#include +#include + +#include "holoscan/core/fragment.hpp" +#include "holoscan/core/operator.hpp" +#include "holoscan/core/operator_spec.hpp" + +#include "../gst_video_recorder_op.hpp" +#include "../../operator_util.hpp" + +using std::string_literals::operator""s; +using pybind11::literals::operator""_a; + +namespace py = pybind11; + +#define STRINGIFY(x) #x +#define MACRO_STRINGIFY(x) STRINGIFY(x) + +namespace holoscan { + +class PyGstVideoRecorderOp : public GstVideoRecorderOp { + public: + using GstVideoRecorderOp::GstVideoRecorderOp; + + PyGstVideoRecorderOp(Fragment* fragment, + const py::args& args, + const std::string& encoder = "nvh264", + const std::string& format = "RGBA", + const std::string& framerate = "30/1", + size_t max_buffers = 10, + bool block = true, + const std::string& filename = "output.mp4", + std::map properties = {}, + const std::string& name = "gst_video_recorder") + : GstVideoRecorderOp(ArgList{Arg{"encoder", encoder}, + Arg{"format", format}, + Arg{"framerate", framerate}, + Arg{"max-buffers", max_buffers}, + Arg{"block", block}, + Arg{"filename", filename}, + Arg{"properties", properties}, + }) { + add_positional_condition_and_resource_args(this, args); + name_ = name; + fragment_ = fragment; + spec_ = std::make_shared(fragment); + setup(*spec_.get()); + } +}; + +PYBIND11_MODULE(_holoscan_gstreamer_bridge, m) { + m.doc() = R"pbdoc( + Python bindings for the HoloHub GStreamer bridge + --------------------------------------- + Exposes GstVideoRecorderOp for direct use from Python. + )pbdoc"; + +#ifdef VERSION_INFO + m.attr("__version__") = MACRO_STRINGIFY(VERSION_INFO); +#else + m.attr("__version__") = "dev"; +#endif + + py::class_>( + m, "GstVideoRecorderOp", doc::GstVideoRecorderOp::doc_GstVideoRecorderOp) + .def(py::init, + const std::string&>(), + "fragment"_a, + "encoder"_a = "nvh264"s, + "format"_a = "RGBA"s, + "framerate"_a = "30/1"s, + "max_buffers"_a = size_t(10), + "block"_a = true, + "filename"_a = "output.mp4"s, + "properties"_a = std::map{}, + "name"_a = "gst_video_recorder"s, + doc::GstVideoRecorderOp::doc_GstVideoRecorderOp) + .def("initialize", + &GstVideoRecorderOp::initialize, + doc::GstVideoRecorderOp::doc_initialize) + .def("setup", + &GstVideoRecorderOp::setup, + "spec"_a, + doc::GstVideoRecorderOp::doc_setup); +} // PYBIND11_MODULE NOLINT +} // namespace holoscan diff --git a/operators/gstreamer/python/gst_video_recorder_op_pydoc.hpp b/operators/gstreamer/python/gst_video_recorder_op_pydoc.hpp new file mode 100644 index 0000000000..d53f6e931e --- /dev/null +++ b/operators/gstreamer/python/gst_video_recorder_op_pydoc.hpp @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2025-2026, TECNALIA. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#ifndef PYHOLOHUB_OPERATORS_GSTREAMER_GST_VIDEO_RECORDER_OP_PYDOC_HPP +#define PYHOLOHUB_OPERATORS_GSTREAMER_GST_VIDEO_RECORDER_OP_PYDOC_HPP + +#include + +#include "macros.hpp" + +namespace holoscan::doc { + +namespace GstVideoRecorderOp { + +// PyGstVideoRecorderOp Constructor +PYDOC(GstVideoRecorderOp, R"doc( + +Operator for recording video streams to file using GStreamer. + +**==Named Inputs==** + +input : TensorMap +Video frames to encode and write to file. +Width, height, and storage type are automatically detected from the first frame. + +Parameters +---------- + +fragment : holoscan.core.Fragment + Fragment that owns the operator. +encoder : str + Encoder base name (e.g. ``"nvh264"``, ``"nvh265"``, ``"x264"``, or ``"x265"``). + The "enc" suffix is automatically appended to form the element name. + Default value is ``"nvh264"``. +format : str + Pixel format for video data (e.g. ``"RGBA"``, ``"RGB"``, ``"BGRA"``, ``"BGR"``, and ``"GRAY8"``). + Default value is ``"RGBA"``. +framerate : str + Video framerate as a fraction or decimal, for example ``"30/1"``, + ``"30000/1001"``, ``"29.97"``, or ``"60"``. + Special value ``"0/1"`` enables live mode with no framerate control. + Default value is ``"30/1"`` +max_buffers : int + Maximum number of buffers to queue (0 = unlimited). + Default value is ``10``. +block : bool + Whether ``push_buffer()`` blocks when the internal queue is full (``True`` = block, ``False`` = non-blocking, may drop/timeout). + Default value is ``True``. +filename : str + Output video filename. + If no extension is provided, ``".mp4"`` is automatically appended. + Default value is ``"output.mp4"``. +properties : dict of str to str + Map of encoder-specific properties. + Examples include ``{"bitrate": "8000", "preset": "1", "gop-size": "30"}``. + Default value is an empty dictionary. +name : str + Operator name. + Default value is ``"gst_video_recorder"``. +)doc") + +PYDOC(initialize, R"doc( +Initialize the operator. + +This method is called only once when the operator is created for the first time, +and uses a light-weight initialization. +)doc") + +PYDOC(setup, R"doc( +Define the operator specification. + +Parameters +---------- +spec : ``holoscan.core.OperatorSpec`` + The operator specification. +)doc") + +} // namespace GstVideoRecorderOp +} // namespace holoscan::doc +#endif // PYHOLOHUB_OPERATORS_GSTREAMER_GST_VIDEO_RECORDER_OP_PYDOC_HPP diff --git a/operators/gstreamer/python/tests/test_gst_video_recorder_op.py b/operators/gstreamer/python/tests/test_gst_video_recorder_op.py new file mode 100644 index 0000000000..d6d39c791b --- /dev/null +++ b/operators/gstreamer/python/tests/test_gst_video_recorder_op.py @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, TECNALIA. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# 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. + +from holoscan.core import Application + +from holohub.holoscan_gstreamer_bridge import GstVideoRecorderOp + + +class _TestApplication(Application): + def compose(self): + pass + + +def test_import_gst_video_recorder_op(): + assert GstVideoRecorderOp.__name__ == "GstVideoRecorderOp" + + +def test_construct_with_defaults(tmp_path): + app = _TestApplication() + op = GstVideoRecorderOp( + app, + filename=str(tmp_path / "output.mp4"), + ) + assert op is not None + + +def test_construct_with_kwargs(tmp_path): + app = _TestApplication() + op = GstVideoRecorderOp( + app, + filename=str(tmp_path / "output.mp4"), + max_buffers=4, + properties={"bitrate": "8000"}, + name="recorder", + ) + assert op is not None