Extensions/aioli-nodes
ComfyUI Extension

aioli-nodes

Custom nodes for ComfyUI — Ratio Outpaint Calc and BBox Multiple Fix

By aiolicollective·Created 6 months ago·Updated 2 months ago· 0
aiolicollective/aioli-nodes
Nodes8
On cloudLocal install
CategoryAioli Nodes
Stars0
Updated2 months ago
Readme

Aioli Nodes — ComfyUI Custom Node Suite

Eight nodes for outpainting, inpainting and multi-region (SAM3 or hand-drawn) editing in ComfyUI — plus ready-to-run example workflows, including three that work out-of-the-box on ComfyUI Cloud (no install required).


💡 Why this approach? (TL;DR)

All workflows in this repo use the same core idea: inpaint only the masked region, never the whole image.

  • The source image's dimensions are preserved end-to-end. Only the crop around the mask goes through the model / KSampler — so the full-resolution original never gets downscaled, stretched, or otherwise degraded to fit a generation budget.
  • More detail inside the masked region. Since only the crop is generated, the model's entire budget (attention, denoising steps, resolution) is spent on that zone — not diluted across background pixels that aren't changing.
  • force_square avoids internal recrops. Most image-edit models (nano-banana, Flux, Qwen-Edit, SDXL inpaint) quietly recrop or letterbox any non-1:1 input. Forcing the crop to be square before sending prevents this "pixel shift", keeping the output pixel-aligned with the source.
  • Pixel-perfect recompose. After generation, the result is stitched back onto the untouched source at the exact original coordinates — no drift, no seams, no colour shift at the mask edges.

Installation

Via ComfyUI Registry (recommended)

Search "Aioli Nodes" directly in the ComfyUI Manager → Install Nodes.

Via Git URL

https://github.com/aiolicollective/aioli-nodes

Manual installation

  1. Copy the aioli-nodes folder into ComfyUI/custom_nodes/
  2. Restart ComfyUI
  3. The nodes appear under the Aioli Nodes category

No extra dependencies — only math (Python stdlib), torch, numpy, scipy and Pillow (all already bundled with ComfyUI).


🖼️ Ratio Outpaint Calc

Prepares an image for outpainting to a standard aspect ratio.
Automatically computes padding and generates the mask.

Inputs | Parameter | Type | Description | |-----------|------|-------------| | image | IMAGE | Source image | | ratio | dropdown | none · 1:1 · 4:5 · 5:4 · 3:4 · 4:3 · 16:9 · 9:16 |

Outputs | Output | Type | Description | |--------|------|-------------| | image_padded | IMAGE | Image padded with neutral grey (0.5) | | mask | MASK | Binary mask (0 = keep, 1 = generate) |

Workflow

Load Image → 🖼️ Ratio Outpaint Calc → VAE Encode (Inpaint) → KSampler

📐 BBox Multiple Fix

Plugs in right after Mask Bounding Box (ComfyUI Essentials).
Rounds the crop to the chosen multiple and handles scaling (up or down) to a Flux-friendly resolution.

The node ensures the inpainted region stitches back pixel-perfectly onto the base image — no border artefacts, no alignment drift, even when the mask zone is at the very edge of the image.

Example

BBox Multiple Fix — Flux2Klein inpaint example

The inpaint applied back onto the base image fits the original contours exactly — pixel-perfect edges, no alignment drift.

Inputs | Parameter | Type | Description | |-----------|------|-------------| | image | IMAGE | Full source image (before crop) | | mask | MASK | Full source mask (before crop) | | x | INT | x output from Mask Bounding Box | | y | INT | y output from Mask Bounding Box | | width | INT | width output from Mask Bounding Box | | height | INT | height output from Mask Bounding Box | | multiple | dropdown | 8 (VAE minimum) · 16 (Flux) · 32 (SD1.5) · 64 (SDXL) | | target | dropdown | none · 512 · 768 · 1024 · 1536 · 2048 | | force_square | BOOLEAN | Force crop to 1:1 ratio — side = max(width, height). Default: False | | force_target_downscale | BOOLEAN | If bbox > target, downscale toward target (GCD). Default: False — fallback to 2048 cap |

