我上一次认真折腾验证码识别,是帮一个做后台自动化的团队收拾残局。他们自建的管理系统在登录页加了四位图形验证码,原本跑了大半年的回归脚本一夜之间全挂,几十条用例红成一片,测试同学只能人工盯屏幕敲字,一天下来眼睛发花。他们最初的想法是"上个大模型",我拦住了——这类老式四位字符验证码,根本用不上那么重的东西,一套预处理加一个几百KB的轻量模型,在自建测试环境里就能把整串准确率做到九成五以上。
这篇东西写给三类人:被验证码卡住自动化流程的工程同学、需要批量处理表单截图里数字字母的开发者、以及想拿一个真实小项目练手图像识别的初学者。我会把从"看清敌人"到"服务化上线"的完整链路拆开讲,包括每一处为什么这么选、参数为什么这么定、以及在自建系统上实测时踩过的坑。所有内容都限定在自有或已获得明确授权的系统范围内使用,这一点我放在后面单独说,因为它比任何调参技巧都重要。
1. 先分清你面对的是哪一类验证码,再谈用什么方案
很多人的第一步就走偏了:拿到一张图,直接pip install一个识别库,跑出来是乱码,就断定"识别库不行"。真实情况是,验证码的技术形态跨度极大,从三十年前的老古董到现在的行为风控,压根不是一个技术栈能覆盖的。你不先给敌人分类,后面所有的选型和调参都是盲打。
1.1 五类验证码的技术难度不在一个量级
我习惯按"图像里承载的信息形态"来分类,而不是按它长什么样。字符型验证码是图像里直接印着答案;算术型是印着一个需要计算的表达式;滑块是答案藏在两张图的差异里;点选是把答案藏在一组图标的位置排序里;行为类则根本没有静态答案,答案在你的鼠标轨迹里。这五类的解法完全不同。
| 类型 | 典型形态 | 可行技术路线 | 相对难度 | 备注 |
|---|---|---|---|---|
| 字符型(无干扰) | 四位数字、字体规整 | 模板匹配 / 通用开源识别库 | 低 | 几行代码就能跑通 |
| 字符型(扭曲+干扰线) | 字符倾斜、有贯穿线、噪点 | 预处理 + CNN/CRNN 自训练 | 中 | 训练样本量决定上限 |
| 算术型 | 3+7=?、8×4=? | OCR 取表达式 + 安全求值 | 中低 | 求值环节别用eval |
| 滑块拼接 | 缺口图 + 背景图 | 边缘检测 + 模板匹配定位 | 中 | 定位准了,轨迹另说 |
| 图标点选 | "请依次点击图中的 XX" | 目标检测 + 语义匹配 | 高 | 涉及文字理解,链路最长 |
我见过有人拿字符识别的模型去啃滑块缺口,折腾三天没结果,本质上就是分类错了。你手里的图属于哪一行,决定了你后面是写二十行代码还是写两千行代码。
1.2 三个问题决定技术路线
选型的时候我只问三个问题,基本能锁定方向。第一个是字符集有多大——纯数字 10 类,数字加小写字母 36 类,数字加大小写 62 类,如果还混了汉字,那类别数直接上百甚至上千。类别数越大,对模型容量和样本量的要求越高,一个 62 类的模型想做到高准确率,训练样本起码要往五位数走。
第二个是字符之间有没有粘连和重叠。这一条直接决定"要不要做字符分割"。不粘连的图,你可以走"先切分、再单字符分类"的老路子,简单粗暴;一旦粘连严重,切分算法会频繁切错,这时候就必须上端到端模型,让网络自己去学对齐关系,绕开分割这个环节。
第三个是你能不能稳定拿到样本。这一点最容易被忽略。如果验证码来自你自己正在维护的系统,你可以写个脚本从测试环境里批量抓几千张,样本管够;如果样本只能偶尔拿到几十张,那自训练模型就是空中楼阁,老老实实用通用识别库加模板匹配更现实。我在项目里见过太多"模型方案很美、样本只有 80 张"的尴尬局面。
1.3 一个反直觉的结论:先试通用库,别急着训练
我给团队的默认建议是:任何字符型验证码,先花二十分钟试通用开源识别库,再决定要不要自训练。市面上那几个常见的轻量识别库,内部集成的就是针对验证码场景裁剪过的 CNN,开箱即用,不需要你做任何预处理,对规整的四位字符验证码经常能给出七成到九成的整串命中率。接口也高度一致:初始化引擎、传入图片字节或图像对象、拿回字符串。
先跑通用库有三个好处。一是能立刻给出一个"基线数字",后面你自训练的模型如果没有明显超过这个基线,说明你的训练投入是亏的;二是能快速暴露预处理问题,比如你的图是 RGBA 格式、带透明通道,通用库一进去就报错,这个问题在自训练流程里同样会存在;三是省时间,很多场景下通用库加上业务侧重试就够用了,压根不需要训练。
真正需要自训练的信号只有两个:通用库在你这类图上整串准确率长期卡在三成以下,或者你的图有非常特殊的干扰模式(特定颜色的贯穿线、特定字体的强扭曲),通用库的通用特征学不到。这两种情况才值得投入一到两周去做数据。
2. 图像预处理:识别率上不去,八成问题出在这一步之前
我在排查识别失败案例时有个习惯:先把失败样本的中间图全部存下来,灰度图、二值图、去噪图各存一份,然后肉眼过一遍。十次里有八次,问题在二值化这一步就已经注定了——要么笔画被腐蚀断成几截,要么干扰线和字符糊成一坨,模型再强也救不回来。预处理不是"顺手做一下"的环节,它决定了信息熵的上限。
2.1 灰度、二值化与自适应阈值怎么选
灰度化没什么争议,按亮度加权取通道就行,0.299R + 0.587G + 0.114B,OpenCV 的cvtColor默认就是这个系数。真正要动脑子的是二值化。
import cv2 import numpy as np def load_bgr(path: str) -> np.ndarray: # 用 imdecode 而不是 imread,能避开中文路径读不出来的老问题 buf = np.fromfile(path, dtype=np.uint8) img = cv2.imdecode(buf, cv2.IMREAD_COLOR) if img is None: raise ValueError(f"图片解码失败: {path}") return img def to_binary(img_bgr: np.ndarray): gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) # 方案一:全局 OTSU,适合背景干净、光照均匀的图 _, otsu = cv2.threshold( gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU ) # 方案二:自适应阈值,适合背景有渐变、有水印阴影的图 # blockSize 必须是奇数,取值约为字符笔画宽度的 3~5 倍 # C 是偏移量,越大越"激进",越多像素会被判成前景 adaptive = cv2.adaptiveThreshold( gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, blockSize=15, C=10 ) return otsu, adaptive这两个方案我自己用下来的经验是:先看背景。背景纯白或纯色、没有渐变,直接 OTSU,它自己会找一个全局最优阈值,省心且稳定;背景带渐变、有阴影、或者从截图上抠下来的图边缘发灰,就必须用自适应阈值。blockSize这个参数我一般从 15 起调,字符笔画细就往下调到 11 或 9,笔画粗就往上加到 21。C从 10 起调,如果二值图里字符缺胳膊少腿,把C调小;如果噪点太多,把C调大。别指望一组参数通吃,同一个系统的验证码换了字体,你就得重新调一遍。
还有一个坑:THRESH_BINARY_INV和THRESH_BINARY选反了,前景背景颠倒,后面的连通域分析和分割全部失效。我的习惯是统一用INV,让字符变成白色(255)前景,因为 OpenCV 的形态学操作、连通域统计默认都按"白是前景"来理解。
2.2 干扰线与噪点:通道分离往往比滤波更有效
新手碰到干扰线,第一反应是中值滤波、高斯模糊、开运算一路往上堆。我试过,效果经常是干扰线没去掉,字符笔画先被削细了,最后模型学到的是残缺的字形。真正好用的一招是通道分离——很多验证码的字符是单一颜色的(比如深蓝),干扰线是另一种颜色(比如红色或灰色),在某个颜色通道上,干扰线会自然变淡甚至消失。
def pick_best_channel(img_bgr: np.ndarray) -> np.ndarray: """在 B/G/R 三个通道里挑干扰最少的一个。 判据:二值化后前景像素总数的方差最小, 即前景分布最"整"、最像连成片的字符,而不是满屏碎点。""" best_ch, best_score = None, -1 for ch in cv2.split(img_bgr): _, bw = cv2.threshold( ch, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU ) # 用连通域数量做粗糙打分,碎片越少得分越高 num, _, stats, _ = cv2.connectedComponentsWithStats(bw, connectivity=8) areas = [stats[i, cv2.CC_STAT_AREA] for i in range(1, num)] big = [a for a in areas if a > 30] score = len(big) - len(areas) * 0.05 if score > best_score: best_score, best_ch = score, ch return best_ch这段打分逻辑很土,但在实际项目里意外地好用。它的思路是:字符是连成片的,干扰线是细长的,噪点是零散的,所以"面积较大的连通域数量"能粗筛出干扰最少的那一个通道。如果三个通道都不干净,再补一步形态学开运算,用 3×3 的核就够了,别用 5×5。
去噪点我更推荐按面积过滤,而不是用滤波。二值化之后跑一遍连通域统计,把面积小于阈值(比如 20 像素)的连通域直接置黑,这一步比中值滤波精准得多,因为它不会伤到主笔画。
2.3 字符粘连:投影法、连通域与"干脆不分割"
分割这件事,我的态度是"能不分就不分"。分割是整条链路里最脆弱的一环,切错一个字符,后面的分类再准也是错的。
如果非要分割,先从最简单的垂直投影法开始:把二值图按列求和,得到一个一维数组,字符所在的列和值大,字符间的空隙和值接近零,找空隙就能切。
def split_by_projection(binary: np.ndarray, min_gap: int = 2): col_sum = (binary > 0).sum(axis=0) gaps, in_char = [], False for x, v in enumerate(col_sum): if v > 0 and not in_char: start, in_char = x, True elif v == 0 and in_char: gaps.append((start, x)) in_char = False if in_char: gaps.append((start, len(col_sum))) # 合并间距过小的相邻段,减少碎片 merged = [] for seg in gaps: if merged and seg[0] - merged[-1][1] < min_gap: merged[-1] = (merged[-1][0], seg[1]) else: merged.append(seg) return merged投影法在字符横向不重叠时几乎必成,但一旦字符有倾斜或者相邻字符笔画咬合,投影会因为"某一列同时有前后两个字符的像素"而切出错误边界。这时候换成连通域分析会更稳一些,connectedComponentsWithStats直接给你每个连通块的包围盒,按横坐标排序就得到字符位置。
但对扭曲严重的图,这两种方法都会失效,因为一个字符可能被拆成两个连通块,两个字符可能粘成一个块。我现在的做法是:对扭曲类验证码直接放弃分割,走端到端模型。这一步"放弃",反而是准确率提升最大的一步,具体在下一节展开。
3. 传统路线与端到端模型:两条路各自能走多远
技术路线这件事,我不信"新的一定比旧的好"。传统模板匹配在某些场景下快得离谱、准得吓人,端到端模型在另一些场景下才是唯一解。关键是你得知道自己站在哪一边。
3.1 模板匹配与 Tesseract 的适用边界
模板匹配的前提非常苛刻:字体固定、字号固定、无旋转、无缩放。满足这四条,它的准确率可以做到接近百分之百,而且推理速度快到微秒级。做法也简单,把每种字符的模板图存下来,待测图切分成单字符后归一化到统一尺寸,逐个算归一化相关系数,取最高分。
def match_template(char_img: np.ndarray, templates: dict): char_img = cv2.resize(char_img, (32, 48), interpolation=cv2.INTER_AREA) best_char, best_score = None, -1.0 for ch, tpl in templates.items(): tpl = cv2.resize(tpl, (32, 48), interpolation=cv2.INTER_AREA) score = cv2.matchTemplate( char_img, tpl, cv2.TM_CCOEFF_NORMED ).max() if score > best_score: best_score, best_char = score, ch return best_char, best_score模板从哪来?从你已有的样本里人工挑出每个字符各做三到五张"干净"的模板,字体的细微变化靠多模板覆盖。这套方案在字体固定的内部系统里非常好用,模板库总共也就几十KB。
Tesseract 这类通用 OCR 引擎我一般不太推荐用在验证码上。它被设计来识别排版规整的文档文字,对扭曲、粘连、小尺寸的验证码字符天然不友好。你可以通过--psm 7(单行文本)模式加上字符白名单来改善,但实测下来投入产出比不高——调半天参数,不如直接上一个专为验证码训练的轻量网络。
3.2 CRNN + CTC 的最小可用实现
端到端方案里,我最常推荐的是 CRNN 结构:卷积层提特征、循环层建模序列、CTC 做免对齐的损失计算。它最大的好处是不需要标注每个字符的位置,你只需要告诉模型"这张图的答案是 8f3k",剩下的对齐它自己搞定。这对人工标注来说省掉了百分之九十的工作量。
模型本身不复杂,一百多行能写完:
import torch import torch.nn as nn class CRNN(nn.Module): def __init__(self, num_classes: int, hidden: int = 128): super().__init__() def block(ci, co): return nn.Sequential( nn.Conv2d(ci, co, 3, 1, 1), nn.BatchNorm2d(co), nn.ReLU(inplace=True), nn.MaxPool2d(2, 2), ) # 输入约定: (B, 1, 32, W) self.cnn = nn.Sequential( block(1, 64), # 32xW -> 16xW/2 block(64, 128), # 16xW/2 -> 8xW/4 nn.Conv2d(128, 256, 3, 1, 1), nn.BatchNorm2d(256), nn.ReLU(inplace=True), # 高度压到 1,宽度不压,保留时间步 nn.MaxPool2d((2, 1), (2, 1)), # 8xW/4 -> 4xW/4 ) self.rnn = nn.LSTM(256, hidden, num_layers=2, bidirectional=True, batch_first=True) self.fc = nn.Linear(hidden * 2, num_classes) def forward(self, x): f = self.cnn(x) # (B, 256, 4, W/4) b, c, h, w = f.shape f = f.permute(0, 3, 1, 2).reshape(b, w, c * h) # (B, T, 256*4) f, _ = self.rnn(f) return self.fc(f) # (B, T, num_classes)这里有个非常关键的尺寸问题,我第一次写的时候就在这里翻车:最终输出的时间步 T 必须大于等于标签的最大长度。上面的结构里 T = W/4,输入宽 128 时 T 只有 32,看着够用,但如果你的验证码是六位甚至八位,再考虑到 CTC 要求相邻重复字符之间必须有空白符(比如答案是88,CTC 需要8, blank, 8三个时间步),T 就有点紧张了。我一般的做法是把输入宽度设到 160 以上,或者把最后一次池化的宽度也压一半,让 T 更充裕。T 不够时的典型症状是训练 loss 一直降不下去,或者模型永远输出比答案短的串。
训练部分用标准的 CTC 损失:
ctc_loss = nn.CTCLoss(blank=0, zero_infinity=True) logits = model(batch_x) # (B, T, C) log_probs = logits.log_softmax(2).permute(1, 0, 2) # (T, B, C) loss = ctc_loss(log_probs, targets, input_lengths, target_lengths)blank=0是约定,第 0 类必须是空白符,字符表从第 1 类开始。zero_infinity=True强烈建议打开,否则一旦某个样本的 T 小于标签长度,loss 会变成 inf,整个 batch 的梯度都被污染。
推理时用贪心解码就够,不需要束搜索:
def greedy_decode(logits: torch.Tensor, idx2char: dict): pred = logits.argmax(dim=2) # (B, T) results = [] for seq in pred.tolist(): chars, prev = [], -1 for idx in seq: if idx != prev and idx != 0: chars.append(idx2char[idx]) prev = idx results.append("".join(chars)) return results解码逻辑的核心是两步合并:先把连续重复的索引压成一个,再去掉空白符。顺序不能反,反了就会把88压成8。
3.3 样本从哪来:采集、标注与划分的工程细节
模型结构是公开知识,真正决定成败的是数据。我在自建系统上采集样本的做法是:从测试环境批量请求登录接口,把返回的验证码图片连同正确答案一起落盘。因为是自己的系统,答案在服务端 session 里就有,采集成本几乎为零,一晚上能攒几万张。这一步的关键是保证样本的真实分布——不要只采早上八点的图,也不要只采同一个账号的图,因为很多系统在不同时段、不同会话下的干扰强度是不一样的。
标注环节,我的经验是能自动化就别手动。如果你能拿到生成端的答案,那就直接文件名即标注,格式我习惯用{答案}_{序号}.png,用下划线分隔,解析时按最后一个下划线切。人工标注的话,四位字符一张大约三到五秒,一万张就是十个小时,成本不低,所以只对模型判错的难例做人工复核更划算。
数据划分上有个细节很多人忽略:验证集必须和训练集来自不同的时间段。如果随机打乱划分,同一批生成的相似样本会同时出现在训练和验证里,验证准确率虚高。我一般是按采集日期切,用前 80% 的日期做训练,后 20% 做验证,这样测出来的数字才接近线上真实表现。
4. 把整串准确率从七成推到九五成的几个动作
基线模型跑通之后,准确率通常停在六七成,这个阶段最容易让人灰心。我做过统计,从七成到九成五的提升,八成来自数据侧的动作,两成来自推理侧的技巧,模型结构改动的贡献反而不大。别一上来就换更深的网络,先把下面几件事做扎实。
4.1 数据增强要贴着干扰特征做,不是越多越好
数据增强的目的是让训练分布覆盖线上可能出现的形态,不是刷指标。所以增强方式必须对着你的验证码特征来设计,我一般按这个顺序过一遍:
| 增强手段 | 参数范围 | 针对的线上情况 |
|---|---|---|
| 随机旋转 | ±8 度 | 字符整体倾斜 |
| 随机仿射/透视 | 幅度 0.02~0.05 | 截图变形、屏幕拍摄 |
| 弹性扭曲 | 网格 4×4,幅度 1~2 像素 | 波浪形扭曲字符 |
| 高斯噪声 | 标准差 3~8 | 压缩噪声、截图失真 |
| 亮度/对比度抖动 | ±20% | 不同显示器、不同光照 |
| 随机遮挡(Cutout) | 1~3 个 4×4 方块 | 干扰线遮挡笔画 |
| 颜色抖动 | 饱和度 ±30% | 不同主题色 |
注意最后一条颜色抖动。如果你的模型输入是灰度图,颜色抖动没意义;但如果你把三通道直接喂进网络,颜色抖动就很有价值,因为能防止模型过度依赖某个颜色通道。
弹性扭曲这一项我要单独说,它是提升扭曲类验证码准确率最猛的一招,但也是最容易用错的一招。幅度设到 3 像素以上,字符会被扭成一团,模型反而学不会。我一般从幅度 1 开始,每轮训练观察验证集,涨了就保持,不涨就加 0.5,加到头就停。
另外提醒一句:增强不要串太多。旋转 + 透视 + 弹性 + 遮挡同时上,一张图会被糟蹋得连人眼都认不出,模型学到的是噪声。我通常固定"旋转 + 亮度抖动"作为基础增强,再从弹性、遮挡、噪声里挑一到两个按概率叠加,每个的概率控制在 0.3 到 0.5。
4.2 难例回流与置信度拒识
模型上线之后,最值钱的资产是它判错的那些图。我在服务里加了一个抽样逻辑:每次推理都记录模型输出的平均置信度,低于阈值(比如 0.85)的图,连同原图一起存到难例目录里,人工快速复核对错,错的直接进训练集。这一套跑上两三轮,验证集准确率往往能再涨三到五个点,因为难例恰好覆盖了线上真实存在的边缘形态。
置信度的计算很直接,把 softmax 之后每个时间步的最大概率取平均:
def sequence_confidence(logits: torch.Tensor) -> torch.Tensor: prob = logits.softmax(dim=-1) # (B, T, C) max_prob, _ = prob.max(dim=-1) # (B, T) return max_prob.mean(dim=1) # (B,)拒识策略就是在这个基础上做的:置信度高于 0.95 直接采用,0.85 到 0.95 之间采用但记录日志,低于 0.85 直接返回"识别失败"让业务侧重试。这个策略看起来损失了一部分可能正确的识别,但换来了一个很关键的性质——输出错误答案的概率大幅下降。在自动化流程里,一个错误答案引发的问题远比一次重试严重得多。
还有一个小技巧是 TTA(测试时增强),同一张图做三份轻微不同的变换各推理一次,取置信度最高的那份结果。实测能涨一到两个点,代价是推理耗时三倍。如果对延迟不敏感,值得开。
4.3 后处理:字符集约束与业务校验
后处理是最便宜的准确率提升手段,因为它不需要训练,纯规则。核心思路是把"模型的输出"和"业务上合法的答案"对齐。
import re # 常见形近字符的映射表,按你的实际字体调整 CONFUSE_MAP = {"O": "0", "o": "0", "D": "0", "I": "1", "l": "1", "i": "1", "S": "5", "s": "5", "B": "8", "Z": "2", "z": "2"} def postprocess(text: str, charset: str = "0123456789") -> str: text = text.strip().replace(" ", "") if set(charset) <= set("0123456789"): text = "".join(CONFUSE_MAP.get(c, c) for c in text) text = re.sub(r"[^0-9]", "", text) return text这段逻辑的价值在于:纯数字验证码里根本不可能出现字母 O,模型把 0 认成 O 是常见错误,映射回去就对了。同样的道理,如果你的答案长度固定是四位,识别出五位直接判失败让业务重试,比强行截断安全得多。
算术型验证码的后处理要单独说一句。识别出3+7=?之后要做求值,千万别用eval。哪怕输入是你自己识别的,模型也可能吐出各种奇怪字符,eval一旦执行到非预期内容就是安全事故。正确做法是用正则把数字和运算符抽出来,再用显式的字典映射计算:
import re OPS = {"+": lambda a, b: a + b, "-": lambda a, b: a - b, "x": lambda a, b: a * b, "×": lambda a, b: a * b, "*": lambda a, b: a * b} def solve_arithmetic(text: str): m = re.search(r"(\d+)\s*([+\-x×*])\s*(\d+)", text) if not m: return None a, op, b = int(m.group(1)), m.group(2), int(m.group(3)) fn = OPS.get(op) return fn(a, b) if fn else None这种写法既安全又可控,运算符白名单之外的字符一律判失败。
5. 服务化:从单张脚本到能扛并发的识别接口
单机脚本跑通和线上服务可用,中间差着一大截。我见过太多"实验室准确率九十八,上线 QPS 只有 3"的项目。这一节讲的是把它做成一个别人能放心调用的服务,需要处理哪些事。
5.1 封装、预热与批量推理
先说延迟。CPU 上跑一个小型 CRNN,单张推理通常在 20 到 60 毫秒之间,这个数字看着还行,但如果你用 PyTorch 直接做在线推理,第一次请求会慢到几百毫秒甚至几秒,因为模型加载、算子初始化、内存分配都在第一次发生。解决办法是服务启动时做一次预热:拿一张全零的假图跑十次推理,把各类缓存都唤醒。
import numpy as np import onnxruntime as ort class CaptchaEngine: def __init__(self, model_path: str): self.sess = ort.InferenceSession( model_path, providers=["CPUExecutionProvider"] ) self.input_name = self.sess.get_inputs()[0].name self._warmup() def _warmup(self, times: int = 10): dummy = np.zeros((1, 1, 32, 160), dtype=np.float32) for _ in range(times): self.sess.run(None, {self.input_name: dummy}) def predict_batch(self, batch: np.ndarray): # batch: (N, 1, 32, 160) float32 归一化到 [0, 1] logits = self.sess.run(None, {self.input_name: batch})[0] return logits用 ONNX Runtime 而不是原生 PyTorch 做推理,是我在这些小模型上的固定选择:模型文件小、依赖少、启动快,CPU 上还能吃到算子融合和线程池的优化,实测比原生 PyTorch 快 1.5 到 2 倍,而且不需要装一整套深度学习框架。
批量推理的收益也很明显。CPU 上单张 40 毫秒,batch=8 的时候总耗时往往只有 120 毫秒左右,摊到每张 15 毫秒。如果你的业务场景是批量处理(比如离线跑一批已经保存下来的截图),务必攒批。但在线接口不要盲目攒批,因为攒批本身引入等待,延迟会变得不可预测,一般设一个很小的等待窗口(比如 5 毫秒)就够了。
5.2 缓存、限流与降级
缓存这件事,前提是"同一张图可能被重复识别"。验证码一般是一次性的,图片不重复,所以缓存命中率通常很低。但有一个场景例外:调试和回放。我在做效果复盘时会把同一批图反复跑,这时候用图片内容的哈希做 key 存到本地或 Redis,能省掉大量重复计算。
import hashlib def image_key(img_bytes: bytes) -> str: return "captcha:" + hashlib.md5(img_bytes).hexdigest()限流是必须的。识别服务往往被上游的批量任务调用,一旦上游脚本写错成了死循环,你的服务瞬间被打满,CPU 跑满之后所有请求都超时。我一般用令牌桶限流,单实例设在 CPU 核数乘以 10 QPS 左右,超出的请求直接返回 429 让上游退避。
降级策略要提前设计好。识别服务不可用时,业务侧应该能退回"人工输入验证码"或者"跳过这一步"的兜底路径,而不是整个流程卡死。这一点我在项目里强调过很多次:任何自动化环节都必须有一条人工兜底的路,否则识别服务就是整个流程的单点。
5.3 监控指标怎么定,样本怎么回流
监控我只盯四个数:整串准确率、拒识率、P99 延迟、调用量。准确率靠人工抽检或者用带答案的样本回放来算,每周跑一次;拒识率是置信度低于阈值被拒的比例,它突然升高通常意味着上游换了验证码样式;P99 延迟反映长尾,比平均值有用得多;调用量的突增则要警惕上游异常。
样本回流是个闭环:线上被拒识的图、置信度低的图、以及业务侧反馈"识别错了"的图,全部落到一个待复核目录,人工快速过一遍,对的留作正样本加深模型信心,错的进训练集。我一般每周做一次,每次挑三五百张,重训一轮,验证集过了就替换线上模型,不过就回滚。这个循环跑起来之后,模型的衰减会被有效抑制——验证码不会一成不变,你的模型也不能。
6. 我踩过的几个坑,以及一条必须先说清楚的边界
前面讲的都是"应该怎么做",这一节讲"我当初怎么错的"。有些错看起来很低级,但在真实项目里几乎人人都会犯一次。
6.1 训练与推理预处理不一致
这是最隐蔽的坑。训练的时候我把图 resize 到 32×160、归一化到 [0,1];写推理脚本时图省事,直接喂了原始尺寸的图,模型照样输出结果,准确率从九成五掉到三成。排查了半天才发现问题,因为模型不报错,它只是默默给你错误答案。
我的解决办法是把预处理和推理封在同一个类里,训练脚本和线上服务都调这同一个类,绝不各写一套。同时加一个断言,检查输入的形状和数值范围:
def check_input(x: np.ndarray): assert x.ndim == 4 and x.shape[1] == 1, f"维度不对: {x.shape}" assert x.shape[2] == 32, f"高度应为 32,实际 {x.shape[2]}" assert 0.0 <= x.min() and x.max() <= 1.0, "数值范围应在 [0,1]"这几行断言在调试阶段救过我至少三次。
还有一个同类问题更隐蔽:训练时用的是cv2.imread读出来的 BGR,推理时前端传过来的是 PIL 读出来的 RGB。如果你的模型输入是灰度图,这个问题不存在;一旦是彩色输入,通道顺序反了就是一个难以发现的精度损失。
6.2 评估指标选错,模型看着很美
我早期犯过一个错:用"字符准确率"评估模型,结果显示 98%,兴冲冲上线,结果整串命中率只有六成出头。原因很朴素——四位的验证码,字符准确率 98% 意味着每个字符有 2% 出错,四位全对的概率是0.98^4 ≈ 0.92,听着还行,但如果字符准确率是 90%,整串就只有0.9^4 ≈ 0.66了。字符准确率会系统性地高估你的模型。
所以评估指标只有一个:整串全对率(sequence accuracy)。模型输出和标签完全逐字符相等才算对。这个指标才对应业务上的真实体验,因为验证码错一个字符就是全错。
顺便提一句训练时的观察指标。CTC 训练的 loss 曲线通常降得很顺,但 loss 低不代表整串对,因为 CTC 允许对齐偏移。我习惯每训练两个 epoch 就在验证集上跑一次整串准确率,看这条曲线而不是 loss 曲线来判断要不要停。
6.3 使用边界:只用在自己有权限的系统上
这一条放在最后,但优先级最高。识别验证码这件事,技术上是图像识别,本质上是绕开一个安全机制。所以它的合法使用范围是有明确边界的。
我自己的原则很简单:只在我有权限的系统上做。具体包括三类——自己开发或维护的系统、本单位明确授权用于自动化测试的测试环境、以及明确提供了自动化接口或测试白名单的第三方系统。超出这个范围,无论技术多成熟我都不碰。这不是技术问题,是基本的职业判断。
实际操作中还有个更省事的思路:很多麻烦其实不用硬解。自建系统的测试环境,通常可以让后端加一个开关,测试环境关闭验证码或者固定成0000;第三方系统做集成测试,通常可以申请测试账号和沙箱环境。我跟团队说过很多次,能通过沟通解决的,不要靠技术硬啃——前者十分钟搞定且零风险,后者要投入两周还留一堆隐患。
只有在确实拿不到开关、又必须在自己系统上做批量回归的情况下,才回到前面那套技术方案。而且我的建议是:即使识别成功了,业务侧也要保留人工兜底路径,不要让整个流程的成败系在一个准确率九成多的模型上。
最后分享一个我自己用得很顺手的小习惯。每次做这类项目,我会在项目根目录下建一个failures/目录,任何一次推理失败(无论是低置信度被拒、还是业务侧反馈错误),都把原图和模型的原始输出存进去,文件名带上时间戳和置信度。这个目录积累到两三百张的时候,几乎每一张都能告诉你模型当前的能力边界在哪。比起盯着一堆准确率数字,看这些具体的失败样本,我对模型的理解要直观得多,下一步该加什么增强、该调什么阈值,往往看一眼就清楚了。