update
This commit is contained in:
@@ -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): 输入的多分镜描述脚本(多行文本)
|
||||
- 格式要求: 使用 `<SHOT_XXX>...</SHOT_XXX>` 标签包裹每个分镜
|
||||
- `start_shot_num` (INT, 可选): 开始导出的分镜编号
|
||||
- 默认值: 0(从第一个开始)
|
||||
- 范围: 0 ~ 100
|
||||
- `shot_count` (INT, 可选): 导出的分镜数量
|
||||
- 默认值: 0(导出全部)
|
||||
- 范围: 0 ~ 100
|
||||
|
||||
**输出**:
|
||||
- `shot_descriptions` (LIST): 拆分后的分镜描述列表
|
||||
- `summary` (STRING): 总结信息
|
||||
|
||||
**文本格式示例**:
|
||||
```
|
||||
<SHOT_1>
|
||||
第一个镜头的描述内容
|
||||
</SHOT_1>
|
||||
<SHOT_2>
|
||||
第二个镜头的描述内容
|
||||
</SHOT_2>
|
||||
```
|
||||
|
||||
**使用场景**:
|
||||
- 分镜脚本拆分
|
||||
- 批量处理分镜描述
|
||||
- 选择性导出特定范围的分镜
|
||||
|
||||
---
|
||||
|
||||
### 6. 对白提取器 / Dialogue Extractor
|
||||
|
||||
**分类**: `Rui-Node🐶/文本处理📝`
|
||||
|
||||
**功能描述**:
|
||||
从分镜描述文本中自动提取旁白/对白内容。
|
||||
|
||||
**输入参数**:
|
||||
- `input_text` (STRING): 输入的分镜描述文本(多行文本)
|
||||
|
||||
**输出**:
|
||||
- `dialogues` (LIST): 提取的旁白/对白列表
|
||||
- `summary` (STRING): 总结信息
|
||||
|
||||
**识别模式**:
|
||||
- 支持格式 1: `旁白:[对白内容]`
|
||||
- 支持格式 2: `旁白:对白内容`
|
||||
- 自动识别 `<SHOT_XXX>` 标签中的旁白
|
||||
|
||||
**使用场景**:
|
||||
- 从分镜脚本中提取对白
|
||||
- 批量收集旁白文本
|
||||
- 准备配音文本
|
||||
|
||||
---
|
||||
|
||||
### 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🐶!** 🎨✨
|
||||
+70
@@ -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']
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"
|
||||
}
|
||||
@@ -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'<SHOT_(\d+)>([\s\S]*?)</SHOT_\1>'
|
||||
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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
+181
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
torch
|
||||
numpy
|
||||
Pillow
|
||||
requests
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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: 总结信息
|
||||
"""
|
||||
# 使用正则表达式匹配所有分镜内容
|
||||
# 匹配<SHOT_XXX>和</SHOT_XXX>之间的内容,其中XXX是数字
|
||||
pattern = r'<SHOT_(\d+)>([\s\S]*?)</SHOT_\1>'
|
||||
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"
|
||||
}
|
||||
Binary file not shown.
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user