1st implementation.

This commit is contained in:
Bruno Madeira
2025-09-18 21:44:15 +01:00
commit 6af4637eb0
68 changed files with 1696 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.py[cod]
*$py.class
*.bak
+492
View File
@@ -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)
+21
View File
@@ -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.
+3
View File
@@ -0,0 +1,3 @@
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
+33
View File
@@ -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)
+1127
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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 = ""
+2
View File
@@ -0,0 +1,2 @@
shapely==2.1.1
matplotlib==3.10.6
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 663 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB