【验证码识别专栏】某盾躲避障碍与某里图像复原验证码识别

2025-04-15ASPCMS社区 - fjmyhfvclm

声明

️本文章中所有内容仅供学习交流使用,不用于其他任何目的,不提供完整代码,抓包内容、敏感网址、数据接口等均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关!

️本文章未经许可禁止转载,禁止任何修改后二次传播,擅自使用本文讲解的技术而导致的任何意外,作者均不负责,若有侵权,请在公众号【K哥爬虫】联系作者立即删除!

前言

近期有粉丝咨询验证码识别相关的问题,是某里 v2 系列中的验证码,其也属于新的验证码类型,但是如果能吃透往期验证码识别相关的文章,这类型验证码的解决方法也是大同小异的。同样,本文也将星球群内,群友的提问一起解决,也欢迎大家随时提问,互相学习:

展开全文

分析目标

  • 地址 1:aHR0cHM6Ly9kdW4uMTYzLmNvbS90cmlhbC9hdm9pZA==
  • 地址 2:aHR0cHM6Ly9oYWhhcGlhby5jbi9wYy8jL2dyYWRPcmRlcg==

验证分析

进入验证码体验页面,找到躲避障碍相关的体验页面,发现这类型验证码的背景是由 5 个图标组成的,其中 4 个为障碍物,其中一个为正确答案,而左下角的白球则是固定的,且有半径大小:

经过分析可知,虽然路径没有碰到障碍物,但是球体本身如果稍微碰到障碍物,或者差一点点碰到障碍物,也是属于验证失败的:

接下来体验的是某里系的图像复原,将缺失的图像在背景图中进行移动,使其复原构成完整的图像。这类型验证码较简单,只需复位即可:

识别分析

障碍躲避

打开验证码体验页,刷新界面,接口得到的图片返回如下,是由原图和目标图拼接而成的:

所以如果我们想拿到目标图(小图),就需要将他从原图上裁剪下来,至于裁剪哪个,这里测试无论是哪个小图都是可以的,默认推荐第一个,代码如下:

url = '图片的URL地址'

response = requests.get(url)

# 将图片内容转换为 numpy 数组

np_data = np.frombuffer(response.content, np.uint8)

# 使用 OpenCV 解码为图像

bg_img = cv2.imdecode(np_data, cv2.IMREAD_UNCHANGED)

char_list = []

char_list.append(bg_img[160:180, 0:20])

至于背景图的检测,可以参考往期文章:

【验证码识别专栏】人均通杀点选验证码:https://mp.weixin.qq.com/s/MgH1obfYrCGivgRvjTX2GA

如果觉得 yolo5 过于麻烦可以优先选择 8、11 等版本,相对 yolo5 而言刚方便,泛型也更强,以下是 yolo8 相关参考训练代码:

from ultralytics import YOLO

import os

os.environ['CUDA_LAUNCH_BLOCKING'] = '1'

model = YOLO("yolov8n.pt")

model.train(data="icon.yaml", epochs=1, device="0", workers=0, name="icon", batch=32)

path = model.export(format="onnx")

print(1)

配置文件 yaml,这里仅仅把背景所有图标归为一类 0,单纯用作目标检测,如果不追求准确率也可以考略使用 dddd 的检测,相关 yaml 配置如下:

path: datasets # dataset root dir

train: train # train images (relative to 'path') 128 images

val: valid # val images (relative to 'path') 128 images

test: # test images (optional)

nc: 1

# Classes

names:

0: 0

至于预测代码,可以使用官方推理也可以使用生态较好的 fastdeploy,相关代码如下:

import fastdeploy as fd

def build_option():

option = fd.RuntimeOption()

return option

icondetection_model=fd.vision.detection.YOLOv8("best.onnx",runtime_option=build_option())

results = icondetection_model.predict(img_array)

label_ids = results.label_ids

boxes = results.boxes

关于孪生的话,这里推荐 2 个:

  • https://github.com/bubbliiiing/Siamese-pytorch (往期文章所用方法)
  • https://github.com/2833844911/dianxuan

对于新手玩家的话推荐第二种办法,本文也以第二种方法为主,训练后导出 onnx:

import torch

from text import Siamese

out_onnx = 'model.onnx'

device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")

dummy = (torch.randn(1, 3, 105, 105).to(device), torch.randn(1, 3, 105, 105).to(device))

model = torch.load('模型路径')

model.eval()

