原来每个方向各按自己的内容裁剪,8 个方向出 8 种尺寸,角色在各自 画面里的位置也不一致,游戏里切换朝向会跳。人工做法是一帧一帧手动 对位置,这里把它自动化。 新增三个参数: - align_mode 锚点对齐·统一画布(默认) - anchor_type 脚底中心(默认)/ 包围盒底边中心 / 包围盒中心 - align_scope 逐帧对齐·脚底钉死(默认)/ 按方向统一平移 锚点为什么取「脚底中心」:角色站在地面上,脚底才是它在世界里的位置; 而斗篷、披风、手杖会把包围盒拽向一侧。故 y 取最低的不透明行, x 取底部窄带的水平质心 —— 那些外挂物基本不会垂到脚底,走路时两脚 一前一后,窄带质心正好落在两脚之间,即人真正站立的点。 画布尺寸由「所有方向、所有帧相对各自锚点的最大延展」求并集得出, 因此既统一又不会切到任何一帧。 实测(97 帧真实素材): 不对齐 8 种尺寸 各方向锚点散布 x 48.9px / y 27px 按方向统一平移 统一267x372 x 7.1px / y 4px,跨帧位移 13~23px(保留摆动) 逐帧对齐 统一284x367 x 0.76px / y 0px,跨帧位移约 1px 默认取逐帧对齐:行走循环本就该原地播放、位移交给游戏代码,sprite 内部不该有整体漂移,而 AI 生成的视频往往有(实测达 22px)。 info 输出新增统一画布尺寸、锚点坐标与 Unity/Godot 归一化 pivot (左下为原点),照着填进引擎 8 个方向即可共用同一套坐标。 两个示例工作流同步更新 widgets(13→16,不同步会参数错位), 并已校验 widget 数量与结构自洽性。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
688 lines
34 KiB
Python
688 lines
34 KiB
Python
# -*- 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 = {
|
||
# 键名直接把取舍写清楚:颜色阈值法快但会啃掉白色衣物,
|
||
# 接模型抠图才是干净的做法(实测对比见 README)
|
||
"已带透明通道(推荐·上游接抠图节点)": "keep",
|
||
"白底转透明(快,但白色衣物会被啃)": "white",
|
||
"不处理(输出不透明)": "none",
|
||
}
|
||
# 早先版本保存的工作流里的旧选项名,不进下拉、只做解析兼容
|
||
_BG_ALIAS = {
|
||
"白底转透明(推荐)": "white",
|
||
"已带透明通道": "keep",
|
||
}
|
||
|
||
_ALIGN_MODES = {
|
||
"锚点对齐·统一画布(做游戏必选)": "anchor",
|
||
"不对齐(各方向独立裁剪)": "none",
|
||
}
|
||
_ANCHORS = {
|
||
"脚底中心(推荐)": "foot",
|
||
"包围盒底边中心": "bbox_bottom",
|
||
"包围盒中心": "bbox_center",
|
||
}
|
||
_ALIGN_SCOPES = {
|
||
"逐帧对齐·脚底钉死(推荐)": "per_frame",
|
||
"按方向统一平移(保留帧内起伏)": "per_dir",
|
||
}
|
||
_ANCHOR_LABEL = {
|
||
"foot": "脚底中心", "bbox_bottom": "包围盒底边中心", "bbox_center": "包围盒中心",
|
||
}
|
||
|
||
# 与 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 _anchor_of(alpha, mode, y0, y1, x0, x1, band_ratio=0.08):
|
||
"""
|
||
求该帧的锚点(轴心),返回全图坐标 (ax, ay)。
|
||
|
||
游戏里角色站在地面上,所以锚点取「脚底中心」最合理:
|
||
ay = 最低的不透明行(脚底所在高度)
|
||
ax = 底部窄带内的水平质心
|
||
x 之所以不用包围盒中心:斗篷、披风、手杖会把包围盒拽向一侧,
|
||
而这些东西基本不会垂到脚底,取底部窄带就避开了它们。
|
||
走路时两脚一前一后,窄带质心正好落在两脚之间,即人站立的位置。
|
||
"""
|
||
if mode == "bbox_center":
|
||
return ((x0 + x1 - 1) / 2.0, (y0 + y1 - 1) / 2.0)
|
||
if mode == "bbox_bottom":
|
||
return ((x0 + x1 - 1) / 2.0, float(y1 - 1))
|
||
band = max(2, int((y1 - y0) * band_ratio))
|
||
sub = alpha[max(y0, y1 - band):y1, x0:x1]
|
||
total = float(sub.sum())
|
||
if total <= 1e-6:
|
||
return ((x0 + x1 - 1) / 2.0, float(y1 - 1))
|
||
col = sub.sum(axis=0)
|
||
ax = float((col * np.arange(x0, x1)).sum() / total)
|
||
return (ax, float(y1 - 1))
|
||
|
||
|
||
def _paste_anchored(canvas, src, src_anchor, dst_anchor):
|
||
"""把 src(RGBA) 按锚点对齐贴进 canvas,越界部分自动裁掉。"""
|
||
ox = int(round(dst_anchor[0] - src_anchor[0]))
|
||
oy = int(round(dst_anchor[1] - src_anchor[1]))
|
||
H, W = canvas.shape[:2]
|
||
h, w = src.shape[:2]
|
||
x0, y0 = max(0, ox), max(0, oy)
|
||
x1, y1 = min(W, ox + w), min(H, oy + h)
|
||
if x1 <= x0 or y1 <= y0:
|
||
return
|
||
canvas[y0:y1, x0:x1] = src[y0 - oy:y1 - oy, x0 - ox:x1 - ox]
|
||
|
||
|
||
def _shrink_alpha(alpha, shrink):
|
||
"""
|
||
把边缘那圈「几乎全是背景」的半透明像素收掉。
|
||
|
||
白底图上,角色边缘的抗锯齿像素本就是前景与白色的混合,而从单张图
|
||
反推 alpha 是欠定问题(一个方程多个未知数),浅色边缘的 alpha 往往
|
||
被估低,于是残留一圈混了白的半透明像素——合成到深色背景就是白边。
|
||
这里把 alpha 低于 shrink 的判为背景,其余重新拉伸到满值,
|
||
相当于平滑地向内收一点边,比形态学腐蚀不容易产生锯齿。
|
||
"""
|
||
if shrink <= 0:
|
||
return alpha
|
||
s = min(0.95, float(shrink))
|
||
return np.clip((alpha - s) / (1.0 - s), 0.0, 1.0).astype(np.float32)
|
||
|
||
|
||
def _decontaminate(rgb, alpha, strength, bg=1.0):
|
||
"""
|
||
颜色反溢出:把混进边缘像素的背景色剥掉。
|
||
|
||
观察色 = 前景色 × a + 背景色 × (1-a),于是
|
||
前景色 = (观察色 - 背景色 × (1-a)) / a
|
||
不做这一步,边缘像素的 RGB 里就一直掺着白,贴到深色背景上必然发白。
|
||
a 极小时该式会放大噪声,故对分母设下限。
|
||
"""
|
||
if strength <= 0:
|
||
return rgb
|
||
a = alpha[..., None]
|
||
fg = (rgb - bg * (1.0 - a)) / np.maximum(a, 0.08)
|
||
fg = np.clip(fg, 0.0, 1.0)
|
||
s = float(min(1.0, strength))
|
||
return (rgb * (1.0 - s) + fg * s).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"
|
||
"【已带透明通道】把上游抠图节点(Lucida / FeyNobg)的\n"
|
||
" 遮罩接到 masks 输入。**强烈推荐**:模型是按语义判断的,\n"
|
||
" 白衣服不会被误判成背景。实测同等白边水平下,\n"
|
||
" 主体被啃掉的面积比阈值法少 35%。\n"
|
||
" 没接 masks 时会自动退回颜色阈值法,不会报错。\n\n"
|
||
"【白底转透明】纯颜色阈值 + 边缘连通性判断,不需要模型、\n"
|
||
" 很快。但它靠「离白色多远」估 alpha,角色身上的白色衣物\n"
|
||
" 天生 alpha 偏低,收边时会被啃出破洞 —— 素材里有白衣服、\n"
|
||
" 白高光时别用这个。\n\n"
|
||
"【不处理】输出不透明。仍会按角色范围裁剪、不切断。"
|
||
}),
|
||
"bg_threshold": ("FLOAT", {
|
||
"default": 0.92, "min": 0.5, "max": 1.0, "step": 0.005,
|
||
"tooltip": "白底判定阈值:像素三通道最小值高于它才算「接近白」。\n"
|
||
"背景没扣干净就调低,角色边缘被啃掉就调高。"
|
||
}),
|
||
"edge_shrink": ("FLOAT", {
|
||
"default": 0.2, "min": 0.0, "max": 0.95, "step": 0.01,
|
||
"tooltip": "【治白边的主力参数】把边缘那圈「几乎全是背景」的\n"
|
||
"半透明像素收掉,相当于平滑地向内收一点边。\n"
|
||
"白底素材的边缘像素本就掺了白,不收掉贴到深色背景就发白。\n\n"
|
||
"配模型 alpha(bg_mode=已带透明通道):0.15~0.25 就够。\n"
|
||
"配颜色阈值法:要 0.35 以上才压得住白边,代价是白色衣物\n"
|
||
" 会被啃出破洞 —— 这也是推荐用模型 alpha 的原因。\n"
|
||
"0 = 完全不收(白边明显)。"
|
||
}),
|
||
"decontaminate": ("FLOAT", {
|
||
"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.05,
|
||
"tooltip": "【治白边的第二道】颜色反溢出:把混进边缘像素的白色剥掉。\n"
|
||
"原理是按 观察色 = 前景×a + 白×(1-a) 反解出真正的前景色。\n"
|
||
"1.0 = 完全反解(推荐),0 = 不处理。\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"
|
||
"对三种 bg_mode 都有效:选「不处理」时也会在内部算一份\n"
|
||
"白底检测来圈定范围,输出仍保持不透明。"
|
||
}),
|
||
"align_mode": (list(_ALIGN_MODES.keys()), {
|
||
"default": "锚点对齐·统一画布(做游戏必选)",
|
||
"tooltip": "【做游戏素材必选】把 8 个方向对齐到同一张画布上。\n\n"
|
||
"不对齐时每个方向各按自己的内容裁剪,尺寸互不相同\n"
|
||
"(如 208×328 与 192×323),且角色在各自画面里的位置\n"
|
||
"也不一致 —— 游戏里切换朝向时角色就会跳一下。\n\n"
|
||
"开启后:所有方向输出同一尺寸,且锚点(默认脚底中心)\n"
|
||
"落在画布里的同一个位置,因此切换朝向时角色纹丝不动。\n"
|
||
"info 输出里会给出画布尺寸与锚点坐标(含 Unity 归一化\n"
|
||
"pivot),照着填进引擎即可。"
|
||
}),
|
||
"anchor_type": (list(_ANCHORS.keys()), {
|
||
"default": "脚底中心(推荐)",
|
||
"tooltip": "用什么当锚点(轴心)。\n\n"
|
||
"脚底中心:y 取最低的不透明行,x 取底部窄带的水平质心。\n"
|
||
" 角色站在地面上,脚底才是它在世界里的位置,所以这个最合理。\n"
|
||
" x 不用包围盒中心,是因为斗篷、披风、手杖会把包围盒\n"
|
||
" 拽向一侧,而它们基本不会垂到脚底。\n"
|
||
"包围盒底边中心:省掉质心计算,角色没有大幅外挂物时够用。\n"
|
||
"包围盒中心:适合飞行单位、投射物这类不站地面的素材。"
|
||
}),
|
||
"align_scope": (list(_ALIGN_SCOPES.keys()), {
|
||
"default": "逐帧对齐·脚底钉死(推荐)",
|
||
"tooltip": "对齐的粒度,直接影响动画观感。\n\n"
|
||
"【逐帧对齐·脚底钉死】每一帧都把自己的锚点钉在同一点,\n"
|
||
" 等价于人工「一帧一帧对位置」。\n"
|
||
" 行走循环本就该原地播放、位移交给游戏代码,所以\n"
|
||
" sprite 内部不该有整体漂移。AI 生成的视频往往有\n"
|
||
" (本仓库实测同方向跨帧漂移达 22px),这个模式能压到 1px。\n\n"
|
||
"【按方向统一平移】同方向所有帧平移同样的距离,\n"
|
||
" 保留帧内的重心起伏。方向之间照样对齐。\n"
|
||
" 适合角色本来就该有前后摆动的动作(如挥剑、跳跃),\n"
|
||
" 或素材本身很干净、不需要修漂移时。"
|
||
}),
|
||
"auto_crop": ("BOOLEAN", {
|
||
"default": True,
|
||
"tooltip": "按内容裁掉多余空白。\n"
|
||
"裁剪框取「该方向所有帧的并集」,因此整条序列尺寸一致,\n"
|
||
"既能合成视频,角色也不会在帧间跳动。"
|
||
}),
|
||
"crop_padding": ("INT", {
|
||
"default": 16, "min": 0, "max": 200, "step": 1,
|
||
"tooltip": "裁剪时在内容外保留的边距(像素)。\n"
|
||
"给足边距不仅好看,也给后续的描边、发光、\n"
|
||
"阴影等特效留出余量,免得贴着边显得像被切了。"
|
||
}),
|
||
},
|
||
"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_shrink,
|
||
decontaminate, edge_softness, fragment_threshold,
|
||
expand_beyond_cell, align_mode, anchor_type, align_scope,
|
||
auto_crop, crop_padding, masks=None):
|
||
mode = _BG_MODES.get(bg_mode) or _BG_ALIAS.get(bg_mode, "white")
|
||
align_kind = _ALIGN_MODES.get(align_mode, "anchor")
|
||
anchor_kind = _ANCHORS.get(anchor_type, "foot")
|
||
align_scope_kind = _ALIGN_SCOPES.get(align_scope, "per_dir")
|
||
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)
|
||
# 注意不要因为 bg_mode=不处理 就跳过这条路径:那样会退回严格格线切分,
|
||
# 角色照样被切。定位与输出是两件事,分开处理即可。
|
||
use_expand = bool(expand_beyond_cell) and _HAS_SCIPY
|
||
|
||
def locate_alpha(b):
|
||
"""
|
||
仅用于圈定角色范围的 alpha。
|
||
即使用户选了「不处理(输出不透明)」,这里也要照算一份 ——
|
||
否则无从判断角色到哪儿为止,只能按格线硬切。
|
||
"""
|
||
if mode == "keep" and a_in is not None:
|
||
return a_in[b]
|
||
return _white_to_alpha(rgb_all[b], bg_threshold, edge_softness)
|
||
|
||
def frame_alpha(b):
|
||
"""真正写进输出的 alpha。"""
|
||
if mode == "none":
|
||
return np.ones((H, W), dtype=np.float32)
|
||
return locate_alpha(b)
|
||
|
||
outs, notes = [], []
|
||
|
||
if use_expand:
|
||
# ===== 内容自适应:格子只定方向,范围由角色自身的连通块决定 =====
|
||
# 第一遍求包围盒与锚点(全图坐标),不留像素,避免整段序列驻留内存
|
||
boxes = [None] * n_dir
|
||
fbox = [[None] * B for _ in range(n_dir)] # 每帧各自的包围盒
|
||
fanc = [[None] * B for _ in range(n_dir)] # 每帧各自的锚点
|
||
for b in range(B):
|
||
la = locate_alpha(b)
|
||
lab, groups = _assign_blocks(la, 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
|
||
m = np.isin(lab, blk)
|
||
bb = _bbox(m.astype(np.float32), 0.5)
|
||
if bb is None:
|
||
continue
|
||
fbox[di][b] = bb
|
||
fanc[di][b] = _anchor_of(la * m, anchor_kind, *bb)
|
||
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]))
|
||
|
||
# ---- 锚点对齐:所有方向输出同一尺寸,且锚点落在画布同一位置 ----
|
||
if align_kind == "anchor":
|
||
# 方向代表锚点取中位数:个别帧抠图不稳也不会把整个方向带偏
|
||
rep = []
|
||
for di in range(n_dir):
|
||
aa = [a for a in fanc[di] if a is not None]
|
||
rep.append((float(np.median([a[0] for a in aa])),
|
||
float(np.median([a[1] for a in aa])))
|
||
if aa else None)
|
||
|
||
# 以锚点为原点,统计所有方向、所有帧向四周的最大延展
|
||
L = R = T = Bt = 0.0
|
||
for di in range(n_dir):
|
||
if rep[di] is None or boxes[di] is None:
|
||
continue
|
||
if align_scope_kind == "per_dir":
|
||
pairs = [(boxes[di], rep[di])]
|
||
else:
|
||
pairs = [(fbox[di][b], fanc[di][b]) for b in range(B)
|
||
if fbox[di][b] is not None]
|
||
for bb, an in pairs:
|
||
L = max(L, an[0] - bb[2])
|
||
R = max(R, bb[3] - 1 - an[0])
|
||
T = max(T, an[1] - bb[0])
|
||
Bt = max(Bt, bb[1] - 1 - an[1])
|
||
|
||
cw = int(np.ceil(L + R)) + 1 + 2 * pad
|
||
ch = int(np.ceil(T + Bt)) + 1 + 2 * pad
|
||
canvas_anchor = (pad + L, pad + T)
|
||
outs = [np.zeros((B, ch, cw, 4), dtype=np.float32)
|
||
for _ in range(n_dir)]
|
||
|
||
for b in range(B):
|
||
la = locate_alpha(b)
|
||
af = frame_alpha(b)
|
||
lab, groups = _assign_blocks(la, ybnd, xbnd, cols,
|
||
fragment_threshold)
|
||
for di, cid in enumerate(cell_ids):
|
||
bb = fbox[di][b]
|
||
blk = groups.get(cid) if groups else None
|
||
if bb is None or not blk:
|
||
continue
|
||
y0, y1, x0, x1 = bb
|
||
rgb = rgb_all[b, y0:y1, x0:x1]
|
||
m = np.isin(lab[y0:y1, x0:x1], blk)
|
||
src = np.zeros((y1 - y0, x1 - x0, 4), dtype=np.float32)
|
||
if mode == "none":
|
||
a = (la[y0:y1, x0:x1] * m)[..., None]
|
||
src[..., :3] = rgb * a + (1.0 - a)
|
||
src[..., 3] = 1.0
|
||
else:
|
||
ac = _shrink_alpha(af[y0:y1, x0:x1] * m, edge_shrink)
|
||
src[..., :3] = _decontaminate(rgb, ac, decontaminate)
|
||
src[..., 3] = ac
|
||
# per_dir 用方向代表锚点:源是全图坐标系,各帧都把同一个
|
||
# 全图点对齐到画布锚点,等价于整方向平移同样的距离,
|
||
# 因此帧间的自然起伏被原样保留。
|
||
an = rep[di] if align_scope_kind == "per_dir" else fanc[di][b]
|
||
_paste_anchored(outs[di][b], src,
|
||
(an[0] - x0, an[1] - y0), canvas_anchor)
|
||
|
||
for di in range(n_dir):
|
||
notes.append(f"{di + 1}.{names[di]} 格{cell_ids[di]}")
|
||
outs[di] = torch.from_numpy(outs[di])
|
||
align_note = (
|
||
f"\n统一画布 {cw}×{ch},锚点({_ANCHOR_LABEL.get(anchor_kind, anchor_kind)})"
|
||
f"位于 ({canvas_anchor[0]:.1f}, {canvas_anchor[1]:.1f})"
|
||
f"\nUnity/Godot 归一化 pivot(左下为原点):"
|
||
f"({canvas_anchor[0] / cw:.4f}, {1.0 - canvas_anchor[1] / ch:.4f})"
|
||
f"\n对齐粒度:{align_scope}")
|
||
info = (f"输入 {B} 帧 {W}×{H} → {cols}×{rows} 网格,"
|
||
f"空格 {sorted(empties) if empties else '无'},"
|
||
f"得到 {n_dir} 个方向 × {B} 帧\n"
|
||
+ " | ".join(notes) + align_note)
|
||
print(f"[Ruinode-8Dir] {info}")
|
||
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,)
|
||
|
||
# 没有 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):
|
||
la = locate_alpha(b)
|
||
af = frame_alpha(b)
|
||
lab, groups = _assign_blocks(la, ybnd, xbnd, cols, fragment_threshold)
|
||
for di, cid in enumerate(cell_ids):
|
||
y0, y1, x0, x1 = final[di]
|
||
rgb = rgb_all[b, y0:y1, x0:x1]
|
||
blk = groups.get(cid) if groups else None
|
||
m = (np.isin(lab[y0:y1, x0:x1], blk) if blk
|
||
else np.zeros(rgb.shape[:2], dtype=bool))
|
||
|
||
if mode == "none":
|
||
# 不透明输出。裁剪框为了不切断角色必然会框进邻居的像素,
|
||
# 「原样保留」与「不切断」不可兼得 —— 这里把非本角色的部分
|
||
# 合成到白底,既保住完整的角色,也不会混进旁边那位。
|
||
a = (locate_alpha(b)[y0:y1, x0:x1] * m)[..., None]
|
||
outs[di][b, ..., :3] = rgb * a + (1.0 - a)
|
||
outs[di][b, ..., 3] = 1.0
|
||
continue
|
||
|
||
if blk:
|
||
ac = _shrink_alpha(af[y0:y1, x0:x1] * m, edge_shrink)
|
||
outs[di][b, ..., :3] = _decontaminate(rgb, ac, decontaminate)
|
||
outs[di][b, ..., 3] = ac
|
||
else:
|
||
outs[di][b, ..., :3] = rgb
|
||
|
||
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):
|
||
rc = rgb[y0:y1, x0:x1]
|
||
ac = alpha[y0:y1, x0:x1]
|
||
if mode != "none":
|
||
ac = _shrink_alpha(ac, edge_shrink)
|
||
rc = _decontaminate(rc, ac, decontaminate)
|
||
stack[fi, ..., :3] = rc
|
||
stack[fi, ..., 3] = ac
|
||
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",
|
||
}
|