尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

onnxruntime部署实时视频帧插值:从模型导出到C++/Python落地

onnxruntime部署实时视频帧插值:从模型导出到C++/Python落地 简介这是一套基于ONNX Runtime的实时视频帧插值部署方案核心面向算法工程师及C/Python开发者解决视频流中生成中间帧、提升流畅度的工程落地问题。压缩包共收录13个文件包含RIFE视频帧插值ONNX模型、C与Python两套推理源码、用于效果验证的PNG测试帧序列以及环境配置与使用说明整体体积约10.58MB目录划分清晰。目前已有98人学习适合需要快速搭建帧插值演示或对比两种语言实现差异的读者。资源中附带的模型可直接用于推理测试帧可立即验证输出效果C与Python代码展示了不同语言的调用方式说明文档则对部署流程和关键参数做了梳理。通过这套资料读者既能获得可运行的完整Demo也能了解从模型加载到后处理的完整链路节省自行摸索的时间方便在各自业务中集成二次开发。1. 用 onnxruntime 把实时视频帧插值从“能跑”推到“能上线”视频帧插值Video Frame InterpolationVFI这几年从论文里走到了播放器、慢动作、监控补帧和工业视觉里。常见的 RIFE、FILM 这类模型输入两张相邻帧输出中间帧效果已经很能打。但模型训出来只是第一步真正难的是把它塞进实时链路摄像头推流 30fps模型一轮推理只有不到 33ms 的预算还要考虑前后处理、内存拷贝和线程调度。onnxruntime 在这个场景里几乎是绕不开的选型它能把 PyTorch 训练的模型导出成 ONNX然后在 CPU、CUDA、TensorRT、OpenVINO 甚至 Jetson 的 GPU 上跑同一份推理代码。本文就围绕“onnxruntime 部署实时视频帧插值”这条主线把 C 和 Python 两条落地路径、模型导出时的算子坑、线程和显存参数怎么调都过一遍。适合准备把 VFI 模型从 Colab 搬到生产环境的工程师也适合在 Jetson 或边缘盒子上做视频处理的同学。2. 帧插值模型导出 ONNX 的常见坑与最小验证2.1 模型结构里最常见的导出障碍网格采样与动态形状VFI 模型RIFE、EMA-VFI、FILM 之类基本都逃不掉两个结构光流估计网络和网格采样grid_sample。光流网络导 ONNX 通常没什么问题PyTorch 的卷积、上采样算子都有对应实现。麻烦的是 grid_sample它在不同 ONNX 版本里的支持情况不同而且 4D 输入和 5D 输入对应的是不同算子grid_sample 和 grid_sampler。我一般会先把模型切到 eval 模式再把输入固定成实际部署时的形状去 trace。常见做法是import torch from model import RIFEModel # 假设是你自己的模型 model RIFEModel() model.eval() # 注意帧插值一般输入是 concat([frame1, frame2])通道数是 6 frame1 torch.randn(1, 3, 480, 640) frame2 torch.randn(1, 3, 480, 640) x torch.cat([frame1, frame2], dim1) with torch.no_grad(): torch.onnx.export( model, x, rife_480x640.onnx, opset_version13, input_names[input], output_names[output], dynamic_axes{input: {0: batch, 2: height, 3: width}, output: {0: batch, 2: height, 3: width}} )这里把 opset_version 设成 13 或更高是因为 grid_sample 的坐标对齐行为在 11 之后有变化且 align_corners 语义在 ONNX 里要显式导出。dynamic_axes 里对 H 和 W 做了动态维度是为了后面支持任意分辨率输入。导出后最好立刻做一次数值对比PyTorch 输出和 ONNX Runtime 输出的最大绝对误差超过 1e-3 就要警惕。常见做法是用 onnxruntime 跑同一个输入import onnxruntime as ort import numpy as np sess ort.InferenceSession(rife_480x640.onnx, providers[CUDAExecutionProvider, CPUExecutionProvider]) onnx_out sess.run(None, {input: x.numpy()})[0] torch_out model(x)[0].numpy() print(max abs diff:, np.abs(onnx_out - torch_out).max())这段代码里 providers 的列表顺序是有讲究的。onnxruntime 会按顺序尝试加载 providerCUDA 加载失败就自动落到 CPU所以把 CUDA 放前面是“能用 GPU 就用 GPU”的意思。第二个参数里的 max abs diff 如果小于 1e-3基本可以认为导出成功。2.2 fixed shape 与 dynamic shape 的选择逻辑很多人第一次导 ONNX 就顺手把 dynamic_axes 删了图省事。但帧插值场景里如果你只在一个固定分辨率上跑fixed shape 的推理速度通常比 dynamic shape 快 5%15%因为算子shape 全部静态化CUDA kernel 不用每次查形状。反过来你面对的是摄像头分辨率不固定的场景比如 USB 摄像头可能从 640x480 切到 1280x720那 fixed shape 就得准备两份模型文件非常蠢。我的经验是先按实际业务里最大分辨率导出 dynamic shape跑通整条链路后再考虑要不要为特定分辨率固化一份。因为 dynamic shape 对 H、W 的约束是“变化范围由 ONNX Runtime 的 arena 分配决定”如果输入分辨率波动太大内存碎片会让显存占用上升这在 Jetson 这种小显存设备上特别明显。2.2.1 验证导出模型的图结构导出后用 onnx.checker 和 onnxruntime 自带的 graph optimization 日志看一眼python -c import onnx; m onnx.load(rife_480x640.onnx); onnx.checker.check_model(m); print(ok)如果 export 时报“Unsupported ONNX opset”或者“GridSample-9 不支持”优先升级 opset 而不是去改模型。RIFE 这类模型依赖的 upsample、grid_sample 在 opset 1113 之间都有对应实现推荐直接用 13。2.3 半精度导出与动态范围的权衡Jetson Orin NX 这类设备上FP16 几乎是必须的。onnxruntime 支持直接加载 FP32 模型并开启 FP16 推理但更稳妥的做法是在导出阶段就用 FP16 权重避免运行时类型转换。转换方法很简单model.half() frame1 torch.randn(1, 3, 480, 640, dtypetorch.float16) frame2 torch.randn(1, 3, 480, 640, dtypetorch.float16)但这里有个坑模型的中间激活值可能溢出 FP16 范围。帧插值模型里光流估计的数值范围通常还好但 grid_sample 的坐标计算如果出现极端值梯度图上会出 NaN。实际部署时我见过不少 FP16 模型在亮暗对比强烈的帧上输出黑斑或条纹就是因为某个中间层的激活值超过了 FP16 的表示范围。所以 FP16 导出后必须拿至少 20 组真实视频帧做推理对比 FP32 输出的 SSIM低于 0.98 就得考虑在网格采样前后插回 FP32。3. Python 快速骨架先让推理链路完整跑起来3.1 用 onnxruntime 包出最小帧插值推理类Python 版本的价值在于快速验证模型、调参与做 POC。我通常先写一个只有推理逻辑的类把输入输出、预处理、后处理封装好后续再翻译成 C。最小骨架如下import cv2 import numpy as np import onnxruntime as ort class VFIEngine: def __init__(self, model_path: str, use_gpu: bool True): providers ([CUDAExecutionProvider, CPUExecutionProvider] if use_gpu else [CPUExecutionProvider]) self.sess ort.InferenceSession(model_path, providersproviders) self.input_name self.sess.get_inputs()[0].name self.output_name self.sess.get_outputs()[0].name def infer(self, frame1: np.ndarray, frame2: np.ndarray) - np.ndarray: # frame1/2: HWC uint8, 需要转成 NCHW float32 def preprocess(f): f cv2.cvtColor(f, cv2.COLOR_BGR2RGB) f f.transpose(2, 0, 1)[None, ...].astype(np.float32) / 255.0 return f x1, x2 preprocess(frame1), preprocess(frame2) x np.concatenate([x1, x2], axis1) y self.sess.run([self.output_name], {self.input_name: x})[0] y y[0].transpose(1, 2, 0) y np.clip(y * 255.0, 0, 255).astype(np.uint8) return cv2.cvtColor(y, cv2.COLOR_RGB2BGR)这个类里有三个关键点。第一个是get_inputs()[0].name一定要动态读取不要硬编码因为导 ONNX 时你可能有多个输入张量。第二个是预处理里transpose(2, 0, 1)把 HWC 转成 CHW这是 PyTorch 模型的标准输入布局。第三个是后处理里clip模型输出在 01 之间是常见约定但个别自定义模型会输出 0255所以后处理要和训练代码保持一致不能想当然。3.2 循环缓冲与双缓冲Python 里也能学的并发姿势实时视频流里最忌讳“逐帧同步推理”。一张帧推理 20ms而摄像头帧间隔 33ms看起来够用但实际上读摄像头、预处理、推理、显示这四个步骤是串行的累计延迟可能到 60ms。我一般会用一个双缓冲队列来解耦采集和推理import threading import queue import time class VideoPipe: def __init__(self, infer_func, src0, buffer_size2): self.cap cv2.VideoCapture(src) self.infer infer_func self.q queue.Queue(maxsizebuffer_size) self.running True threading.Thread(targetself._capture, daemonTrue).start() threading.Thread(targetself._process, daemonTrue).start() def _capture(self): prev None while self.running: ret, frame self.cap.read() if not ret: break if prev is not None: self.q.put((prev, frame)) prev frame.copy() def _process(self): while self.running: if self.q.empty(): time.sleep(0.002) continue f1, f2 self.q.get() mid self.infer(f1, f2) cv2.imshow(mid, mid)这里的队列长度设为 2是为了牺牲一点点实时性换取“采集不丢帧”。如果队列长度设为 1那么 enqueue 时如果队列满采集线程会被阻塞摄像头的内部缓冲反而更容易溢出。buffer_size2 时的行为是只保留最近两对帧旧的直接丢弃这符合“实时性优先”的目标。3.3 Python 侧推理延迟拆解到底慢在哪儿用sess.run的耗时不代表整条链路的耗时。我习惯用time.perf_counter()把预处理、推理、后处理分开计时各跑 100 次看分布times {pre: [], infer: [], post: []} for _ in range(100): t0 time.perf_counter() x np.concatenate([x1, x2], axis1) t1 time.perf_counter() y self.sess.run([self.output_name], {self.input_name: x})[0] t2 time.perf_counter() _ np.clip(y[0].transpose(1, 2, 0) * 255, 0, 255).astype(np.uint8) t3 time.perf_counter() times[pre].append(t1 - t0) times[infer].append(t2 - t1) times[post].append(t3 - t2)以 640x480 输入为例常见分布是预处理 2ms推理 15ms后处理 3ms。预处理慢在 cvtColor 和 transpose 的内存拷贝后处理慢在 clip 和乘 255 的逐元素操作。这两块都可以通过 GPU 上的 CUDA 算子OpenCV 的UMat或自定义 CUDA kernel来压缩但更划算的是直接进 C 后用cv::cuda处理。4. C 部署的完整链路动态库、显存管理与零拷贝4.1 在你的 C 工程里接入 onnxruntime 动态库C 部署零散碎先解决依赖问题。onnxruntime 官方提供预编译的动态库下载后把 include 目录和 lib 目录放进工程。工程配置里需要链接的库是onnxruntime.libWindows或libonnxruntime.soLinux同时要确保运行时能找到onnxruntime.dll或.so.1.x。#include onnxruntime_cxx_api.h #include opencv2/opencv.hpp using namespace Ort; class VfiCppEngine { public: explicit VfiCppEngine(const std::string model_path, bool use_gpu true) { if (use_gpu) { OrtCUDAProviderOptions cuda_options; cuda_options.device_id 0; session_options_.AppendExecutionProvider_CUDA(cuda_options); } session_ std::make_uniqueSession(env_, model_path.c_str(), session_options_); } cv::Mat infer(const cv::Mat f1, const cv::Mat f2) { auto input_shape session_-GetInputTypeInfo(0).GetTensorTypeAndShapeInfo().GetShape(); int64_t h f1.rows, w f1.cols; // 构造输入 tensor std::vectorfloat input_tensor_values(1 * 6 * h * w); // 这里把 f1、f2 的 BGR 转 RGB 并归一化再 NHWC - NCHW // 省略具体拷贝循环 auto memory_info MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault); auto input_tensor Tensor::CreateTensorfloat(memory_info, input_tensor_values.data(), input_tensor_values.size(), input_shape.data(), input_shape.size()); auto output_tensors session_-Run(RunOptions{nullptr}, input_names_.data(), input_tensor, 1, output_names_.data(), output_names_.size()); // 把输出的 CHW 转成 cv::Mat BGR cv::Mat out(static_castint(h), static_castint(w), CV_8UC3); return out; } private: Env env_{ORT_LOGGING_LEVEL_WARNING, vfi_cpp}; SessionOptions session_options_; std::unique_ptrSession session_; std::vectorconst char* input_names_; std::vectorconst char* output_names_; };这里OrtArenaAllocator是 onnxruntime 的默认 CPU 内存分配方式用 arena 来管理内存块减少频繁的 malloc/free。input_names_和output_names_我一般从session_-GetInputNameAllocated(0, allocator)获取避免硬编码。4.2 显存管理onnxruntime 中 CUDA arena 的配置参数CUDA 部署下最常翻车的不是逻辑错误而是显存碎片。onnxruntime 默认会在 CUDA 上做 arena 分配大小策略和 CPU 不同。关键参数在OrtCUDAProviderOptions上OrtCUDAProviderOptions cuda_options; cuda_options.device_id 0; cuda_options.arena_extend_strategy 0; // kNextPowerOfTwo cuda_options.do_copy_in_default_stream 0; session_options_.AppendExecutionProvider_CUDA(cuda_options);几个值得改的参数arena_extend_strategy 0kNextPowerOfTwo适合动态形状模型因为内存按 2 的幂次扩展后续遇到更大的中间张量时不容易反复 realloc。do_copy_in_default_stream 0表示输入张量的拷贝走单独的流这样 CPU 到 GPU 的拷贝能和前一次推理的 GPU kernel 并行。cuda_mem_limit可以设成显存上限的 80%避免 onnxruntime 吃掉所有显存导致摄像头或其他 CUDA 模块分配失败。我见过不少人在 Jetson 上跑帧插值模型只有 30MB但显存占了 1.2GB就是因为 arena 分配策略不对。推理结束后建议查一次cudaMemGetInfo如果 free memory 低于 30%就考虑降低cuda_mem_limit。4.2.1 IO 绑定的零拷贝把 cv::Mat 直接送进 tensor常规写法是把cv::Mat的数据memcpy到std::vectorfloat里再做 BGR 转 RGB 和归一化。640x480 分辨率下这多花 2-3ms。更快的方式是预先分配一块连续的 float 内存用cv::UMat或直接从摄像头 buffer 拷出后直接在 float 数组上做通道转换// 预先分配避免每帧 new std::vectorfloat buffer(1 * 6 * h * w); // f1, f2 已经转换成 RGB 顺序float 类型 memcpy(buffer.data(), f1_rgb.ptrfloat(), f1_rgb.total() * f1_rgb.channels() * sizeof(float)); memcpy(buffer.data() 3 * h * w, f2_rgb.ptrfloat(), f2_rgb.total() * f2_rgb.channels() * sizeof(float));这个 memcpy 的时间常数基本在 0.5ms 以内相比cvtColor的特效操作要稳定得多。如果帧源是 NV12摄像头常见输出可以考虑用cv::cuda::cvtColor把转换合到 GPU 上但注意 onnxruntime 的输入 tensor 得创建在 CUDA 内存上这就引入了更复杂的OrtMemoryInfo配置auto cuda_memory_info MemoryInfo::CreateCuda(0, OrtMemTypeDefault); auto input_tensor Tensor::CreateTensorfloat(cuda_memory_info, cuda_buffer, 1 * 6 * h * w, input_shape.data(), input_shape.size());直接用 CUDA 内存上的 tensor 会让 onnxruntime 跳过 D2H 和 H2D 拷贝推理耗时可能从 20ms 降到 14ms但代价是代码里要自己管 CUDA 内存生命周期。我习惯是帧插值模型本身不大先做 CPU 拷贝版本确认逻辑出来后再优化到零拷贝。4.3 多路视频流的线程模型每个线程一个 Session还是共享 Session多路摄像头同时插帧的场景常见做法有两种。第一种是每路摄像头一个线程每个线程各建一个Session互不干扰。第二种是所有摄像头共享一个 Session用锁保护Run调用。我把两种都测过结论很明确共享 SessionGPU 上 onnxruntime 的Run本身是线程安全的但多个线程并发Run时CUDA 内核的启动开销和显存带宽竞争会抵消掉模型计算的并行收益。实测 2 路并发时每路耗时增加约 40%。每线程一个 Session虽然模型权重会在显存里复制一份帧插值模型一般十几 MB 到几十 MB可接受但每一路的推理延迟和单路时几乎一样且线程之间完全不用加锁。代价是显存翻倍。几十 MB 的模型翻倍也就是 100MB 以内Jetson Orin NX 的 8GB 显存完全扛得住。5. 实时链路中的延迟预算帧率、精度、硬件的三角博弈5.1 30fps 目标的延迟分解表假设目标是把 30fps 的视频流做 2 倍插值即将 30fps 提升到 60fps。这里有个陷阱插值不是把 30fps 变成 60fps而是你要在每两帧之间生成一帧且必须跟上原始帧率。意味着你的推理延迟不能超过 33ms否则队列堆积实时性崩掉。我在 640x480、RTX 3060、ONNX Runtime TensorRT 下的延迟分布参考阶段耗时ms优化手段摄像头采集与去拜耳3硬件解码、内存池BGR-RGB 归一化2合并 kernel或直接用 GPUonnxruntime 推理12-15TensorRT / FP16后处理与显示2-3仅在关键帧上显示这里 onnxruntime 推理只占一半左右所以很多人在 Python 里跑得像模像样一测端到端延迟就傻眼。必须建立以“从摄像头帧到鼠标看到新帧”为口径的延迟测量而不是单独看模型推理。我通常是打时间戳cv::getTickCount()从cap.read()返回后记一次把插值结果imshow前记一次两者相减作为端到端延迟。5.2 用 TensorRT EP 而不是 CUDA EPonnxruntime 推理的进一步压榨CUDAExecutionProvider 只是把 ONNX 算子一个个映射到 CUDA kernel中间没有跨层融合。TensorRTExecutionProvider 则会做层融合和算子选择对于帧插值这种卷积多、激活函数多的模型收益明显。启用 TensorRT EP 的代价是第一次加载时要做 engine 构建30 秒到几分钟不等。生产环境里要避免启动时构建所以流程是离线把模型转成 TensorRT engine 并缓存到磁盘运行期直接加载。OrtTensorRTProviderOptions trt_options{}; trt_options.device_id 0; trt_options.trt_max_partition_iterations 1000; trt_options.trt_min_subgraph_size 1; trt_options.trt_fp16_enable 1; session_options_.AppendExecutionProvider_TensorRT(trt_options);注意trt_min_subgraph_size 1是激进设置它要求所有算子都能被 TensorRT 支持时ONNX Runtime 也尽量不保留 CPU fallback 子图。设置 1 容易在模型里某个算子不被 TensorRT 支持时报完整图构建失败更稳妥是默认的 23。FP16 下RIFE 模型在 640x480 一般能从 CUDA EP 的 15ms 降到 8ms 左右。降不下去的瓶颈是 grid_sample 的 TensorRT 实现效率一般我通常会看一下 profiling 输出里 grid_sample 的耗时占比如果超过 20%就得考虑把光流的后处理拆出来用自定义 CUDA kernel 直接做。5.3 跳帧与插帧的配合不是每帧都要推理帧插值的实时链路里还有个反直觉的优化不要对每一对帧都推理。监控摄像头场景里相邻两帧变化极小时直接复用上一帧的插值结果视觉上无感知但能把负载降一半。判断方法很简单用cv::absdiff算一下两帧的均值绝对误差cv::Mat diff; cv::absdiff(f1, f2, diff); double mae cv::mean(diff)[0]; if (mae 4.0) { // 直接返回上一帧的插值结果或 f1 本身 }MAE 阈值 4.0 对应 8bit 灰度下的轻微变化。在室内固定摄像头场景50% 以上的帧对都能命中这个阈值。注意这个阈值要在真实目标设备上重新调因为不同摄像头传感器的噪声水平会导致同样的场景下 MAE 差异很大。这个优化在 Jetson 上尤其有用因为它的 GPU 算力有限能省则省。和双缓冲队列配合后整条链路变成“采集 → 运动检测 → 跳过或推理”每一路的等效推理负载显著下降。6. 从跑通到调优onnxruntime 日志、profiling 与稳定性验证6.1 开启 onnxruntime 的 profiling 数据直接定位算子的真实耗时onnxruntime 自带 profiling 能力开关在 SessionOptions 上。这一招在图形界面上调参时价值极高因为它能把每个算子的耗时逐条列出来而不是只给你整个 Session 的 run 时间。session_options_.EnableProfiling(vfi_profile);跑几十帧推理后工作目录下会生成onnxruntime_profile_*.json。用 Python 解析一下排序输出耗时前几的算子import json, glob path glob.glob(onnxruntime_profile_*.json)[0] with open(path) as f: data json.load(f) ops {} for event in data[events]: if event.get(cat) Node: name event[name] dur event[dur] # 微秒 ops[name] ops.get(name, 0) dur for name, dur in sorted(ops.items(), keylambda x: -x[1])[:10]: print(f{name}: {dur/1000:.2f} ms)重点看两类算子一是 grid_sample 类二是 upsample 或 resize。帧插值模型里这两个算子如果占总耗时超过 40%那说明模型的并行度不够好可以尝试在 ONNX 图里插入Transpose把 NCHW 转成 NHWC因为 TensorRT 在 NHWC 布局下卷积的 tensor core 利用率更高。不过 NHWC 转换对 onnxruntime 的 CUDA EP 不一定有正向收益建议分别测。6.2 稳定性验证连续 1000 帧不崩、显存不涨、延迟不抖帧插值部署最容易出现的“隐性故障”是显存缓慢上涨跑几个小时后 OOM。这通常不是 onnxruntime 的问题而是自己在循环里创建了新的std::vector或cv::Mat没有复用。我通常用固定分配 清零的方式避免// 在循环外分配循环内只写不 new static std::vectorfloat input_buffer(1 * 6 * h * w); static cv::Mat output_frame(h, w, CV_8UC3);然后用 1000 帧的真实视频做压力测试每 100 帧记录一次显存用量和单帧延迟的滑动平均。如果显存从第 300 帧后还在持续增长优先检查是不是Tensor::CreateTensor时传的指针指向了每次新建的 vector而 onnxruntime 在异步执行时还持有这个指针导致内存无法释放。6.3 一个很有用的参数session_options_.SetIntraOpNumThreadsC 部署里SetIntraOpNumThreads控制的是单个节点内部算子的并行线程数。对帧插值模型我建议直接设 1 或 2。原因很简单模型本身不大多线程并行带来的线程切换开销反而比并行收益更明显。而且在线程池里设置过大的 intra-op 线程数很容易把 CPU 核占满导致摄像头采集线程调度延迟变大。session_options_.SetIntraOpNumThreads(1); session_options_.SetGraphOptimizationLevel(ORT_ENABLE_ALL);ORT_ENABLE_ALL是最高图优化级别它会尝试把 Conv BN ReLU 这类经典组合融合成单个算子对帧插值模型里的卷积层序列尤其有效。务必验证融合前后输出差异但按经验 ONNX Runtime 的融合只做等价变换数值误差在 1e-5 以内。SetGraphOptimizationLevel(ORT_ENABLE_ALL)和 TensorRT EP 同时使用时onnxruntime 会先做一层自己的图优化再传给 TensorRT。有时候 ONNX Runtime 的优化会把某些算子合并成 TensorRT 不认识的自定义算子导致子图划分变差。遇到这种情况可以试ORT_ENABLE_EXTENDED而不是ALL。这是我调 TensorRT EP 时最常碰到的一个隐藏问题。本文还有配套的精品资源点击获取
返回列表