From 4e5b534b0472543cf615345a04f3b661d499c7f3 Mon Sep 17 00:00:00 2001 From: Bouletto <118677264+Bouletto@users.noreply.github.com> Date: Wed, 28 May 2025 00:18:09 +0200 Subject: [PATCH] Initial release - 32 image effect nodes Complete package with Core, Creative, Vintage, Deformation, Light Effects, and Geometric categories --- README.md | 9 +- __init__.py | 146 +++++++ __pycache__/__init__.cpython-312.pyc | Bin 0 -> 4337 bytes __pycache__/ascii_art_node.cpython-312.pyc | Bin 0 -> 7816 bytes __pycache__/ascii_text_node.cpython-312.pyc | Bin 0 -> 6452 bytes .../channel_mixer_node.cpython-312.pyc | Bin 0 -> 3482 bytes .../color_balance_node.cpython-312.pyc | Bin 0 -> 4238 bytes __pycache__/css_filters_node.cpython-312.pyc | Bin 0 -> 4567 bytes __pycache__/curves_node.cpython-312.pyc | Bin 0 -> 5858 bytes __pycache__/kaleidoscope_node.cpython-312.pyc | Bin 0 -> 16658 bytes __pycache__/levels_node.cpython-312.pyc | Bin 0 -> 2468 bytes __pycache__/psd_saver_node.cpython-312.pyc | Bin 0 -> 7160 bytes __pycache__/saver_plus_node.cpython-312.pyc | Bin 0 -> 14200 bytes .../shadow_highlight_node.cpython-312.pyc | Bin 0 -> 3168 bytes __pycache__/text_display_node.cpython-312.pyc | Bin 0 -> 10771 bytes .../text_processor_node.cpython-312.pyc | Bin 0 -> 12085 bytes __pycache__/vibrance_node.cpython-312.pyc | Bin 0 -> 3534 bytes core/__init__.py | 1 + core/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 228 bytes .../channel_mixer_node.cpython-312.pyc | Bin 0 -> 3487 bytes .../color_balance_node.cpython-312.pyc | Bin 0 -> 4243 bytes core/__pycache__/curves_node.cpython-312.pyc | Bin 0 -> 5863 bytes core/__pycache__/levels_node.cpython-312.pyc | Bin 0 -> 2473 bytes .../saver_plus_node.cpython-312.pyc | Bin 0 -> 14205 bytes .../shadow_highlight_node.cpython-312.pyc | Bin 0 -> 3173 bytes .../__pycache__/vibrance_node.cpython-312.pyc | Bin 0 -> 3539 bytes core/channel_mixer_node.py | 80 ++++ core/color_balance_node.py | 78 ++++ core/curves_node.py | 145 +++++++ core/levels_node.py | 74 ++++ core/saver_plus_node.py | 322 ++++++++++++++++ core/shadow_highlight_node.py | 73 ++++ core/vibrance_node.py | 73 ++++ creative/__init__.py | 1 + creative/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 232 bytes .../ascii_art_node.cpython-312.pyc | Bin 0 -> 7825 bytes .../ascii_text_node.cpython-312.pyc | Bin 0 -> 6461 bytes .../css_filters_node.cpython-312.pyc | Bin 0 -> 4576 bytes .../kaleidoscope_node.cpython-312.pyc | Bin 0 -> 16667 bytes creative/ascii_art_node.py | 190 ++++++++++ creative/ascii_text_node.py | 165 ++++++++ creative/css_filters_node.py | 100 +++++ creative/kaleidoscope_node.py | 356 ++++++++++++++++++ deformation/__init__.py | 1 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 219 bytes .../barrel_distortion_node.cpython-312.pyc | Bin 0 -> 3090 bytes .../__pycache__/fisheye_node.cpython-312.pyc | Bin 0 -> 4561 bytes .../__pycache__/pinch_node.cpython-312.pyc | Bin 0 -> 3635 bytes .../__pycache__/ripple_node.cpython-312.pyc | Bin 0 -> 4006 bytes .../__pycache__/spherize_node.cpython-312.pyc | Bin 0 -> 4438 bytes deformation/barrel_distortion_node.py | 72 ++++ deformation/fisheye_node.py | 102 +++++ deformation/pinch_node.py | 90 +++++ deformation/ripple_node.py | 84 +++++ deformation/spherize_node.py | 99 +++++ geometric/__init__.py | 1 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 220 bytes .../crystallize_node.cpython-312.pyc | Bin 0 -> 13416 bytes .../hexagonal_pixelate_node.cpython-312.pyc | Bin 0 -> 6641 bytes .../__pycache__/polygon_node.cpython-312.pyc | Bin 0 -> 6317 bytes .../triangulate_node.cpython-312.pyc | Bin 0 -> 8466 bytes .../__pycache__/voronoi_node.cpython-312.pyc | Bin 0 -> 9135 bytes geometric/crystallize_node.py | 250 ++++++++++++ geometric/hexagonal_pixelate_node.py | 136 +++++++ geometric/polygon_node.py | 136 +++++++ geometric/triangulate_node.py | 168 +++++++++ geometric/voronoi_node.py | 181 +++++++++ light_effects/__init__.py | 1 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 225 bytes .../__pycache__/aurora_node.cpython-312.pyc | Bin 0 -> 5061 bytes .../__pycache__/god_rays_node.cpython-312.pyc | Bin 0 -> 5929 bytes .../holographic_node.cpython-312.pyc | Bin 0 -> 7191 bytes .../lens_flare_node.cpython-312.pyc | Bin 0 -> 10204 bytes .../neon_glow_node.cpython-312.pyc | Bin 0 -> 4455 bytes light_effects/aurora_node.py | 114 ++++++ light_effects/god_rays_node.py | 131 +++++++ light_effects/holographic_node.py | 158 ++++++++ light_effects/lens_flare_node.py | 159 ++++++++ light_effects/neon_glow_node.py | 106 ++++++ requirements.txt | 22 ++ vintage/__init__.py | 1 + vintage/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 209 bytes .../film_grain_node.cpython-312.pyc | Bin 0 -> 5271 bytes .../light_leaks_node.cpython-312.pyc | Bin 0 -> 5822 bytes .../__pycache__/polaroid_node.cpython-312.pyc | Bin 0 -> 7889 bytes .../vhs_glitch_node.cpython-312.pyc | Bin 0 -> 5610 bytes .../vintage_tv_node.cpython-312.pyc | Bin 0 -> 7198 bytes vintage/film_grain_node.py | 110 ++++++ vintage/light_leaks_node.py | 115 ++++++ vintage/polaroid_node.py | 156 ++++++++ vintage/vhs_glitch_node.py | 91 +++++ vintage/vintage_tv_node.py | 125 ++++++ web/css/css_filters.css | 23 ++ web/js/css_filters.js | 45 +++ 94 files changed, 4488 insertions(+), 2 deletions(-) create mode 100644 __init__.py create mode 100644 __pycache__/__init__.cpython-312.pyc create mode 100644 __pycache__/ascii_art_node.cpython-312.pyc create mode 100644 __pycache__/ascii_text_node.cpython-312.pyc create mode 100644 __pycache__/channel_mixer_node.cpython-312.pyc create mode 100644 __pycache__/color_balance_node.cpython-312.pyc create mode 100644 __pycache__/css_filters_node.cpython-312.pyc create mode 100644 __pycache__/curves_node.cpython-312.pyc create mode 100644 __pycache__/kaleidoscope_node.cpython-312.pyc create mode 100644 __pycache__/levels_node.cpython-312.pyc create mode 100644 __pycache__/psd_saver_node.cpython-312.pyc create mode 100644 __pycache__/saver_plus_node.cpython-312.pyc create mode 100644 __pycache__/shadow_highlight_node.cpython-312.pyc create mode 100644 __pycache__/text_display_node.cpython-312.pyc create mode 100644 __pycache__/text_processor_node.cpython-312.pyc create mode 100644 __pycache__/vibrance_node.cpython-312.pyc create mode 100644 core/__init__.py create mode 100644 core/__pycache__/__init__.cpython-312.pyc create mode 100644 core/__pycache__/channel_mixer_node.cpython-312.pyc create mode 100644 core/__pycache__/color_balance_node.cpython-312.pyc create mode 100644 core/__pycache__/curves_node.cpython-312.pyc create mode 100644 core/__pycache__/levels_node.cpython-312.pyc create mode 100644 core/__pycache__/saver_plus_node.cpython-312.pyc create mode 100644 core/__pycache__/shadow_highlight_node.cpython-312.pyc create mode 100644 core/__pycache__/vibrance_node.cpython-312.pyc create mode 100644 core/channel_mixer_node.py create mode 100644 core/color_balance_node.py create mode 100644 core/curves_node.py create mode 100644 core/levels_node.py create mode 100644 core/saver_plus_node.py create mode 100644 core/shadow_highlight_node.py create mode 100644 core/vibrance_node.py create mode 100644 creative/__init__.py create mode 100644 creative/__pycache__/__init__.cpython-312.pyc create mode 100644 creative/__pycache__/ascii_art_node.cpython-312.pyc create mode 100644 creative/__pycache__/ascii_text_node.cpython-312.pyc create mode 100644 creative/__pycache__/css_filters_node.cpython-312.pyc create mode 100644 creative/__pycache__/kaleidoscope_node.cpython-312.pyc create mode 100644 creative/ascii_art_node.py create mode 100644 creative/ascii_text_node.py create mode 100644 creative/css_filters_node.py create mode 100644 creative/kaleidoscope_node.py create mode 100644 deformation/__init__.py create mode 100644 deformation/__pycache__/__init__.cpython-312.pyc create mode 100644 deformation/__pycache__/barrel_distortion_node.cpython-312.pyc create mode 100644 deformation/__pycache__/fisheye_node.cpython-312.pyc create mode 100644 deformation/__pycache__/pinch_node.cpython-312.pyc create mode 100644 deformation/__pycache__/ripple_node.cpython-312.pyc create mode 100644 deformation/__pycache__/spherize_node.cpython-312.pyc create mode 100644 deformation/barrel_distortion_node.py create mode 100644 deformation/fisheye_node.py create mode 100644 deformation/pinch_node.py create mode 100644 deformation/ripple_node.py create mode 100644 deformation/spherize_node.py create mode 100644 geometric/__init__.py create mode 100644 geometric/__pycache__/__init__.cpython-312.pyc create mode 100644 geometric/__pycache__/crystallize_node.cpython-312.pyc create mode 100644 geometric/__pycache__/hexagonal_pixelate_node.cpython-312.pyc create mode 100644 geometric/__pycache__/polygon_node.cpython-312.pyc create mode 100644 geometric/__pycache__/triangulate_node.cpython-312.pyc create mode 100644 geometric/__pycache__/voronoi_node.cpython-312.pyc create mode 100644 geometric/crystallize_node.py create mode 100644 geometric/hexagonal_pixelate_node.py create mode 100644 geometric/polygon_node.py create mode 100644 geometric/triangulate_node.py create mode 100644 geometric/voronoi_node.py create mode 100644 light_effects/__init__.py create mode 100644 light_effects/__pycache__/__init__.cpython-312.pyc create mode 100644 light_effects/__pycache__/aurora_node.cpython-312.pyc create mode 100644 light_effects/__pycache__/god_rays_node.cpython-312.pyc create mode 100644 light_effects/__pycache__/holographic_node.cpython-312.pyc create mode 100644 light_effects/__pycache__/lens_flare_node.cpython-312.pyc create mode 100644 light_effects/__pycache__/neon_glow_node.cpython-312.pyc create mode 100644 light_effects/aurora_node.py create mode 100644 light_effects/god_rays_node.py create mode 100644 light_effects/holographic_node.py create mode 100644 light_effects/lens_flare_node.py create mode 100644 light_effects/neon_glow_node.py create mode 100644 requirements.txt create mode 100644 vintage/__init__.py create mode 100644 vintage/__pycache__/__init__.cpython-312.pyc create mode 100644 vintage/__pycache__/film_grain_node.cpython-312.pyc create mode 100644 vintage/__pycache__/light_leaks_node.cpython-312.pyc create mode 100644 vintage/__pycache__/polaroid_node.cpython-312.pyc create mode 100644 vintage/__pycache__/vhs_glitch_node.cpython-312.pyc create mode 100644 vintage/__pycache__/vintage_tv_node.cpython-312.pyc create mode 100644 vintage/film_grain_node.py create mode 100644 vintage/light_leaks_node.py create mode 100644 vintage/polaroid_node.py create mode 100644 vintage/vhs_glitch_node.py create mode 100644 vintage/vintage_tv_node.py create mode 100644 web/css/css_filters.css create mode 100644 web/js/css_filters.js diff --git a/README.md b/README.md index 13d4f4d..01ba607 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,7 @@ -# ComfyUI-Image-Effects -Complete collection of image effects for ComfyUI - 32 nodes across 6 categories +# Image Effects - Collection d'effets d'image pour ComfyUI + +Collection complète de nœuds d'effets d'image pour ComfyUI, organisée en 7 catégories. + +## 📦 Installation + +1. Clonez ce repository dans votre dossier `custom_nodes` : diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..da69857 --- /dev/null +++ b/__init__.py @@ -0,0 +1,146 @@ +""" +Image Effects - Collection d'effets d'image pour ComfyUI +Version: 1.0.0 +""" + +# Imports des modules core +from .core.channel_mixer_node import ChannelMixerNode +from .core.color_balance_node import ColorBalanceNode +from .core.curves_node import CurvesNode +from .core.levels_node import LevelsNode +from .core.saver_plus_node import SaverPlusNode +from .core.shadow_highlight_node import ShadowHighlightNode +from .core.vibrance_node import VibranceNode + +# Imports des modules creative +from .creative.ascii_art_node import AsciiArtNode +from .creative.ascii_text_node import AsciiTextNode +from .creative.css_filters_node import CSSFiltersNode +from .creative.kaleidoscope_node import KaleidoscopeNode, KaleidoscopeAdvancedNode + +# Imports des modules vintage +from .vintage.vhs_glitch_node import VHSGlitchNode +from .vintage.film_grain_node import FilmGrainNode +from .vintage.light_leaks_node import LightLeaksNode +from .vintage.vintage_tv_node import VintageTVNode +from .vintage.polaroid_node import PolaroidNode + +# Imports des modules deformation +from .deformation.fisheye_node import FisheyeNode +from .deformation.barrel_distortion_node import BarrelDistortionNode +from .deformation.ripple_node import RippleNode +from .deformation.spherize_node import SpherizeNode +from .deformation.pinch_node import PinchNode + +# Imports des modules light effects +from .light_effects.lens_flare_node import LensFlareNode +from .light_effects.god_rays_node import GodRaysNode +from .light_effects.neon_glow_node import NeonGlowNode +from .light_effects.holographic_node import HolographicNode +from .light_effects.aurora_node import AuroraNode + +# Imports des modules geometric +from .geometric.triangulate_node import TriangulateNode +from .geometric.voronoi_node import VoronoiNode +from .geometric.hexagonal_pixelate_node import HexagonalPixelateNode +from .geometric.crystallize_node import CrystallizeNode +from .geometric.polygon_node import PolygonNode + +NODE_CLASS_MAPPINGS = { + # Core Effects + "ChannelMixerNode": ChannelMixerNode, + "ColorBalanceNode": ColorBalanceNode, + "CurvesNode": CurvesNode, + "LevelsNode": LevelsNode, + "SaverPlusNode": SaverPlusNode, + "ShadowHighlightNode": ShadowHighlightNode, + "VibranceNode": VibranceNode, + + # Creative Effects + "AsciiArtNode": AsciiArtNode, + "AsciiTextNode": AsciiTextNode, + "CSSFiltersNode": CSSFiltersNode, + "KaleidoscopeNode": KaleidoscopeNode, + "KaleidoscopeAdvancedNode": KaleidoscopeAdvancedNode, + + # Vintage Effects + "VHSGlitchNode": VHSGlitchNode, + "FilmGrainNode": FilmGrainNode, + "LightLeaksNode": LightLeaksNode, + "VintageTVNode": VintageTVNode, + "PolaroidNode": PolaroidNode, + + # Deformation + "FisheyeNode": FisheyeNode, + "BarrelDistortionNode": BarrelDistortionNode, + "RippleNode": RippleNode, + "SpherizeNode": SpherizeNode, + "PinchNode": PinchNode, + + # Light effects + "LensFlareNode": LensFlareNode, + "GodRaysNode": GodRaysNode, + "NeonGlowNode": NeonGlowNode, + "HolographicNode": HolographicNode, + "AuroraNode": AuroraNode, + + # Geometric + "TriangulateNode": TriangulateNode, + "VoronoiNode": VoronoiNode, + "HexagonalPixelateNode": HexagonalPixelateNode, + "CrystallizeNode": CrystallizeNode, + "PolygonNode": PolygonNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + # Core Effects + "ChannelMixerNode": "🔀 Channel Mixer", + "ColorBalanceNode": "🎨 Color Balance", + "CurvesNode": "📈 RGB Curves", + "LevelsNode": "🎚️ Levels Adjustment", + "SaverPlusNode": "💾 Saver Plus", + "ShadowHighlightNode": "🌗 Shadow/Highlight", + "VibranceNode": "🌈 Vibrance & Saturation", + + # Creative Effects + "AsciiArtNode": "🎭 ASCII Art Generator", + "AsciiTextNode": "📝 ASCII Text Generator", + "CSSFiltersNode": "🎛️ CSS Filters", + "KaleidoscopeNode": "🔮 Kaleidoscope Effect", + "KaleidoscopeAdvancedNode": "✨ Kaleidoscope Advanced", + + # Vintage Effects + "VHSGlitchNode": "📼 VHS Glitch", + "FilmGrainNode": "🎞️ Film Grain", + "LightLeaksNode": "💡 Light Leaks", + "VintageTVNode": "📺 Vintage TV", + "PolaroidNode": "📷 Polaroid Effect", + + # Deformation + "FisheyeNode": "🐠 Fisheye", + "BarrelDistortionNode": "🍐 Barrel Distortion", + "RippleNode": "🌊 Ripple", + "SpherizeNode": "🔵 Spherize", + "PinchNode": "🤏 Pinch", + + # Light effects + "LensFlareNode": "💡 Lens Flare", + "GodRaysNode": "🌞 God Rays", + "NeonGlowNode": "🌟 Neon Glow", + "HolographicNode": "🌈 Holographic", + "AuroraNode": "🌌 Aurora", + + # Geometric + "TriangulateNode": "🔺 Triangulate", + "VoronoiNode": "📐 Voronoi", + "HexagonalPixelateNode": "⬡ Hexagonal Pixelate", + "CrystallizeNode": "❄️ Crystallize", + "PolygonNode": "🔷 Polygon" +} + +__version__ = "1.0.0" + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "__version__"] + +print(f"[Image Effects] Package v{__version__} loaded with {len(NODE_CLASS_MAPPINGS)} nodes") +print(f"[Image Effects] Core: 7 nodes, Creative: 5 nodes, Vintage: 5 nodes") diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9a222563bd53b0a4b7479fe81f0b09114dd67fde GIT binary patch literal 4337 zcmZ8k$xj^F8LtK!F#FPUv-1G6G)uGC7~6Pjz{YqmL)@6j)L@m${T_71uewlmH#VGn z$YB&YGiYpSq?z&Hi4w_CmRpo$6y+btrKK$Dl!THvr<_vBXf8SB``)X9#+Lol?_1td z-}{#KA8Ko>6!`O^??UQnNKyWYg8!${Bj3C!QIt;=s;CML&=MXnOVpA8@={)EmZ@ce zm+_!ku9gcPDCbdcM zYTj&y)R5pcyu}QwVKbsegj~y8%{H}7@H*aZcBmbK*Yi%ZOYIW8fp?odYLDQJoS408 zui#C*&+J$G1#jkO%>i{l@DLv~htwg#TllaURilE3`G`5HjtU;(W9GOzE_f@CnG@=S z;B9=;oKmL*Z|BqIIrW_29sImGqs|E4$uF3*>a5^h{GxeDy(D-yziiH_bAtEqE9O=8 zs^ElQGq0=H1B#*0URLrvK>Ju3U_UDdJj*Hp2Ur#0Agci!Vs(JStN}2}ngB;w2ym2z z0moPbaGbRP##jg71nUBvWIcdWtoM0<^|Jvs#G-7Jjk5_h#m=!AHp?!tId+v@r_*25 z|0JN^z}wJsu%?fm2kfUafM@9izyUf7I7lx74$(`1!}Ky>l+FQ;&?|tW^eW&Oy#_c= zuLH*D4ZsN+2b`qyfKzkapYl|hb z88S{5EY5)%R+>O`K@D0uWC*VvD`$g)xwUtHxoU&i8SuJHrec$^$*O**x(bX5yEdzWQ=X@%`F?n|`^eV5s5Jm)K#6Dgfqk8c~> zDGvX#-m01no|I+iQiq&Yt*+x`jmImcg@db+JW>)9{YYnL({g(%KPM zb6Sfvak`89Q}4jqjoXPOZe)`wUje(BOSW#LJw@FL-fxBJkNmsWZW!q-#CUzfbE#Rg zxNcj9ziIU?Ba>o#eqciLx@|MQXk@aMjql(stGZ|G>~Pu%POfZRqLk9M`kQ zTW(mkm9`AeqvbYxqHkMiov*>e7x~pM*n63*&iTn*t04e;;EPM`Qb-%S3?Yb6j!=P6 ziEsu18@64IP=ip5P=`>D(13uQ+ipT=28h<%(ApKd1tE+OL1;y2Lug0nKxK+}1sO@sxO(O+;#Y>W^My#aK4+vO9-&au1(>NbW7o6Pe>VJkve==(B(S z=3^ogoy6()a+$2j(phiCC%-2m>j|cP4sY%rKKqo&#Gg3M{2X2#EFTg-_sJkE%;szz zb0vr02uJ;b#1jk4%LHnHEU`2Lt7Uulc>dBeVqp|nF>5#74?+Emv;|;cmQvM6-7=;Du>wwQTzdsMU}&xf;avZ z@yjZQN#`E^_+ugp%YP)e9+ValwdE~(_9cPpB3NEI%u3kykYItq2V0N5?}GO#sxklg zXGB(+=k(%hQdDO-`904Mh*xRid*1x~D{@?Fa5q_NBB*a(eqTJ@NxgYnyb!U%irWv0 zW*secnpfXlys0g$#1jeaZhUQRd37n_vI{=T*vzkBnQ9R%ymYe6rYc)3UqLimlXX@fYuSb5Po_ z{Wz_ZZCSP{dRc5s*0D5W**ks3=7*xHHF`WKr6aaZCsOQai@_aY9dM8qp8~Irtw>K-sr7O5nngGIOL1i@y^`%G{AM z_HDTmm_Aa@f#q+?J(Pfg;=I5K&%Ah~fInEqQE^F_VKdlZ3KbWH8JuQ;xMgFJlE0UQ z^R2^f5U5+O1S2R^pirp<8xKa@GsvqXuSQ;@1VaZ8+*;&yO0YfO5p(NNXi$R92PL~INsUJl? zCQlnjKCOg$3Z0XMrp`i$6dK#%qr&}#=5CZCgP;aQ>kulsrcu${{onTjurRMQyPJWr zZW)j<2uQadkTD1d2pEujQ$Ium1dK?&Y22+w0Yj2+8HAXCAY(EOF#!Q%k`GNnP(Z+- zyeYV~QwKa^m;viH^KtFG7PZx`}65`Vn1o}as)zyCqLeDhDr RgTJEv&YzVP`wryy{{hlnR8{~0 literal 0 HcmV?d00001 diff --git a/__pycache__/ascii_art_node.cpython-312.pyc b/__pycache__/ascii_art_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6ef6697058f08b07c445270584ff068b88ebc9e8 GIT binary patch literal 7816 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 S?C^hU7F2lm3k*@I^Zx@4x?gDk literal 0 HcmV?d00001 diff --git a/__pycache__/ascii_text_node.cpython-312.pyc b/__pycache__/ascii_text_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8b105364ff08c4c2d09a45bbb316e82c8a72a676 GIT binary patch literal 6452 zcmaJlZEPDycDp2(Tz*KTM3I!JFY3dxDBH4QUt(K!EXk5=%T^@UlFznaho-nIiZn&i z%cU(by9YP*U70p$tjj^7YX@cy2OQO;r~({N^+yW>?uU!skKHg=5gQj!)dH>2KXQCP zd-tb(v&$tJMZE#`&6{~|-n^N8^WK~Jr>ZIw2IarvZ_Zt*$FP4x4YlZs$m{O`vVu_< zg-5VyT&^|K8mKi9?YwSUhhrM-EJkVXW0dYQjWTMQSj4@=OVk+m;u0}B?+?-eg{HtF zBldb6+AA0>i=NidS_;39eOBb0)=`>ig3?awDc!VzBBn`7KW#K(MvSshhWq%mi6ZY~ z(`L#D&_bC2RxO&nRZ`VpA`l8q(F^Q&jG_a0#mpq&TKHb40KmF%N<(QW9Ys)j%J5h> zs|jKh`BR)SQl=0-tD!(RWs$L}$2zK-vQjpxhN@L~i*0+UT}L@UHRof^w?>v_xE>Q! z{bS7}=50_m=AFT_Ba(J>eCp*z(C1|x0?!dZf5xkm3=}=9a4R9{1IYv%n`f#(8dD2EGMt{67#kTJ zKLf%E;0@A}*^hS1cQ-__a}tqY7bCQ!4@K|L3@Z_5#wG@*{sTs$GEhXe`-4h{Z6Nrk z4^jCmwTB##^pjJUM#sUl@mZuFw5()JCJ-;0p?z;}cXBU$p4@)Q5>qDqh_W+N0oijP8BmPy$!97KUSkDgOXrQl!|Fko85QtZkxt>ay1^{< z9Gt-hAn~TChQoOiX9^P)>_t>{3SqMXpCtx@^H=y>iZoRu=6QDMgp0yNpu9|$|YOI2r3YrdvpJ>cti7Op1TxU&zm z)o`|14Qo>=;U*O;*Ws2ju43vwlxDBxuyC77E!SKvN2t8KozpP{W#a59SQ)gia7UR_ z#W*czD2=G&?65zupA{BYr_M>4ksP#pyn}P>x`WlKRCNsR2x7dG-J|w%4wW*D-59~J z>tM~ii?63F;k_zPx#sGJv5y)85J3W(8(^h;16Rj2AnRDtd}!1`FW;cb@}^ z66jRbBZw`W`w{ku7AVfJw=7k~IJY_$$38r(!#I*bySh)Mmut?+8B2TQq^!H@>Qtri zjg(EcejR6nGf=~60a`yX@J(D3Ra>#MP2mIOIaKVkBBDw|`%PKVnoc;}b${;TYFEi%-H{;_`mp#k|h(mnd0bkf%vm1}BF`M?EO? z_jx3IAqo0mo~mhCN}S982_TAhX}d|1BoP1k4Z$a7bJ&Lv}C$OC;anq2$n&~ zFg7?oG(9mX)%fV>oIe_%A=WA(ShD$oG+XrcO0YH3poAO*BwaWLsfXSlkJC{~GW#mR zFeD31DDG{6oP>_dGRUtm$le(@0?45-?FgWQ%yc4vjuz7eKr)5qgFcpyCSnX^8-T+) zO)(}E3_OPdn++}L z^V@o?wrQnhsU=_2wo$WJtl68d@uu~K&Yqu-eL5zRZE4ek^I81g$*KpI%yecfhv#~7 zO>39ePplt!`kqid`4u_!YdvPG{pijIcOJ}UCbR9?H?r-SD@(C2_x9(l18E(Q>l-sC zay@G)v1d?hJS~h|5gV`MU02fihjT|hJ-^ZMme}#uFPOhv_+la7F_5

