v1.0.0: Smart Grid Cutter - universal grid image splitter for ComfyUI

This commit is contained in:
Infinity AI
2026-05-23 22:55:27 +07:00
commit 3f10e5efb0
5 changed files with 555 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
# Python
__pycache__/
*.pyc
*.pyo
.pytest_cache/
# Test outputs
cut_test/
test_output/
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
+63
View File
@@ -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)
+5
View File
@@ -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
+340
View File
@@ -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) ✂️🔗",
}
+130
View File
@@ -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)