制作MSAK处理 节点

This commit is contained in:
Gin3601
2026-05-25 13:58:50 +08:00
parent fd5a2b9de2
commit b2c98aa65c
10 changed files with 723 additions and 17 deletions
+1 -1
View File
@@ -136,8 +136,8 @@ node/
| 名称 | 类型 | 说明 |
|------|------|------|
| `mask` | `MASK` | 最终遮罩,白色 / `1` 表示要重绘,黑色 / `0` 表示不重绘 |
| `mask_image` | `IMAGE` | 将 `mask` 直接可视化为黑白图像,方便单独预览 |
| `mask` | `MASK` | 最终遮罩,白色 / `1` 表示要重绘,黑色 / `0` 表示不重绘 |
| `area_info` | `LLS_MASK_INFO` | 包含面积、占比、bbox、坐标模式等信息的 dict |
`area_info` 主要包含:
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
from .utils import align_mask_batch, normalize_mask, resize_mask_to_hw, torch
COMBINE_MODE_CHOICES = ["add", "subtract", "intersect", "xor", "max", "min"]
class LLSMaskCombine:
CATEGORY = "LLS/Mask"
FUNCTION = "combine"
RETURN_TYPES = ("MASK",)
RETURN_NAMES = ("mask",)
DESCRIPTION = "Combine two masks with logical-style operations."
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"mask_a": ("MASK",),
"mask_b": ("MASK",),
"mode": (COMBINE_MODE_CHOICES, {"default": "add"}),
}
}
def combine(self, mask_a, mask_b, mode):
mask_a = normalize_mask(mask_a)
mask_b = align_mask_batch(
resize_mask_to_hw(normalize_mask(mask_b), int(mask_a.shape[1]), int(mask_a.shape[2])),
int(mask_a.shape[0]),
)
mode = str(mode or "add")
if mode in {"add", "max"}:
combined = torch.maximum(mask_a, mask_b)
elif mode == "subtract":
combined = torch.clamp(mask_a - mask_b, 0.0, 1.0)
elif mode in {"intersect", "min"}:
combined = torch.minimum(mask_a, mask_b)
elif mode == "xor":
combined = torch.abs(mask_a - mask_b)
else:
raise RuntimeError(f"[LLS] Unsupported mask combine mode '{mode}'.")
return (combined.clamp(0.0, 1.0).to(dtype=mask_a.dtype),)
NODE_CLASS_MAPPINGS = {"LLSMaskCombine": LLSMaskCombine}
NODE_DISPLAY_NAME_MAPPINGS = {"LLSMaskCombine": "LLS Mask Combine"}
+3 -3
View File
@@ -27,8 +27,8 @@ def _resolve_batch_for_mask_creation(input_mask) -> int:
class LLSSimpleMaskCreate:
CATEGORY = "LLS/Mask"
FUNCTION = "create_mask"
RETURN_TYPES = ("MASK", "IMAGE", MASK_INFO_TYPE)
RETURN_NAMES = ("mask", "mask_image", "area_info")
RETURN_TYPES = ("IMAGE", "MASK", MASK_INFO_TYPE)
RETURN_NAMES = ("mask_image", "mask", "area_info")
DESCRIPTION = "Create a geometric mask image from width, height, and shape parameters."
@classmethod
@@ -102,7 +102,7 @@ class LLSSimpleMaskCreate:
invert_mask=bool(invert_mask),
combine_mode=str(combine_mode or "replace"),
)
return final_mask, mask_image, area_info
return mask_image, final_mask, area_info
NODE_CLASS_MAPPINGS = {"LLSSimpleMaskCreate": LLSSimpleMaskCreate}
+99
View File
@@ -0,0 +1,99 @@
from __future__ import annotations
from .utils import (
apply_blur,
apply_grow,
apply_shrink,
align_mask_batch,
fill_mask_holes,
normalize_mask,
remove_small_mask_regions,
resize_mask_to_hw,
resolve_image_size,
)
PROCESS_OPERATION_CHOICES = [
"passthrough",
"threshold",
"invert",
"grow",
"shrink",
"blur",
"feather",
"fill_holes",
"remove_small_regions",
"smooth",
"clamp",
"resize_to_image",
]
class LLSMaskProcess:
CATEGORY = "LLS/Mask"
FUNCTION = "process"
RETURN_TYPES = ("MASK",)
RETURN_NAMES = ("mask",)
DESCRIPTION = "Apply common processing operations to a single mask."
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"mask": ("MASK",),
"operation": (PROCESS_OPERATION_CHOICES, {"default": "passthrough"}),
"value_float": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
"value_int": ("INT", {"default": 8, "min": -512, "max": 512, "step": 1}),
},
"optional": {
"image": ("IMAGE",),
},
}
def process(self, mask, operation, value_float, value_int, image=None):
normalized = normalize_mask(mask)
operation = str(operation or "passthrough")
if operation == "passthrough":
processed = normalized
elif operation == "threshold":
processed = (normalized >= float(value_float)).to(dtype=normalized.dtype)
elif operation == "invert":
processed = 1.0 - normalized
elif operation == "grow":
processed = apply_grow(normalized, value_int)
elif operation == "shrink":
processed = apply_shrink(normalized, value_int)
elif operation == "blur":
processed = apply_blur(normalized, value_int)
elif operation == "feather":
processed = apply_blur(normalized, value_int)
elif operation == "fill_holes":
try:
processed = fill_mask_holes(normalized)
except Exception:
processed = normalized
elif operation == "remove_small_regions":
try:
processed = remove_small_mask_regions(normalized, value_int)
except Exception:
processed = normalized
elif operation == "smooth":
processed = apply_blur(normalized, max(1, int(value_int) if int(value_int) > 0 else 1))
elif operation == "clamp":
processed = normalized
elif operation == "resize_to_image":
image_size = resolve_image_size(image)
if image_size is None:
processed = normalized
else:
batch, height, width = image_size
processed = align_mask_batch(resize_mask_to_hw(normalized, height, width), batch)
else:
raise RuntimeError(f"[LLS] Unsupported mask operation '{operation}'.")
return (processed.clamp(0.0, 1.0).to(dtype=normalized.dtype),)
NODE_CLASS_MAPPINGS = {"LLSMaskProcess": LLSMaskProcess}
NODE_DISPLAY_NAME_MAPPINGS = {"LLSMaskProcess": "LLS Mask Process"}
+12
View File
@@ -7,11 +7,23 @@ from .mask_create import (
NODE_CLASS_MAPPINGS as MASK_CREATE_NODE_CLASS_MAPPINGS,
NODE_DISPLAY_NAME_MAPPINGS as MASK_CREATE_NODE_DISPLAY_NAME_MAPPINGS,
)
from .mask_process import (
NODE_CLASS_MAPPINGS as MASK_PROCESS_NODE_CLASS_MAPPINGS,
NODE_DISPLAY_NAME_MAPPINGS as MASK_PROCESS_NODE_DISPLAY_NAME_MAPPINGS,
)
from .mask_combine import (
NODE_CLASS_MAPPINGS as MASK_COMBINE_NODE_CLASS_MAPPINGS,
NODE_DISPLAY_NAME_MAPPINGS as MASK_COMBINE_NODE_DISPLAY_NAME_MAPPINGS,
)
NODE_CLASS_MAPPINGS = {}
NODE_CLASS_MAPPINGS.update(MASK_CREATE_NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(MASK_PROCESS_NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(MASK_COMBINE_NODE_CLASS_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS.update(MASK_CREATE_NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(MASK_PROCESS_NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(MASK_COMBINE_NODE_DISPLAY_NAME_MAPPINGS)
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+253
View File
@@ -0,0 +1,253 @@
from __future__ import annotations
from collections import deque
import math
try:
import numpy as np
except Exception as exc: # pragma: no cover - optional runtime dependency
np = None
_NUMPY_ERR = exc
else: # pragma: no cover - exercised in runtime/tests
_NUMPY_ERR = None
try:
import torch
import torch.nn.functional as torch_nn_functional
except Exception as exc: # pragma: no cover - optional runtime dependency
torch = None
torch_nn_functional = None
_TORCH_ERR = exc
else: # pragma: no cover - exercised in runtime/tests
_TORCH_ERR = None
def _require_torch():
if torch is None or torch_nn_functional is None:
raise RuntimeError("[LLS] torch is required for mask processing nodes.") from _TORCH_ERR
def _coerce_non_negative_int(value) -> int:
try:
return max(0, int(value))
except Exception:
return 0
def normalize_mask(mask):
_require_torch()
if mask is None:
raise RuntimeError("[LLS] mask input is required.")
if not isinstance(mask, torch.Tensor):
mask = torch.as_tensor(mask)
ndim = int(mask.ndim)
if ndim == 2:
normalized = mask.unsqueeze(0)
elif ndim == 3:
normalized = mask
elif ndim == 4:
if int(mask.shape[-1]) == 1:
normalized = mask.squeeze(-1)
elif int(mask.shape[1]) == 1:
normalized = mask.squeeze(1)
else:
raise RuntimeError(
"[LLS] mask must have shape [H,W], [B,H,W], [B,H,W,1], or [B,1,H,W]."
)
else:
raise RuntimeError(
"[LLS] mask must have shape [H,W], [B,H,W], [B,H,W,1], or [B,1,H,W]."
)
return normalized.to(dtype=torch.float32).clamp(0.0, 1.0)
def resize_mask_to_hw(mask, height, width):
normalized = normalize_mask(mask)
target_height = max(1, int(height))
target_width = max(1, int(width))
if tuple(normalized.shape[1:]) == (target_height, target_width):
return normalized
padded = normalized.unsqueeze(1)
resized = torch_nn_functional.interpolate(
padded,
size=(target_height, target_width),
mode="bilinear",
align_corners=False,
)
return resized.squeeze(1).clamp(0.0, 1.0).to(dtype=torch.float32)
def align_mask_batch(mask, target_batch):
normalized = normalize_mask(mask)
batch = int(normalized.shape[0])
target = max(1, int(target_batch))
if batch == target:
return normalized
if batch == 1:
return normalized.repeat(target, 1, 1)
if batch < target:
repeats = int(math.ceil(target / batch))
return normalized.repeat(repeats, 1, 1)[:target]
return normalized[:target]
def apply_grow(mask, radius):
normalized = normalize_mask(mask)
grow_radius = _coerce_non_negative_int(radius)
if grow_radius == 0:
return normalized
kernel_size = (grow_radius * 2) + 1
grown = torch_nn_functional.max_pool2d(
normalized.unsqueeze(1),
kernel_size=kernel_size,
stride=1,
padding=grow_radius,
)
return grown.squeeze(1).clamp(0.0, 1.0).to(dtype=torch.float32)
def apply_shrink(mask, radius):
normalized = normalize_mask(mask)
shrink_radius = _coerce_non_negative_int(radius)
if shrink_radius == 0:
return normalized
inverted = 1.0 - normalized
kernel_size = (shrink_radius * 2) + 1
pooled = torch_nn_functional.max_pool2d(
inverted.unsqueeze(1),
kernel_size=kernel_size,
stride=1,
padding=shrink_radius,
)
return (1.0 - pooled.squeeze(1)).clamp(0.0, 1.0).to(dtype=torch.float32)
def apply_blur(mask, radius):
normalized = normalize_mask(mask)
blur_radius = _coerce_non_negative_int(radius)
if blur_radius == 0:
return normalized
kernel_size = (blur_radius * 2) + 1
padded = torch_nn_functional.pad(
normalized.unsqueeze(1),
(blur_radius, blur_radius, blur_radius, blur_radius),
mode="replicate",
)
blurred = torch_nn_functional.avg_pool2d(
padded,
kernel_size=kernel_size,
stride=1,
)
return blurred.squeeze(1).clamp(0.0, 1.0).to(dtype=torch.float32)
def fill_mask_holes(mask):
normalized = normalize_mask(mask)
if np is None:
return normalized
binary = (normalized > 0.5).detach().cpu().numpy().astype(np.uint8)
filled = np.zeros_like(binary, dtype=np.float32)
for index, sample in enumerate(binary):
filled[index] = _fill_holes_binary(sample)
return torch.from_numpy(filled).to(device=normalized.device, dtype=torch.float32).clamp(0.0, 1.0)
def remove_small_mask_regions(mask, min_area):
normalized = normalize_mask(mask)
area_threshold = _coerce_non_negative_int(min_area)
if area_threshold <= 1 or np is None:
return normalized
binary = (normalized > 0.5).detach().cpu().numpy().astype(np.uint8)
cleaned = np.zeros_like(binary, dtype=np.float32)
for index, sample in enumerate(binary):
cleaned[index] = _remove_small_regions_binary(sample, area_threshold)
return torch.from_numpy(cleaned).to(device=normalized.device, dtype=torch.float32).clamp(0.0, 1.0)
def resolve_image_size(image):
shape = tuple(getattr(image, "shape", ()))
if len(shape) != 4:
return None
batch = max(1, int(shape[0]))
height = max(1, int(shape[1]))
width = max(1, int(shape[2]))
return batch, height, width
def _fill_holes_binary(sample):
height, width = sample.shape
visited = np.zeros((height, width), dtype=bool)
queue = deque()
def enqueue(y, x):
if 0 <= y < height and 0 <= x < width and sample[y, x] == 0 and not visited[y, x]:
visited[y, x] = True
queue.append((y, x))
for x in range(width):
enqueue(0, x)
enqueue(height - 1, x)
for y in range(height):
enqueue(y, 0)
enqueue(y, width - 1)
while queue:
y, x = queue.popleft()
enqueue(y - 1, x)
enqueue(y + 1, x)
enqueue(y, x - 1)
enqueue(y, x + 1)
result = sample.copy()
result[(sample == 0) & (~visited)] = 1
return result.astype(np.float32)
def _remove_small_regions_binary(sample, min_area):
height, width = sample.shape
visited = np.zeros((height, width), dtype=bool)
result = np.zeros((height, width), dtype=np.float32)
for start_y in range(height):
for start_x in range(width):
if sample[start_y, start_x] == 0 or visited[start_y, start_x]:
continue
queue = deque([(start_y, start_x)])
visited[start_y, start_x] = True
region = []
while queue:
y, x = queue.popleft()
region.append((y, x))
for next_y, next_x in (
(y - 1, x),
(y + 1, x),
(y, x - 1),
(y, x + 1),
):
if (
0 <= next_y < height
and 0 <= next_x < width
and sample[next_y, next_x] == 1
and not visited[next_y, next_x]
):
visited[next_y, next_x] = True
queue.append((next_y, next_x))
if len(region) >= min_area:
for y, x in region:
result[y, x] = 1.0
return result
+11 -11
View File
@@ -68,7 +68,7 @@ class TestMaskCreateNode(unittest.TestCase):
return self.node.create_mask(**params)
def test_rectangle_percent_mode_creates_expected_bbox_and_area_info(self):
mask, mask_image, area_info = self._create()
mask_image, mask, area_info = self._create()
self.assertEqual(tuple(mask.shape), (1, 80, 100))
self.assertEqual(tuple(mask_image.shape), (1, 80, 100, 3))
@@ -88,7 +88,7 @@ class TestMaskCreateNode(unittest.TestCase):
self.assertEqual(float(mask_image[0, 5, 5, 0].item()), 0.0)
def test_square_pixel_mode_uses_width_as_side(self):
mask, _mask_image, area_info = self.node.create_mask(
_mask_image, mask, area_info = self.node.create_mask(
image_width=64,
image_height=64,
shape_type="square",
@@ -112,7 +112,7 @@ class TestMaskCreateNode(unittest.TestCase):
self.assertEqual(float(mask[0, 5, 5].item()), 0.0)
def test_circle_generates_centered_mask(self):
mask, _mask_image, area_info = self.node.create_mask(
_mask_image, mask, area_info = self.node.create_mask(
image_width=101,
image_height=101,
shape_type="circle",
@@ -136,7 +136,7 @@ class TestMaskCreateNode(unittest.TestCase):
self.assertEqual(float(mask[0, 10, 10].item()), 0.0)
def test_ellipse_generates_nonempty_mask(self):
mask, _mask_image, area_info = self.node.create_mask(
_mask_image, mask, area_info = self.node.create_mask(
image_width=120,
image_height=90,
shape_type="ellipse",
@@ -162,7 +162,7 @@ class TestMaskCreateNode(unittest.TestCase):
def test_union_subtract_and_intersect_with_input_mask(self):
input_mask = make_mask(width=32, height=32, value=1.0)
union_mask, _mask_image, union_info = self.node.create_mask(
_mask_image, union_mask, union_info = self.node.create_mask(
image_width=32,
image_height=32,
shape_type="rectangle",
@@ -181,7 +181,7 @@ class TestMaskCreateNode(unittest.TestCase):
self.assertEqual(union_info["binary_area_px"], 32 * 32)
self.assertTrue(torch.all(union_mask == 1.0))
subtract_mask, _mask_image, subtract_info = self.node.create_mask(
_mask_image, subtract_mask, subtract_info = self.node.create_mask(
image_width=32,
image_height=32,
shape_type="rectangle",
@@ -200,7 +200,7 @@ class TestMaskCreateNode(unittest.TestCase):
self.assertLess(subtract_info["binary_area_px"], 32 * 32)
self.assertEqual(float(subtract_mask[0, 16, 16].item()), 0.0)
intersect_mask, _mask_image, intersect_info = self.node.create_mask(
_mask_image, intersect_mask, intersect_info = self.node.create_mask(
image_width=32,
image_height=32,
shape_type="rectangle",
@@ -221,14 +221,14 @@ class TestMaskCreateNode(unittest.TestCase):
self.assertEqual(float(intersect_mask[0, 1, 1].item()), 0.0)
def test_invert_mask_flips_final_mask(self):
mask, _mask_image, area_info = self._create(invert_mask=True)
_mask_image, mask, area_info = self._create(invert_mask=True)
self.assertEqual(area_info["invert_mask"], True)
self.assertEqual(float(mask[0, 40, 50].item()), 0.0)
self.assertEqual(float(mask[0, 5, 5].item()), 1.0)
def test_feather_and_blur_create_soft_mask(self):
mask, _mask_image, area_info = self._create(
_mask_image, mask, area_info = self._create(
shape_type="rectangle",
coordinate_mode="pixel",
center_x=50.0,
@@ -244,7 +244,7 @@ class TestMaskCreateNode(unittest.TestCase):
self.assertTrue(bool(((mask > 0.0) & (mask < 1.0)).any().item()))
def test_shape_outside_image_returns_black_mask_without_crashing(self):
mask, _mask_image, area_info = self._create(
_mask_image, mask, area_info = self._create(
shape_type="circle",
coordinate_mode="percent",
center_x=2.0,
@@ -260,7 +260,7 @@ class TestMaskCreateNode(unittest.TestCase):
image = make_image(width=64, height=64, color=0.25)
input_mask = make_mask(width=16, height=16, value=1.0)
mask_out, _mask_image, area_info = self.node.create_mask(
_mask_image, mask_out, area_info = self.node.create_mask(
image_width=64,
image_height=64,
shape_type="rectangle",
+2 -2
View File
@@ -23,8 +23,8 @@ class TestMaskCreateRegistration(unittest.TestCase):
self.assertEqual(node_cls.CATEGORY, "LLS/Mask")
self.assertEqual(node_cls.FUNCTION, "create_mask")
self.assertEqual(node_cls.RETURN_TYPES, ("MASK", "IMAGE", "LLS_MASK_INFO"))
self.assertEqual(node_cls.RETURN_NAMES, ("mask", "mask_image", "area_info"))
self.assertEqual(node_cls.RETURN_TYPES, ("IMAGE", "MASK", "LLS_MASK_INFO"))
self.assertEqual(node_cls.RETURN_NAMES, ("mask_image", "mask", "area_info"))
required = schema["required"]
optional = schema["optional"]
+208
View File
@@ -0,0 +1,208 @@
import unittest
try:
from .test_mask_draw_helpers import load_plugin_package, torch
except ImportError:
from test_mask_draw_helpers import load_plugin_package, torch
HAS_MASK_PROCESS_RUNTIME_DEPS = torch is not None
MASK_PROCESS_RUNTIME_DEPS_MESSAGE = "mask processing node tests require torch"
def _require_mask_process_runtime_deps():
if not HAS_MASK_PROCESS_RUNTIME_DEPS:
raise unittest.SkipTest(MASK_PROCESS_RUNTIME_DEPS_MESSAGE)
@unittest.skipUnless(HAS_MASK_PROCESS_RUNTIME_DEPS, MASK_PROCESS_RUNTIME_DEPS_MESSAGE)
class TestMaskProcessNode(unittest.TestCase):
@classmethod
def setUpClass(cls):
plugin = load_plugin_package()
cls.process_node = plugin.NODE_CLASS_MAPPINGS["LLSMaskProcess"]()
cls.combine_node = plugin.NODE_CLASS_MAPPINGS["LLSMaskCombine"]()
def assert_is_normalized_mask(self, mask, shape):
self.assertEqual(tuple(mask.shape), shape)
self.assertEqual(mask.dtype, torch.float32)
self.assertGreaterEqual(float(mask.min().item()), 0.0)
self.assertLessEqual(float(mask.max().item()), 1.0)
def test_passthrough_normalizes_supported_mask_shapes(self):
cases = {
"hw": torch.tensor([[-1.0, 0.4], [1.2, 0.7]], dtype=torch.float64),
"bhw": torch.tensor([[[0.0, 1.0], [1.0, 0.0]]], dtype=torch.float32),
"bhw1": torch.tensor([[[[0.0], [1.0]], [[1.0], [0.0]]]], dtype=torch.float32),
"b1hw": torch.tensor([[[[0.0, 1.0], [1.0, 0.0]]]], dtype=torch.float32),
}
for name, source in cases.items():
with self.subTest(name=name):
(mask,) = self.process_node.process(
mask=source,
operation="passthrough",
value_float=0.5,
value_int=8,
)
self.assert_is_normalized_mask(mask, (1, 2, 2))
def test_threshold_and_invert_produce_expected_values(self):
source = torch.tensor([[[0.2, 0.5, 0.7]]], dtype=torch.float32)
(thresholded,) = self.process_node.process(
mask=source,
operation="threshold",
value_float=0.5,
value_int=8,
)
(inverted,) = self.process_node.process(
mask=source,
operation="invert",
value_float=0.5,
value_int=8,
)
self.assertTrue(
torch.equal(
thresholded,
torch.tensor([[[0.0, 1.0, 1.0]]], dtype=torch.float32),
)
)
self.assertTrue(
torch.allclose(
inverted,
torch.tensor([[[0.8, 0.5, 0.3]]], dtype=torch.float32),
)
)
def test_grow_and_shrink_do_not_error_and_keep_normalized_output(self):
source = torch.zeros((1, 5, 5), dtype=torch.float32)
source[0, 2, 2] = 1.0
(grown,) = self.process_node.process(
mask=source,
operation="grow",
value_float=0.5,
value_int=1,
)
(shrunk,) = self.process_node.process(
mask=grown,
operation="shrink",
value_float=0.5,
value_int=1,
)
self.assert_is_normalized_mask(grown, (1, 5, 5))
self.assert_is_normalized_mask(shrunk, (1, 5, 5))
self.assertGreater(float(grown.sum().item()), float(source.sum().item()))
self.assertLessEqual(float(shrunk.sum().item()), float(grown.sum().item()))
def test_blur_and_feather_do_not_error_and_create_soft_edges(self):
source = torch.zeros((1, 7, 7), dtype=torch.float32)
source[:, 2:5, 2:5] = 1.0
(blurred,) = self.process_node.process(
mask=source,
operation="blur",
value_float=0.5,
value_int=1,
)
(feathered,) = self.process_node.process(
mask=source,
operation="feather",
value_float=0.5,
value_int=2,
)
self.assert_is_normalized_mask(blurred, (1, 7, 7))
self.assert_is_normalized_mask(feathered, (1, 7, 7))
self.assertTrue(bool(((blurred > 0.0) & (blurred < 1.0)).any().item()))
self.assertTrue(bool(((feathered > 0.0) & (feathered < 1.0)).any().item()))
def test_fill_holes_remove_small_regions_smooth_and_clamp_are_stable(self):
with_hole = torch.ones((1, 5, 5), dtype=torch.float32)
with_hole[0, 2, 2] = 0.0
(filled,) = self.process_node.process(
mask=with_hole,
operation="fill_holes",
value_float=0.5,
value_int=8,
)
self.assertEqual(float(filled[0, 2, 2].item()), 1.0)
with_small_region = torch.zeros((1, 6, 6), dtype=torch.float32)
with_small_region[0, 0, 0] = 1.0
with_small_region[0, 3:5, 3:5] = 1.0
(removed,) = self.process_node.process(
mask=with_small_region,
operation="remove_small_regions",
value_float=0.5,
value_int=2,
)
self.assertEqual(float(removed[0, 0, 0].item()), 0.0)
self.assertEqual(float(removed[0, 3, 3].item()), 1.0)
jagged = torch.tensor([[[0.0, 1.0, 0.0], [1.0, 1.0, 1.0], [0.0, 1.0, 0.0]]], dtype=torch.float32)
(smoothed,) = self.process_node.process(
mask=jagged,
operation="smooth",
value_float=0.5,
value_int=1,
)
(clamped,) = self.process_node.process(
mask=torch.tensor([[[-2.0, 2.0]]], dtype=torch.float32),
operation="clamp",
value_float=0.5,
value_int=8,
)
self.assert_is_normalized_mask(smoothed, (1, 3, 3))
self.assert_is_normalized_mask(clamped, (1, 1, 2))
def test_resize_to_image_uses_optional_image_dimensions_and_batch(self):
source = torch.ones((1, 2, 3), dtype=torch.float32)
image = torch.zeros((2, 5, 7, 3), dtype=torch.float32)
(resized,) = self.process_node.process(
mask=source,
operation="resize_to_image",
value_float=0.5,
value_int=8,
image=image,
)
self.assert_is_normalized_mask(resized, (2, 5, 7))
self.assertTrue(torch.allclose(resized, torch.ones((2, 5, 7), dtype=torch.float32)))
def test_mask_combine_modes_produce_expected_values(self):
mask_a = torch.tensor([[[1.0, 0.5], [0.0, 1.0]]], dtype=torch.float32)
mask_b = torch.tensor([[[0.5, 1.0], [0.0, 0.25]]], dtype=torch.float32)
expectations = {
"add": torch.tensor([[[1.0, 1.0], [0.0, 1.0]]], dtype=torch.float32),
"subtract": torch.tensor([[[0.5, 0.0], [0.0, 0.75]]], dtype=torch.float32),
"intersect": torch.tensor([[[0.5, 0.5], [0.0, 0.25]]], dtype=torch.float32),
"xor": torch.tensor([[[0.5, 0.5], [0.0, 0.75]]], dtype=torch.float32),
"max": torch.tensor([[[1.0, 1.0], [0.0, 1.0]]], dtype=torch.float32),
"min": torch.tensor([[[0.5, 0.5], [0.0, 0.25]]], dtype=torch.float32),
}
for mode, expected in expectations.items():
with self.subTest(mode=mode):
(combined,) = self.combine_node.combine(mask_a=mask_a, mask_b=mask_b, mode=mode)
self.assert_is_normalized_mask(combined, (1, 2, 2))
self.assertTrue(torch.allclose(combined, expected))
def test_mask_combine_resizes_and_aligns_batch_to_mask_a(self):
mask_a = torch.full((2, 4, 6), 0.25, dtype=torch.float32)
mask_b = torch.ones((1, 2, 3), dtype=torch.float32)
(combined,) = self.combine_node.combine(mask_a=mask_a, mask_b=mask_b, mode="add")
self.assert_is_normalized_mask(combined, (2, 4, 6))
self.assertTrue(torch.allclose(combined, torch.ones((2, 4, 6), dtype=torch.float32)))
if __name__ == "__main__":
unittest.main()
+85
View File
@@ -0,0 +1,85 @@
import unittest
try:
from .test_mask_draw_helpers import load_plugin_package
except ImportError:
from test_mask_draw_helpers import load_plugin_package
class TestMaskProcessRegistration(unittest.TestCase):
def test_plugin_registers_mask_processing_nodes(self):
plugin = load_plugin_package()
self.assertIn("LLSMaskProcess", plugin.NODE_CLASS_MAPPINGS)
self.assertIn("LLSMaskCombine", plugin.NODE_CLASS_MAPPINGS)
self.assertEqual(
plugin.NODE_DISPLAY_NAME_MAPPINGS["LLSMaskProcess"],
"LLS Mask Process",
)
self.assertEqual(
plugin.NODE_DISPLAY_NAME_MAPPINGS["LLSMaskCombine"],
"LLS Mask Combine",
)
def test_mask_process_schema_matches_contract(self):
plugin = load_plugin_package()
node_cls = plugin.NODE_CLASS_MAPPINGS["LLSMaskProcess"]
schema = node_cls.INPUT_TYPES()
required = schema["required"]
optional = schema["optional"]
self.assertEqual(node_cls.CATEGORY, "LLS/Mask")
self.assertEqual(node_cls.FUNCTION, "process")
self.assertEqual(node_cls.RETURN_TYPES, ("MASK",))
self.assertEqual(node_cls.RETURN_NAMES, ("mask",))
self.assertEqual(required["mask"], ("MASK",))
self.assertEqual(
required["operation"][0],
[
"passthrough",
"threshold",
"invert",
"grow",
"shrink",
"blur",
"feather",
"fill_holes",
"remove_small_regions",
"smooth",
"clamp",
"resize_to_image",
],
)
self.assertEqual(required["value_float"][0], "FLOAT")
self.assertEqual(required["value_float"][1]["default"], 0.5)
self.assertEqual(required["value_float"][1]["min"], 0.0)
self.assertEqual(required["value_float"][1]["max"], 1.0)
self.assertEqual(required["value_int"][0], "INT")
self.assertEqual(required["value_int"][1]["default"], 8)
self.assertEqual(required["value_int"][1]["min"], -512)
self.assertEqual(required["value_int"][1]["max"], 512)
self.assertEqual(optional["image"], ("IMAGE",))
def test_mask_combine_schema_matches_contract(self):
plugin = load_plugin_package()
node_cls = plugin.NODE_CLASS_MAPPINGS["LLSMaskCombine"]
schema = node_cls.INPUT_TYPES()
required = schema["required"]
self.assertEqual(node_cls.CATEGORY, "LLS/Mask")
self.assertEqual(node_cls.FUNCTION, "combine")
self.assertEqual(node_cls.RETURN_TYPES, ("MASK",))
self.assertEqual(node_cls.RETURN_NAMES, ("mask",))
self.assertEqual(required["mask_a"], ("MASK",))
self.assertEqual(required["mask_b"], ("MASK",))
self.assertEqual(required["mode"][0], ["add", "subtract", "intersect", "xor", "max", "min"])
def test_plugin_does_not_register_mask_preview_node(self):
plugin = load_plugin_package()
self.assertNotIn("LLSMaskPreview", plugin.NODE_CLASS_MAPPINGS)
self.assertNotIn("LLSMaskPreview", plugin.NODE_DISPLAY_NAME_MAPPINGS)
if __name__ == "__main__":
unittest.main()