s1 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 Iu*{+V8(!y1q5uE@ literal 0 HcmV?d00001 diff --git a/__pycache__/css_filters_node.cpython-312.pyc b/__pycache__/css_filters_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..011b6315d2cc7e9989441998df9f0b581720df78 GIT binary patch literal 4567 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 literal 0 HcmV?d00001 diff --git a/__pycache__/levels_node.cpython-312.pyc b/__pycache__/levels_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..88d1c4f4688b3ef53924ca603a2556ecaf3f28ac GIT binary patch literal 2468 zcmaJ?O>7fK6rTOFH}*PlVt$gC*iJ)BOeli3Di!6Y351X;H58Jnn6%4wXYDxgUv}3G zN!B8jQq@$I3iLoyA%QrhLaNeRdgF-Hi=7;>h90O|sp4iDq)I*Y&Dt(5&`y+Z-`~9V zX6C);pFAEHg0+3~jfq`ugnp$=eepG7a~}}b5k{Cvq9|j3*(hs&xhTh?A%xjQgtI7x zjcA{0Nh6ZMn#wddhRhKM|C?C=$dth>=HQRV0(RWsV=VCebG#Fa4f_ALZ@S^Yt~kQ( z+YFYlC&9$n1cRGzuxC-YCGs6ZgyHx4hX#poV&qi+XuO`=?^U95G(NxO`=MXv7LSH` z;>21^$t6v~r4nfXOX#PT+O6nv$;gb)C_a{7-hzW$JLx+W>Rt* z9>^HCGnchkOjAu`JZV3jt=gNNCGO$K>9J9H^xWyevvb>?zU$rwxdVb3UjQtiiikRQ zmP5N%&))UjJ6nu?Tnfv@;Dr}RI=felwb$<&#nBH-J?DxY(Q1xZb=O-KdlG%F%0*=%xF zt{!U6GfW>TG*~^VDpI@JqSkh4jzjTZN9I3eYcZ4ccBtFJ_fAcZnW-(xv3XJ;iqBk;GGd~>=J zWP&Y&rj1icD@YtQiPM&dAQ5c|hnuM6QuA^2(Itr5VtHVQpwH(zz5gE3ak%*>o1DWFkw1Y-UE&iC|`Qb%MBJI_-JevMHA~rgNG$r-j>K zs%goXPH$Upq2Q%J53SSl>OKlv0EjD*ip!>!HZnR9t78LVF%yKDA&g2Kx@ORM>8s5# zLpC#VR?n!KVGtHFkZKAUE2cs`Rr8udgT@LyxAZO=Jp?efy}-x8gus0@sgRJ+k5Jx$g^?eBljWUqLLl zbza|lb?;?ofhp`SC}0JGuB%<&1PcyeB;OTn-R&nY^_8Sx*|TLySod_5dv@OHsqxNr zPq2}9ta}1BUzCEI4&-~KFi`e)-|oKs{tA1mx8(0DjKG}q`j^hHxAguT?7lg(JhLLL z&X;-z?#>s-E|i8Yly)ga6&GXijo`!;36k(`UE;p9sPV<+i(ss-4gN5-5gf3UJ9<|R zt?eoGohbQ-?(Hf0KPZemaiAT6n+KK;+&Z+{y4t_tf3q<1FxXc(ac%!KWvT7TTkFz} z%FB@3lijEzaKF8`)ZV+YZKHkP{q}>U_JeEtH`)&uJP*BH#g<@E4C+C++G+8DtSx#keE=g%gmZx({vZisXNyRWynmLiddI0tl>CE8h*qKQ6*Mx{C#v;c? zhfhU_c&vYPaOl*TbM`~)ez3IzRv+K)77BEt8oL1&QCaXlW<7#ylS7Wy3Z*Imc-w`M za!c33$#P3z;p7v3pAf3Lp_{{k4wD+`bie*80Cdi$uj#h)e+MMuq3Vf_=*Pg0wxvO9 btimx2^8mFyK+*%``ja1In9v`Hq8;ULUt(-! literal 0 HcmV?d00001 diff --git a/__pycache__/psd_saver_node.cpython-312.pyc b/__pycache__/psd_saver_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..de4507640d96499cc165798b8cdc0fc78eb392e8 GIT binary patch literal 7160 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 Je?bJq{|BflnF0U+ literal 0 HcmV?d00001 diff --git a/__pycache__/saver_plus_node.cpython-312.pyc b/__pycache__/saver_plus_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7186d7813200caf26c76ff7e31ec7a9c98a6c8a6 GIT binary patch literal 14200 zcmb_@d2kbHmS?8!%aSFZvSnMgv5kx|7;_h33>e!SHef@6fz}A6Y-D7~l__zk#>Rf&mzm#vzW00Yefo7-nSp|EMzQ7MSKBD+U+_Z?s@%)Vad=sv7>c0- z)C5hQiU|dIDkqfiR0LErstFa1d6WV5jAlYJqn*%_Z#tlx&?%@vicx()G3xsYsn!Yo zHQJ?liXS5`TGTO~urKVN@wpVDdU(b&RZuU#3*QTrk92B6 z;Zrj72h{ys&IuKxm{2py2@Ru~&@$=?9iy4h>nZ6UQ^{yQpeGEB?gMJV$moHZ7z5BU z#t78Rn1EWCGN9#*8K{-90JSmYKr0w4&`QPzwCY-gt4b^z^IZ0^#{v;qy1Z&(J^{I4Jn2@FaX9Vr!po;DZ>=V zXD0b9BwsmWWo%3Z{8ipjK{?E)A}RS2MRDa!6;sXBypo<Bx6?y<=ePcNAlks}7e& zJ$$r(a6r_Ijh`4E85A|4NO(39J_rk?WqgyKNFXfgXMp+x{-7^vlae=ZI3ey?n2`<3 zYEgL%N~*?(4;~a%hmH-v``ZKij)}^BM~|^4m>1Rp#HA87eq4G^)J^&WzMyBuC+e{x z_pB#;Q7n_Hc29=b8BaJ`dBk(g$M$rQ$0q5~D{A+Rjvg849~pOPMD2{{s(&UjBWkfT zxTmxscnNr}i8|8b*}yeX!+BYsFDM$g&}7&h@K0R~vv%l>s2m;{f9i&}r?{L?8PY@2 zTTwaV4+8OA6;)i=H~UlrX)Nx_r+Um~5RK3V3?E0piDrM$8;CGI_l%F7@-d=;Jl!*} zQ$+1M5l_G$z9yD=7{-kUfD3zOX2mix>=|F!0|&z+mO~%mfVjEHg~?EW@v)-S8=9GA zeH`Zx1>FJPWnVz7!fzi7(-metL2lN=`hs4pIT8-JL$gr1KZ@r|$NJuh_*v+Q4r4<> zPhgpXg;sh49EW3d{&C>I&U4Vfurm`R97I-TcfWtJ7rG~@EiB>YxZZ`*QKowdLACE{mcx(t?Nx{ZjpkmZ?G1tlEiYa4?m@%fmpX23omN1qfVOr+zas)hM z7KZ*TG4_T;sAy$#=v*x~9N}99B>@mwEN){b3Xps88t9~x*4qXyegu`nH+XVJ6Gd{K% zN{U8_ak^0`xXMJUTjqv&(Vz%nAEyxWGi8GEG{@(jRY5iU5mtpq!YBk4x60m()rJwQauq#szg z)H0byvlNjZyQMH_5O7?W-HXKtr(wtpaU$&(4gQ%aci0!?Lab&3xD0=$U~kt6ecQ z3Z}-zXtG^s>iNR7DQl{{eHvm`Ol^XxEis*{6WVuuY1;kFLN#u9UP+ni?syaJA6-iJ z{~(lw5!Lkyb-mg0>P2(Xl5W682l=+G+48o;95hpJjvp>==H290p=r|>rrvCOPpVUB z-yI*ieO@rNKG#qtd)DDxacmGA8&bM-_rnbt$3*;S)>M=0;oKLd^)Tit`$}cEP}!Y) zH$9qNzcYR4p&4eR>NFkKL#nlIv5&W`<8|xU?XX7P+)0L4ibDB8B+yUb1tgG?9SP@U z&@{rKRY8X4)5W#q@-TTBF{h5HSZz%45$F{$^+(hXl|eeDnA3!F+#mR@ZdJwbIh~g_ zg|rwQ)5sEMjEd2~ca#x4OZ(Z+mwl`=;BmrXhb59}n&h(b4aqNMY)4(pE7;EeFRQMM(PYJyxZ?TM+5(LX ze!d+1e3``s^Erc|8C%16ZLkN?nd`=0_27Lh*Cxbl;#HA$5#h$~6 z^BAEbD+V*VNV$O ziZ0&Nm9^BwEw4{3Zf`QN8{rVa#L}ezoqUAYJ&<0!EKnFj;TRpDZYjN>EP55^TLBI! zW6E!;kE3r^eshj_#mzPqHK&?W2BFqDRXBf$V=5U6#1xm{*s~jAs#36#QIsmfD5KDC zSagM43r&^Em#dq;3AHn-n4*pvgR#-@H>bQpxzy3NV<_NRr!VZh3_2AkNw8ly+%O#O z7_TSrP6V_ux=Yj?A=()M7bBo`s^JX#XW=mmsu(*6-C+*{ajB(!f)ZIY04i`xuv}CT z%`_(?QJ``-7r1wFo97^WzP5Q@!ykUg#r-9a>(mot^@`CU7#&|28?(K8R(cNzy$2qQ zebGCdu}=uS!^zDL$N$^JuO|NMxy3biJC{0t*v0n_#}5hii6{2@_#u#47TfL4?}rkG zN9N9~sd~lKAeb5wnjdq?oj-a%^^VZ7op0a4H}3qx^w!tPeCCX~6L>s`n01Rgc}qL5 zYyZOwD^+)v=1^7nan-&G)j!yPc#D+)gvn%|#^7av`hP$C@?J)h2(W6JQC4Oklx*a8yFLR=+U@^FVJ?TiG&P?~|(W(tSG z0JR7@;zXnLg*c0I>BKSuOHm(3W0q*iDbQ{(IiQv<+K^F8HW4|FKt7NmzHk^oCI?1{ zuqO-~%J1daKI~sVwp+x&M7dj$z! zh8!I7%j?wFI;ye#?zyFNAAM(~VT;hPCDX7i+tiV5>Bu&8K56gew+#yIgG<{Uj%NX? zwD$Z{*UqKs2dcbwzAoG3e6Cj3n?b#=v1jX>o;O=jH@4~x5+B6vCWrj~Y5|V?ZG5m4 z50v-SoFQgl=qQ}cQh?)|;e^bK=8Q4pbaC%;y<`-q@i59uumf3pOvl!Q^Xlw0n*3IU z7#Q+oC^BaXwtQ=DHKYEXf|04_dx}Y-d@|ZXEpJe7)%m`cg>ie6Fpa9ls!&q-1I`L! zSQi5^%;;lfz?btT+c`6|SW3}T6Q#Y#$PiOA1~N0JVy0a%wy;T7W6ghAm#?wtgIB%wyMs;viD9B~G zBvT%Rz5Iq9U5<0|hBPatO@S%t&1V5jDy7%jc1O!ppgFmf3M0QJVGYGL&Dp}pwn$)L zPGHAb!t3Oim`(0A?7=+3_%6+;=WOrUuFzMgtI9Lf71~u9K~6d5iCp$gc~}(tkzh{R zOYl%KeR9B9LqX2T&3?Y@^-uZ{3^+Xzr^HO3{wvh+6uJCU;5q=|PYmwsA9;#$PE^7> zL6RcfO{B)MlDz^61@iP?AuQ9PYR1D|iaG~a))xU`6arNOkVnAdiAgcXA#&=aTFrZNcQEGr)!wVh|K(rhx`e29Pz>4d>a1A7yXz+%h5n@SR zRuJ8TsP-;9*m7qhq8cy@SYb6DE_`j)Cn|z~aU!7GYzL@w(KG8~$x!u^Y-q;AvS1aj z0CPo{SOna@t7r;<4ixkn77;&KUoa2RIV7;n!LZSktql6EfL#N6C#q+mNicR}eiddF zwNQv8=?AWQeZ=Z4DgkzhI#iDkPZ8WDY7tI>5yw>_VXu?N~KGvbbZ|N$=c<$naW=BF&3F{+ci_o#_adrJdXg-wir>Zm6 z+iwhhC4c{n{#kQsU+OsgX@vG|>8eb1UwjZCA6ia&1ba_vea5~wZhl-@y)ZaGxNv0t zNWz}Hnw(D4>CUv{!SM(0q5Y-n?M&rq(s)h%N_Ce|-IY9_9^?mzWu|&EZiE_5_QiwH zo5Yo$#_q*ZmmhWPer#{NYhSV_wj?_~>00heTQc^c_#pyL9fHZR_;zyJC%cz--|tIr z64veEO^z>2ec5U!_D`jMo6eeQ+Wiow0A8_mZUyY zyD2^l6Ij*wq_*X;qw((G(%_1tOK@~0&tx3i9@`yvPTZYXnt+@Qn=RhmURbeB+*UG#z-@@?XZlpYZEb4~KZi$&Bq3Z#wnApD8iZAGums z?O$x`9c-t5*{&ZlD}TA6Zb+|us8_?gw-{O=MkBx)K@9+((iVL>RuISqfP>{$g)p9O z5~lN(6FH_}#U=npSBRJ2N&p2w}M!kD+`y) zvD0~xEXM%sLzcQKphtuN9E3JX82}JyQ_2=D`6G&&$tNKeDi#^m8wA&({li9 zzGXeW5f}luD76pEan z4k>{M6mftTF2iMn&zCt#^k0Qy>@_54!Wd!S#aGmdqq>~0SHcVIdzkP(lIuut`6SV= zmW)t;h7lhi`5uyggXG@=aaD@dQcXG2gWJPk4}1*!=a}dFNKl%v{|*Trc=j)l+(NP+ z$sQyqxY^rC=8^m*k`Iym6%rKF>|Z1K8zf6WTsBGkmU2*ZVy1OS5Qi4|AvBWaESho} zv?O{ZZ#Uxx3C!Mw0(ox^F^S&8D*hJ9kAQG^h*t?#_>^Sp5lD-+tkPI>o*-Fg$J+z{ zh!y>{Bwclst$M}UC0M(Xr&1Fc>s}D8wT+32CDTexmr&F7@rh4PFQ3lTY>k^xhSjbZ z8$hludIY00(f`QUCUo|p7;9Q;N;*=PGxj|<4t*tm|IG6bbh;(olWs{(3a;JhNX9+@ zK7iMSYjy3y)cn*!U_Ox8k*ZA@(rX{=;rE^3yT>xs<3xCVyI76>^!@A4Z z&QCThZ%FrL?1ziR>O^vSrF)Oiy(iPX_km65K0qYvK-S(GKlDUb6F1zl+^{Sf6Xylp zzSN~hx_z?nADtgf9L`j3B!a%m`PC}|^=!tmBipoYrKtzyX0y<=ITcvhIwWizdT154 z9_Aa5WF2c)9Ni#0L9{x0hyY!CH?$ObZq%Rv)uRB_%ldw?xIz(#-!Ibuzl-fj1-C9k z6PDWyHX?GUze(#arR&Sg5zWd)c2Q@}E?V3h=m-Y6#5dIj7Ev%!phXlclB8egx&UjI zvW@~rbe7bv$O(j+Fp75)O0hYxcNK(@`esuMbbm5tJ*FUgVKh-A)Tq9>9@4ZVZ4>C( zx=S!?;2bH`UJNO6Eu!r#rbbH$)CCm!s9^v|pf)IdLwxQXpLrpqzR5sqlE8x+q|(rb z5X4FvK3o`(Yl7!1wLrc52ewE2$g}(ra=C~_ zOu!QPQ4ep(fgwEUV-XXu4M-Y+xO9>=I*t)HkbEBrN5h3(@&#Vog$+sp*#;T5o$V$Vrx)sir!XXv8(e}6RLzKx#r%*RK>ku%9CzRzw==8 z&#(Tx`(ZzSZ0uKq!p?DF>v*Q-q*O!nk=2!zYLFtXKC*UXYibw!6RM>l@R*G)*9i4J zLQRj99LreOWozmd$L6mUwU}*efkvB?-sO&amr~g^255H6^YLC+Ms%H&UW%Ji-sB+t_D>ts(4lf>0 z&>2e$uWR|ki*;1hPU3U;yS9NEl@~ZbJ|NU5+;l6v6kj)FGZG%YYH= z>e^_>=mk*!{b0lB$m`0*ZZ~fTOdMV?aw}(pQ=%&DpPUqx)3fk;#dl#=R9=9)X?T9{ zRvBpiV8I@RFUi_%$k)hm*weg#N6->&UbO>XH%OqRhQsX*+B5j|?TPkOTeh+;u>~y5 z)rPnMY|Q0#x8A$)-r`L1WX96V>v|<4Gq8<($WPPnA!>p017pQWAZ)cdrY<>2DqvuW zJ4J4)m>LC2&InxdhEm|rAPx+rjFSP8OXW1AwYoQ#BaHqWuxs6&*Go%G`3-$#4A82v zL|;v$OGwxhfqoeTaOt%EO-%7x`jv# zWaF^l>tJG8#A2dxmJLmjtBT&#wpWjwRh!p*?aMM#76^APgTMU8(BM}C_=1E^|(t@kY6!#hA1lOFc zGOm4MtX&*S#@{OGk^c*Gbu}tFt$)ftrhYV3zV}F`w^IS!>o*{=`xp zH%nU|H3K;yQXm|n76$n$-ePDJE%HW{(H5iJ1+nl&F1xYih4`zAtCAl?9R=@)7yQkb zGo0r~rLGv3@(kycat-)^b7jH5jD8>zU@L1G-N$-a?rwp@TDU}4(ij2QhA}Y4k4^H$ zN4Sb!hX3UH;Sj3OBHaVXJP*&OMB#*3}Wulp8#L6%%Y$oc9xD8L4h z^do5o5;f;9mUT~a-~*(uMAeb-!V!-c5fB;^o)}Zw_7#2-ndWAbFZV%`6 z`$s^o4`PM;k>vF`Q5AxVXmHmAt5q>z>6MSeUd)H459wCfX?PRq$=43TQHX!d14wL@ z@*S^s*dQF97w`aTsGiNKq4a5C^TEu1J~Bk{7wW!Bs46BWrTUzTl4_dGV0-x|I# zeEVX8PBy38zBKLqosu$d2M%A}!0Q~pgN>d4_#=n-?z8&3{yybred>LBtrt(Z9P+&V z8N?KCaQt}yDgZLLmpA&H1^}{xz-uj|oYTeN20gJ7seh=%jZIZJ$39qJ~^0<%kIbPLiT~ zBl~Npj6;5sWBsCj7hd4GI?L{fZhZ}AZH=5dONRyX2kPUR;@f54o>XUscr# zxH6gAQ<D>=Cylqs_jm9;;2lWkfWOz^_H9aY-q3 zLiZ1jA7tC%IX>>H7IkiN9nkF-4Q}-EMZkCE7ENw9{%Qn0Ven=220R=GEc;@JVYfj^ z(R5;9{N#xd>1P-DuaW+v17o7@;K`Bwc^yE1HOk-s9zyP_Z z%kG0}*a0L5kqjX@gamDH>`#y&SeHzSC`VXaCw4QE?MP7hVDUT?qJtL>b?pzRth)TU zqEc;msiZX4XZZH4i>h3E{YbWA0}zYj`cT%=2*g%>{qU0$ zxJYZ%_7~-9b?pnYQQh&P4k~!jwI0ZK=yssT=sI=li|rcq<`?A+>Y5jOjOvD$bryBC z)Gh3h%TUmyMRhpDdM`?B1a~t0P-*yxWJg79f&CQ{GLBcrChB%c%dv<3XUKy5lWPS6 mJ|CL?4Q2ZcW%>=J{~uJt|D;-fLm7XkimK@97Zg&`lm7$8^DYAb literal 0 HcmV?d00001 diff --git a/__pycache__/shadow_highlight_node.cpython-312.pyc b/__pycache__/shadow_highlight_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6ddc2774b656a25b5c9e0a1a2718aaf7e119aa93 GIT binary patch literal 3168 zcma(TTTC0-bv%AQ!5D)v7z`v?3<OC6M|h zS(FT^7gRpY9wvg};!zPQeBz)R=w*VDt%OKMjZ{yGmx^RagUFB;sY5!X3F(pcDG@R( z=zMy~F&w2)>d~#(bTkgXbTEaOu&Tm71Wu^ocQ6S60#zXrsgVY0kq+sR;Y&^AZL|?2 zD*4B0(-FS;OI=JgO!zE8$-px6nHbBUZ&Z?Y;Ew-hzoZS^zUv?Pdnu;rA~L?EoQz*n zko~JZjig6RgwDj%k~$Gf0-zsD8ZOPuDOv;96r}H(PbX<(33{55teji}icX|5$+T1* zEfu45Vo?Mh#ZWpbRh9Y01074FScdzC@TnwqIL>jne%^l%-ncXoP9-7>nV2`taOsKb zm>3wlkOgPO=D2~b=uu%8hf@B^Delj{jjf{WNKfKuTmP_ld z5YU2u?je9BVqZsC>kAF-g*M*?w{<}nx-Za3>|%u0Na5t^&GxN`Fd7ojwAdXLPQ(hW z9UExt+;&Eoq{Yt91aG)-qHD8uE3D|9VTATbp|Nd4vpKx&6vjRkPfQ5z2R~T#4Yqx? zvD&mx7>%X_d_$}rfK*4%&BYg}<7q8g2XN)R{l&DyShloufXEH^OMuHn`mh;!)l8|^ zOR})PRQO0W3!f6J4mTypj&5P4@sC2Gr&lrY|k^uM*1CUDll8+kjFX6yO-2p-V3;2wUsSo|GMKl4xhO=!zso&KzP?5Drq{`1v6{3%Yq zdici|?)U$2bq@=%xKml&q%3|5_hirv7>r5{{W!+?sw8!sNlIE65pxWT&$*1GO=c1> z@-*QTKr%TeX&*%y7Be57mDDj9(UzNZhT~#%@}qc$mCVfJIXa0b6iaZR6_0^NI>m;g zk}1OCK`WaxW|G`|hG7;Np9|guCLUpNyRtPH;C5tj2e3{IaAhS^EHO=`nIxBDB|~vc zLo7O$;qd!njA!75p_5@os#eBZItsjKDvl&ad4QJ>&5Brp=4K^ZSpw5+7xrkLW!NMW zr?}W+`BjS0VMwO(RZ>oD751oeNU|$*IK{H?n8Z>^$$r!emZ~iuRR~&(St;pqE{Mka zj>Y1ZVw(Ugb`JmdpNH!zVg?c}G{yP_Q>Fd!Bo~Xvyi|MRU4W0$=xheQ#}kt#|c~ zSnFHw-w1r$^XrDUp(m-n?C~T zKugxQ=gdEv2zS#9*Q#sHCv^M8Hh-b5tBAmJd7Bagl-N2|XgvYq?o&D2zKO8Z{M!GE zOK&W7yKc`)WQ|>&&AU6-?}_g24ZG+*n;R^+Jv;7B5e_eJHg3%d7l(wAknrgPapXbX zJqdh|cgJ%^^qg5wZa&!_6#B-5(3C)j#ZWl!LAf9#*3h()UC)Ru=S5d<&RS?}7TV5k zjEZfS#Ky~zEl5}zR{XEco?TDNi^7-WQ$sa{;X0B;VPtx9vB!uWai--|~Ow4Pd2qpK$t$*nUN@cx3}Z`*|FO zvsbuyTev$VUYru0Q@Oxj?T+R8=k+UfYxXsN-hL{l+jV(%TpgmTW4$i#`e4U(NpxM> z?8&=6g!rpHLRE`kXkiK{mx)^wmIboPCW?YoHD=B3+(s`r*|6{?z-ZXIaOX}v`=Skb3p--C_yVekP4 svfZP;B*T?rGJWhIh~PoWVTpaUN~L;3)Vv`q|J3xWRNj9O7|PcF1KW#6V*mgE literal 0 HcmV?d00001 diff --git a/__pycache__/text_display_node.cpython-312.pyc b/__pycache__/text_display_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b3b45236f7ffb0faee9d5dea2040e901b13b6ae5 GIT binary patch literal 10771 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 literal 0 HcmV?d00001 diff --git a/__pycache__/text_processor_node.cpython-312.pyc b/__pycache__/text_processor_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6f25e46340cf529f13795cdd848d950684026456 GIT binary patch literal 12085 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 literal 0 HcmV?d00001 diff --git a/core/__pycache__/channel_mixer_node.cpython-312.pyc b/core/__pycache__/channel_mixer_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3deb1cdf976ce4102d7bb2058613b19cdfe4ee51 GIT binary patch literal 3487 zcmc&$TTC0-8J-z?Y~u^Ym^;|mfh8EU7usAl*(5BRwSg5jxxgxM$PR#((QF>*O;z5YtW>q?L(j3t0Sv9w zhgFZ{fBxI~&;OtEpY!>5Hk+A1j?8M$1NM0gfAxfBnIzZkfo@$<9J)pug(1i^^ zA2tFbpzo4l)2zW~QmZacu^b2E!Pp(hhmrtB$OGXmh}WUtUK$aUCpn4oS|NcB=woD* z0yHq7XeLkr%#ZX1+44Wh)<*_lL(wV=(e^p}BMMYKqOKD@N9YNu(u2Xie!owr>aIbc z()~PyoT`5(o`I?nz$lxEr+E{~P^AMy!+j%vMJyUO-<~WovGo%3wr1Mt_33$BoT^L2 zI7HYxs$NLLl;%hfK7&ff66_>YE0dXYDwAd+#f?(UJOqr8%vJvT4e{CmD~*;HK1F1K z*f9}J?P}XwYq5=5>Ef_-;)>GRk@v56NLSyN#v^hkklax5cIG25Rb4>xK*e)1e|^19 zx-u#;6S6NNwF1S}o^M-=Zj4G}jNCCUwXnN3V`Jq`HR18Cp36h2>w@IIsC4$^2iG#v z5B<`~z%H$8tlV|ls?0ltt-`#AQX+T*BC3;3rQ)-t(<)XvXB)&v>G2o$B2t0fwSgxU zTKopOluJbVW&4Zdbm?-He9`x*W(VYl=r1;u)B)Wb>e_j$sMk1~h^}=(S1B4a&Munx*MVNNpy!1638hgg zs>6BCq3i%%W;5*D=8z9Cmf1~*?ekTlk>|>MwD_VGuTW;w;)^zH!z+~8wfJJ?OS>I= z%{$Vl+TxhDmbm?|=l~QjJjJJn#=Jvxl*L6kT|ky2dX&$xdXxv&b3Zy72k+hgwKfV= z6gtj!q#CVCu1%zX`9Jt*6>|>cV=4GpkHpccN8K|m8ilFvwS%e;oNOi9+Oi>51M7$H z62y;#DfVpT{o>kNyo1llS_@|012(>YbdZx^eES zjX(Cjz%R?>Z$J9=XHBpFs`o{~6N4I$-vl22mzez5likhW#FbtiZyACFJ$ol6_^hff zj_{Bcrq~ojFpx&@#Wx~5K+ys#3Yl3oRH*I zQxSmB>IMXK2&0S88D%4A$r5yGQV}{e!Crz+MpINL`PgI(0bvm-Sq?$34PzK6z4NM# zgC8HT(macxQ?(Tr)-n=s+MeSE--;_Zfyi9@5&Sy_P;jlnV{}9d#B)n!HuLEx_deOO zG%wF=T0BaldwJ$z!@c*m8{OIWwwvi9d-*`9rHt65D?yuBKhU1ybM?}~S&FURIyd3bU4g4}#|S4XC2%g35mpe;P!= z-0k0QI(ApCcGKRWcwW74crf#G$8yj8%mbU^Xw9A7baWk~KC|iY9a1ma3j63{rUY+@UO;6UZ z)HW}}#SgN9KRcXD^^5h(bvZ|_Z_Ckt8&y;U-`@?O-cAs-b8_$pKr!*FZ@GK^|wm_!m};+VHG%uI%jYZgl+&I&>Trl*pC z_n^tD^_qWV^jfI+|4=mrMnjiJ28Tnc>2lwQzkm4J&BDduUC6hvTJbU!o;ytNPv}DA zE}_s>yHq7@-qR5V#}4LpJVcFqVMwvJFAOMl_rkz;`ZKhr7zR&7f783shkq559!v^x r53i+~E)62-{VieryMBlyJ%1xGEkyq}Asd@X literal 0 HcmV?d00001 diff --git a/core/__pycache__/color_balance_node.cpython-312.pyc b/core/__pycache__/color_balance_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d81260fb2709547fff8ada47d570bd257b583b63 GIT binary patch literal 4243 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 literal 0 HcmV?d00001 diff --git a/core/__pycache__/levels_node.cpython-312.pyc b/core/__pycache__/levels_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..50edb85376b8f513708981d5d77accdebd49da4c GIT binary patch literal 2473 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 0.001, original_luminance / new_luminance, 1.0) + ratio = np.expand_dims(ratio, axis=2) + result = result * ratio + + result = np.clip(result, 0, 1) + result_tensor = torch.from_numpy(result).unsqueeze(0) + return (result_tensor,) diff --git a/core/color_balance_node.py b/core/color_balance_node.py new file mode 100644 index 0000000..f5e3387 --- /dev/null +++ b/core/color_balance_node.py @@ -0,0 +1,78 @@ +import numpy as np +import torch + +class ColorBalanceNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "adjust_type": (["shadows", "midtones", "highlights"], {"default": "midtones"}), + "cyan_red": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0}), + "magenta_green": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0}), + "yellow_blue": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0}), + }, + "optional": { + "preserve_luminosity": ("BOOLEAN", {"default": True}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_color_balance" + CATEGORY = "Image Effects" + + def apply_color_balance(self, image, adjust_type, cyan_red, magenta_green, yellow_blue, preserve_luminosity=True): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + image_np = img_tensor.cpu().numpy() + result = image_np.copy() + + # Calculer la luminance pour chaque zone + luminance = 0.299 * result[:,:,0] + 0.587 * result[:,:,1] + 0.114 * result[:,:,2] + + # Définir les masques pour chaque zone + if adjust_type == "shadows": + mask = np.where(luminance < 0.33, 1.0 - (luminance / 0.33), 0.0) + elif adjust_type == "highlights": + mask = np.where(luminance > 0.67, (luminance - 0.67) / 0.33, 0.0) + else: # midtones + mask = np.where((luminance >= 0.33) & (luminance <= 0.67), + 1.0 - np.abs(luminance - 0.5) / 0.17, 0.0) + + # Normaliser les ajustements + cyan_red_norm = cyan_red / 100.0 + magenta_green_norm = magenta_green / 100.0 + yellow_blue_norm = yellow_blue / 100.0 + + # Appliquer les ajustements couleur + mask = np.expand_dims(mask, axis=2) + + # Cyan-Red + result[:,:,0] += cyan_red_norm * mask[:,:,0] # Rouge + result[:,:,1] -= cyan_red_norm * 0.5 * mask[:,:,0] # Vert + result[:,:,2] -= cyan_red_norm * 0.5 * mask[:,:,0] # Bleu + + # Magenta-Green + result[:,:,0] += magenta_green_norm * 0.5 * mask[:,:,0] # Rouge + result[:,:,1] -= magenta_green_norm * mask[:,:,0] # Vert + result[:,:,2] += magenta_green_norm * 0.5 * mask[:,:,0] # Bleu + + # Yellow-Blue + result[:,:,0] += yellow_blue_norm * 0.5 * mask[:,:,0] # Rouge + result[:,:,1] += yellow_blue_norm * 0.5 * mask[:,:,0] # Vert + result[:,:,2] -= yellow_blue_norm * mask[:,:,0] # Bleu + + # Préserver la luminosité si demandé + if preserve_luminosity: + original_luminance = 0.299 * image_np[:,:,0] + 0.587 * image_np[:,:,1] + 0.114 * image_np[:,:,2] + new_luminance = 0.299 * result[:,:,0] + 0.587 * result[:,:,1] + 0.114 * result[:,:,2] + ratio = np.where(new_luminance > 0.001, original_luminance / new_luminance, 1.0) + ratio = np.expand_dims(ratio, axis=2) + result = result * ratio + + result = np.clip(result, 0, 1) + result_tensor = torch.from_numpy(result).unsqueeze(0) + return (result_tensor,) diff --git a/core/curves_node.py b/core/curves_node.py new file mode 100644 index 0000000..918388a --- /dev/null +++ b/core/curves_node.py @@ -0,0 +1,145 @@ +import numpy as np +import torch +from scipy import interpolate + +class CurvesNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "channel": (["RGB", "Red", "Green", "Blue"], {"default": "RGB"}), + # Points de contrôle pour la courbe (format: x,y;x,y;...) + "curve_points": ("STRING", {"default": "0,0;64,64;128,128;192,192;255,255", "multiline": False}), + "interpolation": (["linear", "cubic", "quadratic"], {"default": "cubic"}), + }, + "optional": { + "strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.01}), + "preserve_luminosity": ("BOOLEAN", {"default": False}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_curves" + CATEGORY = "Image Effects" + + def parse_curve_points(self, curve_points_str): + """Parse la chaîne de points de courbe en coordonnées""" + try: + points = [] + pairs = curve_points_str.split(';') + for pair in pairs: + x, y = map(float, pair.split(',')) + # Normaliser les valeurs entre 0 et 1 + points.append((x/255.0, y/255.0)) + + # Trier par x pour assurer l'ordre croissant + points.sort(key=lambda p: p[0]) + + # S'assurer que les points de début et fin sont présents + if points[0][0] > 0: + points.insert(0, (0, 0)) + if points[-1][0] < 1: + points.append((1, 1)) + + return points + except: + # Points par défaut si erreur de parsing + return [(0, 0), (0.25, 0.25), (0.5, 0.5), (0.75, 0.75), (1, 1)] + + def create_lookup_table(self, points, interpolation_method): + """Crée une table de correspondance pour la courbe""" + x_points = [p[0] for p in points] + y_points = [p[1] for p in points] + + # Créer 256 points pour la LUT + x_lut = np.linspace(0, 1, 256) + + if interpolation_method == "linear": + y_lut = np.interp(x_lut, x_points, y_points) + elif interpolation_method == "cubic": + if len(points) >= 4: + # Spline cubique + tck = interpolate.splrep(x_points, y_points, s=0, k=min(3, len(points)-1)) + y_lut = interpolate.splev(x_lut, tck) + else: + # Fallback vers linéaire si pas assez de points + y_lut = np.interp(x_lut, x_points, y_points) + else: # quadratic + if len(points) >= 3: + tck = interpolate.splrep(x_points, y_points, s=0, k=min(2, len(points)-1)) + y_lut = interpolate.splev(x_lut, tck) + else: + y_lut = np.interp(x_lut, x_points, y_points) + + # Clipper les valeurs entre 0 et 1 + y_lut = np.clip(y_lut, 0, 1) + + return y_lut + + def rgb_to_luminance(self, rgb): + """Convertit RGB en luminance""" + return 0.299 * rgb[:,:,0] + 0.587 * rgb[:,:,1] + 0.114 * rgb[:,:,2] + + def apply_curves(self, image, channel, curve_points, interpolation, strength=1.0, preserve_luminosity=False): + # Prendre la première image du batch + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + # Convertir en numpy + image_np = img_tensor.cpu().numpy() + h, w, c = image_np.shape + + # Parser les points de courbe + points = self.parse_curve_points(curve_points) + + # Créer la table de correspondance + lut = self.create_lookup_table(points, interpolation) + + # Copier l'image + result = image_np.copy() + + # Sauvegarder la luminance originale si nécessaire + if preserve_luminosity: + original_luminance = self.rgb_to_luminance(result) + + # Déterminer les canaux à traiter + if channel == "RGB": + channels_to_process = [0, 1, 2] + elif channel == "Red": + channels_to_process = [0] + elif channel == "Green": + channels_to_process = [1] + elif channel == "Blue": + channels_to_process = [2] + + # Appliquer la courbe + for ch in channels_to_process: + channel_data = result[:, :, ch] + + # Convertir en indices pour la LUT (0-255) + indices = np.clip((channel_data * 255).astype(int), 0, 255) + + # Appliquer la courbe + curved_data = lut[indices] + + # Mélanger avec l'original selon la force + result[:, :, ch] = channel_data * (1 - strength) + curved_data * strength + + # Restaurer la luminance si demandé + if preserve_luminosity and channel == "RGB": + new_luminance = self.rgb_to_luminance(result) + # Éviter la division par zéro + ratio = np.where(new_luminance > 0.001, original_luminance / new_luminance, 1.0) + ratio = np.expand_dims(ratio, axis=2) + result = result * ratio + + # Clipper les valeurs finales + result = np.clip(result, 0, 1) + + # Reconvertir en tensor + result_tensor = torch.from_numpy(result).unsqueeze(0) + + return (result_tensor,) diff --git a/core/levels_node.py b/core/levels_node.py new file mode 100644 index 0000000..ef02773 --- /dev/null +++ b/core/levels_node.py @@ -0,0 +1,74 @@ +import numpy as np +import torch +import cv2 + +class LevelsNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "input_black": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "input_white": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "gamma": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 3.0, "step": 0.01}), + "output_black": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "output_white": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), + }, + "optional": { + "channel": (["RGB", "Red", "Green", "Blue"], {"default": "RGB"}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_levels" + CATEGORY = "Image Effects" + + def apply_levels(self, image, input_black, input_white, gamma, output_black, output_white, channel="RGB"): + # Prendre la première image du batch + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + # Convertir en numpy + image_np = img_tensor.cpu().numpy() + h, w, c = image_np.shape + + # Copier l'image pour éviter de modifier l'original + result = image_np.copy() + + # Déterminer quels canaux traiter + if channel == "RGB": + channels_to_process = [0, 1, 2] + elif channel == "Red": + channels_to_process = [0] + elif channel == "Green": + channels_to_process = [1] + elif channel == "Blue": + channels_to_process = [2] + + # Appliquer les niveaux sur chaque canal sélectionné + for ch in channels_to_process: + channel_data = result[:, :, ch] + + # Étape 1: Ajuster les niveaux d'entrée + # Normaliser entre input_black et input_white + if input_white > input_black: + channel_data = np.clip((channel_data - input_black) / (input_white - input_black), 0, 1) + + # Étape 2: Appliquer la correction gamma + if gamma != 1.0: + channel_data = np.power(channel_data, 1.0 / gamma) + + # Étape 3: Ajuster les niveaux de sortie + channel_data = channel_data * (output_white - output_black) + output_black + + # Clipper les valeurs + channel_data = np.clip(channel_data, 0, 1) + + result[:, :, ch] = channel_data + + # Reconvertir en tensor + result_tensor = torch.from_numpy(result).unsqueeze(0) + + return (result_tensor,) diff --git a/core/saver_plus_node.py b/core/saver_plus_node.py new file mode 100644 index 0000000..a069487 --- /dev/null +++ b/core/saver_plus_node.py @@ -0,0 +1,322 @@ +import numpy as np +import torch +import os +from datetime import datetime +from PIL import Image, ImageEnhance +import json + +class SaverPlusNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "images": ("IMAGE",), + "filename": ("STRING", {"default": "output", "multiline": False}), + "save_path": ("STRING", {"default": "output/saver_plus/", "multiline": False}), + "output_format": (["PNG", "TIFF", "JPEG", "WEBP", "BMP"], {"default": "PNG"}), + }, + "optional": { + "layer_names": ("STRING", {"default": "Layer1,Layer2,Layer3", "multiline": False}), + "include_merged": ("BOOLEAN", {"default": True}), + "merge_mode": (["maximum", "average", "overlay", "multiply", "screen", "soft_light"], {"default": "maximum"}), + "quality": ("INT", {"default": 95, "min": 1, "max": 100, "step": 1}), + "add_timestamp": ("BOOLEAN", {"default": False}), + "save_metadata": ("BOOLEAN", {"default": True}), + "create_subfolder": ("BOOLEAN", {"default": False}), + "compression_level": ("INT", {"default": 6, "min": 0, "max": 9, "step": 1}), + "preserve_transparency": ("BOOLEAN", {"default": True}), + "auto_optimize": ("BOOLEAN", {"default": True}), + } + } + + RETURN_TYPES = ("STRING", "STRING") + RETURN_NAMES = ("save_info", "folder_path") + FUNCTION = "save_images" + CATEGORY = "Image Effects" + OUTPUT_NODE = True + + def save_images(self, images, filename, save_path, output_format, layer_names="", + include_merged=True, merge_mode="maximum", quality=95, + add_timestamp=False, save_metadata=True, create_subfolder=False, + compression_level=6, preserve_transparency=True, auto_optimize=True): + + # Créer le dossier de sortie avec structure intelligente + final_save_path = self._create_save_path(save_path, filename, create_subfolder, add_timestamp) + os.makedirs(final_save_path, exist_ok=True) + + # Générer le nom de fichier final + final_filename = self._generate_filename(filename, add_timestamp) + + # Parser et valider les noms de calques + layer_name_list = self._parse_layer_names(layer_names, len(images)) + + # Initialiser les métadonnées complètes + metadata = self._init_metadata(output_format, include_merged, merge_mode, + quality, compression_level, len(images)) + + saved_files = [] + + # Sauvegarder chaque calque avec optimisations + for i, img_tensor in enumerate(images): + layer_info = self._save_single_layer( + img_tensor, final_filename, layer_name_list[i], + final_save_path, output_format, quality, + compression_level, preserve_transparency, auto_optimize + ) + saved_files.append(layer_info["path"]) + metadata["layers"].append(layer_info["metadata"]) + + # Créer l'image fusionnée avec mode avancé + if include_merged and len(images) > 1: + merged_info = self._create_merged_image( + images, final_filename, final_save_path, output_format, + merge_mode, quality, compression_level, + preserve_transparency, auto_optimize + ) + saved_files.append(merged_info["path"]) + metadata["merged_file"] = merged_info["metadata"] + + # Sauvegarder les métadonnées enrichies + if save_metadata: + metadata_path = self._save_metadata(metadata, final_filename, final_save_path) + saved_files.append(metadata_path) + + # Générer le rapport de sauvegarde + save_info = self._generate_save_report(saved_files, final_save_path, + output_format, merge_mode, include_merged) + + return (save_info, final_save_path) + + def _create_save_path(self, base_path, filename, create_subfolder, add_timestamp): + """Créer la structure de dossiers intelligente""" + if create_subfolder: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + subfolder_name = f"{filename}_{timestamp}" if add_timestamp else filename + return os.path.join(base_path, subfolder_name) + return base_path + + def _generate_filename(self, filename, add_timestamp): + """Générer le nom de fichier avec horodatage optionnel""" + if add_timestamp: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + return f"{filename}_{timestamp}" + return filename + + def _parse_layer_names(self, layer_names, num_images): + """Parser et valider les noms de calques""" + if layer_names.strip(): + layer_list = [name.strip() for name in layer_names.split(',') if name.strip()] + else: + layer_list = [] + + # Compléter avec des noms par défaut si nécessaire + while len(layer_list) < num_images: + layer_list.append(f"Layer_{len(layer_list)+1}") + + return layer_list[:num_images] # Limiter au nombre d'images + + def _init_metadata(self, output_format, include_merged, merge_mode, + quality, compression_level, num_layers): + """Initialiser les métadonnées complètes""" + return { + "creation_date": datetime.now().isoformat(), + "comfyui_version": "0.3.35", + "saver_plus_version": "1.0", + "output_format": output_format, + "settings": { + "quality": quality if output_format in ["JPEG", "WEBP"] else None, + "compression_level": compression_level, + "merged_included": include_merged, + "merge_mode": merge_mode if include_merged else None + }, + "statistics": { + "total_layers": num_layers, + "total_files": 0 # Sera mis à jour + }, + "layers": [], + "merged_file": None + } + + def _save_single_layer(self, img_tensor, filename, layer_name, save_path, + output_format, quality, compression_level, + preserve_transparency, auto_optimize): + """Sauvegarder un calque avec optimisations spécifiques au format""" + # Convertir le tensor en image PIL + img_np = img_tensor.cpu().numpy() + img_array = (img_np * 255).astype(np.uint8) + + # Gestion intelligente des canaux + if img_array.shape[2] == 4 and preserve_transparency: + pil_img = Image.fromarray(img_array, 'RGBA') + else: + pil_img = Image.fromarray(img_array[:,:,:3], 'RGB') + + # Nom de fichier final + file_extension = self._get_file_extension(output_format) + file_path = os.path.join(save_path, f"{filename}_{layer_name}.{file_extension}") + + # Options de sauvegarde optimisées par format + save_kwargs = self._get_save_options(output_format, quality, compression_level, auto_optimize) + + # Conversion spéciale pour JPEG (pas de transparence) + if output_format == "JPEG" and pil_img.mode == "RGBA": + background = Image.new("RGB", pil_img.size, (255, 255, 255)) + background.paste(pil_img, mask=pil_img.split()[-1]) + pil_img = background + + # Sauvegarder avec gestion d'erreurs + try: + pil_img.save(file_path, output_format, **save_kwargs) + file_size = os.path.getsize(file_path) + except Exception as e: + raise Exception(f"Erreur lors de la sauvegarde de {layer_name}: {str(e)}") + + return { + "path": file_path, + "metadata": { + "name": layer_name, + "filename": os.path.basename(file_path), + "index": len(os.listdir(save_path)) - 1, + "dimensions": [img_array.shape[1], img_array.shape[0]], # width, height + "channels": img_array.shape[2], + "file_size_bytes": file_size, + "color_mode": pil_img.mode + } + } + + def _create_merged_image(self, images, filename, save_path, output_format, + merge_mode, quality, compression_level, + preserve_transparency, auto_optimize): + """Créer l'image fusionnée avec modes avancés""" + merged = self._merge_images_advanced(images, merge_mode) + merged_array = (merged * 255).astype(np.uint8) + + # Créer l'image PIL + if merged_array.shape[2] == 4 and preserve_transparency: + merged_pil = Image.fromarray(merged_array, 'RGBA') + else: + merged_pil = Image.fromarray(merged_array[:,:,:3], 'RGB') + + # Nom de fichier fusionné + file_extension = self._get_file_extension(output_format) + merged_path = os.path.join(save_path, f"{filename}_merged.{file_extension}") + + # Options de sauvegarde + save_kwargs = self._get_save_options(output_format, quality, compression_level, auto_optimize) + + # Conversion pour JPEG + if output_format == "JPEG" and merged_pil.mode == "RGBA": + background = Image.new("RGB", merged_pil.size, (255, 255, 255)) + background.paste(merged_pil, mask=merged_pil.split()[-1]) + merged_pil = background + + merged_pil.save(merged_path, output_format, **save_kwargs) + file_size = os.path.getsize(merged_path) + + return { + "path": merged_path, + "metadata": { + "filename": os.path.basename(merged_path), + "dimensions": [merged_array.shape[1], merged_array.shape[0]], + "channels": merged_array.shape[2], + "file_size_bytes": file_size, + "color_mode": merged_pil.mode + } + } + + def _merge_images_advanced(self, images, merge_mode): + """Modes de fusion avancés""" + if len(images) == 1: + return images[0].cpu().numpy() + + np_images = [img.cpu().numpy() for img in images] + base = np_images[0] + + for img in np_images[1:]: + if merge_mode == "maximum": + base = np.maximum(base, img) + elif merge_mode == "average": + base = (base + img) / 2 + elif merge_mode == "overlay": + mask = base < 0.5 + base = np.where(mask, 2 * base * img, 1 - 2 * (1 - base) * (1 - img)) + elif merge_mode == "multiply": + base = base * img + elif merge_mode == "screen": + base = 1 - (1 - base) * (1 - img) + elif merge_mode == "soft_light": + mask = img < 0.5 + base = np.where(mask, + base - (1 - 2 * img) * base * (1 - base), + base + (2 * img - 1) * (np.sqrt(base) - base)) + + return np.clip(base, 0, 1) + + def _get_file_extension(self, output_format): + """Obtenir l'extension de fichier correcte""" + extensions = { + "PNG": "png", "TIFF": "tiff", "JPEG": "jpg", + "WEBP": "webp", "BMP": "bmp" + } + return extensions.get(output_format, "png") + + def _get_save_options(self, output_format, quality, compression_level, auto_optimize): + """Options de sauvegarde optimisées par format""" + options = {} + + if output_format == "PNG": + options.update({ + "optimize": auto_optimize, + "compress_level": compression_level + }) + elif output_format == "JPEG": + options.update({ + "quality": quality, + "optimize": auto_optimize, + "progressive": True + }) + elif output_format == "WEBP": + options.update({ + "quality": quality, + "optimize": auto_optimize, + "lossless": quality >= 95 + }) + elif output_format == "TIFF": + options.update({ + "compression": "lzw", + "optimize": auto_optimize + }) + elif output_format == "BMP": + pass # BMP n'a pas d'options spéciales + + return options + + def _save_metadata(self, metadata, filename, save_path): + """Sauvegarder les métadonnées enrichies""" + # Mettre à jour les statistiques + metadata["statistics"]["total_files"] = len(metadata["layers"]) + if metadata["merged_file"]: + metadata["statistics"]["total_files"] += 1 + + metadata_path = os.path.join(save_path, f"{filename}_metadata.json") + with open(metadata_path, 'w', encoding='utf-8') as f: + json.dump(metadata, f, indent=2, ensure_ascii=False) + + return metadata_path + + def _generate_save_report(self, saved_files, save_path, output_format, merge_mode, include_merged): + """Générer un rapport de sauvegarde détaillé""" + total_size = sum(os.path.getsize(f) for f in saved_files if os.path.exists(f)) + size_mb = total_size / (1024 * 1024) + + report = f"✅ **SaverPlus - Sauvegarde terminée**\n" + report += f"📁 **Dossier**: {save_path}\n" + report += f"📄 **Format**: {output_format}\n" + report += f"📊 **Fichiers**: {len(saved_files)} ({size_mb:.2f} MB)\n" + + if include_merged: + report += f"🔄 **Fusion**: {merge_mode}\n" + + report += f"⏰ **Heure**: {datetime.now().strftime('%H:%M:%S')}" + + return report diff --git a/core/shadow_highlight_node.py b/core/shadow_highlight_node.py new file mode 100644 index 0000000..1cd49bd --- /dev/null +++ b/core/shadow_highlight_node.py @@ -0,0 +1,73 @@ +import numpy as np +import torch + +class ShadowHighlightNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "shadow_amount": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0}), + "highlight_amount": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0}), + "shadow_width": ("FLOAT", {"default": 50.0, "min": 0.0, "max": 100.0, "step": 1.0}), + "highlight_width": ("FLOAT", {"default": 50.0, "min": 0.0, "max": 100.0, "step": 1.0}), + "radius": ("FLOAT", {"default": 30.0, "min": 0.0, "max": 100.0, "step": 1.0}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_shadow_highlight" + CATEGORY = "Image Effects" + + def apply_shadow_highlight(self, image, shadow_amount, highlight_amount, shadow_width, highlight_width, radius): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + image_np = img_tensor.cpu().numpy() + result = image_np.copy() + + # Calculer la luminance + luminance = 0.299 * result[:,:,0] + 0.587 * result[:,:,1] + 0.114 * result[:,:,2] + + # Créer les masques pour ombres et hautes lumières + shadow_threshold = shadow_width / 100.0 + highlight_threshold = 1.0 - (highlight_width / 100.0) + + # Masque des ombres (transition douce) + shadow_mask = np.where(luminance < shadow_threshold, + 1.0 - (luminance / shadow_threshold), + 0.0) + + # Masque des hautes lumières (transition douce) + highlight_mask = np.where(luminance > highlight_threshold, + (luminance - highlight_threshold) / (1.0 - highlight_threshold), + 0.0) + + # Appliquer un flou gaussien pour adoucir les transitions + if radius > 0: + import cv2 + kernel_size = int(radius / 10) * 2 + 1 + shadow_mask = cv2.GaussianBlur(shadow_mask, (kernel_size, kernel_size), radius/30) + highlight_mask = cv2.GaussianBlur(highlight_mask, (kernel_size, kernel_size), radius/30) + + # Appliquer les corrections + shadow_factor = 1.0 + (shadow_amount / 100.0) + highlight_factor = 1.0 + (highlight_amount / 100.0) + + # Correction des ombres + if shadow_amount != 0: + shadow_mask_3d = np.expand_dims(shadow_mask, axis=2) + shadow_correction = result * shadow_factor + result = result * (1 - shadow_mask_3d) + shadow_correction * shadow_mask_3d + + # Correction des hautes lumières + if highlight_amount != 0: + highlight_mask_3d = np.expand_dims(highlight_mask, axis=2) + highlight_correction = result * highlight_factor + result = result * (1 - highlight_mask_3d) + highlight_correction * highlight_mask_3d + + result = np.clip(result, 0, 1) + result_tensor = torch.from_numpy(result).unsqueeze(0) + return (result_tensor,) diff --git a/core/vibrance_node.py b/core/vibrance_node.py new file mode 100644 index 0000000..6b77bd1 --- /dev/null +++ b/core/vibrance_node.py @@ -0,0 +1,73 @@ +import numpy as np +import torch +import cv2 + +class VibranceNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "vibrance": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0}), + "saturation": ("FLOAT", {"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0}), + }, + "optional": { + "protect_skin_tones": ("BOOLEAN", {"default": True}), + "strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.01}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_vibrance" + CATEGORY = "Image Effects" + + def apply_vibrance(self, image, vibrance, saturation, protect_skin_tones=True, strength=1.0): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + image_np = img_tensor.cpu().numpy() + result = image_np.copy() + + # Convertir en HSV pour les calculs de saturation + hsv = cv2.cvtColor((result * 255).astype(np.uint8), cv2.COLOR_RGB2HSV).astype(np.float32) + hsv[:,:,1] /= 255.0 # Normaliser la saturation + hsv[:,:,2] /= 255.0 # Normaliser la valeur + + # Calculer la saturation actuelle + current_saturation = hsv[:,:,1] + + # Appliquer la vibrance (effet sélectif) + if vibrance != 0: + vibrance_factor = vibrance / 100.0 + # La vibrance affecte moins les couleurs déjà saturées + vibrance_mask = 1.0 - current_saturation + vibrance_adjustment = vibrance_factor * vibrance_mask * strength + hsv[:,:,1] = np.clip(current_saturation + vibrance_adjustment, 0, 1) + + # Appliquer la saturation globale + if saturation != 0: + saturation_factor = 1.0 + (saturation / 100.0) * strength + hsv[:,:,1] = np.clip(hsv[:,:,1] * saturation_factor, 0, 1) + + # Protection des tons chair + if protect_skin_tones: + # Détecter les tons chair (teinte entre 0-30 et 330-360 degrés) + hue = hsv[:,:,0] * 2 # Convertir en degrés (0-360) + skin_mask = ((hue >= 0) & (hue <= 30)) | ((hue >= 330) & (hue <= 360)) + skin_protection = np.where(skin_mask, 0.5, 1.0) + skin_protection = np.expand_dims(skin_protection, axis=2) + + # Réduire l'effet sur les tons chair + protected_result = image_np * (1 - skin_protection) + result * skin_protection + result = protected_result + + # Reconvertir en RGB + hsv[:,:,1] *= 255 + hsv[:,:,2] *= 255 + result = cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2RGB).astype(np.float32) / 255.0 + + result = np.clip(result, 0, 1) + result_tensor = torch.from_numpy(result).unsqueeze(0) + return (result_tensor,) diff --git a/creative/__init__.py b/creative/__init__.py new file mode 100644 index 0000000..74aaab4 --- /dev/null +++ b/creative/__init__.py @@ -0,0 +1 @@ +"""Effets d'image de base - ajustements fondamentaux""" diff --git a/creative/__pycache__/__init__.cpython-312.pyc b/creative/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eb4cead77f6cd42c950c9c7e0cb6856132739697 GIT binary patch literal 232 zcmX@j%ge<81Z(e`W>^C0#~=<2FhUuhIe?7m3@Hpz43&(UOjU-iX=$k?#R@6vnYoGS zsR}8n3Q38@sS3IZiCLw^C8@coc|h5;{JfMz5Hqo~!cUX&7JGbrN`7*D{7Qz;Aaj4Y zyI92}=jWzXmS!rHq!yRNIDgGTve^FJb|50OyQE7XSbN literal 0 HcmV?d00001 diff --git a/creative/__pycache__/ascii_art_node.cpython-312.pyc b/creative/__pycache__/ascii_art_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..940155571a80185d0952c8323e0818c642d58641 GIT binary patch literal 7825 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%0D?OCS}?;!h?lkCn?NX! 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 min_val: + enhanced = (enhanced - min_val) / (max_val - min_val) + + # Appliquer une courbe en S pour plus de contraste + enhanced = 0.5 * (1 + np.tanh(4 * (enhanced - 0.5))) + + return (enhanced * 255).astype(np.uint8) + + def _get_ascii_chars(self, style): + """Jeux de caractères avec meilleur contraste""" + styles = { + "classic": np.array(list(" .:-=+*#%@")), + "detailed": np.array(list(" ░▒▓█")), + "minimal": np.array(list(" .-#@")), + "blocks": np.array(list(" ▁▂▃▄▅▆▇█")) + } + return styles.get(style, styles["classic"]) + + def _create_high_quality_output(self, ascii_lines, target_width, target_height, + background_color, text_color, font_scale): + """Créer une image sans bord avec remplissage complet""" + + # Définir les couleurs + bg_colors = {"white": (255, 255, 255), "black": (0, 0, 0), "gray": (128, 128, 128)} + txt_colors = {"white": (255, 255, 255), "black": (0, 0, 0), "auto": None} + + bg_color = bg_colors.get(background_color, (255, 255, 255)) + txt_color = txt_colors.get(text_color, (0, 0, 0)) + + # Auto color avec contraste maximal + if text_color == "auto": + txt_color = (0, 0, 0) if background_color == "white" else (255, 255, 255) + + # Créer l'image + img = Image.new("RGB", (target_width, target_height), color=bg_color) + draw = ImageDraw.Draw(img) + + # Calculer les dimensions + chars_width = len(ascii_lines[0]) + chars_height = len(ascii_lines) + + # Calculer l'espacement pour REMPLIR COMPLÈTEMENT l'image + char_spacing_w = target_width / chars_width + char_spacing_h = target_height / chars_height + + # Taille de police pour remplir l'espace + font_size = int(min(char_spacing_w, char_spacing_h) * font_scale) + font_size = max(1, font_size) + + print(f"Taille de police calculée: {font_size}px") + + # Charger une police monospace avec fallback robuste + font = self._load_best_font(font_size) + + # Dessiner le texte en remplissant TOUTE l'image + for row, line in enumerate(ascii_lines): + for col, char in enumerate(line): + x = int(col * char_spacing_w) + y = int(row * char_spacing_h) + draw.text((x, y), char, fill=txt_color, font=font) + + # Améliorer le contraste de l'image finale + enhancer = ImageEnhance.Contrast(img) + img = enhancer.enhance(1.5) + + # Convertir en numpy array + img_array = np.array(img).astype(np.float32) / 255.0 + + return img_array + + def _load_best_font(self, font_size): + """Charger la meilleure police monospace disponible""" + fonts_to_try = [ + "consola.ttf", # Windows + "Monaco.ttf", # macOS + "DejaVuSansMono.ttf", # Linux + "LiberationMono-Regular.ttf", # Linux alternative + "CourierNew.ttf" # Fallback + ] + + for font_name in fonts_to_try: + try: + font = ImageFont.truetype(font_name, font_size) + print(f"Police chargée: {font_name}") + return font + except: + continue + + # Dernière option + print("Utilisation de la police par défaut") + return ImageFont.load_default() diff --git a/creative/ascii_text_node.py b/creative/ascii_text_node.py new file mode 100644 index 0000000..8332323 --- /dev/null +++ b/creative/ascii_text_node.py @@ -0,0 +1,165 @@ +import numpy as np +import torch +from PIL import Image + +class AsciiTextNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "ascii_width": ("INT", {"default": 80, "min": 20, "max": 200, "step": 10}), + "style": (["classic", "detailed", "minimal", "blocks", "custom"], {"default": "classic"}), + "invert": ("BOOLEAN", {"default": False}), + }, + "optional": { + "contrast_boost": ("FLOAT", {"default": 2.0, "min": 1.0, "max": 4.0, "step": 0.1}), + "height_compression": ("FLOAT", {"default": 2.0, "min": 1.0, "max": 3.0, "step": 0.1}), + "custom_chars": ("STRING", {"default": " .-+*#@", "multiline": False}), + "add_border": ("BOOLEAN", {"default": False}), + "line_numbers": ("BOOLEAN", {"default": False}), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("ascii_text",) + FUNCTION = "generate_ascii_text" + CATEGORY = "Image Effects" + OUTPUT_NODE = True + + def generate_ascii_text(self, image, ascii_width, style, invert, + contrast_boost=2.0, height_compression=2.0, + custom_chars=" .-+*#@", add_border=False, line_numbers=False): + + # Prendre la première image du batch + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + # Convertir en numpy + img_np = img_tensor.cpu().numpy() + original_h, original_w, c = img_np.shape + + print(f"ASCII Text: {original_w}x{original_h} → {ascii_width} chars wide") + + # Convertir en niveaux de gris + gray = np.dot(img_np[..., :3], [0.2989, 0.5870, 0.1140]) + + # Calculer les dimensions ASCII avec compression de hauteur + ascii_height = int(ascii_width * (original_h / original_w) / height_compression) + + # Redimensionner l'image pour l'analyse ASCII + gray_pil = Image.fromarray((gray * 255).astype(np.uint8)) + resized = np.array(gray_pil.resize((ascii_width, ascii_height), Image.Resampling.LANCZOS)) + + # Améliorer le contraste + enhanced = self._enhance_contrast(resized, contrast_boost) + + # Inverser si demandé + if invert: + enhanced = 255 - enhanced + + # Choisir le jeu de caractères + ascii_chars = self._get_ascii_chars(style, custom_chars) + + # Normaliser et mapper aux caractères + norm_pixels = (enhanced / 255) * (len(ascii_chars) - 1) + norm_pixels = norm_pixels.astype(int) + + # Générer le texte ASCII + ascii_text_lines = [] + for row_idx, row in enumerate(norm_pixels): + line = "".join([ascii_chars[p] for p in row]) + + # Ajouter les numéros de ligne si demandé + if line_numbers: + line = f"{row_idx+1:3d}: {line}" + + ascii_text_lines.append(line) + + # Ajouter une bordure si demandé + if add_border: + ascii_text_lines = self._add_border(ascii_text_lines, line_numbers) + + # Joindre toutes les lignes + ascii_text = "\n".join(ascii_text_lines) + + # Ajouter des informations d'en-tête + header = f"ASCII Art - {ascii_width}x{ascii_height} - Style: {style}\n" + header += "=" * len(header.strip()) + "\n" + + final_text = header + ascii_text + + print(f"ASCII généré: {len(ascii_text_lines)} lignes, {len(ascii_text)} caractères") + + return (final_text,) + + def _enhance_contrast(self, image_array, boost_factor): + """Améliorer le contraste pour un meilleur rendu ASCII""" + # Normaliser + normalized = image_array.astype(np.float32) / 255.0 + + # Appliquer un boost de contraste + enhanced = np.power(normalized, 1.0 / boost_factor) + + # Étalement d'histogramme + min_val = np.min(enhanced) + max_val = np.max(enhanced) + + if max_val > min_val: + enhanced = (enhanced - min_val) / (max_val - min_val) + + # Appliquer une courbe en S pour plus de contraste + enhanced = 0.5 * (1 + np.tanh(4 * (enhanced - 0.5))) + + return (enhanced * 255).astype(np.uint8) + + def _get_ascii_chars(self, style, custom_chars): + """Obtenir le jeu de caractères selon le style""" + styles = { + "classic": list(" .:-=+*#%@"), + "detailed": list(" ░▒▓█"), + "minimal": list(" .-#@"), + "blocks": list(" ▁▂▃▄▅▆▇█"), + "custom": list(custom_chars) + } + + chars = styles.get(style, styles["classic"]) + + # S'assurer qu'on a au moins 2 caractères + if len(chars) < 2: + chars = list(" @") + + return chars + + def _add_border(self, text_lines, has_line_numbers): + """Ajouter une bordure autour du texte ASCII""" + if not text_lines: + return text_lines + + # Calculer la largeur maximale + max_width = max(len(line) for line in text_lines) + + # Caractères de bordure + top_left = "┌" + top_right = "┐" + bottom_left = "└" + bottom_right = "┘" + horizontal = "─" + vertical = "│" + + # Ligne du haut + top_line = top_left + horizontal * (max_width + 2) + top_right + + # Ligne du bas + bottom_line = bottom_left + horizontal * (max_width + 2) + bottom_right + + # Lignes avec bordures latérales + bordered_lines = [top_line] + for line in text_lines: + padded_line = line.ljust(max_width) + bordered_lines.append(f"{vertical} {padded_line} {vertical}") + bordered_lines.append(bottom_line) + + return bordered_lines diff --git a/creative/css_filters_node.py b/creative/css_filters_node.py new file mode 100644 index 0000000..d972cb9 --- /dev/null +++ b/creative/css_filters_node.py @@ -0,0 +1,100 @@ +import numpy as np +import torch +from PIL import Image, ImageEnhance, ImageFilter +import cv2 + +class CSSFiltersNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + }, + "optional": { + "blur": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 20.0, "step": 0.1}), + "brightness": ("FLOAT", {"default": 100.0, "min": 0.0, "max": 300.0, "step": 1.0}), + "contrast": ("FLOAT", {"default": 100.0, "min": 0.0, "max": 300.0, "step": 1.0}), + "grayscale": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 100.0, "step": 1.0}), + "sepia": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 100.0, "step": 1.0}), + "hue_rotate": ("FLOAT", {"default": 0.0, "min": -180.0, "max": 180.0, "step": 1.0}), + "saturate": ("FLOAT", {"default": 100.0, "min": 0.0, "max": 300.0, "step": 1.0}), + "invert": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 100.0, "step": 1.0}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_css_filters" + CATEGORY = "Image Effects" + + def apply_css_filters(self, image, blur=0.0, brightness=100.0, contrast=100.0, + grayscale=0.0, sepia=0.0, hue_rotate=0.0, saturate=100.0, invert=0.0): + + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + # Convertir en PIL + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + pil_img = Image.fromarray(img_np) + + # Appliquer les filtres CSS équivalents + result = pil_img.copy() + + # Blur (flou) + if blur > 0: + result = result.filter(ImageFilter.GaussianBlur(radius=blur)) + + # Brightness (luminosité) + if brightness != 100.0: + enhancer = ImageEnhance.Brightness(result) + result = enhancer.enhance(brightness / 100.0) + + # Contrast (contraste) + if contrast != 100.0: + enhancer = ImageEnhance.Contrast(result) + result = enhancer.enhance(contrast / 100.0) + + # Saturate (saturation) + if saturate != 100.0: + enhancer = ImageEnhance.Color(result) + result = enhancer.enhance(saturate / 100.0) + + # Convertir en numpy pour les filtres avancés + result_np = np.array(result) + + # Grayscale (niveaux de gris) + if grayscale > 0: + gray = cv2.cvtColor(result_np, cv2.COLOR_RGB2GRAY) + gray_rgb = cv2.cvtColor(gray, cv2.COLOR_GRAY2RGB) + alpha = grayscale / 100.0 + result_np = (result_np * (1 - alpha) + gray_rgb * alpha).astype(np.uint8) + + # Sepia + if sepia > 0: + sepia_filter = np.array([ + [0.393, 0.769, 0.189], + [0.349, 0.686, 0.168], + [0.272, 0.534, 0.131] + ]) + sepia_img = result_np @ sepia_filter.T + sepia_img = np.clip(sepia_img, 0, 255) + alpha = sepia / 100.0 + result_np = (result_np * (1 - alpha) + sepia_img * alpha).astype(np.uint8) + + # Hue rotate (rotation de teinte) + if hue_rotate != 0: + hsv = cv2.cvtColor(result_np, cv2.COLOR_RGB2HSV).astype(np.float32) + hsv[:, :, 0] = (hsv[:, :, 0] + hue_rotate) % 180 + result_np = cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2RGB) + + # Invert (inversion) + if invert > 0: + inverted = 255 - result_np + alpha = invert / 100.0 + result_np = (result_np * (1 - alpha) + inverted * alpha).astype(np.uint8) + + # Reconvertir en tensor + result_tensor = torch.from_numpy(result_np.astype(np.float32) / 255.0).unsqueeze(0) + + return (result_tensor,) diff --git a/creative/kaleidoscope_node.py b/creative/kaleidoscope_node.py new file mode 100644 index 0000000..e381201 --- /dev/null +++ b/creative/kaleidoscope_node.py @@ -0,0 +1,356 @@ +import numpy as np +import cv2 +import torch + +class KaleidoscopeNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "facettes": ("INT", {"default": 6, "min": 2, "max": 20}), + }, + "optional": { + "center_x": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "center_y": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "radius": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 2.0, "step": 0.05}), + "rotation": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 360.0, "step": 1.0}), + "mirror_mode": (["alternate", "all", "none"], {"default": "alternate"}), + "blend_mode": (["add", "max", "average", "overlay"], {"default": "add"}), + "fade_edges": ("BOOLEAN", {"default": True}), + "color_shift": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.1}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "generate_kaleidoscope" + CATEGORY = "Image Effects" + + def generate_kaleidoscope(self, image, facettes, center_x=0.5, center_y=0.5, + radius=1.0, rotation=0.0, mirror_mode="alternate", + blend_mode="add", fade_edges=True, color_shift=0.0): + + # Prendre la première image du batch + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + # Convertir en numpy array + image_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = image_np.shape + + # Calculer le centre personnalisé + center = (int(w * center_x), int(h * center_y)) + + # Calculer le rayon effectif + max_radius = min(w, h) // 2 + effective_radius = int(max_radius * radius) + + # Angle de base avec rotation + base_angle = 360 / facettes + + print(f"Kaleidoscope: {facettes} facettes, centre: {center}, rayon: {effective_radius}") + + # Créer le masque pour un secteur + mask = self._create_sector_mask(h, w, center, facettes, effective_radius, rotation, fade_edges) + + # Appliquer le masque pour obtenir le secteur de base + base_sector = cv2.bitwise_and(image_np, image_np, mask=mask) + + # Appliquer un décalage de couleur si demandé + if color_shift != 0.0: + base_sector = self._apply_color_shift(base_sector, color_shift) + + # Créer le résultat kaléidoscope + result = self._create_kaleidoscope_effect( + base_sector, facettes, center, base_angle, rotation, + mirror_mode, blend_mode, h, w + ) + + # Normaliser et convertir en tensor + result = np.clip(result, 0, 255).astype(np.float32) / 255.0 + result_tensor = torch.from_numpy(result).unsqueeze(0) + + return (result_tensor,) + + def _create_sector_mask(self, h, w, center, facettes, radius, rotation, fade_edges): + """Créer un masque triangulaire pour un secteur avec dégradé optionnel""" + mask = np.zeros((h, w), dtype=np.uint8) + + # Calculer l'angle du secteur + sector_angle = np.radians(360 / facettes) + start_angle = np.radians(rotation) + + # Points du secteur triangulaire + x1 = int(center[0] + radius * np.cos(start_angle)) + y1 = int(center[1] + radius * np.sin(start_angle)) + x2 = int(center[0] + radius * np.cos(start_angle + sector_angle)) + y2 = int(center[1] + radius * np.sin(start_angle + sector_angle)) + + points = np.array([center, (x1, y1), (x2, y2)], dtype=np.int32) + cv2.fillConvexPoly(mask, points, 255) + + # Ajouter un dégradé radial pour adoucir les bords + if fade_edges: + mask = self._apply_radial_fade(mask, center, radius) + + return mask + + def _apply_radial_fade(self, mask, center, radius): + """Appliquer un dégradé radial pour adoucir les bords""" + h, w = mask.shape + y, x = np.ogrid[:h, :w] + + # Calculer la distance depuis le centre + distance = np.sqrt((x - center[0])**2 + (y - center[1])**2) + + # Créer un dégradé radial + fade_start = radius * 0.7 + fade_mask = np.where(distance <= fade_start, 1.0, + np.where(distance >= radius, 0.0, + 1.0 - (distance - fade_start) / (radius - fade_start))) + + # Appliquer le dégradé au masque + faded_mask = (mask.astype(np.float32) / 255.0 * fade_mask * 255).astype(np.uint8) + + return faded_mask + + def _apply_color_shift(self, image, shift_amount): + """Appliquer un décalage de couleur HSV""" + if shift_amount == 0.0: + return image + + # Convertir en HSV + hsv = cv2.cvtColor(image, cv2.COLOR_RGB2HSV).astype(np.float32) + + # Décaler la teinte + hsv[:, :, 0] = (hsv[:, :, 0] + shift_amount * 180) % 180 + + # Reconvertir en RGB + shifted = cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2RGB) + + return shifted + + def _create_kaleidoscope_effect(self, base_sector, facettes, center, base_angle, + rotation, mirror_mode, blend_mode, h, w): + """Créer l'effet kaléidoscope avec différents modes de fusion""" + + if blend_mode == "add": + result = np.zeros((h, w, 3), dtype=np.float32) + elif blend_mode == "max": + result = np.zeros((h, w, 3), dtype=np.uint8) + elif blend_mode == "average": + result = np.zeros((h, w, 3), dtype=np.float32) + sector_count = np.zeros((h, w, 1), dtype=np.float32) + else: # overlay + result = base_sector.astype(np.float32) + + for i in range(facettes): + # Calculer l'angle de rotation + angle = base_angle * i + rotation + rotation_matrix = cv2.getRotationMatrix2D(center, angle, 1) + rotated = cv2.warpAffine(base_sector, rotation_matrix, (w, h)) + + # Appliquer l'effet miroir selon le mode + if mirror_mode == "alternate" and i % 2 == 1: + rotated = cv2.flip(rotated, 1) + elif mirror_mode == "all": + rotated = cv2.flip(rotated, 1) + # mirror_mode == "none" : pas de miroir + + # Fusionner selon le mode de fusion + if blend_mode == "add": + result += rotated.astype(np.float32) + elif blend_mode == "max": + result = np.maximum(result, rotated) + elif blend_mode == "average": + mask = (rotated > 0).any(axis=2, keepdims=True) + result += rotated.astype(np.float32) * mask + sector_count += mask + elif blend_mode == "overlay": + # Mode overlay simplifié + mask = (rotated > 0).any(axis=2, keepdims=True) + overlay = rotated.astype(np.float32) / 255.0 + base = result / 255.0 + + overlayed = np.where(overlay < 0.5, + 2 * base * overlay, + 1 - 2 * (1 - base) * (1 - overlay)) + + result = np.where(mask, overlayed * 255, result) + + # Post-traitement selon le mode de fusion + if blend_mode == "add": + # Normaliser pour éviter la saturation + max_val = np.max(result) + if max_val > 255: + result = result * 255 / max_val + result = result.astype(np.uint8) + elif blend_mode == "average": + # Calculer la moyenne + sector_count = np.maximum(sector_count, 1) # Éviter la division par zéro + result = (result / sector_count).astype(np.uint8) + + return result + +class KaleidoscopeAdvancedNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "facettes": ("INT", {"default": 6, "min": 2, "max": 20}), + "pattern_type": (["triangle", "diamond", "hexagon", "custom"], {"default": "triangle"}), + }, + "optional": { + "center_x": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "center_y": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "inner_radius": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 1.0, "step": 0.05}), + "outer_radius": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 2.0, "step": 0.05}), + "rotation": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 360.0, "step": 1.0}), + "symmetry_break": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.05}), + "chromatic_aberration": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 10.0, "step": 0.5}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "generate_advanced_kaleidoscope" + CATEGORY = "Image Effects" + + def generate_advanced_kaleidoscope(self, image, facettes, pattern_type="triangle", + center_x=0.5, center_y=0.5, inner_radius=0.1, + outer_radius=1.0, rotation=0.0, symmetry_break=0.0, + chromatic_aberration=0.0): + + # Prendre la première image du batch + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + image_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = image_np.shape + + center = (int(w * center_x), int(h * center_y)) + + # Créer le masque selon le type de pattern + mask = self._create_pattern_mask(h, w, center, facettes, pattern_type, + inner_radius, outer_radius, rotation) + + # Appliquer l'aberration chromatique si demandée + if chromatic_aberration > 0: + image_np = self._apply_chromatic_aberration(image_np, chromatic_aberration) + + # Créer l'effet avec brisure de symétrie + result = self._create_advanced_effect(image_np, mask, facettes, center, + rotation, symmetry_break) + + result = np.clip(result, 0, 255).astype(np.float32) / 255.0 + result_tensor = torch.from_numpy(result).unsqueeze(0) + + return (result_tensor,) + + def _create_pattern_mask(self, h, w, center, facettes, pattern_type, + inner_radius, outer_radius, rotation): + """Créer différents types de masques""" + mask = np.zeros((h, w), dtype=np.uint8) + + max_radius = min(w, h) // 2 + inner_r = int(max_radius * inner_radius) + outer_r = int(max_radius * outer_radius) + + if pattern_type == "triangle": + # Masque triangulaire standard + sector_angle = np.radians(360 / facettes) + start_angle = np.radians(rotation) + + x1 = int(center[0] + outer_r * np.cos(start_angle)) + y1 = int(center[1] + outer_r * np.sin(start_angle)) + x2 = int(center[0] + outer_r * np.cos(start_angle + sector_angle)) + y2 = int(center[1] + outer_r * np.sin(start_angle + sector_angle)) + + points = np.array([center, (x1, y1), (x2, y2)], dtype=np.int32) + cv2.fillConvexPoly(mask, points, 255) + + elif pattern_type == "diamond": + # Masque en forme de diamant + sector_angle = np.radians(360 / facettes) + start_angle = np.radians(rotation) + + # Points du diamant + x1 = int(center[0] + outer_r * np.cos(start_angle)) + y1 = int(center[1] + outer_r * np.sin(start_angle)) + x2 = int(center[0] + inner_r * np.cos(start_angle + sector_angle/2)) + y2 = int(center[1] + inner_r * np.sin(start_angle + sector_angle/2)) + x3 = int(center[0] + outer_r * np.cos(start_angle + sector_angle)) + y3 = int(center[1] + outer_r * np.sin(start_angle + sector_angle)) + + points = np.array([(x1, y1), (x2, y2), (x3, y3), center], dtype=np.int32) + cv2.fillConvexPoly(mask, points, 255) + + # Créer un trou au centre si inner_radius > 0 + if inner_r > 0: + cv2.circle(mask, center, inner_r, 0, -1) + + return mask + + def _apply_chromatic_aberration(self, image, strength): + """Simuler l'aberration chromatique""" + h, w = image.shape[:2] + center = (w // 2, h // 2) + + # Séparer les canaux + r_channel = image[:, :, 0] + g_channel = image[:, :, 1] + b_channel = image[:, :, 2] + + # Appliquer un décalage différent à chaque canal + offset = int(strength) + + # Décaler le rouge vers l'extérieur + M_r = np.float32([[1, 0, offset], [0, 1, offset]]) + r_shifted = cv2.warpAffine(r_channel, M_r, (w, h)) + + # Décaler le bleu vers l'intérieur + M_b = np.float32([[1, 0, -offset], [0, 1, -offset]]) + b_shifted = cv2.warpAffine(b_channel, M_b, (w, h)) + + # Recombiner les canaux + result = np.stack([r_shifted, g_channel, b_shifted], axis=2) + + return result + + def _create_advanced_effect(self, image, mask, facettes, center, rotation, symmetry_break): + """Créer l'effet avec brisure de symétrie""" + h, w = image.shape[:2] + base_sector = cv2.bitwise_and(image, image, mask=mask) + result = np.zeros_like(image, dtype=np.float32) + + base_angle = 360 / facettes + + for i in range(facettes): + # Ajouter une variation aléatoire pour briser la symétrie + angle_variation = symmetry_break * 30 * (np.random.random() - 0.5) + angle = base_angle * i + rotation + angle_variation + + rotation_matrix = cv2.getRotationMatrix2D(center, angle, 1) + rotated = cv2.warpAffine(base_sector, rotation_matrix, (w, h)) + + # Effet miroir alterné avec variation + if i % 2 == 1: + if symmetry_break > 0.5: + # Parfois ne pas appliquer le miroir pour plus de chaos + if np.random.random() > symmetry_break: + rotated = cv2.flip(rotated, 1) + else: + rotated = cv2.flip(rotated, 1) + + result += rotated.astype(np.float32) + + # Normaliser + max_val = np.max(result) + if max_val > 255: + result = result * 255 / max_val + + return result.astype(np.uint8) diff --git a/deformation/__init__.py b/deformation/__init__.py new file mode 100644 index 0000000..2f66eb9 --- /dev/null +++ b/deformation/__init__.py @@ -0,0 +1 @@ +"""Effets de déformation d'image""" diff --git a/deformation/__pycache__/__init__.cpython-312.pyc b/deformation/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..54e2fa0125cede27db663ff7b28f87a2f5793bba GIT binary patch literal 219 zcmX@j%ge<81Syh$~>zbC9T2ic#lB$q$ zcx76CQEp;MW`3SRih5>lVtT5dCgUyk`1q9kATO{*-;R47R; zE{SmlaY8*~l1qzA@^j5caW5C+uQ-P)@mlVgOq{5AkiI30B%PfhH*DI*J#bJ}1 fpHiBWYFEStG#cd2Vvrj@Ff%eT-eOQGVgYgh7AQTv literal 0 HcmV?d00001 diff --git a/deformation/__pycache__/barrel_distortion_node.cpython-312.pyc b/deformation/__pycache__/barrel_distortion_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9f538632d005c828f1c3af8b317b60ea4dec9c66 GIT binary patch literal 3090 zcmb_eO>7&-72Y2%xm=2*xDqK+mMA(-TG24nNNx=y2BE~+bXn?{usWwocm-fw) zYch(O_Rt~b&G&xhy|?r8hJSE61qxb^aAx7Bfc{Dtvss$p>B|7tDMTSUMa5`iWMYgl zvN4vSE>ehDr4W0UY0QXO3Ut`=gfNjrL)4&6J=OFm8{T4!%p!V~y4$pjSr8NB5F4{1 zOU#CBh+Cy&e8C##arc;{D01rjqN-&TZ84jPWRaYp+d|j@v(TR^Kp>sve`|ts^EbOcx%ijj>bbB5+mM`;@+l3o>BS5X=?1o_nw)FSqA0N8 z7SqzajG6a_p^rd6-E_5gt{LTXGYa)un8kcT&S7|Io}V>0sAbTl5FeQV-sF?7_f4z*cWAsh7; zkQHj5r+Qzk_XM}bA6On<-JW{87P_|WoB3ZP2X-#t+ILaGm+qfg?*~SE<7b$sF1vHkCe-$3h{4z zYtcDLb}KZ|SeOJ)rZk%kM&p#;1G-LW&rh&UFPV45w1x3*Q;+EP21!^QPIUm}B|gLG zbcxsbC9ci93C;VoM4}yPrVg=1n7Kr5&7r0lb%6+%Iu5CTH8U#Nb^B2n&%t+WWYw7lCN?^&_n7v&MfaPy6abF_!s#}{Loax4=a1>VkPFc}4L@$k zs4L05N_-w`YFvhuwzJ=0y%vD(Oo2WJFcHOOaYCco4)N7*F*rx4kNlkRm zb{8@ut<`_3Qmf8tS_}@`OB# z3c00E{sjaoCWo{_PJUvD$V~;2@NQN?vhpSr$VLI*%f1#CF`JSzP=ObuoD6j~m&aTt zpN4X1l~m#aGnpLb@{1YmEar-`l2x%SnaWDq=@EroLd+>r1|9%5@skn2a$3saj>$-L zVk&-lGBPnbgHaGH^DL_}?@lw7I{R|dbuLI~2=n=LmvPzypXJc%}RfCX7=v$jY zj+)XBq1u#yR?5@0&R6*A@#>GNzF#W8Dt=u2vU8-&J?<2bl*0^Ppz5iP z{(N@RQ@LIizVi6imOt{BM<088tHU47tkHYEp6Uyg-cya4 zEe^B;$2VWu$lhma!I3p?O{#jDa%;pca zUiz$ScX+fmJo?APZseU>3di(8bJN}unv)}f%dGNw^%*W#yDJ{q2Scu2d zS(Hx^+!2p2=cQCr<4B|=RZYv@$6nutzMMH>HJFn?h>G9I1062biVXmsM@ zm8n_d1S+I78lfAf(kL5*kkY1}1hPuixy}Q|$q7$c%IeuCXg^4G2UjlF-GeKy*WJOD z*PmI2xKP6lnH=U6_zOTOWWI8mkna&CWCFgvfrD^&RB|f4$ S9#O(SEaNmC`a4Ca5#`^LDfNc{ literal 0 HcmV?d00001 diff --git a/deformation/__pycache__/fisheye_node.cpython-312.pyc b/deformation/__pycache__/fisheye_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..af7a275e23a7e5a1262848962700e0df0d3bdd55 GIT binary patch literal 4561 zcmb^!ZERZC^*!M64F(JZY~wgd^Fi9?vx%~`YwaXBb()aIvD1XOrG)#yP(JMY9%-Qu z)vBV2NRZfR#r@Kp~IH zfKtJnV36V}gOs-vjU541mi4Nh(uqIZSZd%VpIJT(^J#{cwFMNs60ujATTRJ;3Mm2{ zQU=sW70@6p;;ym*EmB`)0=lfmt0RWNm@v*~d4C%5VYW@b4rnF(mQ#ShVHPQn5~&b} z)JSt(6;VVPr2QR>bVwg#BMM|d#;5{j26<+>4u+8#b%3;`A-};~jjYH9KUp8@yskv{ z>xy%X*Wtg(5^m`9zzHAW22Y(C7>UZBO-1?}1oLW>n7Y#cPnpFmz2;RB4dNrAOhP2e zWGn?J^Z`)`B0m8v13#IGp_m|sQX)=vWJqi}_V~4?&kPg& z(Ei60f6HBcE&AIT3SX_T`n^if%csD4@Pid9OSFQB`BYRK_bP}MQ?D_Ph&DYT#?q-! z;wIx|i87oJ1p4Xv{_Q{h^RaL`8Odg1eIhT2W5?)ZbZ9J`5yW&dn1Zh?jLAL+`ACEh zi^3ROnd~ko9E&s_gDkgaB1;TI{x?TQf+H8-^qtFXYI`bs+8AmuE&LMD6tkpdx}K}M zHqVVNy!3#pycMW!xm4LSMtUw*#`sE9tX#@ey06rGcFuKGx?h^l-haMw*k9>7^GwIN z%}Zvccl-Rdh46!ImA5WccZ^kf-mZI|`{9*9b;qSj&)74K%58quVYKL%7^6$IK@!I2Aq+Y`_i`KS%`c`^EsvO);zC&W-oh|-dhUs5l#s?mO=<9m9VhJjgbfo#_yhQ5 zi5_xd5Qk7KBM>#_1)3~Pjj_2`uz|9^?*ZG$DpSV1Wu3c5v zt~uu&&rQ!g{AKQLuI4>@f3)g7S#!Oy=o+oMMl0vvs<|!{Om&N=(y_Us-Hf-wF~jX~ zO5e7eL$1S9nr0w#z`}Z_eTg!}X;NDUw-v2O z%p3l%+Kg@5+B0R-zQnD>kV&L(T?R?lT0PDb*|$q@N^=`mzlQsfoHrmhZS$T+>pEDmq;1cHO>HMZn@-lYSyKIvde{y0>;OG_S@*5Q+uGFY_&$>&4slVzw31)pN2T zXnyF}Q}^t=z4gwHn>*&63p=a(2ksA4_xozQ2dkdJhET^t$KIdNRvZN9teeWas+O%q zHPG(v^7+p%e-XJ6nH&9mvg$clbsa2nbywF$%TIdyK8wyoXZg9a^X&YEmEvn=E6T~ac(uJ10ejLk9aF1gExX1C8d?kN}d^;h@x|MlGB>0tGA z@N4V4&^~oI3MRQ%@q3k+rnv^9aqiUZ43q{GoeJ2V+bcg zf{^6lZ!*GLU>`9K`$k5G{f+;HL_0X@KQ=OS#!s}z21a}*&J16a`$SCNF{ar{w%-T? z+U8)2XhstD0=ml7Im|EdS3x_#pLl@QH zS HvX%b=T39Kz literal 0 HcmV?d00001 diff --git a/deformation/__pycache__/pinch_node.cpython-312.pyc b/deformation/__pycache__/pinch_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..038fac45a1f3021bf11a091963880917d0ceffe1 GIT binary patch literal 3635 zcmb^zTWk~Ab!P0b$FC%I9y@s(lF*p4(FO|3wy>;$1i~f;Lc+sj4hW^8`=uWQ6dO+DiE;Z8i7} z6mf>&sk;PkSf)xEe&a0ZF}}u#FIXa#<}#lsTNE^Feo8R# zn|U)&-zELbtjWV5%Y|?(IOU7;LXfPoYXR5*|F<__f%x<`D!vQ)q52;Ipr`ESbB6?`Vdu9O9gTv>&WB({cvvFdw1Q5@4 z;FHN;FW~Xb36Bw(c_G9lBQi2X!!ek+TgWKMLZU3Zd7>=5bi!jmbU4aQ3W$+pQHV{- zQ^+K8d^joTmWj#R4ZlD0x71xM%mjs)EQo29twFV#E8O7GAs%_V&Vxo z9FK94CBj1@LogyqIF;^y51;<|L@*u=%_hTcS&-z3Qy3W^ng}K(IUWtfAnVeE7E?e7 zg@m9iO~7{IVwA%MCldH*G^xJCEV2#xE{u-_#;#o$xRh$#I<~$lvI*QuH(|O%Y%)Yk zcd@;DrRy30;?U~V>*~IV)wa)yt$SAt&rZECtd3t(yZx&z*WZ|p%{7~LqN4{ygD<*P zujrzh@-|9a*Gm1fOE2nIFJDu8{Hv`ckcd!7}BT6$0vs=Uyu)DM+ZB5%0t761H z(ssq7&>G~BYjumjwdl8PX^p=c4MYh-{+W4caDvFR zwK};`_W{wD&*n6dB|n1)F72%1TDNgKfa_9RiaiCnP1h?<#i2nBRnax+mMg1Luhiqb zDGf>kUQ@u*?WP-*MxMe|A<;^sQuD=WsE=CkQ47{xA?g^&0#sMQ?hjx&oauCvVgg;P z(axKyVqh!2bx3LAO^OL?={eORe=i14qtaA@6?h&sYZjnU%6nR@c&BX43HDpJY*Bjj zWf@?kS%`|&s-w9ok~UqZvO*T>^%&AEie0IehYar=N{jAI&t1Azact9a@6>^u{jXMA zRa|>_(P-r<#mbv-E|&4#hEaVAKcd|R+K{(x+Eu5PCGYPsLWSKwWF+LCl6AP5`*f(X zDhzn7le=}wG}OkeHdhsLlHvbc9G~m=@n_Bd|sMM!xqeU8df5S=vXoeJ;uaISQ}BX1frASn0!>kj}M|#f*6;e{YB!O zeCQyeMJ@)7)F{n}GNKdln}Uc8+zpW6#GuT@4uVyXH%QQvjloDb0r)Smnh@m@$Ufv7 z8yF1?5BUbXqp0TO`O(vWjt&eC51bl9v>X?MQ^*<;af{T#vLs{DOi~b1f~OOl3Xzal z17V5y@f7i!C_3?kb1c@wgskD{WIz^TQd~qP48wlNDMa2xWDrroTkxMnj#AGJl)eEF z4WU7^*OpmC!VkoF{GUY(AC_QSK>;~p(4GTZXifQpAQ0k$5I3@ydbWAdJC@o`Ae$ngEi+-)p_)f zu0OHgum$JIjH&2i=MT&sxM$9gnf?s7X(DQBv+TWz3{`a0lz?|8qPZ>S&CUGAvgloK znXd$uDt);`TWYCTMM2M zwP$3`2NL^!l5bagj$|?1(3ayK9NXyX&NC|?tG!;e>*U-p(7St{81iSA9nWgi-a)nN zOcoQ}z4-%b=aH;^oo#t!U9dvjVu9VCr8n4?+{BZUPY0I_=I;xI6h( zhx^gw!sMcmzqmrKc)y|x9Y?ZsmdiP_{G4t52e`koeLq4HO&yyC!qxI_j3iu*^UZV3 zxg(2*^F3eJul0M?e(zs{YrfA_-{;>sxs2%*+rGy3s%&q*>1q2?``6+hQeUMCo|DhV zRnNHs`{^1xuCn8+m#-Aqs~JbJu6?z(bCu~7TcGxQ9)pNKrz(@jf|x)c#zlod09gZp zXq-<*Fl-M5W|CZ_%&`R{oFqjBc`D9}xQdZ|bYN_J)K~iWMa8CK=C5m+28_G^w-x>&$a}z_GZA3%Io#A3_+nuw; z+KxMC-x<9m?Jn8D+8$cO-9^OhLp+M7pW#WfzTG2Yj+M6bv3MOs@Ea`QOl%rRl6*agua@PvBYe@?Tm(Ui$z5 literal 0 HcmV?d00001 diff --git a/deformation/__pycache__/ripple_node.cpython-312.pyc b/deformation/__pycache__/ripple_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a8173750f7f97ec035dab5c2380a27030ab966e0 GIT binary patch literal 4006 zcmb_fdu$Ww5#P1Fj$e7$c{vY1xIl<=l#<+q3ls|r1lRlJ}K6cKT;K`Lj5NkNR_He?R;x5 zHYq2ns-NPS`DSK+Gv9mi$MSL=25nGxc;-jt81@&`k(;U@yf_5H5=LPZ9>B(Ni7Unx z5?78Z6<9w;DQ;nu@}469#keYlJ5=!ARYnoF@NrE{?a+w2VSgkNpxt4L_TnYbXcS8Ly|@Vka>XeHrKD68 zL8+;-J1U<7`Wi}0>EI{zr!h+ZDNY%vaz75%iaQXMs)R4pRMo35cK20aHv)!?i26l; z6JS)|QC!3vHSRTBBnHnq`&}Z@H+0T9G7V`#d;jEL^8$tyG*@KJPLD$+mQl2ij0QMS z8T5yMkn^I7<><)tjhbKf|2h8Cp6P$CJ%4<5>U_`i&zDjAvd5tW7k`Kr)!wkgRa0SZ zhS5VDrV$Qu*2*U67)4;PEjh19lt3ej%$OxwQy zw+kEAhLZrI@zNoVW<2w+q?kxVW=NJ630Mp==1_o2(-jKoC!p!@vjh-c)=dDq2wU2$_9EYYhWF#9tgH?bWJ*0?uk-M9Y!L**k^ z=IZx^{ho|vGOr;Xc{5ihh5cm4G9_BOH;z2?WsaWBSlrLGL_@_+1qR=>Y8i*K(G(?D3QYrC+U_|5ZFla~QQ+}e`H;!J!0#386#iiwip9h6 zXaIde8&dFiLd$Ds0cUrU59DmwZVMn5RFpCfU(S~1Rh4mq&RTOXAm-VGO1ctKl{455QW@SMwEdn72d?Z{jN@skS7$8X39B`5L|k%?V%2 z*CHMVlZ>0FqliR3ub~tlo`zLF{uU$yZ;+7mynZ(lrH;dlC(I=Xb;TTHiPz{czD~k0 zBmY-<)blE;Oxk(PB?xBuyCUc7cxgZJWv`8+A#@cGH2;CnSo~6!z7gT*{}Q?6oBui% z%DgaN4ZMP{m*%4Z(L^I(S6GYw669t%_acYYta-IRoA|~O?>FVqB+s-IrTI1gH_h_v z{+19tee-ytIbkV@V=ZPQORzherO_#P1Eoc~^B%f`pz&3(U#oqJV<5i;!^B$(82Vip z#v(=%|M-V0I5QvLQDNL3DFl$&cVwx^c^$+ya&}oWyH}Qqyu{jC)XLK5x_huTs3okN zBQHU$c3CQN-ujk%?hW^hyfT!&^hC^ltB5K~ltT7R*m#={hcd$8euio=2Hoh-{sjb{ zQi4KHHG?kKXGkx-@eGRm3|;czp_>Xb6wRE4hcsVfY==)e>P2OM4v7RiLq=$Lk0Vi$ z2t|YNf~rXtA&QDnL?ojA5Z5gdaheIUVwo=xCb{=IMS>wiP(P^HIR+{ddOjj5q0#^p zcxGU)PSY7wbN1np}EyiFcymTk}7>&w?y=F-MhHhm#Iw{~$gmbG=PI|W-;a$u`v-)Dzc52w4* zt?S)F`*${Gg!aB{OMh|*U}`KY>c`csTNc~hvE{LqfpkpR+r7~x>^+&aI2ZeJwbqrk z$F=R>WPfV4-}No~RxYn=g?&dhT*5wQ*4q2~;XjQ1ZtT%O=F+4vG@0?v2tzYjmtU~@ zGr^Ex2_^d=^8Q2X2ZYuW59@^1{!CR1%B49)esXN9*?RZP@|hJT-I;AZm~`cAoeze; z7`s2V;rjJ8!Pc7`*sWO{*lvF}Q}b?eAT^H?nqHn>A=7QEfpzEl+(yU!Xx4f>NhHaX zF-a}zx8CCM6aCjt9ILbLC^1vx%K;oS)h;zGHlz-(bfxz_sNU>w3LVZrc{kmYf_w5S zBe|e{YPM{e+XQo4y6#t&HOm9$i}?L`*3tWLRB)Whn!meg9u>@^nXyY*^W}x|T$LqL zY0GGA47$nP4i$rHhde=bBZG)TD{4HR5E-OB9#QA<1jAG`fMkQmGZ!TT1&iJrAXzp@ zb2DLzvBP(wVc0b?I_%E>2Ow(tM%|}I2G6-g%_--ItN+~a73uge78D4@&mSr2T15n1 z{cH!2TUd^$dakG-bT5>c+PH(H9XnQIy*ZStwA>uXRa$Qjz?${qF8rnP2w~5oAq)q> zp!=NpHXj^j@-(-X|_1k?Rpbq>evUt@?$ GQT`3{)Vogr literal 0 HcmV?d00001 diff --git a/deformation/__pycache__/spherize_node.cpython-312.pyc b/deformation/__pycache__/spherize_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c18b85e6fa30eaecc8a1a7847d24f664e3e9c007 GIT binary patch literal 4438 zcmc&1TWk~Ab!P0b9Y2!TPMmRGI1jQhEYT)g!lS#e0SttPfsn8v4K;&joH+QEnennQ zV~I+97>TOR@)2i+M4}>L8r027E0vWhHJ_;MhmDcq4*5t`q>8_=AgxrXYR?^eVkZh9 zwUv66+;i?Z=bqO+bMNt=OeQ@Bkc!l7cWz-16&3FuP?&{zTynQs2DXvG8#sE zSM4KUtpmKYuDGj`W%NKdtjnmF^0kpMjS);GWAx*op$c|X-zCmtPP6+VE|LAG`;NIJ z^605EeS?1ovoKZ% zR7s>iNRP3Sju$vKG$xEo8jfcCQC?Aqja{$#?XkbcZXN`gVm>5;cPiiyF`VB^2RL-3 zph)LsLjucrzFfzqBy|un=OiQ@$4*B59Lq?$a76HjLv-LF=ENnHH^B2Kv5tS7IkJD$ z8xH!WqJD?K^1|q0v>56i^+tIi9Q1@h6F(|D>tTI9)+_L%5MG!I(n#T`;+rhk6`7I@ z{qD0vgPy^QXIM$4 z{?ZX~!nlbAOU0wOPSkDX@hC(WuZZj822n4nk&U=ftPn{VGYN2a6b5iBlzV_t#|#)I z${xil1;7hqqFTX95OnE%M46A45>LS%k&5Pc)&EpmwNR_r0Pl-ua|BBgms%iBa~W5^ zfeTT`Euuv<#vr$Gt7sNYGFDUOolVhPf}&NlqP&SUVhw^ZU@3azwPGzppsL_Wu~w|S zdIajD3T#w?c8ibr1Y`lLF0F36kQ~Z%+$L%mja*Sb72(E{_floPP$eG}Otgu_dRFU_ zc&L`qzIz9w6>TU=kuYG&PC4qj(2qjHx?ZaiQ5mg9p~;>_)_rV!x5Y9I>skvDmwXR( zzTfY+>)ARU(F!>nh5Qp@9ekH{1}t7D8pS%WT|Bk7pGp$HZT=79ThGvc8Q&&7Z;7vF zi}*HHnrTC{WmO09{X*fz%WJGrselrMI=3p761PJJ%jKI(atbEel{!)K9IqEm8+JJq z*%I7hS3_A{wU#6mOsr=JQO{^mb{5d>4Ws---?O^`c2{iLU7jK0wnYth$nADDCTuTg zhr72!!Aelnfz@iEU7<|C*S~p`ad&LyVb~+x0#qa#@kU`+0RvlI3RZ$*<9ptQE$&_A zG?)r>%P6af?vhpoV+e&CZxWk)IP^+-_e<1Ba_AL&`7bmf3|bA65vC$6XGSD+*8YQ~@?5k^DFvVWB~ZL?tp54MIbqp?Rc2B0>>~jQT^uUJku6 z5*cH;FfVC+fiNw6+#``39fFQZ%};WIL`K5bSWZ&WS3rX1yaFBS0j(gf;-M!~djtLm ztiM3p*dQH|jQ#FG*MR3#zuVO}AXOeZGjIf!1FoZ|T!#lGQV4V2aY^svP)j7cRS^yG zlTnt9vCd{N$_9K~CAh_*HD4!buyTeaQFim2K`S-I>ygKOkP$pg`gMntS>b_+6&G~#t?h0 zL?t0L0Twa}M9_!;B$i`&)TNpVu;?3)K2^s%2Um}RYJw@&u%W@**h!&Qhfl*7cLQ57 zR3@Uox_;|=&d`vWd}?sy?aislS?bn_ReN*d#Hyunrfa$@XK7uwbY?A`Im?dp`K;xm zW%J&wdGGHnf1tjna^^z`P2NJyd^r8#Eo}l%bSLOF4OUr|q;8ESh`gz?fV|OQHhapP z>brAs&OCi-?n1V9XF?BDvvo!{txL6}&ZTVkxd*ZN*t6=M1i4ae*^>UkY)ekgP>H^k z+WJ)Y%y1H4t+k~r(-)FNUO`qGn(pnH-80uScV&Kex+i_*;qLiQa}C`|-KrJh{OZ%W z%*wxmn8+&+DB-N>UZF4POi7#5(9#HdC zde>tr*K+7<4T!e3-5+=`JU^VK7k6d54lZ>r*>l@ZW?N3qY9L0cA=Pu|+I{QXz_06` z)^)C)9?A?~{Nw0-3sUo_Eq!I-gT)is9S4?l&szF3p3Bd??6UW2)_XM*2tJGO8R3gu zJs{MO_b&5nCxvtx5?`rIPE*!L}& zYwk^wNjhatGSh~YcX00*e(>O!t$9s_S?b^TaLiIWW1qICdgpehw|{3{?(WNW_x;(s z?7p0JUw&q$6Pg!P(=yeWr8?8LZ<-!9eaHPS_H8WZJhU{Fb)LvkCzq+AEH#w5@Oh3J zPMGr5O_{1~8QnGxz3OhKibKCE3KP1wofVSK;|bA0*5i@%9uNGuj0O;H^mrztbfCyF zcmp)g2U%e}%y6hfl#Bze!Jz?n;kTfqJ38b(JlKE6E$I&T4Z4n<8Mr9lAsp&gWz&Uw zMSe%ngx)%SCrmf7JX!sUFp~P$DokTuL)cm)R?~drRK9B4jpO;M<{QV~s88Ueqo4=v zc9I-=U^(=Ta{JNLizZq7MvIc}lR`)bIA{Yg^h)vQhFeqNIQ|^7JjaaBG5wF~pX0dW K2Mo=!m45@4Di4zY literal 0 HcmV?d00001 diff --git a/deformation/barrel_distortion_node.py b/deformation/barrel_distortion_node.py new file mode 100644 index 0000000..d376fb1 --- /dev/null +++ b/deformation/barrel_distortion_node.py @@ -0,0 +1,72 @@ +import numpy as np +import torch +import cv2 + +class BarrelDistortionNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "k1": ("FLOAT", {"default": 0.2, "min": -1.0, "max": 1.0, "step": 0.01}), + "k2": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}), + "k3": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}), + }, + "optional": { + "center_x": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "center_y": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "scale": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 2.0, "step": 0.1}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_barrel_distortion" + CATEGORY = "Image Effects" + + def apply_barrel_distortion(self, image, k1, k2, k3, center_x=0.5, center_y=0.5, scale=1.0): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + + # Centre de distorsion + cx = w * center_x + cy = h * center_y + + # Créer les grilles de coordonnées + map_x = np.zeros((h, w), dtype=np.float32) + map_y = np.zeros((h, w), dtype=np.float32) + + # Normalisation + max_radius = max(w, h) / 2 + + for y in range(h): + for x in range(w): + # Coordonnées normalisées par rapport au centre + xu = (x - cx) / max_radius + yu = (y - cy) / max_radius + + # Distance radiale + r2 = xu*xu + yu*yu + r4 = r2*r2 + r6 = r4*r2 + + # Facteur de distorsion + distortion = 1 + k1*r2 + k2*r4 + k3*r6 + + # Nouvelles coordonnées + xd = xu * distortion * scale + yd = yu * distortion * scale + + # Reconvertir en coordonnées image + map_x[y, x] = xd * max_radius + cx + map_y[y, x] = yd * max_radius + cy + + # Appliquer la transformation + result = cv2.remap(img_np, map_x, map_y, cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) diff --git a/deformation/fisheye_node.py b/deformation/fisheye_node.py new file mode 100644 index 0000000..aced80d --- /dev/null +++ b/deformation/fisheye_node.py @@ -0,0 +1,102 @@ +import numpy as np +import torch +import cv2 + +class FisheyeNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "fov": ("FLOAT", {"default": 180.0, "min": 30.0, "max": 360.0, "step": 1.0}), + "mapping": (["equidistant", "equisolid", "orthographic", "stereographic"], {"default": "equidistant"}), + "format": (["fullframe", "circular"], {"default": "fullframe"}), + }, + "optional": { + "center_x": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "center_y": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "strength": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 2.0, "step": 0.1}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_fisheye" + CATEGORY = "Image Effects" + + def apply_fisheye(self, image, fov, mapping, format, center_x=0.5, center_y=0.5, strength=1.0): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + + # Centre de l'effet + cx = int(w * center_x) + cy = int(h * center_y) + + # Rayon maximum + max_radius = min(w, h) // 2 + + # Créer les grilles de coordonnées + result = self._apply_fisheye_distortion(img_np, cx, cy, max_radius, fov, mapping, format, strength) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) + + def _apply_fisheye_distortion(self, image, cx, cy, max_radius, fov, mapping, format, strength): + h, w = image.shape[:2] + + # Créer les grilles de coordonnées + map_x = np.zeros((h, w), dtype=np.float32) + map_y = np.zeros((h, w), dtype=np.float32) + + fov_rad = np.radians(fov) + + for y in range(h): + for x in range(w): + # Distance du centre + dx = x - cx + dy = y - cy + r = np.sqrt(dx*dx + dy*dy) + + if r == 0: + map_x[y, x] = x + map_y[y, x] = y + continue + + # Angle + theta = np.arctan2(dy, dx) + + # Normaliser le rayon + r_norm = r / max_radius + + if format == "circular" and r_norm > 1.0: + map_x[y, x] = x + map_y[y, x] = y + continue + + # Appliquer la projection selon le mapping + if mapping == "equidistant": + r_fish = r_norm * fov_rad / (2 * np.pi) * max_radius + elif mapping == "equisolid": + r_fish = 2 * max_radius * np.sin(r_norm * fov_rad / 4) + elif mapping == "orthographic": + r_fish = max_radius * np.sin(r_norm * fov_rad / 2) + elif mapping == "stereographic": + r_fish = 2 * max_radius * np.tan(r_norm * fov_rad / 4) + + # Appliquer la force + r_fish *= strength + + # Nouvelles coordonnées + new_x = cx + r_fish * np.cos(theta) + new_y = cy + r_fish * np.sin(theta) + + map_x[y, x] = np.clip(new_x, 0, w - 1) + map_y[y, x] = np.clip(new_y, 0, h - 1) + + # Appliquer la transformation + result = cv2.remap(image, map_x, map_y, cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT) + return result diff --git a/deformation/pinch_node.py b/deformation/pinch_node.py new file mode 100644 index 0000000..7fb6b00 --- /dev/null +++ b/deformation/pinch_node.py @@ -0,0 +1,90 @@ +import numpy as np +import torch +import cv2 + +class PinchNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "strength": ("FLOAT", {"default": 0.5, "min": -2.0, "max": 2.0, "step": 0.1}), + "radius": ("FLOAT", {"default": 0.5, "min": 0.1, "max": 1.0, "step": 0.05}), + }, + "optional": { + "center_x": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "center_y": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "falloff": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.1}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_pinch" + CATEGORY = "Image Effects" + + def apply_pinch(self, image, strength, radius, center_x=0.5, center_y=0.5, falloff=0.5): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + + # Centre de l'effet + cx = w * center_x + cy = h * center_y + + # Rayon effectif + effect_radius = min(w, h) / 2 * radius + + # Créer les grilles de coordonnées + map_x = np.zeros((h, w), dtype=np.float32) + map_y = np.zeros((h, w), dtype=np.float32) + + for y in range(h): + for x in range(w): + # Distance du centre + dx = x - cx + dy = y - cy + distance = np.sqrt(dx*dx + dy*dy) + + if distance == 0 or distance > effect_radius: + map_x[y, x] = x + map_y[y, x] = y + continue + + # Facteur de distance normalisé + norm_distance = distance / effect_radius + + # Calcul du facteur de pincement avec falloff + if falloff > 0: + # Falloff doux + falloff_factor = 1 - np.power(norm_distance, 1 / falloff) + else: + # Falloff linéaire + falloff_factor = 1 - norm_distance + + # Facteur de pincement + if strength > 0: + # Pincement vers l'intérieur + pinch_factor = 1 - (strength * falloff_factor) + else: + # Étirement vers l'extérieur + pinch_factor = 1 + (abs(strength) * falloff_factor) + + # Nouvelles coordonnées + new_distance = distance * pinch_factor + angle = np.arctan2(dy, dx) + + new_x = cx + new_distance * np.cos(angle) + new_y = cy + new_distance * np.sin(angle) + + map_x[y, x] = np.clip(new_x, 0, w - 1) + map_y[y, x] = np.clip(new_y, 0, h - 1) + + # Appliquer la transformation + result = cv2.remap(img_np, map_x, map_y, cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) diff --git a/deformation/ripple_node.py b/deformation/ripple_node.py new file mode 100644 index 0000000..cbef982 --- /dev/null +++ b/deformation/ripple_node.py @@ -0,0 +1,84 @@ +import numpy as np +import torch +import cv2 + +class RippleNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "amplitude": ("FLOAT", {"default": 20.0, "min": 0.0, "max": 100.0, "step": 1.0}), + "frequency": ("FLOAT", {"default": 0.02, "min": 0.001, "max": 0.1, "step": 0.001}), + "wave_type": (["sine", "cosine", "both"], {"default": "sine"}), + }, + "optional": { + "center_x": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "center_y": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "phase": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 6.28, "step": 0.1}), + "decay": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_ripple" + CATEGORY = "Image Effects" + + def apply_ripple(self, image, amplitude, frequency, wave_type, + center_x=0.5, center_y=0.5, phase=0.0, decay=0.0): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + + # Centre des ondulations + cx = w * center_x + cy = h * center_y + + # Créer les grilles de coordonnées + map_x = np.zeros((h, w), dtype=np.float32) + map_y = np.zeros((h, w), dtype=np.float32) + + for y in range(h): + for x in range(w): + # Distance du centre + dx = x - cx + dy = y - cy + distance = np.sqrt(dx*dx + dy*dy) + + # Facteur de décroissance + decay_factor = 1.0 + if decay > 0: + max_dist = np.sqrt(w*w + h*h) / 2 + decay_factor = np.exp(-decay * distance / max_dist) + + # Calcul de l'ondulation + if wave_type == "sine": + ripple = np.sin(distance * frequency + phase) + elif wave_type == "cosine": + ripple = np.cos(distance * frequency + phase) + else: # both + ripple = (np.sin(distance * frequency + phase) + np.cos(distance * frequency + phase)) / 2 + + # Amplitude avec décroissance + effective_amplitude = amplitude * decay_factor * ripple + + # Direction de l'ondulation (radiale) + if distance > 0: + angle = np.arctan2(dy, dx) + offset_x = effective_amplitude * np.cos(angle) + offset_y = effective_amplitude * np.sin(angle) + else: + offset_x = offset_y = 0 + + map_x[y, x] = np.clip(x + offset_x, 0, w - 1) + map_y[y, x] = np.clip(y + offset_y, 0, h - 1) + + # Appliquer la transformation + result = cv2.remap(img_np, map_x, map_y, cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) diff --git a/deformation/spherize_node.py b/deformation/spherize_node.py new file mode 100644 index 0000000..bfaa123 --- /dev/null +++ b/deformation/spherize_node.py @@ -0,0 +1,99 @@ +import numpy as np +import torch +import cv2 + +class SpherizeNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "strength": ("FLOAT", {"default": 0.5, "min": -2.0, "max": 2.0, "step": 0.1}), + "radius": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 2.0, "step": 0.1}), + }, + "optional": { + "center_x": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "center_y": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "mode": (["spherize", "cylindrical"], {"default": "spherize"}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_spherize" + CATEGORY = "Image Effects" + + def apply_spherize(self, image, strength, radius, center_x=0.5, center_y=0.5, mode="spherize"): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + + # Centre de l'effet + cx = w * center_x + cy = h * center_y + + # Rayon effectif + max_radius = min(w, h) / 2 * radius + + # Créer les grilles de coordonnées + map_x = np.zeros((h, w), dtype=np.float32) + map_y = np.zeros((h, w), dtype=np.float32) + + for y in range(h): + for x in range(w): + # Distance du centre + dx = x - cx + dy = y - cy + distance = np.sqrt(dx*dx + dy*dy) + + if distance == 0 or distance > max_radius: + map_x[y, x] = x + map_y[y, x] = y + continue + + # Normaliser la distance + norm_distance = distance / max_radius + + if mode == "spherize": + # Effet sphère + if strength > 0: + # Convexe (vers l'extérieur) + factor = np.power(norm_distance, strength) + else: + # Concave (vers l'intérieur) + factor = 1 - np.power(1 - norm_distance, -strength) + else: + # Effet cylindrique (seulement horizontal ou vertical) + if abs(dx) > abs(dy): + # Déformation horizontale + factor = np.power(abs(dx) / max_radius, strength) if strength > 0 else 1 - np.power(1 - abs(dx) / max_radius, -strength) + factor = factor if dx >= 0 else -factor + map_x[y, x] = cx + factor * max_radius + map_y[y, x] = y + continue + else: + # Déformation verticale + factor = np.power(abs(dy) / max_radius, strength) if strength > 0 else 1 - np.power(1 - abs(dy) / max_radius, -strength) + factor = factor if dy >= 0 else -factor + map_x[y, x] = x + map_y[y, x] = cy + factor * max_radius + continue + + # Nouvelles coordonnées + new_distance = factor * max_radius + angle = np.arctan2(dy, dx) + + new_x = cx + new_distance * np.cos(angle) + new_y = cy + new_distance * np.sin(angle) + + map_x[y, x] = np.clip(new_x, 0, w - 1) + map_y[y, x] = np.clip(new_y, 0, h - 1) + + # Appliquer la transformation + result = cv2.remap(img_np, map_x, map_y, cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) diff --git a/geometric/__init__.py b/geometric/__init__.py new file mode 100644 index 0000000..9829435 --- /dev/null +++ b/geometric/__init__.py @@ -0,0 +1 @@ +"""Effets géométriques et patterns""" diff --git a/geometric/__pycache__/__init__.cpython-312.pyc b/geometric/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c9fe7ba2de7f22635c790134b306571f5777f667 GIT binary patch literal 220 zcmXwzF$w}P5JeLe6~QyORT1|L|XVb$z+AgzZ_$#li96xEXnS!OYQxB9lXG6PQPJ}71+U&f5&!@I literal 0 HcmV?d00001 diff --git a/geometric/__pycache__/crystallize_node.cpython-312.pyc b/geometric/__pycache__/crystallize_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..78a2911cff70f3154b1661c82238fee2175524d9 GIT binary patch literal 13416 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 literal 0 HcmV?d00001 diff --git a/geometric/__pycache__/hexagonal_pixelate_node.cpython-312.pyc b/geometric/__pycache__/hexagonal_pixelate_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6e836d4dbe33e2841d9e0b6dda38e574767491fb GIT binary patch literal 6641 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 E05<*q0ssI2 literal 0 HcmV?d00001 diff --git a/geometric/__pycache__/polygon_node.cpython-312.pyc b/geometric/__pycache__/polygon_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..73de18f00bc6b5410208edfb8642bd94a091687b GIT binary patch literal 6317 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 literal 0 HcmV?d00001 diff --git a/geometric/__pycache__/triangulate_node.cpython-312.pyc b/geometric/__pycache__/triangulate_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e5bebfe7ab270a8d28b92a7b0aaaf04f3531d6aa GIT binary patch literal 8466 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 literal 0 HcmV?d00001 diff --git a/geometric/crystallize_node.py b/geometric/crystallize_node.py new file mode 100644 index 0000000..71d25df --- /dev/null +++ b/geometric/crystallize_node.py @@ -0,0 +1,250 @@ +import numpy as np +import torch +import cv2 +from scipy.spatial import Voronoi + +class CrystallizeNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "crystal_size": ("INT", {"default": 30, "min": 10, "max": 100, "step": 5}), + "num_crystals": ("INT", {"default": 200, "min": 50, "max": 1000, "step": 50}), + "crystal_shape": (["angular", "organic", "geometric"], {"default": "angular"}), + }, + "optional": { + "edge_enhancement": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.1}), + "color_variation": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.1}), + "outline_strength": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.1}), + "randomness": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.1}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_crystallize" + CATEGORY = "Image Effects" + + def apply_crystallize(self, image, crystal_size, num_crystals, crystal_shape, + edge_enhancement=0.3, color_variation=0.2, outline_strength=0.5, randomness=0.3): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + + # Générer les centres de cristaux + crystal_centers = self._generate_crystal_centers(img_np, num_crystals, edge_enhancement) + + # Créer l'effet de cristallisation + result = self._create_crystallized_image(img_np, crystal_centers, crystal_size, + crystal_shape, color_variation, + outline_strength, randomness) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) + + def _generate_crystal_centers(self, image, num_crystals, edge_enhancement): + """Générer les centres des cristaux""" + h, w = image.shape[:2] + centers = [] + + if edge_enhancement > 0: + # Détecter les contours pour placer plus de cristaux sur les bords + gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY) + edges = cv2.Canny(gray, 50, 150) + edge_points = np.column_stack(np.where(edges > 0)) + + # Placer des cristaux sur les contours + num_edge_crystals = int(num_crystals * edge_enhancement) + if len(edge_points) > 0: + indices = np.random.choice(len(edge_points), + min(num_edge_crystals, len(edge_points)), + replace=False) + for idx in indices: + y, x = edge_points[idx] + centers.append([x, y]) + + # Compléter avec des centres aléatoires + remaining = num_crystals - len(centers) + for _ in range(remaining): + x = np.random.randint(0, w) + y = np.random.randint(0, h) + centers.append([x, y]) + + return np.array(centers) + + def _create_crystallized_image(self, image, centers, crystal_size, shape, + color_variation, outline_strength, randomness): + """Créer l'image cristallisée""" + h, w, c = image.shape + result = np.zeros_like(image) + + # Créer une carte de régions basée sur la distance + for y in range(h): + for x in range(w): + # Trouver le centre le plus proche + distances = np.sum((centers - np.array([x, y]))**2, axis=1) + closest_idx = np.argmin(distances) + closest_center = centers[closest_idx] + + # Calculer la couleur du cristal + color = self._get_crystal_color(image, closest_center, crystal_size, + color_variation, randomness, closest_idx) + + result[y, x] = color + + # Ajouter les contours des cristaux + if outline_strength > 0: + result = self._add_crystal_outlines(result, centers, outline_strength) + + # Appliquer la forme des cristaux + if shape != "organic": + result = self._apply_crystal_shape(result, centers, crystal_size, shape, randomness) + + return result + + def _get_crystal_color(self, image, center, size, variation, randomness, seed): + """Obtenir la couleur d'un cristal""" + h, w, c = image.shape + center_x, center_y = int(center[0]), int(center[1]) + + # Région autour du centre + region_size = max(5, size // 4) + x1 = max(0, center_x - region_size) + x2 = min(w, center_x + region_size) + y1 = max(0, center_y - region_size) + y2 = min(h, center_y + region_size) + + region = image[y1:y2, x1:x2] + if region.size > 0: + base_color = np.mean(region.reshape(-1, c), axis=0) + else: + base_color = image[center_y, center_x] if 0 <= center_x < w and 0 <= center_y < h else np.array([128, 128, 128]) + + # Ajouter de la variation de couleur + if variation > 0: + np.random.seed(seed) + variation_amount = variation * 50 + color_shift = np.random.uniform(-variation_amount, variation_amount, 3) + base_color = np.clip(base_color + color_shift, 0, 255) + + return base_color + + def _add_crystal_outlines(self, image, centers, strength): + """Ajouter les contours des cristaux""" + h, w = image.shape[:2] + + # Créer une carte des régions + regions = np.zeros((h, w), dtype=np.int32) + for y in range(h): + for x in range(w): + distances = np.sum((centers - np.array([x, y]))**2, axis=1) + regions[y, x] = np.argmin(distances) + + # Détecter les frontières + edges = np.zeros((h, w), dtype=np.uint8) + for y in range(1, h-1): + for x in range(1, w-1): + if (regions[y, x] != regions[y-1, x] or + regions[y, x] != regions[y+1, x] or + regions[y, x] != regions[y, x-1] or + regions[y, x] != regions[y, x+1]): + edges[y, x] = 255 + + # Appliquer l'effet de contour + result = image.copy() + edge_mask = edges > 0 + + # Assombrir les contours + result[edge_mask] = result[edge_mask] * (1 - strength) + + return result + + def _apply_crystal_shape(self, image, centers, size, shape, randomness): + """Appliquer une forme spécifique aux cristaux""" + h, w = image.shape[:2] + result = image.copy() + + if shape == "angular": + # Créer des formes angulaires + for i, center in enumerate(centers): + np.random.seed(i) + num_sides = np.random.randint(3, 8) + radius = size // 2 + np.random.randint(-size//4, size//4) + + # Créer un polygone angulaire + angles = np.linspace(0, 2*np.pi, num_sides, endpoint=False) + if randomness > 0: + angles += np.random.uniform(-randomness, randomness, num_sides) + + points = [] + for angle in angles: + r = radius * (1 + np.random.uniform(-randomness, randomness) * 0.3) + x = int(center[0] + r * np.cos(angle)) + y = int(center[1] + r * np.sin(angle)) + points.append([x, y]) + + if len(points) >= 3: + points = np.array(points, dtype=np.int32) + # Obtenir la couleur moyenne de la région + mask = np.zeros((h, w), dtype=np.uint8) + cv2.fillPoly(mask, [points], 255) + if np.any(mask): + avg_color = np.mean(image[mask > 0], axis=0) + cv2.fillPoly(result, [points], avg_color.tolist()) + + elif shape == "geometric": + # Créer des formes géométriques régulières + for i, center in enumerate(centers): + np.random.seed(i) + shape_type = np.random.choice(['triangle', 'square', 'hexagon']) + radius = size // 2 + + if shape_type == 'triangle': + points = self._create_triangle(center, radius) + elif shape_type == 'square': + points = self._create_square(center, radius) + elif shape_type == 'hexagon': + points = self._create_hexagon(center, radius) + + # Dessiner la forme + mask = np.zeros((h, w), dtype=np.uint8) + cv2.fillPoly(mask, [points], 255) + if np.any(mask): + avg_color = np.mean(image[mask > 0], axis=0) + cv2.fillPoly(result, [points], avg_color.tolist()) + + return result + + def _create_triangle(self, center, radius): + """Créer un triangle""" + points = [] + for i in range(3): + angle = i * 2 * np.pi / 3 + x = int(center[0] + radius * np.cos(angle)) + y = int(center[1] + radius * np.sin(angle)) + points.append([x, y]) + return np.array(points, dtype=np.int32) + + def _create_square(self, center, radius): + """Créer un carré""" + points = [] + for i in range(4): + angle = i * np.pi / 2 + np.pi / 4 + x = int(center[0] + radius * np.cos(angle)) + y = int(center[1] + radius * np.sin(angle)) + points.append([x, y]) + return np.array(points, dtype=np.int32) + + def _create_hexagon(self, center, radius): + """Créer un hexagone""" + points = [] + for i in range(6): + angle = i * np.pi / 3 + x = int(center[0] + radius * np.cos(angle)) + y = int(center[1] + radius * np.sin(angle)) + points.append([x, y]) + return np.array(points, dtype=np.int32) diff --git a/geometric/hexagonal_pixelate_node.py b/geometric/hexagonal_pixelate_node.py new file mode 100644 index 0000000..b3623e7 --- /dev/null +++ b/geometric/hexagonal_pixelate_node.py @@ -0,0 +1,136 @@ +import numpy as np +import torch +import cv2 + +class HexagonalPixelateNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "hex_size": ("INT", {"default": 20, "min": 5, "max": 100, "step": 5}), + "color_mode": (["average", "center", "dominant"], {"default": "average"}), + }, + "optional": { + "spacing": ("FLOAT", {"default": 0.9, "min": 0.5, "max": 1.0, "step": 0.05}), + "rotation": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 60.0, "step": 5.0}), + "outline": ("BOOLEAN", {"default": False}), + "outline_thickness": ("INT", {"default": 1, "min": 1, "max": 3, "step": 1}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_hexagonal_pixelate" + CATEGORY = "Image Effects" + + def apply_hexagonal_pixelate(self, image, hex_size, color_mode, + spacing=0.9, rotation=0.0, outline=False, outline_thickness=1): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + + # Créer l'image hexagonale + result = self._create_hexagonal_pattern(img_np, hex_size, color_mode, + spacing, rotation, outline, outline_thickness) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) + + def _create_hexagonal_pattern(self, image, hex_size, color_mode, spacing, rotation, outline, thickness): + """Créer le motif hexagonal""" + h, w, c = image.shape + result = np.zeros_like(image) + + # Calculer les dimensions hexagonales + hex_height = hex_size * 2 + hex_width = int(hex_size * np.sqrt(3)) + + # Espacement entre hexagones + effective_size = hex_size * spacing + + # Rotation en radians + rot_rad = np.radians(rotation) + + # Parcourir la grille hexagonale + for row in range(-1, h // int(hex_height * 0.75) + 2): + for col in range(-1, w // hex_width + 2): + # Position de l'hexagone + if row % 2 == 0: + x = col * hex_width + else: + x = col * hex_width + hex_width // 2 + + y = row * int(hex_height * 0.75) + + # Appliquer la rotation + if rotation != 0: + center_x, center_y = w // 2, h // 2 + x_rot = (x - center_x) * np.cos(rot_rad) - (y - center_y) * np.sin(rot_rad) + center_x + y_rot = (x - center_x) * np.sin(rot_rad) + (y - center_y) * np.cos(rot_rad) + center_y + x, y = int(x_rot), int(y_rot) + + # Vérifier si l'hexagone est dans l'image + if -hex_size <= x <= w + hex_size and -hex_size <= y <= h + hex_size: + # Créer l'hexagone + hex_points = self._create_hexagon_points(x, y, effective_size, rotation) + + # Obtenir la couleur de l'hexagone + color = self._get_hexagon_color(image, hex_points, color_mode) + + # Dessiner l'hexagone + if hex_points is not None: + cv2.fillPoly(result, [hex_points], color.tolist()) + + # Ajouter le contour si demandé + if outline: + cv2.polylines(result, [hex_points], True, (0, 0, 0), thickness) + + return result + + def _create_hexagon_points(self, center_x, center_y, size, rotation): + """Créer les points d'un hexagone""" + points = [] + rot_rad = np.radians(rotation) + + for i in range(6): + angle = i * np.pi / 3 + rot_rad + x = center_x + size * np.cos(angle) + y = center_y + size * np.sin(angle) + points.append([int(x), int(y)]) + + return np.array(points, dtype=np.int32) + + def _get_hexagon_color(self, image, hex_points, mode): + """Obtenir la couleur d'un hexagone""" + h, w, c = image.shape + + # Créer un masque pour l'hexagone + mask = np.zeros((h, w), dtype=np.uint8) + cv2.fillPoly(mask, [hex_points], 255) + + # Obtenir les pixels dans l'hexagone + masked_pixels = image[mask > 0] + + if len(masked_pixels) == 0: + return np.array([128, 128, 128]) # Couleur par défaut + + if mode == "average": + return np.mean(masked_pixels, axis=0) + elif mode == "center": + # Couleur du centre de l'hexagone + center = np.mean(hex_points, axis=0).astype(int) + if 0 <= center[0] < w and 0 <= center[1] < h: + return image[center[1], center[0]] + else: + return np.mean(masked_pixels, axis=0) + elif mode == "dominant": + # Couleur dominante (approximation) + pixels_reshaped = masked_pixels.reshape(-1, 3) + unique_colors, counts = np.unique(pixels_reshaped, axis=0, return_counts=True) + return unique_colors[np.argmax(counts)] + + return np.mean(masked_pixels, axis=0) diff --git a/geometric/polygon_node.py b/geometric/polygon_node.py new file mode 100644 index 0000000..4e7ee48 --- /dev/null +++ b/geometric/polygon_node.py @@ -0,0 +1,136 @@ +import numpy as np +import torch +import cv2 + +class PolygonNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "polygon_sides": ("INT", {"default": 6, "min": 3, "max": 12, "step": 1}), + "polygon_size": ("INT", {"default": 30, "min": 10, "max": 100, "step": 5}), + "reduction_factor": ("FLOAT", {"default": 0.5, "min": 0.1, "max": 0.9, "step": 0.1}), + }, + "optional": { + "color_mode": (["average", "dominant", "center"], {"default": "average"}), + "edge_preservation": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.1}), + "rotation": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 360.0, "step": 15.0}), + "outline": ("BOOLEAN", {"default": False}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_polygon_reduction" + CATEGORY = "Image Effects" + + def apply_polygon_reduction(self, image, polygon_sides, polygon_size, reduction_factor, + color_mode="average", edge_preservation=0.3, rotation=0.0, outline=False): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + + # Créer l'effet de réduction polygonale + result = self._create_polygon_reduction(img_np, polygon_sides, polygon_size, + reduction_factor, color_mode, + edge_preservation, rotation, outline) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) + + def _create_polygon_reduction(self, image, sides, size, reduction, color_mode, + edge_preservation, rotation, outline): + """Créer l'effet de réduction polygonale""" + h, w, c = image.shape + + # Calculer la nouvelle résolution + new_w = int(w * reduction) + new_h = int(h * reduction) + + # Redimensionner l'image + reduced = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA) + + # Créer l'image de sortie + result = np.zeros_like(image) + + # Calculer l'espacement des polygones + poly_spacing_x = w / new_w + poly_spacing_y = h / new_h + + # Préserver les contours si demandé + edges = None + if edge_preservation > 0: + gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY) + edges = cv2.Canny(gray, 50, 150) + + # Créer les polygones + for y in range(new_h): + for x in range(new_w): + # Position dans l'image originale + orig_x = int(x * poly_spacing_x + poly_spacing_x / 2) + orig_y = int(y * poly_spacing_y + poly_spacing_y / 2) + + # Couleur du pixel réduit + pixel_color = reduced[y, x] + + # Ajuster la couleur selon le mode + if color_mode == "average": + # Moyenner la région autour du pixel + region_size = max(1, int(min(poly_spacing_x, poly_spacing_y) / 2)) + x1 = max(0, orig_x - region_size) + x2 = min(w, orig_x + region_size) + y1 = max(0, orig_y - region_size) + y2 = min(h, orig_y + region_size) + region = image[y1:y2, x1:x2] + if region.size > 0: + pixel_color = np.mean(region.reshape(-1, c), axis=0) + + elif color_mode == "dominant": + # Couleur dominante dans la région + region_size = max(1, int(min(poly_spacing_x, poly_spacing_y) / 2)) + x1 = max(0, orig_x - region_size) + x2 = min(w, orig_x + region_size) + y1 = max(0, orig_y - region_size) + y2 = min(h, orig_y + region_size) + region = image[y1:y2, x1:x2] + if region.size > 0: + pixels = region.reshape(-1, c) + unique_colors, counts = np.unique(pixels, axis=0, return_counts=True) + pixel_color = unique_colors[np.argmax(counts)] + + # Créer le polygone + polygon_points = self._create_polygon_points(orig_x, orig_y, size, sides, rotation) + + # Dessiner le polygone + cv2.fillPoly(result, [polygon_points], pixel_color.tolist()) + + # Ajouter le contour si demandé + if outline: + cv2.polylines(result, [polygon_points], True, (0, 0, 0), 1) + + # Préserver les contours importants + if edge_preservation > 0 and edges is not None: + edge_mask = edges > 0 + blend_factor = edge_preservation + result[edge_mask] = (result[edge_mask] * (1 - blend_factor) + + image[edge_mask] * blend_factor).astype(np.uint8) + + return result + + def _create_polygon_points(self, center_x, center_y, size, sides, rotation): + """Créer les points d'un polygone""" + points = [] + angle_step = 2 * np.pi / sides + rotation_rad = np.radians(rotation) + + for i in range(sides): + angle = i * angle_step + rotation_rad + x = int(center_x + size * np.cos(angle)) + y = int(center_y + size * np.sin(angle)) + points.append([x, y]) + + return np.array(points, dtype=np.int32) diff --git a/geometric/triangulate_node.py b/geometric/triangulate_node.py new file mode 100644 index 0000000..ebdbacd --- /dev/null +++ b/geometric/triangulate_node.py @@ -0,0 +1,168 @@ +import numpy as np +import torch +import cv2 +from scipy.spatial import Delaunay + +class TriangulateNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "num_points": ("INT", {"default": 500, "min": 50, "max": 2000, "step": 50}), + "edge_threshold": ("FLOAT", {"default": 0.3, "min": 0.1, "max": 1.0, "step": 0.05}), + "color_mode": (["average", "dominant", "gradient"], {"default": "average"}), + }, + "optional": { + "point_distribution": (["random", "edge_based", "grid"], {"default": "edge_based"}), + "triangle_outline": ("BOOLEAN", {"default": False}), + "outline_thickness": ("INT", {"default": 1, "min": 1, "max": 5, "step": 1}), + "smoothing": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.1}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_triangulate" + CATEGORY = "Image Effects" + + def apply_triangulate(self, image, num_points, edge_threshold, color_mode, + point_distribution="edge_based", triangle_outline=False, + outline_thickness=1, smoothing=0.0): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + + # Générer les points selon la distribution + points = self._generate_points(img_np, num_points, point_distribution, edge_threshold) + + # Triangulation de Delaunay + tri = Delaunay(points) + + # Créer l'image triangulée + result = self._create_triangulated_image(img_np, points, tri.simplices, + color_mode, triangle_outline, outline_thickness) + + # Appliquer le lissage si demandé + if smoothing > 0: + result = self._apply_smoothing(result, smoothing) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) + + def _generate_points(self, image, num_points, distribution, edge_threshold): + """Générer les points pour la triangulation""" + h, w = image.shape[:2] + points = [] + + # Ajouter les coins + points.extend([[0, 0], [w-1, 0], [w-1, h-1], [0, h-1]]) + + if distribution == "random": + # Distribution aléatoire + for _ in range(num_points - 4): + x = np.random.randint(0, w) + y = np.random.randint(0, h) + points.append([x, y]) + + elif distribution == "edge_based": + # Basé sur les contours + gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY) + edges = cv2.Canny(gray, int(edge_threshold * 100), int(edge_threshold * 200)) + + # Points sur les contours + edge_points = np.column_stack(np.where(edges > 0)) + if len(edge_points) > 0: + # Échantillonner les points de contour + indices = np.random.choice(len(edge_points), + min(num_points // 2, len(edge_points)), + replace=False) + for idx in indices: + y, x = edge_points[idx] + points.append([x, y]) + + # Points aléatoires pour compléter + remaining = num_points - len(points) + for _ in range(remaining): + x = np.random.randint(0, w) + y = np.random.randint(0, h) + points.append([x, y]) + + elif distribution == "grid": + # Distribution en grille avec variation + grid_size = int(np.sqrt(num_points)) + for i in range(grid_size): + for j in range(grid_size): + if len(points) >= num_points: + break + x = int((j + 0.5) * w / grid_size) + np.random.randint(-w//20, w//20) + y = int((i + 0.5) * h / grid_size) + np.random.randint(-h//20, h//20) + x = np.clip(x, 0, w-1) + y = np.clip(y, 0, h-1) + points.append([x, y]) + + return np.array(points) + + def _create_triangulated_image(self, image, points, triangles, color_mode, outline, thickness): + """Créer l'image triangulée""" + h, w, c = image.shape + result = np.zeros_like(image) + + for triangle in triangles: + # Points du triangle + pts = points[triangle].astype(np.int32) + + # Calculer la couleur du triangle + color = self._get_triangle_color(image, pts, color_mode) + + # Dessiner le triangle + cv2.fillPoly(result, [pts], color.tolist()) + + # Dessiner le contour si demandé + if outline: + cv2.polylines(result, [pts], True, (0, 0, 0), thickness) + + return result + + def _get_triangle_color(self, image, triangle_points, mode): + """Calculer la couleur d'un triangle""" + # Créer un masque pour le triangle + mask = np.zeros(image.shape[:2], dtype=np.uint8) + cv2.fillPoly(mask, [triangle_points], 255) + + if mode == "average": + # Couleur moyenne + masked_pixels = image[mask > 0] + if len(masked_pixels) > 0: + return np.mean(masked_pixels, axis=0) + else: + return np.array([128, 128, 128]) + + elif mode == "dominant": + # Couleur dominante (approximation) + masked_pixels = image[mask > 0] + if len(masked_pixels) > 0: + # Quantifier les couleurs et prendre la plus fréquente + pixels_reshaped = masked_pixels.reshape(-1, 3) + unique_colors, counts = np.unique(pixels_reshaped, axis=0, return_counts=True) + dominant_color = unique_colors[np.argmax(counts)] + return dominant_color + else: + return np.array([128, 128, 128]) + + elif mode == "gradient": + # Gradient basé sur la position + center = np.mean(triangle_points, axis=0) + h, w = image.shape[:2] + gradient_factor = center[1] / h # Gradient vertical + base_color = np.mean(image[mask > 0], axis=0) if np.any(mask > 0) else np.array([128, 128, 128]) + return base_color * (0.5 + 0.5 * gradient_factor) + + def _apply_smoothing(self, image, smoothing): + """Appliquer un lissage à l'image""" + kernel_size = int(smoothing * 10) * 2 + 1 + smoothed = cv2.GaussianBlur(image, (kernel_size, kernel_size), 0) + return image * (1 - smoothing) + smoothed * smoothing diff --git a/geometric/voronoi_node.py b/geometric/voronoi_node.py new file mode 100644 index 0000000..9c874a2 --- /dev/null +++ b/geometric/voronoi_node.py @@ -0,0 +1,181 @@ +import numpy as np +import torch +import cv2 +from scipy.spatial import Voronoi, voronoi_plot_2d + +class VoronoiNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "num_seeds": ("INT", {"default": 100, "min": 10, "max": 500, "step": 10}), + "color_mode": (["average", "center_point", "random"], {"default": "average"}), + "cell_outline": ("BOOLEAN", {"default": True}), + }, + "optional": { + "seed_distribution": (["random", "edge_based", "grid"], {"default": "random"}), + "outline_color": (["black", "white", "adaptive"], {"default": "black"}), + "outline_thickness": ("INT", {"default": 2, "min": 1, "max": 5, "step": 1}), + "edge_threshold": ("FLOAT", {"default": 0.3, "min": 0.1, "max": 1.0, "step": 0.05}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_voronoi" + CATEGORY = "Image Effects" + + def apply_voronoi(self, image, num_seeds, color_mode, cell_outline, + seed_distribution="random", outline_color="black", + outline_thickness=2, edge_threshold=0.3): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + + # Générer les graines + seeds = self._generate_seeds(img_np, num_seeds, seed_distribution, edge_threshold) + + # Créer le diagramme de Voronoï + result = self._create_voronoi_image(img_np, seeds, color_mode, + cell_outline, outline_color, outline_thickness) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) + + def _generate_seeds(self, image, num_seeds, distribution, edge_threshold): + """Générer les graines pour le diagramme de Voronoï""" + h, w = image.shape[:2] + seeds = [] + + if distribution == "random": + for _ in range(num_seeds): + x = np.random.randint(0, w) + y = np.random.randint(0, h) + seeds.append([x, y]) + + elif distribution == "edge_based": + gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY) + edges = cv2.Canny(gray, int(edge_threshold * 100), int(edge_threshold * 200)) + edge_points = np.column_stack(np.where(edges > 0)) + + if len(edge_points) > 0: + indices = np.random.choice(len(edge_points), + min(num_seeds // 2, len(edge_points)), + replace=False) + for idx in indices: + y, x = edge_points[idx] + seeds.append([x, y]) + + # Compléter avec des points aléatoires + remaining = num_seeds - len(seeds) + for _ in range(remaining): + x = np.random.randint(0, w) + y = np.random.randint(0, h) + seeds.append([x, y]) + + elif distribution == "grid": + grid_size = int(np.sqrt(num_seeds)) + for i in range(grid_size): + for j in range(grid_size): + if len(seeds) >= num_seeds: + break + x = int((j + 0.5) * w / grid_size) + np.random.randint(-w//20, w//20) + y = int((i + 0.5) * h / grid_size) + np.random.randint(-h//20, h//20) + x = np.clip(x, 0, w-1) + y = np.clip(y, 0, h-1) + seeds.append([x, y]) + + return np.array(seeds) + + def _create_voronoi_image(self, image, seeds, color_mode, outline, outline_color, thickness): + """Créer l'image avec diagramme de Voronoï""" + h, w, c = image.shape + result = np.zeros_like(image) + + # Créer une carte de distance pour chaque graine + for y in range(h): + for x in range(w): + # Trouver la graine la plus proche + distances = np.sum((seeds - np.array([x, y]))**2, axis=1) + closest_seed_idx = np.argmin(distances) + closest_seed = seeds[closest_seed_idx] + + # Déterminer la couleur de la cellule + if color_mode == "average": + # Couleur moyenne autour de la graine + seed_x, seed_y = int(closest_seed[0]), int(closest_seed[1]) + region_size = 10 + x1 = max(0, seed_x - region_size) + x2 = min(w, seed_x + region_size) + y1 = max(0, seed_y - region_size) + y2 = min(h, seed_y + region_size) + region = image[y1:y2, x1:x2] + if region.size > 0: + color = np.mean(region.reshape(-1, c), axis=0) + else: + color = image[seed_y, seed_x] + + elif color_mode == "center_point": + # Couleur du point central de la graine + seed_x, seed_y = int(closest_seed[0]), int(closest_seed[1]) + color = image[seed_y, seed_x] + + elif color_mode == "random": + # Couleur aléatoire par cellule + np.random.seed(closest_seed_idx) + color = np.random.randint(0, 256, 3) + + result[y, x] = color + + # Ajouter les contours si demandé + if outline: + outline_img = self._add_voronoi_outlines(result, seeds, outline_color, thickness) + result = outline_img + + return result + + def _add_voronoi_outlines(self, image, seeds, outline_color, thickness): + """Ajouter les contours des cellules de Voronoï""" + h, w = image.shape[:2] + + # Créer une carte des régions + regions = np.zeros((h, w), dtype=np.int32) + for y in range(h): + for x in range(w): + distances = np.sum((seeds - np.array([x, y]))**2, axis=1) + regions[y, x] = np.argmin(distances) + + # Détecter les frontières + edges = np.zeros((h, w), dtype=np.uint8) + for y in range(1, h-1): + for x in range(1, w-1): + if (regions[y, x] != regions[y-1, x] or + regions[y, x] != regions[y+1, x] or + regions[y, x] != regions[y, x-1] or + regions[y, x] != regions[y, x+1]): + edges[y, x] = 255 + + # Appliquer l'épaisseur + if thickness > 1: + kernel = np.ones((thickness, thickness), np.uint8) + edges = cv2.dilate(edges, kernel, iterations=1) + + # Appliquer la couleur de contour + result = image.copy() + if outline_color == "black": + color = [0, 0, 0] + elif outline_color == "white": + color = [255, 255, 255] + elif outline_color == "adaptive": + # Couleur adaptative basée sur la luminosité locale + gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY) + color = np.where(gray[edges > 0] > 128, [0, 0, 0], [255, 255, 255]) + result[edges > 0] = color + return result + + result[edges > 0] = color + return result diff --git a/light_effects/__init__.py b/light_effects/__init__.py new file mode 100644 index 0000000..dc1e308 --- /dev/null +++ b/light_effects/__init__.py @@ -0,0 +1 @@ +"""Effets de lumière et d'éclairage""" diff --git a/light_effects/__pycache__/__init__.cpython-312.pyc b/light_effects/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b9621a959ec0538c3ba2af1a3fac547ca535ae8e GIT binary patch literal 225 zcmX@j%ge<81jWK;8CpR4F^B^Lj8MjB4j^MXLkdF_LnWgoQ>hY=>zbC9T2ic#lB$qX znwxoeMNz6kYKcOM`r(zyIf*ggYL8C^+K^mQ9Fvooo>2nl#m8snWtPOp n>lIYq;;_lhPbtkwwJTx+8V+)8G03GKm>C%vZ!u^Tu>d&$4gWxw literal 0 HcmV?d00001 diff --git a/light_effects/__pycache__/aurora_node.cpython-312.pyc b/light_effects/__pycache__/aurora_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..177f6264355411afb8f279a066f8923e7be8412b GIT binary patch literal 5061 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 literal 0 HcmV?d00001 diff --git a/light_effects/__pycache__/holographic_node.cpython-312.pyc b/light_effects/__pycache__/holographic_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..161a482b34794aec7ca3f95f7b1e19d7a4c757e4 GIT binary patch literal 7191 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 literal 0 HcmV?d00001 diff --git a/light_effects/__pycache__/lens_flare_node.cpython-312.pyc b/light_effects/__pycache__/lens_flare_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..65310cddbfe466e02aed7b2945240b275a692233 GIT binary patch literal 10204 zcmd5?X>40pc7BhWxQdHNiCRrbV_EbXFNvK@#@1q4wqq-?EXB6Q4ny-jQIsiCx+HCh zAu~qn6jWd!(qtM_9k-?%1E%1#Q8Fo_0SZJ#{K9xL@vm(kq1vf^i4C|tc`o?KW;qSpk5HK0q4TJtunvpUx4?QelqkPsT?et_lR5l6Gs#I ziwqVuX7gAzC`Ow_>+53{ad;I!A{9tjpwFkgqb1gN}qe2Ui`>U(K$ zz%g*@rJm#A+BJu6Yf%wSFc1fEky=$!bwAT>C4jCP!vn zTbY~#OV6bGPo-QVT)QXLG@7YxN_H+dmn644Ia^=K`l8U>H+r2xy-Mj+>IX13ir_GL zgL8b`JLT}^CmYrs!riy^h>PM

8(hFVpjIrLIuH{6&kZ+H*N*)~ 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 literal 0 HcmV?d00001 diff --git a/light_effects/aurora_node.py b/light_effects/aurora_node.py new file mode 100644 index 0000000..4958d5f --- /dev/null +++ b/light_effects/aurora_node.py @@ -0,0 +1,114 @@ +import numpy as np +import torch +import cv2 + +class AuroraNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "intensity": ("FLOAT", {"default": 0.6, "min": 0.0, "max": 2.0, "step": 0.1}), + "color_palette": (["green_blue", "purple_pink", "blue_cyan", "multicolor"], {"default": "green_blue"}), + "wave_frequency": ("FLOAT", {"default": 0.02, "min": 0.005, "max": 0.1, "step": 0.005}), + }, + "optional": { + "position": (["top", "bottom", "center"], {"default": "top"}), + "height": ("FLOAT", {"default": 0.4, "min": 0.1, "max": 0.8, "step": 0.05}), + "animation_speed": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 3.0, "step": 0.1}), + "opacity": ("FLOAT", {"default": 0.7, "min": 0.1, "max": 1.0, "step": 0.05}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_aurora" + CATEGORY = "Image Effects" + + def apply_aurora(self, image, intensity, color_palette, wave_frequency, + position="top", height=0.4, animation_speed=1.0, opacity=0.7): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + result = img_np.copy().astype(np.float32) + + # Palettes de couleurs d'aurore + palettes = { + "green_blue": [(0, 255, 100), (0, 200, 255), (50, 255, 150)], + "purple_pink": [(200, 50, 255), (255, 100, 200), (150, 0, 255)], + "blue_cyan": [(0, 100, 255), (0, 255, 255), (100, 150, 255)], + "multicolor": [(0, 255, 100), (255, 100, 200), (100, 150, 255), (255, 200, 0)] + } + + colors = palettes[color_palette] + + # Créer l'aurore + aurora_overlay = self._create_aurora_effect(h, w, colors, wave_frequency, + position, height, animation_speed, intensity) + + # Fusionner avec l'image + result = result * (1 - opacity) + (result + aurora_overlay) * opacity + result = np.clip(result, 0, 255) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) + + def _create_aurora_effect(self, h, w, colors, frequency, position, height_ratio, speed, intensity): + """Créer l'effet d'aurore boréale""" + overlay = np.zeros((h, w, 3), dtype=np.float32) + + # Zone d'effet selon la position + if position == "top": + start_y = 0 + end_y = int(h * height_ratio) + elif position == "bottom": + start_y = int(h * (1 - height_ratio)) + end_y = h + else: # center + center = h // 2 + half_height = int(h * height_ratio / 2) + start_y = center - half_height + end_y = center + half_height + + # Animation basée sur le temps + import time + time_factor = time.time() * speed + + # Créer plusieurs couches d'aurore + for layer in range(len(colors)): + color = colors[layer] + + # Décalage temporel pour chaque couche + layer_time = time_factor + layer * 2 + + # Créer les vagues d'aurore + for y in range(start_y, end_y): + # Intensité basée sur la position verticale + y_factor = 1.0 - abs(y - (start_y + end_y) / 2) / ((end_y - start_y) / 2) + + for x in range(w): + # Calcul des vagues multiples + wave1 = np.sin(x * frequency + layer_time) * 0.5 + wave2 = np.sin(x * frequency * 2.3 + layer_time * 1.7) * 0.3 + wave3 = np.sin(x * frequency * 0.7 + layer_time * 0.8) * 0.2 + + combined_wave = wave1 + wave2 + wave3 + + # Intensité de l'aurore à ce point + aurora_intensity = max(0, combined_wave * y_factor * intensity) + + # Ajouter la couleur avec variation + for c in range(3): + overlay[y, x, c] += color[c] * aurora_intensity * (0.3 + 0.7 / (layer + 1)) + + # Flou pour effet de diffusion + overlay = cv2.GaussianBlur(overlay, (21, 21), 0) + + # Ajouter du bruit pour plus de réalisme + noise = np.random.random((h, w, 3)) * 10 + overlay += noise + + return np.clip(overlay, 0, 255) diff --git a/light_effects/god_rays_node.py b/light_effects/god_rays_node.py new file mode 100644 index 0000000..38180a4 --- /dev/null +++ b/light_effects/god_rays_node.py @@ -0,0 +1,131 @@ +import numpy as np +import torch +import cv2 + +class GodRaysNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "intensity": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 2.0, "step": 0.1}), + "num_rays": ("INT", {"default": 8, "min": 3, "max": 20, "step": 1}), + "ray_length": ("FLOAT", {"default": 0.8, "min": 0.1, "max": 2.0, "step": 0.1}), + }, + "optional": { + "source_x": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "source_y": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01}), + "color_temp": ("FLOAT", {"default": 3000.0, "min": 2000.0, "max": 8000.0, "step": 100.0}), + "decay": ("FLOAT", {"default": 0.8, "min": 0.1, "max": 1.0, "step": 0.1}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_god_rays" + CATEGORY = "Image Effects" + + def apply_god_rays(self, image, intensity, num_rays, ray_length, + source_x=0.5, source_y=0.2, color_temp=3000.0, decay=0.8): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + result = img_np.copy().astype(np.float32) + + # Source des rayons + source_x_px = int(w * source_x) + source_y_px = int(h * source_y) + + # Couleur des rayons + ray_color = self._temp_to_rgb(color_temp) + + # Créer les rayons divins + rays_overlay = self._create_god_rays(h, w, source_x_px, source_y_px, + num_rays, ray_length, ray_color, intensity, decay) + + # Fusionner avec l'image + result = np.clip(result + rays_overlay, 0, 255) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) + + def _temp_to_rgb(self, temp): + """Convertir température de couleur en RGB""" + temp = temp / 100 + + if temp <= 66: + red = 255 + green = temp + green = 99.4708025861 * np.log(green) - 161.1195681661 + if temp >= 19: + blue = temp - 10 + blue = 138.5177312231 * np.log(blue) - 305.0447927307 + else: + blue = 0 + else: + red = temp - 60 + red = 329.698727446 * np.power(red, -0.1332047592) + green = temp - 60 + green = 288.1221695283 * np.power(green, -0.0755148492) + blue = 255 + + return (np.clip(red, 0, 255), np.clip(green, 0, 255), np.clip(blue, 0, 255)) + + def _create_god_rays(self, h, w, source_x, source_y, num_rays, ray_length, color, intensity, decay): + """Créer les rayons divins""" + overlay = np.zeros((h, w, 3), dtype=np.float32) + + # Longueur maximale des rayons + max_length = int(min(w, h) * ray_length) + + for i in range(num_rays): + # Angle du rayon avec variation aléatoire + base_angle = (2 * np.pi * i) / num_rays + angle_variation = np.random.uniform(-0.3, 0.3) + angle = base_angle + angle_variation + + # Créer un rayon individuel + ray_overlay = self._create_single_ray(h, w, source_x, source_y, + angle, max_length, color, decay) + overlay += ray_overlay + + # Normaliser et appliquer l'intensité + overlay = np.clip(overlay, 0, 255) * intensity + + return overlay + + def _create_single_ray(self, h, w, start_x, start_y, angle, length, color, decay): + """Créer un rayon individuel""" + ray_overlay = np.zeros((h, w, 3), dtype=np.float32) + + # Calculer les points du rayon + end_x = int(start_x + length * np.cos(angle)) + end_y = int(start_y + length * np.sin(angle)) + + # Largeur variable du rayon + num_segments = 50 + for i in range(num_segments): + t = i / num_segments + + # Position le long du rayon + x = int(start_x + t * (end_x - start_x)) + y = int(start_y + t * (end_y - start_y)) + + # Largeur qui diminue avec la distance + width = max(1, int(10 * (1 - t * decay))) + + # Intensité qui diminue avec la distance + alpha = (1 - t) * decay + + # Dessiner un segment du rayon + if 0 <= x < w and 0 <= y < h: + cv2.circle(ray_overlay, (x, y), width, + (color[0] * alpha, color[1] * alpha, color[2] * alpha), -1) + + # Flou gaussien pour adoucir + ray_overlay = cv2.GaussianBlur(ray_overlay, (21, 21), 0) + + return ray_overlay diff --git a/light_effects/holographic_node.py b/light_effects/holographic_node.py new file mode 100644 index 0000000..0f73abf --- /dev/null +++ b/light_effects/holographic_node.py @@ -0,0 +1,158 @@ +import numpy as np +import torch +import cv2 + +class HolographicNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "intensity": ("FLOAT", {"default": 0.6, "min": 0.0, "max": 2.0, "step": 0.1}), + "interference_lines": ("INT", {"default": 100, "min": 20, "max": 300, "step": 10}), + "color_shift": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.1}), + }, + "optional": { + "chromatic_aberration": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.05}), + "transparency": ("FLOAT", {"default": 0.7, "min": 0.1, "max": 1.0, "step": 0.05}), + "flicker": ("BOOLEAN", {"default": True}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_holographic" + CATEGORY = "Image Effects" + + def apply_holographic(self, image, intensity, interference_lines, color_shift, + chromatic_aberration=0.3, transparency=0.7, flicker=True): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + result = img_np.copy().astype(np.float32) + + # Effet de scintillement + flicker_factor = 1.0 + if flicker: + import time + flicker_factor = 0.8 + 0.2 * np.sin(time.time() * 10) * np.random.uniform(0.5, 1.0) + + # Appliquer la transparence + result *= transparency + + # Aberration chromatique + if chromatic_aberration > 0: + result = self._apply_chromatic_aberration(result, chromatic_aberration) + + # Décalage de couleur holographique + if color_shift > 0: + result = self._apply_holographic_color_shift(result, color_shift) + + # Lignes d'interférence + interference_overlay = self._create_interference_lines(h, w, interference_lines, intensity) + result += interference_overlay + + # Appliquer le scintillement + result *= flicker_factor + + # Normaliser + result = np.clip(result, 0, 255) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) + + def _apply_chromatic_aberration(self, image, strength): + """Appliquer l'aberration chromatique""" + h, w = image.shape[:2] + + # Séparer les canaux + r_channel = image[:, :, 0] + g_channel = image[:, :, 1] + b_channel = image[:, :, 2] + + # Décalages pour chaque canal + offset = int(strength * 3) + + # Décaler le rouge + M_r = np.float32([[1, 0, offset], [0, 1, 0]]) + r_shifted = cv2.warpAffine(r_channel, M_r, (w, h)) + + # Décaler le bleu + M_b = np.float32([[1, 0, -offset], [0, 1, 0]]) + b_shifted = cv2.warpAffine(b_channel, M_b, (w, h)) + + # Recombiner + result = np.stack([r_shifted, g_channel, b_shifted], axis=2) + return result + + def _apply_holographic_color_shift(self, image, shift_strength): + """Appliquer un décalage de couleur holographique""" + # Convertir en HSV + hsv = cv2.cvtColor(image.astype(np.uint8), cv2.COLOR_RGB2HSV).astype(np.float32) + + # Créer un gradient de décalage de teinte + h, w = image.shape[:2] + y_gradient = np.linspace(0, 1, h).reshape(-1, 1) + hue_shift = y_gradient * shift_strength * 180 + + # Appliquer le décalage + hsv[:, :, 0] = (hsv[:, :, 0] + hue_shift) % 180 + + # Augmenter la saturation pour l'effet holographique + hsv[:, :, 1] = np.clip(hsv[:, :, 1] * (1 + shift_strength * 0.5), 0, 255) + + # Reconvertir en RGB + result = cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2RGB).astype(np.float32) + return result + + def _create_interference_lines(self, h, w, num_lines, intensity): + """Créer des lignes d'interférence holographiques""" + overlay = np.zeros((h, w, 3), dtype=np.float32) + + # Lignes horizontales d'interférence + line_spacing = h // num_lines + + for i in range(0, h, line_spacing): + # Variation d'intensité aléatoire + line_intensity = intensity * np.random.uniform(0.3, 1.0) + + # Couleur arc-en-ciel basée sur la position + hue = (i / h) * 360 + color = self._hsv_to_rgb(hue, 100, 100) + + # Dessiner la ligne avec dégradé + line_thickness = max(1, line_spacing // 3) + for j in range(line_thickness): + if i + j < h: + alpha = 1.0 - (j / line_thickness) + overlay[i + j, :] = np.array(color) * line_intensity * alpha + + return overlay + + def _hsv_to_rgb(self, h, s, v): + """Convertir HSV en RGB""" + h = h / 60.0 + s = s / 100.0 + v = v / 100.0 + + c = v * s + x = c * (1 - abs((h % 2) - 1)) + m = v - c + + if 0 <= h < 1: + r, g, b = c, x, 0 + elif 1 <= h < 2: + r, g, b = x, c, 0 + elif 2 <= h < 3: + r, g, b = 0, c, x + elif 3 <= h < 4: + r, g, b = 0, x, c + elif 4 <= h < 5: + r, g, b = x, 0, c + else: + r, g, b = c, 0, x + + return ((r + m) * 255, (g + m) * 255, (b + m) * 255) diff --git a/light_effects/lens_flare_node.py b/light_effects/lens_flare_node.py new file mode 100644 index 0000000..09ca323 --- /dev/null +++ b/light_effects/lens_flare_node.py @@ -0,0 +1,159 @@ +import numpy as np +import torch +import cv2 + +class LensFlareNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "flare_type": (["classic", "anamorphic", "starburst", "hexagonal"], {"default": "classic"}), + "intensity": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 2.0, "step": 0.1}), + "size": ("FLOAT", {"default": 0.3, "min": 0.1, "max": 1.0, "step": 0.05}), + }, + "optional": { + "position_x": ("FLOAT", {"default": 0.7, "min": 0.0, "max": 1.0, "step": 0.01}), + "position_y": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01}), + "color_temp": ("FLOAT", {"default": 5500.0, "min": 2000.0, "max": 10000.0, "step": 100.0}), + "rays": ("INT", {"default": 6, "min": 4, "max": 12, "step": 1}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_lens_flare" + CATEGORY = "Image Effects" + + def apply_lens_flare(self, image, flare_type, intensity, size, + position_x=0.7, position_y=0.3, color_temp=5500.0, rays=6): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + result = img_np.copy().astype(np.float32) + + # Position du flare + flare_x = int(w * position_x) + flare_y = int(h * position_y) + + # Couleur basée sur la température + flare_color = self._temp_to_rgb(color_temp) + + # Créer le flare selon le type + if flare_type == "classic": + flare_overlay = self._create_classic_flare(h, w, flare_x, flare_y, size, flare_color, intensity) + elif flare_type == "anamorphic": + flare_overlay = self._create_anamorphic_flare(h, w, flare_x, flare_y, size, flare_color, intensity) + elif flare_type == "starburst": + flare_overlay = self._create_starburst_flare(h, w, flare_x, flare_y, size, flare_color, intensity, rays) + elif flare_type == "hexagonal": + flare_overlay = self._create_hexagonal_flare(h, w, flare_x, flare_y, size, flare_color, intensity) + + # Fusionner avec l'image + result = np.clip(result + flare_overlay, 0, 255) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) + + def _temp_to_rgb(self, temp): + """Convertir température de couleur en RGB""" + temp = temp / 100 + + if temp <= 66: + red = 255 + green = temp + green = 99.4708025861 * np.log(green) - 161.1195681661 + if temp >= 19: + blue = temp - 10 + blue = 138.5177312231 * np.log(blue) - 305.0447927307 + else: + blue = 0 + else: + red = temp - 60 + red = 329.698727446 * np.power(red, -0.1332047592) + green = temp - 60 + green = 288.1221695283 * np.power(green, -0.0755148492) + blue = 255 + + return tuple([int(np.clip(red, 0, 255)), int(np.clip(green, 0, 255)), int(np.clip(blue, 0, 255))]) + + def _create_classic_flare(self, h, w, cx, cy, size, color, intensity): + """Créer un flare classique avec cercles concentriques""" + overlay = np.zeros((h, w, 3), dtype=np.float32) + color_tuple = tuple([int(c) for c in color]) + radius = int(min(w, h) * size * 0.3) + cv2.circle(overlay, (cx, cy), radius, color_tuple, -1) + for i in range(3): + r = radius // (i + 2) + alpha = 0.3 / (i + 1) + circle_overlay = np.zeros_like(overlay) + cv2.circle(circle_overlay, (cx, cy), r, color_tuple, -1) + overlay += circle_overlay * alpha + overlay = cv2.GaussianBlur(overlay, (51, 51), 0) + return overlay * intensity + + def _create_anamorphic_flare(self, h, w, cx, cy, size, color, intensity): + """Créer un flare anamorphique (lignes horizontales)""" + overlay = np.zeros((h, w, 3), dtype=np.float32) + color_tuple = tuple([int(c) for c in color]) + line_height = int(h * size * 0.1) + line_width = int(w * size) + y1 = max(0, cy - line_height // 2) + y2 = min(h, cy + line_height // 2) + x1 = max(0, cx - line_width // 2) + x2 = min(w, cx + line_width // 2) + overlay[y1:y2, x1:x2] = color_tuple + radius = int(min(w, h) * size * 0.1) + cv2.circle(overlay, (cx, cy), radius, color_tuple, -1) + kernel = np.ones((1, 21)) / 21 + for i in range(3): + overlay[:, :, i] = cv2.filter2D(overlay[:, :, i], -1, kernel) + return overlay * intensity + + def _create_starburst_flare(self, h, w, cx, cy, size, color, intensity, rays): + """Créer un flare en étoile""" + overlay = np.zeros((h, w, 3), dtype=np.float32) + color_tuple = tuple([int(c) for c in color]) + ray_length = int(min(w, h) * size) + for i in range(rays): + angle = (2 * np.pi * i) / rays + x1 = cx + y1 = cy + x2 = int(cx + ray_length * np.cos(angle)) + y2 = int(cy + ray_length * np.sin(angle)) + cv2.line(overlay, (x1, y1), (x2, y2), color_tuple, 3) + radius = int(min(w, h) * size * 0.05) + cv2.circle(overlay, (cx, cy), radius, color_tuple, -1) + overlay = cv2.GaussianBlur(overlay, (31, 31), 0) + return overlay * intensity + + def _create_hexagonal_flare(self, h, w, cx, cy, size, color, intensity): + """Créer un flare hexagonal""" + overlay = np.zeros((h, w, 3), dtype=np.float32) + color_tuple = tuple([int(c) for c in color]) + radius = int(min(w, h) * size * 0.2) + points = [] + for i in range(6): + angle = (2 * np.pi * i) / 6 + x = int(cx + radius * np.cos(angle)) + y = int(cy + radius * np.sin(angle)) + points.append([x, y]) + points = np.array(points, dtype=np.int32) + cv2.fillPoly(overlay, [points], color_tuple) + for i in range(1, 4): + r = radius // (i + 1) + hex_points = [] + for j in range(6): + angle = (2 * np.pi * j) / 6 + x = int(cx + r * np.cos(angle)) + y = int(cy + r * np.sin(angle)) + hex_points.append([x, y]) + hex_points = np.array(hex_points, dtype=np.int32) + hex_overlay = np.zeros_like(overlay) + cv2.fillPoly(hex_overlay, [hex_points], color_tuple) + overlay += hex_overlay * (0.5 / i) + overlay = cv2.GaussianBlur(overlay, (41, 41), 0) + return overlay * intensity diff --git a/light_effects/neon_glow_node.py b/light_effects/neon_glow_node.py new file mode 100644 index 0000000..e684b3d --- /dev/null +++ b/light_effects/neon_glow_node.py @@ -0,0 +1,106 @@ +import numpy as np +import torch +import cv2 + +class NeonGlowNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "glow_color": (["cyan", "magenta", "yellow", "red", "green", "blue", "purple", "orange"], {"default": "cyan"}), + "intensity": ("FLOAT", {"default": 0.8, "min": 0.0, "max": 2.0, "step": 0.1}), + "glow_size": ("FLOAT", {"default": 0.3, "min": 0.1, "max": 1.0, "step": 0.05}), + }, + "optional": { + "edge_threshold": ("FLOAT", {"default": 0.3, "min": 0.1, "max": 1.0, "step": 0.05}), + "inner_glow": ("BOOLEAN", {"default": True}), + "outer_glow": ("BOOLEAN", {"default": True}), + "pulsate": ("BOOLEAN", {"default": False}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_neon_glow" + CATEGORY = "Image Effects" + + def apply_neon_glow(self, image, glow_color, intensity, glow_size, + edge_threshold=0.3, inner_glow=True, outer_glow=True, pulsate=False): + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + result = img_np.copy().astype(np.float32) + + # Couleurs néon prédéfinies + neon_colors = { + "cyan": (0, 255, 255), + "magenta": (255, 0, 255), + "yellow": (255, 255, 0), + "red": (255, 50, 50), + "green": (50, 255, 50), + "blue": (50, 50, 255), + "purple": (200, 50, 255), + "orange": (255, 150, 0) + } + + color = neon_colors[glow_color] + + # Effet de pulsation + pulse_factor = 1.0 + if pulsate: + import time + pulse_factor = 0.7 + 0.3 * np.sin(time.time() * 3) + + effective_intensity = intensity * pulse_factor + + # Détecter les contours + gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY) + edges = cv2.Canny(gray, int(edge_threshold * 100), int(edge_threshold * 200)) + + # Créer l'effet néon + neon_overlay = self._create_neon_effect(edges, color, glow_size, + effective_intensity, inner_glow, outer_glow) + + # Fusionner avec l'image + result = np.clip(result + neon_overlay, 0, 255) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) + + def _create_neon_effect(self, edges, color, glow_size, intensity, inner_glow, outer_glow): + """Créer l'effet néon à partir des contours""" + h, w = edges.shape + overlay = np.zeros((h, w, 3), dtype=np.float32) + + # Convertir les contours en image couleur + edge_color = np.zeros((h, w, 3), dtype=np.float32) + edge_mask = edges > 0 + edge_color[edge_mask] = color + + # Lueur intérieure + if inner_glow: + inner_blur_size = max(3, int(glow_size * 20)) + if inner_blur_size % 2 == 0: + inner_blur_size += 1 + inner_glow_layer = cv2.GaussianBlur(edge_color, (inner_blur_size, inner_blur_size), 0) + overlay += inner_glow_layer * 0.8 + + # Lueur extérieure + if outer_glow: + outer_blur_size = max(5, int(glow_size * 40)) + if outer_blur_size % 2 == 0: + outer_blur_size += 1 + outer_glow_layer = cv2.GaussianBlur(edge_color, (outer_blur_size, outer_blur_size), 0) + overlay += outer_glow_layer * 0.4 + + # Contour principal brillant + overlay += edge_color * 1.5 + + # Appliquer l'intensité + overlay *= intensity + + return overlay diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2f79a15 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,22 @@ +# Image Effects - Dépendances Python +# Package de nœuds d'effets d'image pour ComfyUI + +# Bibliothèques de base +numpy>=1.21.0 +torch>=1.9.0 +torchvision>=0.10.0 + +# Traitement d'image +opencv-python>=4.5.0 +Pillow>=8.0.0 +scipy>=1.7.0 + +# Optionnel pour certains effets avancés +scikit-image>=0.18.0 +matplotlib>=3.3.0 + +# Pour les effets géométriques avancés +shapely>=1.7.0 + +# Accélération GPU (optionnel) +cupy-cuda11x>=9.0.0; platform_system=="Linux" diff --git a/vintage/__init__.py b/vintage/__init__.py new file mode 100644 index 0000000..139fad7 --- /dev/null +++ b/vintage/__init__.py @@ -0,0 +1 @@ +"""Effets rétro et vintage""" \ No newline at end of file diff --git a/vintage/__pycache__/__init__.cpython-312.pyc b/vintage/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7ea58c890f6a74f30a5a1a1916e8c05ddaea5081 GIT binary patch literal 209 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 Sb4>krxgW>vf5Q+IW&Z<+bp>bu literal 0 HcmV?d00001 diff --git a/vintage/__pycache__/light_leaks_node.cpython-312.pyc b/vintage/__pycache__/light_leaks_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7e0be417e6968e673817cd7836165b7696012328 GIT binary patch literal 5822 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 m=M6Vaaev2w{QWXmix}mU$kr;_b zu~A&i6{89QVKiW>hn&9o6EP0_(*{W1qo(ny*}a~KEg3erNF$;#`B9Y-R{Ta>IY(so@j zjQQ;YH*t{7>CWCBL4Bfsuyf>#zgDc>>vrsE%ae=~Qr!>aZw$M~35$uLckf-XMA zgyS(HdK2^Gf-)FQB+!QWzJ8(m=vXj58M>4V`xq+0jCG;Pi+y9kWP*uL24b+ci7|1F z0V))tf=ptpxVo{a;{Jp36sD9)F9wWngU0;_#u#gpyH zH66{>v={3ASvEKH62JfDobP<0!JkdsIdnIX8+)DKPvq(^{23}=TFjM(P0Xw}JOHjR zS`R>Q5>r#rOM%iFrNz&gMJ4m~NTLWLR5_t_iKo5|S{E^K#Z!7#A5oW~7mKo%M$l~AlH5ls=U`-{oVOw-BVJIb5;1KX3{{z< zlz}zu#PJxcE@e!aQf9^?k7kXm0?k$$TT#|$+SX@bD@YZ>PF2FVia8*{Rw9_PvL@C- zs!^X3lFyQfd8$CD(DniOx#jJSl#4!~z)j1o6 z1(iX?voLDg7~{4vmhUh|y={y%58|zS9$j%ApEK3693nMxX)9+PkJ+tnIopg^rW2`^ zOIx}8z&?ilx19zT>)I}1q}8mBtd!+MTIV#-@(3Q1^3Gmgo9kTS%EUEGlJI&xg)>LT*(CEMXOt5yg0{)C>-ES*c3;c=q1d^zz z`P!mJ4zO5c|90MC-_Sk0b;s7R_Q&w@ZJJ{TZtCXDAOU-s%yyE1{UHh1SvBosHMAXg zd~ydK?W~5aEXm7WcB&3MiXCZrw2gFVHEC%vUwZT$-Z%%JHxM z=icnb-hb;D|Mee+fBW};_g@|3e?ko{cRQf-W9G+h)wzG|82{v-mwW#4!GCs)S0DWM zM$PyC33Xoog)d7_@Ezl)Op5(~RrQ;W@joI8zqr$}fy}aT_+i-n!1FX5M~p@iAD4WG zn;jeQYGE56qIP6Y6x%mCz}Zj{v*E|Kku&-lu;@ss1p-l+JmV+l@!#ms8vuCG)-I*@DU(!u~pCoFEU-oIS|ZUgZ+a;fuY{xttW@i$yF$} zLK8ST(=;Qf$^;z zDig6FCFm}O$72)-Q_#vDo|=e_M=3!g(Ge_kQIzt8W{HjB3KQtv^nHg!BSiWM0CNow z`E5ITK}fENZcx)J*jF0NWZ{zUym{@-ys)@SL;RD+c>gip*>PtN@9Y(^%=UL%-X+r3 z`DcDKn87nIWOwt8hk4&&-t_d31`8(Jw%$g*@oC=oG;ad*M|;c1mN;0qIgyyt7vD=Q zrEU-X^yQl`=j)#SSh?2L$G7$6ZKu|3XL;M%dqX2@Lu35VSl;$3*Hx&gdmn$lB|~Ji zOZ_YDt2TarJ73ejn&7LCb6xM^@3zbn^V)0u>rVIL*uq$5_ly^7!S9_6h^InD2^_C?o%D_xbbX1em$ zCh?>k>&Acn>PN5ss`b}xzii7tH^S-W-RUMcpZjvw#>~XZ>?(e9F1Pnc&U%E`9Ys6g zNw<7vtSMA^*Q#3is@Ck`)jfB1<*Np{-h!)c@$$mupU^kvmgn-WR_-j?iMV8fpG_HY)^w#8!$$1+Vf z4lW=3u;FJ-A2j6~+E)X2leu%R+>Na_A6#qh;F~+{2EJ0_j@NKb2lIJq*W69KyJ^M! z)4H2=plRNZym^23oh0w?&$~~rxnJVlFXdi7pLdUP=0fGJoTV81J$X^GA@1ZY&Ed!vx@GP-I8@hqkY zxHH=mt7ajj17?{Il2y!6nXgf9vFm6kL z4S~|Kpoof9fyt#$?g7N!PjALPy-7n5G8v;F7AQs(eI(_BcRmcQQxkD`h|x^+Qvf!Q z0ZID-fJJ5oGlZhiTZo=@x*2sLLzqTBgI`A@(~lkmAZTJ>1Q1cWN36hOCB{qp21ek; z@9!Jv=^Uc_i(OL`!T2@wOK9vU0v$aTdU?ANKx8m7*wu7AZzNLz* zcvM@^f<WU|rB)MYuTcyUD3JE2%%GcMAdXdnvkc?lrh#@* zTPsLo$^tx;!dF|2ux7R*qyRoFz=`QrQSLa#l)|(-hPp~}8pd{GfW0M)9RiHEiqoa^ z=dr*3!L0H;HiP@kNu-XQk$4hR)JG;o)uwzT5li@@gb$gu@ae!8j)g$`!xWSUL0A7zO6plvew@|Nz zK7u;X2^93mq!;}Z8buqVYXW_nqLFw675bJ%qwp_{E*n9|&`C=4fK1@zfJ0LJOrt}( zf8jzLKGUEQ-37@0O6lSd-GUz=5&;Ct?vbf*G$4JuQIX;|Gqe`*`;Ew`phvUnnfn8GzVT?@(a!1DE$(;Ty7pE&oVPS_ zs!feb-@NXwP9M*hvsT{Mns*=K`qu3=YxX_7eNX0%?2)|vIj$QlS*K_5^up=P-j&Ae z$o%QN<0z*uIKh$!bFOcNSndatmNOKp>N5C3G;@AsdU=emYT=Cc%@yX(Q>rC(Huj%KJy|_2sm>JEIoc^A!{dYyH zPM^0l@fzTL?SUsF%xl;MvtFh8tNH&acDsLb|vUe zyg1M`(ln18~`Y=cBmHvWd@0F7UOYN1DAeIUI4g7&}1XufhA61yut_QW%>fI7$MAWaR z+YxQ?2^~Qt@#$2a>FXDjNK_S3KO`O==1b5$D~aG3{WmZc#WoT?0Gmo2$3MqxpJS%a YF~fgg`+02tA5`ZQ`0n3h2#WLnFE7C>>;M1& literal 0 HcmV?d00001 diff --git a/vintage/__pycache__/vhs_glitch_node.cpython-312.pyc b/vintage/__pycache__/vhs_glitch_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..106915b09a31e421f36e27c6436f4098bdb982bf GIT binary patch literal 5610 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 MwDMmF?9+Vz2lQ9sMF0Q* literal 0 HcmV?d00001 diff --git a/vintage/__pycache__/vintage_tv_node.cpython-312.pyc b/vintage/__pycache__/vintage_tv_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..40d3479717ae30e68a084d3e83231ced32b4a916 GIT binary patch literal 7198 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 0: + # Créer le grain à une résolution réduite puis l'agrandir + grain_h = int(h / grain_size) + grain_w = int(w / grain_size) + + if color_grain: + # Grain coloré (différent pour chaque canal) + grain_r = np.random.normal(0, grain_intensity * params["grain_scale"], (grain_h, grain_w)) + grain_g = np.random.normal(0, grain_intensity * params["grain_scale"] * 0.8, (grain_h, grain_w)) + grain_b = np.random.normal(0, grain_intensity * params["grain_scale"] * 0.9, (grain_h, grain_w)) + + # Redimensionner le grain + grain_r = cv2.resize(grain_r, (w, h), interpolation=cv2.INTER_LINEAR) + grain_g = cv2.resize(grain_g, (w, h), interpolation=cv2.INTER_LINEAR) + grain_b = cv2.resize(grain_b, (w, h), interpolation=cv2.INTER_LINEAR) + + grain = np.stack([grain_r, grain_g, grain_b], axis=2) * 30 + else: + # Grain monochrome + grain_mono = np.random.normal(0, grain_intensity * params["grain_scale"], (grain_h, grain_w)) + grain_mono = cv2.resize(grain_mono, (w, h), interpolation=cv2.INTER_LINEAR) + grain = np.stack([grain_mono] * 3, axis=2) * 25 + + # Appliquer le grain + result = result + grain + + # 2. Ajustement du contraste selon le type de film + if params["contrast"] != 1.0: + result = np.clip((result - 127.5) * params["contrast"] + 127.5, 0, 255) + + # 3. Tonalité vintage + if vintage_tone > 0: + # Courbe de tonalité vintage (légèrement sépia) + sepia_matrix = np.array([ + [1 - vintage_tone * 0.3, vintage_tone * 0.2, vintage_tone * 0.1], + [vintage_tone * 0.1, 1 - vintage_tone * 0.1, vintage_tone * 0.1], + [vintage_tone * 0.05, vintage_tone * 0.1, 1 - vintage_tone * 0.2] + ]) + + result = result @ sepia_matrix.T + + # Ajouter de la chaleur + warmth = params["warmth"] * vintage_tone + result[:, :, 0] *= (1 + warmth) # Plus de rouge + result[:, :, 2] *= (1 - warmth * 0.5) # Moins de bleu + + # 4. Vignette + if vignette > 0: + center_x, center_y = w // 2, h // 2 + max_dist = np.sqrt(center_x**2 + center_y**2) + + y, x = np.ogrid[:h, :w] + distance = np.sqrt((x - center_x)**2 + (y - center_y)**2) + vignette_mask = 1 - (distance / max_dist) * vignette + vignette_mask = np.clip(vignette_mask, 0, 1) + + result = result * np.expand_dims(vignette_mask, axis=2) + + # Normalisation finale + result = np.clip(result, 0, 255) + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) diff --git a/vintage/light_leaks_node.py b/vintage/light_leaks_node.py new file mode 100644 index 0000000..6f5f315 --- /dev/null +++ b/vintage/light_leaks_node.py @@ -0,0 +1,115 @@ +import numpy as np +import torch +import cv2 + +class LightLeaksNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "leak_intensity": ("FLOAT", {"default": 0.4, "min": 0.0, "max": 1.0, "step": 0.01}), + "leak_color": (["warm", "cool", "rainbow", "vintage", "custom"], {"default": "warm"}), + "leak_count": ("INT", {"default": 2, "min": 1, "max": 5, "step": 1}), + }, + "optional": { + "leak_position": (["random", "corners", "edges", "center"], {"default": "random"}), + "blur_amount": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "custom_hue": ("FLOAT", {"default": 30.0, "min": 0.0, "max": 360.0, "step": 1.0}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_light_leaks" + CATEGORY = "Image Effects" + + def apply_light_leaks(self, image, leak_intensity, leak_color, leak_count, + leak_position="random", blur_amount=0.5, custom_hue=30.0): + + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + result = img_np.copy().astype(np.float32) + + # Couleurs prédéfinies pour les fuites de lumière + color_palettes = { + "warm": [(255, 200, 100), (255, 150, 80), (255, 180, 120)], + "cool": [(100, 150, 255), (120, 200, 255), (80, 180, 255)], + "rainbow": [(255, 100, 100), (100, 255, 100), (100, 100, 255), (255, 255, 100)], + "vintage": [(255, 220, 180), (255, 200, 150), (240, 180, 120)], + "custom": [(255, 200, 100)] # Sera modifié selon custom_hue + } + + if leak_color == "custom": + # Convertir la teinte personnalisée en RGB + hsv_color = np.array([[[custom_hue / 2, 255, 255]]], dtype=np.uint8) + rgb_color = cv2.cvtColor(hsv_color, cv2.COLOR_HSV2RGB)[0, 0] + color_palettes["custom"] = [tuple(rgb_color)] + + colors = color_palettes[leak_color] + + # Générer les fuites de lumière + for i in range(leak_count): + # Choisir une couleur + color = colors[i % len(colors)] + + # Déterminer la position + if leak_position == "corners": + positions = [(0, 0), (w-1, 0), (0, h-1), (w-1, h-1)] + pos_x, pos_y = positions[i % 4] + elif leak_position == "edges": + edge = i % 4 + if edge == 0: # Top + pos_x, pos_y = np.random.randint(0, w), 0 + elif edge == 1: # Right + pos_x, pos_y = w-1, np.random.randint(0, h) + elif edge == 2: # Bottom + pos_x, pos_y = np.random.randint(0, w), h-1 + else: # Left + pos_x, pos_y = 0, np.random.randint(0, h) + elif leak_position == "center": + pos_x = w // 2 + np.random.randint(-w//4, w//4) + pos_y = h // 2 + np.random.randint(-h//4, h//4) + else: # random + pos_x = np.random.randint(0, w) + pos_y = np.random.randint(0, h) + + # Créer le masque de fuite + leak_mask = self._create_leak_mask(h, w, pos_x, pos_y, leak_intensity) + + # Appliquer le flou au masque + if blur_amount > 0: + blur_size = int(blur_amount * 50) * 2 + 1 + leak_mask = cv2.GaussianBlur(leak_mask, (blur_size, blur_size), 0) + + # Appliquer la couleur + for ch in range(3): + color_layer = leak_mask * color[ch] * leak_intensity + result[:, :, ch] = np.clip(result[:, :, ch] + color_layer, 0, 255) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) + + def _create_leak_mask(self, h, w, center_x, center_y, intensity): + """Créer un masque de fuite de lumière organique""" + # Créer un dégradé radial de base + y, x = np.ogrid[:h, :w] + distance = np.sqrt((x - center_x)**2 + (y - center_y)**2) + max_distance = np.sqrt(w**2 + h**2) / 2 + + # Masque radial de base + mask = 1 - (distance / max_distance) + mask = np.clip(mask, 0, 1) + + # Ajouter de la variation organique avec du bruit + noise = np.random.random((h, w)) * 0.3 + mask = mask * (0.7 + noise) + + # Appliquer une courbe non-linéaire pour un effet plus naturel + mask = np.power(mask, 2) * intensity + + return np.clip(mask, 0, 1) diff --git a/vintage/polaroid_node.py b/vintage/polaroid_node.py new file mode 100644 index 0000000..e337cf3 --- /dev/null +++ b/vintage/polaroid_node.py @@ -0,0 +1,156 @@ +import numpy as np +import torch +import cv2 +from PIL import Image, ImageDraw, ImageFont + +class PolaroidNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "border_size": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 0.3, "step": 0.01}), + "vintage_tone": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01}), + "fade_amount": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01}), + }, + "optional": { + "add_text": ("BOOLEAN", {"default": False}), + "text_content": ("STRING", {"default": "Summer '85", "multiline": False}), + "paper_texture": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01}), + "color_shift": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 1.0, "step": 0.01}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_polaroid_effect" + CATEGORY = "Image Effects" + + def apply_polaroid_effect(self, image, border_size, vintage_tone, fade_amount, + add_text=False, text_content="Summer '85", paper_texture=0.3, color_shift=0.1): + + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + + # 1. Calculer les dimensions avec bordure + border_h = int(h * border_size) + border_w = int(w * border_size) + bottom_border = border_h * 3 # Bordure inférieure plus large (caractéristique Polaroid) + + new_h = h + border_h + bottom_border + new_w = w + border_w * 2 + + # 2. Créer l'image avec bordure blanche + polaroid = np.full((new_h, new_w, c), 245, dtype=np.uint8) # Blanc cassé + + # 3. Traitement de l'image principale + result = img_np.copy().astype(np.float32) + + # Effet vintage/sépia + if vintage_tone > 0: + sepia_matrix = np.array([ + [0.393, 0.769, 0.189], + [0.349, 0.686, 0.168], + [0.272, 0.534, 0.131] + ]) + sepia_img = result @ sepia_matrix.T + result = result * (1 - vintage_tone) + sepia_img * vintage_tone + + # Décoloration caractéristique des Polaroids + if fade_amount > 0: + # Réduire légèrement le contraste + result = (result - 127.5) * (1 - fade_amount * 0.3) + 127.5 + + # Ajouter une teinte jaunâtre + result[:, :, 0] *= (1 + fade_amount * 0.1) # Plus de rouge + result[:, :, 1] *= (1 + fade_amount * 0.05) # Légèrement plus de vert + result[:, :, 2] *= (1 - fade_amount * 0.1) # Moins de bleu + + # Décalage de couleur subtil + if color_shift > 0: + hsv = cv2.cvtColor(result.astype(np.uint8), cv2.COLOR_RGB2HSV).astype(np.float32) + hsv[:, :, 0] = (hsv[:, :, 0] + color_shift * 10) % 180 + result = cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2RGB).astype(np.float32) + + # Vignette douce + center_x, center_y = w // 2, h // 2 + max_dist = np.sqrt(center_x**2 + center_y**2) + y, x = np.ogrid[:h, :w] + distance = np.sqrt((x - center_x)**2 + (y - center_y)**2) + vignette = 1 - (distance / max_dist) * 0.2 + vignette = np.clip(vignette, 0.8, 1) + result = result * np.expand_dims(vignette, axis=2) + + result = np.clip(result, 0, 255) + + # 4. Placer l'image dans la bordure + polaroid[border_h:border_h+h, border_w:border_w+w] = result.astype(np.uint8) + + # 5. Ajouter de la texture papier + if paper_texture > 0: + texture = self._generate_paper_texture(new_h, new_w, paper_texture) + polaroid = polaroid.astype(np.float32) + polaroid += texture + polaroid = np.clip(polaroid, 0, 255) + + # 6. Ajouter du texte si demandé + if add_text and text_content: + polaroid = self._add_handwritten_text(polaroid, text_content, bottom_border) + + # 7. Légère rotation aléatoire pour un effet authentique + angle = np.random.uniform(-2, 2) + center = (new_w // 2, new_h // 2) + rotation_matrix = cv2.getRotationMatrix2D(center, angle, 1.0) + polaroid = cv2.warpAffine(polaroid.astype(np.uint8), rotation_matrix, (new_w, new_h), + borderMode=cv2.BORDER_CONSTANT, borderValue=(240, 240, 240)) + + result_tensor = torch.from_numpy(polaroid.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) + + def _generate_paper_texture(self, h, w, intensity): + """Générer une texture de papier photo""" + # Créer du bruit pour simuler la texture du papier + texture = np.random.normal(0, intensity * 10, (h, w)) + + # Ajouter des variations de grain plus grossières + coarse_texture = np.random.normal(0, intensity * 5, (h // 4, w // 4)) + coarse_texture = cv2.resize(coarse_texture, (w, h), interpolation=cv2.INTER_LINEAR) + + texture = texture + coarse_texture + texture = np.expand_dims(texture, axis=2) + texture = np.repeat(texture, 3, axis=2) + + return texture + + def _add_handwritten_text(self, image, text, bottom_border): + """Ajouter du texte dans la bordure inférieure""" + h, w = image.shape[:2] + + # Convertir en PIL pour le texte + pil_image = Image.fromarray(image.astype(np.uint8)) + draw = ImageDraw.Draw(pil_image) + + # Essayer de charger une police + try: + font_size = max(12, bottom_border // 4) + font = ImageFont.truetype("arial.ttf", font_size) + except: + font = ImageFont.load_default() + + # Position du texte (centré dans la bordure inférieure) + text_bbox = draw.textbbox((0, 0), text, font=font) + text_width = text_bbox[2] - text_bbox[0] + text_height = text_bbox[3] - text_bbox[1] + + x = (w - text_width) // 2 + y = h - bottom_border + (bottom_border - text_height) // 2 + + # Couleur du texte (gris foncé pour un effet authentique) + text_color = (80, 80, 80) + draw.text((x, y), text, fill=text_color, font=font) + + return np.array(pil_image) diff --git a/vintage/vhs_glitch_node.py b/vintage/vhs_glitch_node.py new file mode 100644 index 0000000..2e5149d --- /dev/null +++ b/vintage/vhs_glitch_node.py @@ -0,0 +1,91 @@ +import numpy as np +import torch +import cv2 +from PIL import Image, ImageEnhance + +class VHSGlitchNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "glitch_intensity": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "color_shift": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01}), + "scan_lines": ("BOOLEAN", {"default": True}), + "noise_amount": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01}), + }, + "optional": { + "tracking_errors": ("BOOLEAN", {"default": True}), + "color_bleeding": ("FLOAT", {"default": 0.4, "min": 0.0, "max": 1.0, "step": 0.01}), + "frame_jitter": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 1.0, "step": 0.01}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_vhs_glitch" + CATEGORY = "Image Effects" + + def apply_vhs_glitch(self, image, glitch_intensity, color_shift, scan_lines, noise_amount, + tracking_errors=True, color_bleeding=0.4, frame_jitter=0.1): + + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + result = img_np.copy() + + # 1. Décalages horizontaux (tracking errors) + if tracking_errors and glitch_intensity > 0: + max_shift = int(glitch_intensity * 30) + for i in range(0, h, np.random.randint(3, 8)): + if np.random.random() < glitch_intensity: + shift = np.random.randint(-max_shift, max_shift) + end_row = min(i + np.random.randint(1, 5), h) + result[i:end_row] = np.roll(result[i:end_row], shift, axis=1) + + # 2. Saignement de couleur (color bleeding) + if color_bleeding > 0: + # Séparer les canaux RGB + r_channel = result[:, :, 0].astype(np.float32) + g_channel = result[:, :, 1].astype(np.float32) + b_channel = result[:, :, 2].astype(np.float32) + + # Appliquer un flou horizontal différent à chaque canal + blur_amount = int(color_bleeding * 5) + if blur_amount > 0: + kernel = np.ones((1, blur_amount)) / blur_amount + r_channel = cv2.filter2D(r_channel, -1, kernel) + b_channel = cv2.filter2D(b_channel, -1, kernel * 0.8) + + result = np.stack([r_channel, g_channel, b_channel], axis=2).astype(np.uint8) + + # 3. Décalage de couleur chromatique + if color_shift > 0: + hsv = cv2.cvtColor(result, cv2.COLOR_RGB2HSV).astype(np.float32) + hsv[:, :, 0] = (hsv[:, :, 0] + color_shift * 180) % 180 + hsv[:, :, 1] = np.clip(hsv[:, :, 1] * (1 + color_shift * 0.3), 0, 255) + result = cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2RGB) + + # 4. Lignes de balayage (scan lines) + if scan_lines: + for i in range(1, h, 2): + result[i] = (result[i] * 0.8).astype(np.uint8) + + # 5. Bruit VHS + if noise_amount > 0: + noise = np.random.randint(0, int(noise_amount * 50), (h, w, c)) + result = np.clip(result.astype(np.int16) + noise - noise_amount * 25, 0, 255).astype(np.uint8) + + # 6. Tremblement de l'image (frame jitter) + if frame_jitter > 0: + jitter_x = int(np.random.uniform(-frame_jitter * 5, frame_jitter * 5)) + jitter_y = int(np.random.uniform(-frame_jitter * 3, frame_jitter * 3)) + M = np.float32([[1, 0, jitter_x], [0, 1, jitter_y]]) + result = cv2.warpAffine(result, M, (w, h), borderMode=cv2.BORDER_REFLECT) + + # Conversion finale + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) diff --git a/vintage/vintage_tv_node.py b/vintage/vintage_tv_node.py new file mode 100644 index 0000000..2e0e59f --- /dev/null +++ b/vintage/vintage_tv_node.py @@ -0,0 +1,125 @@ +import numpy as np +import torch +import cv2 + +class VintageTVNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "tv_type": (["crt_color", "crt_bw", "old_tv", "security_monitor"], {"default": "crt_color"}), + "scan_lines": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "curvature": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01}), + }, + "optional": { + "static_noise": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 1.0, "step": 0.01}), + "phosphor_glow": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01}), + "brightness": ("FLOAT", {"default": 0.9, "min": 0.5, "max": 1.5, "step": 0.01}), + "contrast": ("FLOAT", {"default": 1.1, "min": 0.5, "max": 2.0, "step": 0.01}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_vintage_tv" + CATEGORY = "Image Effects" + + def apply_vintage_tv(self, image, tv_type, scan_lines, curvature, + static_noise=0.1, phosphor_glow=0.2, brightness=0.9, contrast=1.1): + + if len(image.shape) == 4: + img_tensor = image[0] + else: + img_tensor = image + + img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) + h, w, c = img_np.shape + result = img_np.copy().astype(np.float32) + + # 1. Appliquer la courbure de l'écran CRT + if curvature > 0: + result = self._apply_crt_curvature(result, curvature) + + # 2. Ajustements selon le type de TV + if tv_type == "crt_bw": + # Convertir en noir et blanc avec une légère teinte verte + gray = cv2.cvtColor(result.astype(np.uint8), cv2.COLOR_RGB2GRAY) + result = np.stack([gray * 0.9, gray, gray * 0.9], axis=2).astype(np.float32) + elif tv_type == "security_monitor": + # Effet moniteur de sécurité (vert monochrome) + gray = cv2.cvtColor(result.astype(np.uint8), cv2.COLOR_RGB2GRAY) + result = np.stack([gray * 0.3, gray, gray * 0.3], axis=2).astype(np.float32) + elif tv_type == "old_tv": + # TV ancienne avec saturation réduite + hsv = cv2.cvtColor(result.astype(np.uint8), cv2.COLOR_RGB2HSV).astype(np.float32) + hsv[:, :, 1] *= 0.7 # Réduire la saturation + result = cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2RGB).astype(np.float32) + + # 3. Lignes de balayage + if scan_lines > 0: + for i in range(0, h, 2): + result[i] *= (1 - scan_lines * 0.3) + + # Ajouter des lignes horizontales plus prononcées + for i in range(0, h, 4): + if i < h: + result[i] *= (1 - scan_lines * 0.5) + + # 4. Bruit statique + if static_noise > 0: + noise = np.random.random((h, w, c)) * static_noise * 100 + salt_pepper = np.random.random((h, w, c)) < static_noise * 0.01 + result[salt_pepper] = np.random.choice([0, 255], size=np.sum(salt_pepper)) + result = np.clip(result + noise - static_noise * 50, 0, 255) + + # 5. Lueur phosphore + if phosphor_glow > 0: + # Créer un effet de lueur en dupliquant et floutant l'image + glow = cv2.GaussianBlur(result.astype(np.uint8), (15, 15), 0).astype(np.float32) + result = result * (1 - phosphor_glow * 0.3) + glow * phosphor_glow * 0.3 + + # 6. Ajustements de luminosité et contraste + result = np.clip((result - 127.5) * contrast + 127.5, 0, 255) + result = np.clip(result * brightness, 0, 255) + + # 7. Vignette CRT + center_x, center_y = w // 2, h // 2 + max_dist = np.sqrt(center_x**2 + center_y**2) + y, x = np.ogrid[:h, :w] + distance = np.sqrt((x - center_x)**2 + (y - center_y)**2) + vignette = 1 - (distance / max_dist) * 0.3 + vignette = np.clip(vignette, 0.7, 1) + result = result * np.expand_dims(vignette, axis=2) + + result_tensor = torch.from_numpy(result.astype(np.float32) / 255.0).unsqueeze(0) + return (result_tensor,) + + def _apply_crt_curvature(self, image, strength): + """Appliquer la courbure caractéristique des écrans CRT""" + h, w = image.shape[:2] + + # Créer la grille de déformation + map_x = np.zeros((h, w), dtype=np.float32) + map_y = np.zeros((h, w), dtype=np.float32) + + center_x, center_y = w / 2, h / 2 + + for y in range(h): + for x in range(w): + # Normaliser les coordonnées + norm_x = (x - center_x) / center_x + norm_y = (y - center_y) / center_y + + # Appliquer la déformation barrel + r2 = norm_x * norm_x + norm_y * norm_y + distortion = 1 + strength * 0.1 * r2 + + new_x = center_x + norm_x * center_x * distortion + new_y = center_y + norm_y * center_y * distortion + + map_x[y, x] = np.clip(new_x, 0, w - 1) + map_y[y, x] = np.clip(new_y, 0, h - 1) + + # Appliquer la déformation + result = cv2.remap(image.astype(np.uint8), map_x, map_y, cv2.INTER_LINEAR) + return result.astype(np.float32) diff --git a/web/css/css_filters.css b/web/css/css_filters.css new file mode 100644 index 0000000..0eabaab --- /dev/null +++ b/web/css/css_filters.css @@ -0,0 +1,23 @@ +.css-filters-node { + border: 2px solid #4CAF50 !important; + border-radius: 8px !important; + box-shadow: 0 4px 8px rgba(76, 175, 80, 0.3) !important; +} + +.css-filters-node .litegraph-title { + background: linear-gradient(45deg, #2a4d3a, #4CAF50) !important; + color: white !important; + font-weight: bold !important; +} + +/* Styles pour les sliders */ +.css-filters-node input[type="range"] { + background: #4CAF50 !important; + border-radius: 5px !important; +} + +.css-filters-node input[type="range"]::-webkit-slider-thumb { + background: #ffffff !important; + border: 2px solid #4CAF50 !important; + border-radius: 50% !important; +} diff --git a/web/js/css_filters.js b/web/js/css_filters.js new file mode 100644 index 0000000..ea443a8 --- /dev/null +++ b/web/js/css_filters.js @@ -0,0 +1,45 @@ +import { app } from "../../scripts/app.js"; + +app.registerExtension({ + name: "ImageEffects.CSSFilters", + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name === "CSSFiltersNode") { + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + onNodeCreated?.apply(this, arguments); + + // Ajouter des styles CSS personnalisés au nœud + this.addProperty("css_style", "filter-panel", "string"); + + // Personnaliser l'apparence + this.color = "#2a4d3a"; + this.bgcolor = "#1a2d2a"; + this.title_text_color = "#ffffff"; + + // Ajouter une classe CSS personnalisée + if (this.domElement) { + this.domElement.classList.add("css-filters-node"); + } + }; + + // Personnaliser l'affichage des widgets + const onDrawForeground = nodeType.prototype.onDrawForeground; + nodeType.prototype.onDrawForeground = function (ctx) { + onDrawForeground?.apply(this, arguments); + + // Dessiner un indicateur visuel des filtres actifs + const activeFilters = this.widgets.filter(w => + w.value !== w.options?.default && w.value !== 0 && w.value !== 100 + ).length; + + if (activeFilters > 0) { + ctx.fillStyle = "#4CAF50"; + ctx.fillRect(this.size[0] - 20, 5, 15, 15); + ctx.fillStyle = "#ffffff"; + ctx.font = "10px Arial"; + ctx.fillText(activeFilters.toString(), this.size[0] - 17, 15); + } + }; + } + } +});