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

187 lines
6.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
算式验证码生成器
生成形如 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))