Files
rui40000-RUI-Nodes/feynobg_node.py
T
rui40000andClaude Opus 4.8 5d3834503e fix: 八方向拆分不再切断角色;全仓库参数补齐中文 tooltip
【修复】角色被格线切断(脚、手杖、飘起的斗篷被削掉)
新增 expand_beyond_cell(默认开启):格子只用来判定「这是哪个方向」,
角色的实际范围由它自身的连通区域决定,按质心归属确保邻居不混入。
实测 8/8 方向的裁剪框边缘 alpha 从 1.00(内容顶到边界=被切断)
降到 0.00,S 方向高度 326→356、E 方向宽度 150→188 把缺的部分找了回来。
代价是需要两遍扫描(先求全序列并集框再提取),耗时 4.7s→14.9s。

【规则】每个参数都必须有中文 tooltip,作为以后的统一约定
全仓库 26 个节点 169 个参数,此前缺 115 个,现已 100% 覆盖。
tooltip 写「怎么调」而不只是「是什么」:给取值区间的实际影响、
推荐值与踩坑提示(如 OpenAI/ZenMux 的地址栏不能带 :// ,
素材拆分节点用于动画序列时顺序会漂移等)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 19:07:01 +08:00

353 lines
16 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 -*-
"""
FeyNobg 抠图节点(Ruinode)
===========================
对应模型:https://huggingface.co/feyninc/FeyNobg (Apache-2.0)
上游库: https://github.com/feyninc/nobg
FeyNobg 是 feyn 在 BiRefNet(CAAI AIR 2024)基础上扩展的通用抠图模型:
Swin-Large 主干 + 三项自定义增强(梯度注意力 use_gradient_attention、
图像块注入 use_image_patch_injection、多尺度输入 use_multi_scale_input),
原生 1024×1024 推理,权重约 1.05GB。
与 SDMatte 的定位差异:
- FeyNobg 全自动,不需要任何提示,一步出 alpha,适合批量去背景;
- SDMatte 需要框/掩码提示来指定抠哪个目标,适合画面里有多个主体时精确取一个。
实现说明:上游 nobg 的预处理模块继承 transformers>=5.4 的 TorchvisionBackend,
在 ComfyUI 常见的 transformers 4.x 上会 ImportError,故 Ruinode 内嵌了推理子集
并重写了预处理(见 feynobg/birefnet/image_processing_birefnet.py),
数值规格与官方对齐,且不需要升级 transformers。
"""
import os
import numpy as np
import torch
import folder_paths
# ---------------------------------------------------------------- 模型目录注册
NOBG_DIR = os.path.join(folder_paths.models_dir, "nobg")
os.makedirs(NOBG_DIR, exist_ok=True)
HF_REPO = "feyninc/FeyNobg"
DEFAULT_MODEL_DIRNAME = "FeyNobg"
_MODEL_CACHE = {}
_NO_MODEL_HINT = "未找到模型,将自动下载"
def _is_model_dir(p):
"""一个模型快照目录需同时具备 config.json 与权重文件。"""
if not os.path.isdir(p):
return False
if not os.path.isfile(os.path.join(p, "config.json")):
return False
return any(f.endswith((".safetensors", ".bin"))
for f in os.listdir(p))
def _list_models():
"""扫描 models/nobg 下的模型快照目录。"""
found = []
if os.path.isdir(NOBG_DIR):
for name in sorted(os.listdir(NOBG_DIR)):
if _is_model_dir(os.path.join(NOBG_DIR, name)):
found.append(name)
return found or [_NO_MODEL_HINT]
def _ensure_model(name):
"""返回模型目录;不存在时从 HuggingFace 拉取到 models/nobg/FeyNobg。"""
if name and name != _NO_MODEL_HINT:
path = os.path.join(NOBG_DIR, name)
if _is_model_dir(path):
return path
target = os.path.join(NOBG_DIR, DEFAULT_MODEL_DIRNAME)
if _is_model_dir(target):
return target
print(f"[Ruinode-FeyNobg] 本地未找到模型,开始从 HuggingFace 下载 {HF_REPO}"
f"(约 1.05GB)→ {target}")
try:
from huggingface_hub import snapshot_download
snapshot_download(
HF_REPO, local_dir=target,
allow_patterns=["*.json", "*.safetensors"],
)
except Exception as e:
raise RuntimeError(
f"自动下载失败:{e}\n"
f"请手动下载 https://huggingface.co/{HF_REPO} 的 "
f"config.json / preprocessor_config.json / model.safetensors,"
f"放到:{target}"
)
if not _is_model_dir(target):
raise RuntimeError(f"下载完成但目录不完整,请检查:{target}")
print(f"[Ruinode-FeyNobg] 下载完成:{target}")
return target
def _remap_swin_keys(state_dict):
"""
把 transformers 5.x 保存的 Swin 权重键名,改写成 4.x 的命名。
FeyNobg 的权重是用 transformers 5.x 导出的,而 ComfyUI 环境普遍还是 4.x,
两版的 SwinBackbone 模块命名不同(数学结构一致,纯改名):
bb.swin.X -> bb.X (5.x 多包一层 swin)
attention.{q,k,v}_proj -> attention.self.{query,key,value}
attention.o_proj -> attention.output.dense
attention.relative_position_bias
.relative_position_bias_table -> attention.self.relative_position_bias_table
mlp.fc1 / mlp.fc2 -> intermediate.dense / output.dense
不改名直接 load_state_dict(strict=False) 的后果:958 个参数里只有 405 个能对上,
整个 backbone 形同随机初始化 —— 模型照样能跑完,但输出的 alpha 几乎全黑
(实测 max 0.02、mean 0.000)。这类"不报错的错"最难排查,故此处显式处理。
"""
out = {}
for k, v in state_dict.items():
nk = k
if nk.startswith("bb.swin."):
nk = "bb." + nk[len("bb.swin."):]
nk = nk.replace(".attention.q_proj.", ".attention.self.query.")
nk = nk.replace(".attention.k_proj.", ".attention.self.key.")
nk = nk.replace(".attention.v_proj.", ".attention.self.value.")
nk = nk.replace(".attention.o_proj.", ".attention.output.dense.")
nk = nk.replace(".attention.relative_position_bias.relative_position_bias_table",
".attention.self.relative_position_bias_table")
nk = nk.replace(".mlp.fc1.", ".intermediate.dense.")
nk = nk.replace(".mlp.fc2.", ".output.dense.")
out[nk] = v
return out
def _load_weights_strict(model, ckpt_path):
"""加载权重并严格校验,宁可报错也不接受"能跑但结果劣化"的静默失配。"""
from safetensors.torch import load_file
sd = load_file(ckpt_path)
own = set(model.state_dict().keys())
direct = len(own & set(sd.keys()))
remapped = _remap_swin_keys(sd)
after = len(own & set(remapped.keys()))
# 权重与本机 transformers 命名不一致时才改写;若环境本就是 5.x,直接匹配更优
if after > direct:
print(f"[Ruinode-FeyNobg] 权重键名按 transformers 4.x 改写:"
f"{direct} -> {after} / {len(own)} 匹配")
sd = remapped
result = model.load_state_dict(sd, strict=False)
# relative_position_index 是由窗口大小推出的确定性 buffer,构造时已算好,不需要权重;
# bb.layernorm 是 backbone 末端的 norm,BiRefNet 只取中间各级特征图,用不到。
# 这两类之外的任何缺失/多余,都意味着结构对不上,必须叫停。
bad_missing = [k for k in result.missing_keys
if not k.endswith("relative_position_index")]
bad_unexpected = [k for k in result.unexpected_keys
if not k.startswith("bb.layernorm.")]
if bad_missing or bad_unexpected:
for k in bad_missing[:8]:
print(f" 未被覆盖: {k}")
for k in bad_unexpected[:8]:
print(f" 未被使用: {k}")
raise RuntimeError(
f"权重与网络结构不匹配(异常缺失 {len(bad_missing)},异常多余 "
f"{len(bad_unexpected)})。继续推理只会得到劣化结果,故中止。\n"
f"请确认权重为 feyninc/FeyNobg,且 transformers 版本受支持"
f"(已在 4.57 与 5.x 上验证)。"
)
print(f"[Ruinode-FeyNobg] 权重加载完成({len(own) - len(result.missing_keys)}"
f"/{len(own)} 个张量,另 {len(result.missing_keys)} 个为确定性 buffer)")
def _load_model(model_dir, dtype, device):
key = (model_dir, str(dtype), str(device))
cached = _MODEL_CACHE.get(key)
if cached is not None:
return cached
import dataclasses
import json
from .feynobg import BiRefNet, BiRefNetConfig
print(f"[Ruinode-FeyNobg] 加载模型:{model_dir}({dtype},{device})")
with open(os.path.join(model_dir, "config.json"), "r", encoding="utf-8") as f:
raw_cfg = json.load(f)
# config.json 里含 nobg_version 等非模型字段,按 dataclass 的字段名过滤
valid = {f.name for f in dataclasses.fields(BiRefNetConfig)}
cfg = BiRefNetConfig(**{k: v for k, v in raw_cfg.items() if k in valid})
ckpt = os.path.join(model_dir, "model.safetensors")
if not os.path.isfile(ckpt):
raise FileNotFoundError(f"缺少权重文件:{ckpt}")
_MODEL_CACHE.clear() # 单份约 1GB,不做多份缓存
# 不走 BiRefNet.from_pretrained:它内部按 huggingface_hub 的宽松策略加载,
# 键名对不上时会静默丢弃权重(正是上面 _remap_swin_keys 说明的那个坑)
model = BiRefNet(cfg)
_load_weights_strict(model, ckpt)
model.eval()
model.to(device=device, dtype=dtype)
_MODEL_CACHE[key] = model
return model
class RuiFeyNobg:
"""FeyNobg 全自动抠图:输入图像,输出 alpha 与去背景图。"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE", {
"tooltip": "待抠图的图像,支持批量(逐张推理,避免显存峰值过高)。\n"
"RGBA 输入会自动丢弃 alpha 只取 RGB。"
}),
"model_name": (_list_models(), {
"tooltip": "放在 models/nobg 下的模型目录(需含 config.json 与 "
"model.safetensors)。\n"
"留空或未找到时,首次运行会自动从 HuggingFace 下载 "
"feyninc/FeyNobg(约 1.05GB)。"
}),
"resolution": ([512, 768, 1024, 1280, 1536], {
"default": 1024,
"tooltip": "推理分辨率。模型原生训练分辨率为 1024,改动会影响细节与显存:\n"
"调低更省显存但边缘变粗;调高不一定更好,可能出现结构断裂。"
}),
"precision": (["fp32", "fp16"], {
"default": "fp32",
"tooltip": "实测两者输出一致(同图 alpha 均值都是 0.657),\n"
"但 fp16 显存减半且明显更快,推荐优先用 fp16。\n"
"保留 fp32 作默认,是为老显卡上半精度异常时有个退路。"
}),
"device": (["auto", "cpu"], {
"default": "auto",
"tooltip": "auto = 有显卡就用显卡。\n"
"cpu 会慢十倍以上,仅在显存实在不够时用。"
}),
},
"optional": {
"alpha_threshold": ("FLOAT", {
"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01,
"tooltip": "多大置信度才算前景。模型对拿不准的区域会输出 0.5 上下的\n"
"中间值,表现为「整片主体半透明发灰」。\n"
"把阈值调低(如 0.3)可把这类区域拉回不透明。\n"
"注意:只对已有一定响应的区域有效;模型压根没认出来的\n"
"地方 alpha 接近 0,再降阈值也救不回来。"
}),
"alpha_softness": ("FLOAT", {
"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01,
"tooltip": "阈值两侧过渡带的宽度,决定边缘软硬。\n"
"1.0 = 完全不处理,原样输出模型结果(默认)\n"
"0.2~0.4 = 压掉灰雾但保留发丝级过渡(推荐从 0.3 试)\n"
"0 = 硬二值化,边缘变成锯齿硬边,抠玻璃/头发慎用"
}),
"keep_aspect_ratio": ("BOOLEAN", {
"default": False,
"tooltip": "模型固定吃 1024×1024,默认会把图直接拉伸成正方形\n"
"(与官方训练方式一致)。长图/宽图形变严重时可开启此项,\n"
"改为等比缩放 + 边缘延展补边,推理后再裁掉补边部分。\n"
"注意这与训练分布不同,属于试验性选项:\n"
"极端长宽比(如手机截图 1:2 以上)通常有改善,常规比例建议关闭。"
}),
"invert_mask": ("BOOLEAN", {
"default": False,
"tooltip": "反转 alpha:默认前景为白(1),开启后前景为黑。"
}),
},
}
RETURN_TYPES = ("MASK", "IMAGE")
RETURN_NAMES = ("alpha", "cutout")
FUNCTION = "matting"
CATEGORY = "Rui-Node🐶/抠图✂️"
@classmethod
def VALIDATE_INPUTS(cls, model_name, **kwargs):
"""模型列表随目录变化,宽松放行,运行时兜底(含自动下载)。"""
return True
@staticmethod
def _remap_alpha(alpha, threshold, softness):
"""
按阈值 / 柔和度重新映射 alpha,相当于给遮罩拉一次色阶。
以 threshold 为中心、softness 为宽度取一段区间线性拉伸到 [0,1]:
区间以下压成全透明,以上提成全不透明,区间内保留平滑过渡。
默认 (0.5, 1.0) 时区间恰好是 [0,1],等于原样返回。
"""
low = threshold - softness / 2.0
high = threshold + softness / 2.0
if high <= low: # softness=0:硬二值化
return (alpha >= threshold).to(alpha.dtype)
if abs(low) < 1e-6 and abs(high - 1.0) < 1e-6:
return alpha # 默认参数,一个像素都不动
return ((alpha - low) / (high - low)).clamp(0.0, 1.0)
def matting(self, image, model_name, resolution, precision, device,
alpha_threshold=0.5, alpha_softness=1.0,
keep_aspect_ratio=False, invert_mask=False):
import comfy.model_management
from .feynobg import BiRefNetImageProcessor
model_dir = _ensure_model(model_name)
dev = torch.device("cpu") if device == "cpu" \
else comfy.model_management.get_torch_device()
dtype = torch.float32 if precision == "fp32" else torch.float16
model = _load_model(model_dir, dtype, dev)
proc = BiRefNetImageProcessor.from_pretrained(model_dir)
res = int(resolution)
proc.size = {"height": res, "width": res}
# ComfyUI 的 IMAGE 约定是 RGB,但上游抠图类节点常输出 RGBA
B, H, W, C = image.shape
if C == 4:
image = image[..., :3]
elif C == 1:
image = image.repeat(1, 1, 1, 3)
elif C != 3:
raise ValueError(f"image 需要 1 / 3 / 4 通道,实际收到 {C} 通道")
image = image.contiguous()
alphas = []
for i in range(B):
# 逐张推理:1024 分辨率下 Swin-Large 峰值显存不低,整批一次容易 OOM
pixel_values, meta = proc.preprocess_tensor(
image[i:i + 1], device=dev, dtype=dtype,
keep_aspect=bool(keep_aspect_ratio))
with torch.no_grad():
outputs = model(pixel_values=pixel_values)
# fp16 推理出的 logits 先转回 fp32 再 sigmoid/缩放,避免精度损失
if isinstance(outputs, dict):
outputs = {"logits": outputs["logits"].float()}
alpha = proc.post_process_alpha_matting(
outputs, target_sizes=[(H, W)], crop=meta)[0]
alphas.append(alpha.clamp(0, 1).cpu())
alpha = torch.stack(alphas) # [B,H,W]
alpha = self._remap_alpha(alpha, float(alpha_threshold),
float(alpha_softness))
if invert_mask:
alpha = 1.0 - alpha
cutout = image.detach().cpu().float() * alpha.unsqueeze(-1)
return (alpha, cutout)
NODE_CLASS_MAPPINGS = {
"RuiFeyNobg": RuiFeyNobg,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"RuiFeyNobg": "FeyNobg 抠图 / FeyNobg Matting",
}