add tooltips and README
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
# ComfyUI-MultiMaskOps
|
||||
|
||||
A collection of ComfyUI nodes for working with **multiple masks at once** — sorting them by position, picking one out, removing duplicates, and expanding/feathering them without letting neighbours overlap.
|
||||
|
||||
These nodes are aimed at multi-subject workflows (e.g. multiple characters segmented from one image) where you end up with a batch or list of masks and need to organise, clean, or grow them individually.
|
||||
|
||||
Expansion and feathering are implemented to match ComfyUI's native behaviour: dilation uses the same method as the built-in **GrowMask** node (including `tapered_corners`), and feathering uses the same Gaussian blur convention as **GrowMaskWithBlur** (KJNodes).
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
1. Clone or copy this folder into your `ComfyUI/custom_nodes/` directory:
|
||||
|
||||
```
|
||||
ComfyUI/custom_nodes/ComfyUI-MultiMaskOps/
|
||||
```
|
||||
|
||||
2. Search for **MultiMaskOps** in ComfyUI manager and install it (soon)
|
||||
|
||||
All nodes appear under the **MultiMaskOps** category.
|
||||
|
||||
### Requirements
|
||||
|
||||
The nodes rely on `torch`, `scipy`, and `torchvision`, all of which ship with a standard ComfyUI install. No extra installation should be needed.
|
||||
|
||||
---
|
||||
|
||||
## A note on batches vs lists
|
||||
|
||||
ComfyUI masks come in two shapes:
|
||||
|
||||
- **Batch** — a single tensor of shape `[N, H, W]` holding N masks.
|
||||
- **List** — a Python list of separate mask tensors, processed one item at a time.
|
||||
|
||||
Most nodes here operate on **lists** (so each mask is handled independently). Use **Mask Batch To List** to convert a batch into a list before feeding these nodes, and **Mask List To Batch** to convert back if a downstream node expects a batch.
|
||||
|
||||
---
|
||||
|
||||
## Nodes
|
||||
|
||||
### Mask Batch To List
|
||||
|
||||
Splits a mask batch `[N, H, W]` into a list of N individual masks, each `[1, H, W]`. Use this to feed a batch into the list-based nodes below.
|
||||
|
||||
**Inputs**
|
||||
- `masks` — a mask batch.
|
||||
|
||||
**Outputs**
|
||||
- `masks` — a list of individual masks.
|
||||
|
||||
### Mask List To Batch
|
||||
|
||||
Combines a list of masks back into a single batch tensor `[N, H, W]`. Masks of differing sizes are resized to match the first.
|
||||
|
||||
**Inputs**
|
||||
- `masks` — a list of masks.
|
||||
|
||||
**Outputs**
|
||||
- `masks` — a single mask batch.
|
||||
|
||||
**Example:**
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### Mask Sort By Position
|
||||
|
||||
Sorts a list of masks by their spatial position, so you can reliably address them as "first from the left", "top-most", and so on. Also outputs a debug image showing the reference point used for each mask.
|
||||
|
||||
**Inputs**
|
||||
- `masks` — a list of masks to sort.
|
||||
- `reference_point` — `centroid` (value-weighted centre of the mask) or `extreme_point` (the outermost pixel along the primary sort direction, e.g. the left-most pixel when sorting left-to-right).
|
||||
- `sort_left_to_right` — horizontal direction.
|
||||
- `sort_top_to_bottom` — vertical direction.
|
||||
- `primary_axis_horizontal` — which axis sorts first; the other axis breaks ties. Full ties fall back to original order (stable sort).
|
||||
- `flatten_before_sort` — binarize each mask at `threshold` before computing its sort point (sorting only; output masks are unchanged).
|
||||
- `drop_below_threshold` — ignore pixels below `threshold` when computing the sort point (sorting only; output masks are unchanged).
|
||||
- `threshold` — cutoff used by the two options above.
|
||||
|
||||
**Outputs**
|
||||
- `masks` — the input masks reordered by position.
|
||||
- `debug_image` — a white image with a black dot at each mask's sort reference point, in sorted order.
|
||||
|
||||
**Example:**
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### Mask Pick By Index
|
||||
|
||||
Returns a single mask from a list by index. Supports Python-style negative indexing (`-1` = last), and clamps out-of-range values to the nearest end so it always returns a valid mask (given a non-empty list).
|
||||
|
||||
**Inputs**
|
||||
- `masks` — a list of masks.
|
||||
- `index` — which mask to return. `0` = first, `-1` = last, etc.
|
||||
|
||||
**Outputs**
|
||||
- `mask` — the selected mask.
|
||||
|
||||
Pairs well with **Mask Sort By Position**: sort left-to-right, then pick `0` for the left-most subject or `-1` for the right-most, regardless of how many masks were detected.
|
||||
|
||||
**Example:**
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### Mask Deduplicate
|
||||
|
||||
Detects and removes duplicate masks using **transitive IoU grouping**: if mask A overlaps B and B overlaps C (each above the threshold), all three are treated as one group — even if A and C don't directly overlap enough. Each group is then collapsed to a single mask.
|
||||
|
||||
**Inputs**
|
||||
- `masks` — a list of masks to deduplicate.
|
||||
- `iou_threshold` — minimum Intersection-over-Union for two masks to count as duplicates. Higher = stricter.
|
||||
- `keep` — how to collapse each group:
|
||||
- `largest` / `smallest` / `first` — keep one existing mask from the group.
|
||||
- `OR` — union of the group (soft max).
|
||||
- `AND` — intersection of the group (soft min).
|
||||
- `threshold` — binarization cutoff for computing IoU and areas.
|
||||
|
||||
**Outputs**
|
||||
- `masks` — the deduplicated masks, one per group.
|
||||
- `removed_count` — how many masks were removed.
|
||||
- `group_images` — one debug image per duplicate group, each mask drawn in a distinct colour with overlaps blended (so overlap of two masks reads as a blended colour). Only groups with 2+ members are shown.
|
||||
|
||||
**Example:**
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### Mask Expand Without Overlap
|
||||
|
||||
Expands (and optionally feathers) each mask in a list while making sure no mask ever leaks into another mask's original region. Each mask is grown, then the other masks' regions are subtracted from it.
|
||||
|
||||
Dilation matches the native **GrowMask** method (with `tapered_corners`); feathering matches **GrowMaskWithBlur**'s Gaussian blur.
|
||||
|
||||
**Inputs**
|
||||
- `masks` — a list of masks to expand.
|
||||
- `expand_pixels` — how many pixels to grow each mask.
|
||||
- `tapered_corners` — rounds the corners of the expansion (matches native GrowMask).
|
||||
- `feather_amount` — Gaussian blur radius for soft edges (`0` = no feather).
|
||||
- `feather_mode` — how feathering interacts with neighbours:
|
||||
- `cut_feather` — feather, then subtract neighbours (hard seam at the neighbour edge).
|
||||
- `preserve_feather` — subtract, feather, then subtract again (soft seam that never leaks).
|
||||
- `overlap_policy` — how expansions interact in empty space:
|
||||
- `allow` — expansions may overlap freely outside original regions (only originals are protected).
|
||||
- `retreat` — contested empty pixels are cleared in both masks (clean empty seam).
|
||||
- `priority` — earlier masks in the list win contested pixels (gap-free; deterministic by order).
|
||||
- `subtract_hard` — flatten the protected region so a mask can never leave any value inside another's area (recommended on).
|
||||
- `threshold` — binarization cutoff used when building the protection regions.
|
||||
|
||||
**Outputs**
|
||||
- `masks` — the processed masks, expanded/feathered without overlapping each other's originals.
|
||||
|
||||
**Example:**
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 261 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
+2
-2
@@ -8,7 +8,7 @@ class MaskBatchToList:
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"masks": ("MASK",),
|
||||
"masks": ("MASK", {"tooltip": "A mask batch [N, H, W]. Output is a list of N individual masks, one per downstream execution."}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ class MaskListToBatch:
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"masks": ("MASK",),
|
||||
"masks": ("MASK", {"tooltip": "A list of masks. Output is a single batch tensor [N, H, W]. Masks of differing sizes are resized to match the first."}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-4
@@ -19,16 +19,19 @@ class MaskDeduplicate:
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"masks": ("MASK",),
|
||||
"iou_threshold": ("FLOAT", {"default": 0.9, "min": 0.0, "max": 1.0, "step": 0.01}),
|
||||
"keep": (["largest", "smallest", "first", "AND", "OR"],),
|
||||
"threshold": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
|
||||
"masks": ("MASK", {"tooltip": "A list of masks to deduplicate."}),
|
||||
"iou_threshold": ("FLOAT", {"default": 0.9, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Minimum Intersection-over-Union for two masks to count as duplicates. Grouping is transitive: if A~B and B~C, all three merge. Higher = stricter (only near-identical masks merge)."}),
|
||||
"keep": (["largest", "smallest", "first", "AND", "OR"], {"tooltip": "How to collapse each duplicate group. largest/smallest/first pick one existing mask; OR = union of the group (max); AND = intersection of the group (min)."}),
|
||||
"threshold": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Binarization cutoff for computing IoU and mask areas. Pixels >= threshold count as 'on'. Does not affect AND/OR output values."}),
|
||||
}
|
||||
}
|
||||
|
||||
INPUT_IS_LIST = True
|
||||
RETURN_TYPES = ("MASK", "INT", "IMAGE")
|
||||
RETURN_NAMES = ("masks", "removed_count", "group_images")
|
||||
OUTPUT_TOOLTIPS = ("The deduplicated masks, one per duplicate group.",
|
||||
"How many masks were removed as duplicates.",
|
||||
"One image per duplicate group, each mask in a distinct color (overlaps blend). Only groups with 2+ members are shown.")
|
||||
OUTPUT_IS_LIST = (True, False, True)
|
||||
FUNCTION = "deduplicate"
|
||||
CATEGORY = "MultiMaskOps"
|
||||
|
||||
+8
-8
@@ -27,14 +27,14 @@ class MaskExpandWithoutOverlap:
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"masks": ("MASK",),
|
||||
"expand_pixels": ("INT", {"default": 10, "min": 0, "max": 4096, "step": 1}),
|
||||
"tapered_corners": ("BOOLEAN", {"default": True}),
|
||||
"feather_amount": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 1}),
|
||||
"feather_mode": (["cut_feather", "preserve_feather"],),
|
||||
"overlap_policy": (["allow", "retreat", "priority"],),
|
||||
"subtract_hard": ("BOOLEAN", {"default": True}),
|
||||
"threshold": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
|
||||
"masks": ("MASK", {"tooltip": "A list of masks to expand/feather while preventing overlap."}),
|
||||
"expand_pixels": ("INT", {"default": 10, "min": 0, "max": 4096, "step": 1, "tooltip": "Pixels to grow each mask (native GrowMask method). 0 = no expansion."}),
|
||||
"tapered_corners": ("BOOLEAN", {"default": True, "tooltip": "Rounds the corners of the expansion (matches native GrowMask). True = rounded growth, False = square growth."}),
|
||||
"feather_amount": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 1, "tooltip": "Gaussian blur radius for soft edges (used as sigma, matches KJNodes GrowMaskWithBlur). 0 = no feather."}),
|
||||
"feather_mode": (["cut_feather", "preserve_feather"], {"tooltip": "How feathering interacts with neighbors. cut_feather = feather then subtract neighbors (hard seam at neighbor edge). preserve_feather = subtract, feather, subtract again (soft seam that never leaks into neighbors)."}),
|
||||
"overlap_policy": (["allow", "retreat", "priority"], {"tooltip": "How expansions interact. allow = may overlap freely in empty space (only original regions protected). retreat = contested empty pixels cleared in both masks (clean seam). priority = earlier masks in the list win contested pixels (gap-free)."}),
|
||||
"subtract_hard": ("BOOLEAN", {"default": True, "tooltip": "Flatten the protected/subtracted region so masks can never leave any nonzero value inside another mask's area. Recommended on."}),
|
||||
"threshold": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Binarization cutoff used when building the hard protection regions."}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@ class MaskPickByIndex:
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"masks": ("MASK",),
|
||||
"index": ("INT", {"default": 0, "min": -10000, "max": 10000, "step": 1}),
|
||||
"masks": ("MASK", {"tooltip": "A list of masks to pick from."}),
|
||||
"index": ("INT", {"default": 0, "min": -10000, "max": 10000, "step": 1, "tooltip": "Which mask to return. 0 = first, 1 = second, ... Negative counts from the end (-1 = last). Out-of-range values clamp to the nearest end."}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-8
@@ -15,20 +15,22 @@ class MaskSortByPosition:
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"masks": ("MASK",),
|
||||
"reference_point": (["centroid", "extreme_point"],),
|
||||
"sort_left_to_right": ("BOOLEAN", {"default": True}),
|
||||
"sort_top_to_bottom": ("BOOLEAN", {"default": True}),
|
||||
"primary_axis_horizontal": ("BOOLEAN", {"default": True}),
|
||||
"flatten_before_sort": ("BOOLEAN", {"default": False}),
|
||||
"drop_below_threshold": ("BOOLEAN", {"default": False}),
|
||||
"threshold": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
|
||||
"masks": ("MASK", {"tooltip": "A list of masks to sort by spatial position."}),
|
||||
"reference_point": (["centroid", "extreme_point"], {"tooltip": "Which point of each mask to sort by. 'centroid' = value-weighted center of the mask. 'extreme_point' = the outermost pixel along the primary sort direction (e.g. leftmost pixel when sorting left-to-right)."}),
|
||||
"sort_left_to_right": ("BOOLEAN", {"default": True, "tooltip": "Horizontal direction. True = left to right, False = right to left."}),
|
||||
"sort_top_to_bottom": ("BOOLEAN", {"default": True, "tooltip": "Vertical direction. True = top to bottom, False = bottom to top."}),
|
||||
"primary_axis_horizontal": ("BOOLEAN", {"default": True, "tooltip": "Which axis sorts first. True = sort horizontally first, then vertically to break ties. False = vertical first, then horizontal."}),
|
||||
"flatten_before_sort": ("BOOLEAN", {"default": False, "tooltip": "If enabled, each mask is binarized at 'threshold' before its sort point is computed. Only affects sorting; output masks are unchanged."}),
|
||||
"drop_below_threshold": ("BOOLEAN", {"default": False, "tooltip": "If enabled (and not flattening), pixels below 'threshold' are ignored when computing the sort point (a thresholded ReLU). Only affects sorting; output masks are unchanged."}),
|
||||
"threshold": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Cutoff used by flatten_before_sort and drop_below_threshold to decide which pixels count when computing each mask's sort point."}),
|
||||
}
|
||||
}
|
||||
|
||||
INPUT_IS_LIST = True
|
||||
RETURN_TYPES = ("MASK", "IMAGE")
|
||||
RETURN_NAMES = ("masks", "debug_image")
|
||||
OUTPUT_TOOLTIPS = ("The input masks reordered by spatial position.",
|
||||
"A white image with a black dot marking each mask's sort reference point, in sorted order. For debugging.")
|
||||
OUTPUT_IS_LIST = (True, False)
|
||||
FUNCTION = "sort_masks"
|
||||
CATEGORY = "MultiMaskOps"
|
||||
|
||||
Reference in New Issue
Block a user