ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

RK3588双路YOLOv5s实战:可观测stats设计与性能调优

RK3588双路YOLOv5s实战:可观测stats设计与性能调优

写完RK3588上的双路YOLOv5s方案之后,我又花了整整两天时间,把“能跑”变成“跑得明白”。这篇系列第17篇,要聊的就是阶段三的最后一个关键词:可观测stats。简单说,就是给香橙派上的两路摄像头推理进程装上仪表盘——帧率、延迟、NPU占用、温度、队列深度,全部量化成数字和曲线。没有这套stats,双路视觉一旦出问题,你只能瞪着终端干猜:是哪一路卡了?是NPU排队还是预处理吃CPU?是靠数据说话还是靠玄学调参?本文就来完整分享这套stats的设计思路、代码实现、实测数据和踩坑记录,适合已经能在香橙派5系列上跑通单路yolov5s、准备上双路实时方案的朋友参考。

1. 为什么阶段三必须做可观测stats

1.1 双路视觉最大的坑是“看不见”

单路YOLOv5s在RK3588上其实很轻松,NPU六个TOPS的算力应付一个640x640输入绰绰有余,跑个30甚至40帧都算正常。但一到双路,事情立刻复杂起来:两路摄像头同时采集、同时预处理、同时进NPU,CPU和内存带宽立刻变成瓶颈。我遇到过一种非常典型的“灵异现象”:左边摄像头明明是好的,但只要右边那路启动推理,左边FPS立刻掉到原来的一半;或者系统刚启动时两路都正常,跑半小时之后某一侧画面开始卡顿,延迟从30毫秒慢慢涨到300毫秒。

这些现象用肉眼看摄像头画面是看不到的,必须靠可观测stats把每一环节拆开:预处理花了多少毫秒、推理花了多少毫秒、后处理NMS花了多少毫秒、帧从采集到显示经历了多长的队列等待。有了这些数字,你才能回答“哪一路卡”“为什么卡”“卡了多久”这三个问题。没有stats的双路系统,本质上就是一台没有仪表盘的汽车——油门刹车都正常,但你不知道发动机是不是已经过热了。

1.2 可观测的三个层级

我习惯把双路视觉的可观测性分成三个层面,统计指标也要按这个分层来设计。

第一层是业务指标,直接反映“视觉够不够用”:两路各自的FPS、推理耗时均值、P95延迟、丢帧率、检测到的目标数量、每个类别的命中分布。这层指标决定了你的方案能不能满足业务需求,比如“每路不低于15帧”“高峰期目标数不超过某个阈值”。

第二层是资源指标,反映“系统扛不扛得住”:CPU总使用率和各核心占用、内存占用趋势、NPU功耗与利用率、DDR带宽、核心温度。双路推理相当于持续满载运行,温度一旦长期处于高位,RK3588的CPU和NPU都会主动降频,最终表现就是帧率波动。资源层的stats就是用来提前发现这种风险的。

第三层是运行状态,反映“管道通不通”:两路的采集线程是否正常、帧队列深度是否在涨、推理线程是否有堆卡、有没有出现解码失败。我在这套方案里会用队列积压长度来判断是否发生“生产者快于消费者”的问题,如果队列满了还继续塞帧,内存就会持续上涨,跑一晚上直接OOM。

1.3 谁会需要这套stats

如果你只是拿香橙派在桌面上点亮一个摄像头,跑跑demo,那确实不需要看板。但只要你的方案要长时间运行、要稳定输出、要对外交付性能报告,或者要在双路基础上继续叠加车道检测、人形追踪、行为识别这些模型,那stats就不是锦上添花,而是刚需。

我在实际项目中,配置两路YOLOv5s之前先花了一个下午把stats架子搭好,后面调模型、调线程优先级、试量化精度时全程看数据,效率比之前盲目改代码高太多。这篇文章里给出的代码你直接抄就能用,统计口径也对应“阶段三”的要求,不需要你自己再去设计数据结构。

2. 双路YOLOv5s的管道设计与模型改造

2.1 双路架构怎么选

