first commit

This commit is contained in:
chflame163
2024-01-17 21:05:47 +08:00
committed by GitHub
parent f888853f3e
commit 0c4eda378b
10 changed files with 918 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
# ComfyUI Layer Style
A set of nodes for ComfyUI it generate image like Adobe Photoshop's Layer Style.
the Drop Shadow is first completed node, and follow-up work is in progress.
[中文说明点这里](./README_CN.MD)
## Node Description
### Drop Shadow:
Generate shadow
![image](image/drop_shadow_node.png)
Node options:
* background_image: The background image.
* layer_image: Layer images for composite.
* layer_mask: Mask for layer_image, shadows are generated according to their shape.
* invert_mask: Whether to reverse the mask.
* blend_mode: Blending method of shadows, there are **_normal, multply, screen, add, subtract, difference, darker_** and **_lighter_**.
* opacity: Opacity of shadow.
* distance_x: Horizontal offset of shadow.
* distance_y: Vertical offset of shadow.
* grow: Shadow expansion amplitude.
* blur:Shadow blur level.
* shadow_color: Shadow color, described in hexadecimal RGB format.
Output type:
* image:Completed image.
* shadow_mask:Shadow's mask.
## Example workflow
![image](image/drop_shadow.png)
image Some JSON workflow files in the workflow directory, that is example for ComfyUI.
## How to install
* Open the cmd window in the plugin directory of ComfyUI, like "ComfyUI\custom_nodes\",type```git clone https://github.com/chflame163/ComfyUI_WordCloud.git```
or download the zip file and extracted, copy the resulting folder to ComfyUI\custom_ Nodes\
* Install dependency packages, open the cmd window in the WordCloud plugin directory like "ComfyUI\custom_ Nodes\ComfyUI_WordCloud" and enter the following command:
```..\..\..\python_embeded\python.exe -m pip install -r requirements.txt```
* Restart ComfyUI
+38
View File
@@ -0,0 +1,38 @@
# ComfyUI Layer Style:
ComfyUI插件,通过一组节点实现仿照Adobe Photoshop的图层样式。
Drop Shadow是首个完成的节点,后续工作进行中。
## 节点说明:
### Drop Shadow:
生成阴影。
![image](image/drop_shadow_node.png)
选项说明:
* background_image: 背景图像。
* layer_image: 用于合成的层图像。
* layer_mask: 层图像的遮罩,阴影按此生成。
* invert_mask: 是否反转遮罩。
* blend_mode: 阴影的混合方式,包括normal、multply、screen、add、subtract、difference、darker和lighter。
* opacity: 阴影的不透明度。
* distance_x: 阴影的水平方向偏移量。
* distance_y: 阴影的垂直方向偏移量。
* grow: 阴影扩张幅度。
* blur:阴影模糊程度。
* shadow_color: 阴影颜色,使用16进制RGB格式描述。
输出:
* image:完成的图像。
* shadow_mask:阴影的通道。
## 使用示例:
![image](image/drop_shadow.png)
在workflow目录下有json格式的工作流示例文件。
## 安装方法:
* 在CompyUI插件目录(例如“CompyUI\custom_nodes\”)中打开cmd窗口,键入```git clone https://github.com/chflame163/ComfyUI_WordCloud.git```安装。或者下载解压zip文件,将得到的文件夹复制到 ComfyUI\custom_nodes\
* 安装依赖包,在资源管理器ComfyUI\custom_nodes\ComfyUI_WordCloud 插件目录位置打开cmd窗口,输入以下命令:
```..\..\..\python_embeded\python.exe -m pip install -r requirements.txt```
* 重新打开ComfyUI。
+29
View File
@@ -0,0 +1,29 @@
import importlib.util
import glob
import os
import sys
import filecmp
import shutil
import __main__
from .dzNodes import init, get_ext_dir, log
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
if init():
py = get_ext_dir("py")
files = glob.glob("*.py", root_dir=py, recursive=False)
for file in files:
name = os.path.splitext(file)[0]
spec = importlib.util.spec_from_file_location(name, os.path.join(py, file))
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
if hasattr(module, "NODE_CLASS_MAPPINGS") and getattr(module, "NODE_CLASS_MAPPINGS") is not None:
NODE_CLASS_MAPPINGS.update(module.NODE_CLASS_MAPPINGS)
if hasattr(module, "NODE_DISPLAY_NAME_MAPPINGS") and getattr(module, "NODE_DISPLAY_NAME_MAPPINGS") is not None:
NODE_DISPLAY_NAME_MAPPINGS.update(module.NODE_DISPLAY_NAME_MAPPINGS)
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+4
View File
@@ -0,0 +1,4 @@
{
"name": "dzNodes",
"logging": false
}
+269
View File
@@ -0,0 +1,269 @@
import asyncio
import os
import json
import shutil
import inspect
import aiohttp
from server import PromptServer
from tqdm import tqdm
config = None
def is_logging_enabled():
config = get_extension_config()
if "logging" not in config:
return False
return config["logging"]
def log(message, type=None, always=False, name=None):
if not always and not is_logging_enabled():
return
if type is not None:
message = f"[{type}] {message}"
if name is None:
name = get_extension_config()["name"]
print(f"# 😺dzNodes: {name} -> {message}")
def get_ext_dir(subpath=None, mkdir=False):
dir = os.path.dirname(__file__)
if subpath is not None:
dir = os.path.join(dir, subpath)
dir = os.path.abspath(dir)
if mkdir and not os.path.exists(dir):
os.makedirs(dir)
return dir
def get_comfy_dir(subpath=None, mkdir=False):
dir = os.path.dirname(inspect.getfile(PromptServer))
if subpath is not None:
dir = os.path.join(dir, subpath)
dir = os.path.abspath(dir)
if mkdir and not os.path.exists(dir):
os.makedirs(dir)
return dir
def get_web_ext_dir():
config = get_extension_config()
name = config["name"]
dir = get_comfy_dir("web/extensions/dzNodes")
if not os.path.exists(dir):
os.makedirs(dir)
dir = os.path.join(dir, name)
return dir
def get_extension_config(reload=False):
global config
if reload == False and config is not None:
return config
config_path = get_ext_dir("dzNodes.json")
if not os.path.exists(config_path):
log("Missing json, this extension may not work correctly. Please reinstall the extension.",
type="ERROR", always=True, name="???")
print(f"Extension path: {get_ext_dir()}")
return {"name": "Unknown", "version": -1}
with open(config_path, "r") as f:
config = json.loads(f.read())
return config
def link_js(src, dst):
src = os.path.abspath(src)
dst = os.path.abspath(dst)
if os.name == "nt":
try:
import _winapi
_winapi.CreateJunction(src, dst)
return True
except:
pass
try:
os.symlink(src, dst)
return True
except:
import logging
logging.exception('')
return False
def is_junction(path):
if os.name != "nt":
return False
try:
return bool(os.readlink(path))
except OSError:
return False
def install_js():
src_dir = get_ext_dir("js")
if not os.path.exists(src_dir):
log("No JS")
return
dst_dir = get_web_ext_dir()
if os.path.exists(dst_dir):
if os.path.islink(dst_dir) or is_junction(dst_dir):
log("JS already linked")
return
elif link_js(src_dir, dst_dir):
log("JS linked")
return
log("Copying JS files")
shutil.copytree(src_dir, dst_dir, dirs_exist_ok=True)
def init(check_imports=None):
log("Init")
if check_imports is not None:
import importlib.util
for imp in check_imports:
spec = importlib.util.find_spec(imp)
if spec is None:
log(f"{imp} is required, please check requirements are installed.",
type="ERROR", always=True)
return False
install_js()
return True
def get_async_loop():
loop = None
try:
loop = asyncio.get_event_loop()
except:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
return loop
def get_http_session():
loop = get_async_loop()
return aiohttp.ClientSession(loop=loop)
async def download(url, stream, update_callback=None, session=None):
close_session = False
if session is None:
close_session = True
session = get_http_session()
try:
async with session.get(url) as response:
size = int(response.headers.get('content-length', 0)) or None
with tqdm(
unit='B', unit_scale=True, miniters=1, desc=url.split('/')[-1], total=size,
) as progressbar:
perc = 0
async for chunk in response.content.iter_chunked(2048):
stream.write(chunk)
progressbar.update(len(chunk))
if update_callback is not None and progressbar.total is not None and progressbar.total != 0:
last = perc
perc = round(progressbar.n / progressbar.total, 2)
if perc != last:
last = perc
await update_callback(perc)
finally:
if close_session and session is not None:
await session.close()
async def download_to_file(url, destination, update_callback=None, is_ext_subpath=True, session=None):
if is_ext_subpath:
destination = get_ext_dir(destination)
with open(destination, mode='wb') as f:
download(url, f, update_callback, session)
def wait_for_async(async_fn, loop=None):
res = []
async def run_async():
r = await async_fn()
res.append(r)
if loop is None:
try:
loop = asyncio.get_event_loop()
except:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(run_async())
return res[0]
def update_node_status(client_id, node, text, progress=None):
if client_id is None:
client_id = PromptServer.instance.client_id
if client_id is None:
return
PromptServer.instance.send_sync("dzNodes/update_status", {
"node": node,
"progress": progress,
"text": text
}, client_id)
async def update_node_status_async(client_id, node, text, progress=None):
if client_id is None:
client_id = PromptServer.instance.client_id
if client_id is None:
return
await PromptServer.instance.send("dzNodes/update_status", {
"node": node,
"progress": progress,
"text": text
}, client_id)
def get_config_value(key, default=None, throw=False):
split = key.split(".")
obj = get_extension_config()
for s in split:
if s in split:
obj = obj[s]
else:
if throw:
raise KeyError("Configuration key missing: " + key)
else:
return default
return obj
def is_inside_dir(root_dir, check_path):
root_dir = os.path.abspath(root_dir)
if not os.path.isabs(check_path):
check_path = os.path.abspath(os.path.join(root_dir, check_path))
return os.path.commonpath([check_path, root_dir]) == root_dir
def get_child_dir(root_dir, child_path, throw_if_outside=True):
child_path = os.path.abspath(os.path.join(root_dir, child_path))
if is_inside_dir(root_dir, child_path):
return child_path
if throw_if_outside:
raise NotADirectoryError(
"Saving outside the target folder is not allowed.")
return None
Binary file not shown.

