Add position-based mask pairing

This commit is contained in:
KunLnn
2026-06-23 21:13:30 +08:00
parent a824ed66a5
commit 2cdc313548
3 changed files with 238 additions and 4 deletions
+2 -2
View File
@@ -67,14 +67,14 @@ Inputs:
- `rows`: Number of equal-height bbox grid rows. Default is `4`.
- `columns`: Number of equal-width bbox grid columns. Default is `1`.
- `auto_match_orientation`: Automatically makes the longer split direction follow the longer side of the `mask1` product bbox. Default is enabled.
- `match_mode`: Controls how multiple input masks are paired. `union` unions both sides into one product, `repeat_mask1` repeats one shared `mask1` reference for every `mask2` product, and `pair_by_index` pairs individual `mask1` and `mask2` products by input order. Default is `union`.
- `match_mode`: Controls how multiple input masks are paired. `union` unions both sides into one product, `repeat_mask1` repeats one shared `mask1` reference for every `mask2` product, `pair_by_index` pairs individual products by input order, and `pair_by_position` pairs products by normalized spatial position. Default is `union`.
Outputs:
- `mask1`: First sliced/grouped mask batch.
- `mask2`: Second mask batch aligned to `mask1`. When only one input is connected, this is an empty placeholder batch.
The node splits disconnected painted islands internally, removes components below `min_area_ratio`, unions the remaining regions into one product mask, and slices that product bbox into a row-major grid: top to bottom, left to right. When `auto_match_orientation` is enabled, the larger of `rows` and `columns` follows the longer side of the product bbox: `rows=4, columns=1` stays 4x1 for vertical products and becomes 1x4 for horizontal products, while `rows=1, columns=3` becomes 3x1 for vertical products and stays 1x3 for horizontal products. In `repeat_mask1` mode, `mask2` is not unioned into one bbox; each `mask2` product gets its own matched output set using the same shared `mask1` reference slices. In `pair_by_index` mode, `mask1[i]` is paired with `mask2[i]`, and the last mask on the shorter side is reused when counts differ. In `mask` mode, empty `mask1` cells are skipped and `mask2` keeps the same remaining cell positions. In `bbox` mode, the full `rows × columns` rectangular grid is preserved. Use multiple rows and columns for two-dimensional local regions.
The node splits disconnected painted islands internally, removes components below `min_area_ratio`, unions the remaining regions into one product mask, and slices that product bbox into a row-major grid: top to bottom, left to right. When `auto_match_orientation` is enabled, the larger of `rows` and `columns` follows the longer side of the product bbox: `rows=4, columns=1` stays 4x1 for vertical products and becomes 1x4 for horizontal products, while `rows=1, columns=3` becomes 3x1 for vertical products and stays 1x3 for horizontal products. In `repeat_mask1` mode, `mask2` is not unioned into one bbox; each `mask2` product gets its own matched output set using the same shared `mask1` reference slices. In `pair_by_index` mode, `mask1[i]` is paired with `mask2[i]`, and the last mask on the shorter side is reused when counts differ. In `pair_by_position` mode, products are paired by relative position inside each mask group's union bbox, making it the recommended mode when SAM or detection nodes return multiple product masks in inconsistent order. In `mask` mode, empty `mask1` cells are skipped and `mask2` keeps the same remaining cell positions. In `bbox` mode, the full `rows × columns` rectangular grid is preserved. Use multiple rows and columns for two-dimensional local regions.
### RefineNode Match Product Angle
+234
View File
@@ -441,6 +441,184 @@ def bbox_grid_sliced_masks(
return output
def product_mask_descriptors(mask_images: list[Image.Image], min_area_ratio: float) -> list[dict[str, Any]]:
descriptors: list[dict[str, Any]] = []
for index, mask_l in enumerate(mask_images):
filtered = filtered_union_mask([mask_l], min_area_ratio)
bbox = bbox_from_mask_or_none(filtered)
if bbox is None:
continue
x1, y1, x2, y2 = bbox
descriptors.append(
{
"index": index,
"mask": filtered,
"bbox": bbox,
"center_x": (x1 + x2) * 0.5,
"center_y": (y1 + y2) * 0.5,
"area": max(1, mask_foreground_area(filtered)),
"aspect": max(1e-6, (x2 - x1) / max(1, y2 - y1)),
}
)
if not descriptors:
return []
union_bbox = (
min(desc["bbox"][0] for desc in descriptors),
min(desc["bbox"][1] for desc in descriptors),
max(desc["bbox"][2] for desc in descriptors),
max(desc["bbox"][3] for desc in descriptors),
)
ux1, uy1, ux2, uy2 = union_bbox
union_width = max(1, ux2 - ux1)
union_height = max(1, uy2 - uy1)
union_area = max(1, union_width * union_height)
for desc in descriptors:
x1, y1, x2, y2 = desc["bbox"]
desc["norm_x"] = (desc["center_x"] - ux1) / union_width
desc["norm_y"] = (desc["center_y"] - uy1) / union_height
desc["norm_left"] = (x1 - ux1) / union_width
desc["norm_top"] = (y1 - uy1) / union_height
desc["norm_right"] = (x2 - ux1) / union_width
desc["norm_bottom"] = (y2 - uy1) / union_height
desc["norm_area"] = desc["area"] / union_area
return descriptors
def spatially_order_product_descriptors(descriptors: list[dict[str, Any]]) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for desc in sorted(descriptors, key=lambda item: (item["norm_y"], item["norm_x"], item["index"])):
placed = False
for row in rows:
overlap = min(desc["norm_bottom"], row["bottom"]) - max(desc["norm_top"], row["top"])
row_height = max(1e-6, row["bottom"] - row["top"])
desc_height = max(1e-6, desc["norm_bottom"] - desc["norm_top"])
center_delta = abs(desc["norm_y"] - row["center"])
if overlap >= -0.03 or center_delta <= max(0.08, 0.5 * max(row_height, desc_height)):
row["items"].append(desc)
row["top"] = min(row["top"], desc["norm_top"])
row["bottom"] = max(row["bottom"], desc["norm_bottom"])
row["center"] = sum(item["norm_y"] for item in row["items"]) / len(row["items"])
placed = True
break
if not placed:
rows.append(
{
"top": desc["norm_top"],
"bottom": desc["norm_bottom"],
"center": desc["norm_y"],
"items": [desc],
}
)
ordered: list[dict[str, Any]] = []
for row in sorted(rows, key=lambda item: (item["center"], item["top"])):
ordered.extend(sorted(row["items"], key=lambda item: (item["norm_x"], item["norm_y"], item["index"])))
return ordered
def product_position_match_score(left: dict[str, Any], right: dict[str, Any]) -> float:
center_distance = math.hypot(left["norm_x"] - right["norm_x"], left["norm_y"] - right["norm_y"])
area_delta = abs(math.log(max(left["norm_area"], 1e-6) / max(right["norm_area"], 1e-6)))
aspect_delta = abs(math.log(max(left["aspect"], 1e-6) / max(right["aspect"], 1e-6)))
return center_distance * 10.0 + area_delta * 0.75 + aspect_delta * 0.25
def greedy_product_position_assignment(
left: list[dict[str, Any]],
right: list[dict[str, Any]],
) -> list[tuple[dict[str, Any], dict[str, Any]]]:
candidates = [
(product_position_match_score(left_item, right_item), left_index, right_index)
for left_index, left_item in enumerate(left)
for right_index, right_item in enumerate(right)
]
candidates.sort()
used_left: set[int] = set()
used_right: set[int] = set()
pairs: list[tuple[dict[str, Any], dict[str, Any]]] = []
for _, left_index, right_index in candidates:
if left_index in used_left or right_index in used_right:
continue
used_left.add(left_index)
used_right.add(right_index)
pairs.append((left[left_index], right[right_index]))
if len(pairs) == min(len(left), len(right)):
break
left_order = {id(item): index for index, item in enumerate(left)}
pairs.sort(key=lambda pair: left_order[id(pair[0])])
return pairs
def minimum_product_position_assignment(
left: list[dict[str, Any]],
right: list[dict[str, Any]],
) -> list[tuple[dict[str, Any], dict[str, Any]]]:
if len(left) != len(right):
return greedy_product_position_assignment(left, right)
count = len(left)
if count > 16:
return greedy_product_position_assignment(left, right)
memo: dict[tuple[int, int], tuple[float, tuple[int, ...]]] = {}
def solve(left_index: int, used_mask: int) -> tuple[float, tuple[int, ...]]:
key = (left_index, used_mask)
if key in memo:
return memo[key]
if left_index >= count:
return 0.0, ()
best_cost = float("inf")
best_order: tuple[int, ...] = ()
for right_index in range(count):
bit = 1 << right_index
if used_mask & bit:
continue
rest_cost, rest_order = solve(left_index + 1, used_mask | bit)
total_cost = product_position_match_score(left[left_index], right[right_index]) + rest_cost
order = (right_index,) + rest_order
if total_cost < best_cost - 1e-9 or (abs(total_cost - best_cost) <= 1e-9 and order < best_order):
best_cost = total_cost
best_order = order
memo[key] = best_cost, best_order
return memo[key]
_, right_order = solve(0, 0)
return [(left[index], right[right_index]) for index, right_index in enumerate(right_order)]
def nearest_product_descriptor(
source: dict[str, Any],
candidates: list[dict[str, Any]],
) -> dict[str, Any]:
return min(
candidates,
key=lambda item: (product_position_match_score(source, item), item["norm_y"], item["norm_x"], item["index"]),
)
def position_matched_product_pairs(
mask1_descriptors: list[dict[str, Any]],
mask2_descriptors: list[dict[str, Any]],
) -> list[tuple[dict[str, Any], dict[str, Any]]]:
ordered_mask1 = spatially_order_product_descriptors(mask1_descriptors)
ordered_mask2 = spatially_order_product_descriptors(mask2_descriptors)
if len(ordered_mask1) == len(ordered_mask2):
return minimum_product_position_assignment(ordered_mask1, ordered_mask2)
if len(ordered_mask1) > len(ordered_mask2):
return [(mask1_item, nearest_product_descriptor(mask1_item, ordered_mask2)) for mask1_item in ordered_mask1]
pairs = [
(nearest_product_descriptor(mask2_item, ordered_mask1), mask2_item)
for mask2_item in ordered_mask2
]
mask1_order = {id(item): index for index, item in enumerate(ordered_mask1)}
pairs.sort(key=lambda pair: (mask1_order[id(pair[0])], pair[1]["norm_y"], pair[1]["norm_x"], pair[1]["index"]))
return pairs
def slice_mask_pair_by_product_bbox(
mask1_images: list[Image.Image],
mask2_images: list[Image.Image] | None,
@@ -517,6 +695,62 @@ def slice_masks_by_product_bbox(
output_mask2.extend(pair_mask2)
return output_mask1, output_mask2
if match_mode == "pair_by_position":
output_mask1: list[Image.Image] = []
output_mask2: list[Image.Image] = []
mask1_descriptors = product_mask_descriptors(mask1_images, min_area_ratio)
mask1_size = mask1_images[0].size if mask1_images else (1, 1)
if not mask1_descriptors:
mask2_size = mask2_images[0].size if mask2_images else mask1_size
return ([Image.new("L", mask1_size, 0)], [Image.new("L", mask2_size, 0)])
if not mask2_images:
for mask1_desc in spatially_order_product_descriptors(mask1_descriptors):
pair_mask1, pair_mask2 = slice_mask_pair_by_product_bbox(
[mask1_desc["mask"]],
None,
min_area_ratio,
rows,
columns,
auto_match_orientation,
output_mode,
)
output_mask1.extend(pair_mask1)
output_mask2.extend(pair_mask2)
return output_mask1, output_mask2
mask2_descriptors = product_mask_descriptors(mask2_images, min_area_ratio)
if not mask2_descriptors:
mask2_size = mask2_images[0].size
for mask1_desc in spatially_order_product_descriptors(mask1_descriptors):
pair_mask1, pair_mask2 = slice_mask_pair_by_product_bbox(
[mask1_desc["mask"]],
[Image.new("L", mask2_size, 0)],
min_area_ratio,
rows,
columns,
auto_match_orientation,
output_mode,
)
output_mask1.extend(pair_mask1)
output_mask2.extend(pair_mask2)
return output_mask1, output_mask2
for mask1_desc, mask2_desc in position_matched_product_pairs(mask1_descriptors, mask2_descriptors):
pair_mask1, pair_mask2 = slice_mask_pair_by_product_bbox(
[mask1_desc["mask"]],
[mask2_desc["mask"]],
min_area_ratio,
rows,
columns,
auto_match_orientation,
output_mode,
)
output_mask1.extend(pair_mask1)
output_mask2.extend(pair_mask2)
return output_mask1, output_mask2
if match_mode == "pair_by_index":
if not mask2_images:
output_mask1: list[Image.Image] = []
+2 -2
View File
@@ -69,7 +69,7 @@ class RefineNodeSliceAndMatchMasks:
"rows": ("INT", {"default": 4, "min": 1, "max": 16}),
"columns": ("INT", {"default": 1, "min": 1, "max": 16}),
"auto_match_orientation": ("BOOLEAN", {"default": True}),
"match_mode": (["union", "repeat_mask1", "pair_by_index"], {"default": "union"}),
"match_mode": (["union", "repeat_mask1", "pair_by_index", "pair_by_position"], {"default": "union"}),
},
"optional": {
"mask1": ("MASK",),
@@ -98,7 +98,7 @@ class RefineNodeSliceAndMatchMasks:
rows = clamp_int_value(rows, 4, 1, 16)
columns = clamp_int_value(columns, 1, 1, 16)
auto_match_orientation = bool(auto_match_orientation[0]) if isinstance(auto_match_orientation, list) else bool(auto_match_orientation)
match_mode = normalize_choice(match_mode, "union", {"union", "repeat_mask1", "pair_by_index"})
match_mode = normalize_choice(match_mode, "union", {"union", "repeat_mask1", "pair_by_index", "pair_by_position"})
output_mode = normalize_choice(output_mode, "bbox", {"mask", "bbox"})
if mask1 is None and mask2 is None:
refine_error("RefineNode Slice And Match Masks", "Connect at least one mask input.")