双路视觉最常见的做法有两种。一种是两个线程各自独立完成“采集-预处理-推理-后处理”的完整流程,两路之间井水不犯河水;另一种是采集和推理分离,多个采集线程把帧丢进队列,一个推理线程从队列中取帧处理。

第一种思路写起来最直观,但我的实际测试显示,它会让两路推理请求在NPU驱动层排队,T1推理时间出现周期性波动——因为RKNN的同一个context对象在多线程里并发调用时,内部是会互相等待的。第二种思路用队列解耦生产消费,采集帧率和推理能力互相独立,哪一侧出现积压都能在stats上直接看出来。

我最终采用的是两路各初始化一个独立的RKNN context。RK3588的NPU是双核设计,每路context绑定一路摄像头,NPU调度由驱动层做负载均衡,两个context互不争抢同一个对象。这样可观测性最好:每一路context的推理耗时都是相对独立的,不会被另一方干扰,stats里看到的就是真实的并行效果。

线程模型大致是:两个采集线程分别读摄像头,帧带时间戳推入各自的队列;两个推理线程分别从队列取帧,做缩放、颜色转换、RKNN推理、NMS,最后把结果和耗时记录推到stats模块。为了避免某一侧帧积累太多,队列容量固定为两帧,满了就丢弃最旧的帧,保证进入推理的都是最新画面。

2.2 模型转换链路与量化要点

YOLOv5s要跑到RK3588的NPU上,完整链路是.pt转ONNX再转RKNN。我用的是yolov5官方仓库的export.py导出ONNX,opset别设太高,12就行,过高的opset有些算子RKNNToolkit2支持不好。

python export.py --weights yolov5s.pt --include onnx --opset 12 --simplify

导出之后用Netron看一眼输出节点。默认的YOLOv5s会输出三个特征图,尺寸分别是80x80、40x40、20x20,每个特征图维度是(1, 255, H, W),这里的255就等于(5+80)乘以3,代表80类检测头。转RKNN时可以选择保留这三个原始输出,板端自己做解码和后处理,也可以让RKNN后处理直接在NPU上输出1x25200x85的矩阵,省去解码逻辑。

我推荐保留三个原始输出。原因和可观测stats直接相关:如果你在板端做NMS,后处理耗时就可以单独计时,stats里能看得到post_avg这个指标;如果全部交给NPU的算法工具链,后处理时间被藏进推理耗时里了,想要单独优化时就缺少参考数据。阶段三本来就要看每环耗时的分布,所以我把解码和NMS都留在CPU侧。

转RKNN时量化是个关键点。YOLOv5s本身不算大模型,7M参数级别,默认的INT8量化对精度影响通常可控,但你要注意用来做校准的数据集,别用两三张图糊弄。我在香橙派5上实际测试,量化后mAP大约掉了1到2个百分点,对于一般的检测业务完全可以接受。转换脚本大致是这样:

from rknn.api import RKNN rknn = RKNN(verbose=False) rknn.config(target_platform='rk3588', mean_values=[[0, 0, 0]], std_values=[[255, 255, 255]], quantized_dtype='w8a8') rknn.load_onnx(model='yolov5s.onnx') rknn.build(do_quantization=True, dataset='calib.txt') rknn.export_rknn('yolov5s.rknn')

注意mean_values和std_values要与你板端预处理一致,否则检测精度会直接崩掉。很多初学者转完模型发现检测框乱飞,十有八九就是这里没对齐。

2.3 计时埋点放在哪

可观测stats的数据源头是“埋点”。在推理主循环里,我选了四个位置记录时间戳:

采集线程拿到一帧的时刻、队列里取出帧的时刻、预处理完成时刻、rknn推理完成时刻、NMS完成时刻。用perf_counter来打点,这个API精度高且不受NTP时间同步影响,适合做耗时测量。

