首页
/ 在 Android 上运行深度学习模型:OpenCV DNN 模块加载 MobileNet-SSD ONNX 的完整实战指南

在 Android 上运行深度学习模型:OpenCV DNN 模块加载 MobileNet-SSD ONNX 的完整实战指南

2026-09-07 09:35:43作者:裘旻烁

本教程围绕当前 OpenCV 仓库中面向 Android 的 DNN 应用示例展开:使用 OpenCV 的深度神经网络模块(dnn),在 Android 设备上通过摄像头实时运行一个 MobileNet-SSD 目标检测模型,并把模型以 res/raw 资源形式从内存缓冲区加载。读完本文,你将掌握在 Android Studio 工程中接入 OpenCV、以 Java API 从内存初始化 ONNX 网络、用 Dnn.blobFromImage 完成图像预处理并解析检测输出、最终在相机画面上实时绘制检测框的完整实现方案。

适用范围与硬性前提

本教程对应仓库中的官方教程文档 android_dnn_intro.markdown,其配套的可运行样例位于 samples/android/mobilenet-objdetect。开始之前需满足以下三个前提:

  1. 安装 Android Studio(本教程写作时针对 2022.2.1 版本)。可从 Android Studio 官方站点下载安装。
  2. 获取 OpenCV for Android 预编译 SDK:从 OpenCV 官方 GitHub Releases 页面下载最新的预编译 Android 发布包并解压,例如 opencv-4.X.Y-android-sdk.zip要求最低 OpenCV 4.9(对应本文档标注的 Compatibility 为 OpenCV >= 4.9)。
  3. 下载 MobileNet-SSD 目标检测模型(ONNX 格式),保存为 mobilenet.onnx。这个单文件同时包含网络拓扑结构与训练好的权重,便于后续直接交给 Dnn.readNetFromONNX 加载。

说明:前文所述的“工程初始化与 OpenCV 依赖添加”,请先阅读同目录教程 dev_with_OCV_on_Android.markdown 完成空工程的搭建与 OpenCV 模块导入,本文只聚焦 DNN 集成部分。

创建空工程并添加 OpenCV 依赖

首先参考 dev_with_OCV_on_Android.markdown 完成以下动作:创建空 Activity 工程、Import Module 方式导入 OpenCV SDK、将 openCVLibrary 添加为 app 的模块依赖。仓库中的参考样例即以此为模板,并额外声明了针对目标检测工程的组织方式(源码、资源、Manifest 分离):

目标:应用行为设计

在动手写代码前先明确应用行为:本示例从摄像头持续取帧,把每一帧送入深度网络做前向推理,随后收到一组检测结果——每个结果包含矩形框、类别编号以及取值在 [0, 1] 的置信度;置信度高于阈值的目标会在画面上以矩形框和类别文本形式渲染出来。

步骤一:编写界面布局 activity_main.xml

app/src/main/res/layout/activity_main.xml 修改为如下内容(完整布局见 activity_main.xml),核心是添加一个铺满全屏的 org.opencv.android.JavaCameraView 相机预览控件:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="org.opencv.samples.opencv_mobilenet.MainActivity">

    <org.opencv.android.JavaCameraView
        android:id="@+id/CameraView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:visibility="visible" />
</FrameLayout>

JavaCameraView 是 OpenCV Android SDK 提供的相机桥接组件,它既负责显示实时预览,也把每一帧回调给实现了 CvCameraViewListener2 接口的宿主 Activity(即后面代码里的 onCameraFrame)。它是整个“取帧 → 推理 → 回显”流水线的起点。

步骤二:修改 AndroidManifest.xml 启用相机与全屏横屏

app/src/main/AndroidManifest.xml 需要完成三件事:允许应用使用摄像头、声明相机相关硬件特性为可选(required="false",保证无相机设备也能安装)、把主 Activity 固定为横屏。仓库样例 AndroidManifest.xml 中的关键片段如下:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="org.opencv.samples.opencv_mobilenet">

    <application
        android:label="@string/app_name"
        android:icon="@drawable/icon">
        <activity
                  android:exported="true"
                  android:name=".MainActivity"
                  android:screenOrientation="landscape">  <!--固定横屏-->
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

    <!--允许使用摄像头-->
    <uses-permission android:name="android.permission.CAMERA"/>
    <uses-feature android:name="android.hardware.camera" android:required="false"/>
    <uses-feature android:name="android.hardware.camera.autofocus" android:required="false"/>
    <uses-feature android:name="android.hardware.camera.front" android:required="false"/>
    <uses-feature android:name="android.hardware.camera.front.autofocus" android:required="false"/>

