Initialize repository

This commit is contained in:
Hua
2026-03-10 18:47:29 +08:00
commit 760b80ee5e
32 changed files with 4343 additions and 0 deletions

20
generators/__init__.py Normal file
View File

@@ -0,0 +1,20 @@
"""
数据生成器包
提供三种验证码类型的数据生成器:
- NormalCaptchaGenerator: 普通字符验证码
- MathCaptchaGenerator: 算式验证码
- ThreeDCaptchaGenerator: 3D 立体验证码
"""
from generators.base import BaseCaptchaGenerator
from generators.normal_gen import NormalCaptchaGenerator
from generators.math_gen import MathCaptchaGenerator
from generators.threed_gen import ThreeDCaptchaGenerator
__all__ = [
"BaseCaptchaGenerator",
"NormalCaptchaGenerator",
"MathCaptchaGenerator",
"ThreeDCaptchaGenerator",
]

61
generators/base.py Normal file
View File

@@ -0,0 +1,61 @@
"""
验证码生成器基类
所有验证码生成器继承此基类,实现 generate() 方法。
基类提供通用的 generate_dataset() 批量生成能力。
"""
import os
import random
from pathlib import Path
from PIL import Image
from tqdm import tqdm
from config import RANDOM_SEED
class BaseCaptchaGenerator:
"""验证码生成器基类。"""
def __init__(self, seed: int = RANDOM_SEED):
"""
初始化生成器。
Args:
seed: 随机种子,保证数据生成可复现。
"""
self.seed = seed
self.rng = random.Random(seed)
def generate(self, text: str | None = None) -> tuple[Image.Image, str]:
"""
生成一张验证码图片。
Args:
text: 指定标签文本。为 None 时随机生成。
Returns:
(图片, 标签文本)
"""
raise NotImplementedError
def generate_dataset(self, num_samples: int, output_dir: str) -> None:
"""
批量生成验证码数据集。
文件名格式: {label}_{index:06d}.png
Args:
num_samples: 生成数量。
output_dir: 输出目录路径。
"""
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
# 重置随机种子,保证每次批量生成结果一致
self.rng = random.Random(self.seed)
for i in tqdm(range(num_samples), desc=f"Generating → {output_path.name}"):
img, label = self.generate()
filename = f"{label}_{i:06d}.png"
img.save(output_path / filename)

186
generators/math_gen.py Normal file
View File

