""" FEnodes — Tiling nodes for VFX production pipelines. Author: FugitiveExpert01 Version: v0.0.6 """ __version__ = "v0.0.6" import math import torch import torch.nn.functional as F import numpy as np from PIL import Image, ImageDraw, ImageFont try: from server import PromptServer except ImportError: PromptServer = None class TilingNodeBase: """Base utility for tile masking and tensor shape normalisation.""" @staticmethod def ensure_4d_BCHW(t, device): t = t.to(device=device, dtype=torch.float32) if t.dim() == 2: t = t.unsqueeze(0).unsqueeze(0) elif t.dim() == 3: t = t.unsqueeze(0).permute(0, 3, 1, 2) elif t.dim() == 4: if t.shape[-1] in (1, 3, 4): t = t.permute(0, 3, 1, 2) else: raise ValueError(f"[TileMerge] ensure_4d_BCHW: unexpected shape {t.shape}") assert t.dim() == 4, f"[TileMerge] ensure_4d_BCHW failed, result shape: {t.shape}" return t.contiguous() @staticmethod def create_feather_mask(h, w, ov_top, ov_bottom, ov_left, ov_right, feather_scale, device): """ Linear feather mask. feather_scale multiplies each overlap zone so the user can widen or tighten the fade without touching TileSplit's overlap_percent. Values are clamped so the fade never exceeds half the tile dimension. """ ov_top = min(int(ov_top * feather_scale), h // 2) ov_bottom = min(int(ov_bottom * feather_scale), h // 2) ov_left = min(int(ov_left * feather_scale), w // 2) ov_right = min(int(ov_right * feather_scale), w // 2) mask = torch.ones((1, 1, h, w), device=device) if ov_top > 0: mask[:, :, :ov_top, :] *= torch.linspace(0, 1, ov_top, device=device).view(1, 1, ov_top, 1) if ov_bottom > 0: mask[:, :, -ov_bottom:, :] *= torch.linspace(1, 0, ov_bottom, device=device).view(1, 1, ov_bottom, 1) if ov_left > 0: mask[:, :, :, :ov_left] *= torch.linspace(0, 1, ov_left, device=device).view(1, 1, 1, ov_left) if ov_right > 0: mask[:, :, :, -ov_right:] *= torch.linspace(1, 0, ov_right, device=device).view(1, 1, 1, ov_right) return mask @staticmethod def _send_text(unique_id, html): """Push a progress text update to the node footer in the ComfyUI frontend.""" if unique_id is not None and PromptServer is not None: try: PromptServer.instance.send_progress_text(html, unique_id) except Exception: pass ALIGNMENT_OPTIONS = ["Free", "8 (SD)", "16 (WAN / VACE)"] class TileSplit(TilingNodeBase): """Splits an image or video batch into a list of tile batches.""" @classmethod def INPUT_TYPES(s): return { "required": { "image": ("IMAGE",), "use_pixel_size": ("BOOLEAN", { "default": False, "tooltip": ( "OFF: tiles_x / tiles_y define the grid count. " "ON: tiles_x / tiles_y are reinterpreted as target tile width / height in pixels " "and the grid is derived automatically via ceil(canvas / tile_px)." ), }), "tiles_x": ("INT", {"default": 2, "min": 1, "max": 8192, "step": 1, "tooltip": "Grid mode: number of columns. Pixel mode: target tile width in pixels."}), "tiles_y": ("INT", {"default": 2, "min": 1, "max": 8192, "step": 1, "tooltip": "Grid mode: number of rows. Pixel mode: target tile height in pixels."}), "overlap_percent": ("FLOAT", {"default": 0.15, "min": 0.0, "max": 0.5, "step": 0.01}), "alignment": (ALIGNMENT_OPTIONS, { "default": "Free", "tooltip": ( "Free: no snapping. " "8 (SD): snap tile dimensions to multiples of 8 for SD 1.5 / SDXL. " "16 (WAN / VACE): snap to multiples of 16 — required to avoid token " "count mismatches inside VACE attention blocks." ), }), }, "hidden": { "unique_id": "UNIQUE_ID", }, } RETURN_TYPES = ("IMAGE", "IMAGE", "TILE_CALC") RETURN_NAMES = ("tiles", "debug_image", "tile_calc") OUTPUT_IS_LIST = (True, False, False) FUNCTION = "split" CATEGORY = "FEnodes" def split(self, image, use_pixel_size, tiles_x, tiles_y, overlap_percent, alignment="Free", unique_id=None): B, H, W, C = image.shape device = torch.device("cuda" if torch.cuda.is_available() else "cpu") align_map = {"Free": None, "8 (SD)": 8, "16 (WAN / VACE)": 16} align = align_map.get(alignment, None) # ── Grid resolution ────────────────────────────────────────────────── if use_pixel_size: # tiles_x / tiles_y are pixel dimensions in this mode tiles_x = max(1, math.ceil(W / tiles_x)) tiles_y = max(1, math.ceil(H / tiles_y)) stride_w = W / tiles_x stride_h = H / tiles_y # overlap_percent is a fraction of the full canvas — safe upper limit # is 1/tiles (beyond that the overlap zone exceeds one stride). max_overlap = 1.0 / max(tiles_x, tiles_y) if overlap_percent > max_overlap: print( f"[FEnodes/TileSplit] overlap_percent {overlap_percent:.2f} exceeds safe " f"maximum {max_overlap:.2f} for a {tiles_y}×{tiles_x} grid — clamping." ) overlap_percent = max_overlap raw_w = stride_w + (W * overlap_percent) raw_h = stride_h + (H * overlap_percent) if align is not None: tile_w = int(np.ceil(raw_w / align) * align) tile_h = int(np.ceil(raw_h / align) * align) else: tile_w = int(np.ceil(raw_w)) tile_h = int(np.ceil(raw_h)) tile_w = min(tile_w, W) tile_h = min(tile_h, H) start_x = np.linspace(0, W - tile_w, tiles_x, dtype=int) if tiles_x > 1 else np.array([0]) start_y = np.linspace(0, H - tile_h, tiles_y, dtype=int) if tiles_y > 1 else np.array([0]) print( f"[FEnodes/TileSplit] canvas={H}x{W} grid={tiles_y}x{tiles_x} " f"alignment={alignment} tile={tile_h}x{tile_w} overlap={overlap_percent:.0%}" ) img_tensor = image.permute(0, 3, 1, 2).to(device) num_tiles = tiles_x * tiles_y tile_sequences = [[] for _ in range(num_tiles)] tile_layouts = [] debug_pil = Image.fromarray((image[0].cpu().numpy() * 255).astype(np.uint8)).convert("RGBA") overlay = Image.new("RGBA", debug_pil.size, (0, 0, 0, 120)) debug_pil = Image.alpha_composite(debug_pil, overlay) draw = ImageDraw.Draw(debug_pil) try: font = ImageFont.truetype("LiberationSans-Bold.ttf", int(tile_h * 0.15)) except Exception: font = ImageFont.load_default() for b in range(B): tile_count = 0 for y_idx, y in enumerate(start_y): for x_idx, x in enumerate(start_x): tile_frame = img_tensor[b:b+1, :, y:y+tile_h, x:x+tile_w] tile_sequences[tile_count].append(tile_frame.permute(0, 2, 3, 1).cpu()) if b == 0: ov_l = (start_x[x_idx] - start_x[x_idx-1]) if x_idx > 0 else 0 ov_r = (tile_w - (start_x[x_idx+1] - start_x[x_idx])) if x_idx < tiles_x - 1 else 0 ov_t = (start_y[y_idx] - start_y[y_idx-1]) if y_idx > 0 else 0 ov_b = (tile_h - (start_y[y_idx+1] - start_y[y_idx])) if y_idx < tiles_y - 1 else 0 tile_layouts.append({ "x": int(x), "y": int(y), "ov_l": int(tile_w - ov_l) if x_idx > 0 else 0, "ov_r": int(ov_r), "ov_t": int(tile_h - ov_t) if y_idx > 0 else 0, "ov_b": int(ov_b), }) color = ((x_idx * 40) % 255, (y_idx * 60) % 255, 255, 100) draw.rectangle([x, y, x + tile_w, y + tile_h], outline="white", fill=color, width=2) draw.text((x + 10, y + 10), f"Tile {tile_count}", fill="white", font=font) tile_count += 1 final_tile_batches = [torch.cat(seq, dim=0) for seq in tile_sequences] debug_out = torch.from_numpy( np.array(debug_pil.convert("RGB")).astype(np.float32) / 255.0 ).unsqueeze(0) tile_calc = { "orig_w": W, "orig_h": H, "tile_w": tile_w, "tile_h": tile_h, "batch_size": B, "num_tiles": num_tiles, "layouts": tile_layouts, } # ── Footer ──────────────────────────────────────────────────────────── tile_mb = (B * tile_h * tile_w * C * 4) / (1024 ** 2) total_mb = tile_mb * num_tiles self._send_text(unique_id, ( f"