</manifest>

步骤三:把模型放入资源并理解“为何从内存加载”

把下载好的 mobilenet.onnx 放入 app/src/main/res/raw 目录。这里有一个值得展开的技术背景:

OpenCV 的 DNN 模型加载接口(如 readNetFromONNX(path)主要是为“从文件路径”加载模型设计的。但现代 Android 不允许应用在没有额外存储权限的情况下随意访问文件系统;同时 Android 原生提供了读取应用资源(res 目录)字节流的 Java API。因此样例采用了一个替代性的 DNN API:从内存缓冲区(in-memory buffer)初始化模型,而不是从文件。

模型文件会被先读取为 MatOfByte 对象——它是 std::vector<char> 在 OpenCV Java 世界的对应物,这样就能借助 OpenCV Java API 把字节流转交给 C++ 侧的模型解析器。仓库样例 MainActivity.java 中的读取函数如下:

private MatOfByte loadFileFromResource(int id) {
   byte[] buffer;
    try {
        // load cascade file from application resources
        InputStream is = getResources().openRawResource(id);

        int size = is.available();
        buffer = new byte[size];
        int bytesRead = is.read(buffer);
        is.close();
    } catch (IOException e) {
        e.printStackTrace();
        Log.e(TAG, "Failed to ONNX model from resources! Exception thrown: " + e);
        (Toast.makeText(this, "Failed to ONNX model from resources!", Toast.LENGTH_LONG)).show();
        return null;
    }

    return new MatOfByte(buffer);
}

若想了解 Android 资源提供机制更完整的说明,可查阅 Android 官方《Providing Resources》文档。

步骤四:编写 MainActivity:初始化 OpenCV 与网络

app/src/main/java/.../MainActivity.java 的内容按“包名 + 主逻辑”两部分给出。若工程包名不同,请自行替换开头的 package 声明:

package com.example.myapplication;   // 根据实际工程替换

OpenCV 运行时初始化

onCreate 中首先调用 OpenCVLoader.initLocal() 初始化 OpenCV 本地库(加载各 .so)。失败时打印日志并用 Toast 提示后直接返回:

if (OpenCVLoader.initLocal()) {
    Log.i(TAG, "OpenCV loaded successfully");
} else {
    Log.e(TAG, "OpenCV initialization failed!");
    (Toast.makeText(this, "OpenCV initialization failed!", Toast.LENGTH_LONG)).show();
    return;
}

从内存加载 ONNX 并初始化网络

对应 MainActivity.java 中的 init_model_from_memory 片段:

// 从 res/raw/mobilenet.onnx 加载单文件 MobileNet-SSD ONNX 模型。
// 资源在运行期按名称解析,因此即便模型缺失,示例也能正常编译构建;
// 只是目标检测功能被禁用而已。
int modelResId = getResources().getIdentifier("mobilenet", "raw", getPackageName());
if (modelResId != 0)
    mModelBuffer = loadFileFromResource(modelResId);

if (mModelBuffer == null) {
    Log.e(TAG, "MobileNet ONNX model (res/raw/mobilenet.onnx) not found - object detection disabled");
} else {
    net = Dnn.readNetFromONNX(mModelBuffer);   // 从内存 MatOfByte 而非文件路径读网络
    Log.i(TAG, "Network loaded successfully");
}

要点说明:

  • getResources().getIdentifier("mobilenet", "raw", getPackageName()) 在运行期按名称解析资源 ID,对应文件 res/raw/mobilenet.onnx(扩展名不进入资源名)。这种写法使得模型文件缺失时应用仍可安装运行,检测功能自动降级为“禁用”,便于调试与 CI。
  • Dnn.readNetFromONNX(MatOfByte) 正是“从内存缓冲区加载 ONNX”的替代 API:只要模型字节完整,OpenCV 会解析 ONNX 图结构并重建内部网络。
  • 建议在 onDestroy 中调用 mModelBuffer.release() 释放字节缓冲(样例代码即如此处理)。

步骤五:理解帧预处理与推理输出格式

网络加载完毕后,核心工作落在 onCameraFrame。逐段拆解其背后的原理。

1) 网络输入预处理:Dnn.blobFromImage

