【Web逆向】 文字点选及语序点选验证码识别方案
| 本文章所有内容仅供学习和研究使用,本人不提供具体模型和源码。若有侵权,请联系我立即删除!维护网络安全,人人有责。
前言
目标网址: aHR0cHM6Ly94d3F5LmdzeHQuZ292LmNuLw==
该网站验证码是混合类型的,触发验证会随机弹出以下类型的验证码:
九宫格点选,图标点选,文字点选,语序文字点选
本文主要围绕文字点选及语序点选的识别方案
叠甲:小白第一次发帖,文中代码大部分由AI辅助完成,有不足之处,还请各位大佬指点
思路:
因为两种都是文字点选验证码,只是验证方式不一样,所以决定采用同一种方式来做文字识别,
后续再采用不同方案来作验证
文字识别方案:yolo目标检测+yolo分类识别
准备工作
验证码图片下载
请求图片接口,将图片保存在本地电脑上,因为后续要做yolo分类识别,所以需要非常多的数据集,我这边大概采了10000张左右
yolo目标检测
数据集准备(图片标注)
推荐数据标注网站:https://roboflow.com/ (需科学上网)
非常方便的在线数据标注网站,标注完后可一键导出yolo格式的数据集
网站使用步骤参考文章:https://blog.csdn.net/weixin_43466192/article/details/154242167
因为这里只做目标检测,不做文字识别,所以我们从下载好的图片拿出50-60张图片标注即可
标注好的图片,按训练集:验证集:测试集=7:2:1分,导出数据集即可
yolo预训练模型下载,直接去官网下载 https://docs.ultralytics.com/zh/
我这边下载的是yolo11n模型
模型训练
yolo训练及识别前的准备工作,例如python环境依赖等就不过多赘述了,网上有很多资料,自行查阅
# 训练代码
from ultralytics import YOLO
def train_yolo():
model = YOLO("yolo11n.pt")
# 开始训练
model.train(
data="path/to/your_data.yaml", # 数据集配置文件路径
epochs=100, # 训练轮数
imgsz=640, # 输入图像尺寸
batch=16, # 批次大小 (也可设为 -1 自动调整)
device=0, # 使用 GPU (0, 1...) 或 "cpu"
workers=8, # 数据加载线程数
)
model.export(format="onnx")
if __name__ == "__main__":
train_yolo()
yolo分类识别
数据集准备
现在我们要利用刚训练好的检测模型来识别之前的10000张验证码图片,将识别出目标(单个中文字)分割出来,都放到同一个文件夹里
# 验证码文字分割
import os
import cv2
import numpy as np
import onnxruntime as ort
# ================= 配置修改 =================
MODEL_PATH = "./best.onnx"
INPUT_DIR = "img/" #输入文件夹
OUTPUT_DIR = "targets/" #输出文件夹
IMG_SIZE = (320, 224) # 必须与模型训练时的 input shape 一致
CONF_THRES = 0.25
IOU_THRES = 0.45
SAVE_SIZE = (64, 64)
# ===========================================
if not os.path.exists(OUTPUT_DIR): os.makedirs(OUTPUT_DIR)
session = ort.InferenceSession(MODEL_PATH, providers=['CPUExecutionProvider'])
input_name = session.get_inputs()[0].name
def process_image(img_path):
img_raw = cv2.imread(img_path)
if img_raw is None: return
# 1. 预处理
# BGR -> RGB (重要!YOLO通常需要RGB)
img = cv2.cvtColor(img_raw, cv2.COLOR_BGR2RGB)
img = cv2.resize(img, IMG_SIZE)
# HWC -> CHW, 归一化, 添加 Batch 维度
img = img.transpose(2, 0, 1)
img = np.expand_dims(img, axis=0).astype(np.float32) / 255.0
# 2. 推理
outputs = session.run(None, {input_name: img})[0]
# 兼容处理: (1, 85, N) -> (1, N, 85)
if outputs.shape[1] < outputs.shape[2]:
outputs = outputs.transpose(0, 2, 1)
preds = outputs[0]
# 3. 筛选与 NMS
boxes, scores, class_ids = [], [], []
# 假设格式: [x, y, w, h, obj_conf, cls_conf...]
# 如果是只有1个类别的验证码,通常取第4列(obj)或第5列(cls)为置信度
for det in preds:
# 这里做了简化:取第4个值作为置信度(针对不同YOLO版本可能需要调整为 det[4] * det[5:] 的最大值)
score = det[4]
if score > CONF_THRES:
# 还原坐标到原图
h_raw, w_raw = img_raw.shape[:2]
x, y, w, h = det[0:4]
# 缩放比例
scale_x, scale_y = w_raw / IMG_SIZE[0], h_raw / IMG_SIZE[1]
# 转为左上角坐标 (x, y, w, h)
x = (x - w / 2) * scale_x
y = (y - h / 2) * scale_y
w *= scale_x
h *= scale_y
boxes.append([int(x), int(y), int(w), int(h)])
scores.append(float(score))
# NMS 去重
indices = cv2.dnn.NMSBoxes(boxes, scores, CONF_THRES, IOU_THRES)
# 保存前3个目标
if len(indices) > 0:
indices = indices.flatten()[:3] # 取前3个
file_base = os.path.basename(img_path).split('.')[0]
for i, idx in enumerate(indices):
x, y, w, h = boxes[idx]
# 边界保护
x, y = max(0, x), max(0, y)
if w > 0 and h > 0:
crop = img_raw[y:y + h, x:x + w]
if crop.size > 0:
# 统一 Resize 到 64x64
cv2.imwrite(f"{OUTPUT_DIR}/{file_base}_{i}.jpg", cv2.resize(crop, SAVE_SIZE))
# 批量运行
for f in os.listdir(INPUT_DIR):
process_image(os.path.join(INPUT_DIR, f))
分割完成后,就会获得一个未经过分类的文字数据集
现在的目标是将相同的文字放入同一个文件夹,当然我们不可能手动一个个去做分类
我们可以调用ocr模型的接口来帮我们做分类,我这边使用的是qwen-vl-ocr(可以去百炼平台复制自己的api-key,一般会有免费额度)
注意:图片存在干扰,ocr识别不同模型不同效果
import os
import json
import dashscope
import shutil
import uuid
from tqdm import tqdm
from dashscope import MultiModalConversation
from concurrent.futures import ThreadPoolExecutor
dashscope.base_http_api_url = "https://dashscope.aliyuncs.com/api/v1"
SOURCE_DIR = "targets/" # 待识别的小图文件夹
DATASET_ROOT = "dataest/" # 结果存放根目录(按汉字命名文件夹)
MAX_WORKERS = 15
def qwen_ocr(local_path):
try:
image_path = f"file://{local_path}"
messages = [
{
"role": "user",
"content": [
{
"image": image_path,
"min_pixels": 32 * 32 * 3,
"max_pixels": 32 * 32 * 8192,
"enable_rotate": True,
},
{
"text": """
请提取图像中的单个文字,只有一个中文字,图像是经过混淆的,字体可能存在旋转30度左右的情况,仔细辨别。
返回数据格式以json方式输出,格式为:{'result': 'x'}
"""
},
]
}
]
response = MultiModalConversation.call(
api_key='YOUR_TOKEN', # 替换成你自己的api-key
model="qwen-vl-ocr",
messages=messages,
)
text = response["output"]["choices"][0]["message"].content[0]["text"]
json_str = text.strip().replace('```json', '').replace('```', '').replace("'", '"')
json_data = json.loads(json_str)
return json_data
except Exception as e:
print(e)
def clean_text(text):
"""清洗识别结果,去除特殊符号,防止创建文件夹失败"""
if not text: return None
text = text.strip()
# 排除 Windows/Linux 文件系统非法字符
invalid_chars = ['\\', '/', ':', '*', '?', '"', '<', '>', '|']
for char in invalid_chars:
text = text.replace(char, '')
return text if text else None
def process_single_image(file_name):
"""单个图片处理逻辑"""
local_path = os.path.join(SOURCE_DIR, file_name)
try:
# 1. 调用你的 OCR 函数
result = qwen_ocr(local_path)
text = result['result']
# 2. 清洗结果
folder_name = clean_text(text)
# 如果识别为空,或者是长文本(超过1个字可能是识别错了),归入 unknown
if not folder_name or len(folder_name) > 1:
folder_name = "unknown"
# 3. 创建目标文件夹
target_dir = os.path.join(DATASET_ROOT, folder_name)
os.makedirs(target_dir, exist_ok=True)
# 4. 生成新文件名 (防止覆盖)
# 使用 uuid 确保唯一性,保留原后缀
ext = os.path.splitext(file_name)[1]
new_name = f"{uuid.uuid4().hex[:8]}{ext}"
target_path = os.path.join(target_dir, new_name)
# 5. 移动文件 (或用 shutil.copy 复制)
shutil.move(local_path, target_path)
return True
except Exception as e:
print(f"Error processing {file_name}: {e}")
return False
def main():
if not os.path.exists(DATASET_ROOT):
os.makedirs(DATASET_ROOT)
# 获取所有图片
images = [f for f in os.listdir(SOURCE_DIR) if f.lower().endswith(('.jpg', '.png', '.jpeg'))]
print(f"共发现 {len(images)} 张图片,开始分类...")
# 多线程并发处理
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
# 使用 tqdm 显示进度条
list(tqdm(executor.map(process_single_image, images), total=len(images)))
print("分类完成!")
if __name__ == '__main__':
main()
等待分类完成后,就会获得这样的文件夹,大概会有一千多类
接下来也是最耗时间,最折磨的一步,人工审查这一千多类的文件夹
将识别错的文字放入正确的文件夹,例如以下这个情况
全部分类好后,我们需要分出训练集以及验证集
先做统一标签体系(建立映射表)
import os
import json
import csv
from pathlib import Path
def generate_dataset_metadata(data_root, output_dir='.'):
"""
扫描数据集文件夹,生成 class_map.json 和 dataset.csv
"""
data_root = Path(data_root)
valid_exts = {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.webp'} # 根据需要增减
print(f" 正在扫描目录: {data_root} ...")
# 1. 获取所有类别文件夹并排序 (保证索引一致性)
# 使用 list comprehension + sorted 确保跨平台顺序一致
classes = sorted([
d.name for d in os.scandir(data_root)
if d.is_dir() and not d.name.startswith('.')
])
if not classes:
print("[!] 未找到类别文件夹,请检查路径。")
return
# 2. 生成映射字典 {汉字: ID}
class_to_idx = {cls_name: i for i, cls_name in enumerate(classes)}
# 3. 遍历采集图片路径
data_list = []
file_count = 0
for cls_name in classes:
cls_dir = data_root / cls_name
cls_idx = class_to_idx[cls_name]
# 使用 scandir 高效遍历
with os.scandir(cls_dir) as entries:
for entry in entries:
if entry.is_file():
# 后缀检查 (转小写比较)
if os.path.splitext(entry.name)[1].lower() in valid_exts:
# 记录绝对路径和标签ID
data_list.append((entry.path, cls_idx))
file_count += 1
print(f" 扫描完成。共 {len(classes)} 个汉字类别,{file_count} 张图片。")
# 4. 导出映射文件 (class_map.json)
map_path = os.path.join(output_dir, 'class_map.json')
with open(map_path, 'w', encoding='utf-8') as f:
json.dump(class_to_idx, f, ensure_ascii=False, indent=4)
print(f"[+] 映射文件已保存: {map_path}")
# 5. 导出数据列表 (dataset.csv)
csv_path = os.path.join(output_dir, 'dataset.csv')
with open(csv_path, 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
writer.writerow(['image_path', 'label_idx']) # 表头
writer.writerows(data_list)
print(f"[+] 数据列表已保存: {csv_path}")
if __name__ == '__main__':
# 替换为你的实际数据集根目录路径
DATASET_ROOT = r'./dataest'
generate_dataset_metadata(DATASET_ROOT)
会得到一个csv文件以及json文件,利用这两个文件来分数据集
import os
import json
import shutil
import pandas as pd
from tqdm import tqdm
def convert_balanced_yolo(csv_path, map_path, output_dir="yolo_dataset", val_ratio=0.2, min_samples=20):
# 1. 加载映射字典
with open(map_path, 'r', encoding='utf-8') as f:
class_map = json.load(f)
# 确保 Key 都是字符串,防止匹配失败
idx_to_char = {str(k): str(v) for k, v in class_map.items()}
# 同时也做一个 {索引数字: 汉字} 的映射
idx_to_char_rev = {str(v): str(k) for k, v in class_map.items()}
# 2. 读取 CSV
df = pd.read_csv(csv_path)
# 强制将标签列转为字符串,确保分组准确
df.iloc[:, 1] = df.iloc[:, 1].astype(str)
col_path, col_label = df.columns[0], df.columns[1]
print(f" 正在平衡数据,目标每类至少 {min_samples} 张训练图...")
for label, group in tqdm(df.groupby(col_label)):
# 优先从字典获取汉字名,找不到则用 label 数字
char_name = idx_to_char_rev.get(label, idx_to_char.get(label, f"label_{label}"))
# 组内打乱
group = group.sample(frac=1, random_state=42).reset_index(drop=True)
n = len(group)
# 划分训练和验证
if n == 1:
train_set = group
val_set = group
else:
val_count = max(1, int(n * val_ratio))
val_set = group.iloc[:val_count]
train_set = group.iloc[val_count:]
# --- 核心过采样修复 ---
# 如果训练集少于目标,进行倍数增加
if len(train_set) < min_samples:
repeat_times = (min_samples // len(train_set)) + 1
train_set = pd.concat([train_set] * repeat_times).reset_index(drop=True)
train_set = train_set.iloc[:min_samples] # 精确截断到 20 张
# 4. 物理存放
for split_name, data in [("train", train_set), ("val", val_set)]:
target_dir = os.path.join(output_dir, split_name, char_name)
os.makedirs(target_dir, exist_ok=True)
# 使用 enumerate 产生的 i 作为文件名后缀,确保不被覆盖
for i, row in enumerate(data.itertuples()):
src = str(getattr(row, col_path))
if not os.path.exists(src):
continue
# 文件名格式: 序号_原文件名
ext = os.path.splitext(src)[1]
filename = f"sample_{i}{ext}"
dst = os.path.join(target_dir, filename)
shutil.copy(src, dst)
print(f"[+] 转换完毕!请检查 {output_dir}/train/ 目录下各文件夹图片数量是否为 {min_samples}")
if __name__ == "__main__":
# 请确保两个文件名正确
convert_balanced_yolo("dataset.csv", "class_map.json", min_samples=20)
这一步还做了些特殊处理,因为常见字和偏僻字出现的概率差距很大
一些常见字会有几百个的样本,而一些字只有一两个样本,就会导致数据不均衡
通俗点说,就是通过“过采样”和“结构化重组”,为模型喂一份“营养均衡”的饭
接下来就可以拿输出的yolo_dataset训练模型了
分类模型训练
还是去官网下载分类预训练模型,我这里使用的是yolo11n-cls
# 训练代码
from ultralytics import YOLO
import json
if __name__ == "__main__":
model = YOLO('./yolo11n-cls.pt')
# 开始训练 (它会自动在目录下找 train 和 val)
# patience=10 开启早停机制,有效防止过拟合
results = model.train(data='./yolo_dataset',
epochs=200,
imgsz=64,
batch=64,
patience=50,
amp=False,
fliplr=0.0,
flipud=0.0,
label_smoothing=0.1,
degrees=15, # 随机旋转 ±15 度(让模型学会识别歪一点的字)
translate=0.1, # 随机平移(防止模型只认居中的字)
scale=0.1, # 随机缩放(防止模型只认固定大小的字)
shear=5, # 随机剪切变形
perspective=0.001, # 微小的透视变换
hsv_s=0.3, # 随机饱和度变化
hsv_v=0.3 # 随机亮度变化(应对不同背景颜色的验证码)
)
# 提取 YOLO 生成的新字典映射,并保存为新的 json 供推理时使用
new_vocab = model.names # 格式类似 {0: '工', 1: '爬', 2: '虫'}
with open('yolo_class_map.json', 'w', encoding='utf-8') as f:
json.dump(new_vocab, f, ensure_ascii=False, indent=4)
print("[+] 新的推理字典已保存至 yolo_class_map.json")
# 导出为 ONNX 模型
model.export(format='onnx')
文字点选验证方案
先使用目标检测模型检测到文字,再将得到文字小图做分类识别,最后与题目文字对比确定顺序
因为只做了验证码图片文字识别的模型,所以我们还需要借助开源的轻量ocr识别模型来识别题目的文字
这里用ddddocr或者paddleocr识别都可以,题目文字没有做干扰,基本都能识别正确
# paddleocr识别题目文字
import cv2
import numpy as np
from paddleocr import PaddleOCR
# 初始化模型:lang="ch" 指定中文,use_angle_cls=True 启用方向分类器(识别旋转文字)
# 默认开启 show_log=False 进一步减少控制台输出
ocr = PaddleOCR(use_angle_cls=False, lang="ch", show_log=False)
def process_transparent_image(image_input):
"""
纯 OpenCV + Numpy 矩阵级处理,将透明底替换为白底。
比 PIL 更底层,避免异常格式处理失败。
"""
# 1. 统一转为 numpy 字节流
if isinstance(image_input, bytes):
img_np = np.frombuffer(image_input, np.uint8)
else:
with open(image_input, 'rb') as f:
img_np = np.frombuffer(f.read(), np.uint8)
# 2. cv2.IMREAD_UNCHANGED 是关键,强制保留 Alpha 通道 (-1)
img = cv2.imdecode(img_np, cv2.IMREAD_UNCHANGED)
# 兜底:如果图片本身没有 4 通道(没有透明度),直接返回它前三个通道
if img is None or len(img.shape) < 3 or img.shape[2] != 4:
return img[:, :, :3] if img is not None and len(img.shape) == 3 else img
# 3. 分离 BGR 通道 和 Alpha 通道
bgr = img[:, :, :3]
alpha = img[:, :, 3] # 取出 Alpha 通道 (0-255)
# 4. 创建纯白背景 (全 255)
white_bg = np.ones_like(bgr, dtype=np.uint8) * 255
# 5. 矩阵运算:归一化 Alpha (0.0~1.0)
# np.newaxis 用于扩充维度,使其能与 3 通道的 BGR 矩阵相乘
alpha_factor = alpha[:, :, np.newaxis] / 255.0
# 6. 融合公式:前景 * Alpha + 背景 * (1 - Alpha)
result = bgr * alpha_factor + white_bg * (1 - alpha_factor)
return result.astype(np.uint8)
def recognize_text(image_input):
# 1. 继续使用之前提供的白底替换函数 (process_transparent_image_cv)
processed_img = process_transparent_image(image_input)
# 2. 核心改动:det=False 关闭检测,cls=False 关闭方向分类
result = ocr.ocr(processed_img, det=False, cls=False)
# 3. det=False 时的返回值结构较简单,通常为:[[('头', 0.998)]]
if not result or not result[0]:
return ""
try:
# 直接提取识别文本
wz = result[0][0][0]
return {'class': wz}
except Exception:
return ""
# if __name__ == "__main__":
# # 测试调用
# target_image = "test_3.png"
# text = recognize_text(target_image)
# print("识别结果:", text)
当然yolo分类识别文字不一定每一次都识别正确,所以我们还需要一些容错机制
先将正确识别的文字,确认其点击的顺序,再将接下来的文字做相似度做对比,最后确认顺序
from char_similar import std_cal_sim
def smart_match_coordinates(yolo_data, target_chars):
"""
智能匹配 YOLO 检测结果与 OCR 题目顺序,包含自动容错机制。
:param yolo_data: YOLO 检测出的字典列表,例:[{'char': '金', 'center': (174, 92)}, ...]
:param target_chars: OCR 识别的题目顺序列表,例:['金', '针', '菇']
:return: 按题目顺序排列的坐标列表 [(x1, y1), (x2, y2), ...]
"""
# 初始化结果数组,长度与题目一致,占位 None
matched_coords = [None] * len(target_chars)
# 复制一份可用池,被匹配掉的就从池子里移除
available_yolo = yolo_data.copy()
# 第一阶段:绝对精确匹配
for i, target in enumerate(target_chars):
for j, item in enumerate(available_yolo):
if item['char'] == target:
matched_coords[i] = item['center']
available_yolo.pop(j) # 匹配成功,移出可用池
break
# 第二阶段:容错兜底匹配(处理剩下的 None)
for i, target in enumerate(target_chars):
if matched_coords[i] is None and available_yolo:
best_idx = 0
if len(available_yolo) > 1:
best_score = -1
for j, item in enumerate(available_yolo):
# kind="shape" 表示只计算字形结构相似度 (默认会加上拼音和语义)
# 结果是一个 0~1 的浮点数
score = std_cal_sim(target, item['char'], kind="shape")
if score > best_score:
best_score = score
best_idx = j
matched_coords[i] = available_yolo[best_idx]['center']
available_yolo.pop(best_idx)
return matched_coords
# if __name__ == "__main__":
# # 模拟数据 1:完美识别情况
# yolo_perfect = [{'char': '金', 'center': (174, 92)}, {'char': '菇', 'center': (241, 122)},
# {'char': '针', 'center': (248, 31)}]
# ocr_target = ['金', '针', '菇']
#
# # 模拟数据 2:YOLO 识别错位情况(把'针'识别成了'十',把'菇'识别成了'茹')
# yolo_error_1 = [{'char': '金', 'center': (174, 92)}, {'char': '菇', 'center': (241, 122)},
# {'char': '十', 'center': (248, 31)}]
#
# yolo_error_2 = [{'char': '金', 'center': (174, 92)}, {'char': '茹', 'center': (241, 122)},
# {'char': '汁', 'center': (248, 31)}]
#
# print("完美情况按顺序点击坐标:", smart_match_coordinates(yolo_perfect, ocr_target))
# print("一个错情况按顺序点击坐标:", smart_match_coordinates(yolo_error_1, ocr_target))
# print("两个错情况按顺序点击坐标:", smart_match_coordinates(yolo_error_2, ocr_target))
最后结合检测模型及分类模型,去识别验证码图片及题目文字即可
语序文字验证方案
第一种方案是调用大语言模型的接口做语序排序
第二种是做离线词库去匹配语序
我这边采用第二种方案,在github上找到了开源的离线词库,有三百多万的词
https://github.com/fkxxyz/chinese-dictionary-3.6million/blob/e87060b2316ce02162edd89ab2354ff033e01e2b/%E8%AF%8D%E5%85%B8360%E4%B8%87%EF%BC%88%E4%B8%AA%E4%BA%BA%E6%95%B4%E7%90%86%EF%BC%89.txt
当然我们用不了那么多,只保留分类模型能识别的字,这里问AI帮我们写个‘瘦身’代码即可
# 语序匹配代码
import itertools
import math
class ImprovedLMSolver:
def __init__(self, dict_path='dict_mini.txt'):
self.word_dict = {}
try:
with open(dict_path, 'r', encoding='utf-8') as f:
for line in f:
parts = line.split()
if not parts: continue
word = parts[0]
try:
freq = int(parts[2])
except:
freq = 1
# 【核心修复】:防止 freq=0 导致 log(1)=0 得分失效
# 强制让所有存在于词典中的词,基础频率至少为 1
if freq <= 0:
freq = 1
self.word_dict[word] = freq
except Exception as e:
print(f"词库加载失败: {e}")
def score(self, text):
"""
评分逻辑:遍历所有可能的子串,累加权重。
权重 = 长度平方 * log(频率)
这样 '物理系' (长度3) 的权重会远大于 '物理' (长度2) + '系'
"""
total_score = 0
n = len(text)
# 遍历所有子串: text[i:j]
for i in range(n):
for j in range(i + 1, n + 1):
sub_word = text[i:j]
if sub_word in self.word_dict:
freq = self.word_dict[sub_word]
length = len(sub_word)
# 【关键修改2】长度的平方作为倍率
# 长度3的词权重是9,长度2是4。
# 只要匹配到'物理系',分数直接爆炸,完胜 '系物理'
total_score += (length ** 2) * math.log(freq + 1)
return total_score
def solve(self, chars):
perms = [''.join(p) for p in itertools.permutations(chars)]
return max(perms, key=self.score)
# --- 测试 ---
# solver = ImprovedLMSolver('dict_mini.txt')
# print(solver.solve(['性', '适', '应']))
最后也是结合yolo模型去做识别就可以了
测试
并发测试去做验证,两种验证码混合测试,成功率在80%-90%左右