3916 字
20 分钟
OpenCV 实现 GeeTest4 动态点选验证码识别

最近在一个浏览器自动化项目中遇到了一类 GeeTest4 动态点选验证码:页面上方给出一个目标图标,下方的背景区域里排列着 4 个候选图标,需要找出与目标一致的那一个并点击。

这类图片不适合直接用固定坐标,因为候选项的位置和内容会动态变化;。

思路是:先找到 4 个候选区域,再提取目标和候选项的前景形状,最后综合多种相似度指标选出最佳结果。

基于 OpenCV + NumPy 实现了一个方案,它最终返回候选项中心点坐标,可以直接交给 Playwright 点击。

使用范围

本文代码仅用于自有系统、测试环境或已获得明确授权的自动化测试。验证码属于访问控制措施,请勿用于绕过第三方网站限制或进行未经授权的数据采集。

先看效果#

一闪一闪的是因为一直在调用Plyawright的元素截图,截图传入识别逻辑中。

效果

一、问题建模#

识别器接收两张图片:

  • target_path:只包含目标符号的提示图。
  • background_path:包含 2×2 四个候选区域的验证码截图。

输出值是匹配候选项在背景截图中的中心坐标 (x, y)。如果所有候选项的外观相似度都低于阈值,则返回 None,避免低置信度误点。

整个处理链路可以概括为:

  • 目标点击图案

    • 灰度化 ──> Otsu 二值化 ──> 连通域去噪 ──> 紧边界裁剪 ──> 64×64 归一化
  • 候选图背景

    • 固定阈值分割 ──> 轮廓检测 ──> 定位四个候选框 ──> 逐项执行同样的符号处理

最后计算三路相似度,加权评分。返回候选图中识别选中的中心坐标。

这里有两个重要前提:背景图中必须恰好存在 4 个较大的候选块,并且它们能按上下两行排列成 2×2 网格。如果验证码版式改变,需要相应调整候选区域检测逻辑。

二、依赖与调用方式#

安装依赖:

pip install opencv-python numpy

最小调用示例:

recognizer = Geetest4Recognizer(min_appearance=0.70)
center = recognizer.find_matching_center(
    "target.png",
    "background.png",
)

if center is not None:
    print(f"应点击的坐标为: {center}")

在 Playwright 中,如果 background.png 是对验证码元素本身截图得到的,那么返回值也是相对该元素左上角的坐标,可以直接用于元素内点击:

target_element.screenshot(path=target_path)
background_element.screenshot(path=background_path)

center = recognizer.find_matching_center(target_path, background_path)
if center is not None:
    x, y = center
    background_element.click(position={"x": x, "y": y})

这里必须保证“截图元素”和“点击元素”是同一个元素,否则坐标原点不同,还需要额外做偏移换算。

三、结果数据结构#

每个候选项的计算结果由不可变数据类 _MatchResult 保存:

@dataclass(frozen=True)
class _MatchResult:
    index: int
    position: str
    bbox: tuple[int, int, int, int]
    center: tuple[int, int]
    score: float
    appearance_score: float
    hash_score: float
    pixel_score: float
    component_score: float

字段含义如下:

字段含义
index候选项序号,从 1 开始
position左上、右上、左下或右下
bboxOpenCV 矩形框 (x, y, width, height)
center候选框中心点,也是最终点击坐标
hash_score感知哈希相似度
pixel_scoreDice 像素重合度
component_score连通域结构相似度
appearance_score用于可信度过滤的外观分
score用于最终排序的综合分

保存所有中间分数的好处是便于调试。识别失败时,不只是得到一个 None,还可以从日志中判断究竟是哈希、像素重合还是结构特征拉低了结果。

四、定位四个候选区域#

1. 固定阈值二值化#

背景截图中的候选块通常与浅色背景有明显差异。代码使用阈值 250 做反向二值化:

_, block_mask = cv2.threshold(
    background,
    250,
    255,
    cv2.THRESH_BINARY_INV,
)

灰度值高于 250 的近白色背景变为黑色,其余内容变为白色。这样候选块就成为适合轮廓检测的前景区域。

此处使用固定阈值,而后面提取符号时使用 Otsu 自适应阈值,两者解决的问题不同:这里要利用页面背景颜色稳定的特点找大块区域;后面则要适应不同图标自身的亮度变化。

2. 外轮廓与面积过滤#

