Add max and min operations to MaskComposite.

The and/or/xor operations round both masks to 0 or 1 before combining,
so they discard feathering. There was previously no way to take the
union or intersection of two soft masks without losing intermediate
values. max and min are the standard fuzzy-set equivalents and preserve
them.

Also documents on the operation widget which ops are arithmetic and
which binarize, since the distinction is not visible from the names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
NubeBuster 2026-08-06 01:22:56 +02:00 committed by Mark Cockram
parent aaabf34258
commit 39e79a925b
2 changed files with 63 additions and 1 deletions

View File

@ -262,7 +262,7 @@ class MaskComposite(IO.ComfyNode):
IO.Mask.Input("source"),
IO.Int.Input("x", default=0, min=0, max=nodes.MAX_RESOLUTION, step=1),
IO.Int.Input("y", default=0, min=0, max=nodes.MAX_RESOLUTION, step=1),
IO.Combo.Input("operation", options=["multiply", "add", "subtract", "and", "or", "xor"]),
IO.Combo.Input("operation", options=["multiply", "add", "subtract", "and", "or", "xor", "max", "min"], tooltip="How to combine the two masks. \"multiply\", \"add\", \"subtract\", \"max\" and \"min\" are arithmetic and preserve intermediate (feathered) mask values. \"and\", \"or\" and \"xor\" are boolean: they round each mask to 0 or 1 first, discarding any feathering. Use \"max\"/\"min\" for union/intersection of soft masks."),
],
outputs=[IO.Mask.Output()],
)
@ -292,6 +292,10 @@ class MaskComposite(IO.ComfyNode):
output[:, top:bottom, left:right] = torch.bitwise_or(destination_portion.round().bool(), source_portion.round().bool()).float()
elif operation == "xor":
output[:, top:bottom, left:right] = torch.bitwise_xor(destination_portion.round().bool(), source_portion.round().bool()).float()
elif operation == "max":
output[:, top:bottom, left:right] = torch.max(destination_portion, source_portion)
elif operation == "min":
output[:, top:bottom, left:right] = torch.min(destination_portion, source_portion)
output = torch.clamp(output, 0.0, 1.0)

View File

@ -0,0 +1,58 @@
import pytest
import torch
from unittest.mock import patch, MagicMock
mock_nodes = MagicMock()
mock_nodes.MAX_RESOLUTION = 16384
mock_server = MagicMock()
with patch.dict("sys.modules", {"nodes": mock_nodes, "server": mock_server}):
from comfy_extras.nodes_mask import MaskComposite
class TestMaskCompositeOperations:
@staticmethod
def _exec(destination, source, operation):
d = torch.tensor([[destination]])
s = torch.tensor([[source]])
return MaskComposite.execute(d, s, 0, 0, operation).result[0].flatten().tolist()
def test_max_is_union_of_soft_masks(self):
result = self._exec([0.0, 0.25, 0.75, 1.0], [0.5, 0.5, 0.5, 0.5], "max")
assert result == pytest.approx([0.5, 0.5, 0.75, 1.0])
def test_min_is_intersection_of_soft_masks(self):
result = self._exec([0.0, 0.25, 0.75, 1.0], [0.5, 0.5, 0.5, 0.5], "min")
assert result == pytest.approx([0.0, 0.25, 0.5, 0.5])
def test_max_preserves_intermediate_values(self):
# Unlike "or", max must not round feathered values to 0 or 1.
result = self._exec([0.25, 0.75], [0.0, 0.0], "max")
assert result == pytest.approx([0.25, 0.75])
def test_min_preserves_intermediate_values(self):
result = self._exec([0.25, 0.75], [1.0, 1.0], "min")
assert result == pytest.approx([0.25, 0.75])
def test_or_binarizes(self):
# Documents existing behaviour that motivates max/min.
result = self._exec([0.25, 0.75], [0.0, 0.0], "or")
assert result == pytest.approx([0.0, 1.0])
def test_max_is_commutative(self):
a = self._exec([0.3, 0.8], [0.6, 0.1], "max")
b = self._exec([0.6, 0.1], [0.3, 0.8], "max")
assert a == pytest.approx(b)
def test_min_is_commutative(self):
a = self._exec([0.3, 0.8], [0.6, 0.1], "min")
b = self._exec([0.6, 0.1], [0.3, 0.8], "min")
assert a == pytest.approx(b)
def test_max_with_empty_mask_is_identity(self):
result = self._exec([0.0, 0.4, 1.0], [0.0, 0.0, 0.0], "max")
assert result == pytest.approx([0.0, 0.4, 1.0])
def test_min_with_full_mask_is_identity(self):
result = self._exec([0.0, 0.4, 1.0], [1.0, 1.0, 1.0], "min")
assert result == pytest.approx([0.0, 0.4, 1.0])