From a67ae5bf862650455f6975a79c9f9f819452a681 Mon Sep 17 00:00:00 2001 From: Chris Dumas Date: Tue, 21 Jul 2026 11:38:34 +0000 Subject: [PATCH] Add Dumas image compare node --- README.md | 7 ++ __init__.py | 17 ++++- dumas_image_nodes.py | 98 +++++++++++++++++++++++++ tests/test_dumas_image_nodes.py | 124 ++++++++++++++++++++++++++++++++ 4 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 dumas_image_nodes.py create mode 100644 tests/test_dumas_image_nodes.py diff --git a/README.md b/README.md index 8b1bd05..945f5b8 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,11 @@ ## 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` - Input: `json_string` - 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 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 This repo is intended to grow into a broader set of Dumas-branded generic utility nodes, including JSON helpers and adjacent data-manipulation tools. diff --git a/__init__.py b/__init__.py index 1c19a98..5303dc0 100644 --- a/__init__.py +++ b/__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"] diff --git a/dumas_image_nodes.py b/dumas_image_nodes.py new file mode 100644 index 0000000..c12e27c --- /dev/null +++ b/dumas_image_nodes.py @@ -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", +} diff --git a/tests/test_dumas_image_nodes.py b/tests/test_dumas_image_nodes.py new file mode 100644 index 0000000..d7b1855 --- /dev/null +++ b/tests/test_dumas_image_nodes.py @@ -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()