简介:本地部署式英文数字混合验证码识别服务,面向按键精灵、触摸精灵、触动精灵、Python及其他语言开发者,解决自动化脚本中验证码识别难、环境配置复杂的问题;服务只需一台Windows电脑或服务器即可搭建,兼容Windows 10、11及Server 2012/2016/2019系统版。服务端经完整封包,双击exe即可开启服务,免去配置Python等依赖;内置基于深度学习的识别模型,对英文和数字混合验证码识别率较高。调用方式通用:将验证码图片BASE64编码后POST到插件开放的API即可,支持局域网、互联网及离线运行。资源包总大小27.43MB,共186个文件,其中51个pyd和49个dll构成运行依赖,1个exe为启动程序,1个onnx为模型文件,还包含txt说明文档、wheel、license等元数据,目录结构清晰便于按需检索。配套详细使用说明与作者协助调试支持,已有2724人学习下载,适合需要快速搭建验证码识别服务、希望免去环境配置的脚本开发者。
1. 本地部署的验证码识别插件:自动化脚本卡在验证码时最快的解
写自动化脚本的人大多遇到过同一个坎:前面的流程都跑通了,屏幕上弹出一个英文数字验证码,整个脚本就卡死。手动输入一次两次还行,批量跑起来就会烦到怀疑人生。调用在线识别接口倒是省事,但每张图都要上传,网络延迟从几百毫秒到几秒不等,识别结果还不稳定,更重要的是很多授权环境里不允许数据出网。这时候,一个本地部署的英文数字验证码识别插件就是最顺手的方案:模型和HTTP服务都跑在自己电脑上,按键精灵、触摸精灵、触动精灵、Python这些不同平台和语言,只要会发HTTP请求,就能在几十毫秒内拿到识别结果。下文我把选型、部署、多端对接和踩坑一次讲透。
2. 英文数字验证码识别方案选型:为什么现成OCR几行代码不够“牛”
2.1 先用Tesseract试水:3分钟看到它为什么不够用
许多人第一反应是“直接上Tesseract”。它确实能在最简单的无干扰验证码上跑出不错的结果,命令也很短。以4位英文数字为例,下面是Linux/Mac下的调用方式:
tesseract captcha.png stdout -psm 7 -c tessedit_char_whitelist=ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789参数说明:-psm 7把图片当作单行文本识别,适合定长验证码;tessedit_char_whitelist限定字符集,避免把字母识别成标点。
如果你面对的验证码是规整的深色字符、白底、无干扰线,这个命令的准确率能到九成以上。可一旦字体带扭曲,字符之间粘连,或者背景加了噪点和干扰线,Tesseract就开始翻车。原因在于它是为扫描印刷文档设计的,底层是整词识别和语言模型矫正,而验证码恰恰是“反文字识别”的产物,字体随机、字符无语义、干扰线专门破坏连通域。我在一个后台登录验证码上试过,纯数字4位带两条干扰线,Tesseract准确率只有七成左右,且失败样本偏偏是最常见的几位数字。这个结论不是Tesseract不行,而是技术路线和场景错配。如果你有大量验证码要处理,且字符是扭曲粘连的,就不要在Tesseract上继续调参了,下面的自训练方案才是正路。
顺便提一句,有些做PHP站点的朋友也会来问有没有PHP ocr识别验证码的库,结论一样:通用OCR库对小样本、强干扰的验证码都不友好,最终都会走到自训练模型这条路。
2.2 自训练轻量CNN:定长验证码最稳的逻辑
既然通用OCR不好使,就针对目标验证码训练一个专用模型。常见的验证码是5位、字符集为26个大写字母加10个数字,这就是36类分类问题。因为位数固定,直接训练一个CNN,让模型对每一位字符独立做36分类。相比CRNN+CTC,定长方案训练更快、模型更小,CPU上单张识别耗时能到二三十毫秒。如果后续遇到不定长验证码,再升级CRNN也不迟;从工程落地角度,先把定长跑通,收益最高。
训练的第一步是生成合成样本。原因是验证码采集和标注成本高,合成数据能快速提供几十万张,让模型先学会“字形”这件事。下面这段用PIL生成灰度图,每张图上随机画5个字符、若干干扰线和噪点:
import random from PIL import Image, ImageDraw, ImageFilter, ImageFont CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" W, H = 160, 60 # 输入图片宽高,训练和推理必须一致 N = 5 # 验证码位数 def gen_sample(font_path=None): img = Image.new("L", (W, H), 255) draw = ImageDraw.Draw(img) font = ImageFont.truetype(font_path, 28) if font_path else None text = "".join(random.choices(CHARS, k=N)) for i, ch in enumerate(text): # 每个字符的x在基础位置上加随机偏移,模拟粘连和错位 x = 8 + i * 30 + random.randint(-2, 2) y = random.randint(5, 22) draw.text((x, y), ch, fill=random.randint(40, 180), font=font) for _ in range(random.randint(8, 15)): draw.line((random.randint(0, W), random.randint(0, H), random.randint(0, W), random.randint(0, H)), fill=random.randint(90, 190), width=1) for _ in range(random.randint(200, 400)): draw.point((random.randint(0, W - 1), random.randint(0, H - 1)), fill=random.randint(0, 255)) img = img.filter(ImageFilter.GaussianBlur(0.6)) return img, text这段代码的要点:random.choices保证字符有放回抽样,样本分布均匀;字符灰度值取40到180之间的随机值,避免纯黑,更接近真实验证码的打印效果;干扰线数量8到15根,噪点200到400个,这是模拟“有点干扰但不至于完全不可辨”的常见区间;最后的GaussianBlur(0.6)把字符边缘稍微模糊,模拟低分辨率截图的拉伸感。真实环境里的验证码字体可能不是PIL默认字体,合成数据阶段最好指定和线上相近的字体文件,比如线上验证码用Arial加粗,训练也用一个顺滑的无衬线字体,否则后面真实样本微调要花的功夫更多。
然后是模型定义。验证码图片经过三层卷积后,用自适应平均池化压成128维特征,最后全连接输出5 * 36个值,并reshape成(批次, 5, 36),每一位分别算交叉熵:
import torch.nn as nn class CaptchaCNN(nn.Module): def __init__(self, n_len=5, n_chars=36): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.AdaptiveAvgPool2d((1, 1)), nn.Flatten(), nn.Linear(128, 128), nn.ReLU(), nn.Linear(128, n_len * n_chars), ) def forward(self, x): x = self.features(x) x = self.classifier(x) return x.view(-1, 5, 36)这个结构的选型理由很直接:验证码字符只有5位,不需要像CRNN那样建模长序列依赖;三层卷积的参数量在百万级别,CPU上推理毫无压力。AdaptiveAvgPool2d让模型对输入尺寸不那么敏感,但为了部署省心,我还是建议按160x60固定输入。
训练时把每张图转成(1, H, W)的张量,标签是5个字符在CHARS里的下标,用多目标交叉熵训练:
import torch from torch.utils.data import Dataset, DataLoader class SyntheticDataset(Dataset): def __init__(self, n): self.n = n def __len__(self): return self.n def __getitem__(self, idx): img, label = gen_sample() img = torch.tensor(bytearray(img.tobytes()), dtype=torch.float32).view(1, H, W) / 255.0 target = torch.tensor([CHARS.index(c) for c in label]) return img, target model = CaptchaCNN() opt = torch.optim.Adam(model.parameters(), lr=0.001) loss_fn = nn.CrossEntropyLoss() loader = DataLoader(SyntheticDataset(50000), batch_size=64, shuffle=True) for epoch in range(20): for imgs, labels in loader: out = model(imgs) # (B, 5, 36) loss = loss_fn(out.view(-1, 36), labels.view(-1)) opt.zero_grad() loss.backward() opt.step() if epoch % 5 == 0: print(f"epoch {epoch}, loss {loss.item():.4f}") torch.save(model.state_dict(), "captcha.pt")参数说明:batch_size=64在CPU或小显存GPU上都能跑,lr=0.001是Adam的基准区间。训练20轮,合成数据50万张,在普通GTX 1660上大约需要三四个小时,纯CPU则需要十小时以上。如果你赶时间,可以把SyntheticDataset(50000)改成20000,先跑通流程再补样本。
2.3 数据规模与准确率的关系:合成样本、真实样本怎么配比
很多从没训过验证码模型的人会问一个实际的问题:到底要多少张图才能“识别效果很牛”?我的经验是,合成样本决定模型的下限,真实样本决定上线后的上限。只靠合成样本,对字体和干扰线比较刁钻的验证码,准确率通常在85%到95%之间;加入200到500张真实截图做微调后,准确率能稳定到98%往上,这才是标题里“识别效果很牛”的真相。
真实样本的获取没什么玄学:在自动化脚本运行的时候,把弹出来的验证码原图按文件名顺序保存下来,再写一个几十行的小工具批量标注。标注时建议直接用数字和字母做文件名,例如A1B2C.png,训练时从文件名解析标签。这样一天就能攒几百张,比手工Excel标注快得多。
在微调阶段,把真实样本和合成样本按1:5混合进同一个DataLoader,用更小的学习率lr=0.0001再跑5个epoch。注意不要只在真实样本上反复过拟合,否则遇到线上新样本会突然翻车。
3. 把训练好的模型封装成本地HTTP服务:FastAPI部署与接口约定
模型训好之后,下一步是把它变成一个所有脚本都能调的插件。这里说的“插件”不是某个IDE里的注册插件,而是一个跑在本地、对外提供HTTP接口的服务。原因很简单:按键精灵、触摸精灵、触动精灵、Python能共通的语言就是HTTP;任何平台只要会发POST请求,就能拿到识别结果。
3.1 服务端骨架:加载模型、接收Base64、返回识别文本
用FastAPI写一个最小的服务端。代码结构分为四块:加载模型、图片预处理、接收请求、返回结果。为了保证跨平台不踩编码坑,接口统一用JSON,图片字段用Base64字符串。
import base64, io import numpy as np from PIL import Image import torch from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() model = CaptchaCNN() model.load_state_dict(torch.load("captcha.pt", map_location="cpu")) model.eval() CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" class RecognizeReq(BaseModel): image_base64: str def preprocess(img: Image.Image, resize=(160, 60), threshold=127): img = img.convert("L") # 统一转灰度 img = img.resize(resize, Image.LANCZOS) img = img.point(lambda p: 255 if p > threshold else 0) arr = np.array(img, dtype=np.float32) / 255.0 return torch.from_numpy(arr).view(1, 1, resize[1], resize[0]) @app.post("/recognize") def recognize(req: RecognizeReq): raw = base64.b64decode(req.image_base64) img = Image.open(io.BytesIO(raw)) tensor = preprocess(img) with torch.no_grad(): out = model(tensor) # (1, 5, 36) pred = out.argmax(dim=2).squeeze(0) text = "".join(CHARS[i] for i in pred.tolist()) return {"result": text}逻辑说明:请求里带上Base64图片,服务端先解码成图片,再做灰度化、缩放、二值化,喂给模型推理,最后把5个位置的argmax拼成字符串。这里假定你已经把上一章的CaptchaCNN和CHARS定义保存在同一个py文件里。
这里有一个必须留意的设计选择:recognize函数用的是def而不是async def。FastAPI会把def端点放到线程池里执行,这样CPU密集的模型推理不会阻塞其他请求。如果你写成async def,又不在函数内部手动让出事件循环,高并发时每个请求都会排队等着推理完成,吞吐量直线下降。这是很多新手第一次写推理服务最容易踩的性能坑。
3.2 图片预处理参数:二值化阈值、尺寸和去噪选项
preprocess里有两个参数会影响最终识别率:resize的宽高比、二值化阈值。合成数据用的是160x60,线上图片如果来自不同分辨率,直接拉伸会造成字符变形,所以服务端应保留一个可供校准的预处理流程。我一般把预处理参数做成环境变量或请求里的可选字段,方便出问题时不改代码先调参数。
class RecognizeReq(BaseModel): image_base64: str threshold: int = 127 resize_width: int = 160 resize_height: int = 60在preprocess里接收这些值。调阈值时有个规律可依:字符整体偏淡,阈值往下调到110左右;背景噪点偏重,阈值往上调到150左右。二值化之后如果字符断成了骨架,阈值调低;如果笔画糊成一团,阈值调高。
除了阈值,另一个常见操作是中值滤波。对带细颗粒噪点的验证码,在灰度图上做一遍3x3中值滤波能有效去掉孤立噪点,代价是字符边缘稍微钝化。这个开关我也建议做成可配参数,因为并不是所有验证码都有颗粒噪点。
img = img.filter(ImageFilter.MedianFilter(3)) # 可选,默认关参数表总结:
| 参数 | 默认值 | 调优方向 |
|---|---|---|
| resize_width | 160 | 和训练一致,不要随意改 |
| resize_height | 60 | 和训练一致,不要随意改 |
| threshold | 127 | 字符淡则调低,噪点重则调高 |
| median_filter | false | 有颗粒噪点开启 |
3.3 启动参数与服务化:绑IP、并发和开机自启
服务写好之后,启动命令如下:
uvicorn captcha_server:app --host 0.0.0.0 --port 8000 --workers 1参数说明:--host 0.0.0.0表示监听所有网卡,既允许本机脚本访问,也允许同一局域网里的手机端触摸精灵、触动精灵访问。如果只给本机工具用,可以绑127.0.0.1,但从实际使用看,手机模拟器经常需要访问电脑服务,所以一次绑对0.0.0.0能省很多事。
--workers 1是一开始就要顶住的参数。PyTorch模型加载后占用的内存和线程并不小,开多worker不仅费内存,还会因为每个进程各自加载一份模型导致内存翻倍。如果你的并发需求确实超过单进程能力,优先在应用层做队列或部署多个端口,而不是盲目开worker。
Windows上要让服务开机自启,常见做法是用nssm把uvicorn注册成系统服务。Linux服务器上则写一个systemd unit文件,这里不再展开,核心是让服务进程守护运行,断了能自动拉起。跑自动化脚本的人一般不想折腾服务器,最简陋也最有效的办法是写一个每分钟检查一次的定时任务,发现端口不通就重新拉起uvicorn,这比任何华丽的守护机制都直观。
另外,在服务里加一个/healthz端点,返回{"status":"ok"},脚本每次启动时先探测这个端点,不通就直接重试拉起服务。这个动作看似多余,但能避免脚本跑一半才因为服务挂了而弹错。
4. Python、按键精灵、触摸精灵与触动精灵的对接实例
服务端稳定运行后,剩下的就是各平台发HTTP请求。我按实际会遇到的四种调用方式贴出代码,你按自己的运行环境挑一段抄。
4.1 Python调用:requests单张识别与线程池批量并发
Python侧最简单,直接requests POST JSON。
import base64 import requests def recognize_file(image_path, endpoint="http://127.0.0.1:8000/recognize"): with open(image_path, "rb") as f: b64 = base64.b64encode(f.read()).decode() r = requests.post(endpoint, json={"image_base64": b64}, timeout=5) r.raise_for_status() return r.json()["result"] print(recognize_file("code.png"))这里的timeout=5是个重要参数:如果服务端卡住,不设timeout会挂死整个脚本;5秒对本地服务来说已经非常宽裕,识别本身只要几十毫秒。批量处理验证码时,串行循环会白白浪费本地服务的并发能力,把多张图片一次性扔进线程池更合理:
from concurrent.futures import ThreadPoolExecutor files = [f"code_{i}.png" for i in range(20)] with ThreadPoolExecutor(max_workers=8) as pool: results = list(pool.map(recognize_file, files)) print(results)max_workers=8是一个从本地服务吞吐量看比较稳的并发数。如果你的图片更大、服务部署在低配机器上,降到4;如果服务端是GPU推理,可以加到16。要注意的是,线程池只有图片读取和HTTP请求是并行的,模型推理瓶颈仍在服务端,所以不是workers越多越好。如果你写的是爬虫脚本,建议用requests.Session()复用TCP连接,可以把批量识别总耗时再压掉30%。
4.2 按键精灵PC版对接:用系统XMLHTTP POST JSON
按键精灵PC版的脚本语言接近VBS,直接调用系统MSXML2.XMLHTTP就能完成POST。下面是一个封装好的函数:
Function HttpPost(url, jsonBody) Dim http Set http = CreateObject("MSXML2.XMLHTTP") http.Open "POST", url, False http.SetRequestHeader "Content-Type", "application/json" http.Send jsonBody HttpPost = http.responseText End Function调用方法:先把验证码图片读成Base64,再拼JSON。按键精灵不同版本的Base64编码命令位置不一样,PC版在“命令列表-字符串-编码”里能找到,名称通常是“Base64编码”,你把它封装成一个函数就行,我这里用EncodeBase64占位:
Dim b64, jsonStr, resp b64 = EncodeBase64("C:\config\code.png") ' 按键精灵侧编码函数 jsonStr = "{""image_base64"":""" & b64 & """}" resp = HttpPost("http://127.0.0.1:8000/recognize", jsonStr)这里有两个坑位提前说:第一,JSON字符串内部的双引号在按键精灵里要用两个双引号转义,写成""image_base64"",少了转义服务端会报422;第二,EncodeBase64得到的字符串如果太长,某些版本会按长度截断,建议在代码里加一个长度判断,超过100万字符就报错提示,不要盲发。按键精灵对接的本质只是发HTTP,能解决定位、找图、点击的“Post插件”也能实现同样效果,但直接用系统XMLHTTP最干净,不需要额外装插件。
提示:按键精灵Base64编码生成的长字符串,拼接进JSON前先打印长度,超过500万字符说明图片未压缩,应先把验证码图片缩放后再编码,避免请求体过大。
4.3 触摸精灵与触动精灵的Lua对接:改一个IP就能通
手机端的触摸精灵、触动精灵通常跑Lua脚本。它们访问电脑上的服务时,要注意“本地部署”不等于“localhost”——手机上的localhost是手机自己,不是电脑。正确做法是让手机和电脑连同一个路由器,把请求地址改成电脑的局域网IP。
以下用Lua的luasocket库做一个POST请求示例,这是Lua环境里最通用的HTTP方案:
local json = require("json") local http = require("socket.http") local ltn12 = require("ltn12") local req = json.encode({ image_base64 = b64 }) -- b64由屏幕截图或文件读取得到 local resp_body = {} local res, code = http.request{ url = "http://192.168.1.100:8000/recognize", method = "POST", headers = { ["Content-Type"] = "application/json" }, source = ltn12.source.string(req), sink = ltn12.sink.table(resp_body) } if code == 200 then local obj = json.decode(table.concat(resp_body)) log(obj.result) else log("识别接口返回:" .. tostring(code)) end参数说明:url里的192.168.1.100要换成你电脑的实际局域网IP,在Windows命令行执行ipconfig就能查到。手机端脚本不一定自带json和socket库,常见做法是触动精灵/触摸精灵会在脚本启动时加载自己的内置HTTP API;如果你的运行环境没有luasocket,就改用编辑器自带类似http.post的方法,核心都是POST一个JSON到同一地址。
如果你的脚本跑在安卓模拟器内,而服务又跑在同一台电脑上,模拟器里的localhost需要改成主机在模拟器网络里的网关地址,常见取值是10.0.3.2或10.0.2.2,具体看你用的模拟器网络配置。容器网络和局域网模式不一样,优先用宿主机IP而不是localhost。
另外,电脑的防火墙要放行8000端口。Windows默认会拦截外部设备访问,第一次从手机发起请求时,如果电脑上弹出防火墙提示,直接点允许;如果没弹窗又连不上,手动在“Windows Defender防火墙-高级设置-入站规则”里放行TCP 8000。这一步是最容易被忽略的“手机连不上服务”的原因。
注意:手机端访问服务前,先确认电脑能用自己的局域网IP访问接口,再排查手机脚本。直接在电脑浏览器里访问
http://192.168.1.100:8000/healthz,能通再测手机。
5. 避坑与排查:识别为空、Timeout和内存走高的5个真实案例
服务部署好、脚本也通了,但跑上两三天才是真正考验。这里总结5个我实际遇到过的案例,按“现象→原因→解决”的顺序写,方便你直接把排查步骤套到自己环境里。
5.1 接口返回200却空字符串,模型输出全空
现象:接口通,返回200,但result字段是空字符串,日志里模型输出的5个位置全部是空白字符的索引。
原因:图片是带透明通道的PNG,有些颜色通道在convert("L")转灰度时,透明背景变成接近白色,字符灰度也被冲淡;二值化阈值在127处,把字符笔画切掉了一部分,模型把剩下的部分识别成了空白。
解决:在preprocess里先转RGB再转灰度,并给透明背景补白底。补白底的代码是Image.new("RGB", img.size, (255,255,255)),再paste(img, mask=img.split()[3]),然后才转灰度。这个坑在模拟器截图里特别常见,截图保存的PNG往往带alpha通道。
5.2 按键精灵拼出来的JSON服务端报422
现象:按键精灵把Base64拼成JSON后请求本地服务,FastAPI返回422 Unprocessable Entity。
原因:按键精灵脚本里字符串拼接时,双引号转义不对,最常见的错误是把JSON写成了{image_base64:xxx}这种无引号形式;键名没有双引号,Pydantic校验直接拒绝。
解决:严格按"{""image_base64"":""...""}"的格式拼字符串,在按键精灵里双引号要写成两个连续双引号。调试时先固定image_base64为一个纯字母短串,比如"abc",如果服务端返回正常结果,说明问题在Base64太长或拼接格式上;如果还报422,逐一剥离层数定位。
5.3 并发一高,大量请求超时
现象:Python线程池开到16个workers时,原本20毫秒的识别请求大量超过5秒timeout。
原因:FastAPI的def端点默认走线程池,但线程池默认大小和模型推理的全局解释器锁竞争叠加,单进程在满载时排队严重。
解决:把max_workers降到8,服务端增加一个信号量限制并发推理数,或改用onnxruntime替换PyTorch推理,释放CPU占用。更直接的办法是让服务端单进程处理,客户端用Semaphore控制同时发出请求的数量,让每张图都排队但不超时。超时问题多数不是网络问题,是并发超过服务能力,先降并发再谈优化。
5.4 手机触摸精灵连不上电脑上的识别服务
现象:手机脚本请求http://192.168.1.100:8000/recognize,始终超时,电脑上浏览器访问同样的地址却秒回。
原因:服务绑的是127.0.0.1,或者Windows防火墙没有放行8000端口;手机和电脑可能也不在同一网段。
解决:第一步检查服务启动参数,--host必须为0.0.0.0;第二步在电脑上执行ipconfig,确认手机和电脑的网关一致;第三步放行防火墙,或者在Windows弹窗时点允许。把这三步按顺序走完,这一案基本都能解决。注意不要在手机上把url写成http://localhost:8000,手机上的localhost不是电脑。
5.5 换了新验证码样式,准确率从98%崩到七成
现象:验证码改版,字体、干扰线颜色全变了,模型识别率肉眼可见地下降,连续识别失败。
原因:模型在旧合成数据上过拟合,新样式的字符形态和干扰分布超出了训练集的分布范围。这本质是数据分布漂移,不是模型结构问题。
解决:立刻收集200张新样式验证码,用旧模型先粗识别一遍,再人工修正错误标签,随后和合成数据按1:5混合,用lr=0.0001微调5个epoch。这个流程我在多个项目上验证过,是最快止血的手段。平时可以每天保存线上验证码截图,积累到两百张再统一微调,避免临时抓瞎。
这5个案例覆盖了接口、宿主机、防火墙、并发和模型更新五个层面。如果你遇到的问题不在其中,优先看服务端启动日志,FastAPI的默认日志会打印每次请求的状态码和耗时,能帮你判断是请求没进来还是推理阶段卡住了。本地部署的验证码识别服务是个“黑匣子”,有了日志才不会靠猜。
6. 进阶:让本地识别服务稳定跑到99%的实验台做法
接口通了,模型也能识别,但这套方案真正值钱的地方是稳定。我后期把三个习惯固定下来:真实样本微调、ONNX量化、每日自检。
6.1 真实样本微调是识别效果的后悔药
合成数据训练出来的模型,真实截图识别率通常在90%上下;一旦你开始微调,每多100张真实样本,准确率就能涨1到2个点。微调流程不必写新代码,复用第2章的SyntheticDataset,把真实样本单独做一个Dataset,按1:5比例和合成样本混进同一个loader,学习率降到0.0001,跑5个epoch。有人担心真实样本多了会把模型带偏,只要确保每类字符的样本数相对均衡,这个担心基本不会发生。
6.2 ONNX导出:让CPU推理再快一截
如果脚本场景要求每张图在20毫秒内返回,PyTorch的CPU推理可能不够稳定。导出ONNX后,用onnxruntime推理,同样的模型在CPU上通常能快30%到50%。
import torch dummy = torch.randn(1, 1, 60, 160) torch.onnx.export(model, dummy, "captcha.onnx", input_names=["img"], output_names=["pred"])导出的文件直接给到FastAPI,请求逻辑不变,预处理的维度也不能变。onnxruntime在并发场景下占用内存更小,这是它最大的优势。
6.3 每天跑一次自检脚本,别等线上翻车
我现在的做法是每天凌晨对历史样本跑一遍批量识别,算一次准确率。低于98%就打印告警,并输出最近失败样本的文件路径。这个脚本不需要新框架,就是把recognize_file和标注好的文件名一一对比,统计一下命中率。听起来笨,但它救过我两次,都是验证码悄悄改版的第一天。我最初只信合成模型的高准确率,结果真实样本识别率掉到80%,补了三天标注才拉回来。后来我把自检固定到每天,数据漂移当天就能发现。
这套方案适合所有被困在验证码自动化上的团队和独立开发者。先跑通Tesseract,再自训练,再对接HTTP,最后滚动微调,别想一口吃成“很牛”,但每一步都走稳,结果会自然收敛到99%那个方向。希望帮到你。
本文还有配套的精品资源,点击获取