model = model.to(device)

torch_out = torch.onnx.export(model, dummy, out_onnx,input_names=["x1", "x2"])

预测:

import cv2

import onnx

import onnxruntime

import numpy as np

class ImageSimilarityModel:

def __init__(self, model):

onnx_model_path = 'model.onnx'

self.model = onnxruntime.InferenceSession(onnx_model_path)

def sigmoid(self, x):

return 1 / (1 + np.exp(-x))

def preprocess_image(self, img):

img_resized = cv2.resize(img, (40, 40)) # 调整图像大小

img_normalized = np.array(img_resized, dtype=np.float32) / 255.0 # 归一化像素值到 [0, 1]

img_transposed = np.transpose(img_normalized, (2, 0, 1)) # 调整为 CHW 格式

return np.expand_dims(img_transposed, 0) # 增加批量维度

def predict_similarity(self, image_1, image_2):

# 对图像进行预处理

preprocessed_img_1 = self.preprocess_image(image_1)

preprocessed_img_2 = self.preprocess_image(image_2)

prediction_output = self.model.run(None, {"x1": preprocessed_img_1, "x2": preprocessed_img_2})

similarity_score = self.sigmoid(prediction_output[0])

# 返回相似度的百分比

return int(similarity_score[0][0] * 100)

那么返回答案中心点坐标,以及去除答案之外的障碍物的全部代码如下:

class ImageProcessor:

def __init__(self,):

self.icondetection_model = fd.vision.detection.YOLOv8("bestjc.onnx", runtime_option=build_option())

self.siamese_model = ImageSimilarityModel()

def slice_icon_img(self, image):

"""

截取图标图像的特定区域。

"""

data = []

data.append(image[160:180, 0:20])

return data

def decode_image(self, bg):

"""

解码编码的图像数据,并返回OpenCV格式的图像。

"""

image_data = .b64decode(bg) # 解码编码的图像

np_data = np.frombuffer(image_data, np.uint8) # 将字节数据转换为NumPy数组

return cv2.imdecode(np_data, cv2.IMREAD_UNCHANGED) # 转换为OpenCV图像

def match_characters(self, txt_list, char_list):

"""

将字符列表中的每个字符与txt_list中的图像进行匹配,返回匹配结果。

"""

data = {'dataset': [], 'image_box': [], 'center': []} # 存储结果的字典

for char in char_list:

best_match = self.find_best_match(txt_list, char) # 查找与字符最匹配的图像区域

# 将匹配的图像和字符添加到数据集中

matched_img = copy.deepcopy(txt_list[best_match[0]]['img'])

data['dataset'].append([matched_img, best_match[2]])

# 获取匹配的图像区域的坐标

x1, y1, x2, y2 = txt_list[best_match[0]]['xy']

# 计算匹配区域的中心坐标并保存

data['center'].append([int((x1 + x2) / 2), int((y1 + y2) / 2)])

# 将匹配的图像区域的坐标添加到image_box中

data['image_box'].append(txt_list[best_match[0]]['xy'])

# 从txt_list中移除已匹配的图像区域

txt_list.pop(best_match[0])

return data

def find_best_match(self, txt_list, char):

"""

在txt_list中找到与给定字符最匹配的图像,返回最佳匹配的索引、相似度分数和字符。

"""

best_match = [0, 0, None] # 初始化最优匹配的默认值 [索引, 相似度, 字符]

for index, txt in enumerate(txt_list):

score = self.siamese_model.predict_siamese(txt['img'], char)

if score > best_match[1]:

best_match = [index, score, char]

return best_match

def get_remaining_boxes(self, xy_list, image_box):

"""

从原始目标区域列表xy_list中移除已被匹配的区域,返回剩余未匹配的区域。

"""

return [n for n in xy_list if n != image_box]

def get_box(self, bg):

"""

主函数:处理背景图像,识别目标区域并与字符进行匹配,返回匹配结果。

"""

img_data = .b64decode(bg)

nparr = np.frombuffer(img_data, np.uint8)

bg = cv2.imdecode(nparr, cv2.IMREAD_COLOR)

results = self.icondetection_model.predict(bg)

xy_list = results.boxes

xy_list = [[int(coord) for coord in box] for box in xy_list]

print(xy_list)

char_list = self.slice_icon_img(bg) # 获取字符区域

txt_list = []

for n in xy_list:

x1, y1, x2, y2 = n

