用NCC识别验证码:一个简单但有效的Python实战思路

互联网的"门神"与"潜行者"

在互联网的世界里,我们都是数据流中的匆匆过客。而有些网站为了阻止那些不怀好意的"潜行者"(自动化程序),特意设置了一道道关卡------它们就是我们熟悉的验证码(CAPTCHA) , 这个单词来自 Completely Automated Public Turing test to tell Computers and Humans Apart 的英文首字母缩写。中文大概意思是: 全自动公共图灵测试,用于区分计算机和人类

对于人类而言,这些扭曲的字母、模糊的数字或是简单的拼图,不过是举手之劳。但对于程序来说,这就好比是给它们出了一道无字天书,让它们寸步难行。

然而,凡是设卡的地方,就有绕路的智慧。今天,我们就来聊聊破解这些"数字门神"的几种"潜行"方法,并重点介绍其中一位最朴实无华,但对付简单验证码最有效的"指纹"鉴定师------NCC

"潜行"攻略一:找个"枪手"------打码平台

这是最简单、最偷懒的方法,就像找个专业团队代劳。你只管把验证码图片发过去,对方就会帮你把结果搞定。

优点: 几乎所有类型的验证码,从简单的数字到复杂的拼图,他们都能处理。你完全不需要自己研究技术,省心省力。

缺点: 这种方法也有致命的软肋。首先,你的验证码数据会经过第三方平台,存在隐私泄露的风险。其次,这是一种"按次收费"的服务,量大时成本惊人。最后,网络的延迟也可能影响你的操作速度。

"潜行"攻略二:训练一个"天才"------机器学习模型

这种方法更像是一个"养成"游戏。你需要收集大量的验证码图片,像老师教学生一样,教会一个机器学习模型(比如用ONNX框架),让它学会自己识别。

优点: 一旦"天才"养成,识别速度飞快,且完全在你自己的掌控之下,没有隐私和成本问题。

缺点: 然而,"天才"的养成之路漫长且艰辛。你需要海量的标注数据,这本身就是一个巨大的工程。而且,这些模型对硬件环境有较高的要求,有时候甚至会出现"水土不服"的情况(比如不支持ARM架构等),限制了它的应用场景。

"潜行"攻略三:成为"指纹鉴定师"------NCC

现在,轮到我们的主角 NCC(Normalized Cross-Correlation,归一化互相关)登场了。它不像前两种方法那样高大上,但对于简单的、字体规则的验证码,它的效率和准确性令人惊叹。

简单来说,NCC 的核心思想是模板匹配 。它就像一个精明的侦探,手里拿着一份嫌疑人的"指纹样本"(模板图 ),然后去案发现场(验证码图片)逐一比对,看看哪个地方能完美吻合。

它通过计算两个图像区域的相似度,给出一个0到1之间的得分,得分越高,相似度就越高。最重要的是,它还能自动忽略光线和对比度的变化,让比对结果更加精准可靠。

NCC的硬核内功心法:数学原理的通俗解读

要真正理解NCC为什么这么强大,我们需要稍稍深入它的底层原理。别担心,我们不用复杂的公式,只用最通俗的比喻。

NCC 的核心是两个步骤:互相关归一化

  1. 第一步:互相关(Cross-Correlation) 这就像是一个"像素点"的求和游戏。假设我们有一个小小的"模板图"(比如一个数字"3"的图片),和一张大大的"验证码图"。互相关算法会拿着模板,在验证码图上从左到右、从上到下滑动,就像放大镜在纸上移动一样。在每一个位置,它都会把模板图和验证码图上对应的像素点的亮度值相乘,然后把所有的乘积结果加起来。

    如果两个图像区域非常相似,它们的像素值变化趋势几乎一致,相乘后求和得到的值就会非常高。反之,如果它们南辕北辙,相乘后的值可能就会很小,甚至为负。

  2. 第二步:归一化(Normalization) 如果只做互相关,有一个巨大的问题:如果两张图都很亮,那乘积结果自然会很大;如果两张图都很暗,结果就会很小。这就像是,你不能拿一个高音喇叭和一首轻柔的歌曲去比较谁的音量大,因为它们的基础音量就不同。

    归一化的作用,就是解决这个问题。它会把互相关的结果除以两个图像区域各自的"能量"(你可以理解为它们所有像素亮度值的总和)。这样,无论两张图是亮是暗,最终得出的相似度得分都将被压缩到一个固定的区间内(通常是-1到1)。最终,得分越接近1,就代表它们越相似,得分越接近-1,就代表它们越不相似。

