399 lines
12 KiB
Python
399 lines
12 KiB
Python
import re
|
||
|
||
import torch
|
||
import torch.nn.functional as F
|
||
|
||
|
||
_ASPECT_RATIO_CHOICES = [
|
||
'Free',
|
||
'1:1 (Perfect Square)',
|
||
'2:1 (Double Wide)',
|
||
'1:2 (Split Vertical)',
|
||
'2:3 (Classic Portrait)',
|
||
'3:2 (Classic Landscape)',
|
||
'3:4 (Portrait Classic)',
|
||
'4:3 (Classic Landscape)',
|
||
'4:5 (Artistic Frame)',
|
||
'5:4 (Balanced Frame)',
|
||
'5:7 (Balanced Portrait)',
|
||
'7:5 (Elegant Landscape)',
|
||
'5:8 (Tall Portrait)',
|
||
'8:5 (Cinematic View)',
|
||
'7:9 (Modern Portrait)',
|
||
'9:7 (Artful Horizon)',
|
||
'9:16 (Slim Vertical)',
|
||
'16:9 (Panorama)',
|
||
'9:19 (Tall Slim)',
|
||
'19:9 (Cinematic Ultrawide)',
|
||
'9:21 (Ultra Tall)',
|
||
'21:9 (Epic Ultrawide)',
|
||
'9:32 (Skyline)',
|
||
'32:9 (Extreme Ultrawide)',
|
||
'3:5 (Elegant Vertical)',
|
||
'5:3 (Wide Horizon)',
|
||
'2:5 (Tall Banner)',
|
||
'5:2 (Wide Banner)',
|
||
'1:3 (Vertical Triptych)',
|
||
'3:1 (Horizontal Triptych)',
|
||
'1:4 (Ultra Vertical Strip)',
|
||
'4:1 (Ultra Wide Strip)',
|
||
'10:16 (Poster Portrait)',
|
||
'16:10 (Wide Monitor)',
|
||
'10:18 (Tall Poster)',
|
||
'18:10 (Wide Poster)',
|
||
'11:14 (Portrait Print)',
|
||
'14:11 (Landscape Print)',
|
||
'11:17 (Tabloid Portrait)',
|
||
'17:11 (Tabloid Landscape)',
|
||
'8:11 (Letter Portrait)',
|
||
'11:8 (Letter Landscape)',
|
||
'8:14 (Legal Portrait)',
|
||
'14:8 (Legal Landscape)',
|
||
'1:1.85 (Cinema Vertical)',
|
||
'1.85:1 (Cinema Flat)',
|
||
'1:2.35 (Cinemascope Vertical)',
|
||
'2.35:1 (Cinemascope)',
|
||
'2.39:1 (Cinema Scope)',
|
||
'2.40:1 (Cinema Wide)',
|
||
'2.20:1 (70mm)',
|
||
'65:24 (XPan Panorama)',
|
||
'6:7 (Medium Format Portrait)',
|
||
'7:6 (Medium Format Landscape)',
|
||
'6:9 (Film Portrait)',
|
||
'9:6 (Film Landscape)',
|
||
]
|
||
|
||
_ASPECT_RATIOS = {
|
||
"1:1": 1.0,
|
||
"4:5": 4.0 / 5.0,
|
||
"3:2": 3.0 / 2.0,
|
||
"16:9": 16.0 / 9.0,
|
||
}
|
||
|
||
|
||
class InteractiveCropNode:
|
||
"""
|
||
Orion4D Interactive Crop - Nodes 2.0 friendly backend.
|
||
|
||
IMAGE ComfyUI format: [B, H, W, C], float 0..1.
|
||
The interactive preview is handled by web/interactive_crop.js.
|
||
"""
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"image": ("IMAGE",),
|
||
"scale_multiplier": ("FLOAT", {
|
||
"default": 1.0,
|
||
"min": 0.1,
|
||
"max": 10.0,
|
||
"step": 0.1,
|
||
"display": "number",
|
||
"tooltip": "Scales the cropped image and returned coordinates. 1.0 keeps the crop at source size."
|
||
}),
|
||
"x": ("INT", {
|
||
"default": 0,
|
||
"min": 0,
|
||
"max": 16384,
|
||
"step": 1,
|
||
"tooltip": "Crop X position in source image pixels."
|
||
}),
|
||
"y": ("INT", {
|
||
"default": 0,
|
||
"min": 0,
|
||
"max": 16384,
|
||
"step": 1,
|
||
"tooltip": "Crop Y position in source image pixels."
|
||
}),
|
||
"width": ("INT", {
|
||
"default": 512,
|
||
"min": 1,
|
||
"max": 16384,
|
||
"step": 1,
|
||
"tooltip": "Crop width in source image pixels."
|
||
}),
|
||
"height": ("INT", {
|
||
"default": 512,
|
||
"min": 1,
|
||
"max": 16384,
|
||
"step": 1,
|
||
"tooltip": "Crop height in source image pixels."
|
||
}),
|
||
"aspect_ratio": (_ASPECT_RATIO_CHOICES, {
|
||
"default": "Free",
|
||
"tooltip": "Interactive crop ratio. The backend also clamps the crop safely."
|
||
}),
|
||
},
|
||
}
|
||
|
||
RETURN_TYPES = ("IMAGE", "INT", "INT", "INT", "INT", "INT", "INT")
|
||
RETURN_NAMES = (
|
||
"cropped_image",
|
||
"output_width",
|
||
"output_height",
|
||
"output_x",
|
||
"output_y",
|
||
"crop_width",
|
||
"crop_height",
|
||
)
|
||
FUNCTION = "crop_image"
|
||
CATEGORY = "Orion4D_Layer"
|
||
DESCRIPTION = "Interactive crop with a Nodes 2.0 compatible DOM preview widget."
|
||
|
||
def crop_image(self, image, scale_multiplier, x, y, width, height, aspect_ratio="Free"):
|
||
if image is None or image.ndim != 4:
|
||
raise ValueError("InteractiveCropNode attend une IMAGE ComfyUI au format [B, H, W, C].")
|
||
|
||
_, orig_height, orig_width, _ = image.shape
|
||
if orig_width <= 0 or orig_height <= 0:
|
||
raise ValueError("InteractiveCropNode a reçu une image vide.")
|
||
|
||
safe_x = int(max(0, min(int(x), orig_width - 1)))
|
||
safe_y = int(max(0, min(int(y), orig_height - 1)))
|
||
safe_width = int(max(1, min(int(width), orig_width - safe_x)))
|
||
safe_height = int(max(1, min(int(height), orig_height - safe_y)))
|
||
|
||
safe_width, safe_height = _fit_aspect_ratio(
|
||
safe_width,
|
||
safe_height,
|
||
orig_width - safe_x,
|
||
orig_height - safe_y,
|
||
aspect_ratio,
|
||
)
|
||
|
||
cropped = image[
|
||
:,
|
||
safe_y:safe_y + safe_height,
|
||
safe_x:safe_x + safe_width,
|
||
:
|
||
].contiguous()
|
||
|
||
sm = float(scale_multiplier)
|
||
if sm <= 0:
|
||
sm = 1.0
|
||
|
||
output_width = max(1, int(round(orig_width * sm)))
|
||
output_height = max(1, int(round(orig_height * sm)))
|
||
output_x = max(0, int(round(safe_x * sm)))
|
||
output_y = max(0, int(round(safe_y * sm)))
|
||
crop_width = max(1, int(round(safe_width * sm)))
|
||
crop_height = max(1, int(round(safe_height * sm)))
|
||
|
||
# Keep the image size coherent with the returned scaled coordinates.
|
||
# This makes Interactive Crop -> Paste Cropped Image work directly when scale_multiplier != 1.0.
|
||
if crop_width != safe_width or crop_height != safe_height:
|
||
cropped = _resize_bhwc(cropped, crop_width, crop_height)
|
||
|
||
return (
|
||
cropped.contiguous(),
|
||
output_width,
|
||
output_height,
|
||
output_x,
|
||
output_y,
|
||
crop_width,
|
||
crop_height,
|
||
)
|
||
|
||
|
||
class PasteCroppedImageNode:
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"cropped_image": ("IMAGE",),
|
||
"canvas_width": ("INT", {
|
||
"default": 1024,
|
||
"min": 1,
|
||
"max": 16384,
|
||
"step": 1
|
||
}),
|
||
"canvas_height": ("INT", {
|
||
"default": 1024,
|
||
"min": 1,
|
||
"max": 16384,
|
||
"step": 1
|
||
}),
|
||
"x": ("INT", {
|
||
"default": 0,
|
||
"min": 0,
|
||
"max": 16384,
|
||
"step": 1
|
||
}),
|
||
"y": ("INT", {
|
||
"default": 0,
|
||
"min": 0,
|
||
"max": 16384,
|
||
"step": 1
|
||
}),
|
||
"background_type": (["transparent", "hex_color"], {
|
||
"default": "transparent"
|
||
}),
|
||
"hex_color": ("STRING", {
|
||
"default": "#000000",
|
||
"multiline": False
|
||
}),
|
||
},
|
||
}
|
||
|
||
RETURN_TYPES = ("IMAGE", "MASK", "IMAGE")
|
||
RETURN_NAMES = ("image", "mask", "rgba_image")
|
||
FUNCTION = "paste_image"
|
||
CATEGORY = "Orion4D_Layer"
|
||
DESCRIPTION = "Pastes a cropped image on a new canvas and returns RGB image, alpha mask and RGBA image."
|
||
|
||
def paste_image(self, cropped_image, canvas_width, canvas_height, x, y, background_type="transparent", hex_color="#000000"):
|
||
if cropped_image is None or cropped_image.ndim != 4:
|
||
raise ValueError("PasteCroppedImageNode attend une IMAGE ComfyUI au format [B, H, W, C].")
|
||
|
||
batch_size, crop_h, crop_w, channels = cropped_image.shape
|
||
|
||
canvas_width = int(max(1, canvas_width))
|
||
canvas_height = int(max(1, canvas_height))
|
||
safe_x = int(max(0, min(int(x), canvas_width)))
|
||
safe_y = int(max(0, min(int(y), canvas_height)))
|
||
|
||
paste_width = int(max(0, min(crop_w, canvas_width - safe_x)))
|
||
paste_height = int(max(0, min(crop_h, canvas_height - safe_y)))
|
||
|
||
rgb = torch.zeros(
|
||
(batch_size, canvas_height, canvas_width, 3),
|
||
dtype=cropped_image.dtype,
|
||
device=cropped_image.device,
|
||
)
|
||
|
||
if str(background_type) == "hex_color":
|
||
color = _parse_hex_color(hex_color)
|
||
bg = torch.tensor(
|
||
color,
|
||
dtype=cropped_image.dtype,
|
||
device=cropped_image.device,
|
||
).view(1, 1, 1, 3)
|
||
rgb[:] = bg
|
||
|
||
mask = torch.zeros(
|
||
(batch_size, canvas_height, canvas_width),
|
||
dtype=cropped_image.dtype,
|
||
device=cropped_image.device,
|
||
)
|
||
|
||
if paste_width > 0 and paste_height > 0:
|
||
crop = cropped_image[:, :paste_height, :paste_width, :].clamp(0, 1)
|
||
crop_rgb = _ensure_rgb(crop)
|
||
|
||
if channels >= 4:
|
||
alpha = crop[..., 3].clamp(0, 1)
|
||
else:
|
||
alpha = torch.ones(
|
||
(batch_size, paste_height, paste_width),
|
||
dtype=cropped_image.dtype,
|
||
device=cropped_image.device,
|
||
)
|
||
|
||
alpha_4d = alpha.unsqueeze(-1)
|
||
dst = rgb[:, safe_y:safe_y + paste_height, safe_x:safe_x + paste_width, :]
|
||
rgb[:, safe_y:safe_y + paste_height, safe_x:safe_x + paste_width, :] = (
|
||
crop_rgb * alpha_4d + dst * (1.0 - alpha_4d)
|
||
)
|
||
|
||
mask[:, safe_y:safe_y + paste_height, safe_x:safe_x + paste_width] = alpha
|
||
|
||
rgba = torch.zeros(
|
||
(batch_size, canvas_height, canvas_width, 4),
|
||
dtype=cropped_image.dtype,
|
||
device=cropped_image.device,
|
||
)
|
||
rgba[..., :3] = rgb
|
||
rgba[..., 3] = mask
|
||
|
||
return (rgb.contiguous(), mask.contiguous(), rgba.contiguous())
|
||
|
||
|
||
|
||
def _parse_aspect_ratio(aspect_ratio):
|
||
"""Parse labels such as '16:9 (Panorama)' or '210:297 (A4 Portrait)'."""
|
||
value = str(aspect_ratio or "Free").strip()
|
||
if not value or value.lower().startswith("free"):
|
||
return 0.0
|
||
|
||
if value in _ASPECT_RATIOS:
|
||
return float(_ASPECT_RATIOS[value])
|
||
|
||
match = re.search(r"(\d+(?:\.\d+)?)\s*[:x×]\s*(\d+(?:\.\d+)?)", value)
|
||
if not match:
|
||
return 0.0
|
||
|
||
w = float(match.group(1))
|
||
h = float(match.group(2))
|
||
if w <= 0 or h <= 0:
|
||
return 0.0
|
||
return w / h
|
||
|
||
|
||
def _fit_aspect_ratio(width, height, max_width, max_height, aspect_ratio):
|
||
ratio = _parse_aspect_ratio(aspect_ratio)
|
||
width = int(max(1, min(width, max_width)))
|
||
height = int(max(1, min(height, max_height)))
|
||
|
||
if not ratio:
|
||
return width, height
|
||
|
||
fitted_height = max(1, int(round(width / ratio)))
|
||
if fitted_height <= max_height:
|
||
return width, fitted_height
|
||
|
||
fitted_width = max(1, int(round(max_height * ratio)))
|
||
return min(fitted_width, max_width), max_height
|
||
|
||
|
||
def _resize_bhwc(image, width, height):
|
||
# BHWC -> BCHW -> interpolate -> BHWC
|
||
nchw = image.movedim(-1, 1)
|
||
try:
|
||
resized = F.interpolate(nchw, size=(height, width), mode="bilinear", align_corners=False, antialias=True)
|
||
except TypeError:
|
||
resized = F.interpolate(nchw, size=(height, width), mode="bilinear", align_corners=False)
|
||
return resized.movedim(1, -1)
|
||
|
||
|
||
def _ensure_rgb(image):
|
||
channels = image.shape[-1]
|
||
if channels >= 3:
|
||
return image[..., :3]
|
||
if channels == 1:
|
||
return image.repeat(1, 1, 1, 3)
|
||
if channels == 2:
|
||
return torch.cat([image[..., :2], image[..., 1:2]], dim=-1)
|
||
raise ValueError("L'image doit contenir au moins un canal.")
|
||
|
||
|
||
def _parse_hex_color(hex_color):
|
||
value = str(hex_color or "#000000").strip().lstrip("#")
|
||
|
||
try:
|
||
if len(value) == 3:
|
||
r = int(value[0] * 2, 16)
|
||
g = int(value[1] * 2, 16)
|
||
b = int(value[2] * 2, 16)
|
||
elif len(value) == 6:
|
||
r = int(value[0:2], 16)
|
||
g = int(value[2:4], 16)
|
||
b = int(value[4:6], 16)
|
||
else:
|
||
r, g, b = 0, 0, 0
|
||
except ValueError:
|
||
r, g, b = 0, 0, 0
|
||
|
||
return (r / 255.0, g / 255.0, b / 255.0)
|
||
|
||
|
||
NODE_CLASS_MAPPINGS = {
|
||
"InteractiveCropNode": InteractiveCropNode,
|
||
"PasteCroppedImageNode": PasteCroppedImageNode,
|
||
}
|
||
|
||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||
"InteractiveCropNode": "Interactive Crop",
|
||
"PasteCroppedImageNode": "Paste Cropped Image",
|
||
}
|