做端侧目标检测这几年,总有人问我一个很现实的问题:手机上跑行人检测,什么样的模型结构才靠谱?模型太大,发热和内存占用双双超标;模型太小,漏检误检又让人头疼。我近期完成的这个“基于GhostConv的极简YOLO架构在手机端行人检测中的应用”项目,就是冲着这个痛点去的。它用GhostConv把YOLO的体积压下来,同时保持在手机GPU和NPU上稳定的实时推理能力,最终形成一套可以真实落地的检测方案。
这篇内容我会把GhostConv的原理、极简结构的选择依据、从训练到端侧部署的完整链路都过一遍,顺手把我在项目里踩过、也帮别人排过的几个经典问题一并列出来。适合那些准备在移动端做目标检测,尤其是行人检测方向的开发者和算法工程师。无论你是刚开始接触轻量化网络,还是已经训过一轮YOLO正在找压体积的思路,这篇应该都能给你一些能直接抄作业的东西。
1. 整体思路:为什么用GhostConv把YOLO极简到手机端
1.1 手机端行人检测的真实瓶颈
手机端行人检测最核心的矛盾,是算力预算和模型精度之间的性价比问题。很多人第一反应是用现成的YOLO模型直接转端侧框架,结果一跑就发现三个硬伤:体积大、延迟高、功耗顶不住。
我用一个标准YOLO模型做过对照,体积接近40MB,在某一款中端安卓设备的CPU上跑单帧推理要将近200ms。这还没有算前处理和后处理的时间。如果改用GPU,中端机型上确实能快不少,但内存占用和发热也随之上来,长时间挂在后台做实时检测根本不现实。这种体量显然没法直接拿到手机端用。
行人检测本身也有自己的特殊性。手机摄像头看到的行人,其实大部分是中小目标,尤其是在远距离场景下,一个人可能只占画面里几十个像素。再加上行人是“竖长形”目标,常规的方形先验框和通用检测特征不一定完全匹配。这些问题都决定了:我们不能只盯着FLOPs和参数量做纸面优化,还要考虑目标尺寸分布、端侧硬件的算子支持情况。
所以这个项目一开始就把目标定得很清楚:模型文件最终要压到5MB以内,单帧推理延迟控制在40ms上下,精度不能比大模型差太远。方向定了,后续一切选择都围绕这三个硬指标来展开。
1.2 GhostConv的选型逻辑:用廉价变换换参数
轻量化网络方案其实不少:深度可分离卷积、分组卷积、通道重排、低秩分解等等。为什么我最后选了GhostConv?
当时我对比了两类方案的实际表现。深度可分离卷积整体参数确实少,但MobileNet那一套设计对逐通道卷积的依赖很强,在行人这种小目标叠加遮挡的场景里,我实测会出现较多的漏检,尤其是目标较小时特征融合不够,召回率不稳定。分组卷积则需要反复调分组数量,不同硬件上的算子支持情况还不一样,稍不注意就出现某种算子在端侧框架里执行效率极低的问题。
GhostConv的理念完全不同。它先从“特征图存在大量冗余”这个观察出发:普通卷积输出的很多特征图之间其实高度相似,没必要全部完整算一遍。GhostConv的做法是先算一小部分“内在特征”,再通过廉价的线性变换把其余特征图“生”出来。这种方式能在不牺牲太多表达能力的条件下,把卷积的计算量和参数量降下来。
我实际把GhostConv替换进YOLO结构后,参数量大概砍掉了一半,精度只回落了不到一个点。换成深度可分离卷积做同样替换,精度回落更明显,而且端侧实测速度并没有比GhostConv快。所以最终选GhostConv,不是因为它“听起来新”,而是它在通道融合能力和硬件执行效率之间,找到了一个更适合当前场景的平衡点。
1.3 “极简YOLO”到底简了什么
极简不代表随便砍层。我的原则很简单:凡是实测中不能带来明显收益的模块,统统拿掉;凡是能合并的卷积,尽量合并;凡是能减少分支的设计,优先减少分支。
这个项目里做的第一刀,砍在主干的深度上。通用YOLO主干为了适配各种目标,往往会堆很多层,但行人检测的目标类别单一,不需要那么强的多尺度特征抽象能力。我把主干压缩到不到20个卷积层,每层通道数也压了一圈,后三个下采样阶段保留中等和高层特征图,低层特征图直接交给浅层检测头去处理。
第二刀砍在特征融合结构上。标准YOLO的颈部结构会做多次跨层特征拼接和反复融合,在手机端会带来额外的内存拷贝和算子开销。极简版保留“高层特征回灌到中层”的单向融合路径,剪掉低层和高层之间的重复交互,这样通道数减少,端侧执行时也更连贯。
第三刀砍在检测头。原型用了三分支检测头,我最终减成两分支,一个负责中尺度目标,一个负责小尺度目标。对行人场景来说,三分支中负责大目标的分支实际收益很低,因为手机端画面里真正占满大半屏的行人极少。去掉这个分支,参数量又降了一截,速度明显提升。
这套组合打下来,模型从近40MB降到了3.8MB左右。对比精度,mAP大约掉了1.2个点,但端侧帧率从不到10FPS拉到了25FPS以上。对手机端实时检测来说,这个交换是完全值得的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析:GhostConv原理与极简结构设计
2.1 GhostConv工作原理解读
要理解GhostConv,先要理解一个观点:卷积输出的特征图之间,存在大量“看起来很不一样但其实高度相似”的信息。GhostConv把这部分冗余特征称为“Ghost特征”。
它的实现分为三步。第一步,用一个少量的1x1卷积生成所谓的内在特征,也就是真正承载核心信息的那一小部分通道。这一步算出来的通道数会远小于最终输出通道数,计算量也随之减少。第二步,对内在特征逐通道做一次深度卷积,生成和内在特征等量的“次级特征”。这些次级特征是通过线性变换得到的,不需要完整计算每个通道的独立权重。第三步,把内在特征和次级特征拼接起来,得到最终输出。
我用一个简单的公式来描述计算量变化:普通卷积假设输出N个通道,Ghost模型只需要对其中N/r个通道做标准卷积,剩下的N(1-1/r)个通道由分组卷积或深度卷积生成。压缩比约等于r。r取2的时候,FLOPs和参数量大约能压到原来的一半出头。
下面是一段以PyTorch风格写的GhostConv核心实现,我建议你直接拿去改成自己的版本再对比效果:
python复制class GhostConv(nn.Module):
def __init__(self, in_chs, out_chs, kernel_size=1, ratio=2, dw_size=3, stride=1):
super().__init__()
init_chs = int(out_chs / ratio)
self.conv_init = nn.Conv2d(in_chs, init_chs, kernel_size, stride, kernel_size // 2, bias=False)
self.conv_dw = nn.Conv2d(init_chs, init_chs, dw_size, 1, dw_size // 2, groups=init_chs, bias=False)
self.act = nn.SiLU(inplace=True)
self.out_chs = out_chs
def forward(self, x):
x_init = self.act(self.conv_init(x))
x_ghost = self.conv_dw(x_init)
out_chs_need = self.out_chs - x_ghost.shape[1]
x_ghost = self.act(x_ghost)
return torch.cat([x_init, x_ghost], dim=1)[:, :self.out_chs + out_chs_need]
在实际项目里,我最常用的ratio是2。ratio取4能进一步压参,但精度回落在小目标上很明显,尤其是远距离行人,召回率掉到不能接受。ratio取1等于退化成普通卷积,没有意义。所以如果你第一次上手,直接固定ratio=2去调其他参数,会更稳。
用的时候还要注意一个坑:GhostConv里的深度卷积对端侧框架的算子调度有时不友好。不同框架对分组卷积的底层实现差异比较大,某些框架里同样的dw卷积在CPU上执行效率极高,到了NPU上反而被打回通用卷积。所以我在选型初期就额外加了两条约束:一是尽量避免kernel size过大的GhostConv分支,二是端侧集成完成后必须逐算子看耗时分布,发现不友好的算子要立刻替换成等价的普通卷积或重排结构。
2.2 极简YOLO的主干、颈部和检测头设计
整个极简结构里,我实际跑过三轮比较。第一轮是标准YOLO原封不动,第二轮是只替换主干里的3x3卷积为GhostConv,第三轮才是同时压缩颈部结构并删掉一个大目标分支。每一轮都记录了体积、FLOPs和端侧FPS,最后确定的方案才是我上面说的那套。
主干部分的stem层我保留了常规的3x3卷积,没有直接上GhostConv。原因很简单,stem层处理的是原始输入分辨率,信息密度最高,直接替换会让浅层特征质量下降,导致后续所有层的特征都受影响。虽然只差一个卷积,但在实测中这个差异直接反映为小目标漏检率的上升。从stem之后的每个下采样阶段,我依次使用GhostConv、1x1卷积、残差连接这样的组合,把通道数逐层增加到目标宽度,同时控制层数。
颈部结构简化后,特征融合路径变成了一条主线:深层的输出先通过GhostConv做通道缩减,再上采样,与中层特征拼接后进入一个轻量的融合模块。我把原本需要两次拼接、一次双向融合的完整FPN,改成一次拼接、一次单向融合。这样从计算图上看,端侧的张量流动变得非常整齐,减少了很多中间缓冲区的分配,实测内存占用下降了20%左右。
检测头部分,我保留了anchor-free的方式。这样做的好处是端侧解码时不需要算一堆先验框的坐标偏移和面积,直接回归中心点和宽高,少一层换算。两个分支分别负责下采样16倍和32倍的特征图,一个管中等大小行人,一个管小尺寸行人。上采样8倍的特征图我一度也保留过,后来发现它带来的召回提升很微弱,又多占一堆计算,最终砍掉了。
2.3 针对行人检测的专项适配
行人检测和通用目标检测最大的差别在于目标的几何特征。我做了两个小改动,效果比我想象中好。
第一个改动是在损失函数里加了一个长宽比先验的惩罚项。行人的长宽比通常在0.35到0.5之间,也就是高明显大于宽。anchor-free方案里如果没有这个先验,模型会倾向于输出接近正方形的预测框,把一些非行人目标也框进来。我在回归分支的loss里针对目标的宽高比做了一个轻微约束:当预测框长宽比严重偏离行人统计范围时,额外加一点惩罚。这个改动让误检率大概下降了4%。
第二个改动是输入尺寸的选择。我最初用640x640的方形输入,后来做了3700余张样本的测试统计,发现大部分标注框的高度集中在图像高度的20%到45%之间,宽度相对较窄。于是我把输入尺寸调整为640x448,相当于在保持高度分辨率的同时,砍掉横向的冗余计算。训练和推理的延迟都低了,精度几乎没有变化。这个思路对场景相对固定的手机端行人检测特别适用,因为它是在你了解自己数据分布的前提下做的定向优化,而不是盲目压分辨率。
3. 实操过程与核心环节实现
3.1 环境与数据集准备
训练环境建议用Python加主流深度学习框架,这一点没有悬念。硬件方面我用的是一张消费级显卡完成所有训练,模型很小,不需要多卡分布式,单卡跑完整轮次大概十几个小时。
数据集准备上,我只选公开的行人检测数据,提取其中的person类别,重新整理成标准检测格式,然后划分训练集、验证集和测试集。数据分布上要格外注意:很多公开数据里近景行人占大头,中远距离的行人比例偏低,如果不做采样平衡,模型训练完会对远处的行人很不敏感。
我做了两步处理。第一步是按目标尺寸区间对训练样本做重采样,让中等和偏小目标的数量占比明显提升。第二步是在训练时用了Mosaic增强,把四张图拼成一张去训练,本质上是让模型在同样一张图里看到更多的小目标,提升小目标采样密度。这个增强在行人检测里非常管用,但注意要配合合理的框过滤策略,否则拼接后的真值框会大量重叠导致loss不稳定。
预处理方面,我把图像统一缩放后送入网络,缩放方式用的letterbox等比缩放。这里有一个很多人会忽略的点:letterbox填充的灰度值最好和训练时的填充值保持一致,否则端侧推理时会引入偏移。我见过两次因为填充值不一致导致精度骤降的案例,都是这一步出了问题。
3.2 训练配置与关键参数
我列出了最终稳定训练版本的配置,你可以先照着跑一遍,再在它的基础上去调。
训练分辨率直接用推理目标分辨率640x448,不再做多尺度训练。优化器选AdamW,初始学习率1e-3,权重衰减0.05,周期内使用余弦退火衰减。总批次大小为64,如果显存不够就降到32,学习率等比下调。总训练轮数120轮,前5轮用warmup,让学习率从较小的值逐步爬升到初始值,避免一开始梯度太大冲乱预训练权重。
损失部分,分类损失用的Focal Loss,目标置信度损失用BCE,回归部分用CIoU。三者的权重我调到1:1:3。回归权重给高一点,是因为行人检测对框位置的准确性要求偏高,尤其是在密集人群中,框偏一点就容易把两个人连成一个框。
训练过程中,我习惯每隔10轮跑一次验证集,记录两个指标:一类是常规的mAP,另一类是专门的远距离行人召回率。后者是我自己加的,统计的是目标高度在整张图高度15%以下的样本。这两个指标会给我完全不同的信号:mAP高不代表远距离小目标检测好,只有第二项指标也稳,我才敢把模型往手机端推。如果你做相似项目,建议你自己也定义一个和实际场景挂钩的专属指标,不要只看通用指标。
3.3 模型导出、量化与端侧部署
训练完成后,第一步是把PyTorch权重转换为通用的ONNX格式,这个过程主要有三个坑:动态shape、算子兼容、输出解码。
动态shape在转换时默认会保留动态维度,但端侧推理框架对动态形状的优化通常很差。我直接在导出时把输入尺寸固定为1x3x448x640,这样后续在端侧不需要处理变尺寸分支。算子兼容方面,主要是把训练时用的SiLU激活函数在导出前做一次替换或确认,部分端侧框架对SiLU没有直接实现,会在推理时拆成多个基础算子,导致速度下降。我在项目里把激活函数统一改成了ReLU6,精度几乎没差,端侧执行快了不少。
量化是我这次部署里收获最大的一步。FP32模型在某一款中端安卓设备的CPU上,单帧推理要46ms,离40ms的目标还有距离。转成INT8后,因为模型本来就很小,量化后体积变成了1.1MB,推理时间下降到27ms。最关键的是,用大约500张校准图跑静态量化后,精度只掉了0.6个百分点,这个损失完全可接受。
INT8量化时的校准集选择,我用的是和训练集同分布但完全不相交的验证集子集。千万不要随手拿训练集里的图做校准,那样校准集叠加了训练分布,结果会偏乐观,部署后一到真实场景就露馅。我测过一次拿训练集校准的模型,验证阶段看起来精度几乎不掉,但换了真实街景测试后漏检多了将近一成。
端侧接入测试时,我选了一个社区常见的开源推理框架,它同时支持CPU、GPU和NPU调度。实测下来CPU模式延迟稳定在27ms左右,GPU模式能跑到更快的帧率,但会有一定的首帧编译开销,需要预热。我没有把NPU作为首选方案,因为当前场景下的行人检测模型比较小,NPU带来的提升有限,还增加了算子适配成本。
3.4 实测性能数据
这一节给出我在项目中积累的一组实际测试数据,设备是一台中端安卓机型,Android系统,测试环境为室内与半室外混合路段。数值是多次运行取均值的结果:
表格:不同方案在手机端的实测对比
| 方案 | 模型体积 | 参数量 | 单帧推理延迟(CPU) | 端侧FPS(CPU) | mAP(验证集) |
|---|---|---|---|---|---|
| 原始YOLO模型 | 38.6MB | 约18.5M | 198ms | 约5FPS | 68.4% |
| GhostConv替换主干 | 8.2MB | 约4.1M | 76ms | 约13FPS | 67.6% |
| 极简架构+FP32 | 3.8MB | 约1.9M | 46ms | 约21FPS | 67.1% |
| 极简架构+INT8 | 1.1MB | 约1.9M | 27ms | 约37FPS | 66.5% |
从数据能看出来,INT8量化之后帧率提升非常明显,即使加上前后处理,整条链路依然可以稳定在30FPS以上。对极简架构来说,量化不仅没有拖后腿,反而是端侧性能落地最划算的一步。
我还记录了功耗情况。在连续运行检测任务15分钟后,设备背面温度上升约6摄氏度,没有出现掉帧或异常关机。相比原始模型动辄发热明显、30分钟就开始降频的表现,这个方案的功耗表现已经达到生产可用级别。
4. 常见问题与排查技巧实录
4.1 精度掉点与误检问题
我在这个项目里遇到最多的一个情况,是量化后误检变多。症状很典型:部署到手机上之后,把路面上的杆子、树的阴影、路牌下方的柱子都框出来了,而且置信度还很高。
排查第一步,先看解码逻辑。我发现很多人把模型输出直接拿来做阈值截断,没有注意到ONNX输出排布和训练时定义的输出顺序可能不一样,导致中心点坐标和宽高读错,框出来的位置整体偏移,效果和误检很接近。确认解码没问题之后,第二步比对输入预处理。我拿验证集同一张图在训练环境和端侧环境分别跑,发现如果端侧用了BGR转RGB但训练时没做,模型对颜色的理解就反了,这会直接导致目标响应混乱。
这两步都排干净后,才轮到量化本身。量化导致的误检通常集中在低置信度区域,最有效的补救是微调分类阈值。我建议部署时不要把阈值设成训练时常用的0.25,而是用验证集做一次阈值扫描,找召回率和精确率交叉点偏精确率一侧的阈值,实战效果会好很多。
4.2 端侧延迟波动与算子兼容
延迟波动是部署阶段的另一个高频问题。首帧特别慢、连续推理一段时间后突然卡一下,或者CPU和GPU两种模式下的帧率差异比预期大,都是我在测试过程中实际遇到的。
首帧慢的根源往往在推理框架的初始化阶段。模型加载、内核选择的编译、内存池的建立,这些都会在首帧时发生。解决方案也不复杂:在应用启动阶段提前做一次空白预热推理,把初始化开销挡在用户真正开始检测之前。我在集成后的App里专门加了一个预热函数,冷启动到开始检测的路径上先跑三次推理,之后就不再出现首帧长时间卡顿的问题。
CPU和GPU模式差距过大的情况,通常是由少数几个不支持的算子引起的。模型在GPU上执行时,如果某个算子没法直接在GPU上跑,框架会把它回退到CPU,再由CPU和GPU之间做数据拷贝,这个来回拷贝的开销比本身算子计算还要慢。碰到这个问题,我唯一的建议是逐算子打印耗时,把最慢的那几个在结构设计阶段就换掉。GhostConv里的深度卷积、拼接操作,都属于候选问题源。
4.3 数据与训练相关的细节坑
训练阶段我替别人排查bubbles的问题也有几回了,这里集中说两个高频坑。
第一个是训练时loss震荡不收敛。大部分情况下问题出在数据加载顺序上。如果训练样本里大量由同一场景连续帧构成,相邻batch之间分布高度相关,模型就会来回摆荡。解决的办法是随机打乱数据,并确保同一场景的图片尽量分散到不同批次。Mosaic增强也是类似逻辑,它天然打散了来源,所以我习惯在训练初期就打开它。
第二个是回归loss在密集行人场景下异常偏大。很多人第一反应是加大回归权重,其实更可能是目标框和高分辨率特征图错位了。行人目标小而且密集时,feature map上一个位置可能对应多个真值,回归目标模糊。比较好的做法是先做一个正样本分配策略检查,对同一位置多个目标冲突的情况增加筛选逻辑。我在项目里把分配逻辑改成按中心点距离优先,只保留最近的一个目标作为正样本,回归loss立刻稳定下来,漏检也少了很多。
最后再分享一个小技巧
如果你准备直接复刻这套方案,我建议你从一开始就把“模型可选最大体积”和“端侧最低帧率”这两个数写死在项目文档最前面。我做完这个项目最大的体会是,轻量化方案成败的关键往往不在某个模块选得好不好,而在于你有没有一套用来做取舍的硬指标。硬约束一旦定下来,GhostConv替换、分支删减、INT8量化这些决策就都有了锚点,不太会出现改着改着变成“为了压缩而压缩”的局面。
另一个小技巧是:每次结构改动,都同时记录验证集精度和端侧帧率。不要只盯精度曲线,因为端侧帧率偶尔会因为框架优化产生跳变,两个指标放一起看,会比任何单一指标都更能反映这次改动的真实收益。希望这篇内容能帮你少走几步弯路,也欢迎你按这个方案跑出一组数据回来,我们再一起对比分析。
