1. 前六篇的底子,第七篇要补的缺口
如果你是跟着这个系列一路做到第六篇的,可能已经习惯了我这种节奏:先把环境、模型、单帧推理一件件打通,再回来补工程上的细节。这篇第七篇,正好讲两个“一上量就露馅”的部分——服务层和摄像头输入。模型在板子上单帧跑出 45ms 左右只是起点,真正决定这个项目能不能“像个产品”的,是别人能不能方便地拿到检测结果,以及摄像头断线、空闲、并发拉流的时候系统会不会崩。
我用的板子是 RK3588 开发板,16GB 内存版本,系统是 Ubuntu 22.04,NPU 驱动用的官方 rknpu2,推理框架是 rknn-toolkit2 1.6.0。模型是 YOLOv5s 转出来的 RKNN,输入尺寸 640x640,量化方式是 int8,单帧推理耗时大概 40 到 60ms,这跟具体版型、NPU 频率、是否开 DDR 高性能模式都有关系。模型转换和板端跑通的细节我在前几篇已经写了不少,这篇不重复。
1.1 现有成果与剩余问题
前六篇做完之后,我手里稳定的东西主要是这几个:
- PyTorch 的 YOLOv5s 权重已经通过 onnx 中转,转成了 RKNN 格式,并且在板子上能用 RKNNLite 加载。
- 单张图片推理可以稳定出结果,后处理 NMS 也在板端写好,输出的格式是 YOLOv5 常见的 xyxy + class + confidence。
- RKNN 的输入输出 tensor 我都已经固定好,输入是 1x640x640x3 的 uint8 BGR,输出是三个尺度的检测头。
但这些都是“离线能力”。真正的实时项目要面对三个新问题:视频源从哪来、视频流怎么稳定地一帧一帧送进模型、推理结果怎么在互联网协议下发给前端或者别的服务。这三个问题就是服务层和摄像头要解决的东西。
1.2 服务层与摄像头在这个项目里的真实定位
这个项目的最终目标是做一个局域网内的实时检测服务:一台 RK3588 连着普通 USB 摄像头,跑着 YOLOv5s,前端浏览器通过网页实时看到检测框和类别。偶尔我还需要接入海康的 RTSP 摄像头,以及某些场景需要把检测结果直接推给另一个程序而不是网页。
所以这篇做的事情可以概括成一句话:把之前“手动跑一张图”的程序,改造成一个能持续运行、能被网络访问、能应对摄像头掉线的服务。
如果你没有完整跑过这个系列,直接看这篇也能理解。我尽量不依赖前面篇幅的隐含信息,关键的协议和流程都会讲清楚。
2. 服务层选型与接口设计:先解决“别人怎么拿结果”
服务层听起来高大上,落到这个项目里其实就是两个问题:第一个,摄像头抓到的画面怎么交给模型;第二个,模型算出来的结果以什么形式暴露给外部调用方。很多人在这一步会直接想到 Flask 或者 FastAPI,然后每个 HTTP 请求都现场做一次推理。这种做法不是不行,但到视频场景往往会把板子拖垮。
2.1 为什么不建议“前端每次请求都现场推理一次”
先说一个反直觉的结论:实时视频检测服务不应该为每一个客户端请求做一次推理。原因是摄像头画面是持续产生的,如果三个浏览器窗口同时打开,每个请求都各自去抢 RKNN 的 NPU 资源,推理会互相打架,而且同一帧画面被重复推理好多次,纯属浪费。
我最早就是这么干的:用 Flask 写一个接口,前端用 JavaScript 每隔一两百毫秒轮询一次,传一个 base64 图片过来,后端现场推理,把结果返回。跑是能跑通,但存在三个明显问题。第一,带宽开销非常大,1080p 的 JPEG 一帧约 300KB,轮询一次相当于好几个 TCP 包来回传。第二,RKNN 的推理 context 不是设计来处理高并发抢占的,我实测同 context 并发推理会出现偶发的结果交错,直接表现在画面上就是检测框偶尔“瞬移”到完全不相关的目标上。第三,每次请求创建临时张量、释放临时张量,内存碎片越来越严重,跑半小时以后服务内存从 300MB 涨到 800MB。
后来我彻底换了思路:视频源只有一个,模型也只有一个,那整个服务就应该是“生产者 - 消费者”的单链路架构。摄像头线程负责抓帧,推理线程负责消费最新帧,计算结果统一放在一个结果缓冲区里。任何客户端接入,拿到的都是同一个最新检测结果,而不是为每个客户端重新算一遍。
2.2 FastAPI 下的服务结构:采集线程和推理线程分开
我最终选了 FastAPI,基本原因就两条:自带 async 能力,WebSocket 支持比 Flask 顺手;类型标注和 OpenAPI 文档白送,调试接口方便。如果你不想引太多依赖,用 Flask 加 gevent 也能做,但没必要在这种地方省事。
整个服务的核心结构如下:
- CameraThread:独立线程,只负责从摄像头读帧,然后把帧塞进一个“最新帧队列”。
- DetectorThread:独立线程,阻塞在新帧队列上,取到帧就做预处理、RKNN 推理、后处理,把结果塞进“最新结果队列”。
- FastAPI 路由:不直接调用模型,只读“最新结果”,按 HTTP 或 WebSocket 格式返回。
这个结构的关键是采集和推理解耦。摄像头偶尔掉一帧没关系,推理线程只关心“当前最新的一帧是什么”,不需要排队处理积压帧。这样即使前端 WebSocket 卡了,也不会反向拖住摄像头采集线程。
用代码描述“最新帧队列”的话,我当时直接用 asyncio.Event 做了个极简版本:
import asyncio class LatestFrameQueue: def __init__(self): self._frame = None self._event = asyncio.Event() async def publish(self, frame): self._frame = frame self._event.set() async def subscribe(self, timeout=1.0): self._event.clear() await asyncio.wait_for(self._event.wait(), timeout) return self._frame这个类的语义很简单:永远只保存最近的一帧。多个消费者订阅时,谁先来谁拿走当前帧;没人订阅时,帧就留在内存里,主程序可以按需决定是否暂停推理。
2.3 HTTP 拉取、多段流和 WebSocket 推送
对外接口我实际做了三个:
第一个是GET /api/result,返回一个 JSON,内容是最近一次推理的检测框列表和推理耗时。这个接口给简单的轮询客户端用,开销很小,因为后端不会重新推理。
第二个是GET /api/stream,返回multipart/x-mixed-replace流,内容是一帧一帧的 JPEG。这个适合浏览器直接用<img>标签展示,不需要额外写 JavaScript。对 RK3588 这种性能有限的板子,我会把输出 JPEG 压缩质量降到 80,分辨率降到 960x540,避免 Web 端带宽被占满。
第三个是 WebSocket/ws/result,服务端主动推送检测结果 JSON。这个接口给真正的业务程序用,比如另一个机器人调度程序、高危区域告警脚本等。WebSocket 相比前两个的好处是双向、低延迟、连接保持,不需要客户端反复请求。
这里有一个坑值得提醒:如果你同时开了 HTTP 拉流和 WebSocket 推流,要注意线程安全。后处理结果不能直接写同一个 dict,否则一个线程在改字段,另一个线程在序列化,很容易出现半更新状态。我的做法是在 DetectorThread 里生成结果后直接给它做一个深拷贝,对外发布的是不可变快照。
3. 摄像头接入:UVC、MIPI-CSI、RTSP 三条路我都试了一遍
摄像头这块,标题上只写了“摄像头”,但实际落地你会发现它不是一个简单的东西。同一个 RK3588,接 USB 摄像头、MIPI-CSI 摄像头、局域网 RTSP 摄像头,三种接入方式的坑完全不同。我把三种都试过,最后产品里同时保留了三套采集代码,按配置切换。
3.1 三种输入的本质差异
先看一张对比表格,方便你快速理解差异:
| 输入方式 | 接口/协议 | 设备节点示例 | 常见像素格式 | 主要坑点 |
|---|---|---|---|---|
| USB UVC 摄像头 | V4L2 | /dev/video0 | YUYV, MJPEG | 供电不足、热拔插后设备枚举变化、多摄像头顺序不确定 |
| MIPI-CSI 摄像头 | MIPI CSI-2 总线 | /dev/video0 + media 节点 | NV12, RAW | 设备树、sensor 驱动、media 链路配置复杂 |
| RTSP 网络摄像头 | RTSP over TCP/UDP | 远端 URL | H.264/H.265 | 网络延迟、断流重连、TCP/UDP 的选择影响很大 |
本质上,USB 和 MIPI 都是本机图像设备,走 V4L2 框架;RTSP 是网络流,可以用 gstreamer 或 ffmpeg 去拉流,也可以先用 OpenCV 的 VideoCapture 直接吃 RTSP 地址。我建议不要对 RTSP 直接用 OpenCV,原因后面说。
3.2 USB 摄像头在 RK3588 上的配置细节
USB 摄像头是最容易上手的,但在 RK3588 上有一个典型问题:Ubuntu 系统自带 UVC 驱动,插上以后会分配/dev/video0和/dev/video1两个节点,其中 video0 通常对应预览流,video1 对应拍照流或元数据流。如果你写代码直接写死/dev/video0,换个摄像头就可能选错节点。
我通常先执行:
v4l2-ctl --list-devices看输出,确认当前摄像头对应的主节点是哪个。输出大致是:
USB Camera (1234:5678): /dev/video0 /dev/video1然后我会再查它支持的格式:
v4l2-ctl -d /dev/video0 --list-formats-ext这一步很关键。大部分 USB 摄像头会同时支持 YUYV 和 MJPEG。如果不做任何设置,OpenCV 的 VideoCapture 默认按 MJPEG 抓取,因为带宽占用低、帧率稳定。但 MJPEG 帧拿回来之后是一张 JPEG 图,OpenCV 需要做 JPEG 解码,解码本身就抢 CPU。RK3588 虽然 CPU 不算弱,但一边解码 MJPEG 一边跑 NPU 推理,总归不划算。
我建议直接将摄像头格式固定为 YUYV,让 OpenCV 少一层压缩解码,后续 YUV 转 BGR 的开销比 JPEG 解码小得多。代码设置方式:
cap = cv2.VideoCapture("/dev/video0", cv2.CAP_V4L2) cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc("Y", "U", "Y", "V")) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1920) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 1080) cap.set(cv2.CAP_PROP_FPS, 30)当然 YUYV 在 1080p30 下对 USB 带宽要求高一些,如果主板 USB 口供电和信号质量不行,会出现周期性丢帧。这个后面第五部分的坑也是从这里延伸出去的。
3.3 MIPI-CSI 的调试入口
MIPI-CSI 摄像头在 RK3588 上性能和延迟都比 USB 好不少,因为它走的是 SoC 内部 ISP 通道,不占 USB 总线和 CPU 拷贝。代价是配置复杂。我手里的模块是 OV5647 感光芯片,也就是树莓派官方摄像头同款。
接入后的第一步不是写代码,而是先确认系统认不认这个 sensor。执行:
dmesg | grep -i ov5647 media-ctl -d /dev/media0 -p如果 dmesg 里有registered sensor相关日志,说明驱动已经加载。如果没有,多半是设备树没打开对应 overlay,或者 sensor 供电、时钟没配好,这时候先别写应用层代码,先把 overlay 问题解决。
链路配置方面,需要用 media-ctl 把 sensor 输出和 MIPI CSI 接收器连接起来。命令通常长这样:
media-ctl -d /dev/media0 -r media-ctl -d /dev/media0 -l "'m00_b_ov5647 1-0036':0->'rockchip-mipi-csi2':0[1]" media-ctl -d /dev/media0 -V "'m00_b_ov5647 1-0036':0[fmt:UYVY8_2X8/1920x1080]"链路建立之后,再用 v4l2-ctl 设置主节点格式:
v4l2-ctl -d /dev/video0 --set-fmt-video width=1920,height=1080,pixelformat=NV12 v4l2-ctl -d /dev/video0 --stream-mmap --stream-count=10能抓到预览流,才轮得到服务层代码接管。不要跳过这一步,MIPI 上因为驱动配置没生效而出现绿屏、灰屏、花屏的案例非常多,而且很难从应用层看出来。
3.4 RTSP 摄像头和断线重连策略
RTSP 接入我主要面对的是海康和大华的局域网摄像头。这里最常犯的一个错是直接用 OpenCV 的VideoCapture("rtsp://...")。OpenCV 内部对 RTSP 的实现比较薄,断流后重连能力弱,而且对 TCP/UDP 的切换不灵活。我试过一段时间,网络稍微抖动,VideoCapture 的read()直接返回 False,然后整个线程卡住。
更稳的做法是用 ffmpeg 拉流:
ffmpeg -rtsp_transport tcp -i "rtsp://user:pass@192.168.1.64:554/Streaming/Channels/101" -pix_fmt bgr24 -f rawvideo pipe:1然后用 Python 子进程管道去读 stdout。这样做的好处是 ffmpeg 对 RTSP 的处理非常成熟,TCP 模式也会比 UDP 少很多花屏。注意 RTSP 地址里,海康的/Streaming/Channels/101表示主码流第一路,102是主码流第二路,201是子码流。接入推理服务时,尽量用子码流,分辨率低一些、带宽低一些,检测精度反而不会差太多,因为推理输入本身也只是 640x640。
断线重连也跟普通摄像头不一样。USB 和 MIPI 设备掉线后的恢复策略简单粗暴,重新打开设备就行;RTSP 则需要做退避重连。我的重试策略是:第一次失败等 1 秒,第二次等 2 秒,最多等到 10 秒,连续失败 10 次后把整条 ffmpeg 子进程杀掉重启。如果摄像头长期断线,就输出一条告警日志,不无限重试。
4. 从摄像头帧到 RKNN 输入:一组你必须自己把好的细节
很多人在单帧推理里不会踩的坑,到了实时视频流里全都会冒出来。核心原因是单帧推理时你手里是一张干干净净的图片,而视频流里一帧数据从摄像头驱动到 RKNN 输入,中间走了一条很长的链路,每个环节都可能影响结果。
4.1 像素格式与模型输入的对应关系
摄像头输出的是 YUYV、NV12 这样的 YUV 系格式,RKNN 模型输入需要的是 RGB 或 BGR 的 uint8 张量。这个转换如果做错,最直观的表现是颜色错乱、检测率骤降。
我自己在用 USB 摄像头时,直接让 OpenCV 返回原始 YUYV 帧,然后调用:
rgb = cv2.cvtColor(yuyv_frame, cv2.COLOR_YUV2BGR_YUYV)MIPI-CSI 摄像头输出 NV12 的话,则用:
bgr = cv2.cvtColor(nv12_frame, cv2.COLOR_YUV2BGR_NV12)这里要注意一点:OpenCV 的颜色转换函数只认特定内存布局,NV12 的 Y 平面和 UV 交错平面必须完整,不能截断。我在早期调试时因为从 V4L2 buffer 里拷贝数据时少读了一行尾,导致转换后的图像底部有一层绿色条纹,排查了很久。
4.2 letterbox 与预处理的一致性
YOLOv5 推理前需要把任意长宽比的图像等比缩放到 640x640 输入尺寸,空白部分用 114 灰度填充。这个操作叫 letterbox。如果你在 RKNN 配置阶段使用了 mean 和 std 归一化参数,比如mean_values=[[0,0,0]]、std_values=[[255,255,255]],那么在推理端你只需要传入 uint8 的 BGR 图像,不需要手动除以 255。
letterbox 的实现我直接用 YOLOv5 官方代码那一段,但注意一个细节:推理时的填充颜色、缩放系数必须和训练时保持一致,否则目标位置会有几像素偏移,对小目标的检测影响明显。
def letterbox(img, new_shape=(640, 640), color=(114, 114, 114)): shape = img.shape[:2] r = min(new_shape[0] / shape[0], new_shape[1] / shape[1]) new_unpad = (int(round(shape[1] * r)), int(round(shape[0] * r))) dw = (new_shape[1] - new_unpad[0]) / 2 dh = (new_shape[0] - new_unpad[1]) / 2 img_resized = cv2.resize(img, new_unpad, interpolation=cv2.INTER_LINEAR) top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1)) left, right = int(round(dw - 0.1)), int(round(dw + 0.1)) return cv2.copyMakeBorder(img_resized, top, bottom, left, right, cv2.BORDER_CONSTANT, value=color)4.3 NPU 内存与 V4L2 缓冲区的交叉影响
这是 RK3588 这类带 NPU 的 SoC 跟普通 x86 平台差异最大的一点。
V4L2 摄像头采集时,驱动用 mmap 映射了一批 DMA 缓冲区。RK3588 上这些缓冲区受 CMA 内存池约束。而 RKNN 推理时,如果每次推理都调用rknn_create_mem创建临时输出内存,板子会在应用层和 NPU 驱动之间频繁申请、释放 CMA 内存,久而久之会产生外部碎片。一些大块连续内存申请会失败,轻则打印警告,重则推理直接报错。
所以我在服务层里做了一个固定分配策略:摄像头采集 buffer 固定从 V4L2 申请,推理输入输出 tensor 只在服务启动时创建一次,之后全程复用。具体到 RKNNLite 的调用方式,就是初始化一次,inference反复调用,输出直接写进同一个 list 里的 numpy 数组。这个策略跑了一天一夜,内存曲线基本是平的。
5. 折腾最久的一个坑:空闲自动停流引发的“半帧灰屏”
这个坑我整整调了一个周末,从怀疑驱动到怀疑供电,最后定位到是自己服务层的多线程引用问题。过程很有代表性,值得完整复盘。
5.1 现象:一切正常,唯独重新拉流后异常
服务的初版很朴素,逻辑是前端开着 WebSocket 就持续推流,前端关掉页面就暂停推理。后来我觉得推理暂停还不够,摄像头采集空转也费电又费带宽,于是干脆写了一个“空闲自动停流”逻辑:如果 WebSocket 连接数归零超过 30 秒,就调用 V4L2 的VIDIOC_STREAMOFF停掉摄像头采集,释放 buffer,等服务端有新连接时再重新VIDIOC_STREAMON。
看起来挺合理,但问题来了。前端重新打开页面后,摄像头重新拉流,画面前几帧会出现半白半灰的“鬼影”,像两张图像各取一半叠在一起;偶尔还会直接卡住,日志报VIDIOC_DQBUF: Resource temporarily unavailable。更诡异的是,用 v4l2-ctl 命令行工具手动抓帧是正常的,只有服务层自动停流再恢复时才出错。
5.2 排查过程:我居然先怀疑了驱动和供电
刚开始我怀疑是 USB 摄像头供电不稳,RK3588 的几个 USB 口电流有限制。于是换了一个带外部供电的 USB Hub,问题还在。又怀疑是不是 UVC 驱动在 STREAMOFF 后没有完全复位设备,上网查资料、看内核源码,一直没找到明确答案。
后来我把 OpenCV 的读帧方式换成直接调用 v4l2 ioctl,自己管理 mmap buffer,现象依旧。这时候我开始怀疑是逻辑里某个状态没同步。
我打印了停流和重新拉流时各个线程的执行时间线,发现问题了:STREAMOFF 时,采集线程被强制停止,但工作队列里还剩着几帧引用了已经 STREAMOFF 后驱动侧的 buffer。这些 buffer 是 mmap 映射的内存,驱动停流后并没有马上销毁,只是内容不再更新。服务层里某个“等新帧”的消费者线程还持有旧帧引用,等到 STREAMON 重新开始后,驱动对同一段 DMA 内存重新写入,旧引用被“偷偷更新”成半新半旧的数据,这就出现了半张新图加半张旧图的鬼影。
5.3 根因:V4L2 缓冲区引用在服务层里被多线程滥用
回到本质,V4L2 的 mmap 缓冲并不是“拷贝”给你用的,它是一块内核和用户态共享的物理内存。驱动在 STREAMOFF 后不会保证这块内存内容稳定,重新 STREAMON 后更不会管旧引用是否还在读取。服务层自作聪明地“停流省电”,却忘了把持有旧帧引用的消费者线程全部清干净,最终读取到的是驱动重新写入过程中的中间状态。
半帧灰屏和下帧卡顿,都是同一个原因:旧帧引用在错误的时间被消费了。
这个教训在 RK3588 这种内存带宽敏感的板子上特别容易被放大,因为摄像头 buffer 和 NPU 推理 buffer 在多路并发时可能紧挨着同一片 CMA 内存区域,数据污染会互相传导。
5.4 修复方案和最终的运行策略
修复其实不难,关键是把停流和恢复做成严格的单向状态机,不允许任何旧帧跨过边界继续存活。
我做了三件事:
第一,停流前必须让采集线程完全退出,并且清空所有已发布帧引用。采集线程退出时把自己的输出置空,队列清空,消费者才能安全读到 None 而不是脏数据。
第二,重新拉流时,不要直接 STREAMON 后立刻消费,而是重新申请一次 buffer,并至少要等到驱动返回第一帧完整的、经过解码的画面。我加了一个“预热三帧”逻辑,前三帧只丢弃不推理。
第三,重启流之前,如果检测到上次停止不干净,就整个重新打开设备节点,相当于把 V4L2 状态彻底重置。代码大致是这个样子:
def stop(self): self.running.clear() self.thread.join(timeout=2) self.frame_queue.clear() self._streamoff() self._release_buffers()def start(self): self._acquire_buffers() self._streamon() for _ in range(3): self._dqb_and_discard() self.running.set() self.thread.start()这样改完以后,空闲停流再恢复的问题彻底消失。
不过我后来也反思了一下,在 RK3588 上跑 YOLOv5s 推理,空闲时整个服务层 CPU 占用并不高,NPU 在无推理任务时功耗也有限。为了省那一点资源,引入一个复杂状态机,反而让系统脆弱性大大增加。最终产品里的策略是:摄像头一直保持在采集状态,但如果没有任何客户端连接,暂停推理,只保留最新一帧不推流。也就是说,摄像头采集不停,模型按需推理。这也是我最后想给各位的一个建议:嵌入式平台的省电优化要结合稳定性一起看,别为了省一点功耗给自己挖大坑。
6. 部署到后台:systemd 托管、监控与长时间稳定性
当服务层和摄像头都跑通之后,最后一步是让它作为一个常驻服务运行,而不是在终端里挂一个前台进程。RK3588 开发板有时候会意外重启,或者 USB 摄像头偶发掉线导致线程崩溃,这时候一个可靠的进程托管方案会替你省很多事。
6.1 用 systemd 把服务层变成常驻进程
我在/etc/systemd/system/yolo.service写了一个简单的单元文件:
[Unit] Description=RK3588 YOLOv5s Detection Service After=network.target [Service] Type=simple ExecStart=/usr/bin/python3 /opt/yolo_service/app.py WorkingDirectory=/opt/yolo_service Restart=always RestartSec=5 Environment=LD_LIBRARY_PATH=/usr/local/lib:/opt/rknpu2/lib User=ubuntu StandardOutput=append:/var/log/yolo_service.log StandardError=append:/var/log/yolo_service.log [Install] WantedBy=multi-user.target启动命令是:
sudo systemctl daemon-reload sudo systemctl enable yolo.service sudo systemctl start yolo.serviceRestart=always是必须的,不然摄像头线程意外崩溃后服务就僵在那里了。RestartSec=5给系统一点缓冲时间,避免反复重启太快把日志刷爆。
6.2 健康检查和帧率日志
我还在 FastAPI 里加了一个/health接口,返回当前服务状态、最近一次推理耗时、摄像头最近一帧时间戳、当前连接数。这个接口一方面给前端做探测,另一方面我可以用 curl 定时拉一下,丢给监控脚本。
@app.get("/health") def health(): return { "status": "ok", "fps": fps_counter.current(), "last_infer_ms": detector.last_infer_ms, "last_frame_ts": camera.last_frame_ts, "client_count": ws_manager.count() }日志方面,我建议不要每一帧都打印,那样日志量太大。我选择每 100 帧打印一次摘要,包含推理耗时均值、丢帧数、队列深度。这样既不刷屏,又能看到是否存在周期性卡顿。
6.3 多客户端场景的最终表现
改完这些之后,我把服务挂在局域网里跑了 48 小时。三个客户端同时连接 WebSocket:一个浏览器显示检测画面,一个脚本在跑业务告警,一个手机端开着网页。RK3588 的 CPU 占用大概 40% 左右,NPU 利用率在 50% 上下,内存稳定在 330MB 附近,没有持续上涨。
摄像头自动断开再重连也能在 2 秒内恢复服务,前端最多丢两三帧画面,用户体验上基本无感。
我自己在实际操作中的体会是,这类嵌入式部署项目的复杂度,往往不是集中在单一难点上,而是散落在每一个“你以为能跑就行”的地方。服务层的线程模型、摄像头的 buffer 生命周期、NPU 内存分配,单独看都不难,拼在一起就容易出怪问题。这篇记录下来的坑和处理思路,希望能让你少走几趟弯路。