简介:本资源是一套面向高校学生与开发者的目标检测移动端落地实践方案,聚焦YOLO11模型在Android平台的端侧部署,解决从模型训练、转换、集成到APP实时推理的全流程技术难点,适用于毕设、课设、竞赛及实训项目。压缩包含2000个文件,总大小338.97MB,涵盖Android Studio工程(Java/Kotlin+NDK调用)、PyCharm训练工程(Python+PyTorch)、C/C++底层推理代码(.c/.cpp/.h/.hpp)、配置文件(.json/.yaml/.xml)、数据标注(.txt)及说明文档(.md/.properties),结构清晰、模块解耦,便于按需复用与二次开发。已有1029人学习下载,配套详细设计文档与实操演示视频(含50轮训练效果展示),手把手指导环境搭建、JNI接口封装、TensorFlow Lite模型转换及APP界面交互实现,所有步骤经实战验证无误,支持快速替换为车牌、人脸、手势等其他目标检测任务。
1. 项目概述:从模型到指尖的跨越
最近在社区里看到不少同学在讨论如何把训练好的YOLO模型塞进安卓手机里,做成一个能实时跑起来的APP。这确实是个挺有意思的课题,无论是做毕设、参加竞赛,还是搞个小项目练手,都很有价值。毕竟,能让算法在移动端实时运行,才算真正把AI“落地”了。我自己也折腾过好几次,从早期的YOLOv5到现在的YOLOv11,踩过不少坑,也积累了一些还算靠谱的经验。
这次我们就以“火焰烟雾检测”这个具体的场景为例,手把手走一遍完整的流程。为什么选这个例子?一来,它在安防、森林防火等场景下有明确的应用价值,不是个“玩具”demo;二来,它的实现过程具有通用性,你完全可以把模型换成检测猫狗、车牌、或者某种特定工业零件的,整个流程的骨架是一样的。我们的目标很明确:把一个在电脑上训练好的YOLOv11模型,经过必要的处理和优化,最终部署到一个安卓APP中,实现通过手机摄像头进行实时目标检测。
整个过程会涉及到几个关键环节:模型训练与导出、模型格式转换与优化、安卓开发环境搭建、以及最终的APP集成与调试。听起来步骤不少,但别担心,我会把每个环节的“为什么”和“怎么做”都讲清楚,特别是那些官方文档里可能不会提,但实际做起来又绕不开的细节。
2. 核心思路与方案选型
在开始动手之前,我们得先想清楚要走哪条路。把YOLO部署到安卓上,主流方案有好几种,每种都有它的适用场景和优缺点。
2.1 部署路径的权衡
最常见的有这么几条路:
- 使用NCNN、MNN等移动端推理框架:这是目前社区里最流行、资源也最丰富的方案。这些框架由国内大厂开源,对安卓平台和ARM芯片做了深度优化,性能通常很不错。你需要把PyTorch训练好的模型,先转换成ONNX格式,然后再用这些框架的工具转换成其私有格式(如NCNN的
.param和.bin文件)。优点是成熟、稳定、社区活跃;缺点是多了一层转换,有时候会遇到算子不支持或者精度损失的问题。 - 使用PyTorch Mobile或TensorFlow Lite (TFLite):这是“官方”或“半官方”的路径。PyTorch提供了直接转换到TorchScript然后用于移动端的方案;而如果你用TensorFlow训练YOLO(例如使用TF官方的实现),则可以很方便地转换成TFLite格式。这条路的优点是生态整合好,如果是简单的模型,转换比较顺畅。但缺点是对YOLO这种结构比较复杂的模型,PyTorch Mobile的优化可能不如专用框架,而TFLite路径则需要你一开始就在TensorFlow生态下。
- 使用OpenCV的DNN模块:OpenCV从4.x版本开始强化了DNN模块对ONNX模型的支持。你可以直接将YOLO模型导出为ONNX,然后在安卓上使用OpenCV库来加载和推理。这条路的优点是依赖相对单一(主要就是OpenCV),且ONNX作为中间格式通用性强。缺点是OpenCV DNN在安卓端的性能优化可能不如NCNN等专用框架,对于需要高帧率的实时检测可能有点吃力。
我的选择与理由:经过多次实践,我倾向于选择方案一:NCNN。原因很简单:它在安卓端的推理速度经过充分验证,社区庞大,遇到问题容易找到解决方案。特别是对于YOLO系列模型,NCNN的支持一直很及时,相关的转换工具和示例代码也最全。我们本次的实践就将以NCNN为核心来展开。这并不意味着其他方案不好,而是这条路径对于大多数想要快速实现、追求性能的开发者来说,踩坑的概率相对较小。
2.2 技术栈确认
基于以上选择,我们最终的技术栈如下:
- 训练框架:PyTorch (Ultralytics YOLOv11)。这是目前最活跃的YOLO维护版本,API友好,生态完善。
- 中间格式:ONNX。作为PyTorch到NCNN之间的桥梁,它是一种开放的模型表示格式。
- 移动端推理引擎:NCNN。负责在安卓设备上高效执行模型计算。
- 安卓开发:Android Studio (Java/Kotlin) + NCNN Android SDK。我们主要用Java进行演示,原理与Kotlin相通。
- 目标检测任务:火焰与烟雾检测。我们将准备一个小的自定义数据集进行训练,以演示完整流程。
这个组合拳打下来,基本上能覆盖从研发到部署的全链路,并且每个环节都有比较可靠的工具支持。
3. 模型训练与导出:打造专属检测器
部署的起点是一个训练好的、性能良好的模型。我们不可能直接用官方的预训练权重,因为它不认识“火焰”和“烟雾”。所以,第一步是进行自定义训练。
3.1 数据准备与标注
数据是模型的基石。对于火焰烟雾检测,你可以从公开数据集(如FireSmoke-Dataset)中获取一部分,也可以自己从网络上收集相关图片和视频,并通过抽帧的方式获取图像。这里的关键是质量而非单纯的数量,确保图像中火焰和烟雾的特征清晰、多样(不同场景、大小、亮度)。
标注工具推荐使用labelImg或Roboflow。标注时,将火焰和烟雾视为两个独立的类别(例如fire和smoke)。标注文件采用YOLO格式,即每个图像对应一个.txt文件,每行内容为:<class_id> <x_center> <y_center> <width> <height>,坐标和宽高都是相对于图像尺寸的归一化值。
数据准备好后,按照如下结构组织:
datasets/ └── fire_smoke/ ├── train/ │ ├── images/ (存放训练图片) │ └── labels/ (存放对应的txt标注文件) └── val/ ├── images/ └── labels/3.2 YOLOv11模型训练
我们使用Ultralytics框架进行训练,这是目前最便捷的方式。
# 安装ultralytics pip install ultralytics # 准备数据集配置文件 fire_smoke.yaml # 内容如下: # path: /path/to/your/datasets/fire_smoke # 数据集根目录 # train: train/images # val: val/images # nc: 2 # 类别数,这里是火焰和烟雾 # names: ['fire', 'smoke'] # 类别名称然后,在Python中或通过命令行启动训练:
from ultralytics import YOLO # 加载一个预训练模型作为起点,这里以YOLOv11n为例 model = YOLO('yolo11n.pt') # 开始训练 results = model.train( data='fire_smoke.yaml', epochs=100, imgsz=640, batch=16, device='0', # 使用GPU,如果是CPU则设为'cpu' workers=4, project='fire_smoke_detection', name='exp1' )训练过程会输出损失曲线、精度指标等。你需要关注mAP50-95这个值,它综合反映了模型在不同IoU阈值下的平均精度。对于火焰烟雾这种任务,由于目标形态多变,能达到0.5以上的mAP通常就算不错了。训练完成后,最好的模型权重会保存在runs/detect/exp1/weights/best.pt。
实操心得:训练参数调优
imgsz(图像尺寸):部署到手机,考虑到性能,通常不会使用很大的分辨率。训练时就用640是合理的,兼顾速度和精度。batch:根据你的GPU显存调整。如果出现CUDA out of memory错误,就减小batch size或imgsz。- 数据增强:YOLO默认会启用一些数据增强(如 mosaic, mixup)。对于小数据集,这是防止过拟合的关键。除非你有特殊理由,否则不要轻易关闭。
- 早停(Early Stopping):Ultralytics内置了早停机制。如果验证集指标长时间不提升,训练会自动停止,避免过拟合。
3.3 模型导出为ONNX
训练得到.pt文件后,我们需要将其转换为ONNX格式,这是通向NCNN的必经之路。
from ultralytics import YOLO # 加载训练好的模型 model = YOLO('runs/detect/exp1/weights/best.pt') # 导出模型,指定格式为ONNX success = model.export(format='onnx', imgsz=640, simplify=True, opset=12)关键参数解释:
format='onnx':指定导出格式。imgsz=640:指定模型输入的固定尺寸。这对于移动端部署至关重要,固定的输入尺寸可以简化预处理和优化。simplify=True:启用ONNX简化器,会优化计算图结构,移除不必要的操作,有时能提升推理速度并减少转换出错概率。强烈建议开启。opset=12:指定ONNX算子集版本。版本不宜过低(可能缺少新算子),也不宜过高(NCNN等后端可能还未完全支持)。opset 12是一个比较通用和稳定的选择。
导出成功后,你会得到一个best.onnx文件。务必使用Netron(一个可视化工具)打开这个.onnx文件检查一下。主要看两点:1. 输入节点的名字和形状(通常是images: [1, 3, 640, 640]);2. 输出节点的名字和形状。记下这些名字,后面在NCNN和安卓端写代码时会用到。
4. 模型转换与优化:为移动端瘦身
拿到ONNX模型后,还不能直接给NCNN用,需要转换成NCNN专用的格式。同时,我们还要考虑模型优化,以便在手机有限的算力下跑得更快。
4.1 搭建NCNN转换环境
NCNN提供了完整的工具链。首先,我们需要编译生成模型转换工具onnx2ncnn。
# 1. 克隆ncnn仓库 git clone https://github.com/tencent/ncnn.git cd ncnn # 2. 创建构建目录并编译 (以Ubuntu为例,其他系统请参考官方文档) mkdir -p build && cd build cmake -DCMAKE_BUILD_TYPE=Release -DNCNN_VULKAN=OFF -DNCNN_SYSTEM_GLSLANG=OFF -DNCNN_BUILD_EXAMPLES=ON .. make -j$(nproc) # 编译完成后,在 build/tools/onnx 目录下会生成 onnx2ncnn 可执行文件 # 为了方便,可以将其复制到系统路径或项目目录 cp tools/onnx/onnx2ncnn /usr/local/bin/ # 或你的自定义路径4.2 执行模型转换
转换命令非常简单:
onnx2ncnn best.onnx best.param best.bin这将会生成两个文件:
best.param: 模型结构文件,文本格式,描述了网络层和连接关系。best.bin: 模型权重文件,二进制格式,包含了所有可训练参数。
4.3 模型优化与调试
直接转换得到的模型可能不是最优的,甚至可能包含一些NCNN不支持的算子。因此,优化和调试是必不可少的步骤。
优化1:模型剪枝与量化(可选但推荐)对于追求极致性能的场景,可以对模型进行INT8量化。量化能将模型权重和激活值从32位浮点数(FP32)转换为8位整数(INT8),从而大幅减少模型体积和内存占用,并利用芯片的整数计算单元加速。NCNN提供了
ncnnoptimize工具来进行量化。# 首先确保编译时开启了NCNN_INT8选项,并编译了ncnnoptimize工具 # 在之前的cmake命令中加入 -DNCNN_INT8=ON # 重新编译后,在 build/tools/ 下会有 ncnnoptimize # 执行量化 (需要准备一个校准数据集,这里用训练集图片举例) ncnnoptimize best.param best.bin best_int8.param best_int8.bin 256 [校准数据集路径列表文件]量化需要一小部分代表性数据(校准集)来统计激活值的分布。量化后的模型速度可能有显著提升,但精度会有轻微损失,需要评估是否可接受。对于火焰烟雾检测,如果精度下降不明显,强烈建议量化。
优化2:融合与重参数化NCNN的转换工具会自动尝试进行一些算子融合优化(如Conv-BN-ReLU融合)。你可以通过查看转换时的输出信息,或使用
ncnnoptimize工具(不量化,只做优化)来进一步优化图结构。ncnnoptimize best.param best.bin best_opt.param best_opt.bin 0参数
0表示不进行量化,只做图优化。调试:处理不支持的算子如果转换失败或转换后推理出错,很可能是遇到了不支持的ONNX算子。此时需要:
- 用Netron仔细查看ONNX模型中出错的算子类型。
- 查阅NCNN的 算子支持列表 。
- 如果确实不支持,可以考虑修改模型结构(例如,用一组支持的算子来等效实现),或者寻找是否有其他转换路径(例如,先转到其他框架再转NCNN)。对于YOLOv11,其主流算子通常都已得到良好支持。
注意事项:模型输入输出名转换后,务必打开
best.param文件,查看开头的几行。你会看到类似Input input_name 0 1 images这样的内容,这里的images就是NCNN模型中的输入节点名。同样,在文件末尾附近找到输出节点名。这些名字可能与ONNX中的名字不同!在后续编写安卓代码时,必须使用best.param里定义的输入输出名。
5. 安卓开发环境与NCNN集成
模型准备好了,接下来就是打造承载它的安卓APP。我们从一个简单的摄像头预览应用开始,逐步集成检测功能。
5.1 创建安卓项目与基础配置
- 打开Android Studio,新建一个
Empty Views Activity项目,语言选择Java。 - 在
app/build.gradle文件中,添加NCNN库的依赖。最方便的方式是使用Maven Central的预编译包。
android { ... // 确保设置了这些配置,以支持NCNN的C++代码 defaultConfig { ... externalNativeBuild { cmake { cppFlags "-std=c++11" // 根据你的手机架构添加,通常armeabi-v7a和arm64-v8a是必须的 abiFilters 'armeabi-v7a', 'arm64-v8a' } } ndk { abiFilters 'armeabi-v7a', 'arm64-v8a' } } ... } dependencies { ... // 添加NCNN依赖 implementation 'com.tencent.ncnn:ncnn-android:2024.11.10' // 请使用最新版本 }- 同步Gradle项目。
5.2 准备模型文件与C++推理代码
- 放置模型文件:在
app/src/main/目录下,新建一个名为assets的文件夹(如果没有的话)。将我们转换优化好的best.param和best.bin文件复制进去。Android Studio会自动将这个目录下的文件打包进APK。 - 编写C++推理代码:
- 在
app/src/main/cpp目录下(如果没有则创建),创建yolov11.cpp和yolov11.h。 - 在
app/src/main/cpp下创建CMakeLists.txt文件,用于编译C++代码。
- 在
yolov11.h头文件定义接口:
#ifndef YOLOV11_H #define YOLOV11_H #include <android/bitmap.h> #include <jni.h> #include <opencv2/core/core.hpp> #include <opencv2/imgproc/imgproc.hpp> #include "net.h" // NCNN的头文件 class YoloV11 { public: YoloV11(); ~YoloV11(); bool loadModel(AAssetManager* mgr); // 从assets加载模型 std::vector<Detection> detect(cv::Mat& rgb); // 执行检测 private: ncnn::Net net; int input_size = 640; // 与训练导出时保持一致 float score_threshold = 0.25f; float nms_threshold = 0.45f; // 内部预处理、后处理函数 cv::Mat preprocess(cv::Mat& rgb); std::vector<Detection> decode_outputs(ncnn::Mat& output); void nms(std::vector<Detection>& detections); }; // 定义一个检测结果结构体 struct Detection { cv::Rect bbox; float score; int class_id; }; #endif //YOLOV11_Hyolov11.cpp是实现文件,包含模型加载、图像预处理、推理和后处理(解码输出+NMS)的全部逻辑。由于代码较长,这里给出核心的detect函数框架和关键点:
bool YoloV11::loadModel(AAssetManager* mgr) { net.opt.use_vulkan_compute = false; // 根据是否使用Vulkan API选择 // 使用AAsset从apk的assets目录读取模型文件 int ret = net.load_param(mgr, "best.param"); ret = net.load_model(mgr, "best.bin"); return ret == 0; } std::vector<Detection> YoloV11::detect(cv::Mat& rgb) { std::vector<Detection> results; // 1. 预处理:Resize到640x640,归一化,BGR2RGB,减均值除标准差等 cv::Mat input_blob = preprocess(rgb); // 2. 创建NCNN的Mat,并填入数据 ncnn::Mat in = ncnn::Mat::from_pixels(input_blob.data, ncnn::Mat::PIXEL_RGB, input_size, input_size); // 归一化处理,假设你训练时用了 --norm 0.00392 (1/255) in.substract_mean_normalize(0, norm_vals); // 3. 推理 ncnn::Extractor ex = net.create_extractor(); ex.set_num_threads(4); // 设置线程数,根据CPU核心数调整 ex.input("images", in); // “images”是你在param文件中看到的输入节点名 ncnn::Mat out; ex.extract("output", out); // “output”是输出节点名,请根据param文件确认! // 4. 后处理:解码边界框、置信度、类别,并执行NMS results = decode_outputs(out); nms(results); // 5. 将检测框坐标映射回原始图像尺寸 for (auto& det : results) { det.bbox.x = det.bbox.x * rgb.cols / input_size; det.bbox.y = det.bbox.y * rgb.rows / input_size; det.bbox.width = det.bbox.width * rgb.cols / input_size; det.bbox.height = det.bbox.height * rgb.rows / input_size; } return results; }CMakeLists.txt文件用于组织编译:
cmake_minimum_required(VERSION 3.10.2) project("yolov11ncnn") add_library( yolov11ncnn SHARED yolov11.cpp) find_library( log-lib log ) # 引入NCNN和OpenCV的预编译库 # 假设你将编译好的ncnn android库和opencv android sdk放在了 libs 目录下 set(ncnn_DIR ${CMAKE_SOURCE_DIR}/../../../libs/ncnn-android/${ANDROID_ABI}/lib/cmake/ncnn) set(OpenCV_DIR ${CMAKE_SOURCE_DIR}/../../../libs/opencv-mobile-4.8.1-android/${ANDROID_ABI}/lib/cmake/opencv4) find_package(ncnn REQUIRED) find_package(OpenCV REQUIRED) target_link_libraries( yolov11ncnn ncnn ${OpenCV_LIBS} ${log-lib} )5.3 编写JNI接口与Java层调用
为了让Java代码能够调用C++的推理函数,我们需要使用JNI(Java Native Interface)来搭建桥梁。
- 在
app/src/main/cpp下创建native-lib.cpp:
#include <jni.h> #include <android/asset_manager_jni.h> #include "yolov11.h" extern "C" { JNIEXPORT jlong JNICALL Java_com_example_firesmokedetection_YoloV11NCNN_initModel(JNIEnv *env, jobject thiz, jobject assetManager) { AAssetManager* mgr = AAssetManager_fromJava(env, assetManager); YoloV11* detector = new YoloV11(); if (detector->loadModel(mgr)) { return (jlong) detector; } else { delete detector; return 0; } } JNIEXPORT jobjectArray JNICALL Java_com_example_firesmokedetection_YoloV11NCNN_detect(JNIEnv *env, jobject thiz, jlong netPtr, jint width, jint height, jbyteArray yuvData) { // 将Java层传来的YUV数据(来自摄像头)转换为OpenCV的Mat // 调用 detector->detect(mat) 进行检测 // 将C++的Detection结果转换为Java对象数组并返回 // 代码较长,主要是数据格式转换和JNI对象构造 } JNIEXPORT void JNICALL Java_com_example_firesmokedetection_YoloV11NCNN_deinitModel(JNIEnv *env, jobject thiz, jlong netPtr) { if (netPtr != 0) { YoloV11* detector = (YoloV11*) netPtr; delete detector; } } }- 在Java层创建对应的类
YoloV11NCNN.java:
package com.example.firesmokedetection; import android.content.res.AssetManager; import android.graphics.Bitmap; import android.graphics.RectF; public class YoloV11NCNN { static { System.loadLibrary("yolov11ncnn"); } private long mNativePtr = 0; public boolean initModel(AssetManager assetManager) { mNativePtr = initModel(assetManager); return mNativePtr != 0; } public DetectionResult[] detect(Bitmap bitmap) { if (mNativePtr == 0) return new DetectionResult[0]; return detect(mNativePtr, bitmap); } public void deinitModel() { if (mNativePtr != 0) { deinitModel(mNativePtr); mNativePtr = 0; } } // 定义检测结果的数据结构 public static class DetectionResult { public RectF bbox; public float score; public int label; // 0 for fire, 1 for smoke } // Native方法声明 private native long initModel(AssetManager assetManager); private native DetectionResult[] detect(long netPtr, Bitmap bitmap); private native void deinitModel(long netPtr); }6. APP UI构建与摄像头流处理
有了核心的检测引擎,我们需要一个界面来展示摄像头画面并绘制检测结果。
6.1 设计主界面与权限处理
activity_main.xml可以设计为一个简单的布局,中心是一个TextureView用于显示摄像头预览,上面叠加一个自定义的OverlayView用于绘制检测框。
<?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" /> <com.example.firesmokedetection.OverlayView android:id="@+id/overlayView" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@android:color/transparent" /> <Button android:id="@+id/btnSwitch" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentBottom="true" android:layout_centerHorizontal="true" android:text="Switch Camera" /> </RelativeLayout>在MainActivity.java中,需要处理动态权限申请(摄像头、存储权限),并初始化摄像头。
6.2 实现摄像头数据流捕获与转换
我们使用Camera2 API来获取摄像头数据流,因为它提供了更精细的控制。在onResume中打开摄像头,并设置一个ImageReader来接收预览帧。
private void openCamera() { CameraManager manager = (CameraManager) getSystemService(Context.CAMERA_SERVICE); try { String cameraId = manager.getCameraIdList()[0]; // 默认后置摄像头 manager.openCamera(cameraId, new CameraDevice.StateCallback() { @Override public void onOpened(@NonNull CameraDevice camera) { mCameraDevice = camera; createCameraPreviewSession(); } // ... 其他回调方法 }, null); } catch (CameraAccessException e) { e.printStackTrace(); } } private void createCameraPreviewSession() { // ... 创建CaptureRequest.Builder,设置TextureView作为预览输出目标 // 同时创建ImageReader,用于获取YUV_420_888格式的预览帧进行推理 mImageReader = ImageReader.newInstance(mPreviewSize.getWidth(), mPreviewSize.getHeight(), ImageFormat.YUV_420_888, 2); // 设置缓冲区数量 mImageReader.setOnImageAvailableListener(mOnImageAvailableListener, mBackgroundHandler); // 将ImageReader的surface也加入到预览会话的targets中 List<Surface> surfaces = new ArrayList<>(); surfaces.add(mPreviewSurface); surfaces.add(mImageReader.getSurface()); // ... 创建CaptureSession }关键在mOnImageAvailableListener,当有新的一帧图像可用时,我们将其传递给后台线程进行推理。
private final ImageReader.OnImageAvailableListener mOnImageAvailableListener = new ImageReader.OnImageAvailableListener() { @Override public void onImageAvailable(ImageReader reader) { Image image = reader.acquireLatestImage(); if (image != null) { // 将Image对象转换为YUV数据,然后转换为RGB Bitmap // 注意:这个转换过程比较耗时,必须在后台线程进行! ByteBuffer yBuffer = image.getPlanes()[0].getBuffer(); ByteBuffer uBuffer = image.getPlanes()[1].getBuffer(); ByteBuffer vBuffer = image.getPlanes()[2].getBuffer(); // ... 调用YUV转RGB的函数,例如使用RenderScript或手动转换 Bitmap rgbBitmap = convertYUV420ToRGB(yBuffer, uBuffer, vBuffer, image.getWidth(), image.getHeight()); image.close(); // 将Bitmap传递给推理线程 mInferenceHandler.post(() -> { YoloV11NCNN.DetectionResult[] results = mYoloDetector.detect(rgbBitmap); // 将结果传递给UI线程进行绘制 runOnUiThread(() -> mOverlayView.setResults(results)); }); } } };6.3 实现绘制层与性能优化
OverlayView是一个自定义View,负责在onDraw方法中绘制检测框和标签。
public class OverlayView extends View { private List<DetectionResult> mResults = new ArrayList<>(); public void setResults(List<DetectionResult> results) { mResults = results; postInvalidate(); // 请求重绘 } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); Paint paint = new Paint(); paint.setStyle(Paint.Style.STROKE); paint.setStrokeWidth(4.0f); paint.setTextSize(36.0f); for (DetectionResult result : mResults) { // 根据类别设置不同颜色 if (result.label == 0) { // fire paint.setColor(Color.RED); } else { // smoke paint.setColor(Color.BLUE); } // 绘制矩形框 canvas.drawRect(result.bbox, paint); // 绘制标签和置信度 String label = result.label == 0 ? "Fire" : "Smoke"; String text = String.format("%s %.2f", label, result.score); canvas.drawText(text, result.bbox.left, result.bbox.top - 10, paint); } } }性能优化核心技巧:
- 异步推理:绝对不能在
OnImageAvailableListener的回调中直接进行耗时的模型推理,这会导致预览严重卡顿。必须将图像数据抛到单独的HandlerThread或线程池中处理。- 帧率控制:手机摄像头帧率可能高达30fps,但模型推理可能只能达到10-15fps。盲目处理每一帧会导致队列堆积。一个简单的策略是使用一个“帧采样”机制,比如每处理完一帧后,丢弃接下来的1-2帧,只处理最新的帧。
- 图像缩放:从
ImageReader获取的图像是摄像头原始分辨率(如1920x1080),直接缩放到640x640进行推理开销很大。可以在YUV域直接进行下采样,或者使用Bitmap.createScaledBitmap时选择更高效的滤波方式(如Bitmap.Config.RGB_565和Matrix.ScaleToFit)。- 内存复用:避免在每一帧都创建新的
Bitmap和byte[]。可以预先分配好固定大小的缓冲区,在循环中复用。- 线程数调整:在NCNN的
Extractor中设置ex.set_num_threads()。对于现代手机(8核),设置为4是个不错的起点。可以通过实测找到最佳线程数。
7. 调试、优化与常见问题排查
将各个部分组装起来后,真正的挑战才刚刚开始。你几乎一定会遇到各种问题,从APP崩溃到检测框错乱。下面是一些常见问题及其排查思路。
7.1 模型加载失败或推理崩溃
- 症状:APP启动时闪退,或点击运行后立即崩溃,logcat中看到
signal 11 (SIGSEGV)等native层错误。 - 排查:
- 检查模型文件:确认
best.param和best.bin已正确放入assets文件夹,且文件名在代码中拼写无误。特别注意:Android对资源文件名大小写敏感! - 检查输入输出名:这是最常见的问题。用文本编辑器打开
best.param,确认第一行的输入层名称(如Input images)和最后一层之前的输出层名称。确保在C++代码ex.input()和ex.extract()中使用的字符串与之完全一致(包括大小写)。 - 检查预处理:确认预处理步骤(缩放、归一化、颜色通道顺序)与模型训练和导出时的设置完全一致。YOLO通常输入RGB格式,像素值归一化到0-1(即除以255)。在NCNN中,这通过
in.substract_mean_normalize(0, norm_vals)实现,其中norm_vals[0] = norm_vals[1] = norm_vals[2] = 1/255.0f。 - 检查OpenCV链接:如果使用了OpenCV进行图像处理,确保在
CMakeLists.txt中正确链接了OpenCV库,并且ABI(armeabi-v7a/arm64-v8a)匹配。
- 检查模型文件:确认
7.2 检测结果框位置或类别错误
- 症状:能画出框,但框的位置完全不对,或者所有框的类别都是同一个。
- 排查:
- 坐标映射错误:检查后处理中,将
640x640坐标系下的检测框映射回原始图像坐标系的代码。公式应为:x_original = x_det * width_original / 640。 - 后处理解码错误:这是最复杂的部分。YOLOv11的输出格式需要你精确解析。你需要清楚你的模型输出是
(batch, num_anchors, 85)这样的形状,其中85=4(bbox)+1(obj_conf)+80(cls_conf)。对于自定义的2类模型,最后应该是4+1+2=7维。务必使用Netron打开ONNX模型,仔细查看输出节点的形状和维度顺序。NCNN的Mat数据布局是C/H/W(通道/高/宽),可能需要通过.reshape()或直接计算索引来正确访问数据。 - 置信度阈值和NMS参数:
score_threshold设得太高,所有框都被过滤掉了;设得太低,会留下大量噪声框。nms_threshold设得太小,本应合并的框没有合并;设得太大,会把不同物体的框错误合并。需要根据你的数据集特点进行调整。
- 坐标映射错误:检查后处理中,将
7.3 APP运行卡顿,帧率很低
- 症状:预览画面卡顿,检测延迟很高。
- 排查与优化:
- 测量耗时:在推理代码前后加时间戳,打印出预处理、推理、后处理各阶段的耗时,找到瓶颈。
- 降低输入分辨率:如果训练时用的是
640,可以尝试在安卓端使用480甚至320进行推理,速度会快很多,但精度会下降。这是一个速度与精度的权衡。 - 启用量化:如4.3节所述,使用INT8量化模型,通常能带来30%-50%的速度提升,且精度损失在可接受范围内。
- 尝试Vulkan后端:如果你的手机GPU支持Vulkan,可以在NCNN中启用Vulkan计算(
net.opt.use_vulkan_compute = true)。对于某些模型和芯片,GPU推理比CPU更快。 - 优化图像传递:检查从
ImageReader获取YUV数据到转换成Bitmap或直接转换成ncnn::Mat的路径是否高效。避免不必要的拷贝和格式转换。
7.4 更换自定义检测目标
如果你想检测其他目标,比如“安全帽”或“车牌”,整个流程是完全通用的,只需修改以下几点:
- 数据:准备新的数据集,用
labelImg标注对应的类别(如helmet,no_helmet)。 - 训练:修改数据集配置文件
data.yaml中的nc(类别数)和names(类别名称列表)。重新训练模型。 - 导出与转换:重复第3、4步,得到新的
best.param和best.bin。 - 安卓端:
- 替换
assets目录下的模型文件。 - 在C++或Java代码中,更新类别数量
num_classes。 - 在
OverlayView的绘制代码中,根据新的class_id修改标签文字和框的颜色映射。 - 如果类别含义变化,可能还需要调整后处理中类别置信度的处理逻辑(但通常不变)。
- 替换
整个过程的核心——模型转换、NCNN集成、安卓摄像头流处理、异步推理框架——都是完全复用的。这正体现了本项目作为“模板”的价值。
从模型训练到安卓APP上看到实时检测框,这条路走下来确实不短,涉及到深度学习、模型工程、移动开发等多个领域的知识。但每一步拆解开来,都是有标准做法和工具支持的。最大的挑战往往不是某个具体的技术点,而是整个链条的贯通和调试。我个人的体会是,耐心和细致的调试日志是你最好的朋友。每次卡住时,回到最基本的环节验证:模型输出对吗?数据转换对吗?名字匹配吗?参数对吗?把这几个问题搞清楚,大部分难题都能迎刃而解。最后,别忘了在真机上多测试不同型号和系统版本的手机,兼容性往往是项目“落地”前的最后一关。
本文还有配套的精品资源,点击获取