Outputs | Output | Type | Description | |--------|------|-------------| | image_cropped | IMAGE | Cropped image (scaled if needed) | | mask_cropped | MASK | Cropped mask (scaled if needed) | | x | INT | x position for ImageCompositeMasked | | y | INT | y position for ImageCompositeMasked | | orig_width | INT | Crop width in source BEFORE scale — use for resize-back after VAE Decode | | orig_height | INT | Crop height in source BEFORE scale | | width | INT | Final width after scale | | height | INT | Final height after scale | | target_size | INT | Numeric value of target (0 if none) — connect directly to ImageResize+ |

Scaling behaviour

| Situation | Behaviour | |-----------|-----------| | bbox ≤ target | Upscale crop toward target — exact ratio via GCD | | bbox > target + force_target_downscale = True | Downscale crop toward target — exact ratio via GCD | | bbox > target + force_target_downscale = False | Fallback: round to multiple + cap at 2048px | | target = none + bbox > 2048px | Downscale crop to fit 2048px — ratio preserved via GCD | | force_square = True | Crop is squared first (max side), then scaled |

Anti-clamp guarantee: the crop is always constrained to the available space around the bbox center — even when the mask zone is at the image border, the ratio is preserved pixel-perfectly (0% drift).

Note on force_square with very large bboxes: if max(bbox_w, bbox_h) exceeds the smallest source dimension (i.e. the theoretical square doesn't fit in the image), the crop is reduced to a non-square rectangle by the final clamp, then stretched to fit the square target. The node still returns the original (pre-stretch) dimensions via orig_width / orig_height, so for pixel-perfect recompose in this case, set the downstream ImageResize+ to stretch mode (keep_proportion = False) — the inverse stretch will then restore the correct shape before ImageCompositeMasked. A warning is logged when this case is triggered.

Workflow without scale

BBox Fix → VAE Encode → KSampler → VAE Decode → ImageCompositeMasked ← x, y

Workflow with upscale / downscale

BBox Fix → VAE Encode → KSampler → VAE Decode
  │                                      │
  ├── orig_width, orig_height            │
  ├── x, y               ImageResize+ ←─┘
  │                       ↑
  └── target_size ────────┘
                          │
               ImageCompositeMasked ← x, y

🎨 Inpaint Color Fix

Plugs in right after VAE Decode, before ImageResize+ / ImageCompositeMasked.

Corrects colorimetric drift introduced by the generation — selectively applies a LAB color match only on pixels that haven't significantly changed, leaving truly creative pixels untouched. No external dependencies (pure numpy + torch).

Inputs | Parameter | Type | Description | |-----------|------|-------------| | original_crop | IMAGE | image_cropped from BBoxMultipleFix (before KSampler) | | inpainted_crop | IMAGE | IMAGE from VAE Decode | | delta_e_threshold | FLOAT | Similarity threshold (-1 = auto). Below = corrected, above = creative/intact | | blend_strength | FLOAT | Color match strength on similar zones (0 = none, 1 = full) | | feather_radius | INT | Gaussian blur radius on the correction mask (0 = disabled) | | mask (optional) | MASK | Override mode: bypasses Delta-E entirely, the mask drives correction directly |

Outputs | Output | Type | Description | |--------|------|-------------| | image_corrected | IMAGE | Color-corrected crop — connect to ImageResize+ | | correction_mask | MASK | Debug mask (white = corrected, black = creative/intact) |

Modes

| Mode | Behaviour | |------|-----------| | No mask connected | Delta-E auto-detects similar vs creative pixels | | Mask connected | Delta-E is bypassed — the mask controls correction directly |

Delta-E threshold guide

| Value | Effect | |-------|--------| | -1 (auto) | Recommended starting point | | 5–10 | Strict — corrects almost everything except highly creative pixels | | 15–20 | Balanced — fixes subtle drift, preserves real changes | | 25–35 | Loose — only corrects near-identical pixels | | 50+ | Near-global color match |

Position in workflow

BBoxMultipleFix
  └── image_cropped → KSampler → VAEDecode → 🎨 InpaintColorFix → ImageResize+ → ImageCompositeMasked

🧩 BBox Multiple Assembler

Multi-region recompose — the list-aware successor to ImageCompositeMasked. Stitches a list of N inpainted crops back onto a single base image, each at its own bbox, with per-layer mask growth, feathering, opacity and z-ordering. Used when several masked regions (SAM3 masks, or hand-drawn blobs split by Mask Split Regions) are each cropped, enhanced separately in the KSampler, then merged. INPUT_IS_LIST — maps over the N crops/masks/coords automatically.

Key behaviour: mask_adjust operates on the full image canvas (image-bounded, not crop-bounded), so a region can be grown/shrunk after generation without re-running the sampler. Compositing happens only inside each crop rectangle — where a grown mask spills past the crop, the layer below shows through (true per-hierarchy transparency), never an imposed base-image overwrite. Separable grow/feather + downscale keep it fast even at large radii.

Inputs | Parameter | Type | Description | |-----------|------|-------------| | base_image | IMAGE | Full-resolution untouched source | | crops | IMAGE (list) | The N enhanced crops | | masks | MASK (list) | The N per-region masks | | x / y | INT (list) | Top-left of each crop in the source | | width / height | INT (list) | Size of each crop | | order | dropdown | list_first_on_top (last list item = bottom layer → put the background mask last) · area_large_under (large zones under, auto) | | mask_adjust | INT | Grow (+) / shrink (−) each mask on the full canvas. Default 0 | | feather | INT | Gaussian edge softening. Default 8 | | opacity | FLOAT | Per-layer opacity. Default 1.0 |

Outputs | Output | Type | Description | |--------|------|-------------| | image | IMAGE | Final recomposed image | | combined_mask | MASK | Union of all composited regions | | checker | IMAGE | Debug view with coloured region outlines |

The checker outline is always drawn (frozen internally, not exposed as a widget) — turning it off only made checker a duplicate of image.

Proven byte-identical to ImageCompositeMasked for a single region with feather = 0, mask_adjust = 0.


👁 Region Preview

Preview — before any generation — exactly what BBox Multiple Assembler only outputs at the very end. Branch it as a side-channel right after Region Mask List or Mask Split Regions; it never touches the list pipeline that already works. It reuses the assembler's exact math (the same imported _grow_shrink_fast / _feather_fast / _resize_mask / _PALETTE helpers), so the preview equals the final render.

It also doubles as a single tuning point: set mask_adjust / feather / opacity here, look at the result, then wire those same outputs straight back into BBox Multiple Assembler (convert its widgets to inputs). INPUT_IS_LIST — maps over the mask list like the assembler; singletons taken at [0].

Masks are full-size (the usual output of Region Mask List / Mask Split Regions, already positioned on the canvas), so no coordinates are needed.

Inputs | Parameter | Type | Description | |-----------|------|-------------| | base_image | IMAGE | The base image to preview the regions on | | masks | MASK (list) | The regional masks (full-size) | | mask_adjust | INT | Grow (+) / shrink (−) each mask on the full canvas. Default 0 | | feather | INT | Gaussian edge softening. Default 8 | | opacity | FLOAT | Per-layer opacity. Default 1.0 |

Outputs | Output | Type | Description | |--------|------|-------------| | combined_mask | MASK | Union of all masks with mask_adjust / feather / opacity applied exactly like the assembler | | checker | IMAGE | Base image + per-region coloured outline & light fill, to see what you're working on | | mask_adjust | INT | Pass-through — rewire into BBox Multiple Assembler | | feather | INT | Pass-through — rewire into BBox Multiple Assembler | | opacity | FLOAT | Pass-through — rewire into BBox Multiple Assembler |

Position in workflow (side branch)

Region Mask List ──┬─→ … per-region crop → KSampler → BBox Multiple Assembler   (main list pipeline, untouched)
                   └─→ 👁 Region Preview                                         (side branch — visualise before generating)

Re-wiring back into the assembler. mask_adjust (INT), feather (INT) and opacity (FLOAT) wire cleanly into the assembler's matching inputs (convert its widgets to inputs). order is not exposed here — ComfyUI can't link a node output into a combo input, and order doesn't change the preview — so set it directly on the assembler. The debug outline is always on (frozen, not exposed) — without it the checker would show nothing.


🧱 Region Mask List

Prepares the list of regional masks for a SAM3 multi-region pipeline. Takes the N SAM3 masks (batch or list), flattens them into a clean full-size list in the received order, and optionally appends a background mask (the inverse of the union of all masks) as the last element. INPUT_IS_LIST · OUTPUT_IS_LIST = (True, False, False).

The last element being the background pairs with BBox Multiple Assembler's list_first_on_top ordering (last = bottom layer), so the background stays under the objects. The same list feeds MaskBoundingBox+ (per-region crop + coords), the per-region Gemma caption, the KSampler (N passes) and Regional Mask Conditioning.

Inputs | Parameter | Type | Description | |-----------|------|-------------| | masks | MASK (batch/list) | The SAM3 masks | | add_background | BOOLEAN | Append the inverse-union background mask last. Default True | | threshold | FLOAT | Binarisation cutoff used only to compute the background (inverse union). No effect on the object masks, and none at all when add_background = False. Default 0.5 | | min_bg_area | FLOAT | Skip the background if it covers less than this fraction of the image. Default 0.0 |

Outputs | Output | Type | Description | |--------|------|-------------| | masks | MASK (list) | The N (+1) regional masks | | background_mask | MASK | The computed inverse-union background | | count | INT | Number of layers out |


🗺️ Regional Mask Conditioning

Folds a list of (conditioning, mask) pairs into a single regional conditioning for a one-pass KSampler generation (regional prompting, TTP-style — except the "tiles" are your SAM3 / hand-drawn masks). Each (caption_i, mask_i) becomes a Conditioning (Set Mask) and all are concatenated — ComfyUI can't fold a dynamic list of N on its own, this node does it for any N. Broadcast: supply a single conditioning for N masks and it is applied to every region (handy for a single prompt / debugging). INPUT_IS_LIST.

Optional global layer. Connect a base_conditioning — a prompt describing the whole image — and it is applied over the full frame, blended with the regional prompts via base_strength (its mask_strength). Inside a region the pixel gets a weighted mix of base_strength (global) vs strength (region); outside every region the global applies alone. Leave base_strength = 0 or the input unconnected for pure regional behaviour (unchanged). The blend happens at the sampler level via mask_strength, so prompts of different lengths mix cleanly — no tensor interpolation.

Inputs | Parameter | Type | Description | |-----------|------|-------------| | conditioning | CONDITIONING (list) | One per region, or a single one to broadcast to all | | masks | MASK (list) | The regional masks | | strength | FLOAT | mask_strength per region. Default 1.0 | | set_area_to_bounds | BOOLEAN | Restrict each region's compute to its mask bbox — faster when regions are small. Default False | | base_conditioning (optional) | CONDITIONING | A global prompt describing the whole image, blended over the regions | | base_strength | FLOAT | Weight of the global layer (its mask_strength). 0 = no global. Default 0.5 |

Outputs | Output | Type | Description | |--------|------|-------------| | conditioning | CONDITIONING | The combined regional (+ optional global) conditioning | | covered_mask | MASK | Union of all region masks (debug) |

Performance. Regional conditioning is inherently ~N× slower than a single global prompt: the sampler runs one model forward pass per distinct masked prompt, per step (N regions + the optional global = N+1 passes). With DyPE at high resolution each pass is costly, so keep the region count down and set set_area_to_bounds = True to limit each region's compute to its bounding box.


✂️ Mask Split Regions

Splits one hand-drawn mask into N separate masks, one per disconnected blob (connected-component labelling). Each painted region then gets its own bbox / crop / prompt — manual multi-region inpaint, exactly like SAM3 but keyword-free. A single connected blob → 1 mask (identical to a normal single-region inpaint). Output is a list, so it drops straight into MaskBoundingBox+ / BBox Multiple Fix, which map over it. Uses scipy.ndimage — already bundled with ComfyUI, no install. OUTPUT_IS_LIST = (True, False).

Inputs | Parameter | Type | Description | |-----------|------|-------------| | mask | MASK | The single hand-drawn mask (may contain several disconnected blobs) | | threshold | FLOAT | Binarisation of the drawn mask. Default 0.5 | | min_area | INT | Ignore blobs smaller than this many pixels (anti stray-dots). Default 64 | | sort_by | dropdown | area_desc (default, largest first) · area_asc · top_to_bottom · left_to_right |

Outputs | Output | Type | Description | |--------|------|-------------| | masks | MASK (list) | One full-size mask per detected region | | count | INT | Number of regions found |

Position in workflow (manual branch)

ImageToMask → ✂️ Mask Split Regions → (switch) → MaskBoundingBox+ → BBox Multiple Fix → … per-region inpaint

Only three widgets are exposedthreshold, min_area, sort_by. Two parameters are kept as fixed defaults in the node code to keep the canvas clean: connectivity = 8 (diagonals count — correct for virtually all hand-drawn masks) and merge_distance = 0 (no stroke merging). Edit mask_split_regions.py if you ever need to change them.


☁️ ComfyCloud-compatible workflows (no install required)

If you don't want to install the custom node locally — or if you're running ComfyUI Cloud where custom nodes aren't available — there are three pure-subgraph workflows that reproduce the BBoxMultipleFix behaviour using only pre-installed nodes.

They all package the same reusable subgraph Aioli Node Subgraph — BBox Fix, which wires together MaskBoundingBox+, ComfyMathExpression, ImageCrop+, ImageResize+, ImagePadForOutpaint, MaskComposite, GrowMaskWithBlur, SolidMask, ImpactSwitch, BatchImagesNode, and CropMask to deliver the same features:

  • force_square · inpaint_mode (zone / whole image) · use_mask_blur
  • multiple alignment (with floor-after-clamp, no drift)
  • target_size (0 = none with 2048 cap, or 512 / 768 / 1024 / 1536 / 2048)
  • Anti-clamp guarantee
  • Pixel-perfect recompose via ImageCompositeMasked
  • Auto 1:1 padding in whole-image mode (v2): pads source to a square before sending to the model, then strips the padding off — prevents the model from internally recropping non-square images
  • Optional second reference image (v3, nano-banana only): batch a style-reference image alongside the crop for multi-image prompting — with a use_image2 toggle that safely bypasses the batch when disabled

Three flavours, one per inpainting model

🍌 nano-banana (Gemini Image) — v3

The lightest variant: just an API call to Gemini Image, no local diffusion weights needed. Includes the v3 image2 input for multi-image prompting.

⬇ Download workflow
🚀 Try it live — "NanoInpaint — CropNStitch" on ComfyUI Cloud (optimised for the Cloud app runtime — no setup, just paint & run)

nano-banana example

🌀 Flux.2 Klein 9B

Full local diffusion pipeline using the official ComfyUI Flux.2 Klein inpaint template, combined with the BBox Fix subgraph for pixel-perfect crop & recompose.

⬇ Download workflow

Flux2Klein example

🏮 Qwen Image Edit 2511

Same idea as Flux2Klein but using the Qwen Image Edit 2511 template — a different diffusion model, same BBox Fix subgraph wrapper.

⬇ Download workflow

Qwen Image Edit example

Subgraph widgets (all three workflows)

| Widget | Default | Purpose | |---|---|---| | force_square | True | Force crop to 1:1 ratio (avoids model-internal recrop) | | inpaint_mode | True | True = zone mode (crop around mask) · False = whole-image mode (pad source to 1:1, send everything, crop back) | | use_mask_blur | False | Apply GrowMaskWithBlur before bbox detection | | multiple | 16 | Dimension alignment (16/32/64 for VAE; 0/1 disables) | | target_size | 0 | Long-side target: 0 = none (cap 2048) · 512 · 768 · 1024 · 1536 · 2048 | | use_image2 (nano-banana v3 only) | False | Batch a second reference image alongside the crop |

Running locally?

The nano-banana example uses LoadImageOutput, which works out-of-the-box on ComfyUI Cloud but may not be available in some local setups. If you hit an error on load, just replace that node with a standard Load Image node — everything else stays the same.


🔁 Example Workflow — Multi-region edit (Flux.2 Klein)

⬇ Download workflow JSON

A complete multi-region inpaint workflow for Flux.2 Klein (9B) that exercises most of this repo's inpaint nodes at once: several masked regions are each cropped, prompted and denoised independently, then recomposed pixel-perfectly onto the untouched source. Regions can come from SAM3 masks or from a single hand-drawn mask split by ✂️ Mask Split Regions — either way they flow through 🧱 Region Mask List, get cropped by 📐 BBox Multiple Fix, enhanced per region in the KSampler, colour-matched by 🎨 Inpaint Color Fix, and merged by 🧩 BBox Multiple Assembler. 👁 Region Preview lets you check the layout before generating.

Regions → mask → recompose

The assembler's checker shows each region outlined in its own colour, combined_mask is the union of all regions, and the final image is the recomposed result — every region stitched back at its exact coordinates with no seam or colour drift.

Multi-region checker — each region outlined

checker — each masked region outlined in its own colour (also previewable up-front with 👁 Region Preview).

Multi-region combined mask

combined_mask — the union of all region masks, with mask_adjust / feather applied.

Multi-region recomposed result

Final image — each region enhanced independently, recomposed pixel-perfectly onto the source.

Pipeline

Load Image (+ SAM3 masks  OR  hand-drawn mask → ✂️ Mask Split Regions)
  └→ 🧱 Region Mask List                (clean list + optional background, last)
        ├─→ 👁 Region Preview            (side branch — combined_mask / checker before generating)
        └─→ MaskBoundingBox+ → 📐 BBox Multiple Fix     (per-region crop + coords, Flux-friendly, anti-clamp)
              └→ VAE Encode → KSampler   (N passes, one per region, per-region prompt)
                    └→ VAE Decode → 🎨 Inpaint Color Fix       (selective LAB colour match)
                          └→ 🧩 BBox Multiple Assembler         (recompose N crops → image / combined_mask / checker)

Required models | Role | File | |------|------| | UNet | flux2/flux-2-klein-9b-fp8.safetensors | | VAE | flux2/flux2-vae.safetensors | | Text encoder | qwen_3_8b_fp8mixed.safetensors |

Aioli nodes showcased MaskSplitRegions · RegionMaskList · BBoxMultipleFix · InpaintColorFix · BBoxMultipleAssembler · RegionPreview — plus ComfyUI Essentials (MaskBoundingBox+, ImageResize+) and ComfyUI KJNodes (GrowMaskWithBlur).

Usage

  1. Download the JSON and drag it into ComfyUI
  2. Provide your image and either SAM3 masks or a hand-drawn mask (split automatically into regions)
  3. Give each region its own prompt
  4. Run — every region is enhanced on its own and recomposed pixel-perfectly onto the source

👋 About

These nodes and workflows are built and maintained by the aioli collective — a creative studio exploring what's next for AI-assisted image work.

If this saved you time or inspired something, a ⭐ on the repo goes a long way. You can also follow us to see what we're cooking next:

🌐 aiolicollective.com · 📷 @aioli.collective

Feedback, bug reports, and pull requests are always welcome via GitHub Issues.