feat: 新增去背景/PSD分层导出节点,优化聊天面板与重启逻辑

- 新增 O1keyRemoveBackground 节点(基于 rembg CPU 推理)
- 新增 O1keyColorRemoveBG 节点(颜色距离去背景,支持多模式)
- 新增 O1keySavePSD 节点(手写 PSD 二进制,零外部依赖)
- GPT Image 批量输出不同尺寸时自动 resize 对齐
- 聊天面板大幅增强(多模态/交互优化)
- 重启按钮绕过 beforeunload 弹窗强制刷新
- 默认路由切换为 CF加速
- http_error 新增 system error 友好文案

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
o1key
2026-05-26 18:05:07 +08:00
co-authored by Claude Opus 4.7
parent c974df1b5e
commit 3f0f4099fb
11 changed files with 706 additions and 22 deletions
+4 -1
View File
@@ -28,5 +28,8 @@ from .K3_video import K3Video
from .K3_video_firstlast import K3VideoFirstLast
from .K3_motion_control import K3MotionControl, K3MotionVideoCheck
from .save_image_format import SaveImageFormat
from .save_psd import O1keySavePSD
from .remove_bg import O1keyRemoveBackground
from .color_remove_bg import O1keyColorRemoveBG
__all__ = ['NanoBananaV2', 'NanoBananaV2Batch', 'NanoBanana', 'BatchNanoBananaPro', 'GoogleGemini', 'LoadFile', 'ImageStitchPro', 'BatchCleanMetadata', 'VideoPreview', 'KlingVideo', 'KlingFirstLastFrame', 'KlingMotionControlTest', 'AspectRatioPreset', 'GoogleVeo', 'FluxImageEdit', 'UniversalLLMChat', 'BatchImagesO1key', 'Seedance', 'SeedanceMultiModal', 'StreamPreview', 'DoubaoImage', 'O1keyGPTImage', 'O1keyGrokImage', 'KVideoFirstLast', 'KVideoImage2Video', 'K3Video', 'K3VideoFirstLast', 'K3MotionControl', 'K3MotionVideoCheck', 'AsyncImageGenerator', 'BatchAsyncImageGenerator', 'SaveImageFormat']
__all__ = ['NanoBananaV2', 'NanoBananaV2Batch', 'NanoBanana', 'BatchNanoBananaPro', 'GoogleGemini', 'LoadFile', 'ImageStitchPro', 'BatchCleanMetadata', 'VideoPreview', 'KlingVideo', 'KlingFirstLastFrame', 'KlingMotionControlTest', 'AspectRatioPreset', 'GoogleVeo', 'FluxImageEdit', 'UniversalLLMChat', 'BatchImagesO1key', 'Seedance', 'SeedanceMultiModal', 'StreamPreview', 'DoubaoImage', 'O1keyGPTImage', 'O1keyGrokImage', 'KVideoFirstLast', 'KVideoImage2Video', 'K3Video', 'K3VideoFirstLast', 'K3MotionControl', 'K3MotionVideoCheck', 'AsyncImageGenerator', 'BatchAsyncImageGenerator', 'SaveImageFormat', 'O1keySavePSD', 'O1keyRemoveBackground', 'O1keyColorRemoveBG']
+93
View File
@@ -0,0 +1,93 @@
"""
o1key 颜色去背景节点
基于颜色距离计算,精确可控,不依赖 AI 模型
"""
import numpy as np
import torch
from PIL import Image
class O1keyColorRemoveBG:
"""
颜色去背景 - 精确移除纯色背景
模式说明:
- 白色(white): 移除白色背景,适合大多数场景
- 白色保护(white-preserve): 移除白底但保护浅色前景物体
- 自动检测(corner): 自动采样四角颜色作为背景色
- 指定颜色(color): 手动指定要移除的背景颜色
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"模式": (["白色", "白色保护", "自动检测", "指定颜色"], {
"default": "白色",
}),
"容差": ("FLOAT", {
"default": 8.0,
"min": 0.0,
"max": 100.0,
"step": 1.0,
"tooltip": "颜色距离阈值,越大去除范围越广",
}),
"羽化": ("FLOAT", {
"default": 45.0,
"min": 0.0,
"max": 200.0,
"step": 1.0,
"tooltip": "边缘过渡范围,越大边缘越柔和",
}),
},
"optional": {
"背景色R": ("INT", {"default": 255, "min": 0, "max": 255}),
"背景色G": ("INT", {"default": 255, "min": 0, "max": 255}),
"背景色B": ("INT", {"default": 255, "min": 0, "max": 255}),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("RGBA图像",)
FUNCTION = "remove_bg"
CATEGORY = "o1key/image"
_MODE_MAP = {
"白色": "white",
"白色保护": "white-preserve",
"自动检测": "corner",
"指定颜色": "color",
}
def remove_bg(self, image, 模式, 容差, 羽化, 背景色R=255, 背景色G=255, 背景色B=255):
from ..utils.color_key import remove_background
mode = self._MODE_MAP.get(模式, "white")
bg_color = (背景色R, 背景色G, 背景色B)
batch_size = image.shape[0]
results = []
for i in range(batch_size):
frame = image[i] # [H, W, C]
arr = (frame.cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
if arr.shape[2] == 4:
pil_img = Image.fromarray(arr, mode="RGBA")
else:
pil_img = Image.fromarray(arr, mode="RGB")
result = remove_background(
pil_img, mode=mode, bg_color=bg_color,
tolerance=容差, feather=羽化,
)
result_arr = np.array(result.convert("RGBA")).astype(np.float32) / 255.0
results.append(torch.from_numpy(result_arr))
output = torch.stack(results, dim=0)
print(f"[o1key 颜色去背景] 模式={模式}, 容差={容差}, 羽化={羽化}, "
f"处理 {batch_size}")
return (output,)
+36
View File
@@ -0,0 +1,36 @@
"""
o1key 去背景节点
基于 rembg 实现,支持 CPU 推理
"""
import numpy as np
import torch
class O1keyRemoveBackground:
"""
移除图像背景,输出 RGBA 透明图层
基于 rembg (ISNet-General-Use) 模型,支持 CPU 推理。
首次运行会自动下载模型(约 170MB)。
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("RGBA图像",)
FUNCTION = "remove_bg"
CATEGORY = "o1key/image"
def remove_bg(self, image):
from ..utils.rembg_utils import remove_background_tensor
print("[o1key 去背景] 正在处理...")
result = remove_background_tensor(image)
print(f"[o1key 去背景] 完成,输出 {result.shape[0]} 张 RGBA")
return (result,)
+237
View File
@@ -0,0 +1,237 @@
"""
o1key SavePSD 节点
将多个 IMAGE 图层合成为分层 PSD 文件
手写 PSD 二进制格式,零外部依赖(仅 numpy + Pillow
"""
import os
import struct
import time
import numpy as np
import torch
from PIL import Image
import folder_paths
def _pad_even(data: bytes) -> bytes:
if len(data) % 2:
return data + b"\x00"
return data
def _pad4(data: bytes) -> bytes:
return data + (b"\x00" * ((4 - (len(data) % 4)) % 4))
def _pascal_name(name: str) -> bytes:
raw = name.encode("macroman", errors="replace")[:255]
data = bytes([len(raw)]) + raw
return _pad4(data)
def _unicode_name_block(name: str) -> bytes:
payload = struct.pack(">I", len(name)) + name.encode("utf-16be")
block = b"8BIM" + b"luni" + struct.pack(">I", len(payload)) + _pad_even(payload)
return block
def _layer_extra_data(name: str) -> bytes:
data = b""
data += struct.pack(">I", 0) # layer mask data length
data += struct.pack(">I", 0) # layer blending ranges length
data += _pascal_name(name)
data += _unicode_name_block(name)
return data
def _alpha_bbox(rgba_arr: np.ndarray):
"""找到 RGBA 数组中非透明区域的 bounding box。"""
alpha = rgba_arr[:, :, 3]
rows = np.any(alpha > 0, axis=1)
cols = np.any(alpha > 0, axis=0)
if not rows.any():
return None
top = int(np.argmax(rows))
bottom = int(len(rows) - np.argmax(rows[::-1]))
left = int(np.argmax(cols))
right = int(len(cols) - np.argmax(cols[::-1]))
return top, left, bottom, right
def write_psd(filepath: str, layers: list, canvas_w: int, canvas_h: int):
"""
写入 PSD 文件。
layers: [(name, rgba_array), ...] 从底到顶排列
rgba_array: numpy uint8 [H, W, 4]
"""
records = []
channel_data_blocks = []
layers_top_to_bottom = list(reversed(layers))
for name, rgba in layers_top_to_bottom:
bbox = _alpha_bbox(rgba)
if not bbox:
continue
top, left, bottom, right = bbox
cropped = rgba[top:bottom, left:right]
# PLACEHOLDER_CHANNELS
channels = [
(0, cropped[:, :, 0].tobytes(order="C")),
(1, cropped[:, :, 1].tobytes(order="C")),
(2, cropped[:, :, 2].tobytes(order="C")),
(-1, cropped[:, :, 3].tobytes(order="C")),
]
channel_info = b""
data_block = b""
for channel_id, data in channels:
channel_info += struct.pack(">hI", channel_id, 2 + len(data))
data_block += struct.pack(">H", 0) + data # raw compression
extra = _layer_extra_data(name)
record = b""
record += struct.pack(">iiii", top, left, bottom, right)
record += struct.pack(">H", len(channels))
record += channel_info
record += b"8BIM" + b"norm"
record += bytes([255, 0, 0, 0]) # opacity=255, clipping, flags, filler
record += struct.pack(">I", len(extra)) + extra
records.append(record)
channel_data_blocks.append(data_block)
if not records:
raise ValueError("所有图层均为空(完全透明),无法生成 PSD")
# Layer and Mask Information
layer_info = struct.pack(">h", len(records))
layer_info += b"".join(records) + b"".join(channel_data_blocks)
layer_info = _pad_even(layer_info)
layer_info_block = struct.pack(">I", len(layer_info)) + layer_info
global_mask = struct.pack(">I", 0)
layer_mask_payload = layer_info_block + global_mask
layer_and_mask = struct.pack(">I", len(layer_mask_payload)) + layer_mask_payload
# PLACEHOLDER_COMPOSITE
# Composite preview (flattened image for compatibility)
comp = Image.new("RGBA", (canvas_w, canvas_h), (255, 255, 255, 255))
for name, rgba in layers:
layer_img = Image.fromarray(rgba, "RGBA")
comp.alpha_composite(layer_img)
comp_rgb = np.asarray(comp.convert("RGB"), dtype=np.uint8)
composite_data = (
struct.pack(">H", 0)
+ comp_rgb[:, :, 0].tobytes(order="C")
+ comp_rgb[:, :, 1].tobytes(order="C")
+ comp_rgb[:, :, 2].tobytes(order="C")
)
# Write PSD file
with open(filepath, "wb") as f:
# Header
f.write(b"8BPS")
f.write(struct.pack(">H", 1)) # version
f.write(b"\x00" * 6) # reserved
f.write(struct.pack(">HIIHH", 3, canvas_h, canvas_w, 8, 3))
# Color Mode Data
f.write(struct.pack(">I", 0))
# Image Resources
f.write(struct.pack(">I", 0))
# Layer and Mask
f.write(layer_and_mask)
# Composite Image Data
f.write(composite_data)
# PLACEHOLDER_NODE
class O1keySavePSD:
"""
将多个 IMAGE 输入合成为分层 PSD 文件
每个输入作为独立图层,支持 RGBA 透明通道。
图层从下到上排列(图层1在最底部)。
使用 bbox 裁剪优化文件大小,包含合成预览层。
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"批次图像": ("IMAGE", {
"tooltip": "批次图像输入,每张图自动作为独立图层(支持RGBA透明)",
}),
},
"optional": {
"图层名称": ("STRING", {
"default": "",
"multiline": True,
"tooltip": "每行一个图层名称,与图层顺序对应。留空则自动命名。",
}),
"文件名前缀": ("STRING", {
"default": "o1key_layers",
"tooltip": "输出 PSD 文件名前缀",
}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("文件路径",)
FUNCTION = "save_psd"
CATEGORY = "o1key/image"
OUTPUT_NODE = True
def save_psd(self, 批次图像, 图层名称: str = "", 文件名前缀: str = "o1key_layers", **kwargs):
# 将批次 tensor [B, H, W, C] 拆为单张列表
if 批次图像.dim() == 3:
layer_tensors = [批次图像]
else:
layer_tensors = [批次图像[i] for i in range(批次图像.shape[0])]
names = [n.strip() for n in 图层名称.split("\n") if n.strip()]
# 确定画布尺寸
max_h, max_w = 0, 0
for t in layer_tensors:
h, w = t.shape[0], t.shape[1]
max_h = max(max_h, h)
max_w = max(max_w, w)
# 转换为 [(name, rgba_array), ...] 格式
layers = []
for idx, tensor in enumerate(layer_tensors):
arr = (tensor.cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
h, w = arr.shape[0], arr.shape[1]
channels = arr.shape[2] if arr.ndim == 3 else 1
if channels == 3:
rgba = np.zeros((max_h, max_w, 4), dtype=np.uint8)
rgba[:h, :w, :3] = arr
rgba[:h, :w, 3] = 255
elif channels == 4:
rgba = np.zeros((max_h, max_w, 4), dtype=np.uint8)
rgba[:h, :w] = arr
else:
rgba = np.zeros((max_h, max_w, 4), dtype=np.uint8)
rgba[:h, :w, 0] = rgba[:h, :w, 1] = rgba[:h, :w, 2] = arr[:, :, 0] if arr.ndim == 3 else arr
rgba[:h, :w, 3] = 255
name = names[idx] if idx < len(names) else f"图层 {idx + 1}"
layers.append((name, rgba))
print(f"[o1key SavePSD] 图层 '{name}': {w}×{h}")
# 写入 PSD
output_dir = folder_paths.get_output_directory()
timestamp = time.strftime("%Y%m%d_%H%M%S")
filename = f"{文件名前缀}_{timestamp}.psd"
filepath = os.path.join(output_dir, filename)
write_psd(filepath, layers, max_w, max_h)
size_kb = os.path.getsize(filepath) / 1024
print(f"[o1key SavePSD] 完成: {filepath} ({size_kb:.0f}KB, "
f"{len(layers)} 层, {max_w}×{max_h})")
return (filepath,)