from time import perf_counter def process_one_frame(self, frame): t0 = perf_counter() # 取帧 prepared = self.preprocess(frame) # 缩放 + BGR2RGB + normalize t1 = perf_counter() outputs = self.rknn.inference(inputs=[prepared]) t2 = perf_counter() dets = self.postprocess(outputs) # decode + nms t3 = perf_counter() self.stats.record( pre_ms=(t1 - t0) * 1000, infer_ms=(t2 - t1) * 1000, post_ms=(t3 - t2) * 1000, fps=1.0 / (t3 - self.last_t) )

这个埋点方案的核心逻辑,是把单次帧处理的三个耗时直接交给stats模块。stats模块只负责记录和计算,不参与任何业务,耦合度低,后续你想加一路摄像头,或者把yolov5s换成yolov8n,都只需要继续调record方法就行。

3. 可观测stats采集与可视化的完整实现

3.1 线程安全的指标采集结构

stats模块要同时被多个线程写入,两个推理线程往里塞耗时数据,可视化线程往外读计算结果,所以数据结构必须线程安全。我用了collections.deque配合threading.Lock,deque设置了maxlen,相当于一个滑动窗口——窗口里只保留最近N次样本,计算均值、P95这些指标都在窗口上做。

import collections import threading import statistics class RollingStats: def __init__(self, camera_id, maxlen=300): self.camera_id = camera_id self.pre_samples = collections.deque(maxlen=maxlen) self.infer_samples = collections.deque(maxlen=maxlen) self.post_samples = collections.deque(maxlen=maxlen) self.intervals = collections.deque(maxlen=maxlen) self.lock = threading.Lock() def record(self, pre_ms, infer_ms, post_ms, frame_interval_ms): with self.lock: self.pre_samples.append(pre_ms) self.infer_samples.append(infer_ms) self.post_samples.append(post_ms) self.intervals.append(frame_interval_ms) def snapshot(self): with self.lock: data = { 'cam': self.camera_id, 'pre_avg': statistics.fmean(self.pre_samples), 'infer_avg': statistics.fmean(self.infer_samples), 'post_avg': statistics.fmean(self.post_samples), 'fps': 1000.0 / statistics.fmean(self.intervals), 'fps_min': 1000.0 / max(self.intervals), 'fps_max': 1000.0 / min(self.intervals), } # p95 inference latency sorted_infer = sorted(self.infer_samples) data['infer_p95'] = sorted_infer[int(len(sorted_infer) * 0.95)] return data

这里有个细节,FPS不要用“帧数除以统计周期”来算,而是用“帧间隔的均值再换算”。原因是后者天然就是平滑的,而且能直接得到最小最大帧间隔,方便你捕捉卡顿瞬态。我窗口取300帧,对30FPS的摄像头来说就是十秒内的统计,足够稳定。

同时统计队列深度和丢帧数。采集线程入队时如果满员,就丢弃并计数。这两个值我放在单独的Atomic整型里,Python的GIL保证了赋值操作不多线程出问题,直接读即可。

3.2 系统级资源指标怎么拿

图像推理层面的stats只能告诉我们“模型跑得快不快”,系统级资源则告诉我们“为什么快了/慢了”。在RK3588上,我主要监控四个东西:

CPU占用率:读取/proc/stat两次取差值,计算整体使用率和单核使用率。代码实现不复杂,但要注意两次读取之间间隔不能太短,我一般隔一秒采样一次。

内存占用:读/proc/meminfo里的MemTotal、MemAvailable,计算当前已用比例。重点看MemAvailable的下降趋势——如果持续下降,多半是帧队列堆积或某段缓存没释放。

核心温度:/sys/class/thermal/thermal_zone0/temp,读出来除以1000就是摄氏度。RK3588的触发降频一般在75到85度区间,双路长期满负荷运行很容易摸到这条线。我的stats里把温度画成趋势线,超过80度会自动在终端面板标红。

NPU利用率:这是很多人问的问题,RKNN Runtime目前没有特别官方的公开接口直接读NPU百分比。我的做法是用“推理时间占比”来间接估算:假设NPU最忙时每秒能处理N帧推理,当前帧率为实际帧率,那么NPU占用率约等于实时FPS除以“完全无等待状态下测得的峰值FPS”。把峰值FPS标定值写进配置,stats里显示的就是一个相对百分比。这个方法不完美,但已经能察觉“NPU是不是被占满了”的趋势。

