如果你做过专利检索或者批量爬取公开数据,大概率见过那个烦人的六宫格验证码:弹出一个两行三列的小窗口,让你选出所有包含某个物体的图片,选完才能继续查数据。我第一次碰到它时还在用浏览器自动化硬钢,结果光是等页面渲染、等图片加载就花掉好几秒,识别还得靠人眼盯屏幕,根本谈不上自动化。后来我换了个思路:整个流程不走浏览器渲染,直接用 requests 维持会话,把验证码图片抓回来,用 PIL 拆成六个独立的小图,再用感知哈希比对找出目标图,最后把结果回传给服务端。实测跑下来,单次验证码的处理时间能压到一秒以内,稳定性和效率都比之前的方案强一个量级。这篇复盘就把完整链路、关键代码和踩过的坑一次性说清楚,给正被六宫格验证码劝退的开发者一个能直接参考的解法。
1. 六宫格验证码的本质与选型逻辑
1.1 六宫格验证码比字符验证码难在哪
传统的字符验证码是“识别文本内容”,程序只需要把图片里的字母或数字用 OCR 转成字符串,再填到表单里就行。六宫格验证码则完全不同,它要求程序理解图片的语义内容:六张缩略图里,哪些包含汽车,哪些包含红绿灯,哪些包含某个特定轮廓的物体。这不再是字符匹配,而是图像内容层面的判断。
人眼识别这些图片几乎不费力气,但程序要做到同样的事,至少要解决两层的难题:一是把六张小图从背景、干扰线、噪点中分离出来;二是判断每张图是否属于目标类别。而且目标网站的验证码图片往往经过了压缩、亮度变化、轻微旋转等处理,增加了匹配困难。它的目的就是拦住那种不带浏览器渲染能力的纯脚本访问,逼着你走重流程,从而降低自动化采集的效率。
1.2 为什么选 requests + PIL 而不是 Selenium 也不是深度学习
先说 Selenium。它确实能模拟人点击六宫格,甚至你可以直接让它截屏、定位元素、模拟鼠标点击。但代价太大:启动一个无头浏览器要占几百 MB 内存,加载页面、等待图片异步加载,每一步都在浪费时间。如果你的任务是每天处理几百上千次验证码,用浏览器方案从资源消耗到速度上都不划算。
再说深度学习。用 CNN 分类器识别“图片里有没有某种物体”,是当前最通用的办法,但训练数据从哪来?你得先人工标注几百张带目标的图片,然后训练模型,部署环境也得额外装框架。对于一个目标类别相对固定的验证码场景,这属于杀鸡用牛刀。
所以我把技术栈收敛到 requests + PIL 这个最小组合。requests 负责会话保持和 HTTP 交互,PIL 负责图片的裁剪、去噪、格式转换和特征计算。两个库都是 Python 生态里最基础的工具,安装简单、文档丰富,处理单张图片的耗时可以忽略不计。整个方案的目标不是“百分之百模拟人类”,而是在大多数情况下能用最小的成本跑通流程,把验证码变成可以自动处理的数据。
1.3 技术方案的整体流程
整个自动化链路可以拆成六个环节,每个环节都有它要解决的问题:
- 用 requests.Session 建立稳定的会话,确保验证码获取、提交都处于同一套 Cookie 上下文里。
- 从页面接口或 HTML 源码里提取验证码图片的数据来源,可能是图片 URL,也可能是 Base64 字符串。
- 把整张验证码图片交给 PIL,按照行列数切成六张独立的小图。
- 对每张小图做灰度化、二值化、中值滤波等预处理,降低干扰线对特征提取的影响。
- 计算每张小图的感知哈希值,和预先准备的目标模板比对,按汉明距离排序,选出最可能的目标所在索引。
- 用同一个 Session 发起 POST 请求,提交验证码 ID、目标索引等关键参数,解析返回结果,失败则刷新验证码重试。
这条链路里,最需要注意的是第 1 步和第 6 步必须使用同一个 Session 实例。很多人折腾到最后识别率很高却一直提交失败,十有八九是会话上下文断了,验证码和提交请求服务端对不上号。这个细节稍后详细展开。
2. 环境准备与请求会话搭建
2.1 依赖安装与基础环境配置
环境部分很简单,Python 3.8 以上版本都行,我用的是 3.10。核心依赖就两个:
pip install requests pillow这里有个小坑需要注意:早期有个 PIL 库,官方停止维护很久了,现在大家用的都是 Pillow,但导入语句仍然是from PIL import Image。如果你在旧教程里看到pip install PIL,那基本是过时的信息,直接装 Pillow 即可。
安装完成后,我习惯新建一个按域名命名的配置字典,把请求头、超时时间、重试次数集中管理。这样后面写逻辑的时候不会到处散落魔法数字。
import requests from PIL import Image, ImageFilter, ImageOps import io import time import re import random HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://example-patent-platform.org/search", "Accept": "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", } session = requests.Session() session.headers.update(HEADERS)2.2 Session 会话保持是验证码同步的命门
六宫格验证码最常见的失败场景不是识别错误,而是“验证码明明选对了,服务端却返回校验失败”。我最初排查这个问题时一头雾水,后来抓包对比才发现,问题出在我用了两个独立的请求对象:一个请求去拿验证码图片,另一个请求去提交答案,服务端根本不知道这俩是同一个用户。
requests.Session 解决的就是这个问题。Session 实例会自动保存服务端下发的 Cookie,默认维持同一个 TCP 连接池,后续请求自带上一轮的上下文。验证码校验逻辑通常是:服务端生成验证码时,把验证码 ID 绑定到当前会话;提交校验时,再从这个会话里取对应的验证码信息。如果你中途换了个 Session,服务端查不到对应验证码,自然返回失败。
所以代码里从一开始就统一用全局的 session 变量,拿图片、提 token、发答案都走它。
另外,请求头里的 Referer 也不能省。目标平台会检查图片请求的来源页面,Referer 对不上会直接返回 403。我测试时去掉 Referer 试过一次,图片 URL 可以打开,但内容是一个错误提示页,而不是验证码本身,属于典型的安全策略拦截。
2.3 验证码图片获取:是图片 URL 还是 Base64 字符串
验证码图片的获取方式有两种,取决于站点前端代码怎么写的。
第一种是后端返回一个图片 URL,前端直接用<img>标签加载。这种情况比较简单,直接用 session.get 带 Referer 去下载图片字节流,再转成 PIL Image 对象。
第二种是后端返回 JSON,图片内容以 Base64 编码嵌在字段里。这种情况需要先提取 Base64 数据,再解码成字节流。我遇到的检索平台是第一种,但我也写了第二种的兼容逻辑,方便切换不同页面的验证码类型。
def fetch_captcha_image(session, captcha_url): resp = session.get(captcha_url, headers=HEADERS, timeout=10) resp.raise_for_status() # 如果返回的是 JSON,可能带 base64 字段 if "json" in resp.headers.get("Content-Type", ""): data = resp.json() b64_str = data.get("img") or data.get("base64") or data.get("captchaImg") if b64_str and "," in b64_str: b64_str = b64_str.split(",", 1)[1] img_bytes = base64.b64decode(b64_str) else: img_bytes = resp.content img = Image.open(io.BytesIO(img_bytes)).convert("RGB") return img这里有一个小技巧:如果 Base64 带data:image/png;base64,前缀,要先用逗号分隔去掉前缀,否则解码会报错或者解出空数据。这个前缀经常被新手忽略,解码失败时错误信息又不够明显,排查起来很浪费时间。
3. PIL 图像预处理与六宫格拆分
3.1 确定六宫格布局与切割坐标
拿到整张验证码图片后,第一步不是急着识别,而是把图片切成六个格子。六宫格说是“六宫”,布局却不固定,常见的有两行三列和三行两列两种。为了兼容这两种情况,不能写死坐标,要用图片宽高动态计算。
两行三列就是宽度被分成三等份,高度被分成两等份;三行两列则反过来。具体是哪种布局,可以通过图片宽高比判断:宽明显大于高的通常是两行三列,宽高接近或者宽更小的可能是三行两列。我这边统一写一个函数,传入行列数动态切分。
def split_captcha_grid(img, rows=2, cols=3): width, height = img.size cell_w = width // cols cell_h = height // rows cells = [] for row in range(rows): for col in range(cols): left = col * cell_w upper = row * cell_h right = left + cell_w lower = upper + cell_h box = (left, upper, right, lower) cells.append(img.crop(box)) return cells这段代码是所有环节里最容易理解也最不容易出问题的。但要注意,有些验证码图片周围会有留白或边框,直接等分可能会把边框裁进每个格子里,影响后续特征匹配。遇到这种情况,我会先按比例裁剪掉外圈 1% 到 2% 的边距,再等分切割。至于具体裁多少,需要先保存一张图用肉眼观察,频率不高的验证码图片值得做一次人工校准。
3.2 灰度化、二值化与去噪处理
六宫格验证码的图片并不是干净的分类图集,上面往往叠加了噪点、线条干扰甚至半透明水印。如果不做预处理直接算像素特征,干扰信息会大幅影响匹配结果。
我的预处理分三步走:
第一步,灰度化。用ImageOps.grayscale()把三通道彩色图转成单通道灰度图,这一步能压缩数据量,去掉颜色变化对亮度梯度的干扰。注意这里我用的是 ImageOps 而不是 Image.convert("L"),两者效果一样,但 ImageOps 在后续配合自动对比度调整时更方便。
第二步,中值滤波。ImageFilter.MedianFilter(size=3)可以去除孤立的噪点,同时保留边缘信息。它和模糊滤镜最大的区别是,模糊会把边缘也糊掉,而中值滤波只打掉孤立的异常像素,对后续边缘分析更友好。
第三步,自动对比度增强。ImageOps.autocontrast()会把像素值重新拉伸到 0 到 255 范围,提升前景和背景的区分度。这一步对拍摄亮度不一致的图片非常有效,相当于做了一次归一化。
def preprocess_image(img): img = ImageOps.grayscale(img) img = img.filter(ImageFilter.MedianFilter(size=3)) img = ImageOps.autocontrast(img) return img这步做完,每张小图里的主体轮廓会清晰很多。不过二值化我没有默认加进去,“前景和背景区分得非常干净”的情况其实很少,多数验证码图有渐变背景,强行二值化容易把物体的一部分像素直接变黑或变白,反而丢失有用信息。不如保留灰度值,让后续的感知哈希算法在连续灰度上计算,容错率更高。
3.3 感知哈希:用汉明距离衡量两张图的相似度
识别“这张图里是不是包含目标物体”,最粗暴的办法是逐像素对比。但图片存在缩放、微小位移、亮度变化,逐像素对比会把这些正常差异当成“不相似”。所以需要一种对缩放、亮度不敏感的图片特征描述方式,感知哈希就是很好的选择。
感知哈希的核心思想是:对图片整体生成一串哈希码,两张图越相似,哈希码的汉明距离越小。常用变体里有 dHash(差异哈希),它比较相邻像素之间的亮度变化趋势,对微小偏移和色调变化更鲁棒,而且实现起来只要十几行代码。
def dhash(image, hash_size=8): image = image.convert("L").resize((hash_size + 1, hash_size), Image.LANCZOS) diff_bits = [] for row in range(hash_size): for col in range(hash_size): left = image.getpixel((col, row)) right = image.getpixel((col + 1, row)) diff_bits.append(1 if left > right else 0) hash_value = 0 for i, bit in enumerate(diff_bits): hash_value |= bit << (i % 8) return hash_value def hamming_distance(h1, h2): return bin(h1 ^ h2).count("1")这段代码的思路是:先把图片缩放到 9×8 像素,横纵相邻的像素点两两比较亮度大小,记下 64 个布尔结果,再转成一个整数哈希值。比较两个哈希值时,用异或统计不同位的数量,数量越小,相似度越高。
对比模板时,我会把每个候选格子的哈希值和目标模板的哈希值做汉明距离计算,距离最小的一个格子就是要找的目标。如果距离最小值和第二小太接近,说明此时区分度不够,可以判定为“不确定”,宁可重试验证码也不要强行提交。
4. 目标识别、提交与自动化闭环
4.1 目标特征判断的两种实操策略
用感知哈希比对之前,还得先有一个“模板图”。目标类别在验证码提示里是变化的,不同提示词对应不同模板,所以我一口气准备了一组模板库,按照提示词分类存放。
第一种策略是单模板匹配。验证码提示是“请选择包含飞机的图片”,那就拿一张清晰的飞机模板图,和六个格子逐一算哈希距离,距离最小的那个就是要点的图。这种方法胜在速度快,缺点是对模板图的样式要求高。模板图和验证码里的目标图如果角度、背景差异太大,哈希距离会整体偏大,导致选错。
第二种策略是多模板投票。对同一个目标类别,准备不同角度、不同背景的多张模板,每个格子和一组模板分别算距离,取该格子的最小距离作为最终得分,然后再比较六个格子的最终得分。多模板投票能明显提升匹配稳定性,代价是每一轮比对的计算时间线性增加。实测下来,六张图配 3 个模板,耗时依旧可以忽略不计。
如果你面对的目标类别非常特殊,颜色具有强区分度,比如“找出红色的球”或“找出蓝色的方块”,还可以直接用颜色特征做第一轮过滤。统计每张图红色通道的像素占比,超过阈值的先进入候选集,再用哈希精排。这类规则筛选能显著降低模板依赖。
4.2 构造并提交 POST 请求的关键细节
识别到目标格子索引后,提交环节是整个链路里隐蔽坑最多的地方。
首先要从页面源码或接口返回里提取隐藏参数。常见的参数有token、captcha_id、callback、session_id等。这些字段名称和提交地址都需要抓包确认。这里没有捷径,该看的请求响应要认真看一遍。
其次要理解提交的“坐标”到底是什么。很多人误以为要提交像素坐标,比如x=135&y=78。实际上六宫格验证码的后端逻辑通常只需要知道你点了第几个格子,所以提交的往往是selected_choice或者choice_index这种字段,值为 0 到 5 的整数索引。如果你看到前端代码里点击事件之后传的是boxIndex,这种字段就是格子索引。
def submit_captcha(session, captcha_id, target_index, token): submit_url = "https://example-patent-platform.org/captcha/verify" data = { "captcha_id": captcha_id, "token": token, "selected_index": str(target_index), "timestamp": int(time.time()), } resp = session.post(submit_url, data=data, headers=HEADERS, timeout=10) result = resp.json() return result.get("success", False)这里要特别强调,timestamp字段不一定要加,加不加取决于接口是否要求。如果你发现每次发的包都和前端差一个时间戳字段,而且服务端会校验时间窗口,那就必须补上。我在调试初期忽略了这个字段,服务端始终返回“参数不完整”,补上以后就正常了。
4.3 验证码刷新与重试闭环
识别算法永远有误差,服务端也会有随机拒绝策略,所以脚本必须带重试机制。最基础的重试逻辑是:如果提交失败,重新获取验证码,重复“下载图片-切割-识别-提交”这个闭环。
MAX_RETRY = 5 for attempt in range(MAX_RETRY): captcha_img = fetch_captcha_image(session, captcha_url) cells = split_captcha_grid(captcha_img) cells = [preprocess_image(cell) for cell in cells] target_idx = select_target_by_hash(cells, templates) if target_idx is None: continue success = submit_captcha(session, captcha_id, target_idx, token) if success: break time.sleep(random.uniform(1, 2)) else: raise RuntimeError("验证码重试次数超限")重试之间要加随机延时,延时区间我一般设在 1 到 2 秒之间,避免形成固定的请求节奏。固定间隔的请求在服务端看来比随机间隔更可疑,被限流的概率更高。还有一点,不要在一次会话里无限重试,超过 5 次就应该彻底退出,换一个新会话重新开始。死磕同一个会话,触发风险比收益大得多。
5. 高频问题与避坑手册
5.1 图片下载回来是空白或损坏页面
这个问题在初次接入时遇到的比例很高,典型表现是:图片 URL 打开正常,但通过脚本下载回来却不是验证码图片,而是一张空白图或者一段错误提示页。
排查顺序可以这样来:先看响应状态码,如果是 403,基本可以确认是请求头问题,重点检查 Referer 和 Cookie;如果状态码是 200 但内容不对,把响应内容保存成文件,用文本编辑器打开看是不是 HTML 页面,是的话说明你请求的地址不对,服务端给你返回了兜底页面;如果图片能打开但带水印,说明验证码图片需要登录后才能获得原始分辨率,检查你是否真的带着登录态的 Cookie 去请求图片。
还有一个容易被忽略的点:图片接口可能需要Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8这样的请求头,而通用浏览器请求头里没有明确指定图片类型,部分后端会因此返回不同格式内容。
5.2 识别准确率不够时的调优方向
如果重试次数居高不下,问题大概率出在模板质量上,而不是图像处理代码上。
先检查模板的尺寸和比例是否和验证码格子一致。PIL 在计算 dHash 时会把图片缩放成固定尺寸,模板缩放后如果失真严重,哈希值会带进大量缩放噪声。解决方法是选用分辨率接近验证码格子原尺寸的模板,并裁剪掉多余留白。
再检查模板是否多样。一个目标类别只准备一张模板,只要目标图角度和模板差得稍微大一点,哈希距离就会超标。多准备几套不同角度的模板,匹配时取最小距离,准确率通常会有立竿见影的提升。
如果目标物体在验证码图里的位置经常被遮挡,感知哈希容易失效。这种场景可以融合颜色直方图特征:先统计目标类别的颜色直方图,再统计每个格子的颜色直方图,比较直方图相似度。感知哈希解决“形状像不像”,颜色直方图解决“颜色是不是一类”,两者融合能扛住大部分遮挡场景。
5.3 请求频率限制与合规边界
再稳定的方案,如果使用姿势不正确,也会被封。我在测试阶段刻意模拟过高频请求,结果不到两分钟,整个出口 IP 就被临时限制。后来加上随机延时并把单次任务的总请求数控制在最小范围,再没有触发过限制。
这套方法本质上是一个自动化交互脚本。如果你准备把它用到别人的平台上,第一件事是确认对方是否允许自动化行为,阅读平台的用户协议,只在自己拥有合法授权的范围内使用。我自己在做功能测试时通常会搭建一个本地 mock 服务,模拟六宫格验证码的页面结构和接口逻辑,在 mock 环境里调试识别和提交流程,确认算法稳定后再决定是否用于合规场景。
因此这里给出一个明确的建议:用这套代码做自动化测试验证、学习图像处理流程、研究验证码机制,是没问题的;但在没有授权的情况下用它去批量采集第三方平台的受保护数据,既破坏文明网络秩序,也可能违反平台规则。做技术的底线是把工具用在正确的地方。
5.4 六宫格布局乱序与坐标变化的坑
最后再说一个很多人会踩的坑:六宫格的排列顺序。有时候服务端会把六张图的位置顺序打乱,每次请求的图都不同。很多人以为这会影响识别,其实不会,因为我们是先切图再根据图像内容判断目标位置,图片顺序无论如何变化,识别逻辑都是内容驱动,和位置无关。
真正需要担心的是另一种情况:验证码会随机切换到不同的布局模式,这次是两行三列,下次是三行两列。解决方法是不要在代码里写死 rows 和 cols,而是根据图片宽度高度估算。宽大于高且比例接近 1.5 的用两行三列,高大于宽用三行两列,实在无法判断就直接抛弃重试。
我在本地 mock 测试时专门做了一个 switch 函数,模拟三种布局下的随机切换,验证了识别闭环在布局变化时依然稳定。这个测试逻辑也一并贴出来作为参考。
def detect_grid_layout(img): width, height = img.size if width > height: return 2, 3 elif height > width: return 3, 2 else: return 2, 3这套判断在大部分场景下够用。如果遇到更特殊的布局,比如一行六列或两行两列加右侧额外区域,就需要手动抓几个样本,把布局规律摸清楚再调整。网格识别这件事,多存几张日志图片总能总结出规律,不复杂,但很值得做。
最后再分享一个实际体会:整套方案能不能跑通,识别算法只占一半,另一半在“会话一致性”和“请求细节模拟”上。很多人拿到别人的代码却发现一张图都识别不出来,不是代码错了,而是环境里的验证码布局、参数名、会话机制都和原作者遇到的不一样。正确姿势是先抓包看明白自己的目标接口,再对照这篇文章的思路调整实现。把 requests 管网络、PIL 管图像这条骨架吃透,六宫格这类验证码就再也不是自动化路上的拦路虎了。