尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

STM32N6570-DK部署YOLO-FastestV2:INT8量化后框错乱与误检的完整排查指南

STM32N6570-DK部署YOLO-FastestV2:INT8量化后框错乱与误检的完整排查指南 最近在STM32N6570-DK上跑YOLO-FastestV2量化为INT8之后模型倒是顺利跑起来了可输出的bounding boxes怎么看怎么不对劲框不是贴着目标漂在半空就是同一个目标周围糊了一圈重叠框甚至纯色背景也会被识别出“人”或“车”。如果你也在嵌入式板子上做目标检测相信我这类问题不是个别案例。先说结论绝大多数情况下模型本身没坏NPU也算得没毛病真正出问题的往往是输入预处理、输出张量的反量化方式以及后处理解码之间的匹配关系。这篇文章我会完整记录从现象、排查到最终修复的整个过程把YOLO-FastestV2在STM32N6570-DK这类Cortex-MNPU板卡上部署时碰到INT8框错乱、误检偏多的坑一个一个讲清楚适合正在做边缘端轻量化检测部署的工程师参考。1. 问题复盘现象、环境和初步定位1.1 问题现象我遇到的现象大概分三类。第一类bounding boxes的位置和尺寸完全失真比如输入图像里只有一个人站在画面左下方输出框却跑到画面中央甚至框比整个目标大几倍直接把背景也框进去。第二类显著性很高的重复框同一辆车被输出三四个重叠度不高的框看起来像是同一目标被分到了不同类别或不同网格都检测到但NMS没能把它们合并掉。第三类是false detections就是纯背景区域也会给出高置信度输出比如一片草地被识别成“猫”一块白墙被识别成“dog”。这三类现象同时出现时第一反应往往不是后处理而是怀疑模型本身。但我很快发现模型在PC上用同一份参数跑输出完全正常。这就说明模型文件、量化权重大概率没坏问题出在部署环境的输入输出环节。1.2 我的软硬件环境与部署链路开发板是STM32N6570-DKMCU带AI加速单元资源比较有限。模型中选的是YOLO-FastestV2输入尺寸固定为320x320或416x416量化方式是训练后转INT8。部署链路大致是PyTorch或原仓库训练好的权重 - 转ONNX - PTQ量化成INT8 - 用STM32Cube.AI或等价的转换工具生成C静态数组模型 - STM32工程里调用模型 - 拿到输出tensor - C代码做解码NMS - 画框显示。我一开始图省事直接在官方SDK示例里改了输入和输出size就以为完事了。其实这中间每一段都可能产生“看起来像模型出错”的问题。后面排查时我先从输出侧倒着查再一步步退到输入侧。1.3 为什么INT8容易出这类问题先说一个容易误导人的点INT8量化本身确实会引入精度损失但对YOLO-FastestV2这样的轻量检测模型只要校准集选得不是太离谱损失主要体现在少数小目标或低置信度框上不会导致大面积框乱跳。量化误差更常见的是让置信度普遍偏低或偏高一点点而不是把坐标解码逻辑完全搞乱。所以当我看到“全图都是框、框跟目标毫无关联”这种极端现象时第一判断就是量化只是背锅侠真凶多半在后处理或数据流。当然如果量化时把输出层也强行量化到int8且没有在模型内部做反量化而你又在后处理时忘了乘以scale和zero_point那输出值会是一堆截断后的整数解码出来的坐标就会完全乱掉。这个问题我会在第四章详细说。2. 核查模型输出格式YOLO-FastestV2的head结构与解码约定2.1 输出tensor维度与含义做后处理前第一件事是搞清楚模型输出到底长什么样。我用Python脚本加载ONNX模型用onnxruntime打印各输出节点的shape。常见的YOLO-FastestV2变体会有三个输出对应三个stride比如下采样8、16、32。以416x416输入为例输出层shape可能是小目标层1 x 3 x 52 x 52 x (41num_classes)中目标层1 x 3 x 26 x 26 x (41num_classes)大目标层1 x 3 x 13 x 13 x (41num_classes)其中3表示每个网格有3个anchor4是x,y,w,h1是objectnessnum_classes是类别数。但有些代码把anchor维度放在最后或中间比如 1 x 52 x 52 x 3 x 85 或 1 x (3*85) x 52 x 52。这直接决定了C代码里怎么解释内存。我建议你用Netron或Python打印shape别凭记忆去写C数组下标。我在排查时发现ONNX从某些框架导出后输出顺序可能是反的即大目标层在前面、小目标层在后面。如果代码里写死了第一个输出是stride 8实际它却是stride 32那解码出的框当然会大面积错位。2.2 解码公式与anchor表YOLO-FastestV2的head本质上还是anchor-based。它的预测值不是最终坐标而是相对网格和anchor的偏移量。我用的这个版本解码公式如下bx (sigmoid(tx) grid_x) * strideby (sigmoid(ty) grid_y) * stridebw anchor_w * exp(tw)bh anchor_h * exp(th)其中grid_x、grid_y是当前网格的整数索引anchor_w、anchor_h是该层对应的anchor尺寸stride是下采样倍数。bx、by是该目标中心在输入图像上的像素坐标bw、bh是宽高。注意这个公式里x、y要过sigmoid因为中心点偏移被约束在一个网格内而宽高用exp因为这本质上是相对anchor的尺度倍数。如果你下载的模型是别人训练好了转出来的anchor表可能已经写死在模型的初始化参数里。你必须从模型配置里把anchor读出来而不是用网上默认YOLOv5的anchor。我做了一个实验用正确的anchor替换后误检立刻少了一半。这里要特别留意一个细节有的导出脚本把anchor和sigmoid都融合进了输出tensor也就是说模型输出的直接就是解码后的坐标你再用解码公式套一遍坐标会被二次变换。判断方法很简单用Python加载模型输入一张全零图或单张测试图看第一个输出tensor的值是否都在0~1之间。如果值范围像概率说明head里可能已经带了sigmoid如果范围能到几十几百甚至上千说明你要在外部补sigmoid和anchor解码。2.3 检查你的后处理代码是否和模型输出匹配我在排查时列了一个检查清单这里分享出来输出维度顺序是不是按NCHW读有些C代码按NHWC读但模型输出是NCHW取下标就会错。是否做了objectness * class_prob很多部署后处理少这一步直接把class_prob当最终分数导致低噪声框全部被放行false detections暴增。类别数是否正确比如训练时80类模型输出最后维度854180如果你代码里写的是81类并把背景维度当成了第一个类别那么所有框的类别都会偏移。阈值是否合理在嵌入式上为了“看到框”有人把conf_threshold降到0.05结果满屏误检。建议先设0.3~0.4排查。坐标是否做了clip解码后的bx、by可能超出图像边界直接绘制会显示成错框。通常在NMS前后要把坐标clip到[0, input_size]范围内。这些条目看似基础但90%的“框错误”都出在这里。建议把Python端正确输出和C端输出放在一起逐项对比而不是直接盯着画框结果猜。3. 根因排查数据预处理与归一化一致性3.1 输入数据的归一化方式如果模型输入是浮点常见预处理是像素值除以255然后归一化到0~1有些模型还会用mean/std例如除以256、减0.5乘以2。但INT8模型在嵌入端比较特殊模型的输入层可能是量化层它期望的输入是已经量化到int8/uint8的数组而不是浮点。这里有两个“归一化”第一像素归一化。也就是把0~255的原始图像变成0~1或-1~1的浮点。这个在PC端训练/验证时默认会做。如果你在STM32代码里没有做直接把原始uint8像素喂给一个浮点输入模型输入分布完全不一致检测结果基本就是胡来。第二量化尺度归一化。INT8模型的输入量化参数scale、zero_point通常在模型元数据里。工具链生成代码时有些会自动插入预处理层有些不会。如果工具链不给自动处理你就需要用公式uint8_data (float_data / scale) zero_point转换后再送入输入缓冲区。这里的scale可能是0.0039左右zero_point可能为128对应uint8量化。如果你直接喂0~255的原始像素还以为是正确数据那等于给模型的输入加了巨大的噪声。我在代码里加了打印对比PC端输入和板端输入的实际数值。发现板端图片像素没有归一化就直接当成了模型输入。修正后框的位置从乱飞到基本贴合目标。3.2 输入尺寸与letterboxYOLO系列对输入尺寸非常敏感。训练时如果用了letterbox等比缩放padding推理时也必须保持一致。原因很简单模型的检测头是按输入尺寸训练的stride对应的网格数量是固定的。你训练时是320x320推理时如果直接resize成416x416网格数量变了anchor的相对尺度也变了解码出的框自然不对。STM32N6570-DK资源有限很多人会直接把摄像头图像缩放成模型输入尺寸省掉letterbox。这样确实简化代码但会导致目标变形尤其对目标宽高比敏感的检测任务误检率会明显上升。如果你不想做letterbox至少要保证训练时也是直接resize。最稳妥的方案是在板端做等比缩放然后填充灰边通常填114、127或0取决于训练设置推理完再把框映射回原图时去掉灰边。letterbox的逆变换也很容易出错。比如原图宽640、高480输入尺寸320x320缩放比是0.5padding分别是左右0、上下补了80。你在嵌入式端绘制框时必须把模型输出框坐标减去padding再除以缩放比否则框会整体偏移。这个offset问题经常被误判成解码错误。3.3 一个小实验定位预处理问题我建议做这样一个实验能把预处理问题从后处理问题里剥离出来在PC上用Python加载同一个ONNX或转换后的模型固定输入一张测试图跑一遍输出解码后的框和置信度记下来。在STM32上用同样的测试图写死在Flash或通过SD卡读到内存调用模型把输出tensor通过串口打印出来。在PC端也打印同一个输出tensor的前几十个浮点值和板端逐项比较。比较时看两件事数值数量级是否一致数值的方向是否大致相同。如果板端输出值明显偏大或偏小、符号不对那问题八成在预处理或输入张量的量化参数没对上。比如PC端能看到0.9、0.1这样的概率值板端却是0x7F、0x80这种整数说明板端拿到的是量化后的raw int8后面还需要反量化。我当时做这个实验半天就定位到了问题板端输入图像没有做归一化。这个实验成本不高却是最高效的排查方式。4. INT8量化中的坑校准、量化范围与层融合4.1 INT8量化对目标检测模型的影响INT8量化的本质是把浮点权重和激活映射到8位整数。假设某一层浮点范围是[-6.0, 6.0]量化后一个int8值对应的分辨率约等于6.0/127也就是约0.047。如果某个特征的数值本身就小于这个分辨率它就会被量化成0或1损失掉细节。对于分类任务这种损失可能只是logits轻微偏移但对于目标检测的回归分支尤其是小目标框的宽高微小误差会被后处理中的exp指数放大。所以你会发觉一个INT8版本的YOLO模型在相同阈值下可能比FP32版本多出几个假阳性也可能丢失一些低置信度目标。但这不是“框乱”的主要原因。如果发现INT8模型在PC端就能复现大量误检那需要从量化策略上修正如果PC端正常、只有板端异常就回到前面的输入输出匹配问题。4.2 QAT vs PTQ以及工具链的量化约束我最初用的是PTQ训练后量化简单快速对YOLO-FastestV2这种轻量模型如果校准集选得不错精度通常能接受。但如果PTQ掉点严重就要考虑QAT量化感知训练。QAT是在训练时就把量化误差模拟进去让权重适应INT8的统计特性检测头的坐标回归会更稳。不过QAT不是万能的。很多开源YOLO项目没有直接提供QAT训练脚本你需要用特定框架来改。而且嵌入式转换工具链对QAT模型的支持程度不一有些只认ONNX里带QDQ节点的模型。我的建议是先跑PTQ如果AP下降超过3~5个点再考虑QAT如果只是个别背景误检往往调阈值或优化NMS就够。另外嵌入式转换工具链通常只接受静态量化模型。你在工具里要填每个输入张量的scale和zero_point有的工具允许直接输入一组校验图片让工具自动计算。千万别跳过这一步否则转出来的模型内部激活范围可能完全错乱。4.3 量化转换工具链的输出解析差异这一节是我踩过最大的坑。用STM32Cube.AI一类工具转换后生成的代码对模型输出tensor的处理方式不完全一样。有的工具链会把输出层直接放在float类型缓冲区里也就是模型内部已经做了反量化你直接当float读就行。有的工具链则效率优先输出保持int8/int16的量化值只附带一个输出量化参数结构体。如果你拿到的输出缓冲区是int8但你用float *强制转换去读或者反过来用int8_t *读一个float缓冲区都会得到非常离谱的数值。检测结果的置信度和坐标会充斥着垃圾值。我在调试时给工具生成的输出层包装了一个统一接口如果是int8输出就遍历一次执行out_f[index] (out_i8[index] - zero_point) * scale;如果已经是float就直接memcpy。这个接口看起来简单但能避免后面所有后处理代码被搞乱。4.4 校准数据集的选择如果模型PC端INT8测试就有误检要检查校准集。校准集的作用是统计每层激活值的动态范围。如果校准集只包含几十张单一场景的图片统计出来的min/max就会偏向那个场景。部署环境里的光照、目标大小、背景分布和校准集差异大时激活值可能超出校准范围或大量挤在量化死区造成输出漂移。我的经验是校准集至少200张覆盖目标多尺度、多样背景、不同光照的图片如果可能混入一些包含背景噪声的负样本帮助量化器学到更合理的“背景”分布。INT8模型对校准集的敏感度比很多人想象得高。在STM32上调试时如果发现某类目标总是漏检、某些背景总被误检可以先回PC端用不同校准集重新量化对比两个版本的输出差异。做好记录方便回溯。5. 后处理全流程修正从原始输出到clean boxes5.1 输出解码的完整步骤伪代码这里我把实际可用的伪代码放出来。假设输入尺寸是input_size有三个输出层每层输出排列为[batch, num_anchors, grid_h, grid_w, 5 num_classes]C语言里连续存储为[grid_h * grid_w * num_anchors * (5 num_classes)]。每个位置的值如下index 0: 中心点x偏移 txindex 1: 中心点y偏移 tyindex 2: 宽twindex 3: 高thindex 4: objectnessindex 5..5num_classes-1: class probabilities完整步骤for each layer l: stride input_size / grid_size[l] for gy in range(grid_h): for gx in range(grid_w): for a in range(num_anchors): base ((gy * grid_w gx) * num_anchors a) * (5 num_classes) tx output[base 0] ty output[base 1] tw output[base 2] th output[base 3] obj sigmoid(output[base 4]) bx (sigmoid(tx) gx) * stride by (sigmoid(ty) gy) * stride bw anchor_w[l][a] * exp(tw) bh anchor_h[l][a] * exp(th) score obj class_id argmax(output[base5 : base5num_classes]) class_score sigmoid(output[base5class_id]) final_score score * class_score if final_score conf_threshold: 保存 box (bx,by,bw,bh), class_id, final_score注意有些导出模型已经对objectness和class_score做过sigmoid就不会再套一次而且class score可能会被单独加一个temperature。所以伪代码里的sigmoid是不是要执行取决于你的模型。唯一检验方法还是Python端对比。5.2 NMS参数调优如果你做了解码和阈值过滤仍然看到大量重叠框问题往往在NMS。NMS用于在多个重叠候选框中保留最高置信度的那个。经典流程是按score降序排列取出分数最高的框删除所有与其IoU大于阈值的框循环直到候选框为空。NMS有两个关键参数confidence threshold和IoU threshold。建议先把conf_threshold设在0.3~0.4排除明显噪声IoU threshold通常在0.45~0.5太大会留下重叠框太小会误删正确目标。如果同一个目标被分成两个类别各输出一个框你需要在NMS之前考虑是否做类别无关的NMS。多数场景下按类别分别NMS就够但如果你的类别间高度重叠统一做也可能更好。在嵌入式上NMS的排序是性能瓶颈。C代码里可以先简单用插入排序或快速排序输出分类后得到的小数组。框数量一般不超过几百性能可以接受。我实测在STM32N6570-DK上用简单的选择排序选top-kk100再执行NMS延迟增加不到几毫秒可以忽略。5.3 可视化验证框的坐标系最后一个容易踩的坑是把框画到显示缓冲区时坐标搞错。模型输出的bx、by、bw、bh是在模型输入图像坐标系下的像素值。如果你做了letterbox
返回列表