1. 先想清楚一件事:网络输出的"预测框"为什么不能直接用
做目标检测做到先验框解码这一步,说明你已经跑通了一个检测网络,至少是模型能够正常前向传播了。但这时候你大概率会遇到一个让人头疼的问题:网络输出了一堆张量,形状可能是[batch, num_anchors, 4],看起来是"预测框"的坐标,可你把它们画到原图上,要么框的位置完全对不上目标,要么框的尺寸离谱,要么整个图密密麻麻全是乱框。
先别急着怀疑模型训练出了问题,十有八九是你忽略了"解码"这一步。这也是很多新手从分类任务转到检测任务时最容易卡住的地方。分类网络输出的就是最终结果——一个 softmax 后的概率分布,你直接取 argmax 就能得到类别。但检测网络不是这么回事,它输出的通常是相对于先验框的偏移量,而不是最终的绝对坐标。
换句话说,网络告诉你的不是"这个框左上角在 (x1, y1),右下角在 (x2, y2)",而是"在预设好的一堆参考框的基础上,往右移动多少、往下移动多少、宽高放大或缩小多少倍"。这些参考框就是标题里说的"先验框",英文叫 anchors 或 default boxes。你必须把偏移量加到先验框上,才能还原出真正的预测框坐标。
这就像你问路,别人不告诉你目的地的经纬度,而是告诉你"从你当前位置往东走 200 米,再往南拐,然后爬一层楼"。如果你不知道出发点在哪里,光听后面的方向和距离根本没法定位。先验框就是这个出发点。没有解码,你拿着一堆"相对位移"去画框,当然全乱。
所以解码这件事,本质上就是完成一次坐标系的换算——把模型预测的"相对变化"映射回输入图像的"绝对位置"。这一步做错了,后面所有的置信度过滤、NMS(非极大值抑制)、mAP 评估全都会跟着错,而且错得毫无头绪。我先把这个核心问题说清楚,下面再展开公式和代码。
2. 解码的核心数学:从偏移量到真实坐标的四行公式
先验框解码的数学原理不复杂,但如果只看别人博客里的公式,很容易被各种符号绕晕。我把话说直白一点,你只需要记住四个变量的含义:
prior_boxes:先验框,通常是中心点坐标加宽高的形式,记作(cx, cy, w, h)。pred_delta:网络的回归输出,是解码前的偏移量,记作(dx, dy, dw, dh)。img_w, img_h:输入图像的宽和高。decoded_boxes:最终解码后的预测框,通常转换成(xmin, ymin, xmax, ymax)的角点形式,方便画框和计算 IoU。
解码公式分为两部分。第一部分是中心点的修正:
pred_cx = prior_cx + dx * prior_w pred_cy = prior_cy + dy * prior_h第二部分是宽高的修正:
pred_w = prior_w * exp(dw) pred_h = prior_h * exp(dh)然后按照角点形式组织:
xmin = pred_cx - pred_w / 2 ymin = pred_cy - pred_h / 2 xmax = pred_cx + pred_w / 2 ymax = pred_cy + pred_h / 2为什么要用exp而不是直接加?因为dw是网络输出的原始数值,它可以是正的也可以是负的,范围没有边界。直接用prior_w + dw会导致预测框宽度可能变成负数——这是物理上不可能的。而exp函数可以保证输出永远大于零,且当dw=0时exp(0)=1,网络输出零偏移就意味着宽度不变,语义恰好对齐。你把这个想成"橡皮筋拉伸"就行:exp把任意实数映射到正数区间,拉得越长,值越大,但永远不会变成负的。
中心点部分为什么是dx * prior_w而不是直接加dx?这取决于训练时对 ground truth 的编码方式。大部分检测框架在训练阶段是这样编码的:
dx_target = (gt_cx - prior_cx) / prior_w dy_target = (gt_cy - prior_cy) / prior_h dw_target = log(gt_w / prior_w) dh_target = log(gt_h / prior_h)解码公式就是编码过程的逆运算。所以你要记住一个至关重要的原则:解码方式必须和训练时的编码方式严格对应。如果你的训练脚本里在计算 loss 之前用的是别的编码方式,比如没有除以prior_w,或者没有取log,那么你推理时也必须照这样做反向操作,否则哪怕代码写得再漂亮,出来的框也全是错的。
我见过不止一次有人把开源模型下载下来直接推理,框画出来是歪的,最后发现是不同框架之间编码方式不一致。一个项目里训练阶段用的是中心坐标除以先验框宽高的方式,而推理代码却是按另一个仓库的实现写的,两者对不上,结果自然就崩了。这个"编码-解码对称性"是全文最重要的概念,没有之一。
还有一种解码方式也值得提一下:有些网络不输出偏移量,而是直接回归中心点和宽高的绝对坐标(比如某些版本的 YOLO 会直接输出(x, y, w, h),其中坐标可能经过了归一化)。这种情况下解码就更简单了,基本只需要做坐标范围裁剪。但主流的基于 anchor 的检测器——从 Faster R-CNN、SSD 到 RetinaNet——几乎都是偏移量回归的方式,所以上面这套公式是使用频率最高的,你无论如何都要先把它吃透。
3. 完整代码拆解:一个可直接复用的先验框解码函数
公式理清楚了,接下来就是动手写代码。下面我给出一个用 TensorFlow 实现的完整解码函数。这个函数我故意写得偏"容易看懂",没有做各种晦涩的维度压缩技巧,因为解码这种操作本质上就是个逐元素的算术运算,搞太多黑魔法反而容易出错。你先跑通,再谈优化。
3.1 输入与输出:函数签名设计
我先定义一个用于生成先验框的工具函数,因为解码时需要用到先验框的中心点坐标和宽高。假设我们约定先验框以归一化的方式存储——所有坐标都除以了图像宽高,取值范围在 0 到 1 之间——这样后续不管输入图像尺寸怎么变,都不需要重新生成先验框。解码时再把归一化坐标乘以真实的图像宽高就行了。代码如下。
import tensorflow as tf def generate_default_boxes( img_h, img_w, feat_h_list=(32, 16, 8, 4), feat_w_list=(32, 16, 8, 4), scales=(0.1, 0.2, 0.375, 0.5), ratios=(1.0, 2.0, 0.5, 3.0, 0.333), ): """ 为简单起见,这里只实现一个网格状铺满的先验框生成。 参数说明: img_h, img_w: 输入图像的原始高宽 feat_h_list, feat_w_list: 每个特征层的网格数量 scales: 每个特征层对应的基础尺度(相对图像宽度的比例) ratios: 宽高比列表 返回: Tensor,形状为 [num_anchors, 4],每行是 (cx, cy, w, h) """ default_boxes = [] for layer_idx, (fh, fw) in enumerate(zip(feat_h_list, feat_w_list)): scale = scales[layer_idx] for i in range(fh): for j in range(fw): cx = (j + 0.5) / fw cy = (i + 0.5) / fh w = scale h = scale default_boxes.append([cx, cy, w, h]) for ratio in ratios: # 保持面积大致相等,只调整宽高比 sqrt_r = tf.sqrt(ratio) w_ratio = scale * sqrt_r h_ratio = scale / sqrt_r default_boxes.append([cx, cy, w_ratio, h_ratio]) return tf.convert_to_tensor(default_boxes, dtype=tf.float32)这里我先不要急着追求多尺度、多纵横比的复杂策略,先保证先验框能覆盖整个图像平面。每一个特征图上的网格点中心,对应一个或多个先验框,这已经够用了。实际项目中你可能需要用到更精细的先验框配置,比如 SSD 论文里那样按特征层逐层设置不同的 scale 和 aspect ratio,但解码部分完全不受影响——你只需要把生成好的default_boxes喂给解码函数就行。
3.2 关键实现细节逐段说明
下面是解码函数本体。
def decode_boxes(pred_offsets, default_boxes, img_h=512, img_w=512): """ 先验框解码函数。 参数说明: pred_offsets : Tensor,形状 [batch_size, num_anchors, 4] 模型输出的原始偏移量,最后一维顺序为 (dx, dy, dw, dh) default_boxes : Tensor,形状 [num_anchors, 4] 先验框,最后一维顺序为 (cx, cy, w, h),归一化坐标 img_h, img_w : 原始图像的尺寸,用于把归一化坐标映射回像素坐标 返回: decoded_boxes : Tensor,形状 [batch_size, num_anchors, 4] 解码后的预测框,最后一维顺序为 (xmin, ymin, xmax, ymax) 注意:这里返回的是像素坐标,不是归一化坐标 """ # 1. 分离偏移量的各个分量 dx = pred_offsets[..., 0] dy = pred_offsets[..., 1] dw = pred_offsets[..., 2] dh = pred_offsets[..., 3] # 2. 分离先验框的各个分量 prior_cx = default_boxes[..., 0] prior_cy = default_boxes[..., 1] prior_w = default_boxes[..., 2] prior_h = default_boxes[..., 3] # 3. 按照公式解码中心点坐标(归一化空间) pred_cx = prior_cx + dx * prior_w pred_cy = prior_cy + dy * prior_h # 4. 解码宽高(归一化空间) pred_w = prior_w * tf.exp(dw) pred_h = prior_h * tf.exp(dh) # 5. 转成角点形式(归一化空间) pred_xmin = pred_cx - pred_w / 2.0 pred_ymin = pred_cy - pred_h / 2.0 pred_xmax = pred_cx + pred_w / 2.0 pred_ymax = pred_cy + pred_h / 2.0 # 6. 把归一化坐标映射回像素坐标 xmin = pred_xmin * img_w ymin = pred_ymin * img_h xmax = pred_xmax * img_w ymax = pred_ymax * img_h # 7. 堆叠起来,保持 [batch, num_anchors, 4] 的结构 decoded_boxes = tf.stack([xmin, ymin, xmax, ymax], axis=-1) return decoded_boxes值得解释的细节有这么几个。
第一,我用的是pred_offsets[..., 0]这种切片方式而不是pred_offsets[:, :, 0]。前者是 TensorFlow 的"可变维度"切片写法,前面的...代表所有前置维度,无论你的输入是三维[batch, num, 4]还是二维[num, 4],都能正确取出最后一个维度的某个分量。这样做的好处是函数泛用性更强,测试时你甚至可以只传单个样本的二维张量进去,不用刻意补 batch 维。
第二,先验框的宽高在这里是归一化的(相对于图像尺寸)。这样一来,解码公式里的dx * prior_w实际上是在归一化空间里算的,最后一步再乘回图像宽高。我刻意把乘回像素坐标的操作放在最后面,而不是一开始就用像素级先验框。原因很简单:如果你想换用不同分辨率的图像做测试,只要归一化先验框不变,解码函数就完全不用改,只改传入的 img_h 和 img_w 即可。如果你的先验框本身已经是像素坐标,那第 6 步就应该省略或者只对超出边界的部分做裁剪。
第三,堆叠时用了tf.stack(..., axis=-1),注意axis=-1意味着在最后一个维度把四个分量拼接起来,得到[..., 4]。很多新手容易在这里写成tf.concat(..., axis=-1)。两者有本质区别:tf.stack会新增加一个维度(比如把 4 个形状为[batch, num]的张量拼成[batch, num, 4]),而tf.concat要求所有张量在这一个维度上已经有值,只会把长度叠加(比如 4 个[batch, num, 1]拼成[batch, num, 4])。如果你在变量初始化时用了[batch, num]的形状且没有扩维,直接 concat 会报错或者产生意料之外的广播行为。我建议你写完之后可以故意测试一下这两种方式的差异,这对你理解 TensorFlow 的维度系统很有帮助。
3.3 在完整前向推理链路中的正确位置
光有解码函数还不够,你还要知道它该放在模型推理流程的哪个位置。一个标准的推理流程是这样的:
def predict(model, input_tensor, default_boxes, img_h, img_w): # 1. 通过主干网络提取特征 # 2. 通过检测头得到分类输出和回归输出 pred_logits, pred_offsets = model(input_tensor, training=False) # 3. 对分类输出做 sigmoid,得到置信度 pred_scores = tf.sigmoid(pred_logits) # 4. 对回归输出做先验框解码 decoded_boxes = decode_boxes(pred_offsets, default_boxes, img_h, img_w) # 5. 把解码后的框和置信度一起交到后处理流程(比如 NMS) final_boxes = nms(decoded_boxes, pred_scores) return final_boxes在这里最容易被忽略的是顺序:解码一定要在计算分类置信度之后、做 NMS 之前。NMS 需要用到预测框之间的 IoU,而 IoU 是在真实坐标空间里计算的。如果你拿着未解码的偏移量去算 IoU,得到的结果毫无意义。我见过有人图省事,想直接在归一化空间里做 NMS,然后输出的时候再转换,这也不是不行,但一定要保证所有的框都在同一个坐标空间里比较。最稳妥的做法还是一上来就解码成像素坐标,后面所有的操作都基于像素坐标进行,不做来回切换。
另外提醒一点:此处pred_scores的形状一般是[batch, num_anchors, num_classes],而decoded_boxes的形状是[batch, num_anchors, 4]。在进入 NMS 时,你需要对每个类别分别筛选置信度高于阈值的框。多数开源实现会先用一个min_score_thresh做一个粗筛,把大量低置信度的框快速过滤掉,只保留少量候选框参与 NMS。这一步对推理速度的优化非常明显,因为 NMS 的复杂度跟候选框数量有关,成千上万个框如果全都送去排序,耗时难以接受。
4. 验证解码结果:把先验框和预测框画在一起看差异
代码写完之后,你一定会问一个问题:我怎么知道我解码对没对?最简单也最直观的验证方法是画图。画图不是为了好看,而是为了让你能直观地看到每个预测框相对于先验框是怎么移动、怎么缩放的。
我通常会这样做:取一张测试图片,用训练好的模型跑前向,拿到解码后的预测框。然后把预测框和对应的先验框画在同一张图上。具体来说是让"每个先验框的中心和偏移后的预测框中心"用一条线连起来,这样你可以看到位移的方向和幅度是否符合直觉。
如果你发现先验框的中心和预测框的中心完全重合、没有任何移动,只有框的大小在变,那有可能是你的网络只学会了调整尺度而没有学好定位。反过来,如果预测框全部冲向图像的某个角落,那多半是中心解码公式的符号错了,或者训练标签编码时中心坐标没有减半处理。
更严谨一点,你可以在一个极小的测试集上,计算解码框与 ground truth 的 IoU。如果绝大多数样本的最高 IoU 都大于 0.5,说明解码基本正确。如果最高 IoU 低于 0.3,那么大概率是解码环节出的问题,而不是模型收敛问题。我用这个方法排查过很多次,十次里有八次能定位到 bug。
还有一个我特别推荐的小技巧:人工构造一个极端简单的输入。比如你把模型的回归输出全部强制为 0,即pred_offsets = tf.zeros_like(pred_offsets)。此时解码结果应该就是先验框本身的位置和大小。如果你的解码函数输出和先验框画出来完全重合,说明你的解码流程至少在"零偏移"情况下是自洽的。然后再把某一个分量改成固定值,比如让dx=1.0,那预测框的中心应该相对于先验框中心向右移动一个先验框宽度的大小。用这种方式逐分量验证,本质上是在做单元测试,能快速定位到底是哪一步算坏了。
用 TensorFlow 写这种验证代码一点都不复杂,核心就是把解码函数单独拎出来喂假数据。这个习惯我建议你从一开始就养成,不要等到训完模型再来查解码问题,那会非常痛苦。
5. 实际使用中的坑:尺度、批量、归一化,还有模块间约定
接下来这部分是我觉得最有价值的内容,因为全是实际调试中踩出来的经验。解码公式就四行,但真正落地到项目里,各种隐蔽问题接踵而至。
5.1 坐标尺度不统一:最常见的"隐形杀手"
这是我认为最隐蔽的坑。很多人训练完模型,loss 降得挺低,验证集 mAP 也能看,但一到画框就发现框偏了——不是偏一点,而是整个框画在了完全错误的位置。排查到最后发现,问题出在模型输出层和损失函数代码的坐标尺度约定不一致。
我遇到过一个情况:模型输出的回归量是"归一化到图像尺寸的偏移量",但训练时的 ground truth 是在resize之后的图上标注的(比如 300x300),而推理时却用了完全不同的尺度标准。有人可能会说,偏移量是相对的,跟绝对尺度无关呀——这话对了一半。中心点的偏移量确实是跟图像尺寸无关的,因为它是相对量;但宽高的 log 偏移量同样也是相对量。问题往往出在"归一化操作"是否真的执行了。
比如某个参考代码在数据预处理时,把 512x512 的图缩放到 300x300 之后再归一化坐标到一个 0-1 区间。而你为了图方便,直接用 512 作为img_w和img_h去解码。如果此时网络内部并没有归一化,所有坐标都是 300 量级的像素值,你把它当成 512 量级的去处理,最后画出来的框比例自然不对。处理方式其实很简单:写一个统一的坐标变换工具类,所有模块都从同一个地方获取img_h和img_w,不要在每个环节都自己写一遍乘除法。
5.2 批量维度处理:容易翻车的隐式广播
TensorFlow 的广播机制很强大,但也容易在批量维度上出问题。假设你有batch_size=4的输入,pred_offsets形状是[4, num_anchors, 4],而default_boxes形状是[num_anchors, 4]。直接做prior_cx = default_boxes[..., 0]会得到一个[num_anchors]的一维张量,和dx = pred_offsets[..., 0]这个[4, num_anchors]的二维张量做乘法时,你会得到一个[4, num_anchors]的结果吗?
答案是可以的,TensorFlow 会把[num_anchors]广播成[4, num_anchors]。但如果你不小心把default_boxes的形状搞成了[1, num_anchors, 4],然后取[..., 0],得到的是[1, num_anchors],再和[4, num_anchors]做乘法,也能广播成功,因为末尾维度对齐。真正的坑在于,如果你的先验框生成逻辑里有某个reshape操作不小心把维度交换了一下,比如变成了[num_anchors, 1, 4],那么取[..., 0]得到[num_anchors, 1],和[4, num_anchors]相乘时,这两种形状是无法广播的,会直接报错。
虽然报错比静默出错要好,但更隐蔽的情况是:先验框形状是[num_anchors, 1],偏移量是[4, num_anchors],因为num_anchors恰好等于 4 的倍数,有可能以一种非常扭曲但"合法"的方式对齐,结果算出来的框每个位置都错。所以我建议在解码之前先加一行断言:
tf.debugging.assert_equal(tf.shape(dx)[-1], tf.shape(prior_cx)[-1])这个操作不费多少性能,但能帮你尽早发现维度错位。我一直认为,调试维度问题最大的成本是"发现不对"的过程,而不是"改代码"的过程。提前加断言、加形状打印,能让调试效率翻倍。
5.3 先验框数量的一致性:模型结构改了,解码忘了更新
这个问题我在做实际项目时踩过不止一次:你换了一个更强的 backbone,或者调整了特征金字塔的层数,网络输出的num_anchors数量变了。但是你的先验框生成代码还停留在旧的配置上,导致解码时pred_offsets和default_boxes第一维不同。TensorFlow 会直接崩溃,这还好。怕的是你只改了主干网络,特征图尺寸没变,但每个位置的 anchor 数量变了(比如从 5 个改成了 9 个),此时num_anchors恰好可以整除,于是乘法能通过维度检查,但语义上每个位置对应的先验框已经错乱了,画出来的框自然也是乱飘。
解决这个问题的最好方法是:把先验框生成也纳入模型定义的一部分,让模型结构变化时先验框数量自动跟着变。举个例子,你用特征金字塔的话,每一层的输出特征图长宽是确定的,你可以根据特征图的尺寸动态生成先验框,而不是在模型外硬编码一套数值。这样至少能保证先验框数量和模型输出的数量天然一致。
我通常会在模型初始化后打印一行日志,例如 "Total number of anchors: 8732",如果这个数值和预期不符,说明某个环节的配置同步出了问题。这个日志有时能救命。
5.4 与后处理模块的"通信协议":谁来裁剪、谁来归一化
最后再说一个我在团队协作中发现的问题:解码函数输出的是像素坐标,但 NMS 模块内部很多实现为了计算方便,会先把坐标归一化到 0-1 区间再算 IoU。如果解码模块输出之前自己裁剪了一遍超出图像边界的框,而 NMS 模块又在归一化后在 0-1 区间外做裁剪,两边标准不一致,结果就可能在边界框的处理上产生细微偏差,让 mAP 掉那么零点几个点。
我的建议是:解码函数只做坐标变换,不做任何裁剪操作。裁剪边界、过滤超小框这些逻辑,统一放在后处理流程里做。这样每个模块的职责单一,出了问题也容易定位。你甚至可以给整个检测 pipeline 定义一个明确的数据约定:解码之后的数据结构是一个字典,包含boxes和scores和classes三个字段。所有人都按这个约定来对接,检查代码时一眼就能看出哪个环节越权了。
6. 配合训练阶段的编码逻辑:保证前后端对称
前面反复强调过"编码-解码对称性",这一节我想更系统地讲一下,因为这是很多半路出家的开发者最容易忽略的地方。你在训练脚本里写损失函数,通常需要把 ground truth 编码成和网络输出一致的偏移量形式才能算 loss。编码代码往往长这样:
def encode_boxes(gt_boxes, default_boxes): """ gt_boxes : 形状 [num_gt, 4],格式为 (xmin, ymin, xmax, ymax),像素坐标 default_boxes : 形状 [num_anchors, 4],格式为 (cx, cy, w, h),归一化坐标 """ # 把 gt 转成中心点形式 gt_cx = (gt_boxes[:, 0] + gt_boxes[:, 2]) / 2.0 gt_cy = (gt_boxes[:, 1] + gt_boxes[:, 3]) / 2.0 gt_w = gt_boxes[:, 2] - gt_boxes[:, 0] gt_h = gt_boxes[:, 3] - gt_boxes[:, 1] # 只保留与 gt IoU 最大的那些先验框(此处省略匹配逻辑) # 假设已经拿到了匹配结果 matched_priors, matched_idxs dx = (gt_cx - prior_cx) / prior_w dy = (gt_cy - prior_cy) / prior_h dw = tf.math.log(gt_w / prior_w) dh = tf.math.log(gt_h / prior_h) return dx, dy, dw, dh解码公式和编码公式必须是一对逆运算。如果你在训练时用了(gt_cx - prior_cx) / prior_w,那解码时就必须是prior_cx + dx * prior_w。如果你不小心在解码时写成了prior_cx + dx * 0.1或者别的缩放系数,除非你训练时也用了同样的 0.1 系数,否则结果必然对不上。这个对称性的含义用一句话概括就是:解码是编码的逆函数,同增同减,一一对应。
为了确保对称,有一个非常取巧的做法:把编码和解码函数放在同一个 Python 文件里,并且互相对着写。每次改编码逻辑时,就强迫自己看一眼前面对应的解码逻辑。我甚至会写一个roundtrip测试——随机生成一些框,先编码再解码,看还原出来的坐标跟原始坐标的误差是否接近于 0。这种测试能覆盖大部分坐标变换 bug,写一次能用很久。
7. 进阶思路:为什么解码要和 NMS 联动,以及怎么提速
讲完了基础解码和避坑,最后聊聊一个稍微进阶的话题。在实际的检测推理流程中,解码往往不是整个 pipeline 的瓶颈,但它被调用的频率很高,尤其是num_anchors特别多的模型(比如 SSD 在 300x300 输入下有 8732 个先验框,RetinaNet 甚至更多)。如果你在循环里对每张图都调用一次解码,而解码过程中包含了大量的tf.exp和tf.stack,堆叠操作在某些计算图上会产生不少额外开销。一个常用的提速小技巧是把解码函数用tf.function包起来:
decode_boxes_tf = tf.function(decode_boxes, input_signature=[ tf.TensorSpec(shape=[None, None, 4], dtype=tf.float32), tf.TensorSpec(shape=[None, 4], dtype=tf.float32), tf.TensorSpec(shape=[], dtype=tf.int32), tf.TensorSpec(shape=[], dtype=tf.int32), ])用input_signature显式声明输入张量形状,可以让 TensorFlow 在第一次调用时生成一个优化计算图,后续调用不再走 Python 的调度开销。这个方法可以在模型推理的批量场景中省掉不少微秒级别的延迟。需要注意的是,如果你在解码之后马上接tf.image.non_max_suppression,有时候可以把tf.exp、tf.stack的中间结果在 GPU 上保留,减少一次 CPU-GPU 之间的数据拷贝。这种优化效果在小 batch 上最明显,大 batch 反而影响不大,原因在于数据拷贝的开销在总量中占比会下降。
另外一个和 NMS 联动的角度是:解码之后你可能需要对成千上万个框做置信度阈值过滤。如果把过滤操作也放到 GPU 上先执行一遍,只把少量候选框拷贝回 CPU 做 NMS,速度会快很多。现在的 TensorFlow 有tf.image.combined_non_max_suppression,可以直接把解码、置信度过滤、多类别 NMS 一次性封装起来。但我不建议你一开始就用这么高级的封装,等基础版流程跑通了,再考虑逐步替换,这样每一步都可以用肉眼验证输出结果是否合理。
最后提醒一个容易忽略的实际问题:当 batch size 大于 1 时,先验框的解码结果数量是固定的,但每张图经过过滤后保留下来的框数量可能不一样。NMS 对这种变长结果的处理很麻烦,通常需要 padding 到统一长度。如果你在 Decode 并过滤之后直接把结果送入tf.image.non_max_suppression,TensorFlow 要求框的数量是固定的,这时你需要给过滤操作设置一个max_output_size,并额外生成一个valid_detections掩码,标记哪些位置的框是真正有效的。我在实际项目里就是踩过这个坑,才意识到一个简单的解码函数,一路走到后处理,中间有这么多细节要处理。
先验框解码本身不难,但它和前后模块的对接非常微妙。我的经验是:一次性把"编码-解码-NMS-评估"整条链路跑通并验证清楚,比单独把解码函数做得再花哨都重要。以后你在自己的项目中无论换什么模型结构、什么输入尺寸,只要这条链路的坐标约定不变,解码逻辑就永远不用动。真到了需要动的那一天,记得看一眼对应的编码代码——把两个逆操作放在一起对比着改,比对着公式记忆要可靠得多。