2025-07-31 15:35:23 +08:00

794 lines
28 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 logging
import random
from typing import Tuple, List, Dict, Any, Optional, Union
from pathlib import Path
import colorsys
import math
from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageEnhance
logger = logging.getLogger(__name__)
class ImageProcessor:
"""图像处理工具类"""
def __init__(self):
self.logger = logging.getLogger(__name__)
def resize_image(self, image: Image.Image, target_size: Tuple[int, int],
keep_aspect: bool = True, crop: bool = False) -> Image.Image:
"""
调整图像大小
Args:
image: 原始图像
target_size: 目标尺寸 (宽, 高)
keep_aspect: 是否保持宽高比
crop: 是否裁剪以适应目标尺寸
Returns:
调整后的图像
"""
if not keep_aspect:
return image.resize(target_size, Image.LANCZOS)
# 保持宽高比
target_width, target_height = target_size
width, height = image.size
# 计算缩放比例
ratio_w = target_width / width
ratio_h = target_height / height
if crop:
# 裁剪模式:先缩放到较大的尺寸,然后裁剪中心部分
ratio = max(ratio_w, ratio_h)
new_width = int(width * ratio)
new_height = int(height * ratio)
resized = 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.crop((left, top, right, bottom))
else:
# 非裁剪模式:缩放到适应目标尺寸的最大尺寸
ratio = min(ratio_w, ratio_h)
new_width = int(width * ratio)
new_height = int(height * ratio)
return image.resize((new_width, new_height), Image.LANCZOS)
def apply_overlay(self, image: Image.Image, color: Tuple[int, int, int],
opacity: float = 0.5) -> Image.Image:
"""
在图像上应用半透明覆盖层
Args:
image: 原始图像
color: 覆盖层颜色 (R, G, B)
opacity: 不透明度 (0.0-1.0)
Returns:
应用覆盖层后的图像
"""
overlay = Image.new('RGBA', image.size, color + (int(255 * opacity),))
if image.mode != 'RGBA':
image = image.convert('RGBA')
return Image.alpha_composite(image, overlay)
def apply_blur(self, image: Image.Image, radius: int = 5) -> Image.Image:
"""
应用模糊效果
Args:
image: 原始图像
radius: 模糊半径
Returns:
模糊后的图像
"""
return image.filter(ImageFilter.GaussianBlur(radius))
def create_rounded_corners(self, image: Image.Image, radius: int = 20) -> Image.Image:
"""
创建圆角图像
Args:
image: 原始图像
radius: 圆角半径
Returns:
圆角处理后的图像
"""
# 创建一个透明的画布
rounded = Image.new('RGBA', image.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(rounded)
# 绘制圆角矩形
width, height = image.size
draw.rounded_rectangle([(0, 0), (width, height)], radius, fill=(255, 255, 255, 255))
# 确保图像是RGBA模式
if image.mode != 'RGBA':
image = image.convert('RGBA')
# 使用圆角矩形作为蒙版
result = Image.new('RGBA', image.size, (0, 0, 0, 0))
result.paste(image, (0, 0), mask=rounded)
return result
def add_shadow(self, image: Image.Image, offset: Tuple[int, int] = (5, 5),
radius: int = 10, color: Tuple[int, int, int] = (0, 0, 0),
opacity: float = 0.7) -> Image.Image:
"""
为图像添加阴影效果
Args:
image: 原始图像
offset: 阴影偏移量 (x, y)
radius: 阴影模糊半径
color: 阴影颜色
opacity: 阴影不透明度
Returns:
添加阴影后的图像
"""
# 确保图像是RGBA模式
if image.mode != 'RGBA':
image = image.convert('RGBA')
# 创建阴影蒙版
shadow = Image.new('RGBA', image.size, (0, 0, 0, 0))
shadow_draw = ImageDraw.Draw(shadow)
shadow_draw.rectangle([(0, 0), image.size], fill=color + (int(255 * opacity),))
# 应用模糊
shadow = shadow.filter(ImageFilter.GaussianBlur(radius))
# 创建结果图像
result = Image.new('RGBA', (
image.width + abs(offset[0]),
image.height + abs(offset[1])
), (0, 0, 0, 0))
# 放置阴影和原图
x_offset = max(0, offset[0])
y_offset = max(0, offset[1])
result.paste(shadow, (x_offset, y_offset))
result.paste(image, (max(0, -offset[0]), max(0, -offset[1])), mask=image)
return result
class TextRenderer:
"""文本渲染工具类"""
def __init__(self, font_dir: str = "assets/font"):
self.font_dir = font_dir
self.logger = logging.getLogger(__name__)
self.default_font = self._load_default_font()
def _load_default_font(self, size: int = 24) -> Optional[ImageFont.FreeTypeFont]:
"""加载默认字体"""
try:
# 尝试加载系统字体
system_fonts = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", # Linux
"/System/Library/Fonts/STHeiti Light.ttc", # macOS
"C:\\Windows\\Fonts\\msyh.ttc" # Windows
]
# 首先尝试加载指定目录中的字体
if os.path.exists(self.font_dir):
font_files = [f for f in os.listdir(self.font_dir)
if f.endswith(('.ttf', '.otf', '.ttc'))]
if font_files:
return ImageFont.truetype(os.path.join(self.font_dir, font_files[0]), size)
# 然后尝试系统字体
for font_path in system_fonts:
if os.path.exists(font_path):
return ImageFont.truetype(font_path, size)
# 最后使用默认字体
return ImageFont.load_default()
except Exception as e:
self.logger.warning(f"加载默认字体失败: {e}")
return ImageFont.load_default()
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):
"""
绘制带描边的文字
Args:
draw: PIL绘图对象
position: 文字位置
text: 文字内容
font: 字体对象
text_color: 文字颜色
outline_color: 描边颜色
outline_width: 描边宽度
"""
x, y = position
# 绘制描边
for offset_x in range(-outline_width, outline_width + 1):
for offset_y in range(-outline_width, outline_width + 1):
if offset_x == 0 and offset_y == 0:
continue
draw.text((x + offset_x, y + offset_y), text, font=font, fill=outline_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_color: Tuple[int, int, int, int],
outline_color: Optional[Tuple[int, int, int, int]] = None,
outline_width: int = 0):
"""
绘制圆角矩形
Args:
draw: PIL绘图对象
position: 左上角位置
size: 矩形大小
radius: 圆角半径
fill_color: 填充颜色
outline_color: 边框颜色
outline_width: 边框宽度
"""
x, y = position
width, height = size
# 确保尺寸有效
if width <= 0 or height <= 0:
return
# 限制圆角半径
radius = min(radius, width // 2, height // 2)
# 创建圆角矩形路径
# 这是一个简化版本PIL的较新版本有更好的圆角矩形支持
if radius > 0:
# 绘制中心矩形
draw.rectangle([x + radius, y, x + width - radius, y + height], fill=fill_color)
draw.rectangle([x, y + radius, x + width, y + height - radius], fill=fill_color)
# 绘制四个圆角
draw.pieslice([x, y, x + 2*radius, y + 2*radius], 180, 270, fill=fill_color)
draw.pieslice([x + width - 2*radius, y, x + width, y + 2*radius], 270, 360, fill=fill_color)
draw.pieslice([x, y + height - 2*radius, x + 2*radius, y + height], 90, 180, fill=fill_color)
draw.pieslice([x + width - 2*radius, y + height - 2*radius, x + width, y + height], 0, 90, fill=fill_color)
else:
# 普通矩形
draw.rectangle([x, y, x + width, y + height], fill=fill_color)
# 绘制边框(如果需要)
if outline_color and outline_width > 0:
# 简化的边框绘制 - 使用线条而不是矩形避免坐标错误
for i in range(outline_width):
offset = i
# 确保坐标有效
if radius > 0:
# 上边
if x + radius + offset < x + width - radius - offset:
draw.line([x + radius + offset, y + offset,
x + width - radius - offset, y + offset],
fill=outline_color, width=1)
# 下边
if x + radius + offset < x + width - radius - offset and y + height - offset >= y + offset:
draw.line([x + radius + offset, y + height - offset,
x + width - radius - offset, y + height - offset],
fill=outline_color, width=1)
# 左边
if y + radius + offset < y + height - radius - offset:
draw.line([x + offset, y + radius + offset,
x + offset, y + height - radius - offset],
fill=outline_color, width=1)
# 右边
if y + radius + offset < y + height - radius - offset:
draw.line([x + width - offset, y + radius + offset,
x + width - offset, y + height - radius - offset],
fill=outline_color, width=1)
else:
# 普通矩形边框
draw.rectangle([x + offset, y + offset, x + width - offset, y + height - offset],
outline=outline_color, width=1)
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 get_font(self, font_name: Optional[str] = None, size: int = 24) -> ImageFont.FreeTypeFont:
"""
获取指定字体
Args:
font_name: 字体文件名如果为None则使用默认字体
size: 字体大小
Returns:
字体对象
"""
if not font_name:
return self.default_font.font_variant(size=size)
try:
# 尝试从字体目录加载
font_path = os.path.join(self.font_dir, font_name)
if os.path.exists(font_path):
return ImageFont.truetype(font_path, size)
# 尝试作为绝对路径加载
if os.path.exists(font_name):
return ImageFont.truetype(font_name, size)
self.logger.warning(f"找不到字体: {font_name},使用默认字体")
return self.default_font.font_variant(size=size)
except Exception as e:
self.logger.warning(f"加载字体 {font_name} 失败: {e}")
return self.default_font.font_variant(size=size)
def draw_text(self, image: Image.Image, text: str, position: Tuple[int, int],
font_name: Optional[str] = None, font_size: int = 24,
color: Tuple[int, int, int] = (0, 0, 0),
align: str = "left", max_width: Optional[int] = None) -> Image.Image:
"""
在图像上绘制文本
Args:
image: 目标图像
text: 要绘制的文本
position: 文本位置 (x, y)
font_name: 字体文件名
font_size: 字体大小
color: 文本颜色 (R, G, B)
align: 对齐方式 ('left', 'center', 'right')
max_width: 最大宽度,超过会自动换行
Returns:
绘制文本后的图像
"""
draw = ImageDraw.Draw(image)
font = self.get_font(font_name, font_size)
if not max_width:
# 简单绘制文本
x, y = position
if align == "center":
text_width, _ = draw.textsize(text, font=font)
x -= text_width // 2
elif align == "right":
text_width, _ = draw.textsize(text, font=font)
x -= text_width
draw.text((x, y), text, font=font, fill=color)
return image
# 处理自动换行
lines = []
current_line = ""
for word in text.split():
test_line = current_line + word + " "
text_width, _ = draw.textsize(test_line, font=font)
if text_width <= max_width:
current_line = test_line
else:
lines.append(current_line)
current_line = word + " "
if current_line:
lines.append(current_line)
# 绘制每一行
x, y = position
line_height = font_size * 1.2 # 估计行高
for i, line in enumerate(lines):
line_y = y + i * line_height
if align == "center":
line_width, _ = draw.textsize(line, font=font)
line_x = x - line_width // 2
elif align == "right":
line_width, _ = draw.textsize(line, font=font)
line_x = x - line_width
else:
line_x = x
draw.text((line_x, line_y), line, font=font, fill=color)
return image
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:
"""
计算最适合的字体大小
Args:
text: 文字内容
target_width: 目标宽度
font_name: 字体文件名
max_size: 最大字体大小
min_size: 最小字体大小
Returns:
最适合的字体大小
"""
if not text.strip():
return min_size
# 二分查找最佳字体大小
left, right = min_size, max_size
best_size = min_size
while left <= right:
mid_size = (left + right) // 2
try:
if font_path:
font = self._load_default_font(mid_size)
else:
font = ImageFont.load_default()
# 获取文字边界框
bbox = font.getbbox(text)
text_width = bbox[2] - bbox[0]
if text_width <= target_width:
best_size = mid_size
left = mid_size + 1
else:
right = mid_size - 1
except Exception:
right = mid_size - 1
return best_size
def get_text_size(self, text: str, font: ImageFont.FreeTypeFont) -> Tuple[int, int]:
"""
获取文字的尺寸
Args:
text: 文字内容
font: 字体对象
Returns:
文字尺寸 (width, height)
"""
bbox = font.getbbox(text)
return bbox[2] - bbox[0], bbox[3] - bbox[1]
def draw_multiline_text(self, image: Image.Image, text: str, position: Tuple[int, int],
font_name: Optional[str] = None, font_size: int = 24,
color: Tuple[int, int, int] = (0, 0, 0),
align: str = "left", spacing: int = 4,
max_width: Optional[int] = None) -> Image.Image:
"""
绘制多行文本,支持自动换行
Args:
image: 目标图像
text: 要绘制的多行文本
position: 文本起始位置 (x, y)
font_name: 字体文件名
font_size: 字体大小
color: 文本颜色 (R, G, B)
align: 对齐方式 ('left', 'center', 'right')
spacing: 行间距
max_width: 最大宽度,超过会自动换行
Returns:
绘制文本后的图像
"""
draw = ImageDraw.Draw(image)
font = self.get_font(font_name, font_size)
# 处理预先分行的文本
paragraphs = text.split('\n')
all_lines = []
for paragraph in paragraphs:
if not max_width:
all_lines.append(paragraph)
continue
# 自动换行处理
words = paragraph.split()
current_line = ""
for word in words:
test_line = current_line + word + " "
text_width, _ = draw.textsize(test_line, font=font)
if text_width <= max_width:
current_line = test_line
else:
all_lines.append(current_line)
current_line = word + " "
if current_line:
all_lines.append(current_line)
# 绘制所有行
x, y = position
line_height = font_size + spacing
for i, line in enumerate(all_lines):
line_y = y + i * line_height
if align == "center":
line_width, _ = draw.textsize(line, font=font)
line_x = x - line_width // 2
elif align == "right":
line_width, _ = draw.textsize(line, font=font)
line_x = x - line_width
else:
line_x = x
draw.text((line_x, line_y), line, font=font, fill=color)
return image
class ColorExtractor:
"""颜色提取工具类"""
def __init__(self):
self.logger = logging.getLogger(__name__)
def extract_dominant_color(self, image: Image.Image, num_colors: int = 5) -> List[Tuple[int, int, int]]:
"""
提取图像中的主要颜色
Args:
image: 输入图像
num_colors: 要提取的颜色数量
Returns:
颜色列表,按主导程度排序
"""
# 缩小图像以加快处理速度
small_image = image.resize((100, 100))
# 确保图像是RGB模式
if small_image.mode != 'RGB':
small_image = small_image.convert('RGB')
# 获取所有像素
pixels = list(small_image.getdata())
# 计算颜色频率
color_counts = {}
for pixel in pixels:
if pixel in color_counts:
color_counts[pixel] += 1
else:
color_counts[pixel] = 1
# 按频率排序
sorted_colors = sorted(color_counts.items(), key=lambda x: x[1], reverse=True)
# 返回前N个颜色
return [color for color, _ in sorted_colors[:num_colors]]
def extract_color_palette(self, image: Image.Image, num_colors: int = 5) -> List[Tuple[int, int, int]]:
"""
提取图像的颜色调色板
使用k-means聚类算法的简化版本
Args:
image: 输入图像
num_colors: 调色板中的颜色数量
Returns:
颜色列表
"""
# 缩小图像以加快处理速度
small_image = image.resize((100, 100))
# 确保图像是RGB模式
if small_image.mode != 'RGB':
small_image = small_image.convert('RGB')
# 获取所有像素
pixels = list(small_image.getdata())
# 随机选择初始中心点
centers = random.sample(pixels, num_colors)
# 简单的k-means聚类 (最多迭代10次)
for _ in range(10):
clusters = [[] for _ in range(num_colors)]
# 将每个像素分配给最近的中心点
for pixel in pixels:
distances = [self._color_distance(pixel, center) for center in centers]
closest_center = distances.index(min(distances))
clusters[closest_center].append(pixel)
# 更新中心点
new_centers = []
for cluster in clusters:
if not cluster:
# 如果聚类为空,保持原中心点
new_centers.append(centers[len(new_centers)])
continue
# 计算聚类中所有像素的平均值
r_sum = sum(pixel[0] for pixel in cluster)
g_sum = sum(pixel[1] for pixel in cluster)
b_sum = sum(pixel[2] for pixel in cluster)
new_center = (
r_sum // len(cluster),
g_sum // len(cluster),
b_sum // len(cluster)
)
new_centers.append(new_center)
# 检查是否收敛
if centers == new_centers:
break
centers = new_centers
return centers
def _color_distance(self, color1: Tuple[int, int, int], color2: Tuple[int, int, int]) -> float:
"""计算两个颜色之间的欧几里得距离"""
r1, g1, b1 = color1
r2, g2, b2 = color2
return math.sqrt((r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2)
def generate_complementary_color(self, color: Tuple[int, int, int]) -> Tuple[int, int, int]:
"""
生成互补色
Args:
color: 输入颜色 (R, G, B)
Returns:
互补色 (R, G, B)
"""
r, g, b = color
# 转换为HSV
h, s, v = colorsys.rgb_to_hsv(r / 255, g / 255, b / 255)
# 互补色的色相相差180度
h = (h + 0.5) % 1.0
# 转回RGB
r, g, b = colorsys.hsv_to_rgb(h, s, v)
return (int(r * 255), int(g * 255), int(b * 255))
def generate_color_scheme(self, base_color: Tuple[int, int, int],
scheme_type: str = "complementary",
num_colors: int = 5) -> List[Tuple[int, int, int]]:
"""
生成配色方案
Args:
base_color: 基础颜色 (R, G, B)
scheme_type: 配色方案类型 ('complementary', 'analogous', 'triadic', 'monochromatic')
num_colors: 生成的颜色数量
Returns:
颜色列表
"""
r, g, b = base_color
h, s, v = colorsys.rgb_to_hsv(r / 255, g / 255, b / 255)
colors = []
if scheme_type == "complementary":
# 互补色方案
colors.append(base_color)
h_comp = (h + 0.5) % 1.0
r_comp, g_comp, b_comp = colorsys.hsv_to_rgb(h_comp, s, v)
colors.append((int(r_comp * 255), int(g_comp * 255), int(b_comp * 255)))
# 添加额外的颜色
for i in range(num_colors - 2):
h_extra = (h + (i + 1) * 0.1) % 1.0
r_extra, g_extra, b_extra = colorsys.hsv_to_rgb(h_extra, s, v)
colors.append((int(r_extra * 255), int(g_extra * 255), int(b_extra * 255)))
elif scheme_type == "analogous":
# 类似色方案
for i in range(num_colors):
h_analog = (h + (i - num_colors // 2) * 0.05) % 1.0
r_analog, g_analog, b_analog = colorsys.hsv_to_rgb(h_analog, s, v)
colors.append((int(r_analog * 255), int(g_analog * 255), int(b_analog * 255)))
elif scheme_type == "triadic":
# 三色方案
for i in range(3):
h_triad = (h + i / 3) % 1.0
r_triad, g_triad, b_triad = colorsys.hsv_to_rgb(h_triad, s, v)
colors.append((int(r_triad * 255), int(g_triad * 255), int(b_triad * 255)))
# 添加额外的颜色
for i in range(num_colors - 3):
h_extra = (h + (i + 1) * 0.1) % 1.0
r_extra, g_extra, b_extra = colorsys.hsv_to_rgb(h_extra, s, v)
colors.append((int(r_extra * 255), int(g_extra * 255), int(b_extra * 255)))
elif scheme_type == "monochromatic":
# 单色方案
for i in range(num_colors):
s_mono = max(0.1, min(1.0, s - 0.3 + i * 0.6 / (num_colors - 1)))
v_mono = max(0.2, min(1.0, v - 0.3 + i * 0.6 / (num_colors - 1)))
r_mono, g_mono, b_mono = colorsys.hsv_to_rgb(h, s_mono, v_mono)
colors.append((int(r_mono * 255), int(g_mono * 255), int(b_mono * 255)))
else:
# 默认返回随机颜色
colors.append(base_color)
for _ in range(num_colors - 1):
h_rand = random.random()
s_rand = random.uniform(0.5, 1.0)
v_rand = random.uniform(0.5, 1.0)
r_rand, g_rand, b_rand = colorsys.hsv_to_rgb(h_rand, s_rand, v_rand)
colors.append((int(r_rand * 255), int(g_rand * 255), int(b_rand * 255)))
return colors