puzzle-captcha

使用 Selenium、Python 与 OpenCV 研究乱序拼图验证码的采集、匹配与还原。

puzzle-captcha 项目封面或真实界面
Jupyter Notebook主要技术栈
5GitHub Stars
8Forks
31索引文件
MIT License许可标记
2025-12-02最近更新

01 / PROJECT OVERVIEW

项目研究解读

使用 Selenium、Python 与 OpenCV 研究乱序拼图验证码的采集、匹配与还原。 本仓库以 Jupyter Notebook 为主要技术栈,当前快照索引 31 个文件。报告依据README 与目录结构整理,重点呈现项目用途、工程结构和复用方式,不把宣传描述等同于验证结论。

02 / CORE CAPABILITIES

值得关注的能力

  1. 01

    围绕“使用 Selenium、Python 与 OpenCV 研究乱序拼图验证码的采集、匹配与还原”提供公开实现或研究资料

  2. 02

    以 Jupyter Notebook 为主,保留 31 个可检查仓库文件

  3. 03

    提供原始 GitHub 链接、目录导览和本地研究快照,便于复核与二次开发

03 / ENGINEERING

工程与仓库结构

仓库以 Jupyter Notebook 为主要实现语言,研究快照包含 31 个文件、5 个主要顶层入口。已提供 README 作为使用与能力说明。 项目最近更新时间为 2025-12-02,许可标记为 MIT License。

01LICENSE

项目文件与实现资料

02README.md

项目文件与实现资料

03docs

设计文档、研究记录或项目配图

04puzzle-captcha.ipynb

项目文件与实现资料

05test.png

项目文件与实现资料

04 / USE CASES

适合用在哪里

  • 学习项目设计与实现
  • 评估后用于二次开发
  • 作为相关领域的研究参考

05 / QUICK START

从仓库开始

以下命令摘自项目 README 或根据仓库地址生成。请先阅读源仓库的安装要求、许可和脚本,再在自己的环境中运行。

!pip install setuptools
!pip install selenium
!pip install numpy Matplotlib
!pip install opencv-python

06 / REPOSITORY MAP

目录证据导览

这里列出快照中的前 31 个索引文件中的代表路径,帮助你快速定位源码、文档、测试与资源。

  • .DS_Store
  • .gitignore
  • LICENSE
  • README.md
  • docs/1.png
  • docs/2.png
  • docs/3.png
  • docs/logo.jpeg
  • docs/output_18_1.png
  • docs/output_18_3.png
  • docs/output_18_4.png
  • docs/output_23_1.png
  • docs/output_27_1.png
  • docs/output_27_3.png
  • docs/output_27_5.png
  • docs/output_29_1.png
  • docs/output_29_3.png
  • docs/output_29_5.png
  • docs/output_32_1.png
  • docs/output_32_3.png
  • docs/output_32_5.png
  • docs/output_34_1.png
  • docs/output_34_2.png
  • docs/output_36_1.png
  • docs/output_38_1.png
  • docs/output_38_3.png
  • docs/output_38_4.png
  • docs/output_38_5.png
  • docs/x.gif
  • puzzle-captcha.ipynb
  • test.png

07 / SOURCE NOTES

README 原文摘录

保留原始说明便于复核。中文研究结论以当前快照为依据,后续变化请以 GitHub 仓库为准。

乱序拼图验证的识别并还原-puzzle-captcha

一、前言

乱序拼图验证是一种较少见的验证码防御,市面上更多的是拖动滑块,被完美攻克的有不少,都在行为轨迹上下足了功夫,本文不讨论轨迹模拟范畴,就只针对拼图还原进行研究。

[图片] 查看原图

找一个市面比较普及的顶像乱序拼图进行验证,它号称的防御能力4星,用户体验3星,通过研究发现,它的还原程度相当高,思路也很简单,下面一步步的讲解还原过程。

[图片] 查看原图

二、环境准备

1.依赖
  • 采集模拟 selenium
  • 特征匹配 python+opencv
2.安装环境
!pip install setuptools
!pip install selenium
!pip install numpy Matplotlib
!pip install opencv-python
3.chormedriver 下载

