验证码识别实战:前端不写页面,改训模型了?

一、引言:前端工程师训练模型,已经不是天方夜谭

天天跟 Canvas、WebGL、图像上传组件打交道的前端,其实早就在和"像素矩阵"打交道------这和卷积神经网络干的事本质相通。区别只是以前训练模型门槛太高,现在有了 AI 辅助,写 TypeScript 的人也能顺手训个 CNN

本文记录我们用 DDDD 低代码训练PyTorch CNN 两种方案,分别搞定同一类验证码(4位数字+字母)不同难度样本的完整过程。特别想强调的是,CNN 方案的代码核心是由 AI 生成的 ------我们只花了半个小时,通过多轮 prompt 调整和参数微调,就跑通了训练流程。写这篇文章不是为了炫耀技术深度,而是想证明一件事:在 AI 时代,前端的工具箱里完全可以多一把"模型训练"的螺丝刀,而且这把螺丝刀是 AI 递给你的。


二、实战背景:同一类验证码,两种难度,两种策略

我们手上是同一类业务中遇到的验证码图片,但难度差异明显:

类型 特征 训练策略
简单难度:4位数字+字母验证码 4位字符、轻微噪点、常规变形 DDDD 快速方案------配置即训练,覆盖多个简单样本
复杂难度:粘连+旋转扭曲验证码 4位字符、严重粘连、有明显旋转扭曲 AI 辅助 CNN 方案------AI 生成代码骨架,精调攻坚

三、方案一:DDDD(ddddocr)------ 低代码快速覆盖多个简单难度样本

ddddocr 配套的 dddd_trainer 把模型训练抽象成了"改配置 + 跑命令"两步,对前端极其友好。我们用它来处理多个简单难度的 4 位数字+字母验证码样本。

3.1 环境准备

bash 复制代码
conda create -n captcha python=3.10
conda activate captcha
pip install torch torchvision dddd_trainer

3.2 数据集组织

DDDD 的训练数据组织方式是平铺文件夹 + 文件名带标签 :所有图片放在同一个目录里,文件名格式为 标签_随机值.扩展名,下划线前面是验证码内容,后面是随机哈希(防止重名)。

bash 复制代码
/root/images_set/
├── 3x9k_a1b2c3d4.png
├── ab2c_e5f6g7h8.jpg
└── 0000_x9y8z7w6.png

如果你的文件名不方便改成这种格式,DDDD 也支持第二种方式:通过 labels.txt 文件映射,图片文件名完全随意,标签单独写在 txt 里。

我们借助 DDDD 的方案,陆续标注和训练了多个简单难度的 4 位验证码样本 ,总计约几万张图片。验证集比例在 config.yaml 里通过 Val: 0.03 配置,执行 cache 命令时由工具自动划分,不需要手动分文件夹。

3.3 配置文件与训练

yaml 复制代码
Model:
  CharSet: []
  ImageChannel: 1
  ImageHeight: 64
  ImageWidth: -1
System:
  GPU: true
  Val: 0.03
Train:
  BATCH_SIZE: 64
  CNN: {NAME: ddddocr}
  LR: 0.01
  TARGET:
    Accuracy: 0.97
    Epoch: 20
bash 复制代码
python app.py cache --project std_captcha
python app.py train --project std_captcha

过程与结果 :DDDD 的技术调研和方案跑通大概花了一两天;随后我们基于这个流程积累的数据标注经验,快速覆盖了多个简单难度的 4 位验证码样本。最终在 RTX 4070S 上单个类型训练约 10 分钟,验证集准确率普遍达到 97% 以上。DDDD 方案适合简单难度的验证码快速交付,但遇到字符粘连严重、旋转扭曲剧烈的复杂难度时,默认配置就不够用了。


四、方案二:AI 辅助 PyTorch CNN ------ 半个小时从 0 到跑通复杂难度

说实话,一开始我们完全不懂 CNN。 面对那些粘连严重、旋转扭曲特别厉害的验证码,DDDD 反复训练都出不来结果,我们也不知道还能怎么办。抱着试试看的心态,我们把几张最难的样本图直接发给了 AI,问它这种验证码该怎么解决。AI 看了图片后告诉我们:这种程度的变形和粘连,低代码工具搞不定,建议上 PyTorch 自己搭一个 CNN,并且给出了完整的方案思路。

我们当时连 CNN 是什么都没概念,更别说自己写模型、调学习率、看 Loss 曲线了。但因为 AI 已经把路指出来了,我们决定跟着走一遍。

结果我们把需求描述给 AI,AI 生成了完整的代码骨架(config + dataset + model + train),我们只花了大概半个多小时做数据适配和参数微调,就跑通了训练。