除此之外,双路视觉如果你想顺便保存视频流,RK3588的VPU是不错的帮手。用rockchip mpp库做硬件H.264编码,不占CPU,stats面板里同样可以加一路“编码帧率”指标,不过这不是阶段三的重点,一句话带过。

3.3 终端面板和Web看板的落地

可观测stats最终要给人看,我做了两种呈现方式。

第一种是终端实时面板,用Python的rich库渲染,每秒刷新两次。为什么要用终端面板?因为很多现场调试环境根本没有图形界面,SSH进去直接看到表格最方便。rich的Live组件可以在同一位置刷新内容,我不会画流程图,但我会把两路的fps、耗时、温度用表格一行行排开,并且用颜色标注异常项。

from rich.live import Live from rich.table import Table def render(metrics_list): table = Table(title="Dual YOLOv5s Stats") table.add_column("Camera") table.add_column("FPS") table.add_column("Pre(ms)") table.add_column("Infer(ms)") table.add_column("NMS(ms)") table.add_column("Temp(C)") for m in metrics_list: table.add_row(str(m['cam']), f"{m['fps']:.1f}", f"{m['pre_avg']:.1f}", f"{m['infer_avg']:.1f}", f"{m['post_avg']:.1f}", f"{m['temp']:.1f}") return table with Live(render(metrics), refresh_per_second=2) as live: while True: metrics = [s.snapshot() for s in stats_list] live.update(render(metrics)) time.sleep(0.5)

第二种是Web看板,用Flask提供JSON接口,前端是Chart.js画实时曲线。这个方案的优势是可以让团队里其他人打开浏览器看实时状态,不用每个人都SSH进来。Flask这边很简单,路由返回stats模块的snapshot快照:

@app.route('/api/stats') def api_stats(): return jsonify({ 'cam0': stats0.snapshot(), 'cam1': stats1.snapshot(), })

前端Chart.js用setInterval,每秒请求一次接口,把fps和延时数据推到折线图上,维护最近一分钟的曲线。这样双路的帧率起伏、温度走势一目了然,卡顿是瞬时的还是持续的,曲线形状会直接告诉你。如果你用的是带MIPI屏的香橙派,也可以直接把浏览器开在板载屏幕上看这个页面。不过RK3588的Linux对MIPI DSI屏适配确实有点看缘分,我手上这块屏最后是用HDMI转接板接上的,比直接调MIPI省了很多时间。

如果你觉得从零写前端太费劲,也可以用Grafana加Prometheus,node_exporter加上自定义exporter,把stats推给Prometheus,由Grafana出图。我实际尝试过,对这个场景来说太重了,双路视觉的metrics量级根本不需要时序数据库,Flask加Chart.js十行代码就够,还能完全自定义展示样式。

4. 实测调优与问题排查

4.1 用stats定位性能瓶颈

stats面板搭好之后,我第一次完整跑双路YOLOv5s,看到的数据很有代表性。单路时推理固定在20毫秒左右,FPS能到30往上;双路启动后,每路FPS掉到17到20,原因一眼就能看明白——pre_avg这个指标从4毫秒涨到了12毫秒,infer_avg反而只是轻微波动。

这说明瓶颈根本不在NPU,而在CPU侧的预处理。OpenCV的resize加颜色转换是逐像素操作,在RK3588的A76大核上跑虽然不慢,但两路同时进行就会抢CPU资源,相互拖累。系统级资源表里CPU占用率已经冲到85%以上,这印证了判断。

我顺着这个排查思路做的优化是:把预处理里的缩放操作交给RGA硬件模块。RGA是Rockchip芯片里的2D图形加速单元,专门做格式转换和缩放,走硬件之后CPU占用立刻降下来。如果你的环境里暂时调不通RGA的Python绑定,还有一个降级方案:把输入分辨率从640x640降到512x512,精度损失一点点,但每路预处理时间能减少接近一半。这两种方案怎么选,我在stats里对比之后,最后是640加RGA,双路每路稳定在22到25帧。

