594 lines
24 KiB
Python
Raw 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.

#!/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)