4.1 项目结构:AI 生成的工程化分层

以下是 AI 根据我们的需求生成的项目结构,非常符合前端工程直觉:

bash 复制代码
captcha_cnn/
├── config.py          # 集中配置(像前端的 constants.ts)
├── dataset.py         # 数据加载与清洗
├── model.py           # CNN 网络定义
├── train.py           # 训练主流程
└── data/
    └── raw/           # 原始图片,命名如:a3b9_001.png

4.2 config.py:AI 建议,我们拍板

AI 生成的 config.py 把字符集、图片尺寸、训练参数集中管理。我们根据实际数据做了调整------比如验证码是 4 位 、图片尺寸是 420×80(宽×高):

python 复制代码
# config.py
IMG_W, IMG_H = 420, 80
CHARS = "0123456789abcdefghijklmnopqrstuvwxyz"   # 36 类字符
NUM_CLASSES = len(CHARS)
MAX_LEN = 4          # 验证码长度(文件名前缀均为4字符)
CHAR2IDX = {c: i for i, c in enumerate(CHARS)}
IDX2CHAR = {i: c for i, c in enumerate(CHARS)}

DATA_DIRS = [
    "data/row",
]
TRAIN_RATIO = 0.9

BATCH_SIZE = 64
EPOCHS = 60
LR = 1e-3
MODEL_PATH = "best_captcha.pth"

4.3 dataset.py:AI 写骨架,我们填业务逻辑

AI 生成的 dataset.py 已经包含了数据增强和加载的标准写法。我们根据实际数据做了关键调整------加了严格的脏样本过滤

python 复制代码
import random
from pathlib import Path
from PIL import Image
import torch
from torch.utils.data import Dataset, DataLoader
from torchvision import transforms
from config import *

# 训练集做增强,验证集保持原样
train_tf = transforms.Compose([
    transforms.Grayscale(),
    transforms.Resize((IMG_H, IMG_W)),
    transforms.RandomRotation(5),                    # 轻微旋转,模拟真实场景
    transforms.ColorJitter(brightness=0.3, contrast=0.3),  # 亮度/对比度抖动
    transforms.GaussianBlur(kernel_size=3, sigma=(0.1, 1.0)),  # 轻微模糊
    transforms.ToTensor(),
    transforms.Normalize([0.5], [0.5]),             # 归一化到 [-1, 1]
])

val_tf = transforms.Compose([
    transforms.Grayscale(),
    transforms.Resize((IMG_H, IMG_W)),
    transforms.ToTensor(),
    transforms.Normalize([0.5], [0.5]),
])


class CaptchaDataset(Dataset):
    def __init__(self, img_paths, tf):
        self.paths = img_paths
        self.tf = tf

    def __len__(self):
        return len(self.paths)

    def __getitem__(self, idx):
        p = self.paths[idx]
        # 从文件名解析标签,如 a3b9_001.png -> 标签 "a3b9"
        label_str = Path(p).stem.split("_")[0].lower()[:MAX_LEN]
        img = Image.open(p).convert("RGB")
        x = self.tf(img)
        y = torch.tensor([CHAR2IDX[c] for c in label_str], dtype=torch.long)
        return x, y


def get_loaders():
    all_paths = []
    for d in DATA_DIRS:
        all_paths += [
            str(p) for p in Path(d).glob("*.png")
            if len(Path(p).stem.split("_")[0]) == MAX_LEN          # 过滤长度不对
            and all(c in CHARS for c in Path(p).stem.split("_")[0].lower())  # 过滤非法字符
        ]
    random.shuffle(all_paths)
    n_train = int(len(all_paths) * TRAIN_RATIO)

    train_ds = CaptchaDataset(all_paths[:n_train], train_tf)
    val_ds   = CaptchaDataset(all_paths[n_train:], val_tf)

    train_loader = DataLoader(train_ds, batch_size=BATCH_SIZE, shuffle=True,  num_workers=2)
    val_loader   = DataLoader(val_ds,   batch_size=BATCH_SIZE, shuffle=False, num_workers=2)
    print(f"train: {len(train_ds)}  val: {len(val_ds)}")
    return train_loader, val_loader

几个和 AI 一起踩的坑

  • AI 最初建议 num_workers=4,我们在 Mac MPS 上跑直接报错,降到 2 才稳定。这是 AI 不知道你的硬件环境时容易出的问题,人必须介入验证
  • RandomRotation(5) 这个参数我们和 AI 讨论了两轮:AI 一开始建议 15 度,我们试过后发现字符转出边界,最后折中到 5 度。业务数据的特点,AI 猜不到,必须人告诉它
  • 灰度图输入通道是 1,但 Image.open 默认是 RGB。AI 最初写的代码没处理这个细节,我们加了 convert("RGB") 再让 transforms.Grayscale() 处理,避免某些 PNG 格式异常。

