接 LingBot-Vision 到已有视觉流水线,真正容易出错的不是模型调用本身,而是两侧对张量格式和输出字段的理解不一致:上游按老模型的习惯送 BGR、0-255,LingBot-Vision 可能按 RGB、0-1 接收;老代码拿到的是 xywh 归一化框,新模型吐的是 xyxy 像素框。稳妥的做法是先把契约冻结下来,再写一层适配把差异收口,最后才动调用点。这样上游预处理和下游后处理不需要同时改,出问题时也只需要回查一处。
适用场景:已有视觉流水线要换用 LingBot-Vision,但张量格式、坐标系、输出字段尚未对齐。处理动作:先以现有接口为基准写出输入输出契约表,再写一层适配函数承接转换与字段映射,用同一批图做新旧输出对照,确认无系统性偏移后才替换调用点。验证方式:契约表逐项对照,对照脚本输出字段级差异清单,切换后看日志中的形状、空结果条数和异常计数。风险边界:若新模型本身缺少老模型具备的输出字段(例如某类掩码),适配层无法凭空补齐,需要在契约里显式标注为不支持。
把现有流水线的输入输出契约写下来
不要一上来就读新模型文档,先记录现在这条流水线的边界。以现有接口为基准写契约表,好处是替换时有一个稳定的参照物,而不是两边各改一点、最后对不上。需要记录的内容包括:输入形状、数值取值范围、通道顺序、批次维度含义、调用时序,以及输出的每个字段名、维度、坐标系和编号体系。
- 输入形状:是
(N,C,H,W)还是(N,H,W,C),是否含 batch 维,H/W 是否固定。 - 取值范围:
uint8 0-255、float32 0-1,还是按 ImageNet 均值方差归一化后的值。 - 输出字段:框、分数、类别、掩码分别叫什么,是否同一个数组打包返回。
- 调用时序:同步还是异步,是否允许并发,输入尺寸变化是否需要重建会话。
建议把契约写成一段可执行的结构定义,而不是只写在文档里。下面是一份通用骨架,字段名可按实际项目调整:
# contracts.py —— 两侧共用的契约定义
from dataclasses import dataclass
import numpy as np
@dataclass
class DetInput:
images: np.ndarray # (N, H, W, 3) uint8, RGB, 0-255
orig_sizes: list # [(w, h), ...] 原图尺寸,用于坐标回投
@dataclass
class DetOutput:
boxes_xyxy: np.ndarray # (M, 4) float32, 原图像素坐标, 左上原点
scores: np.ndarray # (M,) float32
labels: np.ndarray # (M,) int64, 沿用现有 label_map 编号
调用时序的记录格式建议统一成一行结构化日志,至少包含:请求 id、输入形状、dtype、通道顺序、耗时毫秒、输出条目数、异常类型。这样对照阶段可以直接拿日志做差集,不需要靠人工回忆。
对齐输入张量的格式与取值范围
确认两侧预处理差异落在哪一步,比笼统地说“格式不对”更有用。常见差异集中在三处:通道顺序、归一化参数、尺寸缩放方式。把它们列成对照项逐条比对,差异清单会自然浮现。
- 通道顺序:现有流水线可能是 OpenCV 读图的 BGR,而 LingBot-Vision 通常按 RGB 解释。顺序错了不会报错,只会让颜色相关的判断整体偏移。
- 归一化:是否需要除以 255,是否再减均值除标准差,均值方差取自哪一组常量。先用同一张纯色图跑一遍,看输出分数是否稳定,能快速暴露归一化问题。
- 尺寸缩放:等比缩放加 padding,还是直接拉伸。若做了 padding,输出坐标回投时必须先减去 padding 再除以缩放比,这一步经常被漏掉。
差异清单建议写成三列:对照项、现有流水线取值、LingBot-Vision 侧取值(按实际接口确认后填写)。凡是无法确认的项,先在适配层里做成可配置参数,用日志打印实际生效值,不要凭猜测写死。运行一次后从日志中确认,再决定是否收紧成常量。
写一层适配代码隔离两侧差异
适配层的作用是让替换只影响一个文件。上游仍然按老契约送 DetInput,下游仍然按老契约读 DetOutput,中间所有转换都在适配函数里完成。函数签名建议固定,内部实现随接口替换。
def lingbot_detect(inp: DetInput, params: dict) -> DetOutput:
# 1) 张量转换:与第 2 节对照表保持一致
x = inp.images.astype(np.float32) / 255.0 # TODO 按实际归一化参数替换
x = x[..., ::-1] # BGR 转 RGB, 若上游已是 RGB 则删除
x = np.transpose(x, (0, 3, 1, 2)) # NHWC -> NCHW, 按实际接口确认
# 2) 调用:替换成真实 SDK 入口
raw = lingbot_client.infer(x, **params) # TODO 替换为实际调用方式
# 3) 字段映射:按实际返回结构替换
boxes = raw["boxes"] # TODO 若为 xywh 需转 xyxy
scores = raw["scores"] # TODO 确认字段名与维度
idx = raw["classes"] # TODO 确认编号体系
labels = np.array([LABEL_MAP.get(int(i), -1) for i in idx])
# 4) 坐标回投到原图:若做了缩放或 padding, 在此处还原
boxes = rescale_boxes(boxes, inp.orig_sizes) # TODO 按实际缩放方式替换
return DetOutput(boxes_xyxy=boxes, scores=scores, labels=labels)
四处 TODO 就是需要按实际接口替换的位置:归一化参数、调用入口、返回字段名、坐标还原方式。适配层里不要混入业务逻辑,也不要顺手改阈值或 NMS 参数,否则对照阶段会分不清差异来自模型还是后处理。
用同一批图做新旧输出对照
对照样本建议覆盖几类:纯色或低纹理图、目标密集的小图、目标极大的图、目标贴近边界的图、以及历史中出现过误检的图。数量不必多,几十张即可,关键是每类都要有,且固定下来不复用其他用途。
比较指标建议选可计数的项,而不是笼统的“效果更好”:
- 框数量差异:同一张图上新旧输出的条目数是否一致。
- 匹配率:按 IoU 阈值做配对后,未匹配上的框有几条。
- 类别不一致数:配对成功的框里,标签不同的有几条。
- 分数差值:配对框的分数差的分布范围,关注是否存在整体性偏移。
- 坐标差值:边界框四个坐标的绝对差,重点看是否有固定量级的系统性平移,那通常指向 padding 或缩放还原错误。
差异超出预期时,按这个顺序回查:通道顺序 → 归一化参数 → 尺寸缩放与 padding → 坐标回投 → 置信度阈值与 NMS 参数 → 标签映射表。前四项属于契约问题,改适配层即可;后两项属于后处理参数,需要单独确认是否本来就不一致,不要用调阈值的方式掩盖契约错误。
固定契约后替换调用点
对照通过后再动调用点。替换步骤建议拆成小步:先把原调用点复制到一个新函数保留为旧路径,再让业务代码通过配置项选择走旧路径还是适配层,跑一段时间后把旧路径标记为待删除,确认无回滚需求再清理。
- 在配置中增加开关,例如
vision.backend = legacy | lingbot,默认保持legacy。 - 把调用点改为读取配置后分支调用,业务层其余代码不动。
- 灰度放开,观察日志中的输入形状、dtype、输出条目数、异常计数和耗时分位值。
- 确认稳定后,把默认值切到新路径,旧路径保留一个可回滚的提交点。
回滚方式就是配置项改回旧值,或者回退到切换前的提交。前提是适配层不删、旧调用点不删,所以这两处要等到观察期结束再清理。
切换后的观察项建议包括:每个请求的输入形状是否符合契约、空结果条数是否突然增多、类别分布是否与切换前明显不同、异常日志中是否出现形状不匹配或 dtype 报错。这些项都能从日志和配置直接核对,不依赖主观判断。只要其中一项出现持续异常,先回滚到旧路径再定位,不要在线上直接调参数试探。