MobileNet-SSD 的输入张量为 300×300 的三通道图像。相机帧需经过“转色 → 缩放 → 减均值 → 乘缩放系数”的预处理才能成为网络输入。样例中这段核心逻辑如下:

Mat frame = inputFrame.rgba();
Imgproc.cvtColor(frame, frame, Imgproc.COLOR_RGBA2RGB);   // JavaCameraView 默认输出 RGBA

Mat blob = Dnn.blobFromImage(frame, IN_SCALE_FACTOR,
        new Size(IN_WIDTH, IN_HEIGHT),
        new Scalar(MEAN_VAL, MEAN_VAL, MEAN_VAL), /*swapRB*/false, /*crop*/false);
net.setInput(blob);
Mat detections = net.forward();

其中关键常量为:

final int IN_WIDTH = 300;
final int IN_HEIGHT = 300;
final double IN_SCALE_FACTOR = 0.007843;   // 约等于 1/127.5
final double MEAN_VAL = 127.5;
final double THRESHOLD = 0.2;              // 置信度阈值

对照 OpenCV 头文件 dnn.hppblobFromImage 的官方文档,可确认如下实现语义:

  • 张量归一化公式为 (input − mean) × scalefactor。对本例:像素先减 127.5(得到约 −127.5..127.5),再乘 0.007843(≈ 1/127.5),最终像素值大致落在 [−1, 1]——这正是 MobileNet-SSD 训练时要求的输入分布。
  • 返回的是 NCHW 布局的 4 维 MatN(batch)=1、C=3、H=W=300。
  • crop=false 表示不做中心裁剪,直接缩放并保持宽高比填充到目标尺寸;crop=true 时则先等比缩放再按中心裁剪。
  • swapRB=false:由于前面已经用 COLOR_RGBA2RGB 把相机帧转成 RGB 顺序,这里无需再做 R/B 通道交换。
  • ddepth 默认 CV_32F,即输出 32 位浮点 blob。
  • net.setInput(blob) 把预处理结果设为网络输入,随后 net.forward() 触发前向推理并返回输出张量。

2) 解析检测输出张量

MobileNet-SSD 的输出是一个四维张量,通常可解释为 1×1×N×7,其中 N 为候选框数量。样例先把它重排为 N×7 的矩阵:

detections = detections.reshape(1, (int)detections.total() / 7);

接着逐行解析(以仓库 MainActivity.java 的实现为准):每行对应一个候选目标,行内第 0 列在本示例中未参与绘图,第 1 列是类别编号(class id),第 2 列是置信度(confidence,范围 [0, 1]),第 3~6 列依次是 left、top、right、bottom 四个框坐标。所有坐标均为 [0, 1] 的归一化值,渲染到图像前必须分别乘以图像宽/高换算成像素:

double confidence = detections.get(i, 2)[0];
if (confidence > THRESHOLD) {
    int classId = (int)detections.get(i, 1)[0];

    int left   = (int)(detections.get(i, 3)[0] * cols);
    int top    = (int)(detections.get(i, 4)[0] * rows);
    int right  = (int)(detections.get(i, 5)[0] * cols);
    int bottom = (int)(detections.get(i, 6)[0] * rows);
    ...
}

cols/rows 取自身 frame.cols()/frame.rows(),即把归一化坐标还原到实际画面分辨率。

3) 类别名映射与结果绘制

MobileNet-SSD 在 PASCAL VOC 数据集上训练,共识别 20 类物体(外加下标 0 的 background)。样例用 classNames 字符串数组把 classId 翻译成可读名称并叠加绘制(见 MainActivity.java):

private static final String[] classNames = {"background",
        "aeroplane", "bicycle", "bird", "boat",
        "bottle", "bus", "car", "cat", "chair",
        "cow", "diningtable", "dog", "horse",
        "motorbike", "person", "pottedplant",
        "sheep", "sofa", "train", "tvmonitor"};

