Files
Bouletto 4e5b534b04 Initial release - 32 image effect nodes
Complete package with Core, Creative, Vintage, Deformation, Light Effects, and Geometric categories
2025-05-28 00:18:09 +02:00

191 lines
7.4 KiB
Python

import numpy as np
import torch
from PIL import Image, ImageFont, ImageDraw, ImageEnhance
class AsciiArtNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"ascii_resolution": ("INT", {"default": 80, "min": 20, "max": 200, "step": 10}),
"style": (["classic", "detailed", "minimal", "blocks"], {"default": "classic"}),
"invert": ("BOOLEAN", {"default": False}),
},
"optional": {
"background_color": (["white", "black", "gray"], {"default": "white"}),
"text_color": (["black", "white", "auto"], {"default": "black"}),
"contrast_boost": ("FLOAT", {"default": 2.0, "min": 1.0, "max": 4.0, "step": 0.1}),
"height_compression": ("FLOAT", {"default": 2.0, "min": 1.0, "max": 3.0, "step": 0.1}),
"font_scale": ("FLOAT", {"default": 0.9, "min": 0.3, "max": 1.5, "step": 0.1}),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "generate_ascii_art"
CATEGORY = "Image Effects"
def generate_ascii_art(self, image, ascii_resolution, style, invert,
background_color="white", text_color="black",
contrast_boost=2.0, height_compression=2.0, font_scale=0.9):
# Prendre la première image du batch
if len(image.shape) == 4:
img_tensor = image[0]
else:
img_tensor = image
# Convertir en numpy
img_np = img_tensor.cpu().numpy()
original_h, original_w, c = img_np.shape
print(f"Image d'entrée: {original_w}x{original_h} pixels")
# Convertir en niveaux de gris
gray = np.dot(img_np[..., :3], [0.2989, 0.5870, 0.1140])
# Calculer les dimensions ASCII avec compression de hauteur
ascii_width = ascii_resolution
ascii_height = int(ascii_width * (original_h / original_w) / height_compression)
print(f"Résolution ASCII: {ascii_width}x{ascii_height} caractères")
# Redimensionner l'image pour l'analyse ASCII
gray_pil = Image.fromarray((gray * 255).astype(np.uint8))
resized = np.array(gray_pil.resize((ascii_width, ascii_height), Image.Resampling.LANCZOS))
# Améliorer le contraste AVANT la conversion ASCII
resized = self._enhance_contrast(resized, contrast_boost)
# Inverser si demandé
if invert:
resized = 255 - resized
# Choisir le jeu de caractères
ascii_chars = self._get_ascii_chars(style)
# Normaliser et mapper aux caractères
norm_pixels = (resized / 255) * (len(ascii_chars) - 1)
norm_pixels = norm_pixels.astype(int)
ascii_image = ascii_chars[norm_pixels]
# Convertir en lignes de texte
ascii_lines = ["".join(row) for row in ascii_image]
# Créer l'image finale avec rendu optimisé
result_image = self._create_high_quality_output(
ascii_lines, original_w, original_h,
background_color, text_color, font_scale
)
print(f"Image de sortie: {original_w}x{original_h} pixels")
# Convertir en tensor ComfyUI
result_tensor = torch.from_numpy(result_image).unsqueeze(0)
return (result_tensor,)
def _enhance_contrast(self, image_array, boost_factor):
"""Améliorer drastiquement le contraste"""
# Normaliser
normalized = image_array.astype(np.float32) / 255.0
# Appliquer un boost de contraste plus agressif
enhanced = np.power(normalized, 1.0 / boost_factor)
# Étalement d'histogramme
min_val = np.min(enhanced)
max_val = np.max(enhanced)
if max_val > min_val:
enhanced = (enhanced - min_val) / (max_val - min_val)
# Appliquer une courbe en S pour plus de contraste
enhanced = 0.5 * (1 + np.tanh(4 * (enhanced - 0.5)))
return (enhanced * 255).astype(np.uint8)
def _get_ascii_chars(self, style):
"""Jeux de caractères avec meilleur contraste"""
styles = {
"classic": np.array(list(" .:-=+*#%@")),
"detailed": np.array(list(" ░▒▓█")),
"minimal": np.array(list(" .-#@")),
"blocks": np.array(list(" ▁▂▃▄▅▆▇█"))
}
return styles.get(style, styles["classic"])
def _create_high_quality_output(self, ascii_lines, target_width, target_height,
background_color, text_color, font_scale):
"""Créer une image sans bord avec remplissage complet"""
# Définir les couleurs
bg_colors = {"white": (255, 255, 255), "black": (0, 0, 0), "gray": (128, 128, 128)}
txt_colors = {"white": (255, 255, 255), "black": (0, 0, 0), "auto": None}
bg_color = bg_colors.get(background_color, (255, 255, 255))
txt_color = txt_colors.get(text_color, (0, 0, 0))
# Auto color avec contraste maximal
if text_color == "auto":
txt_color = (0, 0, 0) if background_color == "white" else (255, 255, 255)
# Créer l'image
img = Image.new("RGB", (target_width, target_height), color=bg_color)
draw = ImageDraw.Draw(img)
# Calculer les dimensions
chars_width = len(ascii_lines[0])
chars_height = len(ascii_lines)
# Calculer l'espacement pour REMPLIR COMPLÈTEMENT l'image
char_spacing_w = target_width / chars_width
char_spacing_h = target_height / chars_height
# Taille de police pour remplir l'espace
font_size = int(min(char_spacing_w, char_spacing_h) * font_scale)
font_size = max(1, font_size)
print(f"Taille de police calculée: {font_size}px")
# Charger une police monospace avec fallback robuste
font = self._load_best_font(font_size)
# Dessiner le texte en remplissant TOUTE l'image
for row, line in enumerate(ascii_lines):
for col, char in enumerate(line):
x = int(col * char_spacing_w)
y = int(row * char_spacing_h)
draw.text((x, y), char, fill=txt_color, font=font)
# Améliorer le contraste de l'image finale
enhancer = ImageEnhance.Contrast(img)
img = enhancer.enhance(1.5)
# Convertir en numpy array
img_array = np.array(img).astype(np.float32) / 255.0
return img_array
def _load_best_font(self, font_size):
"""Charger la meilleure police monospace disponible"""
fonts_to_try = [
"consola.ttf", # Windows
"Monaco.ttf", # macOS
"DejaVuSansMono.ttf", # Linux
"LiberationMono-Regular.ttf", # Linux alternative
"CourierNew.ttf" # Fallback
]
for font_name in fonts_to_try:
try:
font = ImageFont.truetype(font_name, font_size)
print(f"Police chargée: {font_name}")
return font
except:
continue
# Dernière option
print("Utilisation de la police par défaut")
return ImageFont.load_default()