引言

最近 hCaptcha 迎来了一次更新,从之前的物体识别,改成了选出图片中的垂直河流。如上图,截自https://www.hcaptcha.com/

说实话,第一看看到就知道是一些生成模型搞的,因为图片特征很明显,有些地方特别模糊,然后生成的其实很没有逻辑。最开始以为是 GPT-3 的应用,就把图片描述转成文字,后来觉得不合理,因为太缺乏想象力了,而且各个元素的分布其实也特别明显,都是直直的,很不自然。应该是一些从语义图片生成原始图片的工作,例如 NVIDIA 的一些艺术创作。

做之前我没想到过 NVIDIA 的那些 GAN 居然可以用到验证码领域,也没想到他升级之后从原来的目标检测降维到了只需要图像处理就能通过。

正文

@QIN2DIM 之前就写过一个 hCaptcha-challenger 的项目利用 YOLOv5 来搞定,在更新后 release 了一个数据集 vertical_river,于是我用图像处理光速实现了一个 Demo。

首先这个图像特征很明显,主要就是有几个元素:后面的天空、山、草地、水,又是非常明显的划分和分布,都是一块一块的。

最一开始的思路是,先来几个滤波,比如 高斯滤波 ,然后直接用 slic 进行超像素分割,期望的结果是,超像素能够把每个色块全部归成一个超像素。最后结果其实不是很理想,因为超像素的划分很大程度上不是特别依赖颜色,而且如果限制了超像素数量就会让一些更大颜色范围的聚成一类。下面是一个例子。

其实是可以看出来很多问题的,有些该划分的部分并没有得到正确的划分,反而聚成了一类,有些不该划分的地方,反而因为滤波的作用让他们聚成了一类。

最后反复调整参数无果,感觉这种划分方式不应该采用一个超像素作为一个色块,而是以超像素的划分结果作为参考或者划分边界来进行划分。

本来想尝试一些边缘增强的算法,但是也没有找到合适的,因为观察到验证码的图片大多边界非常模糊,所以在划分的时候保留边缘信息是十分重要的。所以在选取滤波的时候,就得特别重视这个滤波是否能正确的保存边缘信息,而不是让边缘模糊,其中 双边滤波 就是一个很好的选择,除此之外,均值偏移也是一个不错的选择。

经过这两步,你已经变得 近视 了,但是边缘还比较清晰,你现在可以做色块划分了。这里主要用了 scikit-image.graph.rag_mean_color 来获取平均色块,主要参考了官方的 RAG Merge 的实现。

判断条件就写的比较简单了,我只判断了最后一行是否存在 3 个色块及以上,如果存在那就认为中间是有被河流分开,存在垂直河流。感觉这个判断条件还是可以优化一下的,不过经过测试,大概 100 张图片也就错 2 张图片左右,即使不优化这个正确率其实也是可以接受的。

最后效果是这样的:

发了一个 pr,被 @QIN2DIM merge 以后的效果如下:

测试 + 可视化用到的 Code:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# @File    :   src\services\hcaptcha_challenger\river_challenger.py
# @Time    :   2022-03-01 20:32:08
# @Author  :   Bingjie Yan
# @Email   :   bj.yan.pa@qq.com
# @License :   Apache License 2.0

import cv2
import numpy as np

import skimage
from skimage.morphology import disk
from skimage.segmentation import watershed, slic, mark_boundaries
from skimage.filters import rank
from skimage.util import img_as_ubyte
from skimage.color import rgb2gray, label2rgb
from skimage.future import graph
from scipy import ndimage as ndi
import matplotlib.pyplot as plt


def _weight_mean_color(graph, src, dst, n):
    """Callback to handle merging nodes by recomputing mean color.

    The method expects that the mean color of `dst` is already computed.

    Parameters
    ----------
    graph : RAG
        The graph under consideration.
    src, dst : int
        The vertices in `graph` to be merged.
    n : int
        A neighbor of `src` or `dst` or both.

    Returns
    -------
    data : dict
        A dictionary with the `"weight"` attribute set as the absolute
        difference of the mean color between node `dst` and `n`.
    """

    diff = graph.nodes[dst]['mean color'] - graph.nodes[n]['mean color']
    diff = np.linalg.norm(diff)
    return {'weight': diff}


def merge_mean_color(graph, src, dst):
    """Callback called before merging two nodes of a mean color distance graph.

    This method computes the mean color of `dst`.

    Parameters
    ----------
    graph : RAG
        The graph under consideration.
    src, dst : int
        The vertices in `graph` to be merged.
    """
    graph.nodes[dst]['total color'] += graph.nodes[src]['total color']
    graph.nodes[dst]['pixel count'] += graph.nodes[src]['pixel count']
    graph.nodes[dst]['mean color'] = (graph.nodes[dst]['total color'] /
                                      graph.nodes[dst]['pixel count'])


class RiverChallenger(object):
    def __init__(self) -> None:
        pass

    def challenge(self, img_stream):
        img_arr = np.frombuffer(img_stream, np.uint8)
        img = cv2.imdecode(img_arr, flags=1)
        height, width = img.shape[:2]

        # # filter
        img = cv2.pyrMeanShiftFiltering(img, sp=10, sr=40)
        img = cv2.bilateralFilter(img, d=9, sigmaColor=100, sigmaSpace=75)

        # # enhance brightness
        # img_hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
        # img_hsv[:, :, 2] = img_hsv[:, :, 2] * 1.2
        # img = cv2.cvtColor(img_hsv, cv2.COLOR_HSV2BGR)

        labels = slic(img, compactness=30, n_segments=400, start_label=1)
        g = graph.rag_mean_color(img, labels)

        labels2 = graph.merge_hierarchical(labels,
                                           g,
                                           thresh=35,
                                           rag_copy=False,
                                           in_place_merge=True,
                                           merge_func=merge_mean_color,
                                           weight_func=_weight_mean_color)

        # view results
        out = label2rgb(labels2, img, kind='avg', bg_label=0)
        out = mark_boundaries(out, labels2, (0, 0, 0))
        skimage.io.imshow(out)
        skimage.io.show()
        print(np.unique(labels2[-1]))

        ref_value = len(np.unique(labels2[-1]))
        return ref_value >= 3


if __name__ == '__main__':
    import os
    import sys
    sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

    # result_path = 'result.txt'
    # if os.path.exists(result_path):
    #     os.remove(result_path)

    # result_file = open(result_path, 'w')
    result_file = sys.stdout

    base_path = os.path.join('database', '_challenge')
    list_dirs = os.listdir(base_path)
    for dir in list_dirs[:1]:
        print(dir, file=result_file)
        for i in range(1, 10):
            img_filepath = os.path.join(base_path, dir, f'挑战图片{i}.png')

            with open(img_filepath, "rb") as file:
                data = file.read()

            rc = RiverChallenger()
            result = rc.challenge(data)
            print(f'挑战图片{i}.png:{result}', file=result_file)

结语

神经网络让一切变得复杂了,又让一切变得简单了。

(论 hCaptcha 工程师看到自己开发半天的项目被不到一夜之间用了更简单的算法突破了是什么体验?)