@@ -0,0 +1,186 @@
"""
算式验证码生成器
生成形如 A op B = ? 的算式图片:
- A, B 范围: 1-30 的整数
- op: +, -, × (除法只生成能整除的)
- 确保结果为非负整数
- 标签格式: "3+8" (存储算式本身,不存结果)
- 视觉风格: 浅色背景、深色字符、干扰线
"""
import random
from PIL import Image, ImageDraw, ImageFilter, ImageFont
from config import GENERATE_CONFIG
from generators.base import BaseCaptchaGenerator
# 字体
_FONT_PATHS = [
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
"/usr/share/fonts/TTF/DejaVuSerif-Bold.ttf",
"/usr/share/fonts/TTF/DejaVuSansMono-Bold.ttf",
"/usr/share/fonts/liberation/LiberationSans-Bold.ttf",
"/usr/share/fonts/liberation/LiberationMono-Bold.ttf",
"/usr/share/fonts/gnu-free/FreeSansBold.otf",
]
# 深色调色板
_DARK_COLORS = [
(0, 0, 180),
(180, 0, 0),
(0, 130, 0),
(130, 0, 130),
(120, 60, 0),
(0, 0, 0),
(50, 50, 150),
]
# 运算符显示映射(用于渲染)
_OP_DISPLAY = {
"+": "+",
"-": "-",
"×": "×",
"÷": "÷",
}
class MathCaptchaGenerator(BaseCaptchaGenerator):
"""算式验证码生成器。"""
def __init__(self, seed: int | None = None):
from config import RANDOM_SEED
super().__init__(seed=seed if seed is not None else RANDOM_SEED)
self.cfg = GENERATE_CONFIG["math"]
self.width, self.height = self.cfg["image_size"]
self.operators = self.cfg["operators"]
self.op_lo, self.op_hi = self.cfg["operand_range"]
# 预加载可用字体
self._fonts: list[str] = []
for p in _FONT_PATHS:
try:
ImageFont.truetype(p, 20)
self._fonts.append(p)
except OSError:
continue
if not self._fonts:
raise RuntimeError("未找到任何可用字体,无法生成验证码")
# ----------------------------------------------------------
# 公共接口
# ----------------------------------------------------------
def generate(self, text: str | None = None) -> tuple[Image.Image, str]:
rng = self.rng
# 1. 生成算式
if text is None:
a, op, b = self._random_expression(rng)
text = f"{a}{op}{b}"
else:
a, op, b = self._parse_expression(text)
# 显示文本: "3+8=?"
display = f"{a}{_OP_DISPLAY.get(op, op)}{b}=?"
# 2. 浅色背景
bg_lo, bg_hi = self.cfg["bg_color_range"]
bg = tuple(rng.randint(bg_lo, bg_hi) for _ in range(3))
img = Image.new("RGB", (self.width, self.height), bg)
# 3. 绘制算式文本
self._draw_expression(img, display, rng)
# 4. 干扰线
self._draw_noise_lines(img, rng)
# 5. 轻微模糊
img = img.filter(ImageFilter.GaussianBlur(radius=0.6))
return img, text
# ----------------------------------------------------------
# 私有方法
# ----------------------------------------------------------
def _random_expression(self, rng: random.Random) -> tuple[int, str, int]:
"""随机生成一个合法算式 (a, op, b),确保结果为非负整数。"""
while True:
op = rng.choice(self.operators)
a = rng.randint(self.op_lo, self.op_hi)
b = rng.randint(self.op_lo, self.op_hi)
if op == "+":
return a, op, b
elif op == "-":
# 确保 a >= b结果非负
if a < b:
a, b = b, a
return a, op, b
elif op == "×":
# 限制乘积不过大,保持合理
if a * b <= 900:
return a, op, b
elif op == "÷":
# 只生成能整除的
if b != 0 and a % b == 0:
return a, op, b
@staticmethod
def _parse_expression(text: str) -> tuple[int, str, int]:
"""解析标签文本,如 '3+8' -> (3, '+', 8)。"""
for op in ["×", "÷", "+", "-"]:
if op in text:
parts = text.split(op, 1)
return int(parts[0]), op, int(parts[1])
raise ValueError(f"无法解析算式: {text}")
def _draw_expression(self, img: Image.Image, display: str, rng: random.Random) -> None:
"""将算式文本绘制到图片上,每个字符单独渲染并带轻微旋转。"""
n = len(display)
slot_w = self.width // n
font_size = int(min(slot_w * 0.85, self.height * 0.65))
font_size = max(font_size, 14)
for i, ch in enumerate(display):
font_path = rng.choice(self._fonts)
# 对于 × 等特殊符号,某些字体可能不支持,回退到 DejaVu
try:
font = ImageFont.truetype(font_path, font_size)
bbox = font.getbbox(ch)
if bbox[2] - bbox[0] <= 0:
raise ValueError
except (OSError, ValueError):
font = ImageFont.truetype(self._fonts[0], font_size)
bbox = font.getbbox(ch)
color = rng.choice(_DARK_COLORS)
cw = bbox[2] - bbox[0] + 4
ch_h = bbox[3] - bbox[1] + 4
char_img = Image.new("RGBA", (cw, ch_h), (0, 0, 0, 0))
ImageDraw.Draw(char_img).text((-bbox[0] + 2, -bbox[1] + 2), ch, fill=color, font=font)
# 轻微旋转
angle = rng.randint(*self.cfg["rotation_range"])
char_img = char_img.rotate(angle, resample=Image.BICUBIC, expand=True)
x = slot_w * i + (slot_w - char_img.width) // 2
y = (self.height - char_img.height) // 2 + rng.randint(-2, 2)
x = max(0, min(x, self.width - char_img.width))
y = max(0, min(y, self.height - char_img.height))
img.paste(char_img, (x, y), char_img)
def _draw_noise_lines(self, img: Image.Image, rng: random.Random) -> None:
"""绘制浅色干扰线。"""
draw = ImageDraw.Draw(img)
lo, hi = self.cfg["noise_line_range"]
num = rng.randint(lo, hi)
for _ in range(num):
x1, y1 = rng.randint(0, self.width), rng.randint(0, self.height)
x2, y2 = rng.randint(0, self.width), rng.randint(0, self.height)
color = tuple(rng.randint(150, 220) for _ in range(3))
draw.line([(x1, y1), (x2, y2)], fill=color, width=rng.randint(1, 2))

154
generators/normal_gen.py Normal file
View File

