很多项目里"相机"和"AI 识别"是两套程序:相机管拍,识别另起进程跑。
本文基于触觉智能RK3576开发板Purple Pi OH2开发板制作的采集终端(RkDataTerminal)Demo里做的另一种结合方式------把NPU检测直接嵌进采集链路 :相机取帧→模型出框→框烧进照片→连同当时的通道数据一起归档上报;值守时更是1fps持续检测,人/动物经过就自动拍下带框的留证照片 。
采集终端演示视频:
https://www.bilibili.com/video/BV1Y2pr6aE2X/
深圳触觉智能
,赞107
本文给出链路、关键代码、实测数据,以及"AI 如何部署上板"的完整步骤

目标:不是"拍照 + 识别",而是"拍下即判"
传统做法:相机拍一张图存盘,再让另一个程序读图去推理------两者时间对不上、数据也对不上。
我们要的是一次采集完成三件事 :
- 拍摄:拿到现场画面(拍照 4K / 值守低帧率取帧);
- 判断:同一时刻送 NPU 推理,得到目标类别与位置;
- 留寸:把检测框和当时的采集数据烧进同一张图并入库,附带结构化 JSON。
一句话:证据自证 ------图上既有现场,也有判定,还有当时的数据。

总体设计:三条链路,一张图看懂

三条链路的分工 :
| 链路 | 取帧方式 | 推理频率 | 用途 |
|---|---|---|---|
| 拍照留证 | Camera2 抓 4K JPEG | 每次拍照 1 次 | 手动/定时/被触发时的证据 |
| 值守检测 | 预览挂 YUV ImageReader | ~1fps 持续 | 决定"什么时候该拍" |
| 远程查看 | 复用值守帧压 JPEG | ~1--2fps | 浏览器看现场 |

相机侧:两条取帧路径
3.1 拍照路径:会话重建(预览 → 拍照 → 回预览)
Camera2 下"预览 / 拍照 / 录像"通常不能同会话共存(尤其这套 legacy 包装的 HAL),所以拍照采用按需重建会话 :
预览会话(preview + 值守YUV)
└─ takePhoto → 换成 still 会话(preview + JPEG ImageReader)
└─ 出图后 restorePreview() → 回到预览会话
代价是每次重建约1.2s(HAL实测),所以拍照不能太频繁 ------这也是后面"值守 15s冷却"的由来之一。
3.2 值守路径:给预览挂一路 YUV,且必须"随到随清"
值守要在不打断预览的前提下持续拿帧。做法是给预览会话再挂一个720p的YUV ImageReader ,作为重复请求的第二个target。
这里有个极易踩的坑 :如果回调里觉得"没到检测节奏"就直接return、不取帧 ,两个缓冲很快占满,相机侧没有空 buffer 就会停摆------实测帧率掉到~6 秒一帧。
正确写法是每回调必排空,节奏另判 :
// CameraController.onWatchFrame()
image = reader.acquireLatestImage(); // 先取走最新帧 → 缓冲永远有空位
if (now - mLastWatchMs < MIN_INTERVAL) { // 到了节奏才往下送
return; // 已经 drain,直接丢不影响相机
}
mLastWatchMs = now;
mWatchListener.onWatchFrame(w, h, nv21); // 交给检测线程
一句话记住:ImageReader 必须"随到随清",节流逻辑放后面。
另外两条相机侧硬约束:
- 预览分辨率必须 ≤1080P:试过 4K 预览,会把 legacy HAL 直接拖崩(device error 3);
- 拍照/录像期间值守暂停:会话被占用,恢复预览后值守自动续上。

AI 侧:从一帧图像到一组框
4.1 预处理:letterbox 到 640×640(且必须记住缩放与偏移)
模型输入固定640×640,而相机画面是16:9(如1280×720)。直接拉伸会变形,所以用letterbox :等比缩放+居中补边(补灰色114)。
关键是把缩放比和补边量记下来 ,后面才能把模型输出的框坐标反算回原图 :
float scale = Math.min(640f / w, 640f / h); // 等比
int nw = round(w * scale), nh = round(h * scale);
int padX = (640 - nw) / 2, padY = (640 - nh) / 2; // 补边量
// ...缩放绘制到 640x640,再把像素读成 byte[] RGB 喂给 native
4.2 模型与 runtime 随身带,不依赖固件
不假设固件预装了 NPU 运行时,把两样东西直接打进 APK:
-
librknnrt.so(RKNN runtime,引用树内 hardware/rockchip/rknpu2 模块);
-
yolov5s-640-640.rknn(COCO80 模型,8MB)放在 assets/,首次运行时解包到私有目录再 rknn_init。
Android.bp 里真正关键的两行:
cc_library_shared {
name: "librkdt_yolo",
stl: "c++_static", // 必须静态 libc++:App 命名空间没有动态 libc++.so
}
android_app {
name: "RkDataTerminal",
jni_libs: ["librkdt_yolo", "librknnrt"], // 两个 .so 一起内嵌进 APK
}
4.3 推理与后处理:三头 int8 + NMS
native 侧(jni/Detector.cpp + 移植自官方 demo 的 postprocess.cc)职责很单一:
rknn_inputs_set(uint8 RGB, NHWC) → rknn_run → 三头 int8 输出 → 反量化(zp/scale) + 置信度过滤 + NMS → 出框 class/score/box
出框坐标先在 640 坐标系 里,再用 4.1 记下的 scale/padX/padY 反算回原图:
原图坐标 = (框坐标 − pad) / scale
这就是"结合"的第一处细节:相机给的尺度和模型要的尺度不一样,中间的 letterbox + 逆映射是必须补齐的一环。
4.4 两条推理入口:位图 与 NV21
-
拍照链路:detect(Bitmap) ------ 从4K JPEG 采样解码 (decodeSampled(path, 1920))再 letterbox;
-
值守链路:detectNv21(w,h,byte\[\]) ------ YUV 帧直接 转RGB并letterbox,免一次JPEG解码 。
两条入口共用一套"letterbox→native→逆映射"逻辑,仅喂进去像素来源不同。

takePhoto → JPEG(4K)
├─ 送 NPU 检测 → dets[]
├─ 画水印:站点/SN/时间/8 路通道值 + dets 的框与类别
└─ 入库:记录 JSON 里追加 detections[],图为带框的 _wm.jpg
结合点:什么时候让 AI 干活
5.1 拍照即判:一次拍照,两种产物
检测在原始大图上算,水印在JPEG上 ,两者坐标系一致⇒图上的框就是真实位置。
5.2 值守触发:AI 决定"什么时候按快门"
值守每秒判一帧,命中就自动走上面的"拍照即判":
// handleWatchDetections()
if (best != null && best.score >= TRIGGER_SCORE // 0.55
&& now - mLastAlertMs > ALERT_COOLDOWN_MS) { // 15s 冷却
mLastAlertMs = now;
mAutoNote = label(best.cls) + " " + best.score; // 写进水印/摘要
triggerCapture(); // 自动留证
}
| 参数 | 取值 | 说明 |
|---|---|---|
| 触发类别 | person + 10 类动物 | COCO80 里 person 与 bird/cat/dog/horse/sheep/cow/elephant/bear/zebra/giraffe |
| 置信度阈值 | 0.55 | 太低误报、太高漏检,实测取中 |
| 冷却时间 | 15s | 目标站着不动时不会连拍 |
| 检测频率 | ~1fps | NPU 单帧 0.1--0.2s,够用省电 |
触发后的记录摘要会带上 值守 person 0.88 前缀------翻记录时一眼就知道"为什么拍了这张"。