Files
rui40000-RUI-Nodes/watermark_node.py
T
rui40000andClaude Opus 4.8 1bd90c71b6 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>
2026-07-29 09:26:05 +08:00

219 lines
8.3 KiB
Python

# -*- 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",
}