Add files via upload
This commit is contained in:
@@ -18,6 +18,8 @@ from .pvl_fal_lumaphoton_flash_reframe import PVL_fal_LumaPhoton_FlashReframe_AP
|
||||
from .pvl_fal_lumaphoton_reframe import PVL_fal_LumaPhoton_Reframe_API
|
||||
from .pvl_NoneOutputNode import PVL_NoneOutputNode
|
||||
from .pvl_SaveOrNot import PVL_SaveOrNot
|
||||
from .pvl_ImageResize import PVL_ImageResize
|
||||
from .pvl_ImageStitch import PVL_ImageStitch
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"PVL Call OpenAI Assistant": CallAssistantNode,
|
||||
@@ -40,6 +42,8 @@ NODE_CLASS_MAPPINGS = {
|
||||
"PVL_fal_LumaPhoton_Reframe_API": PVL_fal_LumaPhoton_Reframe_API,
|
||||
"PVL_NoneOutputNode": PVL_NoneOutputNode,
|
||||
"PVL_SaveOrNot": PVL_SaveOrNot,
|
||||
"PVL_ImageResize": PVL_ImageResize,
|
||||
"PVL_ImageStitch": PVL_ImageStitch,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
@@ -62,4 +66,6 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"PVL_fal_LumaPhoton_Reframe_API": "PVL LumaPhoton Reframe (fal.ai)",
|
||||
"PVL_NoneOutputNode": "PVL NoneOutputNode",
|
||||
"PVL_SaveOrNot": "PVL Save Or Not",
|
||||
"PVL_ImageResize": "PVL Image Resize",
|
||||
"PVL_ImageStitch": "PVL Image Stitch",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from comfy import model_management
|
||||
from comfy.utils import common_upscale
|
||||
|
||||
class PVL_ImageResize:
|
||||
upscale_methods = ["nearest-exact", "bilinear", "area", "bicubic", "lanczos"]
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"width": ("INT", {"default": 512, "min": 0, "max": 8192, "step": 1}),
|
||||
"height": ("INT", {"default": 512, "min": 0, "max": 8192, "step": 1}),
|
||||
"upscale_method": (cls.upscale_methods,),
|
||||
"keep_proportion": (["stretch", "resize", "pad", "pad_edge", "crop"], {"default": "resize"}),
|
||||
"pad_color": ("STRING", {"default": "0, 0, 0", "tooltip": "Color to use for padding."}),
|
||||
"crop_position": (["center", "top", "bottom", "left", "right"], {"default": "center"}),
|
||||
"divisible_by": ("INT", {"default": 2, "min": 0, "max": 512, "step": 1}),
|
||||
"downsize_only": ("BOOLEAN", {"default": False, "tooltip": "Only resize if target dimensions are smaller than original."}),
|
||||
},
|
||||
"optional": {
|
||||
"image": ("IMAGE",),
|
||||
"device": (["cpu", "gpu"], {"default": "cpu"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "INT", "INT")
|
||||
RETURN_NAMES = ("IMAGE", "width", "height")
|
||||
FUNCTION = "resize"
|
||||
CATEGORY = "PVL_tools"
|
||||
DESCRIPTION = """
|
||||
Resizes the image to the specified width and height.
|
||||
Keep proportions maintains the aspect ratio of the image.
|
||||
Downsize only prevents upscaling when enabled.
|
||||
"""
|
||||
|
||||
def resize(self, image=None, width=512, height=512, keep_proportion="resize", upscale_method="bicubic",
|
||||
divisible_by=2, pad_color="0, 0, 0", crop_position="center", downsize_only=False, device="cpu"):
|
||||
if image is None:
|
||||
return (None, 0, 0)
|
||||
|
||||
B, H, W, C = image.shape
|
||||
|
||||
original_width = W
|
||||
original_height = H
|
||||
|
||||
if downsize_only:
|
||||
if width > original_width:
|
||||
width = original_width
|
||||
if height > original_height:
|
||||
height = original_height
|
||||
|
||||
if device == "gpu":
|
||||
if upscale_method == "lanczos":
|
||||
raise Exception("Lanczos is not supported on the GPU")
|
||||
device = model_management.get_torch_device()
|
||||
else:
|
||||
device = torch.device("cpu")
|
||||
if width == 0:
|
||||
width = W
|
||||
if height == 0:
|
||||
height = H
|
||||
|
||||
if keep_proportion == "resize" or keep_proportion.startswith("pad"):
|
||||
if width == 0 and height != 0:
|
||||
ratio = height / H
|
||||
new_width = round(W * ratio)
|
||||
new_height = height
|
||||
elif height == 0 and width != 0:
|
||||
ratio = width / W
|
||||
new_height = round(H * ratio)
|
||||
new_width = width
|
||||
elif width != 0 and height != 0:
|
||||
ratio = min(width / W, height / H)
|
||||
new_width = round(W * ratio)
|
||||
new_height = round(H * ratio)
|
||||
else:
|
||||
new_width = W
|
||||
new_height = H
|
||||
if keep_proportion.startswith("pad"):
|
||||
pad_left = (width - new_width) // 2
|
||||
pad_right = width - new_width - pad_left
|
||||
pad_top = (height - new_height) // 2
|
||||
pad_bottom = height - new_height - pad_top
|
||||
width = new_width
|
||||
height = new_height
|
||||
|
||||
if divisible_by > 1:
|
||||
width = width - (width % divisible_by)
|
||||
height = height - (height % divisible_by)
|
||||
out_image = image.clone().to(device)
|
||||
|
||||
if keep_proportion == "crop":
|
||||
old_width = W
|
||||
old_height = H
|
||||
old_aspect = old_width / old_height
|
||||
new_aspect = width / height
|
||||
|
||||
if old_aspect > new_aspect:
|
||||
crop_w = round(old_height * new_aspect)
|
||||
crop_h = old_height
|
||||
else:
|
||||
crop_w = old_width
|
||||
crop_h = round(old_width / new_aspect)
|
||||
|
||||
if crop_position == "center":
|
||||
x = (old_width - crop_w) // 2
|
||||
y = (old_height - crop_h) // 2
|
||||
elif crop_position == "top":
|
||||
x = (old_width - crop_w) // 2
|
||||
y = 0
|
||||
elif crop_position == "bottom":
|
||||
x = (old_width - crop_w) // 2
|
||||
y = old_height - crop_h
|
||||
elif crop_position == "left":
|
||||
x = 0
|
||||
y = (old_height - crop_h) // 2
|
||||
elif crop_position == "right":
|
||||
x = old_width - crop_w
|
||||
y = (old_height - crop_h) // 2
|
||||
|
||||
out_image = out_image.narrow(-2, x, crop_w).narrow(-3, y, crop_h)
|
||||
|
||||
out_image = common_upscale(out_image.movedim(-1,1), width, height, upscale_method, crop="disabled").movedim(1,-1)
|
||||
if keep_proportion.startswith("pad"):
|
||||
if pad_left > 0 or pad_right > 0 or pad_top > 0 or pad_bottom > 0:
|
||||
padded_width = width + pad_left + pad_right
|
||||
padded_height = height + pad_top + pad_bottom
|
||||
if divisible_by > 1:
|
||||
width_remainder = padded_width % divisible_by
|
||||
height_remainder = padded_height % divisible_by
|
||||
if width_remainder > 0:
|
||||
extra_width = divisible_by - width_remainder
|
||||
pad_right += extra_width
|
||||
if height_remainder > 0:
|
||||
extra_height = divisible_by - height_remainder
|
||||
pad_bottom += extra_height
|
||||
out_image, _ = self.pad(out_image, pad_left, pad_right, pad_top, pad_bottom, 0, pad_color, "edge" if keep_proportion == "pad_edge" else "color")
|
||||
|
||||
return (out_image.cpu(), out_image.shape[2], out_image.shape[1])
|
||||
|
||||
def pad(self, image, left, right, top, bottom, extra_padding, color, mode):
|
||||
B, H, W, C = image.shape
|
||||
|
||||
bg_color = [int(x.strip())/255.0 for x in color.split(",")]
|
||||
if len(bg_color) == 1:
|
||||
bg_color = bg_color * 3
|
||||
bg_color = torch.tensor(bg_color, dtype=image.dtype, device=image.device)
|
||||
|
||||
padded_width = W + left + right
|
||||
padded_height = H + top + bottom
|
||||
out_image = torch.zeros((B, padded_height, padded_width, C), dtype=image.dtype, device=image.device)
|
||||
|
||||
for b in range(B):
|
||||
if mode == "edge":
|
||||
top_edge = image[b, 0, :, :]
|
||||
bottom_edge = image[b, H-1, :, :]
|
||||
left_edge = image[b, :, 0, :]
|
||||
right_edge = image[b, :, W-1, :]
|
||||
|
||||
out_image[b, :top, :, :] = top_edge.mean(dim=0)
|
||||
out_image[b, top+H:, :, :] = bottom_edge.mean(dim=0)
|
||||
out_image[b, :, :left, :] = left_edge.mean(dim=0)
|
||||
out_image[b, :, left+W:, :] = right_edge.mean(dim=0)
|
||||
out_image[b, top:top+H, left:left+W, :] = image[b]
|
||||
else:
|
||||
out_image[b, :, :, :] = bg_color.unsqueeze(0).unsqueeze(0)
|
||||
out_image[b, top:top+H, left:left+W, :] = image[b]
|
||||
|
||||
return (out_image, torch.ones((B, padded_height, padded_width), dtype=image.dtype, device=image.device))
|
||||
@@ -0,0 +1,689 @@
|
||||
# pvl_ImageStitch.py
|
||||
# PVL – Image Stitch (ComfyUI custom node)
|
||||
#
|
||||
# Features:
|
||||
# - 1 required image input + up to 4 optional image inputs (total 5).
|
||||
# - If only one image provided: passthrough (drop alpha, keep size).
|
||||
# - Linear arrangement: right / left / up / down.
|
||||
# - Pack-to-square:
|
||||
# * pack_to_square=True ignores linear arrangement and performs a global pack.
|
||||
# * Two pack modes:
|
||||
# - "grid" → tries ALL row/column partitions across ALL permutations (legacy).
|
||||
# - "guillotine" → MaxRects/guillotine-style free-rect packing (can fill L-shaped space).
|
||||
# * Optional post-compaction for guillotine: vertical & horizontal nudging with left/up slides.
|
||||
# - Optional pre-normalization when packing:
|
||||
# * match_image_size=True (AR preserved), compute a single S and resize each image once:
|
||||
# - match_type="upscale" → S = AVGmax = (max_w + max_h) / 2
|
||||
# - match_type="downscale" → S = AVGmin = (min_w + min_h) / 2
|
||||
# Then pack the already-normalized tiles; no additional per-row/col matching.
|
||||
# - Resample: lanczos/bicubic/bilinear/area/nearest (Lanczos CPU-only).
|
||||
# - Spacing (pixels) and spacing color "R,G,B" (0..255).
|
||||
# - Centering: linear layouts center on the cross-axis; grid-pack centers rows/cols.
|
||||
# - Square tolerance (%): allow extra area above absolute minimum to select a squarer layout,
|
||||
# applied to both grid and guillotine packers.
|
||||
#
|
||||
# Output: IMAGE [1,H,W,3] RGB float32 in [0..1]
|
||||
|
||||
from __future__ import annotations
|
||||
import itertools
|
||||
import math
|
||||
import torch
|
||||
from comfy.utils import common_upscale
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# Utility helpers
|
||||
# -----------------------------
|
||||
def _render_linear_row(tiles: list[torch.Tensor], spacing: int, bg_rgb: torch.Tensor, direction: str) -> torch.Tensor:
|
||||
# tiles are [1,C,H,W]
|
||||
heights = [t.shape[2] for t in tiles]
|
||||
widths = [t.shape[3] for t in tiles]
|
||||
H_out = max(heights)
|
||||
W_out = sum(widths) + spacing * max(0, len(tiles) - 1)
|
||||
|
||||
device = tiles[0].device
|
||||
dtype = tiles[0].dtype
|
||||
out = torch.ones((1, 3, H_out, W_out), device=device, dtype=dtype)
|
||||
out[:, 0].fill_(bg_rgb[0]); out[:, 1].fill_(bg_rgb[1]); out[:, 2].fill_(bg_rgb[2])
|
||||
|
||||
if direction == "right":
|
||||
x = 0
|
||||
for tile in tiles:
|
||||
_, _, h, w = tile.shape
|
||||
y_off = max(0, (H_out - h) // 2)
|
||||
out[:, :, y_off:y_off + h, x:x + w] = tile
|
||||
x += w + spacing
|
||||
elif direction == "left":
|
||||
x = W_out
|
||||
for tile in tiles:
|
||||
_, _, h, w = tile.shape
|
||||
x -= w
|
||||
y_off = max(0, (H_out - h) // 2)
|
||||
out[:, :, y_off:y_off + h, x:x + w] = tile
|
||||
x -= spacing
|
||||
else:
|
||||
raise ValueError("_render_linear_row: direction must be 'right' or 'left'")
|
||||
return out
|
||||
|
||||
|
||||
def _render_linear_col(tiles: list[torch.Tensor], spacing: int, bg_rgb: torch.Tensor, direction: str) -> torch.Tensor:
|
||||
# tiles are [1, C, H, W]
|
||||
heights = [t.shape[2] for t in tiles]
|
||||
widths = [t.shape[3] for t in tiles]
|
||||
H_out = sum(heights) + spacing * max(0, len(tiles) - 1)
|
||||
W_out = max(widths)
|
||||
|
||||
device = tiles[0].device
|
||||
dtype = tiles[0].dtype
|
||||
out = torch.ones((1, 3, H_out, W_out), device=device, dtype=dtype)
|
||||
out[:, 0].fill_(bg_rgb[0]); out[:, 1].fill_(bg_rgb[1]); out[:, 2].fill_(bg_rgb[2])
|
||||
|
||||
if direction == "down":
|
||||
y = 0
|
||||
for tile in tiles:
|
||||
_, _, h, w = tile.shape
|
||||
x_off = max(0, (W_out - w) // 2)
|
||||
out[:, :, y:y + h, x_off:x_off + w] = tile
|
||||
y += h + spacing
|
||||
elif direction == "up":
|
||||
y = H_out
|
||||
for tile in tiles:
|
||||
_, _, h, w = tile.shape
|
||||
y -= h
|
||||
x_off = max(0, (W_out - w) // 2)
|
||||
out[:, :, y:y + h, x_off:x_off + w] = tile
|
||||
y -= spacing
|
||||
else:
|
||||
raise ValueError("_render_linear_col: direction must be 'down' or 'up'")
|
||||
return out
|
||||
|
||||
def _to_nchw(x: torch.Tensor) -> torch.Tensor:
|
||||
return x.permute(0, 3, 1, 2) # [B,H,W,C] -> [B,C,H,W]
|
||||
|
||||
def _to_bhwc(x: torch.Tensor) -> torch.Tensor:
|
||||
return x.permute(0, 2, 3, 1) # [B,C,H,W] -> [B,H,W,C]
|
||||
|
||||
def _drop_alpha(x_bhwc: torch.Tensor) -> torch.Tensor:
|
||||
return x_bhwc[..., :3] if x_bhwc.shape[-1] == 4 else x_bhwc
|
||||
|
||||
def _parse_rgb(s: str, device, dtype) -> torch.Tensor:
|
||||
if not isinstance(s, str):
|
||||
raise ValueError("pvl_ImageStitch: invalid spacing_color (not a string)")
|
||||
parts = [p.strip() for p in s.split(',') if p.strip()]
|
||||
if len(parts) != 3:
|
||||
raise ValueError("pvl_ImageStitch: invalid spacing_color, expected 'R,G,B'")
|
||||
try:
|
||||
r, g, b = (int(parts[0]), int(parts[1]), int(parts[2]))
|
||||
except Exception:
|
||||
raise ValueError("pvl_ImageStitch: invalid spacing_color numbers")
|
||||
for v in (r, g, b):
|
||||
if not (0 <= v <= 255):
|
||||
raise ValueError("pvl_ImageStitch: spacing_color must be 0..255")
|
||||
return torch.tensor([r/255.0, g/255.0, b/255.0], device=device, dtype=dtype)
|
||||
|
||||
def _interp_mode(mode: str):
|
||||
m = (mode or "").lower().strip()
|
||||
if m in ("nearest", "nearest-exact"): return "nearest"
|
||||
if m in ("bilinear",): return "bilinear"
|
||||
if m in ("bicubic",): return "bicubic"
|
||||
if m in ("area",): return "area"
|
||||
if m in ("lanczos","lancoz","lanczos3","lanczos_3"): return "lanczos"
|
||||
raise ValueError(f"Unsupported resample mode: {mode}")
|
||||
|
||||
def _resize_ar_nchw(img: torch.Tensor, target_h: int | None = None, target_w: int | None = None, resample: str = "bicubic") -> torch.Tensor:
|
||||
"""AR-preserving resize to target_h or target_w (one must be provided). img: [1,C,H,W]."""
|
||||
assert img.ndim == 4 and img.shape[0] == 1, "_resize_ar_nchw expects [1,C,H,W]"
|
||||
_, _, h, w = img.shape
|
||||
if (target_h is None) == (target_w is None):
|
||||
raise ValueError("Provide exactly one of target_h or target_w")
|
||||
if target_h is not None:
|
||||
scale = target_h / float(h)
|
||||
new_h = int(round(target_h)); new_w = max(1, int(round(w * scale)))
|
||||
else:
|
||||
scale = target_w / float(w)
|
||||
new_w = int(round(target_w)); new_h = max(1, int(round(h * scale)))
|
||||
mode = _interp_mode(resample)
|
||||
if mode == "lanczos" and img.device.type == "cuda":
|
||||
raise Exception("Lanczos is not supported on the GPU")
|
||||
return common_upscale(img, new_w, new_h, "nearest" if mode=="nearest" else mode, crop="disabled")
|
||||
|
||||
def _resize_fit_square_nchw(img: torch.Tensor, S: int, mode: str, resample: str) -> torch.Tensor:
|
||||
"""Fit [1,C,H,W] into S×S square. mode: 'downscale' (never up) or 'upscale' (never down)."""
|
||||
assert img.ndim == 4 and img.shape[0] == 1
|
||||
_, _, h, w = img.shape
|
||||
if S <= 0:
|
||||
return img
|
||||
scale = min(S/float(w), S/float(h))
|
||||
scale = min(scale, 1.0) if mode == "downscale" else max(scale, 1.0)
|
||||
new_w = max(1, int(round(w * scale)))
|
||||
new_h = max(1, int(round(h * scale)))
|
||||
if _interp_mode(resample) == "lanczos" and img.device.type == "cuda":
|
||||
raise Exception("Lanczos is not supported on the GPU")
|
||||
return common_upscale(img, new_w, new_h,
|
||||
"nearest" if _interp_mode(resample)=="nearest" else _interp_mode(resample),
|
||||
crop="disabled")
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# GRID packing (legacy)
|
||||
# -----------------------------
|
||||
|
||||
def _group_by_breaks(seq: list[int], mask: int) -> list[list[int]]:
|
||||
groups = []; cur = [seq[0]]
|
||||
for i in range(len(seq) - 1):
|
||||
if (mask >> i) & 1:
|
||||
groups.append(cur); cur = [seq[i+1]]
|
||||
else:
|
||||
cur.append(seq[i+1])
|
||||
groups.append(cur); return groups
|
||||
|
||||
def _simulate_rows(sizes: list[tuple[int,int]], groups: list[list[int]], spacing: int, match: bool, match_type: str) -> tuple[int,int,list[tuple[int,int]], list[list[tuple[int,int]]]]:
|
||||
row_dims: list[tuple[int,int]] = []; row_tiles_dims: list[list[tuple[int,int]]] = []
|
||||
for g in groups:
|
||||
tiles = [sizes[i] for i in g]
|
||||
if match and tiles:
|
||||
heights = [h for (_,h) in tiles]
|
||||
tgt_h = min(heights) if match_type == "downscale" else max(heights)
|
||||
tiles = [(w if h==tgt_h else max(1, int(round(w * (tgt_h/float(h))))), tgt_h) for (w,h) in tiles]
|
||||
row_w = sum(w for (w,_) in tiles) + spacing * max(0, len(tiles)-1)
|
||||
row_h = max(h for (_,h) in tiles)
|
||||
row_dims.append((row_w, row_h)); row_tiles_dims.append(tiles)
|
||||
canvas_w = max(w for (w,_) in row_dims)
|
||||
canvas_h = sum(h for (_,h) in row_dims) + spacing * max(0, len(row_dims)-1)
|
||||
return canvas_w, canvas_h, row_dims, row_tiles_dims
|
||||
|
||||
def _simulate_cols(sizes: list[tuple[int,int]], groups: list[list[int]], spacing: int, match: bool, match_type: str) -> tuple[int,int,list[tuple[int,int]], list[list[tuple[int,int]]]]:
|
||||
col_dims: list[tuple[int,int]] = []; col_tiles_dims: list[list[tuple[int,int]]] = []
|
||||
for g in groups:
|
||||
tiles = [sizes[i] for i in g]
|
||||
if match and tiles:
|
||||
widths = [w for (w,_) in tiles]
|
||||
tgt_w = min(widths) if match_type == "downscale" else max(widths)
|
||||
tiles = [(tgt_w, h if w==tgt_w else max(1, int(round(h * (tgt_w/float(w)))))) for (w,h) in tiles]
|
||||
col_w = max(w for (w,_) in tiles)
|
||||
col_h = sum(h for (_,h) in tiles) + spacing * max(0, len(tiles)-1)
|
||||
col_dims.append((col_w, col_h)); col_tiles_dims.append(tiles)
|
||||
canvas_w = sum(w for (w,_) in col_dims) + spacing * max(0, len(col_dims)-1)
|
||||
canvas_h = max(h for (_,h) in col_dims)
|
||||
return canvas_w, canvas_h, col_dims, col_tiles_dims
|
||||
|
||||
def _choose_best_candidate(
|
||||
cands: list[tuple[str, tuple[int,int], list[list[int]], list[list[tuple[int,int]]]]],
|
||||
area_tolerance: float = 0.15
|
||||
) -> tuple[str, tuple[int,int], list[list[int]], list[list[tuple[int,int]]]]:
|
||||
# 1) min area
|
||||
areas = [W*H for _, (W,H), _, _ in cands]
|
||||
A_min = min(areas)
|
||||
thresh = int(math.ceil(A_min * (1.0 + max(0.0, area_tolerance))))
|
||||
# 2) near-minimal set
|
||||
kept = [c for c in cands if (c[1][0] * c[1][1]) <= thresh]
|
||||
# 3) prefer square, then smaller W/H, then fewer groups, rows first
|
||||
def keyfn(c):
|
||||
kind, (W,H), groups, _ = c
|
||||
return (abs(W-H), W, H, len(groups), 0 if kind=="rows" else 1)
|
||||
return min(kept, key=keyfn)
|
||||
|
||||
def _pack_grid(sizes: list[tuple[int,int]], spacing: int, allow_match: bool, match_type: str, area_tolerance: float = 0.15):
|
||||
N = len(sizes); idx = list(range(N)); cands = []
|
||||
for perm in itertools.permutations(idx, N):
|
||||
for mask in range(1 << (N-1)):
|
||||
groups = _group_by_breaks(list(perm), mask)
|
||||
W,H, _, row_tiles = _simulate_rows(sizes, groups, spacing, allow_match, match_type)
|
||||
cands.append(("rows", (W,H), groups, row_tiles))
|
||||
W,H, _, col_tiles = _simulate_cols(sizes, groups, spacing, allow_match, match_type)
|
||||
cands.append(("cols", (W,H), groups, col_tiles))
|
||||
return _choose_best_candidate(cands, area_tolerance)
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# GUILLotine / MaxRects packing + compaction
|
||||
# -----------------------------
|
||||
|
||||
class _Rect:
|
||||
__slots__ = ("x","y","w","h")
|
||||
def __init__(self, x:int, y:int, w:int, h:int): self.x=x; self.y=y; self.w=w; self.h=h
|
||||
def right(self): return self.x + self.w
|
||||
def bottom(self): return self.y + self.h
|
||||
def area(self): return self.w * self.h
|
||||
|
||||
def _rect_overlap(a:_Rect, b:_Rect) -> bool:
|
||||
return not (a.x >= b.x + b.w or a.x + a.w <= b.x or a.y >= b.y + b.h or a.y + a.h <= b.y)
|
||||
|
||||
def _split_free_rect(f:_Rect, used:_Rect) -> list[_Rect]:
|
||||
"""Split free rect f by used rect; return list of non-overlapping remainders."""
|
||||
out = []
|
||||
# Above
|
||||
if used.y > f.y and used.y < f.y + f.h:
|
||||
out.append(_Rect(f.x, f.y, f.w, used.y - f.y))
|
||||
# Below
|
||||
if used.y + used.h < f.y + f.h:
|
||||
out.append(_Rect(f.x, used.y + used.h, f.w, (f.y + f.h) - (used.y + used.h)))
|
||||
# Left
|
||||
if used.x > f.x and used.x < f.x + f.w:
|
||||
out.append(_Rect(f.x, f.y, used.x - f.x, f.h))
|
||||
# Right
|
||||
if used.x + used.w < f.x + f.w:
|
||||
out.append(_Rect(used.x + used.w, f.y, (f.x + f.w) - (used.x + used.w), f.h))
|
||||
return [r for r in out if r.w > 0 and r.h > 0]
|
||||
|
||||
def _prune_free_list(free:list[_Rect]) -> list[_Rect]:
|
||||
"""Remove contained rectangles."""
|
||||
pruned = []
|
||||
for i, r in enumerate(free):
|
||||
contained = False
|
||||
for j, s in enumerate(free):
|
||||
if i != j and r.x >= s.x and r.y >= s.y and r.right() <= s.right() and r.bottom() <= s.bottom():
|
||||
contained = True; break
|
||||
if not contained: pruned.append(r)
|
||||
return pruned
|
||||
|
||||
def _maxrects_pack_fixed_width(sizes:list[tuple[int,int]], gap:int, width:int):
|
||||
"""
|
||||
MaxRects/BSSF packing into fixed width. Returns (ok, placements, W, H)
|
||||
placements = list[(idx, x, y, w_eff, h_eff)] where w_eff/h_eff include the gap on right/bottom.
|
||||
We expand each tile to (w+gap, h+gap), then subtract a final gap from W/H so there's no outer border gap.
|
||||
"""
|
||||
eff = [(w + (gap if gap>0 else 0), h + (gap if gap>0 else 0)) for (w,h) in sizes]
|
||||
total_h_lim = sum(h for (_,h) in eff) # generous height limit
|
||||
free = [_Rect(0,0,width,total_h_lim)]
|
||||
placements = []
|
||||
|
||||
for idx, (w,h) in enumerate(eff):
|
||||
best_rect = None
|
||||
best_key = None
|
||||
# Best Short Side Fit + tie on long side, then y, then x
|
||||
for fr in free:
|
||||
if w <= fr.w and h <= fr.h:
|
||||
ssf = min(fr.w - w, fr.h - h)
|
||||
lsf = max(fr.w - w, fr.h - h)
|
||||
key = (ssf, lsf, fr.y, fr.x)
|
||||
if best_key is None or key < best_key:
|
||||
best_key = key; best_rect = fr
|
||||
if best_rect is None:
|
||||
return False, [], width, 0 # doesn't fit this width
|
||||
|
||||
used = _Rect(best_rect.x, best_rect.y, w, h)
|
||||
# split all overlapping free rects
|
||||
new_free = []
|
||||
for fr in free:
|
||||
if _rect_overlap(fr, used):
|
||||
new_free.extend(_split_free_rect(fr, used))
|
||||
else:
|
||||
new_free.append(fr)
|
||||
free = _prune_free_list(new_free)
|
||||
placements.append((idx, used.x, used.y, w, h))
|
||||
|
||||
# compute tight bbox; remove outer gap on right/bottom
|
||||
W = max((x + w for (_,x,_,w,_) in placements), default=0)
|
||||
H = max((y + h for (_,_,y,_,h) in placements), default=0)
|
||||
if gap > 0:
|
||||
W = max(0, W - gap)
|
||||
H = max(0, H - gap)
|
||||
# Return tight bbox (crop unused right/bottom space)
|
||||
return True, placements, W, H
|
||||
|
||||
# ---------- Post-packing compaction (bidirectional) ----------
|
||||
|
||||
def _tight_bbox_from_eff(placements):
|
||||
if not placements:
|
||||
return 0, 0
|
||||
W = max(x + w for (_, x, _, w, _) in placements)
|
||||
H = max(y + h for (_, _, y, _, h) in placements)
|
||||
return W, H
|
||||
|
||||
def _vertical_candidates(placements, i_idx, h_eff):
|
||||
ys = {0}
|
||||
for k, (_, _xk, yk, _wk, hk) in enumerate(placements):
|
||||
if k == i_idx:
|
||||
continue
|
||||
ys.add(yk + hk)
|
||||
return sorted(ys)
|
||||
|
||||
def _horizontal_candidates(placements, i_idx, w_eff):
|
||||
xs = {0}
|
||||
for k, (_idx, xk, _yk, wk, _hk) in enumerate(placements):
|
||||
if k == i_idx:
|
||||
continue
|
||||
xs.add(xk + wk)
|
||||
return sorted(xs)
|
||||
|
||||
def _min_left_x_at_y(placements, i_idx, y, w_eff, h_eff):
|
||||
left_bound = 0
|
||||
for k, (_idx, xk, yk, wk, hk) in enumerate(placements):
|
||||
if k == i_idx:
|
||||
continue
|
||||
if not (y + h_eff <= yk or y >= yk + hk): # vertical overlap
|
||||
left_bound = max(left_bound, xk + wk)
|
||||
return left_bound
|
||||
|
||||
def _min_top_y_at_x(placements, i_idx, x, w_eff, h_eff):
|
||||
y_min = 0
|
||||
for k, (_idx, xk, yk, wk, hk) in enumerate(placements):
|
||||
if k == i_idx:
|
||||
continue
|
||||
# horizontal overlap?
|
||||
if not (x + w_eff <= xk or x >= xk + wk):
|
||||
y_min = max(y_min, yk + hk) # must sit above this one
|
||||
return y_min
|
||||
|
||||
def _improve_by_nudging(placements, max_iters=10):
|
||||
"""
|
||||
Bidirectional local search on *effective* rectangles (gap included):
|
||||
A) Vertical anchors -> slide left (reduce width).
|
||||
B) Horizontal anchors -> slide up (reduce height).
|
||||
Accept moves that improve (area, width, height). Repeat until stable or max_iters.
|
||||
"""
|
||||
if not placements:
|
||||
return placements
|
||||
|
||||
for _ in range(max_iters):
|
||||
improved = False
|
||||
W_cur, H_cur = _tight_bbox_from_eff(placements)
|
||||
A_cur = W_cur * H_cur
|
||||
|
||||
# Pass A: down anchors then left slide
|
||||
for i in range(len(placements)):
|
||||
idx_i, xi, yi, wi, hi = placements[i]
|
||||
best = (A_cur, W_cur, H_cur, xi, yi)
|
||||
for y_cand in _vertical_candidates(placements, i, hi):
|
||||
x_cand = _min_left_x_at_y(placements, i, y_cand, wi, hi)
|
||||
old = placements[i]
|
||||
placements[i] = (idx_i, x_cand, y_cand, wi, hi)
|
||||
W_try, H_try = _tight_bbox_from_eff(placements)
|
||||
A_try = W_try * H_try
|
||||
if (A_try < best[0]) or (A_try == best[0] and (W_try < best[1] or (W_try == best[1] and H_try < best[2]))):
|
||||
best = (A_try, W_try, H_try, x_cand, y_cand)
|
||||
placements[i] = old
|
||||
if (best[0], best[1], best[2]) < (A_cur, W_cur, H_cur):
|
||||
placements[i] = (idx_i, best[3], best[4], wi, hi)
|
||||
W_cur, H_cur = _tight_bbox_from_eff(placements)
|
||||
A_cur = W_cur * H_cur
|
||||
improved = True
|
||||
|
||||
# Pass B: right anchors then up slide
|
||||
for i in range(len(placements)):
|
||||
idx_i, xi, yi, wi, hi = placements[i]
|
||||
best = (A_cur, W_cur, H_cur, xi, yi)
|
||||
for x_cand in _horizontal_candidates(placements, i, wi):
|
||||
y_cand = _min_top_y_at_x(placements, i, x_cand, wi, hi)
|
||||
old = placements[i]
|
||||
placements[i] = (idx_i, x_cand, y_cand, wi, hi)
|
||||
W_try, H_try = _tight_bbox_from_eff(placements)
|
||||
A_try = W_try * H_try
|
||||
if (A_try < best[0]) or (A_try == best[0] and (H_try < best[2] or (H_try == best[2] and W_try < best[1]))):
|
||||
best = (A_try, W_try, H_try, x_cand, y_cand)
|
||||
placements[i] = old
|
||||
if (best[0], best[1], best[2]) < (A_cur, W_cur, H_cur):
|
||||
placements[i] = (idx_i, best[3], best[4], wi, hi)
|
||||
W_cur, H_cur = _tight_bbox_from_eff(placements)
|
||||
A_cur = W_cur * H_cur
|
||||
improved = True
|
||||
|
||||
if not improved:
|
||||
break
|
||||
|
||||
return placements
|
||||
|
||||
def _pack_guillotine(sizes:list[tuple[int,int]], gap:int, area_tolerance: float = 0.15):
|
||||
"""
|
||||
Try candidate widths around sqrt(total_area) and heuristics.
|
||||
Explore all permutations; collect candidates, run local compaction, then choose among those
|
||||
within (1+area_tolerance)*min_area by squareness (then W, H, then area).
|
||||
Returns: ((W,H), placements) with placements [(orig_idx, x, y, w, h)] using *real* sizes.
|
||||
"""
|
||||
A = sum(w*h for (w,h) in sizes)
|
||||
max_w = max(w for (w,_) in sizes)
|
||||
sum_w = sum(w for (w,_) in sizes) + gap * max(0, len(sizes)-1)
|
||||
S = int(math.ceil(math.sqrt(A)))
|
||||
cand_widths = sorted(set([max_w, S, S+1, S+2, max(max_w, S+3), sum_w]))
|
||||
|
||||
candidates = [] # (W,H, mapped_placements)
|
||||
N = len(sizes)
|
||||
for perm in itertools.permutations(range(N), N):
|
||||
perm_sizes = [sizes[i] for i in perm]
|
||||
for W in cand_widths:
|
||||
ok, placements, _W_eff, _H_eff = _maxrects_pack_fixed_width(perm_sizes, gap, W)
|
||||
if not ok: continue
|
||||
|
||||
# Post-pack compaction (works on effective placements)
|
||||
placements = _improve_by_nudging(placements)
|
||||
|
||||
# Tight bbox after compaction
|
||||
W_eff, H_eff = _tight_bbox_from_eff(placements)
|
||||
|
||||
# Map back to original indices, stripping gap from sizes (positions stay the same)
|
||||
mapped = []
|
||||
for (local_idx, x, y, w_eff, h_eff) in placements:
|
||||
orig_idx = perm[local_idx]
|
||||
w_real, h_real = sizes[orig_idx]
|
||||
mapped.append((orig_idx, x, y, w_real, h_real))
|
||||
candidates.append((W_eff, H_eff, mapped))
|
||||
|
||||
if not candidates:
|
||||
# Fallback: vertical strip
|
||||
W = max(w for (w,_) in sizes)
|
||||
H = sum(h for (_,h) in sizes) + gap * (len(sizes)-1)
|
||||
y = 0
|
||||
mapped = []
|
||||
for i, (w, h) in enumerate(sizes):
|
||||
mapped.append((i, 0, y, w, h)); y += h + gap
|
||||
return (W, H), mapped
|
||||
|
||||
# 1) minimal area
|
||||
areas = [W*H for (W,H,_) in candidates]
|
||||
A_min = min(areas)
|
||||
thresh = int(math.ceil(A_min * (1.0 + max(0.0, area_tolerance))))
|
||||
near = [(W,H,m) for (W,H,m) in candidates if W*H <= thresh]
|
||||
|
||||
# 2) prefer squarer, then smaller W/H, then final tie by area
|
||||
near.sort(key=lambda t: (abs(t[0]-t[1]), t[0], t[1], t[0]*t[1]))
|
||||
W_best, H_best, placements = near[0]
|
||||
return (W_best, H_best), placements
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# Rendering helpers
|
||||
# -----------------------------
|
||||
|
||||
def _paste_rows(tiles: list[torch.Tensor], groups: list[list[int]], tiles_dims: list[list[tuple[int,int]]], canvas_wh: tuple[int,int], spacing: int, bg_rgb: torch.Tensor, resample: str) -> torch.Tensor:
|
||||
device = tiles[0].device; dtype = tiles[0].dtype
|
||||
W_out, H_out = canvas_wh
|
||||
out = torch.ones((1,3,H_out,W_out), device=device, dtype=dtype)
|
||||
out[:,0].fill_(bg_rgb[0]); out[:,1].fill_(bg_rgb[1]); out[:,2].fill_(bg_rgb[2])
|
||||
y = 0; t_index = 0
|
||||
for g_idx, g in enumerate(groups):
|
||||
row_tiles = tiles_dims[g_idx]
|
||||
row_h = max(h for (_,h) in row_tiles)
|
||||
row_w = sum(w for (w,_) in row_tiles) + spacing * max(0, len(row_tiles)-1)
|
||||
x = max(0, (W_out - row_w)//2)
|
||||
for i_in_row in range(len(g)):
|
||||
w_target, h_target = row_tiles[i_in_row]
|
||||
tile = tiles[t_index]
|
||||
_, _, h, w = tile.shape
|
||||
if (h != h_target) or (w != w_target):
|
||||
tile = _resize_ar_nchw(tile, target_h=h_target, resample=resample) if h != h_target else _resize_ar_nchw(tile, target_w=w_target, resample=resample)
|
||||
_, _, h, w = tile.shape
|
||||
y_off = y + max(0, (row_h - h)//2)
|
||||
out[:, :, y_off:y_off+h, x:x+w] = tile
|
||||
x += w + spacing; t_index += 1
|
||||
y += row_h + spacing
|
||||
return out
|
||||
|
||||
def _paste_cols(tiles: list[torch.Tensor], groups: list[list[int]], tiles_dims: list[list[tuple[int,int]]], canvas_wh: tuple[int,int], spacing: int, bg_rgb: torch.Tensor, resample: str) -> torch.Tensor:
|
||||
device = tiles[0].device; dtype = tiles[0].dtype
|
||||
W_out, H_out = canvas_wh
|
||||
out = torch.ones((1,3,H_out,W_out), device=device, dtype=dtype)
|
||||
out[:,0].fill_(bg_rgb[0]); out[:,1].fill_(bg_rgb[1]); out[:,2].fill_(bg_rgb[2])
|
||||
x = 0; t_index = 0
|
||||
for g_idx, g in enumerate(groups):
|
||||
col_tiles = tiles_dims[g_idx]
|
||||
col_w = max(w for (w,_) in col_tiles)
|
||||
col_h = sum(h for (_,h) in col_tiles) + spacing * max(0, len(col_tiles)-1)
|
||||
y = max(0, (H_out - col_h)//2)
|
||||
for i_in_col in range(len(g)):
|
||||
w_target, h_target = col_tiles[i_in_col]
|
||||
tile = tiles[t_index]
|
||||
_, _, h, w = tile.shape
|
||||
if (h != h_target) or (w != w_target):
|
||||
tile = _resize_ar_nchw(tile, target_w=w_target, resample=resample) if w != w_target else _resize_ar_nchw(tile, target_h=h_target, resample=resample)
|
||||
_, _, h, w = tile.shape
|
||||
x_off = x + max(0, (col_w - w)//2)
|
||||
out[:, :, y:y+h, x_off:x_off+w] = tile
|
||||
y += h + spacing; t_index += 1
|
||||
x += col_w + spacing
|
||||
return out
|
||||
|
||||
def _paste_absolute(tiles_by_index: dict[int, torch.Tensor], placements: list[tuple[int,int,int,int,int]], canvas_wh: tuple[int,int], bg_rgb: torch.Tensor) -> torch.Tensor:
|
||||
"""placements: list of (orig_idx, x, y, w, h) with *real* sizes."""
|
||||
tile0 = next(iter(tiles_by_index.values()))
|
||||
device = tile0.device; dtype = tile0.dtype
|
||||
W_out, H_out = canvas_wh
|
||||
out = torch.ones((1,3,H_out,W_out), device=device, dtype=dtype)
|
||||
out[:,0].fill_(bg_rgb[0]); out[:,1].fill_(bg_rgb[1]); out[:,2].fill_(bg_rgb[2])
|
||||
|
||||
for (orig_idx, x, y, w_t, h_t) in placements:
|
||||
tile = tiles_by_index[orig_idx]
|
||||
_, _, h, w = tile.shape
|
||||
if (h != h_t) or (w != w_t):
|
||||
if h != h_t:
|
||||
tile = _resize_ar_nchw(tile, target_h=h_t, resample="bicubic")
|
||||
else:
|
||||
tile = _resize_ar_nchw(tile, target_w=w_t, resample="bicubic")
|
||||
_, _, h, w = tile.shape
|
||||
out[:, :, y:y+h, x:x+w] = tile
|
||||
return out
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# ComfyUI Node
|
||||
# -----------------------------
|
||||
class PVL_ImageStitch:
|
||||
"""PVL – Image Stitch: stitch up to 5 images either linearly or via pack-to-square."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image_1": ("IMAGE",),
|
||||
"arrangement": (["right","left","up","down"], {"default": "right"}),
|
||||
"pack_to_square": ("BOOLEAN", {"default": False}),
|
||||
"pack_mode": (["grid","guillotine"], {"default": "grid"}),
|
||||
"match_image_size": ("BOOLEAN", {"default": False}),
|
||||
"match_type": (["downscale","upscale"], {"default": "downscale"}),
|
||||
"resample": (["lanczos","bicubic","bilinear","area","nearest"], {"default": "bicubic"}),
|
||||
"spacing_width": ("INT", {"default": 0, "min": 0, "max": 2048}),
|
||||
"spacing_color": ("STRING", {"default": "0,0,0"}),
|
||||
},
|
||||
"optional": {
|
||||
"square_tolerance_pct": ("INT", {"default": 15, "min": 0, "max": 50,
|
||||
"tooltip": "Allow up to this % extra area to pick a squarer layout."}),
|
||||
"image_2": ("IMAGE",),
|
||||
"image_3": ("IMAGE",),
|
||||
"image_4": ("IMAGE",),
|
||||
"image_5": ("IMAGE",),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "stitch"
|
||||
CATEGORY = "image/compose"
|
||||
|
||||
def _validate_and_collect(self, imgs: list[torch.Tensor | None]) -> list[torch.Tensor]:
|
||||
out = []
|
||||
for t in imgs:
|
||||
if t is None: continue
|
||||
if not isinstance(t, torch.Tensor): raise TypeError("pvl_ImageStitch: expected IMAGE tensors")
|
||||
if t.ndim != 4: raise ValueError("pvl_ImageStitch: expected [B,H,W,C] tensors")
|
||||
b, _, _, c = t.shape
|
||||
if b != 1: raise ValueError(f"pvl_ImageStitch: batch > 1 is not supported (got B={b}).")
|
||||
if c not in (3,4): raise ValueError(f"pvl_ImageStitch: expected channels C=3 or 4, got C={c}.")
|
||||
out.append(_drop_alpha(t))
|
||||
return out
|
||||
|
||||
def _maybe_match_linear(self, tiles_nchw: list[torch.Tensor], axis: str, match_type: str, resample: str) -> list[torch.Tensor]:
|
||||
if not tiles_nchw: return tiles_nchw
|
||||
if axis == "row":
|
||||
heights = [t.shape[2] for t in tiles_nchw]
|
||||
tgt_h = min(heights) if match_type=="downscale" else max(heights)
|
||||
return [t if t.shape[2]==tgt_h else _resize_ar_nchw(t, target_h=tgt_h, resample=resample) for t in tiles_nchw]
|
||||
elif axis == "col":
|
||||
widths = [t.shape[3] for t in tiles_nchw]
|
||||
tgt_w = min(widths) if match_type=="downscale" else max(widths)
|
||||
return [t if t.shape[3]==tgt_w else _resize_ar_nchw(t, target_w=tgt_w, resample=resample) for t in tiles_nchw]
|
||||
else:
|
||||
raise ValueError("axis must be 'row' or 'col'")
|
||||
|
||||
def stitch(self,
|
||||
image_1: torch.Tensor,
|
||||
arrangement: str,
|
||||
pack_to_square: bool,
|
||||
pack_mode: str,
|
||||
match_image_size: bool,
|
||||
match_type: str,
|
||||
resample: str,
|
||||
spacing_width: int,
|
||||
spacing_color: str,
|
||||
square_tolerance_pct: int = 15,
|
||||
image_2: torch.Tensor | None = None,
|
||||
image_3: torch.Tensor | None = None,
|
||||
image_4: torch.Tensor | None = None,
|
||||
image_5: torch.Tensor | None = None):
|
||||
|
||||
# Collect & validate
|
||||
img_list_bhwc = self._validate_and_collect([image_1, image_2, image_3, image_4, image_5])
|
||||
if len(img_list_bhwc) == 0:
|
||||
raise ValueError("pvl_ImageStitch: no valid images provided.")
|
||||
if len(img_list_bhwc) == 1:
|
||||
return (img_list_bhwc[0],)
|
||||
|
||||
device = img_list_bhwc[0].device
|
||||
dtype = img_list_bhwc[0].dtype
|
||||
bg_rgb = _parse_rgb(spacing_color, device=device, dtype=dtype)
|
||||
|
||||
# NCHW
|
||||
tiles_nchw = [_to_nchw(t) for t in img_list_bhwc]
|
||||
|
||||
# Linear path
|
||||
if not pack_to_square:
|
||||
if match_image_size:
|
||||
if arrangement in ("right","left"):
|
||||
tiles_nchw = self._maybe_match_linear(tiles_nchw, "row", match_type, resample)
|
||||
elif arrangement in ("up","down"):
|
||||
tiles_nchw = self._maybe_match_linear(tiles_nchw, "col", match_type, resample)
|
||||
else:
|
||||
raise ValueError("Invalid arrangement")
|
||||
if arrangement in ("right","left"):
|
||||
out_nchw = _render_linear_row(tiles_nchw, spacing_width, bg_rgb, arrangement)
|
||||
else:
|
||||
out_nchw = _render_linear_col(tiles_nchw, spacing_width, bg_rgb, arrangement)
|
||||
return (_to_bhwc(out_nchw).contiguous(),)
|
||||
|
||||
# Pack path (pre-normalize when requested)
|
||||
if match_image_size:
|
||||
widths = [int(t.shape[3]) for t in tiles_nchw]
|
||||
heights = [int(t.shape[2]) for t in tiles_nchw]
|
||||
if match_type == "upscale":
|
||||
S = int(round((max(widths) + max(heights)) / 2))
|
||||
else:
|
||||
S = int(round((min(widths) + min(heights)) / 2))
|
||||
tiles_nchw = [_resize_fit_square_nchw(t, S, match_type, resample) for t in tiles_nchw]
|
||||
|
||||
sizes = [(int(t.shape[3]), int(t.shape[2])) for t in tiles_nchw] # (w,h)
|
||||
tol = max(0.0, float(square_tolerance_pct) / 100.0)
|
||||
|
||||
if pack_mode == "grid":
|
||||
# After pre-normalization, do NOT perform per-row/column matching during grid pack
|
||||
kind, (W_out, H_out), groups, tiles_dims = _pack_grid(
|
||||
sizes, spacing_width, allow_match=False, match_type=match_type, area_tolerance=tol
|
||||
)
|
||||
perm_indices = [i for g in groups for i in g]
|
||||
tiles_perm = [tiles_nchw[i] for i in perm_indices]
|
||||
out_nchw = _paste_rows(tiles_perm, groups, tiles_dims, (W_out, H_out), spacing_width, bg_rgb, resample) if kind=="rows" \
|
||||
else _paste_cols(tiles_perm, groups, tiles_dims, (W_out, H_out), spacing_width, bg_rgb, resample)
|
||||
return (_to_bhwc(out_nchw).contiguous(),)
|
||||
|
||||
# pack_mode == "guillotine"
|
||||
(W_out, H_out), placements = _pack_guillotine(sizes, spacing_width, area_tolerance=tol)
|
||||
tiles_by_idx = {i: tiles_nchw[i] for i in range(len(tiles_nchw))}
|
||||
out_nchw = _paste_absolute(tiles_by_idx, placements, (W_out, H_out), bg_rgb)
|
||||
return (_to_bhwc(out_nchw).contiguous(),)
|
||||
+139
-20
@@ -1,34 +1,153 @@
|
||||
# pvl_checkIfNone.py
|
||||
# Single-input checker with '*' passthrough and a single 'has_value' BOOLEAN.
|
||||
# No external deps; includes a minimal SmartType/VariantSupport that avoids recursion.
|
||||
|
||||
import copy
|
||||
|
||||
# --- SmartType/VariantSupport (permissive '*' / unions, non-recursive) ---
|
||||
|
||||
def MakeSmartType(t):
|
||||
if isinstance(t, SmartType):
|
||||
return t
|
||||
if isinstance(t, str):
|
||||
return SmartType(t)
|
||||
return t
|
||||
|
||||
class SmartType(str):
|
||||
@staticmethod
|
||||
def _to_set(x):
|
||||
if isinstance(x, (SmartType, str)):
|
||||
return set(str(x).split(","))
|
||||
return {str(x)}
|
||||
|
||||
@staticmethod
|
||||
def _compatible(a, b):
|
||||
if str(a) == "*" or str(b) == "*":
|
||||
return True
|
||||
aset = SmartType._to_set(a)
|
||||
bset = SmartType._to_set(b)
|
||||
return bool(aset & bset) or aset.issubset(bset) or bset.issubset(aset)
|
||||
|
||||
def __eq__(self, other):
|
||||
return SmartType._compatible(self, other)
|
||||
|
||||
def __ne__(self, other):
|
||||
return not SmartType._compatible(self, other)
|
||||
|
||||
def VariantSupport():
|
||||
def decorator(cls):
|
||||
if hasattr(cls, "INPUT_TYPES"):
|
||||
old_input_types = getattr(cls, "INPUT_TYPES")
|
||||
def new_input_types(*args, **kwargs):
|
||||
types = copy.deepcopy(old_input_types(*args, **kwargs))
|
||||
for section in ("required", "optional"):
|
||||
if section in types:
|
||||
for key, spec in list(types[section].items()):
|
||||
if isinstance(spec, tuple) and spec:
|
||||
first = MakeSmartType(spec[0])
|
||||
rest = spec[1:] if len(spec) > 1 else ()
|
||||
types[section][key] = (first,) + rest
|
||||
elif isinstance(spec, str):
|
||||
types[section][key] = (MakeSmartType(spec),)
|
||||
return types
|
||||
setattr(cls, "INPUT_TYPES", new_input_types)
|
||||
|
||||
if hasattr(cls, "RETURN_TYPES"):
|
||||
setattr(cls, "RETURN_TYPES",
|
||||
tuple(MakeSmartType(x) for x in getattr(cls, "RETURN_TYPES")))
|
||||
|
||||
if not hasattr(cls, "VALIDATE_INPUTS"):
|
||||
@staticmethod
|
||||
def VALIDATE_INPUTS(input_types: dict):
|
||||
inputs = cls.INPUT_TYPES()
|
||||
for section in ("required", "optional"):
|
||||
if section not in inputs:
|
||||
continue
|
||||
for key, spec in inputs[section].items():
|
||||
if key not in input_types:
|
||||
continue # optional and not connected is fine
|
||||
expected = spec[0] if isinstance(spec, tuple) else spec
|
||||
expected = MakeSmartType(expected)
|
||||
actual = MakeSmartType(input_types[key])
|
||||
if actual != expected:
|
||||
return f"Invalid type for '{key}': {actual} (expected {expected})"
|
||||
return True
|
||||
setattr(cls, "VALIDATE_INPUTS", VALIDATE_INPUTS)
|
||||
return cls
|
||||
return decorator
|
||||
|
||||
# --- The node ---
|
||||
|
||||
_SENTINEL = object() # detect "not connected" vs "connected None"
|
||||
|
||||
@VariantSupport()
|
||||
class IsConnected:
|
||||
"""
|
||||
Returns True if the input is connected (not None), otherwise False.
|
||||
This version checks IMAGE-type input.
|
||||
One universal '*' input (non-lazy).
|
||||
Outputs:
|
||||
- value ('*' passthrough) → same type/value as input (binds wildcard)
|
||||
- has_value (BOOLEAN) → True if connected AND not empty
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"optional": {
|
||||
"value": ("IMAGE", {
|
||||
"default": None,
|
||||
"tooltip": "Connect any image here. Will return True if connected, False if not."
|
||||
}),
|
||||
# IMPORTANT: no {"lazy": True}; always fetch upstream value
|
||||
"value": ("*",),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("BOOLEAN",)
|
||||
RETURN_NAMES = ("is_connected",)
|
||||
RETURN_TYPES = ("*", "BOOLEAN")
|
||||
RETURN_NAMES = ("value", "has_value")
|
||||
FUNCTION = "check"
|
||||
CATEGORY = "PVL_tools"
|
||||
|
||||
def check(self, value=None):
|
||||
return (value is not None,)
|
||||
# ---- Non-emptiness rules ----
|
||||
def _has_value(self, v):
|
||||
if v is None:
|
||||
return False
|
||||
# Booleans: presence counts, even if False
|
||||
if isinstance(v, bool):
|
||||
return True
|
||||
# Numbers: presence counts, even if 0
|
||||
if isinstance(v, (int, float)):
|
||||
return True
|
||||
# Strings: require non-whitespace content
|
||||
if isinstance(v, str):
|
||||
return len(v.strip()) > 0
|
||||
# Bytes-like
|
||||
if isinstance(v, (bytes, bytearray, memoryview)):
|
||||
return len(v) > 0
|
||||
# Sequences / mappings
|
||||
if isinstance(v, (list, tuple, dict, set)):
|
||||
# Conditioning often is list[tuple(...)] — non-empty counts
|
||||
if isinstance(v, list) and v and isinstance(v[0], tuple):
|
||||
return True
|
||||
return len(v) > 0
|
||||
# Latent dicts typically have 'samples' tensor
|
||||
if isinstance(v, dict) and "samples" in v:
|
||||
samples = v.get("samples", None)
|
||||
try:
|
||||
import torch
|
||||
if isinstance(samples, torch.Tensor):
|
||||
return samples.numel() > 0
|
||||
except Exception:
|
||||
return samples is not None
|
||||
return samples is not None
|
||||
# Torch tensors: images/masks/others
|
||||
try:
|
||||
import torch
|
||||
if isinstance(v, torch.Tensor):
|
||||
return v.numel() > 0
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback: Python truthiness
|
||||
try:
|
||||
return bool(v)
|
||||
except Exception:
|
||||
return True # be permissive if in doubt
|
||||
|
||||
|
||||
# Required exports for ComfyUI
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"PVLCheckIfConnected": IsConnected,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"PVLCheckIfConnected": "PVL Check If Connected",
|
||||
}
|
||||
def check(self, value=_SENTINEL):
|
||||
v = None if value is _SENTINEL else value
|
||||
has_value = self._has_value(v) if value is not _SENTINEL else False
|
||||
return (v, has_value)
|
||||
Reference in New Issue
Block a user