【实战指南】从零到一:将YOLOv5模型部署至Android App的完整流程与性能调优

【实战指南】从零到一:将YOLOv5模型部署至Android App的完整流程与性能调优

1. 环境准备与模型训练

想要在Android上跑YOLOv5目标检测,第一步得把模型训练好。我建议直接用PyTorch官方提供的YOLOv5代码库,这个项目维护得相当不错,社区支持也很活跃。先装个Anaconda创建虚拟环境,避免把本地环境搞乱:

conda create -n yolov5 python=3.8 conda activate yolov5 pip install torch==1.9.0+cu111 torchvision==0.10.0+cu111 -f https://download.pytorch.org/whl/torch_stable.html

数据集处理是个容易踩坑的地方。VOC数据集下载解压后,记得按照YOLOv5要求的格式重组文件夹结构。关键是要创建正确的.yaml文件,比如我的VOC.yaml长这样:

train: ../VOC/images/train val: ../VOC/images/val nc: 20 # 类别数 names: ['aeroplane', 'bicycle', 'bird', 'boat', 'bottle', 'bus', 'car', 'cat', 'chair', 'cow', 'diningtable', 'dog', 'horse', 'motorbike', 'person', 'pottedplant', 'sheep', 'sofa', 'train', 'tvmonitor']

训练参数调优我踩过不少坑。batch size不是越大越好,我的RTX 3070显卡在8-12之间表现最佳。workers数量要根据CPU核心数来定,设太高反而会导致数据加载阻塞。建议先用小规模数据跑几个epoch验证管道是否通畅:

python train.py --img 640 --batch 8 --epochs 50 --data VOC.yaml --weights yolov5s.pt

训练完成后别急着收工,一定要用验证集测试模型表现。我习惯用--task study参数生成各种指标可视化报告,特别是PR曲线和混淆矩阵,能直观看出哪些类别识别效果差:

python val.py --weights runs/train/exp/weights/best.pt --data VOC.yaml --task study

2. 模型转换与优化

PyTorch模型不能直接在Android跑,需要先转成移动端友好的格式。我推荐PyTorch→ONNX→NCNN这条转换路径,实测兼容性最好。转换前有个关键步骤:修改YOLOv5的Focus层实现。原始实现用了数组切片操作,在移动端容易出问题:

# 修改前(训练用) def forward(self, x): return self.conv(torch.cat([x[..., ::2, ::2], x[..., 1::2, ::2], x[..., ::2, 1::2], x[..., 1::2, 1::2]], 1)) # 修改后(部署用) def forward(self, x): return self.conv(torch.cat([x, x, x, x], 1)) # 简单复制四份

ONNX转换时这几个参数特别重要:--dynamic让输入尺寸可变,--simplify自动优化计算图,--opset 11确保算子兼容性。转换完记得用onnx-simplifier进一步优化:

python export.py --weights best.pt --include onnx --dynamic --simplify --opset 11 python -m onnxsim yolov5s.onnx yolov5s-sim.onnx

转NCNN时我试过在线转换工具,但最终选择本地编译onnx2ncnn工具。关键是要下载对应版本的预编译工具包,转换后一定要做optimize:

./onnx2ncnn yolov5s-sim.onnx yolov5s.param yolov5s.bin ./ncnnoptimize yolov5s.param yolov5s.bin yolov5s-opt.param yolov5s-opt.bin 65536

最后记得手动修改.param文件:把最后的三个输出层维度全改为-1。这个坑我踩过,不改的话Android端会输出无数个无效检测框,画面直接糊掉。

3. Android工程配置

Android Studio工程配置有几个关键点。首先在build.gradle里正确引入NCNN库,我用的2022年稳定版:

dependencies { implementation 'com.tencent.ncnn:ncnn-android-vulkan:20220216' }

NDK配置要注意ABI过滤,现在主流设备都是arm64-v8a,没必要支持armeabi-v7a徒增包体积:

android { defaultConfig { ndk { abiFilters 'arm64-v8a' } } }

模型文件要放在assets目录,但Android对单个文件大小有限制。我用过的最稳方案是把.bin文件拆分成多个小文件,运行时再合并:

// 在assets目录创建 yolov5s-opt.bin.0, yolov5s-opt.bin.1... InputStream[] streams = new InputStream[4]; for (int i = 0; i < 4; i++) { streams[i] = getAssets().open("yolov5s-opt.bin." + i); } FileOutputStream fos = new FileOutputStream(mergedFile); byte[] buffer = new byte[8192]; for (InputStream is : streams) { int length; while ((length = is.read(buffer)) > 0) { fos.write(buffer, 0, length); } is.close(); }

别忘了在AndroidManifest.xml开启硬件加速和Vulkan支持:

<uses-feature android:name="android.hardware.vulkan.version" android:required="false"/> <uses-feature android:name="android.hardware.opengles.version" android:required="true"/>

4. 模型加载与推理优化

模型加载阶段最容易出现内存问题。我的经验是先创建静态Net对象,在Application初始化时预加载:

public class YOLOApp extends Application { public static Net yoloNet; @Override public void onCreate() { super.onCreate(); yoloNet = new Net(); yoloNet.opt.use_vulkan_compute = true; yoloNet.loadParam(getAssets().open("yolov5s-opt.param")); yoloNet.loadModel(getAssets().open("yolov5s-opt.bin")); } }

图像预处理要特别注意颜色通道和归一化。YOLOv5的输入需要RGB格式且数值归一化到0-1:

Mat rgb = new Mat(); Imgproc.cvtColor(inputMat, rgb, Imgproc.COLOR_BGR2RGB); rgb.convertTo(rgb, CvType.CV_32FC3, 1.0 / 255.0); Mat blob = Dnn.blobFromImage(rgb); Extractor extractor = yoloNet.createExtractor(); extractor.input("input", blob);

后处理阶段最耗CPU,我优化后的方案是先用Java实现基础版本,再用C++重写关键部分。特别是NMS(非极大值抑制)算法,用C++能提速3倍以上:

extern "C" JNIEXPORT jobjectArray JNICALL Java_com_example_yolo_YOLOv5_nativeProcess(JNIEnv *env, jobject thiz, jfloatArray output, jint num_classes) { jfloat *outputPtr = env->GetFloatArrayElements(output, nullptr); // C++实现的高效NMS算法 ... env->ReleaseFloatArrayElements(output, outputPtr, 0); return result; }

实测发现动态调整推理分辨率能显著提升帧率。我的策略是:当连续3帧检测耗时超过50ms时,自动将输入尺寸从640x640降到416x416。

5. 性能调优实战

内存优化是第一要务。通过Android Profiler发现,每次推理都会产生临时内存碎片,解决方案是复用Mat对象:

private Mat reusableMat; private Mat reusableBlob; void detect(Mat input) { if (reusableMat == null) { reusableMat = new Mat(input.size(), CvType.CV_32FC3); } Imgproc.cvtColor(input, reusableMat, Imgproc.COLOR_BGR2RGB); if (reusableBlob == null) { reusableBlob = new Mat(); } Dnn.blobFromImage(reusableMat, reusableBlob, 1.0 / 255.0); ... }

线程管理也很关键。我建议用单线程Executor处理推理任务,配合Handler实现结果回调:

private ExecutorService inferenceExecutor = Executors.newSingleThreadExecutor(); void asyncDetect(Mat input, DetectionCallback callback) { inferenceExecutor.execute(() -> { Result result = blockingDetect(input); new Handler(Looper.getMainLooper()).post(() -> { callback.onDetected(result); }); }); }

功耗优化方面,我总结出三个有效手段:

  1. 检测到设备温度超过45℃时自动降低推理频率
  2. 屏幕关闭时暂停检测任务
  3. 使用WakeLock保持CPU唤醒,但每次最多持有500ms
PowerManager powerManager = (PowerManager) getSystemService(POWER_SERVICE); WakeLock wakeLock = powerManager.newWakeLock( PowerManager.PARTIAL_WAKE_LOCK, "YOLOv5::WakeLock"); wakeLock.acquire(500);

最后别忘了做机型适配。有些低端GPU不支持FP16运算,需要在运行时动态判断:

boolean supportFP16 = false; if (yoloNet.opt.use_vulkan_compute) { int[] vulkanInfo = new int[3]; yoloNet.getVulkanGpuInfo(vulkanInfo); supportFP16 = (vulkanInfo[2] & 0x1) != 0; } extractor.setEnableFp16Storate(supportFP16);

6. 实用技巧与问题排查

模型量化能大幅减小体积。我用的Post-training量化方案,虽然精度损失约2%,但模型体积缩小4倍:

./ncnn2int8 yolov5s-opt.param yolov5s-opt.bin yolov5s-int8.param yolov5s-int8.bin imagelist.txt

常见的图像方向问题可以通过EXIF信息解决。Android相机拍的照片可能带旋转标记:

int orientation = ExifInterface.ORIENTATION_NORMAL; try { ExifInterface exif = new ExifInterface(imagePath); orientation = exif.getAttributeInt( ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); } catch (IOException e) { e.printStackTrace(); } Matrix matrix = new Matrix(); switch (orientation) { case ExifInterface.ORIENTATION_ROTATE_90: matrix.postRotate(90); break; case ExifInterface.ORIENTATION_ROTATE_180: matrix.postRotate(180); break; case ExifInterface.ORIENTATION_ROTATE_270: matrix.postRotate(270); break; } Bitmap rotatedBitmap = Bitmap.createBitmap( originalBitmap, 0, 0, originalBitmap.getWidth(), originalBitmap.getHeight(), matrix, true);

遇到模型加载失败时,先检查文件MD5值是否匹配。我遇到过assets文件压缩导致模型损坏的情况,解决方案是在build.gradle中禁用压缩:

android { aaptOptions { noCompress 'bin', 'param' } }

如果出现检测框漂移,大概率是输入尺寸不匹配。确保Android端的预处理和训练时的预处理完全一致,包括:

  • 相同的归一化方式(0-1或0-255)
  • 相同的颜色通道顺序(RGB或BGR)
  • 相同的resize算法(通常用INTER_LINEAR)