正是有了归一化这个步骤,NCC才能成为一个不受光线、对比度影响的"指纹鉴定师"。

NCC的优势:

  • 轻量,不挑设备: 它的计算量非常小,任何一台普通的设备都能轻松运行,对CPU资源几乎没有要求。
  • 无需训练,即插即用: 你不需要花时间和精力去训练模型,只要有现成的模板,就能立即投入使用。
  • 本地计算,速度极快: 所有的计算都在本地完成,没有网络延迟,对于需要高速识别的场景,简直是神一样的存在。

NCC实战:三步破解一个简单验证码

光说不练假把式,现在我们来亲自体验一下 NCC 的"指纹鉴定"能力。 互联网平台有一种典型的滑块验证码:需要通过用户通过滑动块滑动到准确位置之后解锁,这种验证码最大的特点就是模版和背景图基本都同时给到了,很适合使用NCC技法。

第一步:准备"指纹样本"

作为我们的比对模板。这些模板就像是鉴定师的"标准指纹库"。

第二步:获取"待鉴定对象"

接下来,就是我们从网站上抓取到的验证码图片,也就是我们待鉴定的"对象"。

第三步:NCC的"鉴定过程"与结果

现在,我们让 NCC 侦探开始工作。它会拿着每一个模板,在验证码图片的每一个位置进行比对,并记录下相似度得分最高的结果。这个过程就像在做一张"热力图",颜色越亮的地方,代表相似度越高。下图是我使用Python执行之后将NCC找到的位置用红色框画出的样子,从结果来看,它的算法虽然简单,但对于这类任务,却比那些复杂的机器学习模型更有效率。

python 复制代码
"""演示一下如何使用NCC 以及扣除透明背景寻找真实的模版

作者: shellvon
"""
import base64
import io
import numpy as np
from PIL import Image, ImageDraw
import logging

# 配置日志
_LOGGER = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')

class NCCMatcherCLI:
    """
    使用归一化互相关 (NCC) 算法进行带透明度模板匹配的命令行工具。
    """
    
    def __init__(self):
        self.matcher = NCCMatcher()

    def process_images(self, template_base64_str: str, background_base64_str: str):
        """
        处理并匹配图像。
        """
        _LOGGER.info("正在处理模板...")
        try:
            # 1. 提取非透明部分作为最终模板,并获取 Y 坐标
            final_template_base64, found_y = self._extract_opaque_and_get_y(template_base64_str)
            if final_template_base64 is None:
                _LOGGER.error("模板处理失败,程序退出。")
                return

            _LOGGER.info(f"提取非透明部分成功。找到的第一个非透明像素Y坐标为: {found_y}")

            # 2. 找到最佳匹配位置
            x, y, confidence = self.matcher.find_best_match(final_template_base64, background_base64_str)
            
            # 3. 保存文件并输出结果
            self._save_and_output_results(
                final_template_base64, 
                background_base64_str, 
                x, y, 
                confidence,
                found_y
            )

        except Exception as e:
            _LOGGER.error(f"处理过程中发生错误: {e}")
            
    def _extract_opaque_and_get_y(self, template_base64: str) -> tuple:
        """
        从带有透明度的PNG模板中提取非透明区域的图像数据,并返回其base64和找到的Y坐标。因为Y也可以让我们后续查找提速。
        """
        try:
            template_img = Image.open(io.BytesIO(base64.b64decode(template_base64)))
            
            if template_img.mode != 'RGBA':
                _LOGGER.error("提供的模板图像不包含Alpha通道。")
                return None, None
                
            template_data = np.array(template_img)
            alpha_channel = template_data[:, :, 3]
            
            # 找到非透明像素的边界
            rows, cols = np.where(alpha_channel > 0)
            
            if len(rows) == 0 or len(cols) == 0:
                _LOGGER.warning("模板中没有非透明像素。")
                return None, None
                
            min_y, max_y = np.min(rows), np.max(rows)
            min_x, max_x = np.min(cols), np.max(cols)
            
            # 裁剪出非透明区域的RGB/RGBA数据
            # 这里为了保存透明度信息,我们保留RGBA模式
            opaque_img = template_img.crop((min_x, min_y, max_x + 1, max_y + 1))
            
            # 将裁剪后的图像重新编码为base64
            with io.BytesIO() as buffer:
                opaque_img.save(buffer, format='PNG')
                final_template_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
            
            return final_template_base64, min_y
            
        except Exception as e:
            _LOGGER.error(f"提取非透明模板失败: {e}")
            return None, None

    def _save_and_output_results(self, final_template_base64: str, background_base64: str, 
                                 x: int, y: int, confidence: float, found_y: int):
        """
        保存所有相关文件并输出结果到控制台。
        """
        # 保存原始背景图
        bg_img = self.matcher._base64_to_image(background_base64)
        bg_img.save("background.png")
        _LOGGER.info("原始背景图已保存到 background.png")

        # 保存裁剪后的模板
        final_tmpl_img = self.matcher._base64_to_image(final_template_base64)
        final_tmpl_img.save("temp.png")
        _LOGGER.info("抠图后的模板已保存到 temp.png")
        
        print("\n========== 匹配结果 ==========")
        print(f"模板抠图后,第一个非透明像素的Y坐标: {found_y}")
        if x is not None and y is not None:
            print(f"找到最佳匹配位置: (x={x}, y={y})")
            print(f"NCC相关性: {confidence:.4f}")
            self.matcher.draw_match_rectangle(background_base64, x, y, final_template_base64, 'result.png')
        else:
            print("未找到最佳匹配位置。")
            
        print("================================")
        

