feat: FeyNobg 抠图开放半透明控制与长图形变补救参数

针对「主体整片半透明发灰」的实际反馈,模型侧无可调参数
(架构参数训练时已固化),故在后处理层开放:

- alpha_threshold / alpha_softness:以阈值为中心、柔和度为宽度
  取一段区间线性拉伸到 [0,1],压掉灰雾同时保留边缘过渡。
  默认 (0.5, 1.0) 区间恰为 [0,1],是恒等变换,行为与之前逐位一致;
  实测 (0.35, 0.3) 半透明像素占比 1.49% -> 0.31%,主体均值几乎不变
- keep_aspect_ratio:模型固定吃 1024 方图,长图默认被拉伸压扁。
  开启后改为等比缩放 + 边缘延展补边(不填黑,避免造出假轮廓),
  推理后按有效区域裁掉补边

同步更新示例工作流的 widgets_values(widget 由 5 个增至 8 个,
不同步会导致参数错位)与 Note 说明。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rui40000
2026-07-29 10:10:10 +08:00
co-authored by Claude Opus 4.8
parent 01a26baea9
commit be93fd36a3
4 changed files with 109 additions and 18 deletions
+21
View File
@@ -865,6 +865,9 @@ WAS Node Suite 的「Text Multiline」会把 `#` 开头的行**当注释删除**
- `resolution` (选择): 推理分辨率,默认 **1024**(模型原生训练分辨率)。调低省显存但边缘变粗;调高不一定更好,可能出现结构断裂
- `precision` (选择): `fp32`(默认)/ `fp16`。**实测两者输出一致**(同图 alpha 均值均为 0.657),fp16 显存减半且明显更快,推荐优先用 fp16
- `device` (选择): `auto` / `cpu`
- `alpha_threshold` (FLOAT, 可选): 前景判定阈值,默认 0.5。见下方「主体半透明发灰怎么救」
- `alpha_softness` (FLOAT, 可选): 阈值两侧过渡带宽度,默认 **1.0 = 完全不处理**
- `keep_aspect_ratio` (BOOLEAN, 可选): 保持宽高比(等比缩放 + 边缘延展补边),默认关闭
- `invert_mask` (BOOLEAN, 可选): 反转 alpha,默认前景为白
**输出**:
@@ -881,6 +884,24 @@ WAS Node Suite 的「Text Multiline」会把 `#` 开头的行**当注释删除**
发丝、飘带、细链条等高频细节均能完整分离,边缘为自然的半透明过渡而非硬边。
**主体「整片半透明发灰」怎么救**:
模型对拿不准的区域会输出 0.5 上下的中间值,表现为整个人物/物体呈半透明。模型本身**没有**开放任何控制该行为的参数(`use_gradient_attention` 等是训练时固化的架构参数,推理期不可调),因此节点在后处理层提供了一对色阶参数:
| alpha_threshold | alpha_softness | 效果 |
|:---------------:|:--------------:|:-----|
| 0.5 | 1.0 | **默认**,原样输出,一个像素都不动 |
| 0.35 | 0.3 | **推荐**,半透明像素占比 1.49% → 0.31%(降 79%),主体均值几乎不变 |
| 0.5 | 0.0 | 硬二值化,锯齿硬边,抠头发/玻璃慎用 |
原理是以 `threshold` 为中心、`softness` 为宽度取一段区间线性拉伸到 [0,1]:区间以下压成全透明,以上提成全不透明,区间内保留平滑过渡。默认参数下该区间恰好是 [0,1],等于恒等变换。
两点边界必须说明:
- **只对已有一定响应的区域有效**。模型压根没认出来的地方 alpha 接近 0,再降阈值也救不回来——那属于语义判断差异(BiRefNet-General 倾向保留画面全部前景,FeyNobg 更强调「找主要主体」),需要换模型或改用 SDMatte 指定目标。
- `softness` 越小,发丝等**真实**半透明细节损失越多,是一对权衡。
**长图形变**:模型固定吃 1024×1024,默认把图直接拉伸成正方形(与官方训练方式一致)。手机截图这类 1:2 以上的长图横向会被压到一半,可开 `keep_aspect_ratio` 改为等比缩放 + 边缘延展补边、推理后裁掉补边。实测 2.36:1 的图半透明占比 0.1192 → 0.1041。该选项与训练分布不同,属试验性,常规比例建议保持关闭。
**实现说明**(两个坑,都已在节点内处理):
1. **预处理依赖**:上游 `nobg` 的预处理模块继承 `transformers>=5.4` 的 `TorchvisionBackend`,而 ComfyUI 常见环境仍是 transformers 4.x,直接引入会报 `No module named 'transformers.image_processing_backends'`。本节点内嵌了 nobg 推理子集(`feynobg/`)并**重写了预处理**,数值规格与官方逐项对齐(1024 双线性抗锯齿缩放 + ImageNet 标准化;后处理先 sigmoid 再缩放),**无需升级 transformers**。同时绕开了上游 `AutoModel` 里会联网查 tags 的 `model_info()`,保证离线可用。
+3 -3
View File
@@ -39,7 +39,7 @@
"id": 2,
"type": "RuiFeyNobg",
"pos": [-1240, 400],
"size": [320, 170],
"size": [340, 260],
"flags": {},
"order": 1,
"mode": 0,
@@ -66,7 +66,7 @@
"aux_id": "rui40000/Ruinode",
"Node name for S&R": "RuiFeyNobg"
},
"widgets_values": ["FeyNobg", 1024, "fp16", "auto", false],
"widgets_values": ["FeyNobg", 1024, "fp16", "auto", 0.5, 1, false, false],
"color": "#232",
"bgcolor": "#353",
"title": "② FeyNobg 抠图(全自动,无需提示)"
@@ -247,7 +247,7 @@
"outputs": [],
"properties": {},
"widgets_values": [
"FeyNobg 抠图 —— 使用要点\n\n【模型】首次运行会自动下载到 ComfyUI/models/nobg/FeyNobg(约 1.05GB)。\n 也可手动下载 https://huggingface.co/feyninc/FeyNobg 的三个文件放进去:\n config.json / preprocessor_config.json / model.safetensors\n\n【precision】推荐 fp16\n 实测 fp16 与 fp32 输出完全一致(同图 alpha 均值都是 0.657),\n 但 fp16 显存减半、速度快数倍。默认给 fp32 只是为老显卡留退路。\n\n【resolution】保持 1024\n 这是模型原生训练分辨率。降到 768 差别很小(前景占比 0.656 vs 0.659)\n 且更省显存;调高不一定更好,可能出现结构断裂。\n\n【适用范围】画面主体明确时效果最好\n 实测发丝、飘带、细链条等高频细节都能完整分离,边缘是自然的半透明过渡。\n 但它是「找出画面主体」的模型:给一张没有明确主体的图(例如 UI 截图、\n 纯风景),输出接近全黑是正常反应,不是故障。\n\n【与 SDMatte 的分工】\n · FeyNobg:全自动、快,适合批量去背景 —— 主体明确时首选\n · SDMatte:需要框/掩码提示指定目标,适合画面里有多个主体、只抠其中一个\n\n【透明 PNG】cutout 输出是黑底 RGB。要透明背景需走下面这条链路:\n alpha → InvertMask → JoinImageWithAlpha(该节点内部会再反一次,故须先反相)\n\n【invert_mask】节点自带的反转开关只影响 alpha 本身,\n 与上面为 JoinImageWithAlpha 做的反相是两回事,别混用。"
"FeyNobg 抠图 —— 使用要点\n\n【模型】首次运行会自动下载到 ComfyUI/models/nobg/FeyNobg(约 1.05GB)。\n 也可手动下载 https://huggingface.co/feyninc/FeyNobg 的三个文件放进去:\n config.json / preprocessor_config.json / model.safetensors\n\n【precision】推荐 fp16\n 实测 fp16 与 fp32 输出完全一致,但显存减半、速度快数倍。\n 默认给 fp32 只是为老显卡留退路。\n\n【resolution】保持 1024,这是模型原生训练分辨率。\n\n■ 主体「整片半透明发灰」怎么救 ————————————————\n\n模型对拿不准的区域会输出 0.5 上下的中间值,表现为整个人物/物体半透明。\n用下面两个参数拉一次色阶即可:\n\n【alpha_threshold】多大置信度算前景,默认 0.5\n【alpha_softness】过渡带宽度,默认 1.0 = 完全不处理\n\n · 默认 (0.5, 1.0) —— 原样输出,一个像素都不动\n · 推荐 (0.35, 0.3) —— 实测半透明像素占比从 1.49% 降到 0.31%,\n 主体均值几乎不变,即只压灰雾、不吃主体\n · 硬边 (0.5, 0.0) —— 直接二值化,锯齿硬边,抠头发/玻璃慎用\n\n ⚠ 只对已有一定响应的区域有效。模型压根没认出来的地方 alpha 接近 0,\n 再降阈值也救不回来 —— 那属于语义判断差异,得换模型或改用 SDMatte 指定目标。\n ⚠ softness 越小,发丝等真实半透明细节损失越多,是一对权衡。\n\n【keep_aspect_ratio】长图形变的补救,默认关闭\n 模型固定吃 1024×1024,默认把图直接拉伸成正方形(与官方训练一致)。\n 手机截图这类 1:2 以上的长图会被压得很扁,可开启此项改为等比缩放 + 补边。\n 属试验性选项(与训练分布不同),常规比例建议保持关闭。\n\n【适用范围】它是「找出画面主体」的模型\n 发丝、飘带、细链条等高频细节都能完整分离。但给一张没有明确主体的图\n (UI 截图、纯风景),输出接近全黑是正常反应,不是故障。\n\n【与 SDMatte 的分工】\n · FeyNobg:全自动、快,适合批量去背景 —— 主体明确时首选\n · SDMatte:需要框/掩码提示指定目标,适合画面里有多个主体、只抠其中一个\n\n【透明 PNG】cutout 输出是黑底 RGB。要透明背景需走下面这条链路:\n alpha → InvertMask → JoinImageWithAlpha(该节点内部会再反一次,故须先反相)\n\n【invert_mask】节点自带的反转开关只影响 alpha 本身,\n 与上面为 JoinImageWithAlpha 做的反相是两回事,别混用。"
],
"color": "#432",
"bgcolor": "#653"
+37 -11
View File
@@ -65,25 +65,47 @@ class BiRefNetImageProcessor:
)
# ---------------------------------------------------------------- 预处理
def preprocess_tensor(self, images, device=None, dtype=torch.float32):
def preprocess_tensor(self, images, device=None, dtype=torch.float32,
keep_aspect=False):
"""
ComfyUI 原生张量入口,避免 PIL 往返。
images: (B, H, W, 3) float,值域 [0,1](ComfyUI 的 IMAGE 约定)
返回: (B, 3, size_h, size_w),已 ImageNet 标准化
keep_aspect: False 走官方路径,直接拉伸成正方形(与训练一致);
True 则按长边等比缩放后补边,保留原始比例。
返回 (pixel_values, meta):
pixel_values (B, 3, size_h, size_w),已 ImageNet 标准化
meta keep_aspect 时为有效区域 (nh, nw),供后处理裁掉补边;否则 None
"""
x = images.permute(0, 3, 1, 2).contiguous().float() # BHWC -> BCHW
# 官方走 torchvision 的 resize,对张量默认开抗锯齿;缩小到 1024 时
# 是否抗锯齿对细边缘影响可见,这里保持一致
x = F.interpolate(
x, size=(self.size["height"], self.size["width"]),
mode="bilinear", align_corners=False, antialias=True,
)
th, tw = self.size["height"], self.size["width"]
meta = None
if keep_aspect:
_, _, H, W = x.shape
scale = min(th / H, tw / W)
nh = max(1, min(th, int(round(H * scale))))
nw = max(1, min(tw, int(round(W * scale))))
x = F.interpolate(x, size=(nh, nw), mode="bilinear",
align_corners=False, antialias=True)
# 补边放在右/下侧,用边缘像素延展而非填黑:填黑会凭空造出一条
# 高对比直边,模型容易把它当成物体轮廓
if nh < th or nw < tw:
x = F.pad(x, (0, tw - nw, 0, th - nh), mode="replicate")
meta = (nh, nw)
else:
# 官方走 torchvision 的 resize,对张量默认开抗锯齿;缩小到 1024 时
# 是否抗锯齿对细边缘影响可见,这里保持一致
x = F.interpolate(x, size=(th, tw), mode="bilinear",
align_corners=False, antialias=True)
mean = torch.tensor(self.image_mean, dtype=torch.float32).view(1, 3, 1, 1)
std = torch.tensor(self.image_std, dtype=torch.float32).view(1, 3, 1, 1)
x = (x - mean) / std
return x.to(device=device, dtype=dtype) if device is not None \
x = x.to(device=device, dtype=dtype) if device is not None \
else x.to(dtype=dtype)
return x, meta
def __call__(self, images, return_tensors="pt", **kwargs):
"""PIL 入口,兼容官方 README 的用法:processor(image, return_tensors='pt')。"""
@@ -97,17 +119,21 @@ class BiRefNetImageProcessor:
im = im.convert("RGB")
arrs.append(np.asarray(im, dtype=np.float32) * self.rescale_factor)
batch = torch.from_numpy(np.stack(arrs)) # (B,H,W,3) in [0,1]
return {"pixel_values": self.preprocess_tensor(batch)}
return {"pixel_values": self.preprocess_tensor(batch)[0]}
# ---------------------------------------------------------------- 后处理
def post_process_alpha_matting(self, outputs, target_sizes=None):
def post_process_alpha_matting(self, outputs, target_sizes=None, crop=None):
"""
原始 logits -> 每图 alpha([0,1],形状 (H, W))。
outputs: 含 "logits" 的 dict 或带 .logits 的对象,logits 形状 (B,1,H,W)
target_sizes: [(h, w), ...],逐图缩放回原尺寸
crop: 预处理若做过等比补边,这里传 (nh, nw) 把补出来的部分裁掉
"""
logits = outputs["logits"] if isinstance(outputs, dict) else outputs.logits
if crop is not None:
nh, nw = crop
logits = logits[:, :, :nh, :nw]
if target_sizes is not None and len(logits) != len(target_sizes):
raise ValueError(
f"给了 {len(target_sizes)} 个目标尺寸,但批次里有 {len(logits)} 张图"
+48 -4
View File
@@ -228,6 +228,29 @@ class RuiFeyNobg:
"device": (["auto", "cpu"], {"default": "auto"}),
},
"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),开启后前景为黑。"
@@ -245,8 +268,26 @@ class RuiFeyNobg:
"""模型列表随目录变化,宽松放行,运行时兜底(含自动下载)。"""
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,
invert_mask=False):
alpha_threshold=0.5, alpha_softness=1.0,
keep_aspect_ratio=False, invert_mask=False):
import comfy.model_management
from .feynobg import BiRefNetImageProcessor
@@ -274,18 +315,21 @@ class RuiFeyNobg:
alphas = []
for i in range(B):
# 逐张推理:1024 分辨率下 Swin-Large 峰值显存不低,整批一次容易 OOM
pixel_values = proc.preprocess_tensor(
image[i:i + 1], device=dev, dtype=dtype)
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)])[0]
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