Initialize repository
This commit is contained in:
186
generators/math_gen.py
Normal file
186
generators/math_gen.py
Normal 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))
|
||||
Reference in New Issue
Block a user