@@ -0,0 +1,154 @@
"""
普通字符验证码生成器
生成风格:
- 浅色随机背景 (RGB 各通道 230-255)
- 每个字符随机深色 (蓝/红/绿/紫/棕等)
- 字符数量 4-5 个
- 字符有 ±15° 随机旋转
- 2-5 条浅色干扰线
- 少量噪点
- 可选轻微高斯模糊
"""
import random
from PIL import Image, ImageDraw, ImageFilter, ImageFont
from config import GENERATE_CONFIG, NORMAL_CHARS
from generators.base import BaseCaptchaGenerator
# 系统可用字体列表(粗体/常规混合,增加多样性)
_FONT_PATHS = [
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
"/usr/share/fonts/TTF/DejaVuSerif-Bold.ttf",
"/usr/share/fonts/TTF/DejaVuSansMono-Bold.ttf",
"/usr/share/fonts/liberation/LiberationSans-Bold.ttf",
"/usr/share/fonts/liberation/LiberationMono-Bold.ttf",
"/usr/share/fonts/liberation/LiberationSerif-Bold.ttf",
"/usr/share/fonts/gnu-free/FreeSansBold.otf",
"/usr/share/fonts/gnu-free/FreeMonoBold.otf",
]
# 深色调色板 (R, G, B)
_DARK_COLORS = [
(0, 0, 180), # 蓝
(180, 0, 0), # 红
(0, 130, 0), # 绿
(130, 0, 130), # 紫
(120, 60, 0), # 棕
(0, 100, 100), # 青
(80, 80, 0), # 橄榄
(0, 0, 0), # 黑
(100, 0, 50), # 暗玫红
(50, 50, 150), # 钢蓝
]
class NormalCaptchaGenerator(BaseCaptchaGenerator):
"""普通字符验证码生成器。"""
def __init__(self, seed: int | None = None):
from config import RANDOM_SEED
super().__init__(seed=seed if seed is not None else RANDOM_SEED)
self.cfg = GENERATE_CONFIG["normal"]
self.chars = NORMAL_CHARS
self.width, self.height = self.cfg["image_size"]
# 预加载可用字体
self._fonts: list[str] = []
for p in _FONT_PATHS:
try:
ImageFont.truetype(p, 20)
self._fonts.append(p)
except OSError:
continue
if not self._fonts:
raise RuntimeError("未找到任何可用字体,无法生成验证码")
# ----------------------------------------------------------
# 公共接口
# ----------------------------------------------------------
def generate(self, text: str | None = None) -> tuple[Image.Image, str]:
rng = self.rng
# 1. 随机文本
if text is None:
length = rng.randint(*self.cfg["char_count_range"])
text = "".join(rng.choices(self.chars, k=length))
# 2. 浅色背景
bg_lo, bg_hi = self.cfg["bg_color_range"]
bg = tuple(rng.randint(bg_lo, bg_hi) for _ in range(3))
img = Image.new("RGB", (self.width, self.height), bg)
# 3. 逐字符绘制(旋转后粘贴)
self._draw_text(img, text, rng)
# 4. 干扰线
self._draw_noise_lines(img, rng)
# 5. 噪点
self._draw_noise_points(img, rng)
# 6. 轻微高斯模糊
if self.cfg["blur_radius"] > 0:
img = img.filter(ImageFilter.GaussianBlur(radius=self.cfg["blur_radius"]))
return img, text
# ----------------------------------------------------------
# 私有方法
# ----------------------------------------------------------
def _draw_text(self, img: Image.Image, text: str, rng: random.Random) -> None:
"""逐字符旋转并粘贴到画布上。"""
n = len(text)
# 每个字符的水平可用宽度
slot_w = self.width // n
font_size = int(min(slot_w * 0.9, self.height * 0.7))
font_size = max(font_size, 12)
for i, ch in enumerate(text):
font_path = rng.choice(self._fonts)
font = ImageFont.truetype(font_path, font_size)
color = rng.choice(_DARK_COLORS)
# 绘制单字符到临时透明图层
bbox = font.getbbox(ch)
cw = bbox[2] - bbox[0] + 4
ch_h = bbox[3] - bbox[1] + 4
char_img = Image.new("RGBA", (cw, ch_h), (0, 0, 0, 0))
ImageDraw.Draw(char_img).text((-bbox[0] + 2, -bbox[1] + 2), ch, fill=color, font=font)
# 随机旋转
angle = rng.randint(*self.cfg["rotation_range"])
char_img = char_img.rotate(angle, resample=Image.BICUBIC, expand=True)
# 粘贴位置
x = slot_w * i + (slot_w - char_img.width) // 2
y = (self.height - char_img.height) // 2 + rng.randint(-3, 3)
x = max(0, min(x, self.width - char_img.width))
y = max(0, min(y, self.height - char_img.height))
img.paste(char_img, (x, y), char_img)
def _draw_noise_lines(self, img: Image.Image, rng: random.Random) -> None:
"""绘制浅色干扰线。"""
draw = ImageDraw.Draw(img)
lo, hi = self.cfg["noise_line_range"]
num = rng.randint(lo, hi)
for _ in range(num):
x1, y1 = rng.randint(0, self.width), rng.randint(0, self.height)
x2, y2 = rng.randint(0, self.width), rng.randint(0, self.height)
color = tuple(rng.randint(150, 220) for _ in range(3))
draw.line([(x1, y1), (x2, y2)], fill=color, width=rng.randint(1, 2))
def _draw_noise_points(self, img: Image.Image, rng: random.Random) -> None:
"""绘制噪点。"""
draw = ImageDraw.Draw(img)
for _ in range(self.cfg["noise_point_num"]):
x = rng.randint(0, self.width - 1)
y = rng.randint(0, self.height - 1)
color = tuple(rng.randint(0, 200) for _ in range(3))
draw.point((x, y), fill=color)

