RK3588边缘视觉服务层实战:YOLOv5s与FastAPI集成
1. 项目背景与服务层设计思路1.1 为什么要在 RK3588 上做服务层RK3588 这颗芯片在边缘计算圈子里热度一直很高8 核 CPU4 个 A76 大核 4 个 A55 小核、6 TOPS 算力的 NPU、Mali-G610 GPU再加上丰富的接口MIPI CSI、HDMI、千兆网口、USB 3.0拿来跑视觉推理任务非常合适。但很多人把 YOLOv5s 模型转换完、NPU 推理跑通之后就停在了一个“能跑 demo”的阶段——模型输出一堆检测框坐标然后呢没有然后了。真正要落地一个视觉项目模型推理只是中间环节。前端要有摄像头采集中间要有推理调度后端要有服务接口把结果吐出去给上层业务用。这就是服务层存在的意义。我在这个项目里选择 FastAPI 作为服务层框架核心原因有三个第一它是异步框架天然适合处理摄像头取流和推理这种 IO 密集 计算密集混合的场景第二它自带 OpenAPI 文档调试接口非常方便第三Python 生态和 YOLOv5 的 Python 推理代码无缝衔接不需要跨语言调用。整个链路是这样的MIPI 摄像头通过 V4L2 采集原始帧经过格式转换后送入 RKNN 推理引擎推理结果通过 FastAPI 暴露成 HTTP 接口上层业务系统通过 RESTful API 拉取检测结果或者订阅推送。这个架构看起来简单但每一层都有坑尤其是摄像头这一层后面我会详细说。1.2 服务层的核心需求拆解在动手写代码之前我先梳理了一下服务层需要满足的需求实时性摄像头采集和推理要持续运行不能因为 HTTP 请求阻塞而中断推理循环并发性可能同时有多个客户端拉取结果服务层要能处理并发请求可观测性需要能看到当前推理帧率、NPU 占用、摄像头状态等运行时指标可配置性置信度阈值、NMS 阈值、检测类别等参数要能通过接口动态调整健壮性摄像头掉线、NPU 异常等情况要有容错机制不能整个服务崩掉基于这些需求我把服务层拆成了三个模块采集模块负责从摄像头拿帧推理模块负责调用 RKNN 引擎做检测API 模块负责对外暴露接口。三个模块之间通过线程安全的队列传递数据推理模块在独立线程中运行API 模块在主线程中处理请求。这样设计的好处是推理循环不会被 HTTP 请求阻塞同时 API 层可以随时读取最新的推理结果。注意不要把推理放在 FastAPI 的请求处理函数里同步执行否则每个请求都要等一次完整推理延迟会非常高而且并发请求会直接排队堵死。2. 摄像头接入的完整实操2.1 MIPI 摄像头硬件连接与驱动确认我用的是一块常见的 OV5647 MIPI 摄像头模组通过 FPC 排线接到 RK3588 开发板的 MIPI CSI 接口上。硬件连接本身不复杂但有几个细节要注意排线方向不能插反金手指要完全插入卡扣卡扣要压紧。我第一次插的时候没插到底系统能识别到设备但取流一直失败排查了半天才发现是接触不良。连接好之后上电启动系统先确认驱动是否加载# 查看 V4L2 设备节点 ls /dev/video* # 查看设备详细信息 v4l2-ctl --device/dev/video0 --info # 列出支持的格式 v4l2-ctl --device/dev/video0 --list-formats-ext如果/dev/video*下没有设备节点说明驱动没加载。RK3588 的 BSP 内核一般已经内置了常见摄像头的驱动但需要在设备树里使能对应的 MIPI CSI 节点。我用的是鲁班猫 5 的板子设备树里默认使能了 MIPI CSI所以直接就能识别。如果你用的是其他板子可能需要自己修改设备树把对应的rockchip,csi2节点状态改成okay并配置好 I2C 总线和 GPIO 复位引脚。v4l2-ctl --list-formats-ext的输出很关键它会告诉你摄像头支持哪些分辨率和像素格式。OV5647 一般支持 1920x1080、1280x720、640x480 等分辨率像素格式通常是 YUYV 或 NV12。记住这些信息后面配置采集参数要用。2.2 用 OpenCV 还是 V4L2 直接采集这里有一个选型问题用 OpenCV 的VideoCapture还是直接用 V4L2 接口采集我两种都试过。OpenCV 的优点是代码简单几行就能拿到帧缺点是它内部会做一次格式转换YUYV 转 BGR在 RK3588 上这个转换是 CPU 做的1080p 下会吃掉不少 CPU 资源。而且 OpenCV 的VideoCapture在 Linux 下底层也是调 V4L2但它的缓冲管理不够灵活有时候会出现帧延迟累积的问题。直接用 V4L2 采集的优点是零拷贝、格式可控可以直接拿到 NV12 数据送给 NPU 做推理RKNN 的输入格式通常就是 NV12 或 RGB888省掉一次转换。缺点是代码复杂一些要自己管理 buffer 的申请、入队、出队。我最终选择了 V4L2 直接采集因为推理链路对延迟敏感能省一次转换就省一次。核心代码结构大概是这样// 申请缓冲区 struct v4l2_requestbuffers req; req.count 4; req.type V4L2_BUF_TYPE_VIDEO_CAPTURE; req.memory V4L2_MEMORY_MMAP; ioctl(fd, VIDIOC_REQBUFS, req); // 映射缓冲区到用户空间 for (int i 0; i req.count; i) { struct v4l2_buffer buf; buf.type V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory V4L2_MEMORY_MMAP; buf.index i; ioctl(fd, VIDIOC_QUERYBUF, buf); buffers[i].length buf.length; buffers[i].start mmap(NULL, buf.length, PROT_READ | PROT_WRITE, MAP_SHARED, fd, buf.m.offset); } // 入队所有缓冲区 for (int i 0; i req.count; i) { struct v4l2_buffer buf; buf.type V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory V4L2_MEMORY_MMAP; buf.index i; ioctl(fd, VIDIOC_QBUF, buf); } // 开始采集 enum v4l2_buf_type type V4L2_BUF_TYPE_VIDEO_CAPTURE; ioctl(fd, VIDIOC_STREAMON, type);采集循环里就是不断地VIDIOC_DQBUF取帧、处理、再VIDIOC_QBUF还回去。缓冲区数量我设的是 4 个太少容易丢帧太多会增加延迟。4 个是一个比较平衡的值。实操心得V4L2 采集一定要用 MMAP 方式不要用 READ 方式。READ 方式每帧都要做一次内存拷贝1080p 下 CPU 占用会高很多。2.3 摄像头采集的常见坑摄像头这块我踩的坑最多挑几个典型的说一下。第一个坑格式协商失败。摄像头支持的格式和你请求的格式不一致时VIDIOC_S_FMT会返回失败或者静默改成摄像头支持的格式。我一开始请求的是 RGB888但 OV5647 只支持 YUYV 和 NV12结果VIDIOC_S_FMT虽然返回成功但实际格式被改成了 YUYV后面按 RGB888 解析数据全是花屏。解决办法是设置完格式后一定要用VIDIOC_G_FMT读回来确认实际生效的格式。第二个坑帧率不稳定。MIPI 摄像头的帧率受曝光时间影响很大。光线暗的时候摄像头会自动增加曝光时间帧率就会掉下来。如果项目对帧率稳定性要求高可以手动设置曝光模式为手动固定曝光时间。通过v4l2-ctl可以设置# 关闭自动曝光 v4l2-ctl --device/dev/video0 --set-ctrlauto_exposure1 # 设置曝光时间具体值需要根据光线条件调整 v4l2-ctl --device/dev/video0 --set-ctrlexposure1000第三个坑缓冲区溢出。如果推理速度跟不上采集速度缓冲区会一直满着VIDIOC_DQBUF会阻塞或者返回错误。我的处理方式是如果推理线程来不及处理就主动丢弃旧帧只保留最新帧。具体做法是在采集线程里维护一个“最新帧”变量推理线程每次取最新帧处理而不是从队列里按顺序取。这样虽然会丢帧但保证推理的永远是最新画面不会出现延迟累积。3. FastAPI 服务层的实现细节3.1 项目目录结构设计FastAPI 项目最容易犯的错误就是把所有代码堆在一个main.py里。项目稍微大一点就完全没法维护。我用的目录结构是这样的yolov5s_service/ ├── app/ │ ├── __init__.py │ ├── main.py # FastAPI 入口 │ ├── config.py # 配置管理 │ ├── camera/ │ │ ├── __init__.py │ │ ├── capture.py # V4L2 采集封装 │ │ └── buffer.py # 帧缓冲管理 │ ├── inference/ │ │ ├── __init__.py │ │ ├── rknn_engine.py # RKNN 推理封装 │ │ └── postprocess.py # NMS、坐标转换等后处理 │ ├── api/ │ │ ├── __init__.py │ │ ├── routes.py # 路由定义 │ │ └── schemas.py # Pydantic 模型 │ └── utils/ │ ├── __init__.py │ └── logger.py # 日志配置 ├── models/ │ └── yolov5s.rknn # 转换好的 RKNN 模型 ├── requirements.txt └── run.py # 启动脚本这个结构的好处是职责清晰camera只管采集inference只管推理api只管接口。模块之间通过明确的接口交互改一个模块不会影响其他模块。比如后面我想把 OV5647 换成其他摄像头只需要改camera/capture.py推理和 API 层完全不用动。3.2 推理线程与 API 线程的协作前面说了推理要在独立线程里跑API 在主线程处理请求。两者之间的数据交换我用了一个简单的共享状态对象import threading import time class InferenceState: def __init__(self): self._lock threading.Lock() self._latest_result None self._latest_frame None self._fps 0.0 self._frame_count 0 self._last_time time.time() def update(self, frame, result): with self._lock: self._latest_frame frame self._latest_result result self._frame_count 1 now time.time() if now - self._last_time 1.0: self._fps self._frame_count / (now - self._last_time) self._frame_count 0 self._last_time now def get_latest(self): with self._lock: return self._latest_frame, self._latest_result property def fps(self): with self._lock: return self._fps推理线程的循环大概是这样def inference_loop(state, engine, camera): while not stop_event.is_set(): frame camera.get_latest_frame() if frame is None: time.sleep(0.001) continue result engine.infer(frame) state.update(frame, result)API 层读取结果就很简单了app.get(/detect) async def detect(): frame, result state.get_latest() if result is None: return {code: 1, msg: no result yet} return {code: 0, data: result}这里有一个细节state.get_latest()返回的是引用如果推理线程在 API 读取的同时更新了结果可能会有数据竞争。Python 的 GIL 在一定程度上保护了这种情况但为了保险我在update和get_latest里都加了锁。锁的粒度很小只保护引用赋值不会影响性能。3.3 API 接口设计与参数配置我设计了几个核心接口接口路径方法功能返回内容/detectGET获取最新检测结果检测框列表、类别、置信度/frameGET获取最新帧的 JPEG 图片图片二进制流/configGET获取当前配置置信度阈值、NMS 阈值等/configPOST更新配置更新结果/statusGET获取运行状态FPS、NPU 占用、摄像头状态/healthGET健康检查服务是否正常配置更新接口的实现要注意线程安全。置信度阈值这些参数在推理线程里会被读取在 API 线程里会被修改。我用了一个Config类内部用锁保护class Config: def __init__(self): self._lock threading.Lock() self._conf_threshold 0.25 self._nms_threshold 0.45 self._classes None property def conf_threshold(self): with self._lock: return self._conf_threshold conf_threshold.setter def conf_threshold(self, value): with self._lock: self._conf_threshold value推理线程每次推理前读取一次配置API 线程修改配置时加锁。这样保证推理线程拿到的配置始终是一致的不会出现读到一半被改的情况。注意FastAPI 的异步接口里不要做阻塞操作。比如读取摄像头帧、调用 NPU 推理这些都是阻塞的如果直接在async def里调用会阻塞事件循环导致其他请求无法处理。我的做法是推理在独立线程里跑API 只读取共享状态不直接调用推理。3.4 uvicorn 日志丢失问题的处理这个问题我折腾了很久。用 uvicorn 启动 FastAPI 后发现应用里用logging打的日志有时候会丢尤其是多 worker 模式下。排查后发现原因是 uvicorn 自己配置了 logging把 root logger 的 handler 覆盖了导致应用里通过logging.getLogger(__name__)打的日志没有输出。解决办法是在应用启动时重新配置 loggingimport logging import sys def setup_logging(): # 清除 uvicorn 添加的 handler for handler in logging.root.handlers[:]: logging.root.removeHandler(handler) logging.basicConfig( levellogging.INFO, format%(asctime)s [%(levelname)s] %(name)s: %(message)s, handlers[ logging.StreamHandler(sys.stdout), logging.FileHandler(service.log) ] )然后在main.py里在创建 FastAPI 应用之前调用setup_logging()。这样应用日志就能正常输出了。另外如果用的是多 worker 模式uvicorn --workers 4每个 worker 是独立进程日志会分别写到同一个文件里可能会有交错。生产环境建议用logging.handlers.RotatingFileHandler并配合进程 ID 区分或者直接输出到 stdout 由外部日志系统收集。4. 那个折腾最久的坑摄像头与 NPU 的数据格式对齐4.1 问题现象与初步排查这个坑是我整个项目里花时间最多的。现象是推理结果框的位置总是偏的而且偏得没有规律。有时候框偏左有时候偏右有时候框的大小也不对。一开始我以为是模型转换的问题重新转换了两次 RKNN 模型问题依旧。又怀疑是后处理代码的坐标转换写错了检查了好几遍也没发现问题。后来我写了一个测试脚本把摄像头采集的原始帧保存成图片同时把送入 NPU 的数据也保存成图片对比两者。结果发现送入 NPU 的数据和原始帧相比颜色是错的而且图像有轻微的拉伸。这就定位到问题了数据格式转换环节有 bug。4.2 根因分析YUYV 到 NV12 的转换OV5647 输出的是 YUYV 格式也叫 YUY2而 RKNN 模型输入的通常是 NV12 格式。YUYV 和 NV12 都是 YUV 格式但排列方式不同YUYV每两个像素共享一组 UV 分量排列顺序是 Y0 U0 Y1 V0 Y2 U1 Y3 V1...NV12先存储所有 Y 分量然后交错存储 UV 分量排列是 Y0 Y1 Y2 Y3... U0 V0 U1 V1...我一开始用的转换代码是从网上找的它假设输入是 YUYV输出是 NV12但实际写的时候把 UV 的顺序搞反了变成了 U 和 V 互换。这导致颜色偏色进而影响推理——因为 YOLOv5s 是在 RGB 图像上训练的颜色信息对检测结果有影响尤其是对颜色敏感的类别。更隐蔽的问题是 stride。V4L2 采集出来的数据每行可能有 padding对齐要求比如宽度是 1920但实际每行占 1920 16 字节。如果转换时按 1920 字节一行处理图像就会错位。这个问题在宽度不是 16 的倍数时特别明显。4.3 解决方案与验证解决方法是自己写一个正确的 YUYV 到 NV12 转换函数并且正确处理 stridedef yuyv_to_nv12(yuyv_data, width, height, y_stride, uv_stride): yuyv_data: 原始 YUYV 数据 width: 图像宽度 height: 图像高度 y_stride: Y 分量的行跨度可能大于 width uv_stride: UV 分量的行跨度 nv12 bytearray(y_stride * height uv_stride * height // 2) y_plane nv12[:y_stride * height] uv_plane nv12[y_stride * height:] for row in range(height): src_offset row * width * 2 dst_y_offset row * y_stride dst_uv_offset (row // 2) * uv_stride for col in range(0, width, 2): y0 yuyv_data[src_offset col * 2] u yuyv_data[src_offset col * 2 1] y1 yuyv_data[src_offset col * 2 2] v yuyv_data[src_offset col * 2 3] y_plane[dst_y_offset col] y0 y_plane[dst_y_offset col 1] y1 if row % 2 0: uv_plane[dst_uv_offset col] u uv_plane[dst_uv_offset col 1] v return bytes(nv12)这个函数的关键点UV 分量的写入只在偶数行进行因为 NV12 的 UV 是 2x2 下采样的而且 U 和 V 的顺序不能搞反。stride 参数从 V4L2 的VIDIOC_G_FMT里获取不要自己假设等于 width。验证方法转换后的 NV12 数据用ffmpeg或者 Python 的cv2.cvtColor转成 RGB 图片和原始 YUYV 转出来的 RGB 图片对比颜色和内容应该完全一致。我写了一个小脚本做这个对比确认无误后才接入推理链路。实操心得摄像头数据格式转换是视觉项目里最容易出问题的地方。我的建议是不管多自信都要写一个可视化对比脚本把每一步转换的结果都保存成图片看一眼。肉眼确认比任何调试都可靠。4.4 性能优化从 CPU 转换到 RGA 硬件加速上面那个 Python 版本的转换函数在 1080p 下每帧要几十毫秒完全跟不上 30fps 的采集速度。我一开始用 C 重写了这个函数性能提升到每帧 5ms 左右但还是占 CPU。后来发现 RK3588 有一个 RGARaster Graphic Acceleration硬件模块专门做图像格式转换和缩放可以完全卸载 CPU。RGA 的使用需要通过librga库代码大概是这样#include rga/RgaApi.h // 源图像信息 rga_info_t src_info; memset(src_info, 0, sizeof(src_info)); src_info.fd src_dma_fd; src_info.mmuFlag 1; rga_set_rect(src_info.rect, 0, 0, src_width, src_height, src_stride, src_height, RK_FORMAT_YUYV_422); // 目标图像信息 rga_info_t dst_info; memset(dst_info, 0, sizeof(dst_info)); dst_info.fd dst_dma_fd; dst_info.mmuFlag 1; rga_set_rect(dst_info.rect, 0, 0, dst_width, dst_height, dst_stride, dst_height, RK_FORMAT_YCbCr_420_SP); // 执行转换 c_RkRgaBlit(src_info, dst_info, NULL);用 RGA 之后格式转换的 CPU 占用几乎降到零而且延迟从 5ms 降到了 1ms 以内。这是 RK3588 平台相比纯 CPU 方案的一个巨大优势一定要用起来。不过 RGA 也有坑它要求输入输出都是 DMA buffer不能是普通 malloc 的内存。所以摄像头采集的 buffer 要直接 mmap 成 DMA buffer或者用dma_buf机制申请。这块代码稍微复杂一些但一旦跑通就非常稳定。5. 常见问题速查与排查技巧5.1 摄像头相关问题现象可能原因排查方法解决方案/dev/video*不存在驱动未加载或设备树未使能dmesggrep csi 查看内核日志取流失败VIDIOC_DQBUF返回错误缓冲区未正确入队或格式协商失败检查VIDIOC_QBUF返回值确认格式设置成功后再入队图像花屏格式不匹配或 stride 处理错误保存原始帧用工具查看用VIDIOC_G_FMT确认实际格式帧率不稳定自动曝光导致曝光时间变化v4l2-ctl --list-ctrls查看曝光模式手动设置曝光时间颜色偏色YUV 转换时 UV 顺序错误对比转换前后的 RGB 图片检查 U/V 顺序5.2 推理与服务相关问题现象可能原因排查方法解决方案推理结果框位置偏移输入数据格式或尺寸不对保存送入 NPU 的数据可视化检查格式转换和 resize 逻辑服务响应慢推理阻塞了 API 线程查看线程堆栈推理放独立线程API 只读状态日志丢失uvicorn 覆盖了 logging 配置检查 root logger handler启动时重新配置 logging内存持续增长帧缓冲未释放或引用泄漏ps查看内存占用检查 buffer 释放逻辑NPU 利用率低推理循环等待采集查看推理耗时和采集耗时优化采集或推理环节5.3 独家避坑技巧技巧一用v4l2-ctl做快速验证。在写代码之前先用v4l2-ctl命令行工具确认摄像头能正常出图# 采集 10 帧保存为图片 v4l2-ctl --device/dev/video0 --set-fmt-videowidth1920,height1080,pixelformatYUYV \ --stream-mmap --stream-count10 --stream-totest.raw然后用ffmpeg把 raw 数据转成图片看一眼。这一步能排除掉大部分硬件和驱动问题。技巧二推理结果可视化。不要只看日志里的坐标数字一定要把检测框画到图片上保存下来看。我写了一个简单的可视化函数每次推理后把结果画到帧上保存成 JPEG这样一眼就能看出框的位置对不对、类别对不对。技巧三分阶段测延迟。把整个链路的延迟拆开测采集延迟、格式转换延迟、推理延迟、后处理延迟。我实测下来OV5647 采集 1080p 每帧约 33ms30fpsRGA 转换约 1msRKNN 推理约 25ms后处理约 3ms总延迟约 62ms。知道每个环节的耗时优化的时候就知道该从哪里下手。技巧四NPU 频率要拉满。RK3588 的 NPU 默认频率可能不是最高可以通过 sysfs 调整# 查看当前频率 cat /sys/class/devfreq/fdab0000.npu/cur_freq # 设置为性能模式 echo performance /sys/class/devfreq/fdab0000.npu/governor设置成 performance 模式后NPU 会一直跑在最高频率推理延迟能降低 10% 到 20%。代价是功耗和发热增加如果板子散热不好要注意温度。技巧五模型输入尺寸要匹配。YOLOv5s 默认输入是 640x640但摄像头采集的是 1920x1080。如果直接把 1920x1080 的帧 resize 到 640x640长宽比会变导致检测框变形。正确的做法是保持长宽比缩放然后 padding 到 640x640。后处理时再把坐标映射回原始尺寸。这个细节很多教程都不提但不做的话检测精度会明显下降。6. 性能实测与优化建议6.1 实测数据我在鲁班猫 5RK35884GB 内存上做了完整的性能测试数据如下环节耗时备注摄像头采集1080p33ms30fps 固定YUYV 转 NV12RGA1ms硬件加速图像 resize padding2msRGA 完成RKNN 推理YOLOv5s 640x64025msNPU 性能模式后处理NMS 坐标映射3msCPU端到端延迟约 64ms约 15.6fps这个性能对于大多数边缘视觉场景已经够用了。如果追求更高帧率可以降低采集分辨率到 720p推理输入降到 416x416端到端延迟能降到 40ms 左右约 25fps。6.2 进一步优化方向如果还想压榨性能有几个方向模型量化。YOLOv5s 的 RKNN 模型可以用 int8 量化推理速度能提升 30% 到 50%精度损失通常在 1% 到 3% 之间。量化需要准备校准数据集用 RKNN Toolkit 的量化工具做。我实测 int8 量化后推理耗时从 25ms 降到了 16ms精度在 COCO 数据集上 mAP 只掉了 1.2 个百分点。多线程流水线。目前采集和推理是串行的采集一帧、推理一帧。可以改成流水线采集线程持续采集推理线程从队列取帧推理两个线程并行。这样采集的 33ms 和推理的 25ms 可以重叠理论帧率能提升到接近 30fps。但要注意队列深度控制太深会导致延迟累积。零拷贝链路。目前摄像头采集的 buffer 到 RGA 到 NPU 之间还有一次拷贝。如果全部用 DMA buffer 串联可以实现零拷贝进一步降低延迟和 CPU 占用。这需要修改 V4L2 的 buffer 申请方式用V4L2_MEMORY_DMABUF替代V4L2_MEMORY_MMAP然后把这个 DMA fd 直接传给 RGA 和 RKNN。这块我还在折腾中跑通后再分享。6.3 稳定性方面的经验服务跑长时间运行稳定性比性能更重要。我总结了几个要点摄像头断线重连MIPI 摄像头有时候会因为静电或者干扰导致断流VIDIOC_DQBUF会返回错误。我的处理是捕获这个错误关闭设备重新打开重新初始化采集。整个过程在 1 秒内完成上层业务基本无感知。NPU 异常处理RKNN 推理偶尔会返回错误码可能是输入数据异常导致的。我的处理是记录错误日志跳过这一帧继续下一帧。不要因为一帧异常就退出整个推理循环。内存监控长时间运行要关注内存占用。我用psutil定期采集进程内存如果持续增长就说明有泄漏需要排查。常见泄漏点是帧 buffer 没有正确释放或者日志对象持有大量引用。看门狗我加了一个简单的看门狗线程定期检查推理线程的心跳。如果推理线程超过 5 秒没有更新结果就认为它卡死了重启推理线程。这个机制在实际运行中救过我好几次。7. 后续扩展思路这套服务层的架构是通用的换模型、换摄像头、换业务逻辑都很方便。我后续打算做几个扩展多路摄像头支持。目前只接了一路 MIPI 摄像头。RK3588 有多个 MIPI CSI 接口可以同时接多路。服务层可以扩展成多路并行推理每路独立管理采集和推理线程API 层按摄像头 ID 区分结果。这对多视角监控场景很有用。RTSP 推流。目前 API 只提供 JPEG 图片拉取延迟较高。可以集成 RTSP 服务把带检测框的视频流实时推出去上层用 VLC 或者 Web 播放器直接看。RK3588 的 VPU 支持硬件编码 H.264/H.265推流对 CPU 占用很低。检测结果持久化。目前结果只在内存里重启就没了。可以接入 SQLite 或者轻量级时序数据库把检测结果按时间戳存下来方便后续做统计分析和回溯。模型热切换。目前模型是启动时加载的换模型要重启服务。可以做成模型池API 层支持动态切换模型不同场景用不同模型。比如白天用通用模型晚上用红外模型。这套东西从零搭起来确实花了不少时间尤其是摄像头格式转换那个坑前前后后折腾了将近一周。但跑通之后整个链路的稳定性还是不错的连续跑了 72 小时没有出现崩溃或者明显的内存泄漏。如果你也在做类似的项目希望这些经验能帮你少走一些弯路。摄像头这块我的建议是先用v4l2-ctl把硬件和驱动确认好再写代码格式转换一定要做可视化验证能用 RGA 硬件加速就别用 CPU 软转。NPU 推理这块注意输入尺寸和格式的匹配模型量化能大幅提升性能。服务层用 FastAPI 是个不错的选择但要注意线程模型和日志配置。