LingBot-Vision 输出跟预期对不上 / 是预处理还是后处理没对齐?

文章导读
推理能跑完、结果却和预期对不上时,先别动权重,也别急着重训。多数偏差落在两处接缝上:进网络之前的预处理(通道顺序、归一化、尺寸变换),和出网络之后的后处理(阈值、坐标还原、类别映射)。这两类错误的表现很像,都是框偏、类别错、置信度怪,但验证动作完全不同,混在一起改只会互相干扰。可行的做法是把输入张量和输出张量各打印一次,再用“一次只改一个变量”的对照实验,把预处理和后处理分开定性。
📋 目录
  1. 一 固定一张图和一个随机种子复现问题
  2. 二 通道顺序与归一化参数逐项对照
  3. 三 打印输出张量的统计量判断是否合理
  4. 四 后处理解码:阈值、坐标还原、类别映射
  5. 五 对照实验:一次只改一个变量看结果怎么动
A A

推理能跑完、结果却和预期对不上时,先别动权重,也别急着重训。多数偏差落在两处接缝上:进网络之前的预处理(通道顺序、归一化、尺寸变换),和出网络之后的后处理(阈值、坐标还原、类别映射)。这两类错误的表现很像,都是框偏、类别错、置信度怪,但验证动作完全不同,混在一起改只会互相干扰。可行的做法是把输入张量和输出张量各打印一次,再用“一次只改一个变量”的对照实验,把预处理和后处理分开定性。

适用场景:模型能加载、推理能跑完,但可视化结果与预期不一致。判断方向是先确认可复现性,再按“输入张量 → 原始输出张量 → 解码结果”三段对齐,而不是先怀疑权重。操作动作:固定一张图与随机种子,逐项对照通道顺序、归一化、尺寸变换,再核对解码与坐标还原。验证方式:两次运行输出一致,且解码后的框能对回原图坐标。风险边界:两次运行不一致时,先解决随机性再谈对齐。

固定一张图和一个随机种子复现问题

先把输入固定:选一张内容明确、目标清晰的图,存到一个固定路径,后面所有实验都用它。然后关掉推理链路里的随机源,常见的有:模型没切到 eval 模式(dropout、BN 仍在按 batch 统计)、推理时仍带随机数据增强或 TTA、多尺度多裁剪投票、后处理里的随机采样。用固定种子加显式关闭开关:

import random, numpy as np, torch

def seed_everything(seed=0):
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
    torch.cuda.manual_seed_all(seed)
    torch.backends.cudnn.deterministic = True
    torch.backends.cudnn.benchmark = False

# 推理前
seed_everything(0)
model.eval()

两次运行一致性的验证方法:把后处理之前的原始输出存成 npy,或者打印张量的 shape、min、max,再用 allclose 比较两次结果。如果两次不一致,先固定 cudnn 行为、固定线程数,再继续后面的对照。边界在于:GPU 上个别算子本身存在非确定性,若差异只出现在小数点后很多位,通常可以接受;但如果结果的结构都变了,说明随机源没关干净,此时任何对照结论都不可信。

通道顺序与归一化参数逐项对照

输入端的偏差一般来自三处,逐项对,不要一起改。

通道顺序

读图库的默认顺序并不统一,OpenCV 常见 BGR,PIL 常见 RGB。检查方法可以造一张只有三种已知颜色的图,或打印一个已知像素的三通道值,确认进入网络的第一维到底是 R 还是 B。

img = cv2.imread(path)              # 默认 BGR
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
print('after convert, pixel(0,0) =', img[0, 0])

归一化均值方差

均值方差的来源应当是训练侧的预处理配置,不是凭经验填。需要拿训练 config、导出脚本或模型说明里的预处理定义来对照,同时确认两点:mean/std 的通道排列顺序是否与你当前的通道顺序一致;是否除以过 255。这两项是分开的开关,容易漏。

