""" 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_text"] 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(*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(-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))