Files
orion4d-Orion4D_layer_manager/layer_manager_node.py
T
2026-07-10 19:49:35 +02:00

587 lines
21 KiB
Python

from __future__ import annotations
import json
import math
import os
import uuid
from typing import Any, Tuple
import numpy as np
import torch
from PIL import Image
NODE_ID = "Orion4D_LayerManager"
DISPLAY_NAME = "🧩 Layer Manager"
CATEGORY = "Orion4D_Layer"
MAX_LAYERS = 20
def _first_image(img_tensor: torch.Tensor | None) -> torch.Tensor | None:
if img_tensor is None:
return None
t = img_tensor.detach()
if t.dim() == 4:
t = t[0]
if t.dim() != 3:
return None
return t
def tensor_image_to_pil_rgba(img_tensor: torch.Tensor | None) -> Image.Image | None:
t = _first_image(img_tensor)
if t is None:
return None
arr = (t.clamp(0, 1).cpu().numpy() * 255.0).round().astype(np.uint8)
if arr.ndim != 3:
return None
c = arr.shape[-1]
if c == 1:
rgb = np.repeat(arr, 3, axis=2)
alpha = np.full((*arr.shape[:2], 1), 255, dtype=np.uint8)
arr = np.concatenate([rgb, alpha], axis=2)
elif c == 2:
gray = np.repeat(arr[:, :, :1], 3, axis=2)
alpha = arr[:, :, 1:2]
arr = np.concatenate([gray, alpha], axis=2)
elif c == 3:
alpha = np.full((*arr.shape[:2], 1), 255, dtype=np.uint8)
arr = np.concatenate([arr, alpha], axis=2)
else:
arr = arr[:, :, :4]
return Image.fromarray(arr, mode="RGBA")
def tensor_mask_to_pil_l(mask_tensor: torch.Tensor | None, width: int, height: int, invert: bool = False) -> Image.Image | None:
if mask_tensor is None:
return None
mt = mask_tensor.detach()
if mt.dim() == 4:
mt = mt[0]
if mt.dim() == 3:
if mt.shape[-1] == 1:
mt = mt[:, :, 0]
else:
mt = mt[0]
if mt.dim() != 2:
return None
arr = mt.clamp(0, 1).cpu().numpy().astype(np.float32)
if invert:
arr = 1.0 - arr
arr = (arr * 255.0).round().clip(0, 255).astype(np.uint8)
pil = Image.fromarray(arr, mode="L")
if pil.size != (width, height):
pil = pil.resize((width, height), Image.BICUBIC)
return pil
def apply_external_mask_to_rgba(layer_rgba: Image.Image, mask_tensor: torch.Tensor | None, invert_mask: bool) -> Image.Image:
mask = tensor_mask_to_pil_l(mask_tensor, layer_rgba.width, layer_rgba.height, invert=invert_mask)
if mask is None:
return layer_rgba
arr = np.asarray(layer_rgba, dtype=np.uint8).copy()
mask_arr = np.asarray(mask, dtype=np.float32) / 255.0
arr[:, :, 3] = (arr[:, :, 3].astype(np.float32) * mask_arr).round().clip(0, 255).astype(np.uint8)
return Image.fromarray(arr, mode="RGBA")
def pil_rgba_to_tensors(pil_img: Image.Image) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
rgba = np.asarray(pil_img.convert("RGBA"), dtype=np.float32) / 255.0
rgb = torch.from_numpy(rgba[:, :, :3])[None, ...]
rgba_t = torch.from_numpy(rgba)[None, ...]
alpha = torch.from_numpy(rgba[:, :, 3])[None, ...]
return rgb, rgba_t, alpha
def pil_rgb_tensor(pil_img: Image.Image) -> torch.Tensor:
arr = np.asarray(pil_img.convert("RGB"), dtype=np.float32) / 255.0
return torch.from_numpy(arr)[None, ...]
def parse_bg(bg_hex: str) -> tuple[int, int, int, int]:
if not isinstance(bg_hex, str):
return 255, 255, 255, 255
s = bg_hex.strip()
if s.lower() in {"transparent", "none", "alpha", "rgba"}:
return 0, 0, 0, 0
hx = s.lstrip("#")
if len(hx) == 3:
hx = "".join(ch * 2 for ch in hx) + "FF"
elif len(hx) == 4:
hx = "".join(ch * 2 for ch in hx)
elif len(hx) == 6:
hx += "FF"
if len(hx) != 8:
return 255, 255, 255, 255
try:
return tuple(int(hx[i:i+2], 16) for i in (0, 2, 4, 6)) # type: ignore[return-value]
except ValueError:
return 255, 255, 255, 255
def visible_to_bool(value: Any, default: bool = True) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
v = value.strip().lower()
if v in {"false", "0", "hidden", "hide", "off", "no", "disabled"}:
return False
if v in {"true", "1", "visible", "show", "on", "yes", "enabled"}:
return True
return default
return bool(value)
def normalize_anchor(anchor: Any) -> str:
if not isinstance(anchor, str):
return "top_left"
value = anchor.strip().lower()
mapping = {
"haut_gauche": "top_left", "top_left": "top_left", "topleft": "top_left", "upper_left": "top_left",
"haut_droite": "top_right", "top_right": "top_right", "topright": "top_right", "upper_right": "top_right",
"bas_gauche": "bottom_left", "bottom_left": "bottom_left", "bottomleft": "bottom_left", "lower_left": "bottom_left",
"bas_droite": "bottom_right", "bottom_right": "bottom_right", "bottomright": "bottom_right", "lower_right": "bottom_right",
"centre": "center", "center": "center", "middle": "center",
}
return mapping.get(value, "top_left")
def normalize_blend_mode(mode: Any) -> str:
if not isinstance(mode, str):
return "normal"
value = mode.strip().lower().replace(" ", "_")
allowed = {
"normal", "multiply", "screen", "overlay", "darken", "lighten",
"add", "subtract", "difference", "soft_light", "hard_light",
}
return value if value in allowed else "normal"
def _default_layer() -> dict[str, Any]:
return {
"name": "", "x": 0, "y": 0, "scale": 100.0, "scale_x": 100.0, "scale_y": 100.0,
"constrain_homothety": True, "collapsed": False, "rot": 0.0, "opacity": 1.0,
"visible": True, "edit": False, "anchor": "top_left", "blend": "normal",
}
def parse_layer_config(layer_config: Any, kwargs: dict[str, Any]) -> dict[int, dict[str, Any]]:
data: dict[str, Any] = {}
if isinstance(layer_config, str) and layer_config.strip():
try:
data = json.loads(layer_config)
except Exception:
data = {}
elif isinstance(layer_config, dict):
data = layer_config
out = {}
layers_data = data.get("layers")
# Extraction depuis le JSON (Indexé en base 1 désormais)
if isinstance(layers_data, list):
for idx, layer in enumerate(layers_data):
if layer and isinstance(layer, dict) and idx >= 1:
out[idx] = {
"x": layer.get("x", 0),
"y": layer.get("y", 0),
"scale": layer.get("scale", 100.0),
"scale_x": layer.get("scale_x", layer.get("scale", 100.0)),
"scale_y": layer.get("scale_y", layer.get("scale", 100.0)),
"constrain_homothety": layer.get("constrain_homothety", True),
"collapsed": layer.get("collapsed", False),
"rot": layer.get("rot", 0.0),
"opacity": layer.get("opacity", 1.0),
"visible": visible_to_bool(layer.get("visible", True), True),
"edit": visible_to_bool(layer.get("edit", False), False),
"anchor": normalize_anchor(layer.get("anchor", "top_left")),
"blend": normalize_blend_mode(layer.get("blend", "normal")),
}
# Fallback sur les kwargs
for i in range(1, MAX_LAYERS + 1):
p = f"L{i}_"
if i not in out:
if f"{p}image" in kwargs or f"{p}x" in kwargs:
out[i] = {
"x": kwargs.get(f"{p}x", 0),
"y": kwargs.get(f"{p}y", 0),
"scale": kwargs.get(f"{p}scale", 100.0),
"scale_x": kwargs.get(f"{p}scale_x", kwargs.get(f"{p}scale", 100.0)),
"scale_y": kwargs.get(f"{p}scale_y", kwargs.get(f"{p}scale", 100.0)),
"constrain_homothety": kwargs.get(f"{p}constrain_homothety", True),
"collapsed": kwargs.get(f"{p}collapsed", False),
"rot": kwargs.get(f"{p}rot", 0.0),
"opacity": kwargs.get(f"{p}opacity", 1.0),
"visible": visible_to_bool(kwargs.get(f"{p}visible", True), True),
"edit": visible_to_bool(kwargs.get(f"{p}edit", False), False),
"anchor": normalize_anchor(kwargs.get(f"{p}anchor", "top_left")),
"blend": normalize_blend_mode(kwargs.get(f"{p}blend", "normal")),
}
else:
out[i] = _default_layer()
return out
def parse_layer_order(layer_config: Any) -> list[int]:
data: dict[str, Any] = {}
if isinstance(layer_config, str) and layer_config.strip():
try:
parsed = json.loads(layer_config)
if isinstance(parsed, dict):
data = parsed
except Exception:
data = {}
elif isinstance(layer_config, dict):
data = layer_config
order: list[int] = []
raw_order = data.get("order", [])
if isinstance(raw_order, list):
for item in raw_order:
try:
idx = int(item)
except Exception:
continue
if 1 <= idx <= MAX_LAYERS and idx not in order:
order.append(idx)
for idx in range(1, MAX_LAYERS + 1):
if idx not in order:
order.append(idx)
return order
def parse_solo_layer(layer_config: Any) -> int | None:
data: dict[str, Any] = {}
if isinstance(layer_config, str) and layer_config.strip():
try:
parsed = json.loads(layer_config)
if isinstance(parsed, dict):
data = parsed
except Exception:
data = {}
elif isinstance(layer_config, dict):
data = layer_config
try:
index = int(data.get("solo_layer"))
except Exception:
return None
return index if 1 <= index <= MAX_LAYERS else None
def anchor_point(width: float, height: float, anchor: Any) -> tuple[float, float]:
anchor = normalize_anchor(anchor)
points = {
"top_left": (0.0, 0.0),
"top_right": (float(width), 0.0),
"bottom_left": (0.0, float(height)),
"bottom_right": (float(width), float(height)),
"center": (float(width) / 2.0, float(height) / 2.0),
}
return points[anchor]
def _rotated_bounds(width: float, height: float, anchor_xy: tuple[float, float], rot_deg: float) -> tuple[float, float, float, float]:
ax, ay = anchor_xy
theta = math.radians(-float(rot_deg))
cos_t = math.cos(theta)
sin_t = math.sin(theta)
corners = [(0.0, 0.0), (float(width), 0.0), (float(width), float(height)), (0.0, float(height))]
xs, ys = [], []
for x, y in corners:
dx, dy = x - ax, y - ay
xs.append(ax + dx * cos_t - dy * sin_t)
ys.append(ay + dx * sin_t + dy * cos_t)
return min(xs), min(ys), max(xs), max(ys)
def transform_layer_with_anchor(
pil_img: Image.Image,
scale_x_percent: float,
scale_y_percent: float,
rot_deg: float,
anchor: Any,
) -> tuple[Image.Image, tuple[float, float]]:
scale_x = max(float(scale_x_percent) / 100.0, 0.001)
scale_y = max(float(scale_y_percent) / 100.0, 0.001)
if abs(scale_x - 1.0) > 1e-8 or abs(scale_y - 1.0) > 1e-8:
new_w = max(1, int(round(pil_img.width * scale_x)))
new_h = max(1, int(round(pil_img.height * scale_y)))
pil_img = pil_img.resize((new_w, new_h), Image.BICUBIC)
ax, ay = anchor_point(pil_img.width, pil_img.height, anchor)
min_x, min_y, _, _ = _rotated_bounds(pil_img.width, pil_img.height, (ax, ay), float(rot_deg))
if abs(float(rot_deg)) > 1e-8:
pil_img = pil_img.rotate(-float(rot_deg), expand=True, resample=Image.BICUBIC, center=(ax, ay))
return pil_img, (ax - min_x, ay - min_y)
def transform_layer_canvas_exact(
pil_img: Image.Image,
canvas_width: int,
canvas_height: int,
x: float,
y: float,
scale_x_percent: float,
scale_y_percent: float,
rot_deg: float,
anchor: Any,
) -> Image.Image:
"""Render a layer with the same transform order as the JS preview canvas.
Frontend drawing order is:
translate(x, y) -> rotate(rot) -> scale(scale_x, scale_y) -> drawImage(-anchor_x, -anchor_y)
The previous backend implementation resized first and then used PIL.rotate(expand=True).
That is close for simple cases, but it can drift from the browser preview when several
rotated/scaled layers are stacked. This affine transform maps destination canvas pixels
back to source image pixels, matching the Canvas 2D transform directly.
"""
canvas_width = int(canvas_width)
canvas_height = int(canvas_height)
scale_x = max(float(scale_x_percent) / 100.0, 0.001)
scale_y = max(float(scale_y_percent) / 100.0, 0.001)
ax, ay = anchor_point(pil_img.width, pil_img.height, anchor)
theta = math.radians(float(rot_deg))
cos_t = math.cos(theta)
sin_t = math.sin(theta)
x = float(x)
y = float(y)
# PIL Image.transform expects the inverse affine matrix:
# source_x = a * dst_x + b * dst_y + c
# source_y = d * dst_x + e * dst_y + f
matrix = (
cos_t / scale_x,
sin_t / scale_x,
ax - (cos_t * x + sin_t * y) / scale_x,
-sin_t / scale_y,
cos_t / scale_y,
ay + (sin_t * x - cos_t * y) / scale_y,
)
try:
resample = Image.Resampling.BICUBIC
except AttributeError:
resample = Image.BICUBIC
return pil_img.transform(
(canvas_width, canvas_height),
Image.AFFINE,
matrix,
resample=resample,
fillcolor=(0, 0, 0, 0),
)
def _blend_rgb(base_rgb: np.ndarray, src_rgb: np.ndarray, mode: str) -> np.ndarray:
mode = normalize_blend_mode(mode)
if mode == "normal":
return src_rgb
if mode == "multiply":
return base_rgb * src_rgb
if mode == "screen":
return 1.0 - (1.0 - base_rgb) * (1.0 - src_rgb)
if mode == "overlay":
return np.where(base_rgb <= 0.5, 2.0 * base_rgb * src_rgb, 1.0 - 2.0 * (1.0 - base_rgb) * (1.0 - src_rgb))
if mode == "darken":
return np.minimum(base_rgb, src_rgb)
if mode == "lighten":
return np.maximum(base_rgb, src_rgb)
if mode == "add":
return np.clip(base_rgb + src_rgb, 0.0, 1.0)
if mode == "subtract":
return np.clip(base_rgb - src_rgb, 0.0, 1.0)
if mode == "difference":
return np.abs(base_rgb - src_rgb)
if mode == "soft_light":
return np.clip((1.0 - 2.0 * src_rgb) * (base_rgb ** 2) + 2.0 * src_rgb * base_rgb, 0.0, 1.0)
if mode == "hard_light":
return np.where(src_rgb <= 0.5, 2.0 * base_rgb * src_rgb, 1.0 - 2.0 * (1.0 - base_rgb) * (1.0 - src_rgb))
return src_rgb
def blend_rgba_onto_canvas(canvas_rgba: Image.Image, layer_rgba: Image.Image, top_left_x: int, top_left_y: int, blend_mode: str) -> None:
canvas_arr = np.asarray(canvas_rgba, dtype=np.float32) / 255.0
layer_arr = np.asarray(layer_rgba, dtype=np.float32) / 255.0
h, w = layer_arr.shape[:2]
x0 = max(0, int(top_left_x))
y0 = max(0, int(top_left_y))
x1 = min(canvas_arr.shape[1], int(top_left_x) + w)
y1 = min(canvas_arr.shape[0], int(top_left_y) + h)
if x0 >= x1 or y0 >= y1:
return
lx0, ly0 = x0 - int(top_left_x), y0 - int(top_left_y)
lx1, ly1 = lx0 + (x1 - x0), ly0 + (y1 - y0)
base = canvas_arr[y0:y1, x0:x1, :].copy()
src = layer_arr[ly0:ly1, lx0:lx1, :].copy()
base_rgb, base_a = base[:, :, :3], base[:, :, 3:4]
src_rgb, src_a = src[:, :, :3], src[:, :, 3:4]
blended_rgb = _blend_rgb(base_rgb, src_rgb, normalize_blend_mode(blend_mode))
effective_rgb = src_rgb * (1.0 - base_a) + blended_rgb * base_a
out_a = src_a + base_a * (1.0 - src_a)
premul_rgb = effective_rgb * src_a + base_rgb * base_a * (1.0 - src_a)
out_rgb = np.divide(premul_rgb, np.maximum(out_a, 1e-6))
canvas_arr[y0:y1, x0:x1, :] = np.clip(np.concatenate([out_rgb, out_a], axis=2), 0.0, 1.0)
result = Image.fromarray((canvas_arr * 255.0).round().clip(0, 255).astype(np.uint8), mode="RGBA")
canvas_rgba.paste(result)
def apply_opacity(layer_rgba: Image.Image, opacity: float) -> Image.Image:
opacity = float(np.clip(opacity, 0.0, 1.0))
if opacity >= 0.999:
return layer_rgba
arr = np.asarray(layer_rgba, dtype=np.uint8).copy()
arr[:, :, 3] = (arr[:, :, 3].astype(np.float32) * opacity).round().clip(0, 255).astype(np.uint8)
return Image.fromarray(arr, mode="RGBA")
def composite_rgba_over_bg(pil_img: Image.Image, bg_rgba: tuple[int, int, int, int]) -> Image.Image:
src = np.asarray(pil_img.convert("RGBA"), dtype=np.float32) / 255.0
bg = np.array(bg_rgba, dtype=np.float32) / 255.0
bg_rgb = bg[:3].reshape(1, 1, 3)
src_rgb = src[:, :, :3]
src_a = src[:, :, 3:4]
out_rgb = src_rgb * src_a + bg_rgb * (1.0 - src_a)
out = np.concatenate([out_rgb, np.ones_like(src_a)], axis=2)
return Image.fromarray((out * 255.0).round().clip(0, 255).astype(np.uint8), mode="RGBA")
def _temp_directory() -> str:
try:
import folder_paths # type: ignore
return folder_paths.get_temp_directory()
except Exception:
return os.path.join(os.getcwd(), "temp")
def save_layer_cache_png(layer_rgba: Image.Image, layer_index: int) -> dict[str, Any]:
temp_dir = _temp_directory()
os.makedirs(temp_dir, exist_ok=True)
filename = f"orion4d_layer_cache_{uuid.uuid4().hex}_L{layer_index}.png"
path = os.path.join(temp_dir, filename)
layer_rgba.save(path)
return {
"layer": int(layer_index),
"filename": filename,
"subfolder": "",
"type": "temp",
}
def composite_layers_impl(canvas_width: int, canvas_height: int, bg_hex: str, flatten_output: bool, invert_input_masks: bool, layer_config: Any = "{}", **kwargs: Any) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, list[dict[str, Any]]]:
canvas_width = int(np.clip(int(canvas_width), 16, 8192))
canvas_height = int(np.clip(int(canvas_height), 16, 8192))
canvas = Image.new("RGBA", (canvas_width, canvas_height), (0, 0, 0, 0))
layer_cache: list[dict[str, Any]] = []
layers_cfg = parse_layer_config(layer_config, kwargs)
solo_layer = parse_solo_layer(layer_config)
# Bottom-to-top order can be changed from the external Layer Manager app.
for i in parse_layer_order(layer_config):
if solo_layer is not None and i != solo_layer:
continue
layer = layers_cfg.get(i, {})
if not visible_to_bool(layer.get("visible", True)):
continue
layer_rgba = tensor_image_to_pil_rgba(kwargs.get(f"L{i}_image"))
if layer_rgba is None:
continue
layer_rgba = apply_external_mask_to_rgba(layer_rgba, kwargs.get(f"L{i}_mask"), invert_mask=bool(invert_input_masks))
try:
layer_cache.append(save_layer_cache_png(layer_rgba, i))
except Exception:
pass
sx = layer.get("scale_x", layer.get("scale", 100.0))
sy = layer.get("scale_y", layer.get("scale", 100.0))
if visible_to_bool(layer.get("constrain_homothety", True)):
sx = sy = layer.get("scale", sx)
layer_rgba = apply_opacity(layer_rgba, layer.get("opacity", 1.0))
transformed = transform_layer_canvas_exact(
layer_rgba,
canvas_width,
canvas_height,
layer.get("x", 0),
layer.get("y", 0),
sx,
sy,
layer.get("rot", 0.0),
layer.get("anchor", "top_left"),
)
blend_rgba_onto_canvas(canvas, transformed, 0, 0, layer.get("blend", "normal"))
straight_rgb, image_rgba, alpha = pil_rgba_to_tensors(canvas)
if flatten_output:
image_bg = pil_rgb_tensor(composite_rgba_over_bg(canvas, parse_bg(bg_hex)))
else:
image_bg = straight_rgb
return image_bg, image_rgba, alpha, layer_cache
class LayerManagerNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"canvas_width": ("INT", {"default": 1024, "min": 16, "max": 8192, "step": 1}),
"canvas_height": ("INT", {"default": 1024, "min": 16, "max": 8192, "step": 1}),
"bg_hex": ("STRING", {"default": "#FFFFFF", "multiline": False}),
"flatten_output": ("BOOLEAN", {"default": True}),
"invert_input_masks": ("BOOLEAN", {"default": False}),
"layer_config": ("STRING", {"default": "{}", "multiline": True}),
},
"optional": {
"L1_image": ("IMAGE",),
"L1_mask": ("MASK",),
# Les ports L2, L3 s'ajouteront dynamiquement par kwargs.
},
}
RETURN_TYPES = ("IMAGE", "IMAGE", "MASK")
RETURN_NAMES = ("image_bg", "image_rgba", "alpha")
FUNCTION = "composite_layers"
CATEGORY = CATEGORY
@classmethod
def VALIDATE_INPUTS(cls, **kwargs):
return True
def composite_layers(self, canvas_width, canvas_height, bg_hex, flatten_output=True, invert_input_masks=False, layer_config="{}", **kwargs):
image_bg, image_rgba, alpha, layer_cache = composite_layers_impl(
canvas_width,
canvas_height,
bg_hex,
flatten_output,
invert_input_masks,
layer_config,
**kwargs,
)
return {
"ui": {"orion_layer_cache": layer_cache},
"result": (image_bg, image_rgba, alpha),
}
NODE_CLASS_MAPPINGS = {NODE_ID: LayerManagerNode}
NODE_DISPLAY_NAME_MAPPINGS = {NODE_ID: DISPLAY_NAME}