资讯详情 六宫格验证码自动化识别:requests+PIL+感知哈希实战
📅 2026/10/21 9:55:23
如果你做过专利检索或者批量爬取公开数据大概率见过那个烦人的六宫格验证码弹出一个两行三列的小窗口让你选出所有包含某个物体的图片选完才能继续查数据。我第一次碰到它时还在用浏览器自动化硬钢结果光是等页面渲染、等图片加载就花掉好几秒识别还得靠人眼盯屏幕根本谈不上自动化。后来我换了个思路整个流程不走浏览器渲染直接用 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/xhtmlxml,application/json;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.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, headersHEADERS, timeout10) 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, rows2, cols3): 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(size3)可以去除孤立的噪点同时保留边缘信息。它和模糊滤镜最大的区别是模糊会把边缘也糊掉而中值滤波只打掉孤立的异常像素对后续边缘分析更友好。第三步自动对比度增强。ImageOps.autocontrast()会把像素值重新拉伸到 0 到 255 范围提升前景和背景的区分度。这一步对拍摄亮度不一致的图片非常有效相当于做了一次归一化。def preprocess_image(img): img ImageOps.grayscale(img) img img.filter(ImageFilter.MedianFilter(size3)) img ImageOps.autocontrast(img) return img这步做完每张小图里的主体轮廓会清晰很多。不过二值化我没有默认加进去“前景和背景区分得非常干净”的情况其实很少多数验证码图有渐变背景强行二值化容易把物体的一部分像素直接变黑或变白反而丢失有用信息。不如保留灰度值让后续的感知哈希算法在连续灰度上计算容错率更高。3.3 感知哈希用汉明距离衡量两张图的相似度识别“这张图里是不是包含目标物体”最粗暴的办法是逐像素对比。但图片存在缩放、微小位移、亮度变化逐像素对比会把这些正常差异当成“不相似”。所以需要一种对缩放、亮度不敏感的图片特征描述方式感知哈希就是很好的选择。感知哈希的核心思想是对图片整体生成一串哈希码两张图越相似哈希码的汉明距离越小。常用变体里有 dHash差异哈希它比较相邻像素之间的亮度变化趋势对微小偏移和色调变化更鲁棒而且实现起来只要十几行代码。def dhash(image, hash_size8): 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等。这些字段名称和提交地址都需要抓包确认。这里没有捷径该看的请求响应要认真看一遍。其次要理解提交的“坐标”到底是什么。很多人误以为要提交像素坐标比如x135y78。实际上六宫格验证码的后端逻辑通常只需要知道你点了第几个格子所以提交的往往是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, datadata, headersHEADERS, timeout10) 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/svgxml,image/*,*/*;q0.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 管图像这条骨架吃透六宫格这类验证码就再也不是自动化路上的拦路虎了。