点选验证码处理

43 阅读15分钟

前言

文字点选验证码

image-20251203155405522.png

处理此类验证码,有着比较固定的套路:

1、图片以及题目信息的获取

2、识别文字所在位置(一般用 OCR 模型/目标检测模型),裁切文字小图交由下一步

此处可根据题目信息来走两条分支

3、

分支1:题目是明文在接口中响应的,接上步得到的小图,识别这是啥字,并且与题目信息对比

分支2:如果题目本身是图片渲染的,就裁切它出现的固定区域,再交给“孪生网络”对比(一个判断两张图是否相同的模型)。

4、根据校验接口提交的信息,来将坐标,轨迹等信息提交,完事~

整套流程说白了,就是把需要识别的字/图扣出来,让模型告诉你选哪个,最后把正确的坐标和轨迹拼成校验接口要的数据。

至于这个识别的过程,那就需要好好说道说道了,一部分人可能会认为枯燥,但我觉得大部分人还是会觉得有点意思的


1、识别

在我们这个时代,就先别自己造轮子了,直接用前辈大佬们打下来的“江山”几乎是最好的选择,当然简单的原理还是了解下的好


YOLO(You Only Look Once)是一类“目标检测模型”,核心优势:速度快、端到端、一次看图就能给出所有目标的位置与类别。

传统检测方法要先找候选区域再分类,而 YOLO 把整张图分成网格,让网络一次性预测出所有可能的目标框和类别,所以速度极快。

YOLO 的核心思想是把“目标在哪 + 是啥”视为一个统一的回归问题,通过单次前向传播就能完成检测。

一句话总结:

YOLO = 快速、高效、端到端的目标检测器。


要使用 yolo 达成我们本次的目的,例如:

image-20251205104548725.png

能够精准框选到文字,或者图标,需要准备一批这样的数据:

  • 文字所在位置的左上、右上、左下、右下四个坐标值
  • 与文字所在位置坐标相同的真实图片

这两项,一是为了让模型知道你想框选的位置在哪,二是为了让模型认清楚这个位置的文字长啥样

这两项一起,也就组成了 “初始数据集”,但要完全能够输送给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 = 8PADDING = 1det_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,把所有拿到的验证码图片都放进去

image.png

运行代码后会自动裁切,识别,并保存小图

image.png

image.png

这一步的目的不是为了精准识别文字本身,而是为了先拿到一批干净的小字图,便于后面提取特征、合成专用的数据集。


裁切个一百张左右差不多就行了,之后把干扰很大的小字图给去掉

image.png

可以发现,字体是比较标准的,这里我用的是:

fontmeme.com/fonts/noto-…

image.png

就是有额外颜色,轻微的扭曲,和角度旋转,


接下来就是背景了,背景可以选择自己合成,这样也不用费劲去搞原版背景底图,不过效果肯定不如原版的

本次选用原版的,底图人家基本不会放接口里,找了一下也没找着,但同一张底图上的小字是在不同位置的,我们可以拿着这个特征用算法提取到干净的背景图

从这样:

image.png

转为这样:

image.png

核心原理就是用 中值滤波法,从多张"相同背景、不同前景"的图片中重建出干净背景:

多张验证码图片(同一背景模板)
    ↓
按像素位置堆叠
    ↓
计算每个像素的中值
    ↓
得到没有干扰物的纯净背景

这里有个关键前提,就是同一背景的多张底图,可是我们抓来的都是乱序的,这就需要再次用算法简单分组一下,

从这样的:

image.png

转为这样的分好组的:

image.png

image.png

核心原理是用 感知哈希汉明距离,判断图片相似度,将"背景相同"的都归为一组。

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 = 32CANVAS_SIZE = 48N_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配置文件

image.png

至此,数据集准备完毕。


1.2、训练

(待星球续集)


此外

进阶内容可加入我的知识星球查看

适合有一定逆向 / 爬虫经验的 coder。 小白也可,但需要耐心以及肯下功夫。

所有文章内容以「可复刻」为标准: 跟着走,能自己跑通; 跑通后,能总结成自己的方法论。

涵盖: 某里 v2 验证流程、v2拼图轨迹,pay拼图 sign 纯算、轨迹转化 AST 插件、训练数据集 某里 231递归字符串解密、东航 req / res 加解密 某音评论ab纯算等等

未来会加入app逆向资源,从如何以及为何root一部手机说起,到如何分析汇编...

海报 (1).png