前言
文字点选验证码
处理此类验证码,有着比较固定的套路:
1、图片以及题目信息的获取
2、识别文字所在位置(一般用 OCR 模型/目标检测模型),裁切文字小图交由下一步
此处可根据题目信息来走两条分支
3、
分支1:题目是明文在接口中响应的,接上步得到的小图,识别这是啥字,并且与题目信息对比
分支2:如果题目本身是图片渲染的,就裁切它出现的固定区域,再交给“孪生网络”对比(一个判断两张图是否相同的模型)。
4、根据校验接口提交的信息,来将坐标,轨迹等信息提交,完事~
整套流程说白了,就是把需要识别的字/图扣出来,让模型告诉你选哪个,最后把正确的坐标和轨迹拼成校验接口要的数据。
至于这个识别的过程,那就需要好好说道说道了,一部分人可能会认为枯燥,但我觉得大部分人还是会觉得有点意思的
1、识别
在我们这个时代,就先别自己造轮子了,直接用前辈大佬们打下来的“江山”几乎是最好的选择,当然简单的原理还是了解下的好
YOLO(You Only Look Once)是一类“目标检测模型”,核心优势:速度快、端到端、一次看图就能给出所有目标的位置与类别。
传统检测方法要先找候选区域再分类,而 YOLO 把整张图分成网格,让网络一次性预测出所有可能的目标框和类别,所以速度极快。
YOLO 的核心思想是把“目标在哪 + 是啥”视为一个统一的回归问题,通过单次前向传播就能完成检测。
一句话总结:
YOLO = 快速、高效、端到端的目标检测器。
要使用 yolo 达成我们本次的目的,例如:
能够精准框选到文字,或者图标,需要准备一批这样的数据:
- 文字所在位置的左上、右上、左下、右下四个坐标值
- 与文字所在位置坐标相同的真实图片
这两项,一是为了让模型知道你想框选的位置在哪,二是为了让模型认清楚这个位置的文字长啥样
这两项一起,也就组成了 “初始数据集”,但要完全能够输送给yolo,让ta能走完整体流程,
还需要划分下训练集、验证集
训练集: 负责让模型学习认清文字
验证集: 负责让模型验证下本次学习结果
看起来很像上学时的学习与考试吧,训练集就平时老师讲的知识以及自己做的题,而验证集就是考试
模型识别的结果,深度依赖了我们给ta输送的数据集,所以数据集至关重要
1.1、数据集
对于这种文字点选的,最佳数据集自然是真实图片上直接裁切抠来的小图,但是有个很大的问题在于,我们大部分人都懒(个人想法),不想自己抠,不想自己去标注
对于这种情况也有几种解决方案:
1、找找接口里有没有直接返回文字本身的图片,像某象的图标点选,就是直接返回的图标,然后渲染至底图的
2、尝试已有开源方案识别,例如ddddocr,经过实测,ddddocr识别这个的效果还可以,但毕竟是通用类型,不是单一专精,能帮到最好
3、如果既没返回小图标,也没开源方案能帮到一点忙,那这标注应该逃不了了,不过我们可以小标一点,直接训练个初始mini版专精模型,然后拿初始mini版专精模型去识别,总能识别到不少有用的我们需要到的小图,并且全自动,我们只需要筛选识别错的就行,这样不用循环几次,就能得到不错的效果了
本次主角里的文字比较端正,所以直接用 ddddocr 做第一轮检测与裁切就够了。虽然识别率不算高,但对我们来说主要是为了拿到字图、观察字符的形状和特征即可。
import io
import csv
from pathlib import Path
from PIL import Image
import ddddocr
INPUT_DIR = Path("captchas_raw")
OUTPUT_ROOT = Path("dataset_stage1_ddddocr")
SAVE_MAPPING_CSV = True
MAPPING_CSV_PATH = OUTPUT_ROOT / "mapping_ddddocr.csv"
MIN_BOX_W = 8
MIN_BOX_H = 8
PADDING = 1
det_ocr = ddddocr.DdddOcr(det=True, ocr=False, show_ad=False)
cls_ocr = ddddocr.DdddOcr(det=False, ocr=True, show_ad=False)
def ensure_output_dir():
OUTPUT_ROOT.mkdir(parents=True, exist_ok=True)
def clamp(v, lo, hi):
return max(lo, min(hi, v))
def process_one_image(img_path: Path, csv_writer=None):
"""
处理一张大图:
1. ddddocr 检测出所有文字框
2. 按框裁切成小图
3. 用 ddddocr 识别每个小图是啥字
4. 保存到 OUTPUT_ROOT / {字} / {原图名_idx}.png
"""
with img_path.open("rb") as f:
img_bytes = f.read()
# 读成 PIL 图,方便裁切
pil_img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
w, h = pil_img.size
# 1. 检测文字位置
boxes = det_ocr.detection(img_bytes) # 返回 [[x1,y1,x2,y2], ...]
if not boxes:
print(f"[WARN] {img_path.name} 没检测到任何框")
return 0
saved_cnt = 0
for idx, box in enumerate(boxes):
x1, y1, x2, y2 = box
# 加 padding 并裁剪到合法范围
x1 = clamp(x1 - PADDING, 0, w - 1)
y1 = clamp(y1 - PADDING, 0, h - 1)
x2 = clamp(x2 + PADDING, 0, w)
y2 = clamp(y2 + PADDING, 0, h)
bw = x2 - x1
bh = y2 - y1
if bw < MIN_BOX_W or bh < MIN_BOX_H:
# 太小的框基本是噪声,直接丢
continue
# 2. 裁切小图
crop = pil_img.crop((x1, y1, x2, y2))
# 3. 用 ddddocr 识别这个小图是啥字
buf = io.BytesIO()
crop.save(buf, format="PNG")
char_text = cls_ocr.classification(buf.getvalue()) # 返回字符串
if not char_text:
continue
label = char_text.strip()
# 4. 存到对应目录
class_dir = OUTPUT_ROOT / label
class_dir.mkdir(parents=True, exist_ok=True)
out_name = f"{img_path.stem}_{idx}.png"
out_path = class_dir / out_name
crop.save(out_path)
saved_cnt += 1
if csv_writer is not None:
csv_writer.writerow([
img_path.name,
idx,
label,
x1,
y1,
x2,
y2,
str(out_path.relative_to(OUTPUT_ROOT))
])
return saved_cnt
def main():
ensure_output_dir()
csv_file = None
csv_writer = None
if SAVE_MAPPING_CSV:
MAPPING_CSV_PATH.parent.mkdir(parents=True, exist_ok=True)
csv_file = MAPPING_CSV_PATH.open("w", newline="", encoding="utf-8")
csv_writer = csv.writer(csv_file)
csv_writer.writerow([
"src_image",
"box_index",
"pred_char",
"x1",
"y1",
"x2",
"y2",
"saved_rel_path",
])
total_imgs = 0
total_crops = 0
exts = ("*.png", "*.jpg", "*.jpeg", "*.bmp")
for ext in exts:
for img_path in INPUT_DIR.glob(ext):
total_imgs += 1
cnt = process_one_image(img_path, csv_writer=csv_writer)
total_crops += cnt
print(f"[INFO] {img_path.name} => 保存 {cnt} 个小图")
if csv_file is not None:
csv_file.close()
print(f"\n[DONE] 共处理大图 {total_imgs} 张,生成小图 {total_crops} 个")
if SAVE_MAPPING_CSV:
print(f"[INFO] 映射表已保存到: {MAPPING_CSV_PATH}")
if __name__ == "__main__":
main()
在代码同目录新建文件夹 captchas_raw,把所有拿到的验证码图片都放进去
运行代码后会自动裁切,识别,并保存小图
这一步的目的不是为了精准识别文字本身,而是为了先拿到一批干净的小字图,便于后面提取特征、合成专用的数据集。
裁切个一百张左右差不多就行了,之后把干扰很大的小字图给去掉
可以发现,字体是比较标准的,这里我用的是:
就是有额外颜色,轻微的扭曲,和角度旋转,
接下来就是背景了,背景可以选择自己合成,这样也不用费劲去搞原版背景底图,不过效果肯定不如原版的
本次选用原版的,底图人家基本不会放接口里,找了一下也没找着,但同一张底图上的小字是在不同位置的,我们可以拿着这个特征用算法提取到干净的背景图
从这样:
转为这样:
核心原理就是用 中值滤波法,从多张"相同背景、不同前景"的图片中重建出干净背景:
多张验证码图片(同一背景模板)
↓
按像素位置堆叠
↓
计算每个像素的中值
↓
得到没有干扰物的纯净背景
这里有个关键前提,就是同一背景的多张底图,可是我们抓来的都是乱序的,这就需要再次用算法简单分组一下,
从这样的:
转为这样的分好组的:
核心原理是用 感知哈希和汉明距离,判断图片相似度,将"背景相同"的都归为一组。
captchas/ (混杂各种背景的验证码)
↓
计算每张图的 aHash (64bit)
↓
比较汉明距离,相似的归入同一组
↓
grouped/group_001/, group_002/, ... (每组都是同背景)
这样,我们有了干净原版背景图,又根据得到的小字图知道了他们的特征,就可以开始着手合成数据集了,
- 旋转
- 轻微扭曲
- 颜色变换
- 随机描边
还需要注意下碰撞检测,以及字的范围,不然直接几千个字...没必要....
这个字的范围接口里返回的有,取个差不多就行了,保存为charset_collected.txt
代码如下(注意要把下载的字体文件放程序同目录里的fonts文件夹内):
import random
import logging
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFont, ImageFilter
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s"
)
logger = logging.getLogger(__name__)
CHARSET_FILE = Path("charset_collected.txt")
BG_DIR = Path("backgrounds_clean")
FONT_DIR = Path("fonts")
OUT_ROOT = Path("dataset_synth_v2")
IMG_SIZE = 32
CANVAS_SIZE = 48
N_PER_CHAR = 80
# =====================================
def load_charset():
text = CHARSET_FILE.read_text(encoding="utf-8")
chars = sorted(set(ch for ch in text if ch.strip()))
logger.info("加载字符集,共 %d 个字符", len(chars))
return chars
def load_backgrounds():
paths = []
for ext in ("*.png", "*.jpg", "*.jpeg", "*.bmp"):
paths.extend(BG_DIR.rglob(ext))
if not paths:
raise RuntimeError(f"背景目录 {BG_DIR.resolve()} 里没找到图片")
logger.info("发现背景图片 %d 张", len(paths))
return paths
def load_fonts():
font_paths = []
for ext in ("*.ttf", "*.otf"):
font_paths.extend(FONT_DIR.rglob(ext))
if not font_paths:
raise RuntimeError(f"字体目录 {FONT_DIR.resolve()} 里没找到 ttf/otf 字体文件")
logger.info("发现可用字体 %d 个", len(font_paths))
return font_paths
def random_bg_patch(bg_paths, size):
"""从干净背景里裁一块 size×size 的小 patch"""
bg_path = random.choice(bg_paths)
bg = Image.open(bg_path).convert("RGB")
w, h = bg.size
if w < size or h < size:
scale = max(size / w, size / h)
bg = bg.resize(
(int(w * scale) + 2, int(h * scale) + 2),
resample=Image.BICUBIC
)
w, h = bg.size
x = random.randint(0, w - size)
y = random.randint(0, h - size)
patch = bg.crop((x, y, x + size, y + size))
if random.random() < 0.4:
patch = patch.filter(ImageFilter.GaussianBlur(random.uniform(0.2, 0.6)))
return patch
CHAR_COLORS = [
(230, 80, 90),
(230, 130, 80),
(230, 180, 60),
(120, 190, 90),
(80, 180, 220),
(140, 120, 220),
(220, 80, 200),
]
STROKE_COLORS = [
(255, 255, 255),
(245, 245, 245),
(230, 230, 230),
]
def random_font(font_paths):
path = random.choice(font_paths)
size = random.randint(22, 28)
return ImageFont.truetype(str(path), size)
def apply_geom_augment(img):
"""几何增强:旋转 + 轻微错切"""
# 旋转
angle = random.uniform(-15, 15)
img = img.rotate(angle, resample=Image.BICUBIC, expand=False)
# 轻微错切,模拟扭曲
if random.random() < 0.4:
shear = random.uniform(-0.2, 0.2)
# (a, b, c, d, e, f) -> [x', y'] = [a x + b y + c, d x + e y + f]
a, b, c, d, e, f = 1, shear, 0, 0, 1, 0
img = img.transform(
img.size,
Image.AFFINE,
(a, b, c, d, e, f),
resample=Image.BICUBIC
)
return img
def apply_color_jitter(img):
"""简易亮度/对比度扰动"""
arr = np.asarray(img).astype("float32")
alpha = random.uniform(0.9, 1.1) # 对比度
beta = random.uniform(-10, 10) # 亮度
arr = arr * alpha + beta
arr = np.clip(arr, 0, 255).astype("uint8")
return Image.fromarray(arr)
def synth_one_char(ch, bg_paths, font_paths):
SAFE_CANVAS = 64
canvas = random_bg_patch(bg_paths, SAFE_CANVAS)
draw = ImageDraw.Draw(canvas)
path = random.choice(font_paths)
size = random.randint(22, 26)
font = ImageFont.truetype(str(path), size)
fill = random.choice(CHAR_COLORS)
stroke_color = random.choice(STROKE_COLORS)
stroke_width = random.choice([1, 1, 2])
try:
bbox = draw.textbbox((0, 0), ch, font=font, stroke_width=stroke_width)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
offset_x, offset_y = bbox[0], bbox[1]
except Exception:
tw, th = draw.textsize(ch, font=font)
offset_x, offset_y = 0, 0
x0 = (SAFE_CANVAS - tw) // 2 - offset_x + random.randint(-1, 1)
y0 = (SAFE_CANVAS - th) // 2 - offset_y + random.randint(-1, 1)
draw.text(
(x0, y0),
ch,
font=font,
fill=fill,
stroke_width=stroke_width,
stroke_fill=stroke_color,
)
angle = random.uniform(-12, 12)
canvas = canvas.rotate(angle, resample=Image.BICUBIC, expand=False)
if random.random() < 0.3: # 降低错切概率
shear = random.uniform(-0.15, 0.15)
a, b, c, d, e, f = 1, shear, 0, 0, 1, 0
canvas = canvas.transform(
canvas.size,
Image.AFFINE,
(a, b, c, d, e, f),
resample=Image.BICUBIC
)
w, h = canvas.size
left = (w - IMG_SIZE) // 2
top = (h - IMG_SIZE) // 2
img = canvas.crop((left, top, left + IMG_SIZE, top + IMG_SIZE))
# 颜色扰动
if random.random() < 0.6:
img = apply_color_jitter(img)
return img
def main():
chars = load_charset()
bg_paths = load_backgrounds()
font_paths = load_fonts()
OUT_ROOT.mkdir(parents=True, exist_ok=True)
total = len(chars) * N_PER_CHAR
idx = 0
for ch in chars:
out_dir = OUT_ROOT / ch
out_dir.mkdir(parents=True, exist_ok=True)
for i in range(N_PER_CHAR):
img = synth_one_char(ch, bg_paths, font_paths)
img.save(out_dir / f"char_{i:05d}.png")
idx += 1
if idx % 1000 == 0:
logger.info("已生成 %d/%d 张", idx, total)
logger.info("完成:共生成 %d 张,字符数=%d", total, len(chars))
if __name__ == "__main__":
main()
当然如果就是想要用之前裁切下来的小字图,也可以用这段代码:
import random
from pathlib import Path
from PIL import Image
# 背景图目录
BACKGROUND_DIR = "backgrounds"
# 字体小图目录(透明 PNG)
GLYPH_DIR = "chars_rgba"
# 输出的数据集根目录
OUTPUT_ROOT = "char_yolo_dataset"
# 合成图的尺寸
IMG_SIZE = 320
# 训练 / 验证数量
NUM_TRAIN = 2000
NUM_VAL = 400
# 每张图里贴多少个字符(期望值)
MIN_CHARS_PER_IMAGE = 3
MAX_CHARS_PER_IMAGE = 5
# 类别名
CLASS_NAMES = ["char"]
# 字符缩放范围
GLYPH_SCALE_MIN = 1.0
GLYPH_SCALE_MAX = 2.0
# 随机种子
RANDOM_SEED = 42
# 碰撞检测相关
MAX_PLACEMENT_TRIES = 30 # 每个字符最多尝试放置次数
MAX_IOU = 0.02 # IoU 超过这个就算“碰撞”,重新随机位置
# =====================================================
def list_images(dir_path: Path):
exts = {".jpg", ".jpeg", ".png", ".bmp", ".webp"}
files = [p for p in dir_path.iterdir() if p.suffix.lower() in exts and p.is_file()]
return files
def iou_xyxy(box1, box2) -> float:
"""
计算两个 bbox 的 IoU,box = (x1, y1, x2, y2)
"""
x1, y1, x2, y2 = box1
x1b, y1b, x2b, y2b = box2
inter_x1 = max(x1, x1b)
inter_y1 = max(y1, y1b)
inter_x2 = min(x2, x2b)
inter_y2 = min(y2, y2b)
inter_w = inter_x2 - inter_x1
inter_h = inter_y2 - inter_y1
if inter_w <= 0 or inter_h <= 0:
return 0.0
inter_area = inter_w * inter_h
area1 = (x2 - x1) * (y2 - y1)
area2 = (x2b - x1b) * (y2b - y1b)
return inter_area / (area1 + area2 - inter_area + 1e-9)
def has_collision(candidate_box, placed_boxes, max_iou=MAX_IOU) -> bool:
"""
candidate_box 与任意已放置 box 的 IoU > max_iou 则视为碰撞
"""
for b in placed_boxes:
if iou_xyxy(candidate_box, b) > max_iou:
return True
return False
def paste_glyph_on_bg(bg: Image.Image,
glyph: Image.Image,
placed_boxes_px):
"""
在 bg 上随机贴一个 glyph,带碰撞检测。
placed_boxes_px: 已放置字符的 bbox 列表(像素坐标,(x1,y1,x2,y2))
返回:
(x_c, y_c, w_norm, h_norm, box_px) 或 None
"""
bg_w, bg_h = bg.size
g_orig = glyph.convert("RGBA")
for _ in range(MAX_PLACEMENT_TRIES):
g = g_orig
# 随机缩放
scale = random.uniform(GLYPH_SCALE_MIN, GLYPH_SCALE_MAX)
new_w = int(g.width * scale)
new_h = int(g.height * scale)
# 限制最小/最大尺寸,避免太小或超过背景
new_w = max(4, min(new_w, bg_w))
new_h = max(4, min(new_h, bg_h))
if new_w <= 0 or new_h <= 0:
continue
g = g.resize((new_w, new_h), resample=Image.BICUBIC)
max_x = bg_w - new_w
max_y = bg_h - new_h
if max_x < 0 or max_y < 0:
continue
x = random.randint(0, max_x)
y = random.randint(0, max_y)
candidate_box = (x, y, x + new_w, y + new_h)
# 碰撞检测
if has_collision(candidate_box, placed_boxes_px):
continue
# 通过碰撞检测,正式贴图
bg.paste(g, (x, y), mask=g.split()[-1])
# 计算 YOLO 格式的归一化 bbox
x_center = (x + new_w / 2) / bg_w
y_center = (y + new_h / 2) / bg_h
w_norm = new_w / bg_w
h_norm = new_h / bg_h
return x_center, y_center, w_norm, h_norm, candidate_box
# 多次尝试都放不上,放弃这个字符
return None
def generate_split(
split: str,
num_images: int,
bg_paths,
glyph_paths,
output_root: Path,
):
"""
生成 train/val 某一个 split 的数据。
"""
img_dir = output_root / "images" / split
label_dir = output_root / "labels" / split
img_dir.mkdir(parents=True, exist_ok=True)
label_dir.mkdir(parents=True, exist_ok=True)
count = 0
trial = 0
max_trials = num_images * 10 # 防止某种奇怪情况死循环
while count < num_images and trial < max_trials:
trial += 1
bg_path = random.choice(bg_paths)
bg = Image.open(bg_path).convert("RGBA")
bg = bg.resize((IMG_SIZE, IMG_SIZE), resample=Image.BICUBIC)
n_chars = random.randint(MIN_CHARS_PER_IMAGE, MAX_CHARS_PER_IMAGE)
bboxes = []
placed_boxes_px = [] # 像素坐标的 box 列表,用于碰撞检测
for _ in range(n_chars):
glyph_path = random.choice(glyph_paths)
glyph = Image.open(glyph_path).convert("RGBA")
res = paste_glyph_on_bg(bg, glyph, placed_boxes_px)
if res is None:
# 这次没放上去,尝试放下一个字符
continue
x_c, y_c, w_norm, h_norm, box_px = res
placed_boxes_px.append(box_px)
# 单类别:class_id = 0
bboxes.append((0, x_c, y_c, w_norm, h_norm))
if not bboxes:
# 没成功贴上任何字符,跳过这张
continue
filename = f"{split}_{count:06d}"
img_out_path = img_dir / f"{filename}.jpg"
lbl_out_path = label_dir / f"{filename}.txt"
# 转成 RGB 保存
bg_rgb = bg.convert("RGB")
bg_rgb.save(img_out_path, quality=95)
# 写 label(YOLO 格式:class cx cy w h)
with open(lbl_out_path, "w", encoding="utf-8") as f:
for cls_id, x_c, y_c, w_norm, h_norm in bboxes:
f.write(
f"{cls_id} {x_c:.6f} {y_c:.6f} {w_norm:.6f} {h_norm:.6f}\n"
)
count += 1
if count % 100 == 0:
print(f"[{split}] 已生成 {count}/{num_images} 张")
print(f"[{split}] 完成生成 {count} 张图片(目标 {num_images})")
def write_data_yaml(output_root: Path, class_names):
yaml_path = output_root / "char_data.yaml"
lines = []
lines.append(f"path: {output_root.resolve()}")
lines.append("train: images/train")
lines.append("val: images/val")
lines.append("names:")
for idx, name in enumerate(class_names):
lines.append(f" {idx}: {name}")
yaml_text = "\n".join(lines)
yaml_path.write_text(yaml_text, encoding="utf-8")
print(f"[data.yaml] 已写入: {yaml_path}")
def main():
random.seed(RANDOM_SEED)
bg_dir = Path(BACKGROUND_DIR)
glyph_dir = Path(GLYPH_DIR)
output_root = Path(OUTPUT_ROOT)
if not bg_dir.exists():
raise FileNotFoundError(f"背景目录不存在: {bg_dir}")
if not glyph_dir.exists():
raise FileNotFoundError(f"字体目录不存在: {glyph_dir}")
bg_paths = list_images(bg_dir)
glyph_paths = list_images(glyph_dir)
if not bg_paths:
raise RuntimeError(f"背景目录里没找到图片: {bg_dir}")
if not glyph_paths:
raise RuntimeError(f"字体目录里没找到图片: {glyph_dir}")
print(f"背景图数量: {len(bg_paths)}")
print(f"字体图数量: {len(glyph_paths)}")
print(f"输出目录: {output_root}")
# 创建 train / val
generate_split("train", NUM_TRAIN, bg_paths, glyph_paths, output_root)
generate_split("val", NUM_VAL, bg_paths, glyph_paths, output_root)
# 写 YOLO data.yaml
write_data_yaml(output_root, CLASS_NAMES)
print("全部完成。")
if __name__ == "__main__":
main()
运行过后,会自动划分好前文所说的训练集和验证集,以及yolo开启训练时所需要到的一个yaml配置文件
至此,数据集准备完毕。
1.2、训练
(待星球续集)
此外
进阶内容可加入我的知识星球查看
适合有一定逆向 / 爬虫经验的 coder。 小白也可,但需要耐心以及肯下功夫。
所有文章内容以「可复刻」为标准: 跟着走,能自己跑通; 跑通后,能总结成自己的方法论。
涵盖: 某里 v2 验证流程、v2拼图轨迹,pay拼图 sign 纯算、轨迹转化 AST 插件、训练数据集 某里 231递归字符串解密、东航 req / res 加解密 某音评论ab纯算等等
未来会加入app逆向资源,从如何以及为何root一部手机说起,到如何分析汇编...