commit 3188ddb9aab4c903e52220326c162ca1f359dccb Author: rui40000 Date: Fri Mar 27 10:54:04 2026 +0800 update diff --git a/README.md b/README.md new file mode 100644 index 0000000..48c02be --- /dev/null +++ b/README.md @@ -0,0 +1,505 @@ +# Rui-Node🐶 - ComfyUI 图像处理节点集 + +Rui-Node🐶 是一个功能丰富的 ComfyUI 节点集合,提供图像处理、文本处理、AI 模型集成和遮罩处理等多种功能。 + +## 📦 安装方法 + +1. 将此文件夹复制到 ComfyUI 的 `custom_nodes` 目录中 +2. 安装依赖:`pip install -r requirements.txt` +3. 重启 ComfyUI + +## 📋 节点目录 + +### 🎨 图像调节类 +- [调整饱和度 / Saturation Adjustment](#1-调整饱和度--saturation-adjustment) +- [图像翻转 / Image Flip](#2-图像翻转--image-flip) +- [颜色匹配器 / Color Matcher](#13-颜色匹配器--color-matcher) +- [素材拆分 / Sprite Splitter](#14-素材拆分--sprite-splitter) +- [素材拆分(带透明通道) / Sprite Splitter RGBA](#15-素材拆分带透明通道--sprite-splitter-rgba) + +### 📁 文件存储与加载类 +- [按路径加载图像 / Load Image By Path](#3-按路径加载图像--load-image-by-path) + +### 🤖 AI模型类 +- [千问编辑图像生成 / Qwen Edit Image Generation](#4-千问编辑图像生成--qwen-edit-image-generation) + +### 📝 文本处理类 +- [镜头分词器 / Shot Splitter](#5-镜头分词器--shot-splitter) +- [对白提取器 / Dialogue Extractor](#6-对白提取器--dialogue-extractor) +- [页面旁白删除器 / Page Narration Remover](#7-页面旁白删除器--page-narration-remover) +- [文本列表制作器 / Text List Creator](#8-文本列表制作器--text-list-creator) +- [转化为utf-8编码 / Convert to UTF-8](#11-转化为utf-8编码--convert-to-utf-8) + +### 🎭 遮罩处理类 +- [遮罩筛选 / Mask Selector](#9-遮罩筛选--mask-selector) +- [遮罩预览 / Mask Preview](#10-遮罩预览--mask-preview) + +--- + +## 📖 节点详细说明 + +### 1. 调整饱和度 / Saturation Adjustment + +**分类**: `Rui-Node🐶/图像调节🎨` + +**功能描述**: +调整图像的色彩饱和度,可以创建黑白图像或增强色彩鲜艳度。 + +**输入参数**: +- `image` (IMAGE): 输入图像 +- `saturation` (FLOAT): 饱和度调整系数 + - 默认值: 1.0 + - 范围: 0.0 ~ 5.0 + - 步长: 0.1 + - 说明: + - 0.0 = 完全无饱和度(黑白图像) + - 1.0 = 原始饱和度(不变) + - >1.0 = 增加饱和度 + +**输出**: +- `IMAGE`: 调整后的图像 + +**使用场景**: +- 将彩色图像转换为黑白 +- 增强图像色彩表现力 +- 降低过于鲜艳的色彩 + +--- + +### 2. 图像翻转 / Image Flip + +**分类**: `Rui-Node🐶/图像调节🎨` + +**功能描述**: +对图像进行水平或垂直翻转操作。 + +**输入参数**: +- `image` (IMAGE): 输入图像 +- `flip_direction` (选择): 翻转方向 + - 选项: "水平" 或 "垂直" + - 默认值: "水平" + +**输出**: +- `IMAGE`: 翻转后的图像 + +**使用场景**: +- 镜像翻转图像 +- 创建对称效果 +- 调整图像方向 + +--- + +### 3. 按路径加载图像 / Load Image By Path + +**分类**: `Rui-Node🐶/文件存储与加载📁` + +**功能描述**: +从指定的文件路径加载图像文件,支持绝对路径输入。 + +**输入参数**: +- `image_path` (STRING): 图像文件的完整路径 + - 默认值: "E:\\ComfyUIModels\\input\\10\\1.png" + - 支持格式: PNG、JPG、JPEG 等常见图像格式 + +**输出**: +- `IMAGE`: 加载的图像 + +**特殊处理**: +- 如果文件不存在,返回 512x512 的黑色默认图像 +- 自动将非 RGB 图像转换为 RGB 模式 + +**使用场景**: +- 从外部路径加载特定图像 +- 批量处理指定目录的图像 +- 加载非 ComfyUI 默认输入目录的图像 + +--- + +### 4. 千问编辑图像生成 / Qwen Edit Image Generation + +**分类**: `Rui-Node🐶/AI模型🤖` + +**功能描述**: +使用阿里云千问(Qwen)编辑模型 API 进行 AI 图像生成,支持多种控制模式。 + +**输入参数**: +- `image1` ~ `image4` (IMAGE): 最多 4 张输入图像作为参考 +- `api_key` (STRING): 阿里云 API 密钥 +- `base_url` (STRING): API 基础 URL + - 默认值: "https://dashscope.aliyuncs.com/api/v1/services/aigc/text2image-generation/generation" +- `seed` (INT): 随机种子 + - 默认值: -1(随机生成) + - 范围: -1 ~ 2147483647 +- `control_mode` (选择): 控制模式 + - 选项: reference, sketch, scribble, pose, canny, depth, hed, mlsd, normal, seg + - 默认值: "reference" +- `width` (INT): 输出图像宽度 + - 默认值: 1024 + - 范围: 512 ~ 2048 + - 步长: 8 +- `height` (INT): 输出图像高度 + - 默认值: 1024 + - 范围: 512 ~ 2048 + - 步长: 8 + +**输出**: +- `IMAGE`: AI 生成的图像 + +**使用场景**: +- AI 辅助图像创作 +- 基于参考图生成新图像 +- 多模态图像控制生成 + +--- + +### 5. 镜头分词器 / Shot Splitter + +**分类**: `Rui-Node🐶/文本处理📝` + +**功能描述**: +将包含多个分镜描述的脚本文本拆分成独立的分镜列表,支持按范围筛选导出。 + +**输入参数**: +- `input_text` (STRING): 输入的多分镜描述脚本(多行文本) + - 格式要求: 使用 `...` 标签包裹每个分镜 +- `start_shot_num` (INT, 可选): 开始导出的分镜编号 + - 默认值: 0(从第一个开始) + - 范围: 0 ~ 100 +- `shot_count` (INT, 可选): 导出的分镜数量 + - 默认值: 0(导出全部) + - 范围: 0 ~ 100 + +**输出**: +- `shot_descriptions` (LIST): 拆分后的分镜描述列表 +- `summary` (STRING): 总结信息 + +**文本格式示例**: +``` + +第一个镜头的描述内容 + + +第二个镜头的描述内容 + +``` + +**使用场景**: +- 分镜脚本拆分 +- 批量处理分镜描述 +- 选择性导出特定范围的分镜 + +--- + +### 6. 对白提取器 / Dialogue Extractor + +**分类**: `Rui-Node🐶/文本处理📝` + +**功能描述**: +从分镜描述文本中自动提取旁白/对白内容。 + +**输入参数**: +- `input_text` (STRING): 输入的分镜描述文本(多行文本) + +**输出**: +- `dialogues` (LIST): 提取的旁白/对白列表 +- `summary` (STRING): 总结信息 + +**识别模式**: +- 支持格式 1: `旁白:[对白内容]` +- 支持格式 2: `旁白:对白内容` +- 自动识别 `` 标签中的旁白 + +**使用场景**: +- 从分镜脚本中提取对白 +- 批量收集旁白文本 +- 准备配音文本 + +--- + +### 7. 页面旁白删除器 / Page Narration Remover + +**分类**: `Rui-Node🐶/文本处理📝` + +**功能描述**: +删除文本中所有以"页面旁白:"或"页面旁白:"开头的整行内容。 + +**输入参数**: +- `input_text` (STRING): 原始文本(多行文本) + +**输出**: +- `clean_text` (STRING): 移除页面旁白行后的文本 + +**处理规则**: +- 自动识别并删除以"页面旁白:"或"页面旁白:"开头的行 +- 忽略行首行尾的空白字符 +- 保留其他所有内容 + +**使用场景**: +- 清理脚本中的页面旁白 +- 文本预处理 +- 提取纯净对白内容 + +--- + +### 8. 文本列表制作器 / Text List Creator + +**分类**: `Rui-Node🐶/文本处理📝` + +**功能描述**: +将多个独立的文本段落组织成列表形式输出。 + +**输入参数**: +- `text1` (STRING, 必需): 第一段文本(多行文本) +- `text2` ~ `text5` (STRING, 可选): 第 2~5 段文本(多行文本) + +**输出**: +- `text_list` (LIST): 文本列表(Python 列表格式) +- `summary` (STRING): 总结信息 + +**处理规则**: +- 自动过滤空文本 +- 去除每段文本首尾的空白字符 +- 保留内部段落结构 + +**使用场景**: +- 组织多段文本为列表 +- 批量文本处理准备 +- 文本分组管理 + +--- + +### 9. 遮罩筛选 / Mask Selector + +**分类**: `Rui-Node🐶/遮罩处理🎭` + +**功能描述**: +对输入的多个遮罩进行排序并选择特定遮罩,同时输出剩余遮罩的合并结果。 + +**输入参数**: +- `masks` (MASK): 输入的遮罩(可包含多个遮罩) +- `sort_method` (选择): 排序方法 + - 选项: + - "按面积排序 / By Area": 按遮罩面积从大到小排序 + - "从左到右 / Left to Right": 按遮罩质心 X 坐标升序排序 + - "从上到下 / Top to Bottom": 按遮罩质心 Y 坐标升序排序 + - 默认值: "按面积排序 / By Area" +- `index` (INT): 选择的遮罩编号(1-based 索引) + - 默认值: 1 + - 最小值: 1 + - 说明: 如果超出范围会自动夹取到有效范围 + +**输出**: +- `选中遮罩 / Selected` (MASK): 选中的单个遮罩 +- `剩余遮罩 / Remaining` (MASK): 其他遮罩的合并结果 +- `信息 / Info` (STRING): JSON 格式的详细信息 + - `total_masks`: 遮罩总数 + - `selected_index`: 选中编号 + - `sort_method`: 排序方式 + - `selected_area`: 选中遮罩的像素面积 + - `selected_center`: 选中遮罩的质心坐标 [x, y] + - `index_clamped`: 编号是否越界被修正 + +**使用场景**: +- 从多个检测结果中选择特定目标 +- 分离主体和背景遮罩 +- 基于大小或位置筛选遮罩 + +--- + +### 10. 遮罩预览 / Mask Preview + +**分类**: `Rui-Node🐶/遮罩处理🎭` + +**功能描述**: +将遮罩以半透明彩色形式叠加显示在图像上,方便直观查看遮罩覆盖区域。节点自带预览功能,同时输出合成后的图像。 + +**输入参数**: +- `image` (IMAGE): 作为底图的原始图像 +- `mask` (MASK): 需要可视化的遮罩 +- `mask_color` (选择): 遮罩显示颜色 + - 默认值: 红色 / Red + - 选项: 红色、绿色、蓝色、黄色、青色、品红、白色 +- `opacity` (FLOAT, 可选): 不透明度 + - 默认值: 0.5 + - 范围: 0.0 ~ 1.0 + - 步长: 0.05 + +**输出**: +- `图像 / Image` (IMAGE): 合成了半透明彩色遮罩的图像 + +**特性**: +- 自动处理遮罩与图像的尺寸差异 +- 节点界面直接显示预览效果 +- 支持批量处理 +- 7种预设颜色可选 +- 可调节不透明度 + +**使用场景**: +- 检查分割结果的准确性 +- 调试遮罩处理流程 +- 多遮罩对比(使用不同颜色) +- 制作遮罩可视化图 + +--- + +### 11. 转化为utf-8编码 / Convert to UTF-8 + +**分类**: `Rui-Node🐶/文本处理📝` + +**功能描述**: +删除输入字符串中所有非 UTF-8 编码字符(如孤立的代理对),确保输出的字符串符合 UTF-8 编码规范。 + +**输入参数**: +- `input_text` (STRING): 需要处理的原始字符串(支持多行) + +**输出**: +- `filtered_text` (STRING): 过滤后的符合 UTF-8 规范的字符串 +- `log` (STRING): 处理日志,包含移除字符的详细信息和统计总结 + +**使用场景**: +- 清理可能包含非法字符的文本数据 +- 确保文本在保存或传输时的编码安全性 +- 调试文本编码问题 + +--- + +### 12. OpenAI API 连接 / OpenAI API Connector + +**分类**: `Rui-Node🐶/AI模型🤖` + +**功能描述**: +连接 OpenAI 或兼容 API(如 DeepSeek、Moonshot 等),进行文本生成或多模态图像理解。 + +**输入参数**: +- `api_url` (STRING): API 接口地址 + - 默认值: "https://api.openai.com/v1/chat/completions" +- `api_key` (STRING): API 密钥 +- `model` (STRING): 模型名称 + - 默认值: "gpt-4o" +- `system_prompt` (STRING): 系统提示词 +- `user_prompt` (STRING): 用户提示词 +- `seed` (INT): 随机种子,用于控制生成的随机性 +- `image` (IMAGE, 可选): 输入图像(用于多模态模型) + +**输出**: +- `text` (STRING): 模型生成的文本内容 + +**使用场景**: +- 调用 LLM 进行文本生成 +- 使用 Vision 模型进行图像理解 +- 连接本地或第三方兼容 OpenAI 协议的 API + +--- + +### 13. 颜色匹配器 / Color Matcher + +**分类**: `Rui-Node🐶/图像调节🎨` + +**功能描述**: +将目标图像的颜色分布匹配到参考图像的颜色分布,支持多种匹配算法和混合调节。 + +**输入参数**: +- `reference_image` (IMAGE): 作为颜色参考的图像 +- `moving_image` (IMAGE): 需要改变颜色的目标图像 +- `match_method` (选择): 匹配算法 + - 选项: "histogram" (直方图匹配), "mean_std" (均值标准差匹配), "none" (无匹配) + - 默认值: "histogram" +- `blend_factor` (FLOAT): 混合系数 + - 默认值: 1.0 + - 范围: 0.0 ~ 1.0 + - 步长: 0.01 + - 说明: 控制原图和匹配后图像的混合比例,1.0为完全使用匹配后图像 + +**输出**: +- `颜色匹配后图像` (IMAGE): 颜色调整后的图像 +- `匹配信息` (STRING): 记录了使用的匹配方式以及混合系数的日志信息 + +**使用场景**: +- 统一多张图像的色调风格 +- 将素材无缝融合进背景 +- 图像色彩风格迁移 + +--- + +### 14. 素材拆分 / Sprite Splitter + +**分类**: `Rui-Node🐶/图像调节🎨` + +**功能描述**: +从白色/浅色背景的合图(Sprite Sheet)中自动拆分出每个独立的美术元素,通过连通区域检测进行裁剪,并将每个独立元素作为图像列表输出。 + +**输入参数**: +- `图像` (IMAGE): 输入的带有透明通道的合图图像(RGBA格式) +- `最小面积过滤(像素数)` (INT): 最小面积过滤 + - 默认值: 100 + - 范围: 1 ~ 50000 + - 说明: 面积小于此值(像素数)的连通区域将被过滤,避免拆分出噪点碎片。 +- `裁剪边距` (INT): 裁剪边距 + - 默认值: 2 + - 范围: 0 ~ 50 + - 说明: 每个元素裁剪时在包围盒外额外保留的像素边距。 +- `排序方式` (选择): 排序方式 + - 选项: "从左到右-从上到下", "从上到下-从左到右", "面积从大到小", "面积从小到大" + - 默认值: "从左到右-从上到下" + +**输出**: +- `图像列表` (IMAGE): 拆分后的多张图像列表,透明区域会用白色填充输出。 + +**使用场景**: +- 游戏素材合图切分 +- 批量图标提取 +- 白底素材自动裁剪 + +--- + +### 15. 素材拆分(带透明通道) / Sprite Splitter RGBA + +**分类**: `Rui-Node🐶/图像调节🎨` + +**功能描述**: +与标准素材拆分节点功能相同,但保留并额外输出 Alpha 透明通道,适用于需要透明背景的美术素材提取。 + +**输入参数**: +- 输入参数与 [素材拆分 / Sprite Splitter](#14-素材拆分--sprite-splitter) 完全一致。 + +**输出**: +- `图像列表` (IMAGE): 拆分后的多张 RGB 图像列表 +- `遮罩列表` (MASK): 对应的多张 Alpha 透明通道遮罩列表,1.0代表不透明,0.0代表透明 + +**使用场景**: +- 提取带透明背景的游戏角色、道具素材 +- 搭配 `JoinImageWithAlpha` 等节点生成透明 PNG 图像 + +--- + +## 🔧 依赖库 + +主要依赖库包括: +- `torch`: PyTorch 深度学习框架 +- `numpy`: 数值计算 +- `Pillow (PIL)`: 图像处理 +- `requests`: HTTP 请求(用于 API 调用) + +完整依赖请查看 `requirements.txt` + +## 📝 注意事项 + +1. 所有节点都兼容 ComfyUI 的标准图像处理流程 +2. 图像格式统一为 BHWC(批次、高度、宽度、通道) +3. 图像值范围为 0.0 ~ 1.0 的浮点数 +4. 使用 AI 模型节点需要配置有效的 API 密钥 +5. 文本处理节点支持多行文本输入 +6. 所有节点名称采用中英双语显示 +7. 遮罩处理节点自动处理尺寸不匹配问题 + +## 🐕 关于 Rui-Node🐶 + +Rui-Node🐶 致力于为 ComfyUI 用户提供实用、高效的节点工具集。🐶 是我们的项目标志,代表着忠诚、友好和可靠。 + +## 📄 许可证 + +本项目遵循开源协议,欢迎使用和贡献。 + +--- + +**Happy Creating with Rui-Node🐶!** 🎨✨ diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..0476524 --- /dev/null +++ b/__init__.py @@ -0,0 +1,70 @@ +# 顶部已有的导入 +from .saturation_node import NODE_CLASS_MAPPINGS as SAT_NODE_CLASS_MAPPINGS +from .saturation_node import NODE_DISPLAY_NAME_MAPPINGS as SAT_NODE_DISPLAY_NAME_MAPPINGS +from .flip_node import NODE_CLASS_MAPPINGS as FLIP_NODE_CLASS_MAPPINGS +from .flip_node import NODE_DISPLAY_NAME_MAPPINGS as FLIP_NODE_DISPLAY_NAME_MAPPINGS +from .load_image_node import NODE_CLASS_MAPPINGS as LOAD_NODE_CLASS_MAPPINGS +from .load_image_node import NODE_DISPLAY_NAME_MAPPINGS as LOAD_NODE_DISPLAY_NAME_MAPPINGS +from .qwenedit_node import NODE_CLASS_MAPPINGS as QWEN_NODE_CLASS_MAPPINGS +from .qwenedit_node import NODE_DISPLAY_NAME_MAPPINGS as QWEN_NODE_DISPLAY_NAME_MAPPINGS +from .shot_splitter_node import NODE_CLASS_MAPPINGS as SHOT_NODE_CLASS_MAPPINGS +from .shot_splitter_node import NODE_DISPLAY_NAME_MAPPINGS as SHOT_NODE_DISPLAY_NAME_MAPPINGS +from .dialogue_extractor_node import NODE_CLASS_MAPPINGS as DIALOGUE_NODE_CLASS_MAPPINGS +from .dialogue_extractor_node import NODE_DISPLAY_NAME_MAPPINGS as DIALOGUE_NODE_DISPLAY_NAME_MAPPINGS +# 新增:遮罩筛选节点 +from .mask_selector_node import NODE_CLASS_MAPPINGS as MASKSELECT_NODE_CLASS_MAPPINGS +from .mask_selector_node import NODE_DISPLAY_NAME_MAPPINGS as MASKSELECT_NODE_DISPLAY_NAME_MAPPINGS +from .page_narration_remover_node import NODE_CLASS_MAPPINGS as NARRATION_NODE_CLASS_MAPPINGS +from .page_narration_remover_node import NODE_DISPLAY_NAME_MAPPINGS as NARRATION_NODE_DISPLAY_NAME_MAPPINGS +from .text_list_node import NODE_CLASS_MAPPINGS as TEXTLIST_NODE_CLASS_MAPPINGS +from .text_list_node import NODE_DISPLAY_NAME_MAPPINGS as TEXTLIST_NODE_DISPLAY_NAME_MAPPINGS +from .mask_preview_node import NODE_CLASS_MAPPINGS as MASKPREVIEW_NODE_CLASS_MAPPINGS +from .mask_preview_node import NODE_DISPLAY_NAME_MAPPINGS as MASKPREVIEW_NODE_DISPLAY_NAME_MAPPINGS +from .utf8_converter_node import NODE_CLASS_MAPPINGS as UTF8_NODE_CLASS_MAPPINGS +from .utf8_converter_node import NODE_DISPLAY_NAME_MAPPINGS as UTF8_NODE_DISPLAY_NAME_MAPPINGS +from .openai_node import NODE_CLASS_MAPPINGS as OPENAI_NODE_CLASS_MAPPINGS +from .openai_node import NODE_DISPLAY_NAME_MAPPINGS as OPENAI_NODE_DISPLAY_NAME_MAPPINGS +# 新增:颜色匹配器节点 +from .color_matcher_node import NODE_CLASS_MAPPINGS as COLORMATCHER_NODE_CLASS_MAPPINGS +from .color_matcher_node import NODE_DISPLAY_NAME_MAPPINGS as COLORMATCHER_NODE_DISPLAY_NAME_MAPPINGS +# 新增:素材拆分节点 +from .image_splitter_node import NODE_CLASS_MAPPINGS as IMAGESPLITTER_NODE_CLASS_MAPPINGS +from .image_splitter_node import NODE_DISPLAY_NAME_MAPPINGS as IMAGESPLITTER_NODE_DISPLAY_NAME_MAPPINGS + +# 合并节点映射字典 +NODE_CLASS_MAPPINGS = {} +NODE_CLASS_MAPPINGS.update(SAT_NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(FLIP_NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(LOAD_NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(QWEN_NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(SHOT_NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(DIALOGUE_NODE_CLASS_MAPPINGS) +# 新增:合并遮罩筛选节点 +NODE_CLASS_MAPPINGS.update(MASKSELECT_NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(NARRATION_NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(TEXTLIST_NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(MASKPREVIEW_NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(UTF8_NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(OPENAI_NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(COLORMATCHER_NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(IMAGESPLITTER_NODE_CLASS_MAPPINGS) + +# 合并节点显示名称映射 +NODE_DISPLAY_NAME_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS.update(SAT_NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(FLIP_NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(LOAD_NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(QWEN_NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(SHOT_NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(DIALOGUE_NODE_DISPLAY_NAME_MAPPINGS) +# 新增:合并遮罩筛选节点显示名 +NODE_DISPLAY_NAME_MAPPINGS.update(MASKSELECT_NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(NARRATION_NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(TEXTLIST_NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(MASKPREVIEW_NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(UTF8_NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(OPENAI_NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(COLORMATCHER_NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(IMAGESPLITTER_NODE_DISPLAY_NAME_MAPPINGS) + +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] \ No newline at end of file diff --git a/__pycache__/__init__.cpython-310.pyc b/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..83e6415 Binary files /dev/null and b/__pycache__/__init__.cpython-310.pyc differ diff --git a/__pycache__/dialogue_extractor_node.cpython-310.pyc b/__pycache__/dialogue_extractor_node.cpython-310.pyc new file mode 100644 index 0000000..c7b8700 Binary files /dev/null and b/__pycache__/dialogue_extractor_node.cpython-310.pyc differ diff --git a/__pycache__/flip_node.cpython-310.pyc b/__pycache__/flip_node.cpython-310.pyc new file mode 100644 index 0000000..264fc7b Binary files /dev/null and b/__pycache__/flip_node.cpython-310.pyc differ diff --git a/__pycache__/load_image_node.cpython-310.pyc b/__pycache__/load_image_node.cpython-310.pyc new file mode 100644 index 0000000..1d8777c Binary files /dev/null and b/__pycache__/load_image_node.cpython-310.pyc differ diff --git a/__pycache__/mask_preview_node.cpython-310.pyc b/__pycache__/mask_preview_node.cpython-310.pyc new file mode 100644 index 0000000..5c243e9 Binary files /dev/null and b/__pycache__/mask_preview_node.cpython-310.pyc differ diff --git a/__pycache__/mask_selector_node.cpython-310.pyc b/__pycache__/mask_selector_node.cpython-310.pyc new file mode 100644 index 0000000..11c42cb Binary files /dev/null and b/__pycache__/mask_selector_node.cpython-310.pyc differ diff --git a/__pycache__/page_narration_remover_node.cpython-310.pyc b/__pycache__/page_narration_remover_node.cpython-310.pyc new file mode 100644 index 0000000..a765271 Binary files /dev/null and b/__pycache__/page_narration_remover_node.cpython-310.pyc differ diff --git a/__pycache__/qwenedit_node.cpython-310.pyc b/__pycache__/qwenedit_node.cpython-310.pyc new file mode 100644 index 0000000..fba6b6f Binary files /dev/null and b/__pycache__/qwenedit_node.cpython-310.pyc differ diff --git a/__pycache__/saturation_node.cpython-310.pyc b/__pycache__/saturation_node.cpython-310.pyc new file mode 100644 index 0000000..ec0d87a Binary files /dev/null and b/__pycache__/saturation_node.cpython-310.pyc differ diff --git a/__pycache__/shot_splitter_node.cpython-310.pyc b/__pycache__/shot_splitter_node.cpython-310.pyc new file mode 100644 index 0000000..4655f3c Binary files /dev/null and b/__pycache__/shot_splitter_node.cpython-310.pyc differ diff --git a/__pycache__/text_list_node.cpython-310.pyc b/__pycache__/text_list_node.cpython-310.pyc new file mode 100644 index 0000000..5cb9675 Binary files /dev/null and b/__pycache__/text_list_node.cpython-310.pyc differ diff --git a/color_matcher_node.py b/color_matcher_node.py new file mode 100644 index 0000000..bd1ca77 --- /dev/null +++ b/color_matcher_node.py @@ -0,0 +1,112 @@ +import numpy as np +import torch + +def to_numpy_uint8(img_t): # [H,W,C] float(0..1) -> np.uint8 + img = img_t.detach().cpu().numpy() + img = (np.clip(img, 0.0, 1.0) * 255.0).round().astype(np.uint8) + return img + +def to_tensor_image(img_np): # np.uint8 HWC -> torch [H,W,C] float(0..1) + t = torch.from_numpy(img_np.astype(np.float32) / 255.0) + return t + +def histogram_match(source, template): + # 逐通道直方图匹配 + src = source.copy() + for c in range(src.shape[2]): + s = src[..., c].ravel() + t = template[..., c].ravel() + s_values, bin_idx, s_counts = np.unique(s, return_inverse=True, return_counts=True) + t_values, t_counts = np.unique(t, return_counts=True) + s_quantiles = np.cumsum(s_counts).astype(np.float64) / s.size + t_quantiles = np.cumsum(t_counts).astype(np.float64) / t.size + interp_t_values = np.interp(s_quantiles, t_quantiles, t_values) + src[..., c] = interp_t_values[bin_idx].reshape(src.shape[:2]) + return src + +class ColorMatcherNode: + """颜色匹配节点:将移动图像的颜色分布匹配到参考图像""" + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "reference_image": ("IMAGE",), + "moving_image": ("IMAGE",), + "match_method": (["histogram", "mean_std", "none"], {"default": "histogram"}), + "blend_factor": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), + } + } + + RETURN_TYPES = ("IMAGE", "STRING") + RETURN_NAMES = ("颜色匹配后图像", "匹配信息") + FUNCTION = "match_colors" + CATEGORY = "Rui-Node🐶/图像调节🎨" + + def match_colors(self, reference_image, moving_image, match_method, blend_factor): + B = reference_image.shape[0] + assert moving_image.shape[0] == B, "batch 大小必须一致" + + out_images = [] + match_info = [] + + for i in range(B): + ref_img = reference_image[i] # [H,W,C] + mov_img = moving_image[i] + + if match_method == "none": + match_info.append(f"Batch {i}: 跳过颜色匹配") + out_images.append(mov_img.unsqueeze(0)) + continue + + ref_np = to_numpy_uint8(ref_img) + mov_np = to_numpy_uint8(mov_img) + + if match_method == "histogram": + # 直方图匹配 + matched_np = histogram_match(mov_np, ref_np) + method_name = "直方图匹配" + elif match_method == "mean_std": + # 均值标准差匹配 + matched_np = self.mean_std_match(mov_np, ref_np) + method_name = "均值标准差匹配" + else: + matched_np = mov_np + method_name = "无匹配" + + # 混合原图和匹配后的图像 + if blend_factor < 1.0: + matched_np = (matched_np * blend_factor + mov_np * (1 - blend_factor)).astype(np.uint8) + + match_info.append(f"Batch {i}: {method_name},混合系数: {blend_factor:.2f}") + matched_tensor = to_tensor_image(matched_np) + out_images.append(matched_tensor.unsqueeze(0)) + + matched_images = torch.cat(out_images, dim=0) + info_str = " | ".join(match_info) + + return (matched_images, info_str) + + def mean_std_match(self, source, template): + """均值标准差颜色匹配""" + src = source.astype(np.float32) + tpl = template.astype(np.float32) + + matched = src.copy() + for c in range(src.shape[2]): + src_mean = np.mean(src[..., c]) + src_std = np.std(src[..., c]) + tpl_mean = np.mean(tpl[..., c]) + tpl_std = np.std(tpl[..., c]) + + if src_std > 0: + matched[..., c] = (src[..., c] - src_mean) * (tpl_std / src_std) + tpl_mean + + return np.clip(matched, 0, 255).astype(np.uint8) + +NODE_CLASS_MAPPINGS = { + "ColorMatcher": ColorMatcherNode +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "ColorMatcher": "颜色匹配器 / Color Matcher" +} \ No newline at end of file diff --git a/dialogue_extractor_node.py b/dialogue_extractor_node.py new file mode 100644 index 0000000..5f0e065 --- /dev/null +++ b/dialogue_extractor_node.py @@ -0,0 +1,98 @@ +import re +import torch + +class DialogueExtractorNode: + """ + 对白提取器节点:从分镜描述中提取旁白/对白部分 + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "input_text": ("STRING", { + "multiline": True, + "default": "" + }), + }, + } + + RETURN_TYPES = ("LIST", "STRING") + RETURN_NAMES = ("dialogues", "summary") + FUNCTION = "extract_dialogues" + CATEGORY = "Rui-Node🐶/文本处理📝" + + def extract_dialogues(self, input_text): + """ + 从分镜描述中提取旁白/对白部分 + + 参数: + input_text: 输入的分镜描述文本 + + 返回: + dialogues: 提取的旁白/对白列表 + summary: 总结信息 + """ + # 使用正则表达式匹配所有分镜内容 + shot_pattern = r'([\s\S]*?)' + shot_matches = re.findall(shot_pattern, input_text) + + # 如果没有找到分镜标记,则直接在整个文本中查找旁白 + if not shot_matches: + return self._extract_from_text(input_text) + + # 从每个分镜中提取旁白 + dialogues = [] + for shot_num, content in shot_matches: + dialogue = self._extract_from_text(content) + if dialogue[0]: # 如果提取到了旁白 + dialogues.extend(dialogue[0]) + + # 生成总结信息 + summary = f"一共提取了{len(dialogues)}条旁白/对白。" + + return (dialogues, summary) + + def _extract_from_text(self, text): + """ + 从文本中提取旁白/对白 + + 参数: + text: 输入文本 + + 返回: + dialogues: 提取的旁白/对白列表 + summary: 总结信息 + """ + # 使用正则表达式匹配旁白部分 + # 匹配"旁白:"后面的方括号内容 + dialogue_pattern = r'旁白:\s*\[([^\[\]]*?)\]' + dialogue_matches = re.findall(dialogue_pattern, text) + + # 如果没有找到旁白,尝试匹配其他可能的格式 + if not dialogue_matches: + # 尝试匹配没有方括号的格式 + alt_pattern = r'旁白:\s*([^\[\]\n]*?)(?:\n|$)' + dialogue_matches = re.findall(alt_pattern, text) + + dialogues = [] + for dialogue in dialogue_matches: + # 去除开头和结尾的空白字符 + dialogue = dialogue.strip() + if dialogue: # 如果不是空字符串 + dialogues.append(dialogue) + + # 生成总结信息 + summary = f"提取了{len(dialogues)}条旁白/对白。" + + return (dialogues, summary) + +# 节点映射字典,用于 ComfyUI 注册节点 +NODE_CLASS_MAPPINGS = { + "DialogueExtractor": DialogueExtractorNode +} + +# 节点显示名称映射,用于在 UI 中显示友好名称 +NODE_DISPLAY_NAME_MAPPINGS = { + "DialogueExtractor": "对白提取器 / Dialogue Extractor" +} \ No newline at end of file diff --git a/flip_node.py b/flip_node.py new file mode 100644 index 0000000..3bdb4da --- /dev/null +++ b/flip_node.py @@ -0,0 +1,74 @@ +import torch +import numpy as np +from PIL import Image + +class FlipNode: + """ + 图像翻转节点:可以将图像进行左右或上下翻转 + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "flip_direction": (["水平", "垂直"], { + "default": "水平" + }), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "flip_image" + CATEGORY = "Rui-Node🐶/图像调节🎨" + + def flip_image(self, image, flip_direction): + """ + 翻转输入图像 + + 参数: + image: 输入图像张量 (B, H, W, C) 格式 + flip_direction: 翻转方向,"水平"或"垂直" + + 返回: + 翻转后的图像张量 + """ + # 将图像从 PyTorch 张量转换为 PIL 图像进行处理 + batch_size = image.shape[0] + result = [] + + for i in range(batch_size): + # 将单个图像从 PyTorch 张量转换为 NumPy 数组 + # ComfyUI 中图像格式为 BHWC,值范围为 0-1 + img_np = image[i].cpu().numpy() + + # 确保值范围在 0-1 之间 + img_np = np.clip(img_np, 0, 1) + + # 转换为 PIL 图像 (值范围 0-255) + img_pil = Image.fromarray((img_np * 255).astype(np.uint8), 'RGB') + + # 根据选择的方向翻转图像 + if flip_direction == "水平": + flipped_img = img_pil.transpose(Image.FLIP_LEFT_RIGHT) + else: # 垂直翻转 + flipped_img = img_pil.transpose(Image.FLIP_TOP_BOTTOM) + + # 将处理后的图像转回 NumPy 数组,然后转为 PyTorch 张量 + flipped_np = np.array(flipped_img).astype(np.float32) / 255.0 + flipped_tensor = torch.from_numpy(flipped_np) + + result.append(flipped_tensor) + + # 将结果堆叠为批次 + return (torch.stack(result),) + +# 节点映射字典,用于 ComfyUI 注册节点 +NODE_CLASS_MAPPINGS = { + "ImageFlip": FlipNode +} + +# 节点显示名称映射,用于在 UI 中显示友好名称 +NODE_DISPLAY_NAME_MAPPINGS = { + "ImageFlip": "图像翻转 / Image Flip" +} \ No newline at end of file diff --git a/image_splitter_node.py b/image_splitter_node.py new file mode 100644 index 0000000..db9d4c1 --- /dev/null +++ b/image_splitter_node.py @@ -0,0 +1,289 @@ +import torch +import numpy as np +import scipy.ndimage + +class SpriteSplitter: + """ + 从白色/浅色背景的合图中自动拆分独立素材元素。 + 去除背景后,通过连通区域检测将每个独立元素裁剪为单独的图像输出。 + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "图像": ("IMAGE",), + "最小面积过滤(像素数)": ("INT", { + "default": 100, + "min": 1, + "max": 50000, + "step": 1, + "tooltip": "最小面积(像素数):面积小于此值的连通区域将被过滤,避免噪点碎片。" + }), + "裁剪边距": ("INT", { + "default": 2, + "min": 0, + "max": 50, + "step": 1, + "tooltip": "裁剪边距(像素):每个元素裁剪时在包围盒外额外保留的像素边距。" + }), + "排序方式": (["从左到右-从上到下", "从上到下-从左到右", "面积从大到小", "面积从小到大"],{ + "default": "从左到右-从上到下", + "tooltip": "输出排序方式:控制拆分后元素的排列顺序。" + }), + } + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("图像列表",) + OUTPUT_IS_LIST = (True,) + FUNCTION = "split_sprites" + CATEGORY = "Rui-Node🐶/图像调节🎨" + DESCRIPTION = "Sprite Splitter - 从白色背景合图中自动拆分独立素材元素" + + def split_sprites(self, **kwargs): + 图像 = kwargs.get("图像") + 最小面积过滤_像素数 = kwargs.get("最小面积过滤(像素数)") + 裁剪边距 = kwargs.get("裁剪边距") + 排序方式 = kwargs.get("排序方式") + """ + 主处理函数。 + """ + # 取 batch 中的第一张图 + img_tensor = 图像[0] # [H, W, C] + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + + # 确保图像有 Alpha 通道 + if c == 3: + # 如果输入只有 RGB,说明用户可能没有提供透明背景的图 + # 但既然去除了内置的去背逻辑,我们只能假设纯黑 (0,0,0) 或用户需要自行提供 RGBA + # 为了兼容性,这里我们默认将全图设为不透明,或者如果有单独 Mask 处理的话... + # 建议用户输入带有透明通道的图像。 + rgba = np.zeros((h, w, 4), dtype=np.uint8) + rgba[:, :, :3] = img_np + rgba[:, :, 3] = 255 # 全不透明,将导致无法拆分,所以最好输入带Alpha的图 + else: + rgba = img_np.copy() + + # ========== 连通区域检测 (使用 scipy.ndimage 加速) ========== + alpha = (rgba[:, :, 3] > 0).astype(np.uint8) + + # 定义 4-连通 的结构元素 + structure = np.array([[0, 1, 0], + [1, 1, 1], + [0, 1, 0]]) + + # 使用 scipy.ndimage.label 找出连通区域 + labels, num_features = scipy.ndimage.label(alpha, structure=structure) + + bboxes = [] + if num_features > 0: + # 使用 scipy.ndimage.find_objects 快速获取包围盒 + slices = scipy.ndimage.find_objects(labels) + + # 使用 numpy.bincount 快速计算每个标签的面积 + areas = np.bincount(labels.ravel()) + + for i, slc in enumerate(slices): + if slc is not None: + label_idx = i + 1 + area = areas[label_idx] + + min_y, max_y = slc[0].start, slc[0].stop - 1 + min_x, max_x = slc[1].start, slc[1].stop - 1 + + bboxes.append({ + 'label': label_idx, + 'min_x': min_x, 'min_y': min_y, + 'max_x': max_x, 'max_y': max_y, + 'area': area + }) + + # ========== Step 3: 过滤和排序 ========== + valid_bboxes = [b for b in bboxes if b['area'] >= 最小面积过滤_像素数] + + if 排序方式 == "从左到右-从上到下": + row_height = max(60, h // 15) # 自适应行高 + valid_bboxes.sort(key=lambda b: (b['min_y'] // row_height, b['min_x'])) + elif 排序方式 == "从上到下-从左到右": + col_width = max(60, w // 15) + valid_bboxes.sort(key=lambda b: (b['min_x'] // col_width, b['min_y'])) + elif 排序方式 == "面积从大到小": + valid_bboxes.sort(key=lambda b: -b['area']) + elif 排序方式 == "面积从小到大": + valid_bboxes.sort(key=lambda b: b['area']) + + # ========== Step 4: 裁剪并输出 ========== + result_images = [] + + for b in valid_bboxes: + sx = max(0, b['min_x'] - 裁剪边距) + sy = max(0, b['min_y'] - 裁剪边距) + ex = min(w, b['max_x'] + 裁剪边距 + 1) + ey = min(h, b['max_y'] + 裁剪边距 + 1) + + # 使用 numpy 切片快速裁剪 + sprite_rgba = rgba[sy:ey, sx:ex].copy() + sprite_labels = labels[sy:ey, sx:ex] + + # 只保留当前标签的像素,其他设为透明 + mask = (sprite_labels != b['label']) + sprite_rgba[mask] = 0 + + # 转为 ComfyUI IMAGE tensor: [1, H, W, C], float32, range [0,1] + # ComfyUI 标准 IMAGE 是 RGB (3通道),我们输出 RGBA 以保留透明度 + # 但标准IMAGE是3通道,所以将透明区域设为白色并输出RGB + alpha_f = sprite_rgba[:, :, 3:4].astype(np.float32) / 255.0 + sprite_rgb = (sprite_rgba[:, :, :3].astype(np.float32) / 255.0) * alpha_f + (1.0 - alpha_f) + + tensor = torch.from_numpy(sprite_rgb).unsqueeze(0) # [1, H, W, 3] + result_images.append(tensor) + + # 如果没有检测到元素,返回原图 + if not result_images: + result_images.append(图像[:1]) + + return (result_images,) + + +class SpriteSplitterRGBA: + """ + 与 SpriteSplitter 功能相同,但输出带 Alpha 通道的 RGBA 图像。 + 透明区域保持透明(而非白色填充)。 + 需要下游节点支持 4 通道图像(如 SaveImageWithAlpha 等)。 + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "图像": ("IMAGE",), + "最小面积过滤(像素数)": ("INT", { + "default": 100, "min": 1, "max": 50000, "step": 1, + "tooltip": "最小面积过滤。" + }), + "裁剪边距": ("INT", { + "default": 2, "min": 0, "max": 50, "step": 1, + "tooltip": "裁剪边距(像素)。" + }), + "排序方式": (["从左到右-从上到下", "从上到下-从左到右", "面积从大到小", "面积从小到大"],{ + "default": "从左到右-从上到下", + }), + } + } + + RETURN_TYPES = ("IMAGE", "MASK") + RETURN_NAMES = ("图像列表", "遮罩列表") + OUTPUT_IS_LIST = (True, True) + FUNCTION = "split_sprites_rgba" + CATEGORY = "Rui-Node🐶/图像调节🎨" + DESCRIPTION = "Sprite Splitter RGBA - 拆分素材并输出带透明通道的图像 + Mask" + + def split_sprites_rgba(self, **kwargs): + 图像 = kwargs.get("图像") + 最小面积过滤_像素数 = kwargs.get("最小面积过滤(像素数)") + 裁剪边距 = kwargs.get("裁剪边距") + 排序方式 = kwargs.get("排序方式") + img_tensor = 图像[0] + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + + if c == 3: + rgba = np.zeros((h, w, 4), dtype=np.uint8) + rgba[:, :, :3] = img_np + rgba[:, :, 3] = 255 + else: + rgba = img_np.copy() + + alpha = (rgba[:, :, 3] > 0).astype(np.uint8) + + # 定义 4-连通 的结构元素 + structure = np.array([[0, 1, 0], + [1, 1, 1], + [0, 1, 0]]) + + # 使用 scipy.ndimage.label 找出连通区域 + labels, num_features = scipy.ndimage.label(alpha, structure=structure) + + bboxes = [] + if num_features > 0: + # 使用 scipy.ndimage.find_objects 快速获取包围盒 + slices = scipy.ndimage.find_objects(labels) + + # 使用 numpy.bincount 快速计算每个标签的面积 + areas = np.bincount(labels.ravel()) + + for i, slc in enumerate(slices): + if slc is not None: + label_idx = i + 1 + area = areas[label_idx] + + min_y, max_y = slc[0].start, slc[0].stop - 1 + min_x, max_x = slc[1].start, slc[1].stop - 1 + + bboxes.append({ + 'label': label_idx, + 'min_x': min_x, 'min_y': min_y, + 'max_x': max_x, 'max_y': max_y, + 'area': area + }) + + valid_bboxes = [b for b in bboxes if b['area'] >= 最小面积过滤_像素数] + + if 排序方式 == "从左到右-从上到下": + row_height = max(60, h // 15) + valid_bboxes.sort(key=lambda b: (b['min_y'] // row_height, b['min_x'])) + elif 排序方式 == "从上到下-从左到右": + col_width = max(60, w // 15) + valid_bboxes.sort(key=lambda b: (b['min_x'] // col_width, b['min_y'])) + elif 排序方式 == "面积从大到小": + valid_bboxes.sort(key=lambda b: -b['area']) + elif 排序方式 == "面积从小到大": + valid_bboxes.sort(key=lambda b: b['area']) + + result_images = [] + result_masks = [] + + for b in valid_bboxes: + sx = max(0, b['min_x'] - 裁剪边距) + sy = max(0, b['min_y'] - 裁剪边距) + ex = min(w, b['max_x'] + 裁剪边距 + 1) + ey = min(h, b['max_y'] + 裁剪边距 + 1) + + # 使用 numpy 切片快速裁剪 + sprite_rgba = rgba[sy:ey, sx:ex].copy() + sprite_labels = labels[sy:ey, sx:ex] + + # 只保留当前标签的像素,其他设为透明 + mask = (sprite_labels != b['label']) + sprite_rgba[mask] = 0 + + # IMAGE: RGB [1, H, W, 3] + sprite_rgb = sprite_rgba[:, :, :3].astype(np.float32) / 255.0 + img_t = torch.from_numpy(sprite_rgb).unsqueeze(0) + result_images.append(img_t) + + # MASK: [1, H, W], 1.0 = 不透明, 0.0 = 透明 + mask_np = sprite_rgba[:, :, 3].astype(np.float32) / 255.0 + mask_t = torch.from_numpy(mask_np).unsqueeze(0) + result_masks.append(mask_t) + + if not result_images: + result_images.append(图像[:1]) + h0, w0 = 图像.shape[1], 图像.shape[2] + result_masks.append(torch.ones(1, h0, w0)) + + return (result_images, result_masks) + + +# ======== 节点注册 ======== +NODE_CLASS_MAPPINGS = { + "RuiSpriteSplitter": SpriteSplitter, + "RuiSpriteSplitterRGBA": SpriteSplitterRGBA, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "RuiSpriteSplitter": "素材拆分 / Sprite Splitter", + "RuiSpriteSplitterRGBA": "素材拆分(带透明通道) / Sprite Splitter RGBA", +} diff --git a/load_image_node.py b/load_image_node.py new file mode 100644 index 0000000..1d63731 --- /dev/null +++ b/load_image_node.py @@ -0,0 +1,73 @@ +import torch +import numpy as np +from PIL import Image +import os + +class LoadImageByPathNode: + """ + 按路径加载图像的节点 + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image_path": ("STRING", { + "default": "E:\\ComfyUIModels\\input\\10\\1.png", + "multiline": False + }), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "load_image_by_path" + CATEGORY = "Rui-Node🐶/文件存储与加载📁" + + def load_image_by_path(self, image_path): + """ + 根据指定路径加载图像 + + 参数: + image_path: 图像文件的完整路径 + + 返回: + 加载的图像张量 + """ + try: + # 检查文件是否存在 + if not os.path.exists(image_path): + raise FileNotFoundError(f"图像文件不存在: {image_path}") + + # 使用 PIL 加载图像 + img_pil = Image.open(image_path) + + # 确保图像为 RGB 模式 + if img_pil.mode != 'RGB': + img_pil = img_pil.convert('RGB') + + # 将 PIL 图像转换为 NumPy 数组 + img_np = np.array(img_pil).astype(np.float32) / 255.0 + + # 转换为 PyTorch 张量并添加批次维度 + # ComfyUI 期望的格式为 BHWC + img_tensor = torch.from_numpy(img_np).unsqueeze(0) + + return (img_tensor,) + + except Exception as e: + # 如果加载失败,创建一个默认的黑色图像 + print(f"加载图像失败: {str(e)}") + # 创建一个 512x512 的黑色图像作为默认值 + default_img = np.zeros((512, 512, 3), dtype=np.float32) + default_tensor = torch.from_numpy(default_img).unsqueeze(0) + return (default_tensor,) + +# 节点映射字典,用于 ComfyUI 注册节点 +NODE_CLASS_MAPPINGS = { + "LoadImageByPath": LoadImageByPathNode +} + +# 节点显示名称映射,用于在 UI 中显示友好名称 +NODE_DISPLAY_NAME_MAPPINGS = { + "LoadImageByPath": "按路径加载图像 / Load Image By Path" +} \ No newline at end of file diff --git a/mask_preview_node.py b/mask_preview_node.py new file mode 100644 index 0000000..98826f0 --- /dev/null +++ b/mask_preview_node.py @@ -0,0 +1,172 @@ +import torch +import torch.nn.functional as F +import numpy as np +from PIL import Image +import folder_paths +import os +import json + +class RuiMaskPreview: + """ + 遮罩预览节点:将遮罩以半透明彩色形式叠加到图像上进行可视化预览 + """ + + COLOR_MAP = { + "red": (1.0, 0.0, 0.0), + "green": (0.0, 1.0, 0.0), + "blue": (0.0, 0.0, 1.0), + "yellow": (1.0, 1.0, 0.0), + "cyan": (0.0, 1.0, 1.0), + "magenta": (1.0, 0.0, 1.0), + "white": (1.0, 1.0, 1.0), + } + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "mask": ("MASK",), + "mask_color": ( + ["红色 / Red", "绿色 / Green", "蓝色 / Blue", "黄色 / Yellow", + "青色 / Cyan", "品红 / Magenta", "白色 / White"], + {"default": "红色 / Red"} + ), + }, + "optional": { + "opacity": ("FLOAT", { + "default": 0.5, + "min": 0.0, + "max": 1.0, + "step": 0.05 + }), + } + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("图像 / Image",) + FUNCTION = "preview_mask" + CATEGORY = "Rui-Node🐶/遮罩处理🎭" + OUTPUT_NODE = True + + def preview_mask(self, image, mask, mask_color, opacity=0.5): + """ + 将遮罩以半透明彩色形式叠加到图像上 + + 参数: + image: 输入图像张量 (N, H, W, C) + mask: 输入遮罩张量 (N, H, W) 或 (H, W) + mask_color: 遮罩显示颜色(中英双语字符串) + opacity: 不透明度 (0.0-1.0) + + 返回: + 合成后的图像张量和预览信息 + """ + color_mapping = { + "红色 / Red": "red", + "绿色 / Green": "green", + "蓝色 / Blue": "blue", + "黄色 / Yellow": "yellow", + "青色 / Cyan": "cyan", + "品红 / Magenta": "magenta", + "白色 / White": "white", + } + + color_key = color_mapping.get(mask_color, "red") + color_rgb = self.COLOR_MAP[color_key] + + batch, height, width, channels = image.shape + + if mask.dim() == 2: + mask = mask.unsqueeze(0) + + mask_batch, mask_height, mask_width = mask.shape + + if mask_height != height or mask_width != width: + mask = mask.unsqueeze(1) + mask = F.interpolate( + mask, + size=(height, width), + mode='bilinear', + align_corners=False + ) + mask = mask.squeeze(1) + + if mask_batch == 1 and batch > 1: + mask = mask.repeat(batch, 1, 1) + elif mask_batch != batch: + min_batch = min(mask_batch, batch) + mask = mask[:min_batch] + image = image[:min_batch] + batch = min_batch + print(f"警告: 遮罩批次数({mask_batch})与图像批次数({batch})不匹配,已截取为{min_batch}") + + mask = torch.clamp(mask, 0.0, 1.0) + + if channels > 3: + image = image[:, :, :, :3] + + mask_expanded = mask.unsqueeze(-1) + + color_tensor = torch.tensor( + color_rgb, + dtype=image.dtype, + device=image.device + ).view(1, 1, 1, 3) + + color_layer = mask_expanded * color_tensor + + alpha = mask_expanded * opacity + + output = image * (1 - alpha) + color_layer * opacity + + output = torch.clamp(output, 0.0, 1.0) + + results = self.save_images(output) + + return { + "ui": {"images": results}, + "result": (output,) + } + + def save_images(self, images): + """ + 保存图像供预览使用 + + 参数: + images: 图像张量 (N, H, W, C) + + 返回: + 包含图像信息的列表 + """ + results = [] + + output_dir = folder_paths.get_temp_directory() + + for i, image_tensor in enumerate(images): + img_np = image_tensor.cpu().numpy() + + img_np = np.clip(img_np * 255, 0, 255).astype(np.uint8) + + img_pil = Image.fromarray(img_np, 'RGB') + + filename = f"mask_preview_{i:05d}.png" + filepath = os.path.join(output_dir, filename) + + img_pil.save(filepath, compress_level=4) + + results.append({ + "filename": filename, + "subfolder": "", + "type": "temp" + }) + + return results + +NODE_CLASS_MAPPINGS = { + "RuiMaskPreview": RuiMaskPreview +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "RuiMaskPreview": "遮罩预览 / Mask Preview" +} diff --git a/mask_selector_node.py b/mask_selector_node.py new file mode 100644 index 0000000..4d2b68d --- /dev/null +++ b/mask_selector_node.py @@ -0,0 +1,176 @@ +import torch +import json + +class MaskSelector: + """ + 遮罩筛选节点: + - 对输入遮罩进行排序(按面积、从左到右、从上到下) + - 输出指定编号的遮罩、剩余遮罩的合并结果,以及 JSON 信息 + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "masks": ("MASK",), + "sort_method": (["按面积排序 / By Area", "从左到右 / Left to Right", "从上到下 / Top to Bottom"], { + "default": "按面积排序 / By Area" + }), + "index": ("INT", { + "default": 1, + "min": 1, + "step": 1 + }), + } + } + + RETURN_TYPES = ("MASK", "MASK", "STRING") + RETURN_NAMES = ("选中遮罩 / Selected", "剩余遮罩 / Remaining", "信息 / Info") + FUNCTION = "select_mask" + CATEGORY = "Rui-Node🐶/遮罩处理🎭" + + def select_mask(self, masks, sort_method, index): + """ + 主处理流程: + - 标准化输入形状为 (N, H, W) + - 计算每个遮罩的面积与质心 + - 按指定规则排序 + - 选择指定编号并返回三个输出 + """ + # 标准化输入形状 + if masks.dim() == 2: + # (H, W) -> (1, H, W) + masks = masks.unsqueeze(0) + elif masks.dim() == 3: + pass # (N, H, W) + else: + # 非法形状,返回空结果 + H = W = 0 + empty = torch.zeros((1, H, W), dtype=torch.float32) + info = json.dumps({"error": "invalid mask shape"}, ensure_ascii=False) + return (empty, empty, info) + + N, H, W = masks.shape + + # 计算面积与质心 + # 使用阈值 0.5 判定非零像素 + bin_masks = (masks > 0.5) + + areas = [] + centers = [] + all_empty = True + for i in range(N): + nz = torch.nonzero(bin_masks[i], as_tuple=False) + area = nz.shape[0] + areas.append(int(area)) + if area > 0: + all_empty = False + # 质心坐标:x 为列索引平均,y 为行索引平均 + y_mean = float(nz[:, 0].float().mean().item()) + x_mean = float(nz[:, 1].float().mean().item()) + centers.append((x_mean, y_mean)) + else: + # 空遮罩:用占位中心使其排序在末尾 + centers.append((float('inf'), float('inf'))) + + # 映射中英双语排序选项到内部代码 + method_map = { + "按面积排序 / By Area": "by_area", + "从左到右 / Left to Right": "left_to_right", + "从上到下 / Top to Bottom": "top_to_bottom", + # 兼容老值 + "by_area": "by_area", + "left_to_right": "left_to_right", + "top_to_bottom": "top_to_bottom", + } + internal_method = method_map.get(sort_method, "by_area") + + # 排序索引 + if internal_method == "by_area": + # 面积从大到小 + sort_key = [(areas[i], -i) for i in range(N)] + order = sorted(range(N), key=lambda i: sort_key[i], reverse=True) + elif internal_method == "left_to_right": + # 按质心 X 升序 + sort_key = [centers[i][0] for i in range(N)] + order = sorted(range(N), key=lambda i: sort_key[i]) + elif internal_method == "top_to_bottom": + # 按质心 Y 升序 + sort_key = [centers[i][1] for i in range(N)] + order = sorted(range(N), key=lambda i: sort_key[i]) + else: + # 未知排序方式,默认按面积 + sort_key = [(areas[i], -i) for i in range(N)] + order = sorted(range(N), key=lambda i: sort_key[i], reverse=True) + + # 1-based 索引选择与夹取 + index_clamped = False + target_idx = index - 1 + if target_idx < 0: + target_idx = 0 + index_clamped = True + if target_idx >= N: + target_idx = N - 1 + index_clamped = True + + selected_i = order[target_idx] + selected = masks[selected_i].unsqueeze(0) # (1, H, W) + + # 合并剩余遮罩:逻辑或 + remaining_indices = [i for i in order if i != selected_i] + if len(remaining_indices) == 0: + remaining = torch.zeros((1, H, W), dtype=masks.dtype, device=masks.device) + else: + remaining_stack = masks[remaining_indices] # (M, H, W) + # 使用 max 合并为逻辑或 + remaining = torch.max(remaining_stack, dim=0).values.unsqueeze(0) + + # JSON 信息(包含中文注释的字符串) + selected_area = areas[selected_i] + sel_center = centers[selected_i] + # 对空遮罩设定中心为 [0, 0] + if sel_center[0] == float('inf') or sel_center[1] == float('inf'): + sel_center_out = [0, 0] + else: + sel_center_out = [int(round(sel_center[0])), int(round(sel_center[1]))] + + info_obj = { + "total_masks": N, # 遮罩总数:检测到的遮罩总数量 + "selected_index": index, # 选中编号:用户指定输出的遮罩编号(原始输入) + "sort_method": internal_method, # 排序方式:当前使用的排序规则代码 + "selected_area": selected_area, # 选中面积:选中遮罩的像素面积 + "selected_center": sel_center_out, # 选中中心点:选中遮罩的质心坐标 [x, y] + "index_clamped": index_clamped # 编号越界:若编号超出范围被自动修正则为 true + } + if all_empty: + info_obj["warning"] = "所有遮罩为空 / All masks empty" + + # 生成带注释的 JSON 字符串 + # 注意:标准 JSON 不支持注释,这里按照需求输出 JSON 风格字符串并附带注释 + # 若后续需严格 JSON,可移除注释并使用 json.dumps(info_obj, ensure_ascii=False) + info_lines = [ + "{", + f' "total_masks": {info_obj["total_masks"]}, // 遮罩总数:检测到的遮罩总数量', + f' "selected_index": {info_obj["selected_index"]}, // 选中编号:用户指定输出的遮罩编号', + f' "sort_method": "{info_obj["sort_method"]}", // 排序方式:当前使用的排序规则代码', + f' "selected_area": {info_obj["selected_area"]}, // 选中面积:选中遮罩的像素面积', + f' "selected_center": {json.dumps(info_obj["selected_center"], ensure_ascii=False)}, // 选中中心点:选中遮罩的质心坐标 [x, y]', + f' "index_clamped": {"true" if info_obj["index_clamped"] else "false"} // 编号越界:如果用户输入的编号超出范围被自动修正则为 true' + ] + if "warning" in info_obj: + info_lines.append(f' ,"warning": "{info_obj["warning"]}" // 警告:所有遮罩为空') + info_lines.append("}") + info_str = "\n".join(info_lines) + + return (selected, remaining, info_str) + + +# 节点映射字典,用于 ComfyUI 注册节点 +NODE_CLASS_MAPPINGS = { + "MaskSelector": MaskSelector +} + +# 节点显示名称映射,用于在 UI 中显示友好名称 +NODE_DISPLAY_NAME_MAPPINGS = { + "MaskSelector": "遮罩筛选 / Mask Selector" +} \ No newline at end of file diff --git a/openai_node.py b/openai_node.py new file mode 100644 index 0000000..9fcf45f --- /dev/null +++ b/openai_node.py @@ -0,0 +1,181 @@ +import torch +import numpy as np +import requests +import json +import base64 +import io +import os +from PIL import Image + +# 快速解决方案:清除可能导致连接错误的代理环境变量 +# Fast solution: Clear proxy environment variables that might cause connection errors +# 许多用户在使用 requests 库连接 OpenAI API 时会遇到 ProxyError +# 这是因为 Python 环境可能读取了不正确的系统代理设置 +# Many users encounter ProxyError when connecting to OpenAI API with requests +# This is because the Python environment might read incorrect system proxy settings +os.environ['HTTP_PROXY'] = '' +os.environ['HTTPS_PROXY'] = '' +os.environ['http_proxy'] = '' +os.environ['https_proxy'] = '' + +class OpenAINode: + """ + OpenAI API 节点: + 支持连接 OpenAI 及其兼容 API(如 DeepSeek, Moonshot 等), + 支持文本生成和多模态图像理解。 + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "api_url": ("STRING", { + "default": "https://api.openai.com/v1/chat/completions", + "multiline": False + }), + "api_key": ("STRING", { + "default": "", + "multiline": False + }), + "model": ("STRING", { + "default": "gpt-4o", + "multiline": False + }), + "system_prompt": ("STRING", { + "default": "You are a helpful assistant.", + "multiline": True + }), + "user_prompt": ("STRING", { + "default": "", + "multiline": True + }), + "seed": ("INT", { + "default": 0, + "min": 0, + "max": 0xffffffffffffffff + }), + # max_tokens, temperature 等常用参数可以根据需要添加,这里保持精简 + }, + "optional": { + "image": ("IMAGE",), + "proxy_url": ("STRING", { + "default": "", + "multiline": False, + "placeholder": "e.g., http://127.0.0.1:7890" + }), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("text",) + FUNCTION = "generate_content" + CATEGORY = "Rui-Node🐶/AI模型🤖" + + def generate_content(self, api_url, api_key, model, system_prompt, user_prompt, seed, image=None, proxy_url=""): + """ + 调用 OpenAI API 生成内容 + """ + + # 准备消息列表 + messages = [ + {"role": "system", "content": system_prompt} + ] + + user_content = [] + + # 添加用户文本提示词 + if user_prompt: + user_content.append({ + "type": "text", + "text": user_prompt + }) + + # 处理图像输入 + if image is not None: + # 获取批次中的第一张图像 + img_tensor = image[0] + + # 将 Tensor 转换为 PIL Image + img_np = img_tensor.cpu().numpy() + img_np = np.clip(img_np, 0, 1) + img_pil = Image.fromarray((img_np * 255).astype(np.uint8), 'RGB') + + # 将图像转换为 base64 + buffered = io.BytesIO() + img_pil.save(buffered, format="JPEG") + img_base64 = base64.b64encode(buffered.getvalue()).decode('utf-8') + + # 添加图像内容 + user_content.append({ + "type": "image_url", + "image_url": { + "url": f"data:image/jpeg;base64,{img_base64}" + } + }) + + # 如果 user_content 为空,且没有图像,至少添加一个空文本以防 API 报错 + if not user_content: + user_content.append({ + "type": "text", + "text": " " + }) + + # 构造用户消息 + # 注意:对于不支持多模态的模型(如 gpt-3.5-turbo),发送 image_url 可能会报错 + # 但遵循“符合最新规范”的要求,我们默认使用 content list 结构 + # 如果模型不支持 list content,可以尝试回退到纯字符串(但这会丢失图片) + # 这里为了保持代码简洁,我们始终使用 list 结构,依赖用户选择支持 vision 的模型或仅输入文本 + messages.append({ + "role": "user", + "content": user_content + }) + + # 构造请求头 + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}" + } + + # 构造请求体 + payload = { + "model": model, + "messages": messages, + "seed": seed, + # 可以添加 temperature 等参数 + } + + # 处理代理设置 + proxies = None + if proxy_url and proxy_url.strip(): + proxies = { + "http": proxy_url, + "https": proxy_url + } + + try: + # 发送请求 + response = requests.post(api_url, headers=headers, json=payload, proxies=proxies, timeout=60) + response.raise_for_status() + + # 解析响应 + result = response.json() + + # 提取生成的文本 + if "choices" in result and len(result["choices"]) > 0: + content = result["choices"][0]["message"]["content"] + return (content,) + else: + return (f"Error: API response format unexpected. Response: {json.dumps(result)}",) + + except Exception as e: + return (f"Error calling OpenAI API: {str(e)}",) + +# 节点映射字典 +NODE_CLASS_MAPPINGS = { + "OpenAIAPINode": OpenAINode +} + +# 节点显示名称映射 +NODE_DISPLAY_NAME_MAPPINGS = { + "OpenAIAPINode": "OpenAI API 连接 / OpenAI API Connector" +} diff --git a/page_narration_remover_node.py b/page_narration_remover_node.py new file mode 100644 index 0000000..818913d --- /dev/null +++ b/page_narration_remover_node.py @@ -0,0 +1,57 @@ +class PageNarrationRemoverNode: + """ + 页面旁白删除器:删除文本中单独一行的页面旁白内容,如: + 页面旁白:"……" + + 输入: + - input_text: 原始文本(支持多行) + 输出: + - clean_text: 移除页面旁白行后的文本 + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "input_text": ("STRING", { + "multiline": True, + "default": "" + }), + }, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("clean_text",) + FUNCTION = "remove_narration" + CATEGORY = "Rui-Node🐶/文本处理📝" + + def remove_narration(self, input_text: str): + """ + 删除文本中所有以“页面旁白:”或“页面旁白:”开头的整行(忽略前后空白)。 + 保留其他内容,并以换行符连接返回。 + """ + if not isinstance(input_text, str): + input_text = str(input_text) + + lines = input_text.splitlines() + kept_lines = [] + for line in lines: + stripped = line.strip() + # 删除以“页面旁白:”或“页面旁白:”开头的整行 + if stripped.startswith("页面旁白:") or stripped.startswith("页面旁白:"): + continue + kept_lines.append(line) + + clean_text = "\n".join(kept_lines) + return (clean_text,) + + +# 节点映射字典,用于 ComfyUI 注册节点 +NODE_CLASS_MAPPINGS = { + "PageNarrationRemover": PageNarrationRemoverNode +} + +# 节点显示名称映射,用于在 UI 中显示友好名称 +NODE_DISPLAY_NAME_MAPPINGS = { + "PageNarrationRemover": "页面旁白删除器 / Page Narration Remover" +} \ No newline at end of file diff --git a/qwenedit_node.py b/qwenedit_node.py new file mode 100644 index 0000000..bb8a349 --- /dev/null +++ b/qwenedit_node.py @@ -0,0 +1,176 @@ +import torch +import numpy as np +import requests +import json +import base64 +import io +from PIL import Image +import random + +class QwenEditNode: + """ + 使用阿里云千问编辑模型API进行图像生成的节点 + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image1": ("IMAGE",), + "image2": ("IMAGE",), + "image3": ("IMAGE",), + "image4": ("IMAGE",), + "api_key": ("STRING", { + "default": "", + "multiline": False + }), + "base_url": ("STRING", { + "default": "https://dashscope.aliyuncs.com/api/v1/services/aigc/text2image-generation/generation", + "multiline": False + }), + "seed": ("INT", { + "default": -1, + "min": -1, + "max": 2147483647 + }), + "control_mode": (["reference", "sketch", "scribble", "pose", "canny", "depth", "hed", "mlsd", "normal", "seg"], { + "default": "reference" + }), + "width": ("INT", { + "default": 1024, + "min": 512, + "max": 2048, + "step": 8 + }), + "height": ("INT", { + "default": 1024, + "min": 512, + "max": 2048, + "step": 8 + }), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "generate_image" + CATEGORY = "Rui-Node🐶/AI模型🤖" + + def generate_image(self, image1, image2, image3, image4, api_key, base_url, seed, control_mode, width, height): + """ + 使用千问编辑模型API生成图像 + + 参数: + image1-4: 输入图像张量 (B, H, W, C) 格式 + api_key: 阿里云API密钥 + base_url: API基础URL + seed: 随机种子值,-1表示随机生成 + control_mode: 控制模式 + width: 输出图像宽度 + height: 输出图像高度 + + 返回: + 生成的图像张量 + """ + # 如果seed为-1,则随机生成种子 + if seed == -1: + seed = random.randint(0, 2147483647) + + # 准备图像数据 + images = [image1, image2, image3, image4] + image_data = [] + + for i, img in enumerate(images): + if img is None or img.shape[0] == 0: + continue + + # 取批次中的第一张图像 + img_np = img[0].cpu().numpy() + + # 确保值范围在 0-1 之间 + img_np = np.clip(img_np, 0, 1) + + # 转换为 PIL 图像 (值范围 0-255) + img_pil = Image.fromarray((img_np * 255).astype(np.uint8), 'RGB') + + # 转换为base64编码 + buffered = io.BytesIO() + img_pil.save(buffered, format="PNG") + img_base64 = base64.b64encode(buffered.getvalue()).decode("utf-8") + + # 添加到图像数据列表 + image_data.append({ + "image": img_base64, + "control_type": control_mode + }) + + # 准备API请求 + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}" + } + + payload = { + "model": "qwen-vl-plus", + "input": { + "images": image_data + }, + "parameters": { + "seed": seed, + "width": width, + "height": height + } + } + + try: + # 发送API请求 + response = requests.post(base_url, headers=headers, data=json.dumps(payload)) + response.raise_for_status() + + # 解析响应 + result = response.json() + + # 检查是否成功 + if "output" in result and "images" in result["output"] and len(result["output"]["images"]) > 0: + # 获取生成的图像 + generated_image_base64 = result["output"]["images"][0] + + # 解码base64图像 + image_bytes = base64.b64decode(generated_image_base64) + img_pil = Image.open(io.BytesIO(image_bytes)) + + # 确保图像为 RGB 模式 + if img_pil.mode != 'RGB': + img_pil = img_pil.convert('RGB') + + # 转换为 NumPy 数组 + img_np = np.array(img_pil).astype(np.float32) / 255.0 + + # 转换为 PyTorch 张量并添加批次维度 + img_tensor = torch.from_numpy(img_np).unsqueeze(0) + + return (img_tensor,) + else: + # 如果响应中没有图像,返回错误信息 + print(f"API响应中没有图像: {result}") + # 创建一个默认的黑色图像作为返回值 + default_img = np.zeros((height, width, 3), dtype=np.float32) + default_tensor = torch.from_numpy(default_img).unsqueeze(0) + return (default_tensor,) + + except Exception as e: + # 如果API请求失败,打印错误信息并返回默认图像 + print(f"API请求失败: {str(e)}") + # 创建一个默认的黑色图像作为返回值 + default_img = np.zeros((height, width, 3), dtype=np.float32) + default_tensor = torch.from_numpy(default_img).unsqueeze(0) + return (default_tensor,) + +# 节点映射字典,用于 ComfyUI 注册节点 +NODE_CLASS_MAPPINGS = { + "QwenEditImageGeneration": QwenEditNode +} + +# 节点显示名称映射,用于在 UI 中显示友好名称 +NODE_DISPLAY_NAME_MAPPINGS = { + "QwenEditImageGeneration": "千问编辑图像生成 / Qwen Edit Image Generation" +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..384e15c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +torch +numpy +Pillow +requests \ No newline at end of file diff --git a/saturation_node.py b/saturation_node.py new file mode 100644 index 0000000..d6b4b14 --- /dev/null +++ b/saturation_node.py @@ -0,0 +1,75 @@ +import torch +import numpy as np +from PIL import Image, ImageEnhance + +class SaturationNode: + """ + 调整图像饱和度的节点 + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "saturation": ("FLOAT", { + "default": 1.0, + "min": 0.0, + "max": 5.0, + "step": 0.1 + }), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "adjust_saturation" + CATEGORY = "Rui-Node🐶/图像调节🎨" + + def adjust_saturation(self, image, saturation): + """ + 调整输入图像的饱和度 + + 参数: + image: 输入图像张量 (B, H, W, C) 格式 + saturation: 饱和度调整系数,1.0为原始饱和度 + + 返回: + 调整后的图像张量 + """ + # 将图像从 PyTorch 张量转换为 PIL 图像进行处理 + batch_size = image.shape[0] + result = [] + + for i in range(batch_size): + # 将单个图像从 PyTorch 张量转换为 NumPy 数组 + # ComfyUI 中图像格式为 BHWC,值范围为 0-1 + img_np = image[i].cpu().numpy() + + # 确保值范围在 0-1 之间 + img_np = np.clip(img_np, 0, 1) + + # 转换为 PIL 图像 (值范围 0-255) + img_pil = Image.fromarray((img_np * 255).astype(np.uint8), 'RGB') + + # 使用 PIL 的 ImageEnhance.Color 调整饱和度 + enhancer = ImageEnhance.Color(img_pil) + enhanced_img = enhancer.enhance(saturation) + + # 将处理后的图像转回 NumPy 数组,然后转为 PyTorch 张量 + enhanced_np = np.array(enhanced_img).astype(np.float32) / 255.0 + enhanced_tensor = torch.from_numpy(enhanced_np) + + result.append(enhanced_tensor) + + # 将结果堆叠为批次 + return (torch.stack(result),) + +# 节点映射字典,用于 ComfyUI 注册节点 +NODE_CLASS_MAPPINGS = { + "SaturationAdjustment": SaturationNode +} + +# 节点显示名称映射,用于在 UI 中显示友好名称 +NODE_DISPLAY_NAME_MAPPINGS = { + "SaturationAdjustment": "调整饱和度 / Saturation Adjustment" +} \ No newline at end of file diff --git a/shot_splitter_node.py b/shot_splitter_node.py new file mode 100644 index 0000000..a0ac109 --- /dev/null +++ b/shot_splitter_node.py @@ -0,0 +1,99 @@ +import re + +class ShotSplitterNode: + """ + 镜头分词器节点:将输入的多分镜描述脚本拆分成独立的分镜描述 + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "input_text": ("STRING", { + "multiline": True, + "default": "" + }), + }, + "optional": { + "start_shot_num": ("INT", { + "default": 0, + "min": 0, + "max": 100, + "step": 1, + }), + "shot_count": ("INT", { + "default": 0, + "min": 0, + "max": 100, + "step": 1, + }), + }, + } + + RETURN_TYPES = ("LIST", "STRING") + RETURN_NAMES = ("shot_descriptions", "summary") + FUNCTION = "split_shots" + CATEGORY = "Rui-Node🐶/文本处理📝" + + def split_shots(self, input_text, start_shot_num=0, shot_count=0): + """ + 将输入的多分镜描述脚本拆分成独立的分镜描述 + + 参数: + input_text: 输入的多分镜描述脚本 + + 返回: + shot_descriptions: 拆分后的分镜描述列表(Python列表格式的字符串) + summary: 总结信息 + """ + # 使用正则表达式匹配所有分镜内容 + # 匹配和之间的内容,其中XXX是数字 + pattern = r'([\s\S]*?)' + matches = re.findall(pattern, input_text) + + # 提取分镜描述 + shot_descriptions = [] + for shot_num, content in matches: + # 去除开头和结尾的空白字符,但保留内部段落结构 + content = content.strip() + shot_descriptions.append(content) + + # 根据开始和结束分镜编号筛选分镜 + filtered_shots = [] + all_shot_nums = [int(shot_num) for shot_num, _ in matches] + + # 如果用户没有输入开始编号和导出数量(都为0),则导出所有分镜 + if start_shot_num == 0 and shot_count == 0: + filtered_shots = shot_descriptions + else: + # 如果没有设置开始编号,则从第一个分镜开始 + if start_shot_num == 0: + start_shot_num = min(all_shot_nums) if all_shot_nums else 0 + + # 计算结束编号 + end_shot_num = start_shot_num + shot_count - 1 if shot_count > 0 else max(all_shot_nums) + + # 筛选指定范围内的分镜 + for i, (shot_num, content) in enumerate(matches): + if start_shot_num <= int(shot_num) <= end_shot_num: + filtered_shots.append(shot_descriptions[i]) + + # 生成总结信息 + if start_shot_num > 0 or shot_count > 0: + end_num = start_shot_num + shot_count - 1 if shot_count > 0 else max(all_shot_nums) + summary = f"一共拆分成{len(shot_descriptions)}个分镜,导出了{len(filtered_shots)}个分镜(从{start_shot_num}开始,共{shot_count if shot_count > 0 else '全部'}个)。" + else: + summary = f"一共拆分成{len(shot_descriptions)}个分镜。" + + # 直接返回Python列表,不转换为字符串 + return (filtered_shots, summary) + +# 节点映射字典,用于 ComfyUI 注册节点 +NODE_CLASS_MAPPINGS = { + "ShotSplitter": ShotSplitterNode +} + +# 节点显示名称映射,用于在 UI 中显示友好名称 +NODE_DISPLAY_NAME_MAPPINGS = { + "ShotSplitter": "镜头分词器 / Shot Splitter" +} \ No newline at end of file diff --git a/test_binary_null.txt b/test_binary_null.txt new file mode 100644 index 0000000..b53b8ca Binary files /dev/null and b/test_binary_null.txt differ diff --git a/text_list_node.py b/text_list_node.py new file mode 100644 index 0000000..b00fc70 --- /dev/null +++ b/text_list_node.py @@ -0,0 +1,81 @@ +import torch +import json + +class TextListNode: + """ + 文本列表制作器节点:将多个独立文本组织成列表形式输出 + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "text1": ("STRING", { + "multiline": True, + "default": "" + }), + }, + "optional": { + "text2": ("STRING", { + "multiline": True, + "default": "" + }), + "text3": ("STRING", { + "multiline": True, + "default": "" + }), + "text4": ("STRING", { + "multiline": True, + "default": "" + }), + "text5": ("STRING", { + "multiline": True, + "default": "" + }), + } + } + + RETURN_TYPES = ("LIST", "STRING") + RETURN_NAMES = ("text_list", "summary") + FUNCTION = "create_text_list" + CATEGORY = "Rui-Node🐶/文本处理📝" + + def create_text_list(self, text1, text2="", text3="", text4="", text5=""): + """ + 将多个文本组织成列表形式输出 + + 参数: + text1-5: 输入的文本 + + 返回: + text_list: 文本列表 + summary: 总结信息 + """ + # 收集所有非空文本 + text_list = [] + if text1.strip(): + text_list.append(text1.strip()) + if text2.strip(): + text_list.append(text2.strip()) + if text3.strip(): + text_list.append(text3.strip()) + if text4.strip(): + text_list.append(text4.strip()) + if text5.strip(): + text_list.append(text5.strip()) + + # 生成总结信息 + summary = f"一共组织了{len(text_list)}段文本。" + + # 直接返回Python列表,不转换为字符串 + return (text_list, summary) + +# 节点映射字典,用于 ComfyUI 注册节点 +NODE_CLASS_MAPPINGS = { + "TextList": TextListNode +} + +# 节点显示名称映射,用于在 UI 中显示友好名称 +NODE_DISPLAY_NAME_MAPPINGS = { + "TextList": "文本列表制作器 / Text List Creator" +} \ No newline at end of file diff --git a/utf8_converter_node.py b/utf8_converter_node.py new file mode 100644 index 0000000..133f0be --- /dev/null +++ b/utf8_converter_node.py @@ -0,0 +1,341 @@ +import os +import torch + +def is_binary_file(path): + try: + with open(path, "rb") as f: + chunk = f.read(1024) + if b"\x00" in chunk: + return True + except: + return True + return False + +def try_decode(data): + """ + 尝试多种编码格式解码二进制数据 + Try to decode binary data with multiple encodings + """ + # 1. Try UTF-8 + try: + return data.decode("utf-8"), "utf-8" + except: + pass + + # 2. Try UTF-16 + try: + return data.decode("utf-16"), "utf-16" + except: + pass + + # 3. Try GBK + try: + return data.decode("gbk"), "gbk" + except: + pass + + # 4. Fallback: UTF-8 with ignore + return data.decode("utf-8", errors="ignore"), "fallback-ignore" + +def clean_text(text): + """ + 严格清洗文本: + - 删除不可见控制字符 (0-31, 保留 \n \t) + - 删除代理对 (Surrogates) + - 删除 4 字节字符 (Emoji, > 0xFFFF) + """ + cleaned = [] + for c in text: + code = ord(c) + + # 删除不可见控制字符 (U+0000 - U+001F),保留 \n (10) 和 \t (9) + if 0 <= code < 32 and c not in ("\n", "\t"): + continue + + # 过滤非法 surrogate 字符 (U+D800 - U+DFFF) + if 0xD800 <= code <= 0xDFFF: + continue + + # 删除 Emoji 和其他 4 字节字符 (> 0xFFFF) + # 注意:某些 Emoji(如 ⚠️ U+26A0)位于 BMP (Basic Multilingual Plane) 内,码点 < 0xFFFF + # 因此仅判断 code > 0xFFFF 是不够的,需要引入 regex 或 Unicode 范围判断 + # 这里我们扩展过滤范围,根据 Unicode 标准过滤 Emoji + # 简单起见,如果需要严格禁止 Emoji,可以使用 Unicode 范围 + # ⚠️ (Warning Sign) is U+26A0 + # ⚡ (High Voltage) is U+26A1 + # ⚽ (Soccer Ball) is U+26BD + # 常见 Emoji 范围包括: + # U+1F300-U+1F5FF (Miscellaneous Symbols and Pictographs) + # U+1F600-U+1F64F (Emoticons) + # U+1F680-U+1F6FF (Transport and Map Symbols) + # U+2600-U+26FF (Miscellaneous Symbols) -> 包含 ⚠️, ⚽, ☁️ 等 + # U+2700-U+27BF (Dingbats) -> 包含 ✈️, ✉️, ✏️ 等 + # + # 用户要求:允许特殊符号如 ★(U+2605), →(U+2192), 《(U+300A), ·(U+00B7) + # ⚠️ (U+26A0) 在 Miscellaneous Symbols 区域,与 ★ (U+2605) 同区! + # 这是一个难点。 + # 策略更新: + # 1. 删除所有 > 0xFFFF 的字符 (4字节字符) -> 已实现 + # 2. 针对 BMP 内的 Emoji,我们需要更细致的黑名单或白名单。 + # 考虑到用户说 "严禁使用任何 Emoji",但 "允许星号、箭头等"。 + # 星号 ★ U+2605 + # 警告 ⚠️ U+26A0 + # 它们非常接近。 + # 我们可以使用 unicode property 或 regex。但在不引入外部库(如 emoji)的情况下, + # 我们可以过滤特定的 Emoji 块,但保留特定的白名单字符。 + + # 简单处理:保留 > 0xFFFF 过滤。 + # 针对 BMP 内的 Emoji,用户提到的 ⚠️ 是 U+26A0。 + # 许多 Emoji 位于 U+2000-U+2FFF 之间,但也包含数学符号和箭头。 + # 让我们尝试过滤 variation selectors (U+FE00-U+FE0F) + # ⚠️ (U+26A0) 通常后面跟着 U+FE0F (VS16) 变成 Emoji 样式。 + # 但单字符 ⚠️ 也是存在的。 + + # 鉴于用户明确指出 ⚠️ 未删除,我们需要加强过滤。 + # 常见 BMP Emoji 范围: + # U+2600-U+26FF (杂项符号) -> 混合了 Emoji 和 符号(如 ★) + # U+2700-U+27BF (Dingbats) -> 混合了 Emoji 和 符号(如 ✂️) + + # 强制过滤列表 (手动列出常见 BMP Emoji 区域或字符) + # 或者,更激进地,如果字符属于 "Symbol, Other" (So) 类别且不是白名单? + # Python 的 unicodedata 库可以帮忙。 + + import unicodedata + try: + category = unicodedata.category(c) + except: + category = "Cn" # Not assigned + + # 4字节字符统统删除 + if code > 0xFFFF: + continue + + # 针对 BMP 字符的特殊过滤 + # 过滤 Variation Selectors (U+FE00 - U+FE0F) + if 0xFE00 <= code <= 0xFE0F: + continue + + # 过滤特定 Emoji 字符 (黑名单补丁) + # ⚠️ U+26A0, ⚡ U+26A1, ✋ U+270B 等 + # 这是一个无底洞,但我们可以尝试过滤掉呈现为 Emoji 的符号 + # 许多现代 Emoji 实际上是基本字符 + VS16。 + # 如果我们删除了 VS16,它们会变成黑白文本符号。 + # 但用户希望 "删除",即完全消失。 + + # 使用 unicode category 过滤? + # ★ (BLACK STAR) -> 'So' (Symbol, Other) + # ⚠️ (WARNING SIGN) -> 'So' + # 两者类别相同。 + + # 既然无法通过类别区分,我们只能依赖码点范围,并设置白名单。 + # 允许:星号(★ U+2605)、方块(■ U+25A0)、箭头(→ U+2192) + # + # 让我们检查 ⚠️ (U+26A0) + # + # 临时方案:增加对常见 BMP Emoji 的过滤,如果不小心误杀,后续再调整。 + # + # 范围 U+2600 - U+26FF (Miscellaneous Symbols) + # 包含: + # 2600-2604 (太阳云雨等) -> Emoji? Yes (☀️, ☁️) + # 2605-2606 (星星) -> 允许 (★, ☆) + # 260E (电话 ☎️) -> Emoji + # 26A0 (警告 ⚠️) -> Emoji + # + # 我们可以只过滤那些明显的 Emoji 范围,或者根据用户反馈的 Bad Case (⚠️) 进行定点清除。 + # 但为了通用性,最好能区分。 + # + # 既然用户要求 "严禁使用任何 Emoji",且 "允许使用的特殊符号仅限 UTF-8收录范围,例如..." + # 这是一个比较模糊的边界。 + # + # 让我们引入 `unicodedata` 并结合范围判断。 + # + # 更新逻辑: + # 1. 保留 code > 0xFFFF 过滤 (处理了绝大多数 Emoji) + # 2. 增加对 BMP Emoji 的过滤。 + # 由于 Python 标准库没有 is_emoji,我们只能根据 Block 进行粗略过滤,并豁免常用符号。 + + # 定义需要检查的 Block (潜在 Emoji 区域) + # Dingbats: U+2700–U+27BF + # Miscellaneous Symbols: U+2600–U+26FF + # Transport and Map Symbols: U+1F680-U+1F6FF (已被 >0xFFFF 覆盖) + + # 针对 BMP 的补充过滤: + if 0x2600 <= code <= 0x27BF: + # 白名单 (用户明确允许或常见的非Emoji符号) + # U+2605 ★, U+2606 ☆ + # U+25A0-U+25FF (Geometric Shapes) -> 不在 2600-27BF 范围内,安全 + # U+2190-U+21FF (Arrows) -> 不在范围内,安全 + # U+300A 《, U+00B7 · -> 不在范围内,安全 + + # 允许的例外列表 (Decimal) + # 9733 (★), 9734 (☆) + allowed_in_range = [9733, 9734] + + if code not in allowed_in_range: + # 进一步检查:如果是 ⚠️ (9888) 或其他 Emoji,则过滤 + # 简单粗暴:在这个范围内,除了白名单,全部视为潜在 Emoji/不常用符号进行过滤? + # 这样可能误杀太严重。 + # + # 让我们仅过滤特定的 Emoji 子集。 + # ⚠️ U+26A0 (9888) + # ⚡ U+26A1 (9889) + # ⚰️ U+26B0 + # ⚽ U+26BD + # ⛄ U+26C4 + # ⛳ U+26F3 + # ... + # + # 更好的方法:只过滤 > 0xFFFF 的字符 + 代理对 + 控制字符。 + # 对于 BMP 内的字符,除非用户指定要过滤,否则保留。 + # 但用户明确反馈 ⚠️ 未删除。 + # + # 让我们针对性地过滤 "Emoji Presentation" 的字符。 + # 但没有库很难做到。 + # + # 妥协方案:硬编码过滤常见的 BMP Emoji 范围,或仅过滤用户提到的 ⚠️。 + # 考虑到 "严禁使用任何 Emoji",我们应该扩大过滤范围。 + + # U+2600-U+26FF 包含很多天气、星座、棋子等,大多被视为 Emoji。 + # U+2700-U+27BF (Dingbats) 包含剪刀、飞机、信封等,大多被视为 Emoji。 + + # 如果我们过滤掉这两个区段,除了白名单? + # 用户提到的 "允许特殊符号": + # 星号 ★ (U+2605) -> 在 U+2600-U+26FF + # 方块 ■ (U+25A0) -> 不在 + # 箭头 → (U+2192) -> 不在 + # 书名号 《 (U+300A) -> 不在 + # 间隔号 · (U+00B7) -> 不在 + + # 结论:只要保护好 ★ (及其他可能的符号),我们可以激进地过滤 U+2600-U+26FF 和 U+2700-U+27BF。 + pass # 逻辑将在下方实现 + + # 实现: + # 1. 过滤 Dingbats (U+2700 - U+27BF) + if 0x2700 <= code <= 0x27BF: + continue + + # 2. 过滤 Miscellaneous Symbols (U+2600 - U+26FF),但保留白名单 + if 0x2600 <= code <= 0x26FF: + # 白名单: + # U+2605 ★ (9733) + # U+2606 ☆ (9734) + whitelist_26xx = {0x2605, 0x2606} + if code not in whitelist_26xx: + continue + + # 3. 过滤 Miscellaneous Technical 中常见的 Emoji (U+23xx) + # ⌚ (231A), ⌛ (231B), ⌨ (2328) + # ⏩ (23E9) - ⏳ (23F3) + # ⏸ (23F8) - ⏺ (23FA) + if code in (0x231A, 0x231B, 0x2328, 0x23CF): + continue + if 0x23E9 <= code <= 0x23F3: + continue + if 0x23F8 <= code <= 0x23FA: + continue + + # 4. 过滤其他零散的常见 Emoji 符号 + # ⭐ (2B50), ⭕ (2B55) + if code in (0x2B50, 0x2B55): + continue + + cleaned.append(c) + + return "".join(cleaned) + +class UTF8ConverterNode: + """ + UTF-8 编码转换与文本清洗节点 (重构版): + 使用鲁棒的解码策略读取文件或处理文本,并执行严格的字符清洗。 + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "input_text": ("STRING", { + "multiline": True, + "default": "", + "placeholder": "输入文本 / Input Text" + }), + }, + "optional": { + "input_file": ("STRING", { + "multiline": False, + "default": "", + "placeholder": "文件路径 / File Path (优先读取 / Overrides input_text)" + }), + } + } + + RETURN_TYPES = ("STRING", "STRING") + RETURN_NAMES = ("filtered_text", "log") + FUNCTION = "run" + CATEGORY = "Rui-Node🐶/文本处理📝" + + def run(self, input_text, input_file=""): + log = [] + text_content = "" + encoding_used = "none" + + # 1. 获取原始内容 (文件优先) + if input_file and input_file.strip(): + if os.path.exists(input_file): + log.append(f"正在读取文件: {input_file}") + + # 检查是否为二进制文件 + if is_binary_file(input_file): + # 虽然是二进制,但我们仍尝试解码,或者直接报错 + # 根据需求"转化为utf-8",我们尝试强制解码 + log.append("警告: 检测到可能的二进制文件") + + try: + with open(input_file, "rb") as f: + raw_data = f.read() + + text_content, encoding_used = try_decode(raw_data) + log.append(f"解码成功: 使用编码 {encoding_used}") + + except Exception as e: + return ("", f"读取或解码文件时发生错误: {str(e)}") + else: + log.append(f"错误: 文件不存在 -> {input_file}") + log.append("回退到使用 input_text") + text_content = input_text + encoding_used = "input_text" + else: + log.append("使用直接输入的文本") + text_content = input_text + encoding_used = "input_text" + + # 2. 清洗文本 + log.append("-" * 20) + log.append("开始清洗文本...") + original_len = len(text_content) + + cleaned_content = clean_text(text_content) + + final_len = len(cleaned_content) + removed_count = original_len - final_len + + # 3. 生成日志 + log.append(f"原始长度: {original_len}") + log.append(f"输出长度: {final_len}") + log.append(f"移除字符数: {removed_count}") + + if removed_count > 0: + log.append("清洗详情: 已移除所有 Emoji、4字节字符、代理对及非法控制字符。") + else: + log.append("结果: 文本已符合规范,无需修改。") + + return (cleaned_content, "\n".join(log)) + +# 节点映射字典 +NODE_CLASS_MAPPINGS = { + "UTF8Converter": UTF8ConverterNode +} + +# 节点显示名称映射 +NODE_DISPLAY_NAME_MAPPINGS = { + "UTF8Converter": "转化为utf-8编码 / Convert to UTF-8" +}