diff --git a/__init__.py b/__init__.py index 4781e89..11d867e 100644 --- a/__init__.py +++ b/__init__.py @@ -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", } diff --git a/pvl_ImageResize.py b/pvl_ImageResize.py new file mode 100644 index 0000000..39412d4 --- /dev/null +++ b/pvl_ImageResize.py @@ -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)) diff --git a/pvl_ImageStitch.py b/pvl_ImageStitch.py new file mode 100644 index 0000000..5de1bdc --- /dev/null +++ b/pvl_ImageStitch.py @@ -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(),) \ No newline at end of file diff --git a/pvl_checkIfNone.py b/pvl_checkIfNone.py index 3a89d02..178b8d5 100644 --- a/pvl_checkIfNone.py +++ b/pvl_checkIfNone.py @@ -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) \ No newline at end of file