这是我在做本地化部署的客服自动回复工具时踩到的坑。整套链路跑在内网一台机器上:OCR 读微信聊天窗口顶部的标题栏,拿会话对象的名字做归属校验,名字对得上,机器人才允许代客服回消息。日均处理几百场会话,上线半个多月一直平稳,直到有客服反馈,"李文宇"这个会话开始漏回消息,机器人像没看见一样。

现象:一个字的偏差,整场会话被拒

日志很快给出线索:OCR 读出的标题栏文本是"李文字",而联系人真名是"李文宇"。归属校验按字符串精确匹配,一字之差,整场会话被判为"归属不明",自动回复直接跳过。这是当初定的保守策略——宁可漏回,不能回错,漏回可以报警补回,回错没法撤回。

先排除了名字库写错和客服改昵称两种可能:名字库里"李文宇"三个字一字不差,联系人资料页显示的昵称也没变过。再让那位客服把窗口拉大、切换几次会话再切回来,误读依旧。问题收敛到 OCR 环节:标题栏里的"宇",被认成了"字"。

有意思的是,这不是整条链路第一次出小字问题。之前手机端一些字号更小的界面元素也被读错过,当时靠正则白名单兜住了,没往深处查。这次不一样——"李文字"这个串在名字库里恰好也存在,说明字符串层面完全合法,白名单兜不住这种错,必须把根因挖出来。

对照实验:合成 36 个样本,锁定崩法

要回答的问题是:这是偶发抖动,还是系统性误读?

我写了个脚本,复现标题栏的截图条件,合成 36 个样本做对照——不同背景色、不同抗锯齿渲染相位、成对出现"宇"和"字"的联系人名,还混了几个形近组做陪跑(比如"峰/锋"),喂给同一套 OCR 流程。之所以用合成样本而不是直接截图,是因为截图条件不受控:窗口大小一变、DPI 一变,字渲染出来就不一样,变量太多。合成可以一次只动一个变量。

结果很干净:

  • 带"字"的样本识别恒对,一次没错过;
  • 原生分辨率档里带"宇"的样本,识别分落在 79 一档,错例集中崩向"字";
  • 陪跑的"峰/锋"组在同样条件下表现正常,说明不是所有小字都糊,问题特定在"宇/字"这组笔画差异上;
  • 同一样本连续截多张图,只改截图的取像相位,误读跟着相位漂移——说明问题不在内容,而在像素级采样。

顺带把 det 检测框和 rec 裁剪两路的日志对了一遍:det 框定位标题栏文字没有偏,裁剪区域也踩得准,两路单看都"正常"。这排除了框漂移,把嫌疑集中到识别环节本身。

三层根因:小字、偏置、没放大

先说字本身:墨芯只有 12px。 标题栏字号 12px,"宇"和"字"的差别只在宝盖头底下那几笔——"宇"是"于","字"是"子",而"于"和"子"在 12px 下本来就长得像。低分辨率下,这几笔的墨芯加起来不到三个像素宽,抗锯齿一糊,两个字形在像素层面几乎收敛成同一个图案。相位差实验正好从侧面印证:换一个采样相位,糊的位置就变。可以说这组字天生就踩在小字号识别的坑底。

再看模型:不对称的崩向。 识别日志里有个很典型的不对称:把"宇"认成"字"的错例,置信度是 0.84;而真"字"样本的置信度恒为 1.000。原因不难猜——rec 模型的训练语料里"字"的出现频率远高于"宇",笔画证据不足时,模型塌向先验更强的那个字。好认的字置信度拉满,难认的字反而给出一个"看着还不错"的分数。0.84 搭配 1.000,想靠置信度阈值拦截,两头都拦不住:阈值设高,0.84 的错例漏过去;设低,一堆本该信的正确结果被反复打回人工。这个不对称后来成了我们审核 OCR 结果时的一条经验:分数高不等于可信,高频字的满分尤其值得多看一眼。

后看链路:裁剪框恒取原始分辨率。 这是真正可以在工程上补的洞:rec 识别阶段直接按 det 框在原始截图上抠图,整条链路没有一处对小字区域做过放大或插值,12px 的字就以 12px 的原始状态进了识别网络。前两层是先天条件——字形本身的像素就不够,模型先验又偏向错的那一边;这一层是流程缺陷,属于"本可以做但没做"的那类。三层叠在一起,误读就成了必然事件,只是等一个高频字的会话来触发。

修复:窄带 2× 放大,坐标 ÷2 映射回去

修复思路很克制:不换引擎、不重训模型,在 det 之后、rec 之前,对高度低于阈值的窄带区域做 2× 放大,识别完把坐标除回去。插值选 Triangle(双线性),对小字够用,开销也小。为什么不是 4×?试过,收益递减,还会放大噪点,推理耗时也上去了;2× 已经能把"宇"和"字"的墨芯拉开可分的距离。

# 伪代码:窄带区域 2x 放大 + 坐标回映
UPSCALE    = 2
MIN_LINE_H = 20   # 像素,低于该高度按小字窄带处理

def recognize_regions(frame, det_boxes):
    results = []
    for x, y, w, h in det_boxes:
        crop = frame[y:y+h, x:x+w]
        if h < MIN_LINE_H:
            big = resize(crop, (w*UPSCALE, h*UPSCALE),
                         interp=TRIANGLE)          # Triangle 双线性插值
            text, conf, rel = rec(big)             # 在放大图上识别
            rel = [(px/UPSCALE, py/UPSCALE)        # 坐标 ÷2 映射回原图
                   for px, py in rel]
        else:
            text, conf, rel = rec(crop)
        results.append(((x, y, w, h), text, conf, rel))
    return results

改完重跑那 36 个样本:"宇"组识别全对,"字"组保持恒对,没有引入新的回归。同时加了兜底:放大识别链路一旦异常(裁剪越界、rec 超时之类),降级为跳过该会话的自动回复并记日志告警,不吞异常、不装无事发生——漏回可以补,装作正常才是大问题。

复盘:三条经验

  1. 警惕满分置信度。不对称崩向意味着"低置信度过滤"的盲区恰好落在高频字上——难字给 0.84,高频字给 1.000,真正该怀疑的反而分数漂亮。校验环节后来补了形近字表(宇/字、已/己、土/士一类),匹配时把表内字视为等价候选,再结合备注、昵称等上下文字段二次确认。
  2. det 和 rec 不是一条链路。定位准不代表识别拿到的图是对的,中间的裁剪与缩放策略值得单独审计一遍。
  3. 小字问题优先在图像侧解决。与其在文本后处理里猜错字,不如在进识别网络之前把像素喂够。

这类会话归属问题在客服自动化里很典型,名字错一个字,校验链路就整段失效。之前整理过两篇相关文章,一篇讲微信自动回复机器人如何像真人一样接住会话,一篇拆微信 AI 客服的成本构成,一并附在文末。

参考文章

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