Compare commits

...

7 Commits

Author SHA1 Message Date
crosstyan 51d03d4279 feat(encode): add Jetson Multimedia API encoder backend
Integrate a native Jetson Multimedia API encoder path and keep the existing encoded access-unit contract for RTMP, RTP, and recording consumers.

This adds conditional Jetson MMAPI detection in CMake, builds the required NVIDIA sample common classes into a dedicated support library, and compiles the new backend only when the platform dependencies are present.

The runtime selector now lets encoder.backend=auto prefer Jetson MM for NVIDIA hardware requests while keeping encoder.backend=ffmpeg as an explicit FFmpeg path. Device selection semantics are updated so nvidia requires the Jetson backend, auto can fall back to FFmpeg software, and software remains FFmpeg-only.

The Jetson backend converts supported raw inputs through swscale, feeds NvVideoEncoder in YUV420M, emits Annex-B access units, and harvests decoder configuration from a warmup keyframe so downstream packetizers keep their existing contract.

This also splits FFmpeg encoder option handling into a shared header, updates runtime config/help text and tester defaults, and refreshes compatibility/caveat documentation to reflect the new selection behavior.

Build-tree runtime RPATH handling is tightened so GCC 15 builds keep the matching libstdc++ visible locally. Verification covered GCC 15 builds, RTP H.264/H.265 tester runs, RTMP H.264 stub publish, local live SRS smoke with cvmmap://zed1, and remote execution on 192.168.2.155 using the deployed bundle lib directory for the GCC 15 runtime.
2026-04-15 18:37:44 +08:00
crosstyan 16a1a38645 Make MCAP and depth support optional 2026-04-14 17:13:29 +08:00
crosstyan ddea6b0e3d refactor(zed): remove extracted offline helper tooling
Drop the offline ZED helper implementations that were moved into zed-offline-tools.\n\nThis removes the standalone conversion binaries, batch/index/inspection scripts, related configs and tests, and the tool-specific support code that no longer belongs in cvmmap-streamer.\n\nThe build files and docs are updated to point at the standalone repo while keeping the streamer runtime surface intact.
2026-04-14 10:28:13 +08:00
crosstyan 30cd956c5c fix(rtmp): avoid segfault on connection-refused teardown
Track libavformat RTMP session initialization state so teardown only writes a trailer after the muxer header succeeds. This avoids calling av_write_trailer() on partially initialized sessions when avio_open2() fails with Connection refused.

Add a fault-suite regression for libavformat RTMP connection refusal and update the summary helper to compute all_pass from the actual manifest size instead of a hardcoded seven-row expectation.

Verified by rebuilding cvmmap_streamer and rtmp_output_tester, reproducing the refused-connection path without a crash, and running ./scripts/fault_suite.sh successfully (8/8).
2026-04-14 10:21:04 +08:00
crosstyan b277ed363f Fix MP4 recorder timestamp monotonicity 2026-04-12 22:04:06 +08:00
crosstyan 3e5b720e0e fix: use the non-deprecated NATS header
Replace the deprecated <nats.h> include with <nats/nats.h> in the streamer request/reply service implementation to avoid the cnats deprecation warning during builds.
2026-04-12 20:25:52 +08:00
crosstyan 213adee887 feat: add streamer-owned recording control service
Introduce a dedicated streamer-side recording control plane instead of sharing the producer recorder API.

- register streamer-owned recorder endpoints as a NATS micro service
- add explicit MP4 and MCAP recorder control protobufs and subject helpers
- wire recorder lifecycle handling into the pipeline runtime
- add MP4 writer and depth-alignment support files used by the new recording flow
2026-04-12 20:22:28 +08:00
53 changed files with 3752 additions and 12747 deletions
+382 -263
View File
@@ -8,10 +8,81 @@ set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
include(GNUInstallDirs)
option(
CVMMAP_BUILD_ZED_SVO_GRID_TO_MP4
"Build the OpenCV-based zed_svo_grid_to_mp4 tool"
ON)
set(CVMMAP_STREAMER_INSTALL_RPATH "$ORIGIN/../${CMAKE_INSTALL_LIBDIR}")
function(cvmmap_streamer_append_runtime_dir out_var runtime_library)
execute_process(
COMMAND "${CMAKE_CXX_COMPILER}" -print-file-name=${runtime_library}
OUTPUT_VARIABLE runtime_library_path
OUTPUT_STRIP_TRAILING_WHITESPACE)
if (IS_ABSOLUTE "${runtime_library_path}")
get_filename_component(runtime_library_dir "${runtime_library_path}" DIRECTORY)
set(${out_var} "${${out_var}};${runtime_library_dir}" PARENT_SCOPE)
endif()
endfunction()
function(cvmmap_streamer_resolve_feature_mode out_var option_name requested_mode available unavailable_reason)
set(_valid_modes AUTO ON OFF)
list(FIND _valid_modes "${requested_mode}" _requested_mode_index)
if (_requested_mode_index EQUAL -1)
message(FATAL_ERROR
"Invalid ${option_name}='${requested_mode}' (expected: AUTO|ON|OFF)")
endif()
if (requested_mode STREQUAL "OFF")
set(${out_var} FALSE PARENT_SCOPE)
return()
endif()
if (requested_mode STREQUAL "ON")
if (NOT available)
message(FATAL_ERROR
"${option_name}=ON requires ${unavailable_reason}")
endif()
set(${out_var} TRUE PARENT_SCOPE)
return()
endif()
set(${out_var} ${available} PARENT_SCOPE)
endfunction()
set(CVMMAP_STREAMER_BUILD_RPATH "")
# Build-tree binaries compiled with a non-system GCC need that toolchain's
# C++ runtime on the dynamic loader search path.
cvmmap_streamer_append_runtime_dir(CVMMAP_STREAMER_BUILD_RPATH libstdc++.so.6)
cvmmap_streamer_append_runtime_dir(CVMMAP_STREAMER_BUILD_RPATH libgcc_s.so.1)
list(REMOVE_DUPLICATES CVMMAP_STREAMER_BUILD_RPATH)
set(
CVMMAP_STREAMER_ENABLE_MCAP
"AUTO"
CACHE STRING
"Enable MCAP recording support: AUTO, ON, or OFF")
set_property(CACHE CVMMAP_STREAMER_ENABLE_MCAP PROPERTY STRINGS AUTO ON OFF)
set(
CVMMAP_STREAMER_ENABLE_MCAP_DEPTH
"AUTO"
CACHE STRING
"Enable MCAP depth recording support: AUTO, ON, or OFF")
set_property(CACHE CVMMAP_STREAMER_ENABLE_MCAP_DEPTH PROPERTY STRINGS AUTO ON OFF)
set(
CVMMAP_STREAMER_MCAP_INCLUDE_DIR
"${CMAKE_CURRENT_LIST_DIR}/third_party/mcap/include"
CACHE PATH
"Path to MCAP headers")
set(
CVMMAP_STREAMER_ENABLE_JETSON_MM
"AUTO"
CACHE STRING
"Enable Jetson Multimedia API encoder backend: AUTO, ON, or OFF")
set_property(CACHE CVMMAP_STREAMER_ENABLE_JETSON_MM PROPERTY STRINGS AUTO ON OFF)
set(
CVMMAP_STREAMER_JETSON_MMAPI_ROOT
"/usr/src/jetson_multimedia_api"
CACHE PATH
"Path to the Jetson Multimedia API root")
find_package(Threads REQUIRED)
find_package(OpenSSL REQUIRED)
@@ -60,7 +131,7 @@ if (CVMMAP_CNATS_PROVIDER STREQUAL "system")
find_package(cnats CONFIG REQUIRED)
find_package(cvmmap-core CONFIG QUIET PATHS "${CVMMAP_LOCAL_CORE_DIR}" NO_DEFAULT_PATH)
if (NOT TARGET cvmmap::client)
find_package(cvmmap-core CONFIG QUIET)
find_package(cvmmap-core CONFIG REQUIRED PATHS "${CVMMAP_LOCAL_CORE_DIR}" NO_DEFAULT_PATH)
endif()
else()
if (NOT EXISTS "${CVMMAP_LOCAL_NATS_STATIC}")
@@ -78,43 +149,182 @@ endif()
find_package(ZeroMQ QUIET)
find_package(spdlog REQUIRED)
find_package(Protobuf REQUIRED)
find_package(PkgConfig REQUIRED)
find_package(rvl CONFIG QUIET)
set(ZED_DIR "/usr/local/zed" CACHE PATH "Path to the local ZED SDK")
find_package(ZED QUIET)
set(CVMMAP_HAS_ZED_SDK OFF)
if (ZED_FOUND)
find_package(CUDA ${ZED_CUDA_VERSION} REQUIRED)
find_library(CVMMAP_STREAMER_LIBUSB_LIBRARY NAMES usb-1.0 libusb-1.0)
if (CVMMAP_STREAMER_LIBUSB_LIBRARY)
set(_CVMMAP_STREAMER_ZED_LIBRARIES "")
foreach(_zed_lib IN LISTS ZED_LIBRARIES)
if (_zed_lib STREQUAL "/usr/lib/x86_64-linux-gnu/libusb-1.0.so")
list(APPEND _CVMMAP_STREAMER_ZED_LIBRARIES "${CVMMAP_STREAMER_LIBUSB_LIBRARY}")
else()
list(APPEND _CVMMAP_STREAMER_ZED_LIBRARIES "${_zed_lib}")
endif()
endforeach()
set(ZED_LIBRARIES "${_CVMMAP_STREAMER_ZED_LIBRARIES}")
if (Protobuf_VERSION VERSION_LESS 3.13)
set(_cvmmap_streamer_protoc_wrapper "${CMAKE_BINARY_DIR}/cvmmap-streamer-protoc-wrapper.sh")
file(WRITE "${_cvmmap_streamer_protoc_wrapper}"
"#!/bin/sh\nexec \"${Protobuf_PROTOC_EXECUTABLE}\" --experimental_allow_proto3_optional \"$@\"\n")
file(CHMOD
"${_cvmmap_streamer_protoc_wrapper}"
PERMISSIONS
OWNER_READ OWNER_WRITE OWNER_EXECUTE
GROUP_READ GROUP_EXECUTE
WORLD_READ WORLD_EXECUTE)
if (TARGET protobuf::protoc)
set_target_properties(protobuf::protoc PROPERTIES IMPORTED_LOCATION "${_cvmmap_streamer_protoc_wrapper}")
endif()
set(CVMMAP_HAS_ZED_SDK ON)
message(STATUS "ZED SDK found: enabling zed_svo_to_mcap and zed_svo_to_mp4")
else()
message(STATUS "ZED SDK not found: skipping ZED conversion tools")
set(Protobuf_PROTOC_EXECUTABLE "${_cvmmap_streamer_protoc_wrapper}")
endif()
if (CVMMAP_BUILD_ZED_SVO_GRID_TO_MP4 AND CVMMAP_HAS_ZED_SDK)
find_package(OpenCV REQUIRED COMPONENTS core imgproc)
elseif (CVMMAP_BUILD_ZED_SVO_GRID_TO_MP4)
message(STATUS "CVMMAP_BUILD_ZED_SVO_GRID_TO_MP4=ON but ZED SDK is unavailable; zed_svo_grid_to_mp4 will not be built")
find_package(PkgConfig REQUIRED)
find_package(rvl CONFIG QUIET)
set(_CVMMAP_STREAMER_MCAP_HEADERS_AVAILABLE FALSE)
if (EXISTS "${CVMMAP_STREAMER_MCAP_INCLUDE_DIR}/mcap/mcap.hpp")
set(_CVMMAP_STREAMER_MCAP_HEADERS_AVAILABLE TRUE)
if (NOT TARGET mcap::mcap)
add_library(cvmmap_streamer_mcap_headers INTERFACE)
target_include_directories(cvmmap_streamer_mcap_headers INTERFACE "${CVMMAP_STREAMER_MCAP_INCLUDE_DIR}")
add_library(mcap::mcap ALIAS cvmmap_streamer_mcap_headers)
endif()
elseif (NOT TARGET mcap::mcap)
add_library(mcap::mcap INTERFACE IMPORTED GLOBAL)
endif()
add_subdirectory(third_party)
pkg_check_modules(FFMPEG REQUIRED IMPORTED_TARGET libavcodec libavformat libavutil libswscale)
pkg_check_modules(PROTOBUF_PKG QUIET IMPORTED_TARGET protobuf)
pkg_check_modules(ZSTD REQUIRED IMPORTED_TARGET libzstd)
pkg_check_modules(LZ4 REQUIRED IMPORTED_TARGET liblz4)
pkg_check_modules(ZSTD QUIET IMPORTED_TARGET libzstd)
pkg_check_modules(LZ4 QUIET IMPORTED_TARGET liblz4)
set(_CVMMAP_STREAMER_MCAP_MISSING_DEPS)
if (NOT _CVMMAP_STREAMER_MCAP_HEADERS_AVAILABLE)
list(APPEND _CVMMAP_STREAMER_MCAP_MISSING_DEPS "MCAP headers at ${CVMMAP_STREAMER_MCAP_INCLUDE_DIR}")
endif()
if (NOT ZSTD_FOUND)
list(APPEND _CVMMAP_STREAMER_MCAP_MISSING_DEPS "pkg-config package libzstd")
endif()
if (NOT LZ4_FOUND)
list(APPEND _CVMMAP_STREAMER_MCAP_MISSING_DEPS "pkg-config package liblz4")
endif()
if (_CVMMAP_STREAMER_MCAP_MISSING_DEPS)
list(JOIN _CVMMAP_STREAMER_MCAP_MISSING_DEPS ", " _CVMMAP_STREAMER_MCAP_UNAVAILABLE_REASON)
set(_CVMMAP_STREAMER_MCAP_AVAILABLE FALSE)
else()
set(_CVMMAP_STREAMER_MCAP_UNAVAILABLE_REASON "MCAP dependencies")
set(_CVMMAP_STREAMER_MCAP_AVAILABLE TRUE)
endif()
cvmmap_streamer_resolve_feature_mode(
CVMMAP_STREAMER_HAS_MCAP_BOOL
CVMMAP_STREAMER_ENABLE_MCAP
"${CVMMAP_STREAMER_ENABLE_MCAP}"
${_CVMMAP_STREAMER_MCAP_AVAILABLE}
"${_CVMMAP_STREAMER_MCAP_UNAVAILABLE_REASON}")
set(RVL_LOCAL_ROOT "${CMAKE_CURRENT_LIST_DIR}/../rvl_impl" CACHE PATH "Path to a local rvl_impl checkout")
set(RVL_LOCAL_BUILD "${RVL_LOCAL_ROOT}/build/core" CACHE PATH "Path to local rvl_impl build artifacts")
set(_CVMMAP_STREAMER_RVL_AVAILABLE FALSE)
set(_CVMMAP_STREAMER_RVL_UNAVAILABLE_REASON "rvl::rvl target or local rvl_impl build artifacts")
if (TARGET rvl::rvl)
set(_CVMMAP_STREAMER_RVL_AVAILABLE TRUE)
elseif (
EXISTS "${RVL_LOCAL_ROOT}/core/include/rvl/rvl.hpp"
AND EXISTS "${RVL_LOCAL_BUILD}/librvl_core.a")
set(_CVMMAP_STREAMER_RVL_AVAILABLE TRUE)
endif()
if (CVMMAP_STREAMER_ENABLE_MCAP STREQUAL "OFF" AND CVMMAP_STREAMER_ENABLE_MCAP_DEPTH STREQUAL "ON")
message(FATAL_ERROR
"CVMMAP_STREAMER_ENABLE_MCAP_DEPTH=ON requires CVMMAP_STREAMER_ENABLE_MCAP to be AUTO or ON")
endif()
if (CVMMAP_STREAMER_ENABLE_MCAP_DEPTH STREQUAL "ON" AND NOT CVMMAP_STREAMER_HAS_MCAP_BOOL)
message(FATAL_ERROR
"CVMMAP_STREAMER_ENABLE_MCAP_DEPTH=ON requires MCAP support, but ${_CVMMAP_STREAMER_MCAP_UNAVAILABLE_REASON} is unavailable")
endif()
if (NOT CVMMAP_STREAMER_HAS_MCAP_BOOL AND CVMMAP_STREAMER_ENABLE_MCAP_DEPTH STREQUAL "AUTO")
set(CVMMAP_STREAMER_HAS_MCAP_DEPTH_BOOL FALSE)
else()
cvmmap_streamer_resolve_feature_mode(
CVMMAP_STREAMER_HAS_MCAP_DEPTH_BOOL
CVMMAP_STREAMER_ENABLE_MCAP_DEPTH
"${CVMMAP_STREAMER_ENABLE_MCAP_DEPTH}"
${_CVMMAP_STREAMER_RVL_AVAILABLE}
"${_CVMMAP_STREAMER_RVL_UNAVAILABLE_REASON}")
endif()
if (CVMMAP_STREAMER_HAS_MCAP_BOOL)
set(CVMMAP_STREAMER_HAS_MCAP 1)
else()
set(CVMMAP_STREAMER_HAS_MCAP 0)
endif()
if (CVMMAP_STREAMER_HAS_MCAP_DEPTH_BOOL)
set(CVMMAP_STREAMER_HAS_MCAP_DEPTH 1)
else()
set(CVMMAP_STREAMER_HAS_MCAP_DEPTH 0)
endif()
set(_CVMMAP_STREAMER_JETSON_MM_INCLUDE_DIR "${CVMMAP_STREAMER_JETSON_MMAPI_ROOT}/include")
set(_CVMMAP_STREAMER_JETSON_MM_COMMON_DIR "${CVMMAP_STREAMER_JETSON_MMAPI_ROOT}/samples/common/classes")
set(_CVMMAP_STREAMER_JETSON_MM_COMMON_SOURCES
"${_CVMMAP_STREAMER_JETSON_MM_COMMON_DIR}/NvBuffer.cpp"
"${_CVMMAP_STREAMER_JETSON_MM_COMMON_DIR}/NvElement.cpp"
"${_CVMMAP_STREAMER_JETSON_MM_COMMON_DIR}/NvElementProfiler.cpp"
"${_CVMMAP_STREAMER_JETSON_MM_COMMON_DIR}/NvLogging.cpp"
"${_CVMMAP_STREAMER_JETSON_MM_COMMON_DIR}/NvV4l2Element.cpp"
"${_CVMMAP_STREAMER_JETSON_MM_COMMON_DIR}/NvV4l2ElementPlane.cpp"
"${_CVMMAP_STREAMER_JETSON_MM_COMMON_DIR}/NvVideoEncoder.cpp")
set(_CVMMAP_STREAMER_JETSON_MM_MISSING_DEPS)
if (NOT EXISTS "${_CVMMAP_STREAMER_JETSON_MM_INCLUDE_DIR}/NvVideoEncoder.h")
list(APPEND _CVMMAP_STREAMER_JETSON_MM_MISSING_DEPS
"Jetson Multimedia API headers at ${_CVMMAP_STREAMER_JETSON_MM_INCLUDE_DIR}")
endif()
foreach(_cvmmap_streamer_jetson_mm_source IN LISTS _CVMMAP_STREAMER_JETSON_MM_COMMON_SOURCES)
if (NOT EXISTS "${_cvmmap_streamer_jetson_mm_source}")
list(APPEND _CVMMAP_STREAMER_JETSON_MM_MISSING_DEPS
"Jetson Multimedia API common source ${_cvmmap_streamer_jetson_mm_source}")
endif()
endforeach()
find_library(
CVMMAP_STREAMER_NVBUFSURFACE_LIBRARY
NAMES nvbufsurface
PATHS
/usr/lib/aarch64-linux-gnu/nvidia
/usr/lib/aarch64-linux-gnu/tegra
/usr/lib/aarch64-linux-gnu
PATH_SUFFIXES nvidia tegra)
if (NOT CVMMAP_STREAMER_NVBUFSURFACE_LIBRARY)
list(APPEND _CVMMAP_STREAMER_JETSON_MM_MISSING_DEPS "libnvbufsurface")
endif()
find_library(CVMMAP_STREAMER_LIBV4L2_LIBRARY NAMES v4l2)
if (NOT CVMMAP_STREAMER_LIBV4L2_LIBRARY)
list(APPEND _CVMMAP_STREAMER_JETSON_MM_MISSING_DEPS "libv4l2")
endif()
if (_CVMMAP_STREAMER_JETSON_MM_MISSING_DEPS)
list(JOIN _CVMMAP_STREAMER_JETSON_MM_MISSING_DEPS ", " _CVMMAP_STREAMER_JETSON_MM_UNAVAILABLE_REASON)
set(_CVMMAP_STREAMER_JETSON_MM_AVAILABLE FALSE)
else()
set(_CVMMAP_STREAMER_JETSON_MM_UNAVAILABLE_REASON "Jetson Multimedia API dependencies")
set(_CVMMAP_STREAMER_JETSON_MM_AVAILABLE TRUE)
endif()
cvmmap_streamer_resolve_feature_mode(
CVMMAP_STREAMER_HAS_JETSON_MM_BOOL
CVMMAP_STREAMER_ENABLE_JETSON_MM
"${CVMMAP_STREAMER_ENABLE_JETSON_MM}"
${_CVMMAP_STREAMER_JETSON_MM_AVAILABLE}
"${_CVMMAP_STREAMER_JETSON_MM_UNAVAILABLE_REASON}")
if (CVMMAP_STREAMER_HAS_JETSON_MM_BOOL)
set(CVMMAP_STREAMER_HAS_JETSON_MM 1)
add_library(cvmmap_streamer_jetson_mmapi STATIC
${_CVMMAP_STREAMER_JETSON_MM_COMMON_SOURCES})
target_include_directories(cvmmap_streamer_jetson_mmapi
PUBLIC
"${_CVMMAP_STREAMER_JETSON_MM_INCLUDE_DIR}")
target_link_libraries(cvmmap_streamer_jetson_mmapi
PUBLIC
Threads::Threads
${CVMMAP_STREAMER_LIBV4L2_LIBRARY}
${CVMMAP_STREAMER_NVBUFSURFACE_LIBRARY})
else()
set(CVMMAP_STREAMER_HAS_JETSON_MM 0)
endif()
if (NOT TARGET cvmmap::client)
if (
@@ -137,9 +347,7 @@ if (NOT TARGET cvmmap::client)
endif()
endif()
if (NOT TARGET rvl::rvl)
set(RVL_LOCAL_ROOT "${CMAKE_CURRENT_LIST_DIR}/../rvl_impl" CACHE PATH "Path to a local rvl_impl checkout")
set(RVL_LOCAL_BUILD "${RVL_LOCAL_ROOT}/build/core")
if (CVMMAP_STREAMER_HAS_MCAP_DEPTH AND NOT TARGET rvl::rvl)
if (
EXISTS "${RVL_LOCAL_ROOT}/core/include/rvl/rvl.hpp"
AND EXISTS "${RVL_LOCAL_BUILD}/librvl_core.a")
@@ -176,6 +384,14 @@ protobuf_generate(
"${CMAKE_CURRENT_LIST_DIR}/proto/cvmmap_streamer/DepthMap.proto"
"${CMAKE_CURRENT_LIST_DIR}/proto/cvmmap_streamer/BundleManifest.proto"
IMPORT_DIRS "${CMAKE_CURRENT_LIST_DIR}")
add_library(cvmmap_streamer_control_proto STATIC)
protobuf_generate(
TARGET cvmmap_streamer_control_proto
LANGUAGE cpp
PROTOC_OUT_DIR "${CMAKE_CURRENT_BINARY_DIR}"
PROTOS
"${CMAKE_CURRENT_LIST_DIR}/proto/cvmmap_streamer/recorder_control.proto"
IMPORT_DIRS "${CMAKE_CURRENT_LIST_DIR}")
add_library(cvmmap_streamer_protobuf INTERFACE)
target_include_directories(cvmmap_streamer_foxglove_proto
PUBLIC
@@ -187,6 +403,11 @@ target_include_directories(cvmmap_streamer_depth_proto
"${CMAKE_CURRENT_BINARY_DIR}"
"${CMAKE_CURRENT_BINARY_DIR}/proto"
${Protobuf_INCLUDE_DIRS})
target_include_directories(cvmmap_streamer_control_proto
PUBLIC
"${CMAKE_CURRENT_BINARY_DIR}"
"${CMAKE_CURRENT_BINARY_DIR}/proto"
${Protobuf_INCLUDE_DIRS})
target_include_directories(cvmmap_streamer_protobuf
INTERFACE
"${CMAKE_CURRENT_BINARY_DIR}/proto"
@@ -203,36 +424,31 @@ if (TARGET PkgConfig::PROTOBUF_PKG)
endif()
target_link_libraries(cvmmap_streamer_foxglove_proto PUBLIC cvmmap_streamer_protobuf)
target_link_libraries(cvmmap_streamer_depth_proto PUBLIC cvmmap_streamer_protobuf)
target_link_libraries(cvmmap_streamer_control_proto PUBLIC cvmmap_streamer_protobuf)
add_library(cvmmap_streamer_mcap_runtime STATIC
src/record/mcap_runtime.cpp)
target_include_directories(cvmmap_streamer_mcap_runtime
PUBLIC)
target_link_libraries(cvmmap_streamer_mcap_runtime
PUBLIC
mcap::mcap
PkgConfig::ZSTD
PkgConfig::LZ4)
add_library(cvmmap_streamer_feature_flags INTERFACE)
target_compile_definitions(cvmmap_streamer_feature_flags
INTERFACE
CVMMAP_STREAMER_HAS_MCAP=${CVMMAP_STREAMER_HAS_MCAP}
CVMMAP_STREAMER_HAS_MCAP_DEPTH=${CVMMAP_STREAMER_HAS_MCAP_DEPTH}
CVMMAP_STREAMER_HAS_JETSON_MM=${CVMMAP_STREAMER_HAS_JETSON_MM})
add_library(cvmmap_streamer_record_support STATIC
src/encode/encoder_backend.cpp
src/encode/ffmpeg_encoder_backend.cpp
src/record/protobuf_descriptor.cpp
src/record/mcap_record_sink.cpp)
src/record/mp4_record_writer.cpp)
if (CVMMAP_STREAMER_HAS_JETSON_MM_BOOL)
target_sources(cvmmap_streamer_record_support PRIVATE src/encode/jetson_mm_encoder_backend.cpp)
endif()
target_include_directories(cvmmap_streamer_record_support
PUBLIC
"${CMAKE_CURRENT_LIST_DIR}/include"
"${CMAKE_CURRENT_BINARY_DIR}")
target_link_libraries(cvmmap_streamer_record_support
PUBLIC
cvmmap_streamer_foxglove_proto
cvmmap_streamer_depth_proto
cvmmap_streamer_mcap_runtime
cvmmap_streamer_feature_flags
PkgConfig::FFMPEG
PkgConfig::ZSTD
PkgConfig::LZ4
rvl::rvl
mcap::mcap
msft_proxy4::proxy
cvmmap_streamer_protobuf)
if (TARGET spdlog::spdlog)
@@ -243,6 +459,46 @@ endif()
if (TARGET PkgConfig::PROTOBUF_PKG)
target_link_libraries(cvmmap_streamer_record_support PUBLIC PkgConfig::PROTOBUF_PKG)
endif()
if (CVMMAP_STREAMER_HAS_JETSON_MM_BOOL)
target_link_libraries(cvmmap_streamer_record_support PUBLIC cvmmap_streamer_jetson_mmapi)
endif()
if (CVMMAP_STREAMER_HAS_MCAP)
add_library(cvmmap_streamer_mcap_runtime STATIC
src/record/mcap_runtime.cpp)
target_link_libraries(cvmmap_streamer_mcap_runtime
PUBLIC
cvmmap_streamer_feature_flags
mcap::mcap
PkgConfig::ZSTD
PkgConfig::LZ4)
add_library(cvmmap_streamer_mcap_record_support STATIC
src/record/mcap_record_sink.cpp)
target_include_directories(cvmmap_streamer_mcap_record_support
PUBLIC
"${CMAKE_CURRENT_LIST_DIR}/include"
"${CMAKE_CURRENT_BINARY_DIR}")
target_link_libraries(cvmmap_streamer_mcap_record_support
PUBLIC
cvmmap_streamer_feature_flags
cvmmap_streamer_record_support
cvmmap_streamer_foxglove_proto
cvmmap_streamer_depth_proto
cvmmap_streamer_mcap_runtime
mcap::mcap)
if (CVMMAP_STREAMER_HAS_MCAP_DEPTH)
target_link_libraries(cvmmap_streamer_mcap_record_support PUBLIC rvl::rvl)
endif()
if (TARGET spdlog::spdlog)
target_link_libraries(cvmmap_streamer_mcap_record_support PUBLIC spdlog::spdlog)
elseif (TARGET spdlog)
target_link_libraries(cvmmap_streamer_mcap_record_support PUBLIC spdlog)
endif()
if (TARGET PkgConfig::PROTOBUF_PKG)
target_link_libraries(cvmmap_streamer_mcap_record_support PUBLIC PkgConfig::PROTOBUF_PKG)
endif()
endif()
add_library(cvmmap_streamer_common STATIC
src/ipc/help.cpp
@@ -250,6 +506,7 @@ add_library(cvmmap_streamer_common STATIC
src/core/frame_source.cpp
src/core/ingest_runtime.cpp
src/ipc/contracts.cpp
src/protocol/nats_request_reply_server.cpp
src/protocol/wire_codec.cpp
src/metrics/latency_tracker.cpp
src/pipeline/pipeline_runtime.cpp
@@ -266,13 +523,14 @@ set(CVMMAP_STREAMER_LINK_DEPS
Threads::Threads
cvmmap_streamer_record_support
PkgConfig::FFMPEG
PkgConfig::ZSTD
PkgConfig::LZ4
cvmmap::client
cvmmap::nats
CLI11::CLI11
tomlplusplus::tomlplusplus
mcap::mcap)
cvmmap_streamer_feature_flags)
if (CVMMAP_STREAMER_HAS_MCAP)
list(APPEND CVMMAP_STREAMER_LINK_DEPS cvmmap_streamer_mcap_record_support)
endif()
if (TARGET cppzmq::cppzmq)
list(APPEND CVMMAP_STREAMER_LINK_DEPS cppzmq::cppzmq)
@@ -293,12 +551,22 @@ elseif (TARGET spdlog)
endif()
list(APPEND CVMMAP_STREAMER_LINK_DEPS cvmmap_streamer_protobuf)
list(APPEND CVMMAP_STREAMER_LINK_DEPS cvmmap_streamer_control_proto)
if (TARGET PkgConfig::PROTOBUF_PKG)
list(APPEND CVMMAP_STREAMER_LINK_DEPS PkgConfig::PROTOBUF_PKG)
endif()
target_link_libraries(cvmmap_streamer_common PUBLIC ${CVMMAP_STREAMER_LINK_DEPS})
function(cvmmap_streamer_apply_runtime_rpath target)
set_target_properties(${target} PROPERTIES
INSTALL_RPATH "${CVMMAP_STREAMER_INSTALL_RPATH}")
if (CVMMAP_STREAMER_BUILD_RPATH)
set_target_properties(${target} PROPERTIES
BUILD_RPATH "${CVMMAP_STREAMER_BUILD_RPATH}")
endif()
endfunction()
function(add_cvmmap_binary target source)
add_executable(${target} ${source} ${ARGN})
target_include_directories(${target}
@@ -311,6 +579,7 @@ function(add_cvmmap_binary target source)
set_target_properties(${target} PROPERTIES
OUTPUT_NAME "${target}"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/bin")
cvmmap_streamer_apply_runtime_rpath(${target})
endfunction()
add_cvmmap_binary(cvmmap_streamer src/main_streamer.cpp)
@@ -319,220 +588,70 @@ add_cvmmap_binary(rtp_output_tester src/testers/rtp_output_tester.cpp)
add_cvmmap_binary(rtmp_stub_tester src/testers/rtmp_stub_tester.cpp)
add_cvmmap_binary(rtmp_output_tester src/testers/rtmp_output_tester.cpp)
add_cvmmap_binary(ipc_snapshot_tester src/testers/ipc_snapshot_tester.cpp)
add_cvmmap_binary(mcap_depth_record_tester src/testers/mcap_depth_record_tester.cpp)
add_cvmmap_binary(mcap_body_record_tester src/testers/mcap_body_record_tester.cpp)
add_cvmmap_binary(mcap_body_inspector src/testers/mcap_body_inspector.cpp)
add_cvmmap_binary(mcap_pose_record_tester src/testers/mcap_pose_record_tester.cpp)
add_cvmmap_binary(mcap_multi_record_tester src/testers/mcap_multi_record_tester.cpp)
if (CVMMAP_STREAMER_HAS_MCAP)
add_cvmmap_binary(mcap_body_record_tester src/testers/mcap_body_record_tester.cpp)
add_cvmmap_binary(mcap_body_inspector src/testers/mcap_body_inspector.cpp)
add_cvmmap_binary(mcap_pose_record_tester src/testers/mcap_pose_record_tester.cpp)
add_executable(mcap_reader_tester src/testers/mcap_reader_tester.cpp)
target_include_directories(mcap_reader_tester
PRIVATE
"${CMAKE_CURRENT_LIST_DIR}/include"
"${CMAKE_CURRENT_BINARY_DIR}")
target_link_libraries(mcap_reader_tester
PRIVATE
CLI11::CLI11
cvmmap_streamer_foxglove_proto
cvmmap_streamer_depth_proto
cvmmap_streamer_mcap_runtime
mcap::mcap
PkgConfig::ZSTD
PkgConfig::LZ4)
if (TARGET spdlog::spdlog)
target_link_libraries(mcap_reader_tester PRIVATE spdlog::spdlog)
elseif (TARGET spdlog)
target_link_libraries(mcap_reader_tester PRIVATE spdlog)
endif()
target_link_libraries(mcap_reader_tester PRIVATE cvmmap_streamer_protobuf)
if (TARGET PkgConfig::PROTOBUF_PKG)
target_link_libraries(mcap_reader_tester PRIVATE PkgConfig::PROTOBUF_PKG)
endif()
set_target_properties(mcap_reader_tester PROPERTIES
OUTPUT_NAME "mcap_reader_tester"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/bin")
add_executable(mcap_reader_tester src/testers/mcap_reader_tester.cpp)
target_include_directories(mcap_reader_tester
PRIVATE
"${CMAKE_CURRENT_LIST_DIR}/include"
"${CMAKE_CURRENT_BINARY_DIR}")
target_link_libraries(mcap_reader_tester
PRIVATE
CLI11::CLI11
cvmmap_streamer_feature_flags
cvmmap_streamer_foxglove_proto
cvmmap_streamer_depth_proto
cvmmap_streamer_mcap_runtime
cvmmap_streamer_protobuf)
if (TARGET spdlog::spdlog)
target_link_libraries(mcap_reader_tester PRIVATE spdlog::spdlog)
elseif (TARGET spdlog)
target_link_libraries(mcap_reader_tester PRIVATE spdlog)
endif()
if (TARGET PkgConfig::PROTOBUF_PKG)
target_link_libraries(mcap_reader_tester PRIVATE PkgConfig::PROTOBUF_PKG)
endif()
set_target_properties(mcap_reader_tester PROPERTIES
OUTPUT_NAME "mcap_reader_tester"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/bin")
cvmmap_streamer_apply_runtime_rpath(mcap_reader_tester)
add_executable(mcap_replay_tester src/testers/mcap_replay_tester.cpp)
target_include_directories(mcap_replay_tester
PRIVATE
"${CMAKE_CURRENT_LIST_DIR}/include"
"${CMAKE_CURRENT_BINARY_DIR}")
target_link_libraries(mcap_replay_tester
PRIVATE
Threads::Threads
CLI11::CLI11
cvmmap_streamer_foxglove_proto
cvmmap_streamer_mcap_runtime
mcap::mcap
PkgConfig::ZSTD
PkgConfig::LZ4)
if (TARGET spdlog::spdlog)
target_link_libraries(mcap_replay_tester PRIVATE spdlog::spdlog)
elseif (TARGET spdlog)
target_link_libraries(mcap_replay_tester PRIVATE spdlog)
add_executable(mcap_replay_tester src/testers/mcap_replay_tester.cpp)
target_include_directories(mcap_replay_tester
PRIVATE
"${CMAKE_CURRENT_LIST_DIR}/include"
"${CMAKE_CURRENT_BINARY_DIR}")
target_link_libraries(mcap_replay_tester
PRIVATE
Threads::Threads
CLI11::CLI11
cvmmap_streamer_feature_flags
cvmmap_streamer_foxglove_proto
cvmmap_streamer_mcap_runtime
cvmmap_streamer_protobuf)
if (TARGET spdlog::spdlog)
target_link_libraries(mcap_replay_tester PRIVATE spdlog::spdlog)
elseif (TARGET spdlog)
target_link_libraries(mcap_replay_tester PRIVATE spdlog)
endif()
if (TARGET PkgConfig::PROTOBUF_PKG)
target_link_libraries(mcap_replay_tester PRIVATE PkgConfig::PROTOBUF_PKG)
endif()
set_target_properties(mcap_replay_tester PROPERTIES
OUTPUT_NAME "mcap_replay_tester"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/bin")
cvmmap_streamer_apply_runtime_rpath(mcap_replay_tester)
endif()
target_link_libraries(mcap_replay_tester PRIVATE cvmmap_streamer_protobuf)
if (TARGET PkgConfig::PROTOBUF_PKG)
target_link_libraries(mcap_replay_tester PRIVATE PkgConfig::PROTOBUF_PKG)
endif()
set_target_properties(mcap_replay_tester PROPERTIES
OUTPUT_NAME "mcap_replay_tester"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/bin")
add_executable(mcap_video_bounds src/tools/mcap_video_bounds.cpp)
target_include_directories(mcap_video_bounds
PRIVATE
"${CMAKE_CURRENT_LIST_DIR}/include"
"${CMAKE_CURRENT_BINARY_DIR}")
target_link_libraries(mcap_video_bounds
PRIVATE
CLI11::CLI11
cvmmap_streamer_foxglove_proto
cvmmap_streamer_mcap_runtime
mcap::mcap
PkgConfig::ZSTD
PkgConfig::LZ4)
if (TARGET spdlog::spdlog)
target_link_libraries(mcap_video_bounds PRIVATE spdlog::spdlog)
elseif (TARGET spdlog)
target_link_libraries(mcap_video_bounds PRIVATE spdlog)
if (CVMMAP_STREAMER_HAS_MCAP_DEPTH)
add_cvmmap_binary(mcap_depth_record_tester src/testers/mcap_depth_record_tester.cpp)
add_cvmmap_binary(mcap_multi_record_tester src/testers/mcap_multi_record_tester.cpp)
endif()
target_link_libraries(mcap_video_bounds PRIVATE cvmmap_streamer_protobuf)
if (TARGET PkgConfig::PROTOBUF_PKG)
target_link_libraries(mcap_video_bounds PRIVATE PkgConfig::PROTOBUF_PKG)
endif()
set_target_properties(mcap_video_bounds PROPERTIES
OUTPUT_NAME "mcap_video_bounds"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/bin")
set(CVMMAP_STREAMER_INSTALL_TARGETS cvmmap_streamer)
list(APPEND CVMMAP_STREAMER_INSTALL_TARGETS mcap_video_bounds)
if (CVMMAP_HAS_ZED_SDK)
add_library(
cvmmap_streamer_zed_progress_support
STATIC
src/tools/zed_progress_bar.cpp)
target_include_directories(cvmmap_streamer_zed_progress_support
PUBLIC
"${CMAKE_CURRENT_LIST_DIR}/include"
"${CMAKE_CURRENT_BINARY_DIR}")
add_executable(
zed_svo_to_mcap
src/tools/zed_svo_to_mcap.cpp
src/config/runtime_config.cpp)
target_include_directories(zed_svo_to_mcap
PRIVATE
"${CMAKE_CURRENT_LIST_DIR}/include"
"${CMAKE_CURRENT_BINARY_DIR}"
${ZED_INCLUDE_DIRS}
${CUDA_INCLUDE_DIRS})
target_link_directories(zed_svo_to_mcap
PRIVATE
${ZED_LIBRARY_DIR}
${CUDA_LIBRARY_DIRS})
target_link_libraries(zed_svo_to_mcap
PRIVATE
cvmmap_streamer_zed_progress_support
cvmmap_streamer_record_support
CLI11::CLI11
tomlplusplus::tomlplusplus
${ZED_LIBRARIES}
${CUDA_CUDA_LIBRARY}
${CUDA_CUDART_LIBRARY})
if (TARGET spdlog::spdlog)
target_link_libraries(zed_svo_to_mcap PRIVATE spdlog::spdlog)
elseif (TARGET spdlog)
target_link_libraries(zed_svo_to_mcap PRIVATE spdlog)
endif()
set_target_properties(zed_svo_to_mcap PROPERTIES
OUTPUT_NAME "zed_svo_to_mcap"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/bin")
list(APPEND CVMMAP_STREAMER_INSTALL_TARGETS zed_svo_to_mcap)
add_library(
cvmmap_streamer_zed_svo_mp4_support
STATIC
src/tools/zed_svo_mp4_support.cpp)
target_include_directories(cvmmap_streamer_zed_svo_mp4_support
PUBLIC
"${CMAKE_CURRENT_LIST_DIR}/include"
"${CMAKE_CURRENT_BINARY_DIR}")
target_link_libraries(cvmmap_streamer_zed_svo_mp4_support
PUBLIC
PkgConfig::FFMPEG)
if (TARGET spdlog::spdlog)
target_link_libraries(cvmmap_streamer_zed_svo_mp4_support PUBLIC spdlog::spdlog)
elseif (TARGET spdlog)
target_link_libraries(cvmmap_streamer_zed_svo_mp4_support PUBLIC spdlog)
endif()
add_executable(
zed_svo_to_mp4
src/tools/zed_svo_to_mp4.cpp)
target_include_directories(zed_svo_to_mp4
PRIVATE
"${CMAKE_CURRENT_LIST_DIR}/include"
"${CMAKE_CURRENT_BINARY_DIR}"
${ZED_INCLUDE_DIRS}
${CUDA_INCLUDE_DIRS})
target_link_directories(zed_svo_to_mp4
PRIVATE
${ZED_LIBRARY_DIR}
${CUDA_LIBRARY_DIRS})
target_link_libraries(zed_svo_to_mp4
PRIVATE
CLI11::CLI11
cvmmap_streamer_zed_progress_support
cvmmap_streamer_zed_svo_mp4_support
${ZED_LIBRARIES}
${CUDA_CUDA_LIBRARY}
${CUDA_CUDART_LIBRARY})
if (TARGET spdlog::spdlog)
target_link_libraries(zed_svo_to_mp4 PRIVATE spdlog::spdlog)
elseif (TARGET spdlog)
target_link_libraries(zed_svo_to_mp4 PRIVATE spdlog)
endif()
set_target_properties(zed_svo_to_mp4 PROPERTIES
OUTPUT_NAME "zed_svo_to_mp4"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/bin")
list(APPEND CVMMAP_STREAMER_INSTALL_TARGETS zed_svo_to_mp4)
if (CVMMAP_BUILD_ZED_SVO_GRID_TO_MP4)
add_executable(
zed_svo_grid_to_mp4
src/tools/zed_svo_grid_to_mp4.cpp)
target_include_directories(zed_svo_grid_to_mp4
PRIVATE
"${CMAKE_CURRENT_LIST_DIR}/include"
"${CMAKE_CURRENT_BINARY_DIR}"
${ZED_INCLUDE_DIRS}
${CUDA_INCLUDE_DIRS}
${OpenCV_INCLUDE_DIRS})
target_link_directories(zed_svo_grid_to_mp4
PRIVATE
${ZED_LIBRARY_DIR}
${CUDA_LIBRARY_DIRS})
target_link_libraries(zed_svo_grid_to_mp4
PRIVATE
CLI11::CLI11
cvmmap_streamer_zed_progress_support
cvmmap_streamer_zed_svo_mp4_support
${ZED_LIBRARIES}
${CUDA_CUDA_LIBRARY}
${CUDA_CUDART_LIBRARY}
${OpenCV_LIBS})
if (TARGET spdlog::spdlog)
target_link_libraries(zed_svo_grid_to_mp4 PRIVATE spdlog::spdlog)
elseif (TARGET spdlog)
target_link_libraries(zed_svo_grid_to_mp4 PRIVATE spdlog)
endif()
set_target_properties(zed_svo_grid_to_mp4 PROPERTIES
OUTPUT_NAME "zed_svo_grid_to_mp4"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/bin")
list(APPEND CVMMAP_STREAMER_INSTALL_TARGETS zed_svo_grid_to_mp4)
endif()
endif()
install(
TARGETS ${CVMMAP_STREAMER_INSTALL_TARGETS}
+15 -301
View File
@@ -45,17 +45,6 @@ cmake -B build -S .
cmake --build build
```
When the ZED SDK is available, the build also enables `zed_svo_to_mcap` and
`zed_svo_to_mp4` automatically. When the SDK is absent, those tools are skipped
and the main streamer plus non-ZED testers still build normally.
`zed_svo_grid_to_mp4` remains optional and additionally requires OpenCV. Disable
it explicitly with:
```bash
cmake -B build -S . -DCVMMAP_BUILD_ZED_SVO_GRID_TO_MP4=OFF
```
```bash
# Use a local cv-mmap build tree
cmake -B build -S . \
@@ -69,300 +58,25 @@ cmake --build build
ls -la build/{cvmmap_streamer,rtp_receiver_tester,rtmp_stub_tester}
```
### ZED SVO/SVO2 To MP4
### Offline ZED Tooling
This tool is only built when the ZED SDK is detected during CMake configure.
Offline ZED conversion, batch wrappers, dataset indexing, and MCAP inspection helpers moved to the sibling repository `../zed-offline-tools`.
The repo also includes an offline conversion tool for the left ZED color stream:
Use that repo for:
```bash
CUDA_VISIBLE_DEVICES=GPU-9cc7b26e-90d4-0c49-4d4c-060e528ffba6 \
./build/bin/zed_svo_to_mp4 \
--input <SVO_INPUT> \
--encoder-device auto \
--preset balanced \
--quality 20 \
--start-frame 0 \
--end-frame 89
```
- `zed_svo_to_mcap`
- `zed_svo_to_mp4`
- `zed_svo_grid_to_mp4`
- `mcap_video_bounds`
- `scripts/zed_batch_*`
- `scripts/zed_segment_time_index.py`
- `scripts/generate_playlist_config.py`
- `scripts/mcap_bundle_validator.py`
- `scripts/mcap_rgbd_example.py`
- `scripts/mcap_rgbd_viewer.py`
- `scripts/mcap_depth_alignment.py`
By default the tool writes `foo.mp4` next to `foo.svo` or `foo.svo2`, defaults to `h265`, and shows a tqdm-like progress bar when stderr is attached to a TTY. `--encoder-device auto` tries NVENC first and falls back to software (`libx264` or `libx265`) if the hardware encoder is unavailable or cannot be opened.
### Batch ZED SVO2 To MP4
Python dependencies for the batch wrapper are managed with `uv`:
```bash
uv sync
```
Expected multi-camera dataset layout:
```text
<DATASET_ROOT>/
├── svo2_segments_sorted.csv
├── bar/
│ └── 2026-03-18T11-59-41/
│ ├── 2026-03-18T11-59-41_zed1.svo2
│ ├── 2026-03-18T11-59-41_zed2.svo2
│ ├── 2026-03-18T11-59-41_zed3.svo2
│ └── 2026-03-18T11-59-41_zed4.svo2
└── jump/
└── experiment/
└── 1/
└── 2026-03-18T11-26-23/
├── 2026-03-18T11-26-23_zed1.svo2
├── 2026-03-18T11-26-23_zed2.svo2
├── 2026-03-18T11-26-23_zed3.svo2
└── 2026-03-18T11-26-23_zed4.svo2
```
Placeholders used below:
- `<DATASET_ROOT>`: dataset root containing multi-camera segment directories
- `<SEGMENT_DIR>`: one multi-camera segment directory containing `*_zedN.svo` or `*_zedN.svo2`
- `<SEGMENT_DIR_A>`, `<SEGMENT_DIR_B>`: explicit segment directories
- `<SEGMENTS_CSV>`: CSV file with a `segment_dir` column, for example `config/svo2_segments_sorted.sample.csv`
- `<SVO_INPUT>`: one single-camera `.svo` or `.svo2` file
- `<POSE_CONFIG>`: TOML file such as `config/zed_pose_config.toml`
Use the wrapper to recurse through a folder, run `zed_svo_to_mp4` on every matched `.svo2`, and show one aggregate tqdm progress bar:
```bash
uv run python scripts/zed_batch_svo_to_mp4.py \
<DATASET_ROOT>/bar \
--pattern '*.svo2' \
--recursive \
--jobs 2 \
--encoder-device auto \
--start-frame 0 \
--end-frame 29 \
--cuda-visible-devices GPU-9cc7b26e-90d4-0c49-4d4c-060e528ffba6
```
The batch tool mirrors the common encoder options from `zed_svo_to_mp4`, skips existing sibling `.mp4` outputs by default, and continues after failures while returning a nonzero exit code if any conversion fails.
### ZED SVO Grid To MP4
This tool is only built when the ZED SDK is detected and
`CVMMAP_BUILD_ZED_SVO_GRID_TO_MP4=ON`.
Use the grid converter to merge four synced ZED recordings into a 2x2 CCTV-style MP4 with a Unix timestamp overlay in the top-left corner:
```bash
./build/bin/zed_svo_grid_to_mp4 \
--segment-dir <SEGMENT_DIR> \
--encoder-device auto \
--codec h265 \
--duration-seconds 2
```
The tool syncs the four inputs using the same common-start timestamp rule as the ZED multi-camera playback sample, defaults to a 2x2 layout ordered as `zed1 zed2 / zed3 zed4`, and writes `<segment>/<segment>_grid.mp4` unless `--output` is provided. By default each tile is scaled to `0.5x`, so a four-camera 1920x1200 segment produces a 1920x1200 composite. Use repeated `--input` flags instead of `--segment-dir` when you want explicit row-major ordering.
Use the batch wrapper to run `zed_svo_grid_to_mp4` over many segment directories with one aggregate progress bar:
```bash
uv run python scripts/zed_batch_svo_grid_to_mp4.py \
--dataset-root <DATASET_ROOT> \
--recursive \
--jobs 2 \
--encoder-device auto \
--duration-seconds 2
```
You can also provide the exact segments to convert:
```bash
uv run python scripts/zed_batch_svo_grid_to_mp4.py \
--segment <SEGMENT_DIR_A> \
--segment <SEGMENT_DIR_B> \
--jobs 2
```
Or preserve a precomputed CSV ordering:
```bash
uv run python scripts/zed_batch_svo_grid_to_mp4.py \
--segments-csv <SEGMENTS_CSV> \
--jobs 2 \
--duration-seconds 2
```
The batch grid wrapper mirrors the grid encoder options, skips existing `<segment>/<segment>_grid.mp4` outputs by default, and returns a nonzero exit code if any segment fails.
When you suspect a previous run left behind partial MP4 files, opt into `ffprobe` validation so broken existing outputs are treated as missing instead of skipped:
```bash
uv run python scripts/zed_batch_svo_grid_to_mp4.py \
--dataset-root <DATASET_ROOT> \
--probe-existing \
--jobs 2
```
Use `--report-existing` to audit existing outputs without launching conversions. The report prints invalid existing files only, while the summary still includes valid and missing counts. This is useful for the partial-write failure mode currently seen as `moov atom not found` in some kindergarten grid MP4s:
```bash
uv run python scripts/zed_batch_svo_grid_to_mp4.py \
--dataset-root <DATASET_ROOT> \
--report-existing
```
Use `--dry-run` to preview what the batch wrapper would convert after applying skip logic. Combine it with `--probe-existing` when you want to see which broken existing outputs would be requeued:
```bash
uv run python scripts/zed_batch_svo_grid_to_mp4.py \
<DATASET_ROOT> \
--probe-existing \
--dry-run
```
#### Expected CSV Input Format
The `--segments-csv` input expects a header row with at least a `segment_dir` column. Extra columns are allowed and ignored by the batch wrapper. `segment_dir` values may be absolute paths or paths relative to the CSV file's parent directory. Use `--csv-root` to override that base directory.
Repeated rows for the same `segment_dir` are allowed; the wrapper converts each unique segment once, preserving the first-seen CSV order. The repo includes a small example at `config/svo2_segments_sorted.sample.csv`:
```csv
timestamp,activity,group_path,segment_dir,camera,relative_path
2026-03-18T11-23-22,jump,jump/external/recording,jump/external/recording/2026-03-18T11-23-22,zed1,jump/external/recording/2026-03-18T11-23-22/2026-03-18T11-23-22_zed1.svo2
2026-03-18T11-23-22,jump,jump/external/recording,jump/external/recording/2026-03-18T11-23-22,zed2,jump/external/recording/2026-03-18T11-23-22/2026-03-18T11-23-22_zed2.svo2
```
### Batch ZED Segments To MCAP
This workflow depends on the `zed_svo_to_mcap` binary, which is only built when
the ZED SDK is detected during CMake configure.
Use the wrapper to recurse through a dataset root, run `zed_svo_to_mcap --segment-dir` on every matched multi-camera segment, and show interactive table progress on TTYs with durable text logging elsewhere:
```bash
uv run python scripts/zed_batch_svo_to_mcap.py \
--dataset-root <DATASET_ROOT> \
--recursive \
--jobs 2 \
--cuda-visible-devices GPU-9cc7b26e-90d4-0c49-4d4c-060e528ffba6 \
--start-frame 10 \
--end-frame 29
```
You can also preserve the precomputed kindergarten CSV ordering:
```bash
uv run python scripts/zed_batch_svo_to_mcap.py \
--segments-csv <SEGMENTS_CSV> \
--jobs 2 \
--start-frame 10 \
--end-frame 29
```
Enable per-camera pose export when the segment has valid tracking:
```bash
uv run python scripts/zed_batch_svo_to_mcap.py \
--segment <SEGMENT_DIR> \
--with-pose \
--pose-config <POSE_CONFIG>
```
The batch MCAP wrapper writes `<segment>/<segment>.mcap` by default, skips existing outputs unless told otherwise, and returns a nonzero exit code if any segment fails.
The repo includes a minimal pose config at `config/zed_pose_config.toml` so MCAP conversion does not depend on a separate `cv-mmap` checkout.
In bundled multi-camera timeline mode, `--start-frame` and `--end-frame` mean the first and last emitted bundle indices from the common start timestamp, inclusive.
When stderr is attached to a TTY, `zed_batch_svo_to_mcap.py` uses a `progress-table` view by default; otherwise it emits line-oriented start/completion/failure logs plus periodic heartbeat summaries. Use `--progress-ui table` or `--progress-ui text` to override the automatic mode selection.
Bundled MCAP export now defaults to `--bundle-policy nearest`. That mode emits one `/bundle` manifest message per bundle timestamp on the common timeline and keeps the original per-camera timestamps on `/zedN/video`, `/zedN/depth`, and optional `/zedN/pose`. Faster cameras are sampled onto the slowest common timeline there, so they can end up with the same message count as slower cameras. Consumers that care about grouping should follow `/bundle` instead of inferring bundle membership from identical message timestamps.
Use `--bundle-policy strict` when you want thresholded grouping; `--sync-tolerance-ms` only applies in that strict mode. Use `--bundle-policy copy` when you want one MCAP containing all camera namespaces with their original per-camera cadence and no `/bundle` manifest. `copy` disables `--start-frame`, `--end-frame`, and `--sync-tolerance-ms`; `--copy-range common|full` controls whether it trims to the overlap window or preserves each cameras full timestamp range.
Single-source `zed_svo_to_mcap` now writes the one-camera `copy` shape by default, so `foo_zed4.svo2` exports namespaced topics like `/zed4/video` and `/zed4/depth` with no `/bundle`. See [docs/mcap_layout.md](./docs/mcap_layout.md) for the current bundled/copy contract and [docs/mcap_legacy_single_camera_layout.md](./docs/mcap_legacy_single_camera_layout.md) for the separate legacy `/camera/*` reference.
For the simple non-GUI path, use `scripts/mcap_rgbd_example.py` and [docs/mcap_recipes.md](./docs/mcap_recipes.md). That helper supports current `bundled` and `copy` MCAPs, and it also accepts the legacy `/camera/*` shape by treating it as a single-camera stream with the literal label `camera`.
For calibration-based depth/RGB mapping, use `scripts/mcap_depth_alignment.py` and [docs/depth_alignment.md](./docs/depth_alignment.md). That helper explains the current affine mapping implied by the exported calibration topics and can export example aligned-depth and overlay PNGs from a chosen MCAP frame.
### MCAP RGBD Viewer
The repo includes an example RGB+depth viewer at `scripts/mcap_rgbd_viewer.py`. It supports legacy standalone `/camera/*` MCAPs, bundled `/bundle` + `/zedN/*` MCAPs, and `copy` MCAPs with namespaced `/{label}/*` topics and no `/bundle`, including the default single-source output from `zed_svo_to_mcap`.
Install the optional viewer dependencies first:
```bash
uv sync --extra viewer
```
Then launch the interactive viewer:
```bash
uv run --extra viewer python scripts/mcap_rgbd_viewer.py \
/workspaces/data/kindergarten/bar/2026-03-18T11-59-41/2026-03-18T11-59-41.mcap \
--camera-label zed1
```
You can also use the same script without a GUI to inspect metadata or render a preview PNG:
```bash
uv run --extra viewer python scripts/mcap_rgbd_viewer.py \
--summary-only \
/workspaces/data/kindergarten/bar/2026-03-18T11-59-41/2026-03-18T11-59-41.mcap
```
```bash
uv run --extra viewer python scripts/mcap_rgbd_viewer.py \
--camera-label zed2 \
--frame-index 150 \
--export-preview /tmp/mcap_bundled_gap_preview.png \
/workspaces/data/kindergarten/throw/2026-03-18T12-58-13/2026-03-18T12-58-13.mcap
```
The viewer depends on `ffmpeg` being on `PATH` so it can build a seek-friendly preview cache for H.264/H.265 MCAP video streams.
This is intentionally a simple preview script: it transcodes only the RGB video stream into a temporary intra-frame `mjpeg` cache and then uses that same cache for both scrubbing and normal playback. Depth data is not transcoded to `mjpeg`; it stays in the temporary raw depth cache and is decoded and color-mapped on demand.
### Why Mixed Hardware/Software Mode Exists
Bundled MCAP export opens one video encoder per camera stream. A four-camera segment therefore consumes four H.264/H.265 encoder sessions at once.
This matters because NVIDIA's NVENC session limit is separate from raw CUDA utilization. In NVIDIA's Video Codec SDK documentation, non-qualified systems are capped at 8 concurrent encode sessions across all non-qualified GPUs in the system, and NVIDIA's SDK readme still calls out a 5-session GeForce limit in some contexts. In practice, consumer/GeForce hosts often hit NVENC session-init failures before the GPUs look "full" in `nvidia-smi`.
That is why the batch wrapper supports mixed pools such as two NVENC workers plus two software-encoded workers:
```bash
uv run python scripts/zed_batch_svo_to_mcap.py \
--dataset-root <DATASET_ROOT> \
--recursive \
--overwrite \
--hardware-jobs 2 \
--hardware-cuda-visible-devices 0,1 \
--software-jobs 2 \
--software-cuda-visible-devices 0,1 \
--depth-mode neural_plus
```
With bundled four-camera segments, `4` all-hardware jobs would try to open about `16` NVENC sessions, which is why mixed mode is the safe default for high-throughput rebuilds on GeForce-class machines. The software workers still use the GPUs for ZED neural depth; only video encoding moves to CPU.
If you intentionally want to bypass NVIDIA's consumer NVENC session cap, there is an unofficial driver patch at [`keylase/nvidia-patch`](https://github.com/keylase/nvidia-patch). That can make larger all-hardware batches viable, but it is not NVIDIA-supported and should be treated as an explicit ops decision rather than a project requirement.
Use `--probe-existing` to validate existing MCAPs before skipping them. Invalid outputs are treated as missing and requeued:
```bash
uv run python scripts/zed_batch_svo_to_mcap.py \
--dataset-root <DATASET_ROOT> \
--probe-existing \
--jobs 2
```
Use `--report-existing` to audit existing MCAPs without launching conversions:
```bash
uv run python scripts/zed_batch_svo_to_mcap.py \
--dataset-root <DATASET_ROOT> \
--report-existing
```
Use `--dry-run` to preview what would be converted after applying skip or probe logic:
```bash
uv run python scripts/zed_batch_svo_to_mcap.py \
--segments-csv <SEGMENTS_CSV> \
--probe-existing \
--dry-run
```
This repo keeps the live downstream streamer/runtime plus the MCAP contract docs such as [docs/mcap_layout.md](./docs/mcap_layout.md), [docs/mcap_legacy_single_camera_layout.md](./docs/mcap_legacy_single_camera_layout.md), and [docs/mcap_body_tracking.md](./docs/mcap_body_tracking.md).
### Mandatory Acceptance (Standalone)
-7
View File
@@ -1,7 +0,0 @@
timestamp,activity,group_path,segment_dir,camera,relative_path
2026-03-18T11-23-22,jump,jump/external/recording,jump/external/recording/2026-03-18T11-23-22,zed1,jump/external/recording/2026-03-18T11-23-22/2026-03-18T11-23-22_zed1.svo2
2026-03-18T11-23-22,jump,jump/external/recording,jump/external/recording/2026-03-18T11-23-22,zed2,jump/external/recording/2026-03-18T11-23-22/2026-03-18T11-23-22_zed2.svo2
2026-03-18T11-23-22,jump,jump/external/recording,jump/external/recording/2026-03-18T11-23-22,zed3,jump/external/recording/2026-03-18T11-23-22/2026-03-18T11-23-22_zed3.svo2
2026-03-18T11-23-22,jump,jump/external/recording,jump/external/recording/2026-03-18T11-23-22,zed4,jump/external/recording/2026-03-18T11-23-22/2026-03-18T11-23-22_zed4.svo2
2026-03-18T11-26-23,jump,jump/experiment/1,jump/experiment/1/2026-03-18T11-26-23,zed1,jump/experiment/1/2026-03-18T11-26-23/2026-03-18T11-26-23_zed1.svo2
2026-03-18T11-26-23,jump,jump/experiment/1,jump/experiment/1/2026-03-18T11-26-23,zed2,jump/experiment/1/2026-03-18T11-26-23/2026-03-18T11-26-23_zed2.svo2
1 timestamp activity group_path segment_dir camera relative_path
2 2026-03-18T11-23-22 jump jump/external/recording jump/external/recording/2026-03-18T11-23-22 zed1 jump/external/recording/2026-03-18T11-23-22/2026-03-18T11-23-22_zed1.svo2
3 2026-03-18T11-23-22 jump jump/external/recording jump/external/recording/2026-03-18T11-23-22 zed2 jump/external/recording/2026-03-18T11-23-22/2026-03-18T11-23-22_zed2.svo2
4 2026-03-18T11-23-22 jump jump/external/recording jump/external/recording/2026-03-18T11-23-22 zed3 jump/external/recording/2026-03-18T11-23-22/2026-03-18T11-23-22_zed3.svo2
5 2026-03-18T11-23-22 jump jump/external/recording jump/external/recording/2026-03-18T11-23-22 zed4 jump/external/recording/2026-03-18T11-23-22/2026-03-18T11-23-22_zed4.svo2
6 2026-03-18T11-26-23 jump jump/experiment/1 jump/experiment/1/2026-03-18T11-26-23 zed1 jump/experiment/1/2026-03-18T11-26-23/2026-03-18T11-26-23_zed1.svo2
7 2026-03-18T11-26-23 jump jump/experiment/1 jump/experiment/1/2026-03-18T11-26-23 zed2 jump/experiment/1/2026-03-18T11-26-23/2026-03-18T11-26-23_zed2.svo2
-18
View File
@@ -1,18 +0,0 @@
# Minimal pose-tracking config for zed_svo_to_mcap.
# The converter currently reads only:
# - zed.coordinate_system
# - zed.body_tracking.reference_frame
# - zed.body_tracking.set_floor_as_origin
[zed]
# Native ZED 3D/body coordinate system used when reading positional tracking.
# Supported values in this repo are IMAGE and RIGHT_HANDED_Y_UP.
coordinate_system = "IMAGE"
[zed.body_tracking]
# Reference frame used for per-camera pose estimation.
# Supported values are CAMERA and WORLD.
reference_frame = "CAMERA"
# When true, WORLD origin is placed on the floor during positional tracking.
set_floor_as_origin = false
+19 -13
View File
@@ -16,30 +16,36 @@ Legacy flags such as `--shm-name`, `--zmq-endpoint`, `--input-mode`, and the dum
## Encoder Path
### FFmpeg Is The Only Encoder Backend
### Jetson Hardware Encode Uses The Jetson Multimedia API Backend
The public backend surface is:
The public backend surface is still:
- `--encoder-backend auto`
- `--encoder-backend ffmpeg`
Both resolve to the FFmpeg encoder path. The removed GStreamer backend is no longer available.
`--encoder-backend ffmpeg` keeps the existing FFmpeg encoder path.
`--encoder-backend auto` may now select the in-repo Jetson Multimedia API backend on Jetson builds when hardware encoding is requested.
### NVENC Is Optional
The removed GStreamer backend is still unavailable.
When `--encoder-device nvidia` is selected, FFmpeg must expose `h264_nvenc` and `hevc_nvenc`.
### NVIDIA Device Mode On Jetson No Longer Depends On FFmpeg Hardware Encoders
Useful local checks:
On Jetson builds with `CVMMAP_STREAMER_ENABLE_JETSON_MM` enabled, `--encoder-device nvidia` uses the Jetson Multimedia API encoder directly rather than desktop `*_nvenc` or Jetson FFmpeg `*_v4l2m2m` encoder exposure.
Selection behavior is now:
- `--encoder-device software` -> FFmpeg software encode (`libx264`/`libx265`)
- `--encoder-device nvidia` -> Jetson Multimedia API backend only; fail if unavailable
- `--encoder-device auto` -> try Jetson Multimedia API first on Jetson builds, then fall back to FFmpeg software
Useful local checks on Jetson:
```bash
ffmpeg -hide_banner -encoders | rg 'nvenc|libx264|libx265'
test -e /dev/v4l2-nvenc
gst-inspect-1.0 nvv4l2h264enc nvv4l2h265enc
```
If NVENC is unavailable, use:
```bash
--encoder-device software
```
FFmpeg encoder enumeration is no longer the authoritative Jetson hardware-encode check for this repo.
### Low-Latency Defaults
@@ -47,7 +53,7 @@ The current low-latency defaults are:
- `gop=30`
- `b_frames=0`
- NVENC preset/tune tuned for low latency
- encoder-family-specific low-latency options where FFmpeg exposes them
This favors immediacy over compression efficiency.
+2 -2
View File
@@ -27,7 +27,7 @@ Notes:
- RTMP is Enhanced RTMP only.
- The custom RTMP packetizer and domestic mode are removed.
- `encoder.backend` remains `auto|ffmpeg`; both resolve to FFmpeg.
- `encoder.backend` remains `auto|ffmpeg`; `auto` may select Jetson Multimedia API on Jetson builds, while `ffmpeg` forces the FFmpeg encoder path.
## Optional Checks (Non-Blocking)
@@ -60,7 +60,7 @@ Current recording scope:
| Setting | Value |
|---------|-------|
| Encoder backend | `auto` -> FFmpeg |
| Encoder backend | `auto` -> Jetson MM on Jetson hardware requests, else FFmpeg |
| RTMP transport | `libavformat` |
| RTMP mode | Enhanced only |
| Encoder device | `auto` |
+1 -1
View File
@@ -137,4 +137,4 @@ For multi-camera `copy` MCAP files, the current validation contract is:
Legacy `/camera/*` validation expectations are documented in [mcap_legacy_single_camera_layout.md](./mcap_legacy_single_camera_layout.md).
The repository-level Python helper [scripts/mcap_bundle_validator.py](../scripts/mcap_bundle_validator.py) understands bundled, copy, and legacy `/camera/*` layouts and reports which one it found before applying the corresponding validation rules.
The standalone helper [zed-offline-tools/scripts/mcap_bundle_validator.py](../../zed-offline-tools/scripts/mcap_bundle_validator.py) understands bundled, copy, and legacy `/camera/*` layouts and reports which one it found before applying the corresponding validation rules.
-179
View File
@@ -1,179 +0,0 @@
# MCAP Recipes
This guide is the simple, non-GUI path for inspecting RGB+depth MCAP files.
Use it when you want to:
- confirm whether an MCAP is bundled, `copy`, or legacy `/camera/*`
- inspect camera labels, message counts, and timestamp ranges
- export one RGB frame and one decoded depth sample as a concrete example
- understand how `/bundle` changes the meaning of timestamps and sample grouping
For the current bundled/copy layout contract, see [mcap_layout.md](./mcap_layout.md). The older `/camera/*` wire shape is documented separately in [mcap_legacy_single_camera_layout.md](./mcap_legacy_single_camera_layout.md).
## Quick Summary
The repository includes a small example helper:
```bash
uv run python scripts/mcap_rgbd_example.py --help
```
It has two commands:
- `summary`: print layout, per-camera counts, and timestamp ranges
- `export-sample`: write one RGB image plus one depth array/preview
`summary` works with the base Python dependencies:
```bash
uv sync
```
`export-sample` also needs:
- `ffmpeg` on `PATH`
- the optional depth decoder binding:
```bash
uv sync --extra viewer
```
## The Practical Cases
For this helper, there are really two operational cases:
- `bundled`: multiple namespaced camera topics plus `/bundle`
- single-camera stream with no `/bundle`
That second case can appear in two wire shapes:
- `copy`: namespaced topics such as `/zed4/video`
- legacy single-camera: `/camera/video`
Current single-source `zed_svo_to_mcap` output uses the one-camera `copy` shape by default, so even a one-camera file usually looks like namespaced `/{label}/*` topics with no `/bundle`.
The helper treats legacy `/camera/*` as compatible with `copy` by using the implicit camera label `camera`.
## Recipe: Summarize One MCAP
```bash
uv run python scripts/mcap_rgbd_example.py summary <MCAP_PATH>
```
What the summary prints:
- layout and validation status
- camera labels
- per-camera `video`, `depth`, `pose`, `calibration`, `depth_calibration`, and `body` counts
- per-camera video/depth timestamp ranges
- for bundled files only:
- bundle count
- bundle timestamp range
- bundle policy counts
- per-camera present/corrupted-gap/unknown bundle-member counts
This is the fastest way to answer:
- “is this file bundled, copy, or legacy single-camera?”
- “which camera labels are inside?”
- “do video and depth counts match?”
- “what timestamp range does each camera cover?”
## Recipe: Export One RGB + Depth Sample
```bash
uv run python scripts/mcap_rgbd_example.py export-sample \
<MCAP_PATH> \
--output-dir /tmp/mcap_sample
```
For multi-camera or namespaced one-camera files, choose the camera explicitly when needed:
```bash
uv run python scripts/mcap_rgbd_example.py export-sample \
<MCAP_PATH> \
--camera-label zed2 \
--sample-index 25 \
--output-dir /tmp/mcap_sample_zed2
```
Outputs:
- `rgb.png`
- `depth.npy`
- `depth_preview.png`
- `sample_metadata.json`
`sample_index` is always zero-based per-camera RGB+depth sample order.
That means:
- legacy `/camera/*`: sample `N` is `/camera/video[N]` + `/camera/depth[N]`
- `copy`: sample `N` is `/{label}/video[N]` + `/{label}/depth[N]`
- `bundled`: sample `N` is the `N`th present sample for that camera, not bundle index `N`
In bundled files, `sample_metadata.json` also records the matched `/bundle` member metadata for the selected camera sample.
## Recipe: Understand Bundled vs Non-Bundled Timing
Bundled files intentionally separate bundle time from camera sample time:
- `/bundle.timestamp` is the nominal common-timeline bundle timestamp
- `/zedN/video` and `/zedN/depth` keep the original per-camera sample timestamps
Copy and legacy single-camera files do not have bundle time at all:
- there is no `/bundle`
- each camera topic keeps its own original cadence and timestamps
If you care about grouping, use `/bundle` in bundled files.
For `copy` and legacy single-camera files, treat each camera stream independently.
## Recipe: Inspect `/bundle` In Python
The helper script is intentionally small, but sometimes it is easier to inspect `/bundle` directly.
This snippet shows how to print bundle membership for one camera:
```python
from pathlib import Path
import zed_batch_svo_to_mcap as batch
path = Path("<MCAP_PATH>").expanduser().resolve()
camera_label = "zed1"
reader_module = batch.load_mcap_reader()
with path.open("rb") as stream:
reader = reader_module.make_reader(stream)
for schema, channel, message in reader.iter_messages():
if channel.topic != "/bundle":
continue
if schema is None or schema.name != "cvmmap_streamer.BundleManifest":
continue
bundle_class, present_value = batch.load_bundle_manifest_type(schema.data)
bundle = bundle_class()
bundle.ParseFromString(message.data)
for member in bundle.members:
if str(member.camera_label) != camera_label:
continue
status_value = int(getattr(member, "status", 0))
status_field = member.DESCRIPTOR.fields_by_name.get("status")
status_enum = status_field.enum_type if status_field is not None else None
status_name = (
status_enum.values_by_number.get(status_value).name
if status_enum is not None and status_enum.values_by_number.get(status_value) is not None
else str(status_value)
)
print(bundle.bundle_index, status_name)
break
```
This is the important mental model:
- `bundled`: follow `/bundle` for grouping
- `copy`: treat each namespaced camera as an independent stream
- legacy `/camera/*`: same model as one-camera `copy`, with the implicit label `camera`
-97
View File
@@ -1,97 +0,0 @@
# ZED Segment Time Index
`scripts/zed_segment_time_index.py` builds and queries an embedded DuckDB index for bundled ZED segment folders.
Default artifact name:
```text
<DATASET_ROOT>/segment_time_index.duckdb
```
Primary commands:
```bash
uv run python scripts/zed_segment_time_index.py build <DATASET_ROOT>
uv run python scripts/zed_segment_time_index.py query <DATASET_ROOT> --at 2026-03-18T12-00-23
uv run python scripts/zed_segment_time_index.py query <DATASET_ROOT> --start 2026-03-18T12-00-23 --end 2026-03-18T12-00-30
```
## Data Source Rules
- Segment discovery is recursive and follows the same multi-camera layout assumptions as the batch ZED tooling.
- A directory is considered a valid segment when it contains at least two unique `*_zedN.svo` or `*_zedN.svo2` files and no duplicate camera labels.
- Timing is sourced from the segment MCAP, not from the SVO/SVO2 files.
- A valid segment is skipped when it has no `.mcap` file or more than one `.mcap` file in the segment directory.
## MCAP Bounds Extraction
`build/bin/mcap_video_bounds` scans `foxglove.CompressedVideo` messages in one MCAP and emits:
- `start_ns`
- `end_ns`
- `duration_ns`
- `video_message_count`
- `start_iso_utc`
- `end_iso_utc`
The helper prefers the protobuf `CompressedVideo.timestamp` field and falls back to MCAP `logTime` when that field is zero.
## DuckDB Layout
The database contains two tables: `meta` and `segments`.
### `meta`
Key-value metadata for the index:
- `schema_version`: current schema version, currently `1`
- `dataset_root`: absolute dataset root used when the index was built
- `built_at_utc`: build timestamp in UTC
- `default_timezone`: inferred dataset wall-clock timezone used when querying with `--timezone dataset`
### `segments`
One row per indexed segment.
| Column | Type | Meaning |
|---|---|---|
| `segment_dir` | `VARCHAR` | Absolute path to the segment directory |
| `relative_segment_dir` | `VARCHAR` | Path relative to the dataset root |
| `group_path` | `VARCHAR` | Parent path of the segment within the dataset |
| `activity` | `VARCHAR` | First path component under the dataset root |
| `segment_name` | `VARCHAR` | Segment directory basename |
| `mcap_path` | `VARCHAR` | Absolute MCAP path used for timing |
| `start_ns` | `BIGINT` | Earliest video timestamp in nanoseconds since Unix epoch |
| `end_ns` | `BIGINT` | Latest video timestamp in nanoseconds since Unix epoch |
| `duration_ns` | `BIGINT` | `end_ns - start_ns` |
| `start_iso_utc` | `VARCHAR` | UTC rendering of `start_ns` |
| `end_iso_utc` | `VARCHAR` | UTC rendering of `end_ns` |
| `camera_count` | `INTEGER` | Number of discovered camera inputs in the segment directory |
| `camera_labels` | `VARCHAR` | Comma-separated camera labels, for example `zed1,zed2,zed3,zed4` |
| `video_message_count` | `BIGINT` | Number of `foxglove.CompressedVideo` messages observed in the MCAP |
| `index_source` | `VARCHAR` | Current extractor label, currently `mcap_video_bounds` |
Indexes are created on `start_ns` and `end_ns`.
## Query Semantics
- `--at` performs an overlap lookup, not just an exact nanosecond equality check.
- Query precision follows the precision supplied by the user.
- A second-precision value like `2026-03-18T12-00-23` is treated as the whole second `[12:00:23.000, 12:00:23.999999999]`.
- Integer epochs are widened similarly by their apparent unit:
- 10 digits or fewer: seconds
- 11-13 digits: milliseconds
- 14-16 digits: microseconds
- 17+ digits: nanoseconds
- `--start/--end` returns every segment whose `[start_ns, end_ns]` overlaps the requested interval.
## Timezone Behavior
- Query default is `--timezone dataset`.
- `dataset` resolves to the `default_timezone` stored in `meta`.
- If inference is unavailable, the script falls back to `local`.
- Explicit values are also accepted:
- `local`
- `UTC`
- fixed offsets such as `UTC+08:00`
- IANA zone names such as `Asia/Shanghai`
@@ -8,6 +8,15 @@
#include <string_view>
#include <vector>
#ifndef CVMMAP_STREAMER_HAS_MCAP
#define CVMMAP_STREAMER_HAS_MCAP 0
#endif
#ifndef CVMMAP_STREAMER_HAS_MCAP_DEPTH
#define CVMMAP_STREAMER_HAS_MCAP_DEPTH 0
#endif
namespace cvmmap_streamer {
enum class CodecType {
@@ -89,6 +98,7 @@ struct OutputsConfig {
struct McapRecordConfig {
bool enabled{false};
bool depth_enabled{true};
std::string path{"capture.mcap"};
std::string topic{"/camera/video"};
std::string depth_topic{"/camera/depth"};
@@ -0,0 +1,93 @@
#pragma once
#include "proto/cvmmap_streamer/recorder_control.pb.h"
#include <cstdint>
#include <expected>
#include <functional>
#include <memory>
#include <span>
#include <string>
namespace cvmmap_streamer::protocol {
enum class RpcErrorCode : std::uint8_t {
InvalidRequest,
Unsupported,
Busy,
Internal,
};
struct RpcError {
RpcErrorCode code{RpcErrorCode::Internal};
std::string message{};
};
struct NatsRequestReplyServerOptions {
std::string nats_url{};
std::string instance_name{};
std::string namespace_name{};
std::string ipc_prefix{};
std::string base_name{};
std::string nats_target_key{};
std::string backend{"cvmmap-streamer"};
std::string recording_formats{};
};
class NatsRequestReplyServer {
public:
explicit NatsRequestReplyServer(NatsRequestReplyServerOptions options);
~NatsRequestReplyServer();
NatsRequestReplyServer(const NatsRequestReplyServer &) = delete;
NatsRequestReplyServer &operator=(const NatsRequestReplyServer &) = delete;
template <class Request, class Response>
void register_proto_endpoint(
std::string endpoint_name,
std::string subject,
std::function<std::expected<Response, RpcError>(const Request &)> handler) {
register_raw_endpoint(
std::move(endpoint_name),
std::move(subject),
[handler = std::move(handler)](std::span<const std::uint8_t> payload) {
Request request;
Response response;
if (!request.ParseFromArray(
payload.data(),
static_cast<int>(payload.size()))) {
response.set_code(cvmmap_streamer::proto::RPC_CODE_INVALID_REQUEST);
response.set_error_message("failed to parse protobuf request");
return response.SerializeAsString();
}
auto handled = handler(request);
if (!handled) {
response.set_code(to_proto_rpc_code(handled.error().code));
response.set_error_message(handled.error().message);
return response.SerializeAsString();
}
return handled->SerializeAsString();
});
}
[[nodiscard]]
bool start();
void stop();
private:
struct Endpoint;
struct Impl;
void register_raw_endpoint(
std::string endpoint_name,
std::string subject,
std::function<std::string(std::span<const std::uint8_t>)> handler);
static cvmmap_streamer::proto::RpcCode to_proto_rpc_code(RpcErrorCode code);
std::unique_ptr<Impl> impl_;
};
} // namespace cvmmap_streamer::protocol
@@ -0,0 +1,36 @@
#pragma once
#include <string>
#include <string_view>
namespace cvmmap_streamer::protocol {
inline std::string streamer_subject_prefix(std::string_view target_key) {
return std::string("cvmmap.") + std::string(target_key) + ".streamer";
}
inline std::string subject_recorder_mp4_start(std::string_view target_key) {
return streamer_subject_prefix(target_key) + ".recorder.mp4.start";
}
inline std::string subject_recorder_mp4_stop(std::string_view target_key) {
return streamer_subject_prefix(target_key) + ".recorder.mp4.stop";
}
inline std::string subject_recorder_mp4_status(std::string_view target_key) {
return streamer_subject_prefix(target_key) + ".recorder.mp4.status";
}
inline std::string subject_recorder_mcap_start(std::string_view target_key) {
return streamer_subject_prefix(target_key) + ".recorder.mcap.start";
}
inline std::string subject_recorder_mcap_stop(std::string_view target_key) {
return streamer_subject_prefix(target_key) + ".recorder.mcap.stop";
}
inline std::string subject_recorder_mcap_status(std::string_view target_key) {
return streamer_subject_prefix(target_key) + ".recorder.mcap.status";
}
} // namespace cvmmap_streamer::protocol
@@ -13,6 +13,15 @@
#include <string>
#include <string_view>
#ifndef CVMMAP_STREAMER_HAS_MCAP
#define CVMMAP_STREAMER_HAS_MCAP 0
#endif
#ifndef CVMMAP_STREAMER_HAS_MCAP_DEPTH
#define CVMMAP_STREAMER_HAS_MCAP_DEPTH 0
#endif
namespace cvmmap_streamer::record {
enum class DepthEncoding {
@@ -0,0 +1,70 @@
#pragma once
#include "cvmmap_streamer/config/runtime_config.hpp"
#include <cstddef>
#include <cstdint>
#include <expected>
#include <filesystem>
#include <memory>
#include <string_view>
namespace cvmmap_streamer::record {
inline constexpr int kDefaultMp4Quality = 23;
enum class Mp4InputPixelFormat : std::uint8_t {
Bgr24,
Rgb24,
Bgra32,
Rgba32,
Gray8,
};
struct Mp4EncodeTuning {
int quality{kDefaultMp4Quality};
std::uint32_t gop{30};
std::uint32_t b_frames{0};
};
[[nodiscard]]
std::string_view input_pixel_format_name(Mp4InputPixelFormat pixel_format);
class Mp4RecordWriter {
public:
Mp4RecordWriter();
Mp4RecordWriter(const Mp4RecordWriter &) = delete;
Mp4RecordWriter &operator=(const Mp4RecordWriter &) = delete;
Mp4RecordWriter(Mp4RecordWriter &&) noexcept;
Mp4RecordWriter &operator=(Mp4RecordWriter &&) noexcept;
~Mp4RecordWriter();
[[nodiscard]]
std::expected<void, std::string> open(
const std::filesystem::path &output_path,
CodecType codec,
EncoderDeviceType encoder_device,
std::uint32_t width,
std::uint32_t height,
float fps,
const Mp4EncodeTuning &tuning,
Mp4InputPixelFormat input_pixel_format);
[[nodiscard]]
std::expected<void, std::string> write_frame(
const std::uint8_t *data,
std::size_t row_stride_bytes,
std::uint64_t relative_timestamp_ns);
[[nodiscard]]
std::expected<void, std::string> flush();
[[nodiscard]]
bool using_hardware() const;
private:
struct Impl;
std::unique_ptr<Impl> impl_{};
};
} // namespace cvmmap_streamer::record
@@ -1,30 +0,0 @@
#pragma once
#include <cstdint>
#include <memory>
#include <string_view>
namespace cvmmap_streamer::zed_tools {
[[nodiscard]]
bool stderr_supports_progress_bar();
class ProgressBar {
public:
explicit ProgressBar(std::uint64_t total_frames);
~ProgressBar();
[[nodiscard]]
bool enabled() const;
void update(std::uint64_t completed_frames);
void update_fraction(double fraction, std::string_view detail = {});
void finish(std::uint64_t completed_frames, bool success);
void finish_fraction(double fraction, bool success, std::string_view detail = {});
private:
struct Impl;
std::unique_ptr<Impl> impl_{};
};
} // namespace cvmmap_streamer::zed_tools
@@ -1,104 +0,0 @@
#pragma once
#include "cvmmap_streamer/config/runtime_config.hpp"
#include <cstdint>
#include <expected>
#include <filesystem>
#include <memory>
#include <string>
#include <string_view>
namespace cvmmap_streamer::zed_tools {
using cvmmap_streamer::CodecType;
using cvmmap_streamer::EncoderDeviceType;
inline constexpr std::uint32_t kDefaultGopSize = 30;
inline constexpr std::uint32_t kDefaultBFrames = 0;
inline constexpr int kDefaultQuality = 23;
inline constexpr std::uint64_t kNanosPerSecond = 1'000'000'000ull;
enum class PresetKind : std::uint8_t {
Fast,
Balanced,
Quality,
};
enum class TuneKind : std::uint8_t {
LowLatency,
Balanced,
};
struct EncodeTuning {
PresetKind preset{PresetKind::Fast};
TuneKind tune{TuneKind::LowLatency};
int quality{kDefaultQuality};
std::uint32_t gop{kDefaultGopSize};
std::uint32_t b_frames{kDefaultBFrames};
};
[[nodiscard]]
std::expected<CodecType, std::string> parse_codec(std::string_view raw);
[[nodiscard]]
std::expected<EncoderDeviceType, std::string> parse_encoder_device(std::string_view raw);
[[nodiscard]]
std::expected<PresetKind, std::string> parse_preset(std::string_view raw);
[[nodiscard]]
std::expected<TuneKind, std::string> parse_tune(std::string_view raw);
[[nodiscard]]
std::string_view codec_name(CodecType codec);
[[nodiscard]]
std::string_view preset_name(PresetKind preset);
[[nodiscard]]
std::string_view tune_name(TuneKind tune);
[[nodiscard]]
std::uint64_t frame_period_ns(float fps);
[[nodiscard]]
std::filesystem::path derive_output_path(const std::filesystem::path &input_path);
class Mp4Writer {
public:
Mp4Writer();
Mp4Writer(const Mp4Writer &) = delete;
Mp4Writer &operator=(const Mp4Writer &) = delete;
Mp4Writer(Mp4Writer &&) noexcept;
Mp4Writer &operator=(Mp4Writer &&) noexcept;
~Mp4Writer();
[[nodiscard]]
std::expected<void, std::string> open(
const std::filesystem::path &output_path,
CodecType codec,
EncoderDeviceType encoder_device,
std::uint32_t width,
std::uint32_t height,
float fps,
const EncodeTuning &tuning);
[[nodiscard]]
std::expected<void, std::string> write_bgr_frame(
const std::uint8_t *data,
std::size_t row_stride_bytes,
std::uint64_t relative_timestamp_ns);
[[nodiscard]]
std::expected<void, std::string> flush();
[[nodiscard]]
bool using_hardware() const;
private:
struct Impl;
std::unique_ptr<Impl> impl_{};
};
} // namespace cvmmap_streamer::zed_tools
@@ -0,0 +1,88 @@
syntax = "proto3";
package cvmmap_streamer.proto;
option cc_enable_arenas = true;
enum RpcCode {
RPC_CODE_OK = 0;
RPC_CODE_INVALID_REQUEST = 1;
RPC_CODE_UNSUPPORTED = 2;
RPC_CODE_BUSY = 3;
RPC_CODE_INTERNAL = 4;
}
message Mp4RecorderState {
bool can_record = 1;
bool is_recording = 2;
bool last_frame_ok = 3;
uint32 frames_ingested = 4;
uint32 frames_encoded = 5;
string active_path = 6;
string error_message = 7;
}
message Mp4StartRequest {
string output_path = 1;
}
message Mp4StartResponse {
RpcCode code = 1;
string error_message = 2;
Mp4RecorderState state = 3;
}
message Mp4StopRequest {}
message Mp4StopResponse {
RpcCode code = 1;
string error_message = 2;
Mp4RecorderState state = 3;
}
message Mp4StatusRequest {}
message Mp4StatusResponse {
RpcCode code = 1;
string error_message = 2;
Mp4RecorderState state = 3;
}
message McapRecorderState {
bool can_record = 1;
bool is_recording = 2;
bool last_frame_ok = 3;
uint32 frames_ingested = 4;
uint32 frames_encoded = 5;
string active_path = 6;
string error_message = 7;
}
message McapStartRequest {
string output_path = 1;
optional string compression = 2;
optional string topic = 3;
optional string depth_topic = 4;
optional string body_topic = 5;
optional string frame_id = 6;
}
message McapStartResponse {
RpcCode code = 1;
string error_message = 2;
McapRecorderState state = 3;
}
message McapStopRequest {}
message McapStopResponse {
RpcCode code = 1;
string error_message = 2;
McapRecorderState state = 3;
}
message McapStatusRequest {}
message McapStatusResponse {
RpcCode code = 1;
string error_message = 2;
McapRecorderState state = 3;
}
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
exec /usr/bin/protoc --experimental_allow_proto3_optional "$@"
+13 -1
View File
@@ -238,6 +238,18 @@ EOF
--frame-interval-ms 1 \
--linger-ms 0
run_expected_failure 8 "libavformat_connection_refused" "libavformat connection refusal surfaces without crashing" \
"failed to open RTMP output 'rtmp://127.0.0.1:1/live/test': Connection refused" \
"${RTMP_OUTPUT_TESTER}" \
--rtmp-url rtmp://127.0.0.1:1/live/test \
--transport libavformat \
--codec h264 \
--frames 1 \
--width 320 \
--height 240 \
--frame-interval-ms 1 \
--linger-ms 0
local finished_at_utc
finished_at_utc="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
@@ -294,7 +306,7 @@ PY
echo "counts_pass=${pass_count}"
echo "counts_fail=${fail_count}"
echo "all_pass=${all_pass}"
echo "scenarios=removed_encoder_backend,removed_rtmp_transport,removed_rtmp_mode_cli,removed_rtmp_mode_toml,missing_rtmp_url,invalid_rtp_endpoint,ffmpeg_process_bad_binary"
echo "scenarios=removed_encoder_backend,removed_rtmp_transport,removed_rtmp_mode_cli,removed_rtmp_mode_toml,missing_rtmp_url,invalid_rtp_endpoint,ffmpeg_process_bad_binary,libavformat_connection_refused"
} > "${EVIDENCE_TEXT}"
if [[ "${all_pass}" == "true" ]]; then
+1 -1
View File
@@ -86,7 +86,7 @@ def build_summary(args: CliArgs) -> dict[str, object]:
pass_count = sum(1 for row in rows if row["status"] == "PASS")
fail_count = sum(1 for row in rows if row["status"] == "FAIL")
skip_count = sum(1 for row in rows if row["status"] == "SKIP")
all_pass = len(rows) == 7 and pass_count == 7 and fail_count == 0 and skip_count == 0
all_pass = len(rows) > 0 and pass_count == len(rows) and fail_count == 0 and skip_count == 0
return {
"run_id": args.run_id,
-362
View File
@@ -1,362 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
from collections import Counter
from dataclasses import dataclass, field
from pathlib import Path
import re
import click
import zed_batch_svo_to_mcap as batch
BUNDLE_TOPIC = "/bundle"
CAMERA_PREFIX = "/camera/"
NAMESPACED_TOPIC_PATTERN = re.compile(r"^/([^/]+)/([^/]+)$")
SINGLE_TOPIC_SCHEMA_NAMES = {
"/camera/video": "foxglove.CompressedVideo",
"/camera/depth": "cvmmap_streamer.DepthMap",
"/camera/calibration": "foxglove.CameraCalibration",
"/camera/depth_calibration": "foxglove.CameraCalibration",
"/camera/pose": "foxglove.PoseInFrame",
}
@dataclass(slots=True)
class CameraSummary:
video_messages: int = 0
depth_messages: int = 0
pose_messages: int = 0
calibration_messages: int = 0
depth_calibration_messages: int = 0
body_messages: int = 0
present_members: int = 0
corrupted_gap_members: int = 0
unknown_members: int = 0
@dataclass(slots=True)
class McapSummary:
path: Path
layout: str = "unknown"
validation_status: str = "invalid"
validation_reason: str = ""
camera_labels: tuple[str, ...] = ()
bundle_count: int = 0
policy_counts: Counter[str] = field(default_factory=Counter)
camera_stats: dict[str, CameraSummary] = field(default_factory=dict)
schema_mismatches: list[str] = field(default_factory=list)
def iter_mcap_paths(inputs: tuple[Path, ...], recursive: bool) -> list[Path]:
discovered: list[Path] = []
for input_path in inputs:
resolved = input_path.expanduser().resolve()
if resolved.is_file():
discovered.append(resolved)
continue
if resolved.is_dir():
pattern = "*.mcap" if not recursive else "**/*.mcap"
discovered.extend(sorted(resolved.glob(pattern)))
continue
raise click.ClickException(f"path does not exist: {resolved}")
return sorted(dict.fromkeys(discovered))
def policy_name_from_message(bundle_message: object) -> str:
descriptor = bundle_message.DESCRIPTOR.enum_types_by_name.get("BundlePolicy")
if descriptor is None:
return str(bundle_message.policy)
value = descriptor.values_by_number.get(bundle_message.policy)
return value.name if value is not None else str(bundle_message.policy)
def status_name_from_member(member: object, present_value: int | None) -> str:
if present_value is None:
return "PRESENT" if member.HasField("timestamp") else "UNKNOWN"
field_descriptor = member.DESCRIPTOR.fields_by_name.get("status")
descriptor = field_descriptor.enum_type if field_descriptor is not None else None
if descriptor is None:
return "PRESENT" if member.status == present_value else "UNKNOWN"
value = descriptor.values_by_number.get(member.status)
return value.name if value is not None else str(member.status)
def record_single_camera_topic(
summary: McapSummary,
topic: str,
schema_name: str | None,
) -> None:
stats = summary.camera_stats.setdefault("camera", CameraSummary())
if topic == "/camera/video":
stats.video_messages += 1
elif topic == "/camera/depth":
stats.depth_messages += 1
elif topic == "/camera/pose":
stats.pose_messages += 1
elif topic == "/camera/calibration":
stats.calibration_messages += 1
elif topic == "/camera/depth_calibration":
stats.depth_calibration_messages += 1
elif topic == "/camera/body":
stats.body_messages += 1
expected_schema = SINGLE_TOPIC_SCHEMA_NAMES.get(topic)
if expected_schema is not None and schema_name != expected_schema:
summary.schema_mismatches.append(
f"{topic}: expected schema '{expected_schema}', got '{schema_name or 'none'}'"
)
def probe_single_camera_output(path: Path) -> batch.OutputProbeResult:
base_probe = batch.probe_output(path, ("camera",), layout="single-camera", bundle_topic=None)
if base_probe.status != "valid":
return base_probe
reader_module = batch.load_mcap_reader()
stats = CameraSummary()
schema_mismatches: list[str] = []
try:
with path.open("rb") as stream:
reader = reader_module.make_reader(stream)
for schema, channel, _message in reader.iter_messages():
topic = channel.topic
schema_name = schema.name if schema is not None else None
if topic == "/camera/video":
stats.video_messages += 1
elif topic == "/camera/depth":
stats.depth_messages += 1
elif topic == "/camera/pose":
stats.pose_messages += 1
elif topic == "/camera/calibration":
stats.calibration_messages += 1
elif topic == "/camera/depth_calibration":
stats.depth_calibration_messages += 1
elif topic == "/camera/body":
stats.body_messages += 1
expected_schema = SINGLE_TOPIC_SCHEMA_NAMES.get(topic)
if expected_schema is not None and schema_name != expected_schema:
schema_mismatches.append(
f"{topic}: expected schema '{expected_schema}', got '{schema_name or 'none'}'"
)
except Exception as error: # noqa: BLE001
return batch.OutputProbeResult(output_path=path, status="invalid", reason=str(error))
if schema_mismatches:
return batch.OutputProbeResult(
output_path=path,
status="invalid",
reason=schema_mismatches[0],
)
if stats.video_messages == 0:
return batch.OutputProbeResult(
output_path=path,
status="invalid",
reason="single-camera MCAP has no /camera/video messages",
)
if stats.depth_messages == 0:
return batch.OutputProbeResult(
output_path=path,
status="invalid",
reason="single-camera MCAP has no /camera/depth messages",
)
if stats.video_messages != stats.depth_messages:
return batch.OutputProbeResult(
output_path=path,
status="invalid",
reason=(
"single-camera video/depth count mismatch: "
f"video_messages={stats.video_messages} depth_messages={stats.depth_messages}"
),
)
if stats.calibration_messages != 1:
return batch.OutputProbeResult(
output_path=path,
status="invalid",
reason=(
"single-camera calibration count mismatch: "
f"/camera/calibration={stats.calibration_messages}"
),
)
if stats.depth_calibration_messages not in (0, 1):
return batch.OutputProbeResult(
output_path=path,
status="invalid",
reason=(
"single-camera depth calibration count mismatch: "
f"/camera/depth_calibration={stats.depth_calibration_messages}"
),
)
if stats.pose_messages > stats.video_messages:
return batch.OutputProbeResult(
output_path=path,
status="invalid",
reason=(
"single-camera pose count exceeds video count: "
f"pose_messages={stats.pose_messages} video_messages={stats.video_messages}"
),
)
return batch.OutputProbeResult(output_path=path, status="valid")
def summarize_mcap(path: Path) -> McapSummary:
reader_module = batch.load_mcap_reader()
summary = McapSummary(path=path)
camera_labels: set[str] = set()
saw_single_camera_topic = False
saw_namespaced_camera_topic = False
saw_bundle_manifest = False
with path.open("rb") as stream:
reader = reader_module.make_reader(stream)
for schema, channel, message in reader.iter_messages():
topic = channel.topic
schema_name = schema.name if schema is not None else None
if topic == BUNDLE_TOPIC:
summary.layout = "bundled"
saw_bundle_manifest = True
if schema is None or schema.name != "cvmmap_streamer.BundleManifest":
summary.validation_status = "invalid"
summary.validation_reason = f"bundle topic '{BUNDLE_TOPIC}' is missing the BundleManifest schema"
continue
bundle_class, present_value = batch.load_bundle_manifest_type(schema.data)
bundle = bundle_class()
bundle.ParseFromString(message.data)
summary.bundle_count += 1
summary.policy_counts[policy_name_from_message(bundle)] += 1
for member in bundle.members:
label = str(member.camera_label)
camera_labels.add(label)
stats = summary.camera_stats.setdefault(label, CameraSummary())
status_name = status_name_from_member(member, present_value)
if status_name == "BUNDLE_MEMBER_STATUS_PRESENT" or status_name == "PRESENT":
stats.present_members += 1
elif status_name == "BUNDLE_MEMBER_STATUS_CORRUPTED_GAP":
stats.corrupted_gap_members += 1
else:
stats.unknown_members += 1
continue
if topic.startswith(CAMERA_PREFIX):
saw_single_camera_topic = True
if summary.layout == "unknown":
summary.layout = "single-camera"
record_single_camera_topic(summary, topic, schema_name)
continue
match = NAMESPACED_TOPIC_PATTERN.match(topic)
if not match:
continue
label, stream_kind = match.groups()
if label == "camera":
continue
saw_namespaced_camera_topic = True
if summary.layout == "unknown":
summary.layout = "copy"
camera_labels.add(label)
stats = summary.camera_stats.setdefault(label, CameraSummary())
if stream_kind == "video":
stats.video_messages += 1
elif stream_kind == "depth":
stats.depth_messages += 1
elif stream_kind == "pose":
stats.pose_messages += 1
elif stream_kind == "calibration":
stats.calibration_messages += 1
elif stream_kind == "depth_calibration":
stats.depth_calibration_messages += 1
elif stream_kind == "body":
stats.body_messages += 1
if saw_single_camera_topic and saw_namespaced_camera_topic:
summary.layout = "mixed"
summary.validation_status = "invalid"
summary.validation_reason = "MCAP mixes single-camera and multi-camera topic layouts"
return summary
if saw_namespaced_camera_topic and not saw_bundle_manifest and summary.layout == "bundled":
summary.layout = "copy"
if summary.layout == "single-camera":
summary.camera_labels = ("camera",)
probe = probe_single_camera_output(path)
summary.validation_status = probe.status
summary.validation_reason = probe.reason
if summary.schema_mismatches and summary.validation_status == "valid":
summary.validation_status = "invalid"
summary.validation_reason = summary.schema_mismatches[0]
return summary
summary.camera_labels = tuple(sorted(camera_labels))
if summary.camera_labels:
probe = batch.probe_output(
path,
summary.camera_labels,
layout=summary.layout,
bundle_topic=BUNDLE_TOPIC if summary.layout == "bundled" else None,
)
summary.validation_status = probe.status
summary.validation_reason = probe.reason
else:
summary.validation_status = "invalid"
summary.validation_reason = "could not infer a supported MCAP layout from topics"
return summary
def print_summary(summary: McapSummary) -> None:
status_text = summary.validation_status
layout_text = summary.layout
cameras_text = ",".join(summary.camera_labels) if summary.camera_labels else "-"
policy_text = ",".join(
f"{policy}={count}"
for policy, count in sorted(summary.policy_counts.items())
) or "-"
click.echo(
f"{status_text}: {summary.path} [{layout_text}] bundles={summary.bundle_count} "
f"cameras={cameras_text} policies={policy_text}"
)
for label in summary.camera_labels:
stats = summary.camera_stats[label]
click.echo(
" "
f"{label}: video={stats.video_messages} depth={stats.depth_messages} pose={stats.pose_messages} "
f"calibration={stats.calibration_messages} depth_calibration={stats.depth_calibration_messages} "
f"body={stats.body_messages} present={stats.present_members} "
f"corrupted_gap={stats.corrupted_gap_members} unknown={stats.unknown_members}"
)
if summary.validation_reason:
click.echo(f" reason: {summary.validation_reason}")
@click.command()
@click.argument("paths", nargs=-1, type=click.Path(path_type=Path))
@click.option("--recursive", is_flag=True, help="Recursively discover *.mcap files under directory inputs.")
def main(paths: tuple[Path, ...], recursive: bool) -> None:
"""Summarize and validate legacy single-camera, bundled, or copy-layout MCAP files."""
if not paths:
raise click.ClickException("provide at least one MCAP file or directory")
mcap_paths = iter_mcap_paths(paths, recursive=recursive)
if not mcap_paths:
raise click.ClickException("no .mcap files matched the provided inputs")
invalid_count = 0
for path in mcap_paths:
summary = summarize_mcap(path)
print_summary(summary)
if summary.validation_status != "valid":
invalid_count += 1
if invalid_count:
raise SystemExit(1)
if __name__ == "__main__":
main()
-630
View File
@@ -1,630 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
from dataclasses import asdict, dataclass, field
import json
from pathlib import Path
import subprocess
import tempfile
import click
import cv2
import numpy as np
from google.protobuf import descriptor_pb2, descriptor_pool, message_factory, timestamp_pb2
import mcap_bundle_validator as bundle_validator
import zed_batch_svo_to_mcap as batch
BUNDLE_TOPIC = "/bundle"
DEPTH_PALETTE_TO_OPENCV = {
"Gray": None,
"Turbo": cv2.COLORMAP_TURBO,
"Inferno": cv2.COLORMAP_INFERNO,
"Plasma": cv2.COLORMAP_PLASMA,
"Viridis": cv2.COLORMAP_VIRIDIS,
"Cividis": cv2.COLORMAP_CIVIDIS,
"Magma": cv2.COLORMAP_MAGMA,
"Parula": cv2.COLORMAP_PARULA,
}
VIDEO_INPUT_FORMATS = {"h264": "h264", "h265": "hevc"}
_MESSAGE_CLASS_CACHE: dict[tuple[bytes, str], object] = {}
@dataclass(slots=True)
class TimestampRange:
start_ns: int | None = None
end_ns: int | None = None
def update(self, timestamp_ns: int) -> None:
if self.start_ns is None or timestamp_ns < self.start_ns:
self.start_ns = timestamp_ns
if self.end_ns is None or timestamp_ns > self.end_ns:
self.end_ns = timestamp_ns
@dataclass(slots=True)
class CameraRanges:
video: TimestampRange = field(default_factory=TimestampRange)
depth: TimestampRange = field(default_factory=TimestampRange)
@dataclass(slots=True)
class RecipeSummary:
base: bundle_validator.McapSummary
bundle_timestamps: TimestampRange = field(default_factory=TimestampRange)
camera_ranges: dict[str, CameraRanges] = field(default_factory=dict)
@dataclass(slots=True)
class VideoSample:
timestamp_ns: int
format_name: str
stream_index: int
@dataclass(slots=True)
class DepthSample:
timestamp_ns: int
payload: bytes
stream_index: int
width: int
height: int
encoding_name: str
source_unit_name: str
storage_unit_name: str
@dataclass(slots=True)
class BundleMemberSample:
bundle_index: int
bundle_timestamp_ns: int
member_timestamp_ns: int | None
status_name: str
corrupted_frames_skipped: int
member_stream_index: int
def load_message_class(schema_data: bytes, message_type_name: str):
cache_key = (schema_data, message_type_name)
cached = _MESSAGE_CLASS_CACHE.get(cache_key)
if cached is not None:
return cached
descriptor_set = descriptor_pb2.FileDescriptorSet()
descriptor_set.ParseFromString(schema_data)
pool = descriptor_pool.DescriptorPool()
has_embedded_timestamp = any(
file_descriptor.name == "google/protobuf/timestamp.proto"
for file_descriptor in descriptor_set.file
)
if has_embedded_timestamp:
for file_descriptor in descriptor_set.file:
if file_descriptor.name == "google/protobuf/timestamp.proto":
pool.Add(file_descriptor)
break
else:
pool.AddSerializedFile(timestamp_pb2.DESCRIPTOR.serialized_pb)
for file_descriptor in descriptor_set.file:
if file_descriptor.name == "google/protobuf/timestamp.proto":
continue
pool.Add(file_descriptor)
message_descriptor = pool.FindMessageTypeByName(message_type_name)
message_class = message_factory.GetMessageClass(message_descriptor)
_MESSAGE_CLASS_CACHE[cache_key] = message_class
return message_class
def parse_timestamp_ns(timestamp_message: object, fallback_log_time_ns: int) -> int:
seconds = int(getattr(timestamp_message, "seconds", 0))
nanos = int(getattr(timestamp_message, "nanos", 0))
if seconds == 0 and nanos == 0:
return fallback_log_time_ns
return seconds * 1_000_000_000 + nanos
def format_timestamp_ns(timestamp_ns: int | None) -> str:
if timestamp_ns is None:
return "-"
seconds, nanos = divmod(timestamp_ns, 1_000_000_000)
return f"{seconds}.{nanos:09d}"
def format_range(timestamp_range: TimestampRange) -> str:
return f"{format_timestamp_ns(timestamp_range.start_ns)} .. {format_timestamp_ns(timestamp_range.end_ns)}"
def enum_name(message: object, field_name: str) -> str:
field_descriptor = message.DESCRIPTOR.fields_by_name[field_name]
value = int(getattr(message, field_name))
resolved = field_descriptor.enum_type.values_by_number.get(value)
return resolved.name if resolved is not None else str(value)
def is_present_status(status_name: str) -> bool:
return status_name in {"PRESENT", "BUNDLE_MEMBER_STATUS_PRESENT"}
def topic_for(layout: str, camera_label: str, kind: str) -> str:
if layout == "single-camera":
return f"/camera/{kind}"
if layout not in {"copy", "bundled"}:
raise click.ClickException(f"unsupported layout '{layout}'")
return f"/{camera_label}/{kind}"
def selected_camera_label(base_summary: bundle_validator.McapSummary, camera_label: str | None) -> str:
if camera_label is None:
return base_summary.camera_labels[0]
if camera_label not in base_summary.camera_labels:
available = ", ".join(base_summary.camera_labels)
raise click.ClickException(f"camera label '{camera_label}' not found. available: {available}")
return camera_label
def ensure_supported_layout(base_summary: bundle_validator.McapSummary) -> None:
if base_summary.layout not in {"single-camera", "copy", "bundled"}:
reason = base_summary.validation_reason or "unsupported MCAP layout"
raise click.ClickException(reason)
def summarize_mcap(path: Path) -> RecipeSummary:
base_summary = bundle_validator.summarize_mcap(path)
camera_ranges = {
label: CameraRanges()
for label in (base_summary.camera_labels or ("camera",))
}
bundle_timestamps = TimestampRange()
reader_module = batch.load_mcap_reader()
with path.open("rb") as stream:
reader = reader_module.make_reader(stream)
for schema, channel, message in reader.iter_messages():
topic = channel.topic
if topic == BUNDLE_TOPIC and schema is not None and schema.name == "cvmmap_streamer.BundleManifest":
bundle_class, _present_value = batch.load_bundle_manifest_type(schema.data)
bundle_message = bundle_class()
bundle_message.ParseFromString(message.data)
bundle_timestamps.update(parse_timestamp_ns(bundle_message.timestamp, int(message.log_time)))
continue
if topic.endswith("/video"):
if topic == "/camera/video":
label = "camera"
else:
label = topic.removeprefix("/").removesuffix("/video")
if schema is None or schema.name != "foxglove.CompressedVideo" or label not in camera_ranges:
continue
message_class = load_message_class(schema.data, "foxglove.CompressedVideo")
payload = message_class()
payload.ParseFromString(message.data)
camera_ranges[label].video.update(parse_timestamp_ns(payload.timestamp, int(message.log_time)))
continue
if topic.endswith("/depth"):
if topic == "/camera/depth":
label = "camera"
else:
label = topic.removeprefix("/").removesuffix("/depth")
if schema is None or schema.name != "cvmmap_streamer.DepthMap" or label not in camera_ranges:
continue
message_class = load_message_class(schema.data, "cvmmap_streamer.DepthMap")
payload = message_class()
payload.ParseFromString(message.data)
camera_ranges[label].depth.update(parse_timestamp_ns(payload.timestamp, int(message.log_time)))
return RecipeSummary(
base=base_summary,
bundle_timestamps=bundle_timestamps,
camera_ranges=camera_ranges,
)
def print_summary(summary: RecipeSummary) -> None:
base = summary.base
click.echo(f"path: {base.path}")
click.echo(f"validation: {base.validation_status}")
if base.validation_reason:
click.echo(f"validation reason: {base.validation_reason}")
click.echo(f"layout: {base.layout}")
click.echo(f"camera labels: {', '.join(base.camera_labels) if base.camera_labels else '-'}")
if base.layout == "bundled":
click.echo(f"bundle count: {base.bundle_count}")
click.echo(f"bundle timestamp range: {format_range(summary.bundle_timestamps)}")
policy_text = ", ".join(
f"{policy}={count}"
for policy, count in sorted(base.policy_counts.items())
) or "-"
click.echo(f"bundle policies: {policy_text}")
for label in base.camera_labels:
stats = base.camera_stats[label]
ranges = summary.camera_ranges[label]
click.echo(f"camera: {label}")
click.echo(f" video messages: {stats.video_messages}")
click.echo(f" video timestamp range: {format_range(ranges.video)}")
click.echo(f" depth messages: {stats.depth_messages}")
click.echo(f" depth timestamp range: {format_range(ranges.depth)}")
click.echo(f" pose messages: {stats.pose_messages}")
click.echo(f" calibration messages: {stats.calibration_messages}")
click.echo(f" depth calibration messages: {stats.depth_calibration_messages}")
click.echo(f" body messages: {stats.body_messages}")
if base.layout == "bundled":
click.echo(f" present bundle members: {stats.present_members}")
click.echo(f" corrupted gap members: {stats.corrupted_gap_members}")
click.echo(f" unknown bundle members: {stats.unknown_members}")
def decode_depth_array(depth_sample: DepthSample) -> np.ndarray:
try:
import rvl
except ModuleNotFoundError as error:
raise click.ClickException(
"depth export needs the optional rvl-impl binding; run `uv sync --extra viewer`"
) from error
if depth_sample.encoding_name == "RVL_U16_LOSSLESS":
depth = rvl.decompress_u16(depth_sample.payload).astype(np.float32)
if (
depth_sample.storage_unit_name == "STORAGE_UNIT_MILLIMETER"
or depth_sample.source_unit_name == "DEPTH_UNIT_MILLIMETER"
):
return depth / 1000.0
return depth
if depth_sample.encoding_name == "RVL_F32":
return rvl.decompress_f32(depth_sample.payload).astype(np.float32)
raise click.ClickException(f"unsupported depth encoding '{depth_sample.encoding_name}'")
def colorize_depth(
depth_m: np.ndarray,
*,
depth_min_m: float,
depth_max_m: float,
depth_palette_name: str,
) -> np.ndarray:
valid = np.isfinite(depth_m) & (depth_m > 0.0)
span = max(depth_max_m - depth_min_m, 1e-6)
clipped = np.clip((depth_m - depth_min_m) / span, 0.0, 1.0)
normalized = np.zeros(depth_m.shape, dtype=np.uint8)
normalized[valid] = np.round((1.0 - clipped[valid]) * 255.0).astype(np.uint8)
colormap = DEPTH_PALETTE_TO_OPENCV[depth_palette_name]
if colormap is None:
colored = cv2.cvtColor(normalized, cv2.COLOR_GRAY2BGR)
else:
colored = cv2.applyColorMap(normalized, colormap)
colored[~valid] = 0
return colored
def export_rgb_frame(
*,
ffmpeg_bin: str,
raw_video_path: Path,
video_format: str,
frame_index: int,
output_path: Path,
) -> None:
input_format = VIDEO_INPUT_FORMATS.get(video_format)
if input_format is None:
raise click.ClickException(f"unsupported video format '{video_format}'")
command = [
ffmpeg_bin,
"-hide_banner",
"-loglevel",
"error",
"-y",
"-fflags",
"+genpts",
"-f",
input_format,
"-i",
str(raw_video_path),
"-vf",
f"select=eq(n\\,{frame_index})",
"-frames:v",
"1",
str(output_path),
]
try:
completed = subprocess.run(command, check=False, capture_output=True, text=True)
except FileNotFoundError as error:
raise click.ClickException(f"ffmpeg binary not found: {ffmpeg_bin}") from error
if completed.returncode != 0:
reason = completed.stderr.strip() or completed.stdout.strip() or "ffmpeg failed to export the RGB frame"
raise click.ClickException(reason)
if not output_path.is_file():
raise click.ClickException(f"ffmpeg did not write {output_path}")
def collect_sample_data(
path: Path,
*,
layout: str,
camera_label: str,
sample_index: int,
) -> tuple[VideoSample, DepthSample, BundleMemberSample | None, bytes]:
reader_module = batch.load_mcap_reader()
video_topic = topic_for(layout, camera_label, "video")
depth_topic = topic_for(layout, camera_label, "depth")
video_sample: VideoSample | None = None
depth_sample: DepthSample | None = None
bundle_sample: BundleMemberSample | None = None
video_index = 0
depth_index = 0
bundle_member_index = 0
video_format: str | None = None
with tempfile.TemporaryDirectory(prefix="mcap_rgbd_example_") as temp_dir_name:
raw_video_path = Path(temp_dir_name) / "stream.bin"
with raw_video_path.open("wb") as raw_video_stream:
with path.open("rb") as stream:
reader = reader_module.make_reader(stream)
for schema, channel, message in reader.iter_messages():
topic = channel.topic
if layout == "bundled" and topic == BUNDLE_TOPIC and bundle_sample is None:
if schema is None or schema.name != "cvmmap_streamer.BundleManifest":
continue
bundle_class, present_value = batch.load_bundle_manifest_type(schema.data)
bundle_message = bundle_class()
bundle_message.ParseFromString(message.data)
for member in bundle_message.members:
if str(member.camera_label) != camera_label:
continue
status_name = bundle_validator.status_name_from_member(member, present_value)
member_timestamp_ns = None
if member.HasField("timestamp"):
member_timestamp_ns = parse_timestamp_ns(member.timestamp, int(message.log_time))
if is_present_status(status_name):
if bundle_member_index == sample_index:
bundle_sample = BundleMemberSample(
bundle_index=int(bundle_message.bundle_index),
bundle_timestamp_ns=parse_timestamp_ns(bundle_message.timestamp, int(message.log_time)),
member_timestamp_ns=member_timestamp_ns,
status_name=status_name,
corrupted_frames_skipped=int(getattr(member, "corrupted_frames_skipped", 0)),
member_stream_index=bundle_member_index,
)
bundle_member_index += 1
break
continue
if topic == video_topic:
if schema is None or schema.name != "foxglove.CompressedVideo":
raise click.ClickException(f"unexpected schema on {video_topic}: {schema.name if schema else 'none'}")
message_class = load_message_class(schema.data, "foxglove.CompressedVideo")
payload = message_class()
payload.ParseFromString(message.data)
frame_format = str(payload.format)
if frame_format not in VIDEO_INPUT_FORMATS:
raise click.ClickException(f"unsupported video format '{frame_format}' on {video_topic}")
if video_format is None:
video_format = frame_format
elif video_format != frame_format:
raise click.ClickException(
f"inconsistent video format on {video_topic}: {video_format} then {frame_format}"
)
if video_index <= sample_index:
raw_video_stream.write(bytes(payload.data))
if video_index == sample_index:
video_sample = VideoSample(
timestamp_ns=parse_timestamp_ns(payload.timestamp, int(message.log_time)),
format_name=frame_format,
stream_index=video_index,
)
video_index += 1
continue
if topic == depth_topic:
if schema is None or schema.name != "cvmmap_streamer.DepthMap":
raise click.ClickException(f"unexpected schema on {depth_topic}: {schema.name if schema else 'none'}")
message_class = load_message_class(schema.data, "cvmmap_streamer.DepthMap")
payload = message_class()
payload.ParseFromString(message.data)
if depth_index == sample_index:
depth_sample = DepthSample(
timestamp_ns=parse_timestamp_ns(payload.timestamp, int(message.log_time)),
payload=bytes(payload.data),
stream_index=depth_index,
width=int(payload.width),
height=int(payload.height),
encoding_name=enum_name(payload, "encoding"),
source_unit_name=enum_name(payload, "source_unit"),
storage_unit_name=enum_name(payload, "storage_unit"),
)
depth_index += 1
continue
if (
video_sample is not None
and depth_sample is not None
and (layout != "bundled" or bundle_sample is not None)
):
break
raw_video_bytes = raw_video_path.read_bytes()
if video_sample is None:
raise click.ClickException(f"sample index {sample_index} exceeded available video samples")
if depth_sample is None:
raise click.ClickException(f"sample index {sample_index} exceeded available depth samples")
if layout == "bundled" and bundle_sample is None:
raise click.ClickException(
f"could not map per-camera sample index {sample_index} to a bundle member for {camera_label}"
)
return video_sample, depth_sample, bundle_sample, raw_video_bytes
def write_sample_outputs(
*,
path: Path,
layout: str,
output_dir: Path,
camera_label: str,
sample_index: int,
video_sample: VideoSample,
depth_sample: DepthSample,
bundle_sample: BundleMemberSample | None,
raw_video_bytes: bytes,
ffmpeg_bin: str,
depth_min_m: float,
depth_max_m: float,
depth_palette_name: str,
) -> None:
output_dir.mkdir(parents=True, exist_ok=True)
rgb_output_path = output_dir / "rgb.png"
depth_output_path = output_dir / "depth.npy"
depth_preview_path = output_dir / "depth_preview.png"
metadata_path = output_dir / "sample_metadata.json"
with tempfile.TemporaryDirectory(prefix="mcap_rgbd_example_export_") as temp_dir_name:
raw_video_path = Path(temp_dir_name) / f"sample.{video_sample.format_name}"
raw_video_path.write_bytes(raw_video_bytes)
export_rgb_frame(
ffmpeg_bin=ffmpeg_bin,
raw_video_path=raw_video_path,
video_format=video_sample.format_name,
frame_index=sample_index,
output_path=rgb_output_path,
)
depth_m = decode_depth_array(depth_sample)
np.save(depth_output_path, depth_m)
depth_preview = colorize_depth(
depth_m,
depth_min_m=depth_min_m,
depth_max_m=depth_max_m,
depth_palette_name=depth_palette_name,
)
if not cv2.imwrite(str(depth_preview_path), depth_preview):
raise click.ClickException(f"failed to write depth preview to {depth_preview_path}")
metadata = {
"mcap_path": str(path),
"layout": layout,
}
metadata.update(
{
"camera_label": camera_label,
"sample_index": sample_index,
"video_stream_index": video_sample.stream_index,
"video_timestamp_ns": video_sample.timestamp_ns,
"video_timestamp": format_timestamp_ns(video_sample.timestamp_ns),
"video_format": video_sample.format_name,
"depth_stream_index": depth_sample.stream_index,
"depth_timestamp_ns": depth_sample.timestamp_ns,
"depth_timestamp": format_timestamp_ns(depth_sample.timestamp_ns),
"depth_width": depth_sample.width,
"depth_height": depth_sample.height,
"depth_encoding": depth_sample.encoding_name,
"depth_source_unit": depth_sample.source_unit_name,
"depth_storage_unit": depth_sample.storage_unit_name,
"depth_palette": depth_palette_name,
"depth_min_m": depth_min_m,
"depth_max_m": depth_max_m,
"rgb_output_path": str(rgb_output_path),
"depth_output_path": str(depth_output_path),
"depth_preview_path": str(depth_preview_path),
}
)
if bundle_sample is not None:
metadata["bundle"] = asdict(bundle_sample)
metadata["bundle"]["bundle_timestamp"] = format_timestamp_ns(bundle_sample.bundle_timestamp_ns)
metadata["bundle"]["member_timestamp"] = format_timestamp_ns(bundle_sample.member_timestamp_ns)
metadata_path.write_text(json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8")
@click.group()
def main() -> None:
"""Small MCAP RGBD example helper for bundled, copy, and legacy single-camera MCAP files."""
@main.command("summary")
@click.argument("mcap_path", type=click.Path(path_type=Path, exists=True))
def summary_command(mcap_path: Path) -> None:
"""Print a compact metadata summary for a single MCAP file."""
summary = summarize_mcap(mcap_path.resolve())
ensure_supported_layout(summary.base)
print_summary(summary)
@main.command("export-sample")
@click.argument("mcap_path", type=click.Path(path_type=Path, exists=True))
@click.option("--camera-label", help="Camera label to export. Defaults to `camera` for legacy files or the first sorted namespaced label.")
@click.option("--sample-index", default=0, show_default=True, type=click.IntRange(min=0), help="Zero-based per-camera RGB+depth sample index.")
@click.option("--output-dir", required=True, type=click.Path(path_type=Path), help="Directory to write rgb.png, depth.npy, depth_preview.png, and sample_metadata.json.")
@click.option("--ffmpeg-bin", default="ffmpeg", show_default=True, help="ffmpeg binary used to decode the selected RGB frame.")
@click.option("--depth-min-m", default=0.2, show_default=True, type=float, help="Minimum displayed depth in meters for depth_preview.png.")
@click.option("--depth-max-m", default=5.0, show_default=True, type=float, help="Maximum displayed depth in meters for depth_preview.png.")
@click.option(
"--depth-palette",
default="Turbo",
show_default=True,
type=click.Choice(tuple(DEPTH_PALETTE_TO_OPENCV.keys()), case_sensitive=False),
help="Depth color palette for depth_preview.png.",
)
def export_sample_command(
mcap_path: Path,
camera_label: str | None,
sample_index: int,
output_dir: Path,
ffmpeg_bin: str,
depth_min_m: float,
depth_max_m: float,
depth_palette: str,
) -> None:
"""Export one per-camera RGB/depth sample from a bundled, copy, or legacy single-camera MCAP file."""
summary = summarize_mcap(mcap_path.resolve())
ensure_supported_layout(summary.base)
if summary.base.validation_status != "valid":
raise click.ClickException(
f"refusing to export from invalid MCAP: {summary.base.validation_reason or summary.base.validation_status}"
)
label = selected_camera_label(summary.base, camera_label)
stats = summary.base.camera_stats[label]
pair_count = min(stats.video_messages, stats.depth_messages)
if pair_count <= 0:
raise click.ClickException(f"camera '{label}' has no paired RGB+depth samples")
if sample_index >= pair_count:
raise click.ClickException(
f"--sample-index {sample_index} is outside 0..{pair_count - 1} for camera '{label}'"
)
selected_palette = next(
palette_name
for palette_name in DEPTH_PALETTE_TO_OPENCV
if palette_name.lower() == depth_palette.lower()
)
video_sample, depth_sample, bundle_sample, raw_video_bytes = collect_sample_data(
mcap_path.resolve(),
layout=summary.base.layout,
camera_label=label,
sample_index=sample_index,
)
write_sample_outputs(
path=mcap_path.resolve(),
layout=summary.base.layout,
output_dir=output_dir.expanduser().resolve(),
camera_label=label,
sample_index=sample_index,
video_sample=video_sample,
depth_sample=depth_sample,
bundle_sample=bundle_sample,
raw_video_bytes=raw_video_bytes,
ffmpeg_bin=ffmpeg_bin,
depth_min_m=depth_min_m,
depth_max_m=depth_max_m,
depth_palette_name=selected_palette,
)
click.echo(f"wrote sample export: {output_dir.expanduser().resolve()}")
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
-255
View File
@@ -1,255 +0,0 @@
from __future__ import annotations
import csv
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Generic, Protocol, TypeVar
import click
from click.core import ParameterSource
class SegmentScanLike(Protocol):
segment_dir: Path
matched_files: int
is_valid: bool
ScanT = TypeVar("ScanT", bound=SegmentScanLike)
@dataclass(slots=True, frozen=True)
class SourceResolution(Generic[ScanT]):
mode: str
segment_dirs: tuple[Path, ...]
ignored_partial_dirs: tuple[ScanT, ...]
def dedupe_paths(paths: list[Path]) -> list[Path]:
ordered: list[Path] = []
seen: set[Path] = set()
for path in paths:
resolved = path.expanduser().resolve()
if resolved in seen:
continue
seen.add(resolved)
ordered.append(resolved)
return ordered
def parse_segments_csv(csv_path: Path, csv_root: Path | None) -> tuple[Path, ...]:
csv_path = csv_path.expanduser().resolve()
if not csv_path.is_file():
raise click.ClickException(f"CSV not found: {csv_path}")
if csv_root is not None:
base_dir = csv_root.expanduser().resolve()
if not base_dir.is_dir():
raise click.ClickException(f"CSV root is not a directory: {base_dir}")
else:
base_dir = csv_path.parent
segment_dirs: list[Path] = []
seen: set[Path] = set()
with csv_path.open(newline="") as stream:
reader = csv.DictReader(stream)
if reader.fieldnames is None or "segment_dir" not in reader.fieldnames:
raise click.ClickException(f"{csv_path} must contain a 'segment_dir' header")
for row_number, row in enumerate(reader, start=2):
raw_segment_dir = (row.get("segment_dir") or "").strip()
if not raw_segment_dir:
raise click.ClickException(f"{csv_path}:{row_number} has an empty segment_dir value")
segment_dir = Path(raw_segment_dir)
resolved = segment_dir if segment_dir.is_absolute() else base_dir / segment_dir
resolved = resolved.expanduser().resolve()
if resolved in seen:
continue
seen.add(resolved)
segment_dirs.append(resolved)
if not segment_dirs:
raise click.ClickException(f"{csv_path} did not contain any segment_dir rows")
return tuple(segment_dirs)
def discover_segment_dirs(
root: Path,
recursive: bool,
*,
scan_segment_dir: Callable[[Path], ScanT],
no_matches_message: Callable[[Path], str],
) -> SourceResolution[ScanT]:
resolved_root = root.expanduser().resolve()
if not resolved_root.is_dir():
raise click.ClickException(f"dataset root does not exist: {resolved_root}")
candidate_dirs = {resolved_root}
iterator = resolved_root.rglob("*") if recursive else resolved_root.iterdir()
for path in iterator:
if path.is_dir():
candidate_dirs.add(path.resolve())
valid_dirs: list[Path] = []
ignored_partial_dirs: list[ScanT] = []
for segment_dir in sorted(candidate_dirs):
scan = scan_segment_dir(segment_dir)
if scan.is_valid:
valid_dirs.append(segment_dir)
elif scan.matched_files > 0:
ignored_partial_dirs.append(scan)
if not valid_dirs:
raise click.ClickException(no_matches_message(resolved_root))
return SourceResolution(
mode="dataset-root",
segment_dirs=tuple(valid_dirs),
ignored_partial_dirs=tuple(ignored_partial_dirs),
)
def raise_if_recursive_flag_is_incompatible(
ctx: click.Context,
dataset_root: Path | None,
*,
dataset_root_flag: str = "--dataset-root",
) -> None:
if ctx.get_parameter_source("recursive") is ParameterSource.DEFAULT:
return
if dataset_root is None:
raise click.ClickException(f"--recursive/--no-recursive can only be used with {dataset_root_flag}")
def raise_for_legacy_source_args(
legacy_input_dir: Path | None,
legacy_segment_dirs: tuple[Path, ...],
*,
dataset_root_flag: str = "--dataset-root",
segment_flag: str = "--segment",
) -> None:
if legacy_input_dir is not None:
resolved = legacy_input_dir.expanduser().resolve()
raise click.ClickException(
f"positional dataset paths are no longer supported; use {dataset_root_flag} {resolved}"
)
if legacy_segment_dirs:
resolved = legacy_segment_dirs[0].expanduser().resolve()
raise click.ClickException(
f"--segment-dir is no longer supported in this batch wrapper; use {segment_flag} {resolved} "
f"for an explicit segment directory, or {dataset_root_flag} <DATASET_ROOT> --recursive for discovery"
)
def raise_for_legacy_extra_args(
extra_args: list[str],
*,
dataset_root_flag: str = "--dataset-root",
) -> None:
if not extra_args:
return
first = extra_args[0]
if first.startswith("-"):
extras_text = " ".join(extra_args)
raise click.ClickException(f"unexpected extra arguments: {extras_text}")
resolved = Path(first).expanduser().resolve()
raise click.ClickException(
f"positional dataset paths are no longer supported; use {dataset_root_flag} {resolved}"
)
def raise_if_segment_path_looks_like_dataset_root(
segment_dir: Path,
*,
scan_segment_dir: Callable[[Path], ScanT],
dataset_root_flag: str = "--dataset-root",
segment_flag: str = "--segment",
) -> None:
resolved = segment_dir.expanduser().resolve()
if not resolved.is_dir():
return
scan = scan_segment_dir(resolved)
if scan.is_valid or scan.matched_files > 0:
return
nested_segments = _find_nested_valid_segment_dirs(resolved, scan_segment_dir=scan_segment_dir)
if not nested_segments:
return
example = nested_segments[0]
raise click.ClickException(
f"{resolved} looks like a dataset root, not a segment directory. "
f"{segment_flag} expects a directory that directly contains *_zedN.svo or *_zedN.svo2 files. "
f"Use {dataset_root_flag} {resolved} to discover nested segments such as {example}"
)
def resolve_sources(
dataset_root: Path | None,
segment_dirs: tuple[Path, ...],
segments_csv: Path | None,
csv_root: Path | None,
recursive: bool,
*,
scan_segment_dir: Callable[[Path], ScanT],
no_matches_message: Callable[[Path], str],
) -> SourceResolution[ScanT]:
source_count = sum(
(
1 if dataset_root is not None else 0,
1 if segment_dirs else 0,
1 if segments_csv is not None else 0,
)
)
if source_count != 1:
raise click.ClickException(
"provide exactly one source mode: --dataset-root, --segment, or --segments-csv"
)
if dataset_root is not None:
return discover_segment_dirs(
dataset_root,
recursive,
scan_segment_dir=scan_segment_dir,
no_matches_message=no_matches_message,
)
if segment_dirs:
ordered_dirs = dedupe_paths(list(segment_dirs))
for segment_dir in ordered_dirs:
raise_if_segment_path_looks_like_dataset_root(
segment_dir,
scan_segment_dir=scan_segment_dir,
)
return SourceResolution(mode="segments", segment_dirs=tuple(ordered_dirs), ignored_partial_dirs=())
return SourceResolution(
mode="segments-csv",
segment_dirs=parse_segments_csv(segments_csv, csv_root),
ignored_partial_dirs=(),
)
def _find_nested_valid_segment_dirs(
root: Path,
*,
scan_segment_dir: Callable[[Path], ScanT],
limit: int = 3,
) -> tuple[Path, ...]:
matches: list[Path] = []
for path in sorted(root.rglob("*")):
if not path.is_dir():
continue
resolved = path.resolve()
if resolved == root:
continue
scan = scan_segment_dir(resolved)
if scan.is_valid:
matches.append(resolved)
if len(matches) >= limit:
break
return tuple(matches)
-747
View File
@@ -1,747 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import concurrent.futures
import json
import math
import os
import re
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
import click
from tqdm import tqdm
try:
from scripts import zed_batch_segment_sources as segment_sources
except ModuleNotFoundError:
import zed_batch_segment_sources as segment_sources
SCRIPT_PATH = Path(__file__).resolve()
REPO_ROOT = SCRIPT_PATH.parents[1]
SEGMENT_FILE_PATTERN = re.compile(r".*_zed([1-4])\.svo2?$", re.IGNORECASE)
EXPECTED_CAMERAS = ("zed1", "zed2", "zed3", "zed4")
@dataclass(slots=True, frozen=True)
class BatchConfig:
zed_bin: Path | None
ffprobe_bin: Path | None
probe_existing: bool
cuda_visible_devices: str | None
overwrite: bool
fail_fast: bool
codec: str
encoder_device: str
preset: str
tune: str
quality: int
gop: int
b_frames: int
start_offset_seconds: float
duration_seconds: float | None
output_fps: float | None
tile_scale: float
@dataclass(slots=True, frozen=True)
class ConversionJob:
segment_dir: Path
output_path: Path
@dataclass(slots=True, frozen=True)
class JobResult:
status: str
segment_dir: Path
output_path: Path
command: tuple[str, ...]
return_code: int = 0
stdout: str = ""
stderr: str = ""
@dataclass(slots=True, frozen=True)
class SegmentScan:
segment_dir: Path
matched_files: int
is_valid: bool
reason: str | None = None
@dataclass(slots=True, frozen=True)
class OutputProbeResult:
output_path: Path
status: str
reason: str = ""
duration_seconds: float | None = None
def locate_binary(override: Path | None) -> Path:
if override is not None:
candidate = override.expanduser().resolve()
if not candidate.is_file():
raise click.ClickException(f"binary not found: {candidate}")
return candidate
candidates = (
REPO_ROOT / "build" / "bin" / "zed_svo_grid_to_mp4",
REPO_ROOT / "build" / "zed_svo_grid_to_mp4",
)
for candidate in candidates:
if candidate.is_file():
return candidate
raise click.ClickException(f"could not find zed_svo_grid_to_mp4 under {REPO_ROOT / 'build'}")
def locate_ffprobe(override: Path | None) -> Path:
if override is not None:
candidate = override.expanduser().resolve()
if not candidate.is_file():
raise click.ClickException(f"ffprobe binary not found: {candidate}")
return candidate
resolved = shutil.which("ffprobe")
if resolved is None:
raise click.ClickException("could not find ffprobe on PATH")
return Path(resolved).resolve()
def scan_segment_dir(segment_dir: Path) -> SegmentScan:
if not segment_dir.is_dir():
return SegmentScan(
segment_dir=segment_dir,
matched_files=0,
is_valid=False,
reason=f"segment directory does not exist: {segment_dir}",
)
matched_by_camera: dict[str, list[Path]] = {camera: [] for camera in EXPECTED_CAMERAS}
for child in segment_dir.iterdir():
if not child.is_file():
continue
match = SEGMENT_FILE_PATTERN.fullmatch(child.name)
if match is None:
continue
matched_by_camera[f"zed{match.group(1)}"].append(child)
matched_files = sum(len(paths) for paths in matched_by_camera.values())
duplicate_cameras = [camera for camera, paths in matched_by_camera.items() if len(paths) > 1]
missing_cameras = [camera for camera, paths in matched_by_camera.items() if len(paths) == 0]
if duplicate_cameras:
duplicate_text = ", ".join(duplicate_cameras)
return SegmentScan(
segment_dir=segment_dir,
matched_files=matched_files,
is_valid=False,
reason=f"duplicate camera inputs under {segment_dir}: {duplicate_text}",
)
if missing_cameras:
missing_text = ", ".join(missing_cameras)
return SegmentScan(
segment_dir=segment_dir,
matched_files=matched_files,
is_valid=False,
reason=f"missing camera inputs under {segment_dir}: {missing_text}",
)
return SegmentScan(segment_dir=segment_dir, matched_files=matched_files, is_valid=True)
def output_path_for(segment_dir: Path) -> Path:
return segment_dir / f"{segment_dir.name}_grid.mp4"
def command_for_job(job: ConversionJob, config: BatchConfig) -> list[str]:
if config.zed_bin is None:
raise RuntimeError("zed_svo_grid_to_mp4 binary is not configured")
command = [
str(config.zed_bin),
"--segment-dir",
str(job.segment_dir),
"--codec",
config.codec,
"--encoder-device",
config.encoder_device,
"--preset",
config.preset,
"--tune",
config.tune,
"--quality",
str(config.quality),
"--gop",
str(config.gop),
"--b-frames",
str(config.b_frames),
"--start-offset-seconds",
str(config.start_offset_seconds),
"--tile-scale",
str(config.tile_scale),
]
if config.duration_seconds is not None:
command.extend(["--duration-seconds", str(config.duration_seconds)])
if config.output_fps is not None:
command.extend(["--output-fps", str(config.output_fps)])
return command
def env_for_job(config: BatchConfig) -> dict[str, str]:
env = dict(os.environ)
if config.cuda_visible_devices is not None:
env["CUDA_VISIBLE_DEVICES"] = config.cuda_visible_devices
return env
def probe_output(output_path: Path, ffprobe_bin: Path | None) -> OutputProbeResult:
if not output_path.is_file():
return OutputProbeResult(output_path=output_path, status="missing")
if ffprobe_bin is None:
raise RuntimeError("ffprobe binary is not configured")
completed = subprocess.run(
[
str(ffprobe_bin),
"-v",
"error",
"-print_format",
"json",
"-show_entries",
"format=duration,size:stream=codec_type,codec_name,width,height,nb_frames",
str(output_path),
],
check=False,
capture_output=True,
text=True,
)
if completed.returncode != 0:
reason = completed.stderr.strip() or completed.stdout.strip() or "ffprobe failed"
return OutputProbeResult(output_path=output_path, status="invalid", reason=reason)
try:
payload = json.loads(completed.stdout)
except json.JSONDecodeError as error:
return OutputProbeResult(
output_path=output_path,
status="invalid",
reason=f"ffprobe returned invalid JSON: {error}",
)
streams = payload.get("streams", [])
has_video_stream = any(stream.get("codec_type") == "video" for stream in streams)
if not has_video_stream:
return OutputProbeResult(
output_path=output_path,
status="invalid",
reason="ffprobe found no video stream",
)
format_payload = payload.get("format", {})
duration_text = format_payload.get("duration")
if duration_text in (None, ""):
return OutputProbeResult(
output_path=output_path,
status="invalid",
reason="ffprobe did not report a duration",
)
try:
duration_seconds = float(duration_text)
except (TypeError, ValueError):
return OutputProbeResult(
output_path=output_path,
status="invalid",
reason=f"ffprobe reported a non-numeric duration: {duration_text!r}",
)
if not math.isfinite(duration_seconds) or duration_seconds <= 0.0:
return OutputProbeResult(
output_path=output_path,
status="invalid",
reason=f"ffprobe reported a non-positive duration: {duration_seconds}",
)
return OutputProbeResult(
output_path=output_path,
status="valid",
duration_seconds=duration_seconds,
)
def run_conversion(job: ConversionJob, config: BatchConfig) -> JobResult:
command = command_for_job(job, config)
completed = subprocess.run(
command,
check=False,
capture_output=True,
text=True,
env=env_for_job(config),
)
status = "converted" if completed.returncode == 0 else "failed"
return JobResult(
status=status,
segment_dir=job.segment_dir,
output_path=job.output_path,
command=tuple(command),
return_code=completed.returncode,
stdout=completed.stdout,
stderr=completed.stderr,
)
def summarize_failures(results: list[JobResult]) -> None:
failed_results = [result for result in results if result.status == "failed"]
if not failed_results:
return
click.echo("\nFailed conversions:", err=True)
for result in failed_results:
click.echo(f"- {result.segment_dir} (exit {result.return_code})", err=True)
if result.stderr.strip():
click.echo(result.stderr.rstrip(), err=True)
elif result.stdout.strip():
click.echo(result.stdout.rstrip(), err=True)
def report_invalid_existing_outputs(
invalid_existing: list[tuple[ConversionJob, OutputProbeResult]],
) -> None:
if not invalid_existing:
return
click.echo("\nInvalid existing outputs:", err=True)
for job, probe in invalid_existing:
click.echo(f"- {job.segment_dir}", err=True)
click.echo(f" output: {probe.output_path}", err=True)
reason_lines = probe.reason.splitlines() or [probe.reason]
click.echo(f" reason: {reason_lines[0]}", err=True)
for line in reason_lines[1:]:
click.echo(f" {line}", err=True)
def report_dry_run_plan(
pending_jobs: list[ConversionJob],
pending_reasons: dict[Path, str],
pending_details: dict[Path, str],
) -> None:
if not pending_jobs:
click.echo("dry-run: no conversions would be launched", err=True)
return
click.echo("\nDry-run plan:", err=True)
for job in pending_jobs:
reason = pending_reasons[job.segment_dir]
detail = pending_details.get(job.segment_dir)
line = f"- {job.segment_dir} [{reason}]"
if detail:
line = f"{line}: {detail.replace(chr(10), ' | ')}"
click.echo(line, err=True)
def run_batch(jobs: list[ConversionJob], config: BatchConfig, jobs_limit: int) -> tuple[list[JobResult], int]:
results: list[JobResult] = []
aborted_count = 0
if not jobs:
return results, aborted_count
future_to_job: dict[concurrent.futures.Future[JobResult], ConversionJob] = {}
job_iter = iter(jobs)
stop_submitting = False
with concurrent.futures.ThreadPoolExecutor(max_workers=jobs_limit) as executor:
with tqdm(total=len(jobs), unit="segment", dynamic_ncols=True) as progress:
def submit_next() -> bool:
if stop_submitting:
return False
try:
job = next(job_iter)
except StopIteration:
return False
future = executor.submit(run_conversion, job, config)
future_to_job[future] = job
return True
for _ in range(min(jobs_limit, len(jobs))):
submit_next()
while future_to_job:
done, _ = concurrent.futures.wait(
future_to_job,
return_when=concurrent.futures.FIRST_COMPLETED,
)
for future in done:
job = future_to_job.pop(future)
result = future.result()
results.append(result)
progress.update(1)
if result.status == "failed":
tqdm.write(
f"failed: {job.segment_dir} (exit {result.return_code})",
file=sys.stderr,
)
if config.fail_fast:
stop_submitting = True
if not stop_submitting:
submit_next()
if stop_submitting:
remaining = sum(1 for _ in job_iter)
aborted_count = remaining
progress.total = progress.n + len(future_to_job)
progress.refresh()
return results, aborted_count
@click.command(context_settings={"allow_extra_args": True})
@click.option(
"--dataset-root",
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
help="Dataset root containing segment directories. Mutually exclusive with --segment and --segments-csv.",
)
@click.option(
"--segment",
"segment_dirs",
multiple=True,
type=click.Path(exists=True, path_type=Path, file_okay=False, dir_okay=True),
help=(
"Explicit segment directory. Repeatable. The directory must directly contain "
"*_zedN.svo or *_zedN.svo2 files. Mutually exclusive with --dataset-root and --segments-csv."
),
)
@click.option(
"--segment-dir",
"legacy_segment_dirs",
multiple=True,
type=click.Path(path_type=Path, file_okay=False, dir_okay=True),
hidden=True,
)
@click.option(
"--segments-csv",
type=click.Path(path_type=Path, dir_okay=False),
help="CSV file containing a segment_dir column. Mutually exclusive with --dataset-root and --segment.",
)
@click.option(
"--csv-root",
type=click.Path(path_type=Path, file_okay=False, dir_okay=True),
help="Base directory for relative segment_dir entries in --segments-csv. Defaults to the CSV parent directory.",
)
@click.option(
"--recursive/--no-recursive",
default=True,
show_default=True,
help="Recurse when discovering segment directories from --dataset-root.",
)
@click.option("--jobs", default=1, show_default=True, type=click.IntRange(min=1), help="Parallel conversion jobs.")
@click.option(
"--zed-bin",
type=click.Path(path_type=Path, dir_okay=False),
help="Explicit path to the zed_svo_grid_to_mp4 binary.",
)
@click.option(
"--ffprobe-bin",
type=click.Path(path_type=Path, dir_okay=False),
help="Explicit path to ffprobe. Required when probing existing outputs and ffprobe is not on PATH.",
)
@click.option(
"--cuda-visible-devices",
help="Optional CUDA_VISIBLE_DEVICES value exported for each conversion subprocess.",
)
@click.option("--overwrite/--skip-existing", default=False, show_default=True, help="Overwrite existing grid MP4 files.")
@click.option(
"--probe-existing/--trust-existing",
default=False,
show_default=True,
help="Validate existing grid MP4 files with ffprobe before skipping them. Invalid outputs are treated as missing.",
)
@click.option(
"--report-existing",
is_flag=True,
help="Probe existing grid MP4 files with ffprobe, report invalid ones, and do not launch conversions.",
)
@click.option(
"--dry-run",
is_flag=True,
help="Show which segments would be converted after applying skip/probe logic, without launching conversions.",
)
@click.option(
"--fail-fast/--continue-on-error",
default=False,
show_default=True,
help="Stop submitting new work after the first failed conversion.",
)
@click.option("--codec", type=click.Choice(("h264", "h265")), default="h265", show_default=True)
@click.option(
"--encoder-device",
type=click.Choice(("auto", "nvidia", "software")),
default="auto",
show_default=True,
)
@click.option("--preset", type=click.Choice(("fast", "balanced", "quality")), default="fast", show_default=True)
@click.option(
"--tune",
type=click.Choice(("low-latency", "balanced")),
default="low-latency",
show_default=True,
)
@click.option(
"--quality",
type=click.IntRange(min=0, max=51),
default=23,
show_default=True,
help="Lower values mean higher quality.",
)
@click.option("--gop", type=click.IntRange(min=1), default=30, show_default=True)
@click.option("--b-frames", "b_frames", type=click.IntRange(min=0), default=0, show_default=True)
@click.option(
"--start-offset-seconds",
type=click.FloatRange(min=0.0),
default=0.0,
show_default=True,
help="Offset applied after the synced common start time.",
)
@click.option(
"--duration-seconds",
type=click.FloatRange(min=0.0, min_open=True),
default=None,
help="Limit export duration in seconds after sync.",
)
@click.option(
"--output-fps",
type=click.FloatRange(min=0.0, min_open=True),
default=None,
help="Composite output frame rate. Defaults to the grid tool's native behavior.",
)
@click.option(
"--tile-scale",
type=click.FloatRange(min=0.1, max=1.0),
default=0.5,
show_default=True,
help="Scale each tile relative to the source resolution.",
)
@click.pass_context
def main(
ctx: click.Context,
dataset_root: Path | None,
segment_dirs: tuple[Path, ...],
legacy_segment_dirs: tuple[Path, ...],
segments_csv: Path | None,
csv_root: Path | None,
recursive: bool,
jobs: int,
zed_bin: Path | None,
ffprobe_bin: Path | None,
cuda_visible_devices: str | None,
overwrite: bool,
probe_existing: bool,
report_existing: bool,
dry_run: bool,
fail_fast: bool,
codec: str,
encoder_device: str,
preset: str,
tune: str,
quality: int,
gop: int,
b_frames: int,
start_offset_seconds: float,
duration_seconds: float | None,
output_fps: float | None,
tile_scale: float,
) -> None:
"""Batch-convert synced four-camera ZED segments into grid MP4 files."""
segment_sources.raise_for_legacy_extra_args(ctx.args)
segment_sources.raise_for_legacy_source_args(None, legacy_segment_dirs)
segment_sources.raise_if_recursive_flag_is_incompatible(ctx, dataset_root)
if b_frames > gop:
raise click.BadParameter(f"b-frames {b_frames} must be <= gop {gop}", param_hint="--b-frames")
if report_existing and dry_run:
raise click.ClickException("--report-existing and --dry-run are mutually exclusive")
sources = segment_sources.resolve_sources(
dataset_root,
segment_dirs,
segments_csv,
csv_root,
recursive,
scan_segment_dir=scan_segment_dir,
no_matches_message=lambda root: f"no complete four-camera segments found under {root}",
)
ffprobe_path = locate_ffprobe(ffprobe_bin) if (probe_existing or report_existing) else None
binary_path = None if report_existing else locate_binary(zed_bin)
config = BatchConfig(
zed_bin=binary_path,
ffprobe_bin=ffprobe_path,
probe_existing=probe_existing or report_existing,
cuda_visible_devices=cuda_visible_devices,
overwrite=overwrite,
fail_fast=fail_fast,
codec=codec,
encoder_device=encoder_device,
preset=preset,
tune=tune,
quality=quality,
gop=gop,
b_frames=b_frames,
start_offset_seconds=start_offset_seconds,
duration_seconds=duration_seconds,
output_fps=output_fps,
tile_scale=tile_scale,
)
skipped_results: list[JobResult] = []
failed_results: list[JobResult] = []
pending_jobs: list[ConversionJob] = []
pending_reasons: dict[Path, str] = {}
pending_details: dict[Path, str] = {}
valid_existing: list[OutputProbeResult] = []
invalid_existing: list[tuple[ConversionJob, OutputProbeResult]] = []
missing_outputs: list[ConversionJob] = []
for segment_dir in sources.segment_dirs:
output_path = output_path_for(segment_dir)
job = ConversionJob(segment_dir=segment_dir, output_path=output_path)
command = tuple(command_for_job(job, config)) if config.zed_bin is not None else ()
scan = scan_segment_dir(segment_dir)
if not scan.is_valid:
failed_results.append(
JobResult(
status="failed",
segment_dir=segment_dir,
output_path=output_path,
command=command,
return_code=2,
stderr=scan.reason or "",
)
)
continue
if report_existing:
probe_result = probe_output(output_path, config.ffprobe_bin)
if probe_result.status == "valid":
valid_existing.append(probe_result)
elif probe_result.status == "invalid":
invalid_existing.append((job, probe_result))
else:
missing_outputs.append(job)
continue
if overwrite:
pending_jobs.append(job)
pending_reasons[segment_dir] = "overwrite"
continue
if config.probe_existing:
probe_result = probe_output(output_path, config.ffprobe_bin)
if probe_result.status == "valid":
valid_existing.append(probe_result)
skipped_results.append(
JobResult(
status="skipped",
segment_dir=segment_dir,
output_path=output_path,
command=command,
)
)
continue
if probe_result.status == "invalid":
invalid_existing.append((job, probe_result))
pending_jobs.append(job)
pending_reasons[segment_dir] = "invalid-existing-output"
pending_details[segment_dir] = probe_result.reason
continue
missing_outputs.append(job)
pending_jobs.append(job)
pending_reasons[segment_dir] = "missing-output"
continue
if output_path.exists():
skipped_results.append(
JobResult(
status="skipped",
segment_dir=segment_dir,
output_path=output_path,
command=command,
)
)
continue
pending_jobs.append(job)
pending_reasons[segment_dir] = "missing-output"
if report_existing:
click.echo(
(
f"source={sources.mode} matched={len(sources.segment_dirs)} valid={len(valid_existing)} "
f"invalid={len(invalid_existing)} missing={len(missing_outputs)} "
f"invalid-segments={len(failed_results)}"
),
err=True,
)
if sources.ignored_partial_dirs:
click.echo(f"ignored_incomplete={len(sources.ignored_partial_dirs)}", err=True)
report_invalid_existing_outputs(invalid_existing)
summarize_failures(failed_results)
if failed_results or invalid_existing:
raise SystemExit(1)
return
click.echo(
(
f"source={sources.mode} matched={len(sources.segment_dirs)} pending={len(pending_jobs)} "
f"skipped={len(skipped_results)} invalid={len(failed_results)} jobs={jobs} "
f"dry_run={'yes' if dry_run else 'no'}"
),
err=True,
)
if sources.ignored_partial_dirs:
click.echo(f"ignored_incomplete={len(sources.ignored_partial_dirs)}", err=True)
if config.probe_existing:
click.echo(
(
f"probed-existing: valid={len(valid_existing)} invalid={len(invalid_existing)} "
f"missing={len(missing_outputs)}"
),
err=True,
)
if dry_run:
report_dry_run_plan(pending_jobs, pending_reasons, pending_details)
summarize_failures(failed_results)
if failed_results:
raise SystemExit(1)
return
results = list(skipped_results)
results.extend(failed_results)
conversion_results, aborted_count = run_batch(pending_jobs, config, jobs)
results.extend(conversion_results)
converted_count = sum(1 for result in results if result.status == "converted")
skipped_count = sum(1 for result in results if result.status == "skipped")
failed_count = sum(1 for result in results if result.status == "failed")
click.echo(
(
f"summary: matched={len(sources.segment_dirs)} converted={converted_count} "
f"skipped={skipped_count} failed={failed_count} aborted={aborted_count}"
),
err=True,
)
summarize_failures(results)
if failed_count > 0 or aborted_count > 0:
raise SystemExit(1)
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
-361
View File
@@ -1,361 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import concurrent.futures
import os
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
import click
from tqdm import tqdm
SCRIPT_PATH = Path(__file__).resolve()
REPO_ROOT = SCRIPT_PATH.parents[1]
DEFAULT_PATTERNS = ("*.svo2",)
SUPPORTED_SUFFIXES = {".svo", ".svo2"}
@dataclass(slots=True, frozen=True)
class BatchConfig:
zed_bin: Path
cuda_visible_devices: str | None
overwrite: bool
fail_fast: bool
codec: str
encoder_device: str
preset: str
tune: str
quality: int
gop: int
b_frames: int
start_frame: int
end_frame: int | None
@dataclass(slots=True, frozen=True)
class ConversionJob:
input_path: Path
output_path: Path
@dataclass(slots=True, frozen=True)
class JobResult:
status: str
input_path: Path
output_path: Path
command: tuple[str, ...]
return_code: int = 0
stdout: str = ""
stderr: str = ""
def locate_binary(override: Path | None) -> Path:
if override is not None:
candidate = override.expanduser().resolve()
if not candidate.is_file():
raise click.ClickException(f"binary not found: {candidate}")
return candidate
candidates = (
REPO_ROOT / "build" / "bin" / "zed_svo_to_mp4",
REPO_ROOT / "build" / "zed_svo_to_mp4",
)
for candidate in candidates:
if candidate.is_file():
return candidate
raise click.ClickException(f"could not find zed_svo_to_mp4 under {REPO_ROOT / 'build'}")
def discover_inputs(root: Path, patterns: Iterable[str], recursive: bool) -> list[Path]:
discovered: set[Path] = set()
for pattern in patterns:
iterator = root.rglob(pattern) if recursive else root.glob(pattern)
for path in iterator:
if path.is_file() and path.suffix.lower() in SUPPORTED_SUFFIXES:
discovered.add(path.absolute())
return sorted(discovered)
def output_path_for(input_path: Path) -> Path:
if input_path.suffix:
return input_path.with_suffix(".mp4")
return input_path.with_name(f"{input_path.name}.mp4")
def command_for_job(job: ConversionJob, config: BatchConfig) -> list[str]:
command = [
str(config.zed_bin),
"--input",
str(job.input_path),
"--codec",
config.codec,
"--encoder-device",
config.encoder_device,
"--preset",
config.preset,
"--tune",
config.tune,
"--quality",
str(config.quality),
"--gop",
str(config.gop),
"--b-frames",
str(config.b_frames),
"--start-frame",
str(config.start_frame),
]
if config.end_frame is not None:
command.extend(["--end-frame", str(config.end_frame)])
return command
def env_for_job(config: BatchConfig) -> dict[str, str]:
env = dict(os.environ)
if config.cuda_visible_devices is not None:
env["CUDA_VISIBLE_DEVICES"] = config.cuda_visible_devices
return env
def run_conversion(job: ConversionJob, config: BatchConfig) -> JobResult:
command = command_for_job(job, config)
completed = subprocess.run(
command,
check=False,
capture_output=True,
text=True,
env=env_for_job(config),
)
status = "converted" if completed.returncode == 0 else "failed"
return JobResult(
status=status,
input_path=job.input_path,
output_path=job.output_path,
command=tuple(command),
return_code=completed.returncode,
stdout=completed.stdout,
stderr=completed.stderr,
)
def summarize_failures(results: list[JobResult]) -> None:
failed_results = [result for result in results if result.status == "failed"]
if not failed_results:
return
click.echo("\nFailed conversions:", err=True)
for result in failed_results:
click.echo(f"- {result.input_path} (exit {result.return_code})", err=True)
if result.stderr.strip():
click.echo(result.stderr.rstrip(), err=True)
elif result.stdout.strip():
click.echo(result.stdout.rstrip(), err=True)
def run_batch(jobs: list[ConversionJob], config: BatchConfig, jobs_limit: int) -> tuple[list[JobResult], int]:
results: list[JobResult] = []
aborted_count = 0
if not jobs:
return results, aborted_count
future_to_job: dict[concurrent.futures.Future[JobResult], ConversionJob] = {}
job_iter = iter(jobs)
stop_submitting = False
with concurrent.futures.ThreadPoolExecutor(max_workers=jobs_limit) as executor:
with tqdm(total=len(jobs), unit="file", dynamic_ncols=True) as progress:
def submit_next() -> bool:
if stop_submitting:
return False
try:
job = next(job_iter)
except StopIteration:
return False
future = executor.submit(run_conversion, job, config)
future_to_job[future] = job
return True
for _ in range(min(jobs_limit, len(jobs))):
submit_next()
while future_to_job:
done, _ = concurrent.futures.wait(
future_to_job,
return_when=concurrent.futures.FIRST_COMPLETED,
)
for future in done:
job = future_to_job.pop(future)
result = future.result()
results.append(result)
progress.update(1)
if result.status == "failed":
tqdm.write(f"failed: {job.input_path} (exit {result.return_code})", file=sys.stderr)
if config.fail_fast:
stop_submitting = True
if not stop_submitting:
submit_next()
if stop_submitting:
remaining = sum(1 for _ in job_iter)
aborted_count = remaining
progress.total = progress.n + len(future_to_job)
progress.refresh()
return results, aborted_count
@click.command()
@click.argument("input_dir", type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path))
@click.option(
"--pattern",
"patterns",
multiple=True,
default=DEFAULT_PATTERNS,
show_default=True,
help="Glob pattern to match under the input directory. Repeatable.",
)
@click.option("--recursive/--no-recursive", default=True, show_default=True, help="Use rglob instead of glob.")
@click.option("--jobs", default=1, show_default=True, type=click.IntRange(min=1), help="Parallel conversion jobs.")
@click.option(
"--zed-bin",
type=click.Path(path_type=Path, dir_okay=False),
help="Explicit path to the zed_svo_to_mp4 binary.",
)
@click.option(
"--cuda-visible-devices",
help="Optional CUDA_VISIBLE_DEVICES value exported for each conversion subprocess.",
)
@click.option("--overwrite/--skip-existing", default=False, show_default=True, help="Overwrite existing MP4 files.")
@click.option(
"--fail-fast/--continue-on-error",
default=False,
show_default=True,
help="Stop submitting new work after the first failed conversion.",
)
@click.option("--codec", type=click.Choice(("h264", "h265")), default="h265", show_default=True)
@click.option(
"--encoder-device",
type=click.Choice(("auto", "nvidia", "software")),
default="auto",
show_default=True,
)
@click.option("--preset", type=click.Choice(("fast", "balanced", "quality")), default="fast", show_default=True)
@click.option(
"--tune",
type=click.Choice(("low-latency", "balanced")),
default="low-latency",
show_default=True,
)
@click.option(
"--quality",
type=click.IntRange(min=0, max=51),
default=23,
show_default=True,
help="Lower values mean higher quality.",
)
@click.option("--gop", type=click.IntRange(min=1), default=30, show_default=True)
@click.option("--b-frames", "b_frames", type=click.IntRange(min=0), default=0, show_default=True)
@click.option("--start-frame", type=click.IntRange(min=0), default=0, show_default=True)
@click.option("--end-frame", type=click.IntRange(min=0), default=None)
def main(
input_dir: Path,
patterns: tuple[str, ...],
recursive: bool,
jobs: int,
zed_bin: Path | None,
cuda_visible_devices: str | None,
overwrite: bool,
fail_fast: bool,
codec: str,
encoder_device: str,
preset: str,
tune: str,
quality: int,
gop: int,
b_frames: int,
start_frame: int,
end_frame: int | None,
) -> None:
"""Batch-convert ZED SVO/SVO2 recordings in a folder to MP4."""
if b_frames > gop:
raise click.BadParameter(f"b-frames {b_frames} must be <= gop {gop}", param_hint="--b-frames")
if end_frame is not None and end_frame < start_frame:
raise click.BadParameter(
f"end-frame {end_frame} must be >= start-frame {start_frame}",
param_hint="--end-frame",
)
binary_path = locate_binary(zed_bin)
inputs = discover_inputs(input_dir.absolute(), patterns, recursive)
if not inputs:
raise click.ClickException(f"no .svo/.svo2 files matched under {input_dir}")
config = BatchConfig(
zed_bin=binary_path,
cuda_visible_devices=cuda_visible_devices,
overwrite=overwrite,
fail_fast=fail_fast,
codec=codec,
encoder_device=encoder_device,
preset=preset,
tune=tune,
quality=quality,
gop=gop,
b_frames=b_frames,
start_frame=start_frame,
end_frame=end_frame,
)
skipped_results: list[JobResult] = []
pending_jobs: list[ConversionJob] = []
for input_path in inputs:
output_path = output_path_for(input_path)
command = tuple(command_for_job(ConversionJob(input_path, output_path), config))
if output_path.exists() and not overwrite:
skipped_results.append(
JobResult(
status="skipped",
input_path=input_path,
output_path=output_path,
command=command,
)
)
continue
pending_jobs.append(ConversionJob(input_path=input_path, output_path=output_path))
click.echo(
f"matched={len(inputs)} pending={len(pending_jobs)} skipped={len(skipped_results)} jobs={jobs}",
err=True,
)
results = list(skipped_results)
conversion_results, aborted_count = run_batch(pending_jobs, config, jobs)
results.extend(conversion_results)
converted_count = sum(1 for result in results if result.status == "converted")
skipped_count = sum(1 for result in results if result.status == "skipped")
failed_count = sum(1 for result in results if result.status == "failed")
click.echo(
(
f"summary: matched={len(inputs)} converted={converted_count} "
f"skipped={skipped_count} failed={failed_count} aborted={aborted_count}"
),
err=True,
)
summarize_failures(results)
if failed_count > 0:
raise SystemExit(1)
if aborted_count > 0:
raise SystemExit(1)
if __name__ == "__main__":
main()
-374
View File
@@ -1,374 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import math
import os
import shlex
import subprocess
import sys
import tempfile
from collections import Counter
from pathlib import Path
from typing import Iterable
import cv2
import numpy as np
SCRIPT_PATH = Path(__file__).resolve()
REPO_ROOT = SCRIPT_PATH.parents[1]
WORKSPACE_ROOT = REPO_ROOT.parent
MCAP_PYTHON_ROOT = WORKSPACE_ROOT / "mcap" / "python" / "mcap"
if str(MCAP_PYTHON_ROOT) not in sys.path:
sys.path.insert(0, str(MCAP_PYTHON_ROOT))
from mcap.reader import make_reader # noqa: E402
VIDEO_FORMATS = ("h264", "h265")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Convert ZED SVO/SVO2 recordings to MCAP and generate a lightweight preview. "
"If the input is already an MCAP file, conversion is skipped."
)
)
parser.add_argument("input", help="Input .svo/.svo2 file, .mcap file, or a directory containing SVO files")
parser.add_argument("--output-dir", help="Directory for generated MCAP files and previews")
parser.add_argument(
"--preview-all",
action="store_true",
help="When the input is a directory, generate a preview for every converted MCAP instead of just the first one",
)
parser.add_argument("--no-preview", action="store_true", help="Convert only, do not generate preview images")
parser.add_argument(
"--format",
choices=("auto", "h264", "h265"),
default="auto",
help="CompressedVideo format to extract from MCAP during preview",
)
parser.add_argument("--codec", choices=VIDEO_FORMATS, default="h264", help="Video codec for SVO to MCAP conversion")
parser.add_argument(
"--encoder-device",
choices=("auto", "nvidia", "software"),
default="software",
help="Encoder device passed to zed_svo_to_mcap",
)
parser.add_argument(
"--mcap-compression",
choices=("none", "lz4", "zstd"),
default="none",
help="MCAP chunk compression passed to zed_svo_to_mcap",
)
parser.add_argument(
"--depth-mode",
choices=("neural_light", "neural", "neural_plus"),
default="neural_plus",
help="Depth mode passed to zed_svo_to_mcap",
)
parser.add_argument(
"--depth-size",
default="optimal",
help="Depth size passed to zed_svo_to_mcap (optimal|native|<width>x<height>)",
)
parser.add_argument("--start-frame", type=int, default=0, help="First SVO frame to convert")
parser.add_argument("--end-frame", type=int, help="Last SVO frame to convert")
parser.add_argument(
"--sample-count",
type=int,
default=9,
help="Number of decoded frames to place in the preview contact sheet",
)
parser.add_argument(
"--frame-step",
type=int,
default=15,
help="Decode every Nth frame for the contact sheet",
)
parser.add_argument(
"--contact-sheet-width",
type=int,
default=480,
help="Width of each preview tile in pixels",
)
parser.add_argument(
"--cuda-visible-devices",
help=(
"Optional CUDA_VISIBLE_DEVICES value to export while running zed_svo_to_mcap. "
"Useful when the ZED SDK must be pinned to a specific GPU UUID."
),
)
parser.add_argument("--zed-bin", help="Explicit path to zed_svo_to_mcap")
parser.add_argument("--reader-bin", help="Explicit path to mcap_reader_tester")
return parser.parse_args()
def locate_binary(name: str, override: str | None) -> Path:
if override:
path = Path(override).expanduser().resolve()
if not path.is_file():
raise FileNotFoundError(f"binary not found: {path}")
return path
candidates = (
REPO_ROOT / "build" / "bin" / name,
REPO_ROOT / "build" / name,
)
for candidate in candidates:
if candidate.is_file():
return candidate
raise FileNotFoundError(f"could not find {name} under {REPO_ROOT / 'build'}")
def quote_command(args: Iterable[str]) -> str:
return " ".join(shlex.quote(arg) for arg in args)
def run(args: list[str], env: dict[str, str] | None = None) -> None:
print(f"$ {quote_command(args)}", flush=True)
subprocess.run(args, check=True, env=env)
def summarize_mcap(mcap_path: Path) -> list[tuple[str, str, str, int]]:
counts: Counter[tuple[str, str, str]] = Counter()
with mcap_path.open("rb") as stream:
reader = make_reader(stream)
for schema, channel, _message in reader.iter_messages():
schema_name = schema.name if schema is not None else "<none>"
counts[(channel.topic, channel.message_encoding, schema_name)] += 1
summary_rows = [
(topic, encoding, schema_name, count)
for (topic, encoding, schema_name), count in sorted(counts.items())
]
print(f"MCAP summary: {mcap_path}")
for topic, encoding, schema_name, count in summary_rows:
print(f" {count:6d} topic={topic} encoding={encoding} schema={schema_name}")
return summary_rows
def infer_video_format(reader_bin: Path, mcap_path: Path, requested: str) -> str:
if requested != "auto":
return requested
for candidate in VIDEO_FORMATS:
result = subprocess.run(
[str(reader_bin), str(mcap_path), "--expect-format", candidate, "--min-messages", "1"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
check=False,
)
if result.returncode == 0:
return candidate
raise RuntimeError(f"could not infer video format from {mcap_path}")
def dump_annexb(reader_bin: Path, mcap_path: Path, video_format: str, output_path: Path) -> None:
run(
[
str(reader_bin),
str(mcap_path),
"--expect-format",
video_format,
"--min-messages",
"1",
"--dump-annexb-output",
str(output_path),
]
)
def make_contact_sheet(stream_path: Path, image_path: Path, sample_count: int, frame_step: int, tile_width: int) -> int:
capture = cv2.VideoCapture(str(stream_path))
if not capture.isOpened():
raise RuntimeError(f"OpenCV could not open decoded stream {stream_path}")
frames: list[np.ndarray] = []
frame_index = 0
while len(frames) < sample_count:
ok, frame = capture.read()
if not ok:
break
if frame_index % frame_step == 0:
annotated = frame.copy()
cv2.putText(
annotated,
f"frame {frame_index}",
(20, 40),
cv2.FONT_HERSHEY_SIMPLEX,
1.0,
(0, 255, 0),
2,
cv2.LINE_AA,
)
frames.append(annotated)
frame_index += 1
capture.release()
if not frames:
raise RuntimeError(f"no frames decoded from {stream_path}")
tile_width = max(64, tile_width)
resized: list[np.ndarray] = []
for frame in frames:
scale = tile_width / frame.shape[1]
tile_height = max(1, int(round(frame.shape[0] * scale)))
resized.append(cv2.resize(frame, (tile_width, tile_height), interpolation=cv2.INTER_AREA))
max_height = max(frame.shape[0] for frame in resized)
padded: list[np.ndarray] = []
for frame in resized:
if frame.shape[0] == max_height:
padded.append(frame)
continue
canvas = np.zeros((max_height, frame.shape[1], 3), dtype=np.uint8)
canvas[: frame.shape[0], :, :] = frame
padded.append(canvas)
columns = max(1, math.ceil(math.sqrt(len(padded))))
rows = math.ceil(len(padded) / columns)
blank = np.zeros_like(padded[0])
row_images: list[np.ndarray] = []
for row_index in range(rows):
row_frames = padded[row_index * columns : (row_index + 1) * columns]
while len(row_frames) < columns:
row_frames.append(blank)
row_images.append(np.concatenate(row_frames, axis=1))
sheet = np.concatenate(row_images, axis=0)
image_path.parent.mkdir(parents=True, exist_ok=True)
if not cv2.imwrite(str(image_path), sheet):
raise RuntimeError(f"failed to write preview image {image_path}")
print(f"Preview contact sheet: {image_path}")
return len(frames)
def collect_svo_inputs(input_path: Path) -> list[Path]:
if input_path.is_file():
if input_path.suffix.lower() in {".svo", ".svo2"}:
return [input_path]
if input_path.suffix.lower() == ".mcap":
return []
raise ValueError(f"unsupported input file: {input_path}")
if input_path.is_dir():
return sorted(
path for path in input_path.rglob("*") if path.suffix.lower() in {".svo", ".svo2"}
)
raise FileNotFoundError(f"input not found: {input_path}")
def default_output_dir(input_path: Path) -> Path:
if input_path.is_dir():
return input_path / "mcap_preview"
return input_path.parent / "mcap_preview"
def convert_svo(
zed_bin: Path,
svo_path: Path,
mcap_path: Path,
args: argparse.Namespace,
) -> None:
env = os.environ.copy()
if args.cuda_visible_devices:
env["CUDA_VISIBLE_DEVICES"] = args.cuda_visible_devices
command = [
str(zed_bin),
"--input",
str(svo_path),
"--output",
str(mcap_path),
"--codec",
args.codec,
"--encoder-device",
args.encoder_device,
"--mcap-compression",
args.mcap_compression,
"--depth-mode",
args.depth_mode,
"--depth-size",
args.depth_size,
"--start-frame",
str(args.start_frame),
]
if args.end_frame is not None:
command.extend(["--end-frame", str(args.end_frame)])
mcap_path.parent.mkdir(parents=True, exist_ok=True)
run(command, env=env)
def preview_mcap(reader_bin: Path, mcap_path: Path, args: argparse.Namespace) -> None:
summarize_mcap(mcap_path)
video_format = infer_video_format(reader_bin, mcap_path, args.format)
print(f"Detected video format: {video_format}")
stream_extension = ".h265" if video_format == "h265" else ".h264"
with tempfile.TemporaryDirectory(prefix="zed_mcap_preview_") as temp_dir:
temp_root = Path(temp_dir)
stream_path = temp_root / f"preview{stream_extension}"
dump_annexb(reader_bin, mcap_path, video_format, stream_path)
preview_path = mcap_path.with_suffix(".preview.png")
decoded = make_contact_sheet(
stream_path,
preview_path,
sample_count=args.sample_count,
frame_step=args.frame_step,
tile_width=args.contact_sheet_width,
)
print(f"Decoded {decoded} preview frame(s)")
def main() -> int:
args = parse_args()
input_path = Path(args.input).expanduser().resolve()
output_dir = Path(args.output_dir).expanduser().resolve() if args.output_dir else default_output_dir(input_path)
output_dir.mkdir(parents=True, exist_ok=True)
reader_bin = locate_binary("mcap_reader_tester", args.reader_bin)
zed_bin = locate_binary("zed_svo_to_mcap", args.zed_bin) if input_path.suffix.lower() != ".mcap" or input_path.is_dir() else None
if input_path.is_file() and input_path.suffix.lower() == ".mcap":
if not args.no_preview:
preview_mcap(reader_bin, input_path, args)
return 0
svo_inputs = collect_svo_inputs(input_path)
if not svo_inputs:
raise RuntimeError(f"no .svo/.svo2 files found under {input_path}")
converted_paths: list[Path] = []
for svo_path in svo_inputs:
output_name = f"{svo_path.stem}.mcap"
mcap_path = output_dir / output_name
convert_svo(zed_bin, svo_path, mcap_path, args)
converted_paths.append(mcap_path)
if args.no_preview:
return 0
preview_targets = converted_paths if args.preview_all else converted_paths[:1]
for mcap_path in preview_targets:
preview_mcap(reader_bin, mcap_path, args)
print("Generated MCAP files:")
for mcap_path in converted_paths:
print(f" {mcap_path}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except KeyboardInterrupt:
raise SystemExit(130)
-658
View File
@@ -1,658 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import concurrent.futures
import datetime as dt
import json
import os
import re
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
import click
import duckdb
SCRIPT_PATH = Path(__file__).resolve()
REPO_ROOT = SCRIPT_PATH.parents[1]
DEFAULT_INDEX_NAME = "segment_time_index.duckdb"
INDEX_SCHEMA_VERSION = "1"
SEGMENT_FILE_PATTERN = re.compile(r".*_zed([0-9]+)\.svo2?$", re.IGNORECASE)
FOLDER_TIMESTAMP_PATTERN = re.compile(
r"^(?P<date>\d{4}-\d{2}-\d{2})[T ](?P<hour>\d{2})-(?P<minute>\d{2})-(?P<second>\d{2})(?P<fraction>\.\d+)?(?P<timezone>Z|[+-]\d{2}:\d{2})?$"
)
@dataclass(slots=True, frozen=True)
class SegmentScan:
segment_dir: Path
matched_files: int
camera_labels: tuple[str, ...]
is_valid: bool
reason: str | None = None
@dataclass(slots=True, frozen=True)
class BoundsRow:
segment_dir: Path
relative_segment_dir: str
group_path: str
activity: str
segment_name: str
mcap_path: Path
start_ns: int
end_ns: int
duration_ns: int
start_iso_utc: str
end_iso_utc: str
camera_count: int
camera_labels: str
video_message_count: int
index_source: str
def sorted_camera_labels(labels: set[str]) -> tuple[str, ...]:
return tuple(sorted(labels, key=lambda label: int(label[3:])))
def scan_segment_dir(segment_dir: Path) -> SegmentScan:
if not segment_dir.is_dir():
return SegmentScan(
segment_dir=segment_dir,
matched_files=0,
camera_labels=(),
is_valid=False,
reason=f"segment directory does not exist: {segment_dir}",
)
matched_by_camera: dict[str, list[Path]] = {}
for child in segment_dir.iterdir():
if not child.is_file():
continue
match = SEGMENT_FILE_PATTERN.fullmatch(child.name)
if match is None:
continue
label = f"zed{int(match.group(1))}"
matched_by_camera.setdefault(label, []).append(child)
matched_files = sum(len(paths) for paths in matched_by_camera.values())
camera_labels = sorted_camera_labels(set(matched_by_camera))
duplicate_cameras = [label for label, paths in sorted(matched_by_camera.items()) if len(paths) > 1]
if duplicate_cameras:
return SegmentScan(
segment_dir=segment_dir,
matched_files=matched_files,
camera_labels=camera_labels,
is_valid=False,
reason=f"duplicate camera inputs under {segment_dir}: {', '.join(duplicate_cameras)}",
)
if len(camera_labels) < 2:
return SegmentScan(
segment_dir=segment_dir,
matched_files=matched_files,
camera_labels=camera_labels,
is_valid=False,
reason=f"expected at least 2 camera inputs under {segment_dir}, found {len(camera_labels)}",
)
return SegmentScan(
segment_dir=segment_dir,
matched_files=matched_files,
camera_labels=camera_labels,
is_valid=True,
)
def discover_segment_dirs(root: Path, recursive: bool) -> tuple[list[SegmentScan], list[SegmentScan]]:
if not root.is_dir():
raise click.ClickException(f"input directory does not exist: {root}")
candidate_dirs = {root.resolve()}
iterator = root.rglob("*") if recursive else root.iterdir()
for path in iterator:
if path.is_dir():
candidate_dirs.add(path.resolve())
valid_scans: list[SegmentScan] = []
ignored_partial_scans: list[SegmentScan] = []
for segment_dir in sorted(candidate_dirs):
scan = scan_segment_dir(segment_dir)
if scan.is_valid:
valid_scans.append(scan)
elif scan.matched_files > 0:
ignored_partial_scans.append(scan)
if not valid_scans:
raise click.ClickException(f"no multi-camera segments found under {root}")
return valid_scans, ignored_partial_scans
def locate_binary(name: str, override: Path | None) -> Path:
if override is not None:
candidate = override.expanduser().resolve()
if not candidate.is_file():
raise click.ClickException(f"binary not found: {candidate}")
return candidate
candidates = (
REPO_ROOT / "build" / "bin" / name,
REPO_ROOT / "build" / name,
)
for candidate in candidates:
if candidate.is_file():
return candidate
raise click.ClickException(f"could not find {name} under {REPO_ROOT / 'build'}")
def default_index_path(dataset_root: Path) -> Path:
return dataset_root / DEFAULT_INDEX_NAME
def find_unique_mcap(segment_dir: Path) -> Path | None:
matches = sorted(path for path in segment_dir.iterdir() if path.is_file() and path.suffix.lower() == ".mcap")
if len(matches) == 1:
return matches[0]
return None
def format_ns_iso(ns: int, tzinfo: dt.tzinfo) -> str:
seconds, nanos = divmod(ns, 1_000_000_000)
stamp = dt.datetime.fromtimestamp(seconds, tz=dt.timezone.utc).astimezone(tzinfo)
offset = stamp.strftime("%z")
offset = f"{offset[:3]}:{offset[3:]}" if offset else ""
return f"{stamp.strftime('%Y-%m-%dT%H:%M:%S')}.{nanos:09d}{offset}"
def format_ns_utc(ns: int) -> str:
return format_ns_iso(ns, dt.timezone.utc).replace("+00:00", "Z")
def resolve_timezone(name: str) -> dt.tzinfo:
if name == "local":
local = dt.datetime.now().astimezone().tzinfo
if local is None:
raise click.ClickException("could not resolve local timezone")
return local
if name == "UTC":
return dt.timezone.utc
if name.startswith("UTC") and len(name) == len("UTC+00:00"):
try:
sign = 1 if name[3] == "+" else -1
hours = int(name[4:6])
minutes = int(name[7:9])
except ValueError as exc:
raise click.ClickException(f"invalid fixed UTC offset '{name}'") from exc
return dt.timezone(sign * dt.timedelta(hours=hours, minutes=minutes))
try:
return ZoneInfo(name)
except Exception as exc: # pragma: no cover - defensive wrapper around system tzdb
raise click.ClickException(f"unknown timezone '{name}': {exc}") from exc
def normalize_timestamp_text(value: str) -> str:
match = FOLDER_TIMESTAMP_PATTERN.fullmatch(value)
if match is None:
return value
parts = match.groupdict()
fraction = parts["fraction"] or ""
timezone_text = parts["timezone"] or ""
return f"{parts['date']}T{parts['hour']}:{parts['minute']}:{parts['second']}{fraction}{timezone_text}"
def parse_folder_name_naive(value: str) -> dt.datetime | None:
normalized = normalize_timestamp_text(value)
try:
parsed = dt.datetime.fromisoformat(normalized)
except ValueError:
return None
if parsed.tzinfo is not None:
return None
return parsed
def datetime_to_ns(value: dt.datetime) -> int:
utc_value = value.astimezone(dt.timezone.utc)
return int(utc_value.timestamp()) * 1_000_000_000 + utc_value.microsecond * 1_000
def parse_timestamp_to_ns(value: str, timezone_name: str) -> int:
stripped = value.strip()
if not stripped:
raise click.ClickException("timestamp value is empty")
digit_text = stripped.lstrip("+-")
if digit_text.isdigit():
raw_value = int(stripped)
digits = len(digit_text)
if digits <= 10:
return raw_value * 1_000_000_000
if digits <= 13:
return raw_value * 1_000_000
if digits <= 16:
return raw_value * 1_000
return raw_value
normalized = normalize_timestamp_text(stripped)
if normalized.endswith("Z"):
normalized = normalized[:-1] + "+00:00"
try:
parsed = dt.datetime.fromisoformat(normalized)
except ValueError as exc:
raise click.ClickException(f"invalid timestamp '{value}': {exc}") from exc
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=resolve_timezone(timezone_name))
return datetime_to_ns(parsed)
def parse_timestamp_window(value: str, timezone_name: str) -> tuple[int, int]:
stripped = value.strip()
if not stripped:
raise click.ClickException("timestamp value is empty")
digit_text = stripped.lstrip("+-")
if digit_text.isdigit():
base_ns = parse_timestamp_to_ns(stripped, timezone_name)
digits = len(digit_text)
if digits <= 10:
precision_ns = 1_000_000_000
elif digits <= 13:
precision_ns = 1_000_000
elif digits <= 16:
precision_ns = 1_000
else:
precision_ns = 1
return base_ns, base_ns + precision_ns - 1
normalized = normalize_timestamp_text(stripped)
base_ns = parse_timestamp_to_ns(stripped, timezone_name)
fraction_match = re.search(r"\.(\d+)", normalized)
if fraction_match is None:
precision_ns = 1_000_000_000
else:
digits = min(len(fraction_match.group(1)), 9)
precision_ns = 10 ** (9 - digits)
return base_ns, base_ns + precision_ns - 1
def probe_mcap_bounds(bounds_bin: Path, mcap_path: Path) -> dict[str, Any]:
result = subprocess.run(
[str(bounds_bin), str(mcap_path), "--json"],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if result.returncode != 0:
stderr = result.stderr.strip() or result.stdout.strip() or f"exit {result.returncode}"
raise RuntimeError(f"{mcap_path}: {stderr}")
try:
return json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise RuntimeError(f"{mcap_path}: failed to parse helper JSON: {exc}") from exc
def build_row(dataset_root: Path, scan: SegmentScan, bounds_bin: Path) -> BoundsRow | None:
mcap_path = find_unique_mcap(scan.segment_dir)
if mcap_path is None:
return None
bounds = probe_mcap_bounds(bounds_bin, mcap_path)
relative_segment_dir = scan.segment_dir.relative_to(dataset_root).as_posix()
parent = Path(relative_segment_dir).parent
group_path = "" if str(parent) == "." else parent.as_posix()
parts = Path(relative_segment_dir).parts
activity = parts[0] if parts else scan.segment_dir.name
start_ns = int(bounds["start_ns"])
end_ns = int(bounds["end_ns"])
return BoundsRow(
segment_dir=scan.segment_dir,
relative_segment_dir=relative_segment_dir,
group_path=group_path,
activity=activity,
segment_name=scan.segment_dir.name,
mcap_path=mcap_path,
start_ns=start_ns,
end_ns=end_ns,
duration_ns=max(0, end_ns - start_ns),
start_iso_utc=str(bounds["start_iso_utc"]),
end_iso_utc=str(bounds["end_iso_utc"]),
camera_count=len(scan.camera_labels),
camera_labels=",".join(scan.camera_labels),
video_message_count=int(bounds["video_message_count"]),
index_source="mcap_video_bounds",
)
def init_db(conn: duckdb.DuckDBPyConnection) -> None:
conn.execute(
"""
CREATE TABLE meta (
key VARCHAR PRIMARY KEY,
value VARCHAR NOT NULL
);
"""
)
conn.execute(
"""
CREATE TABLE segments (
segment_dir VARCHAR PRIMARY KEY,
relative_segment_dir VARCHAR NOT NULL,
group_path VARCHAR NOT NULL,
activity VARCHAR NOT NULL,
segment_name VARCHAR NOT NULL,
mcap_path VARCHAR NOT NULL,
start_ns BIGINT NOT NULL,
end_ns BIGINT NOT NULL,
duration_ns BIGINT NOT NULL,
start_iso_utc VARCHAR NOT NULL,
end_iso_utc VARCHAR NOT NULL,
camera_count INTEGER NOT NULL,
camera_labels VARCHAR NOT NULL,
video_message_count BIGINT NOT NULL,
index_source VARCHAR NOT NULL
);
"""
)
conn.execute("CREATE INDEX segments_start_ns_idx ON segments(start_ns);")
conn.execute("CREATE INDEX segments_end_ns_idx ON segments(end_ns);")
def write_index(index_path: Path, dataset_root: Path, rows: list[BoundsRow]) -> None:
index_path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(prefix=f"{index_path.name}.", suffix=".tmp", dir=index_path.parent, delete=False) as handle:
temp_path = Path(handle.name)
temp_path.unlink(missing_ok=True)
inferred_timezone = infer_dataset_timezone(rows)
try:
conn = duckdb.connect(str(temp_path))
try:
init_db(conn)
conn.executemany(
"INSERT INTO meta (key, value) VALUES (?, ?)",
[
("schema_version", INDEX_SCHEMA_VERSION),
("dataset_root", str(dataset_root)),
("built_at_utc", dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")),
("default_timezone", inferred_timezone),
],
)
conn.executemany(
"""
INSERT INTO segments (
segment_dir,
relative_segment_dir,
group_path,
activity,
segment_name,
mcap_path,
start_ns,
end_ns,
duration_ns,
start_iso_utc,
end_iso_utc,
camera_count,
camera_labels,
video_message_count,
index_source
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
str(row.segment_dir),
row.relative_segment_dir,
row.group_path,
row.activity,
row.segment_name,
str(row.mcap_path),
row.start_ns,
row.end_ns,
row.duration_ns,
row.start_iso_utc,
row.end_iso_utc,
row.camera_count,
row.camera_labels,
row.video_message_count,
row.index_source,
)
for row in rows
],
)
finally:
conn.close()
temp_path.replace(index_path)
except Exception:
temp_path.unlink(missing_ok=True)
raise
def infer_dataset_timezone(rows: list[BoundsRow]) -> str:
offset_counts: dict[int, int] = {}
for row in rows:
folder_time = parse_folder_name_naive(row.segment_name)
if folder_time is None:
continue
actual_utc = dt.datetime.fromtimestamp(row.start_ns / 1_000_000_000, tz=dt.timezone.utc).replace(tzinfo=None)
offset_minutes = round((folder_time - actual_utc).total_seconds() / 60.0)
offset_counts[offset_minutes] = offset_counts.get(offset_minutes, 0) + 1
if not offset_counts:
return "local"
minutes = max(offset_counts.items(), key=lambda item: item[1])[0]
if minutes == 0:
return "UTC"
sign = "+" if minutes >= 0 else "-"
absolute_minutes = abs(minutes)
hours, mins = divmod(absolute_minutes, 60)
return f"UTC{sign}{hours:02d}:{mins:02d}"
def require_query_window(at: str | None, start: str | None, end: str | None, timezone_name: str) -> tuple[int, int]:
if at is not None and (start is not None or end is not None):
raise click.ClickException("use either --at or --start/--end, not both")
if at is not None:
return parse_timestamp_window(at, timezone_name)
if start is None or end is None:
raise click.ClickException("provide --at or both --start and --end")
start_ns = parse_timestamp_to_ns(start, timezone_name)
end_ns = parse_timestamp_to_ns(end, timezone_name)
if start_ns > end_ns:
raise click.ClickException("query start must be before or equal to query end")
return start_ns, end_ns
def load_meta(conn: duckdb.DuckDBPyConnection) -> dict[str, str]:
rows = conn.execute("SELECT key, value FROM meta").fetchall()
return {str(key): str(value) for key, value in rows}
def format_duration(duration_ns: int) -> str:
return f"{duration_ns / 1_000_000_000:.3f}s"
@click.group()
def cli() -> None:
"""Build and query a DuckDB index of bundled ZED segment timestamps."""
@cli.command()
@click.argument("dataset_root", type=click.Path(path_type=Path, file_okay=False))
@click.option("--index", "index_path", type=click.Path(path_type=Path, dir_okay=False))
@click.option("--recursive/--no-recursive", default=True, show_default=True)
@click.option("--jobs", type=click.IntRange(min=1), default=min(8, os.cpu_count() or 1), show_default=True)
@click.option("--bounds-bin", type=click.Path(path_type=Path, dir_okay=False))
def build(dataset_root: Path, index_path: Path | None, recursive: bool, jobs: int, bounds_bin: Path | None) -> None:
"""Build or replace the embedded DuckDB time index for DATASET_ROOT."""
dataset_root = dataset_root.expanduser().resolve()
index_path = (index_path or default_index_path(dataset_root)).expanduser().resolve()
bounds_binary = locate_binary("mcap_video_bounds", bounds_bin)
valid_scans, ignored_partial_scans = discover_segment_dirs(dataset_root, recursive)
click.echo(
f"discovered {len(valid_scans)} valid segment directories under {dataset_root}",
err=True,
)
if ignored_partial_scans:
click.echo(f"ignored {len(ignored_partial_scans)} partial segment directories", err=True)
rows: list[BoundsRow] = []
skipped_missing_mcap: list[Path] = []
errors: list[str] = []
with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as executor:
future_to_scan: dict[concurrent.futures.Future[BoundsRow | None], SegmentScan] = {
executor.submit(build_row, dataset_root, scan, bounds_binary): scan for scan in valid_scans
}
for future in concurrent.futures.as_completed(future_to_scan):
scan = future_to_scan[future]
try:
row = future.result()
except Exception as exc:
errors.append(f"{scan.segment_dir}: {exc}")
continue
if row is None:
skipped_missing_mcap.append(scan.segment_dir)
continue
rows.append(row)
rows.sort(key=lambda row: (row.start_ns, row.segment_dir.as_posix()))
if skipped_missing_mcap:
click.echo(f"skipped {len(skipped_missing_mcap)} segments with missing or ambiguous MCAP files", err=True)
if errors:
for error in errors:
click.echo(f"error: {error}", err=True)
raise click.ClickException(f"failed to probe {len(errors)} segment(s)")
if not rows:
raise click.ClickException("no indexable MCAP segments were found")
write_index(index_path, dataset_root, rows)
click.echo(
f"wrote {len(rows)} segments to {index_path} (skipped_missing_mcap={len(skipped_missing_mcap)})",
err=True,
)
@cli.command()
@click.argument("dataset_root", type=click.Path(path_type=Path, file_okay=False))
@click.option("--index", "index_path", type=click.Path(path_type=Path, dir_okay=False))
@click.option("--at")
@click.option("--start")
@click.option("--end")
@click.option("--json", "as_json", is_flag=True)
@click.option("--timezone", "timezone_name", default="dataset", show_default=True)
def query(
dataset_root: Path,
index_path: Path | None,
at: str | None,
start: str | None,
end: str | None,
as_json: bool,
timezone_name: str,
) -> None:
"""Query the embedded time index for matching segment folders."""
dataset_root = dataset_root.expanduser().resolve()
index_path = (index_path or default_index_path(dataset_root)).expanduser().resolve()
if not index_path.is_file():
raise click.ClickException(f"index not found: {index_path}")
conn = duckdb.connect(str(index_path), read_only=True)
try:
meta = load_meta(conn)
indexed_root = Path(meta.get("dataset_root", "")).expanduser().resolve()
if indexed_root != dataset_root:
raise click.ClickException(
f"index root mismatch: index was built for {indexed_root}, not {dataset_root}"
)
effective_timezone_name = meta.get("default_timezone", "local") if timezone_name == "dataset" else timezone_name
query_start_ns, query_end_ns = require_query_window(at, start, end, effective_timezone_name)
display_timezone = resolve_timezone(effective_timezone_name)
result_rows = conn.execute(
"""
SELECT
segment_dir,
relative_segment_dir,
group_path,
activity,
segment_name,
mcap_path,
start_ns,
end_ns,
duration_ns,
start_iso_utc,
end_iso_utc,
camera_count,
camera_labels,
video_message_count,
index_source
FROM segments
WHERE start_ns <= ? AND end_ns >= ?
ORDER BY start_ns, segment_dir
""",
[query_end_ns, query_start_ns],
).fetchall()
finally:
conn.close()
payload = [
{
"segment_dir": row[0],
"relative_segment_dir": row[1],
"group_path": row[2],
"activity": row[3],
"segment_name": row[4],
"mcap_path": row[5],
"start_ns": row[6],
"end_ns": row[7],
"duration_ns": row[8],
"start_iso_utc": row[9],
"end_iso_utc": row[10],
"camera_count": row[11],
"camera_labels": row[12].split(",") if row[12] else [],
"video_message_count": row[13],
"index_source": row[14],
"start_display": format_ns_iso(row[6], display_timezone),
"end_display": format_ns_iso(row[7], display_timezone),
}
for row in result_rows
]
if as_json:
click.echo(json.dumps(payload, indent=2, ensure_ascii=False))
return
if not payload:
click.echo("no matching segments")
return
click.echo(f"matched {len(payload)} segment(s)")
for row in payload:
click.echo(
" | ".join(
(
row["start_display"],
row["end_display"],
format_duration(int(row["duration_ns"])),
row["segment_dir"],
row["mcap_path"],
)
)
)
if __name__ == "__main__":
cli()
+182 -44
View File
@@ -263,16 +263,27 @@ CLI::Validator validate_rtp_endpoint() {
std::string{});
}
std::optional<std::string_view> match_cli_flag(std::string_view arg, std::string_view flag) {
if (arg == flag) {
return flag;
}
if (arg.size() > flag.size() && arg.rfind(flag, 0) == 0 && arg[flag.size()] == '=') {
return flag;
}
return std::nullopt;
}
std::optional<std::string> find_disallowed_boolean_assignment(int argc, char **argv) {
struct FlagPair {
std::string_view positive;
std::string_view negative;
};
constexpr std::array<FlagPair, 6> kFlagPairs{{
constexpr std::array<FlagPair, 7> kFlagPairs{{
{"--rtmp", "--no-rtmp"},
{"--rtp", "--no-rtp"},
{"--mcap", "--no-mcap"},
{"--mcap-depth", "--no-mcap-depth"},
{"--realtime-sync", "--no-realtime-sync"},
{"--force-idr-on-reset", "--no-force-idr-on-reset"},
{"--keep-stream-on-reset", "--no-keep-stream-on-reset"},
@@ -292,6 +303,81 @@ std::optional<std::string> find_disallowed_boolean_assignment(int argc, char **a
return std::nullopt;
}
std::optional<std::string> find_unsupported_mcap_argument(int argc, char **argv) {
constexpr std::array<std::string_view, 10> kMcapFlags{{
"--mcap",
"--no-mcap",
"--mcap-path",
"--mcap-topic",
"--mcap-calibration-topic",
"--mcap-pose-topic",
"--mcap-body-topic",
"--mcap-frame-id",
"--mcap-compression",
"--mcap-depth-topic",
}};
constexpr std::array<std::string_view, 3> kMcapDepthFlags{{
"--mcap-depth",
"--no-mcap-depth",
"--mcap-depth-calibration-topic",
}};
constexpr bool mcap_supported = CVMMAP_STREAMER_HAS_MCAP != 0;
constexpr bool mcap_depth_supported = CVMMAP_STREAMER_HAS_MCAP_DEPTH != 0;
for (int i = 1; i < argc; ++i) {
const std::string_view arg{argv[i]};
if (!mcap_supported) {
for (const auto flag : kMcapFlags) {
if (match_cli_flag(arg, flag)) {
return "unsupported argument in this build: " + std::string(flag) +
" (MCAP recording support is not compiled in)";
}
}
for (const auto flag : kMcapDepthFlags) {
if (match_cli_flag(arg, flag)) {
return "unsupported argument in this build: " + std::string(flag) +
" (MCAP recording support is not compiled in)";
}
}
continue;
}
if (!mcap_depth_supported) {
for (const auto flag : kMcapDepthFlags) {
if (match_cli_flag(arg, flag)) {
return "unsupported argument in this build: " + std::string(flag) +
" (MCAP depth support is not compiled in)";
}
}
}
}
return std::nullopt;
}
bool runtime_supports_mcap() {
return CVMMAP_STREAMER_HAS_MCAP != 0;
}
bool runtime_supports_mcap_depth() {
return CVMMAP_STREAMER_HAS_MCAP_DEPTH != 0;
}
std::expected<void, std::string> validate_mcap_capability_request(const McapRecordConfig &config) {
if (!config.enabled) {
return {};
}
if (!runtime_supports_mcap()) {
return std::unexpected(
"invalid MCAP config: MCAP recording was requested, but this build was compiled without MCAP support");
}
if (config.depth_enabled && !runtime_supports_mcap_depth()) {
return std::unexpected(
"invalid MCAP config: depth recording was requested, but this build was compiled without MCAP depth support");
}
return {};
}
template <typename T>
std::optional<T> toml_value(const toml::table &table, std::string_view path) {
auto node = table.at_path(path);
@@ -462,6 +548,9 @@ std::expected<void, std::string> apply_toml_file(RuntimeConfig &config, const st
if (auto value = toml_value<bool>(table, "record.mcap.enabled")) {
config.record.mcap.enabled = *value;
}
if (auto value = toml_value<bool>(table, "record.mcap.depth_enabled")) {
config.record.mcap.depth_enabled = *value;
}
if (auto value = toml_value<std::string>(table, "record.mcap.path")) {
config.record.mcap.enabled = true;
config.record.mcap.path = *value;
@@ -710,6 +799,7 @@ std::expected<RuntimeConfig, std::string> parse_runtime_config(int argc, char **
std::optional<std::uint16_t> rtp_payload_type_override{};
std::optional<std::string> rtp_sdp_override{};
std::optional<bool> mcap_enabled_override{};
std::optional<bool> mcap_depth_enabled_override{};
std::optional<std::string> mcap_path_override{};
std::optional<std::string> mcap_topic_override{};
std::optional<std::string> mcap_depth_topic_override{};
@@ -834,6 +924,7 @@ std::expected<RuntimeConfig, std::string> parse_runtime_config(int argc, char **
->check(require_non_empty("--sdp"))
->excludes(rtp_sdp);
#if CVMMAP_STREAMER_HAS_MCAP
app.add_flag("--mcap,!--no-mcap", mcap_enabled_override, "Enable or disable MCAP recording")
->group("MCAP Record")
->default_str(defaults.record.mcap.enabled ? "true" : "false")
@@ -843,49 +934,77 @@ std::expected<RuntimeConfig, std::string> parse_runtime_config(int argc, char **
->type_name("PATH")
->check(require_non_empty("--mcap-path"))
->default_str(defaults.record.mcap.path);
app.add_option("--mcap-topic", mcap_topic_override, "Foxglove compressed video topic name")
app.add_option(
"--mcap-topic",
mcap_topic_override,
"Foxglove compressed video topic name")
->group("MCAP Record")
->type_name("TOPIC")
->check(require_non_empty("--mcap-topic"))
->default_str(defaults.record.mcap.topic);
app.add_option("--mcap-depth-topic", mcap_depth_topic_override, "Depth image topic name")
->group("MCAP Record")
->type_name("TOPIC")
->check(require_non_empty("--mcap-depth-topic"))
->default_str(defaults.record.mcap.depth_topic);
app.add_option("--mcap-calibration-topic", mcap_calibration_topic_override, "RGB camera calibration topic name")
app.add_option(
"--mcap-calibration-topic",
mcap_calibration_topic_override,
"RGB camera calibration topic name")
->group("MCAP Record")
->type_name("TOPIC")
->check(require_non_empty("--mcap-calibration-topic"))
->default_str(defaults.record.mcap.calibration_topic);
app.add_option(
"--mcap-depth-calibration-topic",
mcap_depth_calibration_topic_override,
"Depth camera calibration topic name")
->group("MCAP Record")
->type_name("TOPIC")
->check(require_non_empty("--mcap-depth-calibration-topic"))
->default_str(defaults.record.mcap.depth_calibration_topic);
app.add_option("--mcap-pose-topic", mcap_pose_topic_override, "Pose topic name")
->group("MCAP Record")
->type_name("TOPIC")
->check(require_non_empty("--mcap-pose-topic"))
->default_str(defaults.record.mcap.pose_topic);
app.add_option("--mcap-body-topic", mcap_body_topic_override, "Body tracking topic name")
app.add_option(
"--mcap-body-topic",
mcap_body_topic_override,
"Body tracking topic name")
->group("MCAP Record")
->type_name("TOPIC")
->check(require_non_empty("--mcap-body-topic"))
->default_str(defaults.record.mcap.body_topic);
app.add_option("--mcap-frame-id", mcap_frame_id_override, "Frame ID written into MCAP messages")
app.add_option(
"--mcap-frame-id",
mcap_frame_id_override,
"Frame ID written into MCAP messages")
->group("MCAP Record")
->type_name("ID")
->check(require_non_empty("--mcap-frame-id"))
->default_str(defaults.record.mcap.frame_id);
app.add_option("--mcap-compression", mcap_compression_override, "MCAP chunk compression mode")
app.add_option(
"--mcap-compression",
mcap_compression_override,
"MCAP chunk compression mode")
->group("MCAP Record")
->type_name("MODE")
->transform(canonicalize_option(canonicalize_mcap_compression))
->default_str(std::string(to_string(defaults.record.mcap.compression)));
#if CVMMAP_STREAMER_HAS_MCAP_DEPTH
app.add_flag(
"--mcap-depth,!--no-mcap-depth",
mcap_depth_enabled_override,
"Enable or disable MCAP depth recording")
->group("MCAP Depth Record")
->default_str(defaults.record.mcap.depth_enabled ? "true" : "false")
->disable_flag_override();
app.add_option(
"--mcap-depth-topic",
mcap_depth_topic_override,
"Depth image topic name")
->group("MCAP Depth Record")
->type_name("TOPIC")
->check(require_non_empty("--mcap-depth-topic"))
->default_str(defaults.record.mcap.depth_topic);
app.add_option(
"--mcap-depth-calibration-topic",
mcap_depth_calibration_topic_override,
"Depth camera calibration topic name")
->group("MCAP Depth Record")
->type_name("TOPIC")
->check(require_non_empty("--mcap-depth-calibration-topic"))
->default_str(defaults.record.mcap.depth_calibration_topic);
#endif
#endif
app.add_option("--queue-size", queue_size_override, "Pipeline queue depth")
->group("Latency")
@@ -939,6 +1058,10 @@ std::expected<RuntimeConfig, std::string> parse_runtime_config(int argc, char **
->check(CLI::NonNegativeNumber)
->default_str(std::to_string(defaults.latency.emit_stall_ms));
if (auto unsupported_mcap_argument = find_unsupported_mcap_argument(argc, argv)) {
return std::unexpected(*unsupported_mcap_argument);
}
if (auto invalid_boolean_assignment = find_disallowed_boolean_assignment(argc, argv)) {
return std::unexpected(*invalid_boolean_assignment);
}
@@ -1078,6 +1201,9 @@ std::expected<RuntimeConfig, std::string> parse_runtime_config(int argc, char **
if (mcap_enabled_override) {
config.record.mcap.enabled = *mcap_enabled_override;
}
if (mcap_depth_enabled_override) {
config.record.mcap.depth_enabled = *mcap_depth_enabled_override;
}
if (queue_size_override) {
config.latency.queue_size = *queue_size_override;
@@ -1135,7 +1261,7 @@ std::expected<void, std::string> validate_runtime_config(const RuntimeConfig &co
}
if (config.outputs.rtmp.enabled) {
if (config.encoder.backend == EncoderBackendType::Auto) {
// auto resolves to FFmpeg; nothing else is supported.
// auto may select the Jetson Multimedia API backend on Jetson before falling back to FFmpeg software.
} else if (config.encoder.backend != EncoderBackendType::FFmpeg) {
return std::unexpected("invalid backend/output matrix: RTMP requires encoder.backend=ffmpeg or auto");
}
@@ -1157,30 +1283,41 @@ std::expected<void, std::string> validate_runtime_config(const RuntimeConfig &co
}
}
if (config.record.mcap.enabled && config.record.mcap.path.empty()) {
return std::unexpected("invalid MCAP config: enabled MCAP output requires path");
}
if (config.record.mcap.topic.empty()) {
return std::unexpected("invalid MCAP config: topic must not be empty");
}
if (config.record.mcap.depth_topic.empty()) {
return std::unexpected("invalid MCAP config: depth_topic must not be empty");
}
if (config.record.mcap.calibration_topic.empty()) {
return std::unexpected("invalid MCAP config: calibration_topic must not be empty");
}
if (!config.record.mcap.depth_calibration_topic.empty() &&
config.record.mcap.depth_calibration_topic == config.record.mcap.calibration_topic) {
return std::unexpected("invalid MCAP config: depth_calibration_topic must differ from calibration_topic");
}
if (config.record.mcap.pose_topic.empty()) {
return std::unexpected("invalid MCAP config: pose_topic must not be empty");
}
if (config.record.mcap.body_topic.empty()) {
return std::unexpected("invalid MCAP config: body_topic must not be empty");
}
if (config.record.mcap.frame_id.empty()) {
return std::unexpected("invalid MCAP config: frame_id must not be empty");
if (config.record.mcap.enabled) {
if (auto capability_validation = validate_mcap_capability_request(config.record.mcap); !capability_validation) {
return std::unexpected(capability_validation.error());
}
if (config.record.mcap.path.empty()) {
return std::unexpected("invalid MCAP config: enabled MCAP output requires path");
}
if (config.record.mcap.topic.empty()) {
return std::unexpected("invalid MCAP config: topic must not be empty");
}
if (config.record.mcap.calibration_topic.empty()) {
return std::unexpected("invalid MCAP config: calibration_topic must not be empty");
}
if (config.record.mcap.pose_topic.empty()) {
return std::unexpected("invalid MCAP config: pose_topic must not be empty");
}
if (config.record.mcap.body_topic.empty()) {
return std::unexpected("invalid MCAP config: body_topic must not be empty");
}
if (config.record.mcap.frame_id.empty()) {
return std::unexpected("invalid MCAP config: frame_id must not be empty");
}
if (config.record.mcap.depth_enabled) {
if (config.record.mcap.depth_topic.empty()) {
return std::unexpected("invalid MCAP config: depth_topic must not be empty when depth recording is enabled");
}
if (config.record.mcap.depth_calibration_topic.empty()) {
return std::unexpected(
"invalid MCAP config: depth_calibration_topic must not be empty when depth recording is enabled");
}
if (config.record.mcap.depth_calibration_topic == config.record.mcap.calibration_topic) {
return std::unexpected(
"invalid MCAP config: depth_calibration_topic must differ from calibration_topic when depth recording is enabled");
}
}
}
if (config.latency.queue_size == 0) {
@@ -1213,6 +1350,7 @@ std::string summarize_runtime_config(const RuntimeConfig &config) {
ss << ", rtp.endpoint=" << (config.outputs.rtp.endpoint ? *config.outputs.rtp.endpoint : "<unset>");
ss << ", rtp.payload_type=" << static_cast<unsigned>(config.outputs.rtp.payload_type);
ss << ", mcap.enabled=" << (config.record.mcap.enabled ? "true" : "false");
ss << ", mcap.depth_enabled=" << (config.record.mcap.depth_enabled ? "true" : "false");
ss << ", mcap.path=" << config.record.mcap.path;
ss << ", mcap.topic=" << config.record.mcap.topic;
ss << ", mcap.depth_topic=" << config.record.mcap.depth_topic;
+125 -1
View File
@@ -1,14 +1,138 @@
#include "cvmmap_streamer/encode/encoder_backend.hpp"
#ifndef CVMMAP_STREAMER_HAS_JETSON_MM
#define CVMMAP_STREAMER_HAS_JETSON_MM 0
#endif
#include <optional>
#include <spdlog/spdlog.h>
namespace cvmmap_streamer::encode {
EncoderBackend make_ffmpeg_backend();
#if CVMMAP_STREAMER_HAS_JETSON_MM
EncoderBackend make_jetson_mm_backend();
#endif
namespace {
class SelectingEncoderBackend {
public:
[[nodiscard]]
std::string_view backend_name() const {
return active_backend_ ? (*active_backend_)->backend_name() : std::string_view{"selecting"};
}
[[nodiscard]]
bool using_hardware() const {
return active_backend_ ? (*active_backend_)->using_hardware() : false;
}
[[nodiscard]]
Status init(const RuntimeConfig &config, const ipc::FrameInfo &frame_info) {
shutdown();
#if CVMMAP_STREAMER_HAS_JETSON_MM
if (config.encoder.device != EncoderDeviceType::Software) {
auto jetson_backend = make_jetson_mm_backend();
auto jetson_init = jetson_backend->init(config, frame_info);
if (jetson_init) {
active_backend_.emplace(std::move(jetson_backend));
return {};
}
if (config.encoder.device == EncoderDeviceType::Nvidia) {
return std::unexpected(jetson_init.error());
}
spdlog::warn(
"JETSON_MM_ENCODER_FALLBACK reason='{}' fallback=ffmpeg_software",
format_error(jetson_init.error()));
}
if (config.encoder.device == EncoderDeviceType::Nvidia) {
return unexpected_error(
ERR_BACKEND_UNAVAILABLE,
"Jetson hardware encoder backend is unavailable in this build");
}
#else
if (config.encoder.device == EncoderDeviceType::Nvidia) {
return unexpected_error(
ERR_BACKEND_UNAVAILABLE,
"Jetson hardware encoder backend is unavailable in this build");
}
#endif
auto ffmpeg_backend = make_ffmpeg_backend();
auto ffmpeg_config = config;
if (config.encoder.device == EncoderDeviceType::Auto) {
ffmpeg_config.encoder.device = EncoderDeviceType::Software;
}
auto ffmpeg_init = ffmpeg_backend->init(ffmpeg_config, frame_info);
if (!ffmpeg_init) {
return std::unexpected(ffmpeg_init.error());
}
active_backend_.emplace(std::move(ffmpeg_backend));
return {};
}
[[nodiscard]]
Result<EncodedStreamInfo> stream_info() const {
if (!active_backend_) {
return unexpected_error(ERR_NOT_READY, "encoder backend is unavailable before initialization");
}
return (*active_backend_)->stream_info();
}
[[nodiscard]]
Status poll() {
if (!active_backend_) {
return {};
}
return (*active_backend_)->poll();
}
[[nodiscard]]
Status push_frame(const RawVideoFrame &frame) {
if (!active_backend_) {
return unexpected_error(ERR_NOT_READY, "encoder backend not initialized");
}
return (*active_backend_)->push_frame(frame);
}
[[nodiscard]]
Result<std::vector<EncodedAccessUnit>> drain() {
if (!active_backend_) {
return std::vector<EncodedAccessUnit>{};
}
return (*active_backend_)->drain();
}
[[nodiscard]]
Result<std::vector<EncodedAccessUnit>> flush() {
if (!active_backend_) {
return std::vector<EncodedAccessUnit>{};
}
return (*active_backend_)->flush();
}
void shutdown() {
if (active_backend_) {
(*active_backend_)->shutdown();
active_backend_.reset();
}
}
private:
std::optional<EncoderBackend> active_backend_{};
};
} // namespace
Result<EncoderBackend> make_encoder_backend(const RuntimeConfig &config) {
switch (config.encoder.backend) {
case EncoderBackendType::FFmpeg:
case EncoderBackendType::Auto:
return make_ffmpeg_backend();
case EncoderBackendType::Auto:
return pro::make_proxy<EncoderBackendFacade, SelectingEncoderBackend>();
}
return unexpected_error(ERR_INTERNAL, "unknown encoder backend");
+136 -94
View File
@@ -1,4 +1,5 @@
#include "cvmmap_streamer/encode/encoder_backend.hpp"
#include "ffmpeg_encoder_options.hpp"
extern "C" {
#include <libavcodec/avcodec.h>
@@ -52,10 +53,9 @@ public:
Status init(const RuntimeConfig &config, const ipc::FrameInfo &frame_info) {
shutdown();
config_ = &config;
frame_info_ = frame_info;
codec_ = config.encoder.codec;
encoder_pix_fmt_ = pick_encoder_pixel_format(config.encoder.device);
config_ = &config;
frame_info_ = frame_info;
codec_ = config.encoder.codec;
auto input_pixel_format = to_av_pixel_format(frame_info.pixel_format);
if (!input_pixel_format) {
@@ -63,45 +63,15 @@ public:
}
input_pix_fmt_ = *input_pixel_format;
auto encoder_name = pick_encoder_name(config);
if (!encoder_name) {
return std::unexpected(encoder_name.error());
}
using_hardware_ = encoder_name->find("nvenc") != std::string::npos;
const auto *encoder = avcodec_find_encoder_by_name(encoder_name->c_str());
if (encoder == nullptr) {
return unexpected_error(ERR_BACKEND_UNAVAILABLE, "FFmpeg encoder '" + *encoder_name + "' is unavailable");
auto opened_encoder = open_encoder(config);
if (!opened_encoder) {
return std::unexpected(opened_encoder.error());
}
context_ = avcodec_alloc_context3(encoder);
if (context_ == nullptr) {
return unexpected_error(ERR_ALLOCATION_FAILED, "failed to allocate FFmpeg encoder context");
}
context_->codec_type = AVMEDIA_TYPE_VIDEO;
context_->codec_id = encoder->id;
context_->width = static_cast<int>(frame_info.width);
context_->height = static_cast<int>(frame_info.height);
context_->pix_fmt = encoder_pix_fmt_;
context_->flags |= AV_CODEC_FLAG_GLOBAL_HEADER;
context_->time_base = AVRational{1, 1000000000};
context_->framerate = AVRational{30, 1};
context_->gop_size = static_cast<int>(config.encoder.gop);
context_->max_b_frames = static_cast<int>(config.encoder.b_frames);
context_->thread_count = 1;
auto codec_setup = configure_codec(*encoder_name, config);
if (!codec_setup) {
return codec_setup;
}
const auto open_result = avcodec_open2(context_, encoder, nullptr);
if (open_result < 0) {
return unexpected_error(
ERR_ENCODER,
"failed to open FFmpeg encoder '" + *encoder_name + "': " + av_error_string(open_result));
}
context_ = opened_encoder->context;
encoder_name_ = std::string(opened_encoder->candidate.name);
encoder_pix_fmt_ = opened_encoder->candidate.pixel_format;
using_hardware_ = opened_encoder->candidate.using_hardware;
scaler_ = sws_getCachedContext(
nullptr,
@@ -151,10 +121,11 @@ public:
stream_info_ = build_stream_info();
spdlog::info(
"FFMPEG_ENCODER_PATH codec={} device={} encoder={} pix_fmt={}",
"FFMPEG_ENCODER_PATH codec={} device={} encoder={} hardware={} pix_fmt={}",
cvmmap_streamer::to_string(codec_),
device_to_string(config.encoder.device),
*encoder_name,
encoder_name_,
using_hardware_,
av_get_pix_fmt_name(encoder_pix_fmt_));
return {};
}
@@ -218,11 +189,15 @@ public:
}
frame_->pict_type = frame.force_keyframe ? AV_PICTURE_TYPE_I : AV_PICTURE_TYPE_NONE;
#if defined(AV_FRAME_FLAG_KEY)
if (frame.force_keyframe) {
frame_->flags |= AV_FRAME_FLAG_KEY;
} else {
frame_->flags &= ~AV_FRAME_FLAG_KEY;
}
#else
frame_->key_frame = frame.force_keyframe ? 1 : 0;
#endif
frame_->pts = static_cast<std::int64_t>(frame.source_timestamp_ns - *first_source_timestamp_ns_);
const auto send_result = avcodec_send_frame(context_, frame_);
if (send_result < 0) {
@@ -270,6 +245,7 @@ public:
}
first_source_timestamp_ns_.reset();
stream_info_.reset();
encoder_name_.clear();
using_hardware_ = false;
}
@@ -294,14 +270,6 @@ private:
}
}
[[nodiscard]]
static AVPixelFormat pick_encoder_pixel_format(EncoderDeviceType device) {
if (device == EncoderDeviceType::Software) {
return AV_PIX_FMT_YUV420P;
}
return AV_PIX_FMT_NV12;
}
[[nodiscard]]
static std::string_view device_to_string(EncoderDeviceType device) {
switch (device) {
@@ -316,57 +284,130 @@ private:
}
[[nodiscard]]
Result<std::string> pick_encoder_name(const RuntimeConfig &config) const {
const bool prefer_hardware = config.encoder.device != EncoderDeviceType::Software;
const bool prefer_software = config.encoder.device == EncoderDeviceType::Software;
if (codec_ == CodecType::H265) {
if (prefer_hardware && avcodec_find_encoder_by_name("hevc_nvenc") != nullptr) {
return std::string("hevc_nvenc");
}
if (!prefer_hardware || config.encoder.device == EncoderDeviceType::Auto) {
if (avcodec_find_encoder_by_name("libx265") != nullptr) {
return std::string("libx265");
}
}
if (!prefer_software && avcodec_find_encoder_by_name("hevc_nvenc") != nullptr) {
return std::string("hevc_nvenc");
}
static Result<void> set_string_option(AVCodecContext *context, const char *key, std::string_view value) {
const auto result = av_opt_set(context->priv_data, key, std::string(value).c_str(), 0);
if (result < 0) {
return unexpected_error(
ERR_BACKEND_UNAVAILABLE,
"no usable FFmpeg encoder found for h265 (looked for hevc_nvenc, libx265)");
ERR_ENCODER,
"failed to set FFmpeg encoder option '" + std::string(key) + "=" + std::string(value) + "': " + av_error_string(result));
}
if (prefer_hardware && avcodec_find_encoder_by_name("h264_nvenc") != nullptr) {
return std::string("h264_nvenc");
}
if (!prefer_hardware || config.encoder.device == EncoderDeviceType::Auto) {
if (avcodec_find_encoder_by_name("libx264") != nullptr) {
return std::string("libx264");
}
}
if (!prefer_software && avcodec_find_encoder_by_name("h264_nvenc") != nullptr) {
return std::string("h264_nvenc");
}
return unexpected_error(
ERR_BACKEND_UNAVAILABLE,
"no usable FFmpeg encoder found for h264 (looked for h264_nvenc, libx264)");
return {};
}
[[nodiscard]]
Status configure_codec(std::string_view encoder_name, const RuntimeConfig &config) {
av_opt_set(context_->priv_data, "preset", encoder_name.find("nvenc") != std::string_view::npos ? "p1" : "veryfast", 0);
if (encoder_name.find("nvenc") != std::string_view::npos) {
av_opt_set(context_->priv_data, "tune", "ull", 0);
av_opt_set(context_->priv_data, "zerolatency", "1", 0);
av_opt_set(context_->priv_data, "rc-lookahead", "0", 0);
} else {
av_opt_set(context_->priv_data, "tune", "zerolatency", 0);
if (encoder_name == "libx265") {
av_opt_set(context_->priv_data, "x265-params", "repeat-headers=1:scenecut=0", 0);
static Result<void> set_int_option(AVCodecContext *context, const char *key, std::int64_t value) {
const auto result = av_opt_set_int(context->priv_data, key, value, 0);
if (result < 0) {
return unexpected_error(
ERR_ENCODER,
"failed to set FFmpeg encoder option '" + std::string(key) + "=" + std::to_string(value) + "': " + av_error_string(result));
}
return {};
}
struct OpenedEncoder {
AVCodecContext *context{nullptr};
FfmpegEncoderCandidate candidate{};
};
[[nodiscard]]
Result<OpenedEncoder> open_encoder(const RuntimeConfig &config) const {
const auto candidates = ffmpeg_encoder_candidates(codec_, config.encoder.device);
const auto attempted_candidates = ffmpeg_encoder_candidate_list(candidates);
std::string last_error{};
for (const auto &candidate : candidates) {
const auto *encoder = avcodec_find_encoder_by_name(candidate.name.data());
if (encoder == nullptr) {
last_error = "FFmpeg encoder '" + std::string(candidate.name) + "' is unavailable";
spdlog::warn(
"FFmpeg encoder '{}' unavailable in {} mode, trying next candidate",
candidate.name,
device_to_string(config.encoder.device));
continue;
}
auto *context = avcodec_alloc_context3(encoder);
if (context == nullptr) {
return unexpected_error(ERR_ALLOCATION_FAILED, "failed to allocate FFmpeg encoder context");
}
context->codec_type = AVMEDIA_TYPE_VIDEO;
context->codec_id = encoder->id;
context->width = static_cast<int>(frame_info_.width);
context->height = static_cast<int>(frame_info_.height);
context->pix_fmt = candidate.pixel_format;
context->flags |= AV_CODEC_FLAG_GLOBAL_HEADER;
context->time_base = AVRational{1, 1000000000};
context->framerate = AVRational{30, 1};
context->gop_size = static_cast<int>(config.encoder.gop);
context->max_b_frames = static_cast<int>(config.encoder.b_frames);
context->thread_count = 1;
auto codec_setup = configure_codec(context, candidate, config);
if (!codec_setup) {
last_error = codec_setup.error().detail;
avcodec_free_context(&context);
spdlog::warn(
"FFmpeg encoder '{}' configuration failed in {} mode: {}. trying next candidate",
candidate.name,
device_to_string(config.encoder.device),
codec_setup.error().detail);
continue;
}
const auto open_result = avcodec_open2(context, encoder, nullptr);
if (open_result < 0) {
last_error = "failed to open FFmpeg encoder '" + std::string(candidate.name) + "': " + av_error_string(open_result);
avcodec_free_context(&context);
spdlog::warn(
"FFmpeg encoder '{}' failed to open in {} mode: {}. trying next candidate",
candidate.name,
device_to_string(config.encoder.device),
av_error_string(open_result));
continue;
}
return OpenedEncoder{.context = context, .candidate = candidate};
}
av_opt_set_int(context_->priv_data, "forced-idr", config.latency.force_idr_on_reset ? 1 : 0, 0);
if (last_error.empty()) {
last_error = "no usable FFmpeg encoder found";
}
const auto error_code = config.encoder.device == EncoderDeviceType::Auto ? ERR_ENCODER : ERR_BACKEND_UNAVAILABLE;
return unexpected_error(error_code, last_error + " (attempted: " + attempted_candidates + ")");
}
[[nodiscard]]
static Status configure_codec(AVCodecContext *context, const FfmpegEncoderCandidate &candidate, const RuntimeConfig &config) {
if (const auto preset = ffmpeg_encoder_preset(candidate); preset) {
if (auto set = set_string_option(context, "preset", *preset); !set) {
return set;
}
}
if (const auto tune = ffmpeg_encoder_tune(candidate); tune) {
if (auto set = set_string_option(context, "tune", *tune); !set) {
return set;
}
}
if (const auto x265_params = ffmpeg_encoder_x265_params(candidate); x265_params) {
if (auto set = set_string_option(context, "x265-params", *x265_params); !set) {
return set;
}
}
if (ffmpeg_encoder_supports_nvenc_latency_flags(candidate)) {
if (auto set = set_string_option(context, "zerolatency", "1"); !set) {
return set;
}
if (auto set = set_string_option(context, "rc-lookahead", "0"); !set) {
return set;
}
}
if (ffmpeg_encoder_supports_forced_idr_option(candidate)) {
if (auto set = set_int_option(context, "forced-idr", config.latency.force_idr_on_reset ? 1 : 0); !set) {
return set;
}
}
return {};
}
@@ -478,6 +519,7 @@ private:
AVPixelFormat encoder_pix_fmt_{AV_PIX_FMT_NONE};
std::optional<std::uint64_t> first_source_timestamp_ns_{};
std::optional<EncodedStreamInfo> stream_info_{};
std::string encoder_name_{};
bool using_hardware_{false};
};
+197
View File
@@ -0,0 +1,197 @@
#pragma once
#include "cvmmap_streamer/config/runtime_config.hpp"
extern "C" {
#include <libavutil/pixfmt.h>
}
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace cvmmap_streamer::encode {
enum class FfmpegEncoderFamily {
Nvenc,
V4l2M2m,
Omx,
LibX264,
LibX265,
};
struct FfmpegEncoderCandidate {
std::string_view name{};
FfmpegEncoderFamily family{FfmpegEncoderFamily::LibX264};
bool using_hardware{false};
AVPixelFormat pixel_format{AV_PIX_FMT_NONE};
};
[[nodiscard]]
inline std::vector<FfmpegEncoderCandidate> ffmpeg_encoder_candidates(const CodecType codec, const EncoderDeviceType device) {
std::vector<FfmpegEncoderCandidate> candidates{};
auto append_hardware_candidates = [&] {
if (codec == CodecType::H265) {
candidates.push_back(FfmpegEncoderCandidate{
.name = "hevc_nvenc",
.family = FfmpegEncoderFamily::Nvenc,
.using_hardware = true,
.pixel_format = AV_PIX_FMT_NV12,
});
candidates.push_back(FfmpegEncoderCandidate{
.name = "hevc_v4l2m2m",
.family = FfmpegEncoderFamily::V4l2M2m,
.using_hardware = true,
.pixel_format = AV_PIX_FMT_NV12,
});
return;
}
candidates.push_back(FfmpegEncoderCandidate{
.name = "h264_nvenc",
.family = FfmpegEncoderFamily::Nvenc,
.using_hardware = true,
.pixel_format = AV_PIX_FMT_NV12,
});
candidates.push_back(FfmpegEncoderCandidate{
.name = "h264_v4l2m2m",
.family = FfmpegEncoderFamily::V4l2M2m,
.using_hardware = true,
.pixel_format = AV_PIX_FMT_NV12,
});
candidates.push_back(FfmpegEncoderCandidate{
.name = "h264_omx",
.family = FfmpegEncoderFamily::Omx,
.using_hardware = true,
.pixel_format = AV_PIX_FMT_NV12,
});
};
const auto append_software_candidate = [&] {
if (codec == CodecType::H265) {
candidates.push_back(FfmpegEncoderCandidate{
.name = "libx265",
.family = FfmpegEncoderFamily::LibX265,
.using_hardware = false,
.pixel_format = AV_PIX_FMT_YUV420P,
});
return;
}
candidates.push_back(FfmpegEncoderCandidate{
.name = "libx264",
.family = FfmpegEncoderFamily::LibX264,
.using_hardware = false,
.pixel_format = AV_PIX_FMT_YUV420P,
});
};
switch (device) {
case EncoderDeviceType::Auto:
append_hardware_candidates();
append_software_candidate();
break;
case EncoderDeviceType::Nvidia:
append_hardware_candidates();
break;
case EncoderDeviceType::Software:
append_software_candidate();
break;
}
return candidates;
}
[[nodiscard]]
inline std::string ffmpeg_encoder_candidate_list(const std::vector<FfmpegEncoderCandidate> &candidates) {
std::string joined{};
for (const auto &candidate : candidates) {
if (!joined.empty()) {
joined += ", ";
}
joined += candidate.name;
}
return joined;
}
[[nodiscard]]
inline std::optional<std::string_view> ffmpeg_encoder_preset(const FfmpegEncoderCandidate &candidate) {
switch (candidate.family) {
case FfmpegEncoderFamily::Nvenc:
return "p1";
case FfmpegEncoderFamily::LibX264:
case FfmpegEncoderFamily::LibX265:
return "veryfast";
case FfmpegEncoderFamily::V4l2M2m:
case FfmpegEncoderFamily::Omx:
return std::nullopt;
}
return std::nullopt;
}
[[nodiscard]]
inline std::optional<std::string_view> ffmpeg_encoder_tune(const FfmpegEncoderCandidate &candidate) {
switch (candidate.family) {
case FfmpegEncoderFamily::Nvenc:
return "ull";
case FfmpegEncoderFamily::LibX264:
return "zerolatency";
case FfmpegEncoderFamily::V4l2M2m:
case FfmpegEncoderFamily::Omx:
case FfmpegEncoderFamily::LibX265:
return std::nullopt;
}
return std::nullopt;
}
[[nodiscard]]
inline std::optional<std::string_view> ffmpeg_encoder_rate_control_mode(const FfmpegEncoderCandidate &candidate) {
if (candidate.family == FfmpegEncoderFamily::Nvenc) {
return "vbr";
}
return std::nullopt;
}
[[nodiscard]]
inline std::optional<std::string_view> ffmpeg_encoder_quality_key(const FfmpegEncoderCandidate &candidate) {
switch (candidate.family) {
case FfmpegEncoderFamily::Nvenc:
return "cq";
case FfmpegEncoderFamily::LibX264:
case FfmpegEncoderFamily::LibX265:
return "crf";
case FfmpegEncoderFamily::V4l2M2m:
case FfmpegEncoderFamily::Omx:
return std::nullopt;
}
return std::nullopt;
}
[[nodiscard]]
inline std::optional<std::string_view> ffmpeg_encoder_x265_params(const FfmpegEncoderCandidate &candidate) {
if (candidate.family == FfmpegEncoderFamily::LibX265) {
return "repeat-headers=1:scenecut=0";
}
return std::nullopt;
}
[[nodiscard]]
inline bool ffmpeg_encoder_supports_nvenc_latency_flags(const FfmpegEncoderCandidate &candidate) {
return candidate.family == FfmpegEncoderFamily::Nvenc;
}
[[nodiscard]]
inline bool ffmpeg_encoder_supports_forced_idr_option(const FfmpegEncoderCandidate &candidate) {
switch (candidate.family) {
case FfmpegEncoderFamily::Nvenc:
case FfmpegEncoderFamily::LibX264:
case FfmpegEncoderFamily::LibX265:
return true;
case FfmpegEncoderFamily::V4l2M2m:
case FfmpegEncoderFamily::Omx:
return false;
}
return false;
}
} // namespace cvmmap_streamer::encode
+750
View File
@@ -0,0 +1,750 @@
#include "cvmmap_streamer/encode/encoder_backend.hpp"
extern "C" {
#include <libavutil/frame.h>
#include <libavutil/imgutils.h>
#include <libavutil/pixfmt.h>
#include <libswscale/swscale.h>
}
#include <NvBuffer.h>
#include <NvVideoEncoder.h>
#include <nvbufsurface.h>
#include <linux/videodev2.h>
#include <array>
#include <chrono>
#include <condition_variable>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <mutex>
#include <optional>
#include <string>
#include <string_view>
#include <sys/time.h>
#include <unordered_map>
#include <utility>
#include <vector>
#include <spdlog/spdlog.h>
namespace cvmmap_streamer::encode {
namespace {
constexpr std::uint32_t kEncoderBufferCount = 6;
constexpr std::uint32_t kCapturePlaneSizeImage = 2u * 1024u * 1024u;
constexpr std::uint32_t kDefaultFrameRateNum = 30;
constexpr std::uint32_t kDefaultFrameRateDen = 1;
constexpr auto kWarmupTimeout = std::chrono::seconds(2);
constexpr auto kFlushTimeout = std::chrono::seconds(2);
constexpr std::uint8_t kAnnexBStartCode[4]{0x00, 0x00, 0x00, 0x01};
[[nodiscard]]
Result<AVPixelFormat> to_av_pixel_format(ipc::PixelFormat format) {
switch (format) {
case ipc::PixelFormat::BGR:
return AV_PIX_FMT_BGR24;
case ipc::PixelFormat::RGB:
return AV_PIX_FMT_RGB24;
case ipc::PixelFormat::BGRA:
return AV_PIX_FMT_BGRA;
case ipc::PixelFormat::RGBA:
return AV_PIX_FMT_RGBA;
case ipc::PixelFormat::GRAY:
return AV_PIX_FMT_GRAY8;
default:
return unexpected_error(
ERR_UNSUPPORTED,
"unsupported raw pixel format for Jetson backend (supported: BGR/RGB/BGRA/RGBA/GRAY)");
}
}
[[nodiscard]]
std::uint32_t codec_capture_plane_format(CodecType codec) {
return codec == CodecType::H265 ? V4L2_PIX_FMT_H265 : V4L2_PIX_FMT_H264;
}
[[nodiscard]]
std::uint32_t default_bitrate_bits_per_second(const ipc::FrameInfo &frame_info) {
const auto pixels_per_second =
static_cast<std::uint64_t>(frame_info.width) *
static_cast<std::uint64_t>(frame_info.height) *
static_cast<std::uint64_t>(kDefaultFrameRateNum);
const auto estimated = pixels_per_second / 8u;
return static_cast<std::uint32_t>(std::clamp<std::uint64_t>(estimated, 2'000'000u, 25'000'000u));
}
[[nodiscard]]
std::uint64_t timeval_to_token_us(const timeval &timestamp) {
return static_cast<std::uint64_t>(timestamp.tv_sec) * 1'000'000ull +
static_cast<std::uint64_t>(timestamp.tv_usec);
}
[[nodiscard]]
timeval token_us_to_timeval(const std::uint64_t token_us) {
timeval timestamp{};
timestamp.tv_sec = static_cast<time_t>(token_us / 1'000'000ull);
timestamp.tv_usec = static_cast<suseconds_t>(token_us % 1'000'000ull);
return timestamp;
}
[[nodiscard]]
std::optional<std::pair<std::size_t, std::size_t>> next_start_code(std::span<const std::uint8_t> bytes, std::size_t offset) {
for (std::size_t i = offset; i + 3 <= bytes.size(); ++i) {
if (bytes[i] == 0x00 && bytes[i + 1] == 0x00 && bytes[i + 2] == 0x01) {
return std::pair{i, static_cast<std::size_t>(3)};
}
if (i + 4 <= bytes.size() &&
bytes[i] == 0x00 &&
bytes[i + 1] == 0x00 &&
bytes[i + 2] == 0x00 &&
bytes[i + 3] == 0x01) {
return std::pair{i, static_cast<std::size_t>(4)};
}
}
return std::nullopt;
}
[[nodiscard]]
std::vector<std::span<const std::uint8_t>> split_annexb_nalus(std::span<const std::uint8_t> access_unit) {
std::vector<std::span<const std::uint8_t>> nalus{};
for (std::size_t cursor = 0;;) {
auto current_sc = next_start_code(access_unit, cursor);
if (!current_sc) {
break;
}
const auto payload_begin = current_sc->first + current_sc->second;
auto next_sc = next_start_code(access_unit, payload_begin);
const auto payload_end = next_sc ? next_sc->first : access_unit.size();
if (payload_begin < payload_end) {
nalus.push_back(access_unit.subspan(payload_begin, payload_end - payload_begin));
}
if (!next_sc) {
break;
}
cursor = next_sc->first;
}
return nalus;
}
[[nodiscard]]
bool is_parameter_set_nalu(const CodecType codec, std::span<const std::uint8_t> nalu) {
if (nalu.empty()) {
return false;
}
if (codec == CodecType::H265) {
if (nalu.size() < 2) {
return false;
}
const auto nal_type = static_cast<std::uint8_t>((nalu[0] >> 1) & 0x3fu);
return nal_type == 32 || nal_type == 33 || nal_type == 34;
}
const auto nal_type = static_cast<std::uint8_t>(nalu[0] & 0x1fu);
return nal_type == 7 || nal_type == 8;
}
[[nodiscard]]
std::vector<std::uint8_t> extract_decoder_config_annexb(const CodecType codec, std::span<const std::uint8_t> access_unit) {
std::vector<std::uint8_t> decoder_config{};
for (const auto nalu : split_annexb_nalus(access_unit)) {
if (!is_parameter_set_nalu(codec, nalu)) {
continue;
}
decoder_config.insert(decoder_config.end(), std::begin(kAnnexBStartCode), std::end(kAnnexBStartCode));
decoder_config.insert(decoder_config.end(), nalu.begin(), nalu.end());
}
return decoder_config;
}
class JetsonMmEncoderBackend {
public:
~JetsonMmEncoderBackend() {
shutdown();
}
[[nodiscard]]
std::string_view backend_name() const {
return "jetson_mm";
}
[[nodiscard]]
bool using_hardware() const {
return true;
}
[[nodiscard]]
Status init(const RuntimeConfig &config, const ipc::FrameInfo &frame_info) {
shutdown();
frame_info_ = frame_info;
codec_ = config.encoder.codec;
config_ = &config;
auto input_pixel_format = to_av_pixel_format(frame_info.pixel_format);
if (!input_pixel_format) {
return std::unexpected(input_pixel_format.error());
}
input_pix_fmt_ = *input_pixel_format;
scaler_ = sws_getCachedContext(
nullptr,
static_cast<int>(frame_info.width),
static_cast<int>(frame_info.height),
input_pix_fmt_,
static_cast<int>(frame_info.width),
static_cast<int>(frame_info.height),
AV_PIX_FMT_YUV420P,
SWS_BILINEAR,
nullptr,
nullptr,
nullptr);
if (scaler_ == nullptr) {
return unexpected_error(ERR_EXTERNAL_LIBRARY, "failed to create Jetson swscale conversion context");
}
converted_frame_ = av_frame_alloc();
if (converted_frame_ == nullptr) {
return unexpected_error(ERR_ALLOCATION_FAILED, "failed to allocate Jetson conversion frame");
}
converted_frame_->format = AV_PIX_FMT_YUV420P;
converted_frame_->width = static_cast<int>(frame_info.width);
converted_frame_->height = static_cast<int>(frame_info.height);
const auto frame_buffer = av_frame_get_buffer(converted_frame_, 32);
if (frame_buffer < 0) {
return unexpected_error(ERR_ALLOCATION_FAILED, "failed to allocate Jetson conversion frame buffer");
}
encoder_ = NvVideoEncoder::createVideoEncoder("cvmmap_streamer_jetson_mm");
if (encoder_ == nullptr) {
return unexpected_error(ERR_BACKEND_UNAVAILABLE, "failed to create Jetson NvVideoEncoder");
}
if (encoder_->setCapturePlaneFormat(
codec_capture_plane_format(codec_),
frame_info.width,
frame_info.height,
kCapturePlaneSizeImage) < 0) {
return unexpected_error(ERR_BACKEND_UNAVAILABLE, "failed to set Jetson encoder capture plane format");
}
if (encoder_->setOutputPlaneFormat(V4L2_PIX_FMT_YUV420M, frame_info.width, frame_info.height) < 0) {
return unexpected_error(ERR_BACKEND_UNAVAILABLE, "failed to set Jetson encoder output plane format");
}
if (encoder_->setBitrate(default_bitrate_bits_per_second(frame_info)) < 0) {
return unexpected_error(ERR_BACKEND_UNAVAILABLE, "failed to set Jetson encoder bitrate");
}
if (codec_ == CodecType::H264) {
if (encoder_->setProfile(V4L2_MPEG_VIDEO_H264_PROFILE_MAIN) < 0) {
return unexpected_error(ERR_BACKEND_UNAVAILABLE, "failed to set Jetson H.264 profile");
}
if (encoder_->setLevel(V4L2_MPEG_VIDEO_H264_LEVEL_5_1) < 0) {
return unexpected_error(ERR_BACKEND_UNAVAILABLE, "failed to set Jetson H.264 level");
}
} else {
if (encoder_->setProfile(V4L2_MPEG_VIDEO_H265_PROFILE_MAIN) < 0) {
return unexpected_error(ERR_BACKEND_UNAVAILABLE, "failed to set Jetson H.265 profile");
}
}
if (encoder_->setRateControlMode(V4L2_MPEG_VIDEO_BITRATE_MODE_CBR) < 0) {
return unexpected_error(ERR_BACKEND_UNAVAILABLE, "failed to set Jetson encoder rate control mode");
}
if (encoder_->setIDRInterval(config.encoder.gop) < 0) {
return unexpected_error(ERR_BACKEND_UNAVAILABLE, "failed to set Jetson encoder IDR interval");
}
if (encoder_->setIFrameInterval(config.encoder.gop) < 0) {
return unexpected_error(ERR_BACKEND_UNAVAILABLE, "failed to set Jetson encoder I-frame interval");
}
if (encoder_->setNumBFrames(config.encoder.b_frames) < 0) {
return unexpected_error(ERR_BACKEND_UNAVAILABLE, "failed to set Jetson encoder B-frame count");
}
if (encoder_->setInsertSpsPpsAtIdrEnabled(true) < 0) {
return unexpected_error(ERR_BACKEND_UNAVAILABLE, "failed to enable Jetson SPS/PPS insertion at IDR");
}
if (encoder_->setFrameRate(kDefaultFrameRateNum, kDefaultFrameRateDen) < 0) {
return unexpected_error(ERR_BACKEND_UNAVAILABLE, "failed to set Jetson encoder frame rate");
}
if (encoder_->setMaxPerfMode(1) < 0) {
return unexpected_error(ERR_BACKEND_UNAVAILABLE, "failed to enable Jetson max performance mode");
}
if (encoder_->output_plane.setupPlane(V4L2_MEMORY_MMAP, kEncoderBufferCount, true, false) < 0) {
return unexpected_error(ERR_BACKEND_UNAVAILABLE, "failed to set up Jetson output plane buffers");
}
if (encoder_->capture_plane.setupPlane(V4L2_MEMORY_MMAP, kEncoderBufferCount, true, false) < 0) {
return unexpected_error(ERR_BACKEND_UNAVAILABLE, "failed to set up Jetson capture plane buffers");
}
if (!encoder_->capture_plane.setDQThreadCallback(&JetsonMmEncoderBackend::capture_plane_dq_callback)) {
return unexpected_error(ERR_BACKEND_UNAVAILABLE, "failed to register Jetson capture dequeue callback");
}
if (encoder_->output_plane.setStreamStatus(true) < 0) {
return unexpected_error(ERR_BACKEND_UNAVAILABLE, "failed to start Jetson output plane");
}
if (encoder_->capture_plane.setStreamStatus(true) < 0) {
return unexpected_error(ERR_BACKEND_UNAVAILABLE, "failed to start Jetson capture plane");
}
if (encoder_->capture_plane.startDQThread(this) < 0) {
return unexpected_error(ERR_BACKEND_UNAVAILABLE, "failed to start Jetson capture dequeue thread");
}
capture_thread_started_ = true;
for (std::uint32_t i = 0; i < encoder_->capture_plane.getNumBuffers(); ++i) {
v4l2_buffer capture_buffer{};
std::array<v4l2_plane, MAX_PLANES> capture_planes{};
capture_buffer.index = i;
capture_buffer.m.planes = capture_planes.data();
if (encoder_->capture_plane.qBuffer(capture_buffer, nullptr) < 0) {
return unexpected_error(ERR_BACKEND_UNAVAILABLE, "failed to queue empty Jetson capture buffer");
}
}
stream_info_ = EncodedStreamInfo{
.codec = codec_,
.width = frame_info.width,
.height = frame_info.height,
.time_base_num = 1,
.time_base_den = 1'000'000'000u,
.frame_rate_num = kDefaultFrameRateNum,
.frame_rate_den = kDefaultFrameRateDen,
.bitstream_format = EncodedBitstreamFormat::AnnexB,
.decoder_config = {},
};
auto warmup = run_warmup();
if (!warmup) {
return warmup;
}
spdlog::info(
"JETSON_MM_ENCODER_READY codec={} width={} height={} gop={} b_frames={}",
cvmmap_streamer::to_string(codec_),
frame_info.width,
frame_info.height,
config.encoder.gop,
config.encoder.b_frames);
return {};
}
[[nodiscard]]
Result<EncodedStreamInfo> stream_info() const {
if (!stream_info_) {
return unexpected_error(ERR_NOT_READY, "Jetson backend stream info is unavailable before initialization");
}
return *stream_info_;
}
[[nodiscard]]
Status poll() {
return check_async_error();
}
[[nodiscard]]
Status push_frame(const RawVideoFrame &frame) {
auto status = check_async_error();
if (!status) {
return status;
}
if (encoder_ == nullptr || converted_frame_ == nullptr || scaler_ == nullptr) {
return unexpected_error(ERR_NOT_READY, "Jetson backend not initialized");
}
if (frame.bytes.empty()) {
return {};
}
const auto ticket_us = next_ticket_us_++;
const auto stream_pts_ns = ensure_stream_pts(frame.source_timestamp_ns);
auto converted = convert_frame(frame);
if (!converted) {
return converted;
}
return submit_converted_frame(ticket_us, frame.source_timestamp_ns, stream_pts_ns, frame.force_keyframe, false);
}
[[nodiscard]]
Result<std::vector<EncodedAccessUnit>> drain() {
auto status = check_async_error();
if (!status) {
return std::unexpected(status.error());
}
std::vector<EncodedAccessUnit> access_units{};
std::lock_guard lock(mutex_);
access_units.reserve(ready_access_units_.size());
while (!ready_access_units_.empty()) {
access_units.push_back(std::move(ready_access_units_.front()));
ready_access_units_.erase(ready_access_units_.begin());
}
return access_units;
}
[[nodiscard]]
Result<std::vector<EncodedAccessUnit>> flush() {
auto status = check_async_error();
if (!status) {
return std::unexpected(status.error());
}
if (encoder_ == nullptr || eos_sent_) {
return drain();
}
auto eos_submit = submit_end_of_stream();
if (!eos_submit) {
return std::unexpected(eos_submit.error());
}
std::unique_lock lock(mutex_);
const auto completed = condition_.wait_for(lock, kFlushTimeout, [&] {
return capture_eos_ || async_error_.has_value();
});
if (!completed && !capture_eos_) {
return unexpected_error(ERR_ENCODER, "timed out waiting for Jetson encoder EOS");
}
if (async_error_) {
return std::unexpected(*async_error_);
}
lock.unlock();
return drain();
}
void shutdown() {
if (encoder_ != nullptr) {
if (capture_thread_started_) {
encoder_->capture_plane.stopDQThread();
encoder_->capture_plane.waitForDQThread(1000);
capture_thread_started_ = false;
}
encoder_->output_plane.setStreamStatus(false);
encoder_->capture_plane.setStreamStatus(false);
delete encoder_;
encoder_ = nullptr;
}
if (converted_frame_ != nullptr) {
av_frame_free(&converted_frame_);
}
if (scaler_ != nullptr) {
sws_freeContext(scaler_);
scaler_ = nullptr;
}
first_real_source_timestamp_ns_.reset();
stream_info_.reset();
eos_sent_ = false;
capture_eos_ = false;
next_output_buffer_index_ = 0;
next_ticket_us_ = 1;
input_pix_fmt_ = AV_PIX_FMT_NONE;
config_ = nullptr;
std::lock_guard lock(mutex_);
pending_tickets_.clear();
ready_access_units_.clear();
async_error_.reset();
}
private:
struct TicketMetadata {
std::uint64_t source_timestamp_ns{0};
std::uint64_t stream_pts_ns{0};
bool warmup{false};
};
[[nodiscard]]
static bool capture_plane_dq_callback(
v4l2_buffer *v4l2_buf,
NvBuffer *buffer,
NvBuffer * /*shared_buffer*/,
void *data) {
auto *self = static_cast<JetsonMmEncoderBackend *>(data);
return self != nullptr ? self->handle_capture_buffer(v4l2_buf, buffer) : false;
}
[[nodiscard]]
bool handle_capture_buffer(v4l2_buffer *v4l2_buf, NvBuffer *buffer) {
if (v4l2_buf == nullptr || buffer == nullptr) {
store_async_error(unexpected_error(ERR_ENCODER, "Jetson capture dequeue callback received a null buffer").error());
return false;
}
if (buffer->planes[0].bytesused == 0) {
std::lock_guard lock(mutex_);
capture_eos_ = true;
condition_.notify_all();
return false;
}
v4l2_ctrl_videoenc_outputbuf_metadata metadata{};
if (encoder_->getMetadata(v4l2_buf->index, metadata) < 0) {
store_async_error(unexpected_error(ERR_ENCODER, "failed to read Jetson encoder output metadata").error());
return false;
}
TicketMetadata ticket{};
{
std::lock_guard lock(mutex_);
const auto ticket_it = pending_tickets_.find(timeval_to_token_us(v4l2_buf->timestamp));
if (ticket_it == pending_tickets_.end()) {
store_async_error(unexpected_error(ERR_PROTOCOL, "Jetson encoder returned an unknown frame ticket").error());
return false;
}
ticket = ticket_it->second;
pending_tickets_.erase(ticket_it);
}
std::vector<std::uint8_t> annexb_bytes(
buffer->planes[0].data,
buffer->planes[0].data + buffer->planes[0].bytesused);
const auto keyframe = metadata.KeyFrame != 0;
if (keyframe && stream_info_ && stream_info_->decoder_config.empty()) {
stream_info_->decoder_config = extract_decoder_config_annexb(codec_, annexb_bytes);
}
if (!ticket.warmup) {
EncodedAccessUnit access_unit{};
access_unit.codec = codec_;
access_unit.source_timestamp_ns = ticket.source_timestamp_ns;
access_unit.stream_pts_ns = ticket.stream_pts_ns;
access_unit.keyframe = keyframe;
access_unit.annexb_bytes = std::move(annexb_bytes);
std::lock_guard lock(mutex_);
ready_access_units_.push_back(std::move(access_unit));
}
condition_.notify_all();
if (encoder_->capture_plane.qBuffer(*v4l2_buf, nullptr) < 0) {
store_async_error(unexpected_error(ERR_ENCODER, "failed to requeue Jetson capture buffer").error());
return false;
}
return true;
}
void store_async_error(Error error) {
std::lock_guard lock(mutex_);
if (!async_error_) {
async_error_ = std::move(error);
}
condition_.notify_all();
}
[[nodiscard]]
Status check_async_error() const {
std::lock_guard lock(mutex_);
if (async_error_) {
return std::unexpected(*async_error_);
}
return {};
}
[[nodiscard]]
std::uint64_t ensure_stream_pts(const std::uint64_t source_timestamp_ns) {
if (!first_real_source_timestamp_ns_) {
first_real_source_timestamp_ns_ = source_timestamp_ns;
}
return source_timestamp_ns - *first_real_source_timestamp_ns_;
}
[[nodiscard]]
Status convert_frame(const RawVideoFrame &frame) {
const auto make_writable = av_frame_make_writable(converted_frame_);
if (make_writable < 0) {
return unexpected_error(ERR_EXTERNAL_LIBRARY, "failed to make Jetson conversion frame writable");
}
AVFrame input_frame{};
input_frame.format = input_pix_fmt_;
input_frame.width = static_cast<int>(frame_info_.width);
input_frame.height = static_cast<int>(frame_info_.height);
if (av_image_fill_arrays(
input_frame.data,
input_frame.linesize,
const_cast<std::uint8_t *>(frame.bytes.data()),
input_pix_fmt_,
input_frame.width,
input_frame.height,
1) < 0) {
return unexpected_error(ERR_INVALID_ARGUMENT, "failed to map input frame into Jetson conversion image arrays");
}
if (frame.row_stride_bytes != 0) {
input_frame.linesize[0] = static_cast<int>(frame.row_stride_bytes);
}
sws_scale(
scaler_,
input_frame.data,
input_frame.linesize,
0,
input_frame.height,
converted_frame_->data,
converted_frame_->linesize);
return {};
}
void fill_black_frame() {
av_frame_make_writable(converted_frame_);
for (int y = 0; y < converted_frame_->height; ++y) {
std::memset(converted_frame_->data[0] + y * converted_frame_->linesize[0], 16, static_cast<std::size_t>(converted_frame_->width));
}
for (int y = 0; y < converted_frame_->height / 2; ++y) {
std::memset(converted_frame_->data[1] + y * converted_frame_->linesize[1], 128, static_cast<std::size_t>(converted_frame_->width / 2));
std::memset(converted_frame_->data[2] + y * converted_frame_->linesize[2], 128, static_cast<std::size_t>(converted_frame_->width / 2));
}
}
[[nodiscard]]
Status sync_output_buffer_for_device(NvBuffer &buffer) const {
for (std::uint32_t plane = 0; plane < buffer.n_planes; ++plane) {
NvBufSurface *surface = nullptr;
if (NvBufSurfaceFromFd(buffer.planes[plane].fd, reinterpret_cast<void **>(&surface)) != 0 || surface == nullptr) {
return unexpected_error(ERR_EXTERNAL_LIBRARY, "failed to resolve Jetson output plane surface");
}
if (NvBufSurfaceSyncForDevice(surface, 0, static_cast<int>(plane)) != 0) {
return unexpected_error(ERR_EXTERNAL_LIBRARY, "failed to sync Jetson output plane buffer for device");
}
}
return {};
}
[[nodiscard]]
Result<NvBuffer *> acquire_output_buffer(v4l2_buffer &v4l2_buf, std::array<v4l2_plane, MAX_PLANES> &planes) {
planes.fill(v4l2_plane{});
std::memset(&v4l2_buf, 0, sizeof(v4l2_buf));
v4l2_buf.m.planes = planes.data();
NvBuffer *buffer = nullptr;
if (next_output_buffer_index_ < encoder_->output_plane.getNumBuffers()) {
buffer = encoder_->output_plane.getNthBuffer(next_output_buffer_index_);
v4l2_buf.index = next_output_buffer_index_++;
} else if (encoder_->output_plane.dqBuffer(v4l2_buf, &buffer, nullptr, 1000) < 0) {
return unexpected_error(ERR_ENCODER, "failed to dequeue Jetson output plane buffer");
}
if (buffer == nullptr) {
return unexpected_error(ERR_ENCODER, "Jetson output plane returned a null buffer");
}
return buffer;
}
[[nodiscard]]
Status submit_converted_frame(
const std::uint64_t ticket_us,
const std::uint64_t source_timestamp_ns,
const std::uint64_t stream_pts_ns,
const bool force_keyframe,
const bool warmup) {
std::array<v4l2_plane, MAX_PLANES> planes{};
v4l2_buffer v4l2_buf{};
auto acquired = acquire_output_buffer(v4l2_buf, planes);
if (!acquired) {
return std::unexpected(acquired.error());
}
NvBuffer *buffer = *acquired;
if (force_keyframe && encoder_->forceIDR() < 0) {
return unexpected_error(ERR_ENCODER, "failed to force a Jetson IDR frame");
}
for (std::uint32_t plane = 0; plane < buffer->n_planes; ++plane) {
auto &output_plane = buffer->planes[plane];
const auto plane_width = plane == 0 ? frame_info_.width : frame_info_.width / 2;
const auto plane_height = plane == 0 ? frame_info_.height : frame_info_.height / 2;
for (std::uint32_t row = 0; row < plane_height; ++row) {
std::memcpy(
output_plane.data + row * output_plane.fmt.stride,
converted_frame_->data[plane] + row * converted_frame_->linesize[plane],
plane_width);
}
output_plane.bytesused = output_plane.fmt.stride * plane_height;
v4l2_buf.m.planes[plane].bytesused = output_plane.bytesused;
}
v4l2_buf.flags |= V4L2_BUF_FLAG_TIMESTAMP_COPY;
v4l2_buf.timestamp = token_us_to_timeval(ticket_us);
auto sync = sync_output_buffer_for_device(*buffer);
if (!sync) {
return sync;
}
{
std::lock_guard lock(mutex_);
pending_tickets_[ticket_us] = TicketMetadata{
.source_timestamp_ns = source_timestamp_ns,
.stream_pts_ns = stream_pts_ns,
.warmup = warmup,
};
}
if (encoder_->output_plane.qBuffer(v4l2_buf, nullptr) < 0) {
std::lock_guard lock(mutex_);
pending_tickets_.erase(ticket_us);
return unexpected_error(ERR_ENCODER, "failed to queue Jetson output frame");
}
return {};
}
[[nodiscard]]
Status submit_end_of_stream() {
std::array<v4l2_plane, MAX_PLANES> planes{};
v4l2_buffer v4l2_buf{};
auto acquired = acquire_output_buffer(v4l2_buf, planes);
if (!acquired) {
return std::unexpected(acquired.error());
}
v4l2_buf.m.planes[0].bytesused = 0;
if (encoder_->output_plane.qBuffer(v4l2_buf, nullptr) < 0) {
return unexpected_error(ERR_ENCODER, "failed to queue Jetson encoder EOS");
}
eos_sent_ = true;
return {};
}
[[nodiscard]]
Status run_warmup() {
fill_black_frame();
const auto warmup_ticket = next_ticket_us_++;
auto warmup_submit = submit_converted_frame(warmup_ticket, 0, 0, true, true);
if (!warmup_submit) {
return warmup_submit;
}
std::unique_lock lock(mutex_);
const auto completed = condition_.wait_for(lock, kWarmupTimeout, [&] {
return async_error_.has_value() || (stream_info_ && !stream_info_->decoder_config.empty());
});
if (!completed || !stream_info_ || stream_info_->decoder_config.empty()) {
return unexpected_error(ERR_ENCODER, "failed to harvest Jetson decoder configuration from warmup frame");
}
if (async_error_) {
return std::unexpected(*async_error_);
}
return {};
}
const RuntimeConfig *config_{nullptr};
ipc::FrameInfo frame_info_{};
CodecType codec_{CodecType::H264};
NvVideoEncoder *encoder_{nullptr};
SwsContext *scaler_{nullptr};
AVFrame *converted_frame_{nullptr};
AVPixelFormat input_pix_fmt_{AV_PIX_FMT_NONE};
std::optional<std::uint64_t> first_real_source_timestamp_ns_{};
std::optional<EncodedStreamInfo> stream_info_{};
std::uint32_t next_output_buffer_index_{0};
std::uint64_t next_ticket_us_{1};
bool capture_thread_started_{false};
bool eos_sent_{false};
mutable std::mutex mutex_{};
std::condition_variable condition_{};
std::unordered_map<std::uint64_t, TicketMetadata> pending_tickets_{};
std::vector<EncodedAccessUnit> ready_access_units_{};
std::optional<Error> async_error_{};
bool capture_eos_{false};
};
} // namespace
EncoderBackend make_jetson_mm_backend() {
return pro::make_proxy<EncoderBackendFacade, JetsonMmEncoderBackend>();
}
} // namespace cvmmap_streamer::encode
+42 -35
View File
@@ -9,41 +9,48 @@ namespace cvmmap_streamer {
namespace {
constexpr std::array<std::string_view, 34> kHelpLines{
"Usage:",
" --help, -h\tshow this message",
"",
"Options:",
" --version\tprint version information",
" --config <path>\tload runtime config from TOML",
" --input-uri <uri>\tcvmmap source URI (example: cvmmap://default)",
" --run-mode <mode>\tpipeline|ingest",
" --codec <codec>\th264|h265",
" --encoder-backend <backend>\tauto|ffmpeg",
" --encoder-device <device>\tauto|nvidia|software",
" --gop <frames>\tencoder GOP length",
" --b-frames <count>\tencoder B-frame count",
" --keep-stream-on-reset <bool>\tkeep RTMP/RTP sessions alive across upstream stream_reset events",
" --rtp\t\tenable RTP output",
" --rtp-endpoint <host:port>\tRTP destination",
" --rtp-payload-type <pt>\tRTP payload type (96-127)",
" --rtp-sdp <path>\twrite optional SDP sidecar",
" --rtmp\t\tenable RTMP output",
" --rtmp-url <url>\tadd RTMP destination (repeatable)",
" --rtmp-transport <mode>\tlibavformat|ffmpeg_process",
" --rtmp-ffmpeg <path>\tffmpeg binary for ffmpeg_process transport",
" --mcap\t\tenable MCAP recording",
" --mcap-path <path>\tMCAP output file",
" --mcap-topic <topic>\tMCAP topic name",
" --mcap-depth-topic <topic>\tMCAP depth topic name",
" --mcap-body-topic <topic>\tMCAP body topic name",
" --mcap-frame-id <id>\tFoxglove CompressedVideo frame_id",
" --mcap-compression <mode>\tnone|lz4|zstd",
"",
"Examples:",
" cvmmap_streamer --help",
" cvmmap_streamer --run-mode pipeline --input-uri cvmmap://default --help",
" rtp_receiver_tester --help"};
constexpr auto kHelpLines = std::to_array<std::string_view>({
"Usage:",
" --help, -h\tshow this message",
"",
"Options:",
" --version\tprint version information",
" --config <path>\tload runtime config from TOML",
" --input-uri <uri>\tcvmmap source URI (example: cvmmap://default)",
" --run-mode <mode>\tpipeline|ingest",
" --codec <codec>\th264|h265",
" --encoder-backend <backend>\tauto|ffmpeg",
" --encoder-device <device>\tauto|nvidia|software",
" --gop <frames>\tencoder GOP length",
" --b-frames <count>\tencoder B-frame count",
" --keep-stream-on-reset <bool>\tkeep RTMP/RTP sessions alive across upstream stream_reset events",
" --rtp\t\tenable RTP output",
" --rtp-endpoint <host:port>\tRTP destination",
" --rtp-payload-type <pt>\tRTP payload type (96-127)",
" --rtp-sdp <path>\twrite optional SDP sidecar",
" --rtmp\t\tenable RTMP output",
" --rtmp-url <url>\tadd RTMP destination (repeatable)",
" --rtmp-transport <mode>\tlibavformat|ffmpeg_process",
" --rtmp-ffmpeg <path>\tffmpeg binary for ffmpeg_process transport",
#if CVMMAP_STREAMER_HAS_MCAP
" --mcap\t\tenable MCAP recording",
" --mcap-path <path>\tMCAP output file",
" --mcap-topic <topic>\tMCAP topic name",
" --mcap-body-topic <topic>\tMCAP body topic name",
" --mcap-frame-id <id>\tFoxglove CompressedVideo frame_id",
" --mcap-compression <mode>\tnone|lz4|zstd",
#if CVMMAP_STREAMER_HAS_MCAP_DEPTH
" --mcap-depth\t\tenable MCAP depth recording",
" --mcap-depth-topic <topic>\tMCAP depth topic name (implies --mcap)",
" record.mcap.depth_enabled\tTOML toggle for optional depth recording",
#endif
#endif
"",
"Examples:",
" cvmmap_streamer --help",
" cvmmap_streamer --run-mode pipeline --input-uri cvmmap://default --help",
" rtp_receiver_tester --help"
});
}
+639 -98
View File
@@ -1,15 +1,27 @@
#include "cvmmap_streamer/config/runtime_config.hpp"
#include "cvmmap_streamer/core/frame_source.hpp"
#include "cvmmap_streamer/core/status.hpp"
#include "cvmmap_streamer/encode/encoder_backend.hpp"
#include "cvmmap_streamer/ipc/contracts.hpp"
#include "cvmmap_streamer/metrics/latency_tracker.hpp"
#include "cvmmap_streamer/protocol/nats_request_reply_server.hpp"
#include "cvmmap_streamer/protocol/rtmp_output.hpp"
#include "cvmmap_streamer/protocol/rtp_publisher.hpp"
#include "cvmmap_streamer/protocol/streamer_subjects.hpp"
#include "cvmmap_streamer/record/mcap_record_sink.hpp"
#include "cvmmap_streamer/record/mp4_record_writer.hpp"
#include "proto/cvmmap_streamer/recorder_control.pb.h"
#ifndef CVMMAP_STREAMER_HAS_MCAP
#define CVMMAP_STREAMER_HAS_MCAP 0
#endif
#ifndef CVMMAP_STREAMER_HAS_MCAP_DEPTH
#define CVMMAP_STREAMER_HAS_MCAP_DEPTH 0
#endif
#include <cvmmap/client.hpp>
#include <cvmmap/nats_client.hpp>
#include <cvmmap/nats_service.hpp>
#include <cvmmap/parser.hpp>
#include <chrono>
@@ -19,6 +31,7 @@
#include <deque>
#include <exception>
#include <expected>
#include <filesystem>
#include <mutex>
#include <optional>
#include <span>
@@ -46,6 +59,7 @@ namespace cvmmap_streamer::core {
namespace {
namespace ipc = cvmmap_streamer::ipc;
namespace recorder_pb = cvmmap_streamer::proto;
enum class PipelineExitCode : int {
Success = 0,
@@ -418,23 +432,63 @@ std::uint64_t body_tracking_timestamp_ns(const cvmmap::body_tracking_frame_t &fr
return frame.header.sdk_timestamp_ns;
}
[[nodiscard]]
float stream_fps(const encode::EncodedStreamInfo &stream_info) {
if (stream_info.frame_rate_num == 0 || stream_info.frame_rate_den == 0) {
return 30.0f;
}
return static_cast<float>(stream_info.frame_rate_num) /
static_cast<float>(stream_info.frame_rate_den);
}
template <class Config>
[[nodiscard]] bool runtime_mcap_depth_enabled(const Config &config) {
if constexpr (requires { config.record.mcap.depth_enabled; }) {
return config.record.mcap.depth_enabled;
} else {
return true;
}
}
[[nodiscard]] constexpr bool has_mcap_support() {
return CVMMAP_STREAMER_HAS_MCAP != 0;
}
[[nodiscard]] constexpr bool has_mcap_depth_support() {
return CVMMAP_STREAMER_HAS_MCAP_DEPTH != 0;
}
[[nodiscard]] std::string mcap_disabled_message() {
return "MCAP recording support is not compiled into this build";
}
[[nodiscard]] std::string mcap_depth_disabled_message() {
return "MCAP depth recording support is not compiled into this build";
}
#if CVMMAP_STREAMER_HAS_MCAP
struct McapRecorderState {
mutable std::mutex mutex{};
RuntimeConfig base_config{};
std::optional<RuntimeConfig> active_record_config{};
std::optional<encode::EncodedStreamInfo> current_stream_info{};
std::optional<record::McapRecordSink> sink{};
cvmmap::RecordingStatus status{
.format = cvmmap::RecordingFormat::Mcap,
.can_record = true,
};
struct Status {
bool can_record{true};
bool is_recording{false};
bool last_frame_ok{false};
std::uint32_t frames_ingested{0};
std::uint32_t frames_encoded{0};
std::string active_path{};
std::string error_message{};
} status{};
};
[[nodiscard]]
cvmmap::ControlError make_recording_control_error(
const int32_t code,
protocol::RpcError make_recorder_rpc_error(
const protocol::RpcErrorCode code,
std::string message) {
return cvmmap::ControlError{
return protocol::RpcError{
.code = code,
.message = std::move(message),
};
@@ -443,60 +497,90 @@ cvmmap::ControlError make_recording_control_error(
[[nodiscard]]
RuntimeConfig make_mcap_record_config(
const RuntimeConfig &base_config,
const cvmmap::RecordingRequest &request) {
const recorder_pb::McapStartRequest &request) {
auto record_config = base_config;
record_config.record.mcap.enabled = true;
record_config.record.mcap.path = request.output_path;
if (request.mcap_options) {
if (request.mcap_options->topic) {
record_config.record.mcap.topic = *request.mcap_options->topic;
}
if (request.mcap_options->depth_topic) {
record_config.record.mcap.depth_topic = *request.mcap_options->depth_topic;
}
if (request.mcap_options->body_topic) {
record_config.record.mcap.body_topic = *request.mcap_options->body_topic;
}
if (request.mcap_options->frame_id) {
record_config.record.mcap.frame_id = *request.mcap_options->frame_id;
}
record_config.record.mcap.path = request.output_path();
if (request.has_topic()) {
record_config.record.mcap.topic = request.topic();
}
if (request.has_depth_topic()) {
record_config.record.mcap.depth_topic = request.depth_topic();
}
if (request.has_body_topic()) {
record_config.record.mcap.body_topic = request.body_topic();
}
if (request.has_frame_id()) {
record_config.record.mcap.frame_id = request.frame_id();
}
return record_config;
}
void reset_mcap_status_after_stop(cvmmap::RecordingStatus &status) {
void reset_mcap_status_after_stop(McapRecorderState::Status &status) {
status.is_recording = false;
status.is_paused = false;
status.active_path.clear();
}
[[nodiscard]]
std::expected<cvmmap::RecordingStatus, cvmmap::ControlError> start_mcap_recording(
recorder_pb::McapRecorderState to_proto_mcap_state(
const McapRecorderState::Status &status) {
recorder_pb::McapRecorderState wire_status;
wire_status.set_can_record(status.can_record);
wire_status.set_is_recording(status.is_recording);
wire_status.set_last_frame_ok(status.last_frame_ok);
wire_status.set_frames_ingested(status.frames_ingested);
wire_status.set_frames_encoded(status.frames_encoded);
wire_status.set_active_path(status.active_path);
wire_status.set_error_message(status.error_message);
return wire_status;
}
template <class Response>
[[nodiscard]]
Response make_ok_mcap_response(const McapRecorderState::Status &status) {
Response response;
response.set_code(recorder_pb::RPC_CODE_OK);
*response.mutable_state() = to_proto_mcap_state(status);
return response;
}
[[nodiscard]]
std::expected<McapRecorderState::Status, protocol::RpcError> start_mcap_recording(
McapRecorderState &recorder_state,
const cvmmap::RecordingRequest &request) {
const recorder_pb::McapStartRequest &request) {
std::lock_guard lock(recorder_state.mutex);
if (request.format != cvmmap::RecordingFormat::Mcap) {
return std::unexpected(make_recording_control_error(
cvmmap::CONTROL_RESPONSE_UNSUPPORTED,
"recording format is not supported by the streamer"));
}
if (!recorder_state.current_stream_info) {
return std::unexpected(make_recording_control_error(
cvmmap::CONTROL_RESPONSE_ERROR,
return std::unexpected(make_recorder_rpc_error(
protocol::RpcErrorCode::Internal,
"MCAP recorder is not ready; stream info unavailable"));
}
if (recorder_state.sink) {
return std::unexpected(make_recording_control_error(
cvmmap::CONTROL_RESPONSE_ERROR,
return std::unexpected(make_recorder_rpc_error(
protocol::RpcErrorCode::Busy,
"MCAP recording is already active"));
}
if (request.output_path().empty()) {
return std::unexpected(make_recorder_rpc_error(
protocol::RpcErrorCode::InvalidRequest,
"output_path must not be empty"));
}
const auto output_path = std::filesystem::path(request.output_path());
std::error_code mkdir_error{};
if (output_path.has_parent_path()) {
std::filesystem::create_directories(output_path.parent_path(), mkdir_error);
if (mkdir_error) {
return std::unexpected(make_recorder_rpc_error(
protocol::RpcErrorCode::Internal,
"failed to create MCAP output directory: " + mkdir_error.message()));
}
}
auto record_config = make_mcap_record_config(recorder_state.base_config, request);
if (request.mcap_options && request.mcap_options->compression) {
auto parsed = parse_mcap_compression(*request.mcap_options->compression);
if (request.has_compression()) {
auto parsed = parse_mcap_compression(request.compression());
if (!parsed) {
return std::unexpected(make_recording_control_error(
cvmmap::CONTROL_RESPONSE_INVALID_PAYLOAD,
return std::unexpected(make_recorder_rpc_error(
protocol::RpcErrorCode::InvalidRequest,
parsed.error()));
}
record_config.record.mcap.compression = *parsed;
@@ -504,8 +588,8 @@ std::expected<cvmmap::RecordingStatus, cvmmap::ControlError> start_mcap_recordin
auto created = record::McapRecordSink::create(record_config, *recorder_state.current_stream_info);
if (!created) {
return std::unexpected(make_recording_control_error(
cvmmap::CONTROL_RESPONSE_ERROR,
return std::unexpected(make_recorder_rpc_error(
protocol::RpcErrorCode::Internal,
"pipeline MCAP sink init failed: " + created.error()));
}
@@ -513,35 +597,394 @@ std::expected<cvmmap::RecordingStatus, cvmmap::ControlError> start_mcap_recordin
recorder_state.sink.emplace(std::move(*created));
recorder_state.status.can_record = true;
recorder_state.status.is_recording = true;
recorder_state.status.is_paused = false;
recorder_state.status.last_frame_ok = true;
recorder_state.status.frames_ingested = 0;
recorder_state.status.frames_encoded = 0;
recorder_state.status.active_path = request.output_path;
recorder_state.status.active_path = request.output_path();
recorder_state.status.error_message.clear();
return recorder_state.status;
}
[[nodiscard]]
std::expected<cvmmap::RecordingStatus, cvmmap::ControlError> stop_mcap_recording(
McapRecorderState &recorder_state) {
std::expected<McapRecorderState::Status, protocol::RpcError> stop_mcap_recording(
McapRecorderState &recorder_state,
const recorder_pb::McapStopRequest &) {
std::lock_guard lock(recorder_state.mutex);
if (recorder_state.sink) {
recorder_state.sink->close();
recorder_state.sink.reset();
}
recorder_state.active_record_config.reset();
recorder_state.status.last_frame_ok = true;
recorder_state.status.error_message.clear();
reset_mcap_status_after_stop(recorder_state.status);
return recorder_state.status;
}
[[nodiscard]]
std::expected<cvmmap::RecordingStatus, cvmmap::ControlError> get_mcap_recording_status(
McapRecorderState &recorder_state) {
std::expected<McapRecorderState::Status, protocol::RpcError> get_mcap_recording_status(
McapRecorderState &recorder_state,
const recorder_pb::McapStatusRequest &) {
std::lock_guard lock(recorder_state.mutex);
recorder_state.status.can_record = true;
return recorder_state.status;
}
#else
struct McapRecorderState {
struct Status {};
};
[[nodiscard]]
protocol::RpcError make_mcap_unsupported_rpc_error() {
return protocol::RpcError{
.code = protocol::RpcErrorCode::Unsupported,
.message = mcap_disabled_message(),
};
}
[[nodiscard]]
std::expected<McapRecorderState::Status, protocol::RpcError> start_mcap_recording(
McapRecorderState &,
const recorder_pb::McapStartRequest &) {
return std::unexpected(make_mcap_unsupported_rpc_error());
}
[[nodiscard]]
std::expected<McapRecorderState::Status, protocol::RpcError> stop_mcap_recording(
McapRecorderState &,
const recorder_pb::McapStopRequest &) {
return std::unexpected(make_mcap_unsupported_rpc_error());
}
[[nodiscard]]
std::expected<McapRecorderState::Status, protocol::RpcError> get_mcap_recording_status(
McapRecorderState &,
const recorder_pb::McapStatusRequest &) {
return std::unexpected(make_mcap_unsupported_rpc_error());
}
void update_mcap_stream_info(
McapRecorderState &,
const encode::EncodedStreamInfo &) {}
Status write_mcap_access_unit(
McapRecorderState *,
const encode::EncodedAccessUnit &) {
return unexpected_error(ERR_UNSUPPORTED, mcap_disabled_message());
}
Status write_mcap_body_message(
McapRecorderState *,
const record::RawBodyTrackingMessageView &) {
return unexpected_error(ERR_UNSUPPORTED, mcap_disabled_message());
}
Status write_mcap_depth_map(
McapRecorderState *,
const record::RawDepthMapView &) {
return unexpected_error(ERR_UNSUPPORTED, mcap_depth_disabled_message());
}
#endif
struct Mp4RecorderStatus {
bool can_record{false};
bool is_recording{false};
bool last_frame_ok{false};
std::uint32_t frames_ingested{0};
std::uint32_t frames_encoded{0};
std::string active_path{};
std::string error_message{};
};
struct Mp4RecorderState {
mutable std::mutex mutex{};
RuntimeConfig base_config{};
std::optional<ipc::FrameInfo> current_frame_info{};
std::optional<record::Mp4InputPixelFormat> current_input_pixel_format{};
float current_fps{30.0f};
std::optional<record::Mp4RecordWriter> writer{};
std::optional<std::uint64_t> first_frame_timestamp_ns{};
Mp4RecorderStatus status{};
};
[[nodiscard]]
record::Mp4EncodeTuning make_mp4_encode_tuning(const RuntimeConfig &base_config) {
return record::Mp4EncodeTuning{
.quality = record::kDefaultMp4Quality,
.gop = base_config.encoder.gop,
.b_frames = base_config.encoder.b_frames,
};
}
[[nodiscard]]
std::expected<record::Mp4InputPixelFormat, std::string> mp4_input_pixel_format(
const ipc::FrameInfo &frame_info) {
if (frame_info.depth != ipc::Depth::U8) {
return std::unexpected("MP4 recorder requires 8-bit color frames");
}
switch (frame_info.pixel_format) {
case ipc::PixelFormat::BGR:
return record::Mp4InputPixelFormat::Bgr24;
case ipc::PixelFormat::RGB:
return record::Mp4InputPixelFormat::Rgb24;
case ipc::PixelFormat::BGRA:
return record::Mp4InputPixelFormat::Bgra32;
case ipc::PixelFormat::RGBA:
return record::Mp4InputPixelFormat::Rgba32;
case ipc::PixelFormat::GRAY:
return record::Mp4InputPixelFormat::Gray8;
case ipc::PixelFormat::YUV:
case ipc::PixelFormat::YUYV:
return std::unexpected("MP4 recorder does not support packed YUV snapshot frames");
}
return std::unexpected("MP4 recorder does not support the snapshot pixel format");
}
void reset_mp4_status_after_stop(Mp4RecorderStatus &status) {
status.is_recording = false;
status.active_path.clear();
}
void close_mp4_writer_with_error(
Mp4RecorderState &recorder_state,
const std::string &message) {
spdlog::error("pipeline MP4 recorder stopping after error: {}", message);
if (recorder_state.writer) {
auto flush = recorder_state.writer->flush();
if (!flush) {
spdlog::warn("pipeline MP4 flush failed while closing recorder: {}", flush.error());
}
recorder_state.writer.reset();
}
recorder_state.first_frame_timestamp_ns.reset();
recorder_state.status.last_frame_ok = false;
recorder_state.status.error_message = message;
reset_mp4_status_after_stop(recorder_state.status);
}
[[nodiscard]]
protocol::RpcError make_mp4_rpc_error(
const protocol::RpcErrorCode code,
std::string message) {
return protocol::RpcError{
.code = code,
.message = std::move(message),
};
}
[[nodiscard]]
recorder_pb::Mp4RecorderState to_proto_mp4_state(const Mp4RecorderStatus &status) {
recorder_pb::Mp4RecorderState wire_status;
wire_status.set_can_record(status.can_record);
wire_status.set_is_recording(status.is_recording);
wire_status.set_last_frame_ok(status.last_frame_ok);
wire_status.set_frames_ingested(status.frames_ingested);
wire_status.set_frames_encoded(status.frames_encoded);
wire_status.set_active_path(status.active_path);
wire_status.set_error_message(status.error_message);
return wire_status;
}
template <class Response>
[[nodiscard]]
Response make_ok_mp4_response(const Mp4RecorderStatus &status) {
Response response;
response.set_code(recorder_pb::RPC_CODE_OK);
*response.mutable_state() = to_proto_mp4_state(status);
return response;
}
[[nodiscard]]
std::expected<Mp4RecorderStatus, protocol::RpcError> start_mp4_recording(
Mp4RecorderState &recorder_state,
const recorder_pb::Mp4StartRequest &request) {
std::lock_guard lock(recorder_state.mutex);
if (!recorder_state.current_frame_info) {
return std::unexpected(make_mp4_rpc_error(
protocol::RpcErrorCode::Internal,
"MP4 recorder is not ready; snapshot frame info unavailable"));
}
if (!recorder_state.current_input_pixel_format) {
return std::unexpected(make_mp4_rpc_error(
protocol::RpcErrorCode::Unsupported,
recorder_state.status.error_message.empty()
? "MP4 recorder does not support the current snapshot format"
: recorder_state.status.error_message));
}
if (recorder_state.writer) {
return std::unexpected(make_mp4_rpc_error(
protocol::RpcErrorCode::Busy,
"MP4 recording is already active"));
}
const auto output_path = std::filesystem::path(request.output_path());
if (output_path.empty()) {
return std::unexpected(make_mp4_rpc_error(
protocol::RpcErrorCode::InvalidRequest,
"output_path must not be empty"));
}
std::error_code mkdir_error{};
if (output_path.has_parent_path()) {
std::filesystem::create_directories(output_path.parent_path(), mkdir_error);
if (mkdir_error) {
return std::unexpected(make_mp4_rpc_error(
protocol::RpcErrorCode::Internal,
"failed to create MP4 output directory: " + mkdir_error.message()));
}
}
record::Mp4RecordWriter writer{};
auto open = writer.open(
output_path,
recorder_state.base_config.encoder.codec,
recorder_state.base_config.encoder.device,
recorder_state.current_frame_info->width,
recorder_state.current_frame_info->height,
recorder_state.current_fps,
make_mp4_encode_tuning(recorder_state.base_config),
*recorder_state.current_input_pixel_format);
if (!open) {
return std::unexpected(make_mp4_rpc_error(
protocol::RpcErrorCode::Internal,
"pipeline MP4 writer init failed: " + open.error()));
}
recorder_state.writer.emplace(std::move(writer));
recorder_state.first_frame_timestamp_ns.reset();
recorder_state.status.can_record = true;
recorder_state.status.is_recording = true;
recorder_state.status.last_frame_ok = true;
recorder_state.status.frames_ingested = 0;
recorder_state.status.frames_encoded = 0;
recorder_state.status.active_path = request.output_path();
recorder_state.status.error_message.clear();
return recorder_state.status;
}
[[nodiscard]]
std::expected<Mp4RecorderStatus, protocol::RpcError> stop_mp4_recording(
Mp4RecorderState &recorder_state,
const recorder_pb::Mp4StopRequest &) {
std::lock_guard lock(recorder_state.mutex);
if (recorder_state.writer) {
auto flush = recorder_state.writer->flush();
recorder_state.writer.reset();
recorder_state.first_frame_timestamp_ns.reset();
if (!flush) {
recorder_state.status.last_frame_ok = false;
recorder_state.status.error_message = "MP4 recording flush failed: " + flush.error();
reset_mp4_status_after_stop(recorder_state.status);
return std::unexpected(make_mp4_rpc_error(
protocol::RpcErrorCode::Internal,
recorder_state.status.error_message));
}
}
recorder_state.status.last_frame_ok = true;
recorder_state.status.error_message.clear();
reset_mp4_status_after_stop(recorder_state.status);
return recorder_state.status;
}
[[nodiscard]]
std::expected<Mp4RecorderStatus, protocol::RpcError> get_mp4_recording_status(
Mp4RecorderState &recorder_state,
const recorder_pb::Mp4StatusRequest &) {
std::lock_guard lock(recorder_state.mutex);
recorder_state.status.can_record =
recorder_state.current_frame_info.has_value() &&
recorder_state.current_input_pixel_format.has_value();
return recorder_state.status;
}
void update_mp4_source_info(
Mp4RecorderState &recorder_state,
const ipc::FrameInfo &frame_info,
const float fps) {
std::lock_guard lock(recorder_state.mutex);
const auto previous_frame_info = recorder_state.current_frame_info;
recorder_state.current_frame_info = frame_info;
if (fps > 0.0f) {
recorder_state.current_fps = fps;
}
auto input_pixel_format = mp4_input_pixel_format(frame_info);
if (!input_pixel_format) {
recorder_state.current_input_pixel_format.reset();
recorder_state.status.can_record = false;
recorder_state.status.error_message = input_pixel_format.error();
if (recorder_state.writer) {
close_mp4_writer_with_error(recorder_state, input_pixel_format.error());
}
return;
}
const bool source_changed =
recorder_state.writer.has_value() &&
previous_frame_info.has_value() &&
!frame_info_equal(*previous_frame_info, frame_info);
recorder_state.current_input_pixel_format = *input_pixel_format;
recorder_state.status.can_record = true;
recorder_state.status.error_message.clear();
if (source_changed) {
close_mp4_writer_with_error(
recorder_state,
"MP4 recording stopped after snapshot frame format changed");
return;
}
}
void write_mp4_frame(
Mp4RecorderState *recorder_state,
const ipc::CoherentSnapshot &snapshot) {
if (recorder_state == nullptr) {
return;
}
std::lock_guard lock(recorder_state->mutex);
if (!recorder_state->writer) {
return;
}
if (snapshot.left.empty()) {
close_mp4_writer_with_error(*recorder_state, "MP4 recorder received an empty left frame");
return;
}
if (snapshot.metadata.info.height == 0) {
close_mp4_writer_with_error(*recorder_state, "MP4 recorder received an invalid frame height");
return;
}
const auto row_stride_bytes =
static_cast<std::size_t>(snapshot.metadata.info.buffer_size) /
static_cast<std::size_t>(snapshot.metadata.info.height);
if (row_stride_bytes == 0) {
close_mp4_writer_with_error(*recorder_state, "MP4 recorder computed a zero row stride");
return;
}
if (!recorder_state->first_frame_timestamp_ns) {
recorder_state->first_frame_timestamp_ns = snapshot.metadata.timestamp_ns;
}
const auto relative_timestamp_ns =
snapshot.metadata.timestamp_ns >= *recorder_state->first_frame_timestamp_ns
? snapshot.metadata.timestamp_ns - *recorder_state->first_frame_timestamp_ns
: 0ull;
auto write = recorder_state->writer->write_frame(
snapshot.left.data(),
row_stride_bytes,
relative_timestamp_ns);
if (!write) {
close_mp4_writer_with_error(*recorder_state, "MP4 frame write failed: " + write.error());
spdlog::error("pipeline MP4 frame write failed: {}", write.error());
return;
}
recorder_state->status.last_frame_ok = true;
recorder_state->status.is_recording = true;
recorder_state->status.frames_ingested += 1;
recorder_state->status.frames_encoded += 1;
}
#if CVMMAP_STREAMER_HAS_MCAP
void update_mcap_stream_info(
McapRecorderState &recorder_state,
const encode::EncodedStreamInfo &stream_info) {
@@ -630,6 +1073,7 @@ Status write_mcap_depth_map(
recorder_state->status.last_frame_ok = true;
return {};
}
#endif
[[nodiscard]]
Status publish_access_units(
@@ -775,31 +1219,32 @@ int run_pipeline(const RuntimeConfig &config) {
std::optional<protocol::UdpRtpPublisher> rtp_publisher{};
std::optional<protocol::RtmpOutput> rtmp_output{};
#if CVMMAP_STREAMER_HAS_MCAP
McapRecorderState mcap_recorder{};
mcap_recorder.base_config = config;
mcap_recorder.status.can_record = true;
#endif
Mp4RecorderState mp4_recorder{};
mp4_recorder.base_config = config;
cvmmap::NatsControlClient nats_client(
input_endpoints->nats_target_key,
config.input.nats_url);
cvmmap::NatsControlService recorder_service(
cvmmap::NatsControlServiceOptions{
.instance_name = input_endpoints->instance_name,
.namespace_name = input_endpoints->namespace_name,
.ipc_prefix = input_endpoints->ipc_prefix,
.base_name = input_endpoints->base_name,
.target_key = input_endpoints->nats_target_key,
.shm_name = input_endpoints->shm_name,
.zmq_addr = input_endpoints->zmq_endpoint,
.backend = std::string((*source)->backend_name()),
.nats_url = config.input.nats_url,
});
protocol::NatsRequestReplyServer recorder_rpc_server({
.nats_url = config.input.nats_url,
.instance_name = input_endpoints->instance_name,
.namespace_name = input_endpoints->namespace_name,
.ipc_prefix = input_endpoints->ipc_prefix,
.base_name = input_endpoints->base_name,
.nats_target_key = input_endpoints->nats_target_key,
.recording_formats = has_mcap_support() ? "mp4,mcap" : "mp4",
});
std::mutex nats_event_mutex{};
std::deque<std::vector<std::uint8_t>> pending_body_packets{};
std::deque<int32_t> pending_status_codes{};
nats_client.SetModuleStatusCallback([&nats_event_mutex, &pending_status_codes](int32_t status_code) {
nats_client.SetModuleStatusCallback([&nats_event_mutex, &pending_status_codes](cvmmap::ModuleStatus status_code) {
std::lock_guard lock(nats_event_mutex);
pending_status_codes.push_back(status_code);
pending_status_codes.push_back(static_cast<int32_t>(status_code));
});
nats_client.SetBodyTrackingRawCallback(
[&nats_event_mutex, &pending_body_packets](std::span<const std::uint8_t> bytes) {
@@ -811,38 +1256,88 @@ int run_pipeline(const RuntimeConfig &config) {
return exit_code(PipelineExitCode::SubscriberError);
}
cvmmap::NatsControlHandlers recorder_handlers{};
recorder_handlers.on_recording_available =
[](const cvmmap::RecordingFormat format) {
return format == cvmmap::RecordingFormat::Mcap;
};
recorder_handlers.on_start_recording =
[&mcap_recorder](const cvmmap::RecordingRequest &request) {
return start_mcap_recording(mcap_recorder, request);
};
recorder_handlers.on_stop_recording =
[&mcap_recorder](const cvmmap::RecordingFormat format)
-> std::expected<cvmmap::RecordingStatus, cvmmap::ControlError> {
if (format != cvmmap::RecordingFormat::Mcap) {
return std::unexpected(make_recording_control_error(
cvmmap::CONTROL_RESPONSE_UNSUPPORTED,
"recording format is not supported by the streamer"));
}
return stop_mcap_recording(mcap_recorder);
};
recorder_handlers.on_get_recording_status =
[&mcap_recorder](const cvmmap::RecordingFormat format)
-> std::expected<cvmmap::RecordingStatus, cvmmap::ControlError> {
if (format != cvmmap::RecordingFormat::Mcap) {
return std::unexpected(make_recording_control_error(
cvmmap::CONTROL_RESPONSE_UNSUPPORTED,
"recording format is not supported by the streamer"));
}
return get_mcap_recording_status(mcap_recorder);
};
recorder_service.SetHandlers(std::move(recorder_handlers));
if (!recorder_service.Start()) {
spdlog::error("pipeline recorder control service failed on '{}'", config.input.nats_url);
recorder_rpc_server.register_proto_endpoint<
recorder_pb::Mp4StartRequest,
recorder_pb::Mp4StartResponse>(
"recorder_mp4_start",
protocol::subject_recorder_mp4_start(input_endpoints->nats_target_key),
[&mp4_recorder](const recorder_pb::Mp4StartRequest &request)
-> std::expected<recorder_pb::Mp4StartResponse, protocol::RpcError> {
auto status = start_mp4_recording(mp4_recorder, request);
if (!status) {
return std::unexpected(status.error());
}
return make_ok_mp4_response<recorder_pb::Mp4StartResponse>(*status);
});
recorder_rpc_server.register_proto_endpoint<
recorder_pb::Mp4StopRequest,
recorder_pb::Mp4StopResponse>(
"recorder_mp4_stop",
protocol::subject_recorder_mp4_stop(input_endpoints->nats_target_key),
[&mp4_recorder](const recorder_pb::Mp4StopRequest &request)
-> std::expected<recorder_pb::Mp4StopResponse, protocol::RpcError> {
auto status = stop_mp4_recording(mp4_recorder, request);
if (!status) {
return std::unexpected(status.error());
}
return make_ok_mp4_response<recorder_pb::Mp4StopResponse>(*status);
});
recorder_rpc_server.register_proto_endpoint<
recorder_pb::Mp4StatusRequest,
recorder_pb::Mp4StatusResponse>(
"recorder_mp4_status",
protocol::subject_recorder_mp4_status(input_endpoints->nats_target_key),
[&mp4_recorder](const recorder_pb::Mp4StatusRequest &request)
-> std::expected<recorder_pb::Mp4StatusResponse, protocol::RpcError> {
auto status = get_mp4_recording_status(mp4_recorder, request);
if (!status) {
return std::unexpected(status.error());
}
return make_ok_mp4_response<recorder_pb::Mp4StatusResponse>(*status);
});
#if CVMMAP_STREAMER_HAS_MCAP
recorder_rpc_server.register_proto_endpoint<
recorder_pb::McapStartRequest,
recorder_pb::McapStartResponse>(
"recorder_mcap_start",
protocol::subject_recorder_mcap_start(input_endpoints->nats_target_key),
[&mcap_recorder](const recorder_pb::McapStartRequest &request)
-> std::expected<recorder_pb::McapStartResponse, protocol::RpcError> {
auto status = start_mcap_recording(mcap_recorder, request);
if (!status) {
return std::unexpected(status.error());
}
return make_ok_mcap_response<recorder_pb::McapStartResponse>(*status);
});
recorder_rpc_server.register_proto_endpoint<
recorder_pb::McapStopRequest,
recorder_pb::McapStopResponse>(
"recorder_mcap_stop",
protocol::subject_recorder_mcap_stop(input_endpoints->nats_target_key),
[&mcap_recorder](const recorder_pb::McapStopRequest &request)
-> std::expected<recorder_pb::McapStopResponse, protocol::RpcError> {
auto status = stop_mcap_recording(mcap_recorder, request);
if (!status) {
return std::unexpected(status.error());
}
return make_ok_mcap_response<recorder_pb::McapStopResponse>(*status);
});
recorder_rpc_server.register_proto_endpoint<
recorder_pb::McapStatusRequest,
recorder_pb::McapStatusResponse>(
"recorder_mcap_status",
protocol::subject_recorder_mcap_status(input_endpoints->nats_target_key),
[&mcap_recorder](const recorder_pb::McapStatusRequest &request)
-> std::expected<recorder_pb::McapStatusResponse, protocol::RpcError> {
auto status = get_mcap_recording_status(mcap_recorder, request);
if (!status) {
return std::unexpected(status.error());
}
return make_ok_mcap_response<recorder_pb::McapStatusResponse>(*status);
});
#endif
if (!recorder_rpc_server.start()) {
spdlog::error("pipeline streamer recorder service failed on '{}'", config.input.nats_url);
nats_client.Stop();
return exit_code(PipelineExitCode::SubscriberError);
}
@@ -950,6 +1445,7 @@ int run_pipeline(const RuntimeConfig &config) {
live_output_continuity.note_new_session(stream_info);
}
#if CVMMAP_STREAMER_HAS_MCAP
update_mcap_stream_info(mcap_recorder, stream_info);
if (config.record.mcap.enabled) {
std::lock_guard lock(mcap_recorder.mutex);
@@ -962,13 +1458,18 @@ int run_pipeline(const RuntimeConfig &config) {
}
mcap_recorder.active_record_config = config;
mcap_recorder.sink.emplace(std::move(*created));
mcap_recorder.status.format = cvmmap::RecordingFormat::Mcap;
mcap_recorder.status.can_record = true;
mcap_recorder.status.is_recording = true;
mcap_recorder.status.last_frame_ok = true;
mcap_recorder.status.active_path = config.record.mcap.path;
}
}
#else
if (config.record.mcap.enabled) {
return unexpected_error(ERR_UNSUPPORTED, mcap_disabled_message());
}
#endif
update_mp4_source_info(mp4_recorder, target_info, stream_fps(stream_info));
started = true;
restart_pending = false;
restart_target_info.reset();
@@ -1083,6 +1584,7 @@ int run_pipeline(const RuntimeConfig &config) {
continue;
}
#if CVMMAP_STREAMER_HAS_MCAP
auto write_body = write_mcap_body_message(&mcap_recorder, record::RawBodyTrackingMessageView{
.timestamp_ns = body_tracking_timestamp_ns(*parsed_body),
.bytes = body_bytes,
@@ -1092,6 +1594,7 @@ int run_pipeline(const RuntimeConfig &config) {
restart_backend(reason, active_info);
break;
}
#endif
}
if (backend && !using_encoded_input) {
@@ -1134,7 +1637,11 @@ int run_pipeline(const RuntimeConfig &config) {
stats,
rtp_publisher ? &*rtp_publisher : nullptr,
rtmp_output ? &*rtmp_output : nullptr,
#if CVMMAP_STREAMER_HAS_MCAP
&mcap_recorder,
#else
nullptr,
#endif
keep_live_outputs_on_reset ? &live_output_continuity : nullptr,
latency_tracker);
if (!drain) {
@@ -1187,6 +1694,10 @@ int run_pipeline(const RuntimeConfig &config) {
const bool want_encoded_input =
config.input.video_source == InputVideoSource::Encoded ||
(config.input.video_source == InputVideoSource::Auto && has_encoded_access_unit);
update_mp4_source_info(
mp4_recorder,
snapshot->metadata.info,
active_stream_info ? stream_fps(*active_stream_info) : 30.0f);
if (config.input.video_source == InputVideoSource::Encoded && !has_encoded_access_unit) {
spdlog::error("pipeline encoded input requested but SHM snapshot does not contain an encoded access unit");
return exit_code(PipelineExitCode::InitializationError);
@@ -1229,6 +1740,7 @@ int run_pipeline(const RuntimeConfig &config) {
}
latency_tracker.note_ingest();
write_mp4_frame(&mp4_recorder, *snapshot);
if (want_encoded_input) {
auto access_unit = make_access_unit_from_snapshot(*snapshot);
@@ -1253,7 +1765,11 @@ int run_pipeline(const RuntimeConfig &config) {
stats,
rtp_publisher ? &*rtp_publisher : nullptr,
rtmp_output ? &*rtmp_output : nullptr,
#if CVMMAP_STREAMER_HAS_MCAP
&mcap_recorder,
#else
nullptr,
#endif
keep_live_outputs_on_reset ? &live_output_continuity : nullptr,
latency_tracker);
if (!publish) {
@@ -1275,7 +1791,13 @@ int run_pipeline(const RuntimeConfig &config) {
}
}
if (!snapshot->depth.empty()) {
#if CVMMAP_STREAMER_HAS_MCAP
if (!snapshot->depth.empty() && config.record.mcap.enabled && runtime_mcap_depth_enabled(config)) {
#if !CVMMAP_STREAMER_HAS_MCAP_DEPTH
const auto reason = "pipeline depth MCAP write failed: " + mcap_depth_disabled_message();
restart_backend(reason, active_info);
continue;
#else
if (snapshot->depth_unit == ipc::DepthUnit::Unknown) {
if (!warned_unknown_depth_unit) {
spdlog::warn(
@@ -1297,7 +1819,9 @@ int run_pipeline(const RuntimeConfig &config) {
restart_backend(reason, active_info);
continue;
}
#endif
}
#endif
stats.pushed_frames += 1;
if (!want_encoded_input) {
@@ -1308,7 +1832,11 @@ int run_pipeline(const RuntimeConfig &config) {
stats,
rtp_publisher ? &*rtp_publisher : nullptr,
rtmp_output ? &*rtmp_output : nullptr,
#if CVMMAP_STREAMER_HAS_MCAP
&mcap_recorder,
#else
nullptr,
#endif
keep_live_outputs_on_reset ? &live_output_continuity : nullptr,
latency_tracker);
if (!drain) {
@@ -1338,7 +1866,11 @@ int run_pipeline(const RuntimeConfig &config) {
stats,
rtp_publisher ? &*rtp_publisher : nullptr,
rtmp_output ? &*rtmp_output : nullptr,
#if CVMMAP_STREAMER_HAS_MCAP
&mcap_recorder,
#else
nullptr,
#endif
keep_live_outputs_on_reset ? &live_output_continuity : nullptr,
latency_tracker);
if (!drain) {
@@ -1350,8 +1882,17 @@ int run_pipeline(const RuntimeConfig &config) {
if (backend) {
(*backend)->shutdown();
}
std::ignore = stop_mcap_recording(mcap_recorder);
recorder_service.Stop();
auto stop_mp4 = stop_mp4_recording(mp4_recorder, recorder_pb::Mp4StopRequest{});
if (!stop_mp4) {
spdlog::warn("pipeline MP4 recorder stop during shutdown failed: {}", stop_mp4.error().message);
}
#if CVMMAP_STREAMER_HAS_MCAP
auto stop_mcap = stop_mcap_recording(mcap_recorder, recorder_pb::McapStopRequest{});
if (!stop_mcap) {
spdlog::warn("pipeline MCAP recorder stop during shutdown failed: {}", stop_mcap.error().message);
}
#endif
recorder_rpc_server.stop();
spdlog::info(
"PIPELINE_METRICS codec={} backend={} sync_messages={} status_messages={} torn_frames={} pushed_frames={} encoded_access_units={} resets={} format_rebuilds={} supervised_restarts={}",
+263
View File
@@ -0,0 +1,263 @@
#include "cvmmap_streamer/protocol/nats_request_reply_server.hpp"
#include "cvmmap_streamer/protocol/streamer_subjects.hpp"
#include <nats/nats.h>
#include <spdlog/spdlog.h>
#include <string_view>
#include <utility>
#include <vector>
namespace cvmmap_streamer::protocol {
namespace {
constexpr std::string_view kStreamerServiceName = "cvmmap_streamer";
constexpr std::string_view kStreamerServiceVersion = "0.1.0";
constexpr std::string_view kStreamerServiceDescription =
"cvmmap-streamer recorder service";
[[nodiscard]]
std::string nats_status_text(const natsStatus status) {
const char *text = natsStatus_GetText(status);
return text != nullptr ? std::string(text) : std::string("unknown NATS error");
}
[[nodiscard]]
std::string micro_error_to_string(microError *error) {
if (error == nullptr) {
return {};
}
char buffer[512];
return std::string(microError_String(error, buffer, sizeof(buffer)));
}
} // namespace
struct NatsRequestReplyServer::Endpoint {
std::string name{};
std::string subject{};
std::function<std::string(std::span<const std::uint8_t>)> handler{};
};
struct NatsRequestReplyServer::Impl {
explicit Impl(NatsRequestReplyServerOptions options_arg)
: options(std::move(options_arg)) {}
NatsRequestReplyServerOptions options{};
natsConnection *connection{nullptr};
microService *service{nullptr};
std::vector<std::unique_ptr<Endpoint>> endpoints{};
std::vector<std::string> metadata_storage{};
bool started{false};
void build_metadata_storage() {
metadata_storage.clear();
metadata_storage.reserve(18);
auto append = [this](std::string key, std::string value) {
metadata_storage.push_back(std::move(key));
metadata_storage.push_back(std::move(value));
};
append("instance_name", options.instance_name);
append("namespace", options.namespace_name);
append("ipc_prefix", options.ipc_prefix);
append("base_name", options.base_name);
append("nats_target_key", options.nats_target_key);
append(
"streamer_subject_prefix",
streamer_subject_prefix(options.nats_target_key));
append("backend", options.backend);
append("recording_formats", options.recording_formats);
}
};
NatsRequestReplyServer::NatsRequestReplyServer(NatsRequestReplyServerOptions options)
: impl_(std::make_unique<Impl>(std::move(options))) {}
NatsRequestReplyServer::~NatsRequestReplyServer() {
stop();
}
void NatsRequestReplyServer::register_raw_endpoint(
std::string endpoint_name,
std::string subject,
std::function<std::string(std::span<const std::uint8_t>)> handler) {
auto endpoint = std::make_unique<Endpoint>();
endpoint->name = std::move(endpoint_name);
endpoint->subject = std::move(subject);
endpoint->handler = std::move(handler);
impl_->endpoints.push_back(std::move(endpoint));
}
cvmmap_streamer::proto::RpcCode NatsRequestReplyServer::to_proto_rpc_code(
const RpcErrorCode code) {
switch (code) {
case RpcErrorCode::InvalidRequest:
return cvmmap_streamer::proto::RPC_CODE_INVALID_REQUEST;
case RpcErrorCode::Unsupported:
return cvmmap_streamer::proto::RPC_CODE_UNSUPPORTED;
case RpcErrorCode::Busy:
return cvmmap_streamer::proto::RPC_CODE_BUSY;
case RpcErrorCode::Internal:
return cvmmap_streamer::proto::RPC_CODE_INTERNAL;
}
return cvmmap_streamer::proto::RPC_CODE_INTERNAL;
}
bool NatsRequestReplyServer::start() {
if (impl_->started) {
return true;
}
if (impl_->endpoints.empty()) {
spdlog::error("streamer service start requested without any endpoints");
return false;
}
natsOptions *options = nullptr;
auto status = natsOptions_Create(&options);
if (status != NATS_OK) {
spdlog::error("failed to create NATS options: {}", nats_status_text(status));
return false;
}
status = natsOptions_SetURL(options, impl_->options.nats_url.c_str());
if (status != NATS_OK) {
spdlog::error(
"failed to set NATS url '{}': {}",
impl_->options.nats_url,
nats_status_text(status));
natsOptions_Destroy(options);
return false;
}
status = natsConnection_Connect(&impl_->connection, options);
natsOptions_Destroy(options);
if (status != NATS_OK) {
spdlog::error(
"failed to connect streamer service to '{}': {}",
impl_->options.nats_url,
nats_status_text(status));
return false;
}
impl_->build_metadata_storage();
std::vector<const char *> metadata_list{};
metadata_list.reserve(impl_->metadata_storage.size());
for (const auto &entry : impl_->metadata_storage) {
metadata_list.push_back(entry.c_str());
}
const auto &default_endpoint_ref = impl_->endpoints.front();
microEndpointConfig default_endpoint{};
default_endpoint.Name = default_endpoint_ref->name.c_str();
default_endpoint.Subject = default_endpoint_ref->subject.c_str();
default_endpoint.Handler =
[](microRequest *request) -> microError * {
auto *endpoint = static_cast<Endpoint *>(microRequest_GetEndpointState(request));
if (endpoint == nullptr) {
return micro_Errorf("streamer endpoint state is missing");
}
auto *wire_message = microRequest_GetMsg(request);
std::span<const std::uint8_t> payload{};
if (wire_message != nullptr && natsMsg_GetDataLength(wire_message) > 0) {
payload = std::span<const std::uint8_t>(
reinterpret_cast<const std::uint8_t *>(natsMsg_GetData(wire_message)),
static_cast<std::size_t>(natsMsg_GetDataLength(wire_message)));
}
const auto response_payload = endpoint->handler(payload);
return microRequest_Respond(
request,
response_payload.data(),
response_payload.size());
};
default_endpoint.State = default_endpoint_ref.get();
microServiceConfig service_config{};
service_config.Name = kStreamerServiceName.data();
service_config.Version = kStreamerServiceVersion.data();
service_config.Description = kStreamerServiceDescription.data();
service_config.Metadata = natsMetadata{
.List = metadata_list.data(),
.Count = static_cast<int>(metadata_list.size() / 2),
};
service_config.Endpoint = &default_endpoint;
service_config.State = impl_.get();
service_config.ErrHandler =
[](microService *, microEndpoint *, natsStatus internal_status) {
spdlog::error(
"streamer micro service internal error: {}",
natsStatus_GetText(internal_status));
};
service_config.DoneHandler =
[](microService *service) {
auto *self = static_cast<Impl *>(microService_GetState(service));
if (self == nullptr) {
return;
}
spdlog::info(
"streamer micro service stopped for target '{}'",
self->options.nats_target_key);
};
if (auto *error = micro_AddService(&impl_->service, impl_->connection, &service_config)) {
spdlog::error(
"failed to start streamer micro service '{}': {}",
kStreamerServiceName,
micro_error_to_string(error));
microError_Destroy(error);
natsConnection_Close(impl_->connection);
natsConnection_Destroy(impl_->connection);
impl_->connection = nullptr;
return false;
}
for (std::size_t index = 1; index < impl_->endpoints.size(); ++index) {
const auto &endpoint_ref = impl_->endpoints[index];
microEndpointConfig endpoint_config{};
endpoint_config.Name = endpoint_ref->name.c_str();
endpoint_config.Subject = endpoint_ref->subject.c_str();
endpoint_config.Handler = default_endpoint.Handler;
endpoint_config.State = endpoint_ref.get();
if (auto *error = microService_AddEndpoint(impl_->service, &endpoint_config)) {
spdlog::error(
"failed to add streamer endpoint '{}' on '{}': {}",
endpoint_ref->name,
endpoint_ref->subject,
micro_error_to_string(error));
microError_Destroy(error);
stop();
return false;
}
}
impl_->started = true;
spdlog::info(
"streamer micro service '{}' started for target '{}' with {} endpoints",
kStreamerServiceName,
impl_->options.nats_target_key,
impl_->endpoints.size());
return true;
}
void NatsRequestReplyServer::stop() {
if (impl_->service != nullptr) {
if (auto *error = microService_Destroy(impl_->service)) {
spdlog::error(
"failed to stop streamer micro service '{}': {}",
kStreamerServiceName,
micro_error_to_string(error));
microError_Destroy(error);
}
impl_->service = nullptr;
}
if (impl_->connection != nullptr) {
natsConnection_Close(impl_->connection);
natsConnection_Destroy(impl_->connection);
impl_->connection = nullptr;
}
impl_->started = false;
}
} // namespace cvmmap_streamer::protocol
+12 -2
View File
@@ -230,6 +230,8 @@ public:
std::string url{};
AVFormatContext *format_context{nullptr};
AVStream *video_stream{nullptr};
bool io_opened{false};
bool header_written{false};
};
LibavformatRtmpOutput() = default;
@@ -368,6 +370,7 @@ private:
ERR_NETWORK,
"failed to open RTMP output '" + url + "': " + av_error_string(open_result));
}
session.io_opened = true;
}
// RTMP sockets are non-seekable, so the FLV muxer must not try to backfill
@@ -388,6 +391,7 @@ private:
ERR_PROTOCOL,
"failed to write RTMP header for '" + url + "': " + av_error_string(header_result));
}
session.header_written = true;
spdlog::info(
"RTMP_OUTPUT_READY backend=libavformat codec={} url={}",
@@ -401,9 +405,15 @@ private:
return;
}
av_write_trailer(session.format_context);
if (!(session.format_context->oformat->flags & AVFMT_NOFILE) && session.format_context->pb != nullptr) {
if (session.header_written) {
av_write_trailer(session.format_context);
session.header_written = false;
}
if (session.io_opened &&
!(session.format_context->oformat->flags & AVFMT_NOFILE) &&
session.format_context->pb != nullptr) {
avio_closep(&session.format_context->pb);
session.io_opened = false;
}
avformat_free_context(session.format_context);
session.format_context = nullptr;
+197 -34
View File
@@ -1,16 +1,28 @@
#include <mcap/writer.hpp>
#include "cvmmap_streamer/record/mcap_record_sink.hpp"
#ifndef CVMMAP_STREAMER_HAS_MCAP
#define CVMMAP_STREAMER_HAS_MCAP 0
#endif
#ifndef CVMMAP_STREAMER_HAS_MCAP_DEPTH
#define CVMMAP_STREAMER_HAS_MCAP_DEPTH 0
#endif
#if CVMMAP_STREAMER_HAS_MCAP
#include <mcap/writer.hpp>
#include "protobuf_descriptor.hpp"
#include "proto/cvmmap_streamer/BundleManifest.pb.h"
#include "proto/cvmmap_streamer/DepthMap.pb.h"
#include "proto/foxglove/CameraCalibration.pb.h"
#include "proto/foxglove/CompressedVideo.pb.h"
#include "proto/foxglove/PoseInFrame.pb.h"
#if CVMMAP_STREAMER_HAS_MCAP_DEPTH
#include <rvl/rvl.hpp>
#endif
#include <google/protobuf/timestamp.pb.h>
#endif
#include <cmath>
#include <cstddef>
@@ -26,10 +38,23 @@
namespace cvmmap_streamer::record {
template <class Config>
[[nodiscard]] bool mcap_depth_requested(const Config &config) {
if constexpr (requires { config.record.mcap.depth_enabled; }) {
return config.record.mcap.depth_enabled;
} else {
return true;
}
}
#if CVMMAP_STREAMER_HAS_MCAP
namespace {
#if CVMMAP_STREAMER_HAS_MCAP_DEPTH
constexpr float kRvlDepthQuantization = 200.0f;
constexpr float kMinDepthMaxMeters = 20.0f;
#endif
constexpr std::string_view kBodyTrackingMessageEncoding = "cvmmap.body_tracking.v1";
[[nodiscard]]
@@ -268,6 +293,7 @@ std::expected<std::vector<std::uint8_t>, std::string> decoder_config_to_annexb(
return avcc_to_annexb(decoder_config);
}
#if CVMMAP_STREAMER_HAS_MCAP_DEPTH
[[nodiscard]]
bool can_encode_lossless_u16_mm(const RawDepthMapView &depth_map) {
if (normalize_depth_source_unit(depth_map.source_unit) != ipc::DepthUnit::Millimeter) {
@@ -367,6 +393,7 @@ std::expected<EncodedDepthPayload, std::string> encode_depth_payload(const RawDe
return std::unexpected(std::string("failed to RVL-encode depth map: ") + error.what());
}
}
#endif
[[nodiscard]]
std::expected<void, std::string> append_repeated_double(
@@ -619,18 +646,26 @@ std::expected<McapRecordSink, std::string> McapRecordSink::create(
state->writer.addChannel(channel);
state->video_channel_id = channel.id;
const auto depth_descriptor_set = build_file_descriptor_set(cvmmap_streamer::DepthMap::descriptor());
std::string depth_schema_bytes{};
if (!depth_descriptor_set.SerializeToString(&depth_schema_bytes)) {
return std::unexpected("failed to serialize cvmmap_streamer.DepthMap descriptor set");
#if CVMMAP_STREAMER_HAS_MCAP_DEPTH
if (mcap_depth_requested(config)) {
const auto depth_descriptor_set = build_file_descriptor_set(cvmmap_streamer::DepthMap::descriptor());
std::string depth_schema_bytes{};
if (!depth_descriptor_set.SerializeToString(&depth_schema_bytes)) {
return std::unexpected("failed to serialize cvmmap_streamer.DepthMap descriptor set");
}
mcap::Schema depth_schema("cvmmap_streamer.DepthMap", "protobuf", depth_schema_bytes);
state->writer.addSchema(depth_schema);
mcap::Channel depth_channel(config.record.mcap.depth_topic, "protobuf", depth_schema.id);
state->writer.addChannel(depth_channel);
state->depth_channel_id = depth_channel.id;
}
mcap::Schema depth_schema("cvmmap_streamer.DepthMap", "protobuf", depth_schema_bytes);
state->writer.addSchema(depth_schema);
mcap::Channel depth_channel(config.record.mcap.depth_topic, "protobuf", depth_schema.id);
state->writer.addChannel(depth_channel);
state->depth_channel_id = depth_channel.id;
#else
if (mcap_depth_requested(config)) {
return std::unexpected("MCAP depth recording support is not compiled into this build");
}
#endif
const auto calibration_descriptor_set = build_file_descriptor_set(foxglove::CameraCalibration::descriptor());
std::string calibration_schema_bytes{};
@@ -661,6 +696,7 @@ std::expected<McapRecordSink, std::string> McapRecordSink::create(
return sink;
}
std::expected<void, std::string> McapRecordSink::update_stream_info(const encode::EncodedStreamInfo &stream_info) {
if (state_ == nullptr) {
return std::unexpected("MCAP sink is not open");
@@ -717,8 +753,14 @@ std::expected<void, std::string> McapRecordSink::write_depth_map(const RawDepthM
if (state_ == nullptr) {
return std::unexpected("MCAP sink is not open");
}
if (state_->depth_channel_id == 0) {
return std::unexpected("MCAP depth recording support is not enabled for this sink");
}
#if !CVMMAP_STREAMER_HAS_MCAP_DEPTH
static_cast<void>(depth_map);
return std::unexpected("MCAP depth recording support is not compiled into this build");
#else
const auto source_unit = normalize_depth_source_unit(depth_map.source_unit);
auto encoded = encode_depth_payload(depth_map);
if (!encoded) {
return std::unexpected(encoded.error());
@@ -734,13 +776,20 @@ std::expected<void, std::string> McapRecordSink::write_depth_map(const RawDepthM
state_->depth_channel_id,
state_->depth_sequence,
*encoded);
#endif
}
std::expected<void, std::string> McapRecordSink::write_depth_map_u16(const RawDepthMapU16View &depth_map) {
if (state_ == nullptr) {
return std::unexpected("MCAP sink is not open");
}
if (state_->depth_channel_id == 0) {
return std::unexpected("MCAP depth recording support is not enabled for this sink");
}
#if !CVMMAP_STREAMER_HAS_MCAP_DEPTH
static_cast<void>(depth_map);
return std::unexpected("MCAP depth recording support is not compiled into this build");
#else
auto encoded = encode_depth_payload(depth_map);
if (!encoded) {
return std::unexpected(encoded.error());
@@ -756,6 +805,7 @@ std::expected<void, std::string> McapRecordSink::write_depth_map_u16(const RawDe
state_->depth_channel_id,
state_->depth_sequence,
*encoded);
#endif
}
std::expected<void, std::string> McapRecordSink::write_camera_calibration(const RawCameraCalibrationView &calibration) {
@@ -777,6 +827,9 @@ std::expected<void, std::string> McapRecordSink::write_depth_camera_calibration(
if (state_ == nullptr) {
return std::unexpected("MCAP sink is not open");
}
if (state_->depth_channel_id == 0) {
return std::unexpected("MCAP depth recording support is not enabled for this sink");
}
return write_calibration_message(
state_->writer,
calibration.timestamp_ns,
@@ -959,9 +1012,14 @@ std::expected<void, std::string> validate_new_stream_config(
if (config.topic.empty()) {
return std::unexpected("video topic is empty");
}
if (config.depth_topic.empty()) {
return std::unexpected("depth topic is empty");
#if CVMMAP_STREAMER_HAS_MCAP_DEPTH
const bool depth_enabled = !config.depth_topic.empty();
#else
if (!config.depth_topic.empty()) {
return std::unexpected("MCAP depth recording support is not compiled into this build");
}
constexpr bool depth_enabled = false;
#endif
if (config.frame_id.empty()) {
return std::unexpected("frame_id is empty");
}
@@ -984,9 +1042,11 @@ std::expected<void, std::string> validate_new_stream_config(
};
std::vector<std::string> config_topics{};
config_topics.push_back(config.topic);
if (depth_enabled) {
config_topics.push_back(config.depth_topic);
}
for (const auto *topic : {
&config.topic,
&config.depth_topic,
&config.calibration_topic,
&config.depth_calibration_topic,
&config.pose_topic,
@@ -1003,16 +1063,9 @@ std::expected<void, std::string> validate_new_stream_config(
}
}
for (const auto *topic : {
&config.topic,
&config.depth_topic,
&config.calibration_topic,
&config.depth_calibration_topic,
&config.pose_topic,
&config.body_topic,
}) {
if (!topic->empty() && topic_in_use(*topic)) {
return std::unexpected("duplicate MCAP topic: " + *topic);
for (const auto &topic : config_topics) {
if (topic_in_use(topic)) {
return std::unexpected("duplicate MCAP topic: " + topic);
}
}
return {};
@@ -1063,6 +1116,7 @@ std::expected<MultiMcapRecordSink, std::string> MultiMcapRecordSink::create(
state->writer.addSchema(video_schema);
state->video_schema_id = video_schema.id;
#if CVMMAP_STREAMER_HAS_MCAP_DEPTH
const auto depth_descriptor_set = build_file_descriptor_set(cvmmap_streamer::DepthMap::descriptor());
std::string depth_schema_bytes{};
if (!depth_descriptor_set.SerializeToString(&depth_schema_bytes)) {
@@ -1071,6 +1125,7 @@ std::expected<MultiMcapRecordSink, std::string> MultiMcapRecordSink::create(
mcap::Schema depth_schema("cvmmap_streamer.DepthMap", "protobuf", depth_schema_bytes);
state->writer.addSchema(depth_schema);
state->depth_schema_id = depth_schema.id;
#endif
if (!state->bundle_topic.empty()) {
const auto bundle_descriptor_set = build_file_descriptor_set(cvmmap_streamer::BundleManifest::descriptor());
@@ -1126,9 +1181,13 @@ std::expected<MultiMcapRecordSink::StreamId, std::string> MultiMcapRecordSink::a
state_->writer.addChannel(video_channel);
stream.video_channel_id = video_channel.id;
mcap::Channel depth_channel(config.depth_topic, "protobuf", state_->depth_schema_id);
state_->writer.addChannel(depth_channel);
stream.depth_channel_id = depth_channel.id;
#if CVMMAP_STREAMER_HAS_MCAP_DEPTH
if (!config.depth_topic.empty()) {
mcap::Channel depth_channel(config.depth_topic, "protobuf", state_->depth_schema_id);
state_->writer.addChannel(depth_channel);
stream.depth_channel_id = depth_channel.id;
}
#endif
if (auto updated = update_stream_info_impl(stream, stream_info); !updated) {
return std::unexpected(updated.error());
@@ -1200,7 +1259,13 @@ std::expected<void, std::string> MultiMcapRecordSink::write_depth_map(
if (stream == nullptr) {
return std::unexpected(error);
}
if (stream->depth_channel_id == 0) {
return std::unexpected("MCAP depth recording support is not enabled for this stream");
}
#if !CVMMAP_STREAMER_HAS_MCAP_DEPTH
static_cast<void>(depth_map);
return std::unexpected("MCAP depth recording support is not compiled into this build");
#else
const auto source_unit = normalize_depth_source_unit(depth_map.source_unit);
auto encoded = encode_depth_payload(depth_map);
if (!encoded) {
@@ -1217,6 +1282,7 @@ std::expected<void, std::string> MultiMcapRecordSink::write_depth_map(
stream->depth_channel_id,
stream->depth_sequence,
*encoded);
#endif
}
std::expected<void, std::string> MultiMcapRecordSink::write_depth_map_u16(
@@ -1227,7 +1293,13 @@ std::expected<void, std::string> MultiMcapRecordSink::write_depth_map_u16(
if (stream == nullptr) {
return std::unexpected(error);
}
if (stream->depth_channel_id == 0) {
return std::unexpected("MCAP depth recording support is not enabled for this stream");
}
#if !CVMMAP_STREAMER_HAS_MCAP_DEPTH
static_cast<void>(depth_map);
return std::unexpected("MCAP depth recording support is not compiled into this build");
#else
auto encoded = encode_depth_payload(depth_map);
if (!encoded) {
return std::unexpected(encoded.error());
@@ -1243,6 +1315,7 @@ std::expected<void, std::string> MultiMcapRecordSink::write_depth_map_u16(
stream->depth_channel_id,
stream->depth_sequence,
*encoded);
#endif
}
std::expected<void, std::string> MultiMcapRecordSink::write_camera_calibration(
@@ -1272,6 +1345,9 @@ std::expected<void, std::string> MultiMcapRecordSink::write_depth_camera_calibra
if (stream == nullptr) {
return std::unexpected(error);
}
if (stream->depth_channel_id == 0) {
return std::unexpected("MCAP depth recording support is not enabled for this stream");
}
return write_calibration_message(
state_->writer,
calibration.timestamp_ns,
@@ -1407,4 +1483,91 @@ void MultiMcapRecordSink::close() {
state_ = nullptr;
}
#else
namespace {
[[nodiscard]] std::string mcap_unavailable_error() {
return "MCAP recording support is not compiled into this build";
}
[[nodiscard]] std::string mcap_depth_unavailable_error() {
return "MCAP depth recording support is not compiled into this build";
}
} // namespace
McapRecordSink::~McapRecordSink() = default;
McapRecordSink::McapRecordSink(McapRecordSink &&other) noexcept = default;
McapRecordSink &McapRecordSink::operator=(McapRecordSink &&other) noexcept = default;
std::expected<McapRecordSink, std::string> McapRecordSink::create(
const RuntimeConfig &,
const encode::EncodedStreamInfo &) {
return std::unexpected(mcap_unavailable_error());
}
std::expected<void, std::string> McapRecordSink::update_stream_info(const encode::EncodedStreamInfo &) {
return std::unexpected(mcap_unavailable_error());
}
std::expected<void, std::string> McapRecordSink::write_access_unit(const encode::EncodedAccessUnit &) {
return std::unexpected(mcap_unavailable_error());
}
std::expected<void, std::string> McapRecordSink::write_depth_map(const RawDepthMapView &) {
return std::unexpected(mcap_depth_unavailable_error());
}
std::expected<void, std::string> McapRecordSink::write_depth_map_u16(const RawDepthMapU16View &) {
return std::unexpected(mcap_depth_unavailable_error());
}
std::expected<void, std::string> McapRecordSink::write_camera_calibration(const RawCameraCalibrationView &) {
return std::unexpected(mcap_unavailable_error());
}
std::expected<void, std::string> McapRecordSink::write_depth_camera_calibration(const RawCameraCalibrationView &) {
return std::unexpected(mcap_depth_unavailable_error());
}
std::expected<void, std::string> McapRecordSink::write_pose(const RawPoseView &) {
return std::unexpected(mcap_unavailable_error());
}
std::expected<void, std::string> McapRecordSink::write_body_tracking_message(const RawBodyTrackingMessageView &) {
return std::unexpected(mcap_unavailable_error());
}
bool McapRecordSink::is_open() const {
return false;
}
std::string_view McapRecordSink::path() const {
return {};
}
void McapRecordSink::close() {}
MultiMcapRecordSink::~MultiMcapRecordSink() = default;
MultiMcapRecordSink::MultiMcapRecordSink(MultiMcapRecordSink &&other) noexcept = default;
MultiMcapRecordSink &MultiMcapRecordSink::operator=(MultiMcapRecordSink &&other) noexcept = default;
std::expected<MultiMcapRecordSink, std::string> MultiMcapRecordSink::create(
std::string,
McapCompression,
std::string) {
return std::unexpected(mcap_unavailable_error());
}
std::expected<MultiMcapRecordSink::StreamId, std::string> MultiMcapRecordSink::add_stream(
const McapRecordStreamConfig &,
const encode::EncodedStreamInfo &) {
return std::unexpected(const {
return {};
}
void MultiMcapRecordSink::close() {}
#endif
}
@@ -1,6 +1,5 @@
#include "cvmmap_streamer/tools/zed_svo_mp4_support.hpp"
#include <spdlog/spdlog.h>
#include "cvmmap_streamer/record/mp4_record_writer.hpp"
#include "../encode/ffmpeg_encoder_options.hpp"
extern "C" {
#include <libavcodec/avcodec.h>
@@ -14,29 +13,26 @@ extern "C" {
#include <cmath>
#include <optional>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
namespace cvmmap_streamer::zed_tools {
#include <spdlog/spdlog.h>
namespace cvmmap_streamer::record {
namespace {
struct EncoderCandidate {
std::string name{};
bool using_hardware{false};
AVPixelFormat pixel_format{AV_PIX_FMT_NONE};
};
inline constexpr std::uint64_t kNanosPerSecond = 1'000'000'000ull;
struct ResolvedEncoderSettings {
std::string requested_preset{};
std::string requested_tune{};
std::string mapped_preset{};
std::optional<std::string> mapped_tune{};
std::optional<std::string> rate_control_mode{};
std::string quality_key{};
int quality_value{kDefaultQuality};
std::uint32_t gop{kDefaultGopSize};
std::uint32_t b_frames{kDefaultBFrames};
int quality_value{kDefaultMp4Quality};
std::uint32_t gop{30};
std::uint32_t b_frames{0};
};
[[nodiscard]]
@@ -51,6 +47,23 @@ AVCodecID codec_id(const CodecType codec) {
return codec == CodecType::H265 ? AV_CODEC_ID_HEVC : AV_CODEC_ID_H264;
}
[[nodiscard]]
AVPixelFormat av_pixel_format(const Mp4InputPixelFormat pixel_format) {
switch (pixel_format) {
case Mp4InputPixelFormat::Bgr24:
return AV_PIX_FMT_BGR24;
case Mp4InputPixelFormat::Rgb24:
return AV_PIX_FMT_RGB24;
case Mp4InputPixelFormat::Bgra32:
return AV_PIX_FMT_BGRA;
case Mp4InputPixelFormat::Rgba32:
return AV_PIX_FMT_RGBA;
case Mp4InputPixelFormat::Gray8:
return AV_PIX_FMT_GRAY8;
}
return AV_PIX_FMT_NONE;
}
[[nodiscard]]
AVRational frame_rate_rational(const float fps) {
if (!(fps > 0.0f)) {
@@ -65,76 +78,23 @@ AVRational frame_rate_rational(const float fps) {
}
[[nodiscard]]
std::vector<EncoderCandidate> encoder_candidates(const CodecType codec, const EncoderDeviceType device) {
const std::string hardware_name = codec == CodecType::H265 ? "hevc_nvenc" : "h264_nvenc";
const std::string software_name = codec == CodecType::H265 ? "libx265" : "libx264";
switch (device) {
case EncoderDeviceType::Auto:
return {
EncoderCandidate{.name = hardware_name, .using_hardware = true, .pixel_format = AV_PIX_FMT_NV12},
EncoderCandidate{.name = software_name, .using_hardware = false, .pixel_format = AV_PIX_FMT_YUV420P},
};
case EncoderDeviceType::Nvidia:
return {
EncoderCandidate{.name = hardware_name, .using_hardware = true, .pixel_format = AV_PIX_FMT_NV12},
};
case EncoderDeviceType::Software:
return {
EncoderCandidate{.name = software_name, .using_hardware = false, .pixel_format = AV_PIX_FMT_YUV420P},
};
std::uint64_t frame_period_ns(const AVRational frame_rate) {
if (frame_rate.num <= 0 || frame_rate.den <= 0) {
return 33'333'333ull;
}
return {};
const auto numerator =
static_cast<std::uint64_t>(frame_rate.den) * kNanosPerSecond;
const auto denominator = static_cast<std::uint64_t>(frame_rate.num);
if (denominator == 0) {
return 33'333'333ull;
}
const auto interval = numerator / denominator;
return interval == 0 ? 1ull : interval;
}
[[nodiscard]]
std::string mapped_preset_value(const EncoderCandidate &candidate, const PresetKind preset) {
if (candidate.using_hardware) {
switch (preset) {
case PresetKind::Fast:
return "p1";
case PresetKind::Balanced:
return "p4";
case PresetKind::Quality:
return "p7";
}
}
switch (preset) {
case PresetKind::Fast:
return "veryfast";
case PresetKind::Balanced:
return "medium";
case PresetKind::Quality:
return "slow";
}
return "veryfast";
}
[[nodiscard]]
std::optional<std::string> mapped_tune_value(const EncoderCandidate &candidate, const TuneKind tune) {
if (candidate.using_hardware) {
return tune == TuneKind::LowLatency ? std::optional<std::string>{"ull"} : std::optional<std::string>{"hq"};
}
if (candidate.name == "libx264" && tune == TuneKind::LowLatency) {
return std::optional<std::string>{"zerolatency"};
}
return std::nullopt;
}
[[nodiscard]]
std::optional<std::string> x265_params_value(const EncoderCandidate &candidate, const TuneKind tune) {
if (candidate.name != "libx265") {
return std::nullopt;
}
if (tune == TuneKind::LowLatency) {
return std::optional<std::string>{"repeat-headers=1:scenecut=0"};
}
return std::optional<std::string>{"repeat-headers=1"};
}
[[nodiscard]]
std::expected<void, std::string> set_string_option(AVCodecContext *context, const char *key, const std::string &value) {
@@ -157,12 +117,12 @@ std::expected<void, std::string> set_int_option(AVCodecContext *context, const c
[[nodiscard]]
std::expected<ResolvedEncoderSettings, std::string> configure_codec_context(
AVCodecContext *context,
const EncoderCandidate &candidate,
const encode::FfmpegEncoderCandidate &candidate,
const CodecType codec,
const std::uint32_t width,
const std::uint32_t height,
const AVRational framerate,
const EncodeTuning &tuning) {
const Mp4EncodeTuning &tuning) {
context->codec_type = AVMEDIA_TYPE_VIDEO;
context->codec_id = codec_id(codec);
context->width = static_cast<int>(width);
@@ -176,55 +136,52 @@ std::expected<ResolvedEncoderSettings, std::string> configure_codec_context(
context->thread_count = 1;
ResolvedEncoderSettings resolved{
.requested_preset = std::string(preset_name(tuning.preset)),
.requested_tune = std::string(tune_name(tuning.tune)),
.mapped_preset = mapped_preset_value(candidate, tuning.preset),
.mapped_tune = mapped_tune_value(candidate, tuning.tune),
.quality_value = tuning.quality,
.gop = tuning.gop,
.b_frames = tuning.b_frames,
};
if (auto set = set_string_option(context, "preset", resolved.mapped_preset); !set) {
return std::unexpected(set.error());
if (const auto preset = encode::ffmpeg_encoder_preset(candidate); preset) {
resolved.mapped_preset = std::string(*preset);
if (auto set = set_string_option(context, "preset", resolved.mapped_preset); !set) {
return std::unexpected(set.error());
}
}
if (resolved.mapped_tune) {
if (const auto tune = encode::ffmpeg_encoder_tune(candidate); tune) {
resolved.mapped_tune = std::string(*tune);
if (auto set = set_string_option(context, "tune", *resolved.mapped_tune); !set) {
return std::unexpected(set.error());
}
}
if (candidate.using_hardware) {
resolved.rate_control_mode = "vbr";
resolved.quality_key = "cq";
if (const auto rc_mode = encode::ffmpeg_encoder_rate_control_mode(candidate); rc_mode) {
resolved.rate_control_mode = std::string(*rc_mode);
if (auto set = set_string_option(context, "rc", *resolved.rate_control_mode); !set) {
return std::unexpected(set.error());
}
if (auto set = set_int_option(context, "cq", resolved.quality_value); !set) {
}
if (const auto quality_key = encode::ffmpeg_encoder_quality_key(candidate); quality_key) {
resolved.quality_key = std::string(*quality_key);
if (auto set = set_int_option(context, resolved.quality_key.c_str(), resolved.quality_value); !set) {
return std::unexpected(set.error());
}
if (tuning.tune == TuneKind::LowLatency) {
if (auto set = set_string_option(context, "zerolatency", "1"); !set) {
return std::unexpected(set.error());
}
if (auto set = set_string_option(context, "rc-lookahead", "0"); !set) {
return std::unexpected(set.error());
}
}
} else {
resolved.quality_key = "crf";
if (auto set = set_int_option(context, "crf", resolved.quality_value); !set) {
return std::unexpected(set.error());
}
if (const auto x265_params = x265_params_value(candidate, tuning.tune); x265_params) {
if (auto set = set_string_option(context, "x265-params", *x265_params); !set) {
return std::unexpected(set.error());
}
}
}
if (auto set = set_int_option(context, "forced-idr", 1); !set) {
return std::unexpected(set.error());
if (const auto x265_params = encode::ffmpeg_encoder_x265_params(candidate); x265_params) {
if (auto set = set_string_option(context, "x265-params", std::string(*x265_params)); !set) {
return std::unexpected(set.error());
}
}
if (encode::ffmpeg_encoder_supports_nvenc_latency_flags(candidate)) {
if (auto set = set_string_option(context, "zerolatency", "1"); !set) {
return std::unexpected(set.error());
}
if (auto set = set_string_option(context, "rc-lookahead", "0"); !set) {
return std::unexpected(set.error());
}
}
if (encode::ffmpeg_encoder_supports_forced_idr_option(candidate)) {
if (auto set = set_int_option(context, "forced-idr", 1); !set) {
return std::unexpected(set.error());
}
}
return resolved;
@@ -232,7 +189,7 @@ std::expected<ResolvedEncoderSettings, std::string> configure_codec_context(
struct OpenedEncoder {
AVCodecContext *context{nullptr};
EncoderCandidate candidate{};
encode::FfmpegEncoderCandidate candidate{};
ResolvedEncoderSettings resolved{};
};
@@ -243,21 +200,20 @@ std::expected<OpenedEncoder, std::string> open_encoder(
const std::uint32_t width,
const std::uint32_t height,
const AVRational framerate,
const EncodeTuning &tuning) {
const Mp4EncodeTuning &tuning) {
const auto candidates = encode::ffmpeg_encoder_candidates(codec, device);
const auto attempted_candidates = encode::ffmpeg_encoder_candidate_list(candidates);
std::string last_error{};
for (const auto &candidate : encoder_candidates(codec, device)) {
const auto *encoder = avcodec_find_encoder_by_name(candidate.name.c_str());
for (const auto &candidate : candidates) {
const auto *encoder = avcodec_find_encoder_by_name(candidate.name.data());
if (encoder == nullptr) {
last_error = "FFmpeg encoder '" + candidate.name + "' is unavailable";
if (device == EncoderDeviceType::Auto) {
spdlog::warn(
"encoder '{}' unavailable for codec={} in auto mode, trying next candidate",
candidate.name,
codec_name(codec));
continue;
}
return std::unexpected(last_error);
last_error = "FFmpeg encoder '" + std::string(candidate.name) + "' is unavailable";
spdlog::warn(
"MP4 encoder '{}' unavailable in {} mode, trying next candidate",
candidate.name,
to_string(device));
continue;
}
auto *context = avcodec_alloc_context3(encoder);
@@ -267,22 +223,26 @@ std::expected<OpenedEncoder, std::string> open_encoder(
auto resolved = configure_codec_context(context, candidate, codec, width, height, framerate, tuning);
if (!resolved) {
last_error = resolved.error();
avcodec_free_context(&context);
return std::unexpected(resolved.error());
spdlog::warn(
"MP4 encoder '{}' configuration failed in {} mode: {}. trying next candidate",
candidate.name,
to_string(device),
resolved.error());
continue;
}
const auto open_result = avcodec_open2(context, encoder, nullptr);
if (open_result < 0) {
last_error = "failed to open FFmpeg encoder '" + candidate.name + "': " + av_error_string(open_result);
last_error = "failed to open FFmpeg encoder '" + std::string(candidate.name) + "': " + av_error_string(open_result);
avcodec_free_context(&context);
if (device == EncoderDeviceType::Auto) {
spdlog::warn(
"encoder '{}' failed to open in auto mode: {}. trying software fallback",
candidate.name,
av_error_string(open_result));
continue;
}
return std::unexpected(last_error);
spdlog::warn(
"MP4 encoder '{}' failed to open in {} mode: {}. trying next candidate",
candidate.name,
to_string(device),
av_error_string(open_result));
continue;
}
return OpenedEncoder{
@@ -293,14 +253,14 @@ std::expected<OpenedEncoder, std::string> open_encoder(
}
if (last_error.empty()) {
last_error = "no usable FFmpeg encoder candidates were configured";
last_error = "no usable FFmpeg encoder found";
}
return std::unexpected(last_error);
return std::unexpected(last_error + " (attempted: " + attempted_candidates + ")");
}
} // namespace
struct Mp4Writer::Impl {
struct Mp4RecordWriter::Impl {
[[nodiscard]]
std::expected<void, std::string> open(
const std::filesystem::path &output_path,
@@ -309,11 +269,20 @@ struct Mp4Writer::Impl {
const std::uint32_t width,
const std::uint32_t height,
const float fps,
const EncodeTuning &tuning) {
const Mp4EncodeTuning &tuning,
const Mp4InputPixelFormat input_pixel_format_arg) {
close();
codec = codec_arg;
frame_rate = frame_rate_rational(fps);
frame_period = frame_period_ns(frame_rate);
last_frame_pts_ns.reset();
input_pixel_format = input_pixel_format_arg;
source_pixel_format = av_pixel_format(input_pixel_format);
if (source_pixel_format == AV_PIX_FMT_NONE) {
return std::unexpected("unsupported MP4 writer input pixel format");
}
auto encoder = open_encoder(codec, encoder_device, width, height, frame_rate, tuning);
if (!encoder) {
return std::unexpected(encoder.error());
@@ -329,7 +298,7 @@ struct Mp4Writer::Impl {
nullptr,
static_cast<int>(width),
static_cast<int>(height),
AV_PIX_FMT_BGR24,
source_pixel_format,
static_cast<int>(width),
static_cast<int>(height),
encoder_pixel_format,
@@ -400,30 +369,28 @@ struct Mp4Writer::Impl {
return std::unexpected("failed to write MP4 header: " + av_error_string(header_result));
}
const auto quality_key = resolved_settings.quality_key.empty() ? std::string("auto") : resolved_settings.quality_key;
const auto quality_value = resolved_settings.quality_key.empty() ? std::string("n/a") : std::to_string(resolved_settings.quality_value);
spdlog::info(
"ZED_SVO_MP4_READY codec={} encoder={} hardware={} width={} height={} fps={}/{} requested_preset={} requested_tune={} mapped_preset={} mapped_tune={} rc={} {}={} gop={} b_frames={} output={}",
codec_name(codec),
"MP4_RECORD_READY codec={} encoder={} hardware={} width={} height={} fps={}/{} rc={} quality={} gop={} b_frames={} input={} output={}",
cvmmap_streamer::to_string(codec),
encoder_name,
using_hardware,
width,
height,
frame_rate.num,
frame_rate.den,
resolved_settings.requested_preset,
resolved_settings.requested_tune,
resolved_settings.mapped_preset,
resolved_settings.mapped_tune.value_or("none"),
resolved_settings.rate_control_mode.value_or("auto"),
resolved_settings.quality_key,
resolved_settings.quality_value,
quality_key + "=" + quality_value,
resolved_settings.gop,
resolved_settings.b_frames,
input_pixel_format_name(input_pixel_format),
output_path.string());
return {};
}
[[nodiscard]]
std::expected<void, std::string> write_bgr_frame(
std::expected<void, std::string> write_frame(
const std::uint8_t *data,
const std::size_t row_stride_bytes,
const std::uint64_t relative_timestamp_ns) {
@@ -447,7 +414,13 @@ struct Mp4Writer::Impl {
frame->data,
frame->linesize);
frame->pts = static_cast<std::int64_t>(relative_timestamp_ns);
auto normalized_timestamp_ns = relative_timestamp_ns;
if (last_frame_pts_ns && normalized_timestamp_ns <= *last_frame_pts_ns) {
normalized_timestamp_ns = *last_frame_pts_ns + frame_period;
}
frame->pts = static_cast<std::int64_t>(normalized_timestamp_ns);
last_frame_pts_ns = normalized_timestamp_ns;
const auto send_result = avcodec_send_frame(encoder_context, frame);
if (send_result < 0) {
@@ -542,6 +515,8 @@ struct Mp4Writer::Impl {
encoder_name.clear();
using_hardware = false;
trailer_written = false;
frame_period = 33'333'333ull;
last_frame_pts_ns.reset();
resolved_settings = ResolvedEncoderSettings{};
}
@@ -557,129 +532,65 @@ struct Mp4Writer::Impl {
AVPacket *packet{nullptr};
SwsContext *scaler{nullptr};
AVPixelFormat encoder_pixel_format{AV_PIX_FMT_NONE};
AVPixelFormat source_pixel_format{AV_PIX_FMT_NONE};
AVRational frame_rate{30, 1};
std::uint64_t frame_period{33'333'333ull};
std::optional<std::uint64_t> last_frame_pts_ns{};
std::string encoder_name{};
ResolvedEncoderSettings resolved_settings{};
bool using_hardware{false};
bool trailer_written{false};
Mp4InputPixelFormat input_pixel_format{Mp4InputPixelFormat::Bgr24};
};
std::expected<CodecType, std::string> parse_codec(const std::string_view raw) {
if (raw == "h264") {
return CodecType::H264;
std::string_view input_pixel_format_name(const Mp4InputPixelFormat pixel_format) {
switch (pixel_format) {
case Mp4InputPixelFormat::Bgr24:
return "bgr24";
case Mp4InputPixelFormat::Rgb24:
return "rgb24";
case Mp4InputPixelFormat::Bgra32:
return "bgra";
case Mp4InputPixelFormat::Rgba32:
return "rgba";
case Mp4InputPixelFormat::Gray8:
return "gray8";
}
if (raw == "h265") {
return CodecType::H265;
}
return std::unexpected("invalid codec: '" + std::string(raw) + "' (expected: h264|h265)");
return "unknown";
}
std::expected<EncoderDeviceType, std::string> parse_encoder_device(const std::string_view raw) {
if (raw == "auto") {
return EncoderDeviceType::Auto;
}
if (raw == "nvidia") {
return EncoderDeviceType::Nvidia;
}
if (raw == "software") {
return EncoderDeviceType::Software;
}
return std::unexpected("invalid encoder device: '" + std::string(raw) + "' (expected: auto|nvidia|software)");
}
std::expected<PresetKind, std::string> parse_preset(const std::string_view raw) {
if (raw == "fast") {
return PresetKind::Fast;
}
if (raw == "balanced") {
return PresetKind::Balanced;
}
if (raw == "quality") {
return PresetKind::Quality;
}
return std::unexpected("invalid preset: '" + std::string(raw) + "' (expected: fast|balanced|quality)");
}
std::expected<TuneKind, std::string> parse_tune(const std::string_view raw) {
if (raw == "low-latency") {
return TuneKind::LowLatency;
}
if (raw == "balanced") {
return TuneKind::Balanced;
}
return std::unexpected("invalid tune: '" + std::string(raw) + "' (expected: low-latency|balanced)");
}
std::string_view codec_name(const CodecType codec) {
return codec == CodecType::H265 ? "h265" : "h264";
}
std::string_view preset_name(const PresetKind preset) {
switch (preset) {
case PresetKind::Fast:
return "fast";
case PresetKind::Balanced:
return "balanced";
case PresetKind::Quality:
return "quality";
}
return "fast";
}
std::string_view tune_name(const TuneKind tune) {
switch (tune) {
case TuneKind::LowLatency:
return "low-latency";
case TuneKind::Balanced:
return "balanced";
}
return "low-latency";
}
std::uint64_t frame_period_ns(const float fps) {
if (!(fps > 0.0f)) {
return 33'333'333ull;
}
return static_cast<std::uint64_t>(std::llround(1'000'000'000.0 / static_cast<double>(fps)));
}
std::filesystem::path derive_output_path(const std::filesystem::path &input_path) {
auto output_path = input_path;
output_path.replace_extension(".mp4");
return output_path;
}
Mp4Writer::Mp4Writer()
Mp4RecordWriter::Mp4RecordWriter()
: impl_(std::make_unique<Impl>()) {}
Mp4Writer::Mp4Writer(Mp4Writer &&) noexcept = default;
Mp4Writer &Mp4Writer::operator=(Mp4Writer &&) noexcept = default;
Mp4Writer::~Mp4Writer() = default;
Mp4RecordWriter::Mp4RecordWriter(Mp4RecordWriter &&) noexcept = default;
Mp4RecordWriter &Mp4RecordWriter::operator=(Mp4RecordWriter &&) noexcept = default;
Mp4RecordWriter::~Mp4RecordWriter() = default;
std::expected<void, std::string> Mp4Writer::open(
std::expected<void, std::string> Mp4RecordWriter::open(
const std::filesystem::path &output_path,
const CodecType codec,
const EncoderDeviceType encoder_device,
const std::uint32_t width,
const std::uint32_t height,
const float fps,
const EncodeTuning &tuning) {
return impl_->open(output_path, codec, encoder_device, width, height, fps, tuning);
const Mp4EncodeTuning &tuning,
const Mp4InputPixelFormat input_pixel_format) {
return impl_->open(output_path, codec, encoder_device, width, height, fps, tuning, input_pixel_format);
}
std::expected<void, std::string> Mp4Writer::write_bgr_frame(
std::expected<void, std::string> Mp4RecordWriter::write_frame(
const std::uint8_t *data,
const std::size_t row_stride_bytes,
const std::uint64_t relative_timestamp_ns) {
return impl_->write_bgr_frame(data, row_stride_bytes, relative_timestamp_ns);
return impl_->write_frame(data, row_stride_bytes, relative_timestamp_ns);
}
std::expected<void, std::string> Mp4Writer::flush() {
std::expected<void, std::string> Mp4RecordWriter::flush() {
return impl_->flush();
}
bool Mp4Writer::using_hardware() const {
bool Mp4RecordWriter::using_hardware() const {
return impl_ != nullptr && impl_->using_hardware;
}
} // namespace cvmmap_streamer::zed_tools
} // namespace cvmmap_streamer::record
-1
View File
@@ -77,7 +77,6 @@ int main(int argc, char **argv) {
config.record.mcap.enabled = true;
config.record.mcap.path = output_path.string();
config.record.mcap.topic = "/camera/video";
config.record.mcap.depth_topic = "/camera/depth";
config.record.mcap.body_topic = std::string(kBodyTopic);
config.record.mcap.frame_id = "camera";
config.record.mcap.compression = cvmmap_streamer::McapCompression::None;
+1 -53
View File
@@ -1,14 +1,12 @@
#include <mcap/reader.hpp>
#include "proto/cvmmap_streamer/BundleManifest.pb.h"
#include "proto/cvmmap_streamer/DepthMap.pb.h"
#include "cvmmap_streamer/common.h"
#include "cvmmap_streamer/record/mcap_record_sink.hpp"
#include "proto/foxglove/CameraCalibration.pb.h"
#include "proto/foxglove/CompressedVideo.pb.h"
#include "proto/foxglove/PoseInFrame.pb.h"
#include <rvl/rvl.hpp>
#include <spdlog/spdlog.h>
#include <cstdint>
@@ -86,18 +84,14 @@ int main(int argc, char **argv) {
auto zed1 = sink->add_stream(cvmmap_streamer::record::McapRecordStreamConfig{
.topic = "/zed1/video",
.depth_topic = "/zed1/depth",
.calibration_topic = "/zed1/calibration",
.depth_calibration_topic = "/zed1/depth_calibration",
.pose_topic = "/zed1/pose",
.body_topic = "/zed1/body",
.frame_id = "zed1",
}, stream_info);
auto zed2 = sink->add_stream(cvmmap_streamer::record::McapRecordStreamConfig{
.topic = "/zed2/video",
.depth_topic = "/zed2/depth",
.calibration_topic = "/zed2/calibration",
.depth_calibration_topic = "/zed2/depth_calibration",
.pose_topic = "/zed2/pose",
.body_topic = "/zed2/body",
.frame_id = "zed2",
@@ -107,10 +101,6 @@ int main(int argc, char **argv) {
return exit_code(TesterExitCode::CreateError);
}
const std::vector<std::uint16_t> depth_pixels{
1000, 2000,
1500, 2500,
};
const std::vector<double> distortion{0.0, 0.0, 0.0, 0.0, 0.0};
const std::vector<double> intrinsic_matrix{
500.0, 0.0, 320.0,
@@ -168,15 +158,6 @@ int main(int argc, char **argv) {
return exit_code(TesterExitCode::WriteError);
}
if (auto write = sink->write_depth_map_u16(stream_id, cvmmap_streamer::record::RawDepthMapU16View{
.timestamp_ns = 100,
.width = 2,
.height = 2,
.pixels = depth_pixels,
}); !write) {
spdlog::error("failed to write depth map: {}", write.error());
return exit_code(TesterExitCode::WriteError);
}
if (auto write = sink->write_camera_calibration(stream_id, cvmmap_streamer::record::RawCameraCalibrationView{
.timestamp_ns = 100,
@@ -191,19 +172,7 @@ int main(int argc, char **argv) {
spdlog::error("failed to write calibration: {}", write.error());
return exit_code(TesterExitCode::WriteError);
}
if (auto write = sink->write_depth_camera_calibration(stream_id, cvmmap_streamer::record::RawCameraCalibrationView{
.timestamp_ns = 100,
.width = 320,
.height = 240,
.distortion_model = "plumb_bob",
.distortion = distortion,
.intrinsic_matrix = intrinsic_matrix,
.rectification_matrix = rectification_matrix,
.projection_matrix = projection_matrix,
}); !write) {
spdlog::error("failed to write depth calibration: {}", write.error());
return exit_code(TesterExitCode::WriteError);
}
if (auto write = sink->write_pose(stream_id, cvmmap_streamer::record::RawPoseView{
.timestamp_ns = 100,
@@ -251,23 +220,6 @@ int main(int argc, char **argv) {
continue;
}
if (it->schema->name == "cvmmap_streamer.DepthMap") {
cvmmap_streamer::DepthMap depth{};
if (!depth.ParseFromArray(it->message.data, static_cast<int>(it->message.dataSize))) {
spdlog::error("failed to parse cvmmap_streamer.DepthMap");
reader.close();
return exit_code(TesterExitCode::VerificationError);
}
const auto decoded = rvl::decompress_image(std::span<const std::uint8_t>(
reinterpret_cast<const std::uint8_t *>(depth.data().data()),
depth.data().size()));
if (decoded.pixels.size() != depth_pixels.size()) {
spdlog::error("decoded depth pixel count mismatch");
reader.close();
return exit_code(TesterExitCode::VerificationError);
}
continue;
}
if (it->schema->name == "cvmmap_streamer.BundleManifest") {
cvmmap_streamer::BundleManifest bundle{};
@@ -319,14 +271,10 @@ int main(int argc, char **argv) {
for (const auto &topic : {
"/bundle",
"/zed1/video",
"/zed1/depth",
"/zed1/calibration",
"/zed1/depth_calibration",
"/zed1/pose",
"/zed2/video",
"/zed2/depth",
"/zed2/calibration",
"/zed2/depth_calibration",
"/zed2/pose",
}) {
if (topic_counts[topic] != 1) {
+3 -50
View File
@@ -1,13 +1,11 @@
#include <mcap/reader.hpp>
#include "proto/cvmmap_streamer/DepthMap.pb.h"
#include "cvmmap_streamer/common.h"
#include "cvmmap_streamer/record/mcap_record_sink.hpp"
#include "proto/foxglove/CameraCalibration.pb.h"
#include "proto/foxglove/CompressedVideo.pb.h"
#include "proto/foxglove/PoseInFrame.pb.h"
#include <rvl/rvl.hpp>
#include <spdlog/spdlog.h>
#include <cmath>
@@ -56,7 +54,6 @@ int main(int argc, char **argv) {
config.record.mcap.enabled = true;
config.record.mcap.path = output_path.string();
config.record.mcap.topic = "/camera/video";
config.record.mcap.depth_topic = "/camera/depth";
config.record.mcap.calibration_topic = "/camera/calibration";
config.record.mcap.pose_topic = "/camera/pose";
config.record.mcap.frame_id = "camera";
@@ -82,19 +79,6 @@ int main(int argc, char **argv) {
return exit_code(TesterExitCode::WriteError);
}
const std::vector<std::uint16_t> depth_pixels{
1000, 2000,
0, 1500,
};
if (auto write = sink->write_depth_map_u16(cvmmap_streamer::record::RawDepthMapU16View{
.timestamp_ns = 101,
.width = 2,
.height = 2,
.pixels = depth_pixels,
}); !write) {
spdlog::error("failed to write depth map: {}", write.error());
return exit_code(TesterExitCode::WriteError);
}
const std::vector<double> distortion{0.0, 0.0, 0.0, 0.0, 0.0};
const std::vector<double> intrinsic_matrix{
@@ -146,7 +130,6 @@ int main(int argc, char **argv) {
}
std::uint64_t video_messages{0};
std::uint64_t depth_messages{0};
std::uint64_t calibration_messages{0};
std::uint64_t pose_messages{0};
@@ -179,35 +162,6 @@ int main(int argc, char **argv) {
continue;
}
if (it->schema->name == "cvmmap_streamer.DepthMap") {
cvmmap_streamer::DepthMap depth{};
if (!depth.ParseFromArray(it->message.data, static_cast<int>(it->message.dataSize))) {
spdlog::error("failed to parse cvmmap_streamer.DepthMap");
reader.close();
return exit_code(TesterExitCode::VerificationError);
}
if (it->channel->topic != "/camera/depth" ||
depth.frame_id() != "camera" ||
depth.encoding() != cvmmap_streamer::DepthMap::RVL_U16_LOSSLESS) {
spdlog::error("depth message metadata verification failed");
reader.close();
return exit_code(TesterExitCode::VerificationError);
}
const auto decoded = rvl::decompress_image(std::span<const std::uint8_t>(
reinterpret_cast<const std::uint8_t *>(depth.data().data()),
depth.data().size()));
if (decoded.pixels.size() != depth_pixels.size() ||
decoded.pixels[0] != 1000 ||
decoded.pixels[1] != 2000 ||
decoded.pixels[2] != 0 ||
decoded.pixels[3] != 1500) {
spdlog::error("depth RVL round-trip verification failed");
reader.close();
return exit_code(TesterExitCode::VerificationError);
}
depth_messages += 1;
continue;
}
if (it->schema->name == "foxglove.CameraCalibration") {
foxglove::CameraCalibration calibration{};
@@ -262,18 +216,17 @@ int main(int argc, char **argv) {
reader.close();
if (video_messages != 1 || depth_messages != 1 || calibration_messages != 1 || pose_messages != 1) {
if (video_messages != 1 || calibration_messages != 1 || pose_messages != 1) {
spdlog::error(
"unexpected message counts: video={} depth={} calibration={} pose={}",
"unexpected message counts: video={} calibration={} pose={}",
video_messages,
depth_messages,
calibration_messages,
pose_messages);
return exit_code(TesterExitCode::VerificationError);
}
spdlog::info(
"validated same-file MCAP video+depth+calibration+pose recording at '{}'",
"validated same-file MCAP video+calibration+pose recording at '{}'",
output_path.string());
return exit_code(TesterExitCode::Success);
}
+2 -2
View File
@@ -52,7 +52,7 @@ struct Config {
[[nodiscard]]
std::expected<Config, int> parse_args(int argc, char **argv) {
Config config{};
CLI::App app{"rtmp_output_tester - publish synthetic encoded video to RTMP using the configured sink"};
CLI::App app{"rtmp_output_tester - publish synthetic encoded video to RTMP using the runtime encoder selection path"};
app.add_option("--rtmp-url", config.rtmp_url, "RTMP destination URL")->required();
app.add_option("--transport", config.transport, "RTMP transport backend (libavformat|ffmpeg_process)")
->check(CLI::IsMember({"libavformat", "ffmpeg_process"}));
@@ -150,7 +150,7 @@ int main(int argc, char **argv) {
}
cvmmap_streamer::RuntimeConfig config = cvmmap_streamer::RuntimeConfig::defaults();
config.encoder.backend = cvmmap_streamer::EncoderBackendType::FFmpeg;
config.encoder.backend = cvmmap_streamer::EncoderBackendType::Auto;
config.encoder.device = *encoder_device;
config.encoder.codec = *codec;
config.encoder.gop = 15;
+2 -2
View File
@@ -48,7 +48,7 @@ struct Config {
[[nodiscard]]
std::expected<Config, int> parse_args(int argc, char **argv) {
Config config{};
CLI::App app{"rtp_output_tester - publish synthetic encoded video to RTP using the FFmpeg encoder path"};
CLI::App app{"rtp_output_tester - publish synthetic encoded video to RTP using the runtime encoder selection path"};
app.add_option("--host", config.host, "RTP destination host")->required();
app.add_option("--port", config.port, "RTP destination port")->required()->check(CLI::Range(1, 65535));
app.add_option("--payload-type", config.payload_type, "RTP payload type (96-127)")->check(CLI::Range(96, 127));
@@ -128,7 +128,7 @@ int main(int argc, char **argv) {
}
cvmmap_streamer::RuntimeConfig config = cvmmap_streamer::RuntimeConfig::defaults();
config.encoder.backend = cvmmap_streamer::EncoderBackendType::FFmpeg;
config.encoder.backend = cvmmap_streamer::EncoderBackendType::Auto;
config.encoder.device = *encoder_device;
config.encoder.codec = *codec;
config.encoder.gop = 15;
-219
View File
@@ -1,219 +0,0 @@
#include <CLI/CLI.hpp>
#include <spdlog/spdlog.h>
#include <foxglove/CompressedVideo.pb.h>
#include <mcap/reader.hpp>
#include <algorithm>
#include <cstdint>
#include <cstdlib>
#include <iomanip>
#include <iostream>
#include <limits>
#include <sstream>
#include <string>
namespace {
enum class ToolExitCode : int {
Success = 0,
UsageError = 2,
OpenError = 3,
SchemaError = 4,
ParseError = 5,
EmptyError = 6,
};
struct Config {
std::string input_path{};
bool json{false};
};
struct BoundsSummary {
std::uint64_t start_ns{std::numeric_limits<std::uint64_t>::max()};
std::uint64_t end_ns{0};
std::uint64_t message_count{0};
};
[[nodiscard]]
constexpr int exit_code(const ToolExitCode code) {
return static_cast<int>(code);
}
[[nodiscard]]
std::uint64_t proto_timestamp_ns(const google::protobuf::Timestamp &timestamp) {
return static_cast<std::uint64_t>(timestamp.seconds()) * 1000000000ull + static_cast<std::uint64_t>(timestamp.nanos());
}
[[nodiscard]]
std::string json_escape(const std::string &input) {
std::ostringstream output;
for (const unsigned char ch : input) {
switch (ch) {
case '\\':
output << "\\\\";
break;
case '"':
output << "\\\"";
break;
case '\b':
output << "\\b";
break;
case '\f':
output << "\\f";
break;
case '\n':
output << "\\n";
break;
case '\r':
output << "\\r";
break;
case '\t':
output << "\\t";
break;
default:
if (ch < 0x20) {
output << "\\u" << std::hex << std::setw(4) << std::setfill('0') << static_cast<int>(ch) << std::dec;
} else {
output << static_cast<char>(ch);
}
break;
}
}
return output.str();
}
[[nodiscard]]
std::string format_iso_utc(const std::uint64_t timestamp_ns) {
const auto seconds = static_cast<std::time_t>(timestamp_ns / 1000000000ull);
const auto nanos = timestamp_ns % 1000000000ull;
std::tm tm{};
#if defined(_WIN32)
gmtime_s(&tm, &seconds);
#else
gmtime_r(&seconds, &tm);
#endif
std::ostringstream output;
output << std::put_time(&tm, "%Y-%m-%dT%H:%M:%S") << '.' << std::setw(9) << std::setfill('0') << nanos << 'Z';
return output.str();
}
[[nodiscard]]
bool is_video_message(const auto &view) {
if (view.channel == nullptr || view.schema == nullptr) {
return false;
}
return view.schema->encoding == "protobuf" &&
view.schema->name == "foxglove.CompressedVideo" &&
view.channel->messageEncoding == "protobuf";
}
[[nodiscard]]
BoundsSummary collect_bounds(const Config &config, ToolExitCode &error_code) {
mcap::McapReader reader{};
const auto open_status = reader.open(config.input_path);
if (!open_status.ok()) {
spdlog::error("failed to open MCAP file '{}': {}", config.input_path, open_status.message);
error_code = ToolExitCode::OpenError;
return {};
}
BoundsSummary summary{};
auto messages = reader.readMessages();
for (auto it = messages.begin(); it != messages.end(); ++it) {
if (it->channel == nullptr) {
spdlog::error("MCAP message missing channel metadata");
reader.close();
error_code = ToolExitCode::SchemaError;
return {};
}
if (it->schema == nullptr) {
continue;
}
if (!is_video_message(*it)) {
continue;
}
foxglove::CompressedVideo message{};
if (!message.ParseFromArray(it->message.data, static_cast<int>(it->message.dataSize))) {
spdlog::error("failed to parse foxglove.CompressedVideo payload from '{}'", config.input_path);
reader.close();
error_code = ToolExitCode::ParseError;
return {};
}
auto timestamp_ns = proto_timestamp_ns(message.timestamp());
if (timestamp_ns == 0) {
timestamp_ns = it->message.logTime;
}
summary.start_ns = std::min(summary.start_ns, timestamp_ns);
summary.end_ns = std::max(summary.end_ns, timestamp_ns);
summary.message_count += 1;
}
reader.close();
if (summary.message_count == 0) {
spdlog::error("no foxglove.CompressedVideo messages found in '{}'", config.input_path);
error_code = ToolExitCode::EmptyError;
return {};
}
error_code = ToolExitCode::Success;
return summary;
}
void print_json(const Config &config, const BoundsSummary &summary) {
std::cout
<< '{'
<< "\"input_path\":\"" << json_escape(config.input_path) << "\","
<< "\"start_ns\":" << summary.start_ns << ','
<< "\"end_ns\":" << summary.end_ns << ','
<< "\"duration_ns\":" << (summary.end_ns - summary.start_ns) << ','
<< "\"video_message_count\":" << summary.message_count << ','
<< "\"start_iso_utc\":\"" << format_iso_utc(summary.start_ns) << "\","
<< "\"end_iso_utc\":\"" << format_iso_utc(summary.end_ns) << "\""
<< "}\n";
}
void print_text(const Config &config, const BoundsSummary &summary) {
std::cout
<< config.input_path << '\t'
<< summary.start_ns << '\t'
<< summary.end_ns << '\t'
<< summary.message_count << '\t'
<< format_iso_utc(summary.start_ns) << '\t'
<< format_iso_utc(summary.end_ns)
<< '\n';
}
} // namespace
int main(int argc, char **argv) {
Config config{};
CLI::App app{"mcap_video_bounds - emit bundled video timestamp bounds from an MCAP"};
app.add_option("input", config.input_path, "Input MCAP path")->required();
app.add_flag("--json", config.json, "Emit a JSON object instead of tab-separated text");
try {
app.parse(argc, argv);
} catch (const CLI::ParseError &e) {
return app.exit(e);
}
auto error_code = ToolExitCode::Success;
const auto summary = collect_bounds(config, error_code);
if (error_code != ToolExitCode::Success) {
return exit_code(error_code);
}
if (config.json) {
print_json(config, summary);
} else {
print_text(config, summary);
}
return exit_code(ToolExitCode::Success);
}
-178
View File
@@ -1,178 +0,0 @@
#include "cvmmap_streamer/tools/zed_progress_bar.hpp"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <string>
#include <unistd.h>
namespace cvmmap_streamer::zed_tools {
namespace {
[[nodiscard]]
std::string format_duration(const double seconds_raw) {
const auto seconds = seconds_raw > 0.0 ? static_cast<long long>(std::llround(seconds_raw)) : 0ll;
const auto hours = seconds / 3600;
const auto minutes = (seconds % 3600) / 60;
const auto secs = seconds % 60;
char buffer[32]{};
if (hours > 0) {
std::snprintf(buffer, sizeof(buffer), "%02lld:%02lld:%02lld", hours, minutes, secs);
} else {
std::snprintf(buffer, sizeof(buffer), "%02lld:%02lld", minutes, secs);
}
return std::string(buffer);
}
} // namespace
bool stderr_supports_progress_bar() {
return ::isatty(STDERR_FILENO) == 1;
}
struct ProgressBar::Impl {
using Clock = std::chrono::steady_clock;
explicit Impl(const std::uint64_t total_frames_arg)
: total_frames(total_frames_arg),
enabled(stderr_supports_progress_bar()),
started_at(Clock::now()),
last_render_at(started_at) {}
void render_prefix(const double ratio, const Clock::time_point now, char *line, const std::size_t line_size) {
const auto filled = static_cast<std::size_t>(std::llround(ratio * 24.0));
std::string bar{};
bar.reserve(24);
for (std::size_t index = 0; index < 24; ++index) {
bar.push_back(index < filled ? '#' : '-');
}
const auto elapsed_seconds = std::chrono::duration<double>(now - started_at).count();
const auto eta_seconds = ratio > 0.0 ? elapsed_seconds * (1.0 - ratio) / ratio : 0.0;
std::snprintf(
line,
line_size,
"\r[%s] %6.2f%% | %s elapsed | %s ETA",
bar.c_str(),
ratio * 100.0,
format_duration(elapsed_seconds).c_str(),
format_duration(eta_seconds).c_str());
}
void render(const std::uint64_t completed_frames, const bool force) {
if (!enabled || total_frames == 0) {
return;
}
const auto now = Clock::now();
if (!force && rendered && now - last_render_at < std::chrono::milliseconds(125)) {
return;
}
last_render_at = now;
rendered = true;
const auto bounded_completed = completed_frames > total_frames ? total_frames : completed_frames;
const double ratio = static_cast<double>(bounded_completed) / static_cast<double>(total_frames);
const auto elapsed_seconds = std::chrono::duration<double>(now - started_at).count();
const auto fps = elapsed_seconds > 0.0 ? static_cast<double>(bounded_completed) / elapsed_seconds : 0.0;
char line[256]{};
render_prefix(ratio, now, line, sizeof(line));
const auto written = std::char_traits<char>::length(line);
std::snprintf(
line + written,
sizeof(line) - written,
" | %llu/%llu | %5.1f fps\x1b[K",
static_cast<unsigned long long>(bounded_completed),
static_cast<unsigned long long>(total_frames),
fps);
std::fprintf(stderr, "%s", line);
std::fflush(stderr);
}
void render_fraction(const double fraction, const std::string_view detail, const bool force) {
if (!enabled) {
return;
}
const auto now = Clock::now();
if (!force && rendered && now - last_render_at < std::chrono::milliseconds(125)) {
return;
}
last_render_at = now;
rendered = true;
const double bounded_fraction = std::clamp(fraction, 0.0, 1.0);
char line[256]{};
render_prefix(bounded_fraction, now, line, sizeof(line));
if (!detail.empty()) {
const auto written = std::char_traits<char>::length(line);
std::snprintf(line + written, sizeof(line) - written, " | %.*s\x1b[K", static_cast<int>(detail.size()), detail.data());
} else {
const auto written = std::char_traits<char>::length(line);
std::snprintf(line + written, sizeof(line) - written, "\x1b[K");
}
std::fprintf(stderr, "%s", line);
std::fflush(stderr);
}
std::uint64_t total_frames{0};
bool enabled{false};
bool rendered{false};
Clock::time_point started_at{};
Clock::time_point last_render_at{};
};
ProgressBar::ProgressBar(const std::uint64_t total_frames)
: impl_(std::make_unique<Impl>(total_frames)) {}
ProgressBar::~ProgressBar() = default;
bool ProgressBar::enabled() const {
return impl_ != nullptr && impl_->enabled;
}
void ProgressBar::update(const std::uint64_t completed_frames) {
impl_->render(completed_frames, false);
}
void ProgressBar::update_fraction(const double fraction, const std::string_view detail) {
impl_->render_fraction(fraction, detail, false);
}
void ProgressBar::finish(const std::uint64_t completed_frames, const bool success) {
if (impl_ == nullptr || !impl_->enabled) {
return;
}
if (!(success && impl_->rendered && completed_frames >= impl_->total_frames)) {
impl_->render(completed_frames, true);
if (!impl_->rendered) {
return;
}
}
std::fprintf(stderr, "%s", success ? "\n" : " [failed]\n");
std::fflush(stderr);
}
void ProgressBar::finish_fraction(const double fraction, const bool success, const std::string_view detail) {
if (impl_ == nullptr || !impl_->enabled) {
return;
}
if (!(success && impl_->rendered && fraction >= 1.0)) {
impl_->render_fraction(fraction, detail, true);
if (!impl_->rendered) {
return;
}
}
std::fprintf(stderr, "%s", success ? "\n" : " [failed]\n");
std::fflush(stderr);
}
} // namespace cvmmap_streamer::zed_tools
-728
View File
@@ -1,728 +0,0 @@
#include <CLI/CLI.hpp>
#include <spdlog/spdlog.h>
#include <sl/Camera.hpp>
#include <opencv2/core.hpp>
#include <opencv2/imgproc.hpp>
#include "cvmmap_streamer/tools/zed_progress_bar.hpp"
#include "cvmmap_streamer/tools/zed_svo_mp4_support.hpp"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <expected>
#include <filesystem>
#include <memory>
#include <optional>
#include <regex>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
namespace {
using cvmmap_streamer::zed_tools::EncodeTuning;
using cvmmap_streamer::zed_tools::Mp4Writer;
using cvmmap_streamer::zed_tools::ProgressBar;
using cvmmap_streamer::zed_tools::frame_period_ns;
using cvmmap_streamer::zed_tools::parse_codec;
using cvmmap_streamer::zed_tools::parse_encoder_device;
using cvmmap_streamer::zed_tools::parse_preset;
using cvmmap_streamer::zed_tools::parse_tune;
constexpr std::size_t kExpectedInputCount = 4;
enum class ToolExitCode : int {
Success = 0,
UsageError = 2,
RuntimeError = 3,
};
struct CliOptions {
std::vector<std::string> input_paths{};
std::string segment_dir{};
std::string output_path{};
std::string codec{"h265"};
std::string encoder_device{"auto"};
std::string preset{"fast"};
std::string tune{"low-latency"};
int quality{cvmmap_streamer::zed_tools::kDefaultQuality};
std::uint32_t gop{cvmmap_streamer::zed_tools::kDefaultGopSize};
std::uint32_t b_frames{cvmmap_streamer::zed_tools::kDefaultBFrames};
double start_offset_seconds{0.0};
double duration_seconds{0.0};
bool has_duration{false};
double output_fps{0.0};
bool has_output_fps{false};
double tile_scale{0.5};
};
struct SourceSpec {
std::filesystem::path path{};
std::string label{};
};
struct CameraStream {
SourceSpec source{};
std::unique_ptr<sl::Camera> camera{};
sl::RuntimeParameters runtime{};
sl::Mat current_frame{};
sl::Mat next_frame{};
std::uint64_t current_timestamp_ns{0};
std::uint64_t next_timestamp_ns{0};
std::uint64_t first_timestamp_ns{0};
std::uint64_t last_timestamp_ns{0};
std::uint64_t total_frames{0};
std::uint64_t nominal_frame_period_ns{0};
float fps{0.0f};
std::uint32_t width{0};
std::uint32_t height{0};
int sync_position{-1};
bool has_next{false};
};
[[nodiscard]]
constexpr int exit_code(const ToolExitCode code) {
return static_cast<int>(code);
}
[[nodiscard]]
std::string zed_string(const sl::String &value) {
return std::string(value.c_str() == nullptr ? "" : value.c_str());
}
[[nodiscard]]
std::string zed_status_string(const sl::ERROR_CODE code) {
return zed_string(sl::toString(code));
}
[[nodiscard]]
std::expected<void, std::string> validate_u8c3_mat(const sl::Mat &mat, const std::string_view label) {
if (mat.getDataType() != sl::MAT_TYPE::U8_C3) {
return std::unexpected(std::string(label) + " must be U8_C3");
}
if (mat.getWidth() == 0 || mat.getHeight() == 0) {
return std::unexpected(std::string(label) + " dimensions must be non-zero");
}
if (mat.getPtr<sl::uchar1>(sl::MEM::CPU) == nullptr) {
return std::unexpected(std::string(label) + " CPU buffer is null");
}
return {};
}
[[nodiscard]]
std::expected<std::vector<SourceSpec>, std::string> discover_segment_inputs(const std::filesystem::path &segment_dir) {
if (!std::filesystem::is_directory(segment_dir)) {
return std::unexpected("segment directory does not exist: " + segment_dir.string());
}
const std::regex pattern{R"(.*_zed([1-4])\.svo2?$)", std::regex::icase};
std::vector<std::pair<int, std::filesystem::path>> ordered_paths{};
for (const auto &entry : std::filesystem::directory_iterator{segment_dir}) {
if (!entry.is_regular_file()) {
continue;
}
std::smatch match{};
const auto filename = entry.path().filename().string();
if (!std::regex_match(filename, match, pattern)) {
continue;
}
ordered_paths.emplace_back(std::stoi(match[1].str()), entry.path());
}
std::sort(
ordered_paths.begin(),
ordered_paths.end(),
[](const auto &left, const auto &right) {
return left.first < right.first;
});
if (ordered_paths.size() != kExpectedInputCount) {
return std::unexpected(
"expected exactly 4 SVO inputs under '" + segment_dir.string() + "', found " + std::to_string(ordered_paths.size()));
}
std::vector<SourceSpec> sources{};
sources.reserve(ordered_paths.size());
for (const auto &[camera_index, path] : ordered_paths) {
sources.push_back(SourceSpec{
.path = path,
.label = "zed" + std::to_string(camera_index),
});
}
return sources;
}
[[nodiscard]]
std::expected<std::vector<SourceSpec>, std::string> resolve_sources(const CliOptions &options) {
if (!options.segment_dir.empty()) {
return discover_segment_inputs(std::filesystem::path{options.segment_dir});
}
if (options.input_paths.size() != kExpectedInputCount) {
return std::unexpected("repeat --input exactly 4 times");
}
std::vector<SourceSpec> sources{};
sources.reserve(options.input_paths.size());
for (std::size_t index = 0; index < options.input_paths.size(); ++index) {
const auto path = std::filesystem::path{options.input_paths[index]};
if (!std::filesystem::is_regular_file(path)) {
return std::unexpected("input file does not exist: " + path.string());
}
sources.push_back(SourceSpec{
.path = path,
.label = "view" + std::to_string(index + 1),
});
}
return sources;
}
[[nodiscard]]
std::filesystem::path derive_grid_output_path(const CliOptions &options, const std::vector<SourceSpec> &sources) {
if (!options.output_path.empty()) {
return std::filesystem::path{options.output_path};
}
if (!options.segment_dir.empty()) {
const auto segment_dir = std::filesystem::path{options.segment_dir};
return segment_dir / (segment_dir.filename().string() + "_grid.mp4");
}
auto output_path = sources.front().path;
output_path.replace_extension("");
output_path += "_grid.mp4";
return output_path;
}
[[nodiscard]]
std::string format_unix_timestamp(const std::uint64_t timestamp_ns) {
const auto seconds = timestamp_ns / cvmmap_streamer::zed_tools::kNanosPerSecond;
const auto milliseconds = (timestamp_ns % cvmmap_streamer::zed_tools::kNanosPerSecond) / 1'000'000ull;
return std::to_string(seconds) + "." + (milliseconds < 100 ? (milliseconds < 10 ? "00" : "0") : "") + std::to_string(milliseconds);
}
void draw_timestamp_overlay(cv::Mat &canvas, const std::uint64_t timestamp_ns) {
const auto text = format_unix_timestamp(timestamp_ns);
int baseline = 0;
const auto font_face = cv::FONT_HERSHEY_SIMPLEX;
const double font_scale = 0.8;
const int thickness = 2;
const auto text_size = cv::getTextSize(text, font_face, font_scale, thickness, &baseline);
const cv::Point origin{16, 16 + text_size.height};
const cv::Rect background{
8,
8,
text_size.width + 16,
text_size.height + baseline + 16,
};
cv::rectangle(canvas, background, cv::Scalar(0, 0, 0), cv::FILLED);
cv::putText(
canvas,
text,
origin,
font_face,
font_scale,
cv::Scalar(255, 255, 255),
thickness,
cv::LINE_AA);
}
[[nodiscard]]
std::expected<std::uint64_t, std::string> read_image_timestamp_ns(
sl::Camera &camera,
const std::optional<std::uint64_t> fallback_timestamp_ns,
const std::uint64_t nominal_frame_period_ns) {
auto timestamp_ns = camera.getTimestamp(sl::TIME_REFERENCE::IMAGE).getNanoseconds();
if (timestamp_ns == 0) {
if (!fallback_timestamp_ns) {
return std::unexpected("ZED SDK returned a zero image timestamp for the first frame");
}
timestamp_ns = *fallback_timestamp_ns + nominal_frame_period_ns;
}
return timestamp_ns;
}
[[nodiscard]]
std::expected<void, std::string> read_into_mat(
sl::Camera &camera,
sl::RuntimeParameters &runtime,
sl::Mat &target,
std::optional<std::uint64_t> fallback_timestamp_ns,
std::uint64_t nominal_frame_period_ns,
std::uint64_t &timestamp_ns_out,
const std::string_view label) {
const auto grab_status = camera.grab(runtime);
if (grab_status == sl::ERROR_CODE::END_OF_SVOFILE_REACHED) {
return std::unexpected("end-of-svo");
}
if (grab_status != sl::ERROR_CODE::SUCCESS) {
return std::unexpected("failed to grab frame for " + std::string(label) + ": " + zed_status_string(grab_status));
}
const auto image_status = camera.retrieveImage(target, sl::VIEW::LEFT_BGR, sl::MEM::CPU);
if (image_status != sl::ERROR_CODE::SUCCESS) {
return std::unexpected("failed to retrieve left image for " + std::string(label) + ": " + zed_status_string(image_status));
}
if (auto valid = validate_u8c3_mat(target, label); !valid) {
return std::unexpected(valid.error());
}
auto timestamp_ns = read_image_timestamp_ns(camera, fallback_timestamp_ns, nominal_frame_period_ns);
if (!timestamp_ns) {
return std::unexpected(timestamp_ns.error());
}
timestamp_ns_out = *timestamp_ns;
return {};
}
[[nodiscard]]
std::expected<void, std::string> fill_next_frame(CameraStream &stream) {
std::uint64_t timestamp_ns = 0;
auto next = read_into_mat(
*stream.camera,
stream.runtime,
stream.next_frame,
stream.current_timestamp_ns,
stream.nominal_frame_period_ns,
timestamp_ns,
stream.source.label);
if (!next) {
if (next.error() == "end-of-svo") {
stream.has_next = false;
return {};
}
return std::unexpected(next.error());
}
stream.next_timestamp_ns = timestamp_ns;
stream.has_next = true;
return {};
}
[[nodiscard]]
std::expected<void, std::string> promote_next_frame(CameraStream &stream) {
if (!stream.has_next) {
return std::unexpected("no buffered next frame is available for " + stream.source.label);
}
std::swap(stream.current_frame, stream.next_frame);
std::swap(stream.current_timestamp_ns, stream.next_timestamp_ns);
stream.has_next = false;
return fill_next_frame(stream);
}
[[nodiscard]]
std::expected<std::uint64_t, std::string> read_last_readable_timestamp(CameraStream &stream) {
const auto last_candidate = static_cast<int>(stream.total_frames - 1);
std::string last_error{};
for (int position = last_candidate; position >= 0; --position) {
stream.camera->setSVOPosition(position);
std::uint64_t timestamp_ns = 0;
auto frame = read_into_mat(
*stream.camera,
stream.runtime,
stream.current_frame,
std::nullopt,
stream.nominal_frame_period_ns,
timestamp_ns,
stream.source.label);
if (frame) {
const auto skipped_tail_frames = static_cast<std::uint64_t>(last_candidate - position);
if (skipped_tail_frames > 0) {
spdlog::warn(
"skipping {} unreadable tail frame(s) for {} last_error={}",
skipped_tail_frames,
stream.source.path.string(),
last_error);
}
return timestamp_ns;
}
last_error = frame.error();
}
return std::unexpected(
"failed to read any trailing frame for " + stream.source.path.string() + ": " + last_error);
}
[[nodiscard]]
std::expected<CameraStream, std::string> open_camera_stream(const SourceSpec &source) {
CameraStream stream{};
stream.source = source;
stream.camera = std::make_unique<sl::Camera>();
sl::InitParameters init{};
init.input.setFromSVOFile(source.path.c_str());
init.svo_real_time_mode = false;
init.coordinate_system = sl::COORDINATE_SYSTEM::IMAGE;
init.coordinate_units = sl::UNIT::METER;
init.depth_mode = sl::DEPTH_MODE::NONE;
init.sdk_verbose = false;
const auto open_status = stream.camera->open(init);
if (open_status != sl::ERROR_CODE::SUCCESS) {
return std::unexpected("failed to open SVO '" + source.path.string() + "': " + zed_status_string(open_status));
}
const auto total_frames = stream.camera->getSVONumberOfFrames();
if (total_frames <= 0) {
return std::unexpected("input SVO has no frames: " + source.path.string());
}
stream.total_frames = static_cast<std::uint64_t>(total_frames);
const auto camera_info = stream.camera->getCameraInformation().camera_configuration;
stream.width = static_cast<std::uint32_t>(camera_info.resolution.width);
stream.height = static_cast<std::uint32_t>(camera_info.resolution.height);
stream.fps = camera_info.fps;
stream.nominal_frame_period_ns = frame_period_ns(camera_info.fps);
if (stream.width == 0 || stream.height == 0) {
return std::unexpected("camera resolution reported by the ZED SDK is invalid for " + source.path.string());
}
std::uint64_t first_timestamp_ns = 0;
auto first_frame = read_into_mat(
*stream.camera,
stream.runtime,
stream.current_frame,
std::nullopt,
stream.nominal_frame_period_ns,
first_timestamp_ns,
source.label);
if (!first_frame) {
return std::unexpected(first_frame.error());
}
stream.first_timestamp_ns = first_timestamp_ns;
auto last_timestamp_ns = read_last_readable_timestamp(stream);
if (!last_timestamp_ns) {
return std::unexpected(last_timestamp_ns.error());
}
stream.last_timestamp_ns = *last_timestamp_ns;
return stream;
}
void close_camera_streams(std::vector<CameraStream> &streams) {
for (auto &stream : streams) {
if (stream.camera != nullptr && stream.camera->isOpened()) {
stream.camera->close();
}
}
}
} // namespace
int main(int argc, char **argv) {
CliOptions options{};
CLI::App app{"zed_svo_grid_to_mp4 - merge four synced ZED SVO/SVO2 inputs into a CCTV-style grid MP4"};
auto *input_option = app.add_option("--input", options.input_paths, "Input SVO/SVO2 file in row-major order (repeat exactly 4 times)");
auto *segment_dir_option = app.add_option("--segment-dir", options.segment_dir, "Segment directory containing *_zed[1-4].svo or *_zed[1-4].svo2 files");
input_option->excludes(segment_dir_option);
segment_dir_option->excludes(input_option);
app.add_option("--output", options.output_path, "Output MP4 file");
app.add_option("--codec", options.codec, "Video codec (h264|h265)")
->check(CLI::IsMember({"h264", "h265"}));
app.add_option("--encoder-device", options.encoder_device, "Encoder device (auto|nvidia|software)")
->check(CLI::IsMember({"auto", "nvidia", "software"}));
app.add_option("--preset", options.preset, "Encoding preset (fast|balanced|quality)")
->check(CLI::IsMember({"fast", "balanced", "quality"}));
app.add_option("--tune", options.tune, "Encoding tune (low-latency|balanced)")
->check(CLI::IsMember({"low-latency", "balanced"}));
app.add_option("--quality", options.quality, "Encoder quality target (0-51, lower is better)")
->check(CLI::Range(0, 51));
app.add_option("--gop", options.gop, "Encoder GOP length in frames")
->check(CLI::PositiveNumber);
app.add_option("--b-frames", options.b_frames, "Encoder B-frame count")
->check(CLI::NonNegativeNumber);
app.add_option("--start-offset-seconds", options.start_offset_seconds, "Offset to apply after the synced common start time in seconds")
->check(CLI::NonNegativeNumber);
auto *duration_option = app.add_option("--duration-seconds", options.duration_seconds, "Limit export duration in seconds after sync")
->check(CLI::PositiveNumber);
auto *output_fps_option = app.add_option("--output-fps", options.output_fps, "Composite output frame rate (default: max input fps)")
->check(CLI::PositiveNumber);
app.add_option("--tile-scale", options.tile_scale, "Scale each tile relative to the source resolution")
->check(CLI::Range(0.1, 1.0));
try {
app.parse(argc, argv);
} catch (const CLI::ParseError &error) {
return app.exit(error);
}
options.has_duration = duration_option->count() > 0;
options.has_output_fps = output_fps_option->count() > 0;
if (options.input_paths.empty() && options.segment_dir.empty()) {
spdlog::error("provide either --segment-dir or repeat --input exactly 4 times");
return exit_code(ToolExitCode::UsageError);
}
if (options.b_frames > options.gop) {
spdlog::error(
"invalid encoder config: b-frames {} must be <= gop {}",
options.b_frames,
options.gop);
return exit_code(ToolExitCode::UsageError);
}
auto codec = parse_codec(options.codec);
if (!codec) {
spdlog::error("{}", codec.error());
return exit_code(ToolExitCode::UsageError);
}
auto encoder_device = parse_encoder_device(options.encoder_device);
if (!encoder_device) {
spdlog::error("{}", encoder_device.error());
return exit_code(ToolExitCode::UsageError);
}
auto preset = parse_preset(options.preset);
if (!preset) {
spdlog::error("{}", preset.error());
return exit_code(ToolExitCode::UsageError);
}
auto tune = parse_tune(options.tune);
if (!tune) {
spdlog::error("{}", tune.error());
return exit_code(ToolExitCode::UsageError);
}
auto sources = resolve_sources(options);
if (!sources) {
spdlog::error("{}", sources.error());
return exit_code(ToolExitCode::UsageError);
}
const auto output_path = derive_grid_output_path(options, *sources);
if (output_path.has_parent_path()) {
std::filesystem::create_directories(output_path.parent_path());
}
const EncodeTuning tuning{
.preset = *preset,
.tune = *tune,
.quality = options.quality,
.gop = options.gop,
.b_frames = options.b_frames,
};
std::vector<CameraStream> streams{};
streams.reserve(sources->size());
for (const auto &source : *sources) {
auto stream = open_camera_stream(source);
if (!stream) {
close_camera_streams(streams);
spdlog::error("{}", stream.error());
return exit_code(ToolExitCode::RuntimeError);
}
streams.push_back(std::move(*stream));
}
const auto sync_start_ts = std::max_element(
streams.begin(),
streams.end(),
[](const auto &left, const auto &right) {
return left.first_timestamp_ns < right.first_timestamp_ns;
})->first_timestamp_ns;
const auto start_offset_ns = static_cast<std::uint64_t>(std::llround(options.start_offset_seconds * 1'000'000'000.0));
const auto effective_start_ts = sync_start_ts + start_offset_ns;
const auto common_end_ts = std::min_element(
streams.begin(),
streams.end(),
[](const auto &left, const auto &right) {
return left.last_timestamp_ns < right.last_timestamp_ns;
})->last_timestamp_ns;
const auto requested_end_exclusive_ts = options.has_duration
? effective_start_ts + static_cast<std::uint64_t>(std::llround(options.duration_seconds * 1'000'000'000.0))
: common_end_ts + 1;
const auto output_end_exclusive_ts = std::min(requested_end_exclusive_ts, common_end_ts + 1);
if (effective_start_ts >= output_end_exclusive_ts) {
close_camera_streams(streams);
spdlog::error(
"synced time window is empty: start_ts={} end_ts={}",
effective_start_ts,
output_end_exclusive_ts);
return exit_code(ToolExitCode::UsageError);
}
std::uint32_t source_width = streams.front().width;
std::uint32_t source_height = streams.front().height;
float max_input_fps = streams.front().fps;
for (const auto &stream : streams) {
if (stream.width != source_width || stream.height != source_height) {
close_camera_streams(streams);
spdlog::error(
"all inputs must share the same resolution: expected {}x{}, got {}x{} for {}",
source_width,
source_height,
stream.width,
stream.height,
stream.source.path.string());
return exit_code(ToolExitCode::UsageError);
}
max_input_fps = std::max(max_input_fps, stream.fps);
}
const auto output_fps = options.has_output_fps ? static_cast<float>(options.output_fps) : max_input_fps;
const auto output_period_ns = frame_period_ns(output_fps);
const auto total_frames_to_emit =
static_cast<std::uint64_t>((output_end_exclusive_ts - effective_start_ts + output_period_ns - 1) / output_period_ns);
for (auto &stream : streams) {
stream.sync_position = stream.camera->getSVOPositionAtTimestamp(sl::Timestamp{effective_start_ts});
if (stream.sync_position < 0) {
close_camera_streams(streams);
spdlog::error(
"failed to compute synced start frame for {} at timestamp {}",
stream.source.path.string(),
effective_start_ts);
return exit_code(ToolExitCode::RuntimeError);
}
stream.camera->setSVOPosition(stream.sync_position);
std::uint64_t current_timestamp_ns = 0;
auto current = read_into_mat(
*stream.camera,
stream.runtime,
stream.current_frame,
std::nullopt,
stream.nominal_frame_period_ns,
current_timestamp_ns,
stream.source.label);
if (!current) {
close_camera_streams(streams);
spdlog::error("{}", current.error());
return exit_code(ToolExitCode::RuntimeError);
}
stream.current_timestamp_ns = current_timestamp_ns;
auto next = fill_next_frame(stream);
if (!next) {
close_camera_streams(streams);
spdlog::error("{}", next.error());
return exit_code(ToolExitCode::RuntimeError);
}
while (stream.current_timestamp_ns < effective_start_ts && stream.has_next) {
auto promote = promote_next_frame(stream);
if (!promote) {
close_camera_streams(streams);
spdlog::error("{}", promote.error());
return exit_code(ToolExitCode::RuntimeError);
}
}
spdlog::info(
"ZED_SVO_GRID_SYNC input={} label={} sync_position={} first_timestamp_ns={} current_timestamp_ns={} next_timestamp_ns={}",
stream.source.path.string(),
stream.source.label,
stream.sync_position,
stream.first_timestamp_ns,
stream.current_timestamp_ns,
stream.has_next ? stream.next_timestamp_ns : 0);
}
const auto tile_width = static_cast<int>(std::llround(static_cast<double>(source_width) * options.tile_scale));
const auto tile_height = static_cast<int>(std::llround(static_cast<double>(source_height) * options.tile_scale));
if (tile_width <= 0 || tile_height <= 0) {
close_camera_streams(streams);
spdlog::error("tile-scale {} produced invalid tile dimensions", options.tile_scale);
return exit_code(ToolExitCode::UsageError);
}
const auto composite_width = tile_width * 2;
const auto composite_height = tile_height * 2;
Mp4Writer writer{};
if (auto open_writer = writer.open(
output_path,
*codec,
*encoder_device,
static_cast<std::uint32_t>(composite_width),
static_cast<std::uint32_t>(composite_height),
output_fps,
tuning);
!open_writer) {
close_camera_streams(streams);
spdlog::error("failed to initialize MP4 writer: {}", open_writer.error());
return exit_code(ToolExitCode::RuntimeError);
}
cv::Mat composite(composite_height, composite_width, CV_8UC3);
std::vector<cv::Mat> resized_tiles(streams.size());
ProgressBar progress{total_frames_to_emit};
for (std::uint64_t emitted_frames = 0; emitted_frames < total_frames_to_emit; ++emitted_frames) {
const auto target_timestamp_ns = effective_start_ts + emitted_frames * output_period_ns;
if (target_timestamp_ns >= output_end_exclusive_ts) {
break;
}
for (auto &stream : streams) {
while (stream.has_next && stream.next_timestamp_ns <= target_timestamp_ns) {
auto promote = promote_next_frame(stream);
if (!promote) {
progress.finish(emitted_frames, false);
close_camera_streams(streams);
spdlog::error("{}", promote.error());
return exit_code(ToolExitCode::RuntimeError);
}
}
}
composite.setTo(cv::Scalar(0, 0, 0));
for (std::size_t index = 0; index < streams.size(); ++index) {
auto &stream = streams[index];
cv::Mat source_view(
static_cast<int>(stream.current_frame.getHeight()),
static_cast<int>(stream.current_frame.getWidth()),
CV_8UC3,
stream.current_frame.getPtr<sl::uchar1>(sl::MEM::CPU),
stream.current_frame.getStepBytes(sl::MEM::CPU));
cv::resize(source_view, resized_tiles[index], cv::Size(tile_width, tile_height), 0.0, 0.0, cv::INTER_AREA);
const int row = static_cast<int>(index / 2);
const int col = static_cast<int>(index % 2);
const cv::Rect roi{col * tile_width, row * tile_height, tile_width, tile_height};
resized_tiles[index].copyTo(composite(roi));
}
draw_timestamp_overlay(composite, target_timestamp_ns);
if (auto write = writer.write_bgr_frame(
composite.data,
static_cast<std::size_t>(composite.step),
target_timestamp_ns - effective_start_ts);
!write) {
progress.finish(emitted_frames, false);
close_camera_streams(streams);
spdlog::error("failed to encode or mux frame: {}", write.error());
return exit_code(ToolExitCode::RuntimeError);
}
progress.update(emitted_frames + 1);
}
if (auto flush = writer.flush(); !flush) {
progress.finish(total_frames_to_emit, false);
close_camera_streams(streams);
spdlog::error("failed to finalize MP4 output: {}", flush.error());
return exit_code(ToolExitCode::RuntimeError);
}
progress.finish(total_frames_to_emit, true);
close_camera_streams(streams);
spdlog::info(
"converted {} synced frames to '{}' using codec={} hardware={}",
total_frames_to_emit,
output_path.string(),
cvmmap_streamer::zed_tools::codec_name(*codec),
writer.using_hardware());
return exit_code(ToolExitCode::Success);
}
File diff suppressed because it is too large Load Diff
-320
View File
@@ -1,320 +0,0 @@
#include <CLI/CLI.hpp>
#include <spdlog/spdlog.h>
#include <sl/Camera.hpp>
#include "cvmmap_streamer/tools/zed_progress_bar.hpp"
#include "cvmmap_streamer/tools/zed_svo_mp4_support.hpp"
#include <cstdint>
#include <expected>
#include <filesystem>
#include <optional>
#include <string>
#include <utility>
namespace {
using cvmmap_streamer::zed_tools::EncodeTuning;
using cvmmap_streamer::zed_tools::Mp4Writer;
using cvmmap_streamer::zed_tools::ProgressBar;
using cvmmap_streamer::zed_tools::derive_output_path;
using cvmmap_streamer::zed_tools::frame_period_ns;
using cvmmap_streamer::zed_tools::parse_codec;
using cvmmap_streamer::zed_tools::parse_encoder_device;
using cvmmap_streamer::zed_tools::parse_preset;
using cvmmap_streamer::zed_tools::parse_tune;
enum class ToolExitCode : int {
Success = 0,
UsageError = 2,
RuntimeError = 3,
};
struct CliOptions {
std::string input_path{};
std::string output_path{};
std::string codec{"h265"};
std::string encoder_device{"auto"};
std::string preset{"fast"};
std::string tune{"low-latency"};
int quality{cvmmap_streamer::zed_tools::kDefaultQuality};
std::uint32_t gop{cvmmap_streamer::zed_tools::kDefaultGopSize};
std::uint32_t b_frames{cvmmap_streamer::zed_tools::kDefaultBFrames};
std::uint32_t start_frame{0};
std::uint32_t end_frame{0};
bool has_end_frame{false};
};
[[nodiscard]]
constexpr int exit_code(const ToolExitCode code) {
return static_cast<int>(code);
}
[[nodiscard]]
std::string zed_string(const sl::String &value) {
return std::string(value.c_str() == nullptr ? "" : value.c_str());
}
[[nodiscard]]
std::string zed_status_string(const sl::ERROR_CODE code) {
return zed_string(sl::toString(code));
}
[[nodiscard]]
std::expected<void, std::string> validate_u8c3_mat(const sl::Mat &mat, const std::string_view label) {
if (mat.getDataType() != sl::MAT_TYPE::U8_C3) {
return std::unexpected(std::string(label) + " must be U8_C3");
}
if (mat.getWidth() == 0 || mat.getHeight() == 0) {
return std::unexpected(std::string(label) + " dimensions must be non-zero");
}
if (mat.getPtr<sl::uchar1>(sl::MEM::CPU) == nullptr) {
return std::unexpected(std::string(label) + " CPU buffer is null");
}
return {};
}
} // namespace
int main(int argc, char **argv) {
CliOptions options{};
CLI::App app{"zed_svo_to_mp4 - convert ZED SVO/SVO2 playback to MP4"};
app.add_option("--input", options.input_path, "Input SVO/SVO2 file")->required();
app.add_option("--output", options.output_path, "Output MP4 file (default: input path with .mp4 suffix)");
app.add_option("--codec", options.codec, "Video codec (h264|h265)")
->check(CLI::IsMember({"h264", "h265"}));
app.add_option("--encoder-device", options.encoder_device, "Encoder device (auto|nvidia|software)")
->check(CLI::IsMember({"auto", "nvidia", "software"}));
app.add_option("--preset", options.preset, "Encoding preset (fast|balanced|quality)")
->check(CLI::IsMember({"fast", "balanced", "quality"}));
app.add_option("--tune", options.tune, "Encoding tune (low-latency|balanced)")
->check(CLI::IsMember({"low-latency", "balanced"}));
app.add_option("--quality", options.quality, "Encoder quality target (0-51, lower is better)")
->check(CLI::Range(0, 51));
app.add_option("--gop", options.gop, "Encoder GOP length in frames")
->check(CLI::PositiveNumber);
app.add_option("--b-frames", options.b_frames, "Encoder B-frame count")
->check(CLI::NonNegativeNumber);
app.add_option("--start-frame", options.start_frame, "First SVO frame to export (inclusive)")
->check(CLI::NonNegativeNumber);
auto *end_frame_option = app.add_option("--end-frame", options.end_frame, "Last SVO frame to export (inclusive)")
->check(CLI::NonNegativeNumber);
try {
app.parse(argc, argv);
} catch (const CLI::ParseError &error) {
return app.exit(error);
}
options.has_end_frame = end_frame_option->count() > 0;
auto codec = parse_codec(options.codec);
if (!codec) {
spdlog::error("{}", codec.error());
return exit_code(ToolExitCode::UsageError);
}
auto encoder_device = parse_encoder_device(options.encoder_device);
if (!encoder_device) {
spdlog::error("{}", encoder_device.error());
return exit_code(ToolExitCode::UsageError);
}
auto preset = parse_preset(options.preset);
if (!preset) {
spdlog::error("{}", preset.error());
return exit_code(ToolExitCode::UsageError);
}
auto tune = parse_tune(options.tune);
if (!tune) {
spdlog::error("{}", tune.error());
return exit_code(ToolExitCode::UsageError);
}
if (options.has_end_frame && options.end_frame < options.start_frame) {
spdlog::error(
"invalid frame range: start-frame={} end-frame={}",
options.start_frame,
options.end_frame);
return exit_code(ToolExitCode::UsageError);
}
if (options.b_frames > options.gop) {
spdlog::error(
"invalid encoder config: b-frames {} must be <= gop {}",
options.b_frames,
options.gop);
return exit_code(ToolExitCode::UsageError);
}
const auto output_path = options.output_path.empty()
? derive_output_path(std::filesystem::path{options.input_path})
: std::filesystem::path{options.output_path};
if (output_path.empty()) {
spdlog::error("output path must not be empty");
return exit_code(ToolExitCode::UsageError);
}
if (output_path.has_parent_path()) {
std::filesystem::create_directories(output_path.parent_path());
}
const EncodeTuning tuning{
.preset = *preset,
.tune = *tune,
.quality = options.quality,
.gop = options.gop,
.b_frames = options.b_frames,
};
sl::Camera camera{};
auto close_camera = [&]() {
if (camera.isOpened()) {
camera.close();
}
};
sl::InitParameters init{};
init.input.setFromSVOFile(options.input_path.c_str());
init.svo_real_time_mode = false;
init.coordinate_system = sl::COORDINATE_SYSTEM::IMAGE;
init.coordinate_units = sl::UNIT::METER;
init.depth_mode = sl::DEPTH_MODE::NONE;
init.sdk_verbose = false;
const auto open_status = camera.open(init);
if (open_status != sl::ERROR_CODE::SUCCESS) {
spdlog::error(
"failed to open SVO '{}': {}",
options.input_path,
zed_status_string(open_status));
return exit_code(ToolExitCode::RuntimeError);
}
const auto total_frames = camera.getSVONumberOfFrames();
if (total_frames <= 0) {
close_camera();
spdlog::error("input SVO has no frames");
return exit_code(ToolExitCode::RuntimeError);
}
if (options.start_frame >= static_cast<std::uint32_t>(total_frames)) {
close_camera();
spdlog::error(
"start-frame {} is out of range for {} frames",
options.start_frame,
total_frames);
return exit_code(ToolExitCode::UsageError);
}
if (options.has_end_frame && options.end_frame >= static_cast<std::uint32_t>(total_frames)) {
close_camera();
spdlog::error(
"end-frame {} is out of range for {} frames",
options.end_frame,
total_frames);
return exit_code(ToolExitCode::UsageError);
}
camera.setSVOPosition(static_cast<int>(options.start_frame));
const auto camera_info = camera.getCameraInformation();
const auto &camera_config = camera_info.camera_configuration;
const auto width = static_cast<std::uint32_t>(camera_config.resolution.width);
const auto height = static_cast<std::uint32_t>(camera_config.resolution.height);
if (width == 0 || height == 0) {
close_camera();
spdlog::error("camera resolution reported by the ZED SDK is invalid");
return exit_code(ToolExitCode::RuntimeError);
}
Mp4Writer writer{};
if (auto open_writer = writer.open(output_path, *codec, *encoder_device, width, height, camera_config.fps, tuning); !open_writer) {
close_camera();
spdlog::error("failed to initialize MP4 writer: {}", open_writer.error());
return exit_code(ToolExitCode::RuntimeError);
}
sl::RuntimeParameters runtime_parameters{};
sl::Mat left_frame{};
std::optional<std::uint64_t> first_timestamp_ns{};
std::optional<std::uint64_t> last_timestamp_ns{};
std::uint64_t emitted_frames{0};
const auto nominal_frame_period_ns = frame_period_ns(camera_config.fps);
const auto last_frame = options.has_end_frame
? options.end_frame
: static_cast<std::uint32_t>(total_frames - 1);
const auto total_frames_to_emit = static_cast<std::uint64_t>(last_frame - options.start_frame + 1);
ProgressBar progress{total_frames_to_emit};
while (options.start_frame + emitted_frames <= last_frame) {
const auto grab_status = camera.grab(runtime_parameters);
if (grab_status == sl::ERROR_CODE::END_OF_SVOFILE_REACHED) {
break;
}
if (grab_status != sl::ERROR_CODE::SUCCESS) {
progress.finish(emitted_frames, false);
close_camera();
spdlog::error("failed to grab SVO frame: {}", zed_status_string(grab_status));
return exit_code(ToolExitCode::RuntimeError);
}
const auto image_status = camera.retrieveImage(left_frame, sl::VIEW::LEFT_BGR, sl::MEM::CPU);
if (image_status != sl::ERROR_CODE::SUCCESS) {
progress.finish(emitted_frames, false);
close_camera();
spdlog::error("failed to retrieve left image: {}", zed_status_string(image_status));
return exit_code(ToolExitCode::RuntimeError);
}
if (auto valid = validate_u8c3_mat(left_frame, "left image"); !valid) {
progress.finish(emitted_frames, false);
close_camera();
spdlog::error("{}", valid.error());
return exit_code(ToolExitCode::RuntimeError);
}
auto timestamp_ns = camera.getTimestamp(sl::TIME_REFERENCE::IMAGE).getNanoseconds();
if (timestamp_ns == 0) {
timestamp_ns = emitted_frames * nominal_frame_period_ns;
}
if (last_timestamp_ns && timestamp_ns <= *last_timestamp_ns) {
timestamp_ns = *last_timestamp_ns + 1;
}
last_timestamp_ns = timestamp_ns;
if (!first_timestamp_ns) {
first_timestamp_ns = timestamp_ns;
}
const auto relative_timestamp_ns = timestamp_ns - *first_timestamp_ns;
if (auto write = writer.write_bgr_frame(
left_frame.getPtr<sl::uchar1>(sl::MEM::CPU),
left_frame.getStepBytes(sl::MEM::CPU),
relative_timestamp_ns);
!write) {
progress.finish(emitted_frames, false);
close_camera();
spdlog::error("failed to encode or mux frame: {}", write.error());
return exit_code(ToolExitCode::RuntimeError);
}
emitted_frames += 1;
progress.update(emitted_frames);
}
if (auto flush = writer.flush(); !flush) {
progress.finish(emitted_frames, false);
close_camera();
spdlog::error("failed to finalize MP4 output: {}", flush.error());
return exit_code(ToolExitCode::RuntimeError);
}
progress.finish(emitted_frames, true);
close_camera();
spdlog::info(
"converted {} frames from '{}' to '{}' using codec={} hardware={}",
emitted_frames,
options.input_path,
output_path.string(),
cvmmap_streamer::zed_tools::codec_name(*codec),
writer.using_hardware());
return exit_code(ToolExitCode::Success);
}
-268
View File
@@ -1,268 +0,0 @@
from __future__ import annotations
import dataclasses
import tempfile
import unittest
from pathlib import Path
import click
from click.testing import CliRunner
from scripts import zed_batch_segment_sources as segment_sources
from scripts.zed_batch_svo_grid_to_mp4 import main as grid_main
from scripts.zed_batch_svo_to_mcap import main as mcap_main
@dataclasses.dataclass(slots=True, frozen=True)
class FakeScan:
segment_dir: Path
matched_files: int
is_valid: bool
reason: str | None = None
def fake_scan(segment_dir: Path) -> FakeScan:
if not segment_dir.is_dir():
return FakeScan(segment_dir=segment_dir, matched_files=0, is_valid=False, reason="missing directory")
if (segment_dir / "valid.segment").is_file():
return FakeScan(segment_dir=segment_dir, matched_files=2, is_valid=True)
if (segment_dir / "partial.segment").is_file():
return FakeScan(segment_dir=segment_dir, matched_files=1, is_valid=False, reason="partial segment")
return FakeScan(segment_dir=segment_dir, matched_files=0, is_valid=False, reason="no camera files")
def create_multicamera_segment(parent: Path, segment_name: str) -> Path:
segment_dir = parent / segment_name
segment_dir.mkdir(parents=True)
for camera_index in range(1, 5):
(segment_dir / f"{segment_name}_zed{camera_index}.svo2").write_bytes(b"")
return segment_dir
class SharedSourceResolutionTests(unittest.TestCase):
def test_dataset_root_recursive_discovers_nested_segments(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
dataset_root = Path(tmp) / "dataset"
segment_dir = dataset_root / "run" / "2026-04-08T11-50-32"
segment_dir.mkdir(parents=True)
(segment_dir / "valid.segment").write_text("", encoding="utf-8")
sources = segment_sources.resolve_sources(
dataset_root,
(),
None,
None,
True,
scan_segment_dir=fake_scan,
no_matches_message=lambda root: f"no segments under {root}",
)
self.assertEqual(sources.mode, "dataset-root")
self.assertEqual(sources.segment_dirs, (segment_dir.resolve(),))
def test_dataset_root_without_recursive_does_not_descend(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
dataset_root = Path(tmp) / "dataset"
segment_dir = dataset_root / "run" / "2026-04-08T11-50-32"
segment_dir.mkdir(parents=True)
(segment_dir / "valid.segment").write_text("", encoding="utf-8")
with self.assertRaises(click.ClickException) as error:
segment_sources.resolve_sources(
dataset_root,
(),
None,
None,
False,
scan_segment_dir=fake_scan,
no_matches_message=lambda root: f"no segments under {root}",
)
self.assertIn("no segments under", str(error.exception))
def test_explicit_segments_are_deduped(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
segment_dir = Path(tmp) / "2026-04-08T11-50-32"
segment_dir.mkdir()
(segment_dir / "valid.segment").write_text("", encoding="utf-8")
sources = segment_sources.resolve_sources(
None,
(segment_dir, segment_dir),
None,
None,
True,
scan_segment_dir=fake_scan,
no_matches_message=lambda root: f"no segments under {root}",
)
self.assertEqual(sources.mode, "segments")
self.assertEqual(sources.segment_dirs, (segment_dir.resolve(),))
def test_segments_csv_uses_segment_dir_column(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
segment_dir = tmp_path / "segments" / "2026-04-08T11-50-32"
segment_dir.mkdir(parents=True)
(segment_dir / "valid.segment").write_text("", encoding="utf-8")
csv_path = tmp_path / "segments.csv"
csv_path.write_text("segment_dir\nsegments/2026-04-08T11-50-32\n", encoding="utf-8")
sources = segment_sources.resolve_sources(
None,
(),
csv_path,
None,
True,
scan_segment_dir=fake_scan,
no_matches_message=lambda root: f"no segments under {root}",
)
self.assertEqual(sources.mode, "segments-csv")
self.assertEqual(sources.segment_dirs, (segment_dir.resolve(),))
def test_segment_path_like_dataset_root_has_hint(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
dataset_root = Path(tmp) / "dataset"
segment_dir = dataset_root / "run" / "2026-04-08T11-50-32"
segment_dir.mkdir(parents=True)
(segment_dir / "valid.segment").write_text("", encoding="utf-8")
with self.assertRaises(click.ClickException) as error:
segment_sources.resolve_sources(
None,
(dataset_root,),
None,
None,
True,
scan_segment_dir=fake_scan,
no_matches_message=lambda root: f"no segments under {root}",
)
message = str(error.exception)
self.assertIn("looks like a dataset root", message)
self.assertIn("--dataset-root", message)
class BatchCliSmokeTests(unittest.TestCase):
def setUp(self) -> None:
self.runner = CliRunner()
def test_mcap_dataset_root_flag_discovers_segments(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
dataset_root = Path(tmp) / "dataset"
create_multicamera_segment(dataset_root / "run", "2026-04-08T11-50-32")
result = self.runner.invoke(
mcap_main,
[
"--dataset-root",
str(dataset_root),
"--recursive",
"--dry-run",
"--zed-bin",
"/bin/true",
],
)
self.assertEqual(result.exit_code, 0, result.output)
self.assertIn("source=dataset-root matched=1 pending=1", result.output)
def test_mcap_segment_flag_rejects_dataset_root_with_hint(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
dataset_root = Path(tmp) / "dataset"
create_multicamera_segment(dataset_root / "run", "2026-04-08T11-50-32")
result = self.runner.invoke(
mcap_main,
[
"--segment",
str(dataset_root),
"--dry-run",
"--zed-bin",
"/bin/true",
],
)
self.assertNotEqual(result.exit_code, 0)
self.assertIn("looks like a dataset root", result.output)
self.assertIn("--dataset-root", result.output)
def test_mcap_rejects_legacy_positional_dataset_root(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
dataset_root = Path(tmp) / "dataset"
create_multicamera_segment(dataset_root / "run", "2026-04-08T11-50-32")
result = self.runner.invoke(
mcap_main,
[
str(dataset_root),
"--dry-run",
"--zed-bin",
"/bin/true",
],
)
self.assertNotEqual(result.exit_code, 0)
self.assertIn("positional dataset paths are no longer supported", result.output)
self.assertIn("--dataset-root", result.output)
def test_mcap_rejects_recursive_without_dataset_root(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
segment_dir = create_multicamera_segment(Path(tmp), "2026-04-08T11-50-32")
result = self.runner.invoke(
mcap_main,
[
"--segment",
str(segment_dir),
"--no-recursive",
"--dry-run",
"--zed-bin",
"/bin/true",
],
)
self.assertNotEqual(result.exit_code, 0)
self.assertIn("--recursive/--no-recursive can only be used with --dataset-root", result.output)
def test_grid_segment_flag_discovers_one_segment(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
segment_dir = create_multicamera_segment(Path(tmp), "2026-04-08T11-50-32")
result = self.runner.invoke(
grid_main,
[
"--segment",
str(segment_dir),
"--dry-run",
"--zed-bin",
"/bin/true",
],
)
self.assertEqual(result.exit_code, 0, result.output)
self.assertIn("source=segments matched=1 pending=1", result.output)
def test_grid_rejects_legacy_segment_dir_flag(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
segment_dir = create_multicamera_segment(Path(tmp), "2026-04-08T11-50-32")
result = self.runner.invoke(
grid_main,
[
"--segment-dir",
str(segment_dir),
"--dry-run",
"--zed-bin",
"/bin/true",
],
)
self.assertNotEqual(result.exit_code, 0)
self.assertIn("--segment-dir is no longer supported", result.output)
self.assertIn("--segment", result.output)
if __name__ == "__main__":
unittest.main()
-139
View File
@@ -1,139 +0,0 @@
from __future__ import annotations
import datetime as dt
import tempfile
import unittest
from pathlib import Path
import duckdb
from scripts.zed_segment_time_index import (
BoundsRow,
format_ns_iso,
infer_dataset_timezone,
parse_timestamp_to_ns,
parse_timestamp_window,
require_query_window,
scan_segment_dir,
write_index,
)
class TimestampParseTests(unittest.TestCase):
def test_parse_folder_style_timestamp(self) -> None:
actual = parse_timestamp_to_ns("2026-03-18T12-00-23", "UTC")
expected = parse_timestamp_to_ns("2026-03-18T12:00:23+00:00", "UTC")
self.assertEqual(actual, expected)
def test_parse_integer_epoch_milliseconds(self) -> None:
self.assertEqual(parse_timestamp_to_ns("1710000000123", "UTC"), 1710000000123 * 1_000_000)
def test_parse_timestamp_window_for_second_precision_text(self) -> None:
start_ns, end_ns = parse_timestamp_window("2026-03-18T12-00-23", "UTC")
self.assertEqual(end_ns - start_ns, 999_999_999)
def test_require_query_window_rejects_mixed_modes(self) -> None:
with self.assertRaises(Exception):
require_query_window("1", "2", "3", "UTC")
def test_format_ns_iso_utc(self) -> None:
rendered = format_ns_iso(1_710_000_000_123_000_000, dt.timezone.utc)
self.assertTrue(rendered.startswith("2024-03-09T16:00:00.123000000"))
class SegmentDiscoveryTests(unittest.TestCase):
def test_scan_segment_dir_accepts_multicamera_dir(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
segment_dir = Path(tmp)
for label in ("zed1", "zed2", "zed3", "zed4"):
(segment_dir / f"2026-03-18T12-00-23_{label}.svo2").write_bytes(b"")
scan = scan_segment_dir(segment_dir)
self.assertTrue(scan.is_valid)
self.assertEqual(scan.camera_labels, ("zed1", "zed2", "zed3", "zed4"))
def test_scan_segment_dir_rejects_partial_dir(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
segment_dir = Path(tmp)
(segment_dir / "2026-03-18T12-00-23_zed1.svo2").write_bytes(b"")
scan = scan_segment_dir(segment_dir)
self.assertFalse(scan.is_valid)
class DuckDbIndexTests(unittest.TestCase):
def test_infer_dataset_timezone_from_folder_names(self) -> None:
row = BoundsRow(
segment_dir=Path("/tmp/bar/2026-03-18T11-59-41"),
relative_segment_dir="bar/2026-03-18T11-59-41",
group_path="bar",
activity="bar",
segment_name="2026-03-18T11-59-41",
mcap_path=Path("/tmp/bar/2026-03-18T11-59-41/2026-03-18T11-59-41.mcap"),
start_ns=1_773_806_381_201_081_000,
end_ns=1_773_806_392_268_226_000,
duration_ns=11_067_145_000,
start_iso_utc="2026-03-18T03:59:41.201081000Z",
end_iso_utc="2026-03-18T03:59:52.268226000Z",
camera_count=4,
camera_labels="zed1,zed2,zed3,zed4",
video_message_count=1330,
index_source="mcap_video_bounds",
)
self.assertEqual(infer_dataset_timezone([row]), "UTC+08:00")
def test_write_index_and_query_overlap(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / "dataset"
root.mkdir()
index_path = root / "segment_time_index.duckdb"
rows = [
BoundsRow(
segment_dir=root / "bar" / "2026-03-18T12-00-23",
relative_segment_dir="bar/2026-03-18T12-00-23",
group_path="bar",
activity="bar",
segment_name="2026-03-18T12-00-23",
mcap_path=root / "bar" / "2026-03-18T12-00-23" / "2026-03-18T12-00-23.mcap",
start_ns=100,
end_ns=200,
duration_ns=100,
start_iso_utc="1970-01-01T00:00:00.000000100Z",
end_iso_utc="1970-01-01T00:00:00.000000200Z",
camera_count=4,
camera_labels="zed1,zed2,zed3,zed4",
video_message_count=1330,
index_source="mcap_video_bounds",
),
BoundsRow(
segment_dir=root / "run" / "2026-03-18T12-01-00",
relative_segment_dir="run/2026-03-18T12-01-00",
group_path="run",
activity="run",
segment_name="2026-03-18T12-01-00",
mcap_path=root / "run" / "2026-03-18T12-01-00" / "2026-03-18T12-01-00.mcap",
start_ns=250,
end_ns=400,
duration_ns=150,
start_iso_utc="1970-01-01T00:00:00.000000250Z",
end_iso_utc="1970-01-01T00:00:00.000000400Z",
camera_count=4,
camera_labels="zed1,zed2,zed3,zed4",
video_message_count=1400,
index_source="mcap_video_bounds",
),
]
write_index(index_path, root, rows)
conn = duckdb.connect(str(index_path), read_only=True)
try:
matches = conn.execute(
"SELECT relative_segment_dir FROM segments WHERE start_ns <= ? AND end_ns >= ? ORDER BY start_ns",
[300, 180],
).fetchall()
self.assertEqual(matches, [("bar/2026-03-18T12-00-23",), ("run/2026-03-18T12-01-00",)])
finally:
conn.close()
if __name__ == "__main__":
unittest.main()
Generated
+302 -302
View File
@@ -10,22 +10,22 @@ resolution-markers = [
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
@@ -35,8 +35,8 @@ source = { virtual = "." }
dependencies = [
{ name = "click" },
{ name = "duckdb" },
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "numpy", version = "2.2.6", source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" }, marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "2.4.3", source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" }, marker = "python_full_version >= '3.11'" },
{ name = "opencv-python-headless" },
{ name = "progress-table" },
{ name = "protobuf" },
@@ -68,260 +68,260 @@ provides-extras = ["viewer"]
[[package]]
name = "dearpygui"
version = "2.2"
source = { registry = "https://pypi.org/simple" }
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/71/114626e9b77b07b2d5d92e0030b00b4a78e73de1212cbe63656af3da636e/dearpygui-2.2-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:9805b99abcdf89b18c6877cfd4865f844398e1c555316d2f7347b1e8e62f29fd", size = 1931334, upload-time = "2026-02-17T14:21:51.362Z" },
{ url = "https://files.pythonhosted.org/packages/28/f5/dbd692d64a27c94d7bf4f05b87a4bd74bcd61699248a7fb1166635cef17a/dearpygui-2.2-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:8b42ebd0a73ddf03ab5fb0777636216035716089ae449f904fe37ccebbed0061", size = 2592856, upload-time = "2026-02-17T14:22:00.223Z" },
{ url = "https://files.pythonhosted.org/packages/58/e0/4be23bd80453b5ee216319a1f2005b57a7c25d00872056f7a96a0a21ef4e/dearpygui-2.2-cp310-cp310-win_amd64.whl", hash = "sha256:9872af7c4d1c7f8b4f1031c1c333ff83c778332674ac3d54178fa7ca0230c6ab", size = 1830505, upload-time = "2026-02-17T14:21:40.74Z" },
{ url = "https://files.pythonhosted.org/packages/b7/80/c62a26549688a9a2251fede8c1ba10f5e41964a4bb97dba486bcb1e0be28/dearpygui-2.2-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:a2dbbd975e1dbdf4688ef49b95651192b6417c8722e470b9ad2b7f5029555c63", size = 1931280, upload-time = "2026-02-17T14:21:52.98Z" },
{ url = "https://files.pythonhosted.org/packages/01/a1/6c40624fcaa0ea429aa2b6906b19c639175de0677b2af52f00c2794a56ce/dearpygui-2.2-cp311-cp311-manylinux1_x86_64.whl", hash = "sha256:87c16bc00b94ee748c8c156c10f353b7f0b6e843ecec54121cb3b9f254abf940", size = 2592871, upload-time = "2026-02-17T14:22:01.806Z" },
{ url = "https://files.pythonhosted.org/packages/58/ca/3683b74526a869403ca63bac33c47c8d1bbabe57d186eb33490b5d18459a/dearpygui-2.2-cp311-cp311-win_amd64.whl", hash = "sha256:d5a38e58a03a41e09915f9b026759899d772d32e920bcd114d1b3f344946e0f0", size = 1830497, upload-time = "2026-02-17T14:21:42.108Z" },
{ url = "https://files.pythonhosted.org/packages/17/c8/b4afdac89c7bf458513366af3143f7383d7b09721637989c95788d93e24c/dearpygui-2.2-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:34ceae1ca1b65444e49012d6851312e44f08713da1b8cc0150cf41f1c207af9c", size = 1931443, upload-time = "2026-02-17T14:21:54.394Z" },
{ url = "https://files.pythonhosted.org/packages/43/93/a2d083b2e0edb095be815662cc41e40cf9ea7b65d6323e47bb30df7eb284/dearpygui-2.2-cp312-cp312-manylinux1_x86_64.whl", hash = "sha256:e1fae9ae59fec0e41773df64c80311a6ba67696219dde5506a2a4c013e8bcdfa", size = 2592645, upload-time = "2026-02-17T14:22:02.869Z" },
{ url = "https://files.pythonhosted.org/packages/80/ba/eae13acaad479f522db853e8b1ccd695a7bc8da2b9685c1d70a3b318df89/dearpygui-2.2-cp312-cp312-win_amd64.whl", hash = "sha256:7d399543b5a26ab6426ef3bbd776e55520b491b3e169647bde5e6b2de3701b35", size = 1830531, upload-time = "2026-02-17T14:21:43.386Z" },
{ url = "https://files.pythonhosted.org/packages/18/ab/eb8070ca8fd881d4a9ac49fca5fb7b54ce66cc2742afa38e59d72b2c2dec/dearpygui-2.2-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:084c309c56d3e05fcf75eef872df6df97f5e3e19da5ecad393a57cf7a5e56294", size = 1931423, upload-time = "2026-02-17T14:21:56.397Z" },
{ url = "https://files.pythonhosted.org/packages/bc/03/5988d5f4cf3ddc7c3d886623bb904b76c5f5f628a0256ac53d848df33cf7/dearpygui-2.2-cp313-cp313-manylinux1_x86_64.whl", hash = "sha256:05d8c18a0134d72f680e333c80ccab264351170293f86a05f5a0e14222992f27", size = 2592542, upload-time = "2026-02-17T14:22:03.949Z" },
{ url = "https://files.pythonhosted.org/packages/6e/5a/573df5f7277a13b5044daa9a27797fbd4e766da03cab6462a151b557727c/dearpygui-2.2-cp313-cp313-win_amd64.whl", hash = "sha256:500087e88d61b4ef0c841f30b12a05f5128774db3883fde7ff7c6172f03f6d79", size = 1830558, upload-time = "2026-02-17T14:21:44.551Z" },
{ url = "https://files.pythonhosted.org/packages/8b/76/3ccaec465021b647f13c83be42a635043a08255076984a658ed691701498/dearpygui-2.2-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:22451146968729429ba37afa2602957dfefc03ff92dcc627dd4d85ba3f93e771", size = 1931385, upload-time = "2026-02-17T14:21:58.193Z" },
{ url = "https://files.pythonhosted.org/packages/52/ac/8e591f33a712563742fe77b0731c1c900fe2fcc3d3e75bd4c7d8e60057a8/dearpygui-2.2-cp314-cp314-manylinux1_x86_64.whl", hash = "sha256:dcc9377d8d9fe27f659ae6b016fe96aa37d8b26b57ce60c47985290e1be7801e", size = 2592691, upload-time = "2026-02-17T14:22:05.191Z" },
{ url = "https://files.pythonhosted.org/packages/f8/03/aeb4ebe09a0240c8c9337018d2ac3e087fd911f6051a3bb0131248fbd942/dearpygui-2.2-cp314-cp314-win_amd64.whl", hash = "sha256:fe3c8dc37be3ddce0356afb0c16721c0e485a4c94a831886935a0692bb9a9966", size = 1889279, upload-time = "2026-02-17T14:21:46.16Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/59/71/114626e9b77b07b2d5d92e0030b00b4a78e73de1212cbe63656af3da636e/dearpygui-2.2-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:9805b99abcdf89b18c6877cfd4865f844398e1c555316d2f7347b1e8e62f29fd", size = 1931334, upload-time = "2026-02-17T14:21:51.362Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/28/f5/dbd692d64a27c94d7bf4f05b87a4bd74bcd61699248a7fb1166635cef17a/dearpygui-2.2-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:8b42ebd0a73ddf03ab5fb0777636216035716089ae449f904fe37ccebbed0061", size = 2592856, upload-time = "2026-02-17T14:22:00.223Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/58/e0/4be23bd80453b5ee216319a1f2005b57a7c25d00872056f7a96a0a21ef4e/dearpygui-2.2-cp310-cp310-win_amd64.whl", hash = "sha256:9872af7c4d1c7f8b4f1031c1c333ff83c778332674ac3d54178fa7ca0230c6ab", size = 1830505, upload-time = "2026-02-17T14:21:40.74Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/b7/80/c62a26549688a9a2251fede8c1ba10f5e41964a4bb97dba486bcb1e0be28/dearpygui-2.2-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:a2dbbd975e1dbdf4688ef49b95651192b6417c8722e470b9ad2b7f5029555c63", size = 1931280, upload-time = "2026-02-17T14:21:52.98Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/01/a1/6c40624fcaa0ea429aa2b6906b19c639175de0677b2af52f00c2794a56ce/dearpygui-2.2-cp311-cp311-manylinux1_x86_64.whl", hash = "sha256:87c16bc00b94ee748c8c156c10f353b7f0b6e843ecec54121cb3b9f254abf940", size = 2592871, upload-time = "2026-02-17T14:22:01.806Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/58/ca/3683b74526a869403ca63bac33c47c8d1bbabe57d186eb33490b5d18459a/dearpygui-2.2-cp311-cp311-win_amd64.whl", hash = "sha256:d5a38e58a03a41e09915f9b026759899d772d32e920bcd114d1b3f344946e0f0", size = 1830497, upload-time = "2026-02-17T14:21:42.108Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/17/c8/b4afdac89c7bf458513366af3143f7383d7b09721637989c95788d93e24c/dearpygui-2.2-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:34ceae1ca1b65444e49012d6851312e44f08713da1b8cc0150cf41f1c207af9c", size = 1931443, upload-time = "2026-02-17T14:21:54.394Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/43/93/a2d083b2e0edb095be815662cc41e40cf9ea7b65d6323e47bb30df7eb284/dearpygui-2.2-cp312-cp312-manylinux1_x86_64.whl", hash = "sha256:e1fae9ae59fec0e41773df64c80311a6ba67696219dde5506a2a4c013e8bcdfa", size = 2592645, upload-time = "2026-02-17T14:22:02.869Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/80/ba/eae13acaad479f522db853e8b1ccd695a7bc8da2b9685c1d70a3b318df89/dearpygui-2.2-cp312-cp312-win_amd64.whl", hash = "sha256:7d399543b5a26ab6426ef3bbd776e55520b491b3e169647bde5e6b2de3701b35", size = 1830531, upload-time = "2026-02-17T14:21:43.386Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/18/ab/eb8070ca8fd881d4a9ac49fca5fb7b54ce66cc2742afa38e59d72b2c2dec/dearpygui-2.2-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:084c309c56d3e05fcf75eef872df6df97f5e3e19da5ecad393a57cf7a5e56294", size = 1931423, upload-time = "2026-02-17T14:21:56.397Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/bc/03/5988d5f4cf3ddc7c3d886623bb904b76c5f5f628a0256ac53d848df33cf7/dearpygui-2.2-cp313-cp313-manylinux1_x86_64.whl", hash = "sha256:05d8c18a0134d72f680e333c80ccab264351170293f86a05f5a0e14222992f27", size = 2592542, upload-time = "2026-02-17T14:22:03.949Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/6e/5a/573df5f7277a13b5044daa9a27797fbd4e766da03cab6462a151b557727c/dearpygui-2.2-cp313-cp313-win_amd64.whl", hash = "sha256:500087e88d61b4ef0c841f30b12a05f5128774db3883fde7ff7c6172f03f6d79", size = 1830558, upload-time = "2026-02-17T14:21:44.551Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8b/76/3ccaec465021b647f13c83be42a635043a08255076984a658ed691701498/dearpygui-2.2-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:22451146968729429ba37afa2602957dfefc03ff92dcc627dd4d85ba3f93e771", size = 1931385, upload-time = "2026-02-17T14:21:58.193Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/52/ac/8e591f33a712563742fe77b0731c1c900fe2fcc3d3e75bd4c7d8e60057a8/dearpygui-2.2-cp314-cp314-manylinux1_x86_64.whl", hash = "sha256:dcc9377d8d9fe27f659ae6b016fe96aa37d8b26b57ce60c47985290e1be7801e", size = 2592691, upload-time = "2026-02-17T14:22:05.191Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f8/03/aeb4ebe09a0240c8c9337018d2ac3e087fd911f6051a3bb0131248fbd942/dearpygui-2.2-cp314-cp314-win_amd64.whl", hash = "sha256:fe3c8dc37be3ddce0356afb0c16721c0e485a4c94a831886935a0692bb9a9966", size = 1889279, upload-time = "2026-02-17T14:21:46.16Z" },
]
[[package]]
name = "duckdb"
version = "1.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/11/e05a7eb73a373d523e45d83c261025e02bc31ebf868e6282c30c4d02cc59/duckdb-1.5.0.tar.gz", hash = "sha256:f974b61b1c375888ee62bc3125c60ac11c4e45e4457dd1bb31a8f8d3cf277edd", size = 17981141, upload-time = "2026-03-09T12:50:26.372Z" }
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ee/11/e05a7eb73a373d523e45d83c261025e02bc31ebf868e6282c30c4d02cc59/duckdb-1.5.0.tar.gz", hash = "sha256:f974b61b1c375888ee62bc3125c60ac11c4e45e4457dd1bb31a8f8d3cf277edd", size = 17981141, upload-time = "2026-03-09T12:50:26.372Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/5d/8fa129bbd604d0e91aa9a0a407e7d2acc559b6024c3f887868fd7a13871d/duckdb-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:47fbb1c053a627a91fa71ec883951561317f14a82df891c00dcace435e8fea78", size = 30012348, upload-time = "2026-03-09T12:48:39.133Z" },
{ url = "https://files.pythonhosted.org/packages/0c/31/db320641a262a897755e634d16838c98d5ca7dc91f4e096e104e244a3a01/duckdb-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2b546a30a6ac020165a86ab3abac553255a6e8244d5437d17859a6aa338611aa", size = 15940515, upload-time = "2026-03-09T12:48:41.905Z" },
{ url = "https://files.pythonhosted.org/packages/0b/45/5725684794fbabf54d8dbae5247685799a6bf8e1e930ebff3a76a726772c/duckdb-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:122396041c0acb78e66d7dc7d36c55f03f67fe6ad012155c132d82739722e381", size = 14193724, upload-time = "2026-03-09T12:48:44.105Z" },
{ url = "https://files.pythonhosted.org/packages/27/68/f110c66b43e27191d7e53d3587e118568b73d66f23cb9bd6c7e0a560fd6d/duckdb-1.5.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a2cd73d50ea2c2bf618a4b7d22fe7c4115a1c9083d35654a0d5d421620ed999", size = 19218777, upload-time = "2026-03-09T12:48:46.399Z" },
{ url = "https://files.pythonhosted.org/packages/ec/9d/46affc9257377cbc865e494650312a7a08a56e85aa8d702eb297bec430b7/duckdb-1.5.0-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63a8ea3b060a881c90d1c1b9454abed3daf95b6160c39bbb9506fee3a9711730", size = 21311205, upload-time = "2026-03-09T12:48:48.895Z" },
{ url = "https://files.pythonhosted.org/packages/3b/34/dac03ab7340989cda258655387959c88342ea3b44949751391267bcbc830/duckdb-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:238d576ae1dda441f8c79ed1370c5ccf863e4a5d59ca2563f9c96cd26b2188ac", size = 13043217, upload-time = "2026-03-09T12:48:51.262Z" },
{ url = "https://files.pythonhosted.org/packages/01/0c/0282b10a1c96810606b916b8d58a03f2131bd3ede14d2851f58b0b860e7c/duckdb-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3298bd17cf0bb5f342fb51a4edc9aadacae882feb2b04161a03eb93271c70c86", size = 30014615, upload-time = "2026-03-09T12:48:54.061Z" },
{ url = "https://files.pythonhosted.org/packages/71/e8/cbbc920078a794f24f63017fc55c9cbdb17d6fb94d3973f479b2d9f2983d/duckdb-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:13f94c49ca389731c439524248e05007fb1a86cd26f1e38f706abc261069cd41", size = 15940493, upload-time = "2026-03-09T12:48:57.85Z" },
{ url = "https://files.pythonhosted.org/packages/31/b6/6cae794d5856259b0060f79d5db71c7fdba043950eaa6a9d72b0bad16095/duckdb-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ab9d597b1e8668466f1c164d0ea07eaf0ebb516950f5a2e794b0f52c81ff3b16", size = 14194663, upload-time = "2026-03-09T12:49:00.416Z" },
{ url = "https://files.pythonhosted.org/packages/82/07/aba3887658b93a36ce702dd00ca6a6422de3d14c7ee3a4b4c03ea20a99c0/duckdb-1.5.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a43f8289b11c0b50d13f96ab03210489d37652f3fd7911dc8eab04d61b049da2", size = 19220501, upload-time = "2026-03-09T12:49:03.431Z" },
{ url = "https://files.pythonhosted.org/packages/fc/a2/723e6df48754e468fa50d7878eb860906c975eafe317c4134a8482ca220e/duckdb-1.5.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f514e796a116c5de070e99974e42d0b8c2e6c303386790e58408c481150d417", size = 21316142, upload-time = "2026-03-09T12:49:06.223Z" },
{ url = "https://files.pythonhosted.org/packages/03/af/4dcbdf8f2349ed0b054c254ec59bc362ce6ddf603af35f770124c0984686/duckdb-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:cf503ba2c753d97c76beb111e74572fef8803265b974af2dca67bba1de4176d2", size = 13043445, upload-time = "2026-03-09T12:49:08.892Z" },
{ url = "https://files.pythonhosted.org/packages/60/5e/1bb7e75a63bf3dc49bc5a2cd27a65ffeef151f52a32db980983516f2d9f6/duckdb-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:a1156e91e4e47f0e7d9c9404e559a1d71b372cd61790a407d65eb26948ae8298", size = 13883145, upload-time = "2026-03-09T12:49:11.566Z" },
{ url = "https://files.pythonhosted.org/packages/43/73/120e673e48ae25aaf689044c25ef51b0ea1d088563c9a2532612aea18e0a/duckdb-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9ea988d1d5c8737720d1b2852fd70e4d9e83b1601b8896a1d6d31df5e6afc7dd", size = 30057869, upload-time = "2026-03-09T12:49:14.65Z" },
{ url = "https://files.pythonhosted.org/packages/21/e9/61143471958d36d3f3e764cb4cd43330be208ddbff1c78d3310b9ee67fe8/duckdb-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb786d5472afc16cc3c7355eb2007172538311d6f0cc6f6a0859e84a60220375", size = 15963092, upload-time = "2026-03-09T12:49:17.478Z" },
{ url = "https://files.pythonhosted.org/packages/4f/71/76e37c9a599ad89dd944e6cbb3e6a8ad196944a421758e83adea507637b6/duckdb-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dc92b238f4122800a7592e99134124cc9048c50f766c37a0778dd2637f5cbe59", size = 14220562, upload-time = "2026-03-09T12:49:23.518Z" },
{ url = "https://files.pythonhosted.org/packages/db/b8/de1831656d5d13173e27c79c7259c8b9a7bdc314fdc8920604838ea4c46d/duckdb-1.5.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b74cb205c21d3696d8f8b88adca401e1063d6e6f57c1c4f56a243610b086e30", size = 19245329, upload-time = "2026-03-09T12:49:26.307Z" },
{ url = "https://files.pythonhosted.org/packages/1f/8d/33d349a3bcbd3e9b7b4e904c19d5b97f058c4c20791b89a8d6323bb93dce/duckdb-1.5.0-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e56c19ffd1ffe3642fa89639e71e2e00ab0cf107b62fe16e88030acaebcbde6", size = 21348041, upload-time = "2026-03-09T12:49:30.283Z" },
{ url = "https://files.pythonhosted.org/packages/e2/ec/591a4cad582fae04bc8f8b4a435eceaaaf3838cf0ca771daae16a3c2995b/duckdb-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:86525e565ec0c43420106fd34ba2c739a54c01814d476c7fed3007c9ed6efd86", size = 13053781, upload-time = "2026-03-09T12:49:33.574Z" },
{ url = "https://files.pythonhosted.org/packages/db/62/42e0a13f9919173bec121c0ff702406e1cdd91d8084c3e0b3412508c3891/duckdb-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:5faeebc178c986a7bfa68868a023001137a95a1110bf09b7356442a4eae0f7e7", size = 13862906, upload-time = "2026-03-09T12:49:36.598Z" },
{ url = "https://files.pythonhosted.org/packages/35/5d/af5501221f42e4e3662c047ecec4dcd0761229fceeba3c67ad4d9d8741df/duckdb-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11dd05b827846c87f0ae2f67b9ae1d60985882a7c08ce855379e4a08d5be0e1d", size = 30057396, upload-time = "2026-03-09T12:49:39.95Z" },
{ url = "https://files.pythonhosted.org/packages/43/bd/a278d73fedbd3783bf9aedb09cad4171fe8e55bd522952a84f6849522eb6/duckdb-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ad8d9c91b7c280ab6811f59deff554b845706c20baa28c4e8f80a95690b252b", size = 15962700, upload-time = "2026-03-09T12:49:43.504Z" },
{ url = "https://files.pythonhosted.org/packages/76/fc/c916e928606946209c20fb50898dabf120241fb528a244e2bd8cde1bd9e2/duckdb-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ee4dabe03ed810d64d93927e0fd18cd137060b81ee75dcaeaaff32cbc816656", size = 14220272, upload-time = "2026-03-09T12:49:46.867Z" },
{ url = "https://files.pythonhosted.org/packages/53/07/1390e69db922423b2e111e32ed342b3e8fad0a31c144db70681ea1ba4d56/duckdb-1.5.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409ed1184b363ddea239609c5926f5148ee412b8d9e5ffa617718d755d942f6", size = 19244401, upload-time = "2026-03-09T12:49:49.865Z" },
{ url = "https://files.pythonhosted.org/packages/54/13/b58d718415cde993823a54952ea511d2612302f1d2bc220549d0cef752a4/duckdb-1.5.0-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1df8c4f9c853a45f3ec1e79ed7fe1957a203e5ec893bbbb853e727eb93e0090f", size = 21345827, upload-time = "2026-03-09T12:49:52.977Z" },
{ url = "https://files.pythonhosted.org/packages/e0/96/4460429651e371eb5ff745a4790e7fa0509c7a58c71fc4f0f893404c9646/duckdb-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:9a3d3dfa2d8bc74008ce3ad9564761ae23505a9e4282f6a36df29bd87249620b", size = 13053101, upload-time = "2026-03-09T12:49:56.134Z" },
{ url = "https://files.pythonhosted.org/packages/ba/54/6d5b805113214b830fa3c267bb3383fb8febaa30760d0162ef59aadb110a/duckdb-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:2deebcbafd9d39c04f31ec968f4dd7cee832c021e10d96b32ab0752453e247c8", size = 13865071, upload-time = "2026-03-09T12:49:59.282Z" },
{ url = "https://files.pythonhosted.org/packages/66/9f/dd806d4e8ecd99006eb240068f34e1054533da1857ad06ac726305cd102d/duckdb-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:d4b618de670cd2271dd7b3397508c7b3c62d8ea70c592c755643211a6f9154fa", size = 30065704, upload-time = "2026-03-09T12:50:02.671Z" },
{ url = "https://files.pythonhosted.org/packages/79/c2/7b7b8a5c65d5535c88a513e267b5e6d7a55ab3e9b67e4ddd474454653268/duckdb-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:065ae50cb185bac4b904287df72e6b4801b3bee2ad85679576dd712b8ba07021", size = 15964883, upload-time = "2026-03-09T12:50:06.343Z" },
{ url = "https://files.pythonhosted.org/packages/23/c5/9a52a2cdb228b8d8d191a603254364d929274d9cc7d285beada8f7daa712/duckdb-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6be5e48e287a24d98306ce9dd55093c3b105a8fbd8a2e7a45e13df34bf081985", size = 14221498, upload-time = "2026-03-09T12:50:10.567Z" },
{ url = "https://files.pythonhosted.org/packages/b8/68/646045cb97982702a8a143dc2e45f3bdcb79fbe2d559a98d74b8c160e5e2/duckdb-1.5.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5ee41a0bf793882f02192ce105b9a113c3e8c505a27c7ef9437d7b756317113", size = 19249787, upload-time = "2026-03-09T12:50:13.524Z" },
{ url = "https://files.pythonhosted.org/packages/15/1b/5abf0c7f38febb3b4a231c784223fceccfd3f2bfd957699d786f46e41ce6/duckdb-1.5.0-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8e42aaf3cd217417c5dc9ff522dc3939d18b25a6fe5f846348277e831e6f59c", size = 21351583, upload-time = "2026-03-09T12:50:16.701Z" },
{ url = "https://files.pythonhosted.org/packages/93/a4/a90f2901cc0a1ce7ca4f0564b8492b9dbfe048a6395b27933d46ae9be473/duckdb-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:11ae50aaeda2145b50294ee0247e4f11fb9448b3cc3d2aea1cfc456637dfb977", size = 13575130, upload-time = "2026-03-09T12:50:19.716Z" },
{ url = "https://files.pythonhosted.org/packages/64/aa/f14dd5e241ec80d9f9d82196ca65e0c53badfc8a7a619d5497c5626657ad/duckdb-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:d6d2858c734d1a7e7a1b6e9b8403b3fce26dfefb4e0a2479c420fba6cd36db36", size = 14341879, upload-time = "2026-03-09T12:50:22.347Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e0/5d/8fa129bbd604d0e91aa9a0a407e7d2acc559b6024c3f887868fd7a13871d/duckdb-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:47fbb1c053a627a91fa71ec883951561317f14a82df891c00dcace435e8fea78", size = 30012348, upload-time = "2026-03-09T12:48:39.133Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/0c/31/db320641a262a897755e634d16838c98d5ca7dc91f4e096e104e244a3a01/duckdb-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2b546a30a6ac020165a86ab3abac553255a6e8244d5437d17859a6aa338611aa", size = 15940515, upload-time = "2026-03-09T12:48:41.905Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/0b/45/5725684794fbabf54d8dbae5247685799a6bf8e1e930ebff3a76a726772c/duckdb-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:122396041c0acb78e66d7dc7d36c55f03f67fe6ad012155c132d82739722e381", size = 14193724, upload-time = "2026-03-09T12:48:44.105Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/27/68/f110c66b43e27191d7e53d3587e118568b73d66f23cb9bd6c7e0a560fd6d/duckdb-1.5.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a2cd73d50ea2c2bf618a4b7d22fe7c4115a1c9083d35654a0d5d421620ed999", size = 19218777, upload-time = "2026-03-09T12:48:46.399Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ec/9d/46affc9257377cbc865e494650312a7a08a56e85aa8d702eb297bec430b7/duckdb-1.5.0-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63a8ea3b060a881c90d1c1b9454abed3daf95b6160c39bbb9506fee3a9711730", size = 21311205, upload-time = "2026-03-09T12:48:48.895Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/3b/34/dac03ab7340989cda258655387959c88342ea3b44949751391267bcbc830/duckdb-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:238d576ae1dda441f8c79ed1370c5ccf863e4a5d59ca2563f9c96cd26b2188ac", size = 13043217, upload-time = "2026-03-09T12:48:51.262Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/01/0c/0282b10a1c96810606b916b8d58a03f2131bd3ede14d2851f58b0b860e7c/duckdb-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3298bd17cf0bb5f342fb51a4edc9aadacae882feb2b04161a03eb93271c70c86", size = 30014615, upload-time = "2026-03-09T12:48:54.061Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/71/e8/cbbc920078a794f24f63017fc55c9cbdb17d6fb94d3973f479b2d9f2983d/duckdb-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:13f94c49ca389731c439524248e05007fb1a86cd26f1e38f706abc261069cd41", size = 15940493, upload-time = "2026-03-09T12:48:57.85Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/31/b6/6cae794d5856259b0060f79d5db71c7fdba043950eaa6a9d72b0bad16095/duckdb-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ab9d597b1e8668466f1c164d0ea07eaf0ebb516950f5a2e794b0f52c81ff3b16", size = 14194663, upload-time = "2026-03-09T12:49:00.416Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/82/07/aba3887658b93a36ce702dd00ca6a6422de3d14c7ee3a4b4c03ea20a99c0/duckdb-1.5.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a43f8289b11c0b50d13f96ab03210489d37652f3fd7911dc8eab04d61b049da2", size = 19220501, upload-time = "2026-03-09T12:49:03.431Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/fc/a2/723e6df48754e468fa50d7878eb860906c975eafe317c4134a8482ca220e/duckdb-1.5.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f514e796a116c5de070e99974e42d0b8c2e6c303386790e58408c481150d417", size = 21316142, upload-time = "2026-03-09T12:49:06.223Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/03/af/4dcbdf8f2349ed0b054c254ec59bc362ce6ddf603af35f770124c0984686/duckdb-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:cf503ba2c753d97c76beb111e74572fef8803265b974af2dca67bba1de4176d2", size = 13043445, upload-time = "2026-03-09T12:49:08.892Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/60/5e/1bb7e75a63bf3dc49bc5a2cd27a65ffeef151f52a32db980983516f2d9f6/duckdb-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:a1156e91e4e47f0e7d9c9404e559a1d71b372cd61790a407d65eb26948ae8298", size = 13883145, upload-time = "2026-03-09T12:49:11.566Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/43/73/120e673e48ae25aaf689044c25ef51b0ea1d088563c9a2532612aea18e0a/duckdb-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9ea988d1d5c8737720d1b2852fd70e4d9e83b1601b8896a1d6d31df5e6afc7dd", size = 30057869, upload-time = "2026-03-09T12:49:14.65Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/21/e9/61143471958d36d3f3e764cb4cd43330be208ddbff1c78d3310b9ee67fe8/duckdb-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb786d5472afc16cc3c7355eb2007172538311d6f0cc6f6a0859e84a60220375", size = 15963092, upload-time = "2026-03-09T12:49:17.478Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/4f/71/76e37c9a599ad89dd944e6cbb3e6a8ad196944a421758e83adea507637b6/duckdb-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dc92b238f4122800a7592e99134124cc9048c50f766c37a0778dd2637f5cbe59", size = 14220562, upload-time = "2026-03-09T12:49:23.518Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/db/b8/de1831656d5d13173e27c79c7259c8b9a7bdc314fdc8920604838ea4c46d/duckdb-1.5.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b74cb205c21d3696d8f8b88adca401e1063d6e6f57c1c4f56a243610b086e30", size = 19245329, upload-time = "2026-03-09T12:49:26.307Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/1f/8d/33d349a3bcbd3e9b7b4e904c19d5b97f058c4c20791b89a8d6323bb93dce/duckdb-1.5.0-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e56c19ffd1ffe3642fa89639e71e2e00ab0cf107b62fe16e88030acaebcbde6", size = 21348041, upload-time = "2026-03-09T12:49:30.283Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e2/ec/591a4cad582fae04bc8f8b4a435eceaaaf3838cf0ca771daae16a3c2995b/duckdb-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:86525e565ec0c43420106fd34ba2c739a54c01814d476c7fed3007c9ed6efd86", size = 13053781, upload-time = "2026-03-09T12:49:33.574Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/db/62/42e0a13f9919173bec121c0ff702406e1cdd91d8084c3e0b3412508c3891/duckdb-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:5faeebc178c986a7bfa68868a023001137a95a1110bf09b7356442a4eae0f7e7", size = 13862906, upload-time = "2026-03-09T12:49:36.598Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/35/5d/af5501221f42e4e3662c047ecec4dcd0761229fceeba3c67ad4d9d8741df/duckdb-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11dd05b827846c87f0ae2f67b9ae1d60985882a7c08ce855379e4a08d5be0e1d", size = 30057396, upload-time = "2026-03-09T12:49:39.95Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/43/bd/a278d73fedbd3783bf9aedb09cad4171fe8e55bd522952a84f6849522eb6/duckdb-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ad8d9c91b7c280ab6811f59deff554b845706c20baa28c4e8f80a95690b252b", size = 15962700, upload-time = "2026-03-09T12:49:43.504Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/76/fc/c916e928606946209c20fb50898dabf120241fb528a244e2bd8cde1bd9e2/duckdb-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ee4dabe03ed810d64d93927e0fd18cd137060b81ee75dcaeaaff32cbc816656", size = 14220272, upload-time = "2026-03-09T12:49:46.867Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/53/07/1390e69db922423b2e111e32ed342b3e8fad0a31c144db70681ea1ba4d56/duckdb-1.5.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409ed1184b363ddea239609c5926f5148ee412b8d9e5ffa617718d755d942f6", size = 19244401, upload-time = "2026-03-09T12:49:49.865Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/54/13/b58d718415cde993823a54952ea511d2612302f1d2bc220549d0cef752a4/duckdb-1.5.0-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1df8c4f9c853a45f3ec1e79ed7fe1957a203e5ec893bbbb853e727eb93e0090f", size = 21345827, upload-time = "2026-03-09T12:49:52.977Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e0/96/4460429651e371eb5ff745a4790e7fa0509c7a58c71fc4f0f893404c9646/duckdb-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:9a3d3dfa2d8bc74008ce3ad9564761ae23505a9e4282f6a36df29bd87249620b", size = 13053101, upload-time = "2026-03-09T12:49:56.134Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ba/54/6d5b805113214b830fa3c267bb3383fb8febaa30760d0162ef59aadb110a/duckdb-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:2deebcbafd9d39c04f31ec968f4dd7cee832c021e10d96b32ab0752453e247c8", size = 13865071, upload-time = "2026-03-09T12:49:59.282Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/66/9f/dd806d4e8ecd99006eb240068f34e1054533da1857ad06ac726305cd102d/duckdb-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:d4b618de670cd2271dd7b3397508c7b3c62d8ea70c592c755643211a6f9154fa", size = 30065704, upload-time = "2026-03-09T12:50:02.671Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/79/c2/7b7b8a5c65d5535c88a513e267b5e6d7a55ab3e9b67e4ddd474454653268/duckdb-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:065ae50cb185bac4b904287df72e6b4801b3bee2ad85679576dd712b8ba07021", size = 15964883, upload-time = "2026-03-09T12:50:06.343Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/23/c5/9a52a2cdb228b8d8d191a603254364d929274d9cc7d285beada8f7daa712/duckdb-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6be5e48e287a24d98306ce9dd55093c3b105a8fbd8a2e7a45e13df34bf081985", size = 14221498, upload-time = "2026-03-09T12:50:10.567Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/b8/68/646045cb97982702a8a143dc2e45f3bdcb79fbe2d559a98d74b8c160e5e2/duckdb-1.5.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5ee41a0bf793882f02192ce105b9a113c3e8c505a27c7ef9437d7b756317113", size = 19249787, upload-time = "2026-03-09T12:50:13.524Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/15/1b/5abf0c7f38febb3b4a231c784223fceccfd3f2bfd957699d786f46e41ce6/duckdb-1.5.0-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8e42aaf3cd217417c5dc9ff522dc3939d18b25a6fe5f846348277e831e6f59c", size = 21351583, upload-time = "2026-03-09T12:50:16.701Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/93/a4/a90f2901cc0a1ce7ca4f0564b8492b9dbfe048a6395b27933d46ae9be473/duckdb-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:11ae50aaeda2145b50294ee0247e4f11fb9448b3cc3d2aea1cfc456637dfb977", size = 13575130, upload-time = "2026-03-09T12:50:19.716Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/64/aa/f14dd5e241ec80d9f9d82196ca65e0c53badfc8a7a619d5497c5626657ad/duckdb-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:d6d2858c734d1a7e7a1b6e9b8403b3fce26dfefb4e0a2479c420fba6cd36db36", size = 14341879, upload-time = "2026-03-09T12:50:22.347Z" },
]
[[package]]
name = "numpy"
version = "2.2.6"
source = { registry = "https://pypi.org/simple" }
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" }
resolution-markers = [
"python_full_version < '3.11'",
]
sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" },
{ url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" },
{ url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" },
{ url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" },
{ url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" },
{ url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" },
{ url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" },
{ url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" },
{ url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" },
{ url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" },
{ url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" },
{ url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" },
{ url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" },
{ url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" },
{ url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" },
{ url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" },
{ url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" },
{ url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" },
{ url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" },
{ url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" },
{ url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" },
{ url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" },
{ url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" },
{ url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" },
{ url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" },
{ url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" },
{ url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" },
{ url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" },
{ url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" },
{ url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" },
{ url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" },
{ url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" },
{ url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" },
{ url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" },
{ url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" },
{ url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" },
{ url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" },
{ url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" },
{ url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" },
{ url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" },
{ url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" },
{ url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" },
{ url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" },
{ url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" },
{ url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" },
{ url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" },
{ url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" },
{ url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" },
{ url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" },
{ url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" },
{ url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" },
{ url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" },
]
[[package]]
name = "numpy"
version = "2.4.3"
source = { registry = "https://pypi.org/simple" }
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" }
resolution-markers = [
"python_full_version >= '3.12'",
"python_full_version == '3.11.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/10/8b/c265f4823726ab832de836cdd184d0986dcf94480f81e8739692a7ac7af2/numpy-2.4.3.tar.gz", hash = "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd", size = 20727743, upload-time = "2026-03-09T07:58:53.426Z" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/10/8b/c265f4823726ab832de836cdd184d0986dcf94480f81e8739692a7ac7af2/numpy-2.4.3.tar.gz", hash = "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd", size = 20727743, upload-time = "2026-03-09T07:58:53.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/51/5093a2df15c4dc19da3f79d1021e891f5dcf1d9d1db6ba38891d5590f3fe/numpy-2.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:33b3bf58ee84b172c067f56aeadc7ee9ab6de69c5e800ab5b10295d54c581adb", size = 16957183, upload-time = "2026-03-09T07:55:57.774Z" },
{ url = "https://files.pythonhosted.org/packages/b5/7c/c061f3de0630941073d2598dc271ac2f6cbcf5c83c74a5870fea07488333/numpy-2.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8ba7b51e71c05aa1f9bc3641463cd82308eab40ce0d5c7e1fd4038cbf9938147", size = 14968734, upload-time = "2026-03-09T07:56:00.494Z" },
{ url = "https://files.pythonhosted.org/packages/ef/27/d26c85cbcd86b26e4f125b0668e7a7c0542d19dd7d23ee12e87b550e95b5/numpy-2.4.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a1988292870c7cb9d0ebb4cc96b4d447513a9644801de54606dc7aabf2b7d920", size = 5475288, upload-time = "2026-03-09T07:56:02.857Z" },
{ url = "https://files.pythonhosted.org/packages/2b/09/3c4abbc1dcd8010bf1a611d174c7aa689fc505585ec806111b4406f6f1b1/numpy-2.4.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:23b46bb6d8ecb68b58c09944483c135ae5f0e9b8d8858ece5e4ead783771d2a9", size = 6805253, upload-time = "2026-03-09T07:56:04.53Z" },
{ url = "https://files.pythonhosted.org/packages/21/bc/e7aa3f6817e40c3f517d407742337cbb8e6fc4b83ce0b55ab780c829243b/numpy-2.4.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a016db5c5dba78fa8fe9f5d80d6708f9c42ab087a739803c0ac83a43d686a470", size = 15969479, upload-time = "2026-03-09T07:56:06.638Z" },
{ url = "https://files.pythonhosted.org/packages/78/51/9f5d7a41f0b51649ddf2f2320595e15e122a40610b233d51928dd6c92353/numpy-2.4.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:715de7f82e192e8cae5a507a347d97ad17598f8e026152ca97233e3666daaa71", size = 16901035, upload-time = "2026-03-09T07:56:09.405Z" },
{ url = "https://files.pythonhosted.org/packages/64/6e/b221dd847d7181bc5ee4857bfb026182ef69499f9305eb1371cbb1aea626/numpy-2.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2ddb7919366ee468342b91dea2352824c25b55814a987847b6c52003a7c97f15", size = 17325657, upload-time = "2026-03-09T07:56:12.067Z" },
{ url = "https://files.pythonhosted.org/packages/eb/b8/8f3fd2da596e1063964b758b5e3c970aed1949a05200d7e3d46a9d46d643/numpy-2.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a315e5234d88067f2d97e1f2ef670a7569df445d55400f1e33d117418d008d52", size = 18635512, upload-time = "2026-03-09T07:56:14.629Z" },
{ url = "https://files.pythonhosted.org/packages/5c/24/2993b775c37e39d2f8ab4125b44337ab0b2ba106c100980b7c274a22bee7/numpy-2.4.3-cp311-cp311-win32.whl", hash = "sha256:2b3f8d2c4589b1a2028d2a770b0fc4d1f332fb5e01521f4de3199a896d158ddd", size = 6238100, upload-time = "2026-03-09T07:56:17.243Z" },
{ url = "https://files.pythonhosted.org/packages/76/1d/edccf27adedb754db7c4511d5eac8b83f004ae948fe2d3509e8b78097d4c/numpy-2.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:77e76d932c49a75617c6d13464e41203cd410956614d0a0e999b25e9e8d27eec", size = 12609816, upload-time = "2026-03-09T07:56:19.089Z" },
{ url = "https://files.pythonhosted.org/packages/92/82/190b99153480076c8dce85f4cfe7d53ea84444145ffa54cb58dcd460d66b/numpy-2.4.3-cp311-cp311-win_arm64.whl", hash = "sha256:eb610595dd91560905c132c709412b512135a60f1851ccbd2c959e136431ff67", size = 10485757, upload-time = "2026-03-09T07:56:21.753Z" },
{ url = "https://files.pythonhosted.org/packages/a9/ed/6388632536f9788cea23a3a1b629f25b43eaacd7d7377e5d6bc7b9deb69b/numpy-2.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61b0cbabbb6126c8df63b9a3a0c4b1f44ebca5e12ff6997b80fcf267fb3150ef", size = 16669628, upload-time = "2026-03-09T07:56:24.252Z" },
{ url = "https://files.pythonhosted.org/packages/74/1b/ee2abfc68e1ce728b2958b6ba831d65c62e1b13ce3017c13943f8f9b5b2e/numpy-2.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7395e69ff32526710748f92cd8c9849b361830968ea3e24a676f272653e8983e", size = 14696872, upload-time = "2026-03-09T07:56:26.991Z" },
{ url = "https://files.pythonhosted.org/packages/ba/d1/780400e915ff5638166f11ca9dc2c5815189f3d7cf6f8759a1685e586413/numpy-2.4.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:abdce0f71dcb4a00e4e77f3faf05e4616ceccfe72ccaa07f47ee79cda3b7b0f4", size = 5203489, upload-time = "2026-03-09T07:56:29.414Z" },
{ url = "https://files.pythonhosted.org/packages/0b/bb/baffa907e9da4cc34a6e556d6d90e032f6d7a75ea47968ea92b4858826c4/numpy-2.4.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:48da3a4ee1336454b07497ff7ec83903efa5505792c4e6d9bf83d99dc07a1e18", size = 6550814, upload-time = "2026-03-09T07:56:32.225Z" },
{ url = "https://files.pythonhosted.org/packages/7b/12/8c9f0c6c95f76aeb20fc4a699c33e9f827fa0d0f857747c73bb7b17af945/numpy-2.4.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e3bef222ad6b052280311d1d60db8e259e4947052c3ae7dd6817451fc8a4c5", size = 15666601, upload-time = "2026-03-09T07:56:34.461Z" },
{ url = "https://files.pythonhosted.org/packages/bd/79/cc665495e4d57d0aa6fbcc0aa57aa82671dfc78fbf95fe733ed86d98f52a/numpy-2.4.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7dd01a46700b1967487141a66ac1a3cf0dd8ebf1f08db37d46389401512ca97", size = 16621358, upload-time = "2026-03-09T07:56:36.852Z" },
{ url = "https://files.pythonhosted.org/packages/a8/40/b4ecb7224af1065c3539f5ecfff879d090de09608ad1008f02c05c770cb3/numpy-2.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76f0f283506c28b12bba319c0fab98217e9f9b54e6160e9c79e9f7348ba32e9c", size = 17016135, upload-time = "2026-03-09T07:56:39.337Z" },
{ url = "https://files.pythonhosted.org/packages/f7/b1/6a88e888052eed951afed7a142dcdf3b149a030ca59b4c71eef085858e43/numpy-2.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:737f630a337364665aba3b5a77e56a68cc42d350edd010c345d65a3efa3addcc", size = 18345816, upload-time = "2026-03-09T07:56:42.31Z" },
{ url = "https://files.pythonhosted.org/packages/f3/8f/103a60c5f8c3d7fc678c19cd7b2476110da689ccb80bc18050efbaeae183/numpy-2.4.3-cp312-cp312-win32.whl", hash = "sha256:26952e18d82a1dbbc2f008d402021baa8d6fc8e84347a2072a25e08b46d698b9", size = 5960132, upload-time = "2026-03-09T07:56:44.851Z" },
{ url = "https://files.pythonhosted.org/packages/d7/7c/f5ee1bf6ed888494978046a809df2882aad35d414b622893322df7286879/numpy-2.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:65f3c2455188f09678355f5cae1f959a06b778bc66d535da07bf2ef20cd319d5", size = 12316144, upload-time = "2026-03-09T07:56:47.057Z" },
{ url = "https://files.pythonhosted.org/packages/71/46/8d1cb3f7a00f2fb6394140e7e6623696e54c6318a9d9691bb4904672cf42/numpy-2.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:2abad5c7fef172b3377502bde47892439bae394a71bc329f31df0fd829b41a9e", size = 10220364, upload-time = "2026-03-09T07:56:49.849Z" },
{ url = "https://files.pythonhosted.org/packages/b6/d0/1fe47a98ce0df229238b77611340aff92d52691bcbc10583303181abf7fc/numpy-2.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b346845443716c8e542d54112966383b448f4a3ba5c66409771b8c0889485dd3", size = 16665297, upload-time = "2026-03-09T07:56:52.296Z" },
{ url = "https://files.pythonhosted.org/packages/27/d9/4e7c3f0e68dfa91f21c6fb6cf839bc829ec920688b1ce7ec722b1a6202fb/numpy-2.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2629289168f4897a3c4e23dc98d6f1731f0fc0fe52fb9db19f974041e4cc12b9", size = 14691853, upload-time = "2026-03-09T07:56:54.992Z" },
{ url = "https://files.pythonhosted.org/packages/3a/66/bd096b13a87549683812b53ab211e6d413497f84e794fb3c39191948da97/numpy-2.4.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bb2e3cf95854233799013779216c57e153c1ee67a0bf92138acca0e429aefaee", size = 5198435, upload-time = "2026-03-09T07:56:57.184Z" },
{ url = "https://files.pythonhosted.org/packages/a2/2f/687722910b5a5601de2135c891108f51dfc873d8e43c8ed9f4ebb440b4a2/numpy-2.4.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:7f3408ff897f8ab07a07fbe2823d7aee6ff644c097cc1f90382511fe982f647f", size = 6546347, upload-time = "2026-03-09T07:56:59.531Z" },
{ url = "https://files.pythonhosted.org/packages/bf/ec/7971c4e98d86c564750393fab8d7d83d0a9432a9d78bb8a163a6dc59967a/numpy-2.4.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:decb0eb8a53c3b009b0962378065589685d66b23467ef5dac16cbe818afde27f", size = 15664626, upload-time = "2026-03-09T07:57:01.385Z" },
{ url = "https://files.pythonhosted.org/packages/7e/eb/7daecbea84ec935b7fc732e18f532073064a3816f0932a40a17f3349185f/numpy-2.4.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5f51900414fc9204a0e0da158ba2ac52b75656e7dce7e77fb9f84bfa343b4cc", size = 16608916, upload-time = "2026-03-09T07:57:04.008Z" },
{ url = "https://files.pythonhosted.org/packages/df/58/2a2b4a817ffd7472dca4421d9f0776898b364154e30c95f42195041dc03b/numpy-2.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6bd06731541f89cdc01b261ba2c9e037f1543df7472517836b78dfb15bd6e476", size = 17015824, upload-time = "2026-03-09T07:57:06.347Z" },
{ url = "https://files.pythonhosted.org/packages/4a/ca/627a828d44e78a418c55f82dd4caea8ea4a8ef24e5144d9e71016e52fb40/numpy-2.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22654fe6be0e5206f553a9250762c653d3698e46686eee53b399ab90da59bd92", size = 18334581, upload-time = "2026-03-09T07:57:09.114Z" },
{ url = "https://files.pythonhosted.org/packages/cd/c0/76f93962fc79955fcba30a429b62304332345f22d4daec1cb33653425643/numpy-2.4.3-cp313-cp313-win32.whl", hash = "sha256:d71e379452a2f670ccb689ec801b1218cd3983e253105d6e83780967e899d687", size = 5958618, upload-time = "2026-03-09T07:57:11.432Z" },
{ url = "https://files.pythonhosted.org/packages/b1/3c/88af0040119209b9b5cb59485fa48b76f372c73068dbf9254784b975ac53/numpy-2.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:0a60e17a14d640f49146cb38e3f105f571318db7826d9b6fef7e4dce758faecd", size = 12312824, upload-time = "2026-03-09T07:57:13.586Z" },
{ url = "https://files.pythonhosted.org/packages/58/ce/3d07743aced3d173f877c3ef6a454c2174ba42b584ab0b7e6d99374f51ed/numpy-2.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:c9619741e9da2059cd9c3f206110b97583c7152c1dc9f8aafd4beb450ac1c89d", size = 10221218, upload-time = "2026-03-09T07:57:16.183Z" },
{ url = "https://files.pythonhosted.org/packages/62/09/d96b02a91d09e9d97862f4fc8bfebf5400f567d8eb1fe4b0cc4795679c15/numpy-2.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7aa4e54f6469300ebca1d9eb80acd5253cdfa36f2c03d79a35883687da430875", size = 14819570, upload-time = "2026-03-09T07:57:18.564Z" },
{ url = "https://files.pythonhosted.org/packages/b5/ca/0b1aba3905fdfa3373d523b2b15b19029f4f3031c87f4066bd9d20ef6c6b/numpy-2.4.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d1b90d840b25874cf5cd20c219af10bac3667db3876d9a495609273ebe679070", size = 5326113, upload-time = "2026-03-09T07:57:21.052Z" },
{ url = "https://files.pythonhosted.org/packages/c0/63/406e0fd32fcaeb94180fd6a4c41e55736d676c54346b7efbce548b94a914/numpy-2.4.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a749547700de0a20a6718293396ec237bb38218049cfce788e08fcb716e8cf73", size = 6646370, upload-time = "2026-03-09T07:57:22.804Z" },
{ url = "https://files.pythonhosted.org/packages/b6/d0/10f7dc157d4b37af92720a196be6f54f889e90dcd30dce9dc657ed92c257/numpy-2.4.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f3c4a151a2e529adf49c1d54f0f57ff8f9b233ee4d44af623a81553ab86368", size = 15723499, upload-time = "2026-03-09T07:57:24.693Z" },
{ url = "https://files.pythonhosted.org/packages/66/f1/d1c2bf1161396629701bc284d958dc1efa3a5a542aab83cf11ee6eb4cba5/numpy-2.4.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22c31dc07025123aedf7f2db9e91783df13f1776dc52c6b22c620870dc0fab22", size = 16657164, upload-time = "2026-03-09T07:57:27.676Z" },
{ url = "https://files.pythonhosted.org/packages/1a/be/cca19230b740af199ac47331a21c71e7a3d0ba59661350483c1600d28c37/numpy-2.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:148d59127ac95979d6f07e4d460f934ebdd6eed641db9c0db6c73026f2b2101a", size = 17081544, upload-time = "2026-03-09T07:57:30.664Z" },
{ url = "https://files.pythonhosted.org/packages/b9/c5/9602b0cbb703a0936fb40f8a95407e8171935b15846de2f0776e08af04c7/numpy-2.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a97cbf7e905c435865c2d939af3d93f99d18eaaa3cabe4256f4304fb51604349", size = 18380290, upload-time = "2026-03-09T07:57:33.763Z" },
{ url = "https://files.pythonhosted.org/packages/ed/81/9f24708953cd30be9ee36ec4778f4b112b45165812f2ada4cc5ea1c1f254/numpy-2.4.3-cp313-cp313t-win32.whl", hash = "sha256:be3b8487d725a77acccc9924f65fd8bce9af7fac8c9820df1049424a2115af6c", size = 6082814, upload-time = "2026-03-09T07:57:36.491Z" },
{ url = "https://files.pythonhosted.org/packages/e2/9e/52f6eaa13e1a799f0ab79066c17f7016a4a8ae0c1aefa58c82b4dab690b4/numpy-2.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1ec84fd7c8e652b0f4aaaf2e6e9cc8eaa9b1b80a537e06b2e3a2fb176eedcb26", size = 12452673, upload-time = "2026-03-09T07:57:38.281Z" },
{ url = "https://files.pythonhosted.org/packages/c4/04/b8cece6ead0b30c9fbd99bb835ad7ea0112ac5f39f069788c5558e3b1ab2/numpy-2.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:120df8c0a81ebbf5b9020c91439fccd85f5e018a927a39f624845be194a2be02", size = 10290907, upload-time = "2026-03-09T07:57:40.747Z" },
{ url = "https://files.pythonhosted.org/packages/70/ae/3936f79adebf8caf81bd7a599b90a561334a658be4dcc7b6329ebf4ee8de/numpy-2.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5884ce5c7acfae1e4e1b6fde43797d10aa506074d25b531b4f54bde33c0c31d4", size = 16664563, upload-time = "2026-03-09T07:57:43.817Z" },
{ url = "https://files.pythonhosted.org/packages/9b/62/760f2b55866b496bb1fa7da2a6db076bef908110e568b02fcfc1422e2a3a/numpy-2.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:297837823f5bc572c5f9379b0c9f3a3365f08492cbdc33bcc3af174372ebb168", size = 14702161, upload-time = "2026-03-09T07:57:46.169Z" },
{ url = "https://files.pythonhosted.org/packages/32/af/a7a39464e2c0a21526fb4fb76e346fb172ebc92f6d1c7a07c2c139cc17b1/numpy-2.4.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a111698b4a3f8dcbe54c64a7708f049355abd603e619013c346553c1fd4ca90b", size = 5208738, upload-time = "2026-03-09T07:57:48.506Z" },
{ url = "https://files.pythonhosted.org/packages/29/8c/2a0cf86a59558fa078d83805589c2de490f29ed4fb336c14313a161d358a/numpy-2.4.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:4bd4741a6a676770e0e97fe9ab2e51de01183df3dcbcec591d26d331a40de950", size = 6543618, upload-time = "2026-03-09T07:57:50.591Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b8/612ce010c0728b1c363fa4ea3aa4c22fe1c5da1de008486f8c2f5cb92fae/numpy-2.4.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54f29b877279d51e210e0c80709ee14ccbbad647810e8f3d375561c45ef613dd", size = 15680676, upload-time = "2026-03-09T07:57:52.34Z" },
{ url = "https://files.pythonhosted.org/packages/a9/7e/4f120ecc54ba26ddf3dc348eeb9eb063f421de65c05fc961941798feea18/numpy-2.4.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:679f2a834bae9020f81534671c56fd0cc76dd7e5182f57131478e23d0dc59e24", size = 16613492, upload-time = "2026-03-09T07:57:54.91Z" },
{ url = "https://files.pythonhosted.org/packages/2c/86/1b6020db73be330c4b45d5c6ee4295d59cfeef0e3ea323959d053e5a6909/numpy-2.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d84f0f881cb2225c2dfd7f78a10a5645d487a496c6668d6cc39f0f114164f3d0", size = 17031789, upload-time = "2026-03-09T07:57:57.641Z" },
{ url = "https://files.pythonhosted.org/packages/07/3a/3b90463bf41ebc21d1b7e06079f03070334374208c0f9a1f05e4ae8455e7/numpy-2.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d213c7e6e8d211888cc359bab7199670a00f5b82c0978b9d1c75baf1eddbeac0", size = 18339941, upload-time = "2026-03-09T07:58:00.577Z" },
{ url = "https://files.pythonhosted.org/packages/a8/74/6d736c4cd962259fd8bae9be27363eb4883a2f9069763747347544c2a487/numpy-2.4.3-cp314-cp314-win32.whl", hash = "sha256:52077feedeff7c76ed7c9f1a0428558e50825347b7545bbb8523da2cd55c547a", size = 6007503, upload-time = "2026-03-09T07:58:03.331Z" },
{ url = "https://files.pythonhosted.org/packages/48/39/c56ef87af669364356bb011922ef0734fc49dad51964568634c72a009488/numpy-2.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:0448e7f9caefb34b4b7dd2b77f21e8906e5d6f0365ad525f9f4f530b13df2afc", size = 12444915, upload-time = "2026-03-09T07:58:06.353Z" },
{ url = "https://files.pythonhosted.org/packages/9d/1f/ab8528e38d295fd349310807496fabb7cf9fe2e1f70b97bc20a483ea9d4a/numpy-2.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:b44fd60341c4d9783039598efadd03617fa28d041fc37d22b62d08f2027fa0e7", size = 10494875, upload-time = "2026-03-09T07:58:08.734Z" },
{ url = "https://files.pythonhosted.org/packages/e6/ef/b7c35e4d5ef141b836658ab21a66d1a573e15b335b1d111d31f26c8ef80f/numpy-2.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0a195f4216be9305a73c0e91c9b026a35f2161237cf1c6de9b681637772ea657", size = 14822225, upload-time = "2026-03-09T07:58:11.034Z" },
{ url = "https://files.pythonhosted.org/packages/cd/8d/7730fa9278cf6648639946cc816e7cc89f0d891602584697923375f801ed/numpy-2.4.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:cd32fbacb9fd1bf041bf8e89e4576b6f00b895f06d00914820ae06a616bdfef7", size = 5328769, upload-time = "2026-03-09T07:58:13.67Z" },
{ url = "https://files.pythonhosted.org/packages/47/01/d2a137317c958b074d338807c1b6a383406cdf8b8e53b075d804cc3d211d/numpy-2.4.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:2e03c05abaee1f672e9d67bc858f300b5ccba1c21397211e8d77d98350972093", size = 6649461, upload-time = "2026-03-09T07:58:15.912Z" },
{ url = "https://files.pythonhosted.org/packages/5c/34/812ce12bc0f00272a4b0ec0d713cd237cb390666eb6206323d1cc9cedbb2/numpy-2.4.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d1ce23cce91fcea443320a9d0ece9b9305d4368875bab09538f7a5b4131938a", size = 15725809, upload-time = "2026-03-09T07:58:17.787Z" },
{ url = "https://files.pythonhosted.org/packages/25/c0/2aed473a4823e905e765fee3dc2cbf504bd3e68ccb1150fbdabd5c39f527/numpy-2.4.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c59020932feb24ed49ffd03704fbab89f22aa9c0d4b180ff45542fe8918f5611", size = 16655242, upload-time = "2026-03-09T07:58:20.476Z" },
{ url = "https://files.pythonhosted.org/packages/f2/c8/7e052b2fc87aa0e86de23f20e2c42bd261c624748aa8efd2c78f7bb8d8c6/numpy-2.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9684823a78a6cd6ad7511fc5e25b07947d1d5b5e2812c93fe99d7d4195130720", size = 17080660, upload-time = "2026-03-09T07:58:23.067Z" },
{ url = "https://files.pythonhosted.org/packages/f3/3d/0876746044db2adcb11549f214d104f2e1be00f07a67edbb4e2812094847/numpy-2.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0200b25c687033316fb39f0ff4e3e690e8957a2c3c8d22499891ec58c37a3eb5", size = 18380384, upload-time = "2026-03-09T07:58:25.839Z" },
{ url = "https://files.pythonhosted.org/packages/07/12/8160bea39da3335737b10308df4f484235fd297f556745f13092aa039d3b/numpy-2.4.3-cp314-cp314t-win32.whl", hash = "sha256:5e10da9e93247e554bb1d22f8edc51847ddd7dde52d85ce31024c1b4312bfba0", size = 6154547, upload-time = "2026-03-09T07:58:28.289Z" },
{ url = "https://files.pythonhosted.org/packages/42/f3/76534f61f80d74cc9cdf2e570d3d4eeb92c2280a27c39b0aaf471eda7b48/numpy-2.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:45f003dbdffb997a03da2d1d0cb41fbd24a87507fb41605c0420a3db5bd4667b", size = 12633645, upload-time = "2026-03-09T07:58:30.384Z" },
{ url = "https://files.pythonhosted.org/packages/1f/b6/7c0d4334c15983cec7f92a69e8ce9b1e6f31857e5ee3a413ac424e6bd63d/numpy-2.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:4d382735cecd7bcf090172489a525cd7d4087bc331f7df9f60ddc9a296cf208e", size = 10565454, upload-time = "2026-03-09T07:58:33.031Z" },
{ url = "https://files.pythonhosted.org/packages/64/e4/4dab9fb43c83719c29241c535d9e07be73bea4bc0c6686c5816d8e1b6689/numpy-2.4.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c6b124bfcafb9e8d3ed09130dbee44848c20b3e758b6bbf006e641778927c028", size = 16834892, upload-time = "2026-03-09T07:58:35.334Z" },
{ url = "https://files.pythonhosted.org/packages/c9/29/f8b6d4af90fed3dfda84ebc0df06c9833d38880c79ce954e5b661758aa31/numpy-2.4.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:76dbb9d4e43c16cf9aa711fcd8de1e2eeb27539dcefb60a1d5e9f12fae1d1ed8", size = 14893070, upload-time = "2026-03-09T07:58:37.7Z" },
{ url = "https://files.pythonhosted.org/packages/9a/04/a19b3c91dbec0a49269407f15d5753673a09832daed40c45e8150e6fa558/numpy-2.4.3-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:29363fbfa6f8ee855d7569c96ce524845e3d726d6c19b29eceec7dd555dab152", size = 5399609, upload-time = "2026-03-09T07:58:39.853Z" },
{ url = "https://files.pythonhosted.org/packages/79/34/4d73603f5420eab89ea8a67097b31364bf7c30f811d4dd84b1659c7476d9/numpy-2.4.3-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:bc71942c789ef415a37f0d4eab90341425a00d538cd0642445d30b41023d3395", size = 6714355, upload-time = "2026-03-09T07:58:42.365Z" },
{ url = "https://files.pythonhosted.org/packages/58/ad/1100d7229bb248394939a12a8074d485b655e8ed44207d328fdd7fcebc7b/numpy-2.4.3-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e58765ad74dcebd3ef0208a5078fba32dc8ec3578fe84a604432950cd043d79", size = 15800434, upload-time = "2026-03-09T07:58:44.837Z" },
{ url = "https://files.pythonhosted.org/packages/0c/fd/16d710c085d28ba4feaf29ac60c936c9d662e390344f94a6beaa2ac9899b/numpy-2.4.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e236dbda4e1d319d681afcbb136c0c4a8e0f1a5c58ceec2adebb547357fe857", size = 16729409, upload-time = "2026-03-09T07:58:47.972Z" },
{ url = "https://files.pythonhosted.org/packages/57/a7/b35835e278c18b85206834b3aa3abe68e77a98769c59233d1f6300284781/numpy-2.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4b42639cdde6d24e732ff823a3fa5b701d8acad89c4142bc1d0bd6dc85200ba5", size = 12504685, upload-time = "2026-03-09T07:58:50.525Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f9/51/5093a2df15c4dc19da3f79d1021e891f5dcf1d9d1db6ba38891d5590f3fe/numpy-2.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:33b3bf58ee84b172c067f56aeadc7ee9ab6de69c5e800ab5b10295d54c581adb", size = 16957183, upload-time = "2026-03-09T07:55:57.774Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/b5/7c/c061f3de0630941073d2598dc271ac2f6cbcf5c83c74a5870fea07488333/numpy-2.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8ba7b51e71c05aa1f9bc3641463cd82308eab40ce0d5c7e1fd4038cbf9938147", size = 14968734, upload-time = "2026-03-09T07:56:00.494Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ef/27/d26c85cbcd86b26e4f125b0668e7a7c0542d19dd7d23ee12e87b550e95b5/numpy-2.4.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a1988292870c7cb9d0ebb4cc96b4d447513a9644801de54606dc7aabf2b7d920", size = 5475288, upload-time = "2026-03-09T07:56:02.857Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/2b/09/3c4abbc1dcd8010bf1a611d174c7aa689fc505585ec806111b4406f6f1b1/numpy-2.4.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:23b46bb6d8ecb68b58c09944483c135ae5f0e9b8d8858ece5e4ead783771d2a9", size = 6805253, upload-time = "2026-03-09T07:56:04.53Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/21/bc/e7aa3f6817e40c3f517d407742337cbb8e6fc4b83ce0b55ab780c829243b/numpy-2.4.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a016db5c5dba78fa8fe9f5d80d6708f9c42ab087a739803c0ac83a43d686a470", size = 15969479, upload-time = "2026-03-09T07:56:06.638Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/78/51/9f5d7a41f0b51649ddf2f2320595e15e122a40610b233d51928dd6c92353/numpy-2.4.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:715de7f82e192e8cae5a507a347d97ad17598f8e026152ca97233e3666daaa71", size = 16901035, upload-time = "2026-03-09T07:56:09.405Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/64/6e/b221dd847d7181bc5ee4857bfb026182ef69499f9305eb1371cbb1aea626/numpy-2.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2ddb7919366ee468342b91dea2352824c25b55814a987847b6c52003a7c97f15", size = 17325657, upload-time = "2026-03-09T07:56:12.067Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/eb/b8/8f3fd2da596e1063964b758b5e3c970aed1949a05200d7e3d46a9d46d643/numpy-2.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a315e5234d88067f2d97e1f2ef670a7569df445d55400f1e33d117418d008d52", size = 18635512, upload-time = "2026-03-09T07:56:14.629Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/5c/24/2993b775c37e39d2f8ab4125b44337ab0b2ba106c100980b7c274a22bee7/numpy-2.4.3-cp311-cp311-win32.whl", hash = "sha256:2b3f8d2c4589b1a2028d2a770b0fc4d1f332fb5e01521f4de3199a896d158ddd", size = 6238100, upload-time = "2026-03-09T07:56:17.243Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/76/1d/edccf27adedb754db7c4511d5eac8b83f004ae948fe2d3509e8b78097d4c/numpy-2.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:77e76d932c49a75617c6d13464e41203cd410956614d0a0e999b25e9e8d27eec", size = 12609816, upload-time = "2026-03-09T07:56:19.089Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/92/82/190b99153480076c8dce85f4cfe7d53ea84444145ffa54cb58dcd460d66b/numpy-2.4.3-cp311-cp311-win_arm64.whl", hash = "sha256:eb610595dd91560905c132c709412b512135a60f1851ccbd2c959e136431ff67", size = 10485757, upload-time = "2026-03-09T07:56:21.753Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a9/ed/6388632536f9788cea23a3a1b629f25b43eaacd7d7377e5d6bc7b9deb69b/numpy-2.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61b0cbabbb6126c8df63b9a3a0c4b1f44ebca5e12ff6997b80fcf267fb3150ef", size = 16669628, upload-time = "2026-03-09T07:56:24.252Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/74/1b/ee2abfc68e1ce728b2958b6ba831d65c62e1b13ce3017c13943f8f9b5b2e/numpy-2.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7395e69ff32526710748f92cd8c9849b361830968ea3e24a676f272653e8983e", size = 14696872, upload-time = "2026-03-09T07:56:26.991Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ba/d1/780400e915ff5638166f11ca9dc2c5815189f3d7cf6f8759a1685e586413/numpy-2.4.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:abdce0f71dcb4a00e4e77f3faf05e4616ceccfe72ccaa07f47ee79cda3b7b0f4", size = 5203489, upload-time = "2026-03-09T07:56:29.414Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/0b/bb/baffa907e9da4cc34a6e556d6d90e032f6d7a75ea47968ea92b4858826c4/numpy-2.4.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:48da3a4ee1336454b07497ff7ec83903efa5505792c4e6d9bf83d99dc07a1e18", size = 6550814, upload-time = "2026-03-09T07:56:32.225Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/7b/12/8c9f0c6c95f76aeb20fc4a699c33e9f827fa0d0f857747c73bb7b17af945/numpy-2.4.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e3bef222ad6b052280311d1d60db8e259e4947052c3ae7dd6817451fc8a4c5", size = 15666601, upload-time = "2026-03-09T07:56:34.461Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/bd/79/cc665495e4d57d0aa6fbcc0aa57aa82671dfc78fbf95fe733ed86d98f52a/numpy-2.4.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7dd01a46700b1967487141a66ac1a3cf0dd8ebf1f08db37d46389401512ca97", size = 16621358, upload-time = "2026-03-09T07:56:36.852Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a8/40/b4ecb7224af1065c3539f5ecfff879d090de09608ad1008f02c05c770cb3/numpy-2.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76f0f283506c28b12bba319c0fab98217e9f9b54e6160e9c79e9f7348ba32e9c", size = 17016135, upload-time = "2026-03-09T07:56:39.337Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f7/b1/6a88e888052eed951afed7a142dcdf3b149a030ca59b4c71eef085858e43/numpy-2.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:737f630a337364665aba3b5a77e56a68cc42d350edd010c345d65a3efa3addcc", size = 18345816, upload-time = "2026-03-09T07:56:42.31Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f3/8f/103a60c5f8c3d7fc678c19cd7b2476110da689ccb80bc18050efbaeae183/numpy-2.4.3-cp312-cp312-win32.whl", hash = "sha256:26952e18d82a1dbbc2f008d402021baa8d6fc8e84347a2072a25e08b46d698b9", size = 5960132, upload-time = "2026-03-09T07:56:44.851Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/d7/7c/f5ee1bf6ed888494978046a809df2882aad35d414b622893322df7286879/numpy-2.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:65f3c2455188f09678355f5cae1f959a06b778bc66d535da07bf2ef20cd319d5", size = 12316144, upload-time = "2026-03-09T07:56:47.057Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/71/46/8d1cb3f7a00f2fb6394140e7e6623696e54c6318a9d9691bb4904672cf42/numpy-2.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:2abad5c7fef172b3377502bde47892439bae394a71bc329f31df0fd829b41a9e", size = 10220364, upload-time = "2026-03-09T07:56:49.849Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/b6/d0/1fe47a98ce0df229238b77611340aff92d52691bcbc10583303181abf7fc/numpy-2.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b346845443716c8e542d54112966383b448f4a3ba5c66409771b8c0889485dd3", size = 16665297, upload-time = "2026-03-09T07:56:52.296Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/27/d9/4e7c3f0e68dfa91f21c6fb6cf839bc829ec920688b1ce7ec722b1a6202fb/numpy-2.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2629289168f4897a3c4e23dc98d6f1731f0fc0fe52fb9db19f974041e4cc12b9", size = 14691853, upload-time = "2026-03-09T07:56:54.992Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/3a/66/bd096b13a87549683812b53ab211e6d413497f84e794fb3c39191948da97/numpy-2.4.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bb2e3cf95854233799013779216c57e153c1ee67a0bf92138acca0e429aefaee", size = 5198435, upload-time = "2026-03-09T07:56:57.184Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a2/2f/687722910b5a5601de2135c891108f51dfc873d8e43c8ed9f4ebb440b4a2/numpy-2.4.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:7f3408ff897f8ab07a07fbe2823d7aee6ff644c097cc1f90382511fe982f647f", size = 6546347, upload-time = "2026-03-09T07:56:59.531Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/bf/ec/7971c4e98d86c564750393fab8d7d83d0a9432a9d78bb8a163a6dc59967a/numpy-2.4.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:decb0eb8a53c3b009b0962378065589685d66b23467ef5dac16cbe818afde27f", size = 15664626, upload-time = "2026-03-09T07:57:01.385Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/7e/eb/7daecbea84ec935b7fc732e18f532073064a3816f0932a40a17f3349185f/numpy-2.4.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5f51900414fc9204a0e0da158ba2ac52b75656e7dce7e77fb9f84bfa343b4cc", size = 16608916, upload-time = "2026-03-09T07:57:04.008Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/df/58/2a2b4a817ffd7472dca4421d9f0776898b364154e30c95f42195041dc03b/numpy-2.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6bd06731541f89cdc01b261ba2c9e037f1543df7472517836b78dfb15bd6e476", size = 17015824, upload-time = "2026-03-09T07:57:06.347Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/4a/ca/627a828d44e78a418c55f82dd4caea8ea4a8ef24e5144d9e71016e52fb40/numpy-2.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22654fe6be0e5206f553a9250762c653d3698e46686eee53b399ab90da59bd92", size = 18334581, upload-time = "2026-03-09T07:57:09.114Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/cd/c0/76f93962fc79955fcba30a429b62304332345f22d4daec1cb33653425643/numpy-2.4.3-cp313-cp313-win32.whl", hash = "sha256:d71e379452a2f670ccb689ec801b1218cd3983e253105d6e83780967e899d687", size = 5958618, upload-time = "2026-03-09T07:57:11.432Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/b1/3c/88af0040119209b9b5cb59485fa48b76f372c73068dbf9254784b975ac53/numpy-2.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:0a60e17a14d640f49146cb38e3f105f571318db7826d9b6fef7e4dce758faecd", size = 12312824, upload-time = "2026-03-09T07:57:13.586Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/58/ce/3d07743aced3d173f877c3ef6a454c2174ba42b584ab0b7e6d99374f51ed/numpy-2.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:c9619741e9da2059cd9c3f206110b97583c7152c1dc9f8aafd4beb450ac1c89d", size = 10221218, upload-time = "2026-03-09T07:57:16.183Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/62/09/d96b02a91d09e9d97862f4fc8bfebf5400f567d8eb1fe4b0cc4795679c15/numpy-2.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7aa4e54f6469300ebca1d9eb80acd5253cdfa36f2c03d79a35883687da430875", size = 14819570, upload-time = "2026-03-09T07:57:18.564Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/b5/ca/0b1aba3905fdfa3373d523b2b15b19029f4f3031c87f4066bd9d20ef6c6b/numpy-2.4.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d1b90d840b25874cf5cd20c219af10bac3667db3876d9a495609273ebe679070", size = 5326113, upload-time = "2026-03-09T07:57:21.052Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c0/63/406e0fd32fcaeb94180fd6a4c41e55736d676c54346b7efbce548b94a914/numpy-2.4.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a749547700de0a20a6718293396ec237bb38218049cfce788e08fcb716e8cf73", size = 6646370, upload-time = "2026-03-09T07:57:22.804Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/b6/d0/10f7dc157d4b37af92720a196be6f54f889e90dcd30dce9dc657ed92c257/numpy-2.4.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f3c4a151a2e529adf49c1d54f0f57ff8f9b233ee4d44af623a81553ab86368", size = 15723499, upload-time = "2026-03-09T07:57:24.693Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/66/f1/d1c2bf1161396629701bc284d958dc1efa3a5a542aab83cf11ee6eb4cba5/numpy-2.4.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22c31dc07025123aedf7f2db9e91783df13f1776dc52c6b22c620870dc0fab22", size = 16657164, upload-time = "2026-03-09T07:57:27.676Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/1a/be/cca19230b740af199ac47331a21c71e7a3d0ba59661350483c1600d28c37/numpy-2.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:148d59127ac95979d6f07e4d460f934ebdd6eed641db9c0db6c73026f2b2101a", size = 17081544, upload-time = "2026-03-09T07:57:30.664Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/b9/c5/9602b0cbb703a0936fb40f8a95407e8171935b15846de2f0776e08af04c7/numpy-2.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a97cbf7e905c435865c2d939af3d93f99d18eaaa3cabe4256f4304fb51604349", size = 18380290, upload-time = "2026-03-09T07:57:33.763Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ed/81/9f24708953cd30be9ee36ec4778f4b112b45165812f2ada4cc5ea1c1f254/numpy-2.4.3-cp313-cp313t-win32.whl", hash = "sha256:be3b8487d725a77acccc9924f65fd8bce9af7fac8c9820df1049424a2115af6c", size = 6082814, upload-time = "2026-03-09T07:57:36.491Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e2/9e/52f6eaa13e1a799f0ab79066c17f7016a4a8ae0c1aefa58c82b4dab690b4/numpy-2.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1ec84fd7c8e652b0f4aaaf2e6e9cc8eaa9b1b80a537e06b2e3a2fb176eedcb26", size = 12452673, upload-time = "2026-03-09T07:57:38.281Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c4/04/b8cece6ead0b30c9fbd99bb835ad7ea0112ac5f39f069788c5558e3b1ab2/numpy-2.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:120df8c0a81ebbf5b9020c91439fccd85f5e018a927a39f624845be194a2be02", size = 10290907, upload-time = "2026-03-09T07:57:40.747Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/70/ae/3936f79adebf8caf81bd7a599b90a561334a658be4dcc7b6329ebf4ee8de/numpy-2.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5884ce5c7acfae1e4e1b6fde43797d10aa506074d25b531b4f54bde33c0c31d4", size = 16664563, upload-time = "2026-03-09T07:57:43.817Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/9b/62/760f2b55866b496bb1fa7da2a6db076bef908110e568b02fcfc1422e2a3a/numpy-2.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:297837823f5bc572c5f9379b0c9f3a3365f08492cbdc33bcc3af174372ebb168", size = 14702161, upload-time = "2026-03-09T07:57:46.169Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/32/af/a7a39464e2c0a21526fb4fb76e346fb172ebc92f6d1c7a07c2c139cc17b1/numpy-2.4.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a111698b4a3f8dcbe54c64a7708f049355abd603e619013c346553c1fd4ca90b", size = 5208738, upload-time = "2026-03-09T07:57:48.506Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/29/8c/2a0cf86a59558fa078d83805589c2de490f29ed4fb336c14313a161d358a/numpy-2.4.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:4bd4741a6a676770e0e97fe9ab2e51de01183df3dcbcec591d26d331a40de950", size = 6543618, upload-time = "2026-03-09T07:57:50.591Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/aa/b8/612ce010c0728b1c363fa4ea3aa4c22fe1c5da1de008486f8c2f5cb92fae/numpy-2.4.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54f29b877279d51e210e0c80709ee14ccbbad647810e8f3d375561c45ef613dd", size = 15680676, upload-time = "2026-03-09T07:57:52.34Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a9/7e/4f120ecc54ba26ddf3dc348eeb9eb063f421de65c05fc961941798feea18/numpy-2.4.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:679f2a834bae9020f81534671c56fd0cc76dd7e5182f57131478e23d0dc59e24", size = 16613492, upload-time = "2026-03-09T07:57:54.91Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/2c/86/1b6020db73be330c4b45d5c6ee4295d59cfeef0e3ea323959d053e5a6909/numpy-2.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d84f0f881cb2225c2dfd7f78a10a5645d487a496c6668d6cc39f0f114164f3d0", size = 17031789, upload-time = "2026-03-09T07:57:57.641Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/07/3a/3b90463bf41ebc21d1b7e06079f03070334374208c0f9a1f05e4ae8455e7/numpy-2.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d213c7e6e8d211888cc359bab7199670a00f5b82c0978b9d1c75baf1eddbeac0", size = 18339941, upload-time = "2026-03-09T07:58:00.577Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a8/74/6d736c4cd962259fd8bae9be27363eb4883a2f9069763747347544c2a487/numpy-2.4.3-cp314-cp314-win32.whl", hash = "sha256:52077feedeff7c76ed7c9f1a0428558e50825347b7545bbb8523da2cd55c547a", size = 6007503, upload-time = "2026-03-09T07:58:03.331Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/48/39/c56ef87af669364356bb011922ef0734fc49dad51964568634c72a009488/numpy-2.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:0448e7f9caefb34b4b7dd2b77f21e8906e5d6f0365ad525f9f4f530b13df2afc", size = 12444915, upload-time = "2026-03-09T07:58:06.353Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/9d/1f/ab8528e38d295fd349310807496fabb7cf9fe2e1f70b97bc20a483ea9d4a/numpy-2.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:b44fd60341c4d9783039598efadd03617fa28d041fc37d22b62d08f2027fa0e7", size = 10494875, upload-time = "2026-03-09T07:58:08.734Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e6/ef/b7c35e4d5ef141b836658ab21a66d1a573e15b335b1d111d31f26c8ef80f/numpy-2.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0a195f4216be9305a73c0e91c9b026a35f2161237cf1c6de9b681637772ea657", size = 14822225, upload-time = "2026-03-09T07:58:11.034Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/cd/8d/7730fa9278cf6648639946cc816e7cc89f0d891602584697923375f801ed/numpy-2.4.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:cd32fbacb9fd1bf041bf8e89e4576b6f00b895f06d00914820ae06a616bdfef7", size = 5328769, upload-time = "2026-03-09T07:58:13.67Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/47/01/d2a137317c958b074d338807c1b6a383406cdf8b8e53b075d804cc3d211d/numpy-2.4.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:2e03c05abaee1f672e9d67bc858f300b5ccba1c21397211e8d77d98350972093", size = 6649461, upload-time = "2026-03-09T07:58:15.912Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/5c/34/812ce12bc0f00272a4b0ec0d713cd237cb390666eb6206323d1cc9cedbb2/numpy-2.4.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d1ce23cce91fcea443320a9d0ece9b9305d4368875bab09538f7a5b4131938a", size = 15725809, upload-time = "2026-03-09T07:58:17.787Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/25/c0/2aed473a4823e905e765fee3dc2cbf504bd3e68ccb1150fbdabd5c39f527/numpy-2.4.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c59020932feb24ed49ffd03704fbab89f22aa9c0d4b180ff45542fe8918f5611", size = 16655242, upload-time = "2026-03-09T07:58:20.476Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f2/c8/7e052b2fc87aa0e86de23f20e2c42bd261c624748aa8efd2c78f7bb8d8c6/numpy-2.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9684823a78a6cd6ad7511fc5e25b07947d1d5b5e2812c93fe99d7d4195130720", size = 17080660, upload-time = "2026-03-09T07:58:23.067Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f3/3d/0876746044db2adcb11549f214d104f2e1be00f07a67edbb4e2812094847/numpy-2.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0200b25c687033316fb39f0ff4e3e690e8957a2c3c8d22499891ec58c37a3eb5", size = 18380384, upload-time = "2026-03-09T07:58:25.839Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/07/12/8160bea39da3335737b10308df4f484235fd297f556745f13092aa039d3b/numpy-2.4.3-cp314-cp314t-win32.whl", hash = "sha256:5e10da9e93247e554bb1d22f8edc51847ddd7dde52d85ce31024c1b4312bfba0", size = 6154547, upload-time = "2026-03-09T07:58:28.289Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/42/f3/76534f61f80d74cc9cdf2e570d3d4eeb92c2280a27c39b0aaf471eda7b48/numpy-2.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:45f003dbdffb997a03da2d1d0cb41fbd24a87507fb41605c0420a3db5bd4667b", size = 12633645, upload-time = "2026-03-09T07:58:30.384Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/1f/b6/7c0d4334c15983cec7f92a69e8ce9b1e6f31857e5ee3a413ac424e6bd63d/numpy-2.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:4d382735cecd7bcf090172489a525cd7d4087bc331f7df9f60ddc9a296cf208e", size = 10565454, upload-time = "2026-03-09T07:58:33.031Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/64/e4/4dab9fb43c83719c29241c535d9e07be73bea4bc0c6686c5816d8e1b6689/numpy-2.4.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c6b124bfcafb9e8d3ed09130dbee44848c20b3e758b6bbf006e641778927c028", size = 16834892, upload-time = "2026-03-09T07:58:35.334Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c9/29/f8b6d4af90fed3dfda84ebc0df06c9833d38880c79ce954e5b661758aa31/numpy-2.4.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:76dbb9d4e43c16cf9aa711fcd8de1e2eeb27539dcefb60a1d5e9f12fae1d1ed8", size = 14893070, upload-time = "2026-03-09T07:58:37.7Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/9a/04/a19b3c91dbec0a49269407f15d5753673a09832daed40c45e8150e6fa558/numpy-2.4.3-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:29363fbfa6f8ee855d7569c96ce524845e3d726d6c19b29eceec7dd555dab152", size = 5399609, upload-time = "2026-03-09T07:58:39.853Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/79/34/4d73603f5420eab89ea8a67097b31364bf7c30f811d4dd84b1659c7476d9/numpy-2.4.3-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:bc71942c789ef415a37f0d4eab90341425a00d538cd0642445d30b41023d3395", size = 6714355, upload-time = "2026-03-09T07:58:42.365Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/58/ad/1100d7229bb248394939a12a8074d485b655e8ed44207d328fdd7fcebc7b/numpy-2.4.3-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e58765ad74dcebd3ef0208a5078fba32dc8ec3578fe84a604432950cd043d79", size = 15800434, upload-time = "2026-03-09T07:58:44.837Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/0c/fd/16d710c085d28ba4feaf29ac60c936c9d662e390344f94a6beaa2ac9899b/numpy-2.4.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e236dbda4e1d319d681afcbb136c0c4a8e0f1a5c58ceec2adebb547357fe857", size = 16729409, upload-time = "2026-03-09T07:58:47.972Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/57/a7/b35835e278c18b85206834b3aa3abe68e77a98769c59233d1f6300284781/numpy-2.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4b42639cdde6d24e732ff823a3fa5b701d8acad89c4142bc1d0bd6dc85200ba5", size = 12504685, upload-time = "2026-03-09T07:58:50.525Z" },
]
[[package]]
name = "opencv-python-headless"
version = "4.13.0.92"
source = { registry = "https://pypi.org/simple" }
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" }
dependencies = [
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "numpy", version = "2.2.6", source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" }, marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "2.4.3", source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" }, marker = "python_full_version >= '3.11'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/42/2310883be3b8826ac58c3f2787b9358a2d46923d61f88fedf930bc59c60c/opencv_python_headless-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:1a7d040ac656c11b8c38677cc8cccdc149f98535089dbe5b081e80a4e5903209", size = 46247192, upload-time = "2026-02-05T07:01:35.187Z" },
{ url = "https://files.pythonhosted.org/packages/2d/1e/6f9e38005a6f7f22af785df42a43139d0e20f169eb5787ce8be37ee7fcc9/opencv_python_headless-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:3e0a6f0a37994ec6ce5f59e936be21d5d6384a4556f2d2da9c2f9c5dc948394c", size = 32568914, upload-time = "2026-02-05T07:01:51.989Z" },
{ url = "https://files.pythonhosted.org/packages/21/76/9417a6aef9def70e467a5bf560579f816148a4c658b7d525581b356eda9e/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c8cfc8e87ed452b5cecb9419473ee5560a989859fe1d10d1ce11ae87b09a2cb", size = 33703709, upload-time = "2026-02-05T10:24:46.469Z" },
{ url = "https://files.pythonhosted.org/packages/92/ce/bd17ff5772938267fd49716e94ca24f616ff4cb1ff4c6be13085108037be/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0525a3d2c0b46c611e2130b5fdebc94cf404845d8fa64d2f3a3b679572a5bd22", size = 56016764, upload-time = "2026-02-05T10:26:48.904Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b4/b7bcbf7c874665825a8c8e1097e93ea25d1f1d210a3e20d4451d01da30aa/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb60e36b237b1ebd40a912da5384b348df8ed534f6f644d8e0b4f103e272ba7d", size = 35010236, upload-time = "2026-02-05T10:28:11.031Z" },
{ url = "https://files.pythonhosted.org/packages/4b/33/b5db29a6c00eb8f50708110d8d453747ca125c8b805bc437b289dbdcc057/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0bd48544f77c68b2941392fcdf9bcd2b9cdf00e98cb8c29b2455d194763cf99e", size = 60391106, upload-time = "2026-02-05T10:30:14.236Z" },
{ url = "https://files.pythonhosted.org/packages/fb/c3/52cfea47cd33e53e8c0fbd6e7c800b457245c1fda7d61660b4ffe9596a7f/opencv_python_headless-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:a7cf08e5b191f4ebb530791acc0825a7986e0d0dee2a3c491184bd8599848a4b", size = 30812232, upload-time = "2026-02-05T07:02:29.594Z" },
{ url = "https://files.pythonhosted.org/packages/4a/90/b338326131ccb2aaa3c2c85d00f41822c0050139a4bfe723cfd95455bd2d/opencv_python_headless-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:77a82fe35ddcec0f62c15f2ba8a12ecc2ed4207c17b0902c7a3151ae29f37fb6", size = 40070414, upload-time = "2026-02-05T07:02:26.448Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/79/42/2310883be3b8826ac58c3f2787b9358a2d46923d61f88fedf930bc59c60c/opencv_python_headless-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:1a7d040ac656c11b8c38677cc8cccdc149f98535089dbe5b081e80a4e5903209", size = 46247192, upload-time = "2026-02-05T07:01:35.187Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/2d/1e/6f9e38005a6f7f22af785df42a43139d0e20f169eb5787ce8be37ee7fcc9/opencv_python_headless-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:3e0a6f0a37994ec6ce5f59e936be21d5d6384a4556f2d2da9c2f9c5dc948394c", size = 32568914, upload-time = "2026-02-05T07:01:51.989Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/21/76/9417a6aef9def70e467a5bf560579f816148a4c658b7d525581b356eda9e/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c8cfc8e87ed452b5cecb9419473ee5560a989859fe1d10d1ce11ae87b09a2cb", size = 33703709, upload-time = "2026-02-05T10:24:46.469Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/92/ce/bd17ff5772938267fd49716e94ca24f616ff4cb1ff4c6be13085108037be/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0525a3d2c0b46c611e2130b5fdebc94cf404845d8fa64d2f3a3b679572a5bd22", size = 56016764, upload-time = "2026-02-05T10:26:48.904Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8f/b4/b7bcbf7c874665825a8c8e1097e93ea25d1f1d210a3e20d4451d01da30aa/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb60e36b237b1ebd40a912da5384b348df8ed534f6f644d8e0b4f103e272ba7d", size = 35010236, upload-time = "2026-02-05T10:28:11.031Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/4b/33/b5db29a6c00eb8f50708110d8d453747ca125c8b805bc437b289dbdcc057/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0bd48544f77c68b2941392fcdf9bcd2b9cdf00e98cb8c29b2455d194763cf99e", size = 60391106, upload-time = "2026-02-05T10:30:14.236Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/fb/c3/52cfea47cd33e53e8c0fbd6e7c800b457245c1fda7d61660b4ffe9596a7f/opencv_python_headless-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:a7cf08e5b191f4ebb530791acc0825a7986e0d0dee2a3c491184bd8599848a4b", size = 30812232, upload-time = "2026-02-05T07:02:29.594Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/4a/90/b338326131ccb2aaa3c2c85d00f41822c0050139a4bfe723cfd95455bd2d/opencv_python_headless-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:77a82fe35ddcec0f62c15f2ba8a12ecc2ed4207c17b0902c7a3151ae29f37fb6", size = 40070414, upload-time = "2026-02-05T07:02:26.448Z" },
]
[[package]]
name = "progress-table"
version = "3.2.2"
source = { registry = "https://pypi.org/simple" }
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" }
dependencies = [
{ name = "colorama" },
{ name = "wcwidth" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/11/dce0b115815943d41392966de72e2d47adf8273a62a27e1ac6d14dd57c15/progress_table-3.2.2.tar.gz", hash = "sha256:6a415cc7b61f1f3ea2c3fe322d93abb77eb3b6db268b281673d88eee95bf692e", size = 28404, upload-time = "2025-12-05T18:11:41.534Z" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e9/11/dce0b115815943d41392966de72e2d47adf8273a62a27e1ac6d14dd57c15/progress_table-3.2.2.tar.gz", hash = "sha256:6a415cc7b61f1f3ea2c3fe322d93abb77eb3b6db268b281673d88eee95bf692e", size = 28404, upload-time = "2025-12-05T18:11:41.534Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/06/01/0e7b331ece3f6dd69d4776aa351862a19c3f9493ba2a662331c6530c8466/progress_table-3.2.2-py3-none-any.whl", hash = "sha256:e0e35a23e726310b347db508c2da661a4abbda30ef7ad41963d7f4306704521e", size = 20967, upload-time = "2025-12-05T18:11:40.385Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/06/01/0e7b331ece3f6dd69d4776aa351862a19c3f9493ba2a662331c6530c8466/progress_table-3.2.2-py3-none-any.whl", hash = "sha256:e0e35a23e726310b347db508c2da661a4abbda30ef7ad41963d7f4306704521e", size = 20967, upload-time = "2025-12-05T18:11:40.385Z" },
]
[[package]]
name = "protobuf"
version = "7.34.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708, upload-time = "2026-03-20T17:34:47.036Z" }
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708, upload-time = "2026-03-20T17:34:47.036Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" },
{ url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" },
{ url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" },
{ url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" },
{ url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" },
{ url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" },
{ url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" },
]
[[package]]
@@ -332,110 +332,110 @@ source = { git = "https://github.com/crosstyan/rvl-impl.git?rev=74308bcaf184cb39
[[package]]
name = "tqdm"
version = "4.67.3"
source = { registry = "https://pypi.org/simple" }
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
]
[[package]]
name = "wcwidth"
version = "0.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" }
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" },
]
[[package]]
name = "zstandard"
version = "0.25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" }
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/7a/28efd1d371f1acd037ac64ed1c5e2b41514a6cc937dd6ab6a13ab9f0702f/zstandard-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd", size = 795256, upload-time = "2025-09-14T22:15:56.415Z" },
{ url = "https://files.pythonhosted.org/packages/96/34/ef34ef77f1ee38fc8e4f9775217a613b452916e633c4f1d98f31db52c4a5/zstandard-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7", size = 640565, upload-time = "2025-09-14T22:15:58.177Z" },
{ url = "https://files.pythonhosted.org/packages/9d/1b/4fdb2c12eb58f31f28c4d28e8dc36611dd7205df8452e63f52fb6261d13e/zstandard-0.25.0-cp310-cp310-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:ab85470ab54c2cb96e176f40342d9ed41e58ca5733be6a893b730e7af9c40550", size = 5345306, upload-time = "2025-09-14T22:16:00.165Z" },
{ url = "https://files.pythonhosted.org/packages/73/28/a44bdece01bca027b079f0e00be3b6bd89a4df180071da59a3dd7381665b/zstandard-0.25.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e05ab82ea7753354bb054b92e2f288afb750e6b439ff6ca78af52939ebbc476d", size = 5055561, upload-time = "2025-09-14T22:16:02.22Z" },
{ url = "https://files.pythonhosted.org/packages/e9/74/68341185a4f32b274e0fc3410d5ad0750497e1acc20bd0f5b5f64ce17785/zstandard-0.25.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:78228d8a6a1c177a96b94f7e2e8d012c55f9c760761980da16ae7546a15a8e9b", size = 5402214, upload-time = "2025-09-14T22:16:04.109Z" },
{ url = "https://files.pythonhosted.org/packages/8b/67/f92e64e748fd6aaffe01e2b75a083c0c4fd27abe1c8747fee4555fcee7dd/zstandard-0.25.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b6bd67528ee8b5c5f10255735abc21aa106931f0dbaf297c7be0c886353c3d0", size = 5449703, upload-time = "2025-09-14T22:16:06.312Z" },
{ url = "https://files.pythonhosted.org/packages/fd/e5/6d36f92a197c3c17729a2125e29c169f460538a7d939a27eaaa6dcfcba8e/zstandard-0.25.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b6d83057e713ff235a12e73916b6d356e3084fd3d14ced499d84240f3eecee0", size = 5556583, upload-time = "2025-09-14T22:16:08.457Z" },
{ url = "https://files.pythonhosted.org/packages/d7/83/41939e60d8d7ebfe2b747be022d0806953799140a702b90ffe214d557638/zstandard-0.25.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9174f4ed06f790a6869b41cba05b43eeb9a35f8993c4422ab853b705e8112bbd", size = 5045332, upload-time = "2025-09-14T22:16:10.444Z" },
{ url = "https://files.pythonhosted.org/packages/b3/87/d3ee185e3d1aa0133399893697ae91f221fda79deb61adbe998a7235c43f/zstandard-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:25f8f3cd45087d089aef5ba3848cd9efe3ad41163d3400862fb42f81a3a46701", size = 5572283, upload-time = "2025-09-14T22:16:12.128Z" },
{ url = "https://files.pythonhosted.org/packages/0a/1d/58635ae6104df96671076ac7d4ae7816838ce7debd94aecf83e30b7121b0/zstandard-0.25.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3756b3e9da9b83da1796f8809dd57cb024f838b9eeafde28f3cb472012797ac1", size = 4959754, upload-time = "2025-09-14T22:16:14.225Z" },
{ url = "https://files.pythonhosted.org/packages/75/d6/57e9cb0a9983e9a229dd8fd2e6e96593ef2aa82a3907188436f22b111ccd/zstandard-0.25.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:81dad8d145d8fd981b2962b686b2241d3a1ea07733e76a2f15435dfb7fb60150", size = 5266477, upload-time = "2025-09-14T22:16:16.343Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a9/ee891e5edf33a6ebce0a028726f0bbd8567effe20fe3d5808c42323e8542/zstandard-0.25.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a5a419712cf88862a45a23def0ae063686db3d324cec7edbe40509d1a79a0aab", size = 5440914, upload-time = "2025-09-14T22:16:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/58/08/a8522c28c08031a9521f27abc6f78dbdee7312a7463dd2cfc658b813323b/zstandard-0.25.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e7360eae90809efd19b886e59a09dad07da4ca9ba096752e61a2e03c8aca188e", size = 5819847, upload-time = "2025-09-14T22:16:20.559Z" },
{ url = "https://files.pythonhosted.org/packages/6f/11/4c91411805c3f7b6f31c60e78ce347ca48f6f16d552fc659af6ec3b73202/zstandard-0.25.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75ffc32a569fb049499e63ce68c743155477610532da1eb38e7f24bf7cd29e74", size = 5363131, upload-time = "2025-09-14T22:16:22.206Z" },
{ url = "https://files.pythonhosted.org/packages/ef/d6/8c4bd38a3b24c4c7676a7a3d8de85d6ee7a983602a734b9f9cdefb04a5d6/zstandard-0.25.0-cp310-cp310-win32.whl", hash = "sha256:106281ae350e494f4ac8a80470e66d1fe27e497052c8d9c3b95dc4cf1ade81aa", size = 436469, upload-time = "2025-09-14T22:16:25.002Z" },
{ url = "https://files.pythonhosted.org/packages/93/90/96d50ad417a8ace5f841b3228e93d1bb13e6ad356737f42e2dde30d8bd68/zstandard-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:ea9d54cc3d8064260114a0bbf3479fc4a98b21dffc89b3459edd506b69262f6e", size = 506100, upload-time = "2025-09-14T22:16:23.569Z" },
{ url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" },
{ url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" },
{ url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" },
{ url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" },
{ url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" },
{ url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" },
{ url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" },
{ url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" },
{ url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" },
{ url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" },
{ url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" },
{ url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" },
{ url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" },
{ url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" },
{ url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" },
{ url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" },
{ url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" },
{ url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" },
{ url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" },
{ url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" },
{ url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" },
{ url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" },
{ url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" },
{ url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" },
{ url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" },
{ url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" },
{ url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" },
{ url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" },
{ url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" },
{ url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" },
{ url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" },
{ url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" },
{ url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" },
{ url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" },
{ url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" },
{ url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" },
{ url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" },
{ url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" },
{ url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" },
{ url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" },
{ url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" },
{ url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" },
{ url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" },
{ url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" },
{ url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" },
{ url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" },
{ url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" },
{ url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" },
{ url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" },
{ url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" },
{ url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" },
{ url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" },
{ url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" },
{ url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" },
{ url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" },
{ url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" },
{ url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" },
{ url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" },
{ url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" },
{ url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" },
{ url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" },
{ url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" },
{ url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" },
{ url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/56/7a/28efd1d371f1acd037ac64ed1c5e2b41514a6cc937dd6ab6a13ab9f0702f/zstandard-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd", size = 795256, upload-time = "2025-09-14T22:15:56.415Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/96/34/ef34ef77f1ee38fc8e4f9775217a613b452916e633c4f1d98f31db52c4a5/zstandard-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7", size = 640565, upload-time = "2025-09-14T22:15:58.177Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/9d/1b/4fdb2c12eb58f31f28c4d28e8dc36611dd7205df8452e63f52fb6261d13e/zstandard-0.25.0-cp310-cp310-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:ab85470ab54c2cb96e176f40342d9ed41e58ca5733be6a893b730e7af9c40550", size = 5345306, upload-time = "2025-09-14T22:16:00.165Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/73/28/a44bdece01bca027b079f0e00be3b6bd89a4df180071da59a3dd7381665b/zstandard-0.25.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e05ab82ea7753354bb054b92e2f288afb750e6b439ff6ca78af52939ebbc476d", size = 5055561, upload-time = "2025-09-14T22:16:02.22Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e9/74/68341185a4f32b274e0fc3410d5ad0750497e1acc20bd0f5b5f64ce17785/zstandard-0.25.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:78228d8a6a1c177a96b94f7e2e8d012c55f9c760761980da16ae7546a15a8e9b", size = 5402214, upload-time = "2025-09-14T22:16:04.109Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8b/67/f92e64e748fd6aaffe01e2b75a083c0c4fd27abe1c8747fee4555fcee7dd/zstandard-0.25.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b6bd67528ee8b5c5f10255735abc21aa106931f0dbaf297c7be0c886353c3d0", size = 5449703, upload-time = "2025-09-14T22:16:06.312Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/fd/e5/6d36f92a197c3c17729a2125e29c169f460538a7d939a27eaaa6dcfcba8e/zstandard-0.25.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b6d83057e713ff235a12e73916b6d356e3084fd3d14ced499d84240f3eecee0", size = 5556583, upload-time = "2025-09-14T22:16:08.457Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/d7/83/41939e60d8d7ebfe2b747be022d0806953799140a702b90ffe214d557638/zstandard-0.25.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9174f4ed06f790a6869b41cba05b43eeb9a35f8993c4422ab853b705e8112bbd", size = 5045332, upload-time = "2025-09-14T22:16:10.444Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/b3/87/d3ee185e3d1aa0133399893697ae91f221fda79deb61adbe998a7235c43f/zstandard-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:25f8f3cd45087d089aef5ba3848cd9efe3ad41163d3400862fb42f81a3a46701", size = 5572283, upload-time = "2025-09-14T22:16:12.128Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/0a/1d/58635ae6104df96671076ac7d4ae7816838ce7debd94aecf83e30b7121b0/zstandard-0.25.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3756b3e9da9b83da1796f8809dd57cb024f838b9eeafde28f3cb472012797ac1", size = 4959754, upload-time = "2025-09-14T22:16:14.225Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/75/d6/57e9cb0a9983e9a229dd8fd2e6e96593ef2aa82a3907188436f22b111ccd/zstandard-0.25.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:81dad8d145d8fd981b2962b686b2241d3a1ea07733e76a2f15435dfb7fb60150", size = 5266477, upload-time = "2025-09-14T22:16:16.343Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/d1/a9/ee891e5edf33a6ebce0a028726f0bbd8567effe20fe3d5808c42323e8542/zstandard-0.25.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a5a419712cf88862a45a23def0ae063686db3d324cec7edbe40509d1a79a0aab", size = 5440914, upload-time = "2025-09-14T22:16:18.453Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/58/08/a8522c28c08031a9521f27abc6f78dbdee7312a7463dd2cfc658b813323b/zstandard-0.25.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e7360eae90809efd19b886e59a09dad07da4ca9ba096752e61a2e03c8aca188e", size = 5819847, upload-time = "2025-09-14T22:16:20.559Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/6f/11/4c91411805c3f7b6f31c60e78ce347ca48f6f16d552fc659af6ec3b73202/zstandard-0.25.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75ffc32a569fb049499e63ce68c743155477610532da1eb38e7f24bf7cd29e74", size = 5363131, upload-time = "2025-09-14T22:16:22.206Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ef/d6/8c4bd38a3b24c4c7676a7a3d8de85d6ee7a983602a734b9f9cdefb04a5d6/zstandard-0.25.0-cp310-cp310-win32.whl", hash = "sha256:106281ae350e494f4ac8a80470e66d1fe27e497052c8d9c3b95dc4cf1ade81aa", size = 436469, upload-time = "2025-09-14T22:16:25.002Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/93/90/96d50ad417a8ace5f841b3228e93d1bb13e6ad356737f42e2dde30d8bd68/zstandard-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:ea9d54cc3d8064260114a0bbf3479fc4a98b21dffc89b3459edd506b69262f6e", size = 506100, upload-time = "2025-09-14T22:16:23.569Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" },
]