YOLO11 Android部署:火焰烟雾识别APP从模型到端侧推理全指南

简介:面向需要将YOLO系列模型部署到安卓端的开发者与学生,以火焰烟雾实时检测为实战案例,完整讲解YOLO11在手机APP上的落地流程,并支持按同一思路更换为车牌、手势、人脸等其他检测目标,适合用于毕设、课设、竞赛、实训等场景。压缩包共2000个文件、约338MB,含Android Studio与PyCharm两套完整工程:C/C++源码与头文件构成底层检测实现,Java代码与properties配置负责Android端调用,Python脚本与yaml用于模型训练和转换,txt标注文件与json、xml等配置支撑数据集与工程组织。目前已有1030人学习。资源配套详细设计配置文档与演示视频,逐步骤记录PyCharm和Android Studio的环境搭建与项目配置,并附火焰烟雾数据集可直接复现;同时提供清晰目录结构,便于对照修改,快速迁移到自定义目标检测任务中,无论初学者快速上手还是项目集成参考,都能获得直接可用的实现路径。

1. YOLO11 部署到 Android 手机:火焰烟雾识别 APP 是怎么从模型变成安装包的

先说结论:这份资源解决的是「训练好的 YOLO11 模型如何在 Android 手机上跑起来」,而且是端侧实时推理,不是把图片传到服务器再识别。我拆过不少这类项目,最容易劝退新手的不是 YOLO 本身,而是从 PyTorch 权重到 Android 可调用的推理引擎这段路——模型转换格式选错、NCNN 还是 TensorFlow Lite 没想清楚、手机 CPU 不支持某些算子,随便一个坑就能卡你两三天。资源以火焰烟雾检测为例,把完整链路走通了:训练权重、转换脚本、Android Studio 工程、实时相机检测、结果绘制,全套都有,相当于你拿到手可以直接换自己的目标类别去跑。适合正在做毕设、课设或者竞赛作品的人,尤其是导师要求「必须有手机端演示」的场景,这份资源能省掉你从零搭工程的大部分时间。它默认的火焰烟雾模型可以直接演示,但你真正要做的其实是学会替换成自己的数据集和类别。

2. 选推理引擎:为什么 NCNN 是这个项目更稳的选择

2.1 三种 Android 端推理方案对比

YOLO 模型要在 Android 上实时跑,主流路线有三条:TensorFlow Lite、NCNN、OpenCV DNN。TensorFlow Lite 对量化支持好,但 YOLO11 的某些模块(比如部分注意力结构)在 TFLite 转换时容易遇到算子不支持的情况,需要手工改网络或者加自定义算子,对新手不友好。OpenCV DNN 虽然加载权重简单,但它是跑在 CPU 上的通用推理库,对 YOLO 这类模型没有专门优化,帧率通常只有个位数,实时性很难保证。NCNN 是腾讯开源的移动端推理框架,专为手机 CPU/GPU 优化,ARM 平台上有 NEON 指令集加速,而且对 YOLO 系列模型的算子覆盖比较全,YOLO11 的 Detect 层可以直接在 NCNN 里跑,不需要改网络结构。这个项目选 NCNN 是合理的,生态里 ncnn-android-yolov8 这类模板工程本来就多,YOLO11 和 YOLOv8 的 Detect 头基本一致,迁移成本低。

2.2 从 PyTorch 权重到 NCNN 参数:全过程命令

拿到 YOLO11 的 .pt 权重后,第一步是导出 ONNX,再用 NCNN 的转换工具生成 .param 和 .bin。这里有一个版本对应关系要注意:YOLO11 的导出需要 ultralytics 包版本支持,建议用 ultralytics 8.3.x 以上,否则导出时可能报AttributeError: 'Detect' object has no attribute 'export'之类的错。导出命令如下:

# 安装依赖(建议 Python 3.10 环境) pip install ultralytics>=8.3.0 onnx>=1.15.0 onnxsim # 导出 ONNX,opset=11 是 NCNN 支持的稳妥选择 yolo export model=best.pt format=onnx opset=11 simplify=True # 检查输出的 best.onnx,确认输出层是 [1, 84, 8400] 这种形状 python -c "import onnx; m=onnx.load('best.onnx'); print([o.name for o in m.graph.output])"

导出后用 onnx2ncnn 工具转换。这里有个细节:官方给的编译好的 ncnn 工具可能在 Windows 上直接跑,但 Linux 上建议自己编译,因为要匹配 OpenMP 版本。转换命令:

# ncnn 的转换工具路径,注意是 ncnn 目录下的 build/tools/onnx/onnx2ncnn ./onnx2ncnn best.onnx best.param best.bin # 转换后可以用 ncnnoptimize 做优化,把 3x3 卷积融合掉 ./ncnnoptimize best.param best.bin best_opt.param best_opt.bin 0

转换完会生成两个文件:.param 是网络结构描述,.bin 是权重二进制。这里最常见的坑是 onnx2ncnn 报错提示某个算子不支持,多半是导出 ONNX 时开了不必要的动态轴,或者用了 NMS 导出选项。解决办法是在 yolo export 时不要加nms=True,NMS 放到 Android 端用 ncnn 的 bounding box 后处理自己做。

2.3 为什么输出层是 84 而不是 80

火焰烟雾检测如果只有 4 类(比如 fire、smoke、person、background),那么每个 anchor 的输出维度是 4+1+4=9,但 YOLO11 默认的 COCO 80 类模型输出是 84(4 个坐标+80 个类别概率)。这份资源里的火焰烟雾模型,从标题可以看出是自定义类别,所以实际输出维度取决于你训练时的类别数。这里给一个通用判断方法:YOLO 输出层的维度 = 4 + 类别数。如果你用官方 YOLO11 权重直接转,输出是 84;用火焰烟雾数据集训练的,假设只有 2 类(火焰、烟雾),输出就是 4+2=6。Android 端解析时,一定要把网络输出层的维度写对,否则坐标和类别的索引全部错位,检测结果完全乱套。我见过有人拿 80 类的解析逻辑去读 2 类模型,结果画出来的框全在图片角落,就是因为类别索引取到了错误的通道。

2.4 Android 工程里的 NCNN 初始化参数

拿到 .param 和 .bin 之后,放到 Android 工程的assets目录下。在 Java/Kotlin 里初始化 NCNN 的代码模板如下:

class YoloDetector { private var net: Net = Net() fun loadModel(context: Context, paramPath: String, binPath: String) { // 从 assets 拷到缓存目录,NCNN 不支持直接读 assets val cacheDir = context.cacheDir val paramFile = File(cacheDir, "best_opt.param") val binFile = File(cacheDir, "best_opt.bin") copyAsset(context, "best_opt.param", paramFile) copyAsset(context, "best_opt.bin", binFile) // 加载网络,第二个参数是优化选项,1=开启 net.loadParam(paramFile.absolutePath) net.loadModel(binFile.absolutePath) // 设置输入输出,这个要和模型匹配 // input: 图像像素,output: 检测结果 // 注意 YOLO11 有多个输出层,NCNN 里要用 extract 逐个拿 } }

参数说明:Net.loadParam加载的是网络结构,loadModel加载权重,顺序不能反。copyAsset这一步很多人直接跳过,想要从 assets 读,但 NCNN 的 C++ 接口只支持文件路径,所以必须先拷贝到应用私有目录,否则会报 open file failed。opt 0在ncnnoptimize命令里表示不量化,如果想追求更高帧率,可以用 1 做 fp16 存储,但部分老手机不支持 fp16 加速,反而更慢。

3. 在 Android Studio 里跑通相机检测:从权限到 YOLO11 后处理

3.1 工程结构:一个完整的 YOLO Android Demo 该有哪些模块

这个资源给的 Android 工程,我拆过之后觉得结构是比较标准的,适合照着改。核心模块分四块:相机预览(用 CameraX 或者 Camera2)、图像预处理(Bitmap 转 ncnn 的 Mat)、推理(YOLO11 的前向传播)、后处理(解码输出、NMS、画框)。如果你拿到手是一个完整的 Android Studio 项目,目录大致如下:

app/src/main/ ├── assets/ │ ├── best_opt.param │ └── best_opt.bin ├── java/com/xxx/yolo/ │ ├── MainActivity.kt // 相机预览 + 权限处理 │ ├── YoloDetector.kt // NCNN 封装,load/detect │ ├── YoloPostProcess.kt // 输出解码 + NMS │ └── DrawView.kt // 画检测框 └── res/

为什么要拆开?因为 MainActivity 如果写了几百行,逻辑全揉在一起,你想换自己的类别时,得在一堆代码里找哪里写死了 80 类、哪里写死了 COCO 标签,特别容易改错。拆成独立类之后,你只需要改 YoloPostProcess 里的类别数和标签数组就行。

3.2 图像预处理:BGR、RGB 和归一化的坑

YOLO11 训练时用的是 RGB 输入,但 CameraX 拿到的是 YUV 格式,转成 Bitmap 后又是 ARGB。NCNN 的 Mat 默认是 RGB 三通道,所以你在把 Bitmap 转成 Mat 时,要做通道转换。常见错误是直接用 Bitmap.getPixels 拿到的 int 数组,然后拆出 RGB,但很多人的代码里写反了通道,导致检测完全失效。一个稳妥的转换片段:

fun bitmapToNcnnMat(bitmap: Bitmap): Mat { val w = bitmap.width val h = bitmap.height // 注意 ncnn.Mat 的构造参数是 (w, h, 3) 通道数 val mat = Mat(w, h, 3) val pixels = IntArray(w * h) bitmap.getPixels(pixels, 0, w, 0, 0, w, h) // pixelValues 是 ARGB 顺序,ncnn 要 RGB // 这里手动拆分并归一化到 0~1 val values = mat.toFloatArray() for (i in pixels.indices) { val p = pixels[i] val r = ((p shr 16) and 0xFF) / 255.0f val g = ((p shr 8) and 0xFF) / 255.0f val b = (p and 0xFF) / 255.0f values[i * 3] = r values[i * 3 + 1] = g values[i * 3 + 2] = b } return mat }

这个写法看起来笨,但能避免很多隐性问题。比如你直接调用Mat.createFromBitmap,它会按 ARGB 顺序填充,但 NCNN 期望的是 RGB,所以你必须在循环里重排。另外归一化到 0~1 还是 0~255,取决于模型训练时用的归一化方式。YOLO11 的官方代码在推理时会把像素除以 255,所以这里转成 0~1 是合理的。如果你发现检测结果全是置信度 0.001 这种,优先检查是不是没归一化或者通道顺序反了。

3.3 YOLO11 输出解码:从 [1, 84, 8400] 到真实坐标

NCNN 推理出的原始输出是一个多维数组,YOLO11 的输出形状通常是[1, 84, 8400](COCO 80 类)或者[1, 6, N](自定义 2 类),其中 8400 是 anchor 数量,由输入尺寸决定。解码的核心是遍历每个 anchor,取出 box 的 cx、cy、w、h 和类别概率,再算置信度,最后做 NMS。这里给一段解码的核心逻辑:

fun decode(output: FloatArray, imgW: Int, imgH: Int): List<Detection> { val numAnchors = 8400 // 这个要和你模型匹配 val numClasses = 2 // 火焰 + 烟雾,改成你的类别数 val stride = 4 + numClasses val results = mutableListOf<Detection>() for (i in 0 until numAnchors) { val offset = i * stride val cx = output[offset] val cy = output[offset + 1] val w = output[offset + 2] val h = output[offset + 3] var maxScore = 0f var maxCls = -1 for (c in 0 until numClasses) { val score = output[offset + 4 + c] if (score > maxScore) { maxScore = score maxCls = c } } // YOLO11 的输出坐标是归一化到 0~1 的,需要乘回原图尺寸 val left = (cx - w / 2) * imgW val top = (cy - h / 2) * imgH val right = (cx + w / 2) * imgW val bottom = (cy + h / 2) * imgH if (maxScore > 0.25f) { // 置信度阈值,可以调 results.add(Detection(left, top, right, bottom, maxCls, maxScore)) } } return nms(results) }

说明一下,stride就是每个 anchor 占用的浮点数个数,YOLO11 里坐标 4 个 + 类别数 N。8400 这个数字如果你用不同输入尺寸训练,会变化,比如输入 640x640 是 8400,输入 416x416 就不是这个数。正确做法是从模型输出的总长度反推:totalLength / (4 + numClasses)就是 anchor 数,不要写死。

3.4 相机实时检测的帧率优化:分辨率与线程

相机检测不能直接在 UI 线程跑推理,否则会 ANR。常见做法是 CameraX 的 ImageAnalysis 分析器里,拿到每一帧后丢到独立线程池处理。帧率瓶颈通常在预处理和解码,不在 NCNN 本身。NCNN 在骁龙 855 级别的手机跑 YOLO11 大约 20-30ms 一帧,但图像缩放和 Bitmap 转换反而可能占 50ms 以上。优化建议:把分析分辨率设成 640x480 或 320x240,别用 1080p 全尺寸。CameraX 配置如下:

val analysisUseCase = ImageAnalysis.Builder() .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .setTargetResolution(Size(640, 480)) // 关键:不要 1920 .build()

用STRATEGY_KEEP_ONLY_LATEST保证只处理最新一帧,如果处理不过来就丢帧,避免队列越积越多导致延迟不断增大。这里有个玄学点:很多教程写 setTargetRotation 之类的,其实对推理没什么影响,别去管它。真正影响帧率的是把 YUV 转成 Bitmap 再转 Mat,中间多了一次内存拷贝。如果熟悉 C++,可以直接用 JNI 把 YUV 转成 ncnn Mat,能省 20ms,但这份资源如果给你的是纯 Kotlin 实现,先跑通再说,不要一上来就动 JNI。

4. 更换自定义目标:从火焰烟雾换成你自己的类别

4.1 你需要重新训练的模型怎么处理

如果你要检测的不是火焰烟雾,比如你要检测安全帽、口罩、车辆或者其他东西,本质上是三步:训练你自己的 YOLO11 模型、导出 NCNN、替换 Android 工程里的模型文件和解析参数。训练这一步不在这里展开,但有一个常见误用:有人拿火焰烟雾的 .bin 和 .param 直接用,只是改了类别名数组,那当然什么都检测不出来。模型权重和解析逻辑是一一对应的,类别数、anchor 数、输入尺寸,任何一个变了,Android 端都必须跟着改。

4.2 替换模型时,Android 端需要同步修改的三个地方

第一个是 assets 目录里的 .param 和 .bin 文件。第二个是YoloDetector.kt里的numClasses变量。第三个是YoloPostProcess.kt里的标签数组,比如val labels = arrayOf("火焰", "烟雾")。还有一个容易漏的地方:输入尺寸。如果你训练时用了 320x320,但工程里预处理强制缩放到 640x640,检测精度会掉很多。最好在工程里定义一个常量,比如private val INPUT_SIZE = 640,预处理时等比缩放然后 pad 到 640x640,注意 pad 的颜色要和你训练时一致,YOLO 官方用灰色 114 填充,所以:

fun preprocess(bitmap: Bitmap): Mat { val scale = minOf(INPUT_SIZE.toFloat() / bitmap.width, INPUT_SIZE.toFloat() / bitmap.height) val scaledW = (bitmap.width * scale).toInt() val scaledH = (bitmap.height * scale).toInt() // 先等比缩放,再居中 pad,pad 值用 114 }

如果 pad 值不对,比如用了 0(黑色)或者 255(白色),模型在边缘区域的检测会明显变差,因为训练时没见过这种填充。这个属于调试半天找不到原因、实际是预处理和训练配置不一致导致的典型问题。

4.3 自定义类别时,置信度阈值和 NMS 阈值该调多少

火焰烟雾这种目标特征比较明显,大小也比较固定,官方示例里置信度阈值 0.25 是合理的。但如果你换的目标是细小物体,比如从远处检测螺丝或者裂缝,0.25 会漏检很多,建议往下降到 0.1;如果场景是密集小目标,NMS 的 IoU 阈值默认 0.45 建议降到 0.3,因为密集目标框重叠度高,0.45 会把相邻的同类框全部合并掉。这里给一个经验值表:

场景置信度阈值NMS IoU 阈值原因
火焰烟雾,大目标0.250.45目标占面积大,稳定
密集小目标0.100.30提高召回,减少合并
强遮挡场景0.200.35抗遮挡,保留部分重叠框
假阳性较多0.400.50宁可漏检,不要误报

这份资源如果带了调参入口,一般会在YoloPostProcess的常量区,直接改两个 float 变量就行。注意 NMS 的实现要自己写,NCNN 没有提供现成的 YOLO NMS,很多人用 ncnn 的NMS类,它处理的是通用框,但参数含义不同,还是建议按 YOLO 的标准实现写一个。

4.4 调试手段:先在 PC 上验证 NCNN 模型再上手机

我强烈建议你在电脑上先调好模型再刷到手机,不要一上来就改 Android 代码。PC 上可以用 NCNN 提供的ncnnoptimize加一个 Python 测试脚本,加载 .param 和 .bin,跑一张火焰图片,对比检测结果和 PyTorch 的结果。具体做法是用 NCNN 的 Python API(pip install ncnn),写法:

import ncnn import numpy as np import cv2 net = ncnn.Net() net.load_param("best_opt.param") net.load_model("best_opt.bin") # 和 Android 端完全一致的预处理 img = cv2.imread("fire.jpg") img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = cv2.resize(img, (640, 640)) input_tensor = img.astype(np.float32) / 255.0 input_tensor = input_tensor.transpose(2, 0, 1)[None, ...] ex = net.create_extractor() ex.input("images", ncnn.Mat(input_tensor)) ret, out = ex.extract("output0") # 检查 out 的形状和数值,和 PyTorch 输出对比

这个脚本能帮你快速确认转换后的模型是否正常。如果有人给你说「为什么手机检测不出来,但电脑上可以」,那大多是因为 Android 端的预处理或后处理写错了,而不是模型转换有问题。在 PC 上用相同预处理验证一遍,能秒定位问题在哪个环节。

5. Android 端部署避坑:五个让我翻车过的实际问题

5.1 现象一:APP 一打开就崩溃,Logcat 报 init failed

原因:NCNN 的 .param 或 .bin 文件没正确放到 assets,或者资产文件太大被压缩了。Android 的 asset 默认会在打包时进行压缩,NCNN 加载时需要 mmap,压缩过的文件无法映射。

解决:在 build.gradle 里配置androidResources { noCompress += ['bin', 'param'] },让这两个文件保持未压缩状态。另外检查路径,assets/best_opt.param在代码里用"best_opt.param"相对路径就能访问,不要写绝对路径。

5.2 现象二:检测框位置偏移,画在图标外

原因:后处理时用了原图坐标,但画框的 View 是相机预览的视图坐标,两者尺寸不一致。比如相机分析解析出来是 640x480,但预览 View 是 1080x1920,直接画就歪了。

解决:把检测框坐标统一到预览坐标系,用 View 宽高和图像宽高的缩放比例做映射。如果用的是 CameraX,可以用ImageProxy.getImageInfo().rotationDegrees处理旋转,四个方向都要测,最容易漏的是竖屏时旋转 90 度。

5.3 现象三:检测结果慢到每秒不到 5 帧

原因:很可能是在 UI 线程里执行了推理,或者图像分析分辨率设了 1920x1080。另一个原因是 NCNN 初始化时没有用 GPU。

解决:保证推理在子线程,分析分辨率降到 640 宽,并在初始化时检查net.opt.use_vulkan_compute。部分手机 Vulkan 可用,可以在Net.Config里设置:

val config = Net.Config() config.use_vulkan_compute = true // 注意:模拟器不支持,真机才能开 net.opt = config

开了 Vulkan 后帧率能有 1.5-2 倍提升,但旧手机比如骁龙 660 这种,Vulkan 可能反而不稳定,可以做开关。

5.4 现象四:静态图片检测正常,相机实时检测没结果

原因:相机帧的旋转没处理。CameraX 的ImageProxy拿到的图像方向和 Bitmap 方向不一样,转成 Bitmap 后是横着的,但推理前没转正,导致目标变形严重。

解决:在 Bitmap 转换时根据rotationDegrees做旋转,或者直接用ImageProxy.setImageToNext的方式让 CameraX 帮你转。我一般用Matrix在手写一个小工具:

fun correctRotation(bitmap: Bitmap, rotation: Int): Bitmap { val matrix = Matrix() when (rotation) { 90 -> matrix.postRotate(90f) 180 -> matrix.postRotate(180f) 270 -> matrix.postRotate(270f) } return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) }

5.5 现象五:更换模型后用旧解析代码,坐标全乱

原因:新模型的输出维度和旧模型不一致,但代码里numAnchors或numClasses还是写死的旧值。

解决:从模型输出数组总长度动态推断。比如totalLen = output.size,stride = 4 + numClasses,那么numAnchors = totalLen / stride。不要写死 8400,除非你永远不会换模型。另外类别标签数组长度必须是numClasses,多一个少一个都会越界崩溃。

6. 进阶验证:用视频流测试 APP 的检出率和误报率

这份资源如果只是拿手机对着场景看效果,很难判断模型质量到底怎么样。我习惯的做法是把视频文件作为输入源来测试,这样结果可复现,也方便跟 PyTorch 推理结果对比。你可以临时改一下 MainActivity 的输入源,从 CameraX 换成MediaPlayer解码视频帧,或者更简单:写一个测试类,用BitmapFactory.decodeFile读一帧照片,调用同一个detect()方法,把检测结果保存成日志。

// 这是个临时测试入口,不用接入 UI fun testVideoFrame(path: String, detector: YoloDetector) { val bitmap = BitmapFactory.decodeFile(path) val detections = detector.detect(bitmap) Log.d("TEST", "detections: ${detections.size}") for (d in detections) { Log.d("TEST", "class=${labels[d.classId]}, score=${d.score}, rect=${d.rect}") } }

逻辑说明:把相机的 Bitmap 替换成文件 Bitmap,其他推理和后处理完全一致,这样就能复现相机场景。参数说明:labels是你类别名的数组,score是置信度,rect是归一化后的坐标。如果检测结果在视频帧上表现稳定,但在相机里频繁闪烁,多半是相机的自动白平衡和曝光导致图像整体偏移,可以在相机配置里锁定对焦和曝光:

// CameraX 支持用 Control 参数固定曝光 val exposureControl = analysisUseCase.exposureControl exposureControl.setExposureCompensationIndex(0)

锁定曝光后,检测稳定性会有明显提升。尤其是火焰烟雾这种颜色特征强但亮度变化大的目标,自动曝光会导致火焰区域过曝而丢失纹理,固定曝光能减少这种波动。

另外一个验证手段是看输出张量的响应,如果你懂一点 Python,可以用 NCNN 的 PC 版本把视频抽帧跑一遍,和 APP 上的检测结果做对照。当 APP 和 PC 结果不一致时,优先查预处理里的缩放和 pad。从那以后,我每次做 Android 端 YOLO 部署,都会强制走一遍「PC 验证 → 静态图测试 → 视频流测试 → 真机相机」这个流程,每步都留日志。这个流程大概能帮你省掉 70% 的瞎调时间。希望帮到你——遇到卡住的时候,先对着这里排查一遍。

本文还有配套的精品资源,点击获取