txt_list.append({'xy': n, 'img': bg[y1:y2, x1:x2]})

# 匹配字符和目标区域,并得到匹配结果

data = self.match_characters(txt_list, char_list)

logger.info(data["image_box"])

updated_xy_list = self.get_remaining_boxes(xy_list, data['image_box'][0])

logger.info(updated_xy_list)

logger.info(data['center'])

return data['center'], updated_xy_list

经过上述流程,我们便可以拿到目标物体以及障碍物坐标的全部信息,下面就是本文的主要内容,如何躲避障碍达到终点,这里便要用到 A* 算法, A* 算法 是一种启发式搜索算法,结合了广度优先搜索(BFS)的全面性和贪心算法的高效性。它通过计算每个节点的代价(f = g + h),其中 g 是从起点到当前节点的实际代价(路径长度),h 是当前节点到终点的最佳距离。那么在生成路径的时候我们还要动态判断路径点是否在障碍物的框内, 如果存在于障碍物的范围内,那么 A* 算法 会跳过这个点,并继续寻找其他可以通行的路径。

经过测试发现,上面我们的那种思路生成的路径有个弊端,就是路径会贴着障碍物,这样肯定是不行的:

网上也有不少方法,是通过减少初始 y 来避免路径紧贴的问题的:

这无疑是一种办法,但是底部就顾不上了,最新版本中,障碍物如果途径底部,那么百分之 99 是成功不了,就算肉眼差一丝没碰到也是不行的:

那么,我们可以采取更灵活的方式,便是障碍物膨胀,把障碍物进行二次处理,即使他紧贴的是膨胀后的障碍物,那对真实的障碍物而言也是丝毫不影响通过率的,那么我们思路便是,向左和向上膨胀 8 个单位(防止膨胀超出边界,确保坐标不小于 0),向右和向下膨胀 8 个单位(防止膨胀超出网格范围,确保坐标不大于 GRID_WIDTH 和 GRID_HEIGHT):

def inflate_obstacles(obstacle_boxes):

inflated_boxes = []

for box in obstacle_boxes:

x1, y1, x2, y2 = box

x1 = max(0, x1 - 8)

y1 = max(0, y1 - 8)

x2 = min(GRID_WIDTH, x2 + 8)

y2 = min(GRID_HEIGHT, y2 + 8)

inflated_boxes.append([x1, y1, x2, y2])

return inflated_boxes

膨胀参数一般 6-8 就可以,不仅如此,我们还可以增加一个障碍物,将下面的路给堵死,在返回障碍物框的时候我们自增一个障碍物框 ️all_box.append([87,142,142,158])

经过测试,多次 100 次后测试后,成功率稳定到 99 左右:

识别失败的我们保存到本地,发现是目标检测的问题,没有框到障碍物导致失败了:

很多人不明白既然膨胀了,为什么还要自己加一个障碍物:

如果我们的目标检测样本数量不是特别多,那么障碍物没有完全框到,即使膨胀那么 A*算法 依旧会选择下方,这时候如果将我们那段障碍物加进去,将下方堵死,那么这种情况就不会错,相反如果你目标检测样本很足,训练拟合很好,单靠膨胀也是可以达到 98+ 的成功率的。

算法以及轨迹生成如下(完整算法可于知识星球获取):

import math

import heapq

import random

import requests

from PIL import Image, ImageDraw

import matplotlib.pyplot as plt

from io import BytesIO

GRID_WIDTH = 320

GRID_HEIGHT = 160

# 膨胀障碍物的函数

def inflate_obstacles(obstacle_boxes):

inflated_boxes = []

for box in obstacle_boxes:

x1, y1, x2, y2 = box

x1 = max(0, x1 - 8)

y1 = max(0, y1 - 8)

x2 = min(GRID_WIDTH, x2 + 8)

y2 = min(GRID_HEIGHT, y2 + 8)

inflated_boxes.append([x1, y1, x2, y2])

return inflated_boxes

# 判断某点是否在障碍物内

def is_point_in_obstacle(point, obstacle):

x, y = point

x1, y1, x2, y2 = obstacle

return x1 <= x <= x2 and y1 <= y <= y2

# A*算法的节点类

class Node:

def __init__(self, position, parent=None):

self.position = position

self.parent = parent

self.g = 0

self.h = 0

self.f = 0

def __eq__(self, other):

return self.position == other.position

def __lt__(self, other):

return self.f < other.f

