2400 lines
126 KiB
Python
2400 lines
126 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
高级封面生成器 - 支持完整的模板功能
|
||
Advanced Cover Generator - Full Template Support
|
||
|
||
功能:
|
||
- 背景模糊
|
||
- 人物抠图
|
||
- 人物描边
|
||
- 蒙版叠加
|
||
- 自定义字体和文字样式
|
||
- 步骤化生成,每步生成预览图
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import json
|
||
import argparse
|
||
from typing import Dict, Any, Optional, Tuple
|
||
from pathlib import Path
|
||
|
||
# ⚠️ 修复模块路径:确保 Python 能找到 modules 和其他本地模块
|
||
# 将脚本所在目录(python 目录)添加到 sys.path
|
||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||
|
||
# 🔧 关键修复:在打包环境中,modules可能在当前目录或上级目录
|
||
# 添加多个可能的路径
|
||
possible_paths = [
|
||
script_dir, # 脚本所在目录
|
||
os.path.join(script_dir, '..'), # 上一级目录(如果python-scripts是子目录)
|
||
os.getcwd(), # 当前工作目录
|
||
]
|
||
|
||
for path in possible_paths:
|
||
abs_path = os.path.abspath(path)
|
||
if abs_path not in sys.path:
|
||
sys.path.insert(0, abs_path)
|
||
print(f"[PATH] Added to sys.path: {abs_path}", file=sys.stderr, flush=True)
|
||
|
||
print(f"[PATH] Current sys.path (first 3): {sys.path[:3]}", file=sys.stderr, flush=True) # 显示前3个路径用于诊断
|
||
|
||
# ⚠️ 强制输出:验证Python脚本确实在运行
|
||
print("=" * 60, file=sys.stderr, flush=True)
|
||
print("ADVANCED_COVER_GENERATOR.PY STARTED", file=sys.stderr, flush=True)
|
||
print("=" * 60, file=sys.stderr, flush=True)
|
||
sys.stderr.flush()
|
||
|
||
# 导入基础库
|
||
try:
|
||
import cv2
|
||
import numpy as np
|
||
from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageEnhance
|
||
print("✅ Basic libraries loaded (cv2, numpy, PIL)", file=sys.stderr, flush=True)
|
||
except ImportError as e:
|
||
print(f"❌ Error: Missing required library - {e}", file=sys.stderr, flush=True)
|
||
sys.exit(1)
|
||
|
||
# 导入人像分割模块(支持MODNet和rembg)
|
||
try:
|
||
from modules.segment import PersonSegmenter
|
||
SEGMENT_AVAILABLE = True
|
||
diag_segment = "✅ PersonSegmenter loaded successfully (supports MODNet + rembg)"
|
||
print(diag_segment, file=sys.stderr)
|
||
sys.stderr.flush()
|
||
except ImportError as e:
|
||
SEGMENT_AVAILABLE = False
|
||
PersonSegmenter = None
|
||
diag_segment = f"❌ PersonSegmenter failed to load: {e}"
|
||
print(diag_segment, file=sys.stderr)
|
||
print(f"[DIAGNOSTIC] Attempted module paths:", file=sys.stderr)
|
||
for p in sys.path[:5]:
|
||
modules_path = os.path.join(p, 'modules', 'segment.py')
|
||
exists = "EXISTS" if os.path.exists(modules_path) else "NOT FOUND"
|
||
print(f" {modules_path} - {exists}", file=sys.stderr)
|
||
sys.stderr.flush()
|
||
|
||
# 尝试其他可能的导入方式
|
||
try:
|
||
import modules.segment as seg_module
|
||
PersonSegmenter = seg_module.PersonSegmenter
|
||
SEGMENT_AVAILABLE = True
|
||
print("✅ PersonSegmenter loaded via alternative import path", file=sys.stderr)
|
||
except Exception as e2:
|
||
print(f"❌ Alternative import also failed: {e2}", file=sys.stderr)
|
||
sys.stderr.flush()
|
||
|
||
|
||
class AdvancedCoverGenerator:
|
||
"""高级封面生成器"""
|
||
|
||
def __init__(self, config: Dict[str, Any]):
|
||
"""
|
||
初始化生成器
|
||
|
||
Args:
|
||
config: 配置字典
|
||
"""
|
||
self.config = config
|
||
self.template = config
|
||
self.output_dir = os.path.dirname(config.get('output', './output/cover.png'))
|
||
self.output_path = config.get('output', './output/cover.png')
|
||
|
||
# 创建输出目录
|
||
os.makedirs(self.output_dir, exist_ok=True)
|
||
|
||
# 步骤预览图列表
|
||
self.preview_images = []
|
||
|
||
# 初始化人像分割器(支持MODNet优先级,与参考项目一致)
|
||
self.segmenter = None
|
||
self.current_model = "none"
|
||
if SEGMENT_AVAILABLE:
|
||
self._init_segmenter()
|
||
|
||
def _init_segmenter(self):
|
||
"""
|
||
初始化人像分割器,按优先级尝试加载模型
|
||
|
||
模型优先级(修复:优先使用rembg模型,避免依赖MODNet本地模型):
|
||
1. u2net - 通用高质量模型(rembg默认,会自动下载)
|
||
2. u2net_human_seg - 人像专用模型
|
||
3. u2netp - 轻量版模型
|
||
4. silueta - 通用抠图模型
|
||
5. modnet - MODNet专业人像抠图(仅作为备选)
|
||
6. simple - 简化方法(后备)
|
||
"""
|
||
model_priority = [
|
||
"u2net", # 通用高质量(rembg默认,优先使用)
|
||
"u2net_human_seg", # 人像专用
|
||
"u2netp", # 轻量版
|
||
"silueta", # 备用
|
||
"modnet", # MODNet专业人像(仅作为备选,避免本地模型依赖)
|
||
"simple" # 简化方法
|
||
]
|
||
|
||
for model_name in model_priority:
|
||
try:
|
||
print(f"[COVER_GENERATOR] Trying to load segmentation model: {model_name}", file=sys.stderr)
|
||
self.segmenter = PersonSegmenter(model_name=model_name)
|
||
print(f"[COVER_GENERATOR] Successfully loaded segmentation model: {model_name}", file=sys.stderr)
|
||
self.current_model = model_name
|
||
return
|
||
except Exception as e:
|
||
print(f"[COVER_GENERATOR] Failed to load model {model_name}: {e}", file=sys.stderr)
|
||
import traceback
|
||
traceback.print_exc(file=sys.stderr)
|
||
continue
|
||
|
||
# 如果所有模型都失败
|
||
print("[COVER_GENERATOR] Warning: All segmentation models failed", file=sys.stderr)
|
||
self.segmenter = None
|
||
self.current_model = "none"
|
||
|
||
def _segment_person_with_modnet(self, pil_image: Image.Image) -> Image.Image:
|
||
"""
|
||
使用PersonSegmenter进行人像分割(支持MODNet优先级)
|
||
|
||
Args:
|
||
pil_image: PIL Image对象(RGB或RGBA)
|
||
|
||
Returns:
|
||
分割后的PIL Image(RGBA格式,背景完全透明)
|
||
"""
|
||
if self.segmenter is None:
|
||
raise RuntimeError("PersonSegmenter not initialized")
|
||
|
||
# 转换PIL Image到numpy数组(BGR格式,PersonSegmenter需要)
|
||
img_np = np.array(pil_image)
|
||
if img_np.shape[2] == 4: # RGBA
|
||
img_np = cv2.cvtColor(img_np, cv2.COLOR_RGBA2BGR)
|
||
elif img_np.shape[2] == 3: # RGB
|
||
img_np = cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR)
|
||
|
||
# 使用PersonSegmenter进行分割(获取mask,而不是预处理后的图像)
|
||
print(f"Using segmentation model: {self.current_model}", file=sys.stderr)
|
||
|
||
# 关键修复:使用 return_mask=True 获取分割mask,而不是预处理后的图像
|
||
# 这样可以确保背景被正确处理为透明
|
||
_, mask = self.segmenter.segment_person(img_np, return_mask=True)
|
||
|
||
# 确保mask是uint8格式
|
||
if mask.dtype != np.uint8:
|
||
if mask.max() <= 1.0:
|
||
mask = (mask * 255).astype(np.uint8)
|
||
else:
|
||
mask = mask.astype(np.uint8)
|
||
|
||
print(f"Segmentation mask: shape={mask.shape}, dtype={mask.dtype}, min={mask.min()}, max={mask.max()}", file=sys.stderr)
|
||
|
||
# 将mask应用到原始图像,创建带透明背景的图像
|
||
# 原始图像是RGB,需要转换为RGBA
|
||
img_rgb = cv2.cvtColor(img_np, cv2.COLOR_BGR2RGB)
|
||
|
||
# 创建RGBA图像:RGB来自原始图像,A来自mask
|
||
segmented_rgba = np.dstack([img_rgb, mask])
|
||
|
||
print(f"Result image: shape={segmented_rgba.shape}, dtype={segmented_rgba.dtype}", file=sys.stderr)
|
||
|
||
return Image.fromarray(segmented_rgba)
|
||
|
||
def refine_mask(self, mask: np.ndarray) -> np.ndarray:
|
||
"""
|
||
优化mask,提高人像分割精度,严格过滤背景物体
|
||
|
||
改进策略:
|
||
1. 高斯模糊平滑
|
||
2. OTSU 自适应阈值
|
||
3. 形态学操作(开运算去噪点,闭运算填充小洞)
|
||
4. 智能轮廓过滤(基于位置、形状、面积多重条件)
|
||
5. 边缘平滑
|
||
|
||
Args:
|
||
mask: 输入的 mask(numpy 数组)
|
||
|
||
Returns:
|
||
优化后的 mask
|
||
"""
|
||
try:
|
||
# 确保mask是uint8格式
|
||
if mask.dtype != np.uint8:
|
||
if mask.max() <= 1.0:
|
||
mask = (mask * 255).astype(np.uint8)
|
||
else:
|
||
mask = mask.astype(np.uint8)
|
||
|
||
h, w = mask.shape[:2]
|
||
print(f"Refining mask: shape={mask.shape}, dtype={mask.dtype}", file=sys.stderr)
|
||
|
||
# 1. 第一次高斯模糊(sigma=0.3)
|
||
mask_blurred = cv2.GaussianBlur(mask, (3, 3), 0.3)
|
||
|
||
# 2. 使用 OTSU 自适应阈值
|
||
_, mask_binary = cv2.threshold(mask_blurred, 0, 255,
|
||
cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
||
|
||
# 3. 形态学操作
|
||
# 开运算(去除噪点)- 使用小kernel保持细节
|
||
kernel_small = np.ones((3, 3), np.uint8)
|
||
mask_cleaned = cv2.morphologyEx(mask_binary, cv2.MORPH_OPEN,
|
||
kernel_small, iterations=1)
|
||
|
||
# 闭运算(填充小洞)- 使用中等kernel
|
||
kernel_medium = np.ones((5, 5), np.uint8)
|
||
mask_filled = cv2.morphologyEx(mask_cleaned, cv2.MORPH_CLOSE,
|
||
kernel_medium, iterations=1)
|
||
|
||
# 4. 智能轮廓过滤(严格过滤背景物体)
|
||
contours, _ = cv2.findContours(mask_filled, cv2.RETR_EXTERNAL,
|
||
cv2.CHAIN_APPROX_SIMPLE)
|
||
|
||
if len(contours) > 0:
|
||
# 找到最大轮廓(通常是人物主体)
|
||
largest_contour = max(contours, key=cv2.contourArea)
|
||
largest_area = cv2.contourArea(largest_contour)
|
||
|
||
# 计算最大轮廓的中心点和边界框
|
||
M = cv2.moments(largest_contour)
|
||
if M["m00"] != 0:
|
||
largest_cx = int(M["m10"] / M["m00"])
|
||
largest_cy = int(M["m01"] / M["m00"])
|
||
else:
|
||
largest_cx, largest_cy = w // 2, h // 2
|
||
|
||
largest_x, largest_y, largest_w, largest_h = cv2.boundingRect(largest_contour)
|
||
|
||
print(f"Largest contour: area={largest_area}, center=({largest_cx},{largest_cy}), bbox=({largest_x},{largest_y},{largest_w},{largest_h})", file=sys.stderr)
|
||
|
||
# 创建新的mask,只保留符合条件的轮廓
|
||
mask_refined = np.zeros_like(mask_filled)
|
||
cv2.fillPoly(mask_refined, [largest_contour], 255)
|
||
|
||
# 过滤其他轮廓(更严格的条件)
|
||
kept_contours = 1
|
||
for contour in contours:
|
||
if contour is largest_contour:
|
||
continue
|
||
|
||
area = cv2.contourArea(contour)
|
||
|
||
# 条件1: 面积必须大于最大轮廓的5%(提高阈值从1.5%到5%)
|
||
if area < largest_area * 0.05:
|
||
continue
|
||
|
||
# 计算轮廓的中心点和边界框
|
||
M = cv2.moments(contour)
|
||
if M["m00"] != 0:
|
||
cx = int(M["m10"] / M["m00"])
|
||
cy = int(M["m01"] / M["m00"])
|
||
else:
|
||
continue
|
||
|
||
x, y, cw, ch = cv2.boundingRect(contour)
|
||
|
||
# 条件2: 必须与最大轮廓在垂直方向上有重叠(人物的手臂、腿等)
|
||
vertical_overlap = not (y + ch < largest_y or y > largest_y + largest_h)
|
||
if not vertical_overlap:
|
||
print(f"Filtered contour: no vertical overlap, area={area}, pos=({x},{y})", file=sys.stderr)
|
||
continue
|
||
|
||
# 条件3: 水平距离不能太远(必须在最大轮廓宽度的1.5倍范围内)
|
||
horizontal_distance = min(abs(x - largest_x), abs((x + cw) - (largest_x + largest_w)))
|
||
max_horizontal_distance = largest_w * 1.5
|
||
if horizontal_distance > max_horizontal_distance:
|
||
print(f"Filtered contour: too far horizontally, distance={horizontal_distance}, area={area}", file=sys.stderr)
|
||
continue
|
||
|
||
# 条件4: 长宽比检查(避免保留细长的背景物体)
|
||
aspect_ratio = cw / ch if ch > 0 else 0
|
||
# 人体部位的长宽比通常在0.2到5之间
|
||
if aspect_ratio < 0.2 or aspect_ratio > 5:
|
||
print(f"Filtered contour: abnormal aspect ratio={aspect_ratio:.2f}, area={area}", file=sys.stderr)
|
||
continue
|
||
|
||
# 条件5: 位置检查(避免保留图像边缘的物体)
|
||
# 如果轮廓紧贴图像边缘且不是最大轮廓,很可能是背景物体
|
||
edge_margin = 10 # 边缘容差
|
||
is_at_edge = (x < edge_margin or y < edge_margin or
|
||
x + cw > w - edge_margin or y + ch > h - edge_margin)
|
||
if is_at_edge and area < largest_area * 0.3:
|
||
print(f"Filtered contour: at edge with small area={area}, pos=({x},{y})", file=sys.stderr)
|
||
continue
|
||
|
||
# 通过所有条件,保留此轮廓
|
||
cv2.fillPoly(mask_refined, [contour], 255)
|
||
kept_contours += 1
|
||
print(f"Kept contour: area={area}, center=({cx},{cy}), aspect_ratio={aspect_ratio:.2f}", file=sys.stderr)
|
||
|
||
print(f"Contour filtering: kept {kept_contours}/{len(contours)} contours", file=sys.stderr)
|
||
mask_filled = mask_refined
|
||
|
||
# 5. 第二次高斯模糊使边缘自然(sigma=0.2)
|
||
mask_smooth = cv2.GaussianBlur(mask_filled, (3, 3), 0.2)
|
||
|
||
# 最终二值化(使用OTSU自动计算阈值,而不是固定值100)
|
||
# 这样可以避免背景被误认为是人物
|
||
_, mask_final = cv2.threshold(mask_smooth, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
||
|
||
print(f"Mask refinement completed. Final mask: non-zero pixels={np.count_nonzero(mask_final)}", file=sys.stderr)
|
||
return mask_final
|
||
|
||
except Exception as e:
|
||
print(f"Mask refinement failed: {e}", file=sys.stderr)
|
||
import traceback
|
||
traceback.print_exc(file=sys.stderr)
|
||
return mask
|
||
|
||
def hex_to_rgb(self, hex_color: str) -> Tuple[int, int, int]:
|
||
"""
|
||
将十六进制颜色转换为RGB元组
|
||
|
||
Args:
|
||
hex_color: 十六进制颜色字符串(如 "#FFFFFF")
|
||
|
||
Returns:
|
||
RGB元组
|
||
"""
|
||
hex_color = hex_color.lstrip('#')
|
||
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
|
||
|
||
def load_image(self, image_path: str) -> Image.Image:
|
||
"""
|
||
加载图像
|
||
|
||
Args:
|
||
image_path: 图像路径
|
||
|
||
Returns:
|
||
PIL图像对象
|
||
"""
|
||
print(f"Loading image: {image_path}", file=sys.stderr)
|
||
|
||
if image_path.lower().endswith(('.jpg', '.jpeg', '.png', '.bmp', '.webp')):
|
||
# 直接加载图像
|
||
return Image.open(image_path).convert('RGB')
|
||
else:
|
||
# 从视频提取帧
|
||
cap = cv2.VideoCapture(image_path)
|
||
ret, frame = cap.read()
|
||
cap.release()
|
||
|
||
if not ret:
|
||
raise Exception("Failed to extract frame from video")
|
||
|
||
# 转换BGR到RGB
|
||
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||
return Image.fromarray(frame_rgb)
|
||
|
||
def save_preview(self, image: Image.Image, step_name: str) -> str:
|
||
"""
|
||
保存步骤预览图
|
||
|
||
Args:
|
||
image: PIL图像对象
|
||
step_name: 步骤名称
|
||
|
||
Returns:
|
||
预览图路径
|
||
"""
|
||
preview_path = os.path.join(
|
||
self.output_dir,
|
||
f"preview_{len(self.preview_images) + 1}_{step_name}.png"
|
||
)
|
||
image.save(preview_path, quality=95)
|
||
self.preview_images.append({
|
||
'step': len(self.preview_images) + 1,
|
||
'name': step_name,
|
||
'path': preview_path
|
||
})
|
||
print(f"Preview saved: {step_name} -> {preview_path}", file=sys.stderr)
|
||
return preview_path
|
||
|
||
def apply_background_blur(self, image: Image.Image) -> Image.Image:
|
||
"""
|
||
步骤1: 应用背景模糊
|
||
|
||
Args:
|
||
image: 输入图像
|
||
|
||
Returns:
|
||
模糊后的图像
|
||
"""
|
||
blur_enabled = self.template.get('backgroundBlurEnabled', False)
|
||
|
||
if not blur_enabled:
|
||
print("Step 1: Background blur disabled, skipping", file=sys.stderr)
|
||
return image
|
||
|
||
blur_intensity = self.template.get('backgroundBlurIntensity', 10)
|
||
print(f"Step 1: Applying background blur (intensity: {blur_intensity})", file=sys.stderr)
|
||
|
||
# 应用高斯模糊
|
||
blurred = image.filter(ImageFilter.GaussianBlur(radius=blur_intensity))
|
||
|
||
# 保存预览
|
||
self.save_preview(blurred, "background_blur")
|
||
|
||
return blurred
|
||
|
||
def extract_and_composite_person(self, background: Image.Image) -> Image.Image:
|
||
"""
|
||
步骤2: 人物抠图和合成
|
||
|
||
Args:
|
||
background: 背景图像
|
||
|
||
Returns:
|
||
合成后的图像
|
||
"""
|
||
person_border_enabled = self.template.get('personBorderEnabled', False)
|
||
|
||
if not person_border_enabled or not SEGMENT_AVAILABLE:
|
||
print("Step 2: Person extraction disabled or PersonSegmenter not available, skipping", file=sys.stderr)
|
||
return background
|
||
|
||
print("Step 2: Extracting person from original image", file=sys.stderr)
|
||
|
||
# 从原始图像抠图(不是从模糊的背景)
|
||
original_image = self.load_image(self.config.get('video', ''))
|
||
|
||
# 调整大小以匹配背景
|
||
if original_image.size != background.size:
|
||
original_image = original_image.resize(background.size, Image.Resampling.LANCZOS)
|
||
|
||
# 使用PersonSegmenter进行抠图(支持MODNet优先级)
|
||
try:
|
||
person_rgba = self._segment_person_with_modnet(original_image)
|
||
print("Person extraction completed with PersonSegmenter", file=sys.stderr)
|
||
except Exception as e:
|
||
print(f"PersonSegmenter extraction failed: {e}", file=sys.stderr)
|
||
import traceback
|
||
traceback.print_exc(file=sys.stderr)
|
||
return background
|
||
|
||
# 确保是 RGBA 模式
|
||
if person_rgba.mode != 'RGBA':
|
||
person_rgba = person_rgba.convert('RGBA')
|
||
|
||
# 提取 alpha 通道并优化
|
||
alpha_channel = np.array(person_rgba.split()[3])
|
||
refined_alpha = self.refine_mask(alpha_channel)
|
||
|
||
# 将优化后的 alpha 通道应用回图像
|
||
person_rgba.putalpha(Image.fromarray(refined_alpha))
|
||
|
||
print("Mask refinement applied to extracted person", file=sys.stderr)
|
||
|
||
# 保存抠图预览(带透明背景)
|
||
self.save_preview(person_rgba, "person_extracted")
|
||
|
||
# 调整人物大小
|
||
person_size = self.template.get('personSize', 100)
|
||
person_rotation = self.template.get('personRotation', 0)
|
||
if person_size != 100:
|
||
print(f"Step 2: Resizing person to {person_size}%", file=sys.stderr)
|
||
new_width = int(person_rgba.width * person_size / 100)
|
||
new_height = int(person_rgba.height * person_size / 100)
|
||
person_rgba = person_rgba.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||
|
||
# 调整人物位置
|
||
person_position = self.template.get('personPosition', {'x': 50, 'y': 50})
|
||
|
||
# ✅ 新增:人像旋转
|
||
if person_rotation != 0:
|
||
print(f"Rotating person by {person_rotation}°", file=sys.stderr)
|
||
# 旋转人像(使用expand=True以保留完整旋转结果)
|
||
person_rgba = person_rgba.rotate(-person_rotation, resample=Image.BICUBIC, expand=True, fillcolor=(0, 0, 0, 0))
|
||
print(f"Person rotated, new size: {person_rgba.size}", file=sys.stderr)
|
||
|
||
person_x = int(background.width * person_position['x'] / 100) - person_rgba.width // 2
|
||
person_y = int(background.height * person_position['y'] / 100) - person_rgba.height // 2
|
||
|
||
# 合成到背景(保持 RGBA 模式以保留透明度)
|
||
result = background.convert('RGBA')
|
||
result.paste(person_rgba, (person_x, person_y), person_rgba)
|
||
|
||
# 保存预览(保持RGBA模式)
|
||
self.save_preview(result, "person_composited")
|
||
|
||
return result
|
||
|
||
def apply_person_outline(self, image: Image.Image) -> Image.Image:
|
||
"""
|
||
步骤3: 应用人物描边
|
||
|
||
注意:此方法已废弃,描边逻辑已移至 _apply_outline_to_person
|
||
保留此方法仅为兼容性
|
||
|
||
Args:
|
||
image: 输入图像
|
||
|
||
Returns:
|
||
添加描边后的图像
|
||
"""
|
||
person_border_enabled = self.template.get('personBorderEnabled', False)
|
||
|
||
if not person_border_enabled or not SEGMENT_AVAILABLE:
|
||
print("Step 3: Person outline disabled or PersonSegmenter not available, skipping", file=sys.stderr)
|
||
return image
|
||
|
||
print("Step 3: Applying person outline (legacy method)", file=sys.stderr)
|
||
print("Warning: This method is deprecated, use the new generate() flow instead", file=sys.stderr)
|
||
|
||
# 从原始图像抠图(使用PersonSegmenter)
|
||
original_image = self.load_image(self.config.get('video', ''))
|
||
if original_image.size != image.size:
|
||
original_image = original_image.resize(image.size, Image.Resampling.LANCZOS)
|
||
|
||
# 使用PersonSegmenter进行抠图
|
||
try:
|
||
person_rgba = self._segment_person_with_modnet(original_image)
|
||
except Exception as e:
|
||
print(f"PersonSegmenter extraction failed: {e}", file=sys.stderr)
|
||
import traceback
|
||
traceback.print_exc(file=sys.stderr)
|
||
return image
|
||
|
||
# 确保是 RGBA 模式
|
||
if person_rgba.mode != 'RGBA':
|
||
person_rgba = person_rgba.convert('RGBA')
|
||
|
||
# 提取 alpha 通道并优化
|
||
alpha_channel = np.array(person_rgba.split()[3])
|
||
refined_alpha = self.refine_mask(alpha_channel)
|
||
person_rgba.putalpha(Image.fromarray(refined_alpha))
|
||
|
||
# 应用描边
|
||
person_rgba = self._apply_outline_to_person(person_rgba)
|
||
|
||
# 调整人物大小
|
||
person_size = self.template.get('personSize', 100)
|
||
person_rotation = self.template.get('personRotation', 0)
|
||
if person_size != 100:
|
||
new_width = int(person_rgba.width * person_size / 100)
|
||
new_height = int(person_rgba.height * person_size / 100)
|
||
person_rgba = person_rgba.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||
|
||
# 调整大小和位置
|
||
person_position = self.template.get('personPosition', {'x': 50, 'y': 50})
|
||
person_x = int(image.width * person_position['x'] / 100) - person_rgba.width // 2
|
||
person_y = int(image.height * person_position['y'] / 100) - person_rgba.height // 2
|
||
|
||
# 合成
|
||
result = image.convert('RGBA')
|
||
result.paste(person_rgba, (person_x, person_y), person_rgba)
|
||
|
||
# 保存预览(保持RGBA模式)
|
||
self.save_preview(result, "person_outlined")
|
||
|
||
return result
|
||
|
||
def apply_mask(self, image: Image.Image) -> Image.Image:
|
||
"""
|
||
步骤4: 应用蒙版
|
||
|
||
Args:
|
||
image: 输入图像
|
||
|
||
Returns:
|
||
添加蒙版后的图像
|
||
"""
|
||
mask_enabled = self.template.get('maskEnabled', False)
|
||
|
||
if not mask_enabled:
|
||
print("Step 4: Mask disabled, skipping", file=sys.stderr)
|
||
return image
|
||
|
||
mask_path = self.template.get('maskImagePath', '')
|
||
if not mask_path or not os.path.exists(mask_path):
|
||
print(f"Step 4: Mask image not found: {mask_path}, skipping", file=sys.stderr)
|
||
return image
|
||
|
||
print(f"Step 4: Applying mask from {mask_path}", file=sys.stderr)
|
||
|
||
# 加载蒙版
|
||
mask_img = Image.open(mask_path).convert('RGBA')
|
||
|
||
# 获取蒙版参数
|
||
mask_size = self.template.get('maskSize', 100)
|
||
mask_position = self.template.get('maskPosition', {'x': 50, 'y': 50})
|
||
mask_opacity = self.template.get('maskOpacity', 100)
|
||
|
||
# 调整蒙版大小
|
||
mask_width = int(image.width * mask_size / 100)
|
||
mask_height = int(image.height * mask_size / 100)
|
||
mask_img = mask_img.resize((mask_width, mask_height), Image.Resampling.LANCZOS)
|
||
|
||
# 调整透明度
|
||
if mask_opacity < 100:
|
||
alpha = mask_img.split()[3]
|
||
alpha = ImageEnhance.Brightness(alpha).enhance(mask_opacity / 100)
|
||
mask_img.putalpha(alpha)
|
||
|
||
# 计算位置
|
||
mask_x = int(image.width * mask_position['x'] / 100) - mask_width // 2
|
||
mask_y = int(image.height * mask_position['y'] / 100) - mask_height // 2
|
||
|
||
# 合成
|
||
result = image.convert('RGBA')
|
||
result.paste(mask_img, (mask_x, mask_y), mask_img)
|
||
|
||
# 保存预览(保持RGBA模式)
|
||
self.save_preview(result, "mask_applied")
|
||
|
||
return result
|
||
|
||
def add_text(self, image: Image.Image) -> Image.Image:
|
||
"""
|
||
步骤5: 添加文字(主标题和副标题)
|
||
|
||
Args:
|
||
image: 输入图像
|
||
|
||
Returns:
|
||
添加文字后的图像
|
||
"""
|
||
title_text = self.config.get('title', self.template.get('titleText', ''))
|
||
subtitle_text = self.template.get('subtitleText', '')
|
||
|
||
if not title_text and not subtitle_text:
|
||
print("Step 5: No title or subtitle text, skipping", file=sys.stderr)
|
||
return image
|
||
|
||
print(f"Step 5: Adding text - Title: {title_text}, Subtitle: {subtitle_text}", file=sys.stderr)
|
||
|
||
# 转换为RGBA以支持透明度
|
||
result = image.convert('RGBA')
|
||
draw = ImageDraw.Draw(result)
|
||
|
||
width, height = result.size
|
||
print(f"[add_text] Image size: {width}x{height}", file=sys.stderr)
|
||
|
||
# 获取文字参数
|
||
font_family = self.template.get('titleFontFamily', 'NotoSerifCJK-VF')
|
||
font_size = self.template.get('titleFontSize', 120)
|
||
font_weight = self.template.get('titleFontWeight', 700)
|
||
text_color = self.hex_to_rgb(self.template.get('titleColor', '#FFFFFF'))
|
||
stroke_color = self.hex_to_rgb(self.template.get('titleStrokeColor', '#000000'))
|
||
stroke_width = self.template.get('titleStrokeWidth', 2)
|
||
|
||
# 修复:处理文本位置参数,确保是字典类型
|
||
text_position = self.template.get('titlePosition', {'x': 50, 'y': 80})
|
||
print(f"[add_text] Title position (percentage): x={text_position['x']}%, y={text_position['y']}%", file=sys.stderr)
|
||
|
||
# 如果是字符串(旧格式),转换为字典
|
||
if isinstance(text_position, str):
|
||
position_map = {
|
||
'top': {'x': 50, 'y': 20},
|
||
'center': {'x': 50, 'y': 50},
|
||
'bottom': {'x': 50, 'y': 80}
|
||
}
|
||
text_position = position_map.get(text_position, {'x': 50, 'y': 80})
|
||
print(f"Converted text position from string to dict: {text_position}", file=sys.stderr)
|
||
|
||
# 加载字体
|
||
font_path = self._get_font_path(font_family)
|
||
try:
|
||
font = ImageFont.truetype(font_path, font_size)
|
||
except:
|
||
print(f"Warning: Failed to load font {font_path}, using default", file=sys.stderr)
|
||
font = ImageFont.load_default()
|
||
|
||
# ✅ 关键修复:支持多行文字,使用anchor='mm'实现中心对齐
|
||
# 计算文字中心点位置(百分比转像素)
|
||
# 主标题不需要坐标反转
|
||
text_x_from_config = text_position['x']
|
||
text_y_from_config = text_position['y']
|
||
text_x = int(width * text_x_from_config / 100)
|
||
text_y = int(height * text_y_from_config / 100)
|
||
print(f"[add_text] Title initial position (pixels): x={text_x}px, y={text_y}px (from {text_x_from_config}%, {text_y_from_config}%)", file=sys.stderr)
|
||
|
||
if '\n' in title_text:
|
||
lines = title_text.split('\n')
|
||
print(f"Rendering multiline text: {len(lines)} lines, center=({text_x},{text_y}), text='{title_text}'", file=sys.stderr)
|
||
else:
|
||
print(f"Rendering single line text, center=({text_x},{text_y}), text='{title_text}'", file=sys.stderr)
|
||
|
||
# 绘制装饰图层1(原文字背景,现为独立装饰层)
|
||
if self.template.get('titleBackgroundEnabled', False):
|
||
bg_color = self.hex_to_rgb(self.template.get('titleBackgroundColor', '#000000'))
|
||
bg_opacity = self.template.get('titleBackgroundOpacity', 70)
|
||
bg_shape = self.template.get('titleBackgroundShape', 'rectangle')
|
||
bg_size = self.template.get('titleBackgroundSize', {'width': 30, 'height': 10})
|
||
bg_position = self.template.get('titleBackgroundPosition', {'x': 50, 'y': 30})
|
||
bg_radius = self.template.get('titleBackgroundRadius', 10)
|
||
bg_points = self.template.get('titleBackgroundPoints', [])
|
||
title_background_rotation = self.template.get('titleBackgroundRotation', 0)
|
||
|
||
bg_alpha = int(255 * bg_opacity / 100)
|
||
|
||
print(f"Title background: color={bg_color}, opacity={bg_opacity}%, alpha={bg_alpha}, shape={bg_shape}", file=sys.stderr)
|
||
# 🔧 调试:输出多边形点数据
|
||
print(f"[DEBUG] titleBackgroundPoints: {bg_points}, type: {type(bg_points)}, len: {len(bg_points) if isinstance(bg_points, (list, tuple)) else 'N/A'}", file=sys.stderr)
|
||
|
||
# 创建单独的图层来绘制背景(确保透明度正确)
|
||
bg_layer = Image.new('RGBA', result.size, (0, 0, 0, 0))
|
||
bg_draw = ImageDraw.Draw(bg_layer)
|
||
|
||
# 使用独立的位置和大小参数(百分比转像素)
|
||
bg_width = int(width * bg_size['width'] / 100)
|
||
bg_height = int(height * bg_size['height'] / 100)
|
||
bg_center_x = int(width * bg_position['x'] / 100)
|
||
bg_center_y = int(height * bg_position['y'] / 100)
|
||
|
||
bg_left = bg_center_x - bg_width // 2
|
||
bg_top = bg_center_y - bg_height // 2
|
||
bg_right = bg_center_x + bg_width // 2
|
||
bg_bottom = bg_center_y + bg_height // 2
|
||
|
||
# 🔧 调试:记录每个条件的结果
|
||
print(f"[DEBUG] Shape check: bg_shape='{bg_shape}' (type: {type(bg_shape).__name__})", file=sys.stderr)
|
||
print(f"[DEBUG] Points check: bg_points={bg_points}, type={type(bg_points).__name__}, len={len(bg_points) if isinstance(bg_points, list) else 'N/A'}, bool(bg_points)={bool(bg_points)}", file=sys.stderr)
|
||
|
||
# 🔧 详细输出:模板中的所有背景相关字段
|
||
print(f"[DEBUG] Template background fields:", file=sys.stderr)
|
||
print(f" titleBackgroundEnabled: {self.template.get('titleBackgroundEnabled')}", file=sys.stderr)
|
||
print(f" titleBackgroundShape: {self.template.get('titleBackgroundShape')}", file=sys.stderr)
|
||
print(f" titleBackgroundPoints raw: {self.template.get('titleBackgroundPoints')}", file=sys.stderr)
|
||
print(f"[DEBUG] Polygon condition: bg_shape=='polygon'={bg_shape == 'polygon'}, bg_points truthy={bool(bg_points)}", file=sys.stderr)
|
||
|
||
if bg_shape == 'rectangle':
|
||
# 矩形背景
|
||
print(f"[DEBUG] Drawing RECTANGLE background", file=sys.stderr)
|
||
bg_draw.rectangle(
|
||
[bg_left, bg_top, bg_right, bg_bottom],
|
||
fill=(*bg_color, bg_alpha)
|
||
)
|
||
elif bg_shape == 'rounded':
|
||
# 圆角矩形背景
|
||
print(f"[DEBUG] Drawing ROUNDED RECTANGLE background", file=sys.stderr)
|
||
bg_draw.rounded_rectangle(
|
||
[bg_left, bg_top, bg_right, bg_bottom],
|
||
radius=bg_radius,
|
||
fill=(*bg_color, bg_alpha)
|
||
)
|
||
elif bg_shape == 'polygon' and bg_points:
|
||
# 自定义多边形背景
|
||
print(f"[DEBUG] Drawing POLYGON background with {len(bg_points)} points", file=sys.stderr)
|
||
polygon_points = []
|
||
for i, point in enumerate(bg_points):
|
||
try:
|
||
px = bg_center_x + int(point['x'] * bg_width / 100)
|
||
py = bg_center_y + int(point['y'] * bg_height / 100)
|
||
polygon_points.append((px, py))
|
||
print(f"[DEBUG] Point {i}: {point} -> ({px}, {py})", file=sys.stderr)
|
||
except Exception as e:
|
||
print(f"[DEBUG] Error processing point {i}: {point}, error: {e}", file=sys.stderr)
|
||
|
||
if len(polygon_points) >= 3:
|
||
print(f"[DEBUG] Drawing polygon with {len(polygon_points)} points: {polygon_points}", file=sys.stderr)
|
||
bg_draw.polygon(polygon_points, fill=(*bg_color, bg_alpha))
|
||
else:
|
||
print(f"[DEBUG] Not enough points for polygon (need >= 3, got {len(polygon_points)}). Falling back to rectangle.", file=sys.stderr)
|
||
bg_draw.rectangle(
|
||
[bg_left, bg_top, bg_right, bg_bottom],
|
||
fill=(*bg_color, bg_alpha)
|
||
)
|
||
else:
|
||
# 默认使用矩形
|
||
print(f"[DEBUG] Drawing DEFAULT RECTANGLE (didn't match any shape type)", file=sys.stderr)
|
||
bg_draw.rectangle(
|
||
[bg_left, bg_top, bg_right, bg_bottom],
|
||
fill=(*bg_color, bg_alpha)
|
||
)
|
||
|
||
# ✅ 如果需要旋转,旋转背景图层
|
||
if title_background_rotation != 0:
|
||
print(f"Rotating title background by {title_background_rotation}°", file=sys.stderr)
|
||
bg_layer = bg_layer.rotate(-title_background_rotation, resample=Image.BICUBIC, expand=False, fillcolor=(0, 0, 0, 0))
|
||
|
||
# 使用alpha_composite合成背景层
|
||
result = Image.alpha_composite(result, bg_layer)
|
||
draw = ImageDraw.Draw(result) # 重新创建draw对象
|
||
|
||
# 绘制装饰图层2(副标题背景,现为独立装饰层)
|
||
if self.template.get('subtitleBackgroundEnabled', False):
|
||
bg_color = self.hex_to_rgb(self.template.get('subtitleBackgroundColor', '#000000'))
|
||
bg_opacity = self.template.get('subtitleBackgroundOpacity', 70)
|
||
bg_shape = self.template.get('subtitleBackgroundShape', 'rectangle')
|
||
bg_size = self.template.get('subtitleBackgroundSize', {'width': 30, 'height': 10})
|
||
bg_position = self.template.get('subtitleBackgroundPosition', {'x': 50, 'y': 60})
|
||
bg_radius = self.template.get('subtitleBackgroundRadius', 10)
|
||
bg_points = self.template.get('subtitleBackgroundPoints', [])
|
||
subtitle_background_rotation = self.template.get('subtitleBackgroundRotation', 0)
|
||
|
||
bg_alpha = int(255 * bg_opacity / 100)
|
||
|
||
print(f"Subtitle background: color={bg_color}, opacity={bg_opacity}%, alpha={bg_alpha}, shape={bg_shape}", file=sys.stderr)
|
||
# 🔧 调试:输出副标题多边形点数据
|
||
print(f"[DEBUG] subtitleBackgroundPoints: {bg_points}, type: {type(bg_points)}, len: {len(bg_points) if isinstance(bg_points, (list, tuple)) else 'N/A'}", file=sys.stderr)
|
||
|
||
# 创建单独的图层来绘制背景(确保透明度正确)
|
||
bg_layer = Image.new('RGBA', result.size, (0, 0, 0, 0))
|
||
bg_draw = ImageDraw.Draw(bg_layer)
|
||
|
||
# 使用独立的位置和大小参数(百分比转像素)
|
||
bg_width = int(width * bg_size['width'] / 100)
|
||
bg_height = int(height * bg_size['height'] / 100)
|
||
bg_center_x = int(width * bg_position['x'] / 100)
|
||
bg_center_y = int(height * bg_position['y'] / 100)
|
||
|
||
bg_left = bg_center_x - bg_width // 2
|
||
bg_top = bg_center_y - bg_height // 2
|
||
bg_right = bg_center_x + bg_width // 2
|
||
bg_bottom = bg_center_y + bg_height // 2
|
||
|
||
# 🔧 调试:记录每个条件的结果
|
||
print(f"[DEBUG-SUB] Shape check: bg_shape='{bg_shape}' (type: {type(bg_shape).__name__})", file=sys.stderr)
|
||
print(f"[DEBUG-SUB] Points check: bg_points={bg_points}, bool(bg_points)={bool(bg_points)}", file=sys.stderr)
|
||
print(f"[DEBUG-SUB] Polygon condition: bg_shape=='polygon'={bg_shape == 'polygon'}, bg_points truthy={bool(bg_points)}", file=sys.stderr)
|
||
|
||
if bg_shape == 'rectangle':
|
||
# 矩形背景
|
||
print(f"[DEBUG-SUB] Drawing RECTANGLE background", file=sys.stderr)
|
||
bg_draw.rectangle(
|
||
[bg_left, bg_top, bg_right, bg_bottom],
|
||
fill=(*bg_color, bg_alpha)
|
||
)
|
||
elif bg_shape == 'rounded':
|
||
# 圆角矩形背景
|
||
print(f"[DEBUG-SUB] Drawing ROUNDED RECTANGLE background", file=sys.stderr)
|
||
bg_draw.rounded_rectangle(
|
||
[bg_left, bg_top, bg_right, bg_bottom],
|
||
radius=bg_radius,
|
||
fill=(*bg_color, bg_alpha)
|
||
)
|
||
elif bg_shape == 'polygon' and bg_points:
|
||
# 自定义多边形背景
|
||
print(f"[DEBUG-SUB] Drawing POLYGON background with {len(bg_points)} points", file=sys.stderr)
|
||
polygon_points = []
|
||
for i, point in enumerate(bg_points):
|
||
try:
|
||
px = bg_center_x + int(point['x'] * bg_width / 100)
|
||
py = bg_center_y + int(point['y'] * bg_height / 100)
|
||
polygon_points.append((px, py))
|
||
print(f"[DEBUG-SUB] Point {i}: {point} -> ({px}, {py})", file=sys.stderr)
|
||
except Exception as e:
|
||
print(f"[DEBUG-SUB] Error processing point {i}: {point}, error: {e}", file=sys.stderr)
|
||
|
||
if len(polygon_points) >= 3:
|
||
print(f"[DEBUG-SUB] Drawing polygon with {len(polygon_points)} points: {polygon_points}", file=sys.stderr)
|
||
bg_draw.polygon(polygon_points, fill=(*bg_color, bg_alpha))
|
||
else:
|
||
print(f"[DEBUG-SUB] Not enough points for polygon (need >= 3, got {len(polygon_points)}). Falling back to rectangle.", file=sys.stderr)
|
||
bg_draw.rectangle(
|
||
[bg_left, bg_top, bg_right, bg_bottom],
|
||
fill=(*bg_color, bg_alpha)
|
||
)
|
||
else:
|
||
# 默认使用矩形
|
||
print(f"[DEBUG-SUB] Drawing DEFAULT RECTANGLE (didn't match any shape type)", file=sys.stderr)
|
||
bg_draw.rectangle(
|
||
[bg_left, bg_top, bg_right, bg_bottom],
|
||
fill=(*bg_color, bg_alpha)
|
||
)
|
||
|
||
# ✅ 如果需要旋转,旋转背景图层
|
||
if subtitle_background_rotation != 0:
|
||
print(f"Rotating subtitle background by {subtitle_background_rotation}°", file=sys.stderr)
|
||
bg_layer = bg_layer.rotate(-subtitle_background_rotation, resample=Image.BICUBIC, expand=False, fillcolor=(0, 0, 0, 0))
|
||
|
||
# 使用alpha_composite合成背景层
|
||
result = Image.alpha_composite(result, bg_layer)
|
||
draw = ImageDraw.Draw(result) # 重新创建draw对象
|
||
|
||
# ✅ 修复:直接在原始图层上绘制,不使用临时图层
|
||
# 这样可以确保坐标计算和前端Canvas完全一致
|
||
print(f"Drawing title text directly on original layer (no temp layer)", file=sys.stderr)
|
||
title_draw = draw
|
||
|
||
# 绘制标题文字(带描边),使用anchor='mm'实现中心对齐
|
||
# ✅ 新增:检查是否使用titles参数(支持direction、charSpacing、lineSpacing、maxLength)
|
||
titles_config = self.template.get('titles')
|
||
|
||
# 📋 调试日志:显示 titles_config 的完整信息
|
||
print(f"[DEBUG-TITLES] titles_config exists: {titles_config is not None}", file=sys.stderr)
|
||
if titles_config and 'main' in titles_config:
|
||
print(f"[DEBUG-TITLES] titles.main exists", file=sys.stderr)
|
||
print(f"[DEBUG-TITLES] titles.main keys: {list(titles_config['main'].keys())}", file=sys.stderr)
|
||
print(f"[DEBUG-TITLES] titles.main.maxLength: {titles_config['main'].get('maxLength')}", file=sys.stderr)
|
||
else:
|
||
print(f"[DEBUG-TITLES] titles.main does NOT exist, will use defaults or titleMaxCharsPerLine", file=sys.stderr)
|
||
print(f"[DEBUG-TITLES] template.titleMaxCharsPerLine: {self.template.get('titleMaxCharsPerLine')}", file=sys.stderr)
|
||
|
||
# ✅ 修复:总是使用图层渲染方式(支持旋转),即使没有titles配置
|
||
# 从顶级模板读取direction等参数,确保走新逻辑分支
|
||
if not titles_config or 'main' not in titles_config:
|
||
# 创建默认的titles配置,从顶级模板读取参数
|
||
titles_config = {
|
||
'main': {
|
||
'direction': self.template.get('titleDirection', 'horizontal'),
|
||
'charSpacing': self.template.get('titleCharSpacing'),
|
||
'lineSpacing': self.template.get('titleLineSpacing'),
|
||
'maxLength': self.template.get('titleMaxCharsPerLine', 10),
|
||
'rotation': self.template.get('titleRotation', 0),
|
||
'backgroundRotation': self.template.get('titleBackgroundRotation', 0),
|
||
}
|
||
}
|
||
|
||
if titles_config and 'main' in titles_config:
|
||
# 使用titles参数配置绘制主标题(支持横竖排、字符间距、行间距、自动折行)
|
||
print(f"[advanced_cover_generator] Using titles.main config for title rendering", file=sys.stderr)
|
||
main_config = titles_config['main']
|
||
|
||
# ✅ 修复:优先从 titles.main 读取字体大小,确保与前端一致
|
||
font_size_from_config = main_config.get('fontSize', font_size)
|
||
if font_size_from_config != font_size:
|
||
print(f" [INFO] Using fontSize from titles.main: {font_size_from_config} (was {font_size})", file=sys.stderr)
|
||
font_size = font_size_from_config
|
||
# 重新加载字体
|
||
try:
|
||
font = ImageFont.truetype(font_path, font_size)
|
||
except:
|
||
pass
|
||
|
||
# 获取排版参数 - 优先使用 titles.main 中的值,否则使用顶级配置,最后使用默认值
|
||
direction = main_config.get('direction', self.template.get('titleDirection', 'horizontal'))
|
||
|
||
# ✅ 修复:优先使用 titles.main 中的间距值,然后是顶级配置,最后是默认值
|
||
char_spacing = main_config.get('charSpacing')
|
||
if char_spacing is None:
|
||
char_spacing = self.template.get('titleCharSpacing')
|
||
if char_spacing is None:
|
||
char_spacing = int(font_size * 0.2)
|
||
|
||
line_spacing = main_config.get('lineSpacing')
|
||
if line_spacing is None:
|
||
line_spacing = self.template.get('titleLineSpacing')
|
||
if line_spacing is None:
|
||
line_spacing = int(font_size * 1.2)
|
||
|
||
max_chars_per_line = main_config.get('maxLength', self.template.get('titleMaxCharsPerLine', 10))
|
||
|
||
# 📋 调试日志:显示 maxLength 的实际值和来源
|
||
print(f"[DEBUG-MAXLENGTH] main_config.get('maxLength'): {main_config.get('maxLength')}", file=sys.stderr)
|
||
print(f"[DEBUG-MAXLENGTH] self.template.get('titleMaxCharsPerLine'): {self.template.get('titleMaxCharsPerLine')}", file=sys.stderr)
|
||
print(f"[DEBUG-MAXLENGTH] Final max_chars_per_line: {max_chars_per_line}", file=sys.stderr)
|
||
|
||
# ✅ 新增:获取阴影参数
|
||
shadow_enabled = main_config.get('shadowEnabled', self.template.get('titleShadowEnabled', False))
|
||
shadow_color = self.hex_to_rgb(main_config.get('shadowColor', self.template.get('titleShadowColor', '#000000')))
|
||
shadow_offset_x = main_config.get('shadowOffsetX', self.template.get('titleShadowOffsetX', 0))
|
||
shadow_offset_y = main_config.get('shadowOffsetY', self.template.get('titleShadowOffsetY', 0))
|
||
shadow_blur = main_config.get('shadowBlur', self.template.get('titleShadowBlur', 0))
|
||
|
||
# ✅ 新增:获取多层阴影参数
|
||
shadow_layers = main_config.get('shadowLayers', self.template.get('titleShadowLayers', []))
|
||
print(f" 📦 Shadow layers: {len(shadow_layers)} layer(s)", file=sys.stderr)
|
||
if shadow_layers:
|
||
for i, layer in enumerate(shadow_layers):
|
||
if layer.get('enabled', True):
|
||
print(f" Layer {i}: offset=({layer.get('offsetX', 0)}, {layer.get('offsetY', 0)}), blur={layer.get('blur', 0)}, color={layer.get('color', '#000000')}, opacity={layer.get('opacity', 100)}", file=sys.stderr)
|
||
rotation_angle = main_config.get('rotation', self.template.get('titleRotation', 0))
|
||
title_background_rotation = main_config.get('backgroundRotation', self.template.get('titleBackgroundRotation', 0))
|
||
|
||
# ✅ 修复旋转坐标问题:根据旋转角度使用不同的变换
|
||
# 将角度归一化到0-360范围
|
||
normalized_angle = rotation_angle % 360
|
||
|
||
if 45 <= normalized_angle < 135:
|
||
# ~90度旋转:X=Y, Y=100-X
|
||
text_x = int(width * text_y_from_config / 100)
|
||
text_y = int(height * (100 - text_x_from_config) / 100)
|
||
print(f"[ROTATION 90°] Main title: ({text_x_from_config}%, {text_y_from_config}%) → ({text_y_from_config}%, {100-text_x_from_config}%) → ({text_x}px, {text_y}px)", file=sys.stderr)
|
||
elif 135 <= normalized_angle < 225:
|
||
# ~180度旋转:X=100-X, Y=100-Y
|
||
text_x = int(width * (100 - text_x_from_config) / 100)
|
||
text_y = int(height * (100 - text_y_from_config) / 100)
|
||
print(f"[ROTATION 180°] Main title: ({text_x_from_config}%, {text_y_from_config}%) → ({100-text_x_from_config}%, {100-text_y_from_config}%) → ({text_x}px, {text_y}px)", file=sys.stderr)
|
||
elif 225 <= normalized_angle < 315:
|
||
# ~270度旋转:X=100-Y, Y=X
|
||
text_x = int(width * (100 - text_y_from_config) / 100)
|
||
text_y = int(height * text_x_from_config / 100)
|
||
print(f"[ROTATION 270°] Main title: ({text_x_from_config}%, {text_y_from_config}%) → ({100-text_y_from_config}%, {text_x_from_config}%) → ({text_x}px, {text_y}px)", file=sys.stderr)
|
||
else:
|
||
# ~0度或360度:不变换
|
||
text_x = int(width * text_x_from_config / 100)
|
||
text_y = int(height * text_y_from_config / 100)
|
||
print(f"[NO ROTATION] Main title: ({text_x_from_config}%, {text_y_from_config}%) → ({text_x}px, {text_y}px)", file=sys.stderr)
|
||
|
||
# ✅ 新增:计算文字实际高度并调整Y坐标,与前端getElementStyleWithSize保持一致
|
||
# 不限制范围,允许文字超出边界
|
||
try:
|
||
bbox = draw.textbbox((text_x, text_y), title_text, font=font, anchor='mm')
|
||
text_height = bbox[3] - bbox[1]
|
||
# 调整Y坐标:top = centerY - height/2(与前端一致)
|
||
text_y = text_y - text_height // 2
|
||
print(f"[TITLE HEIGHT ADJUST] text_height={text_height}, adjusted text_y={text_y}", file=sys.stderr)
|
||
except Exception as e:
|
||
print(f"[TITLE HEIGHT ADJUST] Warning: {e}", file=sys.stderr)
|
||
|
||
print(f"[DEBUG] Main title config:", file=sys.stderr)
|
||
print(f" Text: '{title_text}' (length: {len(title_text)})", file=sys.stderr)
|
||
print(f" maxLength: {max_chars_per_line}", file=sys.stderr)
|
||
print(f" Direction: {direction}", file=sys.stderr)
|
||
print(f" Char spacing: {char_spacing}px, Line spacing: {line_spacing}px", file=sys.stderr)
|
||
print(f" Position: ({text_x}, {text_y})", file=sys.stderr)
|
||
print(f" ⚠️ Shadow params: color={shadow_color}, offsetX={shadow_offset_x}, offsetY={shadow_offset_y}, blur={shadow_blur}", file=sys.stderr)
|
||
print(f" 🔄 Rotation angle: {rotation_angle}°", file=sys.stderr)
|
||
|
||
# 将文字分行
|
||
lines = []
|
||
for i in range(0, len(title_text), max_chars_per_line):
|
||
lines.append(title_text[i:i + max_chars_per_line])
|
||
|
||
print(f" Split into {len(lines)} lines: {lines}", file=sys.stderr)
|
||
|
||
if direction == 'vertical':
|
||
# ✅ 修复:与前端一致,竖排从左到右排列,每列从上到下
|
||
total_width = (len(lines) - 1) * line_spacing + font_size
|
||
max_col_chars = max(len(line) for line in lines) if lines else 1
|
||
total_height = (max_col_chars - 1) * (font_size + char_spacing) + font_size
|
||
|
||
# ✅ 修复:计算起始位置(整体居中)
|
||
# 前端:left = centerX - totalWidth/2,第一列colX=0
|
||
# 所以第一列中心 = centerX - totalWidth/2 + fontSize/2
|
||
start_x = text_x - total_width // 2
|
||
start_y = text_y - total_height // 2
|
||
print(f" [Vertical] total_size=({total_width},{total_height}), start=({start_x},{start_y})", file=sys.stderr)
|
||
|
||
# ✅ 计算padding:确保文字+阴影+模糊完全在图层内
|
||
# 基础padding:考虑负坐标和超出边界
|
||
base_padding = max(
|
||
abs(min(0, start_x)),
|
||
abs(min(0, start_y)),
|
||
max(0, start_x + total_width - width),
|
||
max(0, start_y + total_height - height),
|
||
shadow_blur * 3,
|
||
abs(shadow_offset_x),
|
||
abs(shadow_offset_y)
|
||
)
|
||
# 旋转padding:旋转会扩大边界框,预留足够空间
|
||
# 对角线长度作为旋转后的最大可能尺寸
|
||
diagonal = int(((total_width ** 2 + total_height ** 2) ** 0.5) / 2)
|
||
rotation_padding = diagonal if rotation_angle != 0 else 0
|
||
|
||
padding = max(base_padding, rotation_padding) + 100 # 额外安全边距
|
||
|
||
expanded_width = width + padding * 2
|
||
expanded_height = height + padding * 2
|
||
print(f" [Vertical Title] Using expanded layer: {width}x{height} → {expanded_width}x{expanded_height}, padding={padding} (base={base_padding}, rotation={rotation_padding})", file=sys.stderr)
|
||
|
||
# ✅ 步骤1: 处理多层阴影或单层阴影
|
||
has_shadow = shadow_enabled and (shadow_blur > 0 or shadow_offset_x != 0 or shadow_offset_y != 0)
|
||
has_multiple_shadows = len(shadow_layers) > 0 and any(layer.get('enabled', True) for layer in shadow_layers)
|
||
|
||
if has_multiple_shadows or has_shadow:
|
||
print(f" [Vertical Title] Creating shadow layer(s): {len([l for l in shadow_layers if l.get('enabled', True)])} multi-layer + single={has_shadow}", file=sys.stderr)
|
||
shadow_layer = Image.new('RGBA', (expanded_width, expanded_height), (0, 0, 0, 0))
|
||
|
||
# ✅ 首先绘制多层阴影(如果有)
|
||
if has_multiple_shadows:
|
||
for layer_idx, layer in enumerate(shadow_layers):
|
||
if layer.get('enabled', True):
|
||
layer_color = self.hex_to_rgb(layer.get('color', '#000000'))
|
||
layer_opacity = int(255 * (layer.get('opacity', 100) / 100))
|
||
layer_offset_x = layer.get('offsetX', 0)
|
||
layer_offset_y = layer.get('offsetY', 0)
|
||
layer_blur = layer.get('blur', 0)
|
||
|
||
shadow_draw = ImageDraw.Draw(shadow_layer)
|
||
|
||
# 为这一层绘制文字
|
||
for col_idx, line in enumerate(lines):
|
||
col_x = start_x + padding + col_idx * line_spacing
|
||
for char_idx, char in enumerate(line):
|
||
char_y = start_y + padding + char_idx * (font_size + char_spacing) + font_size // 2
|
||
char_x = col_x
|
||
shadow_x = char_x + layer_offset_x
|
||
shadow_y = char_y + layer_offset_y
|
||
shadow_draw.text((shadow_x, shadow_y), char, font=font, fill=(*layer_color, layer_opacity), anchor='mm')
|
||
|
||
# 对这一层应用模糊
|
||
if layer_blur > 0:
|
||
print(f" [Vertical Title] Applying blur={layer_blur} to shadow layer {layer_idx}", file=sys.stderr)
|
||
shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=layer_blur))
|
||
|
||
print(f" [Vertical Title] Shadow layer {layer_idx} composited", file=sys.stderr)
|
||
|
||
# ✅ 然后绘制传统单层阴影(如果有)
|
||
if has_shadow:
|
||
shadow_draw = ImageDraw.Draw(shadow_layer)
|
||
for col_idx, line in enumerate(lines):
|
||
col_x = start_x + padding + col_idx * line_spacing
|
||
for char_idx, char in enumerate(line):
|
||
char_y = start_y + padding + char_idx * (font_size + char_spacing) + font_size // 2
|
||
char_x = col_x
|
||
shadow_x = char_x + shadow_offset_x
|
||
shadow_y = char_y + shadow_offset_y
|
||
shadow_draw.text((shadow_x, shadow_y), char, font=font, fill=(*shadow_color, 160), anchor='mm')
|
||
|
||
# 应用真正的高斯模糊(如果blur > 0)
|
||
if shadow_blur > 0:
|
||
print(f" [Vertical Title] Applying GaussianBlur with radius={shadow_blur}", file=sys.stderr)
|
||
shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=shadow_blur))
|
||
|
||
# ✅ 旋转扩展图层并裁剪回原始尺寸
|
||
if rotation_angle != 0:
|
||
print(f" [Vertical Title] Rotating shadow layer by {rotation_angle}°", file=sys.stderr)
|
||
shadow_layer = shadow_layer.rotate(-rotation_angle, resample=Image.BICUBIC, expand=True, fillcolor=(0, 0, 0, 0))
|
||
crop_x = (shadow_layer.width - width) // 2
|
||
crop_y = (shadow_layer.height - height) // 2
|
||
shadow_layer = shadow_layer.crop((crop_x, crop_y, crop_x + width, crop_y + height))
|
||
result = Image.alpha_composite(result, shadow_layer)
|
||
else:
|
||
shadow_layer = shadow_layer.crop((padding, padding, padding + width, padding + height))
|
||
result = Image.alpha_composite(result, shadow_layer)
|
||
title_draw = ImageDraw.Draw(result) # 重新创建draw对象
|
||
print(f" [Vertical Title] Shadow layer composited", file=sys.stderr)
|
||
|
||
# ✅ 步骤2: 在扩展图层绘制描边和主文字
|
||
print(f" [Vertical Title] Creating text layer", file=sys.stderr)
|
||
text_layer = Image.new('RGBA', (expanded_width, expanded_height), (0, 0, 0, 0))
|
||
text_draw = ImageDraw.Draw(text_layer)
|
||
|
||
for col_idx, line in enumerate(lines):
|
||
col_x = start_x + padding + col_idx * line_spacing
|
||
|
||
for char_idx, char in enumerate(line):
|
||
char_y = start_y + padding + char_idx * (font_size + char_spacing) + font_size // 2
|
||
char_x = col_x
|
||
|
||
# 绘制描边
|
||
if stroke_width > 0:
|
||
for offset_x in range(-stroke_width, stroke_width + 1):
|
||
for offset_y in range(-stroke_width, stroke_width + 1):
|
||
if offset_x == 0 and offset_y == 0:
|
||
continue
|
||
text_draw.text((char_x + offset_x, char_y + offset_y),
|
||
char, font=font, fill=(*stroke_color, 255), anchor='mm')
|
||
|
||
# 绘制主体
|
||
text_draw.text((char_x, char_y), char, font=font, fill=(*text_color, 255), anchor='mm')
|
||
|
||
# ✅ 步骤3: 旋转扩展图层并裁剪回原始尺寸
|
||
if rotation_angle != 0:
|
||
print(f" [Vertical Title] Rotating text layer by {rotation_angle}°", file=sys.stderr)
|
||
text_layer = text_layer.rotate(-rotation_angle, resample=Image.BICUBIC, expand=True, fillcolor=(0, 0, 0, 0))
|
||
crop_x = (text_layer.width - width) // 2
|
||
crop_y = (text_layer.height - height) // 2
|
||
text_layer = text_layer.crop((crop_x, crop_y, crop_x + width, crop_y + height))
|
||
result = Image.alpha_composite(result, text_layer)
|
||
else:
|
||
text_layer = text_layer.crop((padding, padding, padding + width, padding + height))
|
||
result = Image.alpha_composite(result, text_layer)
|
||
title_draw = ImageDraw.Draw(result) # 重新创建draw对象
|
||
print(f" [Vertical Title] Text layer composited", file=sys.stderr)
|
||
else:
|
||
# 横排:从左到右,从上到下(默认)
|
||
# ✅ 修复:与前端一致,行间距 = font_size + line_spacing
|
||
total_height = (len(lines) - 1) * (font_size + line_spacing) + font_size
|
||
|
||
# ✅ 修复:计算最长行的宽度,所有行都基于此宽度左对齐(与前端一致)
|
||
max_line_chars = max(len(line) for line in lines) if lines else 1
|
||
total_width = (max_line_chars - 1) * (font_size + char_spacing) + font_size
|
||
|
||
# 计算整体起始位置(基于最长行居中,然后所有行左对齐)
|
||
start_x = text_x - total_width // 2
|
||
start_y = text_y - total_height // 2
|
||
print(f" [Horizontal] total_size=({total_width},{total_height}), start=({start_x},{start_y})", file=sys.stderr)
|
||
|
||
# ✅ 计算padding:确保文字+阴影+模糊完全在图层内
|
||
base_padding = max(
|
||
abs(min(0, start_x)),
|
||
abs(min(0, start_y)),
|
||
max(0, start_x + total_width - width),
|
||
max(0, start_y + total_height - height),
|
||
shadow_blur * 3,
|
||
abs(shadow_offset_x),
|
||
abs(shadow_offset_y)
|
||
)
|
||
diagonal = int(((total_width ** 2 + total_height ** 2) ** 0.5) / 2)
|
||
rotation_padding = diagonal if rotation_angle != 0 else 0
|
||
padding = max(base_padding, rotation_padding) + 100
|
||
|
||
expanded_width = width + padding * 2
|
||
expanded_height = height + padding * 2
|
||
print(f" [Horizontal Title] Using expanded layer: {width}x{height} → {expanded_width}x{expanded_height}, padding={padding} (base={base_padding}, rotation={rotation_padding})", file=sys.stderr)
|
||
|
||
# ✅ 步骤1: 处理多层阴影或单层阴影
|
||
has_shadow = shadow_enabled and (shadow_blur > 0 or shadow_offset_x != 0 or shadow_offset_y != 0)
|
||
has_multiple_shadows = len(shadow_layers) > 0 and any(layer.get('enabled', True) for layer in shadow_layers)
|
||
|
||
if has_multiple_shadows or has_shadow:
|
||
print(f" [Horizontal Title] Creating shadow layer(s): {len([l for l in shadow_layers if l.get('enabled', True)])} multi-layer + single={has_shadow}", file=sys.stderr)
|
||
shadow_layer = Image.new('RGBA', (expanded_width, expanded_height), (0, 0, 0, 0))
|
||
|
||
# ✅ 首先绘制多层阴影(如果有)
|
||
if has_multiple_shadows:
|
||
for layer_idx, layer in enumerate(shadow_layers):
|
||
if layer.get('enabled', True):
|
||
layer_color = self.hex_to_rgb(layer.get('color', '#000000'))
|
||
layer_opacity = int(255 * (layer.get('opacity', 100) / 100))
|
||
layer_offset_x = layer.get('offsetX', 0)
|
||
layer_offset_y = layer.get('offsetY', 0)
|
||
layer_blur = layer.get('blur', 0)
|
||
|
||
shadow_draw = ImageDraw.Draw(shadow_layer)
|
||
|
||
# 为这一层绘制文字
|
||
for row_idx, line in enumerate(lines):
|
||
line_y = start_y + padding + row_idx * (font_size + line_spacing) + font_size // 2
|
||
for char_idx, char in enumerate(line):
|
||
char_x = start_x + padding + char_idx * (font_size + char_spacing) + font_size // 2
|
||
char_y = line_y
|
||
shadow_x = char_x + layer_offset_x
|
||
shadow_y = char_y + layer_offset_y
|
||
shadow_draw.text((shadow_x, shadow_y), char, font=font, fill=(*layer_color, layer_opacity), anchor='mm')
|
||
|
||
# 对这一层应用模糊
|
||
if layer_blur > 0:
|
||
print(f" [Horizontal Title] Applying blur={layer_blur} to shadow layer {layer_idx}", file=sys.stderr)
|
||
shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=layer_blur))
|
||
|
||
print(f" [Horizontal Title] Shadow layer {layer_idx} composited", file=sys.stderr)
|
||
|
||
# ✅ 然后绘制传统单层阴影(如果有)
|
||
if has_shadow:
|
||
shadow_draw = ImageDraw.Draw(shadow_layer)
|
||
for row_idx, line in enumerate(lines):
|
||
line_y = start_y + padding + row_idx * (font_size + line_spacing) + font_size // 2
|
||
for char_idx, char in enumerate(line):
|
||
char_x = start_x + padding + char_idx * (font_size + char_spacing) + font_size // 2
|
||
char_y = line_y
|
||
shadow_x = char_x + shadow_offset_x
|
||
shadow_y = char_y + shadow_offset_y
|
||
shadow_draw.text((shadow_x, shadow_y), char, font=font, fill=(*shadow_color, 160), anchor='mm')
|
||
|
||
# 应用真正的高斯模糊(如果blur > 0)
|
||
if shadow_blur > 0:
|
||
print(f" [Horizontal Title] Applying GaussianBlur with radius={shadow_blur}", file=sys.stderr)
|
||
shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=shadow_blur))
|
||
|
||
# ✅ 旋转扩展图层并裁剪回原始尺寸
|
||
if rotation_angle != 0:
|
||
print(f" [Horizontal Title] Rotating shadow layer by {rotation_angle}°", file=sys.stderr)
|
||
shadow_layer = shadow_layer.rotate(-rotation_angle, resample=Image.BICUBIC, expand=True, fillcolor=(0, 0, 0, 0))
|
||
crop_x = (shadow_layer.width - width) // 2
|
||
crop_y = (shadow_layer.height - height) // 2
|
||
shadow_layer = shadow_layer.crop((crop_x, crop_y, crop_x + width, crop_y + height))
|
||
result = Image.alpha_composite(result, shadow_layer)
|
||
else:
|
||
shadow_layer = shadow_layer.crop((padding, padding, padding + width, padding + height))
|
||
result = Image.alpha_composite(result, shadow_layer)
|
||
title_draw = ImageDraw.Draw(result) # 重新创建draw对象
|
||
print(f" [Horizontal Title] Shadow layer composited", file=sys.stderr)
|
||
|
||
# ✅ 步骤2: 在扩展图层绘制描边和主文字
|
||
print(f" [Horizontal Title] Creating text layer", file=sys.stderr)
|
||
text_layer = Image.new('RGBA', (expanded_width, expanded_height), (0, 0, 0, 0))
|
||
text_draw = ImageDraw.Draw(text_layer)
|
||
|
||
for row_idx, line in enumerate(lines):
|
||
line_y = start_y + padding + row_idx * (font_size + line_spacing) + font_size // 2
|
||
|
||
for char_idx, char in enumerate(line):
|
||
char_x = start_x + padding + char_idx * (font_size + char_spacing) + font_size // 2
|
||
char_y = line_y
|
||
|
||
# 绘制描边
|
||
if stroke_width > 0:
|
||
for offset_x in range(-stroke_width, stroke_width + 1):
|
||
for offset_y in range(-stroke_width, stroke_width + 1):
|
||
if offset_x == 0 and offset_y == 0:
|
||
continue
|
||
text_draw.text((char_x + offset_x, char_y + offset_y),
|
||
char, font=font, fill=(*stroke_color, 255), anchor='mm')
|
||
|
||
# 绘制主体
|
||
text_draw.text((char_x, char_y), char, font=font, fill=(*text_color, 255), anchor='mm')
|
||
|
||
# ✅ 步骤3: 旋转扩展图层并裁剪回原始尺寸
|
||
if rotation_angle != 0:
|
||
print(f" [Horizontal Title] Rotating text layer by {rotation_angle}°", file=sys.stderr)
|
||
text_layer = text_layer.rotate(-rotation_angle, resample=Image.BICUBIC, expand=True, fillcolor=(0, 0, 0, 0))
|
||
crop_x = (text_layer.width - width) // 2
|
||
crop_y = (text_layer.height - height) // 2
|
||
text_layer = text_layer.crop((crop_x, crop_y, crop_x + width, crop_y + height))
|
||
result = Image.alpha_composite(result, text_layer)
|
||
else:
|
||
text_layer = text_layer.crop((padding, padding, padding + width, padding + height))
|
||
result = Image.alpha_composite(result, text_layer)
|
||
title_draw = ImageDraw.Draw(result) # 重新创建draw对象
|
||
print(f" [Horizontal Title] Text layer composited", file=sys.stderr)
|
||
elif '\n' in title_text:
|
||
# 多行文字渲染(原有逻辑)
|
||
if stroke_width > 0:
|
||
title_draw.multiline_text(
|
||
(text_x, text_y), # 直接使用text_x, text_y
|
||
title_text,
|
||
font=font,
|
||
fill=(*text_color, 255),
|
||
align='center',
|
||
anchor='mm',
|
||
stroke_width=stroke_width,
|
||
stroke_fill=(*stroke_color, 255)
|
||
)
|
||
else:
|
||
title_draw.multiline_text(
|
||
(text_x, text_y), # 直接使用text_x, text_y
|
||
title_text,
|
||
font=font,
|
||
fill=(*text_color, 255),
|
||
align='center',
|
||
anchor='mm'
|
||
)
|
||
else:
|
||
# 单行文字渲染(原有逻辑)
|
||
if stroke_width > 0:
|
||
title_draw.text(
|
||
(text_x, text_y), # 直接使用text_x, text_y
|
||
title_text,
|
||
font=font,
|
||
fill=(*text_color, 255),
|
||
anchor='mm',
|
||
stroke_width=stroke_width,
|
||
stroke_fill=(*stroke_color, 255)
|
||
)
|
||
else:
|
||
title_draw.text(
|
||
(text_x, text_y), # 直接使用text_x, text_y
|
||
title_text,
|
||
font=font,
|
||
fill=(*text_color, 255),
|
||
anchor='mm'
|
||
)
|
||
|
||
# 绘制副标题(如果有)
|
||
if subtitle_text:
|
||
print(f"Adding subtitle text: {subtitle_text}", file=sys.stderr)
|
||
|
||
# 获取副标题参数
|
||
subtitle_font_family = self.template.get('subtitleFontFamily', 'NotoSerifCJK-VF')
|
||
subtitle_font_size = self.template.get('subtitleFontSize', 60)
|
||
subtitle_font_weight = self.template.get('subtitleFontWeight', 500)
|
||
subtitle_text_color = self.hex_to_rgb(self.template.get('subtitleColor', '#FFFFFF'))
|
||
subtitle_stroke_color = self.hex_to_rgb(self.template.get('subtitleStrokeColor', '#000000'))
|
||
subtitle_stroke_width = self.template.get('subtitleStrokeWidth', 1)
|
||
|
||
# 修复:处理副标题位置参数
|
||
subtitle_position = self.template.get('subtitlePosition', {'x': 50, 'y': 90})
|
||
|
||
# 如果是字符串(旧格式),转换为字典
|
||
if isinstance(subtitle_position, str):
|
||
position_map = {
|
||
'top': {'x': 50, 'y': 30},
|
||
'center': {'x': 50, 'y': 50},
|
||
'bottom': {'x': 50, 'y': 90}
|
||
}
|
||
subtitle_position = position_map.get(subtitle_position, {'x': 50, 'y': 90})
|
||
print(f"Converted subtitle position from string to dict: {subtitle_position}", file=sys.stderr)
|
||
|
||
# 加载副标题字体
|
||
subtitle_font_path = self._get_font_path(subtitle_font_family)
|
||
try:
|
||
subtitle_font = ImageFont.truetype(subtitle_font_path, subtitle_font_size)
|
||
except:
|
||
print(f"Warning: Failed to load subtitle font {subtitle_font_path}, using default", file=sys.stderr)
|
||
subtitle_font = ImageFont.load_default()
|
||
|
||
# 计算副标题中心点位置(百分比转像素)
|
||
# ✅ 修复旋转坐标问题:有旋转时Y轴需要反转,无旋转时不需要
|
||
subtitle_x = int(width * subtitle_position['x'] / 100)
|
||
# 注意:这里还没有读取到rotation_angle,先使用正常计算,后面会根据rotation调整
|
||
subtitle_y_from_config = subtitle_position['y']
|
||
subtitle_y = int(height * subtitle_y_from_config / 100)
|
||
|
||
print(f"Rendering subtitle, initial center=({subtitle_x},{subtitle_y}), text='{subtitle_text}' (from {subtitle_position['x']}%, {subtitle_y_from_config}%)", file=sys.stderr)
|
||
|
||
# ✅ 直接在原图上绘制副标题(不使用临时图层)
|
||
print(f"Drawing subtitle directly on original image at ({subtitle_x},{subtitle_y})", file=sys.stderr)
|
||
|
||
# 直接在原图上绘制,不使用临时图层
|
||
subtitle_draw = draw
|
||
|
||
# 绘制副标题(使用anchor='mm'实现中心对齐)
|
||
# ✅ 新增:检查是否使用titles.sub参数(支持direction、charSpacing、lineSpacing、maxLength)
|
||
|
||
# ✅ 修复:总是使用图层渲染方式(支持旋转),即使没有titles配置
|
||
if not titles_config or 'sub' not in titles_config:
|
||
# 创建默认的titles配置,从顶级模板读取参数
|
||
if not titles_config:
|
||
titles_config = {}
|
||
titles_config['sub'] = {
|
||
'direction': self.template.get('subtitleDirection', 'horizontal'),
|
||
'charSpacing': self.template.get('subtitleCharSpacing'),
|
||
'lineSpacing': self.template.get('subtitleLineSpacing'),
|
||
'maxLength': self.template.get('subtitleMaxCharsPerLine', 15),
|
||
'rotation': self.template.get('subtitleRotation', 0),
|
||
'backgroundRotation': self.template.get('subtitleBackgroundRotation', 0),
|
||
}
|
||
|
||
if titles_config and 'sub' in titles_config:
|
||
# 使用titles.sub参数配置绘制副标题(支持横竖排、字符间距、行间距、自动折行)
|
||
print(f"[advanced_cover_generator] Using titles.sub config for subtitle rendering", file=sys.stderr)
|
||
sub_config = titles_config['sub']
|
||
|
||
# ✅ 修复:优先从 titles.sub 读取字体大小,确保与前端一致
|
||
subtitle_font_size_from_config = sub_config.get('fontSize', subtitle_font_size)
|
||
if subtitle_font_size_from_config != subtitle_font_size:
|
||
print(f" [INFO] Using fontSize from titles.sub: {subtitle_font_size_from_config} (was {subtitle_font_size})", file=sys.stderr)
|
||
subtitle_font_size = subtitle_font_size_from_config
|
||
# 重新加载字体
|
||
try:
|
||
subtitle_font = ImageFont.truetype(subtitle_font_path, subtitle_font_size)
|
||
except:
|
||
pass
|
||
|
||
# 获取排版参数 - 优先使用 titles.sub 中的值,否则使用顶级配置,最后使用默认值
|
||
direction = sub_config.get('direction', self.template.get('subtitleDirection', 'horizontal'))
|
||
|
||
# ✅ 修复:优先使用 titles.sub 中的间距值,然后是顶级配置,最后是默认值
|
||
char_spacing = sub_config.get('charSpacing')
|
||
if char_spacing is None:
|
||
char_spacing = self.template.get('subtitleCharSpacing')
|
||
if char_spacing is None:
|
||
char_spacing = int(subtitle_font_size * 0.2)
|
||
|
||
line_spacing = sub_config.get('lineSpacing')
|
||
if line_spacing is None:
|
||
line_spacing = self.template.get('subtitleLineSpacing')
|
||
if line_spacing is None:
|
||
line_spacing = int(subtitle_font_size * 1.2)
|
||
|
||
max_chars_per_line = sub_config.get('maxLength', self.template.get('subtitleMaxCharsPerLine', 15))
|
||
|
||
# ✅ 新增:获取副标题阴影参数
|
||
subtitle_shadow_enabled = sub_config.get('shadowEnabled', self.template.get('subtitleShadowEnabled', False))
|
||
subtitle_shadow_color = self.hex_to_rgb(sub_config.get('shadowColor', self.template.get('subtitleShadowColor', '#000000')))
|
||
subtitle_shadow_offset_x = sub_config.get('shadowOffsetX', self.template.get('subtitleShadowOffsetX', 0))
|
||
subtitle_shadow_offset_y = sub_config.get('shadowOffsetY', self.template.get('subtitleShadowOffsetY', 0))
|
||
subtitle_shadow_blur = sub_config.get('shadowBlur', self.template.get('subtitleShadowBlur', 0))
|
||
subtitle_rotation_angle = sub_config.get('rotation', self.template.get('subtitleRotation', 0))
|
||
subtitle_background_rotation = sub_config.get('backgroundRotation', self.template.get('subtitleBackgroundRotation', 0))
|
||
|
||
# ✅ 修复旋转坐标问题:根据旋转角度使用不同的变换(和主标题一致)
|
||
normalized_subtitle_angle = subtitle_rotation_angle % 360
|
||
|
||
if 45 <= normalized_subtitle_angle < 135:
|
||
# ~90度旋转
|
||
subtitle_x = int(width * subtitle_y_from_config / 100)
|
||
subtitle_y = int(height * (100 - subtitle_position['x']) / 100)
|
||
print(f"[ROTATION 90°] Subtitle: ({subtitle_position['x']}%, {subtitle_y_from_config}%) → ({subtitle_y_from_config}%, {100-subtitle_position['x']}%) → ({subtitle_x}px, {subtitle_y}px)", file=sys.stderr)
|
||
elif 135 <= normalized_subtitle_angle < 225:
|
||
# ~180度旋转
|
||
subtitle_x = int(width * (100 - subtitle_position['x']) / 100)
|
||
subtitle_y = int(height * (100 - subtitle_y_from_config) / 100)
|
||
print(f"[ROTATION 180°] Subtitle: ({subtitle_position['x']}%, {subtitle_y_from_config}%) → ({100-subtitle_position['x']}%, {100-subtitle_y_from_config}%) → ({subtitle_x}px, {subtitle_y}px)", file=sys.stderr)
|
||
elif 225 <= normalized_subtitle_angle < 315:
|
||
# ~270度旋转
|
||
subtitle_x = int(width * (100 - subtitle_y_from_config) / 100)
|
||
subtitle_y = int(height * subtitle_position['x'] / 100)
|
||
print(f"[ROTATION 270°] Subtitle: ({subtitle_position['x']}%, {subtitle_y_from_config}%) → ({100-subtitle_y_from_config}%, {subtitle_position['x']}%) → ({subtitle_x}px, {subtitle_y}px)", file=sys.stderr)
|
||
else:
|
||
# ~0度或360度
|
||
print(f"[NO ROTATION] Subtitle: ({subtitle_position['x']}%, {subtitle_y_from_config}%) → ({subtitle_x}px, {subtitle_y}px)", file=sys.stderr)
|
||
|
||
# ✅ 新增:计算副标题实际高度并调整Y坐标,与前端getElementStyleWithSize保持一致
|
||
# 不限制范围,允许文字超出边界
|
||
try:
|
||
subtitle_bbox = draw.textbbox((subtitle_x, subtitle_y), subtitle_text, font=subtitle_font, anchor='mm')
|
||
subtitle_height = subtitle_bbox[3] - subtitle_bbox[1]
|
||
# 调整Y坐标:top = centerY - height/2(与前端一致)
|
||
subtitle_y = subtitle_y - subtitle_height // 2
|
||
print(f"[SUBTITLE HEIGHT ADJUST] subtitle_height={subtitle_height}, adjusted subtitle_y={subtitle_y}", file=sys.stderr)
|
||
except Exception as e:
|
||
print(f"[SUBTITLE HEIGHT ADJUST] Warning: {e}", file=sys.stderr)
|
||
|
||
print(f"[DEBUG] Sub title config:", file=sys.stderr)
|
||
print(f" Text: '{subtitle_text}' (length: {len(subtitle_text)})", file=sys.stderr)
|
||
print(f" maxLength: {max_chars_per_line}", file=sys.stderr)
|
||
print(f" Direction: {direction}", file=sys.stderr)
|
||
print(f" Char spacing: {char_spacing}px, Line spacing: {line_spacing}px", file=sys.stderr)
|
||
print(f" Position: ({subtitle_x}, {subtitle_y})", file=sys.stderr)
|
||
|
||
# 将文字分行
|
||
lines = []
|
||
for i in range(0, len(subtitle_text), max_chars_per_line):
|
||
lines.append(subtitle_text[i:i + max_chars_per_line])
|
||
|
||
print(f" Split into {len(lines)} lines: {lines}", file=sys.stderr)
|
||
|
||
if direction == 'vertical':
|
||
# ✅ 修复:与前端一致,竖排从左到右排列,每列从上到下
|
||
total_width = (len(lines) - 1) * line_spacing + subtitle_font_size
|
||
max_col_chars = max(len(line) for line in lines) if lines else 1
|
||
total_height = (max_col_chars - 1) * (subtitle_font_size + char_spacing) + subtitle_font_size
|
||
|
||
# ✅ 修复:计算起始位置(整体居中),与前端一致
|
||
start_x = subtitle_x - total_width // 2
|
||
start_y = subtitle_y - total_height // 2
|
||
|
||
print(f" [Vertical] total_size=({total_width},{total_height}), start=({start_x},{start_y})", file=sys.stderr)
|
||
|
||
# ✅ 计算padding:确保文字+阴影+模糊完全在图层内
|
||
base_padding = max(
|
||
abs(min(0, start_x)),
|
||
abs(min(0, start_y)),
|
||
max(0, start_x + total_width - width),
|
||
max(0, start_y + total_height - height),
|
||
subtitle_shadow_blur * 3,
|
||
abs(subtitle_shadow_offset_x),
|
||
abs(subtitle_shadow_offset_y)
|
||
)
|
||
diagonal = int(((total_width ** 2 + total_height ** 2) ** 0.5) / 2)
|
||
rotation_padding = diagonal if subtitle_rotation_angle != 0 else 0
|
||
padding = max(base_padding, rotation_padding) + 100
|
||
|
||
expanded_width = width + padding * 2
|
||
expanded_height = height + padding * 2
|
||
print(f" [Vertical Subtitle] Using expanded layer: {width}x{height} → {expanded_width}x{expanded_height}, padding={padding} (base={base_padding}, rotation={rotation_padding})", file=sys.stderr)
|
||
|
||
# ✅ 步骤1: 处理多层阴影或单层阴影(与主标题逻辑一致)
|
||
subtitle_has_shadow = subtitle_shadow_enabled and (subtitle_shadow_blur > 0 or subtitle_shadow_offset_x != 0 or subtitle_shadow_offset_y != 0)
|
||
subtitle_shadow_layers = sub_config.get('shadowLayers', self.template.get('subtitleShadowLayers', []))
|
||
subtitle_has_multiple_shadows = len(subtitle_shadow_layers) > 0 and any(layer.get('enabled', True) for layer in subtitle_shadow_layers)
|
||
|
||
if subtitle_has_multiple_shadows or subtitle_has_shadow:
|
||
print(f" [Vertical Subtitle] Creating shadow layer(s): {len([l for l in subtitle_shadow_layers if l.get('enabled', True)])} multi-layer + single={subtitle_has_shadow}", file=sys.stderr)
|
||
shadow_layer = Image.new('RGBA', (expanded_width, expanded_height), (0, 0, 0, 0))
|
||
|
||
# ✅ 首先绘制多层阴影(如果有)
|
||
if subtitle_has_multiple_shadows:
|
||
for layer_idx, layer in enumerate(subtitle_shadow_layers):
|
||
if layer.get('enabled', True):
|
||
layer_color = self.hex_to_rgb(layer.get('color', '#000000'))
|
||
layer_opacity = int(255 * (layer.get('opacity', 100) / 100))
|
||
layer_offset_x = layer.get('offsetX', 0)
|
||
layer_offset_y = layer.get('offsetY', 0)
|
||
layer_blur = layer.get('blur', 0)
|
||
|
||
shadow_draw = ImageDraw.Draw(shadow_layer)
|
||
|
||
# 为这一层绘制文字
|
||
for col_idx, line in enumerate(lines):
|
||
col_x = start_x + padding + col_idx * line_spacing
|
||
for char_idx, char in enumerate(line):
|
||
char_y = start_y + padding + char_idx * (subtitle_font_size + char_spacing) + subtitle_font_size // 2
|
||
char_x = col_x
|
||
shadow_x = char_x + layer_offset_x
|
||
shadow_y = char_y + layer_offset_y
|
||
shadow_draw.text((shadow_x, shadow_y), char, font=subtitle_font, fill=(*layer_color, layer_opacity), anchor='mm')
|
||
|
||
# 对这一层应用模糊
|
||
if layer_blur > 0:
|
||
print(f" [Vertical Subtitle] Applying blur={layer_blur} to shadow layer {layer_idx}", file=sys.stderr)
|
||
shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=layer_blur))
|
||
|
||
print(f" [Vertical Subtitle] Shadow layer {layer_idx} composited", file=sys.stderr)
|
||
|
||
# ✅ 然后绘制传统单层阴影(如果有)
|
||
if subtitle_has_shadow:
|
||
shadow_draw_temp = ImageDraw.Draw(shadow_layer)
|
||
for col_idx, line in enumerate(lines):
|
||
col_x = start_x + padding + col_idx * line_spacing
|
||
for char_idx, char in enumerate(line):
|
||
char_y = start_y + padding + char_idx * (subtitle_font_size + char_spacing) + subtitle_font_size // 2
|
||
char_x = col_x
|
||
shadow_x = char_x + subtitle_shadow_offset_x
|
||
shadow_y = char_y + subtitle_shadow_offset_y
|
||
shadow_draw_temp.text((shadow_x, shadow_y), char, font=subtitle_font, fill=(*subtitle_shadow_color, 160), anchor='mm')
|
||
|
||
if subtitle_shadow_blur > 0:
|
||
print(f" [Vertical Subtitle] Applying GaussianBlur with radius={subtitle_shadow_blur}", file=sys.stderr)
|
||
shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=subtitle_shadow_blur))
|
||
|
||
# ✅ 旋转扩展图层并裁剪回原始尺寸
|
||
if subtitle_rotation_angle != 0:
|
||
print(f" [Vertical Subtitle] Rotating shadow layer by {subtitle_rotation_angle}°", file=sys.stderr)
|
||
shadow_layer = shadow_layer.rotate(-subtitle_rotation_angle, resample=Image.BICUBIC, expand=True, fillcolor=(0, 0, 0, 0))
|
||
crop_x = (shadow_layer.width - width) // 2
|
||
crop_y = (shadow_layer.height - height) // 2
|
||
shadow_layer = shadow_layer.crop((crop_x, crop_y, crop_x + width, crop_y + height))
|
||
result = Image.alpha_composite(result, shadow_layer)
|
||
else:
|
||
shadow_layer = shadow_layer.crop((padding, padding, padding + width, padding + height))
|
||
result = Image.alpha_composite(result, shadow_layer)
|
||
subtitle_draw = ImageDraw.Draw(result)
|
||
print(f" [Vertical Subtitle] Shadow layer composited", file=sys.stderr)
|
||
|
||
# ✅ 步骤2: 在扩展图层绘制描边和主文字
|
||
print(f" [Vertical Subtitle] Creating text layer", file=sys.stderr)
|
||
text_layer = Image.new('RGBA', (expanded_width, expanded_height), (0, 0, 0, 0))
|
||
text_draw = ImageDraw.Draw(text_layer)
|
||
|
||
for col_idx, line in enumerate(lines):
|
||
col_x = start_x + padding + col_idx * line_spacing
|
||
|
||
for char_idx, char in enumerate(line):
|
||
char_y = start_y + padding + char_idx * (subtitle_font_size + char_spacing) + subtitle_font_size // 2
|
||
char_x = col_x
|
||
|
||
# 绘制描边
|
||
if subtitle_stroke_width > 0:
|
||
for offset_x in range(-subtitle_stroke_width, subtitle_stroke_width + 1):
|
||
for offset_y in range(-subtitle_stroke_width, subtitle_stroke_width + 1):
|
||
if offset_x == 0 and offset_y == 0:
|
||
continue
|
||
text_draw.text((char_x + offset_x, char_y + offset_y),
|
||
char, font=subtitle_font, fill=(*subtitle_stroke_color, 255), anchor='mm')
|
||
|
||
# 绘制主体
|
||
text_draw.text((char_x, char_y), char, font=subtitle_font, fill=(*subtitle_text_color, 255), anchor='mm')
|
||
|
||
# ✅ 步骤3: 旋转扩展图层并裁剪回原始尺寸
|
||
if subtitle_rotation_angle != 0:
|
||
print(f" [Vertical Subtitle] Rotating text layer by {subtitle_rotation_angle}°", file=sys.stderr)
|
||
text_layer = text_layer.rotate(-subtitle_rotation_angle, resample=Image.BICUBIC, expand=True, fillcolor=(0, 0, 0, 0))
|
||
crop_x = (text_layer.width - width) // 2
|
||
crop_y = (text_layer.height - height) // 2
|
||
text_layer = text_layer.crop((crop_x, crop_y, crop_x + width, crop_y + height))
|
||
result = Image.alpha_composite(result, text_layer)
|
||
else:
|
||
text_layer = text_layer.crop((padding, padding, padding + width, padding + height))
|
||
result = Image.alpha_composite(result, text_layer)
|
||
subtitle_draw = ImageDraw.Draw(result)
|
||
print(f" [Vertical Subtitle] Text layer composited", file=sys.stderr)
|
||
else:
|
||
# 横排:从左到右,从上到下(默认)
|
||
# ✅ 修复:与前端一致,行间距 = subtitle_font_size + line_spacing
|
||
total_height = (len(lines) - 1) * (subtitle_font_size + line_spacing) + subtitle_font_size
|
||
|
||
# ✅ 修复:计算最长行的宽度,所有行都基于此宽度左对齐(与前端一致)
|
||
max_line_chars = max(len(line) for line in lines) if lines else 1
|
||
total_width = (max_line_chars - 1) * (subtitle_font_size + char_spacing) + subtitle_font_size
|
||
|
||
# 计算整体起始位置(基于最长行居中,然后所有行左对齐)
|
||
start_x = subtitle_x - total_width // 2
|
||
start_y = subtitle_y - total_height // 2
|
||
|
||
print(f" [Horizontal] total_size=({total_width},{total_height}), start=({start_x},{start_y})", file=sys.stderr)
|
||
|
||
# ✅ 计算padding:确保文字+阴影+模糊完全在图层内
|
||
base_padding = max(
|
||
abs(min(0, start_x)),
|
||
abs(min(0, start_y)),
|
||
max(0, start_x + total_width - width),
|
||
max(0, start_y + total_height - height),
|
||
subtitle_shadow_blur * 3,
|
||
abs(subtitle_shadow_offset_x),
|
||
abs(subtitle_shadow_offset_y)
|
||
)
|
||
diagonal = int(((total_width ** 2 + total_height ** 2) ** 0.5) / 2)
|
||
rotation_padding = diagonal if subtitle_rotation_angle != 0 else 0
|
||
padding = max(base_padding, rotation_padding) + 100
|
||
|
||
expanded_width = width + padding * 2
|
||
expanded_height = height + padding * 2
|
||
print(f" [Horizontal Subtitle] Using expanded layer: {width}x{height} → {expanded_width}x{expanded_height}, padding={padding} (base={base_padding}, rotation={rotation_padding})", file=sys.stderr)
|
||
|
||
# ✅ 步骤1: 处理多层阴影或单层阴影(与主标题逻辑一致)
|
||
subtitle_has_shadow = subtitle_shadow_enabled and (subtitle_shadow_blur > 0 or subtitle_shadow_offset_x != 0 or subtitle_shadow_offset_y != 0)
|
||
subtitle_shadow_layers = sub_config.get('shadowLayers', self.template.get('subtitleShadowLayers', []))
|
||
subtitle_has_multiple_shadows = len(subtitle_shadow_layers) > 0 and any(layer.get('enabled', True) for layer in subtitle_shadow_layers)
|
||
|
||
if subtitle_has_multiple_shadows or subtitle_has_shadow:
|
||
print(f" [Horizontal Subtitle] Creating shadow layer(s): {len([l for l in subtitle_shadow_layers if l.get('enabled', True)])} multi-layer + single={subtitle_has_shadow}", file=sys.stderr)
|
||
shadow_layer = Image.new('RGBA', (expanded_width, expanded_height), (0, 0, 0, 0))
|
||
|
||
# ✅ 首先绘制多层阴影(如果有)
|
||
if subtitle_has_multiple_shadows:
|
||
for layer_idx, layer in enumerate(subtitle_shadow_layers):
|
||
if layer.get('enabled', True):
|
||
layer_color = self.hex_to_rgb(layer.get('color', '#000000'))
|
||
layer_opacity = int(255 * (layer.get('opacity', 100) / 100))
|
||
layer_offset_x = layer.get('offsetX', 0)
|
||
layer_offset_y = layer.get('offsetY', 0)
|
||
layer_blur = layer.get('blur', 0)
|
||
|
||
shadow_draw = ImageDraw.Draw(shadow_layer)
|
||
|
||
# 为这一层绘制文字
|
||
for row_idx, line in enumerate(lines):
|
||
line_y = start_y + padding + row_idx * (subtitle_font_size + line_spacing) + subtitle_font_size // 2
|
||
for char_idx, char in enumerate(line):
|
||
char_x = start_x + padding + char_idx * (subtitle_font_size + char_spacing) + subtitle_font_size // 2
|
||
char_y = line_y
|
||
shadow_x = char_x + layer_offset_x
|
||
shadow_y = char_y + layer_offset_y
|
||
shadow_draw.text((shadow_x, shadow_y), char, font=subtitle_font, fill=(*layer_color, layer_opacity), anchor='mm')
|
||
|
||
# 对这一层应用模糊
|
||
if layer_blur > 0:
|
||
print(f" [Horizontal Subtitle] Applying blur={layer_blur} to shadow layer {layer_idx}", file=sys.stderr)
|
||
shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=layer_blur))
|
||
|
||
print(f" [Horizontal Subtitle] Shadow layer {layer_idx} composited", file=sys.stderr)
|
||
|
||
# ✅ 然后绘制传统单层阴影(如果有)
|
||
if subtitle_has_shadow:
|
||
shadow_draw_temp = ImageDraw.Draw(shadow_layer)
|
||
for row_idx, line in enumerate(lines):
|
||
line_y = start_y + padding + row_idx * (subtitle_font_size + line_spacing) + subtitle_font_size // 2
|
||
for char_idx, char in enumerate(line):
|
||
char_x = start_x + padding + char_idx * (subtitle_font_size + char_spacing) + subtitle_font_size // 2
|
||
char_y = line_y
|
||
shadow_x = char_x + subtitle_shadow_offset_x
|
||
shadow_y = char_y + subtitle_shadow_offset_y
|
||
shadow_draw_temp.text((shadow_x, shadow_y), char, font=subtitle_font, fill=(*subtitle_shadow_color, 160), anchor='mm')
|
||
|
||
if subtitle_shadow_blur > 0:
|
||
print(f" [Horizontal Subtitle] Applying GaussianBlur with radius={subtitle_shadow_blur}", file=sys.stderr)
|
||
shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=subtitle_shadow_blur))
|
||
|
||
# ✅ 旋转扩展图层并裁剪回原始尺寸
|
||
if subtitle_rotation_angle != 0:
|
||
print(f" [Horizontal Subtitle] Rotating shadow layer by {subtitle_rotation_angle}°", file=sys.stderr)
|
||
shadow_layer = shadow_layer.rotate(-subtitle_rotation_angle, resample=Image.BICUBIC, expand=True, fillcolor=(0, 0, 0, 0))
|
||
# 裁剪回原始尺寸:计算中心位置
|
||
crop_x = (shadow_layer.width - width) // 2
|
||
crop_y = (shadow_layer.height - height) // 2
|
||
shadow_layer = shadow_layer.crop((crop_x, crop_y, crop_x + width, crop_y + height))
|
||
result = Image.alpha_composite(result, shadow_layer)
|
||
else:
|
||
# 裁剪掉padding
|
||
shadow_layer = shadow_layer.crop((padding, padding, padding + width, padding + height))
|
||
result = Image.alpha_composite(result, shadow_layer)
|
||
subtitle_draw = ImageDraw.Draw(result)
|
||
print(f" [Horizontal Subtitle] Shadow layer composited", file=sys.stderr)
|
||
|
||
# ✅ 步骤2: 在扩展图层绘制描边和主文字
|
||
print(f" [Horizontal Subtitle] Creating text layer", file=sys.stderr)
|
||
text_layer = Image.new('RGBA', (expanded_width, expanded_height), (0, 0, 0, 0))
|
||
text_draw = ImageDraw.Draw(text_layer)
|
||
|
||
for row_idx, line in enumerate(lines):
|
||
line_y = start_y + padding + row_idx * (subtitle_font_size + line_spacing) + subtitle_font_size // 2
|
||
|
||
for char_idx, char in enumerate(line):
|
||
char_x = start_x + padding + char_idx * (subtitle_font_size + char_spacing) + subtitle_font_size // 2
|
||
char_y = line_y
|
||
|
||
# 绘制描边
|
||
if subtitle_stroke_width > 0:
|
||
for offset_x in range(-subtitle_stroke_width, subtitle_stroke_width + 1):
|
||
for offset_y in range(-subtitle_stroke_width, subtitle_stroke_width + 1):
|
||
if offset_x == 0 and offset_y == 0:
|
||
continue
|
||
text_draw.text((char_x + offset_x, char_y + offset_y),
|
||
char, font=subtitle_font, fill=(*subtitle_stroke_color, 255), anchor='mm')
|
||
|
||
# 绘制主体
|
||
text_draw.text((char_x, char_y), char, font=subtitle_font, fill=(*subtitle_text_color, 255), anchor='mm')
|
||
|
||
# ✅ 步骤3: 旋转扩展图层并裁剪回原始尺寸
|
||
if subtitle_rotation_angle != 0:
|
||
print(f" [Horizontal Subtitle] Rotating text layer by {subtitle_rotation_angle}°", file=sys.stderr)
|
||
text_layer = text_layer.rotate(-subtitle_rotation_angle, resample=Image.BICUBIC, expand=True, fillcolor=(0, 0, 0, 0))
|
||
crop_x = (text_layer.width - width) // 2
|
||
crop_y = (text_layer.height - height) // 2
|
||
text_layer = text_layer.crop((crop_x, crop_y, crop_x + width, crop_y + height))
|
||
result = Image.alpha_composite(result, text_layer)
|
||
else:
|
||
# 裁剪掉padding
|
||
text_layer = text_layer.crop((padding, padding, padding + width, padding + height))
|
||
result = Image.alpha_composite(result, text_layer)
|
||
subtitle_draw = ImageDraw.Draw(result)
|
||
print(f" [Horizontal Subtitle] Text layer composited", file=sys.stderr)
|
||
elif subtitle_stroke_width > 0:
|
||
# 原有逻辑:带描边(回退到简单multiline_text)
|
||
subtitle_draw.text(
|
||
(subtitle_x, subtitle_y),
|
||
subtitle_text,
|
||
font=subtitle_font,
|
||
fill=(*subtitle_text_color, 255),
|
||
anchor='mm',
|
||
stroke_width=subtitle_stroke_width,
|
||
stroke_fill=(*subtitle_stroke_color, 255)
|
||
)
|
||
else:
|
||
# 原有逻辑:不带描边(回退到简单multiline_text)
|
||
subtitle_draw.text(
|
||
(subtitle_x, subtitle_y),
|
||
subtitle_text,
|
||
font=subtitle_font,
|
||
fill=(*subtitle_text_color, 255),
|
||
anchor='mm'
|
||
)
|
||
|
||
# 保存预览(保持RGBA模式)
|
||
self.save_preview(result, "text_added")
|
||
|
||
return result
|
||
|
||
|
||
def _get_font_path(self, font_family: str) -> str:
|
||
app_root = os.environ.get('APP_ROOT')
|
||
resource_bundle_root = os.environ.get('RESOURCE_BUNDLE_ROOT')
|
||
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
font_candidates = []
|
||
if resource_bundle_root:
|
||
font_candidates.extend([
|
||
os.path.join(resource_bundle_root, 'fonts', 'NotoSerifCJK-VF.ttf.ttc'),
|
||
os.path.join(resource_bundle_root, 'ziti', 'NotoSerifCJK-VF.ttf.ttc'),
|
||
os.path.join(resource_bundle_root, 'fonts', 'ziti', 'NotoSerifCJK-VF.ttf.ttc'),
|
||
])
|
||
if app_root:
|
||
font_candidates.extend([
|
||
os.path.join(app_root, 'resources-bundles', 'fonts', 'NotoSerifCJK-VF.ttf.ttc'),
|
||
os.path.join(app_root, 'resources-bundles', 'ziti', 'NotoSerifCJK-VF.ttf.ttc'),
|
||
os.path.join(app_root, 'resources-bundles', 'fonts', 'ziti', 'NotoSerifCJK-VF.ttf.ttc'),
|
||
os.path.join(app_root, 'extra', 'common', 'fonts', 'NotoSerifCJK-VF.ttf.ttc'),
|
||
os.path.join(app_root, 'extra', 'common', 'fonts', 'ziti', 'NotoSerifCJK-VF.ttf.ttc'),
|
||
])
|
||
font_candidates.extend([
|
||
os.path.join(project_root, 'fonts', 'bundled', 'NotoSerifCJK-VF.ttf.ttc'),
|
||
os.path.join(project_root, 'ziti', 'NotoSerifCJK-VF.ttf.ttc'),
|
||
os.path.join(os.getcwd(), 'fonts', 'bundled', 'NotoSerifCJK-VF.ttf.ttc'),
|
||
os.path.join(os.getcwd(), 'ziti', 'NotoSerifCJK-VF.ttf.ttc'),
|
||
])
|
||
|
||
for font_path in font_candidates:
|
||
if font_path and os.path.exists(font_path):
|
||
return font_path
|
||
|
||
raise RuntimeError("Packaged cover font not found: NotoSerifCJK-VF.ttf.ttc")
|
||
|
||
COVER_WIDTH = 1080
|
||
COVER_HEIGHT = 1440 # 3:4 比例
|
||
|
||
def _resize_to_cover_size(self, image: Image.Image) -> Image.Image:
|
||
"""
|
||
将图片调整到标准封面尺寸 (1080x1920),保持比例并居中裁剪
|
||
|
||
Args:
|
||
image: 输入图像
|
||
|
||
Returns:
|
||
调整后的图像
|
||
"""
|
||
target_width = self.COVER_WIDTH
|
||
target_height = self.COVER_HEIGHT
|
||
target_ratio = target_width / target_height # 9:16 = 0.5625
|
||
|
||
orig_width, orig_height = image.size
|
||
orig_ratio = orig_width / orig_height
|
||
|
||
print(f"[resize_to_cover_size] Original: {orig_width}x{orig_height} (ratio: {orig_ratio:.4f})", file=sys.stderr)
|
||
print(f"[resize_to_cover_size] Target: {target_width}x{target_height} (ratio: {target_ratio:.4f})", file=sys.stderr)
|
||
|
||
# 计算缩放和裁剪
|
||
if orig_ratio > target_ratio:
|
||
# 原图更宽,以高度为基准缩放,然后裁剪宽度
|
||
new_height = target_height
|
||
new_width = int(orig_width * (target_height / orig_height))
|
||
resized = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||
# 居中裁剪
|
||
left = (new_width - target_width) // 2
|
||
cropped = resized.crop((left, 0, left + target_width, target_height))
|
||
else:
|
||
# 原图更高或相等,以宽度为基准缩放,然后裁剪高度
|
||
new_width = target_width
|
||
new_height = int(orig_height * (target_width / orig_width))
|
||
resized = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||
# 居中裁剪
|
||
top = (new_height - target_height) // 2
|
||
cropped = resized.crop((0, top, target_width, top + target_height))
|
||
|
||
print(f"[resize_to_cover_size] Result: {cropped.size}", file=sys.stderr)
|
||
return cropped
|
||
|
||
def generate(self) -> Dict[str, Any]:
|
||
"""
|
||
生成封面(完整流程)
|
||
|
||
正确流程:
|
||
1. 人物抠图(带透明背景)
|
||
2. 人物描边(在抠图上添加描边)
|
||
3. 模糊背景(原图模糊处理)
|
||
4. 合成2和3(将描边后的人物合成到模糊背景上)
|
||
5. 添加文字和其他信息(文字背景半透明)
|
||
|
||
Returns:
|
||
生成结果
|
||
"""
|
||
try:
|
||
print("=" * 80, file=sys.stderr)
|
||
print("Starting advanced cover generation", file=sys.stderr)
|
||
print(f"Target cover size: {self.COVER_WIDTH}x{self.COVER_HEIGHT}", file=sys.stderr)
|
||
print("=" * 80, file=sys.stderr)
|
||
|
||
# 加载原始图像
|
||
original_image = self.load_image(self.config.get('video', ''))
|
||
|
||
# ✅ 关键修复:将图片调整到标准封面尺寸,与前端画布一致
|
||
original_image = self._resize_to_cover_size(original_image)
|
||
|
||
self.save_preview(original_image, "original")
|
||
|
||
# 步骤1: 人物抠图(如果启用)
|
||
person_rgba = None
|
||
if self.template.get('personBorderEnabled', False) and SEGMENT_AVAILABLE:
|
||
print("Step 1: Extracting person from original image using PersonSegmenter", file=sys.stderr)
|
||
try:
|
||
# 使用PersonSegmenter进行抠图(支持MODNet优先级)
|
||
person_rgba = self._segment_person_with_modnet(original_image)
|
||
|
||
if person_rgba.mode != 'RGBA':
|
||
person_rgba = person_rgba.convert('RGBA')
|
||
|
||
# 提取 alpha 通道并优化(确保只有人物轮廓,其他部分完全透明)
|
||
alpha_channel = np.array(person_rgba.split()[3])
|
||
print(f"Alpha channel before refinement: min={alpha_channel.min()}, max={alpha_channel.max()}, unique_values={len(np.unique(alpha_channel))}", file=sys.stderr)
|
||
|
||
refined_alpha = self.refine_mask(alpha_channel)
|
||
print(f"Alpha channel after refinement: min={refined_alpha.min()}, max={refined_alpha.max()}, unique_values={len(np.unique(refined_alpha))}", file=sys.stderr)
|
||
|
||
# 关键修复:确保只有人物形状是不透明的,周围完全透明
|
||
# 使用严格的二值化,避免灰度值导致的半透明边缘扩展
|
||
_, refined_alpha_binary = cv2.threshold(refined_alpha, 127, 255, cv2.THRESH_BINARY)
|
||
|
||
person_rgba.putalpha(Image.fromarray(refined_alpha_binary))
|
||
|
||
self.save_preview(person_rgba, "step1_person_extracted")
|
||
print("Step 1: Person extraction completed with PersonSegmenter and mask refinement", file=sys.stderr)
|
||
except Exception as e:
|
||
print(f"Step 1: Person extraction failed: {e}", file=sys.stderr)
|
||
import traceback
|
||
traceback.print_exc(file=sys.stderr)
|
||
person_rgba = None
|
||
|
||
# 步骤2: 人物描边(如果有抠图)
|
||
if person_rgba is not None:
|
||
print("Step 2: Applying person outline", file=sys.stderr)
|
||
person_rgba = self._apply_outline_to_person(person_rgba)
|
||
self.save_preview(person_rgba, "step2_person_outlined")
|
||
|
||
# 步骤3: 模糊背景
|
||
print("Step 3: Applying background blur", file=sys.stderr)
|
||
blurred_background = self.apply_background_blur(original_image)
|
||
|
||
# 步骤4: 合成人物到模糊背景
|
||
if person_rgba is not None:
|
||
print("Step 4: Compositing person onto blurred background", file=sys.stderr)
|
||
result = self._composite_person_to_background(person_rgba, blurred_background)
|
||
self.save_preview(result, "step4_person_composited")
|
||
else:
|
||
result = blurred_background.convert('RGBA')
|
||
|
||
# 步骤5: 蒙版叠加
|
||
result = self.apply_mask(result)
|
||
|
||
# 步骤6: 添加文字(文字背景半透明)
|
||
result = self.add_text(result)
|
||
|
||
# 保存最终结果(PNG格式保留透明度)
|
||
print(f"Saving final cover to: {self.output_path}", file=sys.stderr)
|
||
|
||
# 确保输出为PNG格式以保留透明度
|
||
if not self.output_path.lower().endswith('.png'):
|
||
print("Warning: Output format should be PNG to preserve transparency", file=sys.stderr)
|
||
|
||
# 保存为RGBA模式的PNG
|
||
if result.mode != 'RGBA':
|
||
result = result.convert('RGBA')
|
||
|
||
result.save(self.output_path, 'PNG', quality=95)
|
||
|
||
print("=" * 80, file=sys.stderr)
|
||
print("Cover generation completed successfully", file=sys.stderr)
|
||
print(f"Total preview images: {len(self.preview_images)}", file=sys.stderr)
|
||
print("=" * 80, file=sys.stderr)
|
||
|
||
return {
|
||
'success': True,
|
||
'coverPath': self.output_path,
|
||
'previewImages': self.preview_images,
|
||
'message': 'Cover generated successfully'
|
||
}
|
||
|
||
except Exception as e:
|
||
print(f"Error generating cover: {e}", file=sys.stderr)
|
||
import traceback
|
||
traceback.print_exc(file=sys.stderr)
|
||
return {
|
||
'success': False,
|
||
'error': str(e),
|
||
'message': f'Cover generation failed: {str(e)}'
|
||
}
|
||
|
||
def _apply_outline_to_person(self, person_rgba: Image.Image) -> Image.Image:
|
||
"""
|
||
对抠出的人物应用描边
|
||
|
||
正确方案:扩大画布 → 绘制完整描边 → 保持扩大后的尺寸
|
||
|
||
Args:
|
||
person_rgba: 抠出的人物图像(RGBA模式)
|
||
|
||
Returns:
|
||
添加描边后的人物图像(尺寸会比原图大)
|
||
"""
|
||
# 获取描边参数
|
||
border_color = self.hex_to_rgb(self.template.get('personBorderColor', '#FFFFFF'))
|
||
border_width = self.template.get('personBorderWidth', 6)
|
||
border_style = self.template.get('personBorderStyle', 'solid')
|
||
|
||
# 使用文档规定的默认值
|
||
dash_length = self.template.get('personBorderDashLength')
|
||
gap_length = self.template.get('personBorderGapLength')
|
||
|
||
if dash_length is None:
|
||
dash_length = border_width * 3
|
||
if gap_length is None:
|
||
gap_length = border_width * 2
|
||
|
||
print(f"Outline params: color={border_color}, width={border_width}, style={border_style}, dash={dash_length}, gap={gap_length}", file=sys.stderr)
|
||
|
||
# 步骤1: 扩大画布(给描边留出空间)
|
||
padding = border_width + 2 # 描边宽度 + 2像素余量
|
||
original_width, original_height = person_rgba.size
|
||
|
||
# 创建扩大后的画布
|
||
expanded_person = Image.new('RGBA',
|
||
(original_width + padding * 2, original_height + padding * 2),
|
||
(0, 0, 0, 0))
|
||
# 将原始人物图像粘贴到中心
|
||
expanded_person.paste(person_rgba, (padding, padding), person_rgba)
|
||
|
||
print(f"Expanded person canvas: original={person_rgba.size}, expanded={expanded_person.size}, padding={padding}", file=sys.stderr)
|
||
|
||
# 步骤2: 从扩大后的图像提取 alpha 通道
|
||
alpha = expanded_person.split()[3]
|
||
alpha_np = np.array(alpha)
|
||
|
||
# 确保是uint8格式
|
||
if alpha_np.dtype != np.uint8:
|
||
if alpha_np.max() <= 1.0:
|
||
alpha_np = (alpha_np * 255).astype(np.uint8)
|
||
else:
|
||
alpha_np = alpha_np.astype(np.uint8)
|
||
|
||
# 二值化mask(与参考项目一致 - cover.py:1221)
|
||
# 这一步很关键:确保mask只有0和255两个值,提高轮廓检测精度
|
||
_, alpha_binary = cv2.threshold(alpha_np, 127, 255, cv2.THRESH_BINARY)
|
||
print(f"Alpha channel binarized: non-zero pixels = {np.count_nonzero(alpha_binary)}", file=sys.stderr)
|
||
|
||
# 步骤3: 创建描边 mask - 使用轮廓检测(向外扩散,不覆盖人物)
|
||
border_mask = None
|
||
|
||
# 方法1: 轮廓检测(在扩大的画布上,描边不会被截断)
|
||
try:
|
||
contours, _ = cv2.findContours(alpha_binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||
|
||
if len(contours) > 0:
|
||
print(f"Using contour detection method, found {len(contours)} contours", file=sys.stderr)
|
||
|
||
# 创建空白 mask(与扩大后的画布同尺寸)
|
||
border_mask = np.zeros_like(alpha_np)
|
||
|
||
# 🔧 优化:使用 border_width * 2 作为绘制宽度
|
||
# 因为后面会减去人物本身,实际描边只会保留外部的一半
|
||
# 所以需要加倍线宽才能达到用户期望的描边粗细
|
||
draw_width = border_width * 2
|
||
cv2.drawContours(border_mask, contours, -1, 255, draw_width)
|
||
|
||
# 🔧 关键修复:从描边mask中减去人物本身,确保描边只在外部
|
||
# 这样描边就是向外扩散的,不会覆盖人物边缘
|
||
border_mask = cv2.subtract(border_mask, alpha_binary)
|
||
|
||
print(f"Contour detection succeeded, border_pixels={np.count_nonzero(border_mask)} (外部描边, 实际宽度≈{border_width}px)", file=sys.stderr)
|
||
except Exception as e:
|
||
print(f"Contour detection failed: {e}", file=sys.stderr)
|
||
|
||
# 方法2: 形态学操作(后备)- 这个方法本身就是向外扩散的
|
||
if border_mask is None or np.sum(border_mask) == 0:
|
||
print("Using morphological operation method (fallback)", file=sys.stderr)
|
||
|
||
# 计算 kernel 大小和迭代次数
|
||
kernel_size = border_width * 2 + 1
|
||
kernel = np.ones((kernel_size, kernel_size), np.uint8)
|
||
iterations = max(1, border_width // 2)
|
||
|
||
# 膨胀操作(使用二值化的alpha_binary以获得更清晰的边缘)
|
||
dilated_mask = cv2.dilate(alpha_binary, kernel, iterations=iterations)
|
||
|
||
# 边缘 = 膨胀后的mask - 原始mask(这就是向外扩散的描边)
|
||
border_mask = cv2.subtract(dilated_mask, alpha_binary)
|
||
|
||
print(f"Morphological method: kernel_size={kernel_size}, iterations={iterations}", file=sys.stderr)
|
||
|
||
# 根据描边样式处理
|
||
if border_style == 'dashed':
|
||
print("Applying dashed border style", file=sys.stderr)
|
||
border_mask = self._create_dashed_border(border_mask, border_width, dash_length, gap_length)
|
||
|
||
# 步骤4: 在扩大的画布上应用描边
|
||
person_width, person_height = expanded_person.size
|
||
person_array = np.array(expanded_person)
|
||
border_mask_3d = np.stack([border_mask] * 3, axis=2) / 255.0 # 归一化到0-1
|
||
|
||
# 确保border_mask尺寸与人物图像匹配
|
||
if border_mask.shape[:2] != (person_height, person_width):
|
||
border_mask = cv2.resize(border_mask, (person_width, person_height), interpolation=cv2.INTER_NEAREST)
|
||
border_mask_3d = np.stack([border_mask] * 3, axis=2) / 255.0
|
||
print(f"Resized border_mask to match person image: {border_mask.shape}", file=sys.stderr)
|
||
|
||
# 应用描边颜色到透明画布
|
||
border_rgb_array = np.array(border_color, dtype=np.float32)
|
||
h, w = person_array.shape[:2]
|
||
border_rgb_3d = np.tile(border_rgb_array.reshape(1, 1, 3), (h, w, 1))
|
||
|
||
# 分离RGB和Alpha通道
|
||
person_rgb = person_array[:, :, :3].astype(np.float32)
|
||
person_alpha = person_array[:, :, 3:4].astype(np.float32) / 255.0 # 归一化alpha到0-1
|
||
|
||
# 关键修复:处理透明区域的RGB值
|
||
# 在完全透明的区域(alpha=0),RGB值应该被保留为原值(通常是0)
|
||
# 只在有描边和人物的地方应用颜色混合
|
||
|
||
# 对RGB通道应用描边:只在border_mask > 0的地方混合颜色
|
||
person_rgb = (person_rgb * (1 - border_mask_3d) +
|
||
border_rgb_3d * border_mask_3d).astype(np.uint8)
|
||
|
||
# 对Alpha通道:描边区域应该是不透明的(255),人物区域保持原有alpha
|
||
# border_mask是单通道的,值在0-255之间
|
||
border_alpha_mask = border_mask[:, :, np.newaxis] / 255.0 # 归一化到0-1
|
||
|
||
# 关键修复:确保完全透明的区域保持透明
|
||
# 只在border_mask有值或person_alpha > 0的地方设置alpha
|
||
# 这样可以避免扩大画布周围被填充成白色
|
||
person_alpha_new = np.where(
|
||
(border_alpha_mask > 0) | (person_alpha > 0),
|
||
np.maximum(person_alpha, border_alpha_mask),
|
||
0 # 透明区域保持完全透明
|
||
) * 255.0
|
||
person_alpha_new = person_alpha_new.astype(np.uint8)
|
||
|
||
# 合并RGB和Alpha通道
|
||
person_array = np.concatenate([person_rgb, person_alpha_new], axis=2)
|
||
|
||
person_with_border = Image.fromarray(person_array, 'RGBA')
|
||
print(f"Border applied on expanded canvas, final size={person_with_border.size}, color RGB: {border_color}", file=sys.stderr)
|
||
|
||
# 步骤5: 返回扩大后的图像(不裁剪,保持描边完整)
|
||
return person_with_border
|
||
|
||
def _create_dashed_border(self, border_mask: np.ndarray, border_width: int,
|
||
dash_length: int, gap_length: int) -> np.ndarray:
|
||
"""
|
||
将实线描边转换为虚线描边
|
||
|
||
使用轮廓路径追踪算法,沿着轮廓路径交替绘制虚线段和间隔
|
||
关键优化:扩展画布以避免边缘虚线被截断
|
||
|
||
Args:
|
||
border_mask: 实线描边 mask
|
||
border_width: 描边宽度
|
||
dash_length: 虚线长度
|
||
gap_length: 间隔长度
|
||
|
||
Returns:
|
||
虚线描边 mask
|
||
"""
|
||
# 扩展画布以避免边缘虚线被截断
|
||
padding = border_width * 2
|
||
h, w = border_mask.shape
|
||
|
||
# 创建扩展后的画布
|
||
padded_border_mask = np.zeros((h + padding * 2, w + padding * 2), dtype=np.uint8)
|
||
padded_border_mask[padding:padding+h, padding:padding+w] = border_mask
|
||
|
||
print(f"Padded border_mask for dashed border: original={border_mask.shape}, padded={padded_border_mask.shape}, padding={padding}", file=sys.stderr)
|
||
|
||
# 在扩展后的画布上找到轮廓
|
||
contours, _ = cv2.findContours(padded_border_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||
|
||
# 在扩展后的画布上创建虚线mask
|
||
padded_dashed_mask = np.zeros_like(padded_border_mask)
|
||
|
||
print(f"Creating dashed border: {len(contours)} contours, dash={dash_length}px, gap={gap_length}px", file=sys.stderr)
|
||
|
||
# 对每个轮廓绘制虚线
|
||
for contour_idx, contour in enumerate(contours):
|
||
if len(contour) < 2:
|
||
continue
|
||
|
||
# 将轮廓点连接成连续的路径
|
||
contour_points = [tuple(pt[0]) for pt in contour]
|
||
|
||
# 计算轮廓总长度
|
||
total_length = 0.0
|
||
for i in range(len(contour_points) - 1):
|
||
pt1 = contour_points[i]
|
||
pt2 = contour_points[i + 1]
|
||
total_length += np.sqrt((pt2[0] - pt1[0])**2 + (pt2[1] - pt1[1])**2)
|
||
|
||
print(f"Contour {contour_idx}: {len(contour_points)} points, length={total_length:.1f}px", file=sys.stderr)
|
||
|
||
# 沿着轮廓路径绘制虚线
|
||
is_dash = True # 当前是否在绘制虚线段
|
||
dash_remaining = float(dash_length) # 当前虚线段剩余长度
|
||
gap_remaining = float(gap_length) # 当前间隔剩余长度
|
||
|
||
for i in range(len(contour_points) - 1):
|
||
pt1 = contour_points[i]
|
||
pt2 = contour_points[i + 1]
|
||
|
||
# 计算两点间距离
|
||
segment_dist = np.sqrt((pt2[0] - pt1[0])**2 + (pt2[1] - pt1[1])**2)
|
||
|
||
if segment_dist < 0.1: # 跳过太短的点
|
||
continue
|
||
|
||
# 沿着这个线段绘制虚线
|
||
remaining_in_segment = segment_dist
|
||
segment_offset = 0.0 # 在当前线段中的偏移
|
||
|
||
while remaining_in_segment > 0.01: # 还有剩余距离
|
||
if is_dash:
|
||
# 绘制虚线段
|
||
draw_length = min(dash_remaining, remaining_in_segment)
|
||
|
||
# 计算起点和终点在 pt1-pt2 线段上的位置
|
||
t1 = segment_offset / segment_dist
|
||
t2 = (segment_offset + draw_length) / segment_dist
|
||
|
||
# 确保 t 在 [0, 1] 范围内
|
||
t1 = max(0.0, min(1.0, t1))
|
||
t2 = max(0.0, min(1.0, t2))
|
||
|
||
x1 = int(pt1[0] + (pt2[0] - pt1[0]) * t1)
|
||
y1 = int(pt1[1] + (pt2[1] - pt1[1]) * t1)
|
||
x2 = int(pt1[0] + (pt2[0] - pt1[0]) * t2)
|
||
y2 = int(pt1[1] + (pt2[1] - pt1[1]) * t2)
|
||
|
||
# 绘制虚线段(在扩展画布上)
|
||
if abs(x2 - x1) > 0 or abs(y2 - y1) > 0: # 确保不是同一个点
|
||
cv2.line(padded_dashed_mask, (x1, y1), (x2, y2), 255, border_width)
|
||
|
||
segment_offset += draw_length
|
||
remaining_in_segment -= draw_length
|
||
dash_remaining -= draw_length
|
||
|
||
# 如果虚线段绘制完成,切换到间隔
|
||
if dash_remaining <= 0.01:
|
||
is_dash = False
|
||
gap_remaining = float(gap_length) # 重置间隔长度
|
||
else:
|
||
# 跳过间隔段
|
||
skip_length = min(gap_remaining, remaining_in_segment)
|
||
segment_offset += skip_length
|
||
remaining_in_segment -= skip_length
|
||
gap_remaining -= skip_length
|
||
|
||
# 如果间隔段完成,切换到虚线
|
||
if gap_remaining <= 0.01:
|
||
is_dash = True
|
||
dash_remaining = float(dash_length) # 重置虚线长度
|
||
|
||
# 裁剪回原始尺寸
|
||
dashed_mask = padded_dashed_mask[padding:padding+h, padding:padding+w]
|
||
print(f"Dashed border created and cropped back to original size", file=sys.stderr)
|
||
|
||
return dashed_mask
|
||
|
||
def _composite_person_to_background(self, person_rgba: Image.Image, background: Image.Image) -> Image.Image:
|
||
"""
|
||
将人物合成到背景上
|
||
|
||
Args:
|
||
person_rgba: 人物图像(RGBA模式,可能带描边)
|
||
background: 背景图像(RGB模式)
|
||
|
||
Returns:
|
||
合成后的图像(RGBA模式)
|
||
"""
|
||
# 调整人物大小
|
||
person_size = self.template.get('personSize', 100)
|
||
person_rotation = self.template.get('personRotation', 0)
|
||
if person_size != 100:
|
||
new_width = int(person_rgba.width * person_size / 100)
|
||
new_height = int(person_rgba.height * person_size / 100)
|
||
person_rgba = person_rgba.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||
|
||
# 调整人物位置
|
||
person_position = self.template.get('personPosition', {'x': 50, 'y': 50})
|
||
person_x = int(background.width * person_position['x'] / 100) - person_rgba.width // 2
|
||
person_y = int(background.height * person_position['y'] / 100) - person_rgba.height // 2
|
||
|
||
# 合成
|
||
result = background.convert('RGBA')
|
||
result.paste(person_rgba, (person_x, person_y), person_rgba)
|
||
|
||
return result
|
||
|
||
def run_and_output_json(self):
|
||
"""
|
||
运行生成器并输出JSON结果(只输出一行JSON)
|
||
"""
|
||
result = self.generate()
|
||
# 确保只输出一行JSON
|
||
sys.stdout.write(json.dumps(result, ensure_ascii=False))
|
||
sys.stdout.flush()
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description='Advanced Cover Generator')
|
||
parser.add_argument('--video', required=True, help='Path to video or image file')
|
||
parser.add_argument('--title', required=True, help='Title text')
|
||
parser.add_argument('--output', required=True, help='Output cover path')
|
||
parser.add_argument('--config', default='{}', help='JSON configuration')
|
||
|
||
args = parser.parse_args()
|
||
|
||
try:
|
||
# 确保系统编码支持中文
|
||
import sys
|
||
import locale
|
||
if sys.platform == 'win32':
|
||
# Windows 设置控制台编码
|
||
import ctypes
|
||
kernel32 = ctypes.windll.kernel32
|
||
kernel32.SetConsoleOutputCP(65001) # UTF-8
|
||
|
||
# 解析配置
|
||
config = json.loads(args.config)
|
||
|
||
# ⚠️ 诊断日志:同时输出到 stdout 和 stderr,确保能看到
|
||
import sys
|
||
diag_msg = f"""
|
||
=== ADVANCED COVER GENERATOR DIAGNOSTIC ===
|
||
[CONFIG] Received config keys: {list(config.keys())[:20]}... (showing first 20)
|
||
[CONFIG] titleStrokeWidth: {config.get('titleStrokeWidth', 'NOT SET')}
|
||
[CONFIG] titleStrokeColor: {config.get('titleStrokeColor', 'NOT SET')}
|
||
[CONFIG] titleFontFamily: {config.get('titleFontFamily', 'NOT SET')}
|
||
[CONFIG] personBorderEnabled: {config.get('personBorderEnabled', 'NOT SET')}
|
||
[CONFIG] personBorderWidth: {config.get('personBorderWidth', 'NOT SET')}
|
||
[CONFIG] personBorderColor: {config.get('personBorderColor', 'NOT SET')}
|
||
[CONFIG] backgroundBlurEnabled: {config.get('backgroundBlurEnabled', 'NOT SET')}
|
||
===========================================
|
||
"""
|
||
print(diag_msg, file=sys.stderr, flush=True)
|
||
sys.stderr.flush()
|
||
|
||
# 添加命令行参数到配置
|
||
config['video'] = args.video
|
||
config['title'] = args.title
|
||
config['output'] = args.output
|
||
|
||
# 创建生成器
|
||
generator = AdvancedCoverGenerator(config)
|
||
|
||
# 运行并输出结果(只输出一行JSON)
|
||
generator.run_and_output_json()
|
||
|
||
except Exception as e:
|
||
error_result = {
|
||
'success': False,
|
||
'error': str(e),
|
||
'message': f'Error: {str(e)}'
|
||
}
|
||
sys.stdout.write(json.dumps(error_result, ensure_ascii=False))
|
||
sys.stdout.flush()
|
||
sys.exit(1)
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|