目录
[一、前置说明 & 新手扫盲](#一、前置说明 & 新手扫盲)
[二、第一步:环境 & 资源准备,新手零坑版](#二、第一步:环境 & 资源准备,新手零坑版)
[模块 1:动态权限申请](#模块 1:动态权限申请)
[模块 2:RKNN 模型加载与初始化](#模块 2:RKNN 模型加载与初始化)
[模块 3:相机预览初始化(Camera2 API)](#模块 3:相机预览初始化(Camera2 API))
[模块 4:实时推理 + 结果解析 + 渲染](#模块 4:实时推理 + 结果解析 + 渲染)
[五、编译运行,部署到 RK3576 板子上](#五、编译运行,部署到 RK3576 板子上)
[新手必踩的坑 & 解决方案](#新手必踩的坑 & 解决方案)
大家好,我是黒漂技术佬。上一篇我们成功把训好的 YOLO 模型转换成了 RK3576 NPU 专用的 RKNN 模型,仿真验证精度、速度都完美达标。
这一篇,我们就直接落地到 RK3576 的安卓系统里,实现摄像头实时预览 + RKNN NPU 硬件加速推理 + 商品识别结果实时渲染的完整闭环,完全贴合无人售货柜的业务需求,新手跟着走,就能在你的售货柜主控上跑通实时商品识别。
一、前置说明 & 新手扫盲
先给新手打个底,说清楚前提要求和核心逻辑:
- 你已经有 RK3576 开发板 / 售货柜主控,刷好了安卓系统(安卓 10/11/12 都可以,推荐安卓 12,最稳)
- 你有基础的安卓开发能力,会用 Android Studio,能正常给 RK3576 板子装 APK
- 你已经有上一篇转好的
best.rknn模型文件
【新手概念科普】RKNN 在安卓上怎么跑?RK3576 的安卓系统里,要调用 NPU 跑 RKNN 模型,必须用瑞芯微官方提供的RKNN Runtime Android SDK,它提供了 JNI 接口,让安卓 APP 能通过 Java/Kotlin 调用 NPU 的推理能力,实现硬件加速。
简单说:我们把 RKNN 模型、RKNN Runtime 的 so 库打包进 APK,APP 打开摄像头拿到预览帧,预处理后喂给 RKNN Runtime,调用 NPU 推理,拿到识别结果解析后渲染到屏幕上,就完成了整个实时识别流程。
新手必守的红线
- RKNN Runtime 版本必须和你转模型用的 RKNN-Toolkit2 版本完全一致!也就是 v1.6.0,版本不一致,必出现推理结果异常、闪退、甚至板子死机
- 安卓 minSdkVersion 必须≥21,targetSdkVersion 推荐 31,别用太高的版本,RK3576 的安卓系统兼容性不好
- 必须申请相机、存储权限,安卓 10 以上必须申请动态权限,不然相机打不开、模型加载不了
- 模型文件必须放到安卓的 assets 文件夹里,别放到其他地方,不然加载失败
二、第一步:环境 & 资源准备,新手零坑版
-
安装 Android Studio,官网下载最新稳定版,一键安装,配置好安卓 SDK(API Level 31)。
-
下载 RKNN-Toolkit2 v1.6.0 的安卓 SDK,瑞芯微官方 GitHub 地址:https://github.com/rockchip-linux/rknn-toolkit2,下载 v1.6.0 的 release 包,解压后在
rknn-toolkit2/rknn_runtime/Android目录里,拿到我们需要的库文件:- 动态库:
librknn_api.so(RK3576 是 64 位,只用 arm64-v8a 版本的就行) - 安卓封装类:
RKNNRuntime.java(官方示例里有,我们直接用核心封装)
- 动态库:
-
准备好你转好的
best.rknn模型文件,还有你的商品类别名文件classes.txt,里面按训练时的顺序写好商品类别名,比如:plaintext
kele_1 xueli_2 kuangquanshui_3 shutiao_4 kele_lingdu_5
三、第二步:创建安卓项目,配置环境
-
打开 Android Studio,创建新项目,选择 Empty Activity,语言选 Java(新手友好,官方示例都是 Java),包名比如
com.shouhuogui.yolo,minSdkVersion 选 21,targetSdkVersion 选 31,点击创建。 -
配置 app 模块的
build.gradle,确保支持 arm64-v8a 架构,打开 app 模块的 build.gradle,修改成以下内容:gradle
android { compileSdk 31 defaultConfig { applicationId "com.shouhuogui.yolo" minSdk 21 targetSdk 31 versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" // 新增:只支持arm64-v8a,RK3576专用 ndk { abiFilters 'arm64-v8a' } } // 新增:确保so库能被正确打包 sourceSets { main { jniLibs.srcDirs = ['src/main/jniLibs'] } } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } } dependencies { implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.8.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' } -
导入 RKNN Runtime 库文件:
- 在
app/src/main目录下,创建jniLibs文件夹,再在里面创建arm64-v8a文件夹,把librknn_api.so放进去 - 在
app/src/main/java/你的包名目录下,创建RKNNRuntime.java文件,把官方的 NPU 推理封装类放进去,核心功能是加载模型、初始化 NPU、执行推理、释放资源
- 在
-
导入模型和类别文件:
- 在
app/src/main目录下,创建assets文件夹,把best.rknn模型文件、classes.txt类别文件放进去
- 在
-
申请权限:打开
AndroidManifest.xml,在application标签前面,加上相机、存储权限:xml
<!-- 相机权限 --> <uses-permission android:name="android.permission.CAMERA" /> <!-- 存储权限 --> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <!-- 相机功能声明 --> <uses-feature android:name="android.hardware.camera" /> <uses-feature android:name="android.hardware.camera.autofocus" />
到这里,项目的环境配置就全部完成了,接下来就是核心代码的编写。
四、第三步:核心功能实现,全流程代码带注释
我们把整个流程分成 4 个核心模块,每个模块都给你可直接复制的代码,还有详细的解释,全程贴合售货柜的业务需求。
模块 1:动态权限申请
安卓 6.0 以上,相机和存储权限必须动态申请,不然 APP 一打开就崩溃。我们把这部分代码写到MainActivity里:
java
运行
public class MainActivity extends AppCompatActivity {
// 需要申请的权限
private static final String[] PERMISSIONS = {
Manifest.permission.CAMERA,
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE
};
private static final int PERMISSION_REQUEST_CODE = 100;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// 检查权限,没有就申请
if (!checkPermissions()) {
ActivityCompat.requestPermissions(this, PERMISSIONS, PERMISSION_REQUEST_CODE);
} else {
// 权限已获取,初始化相机和模型
init();
}
}
// 检查权限是否已获取
private boolean checkPermissions() {
for (String permission : PERMISSIONS) {
if (ActivityCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) {
return false;
}
}
return true;
}
// 权限申请结果回调
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == PERMISSION_REQUEST_CODE) {
boolean allGranted = true;
for (int result : grantResults) {
if (result != PackageManager.PERMISSION_GRANTED) {
allGranted = false;
break;
}
}
if (allGranted) {
init();
} else {
Toast.makeText(this, "必须获取所有权限才能使用APP", Toast.LENGTH_SHORT).show();
finish();
}
}
}
// 初始化函数,后面写核心逻辑
private void init() {
// 1. 加载RKNN模型
loadModel();
// 2. 初始化相机预览
initCamera();
}
}
模块 2:RKNN 模型加载与初始化
这部分是核心,负责把 assets 里的 RKNN 模型加载到内存,初始化 NPU runtime,准备推理。先在 MainActivity 里定义全局变量:
java
运行
// RKNN相关全局变量
private RKNNRuntime rknnRuntime;
private boolean isModelLoaded = false;
// 模型输入尺寸,和训练时一致
private static final int INPUT_WIDTH = 640;
private static final int INPUT_HEIGHT = 640;
// 商品类别数,改成你自己的数量
private static final int NUM_CLASSES = 10;
// 类别名列表
private List<String> classNames = new ArrayList<>();
然后写loadModel函数,加载模型和类别名:
java
运行
private void loadModel() {
new Thread(() -> {
try {
// 1. 加载类别名
InputStream classStream = getAssets().open("classes.txt");
BufferedReader reader = new BufferedReader(new InputStreamReader(classStream));
String line;
while ((line = reader.readLine()) != null) {
classNames.add(line.trim());
}
reader.close();
classStream.close();
// 2. 加载RKNN模型文件
InputStream modelStream = getAssets().open("best.rknn");
byte[] modelData = new byte[modelStream.available()];
modelStream.read(modelData);
modelStream.close();
// 3. 初始化RKNN Runtime,加载模型
rknnRuntime = new RKNNRuntime();
int ret = rknnRuntime.loadModel(modelData);
if (ret != 0) {
throw new Exception("加载RKNN模型失败,错误码:" + ret);
}
// 4. 初始化NPU运行环境
ret = rknnRuntime.initRuntime();
if (ret != 0) {
throw new Exception("初始化RKNN Runtime失败,错误码:" + ret);
}
isModelLoaded = true;
runOnUiThread(() -> Toast.makeText(this, "模型加载成功!", Toast.LENGTH_SHORT).show());
} catch (Exception e) {
e.printStackTrace();
runOnUiThread(() -> Toast.makeText(this, "模型加载失败:" + e.getMessage(), Toast.LENGTH_SHORT).show());
}
}).start();
}
模块 3:相机预览初始化(Camera2 API)
安卓推荐用 Camera2 API,兼容性好,能拿到实时的预览帧数据。我们用 TextureView 来显示预览,先改activity_main.xml布局文件:
xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- 相机预览控件 -->
<TextureView
android:id="@+id/textureView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<!-- 识别结果显示控件 -->
<TextView
android:id="@+id/tv_result"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:background="#80000000"
android:textColor="#FFFFFF"
android:textSize="16sp"
android:padding="10dp" />
</RelativeLayout>
回到 MainActivity,定义相机相关的全局变量:
java
运行
// 相机相关全局变量
private TextureView textureView;
private CameraDevice cameraDevice;
private CameraCaptureSession captureSession;
private static final int CAMERA_ID = 0; // 售货柜一般用后置摄像头
然后写initCamera函数,初始化相机预览:
java
运行
private void initCamera() {
textureView = findViewById(R.id.textureView);
// 监听TextureView准备完成
textureView.setSurfaceTextureListener(new TextureView.SurfaceTextureListener() {
@Override
public void onSurfaceTextureAvailable(@NonNull SurfaceTexture surface, int width, int height) {
// 打开相机
openCamera();
}
@Override
public void onSurfaceTextureSizeChanged(@NonNull SurfaceTexture surface, int width, int height) {}
@Override
public boolean onSurfaceTextureDestroyed(@NonNull SurfaceTexture surface) {
// 释放相机资源
releaseCamera();
return true;
}
@Override
public void onSurfaceTextureUpdated(@NonNull SurfaceTexture surface) {}
});
}
// 打开相机
private void openCamera() {
CameraManager cameraManager = (CameraManager) getSystemService(CAMERA_SERVICE);
try {
// 检查权限
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
return;
}
// 打开相机
cameraManager.openCamera(String.valueOf(CAMERA_ID), new CameraDevice.StateCallback() {
@Override
public void onOpened(@NonNull CameraDevice camera) {
cameraDevice = camera;
// 开始预览
startPreview();
}
@Override
public void onDisconnected(@NonNull CameraDevice camera) {
camera.close();
cameraDevice = null;
}
@Override
public void onError(@NonNull CameraDevice camera, int error) {
camera.close();
cameraDevice = null;
}
}, null);
} catch (Exception e) {
e.printStackTrace();
}
}
// 开始相机预览
private void startPreview() {
try {
SurfaceTexture surfaceTexture = textureView.getSurfaceTexture();
surfaceTexture.setDefaultBufferSize(1920, 1080); // 预览分辨率1080P
Surface surface = new Surface(surfaceTexture);
// 创建预览请求
final CaptureRequest.Builder previewRequestBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);
previewRequestBuilder.addTarget(surface);
// 创建相机捕获会话
cameraDevice.createCaptureSession(Collections.singletonList(surface), new CameraCaptureSession.StateCallback() {
@Override
public void onConfigured(@NonNull CameraCaptureSession session) {
captureSession = session;
previewRequestBuilder.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE);
previewRequestBuilder.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH);
// 无限循环预览
try {
session.setRepeatingRequest(previewRequestBuilder.build(), null, null);
// 开启推理线程,每隔100ms取一帧做推理
startInferenceThread();
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
public void onConfigureFailed(@NonNull CameraCaptureSession session) {}
}, null);
} catch (Exception e) {
e.printStackTrace();
}
}
// 释放相机资源
private void releaseCamera() {
if (captureSession != null) {
captureSession.stopRepeating();
captureSession.abortCaptures();
captureSession.close();
captureSession = null;
}
if (cameraDevice != null) {
cameraDevice.close();
cameraDevice = null;
}
}
模块 4:实时推理 + 结果解析 + 渲染
这是最核心的部分,负责从 TextureView 拿到预览帧,预处理成模型需要的格式,喂给 RKNN NPU 推理,解析结果做 NMS 去重,然后把识别结果显示到屏幕上,完全适配售货柜的快照识别需求。
先定义推理相关的全局变量:
java
运行
// 推理线程相关
private Thread inferenceThread;
private boolean isInferencing = false;
// 识别结果控件
private TextView tvResult;
// 置信度阈值和NMS阈值,和训练时一致
private static final float CONF_THRESHOLD = 0.5f;
private static final float NMS_THRESHOLD = 0.45f;
然后写startInferenceThread函数,开启推理线程,循环取帧推理:
java
运行
private void startInferenceThread() {
tvResult = findViewById(R.id.tv_result);
isInferencing = true;
inferenceThread = new Thread(() -> {
while (isInferencing && isModelLoaded) {
try {
// 1. 从TextureView拿到预览帧Bitmap
Bitmap bitmap = textureView.getBitmap();
if (bitmap == null) {
Thread.sleep(100);
continue;
}
// 2. 图片预处理:缩放到640×640,转成RGB格式
Bitmap resizedBitmap = Bitmap.createScaledBitmap(bitmap, INPUT_WIDTH, INPUT_HEIGHT, true);
byte[] inputData = bitmapToInputData(resizedBitmap);
// 3. RKNN NPU推理
long startTime = System.currentTimeMillis();
float[][] outputs = rknnRuntime.runModel(inputData);
long inferenceTime = System.currentTimeMillis() - startTime;
// 4. 解析推理结果,做NMS去重
List<DetectionResult> results = parseOutputs(outputs, bitmap.getWidth(), bitmap.getHeight());
// 5. 把结果渲染到屏幕上
runOnUiThread(() -> {
// 绘制识别框和类别名
Canvas canvas = textureView.lockCanvas();
if (canvas != null) {
Paint paint = new Paint();
paint.setColor(Color.RED);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(3);
paint.setTextSize(40);
for (DetectionResult result : results) {
// 画识别框
paint.setColor(Color.RED);
paint.setStyle(Paint.Style.STROKE);
canvas.drawRect(result.rect, paint);
// 画类别名和置信度
paint.setStyle(Paint.Style.FILL);
paint.setColor(Color.WHITE);
String text = classNames.get(result.classId) + " " + String.format("%.2f", result.confidence);
canvas.drawText(text, result.rect.left, result.rect.top - 10, paint);
}
textureView.unlockCanvasAndPost(canvas);
}
// 更新结果文本
StringBuilder resultText = new StringBuilder();
resultText.append("推理耗时:").append(inferenceTime).append("ms | 识别到商品:");
for (DetectionResult result : results) {
resultText.append(classNames.get(result.classId)).append(" ");
}
tvResult.setText(resultText.toString());
});
// 释放Bitmap资源,避免内存泄漏
bitmap.recycle();
resizedBitmap.recycle();
// 控制推理帧率,10帧/秒足够售货柜场景用,避免占用太多NPU资源
Thread.sleep(100);
} catch (Exception e) {
e.printStackTrace();
}
}
});
inferenceThread.start();
}
// Bitmap转模型输入的byte数组,预处理
private byte[] bitmapToInputData(Bitmap bitmap) {
int width = bitmap.getWidth();
int height = bitmap.getHeight();
int[] pixels = new int[width * height];
bitmap.getPixels(pixels, 0, width, 0, 0, width, height);
byte[] data = new byte[width * height * 3];
int index = 0;
// 转RGB格式,RKNN输入要求是NHWC格式,RGB通道
for (int i = 0; i < height; i++) {
for (int j = 0; j < width; j++) {
int pixel = pixels[i * width + j];
data[index++] = (byte) ((pixel >> 16) & 0xFF); // R
data[index++] = (byte) ((pixel >> 8) & 0xFF); // G
data[index++] = (byte) (pixel & 0xFF); // B
}
}
return data;
}
// 解析模型输出,做NMS去重
private List<DetectionResult> parseOutputs(float[][] outputs, int imgWidth, int imgHeight) {
List<DetectionResult> results = new ArrayList<>();
float[] output = outputs[0];
int numBoxes = output.length / (5 + NUM_CLASSES);
// 缩放比例,把640×640的框映射回原始预览分辨率
float xScale = (float) imgWidth / INPUT_WIDTH;
float yScale = (float) imgHeight / INPUT_HEIGHT;
// 遍历所有预测框
for (int i = 0; i < numBoxes; i++) {
int offset = i * (5 + NUM_CLASSES);
float confidence = output[offset + 4];
// 过滤置信度低的框
if (confidence < CONF_THRESHOLD) {
continue;
}
// 找到置信度最高的类别
int classId = 0;
float maxClassConf = 0;
for (int j = 0; j < NUM_CLASSES; j++) {
float classConf = output[offset + 5 + j];
if (classConf > maxClassConf) {
maxClassConf = classConf;
classId = j;
}
}
// 最终置信度 = 目标置信度 × 类别置信度
float finalConf = confidence * maxClassConf;
if (finalConf < CONF_THRESHOLD) {
continue;
}
// 解析框的坐标,cx, cy, w, h → left, top, right, bottom
float cx = output[offset] * xScale;
float cy = output[offset + 1] * yScale;
float w = output[offset + 2] * xScale;
float h = output[offset + 3] * yScale;
RectF rect = new RectF(
cx - w / 2,
cy - h / 2,
cx + w / 2,
cy + h / 2
);
results.add(new DetectionResult(classId, finalConf, rect));
}
// 执行NMS非极大值抑制,去重
return nms(results);
}
// NMS非极大值抑制
private List<DetectionResult> nms(List<DetectionResult> results) {
List<DetectionResult> finalResults = new ArrayList<>();
// 按置信度从高到低排序
results.sort((a, b) -> Float.compare(b.confidence, a.confidence));
boolean[] suppressed = new boolean[results.size()];
for (int i = 0; i < results.size(); i++) {
if (suppressed[i]) continue;
DetectionResult result = results.get(i);
finalResults.add(result);
// 和剩下的框做IOU比对
for (int j = i + 1; j < results.size(); j++) {
if (suppressed[j]) continue;
DetectionResult other = results.get(j);
// 同一个类别才做NMS
if (other.classId != result.classId) continue;
float iou = calculateIOU(result.rect, other.rect);
if (iou > NMS_THRESHOLD) {
suppressed[j] = true;
}
}
}
return finalResults;
}
// 计算两个框的IOU
private float calculateIOU(RectF a, RectF b) {
float intersectionLeft = Math.max(a.left, b.left);
float intersectionTop = Math.max(a.top, b.top);
float intersectionRight = Math.min(a.right, b.right);
float intersectionBottom = Math.min(a.bottom, b.bottom);
float intersectionArea = Math.max(0, intersectionRight - intersectionLeft) * Math.max(0, intersectionBottom - intersectionTop);
float unionArea = (a.right - a.left) * (a.bottom - a.top) + (b.right - b.left) * (b.bottom - b.top) - intersectionArea;
return unionArea == 0 ? 0 : intersectionArea / unionArea;
}
// 识别结果实体类
private static class DetectionResult {
int classId;
float confidence;
RectF rect;
public DetectionResult(int classId, float confidence, RectF rect) {
this.classId = classId;
this.confidence = confidence;
this.rect = rect;
}
}
最后,别忘了在 Activity 销毁的时候,释放所有资源,避免内存泄漏、NPU 资源占用:
java
运行
@Override
protected void onDestroy() {
super.onDestroy();
// 停止推理线程
isInferencing = false;
if (inferenceThread != null) {
inferenceThread.interrupt();
inferenceThread = null;
}
// 释放相机资源
releaseCamera();
// 释放RKNN资源
if (rknnRuntime != null) {
rknnRuntime.release();
rknnRuntime = null;
}
}
五、编译运行,部署到 RK3576 板子上
- 把 RK3576 开发板 / 售货柜主控用 USB 线连接到电脑,打开板子的开发者选项和 USB 调试
- 在 Android Studio 里,选择连接的设备,点击运行按钮,APP 就会自动安装到板子上
- 打开 APP,授予权限,就能看到相机实时预览,同时屏幕上会实时渲染商品识别的框、类别名、置信度,还有推理耗时
正常情况下,RK3576 的 NPU 推理耗时在 20~30ms,完全能做到实时识别,精度和电脑上的 ONNX 模型基本一致,完美满足无人售货柜的需求。
新手必踩的坑 & 解决方案
- 模型加载失败:检查 RKNN Runtime 版本和转模型的版本是否一致,检查模型文件是否放到了 assets 文件夹,检查 so 库是否正确导入
- 推理结果全错:检查预处理是否正确,RKNN 的输入是 NHWC 格式、RGB 通道,归一化是否和训练时一致,检查类别数是否正确
- 推理速度慢:检查是否真的用了 NPU 推理,有没有用 CPU 跑,检查模型是不是 INT8 量化的,有没有开启优化级别
- APP 闪退:检查权限是否申请,检查有没有在主线程做耗时操作(模型加载和推理必须放到子线程)
- 识别框位置不对:检查坐标缩放比例是否正确,有没有把 640×640 的框正确映射回原始预览分辨率
最后说两句
到这里,恭喜你!你已经完成了从 YOLO 模型训练→RKNN 转换→RK3576 安卓端实时推理的完整闭环,你的无人售货柜已经有了核心的商品识别能力。
下一卷,我们就把这个识别能力,和无人售货柜的业务逻辑结合起来,实现开门前基线快照→关门后商品比对→SKU 数量统计→自动结算的完整业务闭环,让你的识别能力真正变成能商用的售货柜系统。