Files
2026-10-01 15:44:54 +08:00

545 lines
16 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
工具函数模块
提供图像处理、URL解析、数据转换等通用功能
"""
import io
import requests
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from typing import Optional, Union, List, Tuple
import torch
import re
try:
from .http_client import (
TimeoutValue,
create_http_session,
describe_request_error,
normalize_timeout,
)
except ImportError:
from http_client import (
TimeoutValue,
create_http_session,
describe_request_error,
normalize_timeout,
)
def download_image(
url: str,
timeout: TimeoutValue = 30,
raise_on_error: bool = False,
) -> Optional[Image.Image]:
"""
从URL下载图像
Args:
url: 图像URL
timeout: 超时时间(秒)
Returns:
PIL.Image对象,如果下载失败返回None
"""
headers = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 ComfyUI-GrsAI/1.1.5"
)
}
session = create_http_session(headers=headers)
try:
with session.get(
url,
stream=True,
timeout=normalize_timeout(timeout),
) as response:
response.raise_for_status()
content_type = response.headers.get("Content-Type", "未知")
image_buffer = io.BytesIO()
for chunk in response.iter_content(chunk_size=1024 * 1024):
if chunk:
image_buffer.write(chunk)
if image_buffer.tell() == 0:
raise ValueError("服务器返回了空文件")
image_buffer.seek(0)
try:
image = Image.open(image_buffer)
image.load()
return image
except Exception as exc:
raise ValueError(
f"服务器返回的内容不是有效图片 (Content-Type: {content_type})"
) from exc
except requests.RequestException as exc:
error_message = describe_request_error(exc, "图像下载")
except Exception as exc:
error_message = f"图像下载失败:{exc}"
finally:
session.close()
print(f"❌ {error_message} | URL: {url}")
if raise_on_error:
raise RuntimeError(error_message)
return None
def _load_preview_font(size: int, bold: bool = False) -> ImageFont.ImageFont:
"""加载跨平台、尽可能支持中文的预览字体。"""
font_names = (
[
"/System/Library/Fonts/PingFang.ttc",
"/System/Library/Fonts/STHeiti Medium.ttc",
"/Library/Fonts/Arial Unicode.ttf",
"C:/Windows/Fonts/msyhbd.ttc",
"C:/Windows/Fonts/msyh.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
]
if bold
else [
"/System/Library/Fonts/PingFang.ttc",
"/System/Library/Fonts/STHeiti Light.ttc",
"/Library/Fonts/Arial Unicode.ttf",
"C:/Windows/Fonts/msyh.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
]
)
fallback_names = (
["Arial Bold.ttf", "DejaVuSans-Bold.ttf"]
if bold
else ["Arial.ttf", "DejaVuSans.ttf"]
)
for font_name in [*font_names, *fallback_names]:
try:
return ImageFont.truetype(font_name, size=size)
except (OSError, ValueError):
continue
try:
return ImageFont.load_default(size=size)
except TypeError:
return ImageFont.load_default()
def _wrap_preview_text(
draw: ImageDraw.ImageDraw,
text: str,
font: ImageFont.ImageFont,
max_width: int,
max_lines: int,
) -> List[str]:
"""按实际像素宽度换行,长错误文本会截断并显示省略号。"""
normalized = re.sub(r"\s+", " ", str(text)).strip()
if not normalized:
return ["未提供具体错误信息"]
lines: List[str] = []
current = ""
truncated = False
for char in normalized:
candidate = current + char
box = draw.textbbox((0, 0), candidate, font=font)
if current and box[2] - box[0] > max_width:
lines.append(current.rstrip())
current = char.lstrip()
if len(lines) == max_lines:
truncated = True
break
else:
current = candidate
if len(lines) < max_lines and current:
lines.append(current.rstrip())
if truncated and lines:
ellipsis = "…"
last_line = lines[-1]
while last_line:
candidate = last_line.rstrip() + ellipsis
box = draw.textbbox((0, 0), candidate, font=font)
if box[2] - box[0] <= max_width:
lines[-1] = candidate
break
last_line = last_line[:-1]
if not last_line:
lines[-1] = ellipsis
return lines
def create_generation_failed_image(
width: int = 1024,
height: int = 1024,
error_message: str = "",
) -> Image.Image:
"""创建包含实际失败原因、适合在 ComfyUI 中预览的提示图。"""
width = max(320, int(width))
height = max(320, int(height))
image = Image.new("RGB", (width, height), (35, 24, 24))
draw = ImageDraw.Draw(image)
margin = max(24, min(width, height) // 12)
line_width = max(8, min(width, height) // 80)
red = (220, 68, 68)
draw.rounded_rectangle(
(margin, margin, width - margin, height - margin),
radius=max(16, margin // 2),
outline=red,
width=line_width,
)
center_x = width // 2
cross_center_y = margin + max(72, min(width, height) // 7)
cross_size = max(38, min(width, height) // 13)
draw.line(
(
center_x - cross_size,
cross_center_y - cross_size,
center_x + cross_size,
cross_center_y + cross_size,
),
fill=red,
width=line_width * 2,
)
draw.line(
(
center_x + cross_size,
cross_center_y - cross_size,
center_x - cross_size,
cross_center_y + cross_size,
),
fill=red,
width=line_width * 2,
)
scale = min(width, height) / 1024
title_font = _load_preview_font(max(32, round(54 * scale)), bold=True)
label_font = _load_preview_font(max(26, round(36 * scale)), bold=True)
reason_font = _load_preview_font(max(24, round(32 * scale)))
title = "生成失败 / GENERATION FAILED"
title_box = draw.textbbox((0, 0), title, font=title_font)
if title_box[2] - title_box[0] > width - 2 * margin:
title = "GENERATION FAILED"
title_box = draw.textbbox((0, 0), title, font=title_font)
if title_box[2] - title_box[0] > width - 2 * margin:
title = "FAILED"
title_box = draw.textbbox((0, 0), title, font=title_font)
title_y = cross_center_y + cross_size + max(28, round(34 * scale))
draw.text(
((width - (title_box[2] - title_box[0])) // 2, title_y),
title,
fill=(255, 225, 225),
font=title_font,
)
content_left = margin + max(24, round(28 * scale))
content_width = width - 2 * content_left
label_y = title_y + (title_box[3] - title_box[1]) + max(34, round(48 * scale))
draw.text(
(content_left, label_y),
"失败原因:",
fill=(245, 170, 170),
font=label_font,
)
label_box = draw.textbbox((0, 0), "失败原因:", font=label_font)
reason_y = label_y + (label_box[3] - label_box[1]) + max(16, round(20 * scale))
line_spacing = max(10, round(12 * scale))
line_box = draw.textbbox((0, 0), "Ag中文", font=reason_font)
line_height = max(1, line_box[3] - line_box[1]) + line_spacing
available_height = max(line_height, height - margin - reason_y)
max_lines = max(1, available_height // line_height)
reason_lines = _wrap_preview_text(
draw,
error_message or "图像生成失败,服务未提供具体错误信息",
reason_font,
content_width,
max_lines,
)
draw.multiline_text(
(content_left, reason_y),
"\n".join(reason_lines),
fill=(245, 225, 225),
font=reason_font,
spacing=line_spacing,
)
return image
def tensor_to_pil(tensor: torch.Tensor) -> List[Image.Image]:
"""将torch张量(B, H, W, C)转换为PIL图像列表,支持RGBA透明通道"""
if not isinstance(tensor, torch.Tensor):
return []
images = []
for i in range(tensor.shape[0]):
# [H, W, C]
img_tensor = tensor[i]
# 确保值在[0, 1]范围内
img_tensor = torch.clamp(img_tensor, 0, 1)
# 转换为numpy数组并缩放到[0, 255]
img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8)
# 根据通道数创建相应格式的PIL图像
if img_tensor.shape[2] == 4: # RGBA
images.append(Image.fromarray(img_np, "RGBA"))
else: # RGB
images.append(Image.fromarray(img_np, "RGB"))
return images
def handle_transparent_background(
image: Image.Image, background_color: Tuple[int, int, int] = (0, 0, 0)
) -> Image.Image:
"""
处理透明背景的图像
Args:
image: PIL图像对象
background_color: 背景颜色RGB元组,默认为黑色(0, 0, 0)
Returns:
处理后的RGB图像
"""
if image.mode == "RGBA":
# 创建指定颜色的背景
background = Image.new("RGB", image.size, background_color)
# 使用alpha通道进行合成
image = Image.alpha_composite(background.convert("RGBA"), image).convert("RGB")
elif image.mode != "RGB":
image = image.convert("RGB")
return image
def pil_to_tensor(
pil_images: Union[Image.Image, List[Image.Image]],
background_color: Union[Tuple[int, int, int], bool, None] = None,
preserve_transparency: Optional[bool] = None,
) -> torch.Tensor:
"""
将单个PIL图像或PIL图像列表转换为ComfyUI图像张量
Args:
pil_images: PIL图像或PIL图像列表
background_color: 向后兼容参数 - 透明背景替换颜色,如果为tuple则不保留透明度
preserve_transparency: 是否保留透明度信息,默认为True(除非指定了background_color)
Returns:
ComfyUI张量格式的图像
"""
# 向后兼容性处理
if background_color is not None and isinstance(background_color, tuple):
# 旧API调用:指定了背景颜色,不保留透明度
preserve_transparency = False
bg_color = background_color
else:
# 新API调用:默认保留透明度
if preserve_transparency is None:
preserve_transparency = True
bg_color = (0, 0, 0) # 默认黑色背景
if not isinstance(pil_images, list):
pil_images = [pil_images]
processed_images: List[Image.Image] = []
for pil_image in pil_images:
# 如果保留透明度且图像有alpha通道,则保持RGBA格式
if preserve_transparency and pil_image.mode == "RGBA":
# 保持RGBA格式
processed_image = pil_image
elif preserve_transparency and pil_image.mode in ("LA", "P"):
# 将其他带透明度的格式转换为RGBA
processed_image = pil_image.convert("RGBA")
else:
# 对于其他情况,转换为RGB(保持原有行为)
if pil_image.mode == "RGBA":
processed_image = handle_transparent_background(pil_image, bg_color)
elif pil_image.mode != "RGB":
processed_image = pil_image.convert("RGB")
else:
processed_image = pil_image
processed_images.append(processed_image)
if not processed_images:
# 如果列表为空,返回一个空的占位符张量
channels = (
4
if (pil_images and pil_images[0].mode == "RGBA" and preserve_transparency)
else 3
)
return torch.empty((0, 1, 1, channels), dtype=torch.float32)
# 批次内若存在多种尺寸/通道,统一对齐到最大尺寸 + 最大通道数
# 通过“居中填充”而非缩放,避免内容失真;
# RGBA 用透明填充,RGB 用 bg_color 填充。
max_w = max(img.width for img in processed_images)
max_h = max(img.height for img in processed_images)
has_rgba = any(img.mode == "RGBA" for img in processed_images)
target_mode = "RGBA" if has_rgba else "RGB"
if target_mode == "RGBA":
fill_color: Tuple[int, ...] = (0, 0, 0, 0)
else:
fill_color = bg_color
aligned_images: List[Image.Image] = []
for img in processed_images:
if img.mode != target_mode:
img = img.convert(target_mode)
if img.size == (max_w, max_h):
aligned_images.append(img)
continue
canvas = Image.new(target_mode, (max_w, max_h), fill_color)
offset = ((max_w - img.width) // 2, (max_h - img.height) // 2)
if target_mode == "RGBA":
canvas.paste(img, offset, img)
else:
canvas.paste(img, offset)
aligned_images.append(canvas)
tensors = []
for img in aligned_images:
img_array = np.array(img).astype(np.float32) / 255.0
tensor = torch.from_numpy(img_array)[None,]
tensors.append(tensor)
return torch.cat(tensors, dim=0)
def tensor_to_base64(tensor: torch.Tensor, image_format: str = "png") -> str:
"""
将ComfyUI图像张量转换为Base64编码的字符串
Args:
tensor: ComfyUI图像张量
image_format: 图像格式 ('png' or 'jpeg')
Returns:
str: Base64编码的字符串
"""
import base64
pil_images = tensor_to_pil(tensor)
if not pil_images:
raise ValueError("无法从张量转换为PIL图像")
# 使用第一个图像进行Base64编码
pil_image = pil_images[0]
buffered = io.BytesIO()
# 根据指定的格式保存
if image_format.lower() == "jpeg":
pil_image.save(buffered, format="JPEG")
else:
pil_image.save(buffered, format="PNG")
base64_string = base64.b64encode(buffered.getvalue()).decode("utf-8")
return base64_string
def validate_aspect_ratio(aspect_ratio: str) -> bool:
"""
验证宽高比格式是否正确
Args:
aspect_ratio: 宽高比字符串,如 "16:9"
Returns:
bool: 格式是否正确
"""
pattern = r"^\d+:\d+$"
return bool(re.match(pattern, aspect_ratio))
def calculate_dimensions(aspect_ratio: str, base_size: int = 1024) -> Tuple[int, int]:
"""
根据宽高比计算图像尺寸
Args:
aspect_ratio: 宽高比字符串,如 "16:9"
base_size: 基础尺寸
Returns:
Tuple[int, int]: (宽度, 高度)
"""
try:
width_ratio, height_ratio = map(int, aspect_ratio.split(":"))
# 计算实际尺寸,保持总像素数接近base_size^2
total_ratio = width_ratio * height_ratio
scale = (base_size * base_size / total_ratio) ** 0.5
width = int(width_ratio * scale)
height = int(height_ratio * scale)
# 确保尺寸是8的倍数(AI模型通常需要)
width = (width // 8) * 8
height = (height // 8) * 8
return width, height
except:
return base_size, base_size
def format_error_message(error: Exception, context: str = "") -> str:
"""
格式化错误消息,提供用户友好的错误信息
Args:
error: 异常对象
context: 错误上下文
Returns:
str: 格式化的错误消息
"""
error_type = type(error).__name__
error_msg = str(error)
if context:
return f"[{context}] {error_type}: {error_msg}"
else:
return f"{error_type}: {error_msg}"
def safe_filename(filename: str) -> str:
"""
生成安全的文件名,移除特殊字符
Args:
filename: 原始文件名
Returns:
str: 安全的文件名
"""
# 移除或替换不安全的字符
safe_chars = re.sub(r'[<>:"/\\|?*]', "_", filename)
# 限制长度
if len(safe_chars) > 100:
safe_chars = safe_chars[:100]
return safe_chars
def bytes_to_mb(bytes_size: int) -> float:
"""
将字节转换为MB
Args:
bytes_size: 字节大小
Returns:
float: MB大小
"""
return bytes_size / (1024 * 1024)