找到对应浏览器版本+系统平台的driver后,macOS 建议存放到 /usr/local/bin

!wget https://npm.taobao.org/mirrors/chromedriver/95.0.4638.69/chromedriver_mac64.zip

三、采集样本

引入依赖库,使用 webdriver 打开官方网站的产品演示页面

[图片] 查看原图

import os
import cv2
import time
import urllib.request
import matplotlib.pyplot as plt
import numpy as np

from PIL import Image
from selenium import webdriver
from selenium.webdriver import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait

创建下载样本的代码,主要流程是打开官网的demo页后,截图并保存

[图片] 查看原图

# 采集代码
class CrackPuzzleCaptcha():
    # 初始化webdriver
    def init(self):
        self.url = 'https://www.dingxiang-inc.com/business/captcha'
        chrome_options = webdriver.ChromeOptions()
        # chrome_options.add_argument("--start-maximized")
        chrome_options.add_experimental_option("excludeSwitches", ["ignore-certificate-errors","enable-automation"]) # 设置为开发者模式
        path = r'/usr/local/bin/chromedriver' #macOS
#         path = r'D:\Anaconda3\chromedriver.exe' #windows
        self.browser = webdriver.Chrome(executable_path=path,chrome_options=chrome_options)
        #设置显示等待时间
        self.wait = WebDriverWait(self.browser, 20)
        self.browser.get(self.url)
    # 打开验证码demo页面,并强制元素在浏览器可视区域
    def openTest(self):
        time.sleep(1)
        self.browser.execute_script('setTimeout(function(){document.querySelector("body > div.wrapper-main > div.wrapper.wrapper-content > div > div.captcha-intro > div.captcha-intro-header > div > div > ul > li.item-8").click();},0)')
        self.browser.execute_script('setTimeout(function(){document.querySelector("body > div.wrapper-main > div.wrapper.wrapper-content > div > div.captcha-intro > div.captcha-intro-body > div > div.captcha-intro-demo").scrollIntoView();},0)')
        time.sleep(1)
    # 找到原图,webp格式,直接下载保存
    def download(self):
        onebtn = self.browser.find_element_by_css_selector('#dx_captcha_oneclick_bar-logo_2 > span')
        ActionChains(self.browser).move_to_element(onebtn).perform() 
        time.sleep(1)
        #下载webp
        img_url = self.browser.find_element_by_css_selector('#dx_captcha_jigsaw_fragment-top-left_3 > img').get_attribute("src")
        img_address = "test.png" # 样本文件
        response = urllib.request.urlopen(img_url)
        img = response.read()
        with open(img_address, 'wb') as f:
            f.write(img)
            print('已保存', img_address)
        return self.browser
            
    def crack(self):
        pass

开始采集

crack = CrackPuzzleCaptcha()
crack.init()
crack.openTest()
browser2 = crack.download()

已保存 test.png

四、调研结果

  • 关键1:显示的拼图的原图就是已经乱序的状态
  • 关键2:原图是一个整体,那么获取原图切割并编号,能得到与拼图过程一致的结果
  • 关键3:拼图只需要做1次换位即可,2x2的矩阵,可以对[1,2,3,4]进行排列组合,得到所有的拼接结果

五、分析过程

1.辅助函数

定义辅助函数,方便获取参数

# 显示图形
def show_images(images: list , title = '') -> None:
    if title!='':
        print(title)
    n: int = len(images)
    f = plt.figure()
    for i in range(n):
        f.add_subplot(1, n, i + 1)
        plt.imshow(images[i])
    plt.show(block=True)
    
# 获取图像的基本信息
def getSize(p):
    sum_rows = p.shape[0]
    sum_cols = p.shape[1]
    channels = p.shape[2]
    return sum_rows,sum_cols,channels
2.图像切割
# 输入样本
file = 'test.png'
img = cv2.imread(file)

sum_rows,sum_cols,channels = getSize(img)
part_rows,part_cols = round(sum_rows/2),round(sum_cols/2)
print('样本图 高度、宽度、通道',sum_rows,sum_cols,channels)
print('四图切分,求原图中心位置',part_rows,part_cols)

