diff --git a/README.md b/README.md index 99205ea..641b25e 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,25 @@ This node merges all tiled conditions into one and prepares them for building th ![Condition Merge Node](https://github.com/user-attachments/assets/3039c8a3-8284-4b71-a9de-4120723258c7) +--- +### **7. Manual Tile Nodes** +Use **TTP Smart Tile Batch** to crop arbitrary regions defined by a list of bounding boxes. After processing the tiles with any other ComfyUI nodes, **TTP Smart Image Assy** pastes them back in place. + +Bounding boxes should be provided as `[x1, y1, x2, y2]` coordinates. + +See `examples/smart_tile_example.json` for a minimal workflow using these nodes. + +You can also draw bounding boxes interactively using `manual_crop_tool.py`: + +```bash +python manual_crop_tool.py input.jpg output_dir --tile-size 512 +``` + +Draw rectangles on the displayed image. Press `q` when finished. The script saves +all tiles to `output_dir` and writes `boxes.json` containing the coordinates. +Large selections are automatically split into smaller tiles using the specified +tile size. + --- ## **Examples** @@ -116,6 +135,9 @@ This node merges all tiled conditions into one and prepares them for building th ### **Latent Example** ![Latent Example Workflow](https://github.com/TTPlanetPig/Comfyui_TTP_Toolset/blob/main/examples/Flux_8Mega_Pixel_image_upscale_process.png) +### **Smart Tile Example** +`examples/smart_tile_example.json` demonstrates manual tiling and recombination. + --- diff --git a/TTP_smart_tile.py b/TTP_smart_tile.py new file mode 100644 index 0000000..8b04ac6 --- /dev/null +++ b/TTP_smart_tile.py @@ -0,0 +1,85 @@ +import torch +from PIL import Image +from typing import List, Tuple + +from .TTP_toolsets import pil2tensor, tensor2pil + + +class TTP_Smart_Tile_Batch: + """Manually crop image regions based on provided bounding boxes.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "boxes": ("LIST",), + } + } + + RETURN_TYPES = ("IMAGE", "LIST") + RETURN_NAMES = ("tiles", "positions") + FUNCTION = "tile_image" + CATEGORY = "TTP/Image" + + def tile_image(self, image, boxes: List[Tuple[int, int, int, int]]): + pil_img = tensor2pil(image.squeeze(0)) + + tiles = [] + positions = [] + max_w = 0 + max_h = 0 + for box in boxes: + if len(box) != 4: + raise ValueError(f"Each box must contain 4 values, got {box}") + x1, y1, x2, y2 = map(int, box) + crop = pil_img.crop((x1, y1, x2, y2)) + tile_tensor = pil2tensor(crop) + _, h, w, _ = tile_tensor.shape + max_h = max(max_h, h) + max_w = max(max_w, w) + tiles.append(tile_tensor) + positions.append((x1, y1, x2, y2)) + + padded = [] + for tile in tiles: + _, h, w, _ = tile.shape + pad_w = max_w - w + pad_h = max_h - h + if pad_w or pad_h: + tile_chw = tile.permute(0, 3, 1, 2) + tile_chw = torch.nn.functional.pad(tile_chw, (0, pad_w, 0, pad_h)) + tile = tile_chw.permute(0, 2, 3, 1) + padded.append(tile) + + tiles_tensor = torch.cat(padded, dim=0) if padded else torch.empty((0, max_h, max_w, 3)) + return tiles_tensor, positions + + +class TTP_Smart_Image_Assy: + """Reassemble manually cropped tiles back onto the base image.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "base_image": ("IMAGE",), + "tiles": ("IMAGE", {"forceInput": True}), + "positions": ("LIST",), + } + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("image",) + FUNCTION = "assemble_image" + CATEGORY = "TTP/Image" + + def assemble_image(self, base_image, tiles, positions): + canvas = tensor2pil(base_image.squeeze(0)).copy() + for idx, tile in enumerate(tiles): + x1, y1, x2, y2 = positions[idx] + width = x2 - x1 + height = y2 - y1 + tile_img = tensor2pil(tile.unsqueeze(0)).crop((0, 0, width, height)) + canvas.paste(tile_img, (x1, y1, x2, y2)) + return pil2tensor(canvas) diff --git a/__init__.py b/__init__.py index 12efac0..5af0929 100644 --- a/__init__.py +++ b/__init__.py @@ -1,3 +1,16 @@ -from .TTP_toolsets import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS - -__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] \ No newline at end of file +from .TTP_toolsets import NODE_CLASS_MAPPINGS as TOOLSET_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as TOOLSET_DISPLAY +from .TTP_smart_tile import TTP_Smart_Tile_Batch, TTP_Smart_Image_Assy + +NODE_CLASS_MAPPINGS = { + **TOOLSET_MAPPINGS, + "TTP_Smart_Tile_Batch": TTP_Smart_Tile_Batch, + "TTP_Smart_Image_Assy": TTP_Smart_Image_Assy, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + **TOOLSET_DISPLAY, + "TTP_Smart_Tile_Batch": "TTP Smart Tile Batch", + "TTP_Smart_Image_Assy": "TTP Smart Image Assy", +} + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/examples/smart_tile_example.json b/examples/smart_tile_example.json new file mode 100644 index 0000000..c47bd64 --- /dev/null +++ b/examples/smart_tile_example.json @@ -0,0 +1,32 @@ +{ + "last_node_id": 4, + "last_link_id": 4, + "nodes": [ + { + "id": 1, + "type": "ImageInput", + "pos": [0, 0], + "size": [210, 70], + "widgets_values": ["input.png"] + }, + { + "id": 2, + "type": "TTP_Smart_Tile_Batch", + "pos": [250, 0], + "size": [250, 120], + "widgets_values": [[ [10, 10, 110, 110], [130, 130, 220, 220] ]] + }, + { + "id": 3, + "type": "TTP_Smart_Image_Assy", + "pos": [500, 0], + "size": [250, 120], + "widgets_values": [] + } + ], + "links": [ + [1, 0, 2, 0], + [2, 0, 3, 1], + [2, 1, 3, 2] + ] +} diff --git a/manual_crop_tool.py b/manual_crop_tool.py new file mode 100644 index 0000000..0b8bb22 --- /dev/null +++ b/manual_crop_tool.py @@ -0,0 +1,84 @@ +import argparse +import json +import os +from PIL import Image +import matplotlib.pyplot as plt +from matplotlib.widgets import RectangleSelector + + +def split_box(box, tile_w, tile_h): + x1, y1, x2, y2 = box + boxes = [] + for top in range(y1, y2, tile_h): + for left in range(x1, x2, tile_w): + boxes.append([ + left, + top, + min(left + tile_w, x2), + min(top + tile_h, y2), + ]) + return boxes + + +def crop_tiles(img, boxes, out_dir): + tiles = [] + for idx, b in enumerate(boxes): + tile = img.crop(b) + tiles.append(tile) + tile.save(os.path.join(out_dir, f"tile_{idx}.png")) + return tiles + + +def main(): + parser = argparse.ArgumentParser(description="Draw rectangles on an image and export tiles") + parser.add_argument("image", help="Path to image") + parser.add_argument("output", help="Directory to save tiles") + parser.add_argument("--tile-size", type=int, default=512, help="Max tile size when splitting selections") + args = parser.parse_args() + + os.makedirs(args.output, exist_ok=True) + img = Image.open(args.image).convert("RGB") + + fig, ax = plt.subplots() + ax.imshow(img) + ax.set_title("Drag rectangles, press 'q' when done") + + boxes = [] + + def onselect(eclick, erelease): + x1, y1 = int(eclick.xdata), int(eclick.ydata) + x2, y2 = int(erelease.xdata), int(erelease.ydata) + if x2 < x1: + x1, x2 = x2, x1 + if y2 < y1: + y1, y2 = y2, y1 + ax.add_patch(plt.Rectangle((x1, y1), x2 - x1, y2 - y1, fill=False, edgecolor='red')) + boxes.append([x1, y1, x2, y2]) + fig.canvas.draw() + + toggle_selector = RectangleSelector(ax, onselect, drawtype="box", useblit=True) + + def on_key(event): + if event.key == 'q': + plt.close(event.canvas.figure) + fig.canvas.mpl_connect('key_press_event', on_key) + plt.show() + + final_boxes = [] + for b in boxes: + width = b[2] - b[0] + height = b[3] - b[1] + if width > args.tile_size or height > args.tile_size: + final_boxes.extend(split_box(b, args.tile_size, args.tile_size)) + else: + final_boxes.append(b) + + with open(os.path.join(args.output, "boxes.json"), "w", encoding="utf-8") as f: + json.dump(final_boxes, f) + + crop_tiles(img, final_boxes, args.output) + print(f"Saved {len(final_boxes)} tiles to {args.output}") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 64d3289..18410a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,9 @@ [project] name = "comfyui_ttp_toolset" description = "This is a workflow for my simple logic amazing upscale node for DIT model. it can be common use for Flux,Hunyuan,SD3 It can simple tile the initial image into pieces and then use image-interrogator to get each tile prompts for more accurate upscale process. The condition will be properly handled and the hallucination will be significantly eliminated." -version = "1.0.5" +version = "1.0.7" license = {file = "LICENSE"} +dependencies = [] [project.urls] Repository = "https://github.com/TTPlanetPig/Comfyui_TTP_Toolset" @@ -12,3 +13,12 @@ Repository = "https://github.com/TTPlanetPig/Comfyui_TTP_Toolset" PublisherId = "ttplanet" DisplayName = "Comfyui_TTP_Toolset" Icon = "🪐" + +[build-system] +requires = ["setuptools>=66", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["Comfyui_TTP_Toolset"] +[tool.setuptools.package-dir] +Comfyui_TTP_Toolset = "."