x = img.astype(np.float32) / 255.0
mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
std  = np.array([0.229, 0.224, 0.225], dtype=np.float32)
x = (x - mean) / std
x = x.transpose(2, 0, 1)[None, ...]
print('input tensor', x.shape, float(x.min()), float(x.max()), float(x.mean()))

尺寸变换

确认是直接 resize 还是保持长宽比加填充(letterbox),填充值、插值方式也要和训练侧一致。把这一步算出来的缩放比例和 padding 记下来,第 4 节的坐标还原要用。

打印输出张量的统计量判断是否合理

要区分“权重或加载不对”还是“后处理解错了”,最省事的办法是在 forward 之后、任何激活与 NMS 之前,把原始输出打印出来。

LingBot-Vision 输出跟预期对不上 / 是预处理还是后处理没对齐?
with torch.no_grad():
    out = model(x)

outs = out if isinstance(out, dict) else {'out': out}
for k, t in outs.items():
    print(k, tuple(t.shape), t.dtype,
          float(t.min()), float(t.max()), float(t.mean()))
    print('nan:', bool(torch.isnan(t).any()),
          'inf:', bool(torch.isinf(t).any()))

重点看四个观察点:形状是否与导出说明一致,batch 维、锚点维、类别维有没有搞反;dtype 是 float16 还是 float32,有没有意外的整数类型;取值范围是否已经落在 0~1(说明网络内含激活,后处理里不能再套一次 sigmoid);极值分布里是否出现 NaN、Inf,或者整张张量几乎只有一个值。最后一种情况通常指向输入没真正送进网络,或者权重根本没加载上。

后处理解码:阈值、坐标还原、类别映射

阈值

先把置信度阈值临时调低(例如 0.1 这个量级,具体取值以你的配置为准),看框的整体位置对不对。如果位置对、只是数量偏多,往上调阈值就行;如果低阈值下位置也偏,问题在坐标还原或输入对齐,不在阈值。

坐标还原

网络输出的坐标通常是相对“送进网络的那张图”(已 resize 或 letterbox)的归一化值或像素值,还原到原图需要减 padding、除缩放比例:

scale = min(new_w / orig_w, new_h / orig_h)
pad_x = (new_w - orig_w * scale) / 2
pad_y = (new_h - orig_h * scale) / 2

x1 = (x1 - pad_x) / scale
y1 = (y1 - pad_y) / scale
x2 = (x2 - pad_x) / scale
y2 = (y2 - pad_y) / scale

把还原后的框画回原图做肉眼比对,是最直接的验证。整体平移说明 pad 的加减方向反了;整体缩放不对,检查 scale 用的是取整后的输入尺寸还是原始浮点尺寸。

类别映射

把索引和名称打印成对照表,核对映射表是否与训练数据集一致。常见问题是映射表里多了一个 background 占位,导致整体错位一位,表现为“框对了、类别全错”。

对照实验:一次只改一个变量看结果怎么动

把可疑变量列成清单,每次只改一个,其余保持不动,逐行记录变化。变量清单和对照记录表可以这样组织:

变量改法预期变化实际变化判定
通道顺序RGB ↔ BGR 互换颜色相关的框/类别明显变化待填变化明显则预处理未对齐
归一化去掉或更换 mean/std置信度整体偏移待填同上
尺寸变换resize ↔ letterbox框位置整体偏移待填结合坐标还原一起看
激活加/去一次 sigmoid置信度分布形态改变待填重叠则后处理多算了一层
阈值调低或调高框数量增减,位置不变待填位置也变则不是阈值问题
坐标还原改 pad 加减或 scale框回到目标上待填命中则问题在还原公式
类别映射整体偏移一位类别名称对得上待填命中则映射表有占位类

结论的判定条件可以简化为几条:只改通道顺序或归一化之后结果接近预期,说明是预处理没对齐;输入张量与参考实现一致、只改解码就修正,说明是后处理没对齐;输入和输出张量都一致但渲染结果仍不对,优先怀疑类别映射或坐标还原顺序;所有单项都试过仍无改善,回到第一节确认可复现性,再考虑权重是否加载正确、模型与当前任务是否匹配。