Files
rui40000-RUI-Nodes/eightdir_node.py
T
rui40000andClaude Opus 4.8 5d3834503e fix: 八方向拆分不再切断角色;全仓库参数补齐中文 tooltip
【修复】角色被格线切断(脚、手杖、飘起的斗篷被削掉)
新增 expand_beyond_cell(默认开启):格子只用来判定「这是哪个方向」,
角色的实际范围由它自身的连通区域决定,按质心归属确保邻居不混入。
实测 8/8 方向的裁剪框边缘 alpha 从 1.00(内容顶到边界=被切断)
降到 0.00,S 方向高度 326→356、E 方向宽度 150→188 把缺的部分找了回来。
代价是需要两遍扫描(先求全序列并集框再提取),耗时 4.7s→14.9s。

【规则】每个参数都必须有中文 tooltip,作为以后的统一约定
全仓库 26 个节点 169 个参数,此前缺 115 个,现已 100% 覆盖。
tooltip 写「怎么调」而不只是「是什么」:给取值区间的实际影响、
推荐值与踩坑提示(如 OpenAI/ZenMux 的地址栏不能带 :// ,
素材拆分节点用于动画序列时顺序会漂移等)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 19:07:01 +08:00

409 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
八方向雪碧图序列拆分节点(Ruinode)
===================================
用于「8 方向行走动画」制作管线:把每帧排布着 8 个朝向的雪碧图序列,
拆成 8 条各自独立、可直接成片的动画序列。
典型管线:
角色图 → (GPTimage2) 八方向静态图 → (Seedance 首尾帧) 循环行走视频
→ VHS「Load Video」转序列帧(可选帧率)
→ 【本节点】拆分 + 方向编号 + 分组
→ 8×SaveImage 出序列帧,8×VHS「Video Combine」出透明 webm
为什么用固定网格而不是连通区域拆分(本仓库的 RuiSpriteSplitterRGBA):
- 连通区域按包围盒排序,角色走动时位置会浮动,一旦跨过排序的行界,
方向对应关系就错乱 —— 几十上百帧里错一帧,整条动画就废了;
- 连通区域按各自 bbox 裁剪,每个 sprite 尺寸不同,无法直接合成视频。
固定网格没有这两个问题:格子位置恒定,方向对应天然稳定,尺寸也一致。
(连通区域拆分依然更适合单张静态合图,两者各有用武之地。)
白底转透明的关键点:角色身上常有白色衣物,按亮度阈值一刀切会把白衬衫
一起掏空。这里改为**从画面边缘做连通性判断**:只有与边缘相连的白色才算背景,
被角色包围的白色(衣服、高光)一律保留。
"""
import numpy as np
import torch
try:
import scipy.ndimage as ndi
_HAS_SCIPY = True
except Exception: # 理论上 ComfyUI 环境都有
_HAS_SCIPY = False
MAX_DIRS = 8
_BG_MODES = {
"白底转透明(推荐)": "white",
"已带透明通道": "keep",
"不处理(输出不透明)": "none",
}
# 与 3×3 中间留空的常见排布对应:行 1 面向观众、行 3 背对观众
_DEFAULT_NAMES = "SW,S,SE,W,E,NW,N,NE"
def _white_to_alpha(rgb, threshold, softness):
"""
白底 → alpha。rgb: (H,W,3) float[0,1],返回 (H,W) float[0,1]。
先按「离白色多远」算出软 alpha 保住边缘抗锯齿,再用连通性把
与画面边缘相连的白色判为背景 —— 只有这一部分才真正抹成全透明。
这样角色内部的白衬衫、白高光不会被误伤。
"""
dist = 1.0 - rgb.min(axis=2) # 纯白=0,越大越不白
cut = max(1e-4, 1.0 - float(threshold))
soft = np.clip(dist / (cut * max(1e-3, softness)), 0.0, 1.0)
near_white = dist <= cut
if _HAS_SCIPY and near_white.any():
lab, n = ndi.label(near_white)
if n > 0:
border = np.concatenate([lab[0, :], lab[-1, :], lab[:, 0], lab[:, -1]])
ids = np.unique(border)
ids = ids[ids != 0]
if ids.size:
bg = np.isin(lab, ids)
soft = np.where(bg, 0.0, soft)
else:
soft = np.where(near_white, 0.0, soft)
return soft.astype(np.float32)
def _drop_fragments(alpha, ratio):
"""
清掉远小于主体的连通碎片。
网格切分难免会把相邻格子探过来的部件(手杖尖、飘起的衣角)切进本格,
这些碎片不仅难看,还会把 auto_crop 的包围盒撑大。
按「面积不足主体 ratio 倍」判定为碎片,这样与身体相连的道具不会被误删。
"""
if ratio <= 0 or not _HAS_SCIPY:
return alpha
solid = alpha > 0.1
if not solid.any():
return alpha
lab, n = ndi.label(solid)
if n <= 1:
return alpha
areas = np.bincount(lab.ravel())
areas[0] = 0
keep = areas >= areas.max() * float(ratio)
keep[0] = False
return np.where(keep[lab], alpha, 0.0).astype(np.float32)
def _assign_blocks(alpha_full, ybnd, xbnd, cols, frag_ratio):
"""
全图连通标记 + 按质心把每块归属到格子。
这样格子只负责回答「这是哪个方向」,角色的实际范围由它自己的连通块决定,
因此**角色超出格子边界也不会被切**(原本严格等分会把探出去的脚、
飘起的斗篷直接截断)。归属按质心判定,每块只属于一个格子,
相邻角色不会被重复计入。
返回 (lab, {cell_id: [块标签...]});scipy 不可用时返回 (None, None)。
"""
if not _HAS_SCIPY:
return None, None
solid = alpha_full > 0.1
if not solid.any():
return None, {}
lab, n = ndi.label(solid)
if n == 0:
return lab, {}
areas = np.bincount(lab.ravel())
areas[0] = 0
big = areas.max()
cents = ndi.center_of_mass(solid, lab, np.arange(1, n + 1))
rows = len(ybnd) - 1
out = {}
for i in range(1, n + 1):
if frag_ratio > 0 and areas[i] < big * float(frag_ratio):
continue # 碎片,丢弃
cy, cx = cents[i - 1]
r = min(rows - 1, max(0, int(np.searchsorted(ybnd, cy, "right") - 1)))
c = min(cols - 1, max(0, int(np.searchsorted(xbnd, cx, "right") - 1)))
out.setdefault(r * cols + c, []).append(i)
return lab, out
def _bbox(alpha, thr=0.02):
"""内容包围盒 (y0,y1,x0,x1),无内容时返回 None。"""
m = alpha > thr
if not m.any():
return None
ys = np.where(m.any(axis=1))[0]
xs = np.where(m.any(axis=0))[0]
return int(ys[0]), int(ys[-1]) + 1, int(xs[0]), int(xs[-1]) + 1
def _parse_cells(text, total):
out = set()
for tok in str(text or "").replace(",", ",").split(","):
tok = tok.strip()
if not tok:
continue
try:
v = int(tok)
except ValueError:
continue
if 0 <= v < total:
out.add(v)
return out
class RuiEightDirSplit:
"""八方向雪碧图序列 → 8 条独立动画序列。"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE", {
"tooltip": "视频转出的序列帧,每帧是一张排布着多个朝向的雪碧图。"
}),
"grid_cols": ("INT", {
"default": 3, "min": 1, "max": 8, "step": 1,
"tooltip": "雪碧图的列数。"
}),
"grid_rows": ("INT", {
"default": 3, "min": 1, "max": 8, "step": 1,
"tooltip": "雪碧图的行数。"
}),
"empty_cells": ("STRING", {
"default": "4", "multiline": False,
"tooltip": "空格子的序号(行优先、从 0 开始,逗号分隔)。\n"
"3×3 布局中间留空即填 4。留空表示没有空格。"
}),
"direction_names": ("STRING", {
"default": _DEFAULT_NAMES, "multiline": False,
"tooltip": "按「跳过空格后的先后顺序」给每个方向命名,逗号分隔。\n"
"默认对应 3×3 中间留空、行 1 面向观众的排布:\n"
" SW S SE\n"
" W E\n"
" NW N NE\n"
"名字只用于 info 与你自己辨认,不影响画面内容。"
}),
"bg_mode": (list(_BG_MODES.keys()), {
"default": "白底转透明(推荐)",
"tooltip": "webm 要保留透明就必须先把白底转成 alpha。\n"
"转换只把与画面边缘相连的白色判为背景,\n"
"角色身上的白衣服、白高光会被保留。"
}),
"bg_threshold": ("FLOAT", {
"default": 0.92, "min": 0.5, "max": 1.0, "step": 0.005,
"tooltip": "白底判定阈值:像素三通道最小值高于它才算「接近白」。\n"
"背景没扣干净就调低,角色边缘被啃掉就调高。"
}),
"edge_softness": ("FLOAT", {
"default": 1.0, "min": 0.1, "max": 4.0, "step": 0.05,
"tooltip": "边缘过渡宽度。原图边缘带抗锯齿,过渡太硬会有锯齿白边;\n"
"调大更柔和,调小更锐利。"
}),
"fragment_threshold": ("FLOAT", {
"default": 0.05, "min": 0.0, "max": 0.5, "step": 0.01,
"tooltip": "清掉面积不足主体这一比例的连通碎片。\n"
"网格切分会把相邻格子探过来的部件(手杖尖、飘起的衣角)\n"
"切进本格,既难看又会撑大自动裁剪的范围。\n"
"0 = 不清理;与身体相连的道具不会被误删。"
}),
"expand_beyond_cell": ("BOOLEAN", {
"default": True,
"tooltip": "允许角色超出格子边界(强烈建议开启)。\n"
"关闭时按格子严格切分,角色只要探出格线就会被切断\n"
"——最常见的是脚、飘起的斗篷和手杖被削掉一截。\n"
"开启后格子只用来判定「这是哪个方向」,实际范围由角色\n"
"自身的连通区域决定,因此不会被切;按质心归属,\n"
"相邻角色也不会被卷进来。"
}),
"auto_crop": ("BOOLEAN", {
"default": True,
"tooltip": "按内容裁掉多余空白。\n"
"裁剪框取「该方向所有帧的并集」,因此整条序列尺寸一致,\n"
"既能合成视频,角色也不会在帧间跳动。"
}),
"crop_padding": ("INT", {
"default": 8, "min": 0, "max": 200, "step": 1,
"tooltip": "裁剪时在内容外保留的边距(像素)。"
}),
},
"optional": {
"masks": ("MASK", {
"tooltip": "可选。已有的透明通道(如上游抠图结果),\n"
"配合 bg_mode=已带透明通道 使用。"
}),
},
}
RETURN_TYPES = ("IMAGE",) * MAX_DIRS + ("STRING",)
RETURN_NAMES = tuple(f"dir_{i + 1}" for i in range(MAX_DIRS)) + ("info",)
FUNCTION = "split"
CATEGORY = "Rui-Node🐶/图像调节🎨"
@classmethod
def VALIDATE_INPUTS(cls, **kwargs):
return True
def split(self, images, grid_cols, grid_rows, empty_cells,
direction_names, bg_mode, bg_threshold, edge_softness,
fragment_threshold, expand_beyond_cell, auto_crop, crop_padding,
masks=None):
mode = _BG_MODES.get(bg_mode, "white")
B, H, W, C = images.shape
cols, rows = int(grid_cols), int(grid_rows)
total = cols * rows
empties = _parse_cells(empty_cells, total)
cell_ids = [i for i in range(total) if i not in empties]
n_dir = len(cell_ids)
names = [s.strip() for s in str(direction_names).replace(",", ",").split(",")
if s.strip()]
while len(names) < n_dir:
names.append(f"dir{len(names) + 1}")
arr = images.detach().cpu().float().numpy()
if C == 4:
rgb_all, a_in = arr[..., :3], arr[..., 3]
else:
rgb_all, a_in = arr[..., :3], None
if masks is not None:
m = masks.detach().cpu().float().numpy()
if m.ndim == 2:
m = m[None]
if m.shape[0] != B:
m = np.repeat(m[:1], B, axis=0)
a_in = m
# 格子边界按浮点等分再取整,避免整除不尽时累计误差(如 1112/3)
ybnd = [int(round(r * H / rows)) for r in range(rows + 1)]
xbnd = [int(round(c * W / cols)) for c in range(cols + 1)]
pad = int(crop_padding)
use_expand = bool(expand_beyond_cell) and mode != "none" and _HAS_SCIPY
def frame_alpha(b):
if mode == "white":
return _white_to_alpha(rgb_all[b], bg_threshold, edge_softness)
if mode == "keep" and a_in is not None:
return a_in[b]
return np.ones((H, W), dtype=np.float32)
outs, notes = [], []
if use_expand:
# ===== 内容自适应:格子只定方向,范围由角色自身的连通块决定 =====
# 第一遍只求包围盒(全图坐标),不留像素,避免整段序列驻留内存
boxes = [None] * n_dir
for b in range(B):
lab, groups = _assign_blocks(frame_alpha(b), ybnd, xbnd,
cols, fragment_threshold)
if lab is None:
continue
for di, cid in enumerate(cell_ids):
blk = groups.get(cid)
if not blk:
continue
bb = _bbox(np.isin(lab, blk).astype(np.float32), 0.5)
if bb is None:
continue
boxes[di] = bb if boxes[di] is None else (
min(boxes[di][0], bb[0]), max(boxes[di][1], bb[1]),
min(boxes[di][2], bb[2]), max(boxes[di][3], bb[3]))
# 没有 auto_crop 时退回该格的格线范围,仍允许块超界的像素带出来
final = []
for di, cid in enumerate(cell_ids):
r, c = divmod(cid, cols)
if auto_crop and boxes[di] is not None:
y0, y1, x0, x1 = boxes[di]
y0 = max(0, y0 - pad); x0 = max(0, x0 - pad)
y1 = min(H, y1 + pad); x1 = min(W, x1 + pad)
elif boxes[di] is not None:
y0, y1, x0, x1 = (min(ybnd[r], boxes[di][0]),
max(ybnd[r + 1], boxes[di][1]),
min(xbnd[c], boxes[di][2]),
max(xbnd[c + 1], boxes[di][3]))
else:
y0, y1, x0, x1 = ybnd[r], ybnd[r + 1], xbnd[c], xbnd[c + 1]
final.append((y0, y1, x0, x1))
outs.append(np.zeros((B, y1 - y0, x1 - x0, 4), dtype=np.float32))
# 第二遍按并集框提取;只保留归属本格的连通块,邻居不会混进来
for b in range(B):
af = frame_alpha(b)
lab, groups = _assign_blocks(af, ybnd, xbnd, cols, fragment_threshold)
for di, cid in enumerate(cell_ids):
y0, y1, x0, x1 = final[di]
outs[di][b, ..., :3] = rgb_all[b, y0:y1, x0:x1]
blk = groups.get(cid) if groups else None
if not blk:
continue
m = np.isin(lab[y0:y1, x0:x1], blk)
outs[di][b, ..., 3] = af[y0:y1, x0:x1] * m
for di in range(n_dir):
y0, y1, x0, x1 = final[di]
notes.append(f"{di + 1}.{names[di]} 格{cell_ids[di]} "
f"{x1 - x0}×{y1 - y0}")
outs[di] = torch.from_numpy(outs[di])
else:
# ===== 严格按格线切分(角色探出格线会被截断)=====
per_dir = [[] for _ in range(n_dir)]
boxes = [None] * n_dir
for b in range(B):
af = frame_alpha(b)
for di, cid in enumerate(cell_ids):
r, c = divmod(cid, cols)
y0, y1, x0, x1 = ybnd[r], ybnd[r + 1], xbnd[c], xbnd[c + 1]
rgb = rgb_all[b, y0:y1, x0:x1]
alpha = af[y0:y1, x0:x1]
if mode != "none":
alpha = _drop_fragments(alpha, fragment_threshold)
per_dir[di].append((rgb, alpha))
if auto_crop:
bb = _bbox(alpha)
if bb is not None:
boxes[di] = bb if boxes[di] is None else (
min(boxes[di][0], bb[0]), max(boxes[di][1], bb[1]),
min(boxes[di][2], bb[2]), max(boxes[di][3], bb[3]))
for di in range(n_dir):
frames = per_dir[di]
ch, cw = frames[0][0].shape[:2]
if auto_crop and boxes[di] is not None:
y0, y1, x0, x1 = boxes[di]
y0 = max(0, y0 - pad); x0 = max(0, x0 - pad)
y1 = min(ch, y1 + pad); x1 = min(cw, x1 + pad)
else:
y0, y1, x0, x1 = 0, ch, 0, cw
stack = np.empty((len(frames), y1 - y0, x1 - x0, 4), dtype=np.float32)
for fi, (rgb, alpha) in enumerate(frames):
stack[fi, ..., :3] = rgb[y0:y1, x0:x1]
stack[fi, ..., 3] = alpha[y0:y1, x0:x1]
outs.append(torch.from_numpy(stack))
notes.append(f"{di + 1}.{names[di]} 格{cell_ids[di]} "
f"{x1 - x0}×{y1 - y0}")
info = (f"输入 {B} 帧 {W}×{H} → {cols}×{rows} 网格,"
f"空格 {sorted(empties) if empties else '无'},"
f"得到 {n_dir} 个方向 × {B} 帧\n" + " | ".join(notes))
if n_dir > MAX_DIRS:
info += f"\n⚠ 方向数 {n_dir} 超过输出口数量 {MAX_DIRS},只输出前 {MAX_DIRS} 个"
elif n_dir < MAX_DIRS:
info += (f"\n⚠ 方向数 {n_dir} 少于输出口数量 {MAX_DIRS},"
f"dir_{n_dir + 1}~dir_{MAX_DIRS} 为占位空图,请勿使用")
print(f"[Ruinode-8Dir] {info}")
# 输出口数量固定,方向不足时补占位图,避免下游拿到 None 直接报错
blank = torch.zeros((1, 8, 8, 4), dtype=torch.float32)
result = [outs[i] if i < len(outs) else blank for i in range(MAX_DIRS)]
return tuple(result) + (info,)
NODE_CLASS_MAPPINGS = {
"RuiEightDirSplit": RuiEightDirSplit,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"RuiEightDirSplit": "八方向序列拆分 / 8-Direction Sprite Split",
}