diff --git a/README.md b/README.md index 13d4f4d..01ba607 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,7 @@ -# ComfyUI-Image-Effects -Complete collection of image effects for ComfyUI - 32 nodes across 6 categories +# Image Effects - Collection d'effets d'image pour ComfyUI + +Collection complète de nœuds d'effets d'image pour ComfyUI, organisée en 7 catégories. + +## 📦 Installation + +1. Clonez ce repository dans votre dossier `custom_nodes` : diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..da69857 --- /dev/null +++ b/__init__.py @@ -0,0 +1,146 @@ +""" +Image Effects - Collection d'effets d'image pour ComfyUI +Version: 1.0.0 +""" + +# Imports des modules core +from .core.channel_mixer_node import ChannelMixerNode +from .core.color_balance_node import ColorBalanceNode +from .core.curves_node import CurvesNode +from .core.levels_node import LevelsNode +from .core.saver_plus_node import SaverPlusNode +from .core.shadow_highlight_node import ShadowHighlightNode +from .core.vibrance_node import VibranceNode + +# Imports des modules creative +from .creative.ascii_art_node import AsciiArtNode +from .creative.ascii_text_node import AsciiTextNode +from .creative.css_filters_node import CSSFiltersNode +from .creative.kaleidoscope_node import KaleidoscopeNode, KaleidoscopeAdvancedNode + +# Imports des modules vintage +from .vintage.vhs_glitch_node import VHSGlitchNode +from .vintage.film_grain_node import FilmGrainNode +from .vintage.light_leaks_node import LightLeaksNode +from .vintage.vintage_tv_node import VintageTVNode +from .vintage.polaroid_node import PolaroidNode + +# Imports des modules deformation +from .deformation.fisheye_node import FisheyeNode +from .deformation.barrel_distortion_node import BarrelDistortionNode +from .deformation.ripple_node import RippleNode +from .deformation.spherize_node import SpherizeNode +from .deformation.pinch_node import PinchNode + +# Imports des modules light effects +from .light_effects.lens_flare_node import LensFlareNode +from .light_effects.god_rays_node import GodRaysNode +from .light_effects.neon_glow_node import NeonGlowNode +from .light_effects.holographic_node import HolographicNode +from .light_effects.aurora_node import AuroraNode + +# Imports des modules geometric +from .geometric.triangulate_node import TriangulateNode +from .geometric.voronoi_node import VoronoiNode +from .geometric.hexagonal_pixelate_node import HexagonalPixelateNode +from .geometric.crystallize_node import CrystallizeNode +from .geometric.polygon_node import PolygonNode + +NODE_CLASS_MAPPINGS = { + # Core Effects + "ChannelMixerNode": ChannelMixerNode, + "ColorBalanceNode": ColorBalanceNode, + "CurvesNode": CurvesNode, + "LevelsNode": LevelsNode, + "SaverPlusNode": SaverPlusNode, + "ShadowHighlightNode": ShadowHighlightNode, + "VibranceNode": VibranceNode, + + # Creative Effects + "AsciiArtNode": AsciiArtNode, + "AsciiTextNode": AsciiTextNode, + "CSSFiltersNode": CSSFiltersNode, + "KaleidoscopeNode": KaleidoscopeNode, + "KaleidoscopeAdvancedNode": KaleidoscopeAdvancedNode, + + # Vintage Effects + "VHSGlitchNode": VHSGlitchNode, + "FilmGrainNode": FilmGrainNode, + "LightLeaksNode": LightLeaksNode, + "VintageTVNode": VintageTVNode, + "PolaroidNode": PolaroidNode, + + # Deformation + "FisheyeNode": FisheyeNode, + "BarrelDistortionNode": BarrelDistortionNode, + "RippleNode": RippleNode, + "SpherizeNode": SpherizeNode, + "PinchNode": PinchNode, + + # Light effects + "LensFlareNode": LensFlareNode, + "GodRaysNode": GodRaysNode, + "NeonGlowNode": NeonGlowNode, + "HolographicNode": HolographicNode, + "AuroraNode": AuroraNode, + + # Geometric + "TriangulateNode": TriangulateNode, + "VoronoiNode": VoronoiNode, + "HexagonalPixelateNode": HexagonalPixelateNode, + "CrystallizeNode": CrystallizeNode, + "PolygonNode": PolygonNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + # Core Effects + "ChannelMixerNode": "🔀 Channel Mixer", + "ColorBalanceNode": "🎨 Color Balance", + "CurvesNode": "📈 RGB Curves", + "LevelsNode": "🎚️ Levels Adjustment", + "SaverPlusNode": "💾 Saver Plus", + "ShadowHighlightNode": "🌗 Shadow/Highlight", + "VibranceNode": "🌈 Vibrance & Saturation", + + # Creative Effects + "AsciiArtNode": "🎭 ASCII Art Generator", + "AsciiTextNode": "📝 ASCII Text Generator", + "CSSFiltersNode": "🎛️ CSS Filters", + "KaleidoscopeNode": "🔮 Kaleidoscope Effect", + "KaleidoscopeAdvancedNode": "✨ Kaleidoscope Advanced", + + # Vintage Effects + "VHSGlitchNode": "📼 VHS Glitch", + "FilmGrainNode": "🎞️ Film Grain", + "LightLeaksNode": "💡 Light Leaks", + "VintageTVNode": "📺 Vintage TV", + "PolaroidNode": "📷 Polaroid Effect", + + # Deformation + "FisheyeNode": "🐠 Fisheye", + "BarrelDistortionNode": "🍐 Barrel Distortion", + "RippleNode": "🌊 Ripple", + "SpherizeNode": "🔵 Spherize", + "PinchNode": "🤏 Pinch", + + # Light effects + "LensFlareNode": "💡 Lens Flare", + "GodRaysNode": "🌞 God Rays", + "NeonGlowNode": "🌟 Neon Glow", + "HolographicNode": "🌈 Holographic", + "AuroraNode": "🌌 Aurora", + + # Geometric + "TriangulateNode": "🔺 Triangulate", + "VoronoiNode": "📐 Voronoi", + "HexagonalPixelateNode": "⬡ Hexagonal Pixelate", + "CrystallizeNode": "❄️ Crystallize", + "PolygonNode": "🔷 Polygon" +} + +__version__ = "1.0.0" + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "__version__"] + +print(f"[Image Effects] Package v{__version__} loaded with {len(NODE_CLASS_MAPPINGS)} nodes") +print(f"[Image Effects] Core: 7 nodes, Creative: 5 nodes, Vintage: 5 nodes") diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..9a22256 Binary files /dev/null and b/__pycache__/__init__.cpython-312.pyc differ diff --git a/__pycache__/ascii_art_node.cpython-312.pyc b/__pycache__/ascii_art_node.cpython-312.pyc new file mode 100644 index 0000000..6ef6697 Binary files /dev/null and b/__pycache__/ascii_art_node.cpython-312.pyc differ diff --git a/__pycache__/ascii_text_node.cpython-312.pyc b/__pycache__/ascii_text_node.cpython-312.pyc new file mode 100644 index 0000000..8b10536 Binary files /dev/null and b/__pycache__/ascii_text_node.cpython-312.pyc differ diff --git a/__pycache__/channel_mixer_node.cpython-312.pyc b/__pycache__/channel_mixer_node.cpython-312.pyc new file mode 100644 index 0000000..e183701 Binary files /dev/null and b/__pycache__/channel_mixer_node.cpython-312.pyc differ diff --git a/__pycache__/color_balance_node.cpython-312.pyc b/__pycache__/color_balance_node.cpython-312.pyc new file mode 100644 index 0000000..72cc95a Binary files /dev/null and b/__pycache__/color_balance_node.cpython-312.pyc differ diff --git a/__pycache__/css_filters_node.cpython-312.pyc b/__pycache__/css_filters_node.cpython-312.pyc new file mode 100644 index 0000000..011b631 Binary files /dev/null and b/__pycache__/css_filters_node.cpython-312.pyc differ diff --git a/__pycache__/curves_node.cpython-312.pyc b/__pycache__/curves_node.cpython-312.pyc new file mode 100644 index 0000000..0777092 Binary files /dev/null and b/__pycache__/curves_node.cpython-312.pyc differ diff --git a/__pycache__/kaleidoscope_node.cpython-312.pyc b/__pycache__/kaleidoscope_node.cpython-312.pyc new file mode 100644 index 0000000..5532b5e Binary files /dev/null and b/__pycache__/kaleidoscope_node.cpython-312.pyc differ diff --git a/__pycache__/levels_node.cpython-312.pyc b/__pycache__/levels_node.cpython-312.pyc new file mode 100644 index 0000000..88d1c4f Binary files /dev/null and b/__pycache__/levels_node.cpython-312.pyc differ diff --git a/__pycache__/psd_saver_node.cpython-312.pyc b/__pycache__/psd_saver_node.cpython-312.pyc new file mode 100644 index 0000000..de45076 Binary files /dev/null and b/__pycache__/psd_saver_node.cpython-312.pyc differ diff --git a/__pycache__/saver_plus_node.cpython-312.pyc b/__pycache__/saver_plus_node.cpython-312.pyc new file mode 100644 index 0000000..7186d78 Binary files /dev/null and b/__pycache__/saver_plus_node.cpython-312.pyc differ diff --git a/__pycache__/shadow_highlight_node.cpython-312.pyc b/__pycache__/shadow_highlight_node.cpython-312.pyc new file mode 100644 index 0000000..6ddc277 Binary files /dev/null and b/__pycache__/shadow_highlight_node.cpython-312.pyc differ diff --git a/__pycache__/text_display_node.cpython-312.pyc b/__pycache__/text_display_node.cpython-312.pyc new file mode 100644 index 0000000..b3b4523 Binary files /dev/null and b/__pycache__/text_display_node.cpython-312.pyc differ diff --git a/__pycache__/text_processor_node.cpython-312.pyc b/__pycache__/text_processor_node.cpython-312.pyc new file mode 100644 index 0000000..6f25e46 Binary files /dev/null and b/__pycache__/text_processor_node.cpython-312.pyc differ diff --git a/__pycache__/vibrance_node.cpython-312.pyc b/__pycache__/vibrance_node.cpython-312.pyc new file mode 100644 index 0000000..a5085d2 Binary files /dev/null and b/__pycache__/vibrance_node.cpython-312.pyc differ diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..74aaab4 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1 @@ +"""Effets d'image de base - ajustements fondamentaux""" diff --git a/core/__pycache__/__init__.cpython-312.pyc b/core/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..19c99cd Binary files /dev/null and b/core/__pycache__/__init__.cpython-312.pyc differ diff --git a/core/__pycache__/channel_mixer_node.cpython-312.pyc b/core/__pycache__/channel_mixer_node.cpython-312.pyc new file mode 100644 index 0000000..3deb1cd Binary files /dev/null and b/core/__pycache__/channel_mixer_node.cpython-312.pyc differ diff --git a/core/__pycache__/color_balance_node.cpython-312.pyc b/core/__pycache__/color_balance_node.cpython-312.pyc new file mode 100644 index 0000000..d81260f Binary files /dev/null and b/core/__pycache__/color_balance_node.cpython-312.pyc differ diff --git a/core/__pycache__/curves_node.cpython-312.pyc b/core/__pycache__/curves_node.cpython-312.pyc new file mode 100644 index 0000000..bc8f804 Binary files /dev/null and b/core/__pycache__/curves_node.cpython-312.pyc differ diff --git a/core/__pycache__/levels_node.cpython-312.pyc b/core/__pycache__/levels_node.cpython-312.pyc new file mode 100644 index 0000000..50edb85 Binary files /dev/null and b/core/__pycache__/levels_node.cpython-312.pyc differ diff --git a/core/__pycache__/saver_plus_node.cpython-312.pyc b/core/__pycache__/saver_plus_node.cpython-312.pyc new file mode 100644 index 0000000..1b718fb Binary files /dev/null and b/core/__pycache__/saver_plus_node.cpython-312.pyc differ diff --git a/core/__pycache__/shadow_highlight_node.cpython-312.pyc b/core/__pycache__/shadow_highlight_node.cpython-312.pyc new file mode 100644 index 0000000..e48be7b Binary files /dev/null and b/core/__pycache__/shadow_highlight_node.cpython-312.pyc differ diff --git a/core/__pycache__/vibrance_node.cpython-312.pyc b/core/__pycache__/vibrance_node.cpython-312.pyc new file mode 100644 index 0000000..64c35aa Binary files /dev/null and b/core/__pycache__/vibrance_node.cpython-312.pyc differ diff --git a/core/channel_mixer_node.py b/core/channel_mixer_node.py new file mode 100644 index 0000000..4b233d7 --- /dev/null +++ b/core/channel_mixer_node.py @@ -0,0 +1,80 @@ +import numpy as np +import torch + +class ChannelMixerNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "output_channel": (["Red", "Green", "Blue"], {"default": "Red"}), + "red_source": ("FLOAT", {"default": 100.0, "min": -200.0, "max": 200.0, "step": 1.0}), + "green_source": ("FLOAT", {"default": 0.0, "min": -200.0, "max": 200.0, "step": 1.0}), + "blue_source": ("FLOAT", {"default": 0.0, "min": -200.0, "max": 200.0, "step": 1.0}), + "constant": ("FLOAT", {"default": 0.0, "min": -200.0, "max": 200.0, "step": 1.0}), + }, + "optional": { + "monochrome": ("BOOLEAN", {"default": False}), + "preserve_luminosity": ("BOOLEAN", {"default": False}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_channel_mixer" + CATEGORY = "Image Effects" + + def apply_channel_mixer(self, image, output_channel, red_source, green_source, blue_source, constant, monochrome=False, preserve_luminosity=False): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + image_np = img_tensor.cpu().numpy() + result = image_np.copy() + + # Normaliser les valeurs sources + red_factor = red_source / 100.0 + green_factor = green_source / 100.0 + blue_factor = blue_source / 100.0 + constant_factor = constant / 100.0 + + # Sauvegarder la luminance originale si nécessaire + if preserve_luminosity: + original_luminance = 0.299 * result[:,:,0] + 0.587 * result[:,:,1] + 0.114 * result[:,:,2] + + if monochrome: + # Mode monochrome : appliquer le mélange à tous les canaux + mixed_channel = (result[:,:,0] * red_factor + + result[:,:,1] * green_factor + + result[:,:,2] * blue_factor + + constant_factor) + mixed_channel = np.clip(mixed_channel, 0, 1) + + result[:,:,0] = mixed_channel + result[:,:,1] = mixed_channel + result[:,:,2] = mixed_channel + else: + # Mode couleur : mélanger seulement le canal sélectionné + mixed_channel = (result[:,:,0] * red_factor + + result[:,:,1] * green_factor + + result[:,:,2] * blue_factor + + constant_factor) + mixed_channel = np.clip(mixed_channel, 0, 1) + + if output_channel == "Red": + result[:,:,0] = mixed_channel + elif output_channel == "Green": + result[:,:,1] = mixed_channel + elif output_channel == "Blue": + result[:,:,2] = mixed_channel + + # Restaurer la luminance si demandé + if preserve_luminosity and not monochrome: + new_luminance = 0.299 * result[:,:,0] + 0.587 * result[:,:,1] + 0.114 * result[:,:,2] + ratio = np.where(new_luminance > 0.001, original_luminance / new_luminance, 1.0) + ratio = np.expand_dims(ratio, axis=2) + result = result * ratio + + result = np.clip(result, 0, 1) + result_tensor = torch.from_numpy(result).unsqueeze(0) + return (result_tensor,) diff --git a/core/color_balance_node.py b/core/color_balance_node.py new file mode 100644 index 0000000..f5e3387 --- /dev/null +++ b/core/color_balance_node.py @@ -0,0 +1,78 @@ +import numpy as np +import torch + +class ColorBalanceNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "adjust_type": (["shadows", "midtones", "highlights"], {"default": "midtones"}), + "cyan_red": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0}), + "magenta_green": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0}), + "yellow_blue": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0}), + }, + "optional": { + "preserve_luminosity": ("BOOLEAN", {"default": True}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_color_balance" + CATEGORY = "Image Effects" + + def apply_color_balance(self, image, adjust_type, cyan_red, magenta_green, yellow_blue, preserve_luminosity=True): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + image_np = img_tensor.cpu().numpy() + result = image_np.copy() + + # Calculer la luminance pour chaque zone + luminance = 0.299 * result[:,:,0] + 0.587 * result[:,:,1] + 0.114 * result[:,:,2] + + # Définir les masques pour chaque zone + if adjust_type == "shadows": + mask = np.where(luminance < 0.33, 1.0 - (luminance / 0.33), 0.0) + elif adjust_type == "highlights": + mask = np.where(luminance > 0.67, (luminance - 0.67) / 0.33, 0.0) + else: # midtones + mask = np.where((luminance >= 0.33) & (luminance <= 0.67), + 1.0 - np.abs(luminance - 0.5) / 0.17, 0.0) + + # Normaliser les ajustements + cyan_red_norm = cyan_red / 100.0 + magenta_green_norm = magenta_green / 100.0 + yellow_blue_norm = yellow_blue / 100.0 + + # Appliquer les ajustements couleur + mask = np.expand_dims(mask, axis=2) + + # Cyan-Red + result[:,:,0] += cyan_red_norm * mask[:,:,0] # Rouge + result[:,:,1] -= cyan_red_norm * 0.5 * mask[:,:,0] # Vert + result[:,:,2] -= cyan_red_norm * 0.5 * mask[:,:,0] # Bleu + + # Magenta-Green + result[:,:,0] += magenta_green_norm * 0.5 * mask[:,:,0] # Rouge + result[:,:,1] -= magenta_green_norm * mask[:,:,0] # Vert + result[:,:,2] += magenta_green_norm * 0.5 * mask[:,:,0] # Bleu + + # Yellow-Blue + result[:,:,0] += yellow_blue_norm * 0.5 * mask[:,:,0] # Rouge + result[:,:,1] += yellow_blue_norm * 0.5 * mask[:,:,0] # Vert + result[:,:,2] -= yellow_blue_norm * mask[:,:,0] # Bleu + + # Préserver la luminosité si demandé + if preserve_luminosity: + original_luminance = 0.299 * image_np[:,:,0] + 0.587 * image_np[:,:,1] + 0.114 * image_np[:,:,2] + new_luminance = 0.299 * result[:,:,0] + 0.587 * result[:,:,1] + 0.114 * result[:,:,2] + ratio = np.where(new_luminance > 0.001, original_luminance / new_luminance, 1.0) + ratio = np.expand_dims(ratio, axis=2) + result = result * ratio + + result = np.clip(result, 0, 1) + result_tensor = torch.from_numpy(result).unsqueeze(0) + return (result_tensor,) diff --git a/core/curves_node.py b/core/curves_node.py new file mode 100644 index 0000000..918388a --- /dev/null +++ b/core/curves_node.py @@ -0,0 +1,145 @@ +import numpy as np +import torch +from scipy import interpolate + +class CurvesNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "channel": (["RGB", "Red", "Green", "Blue"], {"default": "RGB"}), + # Points de contrôle pour la courbe (format: x,y;x,y;...) + "curve_points": ("STRING", {"default": "0,0;64,64;128,128;192,192;255,255", "multiline": False}), + "interpolation": (["linear", "cubic", "quadratic"], {"default": "cubic"}), + }, + "optional": { + "strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.01}), + "preserve_luminosity": ("BOOLEAN", {"default": False}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_curves" + CATEGORY = "Image Effects" + + def parse_curve_points(self, curve_points_str): + """Parse la chaîne de points de courbe en coordonnées""" + try: + points = [] + pairs = curve_points_str.split(';') + for pair in pairs: + x, y = map(float, pair.split(',')) + # Normaliser les valeurs entre 0 et 1 + points.append((x/255.0, y/255.0)) + + # Trier par x pour assurer l'ordre croissant + points.sort(key=lambda p: p[0]) + + # S'assurer que les points de début et fin sont présents + if points[0][0] > 0: + points.insert(0, (0, 0)) + if points[-1][0] < 1: + points.append((1, 1)) + + return points + except: + # Points par défaut si erreur de parsing + return [(0, 0), (0.25, 0.25), (0.5, 0.5), (0.75, 0.75), (1, 1)] + + def create_lookup_table(self, points, interpolation_method): + """Crée une table de correspondance pour la courbe""" + x_points = [p[0] for p in points] + y_points = [p[1] for p in points] + + # Créer 256 points pour la LUT + x_lut = np.linspace(0, 1, 256) + + if interpolation_method == "linear": + y_lut = np.interp(x_lut, x_points, y_points) + elif interpolation_method == "cubic": + if len(points) >= 4: + # Spline cubique + tck = interpolate.splrep(x_points, y_points, s=0, k=min(3, len(points)-1)) + y_lut = interpolate.splev(x_lut, tck) + else: + # Fallback vers linéaire si pas assez de points + y_lut = np.interp(x_lut, x_points, y_points) + else: # quadratic + if len(points) >= 3: + tck = interpolate.splrep(x_points, y_points, s=0, k=min(2, len(points)-1)) + y_lut = interpolate.splev(x_lut, tck) + else: + y_lut = np.interp(x_lut, x_points, y_points) + + # Clipper les valeurs entre 0 et 1 + y_lut = np.clip(y_lut, 0, 1) + + return y_lut + + def rgb_to_luminance(self, rgb): + """Convertit RGB en luminance""" + return 0.299 * rgb[:,:,0] + 0.587 * rgb[:,:,1] + 0.114 * rgb[:,:,2] + + def apply_curves(self, image, channel, curve_points, interpolation, strength=1.0, preserve_luminosity=False): + # Prendre la première image du batch + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + # Convertir en numpy + image_np = img_tensor.cpu().numpy() + h, w, c = image_np.shape + + # Parser les points de courbe + points = self.parse_curve_points(curve_points) + + # Créer la table de correspondance + lut = self.create_lookup_table(points, interpolation) + + # Copier l'image + result = image_np.copy() + + # Sauvegarder la luminance originale si nécessaire + if preserve_luminosity: + original_luminance = self.rgb_to_luminance(result) + + # Déterminer les canaux à traiter + if channel == "RGB": + channels_to_process = [0, 1, 2] + elif channel == "Red": + channels_to_process = [0] + elif channel == "Green": + channels_to_process = [1] + elif channel == "Blue": + channels_to_process = [2] + + # Appliquer la courbe + for ch in channels_to_process: + channel_data = result[:, :, ch] + + # Convertir en indices pour la LUT (0-255) + indices = np.clip((channel_data * 255).astype(int), 0, 255) + + # Appliquer la courbe + curved_data = lut[indices] + + # Mélanger avec l'original selon la force + result[:, :, ch] = channel_data * (1 - strength) + curved_data * strength + + # Restaurer la luminance si demandé + if preserve_luminosity and channel == "RGB": + new_luminance = self.rgb_to_luminance(result) + # Éviter la division par zéro + ratio = np.where(new_luminance > 0.001, original_luminance / new_luminance, 1.0) + ratio = np.expand_dims(ratio, axis=2) + result = result * ratio + + # Clipper les valeurs finales + result = np.clip(result, 0, 1) + + # Reconvertir en tensor + result_tensor = torch.from_numpy(result).unsqueeze(0) + + return (result_tensor,) diff --git a/core/levels_node.py b/core/levels_node.py new file mode 100644 index 0000000..ef02773 --- /dev/null +++ b/core/levels_node.py @@ -0,0 +1,74 @@ +import numpy as np +import torch +import cv2 + +class LevelsNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "input_black": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "input_white": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "gamma": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 3.0, "step": 0.01}), + "output_black": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "output_white": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), + }, + "optional": { + "channel": (["RGB", "Red", "Green", "Blue"], {"default": "RGB"}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_levels" + CATEGORY = "Image Effects" + + def apply_levels(self, image, input_black, input_white, gamma, output_black, output_white, channel="RGB"): + # Prendre la première image du batch + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + # Convertir en numpy + image_np = img_tensor.cpu().numpy() + h, w, c = image_np.shape + + # Copier l'image pour éviter de modifier l'original + result = image_np.copy() + + # Déterminer quels canaux traiter + if channel == "RGB": + channels_to_process = [0, 1, 2] + elif channel == "Red": + channels_to_process = [0] + elif channel == "Green": + channels_to_process = [1] + elif channel == "Blue": + channels_to_process = [2] + + # Appliquer les niveaux sur chaque canal sélectionné + for ch in channels_to_process: + channel_data = result[:, :, ch] + + # Étape 1: Ajuster les niveaux d'entrée + # Normaliser entre input_black et input_white + if input_white > input_black: + channel_data = np.clip((channel_data - input_black) / (input_white - input_black), 0, 1) + + # Étape 2: Appliquer la correction gamma + if gamma != 1.0: + channel_data = np.power(channel_data, 1.0 / gamma) + + # Étape 3: Ajuster les niveaux de sortie + channel_data = channel_data * (output_white - output_black) + output_black + + # Clipper les valeurs + channel_data = np.clip(channel_data, 0, 1) + + result[:, :, ch] = channel_data + + # Reconvertir en tensor + result_tensor = torch.from_numpy(result).unsqueeze(0) + + return (result_tensor,) diff --git a/core/saver_plus_node.py b/core/saver_plus_node.py new file mode 100644 index 0000000..a069487 --- /dev/null +++ b/core/saver_plus_node.py @@ -0,0 +1,322 @@ +import numpy as np +import torch +import os +from datetime import datetime +from PIL import Image, ImageEnhance +import json + +class SaverPlusNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "images": ("IMAGE",), + "filename": ("STRING", {"default": "output", "multiline": False}), + "save_path": ("STRING", {"default": "output/saver_plus/", "multiline": False}), + "output_format": (["PNG", "TIFF", "JPEG", "WEBP", "BMP"], {"default": "PNG"}), + }, + "optional": { + "layer_names": ("STRING", {"default": "Layer1,Layer2,Layer3", "multiline": False}), + "include_merged": ("BOOLEAN", {"default": True}), + "merge_mode": (["maximum", "average", "overlay", "multiply", "screen", "soft_light"], {"default": "maximum"}), + "quality": ("INT", {"default": 95, "min": 1, "max": 100, "step": 1}), + "add_timestamp": ("BOOLEAN", {"default": False}), + "save_metadata": ("BOOLEAN", {"default": True}), + "create_subfolder": ("BOOLEAN", {"default": False}), + "compression_level": ("INT", {"default": 6, "min": 0, "max": 9, "step": 1}), + "preserve_transparency": ("BOOLEAN", {"default": True}), + "auto_optimize": ("BOOLEAN", {"default": True}), + } + } + + RETURN_TYPES = ("STRING", "STRING") + RETURN_NAMES = ("save_info", "folder_path") + FUNCTION = "save_images" + CATEGORY = "Image Effects" + OUTPUT_NODE = True + + def save_images(self, images, filename, save_path, output_format, layer_names="", + include_merged=True, merge_mode="maximum", quality=95, + add_timestamp=False, save_metadata=True, create_subfolder=False, + compression_level=6, preserve_transparency=True, auto_optimize=True): + + # Créer le dossier de sortie avec structure intelligente + final_save_path = self._create_save_path(save_path, filename, create_subfolder, add_timestamp) + os.makedirs(final_save_path, exist_ok=True) + + # Générer le nom de fichier final + final_filename = self._generate_filename(filename, add_timestamp) + + # Parser et valider les noms de calques + layer_name_list = self._parse_layer_names(layer_names, len(images)) + + # Initialiser les métadonnées complètes + metadata = self._init_metadata(output_format, include_merged, merge_mode, + quality, compression_level, len(images)) + + saved_files = [] + + # Sauvegarder chaque calque avec optimisations + for i, img_tensor in enumerate(images): + layer_info = self._save_single_layer( + img_tensor, final_filename, layer_name_list[i], + final_save_path, output_format, quality, + compression_level, preserve_transparency, auto_optimize + ) + saved_files.append(layer_info["path"]) + metadata["layers"].append(layer_info["metadata"]) + + # Créer l'image fusionnée avec mode avancé + if include_merged and len(images) > 1: + merged_info = self._create_merged_image( + images, final_filename, final_save_path, output_format, + merge_mode, quality, compression_level, + preserve_transparency, auto_optimize + ) + saved_files.append(merged_info["path"]) + metadata["merged_file"] = merged_info["metadata"] + + # Sauvegarder les métadonnées enrichies + if save_metadata: + metadata_path = self._save_metadata(metadata, final_filename, final_save_path) + saved_files.append(metadata_path) + + # Générer le rapport de sauvegarde + save_info = self._generate_save_report(saved_files, final_save_path, + output_format, merge_mode, include_merged) + + return (save_info, final_save_path) + + def _create_save_path(self, base_path, filename, create_subfolder, add_timestamp): + """Créer la structure de dossiers intelligente""" + if create_subfolder: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + subfolder_name = f"{filename}_{timestamp}" if add_timestamp else filename + return os.path.join(base_path, subfolder_name) + return base_path + + def _generate_filename(self, filename, add_timestamp): + """Générer le nom de fichier avec horodatage optionnel""" + if add_timestamp: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + return f"{filename}_{timestamp}" + return filename + + def _parse_layer_names(self, layer_names, num_images): + """Parser et valider les noms de calques""" + if layer_names.strip(): + layer_list = [name.strip() for name in layer_names.split(',') if name.strip()] + else: + layer_list = [] + + # Compléter avec des noms par défaut si nécessaire + while len(layer_list) < num_images: + layer_list.append(f"Layer_{len(layer_list)+1}") + + return layer_list[:num_images] # Limiter au nombre d'images + + def _init_metadata(self, output_format, include_merged, merge_mode, + quality, compression_level, num_layers): + """Initialiser les métadonnées complètes""" + return { + "creation_date": datetime.now().isoformat(), + "comfyui_version": "0.3.35", + "saver_plus_version": "1.0", + "output_format": output_format, + "settings": { + "quality": quality if output_format in ["JPEG", "WEBP"] else None, + "compression_level": compression_level, + "merged_included": include_merged, + "merge_mode": merge_mode if include_merged else None + }, + "statistics": { + "total_layers": num_layers, + "total_files": 0 # Sera mis à jour + }, + "layers": [], + "merged_file": None + } + + def _save_single_layer(self, img_tensor, filename, layer_name, save_path, + output_format, quality, compression_level, + preserve_transparency, auto_optimize): + """Sauvegarder un calque avec optimisations spécifiques au format""" + # Convertir le tensor en image PIL + img_np = img_tensor.cpu().numpy() + img_array = (img_np * 255).astype(np.uint8) + + # Gestion intelligente des canaux + if img_array.shape[2] == 4 and preserve_transparency: + pil_img = Image.fromarray(img_array, 'RGBA') + else: + pil_img = Image.fromarray(img_array[:,:,:3], 'RGB') + + # Nom de fichier final + file_extension = self._get_file_extension(output_format) + file_path = os.path.join(save_path, f"{filename}_{layer_name}.{file_extension}") + + # Options de sauvegarde optimisées par format + save_kwargs = self._get_save_options(output_format, quality, compression_level, auto_optimize) + + # Conversion spéciale pour JPEG (pas de transparence) + if output_format == "JPEG" and pil_img.mode == "RGBA": + background = Image.new("RGB", pil_img.size, (255, 255, 255)) + background.paste(pil_img, mask=pil_img.split()[-1]) + pil_img = background + + # Sauvegarder avec gestion d'erreurs + try: + pil_img.save(file_path, output_format, **save_kwargs) + file_size = os.path.getsize(file_path) + except Exception as e: + raise Exception(f"Erreur lors de la sauvegarde de {layer_name}: {str(e)}") + + return { + "path": file_path, + "metadata": { + "name": layer_name, + "filename": os.path.basename(file_path), + "index": len(os.listdir(save_path)) - 1, + "dimensions": [img_array.shape[1], img_array.shape[0]], # width, height + "channels": img_array.shape[2], + "file_size_bytes": file_size, + "color_mode": pil_img.mode + } + } + + def _create_merged_image(self, images, filename, save_path, output_format, + merge_mode, quality, compression_level, + preserve_transparency, auto_optimize): + """Créer l'image fusionnée avec modes avancés""" + merged = self._merge_images_advanced(images, merge_mode) + merged_array = (merged * 255).astype(np.uint8) + + # Créer l'image PIL + if merged_array.shape[2] == 4 and preserve_transparency: + merged_pil = Image.fromarray(merged_array, 'RGBA') + else: + merged_pil = Image.fromarray(merged_array[:,:,:3], 'RGB') + + # Nom de fichier fusionné + file_extension = self._get_file_extension(output_format) + merged_path = os.path.join(save_path, f"{filename}_merged.{file_extension}") + + # Options de sauvegarde + save_kwargs = self._get_save_options(output_format, quality, compression_level, auto_optimize) + + # Conversion pour JPEG + if output_format == "JPEG" and merged_pil.mode == "RGBA": + background = Image.new("RGB", merged_pil.size, (255, 255, 255)) + background.paste(merged_pil, mask=merged_pil.split()[-1]) + merged_pil = background + + merged_pil.save(merged_path, output_format, **save_kwargs) + file_size = os.path.getsize(merged_path) + + return { + "path": merged_path, + "metadata": { + "filename": os.path.basename(merged_path), + "dimensions": [merged_array.shape[1], merged_array.shape[0]], + "channels": merged_array.shape[2], + "file_size_bytes": file_size, + "color_mode": merged_pil.mode + } + } + + def _merge_images_advanced(self, images, merge_mode): + """Modes de fusion avancés""" + if len(images) == 1: + return images[0].cpu().numpy() + + np_images = [img.cpu().numpy() for img in images] + base = np_images[0] + + for img in np_images[1:]: + if merge_mode == "maximum": + base = np.maximum(base, img) + elif merge_mode == "average": + base = (base + img) / 2 + elif merge_mode == "overlay": + mask = base < 0.5 + base = np.where(mask, 2 * base * img, 1 - 2 * (1 - base) * (1 - img)) + elif merge_mode == "multiply": + base = base * img + elif merge_mode == "screen": + base = 1 - (1 - base) * (1 - img) + elif merge_mode == "soft_light": + mask = img < 0.5 + base = np.where(mask, + base - (1 - 2 * img) * base * (1 - base), + base + (2 * img - 1) * (np.sqrt(base) - base)) + + return np.clip(base, 0, 1) + + def _get_file_extension(self, output_format): + """Obtenir l'extension de fichier correcte""" + extensions = { + "PNG": "png", "TIFF": "tiff", "JPEG": "jpg", + "WEBP": "webp", "BMP": "bmp" + } + return extensions.get(output_format, "png") + + def _get_save_options(self, output_format, quality, compression_level, auto_optimize): + """Options de sauvegarde optimisées par format""" + options = {} + + if output_format == "PNG": + options.update({ + "optimize": auto_optimize, + "compress_level": compression_level + }) + elif output_format == "JPEG": + options.update({ + "quality": quality, + "optimize": auto_optimize, + "progressive": True + }) + elif output_format == "WEBP": + options.update({ + "quality": quality, + "optimize": auto_optimize, + "lossless": quality >= 95 + }) + elif output_format == "TIFF": + options.update({ + "compression": "lzw", + "optimize": auto_optimize + }) + elif output_format == "BMP": + pass # BMP n'a pas d'options spéciales + + return options + + def _save_metadata(self, metadata, filename, save_path): + """Sauvegarder les métadonnées enrichies""" + # Mettre à jour les statistiques + metadata["statistics"]["total_files"] = len(metadata["layers"]) + if metadata["merged_file"]: + metadata["statistics"]["total_files"] += 1 + + metadata_path = os.path.join(save_path, f"{filename}_metadata.json") + with open(metadata_path, 'w', encoding='utf-8') as f: + json.dump(metadata, f, indent=2, ensure_ascii=False) + + return metadata_path + + def _generate_save_report(self, saved_files, save_path, output_format, merge_mode, include_merged): + """Générer un rapport de sauvegarde détaillé""" + total_size = sum(os.path.getsize(f) for f in saved_files if os.path.exists(f)) + size_mb = total_size / (1024 * 1024) + + report = f"✅ **SaverPlus - Sauvegarde terminée**\n" + report += f"📁 **Dossier**: {save_path}\n" + report += f"📄 **Format**: {output_format}\n" + report += f"📊 **Fichiers**: {len(saved_files)} ({size_mb:.2f} MB)\n" + + if include_merged: + report += f"🔄 **Fusion**: {merge_mode}\n" + + report += f"⏰ **Heure**: {datetime.now().strftime('%H:%M:%S')}" + + return report diff --git a/core/shadow_highlight_node.py b/core/shadow_highlight_node.py new file mode 100644 index 0000000..1cd49bd --- /dev/null +++ b/core/shadow_highlight_node.py @@ -0,0 +1,73 @@ +import numpy as np +import torch + +class ShadowHighlightNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "shadow_amount": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0}), + "highlight_amount": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0}), + "shadow_width": ("FLOAT", {"default": 50.0, "min": 0.0, "max": 100.0, "step": 1.0}), + "highlight_width": ("FLOAT", {"default": 50.0, "min": 0.0, "max": 100.0, "step": 1.0}), + "radius": ("FLOAT", {"default": 30.0, "min": 0.0, "max": 100.0, "step": 1.0}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_shadow_highlight" + CATEGORY = "Image Effects" + + def apply_shadow_highlight(self, image, shadow_amount, highlight_amount, shadow_width, highlight_width, radius): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + image_np = img_tensor.cpu().numpy() + result = image_np.copy() + + # Calculer la luminance + luminance = 0.299 * result[:,:,0] + 0.587 * result[:,:,1] + 0.114 * result[:,:,2] + + # Créer les masques pour ombres et hautes lumières + shadow_threshold = shadow_width / 100.0 + highlight_threshold = 1.0 - (highlight_width / 100.0) + + # Masque des ombres (transition douce) + shadow_mask = np.where(luminance < shadow_threshold, + 1.0 - (luminance / shadow_threshold), + 0.0) + + # Masque des hautes lumières (transition douce) + highlight_mask = np.where(luminance > highlight_threshold, + (luminance - highlight_threshold) / (1.0 - highlight_threshold), + 0.0) + + # Appliquer un flou gaussien pour adoucir les transitions + if radius > 0: + import cv2 + kernel_size = int(radius / 10) * 2 + 1 + shadow_mask = cv2.GaussianBlur(shadow_mask, (kernel_size, kernel_size), radius/30) + highlight_mask = cv2.GaussianBlur(highlight_mask, (kernel_size, kernel_size), radius/30) + + # Appliquer les corrections + shadow_factor = 1.0 + (shadow_amount / 100.0) + highlight_factor = 1.0 + (highlight_amount / 100.0) + + # Correction des ombres + if shadow_amount != 0: + shadow_mask_3d = np.expand_dims(shadow_mask, axis=2) + shadow_correction = result * shadow_factor + result = result * (1 - shadow_mask_3d) + shadow_correction * shadow_mask_3d + + # Correction des hautes lumières + if highlight_amount != 0: + highlight_mask_3d = np.expand_dims(highlight_mask, axis=2) + highlight_correction = result * highlight_factor + result = result * (1 - highlight_mask_3d) + highlight_correction * highlight_mask_3d + + result = np.clip(result, 0, 1) + result_tensor = torch.from_numpy(result).unsqueeze(0) + return (result_tensor,) diff --git a/core/vibrance_node.py b/core/vibrance_node.py new file mode 100644 index 0000000..6b77bd1 --- /dev/null +++ b/core/vibrance_node.py @@ -0,0 +1,73 @@ +import numpy as np +import torch +import cv2 + +class VibranceNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "vibrance": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0}), + "saturation": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0}), + }, + "optional": { + "protect_skin_tones": ("BOOLEAN", {"default": True}), + "strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.01}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_vibrance" + CATEGORY = "Image Effects" + + def apply_vibrance(self, image, vibrance, saturation, protect_skin_tones=True, strength=1.0): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + image_np = img_tensor.cpu().numpy() + result = image_np.copy() + + # Convertir en HSV pour les calculs de saturation + hsv = cv2.cvtColor((result * 255).astype(np.uint8), cv2.COLOR_RGB2HSV).astype(np.float32) + hsv[:,:,1] /= 255.0 # Normaliser la saturation + hsv[:,:,2] /= 255.0 # Normaliser la valeur + + # Calculer la saturation actuelle + current_saturation = hsv[:,:,1] + + # Appliquer la vibrance (effet sélectif) + if vibrance != 0: + vibrance_factor = vibrance / 100.0 + # La vibrance affecte moins les couleurs déjà saturées + vibrance_mask = 1.0 - current_saturation + vibrance_adjustment = vibrance_factor * vibrance_mask * strength + hsv[:,:,1] = np.clip(current_saturation + vibrance_adjustment, 0, 1) + + # Appliquer la saturation globale + if saturation != 0: + saturation_factor = 1.0 + (saturation / 100.0) * strength + hsv[:,:,1] = np.clip(hsv[:,:,1] * saturation_factor, 0, 1) + + # Protection des tons chair + if protect_skin_tones: + # Détecter les tons chair (teinte entre 0-30 et 330-360 degrés) + hue = hsv[:,:,0] * 2 # Convertir en degrés (0-360) + skin_mask = ((hue >= 0) & (hue <= 30)) | ((hue >= 330) & (hue <= 360)) + skin_protection = np.where(skin_mask, 0.5, 1.0) + skin_protection = np.expand_dims(skin_protection, axis=2) + + # Réduire l'effet sur les tons chair + protected_result = image_np * (1 - skin_protection) + result * skin_protection + result = protected_result + + # Reconvertir en RGB + hsv[:,:,1] *= 255 + hsv[:,:,2] *= 255 + result = cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2RGB).astype(np.float32) / 255.0 + + result = np.clip(result, 0, 1) + result_tensor = torch.from_numpy(result).unsqueeze(0) + return (result_tensor,) diff --git a/creative/__init__.py b/creative/__init__.py new file mode 100644 index 0000000..74aaab4 --- /dev/null +++ b/creative/__init__.py @@ -0,0 +1 @@ +"""Effets d'image de base - ajustements fondamentaux""" diff --git a/creative/__pycache__/__init__.cpython-312.pyc b/creative/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..eb4cead Binary files /dev/null and b/creative/__pycache__/__init__.cpython-312.pyc differ diff --git a/creative/__pycache__/ascii_art_node.cpython-312.pyc b/creative/__pycache__/ascii_art_node.cpython-312.pyc new file mode 100644 index 0000000..9401555 Binary files /dev/null and b/creative/__pycache__/ascii_art_node.cpython-312.pyc differ diff --git a/creative/__pycache__/ascii_text_node.cpython-312.pyc b/creative/__pycache__/ascii_text_node.cpython-312.pyc new file mode 100644 index 0000000..38b2312 Binary files /dev/null and b/creative/__pycache__/ascii_text_node.cpython-312.pyc differ diff --git a/creative/__pycache__/css_filters_node.cpython-312.pyc b/creative/__pycache__/css_filters_node.cpython-312.pyc new file mode 100644 index 0000000..8f568e2 Binary files /dev/null and b/creative/__pycache__/css_filters_node.cpython-312.pyc differ diff --git a/creative/__pycache__/kaleidoscope_node.cpython-312.pyc b/creative/__pycache__/kaleidoscope_node.cpython-312.pyc new file mode 100644 index 0000000..e10a33d Binary files /dev/null and b/creative/__pycache__/kaleidoscope_node.cpython-312.pyc differ diff --git a/creative/ascii_art_node.py b/creative/ascii_art_node.py new file mode 100644 index 0000000..d01c738 --- /dev/null +++ b/creative/ascii_art_node.py @@ -0,0 +1,190 @@ +import numpy as np +import torch +from PIL import Image, ImageFont, ImageDraw, ImageEnhance + +class AsciiArtNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "ascii_resolution": ("INT", {"default": 80, "min": 20, "max": 200, "step": 10}), + "style": (["classic", "detailed", "minimal", "blocks"], {"default": "classic"}), + "invert": ("BOOLEAN", {"default": False}), + }, + "optional": { + "background_color": (["white", "black", "gray"], {"default": "white"}), + "text_color": (["black", "white", "auto"], {"default": "black"}), + "contrast_boost": ("FLOAT", {"default": 2.0, "min": 1.0, "max": 4.0, "step": 0.1}), + "height_compression": ("FLOAT", {"default": 2.0, "min": 1.0, "max": 3.0, "step": 0.1}), + "font_scale": ("FLOAT", {"default": 0.9, "min": 0.3, "max": 1.5, "step": 0.1}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "generate_ascii_art" + CATEGORY = "Image Effects" + + def generate_ascii_art(self, image, ascii_resolution, style, invert, + background_color="white", text_color="black", + contrast_boost=2.0, height_compression=2.0, font_scale=0.9): + + # Prendre la première image du batch + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + # Convertir en numpy + img_np = img_tensor.cpu().numpy() + original_h, original_w, c = img_np.shape + + print(f"Image d'entrée: {original_w}x{original_h} pixels") + + # Convertir en niveaux de gris + gray = np.dot(img_np[..., :3], [0.2989, 0.5870, 0.1140]) + + # Calculer les dimensions ASCII avec compression de hauteur + ascii_width = ascii_resolution + ascii_height = int(ascii_width * (original_h / original_w) / height_compression) + + print(f"Résolution ASCII: {ascii_width}x{ascii_height} caractères") + + # Redimensionner l'image pour l'analyse ASCII + gray_pil = Image.fromarray((gray * 255).astype(np.uint8)) + resized = np.array(gray_pil.resize((ascii_width, ascii_height), Image.Resampling.LANCZOS)) + + # Améliorer le contraste AVANT la conversion ASCII + resized = self._enhance_contrast(resized, contrast_boost) + + # Inverser si demandé + if invert: + resized = 255 - resized + + # Choisir le jeu de caractères + ascii_chars = self._get_ascii_chars(style) + + # Normaliser et mapper aux caractères + norm_pixels = (resized / 255) * (len(ascii_chars) - 1) + norm_pixels = norm_pixels.astype(int) + ascii_image = ascii_chars[norm_pixels] + + # Convertir en lignes de texte + ascii_lines = ["".join(row) for row in ascii_image] + + # Créer l'image finale avec rendu optimisé + result_image = self._create_high_quality_output( + ascii_lines, original_w, original_h, + background_color, text_color, font_scale + ) + + print(f"Image de sortie: {original_w}x{original_h} pixels") + + # Convertir en tensor ComfyUI + result_tensor = torch.from_numpy(result_image).unsqueeze(0) + + return (result_tensor,) + + def _enhance_contrast(self, image_array, boost_factor): + """Améliorer drastiquement le contraste""" + # Normaliser + normalized = image_array.astype(np.float32) / 255.0 + + # Appliquer un boost de contraste plus agressif + enhanced = np.power(normalized, 1.0 / boost_factor) + + # Étalement d'histogramme + min_val = np.min(enhanced) + max_val = np.max(enhanced) + + if max_val > min_val: + enhanced = (enhanced - min_val) / (max_val - min_val) + + # Appliquer une courbe en S pour plus de contraste + enhanced = 0.5 * (1 + np.tanh(4 * (enhanced - 0.5))) + + return (enhanced * 255).astype(np.uint8) + + def _get_ascii_chars(self, style): + """Jeux de caractères avec meilleur contraste""" + styles = { + "classic": np.array(list(" .:-=+*#%@")), + "detailed": np.array(list(" ░▒▓█")), + "minimal": np.array(list(" .-#@")), + "blocks": np.array(list(" ▁▂▃▄▅▆▇█")) + } + return styles.get(style, styles["classic"]) + + def _create_high_quality_output(self, ascii_lines, target_width, target_height, + background_color, text_color, font_scale): + """Créer une image sans bord avec remplissage complet""" + + # Définir les couleurs + bg_colors = {"white": (255, 255, 255), "black": (0, 0, 0), "gray": (128, 128, 128)} + txt_colors = {"white": (255, 255, 255), "black": (0, 0, 0), "auto": None} + + bg_color = bg_colors.get(background_color, (255, 255, 255)) + txt_color = txt_colors.get(text_color, (0, 0, 0)) + + # Auto color avec contraste maximal + if text_color == "auto": + txt_color = (0, 0, 0) if background_color == "white" else (255, 255, 255) + + # Créer l'image + img = Image.new("RGB", (target_width, target_height), color=bg_color) + draw = ImageDraw.Draw(img) + + # Calculer les dimensions + chars_width = len(ascii_lines[0]) + chars_height = len(ascii_lines) + + # Calculer l'espacement pour REMPLIR COMPLÈTEMENT l'image + char_spacing_w = target_width / chars_width + char_spacing_h = target_height / chars_height + + # Taille de police pour remplir l'espace + font_size = int(min(char_spacing_w, char_spacing_h) * font_scale) + font_size = max(1, font_size) + + print(f"Taille de police calculée: {font_size}px") + + # Charger une police monospace avec fallback robuste + font = self._load_best_font(font_size) + + # Dessiner le texte en remplissant TOUTE l'image + for row, line in enumerate(ascii_lines): + for col, char in enumerate(line): + x = int(col * char_spacing_w) + y = int(row * char_spacing_h) + draw.text((x, y), char, fill=txt_color, font=font) + + # Améliorer le contraste de l'image finale + enhancer = ImageEnhance.Contrast(img) + img = enhancer.enhance(1.5) + + # Convertir en numpy array + img_array = np.array(img).astype(np.float32) / 255.0 + + return img_array + + def _load_best_font(self, font_size): + """Charger la meilleure police monospace disponible""" + fonts_to_try = [ + "consola.ttf", # Windows + "Monaco.ttf", # macOS + "DejaVuSansMono.ttf", # Linux + "LiberationMono-Regular.ttf", # Linux alternative + "CourierNew.ttf" # Fallback + ] + + for font_name in fonts_to_try: + try: + font = ImageFont.truetype(font_name, font_size) + print(f"Police chargée: {font_name}") + return font + except: + continue + + # Dernière option + print("Utilisation de la police par défaut") + return ImageFont.load_default() diff --git a/creative/ascii_text_node.py b/creative/ascii_text_node.py new file mode 100644 index 0000000..8332323 --- /dev/null +++ b/creative/ascii_text_node.py @@ -0,0 +1,165 @@ +import numpy as np +import torch +from PIL import Image + +class AsciiTextNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "ascii_width": ("INT", {"default": 80, "min": 20, "max": 200, "step": 10}), + "style": (["classic", "detailed", "minimal", "blocks", "custom"], {"default": "classic"}), + "invert": ("BOOLEAN", {"default": False}), + }, + "optional": { + "contrast_boost": ("FLOAT", {"default": 2.0, "min": 1.0, "max": 4.0, "step": 0.1}), + "height_compression": ("FLOAT", {"default": 2.0, "min": 1.0, "max": 3.0, "step": 0.1}), + "custom_chars": ("STRING", {"default": " .-+*#@", "multiline": False}), + "add_border": ("BOOLEAN", {"default": False}), + "line_numbers": ("BOOLEAN", {"default": False}), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("ascii_text",) + FUNCTION = "generate_ascii_text" + CATEGORY = "Image Effects" + OUTPUT_NODE = True + + def generate_ascii_text(self, image, ascii_width, style, invert, + contrast_boost=2.0, height_compression=2.0, + custom_chars=" .-+*#@", add_border=False, line_numbers=False): + + # Prendre la première image du batch + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + # Convertir en numpy + img_np = img_tensor.cpu().numpy() + original_h, original_w, c = img_np.shape + + print(f"ASCII Text: {original_w}x{original_h} → {ascii_width} chars wide") + + # Convertir en niveaux de gris + gray = np.dot(img_np[..., :3], [0.2989, 0.5870, 0.1140]) + + # Calculer les dimensions ASCII avec compression de hauteur + ascii_height = int(ascii_width * (original_h / original_w) / height_compression) + + # Redimensionner l'image pour l'analyse ASCII + gray_pil = Image.fromarray((gray * 255).astype(np.uint8)) + resized = np.array(gray_pil.resize((ascii_width, ascii_height), Image.Resampling.LANCZOS)) + + # Améliorer le contraste + enhanced = self._enhance_contrast(resized, contrast_boost) + + # Inverser si demandé + if invert: + enhanced = 255 - enhanced + + # Choisir le jeu de caractères + ascii_chars = self._get_ascii_chars(style, custom_chars) + + # Normaliser et mapper aux caractères + norm_pixels = (enhanced / 255) * (len(ascii_chars) - 1) + norm_pixels = norm_pixels.astype(int) + + # Générer le texte ASCII + ascii_text_lines = [] + for row_idx, row in enumerate(norm_pixels): + line = "".join([ascii_chars[p] for p in row]) + + # Ajouter les numéros de ligne si demandé + if line_numbers: + line = f"{row_idx+1:3d}: {line}" + + ascii_text_lines.append(line) + + # Ajouter une bordure si demandé + if add_border: + ascii_text_lines = self._add_border(ascii_text_lines, line_numbers) + + # Joindre toutes les lignes + ascii_text = "\n".join(ascii_text_lines) + + # Ajouter des informations d'en-tête + header = f"ASCII Art - {ascii_width}x{ascii_height} - Style: {style}\n" + header += "=" * len(header.strip()) + "\n" + + final_text = header + ascii_text + + print(f"ASCII généré: {len(ascii_text_lines)} lignes, {len(ascii_text)} caractères") + + return (final_text,) + + def _enhance_contrast(self, image_array, boost_factor): + """Améliorer le contraste pour un meilleur rendu ASCII""" + # Normaliser + normalized = image_array.astype(np.float32) / 255.0 + + # Appliquer un boost de contraste + enhanced = np.power(normalized, 1.0 / boost_factor) + + # Étalement d'histogramme + min_val = np.min(enhanced) + max_val = np.max(enhanced) + + if max_val > min_val: + enhanced = (enhanced - min_val) / (max_val - min_val) + + # Appliquer une courbe en S pour plus de contraste + enhanced = 0.5 * (1 + np.tanh(4 * (enhanced - 0.5))) + + return (enhanced * 255).astype(np.uint8) + + def _get_ascii_chars(self, style, custom_chars): + """Obtenir le jeu de caractères selon le style""" + styles = { + "classic": list(" .:-=+*#%@"), + "detailed": list(" ░▒▓█"), + "minimal": list(" .-#@"), + "blocks": list(" ▁▂▃▄▅▆▇█"), + "custom": list(custom_chars) + } + + chars = styles.get(style, styles["classic"]) + + # S'assurer qu'on a au moins 2 caractères + if len(chars) < 2: + chars = list(" @") + + return chars + + def _add_border(self, text_lines, has_line_numbers): + """Ajouter une bordure autour du texte ASCII""" + if not text_lines: + return text_lines + + # Calculer la largeur maximale + max_width = max(len(line) for line in text_lines) + + # Caractères de bordure + top_left = "┌" + top_right = "┐" + bottom_left = "└" + bottom_right = "┘" + horizontal = "─" + vertical = "│" + + # Ligne du haut + top_line = top_left + horizontal * (max_width + 2) + top_right + + # Ligne du bas + bottom_line = bottom_left + horizontal * (max_width + 2) + bottom_right + + # Lignes avec bordures latérales + bordered_lines = [top_line] + for line in text_lines: + padded_line = line.ljust(max_width) + bordered_lines.append(f"{vertical} {padded_line} {vertical}") + bordered_lines.append(bottom_line) + + return bordered_lines diff --git a/creative/css_filters_node.py b/creative/css_filters_node.py new file mode 100644 index 0000000..d972cb9 --- /dev/null +++ b/creative/css_filters_node.py @@ -0,0 +1,100 @@ +import numpy as np +import torch +from PIL import Image, ImageEnhance, ImageFilter +import cv2 + +class CSSFiltersNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + }, + "optional": { + "blur": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 20.0, "step": 0.1}), + "brightness": ("FLOAT", {"default": 100.0, "min": 0.0, "max": 300.0, "step": 1.0}), + "contrast": ("FLOAT", {"default": 100.0, "min": 0.0, "max": 300.0, "step": 1.0}), + "grayscale": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 100.0, "step": 1.0}), + "sepia": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 100.0, "step": 1.0}), + "hue_rotate": ("FLOAT", {"default": 0.0, "min": -180.0, "max": 180.0, "step": 1.0}), + "saturate": ("FLOAT", {"default": 100.0, "min": 0.0, "max": 300.0, "step": 1.0}), + "invert": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 100.0, "step": 1.0}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_css_filters" + CATEGORY = "Image Effects" + + def apply_css_filters(self, image, blur=0.0, brightness=100.0, contrast=100.0, + grayscale=0.0, sepia=0.0, hue_rotate=0.0, saturate=100.0, invert=0.0): + + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + # Convertir en PIL + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + pil_img = Image.fromarray(img_np) + + # Appliquer les filtres CSS équivalents + result = pil_img.copy() + + # Blur (flou) + if blur > 0: + result = result.filter(ImageFilter.GaussianBlur(radius=blur)) + + # Brightness (luminosité) + if brightness != 100.0: + enhancer = ImageEnhance.Brightness(result) + result = enhancer.enhance(brightness / 100.0) + + # Contrast (contraste) + if contrast != 100.0: + enhancer = ImageEnhance.Contrast(result) + result = enhancer.enhance(contrast / 100.0) + + # Saturate (saturation) + if saturate != 100.0: + enhancer = ImageEnhance.Color(result) + result = enhancer.enhance(saturate / 100.0) + + # Convertir en numpy pour les filtres avancés + result_np = np.array(result) + + # Grayscale (niveaux de gris) + if grayscale > 0: + gray = cv2.cvtColor(result_np, cv2.COLOR_RGB2GRAY) + gray_rgb = cv2.cvtColor(gray, cv2.COLOR_GRAY2RGB) + alpha = grayscale / 100.0 + result_np = (result_np * (1 - alpha) + gray_rgb * alpha).astype(np.uint8) + + # Sepia + if sepia > 0: + sepia_filter = np.array([ + [0.393, 0.769, 0.189], + [0.349, 0.686, 0.168], + [0.272, 0.534, 0.131] + ]) + sepia_img = result_np @ sepia_filter.T + sepia_img = np.clip(sepia_img, 0, 255) + alpha = sepia / 100.0 + result_np = (result_np * (1 - alpha) + sepia_img * alpha).astype(np.uint8) + + # Hue rotate (rotation de teinte) + if hue_rotate != 0: + hsv = cv2.cvtColor(result_np, cv2.COLOR_RGB2HSV).astype(np.float32) + hsv[:, :, 0] = (hsv[:, :, 0] + hue_rotate) % 180 + result_np = cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2RGB) + + # Invert (inversion) + if invert > 0: + inverted = 255 - result_np + alpha = invert / 100.0 + result_np = (result_np * (1 - alpha) + inverted * alpha).astype(np.uint8) + + # Reconvertir en tensor + result_tensor = torch.from_numpy(result_np.astype(np.float32) / 255.0).unsqueeze(0) + + return (result_tensor,) diff --git a/creative/kaleidoscope_node.py b/creative/kaleidoscope_node.py new file mode 100644 index 0000000..e381201 --- /dev/null +++ b/creative/kaleidoscope_node.py @@ -0,0 +1,356 @@ +import numpy as np +import cv2 +import torch + +class KaleidoscopeNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "facettes": ("INT", {"default": 6, "min": 2, "max": 20}), + }, + "optional": { + "center_x": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "center_y": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "radius": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 2.0, "step": 0.05}), + "rotation": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 360.0, "step": 1.0}), + "mirror_mode": (["alternate", "all", "none"], {"default": "alternate"}), + "blend_mode": (["add", "max", "average", "overlay"], {"default": "add"}), + "fade_edges": ("BOOLEAN", {"default": True}), + "color_shift": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.1}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "generate_kaleidoscope" + CATEGORY = "Image Effects" + + def generate_kaleidoscope(self, image, facettes, center_x=0.5, center_y=0.5, + radius=1.0, rotation=0.0, mirror_mode="alternate", + blend_mode="add", fade_edges=True, color_shift=0.0): + + # Prendre la première image du batch + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + # Convertir en numpy array + image_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = image_np.shape + + # Calculer le centre personnalisé + center = (int(w * center_x), int(h * center_y)) + + # Calculer le rayon effectif + max_radius = min(w, h) // 2 + effective_radius = int(max_radius * radius) + + # Angle de base avec rotation + base_angle = 360 / facettes + + print(f"Kaleidoscope: {facettes} facettes, centre: {center}, rayon: {effective_radius}") + + # Créer le masque pour un secteur + mask = self._create_sector_mask(h, w, center, facettes, effective_radius, rotation, fade_edges) + + # Appliquer le masque pour obtenir le secteur de base + base_sector = cv2.bitwise_and(image_np, image_np, mask=mask) + + # Appliquer un décalage de couleur si demandé + if color_shift != 0.0: + base_sector = self._apply_color_shift(base_sector, color_shift) + + # Créer le résultat kaléidoscope + result = self._create_kaleidoscope_effect( + base_sector, facettes, center, base_angle, rotation, + mirror_mode, blend_mode, h, w + ) + + # Normaliser et convertir en tensor + result = np.clip(result, 0, 255).astype(np.float32) / 255.0 + result_tensor = torch.from_numpy(result).unsqueeze(0) + + return (result_tensor,) + + def _create_sector_mask(self, h, w, center, facettes, radius, rotation, fade_edges): + """Créer un masque triangulaire pour un secteur avec dégradé optionnel""" + mask = np.zeros((h, w), dtype=np.uint8) + + # Calculer l'angle du secteur + sector_angle = np.radians(360 / facettes) + start_angle = np.radians(rotation) + + # Points du secteur triangulaire + x1 = int(center[0] + radius * np.cos(start_angle)) + y1 = int(center[1] + radius * np.sin(start_angle)) + x2 = int(center[0] + radius * np.cos(start_angle + sector_angle)) + y2 = int(center[1] + radius * np.sin(start_angle + sector_angle)) + + points = np.array([center, (x1, y1), (x2, y2)], dtype=np.int32) + cv2.fillConvexPoly(mask, points, 255) + + # Ajouter un dégradé radial pour adoucir les bords + if fade_edges: + mask = self._apply_radial_fade(mask, center, radius) + + return mask + + def _apply_radial_fade(self, mask, center, radius): + """Appliquer un dégradé radial pour adoucir les bords""" + h, w = mask.shape + y, x = np.ogrid[:h, :w] + + # Calculer la distance depuis le centre + distance = np.sqrt((x - center[0])**2 + (y - center[1])**2) + + # Créer un dégradé radial + fade_start = radius * 0.7 + fade_mask = np.where(distance <= fade_start, 1.0, + np.where(distance >= radius, 0.0, + 1.0 - (distance - fade_start) / (radius - fade_start))) + + # Appliquer le dégradé au masque + faded_mask = (mask.astype(np.float32) / 255.0 * fade_mask * 255).astype(np.uint8) + + return faded_mask + + def _apply_color_shift(self, image, shift_amount): + """Appliquer un décalage de couleur HSV""" + if shift_amount == 0.0: + return image + + # Convertir en HSV + hsv = cv2.cvtColor(image, cv2.COLOR_RGB2HSV).astype(np.float32) + + # Décaler la teinte + hsv[:, :, 0] = (hsv[:, :, 0] + shift_amount * 180) % 180 + + # Reconvertir en RGB + shifted = cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2RGB) + + return shifted + + def _create_kaleidoscope_effect(self, base_sector, facettes, center, base_angle, + rotation, mirror_mode, blend_mode, h, w): + """Créer l'effet kaléidoscope avec différents modes de fusion""" + + if blend_mode == "add": + result = np.zeros((h, w, 3), dtype=np.float32) + elif blend_mode == "max": + result = np.zeros((h, w, 3), dtype=np.uint8) + elif blend_mode == "average": + result = np.zeros((h, w, 3), dtype=np.float32) + sector_count = np.zeros((h, w, 1), dtype=np.float32) + else: # overlay + result = base_sector.astype(np.float32) + + for i in range(facettes): + # Calculer l'angle de rotation + angle = base_angle * i + rotation + rotation_matrix = cv2.getRotationMatrix2D(center, angle, 1) + rotated = cv2.warpAffine(base_sector, rotation_matrix, (w, h)) + + # Appliquer l'effet miroir selon le mode + if mirror_mode == "alternate" and i % 2 == 1: + rotated = cv2.flip(rotated, 1) + elif mirror_mode == "all": + rotated = cv2.flip(rotated, 1) + # mirror_mode == "none" : pas de miroir + + # Fusionner selon le mode de fusion + if blend_mode == "add": + result += rotated.astype(np.float32) + elif blend_mode == "max": + result = np.maximum(result, rotated) + elif blend_mode == "average": + mask = (rotated > 0).any(axis=2, keepdims=True) + result += rotated.astype(np.float32) * mask + sector_count += mask + elif blend_mode == "overlay": + # Mode overlay simplifié + mask = (rotated > 0).any(axis=2, keepdims=True) + overlay = rotated.astype(np.float32) / 255.0 + base = result / 255.0 + + overlayed = np.where(overlay < 0.5, + 2 * base * overlay, + 1 - 2 * (1 - base) * (1 - overlay)) + + result = np.where(mask, overlayed * 255, result) + + # Post-traitement selon le mode de fusion + if blend_mode == "add": + # Normaliser pour éviter la saturation + max_val = np.max(result) + if max_val > 255: + result = result * 255 / max_val + result = result.astype(np.uint8) + elif blend_mode == "average": + # Calculer la moyenne + sector_count = np.maximum(sector_count, 1) # Éviter la division par zéro + result = (result / sector_count).astype(np.uint8) + + return result + +class KaleidoscopeAdvancedNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "facettes": ("INT", {"default": 6, "min": 2, "max": 20}), + "pattern_type": (["triangle", "diamond", "hexagon", "custom"], {"default": "triangle"}), + }, + "optional": { + "center_x": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "center_y": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "inner_radius": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 1.0, "step": 0.05}), + "outer_radius": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 2.0, "step": 0.05}), + "rotation": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 360.0, "step": 1.0}), + "symmetry_break": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.05}), + "chromatic_aberration": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 10.0, "step": 0.5}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "generate_advanced_kaleidoscope" + CATEGORY = "Image Effects" + + def generate_advanced_kaleidoscope(self, image, facettes, pattern_type="triangle", + center_x=0.5, center_y=0.5, inner_radius=0.1, + outer_radius=1.0, rotation=0.0, symmetry_break=0.0, + chromatic_aberration=0.0): + + # Prendre la première image du batch + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + image_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = image_np.shape + + center = (int(w * center_x), int(h * center_y)) + + # Créer le masque selon le type de pattern + mask = self._create_pattern_mask(h, w, center, facettes, pattern_type, + inner_radius, outer_radius, rotation) + + # Appliquer l'aberration chromatique si demandée + if chromatic_aberration > 0: + image_np = self._apply_chromatic_aberration(image_np, chromatic_aberration) + + # Créer l'effet avec brisure de symétrie + result = self._create_advanced_effect(image_np, mask, facettes, center, + rotation, symmetry_break) + + result = np.clip(result, 0, 255).astype(np.float32) / 255.0 + result_tensor = torch.from_numpy(result).unsqueeze(0) + + return (result_tensor,) + + def _create_pattern_mask(self, h, w, center, facettes, pattern_type, + inner_radius, outer_radius, rotation): + """Créer différents types de masques""" + mask = np.zeros((h, w), dtype=np.uint8) + + max_radius = min(w, h) // 2 + inner_r = int(max_radius * inner_radius) + outer_r = int(max_radius * outer_radius) + + if pattern_type == "triangle": + # Masque triangulaire standard + sector_angle = np.radians(360 / facettes) + start_angle = np.radians(rotation) + + x1 = int(center[0] + outer_r * np.cos(start_angle)) + y1 = int(center[1] + outer_r * np.sin(start_angle)) + x2 = int(center[0] + outer_r * np.cos(start_angle + sector_angle)) + y2 = int(center[1] + outer_r * np.sin(start_angle + sector_angle)) + + points = np.array([center, (x1, y1), (x2, y2)], dtype=np.int32) + cv2.fillConvexPoly(mask, points, 255) + + elif pattern_type == "diamond": + # Masque en forme de diamant + sector_angle = np.radians(360 / facettes) + start_angle = np.radians(rotation) + + # Points du diamant + x1 = int(center[0] + outer_r * np.cos(start_angle)) + y1 = int(center[1] + outer_r * np.sin(start_angle)) + x2 = int(center[0] + inner_r * np.cos(start_angle + sector_angle/2)) + y2 = int(center[1] + inner_r * np.sin(start_angle + sector_angle/2)) + x3 = int(center[0] + outer_r * np.cos(start_angle + sector_angle)) + y3 = int(center[1] + outer_r * np.sin(start_angle + sector_angle)) + + points = np.array([(x1, y1), (x2, y2), (x3, y3), center], dtype=np.int32) + cv2.fillConvexPoly(mask, points, 255) + + # Créer un trou au centre si inner_radius > 0 + if inner_r > 0: + cv2.circle(mask, center, inner_r, 0, -1) + + return mask + + def _apply_chromatic_aberration(self, image, strength): + """Simuler l'aberration chromatique""" + h, w = image.shape[:2] + center = (w // 2, h // 2) + + # Séparer les canaux + r_channel = image[:, :, 0] + g_channel = image[:, :, 1] + b_channel = image[:, :, 2] + + # Appliquer un décalage différent à chaque canal + offset = int(strength) + + # Décaler le rouge vers l'extérieur + M_r = np.float32([[1, 0, offset], [0, 1, offset]]) + r_shifted = cv2.warpAffine(r_channel, M_r, (w, h)) + + # Décaler le bleu vers l'intérieur + M_b = np.float32([[1, 0, -offset], [0, 1, -offset]]) + b_shifted = cv2.warpAffine(b_channel, M_b, (w, h)) + + # Recombiner les canaux + result = np.stack([r_shifted, g_channel, b_shifted], axis=2) + + return result + + def _create_advanced_effect(self, image, mask, facettes, center, rotation, symmetry_break): + """Créer l'effet avec brisure de symétrie""" + h, w = image.shape[:2] + base_sector = cv2.bitwise_and(image, image, mask=mask) + result = np.zeros_like(image, dtype=np.float32) + + base_angle = 360 / facettes + + for i in range(facettes): + # Ajouter une variation aléatoire pour briser la symétrie + angle_variation = symmetry_break * 30 * (np.random.random() - 0.5) + angle = base_angle * i + rotation + angle_variation + + rotation_matrix = cv2.getRotationMatrix2D(center, angle, 1) + rotated = cv2.warpAffine(base_sector, rotation_matrix, (w, h)) + + # Effet miroir alterné avec variation + if i % 2 == 1: + if symmetry_break > 0.5: + # Parfois ne pas appliquer le miroir pour plus de chaos + if np.random.random() > symmetry_break: + rotated = cv2.flip(rotated, 1) + else: + rotated = cv2.flip(rotated, 1) + + result += rotated.astype(np.float32) + + # Normaliser + max_val = np.max(result) + if max_val > 255: + result = result * 255 / max_val + + return result.astype(np.uint8) diff --git a/deformation/__init__.py b/deformation/__init__.py new file mode 100644 index 0000000..2f66eb9 --- /dev/null +++ b/deformation/__init__.py @@ -0,0 +1 @@ +"""Effets de déformation d'image""" diff --git a/deformation/__pycache__/__init__.cpython-312.pyc b/deformation/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..54e2fa0 Binary files /dev/null and b/deformation/__pycache__/__init__.cpython-312.pyc differ diff --git a/deformation/__pycache__/barrel_distortion_node.cpython-312.pyc b/deformation/__pycache__/barrel_distortion_node.cpython-312.pyc new file mode 100644 index 0000000..9f53863 Binary files /dev/null and b/deformation/__pycache__/barrel_distortion_node.cpython-312.pyc differ diff --git a/deformation/__pycache__/fisheye_node.cpython-312.pyc b/deformation/__pycache__/fisheye_node.cpython-312.pyc new file mode 100644 index 0000000..af7a275 Binary files /dev/null and b/deformation/__pycache__/fisheye_node.cpython-312.pyc differ diff --git a/deformation/__pycache__/pinch_node.cpython-312.pyc b/deformation/__pycache__/pinch_node.cpython-312.pyc new file mode 100644 index 0000000..038fac4 Binary files /dev/null and b/deformation/__pycache__/pinch_node.cpython-312.pyc differ diff --git a/deformation/__pycache__/ripple_node.cpython-312.pyc b/deformation/__pycache__/ripple_node.cpython-312.pyc new file mode 100644 index 0000000..a817375 Binary files /dev/null and b/deformation/__pycache__/ripple_node.cpython-312.pyc differ diff --git a/deformation/__pycache__/spherize_node.cpython-312.pyc b/deformation/__pycache__/spherize_node.cpython-312.pyc new file mode 100644 index 0000000..c18b85e Binary files /dev/null and b/deformation/__pycache__/spherize_node.cpython-312.pyc differ diff --git a/deformation/barrel_distortion_node.py b/deformation/barrel_distortion_node.py new file mode 100644 index 0000000..d376fb1 --- /dev/null +++ b/deformation/barrel_distortion_node.py @@ -0,0 +1,72 @@ +import numpy as np +import torch +import cv2 + +class BarrelDistortionNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "k1": ("FLOAT", {"default": 0.2, "min": -1.0, "max": 1.0, "step": 0.01}), + "k2": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}), + "k3": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}), + }, + "optional": { + "center_x": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "center_y": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "scale": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 2.0, "step": 0.1}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_barrel_distortion" + CATEGORY = "Image Effects" + + def apply_barrel_distortion(self, image, k1, k2, k3, center_x=0.5, center_y=0.5, scale=1.0): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + + # Centre de distorsion + cx = w * center_x + cy = h * center_y + + # Créer les grilles de coordonnées + map_x = np.zeros((h, w), dtype=np.float32) + map_y = np.zeros((h, w), dtype=np.float32) + + # Normalisation + max_radius = max(w, h) / 2 + + for y in range(h): + for x in range(w): + # Coordonnées normalisées par rapport au centre + xu = (x - cx) / max_radius + yu = (y - cy) / max_radius + + # Distance radiale + r2 = xu*xu + yu*yu + r4 = r2*r2 + r6 = r4*r2 + + # Facteur de distorsion + distortion = 1 + k1*r2 + k2*r4 + k3*r6 + + # Nouvelles coordonnées + xd = xu * distortion * scale + yd = yu * distortion * scale + + # Reconvertir en coordonnées image + map_x[y, x] = xd * max_radius + cx + map_y[y, x] = yd * max_radius + cy + + # Appliquer la transformation + result = cv2.remap(img_np, map_x, map_y, cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) diff --git a/deformation/fisheye_node.py b/deformation/fisheye_node.py new file mode 100644 index 0000000..aced80d --- /dev/null +++ b/deformation/fisheye_node.py @@ -0,0 +1,102 @@ +import numpy as np +import torch +import cv2 + +class FisheyeNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "fov": ("FLOAT", {"default": 180.0, "min": 30.0, "max": 360.0, "step": 1.0}), + "mapping": (["equidistant", "equisolid", "orthographic", "stereographic"], {"default": "equidistant"}), + "format": (["fullframe", "circular"], {"default": "fullframe"}), + }, + "optional": { + "center_x": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "center_y": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "strength": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 2.0, "step": 0.1}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_fisheye" + CATEGORY = "Image Effects" + + def apply_fisheye(self, image, fov, mapping, format, center_x=0.5, center_y=0.5, strength=1.0): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + + # Centre de l'effet + cx = int(w * center_x) + cy = int(h * center_y) + + # Rayon maximum + max_radius = min(w, h) // 2 + + # Créer les grilles de coordonnées + result = self._apply_fisheye_distortion(img_np, cx, cy, max_radius, fov, mapping, format, strength) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) + + def _apply_fisheye_distortion(self, image, cx, cy, max_radius, fov, mapping, format, strength): + h, w = image.shape[:2] + + # Créer les grilles de coordonnées + map_x = np.zeros((h, w), dtype=np.float32) + map_y = np.zeros((h, w), dtype=np.float32) + + fov_rad = np.radians(fov) + + for y in range(h): + for x in range(w): + # Distance du centre + dx = x - cx + dy = y - cy + r = np.sqrt(dx*dx + dy*dy) + + if r == 0: + map_x[y, x] = x + map_y[y, x] = y + continue + + # Angle + theta = np.arctan2(dy, dx) + + # Normaliser le rayon + r_norm = r / max_radius + + if format == "circular" and r_norm > 1.0: + map_x[y, x] = x + map_y[y, x] = y + continue + + # Appliquer la projection selon le mapping + if mapping == "equidistant": + r_fish = r_norm * fov_rad / (2 * np.pi) * max_radius + elif mapping == "equisolid": + r_fish = 2 * max_radius * np.sin(r_norm * fov_rad / 4) + elif mapping == "orthographic": + r_fish = max_radius * np.sin(r_norm * fov_rad / 2) + elif mapping == "stereographic": + r_fish = 2 * max_radius * np.tan(r_norm * fov_rad / 4) + + # Appliquer la force + r_fish *= strength + + # Nouvelles coordonnées + new_x = cx + r_fish * np.cos(theta) + new_y = cy + r_fish * np.sin(theta) + + map_x[y, x] = np.clip(new_x, 0, w - 1) + map_y[y, x] = np.clip(new_y, 0, h - 1) + + # Appliquer la transformation + result = cv2.remap(image, map_x, map_y, cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT) + return result diff --git a/deformation/pinch_node.py b/deformation/pinch_node.py new file mode 100644 index 0000000..7fb6b00 --- /dev/null +++ b/deformation/pinch_node.py @@ -0,0 +1,90 @@ +import numpy as np +import torch +import cv2 + +class PinchNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "strength": ("FLOAT", {"default": 0.5, "min": -2.0, "max": 2.0, "step": 0.1}), + "radius": ("FLOAT", {"default": 0.5, "min": 0.1, "max": 1.0, "step": 0.05}), + }, + "optional": { + "center_x": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "center_y": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "falloff": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.1}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_pinch" + CATEGORY = "Image Effects" + + def apply_pinch(self, image, strength, radius, center_x=0.5, center_y=0.5, falloff=0.5): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + + # Centre de l'effet + cx = w * center_x + cy = h * center_y + + # Rayon effectif + effect_radius = min(w, h) / 2 * radius + + # Créer les grilles de coordonnées + map_x = np.zeros((h, w), dtype=np.float32) + map_y = np.zeros((h, w), dtype=np.float32) + + for y in range(h): + for x in range(w): + # Distance du centre + dx = x - cx + dy = y - cy + distance = np.sqrt(dx*dx + dy*dy) + + if distance == 0 or distance > effect_radius: + map_x[y, x] = x + map_y[y, x] = y + continue + + # Facteur de distance normalisé + norm_distance = distance / effect_radius + + # Calcul du facteur de pincement avec falloff + if falloff > 0: + # Falloff doux + falloff_factor = 1 - np.power(norm_distance, 1 / falloff) + else: + # Falloff linéaire + falloff_factor = 1 - norm_distance + + # Facteur de pincement + if strength > 0: + # Pincement vers l'intérieur + pinch_factor = 1 - (strength * falloff_factor) + else: + # Étirement vers l'extérieur + pinch_factor = 1 + (abs(strength) * falloff_factor) + + # Nouvelles coordonnées + new_distance = distance * pinch_factor + angle = np.arctan2(dy, dx) + + new_x = cx + new_distance * np.cos(angle) + new_y = cy + new_distance * np.sin(angle) + + map_x[y, x] = np.clip(new_x, 0, w - 1) + map_y[y, x] = np.clip(new_y, 0, h - 1) + + # Appliquer la transformation + result = cv2.remap(img_np, map_x, map_y, cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) diff --git a/deformation/ripple_node.py b/deformation/ripple_node.py new file mode 100644 index 0000000..cbef982 --- /dev/null +++ b/deformation/ripple_node.py @@ -0,0 +1,84 @@ +import numpy as np +import torch +import cv2 + +class RippleNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "amplitude": ("FLOAT", {"default": 20.0, "min": 0.0, "max": 100.0, "step": 1.0}), + "frequency": ("FLOAT", {"default": 0.02, "min": 0.001, "max": 0.1, "step": 0.001}), + "wave_type": (["sine", "cosine", "both"], {"default": "sine"}), + }, + "optional": { + "center_x": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "center_y": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "phase": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 6.28, "step": 0.1}), + "decay": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_ripple" + CATEGORY = "Image Effects" + + def apply_ripple(self, image, amplitude, frequency, wave_type, + center_x=0.5, center_y=0.5, phase=0.0, decay=0.0): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + + # Centre des ondulations + cx = w * center_x + cy = h * center_y + + # Créer les grilles de coordonnées + map_x = np.zeros((h, w), dtype=np.float32) + map_y = np.zeros((h, w), dtype=np.float32) + + for y in range(h): + for x in range(w): + # Distance du centre + dx = x - cx + dy = y - cy + distance = np.sqrt(dx*dx + dy*dy) + + # Facteur de décroissance + decay_factor = 1.0 + if decay > 0: + max_dist = np.sqrt(w*w + h*h) / 2 + decay_factor = np.exp(-decay * distance / max_dist) + + # Calcul de l'ondulation + if wave_type == "sine": + ripple = np.sin(distance * frequency + phase) + elif wave_type == "cosine": + ripple = np.cos(distance * frequency + phase) + else: # both + ripple = (np.sin(distance * frequency + phase) + np.cos(distance * frequency + phase)) / 2 + + # Amplitude avec décroissance + effective_amplitude = amplitude * decay_factor * ripple + + # Direction de l'ondulation (radiale) + if distance > 0: + angle = np.arctan2(dy, dx) + offset_x = effective_amplitude * np.cos(angle) + offset_y = effective_amplitude * np.sin(angle) + else: + offset_x = offset_y = 0 + + map_x[y, x] = np.clip(x + offset_x, 0, w - 1) + map_y[y, x] = np.clip(y + offset_y, 0, h - 1) + + # Appliquer la transformation + result = cv2.remap(img_np, map_x, map_y, cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) diff --git a/deformation/spherize_node.py b/deformation/spherize_node.py new file mode 100644 index 0000000..bfaa123 --- /dev/null +++ b/deformation/spherize_node.py @@ -0,0 +1,99 @@ +import numpy as np +import torch +import cv2 + +class SpherizeNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "strength": ("FLOAT", {"default": 0.5, "min": -2.0, "max": 2.0, "step": 0.1}), + "radius": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 2.0, "step": 0.1}), + }, + "optional": { + "center_x": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "center_y": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "mode": (["spherize", "cylindrical"], {"default": "spherize"}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_spherize" + CATEGORY = "Image Effects" + + def apply_spherize(self, image, strength, radius, center_x=0.5, center_y=0.5, mode="spherize"): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + + # Centre de l'effet + cx = w * center_x + cy = h * center_y + + # Rayon effectif + max_radius = min(w, h) / 2 * radius + + # Créer les grilles de coordonnées + map_x = np.zeros((h, w), dtype=np.float32) + map_y = np.zeros((h, w), dtype=np.float32) + + for y in range(h): + for x in range(w): + # Distance du centre + dx = x - cx + dy = y - cy + distance = np.sqrt(dx*dx + dy*dy) + + if distance == 0 or distance > max_radius: + map_x[y, x] = x + map_y[y, x] = y + continue + + # Normaliser la distance + norm_distance = distance / max_radius + + if mode == "spherize": + # Effet sphère + if strength > 0: + # Convexe (vers l'extérieur) + factor = np.power(norm_distance, strength) + else: + # Concave (vers l'intérieur) + factor = 1 - np.power(1 - norm_distance, -strength) + else: + # Effet cylindrique (seulement horizontal ou vertical) + if abs(dx) > abs(dy): + # Déformation horizontale + factor = np.power(abs(dx) / max_radius, strength) if strength > 0 else 1 - np.power(1 - abs(dx) / max_radius, -strength) + factor = factor if dx >= 0 else -factor + map_x[y, x] = cx + factor * max_radius + map_y[y, x] = y + continue + else: + # Déformation verticale + factor = np.power(abs(dy) / max_radius, strength) if strength > 0 else 1 - np.power(1 - abs(dy) / max_radius, -strength) + factor = factor if dy >= 0 else -factor + map_x[y, x] = x + map_y[y, x] = cy + factor * max_radius + continue + + # Nouvelles coordonnées + new_distance = factor * max_radius + angle = np.arctan2(dy, dx) + + new_x = cx + new_distance * np.cos(angle) + new_y = cy + new_distance * np.sin(angle) + + map_x[y, x] = np.clip(new_x, 0, w - 1) + map_y[y, x] = np.clip(new_y, 0, h - 1) + + # Appliquer la transformation + result = cv2.remap(img_np, map_x, map_y, cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) diff --git a/geometric/__init__.py b/geometric/__init__.py new file mode 100644 index 0000000..9829435 --- /dev/null +++ b/geometric/__init__.py @@ -0,0 +1 @@ +"""Effets géométriques et patterns""" diff --git a/geometric/__pycache__/__init__.cpython-312.pyc b/geometric/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..c9fe7ba Binary files /dev/null and b/geometric/__pycache__/__init__.cpython-312.pyc differ diff --git a/geometric/__pycache__/crystallize_node.cpython-312.pyc b/geometric/__pycache__/crystallize_node.cpython-312.pyc new file mode 100644 index 0000000..78a2911 Binary files /dev/null and b/geometric/__pycache__/crystallize_node.cpython-312.pyc differ diff --git a/geometric/__pycache__/hexagonal_pixelate_node.cpython-312.pyc b/geometric/__pycache__/hexagonal_pixelate_node.cpython-312.pyc new file mode 100644 index 0000000..6e836d4 Binary files /dev/null and b/geometric/__pycache__/hexagonal_pixelate_node.cpython-312.pyc differ diff --git a/geometric/__pycache__/polygon_node.cpython-312.pyc b/geometric/__pycache__/polygon_node.cpython-312.pyc new file mode 100644 index 0000000..73de18f Binary files /dev/null and b/geometric/__pycache__/polygon_node.cpython-312.pyc differ diff --git a/geometric/__pycache__/triangulate_node.cpython-312.pyc b/geometric/__pycache__/triangulate_node.cpython-312.pyc new file mode 100644 index 0000000..e5bebfe Binary files /dev/null and b/geometric/__pycache__/triangulate_node.cpython-312.pyc differ diff --git a/geometric/__pycache__/voronoi_node.cpython-312.pyc b/geometric/__pycache__/voronoi_node.cpython-312.pyc new file mode 100644 index 0000000..e9e6206 Binary files /dev/null and b/geometric/__pycache__/voronoi_node.cpython-312.pyc differ diff --git a/geometric/crystallize_node.py b/geometric/crystallize_node.py new file mode 100644 index 0000000..71d25df --- /dev/null +++ b/geometric/crystallize_node.py @@ -0,0 +1,250 @@ +import numpy as np +import torch +import cv2 +from scipy.spatial import Voronoi + +class CrystallizeNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "crystal_size": ("INT", {"default": 30, "min": 10, "max": 100, "step": 5}), + "num_crystals": ("INT", {"default": 200, "min": 50, "max": 1000, "step": 50}), + "crystal_shape": (["angular", "organic", "geometric"], {"default": "angular"}), + }, + "optional": { + "edge_enhancement": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.1}), + "color_variation": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.1}), + "outline_strength": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.1}), + "randomness": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.1}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_crystallize" + CATEGORY = "Image Effects" + + def apply_crystallize(self, image, crystal_size, num_crystals, crystal_shape, + edge_enhancement=0.3, color_variation=0.2, outline_strength=0.5, randomness=0.3): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + + # Générer les centres de cristaux + crystal_centers = self._generate_crystal_centers(img_np, num_crystals, edge_enhancement) + + # Créer l'effet de cristallisation + result = self._create_crystallized_image(img_np, crystal_centers, crystal_size, + crystal_shape, color_variation, + outline_strength, randomness) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) + + def _generate_crystal_centers(self, image, num_crystals, edge_enhancement): + """Générer les centres des cristaux""" + h, w = image.shape[:2] + centers = [] + + if edge_enhancement > 0: + # Détecter les contours pour placer plus de cristaux sur les bords + gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY) + edges = cv2.Canny(gray, 50, 150) + edge_points = np.column_stack(np.where(edges > 0)) + + # Placer des cristaux sur les contours + num_edge_crystals = int(num_crystals * edge_enhancement) + if len(edge_points) > 0: + indices = np.random.choice(len(edge_points), + min(num_edge_crystals, len(edge_points)), + replace=False) + for idx in indices: + y, x = edge_points[idx] + centers.append([x, y]) + + # Compléter avec des centres aléatoires + remaining = num_crystals - len(centers) + for _ in range(remaining): + x = np.random.randint(0, w) + y = np.random.randint(0, h) + centers.append([x, y]) + + return np.array(centers) + + def _create_crystallized_image(self, image, centers, crystal_size, shape, + color_variation, outline_strength, randomness): + """Créer l'image cristallisée""" + h, w, c = image.shape + result = np.zeros_like(image) + + # Créer une carte de régions basée sur la distance + for y in range(h): + for x in range(w): + # Trouver le centre le plus proche + distances = np.sum((centers - np.array([x, y]))**2, axis=1) + closest_idx = np.argmin(distances) + closest_center = centers[closest_idx] + + # Calculer la couleur du cristal + color = self._get_crystal_color(image, closest_center, crystal_size, + color_variation, randomness, closest_idx) + + result[y, x] = color + + # Ajouter les contours des cristaux + if outline_strength > 0: + result = self._add_crystal_outlines(result, centers, outline_strength) + + # Appliquer la forme des cristaux + if shape != "organic": + result = self._apply_crystal_shape(result, centers, crystal_size, shape, randomness) + + return result + + def _get_crystal_color(self, image, center, size, variation, randomness, seed): + """Obtenir la couleur d'un cristal""" + h, w, c = image.shape + center_x, center_y = int(center[0]), int(center[1]) + + # Région autour du centre + region_size = max(5, size // 4) + x1 = max(0, center_x - region_size) + x2 = min(w, center_x + region_size) + y1 = max(0, center_y - region_size) + y2 = min(h, center_y + region_size) + + region = image[y1:y2, x1:x2] + if region.size > 0: + base_color = np.mean(region.reshape(-1, c), axis=0) + else: + base_color = image[center_y, center_x] if 0 <= center_x < w and 0 <= center_y < h else np.array([128, 128, 128]) + + # Ajouter de la variation de couleur + if variation > 0: + np.random.seed(seed) + variation_amount = variation * 50 + color_shift = np.random.uniform(-variation_amount, variation_amount, 3) + base_color = np.clip(base_color + color_shift, 0, 255) + + return base_color + + def _add_crystal_outlines(self, image, centers, strength): + """Ajouter les contours des cristaux""" + h, w = image.shape[:2] + + # Créer une carte des régions + regions = np.zeros((h, w), dtype=np.int32) + for y in range(h): + for x in range(w): + distances = np.sum((centers - np.array([x, y]))**2, axis=1) + regions[y, x] = np.argmin(distances) + + # Détecter les frontières + edges = np.zeros((h, w), dtype=np.uint8) + for y in range(1, h-1): + for x in range(1, w-1): + if (regions[y, x] != regions[y-1, x] or + regions[y, x] != regions[y+1, x] or + regions[y, x] != regions[y, x-1] or + regions[y, x] != regions[y, x+1]): + edges[y, x] = 255 + + # Appliquer l'effet de contour + result = image.copy() + edge_mask = edges > 0 + + # Assombrir les contours + result[edge_mask] = result[edge_mask] * (1 - strength) + + return result + + def _apply_crystal_shape(self, image, centers, size, shape, randomness): + """Appliquer une forme spécifique aux cristaux""" + h, w = image.shape[:2] + result = image.copy() + + if shape == "angular": + # Créer des formes angulaires + for i, center in enumerate(centers): + np.random.seed(i) + num_sides = np.random.randint(3, 8) + radius = size // 2 + np.random.randint(-size//4, size//4) + + # Créer un polygone angulaire + angles = np.linspace(0, 2*np.pi, num_sides, endpoint=False) + if randomness > 0: + angles += np.random.uniform(-randomness, randomness, num_sides) + + points = [] + for angle in angles: + r = radius * (1 + np.random.uniform(-randomness, randomness) * 0.3) + x = int(center[0] + r * np.cos(angle)) + y = int(center[1] + r * np.sin(angle)) + points.append([x, y]) + + if len(points) >= 3: + points = np.array(points, dtype=np.int32) + # Obtenir la couleur moyenne de la région + mask = np.zeros((h, w), dtype=np.uint8) + cv2.fillPoly(mask, [points], 255) + if np.any(mask): + avg_color = np.mean(image[mask > 0], axis=0) + cv2.fillPoly(result, [points], avg_color.tolist()) + + elif shape == "geometric": + # Créer des formes géométriques régulières + for i, center in enumerate(centers): + np.random.seed(i) + shape_type = np.random.choice(['triangle', 'square', 'hexagon']) + radius = size // 2 + + if shape_type == 'triangle': + points = self._create_triangle(center, radius) + elif shape_type == 'square': + points = self._create_square(center, radius) + elif shape_type == 'hexagon': + points = self._create_hexagon(center, radius) + + # Dessiner la forme + mask = np.zeros((h, w), dtype=np.uint8) + cv2.fillPoly(mask, [points], 255) + if np.any(mask): + avg_color = np.mean(image[mask > 0], axis=0) + cv2.fillPoly(result, [points], avg_color.tolist()) + + return result + + def _create_triangle(self, center, radius): + """Créer un triangle""" + points = [] + for i in range(3): + angle = i * 2 * np.pi / 3 + x = int(center[0] + radius * np.cos(angle)) + y = int(center[1] + radius * np.sin(angle)) + points.append([x, y]) + return np.array(points, dtype=np.int32) + + def _create_square(self, center, radius): + """Créer un carré""" + points = [] + for i in range(4): + angle = i * np.pi / 2 + np.pi / 4 + x = int(center[0] + radius * np.cos(angle)) + y = int(center[1] + radius * np.sin(angle)) + points.append([x, y]) + return np.array(points, dtype=np.int32) + + def _create_hexagon(self, center, radius): + """Créer un hexagone""" + points = [] + for i in range(6): + angle = i * np.pi / 3 + x = int(center[0] + radius * np.cos(angle)) + y = int(center[1] + radius * np.sin(angle)) + points.append([x, y]) + return np.array(points, dtype=np.int32) diff --git a/geometric/hexagonal_pixelate_node.py b/geometric/hexagonal_pixelate_node.py new file mode 100644 index 0000000..b3623e7 --- /dev/null +++ b/geometric/hexagonal_pixelate_node.py @@ -0,0 +1,136 @@ +import numpy as np +import torch +import cv2 + +class HexagonalPixelateNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "hex_size": ("INT", {"default": 20, "min": 5, "max": 100, "step": 5}), + "color_mode": (["average", "center", "dominant"], {"default": "average"}), + }, + "optional": { + "spacing": ("FLOAT", {"default": 0.9, "min": 0.5, "max": 1.0, "step": 0.05}), + "rotation": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 60.0, "step": 5.0}), + "outline": ("BOOLEAN", {"default": False}), + "outline_thickness": ("INT", {"default": 1, "min": 1, "max": 3, "step": 1}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_hexagonal_pixelate" + CATEGORY = "Image Effects" + + def apply_hexagonal_pixelate(self, image, hex_size, color_mode, + spacing=0.9, rotation=0.0, outline=False, outline_thickness=1): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + + # Créer l'image hexagonale + result = self._create_hexagonal_pattern(img_np, hex_size, color_mode, + spacing, rotation, outline, outline_thickness) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) + + def _create_hexagonal_pattern(self, image, hex_size, color_mode, spacing, rotation, outline, thickness): + """Créer le motif hexagonal""" + h, w, c = image.shape + result = np.zeros_like(image) + + # Calculer les dimensions hexagonales + hex_height = hex_size * 2 + hex_width = int(hex_size * np.sqrt(3)) + + # Espacement entre hexagones + effective_size = hex_size * spacing + + # Rotation en radians + rot_rad = np.radians(rotation) + + # Parcourir la grille hexagonale + for row in range(-1, h // int(hex_height * 0.75) + 2): + for col in range(-1, w // hex_width + 2): + # Position de l'hexagone + if row % 2 == 0: + x = col * hex_width + else: + x = col * hex_width + hex_width // 2 + + y = row * int(hex_height * 0.75) + + # Appliquer la rotation + if rotation != 0: + center_x, center_y = w // 2, h // 2 + x_rot = (x - center_x) * np.cos(rot_rad) - (y - center_y) * np.sin(rot_rad) + center_x + y_rot = (x - center_x) * np.sin(rot_rad) + (y - center_y) * np.cos(rot_rad) + center_y + x, y = int(x_rot), int(y_rot) + + # Vérifier si l'hexagone est dans l'image + if -hex_size <= x <= w + hex_size and -hex_size <= y <= h + hex_size: + # Créer l'hexagone + hex_points = self._create_hexagon_points(x, y, effective_size, rotation) + + # Obtenir la couleur de l'hexagone + color = self._get_hexagon_color(image, hex_points, color_mode) + + # Dessiner l'hexagone + if hex_points is not None: + cv2.fillPoly(result, [hex_points], color.tolist()) + + # Ajouter le contour si demandé + if outline: + cv2.polylines(result, [hex_points], True, (0, 0, 0), thickness) + + return result + + def _create_hexagon_points(self, center_x, center_y, size, rotation): + """Créer les points d'un hexagone""" + points = [] + rot_rad = np.radians(rotation) + + for i in range(6): + angle = i * np.pi / 3 + rot_rad + x = center_x + size * np.cos(angle) + y = center_y + size * np.sin(angle) + points.append([int(x), int(y)]) + + return np.array(points, dtype=np.int32) + + def _get_hexagon_color(self, image, hex_points, mode): + """Obtenir la couleur d'un hexagone""" + h, w, c = image.shape + + # Créer un masque pour l'hexagone + mask = np.zeros((h, w), dtype=np.uint8) + cv2.fillPoly(mask, [hex_points], 255) + + # Obtenir les pixels dans l'hexagone + masked_pixels = image[mask > 0] + + if len(masked_pixels) == 0: + return np.array([128, 128, 128]) # Couleur par défaut + + if mode == "average": + return np.mean(masked_pixels, axis=0) + elif mode == "center": + # Couleur du centre de l'hexagone + center = np.mean(hex_points, axis=0).astype(int) + if 0 <= center[0] < w and 0 <= center[1] < h: + return image[center[1], center[0]] + else: + return np.mean(masked_pixels, axis=0) + elif mode == "dominant": + # Couleur dominante (approximation) + pixels_reshaped = masked_pixels.reshape(-1, 3) + unique_colors, counts = np.unique(pixels_reshaped, axis=0, return_counts=True) + return unique_colors[np.argmax(counts)] + + return np.mean(masked_pixels, axis=0) diff --git a/geometric/polygon_node.py b/geometric/polygon_node.py new file mode 100644 index 0000000..4e7ee48 --- /dev/null +++ b/geometric/polygon_node.py @@ -0,0 +1,136 @@ +import numpy as np +import torch +import cv2 + +class PolygonNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "polygon_sides": ("INT", {"default": 6, "min": 3, "max": 12, "step": 1}), + "polygon_size": ("INT", {"default": 30, "min": 10, "max": 100, "step": 5}), + "reduction_factor": ("FLOAT", {"default": 0.5, "min": 0.1, "max": 0.9, "step": 0.1}), + }, + "optional": { + "color_mode": (["average", "dominant", "center"], {"default": "average"}), + "edge_preservation": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.1}), + "rotation": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 360.0, "step": 15.0}), + "outline": ("BOOLEAN", {"default": False}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_polygon_reduction" + CATEGORY = "Image Effects" + + def apply_polygon_reduction(self, image, polygon_sides, polygon_size, reduction_factor, + color_mode="average", edge_preservation=0.3, rotation=0.0, outline=False): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + + # Créer l'effet de réduction polygonale + result = self._create_polygon_reduction(img_np, polygon_sides, polygon_size, + reduction_factor, color_mode, + edge_preservation, rotation, outline) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) + + def _create_polygon_reduction(self, image, sides, size, reduction, color_mode, + edge_preservation, rotation, outline): + """Créer l'effet de réduction polygonale""" + h, w, c = image.shape + + # Calculer la nouvelle résolution + new_w = int(w * reduction) + new_h = int(h * reduction) + + # Redimensionner l'image + reduced = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA) + + # Créer l'image de sortie + result = np.zeros_like(image) + + # Calculer l'espacement des polygones + poly_spacing_x = w / new_w + poly_spacing_y = h / new_h + + # Préserver les contours si demandé + edges = None + if edge_preservation > 0: + gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY) + edges = cv2.Canny(gray, 50, 150) + + # Créer les polygones + for y in range(new_h): + for x in range(new_w): + # Position dans l'image originale + orig_x = int(x * poly_spacing_x + poly_spacing_x / 2) + orig_y = int(y * poly_spacing_y + poly_spacing_y / 2) + + # Couleur du pixel réduit + pixel_color = reduced[y, x] + + # Ajuster la couleur selon le mode + if color_mode == "average": + # Moyenner la région autour du pixel + region_size = max(1, int(min(poly_spacing_x, poly_spacing_y) / 2)) + x1 = max(0, orig_x - region_size) + x2 = min(w, orig_x + region_size) + y1 = max(0, orig_y - region_size) + y2 = min(h, orig_y + region_size) + region = image[y1:y2, x1:x2] + if region.size > 0: + pixel_color = np.mean(region.reshape(-1, c), axis=0) + + elif color_mode == "dominant": + # Couleur dominante dans la région + region_size = max(1, int(min(poly_spacing_x, poly_spacing_y) / 2)) + x1 = max(0, orig_x - region_size) + x2 = min(w, orig_x + region_size) + y1 = max(0, orig_y - region_size) + y2 = min(h, orig_y + region_size) + region = image[y1:y2, x1:x2] + if region.size > 0: + pixels = region.reshape(-1, c) + unique_colors, counts = np.unique(pixels, axis=0, return_counts=True) + pixel_color = unique_colors[np.argmax(counts)] + + # Créer le polygone + polygon_points = self._create_polygon_points(orig_x, orig_y, size, sides, rotation) + + # Dessiner le polygone + cv2.fillPoly(result, [polygon_points], pixel_color.tolist()) + + # Ajouter le contour si demandé + if outline: + cv2.polylines(result, [polygon_points], True, (0, 0, 0), 1) + + # Préserver les contours importants + if edge_preservation > 0 and edges is not None: + edge_mask = edges > 0 + blend_factor = edge_preservation + result[edge_mask] = (result[edge_mask] * (1 - blend_factor) + + image[edge_mask] * blend_factor).astype(np.uint8) + + return result + + def _create_polygon_points(self, center_x, center_y, size, sides, rotation): + """Créer les points d'un polygone""" + points = [] + angle_step = 2 * np.pi / sides + rotation_rad = np.radians(rotation) + + for i in range(sides): + angle = i * angle_step + rotation_rad + x = int(center_x + size * np.cos(angle)) + y = int(center_y + size * np.sin(angle)) + points.append([x, y]) + + return np.array(points, dtype=np.int32) diff --git a/geometric/triangulate_node.py b/geometric/triangulate_node.py new file mode 100644 index 0000000..ebdbacd --- /dev/null +++ b/geometric/triangulate_node.py @@ -0,0 +1,168 @@ +import numpy as np +import torch +import cv2 +from scipy.spatial import Delaunay + +class TriangulateNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "num_points": ("INT", {"default": 500, "min": 50, "max": 2000, "step": 50}), + "edge_threshold": ("FLOAT", {"default": 0.3, "min": 0.1, "max": 1.0, "step": 0.05}), + "color_mode": (["average", "dominant", "gradient"], {"default": "average"}), + }, + "optional": { + "point_distribution": (["random", "edge_based", "grid"], {"default": "edge_based"}), + "triangle_outline": ("BOOLEAN", {"default": False}), + "outline_thickness": ("INT", {"default": 1, "min": 1, "max": 5, "step": 1}), + "smoothing": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.1}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_triangulate" + CATEGORY = "Image Effects" + + def apply_triangulate(self, image, num_points, edge_threshold, color_mode, + point_distribution="edge_based", triangle_outline=False, + outline_thickness=1, smoothing=0.0): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + + # Générer les points selon la distribution + points = self._generate_points(img_np, num_points, point_distribution, edge_threshold) + + # Triangulation de Delaunay + tri = Delaunay(points) + + # Créer l'image triangulée + result = self._create_triangulated_image(img_np, points, tri.simplices, + color_mode, triangle_outline, outline_thickness) + + # Appliquer le lissage si demandé + if smoothing > 0: + result = self._apply_smoothing(result, smoothing) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) + + def _generate_points(self, image, num_points, distribution, edge_threshold): + """Générer les points pour la triangulation""" + h, w = image.shape[:2] + points = [] + + # Ajouter les coins + points.extend([[0, 0], [w-1, 0], [w-1, h-1], [0, h-1]]) + + if distribution == "random": + # Distribution aléatoire + for _ in range(num_points - 4): + x = np.random.randint(0, w) + y = np.random.randint(0, h) + points.append([x, y]) + + elif distribution == "edge_based": + # Basé sur les contours + gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY) + edges = cv2.Canny(gray, int(edge_threshold * 100), int(edge_threshold * 200)) + + # Points sur les contours + edge_points = np.column_stack(np.where(edges > 0)) + if len(edge_points) > 0: + # Échantillonner les points de contour + indices = np.random.choice(len(edge_points), + min(num_points // 2, len(edge_points)), + replace=False) + for idx in indices: + y, x = edge_points[idx] + points.append([x, y]) + + # Points aléatoires pour compléter + remaining = num_points - len(points) + for _ in range(remaining): + x = np.random.randint(0, w) + y = np.random.randint(0, h) + points.append([x, y]) + + elif distribution == "grid": + # Distribution en grille avec variation + grid_size = int(np.sqrt(num_points)) + for i in range(grid_size): + for j in range(grid_size): + if len(points) >= num_points: + break + x = int((j + 0.5) * w / grid_size) + np.random.randint(-w//20, w//20) + y = int((i + 0.5) * h / grid_size) + np.random.randint(-h//20, h//20) + x = np.clip(x, 0, w-1) + y = np.clip(y, 0, h-1) + points.append([x, y]) + + return np.array(points) + + def _create_triangulated_image(self, image, points, triangles, color_mode, outline, thickness): + """Créer l'image triangulée""" + h, w, c = image.shape + result = np.zeros_like(image) + + for triangle in triangles: + # Points du triangle + pts = points[triangle].astype(np.int32) + + # Calculer la couleur du triangle + color = self._get_triangle_color(image, pts, color_mode) + + # Dessiner le triangle + cv2.fillPoly(result, [pts], color.tolist()) + + # Dessiner le contour si demandé + if outline: + cv2.polylines(result, [pts], True, (0, 0, 0), thickness) + + return result + + def _get_triangle_color(self, image, triangle_points, mode): + """Calculer la couleur d'un triangle""" + # Créer un masque pour le triangle + mask = np.zeros(image.shape[:2], dtype=np.uint8) + cv2.fillPoly(mask, [triangle_points], 255) + + if mode == "average": + # Couleur moyenne + masked_pixels = image[mask > 0] + if len(masked_pixels) > 0: + return np.mean(masked_pixels, axis=0) + else: + return np.array([128, 128, 128]) + + elif mode == "dominant": + # Couleur dominante (approximation) + masked_pixels = image[mask > 0] + if len(masked_pixels) > 0: + # Quantifier les couleurs et prendre la plus fréquente + pixels_reshaped = masked_pixels.reshape(-1, 3) + unique_colors, counts = np.unique(pixels_reshaped, axis=0, return_counts=True) + dominant_color = unique_colors[np.argmax(counts)] + return dominant_color + else: + return np.array([128, 128, 128]) + + elif mode == "gradient": + # Gradient basé sur la position + center = np.mean(triangle_points, axis=0) + h, w = image.shape[:2] + gradient_factor = center[1] / h # Gradient vertical + base_color = np.mean(image[mask > 0], axis=0) if np.any(mask > 0) else np.array([128, 128, 128]) + return base_color * (0.5 + 0.5 * gradient_factor) + + def _apply_smoothing(self, image, smoothing): + """Appliquer un lissage à l'image""" + kernel_size = int(smoothing * 10) * 2 + 1 + smoothed = cv2.GaussianBlur(image, (kernel_size, kernel_size), 0) + return image * (1 - smoothing) + smoothed * smoothing diff --git a/geometric/voronoi_node.py b/geometric/voronoi_node.py new file mode 100644 index 0000000..9c874a2 --- /dev/null +++ b/geometric/voronoi_node.py @@ -0,0 +1,181 @@ +import numpy as np +import torch +import cv2 +from scipy.spatial import Voronoi, voronoi_plot_2d + +class VoronoiNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "num_seeds": ("INT", {"default": 100, "min": 10, "max": 500, "step": 10}), + "color_mode": (["average", "center_point", "random"], {"default": "average"}), + "cell_outline": ("BOOLEAN", {"default": True}), + }, + "optional": { + "seed_distribution": (["random", "edge_based", "grid"], {"default": "random"}), + "outline_color": (["black", "white", "adaptive"], {"default": "black"}), + "outline_thickness": ("INT", {"default": 2, "min": 1, "max": 5, "step": 1}), + "edge_threshold": ("FLOAT", {"default": 0.3, "min": 0.1, "max": 1.0, "step": 0.05}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_voronoi" + CATEGORY = "Image Effects" + + def apply_voronoi(self, image, num_seeds, color_mode, cell_outline, + seed_distribution="random", outline_color="black", + outline_thickness=2, edge_threshold=0.3): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + + # Générer les graines + seeds = self._generate_seeds(img_np, num_seeds, seed_distribution, edge_threshold) + + # Créer le diagramme de Voronoï + result = self._create_voronoi_image(img_np, seeds, color_mode, + cell_outline, outline_color, outline_thickness) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) + + def _generate_seeds(self, image, num_seeds, distribution, edge_threshold): + """Générer les graines pour le diagramme de Voronoï""" + h, w = image.shape[:2] + seeds = [] + + if distribution == "random": + for _ in range(num_seeds): + x = np.random.randint(0, w) + y = np.random.randint(0, h) + seeds.append([x, y]) + + elif distribution == "edge_based": + gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY) + edges = cv2.Canny(gray, int(edge_threshold * 100), int(edge_threshold * 200)) + edge_points = np.column_stack(np.where(edges > 0)) + + if len(edge_points) > 0: + indices = np.random.choice(len(edge_points), + min(num_seeds // 2, len(edge_points)), + replace=False) + for idx in indices: + y, x = edge_points[idx] + seeds.append([x, y]) + + # Compléter avec des points aléatoires + remaining = num_seeds - len(seeds) + for _ in range(remaining): + x = np.random.randint(0, w) + y = np.random.randint(0, h) + seeds.append([x, y]) + + elif distribution == "grid": + grid_size = int(np.sqrt(num_seeds)) + for i in range(grid_size): + for j in range(grid_size): + if len(seeds) >= num_seeds: + break + x = int((j + 0.5) * w / grid_size) + np.random.randint(-w//20, w//20) + y = int((i + 0.5) * h / grid_size) + np.random.randint(-h//20, h//20) + x = np.clip(x, 0, w-1) + y = np.clip(y, 0, h-1) + seeds.append([x, y]) + + return np.array(seeds) + + def _create_voronoi_image(self, image, seeds, color_mode, outline, outline_color, thickness): + """Créer l'image avec diagramme de Voronoï""" + h, w, c = image.shape + result = np.zeros_like(image) + + # Créer une carte de distance pour chaque graine + for y in range(h): + for x in range(w): + # Trouver la graine la plus proche + distances = np.sum((seeds - np.array([x, y]))**2, axis=1) + closest_seed_idx = np.argmin(distances) + closest_seed = seeds[closest_seed_idx] + + # Déterminer la couleur de la cellule + if color_mode == "average": + # Couleur moyenne autour de la graine + seed_x, seed_y = int(closest_seed[0]), int(closest_seed[1]) + region_size = 10 + x1 = max(0, seed_x - region_size) + x2 = min(w, seed_x + region_size) + y1 = max(0, seed_y - region_size) + y2 = min(h, seed_y + region_size) + region = image[y1:y2, x1:x2] + if region.size > 0: + color = np.mean(region.reshape(-1, c), axis=0) + else: + color = image[seed_y, seed_x] + + elif color_mode == "center_point": + # Couleur du point central de la graine + seed_x, seed_y = int(closest_seed[0]), int(closest_seed[1]) + color = image[seed_y, seed_x] + + elif color_mode == "random": + # Couleur aléatoire par cellule + np.random.seed(closest_seed_idx) + color = np.random.randint(0, 256, 3) + + result[y, x] = color + + # Ajouter les contours si demandé + if outline: + outline_img = self._add_voronoi_outlines(result, seeds, outline_color, thickness) + result = outline_img + + return result + + def _add_voronoi_outlines(self, image, seeds, outline_color, thickness): + """Ajouter les contours des cellules de Voronoï""" + h, w = image.shape[:2] + + # Créer une carte des régions + regions = np.zeros((h, w), dtype=np.int32) + for y in range(h): + for x in range(w): + distances = np.sum((seeds - np.array([x, y]))**2, axis=1) + regions[y, x] = np.argmin(distances) + + # Détecter les frontières + edges = np.zeros((h, w), dtype=np.uint8) + for y in range(1, h-1): + for x in range(1, w-1): + if (regions[y, x] != regions[y-1, x] or + regions[y, x] != regions[y+1, x] or + regions[y, x] != regions[y, x-1] or + regions[y, x] != regions[y, x+1]): + edges[y, x] = 255 + + # Appliquer l'épaisseur + if thickness > 1: + kernel = np.ones((thickness, thickness), np.uint8) + edges = cv2.dilate(edges, kernel, iterations=1) + + # Appliquer la couleur de contour + result = image.copy() + if outline_color == "black": + color = [0, 0, 0] + elif outline_color == "white": + color = [255, 255, 255] + elif outline_color == "adaptive": + # Couleur adaptative basée sur la luminosité locale + gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY) + color = np.where(gray[edges > 0] > 128, [0, 0, 0], [255, 255, 255]) + result[edges > 0] = color + return result + + result[edges > 0] = color + return result diff --git a/light_effects/__init__.py b/light_effects/__init__.py new file mode 100644 index 0000000..dc1e308 --- /dev/null +++ b/light_effects/__init__.py @@ -0,0 +1 @@ +"""Effets de lumière et d'éclairage""" diff --git a/light_effects/__pycache__/__init__.cpython-312.pyc b/light_effects/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..b9621a9 Binary files /dev/null and b/light_effects/__pycache__/__init__.cpython-312.pyc differ diff --git a/light_effects/__pycache__/aurora_node.cpython-312.pyc b/light_effects/__pycache__/aurora_node.cpython-312.pyc new file mode 100644 index 0000000..177f626 Binary files /dev/null and b/light_effects/__pycache__/aurora_node.cpython-312.pyc differ diff --git a/light_effects/__pycache__/god_rays_node.cpython-312.pyc b/light_effects/__pycache__/god_rays_node.cpython-312.pyc new file mode 100644 index 0000000..68ebe49 Binary files /dev/null and b/light_effects/__pycache__/god_rays_node.cpython-312.pyc differ diff --git a/light_effects/__pycache__/holographic_node.cpython-312.pyc b/light_effects/__pycache__/holographic_node.cpython-312.pyc new file mode 100644 index 0000000..161a482 Binary files /dev/null and b/light_effects/__pycache__/holographic_node.cpython-312.pyc differ diff --git a/light_effects/__pycache__/lens_flare_node.cpython-312.pyc b/light_effects/__pycache__/lens_flare_node.cpython-312.pyc new file mode 100644 index 0000000..65310cd Binary files /dev/null and b/light_effects/__pycache__/lens_flare_node.cpython-312.pyc differ diff --git a/light_effects/__pycache__/neon_glow_node.cpython-312.pyc b/light_effects/__pycache__/neon_glow_node.cpython-312.pyc new file mode 100644 index 0000000..f4f8dc6 Binary files /dev/null and b/light_effects/__pycache__/neon_glow_node.cpython-312.pyc differ diff --git a/light_effects/aurora_node.py b/light_effects/aurora_node.py new file mode 100644 index 0000000..4958d5f --- /dev/null +++ b/light_effects/aurora_node.py @@ -0,0 +1,114 @@ +import numpy as np +import torch +import cv2 + +class AuroraNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "intensity": ("FLOAT", {"default": 0.6, "min": 0.0, "max": 2.0, "step": 0.1}), + "color_palette": (["green_blue", "purple_pink", "blue_cyan", "multicolor"], {"default": "green_blue"}), + "wave_frequency": ("FLOAT", {"default": 0.02, "min": 0.005, "max": 0.1, "step": 0.005}), + }, + "optional": { + "position": (["top", "bottom", "center"], {"default": "top"}), + "height": ("FLOAT", {"default": 0.4, "min": 0.1, "max": 0.8, "step": 0.05}), + "animation_speed": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 3.0, "step": 0.1}), + "opacity": ("FLOAT", {"default": 0.7, "min": 0.1, "max": 1.0, "step": 0.05}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_aurora" + CATEGORY = "Image Effects" + + def apply_aurora(self, image, intensity, color_palette, wave_frequency, + position="top", height=0.4, animation_speed=1.0, opacity=0.7): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + result = img_np.copy().astype(np.float32) + + # Palettes de couleurs d'aurore + palettes = { + "green_blue": [(0, 255, 100), (0, 200, 255), (50, 255, 150)], + "purple_pink": [(200, 50, 255), (255, 100, 200), (150, 0, 255)], + "blue_cyan": [(0, 100, 255), (0, 255, 255), (100, 150, 255)], + "multicolor": [(0, 255, 100), (255, 100, 200), (100, 150, 255), (255, 200, 0)] + } + + colors = palettes[color_palette] + + # Créer l'aurore + aurora_overlay = self._create_aurora_effect(h, w, colors, wave_frequency, + position, height, animation_speed, intensity) + + # Fusionner avec l'image + result = result * (1 - opacity) + (result + aurora_overlay) * opacity + result = np.clip(result, 0, 255) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) + + def _create_aurora_effect(self, h, w, colors, frequency, position, height_ratio, speed, intensity): + """Créer l'effet d'aurore boréale""" + overlay = np.zeros((h, w, 3), dtype=np.float32) + + # Zone d'effet selon la position + if position == "top": + start_y = 0 + end_y = int(h * height_ratio) + elif position == "bottom": + start_y = int(h * (1 - height_ratio)) + end_y = h + else: # center + center = h // 2 + half_height = int(h * height_ratio / 2) + start_y = center - half_height + end_y = center + half_height + + # Animation basée sur le temps + import time + time_factor = time.time() * speed + + # Créer plusieurs couches d'aurore + for layer in range(len(colors)): + color = colors[layer] + + # Décalage temporel pour chaque couche + layer_time = time_factor + layer * 2 + + # Créer les vagues d'aurore + for y in range(start_y, end_y): + # Intensité basée sur la position verticale + y_factor = 1.0 - abs(y - (start_y + end_y) / 2) / ((end_y - start_y) / 2) + + for x in range(w): + # Calcul des vagues multiples + wave1 = np.sin(x * frequency + layer_time) * 0.5 + wave2 = np.sin(x * frequency * 2.3 + layer_time * 1.7) * 0.3 + wave3 = np.sin(x * frequency * 0.7 + layer_time * 0.8) * 0.2 + + combined_wave = wave1 + wave2 + wave3 + + # Intensité de l'aurore à ce point + aurora_intensity = max(0, combined_wave * y_factor * intensity) + + # Ajouter la couleur avec variation + for c in range(3): + overlay[y, x, c] += color[c] * aurora_intensity * (0.3 + 0.7 / (layer + 1)) + + # Flou pour effet de diffusion + overlay = cv2.GaussianBlur(overlay, (21, 21), 0) + + # Ajouter du bruit pour plus de réalisme + noise = np.random.random((h, w, 3)) * 10 + overlay += noise + + return np.clip(overlay, 0, 255) diff --git a/light_effects/god_rays_node.py b/light_effects/god_rays_node.py new file mode 100644 index 0000000..38180a4 --- /dev/null +++ b/light_effects/god_rays_node.py @@ -0,0 +1,131 @@ +import numpy as np +import torch +import cv2 + +class GodRaysNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "intensity": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 2.0, "step": 0.1}), + "num_rays": ("INT", {"default": 8, "min": 3, "max": 20, "step": 1}), + "ray_length": ("FLOAT", {"default": 0.8, "min": 0.1, "max": 2.0, "step": 0.1}), + }, + "optional": { + "source_x": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "source_y": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01}), + "color_temp": ("FLOAT", {"default": 3000.0, "min": 2000.0, "max": 8000.0, "step": 100.0}), + "decay": ("FLOAT", {"default": 0.8, "min": 0.1, "max": 1.0, "step": 0.1}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_god_rays" + CATEGORY = "Image Effects" + + def apply_god_rays(self, image, intensity, num_rays, ray_length, + source_x=0.5, source_y=0.2, color_temp=3000.0, decay=0.8): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + result = img_np.copy().astype(np.float32) + + # Source des rayons + source_x_px = int(w * source_x) + source_y_px = int(h * source_y) + + # Couleur des rayons + ray_color = self._temp_to_rgb(color_temp) + + # Créer les rayons divins + rays_overlay = self._create_god_rays(h, w, source_x_px, source_y_px, + num_rays, ray_length, ray_color, intensity, decay) + + # Fusionner avec l'image + result = np.clip(result + rays_overlay, 0, 255) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) + + def _temp_to_rgb(self, temp): + """Convertir température de couleur en RGB""" + temp = temp / 100 + + if temp <= 66: + red = 255 + green = temp + green = 99.4708025861 * np.log(green) - 161.1195681661 + if temp >= 19: + blue = temp - 10 + blue = 138.5177312231 * np.log(blue) - 305.0447927307 + else: + blue = 0 + else: + red = temp - 60 + red = 329.698727446 * np.power(red, -0.1332047592) + green = temp - 60 + green = 288.1221695283 * np.power(green, -0.0755148492) + blue = 255 + + return (np.clip(red, 0, 255), np.clip(green, 0, 255), np.clip(blue, 0, 255)) + + def _create_god_rays(self, h, w, source_x, source_y, num_rays, ray_length, color, intensity, decay): + """Créer les rayons divins""" + overlay = np.zeros((h, w, 3), dtype=np.float32) + + # Longueur maximale des rayons + max_length = int(min(w, h) * ray_length) + + for i in range(num_rays): + # Angle du rayon avec variation aléatoire + base_angle = (2 * np.pi * i) / num_rays + angle_variation = np.random.uniform(-0.3, 0.3) + angle = base_angle + angle_variation + + # Créer un rayon individuel + ray_overlay = self._create_single_ray(h, w, source_x, source_y, + angle, max_length, color, decay) + overlay += ray_overlay + + # Normaliser et appliquer l'intensité + overlay = np.clip(overlay, 0, 255) * intensity + + return overlay + + def _create_single_ray(self, h, w, start_x, start_y, angle, length, color, decay): + """Créer un rayon individuel""" + ray_overlay = np.zeros((h, w, 3), dtype=np.float32) + + # Calculer les points du rayon + end_x = int(start_x + length * np.cos(angle)) + end_y = int(start_y + length * np.sin(angle)) + + # Largeur variable du rayon + num_segments = 50 + for i in range(num_segments): + t = i / num_segments + + # Position le long du rayon + x = int(start_x + t * (end_x - start_x)) + y = int(start_y + t * (end_y - start_y)) + + # Largeur qui diminue avec la distance + width = max(1, int(10 * (1 - t * decay))) + + # Intensité qui diminue avec la distance + alpha = (1 - t) * decay + + # Dessiner un segment du rayon + if 0 <= x < w and 0 <= y < h: + cv2.circle(ray_overlay, (x, y), width, + (color[0] * alpha, color[1] * alpha, color[2] * alpha), -1) + + # Flou gaussien pour adoucir + ray_overlay = cv2.GaussianBlur(ray_overlay, (21, 21), 0) + + return ray_overlay diff --git a/light_effects/holographic_node.py b/light_effects/holographic_node.py new file mode 100644 index 0000000..0f73abf --- /dev/null +++ b/light_effects/holographic_node.py @@ -0,0 +1,158 @@ +import numpy as np +import torch +import cv2 + +class HolographicNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "intensity": ("FLOAT", {"default": 0.6, "min": 0.0, "max": 2.0, "step": 0.1}), + "interference_lines": ("INT", {"default": 100, "min": 20, "max": 300, "step": 10}), + "color_shift": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.1}), + }, + "optional": { + "chromatic_aberration": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.05}), + "transparency": ("FLOAT", {"default": 0.7, "min": 0.1, "max": 1.0, "step": 0.05}), + "flicker": ("BOOLEAN", {"default": True}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_holographic" + CATEGORY = "Image Effects" + + def apply_holographic(self, image, intensity, interference_lines, color_shift, + chromatic_aberration=0.3, transparency=0.7, flicker=True): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + result = img_np.copy().astype(np.float32) + + # Effet de scintillement + flicker_factor = 1.0 + if flicker: + import time + flicker_factor = 0.8 + 0.2 * np.sin(time.time() * 10) * np.random.uniform(0.5, 1.0) + + # Appliquer la transparence + result *= transparency + + # Aberration chromatique + if chromatic_aberration > 0: + result = self._apply_chromatic_aberration(result, chromatic_aberration) + + # Décalage de couleur holographique + if color_shift > 0: + result = self._apply_holographic_color_shift(result, color_shift) + + # Lignes d'interférence + interference_overlay = self._create_interference_lines(h, w, interference_lines, intensity) + result += interference_overlay + + # Appliquer le scintillement + result *= flicker_factor + + # Normaliser + result = np.clip(result, 0, 255) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) + + def _apply_chromatic_aberration(self, image, strength): + """Appliquer l'aberration chromatique""" + h, w = image.shape[:2] + + # Séparer les canaux + r_channel = image[:, :, 0] + g_channel = image[:, :, 1] + b_channel = image[:, :, 2] + + # Décalages pour chaque canal + offset = int(strength * 3) + + # Décaler le rouge + M_r = np.float32([[1, 0, offset], [0, 1, 0]]) + r_shifted = cv2.warpAffine(r_channel, M_r, (w, h)) + + # Décaler le bleu + M_b = np.float32([[1, 0, -offset], [0, 1, 0]]) + b_shifted = cv2.warpAffine(b_channel, M_b, (w, h)) + + # Recombiner + result = np.stack([r_shifted, g_channel, b_shifted], axis=2) + return result + + def _apply_holographic_color_shift(self, image, shift_strength): + """Appliquer un décalage de couleur holographique""" + # Convertir en HSV + hsv = cv2.cvtColor(image.astype(np.uint8), cv2.COLOR_RGB2HSV).astype(np.float32) + + # Créer un gradient de décalage de teinte + h, w = image.shape[:2] + y_gradient = np.linspace(0, 1, h).reshape(-1, 1) + hue_shift = y_gradient * shift_strength * 180 + + # Appliquer le décalage + hsv[:, :, 0] = (hsv[:, :, 0] + hue_shift) % 180 + + # Augmenter la saturation pour l'effet holographique + hsv[:, :, 1] = np.clip(hsv[:, :, 1] * (1 + shift_strength * 0.5), 0, 255) + + # Reconvertir en RGB + result = cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2RGB).astype(np.float32) + return result + + def _create_interference_lines(self, h, w, num_lines, intensity): + """Créer des lignes d'interférence holographiques""" + overlay = np.zeros((h, w, 3), dtype=np.float32) + + # Lignes horizontales d'interférence + line_spacing = h // num_lines + + for i in range(0, h, line_spacing): + # Variation d'intensité aléatoire + line_intensity = intensity * np.random.uniform(0.3, 1.0) + + # Couleur arc-en-ciel basée sur la position + hue = (i / h) * 360 + color = self._hsv_to_rgb(hue, 100, 100) + + # Dessiner la ligne avec dégradé + line_thickness = max(1, line_spacing // 3) + for j in range(line_thickness): + if i + j < h: + alpha = 1.0 - (j / line_thickness) + overlay[i + j, :] = np.array(color) * line_intensity * alpha + + return overlay + + def _hsv_to_rgb(self, h, s, v): + """Convertir HSV en RGB""" + h = h / 60.0 + s = s / 100.0 + v = v / 100.0 + + c = v * s + x = c * (1 - abs((h % 2) - 1)) + m = v - c + + if 0 <= h < 1: + r, g, b = c, x, 0 + elif 1 <= h < 2: + r, g, b = x, c, 0 + elif 2 <= h < 3: + r, g, b = 0, c, x + elif 3 <= h < 4: + r, g, b = 0, x, c + elif 4 <= h < 5: + r, g, b = x, 0, c + else: + r, g, b = c, 0, x + + return ((r + m) * 255, (g + m) * 255, (b + m) * 255) diff --git a/light_effects/lens_flare_node.py b/light_effects/lens_flare_node.py new file mode 100644 index 0000000..09ca323 --- /dev/null +++ b/light_effects/lens_flare_node.py @@ -0,0 +1,159 @@ +import numpy as np +import torch +import cv2 + +class LensFlareNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "flare_type": (["classic", "anamorphic", "starburst", "hexagonal"], {"default": "classic"}), + "intensity": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 2.0, "step": 0.1}), + "size": ("FLOAT", {"default": 0.3, "min": 0.1, "max": 1.0, "step": 0.05}), + }, + "optional": { + "position_x": ("FLOAT", {"default": 0.7, "min": 0.0, "max": 1.0, "step": 0.01}), + "position_y": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01}), + "color_temp": ("FLOAT", {"default": 5500.0, "min": 2000.0, "max": 10000.0, "step": 100.0}), + "rays": ("INT", {"default": 6, "min": 4, "max": 12, "step": 1}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_lens_flare" + CATEGORY = "Image Effects" + + def apply_lens_flare(self, image, flare_type, intensity, size, + position_x=0.7, position_y=0.3, color_temp=5500.0, rays=6): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + result = img_np.copy().astype(np.float32) + + # Position du flare + flare_x = int(w * position_x) + flare_y = int(h * position_y) + + # Couleur basée sur la température + flare_color = self._temp_to_rgb(color_temp) + + # Créer le flare selon le type + if flare_type == "classic": + flare_overlay = self._create_classic_flare(h, w, flare_x, flare_y, size, flare_color, intensity) + elif flare_type == "anamorphic": + flare_overlay = self._create_anamorphic_flare(h, w, flare_x, flare_y, size, flare_color, intensity) + elif flare_type == "starburst": + flare_overlay = self._create_starburst_flare(h, w, flare_x, flare_y, size, flare_color, intensity, rays) + elif flare_type == "hexagonal": + flare_overlay = self._create_hexagonal_flare(h, w, flare_x, flare_y, size, flare_color, intensity) + + # Fusionner avec l'image + result = np.clip(result + flare_overlay, 0, 255) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) + + def _temp_to_rgb(self, temp): + """Convertir température de couleur en RGB""" + temp = temp / 100 + + if temp <= 66: + red = 255 + green = temp + green = 99.4708025861 * np.log(green) - 161.1195681661 + if temp >= 19: + blue = temp - 10 + blue = 138.5177312231 * np.log(blue) - 305.0447927307 + else: + blue = 0 + else: + red = temp - 60 + red = 329.698727446 * np.power(red, -0.1332047592) + green = temp - 60 + green = 288.1221695283 * np.power(green, -0.0755148492) + blue = 255 + + return tuple([int(np.clip(red, 0, 255)), int(np.clip(green, 0, 255)), int(np.clip(blue, 0, 255))]) + + def _create_classic_flare(self, h, w, cx, cy, size, color, intensity): + """Créer un flare classique avec cercles concentriques""" + overlay = np.zeros((h, w, 3), dtype=np.float32) + color_tuple = tuple([int(c) for c in color]) + radius = int(min(w, h) * size * 0.3) + cv2.circle(overlay, (cx, cy), radius, color_tuple, -1) + for i in range(3): + r = radius // (i + 2) + alpha = 0.3 / (i + 1) + circle_overlay = np.zeros_like(overlay) + cv2.circle(circle_overlay, (cx, cy), r, color_tuple, -1) + overlay += circle_overlay * alpha + overlay = cv2.GaussianBlur(overlay, (51, 51), 0) + return overlay * intensity + + def _create_anamorphic_flare(self, h, w, cx, cy, size, color, intensity): + """Créer un flare anamorphique (lignes horizontales)""" + overlay = np.zeros((h, w, 3), dtype=np.float32) + color_tuple = tuple([int(c) for c in color]) + line_height = int(h * size * 0.1) + line_width = int(w * size) + y1 = max(0, cy - line_height // 2) + y2 = min(h, cy + line_height // 2) + x1 = max(0, cx - line_width // 2) + x2 = min(w, cx + line_width // 2) + overlay[y1:y2, x1:x2] = color_tuple + radius = int(min(w, h) * size * 0.1) + cv2.circle(overlay, (cx, cy), radius, color_tuple, -1) + kernel = np.ones((1, 21)) / 21 + for i in range(3): + overlay[:, :, i] = cv2.filter2D(overlay[:, :, i], -1, kernel) + return overlay * intensity + + def _create_starburst_flare(self, h, w, cx, cy, size, color, intensity, rays): + """Créer un flare en étoile""" + overlay = np.zeros((h, w, 3), dtype=np.float32) + color_tuple = tuple([int(c) for c in color]) + ray_length = int(min(w, h) * size) + for i in range(rays): + angle = (2 * np.pi * i) / rays + x1 = cx + y1 = cy + x2 = int(cx + ray_length * np.cos(angle)) + y2 = int(cy + ray_length * np.sin(angle)) + cv2.line(overlay, (x1, y1), (x2, y2), color_tuple, 3) + radius = int(min(w, h) * size * 0.05) + cv2.circle(overlay, (cx, cy), radius, color_tuple, -1) + overlay = cv2.GaussianBlur(overlay, (31, 31), 0) + return overlay * intensity + + def _create_hexagonal_flare(self, h, w, cx, cy, size, color, intensity): + """Créer un flare hexagonal""" + overlay = np.zeros((h, w, 3), dtype=np.float32) + color_tuple = tuple([int(c) for c in color]) + radius = int(min(w, h) * size * 0.2) + points = [] + for i in range(6): + angle = (2 * np.pi * i) / 6 + x = int(cx + radius * np.cos(angle)) + y = int(cy + radius * np.sin(angle)) + points.append([x, y]) + points = np.array(points, dtype=np.int32) + cv2.fillPoly(overlay, [points], color_tuple) + for i in range(1, 4): + r = radius // (i + 1) + hex_points = [] + for j in range(6): + angle = (2 * np.pi * j) / 6 + x = int(cx + r * np.cos(angle)) + y = int(cy + r * np.sin(angle)) + hex_points.append([x, y]) + hex_points = np.array(hex_points, dtype=np.int32) + hex_overlay = np.zeros_like(overlay) + cv2.fillPoly(hex_overlay, [hex_points], color_tuple) + overlay += hex_overlay * (0.5 / i) + overlay = cv2.GaussianBlur(overlay, (41, 41), 0) + return overlay * intensity diff --git a/light_effects/neon_glow_node.py b/light_effects/neon_glow_node.py new file mode 100644 index 0000000..e684b3d --- /dev/null +++ b/light_effects/neon_glow_node.py @@ -0,0 +1,106 @@ +import numpy as np +import torch +import cv2 + +class NeonGlowNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "glow_color": (["cyan", "magenta", "yellow", "red", "green", "blue", "purple", "orange"], {"default": "cyan"}), + "intensity": ("FLOAT", {"default": 0.8, "min": 0.0, "max": 2.0, "step": 0.1}), + "glow_size": ("FLOAT", {"default": 0.3, "min": 0.1, "max": 1.0, "step": 0.05}), + }, + "optional": { + "edge_threshold": ("FLOAT", {"default": 0.3, "min": 0.1, "max": 1.0, "step": 0.05}), + "inner_glow": ("BOOLEAN", {"default": True}), + "outer_glow": ("BOOLEAN", {"default": True}), + "pulsate": ("BOOLEAN", {"default": False}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_neon_glow" + CATEGORY = "Image Effects" + + def apply_neon_glow(self, image, glow_color, intensity, glow_size, + edge_threshold=0.3, inner_glow=True, outer_glow=True, pulsate=False): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + result = img_np.copy().astype(np.float32) + + # Couleurs néon prédéfinies + neon_colors = { + "cyan": (0, 255, 255), + "magenta": (255, 0, 255), + "yellow": (255, 255, 0), + "red": (255, 50, 50), + "green": (50, 255, 50), + "blue": (50, 50, 255), + "purple": (200, 50, 255), + "orange": (255, 150, 0) + } + + color = neon_colors[glow_color] + + # Effet de pulsation + pulse_factor = 1.0 + if pulsate: + import time + pulse_factor = 0.7 + 0.3 * np.sin(time.time() * 3) + + effective_intensity = intensity * pulse_factor + + # Détecter les contours + gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY) + edges = cv2.Canny(gray, int(edge_threshold * 100), int(edge_threshold * 200)) + + # Créer l'effet néon + neon_overlay = self._create_neon_effect(edges, color, glow_size, + effective_intensity, inner_glow, outer_glow) + + # Fusionner avec l'image + result = np.clip(result + neon_overlay, 0, 255) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) + + def _create_neon_effect(self, edges, color, glow_size, intensity, inner_glow, outer_glow): + """Créer l'effet néon à partir des contours""" + h, w = edges.shape + overlay = np.zeros((h, w, 3), dtype=np.float32) + + # Convertir les contours en image couleur + edge_color = np.zeros((h, w, 3), dtype=np.float32) + edge_mask = edges > 0 + edge_color[edge_mask] = color + + # Lueur intérieure + if inner_glow: + inner_blur_size = max(3, int(glow_size * 20)) + if inner_blur_size % 2 == 0: + inner_blur_size += 1 + inner_glow_layer = cv2.GaussianBlur(edge_color, (inner_blur_size, inner_blur_size), 0) + overlay += inner_glow_layer * 0.8 + + # Lueur extérieure + if outer_glow: + outer_blur_size = max(5, int(glow_size * 40)) + if outer_blur_size % 2 == 0: + outer_blur_size += 1 + outer_glow_layer = cv2.GaussianBlur(edge_color, (outer_blur_size, outer_blur_size), 0) + overlay += outer_glow_layer * 0.4 + + # Contour principal brillant + overlay += edge_color * 1.5 + + # Appliquer l'intensité + overlay *= intensity + + return overlay diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2f79a15 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,22 @@ +# Image Effects - Dépendances Python +# Package de nœuds d'effets d'image pour ComfyUI + +# Bibliothèques de base +numpy>=1.21.0 +torch>=1.9.0 +torchvision>=0.10.0 + +# Traitement d'image +opencv-python>=4.5.0 +Pillow>=8.0.0 +scipy>=1.7.0 + +# Optionnel pour certains effets avancés +scikit-image>=0.18.0 +matplotlib>=3.3.0 + +# Pour les effets géométriques avancés +shapely>=1.7.0 + +# Accélération GPU (optionnel) +cupy-cuda11x>=9.0.0; platform_system=="Linux" diff --git a/vintage/__init__.py b/vintage/__init__.py new file mode 100644 index 0000000..139fad7 --- /dev/null +++ b/vintage/__init__.py @@ -0,0 +1 @@ +"""Effets rétro et vintage""" \ No newline at end of file diff --git a/vintage/__pycache__/__init__.cpython-312.pyc b/vintage/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..7ea58c8 Binary files /dev/null and b/vintage/__pycache__/__init__.cpython-312.pyc differ diff --git a/vintage/__pycache__/film_grain_node.cpython-312.pyc b/vintage/__pycache__/film_grain_node.cpython-312.pyc new file mode 100644 index 0000000..115a7f2 Binary files /dev/null and b/vintage/__pycache__/film_grain_node.cpython-312.pyc differ diff --git a/vintage/__pycache__/light_leaks_node.cpython-312.pyc b/vintage/__pycache__/light_leaks_node.cpython-312.pyc new file mode 100644 index 0000000..7e0be41 Binary files /dev/null and b/vintage/__pycache__/light_leaks_node.cpython-312.pyc differ diff --git a/vintage/__pycache__/polaroid_node.cpython-312.pyc b/vintage/__pycache__/polaroid_node.cpython-312.pyc new file mode 100644 index 0000000..b8f11a4 Binary files /dev/null and b/vintage/__pycache__/polaroid_node.cpython-312.pyc differ diff --git a/vintage/__pycache__/vhs_glitch_node.cpython-312.pyc b/vintage/__pycache__/vhs_glitch_node.cpython-312.pyc new file mode 100644 index 0000000..106915b Binary files /dev/null and b/vintage/__pycache__/vhs_glitch_node.cpython-312.pyc differ diff --git a/vintage/__pycache__/vintage_tv_node.cpython-312.pyc b/vintage/__pycache__/vintage_tv_node.cpython-312.pyc new file mode 100644 index 0000000..40d3479 Binary files /dev/null and b/vintage/__pycache__/vintage_tv_node.cpython-312.pyc differ diff --git a/vintage/film_grain_node.py b/vintage/film_grain_node.py new file mode 100644 index 0000000..4906f4a --- /dev/null +++ b/vintage/film_grain_node.py @@ -0,0 +1,110 @@ +import numpy as np +import torch +import cv2 + +class FilmGrainNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "grain_intensity": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01}), + "grain_size": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 3.0, "step": 0.1}), + "film_type": (["35mm", "16mm", "8mm", "super8"], {"default": "35mm"}), + }, + "optional": { + "color_grain": ("BOOLEAN", {"default": True}), + "vintage_tone": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01}), + "vignette": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 1.0, "step": 0.01}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_film_grain" + CATEGORY = "Image Effects" + + def apply_film_grain(self, image, grain_intensity, grain_size, film_type, + color_grain=True, vintage_tone=0.2, vignette=0.1): + + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + result = img_np.copy().astype(np.float32) + + # Paramètres selon le type de film + film_params = { + "35mm": {"grain_scale": 1.0, "contrast": 1.1, "warmth": 0.05}, + "16mm": {"grain_scale": 1.5, "contrast": 1.2, "warmth": 0.1}, + "8mm": {"grain_scale": 2.0, "contrast": 1.3, "warmth": 0.15}, + "super8": {"grain_scale": 1.8, "contrast": 1.25, "warmth": 0.12} + } + + params = film_params[film_type] + + # 1. Générer le grain de base + if grain_intensity > 0: + # Créer le grain à une résolution réduite puis l'agrandir + grain_h = int(h / grain_size) + grain_w = int(w / grain_size) + + if color_grain: + # Grain coloré (différent pour chaque canal) + grain_r = np.random.normal(0, grain_intensity * params["grain_scale"], (grain_h, grain_w)) + grain_g = np.random.normal(0, grain_intensity * params["grain_scale"] * 0.8, (grain_h, grain_w)) + grain_b = np.random.normal(0, grain_intensity * params["grain_scale"] * 0.9, (grain_h, grain_w)) + + # Redimensionner le grain + grain_r = cv2.resize(grain_r, (w, h), interpolation=cv2.INTER_LINEAR) + grain_g = cv2.resize(grain_g, (w, h), interpolation=cv2.INTER_LINEAR) + grain_b = cv2.resize(grain_b, (w, h), interpolation=cv2.INTER_LINEAR) + + grain = np.stack([grain_r, grain_g, grain_b], axis=2) * 30 + else: + # Grain monochrome + grain_mono = np.random.normal(0, grain_intensity * params["grain_scale"], (grain_h, grain_w)) + grain_mono = cv2.resize(grain_mono, (w, h), interpolation=cv2.INTER_LINEAR) + grain = np.stack([grain_mono] * 3, axis=2) * 25 + + # Appliquer le grain + result = result + grain + + # 2. Ajustement du contraste selon le type de film + if params["contrast"] != 1.0: + result = np.clip((result - 127.5) * params["contrast"] + 127.5, 0, 255) + + # 3. Tonalité vintage + if vintage_tone > 0: + # Courbe de tonalité vintage (légèrement sépia) + sepia_matrix = np.array([ + [1 - vintage_tone * 0.3, vintage_tone * 0.2, vintage_tone * 0.1], + [vintage_tone * 0.1, 1 - vintage_tone * 0.1, vintage_tone * 0.1], + [vintage_tone * 0.05, vintage_tone * 0.1, 1 - vintage_tone * 0.2] + ]) + + result = result @ sepia_matrix.T + + # Ajouter de la chaleur + warmth = params["warmth"] * vintage_tone + result[:, :, 0] *= (1 + warmth) # Plus de rouge + result[:, :, 2] *= (1 - warmth * 0.5) # Moins de bleu + + # 4. Vignette + if vignette > 0: + center_x, center_y = w // 2, h // 2 + max_dist = np.sqrt(center_x**2 + center_y**2) + + y, x = np.ogrid[:h, :w] + distance = np.sqrt((x - center_x)**2 + (y - center_y)**2) + vignette_mask = 1 - (distance / max_dist) * vignette + vignette_mask = np.clip(vignette_mask, 0, 1) + + result = result * np.expand_dims(vignette_mask, axis=2) + + # Normalisation finale + result = np.clip(result, 0, 255) + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) diff --git a/vintage/light_leaks_node.py b/vintage/light_leaks_node.py new file mode 100644 index 0000000..6f5f315 --- /dev/null +++ b/vintage/light_leaks_node.py @@ -0,0 +1,115 @@ +import numpy as np +import torch +import cv2 + +class LightLeaksNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "leak_intensity": ("FLOAT", {"default": 0.4, "min": 0.0, "max": 1.0, "step": 0.01}), + "leak_color": (["warm", "cool", "rainbow", "vintage", "custom"], {"default": "warm"}), + "leak_count": ("INT", {"default": 2, "min": 1, "max": 5, "step": 1}), + }, + "optional": { + "leak_position": (["random", "corners", "edges", "center"], {"default": "random"}), + "blur_amount": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "custom_hue": ("FLOAT", {"default": 30.0, "min": 0.0, "max": 360.0, "step": 1.0}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_light_leaks" + CATEGORY = "Image Effects" + + def apply_light_leaks(self, image, leak_intensity, leak_color, leak_count, + leak_position="random", blur_amount=0.5, custom_hue=30.0): + + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + result = img_np.copy().astype(np.float32) + + # Couleurs prédéfinies pour les fuites de lumière + color_palettes = { + "warm": [(255, 200, 100), (255, 150, 80), (255, 180, 120)], + "cool": [(100, 150, 255), (120, 200, 255), (80, 180, 255)], + "rainbow": [(255, 100, 100), (100, 255, 100), (100, 100, 255), (255, 255, 100)], + "vintage": [(255, 220, 180), (255, 200, 150), (240, 180, 120)], + "custom": [(255, 200, 100)] # Sera modifié selon custom_hue + } + + if leak_color == "custom": + # Convertir la teinte personnalisée en RGB + hsv_color = np.array([[[custom_hue / 2, 255, 255]]], dtype=np.uint8) + rgb_color = cv2.cvtColor(hsv_color, cv2.COLOR_HSV2RGB)[0, 0] + color_palettes["custom"] = [tuple(rgb_color)] + + colors = color_palettes[leak_color] + + # Générer les fuites de lumière + for i in range(leak_count): + # Choisir une couleur + color = colors[i % len(colors)] + + # Déterminer la position + if leak_position == "corners": + positions = [(0, 0), (w-1, 0), (0, h-1), (w-1, h-1)] + pos_x, pos_y = positions[i % 4] + elif leak_position == "edges": + edge = i % 4 + if edge == 0: # Top + pos_x, pos_y = np.random.randint(0, w), 0 + elif edge == 1: # Right + pos_x, pos_y = w-1, np.random.randint(0, h) + elif edge == 2: # Bottom + pos_x, pos_y = np.random.randint(0, w), h-1 + else: # Left + pos_x, pos_y = 0, np.random.randint(0, h) + elif leak_position == "center": + pos_x = w // 2 + np.random.randint(-w//4, w//4) + pos_y = h // 2 + np.random.randint(-h//4, h//4) + else: # random + pos_x = np.random.randint(0, w) + pos_y = np.random.randint(0, h) + + # Créer le masque de fuite + leak_mask = self._create_leak_mask(h, w, pos_x, pos_y, leak_intensity) + + # Appliquer le flou au masque + if blur_amount > 0: + blur_size = int(blur_amount * 50) * 2 + 1 + leak_mask = cv2.GaussianBlur(leak_mask, (blur_size, blur_size), 0) + + # Appliquer la couleur + for ch in range(3): + color_layer = leak_mask * color[ch] * leak_intensity + result[:, :, ch] = np.clip(result[:, :, ch] + color_layer, 0, 255) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) + + def _create_leak_mask(self, h, w, center_x, center_y, intensity): + """Créer un masque de fuite de lumière organique""" + # Créer un dégradé radial de base + y, x = np.ogrid[:h, :w] + distance = np.sqrt((x - center_x)**2 + (y - center_y)**2) + max_distance = np.sqrt(w**2 + h**2) / 2 + + # Masque radial de base + mask = 1 - (distance / max_distance) + mask = np.clip(mask, 0, 1) + + # Ajouter de la variation organique avec du bruit + noise = np.random.random((h, w)) * 0.3 + mask = mask * (0.7 + noise) + + # Appliquer une courbe non-linéaire pour un effet plus naturel + mask = np.power(mask, 2) * intensity + + return np.clip(mask, 0, 1) diff --git a/vintage/polaroid_node.py b/vintage/polaroid_node.py new file mode 100644 index 0000000..e337cf3 --- /dev/null +++ b/vintage/polaroid_node.py @@ -0,0 +1,156 @@ +import numpy as np +import torch +import cv2 +from PIL import Image, ImageDraw, ImageFont + +class PolaroidNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "border_size": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 0.3, "step": 0.01}), + "vintage_tone": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01}), + "fade_amount": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01}), + }, + "optional": { + "add_text": ("BOOLEAN", {"default": False}), + "text_content": ("STRING", {"default": "Summer '85", "multiline": False}), + "paper_texture": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01}), + "color_shift": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 1.0, "step": 0.01}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_polaroid_effect" + CATEGORY = "Image Effects" + + def apply_polaroid_effect(self, image, border_size, vintage_tone, fade_amount, + add_text=False, text_content="Summer '85", paper_texture=0.3, color_shift=0.1): + + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + + # 1. Calculer les dimensions avec bordure + border_h = int(h * border_size) + border_w = int(w * border_size) + bottom_border = border_h * 3 # Bordure inférieure plus large (caractéristique Polaroid) + + new_h = h + border_h + bottom_border + new_w = w + border_w * 2 + + # 2. Créer l'image avec bordure blanche + polaroid = np.full((new_h, new_w, c), 245, dtype=np.uint8) # Blanc cassé + + # 3. Traitement de l'image principale + result = img_np.copy().astype(np.float32) + + # Effet vintage/sépia + if vintage_tone > 0: + sepia_matrix = np.array([ + [0.393, 0.769, 0.189], + [0.349, 0.686, 0.168], + [0.272, 0.534, 0.131] + ]) + sepia_img = result @ sepia_matrix.T + result = result * (1 - vintage_tone) + sepia_img * vintage_tone + + # Décoloration caractéristique des Polaroids + if fade_amount > 0: + # Réduire légèrement le contraste + result = (result - 127.5) * (1 - fade_amount * 0.3) + 127.5 + + # Ajouter une teinte jaunâtre + result[:, :, 0] *= (1 + fade_amount * 0.1) # Plus de rouge + result[:, :, 1] *= (1 + fade_amount * 0.05) # Légèrement plus de vert + result[:, :, 2] *= (1 - fade_amount * 0.1) # Moins de bleu + + # Décalage de couleur subtil + if color_shift > 0: + hsv = cv2.cvtColor(result.astype(np.uint8), cv2.COLOR_RGB2HSV).astype(np.float32) + hsv[:, :, 0] = (hsv[:, :, 0] + color_shift * 10) % 180 + result = cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2RGB).astype(np.float32) + + # Vignette douce + center_x, center_y = w // 2, h // 2 + max_dist = np.sqrt(center_x**2 + center_y**2) + y, x = np.ogrid[:h, :w] + distance = np.sqrt((x - center_x)**2 + (y - center_y)**2) + vignette = 1 - (distance / max_dist) * 0.2 + vignette = np.clip(vignette, 0.8, 1) + result = result * np.expand_dims(vignette, axis=2) + + result = np.clip(result, 0, 255) + + # 4. Placer l'image dans la bordure + polaroid[border_h:border_h+h, border_w:border_w+w] = result.astype(np.uint8) + + # 5. Ajouter de la texture papier + if paper_texture > 0: + texture = self._generate_paper_texture(new_h, new_w, paper_texture) + polaroid = polaroid.astype(np.float32) + polaroid += texture + polaroid = np.clip(polaroid, 0, 255) + + # 6. Ajouter du texte si demandé + if add_text and text_content: + polaroid = self._add_handwritten_text(polaroid, text_content, bottom_border) + + # 7. Légère rotation aléatoire pour un effet authentique + angle = np.random.uniform(-2, 2) + center = (new_w // 2, new_h // 2) + rotation_matrix = cv2.getRotationMatrix2D(center, angle, 1.0) + polaroid = cv2.warpAffine(polaroid.astype(np.uint8), rotation_matrix, (new_w, new_h), + borderMode=cv2.BORDER_CONSTANT, borderValue=(240, 240, 240)) + + result_tensor = torch.from_numpy(polaroid.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) + + def _generate_paper_texture(self, h, w, intensity): + """Générer une texture de papier photo""" + # Créer du bruit pour simuler la texture du papier + texture = np.random.normal(0, intensity * 10, (h, w)) + + # Ajouter des variations de grain plus grossières + coarse_texture = np.random.normal(0, intensity * 5, (h // 4, w // 4)) + coarse_texture = cv2.resize(coarse_texture, (w, h), interpolation=cv2.INTER_LINEAR) + + texture = texture + coarse_texture + texture = np.expand_dims(texture, axis=2) + texture = np.repeat(texture, 3, axis=2) + + return texture + + def _add_handwritten_text(self, image, text, bottom_border): + """Ajouter du texte dans la bordure inférieure""" + h, w = image.shape[:2] + + # Convertir en PIL pour le texte + pil_image = Image.fromarray(image.astype(np.uint8)) + draw = ImageDraw.Draw(pil_image) + + # Essayer de charger une police + try: + font_size = max(12, bottom_border // 4) + font = ImageFont.truetype("arial.ttf", font_size) + except: + font = ImageFont.load_default() + + # Position du texte (centré dans la bordure inférieure) + text_bbox = draw.textbbox((0, 0), text, font=font) + text_width = text_bbox[2] - text_bbox[0] + text_height = text_bbox[3] - text_bbox[1] + + x = (w - text_width) // 2 + y = h - bottom_border + (bottom_border - text_height) // 2 + + # Couleur du texte (gris foncé pour un effet authentique) + text_color = (80, 80, 80) + draw.text((x, y), text, fill=text_color, font=font) + + return np.array(pil_image) diff --git a/vintage/vhs_glitch_node.py b/vintage/vhs_glitch_node.py new file mode 100644 index 0000000..2e5149d --- /dev/null +++ b/vintage/vhs_glitch_node.py @@ -0,0 +1,91 @@ +import numpy as np +import torch +import cv2 +from PIL import Image, ImageEnhance + +class VHSGlitchNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "glitch_intensity": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "color_shift": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01}), + "scan_lines": ("BOOLEAN", {"default": True}), + "noise_amount": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01}), + }, + "optional": { + "tracking_errors": ("BOOLEAN", {"default": True}), + "color_bleeding": ("FLOAT", {"default": 0.4, "min": 0.0, "max": 1.0, "step": 0.01}), + "frame_jitter": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 1.0, "step": 0.01}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_vhs_glitch" + CATEGORY = "Image Effects" + + def apply_vhs_glitch(self, image, glitch_intensity, color_shift, scan_lines, noise_amount, + tracking_errors=True, color_bleeding=0.4, frame_jitter=0.1): + + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + result = img_np.copy() + + # 1. Décalages horizontaux (tracking errors) + if tracking_errors and glitch_intensity > 0: + max_shift = int(glitch_intensity * 30) + for i in range(0, h, np.random.randint(3, 8)): + if np.random.random() < glitch_intensity: + shift = np.random.randint(-max_shift, max_shift) + end_row = min(i + np.random.randint(1, 5), h) + result[i:end_row] = np.roll(result[i:end_row], shift, axis=1) + + # 2. Saignement de couleur (color bleeding) + if color_bleeding > 0: + # Séparer les canaux RGB + r_channel = result[:, :, 0].astype(np.float32) + g_channel = result[:, :, 1].astype(np.float32) + b_channel = result[:, :, 2].astype(np.float32) + + # Appliquer un flou horizontal différent à chaque canal + blur_amount = int(color_bleeding * 5) + if blur_amount > 0: + kernel = np.ones((1, blur_amount)) / blur_amount + r_channel = cv2.filter2D(r_channel, -1, kernel) + b_channel = cv2.filter2D(b_channel, -1, kernel * 0.8) + + result = np.stack([r_channel, g_channel, b_channel], axis=2).astype(np.uint8) + + # 3. Décalage de couleur chromatique + if color_shift > 0: + hsv = cv2.cvtColor(result, cv2.COLOR_RGB2HSV).astype(np.float32) + hsv[:, :, 0] = (hsv[:, :, 0] + color_shift * 180) % 180 + hsv[:, :, 1] = np.clip(hsv[:, :, 1] * (1 + color_shift * 0.3), 0, 255) + result = cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2RGB) + + # 4. Lignes de balayage (scan lines) + if scan_lines: + for i in range(1, h, 2): + result[i] = (result[i] * 0.8).astype(np.uint8) + + # 5. Bruit VHS + if noise_amount > 0: + noise = np.random.randint(0, int(noise_amount * 50), (h, w, c)) + result = np.clip(result.astype(np.int16) + noise - noise_amount * 25, 0, 255).astype(np.uint8) + + # 6. Tremblement de l'image (frame jitter) + if frame_jitter > 0: + jitter_x = int(np.random.uniform(-frame_jitter * 5, frame_jitter * 5)) + jitter_y = int(np.random.uniform(-frame_jitter * 3, frame_jitter * 3)) + M = np.float32([[1, 0, jitter_x], [0, 1, jitter_y]]) + result = cv2.warpAffine(result, M, (w, h), borderMode=cv2.BORDER_REFLECT) + + # Conversion finale + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) diff --git a/vintage/vintage_tv_node.py b/vintage/vintage_tv_node.py new file mode 100644 index 0000000..2e0e59f --- /dev/null +++ b/vintage/vintage_tv_node.py @@ -0,0 +1,125 @@ +import numpy as np +import torch +import cv2 + +class VintageTVNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "tv_type": (["crt_color", "crt_bw", "old_tv", "security_monitor"], {"default": "crt_color"}), + "scan_lines": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "curvature": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01}), + }, + "optional": { + "static_noise": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 1.0, "step": 0.01}), + "phosphor_glow": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01}), + "brightness": ("FLOAT", {"default": 0.9, "min": 0.5, "max": 1.5, "step": 0.01}), + "contrast": ("FLOAT", {"default": 1.1, "min": 0.5, "max": 2.0, "step": 0.01}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_vintage_tv" + CATEGORY = "Image Effects" + + def apply_vintage_tv(self, image, tv_type, scan_lines, curvature, + static_noise=0.1, phosphor_glow=0.2, brightness=0.9, contrast=1.1): + + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + result = img_np.copy().astype(np.float32) + + # 1. Appliquer la courbure de l'écran CRT + if curvature > 0: + result = self._apply_crt_curvature(result, curvature) + + # 2. Ajustements selon le type de TV + if tv_type == "crt_bw": + # Convertir en noir et blanc avec une légère teinte verte + gray = cv2.cvtColor(result.astype(np.uint8), cv2.COLOR_RGB2GRAY) + result = np.stack([gray * 0.9, gray, gray * 0.9], axis=2).astype(np.float32) + elif tv_type == "security_monitor": + # Effet moniteur de sécurité (vert monochrome) + gray = cv2.cvtColor(result.astype(np.uint8), cv2.COLOR_RGB2GRAY) + result = np.stack([gray * 0.3, gray, gray * 0.3], axis=2).astype(np.float32) + elif tv_type == "old_tv": + # TV ancienne avec saturation réduite + hsv = cv2.cvtColor(result.astype(np.uint8), cv2.COLOR_RGB2HSV).astype(np.float32) + hsv[:, :, 1] *= 0.7 # Réduire la saturation + result = cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2RGB).astype(np.float32) + + # 3. Lignes de balayage + if scan_lines > 0: + for i in range(0, h, 2): + result[i] *= (1 - scan_lines * 0.3) + + # Ajouter des lignes horizontales plus prononcées + for i in range(0, h, 4): + if i < h: + result[i] *= (1 - scan_lines * 0.5) + + # 4. Bruit statique + if static_noise > 0: + noise = np.random.random((h, w, c)) * static_noise * 100 + salt_pepper = np.random.random((h, w, c)) < static_noise * 0.01 + result[salt_pepper] = np.random.choice([0, 255], size=np.sum(salt_pepper)) + result = np.clip(result + noise - static_noise * 50, 0, 255) + + # 5. Lueur phosphore + if phosphor_glow > 0: + # Créer un effet de lueur en dupliquant et floutant l'image + glow = cv2.GaussianBlur(result.astype(np.uint8), (15, 15), 0).astype(np.float32) + result = result * (1 - phosphor_glow * 0.3) + glow * phosphor_glow * 0.3 + + # 6. Ajustements de luminosité et contraste + result = np.clip((result - 127.5) * contrast + 127.5, 0, 255) + result = np.clip(result * brightness, 0, 255) + + # 7. Vignette CRT + center_x, center_y = w // 2, h // 2 + max_dist = np.sqrt(center_x**2 + center_y**2) + y, x = np.ogrid[:h, :w] + distance = np.sqrt((x - center_x)**2 + (y - center_y)**2) + vignette = 1 - (distance / max_dist) * 0.3 + vignette = np.clip(vignette, 0.7, 1) + result = result * np.expand_dims(vignette, axis=2) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) + + def _apply_crt_curvature(self, image, strength): + """Appliquer la courbure caractéristique des écrans CRT""" + h, w = image.shape[:2] + + # Créer la grille de déformation + map_x = np.zeros((h, w), dtype=np.float32) + map_y = np.zeros((h, w), dtype=np.float32) + + center_x, center_y = w / 2, h / 2 + + for y in range(h): + for x in range(w): + # Normaliser les coordonnées + norm_x = (x - center_x) / center_x + norm_y = (y - center_y) / center_y + + # Appliquer la déformation barrel + r2 = norm_x * norm_x + norm_y * norm_y + distortion = 1 + strength * 0.1 * r2 + + new_x = center_x + norm_x * center_x * distortion + new_y = center_y + norm_y * center_y * distortion + + map_x[y, x] = np.clip(new_x, 0, w - 1) + map_y[y, x] = np.clip(new_y, 0, h - 1) + + # Appliquer la déformation + result = cv2.remap(image.astype(np.uint8), map_x, map_y, cv2.INTER_LINEAR) + return result.astype(np.float32) diff --git a/web/css/css_filters.css b/web/css/css_filters.css new file mode 100644 index 0000000..0eabaab --- /dev/null +++ b/web/css/css_filters.css @@ -0,0 +1,23 @@ +.css-filters-node { + border: 2px solid #4CAF50 !important; + border-radius: 8px !important; + box-shadow: 0 4px 8px rgba(76, 175, 80, 0.3) !important; +} + +.css-filters-node .litegraph-title { + background: linear-gradient(45deg, #2a4d3a, #4CAF50) !important; + color: white !important; + font-weight: bold !important; +} + +/* Styles pour les sliders */ +.css-filters-node input[type="range"] { + background: #4CAF50 !important; + border-radius: 5px !important; +} + +.css-filters-node input[type="range"]::-webkit-slider-thumb { + background: #ffffff !important; + border: 2px solid #4CAF50 !important; + border-radius: 50% !important; +} diff --git a/web/js/css_filters.js b/web/js/css_filters.js new file mode 100644 index 0000000..ea443a8 --- /dev/null +++ b/web/js/css_filters.js @@ -0,0 +1,45 @@ +import { app } from "../../scripts/app.js"; + +app.registerExtension({ + name: "ImageEffects.CSSFilters", + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name === "CSSFiltersNode") { + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + onNodeCreated?.apply(this, arguments); + + // Ajouter des styles CSS personnalisés au nœud + this.addProperty("css_style", "filter-panel", "string"); + + // Personnaliser l'apparence + this.color = "#2a4d3a"; + this.bgcolor = "#1a2d2a"; + this.title_text_color = "#ffffff"; + + // Ajouter une classe CSS personnalisée + if (this.domElement) { + this.domElement.classList.add("css-filters-node"); + } + }; + + // Personnaliser l'affichage des widgets + const onDrawForeground = nodeType.prototype.onDrawForeground; + nodeType.prototype.onDrawForeground = function (ctx) { + onDrawForeground?.apply(this, arguments); + + // Dessiner un indicateur visuel des filtres actifs + const activeFilters = this.widgets.filter(w => + w.value !== w.options?.default && w.value !== 0 && w.value !== 100 + ).length; + + if (activeFilters > 0) { + ctx.fillStyle = "#4CAF50"; + ctx.fillRect(this.size[0] - 20, 5, 15, 15); + ctx.fillStyle = "#ffffff"; + ctx.font = "10px Arial"; + ctx.fillText(activeFilters.toString(), this.size[0] - 17, 15); + } + }; + } + } +});