fix: 治理抠图白边——推荐改用模型 alpha,新增收边与颜色反溢出

用户反馈边缘一圈白边。诊断:按 alpha 分层统计 RGB 均值,
alpha 0.05~0.3 的边缘像素 RGB 高达 0.989(角色本体只有 0.363),
证实边缘掺了背景白 —— 我只设了 alpha 却没动 RGB。

新增两个参数:
- decontaminate 颜色反溢出,按 观察色=前景×a+白×(1-a) 反解前景色
- edge_shrink 把边缘那圈几乎全是背景的半透明像素收掉

实测白边强度(合成到深绿底,量主体外缘3像素环带比背景亮多少):
  都关闭 0.0483 / 只反溢出 0.0466 / 只收边 0.0283 / 两者 0.0261 / 狠收边 0.0164
即:收边是主力(-41%),反溢出单独几乎无效(-3.5%)——因为观察色
本身就太白,反解出来还是白。这点如实写进 tooltip 与 README。

但收边暴露了颜色阈值法的死穴:它按「离白色多远」估 alpha,
白衬衫接近白、alpha 天生偏低,收边会把衬衫啃出破洞(肉眼可见背景
透过衣服)。这不是调参能解决的,故改推荐语义级抠图:

- bg_mode 默认改为「已带透明通道(推荐·上游接抠图节点)」,
  选项名直接把取舍写进去,旧选项名保留在 _BG_ALIAS 里做兼容
- edge_shrink 默认 0.2(配模型 alpha 够用;配阈值法需 0.35+)
- 示例工作流插入 Lucida 节点:加载视频 → Lucida → 拆分(masks 输入)

