制作MSAK处理 节点
This commit is contained in:
@@ -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` 主要包含:
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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"}
|
||||
@@ -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
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user