Add Dumas image compare node
This commit is contained in:
@@ -4,6 +4,11 @@
|
|||||||
|
|
||||||
## Included Nodes
|
## Included Nodes
|
||||||
|
|
||||||
|
- `Dumas Image Compare`
|
||||||
|
- Inputs: optional `image1`, optional `image2`
|
||||||
|
- Outputs: `new image`
|
||||||
|
- Saves preview images for the built-in compare UI and passes through the second image when present, otherwise the first.
|
||||||
|
|
||||||
- `Dumas JSON String to Object`
|
- `Dumas JSON String to Object`
|
||||||
- Input: `json_string`
|
- Input: `json_string`
|
||||||
- Output: parsed `JSON`
|
- Output: parsed `JSON`
|
||||||
@@ -144,6 +149,8 @@ decr -> use index - 1
|
|||||||
|
|
||||||
`Dumas JSON Flatten` and `Dumas JSON Unflatten` use the same dot-path format as the other path-based nodes.
|
`Dumas JSON Flatten` and `Dumas JSON Unflatten` use the same dot-path format as the other path-based nodes.
|
||||||
|
|
||||||
|
`Dumas Image Compare` accepts one or two images. The `new image` socket forwards `image2` when connected so you can keep the "after" image moving through the workflow, and falls back to `image1` if only one input is present.
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
This repo is intended to grow into a broader set of Dumas-branded generic utility nodes, including JSON helpers and adjacent data-manipulation tools.
|
This repo is intended to grow into a broader set of Dumas-branded generic utility nodes, including JSON helpers and adjacent data-manipulation tools.
|
||||||
|
|||||||
17
__init__.py
17
__init__.py
@@ -1,3 +1,18 @@
|
|||||||
from .dumas_json_nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
from .dumas_image_nodes import (
|
||||||
|
NODE_CLASS_MAPPINGS as IMAGE_NODE_CLASS_MAPPINGS,
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS as IMAGE_NODE_DISPLAY_NAME_MAPPINGS,
|
||||||
|
)
|
||||||
|
from .dumas_json_nodes import (
|
||||||
|
NODE_CLASS_MAPPINGS as JSON_NODE_CLASS_MAPPINGS,
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS as JSON_NODE_DISPLAY_NAME_MAPPINGS,
|
||||||
|
)
|
||||||
|
|
||||||
|
NODE_CLASS_MAPPINGS = {}
|
||||||
|
NODE_CLASS_MAPPINGS.update(JSON_NODE_CLASS_MAPPINGS)
|
||||||
|
NODE_CLASS_MAPPINGS.update(IMAGE_NODE_CLASS_MAPPINGS)
|
||||||
|
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS.update(JSON_NODE_DISPLAY_NAME_MAPPINGS)
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS.update(IMAGE_NODE_DISPLAY_NAME_MAPPINGS)
|
||||||
|
|
||||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||||
|
|||||||
98
dumas_image_nodes.py
Normal file
98
dumas_image_nodes.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import os
|
||||||
|
import random
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
import folder_paths
|
||||||
|
|
||||||
|
|
||||||
|
class DumasImageCompareNode:
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Dumas Image Compare shows the difference between two images directly on "
|
||||||
|
"the node. Connect one or two IMAGE inputs to compare before/after "
|
||||||
|
"results, model variants, or processing stages without breaking a "
|
||||||
|
"workflow when one branch is bypassed."
|
||||||
|
)
|
||||||
|
RETURN_TYPES = ("IMAGE",)
|
||||||
|
RETURN_NAMES = ("new image",)
|
||||||
|
FUNCTION = "compare_images"
|
||||||
|
OUTPUT_NODE = True
|
||||||
|
CATEGORY = "Dumas/Image"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.output_dir = folder_paths.get_temp_directory()
|
||||||
|
self.type = "temp"
|
||||||
|
self.prefix_append = "_dumascmp_" + "".join(
|
||||||
|
random.choice("abcdefghijklmnopqrstuvwxyz") for _ in range(5)
|
||||||
|
)
|
||||||
|
self.compress_level = 4
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"optional": {
|
||||||
|
"image1": (
|
||||||
|
"IMAGE",
|
||||||
|
{
|
||||||
|
"tooltip": (
|
||||||
|
"First image to compare. Optional so muted or bypassed "
|
||||||
|
"branches do not trigger a missing-input error."
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"image2": (
|
||||||
|
"IMAGE",
|
||||||
|
{
|
||||||
|
"tooltip": (
|
||||||
|
"Second image to compare. Optional so the node can still "
|
||||||
|
"display a single available image."
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def compare_images(self, image1=None, image2=None):
|
||||||
|
pairs = ((1, image1), (2, image2))
|
||||||
|
present = [(slot, tensor) for slot, tensor in pairs if tensor is not None]
|
||||||
|
results = []
|
||||||
|
|
||||||
|
if present:
|
||||||
|
first_tensor = present[0][1]
|
||||||
|
prefix = "dumas_compare" + self.prefix_append
|
||||||
|
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(
|
||||||
|
prefix,
|
||||||
|
self.output_dir,
|
||||||
|
first_tensor[0].shape[1],
|
||||||
|
first_tensor[0].shape[0],
|
||||||
|
)
|
||||||
|
for slot, tensor in present:
|
||||||
|
image_array = 255.0 * tensor[0].cpu().numpy()
|
||||||
|
image = Image.fromarray(np.clip(image_array, 0, 255).astype(np.uint8))
|
||||||
|
file_name = f"{filename}_{counter:05}_.png"
|
||||||
|
image.save(
|
||||||
|
os.path.join(full_output_folder, file_name),
|
||||||
|
compress_level=self.compress_level,
|
||||||
|
)
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"filename": file_name,
|
||||||
|
"subfolder": subfolder,
|
||||||
|
"type": self.type,
|
||||||
|
"slot": slot,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
new_image = image2 if image2 is not None else image1
|
||||||
|
return {"ui": {"images": results}, "result": (new_image,)}
|
||||||
|
|
||||||
|
|
||||||
|
NODE_CLASS_MAPPINGS = {
|
||||||
|
"DumasImageCompare": DumasImageCompareNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
|
"DumasImageCompare": "Dumas Image Compare",
|
||||||
|
}
|
||||||
124
tests/test_dumas_image_nodes.py
Normal file
124
tests/test_dumas_image_nodes.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import importlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
class FakeImageArray:
|
||||||
|
def __init__(self, width=8, height=6):
|
||||||
|
self.shape = (height, width, 3)
|
||||||
|
|
||||||
|
def cpu(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def numpy(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def astype(self, _dtype):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __rmul__(self, _value):
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTensorBatch:
|
||||||
|
def __init__(self, width=8, height=6):
|
||||||
|
self.image = FakeImageArray(width=width, height=height)
|
||||||
|
|
||||||
|
def __getitem__(self, index):
|
||||||
|
if index != 0:
|
||||||
|
raise IndexError(index)
|
||||||
|
return self.image
|
||||||
|
|
||||||
|
|
||||||
|
class FakePILImage:
|
||||||
|
saved_paths = []
|
||||||
|
|
||||||
|
def save(self, path, compress_level=0):
|
||||||
|
self.saved_paths.append((path, compress_level))
|
||||||
|
|
||||||
|
|
||||||
|
class DumasImageNodeTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.temp_dir = tempfile.mkdtemp(prefix="dumas-image-node-")
|
||||||
|
fake_numpy = types.SimpleNamespace(
|
||||||
|
clip=lambda array, _low, _high: array,
|
||||||
|
uint8="uint8",
|
||||||
|
)
|
||||||
|
fake_pil_image_module = types.SimpleNamespace(fromarray=lambda _array: FakePILImage())
|
||||||
|
fake_pil_module = types.SimpleNamespace(Image=fake_pil_image_module)
|
||||||
|
fake_folder_paths = types.SimpleNamespace(
|
||||||
|
get_temp_directory=lambda: cls.temp_dir,
|
||||||
|
get_save_image_path=lambda prefix, _out, _width, _height: (
|
||||||
|
cls.temp_dir,
|
||||||
|
prefix,
|
||||||
|
1,
|
||||||
|
"",
|
||||||
|
prefix,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
cls._saved_modules = {
|
||||||
|
name: sys.modules.get(name)
|
||||||
|
for name in ("numpy", "PIL", "PIL.Image", "folder_paths")
|
||||||
|
}
|
||||||
|
sys.modules["numpy"] = fake_numpy
|
||||||
|
sys.modules["PIL"] = fake_pil_module
|
||||||
|
sys.modules["PIL.Image"] = fake_pil_image_module
|
||||||
|
sys.modules["folder_paths"] = fake_folder_paths
|
||||||
|
cls.image_nodes = importlib.import_module("dumas_image_nodes")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
for name, module in cls._saved_modules.items():
|
||||||
|
if module is None:
|
||||||
|
sys.modules.pop(name, None)
|
||||||
|
else:
|
||||||
|
sys.modules[name] = module
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
FakePILImage.saved_paths = []
|
||||||
|
|
||||||
|
def test_compare_images_returns_second_input_as_new_image(self):
|
||||||
|
node = self.image_nodes.DumasImageCompareNode()
|
||||||
|
image1 = FakeTensorBatch()
|
||||||
|
image2 = FakeTensorBatch()
|
||||||
|
|
||||||
|
result = node.compare_images(image1=image1, image2=image2)
|
||||||
|
|
||||||
|
self.assertIs(result["result"][0], image2)
|
||||||
|
self.assertEqual([item["slot"] for item in result["ui"]["images"]], [1, 2])
|
||||||
|
self.assertEqual(len(FakePILImage.saved_paths), 2)
|
||||||
|
|
||||||
|
def test_compare_images_falls_back_to_first_image(self):
|
||||||
|
node = self.image_nodes.DumasImageCompareNode()
|
||||||
|
image1 = FakeTensorBatch()
|
||||||
|
|
||||||
|
result = node.compare_images(image1=image1)
|
||||||
|
|
||||||
|
self.assertIs(result["result"][0], image1)
|
||||||
|
self.assertEqual([item["slot"] for item in result["ui"]["images"]], [1])
|
||||||
|
|
||||||
|
def test_compare_images_handles_missing_inputs(self):
|
||||||
|
node = self.image_nodes.DumasImageCompareNode()
|
||||||
|
|
||||||
|
result = node.compare_images()
|
||||||
|
|
||||||
|
self.assertIsNone(result["result"][0])
|
||||||
|
self.assertEqual(result["ui"]["images"], [])
|
||||||
|
|
||||||
|
def test_saved_filenames_use_dumas_prefix(self):
|
||||||
|
node = self.image_nodes.DumasImageCompareNode()
|
||||||
|
image1 = FakeTensorBatch()
|
||||||
|
|
||||||
|
result = node.compare_images(image1=image1)
|
||||||
|
|
||||||
|
self.assertTrue(result["ui"]["images"][0]["filename"].startswith("dumas_compare"))
|
||||||
|
self.assertTrue(os.path.basename(FakePILImage.saved_paths[0][0]).startswith("dumas_compare"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user