Files

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)