YOLOv8 在 Android 端集成实践
在 Android 端部署 YOLOv8 目前有两条主流技术路线,分别适用于不同场景:
| 方案 | 技术栈 | 难度 | 性能 | 适用场景 |
|---|---|---|---|---|
| 路线一 | TFLite + CameraX | 低 | 良好 | 快速上线、迭代频繁的业务 |
| 路线二 | NCNN + JNI/NDK | 中高 | 极佳 | 对帧率和功耗有极致要求的场景 |
注意:本文档标题为 YOLOv8,若你手头是 YOLOv5/v7 或更早的 YOLO-Nano/SSD-MobileNet 等模型,预处理和输出解析逻辑会有差异,需对照模型结构做相应调整。
一、模型转换与优化
无论选择哪条路线,第一步都是将 Ultralytics 训练好的 .pt 模型转换为移动端可用的格式。
1.1 导出 TFLite(路线一)
Ultralytics 已内置 TFLite 导出,一行命令即可完成:
# 导出 FP16 TFLite(推荐,速度与精度的平衡)
yolo export model=yolov8n.pt format=tflite half
# 或导出 INT8 量化模型(体积最小,需指定代表性数据集)
yolo export model=yolov8n.pt format=tflite int8 data=coco128.yaml
导出后会得到 yolov8n_float16.tflite 或 yolov8n_full_integer_quant.tflite,直接放入 Android 项目的 assets/ 目录即可。
关键参数说明:
half:FP16 量化,模型体积减半,推理速度提升,精度损失极小。int8:INT8 全整数量化,体积最小,但可能需要校准数据集,且部分低端设备支持不佳。
1.2 导出 NCNN(路线二)
NCNN 不直接支持 PyTorch,通常通过 ONNX 中转:
# 1. 导出 ONNX(固定 batch=1,opset 建议 12+)
yolo export model=yolov8n.pt format=onnx opset=12 simplify
# 2. 使用 onnx2ncnn 转换
onnx2ncnn yolov8n.onnx yolov8n.param yolov8n.bin
# 3.(可选)FP16 优化
ncnnoptimize yolov8n.param yolov8n.bin yolov8n-opt.param yolov8n-opt.bin 1
更优方案:如果环境允许,推荐使用 pnnx 直接将 .pt 转为 NCNN,能更好地处理 YOLOv8 的解耦头(Decoupled Head)和动态 Shape 问题,减少后处理代码复杂度。
二、路线一:TFLite + CameraX(推荐新手)
2.1 环境配置
在模块级 build.gradle 中添加依赖:
dependencies {
// CameraX
val cameraxVersion = "1.4.1"
implementation("androidx.camera:camera-core:$cameraxVersion")
implementation("androidx.camera:camera-camera2:$cameraxVersion")
implementation("androidx.camera:camera-lifecycle:$cameraxVersion")
implementation("androidx.camera:camera-view:$cameraxVersion")
// TensorFlow Lite(版本请以 https://github.com/tensorflow/tensorflow/releases 为准)
implementation("org.tensorflow:tensorflow-lite:2.16.1")
implementation("org.tensorflow:tensorflow-lite-gpu:2.16.1")
implementation("org.tensorflow:tensorflow-lite-support:0.4.4")
}
android {
aaptOptions {
noCompress("tflite") // 禁止压缩模型文件
}
}
2.2 图像预处理
YOLOv8 要求输入为 640×640,并采用 Letterbox 填充(保持长宽比,不足部分用灰色 [114, 114, 114] 填充),最后将像素值归一化到 [0, 1]。
// 注意:此处使用 ResizeWithCropOrPadOp 是简化写法,并非严格的 Letterbox。
// 严格 Letterbox 需手动等比缩放后用灰色(114,114,114)填充,TFLite Support 库无现成 Op。
// 对于大多数场景,此简化写法精度损失可接受;若发现边缘目标漏检,建议改用手动 Letterbox。
fun preprocess(bitmap: Bitmap): TensorImage {
val imageProcessor = ImageProcessor.Builder()
.add(ResizeWithCropOrPadOp(640, 640))
.add(ResizeOp(640, 640, ResizeOp.ResizeMethod.BILINEAR))
.add(NormalizeOp(0f, 255f)) // 除以 255
.add(CastOp(DataType.FLOAT32))
.build()
var tensorImage = TensorImage.fromBitmap(bitmap)
tensorImage = imageProcessor.process(tensorImage)
return tensorImage
}
2.3 模型推理与后处理
YOLOv8 的 TFLite 模型输出形状通常为 [1, 84, 8400](84 = 4 个坐标 + 80 个 COCO 类别),需要转置后做 Sigmoid 和 NMS。
class YoloV8Detector(context: Context) {
private val interpreter: Interpreter
private val inputSize = 640
init {
val options = Interpreter.Options().apply {
numThreads = 4
// 若设备支持 GPU,可启用 GPU Delegate
// addDelegate(GpuDelegate())
}
val model = loadModel(context, "yolov8n_float16.tflite")
interpreter = Interpreter(model, options)
}
fun detect(bitmap: Bitmap): List<Detection> {
// 1. 预处理
val input = preprocess(bitmap).tensorBuffer
// 2. 推理(输出形状 [1, 84, 8400])
val outputShape = intArrayOf(1, 84, 8400)
val outputBuffer = TensorBuffer.createFixedSize(outputShape, DataType.FLOAT32)
interpreter.run(input.buffer, outputBuffer.buffer.rewind())
// 3. 解析输出并执行 NMS
return parseOutput(outputBuffer.floatArray, bitmap.width, bitmap.height)
}
private fun parseOutput(floatArray: FloatArray, imgW: Int, imgH: Int): List<Detection> {
val detections = mutableListOf<Detection>()
val numAnchors = 8400
val numClasses = 80
for (i in 0 until numAnchors) {
// 提取第 i 个 anchor 的数据
val x = floatArray[i]
val y = floatArray[numAnchors + i]
val w = floatArray[numAnchors * 2 + i]
val h = floatArray[numAnchors * 3 + i]
// 找最大类别分数
var maxScore = 0f
var classId = 0
for (c in 0 until numClasses) {
val score = floatArray[numAnchors * (4 + c) + i]
if (score > maxScore) {
maxScore = score
classId = c
}
}
if (maxScore > CONFIDENCE_THRESHOLD) {
// 将 640×640 的坐标映射回原始图像尺寸(注意 Letterbox 的偏移补偿)
val scale = min(640f / imgW, 640f / imgH)
val scaledW = imgW * scale
val scaledH = imgH * scale
val dx = (640 - scaledW) / 2
val dy = (640 - scaledH) / 2
val left = (x - w / 2 - dx) / scale
val top = (y - h / 2 - dy) / scale
val right = (x + w / 2 - dx) / scale
val bottom = (y + h / 2 - dy) / scale
detections.add(Detection(left, top, right, bottom, maxScore, classId))
}
}
return applyNMS(detections)
}
}
常见坑点:
- 若使用 Ultralytics 导出的 End2End TFLite 模型(带 NMS),输出形状可能不同,后处理会大幅简化,请通过 Netron 查看模型输出节点确认。
- 坐标映射时务必补偿 Letterbox 的灰边偏移,否则检测框会整体偏移。
三、路线二:NCNN + JNI(性能优先)
3.1 NDK / CMake 配置
在 build.gradle 中启用 CMake:
android {
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
ndkVersion = "25.2.9519653"
}
CMakeLists.txt 示例:
cmake_minimum_required(VERSION 3.22)
project("yolov8ncnn")
set(ncnn_DIR ${CMAKE_SOURCE_DIR}/ncnn-20240102-android-vulkan/${ANDROID_ABI}/lib/cmake/ncnn)
find_package(ncnn REQUIRED)
add_library(yolov8ncnn SHARED native-lib.cpp yolov8.cpp)
find_library(log-lib log)
target_link_libraries(yolov8ncnn ncnn jnigraphics ${log-lib})
依赖获取:
- NCNN Android 预编译库:ncnn releases 下载带 Vulkan 的版本。
- OpenCV Android SDK:建议从 OpenCV 官网 下载,通过 Android Studio 的 "Import Module" 导入,而不是使用不存在的 Maven 坐标。
3.2 JNI 层实现(修正版)
初学者常见错误:示例代码在每次 detect() 内都执行 ncnn::Net yolov8; yolov8.load_param(...)。这会导致每次推理都重新读取磁盘并解析模型结构,实时性完全不可接受。正确做法是在类初始化时加载一次,后续反复复用。
以下给出修正后的核心结构:
// yolov8.h
class YoloV8 {
public:
int load(const char* paramPath, const char* binPath);
int detect(const ncnn::Mat& rgb, std::vector<Object>& objects,
float probThreshold = 0.25f, float nmsThreshold = 0.45f);
private:
ncnn::Net net; // 模型只加载一次,反复复用
int targetSize = 640;
};
// yolov8.cpp
int YoloV8::load(const char* paramPath, const char* binPath) {
net.clear();
net.opt.use_vulkan_compute = true; // 启用 Vulkan GPU 加速
net.opt.num_threads = 4; // 多核并行
int ret = net.load_param(paramPath);
if (ret != 0) return ret;
return net.load_model(binPath);
}
int YoloV8::detect(const ncnn::Mat& rgb, std::vector<Object>& objects,
float probThreshold, float nmsThreshold) {
// 1. Letterbox 缩放
int w = rgb.w;
int h = rgb.h;
float scale = std::min(targetSize / (float)w, targetSize / (float)h);
int dstW = static_cast<int>(w * scale);
int dstH = static_cast<int>(h * scale);
ncnn::Mat in = ncnn::Mat::from_pixels_resize(
rgb.data, ncnn::Mat::PIXEL_RGB, w, h, dstW, dstH);
// 填充灰边到 640x640(注意:以下为伪代码示意,实际应使用 ncnn::copy_make_border)
int dx = (targetSize - dstW) / 2;
int dy = (targetSize - dstH) / 2;
ncnn::Mat in_pad;
ncnn::copy_make_border(in, in_pad, dy, targetSize - dstH - dy,
dx, targetSize - dstW - dx,
ncnn::BORDER_CONSTANT, 114.f);
// 归一化 [0,1]
const float normVals[3] = {1 / 255.f, 1 / 255.f, 1 / 255.f};
in_pad.substract_mean_normalize(0, normVals);
// 2. 推理
ncnn::Extractor ex = net.create_extractor();
ex.input("in0", in_pad); // 输入节点名需与模型一致
ncnn::Mat out;
ex.extract("out0", out); // 输出节点名需与模型一致
// 3. 解析 YOLOv8 输出 (anchor-free, 解耦头)
// out 维度: [1, 84, 8400] 或 NCNN 优化后的多尺度输出
// 此处省略具体解析循环,核心逻辑与 TFLite 路线类似:
// - 提取 x, y, w, h + 80 类分数
// - 找最大类别分数
// - 做 NMS
// 详细实现可参考下方 Demo 项目
return 0;
}
3.3 Kotlin 调用层
class NativeDetector {
init {
System.loadLibrary("yolov8ncnn")
}
external fun initModel(paramPath: String, binPath: String): Boolean
external fun detect(bitmap: Bitmap): Array<Detection>
}
与 Camera 结合:
- 若使用 CameraX,在
ImageAnalysis.Analyzer中获取ImageProxy,转换为Bitmap或ncnn::Mat后传入 JNI。 - 若使用 Camera2,需自行管理
ImageReader和线程,代码量较大,不推荐新项目使用。
四、性能优化技巧
| 优化项 | TFLite 路线 | NCNN 路线 |
|---|---|---|
| GPU 加速 | GpuDelegate() |
use_vulkan_compute = true |
| 量化推理 | INT8 / FP16 | FP16 (ncnnoptimize --fp16) |
| 多线程 | numThreads = 4 |
num_threads = 4 |
| 输入分辨率 | 降低到 320×320(需重新训练/导出) | 同左 |
| 后台线程 | 使用 Executors.newSingleThreadExecutor() 做推理 |
同左 |
| 内存复用 | 复用 ByteBuffer / TensorBuffer |
复用 ncnn::Mat |
特别提醒:
- 千万不要在主线程执行推理,无论模型多小,都会掉帧甚至触发 ANR。
- 对于实时预览场景,若单帧推理耗时 > 33ms(< 30 FPS),建议开启
ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST丢弃旧帧,避免延迟累积。
五、调试与测试
5.1 常见问题排查
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 模型加载失败 | 路径错误、模型版本与库不匹配 | 确认 assets 未压缩;用 Netron 查看模型 opset |
| 检测框整体偏移 | Letterbox 补偿计算错误 | 对比预处理后的图片与原始图片的宽高比 |
| 帧率低 | 未开启量化/GPU、输入分辨率过高 | Android Profiler 查看 CPU 占用;逐步降低分辨率测试 |
| 大量误检/漏检 | 置信度阈值不当、预处理归一化错误 | 固定测试图片,对比 PC 端与移动端输出值 |
| JNI Crash | Native 内存越界、Mat 维度不匹配 | adb logcat | grep DEBUG 查看 backtrace |
5.2 验证工具
- Netron:查看 TFLite / ONNX / NCNN 模型的输入输出节点名称与形状。
- Android Profiler:监控 CPU、内存、GPU 占用。
adb shell dumpsys meminfo <package>:查看详细内存分配。
六、参考实现
| 项目 | 技术栈 | 说明 |
|---|---|---|
| ncnn-android-yolov8 | NCNN + JNI | YOLOv8 基础检测,含完整 NMS 和绘制逻辑 |
| ncnn-android-yolov8-seg | NCNN + JNI | YOLOv8 分割任务 |
| ultralytics/examples | TFLite / CoreML / ONNX | 官方导出脚本,TFLite Android 示例 |
| android/camera-samples/CameraXTfLite | TFLite + CameraX | Google 官方 CameraX + TFLite 图像分类示例,可迁移到检测任务 |
模型转换参考:https://github.com/Digital2Slave/ncnn-android-yolov8-seg/wiki/Convert-yolov8-model-to-ncnn-model
总结
- 如果追求快速落地,优先选择 TFLite + CameraX 路线:Ultralytics 官方原生支持导出,无需编写 C++,CameraX 大幅简化相机开发。
- 如果追求极限性能,选择 NCNN + JNI 路线:Vulkan GPU 加速和多线程优化空间更大,但开发和调试成本显著增加。
- 无论哪条路线,预处理(Letterbox + 归一化)和后处理(NMS + 坐标映射) 都是最容易出错的环节,建议在 PC 端用 Python 写好单元测试,确保数值与移动端完全一致后再上真机调试。
本文链接:YOLOv8 在 Android 端集成实践 - https://h89.cn/archives/322.html
版权声明:原创文章 遵循 CC 4.0 BY-SA 版权协议,转载请附上原文链接和本声明。