开发图像拼接节点

This commit is contained in:
Gin3601
2026-05-25 15:29:07 +08:00
parent b2c98aa65c
commit 54aa780608
5 changed files with 1822 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+230
View File
@@ -0,0 +1,230 @@
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_CONCAT_RUNTIME_DEPS = torch is not None
CONCAT_RUNTIME_DEPS_MESSAGE = "concat node tests require torch"
@unittest.skipUnless(HAS_CONCAT_RUNTIME_DEPS, CONCAT_RUNTIME_DEPS_MESSAGE)
class TestConcatByTargetNode(unittest.TestCase):
@classmethod
def setUpClass(cls):
plugin = load_plugin_package()
cls.node = plugin.NODE_CLASS_MAPPINGS["LLSConcatByTarget"]()
def make_image(self, width, height, value, batch=1):
return torch.full((batch, height, width, 3), float(value), dtype=torch.float32)
def make_mask(self, width, height, value, batch=1):
return torch.full((batch, height, width), float(value), dtype=torch.float32)
def assert_image_output(self, image, mask, width, height, shape):
self.assertEqual((width, height), (shape[2], shape[1]))
self.assertEqual(tuple(image.shape), shape)
self.assertEqual(tuple(mask.shape), (shape[0], shape[1], shape[2]))
self.assertGreaterEqual(float(image.min().item()), 0.0)
self.assertLessEqual(float(image.max().item()), 1.0)
self.assertTrue(torch.allclose(mask, torch.zeros_like(mask)))
def test_image_mode_target_b_right_places_a_to_the_right_of_b(self):
image_a = self.make_image(width=2, height=2, value=0.2)
image_b = self.make_image(width=3, height=2, value=0.8)
image, mask, width, height = self.node.concat(
data_type="IMAGE",
target="B",
position="right",
match_target_size=True,
resize_mode="none",
align="center",
gap=1,
background_color="#000000",
background_value=0.0,
multiple_of=0,
allow_batch_broadcast=True,
image_a=image_a,
image_b=image_b,
mask_a=None,
mask_b=None,
)
self.assert_image_output(image, mask, width, height, (1, 2, 6, 3))
self.assertTrue(torch.allclose(image[:, :, 0:3, :], torch.full((1, 2, 3, 3), 0.8)))
self.assertTrue(torch.allclose(image[:, :, 3:4, :], torch.zeros((1, 2, 1, 3))))
self.assertTrue(torch.allclose(image[:, :, 4:6, :], torch.full((1, 2, 2, 3), 0.2)))
def test_image_mode_keep_proportion_gap_and_multiple_of(self):
image_a = self.make_image(width=4, height=4, value=0.25)
image_b = self.make_image(width=2, height=2, value=0.75)
image, mask, width, height = self.node.concat(
data_type="IMAGE",
target="A",
position="right",
match_target_size=True,
resize_mode="keep_proportion",
align="center",
gap=1,
background_color="#808080",
background_value=0.0,
multiple_of=4,
allow_batch_broadcast=True,
image_a=image_a,
image_b=image_b,
mask_a=None,
mask_b=None,
)
self.assert_image_output(image, mask, width, height, (1, 4, 12, 3))
self.assertTrue(torch.allclose(image[:, :, 0:4, :], torch.full((1, 4, 4, 3), 0.25)))
self.assertTrue(torch.allclose(image[:, :, 4:5, :], torch.full((1, 4, 1, 3), 128.0 / 255.0)))
self.assertTrue(torch.allclose(image[:, :, 5:9, :], torch.full((1, 4, 4, 3), 0.75)))
self.assertTrue(torch.allclose(image[:, :, 9:12, :], torch.full((1, 4, 3, 3), 128.0 / 255.0)))
def test_mask_mode_outputs_mask_and_preview_image(self):
mask_a = self.make_mask(width=2, height=2, value=1.0)
mask_b = self.make_mask(width=2, height=2, value=0.0)
image, mask, width, height = self.node.concat(
data_type="MASK",
target="A",
position="bottom",
match_target_size=True,
resize_mode="none",
align="center",
gap=1,
background_color="#000000",
background_value=0.25,
multiple_of=0,
allow_batch_broadcast=True,
image_a=None,
image_b=None,
mask_a=mask_a,
mask_b=mask_b,
)
self.assertEqual((width, height), (2, 5))
self.assertEqual(tuple(mask.shape), (1, 5, 2))
self.assertEqual(tuple(image.shape), (1, 5, 2, 3))
self.assertTrue(torch.allclose(mask[:, 0:2, :], torch.full((1, 2, 2), 1.0)))
self.assertTrue(torch.allclose(mask[:, 2:3, :], torch.full((1, 1, 2), 0.25)))
self.assertTrue(torch.allclose(mask[:, 3:5, :], torch.zeros((1, 2, 2))))
self.assertTrue(torch.allclose(image[..., 0], mask))
self.assertTrue(torch.allclose(image[..., 1], mask))
self.assertTrue(torch.allclose(image[..., 2], mask))
def test_batch_broadcast_works_when_enabled(self):
image_a = self.make_image(width=2, height=2, value=0.1, batch=1)
image_b = self.make_image(width=2, height=2, value=0.9, batch=2)
image, mask, width, height = self.node.concat(
data_type="IMAGE",
target="A",
position="left",
match_target_size=True,
resize_mode="none",
align="center",
gap=0,
background_color="#000000",
background_value=0.0,
multiple_of=0,
allow_batch_broadcast=True,
image_a=image_a,
image_b=image_b,
mask_a=None,
mask_b=None,
)
self.assert_image_output(image, mask, width, height, (2, 2, 4, 3))
self.assertTrue(torch.allclose(image[:, :, 0:2, :], torch.full((2, 2, 2, 3), 0.9)))
self.assertTrue(torch.allclose(image[:, :, 2:4, :], torch.full((2, 2, 2, 3), 0.1)))
def test_missing_required_inputs_for_current_mode_raise_clear_errors(self):
with self.assertRaisesRegex(RuntimeError, "image_a and image_b"):
self.node.concat(
data_type="IMAGE",
target="A",
position="right",
match_target_size=True,
resize_mode="none",
align="center",
gap=0,
background_color="#000000",
background_value=0.0,
multiple_of=0,
allow_batch_broadcast=True,
image_a=None,
image_b=None,
mask_a=self.make_mask(1, 1, 0.0),
mask_b=self.make_mask(1, 1, 0.0),
)
with self.assertRaisesRegex(RuntimeError, "mask_a and mask_b"):
self.node.concat(
data_type="MASK",
target="A",
position="right",
match_target_size=True,
resize_mode="none",
align="center",
gap=0,
background_color="#000000",
background_value=0.0,
multiple_of=0,
allow_batch_broadcast=True,
image_a=self.make_image(1, 1, 0.0),
image_b=self.make_image(1, 1, 0.0),
mask_a=None,
mask_b=None,
)
def test_invalid_background_color_and_batch_mismatch_raise_clear_errors(self):
image_a = self.make_image(width=2, height=2, value=0.1, batch=2)
image_b = self.make_image(width=2, height=2, value=0.9, batch=3)
with self.assertRaisesRegex(RuntimeError, "background_color"):
self.node.concat(
data_type="IMAGE",
target="A",
position="right",
match_target_size=True,
resize_mode="none",
align="center",
gap=0,
background_color="bad-color",
background_value=0.0,
multiple_of=0,
allow_batch_broadcast=True,
image_a=self.make_image(2, 2, 0.1),
image_b=self.make_image(2, 2, 0.9),
mask_a=None,
mask_b=None,
)
with self.assertRaisesRegex(RuntimeError, "Batch size mismatch"):
self.node.concat(
data_type="IMAGE",
target="A",
position="right",
match_target_size=True,
resize_mode="none",
align="center",
gap=0,
background_color="#000000",
background_value=0.0,
multiple_of=0,
allow_batch_broadcast=False,
image_a=image_a,
image_b=image_b,
mask_a=None,
mask_b=None,
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,51 @@
import unittest
try:
from .test_mask_draw_helpers import load_plugin_package
except ImportError:
from test_mask_draw_helpers import load_plugin_package
class TestConcatByTargetRegistration(unittest.TestCase):
def test_plugin_registers_concat_by_target_node(self):
plugin = load_plugin_package()
self.assertIn("LLSConcatByTarget", plugin.NODE_CLASS_MAPPINGS)
self.assertEqual(
plugin.NODE_DISPLAY_NAME_MAPPINGS["LLSConcatByTarget"],
"LLS Concat By Target",
)
def test_concat_by_target_schema_matches_contract(self):
plugin = load_plugin_package()
node_cls = plugin.NODE_CLASS_MAPPINGS["LLSConcatByTarget"]
schema = node_cls.INPUT_TYPES()
required = schema["required"]
optional = schema["optional"]
self.assertEqual(node_cls.CATEGORY, "LLS/Utils")
self.assertEqual(node_cls.FUNCTION, "concat")
self.assertEqual(node_cls.RETURN_TYPES, ("IMAGE", "MASK", "INT", "INT"))
self.assertEqual(node_cls.RETURN_NAMES, ("image", "mask", "width", "height"))
self.assertEqual(required["data_type"][0], ["IMAGE", "MASK"])
self.assertEqual(required["target"][0], ["A", "B"])
self.assertEqual(required["position"][0], ["top", "bottom", "left", "right"])
self.assertEqual(required["resize_mode"][0], ["keep_proportion", "stretch", "none"])
self.assertEqual(required["align"][0], ["start", "center", "end"])
self.assertEqual(required["match_target_size"][0], "BOOLEAN")
self.assertEqual(required["gap"][0], "INT")
self.assertEqual(required["gap"][1]["default"], 0)
self.assertEqual(required["background_color"][0], "STRING")
self.assertEqual(required["background_color"][1]["default"], "#000000")
self.assertEqual(required["background_value"][0], "FLOAT")
self.assertEqual(required["background_value"][1]["default"], 0.0)
self.assertEqual(required["multiple_of"][0], "INT")
self.assertEqual(required["allow_batch_broadcast"][0], "BOOLEAN")
self.assertEqual(optional["image_a"], ("IMAGE",))
self.assertEqual(optional["image_b"], ("IMAGE",))
self.assertEqual(optional["mask_a"], ("MASK",))
self.assertEqual(optional["mask_b"], ("MASK",))
if __name__ == "__main__":
unittest.main()
+488
View File
@@ -0,0 +1,488 @@
from __future__ import annotations
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
DATA_TYPE_CHOICES = ["IMAGE", "MASK"]
TARGET_CHOICES = ["A", "B"]
POSITION_CHOICES = ["top", "bottom", "left", "right"]
RESIZE_MODE_CHOICES = ["keep_proportion", "stretch", "none"]
ALIGN_CHOICES = ["start", "center", "end"]
def _require_torch():
if torch is None or torch_nn_functional is None:
raise RuntimeError("[LLS] torch is required for LLS Concat By Target.") from _TORCH_ERR
def _validate_choice(name: str, value: str, choices):
if value not in choices:
raise RuntimeError(f"[LLS] Invalid {name} '{value}'. Expected one of: {', '.join(choices)}.")
def parse_hex_color(color_str: str):
value = str(color_str or "").strip()
if value.startswith("#"):
value = value[1:]
if len(value) == 3:
value = "".join(part * 2 for part in value)
if len(value) != 6:
raise RuntimeError("[LLS] background_color must be a valid HEX color like '#000000' or '#ffffff'.")
try:
red = int(value[0:2], 16) / 255.0
green = int(value[2:4], 16) / 255.0
blue = int(value[4:6], 16) / 255.0
except ValueError as exc:
raise RuntimeError(
"[LLS] background_color must be a valid HEX color like '#000000' or '#ffffff'."
) from exc
return red, green, blue
def ensure_image_tensor(x):
_require_torch()
if x is None:
raise RuntimeError("[LLS] IMAGE mode requires both image_a and image_b inputs.")
if not isinstance(x, torch.Tensor):
x = torch.as_tensor(x)
if x.ndim == 3:
x = x.unsqueeze(0)
if x.ndim != 4:
raise RuntimeError("[LLS] IMAGE input must have shape [B,H,W,C] or [H,W,C].")
batch, height, width, channels = [int(dim) for dim in x.shape]
if batch <= 0 or height <= 0 or width <= 0 or channels <= 0:
raise RuntimeError("[LLS] IMAGE input must have positive batch, height, width, and channels.")
if not torch.is_floating_point(x):
x = x.to(dtype=torch.float32)
return x
def ensure_mask_tensor(x):
_require_torch()
if x is None:
raise RuntimeError("[LLS] MASK mode requires both mask_a and mask_b inputs.")
if not isinstance(x, torch.Tensor):
x = torch.as_tensor(x)
if x.ndim == 2:
x = x.unsqueeze(0)
elif x.ndim == 3:
pass
elif x.ndim == 4 and int(x.shape[-1]) == 1:
x = x.squeeze(-1)
elif x.ndim == 4 and int(x.shape[1]) == 1:
x = x.squeeze(1)
else:
raise RuntimeError("[LLS] MASK input must have shape [B,H,W], [H,W], [B,H,W,1], or [B,1,H,W].")
if not torch.is_floating_point(x):
x = x.to(dtype=torch.float32)
return x.to(dtype=torch.float32).clamp(0.0, 1.0)
def _repeat_batch(tensor, target_batch: int):
repeat_shape = [1] * tensor.ndim
repeat_shape[0] = target_batch
return tensor.repeat(*repeat_shape)
def broadcast_batch(a, b, allow_batch_broadcast: bool):
batch_a = int(a.shape[0])
batch_b = int(b.shape[0])
if batch_a == batch_b:
return a, b
if allow_batch_broadcast and batch_a == 1 and batch_b > 1:
return _repeat_batch(a, batch_b), b
if allow_batch_broadcast and batch_b == 1 and batch_a > 1:
return a, _repeat_batch(b, batch_a)
raise RuntimeError(
f"[LLS] Batch size mismatch: A has batch {batch_a}, B has batch {batch_b}, and broadcasting is disabled or impossible."
)
def resize_tensor_to_match(tensor, *, data_type: str, match_axis: str, target_size: int, resize_mode: str):
_validate_choice("resize_mode", resize_mode, RESIZE_MODE_CHOICES)
if resize_mode == "none":
return tensor
if data_type == "IMAGE":
current_height = int(tensor.shape[1])
current_width = int(tensor.shape[2])
else:
current_height = int(tensor.shape[1])
current_width = int(tensor.shape[2])
if match_axis == "height":
if current_height == target_size:
return tensor
new_height = max(1, int(target_size))
if resize_mode == "keep_proportion":
scale = float(new_height) / float(current_height)
new_width = max(1, int(round(current_width * scale)))
else:
new_width = current_width
elif match_axis == "width":
if current_width == target_size:
return tensor
new_width = max(1, int(target_size))
if resize_mode == "keep_proportion":
scale = float(new_width) / float(current_width)
new_height = max(1, int(round(current_height * scale)))
else:
new_height = current_height
else:
raise RuntimeError(f"[LLS] Unsupported match axis '{match_axis}'.")
if data_type == "IMAGE":
resized = torch_nn_functional.interpolate(
tensor.movedim(-1, 1),
size=(new_height, new_width),
mode="bilinear",
align_corners=False,
)
return resized.movedim(1, -1)
resized = torch_nn_functional.interpolate(
tensor.unsqueeze(1),
size=(new_height, new_width),
mode="nearest",
)
return resized.squeeze(1)
def create_canvas(
*,
data_type: str,
batch: int,
height: int,
width: int,
device,
dtype,
channels: int | None = None,
background_color=None,
background_value: float = 0.0,
):
if data_type == "IMAGE":
if channels is None:
raise RuntimeError("[LLS] IMAGE canvas creation requires a channels value.")
canvas = torch.zeros((batch, height, width, channels), device=device, dtype=dtype)
red, green, blue = background_color
if channels == 1:
canvas.fill_((red + green + blue) / 3.0)
return canvas
fill_values = [0.0] * channels
fill_values[0] = red
if channels > 1:
fill_values[1] = green
if channels > 2:
fill_values[2] = blue
if channels == 4:
fill_values[3] = 1.0
fill_tensor = torch.tensor(fill_values, device=device, dtype=dtype).view(1, 1, 1, channels)
canvas[:] = fill_tensor
return canvas
return torch.full(
(batch, height, width),
float(background_value),
device=device,
dtype=dtype,
)
def paste_tensor(canvas, tensor, top: int, left: int):
if canvas.ndim == 4:
canvas[:, top : top + tensor.shape[1], left : left + tensor.shape[2], :] = tensor
else:
canvas[:, top : top + tensor.shape[1], left : left + tensor.shape[2]] = tensor
return canvas
def pad_to_multiple(
tensor,
*,
data_type: str,
multiple_of: int,
background_color=None,
background_value: float = 0.0,
):
if multiple_of <= 0:
return tensor
if data_type == "IMAGE":
batch, height, width, channels = [int(dim) for dim in tensor.shape]
dtype = tensor.dtype
device = tensor.device
else:
batch, height, width = [int(dim) for dim in tensor.shape]
channels = None
dtype = tensor.dtype
device = tensor.device
padded_height = ((height + multiple_of - 1) // multiple_of) * multiple_of
padded_width = ((width + multiple_of - 1) // multiple_of) * multiple_of
if padded_height == height and padded_width == width:
return tensor
canvas = create_canvas(
data_type=data_type,
batch=batch,
height=padded_height,
width=padded_width,
channels=channels,
device=device,
dtype=dtype,
background_color=background_color,
background_value=background_value,
)
return paste_tensor(canvas, tensor, 0, 0)
def _alignment_offset(container_size: int, item_size: int, align: str) -> int:
_validate_choice("align", align, ALIGN_CHOICES)
if align == "start":
return 0
if align == "center":
return max(0, (container_size - item_size) // 2)
return max(0, container_size - item_size)
def concat_by_target(
*,
data_type: str,
tensor_a,
tensor_b,
target: str,
position: str,
match_target_size: bool,
resize_mode: str,
align: str,
gap: int,
multiple_of: int,
allow_batch_broadcast: bool,
background_color=None,
background_value: float = 0.0,
):
_validate_choice("data_type", data_type, DATA_TYPE_CHOICES)
_validate_choice("target", target, TARGET_CHOICES)
_validate_choice("position", position, POSITION_CHOICES)
_validate_choice("resize_mode", resize_mode, RESIZE_MODE_CHOICES)
_validate_choice("align", align, ALIGN_CHOICES)
if gap < 0:
raise RuntimeError("[LLS] gap must be >= 0.")
if multiple_of < 0:
raise RuntimeError("[LLS] multiple_of must be >= 0.")
tensor_a, tensor_b = broadcast_batch(tensor_a, tensor_b, allow_batch_broadcast)
target_tensor = tensor_a if target == "A" else tensor_b
other_tensor = tensor_b if target == "A" else tensor_a
if data_type == "IMAGE":
work_dtype = target_tensor.dtype if torch.is_floating_point(target_tensor) else torch.float32
target_tensor = target_tensor.to(dtype=work_dtype)
other_tensor = other_tensor.to(device=target_tensor.device, dtype=work_dtype)
else:
target_tensor = target_tensor.to(dtype=torch.float32)
other_tensor = other_tensor.to(device=target_tensor.device, dtype=torch.float32)
if match_target_size:
match_axis = "height" if position in {"left", "right"} else "width"
target_size = int(target_tensor.shape[1] if match_axis == "height" else target_tensor.shape[2])
other_tensor = resize_tensor_to_match(
other_tensor,
data_type=data_type,
match_axis=match_axis,
target_size=target_size,
resize_mode=resize_mode,
)
if data_type == "IMAGE":
batch = int(target_tensor.shape[0])
target_height = int(target_tensor.shape[1])
target_width = int(target_tensor.shape[2])
other_height = int(other_tensor.shape[1])
other_width = int(other_tensor.shape[2])
channels = int(target_tensor.shape[3])
else:
batch = int(target_tensor.shape[0])
target_height = int(target_tensor.shape[1])
target_width = int(target_tensor.shape[2])
other_height = int(other_tensor.shape[1])
other_width = int(other_tensor.shape[2])
channels = None
if position in {"left", "right"}:
canvas_height = max(target_height, other_height)
canvas_width = target_width + int(gap) + other_width
target_top = _alignment_offset(canvas_height, target_height, align)
other_top = _alignment_offset(canvas_height, other_height, align)
if position == "right":
target_left = 0
other_left = target_width + int(gap)
else:
other_left = 0
target_left = other_width + int(gap)
else:
canvas_height = target_height + int(gap) + other_height
canvas_width = max(target_width, other_width)
target_left = _alignment_offset(canvas_width, target_width, align)
other_left = _alignment_offset(canvas_width, other_width, align)
if position == "bottom":
target_top = 0
other_top = target_height + int(gap)
else:
other_top = 0
target_top = other_height + int(gap)
canvas = create_canvas(
data_type=data_type,
batch=batch,
height=canvas_height,
width=canvas_width,
channels=channels,
device=target_tensor.device,
dtype=target_tensor.dtype,
background_color=background_color,
background_value=background_value,
)
paste_tensor(canvas, target_tensor, target_top, target_left)
paste_tensor(canvas, other_tensor, other_top, other_left)
canvas = pad_to_multiple(
canvas,
data_type=data_type,
multiple_of=int(multiple_of),
background_color=background_color,
background_value=background_value,
)
if data_type == "IMAGE":
final_height = int(canvas.shape[1])
final_width = int(canvas.shape[2])
else:
final_height = int(canvas.shape[1])
final_width = int(canvas.shape[2])
return canvas, final_width, final_height
def mask_to_preview_image(mask):
normalized = ensure_mask_tensor(mask)
return normalized.unsqueeze(-1).repeat(1, 1, 1, 3).clamp(0.0, 1.0)
class LLSConcatByTarget:
CATEGORY = "LLS/Utils"
FUNCTION = "concat"
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT")
RETURN_NAMES = ("image", "mask", "width", "height")
DESCRIPTION = "Concatenate IMAGE or MASK inputs around a chosen target canvas."
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"data_type": (DATA_TYPE_CHOICES, {"default": "IMAGE"}),
"target": (TARGET_CHOICES, {"default": "A"}),
"position": (POSITION_CHOICES, {"default": "right"}),
"match_target_size": ("BOOLEAN", {"default": True}),
"resize_mode": (RESIZE_MODE_CHOICES, {"default": "keep_proportion"}),
"align": (ALIGN_CHOICES, {"default": "center"}),
"gap": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 1}),
"background_color": ("STRING", {"default": "#000000", "multiline": False}),
"background_value": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"multiple_of": ("INT", {"default": 0, "min": 0, "max": 512, "step": 1}),
"allow_batch_broadcast": ("BOOLEAN", {"default": True}),
},
"optional": {
"image_a": ("IMAGE",),
"image_b": ("IMAGE",),
"mask_a": ("MASK",),
"mask_b": ("MASK",),
},
}
def concat(
self,
data_type,
target,
position,
match_target_size,
resize_mode,
align,
gap,
background_color,
background_value,
multiple_of,
allow_batch_broadcast,
image_a=None,
image_b=None,
mask_a=None,
mask_b=None,
):
_require_torch()
data_type = str(data_type or "IMAGE")
background_value = max(0.0, min(1.0, float(background_value)))
if data_type == "IMAGE":
if image_a is None or image_b is None:
raise RuntimeError("[LLS] IMAGE mode requires both image_a and image_b inputs.")
color = parse_hex_color(background_color)
tensor_a = ensure_image_tensor(image_a)
tensor_b = ensure_image_tensor(image_b)
output_image, width, height = concat_by_target(
data_type="IMAGE",
tensor_a=tensor_a,
tensor_b=tensor_b,
target=str(target),
position=str(position),
match_target_size=bool(match_target_size),
resize_mode=str(resize_mode),
align=str(align),
gap=int(gap),
multiple_of=int(multiple_of),
allow_batch_broadcast=bool(allow_batch_broadcast),
background_color=color,
background_value=background_value,
)
output_image = output_image.clamp(0.0, 1.0)
output_mask = torch.zeros(
(int(output_image.shape[0]), int(output_image.shape[1]), int(output_image.shape[2])),
device=output_image.device,
dtype=torch.float32,
)
return output_image, output_mask, int(width), int(height)
if data_type == "MASK":
if mask_a is None or mask_b is None:
raise RuntimeError("[LLS] MASK mode requires both mask_a and mask_b inputs.")
tensor_a = ensure_mask_tensor(mask_a)
tensor_b = ensure_mask_tensor(mask_b)
output_mask, width, height = concat_by_target(
data_type="MASK",
tensor_a=tensor_a,
tensor_b=tensor_b,
target=str(target),
position=str(position),
match_target_size=bool(match_target_size),
resize_mode=str(resize_mode),
align=str(align),
gap=int(gap),
multiple_of=int(multiple_of),
allow_batch_broadcast=bool(allow_batch_broadcast),
background_color=None,
background_value=background_value,
)
output_mask = output_mask.clamp(0.0, 1.0).to(dtype=torch.float32)
output_image = mask_to_preview_image(output_mask)
return output_image, output_mask, int(width), int(height)
raise RuntimeError(f"[LLS] Invalid data_type '{data_type}'. Expected IMAGE or MASK.")
NODE_CLASS_MAPPINGS = {"LLSConcatByTarget": LLSConcatByTarget}
NODE_DISPLAY_NAME_MAPPINGS = {"LLSConcatByTarget": "LLS Concat By Target"}
+6
View File
@@ -32,6 +32,10 @@ LLS / Utils
"""
from __future__ import annotations
from .concat_by_target import (
NODE_CLASS_MAPPINGS as CONCAT_NODE_CLASS_MAPPINGS,
NODE_DISPLAY_NAME_MAPPINGS as CONCAT_NODE_DISPLAY_NAME_MAPPINGS,
)
from .model_info import (
FAMILY_DEFAULT_PRESET,
MODEL_FAMILY_CHOICES,
@@ -238,6 +242,7 @@ NODE_CLASS_MAPPINGS: dict[str, type] = {
"LLSResolutionSelector": LLSResolutionSelector,
"LLSGenerationConfig": LLSGenerationConfig,
}
NODE_CLASS_MAPPINGS.update(CONCAT_NODE_CLASS_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS: dict[str, str] = {
"LLSStringLiteral": "LLS String Literal",
@@ -246,3 +251,4 @@ NODE_DISPLAY_NAME_MAPPINGS: dict[str, str] = {
"LLSResolutionSelector": "LLS Resolution Selector",
"LLSGenerationConfig": "LLS Generation Config",
}
NODE_DISPLAY_NAME_MAPPINGS.update(CONCAT_NODE_DISPLAY_NAME_MAPPINGS)