ComfyUI/comfy_extras/nodes_compositor.py

856 lines
32 KiB
Python

import hashlib
import json
import math
import numpy as np
import torch
from PIL import Image
from comfy_api.latest import ComfyExtension, io, UI
from comfy_extras.compositor_blend import (
_LAYER_MODES,
blend_composite,
linear_to_srgb,
placed_bounds,
resolve_mode,
srgb_to_linear,
)
from comfy_extras.color_util import hex_to_rgb
from comfy_extras.nodes_bounding_boxes import boxes_from_input
from nodes import MAX_RESOLUTION
from typing_extensions import override
MAX_LAYERS = 50
def document_items(doc) -> list[dict]:
if not isinstance(doc, dict):
return []
version = doc.get("version")
if version is not None and version != 1:
raise ValueError(f"LAYERS document version {version!r} is not supported")
items = []
for item in doc.get("layers") or []:
if not isinstance(item, dict):
continue
item_type = item.get("type", "raster")
if item_type != "raster":
raise ValueError(f"LAYERS item type {item_type!r} is not supported yet")
if not isinstance(item.get("image"), torch.Tensor):
continue
blend = item.get("blend_mode")
if blend is not None and blend not in _LAYER_MODES:
raise ValueError(f"LAYERS item blend_mode {blend!r} is not a known blend mode")
items.append(item)
return sorted(items, key=lambda item: _int(item.get("z_index"), 0))
def document_canvas(doc) -> tuple[int, int] | None:
if not isinstance(doc, dict):
return None
canvas = doc.get("canvas")
if not isinstance(canvas, (tuple, list)) or len(canvas) != 2:
return None
w, h = _int(canvas[0], 0), _int(canvas[1], 0)
return (w, h) if w > 0 and h > 0 else None
def _int(value, default: int) -> int:
return int(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else default
def _bbox_list(bboxes, canvas_width: int, canvas_height: int) -> list[dict]:
if bboxes is None:
return []
if isinstance(bboxes, str):
text = bboxes.strip()
if not text:
return []
try:
bboxes = json.loads(text)
except (json.JSONDecodeError, ValueError) as exc:
raise ValueError(f"bboxes string input is not valid JSON: {exc}") from exc
probe = bboxes if isinstance(bboxes, list) else [bboxes]
if probe and isinstance(probe[0], list):
probe = probe[0]
has_elements = any(
isinstance(box, dict) and isinstance(box.get("bbox"), (list, tuple))
for box in probe
)
if has_elements and (canvas_width <= 0 or canvas_height <= 0):
raise ValueError(
"normalized element boxes need canvas_width and canvas_height to resolve to pixels"
)
return boxes_from_input(bboxes, canvas_width, canvas_height)
def _item_mask_frame(mask, index: int) -> torch.Tensor | None:
if not isinstance(mask, torch.Tensor):
return None
if mask.shape[0] == 1:
return mask[:1]
if index < mask.shape[0]:
return mask[index : index + 1]
return None
def expand_item_frames(items: list[dict]) -> list[dict]:
frames = []
for item in items:
image = item["image"]
for index in range(image.shape[0]):
width = _int(item.get("w"), 0)
height = _int(item.get("h"), 0)
rotation = item.get("rotation")
frames.append({
"tensor": image[index : index + 1],
"mask": _item_mask_frame(item.get("mask"), index),
"name": item.get("name") if isinstance(item.get("name"), str) else None,
"x": _int(item.get("x"), 0),
"y": _int(item.get("y"), 0),
"w": width if width > 0 else int(image.shape[2]),
"h": height if height > 0 else int(image.shape[1]),
"rotation": float(rotation)
if isinstance(rotation, (int, float)) and not isinstance(rotation, bool)
else 0.0,
"opacity": item.get("opacity", 1.0),
"blend": item.get("blend_mode", "normal"),
"visible": item.get("visible", True),
"flip_h": bool(item.get("flip_h", False)),
"flip_v": bool(item.get("flip_v", False)),
})
if len(frames) > MAX_LAYERS:
raise ValueError(
f"Compositor supports at most {MAX_LAYERS} layers, got {len(frames)}"
)
return frames
def frame_alpha(
tensor: torch.Tensor, mask: torch.Tensor | None
) -> torch.Tensor | None:
alpha = tensor[:1, :, :, 3] if tensor.shape[-1] == 4 else None
if mask is None:
return alpha
h, w = tensor.shape[1], tensor.shape[2]
m = mask[:1].to(device=tensor.device, dtype=torch.float32)
if m.shape[1] != h or m.shape[2] != w:
m = torch.nn.functional.interpolate(
m.unsqueeze(1), size=(h, w), mode="bilinear"
).squeeze(1)
inv = torch.clamp(1.0 - m, 0.0, 1.0)
return inv if alpha is None else alpha * inv
def layer_preview_tensor(
tensor: torch.Tensor, alpha: torch.Tensor | None
) -> torch.Tensor:
rgb = tensor[:1, :, :, :3]
if alpha is None:
return rgb
return torch.cat([rgb, alpha.unsqueeze(-1)], dim=-1)
def canvas_extent(frames: list[dict]) -> tuple[int, int]:
right = 1
bottom = 1
for frame in frames:
bx, by, bw, bh = placed_bounds(
frame["x"], frame["y"], frame["w"], frame["h"], frame["rotation"]
)
right = max(right, bx + bw)
bottom = max(bottom, by + bh)
return (right, bottom)
def input_fingerprints(
frames: list[dict], alphas: list[torch.Tensor | None]
) -> list[str]:
fingerprints = []
for frame, alpha in zip(frames, alphas):
tensor = frame["tensor"]
rgb = tensor[0, :, :, :3].detach().cpu().numpy()
rgb8 = np.clip(np.rint(rgb * 255.0), 0, 255).astype(np.uint8)
digest = hashlib.sha256()
digest.update(repr(tuple(tensor.shape)).encode())
digest.update(rgb8.tobytes())
if alpha is not None:
alpha8 = np.clip(
np.rint(alpha[0].detach().cpu().numpy() * 255.0), 0, 255
).astype(np.uint8)
digest.update(alpha8.tobytes())
digest.update(
repr((
frame["x"],
frame["y"],
frame["w"],
frame["h"],
frame["rotation"],
frame["opacity"],
frame["blend"],
bool(frame["visible"]),
frame["flip_h"],
frame["flip_v"],
)).encode()
)
fingerprints.append(digest.hexdigest()[:16])
return fingerprints
def state_from_items(frames: list[dict], canvas: tuple[int, int]) -> dict:
layers = []
for frame in frames:
layers.append({
"name": frame["name"],
"visible": bool(frame["visible"]),
"opacity": frame["opacity"],
"blend": frame["blend"],
"flipH": frame["flip_h"],
"flipV": frame["flip_v"],
"transform": {
"x": frame["x"],
"y": frame["y"],
"w": frame["w"],
"h": frame["h"],
"rotation": frame["rotation"],
},
})
return {
"canvas": canvas,
"layers": layers,
"inputs": None,
"background": {"color": "#ffffff", "opacity": 1.0, "visible": False},
}
def layer_ui_entries(frames: list[dict]) -> list:
entries = []
for frame in frames:
entries.append({
"x": frame["x"],
"y": frame["y"],
"width": int(frame["w"]),
"height": int(frame["h"]),
"rotation": frame["rotation"],
"name": frame["name"],
"visible": bool(frame["visible"]),
"opacity": frame["opacity"] if isinstance(frame["opacity"], (int, float)) else 1.0,
"blend": frame["blend"] if isinstance(frame["blend"], str) else "normal",
"flipH": frame["flip_h"],
"flipV": frame["flip_v"],
})
return entries
_HEX_DIGITS = set("0123456789abcdef")
def _normalize_hex_color(value) -> str:
if isinstance(value, str):
text = value.strip().lower()
if text.startswith("#"):
digits = text[1:]
if len(digits) == 3 and set(digits) <= _HEX_DIGITS:
digits = "".join(ch * 2 for ch in digits)
if len(digits) == 6 and set(digits) <= _HEX_DIGITS:
return "#" + digits
return "#ffffff"
def _parse_background(entry) -> dict | None:
if not isinstance(entry, dict):
return None
return {
"color": _normalize_hex_color(entry.get("color")),
"opacity": min(max(_number(entry, "opacity", 1.0), 0.0), 1.0),
"visible": bool(entry.get("visible", True)),
}
def _parse_order(value, layer_count: int) -> list[int] | None:
if not isinstance(value, list) or not value:
return None
if not all(
isinstance(item, int) and not isinstance(item, bool) for item in value
):
return None
if sorted(value) != list(range(layer_count)):
return None
return value
def layer_state_provided(raw) -> bool:
if isinstance(raw, dict):
return bool(raw)
if isinstance(raw, str):
return raw not in ("", "{}")
return False
def parse_layer_state(raw) -> dict | None:
if isinstance(raw, str):
if not raw.strip():
return None
try:
raw = json.loads(raw)
except (json.JSONDecodeError, ValueError):
return None
if not isinstance(raw, dict):
return None
state = raw
version = state.get("version")
if version is not None and version != 1:
return None
canvas = state.get("canvas")
layers = state.get("layers")
if not isinstance(canvas, dict) or not isinstance(layers, list) or not layers:
return None
try:
w = int(round(float(canvas.get("w"))))
h = int(round(float(canvas.get("h"))))
except (TypeError, ValueError, OverflowError):
return None
if w <= 0 or h <= 0:
return None
inputs = state.get("inputs")
if (
not isinstance(inputs, list)
or len(inputs) != len(layers)
or not all(isinstance(entry, str) for entry in inputs)
):
inputs = None
return {
"canvas": (w, h),
"layers": layers,
"inputs": inputs,
"background": _parse_background(state.get("background")),
"order": _parse_order(state.get("order"), len(layers)),
}
def _number(source: dict, key: str, default: float) -> float:
value = source.get(key, default)
if not isinstance(value, (int, float)) or not math.isfinite(value):
return float(default)
return float(value)
def _clamped_size(value: float, natural: int) -> float:
return float(natural) if value <= 0 else min(value, float(MAX_RESOLUTION))
def _layer_params(entry, natural_w: int, natural_h: int) -> dict:
if not isinstance(entry, dict):
entry = {}
transform = entry.get("transform")
if not isinstance(transform, dict):
transform = {}
blend = entry.get("blend")
return {
"visible": bool(entry.get("visible", True)),
# The layer state is untrusted input: it round-trips through the saved
# workflow and can be posted directly to /prompt. An out-of-range opacity
# would otherwise reach blend_composite as a raw coverage multiplier and
# produce negative or greater-than-white RGB. _parse_background already
# clamps the same field.
"opacity": min(max(_number(entry, "opacity", 1.0), 0.0), 1.0),
"blend": blend if isinstance(blend, str) else "normal",
"x": min(max(_number(transform, "x", 0.0), -MAX_RESOLUTION), MAX_RESOLUTION),
"y": min(max(_number(transform, "y", 0.0), -MAX_RESOLUTION), MAX_RESOLUTION),
"w": _clamped_size(_number(transform, "w", natural_w), natural_w),
"h": _clamped_size(_number(transform, "h", natural_h), natural_h),
"rotation": _number(transform, "rotation", 0.0),
"flip_h": bool(entry.get("flipH", False)),
"flip_v": bool(entry.get("flipV", False)),
}
def _prepare_layer_bitmap(
tensor: torch.Tensor, params: dict, alpha: torch.Tensor | None
) -> Image.Image:
frame = tensor[0, :, :, :3].detach().cpu().numpy()
rgb8 = np.clip(np.rint(frame * 255.0), 0, 255).astype(np.uint8)
if alpha is None:
img = Image.fromarray(rgb8, "RGB").convert("RGBA")
else:
alpha8 = np.clip(
np.rint(alpha[0].detach().cpu().numpy() * 255.0), 0, 255
).astype(np.uint8)
img = Image.fromarray(np.dstack([rgb8, alpha8]), "RGBA")
if params["flip_h"]:
img = img.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
if params["flip_v"]:
img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
target = (max(1, round(params["w"])), max(1, round(params["h"])))
if img.size != target:
img = img.resize(target, Image.Resampling.LANCZOS)
if params["rotation"] != 0:
img = img.rotate(
-math.degrees(params["rotation"]),
expand=True,
resample=Image.Resampling.BICUBIC,
fillcolor=(0, 0, 0, 0),
)
return img
def _place_in_bounds(img: Image.Image, bw: int, bh: int) -> np.ndarray:
arr = np.asarray(img, dtype=np.float32) / 255.0
rgba = np.concatenate([srgb_to_linear(arr[..., :3]), arr[..., 3:4]], axis=-1)
aw, ah = img.size
buf = np.zeros((bh, bw, 4), dtype=np.float32)
ox = (bw - aw) // 2
oy = (bh - ah) // 2
dx0, dy0 = max(ox, 0), max(oy, 0)
dx1, dy1 = min(ox + aw, bw), min(oy + ah, bh)
if dx0 < dx1 and dy0 < dy1:
buf[dy0:dy1, dx0:dx1] = rgba[dy0 - oy : dy1 - oy, dx0 - ox : dx1 - ox]
return buf
def _fill_background(canvas: np.ndarray, background: dict) -> np.ndarray:
layer = np.empty(canvas.shape, dtype=np.float32)
layer[..., :3] = srgb_to_linear(
np.array(hex_to_rgb(background["color"]), dtype=np.float32) / 255.0
)
layer[..., 3] = 1.0
return blend_composite(
resolve_mode("normal"), canvas, layer, background["opacity"]
)
def composite_from_state(
tensors: list[torch.Tensor],
state: dict,
alphas: list[torch.Tensor | None],
) -> torch.Tensor:
cw, ch = state["canvas"]
if cw > MAX_RESOLUTION or ch > MAX_RESOLUTION:
raise ValueError(
f"Compositor canvas {cw}x{ch} exceeds the maximum supported size of "
f"{MAX_RESOLUTION}x{MAX_RESOLUTION}"
)
canvas = np.zeros((ch, cw, 4), dtype=np.float32)
background = state.get("background")
if background is not None and background["visible"] and background["opacity"] > 0:
canvas = _fill_background(canvas, background)
layers = state["layers"]
order = state.get("order") or range(len(tensors))
for index in order:
if index < 0 or index >= len(tensors):
continue
tensor = tensors[index]
entry = layers[index] if index < len(layers) else None
params = _layer_params(entry, tensor.shape[2], tensor.shape[1])
if not params["visible"]:
continue
img = _prepare_layer_bitmap(
tensor, params, alphas[index] if index < len(alphas) else None
)
bx, by, bw, bh = placed_bounds(
params["x"], params["y"], params["w"], params["h"], params["rotation"]
)
buf = _place_in_bounds(img, bw, bh)
x0, y0 = max(bx, 0), max(by, 0)
x1, y1 = min(bx + bw, cw), min(by + bh, ch)
if x0 >= x1 or y0 >= y1:
continue
region = buf[y0 - by : y1 - by, x0 - bx : x1 - bx]
mode = resolve_mode(params["blend"])
canvas[y0:y1, x0:x1] = blend_composite(
mode, canvas[y0:y1, x0:x1], region, params["opacity"]
)
rgb = linear_to_srgb(np.clip(canvas[..., :3], 0.0, 1.0))
alpha = np.clip(canvas[..., 3:4], 0.0, 1.0)
rgba = np.concatenate([rgb, alpha], axis=-1)
return torch.from_numpy(rgba.astype(np.float32)).unsqueeze(0)
OPAQUE_EPSILON = 1e-3
def composite_outputs(out: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
if out.shape[-1] != 4:
return out, torch.zeros(out.shape[:3], dtype=torch.float32)
alpha = out[..., 3]
if bool((alpha >= 1.0 - OPAQUE_EPSILON).all()):
return out[..., :3], torch.zeros_like(alpha)
return out, torch.clamp(1.0 - alpha, 0.0, 1.0)
class ImageCompositor(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="ImageCompositor",
display_name="Create Layered Image",
category="image",
search_aliases=["compositor", "composite", "layer", "layers", "layer editor", "psd"],
is_experimental=True,
# both flags on purpose: terminal compositor graphs must execute (the
# editor needs a run to open), and cache hits must replay the layer UI
is_output_node=True,
has_intermediate_output=True,
inputs=[
io.Layers.Input(
"layers",
tooltip="Layer stack to composite; build it with Add Layer. Items are stacked by z_index, batch frames inside an item expand to consecutive layers, and item placement, opacity, and blend mode define the initial composition. Without an explicit document canvas the size is a best-effort maximum extent of the placed layers. A saved composition that matches the current inputs takes priority.",
),
io.Compositor.Input(
"compositor",
tooltip="Layered composition saved by the compositor editor.",
),
],
outputs=[
io.Image.Output(
tooltip="Composited image. Carries an alpha channel when the composite has transparent areas (e.g. hidden background), otherwise plain RGB."
),
io.Mask.Output(
tooltip="Transparency of the composite (1 = fully transparent). All zeros when the composite is opaque."
),
],
)
@classmethod
def execute(cls, layers: io.Layers.Type, compositor: io.Compositor.Type = None) -> io.NodeOutput:
frames = expand_item_frames(document_items(layers))
tensors = [frame["tensor"] for frame in frames]
alphas = [frame_alpha(frame["tensor"], frame["mask"]) for frame in frames]
layer_refs = []
for tensor, alpha in zip(tensors, alphas):
layer_refs.extend(
UI.PreviewImage(layer_preview_tensor(tensor, alpha), cls=cls).values
)
fp = input_fingerprints(frames, alphas)
raw_state = compositor
state = parse_layer_state(raw_state)
replay = bool(state is not None and tensors and state["inputs"] == fp)
if replay:
out = composite_from_state(tensors, state, alphas)
elif tensors:
canvas = document_canvas(layers) or canvas_extent(frames)
out = composite_from_state(
tensors, state_from_items(frames, canvas), alphas
)
else:
out = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
state_stale = layer_state_provided(raw_state) and not replay
out, mask = composite_outputs(out)
ui_dict = UI.PreviewImage(out, cls=cls).as_dict()
ui_dict["compositor_layers"] = layer_refs
ui_dict["compositor_inputs"] = fp
ui_dict["compositor_bboxes"] = layer_ui_entries(frames)
if state_stale:
ui_dict["compositor_state_stale"] = [True]
return io.NodeOutput(out, mask, ui=ui_dict)
class AddLayer(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="AddLayer",
display_name="Add Layer",
category="image",
is_experimental=True,
inputs=[
io.Layers.Input(
"layers",
optional=True,
tooltip="Layer stack to append to. Leave unconnected to start a new stack.",
),
io.Image.Input(
"image",
tooltip="Layer content at its native size. A batch expands to consecutive layers.",
),
io.Mask.Input(
"mask",
optional=True,
tooltip="Transparency mask for this layer. Masked areas (value 1) become transparent, multiplying with any alpha channel the image already carries.",
),
io.String.Input(
"name",
optional=True,
default="",
tooltip="Layer name shown in the compositor editor.",
),
io.Int.Input(
"x",
optional=True,
default=0,
min=-MAX_RESOLUTION,
max=MAX_RESOLUTION,
tooltip="Initial horizontal placement on the canvas.",
),
io.Int.Input(
"y",
optional=True,
default=0,
min=-MAX_RESOLUTION,
max=MAX_RESOLUTION,
tooltip="Initial vertical placement on the canvas.",
),
io.Float.Input(
"opacity",
optional=True,
default=1.0,
min=0.0,
max=1.0,
step=0.01,
tooltip="Initial layer opacity.",
),
io.Combo.Input(
"blend_mode",
options=list(_LAYER_MODES),
default="normal",
optional=True,
tooltip="Initial blend mode, applied against the layers below. On the bottom layer over the default transparent background, non-normal modes produce transparency.",
),
io.Float.Input(
"rotation",
optional=True,
default=0.0,
min=-360.0,
max=360.0,
step=1.0,
tooltip="Initial rotation in degrees, clockwise.",
),
io.Int.Input(
"width",
optional=True,
default=0,
min=0,
max=MAX_RESOLUTION,
tooltip="Initial display width. 0 keeps the image's native width.",
),
io.Int.Input(
"height",
optional=True,
default=0,
min=0,
max=MAX_RESOLUTION,
tooltip="Initial display height. 0 keeps the image's native height.",
),
io.Int.Input(
"z_index",
optional=True,
default=0,
min=-1000,
max=1000,
tooltip="Stacking override. Layers are stable-sorted by z_index; equal values keep their list order.",
),
io.Boolean.Input(
"flip_h",
optional=True,
default=False,
tooltip="Flip the layer horizontally.",
),
io.Boolean.Input(
"flip_v",
optional=True,
default=False,
tooltip="Flip the layer vertically.",
),
],
outputs=[
io.Layers.Output(tooltip="The layer stack with this layer appended."),
],
)
@classmethod
def execute(cls, image: io.Image.Type, layers: io.Layers.Type = None, mask: io.Mask.Type = None, name: str = "", x: int = 0, y: int = 0, opacity: float = 1.0, blend_mode: str = "normal", rotation: float = 0.0, width: int = 0, height: int = 0, z_index: int = 0, flip_h: bool = False, flip_v: bool = False) -> io.NodeOutput:
item: dict = {
"image": image,
"type": "raster",
"x": int(x),
"y": int(y),
"z_index": int(z_index),
}
if mask is not None:
item["mask"] = mask
if name:
item["name"] = name
if opacity != 1.0:
item["opacity"] = float(opacity)
if blend_mode != "normal":
item["blend_mode"] = blend_mode
if rotation != 0.0:
item["rotation"] = math.radians(rotation)
if width > 0:
item["w"] = int(width)
if height > 0:
item["h"] = int(height)
if flip_h:
item["flip_h"] = True
if flip_v:
item["flip_v"] = True
previous = layers if isinstance(layers, dict) else None
document: dict = {
"version": 1,
"layers": [*(previous.get("layers") or []), item] if previous else [item],
}
previous_canvas = document_canvas(previous)
if previous_canvas:
document["canvas"] = previous_canvas
return io.NodeOutput(document)
class LayersFromBoundingBoxes(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="LayersFromBoundingBoxes",
display_name="Layers From Bounding Boxes",
category="image",
is_experimental=True,
description=(
"Turn an image batch plus its bounding boxes into a layer stack, one layer per frame, "
"each placed by its own box. Use this when a node emits layers as a batch - a batch "
"carries a single placement for every frame, so the individual positions are otherwise lost."
),
inputs=[
io.Image.Input(
"image",
tooltip="Image batch; each frame becomes one layer.",
),
io.MultiType.Input(
"bboxes",
[io.BoundingBox, io.Array, io.String],
tooltip=(
"Placement boxes, index-aligned with the image batch. Accepts bounding boxes "
"(x, y, width, height), normalized elements (with a 'bbox' - these need "
"canvas_width/canvas_height to resolve to pixels), or a JSON string of either. "
"Frames without a matching box are placed at the origin. A box's width/height "
"scales the layer to fit it. metadata.name (or desc) and metadata.z_index are "
"used when present, and metadata.content_rect (frame-relative) crops the frame "
"to its real content."
),
),
io.Mask.Input(
"mask",
optional=True,
tooltip=(
"Per-frame transparency, index-aligned with the image batch "
"(1 = transparent, LoadImage convention)."
),
),
io.Layers.Input(
"layers",
optional=True,
tooltip="Layer stack to append to. Leave unconnected to start a new stack.",
),
io.Boolean.Input(
"crop_to_content",
default=True,
optional=True,
tooltip=(
"Crop each frame to metadata.content_rect where present and place the content "
"at the box position plus the rect offset. Leave on for batches whose frames "
"are padded - it keeps only the real content at its true spot."
),
),
io.Int.Input(
"canvas_width",
default=0,
min=0,
max=MAX_RESOLUTION,
optional=True,
tooltip="Document canvas width. 0 derives it from the placed layers.",
),
io.Int.Input(
"canvas_height",
default=0,
min=0,
max=MAX_RESOLUTION,
optional=True,
tooltip="Document canvas height. 0 derives it from the placed layers.",
),
],
outputs=[
io.Layers.Output(tooltip="The layer stack, ready for Create Layered Image."),
],
)
@classmethod
def execute(
cls,
image: io.Image.Type,
bboxes: io.MultiType.Type,
mask: io.Mask.Type = None,
layers: io.Layers.Type = None,
crop_to_content: bool = True,
canvas_width: int = 0,
canvas_height: int = 0,
) -> io.NodeOutput:
boxes = _bbox_list(bboxes, canvas_width, canvas_height)
previous = layers if isinstance(layers, dict) else None
items: list[dict] = list((previous.get("layers") or []) if previous else [])
base_z = max((_int(i.get("z_index"), 0) for i in items), default=-1) + 1
for index in range(image.shape[0]):
box = boxes[index] if index < len(boxes) else {}
meta = box.get("metadata") if isinstance(box.get("metadata"), dict) else {}
frame = image[index : index + 1]
frame_mask = _item_mask_frame(mask, index)
x, y = _int(box.get("x"), 0), _int(box.get("y"), 0)
box_w, box_h = _int(box.get("width"), 0), _int(box.get("height"), 0)
cropped = False
rect = meta.get("content_rect")
if crop_to_content and isinstance(rect, (list, tuple)) and len(rect) == 4:
left, top, cw, ch = (_int(v, 0) for v in rect)
left = min(max(left, 0), int(frame.shape[2]))
top = min(max(top, 0), int(frame.shape[1]))
cw = min(max(cw, 0), int(frame.shape[2]) - left)
ch = min(max(ch, 0), int(frame.shape[1]) - top)
if cw > 0 and ch > 0:
frame = frame[:, top : top + ch, left : left + cw]
if frame_mask is not None:
frame_mask = frame_mask[:, top : top + ch, left : left + cw]
x, y = x + left, y + top
cropped = True
item: dict = {
"image": frame,
"type": "raster",
"x": x,
"y": y,
"z_index": _int(meta.get("z_index"), base_z + index),
}
if not cropped:
if box_w > 0:
item["w"] = box_w
if box_h > 0:
item["h"] = box_h
if frame_mask is not None:
item["mask"] = frame_mask
name = meta.get("name")
if not (isinstance(name, str) and name):
name = meta.get("desc")
if isinstance(name, str) and name:
item["name"] = name
items.append(item)
document: dict = {"version": 1, "layers": items}
if canvas_width > 0 and canvas_height > 0:
document["canvas"] = (canvas_width, canvas_height)
else:
inherited = document_canvas(previous)
if inherited:
document["canvas"] = inherited
return io.NodeOutput(document)
class CompositorExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[io.ComfyNode]]:
return [ImageCompositor, AddLayer, LayersFromBoundingBoxes]
async def comfy_entrypoint() -> CompositorExtension:
return CompositorExtension()