diff --git a/examples/camera_streamer/.gitignore b/examples/camera_streamer/.gitignore new file mode 100644 index 0000000000..d9a8a68b4d --- /dev/null +++ b/examples/camera_streamer/.gitignore @@ -0,0 +1,6 @@ +.venv*/ +__pycache__/ +.pytest_cache/ +*.egg-info/ +*.so +**/build/ diff --git a/examples/camera_streamer/AGENTS.md b/examples/camera_streamer/AGENTS.md new file mode 100644 index 0000000000..819fcdac49 --- /dev/null +++ b/examples/camera_streamer/AGENTS.md @@ -0,0 +1,14 @@ + + +# Standalone GMSL/Argus streamer + +- Keep the wire/configuration tests runnable without Jetson or CUDA dependencies. +- Preserve upstream notices when adding SPDX headers; missing SPDX does not mean + a file lacks a license. Keep third-party exceptions separate from Apache-2.0 code. +- Keep each review independently buildable: add tests and documentation with their dependencies. +- Normalize generated build fragments to one final newline before staging. +- Keep argus_sender, camera_receiver and cuda_ipc_consumer directly under camera_streamer; + do not group standalone transport components under the TeleViz visualization name. diff --git a/examples/camera_streamer/README.md b/examples/camera_streamer/README.md new file mode 100644 index 0000000000..9d20dff01e --- /dev/null +++ b/examples/camera_streamer/README.md @@ -0,0 +1,31 @@ + + +# Isaac Camera Streamer — GMSL / Argus + +This component streams Argus camera data using AVS1/TCP. The protocol and +configuration tests run without camera hardware. + +## Protocol and configuration tests + +```bash +cmake -S examples/camera_streamer/argus_sender -B /tmp/camera-protocol-build +cmake --build /tmp/camera-protocol-build --parallel 2 +ctest --test-dir /tmp/camera-protocol-build --output-on-failure +``` + +See [the wire specification](argus_sender/WIRE_FORMAT.md). Shared camera +profiles are in `configs/`; select sensor modes using the installed camera driver. + +## Licensing + +This example is Apache-2.0 licensed. Existing upstream notices and external SDK +licenses remain in effect. + +## Rebuilding after a source-directory move + +CMake caches absolute source paths. After updating an existing deployment, configure +and build the native sender and decoder again at the paths shown above. Do not move +or reuse an old build directory from a different source location. diff --git a/examples/camera_streamer/argus_sender/CMakeLists.txt b/examples/camera_streamer/argus_sender/CMakeLists.txt new file mode 100644 index 0000000000..e75d45c825 --- /dev/null +++ b/examples/camera_streamer/argus_sender/CMakeLists.txt @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +cmake_minimum_required(VERSION 3.16) +project(argus_sender LANGUAGES CXX) +set(CMAKE_CXX_STANDARD 14) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +include(CTest) +find_package(Threads REQUIRED) +include(cmake/YamlCpp.cmake) +set(ARGUS_SENDER_ROOT "${CMAKE_CURRENT_SOURCE_DIR}") +include(cmake/Tests.cmake) diff --git a/examples/camera_streamer/argus_sender/WIRE_FORMAT.md b/examples/camera_streamer/argus_sender/WIRE_FORMAT.md new file mode 100644 index 0000000000..3095b25812 --- /dev/null +++ b/examples/camera_streamer/argus_sender/WIRE_FORMAT.md @@ -0,0 +1,228 @@ + + +# AVS1 wire format + +AVS1 means **Argus Video Streams, version 1**. It carries one or more logical +mono/stereo streams, with HEVC or raw NV12 samples, over one reliable byte +stream. The TCP stream and `network.dump_wire` file contain the same repeated +records and have no file-level header or footer. + +All integers are unsigned and encoded in network byte order (big-endian). All +reserved fields must be zero. Sizes and offsets are bytes. + +## TCP record + +| Offset | Size | Field | Meaning | +| ---: | ---: | --- | --- | +| 0 | 4 | `record_length` | Bytes after this prefix | +| 4 | `record_length` | `avs1_record` | One complete bundle | + +Read exactly four bytes, decode `record_length`, then read exactly that many +bytes. The maximum representable record is `2^32 - 1` bytes. + +## Bundle header + +Every `avs1_record` starts with this 64-byte header: + +| Offset | Size | Field | Meaning | +| ---: | ---: | --- | --- | +| 0 | 4 | `magic` | ASCII `AVS1` | +| 4 | 2 | `version` | `1` | +| 6 | 2 | `flags` | Bundle flags | +| 8 | 4 | `header_bytes` | `64` | +| 12 | 2 | `stream_count` | Logical stream descriptor count | +| 14 | 2 | `sample_count` | Total camera sample descriptor count | +| 16 | 8 | `bundle_sequence` | Starts at 1 and increases each base-rate tick | +| 24 | 8 | `session_id` | Nonzero ID; changes when the pipeline is rebuilt | +| 32 | 8 | `sender_capture_ready_ns` | Sender clock after due captures are acquired | +| 40 | 8 | `sender_tcp_enqueue_ns` | Sender clock before the completed bundle is queued | +| 48 | 4 | `descriptor_bytes` | Stream plus sample descriptor bytes | +| 52 | 4 | `payload_bytes` | Combined sample payload bytes | +| 56 | 8 | `sender_capture_ready_tsc_ns` | Sender Tegra TSC read at the `sender_capture_ready_ns` instant; zero unless `SENDER_TSC_VALID` | + +Bundle flags: + +| Mask | Name | Meaning | +| ---: | --- | --- | +| `0x0001` | `SENDER_CLOCK_VALID` | Sender timing fields are valid | +| `0x0002` | `SENDER_CLOCK_TAI` | Valid sender fields use `CLOCK_TAI`; otherwise `CLOCK_REALTIME` | +| `0x0004` | `SENDER_TSC_VALID` | `sender_capture_ready_tsc_ns` is valid | + +`SENDER_CLOCK_TAI` is invalid unless `SENDER_CLOCK_VALID` is also set. + +`SENDER_TSC_VALID` is invalid unless `SENDER_CLOCK_VALID` is also set. When it +is set, `sender_capture_ready_tsc_ns` must be nonzero; when it is clear, the +field must be zero. Parsers reject either violation. Offset 56 was a reserved +zero before this flag existed, so a sender that predates it (flag clear, field +zero) is still parsed unchanged by current receivers. The reverse is not true: a +receiver that predates the flag rejects every bundle that sets it as an unknown +bundle flag, so deploy the receiver before (or together with) a sender that +emits `SENDER_TSC_VALID`. + +The record layout is: + +```text +64-byte bundle header +stream_count * 40-byte stream descriptors +sample_count * 72-byte sample descriptors +payload_bytes of sample data +``` + +Therefore: + +```text +descriptor_bytes = stream_count * 40 + sample_count * 72 +record_length = 64 + descriptor_bytes + payload_bytes +``` + +## Stream descriptor + +There is one 40-byte descriptor for every configured logical stream, in YAML +`streams` order. A descriptor remains in every bundle even when that stream is +not due at the current mixed-rate tick. + +| Offset | Size | Field | Meaning | +| ---: | ---: | --- | --- | +| 0 | 2 | `stream_index` | Zero-based config index | +| 2 | 2 | `flags` | Stream flags | +| 4 | 1 | `codec` | `0` raw NV12, `1` HEVC | +| 5 | 1 | `pixel_format` | `1` NV12 | +| 6 | 2 | `rotation` | `0` or `180`, presentation rotation applied by the receiver | +| 8 | 4 | `width` | Coded/output width | +| 12 | 4 | `height` | Coded/output height | +| 16 | 4 | `fps_numerator` | Configured/resolved stream output rate | +| 20 | 4 | `fps_denominator` | Currently `1`; never zero | +| 24 | 8 | `stream_sequence` | Increases only when this stream is present; zero when absent | +| 32 | 2 | `first_sample` | Flat sample-descriptor index | +| 34 | 2 | `sample_count` | `1` mono, `2` stereo | +| 36 | 4 | `reserved` | Zero | + +Stream flags: + +| Mask | Name | Meaning | +| ---: | --- | --- | +| `0x0001` | `PRESENT` | Samples and payloads are present in this bundle | +| `0x0002` | `STEREO` | Two ordered samples: left, then right | + +For a lower-rate stream on an absent tick, `PRESENT` is clear, +`stream_sequence` is zero, and both stereo samples are absent atomically. The +descriptor still reports codec, dimensions, FPS, and camera count. + +## Sample descriptor + +There is one 72-byte descriptor per physical camera. Samples are flattened in +stream order, then camera order. Stereo order is always left then right. + +| Offset | Size | Field | Meaning | +| ---: | ---: | --- | --- | +| 0 | 2 | `stream_index` | Owning logical stream | +| 2 | 1 | `camera_index` | Index inside stream: `0`, or `0/1` for stereo | +| 3 | 1 | `role` | `0` mono, `1` left, `2` right | +| 4 | 2 | `flags` | Sample validity flags | +| 6 | 2 | `reserved` | Zero | +| 8 | 4 | `device_index` | Argus device index from sender config | +| 12 | 4 | `sensor_mode_index` | Argus mode index from sender config | +| 16 | 8 | `frame_number` | Argus capture frame number | +| 24 | 8 | `sof_tsc_ns` | VI hardware start-of-frame TSC timestamp | +| 32 | 8 | `frame_timestamp_ns` | Argus frame timestamp | +| 40 | 8 | `sender_capture_ready_ns` | Sender clock when due captures were ready | +| 48 | 8 | `sender_process_submit_ns` | Sender clock before copy/encode submission | +| 56 | 8 | `sender_process_done_ns` | Sender clock when raw/encoded payload was ready | +| 64 | 4 | `payload_offset` | Offset within the payload area | +| 68 | 4 | `payload_length` | This sample's payload bytes | + +Sample flags: + +| Mask | Name | Meaning | +| ---: | --- | --- | +| `0x0001` | `PRESENT` | Payload is present | +| `0x0002` | `SOF_TSC_VALID` | `sof_tsc_ns` is valid | +| `0x0004` | `FRAME_TIMESTAMP_VALID` | `frame_timestamp_ns` is valid | +| `0x0008` | `SENDER_TIMING_VALID` | The three per-sample sender fields are valid | + +An absent sample has all sample flags clear, zero timing/frame fields, and a +zero payload length. A present sample has a nonzero payload. Stream and sample +presence must agree; stereo eye presence is atomic. + +`sof_tsc_ns` is from the Tegra-wide VI TSC domain, not wall clock. Signed +`right.sof_tsc_ns - left.sof_tsc_ns` is meaningful for a stereo stream on one +sender. Sender wall-clock values can be compared across hosts only to the extent +that their clocks are synchronized. + +### Sensor SOF wall time + +`sender_capture_ready_ns` is read after Argus has delivered every due capture, +so it trails the exposure by a jittery 17-25 ms. `sender_capture_ready_tsc_ns` +is the sender's Tegra TSC read at that same instant, which places each sample's +VI start-of-frame on the sender wall clock: + +```text +sensor_sof_ns = sender_capture_ready_ns + - (sender_capture_ready_tsc_ns - sof_tsc_ns) +``` + +Use it only when `SENDER_CLOCK_VALID`, `SENDER_TSC_VALID`, and the sample's +`SOF_TSC_VALID` are all set and the lag +`sender_capture_ready_tsc_ns - sof_tsc_ns` lies in `[0, 500 ms]`; otherwise +fall back to `sender_capture_ready_ns`. The sender reads the TSC from the ARM +generic timer scaled by `cntfrq_el0` (the domain that stamps VI SOF) and sets +`SENDER_TSC_VALID` only when that lag check passed for the bundle's due +captures. `frame_timestamp_ns` is in a different domain and must not be used as +the exposure anchor. + +## Payload area + +Payloads are concatenated in stream order and camera order. `payload_offset` is +relative to the first byte after all descriptors. + +- HEVC (`codec = 1`): one opaque Annex-B access unit returned by NVENC, including + start-code-prefixed NAL units. +- Raw (`codec = 0`): tight NV12 with `width * height` Y bytes followed by + `width * height / 2` interleaved UV bytes. Its length must equal + `width * height * 3 / 2`. + +Receivers must validate every payload range against `payload_bytes` and reject +overlap, gaps, unknown flags, unsupported versions, or inconsistent presence. + +## Minimal Python framing + +The standalone parser is +`camera_receiver/avs_wire.py` relative to the example root. This fragment shows the outer framing +and fixed descriptor structs: + +```python +import struct + +HEADER = struct.Struct("!4sHHIHHQQQQIIQ") # 64 bytes +STREAM = struct.Struct("!HHBBHIIIIQHHI") # 40 bytes +SAMPLE = struct.Struct("!HBBHHIIQQQQQQII") # 72 bytes + + +def recv_exact(sock, size): + data = bytearray(size) + view = memoryview(data) + done = 0 + while done < size: + count = sock.recv_into(view[done:]) + if count == 0: + raise EOFError("truncated AVS1 record") + done += count + return bytes(data) + + +def recv_record(sock, max_record_bytes=256 * 1024 * 1024): + (record_length,) = struct.unpack("!I", recv_exact(sock, 4)) + if not 0 < record_length <= max_record_bytes: + raise ValueError(f"invalid AVS1 record length: {record_length}") + record = recv_exact(sock, record_length) + header = HEADER.unpack_from(record) + if header[0] != b"AVS1" or header[1] != 1 or header[3] != HEADER.size: + raise ValueError("unsupported AVS1 record") + return record +``` + +A clean dump ends on a record boundary. An interrupted process can leave a +partial final record; an offline parser should ignore or report that final tail. diff --git a/examples/camera_streamer/argus_sender/cmake/Tests.cmake b/examples/camera_streamer/argus_sender/cmake/Tests.cmake new file mode 100644 index 0000000000..bafdce9a00 --- /dev/null +++ b/examples/camera_streamer/argus_sender/cmake/Tests.cmake @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +get_filename_component(CAMERA_STREAMER_ROOT "${ARGUS_SENDER_ROOT}" DIRECTORY) +set(CAMERA_STREAMER_TEST_ROOT "${CAMERA_STREAMER_ROOT}/tests") + +set(ARGUS_SENDER_TEST_INCLUDE_DIRS + ${CMAKE_CURRENT_SOURCE_DIR}/src + ${CAMERA_STREAMER_TEST_ROOT}/cpp +) + +function(argus_sender_add_cpp_test target test_name) + if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") + target_compile_options(${target} PRIVATE -Wall -Wextra) + endif() + target_include_directories(${target} PRIVATE + ${ARGUS_SENDER_TEST_INCLUDE_DIRS} + ) + target_link_libraries(${target} PRIVATE + Threads::Threads + ) + add_test(NAME ${test_name} COMMAND ${target}) + set_tests_properties(${test_name} PROPERTIES + LABELS "unit" + TIMEOUT 10 + ) +endfunction() + +add_subdirectory(${CAMERA_STREAMER_TEST_ROOT}/cpp/config ${CMAKE_CURRENT_BINARY_DIR}/tests/config) +add_subdirectory(${CAMERA_STREAMER_TEST_ROOT}/cpp/geometry_scheduler ${CMAKE_CURRENT_BINARY_DIR}/tests/geometry_scheduler) +add_subdirectory(${CAMERA_STREAMER_TEST_ROOT}/cpp/priority_frame_handoff ${CMAKE_CURRENT_BINARY_DIR}/tests/priority_frame_handoff) +add_subdirectory(${CAMERA_STREAMER_TEST_ROOT}/cpp/frame_alignment_policy ${CMAKE_CURRENT_BINARY_DIR}/tests/frame_alignment_policy) +add_subdirectory(${CAMERA_STREAMER_TEST_ROOT}/cpp/capture_domain_plan ${CMAKE_CURRENT_BINARY_DIR}/tests/capture_domain_plan) +add_subdirectory(${CAMERA_STREAMER_TEST_ROOT}/cpp/capture_priority_policy ${CMAKE_CURRENT_BINARY_DIR}/tests/capture_priority_policy) +add_subdirectory(${CAMERA_STREAMER_TEST_ROOT}/cpp/avs1_wire ${CMAKE_CURRENT_BINARY_DIR}/tests/avs1_wire) diff --git a/examples/camera_streamer/argus_sender/cmake/YamlCpp.cmake b/examples/camera_streamer/argus_sender/cmake/YamlCpp.cmake new file mode 100644 index 0000000000..51418e22e1 --- /dev/null +++ b/examples/camera_streamer/argus_sender/cmake/YamlCpp.cmake @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +include(FetchContent) +set(YAML_CPP_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(YAML_CPP_BUILD_TOOLS OFF CACHE BOOL "" FORCE) +set(YAML_CPP_BUILD_CONTRIB OFF CACHE BOOL "" FORCE) +set(YAML_BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) +FetchContent_Declare( + yaml_cpp + URL + https://github.com/jbeder/yaml-cpp/releases/download/yaml-cpp-0.9.0/yaml-cpp-yaml-cpp-0.9.0.tar.gz + URL_HASH + SHA256=298593d9c440fd9034b8b193d96318b76d49bc97c6ceadb7b0836edf0b6d7539 +) +FetchContent_MakeAvailable(yaml_cpp) diff --git a/examples/camera_streamer/argus_sender/src/Avs1Wire.cpp b/examples/camera_streamer/argus_sender/src/Avs1Wire.cpp new file mode 100644 index 0000000000..34006849e5 --- /dev/null +++ b/examples/camera_streamer/argus_sender/src/Avs1Wire.cpp @@ -0,0 +1,464 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "Avs1Wire.h" + +#include + +#include +#include + +namespace argus_sender +{ +namespace +{ + +const uint16_t kKnownBundleFlags = + AVS1_BUNDLE_SENDER_CLOCK_VALID | AVS1_BUNDLE_SENDER_CLOCK_TAI | AVS1_BUNDLE_SENDER_TSC_VALID; +const uint16_t kKnownStreamFlags = AVS1_STREAM_PRESENT | AVS1_STREAM_STEREO; +const uint16_t kKnownSampleFlags = AVS1_SAMPLE_PRESENT | AVS1_SAMPLE_SOF_TSC_VALID | AVS1_SAMPLE_FRAME_TIMESTAMP_VALID | + AVS1_SAMPLE_SENDER_TIMING_VALID; + +bool fail(const char** error, const char* message) +{ + if (error) + *error = message; + return false; +} + +bool validateSenderTsc(uint16_t bundleFlags, uint64_t senderCaptureReadyTscNs, const char** error) +{ + // Header offset 56 was a reserved zero before SENDER_TSC_VALID existed, so + // the flag and the field must agree in both directions to stay + // byte-compatible with older senders and receivers. + if (bundleFlags & AVS1_BUNDLE_SENDER_TSC_VALID) + { + if (!(bundleFlags & AVS1_BUNDLE_SENDER_CLOCK_VALID)) + return fail(error, "AVS1 sender TSC flag requires a valid sender clock"); + if (senderCaptureReadyTscNs == 0) + return fail(error, "AVS1 sender TSC flag requires a nonzero TSC"); + return true; + } + if (senderCaptureReadyTscNs != 0) + return fail(error, "AVS1 sender TSC requires the sender TSC flag"); + return true; +} + +void appendU16(std::vector* out, uint16_t value) +{ + const uint16_t encoded = htons(value); + const uint8_t* bytes = reinterpret_cast(&encoded); + out->insert(out->end(), bytes, bytes + sizeof(encoded)); +} + +void appendU32(std::vector* out, uint32_t value) +{ + const uint32_t encoded = htonl(value); + const uint8_t* bytes = reinterpret_cast(&encoded); + out->insert(out->end(), bytes, bytes + sizeof(encoded)); +} + +void appendU64(std::vector* out, uint64_t value) +{ + appendU32(out, static_cast(value >> 32)); + appendU32(out, static_cast(value & 0xffffffffULL)); +} + +bool readU16(const uint8_t* data, size_t size, size_t* offset, uint16_t* value) +{ + if (*offset + 2 > size) + return false; + uint16_t encoded = 0; + std::memcpy(&encoded, data + *offset, sizeof(encoded)); + *value = ntohs(encoded); + *offset += 2; + return true; +} + +bool readU32(const uint8_t* data, size_t size, size_t* offset, uint32_t* value) +{ + if (*offset + 4 > size) + return false; + uint32_t encoded = 0; + std::memcpy(&encoded, data + *offset, sizeof(encoded)); + *value = ntohl(encoded); + *offset += 4; + return true; +} + +bool readU64(const uint8_t* data, size_t size, size_t* offset, uint64_t* value) +{ + uint32_t high = 0; + uint32_t low = 0; + if (!readU32(data, size, offset, &high) || !readU32(data, size, offset, &low)) + { + return false; + } + *value = (static_cast(high) << 32) | low; + return true; +} + +bool validateStream( + const Avs1StreamRecord& stream, size_t streamIndex, size_t* payloadBytes, size_t* sampleCount, const char** error) +{ + if (stream.flags & ~kKnownStreamFlags) + return fail(error, "unknown AVS1 stream flags"); + const bool stereo = (stream.flags & AVS1_STREAM_STEREO) != 0; + const size_t expectedSamples = stereo ? 2 : 1; + if (stream.samples.size() != expectedSamples) + return fail(error, "AVS1 stream camera count does not match its type"); + if (stream.codec != AVS1_CODEC_HEVC && stream.codec != AVS1_CODEC_RAW) + return fail(error, "unknown AVS1 codec"); + if (stream.pixelFormat != AVS1_PIXEL_FORMAT_NV12) + return fail(error, "unknown AVS1 pixel format"); + if (stream.width == 0 || stream.height == 0 || stream.width % 2 != 0 || stream.height % 2 != 0) + { + return fail(error, "invalid AVS1 NV12 dimensions"); + } + if (stream.fpsDenominator == 0) + return fail(error, "AVS1 FPS denominator is zero"); + + if (stream.rotation != 0 && stream.rotation != 180) + return fail(error, "unsupported AVS1 rotation"); + const bool streamPresent = (stream.flags & AVS1_STREAM_PRESENT) != 0; + if (streamPresent != (stream.sequence != 0)) + return fail(error, "AVS1 stream sequence must be nonzero only when the stream is present"); + for (size_t i = 0; i < stream.samples.size(); ++i) + { + const Avs1SampleRecord& sample = stream.samples[i]; + if (sample.flags & ~kKnownSampleFlags) + return fail(error, "unknown AVS1 sample flags"); + if (sample.cameraIndex != i) + return fail(error, "AVS1 camera indices must follow config order"); + const uint8_t expectedRole = stereo ? (i == 0 ? AVS1_ROLE_LEFT : AVS1_ROLE_RIGHT) : AVS1_ROLE_MONO; + if (sample.role != expectedRole) + return fail(error, "AVS1 sample role does not match stream type"); + const bool samplePresent = (sample.flags & AVS1_SAMPLE_PRESENT) != 0; + if (samplePresent != streamPresent) + return fail(error, "AVS1 stereo/mono stream presence must be atomic"); + if (samplePresent && sample.payload.empty()) + return fail(error, "present AVS1 sample has an empty payload"); + if (!samplePresent && !sample.payload.empty()) + return fail(error, "absent AVS1 sample has a payload"); + if (stream.codec == AVS1_CODEC_RAW && samplePresent) + { + const uint64_t expected = static_cast(stream.width) * stream.height * 3ULL / 2ULL; + if (sample.payload.size() != expected) + return fail(error, "raw AVS1 NV12 payload has the wrong size"); + } + if (*payloadBytes > std::numeric_limits::max() - sample.payload.size()) + { + return fail(error, "AVS1 payload area exceeds 32-bit size"); + } + *payloadBytes += sample.payload.size(); + ++*sampleCount; + } + (void)streamIndex; + return true; +} + +} // namespace + +bool buildAvs1WireRecord(const Avs1Bundle& bundle, std::vector* out, const char** error) +{ + if (error) + *error = 0; + if (!out) + return fail(error, "AVS1 output is null"); + if (bundle.sequence == 0 || bundle.sessionId == 0) + return fail(error, "AVS1 sequence and session ID must be nonzero"); + if (bundle.flags & ~kKnownBundleFlags) + return fail(error, "unknown AVS1 bundle flags"); + if ((bundle.flags & AVS1_BUNDLE_SENDER_CLOCK_TAI) && !(bundle.flags & AVS1_BUNDLE_SENDER_CLOCK_VALID)) + { + return fail(error, "AVS1 TAI flag requires a valid sender clock"); + } + if (!validateSenderTsc(bundle.flags, bundle.senderCaptureReadyTscNs, error)) + { + return false; + } + if (bundle.streams.empty() || bundle.streams.size() > std::numeric_limits::max()) + { + return fail(error, "invalid AVS1 stream count"); + } + + size_t payloadBytes = 0; + size_t sampleCount = 0; + for (size_t i = 0; i < bundle.streams.size(); ++i) + { + if (!validateStream(bundle.streams[i], i, &payloadBytes, &sampleCount, error)) + { + return false; + } + } + if (sampleCount > std::numeric_limits::max()) + return fail(error, "invalid AVS1 sample count"); + + const uint64_t descriptorBytes = + bundle.streams.size() * kAvs1StreamDescriptorBytes + sampleCount * kAvs1SampleDescriptorBytes; + const uint64_t recordBytes = kAvs1HeaderBytes + descriptorBytes + payloadBytes; + if (descriptorBytes > std::numeric_limits::max() || recordBytes > std::numeric_limits::max()) + { + return fail(error, "AVS1 record exceeds 32-bit TCP framing"); + } + + out->clear(); + out->reserve(kAvs1LengthPrefixBytes + static_cast(recordBytes)); + appendU32(out, static_cast(recordBytes)); + out->push_back('A'); + out->push_back('V'); + out->push_back('S'); + out->push_back('1'); + appendU16(out, kAvs1Version); + appendU16(out, bundle.flags); + appendU32(out, static_cast(kAvs1HeaderBytes)); + appendU16(out, static_cast(bundle.streams.size())); + appendU16(out, static_cast(sampleCount)); + appendU64(out, bundle.sequence); + appendU64(out, bundle.sessionId); + appendU64(out, bundle.senderCaptureReadyNs); + appendU64(out, bundle.senderTcpEnqueueNs); + appendU32(out, static_cast(descriptorBytes)); + appendU32(out, static_cast(payloadBytes)); + appendU64(out, bundle.senderCaptureReadyTscNs); + + uint16_t firstSample = 0; + for (size_t i = 0; i < bundle.streams.size(); ++i) + { + const Avs1StreamRecord& stream = bundle.streams[i]; + appendU16(out, static_cast(i)); + appendU16(out, stream.flags); + out->push_back(stream.codec); + out->push_back(stream.pixelFormat); + appendU16(out, stream.rotation); + appendU32(out, stream.width); + appendU32(out, stream.height); + appendU32(out, stream.fpsNumerator); + appendU32(out, stream.fpsDenominator); + appendU64(out, stream.sequence); + appendU16(out, firstSample); + appendU16(out, static_cast(stream.samples.size())); + appendU32(out, 0); + firstSample += static_cast(stream.samples.size()); + } + + uint32_t payloadOffset = 0; + for (size_t streamIndex = 0; streamIndex < bundle.streams.size(); ++streamIndex) + { + const Avs1StreamRecord& stream = bundle.streams[streamIndex]; + for (size_t cameraIndex = 0; cameraIndex < stream.samples.size(); ++cameraIndex) + { + const Avs1SampleRecord& sample = stream.samples[cameraIndex]; + appendU16(out, static_cast(streamIndex)); + out->push_back(sample.cameraIndex); + out->push_back(sample.role); + appendU16(out, sample.flags); + appendU16(out, 0); + appendU32(out, sample.deviceIndex); + appendU32(out, sample.sensorModeIndex); + appendU64(out, sample.frameNumber); + appendU64(out, sample.sofTscNs); + appendU64(out, sample.frameTimestampNs); + appendU64(out, sample.senderCaptureReadyNs); + appendU64(out, sample.senderProcessSubmitNs); + appendU64(out, sample.senderProcessDoneNs); + appendU32(out, payloadOffset); + appendU32(out, static_cast(sample.payload.size())); + payloadOffset += static_cast(sample.payload.size()); + } + } + for (size_t streamIndex = 0; streamIndex < bundle.streams.size(); ++streamIndex) + { + const Avs1StreamRecord& stream = bundle.streams[streamIndex]; + for (size_t cameraIndex = 0; cameraIndex < stream.samples.size(); ++cameraIndex) + { + const std::vector& payload = stream.samples[cameraIndex].payload; + out->insert(out->end(), payload.begin(), payload.end()); + } + } + return out->size() == kAvs1LengthPrefixBytes + recordBytes; +} + +bool parseAvs1WireRecord(const uint8_t* data, size_t size, Avs1Bundle* bundle, const char** error) +{ + if (error) + *error = 0; + if (!data || !bundle || size < kAvs1LengthPrefixBytes + kAvs1HeaderBytes) + return fail(error, "short AVS1 record"); + size_t offset = 0; + uint32_t recordBytes = 0; + if (!readU32(data, size, &offset, &recordBytes) || recordBytes != size - kAvs1LengthPrefixBytes) + { + return fail(error, "AVS1 length prefix mismatch"); + } + if (std::memcmp(data + offset, "AVS1", 4) != 0) + return fail(error, "bad AVS1 magic"); + offset += 4; + uint16_t version = 0; + uint16_t streamCount = 0; + uint16_t sampleCount = 0; + uint32_t headerBytes = 0; + uint32_t descriptorBytes = 0; + uint32_t payloadBytes = 0; + Avs1Bundle parsed; + if (!readU16(data, size, &offset, &version) || !readU16(data, size, &offset, &parsed.flags) || + !readU32(data, size, &offset, &headerBytes) || !readU16(data, size, &offset, &streamCount) || + !readU16(data, size, &offset, &sampleCount) || !readU64(data, size, &offset, &parsed.sequence) || + !readU64(data, size, &offset, &parsed.sessionId) || !readU64(data, size, &offset, &parsed.senderCaptureReadyNs) || + !readU64(data, size, &offset, &parsed.senderTcpEnqueueNs) || !readU32(data, size, &offset, &descriptorBytes) || + !readU32(data, size, &offset, &payloadBytes) || !readU64(data, size, &offset, &parsed.senderCaptureReadyTscNs)) + { + return fail(error, "short AVS1 header"); + } + if (version != kAvs1Version || headerBytes != kAvs1HeaderBytes || streamCount == 0) + { + return fail(error, "unsupported AVS1 header"); + } + if (parsed.flags & ~kKnownBundleFlags) + return fail(error, "unknown AVS1 bundle flags"); + if ((parsed.flags & AVS1_BUNDLE_SENDER_CLOCK_TAI) && !(parsed.flags & AVS1_BUNDLE_SENDER_CLOCK_VALID)) + return fail(error, "AVS1 TAI flag requires a valid sender clock"); + if (parsed.sequence == 0 || parsed.sessionId == 0) + return fail(error, "AVS1 sequence and session ID must be nonzero"); + if (!validateSenderTsc(parsed.flags, parsed.senderCaptureReadyTscNs, error)) + { + return false; + } + const uint64_t expectedDescriptors = static_cast(streamCount) * kAvs1StreamDescriptorBytes + + static_cast(sampleCount) * kAvs1SampleDescriptorBytes; + if (descriptorBytes != expectedDescriptors || + static_cast(headerBytes) + descriptorBytes + payloadBytes != recordBytes) + { + return fail(error, "AVS1 descriptor or payload size mismatch"); + } + + struct ParsedSampleLocation + { + uint32_t offset = 0; + uint32_t size = 0; + }; + std::vector firstSamples(streamCount); + std::vector streamSampleCounts(streamCount); + parsed.streams.resize(streamCount); + uint32_t flatCursor = 0; + for (uint16_t i = 0; i < streamCount; ++i) + { + uint16_t streamIndex = 0; + uint32_t reserved32 = 0; + Avs1StreamRecord& stream = parsed.streams[i]; + if (!readU16(data, size, &offset, &streamIndex) || !readU16(data, size, &offset, &stream.flags) || + offset + 2 > size) + { + return fail(error, "short AVS1 stream descriptor"); + } + stream.codec = data[offset++]; + stream.pixelFormat = data[offset++]; + if (!readU16(data, size, &offset, &stream.rotation) || !readU32(data, size, &offset, &stream.width) || + !readU32(data, size, &offset, &stream.height) || !readU32(data, size, &offset, &stream.fpsNumerator) || + !readU32(data, size, &offset, &stream.fpsDenominator) || !readU64(data, size, &offset, &stream.sequence) || + !readU16(data, size, &offset, &firstSamples[i]) || !readU16(data, size, &offset, &streamSampleCounts[i]) || + !readU32(data, size, &offset, &reserved32)) + { + return fail(error, "short AVS1 stream descriptor"); + } + // Validate counts before resize so malformed tables cannot amplify allocations. + const uint16_t expectedCount = (stream.flags & AVS1_STREAM_STEREO) ? 2 : 1; + if (streamIndex != i || reserved32 != 0 || streamSampleCounts[i] != expectedCount || + firstSamples[i] != flatCursor || flatCursor + streamSampleCounts[i] > sampleCount) + { + return fail(error, "invalid AVS1 stream descriptor index"); + } + flatCursor += streamSampleCounts[i]; + stream.samples.resize(streamSampleCounts[i]); + } + + if (flatCursor != sampleCount) + return fail(error, "AVS1 sample descriptors are not fully referenced"); + std::vector locations(sampleCount); + for (uint16_t flat = 0; flat < sampleCount; ++flat) + { + uint16_t streamIndex = 0; + uint16_t flags = 0; + uint16_t reserved16 = 0; + uint32_t deviceIndex = 0; + uint32_t modeIndex = 0; + uint64_t frameNumber = 0; + uint64_t sofTscNs = 0; + uint64_t frameTimestampNs = 0; + uint64_t captureReadyNs = 0; + uint64_t processSubmitNs = 0; + uint64_t processDoneNs = 0; + if (!readU16(data, size, &offset, &streamIndex) || offset + 2 > size) + return fail(error, "short AVS1 sample descriptor"); + const uint8_t cameraIndex = data[offset++]; + const uint8_t role = data[offset++]; + if (!readU16(data, size, &offset, &flags) || !readU16(data, size, &offset, &reserved16) || + !readU32(data, size, &offset, &deviceIndex) || !readU32(data, size, &offset, &modeIndex) || + !readU64(data, size, &offset, &frameNumber) || !readU64(data, size, &offset, &sofTscNs) || + !readU64(data, size, &offset, &frameTimestampNs) || !readU64(data, size, &offset, &captureReadyNs) || + !readU64(data, size, &offset, &processSubmitNs) || !readU64(data, size, &offset, &processDoneNs) || + !readU32(data, size, &offset, &locations[flat].offset) || + !readU32(data, size, &offset, &locations[flat].size)) + { + return fail(error, "short AVS1 sample descriptor"); + } + if (streamIndex >= streamCount || cameraIndex >= parsed.streams[streamIndex].samples.size() || + firstSamples[streamIndex] + cameraIndex != flat || reserved16 != 0) + { + return fail(error, "invalid AVS1 sample descriptor index"); + } + Avs1SampleRecord& sample = parsed.streams[streamIndex].samples[cameraIndex]; + sample.flags = flags; + sample.cameraIndex = cameraIndex; + sample.role = role; + sample.deviceIndex = deviceIndex; + sample.sensorModeIndex = modeIndex; + sample.frameNumber = frameNumber; + sample.sofTscNs = sofTscNs; + sample.frameTimestampNs = frameTimestampNs; + sample.senderCaptureReadyNs = captureReadyNs; + sample.senderProcessSubmitNs = processSubmitNs; + sample.senderProcessDoneNs = processDoneNs; + } + // A size sum alone cannot detect overlapping ranges with a gap elsewhere. + uint64_t expectedOffset = 0; + for (const auto& location : locations) + { + if (location.offset != expectedOffset) + return fail(error, "AVS1 payload ranges overlap or have gaps"); + expectedOffset += location.size; + } + const size_t payloadStart = kAvs1LengthPrefixBytes + headerBytes + descriptorBytes; + for (uint16_t streamIndex = 0; streamIndex < streamCount; ++streamIndex) + { + Avs1StreamRecord& stream = parsed.streams[streamIndex]; + for (size_t cameraIndex = 0; cameraIndex < stream.samples.size(); ++cameraIndex) + { + const size_t flat = firstSamples[streamIndex] + cameraIndex; + const uint64_t end = static_cast(locations[flat].offset) + locations[flat].size; + if (end > payloadBytes) + return fail(error, "AVS1 sample payload exceeds record"); + const uint8_t* begin = data + payloadStart + locations[flat].offset; + stream.samples[cameraIndex].payload.assign(begin, begin + locations[flat].size); + } + } + + size_t validatedPayload = 0; + size_t validatedSamples = 0; + for (size_t i = 0; i < parsed.streams.size(); ++i) + { + if (!validateStream(parsed.streams[i], i, &validatedPayload, &validatedSamples, error)) + { + return false; + } + } + if (validatedPayload != payloadBytes || validatedSamples != sampleCount) + return fail(error, "AVS1 payload coverage mismatch"); + *bundle = std::move(parsed); + return true; +} + +} // namespace argus_sender diff --git a/examples/camera_streamer/argus_sender/src/Avs1Wire.h b/examples/camera_streamer/argus_sender/src/Avs1Wire.h new file mode 100644 index 0000000000..fca710746c --- /dev/null +++ b/examples/camera_streamer/argus_sender/src/Avs1Wire.h @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef ARGUS_SENDER_AVS1_WIRE_H +#define ARGUS_SENDER_AVS1_WIRE_H + +#include +#include +#include + +namespace argus_sender +{ + +const uint16_t kAvs1Version = 1; +const size_t kAvs1LengthPrefixBytes = 4; +const size_t kAvs1HeaderBytes = 64; +const size_t kAvs1StreamDescriptorBytes = 40; +const size_t kAvs1SampleDescriptorBytes = 72; + +enum Avs1BundleFlags +{ + AVS1_BUNDLE_SENDER_CLOCK_VALID = 1U << 0, + AVS1_BUNDLE_SENDER_CLOCK_TAI = 1U << 1, + AVS1_BUNDLE_SENDER_TSC_VALID = 1U << 2 +}; + +enum Avs1StreamFlags +{ + AVS1_STREAM_PRESENT = 1U << 0, + AVS1_STREAM_STEREO = 1U << 1 +}; + +enum Avs1SampleFlags +{ + AVS1_SAMPLE_PRESENT = 1U << 0, + AVS1_SAMPLE_SOF_TSC_VALID = 1U << 1, + AVS1_SAMPLE_FRAME_TIMESTAMP_VALID = 1U << 2, + AVS1_SAMPLE_SENDER_TIMING_VALID = 1U << 3 +}; + +enum Avs1Codec +{ + AVS1_CODEC_RAW = 0, + AVS1_CODEC_HEVC = 1 +}; + +enum Avs1PixelFormat +{ + AVS1_PIXEL_FORMAT_NV12 = 1 +}; + +enum Avs1SampleRole +{ + AVS1_ROLE_MONO = 0, + AVS1_ROLE_LEFT = 1, + AVS1_ROLE_RIGHT = 2 +}; + +struct Avs1SampleRecord +{ + uint16_t flags = 0; + uint8_t cameraIndex = 0; + uint8_t role = AVS1_ROLE_MONO; + uint32_t deviceIndex = 0; + uint32_t sensorModeIndex = 0; + uint64_t frameNumber = 0; + uint64_t sofTscNs = 0; + uint64_t frameTimestampNs = 0; + uint64_t senderCaptureReadyNs = 0; + uint64_t senderProcessSubmitNs = 0; + uint64_t senderProcessDoneNs = 0; + std::vector payload; +}; + +struct Avs1StreamRecord +{ + uint16_t flags = 0; + uint8_t codec = AVS1_CODEC_HEVC; + uint8_t pixelFormat = AVS1_PIXEL_FORMAT_NV12; + uint16_t rotation = 0; + uint32_t width = 0; + uint32_t height = 0; + uint32_t fpsNumerator = 0; + uint32_t fpsDenominator = 1; + uint64_t sequence = 0; + std::vector samples; +}; + +struct Avs1Bundle +{ + uint16_t flags = 0; + uint64_t sequence = 0; + uint64_t sessionId = 0; + uint64_t senderCaptureReadyNs = 0; + uint64_t senderTcpEnqueueNs = 0; + uint64_t senderCaptureReadyTscNs = 0; + std::vector streams; +}; + +bool buildAvs1WireRecord(const Avs1Bundle& bundle, std::vector* out, const char** error = 0); +bool parseAvs1WireRecord(const uint8_t* data, size_t size, Avs1Bundle* bundle, const char** error = 0); + +} // namespace argus_sender + +#endif // ARGUS_SENDER_AVS1_WIRE_H diff --git a/examples/camera_streamer/argus_sender/src/CaptureDomainPlan.cpp b/examples/camera_streamer/argus_sender/src/CaptureDomainPlan.cpp new file mode 100644 index 0000000000..863beb982a --- /dev/null +++ b/examples/camera_streamer/argus_sender/src/CaptureDomainPlan.cpp @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "CaptureDomainPlan.h" + +#include +#include + +namespace argus_sender +{ +namespace +{ + +bool compatible(const CaptureEndpointDescriptor& left, const CaptureEndpointDescriptor& right) +{ + return left.modeIndex == right.modeIndex && left.sourceWidth == right.sourceWidth && + left.sourceHeight == right.sourceHeight && left.framePeriodNs == right.framePeriodNs; +} + +bool failPlan(const std::string& message, CaptureDomainPlan* plan, std::string* error) +{ + if (plan) + *plan = CaptureDomainPlan(); + if (error) + *error = message; + return false; +} + +} // namespace + +bool buildCaptureDomainPlan(const std::vector& endpoints, + CaptureDomainPlan* plan, + std::string* error) +{ + if (!plan) + return false; + *plan = CaptureDomainPlan(); + plan->endpointDomains.assign(endpoints.size(), std::numeric_limits::max()); + + for (size_t endpointIndex = 0; endpointIndex < endpoints.size(); ++endpointIndex) + { + if (plan->endpointDomains[endpointIndex] != std::numeric_limits::max()) + { + continue; + } + + const CaptureEndpointDescriptor& endpoint = endpoints[endpointIndex]; + if (endpoint.streamType == STREAM_TYPE_STEREO) + { + size_t peerIndex = std::numeric_limits::max(); + for (size_t candidate = endpointIndex + 1; candidate < endpoints.size(); ++candidate) + { + if (endpoints[candidate].streamIndex == endpoint.streamIndex) + { + peerIndex = candidate; + break; + } + } + if (peerIndex == std::numeric_limits::max()) + { + std::ostringstream stream; + stream << "stereo stream " << endpoint.streamIndex << " does not have two camera endpoints"; + return failPlan(stream.str(), plan, error); + } + if (!compatible(endpoint, endpoints[peerIndex])) + { + std::ostringstream stream; + stream << "stereo stream " << endpoint.streamIndex << " uses capture-incompatible sensor modes"; + return failPlan(stream.str(), plan, error); + } + const size_t domainIndex = plan->domains.size(); + plan->domains.push_back({ endpointIndex, peerIndex }); + plan->endpointDomains[endpointIndex] = domainIndex; + plan->endpointDomains[peerIndex] = domainIndex; + continue; + } + + const size_t domainIndex = plan->domains.size(); + plan->domains.push_back(std::vector(1, endpointIndex)); + plan->endpointDomains[endpointIndex] = domainIndex; + } + return true; +} + +} // namespace argus_sender diff --git a/examples/camera_streamer/argus_sender/src/CaptureDomainPlan.h b/examples/camera_streamer/argus_sender/src/CaptureDomainPlan.h new file mode 100644 index 0000000000..e948c19b17 --- /dev/null +++ b/examples/camera_streamer/argus_sender/src/CaptureDomainPlan.h @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef ARGUS_SENDER_CAPTURE_DOMAIN_PLAN_H +#define ARGUS_SENDER_CAPTURE_DOMAIN_PLAN_H + +#include "Config.h" + +#include +#include +#include +#include + +namespace argus_sender +{ + +struct CaptureEndpointDescriptor +{ + size_t streamIndex = 0; + size_t cameraIndex = 0; + StreamType streamType = STREAM_TYPE_MONO; + uint32_t modeIndex = 0; + uint32_t sourceWidth = 0; + uint32_t sourceHeight = 0; + uint64_t framePeriodNs = 0; +}; + +struct CaptureDomainPlan +{ + std::vector> domains; + std::vector endpointDomains; +}; + +bool buildCaptureDomainPlan(const std::vector& endpoints, + CaptureDomainPlan* plan, + std::string* error); + +} // namespace argus_sender + +#endif // ARGUS_SENDER_CAPTURE_DOMAIN_PLAN_H diff --git a/examples/camera_streamer/argus_sender/src/CapturePriorityPolicy.cpp b/examples/camera_streamer/argus_sender/src/CapturePriorityPolicy.cpp new file mode 100644 index 0000000000..d2d9dfa168 --- /dev/null +++ b/examples/camera_streamer/argus_sender/src/CapturePriorityPolicy.cpp @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "CapturePriorityPolicy.h" + +#include + +namespace argus_sender +{ +namespace +{ + +uint64_t percentage(uint64_t value, uint32_t percent) +{ + return (value / 100U) * percent + ((value % 100U) * percent) / 100U; +} + +} // namespace + +size_t selectPriorityStream(const AppConfig& config, const std::vector& streamFps) +{ + if (streamFps.empty()) + return 0; + size_t priority = 0; + for (size_t i = 1; i < streamFps.size(); ++i) + { + if (streamFps[i] > streamFps[priority]) + priority = i; + } + const uint32_t baseFps = streamFps[priority]; + const size_t count = std::min(config.streams.size(), streamFps.size()); + for (size_t i = 0; i < count; ++i) + { + if (streamFps[i] == baseFps && config.streams[i].type == STREAM_TYPE_STEREO && + config.streams[i].output.codec == CODEC_HEVC) + { + return i; + } + } + return priority; +} + +uint64_t secondaryCaptureReleaseDelayNs(uint64_t framePeriodNs) +{ + return percentage(framePeriodNs, 42U); +} + +uint64_t prioritySyncToleranceNs(uint64_t framePeriodNs) +{ + return framePeriodNs / 2U; +} + +} // namespace argus_sender diff --git a/examples/camera_streamer/argus_sender/src/CapturePriorityPolicy.h b/examples/camera_streamer/argus_sender/src/CapturePriorityPolicy.h new file mode 100644 index 0000000000..c4486a01b7 --- /dev/null +++ b/examples/camera_streamer/argus_sender/src/CapturePriorityPolicy.h @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef ARGUS_SENDER_CAPTURE_PRIORITY_POLICY_H +#define ARGUS_SENDER_CAPTURE_PRIORITY_POLICY_H + +#include "Config.h" + +#include +#include +#include + +namespace argus_sender +{ + +size_t selectPriorityStream(const AppConfig& config, const std::vector& streamFps); + +uint64_t secondaryCaptureReleaseDelayNs(uint64_t framePeriodNs); + +uint64_t prioritySyncToleranceNs(uint64_t framePeriodNs); + +} // namespace argus_sender + +#endif // ARGUS_SENDER_CAPTURE_PRIORITY_POLICY_H diff --git a/examples/camera_streamer/argus_sender/src/Config.cpp b/examples/camera_streamer/argus_sender/src/Config.cpp new file mode 100644 index 0000000000..9dfe7cdc04 --- /dev/null +++ b/examples/camera_streamer/argus_sender/src/Config.cpp @@ -0,0 +1,500 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "Config.h" + +#include +#include +#include +#include +#include +#include + +#include + +namespace argus_sender +{ +namespace +{ + +bool fail(std::string* error, const std::string& message) +{ + if (error) + *error = message; + return false; +} + +bool requireMap(const YAML::Node& node, const std::string& path, std::string* error) +{ + if (!node || !node.IsMap()) + return fail(error, path + " must be a mapping"); + return true; +} + +bool rejectUnknown(const YAML::Node& node, const std::set& allowed, const std::string& path, std::string* error) +{ + if (!requireMap(node, path, error)) + return false; + for (YAML::const_iterator it = node.begin(); it != node.end(); ++it) + { + const std::string key = it->first.as(); + if (allowed.count(key) == 0) + return fail(error, path + " has unknown key '" + key + "'"); + } + return true; +} + +template +bool readOptional(const YAML::Node& node, const char* key, const std::string& path, T* value, std::string* error) +{ + if (!node[key]) + return true; + try + { + *value = node[key].as(); + return true; + } + catch (const YAML::Exception& e) + { + return fail(error, path + "." + key + ": " + e.what()); + } +} + +bool readUint32(const YAML::Node& node, const char* key, const std::string& path, uint32_t* value, std::string* error) +{ + if (!node[key]) + return true; + uint64_t parsed = 0; + if (!readOptional(node, key, path, &parsed, error)) + return false; + if (parsed > std::numeric_limits::max()) + return fail(error, path + "." + key + " is outside uint32 range"); + *value = static_cast(parsed); + return true; +} + +bool parseCamera(const YAML::Node& node, const std::string& path, CameraConfig* camera, std::string* error) +{ + const std::set allowed = { "device", "mode" }; + if (!rejectUnknown(node, allowed, path, error)) + return false; + if (!node["device"]) + return fail(error, path + ".device is required"); + return readUint32(node, "device", path, &camera->device, error) && + readUint32(node, "mode", path, &camera->mode, error); +} + +bool parseRemap(const YAML::Node& node, const std::string& path, RemapConfig* remap, std::string* error) +{ + const std::set allowed = { "file", "capture_width", "capture_height" }; + if (!rejectUnknown(node, allowed, path, error)) + return false; + if (!node["file"]) + return fail(error, path + ".file is required"); + return readOptional(node, "file", path, &remap->file, error) && + readUint32(node, "capture_width", path, &remap->captureWidth, error) && + readUint32(node, "capture_height", path, &remap->captureHeight, error); +} + +bool parseOutput(const YAML::Node& node, const std::string& path, OutputConfig* output, std::string* error) +{ + const std::set allowed = { "fps", "codec", "width", "height", "resize_mode", + "rotation", "bitrate_mbps", "gop", "remap" }; + if (!rejectUnknown(node, allowed, path, error)) + return false; + if (node["remap"] && !parseRemap(node["remap"], path + ".remap", &output->remap, error)) + { + return false; + } + if (!readUint32(node, "fps", path, &output->fps, error) || !readUint32(node, "width", path, &output->width, error) || + !readUint32(node, "height", path, &output->height, error) || + !readUint32(node, "rotation", path, &output->rotation, error) || + !readUint32(node, "bitrate_mbps", path, &output->bitrateMbps, error) || + !readUint32(node, "gop", path, &output->gop, error)) + { + return false; + } + + std::string codec = "hevc"; + if (!readOptional(node, "codec", path, &codec, error)) + return false; + if (codec == "hevc" || codec == "h265") + output->codec = CODEC_HEVC; + else if (codec == "raw" || codec == "raw_nv12" || codec == "nv12") + output->codec = CODEC_RAW_NV12; + else + return fail(error, path + ".codec must be hevc or raw_nv12"); + + std::string resize = "strict"; + if (!readOptional(node, "resize_mode", path, &resize, error)) + return false; + if (resize == "strict") + output->resizeMode = RESIZE_STRICT; + else if (resize == "center_crop") + output->resizeMode = RESIZE_CENTER_CROP; + else if (resize == "stretch") + output->resizeMode = RESIZE_STRETCH; + else + { + return fail(error, path + ".resize_mode must be strict, center_crop, or stretch"); + } + return true; +} + +bool parseCapture(const YAML::Node& node, const std::string& path, CaptureConfig* capture, std::string* error) +{ + const std::set allowed = { "mode", "fifo_length" }; + if (!rejectUnknown(node, allowed, path, error)) + return false; + + std::string mode = "mailbox"; + if (!readOptional(node, "mode", path, &mode, error) || + !readUint32(node, "fifo_length", path, &capture->fifoLength, error)) + { + return false; + } + if (mode == "mailbox") + capture->mode = CAPTURE_MODE_MAILBOX; + else if (mode == "fifo") + capture->mode = CAPTURE_MODE_FIFO; + else + return fail(error, path + ".mode must be mailbox or fifo"); + if (capture->mode == CAPTURE_MODE_MAILBOX && node["fifo_length"]) + { + return fail(error, path + ".fifo_length is only valid when mode is fifo"); + } + return true; +} + +bool parseAlignment(const YAML::Node& node, const std::string& path, AlignmentConfig* alignment, std::string* error) +{ + const std::set allowed = { "warmup_frames", "interval_frames" }; + if (!rejectUnknown(node, allowed, path, error)) + return false; + return readUint32(node, "warmup_frames", path, &alignment->warmupFrames, error) && + readUint32(node, "interval_frames", path, &alignment->intervalFrames, error); +} + +bool parseStream(const YAML::Node& node, size_t index, StreamConfig* stream, std::string* error) +{ + std::ostringstream pathBuilder; + pathBuilder << "streams[" << index << "]"; + const std::string path = pathBuilder.str(); + const std::set allowed = { "id", "type", "cameras", "capture", "output", "receiver" }; + if (!rejectUnknown(node, allowed, path, error)) + return false; + if (node["receiver"] && !node["receiver"].IsMap()) + return fail(error, path + ".receiver must be a mapping"); + if (!node["id"] || !node["type"] || !node["cameras"] || !node["output"]) + { + return fail(error, path + " requires id, type, cameras, and output"); + } + if (!readOptional(node, "id", path, &stream->id, error)) + return false; + + std::string type; + if (!readOptional(node, "type", path, &type, error)) + return false; + if (type == "mono") + stream->type = STREAM_TYPE_MONO; + else if (type == "stereo") + stream->type = STREAM_TYPE_STEREO; + else + return fail(error, path + ".type must be mono or stereo"); + + const YAML::Node cameras = node["cameras"]; + if (!cameras.IsSequence()) + return fail(error, path + ".cameras must be a sequence"); + for (size_t i = 0; i < cameras.size(); ++i) + { + CameraConfig camera; + std::ostringstream cameraPath; + cameraPath << path << ".cameras[" << i << "]"; + if (!parseCamera(cameras[i], cameraPath.str(), &camera, error)) + return false; + stream->cameras.push_back(camera); + } + if (node["capture"] && !parseCapture(node["capture"], path + ".capture", &stream->capture, error)) + { + return false; + } + if (!parseOutput(node["output"], path + ".output", &stream->output, error)) + return false; + return true; +} + +} // namespace + +void printUsage(const char* argv0) +{ + std::printf( + "Usage: %s --config PATH\n" + " %s --check-config --config PATH\n" + " %s --list-modes\n\n" + "Options:\n" + " -c, --config PATH YAML sender configuration\n" + " --check-config Validate configuration without opening cameras\n" + " --list-modes Print Argus camera devices and sensor modes\n" + " -v, --verbose Enable detailed diagnostic logging\n" + " -h, --help Show this help\n", + argv0, argv0, argv0); +} + +bool parseCommandLine(int argc, char** argv, CommandLine* commandLine) +{ + if (!commandLine) + return false; + for (int i = 1; i < argc; ++i) + { + const std::string arg(argv[i]); + if (arg == "-h" || arg == "--help") + { + printUsage(argv[0]); + std::exit(EXIT_SUCCESS); + } + if (arg == "--list-modes") + { + commandLine->listModes = true; + continue; + } + if (arg == "--check-config") + { + commandLine->checkConfig = true; + continue; + } + if (arg == "-v" || arg == "--verbose") + { + commandLine->verbose = true; + continue; + } + if ((arg == "-c" || arg == "--config") && i + 1 < argc) + { + commandLine->configPath = argv[++i]; + continue; + } + std::fprintf(stderr, "Unknown or incomplete argument: %s\n", arg.c_str()); + return false; + } + if (!commandLine->listModes && commandLine->configPath.empty()) + { + std::fprintf(stderr, "--config PATH is required\n"); + return false; + } + if (commandLine->listModes && !commandLine->configPath.empty()) + { + std::fprintf(stderr, "--list-modes does not accept --config\n"); + return false; + } + if (commandLine->listModes && commandLine->checkConfig) + { + std::fprintf(stderr, "--list-modes and --check-config are mutually exclusive\n"); + return false; + } + return true; +} + +bool loadConfigFile(const std::string& path, AppConfig* config, std::string* error) +{ + if (!config) + return fail(error, "configuration output is null"); + *config = AppConfig(); + try + { + const YAML::Node root = YAML::LoadFile(path); + const std::set rootAllowed = { "version", "network", "runtime", "alignment", + "streams", "receiver", "ipc" }; + if (!rejectUnknown(root, rootAllowed, "config", error)) + return false; + if (!readUint32(root, "version", "config", &config->version, error)) + return false; + + if (root["network"]) + { + const YAML::Node network = root["network"]; + const std::set allowed = { "bind", "port", "reconnect_ms", "dump_wire" }; + if (!rejectUnknown(network, allowed, "network", error) || + !readOptional(network, "bind", "network", &config->network.bindHost, error)) + { + return false; + } + uint32_t port = config->network.port; + if (!readUint32(network, "port", "network", &port, error) || + !readUint32(network, "reconnect_ms", "network", &config->network.reconnectDelayMs, error) || + !readOptional(network, "dump_wire", "network", &config->network.dumpWirePath, error)) + { + return false; + } + if (port > std::numeric_limits::max()) + return fail(error, "network.port must be in 1..65535"); + config->network.port = static_cast(port); + } + + if (root["runtime"]) + { + const YAML::Node runtime = root["runtime"]; + const std::set allowed = { "frames", "kpi", "log_interval_s", "bench_encode" }; + if (!rejectUnknown(runtime, allowed, "runtime", error) || + !readOptional(runtime, "frames", "runtime", &config->runtime.frames, error) || + !readOptional(runtime, "kpi", "runtime", &config->runtime.kpi, error) || + !readUint32(runtime, "log_interval_s", "runtime", &config->runtime.logIntervalSeconds, error) || + !readOptional(runtime, "bench_encode", "runtime", &config->runtime.benchEncode, error)) + { + return false; + } + } + + if (root["alignment"] && !parseAlignment(root["alignment"], "alignment", &config->alignment, error)) + { + return false; + } + + if (!root["streams"] || !root["streams"].IsSequence()) + return fail(error, "streams must be a sequence"); + for (size_t i = 0; i < root["streams"].size(); ++i) + { + StreamConfig stream; + if (!parseStream(root["streams"][i], i, &stream, error)) + return false; + config->streams.push_back(stream); + } + + // Remap files resolve against the config file's directory so the + // deployed tree works regardless of the service's working directory. + const size_t lastSlash = path.find_last_of('/'); + const std::string configDir = lastSlash == std::string::npos ? "." : path.substr(0, lastSlash); + for (size_t i = 0; i < config->streams.size(); ++i) + { + RemapConfig& remap = config->streams[i].output.remap; + if (remap.enabled() && remap.file[0] != '/') + remap.file = configDir + "/" + remap.file; + } + } + catch (const YAML::Exception& e) + { + return fail(error, std::string("failed to parse '") + path + "': " + e.what()); + } + return validateConfig(*config, error); +} + +bool validateConfig(const AppConfig& config, std::string* error) +{ + if (config.version != 1) + return fail(error, "config.version must be 1"); + if (config.network.bindHost.empty()) + return fail(error, "network.bind cannot be empty"); + if (config.network.port == 0) + return fail(error, "network.port must be in 1..65535"); + if (config.streams.empty()) + return fail(error, "at least one stream is required"); + if (config.streams.size() > std::numeric_limits::max()) + return fail(error, "too many streams for AVS1"); + + std::set ids; + std::set devices; + bool sawNativeFps = false; + bool sawExplicitFps = false; + for (size_t i = 0; i < config.streams.size(); ++i) + { + const StreamConfig& stream = config.streams[i]; + std::ostringstream pathBuilder; + pathBuilder << "streams[" << i << "]"; + const std::string path = pathBuilder.str(); + if (stream.id.empty()) + return fail(error, path + ".id cannot be empty"); + if (!ids.insert(stream.id).second) + return fail(error, "duplicate stream id '" + stream.id + "'"); + const size_t expectedCameras = stream.type == STREAM_TYPE_STEREO ? 2 : 1; + if (stream.cameras.size() != expectedCameras) + { + std::ostringstream message; + message << path << " type " << streamTypeName(stream.type) << " requires " << expectedCameras << " camera(s)"; + return fail(error, message.str()); + } + if (stream.type == STREAM_TYPE_STEREO && stream.cameras[0].mode != stream.cameras[1].mode) + { + return fail(error, path + " stereo cameras must currently use the same mode index"); + } + for (size_t camera = 0; camera < stream.cameras.size(); ++camera) + { + if (!devices.insert(stream.cameras[camera].device).second) + { + std::ostringstream message; + message << "Argus device " << stream.cameras[camera].device << " is assigned to more than one stream"; + return fail(error, message.str()); + } + } + if (stream.output.width != 0 && stream.output.width % 2 != 0) + return fail(error, path + ".output.width must be even"); + if (stream.output.height != 0 && stream.output.height % 2 != 0) + return fail(error, path + ".output.height must be even"); + if (stream.output.rotation != 0 && stream.output.rotation != 180) + return fail(error, path + ".output.rotation must be 0 or 180"); + if (stream.output.codec == CODEC_HEVC && stream.output.bitrateMbps == 0) + return fail(error, path + ".output.bitrate_mbps must be nonzero for HEVC"); + if (stream.output.remap.enabled()) + { + const RemapConfig& remap = stream.output.remap; + if (stream.output.codec != CODEC_RAW_NV12) + return fail(error, path + ".output.remap requires codec raw_nv12"); + if (stream.type != STREAM_TYPE_MONO) + return fail(error, path + ".output.remap requires a mono stream"); + if (stream.output.width == 0 || stream.output.height == 0) + { + return fail(error, path + ".output.remap requires explicit output width and height"); + } + if (stream.output.resizeMode != RESIZE_STRICT) + return fail(error, path + ".output.remap requires resize_mode strict"); + if (remap.captureWidth == 0 || remap.captureHeight == 0 || remap.captureWidth % 2 != 0 || + remap.captureHeight % 2 != 0) + { + return fail(error, path + ".output.remap.capture_width/height must be nonzero and even"); + } + } + if (stream.capture.mode == CAPTURE_MODE_FIFO && stream.capture.fifoLength < 3) + { + return fail(error, path + ".capture.fifo_length must be at least 3 for the two-frame candidate window"); + } + if (stream.output.fps == 0) + sawNativeFps = true; + else + sawExplicitFps = true; + } + if (sawNativeFps && sawExplicitFps) + { + return fail(error, "fps: 0 cannot be mixed with explicit FPS values; specify every stream rate"); + } + return true; +} + +const char* streamTypeName(StreamType type) +{ + return type == STREAM_TYPE_STEREO ? "stereo" : "mono"; +} + +const char* captureModeName(CaptureMode mode) +{ + return mode == CAPTURE_MODE_FIFO ? "fifo" : "mailbox"; +} + +const char* codecTypeName(CodecType codec) +{ + return codec == CODEC_RAW_NV12 ? "raw_nv12" : "hevc"; +} + +const char* resizeModeName(ResizeMode mode) +{ + switch (mode) + { + case RESIZE_CENTER_CROP: + return "center_crop"; + case RESIZE_STRETCH: + return "stretch"; + default: + return "strict"; + } +} + +} // namespace argus_sender diff --git a/examples/camera_streamer/argus_sender/src/Config.h b/examples/camera_streamer/argus_sender/src/Config.h new file mode 100644 index 0000000000..fe001253f8 --- /dev/null +++ b/examples/camera_streamer/argus_sender/src/Config.h @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef ARGUS_SENDER_CONFIG_H +#define ARGUS_SENDER_CONFIG_H + +#include +#include +#include + +namespace argus_sender +{ + +enum StreamType +{ + STREAM_TYPE_MONO, + STREAM_TYPE_STEREO +}; + +enum CodecType +{ + CODEC_HEVC, + CODEC_RAW_NV12 +}; + +enum ResizeMode +{ + RESIZE_STRICT, + RESIZE_CENTER_CROP, + RESIZE_STRETCH +}; + +enum CaptureMode +{ + CAPTURE_MODE_MAILBOX, + CAPTURE_MODE_FIFO +}; + +struct CameraConfig +{ + uint32_t device = 0; + uint32_t mode = 0; +}; + +// Optional sender-side lookup-table remap (raw_nv12 mono streams only). +// The stream captures at capture_width x capture_height, applies the RMAP +// file (possibly supersampled; see NvRemap.h), and ships the configured +// output width x height ready to record — consumers apply no further remap. +struct RemapConfig +{ + std::string file; + uint32_t captureWidth = 0; + uint32_t captureHeight = 0; + + bool enabled() const + { + return !file.empty(); + } +}; + +struct OutputConfig +{ + uint32_t fps = 0; + CodecType codec = CODEC_HEVC; + uint32_t width = 0; + uint32_t height = 0; + ResizeMode resizeMode = RESIZE_STRICT; + // Presentation rotation advertised to AVS1 consumers. The sender keeps + // captured pixels in native orientation; camera_receiver applies it. + uint32_t rotation = 0; + uint32_t bitrateMbps = 50; + uint32_t gop = 0; + RemapConfig remap; +}; + +struct CaptureConfig +{ + CaptureMode mode = CAPTURE_MODE_MAILBOX; + uint32_t fifoLength = 3; +}; + +struct AlignmentConfig +{ + uint32_t warmupFrames = 60; + uint32_t intervalFrames = 60; +}; + +struct StreamConfig +{ + std::string id; + StreamType type = STREAM_TYPE_MONO; + std::vector cameras; + CaptureConfig capture; + OutputConfig output; +}; + +struct NetworkConfig +{ + std::string bindHost = "0.0.0.0"; + uint16_t port = 5000; + uint32_t reconnectDelayMs = 1000; + std::string dumpWirePath; +}; + +struct RuntimeConfig +{ + uint64_t frames = 0; + bool kpi = true; + uint32_t logIntervalSeconds = 1; + bool benchEncode = false; +}; + +struct AppConfig +{ + uint32_t version = 1; + NetworkConfig network; + RuntimeConfig runtime; + AlignmentConfig alignment; + std::vector streams; +}; + +struct CommandLine +{ + std::string configPath; + bool listModes = false; + bool checkConfig = false; + bool verbose = false; +}; + +void printUsage(const char* argv0); +bool parseCommandLine(int argc, char** argv, CommandLine* commandLine); +bool loadConfigFile(const std::string& path, AppConfig* config, std::string* error); +bool validateConfig(const AppConfig& config, std::string* error); + +const char* streamTypeName(StreamType type); +const char* captureModeName(CaptureMode mode); +const char* codecTypeName(CodecType codec); +const char* resizeModeName(ResizeMode mode); + +} // namespace argus_sender + +#endif // ARGUS_SENDER_CONFIG_H diff --git a/examples/camera_streamer/argus_sender/src/FrameAlignmentPolicy.cpp b/examples/camera_streamer/argus_sender/src/FrameAlignmentPolicy.cpp new file mode 100644 index 0000000000..92da9dee90 --- /dev/null +++ b/examples/camera_streamer/argus_sender/src/FrameAlignmentPolicy.cpp @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "FrameAlignmentPolicy.h" + +#include + +namespace argus_sender +{ + +FrameAlignmentDecision chooseFrameToReacquire(const std::vector& samples, + size_t* sampleIndex, + uint64_t* spanNs) +{ + if (sampleIndex) + *sampleIndex = std::numeric_limits::max(); + if (spanNs) + *spanNs = 0; + if (samples.empty()) + return FRAME_ALIGNMENT_KEEP; + + uint64_t newestTscNs = 0; + uint64_t oldestTscNs = std::numeric_limits::max(); + for (size_t i = 0; i < samples.size(); ++i) + { + if (!samples[i].valid || samples[i].sofTscNs == 0 || samples[i].framePeriodNs == 0) + { + return FRAME_ALIGNMENT_UNAVAILABLE; + } + if (samples[i].sofTscNs > newestTscNs) + newestTscNs = samples[i].sofTscNs; + if (samples[i].sofTscNs < oldestTscNs) + oldestTscNs = samples[i].sofTscNs; + } + if (spanNs) + *spanNs = newestTscNs - oldestTscNs; + + size_t oldestOutOfWindow = std::numeric_limits::max(); + uint64_t largestAgeNs = 0; + for (size_t i = 0; i < samples.size(); ++i) + { + const uint64_t ageNs = newestTscNs - samples[i].sofTscNs; + if (ageNs > samples[i].framePeriodNs / 2 && + (oldestOutOfWindow == std::numeric_limits::max() || ageNs > largestAgeNs)) + { + oldestOutOfWindow = i; + largestAgeNs = ageNs; + } + } + if (oldestOutOfWindow == std::numeric_limits::max()) + return FRAME_ALIGNMENT_KEEP; + if (sampleIndex) + *sampleIndex = oldestOutOfWindow; + return FRAME_ALIGNMENT_REACQUIRE; +} + +bool chooseClosestTscCandidate(const std::vector& candidateTscNs, + uint64_t referenceTscNs, + size_t* candidateIndex, + int64_t* selectedDeltaNs) +{ + if (candidateIndex) + *candidateIndex = std::numeric_limits::max(); + if (selectedDeltaNs) + *selectedDeltaNs = 0; + if (referenceTscNs == 0) + return false; + + size_t bestIndex = std::numeric_limits::max(); + uint64_t bestDistanceNs = std::numeric_limits::max(); + uint64_t bestTscNs = 0; + for (size_t i = 0; i < candidateTscNs.size(); ++i) + { + const uint64_t candidate = candidateTscNs[i]; + if (candidate == 0) + continue; + const uint64_t distance = candidate >= referenceTscNs ? candidate - referenceTscNs : referenceTscNs - candidate; + if (bestIndex == std::numeric_limits::max() || distance < bestDistanceNs || + (distance == bestDistanceNs && candidate > bestTscNs)) + { + bestIndex = i; + bestDistanceNs = distance; + bestTscNs = candidate; + } + } + if (bestIndex == std::numeric_limits::max()) + return false; + + if (candidateIndex) + *candidateIndex = bestIndex; + if (selectedDeltaNs) + { + const uint64_t positiveLimit = static_cast(std::numeric_limits::max()); + if (bestTscNs >= referenceTscNs) + { + const uint64_t delta = bestTscNs - referenceTscNs; + *selectedDeltaNs = delta > positiveLimit ? std::numeric_limits::max() : static_cast(delta); + } + else + { + const uint64_t delta = referenceTscNs - bestTscNs; + *selectedDeltaNs = delta > positiveLimit ? std::numeric_limits::min() : -static_cast(delta); + } + } + return true; +} + +bool computeSynchronizedTscReference(const std::vector& timestamps, + uint64_t maxSpanNs, + uint64_t* referenceTscNs, + uint64_t* spanNs) +{ + if (referenceTscNs) + *referenceTscNs = 0; + if (spanNs) + *spanNs = 0; + if (!referenceTscNs || timestamps.empty()) + return false; + + uint64_t earliest = std::numeric_limits::max(); + uint64_t latest = 0; + for (size_t i = 0; i < timestamps.size(); ++i) + { + if (timestamps[i] == 0) + return false; + if (timestamps[i] < earliest) + earliest = timestamps[i]; + if (timestamps[i] > latest) + latest = timestamps[i]; + } + const uint64_t valueSpanNs = latest - earliest; + if (spanNs) + *spanNs = valueSpanNs; + if (valueSpanNs > maxSpanNs) + return false; + *referenceTscNs = earliest + valueSpanNs / 2ULL; + return true; +} + +} // namespace argus_sender diff --git a/examples/camera_streamer/argus_sender/src/FrameAlignmentPolicy.h b/examples/camera_streamer/argus_sender/src/FrameAlignmentPolicy.h new file mode 100644 index 0000000000..ccb4f61133 --- /dev/null +++ b/examples/camera_streamer/argus_sender/src/FrameAlignmentPolicy.h @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef ARGUS_SENDER_FRAME_ALIGNMENT_POLICY_H +#define ARGUS_SENDER_FRAME_ALIGNMENT_POLICY_H + +#include +#include +#include + +namespace argus_sender +{ + +const uint32_t kMaxFrameAlignmentDrops = 4; + +struct FrameAlignmentSample +{ + uint64_t sofTscNs = 0; + uint64_t framePeriodNs = 0; + bool valid = false; +}; + +enum FrameAlignmentDecision +{ + FRAME_ALIGNMENT_UNAVAILABLE, + FRAME_ALIGNMENT_KEEP, + FRAME_ALIGNMENT_REACQUIRE +}; + +FrameAlignmentDecision chooseFrameToReacquire(const std::vector& samples, + size_t* sampleIndex, + uint64_t* spanNs = NULL); + +// Selects the valid candidate whose SOF TSC is closest to referenceTscNs. +// A tie is resolved in favor of the newer candidate. +bool chooseClosestTscCandidate(const std::vector& candidateTscNs, + uint64_t referenceTscNs, + size_t* candidateIndex, + int64_t* selectedDeltaNs = NULL); + +// Computes the midpoint reference only when every timestamp is valid and the +// complete set fits within maxSpanNs. +bool computeSynchronizedTscReference(const std::vector& timestamps, + uint64_t maxSpanNs, + uint64_t* referenceTscNs, + uint64_t* spanNs = NULL); + +} // namespace argus_sender + +#endif // ARGUS_SENDER_FRAME_ALIGNMENT_POLICY_H diff --git a/examples/camera_streamer/argus_sender/src/MixedRateScheduler.cpp b/examples/camera_streamer/argus_sender/src/MixedRateScheduler.cpp new file mode 100644 index 0000000000..b8965feb02 --- /dev/null +++ b/examples/camera_streamer/argus_sender/src/MixedRateScheduler.cpp @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "MixedRateScheduler.h" + +#include + +namespace argus_sender +{ + +MixedRateScheduler::MixedRateScheduler(const std::vector& streamFps) : m_baseFps(0), m_streamFps(streamFps) +{ + for (size_t i = 0; i < streamFps.size(); ++i) + m_baseFps = std::max(m_baseFps, streamFps[i]); + m_accumulators.resize(streamFps.size()); + for (size_t i = 0; i < streamFps.size(); ++i) + { + m_accumulators[i] = m_baseFps >= streamFps[i] ? m_baseFps - streamFps[i] : 0; + } +} + +uint32_t MixedRateScheduler::baseFps() const +{ + return m_baseFps; +} + +std::vector MixedRateScheduler::next() +{ + std::vector due(m_streamFps.size(), false); + if (m_baseFps == 0) + return due; + for (size_t i = 0; i < m_streamFps.size(); ++i) + { + m_accumulators[i] += m_streamFps[i]; + if (m_accumulators[i] >= m_baseFps) + { + m_accumulators[i] -= m_baseFps; + due[i] = true; + } + } + return due; +} + +} // namespace argus_sender diff --git a/examples/camera_streamer/argus_sender/src/MixedRateScheduler.h b/examples/camera_streamer/argus_sender/src/MixedRateScheduler.h new file mode 100644 index 0000000000..b67b005540 --- /dev/null +++ b/examples/camera_streamer/argus_sender/src/MixedRateScheduler.h @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef ARGUS_SENDER_MIXED_RATE_SCHEDULER_H +#define ARGUS_SENDER_MIXED_RATE_SCHEDULER_H + +#include +#include + +namespace argus_sender +{ + +class MixedRateScheduler +{ +public: + explicit MixedRateScheduler(const std::vector& streamFps); + + uint32_t baseFps() const; + std::vector next(); + +private: + uint32_t m_baseFps; + std::vector m_streamFps; + std::vector m_accumulators; +}; + +} // namespace argus_sender + +#endif // ARGUS_SENDER_MIXED_RATE_SCHEDULER_H diff --git a/examples/camera_streamer/argus_sender/src/OutputGeometry.cpp b/examples/camera_streamer/argus_sender/src/OutputGeometry.cpp new file mode 100644 index 0000000000..94d449989b --- /dev/null +++ b/examples/camera_streamer/argus_sender/src/OutputGeometry.cpp @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "OutputGeometry.h" + +#include +#include +#include + +namespace argus_sender +{ +namespace +{ + +bool fail(std::string* error, const std::string& message) +{ + if (error) + *error = message; + return false; +} + +uint32_t evenRounded(double value) +{ + uint32_t rounded = static_cast(std::floor(value + 0.5)); + if (rounded < 2) + rounded = 2; + if (rounded % 2 != 0) + ++rounded; + return rounded; +} + +bool aspectMatches(double left, double right) +{ + const double scale = std::max(std::fabs(left), std::fabs(right)); + return scale > 0.0 && std::fabs(left - right) / scale <= 0.001; +} + +NormalizedClip centerCrop(double sourceAspect, double targetAspect) +{ + NormalizedClip clip; + if (aspectMatches(sourceAspect, targetAspect)) + return clip; + if (targetAspect > sourceAspect) + { + const float retainedHeight = static_cast(sourceAspect / targetAspect); + clip.top = (1.0F - retainedHeight) * 0.5F; + clip.bottom = 1.0F - clip.top; + } + else + { + const float retainedWidth = static_cast(targetAspect / sourceAspect); + clip.left = (1.0F - retainedWidth) * 0.5F; + clip.right = 1.0F - clip.left; + } + return clip; +} + +} // namespace + +bool resolveOutputGeometry(const OutputConfig& output, + const std::vector& sources, + ResolvedOutputGeometry* resolved, + std::string* error) +{ + if (!resolved) + return fail(error, "resolved geometry output is null"); + *resolved = ResolvedOutputGeometry(); + if (sources.empty()) + return fail(error, "at least one source resolution is required"); + for (size_t i = 0; i < sources.size(); ++i) + { + if (sources[i].width == 0 || sources[i].height == 0) + return fail(error, "source resolution is zero"); + } + + uint32_t width = output.width; + uint32_t height = output.height; + const double referenceAspect = static_cast(sources[0].width) / sources[0].height; + if (width == 0 && height == 0) + { + width = sources[0].width; + height = sources[0].height; + for (size_t i = 1; i < sources.size(); ++i) + { + if (sources[i].width != width || sources[i].height != height) + { + return fail(error, "native stereo resolutions differ; specify output width or height"); + } + } + } + else if (width != 0 && height == 0) + { + height = evenRounded(static_cast(width) / referenceAspect); + } + else if (width == 0 && height != 0) + { + width = evenRounded(static_cast(height) * referenceAspect); + } + if (width == 0 || height == 0 || width % 2 != 0 || height % 2 != 0) + return fail(error, "resolved NV12 dimensions must be nonzero and even"); + + resolved->width = width; + resolved->height = height; + // With a sender-side remap the ISP delivers the remap's source frame and + // the resample to width x height happens in the remap itself, so the + // aspect contract applies to the CAPTURE dimensions, not the output. + resolved->captureWidth = output.remap.enabled() ? output.remap.captureWidth : width; + resolved->captureHeight = output.remap.enabled() ? output.remap.captureHeight : height; + + const double targetAspect = static_cast(resolved->captureWidth) / resolved->captureHeight; + // An inferred dimension preserves the reference aspect before NV12 even-pixel rounding. + // Remap capture dimensions remain an explicit aspect contract. + const bool inferredDimension = (output.width == 0) != (output.height == 0); + const double strictAspect = inferredDimension && !output.remap.enabled() ? referenceAspect : targetAspect; + resolved->clips.reserve(sources.size()); + for (size_t i = 0; i < sources.size(); ++i) + { + const double sourceAspect = static_cast(sources[i].width) / sources[i].height; + if (output.resizeMode == RESIZE_STRICT && !aspectMatches(sourceAspect, strictAspect)) + { + std::ostringstream message; + message << "strict resize rejects source " << sources[i].width << "x" << sources[i].height << " -> " + << resolved->captureWidth << "x" << resolved->captureHeight << " aspect-ratio change"; + return fail(error, message.str()); + } + resolved->clips.push_back(output.resizeMode == RESIZE_CENTER_CROP ? centerCrop(sourceAspect, targetAspect) : + NormalizedClip()); + } + return true; +} + +} // namespace argus_sender diff --git a/examples/camera_streamer/argus_sender/src/OutputGeometry.h b/examples/camera_streamer/argus_sender/src/OutputGeometry.h new file mode 100644 index 0000000000..4f4b522541 --- /dev/null +++ b/examples/camera_streamer/argus_sender/src/OutputGeometry.h @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef ARGUS_SENDER_OUTPUT_GEOMETRY_H +#define ARGUS_SENDER_OUTPUT_GEOMETRY_H + +#include "Config.h" + +#include +#include +#include + +namespace argus_sender +{ + +struct ImageSize +{ + uint32_t width = 0; + uint32_t height = 0; +}; + +struct NormalizedClip +{ + float left = 0.0F; + float top = 0.0F; + float right = 1.0F; + float bottom = 1.0F; +}; + +struct ResolvedOutputGeometry +{ + // Wire/output dimensions (the size shipped in AVS1 stream descriptors). + uint32_t width = 0; + uint32_t height = 0; + // Argus capture (ISP output) dimensions. Equal to width/height unless the + // stream carries a sender-side remap, which captures at the remap's source + // resolution and resamples down to width x height itself. + uint32_t captureWidth = 0; + uint32_t captureHeight = 0; + std::vector clips; +}; + +bool resolveOutputGeometry(const OutputConfig& output, + const std::vector& sources, + ResolvedOutputGeometry* resolved, + std::string* error); + +} // namespace argus_sender + +#endif // ARGUS_SENDER_OUTPUT_GEOMETRY_H diff --git a/examples/camera_streamer/argus_sender/src/PriorityFrameHandoff.h b/examples/camera_streamer/argus_sender/src/PriorityFrameHandoff.h new file mode 100644 index 0000000000..c28b91c7fc --- /dev/null +++ b/examples/camera_streamer/argus_sender/src/PriorityFrameHandoff.h @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef ARGUS_SENDER_PRIORITY_FRAME_HANDOFF_H +#define ARGUS_SENDER_PRIORITY_FRAME_HANDOFF_H + +#include +#include +#include + +namespace argus_sender +{ + +constexpr uint64_t kPriorityStartupDroughtNs = 10000000000ULL; +constexpr uint64_t kPrioritySteadyDroughtNs = 1000000000ULL; + +inline bool priorityFrameDroughtExpired(uint64_t startNs, uint64_t nowNs, bool hasAcquiredFrame) +{ + const uint64_t limitNs = hasAcquiredFrame ? kPrioritySteadyDroughtNs : kPriorityStartupDroughtNs; + return nowNs >= startNs && nowNs - startNs >= limitNs; +} + +// Track progress by new frame identity, not by successful acquisition calls, +// so repeated frame numbers cannot hide a camera drought. +class PriorityFrameProgress +{ +public: + explicit PriorityFrameProgress(uint64_t startNs) : m_lastFreshFrameNs(startNs) + { + } + + void noteFreshFrame(uint64_t nowNs) + { + m_lastFreshFrameNs = nowNs; + m_hasFreshFrame = true; + } + + bool droughtExpired(uint64_t nowNs) const + { + return priorityFrameDroughtExpired(m_lastFreshFrameNs, nowNs, m_hasFreshFrame); + } + + uint64_t droughtLimitNs() const + { + return m_hasFreshFrame ? kPrioritySteadyDroughtNs : kPriorityStartupDroughtNs; + } + +private: + uint64_t m_lastFreshFrameNs; + bool m_hasFreshFrame = false; +}; + +// The caller holds the domain mutex. Returning the superseded frame lets the +// caller release its Argus buffer after unlocking. +template +class LatestFrameSlot +{ +public: + bool occupied() const + { + return static_cast(m_frame); + } + + std::unique_ptr replace(std::unique_ptr frame) + { + std::unique_ptr previous = std::move(m_frame); + m_frame = std::move(frame); + return previous; + } + + std::unique_ptr take() + { + return std::move(m_frame); + } + void reset() + { + m_frame.reset(); + } + +private: + std::unique_ptr m_frame; +}; + +} // namespace argus_sender + +#endif // ARGUS_SENDER_PRIORITY_FRAME_HANDOFF_H diff --git a/examples/camera_streamer/camera_receiver/avs_wire.py b/examples/camera_streamer/camera_receiver/avs_wire.py new file mode 100644 index 0000000000..8058afb554 --- /dev/null +++ b/examples/camera_streamer/camera_receiver/avs_wire.py @@ -0,0 +1,434 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""AVS1 wire-format parser. + +AVS1 records are framed on TCP as: + + uint32_be_record_length || avs1_record + +This module parses only the inner ``avs1_record``. The caller owns the outer +length prefix and any receive timestamps. +""" + +from __future__ import annotations + +import struct +from dataclasses import dataclass +from typing import Optional + +MAGIC = b"AVS1" +VERSION = 1 +HEADER = struct.Struct("!4sHHIHHQQQQIIQ") +STREAM = struct.Struct("!HHBBHIIIIQHHI") +SAMPLE = struct.Struct("!HBBHHIIQQQQQQII") + +BUNDLE_SENDER_CLOCK_VALID = 1 << 0 +BUNDLE_SENDER_CLOCK_TAI = 1 << 1 +BUNDLE_SENDER_TSC_VALID = 1 << 2 +BUNDLE_KNOWN_FLAGS = ( + BUNDLE_SENDER_CLOCK_VALID | BUNDLE_SENDER_CLOCK_TAI | BUNDLE_SENDER_TSC_VALID +) + +# Sensor SOF -> sender capture-ready lag accepted by ``sensor_sof_ns``. +SENSOR_SOF_MAX_LAG_NS = 500_000_000 + +STREAM_PRESENT = 1 << 0 +STREAM_STEREO = 1 << 1 +STREAM_KNOWN_FLAGS = STREAM_PRESENT | STREAM_STEREO + +SAMPLE_PRESENT = 1 << 0 +SAMPLE_SOF_TSC_VALID = 1 << 1 +SAMPLE_FRAME_TIMESTAMP_VALID = 1 << 2 +SAMPLE_SENDER_TIMING_VALID = 1 << 3 +SAMPLE_KNOWN_FLAGS = ( + SAMPLE_PRESENT + | SAMPLE_SOF_TSC_VALID + | SAMPLE_FRAME_TIMESTAMP_VALID + | SAMPLE_SENDER_TIMING_VALID +) + +CODEC_RAW_NV12 = 0 +CODEC_HEVC = 1 +PIXEL_FORMAT_NV12 = 1 +ROLE_MONO = 0 +ROLE_LEFT = 1 +ROLE_RIGHT = 2 + + +@dataclass(frozen=True) +class Avs1Sample: + stream_index: int + camera_index: int + role: int + flags: int + device_index: int + sensor_mode_index: int + frame_number: int + sof_tsc_ns: Optional[int] + frame_timestamp_ns: Optional[int] + sender_capture_ready_ns: Optional[int] + sender_process_submit_ns: Optional[int] + sender_process_done_ns: Optional[int] + payload_offset: int + payload_length: int + payload: memoryview + + @property + def present(self) -> bool: + return bool(self.flags & SAMPLE_PRESENT) + + @property + def role_name(self) -> str: + if self.role == ROLE_LEFT: + return "left" + if self.role == ROLE_RIGHT: + return "right" + return "mono" + + +@dataclass(frozen=True) +class Avs1Stream: + index: int + flags: int + codec: int + pixel_format: int + rotation: int + width: int + height: int + fps_numerator: int + fps_denominator: int + sequence: int + samples: tuple[Avs1Sample, ...] + + @property + def present(self) -> bool: + return bool(self.flags & STREAM_PRESENT) + + @property + def stereo(self) -> bool: + return bool(self.flags & STREAM_STEREO) + + @property + def codec_name(self) -> str: + if self.codec == CODEC_HEVC: + return "hevc" + if self.codec == CODEC_RAW_NV12: + return "raw_nv12" + return f"unknown:{self.codec}" + + @property + def tsc_delta_ns(self) -> Optional[int]: + if not self.stereo or len(self.samples) != 2: + return None + left = self.samples[0].sof_tsc_ns + right = self.samples[1].sof_tsc_ns + if left is None or right is None: + return None + return int(right) - int(left) + + +@dataclass(frozen=True) +class Avs1Bundle: + flags: int + sequence: int + session_id: int + sender_capture_ready_ns: Optional[int] + # Sender TSC (same domain as ``Avs1Sample.sof_tsc_ns``) read at the same + # instant as ``sender_capture_ready_ns``; None when SENDER_TSC_VALID is clear. + sender_capture_ready_tsc_ns: Optional[int] + sender_tcp_enqueue_ns: Optional[int] + sender_clock_domain: Optional[str] + streams: tuple[Avs1Stream, ...] + + +def parse_avs1_record(record: bytes | bytearray | memoryview) -> Avs1Bundle: + packet = memoryview(record) + if len(packet) < HEADER.size: + raise ValueError(f"short AVS1 record: {len(packet)} bytes") + ( + magic, + version, + flags, + header_bytes, + stream_count, + sample_count, + bundle_sequence, + session_id, + capture_ready_ns, + tcp_enqueue_ns, + descriptor_bytes, + payload_bytes, + capture_ready_tsc_ns, + ) = HEADER.unpack_from(packet) + if magic != MAGIC or version != VERSION or header_bytes != HEADER.size: + raise ValueError( + f"unsupported AVS1 header magic={magic!r} version={version} " + f"header_bytes={header_bytes}" + ) + if flags & ~BUNDLE_KNOWN_FLAGS: + raise ValueError(f"unknown AVS1 bundle flags 0x{flags:04x}") + if flags & BUNDLE_SENDER_CLOCK_TAI and not flags & BUNDLE_SENDER_CLOCK_VALID: + raise ValueError("AVS1 CLOCK_TAI flag requires valid sender timing") + if flags & BUNDLE_SENDER_TSC_VALID: + if not flags & BUNDLE_SENDER_CLOCK_VALID: + raise ValueError("AVS1 SENDER_TSC_VALID flag requires valid sender timing") + if capture_ready_tsc_ns == 0: + raise ValueError( + "AVS1 SENDER_TSC_VALID flag with zero sender_capture_ready_tsc_ns" + ) + elif capture_ready_tsc_ns != 0: + raise ValueError( + "AVS1 sender_capture_ready_tsc_ns set without SENDER_TSC_VALID flag" + ) + if stream_count == 0 or bundle_sequence == 0 or session_id == 0: + raise ValueError("invalid AVS1 bundle identity/count") + + expected_descriptors = stream_count * STREAM.size + sample_count * SAMPLE.size + if descriptor_bytes != expected_descriptors: + raise ValueError("AVS1 descriptor byte count mismatch") + expected_length = header_bytes + descriptor_bytes + payload_bytes + if expected_length != len(packet): + raise ValueError( + f"AVS1 record byte count mismatch: header says {expected_length}, " + f"record has {len(packet)}" + ) + + offset = header_bytes + stream_headers: list[tuple] = [] + flat_cursor = 0 + for expected_index in range(stream_count): + fields = STREAM.unpack_from(packet, offset) + offset += STREAM.size + ( + stream_index, + stream_flags, + codec, + pixel_format, + rotation, + width, + height, + _fps_num, + _fps_den, + stream_sequence, + first_sample, + stream_sample_count, + stream_reserved, + ) = fields + if stream_index != expected_index or stream_reserved != 0: + raise ValueError("AVS1 stream descriptors are out of config order") + if stream_flags & ~STREAM_KNOWN_FLAGS: + raise ValueError(f"unknown AVS1 stream flags 0x{stream_flags:04x}") + if codec not in (CODEC_RAW_NV12, CODEC_HEVC): + raise ValueError(f"unsupported AVS1 codec {codec}") + if pixel_format != PIXEL_FORMAT_NV12: + raise ValueError(f"unsupported AVS1 pixel format {pixel_format}") + if rotation not in (0, 180): + raise ValueError(f"unsupported AVS1 rotation {rotation}") + if width <= 0 or height <= 0 or width % 2 or height % 2: + raise ValueError("invalid AVS1 stream dimensions") + expected_sample_count = 2 if stream_flags & STREAM_STEREO else 1 + if stream_sample_count != expected_sample_count: + raise ValueError("AVS1 stream sample count does not match mono/stereo") + if first_sample != flat_cursor: + raise ValueError("AVS1 stream sample range is not contiguous") + flat_cursor += stream_sample_count + if flat_cursor > sample_count: + raise ValueError("AVS1 stream sample range exceeds descriptor table") + if stream_flags & STREAM_PRESENT: + if stream_sequence == 0: + raise ValueError("present AVS1 stream has zero stream_sequence") + elif stream_sequence != 0: + raise ValueError("absent AVS1 stream has nonzero stream_sequence") + stream_headers.append(fields) + + if flat_cursor != sample_count: + raise ValueError("AVS1 sample descriptors are not fully referenced") + + raw_samples = [] + for _flat_index in range(sample_count): + fields = SAMPLE.unpack_from(packet, offset) + offset += SAMPLE.size + if fields[4] != 0: + raise ValueError("AVS1 sample reserved field is nonzero") + raw_samples.append(fields) + + payload_start = header_bytes + descriptor_bytes + streams: list[Avs1Stream] = [] + consumed_ranges: list[tuple[int, int]] = [] + for stream_index, fields in enumerate(stream_headers): + ( + _, + stream_flags, + codec, + pixel_format, + rotation, + width, + height, + fps_num, + fps_den, + stream_sequence, + first_sample, + stream_sample_count, + _reserved, + ) = fields + if fps_den == 0: + raise ValueError("AVS1 fps denominator must be nonzero") + stream_present = bool(stream_flags & STREAM_PRESENT) + samples: list[Avs1Sample] = [] + for camera_index in range(stream_sample_count): + sample_fields = raw_samples[first_sample + camera_index] + ( + sample_stream, + sample_camera, + role, + sample_flags, + _sample_reserved, + device_index, + mode_index, + frame_number, + sof_tsc_ns, + frame_timestamp_ns, + sample_capture_ready_ns, + process_submit_ns, + process_done_ns, + payload_offset, + payload_length, + ) = sample_fields + if sample_stream != stream_index or sample_camera != camera_index: + raise ValueError("AVS1 sample descriptors are out of config order") + expected_role = ( + (ROLE_LEFT if camera_index == 0 else ROLE_RIGHT) + if stream_flags & STREAM_STEREO + else ROLE_MONO + ) + if role != expected_role: + raise ValueError("invalid AVS1 sample role") + if sample_flags & ~SAMPLE_KNOWN_FLAGS: + raise ValueError(f"unknown AVS1 sample flags 0x{sample_flags:04x}") + sample_present = bool(sample_flags & SAMPLE_PRESENT) + if sample_present != stream_present: + raise ValueError("AVS1 stream/sample presence is not atomic") + if payload_offset + payload_length > payload_bytes: + raise ValueError("AVS1 sample payload exceeds payload area") + if sample_present and payload_length == 0: + raise ValueError("present AVS1 sample has empty payload") + if not sample_present and payload_length != 0: + raise ValueError("absent AVS1 sample has payload") + if codec == CODEC_RAW_NV12 and sample_present: + expected_raw = width * height * 3 // 2 + if payload_length != expected_raw: + raise ValueError( + f"raw AVS1 payload has {payload_length} bytes, " + f"expected {expected_raw}" + ) + begin = payload_start + payload_offset + end = begin + payload_length + if payload_length: + consumed_ranges.append( + (payload_offset, payload_offset + payload_length) + ) + sender_timing_valid = bool(sample_flags & SAMPLE_SENDER_TIMING_VALID) + samples.append( + Avs1Sample( + stream_index=stream_index, + camera_index=camera_index, + role=role, + flags=sample_flags, + device_index=device_index, + sensor_mode_index=mode_index, + frame_number=frame_number, + sof_tsc_ns=( + int(sof_tsc_ns) if sample_flags & SAMPLE_SOF_TSC_VALID else None + ), + frame_timestamp_ns=( + int(frame_timestamp_ns) + if sample_flags & SAMPLE_FRAME_TIMESTAMP_VALID + else None + ), + sender_capture_ready_ns=( + int(sample_capture_ready_ns) if sender_timing_valid else None + ), + sender_process_submit_ns=( + int(process_submit_ns) if sender_timing_valid else None + ), + sender_process_done_ns=( + int(process_done_ns) if sender_timing_valid else None + ), + payload_offset=int(payload_offset), + payload_length=int(payload_length), + payload=packet[begin:end], + ) + ) + streams.append( + Avs1Stream( + index=stream_index, + flags=stream_flags, + codec=codec, + pixel_format=pixel_format, + rotation=rotation, + width=width, + height=height, + fps_numerator=fps_num, + fps_denominator=fps_den, + sequence=stream_sequence, + samples=tuple(samples), + ) + ) + + expected_offset = 0 + for begin, end in sorted(consumed_ranges): + if begin != expected_offset: + raise ValueError("AVS1 payload ranges overlap or have gaps") + expected_offset = end + if expected_offset != payload_bytes: + raise ValueError("AVS1 payload descriptors do not cover payload area") + + sender_clock_domain = None + if flags & BUNDLE_SENDER_CLOCK_VALID: + sender_clock_domain = "tai" if flags & BUNDLE_SENDER_CLOCK_TAI else "realtime" + + return Avs1Bundle( + flags=flags, + sequence=int(bundle_sequence), + session_id=int(session_id), + sender_capture_ready_ns=( + int(capture_ready_ns) if sender_clock_domain is not None else None + ), + sender_capture_ready_tsc_ns=( + int(capture_ready_tsc_ns) if flags & BUNDLE_SENDER_TSC_VALID else None + ), + sender_tcp_enqueue_ns=( + int(tcp_enqueue_ns) if sender_clock_domain is not None else None + ), + sender_clock_domain=sender_clock_domain, + streams=tuple(streams), + ) + + +def sensor_sof_lag_ns(bundle: Avs1Bundle, sample: Avs1Sample) -> Optional[int]: + """Sensor SOF -> sender capture-ready lag in the sender TSC domain. + + Returns the raw (possibly out-of-range) lag so callers can report it as a + diagnostic; ``sensor_sof_ns`` applies the validity window. + """ + if bundle.sender_capture_ready_tsc_ns is None or sample.sof_tsc_ns is None: + return None + return int(bundle.sender_capture_ready_tsc_ns) - int(sample.sof_tsc_ns) + + +def sensor_sof_ns(bundle: Avs1Bundle, sample: Avs1Sample) -> Optional[int]: + """Sensor start-of-frame of ``sample`` in the sender wall-clock domain. + + sensor_sof_ns = sender_capture_ready_ns + - (sender_capture_ready_tsc_ns - sof_tsc_ns) + + Only defined when the bundle carries both the wall and TSC capture-ready + stamps, the sample carries its SOF TSC, and the lag lies within + ``[0, SENSOR_SOF_MAX_LAG_NS]``; otherwise None. + """ + if bundle.sender_capture_ready_ns is None: + return None + lag_ns = sensor_sof_lag_ns(bundle, sample) + if lag_ns is None or lag_ns < 0 or lag_ns > SENSOR_SOF_MAX_LAG_NS: + return None + return int(bundle.sender_capture_ready_ns) - lag_ns diff --git a/examples/camera_streamer/configs/s56-mono-1080p30.yaml b/examples/camera_streamer/configs/s56-mono-1080p30.yaml new file mode 100644 index 0000000000..eb8a2f51e6 --- /dev/null +++ b/examples/camera_streamer/configs/s56-mono-1080p30.yaml @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +version: 1 +network: + bind: 0.0.0.0 + port: 5000 + reconnect_ms: 1000 +runtime: + frames: 0 + kpi: true + log_interval_s: 5 +alignment: + warmup_frames: 30 + interval_frames: 30 +streams: +- id: head + type: mono + cameras: + - device: 0 + mode: 0 + output: + width: 1920 + height: 1080 + fps: 30 + codec: hevc + rotation: 0 + resize_mode: strict + bitrate_mbps: 30 + gop: 30 + receiver: + output_format: nv12 + ring_depth: 6 + ipc: + mono: head +receiver: + host: 127.0.0.1 + gpu_id: 0 + max_record_bytes: 268435456 + stats_log_interval_s: 5 + receive_fps_warning_threshold: 24.0 + receive_fps_warning_interval_s: 10 +ipc: + socket_path: /tmp/isaac-camera-streamer.sock + owner_name: isaac_camera_streamer + queue_size: 64 diff --git a/examples/camera_streamer/configs/s56-mono-1080p60.yaml b/examples/camera_streamer/configs/s56-mono-1080p60.yaml new file mode 100644 index 0000000000..f919f9b023 --- /dev/null +++ b/examples/camera_streamer/configs/s56-mono-1080p60.yaml @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +version: 1 +network: + bind: 0.0.0.0 + port: 5000 + reconnect_ms: 1000 +runtime: + frames: 0 + kpi: true + log_interval_s: 5 +alignment: + warmup_frames: 30 + interval_frames: 30 +streams: +- id: head + type: mono + cameras: + - device: 0 + mode: 0 + output: + width: 1920 + height: 1080 + fps: 60 + codec: hevc + rotation: 0 + resize_mode: strict + bitrate_mbps: 30 + gop: 60 + receiver: + output_format: nv12 + ring_depth: 6 + ipc: + mono: head +receiver: + host: 127.0.0.1 + gpu_id: 0 + max_record_bytes: 268435456 + stats_log_interval_s: 5 + receive_fps_warning_threshold: 48.0 + receive_fps_warning_interval_s: 10 +ipc: + socket_path: /tmp/isaac-camera-streamer.sock + owner_name: isaac_camera_streamer + queue_size: 64 diff --git a/examples/camera_streamer/configs/s56-mono-2400x1440-30.yaml b/examples/camera_streamer/configs/s56-mono-2400x1440-30.yaml new file mode 100644 index 0000000000..68e6049141 --- /dev/null +++ b/examples/camera_streamer/configs/s56-mono-2400x1440-30.yaml @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +version: 1 +network: + bind: 0.0.0.0 + port: 5000 + reconnect_ms: 1000 +runtime: + frames: 0 + kpi: true + log_interval_s: 5 +alignment: + warmup_frames: 30 + interval_frames: 30 +streams: +- id: head + type: mono + cameras: + - device: 0 + mode: 1 + output: + width: 2400 + height: 1440 + fps: 30 + codec: hevc + rotation: 0 + resize_mode: center_crop + bitrate_mbps: 30 + gop: 30 + receiver: + output_format: nv12 + ring_depth: 6 + ipc: + mono: head +receiver: + host: 127.0.0.1 + gpu_id: 0 + max_record_bytes: 268435456 + stats_log_interval_s: 5 + receive_fps_warning_threshold: 24.0 + receive_fps_warning_interval_s: 10 +ipc: + socket_path: /tmp/isaac-camera-streamer.sock + owner_name: isaac_camera_streamer + queue_size: 64 diff --git a/examples/camera_streamer/configs/s56-mono-2560x1984-30.yaml b/examples/camera_streamer/configs/s56-mono-2560x1984-30.yaml new file mode 100644 index 0000000000..7ea8622fb8 --- /dev/null +++ b/examples/camera_streamer/configs/s56-mono-2560x1984-30.yaml @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +version: 1 +network: + bind: 0.0.0.0 + port: 5000 + reconnect_ms: 1000 +runtime: + frames: 0 + kpi: true + log_interval_s: 5 +alignment: + warmup_frames: 30 + interval_frames: 30 +streams: +- id: head + type: mono + cameras: + - device: 0 + mode: 1 + output: + width: 2560 + height: 1984 + fps: 30 + codec: hevc + rotation: 0 + resize_mode: strict + bitrate_mbps: 30 + gop: 30 + receiver: + output_format: nv12 + ring_depth: 6 + ipc: + mono: head +receiver: + host: 127.0.0.1 + gpu_id: 0 + max_record_bytes: 268435456 + stats_log_interval_s: 5 + receive_fps_warning_threshold: 24.0 + receive_fps_warning_interval_s: 10 +ipc: + socket_path: /tmp/isaac-camera-streamer.sock + owner_name: isaac_camera_streamer + queue_size: 64 diff --git a/examples/camera_streamer/configs/s56-mono-720p30.yaml b/examples/camera_streamer/configs/s56-mono-720p30.yaml new file mode 100644 index 0000000000..bb1b19ddd5 --- /dev/null +++ b/examples/camera_streamer/configs/s56-mono-720p30.yaml @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +version: 1 +network: + bind: 0.0.0.0 + port: 5000 + reconnect_ms: 1000 +runtime: + frames: 0 + kpi: true + log_interval_s: 5 +alignment: + warmup_frames: 30 + interval_frames: 30 +streams: +- id: head + type: mono + cameras: + - device: 0 + mode: 0 + output: + width: 1280 + height: 720 + fps: 30 + codec: hevc + rotation: 0 + resize_mode: strict + bitrate_mbps: 30 + gop: 30 + receiver: + output_format: nv12 + ring_depth: 6 + ipc: + mono: head +receiver: + host: 127.0.0.1 + gpu_id: 0 + max_record_bytes: 268435456 + stats_log_interval_s: 5 + receive_fps_warning_threshold: 24.0 + receive_fps_warning_interval_s: 10 +ipc: + socket_path: /tmp/isaac-camera-streamer.sock + owner_name: isaac_camera_streamer + queue_size: 64 diff --git a/examples/camera_streamer/configs/s56-mono-720p60.yaml b/examples/camera_streamer/configs/s56-mono-720p60.yaml new file mode 100644 index 0000000000..49093fcfb4 --- /dev/null +++ b/examples/camera_streamer/configs/s56-mono-720p60.yaml @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +version: 1 +network: + bind: 0.0.0.0 + port: 5000 + reconnect_ms: 1000 +runtime: + frames: 0 + kpi: true + log_interval_s: 5 +alignment: + warmup_frames: 30 + interval_frames: 30 +streams: +- id: head + type: mono + cameras: + - device: 0 + mode: 0 + output: + width: 1280 + height: 720 + fps: 60 + codec: hevc + rotation: 0 + resize_mode: strict + bitrate_mbps: 30 + gop: 60 + receiver: + output_format: nv12 + ring_depth: 6 + ipc: + mono: head +receiver: + host: 127.0.0.1 + gpu_id: 0 + max_record_bytes: 268435456 + stats_log_interval_s: 5 + receive_fps_warning_threshold: 48.0 + receive_fps_warning_interval_s: 10 +ipc: + socket_path: /tmp/isaac-camera-streamer.sock + owner_name: isaac_camera_streamer + queue_size: 64 diff --git a/examples/camera_streamer/configs/s56-stereo-1080p60.yaml b/examples/camera_streamer/configs/s56-stereo-1080p60.yaml new file mode 100644 index 0000000000..7ea6950a36 --- /dev/null +++ b/examples/camera_streamer/configs/s56-stereo-1080p60.yaml @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +version: 1 +network: + bind: 0.0.0.0 + port: 5000 + reconnect_ms: 1000 +runtime: + frames: 0 + kpi: true + log_interval_s: 5 +alignment: + warmup_frames: 30 + interval_frames: 30 +streams: +- id: head + type: stereo + cameras: + - device: 0 + mode: 0 + - device: 1 + mode: 0 + output: + width: 1920 + height: 1080 + fps: 60 + codec: hevc + rotation: 0 + resize_mode: strict + bitrate_mbps: 30 + gop: 60 + receiver: + output_format: nv12 + ring_depth: 6 + ipc: + left: head.left + right: head.right +receiver: + host: 127.0.0.1 + gpu_id: 0 + max_record_bytes: 268435456 + stats_log_interval_s: 5 + receive_fps_warning_threshold: 48.0 + receive_fps_warning_interval_s: 10 +ipc: + socket_path: /tmp/isaac-camera-streamer.sock + owner_name: isaac_camera_streamer + queue_size: 64 diff --git a/examples/camera_streamer/configs/s56-stereo-2560x1984-30.yaml b/examples/camera_streamer/configs/s56-stereo-2560x1984-30.yaml new file mode 100644 index 0000000000..198861ba14 --- /dev/null +++ b/examples/camera_streamer/configs/s56-stereo-2560x1984-30.yaml @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +version: 1 +network: + bind: 0.0.0.0 + port: 5000 + reconnect_ms: 1000 +runtime: + frames: 0 + kpi: true + log_interval_s: 5 +alignment: + warmup_frames: 30 + interval_frames: 30 +streams: +- id: head + type: stereo + cameras: + - device: 0 + mode: 1 + - device: 1 + mode: 1 + output: + width: 2560 + height: 1984 + fps: 30 + codec: hevc + rotation: 0 + resize_mode: strict + bitrate_mbps: 30 + gop: 30 + receiver: + output_format: nv12 + ring_depth: 6 + ipc: + left: head.left + right: head.right +receiver: + host: 127.0.0.1 + gpu_id: 0 + max_record_bytes: 268435456 + stats_log_interval_s: 5 + receive_fps_warning_threshold: 25 + receive_fps_warning_interval_s: 10 +ipc: + socket_path: /tmp/isaac-camera-streamer.sock + owner_name: isaac_camera_streamer + queue_size: 64 diff --git a/examples/camera_streamer/configs/shw5g-mono-2400x1440-60.yaml b/examples/camera_streamer/configs/shw5g-mono-2400x1440-60.yaml new file mode 100644 index 0000000000..e1528b72c3 --- /dev/null +++ b/examples/camera_streamer/configs/shw5g-mono-2400x1440-60.yaml @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# Replace mode using this SHW5G driver's --list-modes output. +# Sensor modes are platform-specific; this profile intentionally requires editing. +version: 1 +network: + bind: 0.0.0.0 + port: 5000 + reconnect_ms: 1000 +runtime: + frames: 0 + kpi: true + log_interval_s: 5 +alignment: + warmup_frames: 30 + interval_frames: 30 +streams: +- id: head + type: mono + cameras: + - device: 0 + mode: SET_FROM_LIST_MODES + output: + width: 2400 + height: 1440 + fps: 60 + codec: hevc + rotation: 0 + resize_mode: strict + bitrate_mbps: 30 + gop: 60 + receiver: + output_format: nv12 + ring_depth: 6 + ipc: + mono: head +receiver: + host: 127.0.0.1 + gpu_id: 0 + max_record_bytes: 268435456 + stats_log_interval_s: 5 + receive_fps_warning_threshold: 48.0 + receive_fps_warning_interval_s: 10 +ipc: + socket_path: /tmp/isaac-camera-streamer.sock + owner_name: isaac_camera_streamer + queue_size: 64 diff --git a/examples/camera_streamer/configs/shw5g-mono-2560x1984-30.yaml b/examples/camera_streamer/configs/shw5g-mono-2560x1984-30.yaml new file mode 100644 index 0000000000..4284943abc --- /dev/null +++ b/examples/camera_streamer/configs/shw5g-mono-2560x1984-30.yaml @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# Replace mode using this SHW5G driver's --list-modes output. +# Sensor modes are platform-specific; this profile intentionally requires editing. +version: 1 +network: + bind: 0.0.0.0 + port: 5000 + reconnect_ms: 1000 +runtime: + frames: 0 + kpi: true + log_interval_s: 5 +alignment: + warmup_frames: 30 + interval_frames: 30 +streams: +- id: head + type: mono + cameras: + - device: 0 + mode: SET_FROM_LIST_MODES + output: + width: 2560 + height: 1984 + fps: 30 + codec: hevc + rotation: 0 + resize_mode: strict + bitrate_mbps: 30 + gop: 30 + receiver: + output_format: nv12 + ring_depth: 6 + ipc: + mono: head +receiver: + host: 127.0.0.1 + gpu_id: 0 + max_record_bytes: 268435456 + stats_log_interval_s: 5 + receive_fps_warning_threshold: 24.0 + receive_fps_warning_interval_s: 10 +ipc: + socket_path: /tmp/isaac-camera-streamer.sock + owner_name: isaac_camera_streamer + queue_size: 64 diff --git a/examples/camera_streamer/tests/cpp/TestHarness.h b/examples/camera_streamer/tests/cpp/TestHarness.h new file mode 100644 index 0000000000..c51c30c736 --- /dev/null +++ b/examples/camera_streamer/tests/cpp/TestHarness.h @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef ARGUS_SENDER_TEST_HARNESS_H +#define ARGUS_SENDER_TEST_HARNESS_H + +#include +#include +#include + +class TestContext +{ +public: + explicit TestContext(const char* name) : m_name(name ? name : "test"), m_failures(0) + { + } + + void check(bool condition, const char* expression, const char* file, int line) + { + if (condition) + return; + ++m_failures; + std::fprintf(stderr, "%s:%d: CHECK failed: %s\n", file, line, expression); + } + + template + void checkEqual(const Left& left, + const Right& right, + const char* leftExpression, + const char* rightExpression, + const char* file, + int line) + { + if (left == right) + return; + ++m_failures; + std::ostringstream leftValue; + std::ostringstream rightValue; + leftValue << left; + rightValue << right; + std::fprintf(stderr, "%s:%d: CHECK_EQ failed: %s=%s, %s=%s\n", file, line, leftExpression, + leftValue.str().c_str(), rightExpression, rightValue.str().c_str()); + } + + int finish() const + { + if (m_failures == 0) + { + std::printf("PASS: %s\n", m_name.c_str()); + return 0; + } + std::fprintf(stderr, "FAIL: %s (%d assertion%s)\n", m_name.c_str(), m_failures, m_failures == 1 ? "" : "s"); + return 1; + } + +private: + std::string m_name; + int m_failures; +}; + +#define TEST_CHECK(context, expression) (context).check((expression), #expression, __FILE__, __LINE__) + +#define TEST_CHECK_EQ(context, left, right) (context).checkEqual((left), (right), #left, #right, __FILE__, __LINE__) + +#endif // ARGUS_SENDER_TEST_HARNESS_H diff --git a/examples/camera_streamer/tests/cpp/avs1_wire/CMakeLists.txt b/examples/camera_streamer/tests/cpp/avs1_wire/CMakeLists.txt new file mode 100644 index 0000000000..62ff559bca --- /dev/null +++ b/examples/camera_streamer/tests/cpp/avs1_wire/CMakeLists.txt @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +add_executable(argus_sender_test_avs1_wire + ${CAMERA_STREAMER_TEST_ROOT}/cpp/test_avs1_wire.cpp + ${ARGUS_SENDER_ROOT}/src/Avs1Wire.cpp +) +argus_sender_add_cpp_test(argus_sender_test_avs1_wire avs1_wire) diff --git a/examples/camera_streamer/tests/cpp/capture_domain_plan/CMakeLists.txt b/examples/camera_streamer/tests/cpp/capture_domain_plan/CMakeLists.txt new file mode 100644 index 0000000000..913efccc8c --- /dev/null +++ b/examples/camera_streamer/tests/cpp/capture_domain_plan/CMakeLists.txt @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +add_executable(argus_sender_test_capture_domain_plan + ${CAMERA_STREAMER_TEST_ROOT}/cpp/test_capture_domain_plan.cpp + ${ARGUS_SENDER_ROOT}/src/CaptureDomainPlan.cpp +) +argus_sender_add_cpp_test(argus_sender_test_capture_domain_plan capture_domain_plan) diff --git a/examples/camera_streamer/tests/cpp/capture_priority_policy/CMakeLists.txt b/examples/camera_streamer/tests/cpp/capture_priority_policy/CMakeLists.txt new file mode 100644 index 0000000000..abc235c9dd --- /dev/null +++ b/examples/camera_streamer/tests/cpp/capture_priority_policy/CMakeLists.txt @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +add_executable(argus_sender_test_capture_priority_policy + ${CAMERA_STREAMER_TEST_ROOT}/cpp/test_capture_priority_policy.cpp + ${ARGUS_SENDER_ROOT}/src/CapturePriorityPolicy.cpp +) +argus_sender_add_cpp_test(argus_sender_test_capture_priority_policy capture_priority_policy) diff --git a/examples/camera_streamer/tests/cpp/config/CMakeLists.txt b/examples/camera_streamer/tests/cpp/config/CMakeLists.txt new file mode 100644 index 0000000000..9b80ade13c --- /dev/null +++ b/examples/camera_streamer/tests/cpp/config/CMakeLists.txt @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +add_executable(argus_sender_test_config + ${CAMERA_STREAMER_TEST_ROOT}/cpp/test_config.cpp + ${ARGUS_SENDER_ROOT}/src/Config.cpp +) +argus_sender_add_cpp_test(argus_sender_test_config config) +target_link_libraries(argus_sender_test_config PRIVATE yaml-cpp::yaml-cpp) +target_compile_definitions(argus_sender_test_config PRIVATE + CAMERA_STREAMER_CONFIG_DIR="${CAMERA_STREAMER_ROOT}/configs" +) diff --git a/examples/camera_streamer/tests/cpp/frame_alignment_policy/CMakeLists.txt b/examples/camera_streamer/tests/cpp/frame_alignment_policy/CMakeLists.txt new file mode 100644 index 0000000000..154af9c368 --- /dev/null +++ b/examples/camera_streamer/tests/cpp/frame_alignment_policy/CMakeLists.txt @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +add_executable(argus_sender_test_frame_alignment_policy + ${CAMERA_STREAMER_TEST_ROOT}/cpp/test_frame_alignment_policy.cpp + ${ARGUS_SENDER_ROOT}/src/FrameAlignmentPolicy.cpp +) +argus_sender_add_cpp_test(argus_sender_test_frame_alignment_policy frame_alignment_policy) diff --git a/examples/camera_streamer/tests/cpp/geometry_scheduler/CMakeLists.txt b/examples/camera_streamer/tests/cpp/geometry_scheduler/CMakeLists.txt new file mode 100644 index 0000000000..3dc4b95f08 --- /dev/null +++ b/examples/camera_streamer/tests/cpp/geometry_scheduler/CMakeLists.txt @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +add_executable(argus_sender_test_geometry_scheduler + ${CAMERA_STREAMER_TEST_ROOT}/cpp/test_geometry_scheduler.cpp + ${ARGUS_SENDER_ROOT}/src/OutputGeometry.cpp + ${ARGUS_SENDER_ROOT}/src/MixedRateScheduler.cpp +) +argus_sender_add_cpp_test(argus_sender_test_geometry_scheduler geometry_scheduler) diff --git a/examples/camera_streamer/tests/cpp/priority_frame_handoff/CMakeLists.txt b/examples/camera_streamer/tests/cpp/priority_frame_handoff/CMakeLists.txt new file mode 100644 index 0000000000..67d3a4554b --- /dev/null +++ b/examples/camera_streamer/tests/cpp/priority_frame_handoff/CMakeLists.txt @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +add_executable(argus_sender_test_priority_frame_handoff + ${CAMERA_STREAMER_TEST_ROOT}/cpp/test_priority_frame_handoff.cpp +) +argus_sender_add_cpp_test(argus_sender_test_priority_frame_handoff priority_frame_handoff) diff --git a/examples/camera_streamer/tests/cpp/test_avs1_wire.cpp b/examples/camera_streamer/tests/cpp/test_avs1_wire.cpp new file mode 100644 index 0000000000..8216cd4926 --- /dev/null +++ b/examples/camera_streamer/tests/cpp/test_avs1_wire.cpp @@ -0,0 +1,256 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "Avs1Wire.h" +#include "TestHarness.h" + +#include +#include + +namespace +{ + +argus_sender::Avs1SampleRecord sample(uint8_t camera, uint8_t role, uint32_t device, const std::vector& payload) +{ + argus_sender::Avs1SampleRecord value; + value.flags = argus_sender::AVS1_SAMPLE_PRESENT | argus_sender::AVS1_SAMPLE_SOF_TSC_VALID | + argus_sender::AVS1_SAMPLE_FRAME_TIMESTAMP_VALID | argus_sender::AVS1_SAMPLE_SENDER_TIMING_VALID; + value.cameraIndex = camera; + value.role = role; + value.deviceIndex = device; + value.sensorModeIndex = 2; + value.frameNumber = 100 + camera; + value.sofTscNs = 1000000 + camera * 7; + value.frameTimestampNs = 2000000 + camera * 9; + value.senderCaptureReadyNs = 3000000; + value.senderProcessSubmitNs = 3000100; + value.senderProcessDoneNs = 3000200 + camera; + value.payload = payload; + return value; +} + +argus_sender::Avs1Bundle mixedBundle() +{ + argus_sender::Avs1Bundle bundle; + bundle.flags = argus_sender::AVS1_BUNDLE_SENDER_CLOCK_VALID | argus_sender::AVS1_BUNDLE_SENDER_TSC_VALID; + bundle.sequence = 9; + bundle.sessionId = 0x1122334455667788ULL; + bundle.senderCaptureReadyNs = 3000000; + bundle.senderTcpEnqueueNs = 3000300; + bundle.senderCaptureReadyTscNs = 1234567; + + argus_sender::Avs1StreamRecord stereo; + stereo.flags = argus_sender::AVS1_STREAM_PRESENT | argus_sender::AVS1_STREAM_STEREO; + stereo.codec = argus_sender::AVS1_CODEC_HEVC; + stereo.width = 1280; + stereo.height = 720; + stereo.fpsNumerator = 60; + stereo.sequence = 9; + stereo.samples.push_back(sample(0, argus_sender::AVS1_ROLE_LEFT, 0, { 0x00, 0x00, 0x01 })); + stereo.samples.push_back(sample(1, argus_sender::AVS1_ROLE_RIGHT, 1, { 0x26, 0x01 })); + bundle.streams.push_back(stereo); + + argus_sender::Avs1StreamRecord mono; + mono.codec = argus_sender::AVS1_CODEC_RAW; + mono.width = 4; + mono.height = 2; + mono.fpsNumerator = 30; + mono.samples.resize(1); + mono.samples[0].cameraIndex = 0; + mono.samples[0].role = argus_sender::AVS1_ROLE_MONO; + mono.samples[0].deviceIndex = 2; + bundle.streams.push_back(mono); + return bundle; +} + +void testStreamValidation(TestContext& test) +{ + const char* error = nullptr; + argus_sender::Avs1Bundle parsed; + std::vector wire; + for (uint16_t rotation : { 0, 180 }) + { + auto bundle = mixedBundle(); + bundle.streams[0].rotation = rotation; + TEST_CHECK(test, argus_sender::buildAvs1WireRecord(bundle, &wire, &error)); + TEST_CHECK(test, argus_sender::parseAvs1WireRecord(wire.data(), wire.size(), &parsed, &error)); + } + auto invalid = mixedBundle(); + invalid.streams[0].rotation = 90; + TEST_CHECK(test, !argus_sender::buildAvs1WireRecord(invalid, &wire, &error)); + for (size_t stream = 0; stream < 2; ++stream) + { + invalid = mixedBundle(); + invalid.streams[stream].sequence = stream == 0 ? 0 : 1; + TEST_CHECK(test, !argus_sender::buildAvs1WireRecord(invalid, &wire, &error)); + } + + std::vector valid; + TEST_CHECK(test, argus_sender::buildAvs1WireRecord(mixedBundle(), &valid, &error)); + const size_t firstStream = argus_sender::kAvs1LengthPrefixBytes + argus_sender::kAvs1HeaderBytes; + wire = valid; + wire[firstStream + 7] = 90; + TEST_CHECK(test, !argus_sender::parseAvs1WireRecord(wire.data(), wire.size(), &parsed, &error)); + wire = valid; + wire[firstStream + 31] = 0; + TEST_CHECK(test, !argus_sender::parseAvs1WireRecord(wire.data(), wire.size(), &parsed, &error)); + wire = valid; + wire[firstStream + argus_sender::kAvs1StreamDescriptorBytes + 31] = 1; + TEST_CHECK(test, !argus_sender::parseAvs1WireRecord(wire.data(), wire.size(), &parsed, &error)); +} + +void testBundleValidation(TestContext& test) +{ + const char* error = nullptr; + argus_sender::Avs1Bundle parsed; + auto bundle = mixedBundle(); + bundle.flags = argus_sender::AVS1_BUNDLE_SENDER_CLOCK_VALID; + bundle.senderCaptureReadyTscNs = 0; + std::vector valid; + TEST_CHECK(test, argus_sender::buildAvs1WireRecord(bundle, &valid, &error)); + // Mutate the on-wire fields independently of the builder's checks. + for (uint8_t flags : { 0x80, 0x02 }) + { + auto wire = valid; + wire[4 + 7] = flags; + TEST_CHECK(test, !argus_sender::parseAvs1WireRecord(wire.data(), wire.size(), &parsed, &error)); + } + for (size_t field : { 16, 24 }) + { + auto wire = valid; + for (size_t byte = 0; byte < 8; ++byte) + wire[4 + field + byte] = 0; + TEST_CHECK(test, !argus_sender::parseAvs1WireRecord(wire.data(), wire.size(), &parsed, &error)); + } +} + +void testPayloadCoverage(TestContext& test) +{ + const char* error = nullptr; + argus_sender::Avs1Bundle parsed; + std::vector valid; + TEST_CHECK(test, argus_sender::buildAvs1WireRecord(mixedBundle(), &valid, &error)); + const size_t secondSample = argus_sender::kAvs1LengthPrefixBytes + argus_sender::kAvs1HeaderBytes + + 2 * argus_sender::kAvs1StreamDescriptorBytes + argus_sender::kAvs1SampleDescriptorBytes; + // Two nonempty ranges still sum to five bytes when the second overlaps the first. + for (uint8_t offset : { 0, 2, 4 }) + { + auto wire = valid; + wire[secondSample + 67] = offset; + TEST_CHECK(test, !argus_sender::parseAvs1WireRecord(wire.data(), wire.size(), &parsed, &error)); + } +} + +} // namespace + +int main() +{ + TestContext test("avs1_wire"); + testStreamValidation(test); + testBundleValidation(test); + testPayloadCoverage(test); + { + const char* descriptorError = nullptr; + std::vector validRecord; + argus_sender::Avs1Bundle result; + TEST_CHECK(test, argus_sender::buildAvs1WireRecord(mixedBundle(), &validRecord, &descriptorError)); + const size_t firstStream = argus_sender::kAvs1LengthPrefixBytes + argus_sender::kAvs1HeaderBytes; + // Oversized stereo count and overlapping mono range must fail at the descriptor, + // before allocating sample vectors or reading sample payloads. + for (size_t field : { firstStream + 35, firstStream + argus_sender::kAvs1StreamDescriptorBytes + 33 }) + { + auto malformed = validRecord; + malformed[field] = field == firstStream + 35 ? 3 : 0; + TEST_CHECK( + test, !argus_sender::parseAvs1WireRecord(malformed.data(), malformed.size(), &result, &descriptorError)); + TEST_CHECK_EQ(test, std::string(descriptorError), std::string("invalid AVS1 stream descriptor index")); + } + } + argus_sender::Avs1Bundle input = mixedBundle(); + std::vector wire; + const char* error = 0; + TEST_CHECK(test, argus_sender::buildAvs1WireRecord(input, &wire, &error)); + TEST_CHECK_EQ(test, wire.size(), static_cast(369)); + TEST_CHECK_EQ(test, wire[0], static_cast(0)); + TEST_CHECK_EQ(test, wire[1], static_cast(0)); + TEST_CHECK_EQ(test, wire[2], static_cast(1)); + TEST_CHECK_EQ(test, wire[3], static_cast(109)); + TEST_CHECK_EQ(test, wire[4], static_cast('A')); + TEST_CHECK_EQ(test, wire[7], static_cast('1')); + + argus_sender::Avs1Bundle parsed; + TEST_CHECK(test, argus_sender::parseAvs1WireRecord(wire.data(), wire.size(), &parsed, &error)); + TEST_CHECK_EQ(test, parsed.sequence, 9ULL); + TEST_CHECK_EQ(test, parsed.streams.size(), static_cast(2)); + TEST_CHECK_EQ(test, parsed.streams[0].samples[1].sofTscNs, 1000007ULL); + TEST_CHECK_EQ(test, parsed.streams[0].samples[0].payload.size(), static_cast(3)); + TEST_CHECK(test, !(parsed.streams[1].flags & argus_sender::AVS1_STREAM_PRESENT)); + TEST_CHECK(test, parsed.streams[1].samples[0].payload.empty()); + // Header offset 56 carries sender_capture_ready_tsc_ns (1234567 = 0x12D687). + TEST_CHECK_EQ(test, wire[4 + 56 + 4], static_cast(0x00)); + TEST_CHECK_EQ(test, wire[4 + 56 + 5], static_cast(0x12)); + TEST_CHECK_EQ(test, wire[4 + 56 + 6], static_cast(0xD6)); + TEST_CHECK_EQ(test, wire[4 + 56 + 7], static_cast(0x87)); + TEST_CHECK_EQ(test, parsed.flags, + static_cast(argus_sender::AVS1_BUNDLE_SENDER_CLOCK_VALID | + argus_sender::AVS1_BUNDLE_SENDER_TSC_VALID)); + TEST_CHECK_EQ(test, parsed.senderCaptureReadyTscNs, 1234567ULL); + + argus_sender::Avs1Bundle raw = mixedBundle(); + raw.streams.erase(raw.streams.begin()); + raw.streams[0].flags = argus_sender::AVS1_STREAM_PRESENT; + raw.streams[0].sequence = 1; + raw.streams[0].samples[0] = sample(0, argus_sender::AVS1_ROLE_MONO, 2, std::vector(12, 0x80)); + TEST_CHECK(test, argus_sender::buildAvs1WireRecord(raw, &wire, &error)); + TEST_CHECK(test, argus_sender::parseAvs1WireRecord(wire.data(), wire.size(), &parsed, &error)); + TEST_CHECK_EQ(test, parsed.streams[0].samples[0].payload.size(), static_cast(12)); + + wire[4] = 'X'; + TEST_CHECK(test, !argus_sender::parseAvs1WireRecord(wire.data(), wire.size(), &parsed, &error)); + + argus_sender::Avs1Bundle invalid = mixedBundle(); + invalid.streams[0].samples[1].payload.clear(); + TEST_CHECK(test, !argus_sender::buildAvs1WireRecord(invalid, &wire, &error)); + + // Without SENDER_TSC_VALID offset 56 stays the legacy reserved zero. + argus_sender::Avs1Bundle legacy = mixedBundle(); + legacy.flags = argus_sender::AVS1_BUNDLE_SENDER_CLOCK_VALID; + legacy.senderCaptureReadyTscNs = 0; + TEST_CHECK(test, argus_sender::buildAvs1WireRecord(legacy, &wire, &error)); + for (size_t i = 0; i < 8; ++i) + TEST_CHECK_EQ(test, wire[4 + 56 + i], static_cast(0)); + TEST_CHECK(test, argus_sender::parseAvs1WireRecord(wire.data(), wire.size(), &parsed, &error)); + TEST_CHECK(test, !(parsed.flags & argus_sender::AVS1_BUNDLE_SENDER_TSC_VALID)); + TEST_CHECK_EQ(test, parsed.senderCaptureReadyTscNs, 0ULL); + + // Builder rejects flag-without-field, field-without-flag, TSC-without-clock. + argus_sender::Avs1Bundle tscFlagOnly = mixedBundle(); + tscFlagOnly.senderCaptureReadyTscNs = 0; + TEST_CHECK(test, !argus_sender::buildAvs1WireRecord(tscFlagOnly, &wire, &error)); + argus_sender::Avs1Bundle tscFieldOnly = mixedBundle(); + tscFieldOnly.flags = argus_sender::AVS1_BUNDLE_SENDER_CLOCK_VALID; + TEST_CHECK(test, !argus_sender::buildAvs1WireRecord(tscFieldOnly, &wire, &error)); + argus_sender::Avs1Bundle tscWithoutClock = mixedBundle(); + tscWithoutClock.flags = argus_sender::AVS1_BUNDLE_SENDER_TSC_VALID; + TEST_CHECK(test, !argus_sender::buildAvs1WireRecord(tscWithoutClock, &wire, &error)); + + // Parser rejects the same three shapes, patched into a valid record. + // Bundle flags are the u16 at record offset 6; wire[4 + 7] is its low byte. + std::vector valid; + TEST_CHECK(test, argus_sender::buildAvs1WireRecord(mixedBundle(), &valid, &error)); + TEST_CHECK(test, argus_sender::parseAvs1WireRecord(valid.data(), valid.size(), &parsed, &error)); + wire = valid; + for (size_t i = 0; i < 8; ++i) + wire[4 + 56 + i] = 0; + TEST_CHECK(test, !argus_sender::parseAvs1WireRecord(wire.data(), wire.size(), &parsed, &error)); + wire = valid; + wire[4 + 7] = static_cast(wire[4 + 7] & ~argus_sender::AVS1_BUNDLE_SENDER_TSC_VALID); + TEST_CHECK(test, !argus_sender::parseAvs1WireRecord(wire.data(), wire.size(), &parsed, &error)); + wire = valid; + wire[4 + 7] = static_cast(wire[4 + 7] & ~argus_sender::AVS1_BUNDLE_SENDER_CLOCK_VALID); + TEST_CHECK(test, !argus_sender::parseAvs1WireRecord(wire.data(), wire.size(), &parsed, &error)); + return test.finish(); +} diff --git a/examples/camera_streamer/tests/cpp/test_capture_domain_plan.cpp b/examples/camera_streamer/tests/cpp/test_capture_domain_plan.cpp new file mode 100644 index 0000000000..d4ca7da472 --- /dev/null +++ b/examples/camera_streamer/tests/cpp/test_capture_domain_plan.cpp @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "CaptureDomainPlan.h" +#include "TestHarness.h" + +#include +#include + +namespace +{ + +argus_sender::CaptureEndpointDescriptor endpoint(size_t stream, + size_t camera, + argus_sender::StreamType type, + uint32_t mode, + uint32_t width, + uint32_t height, + uint64_t periodNs) +{ + argus_sender::CaptureEndpointDescriptor value; + value.streamIndex = stream; + value.cameraIndex = camera; + value.streamType = type; + value.modeIndex = mode; + value.sourceWidth = width; + value.sourceHeight = height; + value.framePeriodNs = periodNs; + return value; +} + +} // namespace + +int main() +{ + TestContext test("capture_domain_plan"); + const uint64_t periodNs = 16666667ULL; + std::vector endpoints; + endpoints.push_back(endpoint(0, 0, argus_sender::STREAM_TYPE_STEREO, 0, 2560, 1984, periodNs)); + endpoints.push_back(endpoint(0, 1, argus_sender::STREAM_TYPE_STEREO, 0, 2560, 1984, periodNs)); + endpoints.push_back(endpoint(1, 0, argus_sender::STREAM_TYPE_MONO, 0, 2560, 1984, periodNs)); + endpoints.push_back(endpoint(2, 0, argus_sender::STREAM_TYPE_MONO, 0, 2560, 1984, periodNs)); + + argus_sender::CaptureDomainPlan plan; + std::string error; + TEST_CHECK(test, argus_sender::buildCaptureDomainPlan(endpoints, &plan, &error)); + TEST_CHECK_EQ(test, plan.domains.size(), static_cast(3)); + TEST_CHECK_EQ(test, plan.domains[0].size(), static_cast(2)); + TEST_CHECK_EQ(test, plan.domains[1].size(), static_cast(1)); + TEST_CHECK_EQ(test, plan.domains[2].size(), static_cast(1)); + TEST_CHECK_EQ(test, plan.endpointDomains[0], static_cast(0)); + TEST_CHECK_EQ(test, plan.endpointDomains[1], static_cast(0)); + TEST_CHECK_EQ(test, plan.endpointDomains[2], static_cast(1)); + TEST_CHECK_EQ(test, plan.endpointDomains[3], static_cast(2)); + + endpoints[3].sourceWidth = 1920; + TEST_CHECK(test, argus_sender::buildCaptureDomainPlan(endpoints, &plan, &error)); + TEST_CHECK_EQ(test, plan.domains.size(), static_cast(3)); + + endpoints[1].framePeriodNs = 33333333ULL; + TEST_CHECK(test, !argus_sender::buildCaptureDomainPlan(endpoints, &plan, &error)); + TEST_CHECK(test, !error.empty()); + return test.finish(); +} diff --git a/examples/camera_streamer/tests/cpp/test_capture_priority_policy.cpp b/examples/camera_streamer/tests/cpp/test_capture_priority_policy.cpp new file mode 100644 index 0000000000..e5f004e7d6 --- /dev/null +++ b/examples/camera_streamer/tests/cpp/test_capture_priority_policy.cpp @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "CapturePriorityPolicy.h" +#include "TestHarness.h" + +#include + +int main() +{ + TestContext test("capture_priority_policy"); + + argus_sender::AppConfig config; + config.streams.resize(3); + config.streams[0].type = argus_sender::STREAM_TYPE_MONO; + config.streams[0].output.codec = argus_sender::CODEC_RAW_NV12; + config.streams[1].type = argus_sender::STREAM_TYPE_STEREO; + config.streams[1].output.codec = argus_sender::CODEC_HEVC; + config.streams[2].type = argus_sender::STREAM_TYPE_MONO; + config.streams[2].output.codec = argus_sender::CODEC_HEVC; + + std::vector fps = { 60, 60, 30 }; + TEST_CHECK_EQ(test, argus_sender::selectPriorityStream(config, fps), static_cast(1)); + + fps[1] = 30; + TEST_CHECK_EQ(test, argus_sender::selectPriorityStream(config, fps), static_cast(0)); + + TEST_CHECK_EQ(test, argus_sender::secondaryCaptureReleaseDelayNs(16666667ULL), 7000000ULL); + TEST_CHECK_EQ(test, argus_sender::prioritySyncToleranceNs(16666667ULL), 8333333ULL); + TEST_CHECK_EQ(test, argus_sender::prioritySyncToleranceNs(2000000ULL), 1000000ULL); + + return test.finish(); +} diff --git a/examples/camera_streamer/tests/cpp/test_config.cpp b/examples/camera_streamer/tests/cpp/test_config.cpp new file mode 100644 index 0000000000..b178461ba4 --- /dev/null +++ b/examples/camera_streamer/tests/cpp/test_config.cpp @@ -0,0 +1,281 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "Config.h" +#include "TestHarness.h" + +#include +#include +#include +#include + +namespace +{ + +bool loadText(const std::string& text, argus_sender::AppConfig* config, std::string* error) +{ + char path[] = "/tmp/argus_sender_config_XXXXXX"; + const int fd = mkstemp(path); + if (fd < 0) + return false; + close(fd); + { + std::ofstream file(path); + file << text; + } + const bool ok = argus_sender::loadConfigFile(path, config, error); + std::remove(path); + return ok; +} + +const char* validConfig() +{ + return "version: 1\n" + "network:\n" + " bind: 127.0.0.1\n" + " port: 55000\n" + "runtime:\n" + " frames: 120\n" + " log_interval_s: 5\n" + "alignment:\n" + " warmup_frames: 17\n" + " interval_frames: 29\n" + "streams:\n" + " - id: front\n" + " type: stereo\n" + " capture: {mode: mailbox}\n" + " cameras:\n" + " - {device: 0, mode: 1}\n" + " - {device: 1, mode: 1}\n" + " output:\n" + " fps: 60\n" + " codec: hevc\n" + " width: 1280\n" + " resize_mode: center_crop\n" + " rotation: 180\n" + " bitrate_mbps: 25\n" + " - id: wrist\n" + " type: mono\n" + " cameras: [{device: 2, mode: 0}]\n" + " capture: {mode: fifo, fifo_length: 3}\n" + " output:\n" + " fps: 30\n" + " codec: raw_nv12\n"; +} + +} // namespace + +int main() +{ + TestContext test("config"); + argus_sender::AppConfig config; + std::string error; + TEST_CHECK(test, loadText(validConfig(), &config, &error)); + TEST_CHECK_EQ(test, config.streams.size(), static_cast(2)); + TEST_CHECK_EQ(test, config.alignment.warmupFrames, 17U); + TEST_CHECK_EQ(test, config.alignment.intervalFrames, 29U); + TEST_CHECK_EQ(test, config.runtime.logIntervalSeconds, 5U); + TEST_CHECK_EQ(test, config.streams[0].type, argus_sender::STREAM_TYPE_STEREO); + TEST_CHECK_EQ(test, config.streams[0].capture.mode, argus_sender::CAPTURE_MODE_MAILBOX); + TEST_CHECK_EQ(test, config.streams[0].output.resizeMode, argus_sender::RESIZE_CENTER_CROP); + TEST_CHECK_EQ(test, config.streams[0].output.height, static_cast(0)); + TEST_CHECK_EQ(test, config.streams[1].output.codec, argus_sender::CODEC_RAW_NV12); + TEST_CHECK_EQ(test, config.streams[1].output.fps, static_cast(30)); + TEST_CHECK_EQ(test, config.streams[1].capture.mode, argus_sender::CAPTURE_MODE_FIFO); + TEST_CHECK_EQ(test, config.streams[1].capture.fifoLength, 3U); + + { + char program[] = "argus_sender"; + char configOption[] = "--config"; + char configPath[] = "configs/camera.yaml"; + char verboseOption[] = "--verbose"; + char checkOption[] = "--check-config"; + char* arguments[] = { program, configOption, configPath, verboseOption, checkOption }; + argus_sender::CommandLine commandLine; + TEST_CHECK(test, argus_sender::parseCommandLine(5, arguments, &commandLine)); + TEST_CHECK_EQ(test, commandLine.configPath, std::string("configs/camera.yaml")); + TEST_CHECK(test, commandLine.verbose); + TEST_CHECK(test, commandLine.checkConfig); + } + + for (const char* profile : { "s56-stereo-2560x1984-30.yaml", "s56-stereo-1080p60.yaml", + "s56-mono-2560x1984-30.yaml", "s56-mono-1080p30.yaml", "s56-mono-1080p60.yaml", + "s56-mono-720p30.yaml", "s56-mono-720p60.yaml", "s56-mono-2400x1440-30.yaml" }) + { + argus_sender::AppConfig shared; + TEST_CHECK(test, argus_sender::loadConfigFile( + std::string(CAMERA_STREAMER_CONFIG_DIR) + "/" + profile, &shared, &error)); + TEST_CHECK_EQ(test, shared.network.port, 5000U); + TEST_CHECK_EQ(test, shared.streams.size(), static_cast(1)); + TEST_CHECK_EQ(test, shared.streams[0].id, std::string("head")); + } + + for (const char* profile : { "shw5g-mono-2560x1984-30.yaml", "shw5g-mono-2400x1440-60.yaml" }) + { + argus_sender::AppConfig unresolved; + TEST_CHECK(test, !argus_sender::loadConfigFile( + std::string(CAMERA_STREAMER_CONFIG_DIR) + "/" + profile, &unresolved, &error)); + } + + argus_sender::AppConfig invalid; + TEST_CHECK(test, !loadText("version: 1\nstreams: []\nunknown: true\n", &invalid, &error)); + TEST_CHECK(test, error.find("unknown key") != std::string::npos); + + TEST_CHECK(test, !loadText("version: 1\n" + "streams:\n" + " - {id: a, type: mono, cameras: [{device: 0}], output: {fps: 0}}\n" + " - {id: b, type: mono, cameras: [{device: 1}], output: {fps: 30}}\n", + &invalid, &error)); + TEST_CHECK(test, error.find("cannot be mixed") != std::string::npos); + + TEST_CHECK(test, !loadText("version: 1\n" + "streams:\n" + " - {id: a, type: mono, cameras: [{device: 0}], output: {fps: 30}}\n" + " - {id: b, type: mono, cameras: [{device: 0}], output: {fps: 30}}\n", + &invalid, &error)); + TEST_CHECK(test, error.find("more than one stream") != std::string::npos); + + TEST_CHECK(test, !loadText("version: 1\n" + "streams:\n" + " - id: bad_rotation\n" + " type: mono\n" + " cameras: [{device: 0}]\n" + " output: {fps: 30, rotation: 90}\n", + &invalid, &error)); + TEST_CHECK(test, error.find("must be 0 or 180") != std::string::npos); + + TEST_CHECK(test, !loadText("version: 1\n" + "streams:\n" + " - id: bad_capture_mode\n" + " type: mono\n" + " cameras: [{device: 0}]\n" + " capture: {mode: newest}\n" + " output: {fps: 30}\n", + &invalid, &error)); + TEST_CHECK(test, error.find("mailbox or fifo") != std::string::npos); + + TEST_CHECK(test, !loadText("version: 1\n" + "streams:\n" + " - id: short_fifo\n" + " type: mono\n" + " cameras: [{device: 0}]\n" + " capture: {mode: fifo, fifo_length: 2}\n" + " output: {fps: 30}\n", + &invalid, &error)); + TEST_CHECK(test, error.find("at least 3") != std::string::npos); + + TEST_CHECK(test, !loadText("version: 1\n" + "streams:\n" + " - id: mailbox_fifo_length\n" + " type: mono\n" + " cameras: [{device: 0}]\n" + " capture: {mode: mailbox, fifo_length: 3}\n" + " output: {fps: 30}\n", + &invalid, &error)); + TEST_CHECK(test, error.find("only valid") != std::string::npos); + + TEST_CHECK(test, !loadText("version: 1\n" + "streams:\n" + " - id: nested_alignment\n" + " type: mono\n" + " cameras: [{device: 0}]\n" + " output: {fps: 60}\n" + " alignment: {warmup_frames: 1}\n", + &invalid, &error)); + TEST_CHECK(test, error.find("unknown key") != std::string::npos); + + // Sender-side remap: happy path (relative file resolves against the + // config file's directory, i.e. /tmp for these mkstemp configs). + argus_sender::AppConfig remapConfig; + TEST_CHECK(test, loadText("version: 1\n" + "streams:\n" + " - id: wrist\n" + " type: mono\n" + " cameras: [{device: 4}]\n" + " output:\n" + " fps: 60\n" + " codec: raw_nv12\n" + " width: 640\n" + " height: 480\n" + " remap:\n" + " file: ../remaps/cam4.remap\n" + " capture_width: 2560\n" + " capture_height: 1984\n", + &remapConfig, &error)); + TEST_CHECK(test, remapConfig.streams[0].output.remap.enabled()); + TEST_CHECK_EQ(test, remapConfig.streams[0].output.remap.file, std::string("/tmp/../remaps/cam4.remap")); + TEST_CHECK_EQ(test, remapConfig.streams[0].output.remap.captureWidth, 2560U); + TEST_CHECK_EQ(test, remapConfig.streams[0].output.remap.captureHeight, 1984U); + + TEST_CHECK(test, !loadText("version: 1\n" + "streams:\n" + " - id: remap_needs_raw\n" + " type: mono\n" + " cameras: [{device: 4}]\n" + " output:\n" + " fps: 60\n" + " codec: hevc\n" + " width: 640\n" + " height: 480\n" + " remap: {file: m.remap, capture_width: 2560, capture_height: 1984}\n", + &invalid, &error)); + TEST_CHECK(test, error.find("requires codec raw_nv12") != std::string::npos); + + TEST_CHECK(test, !loadText("version: 1\n" + "streams:\n" + " - id: remap_needs_dims\n" + " type: mono\n" + " cameras: [{device: 4}]\n" + " output:\n" + " fps: 60\n" + " codec: raw_nv12\n" + " remap: {file: m.remap, capture_width: 2560, capture_height: 1984}\n", + &invalid, &error)); + TEST_CHECK(test, error.find("explicit output width and height") != std::string::npos); + + TEST_CHECK(test, !loadText("version: 1\n" + "streams:\n" + " - id: remap_needs_capture\n" + " type: mono\n" + " cameras: [{device: 4}]\n" + " output:\n" + " fps: 60\n" + " codec: raw_nv12\n" + " width: 640\n" + " height: 480\n" + " remap: {file: m.remap}\n", + &invalid, &error)); + TEST_CHECK(test, error.find("capture_width/height") != std::string::npos); + + TEST_CHECK(test, !loadText("version: 1\n" + "streams:\n" + " - id: remap_needs_file\n" + " type: mono\n" + " cameras: [{device: 4}]\n" + " output:\n" + " fps: 60\n" + " codec: raw_nv12\n" + " width: 640\n" + " height: 480\n" + " remap: {capture_width: 2560, capture_height: 1984}\n", + &invalid, &error)); + TEST_CHECK(test, error.find("file is required") != std::string::npos); + + TEST_CHECK(test, !loadText("version: 1\n" + "streams:\n" + " - id: remap_unknown_key\n" + " type: mono\n" + " cameras: [{device: 4}]\n" + " output:\n" + " fps: 60\n" + " codec: raw_nv12\n" + " width: 640\n" + " height: 480\n" + " remap: {file: m.remap, capture_width: 2560, capture_height: 1984, supersample: 2}\n", + &invalid, &error)); + TEST_CHECK(test, error.find("unknown key") != std::string::npos); + return test.finish(); +} diff --git a/examples/camera_streamer/tests/cpp/test_frame_alignment_policy.cpp b/examples/camera_streamer/tests/cpp/test_frame_alignment_policy.cpp new file mode 100644 index 0000000000..2eda922803 --- /dev/null +++ b/examples/camera_streamer/tests/cpp/test_frame_alignment_policy.cpp @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "FrameAlignmentPolicy.h" +#include "TestHarness.h" + +#include +#include + +int main() +{ + TestContext test("frame_alignment_policy"); + const uint64_t periodNs = 16666667ULL; + const uint64_t halfPeriodNs = periodNs / 2; + const uint64_t epochNs = 1000000000ULL; + + std::vector samples(4); + for (size_t i = 0; i < samples.size(); ++i) + { + samples[i].valid = true; + samples[i].framePeriodNs = periodNs; + samples[i].sofTscNs = epochNs + i; + } + size_t index = 99; + uint64_t spanNs = 0; + TEST_CHECK_EQ( + test, argus_sender::chooseFrameToReacquire(samples, &index, &spanNs), argus_sender::FRAME_ALIGNMENT_KEEP); + TEST_CHECK_EQ(test, spanNs, 3ULL); + + samples[2].sofTscNs = epochNs - periodNs; + TEST_CHECK_EQ( + test, argus_sender::chooseFrameToReacquire(samples, &index, &spanNs), argus_sender::FRAME_ALIGNMENT_REACQUIRE); + TEST_CHECK_EQ(test, index, static_cast(2)); + + samples[2].sofTscNs = epochNs - halfPeriodNs; + samples[1].sofTscNs = epochNs; + samples[3].sofTscNs = epochNs; + TEST_CHECK_EQ( + test, argus_sender::chooseFrameToReacquire(samples, &index, &spanNs), argus_sender::FRAME_ALIGNMENT_KEEP); + + samples[2].sofTscNs = epochNs - halfPeriodNs - 1; + TEST_CHECK_EQ( + test, argus_sender::chooseFrameToReacquire(samples, &index, &spanNs), argus_sender::FRAME_ALIGNMENT_REACQUIRE); + TEST_CHECK_EQ(test, index, static_cast(2)); + + samples[0].valid = false; + TEST_CHECK_EQ(test, argus_sender::chooseFrameToReacquire(samples, &index, &spanNs), + argus_sender::FRAME_ALIGNMENT_UNAVAILABLE); + + samples.clear(); + TEST_CHECK_EQ( + test, argus_sender::chooseFrameToReacquire(samples, &index, &spanNs), argus_sender::FRAME_ALIGNMENT_KEEP); + + TEST_CHECK_EQ(test, argus_sender::kMaxFrameAlignmentDrops, static_cast(4)); + + std::vector candidates; + candidates.push_back(epochNs - 4000000ULL); + candidates.push_back(epochNs + 3000000ULL); + int64_t selectedDeltaNs = 0; + TEST_CHECK(test, argus_sender::chooseClosestTscCandidate(candidates, epochNs, &index, &selectedDeltaNs)); + TEST_CHECK_EQ(test, index, static_cast(1)); + TEST_CHECK_EQ(test, selectedDeltaNs, 3000000LL); + + candidates[0] = epochNs - 3000000ULL; + TEST_CHECK(test, argus_sender::chooseClosestTscCandidate(candidates, epochNs, &index, &selectedDeltaNs)); + TEST_CHECK_EQ(test, index, static_cast(1)); + TEST_CHECK_EQ(test, selectedDeltaNs, 3000000LL); + + candidates[0] = 0; + candidates[1] = epochNs - 1000ULL; + TEST_CHECK(test, argus_sender::chooseClosestTscCandidate(candidates, epochNs, &index, &selectedDeltaNs)); + TEST_CHECK_EQ(test, index, static_cast(1)); + TEST_CHECK_EQ(test, selectedDeltaNs, -1000LL); + + candidates.assign(2, 0); + TEST_CHECK(test, !argus_sender::chooseClosestTscCandidate(candidates, epochNs, &index, &selectedDeltaNs)); + TEST_CHECK(test, !argus_sender::chooseClosestTscCandidate(candidates, 0, &index, &selectedDeltaNs)); + + std::vector synchronizedTimestamps; + synchronizedTimestamps.push_back(epochNs); + synchronizedTimestamps.push_back(epochNs + 4000ULL); + uint64_t referenceTscNs = 0; + TEST_CHECK(test, argus_sender::computeSynchronizedTscReference( + synchronizedTimestamps, 1000000ULL, &referenceTscNs, &spanNs)); + TEST_CHECK_EQ(test, referenceTscNs, epochNs + 2000ULL); + TEST_CHECK_EQ(test, spanNs, 4000ULL); + + synchronizedTimestamps[1] = epochNs + periodNs; + TEST_CHECK(test, !argus_sender::computeSynchronizedTscReference( + synchronizedTimestamps, 1000000ULL, &referenceTscNs, &spanNs)); + TEST_CHECK_EQ(test, referenceTscNs, 0ULL); + TEST_CHECK_EQ(test, spanNs, periodNs); + + synchronizedTimestamps[1] = 0; + TEST_CHECK(test, !argus_sender::computeSynchronizedTscReference( + synchronizedTimestamps, 1000000ULL, &referenceTscNs, &spanNs)); + return test.finish(); +} diff --git a/examples/camera_streamer/tests/cpp/test_geometry_scheduler.cpp b/examples/camera_streamer/tests/cpp/test_geometry_scheduler.cpp new file mode 100644 index 0000000000..0526a217de --- /dev/null +++ b/examples/camera_streamer/tests/cpp/test_geometry_scheduler.cpp @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "MixedRateScheduler.h" +#include "OutputGeometry.h" +#include "TestHarness.h" + +#include +#include +#include + +int main() +{ + TestContext test("geometry_scheduler"); + argus_sender::OutputConfig output; + argus_sender::ResolvedOutputGeometry geometry; + std::string error; + std::vector sources(1); + sources[0].width = 2560; + sources[0].height = 1984; + + TEST_CHECK(test, argus_sender::resolveOutputGeometry(output, sources, &geometry, &error)); + TEST_CHECK_EQ(test, geometry.width, 2560U); + TEST_CHECK_EQ(test, geometry.height, 1984U); + + output.width = 1280; + TEST_CHECK(test, argus_sender::resolveOutputGeometry(output, sources, &geometry, &error)); + TEST_CHECK_EQ(test, geometry.height, 992U); + + output.height = 720; + TEST_CHECK(test, !argus_sender::resolveOutputGeometry(output, sources, &geometry, &error)); + output.resizeMode = argus_sender::RESIZE_CENTER_CROP; + TEST_CHECK(test, argus_sender::resolveOutputGeometry(output, sources, &geometry, &error)); + TEST_CHECK(test, geometry.clips[0].top > 0.0F); + TEST_CHECK(test, geometry.clips[0].bottom < 1.0F); + + output.resizeMode = argus_sender::RESIZE_STRETCH; + TEST_CHECK(test, argus_sender::resolveOutputGeometry(output, sources, &geometry, &error)); + TEST_CHECK(test, std::fabs(geometry.clips[0].top) < 0.0001F); + + output = argus_sender::OutputConfig(); + output.height = 480; + TEST_CHECK(test, argus_sender::resolveOutputGeometry(output, sources, &geometry, &error)); + TEST_CHECK_EQ(test, geometry.width, 620U); + TEST_CHECK_EQ(test, geometry.height, 480U); + + output.width = 620; + TEST_CHECK(test, !argus_sender::resolveOutputGeometry(output, sources, &geometry, &error)); + output.height = 0; + TEST_CHECK(test, argus_sender::resolveOutputGeometry(output, sources, &geometry, &error)); + TEST_CHECK_EQ(test, geometry.height, 482U); + + output.width = 0; + output.height = 480; + sources.push_back(sources[0]); + TEST_CHECK(test, argus_sender::resolveOutputGeometry(output, sources, &geometry, &error)); + sources[1].width = 1920; + sources[1].height = 1080; + TEST_CHECK(test, !argus_sender::resolveOutputGeometry(output, sources, &geometry, &error)); + sources.resize(1); + output.remap.file = "test.rmap"; + output.remap.captureWidth = 620; + output.remap.captureHeight = 480; + TEST_CHECK(test, !argus_sender::resolveOutputGeometry(output, sources, &geometry, &error)); + output.remap.captureWidth = 2560; + output.remap.captureHeight = 1984; + TEST_CHECK(test, argus_sender::resolveOutputGeometry(output, sources, &geometry, &error)); + + argus_sender::MixedRateScheduler scheduler({ 60, 30, 45 }); + TEST_CHECK_EQ(test, scheduler.baseFps(), 60U); + uint32_t counts[3] = { 0, 0, 0 }; + for (uint32_t tick = 0; tick < 120; ++tick) + { + const std::vector due = scheduler.next(); + for (size_t i = 0; i < due.size(); ++i) + counts[i] += due[i] ? 1 : 0; + } + TEST_CHECK_EQ(test, counts[0], 120U); + TEST_CHECK_EQ(test, counts[1], 60U); + TEST_CHECK_EQ(test, counts[2], 90U); + return test.finish(); +} diff --git a/examples/camera_streamer/tests/cpp/test_priority_frame_handoff.cpp b/examples/camera_streamer/tests/cpp/test_priority_frame_handoff.cpp new file mode 100644 index 0000000000..4b4a93df13 --- /dev/null +++ b/examples/camera_streamer/tests/cpp/test_priority_frame_handoff.cpp @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "PriorityFrameHandoff.h" +#include "TestHarness.h" + +#include + +namespace +{ + +struct Frame +{ + Frame(int number, int* released) : number(number), released(released) + { + } + ~Frame() + { + ++*released; + } + int number; + int* released; +}; + +} // namespace + +int main() +{ + TestContext test("priority_frame_handoff"); + argus_sender::LatestFrameSlot slot; + int released = 0; + + TEST_CHECK(test, !slot.occupied()); + TEST_CHECK(test, !slot.take()); + TEST_CHECK(test, !slot.replace(std::unique_ptr(new Frame(1, &released)))); + TEST_CHECK(test, slot.occupied()); + + std::unique_ptr stale = slot.replace(std::unique_ptr(new Frame(2, &released))); + TEST_CHECK_EQ(test, stale->number, 1); + TEST_CHECK_EQ(test, released, 0); + stale.reset(); // Release outside the producer's slot lock. + TEST_CHECK_EQ(test, released, 1); + + std::unique_ptr newest = slot.take(); + TEST_CHECK_EQ(test, newest->number, 2); + TEST_CHECK(test, !slot.occupied()); + newest.reset(); + TEST_CHECK_EQ(test, released, 2); + + slot.replace(std::unique_ptr(new Frame(3, &released))); + slot.reset(); // Shutdown releases any unconsumed frame. + TEST_CHECK_EQ(test, released, 3); + TEST_CHECK(test, !slot.occupied()); + + const uint64_t start = 10000000000ULL; + TEST_CHECK(test, !argus_sender::priorityFrameDroughtExpired( + start, start + argus_sender::kPrioritySteadyDroughtNs - 1, true)); + TEST_CHECK( + test, argus_sender::priorityFrameDroughtExpired(start, start + argus_sender::kPrioritySteadyDroughtNs, true)); + TEST_CHECK(test, !argus_sender::priorityFrameDroughtExpired( + start, start + argus_sender::kPriorityStartupDroughtNs - 1, false)); + TEST_CHECK( + test, argus_sender::priorityFrameDroughtExpired(start, start + argus_sender::kPriorityStartupDroughtNs, false)); + TEST_CHECK(test, !argus_sender::priorityFrameDroughtExpired(start, start - 1, true)); + + argus_sender::PriorityFrameProgress progress(start); + TEST_CHECK_EQ(test, progress.droughtLimitNs(), argus_sender::kPriorityStartupDroughtNs); + TEST_CHECK(test, !progress.droughtExpired(start + argus_sender::kPriorityStartupDroughtNs - 1)); + progress.noteFreshFrame(start + 100); + TEST_CHECK_EQ(test, progress.droughtLimitNs(), argus_sender::kPrioritySteadyDroughtNs); + TEST_CHECK(test, !progress.droughtExpired(start + 100 + argus_sender::kPrioritySteadyDroughtNs - 1)); + TEST_CHECK(test, progress.droughtExpired(start + 100 + argus_sender::kPrioritySteadyDroughtNs)); + progress.noteFreshFrame(start + 200 + argus_sender::kPrioritySteadyDroughtNs); + TEST_CHECK(test, !progress.droughtExpired(start + 200 + argus_sender::kPrioritySteadyDroughtNs)); + + return test.finish(); +} diff --git a/examples/camera_streamer/tests/python/test_avs1_validation.py b/examples/camera_streamer/tests/python/test_avs1_validation.py new file mode 100644 index 0000000000..cc77cef61b --- /dev/null +++ b/examples/camera_streamer/tests/python/test_avs1_validation.py @@ -0,0 +1,72 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Protocol-only regressions runnable without the GPU receiver or pytest.""" + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "camera_receiver")) + +from avs_wire import HEADER, SAMPLE, STREAM, parse_avs1_record # noqa: E402 + + +def record(first_samples=(0, 1), extra_sample=False): + streams = b"".join( + STREAM.pack(i, 1, 1, 1, 0, 1280, 720, 30, 1, 1, first, 1, 0) + for i, first in enumerate(first_samples) + ) + samples = b"".join( + SAMPLE.pack(i, 0, 0, 1, 0, i, 0, 1, 0, 0, 0, 0, 0, i, 1) for i in range(2) + ) + if extra_sample: + samples += bytes(SAMPLE.size) + header = HEADER.pack( + b"AVS1", + 1, + 0, + HEADER.size, + 2, + 2 + extra_sample, + 1, + 1, + 0, + 0, + len(streams) + len(samples), + 2, + 0, + ) + return header + streams + samples + b"LR" + + +class DescriptorCoverageTests(unittest.TestCase): + def test_valid_mono_streams(self): + bundle = parse_avs1_record(record()) + self.assertEqual( + [bytes(stream.samples[0].payload) for stream in bundle.streams], + [b"L", b"R"], + ) + + def test_rejects_unreferenced_descriptor(self): + with self.assertRaisesRegex(ValueError, "not fully referenced"): + parse_avs1_record(record(extra_sample=True)) + + def test_rejects_noncontiguous_stream_ranges(self): + for first_samples in [(1, 0), (0, 0), (0, 2)]: + with self.subTest(first_samples=first_samples): + with self.assertRaisesRegex(ValueError, "not contiguous"): + parse_avs1_record(record(first_samples)) + + def test_rejects_range_past_descriptor_table(self): + packet = bytearray(record()) + fields = list(STREAM.unpack_from(packet, HEADER.size + STREAM.size)) + fields[1] |= 2 # Stereo requires two samples, but only one remains. + fields[11] = 2 + STREAM.pack_into(packet, HEADER.size + STREAM.size, *fields) + with self.assertRaisesRegex(ValueError, "exceeds descriptor table"): + parse_avs1_record(packet) + + +if __name__ == "__main__": + unittest.main()