满屏文字水印(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>
219 lines
8.3 KiB
Python
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",
|
|
}
|