class NCCMatcher:
    def _base64_to_image(self, base64_data: str) -> Image.Image:
        if base64_data.startswith('data:image/'):
            base64_data = base64_data.split(',')[1]
        image_data = base64.b64decode(base64_data)
        return Image.open(io.BytesIO(image_data))

    def _ncc_with_mask(self, window: np.ndarray, template: np.ndarray, mask: np.ndarray) -> float:
        window = window.astype(np.float64)
        template = template.astype(np.float64)
        
        masked_window = window * mask
        masked_template = template * mask
        
        valid_pixels = mask > 0
        if not np.any(valid_pixels):
            return 0
        
        window_mean = np.mean(masked_window[valid_pixels])
        template_mean = np.mean(masked_template[valid_pixels])
        
        diff_window = masked_window - window_mean
        diff_template = masked_template - template_mean
        
        numerator = np.sum(diff_window * diff_template)
        
        std_window = np.sqrt(np.sum(diff_window ** 2))
        std_template = np.sqrt(np.sum(diff_template ** 2))
        
        if std_window == 0 or std_template == 0:
            return 0
            
        correlation = numerator / (std_window * std_template)
        return correlation

    def find_best_match(self, template_base64: str, background_base64: str) -> tuple:
        try:
            template_img = self._base64_to_image(template_base64)
            background_img = self._base64_to_image(background_base64)

            if template_img.mode != 'RGBA':
                _LOGGER.error("模板图像不包含Alpha通道,无法进行带掩码的NCC匹配。")
                return None, None, None
            
            template_data = np.array(template_img)
            template_gray = template_data[:, :, 0:3].mean(axis=2).astype(np.uint8)
            template_mask = template_data[:, :, 3] / 255.0

            bg_data = np.array(background_img)
            if bg_data.ndim == 3:
                bg_gray = bg_data[:, :, 0:3].mean(axis=2).astype(np.uint8)
            else:
                bg_gray = bg_data
                
            bg_height, bg_width = bg_gray.shape
            tmpl_height, tmpl_width = template_gray.shape

            max_corr = -1.0
            best_x, best_y = -1, -1

            for y in range(bg_height - tmpl_height + 1):
                for x in range(bg_width - tmpl_width + 1):
                    window = bg_gray[y:y + tmpl_height, x:x + tmpl_width]
                    corr = self._ncc_with_mask(window, template_gray, template_mask)
                    
                    if corr > max_corr:
                        max_corr = corr
                        best_x, best_y = x, y

            _LOGGER.info(f"匹配完成。最佳位置: (x={best_x}, y={best_y}), NCC相关性: {max_corr:.4f}")
            
            return best_x, best_y, max_corr

        except Exception as e:
            _LOGGER.error(f"模板匹配失败: {e}")
            return None, None, None

    def draw_match_rectangle(self, background_base64: str, x: int, y: int, template_base64: str, output_path: str):
        if x is None or y is None:
            _LOGGER.warning("未找到匹配位置,跳过绘制。")
            return
        try:
            background_img = self._base64_to_image(background_base64)
            template_img = self._base64_to_image(template_base64)
            tmpl_width, tmpl_height = template_img.size
            
            draw = ImageDraw.Draw(background_img)
            draw.rectangle([x, y, x + tmpl_width, y + tmpl_height], outline="red", width=2)
            
            background_img.save(output_path)
            _LOGGER.info(f"绘制匹配结果图已保存到: {output_path}")

        except Exception as e:
            _LOGGER.error(f"绘制匹配矩形失败: {e}")