# 启发式函数(欧几里得距离)

def heuristic(a, b):

return math.sqrt((a[0] - b[0])**2 + (a[1] - b[1])**2)

# A*路径规划算法

def astar(start, end, obstacle_boxes):

open_list = []

closed_list = set()

start_node = Node(start)

end_node = Node(end)

heapq.heappush(open_list, start_node)

directions = [(-1, -1), (-1, 0), (-1, 1),

(0, -1), (0, 1),

(1, -1), (1, 0), (1, 1)]

#部分省略,星球自取

#部分省略,星球自取

return []

# 从URL加载图片的函数

def load_image_from_url(image_url):

response = requests.get(image_url)

img = Image.open(BytesIO(response.content))

return img

# 主函数,接收图片URL、障碍物、终点并返回轨迹

def get_trajectory(image_url, original_obstacle_boxes, start, end):

# 从URL加载图像

image = load_image_from_url(image_url)

obstacle_boxes = inflate_obstacles(original_obstacle_boxes)

path = astar(start, end, obstacle_boxes)

if not path:

print("未找到路径")

return []

t_start = random.randint(50, 80) # 初始时间

t_current = t_start

tracks = []

for point in path:

tracks.append([point[0], point[1], t_current]) # 加入时间

t_current += random.randint(5, 10) # 时间递增

# 返回轨迹

draw = ImageDraw.Draw(image)

for box in original_obstacle_boxes:

draw.rectangle(box, fill="red")

draw.ellipse((start[0] - 3, start[1] - 3, start[0] + 3, start[1] + 3), fill="green", outline="green")

draw.ellipse((end[0] - 3, end[1] - 3, end[0] + 3, end[1] + 3), fill="yellow", outline="yellow")

for i in range(len(path) - 1):

draw.line([path[i], path[i + 1]], fill="blue", width=1)

return tracks,image

pointresult, all_box = yd_icon.get_box(base_img)

all_box.append([87,142,142,158])#障碍物添加

start = (10, 150) #起点

#图片url,障碍物信息,起点,目标点中心x,目标点中心y,

tracks,image = get_trajectory(bg_pic_url, all_box, start, (pointresult[0][0],pointresult[0][1]))

某里 v2 图像复原

关于某里图像复原可以参考往期文章:

【验证码识别专栏】今天不炼丹,用 cv 来秒验证码:https://mp.weixin.qq.com/s/YNP7CmjY6nHiGiDARH5UYQ

图像复原主要就是将缺失的部分从左到右移动到指定位置:

针对这种类型采用暴力匹配图像灰度图的 md5 值与对应的移动 x 距离,与往期文章旋转验证码识别方法基本相似。

首先需要自己写一个标注工具,将原图和小图进行平移拼接,将拼接后的完整图像保存到新的文件夹中,命名就用 原来图像的名字_移动距离:

标注好以后,随后通过遍历原图来得到他的 x 移动距离,生成 pkl 文件:

import os

import pickle

import hashlib

import re

import cv2

def calculate_file_md5(image_path):

"""计算文件的 MD5 值"""

md5_hash = hashlib.md5()

# 读取图像并转换为灰度图像

img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)

# 将图像编码为二进制数据

success, encoded_image = cv2.imencode('.jpg', img) # 转换为 .jpg 格式

if success:

binary_image = encoded_image.tobytes() # 转为二进制数据

md5_hash.update(binary_image) # 更新 MD5

return md5_hash.hexdigest()

def generate_pkl(bg_folder, newbg_folder, output_pkl_file):

# 初始化两个字典

hists = {}

histsx = {}

# 遍历 newbg 文件夹中的文件(已标注的图像)

for newbg_filename in os.listdir(newbg_folder):

match = re.match(r'(\d+)_bg_(\d+)\.png', newbg_filename)

if match:

angle = int(match.group(1))

image_id = match.group(2)

# 找到 bg 文件夹中对应的原图

bg_filename = f'bg_{image_id}.png'

bg_image_path = os.path.join(bg_folder, bg_filename)

if os.path.exists(bg_image_path):

# 计算图像的 MD5 值(灰度图)

md5_value = calculate_file_md5(bg_image_path)

# 保存 MD5 值和角度值到字典中

hists[bg_filename] = md5_value

histsx[bg_filename] = angle

# 将字典保存到 pkl 文件

pkl_data = {'hists': hists, 'histsx': histsx}