After

Width:  |  Height:  |  Size: 559 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

+189
View File
@@ -0,0 +1,189 @@
import math
import os
import numpy as np
import torch
import matplotlib.pyplot as plt
import scipy.ndimage
from typing import Union, List
from PIL import Image, ImageFilter, ImageChops
def log(message):
name = 'Layer Style'
print(f"# 😺dzNodes: {name} -> {message}")
def pil2tensor(image:Image) -> torch.Tensor:
return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0)
def np2tensor(img_np: Union[np.ndarray, List[np.ndarray]]) -> torch.Tensor:
if isinstance(img_np, list):
return torch.cat([np2tensor(img) for img in img_np], dim=0)
return torch.from_numpy(img_np.astype(np.float32) / 255.0).unsqueeze(0)
def tensor2np(tensor: torch.Tensor) -> List[np.ndarray]:
if len(tensor.shape) == 3: # Single image
return np.clip(255.0 * tensor.cpu().numpy(), 0, 255).astype(np.uint8)
else: # Batch of images
return [np.clip(255.0 * t.cpu().numpy(), 0, 255).astype(np.uint8) for t in tensor]
def tensor2pil(t_image: torch.Tensor) -> Image:
return Image.fromarray(np.clip(255.0 * t_image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8))
def image2mask(image:Image) -> torch.Tensor:
_image = image.convert('RGBA')
alpha = _image.split() [0]
bg = Image.new("L", _image.size)
_image = Image.merge('RGBA', (bg, bg, bg, alpha))
return pil2tensor(_image)[0, :, :, 3]
def mask2image(mask:torch.Tensor) -> Image:
masks = tensor2np(mask)
# images = []
for m in masks:
_mask = Image.fromarray(m).convert("L")
_image = Image.new("RGBA", _mask.size, color='white')
_image = Image.composite(
_image, Image.new("RGBA", _mask.size, color='black'), _mask)
return _image
def shift_image(image:Image, distance_x:int, distance_y:int) -> Image:
bkcolor = (0, 0, 0)
width = image.width
height = image.height
ret_image = Image.new('RGB', size=(width, height), color=bkcolor)
for x in range(width):
for y in range(height):
if x + distance_x < width and y + distance_y < height:
pixel = image.getpixel((x + distance_x, y + distance_y))
ret_image.putpixel((x, y), pixel)
return ret_image
def chop_image(background_image:Image, layer_image:Image, blend_mode:str, opacity:int) -> Image:
ret_image = background_image
if blend_mode == 'normal':
ret_image = layer_image
if blend_mode == 'multply':
ret_image = ImageChops.multiply(background_image,layer_image)
if blend_mode == 'screen':
ret_image = ImageChops.screen(background_image, layer_image)
if blend_mode == 'add':
ret_image = ImageChops.add(background_image, layer_image, 1, 0)
if blend_mode == 'subtract':
ret_image = ImageChops.subtract(background_image, layer_image, 1, 0)
if blend_mode == 'difference':
ret_image = ImageChops.difference(background_image, layer_image)
if blend_mode == 'darker':
ret_image = ImageChops.darker(background_image, layer_image)
if blend_mode == 'lighter':
ret_image = ImageChops.lighter(background_image, layer_image)
# opacity
if opacity == 0:
ret_image = background_image
elif opacity < 100:
alpha = 1.0 - float(opacity) / 100
ret_image = Image.blend(ret_image, background_image, alpha)
return ret_image
def expand_mask(mask:torch.Tensor, grow:int, blur:int, expandrate:int) -> torch.Tensor:
# grow
c = 0
kernel = np.array([[c, 1, c],
[1, 1, 1],
[c, 1, c]])
growmask = mask.reshape((-1, mask.shape[-2], mask.shape[-1]))
out = []
for m in growmask:
output = m.numpy()
for _ in range(abs(grow)):
if grow < 0:
output = scipy.ndimage.grey_erosion(output, footprint=kernel)
else:
output = scipy.ndimage.grey_dilation(output, footprint=kernel)
if grow < 0:
grow -= abs(expandrate)
else:
grow += abs(expandrate)
output = torch.from_numpy(output)
out.append(output)
# blur
if blur != 0:
for idx, tensor in enumerate(out):
pil_image = tensor2pil(tensor.cpu().detach())
pil_image = pil_image.filter(ImageFilter.GaussianBlur(blur))
out[idx] = pil2tensor(pil_image)
blurred = torch.cat(out, dim=0)
return blurred
class DropShadow:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(self):
chop_mode = ['normal','multply','screen','add','subtract','difference','darker','lighter']
return {
"required": {
"background_image": ("IMAGE", ), #
"layer_image": ("IMAGE",), #
"layer_mask": ("MASK",), #
"invert_mask": ("BOOLEAN", {"default": True}), # 反转mask
"blend_mode": (chop_mode,), # 混合模式
"opacity": ("INT", {"default": 50, "min": 0, "max": 100, "step": 1}), # 透明度
"distance_x": ("INT", {"default": 5, "min": -9999, "max": 9999, "step": 1}), # x_偏移
"distance_y": ("INT", {"default": 5, "min": -9999, "max": 9999, "step": 1}), # y_偏移
"grow": ("INT", {"default": 2, "min": -9999, "max": 9999, "step": 1}), # 扩散
"blur": ("INT", {"default": 15, "min": 0, "max": 100, "step": 1}), # 模糊
"shadow_color": ("STRING", {"default": "#000000"}), # 背景颜色
},
"optional": {
# "test_mask": ("MASK",), #
}
}
RETURN_TYPES = ("IMAGE", "MASK",)
RETURN_NAMES = ("image", "shadow_mask",)
FUNCTION = 'drop_shadow'
CATEGORY = '😺dzNodes'
OUTPUT_NODE = True
def drop_shadow(self, background_image, layer_image, layer_mask,
invert_mask, blend_mode, opacity, distance_x, distance_y,
grow, blur, shadow_color,
):
distance_x = -distance_x
distance_y = -distance_y
# 处理阴影mask
if invert_mask:
layer_mask = 1 - layer_mask
_layer = tensor2pil(layer_image)
_mask = mask2image(layer_mask)
if distance_x != 0 or distance_y != 0:
_mask = shift_image(_mask, distance_x, distance_y) # 位移
shadow_mask = expand_mask(image2mask(_mask), grow, blur, 0) #扩边,模糊,膨胀
# 合成阴影
shadow_color = Image.new("RGB", _layer.size, color=shadow_color)
alpha = tensor2pil(shadow_mask).convert('L')
_canvas = tensor2pil(background_image)
_shadow = chop_image(tensor2pil(background_image), shadow_color, blend_mode, opacity)
_canvas.paste(_shadow, mask=alpha)
# 合成layer
alpha = tensor2pil(layer_mask).convert('L')
_canvas.paste(_layer, mask=alpha)
ret_image = _canvas
ret_mask = shadow_mask
return (pil2tensor(ret_image), ret_mask,)
NODE_CLASS_MAPPINGS = {
"LayerStyle_DropShadow": DropShadow
}
NODE_DISPLAY_NAME_MAPPINGS = {
"LayerStyle_DropShadow": "Layer Style: Drop Shadow"
}
+5
View File
@@ -0,0 +1,5 @@
numpy
pillow
torch
matplotlib
Scipy
+341
View File
@@ -0,0 +1,341 @@
{
"last_node_id": 20,
"last_link_id": 49,
"nodes": [
{
"id": 2,
"type": "LoadImage",
"pos": [
237,
-31
],
"size": {
"0": 315,
"1": 314
},
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
45
],
"shape": 3,
"slot_index": 0
},
{
"name": "MASK",
"type": "MASK",
"links": [
46
],
"shape": 3,
"slot_index": 1
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": [
"512x512.png",
"image"
]
},
{
"id": 17,
"type": "RGB_Picker",
"pos": [
332,
-180
],
"size": {
"0": 210,
"1": 94
},
"flags": {},
"order": 1,
"mode": 0,
"outputs": [
{
"name": "value",
"type": "STRING",
"links": [
49
],
"shape": 3,
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "RGB_Picker"
},
"widgets_values": [
"#2b0303",
"HEX"
]
},
{
"id": 9,
"type": "PreviewImage",
"pos": [
955,
-13
],
"size": {
"0": 210,
"1": 246
},
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 47
}
],
"properties": {
"Node name for S&R": "PreviewImage"
}
},
{
"id": 20,
"type": "LayerStyle_DropShadow",
"pos": [
576,
-21
],
"size": [
355.20001220703125,
266
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "background_image",
"type": "IMAGE",
"link": 44
},
{
"name": "layer_image",
"type": "IMAGE",
"link": 45
},
{
"name": "layer_mask",
"type": "MASK",
"link": 46
},
{
"name": "shadow_color",
"type": "STRING",
"link": 49,
"widget": {
"name": "shadow_color"
}
}
],
"outputs": [
{
"name": "image",
"type": "IMAGE",
"links": [
47
],
"shape": 3,
"slot_index": 0
},
{
"name": "shadow_mask",
"type": "MASK",
"links": [
48
],
"shape": 3,
"slot_index": 1
}
],
"properties": {
"Node name for S&R": "LayerStyle_DropShadow"
},
"widgets_values": [
true,
"multply",
52,
43,
45,
2,
20,
"#000000"
]
},
{
"id": 3,
"type": "LoadImage",
"pos": [
-102,
-33
],
"size": {
"0": 315,
"1": 314
},
"flags": {},
"order": 2,
"mode": 0,
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
44
],
"shape": 3,
"slot_index": 0
},
{
"name": "MASK",
"type": "MASK",
"links": null,
"shape": 3
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": [
"512x512bkgd.jpg",
"image"
]
},
{
"id": 10,
"type": "MaskToImage",
"pos": [
957,
282
],
"size": {
"0": 210,
"1": 26
},
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "mask",
"type": "MASK",
"link": 48
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
11
],
"shape": 3,
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "MaskToImage"
}
},
{
"id": 11,
"type": "PreviewImage",
"pos": [
1188,
-15
],
"size": {
"0": 210,
"1": 246
},
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 11
}
],
"properties": {
"Node name for S&R": "PreviewImage"
}
}
],
"links": [
[
11,
10,
0,
11,
0,
"IMAGE"
],
[
44,
3,
0,
20,
0,
"IMAGE"
],
[
45,
2,
0,
20,
1,
"IMAGE"
],
[
46,
2,
1,
20,
2,
"MASK"
],
[
47,
20,
0,
9,
0,
"IMAGE"
],
[
48,
20,
1,
10,
0,
"MASK"
],
[
49,
17,
0,
20,
3,
"STRING"
]
],
"groups": [],
"config": {},
"extra": {},
"version": 0.4
}