From 3f10e5efb01a610f8810d4c59f19eb99cc86b9c6 Mon Sep 17 00:00:00 2001 From: Infinity AI Date: Sat, 23 May 2026 22:55:27 +0700 Subject: [PATCH] v1.0.0: Smart Grid Cutter - universal grid image splitter for ComfyUI --- .gitignore | 17 +++ README.md | 63 ++++++++++ __init__.py | 5 + nodes.py | 340 ++++++++++++++++++++++++++++++++++++++++++++++++++ test_local.py | 130 +++++++++++++++++++ 5 files changed, 555 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py create mode 100644 nodes.py create mode 100644 test_local.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..86774da --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Python +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ + +# Test outputs +cut_test/ +test_output/ + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..6e61974 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# ComfyUI-SmartGridCutter โœ‚๏ธ + +Universal grid image splitter for ComfyUI. + +Automatically detects grid layout by analyzing brightness/contrast and splits any grid image into individual frames. + +## Features + +- ๐Ÿ” **Smart Detection** โ€” Automatically finds grid dividers by brightness analysis +- ๐Ÿ“ **Preset Modes** โ€” 3ร—3, 4ร—2, 3ร—2, 2ร—2, or custom +- ๐ŸŽฏ **Auto Fallback** โ€” If smart detection fails, falls back to aspect ratio heuristics +- ๐Ÿ”— **URL Support** โ€” Download and cut images directly from URL (for API/n8n integration) +- ๐Ÿ“ **Pixel Alignment** โ€” Aligns output dimensions to 16px for video compatibility +- โœ‚๏ธ **Edge Padding** โ€” Configurable trim to remove divider artifacts + +## Nodes + +### Smart Grid Cutter โœ‚๏ธ +Takes an IMAGE input and splits it into up to 9 individual frames. + +| Input | Type | Description | +|-------|------|-------------| +| image | IMAGE | Grid image to split | +| mode | ENUM | auto / 3x3 / 4x2 / 3x2 / 2x2 / custom | +| custom_cols | INT | Columns (for custom mode) | +| custom_rows | INT | Rows (for custom mode) | +| divider_threshold | FLOAT | Detection sensitivity (0.01-0.5) | +| min_divider_width | INT | Minimum divider width in px | +| padding | INT | Pixels to trim from edges | +| align_to | INT | Dimension alignment (16 for video) | + +### Smart Grid Cutter (URL) โœ‚๏ธ๐Ÿ”— +Downloads image from URL and splits it. Same parameters as above, but takes `image_url` string instead of IMAGE tensor. + +## Output +- `frame_0` through `frame_8` โ€” Individual frames as IMAGE tensors +- `cols`, `rows` โ€” Detected/preset grid dimensions +- `total_frames` โ€” Actual number of frames +- `grid_info` โ€” Human-readable detection summary + +## Installation + +### ComfyUI Manager +Search for "SmartGridCutter" in ComfyUI Manager. + +### Manual +```bash +cd ComfyUI/custom_nodes/ +git clone https://github.com/imind-studio/ComfyUI-SmartGridCutter.git +# Restart ComfyUI +``` + +## Use Cases +- **Storyboard cutting** โ€” Split 3ร—3 AI-generated storyboards into individual scenes +- **Character sheets** โ€” Split 4ร—2 character reference sheets into individual poses +- **Contact sheets** โ€” Split any contact/proof sheet into individual images +- **Comic panels** โ€” Split comic pages into individual panels + +## License +MIT + +## Author +iMind Studio โ€” [imind.pw](https://imind.pw) diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..c7437eb --- /dev/null +++ b/__init__.py @@ -0,0 +1,5 @@ +from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] + +WEB_DIRECTORY = None diff --git a/nodes.py b/nodes.py new file mode 100644 index 0000000..dfaeee4 --- /dev/null +++ b/nodes.py @@ -0,0 +1,340 @@ +""" +ComfyUI-SmartGridCutter โ€” Universal Grid Image Splitter + +Automatically detects grid layout by analyzing contrast/brightness +and splits any grid image into individual frames. + +Works with any grid: 3ร—3 storyboards, 4ร—2 character sheets, +2ร—3 panels, or any arbitrary grid layout. + +Author: iMind Studio +License: MIT +""" + +import torch +import numpy as np +from PIL import Image +import io +import urllib.request + + +class SmartGridCutter: + """ + Splits a grid image into individual cells by detecting + divider lines through brightness/contrast analysis. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "mode": (["auto", "3x3", "4x2", "3x2", "2x2", "custom"],), + }, + "optional": { + "custom_cols": ("INT", {"default": 3, "min": 1, "max": 10, "step": 1}), + "custom_rows": ("INT", {"default": 3, "min": 1, "max": 10, "step": 1}), + "divider_threshold": ("FLOAT", {"default": 0.15, "min": 0.01, "max": 0.5, "step": 0.01, + "tooltip": "Brightness difference threshold for detecting divider lines. Lower = more sensitive."}), + "min_divider_width": ("INT", {"default": 2, "min": 1, "max": 20, "step": 1, + "tooltip": "Minimum width in pixels for a line to be considered a divider."}), + "padding": ("INT", {"default": 2, "min": 0, "max": 20, "step": 1, + "tooltip": "Pixels to trim from each cell edge after cutting."}), + "align_to": ("INT", {"default": 16, "min": 1, "max": 64, "step": 1, + "tooltip": "Align cell dimensions to this value (16 for video compatibility)."}), + } + } + + RETURN_TYPES = ("IMAGE", "IMAGE", "IMAGE", "IMAGE", "IMAGE", + "IMAGE", "IMAGE", "IMAGE", "IMAGE", + "INT", "INT", "INT", "STRING") + RETURN_NAMES = ("frame_0", "frame_1", "frame_2", "frame_3", "frame_4", + "frame_5", "frame_6", "frame_7", "frame_8", + "cols", "rows", "total_frames", "grid_info") + + FUNCTION = "cut_grid" + CATEGORY = "image/transform" + DESCRIPTION = "Splits a grid image into individual frames by detecting divider lines. Works with any grid layout." + + def _find_dividers(self, gray_array, axis, threshold, min_width, margin_pct=0.05): + """ + Find divider positions along an axis using variance + contrast analysis. + + Dividers are detected by TWO criteria: + 1. Low variance (uniform color โ€” solid white/black/gray line) + 2. Contrast with neighbors (different brightness from surrounding cells) + + Args: + gray_array: 2D numpy array (grayscale, 0-1 float) + axis: 0 for horizontal dividers (scanning rows), 1 for vertical (scanning cols) + threshold: NOT USED directly anymore, kept for API compat + min_width: minimum pixel width for a valid divider + margin_pct: ignore edges (percentage of total size) + """ + h, w = gray_array.shape + + if axis == 1: # vertical dividers (scan columns) + size = w + # Per-column stats + profile_mean = np.mean(gray_array[::2, :], axis=0) + profile_std = np.std(gray_array[::2, :], axis=0) + else: # horizontal dividers (scan rows) + size = h + profile_mean = np.mean(gray_array[:, ::2], axis=1) + profile_std = np.std(gray_array[:, ::2], axis=1) + + # Dividers have LOW std (uniform color across the perpendicular axis) + # Content cells have HIGH std (varied pixel values) + median_std = np.median(profile_std) + # A divider line's std should be much lower than typical content + std_threshold = max(median_std * 0.3, 0.01) + + margin = int(size * margin_pct) + dividers = [] + in_divider = False + start = 0 + + for i in range(margin, size - margin): + is_uniform = profile_std[i] < std_threshold + + if is_uniform: + if not in_divider: + start = i + in_divider = True + else: + if in_divider: + width = i - start + if width >= min_width: + # Verify it's actually a divider by checking contrast with neighbors + div_brightness = np.mean(profile_mean[start:i]) + # Check brightness of cells on both sides + left_zone = max(0, start - min(30, start)) + right_zone = min(size, i + 30) + neighbor_brightness = np.mean( + np.concatenate([ + profile_mean[left_zone:start], + profile_mean[i:right_zone] + ]) + ) if (start > left_zone or i < right_zone) else div_brightness + + contrast = abs(div_brightness - neighbor_brightness) + if contrast > 0.05: # Must have SOME contrast with neighbors + dividers.append({ + "start": start, + "end": i, + "mid": (start + i) // 2, + "width": width, + }) + in_divider = False + + return dividers + + def _build_grid_from_dividers(self, x_dividers, y_dividers, img_w, img_h): + """Build cell boundaries from detected dividers.""" + cols = len(x_dividers) + 1 + rows = len(y_dividers) + 1 + + # X boundaries: [0, end_of_div1, end_of_div2, ..., img_w] + x_starts = [0] + [d["end"] for d in x_dividers] + x_ends = [d["start"] for d in x_dividers] + [img_w] + + y_starts = [0] + [d["end"] for d in y_dividers] + y_ends = [d["start"] for d in y_dividers] + [img_h] + + return cols, rows, x_starts, x_ends, y_starts, y_ends + + def _build_grid_from_preset(self, cols, rows, img_w, img_h): + """Build cell boundaries using simple division.""" + cell_w = img_w // cols + cell_h = img_h // rows + + x_starts = [i * cell_w for i in range(cols)] + x_ends = [(i + 1) * cell_w for i in range(cols)] + y_starts = [i * cell_h for i in range(rows)] + y_ends = [(i + 1) * cell_h for i in range(rows)] + + return x_starts, x_ends, y_starts, y_ends + + def _tensor_to_pil(self, tensor): + """Convert ComfyUI IMAGE tensor to PIL Image.""" + # ComfyUI images: [B, H, W, C], float32, 0-1 + if tensor.dim() == 4: + tensor = tensor[0] # Take first batch + arr = (tensor.cpu().numpy() * 255).clip(0, 255).astype(np.uint8) + return Image.fromarray(arr) + + def _pil_to_tensor(self, pil_img): + """Convert PIL Image to ComfyUI IMAGE tensor.""" + arr = np.array(pil_img).astype(np.float32) / 255.0 + if arr.ndim == 2: + arr = np.stack([arr] * 3, axis=-1) + return torch.from_numpy(arr).unsqueeze(0) # [1, H, W, C] + + def cut_grid(self, image, mode, custom_cols=3, custom_rows=3, + divider_threshold=0.15, min_divider_width=2, + padding=2, align_to=16): + """Main cutting function.""" + + pil_img = self._tensor_to_pil(image) + img_w, img_h = pil_img.size + + # Convert to grayscale for analysis + gray = np.array(pil_img.convert("L")).astype(np.float32) / 255.0 + + # Determine grid layout + presets = { + "3x3": (3, 3), + "4x2": (4, 2), + "3x2": (3, 2), + "2x2": (2, 2), + "custom": (custom_cols, custom_rows), + } + + if mode == "auto": + # Smart detection + x_dividers = self._find_dividers(gray, axis=1, + threshold=divider_threshold, min_width=min_divider_width) + y_dividers = self._find_dividers(gray, axis=0, + threshold=divider_threshold, min_width=min_divider_width) + + cols, rows, x_starts, x_ends, y_starts, y_ends = \ + self._build_grid_from_dividers(x_dividers, y_dividers, img_w, img_h) + + detect_method = "auto_detected" + + # Sanity check: if detection gives weird results (1ร—1 or 20ร—20), fallback + if cols < 2 and rows < 2: + # Try preset-based detection using aspect ratio + ar = img_w / img_h + if 0.9 <= ar <= 1.1: + cols, rows = 3, 3 # Square โ†’ likely 3ร—3 + elif ar > 1.4: + cols, rows = 4, 2 # Wide โ†’ likely 4ร—2 + else: + cols, rows = 3, 2 # Portrait-ish โ†’ likely 3ร—2 + + x_starts, x_ends, y_starts, y_ends = \ + self._build_grid_from_preset(cols, rows, img_w, img_h) + detect_method = "aspect_ratio_fallback" + + else: + cols, rows = presets[mode] + # Try smart detection first, validate against preset + x_dividers = self._find_dividers(gray, axis=1, + threshold=divider_threshold, min_width=min_divider_width) + y_dividers = self._find_dividers(gray, axis=0, + threshold=divider_threshold, min_width=min_divider_width) + + detected_cols = len(x_dividers) + 1 + detected_rows = len(y_dividers) + 1 + + if detected_cols == cols and detected_rows == rows: + # Smart detection matches preset โ€” use smart bounds + _, _, x_starts, x_ends, y_starts, y_ends = \ + self._build_grid_from_dividers(x_dividers, y_dividers, img_w, img_h) + detect_method = "smart_verified" + else: + # Fallback to simple division + x_starts, x_ends, y_starts, y_ends = \ + self._build_grid_from_preset(cols, rows, img_w, img_h) + detect_method = f"preset_fallback (detected {detected_cols}x{detected_rows})" + + # Cut frames + frames = [] + for r in range(rows): + for c in range(cols): + left = x_starts[c] + padding + top = y_starts[r] + padding + right = x_ends[c] - padding + bottom = y_ends[r] - padding + + w = right - left + h = bottom - top + + # Align dimensions + if align_to > 1: + w = (w // align_to) * align_to + h = (h // align_to) * align_to + + cell = pil_img.crop((left, top, left + w, top + h)) + frames.append(self._pil_to_tensor(cell)) + + total = len(frames) + + # Build info string + info = (f"Grid: {cols}ร—{rows} = {total} frames | " + f"Source: {img_w}ร—{img_h} | " + f"Cell: {frames[0].shape[2]}ร—{frames[0].shape[1]} | " + f"Detection: {detect_method}") + + # Pad to 9 outputs (max supported) + while len(frames) < 9: + # Create a 1ร—1 black tensor as placeholder + frames.append(torch.zeros(1, 16, 16, 3)) + + return (*frames[:9], cols, rows, total, info) + + +class SmartGridCutterURL: + """ + Downloads an image from URL and splits it into grid cells. + Useful for n8n / API integration. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image_url": ("STRING", {"default": "", "multiline": False}), + "mode": (["auto", "3x3", "4x2", "3x2", "2x2", "custom"],), + }, + "optional": { + "custom_cols": ("INT", {"default": 3, "min": 1, "max": 10}), + "custom_rows": ("INT", {"default": 3, "min": 1, "max": 10}), + "divider_threshold": ("FLOAT", {"default": 0.15, "min": 0.01, "max": 0.5, "step": 0.01}), + "padding": ("INT", {"default": 2, "min": 0, "max": 20}), + "align_to": ("INT", {"default": 16, "min": 1, "max": 64}), + } + } + + RETURN_TYPES = ("IMAGE", "IMAGE", "IMAGE", "IMAGE", "IMAGE", + "IMAGE", "IMAGE", "IMAGE", "IMAGE", + "INT", "INT", "INT", "STRING") + RETURN_NAMES = ("frame_0", "frame_1", "frame_2", "frame_3", "frame_4", + "frame_5", "frame_6", "frame_7", "frame_8", + "cols", "rows", "total_frames", "grid_info") + + FUNCTION = "cut_from_url" + CATEGORY = "image/transform" + DESCRIPTION = "Downloads image from URL and splits it into grid frames." + + def cut_from_url(self, image_url, mode, custom_cols=3, custom_rows=3, + divider_threshold=0.15, padding=2, align_to=16): + """Download image from URL and cut it.""" + # Download image + req = urllib.request.Request(image_url, headers={"User-Agent": "ComfyUI-SmartGridCutter/1.0"}) + with urllib.request.urlopen(req, timeout=30) as response: + img_data = response.read() + + pil_img = Image.open(io.BytesIO(img_data)).convert("RGB") + + # Convert to tensor + arr = np.array(pil_img).astype(np.float32) / 255.0 + tensor = torch.from_numpy(arr).unsqueeze(0) + + # Use the main cutter + cutter = SmartGridCutter() + return cutter.cut_grid(tensor, mode, custom_cols, custom_rows, + divider_threshold, 2, padding, align_to) + + +# โ”€โ”€ Registration โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +NODE_CLASS_MAPPINGS = { + "SmartGridCutter": SmartGridCutter, + "SmartGridCutterURL": SmartGridCutterURL, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "SmartGridCutter": "Smart Grid Cutter โœ‚๏ธ", + "SmartGridCutterURL": "Smart Grid Cutter (URL) โœ‚๏ธ๐Ÿ”—", +} diff --git a/test_local.py b/test_local.py new file mode 100644 index 0000000..0621446 --- /dev/null +++ b/test_local.py @@ -0,0 +1,130 @@ +""" +Local test for SmartGridCutter node logic โ€” runs without ComfyUI. +Uses PIL (Pillow) and numpy only. +""" +import sys +sys.path.insert(0, '.') + +import numpy as np +from PIL import Image +import torch +import os + +# Import our node +from nodes import SmartGridCutter + +def test_with_real_image(): + """Test with actual storyboard image.""" + img_path = os.path.join('..', 'test_videos', 'cf_pipeline', 'storyboard.png') + + if not os.path.exists(img_path): + print(f"โŒ Test image not found: {img_path}") + return False + + print(f"๐Ÿ“ท Loading: {img_path}") + pil_img = Image.open(img_path).convert('RGB') + print(f" Size: {pil_img.size[0]}ร—{pil_img.size[1]}") + + # Convert to ComfyUI tensor format [B, H, W, C] + arr = np.array(pil_img).astype(np.float32) / 255.0 + tensor = torch.from_numpy(arr).unsqueeze(0) + print(f" Tensor: {tensor.shape}") + + # Test auto mode + cutter = SmartGridCutter() + result = cutter.cut_grid(tensor, mode="auto") + + # Unpack results + *frames, cols, rows, total, info = result + + print(f"\nโœ… ะ ะ•ะ—ะฃะ›ะฌะขะะข:") + print(f" {info}") + print(f" Grid: {cols}ร—{rows} = {total} frames") + + # Show frame sizes + for i in range(total): + f = frames[i] + if f.shape[1] > 16: # Not a placeholder + print(f" Frame {i}: {f.shape[2]}ร—{f.shape[1]}px ({f.shape})") + + # Save frames for visual inspection + out_dir = os.path.join('..', 'test_videos', 'cf_pipeline', 'cut_test') + os.makedirs(out_dir, exist_ok=True) + + for i in range(total): + f = frames[i] + if f.shape[1] > 16: + f_arr = (f[0].numpy() * 255).clip(0, 255).astype(np.uint8) + f_img = Image.fromarray(f_arr) + path = os.path.join(out_dir, f'frame_{i:02d}.png') + f_img.save(path) + + print(f"\n ๐Ÿ’พ Saved {total} frames to {out_dir}/") + + # Test 3x3 preset mode + print(f"\n๐Ÿ”ง Testing 3x3 preset mode...") + result2 = cutter.cut_grid(tensor, mode="3x3") + *frames2, cols2, rows2, total2, info2 = result2 + print(f" {info2}") + + return True + + +def test_with_synthetic(): + """Test with a synthetic grid image.""" + print("๐Ÿงช Testing with synthetic 3ร—3 grid (900ร—900)...") + + # Create a 3ร—3 grid with white dividers + cell_size = 290 + divider = 10 + total = cell_size * 3 + divider * 2 # 890 + + img = np.zeros((total, total, 3), dtype=np.uint8) + + colors = [ + (255, 0, 0), (0, 255, 0), (0, 0, 255), + (255, 255, 0), (255, 0, 255), (0, 255, 255), + (128, 0, 0), (0, 128, 0), (0, 0, 128), + ] + + for r in range(3): + for c in range(3): + y = r * (cell_size + divider) + x = c * (cell_size + divider) + idx = r * 3 + c + img[y:y+cell_size, x:x+cell_size] = colors[idx] + + # White dividers + for i in range(2): + pos = (i + 1) * cell_size + i * divider + img[pos:pos+divider, :] = 255 # Horizontal + img[:, pos:pos+divider] = 255 # Vertical + + tensor = torch.from_numpy(img.astype(np.float32) / 255.0).unsqueeze(0) + + cutter = SmartGridCutter() + result = cutter.cut_grid(tensor, mode="auto") + *frames, cols, rows, total_frames, info = result + + print(f" {info}") + assert cols == 3, f"Expected 3 cols, got {cols}" + assert rows == 3, f"Expected 3 rows, got {rows}" + print(" โœ… Synthetic test PASSED") + return True + + +if __name__ == "__main__": + print("=" * 60) + print(" SmartGridCutter โ€” Local Test Suite") + print("=" * 60) + + ok1 = test_with_synthetic() + print() + ok2 = test_with_real_image() + + print("\n" + "=" * 60) + if ok1 and ok2: + print(" โœ… ALL TESTS PASSED") + else: + print(" โš ๏ธ Some tests failed") + print("=" * 60)