咱们这个系列已经写到第七篇了。前面几篇从RK3588的环境准备、YOLOv5s模型导出、RKNN量化转换到单张图片推理,一步步把“模型能在NPU上跑起来”这件事做完了。但模型能跑和系统能用是两码事,尤其是你要面对的不只是终端里的一张测试图,而是真实的摄像头画面、别的程序要调你、7x24小时不能死。这篇我重点记录三件事:服务层怎么搭,摄像头怎么接进来,以及一个让我折腾最久的坑——GStreamer取流和RKNN推理之间的内存生命周期问题。如果你正准备在RK3588上做摄像头实时检测,这篇应该能帮你少走不少弯路。
1. 服务层设计:从命令行跑通到7x24小时可调用的关键一步
1.1 为什么非要加服务层:命令行脚本撑不起产品需求
先说个很现实的问题:命令行跑推理有什么毛病?毛病大了。每次要手动起脚本,结果只有自己看得见,Python脚本是个前台进程,终端一关、SSH一断,整个推理就跟着没了。但实际项目里,你要做的基本都是“提供一个检测接口给别人调用”——给上位机发HTTP请求,给Web页面推实时视频流,给机器人控制器发数据。服务层的本质就是把“推理一次”变成“无限次调用”。
围绕“服务层”这个关键词,设计上无非三件事:常驻进程不退出、提供标准协议接口、按需分配资源。常驻确保模型只加载一次,不用每张图都重新初始化;标准接口让上层系统可以无缝对接;资源分配是避免一个请求把NPU和内存打满然后整个进程死掉。如果只是想自己调试,Flask起个接口确实不难,但真要拿去现场跑,服务层从一开始就得当成核心模块来做,而不是最后补一个装饰。
1.2 推理核心与服务层的边界,不然后期重构到哭
我很早以前犯过一个错:把服务层代码和推理代码揉在一起,一个函数里既收了HTTP的图片数据,又做预处理,又调RKNN,又格式化结果。看起来代码很短,但后面每次要换模型、要改后处理逻辑,都得动整个接口,而且一旦崩溃,你根本不知道是哪个环节出的问题。
后来我强制自己把边界划清楚:推理类只负责“给一张图像的numpy数组,返回检测框列表”,服务层只负责“拿到数据、调推理、把结果序列化回客户端”。中间可以用队列解耦,推理线程不会因为网络请求慢而阻塞。这样说起来很抽象,但实际遇到的问题是具体的——比如FastAPI收到图片后要做Base64解码、要转numpy、要做letterbox,这一串全堆在接口函数里,后面换RKNN版本、换输入分辨率时,改得头皮发麻。反过来,只要你把推理封装成一个独立的类,服务层永远只调那一个接口,模型内部怎么改都影响不到上层。
1.3 框架选型:FastAPI比Flask更适合摄像头实时检测的原因
嵌入式板子上很多人喜欢Flask,因为它轻,但Flask处理并发和长连接确实力不从心。我这次选的是FastAPI加uvicorn,理由有三:一是异步接口天然适合处理视频流和并发请求;二是它自带的OpenAPI调试页,联调的时候省去自己写测试前端;三是Python环境直接pip装,RK3588跑起来没压力。如果你只是做内部小工具,Flask也完全够用,没必要非上FastAPI。
这里有一个关键点必须说清楚:FastAPI的async端点千万不要直接跑推理。推理是CPU和NPU密集操作,一旦它阻塞住事件循环,其他所有请求都会被卡住,表现就是接口“假死”。最简单的做法是把接口函数定义为普通def,FastAPI会自动把它丢到线程池里执行;或者用run_in_executor手动控制。后面联调部分我还会再提一次,这是服务层最容易被忽略的坑。
2. 摄像头接入:RTSP、USB与MIPI选型的实际操作记录
2.1 RTSP摄像头:主码流与子码流如何选,通道号别搞错
摄像头这块,部署现场最常遇到的就是RTSP网络摄像头,海康、大华、宇视这类设备基本都支持RTSP。不同品牌的URL路径格式有差别,海康常见的是rtsp://user:pass@192.168.x.x:554/Streaming/Channels/101,大华常见的是rtsp://user:pass@192.168.x.x:554/cam/realmonitor?channel=1&subtype=0。这个地址能不能取通,直接影响后续所有流程。
最关键的是搞明白主码流和子码流的概念。海康通道编号最后一位的101是主码流,102是子码流;大华的subtype=0是主码流,subtype=1是子码流。做检测时我一般用子码流,分辨率低、码率低、延迟也低,对YOLOv5s常规目标的准确率影响通常可接受。如果你要检测远处小目标、对召回率要求极高,再考虑主码流。子码流通常720p甚至更低,而YOLOv5s输入是640x640,子码流分辨率足够喂给模型,还能省下大量解码带宽。现场部署前把协议路径查清楚,省得到时候和摄像头厂商来回扯皮。
2.2 USB与MIPI摄像头:开发调试和产品落地各自怎么选
开发阶段最省事的是USB摄像头,直接cv2.VideoCapture(0)就能打开,UVC协议免驱,几十块钱的模组就能跑通全流程。但USB摄像头接在RK3588开发板上有个常见坑:主板USB口供电不稳定,尤其是电机、舵机、显示屏也插在上面的时候,摄像头会出现周期性掉线,慢则几小时,快则几分钟一次。
掉线的典型症状是dmesg里刷uvcvideo: Failed to query UVC或者usb 1-1: device descriptor read/64, error -110。我的建议是必须用独立供电的USB Hub,别把功率高的外设和摄像头堆在同一个USB口下。MIPI摄像头是产品级的方案,OV5647这类模组接RK3588要改设备树、配media controller,驱动工程量明显更大,但硬件集成度和长时间稳定性最好。开发阶段先USB跑通流程,产品阶段再上MIPI或RTSP,这个路线最实际。
2.3 OpenCV拉流与GStreamer硬解的差异,CPU占用天壤之别
这里是CPU占用的大头,也是最容易“能用但跑不动”的地方。用OpenCV默认后端cv2.VideoCapture("rtsp://...")拉RTSP流,走的是FFmpeg软解,解码1080p H.264时RK3588的几个A76核心直接打满,这时候NPU再跑YOLOv5s,整体帧率掉到个位数是很正常的。
RK3588本身有VPU硬件解码能力,通过GStreamer的mpph264dec可以把解码卸载到硬件,CPU占用直线下降。我实测同一个1080p RTSP流,OpenCV默认软解CPU占用约百分之八九十,换GStreamer管道硬解后降到10%左右,完全不是一个量级。用OpenCV也可以指定GStreamer后端:
pipeline = "rtspsrc location=rtsp://user:pass@192.168.x.x:554/Streaming/Channels/102 latency=0 ! rtph264depay ! h264parse ! mpph264dec ! videoconvert ! video/x-raw,format=BGR ! appsink" cap = cv2.VideoCapture(pipeline, cv2.CAP_GSTREAMER)但这里有个前提:系统里必须装好GStreamer的rk插件,管道里每一步的格式要对得上,而问题往往就出在这个管道的下游。先给个对比表格,方便你判断自己该走哪条路:
| 方案 | 解码方式 | CPU占用 | 延迟 | 稳定性 |
|---|---|---|---|---|
| OpenCV默认(RTSP) | FFmpeg软解 | 高 | 中 | 一般 |
| OpenCV+GStreamer+mpph264dec | VPU硬解 | 低 | 中 | 较好 |
| 纯GStreamer管道 | VPU硬解 | 低 | 低(加latency=0) | 较好 |
3. 服务层代码骨架:从推理类封装到HTTP接口和视频流
3.1 RKNN推理类封装:换模型不改接口
先上一个推理类的封装思路。这个类只做四件事:初始化RKNN环境、预处理图像、执行推理、后处理结果。服务层完全不需要知道模型是RKNN还是ONNX,只需要拿到检测框列表。
from rknn.api import RKNN class YOLOv5sDetector: def __init__(self, rknn_model_path, target='rk3588'): self.rknn = RKNN() self.rknn.load_rknn(path=rknn_model_path) self.rknn.init_runtime(target=target) # 把anchor、stride、类别名等参数在这里初始化好 def preprocess(self, bgr_img): # 重要:letterbox,保持宽高比,而不是直接resize # 直接resize会拉伸变形,目标检测的框会明显不准 # 转RGB、归一化到0~1或0~255,RKNN一般要NHWC布局 return input_blob def postprocess(self, outputs): # 解码输出、NMS,返回 [{"box": [x1,y1,x2,y2], "score": 0.92, "class": "person"}] return detections def infer(self, bgr_img): input_blob = self.preprocess(bgr_img) outputs = self.rknn.inference(inputs=[input_blob]) return self.postprocess(outputs)封装好之后,服务层永远只调detector.infer(frame),即使后面你要从YOLOv5s换到YOLOv8,甚至从RKNN换到其他推理引擎,也只用动这个类的内部实现,接口不需要变。这个“换模型不改接口”的边界意识,越早建立越好。
3.2 HTTP检测接口:一个同步函数解决阻塞陷阱
FastAPI的检测接口可以写得非常简洁,但简洁不等于简单。下面这个接口接收图片上传,返回检测结果的JSON:
from fastapi import FastAPI, UploadFile, File import numpy as np import cv2 app = FastAPI() detector = YOLOv5sDetector("yolov5s.rknn") @app.post("/detect") def detect(file: UploadFile = File(...)): raw = file.file.read() img = cv2.imdecode(np.frombuffer(raw, dtype=np.uint8), cv2.IMREAD_COLOR) results = detector.infer(img) return {"count": len(results), "detections": results}注意这个接口函数没有写async def,而是普通def。这是故意的,因为FastAPI会自动把普通函数放到线程池里运行,避免推理阻塞事件循环。如果你写成async def detect,里面再直接调推理,那这个接口就把整个服务堵死了,来的慢的请求一个个卡在后面排队,最后表现就是所有接口都“转圈”。
如果要扩展成批量检测,思路一样,只是请求体里多包一层列表,内部循环推理。这里还要注意一个细节:cv2.imdecode的返回值要确认不是None,如果有人传了个坏图上来,直接调detector.infer(None)会炸得很莫名其妙,接口层加个校验成本很低。
3.3 MJPEG视频流:浏览器直接看的实时检测输出
MJPEG流是开发阶段最方便的实时视频方案,浏览器直接用一个<img src="http://ip:8000/video_feed">标签就能看到实时检测画面,不需要额外装播放器,也不依赖WebSocket。核心实现是一个生成器函数,循环从摄像头取帧、推理、画框、编码JPEG、按multipart格式推出去:
from fastapi.responses import StreamingResponse def frame_producer(): while True: frame = camera.get_latest_frame() detections = detector.infer(frame) annotated = draw_boxes(frame, detections) ret, jpeg = cv2.imencode(".jpg", annotated) yield (b"--frame\r\n" b"Content-Type: image/jpeg\r\n\r\n" + jpeg.tobytes() + b"\r\n") @app.get("/video_feed") def video_feed(): return StreamingResponse(frame_producer(), media_type="multipart/x-mixed-replace; boundary=frame")这里有几个产品化细节必须提。第一,摄像头取流必须是独立线程,不能在生成器里直接cap.read(),因为MJPEG客户端一多,多个连接会抢同一个摄像头句柄,取流直接卡死。第二,draw_boxes在生成器里做还是单独线程做,取决于你的帧率预算;如果推理本身已经占了大部分时间,画框就老老实实放在同一个循环里,不要另起线程去抢锁。第三,如果客户端断开,生成器要能正常退出,否则线程和内存越积越多。我这里为了清晰只写了核心,实际工程要把摄像头线程抽象成类,提供get_latest_frame()。
4. 折腾最久的坑:GStreamer取流与RKNN推理的内存生命周期
4.1 现象:单张图正常,接RTSP后随机Segmentation fault
这个坑值得单独开一章。部署好服务、接通摄像头后,程序开始出现随机崩溃。现象非常恶心:拿单张jpg测试接口,怎么测都是好的;一接上RTSP摄像头跑GStreamer管道,有时候几秒就崩,有时候跑十几分钟才崩,报错只有一句话:Segmentation fault。偶尔还会出现检测框位置诡异错乱的情况,感觉模型突然“变笨”了。
因为是随机崩溃,根本没法做长时间联调。我一度怀疑是开发板本身有问题,后来用core dump分析才找到线索。如果你也遇到“单图正常、视频随机崩”的情况,先不要怀疑模型、不要怀疑板子,大概率是视频帧数据在某个环节被提前释放了。
4.2 排查路径:模型、管道、调用栈、最后定位到内存所有权
我的排查过程分了几步。第一步怀疑RKNN模型没转换对,但把之前测通的单张图片传上去一切正常,排除模型问题。第二步怀疑GStreamer管道写错了,换OpenCV默认后端拉RTSP,发现崩溃频率明显降低但仍有偶发,说明问题不在取流本身,而在取流之后和推理的衔接环节。
第三步把程序挂上gdb等崩溃,拿到调用栈后发现几乎每次都死在rknn_inference内部,偶尔死在内存释放相关的函数里。这个线索很关键:推理入参的图像数据是从appsink回调里拿到的GStreamer buffer,为了省内存我直接把这个buffer转成numpy数组传给了推理接口,回调一返回,buffer就归还给GStreamer的内存池,可以被硬件解码器复用,甚至直接释放。而RKNN推理时NPU可能还在异步读取这个地址,一边在用、一边被回收,随机段错误就是这么来的。
更隐蔽的是推理结果错乱的情况:有些编码格式下硬件解码器用的DMA内存和CPU内存不是同一物理域,或者数据对齐不一致,拿过来直接当numpy算,结果自然不对。颜色错乱、坐标错位这些看起来像算法bug的现象,根子其实都在数据内存上。
4.3 修复方案:一个.copy()背后的三层取舍
修复说穿了就一句话:在输入RKNN之前,必须保证内存是自己的。具体做法有三个层次。
第一层,最简单也最直接:在appsink回调里立刻做深拷贝,把数据复制到独立的numpy数组再交给下一个环节。代价是每帧多一次memcpy,换来稳定,调试阶段完全值得。第二层,如果追求效率,可以把RKNN的zero_copy关掉,也就是设置inputs_pass_through=False,让RKNN内部自己拷贝输入数据。代价是RKNN内部多一次拷贝,帧率会有损耗,但比崩溃强太多。第三层,更工程化的方案是预分配固定输入缓冲区,GStreamer buffer通过gst_buffer_map把数据memcpy到固定缓冲区,由你的代码管理生命周期,等NPU推理结束后再释放。
我实际先用第一层把问题钉死,后来改成第三层。代码示意:
def on_new_sample(sink): sample = sink.emit("pull-sample") buf = sample.get_buffer() ok, map_info = buf.map(Gst.MapFlags.READ) if ok: # 关键:.copy(),把GStreamer管理的buffer复制成独立numpy内存 arr = np.frombuffer(map_info.data, dtype=np.uint8).copy() frame_queue.put(arr) buf.unmap(map_info)那个.copy()就是整个坑的答案。我折腾了两个晚上,最后发现败给了一个方法调用。但更深一层看,真正的修复不是加这个copy,而是想清楚:GStreamer的buffer生命周期由谁管、RKNN的输入数据生命周期由谁管、两者交叉时谁负责把数据所有权转移清楚。搞懂这个,下次换任何硬件加速方案都不会再踩同样的坑。
4.4 这个坑留下的三条工程教训
第一条,异构硬件栈里,“谁的内存、谁来释放、什么时候能释放”必须写清楚,否则随机性的崩溃比显式报错难排查十倍。第二条,遇到随机崩溃别急着怀疑算法和模型,先查生命周期和数据所有权,大多数偶发段错误都是内存问题。第三条,零拷贝不是免费的,它把性能压力转移成了内存管理压力,要用就要把生命周期管到底,半吊子的零拷贝比老老实实拷贝还要坑。
5. 摄像头与服务层联调的小坑实录
5.1 RTSP延迟大?latency=0立竿见影
GStreamer管道里如果不指定latency,默认会做缓冲来对抗网络抖动,结果就是延迟可能到几秒。在rtspsrc里加latency=0,实测延迟能从1到3秒降到100ms左右。代价是网络抖动时可能出现卡帧,但对本地局域网摄像头来说基本可忽略。这条对实时检测项目几乎必加,不加的话你会发现画面上的人已经走过去了,检测框才刚跟上。
5.2 USB摄像头掉线:供电不足与代码层重连
前面提到的供电问题在真机上尤其明显。除了换独立供电Hub,我还在代码里加了一个重连机制:开一个监控线程,每隔几秒检查cap.isOpened(),发现False就重新初始化VideoCapture,直到成功。实测能顶住普通的USB抖动。复位后还要注意把GStreamer管道重新建一遍,不能共用原来那个cap对象,否则大概率还是打不开。
重连逻辑还要加个退避,不能一失败就疯狂重试,不然开发板的USB控制器会被你刷爆。我一般是第一次等1秒,第二次等2秒,最多等到30秒封顶,这样即使摄像头长时间离线,系统也不会因为空转把CPU吃掉。
5.3 推理服务并发保护:别让NPU排队排到崩溃
如果你同时有几个客户端调/detect,且每个请求都做一次完整推理,NPU会排队,响应时间会越来越长。我做了一个简单的信号量限流,最大并发推理数设为2,超过的直接返回429,宁可让用户重试,也别把进程搞死。这个在设计服务层接口时就要想好,否则现场一跑,摄像头检测+MJPEG推流+HTTP接口同时压上来,NPU队列能直接拖垮整个板子。
另外,服务被kill之后,GStreamer和摄像头资源不一定会自动释放,下一次启动可能报“Failed to open camera”或“Resource busy”。我的习惯是在服务里注册atexit和信号处理函数,确保退出时释放RKNN、释放摄像头、销毁GStreamer管道。调了好几天的问题,有时候只是上一次的进程没退干净。
最后分享一个我自己的习惯:在做摄像头和服务层联调时,不要一上来就把“摄像头取流+NPU推理+HTTP服务”全串起来,先分三步验证——先用CPU跑通整条链路,再用RKNN加速推理,最后才接摄像头。每一步出问题都能快速定位。说实话,整个第七篇最值钱的不是那几行接口代码,而是那个.copy()背后的内存所有权意识。希望看完这篇,你在RK3588上接摄像头时能比我少熬两个夜。