211
generators/threed_gen.py Normal file
View File

@@ -0,0 +1,211 @@
"""
3D 立体验证码生成器
生成具有 3D 透视/阴影效果的验证码:
- 使用仿射变换模拟 3D 透视
- 添加阴影效果 (偏移的深色副本)
- 字符有深度感和倾斜
- 渐变背景增强立体感
- 标签: 纯字符内容
"""
import math
import random
from PIL import Image, ImageDraw, ImageFilter, ImageFont
from config import GENERATE_CONFIG, THREED_CHARS
from generators.base import BaseCaptchaGenerator
# 字体 (粗体效果更好渲染 3D)
_FONT_PATHS = [
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
"/usr/share/fonts/TTF/DejaVuSerif-Bold.ttf",
"/usr/share/fonts/liberation/LiberationSans-Bold.ttf",
"/usr/share/fonts/liberation/LiberationSerif-Bold.ttf",
"/usr/share/fonts/gnu-free/FreeSansBold.otf",
]
# 前景色 — 鲜艳、对比度高
_FRONT_COLORS = [
(220, 50, 50), # 红
(50, 100, 220), # 蓝
(30, 160, 30), # 绿
(200, 150, 0), # 金
(180, 50, 180), # 紫
(0, 160, 160), # 青
(220, 100, 0), # 橙
]
class ThreeDCaptchaGenerator(BaseCaptchaGenerator):
"""3D 立体验证码生成器。"""
def __init__(self, seed: int | None = None):
from config import RANDOM_SEED
super().__init__(seed=seed if seed is not None else RANDOM_SEED)
self.cfg = GENERATE_CONFIG["3d"]
self.chars = THREED_CHARS
self.width, self.height = self.cfg["image_size"]
# 预加载可用字体
self._fonts: list[str] = []
for p in _FONT_PATHS:
try:
ImageFont.truetype(p, 20)
self._fonts.append(p)
except OSError:
continue
if not self._fonts:
raise RuntimeError("未找到任何可用字体,无法生成验证码")
# ----------------------------------------------------------
# 公共接口
# ----------------------------------------------------------
def generate(self, text: str | None = None) -> tuple[Image.Image, str]:
rng = self.rng
# 1. 随机文本
if text is None:
length = rng.randint(*self.cfg["char_count_range"])
text = "".join(rng.choices(self.chars, k=length))
# 2. 渐变背景 (增强立体感)
img = self._gradient_background(rng)
# 3. 逐字符绘制 (阴影 + 透视 + 前景)
self._draw_3d_text(img, text, rng)
# 4. 干扰线 (较粗、有深度感)
self._draw_depth_lines(img, rng)
# 5. 轻微高斯模糊
img = img.filter(ImageFilter.GaussianBlur(radius=0.7))
return img, text
# ----------------------------------------------------------
# 私有方法
# ----------------------------------------------------------
def _gradient_background(self, rng: random.Random) -> Image.Image:
"""生成从上到下的浅色渐变背景。"""
img = Image.new("RGB", (self.width, self.height))
draw = ImageDraw.Draw(img)
# 随机两个浅色
c1 = tuple(rng.randint(200, 240) for _ in range(3))
c2 = tuple(rng.randint(180, 220) for _ in range(3))
for y in range(self.height):
ratio = y / max(self.height - 1, 1)
r = int(c1[0] + (c2[0] - c1[0]) * ratio)
g = int(c1[1] + (c2[1] - c1[1]) * ratio)
b = int(c1[2] + (c2[2] - c1[2]) * ratio)
draw.line([(0, y), (self.width, y)], fill=(r, g, b))
return img
def _draw_3d_text(self, img: Image.Image, text: str, rng: random.Random) -> None:
"""逐字符绘制 3D 效果: 阴影层 + 透视变换 + 前景层。"""
n = len(text)
slot_w = self.width // n
font_size = int(min(slot_w * 0.8, self.height * 0.65))
font_size = max(font_size, 16)
shadow_dx, shadow_dy = self.cfg["shadow_offset"]
for i, ch in enumerate(text):
font_path = rng.choice(self._fonts)
font = ImageFont.truetype(font_path, font_size)
front_color = rng.choice(_FRONT_COLORS)
# 阴影色: 对应前景色的暗化版本
shadow_color = tuple(max(0, c - 80) for c in front_color)
# 渲染单字符
bbox = font.getbbox(ch)
cw = bbox[2] - bbox[0] + 8
ch_h = bbox[3] - bbox[1] + 8
pad = max(shadow_dx, shadow_dy) + 4 # 额外空间给阴影
canvas_w = cw + pad * 2
canvas_h = ch_h + pad * 2
# --- 阴影层 ---
shadow_img = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))
ImageDraw.Draw(shadow_img).text(
(-bbox[0] + pad + shadow_dx, -bbox[1] + pad + shadow_dy),
ch, fill=shadow_color + (180,), font=font
)
# --- 前景层 ---
front_img = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))
ImageDraw.Draw(front_img).text(
(-bbox[0] + pad, -bbox[1] + pad),
ch, fill=front_color + (255,), font=font
)
# 合并: 先阴影后前景
char_img = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))
char_img = Image.alpha_composite(char_img, shadow_img)
char_img = Image.alpha_composite(char_img, front_img)
# 透视变换 (仿射)
char_img = self._perspective_transform(char_img, rng)
# 随机旋转
angle = rng.randint(-20, 20)
char_img = char_img.rotate(angle, resample=Image.BICUBIC, expand=True)
# 粘贴到画布
x = slot_w * i + (slot_w - char_img.width) // 2
y = (self.height - char_img.height) // 2 + rng.randint(-4, 4)
x = max(0, min(x, self.width - char_img.width))
y = max(0, min(y, self.height - char_img.height))
img.paste(char_img, (x, y), char_img)
def _perspective_transform(self, img: Image.Image, rng: random.Random) -> Image.Image:
"""对单个字符图片施加仿射变换模拟 3D 透视。"""
w, h = img.size
intensity = self.cfg["perspective_intensity"]
# 随机 shear / scale 参数
shear_x = rng.uniform(-intensity, intensity)
shear_y = rng.uniform(-intensity * 0.5, intensity * 0.5)
scale_x = rng.uniform(1.0 - intensity * 0.3, 1.0 + intensity * 0.3)
scale_y = rng.uniform(1.0 - intensity * 0.3, 1.0 + intensity * 0.3)
# 仿射变换矩阵 (a, b, c, d, e, f) -> (x', y') = (a*x+b*y+c, d*x+e*y+f)
# Pillow transform 需要逆变换系数
a = scale_x
b = shear_x
d = shear_y
e = scale_y
# 计算偏移让中心不变
c = (1 - a) * w / 2 - b * h / 2
f = -d * w / 2 + (1 - e) * h / 2
return img.transform(
(w, h), Image.AFFINE,
(a, b, c, d, e, f),
resample=Image.BICUBIC
)
def _draw_depth_lines(self, img: Image.Image, rng: random.Random) -> None:
"""绘制有深度感的干扰线 (较粗、带阴影)。"""
draw = ImageDraw.Draw(img)
num = rng.randint(2, 4)
for _ in range(num):
x1, y1 = rng.randint(0, self.width), rng.randint(0, self.height)
x2, y2 = rng.randint(0, self.width), rng.randint(0, self.height)
# 阴影线
shadow_color = tuple(rng.randint(80, 130) for _ in range(3))
dx, dy = self.cfg["shadow_offset"]
draw.line([(x1 + dx, y1 + dy), (x2 + dx, y2 + dy)],
fill=shadow_color, width=rng.randint(2, 3))
# 前景线
color = tuple(rng.randint(120, 200) for _ in range(3))
draw.line([(x1, y1), (x2, y2)], fill=color, width=rng.randint(1, 2))