Initial release - 32 image effect nodes
Complete package with Core, Creative, Vintage, Deformation, Light Effects, and Geometric categories
This commit is contained in:
@@ -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
@@ -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.
@@ -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.
Binary file not shown.
@@ -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,)
|
||||
@@ -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,)
|
||||
@@ -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,)
|
||||
@@ -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,)
|
||||
@@ -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
|
||||
@@ -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,)
|
||||
@@ -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,)
|
||||
@@ -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.
@@ -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()
|
||||
@@ -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
|
||||
@@ -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,)
|
||||
@@ -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)
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
@@ -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,)
|
||||
@@ -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
|
||||
@@ -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,)
|
||||
@@ -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,)
|
||||
@@ -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,)
|
||||
@@ -0,0 +1 @@
|
||||
"""Effets géométriques et patterns"""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Effets de lumière et d'éclairage"""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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.
@@ -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,)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,)
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user