[Partner Nodes] feat: ImageCompositor node with layer-state compositing, layer from bbox and Seedream Layer Separation node (#15317)
This commit is contained in:
parent
6db4fa2fcd
commit
8fadc7b5be
|
|
@ -847,6 +847,61 @@ class Load3DAnimation(Load3D):
|
|||
...
|
||||
|
||||
|
||||
@comfytype(io_type="LAYERS")
|
||||
class Layers(ComfyTypeIO):
|
||||
BlendMode = Literal[
|
||||
"normal", "multiply", "screen", "overlay", "darken", "lighten",
|
||||
"color-dodge", "color-burn", "hard-light", "soft-light", "difference",
|
||||
"exclusion", "linear-dodge", "linear-burn", "vivid-light", "pin-light",
|
||||
"linear-light", "hard-mix", "subtract", "divide", "grain-extract",
|
||||
"grain-merge", "hue", "saturation", "color", "luminosity",
|
||||
]
|
||||
|
||||
class LayerItem(TypedDict):
|
||||
image: torch.Tensor
|
||||
type: Literal["raster"]
|
||||
x: NotRequired[int]
|
||||
y: NotRequired[int]
|
||||
mask: NotRequired[torch.Tensor]
|
||||
z_index: int
|
||||
name: NotRequired[str]
|
||||
opacity: NotRequired[float]
|
||||
blend_mode: NotRequired["Layers.BlendMode"]
|
||||
visible: NotRequired[bool]
|
||||
flip_h: NotRequired[bool]
|
||||
flip_v: NotRequired[bool]
|
||||
rotation: NotRequired[float]
|
||||
w: NotRequired[int]
|
||||
h: NotRequired[int]
|
||||
|
||||
class Document(TypedDict):
|
||||
version: int
|
||||
canvas: NotRequired[tuple[int, int]]
|
||||
layers: list["Layers.LayerItem"]
|
||||
|
||||
Type = Document
|
||||
|
||||
|
||||
@comfytype(io_type="COMPOSITOR")
|
||||
class Compositor(ComfyTypeIO):
|
||||
class LayerState(TypedDict):
|
||||
version: NotRequired[int]
|
||||
canvas: dict
|
||||
background: NotRequired[dict]
|
||||
inputs: NotRequired[list[str]]
|
||||
order: NotRequired[list[int]]
|
||||
layers: list[dict]
|
||||
|
||||
Type = LayerState
|
||||
|
||||
class Input(WidgetInput):
|
||||
def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None,
|
||||
socketless: bool=True, default: dict=None, advanced: bool=None):
|
||||
super().__init__(id, display_name, optional, tooltip, None, default, socketless, None, None, None, None, advanced)
|
||||
if default is None:
|
||||
self.default = {}
|
||||
|
||||
|
||||
@comfytype(io_type="PHOTOMAKER")
|
||||
class Photomaker(ComfyTypeIO):
|
||||
Type = Any
|
||||
|
|
@ -2403,6 +2458,8 @@ __all__ = [
|
|||
"Load3DModelInfo",
|
||||
"Load3D",
|
||||
"Load3DAnimation",
|
||||
"Compositor",
|
||||
"Layers",
|
||||
"Photomaker",
|
||||
"Point",
|
||||
"FaceAnalysis",
|
||||
|
|
|
|||
|
|
@ -35,6 +35,23 @@ class Seedream4TaskCreationRequest(BaseModel):
|
|||
optimize_prompt_options: Seedream5OptimizePromptOptions | None = None
|
||||
|
||||
|
||||
class Seedream5LayerOptimizePromptOptions(BaseModel):
|
||||
mode: Literal["standard", "fast"] = Field(...)
|
||||
|
||||
|
||||
class Seedream5LayerSeparationRequest(BaseModel):
|
||||
model: str = Field(...)
|
||||
prompt: str | None = Field(None)
|
||||
image: str = Field(..., description="Single image URL")
|
||||
size: str = Field("auto")
|
||||
seed: int = Field(..., ge=0, le=2147483647)
|
||||
response_format: str = Field("url")
|
||||
output_format: str = Field("png")
|
||||
layer_decomposition: bool = Field(True)
|
||||
watermark: bool = Field(False)
|
||||
optimize_prompt_options: Seedream5LayerOptimizePromptOptions | None = Field(None)
|
||||
|
||||
|
||||
class ImageTaskCreationResponse(BaseModel):
|
||||
model: str = Field(...)
|
||||
created: int = Field(..., description="Unix timestamp (in seconds) indicating time when the request was created.")
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
|
|
@ -34,6 +35,8 @@ from comfy_api_nodes.apis.bytedance import (
|
|||
SeedanceVirtualLibraryCreateAssetRequest,
|
||||
Seedream4Options,
|
||||
Seedream4TaskCreationRequest,
|
||||
Seedream5LayerOptimizePromptOptions,
|
||||
Seedream5LayerSeparationRequest,
|
||||
Seedream5OptimizePromptOptions,
|
||||
TaskAudioContent,
|
||||
TaskAudioContentUrl,
|
||||
|
|
@ -75,6 +78,7 @@ from comfy_api_nodes.util import (
|
|||
validate_video_dimensions,
|
||||
validate_video_duration,
|
||||
)
|
||||
from comfy_api_nodes.util.common_exceptions import ProcessingInterrupted
|
||||
from server import PromptServer
|
||||
|
||||
BYTEPLUS_IMAGE_ENDPOINT = "/proxy/byteplus/api/v3/images/generations"
|
||||
|
|
@ -96,6 +100,8 @@ SEEDREAM_PRESETS = {
|
|||
"seedream-4-0-250828": RECOMMENDED_PRESETS_SEEDREAM_4_0,
|
||||
}
|
||||
|
||||
SEEDREAM_LAYER_SEPARATION_MODEL = "seedream-5-0-pro-260628"
|
||||
|
||||
# Long-running tasks endpoints(e.g., video)
|
||||
BYTEPLUS_TASK_ENDPOINT = "/proxy/byteplus/api/v3/contents/generations/tasks"
|
||||
BYTEPLUS_TASK_STATUS_ENDPOINT = "/proxy/byteplus/api/v3/contents/generations/tasks" # + /{task_id}
|
||||
|
|
@ -1044,6 +1050,369 @@ class ByteDanceSeedreamNodeV2(IO.ComfyNode):
|
|||
return IO.NodeOutput(torch.cat([await download_url_to_image_tensor(i) for i in urls]))
|
||||
|
||||
|
||||
class ByteDanceSeedreamLayerSeparationNode(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="ByteDanceSeedreamLayerSeparationNode",
|
||||
display_name="ByteDance Seedream 5.0 Pro Layer Separation",
|
||||
category="partner/image/ByteDance",
|
||||
search_aliases=["layer separation", "split layers", "decompose", "cutout", "RGBA layers"],
|
||||
description=(
|
||||
"Decompose an image into a background plate plus up to 16 repositionable transparent layers, "
|
||||
"each with stacking order, bounding box, name and description."
|
||||
),
|
||||
inputs=[
|
||||
IO.Image.Input(
|
||||
"image",
|
||||
tooltip=(
|
||||
"The image to separate. Exactly one image, at least 512x512 pixels, aspect ratio "
|
||||
"between 1:16 and 16:1. Inputs larger than about 4MP are downscaled before upload."
|
||||
),
|
||||
),
|
||||
IO.String.Input(
|
||||
"prompt",
|
||||
multiline=True,
|
||||
default="",
|
||||
tooltip=(
|
||||
"How to separate the image. Leave empty to auto-detect and separate all major elements. "
|
||||
"Describe elements in natural language to control the separation, or target exact regions "
|
||||
"with <bbox>left top right bottom</bbox> tags (0-1000 per-mille coordinates)."
|
||||
),
|
||||
),
|
||||
IO.Combo.Input(
|
||||
"size",
|
||||
options=["auto", "1K", "1.5K", "2K"],
|
||||
default="auto",
|
||||
tooltip="Output resolution level. 'auto' follows the input image size (clamped to the 1K-2K range).",
|
||||
),
|
||||
IO.Int.Input(
|
||||
"seed",
|
||||
default=0,
|
||||
min=0,
|
||||
max=2147483647,
|
||||
step=1,
|
||||
display_mode=IO.NumberDisplay.number,
|
||||
control_after_generate=True,
|
||||
tooltip="Seed to use for generation.",
|
||||
),
|
||||
IO.Combo.Input(
|
||||
"prompt_optimization",
|
||||
options=["standard", "fast"],
|
||||
default="standard",
|
||||
optional=True,
|
||||
advanced=True,
|
||||
tooltip="Prompt-optimization mode: 'standard' gives higher quality, 'fast' shorter generation time.",
|
||||
),
|
||||
IO.Boolean.Input(
|
||||
"watermark",
|
||||
default=False,
|
||||
optional=True,
|
||||
advanced=True,
|
||||
tooltip='Whether to add an "AI generated" watermark to the images.',
|
||||
),
|
||||
IO.Boolean.Input(
|
||||
"crop_layers",
|
||||
default=False,
|
||||
optional=True,
|
||||
label_on="minimal size",
|
||||
label_off="full canvas",
|
||||
tooltip=(
|
||||
"Geometry of the layers/masks batch outputs (layer_stack is unaffected and always "
|
||||
"tight). Full canvas: each layer on a base-sized canvas at its bounding-box position - "
|
||||
"recompose directly with ImageCompositeMasked. Minimal size: each layer cropped to its "
|
||||
"bounding box (padded to the largest layer for batching) - much smaller tensors; "
|
||||
"rebuild placement with Layers From Bounding Boxes using the bboxes output."
|
||||
),
|
||||
),
|
||||
],
|
||||
outputs=[
|
||||
IO.Image.Output(
|
||||
display_name="base_image",
|
||||
tooltip="The base image (background plate) the layers stack onto.",
|
||||
),
|
||||
IO.Mask.Output(
|
||||
display_name="base_mask",
|
||||
tooltip=(
|
||||
"Transparency of the base image (1 = transparent, LoadImage convention); currently "
|
||||
"always fully opaque."
|
||||
),
|
||||
),
|
||||
IO.Image.Output(
|
||||
display_name="layers",
|
||||
tooltip=(
|
||||
"Transparent layers ordered bottom to top. Full canvas mode: placed on a black "
|
||||
"base-sized canvas at their bounding-box position. Minimal size mode: cropped to "
|
||||
"their bounding box, anchored top-left, padded to the largest layer."
|
||||
),
|
||||
),
|
||||
IO.Mask.Output(
|
||||
display_name="masks",
|
||||
tooltip=(
|
||||
"Per-layer transparency, index-aligned with the layers batch (1 = transparent, "
|
||||
"LoadImage convention). For ImageCompositeMasked-style compositing, add InvertMask first."
|
||||
),
|
||||
),
|
||||
IO.BoundingBox.Output(
|
||||
display_name="bboxes",
|
||||
tooltip=(
|
||||
"One placement box per layer, index-aligned with the layers batch (feed both, plus "
|
||||
"masks, into Layers From Bounding Boxes to rebuild per-layer placement): {x, y, width, "
|
||||
"height, metadata: {name, desc, z_index, native_size, content_rect, flags}}. "
|
||||
"content_rect = [left, top, width, height] is the layer's content region within its "
|
||||
"own frame; it lands on the canvas at the box position plus that offset."
|
||||
),
|
||||
),
|
||||
IO.Layers.Output(
|
||||
display_name="layer_stack",
|
||||
tooltip=(
|
||||
"Ready-to-edit layer document for Create Layered Image: the base plate plus each "
|
||||
"element as its own named, tight-cropped layer at its true position and stacking "
|
||||
"order. Connect directly, or extend with Add Layer."
|
||||
),
|
||||
),
|
||||
],
|
||||
hidden=[
|
||||
IO.Hidden.auth_token_comfy_org,
|
||||
IO.Hidden.api_key_comfy_org,
|
||||
IO.Hidden.unique_id,
|
||||
],
|
||||
is_api_node=True,
|
||||
price_badge=IO.PriceBadge(
|
||||
depends_on=IO.PriceBadgeDepends(widgets=["size"]),
|
||||
expr="""
|
||||
(
|
||||
widgets.size in ["1k", "1.5k"]
|
||||
? {
|
||||
"type": "usd",
|
||||
"usd": 0.032,
|
||||
"format": { "suffix": " x images/Run", "approximate": true }
|
||||
}
|
||||
: {
|
||||
"type": "range_usd",
|
||||
"min_usd": 0.032,
|
||||
"max_usd": 0.064,
|
||||
"format": { "suffix": " x images/Run", "approximate": true }
|
||||
}
|
||||
)
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def execute(
|
||||
cls,
|
||||
image: Input.Image,
|
||||
prompt: str = "",
|
||||
size: str = "auto",
|
||||
seed: int = 0,
|
||||
prompt_optimization: str = "standard",
|
||||
watermark: bool = False,
|
||||
crop_layers: bool = False,
|
||||
) -> IO.NodeOutput:
|
||||
if get_number_of_images(image) != 1:
|
||||
raise ValueError("Only a single input image is supported.")
|
||||
validate_image_aspect_ratio(image, (1, 16), (16, 1), strict=False)
|
||||
validate_image_dimensions(image, min_width=512, min_height=512)
|
||||
|
||||
request = Seedream5LayerSeparationRequest(
|
||||
model=SEEDREAM_LAYER_SEPARATION_MODEL,
|
||||
prompt=prompt.strip() or None,
|
||||
image=await upload_image_to_comfyapi(cls, image),
|
||||
size=size,
|
||||
seed=seed,
|
||||
watermark=watermark,
|
||||
optimize_prompt_options=Seedream5LayerOptimizePromptOptions(mode=prompt_optimization),
|
||||
)
|
||||
response = await sync_op(
|
||||
cls,
|
||||
ApiEndpoint(path=BYTEPLUS_IMAGE_ENDPOINT, method="POST"),
|
||||
response_model=ImageTaskCreationResponse,
|
||||
data=request,
|
||||
wait_label="Separating layers",
|
||||
)
|
||||
if response.error:
|
||||
raise RuntimeError(
|
||||
f"ByteDance request failed. Code: {response.error['code']}, message: {response.error['message']}"
|
||||
)
|
||||
|
||||
def z_index_of(d: dict) -> int:
|
||||
v = d.get("z_index")
|
||||
if isinstance(v, bool):
|
||||
return 1_000_000
|
||||
if isinstance(v, (int, float)):
|
||||
return int(v)
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
return int(v.strip())
|
||||
except ValueError:
|
||||
return 1_000_000
|
||||
return 1_000_000
|
||||
|
||||
data = [d for d in (response.data or []) if isinstance(d, dict)]
|
||||
if not data or "url" not in data[0]:
|
||||
raise RuntimeError("Unexpected response: no base image returned.")
|
||||
base_item = data[0]
|
||||
if base_item.get("bounding_box") is not None:
|
||||
logging.warning(
|
||||
"ByteDance layer separation: base item unexpectedly carries a bounding_box; ignoring it."
|
||||
)
|
||||
if z_index_of(base_item) not in (0, 1_000_000):
|
||||
raise RuntimeError("Unexpected response: the first item is not the base image.")
|
||||
layer_items = [d for d in data[1:] if "url" in d]
|
||||
dropped = len(data) - 1 - len(layer_items)
|
||||
if dropped > 0:
|
||||
logging.warning(
|
||||
"ByteDance layer separation: %d of %d returned elements had no 'url' and were dropped.",
|
||||
dropped,
|
||||
len(data) - 1,
|
||||
)
|
||||
if not layer_items:
|
||||
raise RuntimeError("The model returned no layers. Try a different prompt or input image.")
|
||||
layer_items.sort(key=z_index_of)
|
||||
|
||||
base_image = (await download_url_to_image_tensor(str(base_item["url"])))[..., :3].contiguous()
|
||||
height, width = base_image.shape[1], base_image.shape[2]
|
||||
|
||||
specs = []
|
||||
for item in layer_items:
|
||||
flags = []
|
||||
bbox = item.get("bounding_box")
|
||||
absolute = bbox.get("absolute") if isinstance(bbox, dict) else None
|
||||
if (
|
||||
isinstance(absolute, (list, tuple))
|
||||
and len(absolute) == 4
|
||||
and all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in absolute)
|
||||
):
|
||||
left, top, right, bottom = (int(round(v)) for v in absolute)
|
||||
rect_w, rect_h = right - left, bottom - top # exclusive right/bottom
|
||||
if rect_w > width or rect_h > height:
|
||||
rect_w, rect_h = min(rect_w, width), min(rect_h, height)
|
||||
flags.append("bbox_clamped")
|
||||
if rect_w <= 0 or rect_h <= 0:
|
||||
flags.append("bbox_degenerate")
|
||||
else:
|
||||
flags.append("bbox_missing")
|
||||
left, top, rect_w, rect_h = 0, 0, width, height
|
||||
specs.append({"item": item, "flags": flags, "left": left, "top": top,
|
||||
"rect_w": rect_w, "rect_h": rect_h, "native_size": "", "stack_item": None})
|
||||
|
||||
if crop_layers:
|
||||
canvas_w = max((s["rect_w"] for s in specs if "bbox_degenerate" not in s["flags"]), default=1)
|
||||
canvas_h = max((s["rect_h"] for s in specs if "bbox_degenerate" not in s["flags"]), default=1)
|
||||
else:
|
||||
canvas_w, canvas_h = width, height
|
||||
base_mask = torch.zeros((1, height, width))
|
||||
layers = torch.zeros((len(specs), canvas_h, canvas_w, 3))
|
||||
# Create Layered Image / LoadImage mask convention: 1 = transparent
|
||||
masks = torch.ones((len(specs), canvas_h, canvas_w))
|
||||
|
||||
semaphore = asyncio.Semaphore(4)
|
||||
|
||||
async def fetch_and_place(i: int, spec: dict) -> None:
|
||||
item, flags = spec["item"], spec["flags"]
|
||||
left, top, rect_w, rect_h = spec["left"], spec["top"], spec["rect_w"], spec["rect_h"]
|
||||
async with semaphore:
|
||||
try:
|
||||
rgba = (await download_url_to_image_tensor(str(item["url"])))[0]
|
||||
except ProcessingInterrupted:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"Failed to download layer {i + 1} of {len(specs)} (name={item.get('name')!r}): {exc} "
|
||||
"The generation completed and was billed; the response with all layer URLs "
|
||||
"is in ComfyUI/temp/api_logs/."
|
||||
) from exc
|
||||
spec["native_size"] = f"{rgba.shape[1]}x{rgba.shape[0]}"
|
||||
if "bbox_degenerate" in flags:
|
||||
return
|
||||
if (rgba.shape[1], rgba.shape[0]) != (rect_w, rect_h):
|
||||
# premultiply before resizing: interpolating straight alpha bleeds the undefined
|
||||
# colors of transparent pixels into the anti-aliased edges
|
||||
rgba = rgba.clone()
|
||||
rgba[..., :3] *= rgba[..., 3:4]
|
||||
rgba = (
|
||||
torch.nn.functional.interpolate(
|
||||
rgba.permute(2, 0, 1).unsqueeze(0),
|
||||
size=(rect_h, rect_w),
|
||||
mode="bilinear",
|
||||
antialias=True,
|
||||
)
|
||||
.squeeze(0)
|
||||
.permute(1, 2, 0)
|
||||
)
|
||||
alpha = rgba[..., 3:4]
|
||||
rgba = torch.cat([rgba[..., :3] / alpha.clamp(min=1e-6), alpha], dim=-1).clamp(0, 1)
|
||||
flags.append("resized_to_bbox")
|
||||
# straight (unpremultiplied) RGB: downstream compositing applies the mask itself
|
||||
if crop_layers:
|
||||
layers[i, :rect_h, :rect_w] = rgba[..., :3]
|
||||
masks[i, :rect_h, :rect_w] = 1.0 - rgba[..., 3]
|
||||
else:
|
||||
x0, y0 = max(left, 0), max(top, 0)
|
||||
x1, y1 = min(left + rect_w, width), min(top + rect_h, height)
|
||||
if x0 < x1 and y0 < y1:
|
||||
patch = rgba[y0 - top : y1 - top, x0 - left : x1 - left]
|
||||
layers[i, y0:y1, x0:x1] = patch[..., :3]
|
||||
masks[i, y0:y1, x0:x1] = 1.0 - patch[..., 3]
|
||||
else:
|
||||
flags.append("bbox_out_of_canvas")
|
||||
zi = z_index_of(item)
|
||||
stack_item = {
|
||||
"image": rgba[..., :3].unsqueeze(0).contiguous(),
|
||||
"type": "raster",
|
||||
"x": left,
|
||||
"y": top,
|
||||
"z_index": zi if zi != 1_000_000 else i + 1,
|
||||
"mask": (1.0 - rgba[..., 3]).unsqueeze(0),
|
||||
}
|
||||
if isinstance(item.get("name"), str):
|
||||
stack_item["name"] = item["name"]
|
||||
spec["stack_item"] = stack_item
|
||||
|
||||
await asyncio.gather(*(fetch_and_place(i, s) for i, s in enumerate(specs)))
|
||||
|
||||
stack_items = [{"image": base_image, "type": "raster", "x": 0, "y": 0, "z_index": 0, "name": "background"}]
|
||||
boxes = []
|
||||
for i, s in enumerate(specs):
|
||||
abnormal = [f for f in s["flags"] if f != "resized_to_bbox"]
|
||||
if abnormal:
|
||||
logging.warning(
|
||||
"ByteDance layer separation: layer %d (%r) flagged %s.",
|
||||
i + 1,
|
||||
s["item"].get("name"),
|
||||
", ".join(abnormal),
|
||||
)
|
||||
if s["stack_item"] is not None:
|
||||
stack_items.append(s["stack_item"])
|
||||
zi = z_index_of(s["item"])
|
||||
# placement box sized to this layer's tensor so Create Layered Image renders it 1:1;
|
||||
# the true content rect travels in metadata, frame-relative
|
||||
rect_x, rect_y = (0, 0) if crop_layers else (s["left"], s["top"])
|
||||
boxes.append(
|
||||
{
|
||||
"x": s["left"] if crop_layers else 0,
|
||||
"y": s["top"] if crop_layers else 0,
|
||||
"width": canvas_w,
|
||||
"height": canvas_h,
|
||||
"metadata": {
|
||||
"name": s["item"].get("name"),
|
||||
"desc": s["item"].get("description"),
|
||||
"z_index": zi if zi != 1_000_000 else None,
|
||||
"native_size": s["native_size"],
|
||||
"content_rect": [rect_x, rect_y, max(s["rect_w"], 0), max(s["rect_h"], 0)],
|
||||
"flags": s["flags"],
|
||||
},
|
||||
}
|
||||
)
|
||||
# a single frame holding every box: the per-frame BOUNDING_BOX shape for boxes that
|
||||
# annotate one image, as emitted and consumed by CreateBoundingBoxes
|
||||
bboxes = [boxes]
|
||||
layer_stack = {"version": 1, "canvas": (width, height), "layers": stack_items}
|
||||
return IO.NodeOutput(base_image, base_mask, layers, masks, bboxes, layer_stack)
|
||||
|
||||
|
||||
class ByteDanceTextToVideoNode(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
|
|
@ -3036,6 +3405,7 @@ class ByteDanceExtension(ComfyExtension):
|
|||
ByteDanceImageNode,
|
||||
ByteDanceSeedreamNode,
|
||||
ByteDanceSeedreamNodeV2,
|
||||
ByteDanceSeedreamLayerSeparationNode,
|
||||
ByteDanceTextToVideoNode,
|
||||
ByteDanceImageToVideoNode,
|
||||
ByteDanceFirstLastFrameNode,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,331 @@
|
|||
import math
|
||||
from typing import NamedTuple, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
EPSILON = 1e-6
|
||||
|
||||
LUM_R = 0.2224884
|
||||
LUM_G = 0.71690369
|
||||
LUM_B = 0.06060791
|
||||
|
||||
ArrayLike = Union[np.ndarray, float]
|
||||
|
||||
|
||||
def srgb_to_linear(c: ArrayLike) -> np.ndarray:
|
||||
c = np.asarray(c, dtype=np.float32)
|
||||
high = ((np.maximum(c, 0.0) + 0.055) / 1.055) ** 2.4
|
||||
return np.where(c <= 0.04045, c / 12.92, high).astype(np.float32)
|
||||
|
||||
|
||||
def linear_to_srgb(c: ArrayLike) -> np.ndarray:
|
||||
c = np.asarray(c, dtype=np.float32)
|
||||
high = 1.055 * np.maximum(c, 0.0) ** (1.0 / 2.4) - 0.055
|
||||
return np.where(c <= 0.0031308, 12.92 * c, high).astype(np.float32)
|
||||
|
||||
|
||||
def luminance(rgb: np.ndarray) -> np.ndarray:
|
||||
return rgb[..., 0] * LUM_R + rgb[..., 1] * LUM_G + rgb[..., 2] * LUM_B
|
||||
|
||||
|
||||
def safe_div(a: ArrayLike, b: ArrayLike) -> np.ndarray:
|
||||
a, b = np.broadcast_arrays(
|
||||
np.asarray(a, dtype=np.float32), np.asarray(b, dtype=np.float32)
|
||||
)
|
||||
out = np.zeros(b.shape, dtype=np.float32)
|
||||
np.divide(a, b, out=out, where=np.abs(b) >= EPSILON)
|
||||
return out
|
||||
|
||||
|
||||
CHANNEL_BLEND = {
|
||||
"normal": lambda i, l: l,
|
||||
"multiply": lambda i, l: i * l,
|
||||
"screen": lambda i, l: 1 - (1 - i) * (1 - l),
|
||||
"overlay": lambda i, l: np.where(i < 0.5, 2 * i * l, 1 - 2 * (1 - l) * (1 - i)),
|
||||
"darken": lambda i, l: np.minimum(i, l),
|
||||
"lighten": lambda i, l: np.maximum(i, l),
|
||||
"color-dodge": lambda i, l: np.where(
|
||||
i <= 0,
|
||||
0.0,
|
||||
np.where(1 - l <= EPSILON, 1.0, np.minimum(safe_div(i, 1 - l), 1.0)),
|
||||
),
|
||||
"color-burn": lambda i, l: np.where(
|
||||
i >= 1,
|
||||
1.0,
|
||||
np.where(l <= EPSILON, 0.0, 1 - np.minimum(safe_div(1 - i, l), 1.0)),
|
||||
),
|
||||
"hard-light": lambda i, l: np.where(
|
||||
l > 0.5,
|
||||
np.minimum(1 - (1 - i) * (1 - (l - 0.5) * 2), 1),
|
||||
np.minimum(i * (l * 2), 1),
|
||||
),
|
||||
"soft-light": lambda i, l: (1 - i) * (i * l) + i * (1 - (1 - i) * (1 - l)),
|
||||
"difference": lambda i, l: np.abs(i - l),
|
||||
"exclusion": lambda i, l: 0.5 - 2 * (i - 0.5) * (l - 0.5),
|
||||
"linear-dodge": lambda i, l: i + l,
|
||||
"linear-burn": lambda i, l: i + l - 1,
|
||||
"vivid-light": lambda i, l: np.where(
|
||||
l <= 0.5,
|
||||
np.where(
|
||||
i >= 1,
|
||||
1.0,
|
||||
np.where(
|
||||
2 * l <= EPSILON,
|
||||
0.0,
|
||||
np.maximum(1 - safe_div(1 - i, 2 * l), 0.0),
|
||||
),
|
||||
),
|
||||
np.where(
|
||||
i <= 0,
|
||||
0.0,
|
||||
np.where(
|
||||
2 * (1 - l) <= EPSILON,
|
||||
1.0,
|
||||
np.minimum(safe_div(i, 2 * (1 - l)), 1.0),
|
||||
),
|
||||
),
|
||||
),
|
||||
"pin-light": lambda i, l: np.where(
|
||||
l > 0.5, np.maximum(i, 2 * (l - 0.5)), np.minimum(i, 2 * l)
|
||||
),
|
||||
"linear-light": lambda i, l: i + 2 * l - 1,
|
||||
"hard-mix": lambda i, l: np.where(i + l < 1, 0.0, 1.0),
|
||||
"subtract": lambda i, l: np.maximum(i - l, 0),
|
||||
"divide": lambda i, l: np.clip(i / np.maximum(l, EPSILON), 0, 1),
|
||||
"grain-extract": lambda i, l: i - l + 0.5,
|
||||
"grain-merge": lambda i, l: i + l - 0.5,
|
||||
}
|
||||
|
||||
|
||||
def _blend_hue(i: np.ndarray, l: np.ndarray) -> np.ndarray:
|
||||
src_min = l.min(axis=-1)
|
||||
src_max = l.max(axis=-1)
|
||||
src_delta = src_max - src_min
|
||||
achromatic = src_delta <= EPSILON
|
||||
dest_max = i.max(axis=-1)
|
||||
dest_delta = dest_max - i.min(axis=-1)
|
||||
dest_s = np.where(dest_max != 0, dest_delta / np.where(dest_max != 0, dest_max, 1), 0)
|
||||
ratio = np.where(
|
||||
achromatic, 0, dest_s * dest_max / np.where(achromatic, 1, src_delta)
|
||||
)
|
||||
offset = dest_max - src_max * ratio
|
||||
return np.where(achromatic[..., None], i, l * ratio[..., None] + offset[..., None])
|
||||
|
||||
|
||||
def _blend_saturation(i: np.ndarray, l: np.ndarray) -> np.ndarray:
|
||||
dest_max = i.max(axis=-1)
|
||||
dest_delta = dest_max - i.min(axis=-1)
|
||||
flat = dest_delta <= EPSILON
|
||||
src_max = l.max(axis=-1)
|
||||
src_delta = src_max - l.min(axis=-1)
|
||||
src_s = np.where(src_max != 0, src_delta / np.where(src_max != 0, src_max, 1), 0)
|
||||
ratio = np.where(flat, 0, src_s * dest_max / np.where(flat, 1, dest_delta))
|
||||
offset = (1 - ratio) * dest_max
|
||||
return np.where(
|
||||
flat[..., None],
|
||||
np.broadcast_to(dest_max[..., None], i.shape),
|
||||
i * ratio[..., None] + offset[..., None],
|
||||
)
|
||||
|
||||
|
||||
def _blend_color(i: np.ndarray, l: np.ndarray) -> np.ndarray:
|
||||
dest_l = (i.min(axis=-1) + i.max(axis=-1)) / 2
|
||||
src_l = (l.min(axis=-1) + l.max(axis=-1)) / 2
|
||||
gray = (np.abs(src_l) <= EPSILON) | (np.abs(1 - src_l) <= EPSILON)
|
||||
dest_high = dest_l > 0.5
|
||||
src_high = src_l > 0.5
|
||||
dl = np.minimum(dest_l, 1 - dest_l)
|
||||
sl = np.minimum(src_l, 1 - src_l)
|
||||
ratio = dl / np.where(gray, 1, sl)
|
||||
offset = np.where(dest_high, 1 - 2 * dl, 0) + np.where(src_high, 2 * dl - ratio, 0)
|
||||
return np.where(
|
||||
gray[..., None],
|
||||
np.broadcast_to(dest_l[..., None], i.shape),
|
||||
l * ratio[..., None] + offset[..., None],
|
||||
)
|
||||
|
||||
|
||||
def _blend_luminosity(i: np.ndarray, l: np.ndarray) -> np.ndarray:
|
||||
# Scale the backdrop so it carries the layer's luminance. Where the backdrop
|
||||
# has no luminance to scale there is no hue or saturation to preserve either,
|
||||
# so the result is a neutral grey at the layer's luminance - which is also the
|
||||
# analytic limit of i * lum(l)/lum(i) as a grey backdrop approaches black.
|
||||
# Guarding the numerator here instead (returning black) makes a luminosity
|
||||
# layer disappear over dark backdrops; see tests-unit/comfy_extras_test/
|
||||
# compositor_blend_golden.json.
|
||||
lum_i = luminance(i)
|
||||
lum_l = luminance(l)
|
||||
degenerate = lum_i <= EPSILON
|
||||
ratio = np.where(degenerate, 0.0, lum_l / np.where(degenerate, 1.0, lum_i))
|
||||
return np.where(
|
||||
degenerate[..., None],
|
||||
np.broadcast_to(lum_l[..., None], i.shape),
|
||||
i * ratio[..., None],
|
||||
)
|
||||
|
||||
|
||||
HSL_BLEND = {
|
||||
"hue": _blend_hue,
|
||||
"saturation": _blend_saturation,
|
||||
"color": _blend_color,
|
||||
"luminosity": _blend_luminosity,
|
||||
}
|
||||
|
||||
|
||||
def blend_pixel(blend: str, in_rgb: np.ndarray, layer_rgb: np.ndarray) -> np.ndarray:
|
||||
in_rgb = np.asarray(in_rgb, dtype=np.float32)
|
||||
layer_rgb = np.asarray(layer_rgb, dtype=np.float32)
|
||||
hsl = HSL_BLEND.get(blend)
|
||||
if hsl is not None:
|
||||
return np.asarray(hsl(in_rgb, layer_rgb), dtype=np.float32)
|
||||
fn = CHANNEL_BLEND.get(blend, CHANNEL_BLEND["normal"])
|
||||
return np.asarray(fn(in_rgb, layer_rgb), dtype=np.float32)
|
||||
|
||||
|
||||
def _composite_union(in_c, layer, comp, cov):
|
||||
in_a = in_c[..., 3]
|
||||
layer_a = layer[..., 3] * cov
|
||||
new_a = layer_a + (1 - layer_a) * in_a
|
||||
ratio = np.where(new_a != 0, layer_a / np.where(new_a != 0, new_a, 1), 0)
|
||||
blended = (
|
||||
ratio[..., None]
|
||||
* (in_a[..., None] * (comp - layer[..., :3]) + layer[..., :3] - in_c[..., :3])
|
||||
+ in_c[..., :3]
|
||||
)
|
||||
keep = (layer_a == 0) | (new_a == 0)
|
||||
rgb = np.where(
|
||||
keep[..., None],
|
||||
in_c[..., :3],
|
||||
np.where((in_a == 0)[..., None], layer[..., :3], blended),
|
||||
)
|
||||
return np.concatenate([rgb, new_a[..., None]], axis=-1)
|
||||
|
||||
|
||||
def _composite_clip_to_backdrop(in_c, layer, comp, cov):
|
||||
in_a = in_c[..., 3]
|
||||
layer_a = layer[..., 3] * cov
|
||||
mixed = comp * layer_a[..., None] + in_c[..., :3] * (1 - layer_a[..., None])
|
||||
keep = (in_a == 0) | (layer_a == 0)
|
||||
rgb = np.where(keep[..., None], in_c[..., :3], mixed)
|
||||
return np.concatenate([rgb, in_a[..., None]], axis=-1)
|
||||
|
||||
|
||||
def _composite_clip_to_layer(in_c, layer, comp, cov):
|
||||
in_a = in_c[..., 3]
|
||||
layer_a = layer[..., 3] * cov
|
||||
mixed = comp * in_a[..., None] + layer[..., :3] * (1 - in_a[..., None])
|
||||
rgb = np.where(
|
||||
(layer_a == 0)[..., None],
|
||||
in_c[..., :3],
|
||||
np.where((in_a == 0)[..., None], layer[..., :3], mixed),
|
||||
)
|
||||
return np.concatenate([rgb, layer_a[..., None]], axis=-1)
|
||||
|
||||
|
||||
def _composite_intersection(in_c, layer, comp, cov):
|
||||
new_a = in_c[..., 3] * layer[..., 3] * cov
|
||||
rgb = np.where((new_a == 0)[..., None], in_c[..., :3], comp)
|
||||
return np.concatenate([rgb, new_a[..., None]], axis=-1)
|
||||
|
||||
|
||||
_COMPOSITE = {
|
||||
"union": _composite_union,
|
||||
"clip-to-backdrop": _composite_clip_to_backdrop,
|
||||
"clip-to-layer": _composite_clip_to_layer,
|
||||
"intersection": _composite_intersection,
|
||||
}
|
||||
|
||||
|
||||
def run_composite(mode: str, in_c, layer, comp, cov) -> np.ndarray:
|
||||
fn = _COMPOSITE.get(mode, _composite_union)
|
||||
return fn(in_c, layer, comp, cov)
|
||||
|
||||
|
||||
def _to_space(rgb: np.ndarray, space: str) -> np.ndarray:
|
||||
return rgb if space == "linear" else linear_to_srgb(rgb)
|
||||
|
||||
|
||||
def _from_space(rgb: np.ndarray, space: str) -> np.ndarray:
|
||||
return rgb if space == "linear" else srgb_to_linear(rgb)
|
||||
|
||||
|
||||
class EffectiveMode(NamedTuple):
|
||||
blend: str
|
||||
blend_space: str
|
||||
composite: str
|
||||
|
||||
|
||||
_LAYER_MODES = {
|
||||
"normal": ("linear", "union"),
|
||||
"multiply": ("linear", "clip-to-backdrop"),
|
||||
"screen": ("perceptual", "clip-to-backdrop"),
|
||||
"overlay": ("perceptual", "clip-to-backdrop"),
|
||||
"darken": ("linear", "clip-to-backdrop"),
|
||||
"lighten": ("linear", "clip-to-backdrop"),
|
||||
"color-dodge": ("perceptual", "clip-to-backdrop"),
|
||||
"color-burn": ("perceptual", "clip-to-backdrop"),
|
||||
"hard-light": ("perceptual", "clip-to-backdrop"),
|
||||
"soft-light": ("perceptual", "clip-to-backdrop"),
|
||||
"difference": ("perceptual", "clip-to-backdrop"),
|
||||
"exclusion": ("perceptual", "clip-to-backdrop"),
|
||||
"linear-dodge": ("linear", "clip-to-backdrop"),
|
||||
"linear-burn": ("perceptual", "clip-to-backdrop"),
|
||||
"vivid-light": ("perceptual", "clip-to-backdrop"),
|
||||
"pin-light": ("perceptual", "clip-to-backdrop"),
|
||||
"linear-light": ("perceptual", "clip-to-backdrop"),
|
||||
"hard-mix": ("perceptual", "clip-to-backdrop"),
|
||||
"subtract": ("linear", "clip-to-backdrop"),
|
||||
"divide": ("linear", "clip-to-backdrop"),
|
||||
"grain-extract": ("perceptual", "clip-to-backdrop"),
|
||||
"grain-merge": ("perceptual", "clip-to-backdrop"),
|
||||
"hue": ("perceptual", "clip-to-backdrop"),
|
||||
"saturation": ("perceptual", "clip-to-backdrop"),
|
||||
"color": ("perceptual", "clip-to-backdrop"),
|
||||
"luminosity": ("linear", "clip-to-backdrop"),
|
||||
}
|
||||
|
||||
|
||||
def resolve_mode(blend: str = "normal") -> EffectiveMode:
|
||||
blend_space, composite = _LAYER_MODES.get(blend, _LAYER_MODES["normal"])
|
||||
return EffectiveMode(
|
||||
blend=blend,
|
||||
blend_space=blend_space,
|
||||
composite=composite,
|
||||
)
|
||||
|
||||
|
||||
def blend_composite(
|
||||
mode: EffectiveMode,
|
||||
backdrop: np.ndarray,
|
||||
layer: np.ndarray,
|
||||
opacity: float,
|
||||
mask: Optional[ArrayLike] = None,
|
||||
) -> np.ndarray:
|
||||
backdrop = np.asarray(backdrop, dtype=np.float32)
|
||||
layer = np.asarray(layer, dtype=np.float32)
|
||||
cov = opacity * (1.0 if mask is None else mask)
|
||||
|
||||
in_b = _to_space(backdrop[..., :3], mode.blend_space)
|
||||
layer_b = _to_space(layer[..., :3], mode.blend_space)
|
||||
comp = _from_space(blend_pixel(mode.blend, in_b, layer_b), mode.blend_space)
|
||||
|
||||
return run_composite(mode.composite, backdrop, layer, comp, cov)
|
||||
|
||||
|
||||
def placed_bounds(
|
||||
x: float, y: float, w: float, h: float, rotation: float
|
||||
) -> tuple[int, int, int, int]:
|
||||
cx = x + w / 2
|
||||
cy = y + h / 2
|
||||
cos = math.cos(rotation)
|
||||
sin = math.sin(rotation)
|
||||
hw = w / 2
|
||||
hh = h / 2
|
||||
corners = ((-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh))
|
||||
xs = [cx + dx * cos - dy * sin for dx, dy in corners]
|
||||
ys = [cy + dx * sin + dy * cos for dx, dy in corners]
|
||||
bx = math.floor(min(xs))
|
||||
by = math.floor(min(ys))
|
||||
bw = max(1, math.ceil(max(xs)) - bx)
|
||||
bh = max(1, math.ceil(max(ys)) - by)
|
||||
return bx, by, bw, bh
|
||||
|
|
@ -0,0 +1,852 @@
|
|||
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",
|
||||
is_experimental=True,
|
||||
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.",
|
||||
),
|
||||
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()
|
||||
1
nodes.py
1
nodes.py
|
|
@ -2501,6 +2501,7 @@ async def init_builtin_extra_nodes():
|
|||
"nodes_math.py",
|
||||
"nodes_number_convert.py",
|
||||
"nodes_painter.py",
|
||||
"nodes_compositor.py",
|
||||
"nodes_curve.py",
|
||||
"nodes_bg_removal.py",
|
||||
"nodes_rtdetr.py",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
"""Regenerate ``compositor_blend_golden.json``.
|
||||
|
||||
The golden file is the *shared contract* for layer blending. Every
|
||||
implementation of these 26 modes must reproduce it within ``tolerance``:
|
||||
|
||||
* ``comfy_extras/compositor_blend.py`` - numpy, server-side compositing
|
||||
* ``layerBlend.frag`` - GLSL, the live preview in the layer editor
|
||||
* any future CPU reference in the frontend
|
||||
|
||||
Run from the repository root::
|
||||
|
||||
python tests-unit/comfy_extras_test/compositor_blend_fixture_gen.py
|
||||
|
||||
and review the diff. A change to this file is a change to user-visible
|
||||
blending behaviour in every implementation, so it should never be
|
||||
regenerated just to make a test pass.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
from comfy_extras.compositor_blend import CHANNEL_BLEND, HSL_BLEND, blend_pixel # noqa: E402
|
||||
|
||||
GOLDEN_PATH = os.path.join(os.path.dirname(__file__), "compositor_blend_golden.json")
|
||||
|
||||
# Scalar grid for the per-channel modes: both endpoints, the midpoint, values
|
||||
# just inside each endpoint, and values inside the 1e-6 epsilon guards.
|
||||
SCALARS = [0.0, 1e-7, 0.001, 0.25, 0.5, 0.75, 0.999, 1.0 - 1e-7, 1.0]
|
||||
|
||||
# Colour pairs for the HSL modes, which read all three channels at once.
|
||||
COLORS = [
|
||||
[0.0, 0.0, 0.0],
|
||||
[1.0, 1.0, 1.0],
|
||||
[0.5, 0.5, 0.5],
|
||||
[1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0],
|
||||
[0.2, 0.4, 0.6],
|
||||
[0.9, 0.1, 0.35],
|
||||
[1e-7, 1e-7, 1e-7],
|
||||
[1e-7, 0.0, 0.0],
|
||||
[0.05, 0.05, 0.05],
|
||||
]
|
||||
|
||||
|
||||
def _round(value) -> float:
|
||||
return round(float(value), 7)
|
||||
|
||||
|
||||
def build() -> dict:
|
||||
channel = {}
|
||||
for mode in CHANNEL_BLEND:
|
||||
rows = []
|
||||
for i in SCALARS:
|
||||
for l in SCALARS:
|
||||
out = blend_pixel(mode, np.float32([i] * 3), np.float32([l] * 3))
|
||||
rows.append([_round(i), _round(l), _round(np.asarray(out).reshape(3)[0])])
|
||||
channel[mode] = rows
|
||||
hsl = {}
|
||||
for mode in HSL_BLEND:
|
||||
rows = []
|
||||
for i in COLORS:
|
||||
for l in COLORS:
|
||||
out = blend_pixel(mode, np.float32(i), np.float32(l))
|
||||
rows.append([
|
||||
[_round(v) for v in i],
|
||||
[_round(v) for v in l],
|
||||
[_round(v) for v in np.asarray(out).reshape(3)],
|
||||
])
|
||||
hsl[mode] = rows
|
||||
return {
|
||||
"_comment": (
|
||||
"Golden blend values shared by comfy_extras/compositor_blend.py and "
|
||||
"layerBlend.frag. Inputs are unpremultiplied colours already in the "
|
||||
"blend space; outputs are unclamped (the compositor clamps once, at "
|
||||
"the end). 'channel' rows are [i, l, out] applied per channel; 'hsl' "
|
||||
"rows are [rgb_backdrop, rgb_layer, rgb_out]. Regenerate with "
|
||||
"tests-unit/comfy_extras_test/compositor_blend_fixture_gen.py."
|
||||
),
|
||||
"tolerance": 1e-4,
|
||||
"channel": channel,
|
||||
"hsl": hsl,
|
||||
}
|
||||
|
||||
|
||||
def dumps(data: dict) -> str:
|
||||
"""One row per line, so a behaviour change shows up as a readable diff."""
|
||||
lines = ["{", f' "_comment": {json.dumps(data["_comment"])},', f' "tolerance": {data["tolerance"]},']
|
||||
for section in ("channel", "hsl"):
|
||||
lines.append(f' "{section}": {{')
|
||||
modes = sorted(data[section])
|
||||
for m_index, mode in enumerate(modes):
|
||||
lines.append(f' "{mode}": [')
|
||||
rows = data[section][mode]
|
||||
for r_index, row in enumerate(rows):
|
||||
comma = "" if r_index == len(rows) - 1 else ","
|
||||
lines.append(f" {json.dumps(row)}{comma}")
|
||||
lines.append(" ]" + ("" if m_index == len(modes) - 1 else ","))
|
||||
lines.append(" }" + ("," if section == "channel" else ""))
|
||||
lines.append("}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
with open(GOLDEN_PATH, "w") as handle:
|
||||
handle.write(dumps(build()))
|
||||
sys.stdout.write(f"wrote {GOLDEN_PATH}\n")
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,157 @@
|
|||
"""Blend-mode parity tests for the compositor.
|
||||
|
||||
The compositor blends in three places: this numpy module (server-side), the
|
||||
``layerBlend.frag`` GLSL shader (the live preview the user actually sees), and
|
||||
anything the frontend adds later. They have diverged before, silently, and the
|
||||
divergences only show up as "the render does not look like the preview".
|
||||
|
||||
``compositor_blend_golden.json`` is the shared contract. This file pins the
|
||||
numpy implementation to it and additionally spells out, by hand, the boundary
|
||||
rules that the epsilon guards exist to enforce - so a future refactor of
|
||||
``safe_div`` cannot quietly re-introduce the old behaviour by regenerating the
|
||||
fixture.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from comfy_extras.compositor_blend import (
|
||||
CHANNEL_BLEND,
|
||||
HSL_BLEND,
|
||||
EffectiveMode,
|
||||
blend_composite,
|
||||
blend_pixel,
|
||||
resolve_mode,
|
||||
)
|
||||
|
||||
GOLDEN_PATH = os.path.join(os.path.dirname(__file__), "compositor_blend_golden.json")
|
||||
|
||||
with open(GOLDEN_PATH) as _handle:
|
||||
GOLDEN = json.load(_handle)
|
||||
|
||||
TOLERANCE = GOLDEN["tolerance"]
|
||||
|
||||
|
||||
def _blend(mode: str, i, l) -> np.ndarray:
|
||||
return np.asarray(
|
||||
blend_pixel(mode, np.float32(i), np.float32(l)), dtype=np.float64
|
||||
).reshape(3)
|
||||
|
||||
|
||||
def test_golden_covers_every_mode():
|
||||
"""A new blend mode must arrive with golden values, not silently."""
|
||||
assert set(GOLDEN["channel"]) == set(CHANNEL_BLEND)
|
||||
assert set(GOLDEN["hsl"]) == set(HSL_BLEND)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", sorted(CHANNEL_BLEND))
|
||||
def test_channel_modes_match_golden(mode):
|
||||
for i, l, expected in GOLDEN["channel"][mode]:
|
||||
actual = _blend(mode, [i] * 3, [l] * 3)
|
||||
assert actual == pytest.approx([expected] * 3, abs=TOLERANCE), (
|
||||
f"{mode}(i={i}, l={l}) -> {actual.tolist()}, golden {expected}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", sorted(HSL_BLEND))
|
||||
def test_hsl_modes_match_golden(mode):
|
||||
for i, l, expected in GOLDEN["hsl"][mode]:
|
||||
actual = _blend(mode, i, l)
|
||||
assert actual == pytest.approx(expected, abs=TOLERANCE), (
|
||||
f"{mode}(i={i}, l={l}) -> {actual.tolist()}, golden {expected}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", sorted(set(CHANNEL_BLEND) | set(HSL_BLEND)))
|
||||
def test_no_mode_produces_nan_or_inf(mode):
|
||||
edges = [0.0, 1e-7, 1e-6, 0.5, 1.0 - 1e-7, 1.0]
|
||||
for i in edges:
|
||||
for l in edges:
|
||||
out = _blend(mode, [i, 0.0, 1.0], [l, 1.0, 0.0])
|
||||
assert np.all(np.isfinite(out)), f"{mode}(i={i}, l={l}) -> {out.tolist()}"
|
||||
|
||||
|
||||
class TestBoundaryRules:
|
||||
"""The rules the epsilon guards encode, written out independently of the fixture."""
|
||||
|
||||
def test_color_dodge_full_layer_is_white_not_black(self):
|
||||
# Guarding the denominator returns 0 here, which reads as "the dodge
|
||||
# layer turned the image black" - the exact inversion CodeRabbit flagged.
|
||||
assert _blend("color-dodge", [0.5] * 3, [1.0] * 3) == pytest.approx([1.0] * 3)
|
||||
|
||||
def test_color_dodge_black_backdrop_stays_black(self):
|
||||
assert _blend("color-dodge", [0.0] * 3, [1.0] * 3) == pytest.approx([0.0] * 3)
|
||||
|
||||
def test_color_dodge_is_clamped(self):
|
||||
assert _blend("color-dodge", [0.6] * 3, [0.9] * 3) == pytest.approx([1.0] * 3)
|
||||
|
||||
def test_color_burn_empty_layer_is_black_not_white(self):
|
||||
assert _blend("color-burn", [0.5] * 3, [0.0] * 3) == pytest.approx([0.0] * 3)
|
||||
|
||||
def test_color_burn_white_backdrop_stays_white(self):
|
||||
assert _blend("color-burn", [1.0] * 3, [0.0] * 3) == pytest.approx([1.0] * 3)
|
||||
|
||||
def test_vivid_light_boundaries(self):
|
||||
assert _blend("vivid-light", [0.5] * 3, [0.0] * 3) == pytest.approx([0.0] * 3)
|
||||
assert _blend("vivid-light", [0.5] * 3, [1.0] * 3) == pytest.approx([1.0] * 3)
|
||||
assert _blend("vivid-light", [1.0] * 3, [0.0] * 3) == pytest.approx([1.0] * 3)
|
||||
assert _blend("vivid-light", [0.0] * 3, [1.0] * 3) == pytest.approx([0.0] * 3)
|
||||
|
||||
def test_divide_by_zero_is_clamped_to_one(self):
|
||||
assert _blend("divide", [0.5] * 3, [0.0] * 3) == pytest.approx([1.0] * 3)
|
||||
|
||||
def test_luminosity_over_black_takes_the_layer_luminance(self):
|
||||
# A luminosity layer over a black backdrop must not vanish. There is no
|
||||
# hue or saturation in the backdrop to preserve, so the result is a
|
||||
# neutral grey at the layer's luminance.
|
||||
assert _blend("luminosity", [0.0] * 3, [1.0] * 3) == pytest.approx([1.0] * 3)
|
||||
assert _blend("luminosity", [0.0] * 3, [0.5] * 3) == pytest.approx([0.5] * 3)
|
||||
|
||||
def test_luminosity_is_continuous_approaching_black(self):
|
||||
near = _blend("luminosity", [1e-7] * 3, [1.0] * 3)
|
||||
at = _blend("luminosity", [0.0] * 3, [1.0] * 3)
|
||||
assert near == pytest.approx(at, abs=TOLERANCE)
|
||||
|
||||
def test_luminosity_preserves_backdrop_chroma(self):
|
||||
out = _blend("luminosity", [0.4, 0.2, 0.1], [0.5] * 3)
|
||||
assert out[0] > out[1] > out[2]
|
||||
|
||||
|
||||
class TestCompositeAndModeTable:
|
||||
def test_unknown_blend_mode_falls_back_to_normal(self):
|
||||
unknown = resolve_mode("not-a-mode")
|
||||
assert (unknown.blend_space, unknown.composite) == (
|
||||
resolve_mode("normal").blend_space,
|
||||
resolve_mode("normal").composite,
|
||||
)
|
||||
assert _blend("not-a-mode", [0.1, 0.2, 0.3], [0.4, 0.5, 0.6]) == pytest.approx(
|
||||
_blend("normal", [0.1, 0.2, 0.3], [0.4, 0.5, 0.6])
|
||||
)
|
||||
|
||||
def test_every_blend_mode_has_a_composite_entry(self):
|
||||
for mode in set(CHANNEL_BLEND) | set(HSL_BLEND):
|
||||
resolved = resolve_mode(mode)
|
||||
assert isinstance(resolved, EffectiveMode)
|
||||
assert resolved.blend == mode
|
||||
assert resolved.blend_space in ("linear", "perceptual")
|
||||
assert resolved.composite in (
|
||||
"union",
|
||||
"clip-to-backdrop",
|
||||
"clip-to-layer",
|
||||
"intersection",
|
||||
)
|
||||
|
||||
def test_normal_over_transparent_backdrop_keeps_the_layer(self):
|
||||
backdrop = np.zeros((1, 1, 4), dtype=np.float32)
|
||||
layer = np.float32([[[0.25, 0.5, 0.75, 1.0]]])
|
||||
out = blend_composite(resolve_mode("normal"), backdrop, layer, 1.0)
|
||||
assert out[0, 0].tolist() == pytest.approx([0.25, 0.5, 0.75, 1.0])
|
||||
|
||||
def test_zero_opacity_is_a_no_op(self):
|
||||
backdrop = np.float32([[[0.1, 0.2, 0.3, 1.0]]])
|
||||
layer = np.float32([[[1.0, 1.0, 1.0, 1.0]]])
|
||||
out = blend_composite(resolve_mode("multiply"), backdrop, layer, 0.0)
|
||||
assert out[0, 0].tolist() == pytest.approx([0.1, 0.2, 0.3, 1.0])
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
"""Regression tests for ImageCompositor's handling of untrusted layer state.
|
||||
|
||||
The compositor's `compositor` widget value is persisted into the saved workflow
|
||||
and is accepted verbatim on `POST /prompt`, so every field in it is untrusted
|
||||
input, not an internal invariant.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from comfy_extras.nodes_compositor import (
|
||||
_layer_params,
|
||||
composite_from_state,
|
||||
expand_item_frames,
|
||||
state_from_items,
|
||||
)
|
||||
|
||||
|
||||
def _solid(color, w=4, h=4) -> torch.Tensor:
|
||||
frame = np.zeros((h, w, len(color)), dtype=np.float32)
|
||||
frame[:] = color
|
||||
return torch.from_numpy(frame).unsqueeze(0)
|
||||
|
||||
|
||||
class TestLayerOpacity:
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[(-0.5, 0.0), (0.0, 0.0), (0.25, 0.25), (1.0, 1.0), (3.0, 1.0)],
|
||||
)
|
||||
def test_opacity_is_clamped(self, raw, expected):
|
||||
assert _layer_params({"opacity": raw}, 4, 4)["opacity"] == expected
|
||||
|
||||
def test_opacity_defaults_to_opaque(self):
|
||||
assert _layer_params({}, 4, 4)["opacity"] == 1.0
|
||||
|
||||
def test_out_of_range_opacity_does_not_leak_into_the_next_layer(self):
|
||||
# The canvas is only clamped once, after every layer has been composited,
|
||||
# so an out-of-range coverage multiplier on one layer changes the *blend*
|
||||
# of the layer above it. White at opacity 3.0 over black leaves the canvas
|
||||
# at 3.0; the multiply above it then reads 3.0 as its backdrop and the
|
||||
# result is visibly lighter than the same stack at opacity 1.0.
|
||||
def run(opacity):
|
||||
state = {
|
||||
"canvas": (2, 2),
|
||||
"layers": [{"opacity": opacity}, {"opacity": 1.0, "blend": "multiply"}],
|
||||
"inputs": None,
|
||||
"background": {"color": "#000000", "opacity": 1.0, "visible": True},
|
||||
"order": None,
|
||||
}
|
||||
tensors = [_solid([1.0, 1.0, 1.0], 2, 2), _solid([0.5, 0.5, 0.5], 2, 2)]
|
||||
return composite_from_state(tensors, state, [None, None])[0, 0, 0, :3]
|
||||
|
||||
assert run(3.0).tolist() == pytest.approx(run(1.0).tolist(), abs=1e-6)
|
||||
|
||||
|
||||
class TestGraphOnlyBackground:
|
||||
def test_default_layout_background_is_hidden(self):
|
||||
# A visible white background here would make every graph-only run emit a
|
||||
# white matte instead of transparency.
|
||||
frames = expand_item_frames([{"image": _solid([1.0, 0.0, 0.0])}])
|
||||
state = state_from_items(frames, (4, 4))
|
||||
assert state["background"]["visible"] is False
|
||||
|
||||
def test_uncovered_canvas_stays_transparent(self):
|
||||
tensors = [_solid([1.0, 0.0, 0.0], w=2, h=2)]
|
||||
frames = expand_item_frames([{"image": tensors[0]}])
|
||||
state = state_from_items(frames, (4, 4))
|
||||
out = composite_from_state(tensors, state, [None])[0]
|
||||
assert out.shape[-1] == 4
|
||||
assert float(out[0, 0, 3]) == pytest.approx(1.0)
|
||||
assert float(out[3, 3, 3]) == pytest.approx(0.0)
|
||||
Loading…
Reference in New Issue