if __name__ == '__main__':
    cli = NCCMatcherCLI()
    template_base64_str = '....不要带前缀data:image/png;base64, 若有,请split(",")[1]'
    background_base64_str = '....'
    cli.process_images(template_base64_str, background_base64_str)

我们使用上述照片可以拿到如下输出:

ini 复制代码
2025-08-19 09:36:17,198 - INFO - 正在处理模板...
2025-08-19 09:36:17,219 - INFO - 提取非透明部分成功。找到的第一个非透明像素Y坐标为: 97
2025-08-19 09:36:20,444 - INFO - 匹配完成。最佳位置: (x=235, y=97), NCC相关性: 0.7417
2025-08-19 09:36:20,464 - INFO - 原始背景图已保存到 background.png
2025-08-19 09:36:20,465 - INFO - 抠图后的模板已保存到 temp.png

========== 匹配结果 ==========
模板抠图后,第一个非透明像素的Y坐标: 97
找到最佳匹配位置: (x=235, y=97)
NCC相关性: 0.7417
2025-08-19 09:36:20,484 - INFO - 绘制匹配结果图已保存到: result.png
================================

上面的代码,最重要的部分就是 NCCMatcher 实现了对应的核心逻辑,其中 _ncc_with_mask 方法是 NCC 的核心计算部分,而 find_best_match 则负责遍历图像并找到最佳匹配。上述代码我额外多了透明处理是因为我在实战的时候,实际上拿到的模版图是带有透明背景的占位,需要预处理,所以多了一部分代码,这是带有占位的图:

NCC的局限性与验证码的未来

尽管 NCC 对于简单的验证码是利器,但它并非万能。它的局限性非常明显:它对图像的旋转、倾斜、复杂背景、噪声等情况非常敏感。只要验证码的样式稍有变化,我们精心准备的模板就会失效。

这正是验证码设计者和破解者之间永恒的"猫鼠游戏"。当传统的基于图像的验证码逐渐被攻克时,新的"门神"也随之出现,比如:

  • 行为验证码:它们不再要求你识别图片,而是通过你的鼠标轨迹、滑动速度、点击习惯等行为特征来判断你是否是人类。
  • 无感验证码:更高级的验证码,它会在你访问网站时,在后台默默地收集你的设备信息、IP地址、浏览历史等,然后通过一个复杂的风险评估模型,直接给出你是人类还是机器的结论,全程你可能都不会察觉到它的存在。

你看,NCC 就像是一位老派侦探,戴着圆框眼镜,拿着放大镜,在像素的丛林里一格一格地搜寻真相。它不懂 AI,不会深度学习,也不需要 GPU 集群,但它有一颗执着的心和一套可靠的数学逻辑。

它赢不了未来的"无感验证码",也破不了行为分析的迷阵,但在那些简单、重复、规则明确的战场上,它依然是那个"轻量级拳王"------不炫技,但高效;不昂贵,但可靠。

这场"人机攻防战"永远不会结束。今天的破解者,可能是明天的防御者;今天的验证码,可能是后天的笑谈。但有一点是确定的:只要还有"门",就会有人想绕路;只要还有"规则",就会有人研究"例外"。

或许,真正的终极验证码,不是让你证明"你不是机器",而是系统已经默认你是人,除非你表现得像个机器人。

到那时,NCC 可能早已退休,但它教会我们的一课,依然值得铭记:有时候,最简单的解法,才是最优雅的。

相关推荐
金古圣人6 小时前
hot100 子串
数据结构·c++·算法·leetcode
LQ深蹲不写BUG6 小时前
深挖三色标记算法的底层原理
java·算法
BlackPercy7 小时前
【图论】Graphs.jl 最小生成树算法文档
算法·图论
THMAIL7 小时前
机器学习从入门到精通 - Python环境搭建与Jupyter魔法:机器学习起航必备
linux·人工智能·python·算法·机器学习·docker·逻辑回归
悟能不能悟7 小时前
快速排序算法详解
数据结构·算法·排序算法
jiaway8 小时前
【C语言】第二课 位运算
c语言·开发语言·算法
Q741_1478 小时前
C++ 面试高频考点 力扣 153. 寻找旋转排序数组中的最小值 二分查找 题解 每日一题
c++·算法·leetcode·面试·二分查找
麦格芬2308 小时前
LeetCode 994 腐烂的橘子
算法·leetcode·职场和发展
mit6.8248 小时前
[re_3]
c++·算法