#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 海报生成相关的工具类模块 """ import os import random import logging import colorsys from abc import ABC, abstractmethod from typing import List, Tuple, Optional, Dict, Any, Union import numpy as np from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageEnhance, ExifTags from sklearn.cluster import KMeans from collections import Counter logger = logging.getLogger(__name__) # --- 从 demo_refactored_templates.py 提取 --- class TextRenderer: """统一的文本渲染类""" def __init__(self, font_dir: str = "/root/autodl-tmp/TCC_RESTRUCT/assets"): self.font_dir = font_dir self.fonts = {} self.available_fonts = self._get_available_fonts() if not self.available_fonts: logger.warning(f"在目录 {self.font_dir} 中没有找到字体文件。") self.default_font_name = None else: self.default_font_name = list(self.available_fonts.keys())[0] logger.info(f"可用字体: {list(self.available_fonts.keys())}") def _get_available_fonts(self) -> Dict[str, str]: """获取所有可用字体""" fonts = {} if not os.path.isdir(self.font_dir): return fonts for f in os.listdir(self.font_dir): if f.lower().endswith(('.ttf', '.otf')): font_name = os.path.splitext(f)[0] fonts[font_name] = os.path.join(self.font_dir, f) return fonts def get_font_path(self, font_name: Optional[str] = None) -> str: """获取字体路径,如果未指定则返回默认字体""" if font_name and font_name in self.available_fonts: return self.available_fonts[font_name] if self.default_font_name: return self.available_fonts[self.default_font_name] # 如果没有任何字体,尝试一个通用系统路径作为后备 logger.warning(f"字体 '{font_name}' 不可用, 将尝试系统后备字体。") # 这是一个常见的 linux 系统路径 return "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" def load_font(self, size: int, font_name: Optional[str] = None) -> ImageFont.FreeTypeFont: """加载指定大小和名称的字体""" font_path = self.get_font_path(font_name) try: return ImageFont.truetype(font_path, size) except IOError: logger.error(f"无法加载字体: {font_path}。将使用Pillow默认字体。") return ImageFont.load_default() def calculate_optimal_font_size(self, text: str, target_width: int, font_name: Optional[str] = None, max_size: int = 120, min_size: int = 10) -> int: """二分法计算最佳字体大小以适应目标宽度""" low, high = min_size, max_size optimal_size = min_size font_path = self.get_font_path(font_name) while low <= high: mid = (low + high) // 2 font = self.load_font(mid, font_name) # 使用 getbbox 来获取更精确的宽度 try: text_bbox = font.getbbox(text) text_width = text_bbox[2] - text_bbox[0] except AttributeError: # 兼容旧版 Pillow text_width, _ = font.getsize(text) if text_width <= target_width: optimal_size = mid low = mid + 1 else: high = mid - 1 return optimal_size def get_text_size(self, text: str, font: ImageFont.FreeTypeFont) -> Tuple[int, int]: """ 获取文字的尺寸 Args: text: 文字内容 font: 字体对象 Returns: 文字尺寸 (width, height) """ if not text: return 0, 0 bbox = font.getbbox(text) return bbox[2] - bbox[0], bbox[3] - bbox[1] def calculate_font_size_and_width(self, text: str, target_width: int, font_name: Optional[str] = None, max_size: int = 120, min_size: int = 10) -> Tuple[int, int]: """ 计算文本的最佳字体大小,使其宽度接近目标宽度 Args: text: 文字内容 target_width: 目标宽度 font_name: 字体文件名 max_size: 最大字体大小 min_size: 最小字体大小 Returns: 元组 (最佳字体大小, 实际文本宽度) """ if not text.strip(): return min_size, 0 font_path = self.get_font_path(font_name) low = min_size high = max_size best_size = min_size best_width = 0 tolerance = 0.08 try: font = self.load_font(max_size, font_name) max_width = self.get_text_size(text, font)[0] except Exception: max_width = target_width * 2 if max_width < target_width * (1 + tolerance): best_size = max_size best_width = max_width else: closest_size = min_size closest_diff = target_width while low <= high: mid = (low + high) // 2 try: font = self.load_font(mid, font_name) width = self.get_text_size(text, font)[0] except Exception: width = target_width * 2 diff = abs(width - target_width) if diff < closest_diff: closest_diff = diff closest_size = mid if target_width * (1 - tolerance) <= width <= target_width * (1 + tolerance): best_size = mid best_width = width break if width < target_width: if width > best_width: best_width = width best_size = mid low = mid + 1 else: high = mid - 1 if best_width == 0: best_size = closest_size final_font = self.load_font(best_size, font_name) final_width = self.get_text_size(text, final_font)[0] return best_size, final_width def draw_multiline_text(self, draw: ImageDraw.Draw, position: Tuple[int, int], text: str, font: ImageFont.FreeTypeFont, max_width: int, line_spacing: int = 5, align: str = "left", text_color: Tuple[int, int, int, int] = (255, 255, 255, 255), outline_color: Optional[Tuple[int, int, int, int]] = None, outline_width: int = 1): """绘制多行文本,支持自动换行和对齐""" lines = self.wrap_text(text, font, max_width) x, y = position for line in lines: try: line_bbox = font.getbbox(line) line_width = line_bbox[2] - line_bbox[0] line_height = line_bbox[3] - line_bbox[1] except AttributeError: line_width, line_height = font.getsize(line) if align == "center": draw_x = x - line_width / 2 elif align == "right": draw_x = x - line_width else: # left draw_x = x if outline_color: self.draw_text_with_outline(draw, (draw_x, y), line, font, text_color, outline_color, outline_width) else: draw.text((draw_x, y), line, font=font, fill=text_color) y += line_height + line_spacing def wrap_text(self, text: str, font: ImageFont.FreeTypeFont, max_width: int) -> List[str]: """将文本按最大宽度换行""" lines = [] words = text.split(' ') current_line = "" for word in words: try: test_line_bbox = font.getbbox(current_line + " " + word) test_line_width = test_line_bbox[2] - test_line_bbox[0] except AttributeError: test_line_width, _ = font.getsize(current_line + " " + word) if test_line_width <= max_width: current_line += " " + word else: lines.append(current_line.strip()) current_line = word lines.append(current_line.strip()) return lines def draw_text_with_outline(self, draw: ImageDraw.Draw, position: Tuple[int, int], text: str, font: ImageFont.FreeTypeFont, text_color: Tuple[int, int, int, int] = (255, 255, 255, 255), outline_color: Tuple[int, int, int, int] = (0, 0, 0, 255), outline_width: int = 2): """绘制带描边的文本""" x, y = position # 绘制描边 for i in range(-outline_width, outline_width + 1): for j in range(-outline_width, outline_width + 1): if i != 0 or j != 0: draw.text((x + i, y + j), text, font=font, fill=outline_color) # 绘制文本本身 draw.text((x, y), text, font=font, fill=text_color) def draw_text_with_shadow(self, draw: ImageDraw.Draw, position: Tuple[int, int], text: str, font: ImageFont.FreeTypeFont, text_color: Tuple[int, int, int, int] = (255, 255, 255, 255), shadow_color: Tuple[int, int, int, int] = (0, 0, 0, 128), shadow_offset: Tuple[int, int] = (2, 2)): """ 绘制带阴影的文字 Args: draw: PIL绘图对象 position: 文字位置 text: 文字内容 font: 字体对象 text_color: 文字颜色 shadow_color: 阴影颜色 shadow_offset: 阴影偏移 """ x, y = position shadow_x, shadow_y = shadow_offset # 绘制阴影 draw.text((x + shadow_x, y + shadow_y), text, font=font, fill=shadow_color) # 绘制文字 draw.text(position, text, font=font, fill=text_color) def draw_rounded_rectangle(self, draw: ImageDraw.Draw, position: Tuple[int, int], size: Tuple[int, int], radius: int, fill: Optional[Tuple[int, int, int, int]] = None, outline: Optional[Tuple[int, int, int, int]] = None, width: int = 1): """ 绘制圆角矩形。Pillow的内置方法在某些版本有bug,这里是兼容实现。 """ x1, y1 = position x2, y2 = x1 + size[0], y1 + size[1] # 确保Pillow版本支持rounded_rectangle if hasattr(draw, 'rounded_rectangle'): try: draw.rounded_rectangle([x1, y1, x2, y2], radius=radius, fill=fill, outline=outline, width=width) return except Exception: # 如果新版方法失败,则回退到旧版手动绘制 pass # 手动绘制,作为旧版Pillow的回退方案 if fill: # 绘制中心矩形 draw.rectangle([x1 + radius, y1, x2 - radius, y2], fill=fill) draw.rectangle([x1, y1 + radius, x2, y2 - radius], fill=fill) # 绘制四个角 draw.pieslice([x1, y1, x1 + 2*radius, y1 + 2*radius], 180, 270, fill=fill) draw.pieslice([x2 - 2*radius, y1, x2, y1 + 2*radius], 270, 360, fill=fill) draw.pieslice([x1, y2 - 2*radius, x1 + 2*radius, y2], 90, 180, fill=fill) draw.pieslice([x2 - 2*radius, y2 - 2*radius, x2, y2], 0, 90, fill=fill) if outline and width > 0: # 简化的边框绘制 for i in range(width): draw.arc([x1 + i, y1 + i, x1 + 2*radius - i, y1 + 2*radius - i], 180, 270, fill=outline) draw.arc([x2 - 2*radius + i, y1 + i, x2 - i, y1 + 2*radius - i], 270, 360, fill=outline) draw.arc([x1 + i, y2 - 2*radius + i, x1 + 2*radius - i, y2 - i], 90, 180, fill=outline) draw.arc([x2 - 2*radius + i, y2 - 2*radius + i, x2 - i, y2 - i], 0, 90, fill=outline) draw.line([x1 + radius, y1 + i, x2 - radius, y1 + i], fill=outline) draw.line([x1 + radius, y2 - i, x2 - radius, y2 - i], fill=outline) draw.line([x1 + i, y1 + radius, x1 + i, y2 - radius], fill=outline) draw.line([x2 - i, y1 + radius, x2 - i, y2 - radius], fill=outline) class ColorExtractor: """统一的颜色处理类""" @staticmethod def extract_dominant_colors(image: Image.Image, k: int = 5, sample_size: int = 200) -> List[Tuple[int, int, int]]: """使用K-Means提取主色调""" image_rgb = image.convert("RGB") # 调整采样大小以提高性能 if sample_size: thumb = image_rgb.copy() thumb.thumbnail((sample_size, sample_size)) pixels = list(thumb.getdata()) else: pixels = list(image_rgb.getdata()) # 过滤掉近似白色和黑色的像素 pixels = [p for p in pixels if (sum(p) > 30 and sum(p) < 700)] if not pixels: return [(128, 128, 128)] # 返回默认灰色 kmeans = KMeans(n_clusters=k, random_state=42, n_init=10).fit(pixels) dominant_colors = kmeans.cluster_centers_.astype(int).tolist() # 按颜色在图像中的流行度排序 labels = kmeans.labels_ counts = Counter(labels) sorted_colors = sorted(dominant_colors, key=lambda color: counts[dominant_colors.index(color)], reverse=True) return [tuple(c) for c in sorted_colors] @staticmethod def get_complementary_color(color: Tuple[int, int, int]) -> Tuple[int, int, int]: """获取互补色""" h, l, s = colorsys.rgb_to_hls(color[0]/255.0, color[1]/255.0, color[2]/255.0) h = (h + 0.5) % 1.0 r, g, b = colorsys.hls_to_rgb(h, l, s) return (int(r*255), int(g*255), int(b*255)) @staticmethod def create_gradient_colors(base_color: Tuple[int, int, int], variation: float = 0.3) -> Tuple[Tuple[int, int, int], Tuple[int, int, int]]: """基于基色创建和谐的渐变色对""" h, l, s = colorsys.rgb_to_hls(base_color[0]/255.0, base_color[1]/255.0, base_color[2]/255.0) # 创建更亮和更暗的变体 light_l = min(1.0, l + variation / 2) dark_l = max(0.0, l - variation / 2) top_r, top_g, top_b = colorsys.hls_to_rgb(h, light_l, s) bottom_r, bottom_g, bottom_b = colorsys.hls_to_rgb(h, dark_l, s) return ((int(top_r*255), int(top_g*255), int(top_b*255)), (int(bottom_r*255), int(bottom_g*255), int(bottom_b*255))) class ImageProcessor: """统一的图像处理类""" @staticmethod def ensure_rgba(image: Image.Image) -> Image.Image: """ 确保图像是RGBA模式 Args: image: PIL Image对象 Returns: RGBA模式的PIL Image对象 """ if image.mode == 'RGBA': return image return image.convert('RGBA') @staticmethod def resize_image(image: Image.Image, target_width: int) -> Image.Image: """调整图像大小,保持原始高宽比""" if image.width == target_width: return image orig_aspect = image.height / image.width target_height = int(target_width * orig_aspect) return image.resize((target_width, target_height), Image.LANCZOS) @staticmethod def load_image(image_path: str) -> Optional[Image.Image]: """ 安全地加载图像文件 """ if not os.path.exists(image_path): logger.error(f"图像文件不存在: {image_path}") return None try: image = Image.open(image_path) image.load() # 强制加载图像数据,验证文件完整性 logger.info(f"成功加载图像: {os.path.basename(image_path)}, 尺寸: {image.size}") return image except (IOError, OSError) as e: logger.error(f"加载或验证图像失败: {image_path}, 错误: {e}") return None @staticmethod def resize_and_crop(image: Image.Image, target_size: Tuple[int, int]) -> Image.Image: """ 调整图像大小并居中裁剪到目标尺寸 """ target_width, target_height = target_size img_width, img_height = image.size if img_width == target_width and img_height == target_height: return image scale = max(target_width / image.width, target_height / image.height) new_width = int(image.width * scale) new_height = int(image.height * scale) resized_image = image.resize((new_width, new_height), Image.LANCZOS) left = (new_width - target_width) // 2 top = (new_height - target_height) // 2 right = left + target_width bottom = top + target_height return resized_image.crop((left, top, right, bottom)) @staticmethod def save_image(image: Image.Image, output_path: str, quality: int = 95): """保存图片, 如果是JPG格式且有Alpha通道,则自动处理""" try: # 确保输出目录存在 os.makedirs(os.path.dirname(output_path), exist_ok=True) # 如果是RGBA模式且要保存为JPEG,需要转换 if image.mode == 'RGBA' and output_path.lower().endswith(('.jpg', '.jpeg')): logger.info(f"图像为RGBA模式,将转换为RGB以保存为JPEG: {output_path}") # 创建一个白色背景 background = Image.new('RGB', image.size, (255, 255, 255)) # 将原图粘贴到背景上,使用alpha通道作为蒙版 background.paste(image, mask=image.split()[-1]) background.save(output_path, 'JPEG', quality=quality) else: image.save(output_path, quality=quality) logger.info(f"图片成功保存至: {output_path}") except Exception as e: logger.error(f"保存图片失败: {output_path}, 错误: {e}", exc_info=True) # --- 图像效果与增强 --- @staticmethod def enhance_image(image: Image.Image, contrast: float = 1.0, brightness: float = 1.0, saturation: float = 1.0) -> Image.Image: """增强图片效果""" enhanced_image = image if brightness != 1.0: enhancer = ImageEnhance.Brightness(enhanced_image) enhanced_image = enhancer.enhance(brightness) if contrast != 1.0: enhancer = ImageEnhance.Contrast(enhanced_image) enhanced_image = enhancer.enhance(contrast) if saturation != 1.0: enhancer = ImageEnhance.Color(enhanced_image) enhanced_image = enhancer.enhance(saturation) return enhanced_image @staticmethod def apply_blur(image: Image.Image, radius: float = 2.0) -> Image.Image: """应用高斯模糊效果""" return image.filter(ImageFilter.GaussianBlur(radius=radius)) # --- 哈希干扰方法 (来自 PosterNotesCreator) --- @staticmethod def strip_metadata(image: Image.Image) -> Image.Image: """移除图像的EXIF等元数据""" if not image.info: return image # 创建一个没有元数据的新图像副本 image_without_exif = Image.new(image.mode, image.size) image_without_exif.putdata(list(image.getdata())) # 保留 ICC profile, 因为它影响颜色显示 if 'icc_profile' in image.info: image_without_exif.info['icc_profile'] = image.info['icc_profile'] return image_without_exif @staticmethod def apply_strategic_hash_disruption(image: Image.Image, strength: str = "medium") -> Image.Image: """ 智能地应用一系列哈希干扰方法,以在保持视觉质量的同时最大化改变图像哈希值。 """ logger.debug(f"开始应用策略性哈希干扰,强度: {strength}") # 1. 预处理:移除元数据 disrupted_image = ImageProcessor.strip_metadata(image) # 2. 颜色空间微调 if strength == "low": disrupted_image = ImageProcessor.enhance_image(disrupted_image, saturation=1.01) elif strength == "medium": disrupted_image = ImageProcessor.enhance_image(disrupted_image, brightness=1.01, saturation=1.02) else: # high disrupted_image = ImageProcessor.enhance_image(disrupted_image, brightness=0.99, contrast=1.01, saturation=1.03) # 3. 添加难以察觉的噪声 if strength == "low": noise_intensity = 5 elif strength == "medium": noise_intensity = 10 else: # high noise_intensity = 15 np_image = np.array(disrupted_image.convert("RGB")).astype(np.int16) noise = np.random.randint(-noise_intensity, noise_intensity, np_image.shape, dtype=np.int16) np_image += noise np_image = np.clip(np_image, 0, 255).astype(np.uint8) disrupted_image = Image.fromarray(np_image, "RGB") # 4. 轻微的几何变换 if strength != "low": w, h = disrupted_image.size if strength == "medium": # 裁剪掉1个像素的边框 disrupted_image = disrupted_image.crop((1, 1, w - 1, h - 1)) # 再缩放回去 disrupted_image = disrupted_image.resize((w, h), Image.LANCZOS) else: # high # 细微的透视变换 coeffs = [1, 0.001, 0, 0.001, 1, 0, 0, 0] disrupted_image = disrupted_image.transform((w, h), Image.PERSPECTIVE, coeffs, Image.BICUBIC) logger.debug("策略性哈希干扰应用完成。") return disrupted_image.convert("RGBA") @staticmethod def create_canvas(size: Tuple[int, int], color: Tuple[int, int, int, int] = (255, 255, 255, 255)) -> Image.Image: """创建指定尺寸和颜色的画布""" return Image.new('RGBA', size, color) @staticmethod def paste_image(canvas: Image.Image, image: Image.Image, position: Tuple[int, int], mask: Optional[Image.Image] = None) -> Image.Image: """将图像粘贴到画布上""" canvas.paste(image, position, mask) return canvas @staticmethod def alpha_composite(base: Image.Image, overlay: Image.Image) -> Image.Image: """Alpha合成两个图像""" base = ImageProcessor.ensure_rgba(base) overlay = ImageProcessor.ensure_rgba(overlay) if base.size != overlay.size: overlay = overlay.resize(base.size, Image.LANCZOS) return Image.alpha_composite(base, overlay)