""" Merged grid image splitter. This node is designed for AI-generated contact sheets such as 3x3 or 2x3 grids. Auto mode scores common layouts by looking for strong seams or flat separator bands near the expected grid lines, then crops each cell. """ from __future__ import annotations from dataclasses import dataclass from typing import List, Sequence, Tuple import numpy as np import torch from PIL import Image from ..utils.image_utils import pil_to_tensor, tensor_to_pil _AUTO_LAYOUTS: Sequence[Tuple[int, int]] = ( (3, 3), (2, 3), (3, 2), (2, 2), (1, 2), (2, 1), (1, 3), (3, 1), (4, 4), (3, 4), (4, 3), ) _LAYOUTS = [ "auto", "1x2", "2x1", "1x3", "3x1", "2x2", "2x3", "3x2", "3x3", "3x4", "4x3", "4x4", "custom", ] @dataclass(frozen=True) class _AxisCut: seam: int span_start: int span_end: int score: float @dataclass(frozen=True) class _AxisPlan: intervals: List[Tuple[int, int]] cuts: List[_AxisCut] score: float def _to_float_array(image: Image.Image) -> np.ndarray: if image.mode != "RGB": image = image.convert("RGB") return np.asarray(image).astype(np.float32) / 255.0 def _axis_texture(arr: np.ndarray, axis: str) -> np.ndarray: if axis == "x": profile = arr.std(axis=(0, 2)) else: profile = arr.std(axis=(1, 2)) high = np.percentile(profile, 95) + 1e-6 return np.clip(profile / high, 0.0, 1.0) def _axis_edge(arr: np.ndarray, axis: str) -> np.ndarray: if axis == "x": diff = np.abs(np.diff(arr, axis=1)).mean(axis=(0, 2)) length = arr.shape[1] else: diff = np.abs(np.diff(arr, axis=0)).mean(axis=(1, 2)) length = arr.shape[0] padded = np.zeros(length, dtype=np.float32) if diff.size: padded[1:] = diff high = np.percentile(padded, 95) + 1e-6 return np.clip(padded / high, 0.0, 1.5) def _smooth(profile: np.ndarray, radius: int = 2) -> np.ndarray: if radius <= 0 or profile.size < radius * 2 + 1: return profile kernel = np.ones(radius * 2 + 1, dtype=np.float32) / float(radius * 2 + 1) return np.convolve(profile, kernel, mode="same") def _separator_span( texture: np.ndarray, seam: int, search_px: int, min_separator_px: int, ) -> Tuple[int, int]: length = texture.size if length <= 1: return 0, length limit = max(1, min(search_px, length // 8)) threshold = max(0.08, min(0.28, float(np.percentile(texture, 12)) * 1.8)) left = seam while left > 0 and seam - left < limit and texture[left - 1] <= threshold: left -= 1 right = seam while right < length and right - seam < limit and texture[right] <= threshold: right += 1 if right - left >= max(1, min_separator_px): return left, right return seam, seam def _edge_trim(texture: np.ndarray, search_px: int, min_cell: int) -> Tuple[int, int]: length = texture.size if length <= 2: return 0, length max_trim = max(0, min(search_px * 2, min_cell // 3, length // 6)) if max_trim <= 0: return 0, length threshold = max(0.08, min(0.24, float(np.percentile(texture, 12)) * 1.6)) start = 0 while start < max_trim and texture[start] <= threshold: start += 1 end = length while length - end < max_trim and end > start + min_cell and texture[end - 1] <= threshold: end -= 1 return start, end def _axis_plan( arr: np.ndarray, cells: int, axis: str, search_px: int, crop_separators: bool, trim_outer: bool, min_separator_px: int, ) -> _AxisPlan: length = arr.shape[1] if axis == "x" else arr.shape[0] if cells <= 1: return _AxisPlan(intervals=[(0, length)], cuts=[], score=0.0) raw_texture = _axis_texture(arr, axis) raw_edge = _axis_edge(arr, axis) texture = _smooth(raw_texture, radius=2) edge = _smooth(raw_edge, radius=1) evidence = np.maximum(edge, (1.0 - texture) * 0.75) exact_evidence = np.maximum(raw_edge, (1.0 - raw_texture) * 0.75) cuts: List[_AxisCut] = [] scores: List[float] = [] for idx in range(1, cells): expected = round(length * idx / cells) start = max(1, expected - search_px) end = min(length - 1, expected + search_px) if start >= end: seam = expected score = 0.0 else: window = evidence[start:end + 1] offset = int(window.argmax()) coarse = start + offset fine_start = max(start, coarse - 2) fine_end = min(end, coarse + 2) fine_window = exact_evidence[fine_start:fine_end + 1] seam = fine_start + int(fine_window.argmax()) score = float(window[offset]) span_start, span_end = _separator_span( raw_texture, seam, search_px=search_px, min_separator_px=min_separator_px, ) cuts.append(_AxisCut(seam=seam, span_start=span_start, span_end=span_end, score=score)) scores.append(score) min_cell = max(1, length // cells) outer_start, outer_end = _edge_trim(raw_texture, search_px, min_cell) if trim_outer else (0, length) intervals: List[Tuple[int, int]] = [] cursor = outer_start for cut in cuts: split_start = cut.span_start if crop_separators else cut.seam split_end = cut.span_end if crop_separators else cut.seam intervals.append((cursor, split_start)) cursor = split_end intervals.append((cursor, outer_end)) cleaned: List[Tuple[int, int]] = [] for start, end in intervals: start = max(0, min(length - 1, int(start))) end = max(start + 1, min(length, int(end))) cleaned.append((start, end)) return _AxisPlan( intervals=cleaned, cuts=cuts, score=float(np.mean(scores)) if scores else 0.0, ) def _parse_layout(layout: str, custom_rows: int, custom_cols: int) -> Tuple[int, int]: if layout == "custom": return max(1, int(custom_rows)), max(1, int(custom_cols)) rows_text, cols_text = layout.split("x", 1) return int(rows_text), int(cols_text) def _fallback_layout(width: int, height: int) -> Tuple[int, int]: aspect = width / max(1, height) if 0.82 <= aspect <= 1.22: return 3, 3 if aspect > 1.22: return 2, 3 return 3, 2 def _choose_auto_layout( arr: np.ndarray, search_px: int, crop_separators: bool, trim_outer: bool, min_separator_px: int, ) -> Tuple[int, int, _AxisPlan, _AxisPlan, float, bool]: height, width = arr.shape[:2] best = None for rows, cols in _AUTO_LAYOUTS: x_plan = _axis_plan(arr, cols, "x", search_px, crop_separators, trim_outer, min_separator_px) y_plan = _axis_plan(arr, rows, "y", search_px, crop_separators, trim_outer, min_separator_px) score = (x_plan.score + y_plan.score) / 2.0 # Prefer common 3x3 / 2x3 / 3x2 layouts when the image gives weak signals. if (rows, cols) in ((3, 3), (2, 3), (3, 2)): score += 0.025 if best is None or score > best[0]: best = (score, rows, cols, x_plan, y_plan) assert best is not None score, rows, cols, x_plan, y_plan = best confident = score >= 0.22 if confident: return rows, cols, x_plan, y_plan, score, True rows, cols = _fallback_layout(width, height) x_plan = _axis_plan(arr, cols, "x", search_px, crop_separators, trim_outer, min_separator_px) y_plan = _axis_plan(arr, rows, "y", search_px, crop_separators, trim_outer, min_separator_px) return rows, cols, x_plan, y_plan, score, False def _normalize_sizes(crops: List[Image.Image]) -> List[Image.Image]: min_w = min(crop.width for crop in crops) min_h = min(crop.height for crop in crops) normalized = [] for crop in crops: left = max(0, (crop.width - min_w) // 2) top = max(0, (crop.height - min_h) // 2) normalized.append(crop.crop((left, top, left + min_w, top + min_h))) return normalized def _split_one( image: Image.Image, layout: str, custom_rows: int, custom_cols: int, search_px: int, crop_separators: bool, trim_outer: bool, min_separator_px: int, ) -> Tuple[List[Image.Image], str]: arr = _to_float_array(image) if layout == "auto": rows, cols, x_plan, y_plan, confidence, confident = _choose_auto_layout( arr, search_px=search_px, crop_separators=crop_separators, trim_outer=trim_outer, min_separator_px=min_separator_px, ) mode_note = "auto" if confident else "auto-low-confidence-fallback" else: rows, cols = _parse_layout(layout, custom_rows, custom_cols) x_plan = _axis_plan(arr, cols, "x", search_px, crop_separators, trim_outer, min_separator_px) y_plan = _axis_plan(arr, rows, "y", search_px, crop_separators, trim_outer, min_separator_px) confidence = (x_plan.score + y_plan.score) / 2.0 mode_note = "manual" crops: List[Image.Image] = [] for y0, y1 in y_plan.intervals: for x0, x1 in x_plan.intervals: crops.append(image.crop((x0, y0, x1, y1))) crops = _normalize_sizes(crops) info = ( f"{mode_note}: {rows}x{cols}, cells={len(crops)}, " f"confidence={confidence:.3f}, " f"x={x_plan.intervals}, y={y_plan.intervals}" ) return crops, info class O1keyGridSplitter: """Split AI-generated grid/contact-sheet images into individual cells.""" @classmethod def INPUT_TYPES(cls): return { "required": { "图像": ("IMAGE",), "布局": (_LAYOUTS, {"default": "auto"}), "自定义行数": ("INT", {"default": 3, "min": 1, "max": 12, "step": 1}), "自定义列数": ("INT", {"default": 3, "min": 1, "max": 12, "step": 1}), "搜索范围px": ("INT", {"default": 32, "min": 0, "max": 256, "step": 1}), "裁掉分隔线": ("BOOLEAN", {"default": True}), "裁掉外边距": ("BOOLEAN", {"default": True}), "最小分隔线px": ("INT", {"default": 2, "min": 0, "max": 64, "step": 1}), "最大输出张数": ("INT", {"default": 16, "min": 1, "max": 144, "step": 1}), } } RETURN_TYPES = ("IMAGE", "STRING") RETURN_NAMES = ("切割图像", "检测信息") FUNCTION = "split_grid" CATEGORY = "o1key/image" DESCRIPTION = ( "智能切割 AI 生成的九宫格、六宫格等合并图。" "自动模式会检测常见布局;没有明显分隔线时建议手动选择布局。" ) def split_grid( self, 图像: torch.Tensor, 布局: str = "auto", 自定义行数: int = 3, 自定义列数: int = 3, 搜索范围px: int = 32, 裁掉分隔线: bool = True, 裁掉外边距: bool = True, 最小分隔线px: int = 2, 最大输出张数: int = 16, ): source_images = tensor_to_pil(图像) all_crops: List[Image.Image] = [] info_lines: List[str] = [] for batch_index, image in enumerate(source_images, start=1): crops, info = _split_one( image=image, layout=布局, custom_rows=自定义行数, custom_cols=自定义列数, search_px=搜索范围px, crop_separators=裁掉分隔线, trim_outer=裁掉外边距, min_separator_px=最小分隔线px, ) if len(crops) > 最大输出张数: raise ValueError( f"合并图切割:检测到 {len(crops)} 张,超过最大输出张数 {最大输出张数}。" "请调大最大输出张数,或检查布局设置。" ) all_crops.extend(crops) info_lines.append(f"batch {batch_index}: {info}") if not all_crops: raise ValueError("合并图切割:没有生成任何切片。") all_crops = _normalize_sizes(all_crops) print("[o1key 合并图切割] " + " | ".join(info_lines)) return (pil_to_tensor(all_crops), "\n".join(info_lines))