Files
2026-07-10 19:49:35 +02:00

399 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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",
}