去年我维护一个对内数据核对脚本,跑批时候频繁被登录验证码卡住。人工识别虽然准确,但一多起来就想吐。后来我试了一堆OCR方案,tesseract、ddddocr都上了,粘连字符一多照样翻车。直到有人甩给我一个叫caveman的开源小工具,我才意识到问题根本不在于“认不出来”,而在于“没切对”。这个工具的名字听起来像原始人,处理方式也确实有点像——不讲什么高深模型,就用长度分割法硬切,但效果意外地扎实。
如果你也在搞验证码识别、OCR预处理、爬虫自动化,或者只是想验证自己生成的验证码是否合理,这篇文章值得花几分钟看完。我会从caveman的定位、原理讲起,再带一个完整可复现的识别流水线,最后把我在实际调试中踩过的坑和参数调优经验一并交代清楚。
1. caveman是谁?它解决的是验证码识别里最容易被忽略的一环
1.1 先说清楚这个工具的定位
caveman是一个开源字符分割工具,核心功能就是从图片验证码里把一个个字符分离开。它不做“识别”这件事,只做“切分”。一个完整的OCR流程通常分两步:先分割,后识别。分割质量直接决定识别上限,因为再强的识别模型,吃进去一张“W”和“a”粘在一起或出格的图,也很难输出正确结果。
为什么说它容易被忽略?因为现在大部分人遇到验证码,第一反应是“上深度学习模型”,或者是“调API”。模型确实能端到端识别,但它是个黑盒,不好干预中间过程;API有调用成本和隐私顾虑,也不适合所有场景。caveman提供的是一条传统的、可解释的、完全本地化的路径:先把字符切干净,再用任何一个OCR引擎或轻量分类器去做单字识别。好处是你随时能查看每一步切出来的图,问题出在哪一目了然。
我见过很多人在tesseract上折腾半天,调psm、换whitelist,识别率死活上不去,最后发现是图片里字符连成了一片。这时候如果先用caveman把粘连字符拆开,准确率能立刻上一个台阶。所以,caveman适合这些场景:图片型验证码识别前置处理、OCR流水线里的分割模块、字符分割算法的学习和对比实验。
1.2 为什么粘连字符是OCR的“使绊子”角色
验证码生成方为了对抗自动识别,最常用的手段就是把字符压扁、拉近、交错排列,甚至让两个字符的笔画叠在一起。对OCR引擎来说,字符分割一旦出错,后面所有环节都白搭。比如下面这类情况:
- “m”和“n”连成一条波浪线,OCR直接当成一个字符读,输出乱码;
- 字符太宽,单个连通域里包含了两个字符,识别引擎又把它们当成一个整体;
- 字符出格或倾斜,垂直投影无法形成干净的间隔,传统按列切分直接失效。
这些问题在真实数据里非常常见。我统计过一批带干扰线的验证码样本,“肉眼能看清但不粘连”的大概只有四成,剩下的不是粘连就是倾斜或带噪声。所以只靠tesseract的默认分割逻辑,基本是碰运气。caveman解决的就是这个“碰运气”的部分——它用一套基于字符宽度估算的规则,把粘连域强制拆开,并且尽量拆在正确的边界上。
1.3 长度分割法的核心思想:等宽字符假设
caveman的底层逻辑说穿了不复杂,就是一个假设:验证码里的字符基本都是等宽或近似等宽的。在这个前提下,只要估算出“平均字符宽度”,再结合连通域分析,就能推算出某个连通区域里大概藏着几个字符,然后在合适的位置下刀。
具体拆解下来,它的判断依据有三个:
- 通过统计整张图里所有文字像素的分布,估算出平均字符宽度;
- 通过连通域分析,找出一个个独立的笔画块;
- 当一个连通域的宽度明显大于平均字符宽度,或者相邻连通域的合并宽度异常时,判定为“这里有多个字符粘在一起”,需要切分。
这个思路和人类辨认粘连字符的心理过程很像:你看到一个超宽的笔画块,自动会想“这肯定不止一个字母”,然后在视觉上找“最窄处”或“凹陷位置”切进去。caveman干的也是这事,只是用像素和宽度数据代替了人眼。
值得一提的是,长度分割法并不是唯一的分割思路。业界还有垂直投影分割法、滴水算法、基于深度学习的分割模型等。垂直投影法对干净字符有效,但一遇到轻微倾斜或字符搭边就崩;滴水算法能模拟水滴沿字符轮廓下滑找路径,抗粘连能力强,但实现复杂、计算慢;深度学习方法效果好,但需要大量标注数据。caveman选择的长度分割法优势在于速度极快、不依赖训练数据、可解释性强——这点在做批量任务或快速验证时很实用。
2. 动手前先弄懂caveman的核心处理流水线
2.1 图片预处理:从彩色验证码到干净的二值图
caveman处理的第一步是拿到一张“干净”的图片,这一步往往被人忽视,但直接决定后续分割成功率。理想输入是白底黑字、边缘清晰的二值图。如果喂给它的是一张带彩色噪点、抗干扰线或渐变背景的验证码,最好先做预处理。
我常用的预处理流程是这样:
- 灰度化:把RGB三通道图转成单通道灰度图;
- 二值化:用固定阈值(如127)或自适应阈值算法,把灰度图变成0/1二值图;
- 降噪:去掉孤立噪点,可以借用中值滤波或形态学开运算;
- 校正:如果字符有倾斜,做一下旋转校正,避免投影统计失真。
实际操作中,二值化阈值的选择很讲究。阈值太高,字符笔画会被切断,变成一堆碎点;阈值太低,背景噪点全留下,连通域爆炸。我通常会写一个快速循环脚本,把同一张图按不同阈值处理,跑一遍caveman,看哪组参数切出来最稳定。这种“跑批试参”的方法在后文的调参环节还会用到。
2.2 字符分割阶段:连通域分析和粘连判断的逻辑
预处理完成后,caveman会进入核心分割阶段。这个阶段的执行流程大致如下:
- 第一步,扫描二值图,找出所有文字像素连通区域,记录每个连通域的外接矩形坐标;
- 第二步,统计所有连通域的宽度分布,估算出“平均字符宽度”。这里要注意,验证码里可能混入干扰线形成的连通域,所以它通常取的是宽度分布的中位数或众数,更抗噪;
- 第三步,遍历每个连通域,如果宽度落在合理字符宽度范围内,就标记为“单字符”;如果宽度明显超标,就根据超标倍数推断里面包含几个字符;
- 第四步,对标记为“多字符”的连通域执行切分。切分时优先找凹陷点,即在字符轮廓的凹进去的位置下刀;找不到明显凹陷点时,按等分宽度切;
- 第五步,输出每个字符子图的边界坐标,供后续OCR引擎逐块识别。
这里有一个容易被误解的点:caveman并不总是“每一个宽度超标的连通域都能切对”。当两个字符的笔画大面积交叠时,按等分宽度切出的位置可能刚好砍在某个字符的中间,导致识别时出现“半个字母”。所以后面我们还要靠识别器的置信度做二次修正,这个我在实战环节会具体演示。
2.3 caveman能输出什么:从坐标框到字符候选集
如果你的理解还停留在“输入一张图,输出一段文本”,那说明你还没真正用好caveman。这个工具定位是一个分割器,所以它最有价值的输出是“每个字符子图的坐标和切分后的图片”,而不是最终识别结果。
拿到这些坐标框之后,你可以做很多事情:
- 把切好的字符子图逐个保存为文件,用tesseract按时识别;
- 把坐标框画回原图,可视化检查切分位置是否合理;
- 把坐标框和另一个方向——比如字符上下溢出——结合起来做清洗;
- 把这些切分结果作为训练数据,去训练一个小型单字符分类器。
我个人的经验是:永远先可视化你的切分结果,再谈识别率。caveman切得好不好,一眼就能看出来。如果你发现切分框歪七扭八,那后面接什么识别引擎都救不回来,不如回到预处理环节去调参数。
3. 实战:用caveman + tesseract跑通一个完整的识别流程
3.1 环境准备和最小可运行代码
我的环境是Python 3.10,caveman通过pip直接安装即可。注意它依赖Pillow和numpy,这两个库做图像处理是标配。tesseract则需要单独安装OCR引擎本体和语言包,我用的是tesseract 5.0版本的英文训练数据。
最小识别代码其实很短,核心就是“先切分、后识别”:
from PIL import Image import caveman import pytesseract # 1. 读取图片并做预处理 img = Image.open("captcha.png").convert("L") img = img.point(lambda x: 255 if x > 160 else 0) img.save("captcha_bin.png") # 2. 用caveman找出所有字符子图 boxes = caveman.find_boxes("captcha_bin.png") # 3. 逐个识别:对每个子图单独送tesseract result_text = "" for i, box in enumerate(boxes): piece = img.crop((box["x"], box["y"], box["x"] + box["width"], box["y"] + box["height"])) piece = piece.resize((piece.width * 4, piece.height * 4)) # 放大后再识别,效果更好 char = pytesseract.image_to_string(piece, config="--psm 10 -c tessedit_char_whitelist=abcdefghijklmnopqrstuvwxyz0123456789") result_text += char.strip() piece.save(f"char_{i}.png") print("识别结果:", result_text)这里有两个容易踩的细节。第一,--psm 10表示“识别单个字符”,这是关键配置,因为我们已经切分了,不需要tesseract再自动分段。第二,子图放大4倍再识别,能显著提高单字符识别准确率,但不要用最近邻插值放大,锯齿会干扰识别,建议用PIL默认的Lanczos重采样。
3.2 一顿调试:当默认参数碰上“J”,我踩的坑
第一次跑这个流程,我的识别器几乎把验证码里的“J”全识别成了“]”,把“l”识别成了“1”。这个问题不是caveman切错了,而是切出来的子图里,字符本身带有轻微的倾斜和笔画残缺。单独看字符,“J”的底钩如果因为二值化掉了几个像素,看起来就真的像“]”。
我当时的排查思路是:先保存所有字符子图,人工查看一遍。结果发现,caveman切分坐标基本是对的,但有一小部分字符笔画出界,正好在子图边缘被裁掉了一截。这种情况多半出现在首尾字符上,因为验证码生成时字符不自带边距,边缘像素贴得太紧。
解决办法有两个:
- 在传给caveman之前,先给整张图加一圈白边,比如用
ImageOps.expand(img, border=20, fill=255),这样所有字符都离开图片边缘; - 二值化时把阈值放宽到170~180之间,宁可多留一点浅灰,也不要过早把笔画切掉。
加上白边之后,“J”识别成“]”的问题立刻消失了。这个小改动也让我意识到,caveman分割本身没问题,但它的输出质量高度依赖输入图的边界条件。做任何OCR预处理,都要先想清楚“字符是不是被裁到了”。
3.3 换个验证码风格,参数要跟着改:一个批量调参脚本
我手里的验证码有两类:一类是字符间有明显间距的简单验证码,一类是字符挤在一起甚至部分重叠的复杂验证码。第一类用默认参数几乎百分之百切对;第二类就需要调节caveman的字符宽度估算参数。
caveman允许你传入最大字符宽、最小字符宽和平均字符宽等参数。简单验证码用默认值没问题,复杂验证码则需要根据样本重新估算。我写了一个简单的批量调参脚本:
import caveman import statistics widths = [] for sample in samples: result = caveman.find_boxes(sample) for box in result: if box["width"] > 5: # 过滤掉孤点噪声 widths.append(box["width"]) avg_width = statistics.median(widths) min_width = int(avg_width * 0.5) max_width = int(avg_width * 1.5) print(f"中位数宽度: {avg_width}, 最小宽: {min_width}, 最大宽: {max_width}")把统计出来的宽度参数回传给caveman重新跑分割,切分准确率提升非常明显。核心逻辑是:当验证码风格变化时,平均字符宽度也会变,只有用样本自己统计出来的宽度,才能适配这一套样式。这个思路和训练模型时的数据集校准是同一道理,只不过它发生在规则层。
4. 识别率之外的事:稳定性、边界条件和工程整合
4.1 用样本量说话:什么情况才叫“参数调好了”
很多人在调参时凭感觉,觉得“这张切对了就行”。但在真实工程里,验证码样式是会变的,偶尔还会出现你从没见过的字体变体。所以我会专门留一批标注过的小样本集,每次调完参数都跑一遍整体统计。
具体做法是:准备50~100张已人工标注过答案的验证码图片,跑完整流水线,统计整图识别准确率、单字符准确率、分割失败率三个指标。如果整图准确率稳定在90%以上,单字符准确率在97%以上,再叠加合理的重试策略,整体方案就基本可用了。
这里有个容易被忽略的原则:不要追求单张图的完美,而是追求整批图的高可用。哪怕偶尔有一张识别错了,只要重试机制能兜底,整体体验依然是好的。相反,如果为了把那张刁钻样本切对而牺牲掉大量普通样本,就得不偿失了。
4.2 容易翻车的字符形态:i、l、1、0、O的前世今生
即便caveman切得很好,识别阶段仍会有一批“天生难分”的字符组合。我踩得最多的就是三组:l和1、0和O、i和l。尤其是切分出来的子图分辨率不高时,这些组合几乎无法靠字符形态区分,只能靠上下文或白名单约束。
处理思路有三个层次:
- 第一层,在验证码识别场景里,如果能确认来源验证码只包含数字,那就直接限定字符集为数字,
O和0的歧义自动消失; - 第二层,如果你不能确定字符集,可以做后处理纠错,比如用字典校验;对于连串字符,
0旁边的字母通常是O; - 第三层,在字符图片本身上下工夫,比如把“i”的上点去掉之后再识别,减少它和“l”的混淆概率。
我个人最推荐的是第一层方案:在可控场景下,把字符集白名单写死。这不仅解决了歧义问题,还顺手提升了单字符识别的速度。这也是为什么我在上面的最小示例代码里就加了-c tessedit_char_whitelist的原因。
4.3 放到真实工程里:异步化、重试和日志留痕
当你把caveman识别流水线从脚本搬到正式服务时,有几个工程化问题会被立刻放大。
首先是并发与并发安全。caveman本身是CPU密集型的纯Python计算,受GIL影响,多线程并不会带来线性加速,建议用进程池实现并行。我这里用的是concurrent.futures.ProcessPoolExecutor,每个进程独立初始化模型和caveman实例,这样既规避了GIL,又能共享稳定的内存池。
其次是识别失败的重试策略。OCR识别天然不是100%准确,在工程上要默认它可能失败。我习惯做两到三次识别尝试,每次随机微调预处理参数,比如二值化阈值上下浮动10,放大倍数浮动0.5倍,取置信度最高的那次结果。如果仍然失败,记录日志并抛给人工流程处理,不要硬撑。
最后是留痕。每条识别记录我都会保存原始图片、切分中间图、识别结果和置信度时间戳。这样无论用户反馈什么,都能回溯到具体哪一步出了问题。这套日志机制曾经帮我从一堆“识别异常”里精准定位到“某个时间段网站验证码换了字体”,堪称救命。
4.4 一条更隐蔽的坑:caveman对非等宽字符的误判
前面讲了caveman依赖等宽字符假设,但在真实验证码中,有的字体不是等宽字体,比如手写体风格。字符“W”和“i”的宽度差异极大,平均字符宽度的估算就会失真。“W”可能被判定为“包含两个字符”然后被错误切分;反过来,“i”这种窄字符可能被当成噪点过滤掉。
解决思路有两个方向。一个是绕过:换用滴水算法或基于轮廓分析的分割器,它们不依赖等宽假设,适合手写体;另一个是改造:在调用caveman之前,对字符区域做宽度归一化的预处理,比如把每个连通域单独提取并缩放到统一宽度,再拼接成一行输入。第二个方案我试过,效果不错,但要注意缩放会破坏字符比例,导致“O”变成“0”之类的变形。
5. 必须说清楚的合法使用边界
5.1 这套技术能做和不能做的事
任何OCR技术都有双面性。caveman能做的是自动化识别你自己拥有、或被明确授权测试的系统中的验证码,比如:
- 你自己开发的网站或应用里的验证码可用性测试;
- 你在做爬虫数据采集时,目标网站明确允许自动化访问的场景;
- 内部系统的自动化回归测试。
它不能也不应该用来做这件事:未经授权绕过他人网站的验证码,批量注册账号、抢票、刷接口、发送垃圾请求。这类行为在国内外法律框架下都有明确的违法或违约风险,我也强烈不建议任何人去碰。验证码存在的目的是保护系统资源,绕过它去薅羊毛,轻则封IP,重则吃官司,完全不值得。
我说这些不是站在道德高地上说教,而是作为从业者基于风险给你一个实在的建议。技术能力应该用在提升自身效率上,而不是破坏别人的防线。如果你不确定当前应用场景是否合规,我的建议是:先停下来,问一下你自己“如果我做的这件事被公开报道了,我能坦然面对吗”,只要有一丝犹豫,就别做。
5.2 一个更稳妥的用法:用caveman检验你自家验证码的质量
我在实际工作中发现,caveman最好的应用场景其实是“验证码质量评估”。很多开发者在生成验证码后,不知道自己的验证码到底够不够“抗自动识别”。人工看觉得还行,但机器识别的风险心里没底。这时候,你可以直接用caveman+tesseract搭一个自动识别器,拿它来测量自家验证码的自动识别通过率。
如果识别率太高,说明验证码太弱,需要增加字符粘连程度、干扰线或旋转角度;如果识别率低到连正常用户都看不清,那说明验证码设计过头了,容易流失用户。理想状态是:自动识别通过率控制在可接受范围,同时人类用户能轻松通过。这个“攻防平衡测试”的玩法,比单纯拿caveman去攻破别人网站有价值得多,也更符合行业伦理。
我在给自家业务做验证码优化时,就专门建了一套自动化评估链路:自动生成几百个验证码样本 → 用caveman切分 → 用tesseract识别 → 统计自动通过率 → 调整生成参数。这套流程帮我迅速定位到“背景噪声密度”和“字符间距离”两个关键参数的最优区间,效果立竿见影。
6. 最后聊点实在的:我把caveman用顺手之后的一些体会
用caveman跑了半年多,从最初在脚本里玩一玩,到现在沉淀成内部OCR流水线的固定前置模块,我的感受有两个。
第一,不要迷信端到端黑盒。深度学习模型确实能解决很多传统算法搞不定的问题,但它不擅长给你反馈。caveman这种传统可解释工具的价值在于:每个环节都能看、能调、能量化。当你发现识别率下降时,你能立刻判断是预处理的问题、切分的问题还是识别的问题,而不是对着一个模型发呆。这种“可干预性”在做工程时比什么都重要。
第二,参数调优的本质是理解数据。很多人在调参时盯着代码看,却忘了把头抬起来看看样本。caveman默认参数好不好用,完全取决于你的验证码长什么样。先用一批有代表性的样本做统计,再根据统计结果设置参数,往往比盲目试参数更快见效。工具永远是次要的,理解和掌控数据才是核心能力。
如果你现在也被OCR识别、验证码处理这类问题卡着,不妨像我一样,搭建这样一套用caveman做分割、可视化验证一下切分结果、再搭配轻量识别器的流水线。切分对了,识别就是水到渠成的事。把这个环节做扎实之后,你会发现后续所有跟字符提取相关的任务,都变得异常顺滑。