From e908977d543a23b261a9adaefdf2796e67febe44 Mon Sep 17 00:00:00 2001 From: KunLnn Date: Thu, 7 May 2026 00:56:12 +0800 Subject: [PATCH] Publish ComfyUI RefineNode plugin --- .gitignore | 2 + README.md | 124 ++ __init__.py | 3 + .../Reference-based Logo Refinement.json | 1432 +++++++++++++++++ .../Reference-free Text Refinement.json | 1207 ++++++++++++++ nodes.py | 787 +++++++++ requirements.txt | 2 + 7 files changed, 3557 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py create mode 100644 example_workflows/Reference-based Logo Refinement.json create mode 100644 example_workflows/Reference-free Text Refinement.json create mode 100644 nodes.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43ae0e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.py[cod] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ecca13f --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# ComfyUI-RefineNode + +Generic ComfyUI nodes for local image repainting, detail repair, product/logo/text refinement, and paste-back compositing. + +These nodes are model-agnostic. They do not load checkpoints, patch model internals, or require a Diffusers pipeline. Use them before and after any image editing, inpainting, Kontext, Qwen Image Edit Plus, Flux, SD inpaint, or custom repair workflow where you need: + +- crop the masked region while preserving paste-back metadata; +- prepare target/reference/mask images at matching sizes; +- send the processed image into any model-specific generation path; +- paste the generated result back into the original image through the mask or bbox. + +## Install + +Copy this folder into: + +```text +ComfyUI/custom_nodes/ComfyUI-RefineNode +``` + +Restart ComfyUI. + +`requirements.txt` intentionally has no extra runtime dependency. The nodes use ComfyUI, PyTorch, PIL, and NumPy from the existing ComfyUI environment. + +## Nodes + +| Node | Output | Purpose | +|------|--------|---------| +| `RefineNode Preprocess Mask` | `image`, `spatial_mask_image`, `mask`, `info` | Reads an image plus optional mask, optionally focus-crops the mask region, and stores paste-back metadata in `REFINENODE_INFO`. | +| `RefineNode Reference Image Process` | `image1`, `image2`, `image3`, `info` | Resizes one to three images to the same target size with shared resize/crop/fill settings, and updates `REFINENODE_INFO` so paste-back can reverse that transform. | +| `RefineNode Paste Back` | `image`, `paste_mask` | Composites a generated image back into the original image using the original mask or bbox. | + +## Generic Wiring + +```text +source image + optional mask + -> RefineNode Preprocess Mask + +RefineNode Preprocess Mask.image + -> your model, VAEEncode, image encoder, inpaint image input, or reference pipeline + +RefineNode Preprocess Mask.mask + -> your model's mask input, if it has one + +RefineNode Preprocess Mask.spatial_mask_image + -> optional visual/spatial condition image + +model generated image + RefineNode Preprocess Mask.info + -> RefineNode Paste Back +``` + +Use `RefineNode Reference Image Process` when multiple images must share the same pixels before generation: + +```text +processed target image + -> RefineNode Reference Image Process.image1 + +optional reference image + -> RefineNode Reference Image Process.image2 + +optional spatial mask/control image + -> RefineNode Reference Image Process.image3 + +matching Preprocess Mask.info + -> RefineNode Reference Image Process.info + +generated image + RefineNode Reference Image Process.info + -> RefineNode Paste Back +``` + +The `info` input can come from whichever image you intend to paste back into. The node automatically matches `info.items[*].model_image` against image1/image2/image3 by content signature first, then by size, and records the matched slot's resize/crop/fill transform for paste-back. + +## Focus Crop + +`RefineNode Preprocess Mask` can crop around the painted mask before generation: + +- `focus_crop=true` crops the image and mask around the mask bbox; +- `focus_crop=false` keeps the full image; +- empty or disconnected masks disable focus crop automatically; +- `focus_crop_margin` expands the bbox in a normalized 1024-area model scale, so the visible model-space context stays similar across different source resolutions. + +Use smaller margins for text/logo/detail repair, and larger margins when the model needs more surrounding context. Use `focus_crop_margin=0` when you want a tight mask bbox crop for a reference image. + +## Reference Image Process + +`RefineNode Reference Image Process` is a standalone image size alignment node: + +- `fit_kontext_size=true` makes image1 choose the nearest Flux/Kontext-style preferred size; +- `fit_kontext_size=false` uses a `1024 * 1024` target area and rounds width/height to multiples of `8`; +- image1, optional image2, and optional image3 are all resized to the same target size; +- `resize_method` is shared by all connected images; +- `crop_mode=crop` uses center crop behavior; +- `crop_mode=disable` resizes directly without center cropping; +- `crop_mode=fill` preserves aspect ratio, scales to fit inside the target, then pads with black. + +When `info` is connected, the output `info` stores the transform needed by `RefineNode Paste Back` to reverse this image-size processing before compositing. + +## Example Workflows + +`example_workflows/Reference-based Logo Refinement.json` + +- uses a target image mask and a separate clean reference image; +- uses one `RefineNode Preprocess Mask` for the reference crop and another for the target mask; +- uses `RefineNode Reference Image Process` to align target, reference, and spatial mask; +- uses ComfyUI's native `TextEncodeQwenImageEditPlus` for Qwen conditioning. + +`example_workflows/Reference-free Text Refinement.json` + +- uses only a target image and painted text mask; +- sends target image plus spatial mask to the native Qwen text encoder; +- uses `RefineNode Paste Back` to composite the generated text repair into the original image. + +The examples use Qwen Image Edit Plus because it is convenient for reference-guided detail repair, but the RefineNode preprocessing and paste-back nodes are not Qwen-specific. + +## Model Encoding + +RefineNode no longer includes a Qwen text encoder or model compatibility node. For Qwen Image Edit Plus workflows, use ComfyUI's built-in `TextEncodeQwenImageEditPlus`, shown in the UI as `文本编码(QwenImageEditPlus)`. + +Model-specific nodes remain responsible for prompt encoding, image conditioning, reference latents, sampling, and model patches. RefineNode stays focused on model-independent image/mask preprocessing and paste-back. + +## Notes + +- No color transfer, color alignment, AdaIN, wavelet, or other color correction is applied. +- No model wrapper, text encoder wrapper, or attention/prompt patch is applied. +- If color shift or pixel offset appears, compare your model's generated size with `RefineNode Reference Image Process` output and make sure `RefineNode Paste Back` receives the matching processed `info`. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..39a8c6b --- /dev/null +++ b/__init__.py @@ -0,0 +1,3 @@ +from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/example_workflows/Reference-based Logo Refinement.json b/example_workflows/Reference-based Logo Refinement.json new file mode 100644 index 0000000..c646e6b --- /dev/null +++ b/example_workflows/Reference-based Logo Refinement.json @@ -0,0 +1,1432 @@ +{ + "id": "refinenode-native-product-reference-repair", + "revision": 1, + "last_node_id": 345, + "last_link_id": 836, + "nodes": [ + { + "id": 171, + "type": "ModelSamplingAuraFlow", + "pos": [ + -730, + -700 + ], + "size": [ + 270, + 80 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 378 + } + ], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 387 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.5.1", + "Node name for S&R": "ModelSamplingAuraFlow", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 3.1 + ] + }, + { + "id": 178, + "type": "CFGNorm", + "pos": [ + -370, + -700 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 387 + } + ], + "outputs": [ + { + "name": "patched_model", + "type": "MODEL", + "links": [ + 388 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.5.1", + "Node name for S&R": "CFGNorm", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 1 + ] + }, + { + "id": 172, + "type": "VAELoader", + "pos": [ + -1280, + -270 + ], + "size": [ + 400, + 90 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "VAE", + "type": "VAE", + "slot_index": 0, + "links": [ + 390, + 407, + 814 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.5.1", + "Node name for S&R": "VAELoader", + "models": [ + { + "name": "qwen_image_vae.safetensors", + "url": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/vae/qwen_image_vae.safetensors", + "directory": "vae" + } + ], + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "qwen_image_vae.safetensors" + ] + }, + { + "id": 183, + "type": "UNETLoader", + "pos": [ + -1290, + -700 + ], + "size": [ + 400, + 120 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "slot_index": 0, + "links": [ + 378 + ] + } + ], + "title": "Load Qwen Image Edit 2511 bf16 model", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.5.1", + "Node name for S&R": "UNETLoader", + "models": [ + { + "name": "qwen_image_edit_2511_bf16.safetensors", + "url": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_edit_2511_bf16.safetensors", + "directory": "diffusion_models" + } + ], + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "qwen_image_edit_2511_fp8mixed.safetensors", + "default" + ] + }, + { + "id": 192, + "type": "VAEDecode", + "pos": [ + 610, + -200 + ], + "size": [ + 230, + 80 + ], + "flags": { + "collapsed": false + }, + "order": 19, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 406 + }, + { + "name": "vae", + "type": "VAE", + "link": 407 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "slot_index": 0, + "links": [ + 454, + 507 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.5.1", + "Node name for S&R": "VAEDecode", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [] + }, + { + "id": 182, + "type": "VAEEncode", + "pos": [ + -170, + -60 + ], + "size": [ + 230, + 80 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "name": "pixels", + "type": "IMAGE", + "link": 802 + }, + { + "name": "vae", + "type": "VAE", + "link": 390 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 403 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.5.1", + "Node name for S&R": "VAEEncode", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [] + }, + { + "id": 196, + "type": "ConditioningZeroOut", + "pos": [ + -190, + -280 + ], + "size": [ + 230, + 50 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [ + { + "name": "conditioning", + "type": "CONDITIONING", + "link": 490 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 423 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.20.1", + "Node name for S&R": "ConditioningZeroOut" + }, + "widgets_values": [] + }, + { + "id": 184, + "type": "CLIPLoader", + "pos": [ + -1290, + -490 + ], + "size": [ + 400, + 160 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "CLIP", + "type": "CLIP", + "links": [ + 813 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.5.1", + "Node name for S&R": "CLIPLoader", + "models": [ + { + "name": "qwen_2.5_vl_7b_fp8_scaled.safetensors", + "url": "https://huggingface.co/Comfy-Org/HunyuanVideo_1.5_repackaged/resolve/main/split_files/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors", + "directory": "text_encoders" + } + ], + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "qwen_2.5_vl_7b_fp8_scaled.safetensors", + "qwen_image", + "default" + ] + }, + { + "id": 219, + "type": "LoraLoaderModelOnly", + "pos": [ + 400, + -700 + ], + "size": [ + 290, + 120 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 836 + } + ], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 474 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.5.1", + "Node name for S&R": "LoraLoaderModelOnly", + "models": [ + { + "name": "Qwen-Image-Edit-2511-RefineAny.safetensors", + "directory": "loras" + } + ], + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "Qwen-Image-Edit-2511-RefineAny.safetensors", + 1 + ] + }, + { + "id": 191, + "type": "KSampler", + "pos": [ + 240, + -410 + ], + "size": [ + 270, + 570 + ], + "flags": {}, + "order": 18, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 474 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 703 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 423 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 403 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 406 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.16.4", + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 0, + "fixed", + 4, + 1, + "euler", + "simple", + 1 + ] + }, + { + "id": 201, + "type": "SaveImage", + "pos": [ + 990, + -20 + ], + "size": [ + 1240, + 1830 + ], + "flags": {}, + "order": 22, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 453 + } + ], + "outputs": [], + "title": "Save final repaired target product", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.20.1", + "Node name for S&R": "SaveImage" + }, + "widgets_values": [ + "RefineNode_Product_Repair" + ] + }, + { + "id": 206, + "type": "RefineNodePasteBack", + "pos": [ + 590, + -20 + ], + "size": [ + 300, + 200 + ], + "flags": {}, + "order": 20, + "mode": 0, + "inputs": [ + { + "name": "generated_image", + "type": "IMAGE", + "link": 454 + }, + { + "name": "info", + "type": "REFINENODE_INFO", + "link": 809 + } + ], + "outputs": [ + { + "name": "image", + "type": "IMAGE", + "links": [ + 453 + ] + }, + { + "name": "paste_mask", + "type": "MASK", + "links": [] + } + ], + "title": "Paste repaired product back", + "properties": { + "Node name for S&R": "RefineNodePasteBack" + }, + "widgets_values": [ + "mask", + 3, + 5 + ] + }, + { + "id": 217, + "type": "PreviewImage", + "pos": [ + -830, + 90 + ], + "size": [ + 490, + 460 + ], + "flags": { + "collapsed": false + }, + "order": 11, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 801 + } + ], + "outputs": [], + "title": "Preview target model image for Qwen", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.20.1", + "Node name for S&R": "PreviewImage" + }, + "widgets_values": [] + }, + { + "id": 179, + "type": "LoraLoaderModelOnly", + "pos": [ + -30, + -700 + ], + "size": [ + 370, + 120 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 388 + } + ], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 836 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.5.1", + "Node name for S&R": "LoraLoaderModelOnly", + "models": [ + { + "name": "Qwen-Image-Edit-2511-Lightning-4steps-V1.0-bf16.safetensors", + "url": "https://huggingface.co/lightx2v/Qwen-Image-Edit-2511-Lightning/resolve/main/Qwen-Image-Edit-2511-Lightning-4steps-V1.0-bf16.safetensors", + "directory": "loras" + } + ], + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "Qwen-Image-Edit-2511-Lightning-4steps-V1.0-bf16.safetensors", + 1 + ] + }, + { + "id": 226, + "type": "ImageCompare", + "pos": [ + -890, + 610 + ], + "size": [ + 1100, + 1070 + ], + "flags": {}, + "order": 21, + "mode": 0, + "inputs": [ + { + "name": "image_a", + "shape": 7, + "type": "IMAGE", + "link": 507 + }, + { + "name": "image_b", + "shape": 7, + "type": "IMAGE", + "link": 803 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.20.1", + "Node name for S&R": "ImageCompare" + }, + "widgets_values": [] + }, + { + "id": 229, + "type": "FluxKontextMultiReferenceLatentMethod", + "pos": [ + -220, + -490 + ], + "size": [ + 320, + 110 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "name": "conditioning", + "type": "CONDITIONING", + "link": 818 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 703 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.20.1", + "Node name for S&R": "FluxKontextMultiReferenceLatentMethod" + }, + "widgets_values": [ + "index" + ] + }, + { + "id": 230, + "type": "LoadImage", + "pos": [ + -2370, + -40 + ], + "size": [ + 510, + 620 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 788 + ] + }, + { + "name": "MASK", + "type": "MASK", + "links": [ + 789 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.5.1", + "Node name for S&R": "LoadImage", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "image": "clipspace/clipspace-painted-masked-1777588978061.png [input]" + }, + "widgets_values": [ + "clipspace/clipspace-painted-masked-1777588978061.png [input]", + "image" + ] + }, + { + "id": 330, + "type": "RefineNodePreprocessMask", + "pos": [ + -1780, + -40 + ], + "size": [ + 320, + 240 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 788 + }, + { + "name": "mask", + "shape": 7, + "type": "MASK", + "link": 789 + } + ], + "outputs": [ + { + "name": "image", + "type": "IMAGE", + "links": [ + 819 + ] + }, + { + "name": "spatial_mask_image", + "type": "IMAGE", + "links": null + }, + { + "name": "mask", + "type": "MASK", + "links": null + }, + { + "name": "info", + "type": "REFINENODE_INFO", + "links": null + } + ], + "properties": { + "Node name for S&R": "RefineNodePreprocessMask" + }, + "widgets_values": [ + true, + 32, + "mask" + ] + }, + { + "id": 331, + "type": "RefineNodePreprocessMask", + "pos": [ + -1780, + 700 + ], + "size": [ + 320, + 240 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 826 + }, + { + "name": "mask", + "shape": 7, + "type": "MASK", + "link": 827 + } + ], + "outputs": [ + { + "name": "image", + "type": "IMAGE", + "links": [ + 797 + ] + }, + { + "name": "spatial_mask_image", + "type": "IMAGE", + "links": [ + 820 + ] + }, + { + "name": "mask", + "type": "MASK", + "links": null + }, + { + "name": "info", + "type": "REFINENODE_INFO", + "links": [ + 800 + ] + } + ], + "properties": { + "Node name for S&R": "RefineNodePreprocessMask" + }, + "widgets_values": [ + true, + 32, + "mask" + ] + }, + { + "id": 332, + "type": "RefineNodeReferenceImageProcess", + "pos": [ + -1300, + -60 + ], + "size": [ + 340, + 260 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "name": "image1", + "type": "IMAGE", + "link": 797 + }, + { + "name": "image2", + "shape": 7, + "type": "IMAGE", + "link": 819 + }, + { + "name": "image3", + "shape": 7, + "type": "IMAGE", + "link": 820 + }, + { + "name": "info", + "shape": 7, + "type": "REFINENODE_INFO", + "link": 800 + } + ], + "outputs": [ + { + "name": "image1", + "type": "IMAGE", + "links": [ + 801, + 802, + 803, + 822 + ] + }, + { + "name": "image2", + "type": "IMAGE", + "links": [ + 823, + 834 + ] + }, + { + "name": "image3", + "type": "IMAGE", + "links": [ + 824 + ] + }, + { + "name": "info", + "type": "REFINENODE_INFO", + "links": [ + 809 + ] + } + ], + "properties": { + "Node name for S&R": "RefineNodeReferenceImageProcess" + }, + "widgets_values": [ + false, + "lanczos", + "crop" + ] + }, + { + "id": 333, + "type": "TextEncodeQwenImageEditPlus", + "pos": [ + -680, + -490 + ], + "size": [ + 410, + 370 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 813 + }, + { + "name": "vae", + "shape": 7, + "type": "VAE", + "link": 814 + }, + { + "name": "image1", + "shape": 7, + "type": "IMAGE", + "link": 822 + }, + { + "name": "image2", + "shape": 7, + "type": "IMAGE", + "link": 823 + }, + { + "name": "image3", + "shape": 7, + "type": "IMAGE", + "link": 824 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 818, + 490 + ] + } + ], + "properties": { + "Node name for S&R": "TextEncodeQwenImageEditPlus" + }, + "widgets_values": [ + "refine the masked product using image2 as reference. Restore the logo, text, edges, material texture, color details, and exact product appearance while preserving the target photo lighting, pose, perspective, and background." + ] + }, + { + "id": 334, + "type": "LoadImage", + "pos": [ + -2340, + 700 + ], + "size": [ + 520, + 640 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 826 + ] + }, + { + "name": "MASK", + "type": "MASK", + "links": [ + 827 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.5.1", + "Node name for S&R": "LoadImage", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "image": "clipspace/clipspace-painted-masked-1777588922276.png [input]" + }, + "widgets_values": [ + "clipspace/clipspace-painted-masked-1777588922276.png [input]", + "image" + ] + }, + { + "id": 345, + "type": "PreviewImage", + "pos": [ + -320, + 90 + ], + "size": [ + 450, + 460 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 834 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.20.1", + "Node name for S&R": "PreviewImage" + }, + "widgets_values": [] + } + ], + "links": [ + [ + 378, + 183, + 0, + 171, + 0, + "MODEL" + ], + [ + 387, + 171, + 0, + 178, + 0, + "MODEL" + ], + [ + 388, + 178, + 0, + 179, + 0, + "MODEL" + ], + [ + 390, + 172, + 0, + 182, + 1, + "VAE" + ], + [ + 403, + 182, + 0, + 191, + 3, + "LATENT" + ], + [ + 406, + 191, + 0, + 192, + 0, + "LATENT" + ], + [ + 407, + 172, + 0, + 192, + 1, + "VAE" + ], + [ + 423, + 196, + 0, + 191, + 2, + "CONDITIONING" + ], + [ + 453, + 206, + 0, + 201, + 0, + "IMAGE" + ], + [ + 454, + 192, + 0, + 206, + 0, + "IMAGE" + ], + [ + 474, + 219, + 0, + 191, + 0, + "MODEL" + ], + [ + 490, + 333, + 0, + 196, + 0, + "CONDITIONING" + ], + [ + 507, + 192, + 0, + 226, + 0, + "IMAGE" + ], + [ + 703, + 229, + 0, + 191, + 1, + "CONDITIONING" + ], + [ + 788, + 230, + 0, + 330, + 0, + "IMAGE" + ], + [ + 789, + 230, + 1, + 330, + 1, + "MASK" + ], + [ + 797, + 331, + 0, + 332, + 0, + "IMAGE" + ], + [ + 800, + 331, + 3, + 332, + 3, + "REFINENODE_INFO" + ], + [ + 801, + 332, + 0, + 217, + 0, + "IMAGE" + ], + [ + 802, + 332, + 0, + 182, + 0, + "IMAGE" + ], + [ + 803, + 332, + 0, + 226, + 1, + "IMAGE" + ], + [ + 809, + 332, + 3, + 206, + 1, + "REFINENODE_INFO" + ], + [ + 813, + 184, + 0, + 333, + 0, + "CLIP" + ], + [ + 814, + 172, + 0, + 333, + 1, + "VAE" + ], + [ + 818, + 333, + 0, + 229, + 0, + "CONDITIONING" + ], + [ + 819, + 330, + 0, + 332, + 1, + "IMAGE" + ], + [ + 820, + 331, + 1, + 332, + 2, + "IMAGE" + ], + [ + 822, + 332, + 0, + 333, + 2, + "IMAGE" + ], + [ + 823, + 332, + 1, + 333, + 3, + "IMAGE" + ], + [ + 824, + 332, + 2, + 333, + 4, + "IMAGE" + ], + [ + 826, + 334, + 0, + 331, + 0, + "IMAGE" + ], + [ + 827, + 334, + 1, + 331, + 1, + "MASK" + ], + [ + 834, + 332, + 1, + 345, + 0, + "IMAGE" + ], + [ + 836, + 179, + 0, + 219, + 0, + "MODEL" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 0.45041209887262396, + "offset": [ + 2880.6754318812186, + 868.2047249255137 + ] + }, + "frontendVersion": "1.44.14", + "workflowRendererVersion": "LG", + "groupNodes": {}, + "VHS_latentpreview": false, + "VHS_latentpreviewrate": 0, + "VHS_MetadataImage": true, + "VHS_KeepIntermediate": true + }, + "version": 0.4 +} diff --git a/example_workflows/Reference-free Text Refinement.json b/example_workflows/Reference-free Text Refinement.json new file mode 100644 index 0000000..b2bbdf2 --- /dev/null +++ b/example_workflows/Reference-free Text Refinement.json @@ -0,0 +1,1207 @@ +{ + "id": "e8b4cea5-5f0d-4df2-9d05-42599abb05a8", + "revision": 0, + "last_node_id": 38, + "last_link_id": 76, + "nodes": [ + { + "id": 1, + "type": "ModelSamplingAuraFlow", + "pos": [ + 1490, + 30 + ], + "size": [ + 270, + 80 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 1 + } + ], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 2 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.5.1", + "Node name for S&R": "ModelSamplingAuraFlow", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 3.1 + ] + }, + { + "id": 2, + "type": "CFGNorm", + "pos": [ + 1850, + 30 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 2 + } + ], + "outputs": [ + { + "name": "patched_model", + "type": "MODEL", + "links": [ + 17 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.5.1", + "Node name for S&R": "CFGNorm", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 1 + ] + }, + { + "id": 3, + "type": "VAELoader", + "pos": [ + 940, + 460 + ], + "size": [ + 400, + 90 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "VAE", + "type": "VAE", + "slot_index": 0, + "links": [ + 4, + 6, + 33 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.5.1", + "Node name for S&R": "VAELoader", + "models": [ + { + "name": "qwen_image_vae.safetensors", + "url": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/vae/qwen_image_vae.safetensors", + "directory": "vae" + } + ], + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "qwen_image_vae.safetensors" + ] + }, + { + "id": 4, + "type": "UNETLoader", + "pos": [ + 930, + 30 + ], + "size": [ + 400, + 120 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "slot_index": 0, + "links": [ + 1 + ] + } + ], + "title": "Load Qwen Image Edit 2511 bf16 model", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.5.1", + "Node name for S&R": "UNETLoader", + "models": [ + { + "name": "qwen_image_edit_2511_bf16.safetensors", + "url": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_edit_2511_bf16.safetensors", + "directory": "diffusion_models" + } + ], + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "qwen_image_edit_2511_fp8mixed.safetensors", + "default" + ] + }, + { + "id": 5, + "type": "VAEDecode", + "pos": [ + 2830, + 530 + ], + "size": [ + 230, + 80 + ], + "flags": { + "collapsed": false + }, + "order": 15, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 3 + }, + { + "name": "vae", + "type": "VAE", + "link": 4 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "slot_index": 0, + "links": [ + 14, + 23 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.5.1", + "Node name for S&R": "VAEDecode", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [] + }, + { + "id": 6, + "type": "VAEEncode", + "pos": [ + 1990, + 590 + ], + "size": [ + 230, + 80 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "name": "pixels", + "type": "IMAGE", + "link": 72 + }, + { + "name": "vae", + "type": "VAE", + "link": 6 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 12 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.5.1", + "Node name for S&R": "VAEEncode", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [] + }, + { + "id": 7, + "type": "ConditioningZeroOut", + "pos": [ + 1990, + 430 + ], + "size": [ + 230, + 50 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "name": "conditioning", + "type": "CONDITIONING", + "link": 7 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 11 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.20.1", + "Node name for S&R": "ConditioningZeroOut" + }, + "widgets_values": [] + }, + { + "id": 8, + "type": "CLIPLoader", + "pos": [ + 930, + 240 + ], + "size": [ + 400, + 160 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "CLIP", + "type": "CLIP", + "links": [ + 32 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.5.1", + "Node name for S&R": "CLIPLoader", + "models": [ + { + "name": "qwen_2.5_vl_7b_fp8_scaled.safetensors", + "url": "https://huggingface.co/Comfy-Org/HunyuanVideo_1.5_repackaged/resolve/main/split_files/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors", + "directory": "text_encoders" + } + ], + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "qwen_2.5_vl_7b_fp8_scaled.safetensors", + "qwen_image", + "default" + ] + }, + { + "id": 9, + "type": "LoraLoaderModelOnly", + "pos": [ + 2620, + 30 + ], + "size": [ + 290, + 120 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 8 + } + ], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 9 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.5.1", + "Node name for S&R": "LoraLoaderModelOnly", + "models": [ + { + "name": "Qwen-Image-Edit-2511-RefineAny.safetensors", + "directory": "loras" + } + ], + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "Qwen-Image-Edit-2511-RefineAny.safetensors", + 1 + ] + }, + { + "id": 10, + "type": "KSampler", + "pos": [ + 2490, + 320 + ], + "size": [ + 270, + 570 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 9 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 10 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 11 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 12 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 3 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.16.4", + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 0, + "fixed", + 4, + 1, + "euler", + "simple", + 1 + ] + }, + { + "id": 12, + "type": "RefineNodePasteBack", + "pos": [ + 2810, + 710 + ], + "size": [ + 300, + 200 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "name": "generated_image", + "type": "IMAGE", + "link": 14 + }, + { + "name": "info", + "type": "REFINENODE_INFO", + "link": 76 + } + ], + "outputs": [ + { + "name": "image", + "type": "IMAGE", + "links": [ + 40 + ] + }, + { + "name": "paste_mask", + "type": "MASK", + "links": [] + } + ], + "title": "Paste repaired product back", + "properties": { + "Node name for S&R": "RefineNodePasteBack" + }, + "widgets_values": [ + "mask", + 3, + 5 + ] + }, + { + "id": 14, + "type": "LoraLoaderModelOnly", + "pos": [ + 2180, + 30 + ], + "size": [ + 370, + 120 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 17 + } + ], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 8 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.5.1", + "Node name for S&R": "LoraLoaderModelOnly", + "models": [ + { + "name": "Qwen-Image-Edit-2511-Lightning-4steps-V1.0-bf16.safetensors", + "url": "https://huggingface.co/lightx2v/Qwen-Image-Edit-2511-Lightning/resolve/main/Qwen-Image-Edit-2511-Lightning-4steps-V1.0-bf16.safetensors", + "directory": "loras" + } + ], + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "Qwen-Image-Edit-2511-Lightning-4steps-V1.0-bf16.safetensors", + 1 + ] + }, + { + "id": 18, + "type": "ImageCompare", + "pos": [ + 1210, + 820 + ], + "size": [ + 1250, + 970 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [ + { + "name": "image_a", + "shape": 7, + "type": "IMAGE", + "link": 23 + }, + { + "name": "image_b", + "shape": 7, + "type": "IMAGE", + "link": 73 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.20.1", + "Node name for S&R": "ImageCompare" + }, + "widgets_values": [] + }, + { + "id": 19, + "type": "FluxKontextMultiReferenceLatentMethod", + "pos": [ + 1960, + 240 + ], + "size": [ + 320, + 110 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "name": "conditioning", + "type": "CONDITIONING", + "link": 25 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 10 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.20.1", + "Node name for S&R": "FluxKontextMultiReferenceLatentMethod" + }, + "widgets_values": [ + "index" + ] + }, + { + "id": 21, + "type": "LoadImage", + "pos": [ + -360, + 730 + ], + "size": [ + 690, + 1140 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 64 + ] + }, + { + "name": "MASK", + "type": "MASK", + "links": [ + 65 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.5.1", + "Node name for S&R": "LoadImage", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "image": "clipspace/clipspace-painted-masked-1777579693456.png [input]" + }, + "widgets_values": [ + "clipspace/clipspace-painted-masked-1777579693456.png [input]", + "image" + ] + }, + { + "id": 25, + "type": "TextEncodeQwenImageEditPlus", + "pos": [ + 1540, + 240 + ], + "size": [ + 310, + 350 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 32 + }, + { + "name": "vae", + "shape": 7, + "type": "VAE", + "link": 33 + }, + { + "name": "image1", + "shape": 7, + "type": "IMAGE", + "link": 74 + }, + { + "name": "image2", + "shape": 7, + "type": "IMAGE", + "link": 75 + }, + { + "name": "image3", + "shape": 7, + "type": "IMAGE", + "link": null + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 25, + 7 + ] + } + ], + "properties": { + "Node name for S&R": "TextEncodeQwenImageEditPlus" + }, + "widgets_values": [ + "refine the text \"ABCD\"" + ] + }, + { + "id": 26, + "type": "SaveImage", + "pos": [ + 3210, + 710 + ], + "size": [ + 800, + 1370 + ], + "flags": {}, + "order": 18, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 40 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.20.1", + "Node name for S&R": "SaveImage" + }, + "widgets_values": [ + "ComfyUI" + ] + }, + { + "id": 37, + "type": "RefineNodePreprocessMask", + "pos": [ + 390, + 730 + ], + "size": [ + 320, + 240 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 64 + }, + { + "name": "mask", + "shape": 7, + "type": "MASK", + "link": 65 + } + ], + "outputs": [ + { + "name": "image", + "type": "IMAGE", + "links": [ + 69 + ] + }, + { + "name": "spatial_mask_image", + "type": "IMAGE", + "links": [ + 70 + ] + }, + { + "name": "mask", + "type": "MASK", + "links": null + }, + { + "name": "info", + "type": "REFINENODE_INFO", + "links": [ + 71 + ] + } + ], + "properties": { + "Node name for S&R": "RefineNodePreprocessMask" + }, + "widgets_values": [ + true, + 64, + "mask" + ] + }, + { + "id": 38, + "type": "RefineNodeReferenceImageProcess", + "pos": [ + 800, + 730 + ], + "size": [ + 350, + 250 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "image1", + "type": "IMAGE", + "link": 69 + }, + { + "name": "image2", + "shape": 7, + "type": "IMAGE", + "link": 70 + }, + { + "name": "image3", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "name": "info", + "shape": 7, + "type": "REFINENODE_INFO", + "link": 71 + } + ], + "outputs": [ + { + "name": "image1", + "type": "IMAGE", + "links": [ + 72, + 73, + 74 + ] + }, + { + "name": "image2", + "type": "IMAGE", + "links": [ + 75 + ] + }, + { + "name": "image3", + "type": "IMAGE", + "links": null + }, + { + "name": "info", + "type": "REFINENODE_INFO", + "links": [ + 76 + ] + } + ], + "properties": { + "Node name for S&R": "RefineNodeReferenceImageProcess" + }, + "widgets_values": [ + false, + "lanczos", + "crop" + ] + } + ], + "links": [ + [ + 1, + 4, + 0, + 1, + 0, + "MODEL" + ], + [ + 2, + 1, + 0, + 2, + 0, + "MODEL" + ], + [ + 3, + 10, + 0, + 5, + 0, + "LATENT" + ], + [ + 4, + 3, + 0, + 5, + 1, + "VAE" + ], + [ + 6, + 3, + 0, + 6, + 1, + "VAE" + ], + [ + 7, + 25, + 0, + 7, + 0, + "CONDITIONING" + ], + [ + 8, + 14, + 0, + 9, + 0, + "MODEL" + ], + [ + 9, + 9, + 0, + 10, + 0, + "MODEL" + ], + [ + 10, + 19, + 0, + 10, + 1, + "CONDITIONING" + ], + [ + 11, + 7, + 0, + 10, + 2, + "CONDITIONING" + ], + [ + 12, + 6, + 0, + 10, + 3, + "LATENT" + ], + [ + 14, + 5, + 0, + 12, + 0, + "IMAGE" + ], + [ + 17, + 2, + 0, + 14, + 0, + "MODEL" + ], + [ + 23, + 5, + 0, + 18, + 0, + "IMAGE" + ], + [ + 25, + 25, + 0, + 19, + 0, + "CONDITIONING" + ], + [ + 32, + 8, + 0, + 25, + 0, + "CLIP" + ], + [ + 33, + 3, + 0, + 25, + 1, + "VAE" + ], + [ + 40, + 12, + 0, + 26, + 0, + "IMAGE" + ], + [ + 64, + 21, + 0, + 37, + 0, + "IMAGE" + ], + [ + 65, + 21, + 1, + 37, + 1, + "MASK" + ], + [ + 69, + 37, + 0, + 38, + 0, + "IMAGE" + ], + [ + 70, + 37, + 1, + 38, + 1, + "IMAGE" + ], + [ + 71, + 37, + 3, + 38, + 3, + "REFINENODE_INFO" + ], + [ + 72, + 38, + 0, + 6, + 0, + "IMAGE" + ], + [ + 73, + 38, + 0, + 18, + 1, + "IMAGE" + ], + [ + 74, + 38, + 0, + 25, + 2, + "IMAGE" + ], + [ + 75, + 38, + 1, + 25, + 3, + "IMAGE" + ], + [ + 76, + 38, + 3, + 12, + 1, + "REFINENODE_INFO" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 0.5644739300537777, + "offset": [ + 680.9651611540171, + 113.64139043113664 + ] + }, + "frontendVersion": "1.44.14", + "VHS_latentpreview": false, + "VHS_latentpreviewrate": 0, + "VHS_MetadataImage": true, + "VHS_KeepIntermediate": true + }, + "version": 0.4 +} diff --git a/nodes.py b/nodes.py new file mode 100644 index 0000000..f2b55e0 --- /dev/null +++ b/nodes.py @@ -0,0 +1,787 @@ +from __future__ import annotations + +import io +import hashlib +import math +from typing import Any + +import numpy as np +import torch +from PIL import Image, ImageCms, ImageFilter + + +VAE_IMAGE_SIZE = 1024 * 1024 +DEFAULT_RESIZE_METHOD = "lanczos" +IMAGE_RESIZE_METHODS = ["lanczos", "area", "bicubic", "bilinear", "nearest-exact"] +PREFERRED_KONTEXT_RESOLUTIONS = [ + (672, 1568), + (688, 1504), + (720, 1456), + (752, 1392), + (800, 1328), + (832, 1248), + (880, 1184), + (944, 1104), + (1024, 1024), + (1104, 944), + (1184, 880), + (1248, 832), + (1328, 800), + (1392, 752), + (1456, 720), + (1504, 688), + (1568, 672), +] + +_BICUBIC = getattr(getattr(Image, "Resampling", Image), "BICUBIC") +_BILINEAR = getattr(getattr(Image, "Resampling", Image), "BILINEAR") +_NEAREST = getattr(getattr(Image, "Resampling", Image), "NEAREST") + + +def calculate_dimensions_rounded(target_area: int, ratio: float, multiple: int) -> tuple[int, int]: + width = math.sqrt(float(target_area) * float(ratio)) + height = width / float(ratio) + width = max(multiple, round(width / multiple) * multiple) + height = max(multiple, round(height / multiple) * multiple) + return int(width), int(height) + + +def calculate_dimensions(target_area: int, ratio: float) -> tuple[int, int]: + return calculate_dimensions_rounded(target_area, ratio, 32) + + +def calculate_dimensions_area(target_area: int, width: int, height: int, multiple: int | None = None) -> tuple[int, int]: + scale_by = math.sqrt(float(target_area) / float(width * height)) + target_width = width * scale_by + target_height = height * scale_by + if multiple is not None: + target_width = round(target_width / multiple) * multiple + target_height = round(target_height / multiple) * multiple + return max(multiple, int(target_width)), max(multiple, int(target_height)) + return max(1, round(target_width)), max(1, round(target_height)) + + +def flux_kontext_target_size(width: int, height: int) -> tuple[int, int]: + aspect_ratio = width / height + _, target_width, target_height = min( + (abs(aspect_ratio - w / h), w, h) + for w, h in PREFERRED_KONTEXT_RESOLUTIONS + ) + return target_width, target_height + + +def normalize_to_srgb(img: Image.Image | None) -> Image.Image | None: + if img is None: + return None + icc = img.info.get("icc_profile") if hasattr(img, "info") else None + if icc: + try: + src_profile = ImageCms.ImageCmsProfile(io.BytesIO(icc)) + dst_profile = ImageCms.createProfile("sRGB") + return ImageCms.profileToProfile( + img, + src_profile, + dst_profile, + outputMode="RGB", + ) + except Exception: + return img.convert("RGB") + return img.convert("RGB") + + +def bbox_from_mask(mask_l: Image.Image) -> tuple[int, int, int, int]: + arr = np.array(mask_l.convert("L"), dtype=np.uint8) + ys, xs = np.where(arr > 0) + if xs.size == 0 or ys.size == 0: + raise ValueError("Mask is empty; paint or connect a non-empty mask.") + w, h = mask_l.size + return ( + max(0, int(xs.min())), + max(0, int(ys.min())), + min(w, int(xs.max()) + 1), + min(h, int(ys.max()) + 1), + ) + + +def bbox_from_mask_or_none(mask_l: Image.Image | None) -> tuple[int, int, int, int] | None: + if mask_l is None: + return None + try: + return bbox_from_mask(mask_l) + except ValueError: + return None + + +def focus_crop( + image: Image.Image, + mask_l: Image.Image, + bbox: tuple[int, int, int, int], + margin: int, +) -> tuple[Image.Image, Image.Image, tuple[int, int, int, int]]: + iw, ih = image.size + if iw <= 0 or ih <= 0: + return image, mask_l, (0, 0, iw, ih) + + scale = math.sqrt(1024 * 1024 / float(iw * ih)) + x1, y1, x2, y2 = bbox + m = max(0, int(margin)) + + cx1 = max(0, int(math.floor(max(0.0, x1 * scale - m) / scale))) + cy1 = max(0, int(math.floor(max(0.0, y1 * scale - m) / scale))) + cx2 = min(iw, int(math.ceil(min(iw * scale, x2 * scale + m) / scale))) + cy2 = min(ih, int(math.ceil(min(ih * scale, y2 * scale + m) / scale))) + + if cx2 <= cx1: + cx2 = min(iw, cx1 + 1) + if cy2 <= cy1: + cy2 = min(ih, cy1 + 1) + + crop_box = (cx1, cy1, cx2, cy2) + return image.crop(crop_box), mask_l.crop(crop_box), crop_box + + +def offset_bbox( + bbox: tuple[int, int, int, int], + dx: int, + dy: int, +) -> tuple[int, int, int, int]: + x1, y1, x2, y2 = bbox + return (x1 - dx, y1 - dy, x2 - dx, y2 - dy) + + +def binary_mask(mask_l: Image.Image) -> Image.Image: + arr = np.where(np.array(mask_l.convert("L"), dtype=np.uint8) > 0, 255, 0).astype(np.uint8) + return Image.fromarray(arr, mode="L") + + +def bbox_mask( + size: tuple[int, int], + bbox: tuple[int, int, int, int], +) -> Image.Image: + from PIL import ImageDraw + + w, h = size + x1, y1, x2, y2 = bbox + x1 = max(0, min(w - 1, int(x1))) + y1 = max(0, min(h - 1, int(y1))) + x2 = max(1, min(w, int(x2))) + y2 = max(1, min(h, int(y2))) + mask = Image.new("L", (w, h), 0) + draw = ImageDraw.Draw(mask) + draw.rectangle((x1, y1, max(x1, x2 - 1), max(y1, y2 - 1)), fill=255) + return mask + + +def make_spatial_mask( + mask_l: Image.Image, + source: str, + bbox: tuple[int, int, int, int] | None, +) -> Image.Image: + if (source or "mask").strip().lower() == "bbox" and bbox is not None: + return bbox_mask(mask_l.size, bbox) + return binary_mask(mask_l) + + +def prepare_paste_mask( + mask_l: Image.Image, + mask_grow: int, + blend_blur: int, +) -> Image.Image: + mask = mask_l.convert("L") + if mask_grow > 0: + mask = mask.filter(ImageFilter.MaxFilter(size=2 * int(mask_grow) + 1)) + if blend_blur > 0: + mask = mask.filter(ImageFilter.GaussianBlur(radius=float(blend_blur))) + return mask + + +def composite_masked( + destination: Image.Image, + source: Image.Image, + mask_l: Image.Image, +) -> Image.Image: + dst = np.asarray(destination.convert("RGB")).astype(np.float32) + src = np.asarray(source.convert("RGB").resize(destination.size, _BICUBIC)).astype(np.float32) + alpha = np.asarray(mask_l.convert("L").resize(destination.size, _BILINEAR)).astype(np.float32) / 255.0 + out = src * alpha[:, :, None] + dst * (1.0 - alpha[:, :, None]) + return Image.fromarray(np.clip(out + 0.5, 0, 255).astype(np.uint8), mode="RGB") + + +def tensor_image_to_pil(images: torch.Tensor, index: int) -> Image.Image: + tensor = images.detach().cpu() + if tensor.ndim == 4: + tensor = tensor[min(index, tensor.shape[0] - 1)] + if tensor.ndim != 3: + raise ValueError(f"Expected IMAGE tensor with 3 or 4 dims, got {tuple(images.shape)}") + if tensor.shape[-1] == 1: + tensor = tensor.repeat(1, 1, 3) + if tensor.shape[-1] != 3: + raise ValueError(f"Expected IMAGE tensor channel count 3, got {tensor.shape[-1]}") + arr = (tensor.clamp(0, 1).numpy() * 255.0 + 0.5).astype(np.uint8) + return Image.fromarray(arr, mode="RGB") + + +def tensor_mask_to_pil(masks: torch.Tensor, index: int, size: tuple[int, int]) -> Image.Image: + tensor = masks.detach().cpu() + if tensor.ndim == 4: + tensor = tensor[min(index, tensor.shape[0] - 1)] + if tensor.shape[-1] > 1: + tensor = tensor.max(dim=-1).values + else: + tensor = tensor.squeeze(-1) + elif tensor.ndim == 3: + tensor = tensor[min(index, tensor.shape[0] - 1)] + elif tensor.ndim != 2: + raise ValueError(f"Expected MASK tensor with 2, 3, or 4 dims, got {tuple(masks.shape)}") + + if tensor.ndim != 2: + raise ValueError(f"Expected MASK tensor to resolve to 2 dims, got {tuple(tensor.shape)}") + + arr = (tensor.clamp(0, 1).numpy() * 255.0 + 0.5).astype(np.uint8) + img = Image.fromarray(arr, mode="L") + if img.size != size: + img = img.resize(size, _NEAREST) + return img + + +def pil_to_tensor_image(image: Image.Image) -> torch.Tensor: + arr = np.asarray(image.convert("RGB")).astype(np.float32) / 255.0 + return torch.from_numpy(arr) + + +def pil_to_tensor_mask(mask_l: Image.Image) -> torch.Tensor: + arr = np.asarray(mask_l.convert("L")).astype(np.float32) / 255.0 + return torch.from_numpy(arr) + + +def image_content_signature(image: Image.Image | None) -> str | None: + if image is None: + return None + thumb = image.convert("RGB").resize((32, 32), _BILINEAR) + return hashlib.sha1(thumb.tobytes()).hexdigest() + + +def image_batch_size(images: torch.Tensor | None) -> int: + if images is None: + return 0 + if images.ndim == 4: + return int(images.shape[0]) + return 1 + + +def upscale_image(samples: torch.Tensor, width: int, height: int, method: str, crop: str = "disabled") -> torch.Tensor: + import comfy.utils + + method = (method or DEFAULT_RESIZE_METHOD).strip().lower() + if method == "nearest": + method = "nearest-exact" + + try: + return comfy.utils.common_upscale(samples, width, height, method, crop) + except Exception: + fallback = "area" if method != "area" else "bicubic" + return comfy.utils.common_upscale(samples, width, height, fallback, crop) + + +def upscale_to_fit_with_padding(samples: torch.Tensor, width: int, height: int, method: str) -> torch.Tensor: + _, _, old_height, old_width = samples.shape + scale = min(width / old_width, height / old_height) + resized_width = max(1, min(width, round(old_width * scale))) + resized_height = max(1, min(height, round(old_height * scale))) + resized = upscale_image(samples, resized_width, resized_height, method, "disabled") + out = samples.new_zeros((samples.shape[0], samples.shape[1], height, width)) + x = (width - resized_width) // 2 + y = (height - resized_height) // 2 + out[:, :, y : y + resized_height, x : x + resized_width] = resized + return out + + +def upscale_to_kontext_size(samples: torch.Tensor, width: int, height: int, method: str, crop_mode: str) -> torch.Tensor: + mode = (crop_mode or "crop").strip().lower() + if mode == "fill": + return upscale_to_fit_with_padding(samples, width, height, method) + crop = "disabled" if mode == "disable" else "center" + return upscale_image(samples, width, height, method, crop) + + +def reference_image_transform_metadata( + source_size: tuple[int, int], + target_size: tuple[int, int], + resize_method: str, + crop_mode: str, + sizing_mode: str, + source_signature: str | None = None, +) -> dict[str, Any]: + source_width, source_height = source_size + target_width, target_height = target_size + mode = (crop_mode or "crop").strip().lower() + if mode not in {"crop", "disable", "fill"}: + mode = "crop" + + source_box = (0, 0, int(source_width), int(source_height)) + target_box = (0, 0, int(target_width), int(target_height)) + + if mode == "crop": + old_aspect = source_width / source_height + new_aspect = target_width / target_height + x = 0 + y = 0 + if old_aspect > new_aspect: + x = round((source_width - source_width * (new_aspect / old_aspect)) / 2) + elif old_aspect < new_aspect: + y = round((source_height - source_height * (old_aspect / new_aspect)) / 2) + source_box = ( + int(x), + int(y), + int(source_width - x), + int(source_height - y), + ) + elif mode == "fill": + scale = min(target_width / source_width, target_height / source_height) + resized_width = max(1, min(target_width, round(source_width * scale))) + resized_height = max(1, min(target_height, round(source_height * scale))) + x = (target_width - resized_width) // 2 + y = (target_height - resized_height) // 2 + target_box = (int(x), int(y), int(x + resized_width), int(y + resized_height)) + + return { + "source_size": (int(source_width), int(source_height)), + "target_size": (int(target_width), int(target_height)), + "source_content_box": source_box, + "target_content_box": target_box, + "resize_method": (resize_method or DEFAULT_RESIZE_METHOD).strip().lower(), + "crop_mode": mode, + "sizing_mode": sizing_mode, + "source_signature": source_signature, + } + + +def scale_box( + box: tuple[int, int, int, int], + from_size: tuple[int, int], + to_size: tuple[int, int], +) -> tuple[int, int, int, int]: + from_width, from_height = from_size + to_width, to_height = to_size + if from_width == to_width and from_height == to_height: + return tuple(int(v) for v in box) + scale_x = to_width / from_width + scale_y = to_height / from_height + x1, y1, x2, y2 = box + return ( + int(round(x1 * scale_x)), + int(round(y1 * scale_y)), + int(round(x2 * scale_x)), + int(round(y2 * scale_y)), + ) + + +def clamp_box(box: tuple[int, int, int, int], size: tuple[int, int]) -> tuple[int, int, int, int]: + width, height = size + x1, y1, x2, y2 = tuple(int(v) for v in box) + x1 = max(0, min(width - 1, x1)) + y1 = max(0, min(height - 1, y1)) + x2 = max(x1 + 1, min(width, x2)) + y2 = max(y1 + 1, min(height, y2)) + return x1, y1, x2, y2 + + +def update_info_with_kontext_transforms( + info: dict[str, Any] | None, + slot_transforms: dict[str, list[dict[str, Any] | None]], +) -> dict[str, Any]: + if not isinstance(info, dict): + return {"items": []} + items = info.get("items") + if not items or not slot_transforms: + return info + + def transform_at(slot: str, index: int) -> dict[str, Any] | None: + transforms = slot_transforms.get(slot) or [] + if not transforms: + return None + return transforms[min(index, len(transforms) - 1)] + + def item_model_size(item: dict[str, Any]) -> tuple[int, int] | None: + model_image = item.get("model_image") + if isinstance(model_image, Image.Image): + return tuple(int(v) for v in model_image.size) + size = item.get("model_size") or item.get("model_image_size") + if isinstance(size, (list, tuple)) and len(size) == 2: + return (int(size[0]), int(size[1])) + return None + + def item_model_signature(item: dict[str, Any]) -> str | None: + model_image = item.get("model_image") + if isinstance(model_image, Image.Image): + return image_content_signature(model_image) + signature = item.get("model_image_signature") + if isinstance(signature, str): + return signature + return None + + def choose_transform(item: dict[str, Any], index: int) -> tuple[dict[str, Any] | None, str]: + model_signature = item_model_signature(item) + if model_signature is not None: + for slot in ("image1", "image2", "image3"): + selected = transform_at(slot, index) + if selected and selected.get("source_signature") == model_signature: + return selected, slot + + model_size = item_model_size(item) + if model_size is not None: + for slot in ("image1", "image2", "image3"): + selected = transform_at(slot, index) + if selected and tuple(selected.get("source_size", ())) == model_size: + return selected, slot + + for slot in ("image1", "image2", "image3"): + selected = transform_at(slot, index) + if selected is not None: + return selected, slot + return None, "auto" + + updated_items = [] + for index, item in enumerate(items): + if not isinstance(item, dict): + updated_items.append(item) + continue + updated = item.copy() + selected, selected_source = choose_transform(item, index) + if selected is not None: + updated["reference_image_transform"] = selected + updated["reference_image_transform_source"] = selected_source + updated_items.append(updated) + + updated_info = info.copy() + updated_info["items"] = updated_items + return updated_info + + +def restore_generated_to_model_space( + generated: Image.Image, + model_image: Image.Image, + transform: dict[str, Any] | None, +) -> Image.Image: + if not isinstance(transform, dict): + return generated + + mode = (transform.get("crop_mode") or "crop").strip().lower() + target_size = tuple(transform.get("target_size") or generated.size) + if len(target_size) != 2 or target_size[0] <= 0 or target_size[1] <= 0: + target_size = generated.size + + if mode == "fill": + target_box = tuple(transform.get("target_content_box") or (0, 0, target_size[0], target_size[1])) + target_box = scale_box(target_box, target_size, generated.size) + target_box = clamp_box(target_box, generated.size) + content = generated.crop(target_box) + return content.resize(model_image.size, _BICUBIC) + + if mode == "crop": + source_box = tuple(transform.get("source_content_box") or (0, 0, *model_image.size)) + source_box = clamp_box(source_box, model_image.size) + x1, y1, x2, y2 = source_box + restored = model_image.copy() + content = generated.resize((x2 - x1, y2 - y1), _BICUBIC) + restored.paste(content, (x1, y1)) + return restored + + return generated.resize(model_image.size, _BICUBIC) + + +class RefineNodePreprocessMask: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "focus_crop": ("BOOLEAN", {"default": True}), + "focus_crop_margin": ("INT", {"default": 64, "min": 0, "max": 2048}), + "spatial_prompt_source": (["mask", "bbox"], {"default": "mask"}), + }, + "optional": { + "mask": ("MASK",), + }, + } + + RETURN_TYPES = ("IMAGE", "IMAGE", "MASK", "REFINENODE_INFO") + RETURN_NAMES = ("image", "spatial_mask_image", "mask", "info") + FUNCTION = "preprocess" + CATEGORY = "RefineNode" + + def preprocess( + self, + image: torch.Tensor, + focus_crop: bool, + focus_crop_margin: int, + spatial_prompt_source: str, + mask: torch.Tensor | None = None, + ): + batch = image_batch_size(image) + model_images = [] + spatial_images = [] + model_masks = [] + infos = [] + + for index in range(batch): + original = normalize_to_srgb(tensor_image_to_pil(image, index)) + if mask is None: + mask_l = Image.new("L", original.size, 0) + else: + mask_l = tensor_mask_to_pil(mask, index, original.size) + bbox_raw = bbox_from_mask_or_none(mask_l) + has_region = bbox_raw is not None + + model_image = original + model_mask = mask_l + crop_box = None + bbox_model = bbox_raw + + if focus_crop and bbox_raw is not None: + model_image, model_mask, crop_box = focus_crop_region( + original, + mask_l, + bbox_raw, + int(focus_crop_margin), + ) + bbox_model = offset_bbox(bbox_raw, crop_box[0], crop_box[1]) + + spatial_mask = make_spatial_mask(model_mask, spatial_prompt_source, bbox_model) + + model_images.append(pil_to_tensor_image(model_image)) + spatial_images.append(pil_to_tensor_image(spatial_mask.convert("RGB"))) + model_masks.append(pil_to_tensor_mask(model_mask)) + infos.append( + { + "origin_image": original, + "origin_size": original.size, + "model_image": model_image, + "model_mask": model_mask, + "spatial_mask": spatial_mask, + "bbox_raw": bbox_raw, + "bbox_model": bbox_model, + "crop_box": crop_box, + "has_region": has_region, + "spatial_prompt_source": spatial_prompt_source, + } + ) + + return ( + torch.stack(model_images, dim=0), + torch.stack(spatial_images, dim=0), + torch.stack(model_masks, dim=0), + {"items": infos}, + ) + + +class RefineNodeReferenceImageProcess: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image1": ("IMAGE",), + "fit_kontext_size": ("BOOLEAN", {"default": True}), + "resize_method": (IMAGE_RESIZE_METHODS, {"default": "lanczos"}), + "crop_mode": (["crop", "disable", "fill"], {"default": "crop"}), + }, + "optional": { + "image2": ("IMAGE",), + "image3": ("IMAGE",), + "info": ("REFINENODE_INFO",), + }, + } + + RETURN_TYPES = ("IMAGE", "IMAGE", "IMAGE", "REFINENODE_INFO") + RETURN_NAMES = ( + "image1", + "image2", + "image3", + "info", + ) + FUNCTION = "process" + CATEGORY = "RefineNode" + + def process( + self, + image1: torch.Tensor, + fit_kontext_size: bool, + resize_method: str, + crop_mode: str, + image2: torch.Tensor | None = None, + image3: torch.Tensor | None = None, + info: dict[str, Any] | None = None, + ): + batch = max(image_batch_size(image1), image_batch_size(image2), image_batch_size(image3)) + image1_outputs = [] + image2_outputs = [] + image3_outputs = [] + slot_transforms: dict[str, list[dict[str, Any] | None]] = { + "image1": [], + "image2": [], + "image3": [], + } + target_size = None + + for index in range(batch): + samples1 = tensor_image_to_pil(image1, index) + first = normalize_to_srgb(samples1) + if fit_kontext_size: + width, height = flux_kontext_target_size(*first.size) + sizing_mode = "flux_kontext" + else: + width, height = calculate_dimensions_area(VAE_IMAGE_SIZE, first.size[0], first.size[1], 8) + sizing_mode = "area_1024" + + if target_size is None: + target_size = (width, height) + elif (width, height) != target_size: + raise ValueError( + "Batch items resolve to different Flux Kontext sizes. " + "Split the batch or use images with the same aspect ratio." + ) + + processed = [] + for slot, image in (("image1", image1), ("image2", image2), ("image3", image3)): + if image is None: + slot_transforms[slot].append(None) + processed.append(image1.detach().new_zeros((height, width, 3))) + continue + source_pil = normalize_to_srgb(tensor_image_to_pil(image, index)) + slot_transforms[slot].append( + reference_image_transform_metadata( + source_pil.size, + (width, height), + resize_method, + crop_mode, + sizing_mode, + image_content_signature(source_pil), + ) + ) + sample = image.detach() + if sample.ndim == 4: + sample = sample[min(index, sample.shape[0] - 1)].unsqueeze(0) + elif sample.ndim == 3: + sample = sample.unsqueeze(0) + else: + raise ValueError(f"Expected IMAGE tensor with 3 or 4 dims, got {tuple(image.shape)}") + if sample.shape[-1] == 1: + sample = sample.repeat(1, 1, 1, 3) + if sample.shape[-1] != 3: + raise ValueError(f"Expected IMAGE tensor channel count 3, got {sample.shape[-1]}") + sample = sample.movedim(-1, 1) + out = upscale_to_kontext_size(sample, width, height, resize_method, crop_mode).movedim(1, -1) + processed.append(out[0]) + + image1_outputs.append(processed[0]) + image2_outputs.append(processed[1]) + image3_outputs.append(processed[2]) + + if target_size is None: + raise ValueError("Missing input images.") + + return ( + torch.stack(image1_outputs, dim=0), + torch.stack(image2_outputs, dim=0), + torch.stack(image3_outputs, dim=0), + update_info_with_kontext_transforms(info, slot_transforms), + ) + + +class RefineNodePasteBack: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "generated_image": ("IMAGE",), + "info": ("REFINENODE_INFO",), + "paste_back_mode": (["mask", "bbox"], {"default": "mask"}), + "mask_grow": ("INT", {"default": 3, "min": 0, "max": 256}), + "blend_blur": ("INT", {"default": 5, "min": 0, "max": 256}), + }, + } + + RETURN_TYPES = ("IMAGE", "MASK") + RETURN_NAMES = ("image", "paste_mask") + FUNCTION = "paste_back" + CATEGORY = "RefineNode" + + def paste_back( + self, + generated_image: torch.Tensor, + info: dict[str, Any], + paste_back_mode: str, + mask_grow: int, + blend_blur: int, + ): + items = info.get("items") if isinstance(info, dict) else None + if not items: + raise ValueError("Missing RefineNode Preprocess Mask info.") + + outputs = [] + masks = [] + batch = max(image_batch_size(generated_image), len(items)) + mode = (paste_back_mode or "mask").strip().lower() + + for index in range(batch): + item = items[min(index, len(items) - 1)] + generated = normalize_to_srgb(tensor_image_to_pil(generated_image, index)) + original = item["origin_image"] + model_image = item["model_image"] + model_mask = item["model_mask"] + crop_box = item.get("crop_box") + bbox_model = item.get("bbox_model") + has_region = bool(item.get("has_region", bbox_model is not None)) + generated = restore_generated_to_model_space( + generated, + model_image, + item.get("reference_image_transform"), + ) + + if not has_region or bbox_model is None: + paste_mask = Image.new("L", model_image.size, 255) + result_crop = generated.resize(model_image.size, _BICUBIC) + elif mode == "bbox": + paste_mask = bbox_mask(model_image.size, bbox_model) + paste_mask = prepare_paste_mask(paste_mask, int(mask_grow), int(blend_blur)) + result_crop = composite_masked(model_image, generated, paste_mask) + else: + paste_mask = binary_mask(model_mask) + paste_mask = prepare_paste_mask(paste_mask, int(mask_grow), int(blend_blur)) + result_crop = composite_masked(model_image, generated, paste_mask) + + if crop_box: + result = original.copy() + result.paste(result_crop, (crop_box[0], crop_box[1])) + full_mask = Image.new("L", original.size, 0) + full_mask.paste(paste_mask.resize(result_crop.size, _BILINEAR), (crop_box[0], crop_box[1])) + else: + result = result_crop + full_mask = paste_mask + + outputs.append(pil_to_tensor_image(result)) + masks.append(pil_to_tensor_mask(full_mask)) + + return (torch.stack(outputs, dim=0), torch.stack(masks, dim=0)) + + +def focus_crop_region( + image: Image.Image, + mask_l: Image.Image, + bbox: tuple[int, int, int, int], + margin: int, +) -> tuple[Image.Image, Image.Image, tuple[int, int, int, int]]: + return focus_crop(image, mask_l, bbox, margin) + + +NODE_CLASS_MAPPINGS = { + "RefineNodePreprocessMask": RefineNodePreprocessMask, + "RefineNodeReferenceImageProcess": RefineNodeReferenceImageProcess, + "RefineNodePasteBack": RefineNodePasteBack, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "RefineNodePreprocessMask": "RefineNode Preprocess Mask", + "RefineNodeReferenceImageProcess": "RefineNode Reference Image Process", + "RefineNodePasteBack": "RefineNode Paste Back", +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1a077da --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +# This ComfyUI node uses ComfyUI's native torch/PIL/numpy stack. +# No extra Python package is required for the split-node workflow.