
前言一张空荡荡的检测图把排查方向全带偏了项目折腾了一个周末华为昇腾Atlas200DK上的CANN模型推理已经能跑通了模型转换成功、推理接口调用成功、张量也送进去了单帧耗时看着还像模像样。结果一看检测结果目标框一个都没有——不是框歪了不是错检了是真的“无检测结果”。这种事我估计踩过的朋友不在少数整个链路看起来处处正常唯独输出是空的。这个标题代表的问题说白了就是目标检测结果不对、无检测结果它往往是“推理成功但语义失败”。那你可能会问模型都推理成功了怎么还会检测不到目标因为目标检测的整个链路不只是调用一次模型推理模型转换阶段的AIPP配置、输入输出的规格、应用侧的图像预处理、读输出张量时的坐标解码和置信度阈值这些环节任何一处口径不一致最终的表现都是“没有框”或“框全错”。这篇文章就把我完整的定位过程写出来从现象定位、边界隔离开始到ATC转OM的参数核对、预处理和后处理代码逐行排查最后给出一套可落地的验证方法和自查清单。如果你也遇到底层推理正常、但目标框为空的情况按这套思路走下去大概率能快速锁定问题。如果是刚接触Atlas200DK和CANN目标检测这篇文章也能让你少走不少弯路。1. 现象还原推理能跑、目标框为空问题多半在跑通之后1.1 什么样的“无检测结果”属于这一类先明确一下问题边界。我遇到的“无检测结果”是这样的模型能正常转换。ATC工具执行成功生成om文件没有报算子不支持的错。应用侧推理流程正常。AscendCL初始化、模型加载、输入输出Buffer分配、执行aclmdlExecute都返回成功。日志里看不到明显错误。即使开了ASCEND_GLOBAL_LOG_LEVEL1也只是一些正常的info级信息。后处理代码跑完了但是返回的检测框数组长度永远是0。不是程序崩溃不是坐标越界纯粹就是置信度阈值过滤后一个框都没剩下。这种问题有个迷惑性大多数人第一反应是“模型坏了”或“板子不行”实际上绝大多数情况下模型和硬件都没问题出问题的反而是我们自己的“使用习惯”。1.2 把排查方向拆成三段而不是漫无目的地试我一开始也犯了“到处试”的毛病单独提高阈值、单独改输入尺寸、切换NPU模式折腾一天没效果。后来我把整个链路切成三块模型转换与输出ATC转OM时的输入规格、AIPP配置、输出节点是否和目标模型一致。应用侧预处理图像解码后的色序、Resize方式、归一化范围是否和模型训练时一致。应用侧后处理输出张量布局、类别数、坐标解码、置信度阈值是否匹配。后来证明这个切分方式是对的。下面先讲如何用最小工具把问题隔离到“模型侧”还是“应用侧”这是整套排查里最容易被跳过的关键一步。2. 边界隔离先用MSame把“模型侧”和“应用侧”切开2.1 准备三个对照工具和一组测试图排查目标检测空结果尽量不要一上来就改应用代码。先把“模型本身在NPU上能出什么结果”搞清楚再把“我自己的应用处理流程是否一致”搞清楚。我用到的工具和材料MSame华为昇腾上常用的OM模型命令行推理工具。它有独立维护的版本需要按CANN版本匹配。它的作用很简单加载om输入一张图片或bin文件输出模型各输出节点的原始张量数据不掺入任何后处理逻辑。Python3 numpy用来分析msame输出的bin文件看数值分布。ONNX Runtime如果手里有原始onnx模型可以在PC端跑同一个输入得到参考输出用来和om输出做对比。测试图我准备了三张一张目标很大很明显的比如一只猫填满半个画面、一张目标较小的密集场景、一张正常多目标场景。为什么要这样准备如果问题出在模型本身大目标图理论上应该更容易被检出来如果三张图全都检不出来大概率是链路性的问题而不是某张图恰好不合适。2.2 用MSame直接跑一遍OM模型看“裸输出”假设模型输入是1,3,640,640的RGB图像预处理好的bin文件已经准备好MSame命令大概是这样的msame --model ./model.om --input ./input.bin --output ./output_dir --outfmt BINMSame会为每个输出节点生成一个二进制文件文件名里会带上shape信息。这些bin文件就是NPU推理后的原始输出不经过任何坐标解码和NMS。这一步能拿到最关键的情报模型到底有没有检测到东西只不过检测到的信息被后面的后处理丢掉了而已。拿到bin文件后我用numpy快速统计每个输出张量的数值分布import numpy as np arr np.fromfile(./output_dir/xxx.bin, dtypenp.float32) print(shape guess:, arr.shape) print(min:, arr.min(), max:, arr.max(), mean:, arr.mean()) print(top10:, np.sort(arr)[-10:])这一步的判断很简单如果输出张量数值整体都很小比如最大值只有0.01那么问题很可能出在模型转换或预处理上。如果输出张量有正常的数值范围最大接近几十甚至更大且分布看起来不像全零填充那模型本身在NPU上是“有反应的”问题多半出在应用侧后处理或者应用侧预处理与模型期望不一致。如果输出全是NaN或0优先怀疑输入数据为空、Buffer没填充正确甚至模型转换时输出类型指定成了不匹配的精度。我在项目中第一次跑msame时就看到明显的输出数值从那里起就把排查方向锁定到了“应用侧怎么处理和解析输出”。2.3 确认推理并没有真正“成功得彻底”顺带还要确认一下模型输出的shape。很多目标检测模型会输出多层的特征图每一层的shape通常形如1,3,85,13,13或1,3,13,13,85这类。拿到msame的文件名之后先用size除以4字节再和理论输出大小对比看是否多了一个维度、少了一个维度。为什么这么做因为很多应用代码里的输出解析是写死了shape的如果模型在转om时output_shape动态化了输出维度的组织顺序可能和训练时不一样。后面第5章会重点讲输出布局问题这里先有一个直观判断即可。3. 模型转换期的暗坑ATC参数与AIPP配置对检测结果的隐形影响3.1 输入规格InputShape与动态维度ATC转换时--input-shape是最容易出问题的地方。比如训练时模型输入是1,3,640,640但转om时如果写了1,3,416,416而后面的预处理还是按640做的那推理执行时有可能直接报shape错误也有可能因为模型内部有动态shape而“强行走通”但结果完全错乱。建议转换前把输入规格做成固定值不要依赖动态维度。命令行大致长这样atc --model./yolov5s.onnx --framework5 --output./yolov5s --input_shapeimages:1,3,640,640 --soc_versionAscend310 --output_typeFP16注意Atlas200DK的芯片是Ascend310不同CANN版本可能允许用Ascend310P等要以实际环境为准。转换后生成的om你可以再查一下模型信息确认输入输出shape确实是预期值。如果目标是动态shape建议在应用侧也明确读一下模型实际的输入尺寸而不是自己写死。AscendCL里可以通过aclmdlGetInputSizeByName获取模型输入所需的大小这是一个很实用的兜底手段。3.2 AIPP开启后预处理逻辑就“半自动化”了但很容易被忽略AIPP是在推理阶段由硬件帮你做前处理的机制。它可以在模型转换时配置好静态AIPP也可以在应用运行的时候通过aiset传进去动态AIPP。配置内容包括色序转换、裁剪、缩放、减均值、除以标准差等。AIPP很方便但它有一个非常隐蔽的问题一旦在ATC时配置了AIPP硬件就会按AIPP配置处理输入数据你在应用侧再额外做一套预处理就会变成“重复加工”。比如模型训练时输入是RGB图且归一化到0~1如果你在ATC里配置了AIPP做归一化然后应用侧又把像素除以255再喂进去模型拿到的数据量级就差了255倍检测输出自然全部塌缩。我当时绕开AIPP的原因是为了链路透明度。在应用侧自己做预处理、不依赖AIPP虽然会多写几行代码但每个环节都可控可排查。如果你非要用AIPP记得把配置文件和ATC命令一起存档后面调试时随时能知道硬件到底对输入做了哪些改变。3.3 输出节点与输出类型不能想当然还有一个坑是输出节点。ONNX模型里可能有多个输出节点比如YOLO的3个检测头不同尺度的特征图会分别输出3个tensor。如果ATC转换时用--out_nodes显式指定了输出节点顺序搞错的话代码里按索引解析输出就会拿错数据。输出类型也需要注意。默认情况下OM输出可能是FP16或FP32MSame导出的bin可以按对应类型去读。如果代码里一直按float32去读float16的输出数值会变成不可理解的垃圾值。我在应用的代码里一般会显式读取模型的输出数据类型而不是直接假设float32。检查顺序建议 1. 确认模型输入shape和预处理后的数据shape完全一致。 2. 确认是否配置了AIPP配置了什么。 3. 确认输出节点的个数、顺序、类型。4. 预处理链路逐行对账RGB/BGR、Letterbox、归一化一个都不能错4.1 OpenCV读图后的颜色通道陷阱目标检测应用里最常用的图像读取库是OpenCV但OpenCV默认读进来是BGR而很多分类/检测模型训练时用的是RGB。如果模型输入期望RGB你拍脑袋直接拿OpenCV读到的BGR数据送去推理整个画面的颜色通道全部交换模型看到的颜色信息彻底错了。对于依赖颜色特征的检测模型来说漏检率会直线上升严重时一个框都没有。有人可能会说“我加上cvtColor转回RGB不就行了吗”确实可以但实际项目中我发现很多人加了cvtColor之后又在别的地方又转了一次导致双重转换又变回BGR。建议在预处理入口设置一个颜色通道断言打印首像素三通道值和直接用PIL读出来的结果对比确认一致再继续跑模型。4.2 Letterbox与直接Resize的差异YOLO系列很多模型在训练时用的是letterbox方式等比例缩放图像然后用灰色填充到目标尺寸避免目标被拉伸变形。如果你的推理预处理是直接cv2.resize压到640x640画面里的物体会被横向或纵向拉伸和训练数据的分布不一致。在大目标上可能还能勉强检出小目标基本直接报废。letterbox的典型代码import cv2 import numpy as np def letterbox(img, new_shape(640, 640), color(114, 114, 114)): shape img.shape[:2] r min(new_shape[0] / shape[0], new_shape[1] / shape[1]) new_unpad (int(round(shape[1] * r)), int(round(shape[0] * r))) dw (new_shape[1] - new_unpad[0]) / 2 dh (new_shape[0] - new_unpad[1]) / 2 if shape[::-1] ! new_unpad: img cv2.resize(img, new_unpad, interpolationcv2.INTER_LINEAR) top, bottom int(round(dh - 0.1)), int(round(dh 0.1)) left, right int(round(dw - 0.1)), int(round(dw 0.1)) img cv2.copyMakeBorder(img, top, bottom, left, right, cv2.BORDER_CONSTANT, valuecolor) return img如果模型训练时用了letterbox推理时不加检测不到是常态。相同道理如果模型训练时就是直接resize推理时你却加了letterbox也会有问题。这个“训练和推理口径一致”的原则是整个预处理里最重要的一句话。4.3 归一化的顺序和数值范围错一步置信度就会“群体沉底”目标检测模型的前处理里大多数情况下需要把0~255的像素抠到0~1或某个均方差归一化空间。YOLOv5官方的是“像素除以255”就完事但很多自己训练的模型会套用ImageNet的mean和std做标准化。这里有两个常见的错法先减均值再除以255。对0~255像素来说减均值后再除以255数值几乎都落到很小的区间模型输出置信度会整体塌陷。除以255后又减均值除标准差但mean/std是按0~255范围统计的。两者混用导致数据分布偏移。我建议老老实实按模型训练时的预处理方式一字不差地复刻如果不知道细节就先试试最简单的“像素除以255”跑通后再补mean/std。4.4 送进模型的张量Buffer大小和首元素都要抽查很多无检测结果的问题根源在输入张量根本没填对。AscendCL要求用户把数据拷到Device侧内存然后再填充到模型的输入buffer。如果填充的数据长度和模型输入的shape不一致或者拷贝了错误的内存区域模型可能因为某些优化算子容忍了错误shape而“跑完”但输出就不对了。推荐在第一次送图时做一次全链路校验预处理后的numpy数组shape应为(1,3,640,640)数据类型为float32。首元素值例如RGB第一个像素的红色通道值除以255应在0~1之间。Device侧数据大小等于1x3x640x640x4字节不能多不能少。模型输入tensor名称如果模型输入节点叫images用aclmdlGetInputNameByName确认避免填到了错误tensor槽位。这一套做下来预处理问题基本能暴露得七七八八。5. 后处理才是重灾区输出布局、类别数、置信度阈值与坐标解码5.1 先把推理结果原样导出再决定怎么解析这个问题我问过自己很多次我写的后处理是不是有问题但一直没找到证据。后来调整了方法不直接在C/Python代码里画框先把模型输出原样导成bin文件用numpy细致分析。这一步特别管用。导出后处理时需要关注的参数大概是这几组输出Tensor的维度和排列顺序。每个输出层是“85列”还是“5类别数”的格式哪个维度是类别概率哪个维度是objectness。坐标解码公式。NMS和置信度阈值。5.2 输出张量布局是哪一种排列方式YOLO在转OM后输出tensor的布局不一定跟你训练时一模一样。常见的有两种像1,3,13,13,85其中最后一个维度是85这种解析时比较方便。像1,3,85,13,1385维度被放到了第2维这种解析时需要强调先转transpose或者直接按维度索引处理。我见过不少代码写了固定索引比如output[0][0][i][j][4]当成objectness但如果模型输出实际是1,3,85,13,13这样取值就会拿到类别概率或其他值后处理全乱。解决方法是写一个“布局侦测”小脚本根据输出tensor的shape自动把85这个维度找出来然后判断它是在倒数第一维还是第二维。一个小技巧如果模型输出前已经sigmoid过objectness的值应该在0~1之间如果输出值很大几十、上百通常说明坐标解码前还需要自己做sigmoid。这两种情况直接决定了解码代码的写法。5.3 类别数写死是暗雷锚点参数更是另一个暗雷很多YOLO后处理代码在解析时写死的“85”其实是80类4个坐标1个objectness。如果你用的模型是自定义数据集只有1个类别或者10个类别输出维度就不是85比如6或者15。这时候你按85去解析位置数据全错后处理结果自然是空的。锚点参数同理。YOLO系列的每个检测头都对应一组anchors这套anchors是在训练时根据数据集特性算出来的。如果你换了个模型不更新anchors或者代码里写的是另一套模型的anchors即使前面的预处理、布局都对了解码出来的坐标也会离谱经过NMS后过滤得干干净净。我在排查时会把anchors打印出来和模型配置文件/训练脚本里的参数逐一比照。不要只看数值还要注意三个检测头的顺序有些模型是先小目标头的anchors在前换一个模型可能顺序相反。5.4 置信度阈值不是越大越好先用“阈值扫描法”找到模型的实际输出水平置信度阈值是很多“无检测结果”的直接原因。模型输出的objectness概率经过阈值过滤后如果只有一个非常自信的目标被滤掉整张图就没了框。更大的坑是很多模型在输入预处理和训练有偏差时objectness整体会偏移到0.01~0.1区间此时你把阈值从0.5降到0.3也出不来框非要降到0.01才能看到。所以我推荐做一次“阈值扫描”先把后处理代码里的置信度阈值临时改成0.001。统计每个检测头输出中objectness的最大值、中位数。观察在极低阈值下能输出多少候选框、坐标在图像范围内是否合理。根据模型正常水平逐步调回0.25或0.5。如果在0.001阈值下依然没有候选框那铁定不是阈值问题需要往前找预处理或布局问题。如果0.001下能出框但0.3下完全没有说明模型输出置信度整体偏低要么训练时分布如此要么预处理没对齐。另外NMS的iou阈值也容易背黑锅。iou阈值设成0.99可能导致两个本应合并的框互相竞争结果都被滤掉设成0.1又可能把很多重叠的目标误删。调试时我建议把NMS先关掉只看原始候选框确认有框后再开NMS。6. 修复验证与沉淀把“偶尔出框”变成“稳定出框”6.1 用ONNX/原始模型结果做一致性对比别让“出框”成为幸存者偏差当你终于看到图上出框了别急着庆祝还要验证这个框到底对不对。对比方法很简单在PC上用ONNX Runtime加载原始onnx模型用自己的预处理代码生成一张输入bin跑出PC端输出。在Atlas200DK上用msame跑同一个输入bin得到NPU端输出。对比两组输出的数值相似度。代码思路import numpy as np onnx_out np.load(onnx_out.npy) om_out np.fromfile(om_out.bin, dtypenp.float32).reshape(onnx_out.shape) for i in range(3): sim np.dot(onnx_out[i].flatten(), om_out[i].flatten()) / ( np.linalg.norm(onnx_out[i].flatten()) * np.linalg.norm(om_out[i].flatten()) 1e-6) print(layer, i, cosine sim:, sim)余弦相似度如果接近1说明模型转换本身没问题问题出在后处理或预处理方式如果余弦相似度偏差很大优先回查ATC转换参数、AIPP配置、输入数据是否一致。这个对比能有效避免一个情况框出来了但框的全是错的位置只是因为阈值低所以幸存了几个。没有基准的“出框”都是假阳性。6.2 搭建一套最小回归验证流程防止后续改动再次翻车目标检测在开发板上的调试有个特点每改一处预处理或后处理都要重新跑一遍全链路手工做太容易漏。我建议把这套验证固化成脚本准备3~5张测试图覆盖大目标、小目标、多目标、复杂背景。对每张图走“读取 - letterbox - 归一化 - bin导出 - msame推理 - 后处理 - 输出候选框和类别”整条流程。自动对比检测框数量、置信度、坐标范围。任何改动如果导致某一层输出和之前差异过大就自动报警。这套脚本花不了多少时间但后续换模型、换CANN版本、改预处理时都是“一键回归”的效果。空结果问题最怕的就是你修好了一个bug过两天改动另一处又把结果弄空但你没有感知。6.3 我踩完坑之后留下的排查对照表排查层次典型症状最可能原因快速验证方法修复方向模型转换MSame裸输出全为0或NaN输入shape/输出节点顺序不对检查ATC命令和输入bin首元素重新转OM并固定输入shape模型转换输出数值分布极窄AIPP预处理配置和训练不一致对比ATSAIPP配置文件统一AIPP参数或关闭AIPP在应用侧做预处理预处理三张测试图都检不出RGB/BGR通道错误打印首像素并和PIL对比加入cvtColor并避免重复转换预处理小目标漏检明显直接resize代替letterbox检查预处理代码是否有padding改用letterbox训练时同级方案预处理置信度整体偏低归一化逻辑与训练不一致阈值为0.001查看候选框数量按训练脚本复刻归一化步骤后处理无任何候选框输出tensor布局解析错用numpy检查85维位置写布局侦测脚本并适配维度后处理NMS后候选框为空IoU阈值过低或类别数写死临时关闭NMS观察原始框修正类别数、调低IoU阈值后处理有框但全是乱框anchors参数不匹配打印anchors比对模型配置更换为模型对应的anchors6.4 个人收尾一个小习惯帮我避开了绝大多数“空结果”最后分享一个习惯。每次拿到一个目标检测模型、准备往Atlas200DK上部署时我做的第一件事不是写完整后处理而是先在PC端用ONNX Runtime把模型跑通保存一份“标准输出”再拿着这份输出去校调CANN侧的预处理、模型转换和后处理。这样我在板端每改一步都能和PC端的正确结果对齐而不是等整条链路写完再面对一张空图。如果这个习惯你能坚持类似“无检测结果”的问题大概率在冒头阶段就能被定位。目标检测的空结果排查说到底就是三个字变量少。先把模型侧输出确认了再逐步还原预处理和后处理一层一层切开问题永远比你想的更有线索。