Files
1038lab-ComfyUI-RMBG/py/AILab_Canvas.py
T
2026-10-20 00:23:00 -07:00

361 lines
14 KiB
Python

import json
import os
import torch
import torchvision.transforms.functional as TF
from typing import Dict, Any, Tuple, List
from PIL import Image
import numpy as np
import folder_paths
from AILab_utils import clean_vram, get_device
def hex_to_rgba(hex_str: str) -> Tuple[float, float, float, float]:
if not isinstance(hex_str, str):
return (0.0, 0.0, 0.0, 1.0)
cleaned = hex_str.strip().lstrip("#")
if len(cleaned) == 3:
cleaned = "".join([c * 2 for c in cleaned]) + "FF"
elif len(cleaned) == 6:
cleaned = cleaned + "FF"
elif len(cleaned) != 8:
return (0.0, 0.0, 0.0, 1.0)
try:
r = int(cleaned[0:2], 16) / 255.0
g = int(cleaned[2:4], 16) / 255.0
b = int(cleaned[4:6], 16) / 255.0
a = int(cleaned[6:8], 16) / 255.0
return (r, g, b, a)
except Exception:
return (0.0, 0.0, 0.0, 1.0)
class AILab_Canvas:
@classmethod
def INPUT_TYPES(cls) -> Dict[str, Any]:
return {
"required": {
"canvas_width": (
"INT",
{
"default": 640,
"min": 64,
"max": 8192,
"step": 8,
"tooltip": "Canvas width in pixels when not fitting background image size.",
},
),
"canvas_height": (
"INT",
{
"default": 360,
"min": 64,
"max": 8192,
"step": 8,
"tooltip": "Canvas height in pixels when not fitting background image size.",
},
),
"background": (
["Alpha", "Color"],
{
"default": "Alpha",
"tooltip": "Choose background type: Alpha (transparent) or Color (custom background color).",
},
),
"background_color": (
"COLORCODE",
{
"default": "#222222",
"tooltip": "Pick background color (use color picker).",
},
),
"fit_background_size": (
"BOOLEAN",
{
"default": False,
"tooltip": "When True, canvas dimensions match the background image. When False, canvas dimensions use canvas_width and canvas_height.",
},
),
"canvas_state": (
"STRING",
{
"default": "{}",
"multiline": True,
"tooltip": "Serialized JSON layout containing layer coordinates, scales, rotations, and order.",
},
),
},
"optional": {
"Background": (
"IMAGE",
{"tooltip": "Optional background image input."},
),
"layer_1": (
"IMAGE",
{"tooltip": "Layer 1 image input."},
),
},
}
RETURN_TYPES = ("IMAGE", "MASK")
RETURN_NAMES = ("IMAGE", "MASK")
FUNCTION = "composite"
OUTPUT_NODE = True
CATEGORY = "🧪AILab/🖼️IMAGE"
def _save_images(self, slot_tensors: List[Tuple[str, torch.Tensor]]) -> List[Dict[str, Any]]:
results = []
output_dir = folder_paths.get_temp_directory()
os.makedirs(output_dir, exist_ok=True)
for slot_name, tensor in slot_tensors:
if tensor is None or not isinstance(tensor, torch.Tensor):
continue
t = tensor[0].detach().cpu()
h, w = t.shape[0], t.shape[1]
full_out_dir, base_filename, counter, subfolder, _ = folder_paths.get_save_image_path(
f"ailab_canvas_{slot_name.lower()}", output_dir, w, h
)
arr = np.clip(255.0 * t.numpy(), 0, 255).astype(np.uint8)
if arr.shape[-1] == 1:
arr = np.squeeze(arr, axis=-1)
img = Image.fromarray(arr, mode="L")
elif arr.shape[-1] == 3:
img = Image.fromarray(arr, mode="RGB")
elif arr.shape[-1] == 4:
img = Image.fromarray(arr, mode="RGBA")
else:
img = Image.fromarray(arr[..., :3], mode="RGB")
file = f"{base_filename}_{counter:05}_{slot_name}.png"
img.save(os.path.join(full_out_dir, file), compress_level=1)
results.append({
"filename": file,
"subfolder": subfolder,
"type": "temp",
"slot": slot_name,
"width": w,
"height": h,
})
return results
def composite(
self,
canvas_width: int,
canvas_height: int,
background: str = "Alpha",
background_color: str = "#222222",
fit_background_size: bool = True,
canvas_state: str = "{}",
**kwargs: Any,
):
try:
bg_tensor = kwargs.get("Background", kwargs.get("background_image", None))
if not canvas_state or canvas_state == "{}":
canvas_state = kwargs.get("canvas_state", "{}")
layer_dict = {}
for i in range(1, 6):
key = f"layer_{i}"
if key in kwargs and isinstance(kwargs[key], torch.Tensor):
layer_dict[key] = kwargs[key]
for k, v in kwargs.items():
k_clean = k.lower().replace(" ", "_")
if k_clean.startswith("layer_") and isinstance(v, torch.Tensor):
layer_dict[k_clean] = v
device = bg_tensor.device if bg_tensor is not None else (
next(iter(layer_dict.values())).device if layer_dict else get_device()
)
if bg_tensor is not None and fit_background_size:
target_h = int(bg_tensor.shape[1])
target_w = int(bg_tensor.shape[2])
else:
target_h = max(64, int(canvas_height))
target_w = max(64, int(canvas_width))
target_h = (target_h // 8) * 8
target_w = (target_w // 8) * 8
batch_size = 1
if bg_tensor is not None:
batch_size = max(batch_size, bg_tensor.shape[0])
for l_tensor in layer_dict.values():
batch_size = max(batch_size, l_tensor.shape[0])
canvas = torch.zeros((batch_size, 4, target_h, target_w), dtype=torch.float32, device=device)
fg_mask = torch.zeros((batch_size, 1, target_h, target_w), dtype=torch.float32, device=device)
if bg_tensor is not None:
bg = bg_tensor.to(device=device, dtype=torch.float32)
if bg.shape[0] < batch_size:
bg = bg.repeat(batch_size, 1, 1, 1)
if bg.shape[1] != target_h or bg.shape[2] != target_w:
bg_perm = bg.permute(0, 3, 1, 2)
bg_resized = TF.resize(bg_perm, [target_h, target_w], interpolation=TF.InterpolationMode.BILINEAR)
bg = bg_resized.permute(0, 2, 3, 1)
if bg.shape[-1] >= 4:
canvas[:, :3, :, :] = bg[..., :3].permute(0, 3, 1, 2)
canvas[:, 3:4, :, :] = bg[..., 3:4].permute(0, 3, 1, 2)
else:
canvas[:, :3, :, :] = bg[..., :3].permute(0, 3, 1, 2)
canvas[:, 3:4, :, :] = 1.0
else:
if background == "Alpha":
canvas[:, :, :, :] = 0.0
else:
r, g, b, a = hex_to_rgba(background_color)
canvas[:, 0, :, :] = r
canvas[:, 1, :, :] = g
canvas[:, 2, :, :] = b
canvas[:, 3, :, :] = a
raw_layers = []
for k in sorted(layer_dict.keys(), key=lambda x: int(x.split('_')[1]) if '_' in x and x.split('_')[1].isdigit() else 99):
v = layer_dict[k]
if v is not None and isinstance(v, torch.Tensor):
raw_layers.append((k, v))
parsed_state = {}
if canvas_state and isinstance(canvas_state, str) and canvas_state.strip():
try:
parsed_state = json.loads(canvas_state)
except Exception:
parsed_state = {}
state_layer_map = {}
for item in parsed_state.get("layers", []):
if isinstance(item, dict) and "id" in item:
state_layer_map[str(item["id"]).lower()] = item
state_width = float(parsed_state.get("canvas_width", target_w) or target_w)
state_height = float(parsed_state.get("canvas_height", target_h) or target_h)
scale_ratio_x = target_w / max(1.0, state_width)
scale_ratio_y = target_h / max(1.0, state_height)
ordered_layers = []
for layer_name, tensor in raw_layers:
layer_cfg = state_layer_map.get(layer_name.lower(), {})
z_index = int(layer_cfg.get("zIndex", layer_cfg.get("z_index", 0)))
ordered_layers.append((z_index, layer_name, tensor, layer_cfg))
ordered_layers.sort(key=lambda x: x[0])
for _, _, fg_tensor, cfg in ordered_layers:
fg = fg_tensor.to(device=device, dtype=torch.float32)
if fg.shape[0] < batch_size:
fg = fg.repeat(batch_size, 1, 1, 1)
orig_h, orig_w = int(fg.shape[1]), int(fg.shape[2])
if fg.shape[-1] >= 4:
fg_rgb = fg[..., :3].permute(0, 3, 1, 2)
fg_a = fg[..., 3:4].permute(0, 3, 1, 2)
else:
fg_rgb = fg[..., :3].permute(0, 3, 1, 2)
fg_a = torch.ones((batch_size, 1, orig_h, orig_w), dtype=torch.float32, device=device)
opacity = float(cfg.get("opacity", 1.0))
opacity = max(0.0, min(1.0, opacity))
fg_a = fg_a * opacity
flip_x = bool(cfg.get("flipX", cfg.get("flip_x", False)))
flip_y = bool(cfg.get("flipY", cfg.get("flip_y", False)))
if flip_x:
fg_rgb = torch.flip(fg_rgb, dims=[3])
fg_a = torch.flip(fg_a, dims=[3])
if flip_y:
fg_rgb = torch.flip(fg_rgb, dims=[2])
fg_a = torch.flip(fg_a, dims=[2])
max_w = target_w * 0.85
max_h = target_h * 0.85
if orig_w > max_w or orig_h > max_h:
default_scale = min(max_w / max(1, orig_w), max_h / max(1, orig_h))
else:
default_scale = 1.0
scale = float(cfg.get("scaleX", cfg.get("scale_x", cfg.get("scaleY", default_scale))))
scale_x = scale
scale_y = scale
new_w = max(1, int(round(orig_w * scale_x * scale_ratio_x)))
new_h = max(1, int(round(orig_h * scale_y * scale_ratio_y)))
fg_rgba = torch.cat([fg_rgb, fg_a], dim=1)
if new_w != orig_w or new_h != orig_h:
fg_rgba = TF.resize(fg_rgba, [new_h, new_w], interpolation=TF.InterpolationMode.BILINEAR)
angle = float(cfg.get("angle", 0.0))
if abs(angle) > 1e-3:
fg_rgba = TF.rotate(fg_rgba, angle=-angle, expand=True, interpolation=TF.InterpolationMode.BILINEAR)
rot_h, rot_w = fg_rgba.shape[2], fg_rgba.shape[3]
if "x" in cfg and "y" in cfg:
raw_cx = float(cfg["x"]) * scale_ratio_x
raw_cy = float(cfg["y"]) * scale_ratio_y
else:
raw_cx = target_w / 2.0
raw_cy = target_h / 2.0
x0 = int(round(raw_cx - rot_w / 2.0))
y0 = int(round(raw_cy - rot_h / 2.0))
x1 = x0 + rot_w
y1 = y0 + rot_h
dst_x0, dst_y0 = max(0, x0), max(0, y0)
dst_x1, dst_y1 = min(target_w, x1), min(target_h, y1)
src_x0 = max(0, -x0)
src_y0 = max(0, -y0)
src_x1 = src_x0 + (dst_x1 - dst_x0)
src_y1 = src_y0 + (dst_y1 - dst_y0)
if dst_x1 > dst_x0 and dst_y1 > dst_y0:
fg_patch = fg_rgba[:, :, src_y0:src_y1, src_x0:src_x1]
bg_patch = canvas[:, :, dst_y0:dst_y1, dst_x0:dst_x1]
p_fg_rgb = fg_patch[:, :3]
p_fg_a = fg_patch[:, 3:4]
p_bg_rgb = bg_patch[:, :3]
p_bg_a = bg_patch[:, 3:4]
out_a = p_fg_a + p_bg_a * (1.0 - p_fg_a)
out_rgb = torch.where(
out_a > 1e-6,
(p_fg_rgb * p_fg_a + p_bg_rgb * p_bg_a * (1.0 - p_fg_a)) / out_a,
torch.zeros_like(p_fg_rgb),
)
canvas[:, :3, dst_y0:dst_y1, dst_x0:dst_x1] = out_rgb
canvas[:, 3:4, dst_y0:dst_y1, dst_x0:dst_x1] = out_a
curr_fg_a = fg_mask[:, :, dst_y0:dst_y1, dst_x0:dst_x1]
fg_mask[:, :, dst_y0:dst_y1, dst_x0:dst_x1] = p_fg_a + curr_fg_a * (1.0 - p_fg_a)
if background == "Alpha" and (bg_tensor is None or bg_tensor.shape[-1] >= 4):
out_image = canvas.permute(0, 2, 3, 1).clamp(0.0, 1.0)
else:
out_image = canvas[:, :3, :, :].permute(0, 2, 3, 1).clamp(0.0, 1.0)
out_mask = fg_mask.squeeze(1).clamp(0.0, 1.0)
slot_tensors = [("Background", bg_tensor)]
for i in range(1, 6):
slot_tensors.append((f"layer_{i}", layer_dict.get(f"layer_{i}")))
ui_images = self._save_images(slot_tensors)
return {
"ui": {"images": ui_images},
"result": (out_image.cpu(), out_mask.cpu()),
}
finally:
clean_vram()
NODE_CLASS_MAPPINGS = {
"AILab_Canvas": AILab_Canvas,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"AILab_Canvas": "Canvas (RMBG)",
}