实测 Lucida 一次识别整张雪碧图全部 8 个角色(各格前景占比 0.18~0.24,
中间空格 0.002);同等白边水平下主体被啃面积比阈值法少 35%,白衬衫完好。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rui40000
2026-07-30 10:23:54 +08:00
co-authored by Claude Opus 4.8
parent 9c674bac42
commit 2a6fd42c57
3 changed files with 334 additions and 160 deletions
+25 -1
View File
@@ -1046,12 +1046,36 @@ WAS Node Suite 的「Text Multiline」会把 `#` 开头的行**当注释删除**
**输入参数**:
- `images` (IMAGE): 视频转出的序列帧
- `masks` (MASK, 可选): **上游抠图节点的遮罩,强烈建议接上**(见下方「透明通道怎么来」)
- `grid_cols` / `grid_rows` / `empty_cells`: 网格布局。3×3 中间留空即 `empty_cells=4`(序号行优先、从 0 开始)
- `direction_names` (STRING): 按「跳过空格后的先后顺序」命名,默认 `SW,S,SE,W,E,NW,N,NE`,对应行 1 面向观众、行 3 背对观众的排布
- `bg_mode` / `bg_threshold` / `edge_softness`: 白底转透明
- `bg_mode` / `bg_threshold`: 透明通道来源,见下
- `edge_shrink` / `decontaminate`: 治白边,见下
- `fragment_threshold` (FLOAT): 清掉面积不足主体这一比例的连通碎片
- `expand_beyond_cell` (BOOLEAN): **务必开启**,允许角色超出格子边界
- `auto_crop` / `crop_padding`: 按内容裁剪
**透明通道怎么来(关系到成品质量,别用默认凑合)**:
| bg_mode | 原理 | 代价 |
|:--------|:-----|:-----|
| **已带透明通道**(默认推荐)| 用上游 Lucida / FeyNobg 的语义级 alpha | 需要跑模型 |
| 白底转透明 | 纯颜色阈值 + 边缘连通性 | **角色身上的白衣服会被啃出破洞** |
| 不处理 | 输出不透明(仍按角色范围裁剪不切断)| — |
颜色阈值法的死穴在于它按「离白色多远」估 alpha,白衬衫本身就接近白、alpha 天生偏低,一旦收边压白边,衬衫就被啃穿。实测同一素材、同等白边水平下:
| 方案 | 白边强度 | 主体被啃面积 |
|:-----|--------:|-----------:|
| 颜色阈值法(收边 0.35)| 0.0261 | 0.0373 |
| **Lucida alpha(收边 0.2)** | **0.0256** | **0.0242(少 35%)** |
Lucida 一次就能识别整张雪碧图的全部 8 个角色(各格前景占比 0.18~0.24,中间空格 0.002),肉眼比对:模型 alpha 的白衬衫完好,阈值法的衬衫上布满背景色斑块。
**治白边的两个参数**:
- `edge_shrink`(主力):把边缘那圈「几乎全是背景」的半透明像素收掉。白底素材的边缘像素本就掺了白,不收掉贴到深色背景就发白。实测白边强度:**0 → 0.048;0.2 → 0.026;0.5 → 0.016**。配模型 alpha 用 0.15~0.25,配阈值法要 0.35 以上
- `decontaminate`(辅助):颜色反溢出,按 `观察色 = 前景×a + 白×(1-a)` 反解真正的前景色。**单独用只改善约 3%**(因为观察色本身已经太白,反解出来还是白),必须和收边配合
**输出**: `dir_1` ~ `dir_8` (IMAGE,**4 通道 RGBA**) + `info` (STRING)
**三个关键设计**:
+87 -13
View File
@@ -34,9 +34,16 @@ except Exception: # 理论上 ComfyUI 环境
MAX_DIRS = 8
_BG_MODES = {
# 键名直接把取舍写清楚:颜色阈值法快但会啃掉白色衣物,
# 接模型抠图才是干净的做法(实测对比见 README)
"已带透明通道(推荐·上游接抠图节点)": "keep",
"白底转透明(快,但白色衣物会被啃)": "white",
"不处理(输出不透明)": "none",
}
# 早先版本保存的工作流里的旧选项名,不进下拉、只做解析兼容
_BG_ALIAS = {
"白底转透明(推荐)": "white",
"已带透明通道": "keep",
"不处理(输出不透明)": "none",
}
# 与 3×3 中间留空的常见排布对应:行 1 面向观众、行 3 背对观众
@@ -93,6 +100,40 @@ def _drop_fragments(alpha, ratio):
return np.where(keep[lab], alpha, 0.0).astype(np.float32)
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):
"""
全图连通标记 + 按质心把每块归属到格子。
@@ -186,16 +227,41 @@ class RuiEightDirSplit:
"名字只用于 info 与你自己辨认,不影响画面内容。"
}),
"bg_mode": (list(_BG_MODES.keys()), {
"default": "白底转透明(推荐)",
"tooltip": "webm 要保留透明就必须先把白底转成 alpha。\n"
"转换只把与画面边缘相连的白色判为背景,\n"
"角色身上的白衣服、白高光会被保留。"
"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"
@@ -249,10 +315,10 @@ class RuiEightDirSplit:
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")
direction_names, bg_mode, bg_threshold, edge_shrink,
decontaminate, edge_softness, fragment_threshold,
expand_beyond_cell, auto_crop, crop_padding, masks=None):
mode = _BG_MODES.get(bg_mode) or _BG_ALIAS.get(bg_mode, "white")
B, H, W, C = images.shape
cols, rows = int(grid_cols), int(grid_rows)
total = cols * rows
@@ -364,9 +430,12 @@ class RuiEightDirSplit:
outs[di][b, ..., 3] = 1.0
continue
outs[di][b, ..., :3] = rgb
if blk:
outs[di][b, ..., 3] = af[y0:y1, x0:x1] * m
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]
@@ -404,8 +473,13 @@ class RuiEightDirSplit:
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]
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}")
+222 -146
View File
@@ -1,14 +1,14 @@
{
"id": "5f8c3d21-7b94-4e06-a1d8-9c2e4b7f350a",
"revision": 0,
"last_node_id": 19,
"last_link_id": 17,
"last_node_id": 20,
"last_link_id": 19,
"nodes": [
{
"id": 1,
"type": "VHS_LoadVideo",
"pos": [
-2260,
-2680,
340
],
"size": [
@@ -24,7 +24,8 @@
"name": "IMAGE",
"type": "IMAGE",
"links": [
1
1,
2
]
},
{
@@ -61,44 +62,93 @@
},
{
"id": 2,
"type": "RuiEightDirSplit",
"type": "RuiLucida",
"pos": [
-1850,
-2270,
340
],
"size": [
360,
320
340,
250
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "images",
"name": "image",
"type": "IMAGE",
"link": 1
}
],
"outputs": [
{
"name": "alpha",
"type": "MASK",
"links": [
3
]
},
{
"name": "cutout",
"type": "IMAGE",
"links": []
}
],
"properties": {
"aux_id": "rui40000/Ruinode",
"Node name for S&R": "RuiLucida"
},
"widgets_values": [
"lucida.safetensors",
"fp16",
"auto",
0.5,
1.0,
false,
false
],
"color": "#232",
"bgcolor": "#353",
"title": "② Lucida 抠图(一次抠整张,8 个角色都能识别)"
},
{
"id": 3,
"type": "RuiEightDirSplit",
"pos": [
-1860,
340
],
"size": [
380,
400
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 2
},
{
"name": "masks",
"type": "MASK",
"link": 3
}
],
"outputs": [
{
"name": "dir_1",
"type": "IMAGE",
"links": [
2,
3
]
},
{
"name": "dir_2",
"type": "IMAGE",
"links": [
4,
5
]
},
{
"name": "dir_3",
"name": "dir_2",
"type": "IMAGE",
"links": [
6,
@@ -106,7 +156,7 @@
]
},
{
"name": "dir_4",
"name": "dir_3",
"type": "IMAGE",
"links": [
8,
@@ -114,7 +164,7 @@
]
},
{
"name": "dir_5",
"name": "dir_4",
"type": "IMAGE",
"links": [
10,
@@ -122,7 +172,7 @@
]
},
{
"name": "dir_6",
"name": "dir_5",
"type": "IMAGE",
"links": [
12,
@@ -130,7 +180,7 @@
]
},
{
"name": "dir_7",
"name": "dir_6",
"type": "IMAGE",
"links": [
14,
@@ -138,13 +188,21 @@
]
},
{
"name": "dir_8",
"name": "dir_7",
"type": "IMAGE",
"links": [
16,
17
]
},
{
"name": "dir_8",
"type": "IMAGE",
"links": [
18,
19
]
},
{
"name": "info",
"type": "STRING",
@@ -160,23 +218,25 @@
3,
"4",
"SW,S,SE,W,E,NW,N,NE",
"白底转透明(推荐)",
"已带透明通道(推荐·上游接抠图节点)",
0.92,
0.2,
1.0,
1.0,
0.05,
true,
true,
8
16
],
"color": "#232",
"bgcolor": "#353",
"title": "② 八方向拆分(拆分+编号+分组)"
"title": "③ 八方向拆分(拆分+编号+分组)"
},
{
"id": 3,
"id": 4,
"type": "VHS_VideoCombine",
"pos": [
-1380,
-1400,
-160
],
"size": [
@@ -184,13 +244,13 @@
460
],
"flags": {},
"order": 2,
"order": 3,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 2
"link": 4
},
{
"name": "audio",
@@ -235,10 +295,10 @@
"title": "1. SW → 透明 webm"
},
{
"id": 4,
"id": 5,
"type": "SaveImage",
"pos": [
-1020,
-1040,
-160
],
"size": [
@@ -246,13 +306,13 @@
300
],
"flags": {},
"order": 3,
"order": 4,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 3
"link": 5
}
],
"outputs": [],
@@ -267,10 +327,10 @@
"title": "1. SW → 透明 PNG 序列帧"
},
{
"id": 5,
"id": 6,
"type": "VHS_VideoCombine",
"pos": [
-1380,
-1400,
170
],
"size": [
@@ -278,13 +338,13 @@
460
],
"flags": {},
"order": 4,
"order": 5,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 4
"link": 6
},
{
"name": "audio",
@@ -327,10 +387,10 @@
"title": "2. S → 透明 webm"
},
{
"id": 6,
"id": 7,
"type": "SaveImage",
"pos": [
-1020,
-1040,
170
],
"size": [
@@ -338,13 +398,13 @@
300
],
"flags": {},
"order": 5,
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 5
"link": 7
}
],
"outputs": [],
@@ -359,10 +419,10 @@
"title": "2. S → 透明 PNG 序列帧"
},
{
"id": 7,
"id": 8,
"type": "VHS_VideoCombine",
"pos": [
-1380,
-1400,
500
],
"size": [
@@ -370,13 +430,13 @@
460
],
"flags": {},
"order": 6,
"order": 7,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 6
"link": 8
},
{
"name": "audio",
@@ -419,10 +479,10 @@
"title": "3. SE → 透明 webm"
},
{
"id": 8,
"id": 9,
"type": "SaveImage",
"pos": [
-1020,
-1040,
500
],
"size": [
@@ -430,13 +490,13 @@
300
],
"flags": {},
"order": 7,
"order": 8,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 7
"link": 9
}
],
"outputs": [],
@@ -451,10 +511,10 @@
"title": "3. SE → 透明 PNG 序列帧"
},
{
"id": 9,
"id": 10,
"type": "VHS_VideoCombine",
"pos": [
-1380,
-1400,
830
],
"size": [
@@ -462,13 +522,13 @@
460
],
"flags": {},
"order": 8,
"order": 9,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 8
"link": 10
},
{
"name": "audio",
@@ -511,10 +571,10 @@
"title": "4. W → 透明 webm"
},
{
"id": 10,
"id": 11,
"type": "SaveImage",
"pos": [
-1020,
-1040,
830
],
"size": [
@@ -522,13 +582,13 @@
300
],
"flags": {},
"order": 9,
"order": 10,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 9
"link": 11
}
],
"outputs": [],
@@ -543,10 +603,10 @@
"title": "4. W → 透明 PNG 序列帧"
},
{
"id": 11,
"id": 12,
"type": "VHS_VideoCombine",
"pos": [
-1380,
-1400,
1160
],
"size": [
@@ -554,13 +614,13 @@
460
],
"flags": {},
"order": 10,
"order": 11,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 10
"link": 12
},
{
"name": "audio",
@@ -603,10 +663,10 @@
"title": "5. E → 透明 webm"
},
{
"id": 12,
"id": 13,
"type": "SaveImage",
"pos": [
-1020,
-1040,
1160
],
"size": [
@@ -614,13 +674,13 @@
300
],
"flags": {},
"order": 11,
"order": 12,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 11
"link": 13
}
],
"outputs": [],
@@ -635,10 +695,10 @@
"title": "5. E → 透明 PNG 序列帧"
},
{
"id": 13,
"id": 14,
"type": "VHS_VideoCombine",
"pos": [
-1380,
-1400,
1490
],
"size": [
@@ -646,13 +706,13 @@
460
],
"flags": {},
"order": 12,
"order": 13,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 12
"link": 14
},
{
"name": "audio",
@@ -695,10 +755,10 @@
"title": "6. NW → 透明 webm"
},
{
"id": 14,
"id": 15,
"type": "SaveImage",
"pos": [
-1020,
-1040,
1490
],
"size": [
@@ -706,13 +766,13 @@
300
],
"flags": {},
"order": 13,
"order": 14,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 13
"link": 15
}
],
"outputs": [],
@@ -727,10 +787,10 @@
"title": "6. NW → 透明 PNG 序列帧"
},
{
"id": 15,
"id": 16,
"type": "VHS_VideoCombine",
"pos": [
-1380,
-1400,
1820
],
"size": [
@@ -738,13 +798,13 @@
460
],
"flags": {},
"order": 14,
"order": 15,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 14
"link": 16
},
{
"name": "audio",
@@ -787,10 +847,10 @@
"title": "7. N → 透明 webm"
},
{
"id": 16,
"id": 17,
"type": "SaveImage",
"pos": [
-1020,
-1040,
1820
],
"size": [
@@ -798,13 +858,13 @@
300
],
"flags": {},
"order": 15,
"order": 16,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 15
"link": 17
}
],
"outputs": [],
@@ -819,10 +879,10 @@
"title": "7. N → 透明 PNG 序列帧"
},
{
"id": 17,
"id": 18,
"type": "VHS_VideoCombine",
"pos": [
-1380,
-1400,
2150
],
"size": [
@@ -830,13 +890,13 @@
460
],
"flags": {},
"order": 16,
"order": 17,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 16
"link": 18
},
{
"name": "audio",
@@ -879,10 +939,10 @@
"title": "8. NE → 透明 webm"
},
{
"id": 18,
"id": 19,
"type": "SaveImage",
"pos": [
-1020,
-1040,
2150
],
"size": [
@@ -890,13 +950,13 @@
300
],
"flags": {},
"order": 17,
"order": 18,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 17
"link": 19
}
],
"outputs": [],
@@ -911,24 +971,24 @@
"title": "8. NE → 透明 PNG 序列帧"
},
{
"id": 19,
"id": 20,
"type": "Note",
"pos": [
-2260,
-2680,
880
],
"size": [
700,
900
740,
1120
],
"flags": {},
"order": 18,
"order": 19,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"八方向行走动画 —— 完整管线\n\n【前置步骤(在本工作流之外完成)】\n 角色图 →(GPTimage2)→ 八方向静态图 →(Seedance 首尾帧)→ 循环行走视频\n 视频每一帧上都排布着 8 个朝向的角色。\n\n【本工作流做的事】\n ① VHS「Load Video」把视频转成序列帧\n · force_rate 就是帧率选择。设 0 用源视频帧率;\n 设 12 可减半出帧、素材更小,走路动画通常 8~12 帧一循环就够\n · frame_load_cap 可限制总帧数,先小批量试参数很有用\n ② 本仓库「八方向序列拆分」:切格 + 编号 + 按方向分组,一次给出 8 条序列\n ③ 每个方向各接一个 VHS「Video Combine」出透明 webm\n ④ 每个方向各接一个 SaveImage 出透明 PNG 序列帧\n\n【拆分节点的关键参数】\n · grid_cols / grid_rows / empty_cells\n 3×3 中间留空即 empty_cells=4。序号行优先、从 0 开始。\n · direction_names\n 按「跳过空格后的先后顺序」命名,默认对应下面这个排布:\n SW S SE ← 行1:面向观众\n W E ← 行2:侧面\n NW N NE ← 行3:背对观众\n 与你的素材不符就直接改这行,名字只影响标注不影响画面。\n · bg_mode / bg_threshold\n 白底转透明。只把「与画面边缘相连」的白色判为背景,\n 所以角色身上的白衬衫、白高光不会被掏空。\n 背景没扣净就调低阈值,角色边缘被啃就调高。\n · expand_beyond_cell(务必开启)\n 允许角色超出格子边界。关闭时按格线严格切分,角色只要探出格线\n 就会被切断(最典型的是脚被削掉)。开启后格子只用来判定方向,\n 实际范围由角色自身的连通区域决定。\n · auto_crop\n 裁掉多余空白。裁剪框取该方向所有帧的并集,\n 因此整条序列尺寸一致(能合成视频),角色也不会在帧间跳动。\n\n【为什么不用连通区域拆分(本仓库的「素材拆分」节点)】\n 那个节点按包围盒排序,角色走动时位置浮动,一旦跨过排序行界方向就会错乱;\n 且每个 sprite 按各自 bbox 裁剪、尺寸不一,无法直接合成视频。\n 固定网格没有这两个问题 —— 但它更适合单张静态合图,两者各有用途。\n\n【webm 透明的关键】\n Video Combine 的 format 必须选 video/webm,且 pix_fmt 选 yuva420p。\n 注意:alpha 存放在 WebM 的独立边带里,所以用 ffprobe 看主流会显示\n yuv420p,这是正常的。播放器/引擎(走 libvpx 解码)能正确读到透明通道;\n 若要用 ffmpeg 自行验证,需显式加 -c:v libvpx-vp9 解码,\n 否则内置 vp9 解码器不会输出 alpha,会让人误以为透明丢了。\n\n【实测(97 帧 834×1112 的素材)】\n 拆分耗时约 4 秒,8 个方向各 97 帧,尺寸 172×370 ~ 206×327。"
"八方向行走动画 —— 完整管线\n\n【前置步骤(在本工作流之外完成)】\n 角色图 →(GPTimage2)→ 八方向静态图 →(Seedance 首尾帧)→ 循环行走视频\n 视频每一帧上都排布着 8 个朝向的角色。\n\n【本工作流做的事】\n ① VHS「Load Video」把视频转成序列帧\n · force_rate 就是帧率选择。设 0 用源视频帧率;\n 设 12 可减半出帧、素材更小,走路动画通常 8~12 帧一循环就够\n · frame_load_cap 可限制总帧数,先小批量试参数很有用\n ② Lucida 抠图,为拆分提供语义级 alpha(见下方「为什么要接抠图节点」)\n ③ 八方向拆分:切格 + 编号 + 按方向分组,一次给出 8 条序列\n ④ 每个方向各接一个 VHS「Video Combine」出透明 webm\n ⑤ 每个方向各接一个 SaveImage 出透明 PNG 序列帧\n\n■ 为什么要接抠图节点,而不是让拆分节点自己扣白底 ————\n\n拆分节点自带的「白底转透明」是纯颜色阈值法:按「离白色多远」估 alpha。\n它的死穴是角色身上的白色衣物 —— 白衬衫本身就接近白,alpha 天生偏低,\n一旦收边压白边,衬衫就会被啃出破洞(实测会看到背景色透过衣服)。\n\nLucida 是按语义判断的,知道那是衣服而不是背景。实测(同一素材、\n同等白边水平):主体被啃掉的面积比阈值法少 35%,白衬衫完好。\nLucida 一次就能把整张雪碧图的 8 个角色全部识别出来\n(各格前景占比 0.18~0.24,中间空格 0.002)。\n\n素材里确实没有白色元素时,可以把 bg_mode 改成「白底转透明」省掉模型开销;\n此时 edge_shrink 要调到 0.35 以上才压得住白边。\n\n■ 治白边的两个参数 ————————————————————\n\n · edge_shrink(主力)把边缘那圈几乎全是背景的半透明像素收掉。\n 配模型 alpha 用 0.15~0.25;配阈值法要 0.35+。\n 实测:0 → 白边强度 0.048;0.2 → 0.026;0.5 → 0.016。\n · decontaminate(辅助)颜色反溢出,按\n 观察色 = 前景×a + 白×(1-a) 反解真正的前景色。\n 单独用改善很小(约 3%),要和收边配合。\n\n■ 拆分节点的其他关键参数 ————————————————\n\n · grid_cols / grid_rows / empty_cells\n 3×3 中间留空即 empty_cells=4。序号行优先、从 0 开始。\n · direction_names\n 按「跳过空格后的先后顺序」命名,默认对应:\n SW S SE ← 行1:面向观众\n W E ← 行2:侧面\n NW N NE ← 行3:背对观众\n 与素材不符就直接改这行,名字只影响标注不影响画面。\n · expand_beyond_cell(务必开启)\n 允许角色超出格子边界。关闭时按格线严格切分,角色只要探出格线\n 就会被切断(最典型的是脚被削掉)。开启后格子只用来判定方向,\n 实际范围由角色自身的连通区域决定。三种 bg_mode 都有效。\n · auto_crop\n 裁剪框取该方向所有帧的并集,因此整条序列尺寸一致(能合成视频),\n 角色也不会在帧间跳动。\n\n■ webm 透明的关键 ————————————————————\n\n Video Combine 的 format 必须选 video/webm,pix_fmt 必须选 yuva420p。\n 注意:alpha 存放在 WebM 的独立边带里,用 ffprobe 看主流会显示 yuv420p,\n 这是正常的。播放器/引擎(走 libvpx 解码)能正确读到透明通道;\n 若要用 ffmpeg 自行验证,需显式加 -c:v libvpx-vp9 解码,\n 否则内置 vp9 解码器不输出 alpha,会让人误以为透明丢了。"
],
"color": "#432",
"bgcolor": "#653"
@@ -944,141 +1004,157 @@
"IMAGE"
],
[
2,
2,
0,
3,
0,
"IMAGE"
],
[
3,
2,
0,
4,
0,
"IMAGE"
],
[
4,
2,
1,
5,
0,
3,
0,
"IMAGE"
],
[
3,
2,
0,
3,
1,
"MASK"
],
[
4,
3,
0,
4,
0,
"IMAGE"
],
[
5,
2,
3,
0,
5,
0,
"IMAGE"
],
[
6,
3,
1,
6,
0,
"IMAGE"
],
[
6,
2,
2,
7,
3,
1,
7,
0,
"IMAGE"
],
[
7,
2,
8,
3,
2,
8,
0,
"IMAGE"
],
[
8,
2,
9,
3,
2,
9,
0,
"IMAGE"
],
[
9,
2,
10,
3,
3,
10,
0,
"IMAGE"
],
[
10,
2,
4,
11,
3,
3,
11,
0,
"IMAGE"
],
[
11,
2,
12,
3,
4,
12,
0,
"IMAGE"
],
[
12,
2,
5,
13,
3,
4,
13,
0,
"IMAGE"
],
[
13,
2,
14,
3,
5,
14,
0,
"IMAGE"
],
[
14,
2,
6,
15,
3,
5,
15,
0,
"IMAGE"
],
[
15,
2,
16,
3,
6,
16,
0,
"IMAGE"
],
[
16,
2,
7,
17,
3,
6,
17,
0,
"IMAGE"
],
[
17,
2,
18,
3,
7,
18,
0,
"IMAGE"
],
[
19,
3,
7,
19,
0,
"IMAGE"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.55,
"scale": 0.5,
"offset": [
2400,
2820,
100
]
}