Files
rui40000-RUI-Nodes/unmult_node.py
T
rui40000andClaude Opus 4.8 a1f52de801 feat: Unmult 增加「主体保护」开关,控制是否采纳 subject_mask
subject_mask 的合并逻辑此前已存在,本次补上开关:
- 启用(默认):alpha 取 max(unmult 结果, subject_mask)
- 关闭:即便上游已连线也完全不采纳,等同纯 Unmult
想对比「有无 AI 介入」的差别时拨开关即可,不必拔线。
兼容旧英文名 use_subject_mask。

顺带把黑点/白点 tooltip 里的「(主体保护)」字样去掉 —— 那是描述
参数作用的措辞,和新开关撞名会让人以为是同一功能。

实测(黑底 + 暗色实体,alpha 真值恒为 1,下半近纯黑):
  不接 mask              暗部 alpha 0.080  ← 黑衣黑发被扣穿
  接 mask + 保护启用      暗部 alpha 1.000
  接 mask + 保护关闭      暗部 alpha 0.080,与不接 mask 逐元素一致
即开关确实起作用,且关闭时行为与纯 Unmult 完全等价。

这同时解掉了上次记录的适用边界:黑底 unmult 本质是拿亮度当不透明度,
对实体不成立;现在接一路语义抠图的 alpha 进 subject_mask 即可破解 ——
语义模型负责「哪里是主体」,Unmult 负责「边缘有多透」。README 已按此改写。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 15:44:32 +08:00

196 lines
8.3 KiB
Python
Raw 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 后黑衣区域强制保留为不透明。
支持批量输入(序列帧/视频帧),逐帧处理后堆叠输出。
"""
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 去底,可选主体保护。
参数:
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)
fg_list = []
alpha_list = []
for i in range(B):
frame = image[i].cpu().numpy().astype(np.float32)
smask = None
if subject_mask_tensor is not None:
if subject_mask_tensor.dim() == 2:
raw = subject_mask_tensor.cpu().numpy().astype(np.float32)
else:
idx = min(i, subject_mask_tensor.shape[0] - 1)
raw = subject_mask_tensor[idx].cpu().numpy().astype(np.float32)
if raw.shape[0] != H or raw.shape[1] != W:
import cv2
raw = cv2.resize(raw, (W, H), interpolation=cv2.INTER_LINEAR)
smask = np.clip(raw, 0.0, 1.0)
fg, a = _unmult_frame(frame, bg_rgb,
float(alpha_low), float(alpha_high),
subject_mask=smask)
rgba = np.concatenate([fg, a[..., np.newaxis]], axis=-1)
fg_list.append(torch.from_numpy(rgba))
alpha_list.append(torch.from_numpy(a))
rgba_out = torch.stack(fg_list) # [B, H, W, 4]
alpha_out = torch.stack(alpha_list) # [B, H, W]
return (rgba_out, alpha_out)
NODE_CLASS_MAPPINGS = {
"RuiUnmult": RuiUnmult,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"RuiUnmult": "半透明抠图 / Unmult Matting",
}