Compare commits
7 Commits
4f016d9cef
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 51d03d4279 | |||
| 16a1a38645 | |||
| ddea6b0e3d | |||
| 30cd956c5c | |||
| b277ed363f | |||
| 3e5b720e0e | |||
| 213adee887 |
+382
-263
@@ -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}
|
||||
|
||||
@@ -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 camera’s 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)
|
||||
|
||||
|
||||
@@ -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,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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
@@ -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;
|
||||
}
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
exec /usr/bin/protoc --experimental_allow_proto3_optional "$@"
|
||||
+13
-1
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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};
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 ×tamp) {
|
||||
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
@@ -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"
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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={}",
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ×tamp) {
|
||||
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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 ×tamp_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
@@ -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);
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user