4.4 model.py:AI 生成的网络结构

model.py 是 AI 根据"4 位 验证码、36 类字符、输入 420×80 灰度图"的需求直接生成的。采用 4 层卷积 + 自适应池化 + 多头分类 结构:每个字符位置由独立的分类头输出,而不是单个全连接层一次性输出所有位置:

python 复制代码
import torch
import torch.nn as nn
from config import IMG_W, IMG_H, NUM_CLASSES, MAX_LEN


class CaptchaCNN(nn.Module):
    """
    输入: (B, 1, H, W)  → 输出: (B, MAX_LEN, NUM_CLASSES)
    每个字符位置独立分类(多头分类,非 CTC)
    """
    def __init__(self):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(1, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(),
            nn.MaxPool2d(2),                                        # 40×210
            nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(),
            nn.MaxPool2d(2),                                        # 20×105
            nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(),
            nn.MaxPool2d(2),                                        # 10×52
            nn.Conv2d(128, 256, 3, padding=1), nn.BatchNorm2d(256), nn.ReLU(),
            nn.AdaptiveAvgPool2d((2, 4)),                           # 2×4
        )
        flat = 256 * 2 * 4
        self.heads = nn.ModuleList([
            nn.Sequential(
                nn.Linear(flat, 256), nn.ReLU(), nn.Dropout(0.3),
                nn.Linear(256, NUM_CLASSES)
            )
            for _ in range(MAX_LEN)
        ])

    def forward(self, x):
        feat = self.features(x).flatten(1)
        return torch.stack([h(feat) for h in self.heads], dim=1)  # (B, 4, 36)

结构说明 :前面 4 层卷积逐层下采样,最后通过 AdaptiveAvgPool2d 固定到 2×4 的特征图,flatten 后得到 256*2*4 = 2048 维向量;后面接了 4 个独立的分类头 (对应验证码的 4 个字符位置),每个 head 都是 Linear → ReLU → Dropout → Linear 的两层结构。这种"多头"设计让模型对每个字符位置单独建模,比单个大全连接层直接输出 4×36 的耦合方式更稳定。

4.5 train.py:AI 写逻辑,我们调参数

训练脚本也是 AI 生成的,包含了字符级准确率序列级准确率两个指标。我们主要调整了学习率和 Batch Size:

python 复制代码
import torch
import torch.nn as nn
from torch.optim import Adam
from torch.optim.lr_scheduler import CosineAnnealingLR
from config import *
from dataset import get_loaders
from model import CaptchaCNN


def accuracy(logits, targets):
    # logits: (B, MAX_LEN, NUM_CLASSES)  targets: (B, MAX_LEN)
    preds = logits.argmax(-1)          # (B, MAX_LEN)
    char_acc = (preds == targets).float().mean().item()
    seq_acc  = (preds == targets).all(dim=1).float().mean().item()
    return char_acc, seq_acc


def train():
    # 优先用 Mac MPS,其次是 CUDA,最后是 CPU
    device = torch.device("mps" if torch.backends.mps.is_available() else
                          "cuda" if torch.cuda.is_available() else "cpu")
    print("device:", device)

    train_loader, val_loader = get_loaders()
    model = CaptchaCNN().to(device)
    criterion = nn.CrossEntropyLoss()
    optimizer = Adam(model.parameters(), lr=LR)
    scheduler = CosineAnnealingLR(optimizer, T_max=EPOCHS)

    best_seq_acc = 0.0
    for epoch in range(1, EPOCHS + 1):
        model.train()
        total_loss = 0
        for x, y in train_loader:
            x, y = x.to(device), y.to(device)
            logits = model(x)                          # (B, 4, 36)

            # 每个字符位置单独算交叉熵,再求和
            loss = sum(criterion(logits[:, i], y[:, i]) for i in range(MAX_LEN))

            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
            total_loss += loss.item()

        scheduler.step()

        # 验证
        model.eval()
        all_char, all_seq, n = 0, 0, 0
        with torch.no_grad():
            for x, y in val_loader:
                x, y = x.to(device), y.to(device)
                logits = model(x)
                ca, sa = accuracy(logits, y)
                bs = x.size(0)
                all_char += ca * bs
                all_seq  += sa * bs
                n += bs

        char_acc = all_char / n
        seq_acc  = all_seq  / n
        print(f"epoch {epoch:3d}  loss={total_loss/len(train_loader):.4f}"
              f"  char_acc={char_acc:.4f}  seq_acc={seq_acc:.4f}")

        if seq_acc > best_seq_acc:
            best_seq_acc = seq_acc
            torch.save(model.state_dict(), MODEL_PATH)
            print(f"  -> saved (best seq_acc={best_seq_acc:.4f})")

    print("done. best seq_acc:", best_seq_acc)