contours, _ = cv2.findContours(
    block_mask,
    cv2.RETR_EXTERNAL,
    cv2.CHAIN_APPROX_SIMPLE,
)
min_area = background.size * 0.05

cv2.RETR_EXTERNAL 只取最外层轮廓,避免候选块内部的符号产生嵌套轮廓。随后只保留面积不小于整张图 5% 的轮廓,小噪点和文字不会被当作候选框。

代码要求最终恰好得到 4 个矩形,否则立即抛出异常。这种严格检查看起来不够“宽容”,但能阻止版式识别错误后继续产生错误点击。

3. 按 2×2 网格排序#

OpenCV 返回轮廓的顺序没有业务含义,因此需要主动排序:

  1. 以背景图高度中线为界,拆分为上、下两组。
  2. 每组按矩形的 x 坐标从小到大排序。
  3. 拼接成左上、右上、左下、右下。

这样 _POSITION_NAMES 和候选项序号才能保持稳定,日志也更容易阅读。

五、提取符号前景#

候选框中仍包含留白、边框和噪点,不能直接拿原图比较。_extract_symbol_mask 完成了三步清洗。

1. Otsu 自动分割#

_, mask = cv2.threshold(
    image,
    0,
    255,
    cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU,
)

Otsu 算法根据当前图片的灰度直方图自动寻找阈值。加上 THRESH_BINARY_INV 后,较深的符号成为白色前景,较浅的背景成为黑色。

2. 连通域去噪#

component_count, labels, stats, _ = cv2.connectedComponentsWithStats(mask, 8)
min_area = max(2, round(image.size * 0.00008))

这里采用 8 邻域连通域分析。面积小于阈值的区域被认为是噪点,其余连通域重新画入一张干净掩膜。阈值与当前图片面积成比例,同时至少为 2 个像素,兼顾不同截图尺寸。

需要注意的是,这一步不会只保留最大连通域。目标符号可能天然由多个不相连的笔画组成,如果只保留最大块,结构信息反而会被破坏。

3. 紧边界裁剪#

通过所有前景像素的最小、最大坐标裁剪掩膜:

ys, xs = np.where(cleaned > 0)
return cleaned[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1]

裁剪后只保留符号本体,消除了原图四周留白不同带来的位置干扰。如果完全没有检测到前景,则抛出 ValueError,避免后续出现除零或空数组问题。

六、归一化尺寸与位置#

目标图和候选图的尺寸通常不完全一致。代码把紧边界掩膜缩放并居中到 64×64 画布中:

scale = min((size - 2 * padding) / width, (size - 2 * padding) / height)

默认四周预留 8 像素边距,可用区域为 48×48。取宽、高两个缩放比例中的较小值,可以在不改变长宽比的前提下让整个符号完整放入画布。缩放完成后再次二值化,减少插值产生的灰度边缘影响。

这一步同时解决了三个变量:

  • 尺度差异:统一到相同画布大小。
  • 平移差异:统一放到画布中心。
  • 长宽比保护:不会把高瘦图标强行拉成方形。

但它不会消除旋转和镜像差异。如果验证码会随机旋转图标,还需要加入角度校正或多角度匹配。

七、三路相似度#

单一指标很容易在相似图标之间误判,因此代码并行计算三类特征。

1. 感知哈希相似度#

首先把归一化掩膜缩小到 32×32,然后执行二维离散余弦变换 DCT:

frequencies = cv2.dct(resized.astype(np.float32))[:8, :8].reshape(-1)
frequencies = frequencies[1:]
return frequencies > np.median(frequencies)

DCT 会把图像从空间域转换到频率域,左上角 8×8 区域保存主要的低频结构。第一个值是代表整体亮度的直流分量,信息区分度较低,所以将其移除,最后得到一个 63 位布尔哈希。

两个哈希的相似度就是 1 减去不同位所占比例:

hash_similarity = 1 - HammingDistance / 63

pHash 更关注整体轮廓,对少量像素噪声和轻微缩放比较稳定。

2. Dice 像素相似度#

Dice 系数直接衡量两个二值前景的重叠程度。设目标前景像素集合为 A,候选项为 B

Dice(A, B) = 2 × |A ∩ B| / (|A| + |B|)

完全重合时得分为 1,没有重合时为 0。它对细节差异敏感,正好补充 pHash 偏重整体结构的特点。

