Files
rui40000andClaude Opus 5 becde22105 优化 Unmult 批处理:改 torch 分块运算,124 帧提速 3.8 倍
原实现逐帧 numpy 处理再 torch.stack,124 帧 1024x1024 需 9.98 秒。
瓶颈有二:单帧内近十次中间数组分配(每份 12MB),以及 numpy 绝大多数
算子单线程、多核闲置。

改动:
- 核心运算改用 torch 算子分块处理,走 CPU 多线程;原地运算压掉中间分配
- 结果直写预分配张量,省掉最后那次 stack 大拷贝
- 主体 mask 的 resize 移出循环,不再对同一张 mask 重复缩放 B 次
- 分块而非整批,控制中间量内存峰值

实测 9.98s -> 2.61s(3.82x)。与旧实现 24 组对拍全部逐元素一致,
涵盖黑/白/绿幕/自定义底色、黑白点、mask 的四种形状、主体保护开关、
旧英文参数名、RGBA/单通道输入。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:36:45 +08:00

238 lines
10 KiB
Python
Raw Permalink 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 -*-
"""
半透明抠图节点(Unmult)—— Ruinode
==================================
纯数学去底算法,等效 AE Unmult 效果。
适用于渲染在纯色底上的光效、火焰、烟雾、粒子、UI 特效等半透明素材。
原理:纯色背景合成图满足 C = αF + (1-α)B,
其中 B 为已知背景色。通过各通道与背景色的差异反推 α 和前景色 F。
支持任意背景色(黑/白/绿幕/自定义),纯数学变换,无模型推理。
AI 主体保护(可选):接入 BiRefNet / FeyNobg 等抠图节点输出的 subject_mask,
用 max(unmult_α, subject_mask) 合并,防止主体中与背景色相近的区域被误判为透明。
该合并由「主体保护」开关控制,关闭时即便连了 mask 也不采纳。
典型场景:黑底光效中人物穿黑衣 → 纯 Unmult 会让黑衣半透明,
接入主体 mask 后黑衣区域强制保留为不透明。
支持批量输入(序列帧/视频帧)。批量走 torch 多线程分块运算,
结果直写预分配张量;124 帧 1024x1024 实测 2.6 秒。
"""
import numpy as np
import torch
def _hex_to_rgb01(hex_str: str) -> tuple:
"""将 #RRGGBB 格式的颜色字符串转为 (r, g, b) 浮点元组,值域 [0,1]。"""
h = hex_str.strip().lstrip("#")
if len(h) == 3:
h = h[0] * 2 + h[1] * 2 + h[2] * 2
if len(h) != 6:
raise ValueError(f"无效的颜色格式:{hex_str},需要 #RRGGBB")
r = int(h[0:2], 16) / 255.0
g = int(h[2:4], 16) / 255.0
b = int(h[4:6], 16) / 255.0
return (r, g, b)
def _unmult_frame(rgb: np.ndarray, bg_color: tuple,
alpha_low: float, alpha_high: float,
subject_mask: np.ndarray = None,
epsilon: float = 1e-6) -> tuple:
"""对单帧图像执行 Unmult 去底,可选主体保护。
注意:这是单帧参考实现,节点主路径已改走 unmult() 里的 torch 分块
批处理(快约 3.8 倍)。此函数保留用于对拍验证和外部复用,
两者数值逐元素一致。
参数:
rgb: float32 [H,W,3] 值域 [0,1]
bg_color: (R,G,B) 值域 [0,1]
alpha_low: 黑点(低于此值的 alpha 映射为 0)
alpha_high: 白点(高于此值的 alpha 映射为 1)
subject_mask: float32 [H,W] 值域 [0,1],主体区域为 1
返回:
(foreground [H,W,3], alpha [H,W]) 均 float32
"""
bg = np.array(bg_color, dtype=np.float32).reshape(1, 1, 3)
diff = rgb.astype(np.float32) - bg
scale = np.array([max(bg_color[c], 1.0 - bg_color[c], epsilon)
for c in range(3)], dtype=np.float32).reshape(1, 1, 3)
norm_diff = np.abs(diff) / scale
alpha = np.max(norm_diff, axis=-1)
alpha = np.clip(alpha, 0.0, 1.0)
if alpha_low > 0.0 or alpha_high < 1.0:
span = max(alpha_high - alpha_low, epsilon)
alpha = np.clip((alpha - alpha_low) / span, 0.0, 1.0)
if subject_mask is not None:
alpha = np.maximum(alpha, subject_mask)
alpha_safe = np.maximum(alpha, epsilon)
foreground = bg + diff / alpha_safe[..., np.newaxis]
foreground = np.clip(foreground, 0.0, 1.0)
transparent = alpha < epsilon
foreground[transparent] = 0.0
return foreground.astype(np.float32), alpha.astype(np.float32)
class RuiUnmult:
"""半透明抠图(Unmult):指定背景色,纯数学去底,输出 RGBA 图像 + Alpha 蒙版。
可选接入 AI 主体保护 mask,防止主体中近似背景色的区域被误判为透明。"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE", {
"tooltip": "待去底的图像,支持批量(序列帧/视频帧逐帧处理)。"
}),
"bg_color": ("STRING", {
"default": "#000000",
"tooltip": "要去除的背景色,#RRGGBB 格式。\n"
"常用值:#000000(黑底)、#FFFFFF(白底)、"
"#00FF00(绿幕)、#FF00FF(品红)。"
}),
"黑点": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 0.5, "step": 0.01,
"display": "slider",
"tooltip": "低于此值的 alpha 强制归零。\n"
"调高可清除背景残留噪点,但过高会丢失边缘细节。"
}),
"白点": ("FLOAT", {
"default": 1.0, "min": 0.3, "max": 1.0, "step": 0.01,
"display": "slider",
"tooltip": "高于此值的 alpha 强制归一。\n"
"调低可让主体更实、减少半透明损失,但过低会让边缘硬化。"
}),
"主体保护": ("BOOLEAN", {
"default": True,
"label_on": "启用",
"label_off": "关闭",
"tooltip": "是否采纳下方接入的 subject_mask。\n\n"
"启用:alpha 取 max(unmult 结果, subject_mask),\n"
" 主体区域强制不透明,光效边缘仍保留半透明。\n"
" 黑底素材里的黑衣、黑发全靠它保住。\n\n"
"关闭:即便已经连了 subject_mask 也完全不采纳,\n"
" 等同于纯 Unmult。想对比「有无 AI 介入」的差别时,\n"
" 拨这个开关即可,不必拔线。"
}),
},
"optional": {
"subject_mask": ("MASK", {
"tooltip": "AI 主体保护遮罩(可选)。\n"
"接入 FeyNobg / Lucida / BiRefNet 等抠图节点输出的 alpha,\n"
"节点会执行 max(unmult_α, subject_mask) 合并:\n"
"主体内部强制不透明,光效边缘保留半透明。\n\n"
"典型场景:黑底光效中人物穿黑衣 →\n"
"纯 Unmult 会让黑衣半透明,接入主体 mask 后黑衣保留。\n\n"
"不连接、或上方「主体保护」关闭时,等同于纯 Unmult,无 AI 介入。"
}),
},
}
RETURN_TYPES = ("IMAGE", "MASK")
RETURN_NAMES = ("rgba_image", "alpha")
FUNCTION = "unmult"
CATEGORY = "Rui-Node🐶/抠图✂️"
def unmult(self, **kwargs):
image = kwargs.get("image")
bg_color = kwargs.get("bg_color", "#000000")
alpha_low = kwargs.get("黑点", kwargs.get("alpha_low", 0.0))
alpha_high = kwargs.get("白点", kwargs.get("alpha_high", 1.0))
# 主体保护开关:关闭时彻底忽略 subject_mask,哪怕上游已经连线
use_subject = kwargs.get("主体保护", kwargs.get("use_subject_mask", True))
subject_mask_tensor = kwargs.get("subject_mask", None)
if not use_subject:
subject_mask_tensor = None
bg_rgb = _hex_to_rgb01(bg_color)
B, H, W, C = image.shape
if C == 4:
image = image[..., :3]
elif C == 1:
image = image.repeat(1, 1, 1, 3)
# 主体 mask 预处理:统一成 [N,H,W] float32,只做一次。
# 原先是循环里逐帧 resize,mask 只有一帧时会被重复 resize B 次。
smask_all = None
if subject_mask_tensor is not None:
sm = subject_mask_tensor
if sm.dim() == 2:
sm = sm.unsqueeze(0)
sm = sm.float()
if sm.shape[1] != H or sm.shape[2] != W:
import cv2
arr = sm.cpu().numpy()
arr = np.stack([cv2.resize(a, (W, H),
interpolation=cv2.INTER_LINEAR)
for a in arr])
sm = torch.from_numpy(arr)
smask_all = sm.clamp(0.0, 1.0)
# ── 批处理 ──
# 原实现是「逐帧 numpy → 收集成 list → torch.stack」。实测 124 帧
# 1024×1024 耗时 9 秒,瓶颈有二:单帧内近十次中间数组分配(每份
# 12MB),以及 numpy 绝大多数算子单线程、多核完全闲置。
# 改为 torch 分块处理:torch 的 CPU 算子走多线程,原地运算压掉中间
# 分配,结果直接写入预分配张量、省掉最后那次 stack 大拷贝。
# 分块而不是一口气吃下整批,是因为整批中间量会膨胀到数 GB。
px = max(1, H * W)
chunk = max(1, min(B, int(24_000_000 // px)))
bg_t = torch.tensor(bg_rgb, dtype=torch.float32).view(1, 1, 1, 3)
scale_t = torch.tensor(
[max(bg_rgb[c], 1.0 - bg_rgb[c], 1e-6) for c in range(3)],
dtype=torch.float32).view(1, 1, 1, 3)
eps = 1e-6
lo, hi = float(alpha_low), float(alpha_high)
use_levels = (lo > 0.0) or (hi < 1.0)
span = max(hi - lo, eps)
rgba_out = torch.empty((B, H, W, 4), dtype=torch.float32)
alpha_out = torch.empty((B, H, W), dtype=torch.float32)
for s in range(0, B, chunk):
e = min(B, s + chunk)
blk = image[s:e]
if blk.dtype != torch.float32:
blk = blk.float()
diff = blk - bg_t # [b,H,W,3]
alpha = diff.abs().div_(scale_t).amax(dim=-1).clamp_(0.0, 1.0)
if use_levels:
alpha.sub_(lo).div_(span).clamp_(0.0, 1.0)
if smask_all is not None:
# mask 帧数不足时复用最后一帧,与逐帧版一致
idx = torch.arange(s, e).clamp_(max=smask_all.shape[0] - 1)
alpha = torch.maximum(alpha, smask_all[idx])
# 反解前景:C = αF + (1-α)B ⇒ F = B + (C-B)/α
fg = diff.div_(alpha.clamp(min=eps).unsqueeze(-1)).add_(bg_t)
fg.clamp_(0.0, 1.0)
fg.mul_((alpha >= eps).unsqueeze(-1)) # 全透明处前景归零
rgba_out[s:e, :, :, :3] = fg
rgba_out[s:e, :, :, 3] = alpha
alpha_out[s:e] = alpha
return (rgba_out, alpha_out)
NODE_CLASS_MAPPINGS = {
"RuiUnmult": RuiUnmult,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"RuiUnmult": "半透明抠图 / Unmult Matting",
}