feat: 八方向拆分支持锚点对齐——尺寸统一、切换朝向不跳

原来每个方向各按自己的内容裁剪,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>
This commit is contained in:
rui40000
2026-07-30 22:27:41 +08:00
co-authored by Claude Opus 4.8
parent 5e9e1e6a84
commit cea14ef327
4 changed files with 223 additions and 7 deletions
+31
View File
@@ -1090,6 +1090,37 @@ Lucida 一次就能识别整张雪碧图的全部 8 个角色(各格前景占
**输出**: `dir_1` ~ `dir_8` (IMAGE,**4 通道 RGBA**) + `info` (STRING) **输出**: `dir_1` ~ `dir_8` (IMAGE,**4 通道 RGBA**) + `info` (STRING)
**锚点对齐:让 8 个方向尺寸统一、切换朝向不跳**
做游戏素材时这一步是刚需。不开对齐时,每个方向各按自己的内容裁剪,8 个方向出 8 种尺寸,角色在各自画面里的位置也不一致——游戏里切换朝向角色就会跳一下。人工做法是「一帧一帧手动对位置」,本节点把它自动化了:
| 参数 | 说明 |
|:-----|:-----|
| `align_mode` | 锚点对齐·统一画布(默认)/不对齐 |
| `anchor_type` | **脚底中心**(默认)/包围盒底边中心/包围盒中心 |
| `align_scope` | **逐帧对齐·脚底钉死**(默认)/按方向统一平移 |
**为什么锚点取「脚底中心」而不是包围盒中心**:角色站在地面上,脚底才是它在世界里的位置;而斗篷、披风、手杖会把包围盒拽向一侧。所以 y 取最低的不透明行,x 取**底部窄带的水平质心**——那些外挂物基本不会垂到脚底,走路时两脚一前一后,窄带质心正好落在两脚之间,也就是人真正站立的点。
**实测**(97 帧真实素材):
| | 输出尺寸 | 各方向锚点散布 | 同方向跨帧位移 |
|:---|:---|---:|---:|
| 不对齐 | 8 种各不相同 | x **48.9px** / y **27px** | — |
| 按方向统一平移 | 统一 267×372 | x 7.1px / y 4px | 13~23px(保留摆动)|
| **逐帧对齐(默认)** | **统一 284×367** | **x 0.76px / y 0px** | ~1px |
默认选逐帧对齐,是因为行走循环本就该原地播放、位移交给游戏代码,sprite 内部不该有整体漂移;而 AI 生成的视频往往有(实测同方向跨帧漂移达 22px)。角色本就该有前后摆动的动作(挥剑、跳跃)则改用「按方向统一平移」。
`info` 输出会给出**统一画布尺寸、锚点坐标、以及 Unity/Godot 的归一化 pivot**(左下为原点),例如:
```
统一画布 267×372,锚点(脚底中心)位于 (144.5, 346.0)
Unity/Godot 归一化 pivot(左下为原点):(0.5411, 0.0699)
```
把那个 pivot 填进引擎的 Sprite 设置,8 个方向就能共用同一套坐标。
**三个关键设计**: **三个关键设计**:
1. **用固定网格而非连通区域拆分**(本仓库的[素材拆分](#14-素材拆分--sprite-splitter)节点)。连通区域按包围盒排序,角色走动时位置浮动,一旦跨过排序行界方向就会错乱——上百帧里错一帧整条动画就废了;且每个 sprite 按各自 bbox 裁剪、尺寸不一,无法合成视频。固定网格没有这两个问题。(连通区域拆分依然更适合单张静态合图,两者各有用途。) 1. **用固定网格而非连通区域拆分**(本仓库的[素材拆分](#14-素材拆分--sprite-splitter)节点)。连通区域按包围盒排序,角色走动时位置浮动,一旦跨过排序行界方向就会错乱——上百帧里错一帧整条动画就废了;且每个 sprite 按各自 bbox 裁剪、尺寸不一,无法合成视频。固定网格没有这两个问题。(连通区域拆分依然更适合单张静态合图,两者各有用途。)
+184 -5
View File
@@ -46,6 +46,23 @@ _BG_ALIAS = {
"已带透明通道": "keep", "已带透明通道": "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 背对观众 # 与 3×3 中间留空的常见排布对应:行 1 面向观众、行 3 背对观众
_DEFAULT_NAMES = "SW,S,SE,W,E,NW,N,NE" _DEFAULT_NAMES = "SW,S,SE,W,E,NW,N,NE"
@@ -100,6 +117,44 @@ def _drop_fragments(alpha, ratio):
return np.where(keep[lab], alpha, 0.0).astype(np.float32) 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): def _shrink_alpha(alpha, shrink):
""" """
把边缘那圈「几乎全是背景」的半透明像素收掉。 把边缘那圈「几乎全是背景」的半透明像素收掉。
@@ -284,6 +339,40 @@ class RuiEightDirSplit:
"对三种 bg_mode 都有效:选「不处理」时也会在内部算一份\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", { "auto_crop": ("BOOLEAN", {
"default": True, "default": True,
"tooltip": "按内容裁掉多余空白。\n" "tooltip": "按内容裁掉多余空白。\n"
@@ -317,8 +406,12 @@ class RuiEightDirSplit:
def split(self, images, grid_cols, grid_rows, empty_cells, def split(self, images, grid_cols, grid_rows, empty_cells,
direction_names, bg_mode, bg_threshold, edge_shrink, direction_names, bg_mode, bg_threshold, edge_shrink,
decontaminate, edge_softness, fragment_threshold, decontaminate, edge_softness, fragment_threshold,
expand_beyond_cell, auto_crop, crop_padding, masks=None): 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") 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 B, H, W, C = images.shape
cols, rows = int(grid_cols), int(grid_rows) cols, rows = int(grid_cols), int(grid_rows)
total = cols * rows total = cols * rows
@@ -373,24 +466,110 @@ class RuiEightDirSplit:
if use_expand: if use_expand:
# ===== 内容自适应:格子只定方向,范围由角色自身的连通块决定 ===== # ===== 内容自适应:格子只定方向,范围由角色自身的连通块决定 =====
# 第一遍只求包围盒(全图坐标),不留像素,避免整段序列驻留内存 # 第一遍求包围盒与锚点(全图坐标),不留像素,避免整段序列驻留内存
boxes = [None] * n_dir 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): for b in range(B):
lab, groups = _assign_blocks(locate_alpha(b), ybnd, xbnd, la = locate_alpha(b)
cols, fragment_threshold) lab, groups = _assign_blocks(la, ybnd, xbnd, cols,
fragment_threshold)
if lab is None: if lab is None:
continue continue
for di, cid in enumerate(cell_ids): for di, cid in enumerate(cell_ids):
blk = groups.get(cid) blk = groups.get(cid)
if not blk: if not blk:
continue continue
bb = _bbox(np.isin(lab, blk).astype(np.float32), 0.5) m = np.isin(lab, blk)
bb = _bbox(m.astype(np.float32), 0.5)
if bb is None: if bb is None:
continue continue
fbox[di][b] = bb
fanc[di][b] = _anchor_of(la * m, anchor_kind, *bb)
boxes[di] = bb if boxes[di] is None else ( boxes[di] = bb if boxes[di] is None else (
min(boxes[di][0], bb[0]), max(boxes[di][1], bb[1]), min(boxes[di][0], bb[0]), max(boxes[di][1], bb[1]),
min(boxes[di][2], bb[2]), max(boxes[di][3], bb[3])) 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 时退回该格的格线范围,仍允许块超界的像素带出来 # 没有 auto_crop 时退回该格的格线范围,仍允许块超界的像素带出来
final = [] final = []
for di, cid in enumerate(cell_ids): for di, cid in enumerate(cell_ids):
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long