最近一个项目让我把自训练的检测模型从PC端挪到手机端跑用的就是YOLO-V4和它的轻量版Tiny-YOLO-V4。说实话模型训练本身不复杂真正折腾人的是把Darknet训练出来的权重转换成移动端能跑的格式再在安卓手机上把检测框稳定地画出来。中间踩了不少坑也积累了一些能直接抄作业的经验。这篇博文想把整套流程梳理一遍从为什么要用YOLO-V4和Tiny-YOLO-V4到训练自己的数据集时该怎么调参再到模型转换、移动端部署和性能调优最后附上我在实际项目中遇到的问题排查记录。适合打算做移动端目标检测的开发者参考尤其是那种毕业设计、小团队Demo、工业质检滚动的场景——模型不用太大但要能在几百块的安卓机上流畅跑起来。1. 为什么选YOLO-V4 和 Tiny-YOLO-V4——先搞清楚两者定位1.1 从结构差异看取舍精度和速度的真实差距YOLO-V4在当年算是速度和精度平衡得非常好的一个版本核心结构是CSPDarknet53主干加SPP模块加PANet特征融合配合Mish激活函数和SAT自对抗训练。它在COCO数据集上的mAP确实能打到43%左右同时推理速度也不算离谱。但问题在于YOLO-V4的参数量大约6400万模型文件体积在250MB上下FP32权重这种体积放到移动端几乎属于噩梦级别。Tiny-YOLO-V4则是把主干换成了CSPDarknet53-Tiny激活函数换成Leaky ReLU去掉SPP和部分PANet结构输出层从三个尺度减到两个尺度也就是13x13和26x26输入416时。参数量直接降到600万左右模型文件只有23MB左右。作为对比我把两者在相同数据集、相同输入尺寸下跑了一遍指标YOLO-V4Tiny-YOLO-V4参数量约6400万约600万模型体积(FP32)约250MB约23MB输入尺寸416时GFLOPs约60约6.5手机上单帧推理耗时1.2秒~2.5秒80ms~300msmAP(自定义数据集)0.870.74这个mAP差距不是固定的。如果你的目标是识别大物体、背景不复杂、类别差别明显Tiny-V4完全够用。如果场景里有大量小目标、互相遮挡、光照变化剧烈YOLO-V4的精度优势才真正体现。提示在移动端讨论“实时性”时优先看GFLOPs和内存带宽而不是单纯看mAP。Tiny-YOLO-V4的GFLOPs只有YOLO-V4的十分之一左右这直接决定了手机上能不能跑到30FPS以上。1.2 什么场景用哪个选择不是拍脑袋我遇到过不少小伙伴一上来就问“哪个更快就上哪个”但实际选型要看三个硬约束目标最小尺寸、机器最低配置、检测频率要求。如果检测端的手机是几年前的中端机型内存4GB以内又要求至少实时预览25FPS以上基本只能选Tiny-YOLO-V4而且大概率要配合输入尺寸416甚至320。如果检测目标在画面里占的面积比较大比如工业检测中的螺丝、包装盒、零件表面缺陷Tiny版在416输入下的特征分辨率也足够覆盖。如果检测的是远处的行人、车辆、小动物画面里目标经常只有几十个像素这时候Tiny版容易漏检。更稳妥的做法是YOLO-V4配608输入但代价是手机端基本告别实时。我那次给某工厂做的零部件外观检测Demo选的是Tiny-YOLO-V4。原因很简单缺陷类别就三类划痕、异物、污渍样本又是在固定光源下拍的背景非常干净Tiny版训练到收敛后mAP能到0.8以上而推理速度比YOLO-V4快了十倍完全够用。后面我会反复提到这个“模拟项目X”它就是贯穿全文的参照物。2. 训练自己的模型数据痛点与参数调配2.1 数据标注的通用套路量小也能出效果很多人以为训练检测模型必须要上万张图其实在固定场景下几百张高质量标注图就能训练出一个能用的模型。模拟项目X里我总共只标了1200多张图每张图里目标数量1到4个不等标注类别三个。标注工具我用的开源图像标注工具类似LabelImg那样的框选工具保存成Pascal VOC格式的XML。Darknet训练需要把VOC格式转成YOLO格式的txt也就是每行“类别序号、中心点x、中心点y、宽、高”注意这里的四个坐标值都是相对于图片宽高的归一化小数。写个脚本就能跑核心代码就是把xml里的bndbox坐标换算一下。import os import xml.etree.ElementTree as ET def convert_xml_to_txt(xml_path, out_path, classes): tree ET.parse(xml_path) root tree.getroot() img_w int(root.find(size/width).text) img_h int(root.find(size/height).text) lines [] for obj in root.iter(object): cls obj.find(name).text if cls not in classes: continue cls_id classes.index(cls) box obj.find(bndbox) x_min float(box.find(xmin).text) y_min float(box.find(ymin).text) x_max float(box.find(xmax).text) y_max float(box.find(ymax).text) x_center ((x_min x_max) / 2) / img_w y_center ((y_min y_max) / 2) / img_h w (x_max - x_min) / img_w h (y_max - y_min) / img_h lines.append(f{cls_id} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}) with open(out_path, w) as f: f.write(\n.join(lines))标注层面的几个经验宁可框稍微紧一点也不要框得松尤其是Tiny-YOLO-V4这类轻量模型框松了会导致定位精度进一步下降。固定场景下拍图时尽量保持相机高度、角度、光照一致训练集和测试集分布不漂移模型收敛更快。如果目标存在旋转角度先把图片旋转增广再做标注或训练时开角度增广Darknet里配置angle参数。类别不平衡严重时比如“划痕”样本800张“污渍”只有100张不是简单复制图片而是对少量类别做亮度、噪声、随机裁剪增强。2.2 训练参数调整记录这些配置直接影响移动端结果Darknet训练YOLO-V4和Tiny-YOLO-V4时最关键的是cfg文件里的几个参数我直接说结论。width和height训练输入尺寸。我在模拟项目X里用416x416原因是一个平衡点。608会提高小目标召回但是训练时间和部署耗时都明显上涨320对轻量版更友好但精度损失肉眼可见。batch64、subdivisions16如果你的显卡是8GB显存级别这个组合比较稳。subdivisions是控制一次喂入显卡的样本数显存不够时调大subdivisions等于把64张图分批喂进去再累计梯度。learning_rate0.00261burn_in1000max_batches12000。Darknet官方给的是0.00261我试过0.001和0.010.00261确实是最稳的。如果你的数据集很小一千张以内建议把max_batches降到8000到10000防止后期过拟合。steps在max_batches的80%和90%处设置例如steps9600,10800scales0.1,0.1这是控制学习率在后期衰减的节奏。移动端训练时最容易忽略的一点是训练时的输入尺寸必须和部署时的输入尺寸保持一致或者至少按同一个倍数缩放。不要训练时用608部署时用416那样Anchor的比例分布是错位的检测框偏移问题会特别明显。Anchor本身是Darknet里通过聚类算出来的训练时会自动加载cfg里的anchors但部署时如果改了输入尺寸得重新聚类或让推理框架自动适配。训练完记得看两个东西一是每个类别的AP二是recall和average IoU。如果recall高但precision低说明误检多可以稍微把NMS阈值调高如果average IoU低于80%先检查标注框质量再考虑是不是特征分辨率不够。3. 模型转换最容易翻车的一环3.1 Darknet 到 ONNX 的转换细节训练完得到的weights文件不能直接在手机上用要先把Darknet模型转成ONNX再转成移动端推理引擎吃的格式。这里最容易翻车我拆成几步讲。Darknet的权重文件本身只保存了参数结构信息全在cfg里。转换工具开源的有一堆常见做法是加载cfg和weights后重建网络结构。但转换前要改一个关键点把cfg里的batch改成1subdivisions改成1。原因很简单以ONNX为中间格式导出的模型是静态图输入维度固定成batch1部署时推理就是单张图。如果训完不改成1直接导出导出工具也能跑但后面移动端加载时可能因为batch维度不匹配直接报错。我实际用的转换过程分三步把训练好的cfg复制一份改batch1和subdivisions1。用带Darknet解析功能的开源转换脚本读weights和改好的cfg导出opset为11的ONNX模型。用可视化工具比如Netron这类打开的检查输出节点。YOLO-V4有三个输出节点Tiny-YOLO-V4有两个输出节点节点shape一般能看出特征图尺寸比如1x255x26x26这种。注意Darknet里的通道顺序是BGR而大多数移动端推理框架以及OpenCV的imread读进来是BGR但某些相机采集接口给的是RGB。我建议在部署代码里显式做一次通道顺序转换或者转换时固定选择“输入为RGB”不要靠猜。这里说一个实际踩过的坑ONNX导出的模型如果直接在移动端加载输出还是原始的特征图shape比如Tiny-YOLO-V4在输入416时输出两个张量1x255x13x13和1x255x26x26。这个255是3*(805)COCO类别数80但你自己训练的模型可能只有3类这时候最后一个维度是3*(35)24。所以转换完一定要确认输出channel不是255而是基于你类别数的24否则说明转换时类别的配置还是原始COCO的。另一个容易忽略的是归一化方式。Darknet训练时是除以255做归一化的但移动端要看清推理框架的输入约定有的框架要求0~1浮点有的直接接受0~255uint8。如果框架内部自己归一化我外部又归一化一次输出会乱成一团。我在模拟项目X里用的是0~1浮点输入预处理步骤就是pixel (pixel / 255.0f - 0.5f) * 2.0f这种归一化注意Darknet本身默认是0~1没有减均值除方差所以别套ImageNet的标准归一化参数。3.2 从 ONNX 到移动端推理格式算子兼容与优化拿ONNX转成移动端推理引擎所使用的模型格式比如某轻量级推理引擎的bin/param格式或者通用量化的tflite格式核心是算子兼容性。YOLO-V4和Tiny-YOLO-V4用到的算子其实不算特别冷门但有几个是传统的Darknet特有操作在移动端格式上没有直接对应算子Mish激活函数YOLO-V4主干用的是Mish但很多移动端推理引擎不喜欢它因为不是GPU/NPU原生算子。Tiny-YOLO-V4用的Leaky ReLU就没这个问题。如果你的模型要在NPU上跑最好在训练阶段就把Mish替换成Leaky ReLU重新训一遍精度损失很小但部署省很多事。route和shortcut这俩是Darknet中张量拼接和残差相加的操作转换器一般会映射成Concat和Add属于通用算子移动端框架支持度很好。yolo层也就是region层后的输出Darknet的yolo层不止输出特征图还包括了解码bounding box的逻辑和解码逻辑。但ONNX导出时通常会绕过这个后处理逻辑只导出原始特征图张量。这意味着后处理NMS和坐标解码得自己写。因此转换完模型之后部署端一定要有配套的anchor、box解码和NMS代码。我整理过一份用Python验证解码逻辑的脚本等部署时再翻译成C或Java。这里贴一段Python验证版本供参考import numpy as np def decode_boxes(pred, anchors, num_classes, input_w, input_h): # pred: [batch, anchors_per_cell*(5classes), grid_h, grid_w] # 以Tiny-YOLO-V4二尺度输出中的一个为例 batch, channels, grid_h, grid_w pred.shape num_anchors len(anchors) assert channels num_anchors * (num_classes 5) pred pred.reshape(batch, num_anchors, -1, grid_h, grid_w) # pred[..., 0:4] 是中心tx、ty、tw、th, pred[..., 4]是objectness # 需要用sigmoid处理tx、ty、objectness和类别概率 # 具体解码公式cx (tx_sigmoid col) / grid_w; cy类似 # w anchor_w * exp(tw); h anchor_h * exp(th) # 然后归一化到输入尺寸 # 再按score阈值过滤再做NMS ...提示建议在PC上先把“模型输出→解码→NMS→画框”这个链路用Python或C验证跑通再去折腾移动端。如果这一步没验证好后面手机上的任何坐标漂移问题都会很难定位。4. 移动端部署从“能跑”到“跑得好”4.1 在 Android 端跑通推理的完整过程模拟项目X用的是Android端部署所以我重点讲Android这条链路。首先选推理框架。我在这个项目里对比了几种主流的移动端推理方案最终选了一种支持GPU加速、算子覆盖度广、且社区资料多的轻量级推理框架熟悉移动端部署的人应该能猜到是哪个例子本身不唯一。选型依据就三条能不能直接加载ONNX转换后的模型格式Android 平台上是否有OpenGL或Vulkan后端的GPU加速有没有现成的图像预处理接口省得自己再封装。我踩过选型上的弯路某次图省事选了只支持CPU的框架跑Tiny-YOLO-V4在骁龙665上要600毫秒一帧等于完全没法预览。换了支持GPU的推理后端之后同样模型直接降到180毫秒左右。所以选框架不是选“哪个能跑”而是选“哪个能在GPU上跑”。部署端完整流程大概是这样初始化推理引擎在App启动时加载模型参数文件这个过程一般会花200ms到1秒不等所以别放在首帧渲染路径上。相机预览拿到NV21格式的帧转成RGB或BGR的Bitmap然后缩放到416x416。执行预处理除以255归一化必要时转为NCHW排布注意内存对齐。调用框架的推理接口拿到输出张量。按模型配置的anchor、grid数量做解码得到候选框加上置信度然后NMS去重。把归一化坐标映射回原图画布用OpenCV或自带Canvas画框。这里最需要细心的是输入排布。推理框架通常要求NCHWbatch、channel、height、width排列也就是把R通道的整张图、G通道的整张图、B通道的整张图依次排开。相机回调给的数据是交错排列的RGB得做一个channel_convert。如果排布弄反了模型输出会乱到完全没法看而且误差不会均匀分布往往是所有框全偏到图的一侧。4.2 后处理与性能优化NMS、多线程、GPU加速模型推理只是整个检测链路的一环后处理写不好照样把帧率拖垮。NMS非极大值抑制在移动端不能无脑用循环套循环。几百个候选框两两比较IOU在Java层是致命的最优做法是直接开一个Native层方法用C写NMS。我实际写的时候用的是按置信度降序排序后逐次抑制的朴素版本候选框控制在200个以内耗时基本在2ms以内。如果候选框超过500建议先按score阈值过滤到两三百个再做NMS否则每帧多出来的耗时会在低端机上明显卡顿。GPU加速方面我实测发现Tiny-YOLO-V4在GPU后端比CPU后端平均快2到4倍但代价是GPU初始化时间更长且某些老机型GPU驱动对算子支持不完整。稳妥的做法是做一个运行时检测如果GPU后端初始化失败或首次推理耗时超过阈值就自动回退到CPU后端。这个回退逻辑基本是必备的不然在部分模拟器或低端机上会直接闪退。多线程调度也很关键。相机的预览回调线程不要直接做推理我做了一层缓冲队列相机线程把图像帧推入环形缓冲推理线程从缓冲中取最新一帧来处理。如果检测只有10FPS而相机预览有30FPS丢掉旧帧比阻塞相机回调体验好得多。Android上相机回调如果被阻塞超过一定时间系统会直接报错所以“用丢帧换流畅”不是妥协而是常规操作。最后是模型量化问题。FP32的Tiny-YOLO-V4模型在手机GPU上能跑但内存和带宽压力不小。如果换用INT8量化模型内存占用可以降到5MB左右速度还能再提20%到40%。但量化之后的精度抖动需要自己评估。我在做模拟项目X的时候试过量化Tiny模型三类缺陷的mAP从0.74降到0.69但推理时间从90ms降到60ms。工业场景对误检容忍度低我就放弃了INT8保留FP16。如果是做玩具Demo或对精度不敏感的场景INT8非常香。5. 实测数据与调优记录5.1 同一模型在两种尺寸下的速度对比我拿一台骁龙778G的测试机以及一台骁龙665的低端机分别跑了Tiny-YOLO-V4和YOLO-V4。这里的数据是开了GPU后端、FP32模型、单帧推理不含NMS和图像缩放的实测结果设备Tiny-YOLO-V4输入416Tiny-YOLO-V4输入320YOLO-V4输入416骁龙778G62ms38ms503ms骁龙665190ms115ms1520ms骁龙778G INT8量化45ms29ms无量化版本可以看到YOLO-V4在骁龙665上跑到1.5秒一帧这个算力根本做不了任何实时应用。Tiny-YOLO-V4在骁龙778G上能做到62ms接近16FPS配合丢帧策略和预览降分辨率体验上还算能接受。如果换320输入可以达到26FPS以上体验就顺滑很多。注意这些数字不是绝对标准不同版本算子实现、GPU驱动、内存频率都会影响结果。真要做选型最好拿自己的模型在目标机器上跑一轮再拍板。5.2 模型加速三板斧量化、裁剪、算子优化第一板斧是量化。Tiny-YOLO-V4直接从FP32量化到INT8速度提升20%到40%模型体积缩为四分之一。量化前要先校准不能用训练集直接跑要准备几百张有代表性的图片集。有些困惑的是量化后的模型在GPU后端不兼容那就量化成FP16体积减半、速度提升10%左右精度几乎无损。第二板斧是裁剪。对Tiny-YOLO-V4来说通道裁剪空间有限因为轻量版已经砍得很激进。真正有用的裁剪点是输出层如果你的类别数少比如只有3类最后一个卷积的通道数是3*(35)24跟COCO的255差了很多模型尾部参数已经小了不少。还有头部输入层如果应用场景不需要检测非常小的目标可以把第二个输出层大尺度特征图去掉虽然会损失一部分小目标检测能力但速度能再快一点。第三板斧是算子融合。大部分移动端推理引擎会自动做卷积BN融合、卷积激活融合。Darknet的BN层在部署时可以完全吸收进卷积的权重和偏置。转换时如果没有做这个操作推理引擎会按两个算子依次执行耗时可能多10%到15%。我在项目里直接用推理引擎自带的模型优化工具做了图优化反正能省则省。部署时的板斧顺序建议先跑通FP32再开图优化再降FP16最后尝试INT8。每一步都验证精度和速度不要一步到位否则出了问题没法定位是量化导致的还是算子不支持导致的。6. 常见问题与排查手记6.1 转换后输出全部为0或NaN这是我在模拟项目X最开始遇到的头号问题。ONNX模型在PC上的Python推理输出正常但导入移动端后全部输出0或NaN。排查下来有三个原因输入归一化没对齐。排查思路在移动端写死一段固定输入比如全128的灰度图和PC端用同一个输入跑一遍对比输出是否一致。不一致就逐层排查。模型转换过程中部分算子被错误融合。排查思路关闭图优化用不融合的方式跑看是否恢复。使用了半精度浮点但模型里有数值敏感层。排查思路切回FP32精度试试。提示一个非常实用的排查方法是打印中间层输出。移动端推理框架一般支持获取某一层的输出在PC上用同模型导出每个关键层的输出值两边对比差值小于1e-3就说明部署没问题。6.2 手机端检测框位置偏移位置偏移最常见的表现是模型能检测到目标置信度也正常但框整体偏向左上角或右下角。这个问题多数出在坐标映射时没有考虑输入缩放方式。比如实际图像是1920x1080做缩放时如果直接拉伸到416x416那么x和y的缩放比例其实是一样的但这会改变目标比例导致精度下降。更推荐的做法是等比缩放加paddingletterbox原图缩放后剩余部分填灰色或黑色。如果是这样做的那么在把输出坐标映射回原图时要先减去padding偏移再除以缩放比例。公式很简单scale min(input_w / orig_w, input_h / orig_h) # 等比缩放比例 new_w int(orig_w * scale) new_h int(orig_h * scale) pad_x (input_w - new_w) // 2 pad_y (input_h - new_h) // 2 # 映射回原图时 orig_x (pred_x - pad_x) / scale orig_y (pred_y - pad_y) / scale很多人在这一步直接写x / input_w * orig_w一旦用了letterbox就会偏。我就在这个坑里蹲了大概半天最后看了眼中间特征图可视化才反应过来。6.3 NMS 后重合框无法消除这种情况通常不是NMS代码写错而是不同类别的框也在做NMS。很多人写NMS时只对同一类别的框做抑制但实际场景中两个框可能是同一类目标紧挨着或者是不同类别目标重叠。正确做法要看业务需求一般检测模型对不同类别的输出互不影响NMS是对每个类别内部做但如果你的场景有“一个物体同时被判成两个类别”的误检那就应该加一个跨类别的NMS或者提高类别置信度阈值。另外Tiny-YOLO-V4的两个输出层天然就可能对同一目标产生两个框一个来自13x13网格一个来自26x26网格。如果解码每个特征图层之后先分开画框再统一NMS可能会导致重复框。正确做法是把两层解码后的候选框合并到同一个列表再统一按置信度阈值过滤和NMS。6.4 GPU 初始化失败或首次推理极慢部分手机首次加载GPU程序shader编译可能需要2到5秒这不是死机是编译缓存还没有建立。最好的做法是App启动时提前初始化推理引擎并把首次推理放在后台线程甚至可以用一张全黑图做预热推理。我在项目里遇到过某款老机型首次推理耗时达到7秒的情况如果没预热用户第一帧画面会卡到像崩溃一样。另外如果App切到后台再切回来GPU上下文可能被系统回收这时候需要捕获异常并重新初始化模型。结尾这套流程跑通之后换场景就是重复劳动项目交付之后我自己复盘过一次。YOLO-V4在PC上是很好的训练框架但到了移动端除非是旗舰机加特殊优化否则Tiny-YOLO-V4才是真正能落地的选择。训练部分其实不难难的是把转换、预处理、解码、NMS这些“脏活累活”一次做对。我个人最终的建议是没有极致的精度需求就优先上Tiny-YOLO-V4输入416用推理引擎的图优化和FP16先跑稳再考虑INT8。如果换一个场景其实只是换数据集和anchor整个部署链路完全可以复用。希望这篇记录能帮你少走我走过的弯路尤其是坐标映射和模型转换那两关值得多花点时间验证。