Files
CaptchBreaker/generators/normal_gen.py
2026-03-10 18:47:29 +08:00

155 lines
5.5 KiB
Python

"""
普通字符验证码生成器
生成风格:
- 浅色随机背景 (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)