commit 6af4637eb0debecd94ce0ee269c02f4a754e3de3 Author: Bruno Madeira Date: Thu Sep 18 21:44:15 2025 +0100 1st implementation. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..50f0cd8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.py[cod] +*$py.class +*.bak diff --git a/CutNode.py b/CutNode.py new file mode 100644 index 0000000..210575c --- /dev/null +++ b/CutNode.py @@ -0,0 +1,492 @@ +from __future__ import annotations +from typing import Optional + +from shapely.geometry import Polygon, LineString, Point +from shapely.ops import split, unary_union +from shapely.geometry.polygon import orient +from shapely import affinity +from PIL import Image, ImageDraw, ImageOps +import matplotlib.pyplot as plt +import math +import copy + + +# ----------------------- +# Split presets & constants +# ----------------------- +phi = (1.0 + math.sqrt(5.0)) / 2.0 +SPLIT_MODES = { + 0: 0.5, # midpoint (default) + 1: 1.0 / 3.0, # 1/3 + 2: 2.0 / 3.0, # 2/3 + 3: 1.0 - 1.0 / phi, + 4: 1.0 / phi, # 1/phi +} + + +class CutNode: + def __init__(self, vertical: bool, angle: int = 0, split_mode: int = 0): + """ + vertical: True => vertical cuts; False => horizontal + angle: integer degrees (slant) + split_mode: index into SPLIT_MODES (0 allowed many cuts, nonzero -> only 1 cut) + """ + self.vertical: bool = vertical + self.angle: int = int(angle) + self.split_mode: int = split_mode + self.children: list[Optional[CutNode]] = [] + + def __deepcopy__(self, memo): + node = CutNode(self.vertical, self.angle, self.split_mode) + node.children = [ + copy.deepcopy(c, memo) if c is not None else None + for c in self.children + ] + return node + + @property + def cuts(self) -> int: + return max(0, len(self.children) - 1) + + def add_child(self, child: Optional[CutNode] = None) -> None: + # enforce non-midpoint single-cut rule at add time + if self.split_mode != 0 and len(self.children) >= 2: + raise ValueError("Non-midpoint split modes only support one cut (two children).") + self.children.append(child) + + def to_compact(self) -> str: + """Serialize compactly: V15_3(...). Always include split_mode for simplicity.""" + prefix = "V" if self.vertical else "H" + s = f"{prefix}{self.angle}_{self.split_mode}" + if self.children: + s += "(" + ",".join(c.to_compact() if c else "" for c in self.children) + ")" + return s + + @staticmethod + def from_compact(data: str) -> Optional[CutNode]: + """Parser. Builds the embedded cut tree.""" + if not data or not data.strip(): + return None + + def _split_top_level_commas(s: str) -> list[str]: + tokens: list[str] = [] + buf: list[str] = [] + depth = 0 + for ch in s: + if ch == "(": + depth += 1 + buf.append(ch) + elif ch == ")": + depth -= 1 + buf.append(ch) + elif ch == "," and depth == 0: + tokens.append("".join(buf)) + buf = [] + else: + buf.append(ch) + tokens.append("".join(buf)) + return tokens + + def _parse_node_str(s: str) -> Optional[CutNode]: + s = s.strip() + if not s: + return None + i = 0 + n = len(s) + if s[i] not in ("V", "H"): + raise ValueError(f"Expected 'V' or 'H' at start of node: {s!r}") + vertical = s[i] == "V" + i += 1 + + # integer angle (supports sign) + angle_str = "" + while i < n and (s[i].isdigit() or s[i] in "-+"): + angle_str += s[i] + i += 1 + angle = int(angle_str) if angle_str else 0 + + # optional split_mode after '_' + split_mode = 0 + if i < n and s[i] == "_": + i += 1 + mode_str = "" + while i < n and s[i].isdigit(): + mode_str += s[i] + i += 1 + split_mode = int(mode_str) if mode_str else 0 + + node = CutNode(vertical, angle, split_mode) + + # children (optional) + if i < n and s[i] == "(": + # find matching closing paren + j = i + 1 + depth = 0 + while j < n: + if s[j] == "(": + depth += 1 + elif s[j] == ")": + if depth == 0: + break + depth -= 1 + j += 1 + if j >= n or s[j] != ")": + raise ValueError(f"Unmatched '(' in node string: {s!r}") + inner = s[i + 1: j] + child_tokens = _split_top_level_commas(inner) + for tok in child_tokens: + tok = tok.strip() + if tok == "": + node.add_child(None) + else: + child = _parse_node_str(tok) + node.add_child(child) + # i = j + 1 + return node + + return _parse_node_str(data.strip()) + + @staticmethod + def _weighted_midpoint_of_lines(intersection_geom) -> Optional[tuple[float, float]]: + """ + Given the result of polygon.intersection(line) (maybe LineString, MultiLineString, Point, GeometryCollection), + compute a robust midpoint: weighted average of segment midpoints by their length. + """ + if intersection_geom.is_empty: + return None + + # single line + if intersection_geom.geom_type == "LineString": + coords = list(intersection_geom.coords) + x0, y0 = coords[0] + x1, y1 = coords[-1] + return ((x0 + x1) / 2.0, (y0 + y1) / 2.0) + + # point + if intersection_geom.geom_type == "Point": + return (intersection_geom.x, intersection_geom.y) + + # multi or collection: gather lines and points + total_len = 0.0 + sum_x = 0.0 + sum_y = 0.0 + geoms = getattr(intersection_geom, "geoms", []) # Shapely 1.x and 2.x compat + for g in geoms: + if g.geom_type == "LineString": + coords = list(g.coords) + x0, y0 = coords[0] + x1, y1 = coords[-1] + midx, midy = (x0 + x1) / 2.0, (y0 + y1) / 2.0 + L = g.length + sum_x += midx * L + sum_y += midy * L + total_len += L + elif g.geom_type == "Point": + sum_x += g.x + sum_y += g.y + total_len += 1.0 + if total_len <= 0: + return None + return (sum_x / total_len, sum_y / total_len) + + @staticmethod + def _build_seam_lines_for_panel( + panel: Polygon, + vertical: bool, + angle_deg: int, + cuts: int, + split_mode: int, + ) -> list[LineString]: + """ + Produce a list of seam LineString objects for the given panel. + - cuts: suggested number of cuts (children-1). If split_mode != 0 then we only allow 1 cut. + - split_mode chooses ratio presets (if 0 we use evenly spaced j/(cuts+1)). + Each seam is centered so its midpoint (intersection with panel) lies on the reference point. + """ + if cuts <= 0 or panel.is_empty: + return [] + + if split_mode != 0: + # enforce single cut for non-midpoint modes + cuts = 1 + + xmin, ymin, xmax, ymax = panel.bounds + width = xmax - xmin + height = ymax - ymin + pad = max(width, height) * 10.0 + 1.0 # long enough line to cross panel + cx, cy = panel.centroid.x, panel.centroid.y + + seam_lines: list[LineString] = [] + + for j in range(1, cuts + 1): + # compute ratio + if split_mode == 0: + ratio = j / (cuts + 1) + else: + ratio = SPLIT_MODES.get(split_mode, 0.5) + + if vertical: + c = xmin + (xmax - xmin) * ratio + base = LineString([(c, ymin - pad), (c, ymax + pad)]) + ref_point = Point(c, (ymin + ymax) / 2.0) + else: + c = ymin + (ymax - ymin) * ratio + base = LineString([(xmin - pad, c), (xmax + pad, c)]) + ref_point = Point((xmin + xmax) / 2.0, c) + + # rotate around centroid + line_rot = affinity.rotate(base, angle_deg, origin=(cx, cy)) + + # compute intersection with panel and midpoint + inter = panel.intersection(line_rot) + mid = CutNode._weighted_midpoint_of_lines(inter) + if mid is not None: + dx = ref_point.x - mid[0] + dy = ref_point.y - mid[1] + line_centered = affinity.translate(line_rot, xoff=dx, yoff=dy) + else: + # no intersection found, keep rotated line (it will probably not split) + line_centered = line_rot + + # Option: sanity - only keep the line if it intersects the panel + if not panel.intersects(line_centered): + # skip seam that doesn't intersect (degenerate) + continue + + seam_lines.append(LineString(line_centered)) # make a copy + + return seam_lines + + @staticmethod + def partition_panel(panel: Polygon, node: CutNode, margin_here: float) -> list[Polygon]: + """ + Given a panel polygon and a CutNode (for that panel), compute the list of sub-panels + after applying node's cuts and carving the margin gaps only on seams. + + Returns list of polygons in ascending order along the cut axis (left->right for vertical, + bottom->top for horizontal). + """ + if panel.is_empty or node is None or len(node.children) == 0: + return [panel] + + # number of cuts (children - 1); enforce split_mode rule inside seam builder + cuts = node.cuts + + # 1) build seam lines (centered) + seam_lines = CutNode._build_seam_lines_for_panel(panel, node.vertical, node.angle, cuts, node.split_mode) + + if not seam_lines: + return [panel] + + # 2) unify seams into a single multilinestring for splitting + seam_union = unary_union(seam_lines) + + # 3) split panel by seam union + split_result = split(panel, seam_union) + raw_pieces = [g for g in getattr(split_result, "geoms", [split_result]) if g.geom_type == "Polygon"] + + if not raw_pieces: + # fallback + return [panel] + + # 4) order raw pieces along the primary axis (so child mapping is deterministic) + if node.vertical: + raw_pieces.sort(key=lambda p: p.centroid.x) + else: + raw_pieces.sort(key=lambda p: p.centroid.y) + + # 5) build margin gaps as buffer of seam lines and subtract them from each raw piece + if margin_here is not None and margin_here > 0: + offsets = margin_here / 2.0 + # rectangular-ish gaps using square caps (cap_style=2) to keep straight edges + gap_polys = [s.buffer(offsets, cap_style=2) for s in seam_lines] + gap_union = unary_union(gap_polys) + adjusted = [] + for rp in raw_pieces: + diff = rp.difference(gap_union) + # difference may produce Polygon or MultiPolygon; keep as-is + if diff.is_empty: + # If fully removed, keep an empty placeholder (to preserve counts) + adjusted.append(Polygon()) + else: + adjusted.append(diff) + else: + adjusted = raw_pieces + + # 6) result should have len == node.cuts+1 (or close); if not, pad with empties + expected = max(1, (node.cuts + 1)) + while len(adjusted) < expected: + adjusted.append(Polygon()) + + return adjusted + + @staticmethod + def process_tree(node: Optional[CutNode], panel: Polygon, margin: float = 0.0, rtl=False, depth: int = 0) -> list[Polygon]: + """ + Process the cut tree, starting at `panel`. Uses exact seam-based margin carving. + margin is the top-level margin; it is decayed by phi per depth: + margin_at_depth = margin / (phi ** depth) + + rtl = True, then panels are sorted from right to left, instead of from left to right + """ + if panel.is_empty: + return [] + if node is None or len(node.children) == 0: + return [panel] + + # compute margin for this depth + current_margin = margin / (phi ** depth) if margin > 0 else 0.0 + + # partition this panel into pieces and carve gaps (exact) + pieces = CutNode.partition_panel(panel, node, current_margin) + + out: list[Polygon] = [] + # Now map children to pieces in order + for child, piece in zip(node.children, pieces): + if child is None: + # leaf: piece is final (could be Polygon or MultiPolygon) + out.append(piece) + else: + # recursive + out.extend(CutNode.process_tree(child, piece, margin=margin, rtl=rtl, depth=depth + 1)) + + if depth == 0 and rtl: + # flip polygons + xmin, ymin, xmax, ymax = panel.bounds + cx = (xmin + xmax) / 2 # center x of canvas + return [orient(affinity.scale(p, xfact=-1, yfact=1, origin=(cx, 0)), -1.0) for p in out] # bruh + + return out + + +def layout_to_image( + cut_tree: CutNode, + rtl: bool = False, # stored image in right-to-left format (x mirrored) + canvas_width: int = 210, + canvas_height: int = 297, + font_size: int = 9, + margin: int = 4, + index_font_size: int = 10, +) -> tuple[Image, str]: + """ + Save panels as a PNG image, showing both left-to-right and right-to-left indices. + The cut tree's compact code is also drawn below and stored in PNG metadata. + """ + from shapely.geometry import box + from PIL import Image, ImageDraw, ImageFont + + root_panel = box(0, 0, canvas_width, canvas_height) + polygons = CutNode.process_tree(cut_tree, root_panel, margin=0, rtl=rtl) + base_im = panels_to_image(polygons, index_font_size, "pink" if rtl else "lightblue") + compact_code = cut_tree.to_compact() + + # Extend image at bottom + width, height = base_im.size + extra_height = font_size + 2 * margin + new_im = Image.new("RGBA", (width, height + extra_height), (255, 255, 255, 255)) + new_im.paste(base_im, (0, 0)) + + # Draw compact code text + draw = ImageDraw.Draw(new_im) + try: + font = ImageFont.truetype("DejaVuSansMono.ttf", font_size) + except IOError: + font = ImageFont.load_default() + + bbox = draw.textbbox((0, 0), compact_code, font=font) + text_w = bbox[2] - bbox[0] + # text_h = bbox[3] - bbox[1] + x_pos = (width - text_w) // 2 + y_pos = height + margin + draw.text((x_pos, y_pos), compact_code, font=font, fill=(0, 0, 0, 255)) + + return new_im, compact_code + + +def panels_to_image(panels: list[Polygon], index_font_size: int = 10, + annotate_color: Optional[str] = "lightblue", + canvas: Optional[Polygon] = None) -> Image: + """ + :param annotate_rtl: None->No annotations; False->left to right; True->right to left + """ + import io + fig, ax = plt.subplots() + ax.set_aspect("equal") + ax.axis("off") + ax.yaxis.set_inverted(True) + + if canvas is not None: + x, y = canvas.exterior.xy + ax.fill(x, y, alpha=1, color="white", edgecolor="black", linewidth=2) + + for poly in panels: + x, y = poly.exterior.xy + ax.fill(x, y, alpha=0.6, edgecolor="black", linewidth=1) + + if annotate_color is not None: + for idx, poly_ltr in enumerate(panels): + cx, cy = poly_ltr.centroid.coords[0] + + bg_color = annotate_color + + ax.text( + cx, cy, str(idx), + ha="center", va="center", + fontsize=index_font_size, + color="black", + bbox=dict(facecolor=bg_color, edgecolor="none", boxstyle="circle,pad=0.2", alpha=0.7) + ) + + buf = io.BytesIO() + plt.savefig(buf, format="png", bbox_inches="tight", pad_inches=0) + plt.close(fig) + return Image.open(buf).convert("RGBA") + + +def draw_polygon_contours( + polys: list[Polygon], + canvas: Polygon, + stroke_color: tuple[int, int, int, int] = (0, 0, 0, 255), + stroke_width: int = 1, + pad: int = 0, + upscale: int = 4, +) -> Image.Image: + """ + Draw polygon contours on a transparent RGBA image and return the image. + Supports optional bevel smoothing via CurvatureParams. + """ + xmin, ymin, xmax, ymax = canvas.bounds + width = int(math.ceil(xmax - xmin)) + 2 * pad + height = int(math.ceil(ymax - ymin)) + 2 * pad + + if width <= 0 or height <= 0: + return Image.new("RGBA", (1, 1), (0, 0, 0, 0)) + + # internal supersampled image + W, H = width * upscale, height * upscale + img = Image.new("RGBA", (W, H), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + def _to_image_coords(x: float, y: float) -> tuple[float, float]: + x_img = ((x - xmin) + pad) * upscale + y_img = ((ymax - y) + pad) * upscale + return (x_img, y_img) + + for poly in polys: + if poly is None or not isinstance(poly, Polygon): + continue + + # exterior + ext = [_to_image_coords(x, y) for x, y in poly.exterior.coords] + if len(ext) >= 2: + draw.line(ext + [ext[0]], fill=stroke_color, width=max(1, stroke_width * upscale), joint="curve") + + # holes + for interior in poly.interiors: + coords = [_to_image_coords(x, y) for x, y in interior.coords] + if len(coords) >= 2: + draw.line(coords + [coords[0]], fill=stroke_color, width=max(1, stroke_width * upscale), joint="curve") + + img = ImageOps.flip(img) + return img.resize((width, height), Image.Resampling.LANCZOS) + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3b1924e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 bmad4ever + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..d721463 --- /dev/null +++ b/__init__.py @@ -0,0 +1,3 @@ +from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] \ No newline at end of file diff --git a/aux_data.py b/aux_data.py new file mode 100644 index 0000000..921faff --- /dev/null +++ b/aux_data.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass +from shapely.geometry import Polygon +from typing import Tuple + + +@dataclass +class BBoxSnap: + xmin: float + ymin: float + xmax: float + ymax: float + snap_on_bbox: bool = True + + @classmethod + def from_bounds(cls, bounds: Tuple[float, float, float, float], snap_on_bbox: bool = True) -> "BBoxSnap": + """Create from (xmin, ymin, xmax, ymax).""" + return cls(*bounds, snap_on_bbox) + + @classmethod + def from_polygon(cls, poly: Polygon, snap_on_bbox: bool = True) -> "BBoxSnap": + """Create from a Shapely polygon's bounds.""" + return cls(*poly.bounds, snap_on_bbox) + + def as_tuple(self) -> tuple[float, float, float, float]: + """Return just the numeric bounds as a tuple.""" + return (self.xmin, self.ymin, self.xmax, self.ymax) + + +@dataclass +class CurvatureParams: + curvature: float = 0.0 # bevel radius + iterations: int = 1 # how many times to apply beveling + resolution: int = 16 # buffer resolution (segments per quarter circle) diff --git a/nodes.py b/nodes.py new file mode 100644 index 0000000..b2a8984 --- /dev/null +++ b/nodes.py @@ -0,0 +1,1127 @@ +from shapely.affinity import rotate, scale, translate +from shapely.geometry import box # , Polygon +from PIL.PngImagePlugin import PngInfo +import torch.nn.functional as F +from copy import deepcopy +import numpy as np +import random +import torch +import json +import os +import re + +from comfy.cli_args import args +from nodes import LoadImage +import folder_paths +import node_helpers + +from .CutNode import * +from .aux_data import * + +CATEGORY_PATH = "Bmad/Panels" +META_DATA_KEY = "cut_tree" + + +class IO_Types: + PANEL_LAYOUT = "PANEL_LAYOUT" # Cuts Tree ( technically a node of the tree ) + PANEL = "POLYGON" # The layout panels. using original type to potentially re-use in or interface w/ other packages + BBOX = "BBOX" + BBOX_SNAP = "BBOX_SNAP" + + +# region Core Nodes + +class LoadPanelLayout: + @classmethod + def INPUT_TYPES(cls): + return LoadImage.INPUT_TYPES() + + CATEGORY = CATEGORY_PATH + RETURN_TYPES = (IO_Types.PANEL_LAYOUT,) + OUTPUT_TOOLTIPS = ("'Abstract' Panel layout, represented as a tree of cuts.",) + FUNCTION = "func" + DESCRIPTION = "Load the panel layout embedded in an image." + + def func(self, image): + image_path = folder_paths.get_annotated_filepath(image) + img = Image.open(image_path) + cut_tree_code: str | None = img.info.get("cut_tree", None) # should ret False on VALIDATE_INPUT I think... TBT + if cut_tree_code is None: + raise Exception("cut_tree metadata not found in provided image.") + cut_tree = CutNode.from_compact(cut_tree_code) + return (cut_tree,) + + @classmethod + def IS_CHANGED(cls, image): + return LoadImage.IS_CHANGED(image) + + @classmethod + def VALIDATE_INPUTS(cls, image): + is_image = LoadImage.VALIDATE_INPUTS(image) + if not is_image: + return False + image_path = folder_paths.get_annotated_filepath(image) + img = Image.open(image_path) + return META_DATA_KEY in img.info + + +class SavePanelLayout: + def __init__(self): + self.output_dir = folder_paths.get_output_directory() + self.type = "output" + self.prefix_append = "" + self.compress_level = 4 + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "layout": (IO_Types.PANEL_LAYOUT,), + "draw_as": ("BOOLEAN", {"default": False, + "label_on": "right to left", "label_off": "left to right", + "tooltip": "Flips the layout drawn on the stored image but the stored data is exactly the same."}), + "filename_prefix": ("STRING", {"default": "PanelLayout", + "tooltip": "The prefix for the file to save. This may include formatting information such as %date:yyyy-MM-dd% or %Empty Latent Image.width% to include values from nodes."}) + } + } + + RETURN_TYPES = () + FUNCTION = "func" + + OUTPUT_NODE = True + + CATEGORY = CATEGORY_PATH + DESCRIPTION = "Saves an image of the layout with it embedded to your ComfyUI output directory." + + def func(self, layout: CutNode, draw_as, filename_prefix: str = "PanelLayout"): + filename_prefix += self.prefix_append + full_output_folder, filename, counter, subfolder, filename_prefix = ( + folder_paths.get_save_image_path(filename_prefix, self.output_dir)) + + image, compact_code = layout_to_image(layout, draw_as) + metadata = PngInfo() + metadata.add_text("cut_tree", compact_code) + + filename_with_batch_num_removed = filename.replace("%batch_num%", "") + file = f"{filename_with_batch_num_removed}_{counter:05}_.png" + image.save(os.path.join(full_output_folder, file), pnginfo=metadata, compress_level=self.compress_level) + + results = list() + results.append({ + "filename": file, + "subfolder": subfolder, + "type": self.type + }) + return {"ui": {"images": results}} + + +class StringDecodePanelLayout: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "layout_code": ("STRING", {"default": "Paste the layout code here."}), + } + } + + CATEGORY = CATEGORY_PATH + RETURN_TYPES = (IO_Types.PANEL_LAYOUT,) + FUNCTION = "func" + + def func(self, layout_code): + layout_root_node = CutNode.from_compact(layout_code) + return (layout_root_node,) + + +class StringEncodePanelLayout: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "layout": (IO_Types.PANEL_LAYOUT,), + } + } + + OUTPUT_NODE = True + CATEGORY = CATEGORY_PATH + RETURN_TYPES = ("STRING",) + FUNCTION = "func" + + def func(self, layout): + str_code = CutNode.to_compact(layout) + print(f"Encoded panel layout -> {str_code}") + return (str_code,) + + +class BuildLayoutPanels: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "layout": (IO_Types.PANEL_LAYOUT, {"tooltip": + "Root node of the 'abstract' cut's tree."}), + "canvas": (IO_Types.PANEL, {"tooltip": + "A box shaped polygon representing the area to be cut into the panels."}), + "margin": ("INT", {"default": 32, "min": 0, "max": 1000, "tooltip": + "The distance (in pixels) between the panels formed by the 1st cut." + "The distance for nested cuts decreases the higher the depth in the layout hierarchy."}), + "reading_dir": ("BOOLEAN", {"default": False, + "label_on": "right to left", "label_off": "left to right", + "tooltip": + "Invert the panel layout horizontally to be read from right to left."}), + } + } + + CATEGORY = CATEGORY_PATH + RETURN_TYPES = (IO_Types.PANEL,) + OUTPUT_IS_LIST = (True,) + OUTPUT_TOOLTIPS = ("Panels (Shapely Polygons)",) + FUNCTION = "func" + DESCRIPTION = ("Obtains a list of panels from the provided layout." + "The panels are sorted with respect to hierarchy and defined reading order." + "For example: A vertical cut in left-to-right reading order will place, on the list, the panels" + " from the left side of the cut before to the panels on the right side of the cut.") + + def func(self, layout, canvas, margin, reading_dir): + panels = CutNode.process_tree(layout, canvas, margin, reading_dir) + return (panels,) + + +class CanvasPanel: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "width": ("INT", {"default": 2480, "min": 0, "max": 5000}), + "height": ("INT", {"default": 3508, "min": 0, "max": 5000}), + } + } + + CATEGORY = CATEGORY_PATH + RETURN_TYPES = (IO_Types.PANEL,) + OUTPUT_TOOLTIPS = ("Panel (Shapely Polygon)",) + FUNCTION = "func" + DESCRIPTION = "Canvas bounds for panel related operations." + + def func(self, width, height): + canvas = box(0, 0, width, height) + return (canvas,) + + +class Panel2Mask: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "panel": (IO_Types.PANEL,), + "canvas": (IO_Types.PANEL,), + } + } + + CATEGORY = CATEGORY_PATH + RETURN_TYPES = ("MASK",) + FUNCTION = "func" + DESCRIPTION = "A mask representing the panel area on canvas." + + def func(self, panel, canvas): + """Assumes no holes & no multipolygons.""" + + xmin, ymin, xmax, ymax = canvas.bounds # min should be zero, but better safe than sorry later + w, h = int(xmax - xmin), int(ymax - ymin) + + img = Image.new("L", (w, h), 0) + draw = ImageDraw.Draw(img) + coords = [(x - xmin, y - ymin) for x, y in panel.exterior.coords] + draw.polygon(coords, fill=1, outline=1) + + # Convert to torch tensor (1, H, W) + arr = np.array(img, dtype=np.uint8) + tensor = torch.from_numpy(arr).unsqueeze(0) + print(f"tensor dim for mask {tensor.shape}") + return torch.from_numpy(arr).unsqueeze(0) + + +class PreviewPanelLayout(SavePanelLayout): + def __init__(self): + self.output_dir = folder_paths.get_temp_directory() + self.type = "temp" + self.prefix_append = "_temp_" + ''.join(random.choice("abcdefghijklmnopqrstupvxyz") for x in range(5)) + self.compress_level = 1 + + DESCRIPTION = ("Preview the Panel Layout.\n" + "Without any margins or any panel adjustments.") + + @classmethod + def INPUT_TYPES(cls): + types = SavePanelLayout.INPUT_TYPES() + del types["required"]["filename_prefix"] + return types + + +class PreviewPanels: + def __init__(self): + self.output_dir = folder_paths.get_temp_directory() + self.type = "temp" + self.prefix_append = "_temp_" + ''.join(random.choice("abcdefghijklmnopqrstupvxyz") for x in range(5)) + self.compress_level = 1 + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "panels": (IO_Types.PANEL,), + }, + "optional": { + "canvas": (IO_Types.PANEL,) + }, + "hidden": { + "prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO" + }, + } + + RETURN_TYPES = () + FUNCTION = "func" + INPUT_IS_LIST = True + OUTPUT_NODE = True + CATEGORY = CATEGORY_PATH + + def func(self, panels, canvas: Optional[list[Polygon]] = None, prompt=None, extra_pnginfo=None): + canvas = None if canvas is None else canvas[0] + prompt = None if prompt is None else prompt[0] + extra_pnginfo = None if extra_pnginfo is None else extra_pnginfo[0] + + image = panels_to_image(panels, annotate_color="white", canvas=canvas, ) + + #filename_prefix += self.prefix_append + full_output_folder, filename, counter, subfolder, filename_prefix = ( + folder_paths.get_save_image_path("_", self.output_dir)) + + metadata = None + if not args.disable_metadata: + metadata = PngInfo() + if prompt is not None: + metadata.add_text("prompt", json.dumps(prompt)) + if extra_pnginfo is not None: + for x in extra_pnginfo: + metadata.add_text(x, json.dumps(extra_pnginfo[x])) + filename_with_batch_num_removed = filename.replace("%batch_num%", "") + file = f"{filename_with_batch_num_removed}_{counter:05}_.png" + image.save(os.path.join(full_output_folder, file), pnginfo=metadata, compress_level=self.compress_level) + + results = list() + results.append({ + "filename": file, + "subfolder": subfolder, + "type": self.type + }) + return {"ui": {"images": results}} + +# endregion Core Nodes + +# region Layout Generators + + +class GridPanelLayoutGenerator: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "rows": ("INT", {"default": 4, "min": 1, "max": 32}), + "columns": ("INT", {"default": 2, "min": 1, "max": 32}), + "vcut_first": ("BOOLEAN", {"default": False, "tooltip": + "Whether the cut orientation in the first node is vertical or horizontal." + "Cuts' width decreases the higher the depth on the layout hierarchy." + "The first cut(s) will be the widest."}), + } + } + + CATEGORY = CATEGORY_PATH + RETURN_TYPES = (IO_Types.PANEL_LAYOUT,) + FUNCTION = "func" + DESCRIPTION = "Generates a grid like layout (not its panels, use BuildLayoutPanels node to get the panels)." + + @staticmethod + def grid_cut_tree(rows: int, cols: int, vertical_first: bool = False) -> Optional[CutNode]: + """ + Generate a cut tree that produces an even grid of panels. + + Args: + rows: number of rows + cols: number of columns + vertical_first: whether to slice vertically first (default True) + + Returns: + CutNode root representing the grid + """ + if rows <= 0 or cols <= 0: + return None + if rows == 1 and cols == 1: + return None # just one panel, no cuts + + def build_grid(r: int, c: int, cut_vertical: bool) -> Optional[CutNode]: + if r == 1 and c == 1: + return None + + if cut_vertical and c > 1: + # vertical cut into c parts + node = CutNode(vertical=True, angle=0, split_mode=0) + for _ in range(c): + child = build_grid(r, 1, not cut_vertical) if r > 1 else None + node.add_child(child) + return node + elif not cut_vertical and r > 1: + # horizontal cut into r parts + node = CutNode(vertical=False, angle=0, split_mode=0) + for _ in range(r): + child = build_grid(1, c, not cut_vertical) if c > 1 else None + node.add_child(child) + return node + else: + # no further subdivision + return None + + return build_grid(rows, cols, vertical_first) + + def func(self, rows, columns, vcut_first): + layout = self.grid_cut_tree(rows, columns, vertical_first=vcut_first) + return (layout,) + + +class RandomPanelLayoutGenerator: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "num_panels": ("INT", {"default": 5, "min": 2, "max": 32}), + "min_angle": ("INT", {"default": -30, "min": -45, "max": 45}), + "max_angle": ("INT", {"default": 30, "min": -45, "max": 45}), + "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff, "control_after_generate": True}) + } + } + + CATEGORY = CATEGORY_PATH + RETURN_TYPES = (IO_Types.PANEL_LAYOUT,) + FUNCTION = "func" + DESCRIPTION = "Generates random panel layout within the provided parameters." + + @staticmethod + def random_cut_tree( + num_panels: int = 5, + angle_min: int = -45, + angle_max: int = 45, + seed: Optional[int] = None + ) -> Optional[CutNode]: + """ + Generate a random cut tree that produces approximately `num_panels` panels. + """ + if num_panels <= 0: + return None + if num_panels == 1: + return None # single leaf + + rng = random.Random(seed) + + # recursive builder + def build(panels_left: int) -> CutNode: + # choose random orientation + vertical = rng.choice([True, False]) + # random angle + angle = rng.randint(angle_min, angle_max) + # random split mode + split_mode = rng.choice(list(SPLIT_MODES.keys())) + + # enforce cut count rules + cuts = rng.randint(1, 3) if split_mode == 0 else 1 + + # create node + node = CutNode(vertical, angle, split_mode) + + # always creates 2 children + # decide how many panels to allocate to each branch + if panels_left <= 2: + # exactly 2 panels → both leaves + node.add_child(None) + node.add_child(None) + return node + + # randomly split the remaining panels + left_panels = rng.randint(1, panels_left - 1) + right_panels = panels_left - left_panels + + # recursively build children + node.add_child(build(left_panels) if left_panels > 1 else None) + node.add_child(build(right_panels) if right_panels > 1 else None) + + return node + + return build(num_panels) + + def func(self, num_panels, min_angle, max_angle, seed): + cut_tree = self.random_cut_tree(num_panels, min_angle, max_angle, seed) + return (cut_tree,) + + +class MutatePanelLayout: + @classmethod + def INPUT_TYPES(cls): + return {"required": + { + "layout": (IO_Types.PANEL_LAYOUT,), + "add_cut_prob": ("FLOAT", {"default": 0.1, "min": 0, "max": 1, "step": .005}), + "rem_cut_prob": ("FLOAT", {"default": 0.1, "min": 0, "max": 1, "step": .005}), + "num_cut_prob": ("FLOAT", {"default": 0.1, "min": 0, "max": 1, "step": .005}), + "ang_adj_prob": ("FLOAT", {"default": 0.1, "min": 0, "max": 1, "step": .005}), + "typ_cut_prob": ("FLOAT", {"default": 0.05, "min": 0, "max": 1, "step": .005}), + "max_ang_delt": ("INT", {"default": 15, "min": 0, "max": 45}), + "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff, "control_after_generate": True}) + } + } + + CATEGORY = CATEGORY_PATH + RETURN_TYPES = (IO_Types.PANEL_LAYOUT,) + OUTPUT_TOOLTIPS = ("Panel Layout",) + FUNCTION = "func" + DESCRIPTION = "Modifies an existing Panel Layout." + + @staticmethod + def mutate_tree( + node: CutNode, + prob_add: float = 0.1, + prob_remove: float = 0.1, + prob_change_cuts: float = 0.1, + prob_change_angle: float = 0.1, + prob_change_split_mode: float = 0.05, # NEW + max_angle_delta: int = 15, + seed: Optional[int] = None, + ): + """ + Recursively mutate a CutNode tree in place according to given probabilities. + Deterministic if `seed` is provided. + """ + if node is None: + raise ValueError("node is can not be None") + + node = deepcopy(node) + rng = random.Random(seed) + + def _mutate_node(n: CutNode): + # 1) Add a child + if n.split_mode == 0 and rng.random() < prob_add: + insert_idx = rng.randint(0, len(n.children)) + n.children.insert(insert_idx, None) + + # 2) Remove a child + if n.split_mode == 0 and n.cuts > 0 and rng.random() < prob_remove: + removable_indices = [i for i, c in enumerate(n.children) if c is not None or len(n.children) > 1] + if removable_indices: + idx = rng.choice(removable_indices) + n.children.pop(idx) + + # 3) Change number of cuts + if n.split_mode == 0 and rng.random() < prob_change_cuts: + target_cuts = max(1, rng.randint(1, len(n.children))) + current_cuts = len(n.children) - 1 + if target_cuts > current_cuts: + for _ in range(target_cuts - current_cuts): + insert_idx = rng.randint(0, len(n.children)) + n.children.insert(insert_idx, None) + elif target_cuts < current_cuts: + removable_indices = [i for i, c in enumerate(n.children) if c is None] + rng.shuffle(removable_indices) + for idx in removable_indices[:current_cuts - target_cuts]: + n.children.pop(idx) + + # 4) Change angle + if rng.random() < prob_change_angle: + delta = rng.randint(-max_angle_delta, max_angle_delta) + n.angle += delta + + # 5) Change split mode + if rng.random() < prob_change_split_mode: + available_modes = [k for k in SPLIT_MODES.keys() if k != n.split_mode] + if available_modes: + n.split_mode = rng.choice(available_modes) + # ensure children are compatible: non-midpoint → only 1 cut + if n.split_mode != 0 and len(n.children) > 2: + n.children = n.children[:2] + + # 6) Recurse + for child in n.children: + if child is not None: + _mutate_node(child) + + _mutate_node(node) + return node + + def func(self, layout, add_cut_prob, rem_cut_prob, num_cut_prob, ang_adj_prob, typ_cut_prob, max_ang_delt, seed): + tree = self.mutate_tree(layout, add_cut_prob, rem_cut_prob, num_cut_prob, + ang_adj_prob, typ_cut_prob, max_ang_delt, seed) + return (tree,) + +# endregion Layout Generators + +# region Polygon Operations + + +class OffsetPolygonBounds: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "polygon": (IO_Types.PANEL,), + "offset": ("FLOAT", {"default": 32, "min": -1000, "max": 1000, "step": 0.5, "tooltip": + "The distance (in pixels) to offset the polygons' edges"}) + }, + "optional": { + "bbox_snap": (IO_Types.BBOX_SNAP, {"tooltip": + "Constrain the adjustment operation with respect to a bounding box"}) + } + } + + RETURN_TYPES = (IO_Types.PANEL,) + FUNCTION = "func" + DESCRIPTION = "'Expand' the polygon, when using positive values; or 'erode' it using negative values." + + def func(self, polygon, offset: float, bbox_snap: Optional[BBoxSnap] = None): + new_panel = self.offset_panel(polygon, offset, bbox_snap) + return (new_panel,) + + @staticmethod + def offset_panel(poly: Polygon, + distance: float, + bbox_snap: Optional[BBoxSnap] = None, + tol: float = 1e-6) -> Polygon: + """ + Buffer polygon inward/outward while optionally snapping vertices along the bounding box edges. + + :param poly: Input polygon + :param distance: Buffer distance (positive = dilation, negative = erosion) + :param bbox_snap: Optional (xmin, ymin, xmax, ymax, snap_on_box) bounding box + snap_on_box: + - True: snap coordinates that lie on bbox edges + - False: snap coordinates that do NOT lie on bbox edges + :param tol: Tolerance to consider a vertex on bbox edge + :return: Buffered polygon with snapped vertices + """ + if poly.is_empty: + return poly + + # Identify which coordinates (X or Y) are on bbox edges + snap_info = {} + if bbox_snap is not None: + xmin, ymin, xmax, ymax = bbox_snap.as_tuple() + for i, (x, y) in enumerate(poly.exterior.coords): + on_x_edge = abs(x - xmin) < tol or abs(x - xmax) < tol + on_y_edge = abs(y - ymin) < tol or abs(y - ymax) < tol + + if bbox_snap.snap_on_bbox: + # snap coordinates that ARE on bbox edges + snap_x = x if on_x_edge else None + snap_y = y if on_y_edge else None + else: + # snap coordinates that are NOT on bbox edges + snap_x = x if not on_x_edge else None + snap_y = y if not on_y_edge else None + + if snap_x is not None or snap_y is not None: + snap_info[i] = (snap_x, snap_y) + + # Buffer polygon + buffered = poly.buffer(distance, join_style=2) + #buffered = shapely.buffer(poly, distance, join_style=2) + if buffered.is_empty: + return buffered + + # Snap X or Y components back + if bbox_snap is not None and snap_info: + coords = list(buffered.exterior.coords) + for i, (snap_x, snap_y) in snap_info.items(): + if i < len(coords): + x_new = snap_x if snap_x is not None else coords[i][0] + y_new = snap_y if snap_y is not None else coords[i][1] + coords[i] = (x_new, y_new) + buffered = Polygon(coords) + + return buffered + + +class BBoxSnapNode: + @classmethod + def INPUT_TYPES(cls): + return {"required": + { + "canvas": (IO_Types.PANEL,), + "snap_if": ("BOOLEAN", {"default": False, + "label_on": "on a bbox's edge", "label_off": "not on a bbox's edge", + "tooltip": + "If True, polygons points coordinates coinciding withthe given canvas' edges are not changed; " + "their source points may still be moved, but do so without leaving the canvas' edges.\n" + "This can be used to add extra space between panels.\n\n" + "If False, only point coordinates that are on the box are moved.\n" + "This can be used to add the page's margins."}), + } + } + + CATEGORY = CATEGORY_PATH + RETURN_TYPES = (IO_Types.BBOX_SNAP,) + FUNCTION = "func" + DESCRIPTION = "Optional constraint for the 'Adjust Panel' operation." + + def func(self, canvas, snap_if): + bbox_snap = BBoxSnap.from_polygon(canvas, snap_if) + return (bbox_snap,) + + +class RotatePolygon: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "polygon": (IO_Types.PANEL,), + "angle": ("FLOAT", {"default": 0.0, "min": -360.0, "max": 360.0}), + "origin": ("STRING", {"default": "center"}), # "center", "centroid", or (x,y) + } + } + + RETURN_TYPES = (IO_Types.PANEL,) + FUNCTION = "func" + CATEGORY = CATEGORY_PATH + + def func(self, polygon, angle, origin): + new_poly = rotate(polygon, angle, origin=origin) + return (new_poly,) + + +class ScalePolygon: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "polygon": (IO_Types.PANEL,), + "xfact": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": .001}), + "yfact": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": .001}), + "origin": ("STRING", {"default": "center"}), + } + } + + RETURN_TYPES = (IO_Types.PANEL,) + FUNCTION = "func" + CATEGORY = CATEGORY_PATH + + def func(self, polygon, xfact, yfact, origin): + new_poly = scale(polygon, xfact=xfact, yfact=yfact, origin=origin) + return (new_poly,) + + +class TranslatePolygon: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "polygon": (IO_Types.PANEL,), + "xoff": ("FLOAT", {"default": 0.0, "min": -1000.0, "max": 1000.0}), + "yoff": ("FLOAT", {"default": 0.0, "min": -1000.0, "max": 1000.0}), + } + } + + RETURN_TYPES = (IO_Types.PANEL,) + FUNCTION = "func" + CATEGORY = CATEGORY_PATH + + def func(self, polygon, xoff, yoff): + new_poly = translate(polygon, xoff=xoff, yoff=yoff) + return (new_poly,) + + +class BevelPolygon: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "panel": (IO_Types.PANEL,), + "curvature": ("INT", {"default": 32, "min": 1, "max": 256}), + "iterations": ("INT", {"default": 4, "min": 1, "max": 9}), + "buffer_res": ("INT", {"default": 32, "min": 8, "max": 128}), + } + } + + RETURN_TYPES = (IO_Types.PANEL,) + FUNCTION = "func" + CATEGORY = CATEGORY_PATH + + def func(self, panel: Polygon, curvature, iterations, buffer_res): + p = panel + for _ in range(iterations): + out = p.buffer(curvature, join_style=3, resolution=buffer_res) + back = out.buffer(-curvature, join_style=3, resolution=buffer_res) + if back.is_empty: + return (p,) + if back.geom_type == "Polygon": + p = back + else: + polys = [g for g in getattr(back, "geoms", []) if g.geom_type == "Polygon"] + if not polys: + return p + p = max(polys, key=lambda g: g.area) + return (p,) + +# endregion Polygon Operations + +# region LIST OPERATIONS + + +def str_to_slice(slice_str): + # 1. Validate and clean slice string + if not re.fullmatch(r"\s*-?\d*\s*(:\s*-?\d*\s*(:\s*-?\d*\s*)?)?", slice_str): + raise ValueError(f"Invalid slice string: {slice_str}") + # 2. Parse slice safely into slice object + if ":" in slice_str: + parts = [int(p) if p else None for p in slice_str.split(":")] + sl = slice(*parts) + else: + # Single index case, e.g. [3] + sl = int(slice_str) + sl = slice(*[sl, sl + 1]) + + return sl + + +class SliceListPanel: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "panels": (IO_Types.PANEL,), + "_slice": ("STRING", {"default": "0:", "forceInput": False}) + }, + } + + CATEGORY = CATEGORY_PATH + INPUT_IS_LIST = True + RETURN_TYPES = (IO_Types.PANEL,) + OUTPUT_IS_LIST = (True,) + OUTPUT_TOOLTIPS = ("Panels (Shapely Polygons)",) + FUNCTION = "func" + + def func(self, panels, _slice): + """ + Apply a function to elements of a list selected by a slice string (e.g. [1:5:2], [::-1]). + Returns a new modified copy of the list. + """ + sl = str_to_slice(_slice[0]) + result = deepcopy(panels) + return (result[sl],) + + +class ListTransferPanel: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "to_panels": (IO_Types.PANEL,), + "from_panels": (IO_Types.PANEL,), + "to_slice": ("STRING", {"default": "0:", "forceInput": False}), + "from_slice": ("STRING", {"default": "0:", "forceInput": False}) + }, + } + + CATEGORY = CATEGORY_PATH + INPUT_IS_LIST = True + RETURN_TYPES = (IO_Types.PANEL,) + OUTPUT_IS_LIST = (True,) + OUTPUT_TOOLTIPS = ("Panels (Shapely Polygons)",) + FUNCTION = "func" + + def func(self, to_panels, from_panels, to_slice, from_slice): + to_slice, from_slice = to_slice[0], from_slice[0] + to_slice = str_to_slice(to_slice) + from_slice = str_to_slice(from_slice) + + to_list = deepcopy(to_panels) + from_list_slice = deepcopy(from_panels[from_slice]) + + to_list[to_slice] = from_list_slice + return (to_list,) + + +class ListAppendPanel: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "panels": (IO_Types.PANEL,), + "to_append": (IO_Types.PANEL,), + }, + } + + CATEGORY = CATEGORY_PATH + INPUT_IS_LIST = True + RETURN_TYPES = (IO_Types.PANEL,) + OUTPUT_IS_LIST = (True,) + OUTPUT_TOOLTIPS = ("Panels (Shapely Polygons)",) + FUNCTION = "func" + + def func(self, panels: list[Polygon], to_append): + panels = deepcopy(panels) + panels.extend(deepcopy(to_append)) + return (panels,) + +# endregion PANEL LIST OPERATIONS + +# region Other Nodes + + +class DrawPanelsEdges: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "panels": (IO_Types.PANEL,), + "canvas": (IO_Types.PANEL,), + "stroke_width": ("INT", {"default": 8, "min": 1, "max": 512}), + "color_alpha": ("INT", {"default": 255, "min": 1, "max": 255}), + "stroke_color": ("COLOR", {"default": "#000000"}), # requires bmad or mtb nodes + "upscale": ("INT", {"default": 4, "min": 1, "max": 8, "tooltip": + "The lines are drawn upscaled by this factor to anti-alias the jaggies away."}), + #"pad": ("INT", {"default": 0, "min": 0, "max": 256, "tooltip": "Safety margin. Use this when some " + # "edge falls outside of the provided canvas."}), + # TODO is pad really needed? may need to review the code... + }, + } + + RETURN_TYPES = ("IMAGE", "MASK") + FUNCTION = "func" + INPUT_IS_LIST = True + CATEGORY = CATEGORY_PATH + + def func(self, panels, canvas, stroke_width, stroke_color, color_alpha, upscale): + canvas = canvas[0] + stroke_width = stroke_width[0] + stroke_color = stroke_color[0] + color_alpha = color_alpha[0] + upscale = upscale[0] + + # prepare Color input + if isinstance(stroke_color, str): + stroke_color = int(stroke_color.lstrip("#"), 16) + if isinstance(stroke_color, int): + color = ((stroke_color & 0xFF0000) >> 16, + (stroke_color & 0x00FF00) >> 8, + (stroke_color & 0x0000FF), + color_alpha) + else: # suppose the following without checking -> isinstance(stroke_color, tuple) and len(x) == 3 + color = (stroke_color[0], stroke_color[1], stroke_color[2], color_alpha) + + img = draw_polygon_contours(panels, canvas, stroke_color=color, stroke_width=stroke_width, + pad=0, upscale=upscale) + + i = node_helpers.pillow(ImageOps.exif_transpose, img) + if i.mode == 'I': + i = i.point(lambda p: p * (1 / 255)) + image = i + + image = np.array(image).astype(np.float32) / 255.0 + image = torch.from_numpy(image)[None,] + mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0 + mask = torch.from_numpy(mask) + + return (image, mask) + + +class PolygonUnwrappedBounds: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "polygon": (IO_Types.PANEL,), + } + } + + # Four separate integer outputs + RETURN_TYPES = ("INT", "INT", "INT", "INT") + RETURN_NAMES = ("min_x", "min_y", "max_x", "max_y") + FUNCTION = "func" + CATEGORY = CATEGORY_PATH + DESCRIPTION = "Unwrapped polygon.bounds with rounded values. For potential use with other node packages." + + def func(self, polygon): + minx, miny, maxx, maxy = polygon.bounds + return ( + round(minx), + round(miny), + round(maxx), + round(maxy), + ) + + +class PolygonBounds: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "polygon": (IO_Types.PANEL,), + } + } + + RETURN_TYPES = (IO_Types.BBOX,) + FUNCTION = "func" + CATEGORY = CATEGORY_PATH + DESCRIPTION = "polygon.bounds" + + def func(self, polygon): + bbox = polygon.bounds + return (bbox,) + + +class BBoxFromInts: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "xmin": ("INT", {"default": 0}), + "ymin": ("INT", {"default": 0}), + "xmax": ("INT", {"default": 64}), + "ymax": ("INT", {"default": 64}), + } + } + + RETURN_TYPES = (IO_Types.BBOX,) + FUNCTION = "func" + CATEGORY = CATEGORY_PATH + + def func(self, xmin, ymin, xmax, ymax): + return ((int(xmin), int(ymin), int(xmax), int(ymax)),) + + +class PasteCrops: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "base_image": ("IMAGE",), # (1, H, W, C), float32 [0,1] + "cropped_images": ("IMAGE",), # list of crops (1, h, w, C) + "masks": ("MASK",), # list of masks (1, h, w) or (1, h, w, 1) + "bboxes": ("BBOX",), # list of (x0, y0, x1, y1) + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "func" + CATEGORY = CATEGORY_PATH + DESCRIPTION = \ + ("Pastes the cropped_images into the base_image in the area defined by the corresponding bboxes.\n" + "If an image (or mask) does not match its bbox size, it is resized to fit.\n" + "To avoid quality loss keep the image-mask-bbox pairs with the same dimensions.") + + INPUT_IS_LIST = True + + def func(self, base_image: torch.Tensor, cropped_images, masks, bboxes): + base = base_image[0].clone() # (1, H, W, C) + + for crop, mask, bbox in zip(cropped_images, masks, bboxes): + x0, y0, x1, y1 = bbox + w, h = x1 - x0, y1 - y0 + + # Ensure mask has channel dimension + if mask.ndim == 3: # (1, H, W) + mask = mask.unsqueeze(-1) # (1, H, W, 1) + + # Resize or skip if exact match + crop_h, crop_w = crop.shape[1:3] + if (crop_h, crop_w) == (h, w): + crop_resized = crop + else: + crop_resized = F.interpolate( + crop.permute(0, 3, 1, 2), size=(h, w), mode="bilinear", align_corners=False + ).permute(0, 2, 3, 1) + + # Same as previous step for the mask + mask_h, mask_w = mask.shape[1:3] + if (mask_h, mask_w) == (h, w): + mask_resized = mask + else: + mask_resized = F.interpolate( + mask.permute(0, 3, 1, 2), size=(h, w), mode="bilinear", align_corners=False + ).permute(0, 2, 3, 1) + + # Blend into base + region = base[:, y0:y1, x0:x1, :] # (1, h, w, C) + blended = region * (1 - mask_resized) + crop_resized * mask_resized + base[:, y0:y1, x0:x1, :] = blended + + return (base,) + +# endregion Other Nodes + + + + +NODE_CLASS_MAPPINGS = { + "bmad_CanvasPanel": CanvasPanel, + "bmad_LoadPanelLayout": LoadPanelLayout, + "bmad_SavePanelLayout": SavePanelLayout, + "bmad_StringDecodePanelLayout": StringDecodePanelLayout, + "bmad_StringEncodePanelLayout": StringEncodePanelLayout, + "bmad_PreviewPanelLayout": PreviewPanelLayout, + "bmad_PreviewPanels": PreviewPanels, + + "bmad_OffsetPolygonBounds": OffsetPolygonBounds, + "bmad_BBoxSnap": BBoxSnapNode, + + "bmad_Panel2Mask": Panel2Mask, + "bmad_DrawPanelsEdges": DrawPanelsEdges, + "bmad_RotatePolygon": RotatePolygon, + "bmad_ScalePolygon": ScalePolygon, + "bmad_TranslatePolygon": TranslatePolygon, + "bmad_BevelPolygon": BevelPolygon, + + "bmad_BuildLayoutPanels": BuildLayoutPanels, + "bmad_RandomPanelLayoutGenerator": RandomPanelLayoutGenerator, + "bmad_GridPanelLayoutGenerator": GridPanelLayoutGenerator, + "bmad_MutatePanelLayout": MutatePanelLayout, + + "bmad_PolygonBounds": PolygonBounds, + "bmad_PolygonUnwrappedBounds": PolygonUnwrappedBounds, + "bmad_PasteCrops": PasteCrops, + "bmad_BBoxFromInts": BBoxFromInts, + + "bmad_SliceList_Panels": SliceListPanel, + "bmad_ListTransferPanel": ListTransferPanel, + "bmad_ListAppendPanel": ListAppendPanel, +} + +# A dictionary that contains the friendly/humanly readable titles for the nodes +NODE_DISPLAY_NAME_MAPPINGS = { + "bmad_CanvasPanel": "Canvas Panel", + + "bmad_LoadPanelLayout": "Load Panel Layout", + "bmad_SavePanelLayout": "Save Panel Layout", + "bmad_StringDecodePanelLayout": "String Decode Panel Layout", + "bmad_StringEncodePanelLayout": "String Encode Panel Layout", + "bmad_PreviewPanelLayout": "Preview Panel Layout", + "bmad_PreviewPanels": "Preview Panels", + + "bmad_OffsetPolygonBounds": "Offset Polygon Bounds", + "bmad_BBoxSnap": "BBoxSnap", + + "bmad_Panel2Mask": "Panel to Mask", + "bmad_DrawPanelsEdges": "Draw Panels Edges", + "bmad_RotatePolygon": "Rotate Polygon", + "bmad_ScalePolygon": "Scale Polygon", + "bmad_TranslatePolygon": "Translate Polygon", + "bmad_BevelPolygon": "Bevel Polygon", + + "bmad_BuildLayoutPanels": "Build Layout Panels", + "bmad_RandomPanelLayoutGenerator": "Random Panel Layout Generator", + "bmad_GridPanelLayoutGenerator": "Grid Panel Layout Generator", + "bmad_MutatePanelLayout": "Mutate Panel Layout", + + "bmad_PolygonBounds": "Polygon.bounds", + "bmad_PolygonUnwrappedBounds": "Polygon.bounds (unwrapped)", + "bmad_PasteCrops": "Paste Crops with Masks", + "bmad_BBoxFromInts": "BBox from Ints", + + "bmad_SliceList_Panels": "Slice Panels List", + "bmad_ListTransferPanel": "List Transfer Panels", + "bmad_ListAppendPanel": "List Append Panels", +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3d2fff6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "comfyui_panels" +description = "Comics/Manga like panel layouts." +version = "1.0.0" +license = { file = "LICENSE" } +dependencies = ["shapely==2.1.1", "matplotlib==3.10.6"] + +[project.urls] +Repository = "https://github.com/bmad4ever/comfyui-panels" + +[tool.comfy] +PublisherId = "bmad4ever" +DisplayName = "comfyui-panels" +Icon = "" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..da297a7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +shapely==2.1.1 +matplotlib==3.10.6 \ No newline at end of file diff --git a/sample_layouts/PanelLayout (1).png b/sample_layouts/PanelLayout (1).png new file mode 100644 index 0000000..6600c1b Binary files /dev/null and b/sample_layouts/PanelLayout (1).png differ diff --git a/sample_layouts/PanelLayout (10).png b/sample_layouts/PanelLayout (10).png new file mode 100644 index 0000000..5102da2 Binary files /dev/null and b/sample_layouts/PanelLayout (10).png differ diff --git a/sample_layouts/PanelLayout (11).png b/sample_layouts/PanelLayout (11).png new file mode 100644 index 0000000..8786972 Binary files /dev/null and b/sample_layouts/PanelLayout (11).png differ diff --git a/sample_layouts/PanelLayout (12).png b/sample_layouts/PanelLayout (12).png new file mode 100644 index 0000000..ffd6c0d Binary files /dev/null and b/sample_layouts/PanelLayout (12).png differ diff --git a/sample_layouts/PanelLayout (13).png b/sample_layouts/PanelLayout (13).png new file mode 100644 index 0000000..9bb0e8f Binary files /dev/null and b/sample_layouts/PanelLayout (13).png differ diff --git a/sample_layouts/PanelLayout (14).png b/sample_layouts/PanelLayout (14).png new file mode 100644 index 0000000..ac13c0f Binary files /dev/null and b/sample_layouts/PanelLayout (14).png differ diff --git a/sample_layouts/PanelLayout (15).png b/sample_layouts/PanelLayout (15).png new file mode 100644 index 0000000..662f843 Binary files /dev/null and b/sample_layouts/PanelLayout (15).png differ diff --git a/sample_layouts/PanelLayout (16).png b/sample_layouts/PanelLayout (16).png new file mode 100644 index 0000000..aab08b9 Binary files /dev/null and b/sample_layouts/PanelLayout (16).png differ diff --git a/sample_layouts/PanelLayout (17).png b/sample_layouts/PanelLayout (17).png new file mode 100644 index 0000000..aa3567f Binary files /dev/null and b/sample_layouts/PanelLayout (17).png differ diff --git a/sample_layouts/PanelLayout (18).png b/sample_layouts/PanelLayout (18).png new file mode 100644 index 0000000..8815dc6 Binary files /dev/null and b/sample_layouts/PanelLayout (18).png differ diff --git a/sample_layouts/PanelLayout (19).png b/sample_layouts/PanelLayout (19).png new file mode 100644 index 0000000..36d9c8a Binary files /dev/null and b/sample_layouts/PanelLayout (19).png differ diff --git a/sample_layouts/PanelLayout (2).png b/sample_layouts/PanelLayout (2).png new file mode 100644 index 0000000..69991cb Binary files /dev/null and b/sample_layouts/PanelLayout (2).png differ diff --git a/sample_layouts/PanelLayout (20).png b/sample_layouts/PanelLayout (20).png new file mode 100644 index 0000000..8673a16 Binary files /dev/null and b/sample_layouts/PanelLayout (20).png differ diff --git a/sample_layouts/PanelLayout (21).png b/sample_layouts/PanelLayout (21).png new file mode 100644 index 0000000..762a66b Binary files /dev/null and b/sample_layouts/PanelLayout (21).png differ diff --git a/sample_layouts/PanelLayout (22).png b/sample_layouts/PanelLayout (22).png new file mode 100644 index 0000000..b61bd10 Binary files /dev/null and b/sample_layouts/PanelLayout (22).png differ diff --git a/sample_layouts/PanelLayout (23).png b/sample_layouts/PanelLayout (23).png new file mode 100644 index 0000000..1bb23c7 Binary files /dev/null and b/sample_layouts/PanelLayout (23).png differ diff --git a/sample_layouts/PanelLayout (24).png b/sample_layouts/PanelLayout (24).png new file mode 100644 index 0000000..767e14e Binary files /dev/null and b/sample_layouts/PanelLayout (24).png differ diff --git a/sample_layouts/PanelLayout (25).png b/sample_layouts/PanelLayout (25).png new file mode 100644 index 0000000..b09b75f Binary files /dev/null and b/sample_layouts/PanelLayout (25).png differ diff --git a/sample_layouts/PanelLayout (26).png b/sample_layouts/PanelLayout (26).png new file mode 100644 index 0000000..a4b47b2 Binary files /dev/null and b/sample_layouts/PanelLayout (26).png differ diff --git a/sample_layouts/PanelLayout (27).png b/sample_layouts/PanelLayout (27).png new file mode 100644 index 0000000..d29e098 Binary files /dev/null and b/sample_layouts/PanelLayout (27).png differ diff --git a/sample_layouts/PanelLayout (28).png b/sample_layouts/PanelLayout (28).png new file mode 100644 index 0000000..42e8598 Binary files /dev/null and b/sample_layouts/PanelLayout (28).png differ diff --git a/sample_layouts/PanelLayout (29).png b/sample_layouts/PanelLayout (29).png new file mode 100644 index 0000000..4f88b6c Binary files /dev/null and b/sample_layouts/PanelLayout (29).png differ diff --git a/sample_layouts/PanelLayout (3).png b/sample_layouts/PanelLayout (3).png new file mode 100644 index 0000000..24f202b Binary files /dev/null and b/sample_layouts/PanelLayout (3).png differ diff --git a/sample_layouts/PanelLayout (30).png b/sample_layouts/PanelLayout (30).png new file mode 100644 index 0000000..a736e7b Binary files /dev/null and b/sample_layouts/PanelLayout (30).png differ diff --git a/sample_layouts/PanelLayout (31).png b/sample_layouts/PanelLayout (31).png new file mode 100644 index 0000000..82bcca0 Binary files /dev/null and b/sample_layouts/PanelLayout (31).png differ diff --git a/sample_layouts/PanelLayout (32).png b/sample_layouts/PanelLayout (32).png new file mode 100644 index 0000000..710ba00 Binary files /dev/null and b/sample_layouts/PanelLayout (32).png differ diff --git a/sample_layouts/PanelLayout (33).png b/sample_layouts/PanelLayout (33).png new file mode 100644 index 0000000..d0f9b5d Binary files /dev/null and b/sample_layouts/PanelLayout (33).png differ diff --git a/sample_layouts/PanelLayout (34).png b/sample_layouts/PanelLayout (34).png new file mode 100644 index 0000000..740165a Binary files /dev/null and b/sample_layouts/PanelLayout (34).png differ diff --git a/sample_layouts/PanelLayout (35).png b/sample_layouts/PanelLayout (35).png new file mode 100644 index 0000000..7f3d839 Binary files /dev/null and b/sample_layouts/PanelLayout (35).png differ diff --git a/sample_layouts/PanelLayout (36).png b/sample_layouts/PanelLayout (36).png new file mode 100644 index 0000000..73a0a80 Binary files /dev/null and b/sample_layouts/PanelLayout (36).png differ diff --git a/sample_layouts/PanelLayout (37).png b/sample_layouts/PanelLayout (37).png new file mode 100644 index 0000000..8e25a6d Binary files /dev/null and b/sample_layouts/PanelLayout (37).png differ diff --git a/sample_layouts/PanelLayout (38).png b/sample_layouts/PanelLayout (38).png new file mode 100644 index 0000000..3d6e9e8 Binary files /dev/null and b/sample_layouts/PanelLayout (38).png differ diff --git a/sample_layouts/PanelLayout (39).png b/sample_layouts/PanelLayout (39).png new file mode 100644 index 0000000..32a0836 Binary files /dev/null and b/sample_layouts/PanelLayout (39).png differ diff --git a/sample_layouts/PanelLayout (4).png b/sample_layouts/PanelLayout (4).png new file mode 100644 index 0000000..9fda0b4 Binary files /dev/null and b/sample_layouts/PanelLayout (4).png differ diff --git a/sample_layouts/PanelLayout (40).png b/sample_layouts/PanelLayout (40).png new file mode 100644 index 0000000..97c52a6 Binary files /dev/null and b/sample_layouts/PanelLayout (40).png differ diff --git a/sample_layouts/PanelLayout (41).png b/sample_layouts/PanelLayout (41).png new file mode 100644 index 0000000..06c95d8 Binary files /dev/null and b/sample_layouts/PanelLayout (41).png differ diff --git a/sample_layouts/PanelLayout (42).png b/sample_layouts/PanelLayout (42).png new file mode 100644 index 0000000..9cedd2d Binary files /dev/null and b/sample_layouts/PanelLayout (42).png differ diff --git a/sample_layouts/PanelLayout (43).png b/sample_layouts/PanelLayout (43).png new file mode 100644 index 0000000..293367e Binary files /dev/null and b/sample_layouts/PanelLayout (43).png differ diff --git a/sample_layouts/PanelLayout (44).png b/sample_layouts/PanelLayout (44).png new file mode 100644 index 0000000..938ff4f Binary files /dev/null and b/sample_layouts/PanelLayout (44).png differ diff --git a/sample_layouts/PanelLayout (45).png b/sample_layouts/PanelLayout (45).png new file mode 100644 index 0000000..87765f0 Binary files /dev/null and b/sample_layouts/PanelLayout (45).png differ diff --git a/sample_layouts/PanelLayout (46).png b/sample_layouts/PanelLayout (46).png new file mode 100644 index 0000000..a3860ae Binary files /dev/null and b/sample_layouts/PanelLayout (46).png differ diff --git a/sample_layouts/PanelLayout (47).png b/sample_layouts/PanelLayout (47).png new file mode 100644 index 0000000..0918e28 Binary files /dev/null and b/sample_layouts/PanelLayout (47).png differ diff --git a/sample_layouts/PanelLayout (48).png b/sample_layouts/PanelLayout (48).png new file mode 100644 index 0000000..e30e96e Binary files /dev/null and b/sample_layouts/PanelLayout (48).png differ diff --git a/sample_layouts/PanelLayout (49).png b/sample_layouts/PanelLayout (49).png new file mode 100644 index 0000000..f5c2c7a Binary files /dev/null and b/sample_layouts/PanelLayout (49).png differ diff --git a/sample_layouts/PanelLayout (5).png b/sample_layouts/PanelLayout (5).png new file mode 100644 index 0000000..cf6e746 Binary files /dev/null and b/sample_layouts/PanelLayout (5).png differ diff --git a/sample_layouts/PanelLayout (50).png b/sample_layouts/PanelLayout (50).png new file mode 100644 index 0000000..473f250 Binary files /dev/null and b/sample_layouts/PanelLayout (50).png differ diff --git a/sample_layouts/PanelLayout (51).png b/sample_layouts/PanelLayout (51).png new file mode 100644 index 0000000..c4c70ff Binary files /dev/null and b/sample_layouts/PanelLayout (51).png differ diff --git a/sample_layouts/PanelLayout (52).png b/sample_layouts/PanelLayout (52).png new file mode 100644 index 0000000..46f9c73 Binary files /dev/null and b/sample_layouts/PanelLayout (52).png differ diff --git a/sample_layouts/PanelLayout (53).png b/sample_layouts/PanelLayout (53).png new file mode 100644 index 0000000..d8650c4 Binary files /dev/null and b/sample_layouts/PanelLayout (53).png differ diff --git a/sample_layouts/PanelLayout (54).png b/sample_layouts/PanelLayout (54).png new file mode 100644 index 0000000..b6a27cf Binary files /dev/null and b/sample_layouts/PanelLayout (54).png differ diff --git a/sample_layouts/PanelLayout (55).png b/sample_layouts/PanelLayout (55).png new file mode 100644 index 0000000..fa45d51 Binary files /dev/null and b/sample_layouts/PanelLayout (55).png differ diff --git a/sample_layouts/PanelLayout (56).png b/sample_layouts/PanelLayout (56).png new file mode 100644 index 0000000..8578bae Binary files /dev/null and b/sample_layouts/PanelLayout (56).png differ diff --git a/sample_layouts/PanelLayout (6).png b/sample_layouts/PanelLayout (6).png new file mode 100644 index 0000000..3d7454f Binary files /dev/null and b/sample_layouts/PanelLayout (6).png differ diff --git a/sample_layouts/PanelLayout (7).png b/sample_layouts/PanelLayout (7).png new file mode 100644 index 0000000..636cffc Binary files /dev/null and b/sample_layouts/PanelLayout (7).png differ diff --git a/sample_layouts/PanelLayout (8).png b/sample_layouts/PanelLayout (8).png new file mode 100644 index 0000000..ca8b4fc Binary files /dev/null and b/sample_layouts/PanelLayout (8).png differ diff --git a/sample_layouts/PanelLayout (9).png b/sample_layouts/PanelLayout (9).png new file mode 100644 index 0000000..586fd21 Binary files /dev/null and b/sample_layouts/PanelLayout (9).png differ diff --git a/workflows/Margins and Offset Panels.png b/workflows/Margins and Offset Panels.png new file mode 100644 index 0000000..a27bfbb Binary files /dev/null and b/workflows/Margins and Offset Panels.png differ diff --git a/workflows/Panel Layout Encode and Decode as String.png b/workflows/Panel Layout Encode and Decode as String.png new file mode 100644 index 0000000..9d16da4 Binary files /dev/null and b/workflows/Panel Layout Encode and Decode as String.png differ diff --git a/workflows/Panels Masks from Saved Layout.png b/workflows/Panels Masks from Saved Layout.png new file mode 100644 index 0000000..9d1c92d Binary files /dev/null and b/workflows/Panels Masks from Saved Layout.png differ diff --git a/workflows/Random Layout Generation.png b/workflows/Random Layout Generation.png new file mode 100644 index 0000000..c0557e6 Binary files /dev/null and b/workflows/Random Layout Generation.png differ