制作图像合成节点

This commit is contained in:
Gin3601
2026-05-25 11:15:13 +08:00
parent d6eeba0e4f
commit fd5a2b9de2
12 changed files with 2125 additions and 3 deletions
+39
View File
@@ -207,6 +207,45 @@ node/
- 同时连接 `image + mask`:两者都输出,mask 在保存模式下使用 `<filename_prefix>_mask`
- `image` 与 `mask` 各自独立输出,互不影响
### `LLS Simple Image Composite`
`LLS Simple Image Composite` 用于把一张前景图叠加到背景图上,并输出最终合成图像。背景图尺寸始终作为最终输出尺寸,前景图支持平移、缩放、旋转和透明度控制。
**输入:**
| 名称 | 类型 | 说明 |
|------|------|------|
| `background_image` | `IMAGE` | 背景图 |
| `overlay_image` | `IMAGE` | 前景图 |
| `x_offset` | `INT` | 前景图水平偏移 |
| `y_offset` | `INT` | 前景图垂直偏移 |
| `anchor_mode` | `top_left / center` | 前景图定位方式 |
| `rotation_origin_mode` | `top_left / center` | 前景图旋转原点 |
| `opacity` | `FLOAT` | 前景图透明度 |
| `scale` | `FLOAT` | 前景图缩放倍率 |
| `rotation` | `FLOAT` | 前景图旋转角度 |
| `blend_mode` | `normal` | 当前只支持 normal |
| `keep_aspect` | `BOOLEAN` | 当前保持等比缩放 |
**输出:**
| 名称 | 类型 | 说明 |
|------|------|------|
| `output_image` | `IMAGE` | 合成后的最终图像 |
**典型用途:**
- `Load Image(background) + Load Image(overlay) -> LLS Simple Image Composite -> Preview Image`
- 把 logo、贴纸或局部元素叠加到背景图的指定位置
- 通过 `x_offset`、`y_offset`、`scale`、`rotation` 快速摆位
**说明:**
- 前景图超出背景范围时会自动裁剪
- 如果前景图带 alpha 通道,会优先按 alpha 合成
- 节点内实时预览优先支持 `Load Image` / `Load Image Output` 作为上游文件来源
- `anchor_mode` 与 `rotation_origin_mode` 相互独立,可以分别控制定位参考点和旋转参考点
### `LLS Simple Mask Draw`
`LLS Simple Mask Draw` 是一个面向局部重绘的交互式遮罩输入节点,用来直接在输入图像上手动画 `mask`,并把结果输出给后续修复链路。
@@ -0,0 +1,984 @@
# LLS Simple Image Composite Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a new `LLS Simple Image Composite` node that composites an overlay image onto a background image with translation, scale, rotation, opacity, independent anchor and rotation-origin modes, plus a ComfyUI node-local realtime preview UI.
**Architecture:** Keep the backend compositing logic in focused `image/` modules and merge registration in `image/__init__.py` rather than growing `image/nodes.py` further. Mirror the existing `LLS Simple Mask Draw` pattern by pairing a Python node with a `web/js` extension that renders a node-local preview and synchronizes drag operations back into widgets.
**Tech Stack:** Python, unittest, torch, numpy, Pillow, ComfyUI node contracts, DOM canvas frontend extension
---
### Task 1: Create Test Helpers And Lock Registration / Frontend / Docs Contracts
**Files:**
- Create: `tests/test_image_composite_helpers.py`
- Create: `tests/test_image_composite_registration.py`
- Create: `tests/test_image_composite_frontend.py`
- Create: `tests/test_image_composite_docs.py`
- [ ] **Step 1: Create the shared test helper file**
```python
import importlib
import importlib.util
import pathlib
import sys
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
MODULE_NAME = "lls_node_test_image_composite"
def _import_optional(module_name):
try:
return importlib.import_module(module_name)
except Exception:
return None
torch = _import_optional("torch")
np = _import_optional("numpy")
_pil_image_module = _import_optional("PIL.Image")
Image = _pil_image_module
HAS_IMAGE_COMPOSITE_RUNTIME_DEPS = torch is not None and np is not None and Image is not None
IMAGE_COMPOSITE_RUNTIME_DEPS_MESSAGE = "image composite tests require torch, numpy, and Pillow"
def load_plugin_package():
for name in list(sys.modules):
if name == MODULE_NAME or name.startswith(f"{MODULE_NAME}."):
sys.modules.pop(name)
spec = importlib.util.spec_from_file_location(
MODULE_NAME,
ROOT / "__init__.py",
submodule_search_locations=[str(ROOT)],
)
module = importlib.util.module_from_spec(spec)
sys.modules[MODULE_NAME] = module
spec.loader.exec_module(module)
return module
def require_image_composite_runtime_deps():
if not HAS_IMAGE_COMPOSITE_RUNTIME_DEPS:
raise unittest.SkipTest(IMAGE_COMPOSITE_RUNTIME_DEPS_MESSAGE)
def make_image(width=8, height=8, color=(0.0, 0.0, 0.0), alpha=None):
require_image_composite_runtime_deps()
rgb = tuple(float(channel) for channel in color)
channels = 4 if alpha is not None else 3
tensor = torch.zeros((1, height, width, channels), dtype=torch.float32)
tensor[..., 0] = rgb[0]
tensor[..., 1] = rgb[1]
tensor[..., 2] = rgb[2]
if alpha is not None:
tensor[..., 3] = float(alpha)
return tensor
```
- [ ] **Step 2: Create failing registration, frontend, and docs tests**
```python
# tests/test_image_composite_registration.py
import unittest
try:
from .test_image_composite_helpers import load_plugin_package
except ImportError:
from test_image_composite_helpers import load_plugin_package
class TestImageCompositeRegistration(unittest.TestCase):
def test_plugin_registers_image_composite_node(self):
plugin = load_plugin_package()
self.assertIn("LLSSimpleImageComposite", plugin.NODE_CLASS_MAPPINGS)
self.assertEqual(
plugin.NODE_DISPLAY_NAME_MAPPINGS["LLSSimpleImageComposite"],
"LLS Simple Image Composite",
)
def test_image_composite_schema_matches_contract(self):
plugin = load_plugin_package()
node_cls = plugin.NODE_CLASS_MAPPINGS["LLSSimpleImageComposite"]
schema = node_cls.INPUT_TYPES()
required = schema["required"]
self.assertEqual(node_cls.CATEGORY, "LLS/Image")
self.assertEqual(node_cls.FUNCTION, "composite")
self.assertEqual(node_cls.RETURN_TYPES, ("IMAGE",))
self.assertEqual(node_cls.RETURN_NAMES, ("output_image",))
self.assertEqual(required["background_image"], ("IMAGE",))
self.assertEqual(required["overlay_image"], ("IMAGE",))
self.assertEqual(required["x_offset"][0], "INT")
self.assertEqual(required["x_offset"][1]["default"], 0)
self.assertEqual(required["y_offset"][0], "INT")
self.assertEqual(required["anchor_mode"][0], ["top_left", "center"])
self.assertEqual(required["rotation_origin_mode"][0], ["top_left", "center"])
self.assertEqual(required["opacity"][0], "FLOAT")
self.assertEqual(required["opacity"][1]["default"], 1.0)
self.assertEqual(required["blend_mode"][0], ["normal"])
self.assertEqual(required["scale"][0], "FLOAT")
self.assertEqual(required["rotation"][0], "FLOAT")
self.assertEqual(required["keep_aspect"][0], "BOOLEAN")
```
```python
# tests/test_image_composite_frontend.py
import unittest
try:
from .test_image_composite_helpers import ROOT, load_plugin_package
except ImportError:
from test_image_composite_helpers import ROOT, load_plugin_package
class TestImageCompositeFrontend(unittest.TestCase):
def test_plugin_exports_web_directory(self):
plugin = load_plugin_package()
self.assertEqual(plugin.WEB_DIRECTORY, "./web")
def test_frontend_asset_exists(self):
asset = ROOT / "web" / "js" / "lls_image_composite.js"
self.assertTrue(asset.exists(), msg=f"Missing frontend asset: {asset}")
def test_frontend_asset_registers_image_composite_extension(self):
asset = (ROOT / "web" / "js" / "lls_image_composite.js").read_text(encoding="utf-8")
self.assertIn("app.registerExtension", asset)
self.assertIn("LLSSimpleImageComposite", asset)
self.assertIn("LLS Simple Image Composite", asset)
self.assertIn("beforeRegisterNodeDef", asset)
self.assertIn("addDOMWidget", asset)
self.assertIn("pointerdown", asset)
self.assertIn("pointermove", asset)
self.assertIn("rotation_origin_mode", asset)
```
```python
# tests/test_image_composite_docs.py
import unittest
try:
from .test_image_composite_helpers import ROOT
except ImportError:
from test_image_composite_helpers import ROOT
class TestImageCompositeDocs(unittest.TestCase):
def test_readme_documents_image_composite_node(self):
readme = (ROOT / "README.md").read_text(encoding="utf-8")
self.assertIn("LLS Simple Image Composite", readme)
self.assertIn("background_image", readme)
self.assertIn("overlay_image", readme)
self.assertIn("output_image", readme)
self.assertIn("x_offset", readme)
self.assertIn("y_offset", readme)
self.assertIn("anchor_mode", readme)
self.assertIn("rotation_origin_mode", readme)
self.assertIn("opacity", readme)
self.assertIn("scale", readme)
self.assertIn("rotation", readme)
self.assertIn("keep_aspect", readme)
```
- [ ] **Step 3: Run the contract tests to verify they fail**
Run: `python3 -m unittest tests.test_image_composite_registration tests.test_image_composite_frontend tests.test_image_composite_docs`
Expected: FAIL because the node, frontend asset, and README section do not exist yet.
### Task 2: Write Failing Runtime Tests For Backend Compositing
**Files:**
- Create: `tests/test_image_composite_node.py`
- Test: `tests/test_image_composite_node.py`
- [ ] **Step 1: Create failing node behavior tests**
```python
import unittest
try:
from .test_image_composite_helpers import (
HAS_IMAGE_COMPOSITE_RUNTIME_DEPS,
IMAGE_COMPOSITE_RUNTIME_DEPS_MESSAGE,
load_plugin_package,
make_image,
)
except ImportError:
from test_image_composite_helpers import (
HAS_IMAGE_COMPOSITE_RUNTIME_DEPS,
IMAGE_COMPOSITE_RUNTIME_DEPS_MESSAGE,
load_plugin_package,
make_image,
)
@unittest.skipUnless(HAS_IMAGE_COMPOSITE_RUNTIME_DEPS, IMAGE_COMPOSITE_RUNTIME_DEPS_MESSAGE)
class TestImageCompositeNode(unittest.TestCase):
@classmethod
def setUpClass(cls):
plugin = load_plugin_package()
cls.node = plugin.NODE_CLASS_MAPPINGS["LLSSimpleImageComposite"]()
def test_top_left_offset_places_overlay_at_requested_position(self):
background = make_image(width=6, height=6, color=(0.0, 0.0, 0.0))
overlay = make_image(width=2, height=2, color=(1.0, 1.0, 1.0))
(output_image,) = self.node.composite(
background_image=background,
overlay_image=overlay,
x_offset=3,
y_offset=1,
anchor_mode="top_left",
rotation_origin_mode="center",
opacity=1.0,
blend_mode="normal",
scale=1.0,
rotation=0.0,
keep_aspect=True,
)
self.assertEqual(tuple(output_image.shape), (1, 6, 6, 3))
self.assertTrue((output_image[0, 1:3, 3:5] > 0.99).all().item())
self.assertEqual(float(output_image[0, 0, 0, 0].item()), 0.0)
def test_center_anchor_places_overlay_by_its_center(self):
background = make_image(width=6, height=6, color=(0.0, 0.0, 0.0))
overlay = make_image(width=2, height=2, color=(0.0, 1.0, 0.0))
(output_image,) = self.node.composite(
background_image=background,
overlay_image=overlay,
x_offset=3,
y_offset=2,
anchor_mode="center",
rotation_origin_mode="center",
opacity=1.0,
blend_mode="normal",
scale=1.0,
rotation=0.0,
keep_aspect=True,
)
self.assertTrue((output_image[0, 1:3, 2:4, 1] > 0.99).all().item())
def test_partial_overflow_is_clipped_to_background_bounds(self):
background = make_image(width=5, height=5, color=(0.0, 0.0, 0.0))
overlay = make_image(width=3, height=3, color=(1.0, 0.0, 0.0))
(output_image,) = self.node.composite(
background_image=background,
overlay_image=overlay,
x_offset=4,
y_offset=4,
anchor_mode="top_left",
rotation_origin_mode="center",
opacity=1.0,
blend_mode="normal",
scale=1.0,
rotation=0.0,
keep_aspect=True,
)
self.assertGreater(float(output_image[0, 4, 4, 0].item()), 0.99)
self.assertEqual(float(output_image[0, 0, 0, 0].item()), 0.0)
def test_overlay_fully_outside_returns_background(self):
background = make_image(width=5, height=5, color=(0.1, 0.1, 0.1))
overlay = make_image(width=2, height=2, color=(1.0, 1.0, 1.0))
(output_image,) = self.node.composite(
background_image=background,
overlay_image=overlay,
x_offset=8,
y_offset=8,
anchor_mode="top_left",
rotation_origin_mode="center",
opacity=1.0,
blend_mode="normal",
scale=1.0,
rotation=0.0,
keep_aspect=True,
)
self.assertTrue((output_image == background).all().item())
def test_opacity_zero_returns_background(self):
background = make_image(width=4, height=4, color=(0.2, 0.2, 0.2))
overlay = make_image(width=2, height=2, color=(1.0, 0.0, 0.0))
(output_image,) = self.node.composite(
background_image=background,
overlay_image=overlay,
x_offset=1,
y_offset=1,
anchor_mode="top_left",
rotation_origin_mode="center",
opacity=0.0,
blend_mode="normal",
scale=1.0,
rotation=0.0,
keep_aspect=True,
)
self.assertTrue((output_image == background).all().item())
def test_rgba_overlay_uses_alpha_and_opacity(self):
background = make_image(width=4, height=4, color=(0.0, 0.0, 0.0))
overlay = make_image(width=2, height=2, color=(1.0, 0.0, 0.0), alpha=0.5)
(output_image,) = self.node.composite(
background_image=background,
overlay_image=overlay,
x_offset=1,
y_offset=1,
anchor_mode="top_left",
rotation_origin_mode="center",
opacity=0.5,
blend_mode="normal",
scale=1.0,
rotation=0.0,
keep_aspect=True,
)
self.assertAlmostEqual(float(output_image[0, 1, 1, 0].item()), 0.25, places=2)
def test_scale_expands_overlay_footprint(self):
background = make_image(width=5, height=5, color=(0.0, 0.0, 0.0))
overlay = make_image(width=1, height=1, color=(1.0, 1.0, 1.0))
(output_image,) = self.node.composite(
background_image=background,
overlay_image=overlay,
x_offset=1,
y_offset=1,
anchor_mode="top_left",
rotation_origin_mode="center",
opacity=1.0,
blend_mode="normal",
scale=2.0,
rotation=0.0,
keep_aspect=True,
)
self.assertTrue((output_image[0, 1:3, 1:3] > 0.99).all().item())
def test_rotation_origin_mode_changes_rotated_result(self):
background = make_image(width=8, height=8, color=(0.0, 0.0, 0.0))
overlay = make_image(width=3, height=1, color=(0.0, 0.0, 1.0))
(center_rotated,) = self.node.composite(
background_image=background,
overlay_image=overlay,
x_offset=3,
y_offset=3,
anchor_mode="top_left",
rotation_origin_mode="center",
opacity=1.0,
blend_mode="normal",
scale=1.0,
rotation=90.0,
keep_aspect=True,
)
(corner_rotated,) = self.node.composite(
background_image=background,
overlay_image=overlay,
x_offset=3,
y_offset=3,
anchor_mode="top_left",
rotation_origin_mode="top_left",
opacity=1.0,
blend_mode="normal",
scale=1.0,
rotation=90.0,
keep_aspect=True,
)
self.assertFalse((center_rotated == corner_rotated).all().item())
```
- [ ] **Step 2: Run the backend tests to verify they fail**
Run: `python3 -m unittest tests.test_image_composite_node`
Expected: FAIL because `LLSSimpleImageComposite` and its backend logic do not exist yet.
### Task 3: Implement Backend Compositing Utilities
**Files:**
- Create: `image/composite_utils.py`
- Test: `tests/test_image_composite_node.py`
- [ ] **Step 1: Create the backend utility module with dependency guards and tensor validation**
```python
from __future__ import annotations
import io
try:
import numpy as np
except Exception as exc: # pragma: no cover
np = None
_NUMPY_ERR = exc
else: # pragma: no cover
_NUMPY_ERR = None
try:
from PIL import Image
except Exception as exc: # pragma: no cover
Image = None
_PIL_ERR = exc
else: # pragma: no cover
_PIL_ERR = None
try:
import torch
except Exception as exc: # pragma: no cover
torch = None
_TORCH_ERR = exc
else: # pragma: no cover
_TORCH_ERR = None
ANCHOR_MODE_CHOICES = ["top_left", "center"]
ROTATION_ORIGIN_MODE_CHOICES = ["top_left", "center"]
BLEND_MODE_CHOICES = ["normal"]
def _require_runtime_deps():
if torch is None:
raise RuntimeError("[LLS] torch is required for image compositing.") from _TORCH_ERR
if np is None:
raise RuntimeError("[LLS] numpy is required for image compositing.") from _NUMPY_ERR
if Image is None:
raise RuntimeError("[LLS] Pillow is required for image compositing.") from _PIL_ERR
def _require_image_tensor(name, image):
shape = tuple(getattr(image, "shape", ()))
if len(shape) != 4:
raise RuntimeError(f"[LLS] {name} must have shape [batch, height, width, channels].")
if int(shape[0]) <= 0 or int(shape[1]) <= 0 or int(shape[2]) <= 0:
raise RuntimeError(f"[LLS] {name} must have positive batch, width, and height.")
channels = int(shape[3])
if channels not in {3, 4}:
raise RuntimeError(f"[LLS] {name} must have 3 or 4 channels; got {channels}.")
```
- [ ] **Step 2: Implement the single-image compositing helpers**
```python
def _tensor_sample_to_rgba_pil(sample):
rgb = sample.detach().cpu().clamp(0.0, 1.0).numpy()
if rgb.shape[-1] == 3:
alpha = np.ones((rgb.shape[0], rgb.shape[1], 1), dtype=np.float32)
rgb = np.concatenate([rgb, alpha], axis=-1)
rgba = np.clip(np.round(rgb * 255.0), 0, 255).astype(np.uint8)
return Image.fromarray(rgba, mode="RGBA")
def _pil_to_tensor(image, *, device, dtype):
array = np.asarray(image, dtype=np.float32) / 255.0
if array.ndim != 3 or array.shape[-1] != 4:
raise RuntimeError("[LLS] compositing helper expected an RGBA PIL image.")
rgb = array[..., :3]
return torch.from_numpy(rgb).to(device=device, dtype=dtype)
def _scaled_overlay_rgba(overlay_rgba, scale):
width, height = overlay_rgba.size
scaled_width = max(1, int(round(width * scale)))
scaled_height = max(1, int(round(height * scale)))
if (scaled_width, scaled_height) == (width, height):
return overlay_rgba
return overlay_rgba.resize((scaled_width, scaled_height), Image.BICUBIC)
def _resolve_top_left(width, height, x_offset, y_offset, anchor_mode):
if anchor_mode == "top_left":
return int(x_offset), int(y_offset)
if anchor_mode == "center":
return int(x_offset - (width / 2.0)), int(y_offset - (height / 2.0))
raise RuntimeError(f"[LLS] Unsupported anchor_mode '{anchor_mode}'.")
def _resolve_rotation_center(top_left_x, top_left_y, width, height, rotation_origin_mode):
if rotation_origin_mode == "top_left":
return float(top_left_x), float(top_left_y)
if rotation_origin_mode == "center":
return float(top_left_x) + (width / 2.0), float(top_left_y) + (height / 2.0)
raise RuntimeError(f"[LLS] Unsupported rotation_origin_mode '{rotation_origin_mode}'.")
```
- [ ] **Step 3: Implement the batch-aware public compositing function**
```python
def composite_images(
background_image,
overlay_image,
*,
x_offset,
y_offset,
anchor_mode,
rotation_origin_mode,
opacity,
blend_mode,
scale,
rotation,
keep_aspect,
):
del keep_aspect
_require_runtime_deps()
if background_image is None:
raise RuntimeError("[LLS] background_image is required for image compositing.")
if overlay_image is None:
raise RuntimeError("[LLS] overlay_image is required for image compositing.")
if blend_mode != "normal":
raise RuntimeError(f"[LLS] Unsupported blend_mode '{blend_mode}'.")
_require_image_tensor("background_image", background_image)
_require_image_tensor("overlay_image", overlay_image)
clamped_scale = max(0.01, float(scale))
clamped_opacity = max(0.0, min(1.0, float(opacity)))
if clamped_opacity == 0.0:
return background_image[..., :3].clone()
background_batch = int(background_image.shape[0])
overlay_batch = int(overlay_image.shape[0])
if overlay_batch not in {1, background_batch}:
raise RuntimeError(
f"[LLS] overlay_image batch must be 1 or match background_image batch; got {overlay_batch} and {background_batch}."
)
results = []
for index in range(background_batch):
background_sample = background_image[index]
overlay_sample = overlay_image[0 if overlay_batch == 1 else index]
background_rgba = _tensor_sample_to_rgba_pil(background_sample)
overlay_rgba = _scaled_overlay_rgba(_tensor_sample_to_rgba_pil(overlay_sample), clamped_scale)
top_left_x, top_left_y = _resolve_top_left(
overlay_rgba.size[0],
overlay_rgba.size[1],
int(x_offset),
int(y_offset),
anchor_mode,
)
overlay_canvas = Image.new("RGBA", background_rgba.size, (0, 0, 0, 0))
overlay_canvas.paste(overlay_rgba, (top_left_x, top_left_y), overlay_rgba)
if float(rotation) != 0.0:
rotation_center = _resolve_rotation_center(
top_left_x,
top_left_y,
overlay_rgba.size[0],
overlay_rgba.size[1],
rotation_origin_mode,
)
overlay_canvas = overlay_canvas.rotate(
float(rotation),
resample=Image.BICUBIC,
center=rotation_center,
expand=False,
)
if clamped_opacity < 1.0:
overlay_alpha = overlay_canvas.getchannel("A")
overlay_alpha = overlay_alpha.point(lambda value: int(round(value * clamped_opacity)))
overlay_canvas.putalpha(overlay_alpha)
composed = Image.alpha_composite(background_rgba, overlay_canvas)
results.append(
_pil_to_tensor(
composed,
device=background_image.device,
dtype=background_image.dtype,
)
)
return torch.stack(results, dim=0)
```
- [ ] **Step 4: Run the backend tests to verify they pass**
Run: `python3 -m unittest tests.test_image_composite_node`
Expected: PASS
### Task 4: Implement The Node Class And Merge Registration In `image/__init__.py`
**Files:**
- Create: `image/composite.py`
- Modify: `image/__init__.py`
- Test: `tests/test_image_composite_registration.py`
- [ ] **Step 1: Create the node class file**
```python
from __future__ import annotations
from .composite_utils import (
ANCHOR_MODE_CHOICES,
BLEND_MODE_CHOICES,
ROTATION_ORIGIN_MODE_CHOICES,
composite_images,
)
class LLSSimpleImageComposite:
CATEGORY = "LLS/Image"
FUNCTION = "composite"
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("output_image",)
DESCRIPTION = "Composite an overlay image onto a background image with translation, scale, rotation, and opacity."
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"background_image": ("IMAGE",),
"overlay_image": ("IMAGE",),
"x_offset": ("INT", {"default": 0, "min": -8192, "max": 8192, "step": 1}),
"y_offset": ("INT", {"default": 0, "min": -8192, "max": 8192, "step": 1}),
"anchor_mode": (ANCHOR_MODE_CHOICES, {"default": "top_left"}),
"rotation_origin_mode": (ROTATION_ORIGIN_MODE_CHOICES, {"default": "center"}),
"opacity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"blend_mode": (BLEND_MODE_CHOICES, {"default": "normal"}),
"scale": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 32.0, "step": 0.01}),
"rotation": ("FLOAT", {"default": 0.0, "min": -360.0, "max": 360.0, "step": 0.1}),
"keep_aspect": ("BOOLEAN", {"default": True}),
}
}
def composite(
self,
background_image,
overlay_image,
x_offset,
y_offset,
anchor_mode,
rotation_origin_mode,
opacity,
blend_mode,
scale,
rotation,
keep_aspect,
):
output_image = composite_images(
background_image,
overlay_image,
x_offset=x_offset,
y_offset=y_offset,
anchor_mode=anchor_mode,
rotation_origin_mode=rotation_origin_mode,
opacity=opacity,
blend_mode=blend_mode,
scale=scale,
rotation=rotation,
keep_aspect=keep_aspect,
)
return (output_image,)
NODE_CLASS_MAPPINGS = {"LLSSimpleImageComposite": LLSSimpleImageComposite}
NODE_DISPLAY_NAME_MAPPINGS = {"LLSSimpleImageComposite": "LLS Simple Image Composite"}
```
- [ ] **Step 2: Merge the new node registration in `image/__init__.py`**
```python
"""
node.image
==========
功能域:图像处理与后处理(对应功能分类总览第 5 节)
"""
from .composite import (
NODE_CLASS_MAPPINGS as COMPOSITE_NODE_CLASS_MAPPINGS,
NODE_DISPLAY_NAME_MAPPINGS as COMPOSITE_NODE_DISPLAY_NAME_MAPPINGS,
)
from .nodes import (
NODE_CLASS_MAPPINGS as CORE_NODE_CLASS_MAPPINGS,
NODE_DISPLAY_NAME_MAPPINGS as CORE_NODE_DISPLAY_NAME_MAPPINGS,
)
NODE_CLASS_MAPPINGS = {}
NODE_CLASS_MAPPINGS.update(CORE_NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(COMPOSITE_NODE_CLASS_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS.update(CORE_NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(COMPOSITE_NODE_DISPLAY_NAME_MAPPINGS)
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
```
- [ ] **Step 3: Run the registration tests to verify they pass**
Run: `python3 -m unittest tests.test_image_composite_registration`
Expected: PASS
- [ ] **Step 4: Commit the backend node work**
```bash
git add image/composite.py image/composite_utils.py image/__init__.py \
tests/test_image_composite_helpers.py tests/test_image_composite_registration.py tests/test_image_composite_node.py
git commit -m "Add LLS Simple Image Composite backend"
```
### Task 5: Implement The ComfyUI Frontend Preview Extension
**Files:**
- Create: `web/js/lls_image_composite.js`
- Test: `tests/test_image_composite_frontend.py`
- [ ] **Step 1: Create the frontend extension skeleton and source-resolution helpers**
```javascript
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const EXTENSION_NAME = "LLS.ImageComposite";
const TARGET_NODE_CLASS = "LLSSimpleImageComposite";
const TARGET_NODE_DISPLAY_NAME = "LLS Simple Image Composite";
function findWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name) ?? null;
}
function getInputLink(node, inputName) {
const input = node.inputs?.find((item) => item.name === inputName);
if (!input || input.link == null) {
return null;
}
return node.graph?.links?.[input.link] ?? null;
}
function getUpstreamNode(node, inputName) {
const link = getInputLink(node, inputName);
if (!link) {
return null;
}
return node.graph?.getNodeById?.(link.origin_id) ?? null;
}
function buildViewUrl(filename, type = "input") {
if (!filename) {
return null;
}
const relativePath = `/view?filename=${encodeURIComponent(String(filename))}&type=${encodeURIComponent(type)}`;
return typeof api?.apiURL === "function" ? api.apiURL(relativePath) : relativePath;
}
function resolveImageSource(node, inputName) {
const upstream = getUpstreamNode(node, inputName);
const imageWidget = upstream?.widgets?.find((widget) => widget.name === "image");
if (!imageWidget?.value) {
return null;
}
const className = String(upstream?.comfyClass || upstream?.type || "");
if (className === "LoadImage") {
return buildViewUrl(imageWidget.value, "input");
}
if (className === "LoadImageOutput") {
return buildViewUrl(imageWidget.value, "output");
}
return null;
}
```
- [ ] **Step 2: Implement preview rendering and drag-to-offset synchronization**
```javascript
function renderCompositePreview(context, state) {
const { backgroundImage, overlayImage, xOffset, yOffset, anchorMode, rotationOriginMode, opacity, scale, rotation } = state;
if (!backgroundImage || !overlayImage) {
context.clearRect(0, 0, context.canvas.width, context.canvas.height);
context.fillStyle = "#1b1b1b";
context.fillRect(0, 0, context.canvas.width, context.canvas.height);
context.fillStyle = "#9a9a9a";
context.fillText("Connect Load Image / Load Image Output for preview.", 14, 24);
return;
}
context.clearRect(0, 0, context.canvas.width, context.canvas.height);
context.drawImage(backgroundImage, 0, 0, context.canvas.width, context.canvas.height);
const scaledWidth = overlayImage.width * scale;
const scaledHeight = overlayImage.height * scale;
const topLeftX = anchorMode === "center" ? xOffset - (scaledWidth / 2) : xOffset;
const topLeftY = anchorMode === "center" ? yOffset - (scaledHeight / 2) : yOffset;
const rotationCenterX = rotationOriginMode === "center" ? (topLeftX + scaledWidth / 2) : topLeftX;
const rotationCenterY = rotationOriginMode === "center" ? (topLeftY + scaledHeight / 2) : topLeftY;
context.save();
context.globalAlpha = opacity;
context.translate(rotationCenterX, rotationCenterY);
context.rotate((rotation * Math.PI) / 180);
context.translate(-rotationCenterX, -rotationCenterY);
context.drawImage(overlayImage, topLeftX, topLeftY, scaledWidth, scaledHeight);
context.restore();
}
```
```javascript
app.registerExtension({
name: EXTENSION_NAME,
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== TARGET_NODE_CLASS) {
return;
}
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function onNodeCreatedPatched() {
onNodeCreated?.apply(this, arguments);
const canvas = document.createElement("canvas");
canvas.width = 320;
canvas.height = 220;
const context = canvas.getContext("2d");
let dragging = false;
let dragStart = null;
canvas.addEventListener("pointerdown", (event) => {
dragging = true;
dragStart = { x: event.clientX, y: event.clientY };
canvas.setPointerCapture?.(event.pointerId);
});
canvas.addEventListener("pointermove", (event) => {
if (!dragging || !dragStart) {
return;
}
const xWidget = findWidget(this, "x_offset");
const yWidget = findWidget(this, "y_offset");
const deltaX = Math.round(event.clientX - dragStart.x);
const deltaY = Math.round(event.clientY - dragStart.y);
xWidget.value = Number(xWidget.value || 0) + deltaX;
yWidget.value = Number(yWidget.value || 0) + deltaY;
dragStart = { x: event.clientX, y: event.clientY };
this.setDirtyCanvas?.(true, true);
});
canvas.addEventListener("pointerup", () => {
dragging = false;
dragStart = null;
});
this.addDOMWidget("preview", "preview", canvas, {
serialize: false,
hideOnZoom: false,
});
};
},
});
```
- [ ] **Step 3: Run the frontend tests to verify they pass**
Run: `python3 -m unittest tests.test_image_composite_frontend`
Expected: PASS
- [ ] **Step 4: Commit the frontend work**
```bash
git add web/js/lls_image_composite.js tests/test_image_composite_frontend.py
git commit -m "Add LLS image composite preview extension"
```
### Task 6: Update README And Verify Documentation Coverage
**Files:**
- Modify: `README.md`
- Test: `tests/test_image_composite_docs.py`
- [ ] **Step 1: Add the new README section**
```markdown
### `LLS Simple Image Composite`
`LLS Simple Image Composite` 用于把一张前景图叠加到背景图上,并输出最终合成图像。背景图尺寸始终作为最终输出尺寸,前景图支持平移、缩放、旋转和透明度控制。
**输入:**
| 名称 | 类型 | 说明 |
|------|------|------|
| `background_image` | `IMAGE` | 背景图 |
| `overlay_image` | `IMAGE` | 前景图 |
| `x_offset` | `INT` | 前景图水平偏移 |
| `y_offset` | `INT` | 前景图垂直偏移 |
| `anchor_mode` | `top_left / center` | 前景图定位方式 |
| `rotation_origin_mode` | `top_left / center` | 前景图旋转原点 |
| `opacity` | `FLOAT` | 前景图透明度 |
| `scale` | `FLOAT` | 前景图缩放倍率 |
| `rotation` | `FLOAT` | 前景图旋转角度 |
| `blend_mode` | `normal` | 当前只支持 normal |
| `keep_aspect` | `BOOLEAN` | 当前保持等比缩放 |
**输出:**
| 名称 | 类型 | 说明 |
|------|------|------|
| `output_image` | `IMAGE` | 合成后的最终图像 |
**典型用途:**
- `Load Image(background) + Load Image(overlay) -> LLS Simple Image Composite -> Preview Image`
- 把 logo 贴到背景图指定位置
- 拖动前景图实时预览位置、缩放和旋转
**说明:**
- 前景图超出背景范围时会自动裁剪
- 如果前景图带 alpha 通道,会优先按 alpha 合成
- 节点内实时预览优先支持 `Load Image` / `Load Image Output` 作为上游文件来源
```
- [ ] **Step 2: Run the docs tests to verify they pass**
Run: `python3 -m unittest tests.test_image_composite_docs`
Expected: PASS
### Task 7: Run Full Verification And Final Commit
**Files:**
- Modify: `docs/superpowers/plans/2026-05-25-lls-simple-image-composite.md`
- Test: `tests/test_image_composite_registration.py`
- Test: `tests/test_image_composite_node.py`
- Test: `tests/test_image_composite_frontend.py`
- Test: `tests/test_image_composite_docs.py`
- [ ] **Step 1: Run the focused image composite test suite**
Run: `python3 -m unittest tests.test_image_composite_registration tests.test_image_composite_node tests.test_image_composite_frontend tests.test_image_composite_docs`
Expected: PASS
- [ ] **Step 2: Run the full repository test suite**
Run: `python3 -m unittest discover -s tests`
Expected: PASS with `0 failed`
- [ ] **Step 3: Commit the completed feature**
```bash
git add README.md image/__init__.py image/composite.py image/composite_utils.py \
web/js/lls_image_composite.js \
tests/test_image_composite_helpers.py tests/test_image_composite_registration.py \
tests/test_image_composite_node.py tests/test_image_composite_frontend.py tests/test_image_composite_docs.py \
docs/superpowers/plans/2026-05-25-lls-simple-image-composite.md
git commit -m "Add LLS Simple Image Composite node"
```
+17 -1
View File
@@ -3,6 +3,22 @@ node.image
==========
功能域:图像处理与后处理(对应功能分类总览第 5 节)
"""
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
from .composite import (
NODE_CLASS_MAPPINGS as COMPOSITE_NODE_CLASS_MAPPINGS,
NODE_DISPLAY_NAME_MAPPINGS as COMPOSITE_NODE_DISPLAY_NAME_MAPPINGS,
)
from .nodes import (
NODE_CLASS_MAPPINGS as CORE_NODE_CLASS_MAPPINGS,
NODE_DISPLAY_NAME_MAPPINGS as CORE_NODE_DISPLAY_NAME_MAPPINGS,
)
NODE_CLASS_MAPPINGS = {}
NODE_CLASS_MAPPINGS.update(CORE_NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(COMPOSITE_NODE_CLASS_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS.update(CORE_NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(COMPOSITE_NODE_DISPLAY_NAME_MAPPINGS)
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
from .composite_utils import (
ANCHOR_MODE_CHOICES,
BLEND_MODE_CHOICES,
ROTATION_ORIGIN_MODE_CHOICES,
composite_images,
)
class LLSSimpleImageComposite:
CATEGORY = "LLS/Image"
FUNCTION = "composite"
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("output_image",)
DESCRIPTION = "Composite an overlay image onto a background image with translation, scale, rotation, and opacity."
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"background_image": ("IMAGE",),
"overlay_image": ("IMAGE",),
"x_offset": ("INT", {"default": 0, "min": -8192, "max": 8192, "step": 1}),
"y_offset": ("INT", {"default": 0, "min": -8192, "max": 8192, "step": 1}),
"anchor_mode": (ANCHOR_MODE_CHOICES, {"default": "top_left"}),
"rotation_origin_mode": (ROTATION_ORIGIN_MODE_CHOICES, {"default": "center"}),
"opacity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"blend_mode": (BLEND_MODE_CHOICES, {"default": "normal"}),
"scale": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 32.0, "step": 0.01}),
"rotation": ("FLOAT", {"default": 0.0, "min": -360.0, "max": 360.0, "step": 0.1}),
"keep_aspect": ("BOOLEAN", {"default": True}),
}
}
def composite(
self,
background_image,
overlay_image,
x_offset,
y_offset,
anchor_mode,
rotation_origin_mode,
opacity,
blend_mode,
scale,
rotation,
keep_aspect,
):
output_image = composite_images(
background_image,
overlay_image,
x_offset=x_offset,
y_offset=y_offset,
anchor_mode=anchor_mode,
rotation_origin_mode=rotation_origin_mode,
opacity=opacity,
blend_mode=blend_mode,
scale=scale,
rotation=rotation,
keep_aspect=keep_aspect,
)
return (output_image,)
NODE_CLASS_MAPPINGS = {"LLSSimpleImageComposite": LLSSimpleImageComposite}
NODE_DISPLAY_NAME_MAPPINGS = {"LLSSimpleImageComposite": "LLS Simple Image Composite"}
+180
View File
@@ -0,0 +1,180 @@
from __future__ import annotations
try:
import numpy as np
except Exception as exc: # pragma: no cover
np = None
_NUMPY_ERR = exc
else: # pragma: no cover
_NUMPY_ERR = None
try:
from PIL import Image
except Exception as exc: # pragma: no cover
Image = None
_PIL_ERR = exc
else: # pragma: no cover
_PIL_ERR = None
try:
import torch
except Exception as exc: # pragma: no cover
torch = None
_TORCH_ERR = exc
else: # pragma: no cover
_TORCH_ERR = None
ANCHOR_MODE_CHOICES = ["top_left", "center"]
ROTATION_ORIGIN_MODE_CHOICES = ["top_left", "center"]
BLEND_MODE_CHOICES = ["normal"]
def _require_runtime_deps():
if torch is None:
raise RuntimeError("[LLS] torch is required for image compositing.") from _TORCH_ERR
if np is None:
raise RuntimeError("[LLS] numpy is required for image compositing.") from _NUMPY_ERR
if Image is None:
raise RuntimeError("[LLS] Pillow is required for image compositing.") from _PIL_ERR
def _require_image_tensor(name, image):
shape = tuple(getattr(image, "shape", ()))
if len(shape) != 4:
raise RuntimeError(f"[LLS] {name} must have shape [batch, height, width, channels].")
if int(shape[0]) <= 0 or int(shape[1]) <= 0 or int(shape[2]) <= 0:
raise RuntimeError(f"[LLS] {name} must have positive batch, width, and height.")
channels = int(shape[3])
if channels not in {3, 4}:
raise RuntimeError(f"[LLS] {name} must have 3 or 4 channels; got {channels}.")
def _tensor_sample_to_rgba_pil(sample):
rgba = sample.detach().cpu().clamp(0.0, 1.0).numpy()
if rgba.shape[-1] == 3:
alpha = np.ones((rgba.shape[0], rgba.shape[1], 1), dtype=np.float32)
rgba = np.concatenate([rgba, alpha], axis=-1)
rgba = np.clip(np.round(rgba * 255.0), 0, 255).astype(np.uint8)
return Image.fromarray(rgba, mode="RGBA")
def _pil_to_tensor(image, *, device, dtype):
array = np.asarray(image, dtype=np.float32) / 255.0
if array.ndim != 3 or array.shape[-1] != 4:
raise RuntimeError("[LLS] compositing helper expected an RGBA PIL image.")
rgb = array[..., :3]
return torch.from_numpy(rgb).to(device=device, dtype=dtype)
def _scaled_overlay_rgba(overlay_rgba, scale):
width, height = overlay_rgba.size
scaled_width = max(1, int(round(width * scale)))
scaled_height = max(1, int(round(height * scale)))
if (scaled_width, scaled_height) == (width, height):
return overlay_rgba
return overlay_rgba.resize((scaled_width, scaled_height), Image.BICUBIC)
def _resolve_top_left(width, height, x_offset, y_offset, anchor_mode):
if anchor_mode == "top_left":
return int(x_offset), int(y_offset)
if anchor_mode == "center":
return int(x_offset - (width / 2.0)), int(y_offset - (height / 2.0))
raise RuntimeError(f"[LLS] Unsupported anchor_mode '{anchor_mode}'.")
def _resolve_rotation_center(top_left_x, top_left_y, width, height, rotation_origin_mode):
if rotation_origin_mode == "top_left":
return float(top_left_x), float(top_left_y)
if rotation_origin_mode == "center":
return float(top_left_x) + (width / 2.0), float(top_left_y) + (height / 2.0)
raise RuntimeError(f"[LLS] Unsupported rotation_origin_mode '{rotation_origin_mode}'.")
def composite_images(
background_image,
overlay_image,
*,
x_offset,
y_offset,
anchor_mode,
rotation_origin_mode,
opacity,
blend_mode,
scale,
rotation,
keep_aspect,
):
del keep_aspect
_require_runtime_deps()
if background_image is None:
raise RuntimeError("[LLS] background_image is required for image compositing.")
if overlay_image is None:
raise RuntimeError("[LLS] overlay_image is required for image compositing.")
if blend_mode != "normal":
raise RuntimeError(f"[LLS] Unsupported blend_mode '{blend_mode}'.")
_require_image_tensor("background_image", background_image)
_require_image_tensor("overlay_image", overlay_image)
clamped_scale = max(0.01, float(scale))
clamped_opacity = max(0.0, min(1.0, float(opacity)))
if clamped_opacity == 0.0:
return background_image[..., :3].clone()
background_batch = int(background_image.shape[0])
overlay_batch = int(overlay_image.shape[0])
if overlay_batch not in {1, background_batch}:
raise RuntimeError(
"[LLS] overlay_image batch must be 1 or match background_image batch; "
f"got {overlay_batch} and {background_batch}."
)
results = []
for index in range(background_batch):
background_sample = background_image[index]
overlay_sample = overlay_image[0 if overlay_batch == 1 else index]
background_rgba = _tensor_sample_to_rgba_pil(background_sample)
overlay_rgba = _scaled_overlay_rgba(_tensor_sample_to_rgba_pil(overlay_sample), clamped_scale)
top_left_x, top_left_y = _resolve_top_left(
overlay_rgba.size[0],
overlay_rgba.size[1],
int(x_offset),
int(y_offset),
anchor_mode,
)
overlay_canvas = Image.new("RGBA", background_rgba.size, (0, 0, 0, 0))
overlay_canvas.paste(overlay_rgba, (top_left_x, top_left_y), overlay_rgba)
if float(rotation) != 0.0:
rotation_center = _resolve_rotation_center(
top_left_x,
top_left_y,
overlay_rgba.size[0],
overlay_rgba.size[1],
rotation_origin_mode,
)
overlay_canvas = overlay_canvas.rotate(
float(rotation),
resample=Image.BICUBIC,
center=rotation_center,
expand=False,
)
if clamped_opacity < 1.0:
overlay_alpha = overlay_canvas.getchannel("A")
overlay_alpha = overlay_alpha.point(lambda value: int(round(value * clamped_opacity)))
overlay_canvas.putalpha(overlay_alpha)
composed = Image.alpha_composite(background_rgba, overlay_canvas)
results.append(
_pil_to_tensor(
composed,
device=background_image.device,
dtype=background_image.dtype,
)
)
return torch.stack(results, dim=0)
+2 -2
View File
@@ -27,8 +27,8 @@ def _resolve_batch_for_mask_creation(input_mask) -> int:
class LLSSimpleMaskCreate:
CATEGORY = "LLS/Mask"
FUNCTION = "create_mask"
RETURN_TYPES = ("IMAGE", "MASK", MASK_INFO_TYPE)
RETURN_NAMES = ("mask_image", "mask", "area_info")
RETURN_TYPES = ("MASK", "IMAGE", MASK_INFO_TYPE)
RETURN_NAMES = ("mask", "mask_image", "area_info")
DESCRIPTION = "Create a geometric mask image from width, height, and shape parameters."
@classmethod
+28
View File
@@ -0,0 +1,28 @@
import unittest
try:
from .test_image_composite_helpers import ROOT
except ImportError:
from test_image_composite_helpers import ROOT
class TestImageCompositeDocs(unittest.TestCase):
def test_readme_documents_image_composite_node(self):
readme = (ROOT / "README.md").read_text(encoding="utf-8")
self.assertIn("LLS Simple Image Composite", readme)
self.assertIn("background_image", readme)
self.assertIn("overlay_image", readme)
self.assertIn("output_image", readme)
self.assertIn("x_offset", readme)
self.assertIn("y_offset", readme)
self.assertIn("anchor_mode", readme)
self.assertIn("rotation_origin_mode", readme)
self.assertIn("opacity", readme)
self.assertIn("scale", readme)
self.assertIn("rotation", readme)
self.assertIn("keep_aspect", readme)
if __name__ == "__main__":
unittest.main()
+32
View File
@@ -0,0 +1,32 @@
import unittest
try:
from .test_image_composite_helpers import ROOT, load_plugin_package
except ImportError:
from test_image_composite_helpers import ROOT, load_plugin_package
class TestImageCompositeFrontend(unittest.TestCase):
def test_plugin_exports_web_directory(self):
plugin = load_plugin_package()
self.assertEqual(plugin.WEB_DIRECTORY, "./web")
def test_frontend_asset_exists(self):
asset = ROOT / "web" / "js" / "lls_image_composite.js"
self.assertTrue(asset.exists(), msg=f"Missing frontend asset: {asset}")
def test_frontend_asset_registers_image_composite_extension(self):
asset = (ROOT / "web" / "js" / "lls_image_composite.js").read_text(encoding="utf-8")
self.assertIn("app.registerExtension", asset)
self.assertIn("LLSSimpleImageComposite", asset)
self.assertIn("LLS Simple Image Composite", asset)
self.assertIn("beforeRegisterNodeDef", asset)
self.assertIn("addDOMWidget", asset)
self.assertIn("pointerdown", asset)
self.assertIn("pointermove", asset)
self.assertIn("rotation_origin_mode", asset)
if __name__ == "__main__":
unittest.main()
+58
View File
@@ -0,0 +1,58 @@
import importlib
import importlib.util
import pathlib
import sys
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
MODULE_NAME = "lls_node_test_image_composite"
def _import_optional(module_name):
try:
return importlib.import_module(module_name)
except Exception:
return None
torch = _import_optional("torch")
np = _import_optional("numpy")
_pil_image_module = _import_optional("PIL.Image")
Image = _pil_image_module
HAS_IMAGE_COMPOSITE_RUNTIME_DEPS = torch is not None and np is not None and Image is not None
IMAGE_COMPOSITE_RUNTIME_DEPS_MESSAGE = "image composite tests require torch, numpy, and Pillow"
def load_plugin_package():
for name in list(sys.modules):
if name == MODULE_NAME or name.startswith(f"{MODULE_NAME}."):
sys.modules.pop(name)
spec = importlib.util.spec_from_file_location(
MODULE_NAME,
ROOT / "__init__.py",
submodule_search_locations=[str(ROOT)],
)
module = importlib.util.module_from_spec(spec)
sys.modules[MODULE_NAME] = module
spec.loader.exec_module(module)
return module
def require_image_composite_runtime_deps():
if not HAS_IMAGE_COMPOSITE_RUNTIME_DEPS:
raise unittest.SkipTest(IMAGE_COMPOSITE_RUNTIME_DEPS_MESSAGE)
def make_image(width=8, height=8, color=(0.0, 0.0, 0.0), alpha=None):
require_image_composite_runtime_deps()
rgb = tuple(float(channel) for channel in color)
channels = 4 if alpha is not None else 3
tensor = torch.zeros((1, height, width, channels), dtype=torch.float32)
tensor[..., 0] = rgb[0]
tensor[..., 1] = rgb[1]
tensor[..., 2] = rgb[2]
if alpha is not None:
tensor[..., 3] = float(alpha)
return tensor
+204
View File
@@ -0,0 +1,204 @@
import unittest
try:
from .test_image_composite_helpers import (
HAS_IMAGE_COMPOSITE_RUNTIME_DEPS,
IMAGE_COMPOSITE_RUNTIME_DEPS_MESSAGE,
load_plugin_package,
make_image,
)
except ImportError:
from test_image_composite_helpers import (
HAS_IMAGE_COMPOSITE_RUNTIME_DEPS,
IMAGE_COMPOSITE_RUNTIME_DEPS_MESSAGE,
load_plugin_package,
make_image,
)
@unittest.skipUnless(HAS_IMAGE_COMPOSITE_RUNTIME_DEPS, IMAGE_COMPOSITE_RUNTIME_DEPS_MESSAGE)
class TestImageCompositeNode(unittest.TestCase):
@classmethod
def setUpClass(cls):
plugin = load_plugin_package()
cls.node = plugin.NODE_CLASS_MAPPINGS["LLSSimpleImageComposite"]()
def test_top_left_offset_places_overlay_at_requested_position(self):
background = make_image(width=6, height=6, color=(0.0, 0.0, 0.0))
overlay = make_image(width=2, height=2, color=(1.0, 1.0, 1.0))
(output_image,) = self.node.composite(
background_image=background,
overlay_image=overlay,
x_offset=3,
y_offset=1,
anchor_mode="top_left",
rotation_origin_mode="center",
opacity=1.0,
blend_mode="normal",
scale=1.0,
rotation=0.0,
keep_aspect=True,
)
self.assertEqual(tuple(output_image.shape), (1, 6, 6, 3))
self.assertTrue((output_image[0, 1:3, 3:5] > 0.99).all().item())
self.assertEqual(float(output_image[0, 0, 0, 0].item()), 0.0)
def test_center_anchor_places_overlay_by_its_center(self):
background = make_image(width=6, height=6, color=(0.0, 0.0, 0.0))
overlay = make_image(width=2, height=2, color=(0.0, 1.0, 0.0))
(output_image,) = self.node.composite(
background_image=background,
overlay_image=overlay,
x_offset=3,
y_offset=2,
anchor_mode="center",
rotation_origin_mode="center",
opacity=1.0,
blend_mode="normal",
scale=1.0,
rotation=0.0,
keep_aspect=True,
)
self.assertTrue((output_image[0, 1:3, 2:4, 1] > 0.99).all().item())
def test_partial_overflow_is_clipped_to_background_bounds(self):
background = make_image(width=5, height=5, color=(0.0, 0.0, 0.0))
overlay = make_image(width=3, height=3, color=(1.0, 0.0, 0.0))
(output_image,) = self.node.composite(
background_image=background,
overlay_image=overlay,
x_offset=4,
y_offset=4,
anchor_mode="top_left",
rotation_origin_mode="center",
opacity=1.0,
blend_mode="normal",
scale=1.0,
rotation=0.0,
keep_aspect=True,
)
self.assertGreater(float(output_image[0, 4, 4, 0].item()), 0.99)
self.assertEqual(float(output_image[0, 0, 0, 0].item()), 0.0)
def test_overlay_fully_outside_returns_background(self):
background = make_image(width=5, height=5, color=(0.1, 0.1, 0.1))
overlay = make_image(width=2, height=2, color=(1.0, 1.0, 1.0))
(output_image,) = self.node.composite(
background_image=background,
overlay_image=overlay,
x_offset=8,
y_offset=8,
anchor_mode="top_left",
rotation_origin_mode="center",
opacity=1.0,
blend_mode="normal",
scale=1.0,
rotation=0.0,
keep_aspect=True,
)
self.assertTrue((output_image == background).all().item())
def test_opacity_zero_returns_background(self):
background = make_image(width=4, height=4, color=(0.2, 0.2, 0.2))
overlay = make_image(width=2, height=2, color=(1.0, 0.0, 0.0))
(output_image,) = self.node.composite(
background_image=background,
overlay_image=overlay,
x_offset=1,
y_offset=1,
anchor_mode="top_left",
rotation_origin_mode="center",
opacity=0.0,
blend_mode="normal",
scale=1.0,
rotation=0.0,
keep_aspect=True,
)
self.assertTrue((output_image == background).all().item())
def test_rgba_overlay_uses_alpha_and_opacity(self):
background = make_image(width=4, height=4, color=(0.0, 0.0, 0.0))
overlay = make_image(width=2, height=2, color=(1.0, 0.0, 0.0), alpha=0.5)
(output_image,) = self.node.composite(
background_image=background,
overlay_image=overlay,
x_offset=1,
y_offset=1,
anchor_mode="top_left",
rotation_origin_mode="center",
opacity=0.5,
blend_mode="normal",
scale=1.0,
rotation=0.0,
keep_aspect=True,
)
self.assertAlmostEqual(float(output_image[0, 1, 1, 0].item()), 0.25, places=2)
def test_scale_expands_overlay_footprint(self):
background = make_image(width=5, height=5, color=(0.0, 0.0, 0.0))
overlay = make_image(width=1, height=1, color=(1.0, 1.0, 1.0))
(output_image,) = self.node.composite(
background_image=background,
overlay_image=overlay,
x_offset=1,
y_offset=1,
anchor_mode="top_left",
rotation_origin_mode="center",
opacity=1.0,
blend_mode="normal",
scale=2.0,
rotation=0.0,
keep_aspect=True,
)
self.assertTrue((output_image[0, 1:3, 1:3] > 0.99).all().item())
def test_rotation_origin_mode_changes_rotated_result(self):
background = make_image(width=8, height=8, color=(0.0, 0.0, 0.0))
overlay = make_image(width=3, height=1, color=(0.0, 0.0, 1.0))
(center_rotated,) = self.node.composite(
background_image=background,
overlay_image=overlay,
x_offset=3,
y_offset=3,
anchor_mode="top_left",
rotation_origin_mode="center",
opacity=1.0,
blend_mode="normal",
scale=1.0,
rotation=90.0,
keep_aspect=True,
)
(corner_rotated,) = self.node.composite(
background_image=background,
overlay_image=overlay,
x_offset=3,
y_offset=3,
anchor_mode="top_left",
rotation_origin_mode="top_left",
opacity=1.0,
blend_mode="normal",
scale=1.0,
rotation=90.0,
keep_aspect=True,
)
self.assertFalse((center_rotated == corner_rotated).all().item())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,45 @@
import unittest
try:
from .test_image_composite_helpers import load_plugin_package
except ImportError:
from test_image_composite_helpers import load_plugin_package
class TestImageCompositeRegistration(unittest.TestCase):
def test_plugin_registers_image_composite_node(self):
plugin = load_plugin_package()
self.assertIn("LLSSimpleImageComposite", plugin.NODE_CLASS_MAPPINGS)
self.assertEqual(
plugin.NODE_DISPLAY_NAME_MAPPINGS["LLSSimpleImageComposite"],
"LLS Simple Image Composite",
)
def test_image_composite_schema_matches_contract(self):
plugin = load_plugin_package()
node_cls = plugin.NODE_CLASS_MAPPINGS["LLSSimpleImageComposite"]
schema = node_cls.INPUT_TYPES()
required = schema["required"]
self.assertEqual(node_cls.CATEGORY, "LLS/Image")
self.assertEqual(node_cls.FUNCTION, "composite")
self.assertEqual(node_cls.RETURN_TYPES, ("IMAGE",))
self.assertEqual(node_cls.RETURN_NAMES, ("output_image",))
self.assertEqual(required["background_image"], ("IMAGE",))
self.assertEqual(required["overlay_image"], ("IMAGE",))
self.assertEqual(required["x_offset"][0], "INT")
self.assertEqual(required["x_offset"][1]["default"], 0)
self.assertEqual(required["y_offset"][0], "INT")
self.assertEqual(required["anchor_mode"][0], ["top_left", "center"])
self.assertEqual(required["rotation_origin_mode"][0], ["top_left", "center"])
self.assertEqual(required["opacity"][0], "FLOAT")
self.assertEqual(required["opacity"][1]["default"], 1.0)
self.assertEqual(required["blend_mode"][0], ["normal"])
self.assertEqual(required["scale"][0], "FLOAT")
self.assertEqual(required["rotation"][0], "FLOAT")
self.assertEqual(required["keep_aspect"][0], "BOOLEAN")
if __name__ == "__main__":
unittest.main()
+469
View File
@@ -0,0 +1,469 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const EXTENSION_NAME = "LLS.ImageComposite";
const TARGET_NODE_CLASS = "LLSSimpleImageComposite";
const TARGET_NODE_DISPLAY_NAME = "LLS Simple Image Composite";
const MAX_PREVIEW_EDGE = 420;
const MIN_PREVIEW_EDGE = 220;
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function asNumber(value, fallback) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function findWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name) ?? null;
}
function getNodeClassName(node) {
return String(node?.comfyClass || node?.constructor?.comfyClass || node?.type || node?.title || "");
}
function chainWidgetCallback(widget, handler) {
if (!widget || widget.__llsImageCompositeChained) {
return;
}
const previous = widget.callback;
widget.callback = function chainedImageCompositeCallback() {
previous?.apply(this, arguments);
handler?.apply(this, arguments);
};
widget.__llsImageCompositeChained = true;
}
function normalizePath(rawValue) {
return String(rawValue || "").trim().replace(/\\/g, "/");
}
function splitAnnotatedPath(rawValue) {
const normalized = normalizePath(rawValue);
const match = normalized.match(/^(.*?)(?:\s+\[([^\]]+)\])?$/);
const path = match?.[1] ?? normalized;
const annotatedType = match?.[2] ?? "";
const slashIndex = path.lastIndexOf("/");
return {
path,
annotatedType,
subfolder: slashIndex >= 0 ? path.slice(0, slashIndex) : "",
filename: slashIndex >= 0 ? path.slice(slashIndex + 1) : path,
};
}
function buildViewUrl(rawValue, fallbackType) {
const normalized = normalizePath(rawValue);
if (!normalized) {
return null;
}
const params = new URLSearchParams();
if (normalized.startsWith("blake3:")) {
params.set("filename", normalized);
} else {
const parsed = splitAnnotatedPath(normalized);
if (!parsed.filename) {
return null;
}
params.set("filename", parsed.filename);
if (parsed.subfolder) {
params.set("subfolder", parsed.subfolder);
}
params.set("type", parsed.annotatedType || fallbackType || "input");
}
const relativePath = `/view?${params.toString()}`;
return typeof api?.apiURL === "function" ? api.apiURL(relativePath) : relativePath;
}
function getInputLink(node, inputName) {
const input = node.inputs?.find((item) => item.name === inputName);
if (!input || input.link == null) {
return null;
}
return node.graph?.links?.[input.link] ?? null;
}
function getUpstreamNode(node, inputName) {
const link = getInputLink(node, inputName);
if (!link) {
return null;
}
return node.graph?.getNodeById?.(link.origin_id) ?? null;
}
function resolveImageSource(node, inputName) {
const upstream = getUpstreamNode(node, inputName);
const className = getNodeClassName(upstream);
const imageWidget = findWidget(upstream, "image");
if (!imageWidget?.value) {
return null;
}
if (className === "LoadImage") {
return {
url: buildViewUrl(imageWidget.value, "input"),
label: "LoadImage",
};
}
if (className === "LoadImageOutput") {
return {
url: buildViewUrl(imageWidget.value, "output"),
label: "LoadImageOutput",
};
}
return null;
}
function loadImageElement(url) {
return new Promise((resolve, reject) => {
if (!url) {
resolve(null);
return;
}
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`Failed to load image: ${url}`));
image.src = url;
});
}
function computeDisplaySize(width, height) {
const safeWidth = Math.max(1, Math.round(width));
const safeHeight = Math.max(1, Math.round(height));
const maxEdge = Math.max(safeWidth, safeHeight);
let scale = 1;
if (maxEdge > MAX_PREVIEW_EDGE) {
scale = MAX_PREVIEW_EDGE / maxEdge;
} else if (maxEdge < MIN_PREVIEW_EDGE) {
scale = MIN_PREVIEW_EDGE / maxEdge;
}
return {
width: Math.max(1, Math.round(safeWidth * scale)),
height: Math.max(1, Math.round(safeHeight * scale)),
};
}
function drawHint(context, lines) {
context.clearRect(0, 0, context.canvas.width, context.canvas.height);
context.fillStyle = "#151515";
context.fillRect(0, 0, context.canvas.width, context.canvas.height);
context.fillStyle = "#b7b7b7";
context.font = "12px sans-serif";
lines.forEach((line, index) => {
context.fillText(line, 14, 24 + index * 18);
});
}
function attachCompositePreview(node) {
if (node.__llsImageCompositePreview || typeof node.addDOMWidget !== "function") {
return node.__llsImageCompositePreview ?? null;
}
const xWidget = findWidget(node, "x_offset");
const yWidget = findWidget(node, "y_offset");
const anchorWidget = findWidget(node, "anchor_mode");
const rotationOriginWidget = findWidget(node, "rotation_origin_mode");
const opacityWidget = findWidget(node, "opacity");
const scaleWidget = findWidget(node, "scale");
const rotationWidget = findWidget(node, "rotation");
const container = document.createElement("div");
container.style.display = "flex";
container.style.flexDirection = "column";
container.style.alignItems = "flex-start";
container.style.gap = "8px";
container.style.width = "100%";
container.style.boxSizing = "border-box";
const frame = document.createElement("div");
frame.style.display = "inline-flex";
frame.style.border = "1px solid #4e4e4e";
frame.style.borderRadius = "8px";
frame.style.overflow = "hidden";
frame.style.background = "#111";
const canvas = document.createElement("canvas");
canvas.style.display = "block";
canvas.style.cursor = "grab";
canvas.style.touchAction = "none";
canvas.style.userSelect = "none";
frame.appendChild(canvas);
const status = document.createElement("div");
status.style.fontSize = "12px";
status.style.lineHeight = "1.45";
status.style.color = "#b8b8b8";
status.style.maxWidth = "420px";
status.style.whiteSpace = "normal";
container.append(frame, status);
const domWidget = node.addDOMWidget("lls_image_composite_preview", "preview", container);
domWidget.serialize = false;
if (domWidget.options) {
domWidget.options.hideOnZoom = false;
}
const context = canvas.getContext("2d");
const state = {
backgroundImage: null,
overlayImage: null,
backgroundSource: null,
overlaySource: null,
backgroundConnectionSupported: true,
overlayConnectionSupported: true,
loadToken: 0,
dragging: false,
dragStartClientX: 0,
dragStartClientY: 0,
dragStartOffsetX: 0,
dragStartOffsetY: 0,
};
function updateStatus(message) {
status.textContent = message;
}
function updateNodeSize() {
const width = state.backgroundImage?.naturalWidth || 320;
const height = state.backgroundImage?.naturalHeight || 220;
const displaySize = computeDisplaySize(width, height);
canvas.width = displaySize.width;
canvas.height = displaySize.height;
canvas.style.width = `${displaySize.width}px`;
canvas.style.height = `${displaySize.height}px`;
frame.style.width = `${displaySize.width}px`;
container.style.width = `${displaySize.width}px`;
if (typeof node.setSize === "function") {
requestAnimationFrame(() => {
node.setSize([
Math.max(360, displaySize.width + 28),
Math.max(250, displaySize.height + 84),
]);
app.graph?.setDirtyCanvas(true, true);
});
}
}
function renderPreview() {
updateNodeSize();
const backgroundImage = state.backgroundImage;
const overlayImage = state.overlayImage;
if (!backgroundImage || !overlayImage) {
const lines = [];
if (!getInputLink(node, "background_image") || !getInputLink(node, "overlay_image")) {
lines.push("Connect background_image and overlay_image");
lines.push("from Load Image / Load Image Output for preview.");
} else {
lines.push("Preview supports Load Image / Load Image Output");
lines.push("sources in this first version.");
}
drawHint(context, lines);
return;
}
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(backgroundImage, 0, 0, canvas.width, canvas.height);
const backgroundWidth = Math.max(1, backgroundImage.naturalWidth || backgroundImage.width || canvas.width);
const backgroundHeight = Math.max(1, backgroundImage.naturalHeight || backgroundImage.height || canvas.height);
const canvasScaleX = canvas.width / backgroundWidth;
const canvasScaleY = canvas.height / backgroundHeight;
const scaleValue = Math.max(0.01, asNumber(scaleWidget?.value, 1));
const rotationValue = asNumber(rotationWidget?.value, 0);
const opacityValue = clamp(asNumber(opacityWidget?.value, 1), 0, 1);
const anchorMode = String(anchorWidget?.value || "top_left");
const rotationOriginMode = String(rotationOriginWidget?.value || "center");
const xOffset = asNumber(xWidget?.value, 0);
const yOffset = asNumber(yWidget?.value, 0);
const overlayWidth = Math.max(1, (overlayImage.naturalWidth || overlayImage.width || 1) * scaleValue);
const overlayHeight = Math.max(1, (overlayImage.naturalHeight || overlayImage.height || 1) * scaleValue);
const topLeftX = anchorMode === "center" ? xOffset - overlayWidth / 2 : xOffset;
const topLeftY = anchorMode === "center" ? yOffset - overlayHeight / 2 : yOffset;
const rotationCenterX = rotationOriginMode === "center" ? topLeftX + overlayWidth / 2 : topLeftX;
const rotationCenterY = rotationOriginMode === "center" ? topLeftY + overlayHeight / 2 : topLeftY;
context.save();
context.globalAlpha = opacityValue;
context.translate(rotationCenterX * canvasScaleX, rotationCenterY * canvasScaleY);
context.rotate((rotationValue * Math.PI) / 180);
context.translate(-rotationCenterX * canvasScaleX, -rotationCenterY * canvasScaleY);
context.drawImage(
overlayImage,
topLeftX * canvasScaleX,
topLeftY * canvasScaleY,
overlayWidth * canvasScaleX,
overlayHeight * canvasScaleY,
);
context.restore();
}
function setWidgetValue(widget, value) {
if (!widget) {
return;
}
widget.value = value;
widget.callback?.(value);
}
async function reloadSources() {
const backgroundSource = resolveImageSource(node, "background_image");
const overlaySource = resolveImageSource(node, "overlay_image");
state.backgroundSource = backgroundSource?.url ?? null;
state.overlaySource = overlaySource?.url ?? null;
state.backgroundConnectionSupported = !getInputLink(node, "background_image") || Boolean(backgroundSource?.url);
state.overlayConnectionSupported = !getInputLink(node, "overlay_image") || Boolean(overlaySource?.url);
const token = ++state.loadToken;
updateStatus("Loading composite preview...");
try {
const [backgroundImage, overlayImage] = await Promise.all([
loadImageElement(state.backgroundSource),
loadImageElement(state.overlaySource),
]);
if (token !== state.loadToken) {
return;
}
state.backgroundImage = backgroundImage;
state.overlayImage = overlayImage;
if (backgroundImage && overlayImage) {
const backgroundLabel = backgroundSource?.label || "background";
const overlayLabel = overlaySource?.label || "overlay";
updateStatus(
`Drag to update x_offset / y_offset. Preview source: ${backgroundLabel} + ${overlayLabel}. ` +
"Use scale, rotation, opacity, anchor_mode, and rotation_origin_mode widgets to refine placement.",
);
} else if (!state.backgroundConnectionSupported || !state.overlayConnectionSupported) {
updateStatus("Preview supports Load Image / Load Image Output sources in this first version.");
} else {
updateStatus("Connect Load Image / Load Image Output to both inputs for preview.");
}
renderPreview();
} catch (error) {
if (token !== state.loadToken) {
return;
}
state.backgroundImage = null;
state.overlayImage = null;
updateStatus(error?.message || "Failed to load composite preview sources.");
renderPreview();
}
}
function handlePointerDown(event) {
if (!state.backgroundImage || !state.overlayImage) {
return;
}
state.dragging = true;
state.dragStartClientX = event.clientX;
state.dragStartClientY = event.clientY;
state.dragStartOffsetX = asNumber(xWidget?.value, 0);
state.dragStartOffsetY = asNumber(yWidget?.value, 0);
canvas.style.cursor = "grabbing";
canvas.setPointerCapture?.(event.pointerId);
}
function handlePointerMove(event) {
if (!state.dragging || !state.backgroundImage) {
return;
}
const backgroundWidth = Math.max(1, state.backgroundImage.naturalWidth || state.backgroundImage.width || canvas.width);
const backgroundHeight = Math.max(1, state.backgroundImage.naturalHeight || state.backgroundImage.height || canvas.height);
const deltaX = Math.round((event.clientX - state.dragStartClientX) * (backgroundWidth / canvas.width));
const deltaY = Math.round((event.clientY - state.dragStartClientY) * (backgroundHeight / canvas.height));
setWidgetValue(xWidget, state.dragStartOffsetX + deltaX);
setWidgetValue(yWidget, state.dragStartOffsetY + deltaY);
renderPreview();
node.setDirtyCanvas?.(true, true);
}
function endDrag(event) {
if (!state.dragging) {
return;
}
state.dragging = false;
canvas.style.cursor = "grab";
if (event?.pointerId != null) {
canvas.releasePointerCapture?.(event.pointerId);
}
}
canvas.addEventListener("pointerdown", handlePointerDown);
canvas.addEventListener("pointermove", handlePointerMove);
canvas.addEventListener("pointerup", endDrag);
canvas.addEventListener("pointercancel", endDrag);
canvas.addEventListener("pointerleave", endDrag);
[
xWidget,
yWidget,
anchorWidget,
rotationOriginWidget,
opacityWidget,
scaleWidget,
rotationWidget,
].forEach((widget) => chainWidgetCallback(widget, renderPreview));
const apiHandle = {
handleGraphConfigured() {
void reloadSources();
},
handleConnectionsChange() {
void reloadSources();
},
};
node.__llsImageCompositePreview = apiHandle;
updateStatus("Connect Load Image / Load Image Output to both inputs for preview.");
renderPreview();
return apiHandle;
}
app.registerExtension({
name: EXTENSION_NAME,
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== TARGET_NODE_CLASS && nodeData.display_name !== TARGET_NODE_DISPLAY_NAME) {
return;
}
const previousOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function onImageCompositeNodeCreated() {
const result = previousOnNodeCreated?.apply(this, arguments);
attachCompositePreview(this);
return result;
};
const previousOnGraphConfigured = nodeType.prototype.onGraphConfigured;
nodeType.prototype.onGraphConfigured = function onImageCompositeGraphConfigured() {
const result = previousOnGraphConfigured?.apply(this, arguments);
attachCompositePreview(this)?.handleGraphConfigured?.();
return result;
};
const previousOnConnectionsChange = nodeType.prototype.onConnectionsChange;
nodeType.prototype.onConnectionsChange = function onImageCompositeConnectionsChange() {
const result = previousOnConnectionsChange?.apply(this, arguments);
attachCompositePreview(this)?.handleConnectionsChange?.();
return result;
};
},
});