├── .github └── workflows │ └── publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── __init__.py ├── docs ├── context_menu_reset_all_id.png ├── example_note.png ├── filter_sort_subgraphs.gif ├── fuzzy_search.png ├── menu.gif ├── menu_autoopen.gif ├── menu_new_ui.gif ├── node_context_menu_link_node.png ├── node_context_menu_set_id.png ├── settings.png ├── settings_1.png └── sort_subgraphs.gif ├── easyapi ├── BboxNode.py ├── DetectNode.py ├── ForEachNode.py ├── ImageNode.py ├── SamNode.py ├── UtilNode.py ├── __init__.py ├── api.py ├── logScript.py ├── mirrorUrlApply.py ├── settings.py └── util.py ├── example ├── example.png ├── example_1.png ├── example_2.png ├── example_3.png ├── example_4.png ├── example_image_crop_tag.png └── example_sam_mask.png ├── global.json ├── pyproject.toml ├── requirements.txt └── static ├── css └── classic.min.css └── js ├── custom_node.js ├── debounce.js ├── dialog.js ├── easyapi.js ├── image_node.js ├── pickr.min.js └── workflows.js /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to Comfy registry 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - "pyproject.toml" 9 | 10 | permissions: 11 | issues: write 12 | 13 | jobs: 14 | publish-node: 15 | name: Publish Custom Node to registry 16 | runs-on: ubuntu-latest 17 | if: ${{ github.repository_owner == 'lldacing' }} 18 | steps: 19 | - name: Check out code 20 | uses: actions/checkout@v4 21 | - name: Publish Custom Node 22 | uses: Comfy-Org/publish-node-action@v1 23 | with: 24 | ## Add your own personal access token to your Github Repository secrets and reference it here. 25 | personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | .idea/ 161 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 lldacing 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # comfyui-easyapi-nodes 2 | 针对api接口开发补充的一些自定义节点和功能。 3 | 4 | 转成base64的节点都是输出节点,websocket消息中会包含base64Images和base64Type属性(具体格式请查看ImageNode.py中的ImageToBase64Advanced类源代码,或者自己搭建简单流程运行在浏览器开发者工具-->网络中查看) 5 | 6 | Tips: base64格式字符串比较长,会导致界面卡顿,接口请求带宽可能也会有瓶颈,条件允许可以把图片上传到OSS服务器得到URL,然后用LoadImageFromURL加载,由于无相关OSS账号,上传OSS节点需自行编写,暂不支持。 7 | 8 | ## 安装 9 | - 方式1:通过ComfyUI-Manager安装 10 | - 方式2:在ComfyUI安装目录根目录下打开命令行终端,执行以下命令 11 | ```sh 12 | cd custom_nodes 13 | git clone https://github.com/lldacing/comfyui-easyapi-nodes.git 14 | cd comfyui-easyapi-nodes 15 | pip install -r requirements.txt 16 | ``` 17 | ## 升级 18 | - 在ComfyUI安装目录根目录下打开命令行终端,执行以下命令 19 | ```sh 20 | cd custom_nodes/comfyui-easyapi-nodes 21 | git pull 22 | ``` 23 | 24 | ## 节点 25 | | 是否为输出节点 | 名称 | 说明 | 26 | |:----------:|------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------| 27 | | × | LoadImageFromURL | 从网络地址加载图片,一行代表一个图片 | 28 | | × | LoadMaskFromURL | 从网络地址加载遮罩,一行代表一个 | 29 | | × | Base64ToImage | 把图片base64字符串转成图片 | 30 | | × | Base64ToMask | 把遮罩图片base64字符串转成遮罩 | 31 | | × | ImageToBase64Advanced | 把图片转成base64字符串, 可以选择图片类型(image, mask) ,方便接口调用判断 | 32 | | × | ImageToBase64 | 把图片转成base64字符串(imageType=["image"]) | 33 | | √ | MaskToBase64Image | 把遮罩转成对应图片的base64字符串(imageType=["mask"]) | 34 | | √ | MaskImageToBase64 | 把遮罩图片转成base64字符串(imageType=["mask"]) | 35 | | × | LoadImageToBase64 | 加载本地图片转成base64字符串 | 36 | | √ | SamAutoMaskSEGS | 得到图片所有语义分割的coco_rle或uncompress_rle格式。
配合ComfyUI-Impact-Pack的SAMLoader或comfyui_segment_anything的SAMModelLoader。
但是如果使用hq模型,必须使用comfyui_segment_anything | 37 | | × | SamAutoMaskSEGSAdvanced | 得到图片所有语义分割的coco_rle或uncompress_rle格式。可以调整sam的参数。 | 38 | | × | MaskToRle | 遮罩图转为coco_rle或uncompress_rle格式,rle格式数据只保存二值,所以无法精准还原遮罩 | 39 | | × | RleToMask | coco_rle或uncompress_rle格式转为遮罩,rle格式数据只保存二值,所以无法精准还原遮罩 | 40 | | × | InsightFaceBBOXDetect | 为图片中的人脸添加序号和区域框 | 41 | | × | ColorPicker | 颜色选择器 | 42 | | × | IntToNumber | 整型转数字 | 43 | | × | StringToList | 字符串转列表 | 44 | | × | IntToList | 整型转列表 | 45 | | × | ListMerge | 列表合并 | 46 | | × | JoinList | 列表根据指定分隔符连接(会先把列表元素转成字符串) | 47 | | √ | ShowString | 显示字符串(可指定消息中key值) | 48 | | √ | ShowInt | 显示整型(可指定消息中key值) | 49 | | √ | ShowFloat | 显示浮点型(可指定消息中key值) | 50 | | √ | ShowNumber | 显示数字(可指定消息中key值) | 51 | | √ | ShowBoolean | 显示布尔值(可指定消息中key值) | 52 | | × | ImageEqual | 图片是否相等(可用于通过判断遮罩图是否全黑来判定是否有遮罩) | 53 | | × | SDBaseVerNumber | 判断SD大模型版本是1.5还是xl | 54 | | × | ListWrapper | 包装成列表(任意类型) | 55 | | × | ListUnWrapper | 转成输出列表,后面连接的节点会把每个元素执行一遍,实现类似遍历效果 | 56 | | × | BboxToCropData | bbox转cropData,方便接入was插件节点使用 | 57 | | × | BboxToBbox | bbox两种格式(x,y,w,h)和(x1,y1,x2,y2)的相互转换 | 58 | | × | BboxesToBboxes | BboxToBbox节点的列表版本 | 59 | | × | SelectBbox | 从Bbox列表中选择一个 | 60 | | × | SelectBboxes | 从Bbox列表中选择多个 | 61 | | × | CropImageByBbox | 根据Bbox区域裁剪图片 | 62 | | × | MaskByBboxes | 根据Bbox列表画遮罩 | 63 | | × | SplitStringToList | 根据分隔符把字符串拆分为某种数据类型(str/int/float/bool)的列表 | 64 | | × | IndexOfList | 从列表中获取指定位置的元素 | 65 | | × | IndexesOfList | 从列表中筛选出指定位置的元素列表 | 66 | | × | StringArea | 字符串文本框(多行输入区域) | 67 | | × | ForEachOpen | 循环开始节点 | 68 | | × | ForEachClose | 循环结束节点 | 69 | | × | LoadJsonStrToList | json字符串转换为对象列表 | 70 | | × | ConvertTypeToAny | 转换数据类型为任意类型,桥接实际具有相同数据类型参数的两节点 | 71 | | × | GetValueFromJsonObj | 从对象中获取指定key的值 | 72 | | × | FilterValueForList | 根据指定值过滤列表中元素 | 73 | | × | SliceList | 列表切片 | 74 | | × | LoadLocalFilePath | 列出给定路径下的文件列表 | 75 | | × | LoadImageFromLocalPath | 根据图片全路径加载图片 | 76 | | × | LoadMaskFromLocalPath | 根据遮罩全路径加载遮罩 | 77 | | × | IsNoneOrEmpty | 判断是否为空或空字符串或空列表或空字典 | 78 | | × | IsNoneOrEmptyOptional | 为空时返回指定值(惰性求值),否则返回原值。 | 79 | | √ | EmptyOutputNode | 空的输出类型节点 | 80 | | × | SaveTextToFileByImagePath | 保存文本到图片路径,以图片名作为文件名 | 81 | | × | CopyAndRenameFiles | 把某个目录下的文件复制到另一个目录并重命名,若目标目录为空值,则重命名原文件 | 82 | | × | SaveImagesWithoutOutput | 保存图像到指定目录,不是输出类型节点,可用于循环批量跑图和作为惰性求值的前置节点 | 83 | | × | SaveSingleImageWithoutOutput | 保存单个图像到指定目录,不是输出类型节点,可用于循环批量跑图和作为惰性求值的前置节点 | 84 | | × | CropTargetSizeImageByBbox | 以bbox区域中心向外裁剪指定宽高图片 | 85 | | × | ConvertToJsonStr | 序列化为json字符串 | 86 | | × | SaveTextToLocalFile | 保存文本到本地文件 | 87 | | × | ReadTextFromLocalFile | 从本地文件读取文本 | 88 | | × | TryFreeMemory | 回收内(显)存 | 89 | | × | IfElseForEmptyObject | 可以对list类型进行判断 | 90 | | × | ImageSizeGetter | 获取图片尺寸(宽、高、最大边、最小边、批次) | 91 | | × | FilterSortDependSubGraphs | 使前置依赖子图按指定顺序执行(且只执行配置的前置依赖),如:配置filter_sort为1,4,3 表示按 depend_1 => depend_4 => depend_3 依次执行, 而depend_2不会被执行。 | 92 | | × | SortDependSubGraphs | 使前置依赖子图按指定顺序执行(未配置的依赖输入项在指定项后按默认顺序执行),如:配置sort为1,4 表示先按 depend_1 => depend_4 依次执行, 然后depend_3和depend_2按默认顺序执行。 | 93 | | × | NoneNode | 返回None | 94 | 95 | ### 示例 96 | ![save api extended](docs/example_note.png) 97 | ### [工作流](example) 98 | ![save api extended](example/example.png) 99 | ![save api extended](example/example_1.png) 100 | ![save api extended](example/example_2.png) 101 | ![save api extended](example/example_3.png) 102 | ![save api extended](example/example_4.png) 103 | ![save api extended](example/example_sam_mask.png) 104 | ![批量裁剪打标](example/example_image_crop_tag.png) 105 | 106 | ## 更新记录 107 | ### 2025-05-15 (v1.1.5) 108 | - Fix: FilterSortDependSubGraphs和SortDependSubGraphs前置节点运行异常后,再次提交任务,前置节点没有被执行 109 | ### 2025-05-12 (v1.1.4) 110 | - 新增节点 NoneNode 111 | ### 2025-03-28 (v1.1.3) 112 | - 新增配置项 `allow_create_dir_when_save` 控制保存文本和图像时是否自动创建目录 113 | - 新增节点 ImageSizeGetter、FilterSortDependSubGraphs、SortDependSubGraphs 114 | ![依赖项拓扑顺序](docs/sort_subgraphs.gif) 115 | ![依赖项拓扑过滤和顺序](docs/filter_sort_subgraphs.gif) 116 | ### 2025-02-19 (v1.1.1) 117 | - 新增节点:IsNoneOrEmptyOptional 118 | ### 2024-12-01 (v1.1.0) 119 | - 新增节点:SaveTextToLocalFile、 ReadTextFromLocalFile 120 | 121 | ### 2024-11-04 (v1.0.9) 122 | - 新增节点:SamAutoMaskSEGSAdvanced、 MaskToRle、 RleToMask、 ConvertToJsonStr 123 | 124 | ### 2024-10-24 (v1.0.7) 125 | - 新增节点:SaveTextToFileByImagePath、 CopyAndRenameFiles、 SaveImagesWithoutOutput、 SaveSingleImageWithoutOutput、 CropTargetSizeImageByBbox 126 | 127 | ### 2024-10-18 128 | - 新增节点:SliceList、LoadLocalFilePath、LoadImageFromLocalPath、LoadMaskFromLocalPath、IsNoneOrEmpty、IsNoneOrEmptyOptional、EmptyOutputNode 129 | 130 | ### 2024-09-29 131 | - 新增节点:FilterValueForList 132 | 133 | ### 2024-09-26 134 | - 新增节点:GetValueFromJsonObj、 LoadJsonStrToList、ConvertTypeToAny 135 | 136 | ### 2024-09-25 [示例](example/example_4.png) 137 | - 新增节点:ForEachOpen、 ForEachClose 138 | 139 | ### 2024-09-20 [示例](example/example_4.png) 140 | - 添加节点:SplitStringToList、 IndexOfList、 IndexesOfList、 StringArea 141 | 142 | ### 2024-09-19 143 | - 添加ListUnWrapper节点 144 | 145 | ### 2024-09-04 146 | - 添加一些bbox相关节点 147 | 148 | ### 2024-08-08 149 | - 菜单适配ComfyUI前端新界面 150 | 151 | ![save api extended](docs/menu_new_ui.gif) 152 | 153 | ## 功能 154 | - 扩展Save(Api Format)菜单。 155 | - 复制工作流 156 | - 复制/保存api格式工作流(需打开配置Settings->Enable Dev mode Options) 157 | - Save as / Copy Api 158 | 159 | 保存/复制api格式workflow 160 | - Copy EasyAi as / Copy EasyAi 161 | 162 | 保存/复制api格式workflow。把LoadImage替换成Base64ToImage节点,把PreviewImage和SaveImage替换成ImageToBase64节点 163 | 164 | ![save api extended](docs/menu.gif) 165 | - Settings配置扩展 166 | 167 | ![save api extended](docs/settings.png) 168 | - 保留历史记录最大条数 169 | 170 | 配置路径:Settings -> [EasyApi] Maximum History Size 171 | 172 | Tips: 图片使用base64时,数据存在内存中,默认最大历史记录条数是10000,为防止内存溢出,所以新增此配置项。 173 | 174 | - 是否自动展开当前菜单下的子菜单 175 | 配置路径:Settings -> [EasyApi] Auto Open Sub Menu 176 | 177 | ![save api extended](docs/menu_autoopen.gif) 178 | - 模糊搜索 179 | 配置路径:Settings -> [EasyApi] Fuzzy Search 180 | ![save api extended](docs/fuzzy_search.png) 181 | - 使用镜像地址(模型自动下载问题) 182 | - 配置路径:Settings -> [EasyApi] Huggingface Mirror 183 | - 配置路径:Settings -> [EasyApi] RawGithub Mirror 184 | - 配置路径:Settings -> [EasyApi] Github Mirror 185 | ![save api extended](docs/settings_1.png) 186 | 187 | - 菜单扩展 188 | - 重设某个节点的id(Node Context Menu) 189 | 190 | ![save api extended](docs/node_context_menu_set_id.png) 191 | - 从序号1开始重新设置所有节点的id(Canvas Context Menu) 192 | 193 | ![save api extended](docs/context_menu_reset_all_id.png) 194 | - 定位到与当前节点有连接线的节点(Node Context Menu) 195 | 196 | ![save api extended](docs/node_context_menu_link_node.png) -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import importlib.util 3 | import sys 4 | import os 5 | from .easyapi import api, logScript, mirrorUrlApply 6 | 7 | extension_folder = os.path.dirname(os.path.realpath(__file__)) 8 | 9 | NODE_CLASS_MAPPINGS = {} 10 | NODE_DISPLAY_NAME_MAPPINGS = {} 11 | 12 | pyPath = os.path.join(extension_folder, 'easyapi') 13 | # sys.path.append(extension_folder) 14 | 15 | logScript.log_wrap() 16 | api.init() 17 | mirrorUrlApply.init() 18 | 19 | def loadCustomNodes(): 20 | files = glob.glob(os.path.join(pyPath, "*Node.py"), recursive=True) 21 | api_files = glob.glob(os.path.join(pyPath, "api.py"), recursive=True) 22 | find_files = files + api_files 23 | for file in find_files: 24 | file_relative_path = file[len(extension_folder):] 25 | model_name = file_relative_path.replace(os.sep, '.') 26 | model_name = os.path.splitext(model_name)[0] 27 | module = importlib.import_module(model_name, __name__) 28 | if hasattr(module, "NODE_CLASS_MAPPINGS") and getattr(module, "NODE_CLASS_MAPPINGS") is not None: 29 | NODE_CLASS_MAPPINGS.update(module.NODE_CLASS_MAPPINGS) 30 | if hasattr(module, "NODE_DISPLAY_NAME_MAPPINGS") and getattr(module, "NODE_DISPLAY_NAME_MAPPINGS") is not None: 31 | NODE_DISPLAY_NAME_MAPPINGS.update(module.NODE_DISPLAY_NAME_MAPPINGS) 32 | if hasattr(module, "init"): 33 | getattr(module, "init")() 34 | 35 | 36 | loadCustomNodes() 37 | 38 | WEB_DIRECTORY = "./static" 39 | 40 | __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] 41 | -------------------------------------------------------------------------------- /docs/context_menu_reset_all_id.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lldacing/comfyui-easyapi-nodes/89621e290c57c4a729d04927293000d22a7df525/docs/context_menu_reset_all_id.png -------------------------------------------------------------------------------- /docs/example_note.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lldacing/comfyui-easyapi-nodes/89621e290c57c4a729d04927293000d22a7df525/docs/example_note.png -------------------------------------------------------------------------------- /docs/filter_sort_subgraphs.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lldacing/comfyui-easyapi-nodes/89621e290c57c4a729d04927293000d22a7df525/docs/filter_sort_subgraphs.gif -------------------------------------------------------------------------------- /docs/fuzzy_search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lldacing/comfyui-easyapi-nodes/89621e290c57c4a729d04927293000d22a7df525/docs/fuzzy_search.png -------------------------------------------------------------------------------- /docs/menu.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lldacing/comfyui-easyapi-nodes/89621e290c57c4a729d04927293000d22a7df525/docs/menu.gif -------------------------------------------------------------------------------- /docs/menu_autoopen.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lldacing/comfyui-easyapi-nodes/89621e290c57c4a729d04927293000d22a7df525/docs/menu_autoopen.gif -------------------------------------------------------------------------------- /docs/menu_new_ui.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lldacing/comfyui-easyapi-nodes/89621e290c57c4a729d04927293000d22a7df525/docs/menu_new_ui.gif -------------------------------------------------------------------------------- /docs/node_context_menu_link_node.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lldacing/comfyui-easyapi-nodes/89621e290c57c4a729d04927293000d22a7df525/docs/node_context_menu_link_node.png -------------------------------------------------------------------------------- /docs/node_context_menu_set_id.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lldacing/comfyui-easyapi-nodes/89621e290c57c4a729d04927293000d22a7df525/docs/node_context_menu_set_id.png -------------------------------------------------------------------------------- /docs/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lldacing/comfyui-easyapi-nodes/89621e290c57c4a729d04927293000d22a7df525/docs/settings.png -------------------------------------------------------------------------------- /docs/settings_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lldacing/comfyui-easyapi-nodes/89621e290c57c4a729d04927293000d22a7df525/docs/settings_1.png -------------------------------------------------------------------------------- /docs/sort_subgraphs.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lldacing/comfyui-easyapi-nodes/89621e290c57c4a729d04927293000d22a7df525/docs/sort_subgraphs.gif -------------------------------------------------------------------------------- /easyapi/BboxNode.py: -------------------------------------------------------------------------------- 1 | from json import JSONDecoder 2 | 3 | import torch 4 | 5 | import nodes 6 | from .util import any_type 7 | 8 | 9 | class BboxToCropData: 10 | @classmethod 11 | def INPUT_TYPES(self): 12 | return {"required": { 13 | "bbox": ("BBOX", {"forceInput": True}), 14 | }, 15 | } 16 | 17 | RETURN_TYPES = (any_type,) 18 | RETURN_NAMES = ("crop_data", ) 19 | 20 | FUNCTION = "convert" 21 | 22 | OUTPUT_NODE = False 23 | CATEGORY = "EasyApi/Bbox" 24 | 25 | INPUT_IS_LIST = False 26 | OUTPUT_IS_LIST = (False, ) 27 | DESCRIPTION = "可以把bbox(x,y,w,h)转换为crop_data((w,h),(x,y,x+w,y+w),配合was节点使用" 28 | 29 | def convert(self, bbox): 30 | x, y, w, h = bbox 31 | return (((w, h), (x, y, x+w, y+h),),) 32 | 33 | 34 | class BboxToCropData: 35 | @classmethod 36 | def INPUT_TYPES(self): 37 | return {"required": { 38 | "bbox": ("BBOX", {"forceInput": True}), 39 | }, 40 | "optional": { 41 | "is_xywh": ("BOOLEAN", {"default": False}), 42 | } 43 | } 44 | 45 | RETURN_TYPES = (any_type,) 46 | RETURN_NAMES = ("crop_data", ) 47 | 48 | FUNCTION = "convert" 49 | 50 | OUTPUT_NODE = False 51 | CATEGORY = "EasyApi/Bbox" 52 | 53 | INPUT_IS_LIST = False 54 | OUTPUT_IS_LIST = (False, ) 55 | DESCRIPTION = "可以把bbox(x,y,w,h)转换为crop_data((w,h),(x,y,x+w,y+w),配合was节点使用\nis_xywh表示bbox的格式是(x,y,w,h)还是(x,y,x1,y1)。" 56 | 57 | def convert(self, bbox, is_xywh=False): 58 | if is_xywh: 59 | x, y, w, h = bbox 60 | else: 61 | x, y, x_1, y_1 = bbox 62 | w = x_1 - x 63 | h = y_1 - y 64 | return (((w, h), (x, y, x+w, y+h),),) 65 | 66 | 67 | class BboxToBbox: 68 | @classmethod 69 | def INPUT_TYPES(self): 70 | return {"required": { 71 | "bbox": ("BBOX", {"forceInput": True}), 72 | }, 73 | "optional": { 74 | "is_xywh": ("BOOLEAN", {"default": False}), 75 | "to_xywh": ("BOOLEAN", {"default": False}) 76 | } 77 | } 78 | 79 | RETURN_TYPES = (any_type,) 80 | RETURN_NAMES = ("bbox", ) 81 | 82 | FUNCTION = "convert" 83 | 84 | OUTPUT_NODE = False 85 | CATEGORY = "EasyApi/Bbox" 86 | 87 | INPUT_IS_LIST = False 88 | OUTPUT_IS_LIST = (False, ) 89 | DESCRIPTION = "可以把bbox转换为(x1,y1,x2,y2)或(x,y,w,h),返回任意类型,配合其它bbox节点使用\n is_xywh表示输入的bbox的格式是(x,y,w,h)还是(x,y,x1,y1)。\n to_xywh表示返回的bbox的格式是(x,y,w,h)还是(x,y,x1,y1)。" 90 | 91 | def convert(self, bbox, is_xywh=False, to_xywh=False): 92 | if is_xywh: 93 | x, y, w, h = bbox 94 | else: 95 | x, y, x_1, y_1 = bbox 96 | w = x_1 - x 97 | h = y_1 - y 98 | if to_xywh: 99 | return ((x, y, w, h),) 100 | else: 101 | return ((x, y, x+w, y+h),) 102 | 103 | 104 | class BboxesToBboxes: 105 | @classmethod 106 | def INPUT_TYPES(self): 107 | return {"required": { 108 | "bboxes": ("BBOX", {"forceInput": True}), 109 | }, 110 | "optional": { 111 | "is_xywh": ("BOOLEAN", {"default": False}), 112 | "to_xywh": ("BOOLEAN", {"default": False}) 113 | } 114 | } 115 | 116 | RETURN_TYPES = (any_type,) 117 | RETURN_NAMES = ("bbox", ) 118 | 119 | FUNCTION = "convert" 120 | 121 | OUTPUT_NODE = False 122 | CATEGORY = "EasyApi/Bbox" 123 | 124 | INPUT_IS_LIST = False 125 | OUTPUT_IS_LIST = (False, ) 126 | DESCRIPTION = "可以把bbox转换为(x1,y1,x2,y2)或(x,y,w,h),返回任意类型,配合其它bbox节点使用\n is_xywh表示输入的bbox的格式是(x,y,w,h)还是(x,y,x1,y1)。\n to_xywh表示返回的bbox的格式是(x,y,w,h)还是(x,y,x1,y1)。" 127 | 128 | def convert(self, bboxes, is_xywh=False, to_xywh=False): 129 | new_bboxes = list() 130 | for bbox in bboxes: 131 | if is_xywh: 132 | x, y, w, h = bbox 133 | else: 134 | x, y, x_1, y_1 = bbox 135 | w = x_1 - x 136 | h = y_1 - y 137 | if to_xywh: 138 | new_bboxes.append((x, y, w, h)) 139 | else: 140 | new_bboxes.append((x, y, x+w, y+h)) 141 | return (new_bboxes,) 142 | 143 | 144 | class SelectBbox: 145 | @classmethod 146 | def INPUT_TYPES(self): 147 | return { 148 | "required": { 149 | "index": ('INT', {'default': 0, 'step': 1, 'min': 0, 'max': 50}), 150 | }, 151 | "optional": { 152 | "bboxes": ('BBOX', {'forceInput': True}), 153 | "bboxes_json": ('STRING', {'forceInput': True}), 154 | } 155 | } 156 | 157 | RETURN_TYPES = ("BBOX",) 158 | RETURN_NAMES = ("bbox",) 159 | 160 | FUNCTION = "select" 161 | 162 | OUTPUT_NODE = False 163 | CATEGORY = "EasyApi/Bbox" 164 | 165 | # INPUT_IS_LIST = False 166 | # OUTPUT_IS_LIST = (False, False) 167 | DESCRIPTION = "根据索引过滤" 168 | 169 | def select(self, index, bboxes=None, bboxes_json=None): 170 | if bboxes is None: 171 | if bboxes_json is not None: 172 | _bboxes = JSONDecoder().decode(bboxes_json) 173 | if len(_bboxes) > index: 174 | return (_bboxes[index], ) 175 | if isinstance(bboxes, list) and len(bboxes) > index: 176 | return (bboxes[index], ) 177 | return (None, ) 178 | 179 | 180 | class SelectBboxes: 181 | @classmethod 182 | def INPUT_TYPES(self): 183 | return { 184 | "required": { 185 | "index": ('STRING', {'default': "0"}), 186 | }, 187 | "optional": { 188 | "bboxes": ('BBOX', {'forceInput': True}), 189 | "bboxes_json": ('STRING', {'forceInput': True}), 190 | } 191 | } 192 | 193 | RETURN_TYPES = ("BBOX",) 194 | RETURN_NAMES = ("bboxes",) 195 | 196 | FUNCTION = "select" 197 | 198 | OUTPUT_NODE = False 199 | CATEGORY = "EasyApi/Bbox" 200 | 201 | # INPUT_IS_LIST = False 202 | # OUTPUT_IS_LIST = (False, False) 203 | DESCRIPTION = "根据索引(支持逗号分隔)过滤" 204 | 205 | def select(self, index, bboxes=None, bboxes_json=None): 206 | indices = [int(i) for i in index.split(",")] 207 | if bboxes is None: 208 | if bboxes_json is not None: 209 | _bboxes = JSONDecoder().decode(bboxes_json) 210 | filtered_bboxes = [_bboxes[i] for i in indices if 0 <= i < len(_bboxes)] 211 | return (filtered_bboxes, ) 212 | if isinstance(bboxes, list): 213 | filtered_bboxes = [bboxes[i] for i in indices if 0 <= i < len(bboxes)] 214 | return (filtered_bboxes,) 215 | return (None, ) 216 | 217 | 218 | class CropImageByBbox: 219 | @classmethod 220 | def INPUT_TYPES(cls): 221 | return { 222 | "required": { 223 | "image": ("IMAGE",), 224 | "bbox": ("BBOX",), 225 | "margin": ("INT", {"default": 16, "tooltip": "bbox矩形区域向外扩张的像素距离"}), 226 | } 227 | } 228 | 229 | RETURN_TYPES = ("IMAGE", "MASK", "BBOX", "INT", "INT") 230 | RETURN_NAMES = ("crop_image", "mask", "crop_bbox", "w", "h") 231 | FUNCTION = "crop" 232 | CATEGORY = "EasyApi/Bbox" 233 | DESCRIPTION = "根据bbox区域裁剪图片。 bbox的格式是左上角和右下角坐标: [x,y,x1,y1]" 234 | 235 | def crop(self, image: torch.Tensor, bbox, margin): 236 | x, y, x1, y1 = bbox 237 | w = x1 - x 238 | h = y1 - y 239 | image_height = image.shape[1] 240 | image_width = image.shape[2] 241 | # 左上角坐标 242 | x = min(x, image_width) 243 | y = min(y, image_height) 244 | # 右下角坐标 245 | to_x = min(w + x + margin, image_width) 246 | to_y = min(h + y + margin, image_height) 247 | # 防止越界 248 | x = max(0, x - margin) 249 | y = max(0, y - margin) 250 | to_x = max(0, to_x) 251 | to_y = max(0, to_y) 252 | # 按区域截取图片 253 | crop_img = image[:, y:to_y, x:to_x, :] 254 | new_bbox = (x, y, to_x, to_y) 255 | # 创建与image相同大小的全零张量作为遮罩 256 | mask = torch.zeros((image_height, image_width), dtype=torch.uint8) # 使用uint8类型 257 | # 在mask上设置new_bbox区域为1 258 | mask[new_bbox[1]:new_bbox[3], new_bbox[0]:new_bbox[2]] = 1 259 | # 如果需要转换为浮点数,并且增加一个通道维度, 形状变为 (1, height, width) 260 | mask_tensor = mask.unsqueeze(0) 261 | return crop_img, mask_tensor, new_bbox, to_x - x, to_y - y, 262 | 263 | 264 | class CropTargetSizeImageByBbox: 265 | @classmethod 266 | def INPUT_TYPES(cls): 267 | return { 268 | "required": { 269 | "image": ("IMAGE",), 270 | "bbox": ("BBOX",{"forceInput": True, "tooltip": "参考区域坐标"}), 271 | "width": ("INT", {"default": 512, "min": 1, "max": nodes.MAX_RESOLUTION, "step": 1, "tooltip": "目标宽度"}), 272 | "height": ("INT", {"default": 512, "min": 1, "max": nodes.MAX_RESOLUTION, "step": 1, "tooltip": "目标高度"}), 273 | "contain": ("BOOLEAN", {"default": False, "tooltip": "是否始终包含bbox完整区域"}), 274 | } 275 | } 276 | 277 | RETURN_TYPES = ("IMAGE", "MASK", "BBOX", "INT", "INT") 278 | RETURN_NAMES = ("crop_image", "mask", "crop_bbox", "w", "h") 279 | FUNCTION = "crop" 280 | CATEGORY = "EasyApi/Bbox" 281 | DESCRIPTION = "根据bbox区域中心裁剪指定大小图片。 bbox的格式是左上角和右下角坐标: [x,y,x1,y1]" 282 | 283 | def calc_area(self, image_width, image_height, rect_top_left, rect_bottom_right, w, h): 284 | """ 285 | 以给定的矩形中心点为中心计算指定宽高的矩形框坐标 286 | Args: 287 | image_width: 图片高度 288 | image_height: 图片宽度 289 | rect_top_left: 矩形框左上角坐标 290 | rect_bottom_right: 矩形框右下角坐标 291 | w: 目标宽度 292 | h: 目标高度 293 | 294 | Returns: 295 | 296 | """ 297 | # 计算矩形的宽和高 298 | x, y = rect_top_left 299 | x1, y1 = rect_bottom_right 300 | 301 | # 否则,计算矩形的中心(取整) 302 | center_x = (x + x1) // 2 303 | center_y = (y + y1) // 2 304 | left_w = w // 2 305 | right_w = w - left_w 306 | top_h = h // 2 307 | bottom_h = h - top_h 308 | 309 | # 计算新的坐标 310 | new_top_left_x = max(0, center_x - left_w) 311 | new_top_left_y = max(0, center_y - top_h) 312 | new_bottom_right_x = min(image_width, center_x + right_w) 313 | new_bottom_right_y = min(image_height, center_y + bottom_h) 314 | 315 | # 如果坐标越界,调整坐标 316 | if new_top_left_x == 0: 317 | # 左边可能超过边界了,尝试把左边超出部分加到右边 318 | new_bottom_right_x = min(image_width, new_bottom_right_x + (left_w - center_x)) 319 | elif new_bottom_right_x == image_width: 320 | # 右边可能超过边界了,尝试把右边超出部分加到左边 321 | new_top_left_x = max(0, new_top_left_x - (center_x + left_w - image_width)) 322 | 323 | if new_top_left_y == 0: 324 | # 上边可能超过边界了,尝试把上边超出部分加到下边 325 | new_bottom_right_y = min(image_height, new_bottom_right_y + (top_h - center_y)) 326 | elif new_bottom_right_y == image_height: 327 | # 下边可能超过边界了,尝试把下边超出部分加到上边 328 | new_top_left_y = max(0, new_top_left_y - (center_y + top_h - image_height)) 329 | 330 | return new_top_left_x, new_top_left_y, new_bottom_right_x, new_bottom_right_y 331 | 332 | def crop(self, image: torch.Tensor, bbox, width, height, contain): 333 | x, y, x1, y1 = bbox 334 | image_height = image.shape[1] 335 | image_width = image.shape[2] 336 | 337 | new_x, new_y, to_x, to_y = self.calc_area(image_width, image_height, (x, y), (x1, y1), width, height) 338 | 339 | if contain: 340 | new_x = min(new_x, x) 341 | new_y = min(new_y, y) 342 | to_x = max(to_x, x1) 343 | to_y = max(to_y, y1) 344 | # 按区域截取图片 345 | crop_img = image[:, new_y:to_y, new_x:to_x, :] 346 | new_bbox = (new_x, new_y, to_x, to_y) 347 | # 创建与image相同大小的全零张量作为遮罩 348 | mask = torch.zeros((image_height, image_width), dtype=torch.uint8) # 使用uint8类型 349 | # 在mask上设置new_bbox区域为1 350 | mask[new_bbox[1]:new_bbox[3], new_bbox[0]:new_bbox[2]] = 1 351 | # 如果需要转换为浮点数,并且增加一个通道维度, 形状变为 (1, height, width) 352 | mask_tensor = mask.unsqueeze(0) 353 | return crop_img, mask_tensor, new_bbox, to_x - new_x, to_y - new_y, 354 | 355 | 356 | class MaskByBboxes: 357 | @classmethod 358 | def INPUT_TYPES(cls): 359 | return { 360 | "required": { 361 | "image": ("IMAGE",), 362 | "bboxes": ("BBOX",), 363 | } 364 | } 365 | 366 | RETURN_TYPES = ("MASK", ) 367 | RETURN_NAMES = ("mask", ) 368 | FUNCTION = "crop" 369 | CATEGORY = "EasyApi/Bbox" 370 | DESCRIPTION = "根据bboxes生成遮罩, bboxes格式是(x, y, w, h)" 371 | 372 | def crop(self, image: torch.Tensor, bboxes): 373 | image_height = image.shape[1] 374 | image_width = image.shape[2] 375 | 376 | # 创建与image相同大小的全零张量作为遮罩 377 | mask = torch.zeros((image_height, image_width), dtype=torch.uint8) 378 | # 在mask上设置new_bbox区域为1 379 | for bbox in bboxes: 380 | x, y, w, h = bbox 381 | mask[y:y+h, x:x+w] = 1 382 | # 如果需要转换为浮点数,并且增加一个通道维度, 形状变为 (1, height, width) 383 | mask_tensor = mask.unsqueeze(0) 384 | return mask_tensor, 385 | 386 | 387 | NODE_CLASS_MAPPINGS = { 388 | "BboxToCropData": BboxToCropData, 389 | "BboxToBbox": BboxToBbox, 390 | "BboxesToBboxes": BboxesToBboxes, 391 | "SelectBbox": SelectBbox, 392 | "SelectBboxes": SelectBboxes, 393 | "CropImageByBbox": CropImageByBbox, 394 | "MaskByBboxes": MaskByBboxes, 395 | "CropTargetSizeImageByBbox": CropTargetSizeImageByBbox, 396 | } 397 | 398 | 399 | NODE_DISPLAY_NAME_MAPPINGS = { 400 | "BboxToCropData": "BboxToCropData", 401 | "BboxToBbox": "BboxToBbox", 402 | "BboxesToBboxes": "BboxesToBboxes", 403 | "SelectBbox": "SelectBbox", 404 | "SelectBboxes": "SelectBboxes", 405 | "CropImageByBbox": "CropImageByBbox", 406 | "MaskByBboxes": "MaskByBboxes", 407 | "CropTargetSizeImageByBbox": "CropTargetSizeImageByBbox", 408 | } 409 | -------------------------------------------------------------------------------- /easyapi/DetectNode.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from PIL import Image 4 | from json import JSONEncoder 5 | import numpy as np 6 | 7 | from .util import tensor_to_pil, pil_to_tensor, hex_to_rgba 8 | 9 | import folder_paths 10 | 11 | 12 | class InsightFaceBBOXDetect: 13 | def __init__(self): 14 | self.models = {} 15 | 16 | @classmethod 17 | def INPUT_TYPES(self): 18 | return { 19 | "required": { 20 | "image": ('IMAGE', {}), 21 | "shape": (['rectangle', 'circle', ], {'default': 'rectangle'}), 22 | "shape_color": ('STRING', {'default': '#FF0000'}), 23 | "show_num": ("BOOLEAN", {'default': False}), 24 | }, 25 | "optional": { 26 | "num_color": ('STRING', {'default': '#FF0000'}), 27 | "num_pos": (['center', 'left-top', 'right-top', 'left-bottom', 'right-bottom', ], {}), 28 | "num_sort": (['origin', 'left-right', 'right-left', 'top-bottom', 'bottom-top', 'small-large', 'large-small'], {}), 29 | "INSIGHTFACE": ('INSIGHTFACE', {}) 30 | } 31 | } 32 | 33 | RETURN_TYPES = ("IMAGE", "STRING", "INT", "INSIGHTFACE",) 34 | RETURN_NAMES = ("bbox_image", "bbox", "face_size", "INSIGHTFACE",) 35 | 36 | FUNCTION = "detect" 37 | 38 | OUTPUT_NODE = False 39 | CATEGORY = "EasyApi/Detect" 40 | 41 | # INPUT_IS_LIST = False 42 | # OUTPUT_IS_LIST = (False, False) 43 | DESCRIPTION = "检测图片中的人脸,bbox是一个包含所有人脸的json字符串,格式是每个人脸区域的左上角和右下角角坐标: [[x1_1,y1_1,x1_2,y1_2],[x2_1,y2_1,x2_2,y2_2],...]" 44 | 45 | def detect(self, image, shape, shape_color, show_num, num_color='#FF0000', num_pos=None, num_sort=None, 46 | INSIGHTFACE=None): 47 | model = INSIGHTFACE 48 | import cv2 49 | if model is None: 50 | if 'insightface' not in self.models: 51 | from insightface.app import FaceAnalysis 52 | INSIGHTFACE_DIR = os.path.join(folder_paths.models_dir, "insightface") 53 | model = FaceAnalysis(name="buffalo_l", root=INSIGHTFACE_DIR, 54 | providers=['CUDAExecutionProvider', 'CPUExecutionProvider', ]) 55 | model.prepare(ctx_id=0, det_size=(640, 640)) 56 | self.models['insightface'] = model 57 | else: 58 | model = self.models['insightface'] 59 | 60 | img = cv2.cvtColor(np.array(tensor_to_pil(image)), cv2.COLOR_RGB2BGR) 61 | faces = model.get(img) 62 | if num_sort == 'reactor' or num_sort == 'left-right': 63 | faces = sorted(faces, key=lambda x: x.bbox[0]) 64 | if num_sort == "right-left": 65 | faces = sorted(faces, key=lambda x: x.bbox[0], reverse=True) 66 | if num_sort == "top-bottom": 67 | faces = sorted(faces, key=lambda x: x.bbox[1]) 68 | if num_sort == "bottom-top": 69 | faces = sorted(faces, key=lambda x: x.bbox[1], reverse=True) 70 | if num_sort == "small-large": 71 | faces = sorted(faces, key=lambda x: (x.bbox[2] - x.bbox[0]) * (x.bbox[3] - x.bbox[1])) 72 | if num_sort == "large-small": 73 | faces = sorted(faces, key=lambda x: (x.bbox[2] - x.bbox[0]) * (x.bbox[3] - x.bbox[1]), reverse=True) 74 | 75 | r, g, b, a = hex_to_rgba(shape_color) 76 | n_r, n_g, n_b, n_a = hex_to_rgba(num_color) 77 | 78 | img_with_bbox, bbox = draw_on(img, faces, shape=shape, show_num=show_num, num_pos=num_pos, shape_color=(b, g, r), font_color=(n_b, n_g, n_r)) 79 | img_with_bbox = Image.fromarray(cv2.cvtColor(img_with_bbox, cv2.COLOR_BGR2RGB)) 80 | 81 | bbox_json = JSONEncoder().encode(bbox) 82 | return pil_to_tensor(img_with_bbox), bbox_json, len(bbox), model 83 | 84 | 85 | def draw_on(img, faces, shape=None, show_num=False, num_pos=None, shape_color=(0, 0, 255), font_color=(0, 255, 0), font_scale=1): 86 | import cv2 87 | dimg = img.copy() 88 | bbox = [] 89 | for i in range(len(faces)): 90 | face = faces[i] 91 | box = face.bbox.astype(int) 92 | s_x = box[0] 93 | s_y = box[1] 94 | e_x = box[2] 95 | e_y = box[3] 96 | bbox.append(box.tolist()) 97 | if shape == 'rectangle': 98 | # (图片,长方形框左上角坐标, 长方形框右下角坐标, 颜色(BGR),粗细) 99 | cv2.rectangle(dimg, (s_x, s_y), (e_x, e_y), shape_color, 2) 100 | elif shape == 'circle': 101 | # img:输入的图片data 102 | # center:圆心位置 103 | # radius:圆的半径 104 | # color:圆的颜色 105 | # thickness:圆形轮廓的粗细(如果为正)。负厚度表示要绘制实心圆。 106 | # lineType: 圆边界的类型。cv2.LINE_AA--更平滑 107 | # shift:中心坐标和半径值中的小数位数。 108 | c_x = s_x + round((e_x - s_x) / 2) 109 | c_y = s_y + round((e_y - s_y) / 2) 110 | radius = round(pow(pow(e_x - s_x, 2) + pow(e_y - s_y, 2), 0.5)/2) 111 | cv2.circle(dimg, (c_x, c_y), radius, shape_color, thickness=2, lineType=cv2.LINE_AA) 112 | 113 | # if face.kps is not None: 114 | # kps = face.kps.astype(int) 115 | # #print(landmark.shape) 116 | # for l in range(kps.shape[0]): 117 | # color = (0, 0, 255) 118 | # if l == 0 or l == 3: 119 | # color = (0, 255, 0) 120 | # cv2.circle(dimg, (kps[l][0], kps[l][1]), 1, color, 2) 121 | if show_num is True: 122 | # 图片, 要添加的文字, 文字添加到图片上的位置, 字体的类型, 字体大小(font scale), 字体颜色, 字体粗细, 123 | # font_scale = 2 124 | thickness = 2 125 | # BGR 126 | # width和height是基于字体base line位置的长高,bottom是base line下方字体的高度,按css中文字对齐方式的思想理解 127 | text = '%d' % i 128 | (width, height), bottom = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, font_scale, thickness) 129 | offset_top_y = height + 4 130 | offset_bottom_y = bottom + 2 131 | offset_x = 2 132 | if num_pos == 'center': 133 | c_x = s_x + round((e_x - s_x) / 2) 134 | c_y = s_y + round((e_y - s_y) / 2) 135 | cv2.putText(dimg, text, (c_x - round(width / 2), c_y + round((height + bottom) / 2)), 136 | cv2.FONT_HERSHEY_COMPLEX, font_scale, font_color, thickness) 137 | elif num_pos == 'left-top': 138 | cv2.putText(dimg, text, (s_x + offset_x, s_y + offset_top_y), cv2.FONT_HERSHEY_COMPLEX, font_scale, 139 | font_color, thickness) 140 | pass 141 | elif num_pos == 'right-top': 142 | cv2.putText(dimg, text, (e_x - width - offset_x, s_y + offset_top_y), cv2.FONT_HERSHEY_COMPLEX, 143 | font_scale, 144 | font_color, thickness) 145 | elif num_pos == 'left-bottom': 146 | cv2.putText(dimg, text, (s_x + offset_x, e_y - offset_bottom_y), cv2.FONT_HERSHEY_COMPLEX, font_scale, 147 | font_color, thickness) 148 | elif num_pos == 'right-bottom': 149 | cv2.putText(dimg, text, (e_x - width - offset_x, e_y - offset_bottom_y), cv2.FONT_HERSHEY_COMPLEX, 150 | font_scale, font_color, thickness) 151 | 152 | # cv2.putText(dimg, '%s,%d' % (face.sex, face.age), (box[0], box[1]), cv2.FONT_HERSHEY_COMPLEX, 0.7, 153 | # (0, 255, 0), 1) 154 | 155 | # for key, value in face.items(): 156 | # if key.startswith('landmark_3d'): 157 | # # print(key, value.shape) 158 | # # print(value[0:10,:]) 159 | # lmk = np.round(value).astype(int) 160 | # for l in range(lmk.shape[0]): 161 | # color = (255, 0, 0) 162 | # cv2.circle(dimg, (lmk[l][0], lmk[l][1]), 1, color, 2) 163 | return dimg, bbox 164 | 165 | 166 | NODE_CLASS_MAPPINGS = { 167 | "InsightFaceBBOXDetect": InsightFaceBBOXDetect, 168 | } 169 | 170 | # A dictionary that contains the friendly/humanly readable titles for the nodes 171 | NODE_DISPLAY_NAME_MAPPINGS = { 172 | "InsightFaceBBOXDetect": "InsightFaceBBOXDetect", 173 | } 174 | -------------------------------------------------------------------------------- /easyapi/ForEachNode.py: -------------------------------------------------------------------------------- 1 | from comfy_execution.graph_utils import GraphBuilder, is_link 2 | from .util import any_type, find_max_suffix_number 3 | 4 | # 支持的最大参数个数 5 | NUM_FLOW_SOCKETS = 20 6 | 7 | 8 | class InnerIntMathOperation: 9 | @classmethod 10 | def INPUT_TYPES(cls): 11 | return { 12 | "required": { 13 | "a": ("INT", {"default": 0, "min": -0xffffffffffffffff, "max": 0xffffffffffffffff, "step": 1}), 14 | "b": ("INT", {"default": 0, "min": -0xffffffffffffffff, "max": 0xffffffffffffffff, "step": 1}), 15 | "operation": (["add", "subtract", "multiply", "divide", "modulo", "power"],), 16 | }, 17 | } 18 | 19 | RETURN_TYPES = ("INT",) 20 | FUNCTION = "calc" 21 | 22 | CATEGORY = "EasyApi/Logic" 23 | 24 | def calc(self, a, b, operation): 25 | if operation == "add": 26 | return (a + b,) 27 | elif operation == "subtract": 28 | return (a - b,) 29 | elif operation == "multiply": 30 | return (a * b,) 31 | elif operation == "divide": 32 | return (a // b,) 33 | elif operation == "modulo": 34 | return (a % b,) 35 | elif operation == "power": 36 | return (a ** b,) 37 | 38 | 39 | COMPARE_FUNCTIONS = { 40 | "a == b": lambda a, b: a == b, 41 | "a != b": lambda a, b: a != b, 42 | "a < b": lambda a, b: a < b, 43 | "a > b": lambda a, b: a > b, 44 | "a <= b": lambda a, b: a <= b, 45 | "a >= b": lambda a, b: a >= b, 46 | } 47 | 48 | 49 | class InnerIntCompare: 50 | @classmethod 51 | def INPUT_TYPES(s): 52 | compare_functions = list(COMPARE_FUNCTIONS.keys()) 53 | return { 54 | "required": { 55 | "a": ("INT", {"default": 0}), 56 | "b": ("INT", {"default": 0}), 57 | "comparison": (compare_functions, {"default": "a == b"}), 58 | }, 59 | } 60 | 61 | RETURN_TYPES = ("BOOLEAN",) 62 | RETURN_NAMES = ("boolean",) 63 | FUNCTION = "compare" 64 | CATEGORY = "EasyApi/Logic" 65 | 66 | def compare(self, a, b, comparison): 67 | return (COMPARE_FUNCTIONS[comparison](a, b),) 68 | 69 | 70 | class InnerLoopClose: 71 | def __init__(self): 72 | pass 73 | 74 | @classmethod 75 | def INPUT_TYPES(cls): 76 | inputs = { 77 | "required": { 78 | "flow_control": ("FLOW_CONTROL", {"rawLink": True}), 79 | "condition": ("BOOLEAN", {"forceInput": True}), 80 | }, 81 | "optional": { 82 | }, 83 | "hidden": { 84 | "dynprompt": "DYNPROMPT", 85 | "unique_id": "UNIQUE_ID", 86 | } 87 | } 88 | for i in range(NUM_FLOW_SOCKETS): 89 | inputs["optional"]["initial_value%d" % i] = ("*",) 90 | return inputs 91 | 92 | RETURN_TYPES = tuple([any_type] * NUM_FLOW_SOCKETS) 93 | RETURN_NAMES = tuple(["value%d" % i for i in range(NUM_FLOW_SOCKETS)]) 94 | FUNCTION = "while_loop_close" 95 | 96 | CATEGORY = "EasyApi/Logic" 97 | 98 | def explore_dependencies(self, node_id, dynprompt, upstream): 99 | node_info = dynprompt.get_node(node_id) 100 | if "inputs" not in node_info: 101 | return 102 | for k, v in node_info["inputs"].items(): 103 | if is_link(v): 104 | parent_id = v[0] 105 | if parent_id not in upstream: 106 | upstream[parent_id] = [] 107 | self.explore_dependencies(parent_id, dynprompt, upstream) 108 | upstream[parent_id].append(node_id) 109 | 110 | def collect_contained(self, node_id, upstream, contained): 111 | if node_id not in upstream: 112 | return 113 | for child_id in upstream[node_id]: 114 | if child_id not in contained: 115 | contained[child_id] = True 116 | self.collect_contained(child_id, upstream, contained) 117 | 118 | 119 | def while_loop_close(self, flow_control, condition, dynprompt=None, unique_id=None, **kwargs): 120 | if not condition: 121 | # We're done with the loop 122 | values = [] 123 | for i in range(NUM_FLOW_SOCKETS): 124 | values.append(kwargs.get("initial_value%d" % i, None)) 125 | return tuple(values) 126 | 127 | # We want to loop 128 | this_node = dynprompt.get_node(unique_id) 129 | upstream = {} 130 | # Get the list of all nodes between the open and close nodes 131 | self.explore_dependencies(unique_id, dynprompt, upstream) 132 | 133 | contained = {} 134 | open_node = flow_control[0] 135 | self.collect_contained(open_node, upstream, contained) 136 | contained[unique_id] = True 137 | contained[open_node] = True 138 | 139 | # We'll use the default prefix, but to avoid having node names grow exponentially in size, 140 | # we'll use "Recurse" for the name of the recursively-generated copy of this node. 141 | graph = GraphBuilder() 142 | for node_id in contained: 143 | original_node = dynprompt.get_node(node_id) 144 | node = graph.node(original_node["class_type"], "Recurse" if node_id == unique_id else node_id) 145 | node.set_override_display_id(node_id) 146 | for node_id in contained: 147 | original_node = dynprompt.get_node(node_id) 148 | node = graph.lookup_node("Recurse" if node_id == unique_id else node_id) 149 | for k, v in original_node["inputs"].items(): 150 | if is_link(v) and v[0] in contained: 151 | parent = graph.lookup_node(v[0]) 152 | node.set_input(k, parent.out(v[1])) 153 | else: 154 | node.set_input(k, v) 155 | new_open = graph.lookup_node(open_node) 156 | for i in range(NUM_FLOW_SOCKETS): 157 | key = "initial_value%d" % i 158 | new_open.set_input(key, kwargs.get(key, None)) 159 | my_clone = graph.lookup_node("Recurse") 160 | result = map(lambda x: my_clone.out(x), range(NUM_FLOW_SOCKETS)) 161 | return { 162 | "result": tuple(result), 163 | "expand": graph.finalize(), 164 | } 165 | 166 | 167 | def find_max_initial_value_number(kwargs, substring): 168 | return find_max_suffix_number(kwargs, substring) 169 | 170 | 171 | class ForEachOpen: 172 | @classmethod 173 | def INPUT_TYPES(cls): 174 | return { 175 | "required": { 176 | "total": ("INT", {"default": 1, "min": 1, "max": 1000, "step": 1, "tooltip": "总循环次数"}), 177 | }, 178 | "optional": { 179 | # 必须声明全部,否者循环时只有第一个值能正确传递 180 | "initial_value%d" % i: (any_type,) for i in range(1, NUM_FLOW_SOCKETS) 181 | }, 182 | "hidden": { 183 | "initial_value0": (any_type,) 184 | } 185 | } 186 | 187 | RETURN_TYPES = tuple(["FLOW_CONTROL", "INT", "INT"] + [any_type] * (NUM_FLOW_SOCKETS - 1)) 188 | RETURN_NAMES = tuple(["flow_control", "index", "total"] + ["value%d" % i for i in range(1, NUM_FLOW_SOCKETS)]) 189 | OUTPUT_TOOLTIPS = ("开始节点元信息", "循环索引值", "总循环次数,不宜太大,会影响到消息长度",) 190 | FUNCTION = "for_loop_open" 191 | 192 | CATEGORY = "EasyApi/Logic" 193 | 194 | def for_loop_open(self, total, **kwargs): 195 | 196 | graph = GraphBuilder() 197 | 198 | if "initial_value0" in kwargs: 199 | index = kwargs["initial_value0"] 200 | else: 201 | index = 0 202 | 203 | initial_value_num = find_max_initial_value_number(kwargs, "initial_value") 204 | 205 | # 好像没啥用 206 | # while_open = graph.node("WhileLoopOpen", condition=total, initial_value0=index, **{("initial_value%d" % i): kwargs.get("initial_value%d" % i, None) for i in range(1, initial_value_num + 1)}) 207 | 208 | outputs = [kwargs.get("initial_value%d" % i, None) for i in range(1, initial_value_num + 1)] 209 | return { 210 | "result": tuple(["stub", index, total] + outputs), 211 | "expand": graph.finalize(), 212 | } 213 | 214 | 215 | class ForEachClose: 216 | @classmethod 217 | def INPUT_TYPES(cls): 218 | return { 219 | "required": { 220 | "flow_control": ("FLOW_CONTROL", {"rawLink": True}), 221 | }, 222 | "optional": { 223 | # 必须声明全部,否者循环时只有第一个值能正确传递 224 | "initial_value%d" % i: (any_type, {"rawLink": True}) for i in range(1, NUM_FLOW_SOCKETS) 225 | }, 226 | } 227 | 228 | RETURN_TYPES = tuple([any_type] * (NUM_FLOW_SOCKETS-1)) 229 | RETURN_NAMES = tuple(["value%d" % i for i in range(1, NUM_FLOW_SOCKETS)]) 230 | FUNCTION = "for_loop_close" 231 | 232 | CATEGORY = "EasyApi/Logic" 233 | 234 | def for_loop_close(self, flow_control, **kwargs): 235 | graph = GraphBuilder() 236 | # ForEachOpen node id 237 | openNodeId = flow_control[0] 238 | # 计算索引, a传open节点的第3个输出参数,即index参数 239 | sub = graph.node(InnerIntMathOperation.__name__, operation="add", a=[openNodeId, 1], b=1) 240 | # 边界条件约束, b传open节点的第3个输出参数,即total参数 241 | cond = graph.node(InnerIntCompare.__name__, a=sub.out(0), b=[openNodeId, 2], comparison='a < b') 242 | # 构建循环传递参数 243 | initial_value_num = find_max_initial_value_number(kwargs, "initial_value") 244 | input_values = {("initial_value%d" % i): kwargs.get("initial_value%d" % i, None) for i in range(1, initial_value_num + 1)} 245 | while_close = graph.node(InnerLoopClose.__name__, 246 | flow_control=flow_control, 247 | condition=cond.out(0), 248 | initial_value0=sub.out(0), 249 | **input_values) 250 | return { 251 | "result": tuple([while_close.out(i) for i in range(1, initial_value_num + 1)]), 252 | "expand": graph.finalize(), 253 | } 254 | 255 | 256 | NODE_CLASS_MAPPINGS = { 257 | "InnerIntMathOperation": InnerIntMathOperation, 258 | "InnerIntCompare": InnerIntCompare, 259 | "InnerLoopClose": InnerLoopClose, 260 | "ForEachOpen": ForEachOpen, 261 | "ForEachClose": ForEachClose, 262 | } 263 | 264 | # A dictionary that contains the friendly/humanly readable titles for the nodes 265 | NODE_DISPLAY_NAME_MAPPINGS = { 266 | "InnerIntMathOperation": "InnerIntMathOperation", 267 | "InnerIntCompare": "InnerIntCompare", 268 | "InnerLoopClose": "InnerLoopClose", 269 | "ForEachOpen": "ForEachOpen", 270 | "ForEachClose": "ForEachClose", 271 | } 272 | -------------------------------------------------------------------------------- /easyapi/ImageNode.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import copy 3 | import io 4 | import os 5 | 6 | import numpy as np 7 | import torch 8 | from PIL import ImageOps, Image, ImageSequence 9 | 10 | import folder_paths 11 | import node_helpers 12 | from nodes import LoadImage 13 | from comfy.cli_args import args 14 | from PIL.PngImagePlugin import PngInfo 15 | import json 16 | from json import JSONEncoder, JSONDecoder 17 | from .util import tensor_to_pil, pil_to_tensor, base64_to_image, image_to_base64, read_image_from_url, check_directory 18 | 19 | 20 | class LoadImageFromURL: 21 | """ 22 | 从远程地址读取图片 23 | """ 24 | @classmethod 25 | def INPUT_TYPES(self): 26 | return {"required": { 27 | "urls": ("STRING", {"multiline": True, "default": "", "dynamicPrompts": False}), 28 | }, 29 | } 30 | 31 | RETURN_TYPES = ("IMAGE", "MASK") 32 | RETURN_NAMES = ("images", "masks") 33 | 34 | FUNCTION = "convert" 35 | 36 | CATEGORY = "EasyApi/Image" 37 | 38 | # INPUT_IS_LIST = False 39 | OUTPUT_IS_LIST = (True, True,) 40 | 41 | def convert(self, urls): 42 | urls = urls.splitlines() 43 | images = [] 44 | masks = [] 45 | for url in urls: 46 | if not url.strip().isspace(): 47 | i = read_image_from_url(url.strip()) 48 | i = ImageOps.exif_transpose(i) 49 | if i.mode == 'I': 50 | i = i.point(lambda i: i * (1 / 255)) 51 | image = i.convert("RGB") 52 | image = pil_to_tensor(image) 53 | images.append(image) 54 | if 'A' in i.getbands(): 55 | mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0 56 | mask = 1. - torch.from_numpy(mask) 57 | else: 58 | mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu") 59 | masks.append(mask.unsqueeze(0)) 60 | 61 | return (images, masks, ) 62 | 63 | 64 | class LoadMaskFromURL: 65 | """ 66 | 从远程地址读取图片 67 | """ 68 | _color_channels = ["red", "green", "blue", "alpha"] 69 | 70 | @classmethod 71 | def INPUT_TYPES(self): 72 | return { 73 | "required": { 74 | "urls": ("STRING", {"multiline": True, "default": "", "dynamicPrompts": False}), 75 | "channel": (self._color_channels, {"default": self._color_channels[0]}), 76 | }, 77 | } 78 | 79 | RETURN_TYPES = ("MASK", ) 80 | RETURN_NAMES = ("masks", ) 81 | 82 | FUNCTION = "convert" 83 | 84 | CATEGORY = "EasyApi/Image" 85 | 86 | # INPUT_IS_LIST = False 87 | OUTPUT_IS_LIST = (True, True,) 88 | 89 | def convert(self, urls, channel=_color_channels[0]): 90 | urls = urls.splitlines() 91 | masks = [] 92 | for url in urls: 93 | if not url.strip().isspace(): 94 | i = read_image_from_url(url.strip()) 95 | # 下面代码参考LoadImage 96 | i = ImageOps.exif_transpose(i) 97 | if i.getbands() != ("R", "G", "B", "A"): 98 | i = i.convert("RGBA") 99 | c = channel[0].upper() 100 | if c in i.getbands(): 101 | mask = np.array(i.getchannel(c)).astype(np.float32) / 255.0 102 | mask = torch.from_numpy(mask) 103 | if c == 'A': 104 | mask = 1. - mask 105 | else: 106 | mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu") 107 | masks.append(mask.unsqueeze(0)) 108 | return (masks,) 109 | 110 | 111 | class Base64ToImage: 112 | """ 113 | 图片的base64格式还原成图片的张量 114 | """ 115 | @classmethod 116 | def INPUT_TYPES(self): 117 | return {"required": { 118 | "base64Images": ("STRING", {"multiline": True, "default": "[\"\"]", "dynamicPrompts": False}), 119 | }, 120 | } 121 | 122 | RETURN_TYPES = ("IMAGE", "MASK") 123 | # RETURN_NAMES = ("image", "mask") 124 | 125 | FUNCTION = "convert" 126 | 127 | CATEGORY = "EasyApi/Image" 128 | 129 | # INPUT_IS_LIST = False 130 | OUTPUT_IS_LIST = (True, True) 131 | 132 | def convert(self, base64Images): 133 | # print(base64Image) 134 | base64ImageJson = JSONDecoder().decode(s=base64Images) 135 | images = [] 136 | masks = [] 137 | for base64Image in base64ImageJson: 138 | i = base64_to_image(base64Image) 139 | # 下面代码参考LoadImage 140 | i = ImageOps.exif_transpose(i) 141 | image = i.convert("RGB") 142 | image = np.array(image).astype(np.float32) / 255.0 143 | image = torch.from_numpy(image)[None, ] 144 | if 'A' in i.getbands(): 145 | mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0 146 | mask = 1. - torch.from_numpy(mask) 147 | else: 148 | mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu") 149 | images.append(image) 150 | masks.append(mask.unsqueeze(0)) 151 | 152 | return (images, masks,) 153 | 154 | 155 | class ImageToBase64Advanced: 156 | def __init__(self): 157 | self.imageType = "image" 158 | 159 | @classmethod 160 | def INPUT_TYPES(self): 161 | return {"required": { 162 | "images": ("IMAGE",), 163 | "imageType": (["image", "mask"], {"default": "image"}), 164 | }, 165 | "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}, 166 | } 167 | 168 | RETURN_TYPES = ("STRING",) 169 | RETURN_NAMES = ("base64Images",) 170 | 171 | FUNCTION = "convert" 172 | # 作为输出节点,返回数据格式是{"ui": {output_name:value}, "result": (value,)} 173 | # ui中是websocket返回给前端的内容,result是py执行传给下个节点用的 174 | OUTPUT_NODE = True 175 | 176 | CATEGORY = "EasyApi/Image" 177 | 178 | # INPUT_IS_LIST = False 179 | # OUTPUT_IS_LIST = (False,False,) 180 | 181 | def convert(self, images, imageType=None, prompt=None, extra_pnginfo=None): 182 | if imageType is None: 183 | imageType = self.imageType 184 | 185 | result = list() 186 | for i in images: 187 | img = tensor_to_pil(i) 188 | metadata = None 189 | if not args.disable_metadata: 190 | metadata = PngInfo() 191 | if prompt is not None: 192 | newPrompt = copy.deepcopy(prompt) 193 | for idx in newPrompt: 194 | node = newPrompt[idx] 195 | if node['class_type'] == 'Base64ToImage' or node['class_type'] == 'Base64ToMask': 196 | node['inputs']['base64Images'] = "" 197 | metadata.add_text("prompt", json.dumps(newPrompt)) 198 | if extra_pnginfo is not None: 199 | for x in extra_pnginfo: 200 | metadata.add_text(x, json.dumps(extra_pnginfo[x])) 201 | 202 | # 将图像数据编码为Base64字符串 203 | encoded_image = image_to_base64(img, pnginfo=metadata) 204 | result.append(encoded_image) 205 | base64Images = JSONEncoder().encode(result) 206 | # print(images) 207 | return {"ui": {"base64Images": result, "imageType": [imageType]}, "result": (base64Images,)} 208 | 209 | 210 | class ImageToBase64(ImageToBase64Advanced): 211 | def __init__(self): 212 | self.imageType = "image" 213 | 214 | @classmethod 215 | def INPUT_TYPES(self): 216 | return {"required": { 217 | "images": ("IMAGE",), 218 | }, 219 | "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}, 220 | } 221 | 222 | 223 | class MaskImageToBase64(ImageToBase64): 224 | def __init__(self): 225 | self.imageType = "mask" 226 | 227 | 228 | class MaskToBase64Image(MaskImageToBase64): 229 | @classmethod 230 | def INPUT_TYPES(s): 231 | return { 232 | "required": { 233 | "mask": ("MASK",), 234 | } 235 | } 236 | 237 | CATEGORY = "EasyApi/Image" 238 | 239 | RETURN_TYPES = ("STRING",) 240 | FUNCTION = "mask_to_base64image" 241 | 242 | def mask_to_base64image(self, mask): 243 | """将一个二维的掩码张量扩展为一个四维的彩色图像张量。具体的步骤如下: 244 | 245 | 第一行,使用 torch.reshape 函数,将掩码张量的形状改变为(-1, 1, mask.shape[-2], mask.shape[-1]), 246 | 其中 - 1 表示自动推断该维度的大小,1 表示增加一个新的维度,mask.shape[-2] 和 mask.shape[-1] 表示保持原来的最后两个维度不变。 247 | 这样,掩码张量就变成了一个四维的张量,其中第二个维度只有一个通道。 248 | 249 | 第二行,使用 torch.movedim 函数,将掩码张量的第二个维度(通道维度)移动到最后一个维度的位置,即将形状为(-1, 1, mask.shape[-2], mask.shape[-1]) 250 | 的张量变为(-1, mask.shape[-2], mask.shape[-1], 1) 的张量。这样,掩码张量就变成了一个符合图像格式的张量,其中最后一个维度表示通道数。 251 | 252 | 第三行,使用 torch.Tensor.expand 函数,将掩码张量的最后一个维度(通道维度)扩展为 3,即将形状为(-1, mask.shape[-2], mask.shape[-1], 1) 的张量变为(-1, mask.shape[-2], mask.shape[-1], 3) 的张量。这样,掩码张量就变成了一个彩色图像张量,其中最后一个维度表示红、绿、蓝三个通道。 253 | 254 | 这段代码的结果是一个与原来的掩码张量相同元素的彩色图像张量,表示掩码的颜色 255 | """ 256 | images = mask.reshape((-1, 1, mask.shape[-2], mask.shape[-1])).movedim(1, -1).expand(-1, -1, -1, 3) 257 | return super().convert(images) 258 | 259 | 260 | class MaskToBase64(MaskImageToBase64): 261 | @classmethod 262 | def INPUT_TYPES(s): 263 | return { 264 | "required": { 265 | "mask": ("MASK",), 266 | } 267 | } 268 | 269 | CATEGORY = "EasyApi/Image" 270 | 271 | RETURN_TYPES = ("STRING",) 272 | FUNCTION = "mask_to_base64image" 273 | 274 | def mask_to_base64image(self, mask): 275 | return super().convert(mask) 276 | 277 | 278 | class Base64ToMask: 279 | """ 280 | mask的base64图片还原成mask的张量 281 | """ 282 | _color_channels = ["red", "green", "blue", "alpha"] 283 | @classmethod 284 | def INPUT_TYPES(s): 285 | return { 286 | "required": { 287 | # "base64Images": ("STRING", {"forceInput": True}), 288 | "base64Images": ("STRING", {"multiline": True, "default": "[\"\"]", "dynamicPrompts": False}), 289 | "channel": (s._color_channels, {"default": s._color_channels[0]}), } 290 | } 291 | 292 | CATEGORY = "EasyApi/Image" 293 | 294 | RETURN_TYPES = ("MASK",) 295 | FUNCTION = "base64image_to_mask" 296 | 297 | def base64image_to_mask(self, base64Images, channel=_color_channels[0]): 298 | base64ImageJson = JSONDecoder().decode(s=base64Images) 299 | for base64Image in base64ImageJson: 300 | i = base64_to_image(base64Image) 301 | # 下面代码参考LoadImage 302 | i = ImageOps.exif_transpose(i) 303 | if i.getbands() != ("R", "G", "B", "A"): 304 | i = i.convert("RGBA") 305 | mask = None 306 | c = channel[0].upper() 307 | if c in i.getbands(): 308 | mask = np.array(i.getchannel(c)).astype(np.float32) / 255.0 309 | mask = torch.from_numpy(mask) 310 | if c == 'A': 311 | mask = 1. - mask 312 | else: 313 | mask = torch.zeros((64,64), dtype=torch.float32, device="cpu") 314 | 315 | return (mask.unsqueeze(0),) 316 | 317 | 318 | class LoadImageToBase64(LoadImage): 319 | RETURN_TYPES = ("STRING", "IMAGE", "MASK", ) 320 | RETURN_NAMES = ("base64Images", "IMAGE", "MASK", ) 321 | 322 | FUNCTION = "convert" 323 | OUTPUT_NODE = True 324 | 325 | CATEGORY = "EasyApi/Image" 326 | 327 | # INPUT_IS_LIST = False 328 | # OUTPUT_IS_LIST = (False,False,) 329 | 330 | def convert(self, image): 331 | img, mask = self.load_image(image) 332 | 333 | i = tensor_to_pil(img) 334 | # 创建一个BytesIO对象,用于临时存储图像数据 335 | image_data = io.BytesIO() 336 | 337 | # 将图像保存到BytesIO对象中,格式为PNG 338 | i.save(image_data, format='PNG') 339 | 340 | # 将BytesIO对象的内容转换为字节串 341 | image_data_bytes = image_data.getvalue() 342 | 343 | # 将图像数据编码为Base64字符串 344 | encoded_image = "[\"data:image/png;base64," + base64.b64encode(image_data_bytes).decode('utf-8') + "\"]" 345 | return encoded_image, img, mask 346 | 347 | 348 | class LoadImageFromLocalPath: 349 | @classmethod 350 | def INPUT_TYPES(s): 351 | return {"required": 352 | { 353 | "image_path": ("STRING", {"default": ""},) 354 | }, 355 | } 356 | 357 | CATEGORY = "EasyApi/Image" 358 | 359 | RETURN_TYPES = ("IMAGE", "MASK") 360 | FUNCTION = "load_image" 361 | def load_image(self, image_path): 362 | 363 | img = node_helpers.pillow(Image.open, image_path) 364 | 365 | output_images = [] 366 | output_masks = [] 367 | w, h = None, None 368 | 369 | excluded_formats = ['MPO'] 370 | # 遍历图像的每一帧 371 | for i in ImageSequence.Iterator(img): 372 | # 旋转图像 373 | i = node_helpers.pillow(ImageOps.exif_transpose, i) 374 | 375 | if i.mode == 'I': 376 | i = i.point(lambda i: i * (1 / 255)) 377 | # 将图像转换为RGB格式 378 | image = i.convert("RGB") 379 | 380 | if len(output_images) == 0: 381 | w = image.size[0] 382 | h = image.size[1] 383 | 384 | if image.size[0] != w or image.size[1] != h: 385 | continue 386 | 387 | # 将图像转换为浮点数组 (H,W,Channel) 388 | image = np.array(image).astype(np.float32) / 255.0 389 | # 先把图片转成3维张量,并再在最前面添加一个维度,变成4维(1, H, W,Channel) 390 | image = torch.from_numpy(image)[None,] 391 | # 如果图像包含alpha通道,则将其转换为掩码 392 | if 'A' in i.getbands(): 393 | # 计算后结果数组中透明像素会是0 394 | mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0 395 | # 把数组中透明像素设为1 396 | mask = 1. - torch.from_numpy(mask) 397 | else: 398 | # 否则,创建一个64x64的零张量作为掩码 399 | mask = torch.zeros((64, 64,), dtype=torch.float32, device="cpu") 400 | # 将图像和掩码添加到输出列表中 401 | output_images.append(image) 402 | output_masks.append(mask.unsqueeze(0)) 403 | 404 | if len(output_images) > 1 and img.format not in excluded_formats: 405 | # 如果有多个图像,则将它们按维度0拼接在一起 406 | output_image = torch.cat(output_images, dim=0) 407 | output_mask = torch.cat(output_masks, dim=0) 408 | # 否则,返回单个图像和掩码 409 | else: 410 | output_image = output_images[0] 411 | output_mask = output_masks[0] 412 | # 返回输出图像和掩码 413 | return (output_image, output_mask) 414 | 415 | 416 | class LoadMaskFromLocalPath: 417 | _color_channels = ["alpha", "red", "green", "blue"] 418 | @classmethod 419 | def INPUT_TYPES(s): 420 | return {"required": 421 | { 422 | "image_path": ("STRING", {"default": ""}), 423 | "channel": (s._color_channels, ), 424 | } 425 | } 426 | 427 | CATEGORY = "EasyApi/Image" 428 | 429 | RETURN_TYPES = ("MASK",) 430 | FUNCTION = "load_mask" 431 | def load_mask(self, image_path, channel): 432 | i = node_helpers.pillow(Image.open, image_path) 433 | i = node_helpers.pillow(ImageOps.exif_transpose, i) 434 | if i.getbands() != ("R", "G", "B", "A"): 435 | if i.mode == 'I': 436 | i = i.point(lambda i: i * (1 / 255)) 437 | i = i.convert("RGBA") 438 | mask = None 439 | c = channel[0].upper() 440 | if c in i.getbands(): 441 | mask = np.array(i.getchannel(c)).astype(np.float32) / 255.0 442 | mask = torch.from_numpy(mask) 443 | if c == 'A': 444 | mask = 1. - mask 445 | else: 446 | mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu") 447 | return (mask.unsqueeze(0),) 448 | 449 | 450 | class SaveImagesWithoutOutput: 451 | """ 452 | 保存图片,非输出节点 453 | """ 454 | 455 | def __init__(self): 456 | self.compress_level = 4 457 | 458 | @classmethod 459 | def INPUT_TYPES(self): 460 | return { 461 | "required": { 462 | "images": ("IMAGE",), 463 | "filename_prefix": ("STRING", {"default": "ComfyUI", 464 | "tooltip": "要保存的文件的前缀。支持的占位符:%width% %height% %year% %month% %day% %hour% %minute% %second%"}), 465 | "output_dir": ("STRING", {"default": "", "tooltip": "目标目录(绝对路径),不会自动创建(可配置允许),若为空,存放到output目录"}), 466 | }, 467 | "optional": { 468 | "addMetadata": ("BOOLEAN", {"default": False, "label_on": "True", "label_off": "False"}), 469 | }, 470 | "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}, 471 | } 472 | 473 | RETURN_TYPES = ("STRING", ) 474 | RETURN_NAMES = ("file_paths",) 475 | OUTPUT_TOOLTIPS = ("保存的图片路径列表",) 476 | 477 | FUNCTION = "save_images" 478 | 479 | CATEGORY = "EasyApi/Image" 480 | 481 | DESCRIPTION = "保存图像到指定目录,不自动创建目标目录(可配置允许),可根据返回的文件路径进行后续操作,此节点为非输出节点,适合批量处理和用于惰性求值的前置节点" 482 | OUTPUT_NODE = False 483 | 484 | def save_images(self, images, output_dir, filename_prefix="ComfyUI", addMetadata=False, prompt=None, extra_pnginfo=None): 485 | imageList = list() 486 | if not isinstance(images, list): 487 | imageList.append(images) 488 | else: 489 | imageList = images 490 | 491 | if output_dir is None or len(output_dir.strip()) == 0: 492 | output_dir = folder_paths.get_output_directory() 493 | 494 | output_dir = check_directory(output_dir) 495 | 496 | results = list() 497 | for (index, images) in enumerate(imageList): 498 | for (batch_number, image) in enumerate(images): 499 | full_output_folder, filename, counter, subfolder, curr_filename_prefix = folder_paths.get_save_image_path( 500 | filename_prefix, output_dir, image.shape[1], image.shape[0]) 501 | img = tensor_to_pil(image) 502 | metadata = None 503 | if not args.disable_metadata and addMetadata: 504 | metadata = PngInfo() 505 | if prompt is not None: 506 | metadata.add_text("prompt", json.dumps(prompt)) 507 | if extra_pnginfo is not None: 508 | for x in extra_pnginfo: 509 | metadata.add_text(x, json.dumps(extra_pnginfo[x])) 510 | 511 | filename_with_batch_num = filename.replace("%batch_num%", str(batch_number)) 512 | file = f"{filename_with_batch_num}_{counter:05}_.png" 513 | image_save_path = os.path.join(full_output_folder, file) 514 | img.save(image_save_path, pnginfo=metadata, compress_level=self.compress_level) 515 | results.append(image_save_path) 516 | counter += 1 517 | 518 | return (results,) 519 | 520 | 521 | class SaveSingleImageWithoutOutput: 522 | """ 523 | 保存图片,非输出节点 524 | """ 525 | 526 | def __init__(self): 527 | self.compress_level = 4 528 | 529 | @classmethod 530 | def INPUT_TYPES(self): 531 | return { 532 | "required": { 533 | "image": ("IMAGE",), 534 | "filename_prefix": ("STRING", {"default": "ComfyUI", "tooltip": "要保存的文件的前缀。可以使用格式化信息,如%date:yyyy-MM-dd%或%Empty Latent Image.width%"}), 535 | "full_file_name": ("STRING", {"default": "", "tooltip": "完整的相对路径文件名,包括扩展名。若为空,则使用filename_prefix生成带序号的文件名"}), 536 | "output_dir": ("STRING", {"default": "", "tooltip": "目标目录(绝对路径),不会自动创建(可配置允许)。若为空,存放到output目录"}), 537 | }, 538 | "optional": { 539 | "addMetadata": ("BOOLEAN", {"default": False, "label_on": "True", "label_off": "False"}), 540 | }, 541 | "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}, 542 | } 543 | 544 | RETURN_TYPES = ("STRING", ) 545 | RETURN_NAMES = ("file_path",) 546 | 547 | FUNCTION = "save_image" 548 | 549 | CATEGORY = "EasyApi/Image" 550 | 551 | DESCRIPTION = "保存图像到指定目录,可根据返回的文件路径进行后续操作,此节点为非输出节点,适合循环批处理和用于惰性求值的前置节点。只会处理一个" 552 | OUTPUT_NODE = False 553 | 554 | def save_image(self, image, full_file_name, output_dir, filename_prefix="ComfyUI", addMetadata=False, prompt=None, extra_pnginfo=None): 555 | imageList = list() 556 | if not isinstance(image, list): 557 | imageList.append(image) 558 | else: 559 | imageList = image 560 | 561 | if output_dir is None or len(output_dir.strip()) == 0: 562 | output_dir = folder_paths.get_output_directory() 563 | 564 | output_dir = check_directory(output_dir) 565 | 566 | if len(imageList) > 0: 567 | image = imageList[0] 568 | for (batch_number, image) in enumerate(image): 569 | img = tensor_to_pil(image) 570 | metadata = None 571 | if not args.disable_metadata and addMetadata: 572 | metadata = PngInfo() 573 | if prompt is not None: 574 | metadata.add_text("prompt", json.dumps(prompt)) 575 | if extra_pnginfo is not None: 576 | for x in extra_pnginfo: 577 | metadata.add_text(x, json.dumps(extra_pnginfo[x])) 578 | 579 | if full_file_name is not None and len(full_file_name.strip()) > 0: 580 | # full_file_name是相对路径,添加校验,并自动创建子目录 581 | full_path = os.path.join(output_dir, full_file_name) 582 | full_normpath_name = os.path.normpath(full_path) 583 | file_dir = os.path.dirname(full_normpath_name) 584 | # 确保路径是out_dir 的子目录 585 | if not os.path.isabs(file_dir) or not file_dir.startswith(output_dir): 586 | raise RuntimeError(f"文件 {full_file_name} 不在 {output_dir} 目录下") 587 | if not os.path.isdir(file_dir): 588 | os.makedirs(file_dir, exist_ok=True) 589 | image_save_path = full_normpath_name 590 | else: 591 | full_output_folder, filename, counter, subfolder, curr_filename_prefix = folder_paths.get_save_image_path( 592 | filename_prefix, output_dir, image.shape[1], image.shape[0]) 593 | filename_with_batch_num = filename.replace("%batch_num%", str(batch_number)) 594 | file = f"{filename_with_batch_num}_{counter:05}_.png" 595 | image_save_path = os.path.join(full_output_folder, file) 596 | 597 | img.save(image_save_path, pnginfo=metadata, compress_level=self.compress_level) 598 | return image_save_path, 599 | 600 | return (None,) 601 | 602 | 603 | class ImageSizeGetter: 604 | """ 605 | 获取图片尺寸 606 | """ 607 | @classmethod 608 | def INPUT_TYPES(self): 609 | return { 610 | "required": { 611 | "image": ("IMAGE",), 612 | }, 613 | } 614 | 615 | RETURN_TYPES = ("INT", "INT", "INT", "INT", "INT",) 616 | RETURN_NAMES = ("width", "height", "max", "min", "batch",) 617 | OUTPUT_TOOLTIPS = ("图片宽度", "图片高度", "最大边长度", "最小边长度", "批次数",) 618 | FUNCTION = "get_size" 619 | CATEGORY = "EasyApi/Image" 620 | DESCRIPTION = "获取图片尺寸" 621 | OUTPUT_NODE = False 622 | def get_size(self, image): 623 | width = image.shape[2] 624 | height = image.shape[1] 625 | return width, height, max(width, height), min(width, height), image.shape[0], 626 | 627 | 628 | NODE_CLASS_MAPPINGS = { 629 | "Base64ToImage": Base64ToImage, 630 | "LoadImageFromURL": LoadImageFromURL, 631 | "LoadMaskFromURL": LoadMaskFromURL, 632 | "ImageToBase64": ImageToBase64, 633 | # "MaskToBase64": MaskToBase64, 634 | "Base64ToMask": Base64ToMask, 635 | "ImageToBase64Advanced": ImageToBase64Advanced, 636 | "MaskToBase64Image": MaskToBase64Image, 637 | "MaskImageToBase64": MaskImageToBase64, 638 | "LoadImageToBase64": LoadImageToBase64, 639 | "LoadImageFromLocalPath": LoadImageFromLocalPath, 640 | "LoadMaskFromLocalPath": LoadMaskFromLocalPath, 641 | "SaveImagesWithoutOutput": SaveImagesWithoutOutput, 642 | "SaveSingleImageWithoutOutput": SaveSingleImageWithoutOutput, 643 | "ImageSizeGetter": ImageSizeGetter, 644 | } 645 | 646 | # A dictionary that contains the friendly/humanly readable titles for the nodes 647 | NODE_DISPLAY_NAME_MAPPINGS = { 648 | "Base64ToImage": "Base64 To Image", 649 | "LoadImageFromURL": "Load Image From Url", 650 | "LoadMaskFromURL": "Load Image From Url (As Mask)", 651 | "ImageToBase64": "Image To Base64", 652 | # "MaskToBase64": "Mask To Base64", 653 | "Base64ToMask": "Base64 To Mask", 654 | "ImageToBase64Advanced": "Image To Base64 (Advanced)", 655 | "MaskToBase64Image": "Mask To Base64 Image", 656 | "MaskImageToBase64": "Mask Image To Base64", 657 | "LoadImageToBase64": "Load Image To Base64", 658 | "LoadImageFromLocalPath": "Load Image From Local Path", 659 | "LoadMaskFromLocalPath": "Load Mask From Local Path", 660 | "SaveImagesWithoutOutput": "Save Images Without Output", 661 | "SaveSingleImageWithoutOutput": "Save Single Image Without Output", 662 | "ImageSizeGetter": "Image Size Getter", 663 | } 664 | -------------------------------------------------------------------------------- /easyapi/SamNode.py: -------------------------------------------------------------------------------- 1 | import torch 2 | from segment_anything import SamAutomaticMaskGenerator 3 | import json 4 | import numpy as np 5 | from segment_anything.utils.amg import area_from_rle, mask_to_rle_pytorch, rle_to_mask, batched_mask_to_box, \ 6 | box_xyxy_to_xywh, coco_encode_rle 7 | from pycocotools import mask as mask_utils 8 | 9 | import nodes 10 | from .util import tensor_to_pil 11 | 12 | 13 | class SamAutoMaskSEGSAdvanced: 14 | @classmethod 15 | def INPUT_TYPES(self): 16 | return { 17 | "required": { 18 | "sam_model": ('SAM_MODEL', {}), 19 | "image": ('IMAGE', {}), 20 | }, 21 | "optional": { 22 | "points_per_side": ("INT", 23 | { 24 | "default": 32, 25 | "min": 1, 26 | "max": nodes.MAX_RESOLUTION, 27 | "step": 1, 28 | "tooltip": "沿图像一侧采样的点数。 总点数为points_per_side的平方。优先级盖玉point_grids, 如果为 None,则 'point_grids'采样点必须传" 29 | }), 30 | "points_per_batch": ("INT", 31 | { 32 | "default": 64, 33 | "min": 1, 34 | "max": nodes.MAX_RESOLUTION, 35 | "step": 1, 36 | "tooltip": "设置模型同时执行的点数。 数字越大,速度越快,但会占用更多的 GPU 内存" 37 | }), 38 | "pred_iou_thresh": ("FLOAT", 39 | { 40 | "default": 0.88, 41 | "min": 0, 42 | "max": 1.0, 43 | "step": 0.01, 44 | "tooltip": "置信度阈值。 置信度低于此值的掩码将被忽略" 45 | }), 46 | "stability_score_thresh": ("FLOAT", 47 | { 48 | "default": 0.95, 49 | "min": 0, 50 | "max": 1.0, 51 | "step": 0.01, 52 | "tooltip": "稳定性得分的过滤阈值,范围[0,1]" 53 | }), 54 | "stability_score_offset": ("FLOAT", 55 | { 56 | "default": 1.0, 57 | "min": 0, 58 | "max": 1.0, 59 | "step": 0.01, 60 | "tooltip": "计算稳定性得分时thresh偏移量。\n公式简单理解成 score= (mask > stability_score_thresh+stability_score_offset) / (mask > stability_score_thresh-stability_score_offset)" 61 | }), 62 | "box_nms_thresh": ("FLOAT", 63 | { 64 | "default": 0.7, 65 | "min": 0, 66 | "max": 1.0, 67 | "step": 0.01, 68 | "tooltip": "mask的bbox区域置信度阈值" 69 | }), 70 | "crop_n_layers": ("INT", 71 | { 72 | "default": 0, 73 | "min": 0, 74 | "max": 64, 75 | "step": 1, 76 | "tooltip": "递归重复检测层数,增大此值可以解决多个物体没拆分开的问题,但是速度会变慢" 77 | }), 78 | "crop_nms_thresh": ("FLOAT", 79 | { 80 | "default": 0.7, 81 | "min": 0, 82 | "max": 1.0, 83 | "step": 0.01, 84 | "tooltip": "crop_box区域置信度阈值" 85 | }), 86 | "crop_overlap_ratio": ("FLOAT", 87 | { 88 | "default": 512 / 1500, 89 | "min": 0, 90 | "max": 1.0, 91 | "step": 0.01, 92 | "tooltip": "多层检测时,设置裁剪重叠的程度,第一层使用此值。随着层数增加,重叠程度会减小" 93 | }), 94 | "crop_n_points_downscale_factor": ("INT", 95 | { 96 | "default": 1, 97 | "min": 1, 98 | "max": nodes.MAX_RESOLUTION, 99 | "step": 1, 100 | "tooltip": "用于计算第n层的points_per_side:int(points_per_side/crop_n_points_downscale_factor**n)" 101 | }), 102 | "min_mask_region_area": ("INT", 103 | { 104 | "default": 0, 105 | "min": 0, 106 | "max": nodes.MAX_RESOLUTION, 107 | "step": 1, 108 | "tooltip": "最小区域面积。 用于过滤(忽略)小区域" 109 | }), 110 | "output_mode": (['uncompressed_rle', 'coco_rle'], {"default": "uncompressed_rle"}), 111 | }, 112 | } 113 | 114 | RETURN_TYPES = ("MASK_RLE",) 115 | RETURN_NAMES = ("masks_rle",) 116 | 117 | FUNCTION = "generate" 118 | 119 | OUTPUT_NODE = False 120 | CATEGORY = "EasyApi/Detect" 121 | 122 | def generate(self, 123 | sam_model, 124 | image, 125 | points_per_side: int = 32, 126 | points_per_batch: int = 64, 127 | pred_iou_thresh: float = 0.88, 128 | stability_score_thresh: float = 0.95, 129 | stability_score_offset: float = 1.0, 130 | box_nms_thresh: float = 0.7, 131 | crop_n_layers: int = 0, 132 | crop_nms_thresh: float = 0.7, 133 | crop_overlap_ratio: float = 512 / 1500, 134 | crop_n_points_downscale_factor: int = 1, 135 | min_mask_region_area: int = 0, 136 | output_mode: str = "uncompressed_rle", 137 | ): 138 | """ 139 | # 沿图像一侧采样的点数。 总点数为 points_per_side**2。优先级盖玉point_grids, 如果为 None,则 'point_grids'采样点必须传。 140 | points_per_side = 32 141 | # 设置模型同时执行的点数。 数字越大,速度越快,但会占用更多的 GPU 内存。 142 | points_per_batch = 64 143 | # 置信度阈值。 置信度低于此值的掩码将被忽略。 144 | pred_iou_thresh = 0.88 145 | # 稳定性得分的过滤阈值,范围[0,1] 146 | stability_score_thresh = 0.95 147 | # 计算稳定性得分时thresh偏移量 148 | # 公式简单理解成 score= (mask > stability_score_thresh+stability_score_offset) / (mask > stability_score_thresh-stability_score_offset) 149 | stability_score_offset = 1.0 150 | # mask的bbox区域置信度阈值。 151 | box_nms_thresh = 0.7 152 | # 递归检测次数,增大此值可以解决多个物体没拆分开的问题,但是速度会变慢。 153 | crop_n_layers = 0 154 | # crop_box区域置信度阈值。 155 | crop_nms_thresh = 0.7 156 | # 设置裁剪重叠的程度,第一层使用此值。随着层数增加,重叠程度会减小。 157 | crop_overlap_ratio = 512 / 1500 158 | # 用于计算第n层的points_per_side:按int(points_per_side/crop_n_points_downscale_factor**n)。 159 | crop_n_points_downscale_factor = 1 160 | # 用于采样的点列表,归一化为[0,1]。列表中的第n个点用于第n个裁剪层。points_per_side不为空时不生效。Optional[List[np.ndarray]] 161 | point_grids = None 162 | # 最小区域面积。 用于过滤小区域 163 | min_mask_region_area = 0 164 | """ 165 | point_grids = None 166 | # 判断是不是HQ 167 | encodeClassName = sam_model.image_encoder.__class__.__name__ 168 | if encodeClassName == "ImageEncoderViTHQ": 169 | from custom_nodes.comfyui_segment_anything.sam_hq.automatic import SamAutomaticMaskGeneratorHQ 170 | from custom_nodes.comfyui_segment_anything.sam_hq.predictor import SamPredictorHQ 171 | samHQ = SamPredictorHQ(sam_model, True) 172 | mask_generator = SamAutomaticMaskGeneratorHQ(samHQ, 173 | points_per_side, 174 | points_per_batch, 175 | pred_iou_thresh, 176 | stability_score_thresh, 177 | stability_score_offset, 178 | box_nms_thresh, 179 | crop_n_layers, 180 | crop_nms_thresh, 181 | crop_overlap_ratio, 182 | crop_n_points_downscale_factor, 183 | point_grids, 184 | min_mask_region_area, 185 | output_mode=output_mode) 186 | else: 187 | mask_generator = SamAutomaticMaskGenerator(sam_model, 188 | points_per_side, 189 | points_per_batch, 190 | pred_iou_thresh, 191 | stability_score_thresh, 192 | stability_score_offset, 193 | box_nms_thresh, 194 | crop_n_layers, 195 | crop_nms_thresh, 196 | crop_overlap_ratio, 197 | crop_n_points_downscale_factor, 198 | point_grids, 199 | min_mask_region_area, 200 | output_mode=output_mode) 201 | image_pil = tensor_to_pil(image) 202 | image_np = np.array(image_pil) 203 | image_np_rgb = image_np[..., :3] 204 | 205 | masks = mask_generator.generate(image_np_rgb) 206 | return (masks,) 207 | 208 | 209 | class SamAutoMaskSEGS(SamAutoMaskSEGSAdvanced): 210 | @classmethod 211 | def INPUT_TYPES(self): 212 | return { 213 | "required": { 214 | "sam_model": ('SAM_MODEL', {}), 215 | "image": ('IMAGE', {}), 216 | "output_mode": (['uncompressed_rle', 'coco_rle'], {"default": "uncompressed_rle"}), 217 | }, 218 | } 219 | 220 | RETURN_TYPES = ("STRING",) 221 | RETURN_NAMES = ("RLE_SEGS",) 222 | 223 | FUNCTION = "generate" 224 | 225 | OUTPUT_NODE = True 226 | CATEGORY = "EasyApi/Detect" 227 | 228 | # INPUT_IS_LIST = False 229 | # OUTPUT_IS_LIST = (False, False) 230 | 231 | def generate(self, sam_model, image, output_mode): 232 | masks = super().generate(sam_model, image, output_mode=output_mode) 233 | masksRle = json.JSONEncoder().encode(masks[0]) 234 | return {"ui": {"segsRle": (masksRle,)}, "result": (masksRle,)} 235 | 236 | 237 | class MaskToRle: 238 | @classmethod 239 | def INPUT_TYPES(self): 240 | return { 241 | "required": { 242 | "mask": ('MASK', {}), 243 | "output_mode": (['uncompressed_rle', 'coco_rle'], {"default": "uncompressed_rle"}), 244 | }, 245 | } 246 | 247 | RETURN_TYPES = ("MASK_RLE",) 248 | RETURN_NAMES = ("masks_rle",) 249 | 250 | FUNCTION = "convert" 251 | 252 | OUTPUT_NODE = False 253 | CATEGORY = "EasyApi/Detect" 254 | 255 | def convert(self, mask, output_mode): 256 | masksRle = [] 257 | b, h, w = mask.shape 258 | rles = mask_to_rle_pytorch((mask > 0.15).bool()) 259 | for i in range(b): 260 | single_rle = rles[i] 261 | area = area_from_rle(single_rle) 262 | bbox = box_xyxy_to_xywh(batched_mask_to_box(mask.bool())[i]).tolist() 263 | # stability_scores = calculate_stability_score(mask[i], mask_threshold, threshold_offset) 264 | if output_mode == "coco_rle": 265 | single_rle = coco_encode_rle(single_rle) 266 | 267 | masksRle.append( 268 | { 269 | "segmentation": single_rle, 270 | # 遮罩区域面积(像素点数) 271 | "area": area, 272 | # 蒙版矩形区域XYWH 273 | "bbox": bbox, 274 | # 用于生成此蒙版的图像的裁剪(XYWH格式) 275 | "crop_box": [0, 0, w, h], 276 | # "predicted_iou": 0.9494854211807251, 277 | # 采样点坐标,自动情况下,蒙版区域内的任意一个点就行 278 | # "point_coords": [[54.8475,1075.9375]], 279 | # "stability_score": stability_scores.item(), 280 | } 281 | ) 282 | return (masksRle,) 283 | 284 | 285 | class RleToMask: 286 | @classmethod 287 | def INPUT_TYPES(self): 288 | return { 289 | "required": { 290 | "masks_rle": ('MASK_RLE', {}), 291 | "rle_mode": (['uncompressed_rle', 'coco_rle'], {"default": "uncompressed_rle"}), 292 | }, 293 | } 294 | 295 | RETURN_TYPES = ("MASK",) 296 | RETURN_NAMES = ("masks",) 297 | 298 | FUNCTION = "convert" 299 | 300 | OUTPUT_NODE = False 301 | CATEGORY = "EasyApi/Detect" 302 | 303 | def convert(self, masks_rle, rle_mode='uncompressed_rle'): 304 | masks = [] 305 | if isinstance(masks_rle, dict): 306 | list_rle = [masks_rle] 307 | else: 308 | list_rle = masks_rle 309 | for mask_rle in list_rle: 310 | if rle_mode == "coco_rle": 311 | mask_np = mask_utils.decode(mask_rle["segmentation"]) 312 | else: 313 | mask_np = rle_to_mask(mask_rle["segmentation"]) 314 | 315 | mask = torch.from_numpy(mask_np).to(torch.float32) 316 | 317 | masks.append(mask.unsqueeze(0)) 318 | 319 | if len(masks) > 1: 320 | # 如果有多个图像,则将它们按维度0拼接在一起 321 | output_mask = torch.cat(masks, dim=0) 322 | else: 323 | output_mask = masks[0] 324 | 325 | return (output_mask,) 326 | 327 | 328 | NODE_CLASS_MAPPINGS = { 329 | "SamAutoMaskSEGS": SamAutoMaskSEGS, 330 | "SamAutoMaskSEGSAdvanced": SamAutoMaskSEGSAdvanced, 331 | "MaskToRle": MaskToRle, 332 | "RleToMask": RleToMask, 333 | } 334 | 335 | # A dictionary that contains the friendly/humanly readable titles for the nodes 336 | NODE_DISPLAY_NAME_MAPPINGS = { 337 | "SamAutoMaskSEGS": "SamAutoMaskSEGS", 338 | "SamAutoMaskSEGSAdvanced": "SamAutoMaskSEGSAdvanced", 339 | "MaskToRle": "MaskToRle", 340 | "RleToMask": "RleToMask", 341 | } 342 | -------------------------------------------------------------------------------- /easyapi/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lldacing/comfyui-easyapi-nodes/89621e290c57c4a729d04927293000d22a7df525/easyapi/__init__.py -------------------------------------------------------------------------------- /easyapi/api.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import folder_paths 4 | import nodes 5 | from server import PromptServer 6 | from aiohttp import web 7 | import execution 8 | from simple_lama_inpainting import SimpleLama 9 | from .util import image_to_base64, base64_to_image 10 | from .settings import reset_history_size, get_settings, set_settings 11 | 12 | extension_folder = os.path.dirname(os.path.realpath(__file__)) 13 | 14 | simple_lama = None 15 | lama_model_dir = os.path.join(folder_paths.models_dir, "lama") 16 | lama_model_path = os.path.join(lama_model_dir, "big-lama.pt") 17 | if not os.path.exists(lama_model_path): 18 | os.environ['LAMA_MODEL'] = lama_model_path 19 | print(f"## lama model not found: {lama_model_path}, pls download from https://github.com/enesmsahin/simple-lama-inpainting/releases/download/v0.1.0/big-lama.pt") 20 | else: 21 | os.environ['LAMA_MODEL'] = lama_model_path 22 | os.makedirs(lama_model_dir, exist_ok=True) 23 | 24 | 25 | def register_routes(): 26 | @PromptServer.instance.routes.post("/easyapi/history/size") 27 | async def set_history_size(request): 28 | json_data = await request.json() 29 | size = json_data["maxSize"] 30 | if size is not None: 31 | promptQueue = PromptServer.instance.prompt_queue 32 | with promptQueue.mutex: 33 | maxSize = int(size) 34 | execution.MAXIMUM_HISTORY_SIZE = maxSize 35 | history = promptQueue.history 36 | end = len(history) - maxSize 37 | i = 0 38 | for key in list(history.keys()): 39 | if i >= end: 40 | break 41 | history.pop(key) 42 | i = i + 1 43 | reset_history_size(maxSize) 44 | return web.Response(status=200) 45 | 46 | return web.Response(status=400) 47 | 48 | @PromptServer.instance.routes.get("/easyapi/history/maxSize") 49 | async def get_history_size(request): 50 | maxSize = execution.MAXIMUM_HISTORY_SIZE 51 | data = get_settings(file='config/easyapi.json') 52 | if 'history_max_size' in data: 53 | maxSize = data['history_max_size'] 54 | 55 | return web.json_response({"maxSize": maxSize}) 56 | 57 | @PromptServer.instance.routes.post("/easyapi/settings/{id}") 58 | async def set_setting(request): 59 | setting_id = request.match_info.get("id", None) 60 | if not setting_id: 61 | return web.Response(status=400) 62 | json_body = await request.json() 63 | set_settings(setting_id, json_body[setting_id]) 64 | return web.Response(status=200) 65 | 66 | @PromptServer.instance.routes.get("/easyapi/settings/{id}") 67 | async def get_setting(request): 68 | setting_id = request.match_info.get("id", None) 69 | settings = get_settings(file='config/easyapi.json') 70 | if settings and setting_id in settings: 71 | return web.json_response({setting_id: settings[setting_id]}) 72 | 73 | return web.json_response({}) 74 | 75 | @PromptServer.instance.routes.post("/easyapi/prompt") 76 | async def post_prompt(request): 77 | print("got prompt") 78 | json_data = await request.json() 79 | json_data = PromptServer.instance.trigger_on_prompt(json_data) 80 | prompt_id = json_data["prompt_id"] 81 | print("prompt_id={}".format(json_data["prompt_id"])) 82 | 83 | if "number" in json_data: 84 | number = float(json_data['number']) 85 | else: 86 | number = PromptServer.instance.number 87 | if "front" in json_data: 88 | if json_data['front']: 89 | number = -number 90 | 91 | PromptServer.instance.number += 1 92 | 93 | if "prompt" in json_data: 94 | prompt = json_data["prompt"] 95 | valid = execution.validate_prompt(prompt) 96 | extra_data = {} 97 | if "extra_data" in json_data: 98 | extra_data = json_data["extra_data"] 99 | 100 | if "client_id" in json_data: 101 | extra_data["client_id"] = json_data["client_id"] 102 | if valid[0]: 103 | outputs_to_execute = valid[2] 104 | PromptServer.instance.prompt_queue.put((number, prompt_id, prompt, extra_data, outputs_to_execute)) 105 | response = {"prompt_id": prompt_id, "number": number, "node_errors": valid[3]} 106 | return web.json_response(response) 107 | else: 108 | print("invalid prompt:", valid[1]) 109 | return web.json_response({"error": valid[1], "node_errors": valid[3]}, status=400) 110 | else: 111 | return web.json_response({"error": "no prompt", "node_errors": []}, status=400) 112 | 113 | @PromptServer.instance.routes.post("/easyapi/interrupt") 114 | async def post_interrupt(request): 115 | json_data = await request.json() 116 | prompt_id = json_data["prompt_id"] 117 | current_queue = PromptServer.instance.prompt_queue.get_current_queue() 118 | queue_running = current_queue[0] 119 | if queue_running is not None and len(queue_running) > 0: 120 | if len(queue_running[0]) > 0 and queue_running[0][1] == prompt_id: 121 | nodes.interrupt_processing() 122 | 123 | delete_func = lambda a: a[1] == prompt_id 124 | PromptServer.instance.prompt_queue.delete_queue_item(delete_func) 125 | return web.Response(status=200) 126 | 127 | @PromptServer.instance.routes.post("/easyapi/lama_cleaner") 128 | async def lama_cleaner(request): 129 | json_data = await request.json() 130 | image = json_data["image"] 131 | mask = json_data["mask"] 132 | if image is None or mask is None: 133 | return web.json_response({"error": "missing required params"}, status=400) 134 | 135 | global simple_lama 136 | if simple_lama is None: 137 | simple_lama = SimpleLama() 138 | 139 | image = base64_to_image(image) 140 | mask = base64_to_image(mask) 141 | mask = mask.convert('L') 142 | 143 | res = simple_lama(image, mask) 144 | 145 | encoded_image = image_to_base64(res) 146 | 147 | response = {"base64Image": encoded_image} 148 | return web.json_response(response, status=200) 149 | 150 | 151 | def init(): 152 | reset_history_size(isStart=True) 153 | register_routes() 154 | -------------------------------------------------------------------------------- /easyapi/logScript.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from datetime import datetime as dt 4 | 5 | from server import PromptServer 6 | 7 | old_stdout = sys.stdout 8 | old_stderr = sys.stderr 9 | 10 | 11 | class StdTimeFilter: 12 | def __init__(self, is_stdout): 13 | self.is_stdout = is_stdout 14 | self.newline = True 15 | self.encoding = "utf-8" 16 | 17 | def write(self, message): 18 | if message == '\n': 19 | if self.is_stdout: 20 | old_stdout.write(message) 21 | old_stdout.flush() 22 | else: 23 | old_stderr.write(message) 24 | old_stderr.flush() 25 | self.newline = True 26 | elif self.newline: 27 | if self.is_stdout: 28 | old_stdout.write('%s %s' % (str(dt.now()), message)) 29 | else: 30 | old_stderr.write('%s %s' % (str(dt.now()), message)) 31 | self.newline = False 32 | else: 33 | if self.is_stdout: 34 | old_stdout.write(message) 35 | else: 36 | old_stderr.write(message) 37 | 38 | def flush(self): 39 | if self.is_stdout: 40 | old_stdout.flush() 41 | else: 42 | old_stderr.flush() 43 | 44 | 45 | def socket_wrap(func): 46 | def wrap_func(event, data, sid=None): 47 | if event == "executed" or event == "executing": 48 | print("send message begin, type={}, node={}".format(event, data['node'])) 49 | else: 50 | print("send message begin, {}".format(event)) 51 | func(event, data, sid) 52 | print("send message end, {}".format(event)) 53 | return wrap_func 54 | 55 | 56 | def log_wrap(): 57 | sys.stdout = StdTimeFilter(True) 58 | sys.stderr = StdTimeFilter(False) 59 | # old_send_sync = PromptServer.instance.send_sync 60 | # PromptServer.instance.send_sync = socket_wrap(old_send_sync) 61 | -------------------------------------------------------------------------------- /easyapi/mirrorUrlApply.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from .settings import get_settings 4 | import copy 5 | 6 | mirror_url = [ 7 | { 8 | "id": "rawgithub", 9 | "o_url": "raw.githubusercontent.com", 10 | # "n_url": "raw.gitmirror.com", 11 | "n_url": "None", 12 | "u_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0" 13 | }, 14 | { 15 | "id": "huggingface", 16 | "o_url": "huggingface.co", 17 | # "n_url": "hf-mirror.com" 18 | "n_url": "None", 19 | "u_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0", 20 | }, 21 | { 22 | "id": "github", 23 | "o_url": "github.com", 24 | # "n_url": "mirror.ghproxy.com/https://github.com" 25 | "n_url": "None", 26 | "u_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0", 27 | }, 28 | ] 29 | clone_mirror_url = [ 30 | { 31 | "id": "clone_github", 32 | "o_url": "github.com", 33 | # "n_url": "mirror.ghproxy.com/https://github.com" 34 | "n_url": "None", 35 | "u_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0", 36 | }, 37 | ] 38 | 39 | 40 | class Mirror(Enum): 41 | DOWN_MODEL = 0 42 | GIT_CLONE = 1 43 | 44 | 45 | def get_custom_mirrors(mirror_type=None): 46 | settings = get_settings() 47 | if mirror_type is Mirror.GIT_CLONE: 48 | base_mirrors = copy.deepcopy(clone_mirror_url) 49 | if settings and 'clone_github_mirror' in settings: 50 | base_mirrors[0]['n_url'] = settings['clone_github_mirror'] 51 | elif mirror_type is Mirror.DOWN_MODEL: 52 | base_mirrors = copy.deepcopy(mirror_url) 53 | if settings and 'huggingface_mirror' in settings: 54 | base_mirrors[1]['n_url'] = settings['huggingface_mirror'] 55 | if settings and 'rawgithub_mirror' in settings: 56 | base_mirrors[0]['n_url'] = settings['rawgithub_mirror'] 57 | if settings and 'github_mirror' in settings: 58 | base_mirrors[2]['n_url'] = settings['github_mirror'] 59 | else: 60 | base_mirrors = {} 61 | return base_mirrors 62 | 63 | 64 | def replace_mirror_url(): 65 | from urllib.parse import urlparse 66 | 67 | def replace_url(url: str, mirror_type: Mirror = None): 68 | u = urlparse(url) 69 | netloc = u.netloc 70 | found = False 71 | user_agent = None 72 | for mirror in get_custom_mirrors(mirror_type): 73 | if netloc is not None and len(netloc) > 0 and netloc.lower() == mirror['o_url'] and mirror['n_url'] != 'None': 74 | u = u._replace(netloc=mirror['n_url']) 75 | print('[easyapi] origin url: {}, use mirror url: {}'.format(url, u.geturl())) 76 | if 'u_agent' in mirror: 77 | user_agent = mirror['u_agent'] 78 | found = True 79 | break 80 | return found, u, user_agent 81 | 82 | import urllib.request 83 | import socket 84 | # open(self, fullurl, data=None, timeout=socket._GLOBAL_DEFAULT_TIMEOUT) 85 | origin_urllib_open = urllib.request.OpenerDirector.open 86 | 87 | def wrap_open(obj, fullurl, data=None, timeout=socket._GLOBAL_DEFAULT_TIMEOUT): 88 | """ 89 | implement of lib urllib 90 | Args: 91 | **args: self, fullurl 92 | **kwargs: 93 | 94 | Returns: 95 | 96 | """ 97 | if isinstance(fullurl, str): 98 | found, u, user_agent = replace_url(fullurl, Mirror.DOWN_MODEL) 99 | if found: 100 | url = u.geturl() 101 | if user_agent is not None: 102 | headers = {'User-Agent': user_agent} 103 | url = urllib.request.Request(url, data=data, headers=headers) 104 | 105 | return origin_urllib_open.__call__(obj, url, data, timeout) 106 | else: 107 | return origin_urllib_open.__call__(obj, fullurl, data, timeout) 108 | 109 | else: 110 | # url is urllib.request.Request 111 | full_url = fullurl.get_full_url() 112 | found, u, user_agent = replace_url(full_url, Mirror.DOWN_MODEL) 113 | if found: 114 | fullurl.full_url = u.geturl() 115 | if user_agent is not None: 116 | if fullurl.headers is not None: 117 | fullurl.headers['User-Agent'] = user_agent 118 | else: 119 | fullurl.headers = {'User-Agent': user_agent} 120 | 121 | return origin_urllib_open.__call__(obj, fullurl, data, timeout) 122 | 123 | import requests 124 | origin_request = requests.Session.request 125 | 126 | def wrap_requests(*args, **kwargs): 127 | """ 128 | implement of lib requests 129 | Args: 130 | **args: self, method, url 131 | **kwargs: 132 | 133 | Returns: 134 | 135 | """ 136 | 137 | if 'url' in kwargs: 138 | url = kwargs['url'] 139 | found, u, user_agent = replace_url(url, Mirror.DOWN_MODEL) 140 | if found: 141 | kwargs['url'] = u.geturl() 142 | elif len(args) >= 3: 143 | url = args[2] 144 | found, u, user_agent = replace_url(url, Mirror.DOWN_MODEL) 145 | if found: 146 | new_updater = list(args) 147 | new_updater[2] = u.geturl() 148 | args = tuple(new_updater) 149 | 150 | return origin_request.__call__(*args, **kwargs) 151 | 152 | import aiohttp 153 | origin_async_request = aiohttp.ClientSession._request 154 | 155 | def wrap_aiohttp_requests(*args, **kwargs): 156 | """ 157 | implement of lib aiohttp 158 | Args: 159 | **args: self, method, str_or_url 160 | **kwargs: 161 | 162 | Returns: 163 | 164 | """ 165 | 166 | if 'str_or_url' in kwargs: 167 | url = kwargs['str_or_url'] 168 | found, u, user_agent = replace_url(url, Mirror.DOWN_MODEL) 169 | if found: 170 | kwargs['str_or_url'] = u.geturl() 171 | elif len(args) >= 3: 172 | url = args[2] 173 | found, u, user_agent = replace_url(url, Mirror.DOWN_MODEL) 174 | if found: 175 | new_updater = list(args) 176 | new_updater[2] = u.geturl() 177 | args = tuple(new_updater) 178 | 179 | return origin_async_request.__call__(*args, **kwargs) 180 | 181 | import git 182 | origin_git_clone = git.Repo._clone 183 | 184 | def wrap_git_clone(*args, **kwargs): 185 | """ 186 | implement of lib git clone 187 | Args: 188 | **args: cls, git, url 189 | **kwargs: 190 | 191 | Returns: 192 | 193 | """ 194 | 195 | if 'url' in kwargs: 196 | url = kwargs['url'] 197 | found, u, user_agent = replace_url(url, Mirror.GIT_CLONE) 198 | if found: 199 | kwargs['url'] = u.geturl() 200 | elif len(args) >= 3: 201 | url = args[2] 202 | found, u, user_agent = replace_url(url, Mirror.GIT_CLONE) 203 | if found: 204 | new_updater = list(args) 205 | new_updater[2] = u.geturl() 206 | args = tuple(new_updater) 207 | 208 | return origin_git_clone.__call__(*args, **kwargs) 209 | 210 | # urllib.request.urlopen = wrap_urlopen 211 | urllib.request.OpenerDirector.open = wrap_open 212 | requests.Session.request = wrap_requests 213 | aiohttp.ClientSession._request = wrap_aiohttp_requests 214 | git.Repo._clone = wrap_git_clone 215 | 216 | # try: 217 | # manager has been not loaded 218 | # from ComfyUI-Manager.glob import manager_core 219 | # wrap_manager_git_clone = manager_core.gitclone_install 220 | # 221 | # def wrap_manager_git_clone(files): 222 | # urls = copy.deepcopy(files) 223 | # if isinstance(urls, []|list|()): 224 | # for i in range(len(urls)): 225 | # url = urls[i] 226 | # found, u, user_agent = replace_url(url, Mirror.GIT_CLONE) 227 | # if found: 228 | # urls[i]=u.geturl() 229 | # 230 | # return wrap_manager_git_clone.__call__(urls) 231 | # 232 | # manager_core.gitclone_install = wrap_manager_git_clone 233 | # except Exception as e: 234 | # print("[easyapi] fail to apply manager clone patch, error: {} ".format(e)) 235 | 236 | 237 | def init(): 238 | try: 239 | replace_mirror_url() 240 | except Exception as e: 241 | print("[easyapi] fail to apply mirror url patch, error: {} ".format(e)) 242 | -------------------------------------------------------------------------------- /easyapi/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import execution 4 | 5 | extension_folder = os.path.dirname(os.path.realpath(__file__)) 6 | # configDataFilePath = os.path.join(extension_folder, 'config') 7 | 8 | # if not os.path.exists(configDataFilePath): 9 | # os.mkdir(configDataFilePath) 10 | 11 | 12 | def reset_history_size(max_size=execution.MAXIMUM_HISTORY_SIZE, isStart=False): 13 | if not isStart: 14 | set_settings("history_max_size", max_size) 15 | # if not os.path.exists(configDataFilePath): 16 | # os.mkdir(configDataFilePath) 17 | # configFile = os.path.join(configDataFilePath, "easyapi.json") 18 | # with open(configFile, 'w+', encoding="utf-8") as file: 19 | # json.dump({"history_max_size": max_size}, file, indent=2) 20 | # else: 21 | # configFile = os.path.join(configDataFilePath, "easyapi.json") 22 | # if not os.path.exists(configFile): 23 | # with open(configFile, 'w+', encoding="utf-8") as file: 24 | # json.dump({"history_max_size": max_size}, file, indent=2) 25 | # else: 26 | # with open(configFile, 'r+', encoding="UTF-8") as file: 27 | # data = json.load(file) 28 | # if not isStart: 29 | # data['history_max_size'] = max_size 30 | # 31 | # with open(configFile, 'w+', encoding="UTF-8") as file: 32 | # json.dump(data, file, indent=2) 33 | 34 | 35 | def get_settings(file="config/easyapi.json"): 36 | configFile = check_dir(file) 37 | setting = {} 38 | if not os.path.exists(configFile): 39 | with open(configFile, 'w+', encoding="utf-8") as file: 40 | json.dump({}, file, indent=2) 41 | else: 42 | with open(configFile, 'r+', encoding="utf-8") as file: 43 | setting = json.load(file) 44 | 45 | return setting 46 | 47 | 48 | def set_settings(key, value, file="config/easyapi.json"): 49 | configFile = check_dir(file) 50 | setting_json = get_settings(file=file) 51 | setting_json[key] = value 52 | with open(configFile, 'w+', encoding="utf-8") as file: 53 | json.dump(setting_json, file, indent=2) 54 | 55 | 56 | def check_dir(filePath): 57 | configDataFilePath = os.path.join(extension_folder, os.path.dirname(filePath)) 58 | if not os.path.exists(configDataFilePath): 59 | os.mkdir(configDataFilePath) 60 | return os.path.join(configDataFilePath, os.path.basename(filePath)) 61 | -------------------------------------------------------------------------------- /easyapi/util.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import io 3 | import json 4 | import os 5 | import time 6 | 7 | import numpy as np 8 | import requests 9 | import torch 10 | from PIL import Image 11 | 12 | 13 | # Tensor to PIL 14 | def tensor_to_pil(image): 15 | return Image.fromarray(np.clip(255. * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8)) 16 | 17 | 18 | # Convert PIL to Tensor 19 | def pil_to_tensor(image): 20 | return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0) 21 | 22 | 23 | def base64_to_image(base64_string): 24 | # 去除前缀 25 | base64_list = base64_string.split(",", 1) 26 | if len(base64_list) == 2: 27 | prefix, base64_data = base64_list 28 | else: 29 | base64_data = base64_list[0] 30 | 31 | # 从base64字符串中解码图像数据 32 | image_data = base64.b64decode(base64_data) 33 | 34 | # 创建一个内存流对象 35 | image_stream = io.BytesIO(image_data) 36 | 37 | # 使用PIL的Image模块打开图像数据 38 | image = Image.open(image_stream) 39 | 40 | return image 41 | 42 | 43 | def image_to_base64(pli_image, pnginfo=None): 44 | # 创建一个BytesIO对象,用于临时存储图像数据 45 | image_data = io.BytesIO() 46 | 47 | # 将图像保存到BytesIO对象中,格式为PNG 48 | pli_image.save(image_data, format='PNG', pnginfo=pnginfo) 49 | 50 | # 将BytesIO对象的内容转换为字节串 51 | image_data_bytes = image_data.getvalue() 52 | 53 | # 将图像数据编码为Base64字符串 54 | encoded_image = "data:image/png;base64," + base64.b64encode(image_data_bytes).decode('utf-8') 55 | 56 | return encoded_image 57 | 58 | 59 | def read_image_from_url(image_url): 60 | try: 61 | # Create a new session and disable keep-alive if desired 62 | session = requests.Session() 63 | session.keep_alive = False 64 | 65 | # Get the image content from the URL 66 | response = session.get(image_url, stream=True, verify=False) 67 | response.raise_for_status() # Ensure we got a valid response 68 | 69 | # Convert the response content into a BytesIO object 70 | image_bytes = io.BytesIO(response.content) 71 | 72 | # Open the image using PIL and force loading the image data 73 | img = Image.open(image_bytes) 74 | img.load() # Ensure the image is fully loaded 75 | 76 | return img 77 | except Exception as e: 78 | print(f"Error reading image from URL {image_url}: {e}") 79 | return None 80 | 81 | 82 | def hex_to_rgba(hex_color): 83 | hex_color = hex_color.lstrip('#') 84 | r, g, b = tuple(int(hex_color[i:i + 2], 16) for i in (0, 2, 4)) 85 | if len(hex_color) == 8: 86 | a = int(hex_color[6:8], 16) 87 | else: 88 | a = 255 89 | return r, g, b, a 90 | 91 | 92 | def find_max_suffix_number(kwargs, substring): 93 | # 提取所有键 94 | keys = list(kwargs.keys()) 95 | 96 | # 筛选出形如 'initial_valueX' 的键 97 | matching_keys = [key for key in keys if key.startswith(substring)] 98 | 99 | # 从匹配的键中提取数字部分 100 | numbers = [int(key[len(substring):]) for key in matching_keys] 101 | 102 | # 找到最大数字 103 | max_number = max(numbers) if numbers else 1 104 | 105 | return max_number 106 | 107 | 108 | class AnyType(str): 109 | """A special class that is always equal in not equal comparisons. Credit to pythongosssss""" 110 | 111 | def __ne__(self, __value: object) -> bool: 112 | return False 113 | 114 | 115 | any_type = AnyType("*") 116 | 117 | 118 | global_config = os.path.join(os.path.dirname(os.path.realpath(__file__)), "../global.json") 119 | last_read_time = None 120 | 121 | def read_global_config(): 122 | config = {} 123 | if os.path.exists(global_config): 124 | with open(global_config, encoding='utf-8') as f: 125 | config = json.load(f) 126 | 127 | return config 128 | 129 | 130 | def get_global_config(key): 131 | global last_read_time 132 | global config 133 | current_time = time.time() 134 | if last_read_time is None or current_time - last_read_time >= 300: 135 | config = read_global_config() 136 | last_read_time = current_time 137 | 138 | return config[key] if key in config else None 139 | 140 | 141 | def check_directory(check_dir): 142 | """ 143 | 如果不允许创建目录,检查目录是否存在,是不是绝对路经。 144 | 如果允许创建目录,尝试创建目录,并返回规范化路径。 145 | Args: 146 | check_dir: 147 | 148 | Returns: 规范化后的路径 149 | 150 | """ 151 | allow_create_dir_when_save = get_global_config('allow_create_dir_when_save') 152 | check_dir = os.path.normpath(check_dir) 153 | if not allow_create_dir_when_save and (not os.path.isdir(check_dir) or not os.path.isabs(check_dir)): 154 | raise FileNotFoundError(f"dir not found: {check_dir}") 155 | 156 | if not os.path.isdir(check_dir): 157 | os.makedirs(check_dir, exist_ok=True) 158 | return check_dir -------------------------------------------------------------------------------- /example/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lldacing/comfyui-easyapi-nodes/89621e290c57c4a729d04927293000d22a7df525/example/example.png -------------------------------------------------------------------------------- /example/example_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lldacing/comfyui-easyapi-nodes/89621e290c57c4a729d04927293000d22a7df525/example/example_1.png -------------------------------------------------------------------------------- /example/example_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lldacing/comfyui-easyapi-nodes/89621e290c57c4a729d04927293000d22a7df525/example/example_2.png -------------------------------------------------------------------------------- /example/example_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lldacing/comfyui-easyapi-nodes/89621e290c57c4a729d04927293000d22a7df525/example/example_3.png -------------------------------------------------------------------------------- /example/example_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lldacing/comfyui-easyapi-nodes/89621e290c57c4a729d04927293000d22a7df525/example/example_4.png -------------------------------------------------------------------------------- /example/example_image_crop_tag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lldacing/comfyui-easyapi-nodes/89621e290c57c4a729d04927293000d22a7df525/example/example_image_crop_tag.png -------------------------------------------------------------------------------- /example/example_sam_mask.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lldacing/comfyui-easyapi-nodes/89621e290c57c4a729d04927293000d22a7df525/example/example_sam_mask.png -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "allow_create_dir_when_save": false 3 | } -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "comfyui-easyapi-nodes" 3 | description = "Provides some features and nodes related to API calls. 开发独立应用调用ComfyUI服务的一些补充节点。" 4 | version = "1.1.5" 5 | license = { file = "LICENSE" } 6 | dependencies = ["segment_anything", "simple_lama_inpainting", "insightface", "simplejson", "pycocotools"] 7 | 8 | [project.urls] 9 | Repository = "https://github.com/lldacing/comfyui-easyapi-nodes" 10 | # Used by Comfy Registry https://comfyregistry.org 11 | 12 | [tool.comfy] 13 | PublisherId = "lldacing" 14 | DisplayName = "comfyui-easyapi-nodes" 15 | Icon = "" 16 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | segment_anything 2 | simple_lama_inpainting 3 | insightface 4 | simplejson 5 | pycocotools 6 | -------------------------------------------------------------------------------- /static/css/classic.min.css: -------------------------------------------------------------------------------- 1 | /*! Pickr 1.9.0 MIT | https://github.com/Simonwep/pickr */ 2 | .pickr{position:relative;overflow:visible;transform:translateY(0)}.pickr *{box-sizing:border-box;outline:none;border:none;-webkit-appearance:none}.pickr .pcr-button{position:relative;height:2em;width:2em;padding:.5em;cursor:pointer;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Roboto","Helvetica Neue",Arial,sans-serif;border-radius:.15em;background:url("data:image/svg+xml;utf8, ") no-repeat center;background-size:0;transition:all .3s}.pickr .pcr-button::before{position:absolute;content:"";top:0;left:0;width:100%;height:100%;background:url("data:image/svg+xml;utf8, ");background-size:.5em;border-radius:.15em;z-index:-1}.pickr .pcr-button::before{z-index:initial}.pickr .pcr-button::after{position:absolute;content:"";top:0;left:0;height:100%;width:100%;transition:background .3s;background:var(--pcr-color);border-radius:.15em}.pickr .pcr-button.clear{background-size:70%}.pickr .pcr-button.clear::before{opacity:0}.pickr .pcr-button.clear:focus{box-shadow:0 0 0 1px rgba(255,255,255,.85),0 0 0 3px var(--pcr-color)}.pickr .pcr-button.disabled{cursor:not-allowed}.pickr *,.pcr-app *{box-sizing:border-box;outline:none;border:none;-webkit-appearance:none}.pickr input:focus,.pickr input.pcr-active,.pickr button:focus,.pickr button.pcr-active,.pcr-app input:focus,.pcr-app input.pcr-active,.pcr-app button:focus,.pcr-app button.pcr-active{box-shadow:0 0 0 1px rgba(255,255,255,.85),0 0 0 3px var(--pcr-color)}.pickr .pcr-palette,.pickr .pcr-slider,.pcr-app .pcr-palette,.pcr-app .pcr-slider{transition:box-shadow .3s}.pickr .pcr-palette:focus,.pickr .pcr-slider:focus,.pcr-app .pcr-palette:focus,.pcr-app .pcr-slider:focus{box-shadow:0 0 0 1px rgba(255,255,255,.85),0 0 0 3px rgba(0,0,0,.25)}.pcr-app{position:fixed;display:flex;flex-direction:column;z-index:10000;border-radius:.1em;background:#fff;opacity:0;visibility:hidden;transition:opacity .3s,visibility 0s .3s;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Roboto","Helvetica Neue",Arial,sans-serif;box-shadow:0 .15em 1.5em 0 rgba(0,0,0,.1),0 0 1em 0 rgba(0,0,0,.03);left:0;top:0}.pcr-app.visible{transition:opacity .3s;visibility:visible;opacity:1}.pcr-app .pcr-swatches{display:flex;flex-wrap:wrap;margin-top:.75em}.pcr-app .pcr-swatches.pcr-last{margin:0}@supports(display: grid){.pcr-app .pcr-swatches{display:grid;align-items:center;grid-template-columns:repeat(auto-fit, 1.75em)}}.pcr-app .pcr-swatches>button{font-size:1em;position:relative;width:calc(1.75em - 5px);height:calc(1.75em - 5px);border-radius:.15em;cursor:pointer;margin:2.5px;flex-shrink:0;justify-self:center;transition:all .15s;overflow:hidden;background:rgba(0,0,0,0);z-index:1}.pcr-app .pcr-swatches>button::before{position:absolute;content:"";top:0;left:0;width:100%;height:100%;background:url("data:image/svg+xml;utf8, ");background-size:6px;border-radius:.15em;z-index:-1}.pcr-app .pcr-swatches>button::after{content:"";position:absolute;top:0;left:0;width:100%;height:100%;background:var(--pcr-color);border:1px solid rgba(0,0,0,.05);border-radius:.15em;box-sizing:border-box}.pcr-app .pcr-swatches>button:hover{filter:brightness(1.05)}.pcr-app .pcr-swatches>button:not(.pcr-active){box-shadow:none}.pcr-app .pcr-interaction{display:flex;flex-wrap:wrap;align-items:center;margin:0 -0.2em 0 -0.2em}.pcr-app .pcr-interaction>*{margin:0 .2em}.pcr-app .pcr-interaction input{letter-spacing:.07em;font-size:.75em;text-align:center;cursor:pointer;color:#75797e;background:#f1f3f4;border-radius:.15em;transition:all .15s;padding:.45em .5em;margin-top:.75em}.pcr-app .pcr-interaction input:hover{filter:brightness(0.975)}.pcr-app .pcr-interaction input:focus{box-shadow:0 0 0 1px rgba(255,255,255,.85),0 0 0 3px rgba(66,133,244,.75)}.pcr-app .pcr-interaction .pcr-result{color:#75797e;text-align:left;flex:1 1 8em;min-width:8em;transition:all .2s;border-radius:.15em;background:#f1f3f4;cursor:text}.pcr-app .pcr-interaction .pcr-result::-moz-selection{background:#4285f4;color:#fff}.pcr-app .pcr-interaction .pcr-result::selection{background:#4285f4;color:#fff}.pcr-app .pcr-interaction .pcr-type.active{color:#fff;background:#4285f4}.pcr-app .pcr-interaction .pcr-save,.pcr-app .pcr-interaction .pcr-cancel,.pcr-app .pcr-interaction .pcr-clear{color:#fff;width:auto}.pcr-app .pcr-interaction .pcr-save,.pcr-app .pcr-interaction .pcr-cancel,.pcr-app .pcr-interaction .pcr-clear{color:#fff}.pcr-app .pcr-interaction .pcr-save:hover,.pcr-app .pcr-interaction .pcr-cancel:hover,.pcr-app .pcr-interaction .pcr-clear:hover{filter:brightness(0.925)}.pcr-app .pcr-interaction .pcr-save{background:#4285f4}.pcr-app .pcr-interaction .pcr-clear,.pcr-app .pcr-interaction .pcr-cancel{background:#f44250}.pcr-app .pcr-interaction .pcr-clear:focus,.pcr-app .pcr-interaction .pcr-cancel:focus{box-shadow:0 0 0 1px rgba(255,255,255,.85),0 0 0 3px rgba(244,66,80,.75)}.pcr-app .pcr-selection .pcr-picker{position:absolute;height:18px;width:18px;border:2px solid #fff;border-radius:100%;-webkit-user-select:none;-moz-user-select:none;user-select:none}.pcr-app .pcr-selection .pcr-color-palette,.pcr-app .pcr-selection .pcr-color-chooser,.pcr-app .pcr-selection .pcr-color-opacity{position:relative;-webkit-user-select:none;-moz-user-select:none;user-select:none;display:flex;flex-direction:column;cursor:grab;cursor:-webkit-grab}.pcr-app .pcr-selection .pcr-color-palette:active,.pcr-app .pcr-selection .pcr-color-chooser:active,.pcr-app .pcr-selection .pcr-color-opacity:active{cursor:grabbing;cursor:-webkit-grabbing}.pcr-app[data-theme=classic]{width:28.5em;max-width:95vw;padding:.8em}.pcr-app[data-theme=classic] .pcr-selection{display:flex;justify-content:space-between;flex-grow:1}.pcr-app[data-theme=classic] .pcr-selection .pcr-color-preview{position:relative;z-index:1;width:2em;display:flex;flex-direction:column;justify-content:space-between;margin-right:.75em}.pcr-app[data-theme=classic] .pcr-selection .pcr-color-preview::before{position:absolute;content:"";top:0;left:0;width:100%;height:100%;background:url("data:image/svg+xml;utf8, ");background-size:.5em;border-radius:.15em;z-index:-1}.pcr-app[data-theme=classic] .pcr-selection .pcr-color-preview .pcr-last-color{cursor:pointer;border-radius:.15em .15em 0 0;z-index:2}.pcr-app[data-theme=classic] .pcr-selection .pcr-color-preview .pcr-current-color{border-radius:0 0 .15em .15em}.pcr-app[data-theme=classic] .pcr-selection .pcr-color-preview .pcr-last-color,.pcr-app[data-theme=classic] .pcr-selection .pcr-color-preview .pcr-current-color{background:var(--pcr-color);width:100%;height:50%}.pcr-app[data-theme=classic] .pcr-selection .pcr-color-palette{width:100%;height:8em;z-index:1}.pcr-app[data-theme=classic] .pcr-selection .pcr-color-palette .pcr-palette{flex-grow:1;border-radius:.15em}.pcr-app[data-theme=classic] .pcr-selection .pcr-color-palette .pcr-palette::before{position:absolute;content:"";top:0;left:0;width:100%;height:100%;background:url("data:image/svg+xml;utf8, ");background-size:.5em;border-radius:.15em;z-index:-1}.pcr-app[data-theme=classic] .pcr-selection .pcr-color-chooser,.pcr-app[data-theme=classic] .pcr-selection .pcr-color-opacity{margin-left:.75em}.pcr-app[data-theme=classic] .pcr-selection .pcr-color-chooser .pcr-picker,.pcr-app[data-theme=classic] .pcr-selection .pcr-color-opacity .pcr-picker{left:50%;transform:translateX(-50%)}.pcr-app[data-theme=classic] .pcr-selection .pcr-color-chooser .pcr-slider,.pcr-app[data-theme=classic] .pcr-selection .pcr-color-opacity .pcr-slider{width:8px;flex-grow:1;border-radius:50em}.pcr-app[data-theme=classic] .pcr-selection .pcr-color-chooser .pcr-slider{background:linear-gradient(to bottom, hsl(0, 100%, 50%), hsl(60, 100%, 50%), hsl(120, 100%, 50%), hsl(180, 100%, 50%), hsl(240, 100%, 50%), hsl(300, 100%, 50%), hsl(0, 100%, 50%))}.pcr-app[data-theme=classic] .pcr-selection .pcr-color-opacity .pcr-slider{background:linear-gradient(to bottom, transparent, black),url("data:image/svg+xml;utf8, ");background-size:100%,50%} 3 | -------------------------------------------------------------------------------- /static/js/custom_node.js: -------------------------------------------------------------------------------- 1 | import {app} from "/scripts/app.js"; 2 | import {$el} from "/scripts/ui.js"; 3 | import {ComfyWidgets} from "/scripts/widgets.js"; 4 | 5 | function get_position_style(ctx, node_width, node_height, y, widget_height, isSingle) { 6 | /* Create a transform that deals with all the scrolling and zooming */ 7 | const elRect = ctx.canvas.getBoundingClientRect(); 8 | if (isSingle) { 9 | y = y - 86 10 | y = y + Math.floor((node_height - y - widget_height) / 2) 11 | } 12 | const transform = new DOMMatrix() 13 | .scaleSelf( 14 | elRect.width / ctx.canvas.width, 15 | elRect.height / ctx.canvas.height 16 | ) 17 | .multiplySelf(ctx.getTransform()) 18 | .translateSelf(Math.floor((node_width - widget_height) / 2), y); 19 | 20 | return { 21 | transformOrigin: '0 0', 22 | transform: transform, 23 | left: '0', 24 | top: '0', 25 | cursor: 'pointer', 26 | position: 'absolute', 27 | display: 'flex', 28 | flexDirection: 'column', 29 | justifyContent: 'space-around', 30 | zIndex: '0' 31 | }; 32 | } 33 | 34 | function createColorWidget(node, inputName, inputData, app, isSingle) { 35 | // console.log(node) 36 | const widget = { 37 | type: inputData[0], // the type, CHEESE 38 | name: inputName, // the name, slice 39 | size: [64, 64], // a default size 40 | draw(ctx, node, widget_width, y, H) { 41 | // console.log(node.size[0], node.size[1], y, widget_width, H) 42 | Object.assign( 43 | this.div.style, 44 | get_position_style(ctx, widget_width, node.size[1], y, this.div.clientHeight ? this.div.clientHeight : 0, isSingle) 45 | ); 46 | }, 47 | computeSize(widget_width) { 48 | // console.log(widget_width) 49 | return [64, 64] // a method to compute the current size of the widget 50 | }, 51 | async serializeValue(nodeId, widgetIndex) { 52 | let hexa = widget.value || '#000000' 53 | return hexa 54 | } 55 | } 56 | // adds it to the node 57 | node.addCustomWidget(widget) 58 | widget.div = $el('div', {}) 59 | let inputColor = document.createElement('div') 60 | inputColor.id = 'easyapi-color-picker' 61 | widget.div.appendChild(inputColor); 62 | document.body.appendChild(widget.div) 63 | const picker = Pickr.create({ 64 | el: inputColor, 65 | theme: 'classic', 66 | default: '#000000', 67 | swatches: [ 68 | 'rgba(244, 67, 54, 1)', 69 | 'rgba(233, 30, 99, 0.95)', 70 | 'rgba(156, 39, 176, 0.9)', 71 | 'rgba(103, 58, 183, 0.85)', 72 | 'rgba(63, 81, 181, 0.8)', 73 | 'rgba(33, 150, 243, 0.75)', 74 | 'rgba(3, 169, 244, 0.7)', 75 | 'rgba(0, 188, 212, 0.7)', 76 | 'rgba(0, 150, 136, 0.75)', 77 | 'rgba(76, 175, 80, 0.8)', 78 | 'rgba(139, 195, 74, 0.85)', 79 | 'rgba(205, 220, 57, 0.9)', 80 | 'rgba(255, 235, 59, 0.95)', 81 | 'rgba(255, 193, 7, 1)' 82 | ], 83 | components: { 84 | // Main components 85 | preview: true, 86 | opacity: true, 87 | hue: true, 88 | // Input / output Options 89 | interaction: { 90 | hex: true, 91 | rgba: true, 92 | hsla: true, 93 | hsva: true, 94 | cmyk: true, 95 | input: true, 96 | clear: false, 97 | save: true, 98 | cancel: true 99 | } 100 | } 101 | }) 102 | 103 | picker.on('init', instance => { 104 | if (!!widget.value) { 105 | instance.setColor(widget.value); 106 | } 107 | }).on('save', (color, instance) => { 108 | try { 109 | widget.value = color.toHEXA().toString() 110 | picker && picker.hide() 111 | } catch (error) { 112 | } 113 | }).on('cancel', instance => { 114 | picker && picker.hide() 115 | }) 116 | 117 | if (node.picker === undefined) { 118 | node.picker = [picker] 119 | } else { 120 | node.picker.push(picker); 121 | } 122 | const handleMouseWheel = () => { 123 | try { 124 | if (!!node.picker) { 125 | for (let idx in node.picker) { 126 | node.picker[idx].hide() 127 | } 128 | } 129 | } catch (error) { 130 | } 131 | } 132 | // close selector 133 | document.addEventListener('wheel', handleMouseWheel) 134 | 135 | const onRemoved = node.onRemoved 136 | node.onRemoved = () => { 137 | try { 138 | for (let idx in node.picker) { 139 | if (!!node.picker[idx].widgets) { 140 | for (let wIdx in node.picker[idx].widgets) { 141 | node.picker[idx].widgets[wIdx].div && node.picker[idx].widgets[wIdx].div.remove() 142 | } 143 | } 144 | node.picker[idx].destroyAndRemove(); 145 | } 146 | node.picker = null 147 | document.removeEventListener('wheel', handleMouseWheel) 148 | } catch (error) { 149 | console.log(error) 150 | } 151 | return onRemoved?.() 152 | } 153 | node.serialize_widgets = true 154 | return widget; 155 | } 156 | 157 | app.registerExtension({ 158 | name: "Comfy.EasyApi.custom", 159 | async init(app) { 160 | // Any initial setup to run as soon as the page loads 161 | $el('link', { 162 | rel: 'stylesheet', 163 | href: '/extensions/comfyui-easyapi-nodes/css/classic.min.css', 164 | parent: document.head 165 | }) 166 | }, 167 | async setup(app) { 168 | 169 | }, 170 | async addCustomNodeDefs(defs, app) { 171 | // Add custom node definitions 172 | // These definitions will be configured and registered automatically 173 | // defs is a lookup core nodes, add yours into this 174 | }, 175 | async getCustomWidgets(app) { 176 | // Return custom widget types 177 | // See ComfyWidgets for widget examples 178 | return { 179 | SINGLECOLORPICKER(node, inputName, inputData, app) { 180 | return createColorWidget(node, inputName, inputData, app, true) 181 | }, 182 | COLORPICKER(node, inputName, inputData, app) { 183 | return createColorWidget(node, inputName, inputData, app, false) 184 | } 185 | } 186 | }, 187 | 188 | async beforeRegisterNodeDef(nodeType, nodeData, app) { 189 | // Allows the extension to add additional handling to the node before it is registered with LGraph 190 | const onDrawForeground = nodeType.prototype.onDrawForeground; 191 | nodeType.prototype.onDrawForeground = function (ctx) { 192 | const r = onDrawForeground?.apply?.(this, arguments); 193 | if (this.flags.collapsed) { 194 | if (this.picker && this.widgets) { 195 | for (const i in this.widgets) { 196 | let w = this.widgets[i] 197 | if (w.type == 'SINGLECOLORPICKER' || w.type == 'COLORPICKER') { 198 | // hide it 199 | w.div.style = ""; 200 | } 201 | } 202 | } 203 | } 204 | 205 | return r; 206 | }; 207 | 208 | if (nodeData.name === "ShowString" || nodeData.name === "ShowInt" || nodeData.name === "ShowNumber" || nodeData.name === "ShowFloat" || nodeData.name === "ShowBoolean") { 209 | const outSet = function (text) { 210 | if (this.widgets) { 211 | // if multiline is true, w.type will be customtext 212 | // find the position of first "customtext" 213 | const pos = this.widgets.findIndex((w) => w.type === "customtext"); 214 | if (pos !== -1) { 215 | for (let i = pos; i < this.widgets.length; i++) { 216 | this.widgets[i].onRemove?.(); 217 | } 218 | this.widgets.length = pos; 219 | } 220 | } 221 | 222 | if (Array.isArray(text)) { 223 | for (const list of text) { 224 | const w = ComfyWidgets.STRING(this, "text", ["STRING", {multiline: true}], app).widget; 225 | w.inputEl.readOnly = true; 226 | w.inputEl.style.opacity = "0.6"; 227 | w.value = list; 228 | } 229 | } else { 230 | const w = ComfyWidgets.STRING(this, "text", ["STRING", {multiline: true}], app).widget; 231 | w.inputEl.readOnly = true; 232 | w.inputEl.style.opacity = "0.6"; 233 | w.value = text; 234 | } 235 | 236 | requestAnimationFrame(() => { 237 | const sz = this.computeSize(); 238 | if (sz[0] < this.size[0]) { 239 | sz[0] = this.size[0]; 240 | } 241 | if (sz[1] < this.size[1]) { 242 | sz[1] = this.size[1]; 243 | } 244 | this.onResize?.(sz); 245 | app.graph.setDirtyCanvas(true, false); 246 | }); 247 | } 248 | 249 | const onExecuted = nodeType.prototype.onExecuted; 250 | nodeType.prototype.onExecuted = function (texts) { 251 | onExecuted?.apply(this, arguments); 252 | let show = [] 253 | for (let k in texts) { 254 | show = texts[k] 255 | } 256 | outSet.call(this, show); 257 | }; 258 | /*const onConfigure = nodeType.prototype.onConfigure; 259 | nodeType.prototype.onConfigure = function (w) { 260 | onConfigure?.apply(this, arguments); 261 | if (w?.widgets_values?.length) { 262 | outSet.call(this, w.widgets_values[0]); 263 | } 264 | };*/ 265 | } 266 | }, 267 | async registerCustomNodes(app) { 268 | // Register any custom node implementations here allowing for more flexability than a custom node def 269 | // console.log("[logging]", "register custom nodes"); 270 | }, 271 | async loadedGraphNode(node, app) { 272 | // Fires for each node when loading/dragging/etc a workflow json or png 273 | // If you break something in the backend and want to patch workflows in the frontend 274 | // This fires for every node on each load so only log once 275 | // delete ext.loadedGraphNode; 276 | 277 | }, 278 | async nodeCreated(node, app) { 279 | // Fires every time a node is constructed 280 | // You can modify widgets/add handlers/etc here 281 | } 282 | }) -------------------------------------------------------------------------------- /static/js/debounce.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 防抖函数 3 | * @param fn 4 | * @param delay 5 | * @param immediate 6 | * @returns {function(...[*]): Promise} 7 | * @source https://juejin.cn/post/7075890311649558541 8 | */ 9 | export function debounce(fn, delay, immediate = false) { 10 | // 1.定义一个定时器, 保存上一次的定时器 11 | let timer = null 12 | let isInvoke = false 13 | 14 | // 2.真正执行的函数 15 | const _debounce = function (...args) { 16 | return new Promise((resolve, reject) => { 17 | // 取消上一次的定时器 18 | if (timer) clearTimeout(timer) 19 | 20 | // 判断是否需要立即执行 21 | if (immediate && !isInvoke) { 22 | const result = fn.apply(this, args) 23 | resolve(result) 24 | isInvoke = true 25 | } else { 26 | // 延迟执行 27 | timer = setTimeout(() => { 28 | // 外部传入的真正要执行的函数, 拿到函数返回值并调用resolve 29 | const result = fn.apply(this, args) 30 | resolve(result) 31 | isInvoke = false 32 | timer = null 33 | }, delay) 34 | } 35 | }) 36 | } 37 | 38 | // 封装取消功能 39 | _debounce.cancel = function () { 40 | if (timer) clearTimeout(timer) 41 | timer = null 42 | isInvoke = false 43 | } 44 | 45 | return _debounce 46 | } 47 | -------------------------------------------------------------------------------- /static/js/dialog.js: -------------------------------------------------------------------------------- 1 | import { $el, ComfyDialog } from "/scripts/ui.js"; 2 | 3 | export class EasyApiDialog extends ComfyDialog { 4 | constructor() { 5 | super(); 6 | this.element.classList.add("easyapi-dialog"); 7 | this.initTitle(); 8 | this.showSave = false; 9 | this.saveCb = () => {}; 10 | } 11 | 12 | createButtons() { 13 | return [ 14 | $el("button.easyapi-dialog-save", 15 | { 16 | type: "button", 17 | textContent: "Update", 18 | onclick: () => this.save() 19 | } 20 | ), 21 | $el("button", { 22 | type: "button", 23 | textContent: "Close", 24 | onclick: () => this.close(), 25 | }), 26 | ]; 27 | } 28 | 29 | title(title) { 30 | let titleDiv = this.element.querySelector("div.easyapi-dialog-title") 31 | titleDiv.innerText = title; 32 | return this; 33 | } 34 | 35 | resetPos() { 36 | this.element.style.left="50%"; 37 | this.element.style.top="50%"; 38 | this.element.style.transform="translate(-50%, -50%)"; 39 | return this; 40 | } 41 | initTitle() { 42 | let contentDiv = this.element.querySelector("div.comfy-modal-content") 43 | let titleDiv = $el("div.easyapi-dialog-title", { 44 | content: "title", 45 | style: { 46 | width: "100%", 47 | position: "absolute", 48 | backgroundColor: "#2D2D2D", 49 | top: 0, 50 | left: 0, 51 | textAlign: "center", 52 | height: "40px", 53 | lineHeight: "40px", 54 | cursor: "move", 55 | color: "#ffffff", 56 | fontWeight: "bolder", 57 | borderBottom: "1px solid #161616", 58 | } 59 | }) 60 | this.element.insertBefore(titleDiv, contentDiv); 61 | this.initDragElement(this.element, titleDiv); 62 | } 63 | initDragElement(mainEl, titleEl) { 64 | let offset_x = 0, offset_y = 0, start_x = 0, start_y = 0; 65 | if (titleEl) { 66 | /* 如果存在就是移动 DIV 的地方:*/ 67 | titleEl.onmousedown = dragMouseDown; 68 | } else { 69 | /* 否则,从 DIV 内的任何位置移动:*/ 70 | mainEl.onmousedown = dragMouseDown; 71 | } 72 | 73 | function dragMouseDown(e) { 74 | e = e || window.event; 75 | e.preventDefault(); 76 | // 在启动时获取鼠标光标位置: 77 | start_x = e.clientX; 78 | start_y = e.clientY; 79 | mainEl.style.opacity="0.9" 80 | document.onmouseup = closeDragElement; 81 | // 当光标移动时调用一个函数: 82 | document.onmousemove = dragMouseMove; 83 | } 84 | 85 | function dragMouseMove(e) { 86 | e = e || window.event; 87 | e.preventDefault(); 88 | // 计算新的光标位置: 89 | offset_x = start_x - e.clientX; 90 | offset_y = start_y - e.clientY; 91 | start_x = e.clientX; 92 | start_y = e.clientY; 93 | // 设置元素的新位置: 94 | mainEl.style.top = (mainEl.offsetTop - offset_y) + "px"; 95 | mainEl.style.left = (mainEl.offsetLeft - offset_x) + "px"; 96 | mainEl.style.width = mainEl.keepWidth + "px"; 97 | } 98 | 99 | function closeDragElement() { 100 | /* 释放鼠标按钮时停止移动:*/ 101 | mainEl.style.opacity="1" 102 | document.onmouseup = null; 103 | document.onmousemove = null; 104 | } 105 | } 106 | close() { 107 | this.element.style.display = "none"; 108 | } 109 | 110 | save() { 111 | this.saveCb && this.saveCb(this); 112 | } 113 | showSaveBtn(saveBtnLabel="Update") { 114 | let saveBtn = this.element.getElementsByClassName("easyapi-dialog-save")[0] 115 | saveBtn.innerHTML = saveBtnLabel; 116 | saveBtn.style.display = ""; 117 | } 118 | hideSaveBtn() { 119 | let saveBtn = this.element.getElementsByClassName("easyapi-dialog-save")[0] 120 | saveBtn.style.display = "none"; 121 | } 122 | 123 | show(html, showSave, saveCb, saveBtnLabel="Update") { 124 | if (typeof html === "string") { 125 | this.textElement.innerHTML = html; 126 | } else { 127 | this.textElement.replaceChildren(html); 128 | } 129 | this.showSave = showSave || false; 130 | if (this.showSave) { 131 | this.showSaveBtn(saveBtnLabel); 132 | } else { 133 | this.hideSaveBtn(); 134 | } 135 | this.saveCb = saveCb 136 | this.element.style.display = "flex"; 137 | this.element.keepWidth = this.element.clientWidth; 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /static/js/image_node.js: -------------------------------------------------------------------------------- 1 | import { app } from "/scripts/app.js"; 2 | import { api } from "/scripts/api.js"; 3 | // ================= CREATE EXTENSION ================ 4 | /*app.registerExtension({ 5 | name: "Comfy.EasyApiImageNode", 6 | async beforeRegisterNodeDef(nodeType, nodeData, app) { 7 | if (nodeData.name === "Base64ToImage") { 8 | console.log(nodeData) 9 | } 10 | }, 11 | });*/ 12 | api.addEventListener("executed", ({detail}) => { 13 | const images = detail?.output?.base64Images; 14 | if (!images) return; 15 | const currentNode = app.graph._nodes_by_id[detail.node]; 16 | // console.log(currentNode.imgs) 17 | currentNode.imgs = []; 18 | for(let i in images){ 19 | let img = images[i] 20 | let image = new Image() 21 | image.onload = () => { 22 | currentNode.imgs.push(image); 23 | currentNode.setSizeForImage?.(); 24 | app.graph.setDirtyCanvas(true, true); 25 | }; 26 | image.src=img; 27 | } 28 | }); 29 | // ================= END CREATE EXTENSION ================ 30 | -------------------------------------------------------------------------------- /static/js/pickr.min.js: -------------------------------------------------------------------------------- 1 | /*! Pickr 1.9.0 MIT | https://github.com/Simonwep/pickr */ 2 | !function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.Pickr=e():t.Pickr=e()}(self,(()=>(()=>{"use strict";var t={d:(e,o)=>{for(var n in o)t.o(o,n)&&!t.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:o[n]})},o:(t,e)=>Object.prototype.hasOwnProperty.call(t,e),r:t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})}},e={};t.d(e,{default:()=>E});var o={};function n(t,e,o,n,i={}){e instanceof HTMLCollection||e instanceof NodeList?e=Array.from(e):Array.isArray(e)||(e=[e]),Array.isArray(o)||(o=[o]);for(const s of e)for(const e of o)s[t](e,n,{capture:!1,...i});return Array.prototype.slice.call(arguments,1)}t.r(o),t.d(o,{adjustableInputNumbers:()=>p,createElementFromString:()=>r,createFromTemplate:()=>a,eventPath:()=>l,off:()=>s,on:()=>i,resolveElement:()=>c});const i=n.bind(null,"addEventListener"),s=n.bind(null,"removeEventListener");function r(t){const e=document.createElement("div");return e.innerHTML=t.trim(),e.firstElementChild}function a(t){const e=(t,e)=>{const o=t.getAttribute(e);return t.removeAttribute(e),o},o=(t,n={})=>{const i=e(t,":obj"),s=e(t,":ref"),r=i?n[i]={}:n;s&&(n[s]=t);for(const n of Array.from(t.children)){const t=e(n,":arr"),i=o(n,t?{}:r);t&&(r[t]||(r[t]=[])).push(Object.keys(i).length?i:n)}return n};return o(r(t))}function l(t){let e=t.path||t.composedPath&&t.composedPath();if(e)return e;let o=t.target.parentElement;for(e=[t.target,o];o=o.parentElement;)e.push(o);return e.push(document,window),e}function c(t){return t instanceof Element?t:"string"==typeof t?t.split(/>>/g).reduce(((t,e,o,n)=>(t=t.querySelector(e),ot)){function o(o){const n=[.001,.01,.1][Number(o.shiftKey||2*o.ctrlKey)]*(o.deltaY<0?1:-1);let i=0,s=t.selectionStart;t.value=t.value.replace(/[\d.]+/g,((t,o)=>o<=s&&o+t.length>=s?(s=o,e(Number(t),n,i)):(i++,t))),t.focus(),t.setSelectionRange(s,s),o.preventDefault(),t.dispatchEvent(new Event("input"))}i(t,"focus",(()=>i(window,"wheel",o,{passive:!1}))),i(t,"blur",(()=>s(window,"wheel",o)))}const{min:u,max:h,floor:d,round:m}=Math;function f(t,e,o){e/=100,o/=100;const n=d(t=t/360*6),i=t-n,s=o*(1-e),r=o*(1-i*e),a=o*(1-(1-i)*e),l=n%6;return[255*[o,r,s,s,a,o][l],255*[a,o,o,r,s,s][l],255*[s,s,a,o,o,r][l]]}function v(t,e,o){const n=(2-(e/=100))*(o/=100)/2;return 0!==n&&(e=1===n?0:n<.5?e*o/(2*n):e*o/(2-2*n)),[t,100*e,100*n]}function b(t,e,o){const n=u(t/=255,e/=255,o/=255),i=h(t,e,o),s=i-n;let r,a;if(0===s)r=a=0;else{a=s/i;const n=((i-t)/6+s/2)/s,l=((i-e)/6+s/2)/s,c=((i-o)/6+s/2)/s;t===i?r=c-l:e===i?r=1/3+n-c:o===i&&(r=2/3+l-n),r<0?r+=1:r>1&&(r-=1)}return[360*r,100*a,100*i]}function y(t,e,o,n){e/=100,o/=100;return[...b(255*(1-u(1,(t/=100)*(1-(n/=100))+n)),255*(1-u(1,e*(1-n)+n)),255*(1-u(1,o*(1-n)+n)))]}function g(t,e,o){e/=100;const n=2*(e*=(o/=100)<.5?o:1-o)/(o+e)*100,i=100*(o+e);return[t,isNaN(n)?0:n,i]}function _(t){return b(...t.match(/.{2}/g).map((t=>parseInt(t,16))))}function w(t){t=t.match(/^[a-zA-Z]+$/)?function(t){if("black"===t.toLowerCase())return"#000";const e=document.createElement("canvas").getContext("2d");return e.fillStyle=t,"#000"===e.fillStyle?null:e.fillStyle}(t):t;const e={cmyk:/^cmyk\D+([\d.]+)\D+([\d.]+)\D+([\d.]+)\D+([\d.]+)/i,rgba:/^rgba?\D+([\d.]+)(%?)\D+([\d.]+)(%?)\D+([\d.]+)(%?)\D*?(([\d.]+)(%?)|$)/i,hsla:/^hsla?\D+([\d.]+)\D+([\d.]+)\D+([\d.]+)\D*?(([\d.]+)(%?)|$)/i,hsva:/^hsva?\D+([\d.]+)\D+([\d.]+)\D+([\d.]+)\D*?(([\d.]+)(%?)|$)/i,hexa:/^#?(([\dA-Fa-f]{3,4})|([\dA-Fa-f]{6})|([\dA-Fa-f]{8}))$/i},o=t=>t.map((t=>/^(|\d+)\.\d+|\d+$/.test(t)?Number(t):void 0));let n;t:for(const i in e)if(n=e[i].exec(t))switch(i){case"cmyk":{const[,t,e,s,r]=o(n);if(t>100||e>100||s>100||r>100)break t;return{values:y(t,e,s,r),type:i}}case"rgba":{let[,t,,e,,s,,,r]=o(n);if(t="%"===n[2]?t/100*255:t,e="%"===n[4]?e/100*255:e,s="%"===n[6]?s/100*255:s,r="%"===n[9]?r/100:r,t>255||e>255||s>255||r<0||r>1)break t;return{values:[...b(t,e,s),r],a:r,type:i}}case"hexa":{let[,t]=n;4!==t.length&&3!==t.length||(t=t.split("").map((t=>t+t)).join(""));const e=t.substring(0,6);let o=t.substring(6);return o=o?parseInt(o,16)/255:void 0,{values:[..._(e),o],a:o,type:i}}case"hsla":{let[,t,e,s,,r]=o(n);if(r="%"===n[6]?r/100:r,t>360||e>100||s>100||r<0||r>1)break t;return{values:[...g(t,e,s),r],a:r,type:i}}case"hsva":{let[,t,e,s,,r]=o(n);if(r="%"===n[6]?r/100:r,t>360||e>100||s>100||r<0||r>1)break t;return{values:[t,e,s,r],a:r,type:i}}}return{values:null,type:null}}function A(t=0,e=0,o=0,n=1){const i=(t,e)=>(o=-1)=>e(~o?t.map((t=>Number(t.toFixed(o)))):t),s={h:t,s:e,v:o,a:n,toHSVA(){const t=[s.h,s.s,s.v,s.a];return t.toString=i(t,(t=>`hsva(${t[0]}, ${t[1]}%, ${t[2]}%, ${s.a})`)),t},toHSLA(){const t=[...v(s.h,s.s,s.v),s.a];return t.toString=i(t,(t=>`hsla(${t[0]}, ${t[1]}%, ${t[2]}%, ${s.a})`)),t},toRGBA(){const t=[...f(s.h,s.s,s.v),s.a];return t.toString=i(t,(t=>`rgba(${t[0]}, ${t[1]}, ${t[2]}, ${s.a})`)),t},toCMYK(){const t=function(t,e,o){const n=f(t,e,o),i=n[0]/255,s=n[1]/255,r=n[2]/255,a=u(1-i,1-s,1-r);return[100*(1===a?0:(1-i-a)/(1-a)),100*(1===a?0:(1-s-a)/(1-a)),100*(1===a?0:(1-r-a)/(1-a)),100*a]}(s.h,s.s,s.v);return t.toString=i(t,(t=>`cmyk(${t[0]}%, ${t[1]}%, ${t[2]}%, ${t[3]}%)`)),t},toHEXA(){const t=function(t,e,o){return f(t,e,o).map((t=>m(t).toString(16).padStart(2,"0")))}(s.h,s.s,s.v),e=s.a>=1?"":Number((255*s.a).toFixed(0)).toString(16).toUpperCase().padStart(2,"0");return e&&t.push(e),t.toString=()=>`#${t.join("").toUpperCase()}`,t},clone:()=>A(s.h,s.s,s.v,s.a)};return s}const $=t=>Math.max(Math.min(t,1),0);function C(t){const e={options:Object.assign({lock:null,onchange:()=>0,onstop:()=>0},t),_keyboard(t){const{options:o}=e,{type:n,key:i}=t;if(document.activeElement===o.wrapper){const{lock:o}=e.options,s="ArrowUp"===i,r="ArrowRight"===i,a="ArrowDown"===i,l="ArrowLeft"===i;if("keydown"===n&&(s||r||a||l)){let n=0,i=0;"v"===o?n=s||r?1:-1:"h"===o?n=s||r?-1:1:(i=s?-1:a?1:0,n=l?-1:r?1:0),e.update($(e.cache.x+.01*n),$(e.cache.y+.01*i)),t.preventDefault()}else i.startsWith("Arrow")&&(e.options.onstop(),t.preventDefault())}},_tapstart(t){i(document,["mouseup","touchend","touchcancel"],e._tapstop),i(document,["mousemove","touchmove"],e._tapmove),t.cancelable&&t.preventDefault(),e._tapmove(t)},_tapmove(t){const{options:o,cache:n}=e,{lock:i,element:s,wrapper:r}=o,a=r.getBoundingClientRect();let l=0,c=0;if(t){const e=t&&t.touches&&t.touches[0];l=t?(e||t).clientX:0,c=t?(e||t).clientY:0,la.left+a.width&&(l=a.left+a.width),ca.top+a.height&&(c=a.top+a.height),l-=a.left,c-=a.top}else n&&(l=n.x*a.width,c=n.y*a.height);"h"!==i&&(s.style.left=`calc(${l/a.width*100}% - ${s.offsetWidth/2}px)`),"v"!==i&&(s.style.top=`calc(${c/a.height*100}% - ${s.offsetHeight/2}px)`),e.cache={x:l/a.width,y:c/a.height};const p=$(l/a.width),u=$(c/a.height);switch(i){case"v":return o.onchange(p);case"h":return o.onchange(u);default:return o.onchange(p,u)}},_tapstop(){e.options.onstop(),s(document,["mouseup","touchend","touchcancel"],e._tapstop),s(document,["mousemove","touchmove"],e._tapmove)},trigger(){e._tapmove()},update(t=0,o=0){const{left:n,top:i,width:s,height:r}=e.options.wrapper.getBoundingClientRect();"h"===e.options.lock&&(o=t),e._tapmove({clientX:n+s*t,clientY:i+r*o})},destroy(){const{options:t,_tapstart:o,_keyboard:n}=e;s(document,["keydown","keyup"],n),s([t.wrapper,t.element],"mousedown",o),s([t.wrapper,t.element],"touchstart",o,{passive:!1})}},{options:o,_tapstart:n,_keyboard:r}=e;return i([o.wrapper,o.element],"mousedown",n),i([o.wrapper,o.element],"touchstart",n,{passive:!1}),i(document,["keydown","keyup"],r),e}function k(t={}){t=Object.assign({onchange:()=>0,className:"",elements:[]},t);const e=i(t.elements,"click",(e=>{t.elements.forEach((o=>o.classList[e.target===o?"add":"remove"](t.className))),t.onchange(e),e.stopPropagation()}));return{destroy:()=>s(...e)}}const S={variantFlipOrder:{start:"sme",middle:"mse",end:"ems"},positionFlipOrder:{top:"tbrl",right:"rltb",bottom:"btrl",left:"lrbt"},position:"bottom",margin:8,padding:0},O=(t,e,o)=>{const n="object"!=typeof t||t instanceof HTMLElement?{reference:t,popper:e,...o}:t;return{update(t=n){const{reference:e,popper:o}=Object.assign(n,t);if(!o||!e)throw new Error("Popper- or reference-element missing.");return((t,e,o)=>{const{container:n,arrow:i,margin:s,padding:r,position:a,variantFlipOrder:l,positionFlipOrder:c}={container:document.documentElement.getBoundingClientRect(),...S,...o},{left:p,top:u}=e.style;e.style.left="0",e.style.top="0";const h=t.getBoundingClientRect(),d=e.getBoundingClientRect(),m={t:h.top-d.height-s,b:h.bottom+s,r:h.right+s,l:h.left-d.width-s},f={vs:h.left,vm:h.left+h.width/2-d.width/2,ve:h.left+h.width-d.width,hs:h.top,hm:h.bottom-h.height/2-d.height/2,he:h.bottom-d.height},[v,b="middle"]=a.split("-"),y=c[v],g=l[b],{top:_,left:w,bottom:A,right:$}=n;for(const t of y){const o="t"===t||"b"===t;let n=m[t];const[s,a]=o?["top","left"]:["left","top"],[l,c]=o?[d.height,d.width]:[d.width,d.height],[p,u]=o?[A,$]:[$,A],[v,b]=o?[_,w]:[w,_];if(!(np))for(const p of g){let m=f[(o?"v":"h")+p];if(!(mu)){if(m-=d[a],n-=d[s],e.style[a]=`${m}px`,e.style[s]=`${n}px`,i){const t=o?h.width/2:h.height/2,e=2*tthis.addSwatch(t)));const{button:u,app:h}=this._root;this._nanopop=O(u,h,{margin:r}),u.setAttribute("role","button"),u.setAttribute("aria-label",this._t("btn:toggle"));const d=this;this._setupAnimationFrame=requestAnimationFrame((function e(){if(!h.offsetWidth)return requestAnimationFrame(e);d.setColor(t.default),d._rePositioningPicker(),t.defaultRepresentation&&(d._representation=t.defaultRepresentation,d.setColorRepresentation(d._representation)),t.showAlways&&d.show(),d._initializingActive=!1,d._emit("init")}))}static create=t=>new E(t);_preBuild(){const{options:t}=this;for(const e of["el","container"])t[e]=c(t[e]);this._root=(t=>{const{components:e,useAsButton:o,inline:n,appClass:i,theme:s,lockOpacity:r}=t.options,l=t=>t?"":'style="display:none" hidden',c=e=>t._t(e),p=a(`\n
\n\n ${o?"":''}\n\n
\n
\n
\n \n
\n
\n\n
\n
\n
\n
\n\n
\n
\n
\n
\n\n
\n
\n
\n
\n
\n\n
\n\n
\n \n\n \n \n \n \n \n\n \n \n \n
\n
\n
\n `),u=p.interaction;return u.options.find((t=>!t.hidden&&!t.classList.add("active"))),u.type=()=>u.options.find((t=>t.classList.contains("active"))),p})(this),t.useAsButton&&(this._root.button=t.el),t.container.appendChild(this._root.root)}_finalBuild(){const t=this.options,e=this._root;if(t.container.removeChild(e.root),t.inline){const o=t.el.parentElement;t.el.nextSibling?o.insertBefore(e.app,t.el.nextSibling):o.appendChild(e.app)}else t.container.appendChild(e.app);t.useAsButton?t.inline&&t.el.remove():t.el.parentNode.replaceChild(e.root,t.el),t.disabled&&this.disable(),t.comparison||(e.button.style.transition="none",t.useAsButton||(e.preview.lastColor.style.transition="none")),this.hide()}_buildComponents(){const t=this,e=this.options.components,o=(t.options.sliders||"v").repeat(2),[n,i]=o.match(/^[vh]+$/g)?o:[],s=()=>this._color||(this._color=this._lastColor.clone()),r={palette:C({element:t._root.palette.picker,wrapper:t._root.palette.palette,onstop:()=>t._emit("changestop","slider",t),onchange(o,n){if(!e.palette)return;const i=s(),{_root:r,options:a}=t,{lastColor:l,currentColor:c}=r.preview;t._recalc&&(i.s=100*o,i.v=100-100*n,i.v<0&&(i.v=0),t._updateOutput("slider"));const p=i.toRGBA().toString(0);this.element.style.background=p,this.wrapper.style.background=`\n linear-gradient(to top, rgba(0, 0, 0, ${i.a}), transparent),\n linear-gradient(to left, hsla(${i.h}, 100%, 50%, ${i.a}), rgba(255, 255, 255, ${i.a}))\n `,a.comparison?a.useAsButton||t._lastColor||l.style.setProperty("--pcr-color",p):(r.button.style.setProperty("--pcr-color",p),r.button.classList.remove("clear"));const u=i.toHEXA().toString();for(const{el:e,color:o}of t._swatchColors)e.classList[u===o.toHEXA().toString()?"add":"remove"]("pcr-active");c.style.setProperty("--pcr-color",p)}}),hue:C({lock:"v"===i?"h":"v",element:t._root.hue.picker,wrapper:t._root.hue.slider,onstop:()=>t._emit("changestop","slider",t),onchange(o){if(!e.hue||!e.palette)return;const n=s();t._recalc&&(n.h=360*o),this.element.style.backgroundColor=`hsl(${n.h}, 100%, 50%)`,r.palette.trigger()}}),opacity:C({lock:"v"===n?"h":"v",element:t._root.opacity.picker,wrapper:t._root.opacity.slider,onstop:()=>t._emit("changestop","slider",t),onchange(o){if(!e.opacity||!e.palette)return;const n=s();t._recalc&&(n.a=Math.round(100*o)/100),this.element.style.background=`rgba(0, 0, 0, ${n.a})`,r.palette.trigger()}}),selectable:k({elements:t._root.interaction.options,className:"active",onchange(e){t._representation=e.target.getAttribute("data-type").toUpperCase(),t._recalc&&t._updateOutput("swatch")}})};this._components=r}_bindEvents(){const{_root:t,options:e}=this,o=[i(t.interaction.clear,"click",(()=>this._clearColor())),i([t.interaction.cancel,t.preview.lastColor],"click",(()=>{this.setHSVA(...(this._lastColor||this._color).toHSVA(),!0),this._emit("cancel")})),i(t.interaction.save,"click",(()=>{!this.applyColor()&&!e.showAlways&&this.hide()})),i(t.interaction.result,["keyup","input"],(t=>{this.setColor(t.target.value,!0)&&!this._initializingActive&&(this._emit("change",this._color,"input",this),this._emit("changestop","input",this)),t.stopImmediatePropagation()})),i(t.interaction.result,["focus","blur"],(t=>{this._recalc="blur"===t.type,this._recalc&&this._updateOutput(null)})),i([t.palette.palette,t.palette.picker,t.hue.slider,t.hue.picker,t.opacity.slider,t.opacity.picker],["mousedown","touchstart"],(()=>this._recalc=!0),{passive:!0})];if(!e.showAlways){const n=e.closeWithKey;o.push(i(t.button,"click",(()=>this.isOpen()?this.hide():this.show())),i(document,"keyup",(t=>this.isOpen()&&(t.key===n||t.code===n)&&this.hide())),i(document,["touchstart","mousedown"],(e=>{this.isOpen()&&!l(e).some((e=>e===t.app||e===t.button))&&this.hide()}),{capture:!0}))}if(e.adjustableNumbers){const e={rgba:[255,255,255,1],hsva:[360,100,100,1],hsla:[360,100,100,1],cmyk:[100,100,100,100]};p(t.interaction.result,((t,o,n)=>{const i=e[this.getColorRepresentation().toLowerCase()];if(i){const e=i[n],s=t+(e>=100?1e3*o:o);return s<=0?0:Number((s{n.isOpen()&&(e.closeOnScroll&&n.hide(),null===t?(t=setTimeout((()=>t=null),100),requestAnimationFrame((function e(){n._rePositioningPicker(),null!==t&&requestAnimationFrame(e)}))):(clearTimeout(t),t=setTimeout((()=>t=null),100)))}),{capture:!0}))}this._eventBindings=o}_rePositioningPicker(){const{options:t}=this;if(!t.inline){if(!this._nanopop.update({container:document.body.getBoundingClientRect(),position:t.position})){const t=this._root.app,e=t.getBoundingClientRect();t.style.top=(window.innerHeight-e.height)/2+"px",t.style.left=(window.innerWidth-e.width)/2+"px"}}}_updateOutput(t){const{_root:e,_color:o,options:n}=this;if(e.interaction.type()){const t=`to${e.interaction.type().getAttribute("data-type")}`;e.interaction.result.value="function"==typeof o[t]?o[t]().toString(n.outputPrecision):""}!this._initializingActive&&this._recalc&&this._emit("change",o,t,this)}_clearColor(t=!1){const{_root:e,options:o}=this;o.useAsButton||e.button.style.setProperty("--pcr-color","rgba(0, 0, 0, 0.15)"),e.button.classList.add("clear"),o.showAlways||this.hide(),this._lastColor=null,this._initializingActive||t||(this._emit("save",null),this._emit("clear"))}_parseLocalColor(t){const{values:e,type:o,a:n}=w(t),{lockOpacity:i}=this.options,s=void 0!==n&&1!==n;return e&&3===e.length&&(e[3]=void 0),{values:!e||i&&s?null:e,type:o}}_t(t){return this.options.i18n[t]||E.I18N_DEFAULTS[t]}_emit(t,...e){this._eventListener[t].forEach((t=>t(...e,this)))}on(t,e){return this._eventListener[t].push(e),this}off(t,e){const o=this._eventListener[t]||[],n=o.indexOf(e);return~n&&o.splice(n,1),this}addSwatch(t){const{values:e}=this._parseLocalColor(t);if(e){const{_swatchColors:t,_root:o}=this,n=A(...e),s=r(`