655 lines
31 KiB
Python
655 lines
31 KiB
Python
import torch
|
|
import numpy as np
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
import os
|
|
import json
|
|
import re
|
|
import random
|
|
from dataclasses import dataclass, field
|
|
from typing import Optional, Tuple, List, Dict, Any
|
|
|
|
# Author: KARAS
|
|
# Version: 3.9.0 - Implemented dynamic scaling for small images
|
|
#
|
|
# --- 依赖导入 ---
|
|
try:
|
|
import piexif
|
|
except ImportError:
|
|
piexif = None
|
|
|
|
# --- 插件资源路径设置 ---
|
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
fonts_dir = os.path.join(current_dir, "fonts")
|
|
logos_dir = os.path.join(current_dir, "logos")
|
|
os.makedirs(fonts_dir, exist_ok=True)
|
|
os.makedirs(logos_dir, exist_ok=True)
|
|
|
|
# --- 资源扫描与缓存 ---
|
|
def get_resource_files(directory: str, extensions: Tuple[str, ...]) -> List[str]:
|
|
"""Scans a directory for files with given extensions."""
|
|
try:
|
|
return sorted([f for f in os.listdir(directory) if f.lower().endswith(extensions)])
|
|
except FileNotFoundError:
|
|
return []
|
|
|
|
font_files = get_resource_files(fonts_dir, ('.ttf', '.ttc', '.otf')) or ["default"]
|
|
bold_font_files = [f for f in font_files if 'bold' in f.lower() or 'bd' in f.lower()] or font_files
|
|
logo_files = ["None"] + sorted([os.path.splitext(f)[0] for f in get_resource_files(logos_dir, ('.png',))])
|
|
|
|
# --- 预设数据 ---
|
|
PHONE_MODELS = [
|
|
"HUAWEI Pura 70 Ultra", "HUAWEI Pura 70 Pro", "HUAWEI Mate 60 Pro",
|
|
"iPhone 15 Pro Max", "iPhone 15 Pro",
|
|
"Samsung Galaxy S24 Ultra", "Xiaomi 14 Ultra",
|
|
"OPPO Find X7 Ultra", "vivo X100 Ultra", "Google Pixel 8 Pro", "自定义型号"
|
|
]
|
|
|
|
LAYOUT_OPTIONS = [
|
|
"1. 底部留白 - 左右分布",
|
|
"2. 底部留白 - 居中",
|
|
"3. 右侧留白 - 上下竖排",
|
|
"4. 顶部 & 底部留白",
|
|
"5. 画内叠加 - 左下角",
|
|
"6. 画内叠加 - 右下角",
|
|
"7. 画内叠加 - 左上角",
|
|
"8. 画内叠加 - 右上角",
|
|
"9. 画内叠加 - 居中",
|
|
"10. 独立信息条"
|
|
]
|
|
|
|
LAYOUT_PRESETS = {
|
|
LAYOUT_OPTIONS[0]: {
|
|
"logo": "leica",
|
|
"logo_size_ratio": 1.0,
|
|
"bar_size": 200,
|
|
"bg_color": "#FFFFFF",
|
|
"title_color": "#000000",
|
|
"subtitle_color": "#888888",
|
|
},
|
|
LAYOUT_OPTIONS[1]: {
|
|
"block_b_text_1": "",
|
|
"block_a_text_1": "",
|
|
"block_a_text_2": "",
|
|
"logo": "hasselblad",
|
|
"logo_size_ratio": 1,
|
|
"bg_color": "#FFFFFF",
|
|
"title_color": "#000000",
|
|
"subtitle_color": "#888888",
|
|
},
|
|
LAYOUT_OPTIONS[2]: {
|
|
"bar_size": 300,
|
|
"block_a_text_2": "",
|
|
"logo_size_ratio": 1.0,
|
|
},
|
|
LAYOUT_OPTIONS[3]: {
|
|
"bar_size": 150,
|
|
"block_a_text_2": "",
|
|
"logo_size_ratio": 1.0,
|
|
},
|
|
LAYOUT_OPTIONS[4]: {
|
|
"logo": "zeiss",
|
|
"title_color": "#FFFFFF",
|
|
"subtitle_color": "#CCCCCC",
|
|
"logo_size_ratio": 1.0,
|
|
},
|
|
LAYOUT_OPTIONS[5]: {
|
|
"logo": "zeiss",
|
|
"title_color": "#FFFFFF",
|
|
"subtitle_color": "#CCCCCC",
|
|
"logo_size_ratio": 1.0,
|
|
},
|
|
LAYOUT_OPTIONS[6]: {
|
|
"logo": "zeiss",
|
|
"title_color": "#FFFFFF",
|
|
"subtitle_color": "#CCCCCC",
|
|
"logo_size_ratio": 1.0,
|
|
},
|
|
LAYOUT_OPTIONS[7]: {
|
|
"logo": "zeiss",
|
|
"title_color": "#FFFFFF",
|
|
"subtitle_color": "#CCCCCC",
|
|
"logo_size_ratio": 1.0,
|
|
},
|
|
LAYOUT_OPTIONS[8]: {
|
|
"logo": "zeiss",
|
|
"title_color": "#FFFFFF",
|
|
"subtitle_color": "#CCCCCC",
|
|
"logo_size_ratio": 1.0,
|
|
},
|
|
}
|
|
|
|
|
|
# -----------------------------------------------------------------
|
|
# 节点 1: 加载图片/附加EXIF
|
|
# -----------------------------------------------------------------
|
|
class ImageLoaderWithEXIF:
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
input_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "..", "input")
|
|
files = get_resource_files(input_dir, ('.jpg', '.jpeg', '.png', '.webp'))
|
|
return {
|
|
"required": { "image_file": (sorted(files), {"image_upload": True}) },
|
|
"optional": { "image_passthrough": ("IMAGE",) }
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "STRING")
|
|
RETURN_NAMES = ("image", "exif_json")
|
|
FUNCTION = "load_or_passthrough"
|
|
CATEGORY = "Watermark"
|
|
|
|
def load_or_passthrough(self, image_file: str, image_passthrough: Optional[torch.Tensor] = None) -> Tuple[torch.Tensor, str]:
|
|
if piexif is None:
|
|
raise ImportError("需要 'piexif' 库。请运行: pip install piexif")
|
|
|
|
image_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "..", "input", image_file)
|
|
output_exif_json = "{}"
|
|
try:
|
|
with Image.open(image_path) as img_for_exif:
|
|
if 'exif' in img_for_exif.info and img_for_exif.info['exif']:
|
|
exif_dict = piexif.load(img_for_exif.info['exif'])
|
|
exif_data = self.parse_exif(exif_dict)
|
|
output_exif_json = json.dumps(exif_data)
|
|
except Exception as e:
|
|
print(f"无法从 '{image_file}' 读取EXIF: {e}")
|
|
|
|
if image_passthrough is not None:
|
|
return (image_passthrough, output_exif_json)
|
|
else:
|
|
with Image.open(image_path) as img:
|
|
img_rgb = img.convert("RGB")
|
|
if "exif" in img.info:
|
|
try:
|
|
exif_dict = piexif.load(img.info["exif"])
|
|
orientation = exif_dict.get("0th", {}).get(piexif.ImageIFD.Orientation, 1)
|
|
if orientation > 1:
|
|
img_rgb = self.apply_exif_orientation(img_rgb, orientation)
|
|
except Exception as e:
|
|
print(f"处理EXIF方向时出错: {e}")
|
|
|
|
image_tensor = np.array(img_rgb).astype(np.float32) / 255.0
|
|
return (torch.from_numpy(image_tensor)[None,], output_exif_json)
|
|
|
|
def apply_exif_orientation(self, image: Image.Image, orientation: int) -> Image.Image:
|
|
"""Applies EXIF orientation to the image."""
|
|
if orientation == 2: return image.transpose(Image.FLIP_LEFT_RIGHT)
|
|
if orientation == 3: return image.rotate(180)
|
|
if orientation == 4: return image.transpose(Image.FLIP_TOP_BOTTOM)
|
|
if orientation == 5: return image.rotate(-90, expand=True).transpose(Image.FLIP_LEFT_RIGHT)
|
|
if orientation == 6: return image.rotate(-90, expand=True)
|
|
if orientation == 7: return image.rotate(90, expand=True).transpose(Image.FLIP_LEFT_RIGHT)
|
|
if orientation == 8: return image.rotate(90, expand=True)
|
|
return image
|
|
|
|
def parse_exif(self, exif_dict: Dict) -> Dict[str, Any]:
|
|
"""Parses raw EXIF data into a structured dictionary."""
|
|
exif_data = {}
|
|
zeroth_ifd = exif_dict.get('0th', {})
|
|
exif_ifd = exif_dict.get('Exif', {})
|
|
|
|
if piexif.ImageIFD.Make in zeroth_ifd:
|
|
exif_data['Make'] = zeroth_ifd[piexif.ImageIFD.Make].decode('utf-8', errors='ignore').strip('\x00').strip()
|
|
if piexif.ImageIFD.Model in zeroth_ifd:
|
|
exif_data['Model'] = zeroth_ifd[piexif.ImageIFD.Model].decode('utf-8', errors='ignore').strip('\x00').strip()
|
|
if piexif.ExifIFD.LensModel in exif_ifd:
|
|
exif_data['LensModel'] = exif_ifd[piexif.ExifIFD.LensModel].decode('utf-8', errors='ignore').strip('\x00').strip()
|
|
if piexif.ExifIFD.DateTimeOriginal in exif_ifd:
|
|
exif_data['Timestamp'] = exif_ifd[piexif.ExifIFD.DateTimeOriginal].decode('utf-8', errors='ignore').strip('\x00').strip()
|
|
if piexif.ExifIFD.FNumber in exif_ifd:
|
|
f_num = exif_ifd[piexif.ExifIFD.FNumber]
|
|
exif_data['Aperture'] = f"f/{(f_num[0]/f_num[1]):.1f}" if f_num[1] > 0 else "f/0.0"
|
|
if piexif.ExifIFD.ExposureTime in exif_ifd:
|
|
exp = exif_ifd[piexif.ExifIFD.ExposureTime]
|
|
if exp[0] > 0 and exp[1] > 0:
|
|
shutter = exp[0] / exp[1]
|
|
exif_data['ShutterSpeed'] = f"1/{int(1/shutter)}s" if shutter < 1 else f"{shutter}s"
|
|
if piexif.ExifIFD.ISOSpeedRatings in exif_ifd:
|
|
exif_data['ISO'] = f"ISO{exif_ifd[piexif.ExifIFD.ISOSpeedRatings]}"
|
|
if piexif.ExifIFD.FocalLength in exif_ifd:
|
|
focal = exif_ifd[piexif.ExifIFD.FocalLength]
|
|
if focal[1] > 0:
|
|
exif_data['FocalLength'] = f"{int(focal[0]/focal[1])}mm"
|
|
return exif_data
|
|
|
|
# -----------------------------------------------------------------
|
|
# 节点 2: 相机水印生成器
|
|
# -----------------------------------------------------------------
|
|
@dataclass
|
|
class TextContent:
|
|
block_a: List[str]
|
|
block_b: List[str]
|
|
|
|
@dataclass
|
|
class TextBlock:
|
|
lines: List[Tuple[str, ImageFont.FreeTypeFont, str]] = field(default_factory=list)
|
|
width: int = 0
|
|
height: int = 0
|
|
line_bboxes: List[Tuple[int, int, int, int]] = field(default_factory=list)
|
|
|
|
class CameraWatermarkNode:
|
|
_font_cache = {}
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
"layout": (LAYOUT_OPTIONS,),
|
|
"restore_preset": ("BOOLEAN", {"default": False, "label_on": "应用当前样式预设", "label_off": "应用当前样式预设"}),
|
|
"randomize_style": ("BOOLEAN", {"default": False}),
|
|
"random_style_selection": ("STRING", {"multiline": False, "default": "1-10"}),
|
|
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
|
|
"use_exif": ("BOOLEAN", {"default": True}),
|
|
"block_a_text_1": ("STRING", {"multiline": False, "default": "iPhone 15 Pro Max"}),
|
|
"block_a_text_2": ("STRING", {"multiline": False, "default": "Main Camera"}),
|
|
"block_a_text_3": ("STRING", {"multiline": False, "default": "© KARAS"}),
|
|
"block_b_text_1": ("STRING", {"multiline": False, "default": "SONY"}),
|
|
"block_b_text_2": ("STRING", {"multiline": False, "default": "24mm | f/1.8 | 1/125s | ISO 50"}),
|
|
"block_b_text_3": ("STRING", {"multiline": False, "default": ""}),
|
|
"logo": (logo_files, {"default": "leica" if "leica" in [lf.lower() for lf in logo_files] else logo_files[0]}),
|
|
"logo_size_ratio": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 3.0, "step": 0.1}),
|
|
"bar_size": ("INT", {"default": 200, "min": 0, "max": 2048, "step": 10}),
|
|
"bar_width": ("INT", {"default": 1894, "min": 64, "max": 8192, "step": 64}),
|
|
"bar_height": ("INT", {"default": 165, "min": 64, "max": 8192, "step": 64}),
|
|
"padding": ("INT", {"default": 50, "min": 0, "max": 400, "step": 1}),
|
|
"spacing": ("INT", {"default": 15, "min": 0, "max": 200, "step": 1}),
|
|
"font_name": (font_files, {}),
|
|
"font_bold_name": (bold_font_files, {}),
|
|
"font_size_large": ("INT", {"default": 48, "min": 8, "max": 200, "step": 1}),
|
|
"font_size_medium": ("INT", {"default": 28, "min": 8, "max": 200, "step": 1}),
|
|
"font_size_small": ("INT", {"default": 24, "min": 8, "max": 200, "step": 1}),
|
|
"bg_color": ("STRING", {"multiline": False, "default": "#FFFFFF"}),
|
|
"title_color": ("STRING", {"multiline": False, "default": "#000000"}),
|
|
"subtitle_color": ("STRING", {"multiline": False, "default": "#888888"}),
|
|
},
|
|
"optional": { "exif_data": ("STRING", {"forceInput": True}), }
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE",)
|
|
FUNCTION = "generate_image"
|
|
CATEGORY = "Watermark"
|
|
|
|
def tensor_to_pil(self, tensor: torch.Tensor) -> Optional[Image.Image]:
|
|
if tensor is None: return None
|
|
return Image.fromarray(np.clip(255. * tensor.cpu().numpy().squeeze(0), 0, 255).astype(np.uint8))
|
|
|
|
def pil_to_tensor(self, pil_image: Image.Image) -> torch.Tensor:
|
|
return torch.from_numpy(np.array(pil_image).astype(np.float32) / 255.0).unsqueeze(0)
|
|
|
|
def get_font(self, font_name: str, size: int) -> ImageFont.FreeTypeFont:
|
|
cache_key = f"{font_name}_{size}"
|
|
if cache_key in self._font_cache:
|
|
return self._font_cache[cache_key]
|
|
|
|
if not font_name or font_name == "default":
|
|
try: font = ImageFont.load_default(size=size)
|
|
except AttributeError: font = ImageFont.load_default()
|
|
else:
|
|
font_path = os.path.join(fonts_dir, font_name)
|
|
try: font = ImageFont.truetype(font_path, size=size)
|
|
except Exception as e:
|
|
print(f"警告: 无法加载字体 '{font_name}'。错误: {e}")
|
|
try: font = ImageFont.load_default(size=size)
|
|
except AttributeError: font = ImageFont.load_default()
|
|
|
|
self._font_cache[cache_key] = font
|
|
return font
|
|
|
|
def get_text_bbox(self, text: str, font: ImageFont.FreeTypeFont) -> Tuple[int, int, int, int]:
|
|
if hasattr(font, 'getbbox'):
|
|
return font.getbbox(text)
|
|
else:
|
|
w, h = font.getsize(text)
|
|
return (0, 0, w, h)
|
|
|
|
def _crop_transparent_border(self, pil_image: Image.Image) -> Image.Image:
|
|
"""Crops the transparent border from a PIL image."""
|
|
if pil_image.mode != "RGBA":
|
|
return pil_image
|
|
|
|
bbox = pil_image.getbbox()
|
|
if bbox:
|
|
return pil_image.crop(bbox)
|
|
return pil_image
|
|
|
|
def _parse_range_string(self, range_string: str) -> List[int]:
|
|
numbers = set()
|
|
parts = range_string.split(',')
|
|
for part in parts:
|
|
part = part.strip()
|
|
if not part: continue
|
|
if '-' in part:
|
|
try:
|
|
start, end = map(int, part.split('-'))
|
|
if start <= end:
|
|
numbers.update(range(start, end + 1))
|
|
except ValueError:
|
|
print(f"警告: 无效的范围 '{part}' in random style selection.")
|
|
else:
|
|
try:
|
|
numbers.add(int(part))
|
|
except ValueError:
|
|
print(f"警告: 无效的数字 '{part}' in random style selection.")
|
|
return sorted(list(numbers))
|
|
|
|
def _prepare_content(self, **kwargs) -> Tuple[TextContent, str]:
|
|
exif_data_str = kwargs.get('exif_data')
|
|
has_valid_exif = kwargs['use_exif'] and exif_data_str and exif_data_str.strip() != "{}"
|
|
|
|
b_a1, b_a2, b_a3 = kwargs['block_a_text_1'], kwargs['block_a_text_2'], kwargs['block_a_text_3']
|
|
b_b1, b_b2, b_b3 = kwargs['block_b_text_1'], kwargs['block_b_text_2'], kwargs['block_b_text_3']
|
|
logo_match_source = b_a1.lower()
|
|
|
|
if has_valid_exif:
|
|
try:
|
|
data = json.loads(exif_data_str)
|
|
param_parts = [data.get("FocalLength"), data.get("Aperture"), data.get("ShutterSpeed"), data.get("ISO")]
|
|
b_a1 = data.get("Model", "")
|
|
b_a2 = data.get("LensModel", "")
|
|
b_a3 = kwargs.get('author', '© KARAS')
|
|
b_b1 = data.get("Make", "")
|
|
b_b2 = " | ".join(filter(None, param_parts))
|
|
b_b3 = data.get("Timestamp", "")
|
|
logo_match_source = data.get("Make", "").lower()
|
|
except (json.JSONDecodeError, KeyError) as e:
|
|
print(f"无法解析EXIF: {e}")
|
|
|
|
content = TextContent(block_a=[b_a1, b_a2, b_a3], block_b=[b_b1, b_b2, b_b3])
|
|
|
|
final_logo = kwargs['logo']
|
|
if final_logo == "None" and logo_match_source:
|
|
for logo_name in logo_files:
|
|
if logo_name != "None" and logo_name.lower() in logo_match_source:
|
|
final_logo = logo_name
|
|
break
|
|
|
|
return content, final_logo
|
|
|
|
def _draw_block(self, draw, block: TextBlock, start_x: int, start_y: int, align: str = 'left', spacing: int = 10):
|
|
y = start_y
|
|
for i, (text, font, color) in enumerate(block.lines):
|
|
bbox = block.line_bboxes[i]
|
|
text_width, text_height = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
|
|
|
x = start_x
|
|
if align == 'center':
|
|
x = start_x - text_width / 2
|
|
elif align == 'right':
|
|
x = start_x - text_width
|
|
|
|
draw.text((x - bbox[0], y - bbox[1]), text, font=font, fill=color)
|
|
y += text_height + spacing
|
|
|
|
def _draw_vertical_text_block(self, draw_layer, block: TextBlock, center_x: int, start_y: int, spacing: int):
|
|
if not block.lines:
|
|
return 0
|
|
|
|
temp_surface = Image.new("RGBA", (block.width, block.height), (0, 0, 0, 0))
|
|
temp_draw = ImageDraw.Draw(temp_surface)
|
|
|
|
y_cursor = 0
|
|
min_x_offset = min(box[0] for box in block.line_bboxes)
|
|
|
|
for i, (text, font, color) in enumerate(block.lines):
|
|
bbox = block.line_bboxes[i]
|
|
text_height = bbox[3] - bbox[1]
|
|
temp_draw.text((-min_x_offset, y_cursor - bbox[1]), text, font=font, fill=color)
|
|
y_cursor += text_height + spacing
|
|
|
|
rotated_surface = temp_surface.rotate(90, expand=True, resample=Image.Resampling.LANCZOS)
|
|
|
|
paste_x = center_x - rotated_surface.width / 2
|
|
paste_y = start_y
|
|
|
|
draw_layer.paste(rotated_surface, (int(paste_x), int(paste_y)), rotated_surface)
|
|
|
|
return rotated_surface.height
|
|
|
|
def generate_image(self, **kwargs):
|
|
|
|
params = kwargs.copy()
|
|
layout = params['layout']
|
|
seed = params['seed']
|
|
restore_preset = params.pop('restore_preset', False)
|
|
|
|
if params.get('randomize_style', False):
|
|
random.seed(seed)
|
|
selection_str = params.get('random_style_selection', '1-10')
|
|
allowed_indices = self._parse_range_string(selection_str)
|
|
allowed_indices_0based = [i - 1 for i in allowed_indices if 1 <= i <= len(LAYOUT_OPTIONS)]
|
|
if allowed_indices_0based:
|
|
chosen_index = random.choice(allowed_indices_0based)
|
|
layout = LAYOUT_OPTIONS[chosen_index]
|
|
print(f"随机样式已启用 (种子: {seed}),选择: {layout}")
|
|
|
|
if restore_preset:
|
|
if layout in LAYOUT_PRESETS:
|
|
print(f"恢复预设: {layout}")
|
|
params.update(LAYOUT_PRESETS[layout])
|
|
|
|
content, final_logo_name = self._prepare_content(**params)
|
|
|
|
input_pil = self.tensor_to_pil(params['image'])
|
|
img_w, img_h = input_pil.size
|
|
|
|
# --- Dynamic Scaling Logic ---
|
|
base_width = 1200.0 # Standard width for scaling
|
|
scale_factor = min(1.0, img_w / base_width)
|
|
|
|
bar_size = int(params['bar_size'] * scale_factor)
|
|
padding = int(params['padding'] * scale_factor)
|
|
spacing = int(params['spacing'] * scale_factor)
|
|
font_size_large = int(params['font_size_large'] * scale_factor)
|
|
font_size_medium = int(params['font_size_medium'] * scale_factor)
|
|
font_size_small = int(params['font_size_small'] * scale_factor)
|
|
|
|
bg_color = params['bg_color']
|
|
|
|
if layout.startswith("1.") or layout.startswith("2.") or layout.startswith("10."):
|
|
canvas = Image.new("RGBA", (img_w, img_h + bar_size), bg_color)
|
|
canvas.paste(input_pil.convert("RGBA"), (0, 0))
|
|
draw_area = (0, img_h, img_w, bar_size)
|
|
elif layout.startswith("3."):
|
|
canvas = Image.new("RGBA", (img_w + bar_size, img_h), bg_color)
|
|
canvas.paste(input_pil.convert("RGBA"), (0, 0))
|
|
draw_area = (img_w, 0, bar_size, img_h)
|
|
elif layout.startswith("4."):
|
|
canvas = Image.new("RGBA", (img_w, img_h + bar_size * 2), bg_color)
|
|
canvas.paste(input_pil.convert("RGBA"), (0, bar_size))
|
|
draw_area_top = (0, 0, img_w, bar_size)
|
|
draw_area_bottom = (0, img_h + bar_size, img_w, bar_size)
|
|
draw_area = draw_area_top
|
|
elif layout.startswith("5.") or layout.startswith("6.") or layout.startswith("7.") or layout.startswith("8.") or layout.startswith("9."):
|
|
canvas = input_pil.convert("RGBA")
|
|
draw_area = (0, 0, img_w, img_h)
|
|
else:
|
|
bar_width = int(params['bar_width'] * scale_factor)
|
|
bar_height = int(params['bar_height'] * scale_factor)
|
|
canvas = Image.new("RGBA", (bar_width, bar_height), bg_color)
|
|
draw_area = (0, 0, canvas.width, canvas.height)
|
|
|
|
draw_layer = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(draw_layer)
|
|
|
|
fonts = {
|
|
'large_bold': self.get_font(params['font_bold_name'], font_size_large),
|
|
'medium': self.get_font(params['font_name'], font_size_medium),
|
|
'small': self.get_font(params['font_name'], font_size_small)
|
|
}
|
|
|
|
block_a_fonts = [fonts['large_bold'], fonts['medium'], fonts['small']]
|
|
block_b_fonts = [fonts['large_bold'], fonts['medium'], fonts['small']]
|
|
block_a_colors = [params['title_color'], params['subtitle_color'], params['subtitle_color']]
|
|
block_b_colors = [params['title_color'], params['subtitle_color'], params['subtitle_color']]
|
|
|
|
block_a = TextBlock()
|
|
for i, text in enumerate(content.block_a):
|
|
if text:
|
|
block_a.lines.append((text, block_a_fonts[i], block_a_colors[i]))
|
|
if block_a.lines:
|
|
block_a.line_bboxes = [self.get_text_bbox(line[0], line[1]) for line in block_a.lines]
|
|
min_x = min(box[0] for box in block_a.line_bboxes)
|
|
max_x = max(box[2] for box in block_a.line_bboxes)
|
|
block_a.width = max_x - min_x
|
|
line_heights = [box[3] - box[1] for box in block_a.line_bboxes]
|
|
block_a.height = int(sum(line_heights) + max(0, len(line_heights) - 1) * spacing)
|
|
|
|
block_b = TextBlock()
|
|
for i, text in enumerate(content.block_b):
|
|
if text:
|
|
block_b.lines.append((text, block_b_fonts[i], block_b_colors[i]))
|
|
if block_b.lines:
|
|
block_b.line_bboxes = [self.get_text_bbox(line[0], line[1]) for line in block_b.lines]
|
|
min_x = min(box[0] for box in block_b.line_bboxes)
|
|
max_x = max(box[2] for box in block_b.line_bboxes)
|
|
block_b.width = max_x - min_x
|
|
line_heights = [box[3] - box[1] for box in block_b.line_bboxes]
|
|
block_b.height = int(sum(line_heights) + max(0, len(line_heights) - 1) * spacing)
|
|
|
|
pil_logo = None
|
|
if final_logo_name != "None":
|
|
try:
|
|
logo_filename = next((f for f in os.listdir(logos_dir) if os.path.splitext(f)[0].lower() == final_logo_name.lower()), None)
|
|
if logo_filename:
|
|
pil_logo = Image.open(os.path.join(logos_dir, logo_filename)).convert("RGBA")
|
|
pil_logo = self._crop_transparent_border(pil_logo)
|
|
except Exception as e:
|
|
print(f"无法加载内置Logo '{final_logo_name}': {e}")
|
|
|
|
if pil_logo:
|
|
bar_based_target_height = 0
|
|
if not (layout.startswith("5.") or layout.startswith("6.") or layout.startswith("7.") or layout.startswith("8.") or layout.startswith("9.")):
|
|
max_size_ref = draw_area[3] if not layout.startswith("3.") else draw_area[2]
|
|
bar_based_target_height = int(max_size_ref * 0.5)
|
|
|
|
text_based_target_height = 0
|
|
if layout in [LAYOUT_OPTIONS[0], LAYOUT_OPTIONS[9]]:
|
|
text_based_target_height = block_b.height
|
|
elif layout in [LAYOUT_OPTIONS[1], LAYOUT_OPTIONS[3]] or layout.startswith("5.") or layout.startswith("6.") or layout.startswith("7.") or layout.startswith("8.") or layout.startswith("9."):
|
|
text_based_target_height = block_a.height if block_a.height > 0 else block_b.height
|
|
|
|
base_height = max(text_based_target_height, bar_based_target_height)
|
|
final_target_height = int(base_height * params['logo_size_ratio'])
|
|
|
|
if final_target_height > 0:
|
|
original_width, original_height = pil_logo.size
|
|
if original_height > 0:
|
|
ratio = final_target_height / original_height
|
|
new_width = int(original_width * ratio)
|
|
pil_logo = pil_logo.resize((new_width, final_target_height), Image.Resampling.LANCZOS)
|
|
|
|
if layout == LAYOUT_OPTIONS[0] or layout == LAYOUT_OPTIONS[9]:
|
|
x, y, w, h = draw_area
|
|
y_a = y + (h - block_a.height) / 2
|
|
self._draw_block(draw, block_a, x + padding, y_a, spacing=spacing)
|
|
|
|
y_b = y + (h - block_b.height) / 2
|
|
if pil_logo:
|
|
right_group_width = block_b.width + spacing + pil_logo.width
|
|
start_x_right = x + w - padding - right_group_width
|
|
logo_x = start_x_right
|
|
logo_y = y + (h - pil_logo.height) / 2
|
|
draw_layer.paste(pil_logo, (int(logo_x), int(logo_y)), pil_logo)
|
|
block_b_x = logo_x + pil_logo.width + spacing
|
|
self._draw_block(draw, block_b, block_b_x, y_b, align='left', spacing=spacing)
|
|
else:
|
|
self._draw_block(draw, block_b, x + w - padding, y_b, align='right', spacing=spacing)
|
|
|
|
elif layout == LAYOUT_OPTIONS[1]:
|
|
x, y, w, h = draw_area
|
|
elements = []
|
|
if pil_logo: elements.append({'type': 'logo', 'data': pil_logo, 'height': pil_logo.height})
|
|
if block_a.lines: elements.append({'type': 'block', 'data': block_a, 'height': block_a.height})
|
|
if block_b.lines: elements.append({'type': 'block', 'data': block_b, 'height': block_b.height})
|
|
|
|
total_height = sum(el['height'] for el in elements) + max(0, len(elements) - 1) * spacing
|
|
y_start = y + (h - total_height) / 2
|
|
|
|
for el in elements:
|
|
if el['type'] == 'logo':
|
|
logo = el['data']
|
|
logo_x = x + (w - logo.width) / 2
|
|
draw_layer.paste(logo, (int(logo_x), int(y_start)), logo)
|
|
else:
|
|
self._draw_block(draw, el['data'], x + w/2, y_start, align='center', spacing=spacing)
|
|
y_start += el['height'] + spacing
|
|
|
|
elif layout == LAYOUT_OPTIONS[2]:
|
|
x, y, w, h = draw_area
|
|
center_x = x + w / 2
|
|
|
|
if block_b.lines:
|
|
y_b = y + padding
|
|
self._draw_vertical_text_block(draw_layer, block_b, center_x, y_b, spacing)
|
|
|
|
if block_a.lines:
|
|
rotated_height_a = block_a.width
|
|
y_a = y + h - padding - rotated_height_a
|
|
self._draw_vertical_text_block(draw_layer, block_a, center_x, y_a, spacing)
|
|
|
|
if pil_logo:
|
|
logo_x = center_x - pil_logo.width / 2
|
|
logo_y = y + (h - pil_logo.height) / 2
|
|
draw_layer.paste(pil_logo, (int(logo_x), int(logo_y)), pil_logo)
|
|
|
|
elif layout == LAYOUT_OPTIONS[3]:
|
|
x_top, y_top, w_top, h_top = draw_area_top
|
|
x_bot, y_bot, w_bot, h_bot = draw_area_bottom
|
|
|
|
if pil_logo:
|
|
total_top_width = pil_logo.width + spacing + block_a.width
|
|
start_x_top = x_top + (w_top - total_top_width) / 2
|
|
logo_x = start_x_top
|
|
logo_y = y_top + (h_top - pil_logo.height) / 2
|
|
draw_layer.paste(pil_logo, (int(logo_x), int(logo_y)), pil_logo)
|
|
block_a_x = start_x_top + pil_logo.width + spacing
|
|
self._draw_block(draw, block_a, block_a_x, y_top + (h_top - block_a.height) / 2, align='left', spacing=spacing)
|
|
else:
|
|
self._draw_block(draw, block_a, x_top + w_top / 2, y_top + (h_top - block_a.height) / 2, align='center', spacing=spacing)
|
|
|
|
self._draw_block(draw, block_b, x_bot + w_bot / 2, y_bot + (h_bot - block_b.height) / 2, align='center', spacing=spacing)
|
|
|
|
elif layout.startswith("5.") or layout.startswith("6.") or layout.startswith("7.") or layout.startswith("8.") or layout.startswith("9."):
|
|
x, y, w, h = draw_area
|
|
elements = []
|
|
if block_a.lines: elements.append({'type': 'block', 'data': block_a, 'height': block_a.height, 'width': block_a.width})
|
|
if block_b.lines: elements.append({'type': 'block', 'data': block_b, 'height': block_b.height, 'width': block_b.width})
|
|
if pil_logo: elements.append({'type': 'logo', 'data': pil_logo, 'height': pil_logo.height, 'width': pil_logo.width})
|
|
|
|
if elements:
|
|
total_height = sum(el['height'] for el in elements) + max(0, len(elements) - 1) * spacing
|
|
max_width = max(el['width'] for el in elements) if elements else 0
|
|
|
|
if "左下角" in layout: y_start, x_start, align = h - padding - total_height, padding, 'left'
|
|
elif "右下角" in layout: y_start, x_start, align = h - padding - total_height, w - padding, 'right'
|
|
elif "左上角" in layout: y_start, x_start, align = padding, padding, 'left'
|
|
elif "右上角" in layout: y_start, x_start, align = padding, w - padding, 'right'
|
|
else: y_start, x_start, align = (h - total_height) / 2, w / 2, 'center'
|
|
|
|
current_y = y_start
|
|
for el in elements:
|
|
el_width = el['width']
|
|
if el['type'] == 'logo':
|
|
logo = el['data']
|
|
if align == 'left': logo_x = x_start
|
|
elif align == 'right': logo_x = x_start - el_width
|
|
else: logo_x = x_start - el_width / 2
|
|
draw_layer.paste(logo, (int(logo_x), int(current_y)), logo)
|
|
else:
|
|
self._draw_block(draw, el['data'], x_start, current_y, align=align, spacing=spacing)
|
|
current_y += el['height'] + spacing
|
|
|
|
canvas.alpha_composite(draw_layer)
|
|
final_image = canvas.convert("RGB")
|
|
|
|
return (self.pil_to_tensor(final_image),)
|
|
|
|
# --- ComfyUI 节点注册 ---
|
|
NODE_CLASS_MAPPINGS = {
|
|
"CameraWatermarkNode": CameraWatermarkNode,
|
|
"ImageLoaderWithEXIF": ImageLoaderWithEXIF,
|
|
}
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"CameraWatermarkNode": "相机水印",
|
|
"ImageLoaderWithEXIF": "加载/附加 EXIF",
|
|
}
|