由于 Dice 依赖像素对齐,前面的紧边界裁剪、等比例缩放和居中非常关键。没有归一化时,即使两个符号相同,也可能因为位置偏移得到很低的分数。

3. 连通域结构相似度#

有些符号整体轮廓相近,但笔画的断开方式不同。代码为原始紧边界掩膜提取三组结构特征:

  • 连通域数量。
  • 各连通域高度降序排列后,相对于最大高度的比例序列。
  • 连通域平均“纵向程度”,即 log1p(min(height / width, 10))

对应的三个子分数为:

count_score       = 1 - |目标数量 - 候选数量| / max(目标数量, 候选数量, 1)
height_score      = 1 - mean(|目标高度签名 - 候选高度签名|)
verticality_score = exp(-|目标纵向程度 - 候选纵向程度|)

高度签名长度不一致时,短的一方用 0 补齐。最终结构分采用以下权重:

component_score = 0.55 × count_score
                + 0.30 × height_score
                + 0.15 × verticality_score

连通域数量的权重最高,因为它对“一个整体”和“多个分离笔画”的区分最直接;高度签名和纵向程度用于进一步区分局部形态。

八、为什么要做两阶段决策#

代码没有直接选总分最高的候选项,而是先按外观分过滤,再按综合分排序。

外观分只包含 pHash 和 Dice:

appearance_score = 0.55 × hash_score + 0.45 × pixel_score

综合分加入了结构特征:

score = 0.50 × hash_score
      + 0.40 × pixel_score
      + 0.10 × component_score

选择过程如下:

  1. 只保留 appearance_score >= min_appearance 的候选项。
  2. 如果没有合格项,输出最接近项的分数并返回 None
  3. 如果存在合格项,从中选择综合分 score 最高的候选项。

这种设计把“是否足够像”和“合格项中谁更像”分开了。结构特征只占总分 10%,用于接近候选项之间的微调;它不能让一个外观明显不匹配的候选项仅凭连通域结构蒙混过关。

默认阈值是 0.68,实际调用时可以提高到 0.70 来降低误点率。阈值越高,错误点击越少,但返回 None 和刷新验证码的次数会增加。

九、坐标为何可以直接点击#

候选框由 cv2.boundingRect 返回:

(x, y, width, height)

中心坐标计算为:

center = (x + width // 2, y + height // 2)

只要背景截图直接来自 Playwright 的 element.screenshot(),图片左上角就是元素内坐标 (0, 0)。因此识别器返回的中心点恰好符合 locator.click(position=...) 所需的相对坐标,不需要换算成整个页面的绝对坐标。

中心点击也比点击某个前景像素更稳健:图标笔画可能靠近边缘,而候选区域中心通常有足够大的可点击范围。

附件: 完整源码#

下面是本文所分析的 val_geetest4.py 完整源码:

"""
Author: Xu JunLiang
Created: 2026-08-01
File: val_geetest4.py

Description: 通过 GeeTest4 动态点选验证码
"""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path

import cv2
import numpy as np

_POSITION_NAMES = ("左上", "右上", "左下", "右下")
_DEFAULT_MIN_APPEARANCE = 0.68
_APPEARANCE_HASH_WEIGHT = 0.55
_APPEARANCE_PIXEL_WEIGHT = 0.45
_TOTAL_HASH_WEIGHT = 0.50
_TOTAL_PIXEL_WEIGHT = 0.40
_TOTAL_STRUCTURE_WEIGHT = 0.10


@dataclass(frozen=True)
class _MatchResult:
    index: int
    position: str
    bbox: tuple[int, int, int, int]
    center: tuple[int, int]
    score: float
    appearance_score: float
    hash_score: float
    pixel_score: float
    component_score: float


class Geetest4Recognizer:
    def __init__(self, min_appearance: float = _DEFAULT_MIN_APPEARANCE) -> None:
        self.min_appearance = min_appearance

    def find_matching_center(
        self,
        target_path: str | Path,
        background_path: str | Path,
    ) -> tuple[int, int] | None:
        target_path = Path(target_path)
        background_path = Path(background_path)
        results = self._recognize(target_path, background_path)
        eligible_results = [
            result
            for result in results
            if result.appearance_score >= self.min_appearance
        ]
        closest = max(results, key=lambda result: result.appearance_score)

        self._print_scores(target_path, background_path, results)
        if not eligible_results:
            print(
                "\n识别结果: 未找到匹配项。"
                f"最接近的是第 {closest.index} 个({closest.position}),"
                f"外观相似度={closest.appearance_score:.4f},"
                f"低于门槛 {self.min_appearance:.4f}"
            )
            return None

        best = max(eligible_results, key=lambda result: result.score)
        print(
            f"\n识别结果: 第 {best.index} 个({best.position}),"
            f"背景图内中心坐标={best.center}"
        )
        return best.center

    def _recognize(
        self,
        target_path: Path,
        background_path: Path,
    ) -> list[_MatchResult]:
        target_image = self._read_grayscale(target_path)
        background = self._read_grayscale(background_path)
        rectangles = self._find_option_rectangles(background)

        target_mask = self._extract_symbol_mask(target_image)
        normalized_target = self._normalize_mask(target_mask)
        target_hash = self._perceptual_hash(normalized_target)

        results: list[_MatchResult] = []
        for index, (x, y, width, height) in enumerate(rectangles, start=1):
            option_image = background[y : y + height, x : x + width]
            option_mask = self._extract_symbol_mask(option_image)
            normalized_option = self._normalize_mask(option_mask)

            current_hash_score = self._hash_similarity(
                target_hash,
                self._perceptual_hash(normalized_option),
            )
            current_pixel_score = self._dice_similarity(
                normalized_target,
                normalized_option,
            )
            current_component_score = self._structural_similarity(
                target_mask,
                option_mask,
            )
            appearance_score = (
                _APPEARANCE_HASH_WEIGHT * current_hash_score
                + _APPEARANCE_PIXEL_WEIGHT * current_pixel_score
            )
            score = (
                _TOTAL_HASH_WEIGHT * current_hash_score
                + _TOTAL_PIXEL_WEIGHT * current_pixel_score
                + _TOTAL_STRUCTURE_WEIGHT * current_component_score
            )
            results.append(
                _MatchResult(
                    index=index,
                    position=_POSITION_NAMES[index - 1],
                    bbox=(x, y, width, height),
                    center=(x + width // 2, y + height // 2),
                    score=score,
                    appearance_score=appearance_score,
                    hash_score=current_hash_score,
                    pixel_score=current_pixel_score,
                    component_score=current_component_score,
                )
            )
        return results

    @staticmethod
    def _read_grayscale(path: Path) -> np.ndarray:
        image = cv2.imread(str(path), cv2.IMREAD_GRAYSCALE)
        if image is None:
            raise ValueError(f"无法读取图片: {path}")
        return image

    @staticmethod
    def _find_option_rectangles(
        background: np.ndarray,
    ) -> list[tuple[int, int, int, int]]:
        _, block_mask = cv2.threshold(
            background,
            250,
            255,
            cv2.THRESH_BINARY_INV,
        )
        contours, _ = cv2.findContours(
            block_mask,
            cv2.RETR_EXTERNAL,
            cv2.CHAIN_APPROX_SIMPLE,
        )
        min_area = background.size * 0.05
        rectangles = [
            cv2.boundingRect(contour)
            for contour in contours
            if cv2.contourArea(contour) >= min_area
        ]
        if len(rectangles) != 4:
            raise ValueError(
                f"期望从背景图中识别出 4 个候选区域,实际识别到 {len(rectangles)} 个"
            )

        middle_y = background.shape[0] / 2
        top = sorted(
            (rect for rect in rectangles if rect[1] < middle_y),
            key=lambda rect: rect[0],
        )
        bottom = sorted(
            (rect for rect in rectangles if rect[1] >= middle_y),
            key=lambda rect: rect[0],
        )
        if len(top) != 2 or len(bottom) != 2:
            raise ValueError("无法将 4 个候选区域排列成 2 x 2 网格")
        return top + bottom

    @staticmethod
    def _extract_symbol_mask(image: np.ndarray) -> np.ndarray:
        _, mask = cv2.threshold(
            image,
            0,
            255,
            cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU,
        )
        component_count, labels, stats, _ = cv2.connectedComponentsWithStats(mask, 8)
        cleaned = np.zeros_like(mask)
        min_area = max(2, round(image.size * 0.00008))
        for label in range(1, component_count):
            if stats[label, cv2.CC_STAT_AREA] >= min_area:
                cleaned[labels == label] = 255

        ys, xs = np.where(cleaned > 0)
        if xs.size == 0:
            raise ValueError("图片中没有识别到图标前景")
        return cleaned[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1]

    @staticmethod
    def _normalize_mask(
        mask: np.ndarray,
        size: int = 64,
        padding: int = 8,
    ) -> np.ndarray:
        height, width = mask.shape
        scale = min((size - 2 * padding) / width, (size - 2 * padding) / height)
        resized_width = max(1, round(width * scale))
        resized_height = max(1, round(height * scale))
        resized = cv2.resize(
            mask,
            (resized_width, resized_height),
            interpolation=cv2.INTER_AREA,
        )
        _, resized = cv2.threshold(resized, 127, 255, cv2.THRESH_BINARY)

        normalized = np.zeros((size, size), dtype=np.uint8)
        x = (size - resized_width) // 2
        y = (size - resized_height) // 2
        normalized[y : y + resized_height, x : x + resized_width] = resized
        return normalized

    @staticmethod
    def _perceptual_hash(mask: np.ndarray) -> np.ndarray:
        resized = cv2.resize(mask, (32, 32), interpolation=cv2.INTER_AREA)
        frequencies = cv2.dct(resized.astype(np.float32))[:8, :8].reshape(-1)
        frequencies = frequencies[1:]
        return frequencies > np.median(frequencies)

    @staticmethod
    def _hash_similarity(left: np.ndarray, right: np.ndarray) -> float:
        return float(1.0 - np.mean(np.logical_xor(left, right)))

    @staticmethod
    def _dice_similarity(left: np.ndarray, right: np.ndarray) -> float:
        left_foreground = left > 0
        right_foreground = right > 0
        denominator = np.count_nonzero(left_foreground) + np.count_nonzero(
            right_foreground
        )
        if denominator == 0:
            return 0.0
        intersection = np.count_nonzero(left_foreground & right_foreground)
        return float(2 * intersection / denominator)

    @staticmethod
    def _component_features(mask: np.ndarray) -> tuple[int, np.ndarray, float]:
        count, _, stats, _ = cv2.connectedComponentsWithStats(mask, 8)
        components = [
            stats[index]
            for index in range(1, count)
            if stats[index, cv2.CC_STAT_AREA] >= 2
        ]
        if not components:
            return 0, np.zeros(1, dtype=np.float32), 0.0

        heights = np.array(
            sorted((item[3] for item in components), reverse=True),
            dtype=np.float32,
        )
        heights /= heights.max()
        verticality = float(
            np.mean(
                [
                    np.log1p(min(item[3] / max(item[2], 1), 10.0))
                    for item in components
                ]
            )
        )
        return len(components), heights, verticality

    def _structural_similarity(
        self,
        target: np.ndarray,
        option: np.ndarray,
    ) -> float:
        target_count, target_heights, target_verticality = self._component_features(
            target
        )
        option_count, option_heights, option_verticality = self._component_features(
            option
        )
        max_count = max(target_count, option_count, 1)
        count_score = 1.0 - abs(target_count - option_count) / max_count

        signature_size = max(len(target_heights), len(option_heights))
        target_signature = np.pad(
            target_heights,
            (0, signature_size - len(target_heights)),
        )
        option_signature = np.pad(
            option_heights,
            (0, signature_size - len(option_heights)),
        )
        height_score = 1.0 - float(
            np.mean(np.abs(target_signature - option_signature))
        )
        verticality_score = float(
            np.exp(-abs(target_verticality - option_verticality))
        )
        return 0.55 * count_score + 0.30 * height_score + 0.15 * verticality_score

    @staticmethod
    def _print_scores(
        target_path: Path,
        background_path: Path,
        results: list[_MatchResult],
    ) -> None:
        print(f"目标图: {target_path}")
        print(f"背景图: {background_path}")
        print("\n候选项得分:")
        for result in results:
            print(
                f"  {result.index}. {result.position}: 总分={result.score:.4f}, "
                f"外观={result.appearance_score:.4f}, "
                f"pHash={result.hash_score:.4f}, 像素={result.pixel_score:.4f}, "
                f"结构={result.component_score:.4f}"
            )
OpenCV 实现 GeeTest4 动态点选验证码识别
https://blog.kimbleex.top/posts/2026-08-01-geetest4-recognizer/
作者
许骏亮
发布于
2026-08-01
许可协议
CC BY-NC-SA 4.0