with open(output_pkl_file, 'wb') as pkl_file:

pickle.dump(pkl_data, pkl_file)

print(f"pkl 文件已保存:{output_pkl_file}")

# 调用函数生成 pkl 文件

bg_folder = 'bg' # 原图文件夹路径

newbg_folder = 'newbg' # 标注好的图像文件夹路径

output_pkl_file = 'output.pkl' # 输出的 pkl 文件路径

generate_pkl(bg_folder, newbg_folder, output_pkl_file)

预测代码如下:

import cv2

import numpy as np

import

import pickle

import hashlib

import time

def 2cv(_str, decode_type=cv2.IMREAD_COLOR, intensityalpha=False):

"""

将 编码的图片转换为 OpenCV 格式的图片。

:param _str: 编码的图片字符串

:param decode_type: 解码类型

:param intensityalpha: 是否处理透明度通道(Alpha 通道)

:return: 转换后的 OpenCV 图像

"""

if "," in _str:

_str = _str.split(",")[1]

img_data = .b64decode(_str)

nparr = np.frombuffer(img_data, np.uint8)

img = cv2.imdecode(nparr, decode_type)

if intensityalpha and img.shape[2] == 4: # 处理带 Alpha 通道的图像

output_image = np.zeros((img.shape[0], img.shape[1], 3), dtype=np.uint8)

for i in range(img.shape[0]):

for j in range(img.shape[1]):

alpha = img[i, j, 3]

if alpha > 0: # 只有在透明度大于0时才显示

color_intensity = 255 if i % 2 == 0 else 0

color_value = np.array([color_intensity] * 3, dtype=np.uint8)

output_image[i, j] = (color_value * (alpha / 255.0)).astype(np.uint8)

return output_image

return img

def predict_angle(histmodel, histsx, bgimg, queimg):

start_time = time.time()

# 转换背景图和查询图为 OpenCV 格式

md5bg = 2cv(bgimg, decode_type=cv2.IMREAD_GRAYSCALE)

bgimg = 2cv(bgimg, decode_type=cv2.IMREAD_UNCHANGED)

bgimg = cv2.cvtColor(bgimg, cv2.COLOR_BGR2BGRA)

bgimg[:, :, 3] = 0 # 设置 Alpha 通道透明

queimg = 2cv(queimg, decode_type=cv2.IMREAD_UNCHANGED)

queimg[:, :, 3] = 0 # 设置 Alpha 通道透明

# 计算 MD5 值

success, encoded_image = cv2.imencode('.jpg', md5bg)

binary_image = encoded_image.tobytes()

md5_hex = calculate_file_md5(binary_image)

# 合并直方图加载和特征比较逻辑

maxhist = ''

for histk, histv in histmodel.items():

if histv == md5_hex:

maxhist = histk

break

dis = histsx.get(maxhist, 0) # 获取直方图对应的位移量,默认为 0

end_time = time.time()

print(f"时间: {end_time - start_time}s, 最佳匹配: {maxhist}, 位移量: {dis}")

# 计算两张图的位移量(x方向)

x_distance = int(dis)

h1, w1 = bgimg.shape[:2]

h2, w2 = queimg.shape[:2]

# 创建一个足够大的画布用于显示合成图像

max_height = max(h1, h2)

max_width = w1 + w2 + x_distance

canvas = np.zeros((max_height, max_width, 4), dtype=np.uint8)

# 将背景图和查询图放入画布中

canvas[:h1, :w1] = bgimg

canvas[:h2, x_distance:x_distance + w2] = queimg

return dis # 返回预测的位移量(角度)

def calculate_file_md5(binary_data):

md5_hash = hashlib.md5()

md5_hash.update(binary_data)

return md5_hash.hexdigest()

if __name__ == '__main__':

with open('output.pkl', 'rb') as file:

histograms = pickle.load(file)

histmodel = histograms['hists']

histsx = histograms['histsx']

with open('bg/bg_1027072114.png', 'rb') as file:

bgimg_ = .b64encode(file.read()).decode('utf-8')

with open('que/que_1027072114.png', 'rb') as file:

queimg_ = .b64encode(file.read()).decode('utf-8')

predicted_displacement = predict_angle(histmodel, histsx, bgimg_, queimg_)

print(f"x:为: {predicted_displacement}")

结果展示

完整 A*算法 以及标注工具会上传星球,感兴趣的小伙伴可以自行下载。

全部评论