diff --git a/README.MD b/README.MD new file mode 100644 index 0000000..0424610 --- /dev/null +++ b/README.MD @@ -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 \ No newline at end of file diff --git a/README_CN.MD b/README_CN.MD new file mode 100644 index 0000000..2b40f22 --- /dev/null +++ b/README_CN.MD @@ -0,0 +1,38 @@ +# ComfyUI Layer Style£º +ComfyUI²å¼þ£¬Í¨¹ýÒ»×é½ÚµãʵÏÖ·ÂÕÕAdobe PhotoshopµÄͼ²ãÑùʽ¡£ +Drop ShadowÊÇÊ׸öÍê³ÉµÄ½Úµã£¬ºóÐø¹¤×÷½øÐÐÖС£ + +## ½Úµã˵Ã÷£º + +### Drop Shadow: +Éú³ÉÒõÓ°¡£ +![image](image/drop_shadow_node.png) + + +Ñ¡Ïî˵Ã÷£º +* background_image: ±³¾°Í¼Ïñ¡£ +* layer_image: ÓÃÓںϳɵIJãͼÏñ¡£ +* 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¡£ diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e3736d3 --- /dev/null +++ b/__init__.py @@ -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"] diff --git a/dzNodes.json b/dzNodes.json new file mode 100644 index 0000000..61d7502 --- /dev/null +++ b/dzNodes.json @@ -0,0 +1,4 @@ +{ + "name": "dzNodes", + "logging": false +} \ No newline at end of file diff --git a/dzNodes.py b/dzNodes.py new file mode 100644 index 0000000..e1ae41a --- /dev/null +++ b/dzNodes.py @@ -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 diff --git a/image/drop_shadow.png b/image/drop_shadow.png new file mode 100644 index 0000000..175e60c Binary files /dev/null and b/image/drop_shadow.png differ diff --git a/image/drop_shadow_node.png b/image/drop_shadow_node.png new file mode 100644 index 0000000..f1fd688 Binary files /dev/null and b/image/drop_shadow_node.png differ diff --git a/py/drop_shadow.py b/py/drop_shadow.py new file mode 100644 index 0000000..e46993c --- /dev/null +++ b/py/drop_shadow.py @@ -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" +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cd6f890 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +numpy +pillow +torch +matplotlib +Scipy \ No newline at end of file diff --git a/workflow/drop_shadow_example.json b/workflow/drop_shadow_example.json new file mode 100644 index 0000000..33c3a2f --- /dev/null +++ b/workflow/drop_shadow_example.json @@ -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 +} \ No newline at end of file