Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a9cbe78a5 | ||
|
|
9a8d90dd95 | ||
|
|
24b7aabf8b | ||
|
|
81f944f119 | ||
|
|
327b2a1fe3 | ||
|
|
fb847a5225 | ||
|
|
5a45172d02 | ||
|
|
29e24ec52c | ||
|
|
e5e62a4a79 | ||
|
|
1a24975f99 | ||
|
|
61fa161c34 | ||
|
|
f986f6a442 | ||
|
|
e0d0c3cc2c | ||
|
|
f5ec9d830c | ||
|
|
d1dcf12f10 | ||
|
|
cb92e547c6 | ||
|
|
b5fec4a062 | ||
|
|
42965013f9 | ||
|
|
d20615fb48 | ||
|
|
5bad00f72f | ||
|
|
df54344077 | ||
|
|
f42c0f29b6 | ||
|
|
547c3d5c97 | ||
|
|
73babbd00e | ||
|
|
cac32fe37c | ||
|
|
5620b5c6e2 | ||
|
|
3d4a960982 | ||
|
|
9d533984c2 | ||
|
|
715a41e04f | ||
|
|
aff32e8da6 | ||
|
|
e46123612d | ||
|
|
6e7b2445db | ||
|
|
2f39365248 | ||
|
|
c324f6741d | ||
|
|
c27b662fd8 |
+3
-1
@@ -1,4 +1,6 @@
|
||||
.vscode
|
||||
.env
|
||||
.dev
|
||||
__pycache__
|
||||
__pycache__
|
||||
|
||||
safetychecker/*.safetensors
|
||||
@@ -2,7 +2,15 @@
|
||||
|
||||
Provides nodes and API geared towards using ComfyUI as a backend for external tools.
|
||||
|
||||
## Nodes for sending and receiving images
|
||||
* <a href="#images">Sending and receiving images</a>
|
||||
* <a href="#regions">Regions (Attention Masking)
|
||||
* <a href="#tiles">Tiled image processing
|
||||
* <a href="#misc">Miscellanious nodes
|
||||
* <a href="#api">Http API extensions (Model inspection)
|
||||
* <a href="#installation">⭳ Installation</a>
|
||||
|
||||
|
||||
## <a id="images" href="#toc">Sending and receiving images</a>
|
||||
|
||||
ComfyUI exchanges images via the filesystem. This requires a
|
||||
multi-step process (upload images, prompt, download images), is rather
|
||||
@@ -36,9 +44,33 @@ That is two 32-bit integers (big endian) with values 1 and 2 followed by the PNG
|
||||
{'type': 'executed', 'data': {'node': '<node ID>', 'output': {'images': [{'source': 'websocket', 'content-type': 'image/png', 'type': 'output'}, ...]}, 'prompt_id': '<prompt ID>}}
|
||||
```
|
||||
|
||||
## Nodes for working on regions
|
||||
## <a id="regions" href="#toc">Regions</a>
|
||||
|
||||
When integrating ComfyUI into tools which use layers and compose them on the fly, it is useful to only receive relevant masked regions.
|
||||
These nodes implement attention masking for arbitrary number of image regions. Text prompts only apply to the masked area.
|
||||
In contrast to condition masking, this method is less "forceful", but leads to more natural image compositions.
|
||||
|
||||

|
||||
[Workflow: region_attention_mask.json](workflows/region_attention_mask.json)
|
||||
|
||||
### Background Region
|
||||
|
||||
This node starts a list of regions. It takes a prompt, but no mask. The prompt is assigned to all image areas which are _not_
|
||||
covered by another region mask in the list.
|
||||
|
||||
### Define Region
|
||||
|
||||
Appends a new region to a region list (or starts a new list). Takes a prompt, and mask which defines the area in the image
|
||||
the prompt will apply to. Masks must be the same size as the image _or_ the latent (which is factor 8 smaller).
|
||||
|
||||
### List Region Masks
|
||||
|
||||
This node takes a list of regions and outputs all their masks. It can be useful for inspection, debugging or to reuse the
|
||||
computed background mask.
|
||||
|
||||
### Regions Attention Mask
|
||||
|
||||
Patches the model to use the provided list of regions. This replaces the positive text conditioning which is provided
|
||||
to the sampler. It's still possible to pass ControlNet and other conditioning to the sampler.
|
||||
|
||||
### Apply Mask to Image
|
||||
|
||||
@@ -46,30 +78,144 @@ Copies a mask into the alpha channel of an image.
|
||||
* Inputs: image and mask
|
||||
* Outputs: RGBA image with mask used as transparency
|
||||
|
||||
## API for model inspection
|
||||
|
||||
There are various types of models that can be loaded as checkpoint, LoRA, ControlNet, etc. which cannot be used interchangeably. The following API helps to categorize and filter them.
|
||||
## <a id="tiles" href="#toc">Tiles</a>
|
||||
|
||||
### /etn/model_info
|
||||
Splitting an image into tiles to be processed individually is a useful method to speed up
|
||||
diffusion and save VRAM. There are various nodes out there which provide a fixed pipeline.
|
||||
In contrast, the following nodes only provide a way to split an image into tiles and merge
|
||||
it back together. With tools and scripts it is feasible to generate individual workflows
|
||||
for each tile. This allows maximum flexibility (different prompts, regions, control, etc.).
|
||||
|
||||
Lists available models with additional classification info.
|
||||
* Paramters: _none_
|
||||
* Output: list of model files
|
||||
```
|
||||
{
|
||||
"checkpoint_file.safetensors": {
|
||||
"base_model": "sd15"|"sd20"|"sd21"|"sdxl"|"ssd1b"|"svd"|"cascade-b"|"cascade-c",
|
||||
"is_inpaint": true|false,
|
||||
"is_refiner": true|false
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
The entry is `{"base_model": "unknown"}` for models which are not in safetensors format or do not match any of the known base models.
|
||||

|
||||
[Workflow: image_tiles.json](workflows/image_tiles.json)
|
||||
|
||||
_Note: currently only supports checkpoints. May add other models in the future._
|
||||
### Create Tile Layout
|
||||
|
||||
## Installation
|
||||
This node defines the tiling parameters:
|
||||
* **min_tile_size**: Minimum resolution of each tile in pixels. Tiles may be larger to fit the image size evenly.
|
||||
* **padding**: Padding around each tile in pixels. Overlaps with neighbour tiles. There is no padding at the image borders.
|
||||
* **blending**: The part of the padding area which is used for smooth blending to avoid seams. Affects masks which are generated from this layout.
|
||||
|
||||
The number of tiles is: `image_size // (min_tile_size + 2 * padding)`
|
||||
|
||||
### Extract Image Tile
|
||||
|
||||
Splits out part of an image. Tile indices range from 0 to number of tiles and are column-major
|
||||
(tile 1 is usually below tile 0).
|
||||
|
||||
### Extract Mask Tile
|
||||
|
||||
Same as "Extract Image Tile" but for masks.
|
||||
|
||||
### Merge Image Tile
|
||||
|
||||
Merges a tile into a full image, usually after sampling. Uses a smooth transition overlap
|
||||
between neighbouring tiles depending on padding and blending values.
|
||||
|
||||
### Generate Tile Mask
|
||||
|
||||
Creates a coverage mask for a certain tile. The size of the mask matches the image tile size.
|
||||
The image area will be white (1) and the padding area black (0), with a smooth transition
|
||||
depending on the chosen blend size.
|
||||
|
||||
This mask is used internally by "Merge Image Tile", but it can also be useful as input for "Set Latent Noise Mask" in upscale workflows.
|
||||
|
||||
|
||||
## <a id="misc" href="#toc">Miscellaneous Nodes</a>
|
||||
|
||||
<a id="node-translate"></a>
|
||||
### Translate Text
|
||||
|
||||
Node which translates a string into English. The language to translate from is indicated with a
|
||||
_language directive_ of the form `lang:xx` where xx is a 2-letter language code. Multiple
|
||||
directives are allowed and change language for any text that comes after, until the next
|
||||
directive. `lang:en` (the default) passes through text fragments untouched. Useful
|
||||
for keywords, tags and such.
|
||||
|
||||
Examples:
|
||||
| Input | Output |
|
||||
|:-|:-|
|
||||
| lang:de eine modische handtasche aus grünem kunstleder | a fashionable handbag made of green suede |
|
||||
| origami paperwork, lang:zh 狐狸和鹤, lang:en mountain view | origami paperwork, Fox and crane, mountain view |
|
||||
|
||||
Translation happens entirely local, powered by [argosopentech/argos-translate](https://github.com/argosopentech/argos-translate):
|
||||
* Install with `pip install argostranslate` or `pip install -r requirements.txt`
|
||||
* Models are automatically downloaded on first use.
|
||||
|
||||
There is also a [translation API](#api-translation) for immediate feedback in tool UI.
|
||||
|
||||
### NSFW Filter
|
||||
|
||||
Checks images for NSFW content using [Safety-Checker](https://huggingface.co/CompVis/stable-diffusion-safety-checker). Images which don't pass the check are blurred to
|
||||
obfuscate contents. Model is downloaded on first use.
|
||||
|
||||
Inputs: image and sensitivity (0.5 for explicit content only, 0.7+ to include partial nudity).
|
||||
|
||||
**Important:** the filter isn't perfect. Some explicit content may slip through.
|
||||
|
||||
|
||||
## <a id="api" href="#toc">API extensions</a>
|
||||
|
||||
### GET /api/etn/model_info/{folder_name}
|
||||
|
||||
There are various types of models that can be loaded as checkpoint, LoRA, ControlNet, etc. which cannot be used interchangeably. This endpoint helps to categorize and filter them.
|
||||
|
||||
#### Paramters
|
||||
* `folder_name`: sub-directory in ComfyUI's models folder.
|
||||
Supported model types: `checkpoints`, `diffusion_models`
|
||||
|
||||
#### Output
|
||||
Lists available models with additional classification info:
|
||||
```json
|
||||
{
|
||||
"checkpoint_file.safetensors": {
|
||||
"base_model": "sd15",
|
||||
"is_inpaint": false,
|
||||
"is_refiner": false
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
Possible values for base model: `sd15, sd20, sd21, sd3, sdxl, ssd1b, svd, cascade-b, cascade-c, aura-flow, hunyuan-dit, flux, flux-schnell`
|
||||
|
||||
The entry is `{"base_model": "unknown"}` for models which are not in safetensors format or do not match any of the known base models.
|
||||
|
||||
### GET /api/etn/languages
|
||||
|
||||
Returns a list of available languages for translation.
|
||||
```json
|
||||
[
|
||||
{ "name": "English", "code": "en" },
|
||||
{ ... }
|
||||
]
|
||||
```
|
||||
|
||||
<a id="api-translation"></a>
|
||||
### GET /api/etn/translate/{lang}/{text}
|
||||
|
||||
Translates `text` into English. `lang` is a 2-letter code indicating the language to translate
|
||||
from. `text` may also contain _language directives_ to only translate some fragments.
|
||||
See the [node documentation](#node-translate) for details.
|
||||
|
||||
* Output: JSON string
|
||||
* Example: `/api/etn/translate/de/eine%20modische%20Handtasche` -> `"a fashionable handbag"`
|
||||
|
||||
### PUT /api/etn/upload/{folder_name}/{filename}
|
||||
|
||||
Uploads a model to ComfyUI's local model folder.
|
||||
|
||||
#### Parameters
|
||||
* `folder_name`: the model type. Must match one of the existing folders in ComfyUI's models folder.
|
||||
* `filename`: target filename for the model. Must not contain any (absolute or relative) path. Extension must be .safetensors.
|
||||
|
||||
#### Output
|
||||
* Code `201` and `{ "status": "success" }` after successful upload.
|
||||
* Code `200` and `{ "status": "cached" }` if the file already exists.
|
||||
* Code `400` and `{ "error": "..." }` if the parameters are invalid.
|
||||
|
||||
|
||||
## <a id="installation" href="#toc">Installation</a>
|
||||
|
||||
Download the repository and unpack into the `custom_nodes` folder in the ComfyUI installation directory.
|
||||
|
||||
|
||||
+20
-3
@@ -1,4 +1,4 @@
|
||||
from . import api, nodes, tile, region
|
||||
from . import api, nodes, tile, region, nsfw, translation, krita
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ETN_LoadImageBase64": nodes.LoadImageBase64,
|
||||
@@ -15,6 +15,15 @@ NODE_CLASS_MAPPINGS = {
|
||||
"ETN_DefineRegion": region.DefineRegion,
|
||||
"ETN_ListRegionMasks": region.ListRegionMasks,
|
||||
"ETN_AttentionMask": region.AttentionMask,
|
||||
"ETN_NSFWFilter": nsfw.NSFWFilter,
|
||||
"ETN_Translate": translation.Translate,
|
||||
"ETN_KritaOutput": krita.KritaOutput,
|
||||
"ETN_KritaCanvas": krita.KritaCanvas,
|
||||
"ETN_KritaSelection": krita.KritaSelection,
|
||||
"ETN_KritaImageLayer": krita.KritaImageLayer,
|
||||
"ETN_KritaMaskLayer": krita.KritaMaskLayer,
|
||||
"ETN_Parameter": krita.Parameter,
|
||||
"ETN_KritaStyle": krita.KritaStyle,
|
||||
}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ETN_LoadImageBase64": "Load Image (Base64)",
|
||||
@@ -22,8 +31,6 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ETN_SendImageWebSocket": "Send Image (WebSocket)",
|
||||
"ETN_CropImage": "Crop Image",
|
||||
"ETN_ApplyMaskToImage": "Apply Mask to Image",
|
||||
"ETN_ListAppend": "List 🢒 Append",
|
||||
"ETN_ListElement": "List 🢒 Get Element",
|
||||
"ETN_TileLayout": "Create Tile Layout",
|
||||
"ETN_ExtractImageTile": "Extract Image Tile",
|
||||
"ETN_ExtractMaskTile": "Extract Mask Tile",
|
||||
@@ -33,4 +40,14 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ETN_DefineRegion": "Define Region",
|
||||
"ETN_ListRegionMasks": "List Region Masks",
|
||||
"ETN_AttentionMask": "Regions Attention Mask",
|
||||
"ETN_NSFWFilter": "NSFW Filter",
|
||||
"ETN_Translate": "Translate Text",
|
||||
"ETN_KritaOutput": "Krita Output",
|
||||
"ETN_KritaCanvas": "Krita Canvas",
|
||||
"ETN_KritaSelection": "Krita Selection",
|
||||
"ETN_KritaImageLayer": "Krita Image Layer",
|
||||
"ETN_KritaMaskLayer": "Krita Mask Layer",
|
||||
"ETN_Parameter": "Parameter",
|
||||
"ETN_KritaStyle": "Krita Style",
|
||||
}
|
||||
WEB_DIRECTORY = "./js"
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
from __future__ import annotations
|
||||
from aiohttp import web
|
||||
from typing import NamedTuple
|
||||
from pathlib import Path
|
||||
import json
|
||||
import traceback
|
||||
import re
|
||||
import logging
|
||||
|
||||
from comfy import model_detection, supported_models
|
||||
import comfy.utils
|
||||
from comfy import supported_models
|
||||
from comfy import model_detection
|
||||
import folder_paths
|
||||
import server
|
||||
|
||||
from .translation import available_languages, translate
|
||||
from .krita import WorkflowExchange
|
||||
|
||||
input_block_name = "model.diffusion_model.input_blocks.0.0.weight"
|
||||
|
||||
model_names = {
|
||||
@@ -21,6 +28,12 @@ model_names = {
|
||||
"SVD_img2vid": "svd",
|
||||
"Stable_Cascade_B": "cascade-b",
|
||||
"Stable_Cascade_C": "cascade-c",
|
||||
"SD3": "sd3",
|
||||
"AuraFlow": "aura-flow",
|
||||
"HunyuanDiT": "hunyuan-dit",
|
||||
"HunyuanDiT1": "hunyuan-dit",
|
||||
"Flux": "flux",
|
||||
"FluxSchnell": "flux-schnell",
|
||||
}
|
||||
|
||||
|
||||
@@ -35,10 +48,10 @@ class FakeTensor(NamedTuple):
|
||||
return d
|
||||
|
||||
|
||||
def inspect_checkpoint(filename):
|
||||
def inspect_diffusion_model(filename: str, prefix: str | None, model_type: str):
|
||||
try:
|
||||
# Read header of safetensors file
|
||||
path = folder_paths.get_full_path("checkpoints", filename)
|
||||
path = folder_paths.get_full_path(model_type, filename)
|
||||
header = comfy.utils.safetensors_header(path)
|
||||
if header:
|
||||
cfg = json.loads(header.decode("utf-8"))
|
||||
@@ -49,11 +62,12 @@ def inspect_checkpoint(filename):
|
||||
cfg[key] = FakeTensor.from_dict(cfg[key])
|
||||
|
||||
# Reuse Comfy's model detection
|
||||
unet_args = [cfg, "model.diffusion_model.", "F32"]
|
||||
if prefix is None:
|
||||
prefix = model_detection.unet_prefix_from_state_dict(cfg)
|
||||
try: # latest ComfyUI takes 2 args
|
||||
unet_config = model_detection.detect_unet_config(*unet_args[:-1])
|
||||
unet_config = model_detection.detect_unet_config(cfg, prefix)
|
||||
except TypeError as e: # older ComfyUI versions take 3 args
|
||||
unet_config = model_detection.detect_unet_config(*unet_args)
|
||||
raise TypeError(f"{e} when calling detect_unet_config - old version of ComfyUI?")
|
||||
|
||||
# Get input count to detect inpaint models
|
||||
if input_block := cfg.get(input_block_name, None):
|
||||
@@ -75,18 +89,130 @@ def inspect_checkpoint(filename):
|
||||
}
|
||||
return {"base_model": "unknown"}
|
||||
except Exception as e:
|
||||
# traceback.print_exc()
|
||||
return {"base_model": "unknown", "error": f"Failed to detect base model: {e}"}
|
||||
|
||||
|
||||
if _server := getattr(server.PromptServer, "instance", None):
|
||||
def inspect_models(model_type: str):
|
||||
try:
|
||||
prefix = "" if model_type in ("unet", "diffusion_models") else None
|
||||
info = {
|
||||
filename: inspect_diffusion_model(filename, prefix, model_type)
|
||||
for filename in folder_paths.get_filename_list(model_type)
|
||||
}
|
||||
return web.json_response(info)
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return web.json_response(dict(error=str(e)), status=500)
|
||||
|
||||
|
||||
def has_invalid_folder_name(folder_name: str):
|
||||
valid_names = list(folder_paths.folder_names_and_paths.keys())
|
||||
if folder_name not in valid_names:
|
||||
return web.json_response(
|
||||
dict(error=f"Invalid folder path, must be one of {', '.join(valid_names)}"),
|
||||
status=400,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def has_invalid_filename(filename: str):
|
||||
if not filename.lower().endswith((".sft", ".safetensors")):
|
||||
return web.json_response(dict(error="File extension must be .safetensors"), status=400)
|
||||
if not filename or not filename.strip() or len(filename) > 255:
|
||||
return web.json_response(dict(error="Invalid filename"), status=400)
|
||||
if any(char in filename for char in ["..", "/", "\\", "\n", "\r", "\t", "\0"]):
|
||||
return web.json_response(dict(error="Invalid filename"), status=400)
|
||||
if filename.startswith(".") or not re.match(r"^[a-zA-Z0-9_\-. ]+$", filename):
|
||||
return web.json_response(dict(error="Invalid filename"), status=400)
|
||||
return None
|
||||
|
||||
|
||||
_server: server.PromptServer | None = getattr(server.PromptServer, "instance", None)
|
||||
if _server is not None:
|
||||
|
||||
_workflow_exchange = WorkflowExchange(_server)
|
||||
|
||||
@_server.routes.get("/api/etn/model_info/{folder_name}")
|
||||
async def model_info(request: web.Request):
|
||||
folder_name = request.match_info.get("folder_name", "checkpoints")
|
||||
if error := has_invalid_folder_name(folder_name):
|
||||
return error
|
||||
return inspect_models(folder_name)
|
||||
|
||||
@_server.routes.get("/api/etn/model_info")
|
||||
async def api_model_info(request):
|
||||
return inspect_models("checkpoints")
|
||||
|
||||
@_server.routes.get("/etn/model_info")
|
||||
async def model_info(request):
|
||||
async def api_model_info(request):
|
||||
return inspect_models("checkpoints")
|
||||
|
||||
@_server.routes.get("/api/etn/languages")
|
||||
async def languages(request):
|
||||
try:
|
||||
info = {
|
||||
filename: inspect_checkpoint(filename)
|
||||
for filename in folder_paths.get_filename_list("checkpoints")
|
||||
}
|
||||
return web.json_response(info)
|
||||
result = [dict(name=name, code=code) for code, name in available_languages()]
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
return web.json_response(dict(error=str(e)), status=500)
|
||||
|
||||
@_server.routes.get("/api/etn/translate/{lang}/{text}")
|
||||
async def translate_text(request):
|
||||
try:
|
||||
language = request.match_info.get("lang", "en")
|
||||
text = request.match_info.get("text", "")
|
||||
result = translate(f"lang:{language} {text}")
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
return web.json_response(dict(error=str(e)), status=500)
|
||||
|
||||
@_server.routes.put("/api/etn/upload/{folder_name}/{filename}")
|
||||
async def upload(request: web.Request):
|
||||
folder_name = request.match_info.get("folder_name", "")
|
||||
if error := has_invalid_folder_name(folder_name):
|
||||
return error
|
||||
|
||||
filename = request.match_info.get("filename", "")
|
||||
if error := has_invalid_filename(filename):
|
||||
return error
|
||||
|
||||
try:
|
||||
if folder_paths.get_full_path(folder_name, filename) is not None:
|
||||
return web.json_response(dict(status="cached"), status=200)
|
||||
|
||||
folder = Path(folder_paths.folder_names_and_paths[folder_name][0][0])
|
||||
total_size = int(request.headers.get("Content-Length", "0"))
|
||||
logging.info(f"Uploading {filename} ({total_size/(1024**2):.1f} MB) to {folder} folder")
|
||||
|
||||
with open(folder / filename, "wb") as f:
|
||||
async for chunk, _ in request.content.iter_chunks():
|
||||
f.write(chunk)
|
||||
|
||||
return web.json_response(dict(status="success"), status=201)
|
||||
except Exception as e:
|
||||
return web.json_response(dict(error=str(e)), status=500)
|
||||
|
||||
async def _handle_workflow_request(request: web.Request, handler, *arg_keys):
|
||||
try:
|
||||
data = await request.json()
|
||||
args = [data[key] for key in arg_keys]
|
||||
await handler(*args)
|
||||
return web.json_response(dict(status="success"), status=200)
|
||||
except KeyError as e:
|
||||
return web.json_response(dict(error=str(e)), status=400)
|
||||
except Exception as e:
|
||||
return web.json_response(dict(error=str(e)), status=500)
|
||||
|
||||
@_server.routes.post("/api/etn/workflow/publish")
|
||||
async def publish_workflow(request: web.Request):
|
||||
return await _handle_workflow_request(
|
||||
request, _workflow_exchange.publish, "name", "client_id", "workflow"
|
||||
)
|
||||
|
||||
@_server.routes.post("/api/etn/workflow/subscribe")
|
||||
async def subscribe_workflow(request: web.Request):
|
||||
return await _handle_workflow_request(request, _workflow_exchange.subscribe, "client_id")
|
||||
|
||||
@_server.routes.post("/api/etn/workflow/unsubscribe")
|
||||
async def unsubscribe_workflow(request: web.Request):
|
||||
return await _handle_workflow_request(request, _workflow_exchange.unsubscribe, "client_id")
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,194 @@
|
||||
import { app } from "/scripts/app.js"
|
||||
import { api } from "/scripts/api.js"
|
||||
|
||||
(function() {
|
||||
|
||||
// Workflow publishing
|
||||
// - done whenever the graph changes, as long as there is a KritaOutput node
|
||||
|
||||
let publisherRegistered = false
|
||||
|
||||
async function publishWorkflow(e) {
|
||||
const prompt = await app.graphToPrompt()
|
||||
await api.fetchApi("/api/etn/workflow/publish", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: "ComfyUI Web",
|
||||
client_id: api.clientId,
|
||||
workflow: prompt["output"]
|
||||
}, null, 2)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// Image background for nodes
|
||||
// - this is just for visuals
|
||||
|
||||
function loadImage(base64) {
|
||||
const image = new Image()
|
||||
image.src = base64
|
||||
// image.onerror = () => console.error("Failed to load image");
|
||||
return image
|
||||
}
|
||||
|
||||
const canvasIcon = loadImage("data:image/webp;base64,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")
|
||||
const outputIcon = loadImage("data:image/webp;base64,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")
|
||||
|
||||
function setIconImage(nodeType, image, size, padRows, padCols) {
|
||||
const onAdded = nodeType.prototype.onAdded
|
||||
nodeType.prototype.onAdded = function () {
|
||||
onAdded?.apply(this, arguments)
|
||||
this.size = size
|
||||
}
|
||||
|
||||
const onDrawBackground = nodeType.prototype.onDrawBackground
|
||||
nodeType.prototype.onDrawBackground = function(ctx) {
|
||||
onDrawBackground?.apply(this, arguments)
|
||||
|
||||
const pad = [padCols * 20, LiteGraph.NODE_SLOT_HEIGHT * padRows + 8];
|
||||
if(this.flags.collapsed || pad[1] + 32 > this.size[1] || image.width === 0) {
|
||||
return
|
||||
}
|
||||
const avail = [this.size[0] - pad[0], this.size[1] - pad[1]]
|
||||
const scale = Math.min(1.0, avail[0] / image.width, avail[1] / image.height)
|
||||
const size = [Math.floor(image.width * scale), Math.floor(image.height * scale)]
|
||||
const offset = [Math.max(0, (avail[0] - size[0]) / 2), Math.max(0, (avail[1] - size[1]) / 2)]
|
||||
ctx.drawImage(image, offset[0], pad[1] + offset[1], size[0], size[1])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Parameter node
|
||||
// - represents a customizable parameter that should be exposed in external tools
|
||||
// - adapts to whichever node it is connected to, similar to the built-in "Primitive" node
|
||||
// - can only be connected to slots which are converted widgets
|
||||
|
||||
const replaceableWidgets = ["INT", "FLOAT", "BOOLEAN", "STRING", "COMBO", "INT:seed"]
|
||||
|
||||
const parameterTypes = {
|
||||
"combo": ["choice"],
|
||||
"number": ["number", "number (integer)"],
|
||||
"toggle": ["toggle"],
|
||||
"text": ["text", "prompt (positive)", "prompt (negative)"],
|
||||
}
|
||||
|
||||
function changeWidget(widget, type, value, options) {
|
||||
widget.type = type
|
||||
widget.value = value
|
||||
widget.options = options
|
||||
}
|
||||
|
||||
function changeWidgets(node, type, value, options) {
|
||||
if (type === "customtext") {
|
||||
type = "text"
|
||||
}
|
||||
|
||||
node.widgets[1].value = parameterTypes[type][0]
|
||||
node.widgets[1].options = {values: parameterTypes[type]}
|
||||
changeWidget(node.widgets[2], type, value, options)
|
||||
|
||||
if (type === "number") {
|
||||
changeWidget(node.widgets[3], "number", options?.min ?? 0, options)
|
||||
changeWidget(node.widgets[4], "number", options?.max ?? 100, options)
|
||||
} else {
|
||||
changeWidget(node.widgets[3], "number", 0, {min: 0, max: 0})
|
||||
changeWidget(node.widgets[4], "number", 0, {min: 0, max: 0})
|
||||
}
|
||||
}
|
||||
|
||||
function adaptWidgetsToConnection(node) {
|
||||
if (!node.outputs || node.outputs.length === 0 || !node.outputs[0].links) {
|
||||
return
|
||||
}
|
||||
const links = node.outputs[0].links
|
||||
if (links.length === 1) {
|
||||
const link = node.graph.links[links[0]]
|
||||
if (!link) return
|
||||
|
||||
const theirNode = node.graph.getNodeById(link.target_id)
|
||||
if (!theirNode || !theirNode.inputs) return
|
||||
|
||||
const input = theirNode.inputs[link.target_slot]
|
||||
if (!input) return
|
||||
|
||||
node.outputs[0].type = input.type
|
||||
|
||||
if (node.widgets[0].value === "Parameter") {
|
||||
node.widgets[0].value = input.name
|
||||
}
|
||||
|
||||
const widgetName = input.widget.name
|
||||
const theirWidget = theirNode.widgets.find((w) => w.name === widgetName)
|
||||
const widgetType = theirWidget.origType ?? theirWidget.type
|
||||
changeWidgets(node, widgetType, theirWidget.value, theirWidget.options)
|
||||
|
||||
} else if (links.length === 0) {
|
||||
node.outputs[0].type = "*"
|
||||
}
|
||||
}
|
||||
|
||||
function setupParameterNode(nodeType) {
|
||||
const onAdded = nodeType.prototype.onAdded
|
||||
nodeType.prototype.onAdded = function() {
|
||||
onAdded?.apply(this, arguments)
|
||||
adaptWidgetsToConnection(this)
|
||||
}
|
||||
|
||||
const onAfterGraphConfigured = nodeType.prototype.onAfterGraphConfigured
|
||||
nodeType.prototype.onAfterGraphConfigured = function() {
|
||||
onAfterGraphConfigured?.apply(this, arguments)
|
||||
adaptWidgetsToConnection(this)
|
||||
}
|
||||
|
||||
const onConnectOutput = nodeType.prototype.onConnectOutput
|
||||
nodeType.prototype.onConnectOutput = function(slot, type, input, target_node, target_slot) {
|
||||
if (!input.widget && !(input.type in replaceableWidgets)) {
|
||||
return false
|
||||
} else if (onConnectOutput) {
|
||||
result = onConnectOutput.apply(this, arguments)
|
||||
return result
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const onConnectionsChange = nodeType.prototype.onConnectionsChange
|
||||
nodeType.prototype.onConnectionsChange = function(_, index, connected) {
|
||||
if (!app.configuringGraph) {
|
||||
adaptWidgetsToConnection(this)
|
||||
}
|
||||
onConnectionsChange?.apply(this, arguments)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Register the extension
|
||||
|
||||
app.registerExtension({
|
||||
name: "external_tooling_nodes",
|
||||
|
||||
beforeRegisterNodeDef(nodeType /*typeof LGraphNode*/, nodeData /*ComfyObjectInfo*/, app) {
|
||||
if (nodeData.name === "ETN_KritaCanvas") {
|
||||
setIconImage(nodeType, canvasIcon, [200, 100], 0, 2)
|
||||
} else if (nodeData.name === "ETN_KritaOutput") {
|
||||
setIconImage(nodeType, outputIcon, [200, 120], 2, 0)
|
||||
} else if (nodeData.name == "ETN_Parameter") {
|
||||
setupParameterNode(nodeType)
|
||||
}
|
||||
},
|
||||
|
||||
nodeCreated(node /*ComfyNode*/, app) {
|
||||
if (publisherRegistered || node.comfyClass !== "ETN_KritaOutput") {
|
||||
return
|
||||
}
|
||||
api.addEventListener('graphChanged', publishWorkflow)
|
||||
publisherRegistered = true
|
||||
},
|
||||
|
||||
setup(app) {
|
||||
if (publisherRegistered) {
|
||||
publishWorkflow(null)
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,196 @@
|
||||
import torch
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
from PIL import Image
|
||||
|
||||
import server
|
||||
import comfy.samplers
|
||||
from .nodes import SendImageWebSocket
|
||||
|
||||
|
||||
class Publisher(NamedTuple):
|
||||
name: str
|
||||
id: str
|
||||
workflow: dict
|
||||
|
||||
|
||||
class WorkflowExchange:
|
||||
def __init__(self, server: server.PromptServer):
|
||||
self._server = server
|
||||
self._publishers: dict[str, Publisher] = {}
|
||||
self._subscribers: list[str] = []
|
||||
|
||||
async def publish(self, publisher_name: str, publisher_id: str, workflow: dict):
|
||||
publisher = Publisher(publisher_name, publisher_id, workflow)
|
||||
for client_id in self._subscribers:
|
||||
await self._notify(client_id, publisher)
|
||||
self._publishers[publisher_id] = publisher
|
||||
|
||||
async def subscribe(self, client_id: str):
|
||||
if client_id in self._subscribers:
|
||||
raise KeyError("Already subscribed")
|
||||
self._subscribers.append(client_id)
|
||||
for publisher in self._publishers.values():
|
||||
await self._notify(client_id, publisher)
|
||||
|
||||
def unsubscribe(self, client_id: str):
|
||||
self._subscribers.remove(client_id)
|
||||
|
||||
async def _notify(self, client_id: str, publisher: Publisher):
|
||||
data = {
|
||||
"publisher": {"name": publisher.name, "id": publisher.id},
|
||||
"workflow": publisher.workflow,
|
||||
}
|
||||
await self._server.send_json("etn_workflow_published", data, client_id)
|
||||
|
||||
|
||||
def _placeholder_image():
|
||||
path = Path(__file__).parent / "data" / "external-image-placeholder.webp"
|
||||
image = Image.open(path).convert("RGB")
|
||||
image = np.array(image).astype(np.float32) / 255.0
|
||||
return torch.from_numpy(image)[None,]
|
||||
|
||||
|
||||
class KritaOutput(SendImageWebSocket):
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "send_images"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "krita"
|
||||
|
||||
|
||||
class KritaCanvas:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "INT", "INT", "INT")
|
||||
RETURN_NAMES = ("image", "width", "height", "seed")
|
||||
FUNCTION = "placeholder"
|
||||
CATEGORY = "krita"
|
||||
|
||||
def placeholder(self):
|
||||
return (_placeholder_image(), 512, 512, 0)
|
||||
|
||||
|
||||
class KritaSelection:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {}
|
||||
|
||||
RETURN_TYPES = ("MASK",)
|
||||
RETURN_NAMES = ("mask",)
|
||||
FUNCTION = "placeholder"
|
||||
CATEGORY = "krita"
|
||||
|
||||
def placeholder(self):
|
||||
return (torch.ones(1, 512, 512),)
|
||||
|
||||
|
||||
class KritaImageLayer:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"name": ("STRING", {"default": "Image"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "placeholder"
|
||||
CATEGORY = "krita"
|
||||
|
||||
def placeholder(self, name: str):
|
||||
return (_placeholder_image(),)
|
||||
|
||||
|
||||
class KritaMaskLayer:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"name": ("STRING", {"default": "Mask"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MASK",)
|
||||
RETURN_NAMES = ("mask",)
|
||||
FUNCTION = "placeholder"
|
||||
CATEGORY = "krita"
|
||||
|
||||
def placeholder(self, name: str):
|
||||
return (torch.ones(1, 512, 512),)
|
||||
|
||||
|
||||
_param_types = [
|
||||
"auto",
|
||||
"number",
|
||||
"number (integer)",
|
||||
"toggle",
|
||||
"choice",
|
||||
"text",
|
||||
"prompt (positive)",
|
||||
"prompt (negative)",
|
||||
]
|
||||
|
||||
|
||||
class Parameter:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"name": ("STRING", {"default": "Parameter"}),
|
||||
"type": (_param_types, {"default": "auto"}),
|
||||
"default": ("STRING", {"default": ""}),
|
||||
"min": ("FLOAT", {"default": 0.0}),
|
||||
"max": ("FLOAT", {"default": 1.0}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("*",)
|
||||
RETURN_NAMES = ("value",)
|
||||
FUNCTION = "placeholder"
|
||||
CATEGORY = "krita"
|
||||
|
||||
def placeholder(self, name: str, type: str, default, min, max):
|
||||
return (default,)
|
||||
|
||||
|
||||
class KritaStyle:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"name": ("STRING", {"default": "Style"}),
|
||||
"sampler_preset": (["auto", "regular", "live"],),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = (
|
||||
"MODEL",
|
||||
"CLIP",
|
||||
"VAE",
|
||||
"STRING",
|
||||
"STRING",
|
||||
comfy.samplers.KSampler.SAMPLERS,
|
||||
comfy.samplers.KSampler.SCHEDULERS,
|
||||
"INT",
|
||||
"FLOAT",
|
||||
)
|
||||
RETURN_NAMES = (
|
||||
"model",
|
||||
"clip",
|
||||
"vae",
|
||||
"positive prompt",
|
||||
"negative prompt",
|
||||
"sampler name",
|
||||
"scheduler",
|
||||
"steps",
|
||||
"guidance",
|
||||
)
|
||||
FUNCTION = "placeholder"
|
||||
CATEGORY = "krita"
|
||||
|
||||
def placeholder(self, name: str, sampler_preset: str):
|
||||
raise NotImplementedError("This workflow must be started from Krita!")
|
||||
@@ -50,19 +50,23 @@ class LoadMaskBase64:
|
||||
if img.dim() == 3: # RGB(A) input, use red channel
|
||||
img = img[:, :, 0]
|
||||
return (img.unsqueeze(0),)
|
||||
|
||||
|
||||
|
||||
class SendImageWebSocket:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {"required": {"images": ("IMAGE",)}}
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"format": (["PNG", "JPEG"], {"default": "PNG"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "send_images"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "external_tooling"
|
||||
|
||||
def send_images(self, images):
|
||||
def send_images(self, images, format):
|
||||
results = []
|
||||
for tensor in images:
|
||||
array = 255.0 * tensor.cpu().numpy()
|
||||
@@ -71,17 +75,15 @@ class SendImageWebSocket:
|
||||
server = PromptServer.instance
|
||||
server.send_sync(
|
||||
BinaryEventTypes.UNENCODED_PREVIEW_IMAGE,
|
||||
["PNG", image, None],
|
||||
[format, image, None],
|
||||
server.client_id,
|
||||
)
|
||||
results.append(
|
||||
# Could put some kind of ID here, but for now just match them by index
|
||||
{"source": "websocket", "content-type": "image/png", "type": "output"}
|
||||
{"source": "websocket", "content-type": f"image/{format.lower()}", "type": "output"}
|
||||
)
|
||||
|
||||
return {"ui": {"images": results}}
|
||||
|
||||
|
||||
class CropImage:
|
||||
"""Deprecated, ComfyUI has an ImageCrop node now which does the same."""
|
||||
|
||||
@@ -118,6 +120,22 @@ class CropImage:
|
||||
return (out,)
|
||||
|
||||
|
||||
def to_bchw(image: torch.Tensor):
|
||||
if image.ndim == 3:
|
||||
image = image.unsqueeze(0)
|
||||
return image.movedim(-1, 1)
|
||||
|
||||
|
||||
def to_bhwc(image: torch.Tensor):
|
||||
return image.movedim(1, -1)
|
||||
|
||||
|
||||
def mask_batch(mask: torch.Tensor):
|
||||
if mask.ndim == 2:
|
||||
mask = mask.unsqueeze(0)
|
||||
return mask
|
||||
|
||||
|
||||
class ApplyMaskToImage:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -133,29 +151,21 @@ class ApplyMaskToImage:
|
||||
FUNCTION = "apply_mask"
|
||||
|
||||
def apply_mask(self, image: torch.Tensor, mask: torch.Tensor):
|
||||
# Move the channel to the second dimension for processing
|
||||
out = image.movedim(-1, 1)
|
||||
|
||||
# Check if the images are RGB, and if so, add an alpha channel initialized to 1
|
||||
out = to_bchw(image)
|
||||
if out.shape[1] == 3: # Assuming RGB images
|
||||
out = torch.cat([out, torch.ones_like(out[:, :1, :, :])], dim=1)
|
||||
|
||||
# Ensure masks are unsqueezed to match the alpha channel dimension if needed
|
||||
if mask.ndim == 2:
|
||||
mask = mask.unsqueeze(0) # Add a batch dimension to masks
|
||||
# For single mask, expand it to match size of image batch size.
|
||||
if mask.shape[0] == 1:
|
||||
mask = mask.repeat(out.shape[0], 1, 1)
|
||||
mask = mask_batch(mask)
|
||||
|
||||
assert mask.ndim == 3, f"Mask should have shape [B, H, W]. {mask.shape}"
|
||||
assert out.ndim == 4, f"Image should have shsape [B, C, H, W]. {out.shape}"
|
||||
assert out.shape[-2:] == mask.shape[-2:], f"{out.shape[-2:]} != {mask.shape[-2:]}"
|
||||
assert out.shape[0] == mask.shape[0], f"{out.shape[0]} != {mask.shape[0]}"
|
||||
assert out.ndim == 4, f"Image should have shape [B, C, H, W]. {out.shape}"
|
||||
assert (
|
||||
out.shape[-2:] == mask.shape[-2:]
|
||||
), f"Image size {out.shape[-2:]} must match mask size {mask.shape[-2:]}"
|
||||
is_mask_batch = mask.shape[0] == out.shape[0]
|
||||
|
||||
# Apply each mask in the batch to its corresponding image's alpha channel
|
||||
for i in range(out.shape[0]):
|
||||
out[i, 3, :, :] = mask[i]
|
||||
alpha = mask[i] if is_mask_batch else mask[0]
|
||||
out[i, 3, :, :] = alpha
|
||||
|
||||
# Move the channel back to its original dimension
|
||||
out = out.movedim(1, -1)
|
||||
|
||||
return (out,)
|
||||
return (to_bhwc(out),)
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
from weakref import ref as WeakRef
|
||||
from pathlib import Path
|
||||
from tqdm import tqdm
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
from torch import Tensor
|
||||
from transformers import CLIPImageProcessor, CLIPConfig, CLIPVisionModel, PreTrainedModel
|
||||
from kornia.filters import box_blur
|
||||
|
||||
from .nodes import to_bchw, to_bhwc
|
||||
|
||||
|
||||
def cosine_similarity(image_embeds: Tensor, text_embeds: Tensor):
|
||||
if image_embeds.dim() == 2 and text_embeds.dim() == 2:
|
||||
image_embeds = image_embeds.unsqueeze(1)
|
||||
return F.cosine_similarity(image_embeds, text_embeds, dim=-1)
|
||||
|
||||
|
||||
class CLIPSafetyChecker(PreTrainedModel):
|
||||
# https://huggingface.co/CompVis/stable-diffusion-safety-checker
|
||||
# Adapted from:
|
||||
# https://github.com/huggingface/diffusers/blob/main/src/diffusers/pipelines/stable_diffusion/safety_checker.py
|
||||
|
||||
config_class = CLIPConfig
|
||||
_no_split_modules = ["CLIPEncoderLayer"]
|
||||
|
||||
def __init__(self, config: CLIPConfig):
|
||||
super().__init__(config)
|
||||
projdim = config.projection_dim
|
||||
|
||||
self.vision_model = CLIPVisionModel(config.vision_config)
|
||||
self.visual_projection = nn.Linear(config.vision_config.hidden_size, projdim, bias=False)
|
||||
|
||||
self.concept_embeds = nn.Parameter(torch.ones(17, projdim), requires_grad=False)
|
||||
self.special_care_embeds = nn.Parameter(torch.ones(3, projdim), requires_grad=False)
|
||||
self.concept_embeds_weights = nn.Parameter(torch.ones(17), requires_grad=False)
|
||||
self.special_care_embeds_weights = nn.Parameter(torch.ones(3), requires_grad=False)
|
||||
|
||||
def forward(self, clip_input, images: Tensor, sensitivity: float):
|
||||
with torch.no_grad():
|
||||
image_batch = self.vision_model(clip_input)[1]
|
||||
image_embeds = self.visual_projection(image_batch)
|
||||
sensitivity = -0.1 + 0.14 * sensitivity
|
||||
|
||||
special_cos_dist = cosine_similarity(image_embeds, self.special_care_embeds)
|
||||
special_scores_threshold = self.special_care_embeds_weights.unsqueeze(0)
|
||||
special_scores = special_cos_dist - special_scores_threshold + sensitivity
|
||||
|
||||
if torch.any(special_scores > 0):
|
||||
sensitivity = sensitivity + 0.01
|
||||
|
||||
cos_dist = cosine_similarity(image_embeds, self.concept_embeds)
|
||||
concept_threshold = self.concept_embeds_weights.unsqueeze(0)
|
||||
concept_scores = cos_dist - concept_threshold + sensitivity
|
||||
|
||||
is_nsfw = [torch.any(concept_scores[i] > 0) for i in range(concept_scores.shape[0])]
|
||||
is_nsfw = [x.item() for x in is_nsfw]
|
||||
return self.filter_images(images, is_nsfw)
|
||||
|
||||
def filter_images(self, images: Tensor, is_nsfw: list[bool]):
|
||||
if not any(is_nsfw):
|
||||
return images
|
||||
|
||||
images = images.clone()
|
||||
images_to_filter = (i for i, nsfw in enumerate(is_nsfw) if nsfw)
|
||||
orig_size = images.shape[-2:]
|
||||
for idx in images_to_filter:
|
||||
filtered = images[idx].unsqueeze(0)
|
||||
filtered = F.interpolate(filtered, size=64, mode="nearest")
|
||||
filtered = box_blur(filtered, 11, separable=True)
|
||||
filtered = F.interpolate(filtered, size=orig_size, mode="bilinear")
|
||||
images[idx] = filtered.squeeze(0)
|
||||
return images
|
||||
|
||||
|
||||
class CachedModels:
|
||||
_instance: WeakRef | None = None
|
||||
|
||||
def __init__(self):
|
||||
model_dir = Path(__file__).parent / "safetychecker"
|
||||
model_file = model_dir / "model.safetensors"
|
||||
if not model_file.exists():
|
||||
self.download(
|
||||
"https://huggingface.co/CompVis/stable-diffusion-safety-checker/resolve/refs%2Fpr%2F41/model.safetensors",
|
||||
target=model_file,
|
||||
)
|
||||
self.feature_extractor = CLIPImageProcessor.from_pretrained(model_dir)
|
||||
self.safety_checker = CLIPSafetyChecker.from_pretrained(model_dir)
|
||||
|
||||
@classmethod
|
||||
def load(cls):
|
||||
models = cls._instance and cls._instance()
|
||||
if models is None:
|
||||
models = cls()
|
||||
cls._instance = WeakRef(models)
|
||||
return models
|
||||
|
||||
def download(self, url: str, target: Path):
|
||||
import requests
|
||||
|
||||
try:
|
||||
target_temp = target.with_suffix(".download")
|
||||
with requests.get(url, stream=True) as response:
|
||||
text = "NSFWFilter model download"
|
||||
total = int(response.headers.get("content-length", 0))
|
||||
pbar = tqdm(None, total=total, unit="b", unit_scale=True, desc=text)
|
||||
with open(target_temp, "wb") as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
pbar.update(len(chunk))
|
||||
pbar.close()
|
||||
target_temp.rename(target)
|
||||
except Exception as e:
|
||||
raise RuntimeError(
|
||||
f"NSFWFilter: Failed to download safety-checker model from {url} to target location {target}: {e}"
|
||||
) from e
|
||||
|
||||
|
||||
class NSFWFilter:
|
||||
models: CachedModels
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"sensitivity": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.10}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "check"
|
||||
CATEGORY = "external_tooling"
|
||||
|
||||
def __init__(self):
|
||||
self.models = CachedModels.load()
|
||||
|
||||
def check(self, image, sensitivity):
|
||||
image = to_bchw(image)
|
||||
input = self.models.feature_extractor(image, do_rescale=False, return_tensors="pt")
|
||||
filtered = self.models.safety_checker(
|
||||
images=image, clip_input=input.pixel_values, sensitivity=sensitivity
|
||||
)
|
||||
return (to_bhwc(filtered),)
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
[project]
|
||||
name = "comfyui-tooling-nodes"
|
||||
description = "Provides nodes and server API extensions geared towards using ComfyUI as a backend for external tools."
|
||||
version = "1.0.0"
|
||||
license = "LICENSE"
|
||||
version = "1.5.0"
|
||||
license = { file = "LICENSE" }
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/Acly/comfyui-tooling-nodes"
|
||||
|
||||
@@ -96,6 +96,8 @@ class DefineRegion:
|
||||
FUNCTION = "define"
|
||||
|
||||
def define(self, mask: Tensor, conditioning: list, regions: Region | None = None):
|
||||
if mask.dim() < 3:
|
||||
mask = mask.unsqueeze(0)
|
||||
return (Region(regions, mask, conditioning),)
|
||||
|
||||
|
||||
@@ -148,9 +150,9 @@ class AttentionMask:
|
||||
assert k.mean() == v.mean(), "k and v must be the same."
|
||||
device, dtype = q.device, q.dtype
|
||||
|
||||
if self.conds[0].device != device:
|
||||
if self.conds[0].device != device or self.conds[0].dtype != dtype:
|
||||
self.conds = [cond.to(device, dtype=dtype) for cond in self.conds]
|
||||
if self.mask.device != device:
|
||||
if self.mask.device != device or self.mask.dtype != dtype:
|
||||
self.mask = self.mask.to(device, dtype=dtype)
|
||||
|
||||
cond_or_unconds = extra_options["cond_or_uncond"]
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Optional, only required for Translate node:
|
||||
argostranslate
|
||||
@@ -0,0 +1,171 @@
|
||||
{
|
||||
"_name_or_path": "clip-vit-large-patch14/",
|
||||
"architectures": [
|
||||
"SafetyChecker"
|
||||
],
|
||||
"initializer_factor": 1.0,
|
||||
"logit_scale_init_value": 2.6592,
|
||||
"model_type": "clip",
|
||||
"projection_dim": 768,
|
||||
"text_config": {
|
||||
"_name_or_path": "",
|
||||
"add_cross_attention": false,
|
||||
"architectures": null,
|
||||
"attention_dropout": 0.0,
|
||||
"bad_words_ids": null,
|
||||
"bos_token_id": 0,
|
||||
"chunk_size_feed_forward": 0,
|
||||
"cross_attention_hidden_size": null,
|
||||
"decoder_start_token_id": null,
|
||||
"diversity_penalty": 0.0,
|
||||
"do_sample": false,
|
||||
"dropout": 0.0,
|
||||
"early_stopping": false,
|
||||
"encoder_no_repeat_ngram_size": 0,
|
||||
"eos_token_id": 2,
|
||||
"exponential_decay_length_penalty": null,
|
||||
"finetuning_task": null,
|
||||
"forced_bos_token_id": null,
|
||||
"forced_eos_token_id": null,
|
||||
"hidden_act": "quick_gelu",
|
||||
"hidden_size": 768,
|
||||
"id2label": {
|
||||
"0": "LABEL_0",
|
||||
"1": "LABEL_1"
|
||||
},
|
||||
"initializer_factor": 1.0,
|
||||
"initializer_range": 0.02,
|
||||
"intermediate_size": 3072,
|
||||
"is_decoder": false,
|
||||
"is_encoder_decoder": false,
|
||||
"label2id": {
|
||||
"LABEL_0": 0,
|
||||
"LABEL_1": 1
|
||||
},
|
||||
"layer_norm_eps": 1e-05,
|
||||
"length_penalty": 1.0,
|
||||
"max_length": 20,
|
||||
"max_position_embeddings": 77,
|
||||
"min_length": 0,
|
||||
"model_type": "clip_text_model",
|
||||
"no_repeat_ngram_size": 0,
|
||||
"num_attention_heads": 12,
|
||||
"num_beam_groups": 1,
|
||||
"num_beams": 1,
|
||||
"num_hidden_layers": 12,
|
||||
"num_return_sequences": 1,
|
||||
"output_attentions": false,
|
||||
"output_hidden_states": false,
|
||||
"output_scores": false,
|
||||
"pad_token_id": 1,
|
||||
"prefix": null,
|
||||
"problem_type": null,
|
||||
"pruned_heads": {},
|
||||
"remove_invalid_values": false,
|
||||
"repetition_penalty": 1.0,
|
||||
"return_dict": true,
|
||||
"return_dict_in_generate": false,
|
||||
"sep_token_id": null,
|
||||
"task_specific_params": null,
|
||||
"temperature": 1.0,
|
||||
"tie_encoder_decoder": false,
|
||||
"tie_word_embeddings": true,
|
||||
"tokenizer_class": null,
|
||||
"top_k": 50,
|
||||
"top_p": 1.0,
|
||||
"torch_dtype": null,
|
||||
"torchscript": false,
|
||||
"transformers_version": "4.21.0.dev0",
|
||||
"typical_p": 1.0,
|
||||
"use_bfloat16": false,
|
||||
"vocab_size": 49408
|
||||
},
|
||||
"text_config_dict": {
|
||||
"hidden_size": 768,
|
||||
"intermediate_size": 3072,
|
||||
"num_attention_heads": 12,
|
||||
"num_hidden_layers": 12
|
||||
},
|
||||
"torch_dtype": "float32",
|
||||
"transformers_version": null,
|
||||
"vision_config": {
|
||||
"_name_or_path": "",
|
||||
"add_cross_attention": false,
|
||||
"architectures": null,
|
||||
"attention_dropout": 0.0,
|
||||
"bad_words_ids": null,
|
||||
"bos_token_id": null,
|
||||
"chunk_size_feed_forward": 0,
|
||||
"cross_attention_hidden_size": null,
|
||||
"decoder_start_token_id": null,
|
||||
"diversity_penalty": 0.0,
|
||||
"do_sample": false,
|
||||
"dropout": 0.0,
|
||||
"early_stopping": false,
|
||||
"encoder_no_repeat_ngram_size": 0,
|
||||
"eos_token_id": null,
|
||||
"exponential_decay_length_penalty": null,
|
||||
"finetuning_task": null,
|
||||
"forced_bos_token_id": null,
|
||||
"forced_eos_token_id": null,
|
||||
"hidden_act": "quick_gelu",
|
||||
"hidden_size": 1024,
|
||||
"id2label": {
|
||||
"0": "LABEL_0",
|
||||
"1": "LABEL_1"
|
||||
},
|
||||
"image_size": 224,
|
||||
"initializer_factor": 1.0,
|
||||
"initializer_range": 0.02,
|
||||
"intermediate_size": 4096,
|
||||
"is_decoder": false,
|
||||
"is_encoder_decoder": false,
|
||||
"label2id": {
|
||||
"LABEL_0": 0,
|
||||
"LABEL_1": 1
|
||||
},
|
||||
"layer_norm_eps": 1e-05,
|
||||
"length_penalty": 1.0,
|
||||
"max_length": 20,
|
||||
"min_length": 0,
|
||||
"model_type": "clip_vision_model",
|
||||
"no_repeat_ngram_size": 0,
|
||||
"num_attention_heads": 16,
|
||||
"num_beam_groups": 1,
|
||||
"num_beams": 1,
|
||||
"num_hidden_layers": 24,
|
||||
"num_return_sequences": 1,
|
||||
"output_attentions": false,
|
||||
"output_hidden_states": false,
|
||||
"output_scores": false,
|
||||
"pad_token_id": null,
|
||||
"patch_size": 14,
|
||||
"prefix": null,
|
||||
"problem_type": null,
|
||||
"pruned_heads": {},
|
||||
"remove_invalid_values": false,
|
||||
"repetition_penalty": 1.0,
|
||||
"return_dict": true,
|
||||
"return_dict_in_generate": false,
|
||||
"sep_token_id": null,
|
||||
"task_specific_params": null,
|
||||
"temperature": 1.0,
|
||||
"tie_encoder_decoder": false,
|
||||
"tie_word_embeddings": true,
|
||||
"tokenizer_class": null,
|
||||
"top_k": 50,
|
||||
"top_p": 1.0,
|
||||
"torch_dtype": null,
|
||||
"torchscript": false,
|
||||
"transformers_version": "4.21.0.dev0",
|
||||
"typical_p": 1.0,
|
||||
"use_bfloat16": false
|
||||
},
|
||||
"vision_config_dict": {
|
||||
"hidden_size": 1024,
|
||||
"intermediate_size": 4096,
|
||||
"num_attention_heads": 16,
|
||||
"num_hidden_layers": 24,
|
||||
"patch_size": 14
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"crop_size": 224,
|
||||
"do_center_crop": true,
|
||||
"do_convert_rgb": true,
|
||||
"do_normalize": true,
|
||||
"do_resize": true,
|
||||
"feature_extractor_type": "CLIPFeatureExtractor",
|
||||
"image_mean": [
|
||||
0.48145466,
|
||||
0.4578275,
|
||||
0.40821073
|
||||
],
|
||||
"image_std": [
|
||||
0.26862954,
|
||||
0.26130258,
|
||||
0.27577711
|
||||
],
|
||||
"resample": 3,
|
||||
"size": 224
|
||||
}
|
||||
@@ -41,7 +41,7 @@ class TileLayout:
|
||||
self.image_size = np.array(image.shape[-3:-1])
|
||||
self.padding = padding
|
||||
self.blending = blending
|
||||
self.tile_count = self.image_size // (min_tile_size - 2 * padding)
|
||||
self.tile_count = np.maximum(1, self.image_size // (min_tile_size - 2 * padding))
|
||||
|
||||
image_size_with_overlap = self.image_size + (self.tile_count - 1) * 2 * padding
|
||||
tile_size = np.ceil(image_size_with_overlap / self.tile_count)
|
||||
@@ -85,7 +85,7 @@ class TileLayout:
|
||||
mask = torch.zeros((1, 1, size[0], size[1]), dtype=torch.float)
|
||||
mask[:, :, s[0] : e[0], s[1] : e[1]] = 1.0
|
||||
if blend and self.blending > 0:
|
||||
mask = box_blur(mask, (self.blending, self.blending), separable=True)
|
||||
mask = box_blur(mask, (self.blending, self.blending))
|
||||
return mask.squeeze(0)
|
||||
|
||||
def merge(self, image: Tensor, index: int, tile: Tensor):
|
||||
@@ -93,7 +93,7 @@ class TileLayout:
|
||||
rect = self.rect(coord)
|
||||
mask = self.mask(coord, blend=True)
|
||||
mask = mask.reshape(*mask.shape, 1).repeat(1, 1, 1, image.shape[-1])
|
||||
image[*rect] = (1 - mask) * image[*rect] + mask * tile
|
||||
image[rect] = (1 - mask) * image[rect] + mask * tile
|
||||
|
||||
|
||||
class ExtractImageTile:
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
"""Text translation using Argos Translate.
|
||||
|
||||
The node takes text input and translates it to English. The text may contain any
|
||||
number of language directives in the form `lang:xx` where `xx` is a two-letter
|
||||
language code. Text fragments after a language directives are translated.
|
||||
If the language is `en` text is passed through unmodified.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import re
|
||||
from functools import cache
|
||||
from typing import NamedTuple
|
||||
|
||||
|
||||
@cache
|
||||
def available_languages():
|
||||
try:
|
||||
from argostranslate.package import update_package_index, get_available_packages
|
||||
|
||||
update_package_index()
|
||||
list = get_available_packages()
|
||||
return [(l.from_code, l.from_name) for l in list if l.to_code == "en"]
|
||||
except ImportError:
|
||||
return [("NOT INSTALLED", "NOT INSTALLED")]
|
||||
|
||||
|
||||
def translate_chunk(text: str, language: str):
|
||||
if text.strip() == "":
|
||||
return text
|
||||
|
||||
target = "en"
|
||||
if language == target:
|
||||
return text
|
||||
|
||||
try:
|
||||
from argostranslate.package import get_installed_packages, get_available_packages
|
||||
from argostranslate.translate import translate
|
||||
|
||||
installed = get_installed_packages()
|
||||
if not any(p.from_code == language and p.to_code == target for p in installed):
|
||||
available = get_available_packages()
|
||||
pkg = next(
|
||||
(p for p in available if p.from_code == language and p.to_code == target), None
|
||||
)
|
||||
assert pkg, f"Couldn't find package for translation from {language}"
|
||||
print("Downloading and installing translation package", pkg)
|
||||
pkg.install()
|
||||
|
||||
text, embeddings = _extract_embeddings(text)
|
||||
translation = translate(text, language, target)
|
||||
return embeddings + translation
|
||||
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Argos Translate is not installed. Please install it with `pip install argostranslate`"
|
||||
)
|
||||
|
||||
|
||||
def translate(text: str):
|
||||
chunks = Chunk.parse(text)
|
||||
return " ".join(translate_chunk(c.text, c.lang) for c in chunks)
|
||||
|
||||
|
||||
class Translate:
|
||||
@staticmethod
|
||||
def INPUT_TYPES():
|
||||
return {"required": {"text": ("STRING", {"multiline": True})}}
|
||||
|
||||
CATEGORY = "external_tooling"
|
||||
RETURN_TYPES = ("STRING",)
|
||||
FUNCTION = "translate"
|
||||
|
||||
def translate(self, text: str):
|
||||
return (translate(text),)
|
||||
|
||||
|
||||
_lang_regex = re.compile(r"(lang:\w\w)")
|
||||
|
||||
|
||||
class Chunk(NamedTuple):
|
||||
text: str
|
||||
lang: str
|
||||
|
||||
@staticmethod
|
||||
def parse(text: str):
|
||||
languages = [code for code, name in available_languages()] + ["en"]
|
||||
chunks: list[Chunk] = []
|
||||
lang = "en"
|
||||
last = 0
|
||||
for m in _lang_regex.finditer(text):
|
||||
if m.start() > 0:
|
||||
chunks.append(Chunk(text[last : m.start()].strip(), lang))
|
||||
last = m.end()
|
||||
lang = m.group(0)[5:]
|
||||
if lang not in languages:
|
||||
raise ValueError(
|
||||
f"Invalid language directive {m.group(0)} - {lang} is not a known language code."
|
||||
f" Available languages: {', '.join(languages)}"
|
||||
)
|
||||
if last < len(text):
|
||||
chunks.append(Chunk(text[last:].strip(), lang))
|
||||
return [c for c in chunks if c.text != ""]
|
||||
|
||||
|
||||
_embedding_regex = re.compile(r"(embedding:[^\s,]+)")
|
||||
|
||||
|
||||
def _extract_embeddings(text: str):
|
||||
matches = _embedding_regex.findall(text)
|
||||
embeddings = " ".join(matches)
|
||||
if matches:
|
||||
embeddings += " "
|
||||
for m in matches:
|
||||
text = text.replace(m, "")
|
||||
return text, embeddings
|
||||
@@ -0,0 +1,771 @@
|
||||
{
|
||||
"last_node_id": 78,
|
||||
"last_link_id": 126,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 60,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
-692,
|
||||
-758
|
||||
],
|
||||
"size": {
|
||||
"0": 310.5925598144531,
|
||||
"1": 335.9309997558594
|
||||
},
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
101,
|
||||
110,
|
||||
111,
|
||||
112,
|
||||
113
|
||||
],
|
||||
"shape": 3,
|
||||
"slot_index": 0
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null,
|
||||
"shape": 3
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
"photo.jpg",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 74,
|
||||
"type": "ETN_MergeImageTile",
|
||||
"pos": [
|
||||
-326,
|
||||
-250
|
||||
],
|
||||
"size": {
|
||||
"0": 315,
|
||||
"1": 98
|
||||
},
|
||||
"flags": {},
|
||||
"order": 12,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 120
|
||||
},
|
||||
{
|
||||
"name": "layout",
|
||||
"type": "TILE_LAYOUT",
|
||||
"link": 122,
|
||||
"slot_index": 1
|
||||
},
|
||||
{
|
||||
"name": "tile",
|
||||
"type": "IMAGE",
|
||||
"link": 121
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
123
|
||||
],
|
||||
"shape": 3,
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ETN_MergeImageTile"
|
||||
},
|
||||
"widgets_values": [
|
||||
3
|
||||
],
|
||||
"color": "#232",
|
||||
"bgcolor": "#353"
|
||||
},
|
||||
{
|
||||
"id": 61,
|
||||
"type": "ETN_TileLayout",
|
||||
"pos": [
|
||||
-333,
|
||||
-639
|
||||
],
|
||||
"size": {
|
||||
"0": 315,
|
||||
"1": 106
|
||||
},
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 101
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "TILE_LAYOUT",
|
||||
"type": "TILE_LAYOUT",
|
||||
"links": [
|
||||
102,
|
||||
104,
|
||||
105,
|
||||
106,
|
||||
122,
|
||||
124
|
||||
],
|
||||
"shape": 3,
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ETN_TileLayout"
|
||||
},
|
||||
"widgets_values": [
|
||||
880,
|
||||
48,
|
||||
16
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 63,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
380,
|
||||
-700
|
||||
],
|
||||
"size": {
|
||||
"0": 221.69317626953125,
|
||||
"1": 191.44602966308594
|
||||
},
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 103
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewImage"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 69,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
620,
|
||||
-470
|
||||
],
|
||||
"size": {
|
||||
"0": 231.39317321777344,
|
||||
"1": 200.04603576660156
|
||||
},
|
||||
"flags": {},
|
||||
"order": 11,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 109
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewImage"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 75,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
25,
|
||||
-220
|
||||
],
|
||||
"size": {
|
||||
"0": 321.5931701660156,
|
||||
"1": 266.5460205078125
|
||||
},
|
||||
"flags": {},
|
||||
"order": 14,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 123
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewImage"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 76,
|
||||
"type": "ETN_GenerateTileMask",
|
||||
"pos": [
|
||||
376,
|
||||
-207
|
||||
],
|
||||
"size": {
|
||||
"0": 210,
|
||||
"1": 85.74603271484375
|
||||
},
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "layout",
|
||||
"type": "TILE_LAYOUT",
|
||||
"link": 124,
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": [
|
||||
125
|
||||
],
|
||||
"shape": 3,
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ETN_GenerateTileMask"
|
||||
},
|
||||
"widgets_values": [
|
||||
3,
|
||||
true
|
||||
],
|
||||
"color": "#432",
|
||||
"bgcolor": "#653"
|
||||
},
|
||||
{
|
||||
"id": 77,
|
||||
"type": "MaskToImage",
|
||||
"pos": [
|
||||
620,
|
||||
-210
|
||||
],
|
||||
"size": {
|
||||
"0": 210,
|
||||
"1": 26
|
||||
},
|
||||
"flags": {},
|
||||
"order": 13,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"link": 125
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
126
|
||||
],
|
||||
"shape": 3,
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "MaskToImage"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 78,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
629,
|
||||
-140
|
||||
],
|
||||
"size": {
|
||||
"0": 227.79318237304688,
|
||||
"1": 201.64602661132812
|
||||
},
|
||||
"flags": {},
|
||||
"order": 15,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 126
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewImage"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 71,
|
||||
"type": "EmptyImage",
|
||||
"pos": [
|
||||
-681,
|
||||
-247
|
||||
],
|
||||
"size": {
|
||||
"0": 315,
|
||||
"1": 130
|
||||
},
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
120
|
||||
],
|
||||
"shape": 3,
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
2304,
|
||||
1728,
|
||||
1,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 67,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
380,
|
||||
-470
|
||||
],
|
||||
"size": {
|
||||
"0": 222.79318237304688,
|
||||
"1": 197.94602966308594
|
||||
},
|
||||
"flags": {},
|
||||
"order": 9,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 107
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewImage"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 68,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
620,
|
||||
-703
|
||||
],
|
||||
"size": {
|
||||
"0": 225.89317321777344,
|
||||
"1": 194.74603271484375
|
||||
},
|
||||
"flags": {},
|
||||
"order": 10,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 108
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewImage"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 65,
|
||||
"type": "ETN_ExtractImageTile",
|
||||
"pos": [
|
||||
30,
|
||||
-760
|
||||
],
|
||||
"size": {
|
||||
"0": 278.19317626953125,
|
||||
"1": 78
|
||||
},
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 110
|
||||
},
|
||||
{
|
||||
"name": "layout",
|
||||
"type": "TILE_LAYOUT",
|
||||
"link": 105
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
108
|
||||
],
|
||||
"shape": 3,
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ETN_ExtractImageTile"
|
||||
},
|
||||
"widgets_values": [
|
||||
2
|
||||
],
|
||||
"color": "#2a363b",
|
||||
"bgcolor": "#3f5159"
|
||||
},
|
||||
{
|
||||
"id": 62,
|
||||
"type": "ETN_ExtractImageTile",
|
||||
"pos": [
|
||||
40,
|
||||
-630
|
||||
],
|
||||
"size": {
|
||||
"0": 274.19317626953125,
|
||||
"1": 78
|
||||
},
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 113,
|
||||
"slot_index": 0
|
||||
},
|
||||
{
|
||||
"name": "layout",
|
||||
"type": "TILE_LAYOUT",
|
||||
"link": 102
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
103
|
||||
],
|
||||
"shape": 3,
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ETN_ExtractImageTile"
|
||||
},
|
||||
"widgets_values": [
|
||||
0
|
||||
],
|
||||
"color": "#2a363b",
|
||||
"bgcolor": "#3f5159"
|
||||
},
|
||||
{
|
||||
"id": 64,
|
||||
"type": "ETN_ExtractImageTile",
|
||||
"pos": [
|
||||
40,
|
||||
-510
|
||||
],
|
||||
"size": {
|
||||
"0": 273.2931823730469,
|
||||
"1": 78
|
||||
},
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 111
|
||||
},
|
||||
{
|
||||
"name": "layout",
|
||||
"type": "TILE_LAYOUT",
|
||||
"link": 104
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
107
|
||||
],
|
||||
"shape": 3,
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ETN_ExtractImageTile"
|
||||
},
|
||||
"widgets_values": [
|
||||
1
|
||||
],
|
||||
"color": "#2a363b",
|
||||
"bgcolor": "#3f5159"
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"type": "ETN_ExtractImageTile",
|
||||
"pos": [
|
||||
40,
|
||||
-370
|
||||
],
|
||||
"size": {
|
||||
"0": 275.4931945800781,
|
||||
"1": 78
|
||||
},
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 112
|
||||
},
|
||||
{
|
||||
"name": "layout",
|
||||
"type": "TILE_LAYOUT",
|
||||
"link": 106
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
109,
|
||||
121
|
||||
],
|
||||
"shape": 3,
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ETN_ExtractImageTile"
|
||||
},
|
||||
"widgets_values": [
|
||||
3
|
||||
],
|
||||
"color": "#2a363b",
|
||||
"bgcolor": "#3f5159"
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
101,
|
||||
60,
|
||||
0,
|
||||
61,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
102,
|
||||
61,
|
||||
0,
|
||||
62,
|
||||
1,
|
||||
"TILE_LAYOUT"
|
||||
],
|
||||
[
|
||||
103,
|
||||
62,
|
||||
0,
|
||||
63,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
104,
|
||||
61,
|
||||
0,
|
||||
64,
|
||||
1,
|
||||
"TILE_LAYOUT"
|
||||
],
|
||||
[
|
||||
105,
|
||||
61,
|
||||
0,
|
||||
65,
|
||||
1,
|
||||
"TILE_LAYOUT"
|
||||
],
|
||||
[
|
||||
106,
|
||||
61,
|
||||
0,
|
||||
66,
|
||||
1,
|
||||
"TILE_LAYOUT"
|
||||
],
|
||||
[
|
||||
107,
|
||||
64,
|
||||
0,
|
||||
67,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
108,
|
||||
65,
|
||||
0,
|
||||
68,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
109,
|
||||
66,
|
||||
0,
|
||||
69,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
110,
|
||||
60,
|
||||
0,
|
||||
65,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
111,
|
||||
60,
|
||||
0,
|
||||
64,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
112,
|
||||
60,
|
||||
0,
|
||||
66,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
113,
|
||||
60,
|
||||
0,
|
||||
62,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
120,
|
||||
71,
|
||||
0,
|
||||
74,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
121,
|
||||
66,
|
||||
0,
|
||||
74,
|
||||
2,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
122,
|
||||
61,
|
||||
0,
|
||||
74,
|
||||
1,
|
||||
"TILE_LAYOUT"
|
||||
],
|
||||
[
|
||||
123,
|
||||
74,
|
||||
0,
|
||||
75,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
124,
|
||||
61,
|
||||
0,
|
||||
76,
|
||||
0,
|
||||
"TILE_LAYOUT"
|
||||
],
|
||||
[
|
||||
125,
|
||||
76,
|
||||
0,
|
||||
77,
|
||||
0,
|
||||
"MASK"
|
||||
],
|
||||
[
|
||||
126,
|
||||
77,
|
||||
0,
|
||||
78,
|
||||
0,
|
||||
"IMAGE"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.9090909090909091,
|
||||
"offset": [
|
||||
899.4068198252606,
|
||||
850.8539704011341
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 668 KiB |
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 418 KiB |
Reference in New Issue
Block a user