From 4771326aa3c4dd85c4eec8e38d0bfc872a73d4da Mon Sep 17 00:00:00 2001 From: ttplanet Date: Thu, 2 Jul 2026 14:06:43 +0800 Subject: [PATCH] Add Smart Tile Experimental workflow and nodes --- README.md | 74 +++++++ TTP_toolsets.py | 556 ++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 615 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 99205ea..1dc0362 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,80 @@ https://github.com/user-attachments/assets/af06b9d3-9c84-4a83-ba90-eb4ec4bb2e99 ## **Instructions** +### **Smart Tile Experimental Workflow** + +The **Smart Tile Experimental** nodes provide a manual tile workflow that keeps the existing automatic tile nodes unchanged. They are designed for layouts where you want to split the image by visual structure, such as face, hands, subject, clothes, foreground, and background. + +The first experimental version uses a **large sampling area** and a **smaller paste area**: + +- `TTP Smart Tile Layout (Experimental)`: stores a JSON tile layout. +- `TTP Smart Tile Crop (Experimental)`: reads the input image directly, crops padded tile images, and outputs tile metadata plus a debug preview. +- `TTP Smart Tile Visual Crop (Experimental)`: creates a visual/parameter-based crop plan without JSON, using grid controls plus optional focus regions. +- `TTP Smart Tile Assemble (Experimental)`: assembles sampled tiles back into the final image with feathered weighted blending, priority, and importance weights. + +Visual workflow without JSON: + +```text +Load Image + → TTP Smart Tile Visual Crop (Experimental) + → Preview the plan output and tune grid/focus parameters + → VAE Encode / Sampler / VAE Decode + → TTP Smart Tile Assemble (Experimental) + → Final Image +``` + +`TTP Smart Tile Visual Crop (Experimental)` is the recommended starting point if you want to tune the tile plan visually in ComfyUI instead of editing JSON. It provides controls for grid columns/rows, grid padding/blending, optional full-image coverage, and two optional focus boxes. Connect its `preview` output to `PreviewImage` while adjusting parameters. + +JSON workflow: + +```text +Load Image + → TTP Smart Tile Layout (Experimental) + → TTP Smart Tile Crop (Experimental) + → VAE Encode / Sampler / VAE Decode + → TTP Smart Tile Assemble (Experimental) + → Final Image +``` + +Example layout: + +```json +{ + "defaults": { + "pad": 128, + "blend": 48, + "priority": 50, + "importance": 1.0 + }, + "tiles": [ + { + "name": "full_image", + "x": 0, + "y": 0, + "w": 1.0, + "h": 1.0, + "pad": 0, + "blend": 96, + "priority": 10, + "importance": 0.5 + }, + { + "name": "face", + "x": 0.35, + "y": 0.08, + "w": 0.30, + "h": 0.28, + "pad": 192, + "blend": 64, + "priority": 100, + "importance": 1.0 + } + ] +} +``` + +Coordinates can be pixel values or normalized values from `0.0` to `1.0`. `pad` expands the crop sent into the sampler, while `blend`, `priority`, and `importance` control how the sampled tile is pasted back. + ### **1. Image Tile Batch Node** This node cuts an image into pieces automatically based on your specified width and height. It also records the necessary information for further processing. diff --git a/TTP_toolsets.py b/TTP_toolsets.py index aff6ec4..36cc42f 100644 --- a/TTP_toolsets.py +++ b/TTP_toolsets.py @@ -1,6 +1,7 @@ -import cv2 -import numpy as np -from PIL import Image, ImageFilter, ImageChops, ImageEnhance +import json +import cv2 +import numpy as np +from PIL import Image, ImageFilter, ImageChops, ImageEnhance, ImageDraw import node_helpers import torch import comfy.model_management @@ -133,7 +134,7 @@ class TTP_Image_Tile_Batch: return (tiles, positions, (img_width, img_height), (num_cols, num_rows)) -class TTP_Image_Assy: +class TTP_Image_Assy: def __init__(self, *args, **kwargs): pass @@ -269,7 +270,524 @@ class TTP_Image_Assy: new_final_image.paste(row_images[row], (0, final_image.height)) final_image = new_final_image - return pil2tensor(final_image).unsqueeze(0) + return pil2tensor(final_image).unsqueeze(0) + + +def _ttp_clamp(value, minimum, maximum): + return max(minimum, min(maximum, value)) + + +def _ttp_round_to(value, multiple): + if multiple <= 1: + return int(round(value)) + return int(np.ceil(value / multiple) * multiple) + + +def _ttp_parse_box_value(value, size): + if isinstance(value, float) and 0.0 <= value <= 1.0: + return int(round(value * size)) + return int(round(value)) + + +def _ttp_parse_margin_value(value, size): + if isinstance(value, float) and 0.0 <= value <= 1.0: + return int(round(value * size)) + return int(round(value)) + + +def _ttp_normalize_tile_box(tile, image_width, image_height, defaults): + x = _ttp_parse_box_value(tile.get("x", 0), image_width) + y = _ttp_parse_box_value(tile.get("y", 0), image_height) + w = _ttp_parse_box_value(tile.get("w", tile.get("width", image_width)), image_width) + h = _ttp_parse_box_value(tile.get("h", tile.get("height", image_height)), image_height) + + x = _ttp_clamp(x, 0, image_width) + y = _ttp_clamp(y, 0, image_height) + w = _ttp_clamp(w, 1, image_width - x) + h = _ttp_clamp(h, 1, image_height - y) + + pad_value = tile.get("pad", tile.get("padding", defaults.get("pad", 0))) + blend_value = tile.get("blend", tile.get("feather", defaults.get("blend", 32))) + pad = _ttp_parse_margin_value(pad_value, max(image_width, image_height)) + blend = _ttp_parse_margin_value(blend_value, max(w, h)) + + return { + "name": str(tile.get("name", f"tile_{tile.get('id', 0)}")), + "core_box": [x, y, w, h], + "pad": max(0, pad), + "blend": max(0, blend), + "priority": float(tile.get("priority", defaults.get("priority", 50))), + "importance": float(tile.get("importance", defaults.get("importance", 1.0))), + "strength": float(tile.get("strength", defaults.get("strength", 1.0))), + "prompt_tag": str(tile.get("prompt_tag", tile.get("name", ""))), + "align": bool(tile.get("align", defaults.get("align", False))), + } + + +def _ttp_expand_grid_tiles(grid, defaults): + x_lines = grid.get("x", []) + y_lines = grid.get("y", []) + if len(x_lines) < 2 or len(y_lines) < 2: + raise ValueError("Smart Tile grid layout requires at least two x lines and two y lines") + + prefix = str(grid.get("prefix", defaults.get("prefix", "grid"))) + grid_defaults = defaults.copy() + grid_defaults.update({k: v for k, v in grid.items() if k not in {"x", "y", "prefix"}}) + + tiles = [] + for row in range(len(y_lines) - 1): + for col in range(len(x_lines) - 1): + x0, x1 = x_lines[col], x_lines[col + 1] + y0, y1 = y_lines[row], y_lines[row + 1] + tiles.append({ + **grid_defaults, + "name": f"{prefix}_{row}_{col}", + "x": x0, + "y": y0, + "w": x1 - x0, + "h": y1 - y0, + }) + return tiles + + +def _ttp_parse_smart_tile_layout(layout_text, image_width, image_height): + try: + layout = json.loads(layout_text) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid Smart Tile layout JSON: {exc}") from exc + + defaults = {} + raw_tiles = [] + + if isinstance(layout, list): + raw_tiles = layout + elif isinstance(layout, dict): + defaults = dict(layout.get("defaults", {})) + if "grid" in layout: + raw_tiles.extend(_ttp_expand_grid_tiles(layout["grid"], defaults)) + raw_tiles.extend(layout.get("tiles", [])) + raw_tiles.extend(layout.get("custom", [])) + else: + raise ValueError("Smart Tile layout must be a JSON list or object") + + if not raw_tiles: + raw_tiles = [{"name": "full_image", "x": 0, "y": 0, "w": image_width, "h": image_height}] + + normalized_tiles = [] + for index, tile in enumerate(raw_tiles): + if not isinstance(tile, dict): + raise ValueError(f"Smart Tile entry {index} must be an object") + normalized = _ttp_normalize_tile_box({**tile, "id": index}, image_width, image_height, defaults) + normalized["id"] = index + x, y, w, h = normalized["core_box"] + pad = normalized["pad"] + sample_left = _ttp_clamp(x - pad, 0, image_width) + sample_top = _ttp_clamp(y - pad, 0, image_height) + sample_right = _ttp_clamp(x + w + pad, 0, image_width) + sample_bottom = _ttp_clamp(y + h + pad, 0, image_height) + normalized["sample_box"] = [ + sample_left, + sample_top, + max(1, sample_right - sample_left), + max(1, sample_bottom - sample_top), + ] + normalized["paste_box"] = [x, y, w, h] + normalized_tiles.append(normalized) + + return normalized_tiles + + +def _ttp_create_feather_mask(width, height, feather): + if feather <= 0: + return np.ones((height, width, 1), dtype=np.float32) + + yy, xx = np.mgrid[0:height, 0:width] + distance_left = xx + distance_right = width - 1 - xx + distance_top = yy + distance_bottom = height - 1 - yy + distance = np.minimum(np.minimum(distance_left, distance_right), np.minimum(distance_top, distance_bottom)) + mask = np.clip(distance / max(1, feather), 0.0, 1.0).astype(np.float32) + return mask[:, :, None] + + +def _ttp_pad_image_to_size(image, target_width, target_height): + if image.width == target_width and image.height == target_height: + return image + + padded = Image.new("RGB", (target_width, target_height)) + padded.paste(image, (0, 0)) + + if image.width < target_width: + right_edge = image.crop((image.width - 1, 0, image.width, image.height)) + right_fill = right_edge.resize((target_width - image.width, image.height), Image.Resampling.NEAREST) + padded.paste(right_fill, (image.width, 0)) + + if image.height < target_height: + bottom_edge = padded.crop((0, image.height - 1, target_width, image.height)) + bottom_fill = bottom_edge.resize((target_width, target_height - image.height), Image.Resampling.NEAREST) + padded.paste(bottom_fill, (0, image.height)) + + return padded + + +def _ttp_crop_smart_tiles_from_meta(pil_image, tiles_meta, round_to): + image_width, image_height = pil_image.size + tile_images = [] + positions = [] + for tile in tiles_meta: + sx, sy, sw, sh = tile["sample_box"] + sw = max(1, _ttp_round_to(sw, round_to)) + sh = max(1, _ttp_round_to(sh, round_to)) + if sx + sw > image_width: + sx = max(0, image_width - sw) + if sy + sh > image_height: + sy = max(0, image_height - sh) + sw = min(sw, image_width - sx) + sh = min(sh, image_height - sy) + tile["sample_box"] = [sx, sy, sw, sh] + + crop = pil_image.crop((sx, sy, sx + sw, sy + sh)) + tile_images.append(crop) + tile["tile_canvas_box"] = [0, 0, sw, sh] + x, y, w, h = tile["paste_box"] + positions.append((x, y, x + w, y + h)) + + max_tile_width = max(tile.width for tile in tile_images) + max_tile_height = max(tile.height for tile in tile_images) + max_tile_width = max(1, _ttp_round_to(max_tile_width, round_to)) + max_tile_height = max(1, _ttp_round_to(max_tile_height, round_to)) + tile_tensors = [] + for tile, crop in zip(tiles_meta, tile_images): + tile["tile_canvas_size"] = [max_tile_width, max_tile_height] + padded_crop = _ttp_pad_image_to_size(crop, max_tile_width, max_tile_height) + tile_tensors.append(pil2tensor(padded_crop)) + + tiles = torch.cat(tile_tensors, dim=0) + preview = pil_image.copy() + draw = ImageDraw.Draw(preview) + colors = ["red", "lime", "cyan", "yellow", "magenta", "orange", "blue", "white"] + for tile in tiles_meta: + color = colors[tile["id"] % len(colors)] + sx, sy, sw, sh = tile["sample_box"] + x, y, w, h = tile["paste_box"] + draw.rectangle((sx, sy, sx + sw, sy + sh), outline=color, width=2) + draw.rectangle((x, y, x + w, y + h), outline="white", width=2) + draw.text((x + 4, y + 4), f'{tile["id"]}:{tile["name"]}', fill=color) + + tile_meta = { + "version": 2, + "type": "ttp_smart_tile", + "original_size": [image_width, image_height], + "tiles": tiles_meta, + } + return tiles, tile_meta, positions, pil2tensor(preview) + + +class TTP_Smart_Tile_Layout_Experimental: + @classmethod + def INPUT_TYPES(cls): + default_layout = json.dumps({ + "defaults": {"pad": 128, "blend": 48, "priority": 50, "importance": 1.0}, + "tiles": [ + {"name": "full_image", "x": 0, "y": 0, "w": 1.0, "h": 1.0, "pad": 0, "blend": 96, "priority": 10, "importance": 0.5} + ] + }, indent=2) + return { + "required": { + "layout_json": ("STRING", {"default": default_layout, "multiline": True}), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("layout_json",) + FUNCTION = "pass_layout" + CATEGORY = "TTP/Smart Tile Experimental" + + def pass_layout(self, layout_json): + return (layout_json,) + + +class TTP_Smart_Tile_Crop_Experimental: + @classmethod + def INPUT_TYPES(cls): + default_layout = json.dumps({ + "defaults": {"pad": 128, "blend": 48, "priority": 50, "importance": 1.0}, + "tiles": [ + {"name": "full_image", "x": 0, "y": 0, "w": 1.0, "h": 1.0, "pad": 0, "blend": 96, "priority": 10, "importance": 0.5}, + {"name": "center_detail", "x": 0.25, "y": 0.25, "w": 0.5, "h": 0.5, "pad": 128, "blend": 48, "priority": 100, "importance": 1.0} + ] + }, indent=2) + return { + "required": { + "image": ("IMAGE",), + "layout_json": ("STRING", {"default": default_layout, "multiline": True}), + "round_to": ("INT", {"default": 8, "min": 1, "max": 128, "step": 1}), + } + } + + RETURN_TYPES = ("IMAGE", "TTP_SMART_TILE_META", "LIST", "IMAGE") + RETURN_NAMES = ("tiles", "tile_meta", "positions", "preview") + FUNCTION = "crop_tiles" + CATEGORY = "TTP/Smart Tile Experimental" + + def crop_tiles(self, image, layout_json, round_to=8): + pil_image = tensor2pil(image[0].unsqueeze(0)).convert("RGB") + image_width, image_height = pil_image.size + tiles_meta = _ttp_parse_smart_tile_layout(layout_json, image_width, image_height) + return _ttp_crop_smart_tiles_from_meta(pil_image, tiles_meta, round_to) + + +class TTP_Smart_Tile_Visual_Crop_Experimental: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "grid_columns": ("INT", {"default": 2, "min": 1, "max": 8, "step": 1}), + "grid_rows": ("INT", {"default": 2, "min": 1, "max": 8, "step": 1}), + "include_full_image": ("BOOLEAN", {"default": True}), + "grid_pad": ("INT", {"default": 128, "min": 0, "max": 1024, "step": 8}), + "grid_blend": ("INT", {"default": 64, "min": 0, "max": 512, "step": 8}), + "grid_priority": ("FLOAT", {"default": 40.0, "min": 0.0, "max": 200.0, "step": 1.0}), + "grid_importance": ("FLOAT", {"default": 0.8, "min": 0.0, "max": 4.0, "step": 0.05}), + "round_to": ("INT", {"default": 8, "min": 1, "max": 128, "step": 1}), + "focus_1_enabled": ("BOOLEAN", {"default": False}), + "focus_1_x": ("FLOAT", {"default": 0.35, "min": 0.0, "max": 1.0, "step": 0.01}), + "focus_1_y": ("FLOAT", {"default": 0.08, "min": 0.0, "max": 1.0, "step": 0.01}), + "focus_1_w": ("FLOAT", {"default": 0.30, "min": 0.01, "max": 1.0, "step": 0.01}), + "focus_1_h": ("FLOAT", {"default": 0.28, "min": 0.01, "max": 1.0, "step": 0.01}), + "focus_2_enabled": ("BOOLEAN", {"default": False}), + "focus_2_x": ("FLOAT", {"default": 0.25, "min": 0.0, "max": 1.0, "step": 0.01}), + "focus_2_y": ("FLOAT", {"default": 0.55, "min": 0.0, "max": 1.0, "step": 0.01}), + "focus_2_w": ("FLOAT", {"default": 0.50, "min": 0.01, "max": 1.0, "step": 0.01}), + "focus_2_h": ("FLOAT", {"default": 0.20, "min": 0.01, "max": 1.0, "step": 0.01}), + "focus_pad": ("INT", {"default": 192, "min": 0, "max": 1024, "step": 8}), + "focus_blend": ("INT", {"default": 64, "min": 0, "max": 512, "step": 8}), + "focus_priority": ("FLOAT", {"default": 110.0, "min": 0.0, "max": 300.0, "step": 1.0}), + "focus_importance": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 4.0, "step": 0.05}), + } + } + + RETURN_TYPES = ("IMAGE", "TTP_SMART_TILE_META", "LIST", "IMAGE") + RETURN_NAMES = ("tiles", "tile_meta", "positions", "preview") + FUNCTION = "visual_crop_tiles" + CATEGORY = "TTP/Smart Tile Experimental" + + def _add_tile_meta(self, raw_tiles, image_width, image_height): + tiles_meta = [] + for index, tile in enumerate(raw_tiles): + normalized = _ttp_normalize_tile_box({**tile, "id": index}, image_width, image_height, {}) + normalized["id"] = index + x, y, w, h = normalized["core_box"] + pad = normalized["pad"] + sample_left = _ttp_clamp(x - pad, 0, image_width) + sample_top = _ttp_clamp(y - pad, 0, image_height) + sample_right = _ttp_clamp(x + w + pad, 0, image_width) + sample_bottom = _ttp_clamp(y + h + pad, 0, image_height) + normalized["sample_box"] = [ + sample_left, + sample_top, + max(1, sample_right - sample_left), + max(1, sample_bottom - sample_top), + ] + normalized["paste_box"] = [x, y, w, h] + tiles_meta.append(normalized) + return tiles_meta + + def visual_crop_tiles( + self, + image, + grid_columns, + grid_rows, + include_full_image, + grid_pad, + grid_blend, + grid_priority, + grid_importance, + round_to, + focus_1_enabled, + focus_1_x, + focus_1_y, + focus_1_w, + focus_1_h, + focus_2_enabled, + focus_2_x, + focus_2_y, + focus_2_w, + focus_2_h, + focus_pad, + focus_blend, + focus_priority, + focus_importance, + ): + pil_image = tensor2pil(image[0].unsqueeze(0)).convert("RGB") + image_width, image_height = pil_image.size + raw_tiles = [] + + if include_full_image: + raw_tiles.append({ + "name": "full_image", + "x": 0.0, + "y": 0.0, + "w": 1.0, + "h": 1.0, + "pad": 0, + "blend": max(grid_blend, focus_blend), + "priority": max(0.0, grid_priority * 0.25), + "importance": max(0.0, grid_importance * 0.5), + }) + + for row in range(grid_rows): + for col in range(grid_columns): + raw_tiles.append({ + "name": f"grid_{row}_{col}", + "x": col / grid_columns, + "y": row / grid_rows, + "w": 1.0 / grid_columns, + "h": 1.0 / grid_rows, + "pad": grid_pad, + "blend": grid_blend, + "priority": grid_priority, + "importance": grid_importance, + }) + + focus_tiles = [ + (focus_1_enabled, "focus_1", focus_1_x, focus_1_y, focus_1_w, focus_1_h), + (focus_2_enabled, "focus_2", focus_2_x, focus_2_y, focus_2_w, focus_2_h), + ] + for enabled, name, x, y, w, h in focus_tiles: + if enabled: + raw_tiles.append({ + "name": name, + "x": x, + "y": y, + "w": w, + "h": h, + "pad": focus_pad, + "blend": focus_blend, + "priority": focus_priority, + "importance": focus_importance, + }) + + tiles_meta = self._add_tile_meta(raw_tiles, image_width, image_height) + return _ttp_crop_smart_tiles_from_meta(pil_image, tiles_meta, round_to) + + +class TTP_Smart_Tile_Assemble_Experimental: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "sampled_tiles": ("IMAGE",), + "tile_meta": ("TTP_SMART_TILE_META", {"forceInput": True}), + "blend_multiplier": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 4.0, "step": 0.05}), + "output_scale": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 16.0, "step": 0.05}), + "use_priority": ("BOOLEAN", {"default": True}), + }, + "optional": { + "base_image": ("IMAGE",), + } + } + + RETURN_TYPES = ("IMAGE", "IMAGE") + RETURN_NAMES = ("image", "weight_preview") + FUNCTION = "assemble_tiles" + CATEGORY = "TTP/Smart Tile Experimental" + + def assemble_tiles(self, sampled_tiles, tile_meta, blend_multiplier=1.0, output_scale=0.0, use_priority=True, base_image=None): + if not isinstance(tile_meta, dict) or tile_meta.get("type") != "ttp_smart_tile": + raise ValueError("tile_meta must come from TTP Smart Tile Crop (Experimental)") + + tiles_info = tile_meta.get("tiles", []) + if len(tiles_info) != sampled_tiles.shape[0]: + raise ValueError(f"sampled_tiles batch ({sampled_tiles.shape[0]}) does not match tile_meta tiles ({len(tiles_info)})") + + original_width, original_height = tile_meta["original_size"] + if output_scale <= 0: + inferred_scales = [] + for index, tile in enumerate(tiles_info): + _, _, sw, sh = tile["sample_box"] + canvas_width, canvas_height = tile.get("tile_canvas_size", [sw, sh]) + canvas_box = tile.get("tile_canvas_box", [0, 0, sw, sh]) + content_width = float(sampled_tiles[index].shape[1]) * (canvas_box[2] / max(1, canvas_width)) + content_height = float(sampled_tiles[index].shape[0]) * (canvas_box[3] / max(1, canvas_height)) + inferred_scales.append(content_width / max(1, sw)) + inferred_scales.append(content_height / max(1, sh)) + output_scale = float(np.median(inferred_scales)) if inferred_scales else 1.0 + output_scale = max(0.01, output_scale) + + output_width = max(1, int(round(original_width * output_scale))) + output_height = max(1, int(round(original_height * output_scale))) + + if base_image is not None: + base_pil = tensor2pil(base_image[0].unsqueeze(0)).convert("RGB") + if base_pil.size != (output_width, output_height): + base_pil = base_pil.resize((output_width, output_height), Image.Resampling.LANCZOS) + canvas = np.array(base_pil).astype(np.float32) / 255.0 + weights = np.ones((output_height, output_width, 1), dtype=np.float32) + else: + canvas = np.zeros((output_height, output_width, 3), dtype=np.float32) + weights = np.zeros((output_height, output_width, 1), dtype=np.float32) + + for index, tile in enumerate(tiles_info): + tile_pil = tensor2pil(sampled_tiles[index].unsqueeze(0)).convert("RGB") + sx, sy, sw, sh = tile["sample_box"] + px, py, pw, ph = tile["paste_box"] + + canvas_width, canvas_height = tile.get("tile_canvas_size", [sw, sh]) + canvas_box = tile.get("tile_canvas_box", [0, 0, sw, sh]) + content_left = int(round(tile_pil.width * canvas_box[0] / max(1, canvas_width))) + content_top = int(round(tile_pil.height * canvas_box[1] / max(1, canvas_height))) + content_width = max(1, int(round(tile_pil.width * canvas_box[2] / max(1, canvas_width)))) + content_height = max(1, int(round(tile_pil.height * canvas_box[3] / max(1, canvas_height)))) + + tile_scale_x = content_width / max(1, sw) + tile_scale_y = content_height / max(1, sh) + local_left = int(round((px - sx) * tile_scale_x)) + local_top = int(round((py - sy) * tile_scale_y)) + local_right = int(round(local_left + pw * tile_scale_x)) + local_bottom = int(round(local_top + ph * tile_scale_y)) + local_left = _ttp_clamp(content_left + local_left, 0, tile_pil.width - 1) + local_top = _ttp_clamp(content_top + local_top, 0, tile_pil.height - 1) + local_right = _ttp_clamp(content_left + local_right, local_left + 1, tile_pil.width) + local_bottom = _ttp_clamp(content_top + local_bottom, local_top + 1, tile_pil.height) + + paste_region = tile_pil.crop((local_left, local_top, local_right, local_bottom)) + out_x = int(round(px * output_scale)) + out_y = int(round(py * output_scale)) + out_w = max(1, int(round(pw * output_scale))) + out_h = max(1, int(round(ph * output_scale))) + if paste_region.size != (out_w, out_h): + paste_region = paste_region.resize((out_w, out_h), Image.Resampling.LANCZOS) + + blend = int(round(tile.get("blend", 0) * output_scale * blend_multiplier)) + mask = _ttp_create_feather_mask(out_w, out_h, blend) + importance = max(0.0, float(tile.get("importance", 1.0))) + priority = max(0.0, float(tile.get("priority", 50.0))) + priority_scale = (1.0 + priority / 100.0) if use_priority else 1.0 + mask = mask * importance * priority_scale + + region = np.array(paste_region).astype(np.float32) / 255.0 + x_end = min(output_width, out_x + out_w) + y_end = min(output_height, out_y + out_h) + region_w = x_end - out_x + region_h = y_end - out_y + if region_w <= 0 or region_h <= 0: + continue + canvas[out_y:y_end, out_x:x_end] += region[:region_h, :region_w] * mask[:region_h, :region_w] + weights[out_y:y_end, out_x:x_end] += mask[:region_h, :region_w] + + safe_weights = np.maximum(weights, 1e-6) + output = canvas / safe_weights + output = np.clip(output, 0.0, 1.0) + weight_preview = np.clip(weights / max(1e-6, weights.max()), 0.0, 1.0) + weight_preview = np.repeat(weight_preview, 3, axis=2) + + return ( + torch.from_numpy(output.astype(np.float32)).unsqueeze(0), + torch.from_numpy(weight_preview.astype(np.float32)).unsqueeze(0), + ) class TTP_CoordinateSplitter: @@ -1108,12 +1626,16 @@ NODE_CLASS_MAPPINGS = { "TTP_condsetarea_merge": TTP_condsetarea_merge, "TTP_Tile_image_size": Tile_imageSize, "TTP_condsetarea_merge_test": TTP_condsetarea_merge_test, - "TTP_Expand_And_Mask": TTP_Expand_And_Mask, - "TTP_text_mix": TTP_text_mix, - "TeaCacheHunyuanVideoSampler": TeaCacheHunyuanVideoSampler -} - -NODE_DISPLAY_NAME_MAPPINGS = { + "TTP_Expand_And_Mask": TTP_Expand_And_Mask, + "TTP_text_mix": TTP_text_mix, + "TeaCacheHunyuanVideoSampler": TeaCacheHunyuanVideoSampler, + "TTP_Smart_Tile_Layout_Experimental": TTP_Smart_Tile_Layout_Experimental, + "TTP_Smart_Tile_Crop_Experimental": TTP_Smart_Tile_Crop_Experimental, + "TTP_Smart_Tile_Visual_Crop_Experimental": TTP_Smart_Tile_Visual_Crop_Experimental, + "TTP_Smart_Tile_Assemble_Experimental": TTP_Smart_Tile_Assemble_Experimental, +} + +NODE_DISPLAY_NAME_MAPPINGS = { "TTPlanet_Tile_Preprocessor_Simple": "TTP Tile Preprocessor Simple", "TTP_Image_Tile_Batch": "TTP_Image_Tile_Batch", "TTP_Image_Assy": "TTP_Image_Assy", @@ -1122,7 +1644,11 @@ NODE_DISPLAY_NAME_MAPPINGS = { "TTP_condsetarea_merge": "TTP_condsetarea_merge", "TTP_Tile_image_size": "TTP_Tile_image_size", "TTP_condsetarea_merge_test": "TTP_condsetarea_merge_test", - "TTP_Expand_And_Mask": "TTP_Expand_And_Mask", - "TTP_text_mix": "TTP_text_mix", - "TeaCacheHunyuanVideoSampler": "TTP_TeaCache HunyuanVideo Sampler" -} + "TTP_Expand_And_Mask": "TTP_Expand_And_Mask", + "TTP_text_mix": "TTP_text_mix", + "TeaCacheHunyuanVideoSampler": "TTP_TeaCache HunyuanVideo Sampler", + "TTP_Smart_Tile_Layout_Experimental": "TTP Smart Tile Layout (Experimental)", + "TTP_Smart_Tile_Crop_Experimental": "TTP Smart Tile Crop (Experimental)", + "TTP_Smart_Tile_Visual_Crop_Experimental": "TTP Smart Tile Visual Crop (Experimental)", + "TTP_Smart_Tile_Assemble_Experimental": "TTP Smart Tile Assemble (Experimental)", +}