134 lines
5.0 KiB
Python
134 lines
5.0 KiB
Python
# "Design-focused node suite for ComfyUI."
|
|
# Copyright 2026 Augment Studio
|
|
# Augmentstudio.app
|
|
|
|
import torch
|
|
import numpy as np
|
|
import json
|
|
from PIL import Image
|
|
|
|
|
|
class ImagePlaceNode:
|
|
"""
|
|
Place an image onto a canvas at any x/y/w/h coordinates.
|
|
Handles resizing, alpha compositing, and fit modes.
|
|
Wire from Grid Coords, manual INTs, or any coordinate source.
|
|
"""
|
|
|
|
FIT_MODES = ["fit", "fill", "stretch"]
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"canvas": ("IMAGE",),
|
|
"image": ("IMAGE",),
|
|
"x": ("INT", {"default": 0, "min": -8192, "max": 8192, "step": 1}),
|
|
"y": ("INT", {"default": 0, "min": -8192, "max": 8192, "step": 1}),
|
|
"width": ("INT", {"default": 512, "min": 1, "max": 8192, "step": 1}),
|
|
"height": ("INT", {"default": 512, "min": 1, "max": 8192, "step": 1}),
|
|
"fit": (cls.FIT_MODES, {"default": "fit"}),
|
|
},
|
|
"optional": {
|
|
"mask": ("MASK",),
|
|
"trigger": ("TRIGGER",),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "MASK", "AUGMENT_JSON", "TRIGGER")
|
|
RETURN_NAMES = ("image", "mask", "json_result", "trigger")
|
|
FUNCTION = "place"
|
|
CATEGORY = "Augment/Image"
|
|
|
|
@staticmethod
|
|
def _tensor_to_pil(tensor):
|
|
"""Convert ComfyUI IMAGE tensor [B,H,W,C] to PIL RGBA."""
|
|
img_np = tensor[0].cpu().numpy()
|
|
img_np = np.clip(img_np * 255, 0, 255).astype(np.uint8)
|
|
|
|
if img_np.shape[2] == 4:
|
|
return Image.fromarray(img_np, "RGBA")
|
|
else:
|
|
return Image.fromarray(img_np, "RGB").convert("RGBA")
|
|
|
|
@staticmethod
|
|
def _pil_to_tensor(pil_img):
|
|
"""Convert PIL RGB image to ComfyUI IMAGE tensor [1,H,W,3]."""
|
|
img_np = np.array(pil_img.convert("RGB")).astype(np.float32) / 255.0
|
|
return torch.from_numpy(img_np).unsqueeze(0)
|
|
|
|
@staticmethod
|
|
def _resize_fit(pil_img, target_w, target_h):
|
|
"""Resize to fit inside target, preserving aspect ratio (letterbox)."""
|
|
src_w, src_h = pil_img.size
|
|
scale = min(target_w / src_w, target_h / src_h)
|
|
new_w = int(src_w * scale)
|
|
new_h = int(src_h * scale)
|
|
resized = pil_img.resize((new_w, new_h), Image.LANCZOS)
|
|
|
|
# Center on transparent canvas
|
|
result = Image.new("RGBA", (target_w, target_h), (0, 0, 0, 0))
|
|
offset_x = (target_w - new_w) // 2
|
|
offset_y = (target_h - new_h) // 2
|
|
result.paste(resized, (offset_x, offset_y), resized)
|
|
return result
|
|
|
|
@staticmethod
|
|
def _resize_fill(pil_img, target_w, target_h):
|
|
"""Resize to fill target, cropping excess (cover)."""
|
|
src_w, src_h = pil_img.size
|
|
scale = max(target_w / src_w, target_h / src_h)
|
|
new_w = int(src_w * scale)
|
|
new_h = int(src_h * scale)
|
|
resized = pil_img.resize((new_w, new_h), Image.LANCZOS)
|
|
|
|
# Center crop
|
|
left = (new_w - target_w) // 2
|
|
top = (new_h - target_h) // 2
|
|
return resized.crop((left, top, left + target_w, top + target_h))
|
|
|
|
@staticmethod
|
|
def _resize_stretch(pil_img, target_w, target_h):
|
|
"""Stretch to exact target size."""
|
|
return pil_img.resize((target_w, target_h), Image.LANCZOS)
|
|
|
|
def place(self, canvas, image, x, y, width, height, fit, mask=None, trigger=None):
|
|
canvas_pil = self._tensor_to_pil(canvas)
|
|
image_pil = self._tensor_to_pil(image)
|
|
|
|
canvas_w, canvas_h = canvas_pil.size
|
|
|
|
# Resize image to target region
|
|
if fit == "fit":
|
|
placed = self._resize_fit(image_pil, width, height)
|
|
elif fit == "fill":
|
|
placed = self._resize_fill(image_pil, width, height)
|
|
else:
|
|
placed = self._resize_stretch(image_pil, width, height)
|
|
|
|
# Apply external mask to placed image's alpha if provided
|
|
if mask is not None:
|
|
mask_pil = Image.fromarray((mask[0].cpu().numpy() * 255).astype(np.uint8), "L")
|
|
mask_resized = mask_pil.resize((placed.size[0], placed.size[1]), Image.LANCZOS)
|
|
placed_alpha = placed.split()[3]
|
|
combined_alpha = Image.fromarray(
|
|
np.minimum(np.array(placed_alpha), np.array(mask_resized)).astype(np.uint8), "L"
|
|
)
|
|
placed.putalpha(combined_alpha)
|
|
|
|
# Composite onto canvas
|
|
result = canvas_pil.copy()
|
|
result.paste(placed, (x, y), placed)
|
|
|
|
# Build placement mask (white where image was placed, including alpha)
|
|
mask_img = Image.new("L", (canvas_w, canvas_h), 0)
|
|
placed_alpha = placed.split()[3]
|
|
mask_img.paste(placed_alpha, (x, y))
|
|
|
|
# Convert outputs
|
|
result_tensor = self._pil_to_tensor(result)
|
|
mask_np = np.array(mask_img).astype(np.float32) / 255.0
|
|
mask_tensor = torch.from_numpy(mask_np).unsqueeze(0)
|
|
|
|
json_result = json.dumps({"node": "ImagePlaceNode", "x": x, "y": y, "width": width, "height": height, "fit": fit, "canvas_size": [canvas_w, canvas_h]})
|
|
return (result_tensor, mask_tensor, json_result, trigger) |