diff --git a/README.md b/README.md index e69de29..55bbede 100644 --- a/README.md +++ b/README.md @@ -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:** + +![batch to list list to batch.png](examples/batch%20to%20list%20list%20to%20batch.png) + +--- + +### 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 sort.png](examples/mask%20sort.png) + +--- + +### 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 index.png](examples/mask%20index.png) + +--- + +### 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 deduplicate.png](examples/mask%20deduplicate.png) + +--- + +### 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:** + +![mask expand.png](examples/mask%20expand.png) + +--- + +## License + +MIT diff --git a/examples/batch to list list to batch.png b/examples/batch to list list to batch.png new file mode 100644 index 0000000..36c5844 Binary files /dev/null and b/examples/batch to list list to batch.png differ diff --git a/examples/mask deduplicate.png b/examples/mask deduplicate.png new file mode 100644 index 0000000..750889a Binary files /dev/null and b/examples/mask deduplicate.png differ diff --git a/examples/mask expand.png b/examples/mask expand.png new file mode 100644 index 0000000..5e6b7aa Binary files /dev/null and b/examples/mask expand.png differ diff --git a/examples/mask index.png b/examples/mask index.png new file mode 100644 index 0000000..c64ebc3 Binary files /dev/null and b/examples/mask index.png differ diff --git a/examples/mask sort.png b/examples/mask sort.png new file mode 100644 index 0000000..71ffd06 Binary files /dev/null and b/examples/mask sort.png differ diff --git a/nodes/convert.py b/nodes/convert.py index 935f660..54d2be4 100644 --- a/nodes/convert.py +++ b/nodes/convert.py @@ -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."}), } } diff --git a/nodes/dedup.py b/nodes/dedup.py index 88db0fe..8da2f2e 100644 --- a/nodes/dedup.py +++ b/nodes/dedup.py @@ -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" diff --git a/nodes/expand.py b/nodes/expand.py index c7eac8e..305e5ad 100644 --- a/nodes/expand.py +++ b/nodes/expand.py @@ -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."}), } } diff --git a/nodes/pick.py b/nodes/pick.py index dce0043..11f1eba 100644 --- a/nodes/pick.py +++ b/nodes/pick.py @@ -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."}), } } diff --git a/nodes/sort.py b/nodes/sort.py index f004a98..23368e8 100644 --- a/nodes/sort.py +++ b/nodes/sort.py @@ -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"