Initial release - 32 image effect nodes

Complete package with Core, Creative, Vintage, Deformation, Light Effects, and Geometric categories
This commit is contained in:
Bouletto
2025-05-28 00:18:09 +02:00
parent c5391c089c
commit 4e5b534b04
94 changed files with 4488 additions and 2 deletions
+7 -2
View File
@@ -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` :
+146
View File
@@ -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")
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
"""Effets d'image de base - ajustements fondamentaux"""
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+80
View File
@@ -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,)
+78
View File
@@ -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,)
+145
View File
@@ -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,)
+74
View File
@@ -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,)
+322
View File
@@ -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
+73
View File
@@ -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,)
+73
View File
@@ -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,)
+1
View File
@@ -0,0 +1 @@
"""Effets d'image de base - ajustements fondamentaux"""
Binary file not shown.
Binary file not shown.
Binary file not shown.
+190
View File
@@ -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()
+165
View File
@@ -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
+100
View File
@@ -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,)
+356
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""Effets de déformation d'image"""
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+72
View File
@@ -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,)
+102
View File
@@ -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
+90
View File
@@ -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,)
+84
View File
@@ -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,)
+99
View File
@@ -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,)
+1
View File
@@ -0,0 +1 @@
"""Effets géométriques et patterns"""
Binary file not shown.
Binary file not shown.
Binary file not shown.
+250
View File
@@ -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)
+136
View File
@@ -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)
+136
View File
@@ -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)
+168
View File
@@ -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
+181
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Effets de lumière et d'éclairage"""
Binary file not shown.
+114
View File
@@ -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)
+131
View File
@@ -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
+158
View File
@@ -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)
+159
View File
@@ -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
+106
View File
@@ -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
+22
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
"""Effets rétro et vintage"""
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+110
View File
@@ -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,)
+115
View File
@@ -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)
+156
View File
@@ -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)
+91
View File
@@ -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,)
+125
View File
@@ -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)
+23
View File
@@ -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;
}
+45
View File
@@ -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);
}
};
}
}
});