Files
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

288 lines
12 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 -*-
"""
Lucida 抠图节点(Ruinode)
==========================
模型:https://huggingface.co/egeorcun/lucida (MIT)
项目:https://github.com/egeorcun/lucida
Lucida 是 BiRefNet_HR 的微调版,训练目标是攻克多数开源抠图模型的短板:
伪装物体、透明材质(玻璃)、文字与 Logo、VFX 光效、插画。
作者在 203 图 9 类别基准上的 MAE(越低越好):
伪装 0.0270、插画 0.0092、文字/Logo 0.0091(商业参考为 0.0123)、
印刷设计 0.0235、总体 0.0257。
与本仓库另两个抠图节点的分工:
- Lucida :全自动。文字/Logo、插画、玻璃、伪装物体场景下最强
- FeyNobg:全自动。常规主体照片,速度快
- SDMatte:需框/掩码提示,适合画面中多个主体、只抠其中一个
实现说明:
- 模型代码内嵌在 lucida/ 子包(见其 __init__.py),不使用 trust_remote_code,
避免运行时联网执行远程代码;
- 预处理与 FeyNobg 完全一致(1024 双线性 + ImageNet 标准化),
直接复用那份已验证过的实现,不重复造轮子;
- 模型内部 Config.size=1024 且 dec_ipt_split=True,decoder 与 1024 输入绑定,
因此不开放分辨率选项(实测非 1024 会因通道数不匹配报错)。
"""
import os
import numpy as np
import torch
import folder_paths
# ---------------------------------------------------------------- 模型目录注册
LUCIDA_DIR = os.path.join(folder_paths.models_dir, "lucida")
os.makedirs(LUCIDA_DIR, exist_ok=True)
if "lucida" in folder_paths.folder_names_and_paths:
_paths, _exts = folder_paths.folder_names_and_paths["lucida"]
if LUCIDA_DIR not in _paths:
_paths.append(LUCIDA_DIR)
_exts.update({".safetensors", ".pth", ".pt"})
else:
folder_paths.folder_names_and_paths["lucida"] = (
[LUCIDA_DIR], {".safetensors", ".pth", ".pt"})
HF_REPO = "egeorcun/lucida"
DEFAULT_CKPT = "lucida.safetensors"
_NO_MODEL_HINT = "未找到权重,将自动下载"
_MODEL_CACHE = {}
def _list_ckpts():
files = []
if os.path.isdir(LUCIDA_DIR):
files = sorted(f for f in os.listdir(LUCIDA_DIR)
if f.lower().endswith((".safetensors", ".pth", ".pt")))
return files or [_NO_MODEL_HINT]
def _ensure_ckpt(name):
"""返回权重路径;没有就从 HuggingFace 拉一份(约 885MB)。"""
if name and name != _NO_MODEL_HINT:
p = os.path.join(LUCIDA_DIR, name)
if os.path.isfile(p):
return p
target = os.path.join(LUCIDA_DIR, DEFAULT_CKPT)
if os.path.isfile(target):
return target
print(f"[Ruinode-Lucida] 本地未找到权重,开始从 HuggingFace 下载 {HF_REPO}"
f"(约 885MB)→ {target}")
try:
from huggingface_hub import hf_hub_download
got = hf_hub_download(HF_REPO, "model.safetensors", local_dir=LUCIDA_DIR)
src = os.path.join(LUCIDA_DIR, "model.safetensors")
if os.path.isfile(src) and not os.path.isfile(target):
os.rename(src, target)
elif os.path.isfile(got) and not os.path.isfile(target):
target = got
except Exception as e:
raise RuntimeError(
f"自动下载失败:{e}\n"
f"请手动下载 https://huggingface.co/{HF_REPO} 的 model.safetensors,"
f"重命名为 {DEFAULT_CKPT} 放到:{LUCIDA_DIR}"
)
if not os.path.isfile(target):
raise RuntimeError(f"下载完成但未找到权重文件:{target}")
print(f"[Ruinode-Lucida] 下载完成:{target}")
return target
def _load_weights_strict(model, ckpt_path):
"""加载权重并严格校验,绝不接受"能跑但结果劣化"的静默失配。"""
if ckpt_path.lower().endswith(".safetensors"):
from safetensors.torch import load_file
sd = load_file(ckpt_path)
else:
sd = torch.load(ckpt_path, map_location="cpu")
sd = sd.get("state_dict", sd) if isinstance(sd, dict) else sd
# transformers 保存时可能带 model. 前缀,去掉后再比对
own = set(model.state_dict().keys())
if len(own & set(sd.keys())) == 0:
stripped = {k.split("model.", 1)[-1] if k.startswith("model.") else k: v
for k, v in sd.items()}
if len(own & set(stripped.keys())) > 0:
print("[Ruinode-Lucida] 权重键名去掉 'model.' 前缀后对齐")
sd = stripped
result = model.load_state_dict(sd, strict=False)
# BiRefNet 的 Swin 会注册 relative_position_index / attn_mask 一类
# 由窗口尺寸推出的确定性 buffer,构造时已算好,不需要也不该来自权重
benign = ("relative_position_index", "attn_mask")
bad_missing = [k for k in result.missing_keys if not k.endswith(benign)]
bad_unexpected = list(result.unexpected_keys)
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"请确认权重确为 egeorcun/lucida 的 model.safetensors。"
)
print(f"[Ruinode-Lucida] 权重加载完成({len(own) - len(result.missing_keys)}"
f"/{len(own)} 个张量,另 {len(result.missing_keys)} 个为确定性 buffer)")
def _load_model(ckpt_path, dtype, device):
key = (ckpt_path, str(dtype), str(device))
cached = _MODEL_CACHE.get(key)
if cached is not None:
return cached
from .lucida import BiRefNet, BiRefNetConfig
print(f"[Ruinode-Lucida] 加载模型:{os.path.basename(ckpt_path)}"
f"({dtype},{device})")
_MODEL_CACHE.clear() # 单份 880MB 起,不做多份缓存
# bb_pretrained=False:只搭骨架,不去联网拉 Swin 的 ImageNet 预训练权重
model = BiRefNet(config=BiRefNetConfig(bb_pretrained=False))
_load_weights_strict(model, ckpt_path)
model.eval()
model.to(device=device, dtype=dtype)
_MODEL_CACHE[key] = model
return model
class RuiLucida:
"""Lucida 全自动抠图:擅长文字/Logo、插画、玻璃与伪装物体。"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE", {
"tooltip": "待抠图的图像,支持批量(逐张推理,避免显存峰值过高)。\n"
"RGBA 输入会自动丢弃 alpha 只取 RGB。"
}),
"model_name": (_list_ckpts(), {
"tooltip": "放在 models/lucida 下的权重(.safetensors)。\n"
"未找到时首次运行会自动从 HuggingFace 下载"
"egeorcun/lucida(约 885MB)。"
}),
"precision": (["fp32", "fp16"], {
"default": "fp16",
"tooltip": "fp16 显存减半、速度更快,实测与 fp32 输出基本一致。\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"
"只对已有一定响应的区域有效。"
}),
"alpha_softness": ("FLOAT", {
"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01,
"tooltip": "阈值两侧过渡带宽度,决定边缘软硬。\n"
"1.0 = 完全不处理,原样输出模型结果(默认)\n"
"0.2~0.4 = 压掉灰雾但保留发丝级过渡\n"
"0 = 硬二值化。Lucida 强项之一是玻璃等真实半透明,\n"
"调小会把这类效果一并压掉,务必按素材取舍。"
}),
"keep_aspect_ratio": ("BOOLEAN", {
"default": False,
"tooltip": "模型固定吃 1024×1024,默认把图直接拉伸成正方形\n"
"(与官方用法一致)。长图/宽图形变严重时可开启,\n"
"改为等比缩放 + 边缘延展补边,推理后裁掉补边。\n"
"属试验性选项,常规比例建议关闭。"
}),
"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):
"""按阈值/柔和度给遮罩拉一次色阶;默认 (0.5, 1.0) 时原样返回。"""
low = threshold - softness / 2.0
high = threshold + softness / 2.0
if high <= low:
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, precision, device,
alpha_threshold=0.5, alpha_softness=1.0,
keep_aspect_ratio=False, invert_mask=False):
import comfy.model_management
# BiRefNet 系模型的预处理规格相同(1024 + ImageNet 标准化),
# 直接复用 FeyNobg 节点里那份已验证的实现
from .feynobg import BiRefNetImageProcessor
ckpt = _ensure_ckpt(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(ckpt, dtype, dev)
proc = BiRefNetImageProcessor(size={"height": 1024, "width": 1024})
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):
# 逐张推理:Swin-Large 在 1024 下峰值显存不低,整批一次容易 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():
preds = model(pixel_values)
# eval 模式下 BiRefNet.forward 返回各尺度预测的列表,最后一项是最终结果
logits = preds[-1] if isinstance(preds, (list, tuple)) else preds
alpha = proc.post_process_alpha_matting(
{"logits": logits.float()}, 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 = {
"RuiLucida": RuiLucida,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"RuiLucida": "Lucida 抠图 / Lucida Matting",
}