mask_cells was in the required inputs, so any API graph that omitted the key
failed validation ("Required input is missing") — a default only pre-fills the
UI, it isn't supplied for headless/API graphs. Move it to optional so graphs
without mask_cells validate and fall back to the documented no-op (denoise all).
No behaviour change for graphs that do set it. Verified via /object_info:
mask_cells now under optional, mask output intact.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
321 lines
14 KiB
Python
321 lines
14 KiB
Python
"""
|
|
ComfyUI-GridSplit nodes.
|
|
|
|
AutoGridSplit — detect the seams in a stitched grid/carousel/collage and split
|
|
it into its individual panels.
|
|
GridStitch — the inverse: repeat one image into an R x C grid, scaled to a
|
|
target total megapixels (no distortion; cell aspect = image aspect).
|
|
GridStitchAdvanced — stitch many images into an R x C grid, and optionally emit a
|
|
per-cell MASK (which cells denoise vs stay preserved) for masked
|
|
latent workflows (SetLatentNoiseMask / inpaint / partial denoise).
|
|
|
|
No model, CPU, any resolution.
|
|
"""
|
|
import torch
|
|
import comfy.utils
|
|
from .gridsplit import find_panels, slice_boxes, draw_preview
|
|
|
|
|
|
class AutoGridSplit:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
"sensitivity": ("FLOAT", {"default": 1.0, "min": 1.0, "max": 10.0, "step": 0.5,
|
|
"tooltip": "Splitting aggressiveness. 1 = standard (recommended). Higher = split more eagerly / catch weaker seams."}),
|
|
"min_panel_px": ("INT", {"default": 64, "min": 8, "max": 8192,
|
|
"tooltip": "Reject any split that would make a panel smaller than this."}),
|
|
"edge_trim": ("INT", {"default": 0, "min": 0, "max": 256,
|
|
"tooltip": "Shave N px off each interior seam edge (kills blend/halo/leftover gutter)."}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "INT", "IMAGE")
|
|
RETURN_NAMES = ("panels", "count", "preview")
|
|
OUTPUT_IS_LIST = (True, False, False) # panels is a list (tiles differ in size); count/preview are scalars
|
|
FUNCTION = "split"
|
|
CATEGORY = "image/grid"
|
|
|
|
def split(self, image, sensitivity, min_panel_px, edge_trim):
|
|
panels_out = []
|
|
preview = image[0:1]
|
|
for b in range(image.shape[0]):
|
|
img = image[b] # [H,W,C]
|
|
boxes = find_panels(img, sensitivity=sensitivity, min_panel=min_panel_px)
|
|
tiles = slice_boxes(img, boxes, edge_trim)
|
|
panels_out.extend(t.unsqueeze(0) for t in tiles)
|
|
if b == 0:
|
|
preview = draw_preview(img, boxes).unsqueeze(0)
|
|
|
|
if not panels_out: # nothing detected -> pass the image through unchanged
|
|
panels_out = [image[0:1]]
|
|
return (panels_out, len(panels_out), preview)
|
|
|
|
|
|
class GridStitch:
|
|
"""Single image -> R x C grid, same engine as the advanced node
|
|
(ImageStitch-style), scaled to a target megapixels. The one image fills
|
|
every cell. It's the multi-image node with a single input, for quick
|
|
single-image grids."""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
"rows": ("INT", {"default": 2, "min": 1, "max": 4}),
|
|
"cols": ("INT", {"default": 2, "min": 1, "max": 4}),
|
|
"megapixels": ("FLOAT", {"default": 4.0, "min": 0.1, "max": 100.0, "step": 0.1,
|
|
"tooltip": "Target TOTAL size of the stitched grid, in megapixels."}),
|
|
"scale_method": (["lanczos", "bicubic", "area", "bilinear", "nearest-exact"],
|
|
{"default": "lanczos"}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "INT", "INT")
|
|
RETURN_NAMES = ("grid", "width", "height")
|
|
FUNCTION = "stitch"
|
|
CATEGORY = "image/grid"
|
|
|
|
def stitch(self, image, rows, cols, megapixels, scale_method):
|
|
cell = _to_chw(image) # one image -> every cell
|
|
cells = [cell] * (rows * cols)
|
|
grid, _boxes = stitch_imagestitch_style(cells, rows, cols, scale_method)
|
|
gh, gw = grid.shape[1], grid.shape[2]
|
|
scale = (max(1.0, megapixels * 1_000_000.0) / (gh * gw)) ** 0.5
|
|
out_h, out_w = max(1, round(gh * scale)), max(1, round(gw * scale))
|
|
grid = comfy.utils.common_upscale(grid.unsqueeze(0), out_w, out_h, scale_method, "disabled")[0]
|
|
out = grid.movedim(0, -1).unsqueeze(0).clamp(0.0, 1.0)
|
|
return (out, out_w, out_h)
|
|
|
|
|
|
def _to_chw(img):
|
|
"""[B,H,W,C] or [H,W,C] -> [C,H,W]."""
|
|
if img.dim() == 4:
|
|
img = img[0]
|
|
return img.movedim(-1, 0)
|
|
|
|
|
|
def _match_channels(pieces):
|
|
c = max(p.shape[0] for p in pieces)
|
|
out = []
|
|
for p in pieces:
|
|
if p.shape[0] < c: # pad missing channels with 1.0 (like ImageStitch)
|
|
pad = torch.ones(c - p.shape[0], p.shape[1], p.shape[2], dtype=p.dtype, device=p.device)
|
|
p = torch.cat([p, pad], dim=0)
|
|
out.append(p)
|
|
return out
|
|
|
|
|
|
def stitch_imagestitch_style(cells, rows, cols, scale_method="lanczos", empty_color=0.0):
|
|
"""Replicates ComfyUI ImageStitch (match_image_size=True) over an R x C grid:
|
|
per row, resize each image to the row's first-image HEIGHT (aspect-preserved);
|
|
then resize each row strip to the first row's WIDTH. No padding -> clean rectangle.
|
|
cells: row-major list (len rows*cols) of [C,H,W] tensors or None.
|
|
Returns (grid [C,H,W], boxes) where boxes is a row-major list of
|
|
(y0, y1, x0, x1) cell rectangles in the returned grid's pixel space."""
|
|
def resize(chw, h, w):
|
|
return comfy.utils.common_upscale(chw.unsqueeze(0), w, h, scale_method, "disabled")[0]
|
|
|
|
row_strips = []
|
|
row_widths = [] # per-row list of the pre-normalization cell widths
|
|
for r in range(rows):
|
|
rc = cells[r * cols:(r + 1) * cols]
|
|
anchor_h = next((c.shape[1] for c in rc if c is not None), 512)
|
|
pieces, widths = [], []
|
|
for c in rc:
|
|
if c is None: # empty cell -> square placeholder at row height
|
|
pieces.append(torch.full((3, anchor_h, anchor_h), float(empty_color)))
|
|
widths.append(anchor_h)
|
|
else:
|
|
h, w = c.shape[1], c.shape[2]
|
|
pw = max(1, round(w * anchor_h / h))
|
|
pieces.append(resize(c, anchor_h, pw))
|
|
widths.append(pw)
|
|
row_strips.append(torch.cat(_match_channels(pieces), dim=2)) # concat along width
|
|
row_widths.append(widths)
|
|
|
|
anchor_w = row_strips[0].shape[2]
|
|
finals, boxes, y = [], [], 0
|
|
for r, rs in enumerate(row_strips):
|
|
h, w = rs.shape[1], rs.shape[2]
|
|
fh = max(1, round(h * anchor_w / w))
|
|
finals.append(resize(rs, fh, anchor_w)) # normalize row strip to shared width
|
|
widths = row_widths[r]
|
|
total = float(sum(widths)) or 1.0
|
|
cum = 0
|
|
for c in range(cols): # cell x-bounds within the normalized strip
|
|
x0 = int(round(cum / total * anchor_w))
|
|
cum += widths[c]
|
|
x1 = int(round(cum / total * anchor_w))
|
|
boxes.append((y, y + fh, x0, x1))
|
|
y += fh
|
|
grid = torch.cat(_match_channels(finals), dim=1) # concat along height -> [C,H,W]
|
|
return grid, boxes
|
|
|
|
|
|
RATIOS = ["1:1", "3:4", "4:3", "2:3", "3:2", "4:5", "9:16", "16:9"]
|
|
|
|
|
|
def _parse_ratio(s):
|
|
"""'3:4' -> width/height aspect (0.75)."""
|
|
try:
|
|
w, h = s.split(":")
|
|
return float(w) / float(h)
|
|
except Exception:
|
|
return 1.0
|
|
|
|
|
|
def _center_crop_to_aspect(chw, aspect):
|
|
"""Center-crop [C,H,W] so width/height == aspect (no scaling, just crop)."""
|
|
_, h, w = chw.shape
|
|
if w / h > aspect: # too wide -> trim the sides
|
|
nw = max(1, int(round(h * aspect)))
|
|
x0 = (w - nw) // 2
|
|
return chw[:, :, x0:x0 + nw]
|
|
nh = max(1, int(round(w / aspect))) # too tall -> trim top/bottom
|
|
y0 = (h - nh) // 2
|
|
return chw[:, y0:y0 + nh, :]
|
|
|
|
|
|
def stitch_uniform(cells, rows, cols, aspect, scale_method, base_h=512):
|
|
"""Manual mode: center-crop every image to `aspect`, resize to one shared cell
|
|
size, tile row-major into a clean rows x cols rectangle. Empty cells -> black.
|
|
Returns (grid [C,H,W], boxes) — uniform cell rectangles, row-major."""
|
|
ch = base_h
|
|
cw = max(1, int(round(base_h * aspect)))
|
|
ref = next((c for c in cells if c is not None), None)
|
|
device = ref.device if ref is not None else "cpu"
|
|
dtype = ref.dtype if ref is not None else torch.float32
|
|
|
|
def resize(chw, h, w):
|
|
return comfy.utils.common_upscale(chw.unsqueeze(0), w, h, scale_method, "disabled")[0]
|
|
|
|
tiles = []
|
|
for cell in cells:
|
|
if cell is None:
|
|
tiles.append(torch.zeros(3, ch, cw, device=device, dtype=dtype))
|
|
else:
|
|
tiles.append(resize(_center_crop_to_aspect(cell, aspect), ch, cw))
|
|
tiles = _match_channels(tiles)
|
|
strips = [torch.cat(tiles[r * cols:(r + 1) * cols], dim=2) for r in range(rows)]
|
|
grid = torch.cat(strips, dim=1) # [C, rows*ch, cols*cw]
|
|
boxes = [(r * ch, (r + 1) * ch, c * cw, (c + 1) * cw)
|
|
for r in range(rows) for c in range(cols)]
|
|
return grid, boxes
|
|
|
|
|
|
def _parse_cells(spec, n):
|
|
"""'2,4-6' -> {2,4,5,6}, clamped to 1..n. Empty/garbage -> empty set."""
|
|
out = set()
|
|
if not spec:
|
|
return out
|
|
for part in str(spec).replace(" ", "").split(","):
|
|
if not part:
|
|
continue
|
|
if "-" in part:
|
|
try:
|
|
a, b = part.split("-")
|
|
a, b = int(a), int(b)
|
|
for k in range(min(a, b), max(a, b) + 1):
|
|
if 1 <= k <= n:
|
|
out.add(k)
|
|
except Exception:
|
|
pass
|
|
else:
|
|
try:
|
|
k = int(part)
|
|
if 1 <= k <= n:
|
|
out.add(k)
|
|
except Exception:
|
|
pass
|
|
return out
|
|
|
|
|
|
def _build_mask(boxes, gh, gw, out_h, out_w, denoise):
|
|
"""MASK [1,out_h,out_w]: white(1)=denoise, black(0)=preserve. `boxes` are in the
|
|
pre-scale grid space (gh x gw); rescale to the final (out_h x out_w). An empty
|
|
selection returns all-white (a wired-but-unconfigured mask is a no-op = denoise all)."""
|
|
m = torch.zeros(out_h, out_w)
|
|
if not denoise:
|
|
m += 1.0
|
|
return m.unsqueeze(0)
|
|
fy = out_h / float(gh)
|
|
fx = out_w / float(gw)
|
|
for idx, (y0, y1, x0, x1) in enumerate(boxes, start=1):
|
|
if idx in denoise:
|
|
yy0 = max(0, min(out_h, int(round(y0 * fy))))
|
|
yy1 = max(0, min(out_h, int(round(y1 * fy))))
|
|
xx0 = max(0, min(out_w, int(round(x0 * fx))))
|
|
xx1 = max(0, min(out_w, int(round(x1 * fx))))
|
|
m[yy0:yy1, xx0:xx1] = 1.0
|
|
return m.unsqueeze(0)
|
|
|
|
|
|
class GridStitchAdvanced:
|
|
"""Stitch multiple (different-size) images into an R x C grid, ImageStitch-style,
|
|
scaled to a target megapixels. image_i -> cell i (row-major); empty cells -> black.
|
|
Also emits a per-cell MASK for masked-latent workflows: `mask_cells` lists the cells
|
|
to DENOISE (row-major, 1-indexed, e.g. '2,4-6'); the rest are preserved. The MASK
|
|
lines up with the returned grid, so it can feed SetLatentNoiseMask directly."""
|
|
MAX_IMAGES = 16
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"rows": ("INT", {"default": 2, "min": 1, "max": 4}),
|
|
"cols": ("INT", {"default": 2, "min": 1, "max": 4}),
|
|
"mode": (["auto", "manual"], {"default": "auto",
|
|
"tooltip": "auto = preserve every image, no cropping (cells can differ). manual = center-crop every image to one ratio for a uniform grid."}),
|
|
"ratio": (RATIOS, {"default": "3:4",
|
|
"tooltip": "Manual mode only: the aspect every image is center-cropped to before stitching."}),
|
|
"megapixels": ("FLOAT", {"default": 4.0, "min": 0.1, "max": 100.0, "step": 0.1,
|
|
"tooltip": "Target TOTAL size of the stitched grid."}),
|
|
"scale_method": (["lanczos", "bicubic", "area", "bilinear", "nearest-exact"],
|
|
{"default": "lanczos"}),
|
|
},
|
|
"optional": {
|
|
# optional (default "") so graphs that omit it still validate — matches the no-op contract.
|
|
"mask_cells": ("STRING", {"default": "",
|
|
"tooltip": "Cells to DENOISE for the MASK output (row-major, 1-indexed, e.g. '2,4-6'). Others are preserved. Empty = denoise all (mask is a no-op). Set via the picker's mask toggle, or by hand / from an app."}),
|
|
**{f"image_{i}": ("IMAGE",) for i in range(1, cls.MAX_IMAGES + 1)},
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "INT", "INT", "MASK")
|
|
RETURN_NAMES = ("grid", "width", "height", "mask")
|
|
FUNCTION = "stitch"
|
|
CATEGORY = "image/grid"
|
|
|
|
def stitch(self, rows, cols, mode, ratio, megapixels, scale_method, mask_cells="", **kwargs):
|
|
n = rows * cols
|
|
cells = []
|
|
for i in range(1, n + 1):
|
|
img = kwargs.get(f"image_{i}")
|
|
cells.append(_to_chw(img) if img is not None else None)
|
|
if mode == "manual":
|
|
grid, boxes = stitch_uniform(cells, rows, cols, _parse_ratio(ratio), scale_method)
|
|
else:
|
|
grid, boxes = stitch_imagestitch_style(cells, rows, cols, scale_method)
|
|
gh, gw = grid.shape[1], grid.shape[2]
|
|
scale = (max(1.0, megapixels * 1_000_000.0) / (gh * gw)) ** 0.5
|
|
out_h, out_w = max(1, round(gh * scale)), max(1, round(gw * scale))
|
|
grid = comfy.utils.common_upscale(grid.unsqueeze(0), out_w, out_h, scale_method, "disabled")[0]
|
|
out = grid.movedim(0, -1).unsqueeze(0).clamp(0.0, 1.0)
|
|
mask = _build_mask(boxes, gh, gw, out_h, out_w, _parse_cells(mask_cells, n))
|
|
return (out, out_w, out_h, mask)
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"AutoGridSplit": AutoGridSplit,
|
|
"GridStitch": GridStitch,
|
|
"GridStitchAdvanced": GridStitchAdvanced,
|
|
}
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"AutoGridSplit": "Auto Grid / Carousel Split",
|
|
"GridStitch": "Auto Grid / Stitch (one image)",
|
|
"GridStitchAdvanced": "Auto Grid / Stitch Advanced (multi-image)",
|
|
}
|