diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d2795e --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Python-specific +__pycache__/ +*.pyc + +# Virtual environments +.env/ +venv/ + +# IDE files +.idea/ +.vscode/ +*.code-workspace + +# Build artifacts +build/ +dist/ +*.egg-info/ + +# Other +.DS_Store + +# user junk +__user/ +.coverage +.env \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f5db99a --- /dev/null +++ b/LICENSE @@ -0,0 +1,23 @@ +MIT License + +Copyright (c) 2025 Alexander G. Morano + +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. + +GO NUTS; JUST TRY NOT TO DO IT IN YOUR HEAD. diff --git a/README.md b/README.md index b067d10..157f302 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,82 @@ -# Jovi_Measure -Image metrics nodes for ComfyUI +
+ + + + Image metrics nodes for ComfyUI + + +
+ +
+ +COMFYUI Nodes for image metrics + +
+ +
+ +![KNIVES!](https://badgen.net/github/open-issues/Amorano/Jovi_Measure) +![FORKS!](https://badgen.net/github/forks/Amorano/Jovi_Measure) + +
+ + + +# HIGHLIGHTS + +![image](https://github.com/user-attachments/assets/d8d13740-8dbc-4cc3-ad52-072a32c91517) + +## UPDATES + +**2024/01/07** @1.0.0: +* initial release + +# INSTALLATION + +## COMFYUI MANAGER + +If you have [ComfyUI Manager](https://github.com/ltdrdata/ComfyUI-Manager) installed, simply search for Jovi_Measure and install from the manager's database. + +## MANUAL INSTALL +Clone the repository into your ComfyUI custom_nodes directory. You can clone the repository with the command: +``` +git clone https://github.com/Amorano/Jovi_Measure.git +``` +You can then install the requirements by using the command: +``` +.\python_embed\python.exe -s -m pip install -r .\ComfyUI\custom_nodes\Jovi_Measure\requirements.txt +``` +If you are using a virtual environment (venv), make sure it is activated before installation. Then install the requirements with the command: +``` +pip install -r .\ComfyUI\custom_nodes\Jovi_Measure\requirements.txt +``` +## ENVIRONMENT + + + + + + + + +# SPONSORSHIP + +Please consider sponsoring me if you enjoy the results of my work, code or documentation or otherwise. A good way to keep code development open and free is through sponsorship. + +
+ +[![BE A GITHUB SPONSOR ❤️](https://img.shields.io/badge/sponsor-30363D?style=for-the-badge&logo=GitHub-Sponsors&logoColor=#EA4AAA)](https://github.com/sponsors/Amorano) + +[![DIRECTLY SUPPORT ME VIA PAYPAL](https://img.shields.io/badge/PayPal-00457C?style=for-the-badge&logo=paypal&logoColor=white)](https://www.paypal.com/paypalme/onarom) + +[![PATREON SUPPORTER](https://img.shields.io/badge/Patreon-F96854?style=for-the-badge&logo=patreon&logoColor=white)](https://www.patreon.com/joviex) + +[![SUPPORT ME ON KO-FI!](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/alexandermorano) + +
+ + + +# WHERE TO FIND ME + +You can find me on [![DISCORD](https://dcbadge.vercel.app/api/server/62TJaZ3Z5r?style=flat-square)](https://discord.gg/62TJaZ3Z5r). diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..d64ee2e --- /dev/null +++ b/__init__.py @@ -0,0 +1,152 @@ +""" + ██╗ ██████╗ ██╗ ██╗██╗ ███╗ ███╗███████╗ █████╗ ███████╗██╗ ██╗██████╗ ███████╗ + ██║██╔═══██╗██║ ██║██║ ████╗ ████║██╔════╝██╔══██╗██╔════╝██║ ██║██╔══██╗██╔════╝ + ██║██║ ██║██║ ██║██║ ██╔████╔██║█████╗ ███████║███████╗██║ ██║██████╔╝█████╗ +██ ██║██║ ██║╚██╗ ██╔╝██║ ██║╚██╔╝██║██╔══╝ ██╔══██║╚════██║██║ ██║██╔══██╗██╔══╝ +╚█████╔╝╚██████╔╝ ╚████╔╝ ██║ ██║ ╚═╝ ██║███████╗██║ ██║███████║╚██████╔╝██║ ██║███████╗ + ╚════╝ ╚═════╝ ╚═══╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ + + Image metrics nodes for ComfyUI + http://www.github.com/amorano/Jovi_Measure +""" + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] +__author__ = """Alexander G. Morano""" +__email__ = "amorano@gmail.com" +__version__ = "1.0.0" + +import os +import sys +import json +import inspect +import importlib +from pathlib import Path +from types import ModuleType +from typing import Any + +from loguru import logger + +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} +WEB_DIRECTORY = "./web" + +ROOT = Path(__file__).resolve().parent +ROOT_COMFY = ROOT.parent.parent + +JOV_WEB = ROOT / 'web' + +JOV_LOG_LEVEL = os.getenv("JOV_LOG_LEVEL", "INFO") +logger.configure(handlers=[{"sink": sys.stdout, "level": JOV_LOG_LEVEL}]) + +JOV_INTERNAL = os.getenv("JOV_INTERNAL", 'false').strip().lower() in ('true', '1', 't') + +JOV_PACKAGE = "JOV_MEASURE" + +# ============================================================================== +# === CORE NODES === +# ============================================================================== + +class JOVBaseNode: + NOT_IDEMPOTENT = True + CATEGORY = f"{JOV_PACKAGE} 📺" + RETURN_TYPES = () + FUNCTION = "run" + + @classmethod + def IS_CHANGED(cls, **kw) -> float: + return float('nan') + + @classmethod + def VALIDATE_INPUTS(cls, *arg, **kw) -> bool: + return True + + @classmethod + def INPUT_TYPES(cls, prompt:bool=False, extra_png:bool=False, dynprompt:bool=False) -> dict: + data = { + "required": {}, + "hidden": { + "ident": "UNIQUE_ID" + } + } + if prompt: + data["hidden"]["prompt"] = "PROMPT" + if extra_png: + data["hidden"]["extra_pnginfo"] = "EXTRA_PNGINFO" + + if dynprompt: + data["hidden"]["dynprompt"] = "DYNPROMPT" + return data + +# ============================================================================== +# === TYPE === +# ============================================================================== + +class AnyType(str): + """AnyType input wildcard trick taken from pythongossss's: + + https://github.com/pythongosssss/ComfyUI-Custom-Scripts + """ + def __ne__(self, __value: object) -> bool: + return False + +JOV_TYPE_ANY = AnyType("*") + +# ============================================================================== +# === NODE LOADER === +# ============================================================================== + +def load_module(name: str) -> None|ModuleType: + module = inspect.getmodule(inspect.stack()[0][0]).__name__ + try: + route = str(name).replace("\\", "/") + route = route.split(f"{module}/core/")[1] + route = route.split('.')[0].replace('/', '.') + except Exception as e: + logger.warning(f"module failed {name}") + logger.warning(str(e)) + return + + try: + module = f"{module}.core.{route}" + module = importlib.import_module(module) + except Exception as e: + logger.warning(f"module failed {module}") + logger.warning(str(e)) + return + + return module + +def loader(): + global NODE_DISPLAY_NAME_MAPPINGS, NODE_CLASS_MAPPINGS + NODE_LIST_MAP = {} + + for fname in ROOT.glob('core/**/*.py'): + if fname.stem.startswith('_'): + continue + + if (module := load_module(fname)) is None: + continue + + classes = inspect.getmembers(module, inspect.isclass) + for class_name, class_object in classes: + if not class_name.endswith('BaseNode') and hasattr(class_object, 'NAME') and hasattr(class_object, 'CATEGORY'): + name = f"{class_object.NAME} ({JOV_PACKAGE})" + NODE_DISPLAY_NAME_MAPPINGS[name] = name + NODE_CLASS_MAPPINGS[name] = class_object + desc = class_object.DESCRIPTION if hasattr(class_object, 'DESCRIPTION') else name + NODE_LIST_MAP[name] = desc.split('.')[0].strip('\n') + + NODE_CLASS_MAPPINGS = {x[0] : x[1] for x in sorted(NODE_CLASS_MAPPINGS.items(), + key=lambda item: getattr(item[1], 'SORT', 0))} + + keys = NODE_CLASS_MAPPINGS.keys() + for name in keys: + logger.debug(f"✅ {name}") + logger.info(f"{len(keys)} nodes loaded") + + # only do the list on local runs... + if JOV_INTERNAL: + with open(str(ROOT) + "/node_list.json", "w", encoding="utf-8") as f: + json.dump(NODE_LIST_MAP, f, sort_keys=True, indent=4 ) + +loader() diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..25e2977 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,30 @@ +""" +Jovi_Measure - http://www.github.com/Amorano/Jovi_Measure +Core +""" + +from .. import JOVBaseNode + +# ============================================================================== +# === SUPPORT === +# ============================================================================== + +def deep_merge(d1: dict, d2: dict) -> dict: + """ + Deep merge multiple dictionaries recursively. + + Args: + *dicts: Variable number of dictionaries to be merged. + + Returns: + dict: Merged dictionary. + """ + for key in d2: + if key in d1: + if isinstance(d1[key], dict) and isinstance(d2[key], dict): + deep_merge(d1[key], d2[key]) + else: + d1[key] = d2[key] + else: + d1[key] = d2[key] + return d1 diff --git a/core/metrix.py b/core/metrix.py new file mode 100644 index 0000000..189863d --- /dev/null +++ b/core/metrix.py @@ -0,0 +1,105 @@ +""" +Jovi_Measure - http://www.github.com/amorano/Jovi_Measure +Image Metrics +""" + +import torch +import numpy as np +import skimage.measure as skm + +from comfy.utils import ProgressBar + +from .. import JOVBaseNode + +from . import deep_merge + +# ============================================================================== +# === SUPPORT === +# ============================================================================== + +def tensor2cv(tensor: torch.Tensor, invert_mask:bool=True) -> np.ndarray: + """Convert a torch Tensor to a numpy ndarray.""" + if tensor.ndim > 3: + raise Exception("Tensor is batch of tensors") + + if tensor.ndim < 3: + tensor = tensor.unsqueeze(-1) + + if tensor.shape[2] == 1 and invert_mask: + tensor = 1. - tensor + + tensor = tensor.cpu().numpy() + return np.clip(255.0 * tensor, 0, 255).astype(np.uint8) + +# ============================================================================== +# === COMFYUI NODE === +# ============================================================================== + +class ShannonEntropyNode(JOVBaseNode): + NAME = "SHANNON ENTROPY" + RETURN_TYPES = ("FLOAT",) + RETURN_NAMES = ("FLOAT",) + SORT = 50 + DESCRIPTION = """ +Calculate the Shannon entropy of an image. +""" + + @classmethod + def INPUT_TYPES(cls) -> dict: + d = super().INPUT_TYPES() + d = deep_merge(d, { + "required": { + 'image': ("IMAGE", {"default": None}), + } + }) + return d + + def run(self, image, **kw) -> float: + vals = [] + images = [i for i in image] + pbar = ProgressBar(len(images)) + for idx, image in enumerate(images): + image = tensor2cv(image) + val = skm.shannon_entropy(image) + vals.append(val) + print(val) + pbar.update_absolute(idx) + return vals, + +class BlurEffectNode(JOVBaseNode): + NAME = "BLUR EFFECT" + RETURN_TYPES = ("FLOAT",) + RETURN_NAMES = ("FLOAT",) + SORT = 50 + DESCRIPTION = """ +Calculate the Shannon entropy of an image. +""" + + @classmethod + def INPUT_TYPES(cls) -> dict: + d = super().INPUT_TYPES() + d = deep_merge(d, { + "required": { + 'image': ("IMAGE", {"default": None}), + }, + "optional": { + 'h_size': ("INT", {"default": 11, "tooltip": "Size of the re-blurring filter."}), + # 'channel_axis': ("INT", {"default": 0, "min": 0, "max": 3}), + } + }) + return d + + def run(self, image, h_size, **kw) -> float: + vals = [] + images = [i for i in image] + pbar = ProgressBar(len(images)) + for idx, image in enumerate(images): + image = tensor2cv(image) + channel_axis = 2 + if len(hwc := image.shape) == 2 or hwc[2] == 1: + channel_axis = None + val = skm.blur_effect(image, h_size=h_size, channel_axis=channel_axis) + vals.append(val) + print(val) + pbar.update_absolute(idx) + return vals, \ No newline at end of file diff --git a/node_list.json b/node_list.json new file mode 100644 index 0000000..3057b81 --- /dev/null +++ b/node_list.json @@ -0,0 +1,4 @@ +{ + "BLUR EFFECT (JOV_MEASURE)": "Calculate the Shannon entropy of an image", + "SHANNON ENTROPY (JOV_MEASURE)": "Calculate the Shannon entropy of an image" +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d9b0788 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "Jovi_Measure" +description = "ComfyUI Nodes for using Spout streams " +version = "1.0.0" +license = { file = "LICENSE" } +dependencies = [ + "loguru", + "numpy>=1.26.4,<2.0.0; python_version < '3.12'", + "numpy>=2.0.0; python_version > '3.11'", + "scikit-image", +] + +[project.urls] +Repository = "https://github.com/Amorano/Jovi_Measure" + +[tool.comfy] +PublisherId = "amorano" +DisplayName = "Jovi_Measure" +Icon = "" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6f863ca --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +loguru +numpy>=1.26.4,<2.0.0; python_version < '3.12' +numpy>=2.0.0; python_version > '3.11' +scikit-image