From 3e39e9bb09b80f0cd142c63ecb9cebc9f39ad140 Mon Sep 17 00:00:00 2001 From: Immac Date: Sun, 1 Mar 2026 16:36:11 -0600 Subject: [PATCH] Copied from ImmacTools, --- LICENSE | 22 + MANIFEST.in | 9 + README.md | 99 +++ __init__.py | 19 + __pycache__/__init__.cpython-312.pyc | Bin 0 -> 654 bytes immac_tools.json | 7 + pyproject.toml | 91 +++ src/immac_tools/__init__.py | 40 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 1405 bytes .../forwarding_nodes.cpython-312.pyc | Bin 0 -> 4882 bytes .../__pycache__/nodes.cpython-312.pyc | Bin 0 -> 33127 bytes src/immac_tools/forwarding_nodes.py | 119 +++ src/immac_tools/nodes.py | 684 ++++++++++++++++++ tests/__init__.py | 1 + tests/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 241 bytes .../conftest.cpython-312-pytest-8.4.2.pyc | Bin 0 -> 1095 bytes ...t_immac_tools.cpython-312-pytest-8.4.2.pyc | Bin 0 -> 11046 bytes tests/conftest.py | 6 + tests/pytest.ini | 4 + tests/test_immac_tools.py | 21 + 20 files changed, 1122 insertions(+) create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 README.md create mode 100644 __init__.py create mode 100644 __pycache__/__init__.cpython-312.pyc create mode 100644 immac_tools.json create mode 100644 pyproject.toml create mode 100644 src/immac_tools/__init__.py create mode 100644 src/immac_tools/__pycache__/__init__.cpython-312.pyc create mode 100644 src/immac_tools/__pycache__/forwarding_nodes.cpython-312.pyc create mode 100644 src/immac_tools/__pycache__/nodes.cpython-312.pyc create mode 100644 src/immac_tools/forwarding_nodes.py create mode 100644 src/immac_tools/nodes.py create mode 100644 tests/__init__.py create mode 100644 tests/__pycache__/__init__.cpython-312.pyc create mode 100644 tests/__pycache__/conftest.cpython-312-pytest-8.4.2.pyc create mode 100644 tests/__pycache__/test_immac_tools.cpython-312-pytest-8.4.2.pyc create mode 100644 tests/conftest.py create mode 100644 tests/pytest.ini create mode 100644 tests/test_immac_tools.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d15dd99 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2025, Immac + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..6514960 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,9 @@ +include LICENSE +include README.md + +recursive-exclude * __pycache__ +recursive-exclude * *.py[co] + +recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f3a64d --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ + +# Immac Tools + +A small collection of ComfyUI extension nodes created for personal use. + +## Quick summary + +- Package: `ComfyUI-ImmacTools` +- Purpose: ComfyUI extension providing utility nodes. + +## Installation + +This repository is intended to be used as a ComfyUI custom node. Install it by one of the following methods: + +A) ComfyUI Manager (When/If published) + +- Use ComfyUI's built-in extension/manager (if available) to add this repo directly by URL. The manager will clone the repository into the appropriate `custom_nodes` folder and keep it updated. + +B) Clone into your ComfyUI `custom_nodes` folder + +1. From your ComfyUI repository root (or wherever you keep custom nodes), clone this repo into the `custom_nodes` directory: + +```bash +# Example (from ComfyUI repo root) +git clone https://github.com/Immac/ComfyUI-ImmacTools custom_nodes/ComfyUI-ImmacTools +``` + +2. Restart ComfyUI. The extension's `comfy_entrypoint` should be discovered and the nodes will appear in the node list. + +# Immac Tools + +A small collection of ComfyUI custom nodes created for personal use and convenience. + +## Quick summary + +- Package: `immac_tools` +- Purpose: Utility nodes for ComfyUI (sigma schedule helpers and small helpers). + +## Installation + +This repository is intended to be installed as a ComfyUI custom node. Two common installation methods: + +1) Clone into your ComfyUI `custom_nodes` folder + +From your ComfyUI repository root (or wherever you keep custom nodes), clone this repo into the `custom_nodes` directory: + +```bash +# Example (from ComfyUI repo root) +git clone https://github.com/Immac/ComfyUI-ImmacTools custom_nodes/immac_tools +``` + +Then restart ComfyUI. The extension entrypoint should be discovered and the nodes will appear in the node list. + +2) ComfyUI Manager (if available) + +If you use a community manager/extension manager that supports installing extensions by Git URL, add this repo's URL and let the manager clone/update it for you. + +## Where the code lives + +- Node implementations: `src/immac_tools/nodes.py` and `src/immac_tools/forwarding_nodes.py`. +- Python package metadata: `pyproject.toml` and top-level package under `src/immac_tools`. + +## Nodes included + +The main utility nodes included today are: + +- Concatenate Sigmas Node + - Class: `ConcatenateSigmasImmacTools` + - Display name: `Concatenate Sigmas Node` + - Inputs: `sigmas_1`, `sigmas_2` (sigma schedules as tensors or lists) + - Outputs: concatenated sigma schedule + - Behavior: concatenates two sigma schedules, tolerating None inputs and non-torch objects by converting them to tensors. + +- Splice Sigmas At % + - Class: `SpliceSigmasAtImmacTools` + - Display name: `Splice Sigmas At %` + - Inputs: `sigmas_a`, `sigmas_b`, `splice` (float 0.0 - 1.0) + - Outputs: `spliced_sigmas` (full), `first_part`, `second_part` + - Behavior: picks a boundary on `sigmas_a` based on `splice` and combines `sigmas_a` and `sigmas_b` around that boundary. Accepts tensor or list inputs. + +- Skip Every Nth Image + - Class: `SkipEveryNthImagesImmacTools` + - Display name: `Skip Every Nth Image` + - Inputs: `images` (batch as list/tuple or torch tensor), `n` (int; n<=0 yields empty output) + - Outputs: filtered images batch, indices removed + - Behavior: drops every nth image while preserving the order of remaining items; returns the kept batch plus the zero-based indices that were removed. + +For full details see the source files mentioned above. + + + +## Contributing + +- Bug reports and feature requests: open an issue on the GitHub repository. + +## License & contact + +MIT — see `LICENSE`. +For questions or issues open an issue at: https://github.com/Immac/ComfyUI-ImmacTools diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..aad8d55 --- /dev/null +++ b/__init__.py @@ -0,0 +1,19 @@ +"""Top-level package for immac_tools.""" + +__all__ = [ + "NODE_CLASS_MAPPINGS", + "NODE_DISPLAY_NAME_MAPPINGS", + "WEB_DIRECTORY", +] + +__author__ = """Immac""" +__email__ = "immac.gm@gmail.com" +__version__ = "0.0.4" +__title__ = "Immac Tools" +__description__ = "A collection of Nodes for personal use." +__icon_ = "🧰" +from .src.immac_tools import NODE_CLASS_MAPPINGS +from .src.immac_tools import NODE_DISPLAY_NAME_MAPPINGS +from .src.immac_tools import WEB_DIRECTORY + + diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3be24e01332a184f53921968c668a223b67b365d GIT binary patch literal 654 zcmYk3KabNe7{;Ccxwc7rJ?_MCOc4tdiKGJ(CxH;iL^Y`4nt^~#U=y1;Tfi2! z4794Z8tc1L89o&3iU~XP{7Y}a?737n4+768v6LcmTE*;Wc;4-i(?Mr4A!nV@s6Tu; z$?oLEZhtZwbY^7OIqUs9YrN{cAl?4BcRD>E&$4n~>#YxSS!WSEUj!Z(jxU3Cx$U%_ zM{6s;VNbQ=`d-KOr4Y=Ixm?6NeKekzJ34k2SkQihSEvF zQrs>2bl9qA6+)==3DMvQ0bl|o@r6{tHA0x~2GAnp3iQErSC4rt7+@0wSmY}n>V}{I zLfi++nvwFI|H8^U$o-Xe|A1~_M!#n6g$$URUv$STlo5}m;w*AKP8Mv*l&6*5Ja89c znXeK~{3MEHK$iOBcW-xp#0B>yj|p+YRaSi(NSX-tOdWxf&LKL1XJ~iy4MG9M-~a#s literal 0 HcmV?d00001 diff --git a/immac_tools.json b/immac_tools.json new file mode 100644 index 0000000..bf86dbf --- /dev/null +++ b/immac_tools.json @@ -0,0 +1,7 @@ +{ + "name": "Immac Tools", + "icon": "🧰", + "description": "A collection of Nodes for personal use.", + "author": "Immac", + "version": "0.0.2" +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4d46380 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,91 @@ +[build-system] +requires = ["setuptools>=70.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "ComfyUI-ImmacStyleMixer" +version = "0.0.1" +description = "A collection of Nodes for personal use." +authors = [ + {name = "Immac", email = "immac.gm@gmail.com"} +] +readme = "README.md" +license = {text = "MIT license"} +requires-python = ">=3.10" +classifiers = [] +dependencies = [ + +] + +[project.optional-dependencies] +dev = [ + "bump-my-version", + "coverage", # testing + "mypy", # linting + "pre-commit", # runs linting on commit + "pytest", # testing + "ruff", # linting +] + +[project.urls] +Repository = "https://github.com/Immac/ComfyUI-ImmacTools" +BugTracker = "https://github.com/Immac/ComfyUI-ImmacTools/issues" +Documentation = "https://github.com/Immac/ComfyUI-ImmacTools/wiki" + + +[tool.comfy] +PublisherId = "immac" +DisplayName = "Immac's Tools" +Icon = "" +Tags = ["sigmas"] +Repository = "https://github.com/Immac/ComfyUI-ImmacTools" + +includes = [] + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +"*" = ["*.*"] + +[tool.pytest.ini_options] +minversion = "8.0" +testpaths = [ + "tests", +] + +[tool.mypy] +files = "." + +# Use strict defaults +strict = true +warn_unreachable = true +warn_no_return = true + +[[tool.mypy.overrides]] +# Don't require test functions to include types +module = "tests.*" +allow_untyped_defs = true +disable_error_code = "attr-defined" + +[tool.ruff] +# extend-exclude = ["static", "ci/templates"] +line-length = 140 +src = ["src", "tests"] +target-version = "py310" + +# Add rules to ban exec/eval +[tool.ruff.lint] +select = [ + "S102", # exec-builtin + "S307", # eval-used + "W293", + "F", # The "F" series in Ruff stands for "Pyflakes" rules, which catch various Python syntax errors and undefined names. + # See all rules here: https://docs.astral.sh/ruff/rules/#pyflakes-f +] + +[tool.ruff.lint.flake8-quotes] +inline-quotes = "double" diff --git a/src/immac_tools/__init__.py b/src/immac_tools/__init__.py new file mode 100644 index 0000000..f57bff5 --- /dev/null +++ b/src/immac_tools/__init__.py @@ -0,0 +1,40 @@ +"""ComfyUI-ImmacTools package exports used by ComfyUI. + +This module exposes the node registration mappings and the `WEB_DIRECTORY` +constant expected by ComfyUI web extensions. +""" +from .nodes import ConcatenateSigmasNode, SpliceSigmasAtNode, ResampleSigmas, SkipEveryNthImages, MatchContrastNode +from .forwarding_nodes import ForwardAnyNode, ForwardConditioningNode, ForwardModelNode + +# Mapping from node_id to node class +NODE_CLASS_MAPPINGS: dict[str, type] = { + "ConcatenateSigmasImmacTools": ConcatenateSigmasNode, + "SpliceSigmasAtImmacTools": SpliceSigmasAtNode, + "ResampleSigmasImmacTools": ResampleSigmas, + "SkipEveryNthImagesImmacTools": SkipEveryNthImages, + "MatchContrastImmacTools": MatchContrastNode, + "ForwardAnyImmacTools": ForwardAnyNode, + "ForwardConditioningImmacTools": ForwardConditioningNode, + "ForwardModelImmacTools": ForwardModelNode, +} + +# Mapping from node_id to human readable display name +NODE_DISPLAY_NAME_MAPPINGS: dict[str, str] = { + "ConcatenateSigmasImmacTools": "Concatenate Sigmas Node", + "SpliceSigmasAtImmacTools": "Splice Sigmas At Node", + "ResampleSigmasImmacTools": "Resample Sigmas", + "SkipEveryNthImagesImmacTools": "Skip Every Nth Image", + "MatchContrastImmacTools": "Match Contrast", + "ForwardAnyImmacTools": "Forward Any", + "ForwardConditioningImmacTools": "Forward Conditioning", + "ForwardModelImmacTools": "Forward Model", +} + +# ComfyUI expects this for web extensions +WEB_DIRECTORY = "./web" + +__all__ = [ + "NODE_CLASS_MAPPINGS", + "NODE_DISPLAY_NAME_MAPPINGS", + "WEB_DIRECTORY", +] diff --git a/src/immac_tools/__pycache__/__init__.cpython-312.pyc b/src/immac_tools/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bf4adad45a0dfe9833f070f31e603ce20985c9c9 GIT binary patch literal 1405 zcmZux&u`;I6dwOb;<%f%Xvd;Yajv4OmX zH-Ds!A^?A>&HO6QUar2EFW-Xu00ReL4eMi#>5k3}$6%&ovVv1!MW?7KpD`{m%duG5 zDKp!#H9&M?5R(*0kyxZmY;68uIZNaQsgUZa{<1uoEaSqieBvT5VGEbBjhFBZTuG5D zDYEh$Sxu44PHhPA%1@fJI;k~Rr@!n4>~wO!cc;gg>m3Kdn8VQZ9=k&V$>(7Z2@cOV z!SK-p&Y&&ZJ|0mHS%A;R8Hy7Q#fU&ZzywBQNV$kyK?5H$Hw>vidP{#%o3n}uY3FYrBA+T}f=UYjoDtpFjd;5tkkhGa~wztV`XVt$;}rCUVq=S7Ll{Q@;_2??w9g{a`t-v-hLPD-R%qp=%90W*z12fm~P~h!mJiheT3f}^_nLH})t0T*G@`ofr)W(sNoAog`^|b%LI?*QLP^1wdU#|gtf`iQTI9@w zs3ezU0+IShq*)RZQ%@v@oQ%X2laM6K2)Vu=h;&dmLMe}EU7aXX zyW=rJKZ7f|1^?3aC}5;b)r_`}NEmP`f`}5{b}1SXpJdb47HWRmo*L;hio7!}0+vzA zVo?U*k+-l%i276@)CwnwwVTcLN71V?vzj;lm0T_jP1Bz0re;2~fhM#+0RI!5{0>eo zEzo!`hHuBP6*u0AcixP*--vJT# literal 0 HcmV?d00001 diff --git a/src/immac_tools/__pycache__/forwarding_nodes.cpython-312.pyc b/src/immac_tools/__pycache__/forwarding_nodes.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1ee0b9a88ca1e49b318a855a4bd4edbc90431e19 GIT binary patch literal 4882 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 io.Schema: + return io.Schema( + node_id="ForwardAnyImmacTools", + display_name="Forward Any", + category="Example", + inputs=[ + io.AnyType.Input("value",display_name="->"), + ], + outputs=[ + io.AnyType.Output(display_name="->"), + ], + ) + + @classmethod + def check_lazy_status(cls, value): + return [] + + @classmethod + def execute(cls, value) -> io.NodeOutput: + # Pass-through: simply return the input as output + return io.NodeOutput(value) + + +class ForwardConditioningNode(io.ComfyNode): + """ + ConditioningForwardingNode + A pass-through node specifically for Conditioning types. + + Inputs: + - conditioning: Conditioning input + + Outputs: + - conditioning: same Conditioning output + """ + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="ForwardConditioningImmacTools", + display_name="Forward Conditioning", + category="Example", + inputs=[ + io.Conditioning.Input("conditioning", display_name="->"), + ], + outputs=[ + io.Conditioning.Output(display_name="->"), + ], + ) + + @classmethod + def check_lazy_status(cls, conditioning): + return [] + + @classmethod + def execute(cls, conditioning) -> io.NodeOutput: + # Pass-through: simply return the conditioning as output + return io.NodeOutput(conditioning) + + +class ForwardModelNode(io.ComfyNode): + """ + ModelForwardingNode + A pass-through node specifically for Model types. + + Inputs: + - model: Model input + + Outputs: + - model: same Model output + """ + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="ForwardModelImmacTools", + display_name="Forward Model", + category="Example", + inputs=[ + io.Model.Input("model", display_name="->"), + ], + outputs=[ + io.Model.Output(display_name="->"), + ], + ) + + @classmethod + def check_lazy_status(cls, model): + return [] + + @classmethod + def execute(cls, model) -> io.NodeOutput: + # Pass-through: simply return the model as output + return io.NodeOutput(model) + + +class ExampleForwardingExtension(ComfyExtension): + @override + async def get_node_list(self) -> list[type[io.ComfyNode]]: + return [ForwardAnyNode, ForwardConditioningNode, ForwardModelNode] + + + + diff --git a/src/immac_tools/nodes.py b/src/immac_tools/nodes.py new file mode 100644 index 0000000..19dcb7f --- /dev/null +++ b/src/immac_tools/nodes.py @@ -0,0 +1,684 @@ +from typing_extensions import override +from collections.abc import Sequence + +from comfy_api.latest import ComfyExtension, io +from .forwarding_nodes import ForwardAnyNode, ForwardConditioningNode, ForwardModelNode +import torch +import numpy as np + +class ConcatenateSigmasNode(io.ComfyNode): + """ + ConcatenateSigmasNode + A ComfyUI-compatible node that concatenates two "sigmas" inputs into a single 1D sigma tensor. + + Inputs: + - sigmas_1: torch.Tensor or tensor-like (1D or scalar). May be None. + - sigmas_2: torch.Tensor or tensor-like (1D or scalar). May be None. + + Output: + - io.NodeOutput containing the concatenated torch.Tensor, or the single non-None input, or None if both inputs are None. + + Notes and edge cases: + - If inputs have more than 1 dimension, concatenation occurs along the first dimension; the remaining dimensions must match. + - If inputs are on different devices or have incompatible dtypes/shapes, torch.cat will raise an error. Convert or move tensors to a common device/dtype before feeding them to this node if necessary. + - The node's check_lazy_status does not request any additional lazy evaluation; all provided inputs are processed directly. + + Edge cases and recommendations: + - When inputs are on different devices (CPU vs GPU) or have different dtypes, callers should coerce them to a common device/dtype before connecting to this node; otherwise torch.cat will raise. + - If inputs have trailing dimensions (i.e. dim() > 1), those trailing shapes must match exactly; the node will concatenate along dim=0. + + Examples: + - Given sigmas_1 = torch.tensor([0.1, 0.2]) and sigmas_2 = torch.tensor([0.3]), the output is tensor([0.1, 0.2, 0.3]). + - Given sigmas_1 = 0.5 (scalar) and sigmas_2 = [0.6, 0.7], the output is tensor([0.5, 0.6, 0.7]) after conversion and unsqueezing. + """ + @classmethod + def define_schema(cls) -> io.Schema: + """ + Return a schema which contains all information about the node. + Some types: "Model", "Vae", "Clip", "Conditioning", "Latent", "Image", "Int", "String", "Float", "Combo". + For outputs the "io.Model.Output" should be used, for inputs the "io.Model.Input" can be used. + The type can be a "Combo" - this will be a list for selection. + """ + return io.Schema( + node_id="ConcatenateSigmasImmacTools", + display_name="Concatenate Sigmas Node", + category="Example", + inputs=[ + io.Sigmas.Input("sigmas_1"), + io.Sigmas.Input("sigmas_2"), + ], + outputs=[ + io.Sigmas.Output(), + ], + ) + + @classmethod + def check_lazy_status(cls, sigmas_1, sigmas_2): + """ + Return a list of input names that need to be evaluated. + + This function will be called if there are any lazy inputs which have not yet been + evaluated. As long as you return at least one field which has not yet been evaluated + (and more exist), this function will be called again once the value of the requested + field is available. + + Any evaluated inputs will be passed as arguments to this function. Any unevaluated + inputs will have the value None. + """ + return [] + + @classmethod + def execute(cls, sigmas_1, sigmas_2) -> io.NodeOutput: + if sigmas_1 is None: + return io.NodeOutput(sigmas_2) + if sigmas_2 is None: + return io.NodeOutput(sigmas_1) + + if not isinstance(sigmas_1, torch.Tensor): + sigmas_1 = torch.as_tensor(sigmas_1) + if not isinstance(sigmas_2, torch.Tensor): + sigmas_2 = torch.as_tensor(sigmas_2) + + if sigmas_1.dim() == 0: + sigmas_1 = sigmas_1.unsqueeze(0) + if sigmas_2.dim() == 0: + sigmas_2 = sigmas_2.unsqueeze(0) + + # If the last value of sigmas_1 is very close to the first value of sigmas_2, + # avoid duplicating that boundary sigma so the concatenated schedule has + # exactly one sample at the splice point. + # Ensure both tensors are on the same device/dtype for comparison + try: + sig1_last = sigmas_1[-1] + sig2_first = sigmas_2[0] + except Exception: + # If indexing fails (shouldn't for non-empty 1D tensors), fall back to simple concat + out = torch.cat((sigmas_1, sigmas_2), dim=0) + return io.NodeOutput(out) + + # Move comparison tensors to a common dtype/device for isclose + comp_dtype = sigmas_1.dtype + comp_device = sigmas_1.device + if sigmas_2.device != comp_device: + sigmas_2 = sigmas_2.to(comp_device) + sig2_first = sig2_first.to(comp_device) + if sigmas_2.dtype != comp_dtype: + sigmas_2 = sigmas_2.to(dtype=comp_dtype) + sig2_first = sig2_first.to(dtype=comp_dtype) + + # Use a relative tolerance based on magnitude plus a small absolute tolerance + atol = 1e-6 + rtol = 1e-4 + if torch.isclose(sig1_last, sig2_first, atol=atol, rtol=rtol): + # Concatenate but skip the duplicated first value of sigmas_2 + if sigmas_2.numel() > 1: + out = torch.cat((sigmas_1, sigmas_2[1:]), dim=0) + else: + # sigmas_2 only contained the duplicated boundary, so result is just sigmas_1 + out = sigmas_1.clone() + else: + out = torch.cat((sigmas_1, sigmas_2), dim=0) + + return io.NodeOutput(out) + + """ + The node will always be re executed if any of the inputs change but + this method can be used to force the node to execute again even when the inputs don't change. + You can make this node return a number or a string. This value will be compared to the one returned the last time the node was + executed, if it is different the node will be executed again. + This method is used in the core repo for the LoadImage node where they return the image hash as a string, if the image hash + changes between executions the LoadImage node is executed again. + """ + + #@classmethod + #def fingerprint_inputs(s, image, string_field, int_field, float_field, print_to_screen): + # return "" + +class SpliceSigmasAtNode(io.ComfyNode): + """ + Splice two sigma schedules at a relative point of the first schedule. + + Inputs: + - sigmas_a: first sigma schedule (descending) + - sigmas_b: second sigma schedule + - boundary: float in [0.0, 1.0] representing where to splice relative to the first sigma (0 -> start, 1 -> end) + + Outputs: + - full concatenated sigma (first_part + second_part without duplicated boundary) + - first_part (prefix of sigmas_a up to the boundary, boundary appended if needed) + - second_part (boundary prepended then suffix of sigmas_b <= boundary) + + Edge cases and behavior: + - If `boundary` is outside [0.0, 1.0], it is clamped to the range [0.0, 1.0]. + - The `boundary` value is interpreted as a fraction of the starting sigma value (sigmas_a[0] * (1.0 - boundary)). + For example, `boundary=0.0` results in a boundary equal to `sigmas_a[0]` (i.e. start), and `boundary=1.0` results in a boundary of 0. + - If no values in `sigmas_a` are >= boundary value, `first_part` will be empty (unless `include_boundary` is True, in which case the boundary value will be appended). + - If no values in `sigmas_b` are <= boundary value, `second_part` will be empty (unless `include_boundary` is True, in which case the boundary value will be prepended). + - When `include_boundary` is True but both schedules do not contain the boundary, the boundary will be inserted into both `first_part` and `second_part` (so `full` will contain the boundary once). + - If the inputs are on different devices or have different dtypes, callers should coerce them to a common device/dtype before connecting to this node; the node assumes inputs are compatible for comparisons and concatenation. + """ + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="SpliceSigmasAtImmacTools", + display_name="Splice Sigmas At %", + category="Example", + inputs=[ + io.Sigmas.Input("sigmas_a"), + io.Sigmas.Input("sigmas_b"), + io.Float.Input("boundary", default=0.5, min=0.0, max=1.0, step=0.005), + io.Boolean.Input( + "include_boundary", + display_name="include boundary?", + tooltip="If enabled, the boundary sigma is included at the splice point, which can smooth transitions but adds an extra step." + ) + ], + outputs=[ + io.Sigmas.Output("full","spliced_sigmas"), + io.Sigmas.Output("first_part","first_part"), + io.Sigmas.Output("second_part","second_part"), + ], + ) + + @classmethod + def check_lazy_status(cls, sigmas_a, sigmas_b, boundary, include_boundary): + # Signature matches the defined inputs: (sigmas_a, sigmas_b, boundary, include_boundary) + return [] + + @classmethod + def execute(cls, sigmas_a, sigmas_b, boundary, include_boundary) -> io.NodeOutput: + # If either schedule missing, pass through sensible defaults + if sigmas_a is None and sigmas_b is None: + return io.NodeOutput(None, None, None) + if sigmas_a is None: + return io.NodeOutput(sigmas_b, None, sigmas_b) + if sigmas_b is None: + return io.NodeOutput(sigmas_a, sigmas_a, None) + + # Ensure tensors and 1D + if not isinstance(sigmas_a, torch.Tensor): + sigmas_a = torch.as_tensor(sigmas_a) + if not isinstance(sigmas_b, torch.Tensor): + sigmas_b = torch.as_tensor(sigmas_b) + if sigmas_a.dim() == 0: + sigmas_a = sigmas_a.unsqueeze(0) + if sigmas_b.dim() == 0: + sigmas_b = sigmas_b.unsqueeze(0) + + # Clamp boundary to [0,1] + try: + p = float(boundary) + except Exception: + p = 0.5 + p = max(0.0, min(1.0, p)) + + # Boundary value: interpreted as the remaining fraction of the initial sigma + # (example: boundary=0.5 => boundary = sigmas_a[0] * 0.5) + start_val = float(sigmas_a[0].item()) + boundary_val = start_val * (1.0 - p) + + dtype = sigmas_a.dtype + device = sigmas_a.device + boundary_tensor = torch.as_tensor([boundary_val], dtype=dtype, device=device) + + # Build first part: all values from sigmas_a >= boundary, then ensure boundary appended if needed + mask_a = sigmas_a >= boundary_val + if mask_a.any(): + last_idx = torch.nonzero(mask_a, as_tuple=True)[0][-1].item() + first_part = sigmas_a[: last_idx + 1].clone() + else: + first_part = torch.empty((0,), dtype=dtype, device=device) + + # Build second part: include values from sigmas_b <= boundary + mask_b = sigmas_b <= boundary_val + if mask_b.any(): + suffix_b = sigmas_b[mask_b].clone() + else: + suffix_b = torch.empty((0,), dtype=dtype, device=device) + + if include_boundary: + # Append boundary to first_part if not present + if first_part.numel() == 0 or not torch.isclose(first_part[-1], boundary_tensor[0], atol=1e-6): + first_part = torch.cat((first_part, boundary_tensor)) + # Prepend boundary to second_part + second_part = torch.cat((boundary_tensor, suffix_b)) + # Concatenate without duplicating the boundary value + if second_part.numel() > 1: + full = torch.cat((first_part, second_part[1:])) + else: + full = torch.cat((first_part, second_part[0:0])) # just first_part if second_part has only boundary + else: + # No boundary is added, so just use first_part and suffix_b directly + second_part = suffix_b + # Concatenate first_part and second_part directly + full = torch.cat((first_part, second_part)) + + return io.NodeOutput(full, first_part, second_part) + +class ResampleSigmas(io.ComfyNode): + """ + ResampleSigmas + + Resample / re-interpret a sigma schedule to a requested number of steps. + + Inputs: + - sigmas: torch.Tensor or tensor-like (1D or scalar). May be None. + - steps: int - number of steps to produce (clamped to >= 1) + + Output: + - io.NodeOutput containing the resampled torch.Tensor (shape: [steps + 1, ...]) or None if input sigmas is None + + Behavior: + - The node interprets `steps` as the number of intervals; the returned schedule includes both endpoints and + therefore has length `steps + 1`. + - If `sigmas` is scalar, the output is a length-`steps + 1` tensor filled with that value. + - If `sigmas` is 1D (T,), output is produced by linear interpolation of the curve defined by the T points at + positions [0..T-1] sampled at `steps + 1` evenly spaced positions in that same range. + - If `sigmas` has trailing dimensions (T, ...), interpolation is applied across the first (time) dimension and + trailing dimensions are preserved. + - Dtype and device of the output match the input. + """ + + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="ResampleSigmasImmacTools", + display_name="Resample Sigmas", + category="Example", + inputs=[ + io.Sigmas.Input("sigmas"), + io.Int.Input("steps", default=10, min=1), + ], + outputs=[ + io.Sigmas.Output(), + ], + ) + + @classmethod + def check_lazy_status(cls, sigmas, steps): + return [] + + @classmethod + def execute(cls, sigmas, steps) -> io.NodeOutput: + # Pass-throughs + if sigmas is None: + return io.NodeOutput(None) + + # Ensure steps is an int and at least 1 + try: + s = int(steps) + except Exception: + s = 1 + s = max(1, s) + # We interpret `steps` as the number of intervals; the returned sigma schedule + # should include both endpoints, so it has length `s + 1`. + out_len = s + 1 + + # Ensure tensor + if not isinstance(sigmas, torch.Tensor): + sigmas = torch.as_tensor(sigmas) + + # Defensive: ensure we have a floating dtype for linspace and interpolation. + # If sigmas is integer or non-floating, cast to the default floating dtype + # while keeping device. This prevents unexpected integer linspace behavior. + if not torch.is_floating_point(sigmas): + sigmas = sigmas.to(dtype=torch.get_default_dtype()) + + # If scalar -> repeat the single value for each timestep including endpoints + if sigmas.dim() == 0: + out = sigmas.unsqueeze(0).repeat(out_len) + return io.NodeOutput(out) + + # Work along first dimension (time). Preserve trailing dims. + T = sigmas.shape[0] + dtype = sigmas.dtype + device = sigmas.device + + # If only one input time point, just repeat that point for each output timestep + if T == 1: + out = sigmas.unsqueeze(0).repeat(out_len, *([1] * (sigmas.dim() - 1))) + return io.NodeOutput(out) + + # Flatten trailing dims so we can vectorize interpolation + trailing_shape = sigmas.shape[1:] + D = 1 + for d in trailing_shape: + D *= d + sig_flat = sigmas.contiguous().view(T, D) + + # Source x positions and target x positions + src_x = torch.linspace(0.0, float(T - 1), steps=T, dtype=dtype, device=device) + # Target positions: produce `out_len` samples evenly spaced across [0, T-1] + tgt_x = torch.linspace(0.0, float(T - 1), steps=out_len, dtype=dtype, device=device) + + # For each target position find right-side index + idx = torch.searchsorted(src_x, tgt_x) + # idx in [0..T]; left = clamp(idx-1,0), right = clamp(idx, T-1) + right = torch.clamp(idx, 0, T - 1) + left = torch.clamp(right - 1, 0, T - 1) + + xL = src_x[left] + xR = src_x[right] + + # Gather yL/yR -> shape (out_len, D) + yL = sig_flat[left] # indexing with (out_len,) yields (out_len, D) + yR = sig_flat[right] + + # Avoid division by zero where xR == xL (shouldn't happen except maybe at boundaries) + denom = (xR - xL) + # Where denom==0, set weight to 0 to pick yL + denom_safe = denom.clone() + denom_safe[denom_safe == 0] = 1.0 + weight = ((tgt_x - xL) / denom_safe).unsqueeze(1) + weight = weight.clamp(0.0, 1.0) + + out_flat = yL * (1.0 - weight) + yR * weight + + out = out_flat.view((out_len,) + trailing_shape) + + return io.NodeOutput(out) + + +class SkipEveryNthImages(io.ComfyNode): + """ + Skip every nth image in a batch. + + Accepts a batch of images (list/tuple of frames or torch tensor with batch dim) and drops the nth item while preserving order of the rest. Outputs the filtered batch and the indices that were removed. + """ + + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="SkipEveryNthImagesImmacTools", + display_name="Skip Every Nth Image", + category="Example", + inputs=[ + io.AnyType.Input("images", display_name="images", tooltip="Batch of images/frames (list or tensor)."), + io.Int.Input("n", default=2, min=1, tooltip="Skip every nth image; n<=0 yields empty output."), + ], + outputs=[ + io.AnyType.Output("filtered", display_name="images"), + io.AnyType.Output("removed_indices", display_name="removed indices"), + ], + ) + + @classmethod + def check_lazy_status(cls, images, n): + return [] + + @classmethod + def execute(cls, images, n) -> io.NodeOutput: + # Normalize n; if invalid, treat as <=0 and drop everything + try: + step = int(n) + except Exception: + step = 0 + + # Torch tensor batch: slice along batch dim + if isinstance(images, torch.Tensor): + total = images.shape[0] if images.dim() > 0 else 0 + if total == 0 or step <= 0: + empty = images.new_empty((0,) + tuple(images.shape[1:])) + removed = list(range(total)) + return io.NodeOutput(empty, removed) + + keep_mask = torch.ones(total, dtype=torch.bool, device=images.device) + keep_mask[step - 1 :: step] = False # zero-based index for the nth elements + filtered = images[keep_mask] if total > 0 else images.new_empty((0,) + tuple(images.shape[1:])) + removed = [i for i in range(total) if (i + 1) % step == 0] + return io.NodeOutput(filtered, removed) + + # Sequence (list/tuple) batch: iterate and filter + if isinstance(images, Sequence) and not isinstance(images, (str, bytes)): + total = len(images) + if step <= 0: + removed = list(range(total)) + return io.NodeOutput([], removed) + + kept = [] + removed = [] + for idx, img in enumerate(images): + if (idx + 1) % step == 0: + removed.append(idx) + else: + kept.append(img) + return io.NodeOutput(kept, removed) + + # Fallback: unsupported type, pass through unchanged + return io.NodeOutput(images, []) + + +# --------------------------------------------------------------------------- +# Histogram matching helpers +# --------------------------------------------------------------------------- + +def _match_histograms_channel(src: np.ndarray, ref: np.ndarray) -> np.ndarray: + """Match the histogram of *src* to that of *ref*. + + Both arrays must be float32 in [0, 1] and can have any shape. + The mapping is built by aligning the cumulative distribution functions (CDFs) + of the two arrays. We quantise to uint16 (65 536 levels) which gives + sub-0.002 % precision while keeping the LUT small and fast. + + Returns a float32 array with the same shape as *src*. + """ + src_u = np.clip(np.round(src * 65535.0), 0, 65535).astype(np.uint16) + ref_u = np.clip(np.round(ref * 65535.0), 0, 65535).astype(np.uint16) + + src_hist = np.bincount(src_u.ravel(), minlength=65536) + ref_hist = np.bincount(ref_u.ravel(), minlength=65536) + + src_cdf = np.cumsum(src_hist, dtype=np.float64) / float(src_u.size) + ref_cdf = np.cumsum(ref_hist, dtype=np.float64) / float(ref_u.size) + + # For each source quantisation level, find the reference level whose CDF + # value is closest (i.e. has the same mass to the left). + lut = np.searchsorted(ref_cdf, src_cdf, side="left") + lut = np.clip(lut, 0, 65535).astype(np.uint16) + + matched_u = lut[src_u] + return matched_u.astype(np.float32) / 65535.0 + + +# ---- pure-numpy sRGB <-> linear <-> XYZ <-> LAB helpers (D65) ---- + +def _srgb_to_linear(c: np.ndarray) -> np.ndarray: + return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4) + +def _linear_to_srgb(c: np.ndarray) -> np.ndarray: + c = np.clip(c, 0.0, 1.0) + return np.where(c <= 0.0031308, c * 12.92, 1.055 * c ** (1.0 / 2.4) - 0.055) + +_M_RGB_TO_XYZ = np.array([ + [0.4124564, 0.3575761, 0.1804375], + [0.2126729, 0.7151522, 0.0721750], + [0.0193339, 0.1191920, 0.9503041], +], dtype=np.float64) + +_M_XYZ_TO_RGB = np.array([ + [ 3.2404542, -1.5371385, -0.4985314], + [-0.9692660, 1.8760108, 0.0415560], + [ 0.0556434, -0.2040259, 1.0572252], +], dtype=np.float64) + +_D65 = np.array([0.95047, 1.00000, 1.08883], dtype=np.float64) + + +def _f_lab(t: np.ndarray) -> np.ndarray: + delta = 6.0 / 29.0 + return np.where(t > delta ** 3, np.cbrt(t), t / (3.0 * delta ** 2) + 4.0 / 29.0) + + +def _f_lab_inv(t: np.ndarray) -> np.ndarray: + delta = 6.0 / 29.0 + return np.where(t > delta, t ** 3, 3.0 * delta ** 2 * (t - 4.0 / 29.0)) + + +def _rgb_to_lab(img: np.ndarray) -> np.ndarray: + """Convert [H, W, 3] float32 sRGB image to CIE LAB.""" + lin = _srgb_to_linear(img.astype(np.float64)) + xyz = lin @ _M_RGB_TO_XYZ.T + xyz_n = xyz / _D65 + f = _f_lab(xyz_n) + L = 116.0 * f[..., 1] - 16.0 + a = 500.0 * (f[..., 0] - f[..., 1]) + b = 200.0 * (f[..., 1] - f[..., 2]) + return np.stack([L, a, b], axis=-1).astype(np.float32) + + +def _lab_to_rgb(lab: np.ndarray) -> np.ndarray: + """Convert [H, W, 3] float32 CIE LAB image to sRGB [0, 1] float32.""" + lab64 = lab.astype(np.float64) + L, a, b = lab64[..., 0], lab64[..., 1], lab64[..., 2] + fy = (L + 16.0) / 116.0 + fx = a / 500.0 + fy + fz = fy - b / 200.0 + xyz = np.stack([_f_lab_inv(fx), _f_lab_inv(fy), _f_lab_inv(fz)], axis=-1) * _D65 + lin = xyz @ _M_XYZ_TO_RGB.T + return np.clip(_linear_to_srgb(lin), 0.0, 1.0).astype(np.float32) + + +class MatchContrastNode(io.ComfyNode): + """ + MatchContrastNode + + Adjusts the contrast (tonal distribution) of *image* to match that of + *reference* using CDF-based histogram matching. + + Inputs: + image – the image whose contrast will be adjusted [B, H, W, C] + reference – the image whose contrast is the target [B, H, W, C] + channel_mode: + "rgb" – match each R/G/B channel independently + "luminance" – match only the L channel in CIE LAB space + (preserves the hue and saturation of *image*, + changes only brightness/contrast) + + Output: + Adjusted image with the same spatial dimensions as *image*. + + Notes: + • If *reference* contains fewer frames than *image*, the first frame + of *reference* is used for all source frames. + • The matching is done per-frame (not across the batch). + • No external dependencies beyond NumPy and PyTorch. + """ + + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="MatchContrastImmacTools", + display_name="Match Contrast", + category="image/adjust", + inputs=[ + io.Image.Input("image", display_name="image"), + io.Image.Input("reference", display_name="reference"), + io.Combo.Input( + "channel_mode", + options=["rgb", "luminance"], + default="luminance", + display_name="channel mode", + tooltip=( + "rgb: match each R/G/B channel independently. " + "luminance: match only the L channel in CIE LAB " + "(keeps source colours, adjusts brightness/contrast)." + ), + ), + io.Float.Input( + "strength", + default=1.0, + min=0.0, + max=1.0, + step=0.01, + display_name="strength", + tooltip="Blend between the original image (0.0) and the fully matched image (1.0).", + ), + ], + outputs=[ + io.Image.Output(display_name="image"), + ], + ) + + @classmethod + def check_lazy_status(cls, image, reference, channel_mode, strength): + return [] + + @classmethod + def execute(cls, image: torch.Tensor, reference: torch.Tensor, channel_mode: str, strength: float) -> io.NodeOutput: + # image / reference: [B, H, W, C] float32 in [0, 1] + B = image.shape[0] + ref_B = reference.shape[0] + s = float(np.clip(strength, 0.0, 1.0)) + + out_frames: list[np.ndarray] = [] + + for i in range(B): + src_np = image[i].cpu().numpy() # [H, W, C] + # Broadcast reference: use frame i if available, else frame 0 + ref_np = reference[min(i, ref_B - 1)].cpu().numpy() # [H, W, C] + + if channel_mode == "luminance": + matched = cls._match_luminance(src_np, ref_np) + else: # "rgb" + matched = cls._match_rgb(src_np, ref_np) + + # Blend: 0.0 = original, 1.0 = fully matched + blended = src_np * (1.0 - s) + matched * s + out_frames.append(blended) + + result = torch.from_numpy(np.stack(out_frames, axis=0)).to(image.device) + return io.NodeOutput(result) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + @staticmethod + def _match_rgb(src: np.ndarray, ref: np.ndarray) -> np.ndarray: + """Match each RGB channel of *src* to *ref* independently.""" + C = src.shape[-1] + channels = [ + _match_histograms_channel(src[..., c], ref[..., c]) + for c in range(C) + ] + return np.stack(channels, axis=-1) + + @staticmethod + def _match_luminance(src: np.ndarray, ref: np.ndarray) -> np.ndarray: + """Match only the LAB L-channel of *src* to *ref*; keep src a/b.""" + src_lab = _rgb_to_lab(src) # [H, W, 3] + ref_lab = _rgb_to_lab(ref) + + # Normalise L from [0, 100] to [0, 1] for the histogram helper + L_src = src_lab[..., 0] / 100.0 + L_ref = ref_lab[..., 0] / 100.0 + + L_matched = _match_histograms_channel(L_src, L_ref) * 100.0 # back to [0,100] + + out_lab = src_lab.copy() + out_lab[..., 0] = L_matched + return _lab_to_rgb(out_lab) + + +# Set the web directory, any .js file in that directory will be loaded by the frontend as a frontend extension +# WEB_DIRECTORY = "./somejs" + + +# Add custom API routes, using router + +class ExampleExtension(ComfyExtension): + @override + async def get_node_list(self) -> list[type[io.ComfyNode]]: + return [ + ConcatenateSigmasNode, + SpliceSigmasAtNode, + ResampleSigmas, + SkipEveryNthImages, + MatchContrastNode, + ForwardAnyNode, + ForwardConditioningNode, + ForwardModelNode, + ] + +async def comfy_entrypoint() -> ExampleExtension: # ComfyUI calls this to load your extension and its nodes. + return ExampleExtension() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..2ad204b --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Unit test package for immac_tools.""" diff --git a/tests/__pycache__/__init__.cpython-312.pyc b/tests/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..48d704c4be69d73018a2133c1284ab22d0191e93 GIT binary patch literal 241 zcmYjLu?oU46iigK2>plRWEb-T;wLx=E-sIdrW$PeN|FxsNBjoA#o48!g0s-g$)qTF z%iY0och@+c5|7*cc}P7!g86}dMHhK-CK=h0hNgqlY+EXqIB8u{adG5(ndE2^r8O7e z5DU9lr?i~_WGDbGqzgrFUIfJN1`(Nbep7jk9BelNVY;rVMwDQ(HzjJ){(u&hY) literal 0 HcmV?d00001 diff --git a/tests/__pycache__/conftest.cpython-312-pytest-8.4.2.pyc b/tests/__pycache__/conftest.cpython-312-pytest-8.4.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..381cb4b1bfb3f643021903f1390010b54dcf400e GIT binary patch literal 1095 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 literal 0 HcmV?d00001 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..310609c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,6 @@ +import os +import sys + +# Add the project root directory to Python path +# This allows the tests to import the project +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) diff --git a/tests/pytest.ini b/tests/pytest.ini new file mode 100644 index 0000000..95c76f1 --- /dev/null +++ b/tests/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +testpaths = . # Run tests in the current directory +python_files = test_*.py # Run tests in files that start with "test_" +norecursedirs = .. # Don't run tests in the parent directory diff --git a/tests/test_immac_tools.py b/tests/test_immac_tools.py new file mode 100644 index 0000000..3c049b5 --- /dev/null +++ b/tests/test_immac_tools.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python + +"""Tests for `immac_tools` package.""" + +import pytest +from src.immac_tools.nodes import ConcatenateSigmasNode + +@pytest.fixture +def example_node(): + """Fixture to create an Example node instance.""" + return ConcatenateSigmasNode() + +def test_example_node_initialization(example_node): + """Test that the node can be instantiated.""" + assert isinstance(example_node, ConcatenateSigmasNode) + +def test_return_types(): + """Test the node's metadata.""" + assert ConcatenateSigmasNode.RETURN_TYPES == ("IMAGE",) + assert ConcatenateSigmasNode.FUNCTION == "test" + assert ConcatenateSigmasNode.CATEGORY == "Example"