part1 = img[0:part_rows, 0:part_cols]
part2 = img[0:part_rows, part_cols:sum_cols]
part3 = img[part_rows:sum_rows, 0:part_cols]
part4 = img[part_rows:sum_rows, part_cols:sum_cols]

print('切割为4个小块的 W/H/C 信息,并四图编号:左上=1,右上=2,左下=3,右下=4\n',getSize(part1),getSize(part2),getSize(part3),getSize(part4))

show_images([img],'原图')
show_images([part1,part2],'切割图')
show_images([part3,part4])
样本图 高度、宽度、通道 150 300 3
四图切分,求原图中心位置 75 150
切割为4个小块的 W/H/C 信息,并四图编号:左上=1,右上=2,左下=3,右下=4
(75, 150, 3) (75, 150, 3) (75, 150, 3) (75, 150, 3)

原图 [图片] png 切割图 [图片] png [图片] png

完成切割后,还需要重组合并4个图像,用于匹配最佳结果

3.图像拼接
# 拼接函数
def merge(sum_rows,sum_cols,channels,p1,p2,p3,p4):
    final_matrix = np.zeros((sum_rows, sum_cols,channels), np.uint8)
    part_rows,part_cols = round(sum_rows/2),round(sum_cols/2)

    final_matrix[0:part_rows, 0:part_cols] = p1
    final_matrix[0:part_rows, part_cols:sum_cols] = p2
    final_matrix[part_rows:sum_rows, 0:part_cols] = p3
    final_matrix[part_rows:sum_rows, part_cols:sum_cols] = p4
    return final_matrix

从编号上来看,应该将 [1,2,3,4] 还原成 [4,2,3,1] 就是正确的图,测试下还原效果

# 还原图 
f = merge(sum_rows,sum_cols,channels,part4,part2,part3,part1)
show_images([f],'还原图 [4,2,3,1]')

还原图 [4,2,3,1] [图片] png

4.排列组合

已知 python 实现排列组合非常方便,测试代码如下

import itertools

# 对应拼图的4个块的编号
puzzle_list = [
    "1:左上","2:右下",
    "3:左下","4:右下"
]

result = itertools.permutations(puzzle_list,4)
cnt=0
for x in result:
    cnt+=1
    print(x)
print('共',cnt,'种组合')
    
('1:左上', '2:右下', '3:左下', '4:右下')
('1:左上', '2:右下', '4:右下', '3:左下')
('1:左上', '3:左下', '2:右下', '4:右下')
('1:左上', '3:左下', '4:右下', '2:右下')
('1:左上', '4:右下', '2:右下', '3:左下')
('1:左上', '4:右下', '3:左下', '2:右下')
('2:右下', '1:左上', '3:左下', '4:右下')
('2:右下', '1:左上', '4:右下', '3:左下')
('2:右下', '3:左下', '1:左上', '4:右下')
('2:右下', '3:左下', '4:右下', '1:左上')
('2:右下', '4:右下', '1:左上', '3:左下')
('2:右下', '4:右下', '3:左下', '1:左上')
('3:左下', '1:左上', '2:右下', '4:右下')
('3:左下', '1:左上', '4:右下', '2:右下')
('3:左下', '2:右下', '1:左上', '4:右下')
('3:左下', '2:右下', '4:右下', '1:左上')
('3:左下', '4:右下', '1:左上', '2:右下')
('3:左下', '4:右下', '2:右下', '1:左上')
('4:右下', '1:左上', '2:右下', '3:左下')
('4:右下', '1:左上', '3:左下', '2:右下')
('4:右下', '2:右下', '1:左上', '3:左下')
('4:右下', '2:右下', '3:左下', '1:左上')
('4:右下', '3:左下', '1:左上', '2:右下')
('4:右下', '3:左下', '2:右下', '1:左上')
共 24 种组合
5.特征提取

采用 merge 函数,对切割的小图进行组合还原后,转换为灰度图并提取轮廓。