if __name__ == "__main__":
    train()

和 AI 一起调参的真实过程

  • 前几个 epoch 序列准确率一直在 30%~40% 徘徊,我们以为是模型结构有问题,问 AI 后才知道 LR=1e-3 对于 Adam 偏高 。降到 5e-4 后进步明显。AI 知道原理,但不知道你的数据分布,必须结合实际情况调整
  • AI 建议 CosineAnnealingLR,我们试过后确实比固定学习率好得多。这是 AI 的"知识储备"优势------它见过太多最佳实践,你不需要自己翻论文。
  • 因为前期 DDDD 阶段已经积累了数据标注经验,CNN 方案的数据准备没有花太多时间 ,主要精力都用在调参和验证上。最终针对复杂难度 的验证码,在约 10000 张样本、60 个 epoch 后,测试集序列准确率达到 99% 。从 AI 生成第一版代码到这个结果,总共花了半个多小时

五、前端+AI:我们到底获得了什么?

完成这个项目后,我们最大的感受不是"我们会训练模型了",而是工作方式的改变

  1. AI 承担了"写样板代码"的工作:网络结构定义、训练循环、评估指标------这些 boilerplate 代码 AI 生成得又快又准。我们只需要关注业务逻辑(数据清洗、参数调优)。

  2. 图像处理经验复用了 :以前写 Canvas 图像压缩、写 WebGL 滤镜时积累的"像素直觉",在理解卷积核和池化时直接派上用场。但理解原理和写出能跑的代码是两回事,后者现在可以交给 AI。

  3. 工程化思维通用 :前端天天搞的"常量集中管理"(config.py)、"数据清洗"(过滤脏样本)、"性能监控"(loss/acc 曲线),和模型训练完全同构。这些是我们作为前端本来就擅长的,AI 帮我们把这些经验迁移到了新领域

  4. 从"消费者"变成"生产者" :以前我们调用第三方 OCR API,现在是自己生产模型、导出 ONNX、甚至用 ONNX Runtime 在浏览器里跑推理 。前端不再是 AI 能力的末端消费者,而是可以参与模型生产的一环------而且生产工具也是 AI


六、结语:AI 不是替代你,是放大你

这篇文章不是算法教程,而是一份前端团队的"AI 协作工作流"报告。验证码识别只是一个切入点,同样的路径可以延伸到:图片分类、敏感内容过滤、手写识别、甚至简单的目标检测。

职能的边界不是由岗位描述决定的,而是由你敢不敢把需求丢给 AI、然后坐下来调参数的那一刻决定的。

如果你也是前端,AI 早就是你写代码的日常搭档了------但可能还没想过,这把"电动螺丝刀"还能帮你拧模型训练的螺丝 。我们的经验是:别把它当成"转行算法",而是当成工具箱的又一次扩容。你不需要从零写 PyTorch,就像你不需要从零写 Webpack 一样。知道业务需求怎么描述、知道生成出来的代码哪里需要改、知道出了问题怎么问 AI,就已经能做出以前不敢想的东西了。

相关推荐
MomentYY1 小时前
Temperature:AI 的“脑洞旋钮”
前端·llm·ai编程
远航_2 小时前
OpenSpec 完整详细介绍
前端·后端
召钱熏2 小时前
状态枚举正确≠渲染正确:一个语音按钮的状态机边界修复实录
android·前端
SkyWalking中文站2 小时前
认识 Horizon UI · 1/17:SkyWalking 新一代可观测性控制台
运维·前端·监控
cidy_982 小时前
Dify 操作教程:工作流编排 & Chat 对话编排
前端·工作流引擎
tangdou3690986552 小时前
AI真好玩系列-2分钟快速了解DeepAgents | Quick Guide to DeepAgents in 2 Minutes
前端·javascript·后端
小四的小六2 小时前
AI Agent效果评测实战——搭完Agent才是噩梦的开始
前端
梨子同志2 小时前
JavaScript
前端
彭于晏爱编程2 小时前
纯 JS + Node,一个下午手搓了能读懂公司代码的 AI 助手,老板以为我转行了
前端·javascript