feat: 新增满屏文字水印与 FeyNobg 抠图两个节点
满屏文字水印(watermark_node.py): - 文案/字体/字号/旋转角度/密度/字间距/透明度/颜色 八项可调 - 交错网格平铺后整体旋转再从中心裁切,任意角度下四角均无空白 FeyNobg 抠图(feynobg_node.py + feynobg/): - 内嵌 nobg 推理子集(Apache-2.0),全自动去背景,输出 alpha 与去背景图 - 重写预处理去掉对 transformers>=5.4 的依赖,4.x 环境可直接使用 - 修复权重键名与 transformers 4.x 的 SwinBackbone 命名不兼容: 不处理时 958 个参数仅 405 个对得上,backbone 形同随机初始化, 模型不报错但 alpha 几乎全黑;现按环境自动重映射并严格校验, 除确定性 buffer 外任何失配都直接中止 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
9dd4fbd890
commit
1bd90c71b6
@@ -0,0 +1,218 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
满屏文字水印节点
|
||||
================
|
||||
给输入图像铺满一层平铺的文字水印,常用于版权标注 / 防盗图。
|
||||
|
||||
可调参数:
|
||||
1. text —— 水印文案(支持多行,用换行分隔)
|
||||
2. font —— 字体样式(下拉,来自 Ruinode/font 目录,同 Markdown 节点)
|
||||
3. font_size —— 文字大小(像素)
|
||||
4. angle —— 水印整体旋转角度(度,-180~180)
|
||||
5. density —— 水印密度(综合控制行间距与同行水印之间的间距,值越大越密)
|
||||
6. letter_spacing —— 字间距(单条文案内相邻字符的额外间距,像素,可为负)
|
||||
7. opacity —— 水印透明程度(0=完全透明,100=完全不透明)
|
||||
8. color —— 水印文字颜色(#RRGGBB / #RGB / "r,g,b" / 常见英文色名)
|
||||
|
||||
实现要点:在一张比原图对角线还大的透明画布上按交错网格平铺文字,
|
||||
整体旋转后从中心裁出与原图等大的区域,再 alpha 合成回原图,
|
||||
这样任意旋转角度下都能保证四角也被水印覆盖,不留空白。
|
||||
"""
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from .mdimg.fonts import scan_fonts, load as load_font, DEFAULT_FONT_LABEL
|
||||
|
||||
|
||||
# 常见英文色名兜底
|
||||
_COLOR_NAMES = {
|
||||
"white": (255, 255, 255), "black": (0, 0, 0), "red": (255, 0, 0),
|
||||
"green": (0, 128, 0), "blue": (0, 0, 255), "gray": (128, 128, 128),
|
||||
"grey": (128, 128, 128), "yellow": (255, 255, 0), "orange": (255, 165, 0),
|
||||
"cyan": (0, 255, 255), "magenta": (255, 0, 255), "silver": (192, 192, 192),
|
||||
}
|
||||
|
||||
|
||||
def _parse_color(s):
|
||||
"""把颜色字符串解析为 (r, g, b);无法识别时回退灰色。"""
|
||||
s = str(s or "").strip()
|
||||
if not s:
|
||||
return (128, 128, 128)
|
||||
if s.startswith("#"):
|
||||
h = s[1:].strip()
|
||||
if len(h) == 3: # #RGB -> #RRGGBB
|
||||
h = "".join(c * 2 for c in h)
|
||||
if len(h) == 6:
|
||||
try:
|
||||
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
|
||||
except ValueError:
|
||||
pass
|
||||
if "," in s: # "r,g,b"
|
||||
parts = [p.strip() for p in s.split(",")]
|
||||
try:
|
||||
vals = [max(0, min(255, int(float(p)))) for p in parts[:3]]
|
||||
if len(vals) == 3:
|
||||
return tuple(vals)
|
||||
except ValueError:
|
||||
pass
|
||||
return _COLOR_NAMES.get(s.lower(), (128, 128, 128))
|
||||
|
||||
|
||||
def _line_width(font, text, spacing):
|
||||
"""含字间距时单行文本的像素宽度。"""
|
||||
if not text:
|
||||
return 0.0
|
||||
w = 0.0
|
||||
for ch in text:
|
||||
w += font.getlength(ch) + spacing
|
||||
return max(0.0, w - spacing) # 末字符不加尾随间距
|
||||
|
||||
|
||||
def _draw_spaced_line(draw, xy, text, font, fill, spacing):
|
||||
"""逐字符绘制一行文本,字与字之间插入 spacing 像素间距。"""
|
||||
x, y = xy
|
||||
for ch in text:
|
||||
draw.text((x, y), ch, font=font, fill=fill)
|
||||
x += font.getlength(ch) + spacing
|
||||
|
||||
|
||||
class ImageWatermarkNode:
|
||||
"""满屏文字水印。"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
font_labels = list(scan_fonts().keys())
|
||||
default_font = DEFAULT_FONT_LABEL if DEFAULT_FONT_LABEL in font_labels \
|
||||
else (font_labels[0] if font_labels else "")
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"text": ("STRING", {
|
||||
"default": "仅供参考 / SAMPLE",
|
||||
"multiline": True,
|
||||
}),
|
||||
"font": (font_labels, {"default": default_font}),
|
||||
"font_size": ("INT", {
|
||||
"default": 48, "min": 8, "max": 500, "step": 1,
|
||||
}),
|
||||
"angle": ("FLOAT", {
|
||||
"default": 30.0, "min": -180.0, "max": 180.0, "step": 1.0,
|
||||
}),
|
||||
"density": ("FLOAT", {
|
||||
"default": 1.0, "min": 0.1, "max": 5.0, "step": 0.05,
|
||||
}),
|
||||
"letter_spacing": ("INT", {
|
||||
"default": 0, "min": -20, "max": 200, "step": 1,
|
||||
}),
|
||||
"opacity": ("FLOAT", {
|
||||
"default": 35.0, "min": 0.0, "max": 100.0, "step": 1.0,
|
||||
}),
|
||||
"color": ("STRING", {"default": "#FFFFFF", "multiline": False}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "apply_watermark"
|
||||
CATEGORY = "Rui-Node🐶/图像调节🎨"
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, font, **kwargs):
|
||||
"""字体列表会随 font 目录变化,宽松放行,运行时兜底回退。"""
|
||||
return True
|
||||
|
||||
# ---- 生成单张图的水印层并合成 ----
|
||||
def _watermark_one(self, pil_img, text, font, font_size, angle,
|
||||
density, letter_spacing, opacity, color):
|
||||
w, h = pil_img.size
|
||||
rgb = _parse_color(color)
|
||||
alpha = int(round(max(0.0, min(100.0, opacity)) / 100.0 * 255))
|
||||
if alpha <= 0 or not text.strip():
|
||||
return pil_img.convert("RGB") # 全透明或空文案:原样返回
|
||||
fill = (rgb[0], rgb[1], rgb[2], alpha)
|
||||
|
||||
lines = text.split("\n")
|
||||
ascent, descent = font.getmetrics()
|
||||
line_h = ascent + descent
|
||||
inner_gap = max(1, int(line_h * 0.25)) # 文案内部多行的行距
|
||||
tile_w = max((_line_width(font, ln, letter_spacing) for ln in lines),
|
||||
default=0.0)
|
||||
tile_h = line_h * len(lines) + inner_gap * (len(lines) - 1)
|
||||
tile_w = max(tile_w, 1.0)
|
||||
tile_h = max(tile_h, 1.0)
|
||||
|
||||
# density 越大间距越小:以字号为基准的空隙除以密度
|
||||
gap = max(2.0, font_size * 2.0 / max(0.1, density))
|
||||
step_x = tile_w + gap
|
||||
step_y = tile_h + gap
|
||||
|
||||
# 画布要盖住旋转后的对角线,四周再留一圈平铺余量
|
||||
diag = math.ceil(math.sqrt(w * w + h * h))
|
||||
margin = int(max(step_x, step_y) + max(tile_w, tile_h)) + 10
|
||||
cw = diag + 2 * margin
|
||||
ch = diag + 2 * margin
|
||||
|
||||
layer = Image.new("RGBA", (cw, ch), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(layer)
|
||||
|
||||
# 交错平铺(隔行水平偏移半步),视觉更自然
|
||||
row = 0
|
||||
y = -step_y
|
||||
while y < ch + step_y:
|
||||
x_off = (step_x / 2.0) if (row % 2) else 0.0
|
||||
x = -step_x + x_off
|
||||
while x < cw + step_x:
|
||||
ty = y
|
||||
for ln in lines:
|
||||
if ln:
|
||||
_draw_spaced_line(draw, (x, ty), ln, font, fill,
|
||||
letter_spacing)
|
||||
ty += line_h + inner_gap
|
||||
x += step_x
|
||||
y += step_y
|
||||
row += 1
|
||||
|
||||
# 整体旋转后从中心裁出与原图等大的区域
|
||||
rotated = layer.rotate(angle, resample=Image.BICUBIC, expand=False)
|
||||
left = (cw - w) // 2
|
||||
top = (ch - h) // 2
|
||||
crop = rotated.crop((left, top, left + w, top + h))
|
||||
|
||||
base = pil_img.convert("RGBA")
|
||||
out = Image.alpha_composite(base, crop)
|
||||
return out.convert("RGB")
|
||||
|
||||
def apply_watermark(self, image, text, font, font_size, angle,
|
||||
density, letter_spacing, opacity, color):
|
||||
# 加载字体(沿用 Markdown 节点的扫描/回退逻辑)
|
||||
fmap = scan_fonts()
|
||||
font_path = fmap.get(font)
|
||||
if font_path is None:
|
||||
font_path = next(iter(fmap.values()), "")
|
||||
print(f"[Rui-Node] 水印字体 '{font}' 不在列表中,已回退 "
|
||||
f"{font_path or '内置默认'}")
|
||||
pil_font = load_font(font_path, font_size)
|
||||
|
||||
results = []
|
||||
batch = image.shape[0]
|
||||
for i in range(batch):
|
||||
arr = np.clip(image[i].cpu().numpy(), 0.0, 1.0)
|
||||
pil_img = Image.fromarray((arr * 255).astype(np.uint8), "RGB")
|
||||
out = self._watermark_one(
|
||||
pil_img, text, pil_font, int(font_size), float(angle),
|
||||
float(density), int(letter_spacing), float(opacity), color)
|
||||
out_np = np.asarray(out, dtype=np.float32) / 255.0
|
||||
results.append(torch.from_numpy(out_np))
|
||||
|
||||
return (torch.stack(results),)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ImageWatermark": ImageWatermarkNode,
|
||||
}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ImageWatermark": "满屏文字水印 / Full-Screen Text Watermark",
|
||||
}
|
||||
Reference in New Issue
Block a user