# 还原图 
f = merge(sum_rows,sum_cols,channels,part1,part2,part3,part4)
show_images([f],'还原图[1,2,3,4]')
# 灰度
gray = cv2.cvtColor(f, cv2.COLOR_BGRA2GRAY)
show_images([gray],'灰度')
# 提取轮廓
edges = cv2.Canny(gray, 35, 80, apertureSize=3)
show_images([edges],'提取轮廓')

还原图[1,2,3,4] [图片] png 灰度 [图片] png 提取轮廓 [图片] png

再测试一种新的组合,看看轮廓特征[1,3,2,4]和原始的轮廓特征[4,2,3,1]

f = merge(sum_rows,sum_cols,channels,part1,part3,part2,part4)
gray = cv2.cvtColor(f, cv2.COLOR_BGRA2GRAY)
edges = cv2.Canny(gray, 35, 80, apertureSize=3)
show_images([edges],'提取轮廓')

f = merge(sum_rows,sum_cols,channels,part1,part2,part3,part4)
gray = cv2.cvtColor(f, cv2.COLOR_BGRA2GRAY)
edges = cv2.Canny(gray, 35, 80, apertureSize=3)
show_images([edges],'提取轮廓')

# 正确的
f = merge(sum_rows,sum_cols,channels,part4,part2,part3,part1)
gray = cv2.cvtColor(f, cv2.COLOR_BGRA2GRAY)
edges = cv2.Canny(gray, 35, 80, apertureSize=3)
show_images([edges],'正确的-提取轮廓')

提取轮廓 [图片] png 提取轮廓 [图片] png 正确的-提取轮廓 [图片] png

通过提取轮廓,可以看到拼接结果的明显的线条,错误的图至少存在一条x轴或y轴的线,而拼接成功的基本没有(线段位置或长度及线条数量可以决定正确率,需要多调整参数并筛选)。

这是因为原图有明显的过渡色,它是为了用户体验而设计,方便人们使用它的时候,能够‘容易’的区分,并找出正确的拼图位置。

f = merge(sum_rows,sum_cols,channels,part1,part2,part3,part4)
show_images([f],'背景渐变色')
show_images([part3,part2,part1,part4],'切割后')
f = merge(sum_rows,sum_cols,channels,part1,part2,part3,part4)
lf = f.copy()
cv2.line(lf, (0, 75), (300, 75), (0, 0, 255), 2)
cv2.line(lf, (150, 0), (150, 150), (0, 0, 255), 2)
show_images([lf],'乱序,渐变色成为了‘十字’特征线')

背景渐变色 [图片] png 切割后 [图片] png 乱序,渐变色成为了‘十字’特征线 [图片] png

6.特征匹配

特征已知后,现在剩下的就是对特征进行检测,可以计算 x/2,y/2 十字架的色差,也可以用 opencv 的直线提取,测试代码如下:

f = merge(sum_rows,sum_cols,channels,part1,part2,part3,part4)
gray = cv2.cvtColor(f, cv2.COLOR_BGRA2GRAY)
edges = cv2.Canny(gray, 35, 80, apertureSize=3)
show_images([edges],'提取轮廓')

lines = cv2.HoughLinesP(edges,0.01,np.pi/360,60,minLineLength=50,maxLineGap=10)
if lines is None:
    print('没找到线条')
else:
    lf = f.copy()
    for line in lines:
        x1, y1, x2, y2 = line[0]
        cv2.line(lf, (x1, y1), (x2, y2), (0, 0, 255), 2)
    show_images([lf])

提取轮廓 [图片] png [图片] png

尝试正确的组合 [4,2,3,1]

f = merge(sum_rows,sum_cols,channels,part4,part2,part3,part1)
gray = cv2.cvtColor(f, cv2.COLOR_BGRA2GRAY)
edges = cv2.Canny(gray, 35, 80, apertureSize=3)
show_images([edges],'提取轮廓')

lines = cv2.HoughLinesP(edges,0.01,np.pi/360,60,minLineLength=50,maxLineGap=10)
if lines is None:
    print('没找到线条')
