448 lines
20 KiB
Python
448 lines
20 KiB
Python
"""
|
|
XB-BOX AI 漫画文字渲染节点 (生产版 v3.0)
|
|
=========================================
|
|
基于"文本光栅化物理渲染 + CV视觉锚定 + 前端提示词分流"三层架构,
|
|
从根本上解决扩散模型生成中文对白时的笔画畸形、鬼影残留与排版错位问题。
|
|
|
|
节点清单:
|
|
XB_ComicPromptParser — 前端:智能提示词解析分流 + 字数-气泡尺寸映射
|
|
XB_ComicTextRenderer — 第一代:手动精确坐标渲染 (旁白/拟声词降级预案)
|
|
XB_AutoBubbleTextRenderer — 第二代:全自动 CV 寻迹 + 数字涂改液 + 三层防御
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import textwrap
|
|
import numpy as np
|
|
import torch
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
try:
|
|
import cv2
|
|
HAS_CV2 = True
|
|
except ImportError:
|
|
HAS_CV2 = False
|
|
|
|
|
|
# ============================================================
|
|
# 字体工具
|
|
# ============================================================
|
|
def _find_cjk_fonts():
|
|
"""扫描系统常见中文字体,返回可用字体路径列表"""
|
|
font_paths = []
|
|
win_font_dir = os.environ.get("WINDIR", "C:\\Windows") + "\\Fonts"
|
|
for fname in ["msyh.ttc", "msyhbd.ttc", "simhei.ttf", "simsun.ttc",
|
|
"simkai.ttf", "Deng.ttf", "simfang.ttf"]:
|
|
fp = os.path.join(win_font_dir, fname)
|
|
if os.path.exists(fp):
|
|
font_paths.append(fp)
|
|
# Linux / macOS fallback
|
|
if not font_paths:
|
|
for d in ["/usr/share/fonts/truetype/", "/System/Library/Fonts/"]:
|
|
if os.path.isdir(d):
|
|
for f in os.listdir(d):
|
|
if f.endswith((".ttf", ".ttc", ".otf")):
|
|
font_paths.append(os.path.join(d, f))
|
|
return font_paths
|
|
|
|
|
|
def _load_font(font_name, font_size):
|
|
"""加载字体:支持直接路径、文件名模糊搜索、系统回退"""
|
|
if not font_name:
|
|
font_name = ""
|
|
if os.path.isfile(font_name):
|
|
try:
|
|
return ImageFont.truetype(font_name, font_size)
|
|
except Exception:
|
|
pass
|
|
for fp in _find_cjk_fonts():
|
|
if font_name.lower() in os.path.basename(fp).lower():
|
|
try:
|
|
return ImageFont.truetype(fp, font_size)
|
|
except Exception:
|
|
continue
|
|
for fp in _find_cjk_fonts():
|
|
try:
|
|
return ImageFont.truetype(fp, font_size)
|
|
except Exception:
|
|
continue
|
|
return ImageFont.load_default()
|
|
|
|
|
|
def _parse_hex(color_str):
|
|
"""#RRGGBB → (R, G, B, 255)"""
|
|
c = color_str.lstrip("#")
|
|
if len(c) >= 6:
|
|
return (int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16), 255)
|
|
return (0, 0, 0, 255)
|
|
|
|
|
|
def _wrap_text(text, font, max_width, draw):
|
|
"""按最大宽度自动换行,返回行列表"""
|
|
if not text or max_width <= 0:
|
|
return [text] if text else []
|
|
lines = []
|
|
for para in text.split("\n"):
|
|
if not para:
|
|
lines.append("")
|
|
continue
|
|
cur = ""
|
|
for ch in para:
|
|
test = cur + ch
|
|
bw = draw.textbbox((0, 0), test, font=font)[2]
|
|
if bw > max_width and cur:
|
|
lines.append(cur)
|
|
cur = ch
|
|
else:
|
|
cur = test
|
|
if cur:
|
|
lines.append(cur)
|
|
return lines
|
|
|
|
|
|
# ============================================================
|
|
# 节点 A: XB_ComicPromptParser — 前端提示词智能分流
|
|
# ============================================================
|
|
class XB_ComicPromptParser:
|
|
"""从含 [台词] 标记的剧本中提取台词并清洗提示词。
|
|
自动根据字数向大模型注入匹配的气泡尺寸描述,实现 SSOT 单一数据源架构。"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"raw_prompt": ("STRING", {
|
|
"default": "第一格大图,男生冷酷,[宋琛眉眼清秀,嘴里却叼着根突兀烟],左侧气泡,[真难看。]",
|
|
"multiline": True,
|
|
"tooltip": "完整剧本。用方括号 [台词] 标记对白,节点会自动提取和清洗"
|
|
}),
|
|
"base_bubble_desc": ("STRING", {
|
|
"default": "纯白干净的空白对话框",
|
|
"tooltip": "气泡基础描述。节点会根据字数自动追加微小/标准/宽大/超大等修饰词"
|
|
}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING")
|
|
RETURN_NAMES = ("洗净给大模型的提示词", "给渲染节点的台词(|分隔)")
|
|
FUNCTION = "parse"
|
|
CATEGORY = "XB_ToolBox/Comic"
|
|
DESCRIPTION = (
|
|
"前端提示词拦截解析器 —— SSOT 架构核心。\n"
|
|
"输入含 [台词] 的完整剧本,自动:\n"
|
|
"1. 提取 [] 内台词,拼接为台词1|台词2 格式\n"
|
|
"2. 根据字数映射气泡尺寸 (<=3:微小, <=8:标准, <=18:宽大, >18:超大)\n"
|
|
"3. 清洗提示词,彻底屏蔽大模型看到汉字,根除鬼影"
|
|
)
|
|
|
|
def parse(self, raw_prompt, base_bubble_desc):
|
|
dialogues = []
|
|
|
|
def replacer(m):
|
|
text = m.group(1).strip()
|
|
if not text:
|
|
return ""
|
|
dialogues.append(text)
|
|
n = len(text)
|
|
if n <= 3:
|
|
adj = "一个微小紧凑的"
|
|
elif n <= 8:
|
|
adj = "一个标准大小的"
|
|
elif n <= 18:
|
|
adj = "一个宽大的"
|
|
else:
|
|
adj = "一个占据极大面积的超大长条形"
|
|
return adj + base_bubble_desc
|
|
|
|
cleaned = re.sub(r"\[(.*?)\]", replacer, raw_prompt, flags=re.DOTALL)
|
|
pipeline = " | ".join(dialogues)
|
|
print(f"[XB_ComicPromptParser] 提取 {len(dialogues)} 句台词,已按字数映射气泡尺寸")
|
|
return (cleaned, pipeline)
|
|
|
|
|
|
# ============================================================
|
|
# 节点 B: XB_ComicTextRenderer — 手动精确坐标 (降级预案)
|
|
# ============================================================
|
|
class XB_ComicTextRenderer:
|
|
"""在图像指定坐标用系统字体精确渲染文字。
|
|
用于全自动节点无法覆盖的场景:旁白、拟声词、无气泡文本。"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
"text": ("STRING", {"default": "", "multiline": True,
|
|
"tooltip": "要渲染的文字。支持 \\n 换行"}),
|
|
"font_name": ("STRING", {"default": "msyh.ttc",
|
|
"tooltip": "字体名或路径,如 msyh.ttc / simhei.ttf"}),
|
|
"font_size": ("INT", {"default": 36, "min": 8, "max": 500, "step": 1}),
|
|
"x": ("INT", {"default": 100, "min": 0, "max": 8192, "step": 1,
|
|
"tooltip": "文字起始 X 坐标"}),
|
|
"y": ("INT", {"default": 100, "min": 0, "max": 8192, "step": 1,
|
|
"tooltip": "文字起始 Y 坐标"}),
|
|
"max_width": ("INT", {"default": 400, "min": 0, "max": 8192, "step": 1,
|
|
"tooltip": "最大宽度(px),超宽自动换行;0=不限制"}),
|
|
"alignment": (["left", "center", "right"], {"default": "center"}),
|
|
"text_color": ("STRING", {"default": "#000000"}),
|
|
"outline_width": ("INT", {"default": 0, "min": 0, "max": 10, "step": 1}),
|
|
"outline_color": ("STRING", {"default": "#FFFFFF"}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE",)
|
|
RETURN_NAMES = ("图像",)
|
|
FUNCTION = "render"
|
|
CATEGORY = "XB_ToolBox/Comic"
|
|
DESCRIPTION = (
|
|
"第一代物理光栅化节点 —— 手动指定 X/Y 坐标,用系统原生字体精确渲染。\n"
|
|
"用于处理旁白、拟声词、无气泡文本等全自动节点无法覆盖的特殊场景。\n"
|
|
"提示: 如需多句不同位置,请串联多个此节点分层叠加。"
|
|
)
|
|
|
|
def render(self, image, text, font_name, font_size, x, y, max_width,
|
|
alignment, text_color, outline_width, outline_color):
|
|
fg = _parse_hex(text_color)
|
|
og = _parse_hex(outline_color)
|
|
font = _load_font(font_name, font_size)
|
|
|
|
B = image.shape[0]
|
|
out = []
|
|
for b in range(B):
|
|
arr = (image[b].cpu().numpy() * 255).astype(np.uint8)
|
|
pil = Image.fromarray(arr).convert("RGBA")
|
|
draw = ImageDraw.Draw(pil)
|
|
lines = _wrap_text(text, font, max_width, draw)
|
|
bb = draw.textbbox((0, 0), "啊", font=font)
|
|
lh = bb[3] - bb[1] + 4
|
|
|
|
for i, line in enumerate(lines):
|
|
lw = draw.textbbox((0, 0), line, font=font)[2]
|
|
if alignment == "center" and max_width > 0:
|
|
lx = x + (max_width - lw) // 2
|
|
elif alignment == "right" and max_width > 0:
|
|
lx = x + max_width - lw
|
|
else:
|
|
lx = x
|
|
ly = y + i * lh
|
|
if outline_width > 0:
|
|
for dx in range(-outline_width, outline_width + 1):
|
|
for dy in range(-outline_width, outline_width + 1):
|
|
if dx or dy:
|
|
draw.text((lx + dx, ly + dy), line, font=font, fill=og)
|
|
draw.text((lx, ly), line, font=font, fill=fg)
|
|
|
|
result = np.array(pil.convert("RGB")).astype(np.float32) / 255.0
|
|
out.append(torch.from_numpy(result))
|
|
return (torch.stack(out, dim=0),)
|
|
|
|
|
|
# ============================================================
|
|
# 节点 C: XB_AutoBubbleTextRenderer — 全自动 CV 寻迹 + 涂改液
|
|
# ============================================================
|
|
class XB_AutoBubbleTextRenderer:
|
|
"""OpenCV 自动扫描白色对话框 -> 涂改液抹除鬼影 -> 填入台词。
|
|
含三层防御: 暗黑模式 / 形状过滤 / 边界分离。
|
|
形态学预处理解决气泡内纹理导致的识别断裂。"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
"text": ("STRING", {"default": "", "multiline": True,
|
|
"tooltip": "台词,竖线 | 分隔。如: 台词1 | 台词2"}),
|
|
"font_name": ("STRING", {"default": "msyh.ttc",
|
|
"tooltip": "字体名或路径,如 msyh.ttc / simhei.ttf"}),
|
|
"font_size": ("INT", {"default": 36, "min": 8, "max": 500, "step": 1}),
|
|
"text_color": ("STRING", {"default": "#000000"}),
|
|
"bubble_margin": ("INT", {"default": 15, "min": 0, "max": 200, "step": 1}),
|
|
"auto_clear_bubble": ("BOOLEAN", {
|
|
"default": True,
|
|
"tooltip": "数字涂改液: 用纯白填满气泡内部,彻底抹除 AI 鬼影乱码后再印字"
|
|
}),
|
|
"white_threshold": ("INT", {"default": 220, "min": 160, "max": 255, "step": 1,
|
|
"tooltip": "白色阈值。降低可检测带光影的气泡"}),
|
|
"min_bubble_area": ("INT", {"default": 5000, "min": 500, "max": 1000000, "step": 100}),
|
|
"sort_mode": (["auto", "top_to_bottom", "left_to_right", "largest_first"], {"default": "auto"}),
|
|
"invert_mode": ("BOOLEAN", {"default": False,
|
|
"tooltip": "暗黑模式: 取反后检测深色气泡"}),
|
|
"shape_filter_enabled": ("BOOLEAN", {"default": True,
|
|
"tooltip": "形状过滤: 排除白T恤等长条形误检"}),
|
|
"min_extent": ("FLOAT", {"default": 0.4, "min": 0.1, "max": 1.0, "step": 0.05}),
|
|
"max_aspect_ratio": ("FLOAT", {"default": 3.5, "min": 1.0, "max": 10.0, "step": 0.1}),
|
|
"erode_iterations": ("INT", {"default": 1, "min": 0, "max": 5, "step": 1,
|
|
"tooltip": "边界分离: 腐蚀迭代,分离粘连气泡"}),
|
|
"morph_close_size": ("INT", {"default": 15, "min": 3, "max": 51, "step": 2,
|
|
"tooltip": "形态学内核尺寸。增大可填平气泡内的噪点和残影"}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "STRING")
|
|
RETURN_NAMES = ("图像", "检测信息")
|
|
FUNCTION = "render_auto"
|
|
CATEGORY = "XB_ToolBox/Comic"
|
|
DESCRIPTION = (
|
|
"第二代全自动 CV 视觉锚定节点 (v3.0 生产版)。\n\n"
|
|
"流程: 灰度化 -> 形态学闭运算填平噪点 -> 二值化 -> 轮廓检测 -> 涂改液抹白 -> 填字。\n\n"
|
|
"核心能力:\n"
|
|
"- auto_clear_bubble: 数字涂改液,先抹后写彻底根除鬼影\n"
|
|
"- morph_close_size: 形态学预处理,解决气泡内纹理/残影导致的识别断裂\n"
|
|
"- 三层防御: 暗黑模式(invert) + 形状过滤(shape_filter) + 边界分离(erode)\n"
|
|
"- 台词框输入 台词1 | 台词2 | 台词3,一键自动排版"
|
|
)
|
|
|
|
def render_auto(self, image, text, font_name, font_size, text_color,
|
|
bubble_margin, auto_clear_bubble, white_threshold,
|
|
min_bubble_area, sort_mode, invert_mode=False,
|
|
shape_filter_enabled=True, min_extent=0.4,
|
|
max_aspect_ratio=3.5, erode_iterations=1, morph_close_size=15):
|
|
if not HAS_CV2:
|
|
raise ImportError(
|
|
"[XB_AutoBubbleTextRenderer] 需要 opencv-python。请运行: pip install opencv-python")
|
|
|
|
fg = _parse_hex(text_color)
|
|
dialogues = [d.strip() for d in text.split("|") if d.strip()]
|
|
font = _load_font(font_name, font_size)
|
|
|
|
B = image.shape[0]
|
|
results = []
|
|
info = []
|
|
|
|
for b in range(B):
|
|
# ---- torch -> numpy -> BGR ----
|
|
arr = (image[b].cpu().numpy() * 255).astype(np.uint8)
|
|
if arr.shape[2] == 4:
|
|
cv_rgb = cv2.cvtColor(arr, cv2.COLOR_RGBA2RGB)
|
|
else:
|
|
cv_rgb = arr.copy()
|
|
cv_bgr = cv2.cvtColor(cv_rgb, cv2.COLOR_RGB2BGR)
|
|
|
|
# ---- 1. 灰度 + 暗黑模式 ----
|
|
gray = cv2.cvtColor(cv_bgr, cv2.COLOR_BGR2GRAY)
|
|
if invert_mode:
|
|
gray = cv2.bitwise_not(gray)
|
|
info.append(" 🌙 暗黑模式: 已取反")
|
|
|
|
# ---- 2. 形态学预处理 (关键!) ----
|
|
# 先用大核闭运算把气泡内 AI 残影/噪点/纹理糊成一片
|
|
mk = cv2.getStructuringElement(cv2.MORPH_RECT, (morph_close_size, morph_close_size))
|
|
gray = cv2.morphologyEx(gray, cv2.MORPH_CLOSE, mk)
|
|
|
|
# ---- 3. 二值化 ----
|
|
_, binary = cv2.threshold(gray, white_threshold, 255, cv2.THRESH_BINARY)
|
|
|
|
# ---- 4. 边界分离 (开运算) ----
|
|
if erode_iterations > 0:
|
|
ek = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))
|
|
binary = cv2.morphologyEx(binary, cv2.MORPH_OPEN, ek, iterations=erode_iterations)
|
|
|
|
# ---- 5. 二次闭运算 (弥合腐蚀造成的小缺口) ----
|
|
ck = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
|
|
binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, ck, iterations=2)
|
|
|
|
# ---- 6. 轮廓检测 ----
|
|
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
|
|
# ---- 7. 筛选 (面积 + 形状先验) ----
|
|
filtered = 0
|
|
bubbles = []
|
|
for cnt in contours:
|
|
area = cv2.contourArea(cnt)
|
|
if area < min_bubble_area:
|
|
continue
|
|
rx, ry, rw, rh = cv2.boundingRect(cnt)
|
|
|
|
if shape_filter_enabled:
|
|
bbox_area = float(rw * rh)
|
|
extent = area / bbox_area if bbox_area > 0 else 0.0
|
|
if extent < min_extent:
|
|
filtered += 1
|
|
continue
|
|
ar = max(rw, rh) / min(rw, rh) if min(rw, rh) > 0 else 999.0
|
|
if ar > max_aspect_ratio:
|
|
filtered += 1
|
|
continue
|
|
|
|
bubbles.append({
|
|
"x": rx, "y": ry, "width": rw, "height": rh,
|
|
"cx": rx + rw // 2, "cy": ry + rh // 2,
|
|
"area": area, "contour": cnt,
|
|
})
|
|
|
|
if filtered:
|
|
info.append(f" 🛡️ 形状过滤: 排除 {filtered} 个误检")
|
|
|
|
# ---- 8. 排序 ----
|
|
if sort_mode == "top_to_bottom":
|
|
bubbles.sort(key=lambda b: (b["cy"], b["cx"]))
|
|
elif sort_mode == "left_to_right":
|
|
bubbles.sort(key=lambda b: (b["cx"], b["cy"]))
|
|
elif sort_mode == "largest_first":
|
|
bubbles.sort(key=lambda b: -b["area"])
|
|
else:
|
|
if bubbles:
|
|
avg_h = sum(b["height"] for b in bubbles) / len(bubbles)
|
|
row_th = avg_h * 0.5
|
|
bubbles.sort(key=lambda b: b["cx"])
|
|
rows = []
|
|
for bu in bubbles:
|
|
placed = False
|
|
for row in rows:
|
|
if abs(bu["cy"] - row[0]["cy"]) < row_th:
|
|
row.append(bu)
|
|
placed = True
|
|
break
|
|
if not placed:
|
|
rows.append([bu])
|
|
for row in rows:
|
|
row.sort(key=lambda b: b["cx"])
|
|
rows.sort(key=lambda r: r[0]["cy"])
|
|
bubbles = [b for row in rows for b in row]
|
|
|
|
info.append(f"🫧 Batch {b+1}: 检测 {len(bubbles)} 气泡, 台词 {len(dialogues)} 句")
|
|
|
|
if not bubbles:
|
|
info.append(" ⚠️ 未检测到气泡! 请降低 white_threshold 或增大 morph_close_size")
|
|
results.append(torch.from_numpy(arr.astype(np.float32) / 255.0))
|
|
continue
|
|
|
|
# ---- 9. 涂改液: 用轮廓填充纯白 ----
|
|
if auto_clear_bubble:
|
|
valid_cnts = [b["contour"] for b in bubbles]
|
|
cv2.drawContours(cv_rgb, valid_cnts, -1, (255, 255, 255), thickness=cv2.FILLED)
|
|
info.append(f" 🧼 涂改液: 擦除 {len(valid_cnts)} 个气泡内部鬼影")
|
|
|
|
# ---- 10. PIL 渲染文字 ----
|
|
pil = Image.fromarray(cv_rgb)
|
|
draw = ImageDraw.Draw(pil)
|
|
|
|
for i, bubble in enumerate(bubbles):
|
|
if i >= len(dialogues):
|
|
break
|
|
line_text = dialogues[i]
|
|
avail_w = max(font_size, bubble["width"] - 2 * bubble_margin)
|
|
text_lines = _wrap_text(line_text, font, avail_w, draw)
|
|
bb = draw.textbbox((0, 0), "啊", font=font)
|
|
lh = bb[3] - bb[1] + 4
|
|
total_h = len(text_lines) * lh
|
|
start_y = bubble["y"] + (bubble["height"] - total_h) // 2
|
|
|
|
for j, tline in enumerate(text_lines):
|
|
tlw = draw.textbbox((0, 0), tline, font=font)[2]
|
|
tlx = bubble["x"] + (bubble["width"] - tlw) // 2
|
|
tly = start_y + j * lh
|
|
draw.text((tlx, tly), tline, font=font, fill=fg)
|
|
|
|
info.append(
|
|
f" 💬 气泡{i+1}: ({bubble['x']},{bubble['y']}) "
|
|
f"{bubble['width']}x{bubble['height']} -> \"{line_text[:20]}{'...' if len(line_text) > 20 else ''}\""
|
|
)
|
|
|
|
result = np.array(pil).astype(np.float32) / 255.0
|
|
results.append(torch.from_numpy(result))
|
|
|
|
out_info = "\n".join(info)
|
|
print(f"[XB_AutoBubbleTextRenderer]\n{out_info}")
|
|
return (torch.stack(results, dim=0), out_info)
|