From 2a6fd42c57559f621944ff2f785a31469aabf14b Mon Sep 17 00:00:00 2001 From: rui40000 Date: Thu, 30 Jul 2026 10:23:54 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=B2=BB=E7=90=86=E6=8A=A0=E5=9B=BE?= =?UTF-8?q?=E7=99=BD=E8=BE=B9=E2=80=94=E2=80=94=E6=8E=A8=E8=8D=90=E6=94=B9?= =?UTF-8?q?=E7=94=A8=E6=A8=A1=E5=9E=8B=20alpha=EF=BC=8C=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=94=B6=E8=BE=B9=E4=B8=8E=E9=A2=9C=E8=89=B2=E5=8F=8D=E6=BA=A2?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈边缘一圈白边。诊断:按 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 --- README.md | 26 +- eightdir_node.py | 100 +++++++- example_workflow/八方向行走动画.json | 368 ++++++++++++++++----------- 3 files changed, 334 insertions(+), 160 deletions(-) diff --git a/README.md b/README.md index c9b39b7..863ecf8 100644 --- a/README.md +++ b/README.md @@ -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) **三个关键设计**: diff --git a/eightdir_node.py b/eightdir_node.py index 79bd865..718f7c7 100644 --- a/eightdir_node.py +++ b/eightdir_node.py @@ -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}") diff --git a/example_workflow/八方向行走动画.json b/example_workflow/八方向行走动画.json index 2377843..b894ed3 100644 --- a/example_workflow/八方向行走动画.json +++ b/example_workflow/八方向行走动画.json @@ -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 ] }