绘制部分使用 Imgproc.rectangle 画绿色检测框,再为文本标签画一个白色背景矩形、用 Imgproc.putText 写上 类别名: 置信度

Imgproc.rectangle(frame, new Point(left, top), new Point(right, bottom),
                  new Scalar(0, 255, 0));
String label = classNames[classId] + ": " + confidence;
int[] baseLine = new int[1];
Size labelSize = Imgproc.getTextSize(label, Imgproc.FONT_HERSHEY_SIMPLEX, 0.5, 1, baseLine);

// 标签背景
Imgproc.rectangle(frame, new Point(left, top - labelSize.height),
                  new Point(left + labelSize.width, top + baseLine[0]),
                  new Scalar(255, 255, 255), Imgproc.FILLED);
// 标签文字
Imgproc.putText(frame, label, new Point(left, top),
        Imgproc.FONT_HERSHEY_SIMPLEX, 0.5, new Scalar(0, 0, 0));

4) 相机生命周期回调

MainActivity 继承自 CameraActivity 并实现 CvCameraViewListener2,因此还需要补齐以下回调:onResume/onPause 中调用 enableView()/disableView() 开关相机预览;getCameraViewList() 返回相机控件列表(Collections.singletonList(mOpenCvCameraView));onCameraViewStarted/Stopped 留空或做资源清理。onCameraFrame 中若网络未加载成功(net == null),直接返回原始 rgba() 帧,保证应用不会崩溃。

步骤六:运行与效果验证

将手机通过 USB 连接开发机(开启 USB 调试),在 Android Studio 中选择设备并点击 Run,把应用安装到手机后启动。横屏状态下应用会打开后置摄像头;当画面中出现模型支持的 20 类物体(人、车、猫、瓶子等)时,即可看到实时绘制的检测框与类别/置信度标签,运行效果如下:

Android 设备上基于 OpenCV DNN 的 MobileNet-SSD 实时目标检测运行效果

源码级延伸:OpenCV DNN 在移动端的实现入口

若想深入理解 OpenCV 在 Android 端 DNN 能力的内核,可以从以下两个层面继续探索本仓库:

  • Java API 层:本教程使用的 org.opencv.dnn.Netorg.opencv.dnn.Dnnorg.opencv.core.MatOfByte 等类型位于 OpenCV Java 绑定体系(modules/java),底层通过 JNI 转调 C++ 实现。
  • C++ 核心层Dnn.readNetFromONNX 的完整实现、blobFromImage 的 NCHW 组装与归一化逻辑、以及各类后处理工具都位于 modules/dnn/include/opencv2/dnn/dnn.hpp(含 NetDnn 的全部公开接口与详尽参数文档)对应的 C++ 实现目录(modules/dnn)中。blobFromImage 的参数语义((input − mean) × scalefactor、NCHW 布局、crop 行为、默认 ddepth=CV_32F)均以 dnn.hpp 的注释为准。

常见问题与调优建议

  • 模型放错位置导致“检测禁用”:确认 mobilenet.onnx 位于 app/src/main/res/raw/,且名字能被 getIdentifier("mobilenet", "raw", ...) 解析到;缺失时 logcat 中会出现 Object detection disabled 日志。
  • 误检/漏检多:调整 THRESHOLD(默认 0.2)。调高会减少误报但可能漏检小目标;调低则相反。该阈值过滤位于检测输出解析循环的入口条件处。
  • 想要识别更多类别:替换为其他经过 PASCAL VOC/COCO 训练的检测模型时,需同步替换 classNames 数组、确认输入尺寸与预处理参数(blobFromImage 的 scale/mean/swapRB)是否与模型要求一致,这一步决定了检测精度是否可靠。
  • 换用文件加载接口:若你的应用确实申请到了存储权限且模型放在可访问路径,也可改用按文件路径加载的 readNetFromONNX(String) 变体;但把模型打进 res/raw 并从内存初始化,是当前 Android 权限模型下最稳妥、免权限的做法。
登录后查看全文
热门项目推荐
相关项目推荐