else:
    lf = f.copy()
    for line in lines:
        x1, y1, x2, y2 = line[0]
        cv2.line(lf, (x1, y1), (x2, y2), (0, 0, 255), 2)
    show_images([lf])

提取轮廓 [图片] png

没找到线条
7.匹配过程
import itertools

print('原图顺序')
print(1,2)
print(3,4)
show_images([img])

# 按编号,将切割的图放入list做排列组合
list1 = [
    [1,part1],
    [2,part2],
    [3,part3],
    [4,part4]
]

result = itertools.permutations(list1,4)
idx =1
finded = False
finalResult = []
for x in result:
    # 排列组合合并图像
    f = merge(sum_rows,sum_cols,channels,x[0][1],x[1][1],x[2][1],x[3][1])
    # 图像特征提取
    gray = cv2.cvtColor(f, cv2.COLOR_BGRA2GRAY)
    edges = cv2.Canny(gray, 35, 80, apertureSize=3)
    # 直线匹配
    lines = cv2.HoughLinesP(edges,0.01,np.pi/360,60,minLineLength=50,maxLineGap=10)
    if lines is None:
        print('还原图像')
        show_images([f])
        show_images([gray])
        show_images([edges])
        print('正确顺序')
        print(x[0][0],x[1][0])
        print(x[2][0],x[3][0])
        print('完成!!')
        finded = True
        finalResult =[x[0][0],x[1][0],x[2][0],x[3][0]] #获取最终排列正确的结果
        break
    else:
        print(idx, '排列:' , x[0][0],x[1][0],x[2][0],x[3][0] , '线:', len(lines))
        lf = f.copy()
        for line in lines:
            x1, y1, x2, y2 = line[0]
            cv2.line(lf, (x1, y1), (x2, y2), (0, 0, 255), 2)
#         show_images([lf])
        pass
    idx+=1

print('测试次数',idx,'最终状态',finded,finalResult)
原图顺序
1 2
3 4

[图片] png

1 排列: 1 2 3 4 线: 4
2 排列: 1 2 4 3 线: 5
3 排列: 1 3 2 4 线: 4
4 排列: 1 3 4 2 线: 2
5 排列: 1 4 2 3 线: 3
6 排列: 1 4 3 2 线: 4
7 排列: 2 1 3 4 线: 3
8 排列: 2 1 4 3 线: 5
9 排列: 2 3 1 4 线: 3
10 排列: 2 3 4 1 线: 3
11 排列: 2 4 1 3 线: 1
12 排列: 2 4 3 1 线: 1
13 排列: 3 1 2 4 线: 2
14 排列: 3 1 4 2 线: 2
15 排列: 3 2 1 4 线: 3
16 排列: 3 2 4 1 线: 3
17 排列: 3 4 1 2 线: 5
18 排列: 3 4 2 1 线: 3
19 排列: 4 1 2 3 线: 4
20 排列: 4 1 3 2 线: 3
21 排列: 4 2 1 3 线: 2

还原图像 [图片] png [图片] png [图片] png

正确顺序
4 2
3 1

完成! 测试次数 22 最终状态 True [4, 2, 3, 1]

8.提取结果

再看看如何这种拼图,如果要交换位置的组合有12种

list1 = [1,2,3,4]

result = itertools.permutations(list1,2)
idx=0
for x in result:
    idx+=1
    print(idx,x)
1 (1, 2)
2 (1, 3)
3 (1, 4)
4 (2, 1)
5 (2, 3)
6 (2, 4)
7 (3, 1)
8 (3, 2)
9 (3, 4)
10 (4, 1)
11 (4, 2)
12 (4, 3)
#交换函数
def change_check(a,b):
    diffs = []
    if len(a)!=len(b):
        return diffs
    
    for i in range(len(a)):
        if a[i]!=b[i]:
            diffs.append(b[i])
    return diffs

ab = change_check([1,2,3,4],finalResult)
print('原始',[1,2,3,4])
print('最终',finalResult)
print('要交换的位置',ab)
原始 [1, 2, 3, 4]
最终 [4, 2, 3, 1]
要交换的位置 [4, 1]

将‘交换的位置’换算成小图中心的偏移坐标,采用查表法