这轮调优恰恰说明了可观测stats的核心价值——如果我没有分阶段的耗时指标,只会看到“双路帧率下降了”,然后盲目地去调模型量化、调NPU频率,方向完全跑偏。有了pre_avg和CPU利用率,五分钟内就定位到了元凶。

4.2 模型轻量化与双模型对比

阶段三的可观测stats还有一个用途,就是做模型替换的横向对比。在双路视觉场景里,YOLOv5s不是唯一选择,很多人会纠结要不要换成YOLOv8n,或者再做一轮剪枝蒸馏。热搜里也一直有人在讨论RK3588部署YOLOv8,香橙派5上跑YOLOv8n确实可行,但它和YOLOv5s的对比值不值得迁移,不能靠感觉判断。

我把YOLOv5s的INT8量化模型和YOLOv8n的INT8量化模型分别接入同一套stats框架,跑了相同的一段视频,结果放在一张表里看。YOLOv8n在相同的640x640输入下,单路FPS反而比YOLOv5s低一点,原因是anchor-free的检测头结构后处理逻辑不同,加上有些算子NPU支持效率一般。但YOLOv8n在检测精度上会好一点,边框回归更准。所以结论很明确:如果你的业务对精度更敏感,换YOLOv8n值得;如果帧率是硬指标,YOLOv5s的INT8方案仍然很能打。

模型轻量化方面我试过通道剪枝,把YOLOv5s的宽度因子从0.5调成0.33,模型体积进一步缩小,推理延时下降约百分之十几。但精度下降明显,在自建的小目标数据集上漏检率变高了。最终我在生产线场景里仍然用原版YOLOv5s INT8,只在英伟达和RK3588双端同时部署时才考虑轻量化版本。这个取舍过程,完全是靠stats面板上的精度对比表做的决定,而不是拍脑袋。

4.3 双路视觉常见问题速查表

最后把我遇到过的典型问题和排查步骤整理成速查表,希望帮你少走弯路。这里面的每一种现象,我都是先用stats定位再动手修的。

现象可能的根因stats里的关键指标解决方向
双路FPS明显低于单路CPU预处理争抢pre_avg上涨、CPU占用率高换RGA做缩放,或降低输入分辨率
某一侧偶发大卡顿队列满了丢帧该路queue_depth周期性到顶采集线程降低投递频率,加丢帧策略
整体温度高导致降频双路满载散热不够温度稳定在75度以上,fps周期性下降加主动散热,或对偶发场景限帧
内存持续上涨队列积压或推理对象泄漏MemAvailable单调下降检查队列容量,确认inference输出引用释放
stats的值不更新线程卡在rknn接口内部可视化线程正常但业务线程无新数据给推理调用加超时,检查摄像头是否掉线
长时间运行后检测框变少温度过高NPU算力下降温度高、infer_avg持续升高散热优先,必要时降频保稳定
两路结果偶尔串帧队列里帧和摄像头ID绑定错误理论上stats无法直接看出在帧结构里加入cam_id字段,取帧后校验

排查的思路永远是先看stats再看代码。比如你发现某一侧丢帧率高,别急着改算法,先确认摄像头是不是掉线了、读取间隔是不是正常;你发现infer_avg突然从20毫秒变成40毫秒,先看温度是不是已经冲到85度,而不是怀疑模型出了问题。这套方法论一遍一遍验证下来,确实比什么都靠猜省时间。

做阶段三这轮改造,我个人最大的体会是:可观测stats不是给外人看的展示面板,而是给自己留的一双眼睛。它把双路视觉从“能跑”变成了“跑得稳定、跑得可解释、跑得能调优”。如果你的香橙派双路方案也已经进入长时间运行的阶段,我强烈建议先把stats模块加上,再加功能、换模型、调参数。后面每步决策都会因为有了这组数字而变得踏实很多。最后再分享一个小技巧:所有stats的写入函数都做成非阻塞的,一旦采集模块出问题,不能让可视化反过来拖垮推理链路——观测系统本身绝不能成为性能瓶颈。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进