拆分代码结构

This commit is contained in:
Cyber Dick Lang
2025-06-24 16:22:02 +08:00
parent ca2c13e0ef
commit d402fcb052
159 changed files with 3197 additions and 1211 deletions
+1
View File
@@ -23,6 +23,7 @@
## 节点开发
- 节点参数需有默认值、类型、范围说明。
- 输入输出类型必须与 ComfyUI 规范一致。
- **所有节点的输入和输出shape、类型、参数名、返回名,必须严格遵循ComfyUI官方节点开发规范,保证与ComfyUI原生节点和其他插件节点100%兼容。不得自定义非标准shape或类型。**
- 新增节点需在 `__init__.py` 注册,并补充到文档。
- 节点参数、UI、输出类型、行为与参考项目完全一致。
- 不得随意增删参数或更改默认值。
+203
View File
@@ -0,0 +1,203 @@
# ComfyUI Universal Toolkit 重构计划
## 概述
本项目将按照 ComfyUI-LayerStyle 项目的模块化组织方式,将大型节点文件拆分成多个独立的功能模块,提高代码的可维护性和模块化程度。
## 重构目标
1. **模块化组织**:将大型节点文件按功能拆分成独立模块
2. **代码复用**:创建共用工具函数,避免重复代码
3. **易于维护**:每个节点独立文件,便于修改和调试
4. **清晰结构**:按功能分类组织代码结构
## 目录结构
```
nodes/
├── common_utils.py # 共用工具函数
├── image/ # 图像处理节点
│ ├── __init__.py
│ ├── empty_unit_generator.py
│ ├── image_ratio_detector.py
│ ├── depth_map_blur.py
│ ├── image_concatenate.py
│ ├── image_concatenate_multi.py
│ ├── image_pad_for_outpaint.py
│ ├── image_and_mask_preview.py
│ ├── imitation_hue_node.py
│ ├── image_scale_by_aspect_ratio.py
│ ├── image_mask_scale_as.py
│ ├── image_scale_restore.py
│ ├── image_remove_alpha.py
│ ├── image_combine_alpha.py
│ ├── check_mask.py
│ ├── purge_vram.py
│ ├── crop_by_mask.py
│ └── restore_crop_box.py
├── tools/ # 工具类节点
│ ├── __init__.py
│ ├── show_int.py
│ ├── show_float.py
│ ├── show_list.py
│ ├── show_text.py
│ ├── preview_mask.py
│ └── fill_masked_area.py
├── mask/ # 掩码处理节点
│ ├── __init__.py
│ ├── mask_and.py
│ ├── mask_sub.py
│ └── mask_add.py
├── audio/ # 音频处理节点
│ ├── __init__.py
│ ├── load_audio_plus_from_path.py
│ └── audio_crop_process.py
├── image_nodes_utk.py # 原有文件(逐步迁移后删除)
├── tool_nodes_utk.py # 原有文件(逐步迁移后删除)
├── mask_nodes_utk.py # 原有文件(逐步迁移后删除)
└── audio_nodes_utk.py # 原有文件(逐步迁移后删除)
```
## 重构步骤
### 第一阶段:基础架构 ✅
- [x] 创建目录结构
- [x] 创建共用工具函数 `common_utils.py`
- [x] 创建模块初始化文件
### 第二阶段:图像节点迁移 🔄
- [x] EmptyUnitGenerator_UTK
- [x] ImageRatioDetector_UTK
- [x] DepthMapBlur_UTK
- [x] ImageConcatenate_UTK
- [ ] ImageConcatenateMulti_UTK
- [ ] ImagePadForOutpaintMasked_UTK
- [ ] ImageAndMaskPreview_UTK
- [ ] ImitationHueNode_UTK
- [ ] ImageScaleByAspectRatio_UTK
- [ ] ImageMaskScaleAs_UTK
- [ ] ImageScaleRestore_UTK
- [ ] ImageRemoveAlpha_UTK
- [ ] ImageCombineAlpha_UTK
- [ ] CheckMask_UTK
- [ ] PurgeVRAM_UTK
- [ ] CropByMask_UTK
- [ ] RestoreCropBox_UTK
### 第三阶段:工具节点迁移
- [ ] ShowInt_UTK
- [ ] ShowFloat_UTK
- [ ] ShowList_UTK
- [ ] ShowText_UTK
- [ ] PreviewMask_UTK
- [ ] FillMaskedArea_UTK
### 第四阶段:掩码节点迁移
- [ ] MaskAnd_UTK
- [ ] MaskSub_UTK
- [ ] MaskAdd_UTK
### 第五阶段:音频节点迁移
- [ ] LoadAudioPlusFromPath_UTK
- [ ] AudioCropProcessUTK
### 第六阶段:清理和优化
- [ ] 删除原有大型文件
- [ ] 更新文档
- [ ] 测试所有节点功能
- [ ] 优化导入结构
## 节点文件模板
每个节点文件应遵循以下模板:
```python
"""
节点名称
~~~~~~~~
节点功能描述
:copyright: (c) 2024 by May
:license: MIT, see LICENSE for more details.
"""
import torch
# 其他必要的导入
from ..common_utils import log, tensor2pil, pil2tensor # 使用相对导入
class NodeName_UTK:
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
# 输入参数定义
},
"optional": {
# 可选参数定义
}
}
RETURN_TYPES = ("TYPE1", "TYPE2")
RETURN_NAMES = ("name1", "name2")
FUNCTION = "function_name"
def function_name(self, param1, param2, ...):
# 节点实现逻辑
return (output1, output2)
# Node mappings
NODE_CLASS_MAPPINGS = {
"NodeName_UTK": NodeName_UTK,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"NodeName_UTK": "Node Display Name",
}
```
## 导入结构
主 `__init__.py` 文件应使用以下导入结构:
```python
# 导入新的模块化节点
from .nodes.image.node_file import NODE_CLASS_MAPPINGS as NODE_MAPPINGS
from .nodes.image.node_file import NODE_DISPLAY_NAME_MAPPINGS as NODE_DISPLAY_MAPPINGS
# 合并所有映射
NODE_CLASS_MAPPINGS = {}
NODE_CLASS_MAPPINGS.update(NODE_MAPPINGS)
# ... 其他映射
NODE_DISPLAY_NAME_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS.update(NODE_DISPLAY_MAPPINGS)
# ... 其他显示名称映射
```
## 优势
1. **模块化**:每个节点独立文件,便于维护
2. **可扩展**:新增节点只需创建新文件
3. **代码复用**:共用函数避免重复代码
4. **清晰结构**:按功能分类,易于理解
5. **冲突减少**:独立文件减少合并冲突
## 注意事项
1. **相对导入**:使用 `from ..common_utils import` 进行相对导入
2. **节点映射**:每个文件都要包含 `NODE_CLASS_MAPPINGS` 和 `NODE_DISPLAY_NAME_MAPPINGS`
3. **文档注释**:每个文件都要有清晰的文档字符串
4. **功能测试**:迁移后要测试节点功能是否正常
5. **渐进迁移**:逐步迁移,保持项目可用性
## 完成状态
- [x] 基础架构搭建
- [x] 共用工具函数创建
- [x] 部分图像节点迁移
- [ ] 完整节点迁移
- [ ] 测试和优化
- [ ] 文档更新
+259 -49
View File
@@ -8,13 +8,59 @@ A comprehensive toolkit for ComfyUI that provides various utility nodes for imag
:license: MIT, see LICENSE for more details.
"""
__version__ = "1.0.8"
__version__ = "1.1.2"
__author__ = "CyberDickLang"
__email__ = "286878701@qq.com"
__url__ = "https://github.com/whmc76"
# 更新日志
CHANGELOG = {
"1.1.2": [
"修复 DepthMapBlur_UTK 节点 kernel size 类型和 OpenCV 奇数断言问题,保证所有模糊核为正奇数,完全兼容 ComfyUI 规范。",
"修正 EmptyUnitGenerator_UTK 输出 shape,所有节点输入输出严格遵循 ComfyUI 官方规范。",
"完善节点导入路径为相对导入,兼容 ComfyUI v3 插件机制。"
],
"1.1.1": [
"项目重构:",
"- 将大型节点文件按功能拆分成多个独立模块",
"- 创建共用工具函数文件 common_utils.py",
"- 按功能分类:image/、tools/、mask/、audio/",
"- 提高代码可维护性和模块化程度",
"- 参考 ComfyUI-LayerStyle 项目的模块化组织方式",
"新增多个图像处理节点:",
"- ImageScaleByAspectRatio_UTK: 按比例缩放图像",
"- ImageMaskScaleAs_UTK: 按参考图像缩放",
"- ImageScaleRestore_UTK: 恢复图像尺寸",
"- ImageRemoveAlpha_UTK: 移除Alpha通道",
"- ImageCombineAlpha_UTK: 合并Alpha通道",
"- CheckMask_UTK: 检查掩码有效性",
"- PurgeVRAM_UTK: 清理显存",
"- CropByMask_UTK: 基于掩码裁剪",
"- RestoreCropBox_UTK: 恢复裁剪框",
],
"1.1.0": [
"新增 CropByMaskV3_UTK 节点(掩码裁剪节点):",
"- 基于 ComfyUI-LayerStyle 项目集成",
"- 支持三种检测模式:mask_area、min_bounding_rect、max_inscribed_rect",
"- 支持掩码反转功能",
"- 支持四个方向的预留边距设置",
"- 支持整除倍数调整",
"- 提供裁剪框预览功能",
"新增 RestoreCropBox_UTK 节点(恢复裁剪框节点):",
"- 基于 ComfyUI-LayerStyle 项目集成",
"- 支持将裁剪后的图像恢复到原始背景",
"- 支持掩码反转功能",
"- 支持自定义裁剪框位置",
"- 自动处理批量图像和掩码",
],
"1.0.9": [
"新增 PurgeVRAM_UTK 节点(显存清理节点):",
"- 基于 ComfyUI-LayerStyle 项目集成",
"- 支持清理GPU缓存和模型内存",
"- 支持选择性清理(缓存/模型)",
"- 添加内存清理辅助函数",
"- 支持任意类型输入,保持数据流连续性",
],
"1.0.8": [
"新增 ImitationHueNode_UTK 节点(追色节点):",
"- 基于 ComfyUI-MingNodes 项目集成",
@@ -84,54 +130,206 @@ CHANGELOG = {
]
}
from .nodes.image_nodes_utk import EmptyUnitGenerator_UTK, ImageRatioDetector_UTK, DepthMapBlur_UTK, ImageConcatenate_UTK, ImageConcatenateMulti_UTK, ImagePadForOutpaintMasked_UTK, ImageAndMaskPreview_UTK, ImitationHueNode_UTK
from .nodes.tool_nodes_utk import ShowInt_UTK, ShowFloat_UTK, ShowList_UTK, ShowText_UTK, PreviewMask_UTK, FillMaskedArea_UTK
from .nodes.audio_nodes_utk import LoadAudioPlusFromPath_UTK, AudioCropProcessUTK
from .nodes.mask_nodes_utk import MaskAnd_UTK, MaskSub_UTK, MaskAdd_UTK
# 导入节点模块
try:
# 工具类节点
from .nodes.tools.show_nodes import NODE_CLASS_MAPPINGS as SHOW_NODES_MAPPINGS
from .nodes.tools.show_nodes import NODE_DISPLAY_NAME_MAPPINGS as SHOW_NODES_DISPLAY_MAPPINGS
# 音频节点
from .nodes.audio.audio_crop_process import NODE_CLASS_MAPPINGS as AUDIO_CROP_MAPPINGS
from .nodes.audio.audio_crop_process import NODE_DISPLAY_NAME_MAPPINGS as AUDIO_CROP_DISPLAY_MAPPINGS
# 掩码节点
from .nodes.mask.mask_operations import NODE_CLASS_MAPPINGS as MASK_OPERATIONS_MAPPINGS
from .nodes.mask.mask_operations import NODE_DISPLAY_NAME_MAPPINGS as MASK_OPERATIONS_DISPLAY_MAPPINGS
# 图像节点
from .nodes.image.image_concatenate_multi import NODE_CLASS_MAPPINGS as CONCATENATE_MULTI_MAPPINGS
from .nodes.image.image_concatenate_multi import NODE_DISPLAY_NAME_MAPPINGS as CONCATENATE_MULTI_DISPLAY_MAPPINGS
from .nodes.image.image_pad_for_outpaint_masked import NODE_CLASS_MAPPINGS as PAD_OUTPAINT_MAPPINGS
from .nodes.image.image_pad_for_outpaint_masked import NODE_DISPLAY_NAME_MAPPINGS as PAD_OUTPAINT_DISPLAY_MAPPINGS
from .nodes.image.image_and_mask_preview import NODE_CLASS_MAPPINGS as AND_MASK_PREVIEW_MAPPINGS
from .nodes.image.image_and_mask_preview import NODE_DISPLAY_NAME_MAPPINGS as AND_MASK_PREVIEW_DISPLAY_MAPPINGS
from .nodes.image.imitation_hue_node import NODE_CLASS_MAPPINGS as IMITATION_HUE_MAPPINGS
from .nodes.image.imitation_hue_node import NODE_DISPLAY_NAME_MAPPINGS as IMITATION_HUE_DISPLAY_MAPPINGS
NODE_CLASS_MAPPINGS = {
"EmptyUnitGenerator_UTK": EmptyUnitGenerator_UTK,
"ImageRatioDetector_UTK": ImageRatioDetector_UTK,
"ShowInt_UTK": ShowInt_UTK,
"ShowFloat_UTK": ShowFloat_UTK,
"ShowList_UTK": ShowList_UTK,
"ShowText_UTK": ShowText_UTK,
"PreviewMask_UTK": PreviewMask_UTK,
"FillMaskedArea_UTK": FillMaskedArea_UTK,
"ImageAndMaskPreview_UTK": ImageAndMaskPreview_UTK,
"DepthMapBlur_UTK": DepthMapBlur_UTK,
"ImageConcatenate_UTK": ImageConcatenate_UTK,
"ImageConcatenateMulti_UTK": ImageConcatenateMulti_UTK,
"ImagePadForOutpaintMasked_UTK": ImagePadForOutpaintMasked_UTK,
"LoadAudioPlusFromPath_UTK": LoadAudioPlusFromPath_UTK,
"AudioCropProcessUTK": AudioCropProcessUTK,
"MaskAnd_UTK": MaskAnd_UTK,
"MaskSub_UTK": MaskSub_UTK,
"MaskAdd_UTK": MaskAdd_UTK,
"ImitationHueNode_UTK": ImitationHueNode_UTK,
}
except ImportError as e:
print(f"导入错误: {e}")
# 如果模块化导入失败,使用空字典
SHOW_NODES_MAPPINGS = {}
SHOW_NODES_DISPLAY_MAPPINGS = {}
AUDIO_CROP_MAPPINGS = {}
AUDIO_CROP_DISPLAY_MAPPINGS = {}
MASK_OPERATIONS_MAPPINGS = {}
MASK_OPERATIONS_DISPLAY_MAPPINGS = {}
CONCATENATE_MULTI_MAPPINGS = {}
CONCATENATE_MULTI_DISPLAY_MAPPINGS = {}
PAD_OUTPAINT_MAPPINGS = {}
PAD_OUTPAINT_DISPLAY_MAPPINGS = {}
AND_MASK_PREVIEW_MAPPINGS = {}
AND_MASK_PREVIEW_DISPLAY_MAPPINGS = {}
IMITATION_HUE_MAPPINGS = {}
IMITATION_HUE_DISPLAY_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmptyUnitGenerator_UTK": "Empty Unit Generator",
"ImageRatioDetector_UTK": "Image Ratio Detector",
"ShowInt_UTK": "Show Int (UTK)",
"ShowFloat_UTK": "Show Float (UTK)",
"ShowList_UTK": "Show List (UTK)",
"ShowText_UTK": "Show Text (UTK)",
"PreviewMask_UTK": "Preview Mask (UTK)",
"FillMaskedArea_UTK": "Fill Masked Area (UTK)",
"ImageAndMaskPreview_UTK": "Image And Mask Preview (UTK)",
"DepthMapBlur_UTK": "Depth Map Blur",
"ImageConcatenate_UTK": "Image Concatenate",
"ImageConcatenateMulti_UTK": "Image Concatenate Multi",
"ImagePadForOutpaintMasked_UTK": "Image Pad For Outpaint Masked (UTK)",
"LoadAudioPlusFromPath_UTK": "Load Audio Plus From Path (UTK)",
"AudioCropProcessUTK": "Audio Crop Process (UTK)",
"MaskAnd_UTK": "Mask And (UTK)",
"MaskSub_UTK": "Mask Sub (UTK)",
"MaskAdd_UTK": "Mask Add (UTK)",
"ImitationHueNode_UTK": "Imitation Hue Node (UTK)",
}
# 尝试导入其他可能有依赖的节点
try:
from .nodes.tools.fill_masked_area import NODE_CLASS_MAPPINGS as FILL_MASKED_MAPPINGS
from .nodes.tools.fill_masked_area import NODE_DISPLAY_NAME_MAPPINGS as FILL_MASKED_DISPLAY_MAPPINGS
except ImportError:
FILL_MASKED_MAPPINGS = {}
FILL_MASKED_DISPLAY_MAPPINGS = {}
try:
from .nodes.audio.load_audio import NODE_CLASS_MAPPINGS as LOAD_AUDIO_MAPPINGS
from .nodes.audio.load_audio import NODE_DISPLAY_NAME_MAPPINGS as LOAD_AUDIO_DISPLAY_MAPPINGS
except ImportError:
LOAD_AUDIO_MAPPINGS = {}
LOAD_AUDIO_DISPLAY_MAPPINGS = {}
try:
from .nodes.image.empty_unit_generator import NODE_CLASS_MAPPINGS as IMAGE_GENERATOR_MAPPINGS
from .nodes.image.empty_unit_generator import NODE_DISPLAY_NAME_MAPPINGS as IMAGE_GENERATOR_DISPLAY_MAPPINGS
except ImportError:
IMAGE_GENERATOR_MAPPINGS = {}
IMAGE_GENERATOR_DISPLAY_MAPPINGS = {}
try:
from .nodes.image.image_ratio_detector import NODE_CLASS_MAPPINGS as IMAGE_DETECTOR_MAPPINGS
from .nodes.image.image_ratio_detector import NODE_DISPLAY_NAME_MAPPINGS as IMAGE_DETECTOR_DISPLAY_MAPPINGS
except ImportError:
IMAGE_DETECTOR_MAPPINGS = {}
IMAGE_DETECTOR_DISPLAY_MAPPINGS = {}
try:
from .nodes.image.depth_map_blur import NODE_CLASS_MAPPINGS as DEPTH_BLUR_MAPPINGS
from .nodes.image.depth_map_blur import NODE_DISPLAY_NAME_MAPPINGS as DEPTH_BLUR_DISPLAY_MAPPINGS
except ImportError:
DEPTH_BLUR_MAPPINGS = {}
DEPTH_BLUR_DISPLAY_MAPPINGS = {}
try:
from .nodes.image.image_concatenate import NODE_CLASS_MAPPINGS as CONCATENATE_MAPPINGS
from .nodes.image.image_concatenate import NODE_DISPLAY_NAME_MAPPINGS as CONCATENATE_DISPLAY_MAPPINGS
except ImportError:
CONCATENATE_MAPPINGS = {}
CONCATENATE_DISPLAY_MAPPINGS = {}
try:
from .nodes.image.image_scale_by_aspect_ratio import NODE_CLASS_MAPPINGS as SCALE_ASPECT_MAPPINGS
from .nodes.image.image_scale_by_aspect_ratio import NODE_DISPLAY_NAME_MAPPINGS as SCALE_ASPECT_DISPLAY_MAPPINGS
except ImportError:
SCALE_ASPECT_MAPPINGS = {}
SCALE_ASPECT_DISPLAY_MAPPINGS = {}
try:
from .nodes.image.image_mask_scale_as import NODE_CLASS_MAPPINGS as MASK_SCALE_MAPPINGS
from .nodes.image.image_mask_scale_as import NODE_DISPLAY_NAME_MAPPINGS as MASK_SCALE_DISPLAY_MAPPINGS
except ImportError:
MASK_SCALE_MAPPINGS = {}
MASK_SCALE_DISPLAY_MAPPINGS = {}
try:
from .nodes.image.image_scale_restore import NODE_CLASS_MAPPINGS as SCALE_RESTORE_MAPPINGS
from .nodes.image.image_scale_restore import NODE_DISPLAY_NAME_MAPPINGS as SCALE_RESTORE_DISPLAY_MAPPINGS
except ImportError:
SCALE_RESTORE_MAPPINGS = {}
SCALE_RESTORE_DISPLAY_MAPPINGS = {}
try:
from .nodes.image.image_remove_alpha import NODE_CLASS_MAPPINGS as REMOVE_ALPHA_MAPPINGS
from .nodes.image.image_remove_alpha import NODE_DISPLAY_NAME_MAPPINGS as REMOVE_ALPHA_DISPLAY_MAPPINGS
except ImportError:
REMOVE_ALPHA_MAPPINGS = {}
REMOVE_ALPHA_DISPLAY_MAPPINGS = {}
try:
from .nodes.image.image_combine_alpha import NODE_CLASS_MAPPINGS as COMBINE_ALPHA_MAPPINGS
from .nodes.image.image_combine_alpha import NODE_DISPLAY_NAME_MAPPINGS as COMBINE_ALPHA_DISPLAY_MAPPINGS
except ImportError:
COMBINE_ALPHA_MAPPINGS = {}
COMBINE_ALPHA_DISPLAY_MAPPINGS = {}
try:
from .nodes.image.check_mask import NODE_CLASS_MAPPINGS as CHECK_MASK_MAPPINGS
from .nodes.image.check_mask import NODE_DISPLAY_NAME_MAPPINGS as CHECK_MASK_DISPLAY_MAPPINGS
except ImportError:
CHECK_MASK_MAPPINGS = {}
CHECK_MASK_DISPLAY_MAPPINGS = {}
try:
from .nodes.image.purge_vram import NODE_CLASS_MAPPINGS as PURGE_VRAM_MAPPINGS
from .nodes.image.purge_vram import NODE_DISPLAY_NAME_MAPPINGS as PURGE_VRAM_DISPLAY_MAPPINGS
except ImportError:
PURGE_VRAM_MAPPINGS = {}
PURGE_VRAM_DISPLAY_MAPPINGS = {}
try:
from .nodes.image.crop_by_mask import NODE_CLASS_MAPPINGS as CROP_MASK_MAPPINGS
from .nodes.image.crop_by_mask import NODE_DISPLAY_NAME_MAPPINGS as CROP_MASK_DISPLAY_MAPPINGS
except ImportError:
CROP_MASK_MAPPINGS = {}
CROP_MASK_DISPLAY_MAPPINGS = {}
try:
from .nodes.image.restore_crop_box import NODE_CLASS_MAPPINGS as RESTORE_CROP_MAPPINGS
from .nodes.image.restore_crop_box import NODE_DISPLAY_NAME_MAPPINGS as RESTORE_CROP_DISPLAY_MAPPINGS
except ImportError:
RESTORE_CROP_MAPPINGS = {}
RESTORE_CROP_DISPLAY_MAPPINGS = {}
# 合并所有节点映射
NODE_CLASS_MAPPINGS = {}
NODE_CLASS_MAPPINGS.update(IMAGE_GENERATOR_MAPPINGS)
NODE_CLASS_MAPPINGS.update(IMAGE_DETECTOR_MAPPINGS)
NODE_CLASS_MAPPINGS.update(DEPTH_BLUR_MAPPINGS)
NODE_CLASS_MAPPINGS.update(CONCATENATE_MAPPINGS)
NODE_CLASS_MAPPINGS.update(CONCATENATE_MULTI_MAPPINGS)
NODE_CLASS_MAPPINGS.update(PAD_OUTPAINT_MAPPINGS)
NODE_CLASS_MAPPINGS.update(AND_MASK_PREVIEW_MAPPINGS)
NODE_CLASS_MAPPINGS.update(IMITATION_HUE_MAPPINGS)
NODE_CLASS_MAPPINGS.update(SCALE_ASPECT_MAPPINGS)
NODE_CLASS_MAPPINGS.update(MASK_SCALE_MAPPINGS)
NODE_CLASS_MAPPINGS.update(SCALE_RESTORE_MAPPINGS)
NODE_CLASS_MAPPINGS.update(REMOVE_ALPHA_MAPPINGS)
NODE_CLASS_MAPPINGS.update(COMBINE_ALPHA_MAPPINGS)
NODE_CLASS_MAPPINGS.update(CHECK_MASK_MAPPINGS)
NODE_CLASS_MAPPINGS.update(PURGE_VRAM_MAPPINGS)
NODE_CLASS_MAPPINGS.update(CROP_MASK_MAPPINGS)
NODE_CLASS_MAPPINGS.update(RESTORE_CROP_MAPPINGS)
NODE_CLASS_MAPPINGS.update(SHOW_NODES_MAPPINGS)
NODE_CLASS_MAPPINGS.update(FILL_MASKED_MAPPINGS)
NODE_CLASS_MAPPINGS.update(LOAD_AUDIO_MAPPINGS)
NODE_CLASS_MAPPINGS.update(AUDIO_CROP_MAPPINGS)
NODE_CLASS_MAPPINGS.update(MASK_OPERATIONS_MAPPINGS)
# 合并显示名称映射
NODE_DISPLAY_NAME_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS.update(IMAGE_GENERATOR_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(IMAGE_DETECTOR_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(DEPTH_BLUR_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(CONCATENATE_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(CONCATENATE_MULTI_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(PAD_OUTPAINT_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(AND_MASK_PREVIEW_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(IMITATION_HUE_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(SCALE_ASPECT_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(MASK_SCALE_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(SCALE_RESTORE_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(REMOVE_ALPHA_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(COMBINE_ALPHA_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(CHECK_MASK_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(PURGE_VRAM_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(CROP_MASK_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(RESTORE_CROP_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(SHOW_NODES_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(FILL_MASKED_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(LOAD_AUDIO_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(AUDIO_CROP_DISPLAY_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(MASK_OPERATIONS_DISPLAY_MAPPINGS)
NODE_CATEGORIES = {
"UniversalToolkit": [
@@ -149,6 +347,18 @@ NODE_CATEGORIES = {
"MaskSub_UTK",
"MaskAdd_UTK",
"ImitationHueNode_UTK",
"ImageScaleByAspectRatio_UTK",
"ImageMaskScaleAs_UTK",
"ImageScaleRestore_UTK",
"ImageRemoveAlpha_UTK",
"ImageCombineAlpha_UTK",
"CheckMask_UTK",
"PurgeVRAM_UTK",
"CropByMask_UTK",
"RestoreCropBox_UTK",
"Show_UTK",
"ShowFloat_UTK",
"PreviewMask_UTK",
]
}
@@ -160,5 +370,5 @@ __all__ = [
"__author__",
"__email__",
"__url__",
"CHANGELOG"
]
"CHANGELOG",
]
+77
View File
@@ -0,0 +1,77 @@
"""
Audio Crop Process Node
~~~~~~~~~~~~~~~~~~~~~~
Processes audio with cropping, resampling, and gain adjustments.
:copyright: (c) 2024 by May
:license: MIT, see LICENSE for more details.
"""
import torch
FLOAT_MAX = 99999999999999999.0
class AudioCropProcessUTK:
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"audio": ("AUDIO",),
"gain_db": ("FLOAT", {"default": 0, "min": -100, "max": 100}),
"offset_seconds": ("FLOAT", {"default": 0, "min": 0, "max": FLOAT_MAX}),
"duration_seconds": ("FLOAT", {"default": 0, "min": 0, "max": FLOAT_MAX}),
"resample_to_hz": ("FLOAT", {"default": 0, "min": 0, "max": FLOAT_MAX}),
"make_stereo": ("BOOLEAN", {"default": True}),
}
}
RETURN_TYPES = ("AUDIO", "INT", "INT", "FLOAT")
RETURN_NAMES = ("audio", "sample_rate", "channels", "duration")
FUNCTION = "execute"
@classmethod
def IS_CHANGED(cls, *args):
return args
def execute(
self,
audio,
gain_db: float,
offset_seconds: float,
duration_seconds: float,
resample_to_hz: float,
make_stereo: bool,
):
waveform = audio["waveform"] # [B, C, N]
sample_rate = int(audio["sample_rate"])
# 裁剪offset和duration
start = int(offset_seconds * sample_rate)
end = int(start + duration_seconds * sample_rate) if duration_seconds > 0 else waveform.shape[2]
waveform = waveform[:, :, start:end]
# 重采样
if resample_to_hz > 0 and int(resample_to_hz) != sample_rate:
import torchaudio
waveform = torchaudio.functional.resample(waveform, sample_rate, int(resample_to_hz))
sample_rate = int(resample_to_hz)
# 增益
if gain_db != 0.0:
gain_scalar = 10 ** (gain_db / 20)
waveform = waveform * gain_scalar
# 强制立体声
if make_stereo and waveform.shape[1] == 1:
waveform = torch.cat([waveform, waveform], dim=1)
elif make_stereo and waveform.shape[1] != 2:
raise ValueError(f"Input audio has {waveform.shape[1]} channels, cannot convert to stereo (2 channels)")
channels = int(waveform.shape[1])
duration_val = float(waveform.shape[2] / sample_rate) if sample_rate > 0 else 0.0
return ({"sample_rate": sample_rate, "waveform": waveform}, sample_rate, channels, duration_val)
# Node mappings
NODE_CLASS_MAPPINGS = {
"AudioCropProcessUTK": AudioCropProcessUTK,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"AudioCropProcessUTK": "Audio Crop Process (UTK)",
}
@@ -1,3 +1,13 @@
"""
Load Audio Node
~~~~~~~~~~~~~~
Loads audio files from path with various processing options.
:copyright: (c) 2024 by May
:license: MIT, see LICENSE for more details.
"""
import math
import os
from pathlib import Path
@@ -80,57 +90,11 @@ class LoadAudioPlusFromPath_UTK:
duration_val = float(mix.shape[2] / sample_rate) if sample_rate > 0 else 0.0
return ({"sample_rate": sample_rate, "waveform": mix}, sample_rate, channels, duration_val)
class AudioCropProcessUTK:
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"audio": ("AUDIO",),
"gain_db": ("FLOAT", {"default": 0, "min": -100, "max": 100}),
"offset_seconds": ("FLOAT", {"default": 0, "min": 0, "max": FLOAT_MAX}),
"duration_seconds": ("FLOAT", {"default": 0, "min": 0, "max": FLOAT_MAX}),
"resample_to_hz": ("FLOAT", {"default": 0, "min": 0, "max": FLOAT_MAX}),
"make_stereo": ("BOOLEAN", {"default": True}),
}
}
RETURN_TYPES = ("AUDIO", "INT", "INT", "FLOAT")
RETURN_NAMES = ("audio", "sample_rate", "channels", "duration")
FUNCTION = "execute"
# Node mappings
NODE_CLASS_MAPPINGS = {
"LoadAudioPlusFromPath_UTK": LoadAudioPlusFromPath_UTK,
}
@classmethod
def IS_CHANGED(cls, *args):
return args
def execute(
self,
audio,
gain_db: float,
offset_seconds: float,
duration_seconds: float,
resample_to_hz: float,
make_stereo: bool,
):
waveform = audio["waveform"] # [B, C, N]
sample_rate = int(audio["sample_rate"])
# 裁剪offset和duration
start = int(offset_seconds * sample_rate)
end = int(start + duration_seconds * sample_rate) if duration_seconds > 0 else waveform.shape[2]
waveform = waveform[:, :, start:end]
# 重采样
if resample_to_hz > 0 and int(resample_to_hz) != sample_rate:
import torchaudio
waveform = torchaudio.functional.resample(waveform, sample_rate, int(resample_to_hz))
sample_rate = int(resample_to_hz)
# 增益
if gain_db != 0.0:
gain_scalar = 10 ** (gain_db / 20)
waveform = waveform * gain_scalar
# 强制立体声
if make_stereo and waveform.shape[1] == 1:
waveform = torch.cat([waveform, waveform], dim=1)
elif make_stereo and waveform.shape[1] != 2:
raise ValueError(f"Input audio has {waveform.shape[1]} channels, cannot convert to stereo (2 channels)")
channels = int(waveform.shape[1])
duration_val = float(waveform.shape[2] / sample_rate) if sample_rate > 0 else 0.0
return ({"sample_rate": sample_rate, "waveform": waveform}, sample_rate, channels, duration_val)
NODE_DISPLAY_NAME_MAPPINGS = {
"LoadAudioPlusFromPath_UTK": "Load Audio Plus From Path (UTK)",
}
+294
View File
@@ -0,0 +1,294 @@
"""
ComfyUI Universal Toolkit - Common Utilities
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Common utility functions and constants used across all nodes.
:copyright: (c) 2024 by May
:license: MIT, see LICENSE for more details.
"""
import torch
import torch.nn.functional as F
import numpy as np
import re
import math
import random
import os
import json
import cv2
from comfy.utils import ProgressBar, common_upscale
from PIL import Image
from PIL.PngImagePlugin import PngInfo
# Import ComfyUI modules with fallbacks
MAX_RESOLUTION = 8192
SaveImage = None
ImageCompositeMasked = None
args = None
folder_paths = None
try:
from nodes import MAX_RESOLUTION, SaveImage
except ImportError:
pass
try:
from comfy_extras.nodes_mask import ImageCompositeMasked
except ImportError:
pass
try:
from comfy.cli_args import args
except ImportError:
pass
try:
import folder_paths
except ImportError:
pass
def log(message, message_type='info'):
"""简单的日志函数"""
if message_type == 'error':
print(f"❌ Error: {message}")
elif message_type == 'warning':
print(f"⚠️ Warning: {message}")
elif message_type == 'finish':
print(f"✅ {message}")
else:
print(f"ℹ️ {message}")
def tensor2pil(image):
"""将torch张量转换为PIL图像"""
if image.dim() == 4:
image = image.squeeze(0)
if image.dim() == 3:
if image.shape[0] == 1: # 灰度图
image = image.squeeze(0)
image = (image * 255).clamp(0, 255).to(torch.uint8)
return Image.fromarray(image.cpu().numpy(), mode='L')
else: # RGB图
image = image.permute(1, 2, 0)
image = (image * 255).clamp(0, 255).to(torch.uint8)
return Image.fromarray(image.cpu().numpy(), mode='RGB')
return None
def pil2tensor(image):
"""将PIL图像转换为torch张量"""
if image.mode == 'L':
image = image.convert('RGB')
image = np.array(image).astype(np.float32) / 255.0
image = torch.from_numpy(image)
if image.dim() == 3:
image = image.permute(2, 0, 1)
return image
def image2mask(image):
"""将PIL图像转换为掩码张量"""
if image.mode == 'L':
return torch.tensor([pil2tensor(image)[0, :, :].tolist()])
else:
image = image.convert('RGB').split()[0]
return torch.tensor([pil2tensor(image)[0, :, :].tolist()])
def tensor2cv2(image: torch.Tensor) -> np.array:
"""将torch张量转换为OpenCV格式"""
if image.dim() == 4:
image = image.squeeze()
npimage = image.numpy()
cv2image = np.uint8(npimage * 255 / npimage.max())
return cv2.cvtColor(cv2image, cv2.COLOR_RGB2BGR)
def pil2cv2(pil_img):
"""将PIL图像转换为OpenCV格式"""
np_img_array = np.asarray(pil_img)
return cv2.cvtColor(np_img_array, cv2.COLOR_RGB2BGR)
def cv22pil(cv2_img):
"""将OpenCV图像转换为PIL图像"""
cv2_img = cv2.cvtColor(cv2_img, cv2.COLOR_BGR2RGB)
return Image.fromarray(cv2_img)
def tensor2np(tensor):
"""将torch张量转换为numpy数组"""
if len(tensor.shape) == 3: # Single image
return np.clip(255.0 * tensor.cpu().numpy(), 0, 255).astype(np.uint8)
else: # Batch of images
return [np.clip(255.0 * t.cpu().numpy(), 0, 255).astype(np.uint8) for t in tensor]
# Color transfer functions for ImitationHueNode
def image_stats(image):
return np.mean(image[:, :, 1:], axis=(0, 1)), np.std(image[:, :, 1:], axis=(0, 1))
def is_skin_or_lips(lab_image):
l, a, b = lab_image[:, :, 0], lab_image[:, :, 1], lab_image[:, :, 2]
skin = (l > 20) & (l < 250) & (a > 120) & (a < 180) & (b > 120) & (b < 190)
lips = (l > 20) & (l < 200) & (a > 150) & (b > 140)
return (skin | lips).astype(np.float32)
def adjust_brightness(image, factor, mask=None):
hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
v = hsv[:, :, 2].astype(np.float32)
if mask is not None:
mask = mask.squeeze()
v = np.where(mask > 0, np.clip(v * factor, 0, 255), v)
else:
v = np.clip(v * factor, 0, 255)
hsv[:, :, 2] = v.astype(np.uint8)
return cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)
def adjust_saturation(image, factor, mask=None):
hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
s = hsv[:, :, 1].astype(np.float32)
if mask is not None:
mask = mask.squeeze()
s = np.where(mask > 0, np.clip(s * factor, 0, 255), s)
else:
s = np.clip(s * factor, 0, 255)
hsv[:, :, 1] = s.astype(np.uint8)
return cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)
def adjust_contrast(image, factor, mask=None):
mean = np.mean(image)
adjusted = image.astype(np.float32)
if mask is not None:
mask = mask.squeeze()
mask = np.repeat(mask[:, :, np.newaxis], 3, axis=2)
adjusted = np.where(mask > 0, np.clip((adjusted - mean) * factor + mean, 0, 255), adjusted)
else:
adjusted = np.clip((adjusted - mean) * factor + mean, 0, 255)
return adjusted.astype(np.uint8)
def adjust_tone(source, target, tone_strength=0.7, mask=None):
h, w = target.shape[:2]
source = cv2.resize(source, (w, h))
lab_image = cv2.cvtColor(target, cv2.COLOR_BGR2LAB).astype(np.float32)
lab_source = cv2.cvtColor(source, cv2.COLOR_BGR2LAB).astype(np.float32)
l_image = lab_image[:,:,0]
l_source = lab_source[:,:,0]
if mask is not None:
mask = cv2.resize(mask, (w, h))
mask = mask.astype(np.float32) / 255.0
l_adjusted = np.copy(l_image)
mean_source = np.mean(l_source[mask > 0])
std_source = np.std(l_source[mask > 0])
mean_target = np.mean(l_image[mask > 0])
std_target = np.std(l_image[mask > 0])
l_adjusted[mask > 0] = (l_image[mask > 0] - mean_target) * (std_source / (std_target + 1e-6)) * 0.7 + mean_source
l_adjusted[mask > 0] = np.clip(l_adjusted[mask > 0], 0, 255)
clahe = cv2.createCLAHE(clipLimit=2.5, tileGridSize=(8,8))
l_enhanced = clahe.apply(l_adjusted.astype(np.uint8))
l_final = cv2.addWeighted(l_adjusted, 0.7, l_enhanced.astype(np.float32), 0.3, 0)
l_final = np.clip(l_final, 0, 255)
l_contrast = cv2.addWeighted(l_final, 1.3, l_final, 0, -20)
l_contrast = np.clip(l_contrast, 0, 255)
l_image[mask > 0] = l_image[mask > 0] * (1 - tone_strength) + l_contrast[mask > 0] * tone_strength
else:
mean_source = np.mean(l_source)
std_source = np.std(l_source)
l_mean = np.mean(l_image)
l_std = np.std(l_image)
l_adjusted = (l_image - l_mean) * (std_source / (l_std + 1e-6)) * 0.7 + mean_source
l_adjusted = np.clip(l_adjusted, 0, 255)
clahe = cv2.createCLAHE(clipLimit=2.5, tileGridSize=(8,8))
l_enhanced = clahe.apply(l_adjusted.astype(np.uint8))
l_final = cv2.addWeighted(l_adjusted, 0.7, l_enhanced.astype(np.float32), 0.3, 0)
l_final = np.clip(l_final, 0, 255)
l_contrast = cv2.addWeighted(l_final, 1.3, l_final, 0, -20)
l_contrast = np.clip(l_contrast, 0, 255)
l_image = l_image * (1 - tone_strength) + l_contrast * tone_strength
lab_image[:,:,0] = l_image
return cv2.cvtColor(lab_image.astype(np.uint8), cv2.COLOR_LAB2BGR)
def color_transfer(source, target, mask=None, strength=1.0, skin_protection=0.2, auto_brightness=True,
brightness_range=0.5, auto_contrast=False, contrast_range=0.5,
auto_saturation=False, saturation_range=0.5, auto_tone=False, tone_strength=0.7):
source_lab = cv2.cvtColor(source, cv2.COLOR_BGR2LAB).astype(np.float32)
target_lab = cv2.cvtColor(target, cv2.COLOR_BGR2LAB).astype(np.float32)
src_means, src_stds = image_stats(source_lab)
tar_means, tar_stds = image_stats(target_lab)
skin_lips_mask = is_skin_or_lips(target_lab.astype(np.uint8))
skin_lips_mask = cv2.GaussianBlur(skin_lips_mask, (5, 5), 0)
if mask is not None:
mask = cv2.resize(mask, (target.shape[1], target.shape[0]))
mask = mask.astype(np.float32) / 255.0
result_lab = target_lab.copy()
for i in range(1, 3):
adjusted_channel = (target_lab[:, :, i] - tar_means[i - 1]) * (src_stds[i - 1] / (tar_stds[i - 1] + 1e-6)) + \
src_means[i - 1]
adjusted_channel = np.clip(adjusted_channel, 0, 255)
if mask is not None:
result_lab[:, :, i] = target_lab[:, :, i] * (1 - mask) + \
(target_lab[:, :, i] * skin_lips_mask * skin_protection + \
adjusted_channel * skin_lips_mask * (1 - skin_protection) + \
adjusted_channel * (1 - skin_lips_mask)) * mask
else:
result_lab[:, :, i] = target_lab[:, :, i] * skin_lips_mask * skin_protection + \
adjusted_channel * skin_lips_mask * (1 - skin_protection) + \
adjusted_channel * (1 - skin_lips_mask)
result_bgr = cv2.cvtColor(result_lab.astype(np.uint8), cv2.COLOR_LAB2BGR)
final_result = cv2.addWeighted(target, 1 - strength, result_bgr, strength, 0)
if mask is not None:
mask = cv2.resize(mask, (target.shape[1], target.shape[0]))
mask = mask.astype(np.float32) / 255.0
if auto_brightness:
source_brightness = np.mean(cv2.cvtColor(source, cv2.COLOR_BGR2GRAY))
target_brightness = np.mean(cv2.cvtColor(target, cv2.COLOR_BGR2GRAY))
brightness_difference = source_brightness - target_brightness
brightness_factor = 1.0 + np.clip(brightness_difference / 255 * brightness_range, brightness_range*-1, brightness_range)
final_result = adjust_brightness(final_result, brightness_factor, mask)
if auto_contrast:
source_gray = cv2.cvtColor(source, cv2.COLOR_BGR2GRAY)
target_gray = cv2.cvtColor(target, cv2.COLOR_BGR2GRAY)
source_contrast = np.std(source_gray)
target_contrast = np.std(target_gray)
contrast_difference = source_contrast - target_contrast
contrast_factor = 1.0 + np.clip(contrast_difference / 255, contrast_range*-1, contrast_range)
final_result = adjust_contrast(final_result, contrast_factor, mask)
if auto_saturation:
source_hsv = cv2.cvtColor(source, cv2.COLOR_BGR2HSV)
target_hsv = cv2.cvtColor(target, cv2.COLOR_BGR2HSV)
source_saturation = np.mean(source_hsv[:, :, 1])
target_saturation = np.mean(target_hsv[:, :, 1])
saturation_difference = source_saturation - target_saturation
saturation_factor = 1.0 + np.clip(saturation_difference / 255, saturation_range*-1, saturation_range)
final_result = adjust_saturation(final_result, saturation_factor, mask)
if auto_tone:
final_result = adjust_tone(source, final_result, tone_strength, mask)
else:
if auto_brightness:
source_brightness = np.mean(cv2.cvtColor(source, cv2.COLOR_BGR2GRAY))
target_brightness = np.mean(cv2.cvtColor(target, cv2.COLOR_BGR2GRAY))
brightness_difference = source_brightness - target_brightness
brightness_factor = 1.0 + np.clip(brightness_difference / 255 * brightness_range, brightness_range*-1, brightness_range)
final_result = adjust_brightness(final_result, brightness_factor)
if auto_contrast:
source_gray = cv2.cvtColor(source, cv2.COLOR_BGR2GRAY)
target_gray = cv2.cvtColor(target, cv2.COLOR_BGR2GRAY)
source_contrast = np.std(source_gray)
target_contrast = np.std(target_gray)
contrast_difference = source_contrast - target_contrast
contrast_factor = 1.0 + np.clip(contrast_difference / 255, contrast_range*-1, contrast_range)
final_result = adjust_contrast(final_result, contrast_factor)
if auto_saturation:
source_hsv = cv2.cvtColor(source, cv2.COLOR_BGR2HSV)
target_hsv = cv2.cvtColor(target, cv2.COLOR_BGR2HSV)
source_saturation = np.mean(source_hsv[:, :, 1])
target_saturation = np.mean(target_hsv[:, :, 1])
saturation_difference = source_saturation - target_saturation
saturation_factor = 1.0 + np.clip(saturation_difference / 255, saturation_range*-1, saturation_range)
final_result = adjust_saturation(final_result, saturation_factor)
if auto_tone:
final_result = adjust_tone(source, final_result, tone_strength)
return final_result
+9
View File
@@ -0,0 +1,9 @@
"""
ComfyUI Universal Toolkit - Image Nodes
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Image processing nodes for ComfyUI Universal Toolkit.
:copyright: (c) 2024 by May
:license: MIT, see LICENSE for more details.
"""
+62
View File
@@ -0,0 +1,62 @@
"""
Check Mask Node
~~~~~~~~~~~~~~
Checks if a mask is valid based on white area percentage.
:copyright: (c) 2024 by May
:license: MIT, see LICENSE for more details.
"""
import torch
from PIL import Image
import numpy as np
from ...common_utils import log, tensor2pil, pil2tensor
def mask_white_area(mask, white_point):
"""Calculate the percentage of white area in mask"""
mask_array = np.array(mask)
white_pixels = np.sum(mask_array > white_point)
total_pixels = mask_array.size
return white_pixels / total_pixels if total_pixels > 0 else 0
class CheckMask_UTK:
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"mask": ("MASK",), #
"white_point": ("INT", {"default": 1, "min": 1, "max": 254, "step": 1}), # 用于判断mask是否有效的白点值,高于此值被计入有效
"area_percent": ("INT", {"default": 1, "min": 1, "max": 99, "step": 1}), # 区域百分比,低于此则mask判定无效
},
"optional": { #
}
}
RETURN_TYPES = ("BOOLEAN",)
RETURN_NAMES = ('bool',)
FUNCTION = 'check_mask'
def check_mask(self, mask, white_point, area_percent,):
if mask.dim() == 2:
mask = torch.unsqueeze(mask, 0)
mask = tensor2pil(mask[0])
if mask.width * mask.height > 262144:
target_width = 512
target_height = int(target_width * mask.height / mask.width)
mask = mask.resize((target_width, target_height), Image.LANCZOS)
ret = mask_white_area(mask, white_point) * 100 > area_percent
log(f"CheckMask_UTK:{ret}", message_type="finish")
return (ret,)
# Node mappings
NODE_CLASS_MAPPINGS = {
"CheckMask_UTK": CheckMask_UTK,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"CheckMask_UTK": "Check Mask (UTK)",
}
+149
View File
@@ -0,0 +1,149 @@
"""
Crop By Mask Node
~~~~~~~~~~~~~~~~
Crops images based on mask detection with various detection modes.
:copyright: (c) 2024 by May
:license: MIT, see LICENSE for more details.
"""
import torch
from PIL import Image, ImageDraw
import numpy as np
from ...common_utils import log, tensor2pil, pil2tensor, image2mask
def mask2image(mask):
"""Convert mask tensor to PIL image"""
return tensor2pil(mask).convert('L')
def gaussian_blur(image, radius):
"""Apply Gaussian blur to image"""
return image.filter(ImageFilter.GaussianBlur(radius=radius))
def min_bounding_rect(mask):
"""Find minimum bounding rectangle of mask"""
mask_array = np.array(mask)
coords = np.where(mask_array > 0)
if len(coords[0]) == 0:
return (0, 0, mask.width, mask.height)
y_min, y_max = coords[0].min(), coords[0].max()
x_min, x_max = coords[1].min(), coords[1].max()
return (x_min, y_min, x_max - x_min, y_max - y_min)
def max_inscribed_rect(mask):
"""Find maximum inscribed rectangle of mask"""
# Simplified implementation - returns bounding rect
return min_bounding_rect(mask)
def mask_area(mask):
"""Find area of mask"""
# Simplified implementation - returns bounding rect
return min_bounding_rect(mask)
def num_round_up_to_multiple(num, multiple):
"""Round up to the nearest multiple"""
return ((num + multiple - 1) // multiple) * multiple
def draw_rect(image, x, y, width, height, line_color="#FF0000", line_width=2):
"""Draw rectangle on image"""
draw = ImageDraw.Draw(image)
draw.rectangle([x, y, x + width, y + height], outline=line_color, width=line_width)
return image
class CropByMask_UTK:
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
detect_mode = ['min_bounding_rect', 'max_inscribed_rect', 'mask_area']
return {
"required": {
"image": ("IMAGE", ), #
"mask_for_crop": ("MASK",),
"invert_mask": ("BOOLEAN", {"default": False}), # 反转mask#
"detect": (detect_mode,),
"top_reserve": ("INT", {"default": 20, "min": -9999, "max": 9999, "step": 1}),
"bottom_reserve": ("INT", {"default": 20, "min": -9999, "max": 9999, "step": 1}),
"left_reserve": ("INT", {"default": 20, "min": -9999, "max": 9999, "step": 1}),
"right_reserve": ("INT", {"default": 20, "min": -9999, "max": 9999, "step": 1}),
},
"optional": {
}
}
RETURN_TYPES = ("IMAGE", "MASK", "BOX", "IMAGE",)
RETURN_NAMES = ("croped_image", "croped_mask", "crop_box", "box_preview")
FUNCTION = 'crop_by_mask'
def crop_by_mask(self, image, mask_for_crop, invert_mask, detect,
top_reserve, bottom_reserve, left_reserve, right_reserve
):
ret_images = []
ret_masks = []
l_images = []
l_masks = []
for l in image:
l_images.append(torch.unsqueeze(l, 0))
if mask_for_crop.dim() == 2:
mask_for_crop = torch.unsqueeze(mask_for_crop, 0)
# 如果有多张mask输入,使用第一张
if mask_for_crop.shape[0] > 1:
log(f"Warning: Multiple mask inputs, using the first.", message_type='warning')
mask_for_crop = torch.unsqueeze(mask_for_crop[0], 0)
if invert_mask:
mask_for_crop = 1 - mask_for_crop
l_masks.append(tensor2pil(torch.unsqueeze(mask_for_crop, 0)).convert('L'))
_mask = mask2image(mask_for_crop)
try:
from PIL import ImageFilter
bluredmask = gaussian_blur(_mask, 20).convert('L')
except ImportError:
bluredmask = _mask.convert('L')
x = 0
y = 0
width = 0
height = 0
if detect == "min_bounding_rect":
(x, y, width, height) = min_bounding_rect(bluredmask)
elif detect == "max_inscribed_rect":
(x, y, width, height) = max_inscribed_rect(bluredmask)
else:
(x, y, width, height) = mask_area(_mask)
width = num_round_up_to_multiple(width, 8)
height = num_round_up_to_multiple(height, 8)
log(f"CropByMask_UTK: Box detected. x={x},y={y},width={width},height={height}")
canvas_width, canvas_height = tensor2pil(torch.unsqueeze(image[0], 0)).convert('RGB').size
x1 = x - left_reserve if x - left_reserve > 0 else 0
y1 = y - top_reserve if y - top_reserve > 0 else 0
x2 = x + width + right_reserve if x + width + right_reserve < canvas_width else canvas_width
y2 = y + height + bottom_reserve if y + height + bottom_reserve < canvas_height else canvas_height
preview_image = tensor2pil(mask_for_crop).convert('RGB')
preview_image = draw_rect(preview_image, x, y, width, height, line_color="#F00000", line_width=(width+height)//100)
preview_image = draw_rect(preview_image, x1, y1, x2 - x1, y2 - y1,
line_color="#00F000", line_width=(width+height)//200)
crop_box = (x1, y1, x2, y2)
for i in range(len(l_images)):
_canvas = tensor2pil(l_images[i]).convert('RGB')
_mask = l_masks[0]
ret_images.append(pil2tensor(_canvas.crop(crop_box)))
ret_masks.append(image2mask(_mask.crop(crop_box)))
log(f"CropByMask_UTK Processed {len(ret_images)} image(s).", message_type='finish')
return (torch.cat(ret_images, dim=0), torch.cat(ret_masks, dim=0), list(crop_box), pil2tensor(preview_image),)
# Node mappings
NODE_CLASS_MAPPINGS = {
"CropByMask_UTK": CropByMask_UTK,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"CropByMask_UTK": "Crop By Mask (UTK)",
}
+153
View File
@@ -0,0 +1,153 @@
"""
Depth Map Blur Node
~~~~~~~~~~~~~~~~~~
Applies depth-based blur effects to images using depth maps.
:copyright: (c) 2024 by May
:license: MIT, see LICENSE for more details.
"""
import os
import cv2
import torch
import numpy as np
import folder_paths
class DepthMapBlur_UTK:
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
"depth_map": ("IMAGE",),
"blur_strength": ("FLOAT", {
"default": 64.0,
"min": 0.0,
"max": 256.0,
"step": 1.0
}),
"focal_depth": ("FLOAT", {
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.01
}),
"focus_spread": ("FLOAT", {
"default": 1,
"min": 1.0,
"max": 8.0,
"step": 0.1
}),
"steps": ("INT", {
"default": 5,
"min": 1,
"max": 32,
}),
"focal_range": ("FLOAT", {
"default": 0.0,
"min": 0.0,
"max": 1.0,
"step": 0.01
}),
"mask_blur": ("INT", {
"default": 1,
"min": 1,
"max": 127,
"step": 2
}),
},
}
RETURN_TYPES = ("IMAGE","MASK")
RETURN_NAMES = ()
FUNCTION = "depthblur_image"
DESCRIPTION = """
blur_strength: Represents the blur strength. This parameter controls the overall intensity of the blur effect; the higher the value, the more blurred the image becomes.
focal_depth: Represents the focal depth. This parameter is used to determine which depth level in the image should remain sharp, while other levels are blurred based on depth differences.
focus_spread: Represents the focus spread range. This parameter controls the size of the blur transition area near the focal depth; the larger the value, the wider the transition area, and the smoother the blur effect spreads around the focus.
steps: Represents the number of steps in the blur process. This parameter determines the calculation precision of the blur effect; the more steps, the finer the blur effect, but this also increases the computational load.
focal_range: Represents the focal range. This parameter is used to adjust the depth range within the focal depth that remains sharp; the larger the value, the wider the area around the focal depth that remains sharp.
mask_blur: Represents the mask blur strength for blurring the depth map. This parameter controls the intensity of the depth map's blur treatment, used for preprocessing the depth map before calculating the final blur effect, to achieve a more natural blur transition.
"""
CATEGORY = "UniversalToolkit"
def depthblur_image(self, image: torch.Tensor, depth_map: torch.Tensor, blur_strength: float, focal_depth: float, focus_spread:float, steps: int, focal_range: float, mask_blur: int):
batch_size, height, width, _ = image.shape
image_result = torch.zeros_like(image)
mask_result = torch.zeros((batch_size, height, width), dtype=torch.float32)
for b in range(batch_size):
tensor_image = image[b].numpy()
tensor_image_depth = depth_map[b].numpy()
# Apply blur
blur_image,depth_mask = self.apply_depthblur(tensor_image, tensor_image_depth, blur_strength, focal_depth, focus_spread, steps, focal_range, mask_blur)
tensor_image = torch.from_numpy(blur_image).unsqueeze(0)
tensor_mask = torch.from_numpy(depth_mask).unsqueeze(0)
image_result[b] = tensor_image
mask_result[b] = tensor_mask
return (image_result,mask_result)
def apply_depthblur(self, image, depth_map, blur_strength, focal_depth, focus_spread, steps, focal_range, mask_blur):
def make_odd(x):
x = int(round(x))
return x if x % 2 == 1 else x + 1
# Normalize the input image if needed
needs_normalization = image.max() > 1
if needs_normalization:
image = image.astype(np.float32) / 255
# Normalize the depth map if needed
depth_map = depth_map.astype(np.float32) / 255 if depth_map.max() > 1 else depth_map
# Resize depth map to match the image dimensions
depth_map_resized = cv2.resize(depth_map, (image.shape[1], image.shape[0]), interpolation=cv2.INTER_LINEAR)
if len(depth_map_resized.shape) > 2:
depth_map_resized = cv2.cvtColor(depth_map_resized, cv2.COLOR_BGR2GRAY)
# Adjust the depth map based on the focal plane
depth_mask = np.abs(depth_map_resized - focal_depth)
depth_mask = np.clip(depth_mask / np.max(depth_mask), 0, 1)
# Process the depth_mask
depth_mask[depth_mask < focal_range] = 0
depth_mask[depth_mask >= focal_range] = (depth_mask[depth_mask >= focal_range] - focal_range) / (1 - focal_range)
# Apply mask blur
mask_blur = max(1, make_odd(mask_blur))
depth_mask = cv2.GaussianBlur(depth_mask, (mask_blur, mask_blur), 0)
# Generate blurred versions of the image
blur_ksize = max(1, make_odd(blur_strength))
blurred_images = [cv2.GaussianBlur(image, (blur_ksize, blur_ksize), sigmaX=0) for _ in range(steps)]
# Use the adjusted depth map as a mask for applying blurred images
# 这里简单实现:直接用最重的模糊图和原图按mask混合
final_image = image * (1 - depth_mask[..., None]) + blurred_images[-1] * depth_mask[..., None]
# Convert back to original range if the image was normalized
if needs_normalization:
final_image = np.clip(final_image * 255, 0, 255).astype(np.uint8)
return final_image, depth_mask
# Node mappings
NODE_CLASS_MAPPINGS = {
"DepthMapBlur_UTK": DepthMapBlur_UTK,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"DepthMapBlur_UTK": "Depth Map Blur (UTK)",
}
+112
View File
@@ -0,0 +1,112 @@
"""
Empty Unit Generator Node
~~~~~~~~~~~~~~~~~~~~~~~~~
Generates empty images, masks, and latents with various preset ratios and configurations.
:copyright: (c) 2024 by May
:license: MIT, see LICENSE for more details.
"""
import torch
import numpy as np
import re
from PIL import Image
from ..common_utils import log
class EmptyUnitGenerator_UTK:
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
ratio_options = [
"custom",
"SD1.5 - 1:1 square 512x512",
"SD1.5 - 2:3 portrait 512x768",
"SD1.5 - 3:4 portrait 512x682",
"SD1.5 - 3:2 landscape 768x512",
"SD1.5 - 4:3 landscape 682x512",
"SD1.5 - 16:9 cinema 910x512",
"SD1.5 - 1.85:1 cinema 952x512",
"SD1.5 - 2:1 cinema 1024x512",
"SDXL - 1:1 square 1024x1024",
"SDXL - 3:4 portrait 896x1152",
"SDXL - 5:8 portrait 832x1216",
"SDXL - 9:16 portrait 768x1344",
"SDXL - 9:21 portrait 640x1536",
"SDXL - 4:3 landscape 1152x896",
"SDXL - 3:2 landscape 1216x832",
"SDXL - 16:9 landscape 1344x768",
"SDXL - 21:9 landscape 1536x640",
]
latent_type_options = ["standard", "sd3", "hunyuan", "ltx"]
return {
"required": {
"width": ("INT", {"default": 1024, "min": 64, "max": 4096, "step": 8, "label": "Width (custom only)"}),
"height": ("INT", {"default": 1024, "min": 64, "max": 4096, "step": 8, "label": "Height (custom only)"}),
"ratio": (ratio_options, {"default": ratio_options[9], "label": "Resolution/Ratio"}),
"scale": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 8.0, "step": 0.1, "label": "Scale (放大倍数)"}),
"divisor": ("INT", {"default": 8, "min": 1, "max": 512, "step": 1, "label": "Divisor (整除裁切)"}),
"image_color": (["white", "black", "gray", "red", "green", "blue"], {"default": "white"}),
"batch": ("INT", {"default": 1, "min": 1, "max": 16, "label": "Batch 数量"}),
"latent_type": (latent_type_options, {"default": "standard", "label": "Latent类型"}),
},
"optional": {},
}
RETURN_TYPES = ("IMAGE", "MASK", "LATENT", "INT", "INT")
RETURN_NAMES = ("image", "mask", "latent", "width", "height")
FUNCTION = "generate"
def generate(self, width, height, ratio, scale, divisor, image_color, batch, latent_type):
if ratio == "custom":
w = width
h = height
else:
m = re.search(r"(\d+)x(\d+)", ratio)
if m:
w, h = int(m.group(1)), int(m.group(2))
else:
w, h = 1024, 1024
w = max(1, int(round(w * scale)))
h = max(1, int(round(h * scale)))
if divisor > 1:
w = (w // divisor) * divisor
h = (h // divisor) * divisor
COLOR_OPTIONS = {
"white": (255, 255, 255),
"black": (0, 0, 0),
"gray": (128, 128, 128),
"red": (255, 0, 0),
"green": (0, 255, 0),
"blue": (0, 0, 255),
}
color_rgb = COLOR_OPTIONS[image_color]
images = []
for _ in range(batch):
img = torch.from_numpy(np.array(Image.new("RGB", (w, h), color_rgb))).float() / 255.0
img = img.permute(2, 0, 1)
images.append(img)
images = torch.stack(images, dim=0).permute(0, 2, 3, 1)
mask_value = color_rgb[0] / 255.0
masks = torch.ones([batch, 1, h, w], dtype=torch.float32) * mask_value
latent_channels = {
"standard": 4,
"sd3": 8,
"hunyuan": 8,
"ltx": 16,
}.get(latent_type, 4)
latent = {
"samples": torch.zeros([batch, latent_channels, h // 8, w // 8], dtype=torch.float32),
"batch_index_list": None
}
return images, masks, latent, w, h
# Node mappings
NODE_CLASS_MAPPINGS = {
"EmptyUnitGenerator_UTK": EmptyUnitGenerator_UTK,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EmptyUnitGenerator_UTK": "Empty Unit Generator",
}
+82
View File
@@ -0,0 +1,82 @@
"""
Image And Mask Preview Node
~~~~~~~~~~~~~~~~~~~~~~~~~~
Preview an image or a mask, when both inputs are used composites the mask on top of the image.
:copyright: (c) 2024 by May
:license: MIT, see LICENSE for more details.
"""
import random
from nodes import SaveImage, ImageCompositeMasked
import folder_paths
class ImageAndMaskPreview_UTK(SaveImage):
def __init__(self):
self.output_dir = folder_paths.get_temp_directory()
self.type = "temp"
self.prefix_append = "_temp_" + ''.join(random.choice("abcdefghijklmnopqrstupvxyz") for x in range(5))
self.compress_level = 4
@classmethod
def INPUT_TYPES(s):
colors = ["red", "green", "blue", "yellow", "cyan", "magenta", "white", "black"]
return {
"required": {
"mask_opacity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"mask_color": (colors, {"default": "red"}),
"pass_through": ("BOOLEAN", {"default": False}),
},
"optional": {
"image": ("IMAGE",),
"mask": ("MASK",),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("composite",)
FUNCTION = "execute"
CATEGORY = "UniversalToolkit"
DESCRIPTION = """
Preview an image or a mask, when both inputs are used
composites the mask on top of the image.
with pass_through on the preview is disabled and the
composite is returned from the composite slot instead,
this allows for the preview to be passed for video combine
nodes for example.
"""
def execute(self, mask_opacity, mask_color, pass_through, filename_prefix="ComfyUI", image=None, mask=None, prompt=None, extra_pnginfo=None):
if mask is not None and image is None:
preview = mask.reshape((-1, 1, mask.shape[-2], mask.shape[-1])).movedim(1, -1).expand(-1, -1, -1, 3)
elif mask is None and image is not None:
preview = image
elif mask is not None and image is not None:
mask_adjusted = mask * mask_opacity
mask_image = mask.reshape((-1, 1, mask.shape[-2], mask.shape[-1])).movedim(1, -1).expand(-1, -1, -1, 3).clone()
color_map = {
"red": [255, 0, 0], "green": [0, 255, 0], "blue": [0, 0, 255],
"yellow": [255, 255, 0], "cyan": [0, 255, 255], "magenta": [255, 0, 255],
"white": [255, 255, 255], "black": [0, 0, 0]
}
color_list = color_map.get(mask_color, [255, 0, 0])
mask_image[:, :, :, 0] = color_list[0] / 255 # Red channel
mask_image[:, :, :, 1] = color_list[1] / 255 # Green channel
mask_image[:, :, :, 2] = color_list[2] / 255 # Blue channel
preview, = ImageCompositeMasked.composite(self, image, mask_image, 0, 0, True, mask_adjusted)
if pass_through:
return (preview, )
return(self.save_images(preview, filename_prefix, prompt, extra_pnginfo))
# Node mappings
NODE_CLASS_MAPPINGS = {
"ImageAndMaskPreview_UTK": ImageAndMaskPreview_UTK,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ImageAndMaskPreview_UTK": "Image And Mask Preview (UTK)",
}
+83
View File
@@ -0,0 +1,83 @@
"""
Image Combine Alpha Node
~~~~~~~~~~~~~~~~~~~~~~~
Combines RGB image with mask to create RGBA image.
:copyright: (c) 2024 by May
:license: MIT, see LICENSE for more details.
"""
import torch
from PIL import Image
from ...common_utils import log, tensor2pil, pil2tensor
def image_channel_split(image, mode):
"""Split image into channels"""
if mode == 'RGB':
return image.split()
elif mode == 'RGBA':
return image.split()
else:
return image.split()
def image_channel_merge(channels, mode):
"""Merge channels into image"""
if mode == 'RGBA':
return Image.merge('RGBA', channels)
elif mode == 'RGB':
return Image.merge('RGB', channels)
else:
return Image.merge(mode, channels)
class ImageCombineAlpha_UTK:
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"RGB_image": ("IMAGE", ), #
"mask": ("MASK",), #
},
"optional": {
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("RGBA_image",)
FUNCTION = 'image_combine_alpha'
def image_combine_alpha(self, RGB_image, mask):
ret_images = []
input_images = []
input_masks = []
for i in RGB_image:
input_images.append(torch.unsqueeze(i, 0))
if mask.dim() == 2:
mask = torch.unsqueeze(mask, 0)
for m in mask:
input_masks.append(torch.unsqueeze(m, 0))
max_batch = max(len(input_images), len(input_masks))
for i in range(max_batch):
_image = input_images[i] if i < len(input_images) else input_images[-1]
_mask = input_masks[i] if i < len(input_masks) else input_masks[-1]
r, g, b, _ = image_channel_split(tensor2pil(_image).convert('RGB'), 'RGB')
ret_image = image_channel_merge((r, g, b, tensor2pil(_mask).convert('L')), 'RGBA')
ret_images.append(pil2tensor(ret_image))
log(f"ImageCombineAlpha_UTK Processed {len(ret_images)} image(s).", message_type='finish')
return (torch.cat(ret_images, dim=0),)
# Node mappings
NODE_CLASS_MAPPINGS = {
"ImageCombineAlpha_UTK": ImageCombineAlpha_UTK,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ImageCombineAlpha_UTK": "Image Combine Alpha (UTK)",
}
+205
View File
@@ -0,0 +1,205 @@
"""
Image Concatenate Node
~~~~~~~~~~~~~~~~~~~~~
Concatenates two images side by side or vertically with various options.
:copyright: (c) 2024 by May
:license: MIT, see LICENSE for more details.
"""
import torch
import torch.nn.functional as F
from ..common_utils import log
class ImageConcatenate_UTK:
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image1": ("IMAGE",),
"image2": ("IMAGE",),
"direction": (
[ 'right',
'down',
'left',
'up',
'auto',
],
{
"default": 'auto'
}),
"match_image_size": ("BOOLEAN", {"default": True}),
"max_size": ("INT", {"default": 4096, "min": 64, "max": 8192, "step": 64}),
"background_color": (["black", "white", "gray", "transparent"], {"default": "black"}),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "concatenate"
def concatenate(self, image1, image2, direction, match_image_size, max_size, background_color):
# Check if the batch sizes are different
batch_size1 = image1.shape[0]
batch_size2 = image2.shape[0]
# 保证batch维度一致,补齐到最大batch
if batch_size1 != batch_size2:
max_batch_size = max(batch_size1, batch_size2)
if batch_size1 < max_batch_size:
last_image1 = image1[-1].unsqueeze(0).repeat(max_batch_size - batch_size1, 1, 1, 1)
image1 = torch.cat([image1, last_image1], dim=0)
if batch_size2 < max_batch_size:
last_image2 = image2[-1].unsqueeze(0).repeat(max_batch_size - batch_size2, 1, 1, 1)
image2 = torch.cat([image2, last_image2], dim=0)
# Get original dimensions
h1, w1 = image1.shape[1:3]
h2, w2 = image2.shape[1:3]
# If direction is auto, determine the best direction based on image dimensions
if direction == 'auto':
horizontal_ratio = (w1 + w2) / max(h1, h2)
vertical_ratio = max(w1, w2) / (h1 + h2)
direction = 'right' if abs(horizontal_ratio - 1) <= abs(vertical_ratio - 1) else 'down'
# Match image sizes if requested
if match_image_size:
if direction in ['right', 'left', 'auto']:
target_height = max(h1, h2)
if h1 < target_height:
scale = target_height / h1
new_width = int(w1 * scale)
image1 = torch.nn.functional.interpolate(
image1.permute(0, 3, 1, 2),
size=(target_height, new_width),
mode='bilinear',
align_corners=False
).permute(0, 2, 3, 1)
if h2 < target_height:
scale = target_height / h2
new_width = int(w2 * scale)
image2 = torch.nn.functional.interpolate(
image2.permute(0, 3, 1, 2),
size=(target_height, new_width),
mode='bilinear',
align_corners=False
).permute(0, 2, 3, 1)
else: # up, down
target_width = max(w1, w2)
if w1 < target_width:
scale = target_width / w1
new_height = int(h1 * scale)
image1 = torch.nn.functional.interpolate(
image1.permute(0, 3, 1, 2),
size=(new_height, target_width),
mode='bilinear',
align_corners=False
).permute(0, 2, 3, 1)
if w2 < target_width:
scale = target_width / w2
new_height = int(h2 * scale)
image2 = torch.nn.functional.interpolate(
image2.permute(0, 3, 1, 2),
size=(new_height, target_width),
mode='bilinear',
align_corners=False
).permute(0, 2, 3, 1)
# Update dimensions after scaling
h1, w1 = image1.shape[1:3]
h2, w2 = image2.shape[1:3]
# Calculate final dimensions
if direction in ['right', 'left']:
final_height = max(h1, h2)
final_width = w1 + w2
else: # up, down
final_height = h1 + h2
final_width = max(w1, w2)
# Check if we need to scale down
if max(final_height, final_width) > max_size:
scale = max_size / max(final_height, final_width)
new_h1 = int(h1 * scale)
new_w1 = int(w1 * scale)
new_h2 = int(h2 * scale)
new_w2 = int(w2 * scale)
image1 = torch.nn.functional.interpolate(
image1.permute(0, 3, 1, 2),
size=(new_h1, new_w1),
mode='bilinear',
align_corners=False
).permute(0, 2, 3, 1)
image2 = torch.nn.functional.interpolate(
image2.permute(0, 3, 1, 2),
size=(new_h2, new_w2),
mode='bilinear',
align_corners=False
).permute(0, 2, 3, 1)
h1, w1 = image1.shape[1:3]
h2, w2 = image2.shape[1:3]
if direction in ['right', 'left']:
final_height = max(h1, h2)
final_width = w1 + w2
else:
final_height = h1 + h2
final_width = max(w1, w2)
# Ensure both images have the same number of channels
channels_image1 = image1.shape[-1]
channels_image2 = image2.shape[-1]
if channels_image1 != channels_image2:
if channels_image1 < channels_image2:
alpha_channel = torch.ones((*image1.shape[:-1], channels_image2 - channels_image1), device=image1.device)
image1 = torch.cat((image1, alpha_channel), dim=-1)
else:
alpha_channel = torch.ones((*image2.shape[:-1], channels_image1 - channels_image2), device=image2.device)
image2 = torch.cat((image2, alpha_channel), dim=-1)
# 创建输出张量,batch维度与输入一致
batch_size = image1.shape[0]
if background_color == "transparent":
output = torch.zeros((batch_size, final_height, final_width, image1.shape[-1]), dtype=image1.dtype, device=image1.device)
else:
color_value = 1.0 if background_color == "white" else 0.0 if background_color == "black" else 0.5
output = torch.full((batch_size, final_height, final_width, image1.shape[-1]), color_value, dtype=image1.dtype, device=image1.device)
# 计算放置位置
if direction == 'right':
x1 = 0
x2 = w1
y1 = (final_height - h1) // 2
y2 = (final_height - h2) // 2
elif direction == 'left':
x1 = w2
x2 = 0
y1 = (final_height - h1) // 2
y2 = (final_height - h2) // 2
elif direction == 'down':
x1 = (final_width - w1) // 2
x2 = (final_width - w2) // 2
y1 = 0
y2 = h1
else: # up
x1 = (final_width - w1) // 2
x2 = (final_width - w2) // 2
y1 = h2
y2 = 0
# 批量放置图片
output[:, y1:y1+h1, x1:x1+w1] = image1
output[:, y2:y2+h2, x2:x2+w2] = image2
return (output,)
# Node mappings
NODE_CLASS_MAPPINGS = {
"ImageConcatenate_UTK": ImageConcatenate_UTK,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ImageConcatenate_UTK": "Image Concatenate",
}
+199
View File
@@ -0,0 +1,199 @@
"""
Image Concatenate Multi Node
~~~~~~~~~~~~~~~~~~~~~~~~~~~
Concatenates multiple images in various directions and layouts.
:copyright: (c) 2024 by May
:license: MIT, see LICENSE for more details.
"""
import torch
class ImageConcatenateMulti_UTK:
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"direction": (
[ 'right',
'down',
'left',
'up',
'auto',
],
{
"default": 'auto'
}),
"match_image_size": ("BOOLEAN", {"default": True}),
"max_size": ("INT", {"default": 4096, "min": 64, "max": 8192, "step": 64}),
"background_color": (["black", "white", "gray", "transparent"], {"default": "black"}),
"grid_size": (["auto", "1x1", "2x2", "3x3", "4x4"], {"default": "auto"}),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "concatenate_multi"
def concatenate_multi(self, images, direction, match_image_size, max_size, background_color, grid_size):
if len(images.shape) != 4:
raise ValueError("输入必须是4D张量 [batch, height, width, channels]")
batch_size = images.shape[0]
# 处理网格布局
if grid_size != "auto":
rows, cols = map(int, grid_size.split("x"))
if batch_size > rows * cols:
raise ValueError(f"图像数量 ({batch_size}) 超过网格大小 ({grid_size})")
# 填充到网格大小
if batch_size < rows * cols:
padding = torch.zeros((rows * cols - batch_size, *images.shape[1:]), dtype=images.dtype, device=images.device)
images = torch.cat([images, padding], dim=0)
batch_size = rows * cols
else:
# 自动计算网格大小
if direction in ['right', 'left', 'auto']:
rows = 1
cols = batch_size
else: # up, down
rows = batch_size
cols = 1
# 获取所有图像的尺寸
heights = []
widths = []
for i in range(batch_size):
h, w = images[i].shape[:2]
heights.append(h)
widths.append(w)
# 如果方向是auto,计算最佳方向
if direction == 'auto':
# 计算水平和垂直拼接的宽高比
total_width = sum(widths)
max_height = max(heights)
horizontal_ratio = total_width / max_height
total_height = sum(heights)
max_width = max(widths)
vertical_ratio = max_width / total_height
# 选择更接近1:1的方向
direction = 'right' if abs(horizontal_ratio - 1) <= abs(vertical_ratio - 1) else 'down'
# 如果需要匹配图像尺寸
if match_image_size:
if direction in ['right', 'left', 'auto']:
# 匹配高度
target_height = max(heights)
for i in range(batch_size):
if heights[i] < target_height:
scale = target_height / heights[i]
new_width = int(widths[i] * scale)
images[i] = torch.nn.functional.interpolate(
images[i].unsqueeze(0).permute(0, 3, 1, 2),
size=(target_height, new_width),
mode='bilinear',
align_corners=False
).permute(0, 2, 3, 1).squeeze(0)
else: # up, down
# 匹配宽度
target_width = max(widths)
for i in range(batch_size):
if widths[i] < target_width:
scale = target_width / widths[i]
new_height = int(heights[i] * scale)
images[i] = torch.nn.functional.interpolate(
images[i].unsqueeze(0).permute(0, 3, 1, 2),
size=(new_height, target_width),
mode='bilinear',
align_corners=False
).permute(0, 2, 3, 1).squeeze(0)
# 更新尺寸
heights = []
widths = []
for i in range(batch_size):
h, w = images[i].shape[:2]
heights.append(h)
widths.append(w)
# 计算最终输出尺寸
if direction in ['right', 'left', 'auto']:
final_height = max(heights)
final_width = sum(widths)
else: # up, down
final_height = sum(heights)
final_width = max(widths)
# 检查是否需要缩放
if max(final_height, final_width) > max_size:
scale = max_size / max(final_height, final_width)
for i in range(batch_size):
new_height = int(heights[i] * scale)
new_width = int(widths[i] * scale)
images[i] = torch.nn.functional.interpolate(
images[i].unsqueeze(0).permute(0, 3, 1, 2),
size=(new_height, new_width),
mode='bilinear',
align_corners=False
).permute(0, 2, 3, 1).squeeze(0)
# 更新最终尺寸
heights = []
widths = []
for i in range(batch_size):
h, w = images[i].shape[:2]
heights.append(h)
widths.append(w)
if direction in ['right', 'left', 'auto']:
final_height = max(heights)
final_width = sum(widths)
else: # up, down
final_height = sum(heights)
final_width = max(widths)
# 创建输出张量
if background_color == "transparent":
output = torch.zeros((1, final_height, final_width, images.shape[-1]), dtype=images.dtype, device=images.device)
else:
color_value = 1.0 if background_color == "white" else 0.0 if background_color == "black" else 0.5
output = torch.full((1, final_height, final_width, images.shape[-1]), color_value, dtype=images.dtype, device=images.device)
# 放置图像
if direction in ['right', 'left', 'auto']:
x_offset = 0
for i in range(batch_size):
h, w = images[i].shape[:2]
y_offset = (final_height - h) // 2
if direction == 'left':
x_offset = final_width - sum(widths[i:])
output[0, y_offset:y_offset+h, x_offset:x_offset+w] = images[i]
if direction != 'left':
x_offset += w
else: # up, down
y_offset = 0
for i in range(batch_size):
h, w = images[i].shape[:2]
x_offset = (final_width - w) // 2
if direction == 'up':
y_offset = final_height - sum(heights[i:])
output[0, y_offset:y_offset+h, x_offset:x_offset+w] = images[i]
if direction != 'up':
y_offset += h
return (output,)
# Node mappings
NODE_CLASS_MAPPINGS = {
"ImageConcatenateMulti_UTK": ImageConcatenateMulti_UTK,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ImageConcatenateMulti_UTK": "Image Concatenate Multi (UTK)",
}
+141
View File
@@ -0,0 +1,141 @@
"""
Image Mask Scale As Node
~~~~~~~~~~~~~~~~~~~~~~~
Scales images and masks to match the dimensions of a reference image.
:copyright: (c) 2024 by May
:license: MIT, see LICENSE for more details.
"""
import torch
from PIL import Image
from ...common_utils import log, tensor2pil, pil2tensor, image2mask
def fit_resize_image(image, target_width, target_height, fit_mode, resize_sampler, background_color="#000000"):
"""Resize image according to fit mode"""
if fit_mode == 'letterbox':
# Calculate scaling factor to fit within target dimensions
scale = min(target_width / image.width, target_height / image.height)
new_width = int(image.width * scale)
new_height = int(image.height * scale)
# Resize image
resized = image.resize((new_width, new_height), resize_sampler)
# Create new image with target dimensions and paste resized image
if image.mode == 'RGB':
result = Image.new('RGB', (target_width, target_height), background_color)
else:
result = Image.new('L', (target_width, target_height), 0)
paste_x = (target_width - new_width) // 2
paste_y = (target_height - new_height) // 2
result.paste(resized, (paste_x, paste_y))
return result
elif fit_mode == 'crop':
# Calculate scaling factor to cover target dimensions
scale = max(target_width / image.width, target_height / image.height)
new_width = int(image.width * scale)
new_height = int(image.height * scale)
# Resize image
resized = image.resize((new_width, new_height), resize_sampler)
# Crop to target dimensions
crop_x = (new_width - target_width) // 2
crop_y = (new_height - target_height) // 2
return resized.crop((crop_x, crop_y, crop_x + target_width, crop_y + target_height))
else: # fill
# Simple resize to target dimensions
return image.resize((target_width, target_height), resize_sampler)
class ImageMaskScaleAs_UTK:
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
fit_mode = ['letterbox', 'crop', 'fill']
method_mode = ['lanczos', 'bicubic', 'hamming', 'bilinear', 'box', 'nearest']
return {
"required": {
"scale_as": ("IMAGE",),
"fit": (fit_mode,),
"method": (method_mode,),
},
"optional": {
"image": ("IMAGE",), #
"mask": ("MASK",), #
}
}
RETURN_TYPES = ("IMAGE", "MASK", "BOX", "INT", "INT")
RETURN_NAMES = ("image", "mask", "original_size", "width", "height",)
FUNCTION = 'image_mask_scale_as'
def image_mask_scale_as(self, scale_as, fit, method,
image=None, mask = None,
):
if scale_as.shape[0] > 0:
_asimage = tensor2pil(scale_as[0])
else:
_asimage = tensor2pil(scale_as)
target_width, target_height = _asimage.size
_mask = Image.new('L', size=_asimage.size, color='black')
_image = Image.new('RGB', size=_asimage.size, color='black')
orig_width = 4
orig_height = 4
resize_sampler = Image.LANCZOS
if method == "bicubic":
resize_sampler = Image.BICUBIC
elif method == "hamming":
resize_sampler = Image.HAMMING
elif method == "bilinear":
resize_sampler = Image.BILINEAR
elif method == "box":
resize_sampler = Image.BOX
elif method == "nearest":
resize_sampler = Image.NEAREST
ret_images = []
ret_masks = []
if image is not None:
for i in image:
i = torch.unsqueeze(i, 0)
_image = tensor2pil(i).convert('RGB')
orig_width, orig_height = _image.size
_image = fit_resize_image(_image, target_width, target_height, fit, resize_sampler)
ret_images.append(pil2tensor(_image))
if mask is not None:
if mask.dim() == 2:
mask = torch.unsqueeze(mask, 0)
for m in mask:
m = torch.unsqueeze(m, 0)
_mask = tensor2pil(m).convert('L')
orig_width, orig_height = _mask.size
_mask = fit_resize_image(_mask, target_width, target_height, fit, resize_sampler).convert('L')
ret_masks.append(image2mask(_mask))
if len(ret_images) > 0 and len(ret_masks) >0:
log(f"ImageMaskScaleAs_UTK Processed {len(ret_images)} image(s).", message_type='finish')
return (torch.cat(ret_images, dim=0), torch.cat(ret_masks, dim=0), [orig_width, orig_height],target_width, target_height,)
elif len(ret_images) > 0 and len(ret_masks) == 0:
log(f"ImageMaskScaleAs_UTK Processed {len(ret_images)} image(s).", message_type='finish')
return (torch.cat(ret_images, dim=0), None, [orig_width, orig_height],target_width, target_height,)
elif len(ret_images) == 0 and len(ret_masks) > 0:
log(f"ImageMaskScaleAs_UTK Processed {len(ret_masks)} image(s).", message_type='finish')
return (None, torch.cat(ret_masks, dim=0), [orig_width, orig_height], target_width, target_height,)
else:
log(f"Error: ImageMaskScaleAs_UTK skipped, because the available image or mask is not found.", message_type='error')
return (None, None, [orig_width, orig_height], 0, 0,)
# Node mappings
NODE_CLASS_MAPPINGS = {
"ImageMaskScaleAs_UTK": ImageMaskScaleAs_UTK,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ImageMaskScaleAs_UTK": "Image Mask Scale As (UTK)",
}
@@ -0,0 +1,107 @@
"""
Image Pad For Outpaint Masked Node
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expands image with padding and generates corresponding mask for outpainting.
:copyright: (c) 2024 by May
:license: MIT, see LICENSE for more details.
"""
import torch
import torch.nn.functional as F
MAX_RESOLUTION = 8192
class ImagePadForOutpaintMasked_UTK:
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
color_options = ["gray", "white", "black", "red", "green", "blue", "yellow", "cyan", "magenta"]
return {
"required": {
"image": ("IMAGE",),
"data_mode": (["pixel", "percent"], {"default": "pixel"}),
"left": ("INT", {"default": 0, "min": 0, "max": 1000, "step": 1}),
"top": ("INT", {"default": 0, "min": 0, "max": 1000, "step": 1}),
"right": ("INT", {"default": 0, "min": 0, "max": 1000, "step": 1}),
"bottom": ("INT", {"default": 0, "min": 0, "max": 1000, "step": 1}),
"feathering": ("INT", {"default": 0, "min": 0, "max": MAX_RESOLUTION, "step": 1}),
"background_color": (color_options, {"default": "gray"}),
},
"optional": {
"mask": ("MASK",),
}
}
RETURN_TYPES = ("IMAGE", "MASK")
FUNCTION = "expand_image"
def expand_image(self, image, data_mode, left, top, right, bottom, feathering, background_color, mask=None):
B, H, W, C = image.size()
# 处理 pad 参数
if data_mode == "percent":
left = int(W * left / 100)
right = int(W * right / 100)
top = int(H * top / 100)
bottom = int(H * bottom / 100)
# 背景色映射
color_map = {
"gray": [0.5, 0.5, 0.5],
"white": [1.0, 1.0, 1.0],
"black": [0.0, 0.0, 0.0],
"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],
}
bg_rgb = color_map.get(background_color, [0.5, 0.5, 0.5])
# 新图像
new_image = torch.ones((B, H + top + bottom, W + left + right, C), dtype=torch.float32)
for i in range(C):
new_image[:, :, :, i] = bg_rgb[i]
new_image[:, top:top + H, left:left + W, :] = image
# 掩码逻辑与原实现一致
if mask is not None:
if torch.allclose(mask, torch.zeros_like(mask)):
print("Warning: The incoming mask is fully black. Handling it as None.")
mask = None
if mask is None:
new_mask = torch.ones((B, H + top + bottom, W + left + right), dtype=torch.float32)
t = torch.zeros((B, H, W), dtype=torch.float32)
else:
mask = F.pad(mask, (left, right, top, bottom), mode='constant', value=0)
mask = 1 - mask
t = torch.zeros_like(mask)
if feathering > 0 and feathering * 2 < H and feathering * 2 < W:
for i in range(H):
for j in range(W):
dt = i if top != 0 else H
db = H - i if bottom != 0 else H
dl = j if left != 0 else W
dr = W - j if right != 0 else W
d = min(dt, db, dl, dr)
if d >= feathering:
continue
v = (feathering - d) / feathering
if mask is None:
t[:, i, j] = v * v
else:
t[:, top + i, left + j] = v * v
if mask is None:
new_mask[:, top:top + H, left:left + W] = t
return (new_image, new_mask,)
else:
return (new_image, mask,)
# Node mappings
NODE_CLASS_MAPPINGS = {
"ImagePadForOutpaintMasked_UTK": ImagePadForOutpaintMasked_UTK,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ImagePadForOutpaintMasked_UTK": "Image Pad For Outpaint Masked (UTK)",
}
+77
View File
@@ -0,0 +1,77 @@
"""
Image Ratio Detector Node
~~~~~~~~~~~~~~~~~~~~~~~~~
Detects and analyzes image aspect ratios and dimensions.
:copyright: (c) 2024 by May
:license: MIT, see LICENSE for more details.
"""
import math
from ..common_utils import log
class ImageRatioDetector_UTK:
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
return {"required": {"image": ("IMAGE",)}}
RETURN_TYPES = ("STRING", "INT", "INT", "STRING")
RETURN_NAMES = ("ratio_str", "width", "height", "approx_ratio_str")
FUNCTION = "detect"
def detect(self, image):
if hasattr(image, 'dim') and image.dim() == 4:
img = image[0]
else:
img = image
shape = img.shape
if len(shape) == 3:
if shape[0] <= 4:
_, h, w = shape
else:
h, w, _ = shape
elif len(shape) == 2:
h, w = shape
else:
return "?", 0, 0, "N/A"
h = int(h)
w = int(w)
if w == 0 or h == 0:
ratio_str = "0:0"
approx_ratio_str = "N/A"
return ratio_str, w, h, approx_ratio_str
gcd = math.gcd(w, h)
ratio_str = f"{w//gcd}:{h//gcd}"
std_ratios = {
"1:1": 1.0,
"16:9": 16/9,
"4:3": 4/3,
"3:2": 3/2,
"2:3": 2/3,
"3:4": 3/4,
"9:16": 9/16,
"5:4": 5/4,
"7:5": 7/5,
"21:9": 21/9,
"5:3": 5/3,
"3:1": 3/1,
"1:2": 1/2,
"2:1": 2/1,
"1:1.85": 1/1.85,
"1:2.35": 1/2.35,
}
wh_ratio = float(w) / float(h)
approx_ratio_str = min(std_ratios.keys(), key=lambda k: abs(std_ratios[k] - wh_ratio))
return ratio_str, w, h, approx_ratio_str
# Node mappings
NODE_CLASS_MAPPINGS = {
"ImageRatioDetector_UTK": ImageRatioDetector_UTK,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ImageRatioDetector_UTK": "Image Ratio Detector",
}
+69
View File
@@ -0,0 +1,69 @@
"""
Image Remove Alpha Node
~~~~~~~~~~~~~~~~~~~~~~
Removes alpha channel from RGBA images with optional background filling.
:copyright: (c) 2024 by May
:license: MIT, see LICENSE for more details.
"""
import torch
from PIL import Image
from ...common_utils import log, tensor2pil, pil2tensor
class ImageRemoveAlpha_UTK:
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"RGBA_image": ("IMAGE", ), #
"fill_background": ("BOOLEAN", {"default": False}),
"background_color": ("STRING", {"default": "#000000"}),
},
"optional": {
"mask": ("MASK",), #
}
}
RETURN_TYPES = ("IMAGE", )
RETURN_NAMES = ("RGB_image", )
FUNCTION = 'image_remove_alpha'
def image_remove_alpha(self, RGBA_image, fill_background, background_color, mask=None):
ret_images = []
for index, img in enumerate(RGBA_image):
_image = tensor2pil(img)
if fill_background:
if mask is not None:
m = mask[index].unsqueeze(0) if index < len(mask) else mask[-1].unsqueeze(0)
alpha = tensor2pil(m).convert('L')
elif _image.mode == "RGBA":
alpha = _image.split()[-1]
else:
log(f"Error: ImageRemoveAlpha_UTK skipped, because the input image is not RGBA and mask is None.",
message_type='error')
return (RGBA_image,)
ret_image = Image.new('RGB', size=_image.size, color=background_color)
ret_image.paste(_image, mask=alpha)
ret_images.append(pil2tensor(ret_image))
else:
ret_images.append(pil2tensor(tensor2pil(img).convert('RGB')))
log(f"ImageRemoveAlpha_UTK Processed {len(ret_images)} image(s).", message_type='finish')
return (torch.cat(ret_images, dim=0), )
# Node mappings
NODE_CLASS_MAPPINGS = {
"ImageRemoveAlpha_UTK": ImageRemoveAlpha_UTK,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ImageRemoveAlpha_UTK": "Image Remove Alpha (UTK)",
}
+204
View File
@@ -0,0 +1,204 @@
"""
Image Scale By Aspect Ratio Node
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Scales images to specific aspect ratios with various fitting modes.
:copyright: (c) 2024 by May
:license: MIT, see LICENSE for more details.
"""
import torch
from PIL import Image
import math
from ...common_utils import log, tensor2pil, pil2tensor, image2mask
def num_round_up_to_multiple(num, multiple):
"""Round up to the nearest multiple"""
return ((num + multiple - 1) // multiple) * multiple
def fit_resize_image(image, target_width, target_height, fit_mode, resize_sampler):
"""Resize image according to fit mode"""
if fit_mode == 'letterbox':
# Calculate scaling factor to fit within target dimensions
scale = min(target_width / image.width, target_height / image.height)
new_width = int(image.width * scale)
new_height = int(image.height * scale)
# Resize image
resized = image.resize((new_width, new_height), resize_sampler)
# Create new image with target dimensions and paste resized image
result = Image.new(image.mode, (target_width, target_height), (0, 0, 0))
paste_x = (target_width - new_width) // 2
paste_y = (target_height - new_height) // 2
result.paste(resized, (paste_x, paste_y))
return result
elif fit_mode == 'crop':
# Calculate scaling factor to cover target dimensions
scale = max(target_width / image.width, target_height / image.height)
new_width = int(image.width * scale)
new_height = int(image.height * scale)
# Resize image
resized = image.resize((new_width, new_height), resize_sampler)
# Crop to target dimensions
crop_x = (new_width - target_width) // 2
crop_y = (new_height - target_height) // 2
return resized.crop((crop_x, crop_y, crop_x + target_width, crop_y + target_height))
else: # fill
# Simple resize to target dimensions
return image.resize((target_width, target_height), resize_sampler)
class ImageScaleByAspectRatio_UTK:
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
ratio_list = ['original', 'custom', '1:1', '3:2', '4:3', '16:9', '2:3', '3:4', '9:16']
fit_mode = ['letterbox', 'crop', 'fill']
method_mode = ['lanczos', 'bicubic', 'hamming', 'bilinear', 'box', 'nearest']
multiple_list = ['8', '16', '32', '64', '128', '256', '512', 'None']
return {
"required": {
"aspect_ratio": (ratio_list,),
"proportional_width": ("INT", {"default": 2, "min": 1, "max": 999, "step": 1}),
"proportional_height": ("INT", {"default": 1, "min": 1, "max": 999, "step": 1}),
"fit": (fit_mode,),
"method": (method_mode,),
"round_to_multiple": (multiple_list,),
"scale_to_longest_side": ("BOOLEAN", {"default": False}), # 是否按长边缩放
"longest_side": ("INT", {"default": 1024, "min": 4, "max": 999999, "step": 1}),
},
"optional": {
"image": ("IMAGE",), #
"mask": ("MASK",), #
}
}
RETURN_TYPES = ("IMAGE", "MASK", "BOX", "INT", "INT",)
RETURN_NAMES = ("image", "mask", "original_size", "width", "height",)
FUNCTION = 'image_scale_by_aspect_ratio'
def image_scale_by_aspect_ratio(self, aspect_ratio, proportional_width, proportional_height,
fit, method, round_to_multiple, scale_to_longest_side, longest_side,
image=None, mask = None,
):
orig_images = []
orig_masks = []
orig_width = 0
orig_height = 0
target_width = 0
target_height = 0
ratio = 1.0
ret_images = []
ret_masks = []
if image is not None:
for i in image:
i = torch.unsqueeze(i, 0)
orig_images.append(i)
orig_width, orig_height = tensor2pil(orig_images[0]).size
if mask is not None:
if mask.dim() == 2:
mask = torch.unsqueeze(mask, 0)
for m in mask:
m = torch.unsqueeze(m, 0)
orig_masks.append(m)
_width, _height = tensor2pil(orig_masks[0]).size
if (orig_width > 0 and orig_width != _width) or (orig_height > 0 and orig_height != _height):
log(f"Error: ImageScaleByAspectRatio_UTK skipped, because the mask is does'nt match image.", message_type='error')
return (None, None, None, 0, 0,)
elif orig_width + orig_height == 0:
orig_width = _width
orig_height = _height
if orig_width + orig_height == 0:
log(f"Error: ImageScaleByAspectRatio_UTK skipped, because the image or mask at least one must be input.", message_type='error')
return (None, None, None, 0, 0,)
if aspect_ratio == 'original':
ratio = orig_width / orig_height
elif aspect_ratio == 'custom':
ratio = proportional_width / proportional_height
else:
s = aspect_ratio.split(":")
ratio = int(s[0]) / int(s[1])
# calculate target width and height
if orig_width > orig_height:
if scale_to_longest_side:
target_width = longest_side
else:
target_width = orig_width
target_height = int(target_width / ratio)
else:
if scale_to_longest_side:
target_height = longest_side
else:
target_height = orig_height
target_width = int(target_height * ratio)
if ratio < 1:
if scale_to_longest_side:
_r = longest_side / target_height
target_height = longest_side
else:
_r = orig_height / target_height
target_height = orig_height
target_width = int(target_width * _r)
if round_to_multiple != 'None':
multiple = int(round_to_multiple)
target_width = num_round_up_to_multiple(target_width, multiple)
target_height = num_round_up_to_multiple(target_height, multiple)
_mask = Image.new('L', size=(target_width, target_height), color='black')
_image = Image.new('RGB', size=(target_width, target_height), color='black')
resize_sampler = Image.LANCZOS
if method == "bicubic":
resize_sampler = Image.BICUBIC
elif method == "hamming":
resize_sampler = Image.HAMMING
elif method == "bilinear":
resize_sampler = Image.BILINEAR
elif method == "box":
resize_sampler = Image.BOX
elif method == "nearest":
resize_sampler = Image.NEAREST
if len(orig_images) > 0:
for i in orig_images:
_image = tensor2pil(i).convert('RGB')
_image = fit_resize_image(_image, target_width, target_height, fit, resize_sampler)
ret_images.append(pil2tensor(_image))
if len(orig_masks) > 0:
for m in orig_masks:
_mask = tensor2pil(m).convert('L')
_mask = fit_resize_image(_mask, target_width, target_height, fit, resize_sampler).convert('L')
ret_masks.append(image2mask(_mask))
if len(ret_images) > 0 and len(ret_masks) >0:
log(f"ImageScaleByAspectRatio_UTK Processed {len(ret_images)} image(s).", message_type='finish')
return (torch.cat(ret_images, dim=0), torch.cat(ret_masks, dim=0),[orig_width, orig_height], target_width, target_height,)
elif len(ret_images) > 0 and len(ret_masks) == 0:
log(f"ImageScaleByAspectRatio_UTK Processed {len(ret_images)} image(s).", message_type='finish')
return (torch.cat(ret_images, dim=0), None,[orig_width, orig_height], target_width, target_height,)
elif len(ret_images) == 0 and len(ret_masks) > 0:
log(f"ImageScaleByAspectRatio_UTK Processed {len(ret_masks)} image(s).", message_type='finish')
return (None, torch.cat(ret_masks, dim=0),[orig_width, orig_height], target_width, target_height,)
else:
log(f"Error: ImageScaleByAspectRatio_UTK skipped, because the available image or mask is not found.", message_type='error')
return (None, None, None, 0, 0,)
# Node mappings
NODE_CLASS_MAPPINGS = {
"ImageScaleByAspectRatio_UTK": ImageScaleByAspectRatio_UTK,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ImageScaleByAspectRatio_UTK": "Image Scale By Aspect Ratio (UTK)",
}
+117
View File
@@ -0,0 +1,117 @@
"""
Image Scale Restore Node
~~~~~~~~~~~~~~~~~~~~~~~
Restores images to original size or scales them with specified parameters.
:copyright: (c) 2024 by May
:license: MIT, see LICENSE for more details.
"""
import torch
from PIL import Image
from ...common_utils import log, tensor2pil, pil2tensor, image2mask
class ImageScaleRestore_UTK:
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
method_mode = ['lanczos', 'bicubic', 'hamming', 'bilinear', 'box', 'nearest']
return {
"required": {
"image": ("IMAGE", ), #
"scale": ("FLOAT", {"default": 1, "min": 0.01, "max": 100, "step": 0.01}),
"method": (method_mode,),
"scale_by_longest_side": ("BOOLEAN", {"default": False}), # 是否按长边缩放
"longest_side": ("INT", {"default": 1024, "min": 4, "max": 999999, "step": 1}),
},
"optional": {
"mask": ("MASK",), #
"original_size": ("BOX",),
}
}
RETURN_TYPES = ("IMAGE", "MASK", "BOX", "INT", "INT")
RETURN_NAMES = ("image", "mask", "original_size", "width", "height",)
FUNCTION = 'image_scale_restore'
def image_scale_restore(self, image, scale, method,
scale_by_longest_side, longest_side,
mask = None, original_size = None
):
l_images = []
l_masks = []
ret_images = []
ret_masks = []
for l in image:
l_images.append(torch.unsqueeze(l, 0))
m = tensor2pil(l)
if m.mode == 'RGBA':
l_masks.append(m.split()[-1])
if mask is not None:
if mask.dim() == 2:
mask = torch.unsqueeze(mask, 0)
l_masks = []
for m in mask:
l_masks.append(tensor2pil(torch.unsqueeze(m, 0)).convert('L'))
max_batch = max(len(l_images), len(l_masks))
orig_width, orig_height = tensor2pil(l_images[0]).size
if original_size is not None:
target_width = original_size[0]
target_height = original_size[1]
else:
target_width = int(orig_width * scale)
target_height = int(orig_height * scale)
if scale_by_longest_side:
if orig_width > orig_height:
target_width = longest_side
target_height = int(target_width * orig_height / orig_width)
else:
target_height = longest_side
target_width = int(target_height * orig_width / orig_height)
if target_width < 4:
target_width = 4
if target_height < 4:
target_height = 4
resize_sampler = Image.LANCZOS
if method == "bicubic":
resize_sampler = Image.BICUBIC
elif method == "hamming":
resize_sampler = Image.HAMMING
elif method == "bilinear":
resize_sampler = Image.BILINEAR
elif method == "box":
resize_sampler = Image.BOX
elif method == "nearest":
resize_sampler = Image.NEAREST
for i in range(max_batch):
_image = l_images[i] if i < len(l_images) else l_images[-1]
_canvas = tensor2pil(_image).convert('RGB')
ret_image = _canvas.resize((target_width, target_height), resize_sampler)
ret_mask = Image.new('L', size=ret_image.size, color='white')
if mask is not None:
_mask = l_masks[i] if i < len(l_masks) else l_masks[-1]
ret_mask = _mask.resize((target_width, target_height), resize_sampler)
ret_images.append(pil2tensor(ret_image))
ret_masks.append(image2mask(ret_mask))
log(f"ImageScaleRestore_UTK Processed {len(ret_images)} image(s).", message_type='finish')
return (torch.cat(ret_images, dim=0), torch.cat(ret_masks, dim=0), [orig_width, orig_height], target_width, target_height,)
# Node mappings
NODE_CLASS_MAPPINGS = {
"ImageScaleRestore_UTK": ImageScaleRestore_UTK,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ImageScaleRestore_UTK": "Image Scale Restore (UTK)",
}
+256
View File
@@ -0,0 +1,256 @@
"""
Imitation Hue Node
~~~~~~~~~~~~~~~~~
Performs color transfer and imitation between images with skin protection.
:copyright: (c) 2024 by May
:license: MIT, see LICENSE for more details.
"""
import torch
import numpy as np
import cv2
def image_stats(image):
return np.mean(image[:, :, 1:], axis=(0, 1)), np.std(image[:, :, 1:], axis=(0, 1))
def is_skin_or_lips(lab_image):
l, a, b = lab_image[:, :, 0], lab_image[:, :, 1], lab_image[:, :, 2]
skin = (l > 20) & (l < 250) & (a > 120) & (a < 180) & (b > 120) & (b < 190)
lips = (l > 20) & (l < 200) & (a > 150) & (b > 140)
return (skin | lips).astype(np.float32)
def adjust_brightness(image, factor, mask=None):
hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
v = hsv[:, :, 2].astype(np.float32)
if mask is not None:
mask = mask.squeeze()
v = np.where(mask > 0, np.clip(v * factor, 0, 255), v)
else:
v = np.clip(v * factor, 0, 255)
hsv[:, :, 2] = v.astype(np.uint8)
return cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)
def adjust_saturation(image, factor, mask=None):
hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
s = hsv[:, :, 1].astype(np.float32)
if mask is not None:
mask = mask.squeeze()
s = np.where(mask > 0, np.clip(s * factor, 0, 255), s)
else:
s = np.clip(s * factor, 0, 255)
hsv[:, :, 1] = s.astype(np.uint8)
return cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)
def adjust_contrast(image, factor, mask=None):
mean = np.mean(image)
adjusted = image.astype(np.float32)
if mask is not None:
mask = mask.squeeze()
mask = np.repeat(mask[:, :, np.newaxis], 3, axis=2)
adjusted = np.where(mask > 0, np.clip((adjusted - mean) * factor + mean, 0, 255), adjusted)
else:
adjusted = np.clip((adjusted - mean) * factor + mean, 0, 255)
return adjusted.astype(np.uint8)
def adjust_tone(source, target, tone_strength=0.7, mask=None):
h, w = target.shape[:2]
source = cv2.resize(source, (w, h))
lab_image = cv2.cvtColor(target, cv2.COLOR_BGR2LAB).astype(np.float32)
lab_source = cv2.cvtColor(source, cv2.COLOR_BGR2LAB).astype(np.float32)
l_image = lab_image[:,:,0]
l_source = lab_source[:,:,0]
if mask is not None:
mask = cv2.resize(mask, (w, h))
mask = mask.astype(np.float32) / 255.0
l_adjusted = np.copy(l_image)
mean_source = np.mean(l_source[mask > 0])
std_source = np.std(l_source[mask > 0])
mean_target = np.mean(l_image[mask > 0])
std_target = np.std(l_image[mask > 0])
l_adjusted[mask > 0] = (l_image[mask > 0] - mean_target) * (std_source / (std_target + 1e-6)) * 0.7 + mean_source
l_adjusted[mask > 0] = np.clip(l_adjusted[mask > 0], 0, 255)
clahe = cv2.createCLAHE(clipLimit=2.5, tileGridSize=(8,8))
l_enhanced = clahe.apply(l_adjusted.astype(np.uint8))
l_final = cv2.addWeighted(l_adjusted, 0.7, l_enhanced.astype(np.float32), 0.3, 0)
l_final = np.clip(l_final, 0, 255)
l_contrast = cv2.addWeighted(l_final, 1.3, l_final, 0, -20)
l_contrast = np.clip(l_contrast, 0, 255)
l_image[mask > 0] = l_image[mask > 0] * (1 - tone_strength) + l_contrast[mask > 0] * tone_strength
else:
mean_source = np.mean(l_source)
std_source = np.std(l_source)
l_mean = np.mean(l_image)
l_std = np.std(l_image)
l_adjusted = (l_image - l_mean) * (std_source / (l_std + 1e-6)) * 0.7 + mean_source
l_adjusted = np.clip(l_adjusted, 0, 255)
clahe = cv2.createCLAHE(clipLimit=2.5, tileGridSize=(8,8))
l_enhanced = clahe.apply(l_adjusted.astype(np.uint8))
l_final = cv2.addWeighted(l_adjusted, 0.7, l_enhanced.astype(np.float32), 0.3, 0)
l_final = np.clip(l_final, 0, 255)
l_contrast = cv2.addWeighted(l_final, 1.3, l_final, 0, -20)
l_contrast = np.clip(l_contrast, 0, 255)
l_image = l_image * (1 - tone_strength) + l_contrast * tone_strength
lab_image[:,:,0] = l_image
return cv2.cvtColor(lab_image.astype(np.uint8), cv2.COLOR_LAB2BGR)
def tensor2cv2(image: torch.Tensor) -> np.array:
if image.dim() == 4:
image = image.squeeze()
npimage = image.numpy()
cv2image = np.uint8(npimage * 255 / npimage.max())
return cv2.cvtColor(cv2image, cv2.COLOR_RGB2BGR)
def color_transfer(source, target, mask=None, strength=1.0, skin_protection=0.2, auto_brightness=True,
brightness_range=0.5, auto_contrast=False, contrast_range=0.5,
auto_saturation=False, saturation_range=0.5, auto_tone=False, tone_strength=0.7):
source_lab = cv2.cvtColor(source, cv2.COLOR_BGR2LAB).astype(np.float32)
target_lab = cv2.cvtColor(target, cv2.COLOR_BGR2LAB).astype(np.float32)
src_means, src_stds = image_stats(source_lab)
tar_means, tar_stds = image_stats(target_lab)
skin_lips_mask = is_skin_or_lips(target_lab.astype(np.uint8))
skin_lips_mask = cv2.GaussianBlur(skin_lips_mask, (5, 5), 0)
if mask is not None:
mask = cv2.resize(mask, (target.shape[1], target.shape[0]))
mask = mask.astype(np.float32) / 255.0
result_lab = target_lab.copy()
for i in range(1, 3):
adjusted_channel = (target_lab[:, :, i] - tar_means[i - 1]) * (src_stds[i - 1] / (tar_stds[i - 1] + 1e-6)) + \
src_means[i - 1]
adjusted_channel = np.clip(adjusted_channel, 0, 255)
if mask is not None:
result_lab[:, :, i] = target_lab[:, :, i] * (1 - mask) + \
(target_lab[:, :, i] * skin_lips_mask * skin_protection + \
adjusted_channel * skin_lips_mask * (1 - skin_protection) + \
adjusted_channel * (1 - skin_lips_mask)) * mask
else:
result_lab[:, :, i] = target_lab[:, :, i] * skin_lips_mask * skin_protection + \
adjusted_channel * skin_lips_mask * (1 - skin_protection) + \
adjusted_channel * (1 - skin_lips_mask)
result_bgr = cv2.cvtColor(result_lab.astype(np.uint8), cv2.COLOR_LAB2BGR)
final_result = cv2.addWeighted(target, 1 - strength, result_bgr, strength, 0)
if mask is not None:
mask = cv2.resize(mask, (target.shape[1], target.shape[0]))
mask = mask.astype(np.float32) / 255.0
if auto_brightness:
source_brightness = np.mean(cv2.cvtColor(source, cv2.COLOR_BGR2GRAY))
target_brightness = np.mean(cv2.cvtColor(target, cv2.COLOR_BGR2GRAY))
brightness_difference = source_brightness - target_brightness
brightness_factor = 1.0 + np.clip(brightness_difference / 255 * brightness_range, brightness_range*-1, brightness_range)
final_result = adjust_brightness(final_result, brightness_factor, mask)
if auto_contrast:
source_gray = cv2.cvtColor(source, cv2.COLOR_BGR2GRAY)
target_gray = cv2.cvtColor(target, cv2.COLOR_BGR2GRAY)
source_contrast = np.std(source_gray)
target_contrast = np.std(target_gray)
contrast_difference = source_contrast - target_contrast
contrast_factor = 1.0 + np.clip(contrast_difference / 255, contrast_range*-1, contrast_range)
final_result = adjust_contrast(final_result, contrast_factor, mask)
if auto_saturation:
source_hsv = cv2.cvtColor(source, cv2.COLOR_BGR2HSV)
target_hsv = cv2.cvtColor(target, cv2.COLOR_BGR2HSV)
source_saturation = np.mean(source_hsv[:, :, 1])
target_saturation = np.mean(target_hsv[:, :, 1])
saturation_difference = source_saturation - target_saturation
saturation_factor = 1.0 + np.clip(saturation_difference / 255, saturation_range*-1, saturation_range)
final_result = adjust_saturation(final_result, saturation_factor, mask)
if auto_tone:
final_result = adjust_tone(source, final_result, tone_strength, mask)
else:
if auto_brightness:
source_brightness = np.mean(cv2.cvtColor(source, cv2.COLOR_BGR2GRAY))
target_brightness = np.mean(cv2.cvtColor(target, cv2.COLOR_BGR2GRAY))
brightness_difference = source_brightness - target_brightness
brightness_factor = 1.0 + np.clip(brightness_difference / 255 * brightness_range, brightness_range*-1, brightness_range)
final_result = adjust_brightness(final_result, brightness_factor)
if auto_contrast:
source_gray = cv2.cvtColor(source, cv2.COLOR_BGR2GRAY)
target_gray = cv2.cvtColor(target, cv2.COLOR_BGR2GRAY)
source_contrast = np.std(source_gray)
target_contrast = np.std(target_gray)
contrast_difference = source_contrast - target_contrast
contrast_factor = 1.0 + np.clip(contrast_difference / 255, contrast_range*-1, contrast_range)
final_result = adjust_contrast(final_result, contrast_factor)
if auto_saturation:
source_hsv = cv2.cvtColor(source, cv2.COLOR_BGR2HSV)
target_hsv = cv2.cvtColor(target, cv2.COLOR_BGR2HSV)
source_saturation = np.mean(source_hsv[:, :, 1])
target_saturation = np.mean(target_hsv[:, :, 1])
saturation_difference = source_saturation - target_saturation
saturation_factor = 1.0 + np.clip(saturation_difference / 255, saturation_range*-1, saturation_range)
final_result = adjust_saturation(final_result, saturation_factor)
if auto_tone:
final_result = adjust_tone(source, final_result, tone_strength)
return final_result
class ImitationHueNode_UTK:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"imitation_image": ("IMAGE",),
"target_image": ("IMAGE",),
"strength": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 1.0, "step": 0.1}),
"skin_protection": ("FLOAT", {"default": 0.2, "min": 0, "max": 1.0, "step": 0.1}),
"auto_brightness": ("BOOLEAN", {"default": True}),
"brightness_range": ("FLOAT", {"default": 0.5, "min": 0.1, "max": 1.0, "step": 0.1}),
"auto_contrast": ("BOOLEAN", {"default": False}),
"contrast_range": ("FLOAT", {"default": 0.5, "min": 0.1, "max": 1.0, "step": 0.1}),
"auto_saturation": ("BOOLEAN", {"default": False}),
"saturation_range": ("FLOAT", {"default": 0.5, "min": 0.1, "max": 1.0, "step": 0.1}),
"auto_tone": ("BOOLEAN", {"default": False}),
"tone_strength": ("FLOAT", {"default": 0.5, "min": 0.1, "max": 1.0, "step": 0.1}),
},
"optional": {
"mask": ("MASK", {"default": None}),
},
}
CATEGORY = "UniversalToolkit"
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "imitation_hue"
def imitation_hue(self, imitation_image, target_image, strength, skin_protection, auto_brightness, brightness_range,
auto_contrast, contrast_range, auto_saturation, saturation_range, auto_tone, tone_strength,
mask=None):
for img in imitation_image:
img_cv1 = tensor2cv2(img)
for img in target_image:
img_cv2 = tensor2cv2(img)
img_cv3 = None
if mask is not None:
for img3 in mask:
img_cv3 = img3.cpu().numpy()
img_cv3 = (img_cv3 * 255).astype(np.uint8)
result_img = color_transfer(img_cv1, img_cv2, img_cv3, strength, skin_protection, auto_brightness,
brightness_range,auto_contrast, contrast_range, auto_saturation,
saturation_range, auto_tone, tone_strength)
result_img = cv2.cvtColor(result_img, cv2.COLOR_BGR2RGB)
rst = torch.from_numpy(result_img.astype(np.float32) / 255.0).unsqueeze(0)
return (rst,)
# Node mappings
NODE_CLASS_MAPPINGS = {
"ImitationHueNode_UTK": ImitationHueNode_UTK,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ImitationHueNode_UTK": "Imitation Hue Node (UTK)",
}
+60
View File
@@ -0,0 +1,60 @@
"""
Purge VRAM Node
~~~~~~~~~~~~~~
Purges GPU memory and optionally unloads models.
:copyright: (c) 2024 by May
:license: MIT, see LICENSE for more details.
"""
import torch.cuda
import gc
from ...common_utils import log
def clear_memory():
"""Clear GPU memory"""
if torch.cuda.is_available():
torch.cuda.empty_cache()
gc.collect()
class PurgeVRAM_UTK:
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"anything": ("*", {}),
"purge_cache": ("BOOLEAN", {"default": True}),
"purge_models": ("BOOLEAN", {"default": True}),
},
"optional": {
}
}
RETURN_TYPES = ("*",)
RETURN_NAMES = ("anything",)
FUNCTION = "purge_vram"
OUTPUT_NODE = True
def purge_vram(self, anything, purge_cache, purge_models):
clear_memory()
if purge_models:
try:
import comfy.model_management
comfy.model_management.unload_all_models()
comfy.model_management.soft_empty_cache()
except ImportError:
log("ComfyUI model management not available", message_type="warning")
log("VRAM purged successfully", message_type="finish")
return (anything,)
# Node mappings
NODE_CLASS_MAPPINGS = {
"PurgeVRAM_UTK": PurgeVRAM_UTK,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PurgeVRAM_UTK": "Purge VRAM (UTK)",
}
+88
View File
@@ -0,0 +1,88 @@
"""
Restore Crop Box Node
~~~~~~~~~~~~~~~~~~~
Restores cropped images back to their original background.
:copyright: (c) 2024 by May
:license: MIT, see LICENSE for more details.
"""
import torch
from PIL import Image
from ...common_utils import log, tensor2pil, pil2tensor, image2mask
class RestoreCropBox_UTK:
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"background_image": ("IMAGE", ),
"croped_image": ("IMAGE",),
"invert_mask": ("BOOLEAN", {"default": False}), # 反转mask#
"crop_box": ("BOX",),
},
"optional": {
"croped_mask": ("MASK",),
}
}
RETURN_TYPES = ("IMAGE", "MASK", )
RETURN_NAMES = ("image", "mask", )
FUNCTION = 'restore_crop_box'
def restore_crop_box(self, background_image, croped_image, invert_mask, crop_box,
croped_mask=None
):
b_images = []
l_images = []
l_masks = []
ret_images = []
ret_masks = []
for b in background_image:
b_images.append(torch.unsqueeze(b, 0))
for l in croped_image:
l_images.append(torch.unsqueeze(l, 0))
m = tensor2pil(l)
if m.mode == 'RGBA':
l_masks.append(m.split()[-1])
else:
l_masks.append(Image.new('L', size=m.size, color='white'))
if croped_mask is not None:
if croped_mask.dim() == 2:
croped_mask = torch.unsqueeze(croped_mask, 0)
l_masks = []
for m in croped_mask:
if invert_mask:
m = 1 - m
l_masks.append(tensor2pil(torch.unsqueeze(m, 0)).convert('L'))
max_batch = max(len(b_images), len(l_images), len(l_masks))
for i in range(max_batch):
background_image = b_images[i] if i < len(b_images) else b_images[-1]
croped_image = l_images[i] if i < len(l_images) else l_images[-1]
_mask = l_masks[i] if i < len(l_masks) else l_masks[-1]
_canvas = tensor2pil(background_image).convert('RGB')
_layer = tensor2pil(croped_image).convert('RGB')
ret_mask = Image.new('L', size=_canvas.size, color='black')
_canvas.paste(_layer, box=tuple(crop_box), mask=_mask)
ret_mask.paste(_mask, box=tuple(crop_box))
ret_images.append(pil2tensor(_canvas))
ret_masks.append(image2mask(ret_mask))
log(f"RestoreCropBox_UTK Processed {len(ret_images)} image(s).", message_type='finish')
return (torch.cat(ret_images, dim=0), torch.cat(ret_masks, dim=0),)
# Node mappings
NODE_CLASS_MAPPINGS = {
"RestoreCropBox_UTK": RestoreCropBox_UTK,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"RestoreCropBox_UTK": "Restore Crop Box (UTK)",
}
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,17 @@
"""
Mask Operations Node
~~~~~~~~~~~~~~~~~~
Performs logical operations on masks.
:copyright: (c) 2024 by May
:license: MIT, see LICENSE for more details.
"""
import torch
class MaskAnd_UTK:
CATEGORY = "UniversalToolkit/Mask"
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
return {"required": {"mask1": ("MASK",), "mask2": ("MASK",)}}
@@ -15,7 +25,7 @@ class MaskAnd_UTK:
return (mask1 * mask2,)
class MaskSub_UTK:
CATEGORY = "UniversalToolkit/Mask"
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
return {"required": {"mask1": ("MASK",), "mask2": ("MASK",)}}
@@ -29,7 +39,7 @@ class MaskSub_UTK:
return (torch.clamp(mask1 - mask2, 0, 1),)
class MaskAdd_UTK:
CATEGORY = "UniversalToolkit/Mask"
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
return {"required": {"mask1": ("MASK",), "mask2": ("MASK",)}}
@@ -40,4 +50,17 @@ class MaskAdd_UTK:
# 逐像素加法,支持batch,结果裁剪到[0,1]
if mask1.shape != mask2.shape:
raise ValueError("输入的两个MASK尺寸不一致")
return (torch.clamp(mask1 + mask2, 0, 1),)
return (torch.clamp(mask1 + mask2, 0, 1),)
# Node mappings
NODE_CLASS_MAPPINGS = {
"MaskAnd_UTK": MaskAnd_UTK,
"MaskSub_UTK": MaskSub_UTK,
"MaskAdd_UTK": MaskAdd_UTK,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"MaskAnd_UTK": "Mask And (UTK)",
"MaskSub_UTK": "Mask Sub (UTK)",
"MaskAdd_UTK": "Mask Add (UTK)",
}
@@ -1,106 +1,18 @@
"""
Fill Masked Area Node
~~~~~~~~~~~~~~~~~~~
Fills masked areas in images using various algorithms.
:copyright: (c) 2024 by May
:license: MIT, see LICENSE for more details.
"""
import torch
import numpy as np
import cv2
from scipy.ndimage import binary_erosion, gaussian_filter
class Show_UTK:
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
return {"required": {"input": ("STRING", "INT", "FLOAT", "LIST", "MASK", "IMAGE", "LATENT")}}
RETURN_TYPES = ("STRING", "INT", "FLOAT", "LIST", "MASK", "IMAGE", "LATENT")
RETURN_NAMES = ("string", "int", "float", "list", "mask", "image", "latent")
FUNCTION = "show"
IS_PREVIEW = True
def show(self, input):
outs = [None] * 7
if isinstance(input, str):
outs[0] = input
elif isinstance(input, int):
outs[1] = input
elif isinstance(input, float):
outs[2] = input
elif isinstance(input, list):
outs[3] = input
elif hasattr(input, "shape") and len(input.shape) == 4 and input.shape[1] == 1:
outs[4] = input # MASK
elif hasattr(input, "shape") and len(input.shape) == 4 and input.shape[1] == 3:
outs[5] = input # IMAGE
elif isinstance(input, dict) and "samples" in input:
outs[6] = input # LATENT
return tuple(outs)
class ShowInt_UTK:
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
return {"required": {"int_val": ("INT",)}}
RETURN_TYPES = ("INT",)
RETURN_NAMES = ("int_val",)
FUNCTION = "show"
IS_PREVIEW = True
def show(self, int_val=None):
if int_val is None:
int_val = 0
return (int_val,)
class ShowFloat_UTK:
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
return {"required": {"float_val": ("FLOAT",)}}
RETURN_TYPES = ("FLOAT",)
RETURN_NAMES = ("float_val",)
FUNCTION = "show"
IS_PREVIEW = True
def show(self, float_val=None):
if float_val is None:
float_val = 0.0
return (float_val,)
class ShowList_UTK:
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
return {"required": {"list_val": ("LIST",)}}
RETURN_TYPES = ("LIST",)
RETURN_NAMES = ("list_val",)
FUNCTION = "show"
IS_PREVIEW = True
def show(self, list_val=None):
if list_val is None:
list_val = []
return (list_val,)
class ShowText_UTK:
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
return {"required": {"text": ("STRING",)}}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text",)
FUNCTION = "show"
IS_PREVIEW = True
def show(self, text=None):
if text is None:
text = ""
return (text,)
class PreviewMask_UTK:
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
return {"required": {"mask": ("MASK",)}}
RETURN_TYPES = ("MASK",)
RETURN_NAMES = ("mask",)
FUNCTION = "show"
IS_PREVIEW = True
def show(self, mask=None):
if mask is None:
mask = torch.zeros([1, 1, 64, 64], dtype=torch.float32)
return (mask,)
# mask二值化,阈值0.5
def mask_floor(mask):
return (mask > 0.5).astype(np.float32)
@@ -201,5 +113,11 @@ class FillMaskedArea_UTK:
out = result * (1 - alpha[..., None]) + filled * alpha[..., None]
return np.clip(out, 0, 1)
class ImageAndMaskPreview_UTK:
pass # This class is now implemented in image_nodes_utk.py
# Node mappings
NODE_CLASS_MAPPINGS = {
"FillMaskedArea_UTK": FillMaskedArea_UTK,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"FillMaskedArea_UTK": "Fill Masked Area (UTK)",
}
+128
View File
@@ -0,0 +1,128 @@
"""
Show Nodes
~~~~~~~~~
Display and preview nodes for various data types.
:copyright: (c) 2024 by May
:license: MIT, see LICENSE for more details.
"""
import torch
class Show_UTK:
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
return {"required": {"input": ("STRING", "INT", "FLOAT", "LIST", "MASK", "IMAGE", "LATENT")}}
RETURN_TYPES = ("STRING", "INT", "FLOAT", "LIST", "MASK", "IMAGE", "LATENT")
RETURN_NAMES = ("string", "int", "float", "list", "mask", "image", "latent")
FUNCTION = "show"
IS_PREVIEW = True
def show(self, input):
outs = [None] * 7
if isinstance(input, str):
outs[0] = input
elif isinstance(input, int):
outs[1] = input
elif isinstance(input, float):
outs[2] = input
elif isinstance(input, list):
outs[3] = input
elif hasattr(input, "shape") and len(input.shape) == 4 and input.shape[1] == 1:
outs[4] = input # MASK
elif hasattr(input, "shape") and len(input.shape) == 4 and input.shape[1] == 3:
outs[5] = input # IMAGE
elif isinstance(input, dict) and "samples" in input:
outs[6] = input # LATENT
return tuple(outs)
class ShowInt_UTK:
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
return {"required": {"int_val": ("INT",)}}
RETURN_TYPES = ("INT",)
RETURN_NAMES = ("int_val",)
FUNCTION = "show"
IS_PREVIEW = True
def show(self, int_val=None):
if int_val is None:
int_val = 0
return (int_val,)
class ShowFloat_UTK:
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
return {"required": {"float_val": ("FLOAT",)}}
RETURN_TYPES = ("FLOAT",)
RETURN_NAMES = ("float_val",)
FUNCTION = "show"
IS_PREVIEW = True
def show(self, float_val=None):
if float_val is None:
float_val = 0.0
return (float_val,)
class ShowList_UTK:
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
return {"required": {"list_val": ("LIST",)}}
RETURN_TYPES = ("LIST",)
RETURN_NAMES = ("list_val",)
FUNCTION = "show"
IS_PREVIEW = True
def show(self, list_val=None):
if list_val is None:
list_val = []
return (list_val,)
class ShowText_UTK:
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
return {"required": {"text": ("STRING",)}}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text",)
FUNCTION = "show"
IS_PREVIEW = True
def show(self, text=None):
if text is None:
text = ""
return (text,)
class PreviewMask_UTK:
CATEGORY = "UniversalToolkit"
@classmethod
def INPUT_TYPES(cls):
return {"required": {"mask": ("MASK",)}}
RETURN_TYPES = ("MASK",)
RETURN_NAMES = ("mask",)
FUNCTION = "show"
IS_PREVIEW = True
def show(self, mask=None):
if mask is None:
mask = torch.zeros([1, 1, 64, 64], dtype=torch.float32)
return (mask,)
# Node mappings
NODE_CLASS_MAPPINGS = {
"Show_UTK": Show_UTK,
"ShowInt_UTK": ShowInt_UTK,
"ShowFloat_UTK": ShowFloat_UTK,
"ShowList_UTK": ShowList_UTK,
"ShowText_UTK": ShowText_UTK,
"PreviewMask_UTK": PreviewMask_UTK,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"Show_UTK": "Show (UTK)",
"ShowInt_UTK": "Show Int (UTK)",
"ShowFloat_UTK": "Show Float (UTK)",
"ShowList_UTK": "Show List (UTK)",
"ShowText_UTK": "Show Text (UTK)",
"PreviewMask_UTK": "Preview Mask (UTK)",
}

Some files were not shown because too many files have changed in this diff Show More