
咱们这个系列已经写到第七篇了。前面几篇从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密集操作一旦它阻塞住事件循环其他所有请求都会被卡住表现就是接口“假死”。最简单的做法是把接口函数定义为普通defFastAPI会自动把它丢到线程池里执行或者用run_in_executor手动控制。后面联调部分我还会再提一次这是服务层最容易被忽略的坑。2. 摄像头接入RTSP、USB与MIPI选型的实际操作记录2.1 RTSP摄像头主码流与子码流如何选通道号别搞错摄像头这块部署现场最常遇到的就是RTSP网络摄像头海康、大华、宇视这类设备基本都支持RTSP。不同品牌的URL路径格式有差别海康常见的是rtsp://user:pass192.168.x.x:554/Streaming/Channels/101大华常见的是rtsp://user:pass192.168.x.x:554/cam/realmonitor?channel1subtype0。这个地址能不能取通直接影响后续所有流程。最关键的是搞明白主码流和子码流的概念。海康通道编号最后一位的101是主码流102是子码流大华的subtype0是主码流subtype1是子码流。做检测时我一般用子码流分辨率低、码率低、延迟也低对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 locationrtsp://user:pass192.168.x.x:554/Streaming/Channels/102 latency0 ! rtph264depay ! h264parse ! mpph264dec ! videoconvert ! video/x-raw,formatBGR ! appsink cap cv2.VideoCapture(pipeline, cv2.CAP_GSTREAMER)但这里有个前提系统里必须装好GStreamer的rk插件管道里每一步的格式要对得上而问题往往就出在这个管道的下游。先给个对比表格方便你判断自己该走哪条路方案解码方式CPU占用延迟稳定性OpenCV默认(RTSP)FFmpeg软解高中一般OpenCVGStreamermpph264decVPU硬解低中较好纯GStreamer管道VPU硬解低低加latency0较好3. 服务层代码骨架从推理类封装到HTTP接口和视频流3.1 RKNN推理类封装换模型不改接口先上一个推理类的封装思路。这个类只做四件事初始化RKNN环境、预处理图像、执行推理、后处理结果。服务层完全不需要知道模型是RKNN还是ONNX只需要拿到检测框列表。from rknn.api import RKNN class YOLOv5sDetector: def __init__(self, rknn_model_path, targetrk3588): self.rknn RKNN() self.rknn.load_rknn(pathrknn_model_path) self.rknn.init_runtime(targettarget) # 把anchor、stride、类别名等参数在这里初始化好 def preprocess(self, bgr_img): # 重要letterbox保持宽高比而不是直接resize # 直接resize会拉伸变形目标检测的框会明显不准 # 转RGB、归一化到0~1或0~255RKNN一般要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的检测接口可以写得非常简洁但简洁不等于简单。下面这个接口接收图片上传返回检测结果的JSONfrom 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, dtypenp.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 srchttp://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 bContent-Type: image/jpeg\r\n\r\n jpeg.tobytes() b\r\n) app.get(/video_feed) def video_feed(): return StreamingResponse(frame_producer(), media_typemultipart/x-mixed-replace; boundaryframe)这里有几个产品化细节必须提。第一摄像头取流必须是独立线程不能在生成器里直接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_throughFalse让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, dtypenp.uint8).copy() frame_queue.put(arr) buf.unmap(map_info)那个.copy()就是整个坑的答案。我折腾了两个晚上最后发现败给了一个方法调用。但更深一层看真正的修复不是加这个copy而是想清楚GStreamer的buffer生命周期由谁管、RKNN的输入数据生命周期由谁管、两者交叉时谁负责把数据所有权转移清楚。搞懂这个下次换任何硬件加速方案都不会再踩同样的坑。4.4 这个坑留下的三条工程教训第一条异构硬件栈里“谁的内存、谁来释放、什么时候能释放”必须写清楚否则随机性的崩溃比显式报错难排查十倍。第二条遇到随机崩溃别急着怀疑算法和模型先查生命周期和数据所有权大多数偶发段错误都是内存问题。第三条零拷贝不是免费的它把性能压力转移成了内存管理压力要用就要把生命周期管到底半吊子的零拷贝比老老实实拷贝还要坑。5. 摄像头与服务层联调的小坑实录5.1 RTSP延迟大latency0立竿见影GStreamer管道里如果不指定latency默认会做缓冲来对抗网络抖动结果就是延迟可能到几秒。在rtspsrc里加latency0实测延迟能从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上接摄像头时能比我少熬两个夜。