Files
bmad4ever-comfyui_panels/CutNode.py
T
2025-09-18 21:44:15 +01:00

493 lines
17 KiB
Python

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)