Files
Ranska 1aa58df99d ajout nodes manipulation de palettes (batch 2)
4 nouvelles methodes sur PixelPalette : copy_subset, append_palette,
insert_palette et mix_with_palette avec leurs specs mamba (31 tests).

4 nouveaux nodes ComfyUI : CopySubsetNode, AppendPaletteNode,
InsertPaletteNode et MixPaletteNode avec workflows de test 11-14.
2026-02-14 22:21:20 +01:00

445 lines
15 KiB
Python

# lib/pixel_palette.py
import re
from typing import List, Tuple, Optional, Dict, Any
from dataclasses import dataclass, field
from pathlib import Path
from .pixel_color import PixelColor
from .palette.exporter_context import PaletteExporter
@dataclass
class PixelPalette:
"""
Classe métier pour gérer les palettes de couleurs
Supporte les formats GIMP (.gpl), Adobe (.aco), etc.
"""
name: str = "Untitled Palette"
colors: List[PixelColor] = field(default_factory=list)
raw_content: str = ""
source_filename: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
format_type: str = "unknown"
def __post_init__(self):
# Parse automatiquement si du contenu est fourni
if self.raw_content.strip():
self._parse_content()
@classmethod
def create_monochrome(cls, color: PixelColor, count: int, name: Optional[str] = None) -> 'PixelPalette':
"""
Crée une palette monochrome avec une couleur répétée count fois
Args:
color: La couleur de base à répéter
count: Le nombre de fois à répéter la couleur
name: Nom optionnel de la palette
Returns:
PixelPalette: Une nouvelle palette contenant la couleur répétée
Raises:
ValueError: Si count est inférieur ou égal à 0
"""
if count <= 0:
raise ValueError("Le count doit être supérieur à 0")
# Nom par défaut basé sur la couleur
if name is None:
color_name = color.name if color.name else color.hex
name = f"Palette monochrome - {color_name}"
# Créer la liste de couleurs
colors = []
for i in range(count):
color_copy = color.create_copy()
colors.append(color_copy)
pixel_palette = cls()
# pixel_palette.name = name
pixel_palette.colors = colors
return pixel_palette
@classmethod
def create_gradient_palette(cls, color_start: PixelColor, color_end: PixelColor,
num_colors: int, color_space: str = "rgb") -> 'PixelPalette':
"""
Crée une palette de dégradé entre deux couleurs
Args:
color_start: Couleur de départ
color_end: Couleur d'arrivée
num_colors: Nombre total de couleurs dans la palette (minimum 2)
color_space: Espace de couleur pour l'interpolation ("rgb" ou "hsv")
Returns:
PixelPalette: Nouvelle palette contenant le dégradé
Raises:
ValueError: Si num_colors < 2
"""
if num_colors < 2:
raise ValueError("Le nombre de couleurs doit être au minimum 2")
from .color.color_space_registry import ColorSpaceRegistry
mixer = ColorSpaceRegistry.get_mixer_class(color_space)
palette = cls()
for i in range(num_colors):
ratio = i / (num_colors - 1)
if ratio == 0.0:
new_color = color_start.create_copy()
elif ratio == 1.0:
new_color = color_end.create_copy()
else:
r, g, b = mixer.mix_with(color_start, color_end, ratio)
new_color = PixelColor(r, g, b)
palette.colors.append(new_color)
return palette
def __init__(self, name: str = "Untitled Palette",
colors: Optional[List[PixelColor]] = None,
raw_content: str = "",
source_filename: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
format_type: str = "unknown"):
self.name = name
self.colors = colors if colors is not None else []
self.raw_content = raw_content
self.source_filename = source_filename
self.metadata = metadata if metadata is not None else {}
self.format_type = format_type
# Parse automatiquement si du contenu est fourni
if self.raw_content.strip():
self._parse_content()
def _parse_content(self):
"""Parse le contenu selon le format détecté"""
if not self.raw_content.strip():
return
from .parsers.parser_registry import ParserRegistry
# Essayer les parsers enregistrés
for format_name in ParserRegistry.available_formats():
parser_class = ParserRegistry.get_parser_class(format_name)
parser = parser_class()
if parser.can_parse(self.raw_content):
self.format_type = format_name
self.colors = parser.parse(self.raw_content)
break
# === Méthodes utilitaires ===
def add_color(self, r: int, g: int, b: int, name: str = "") -> None:
"""Ajoute une couleur à la palette"""
self.colors.append(PixelColor(r, g, b, name))
def remove_color(self, index: int) -> bool:
"""Supprime une couleur par index"""
if 0 <= index < len(self.colors):
del self.colors[index]
return True
return False
def find_closest_color(self, target_color: PixelColor) -> Tuple[PixelColor, int]:
"""Trouve la couleur la plus proche dans la palette"""
if not self.colors:
raise ValueError("Palette vide")
closest = self.colors[0]
closest_index = 0
min_distance = target_color.distance_to(closest)
for i, color in enumerate(self.colors[1:], 1):
distance = target_color.distance_to(color)
if distance < min_distance:
min_distance = distance
closest = color
closest_index = i
return closest, closest_index
def get_unique_colors(self) -> List[PixelColor]:
"""Retourne les couleurs uniques de la palette"""
seen = set()
unique = []
for color in self.colors:
rgb_key = color.rgb_tuple
if rgb_key not in seen:
seen.add(rgb_key)
unique.append(color)
return unique
def sort_by_hue(self) -> None:
"""Trie les couleurs par teinte"""
import colorsys
def get_hue(color: PixelColor):
r, g, b = color.rgb_normalized
h, s, v = colorsys.rgb_to_hsv(r, g, b)
return h
self.colors.sort(key=get_hue)
def sort_by_brightness(self) -> None:
"""Trie les couleurs par luminosité"""
def get_brightness(color: PixelColor):
# Formule de luminosité perceptuelle
return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b
self.colors.sort(key=get_brightness)
def create_gradient(self, index1: int, index2: int, color_space: str = "rgb") -> None:
"""
Crée un dégradé entre deux couleurs en modifiant les couleurs existantes entre les index.
Args:
index1: Index de la première couleur (doit être < index2)
index2: Index de la deuxième couleur
color_space: Espace de couleur pour l'interpolation ("rgb" ou "hsv")
Raises:
ValueError: Si index1 >= index2 ou s'il n'y a pas au moins une couleur entre
IndexError: Si les index sont hors limites
"""
# Validation des paramètres
if index1 >= index2:
raise ValueError("index1 doit être inférieur à index2")
if index1 < 0 or index2 >= len(self.colors):
raise IndexError("Index hors limites")
# Vérifier qu'il y a au moins une couleur entre les deux
if index2 - index1 < 2:
raise ValueError("Il doit y avoir au moins une couleur entre index1 et index2")
# Récupérer les couleurs de départ et d'arrivée
start_color = self.colors[index1]
end_color = self.colors[index2]
# Calculer le nombre de couleurs intermédiaires
num_intermediate = index2 - index1 - 1
# Pour chaque couleur intermédiaire
for i in range(1, num_intermediate + 1):
# Calculer le ratio (de 0.0 à 1.0)
ratio = i / (num_intermediate + 1)
# Calculer l'index de la couleur à modifier
current_index = index1 + i
# Utiliser le mixer approprié selon l'espace de couleur
from .color.color_space_registry import ColorSpaceRegistry
mixer = ColorSpaceRegistry.get_mixer_class(color_space)
# Calculer la nouvelle couleur
new_r, new_g, new_b = mixer.mix_with(start_color, end_color, ratio)
# Mettre à jour la couleur existante (conserver le nom)
original_name = self.colors[current_index].name
self.colors[current_index] = PixelColor(new_r, new_g, new_b, original_name)
# === Manipulation de palettes ===
def copy_subset(self, start: int, end: int) -> 'PixelPalette':
"""
Retourne une nouvelle palette contenant les couleurs de start à end (inclusif)
Args:
start: Index de début (inclusif)
end: Index de fin (inclusif)
Returns:
PixelPalette: Nouvelle palette avec le sous-ensemble de couleurs
Raises:
ValueError: Si start > end
IndexError: Si les bornes sont hors limites
"""
if start > end:
raise ValueError("start doit être inférieur ou égal à end")
if start < 0 or end >= len(self.colors):
raise IndexError("Index hors limites")
subset_colors = [c.create_copy() for c in self.colors[start:end + 1]]
return PixelPalette(colors=subset_colors)
def append_palette(self, other: 'PixelPalette') -> 'PixelPalette':
"""
Retourne une nouvelle palette = self.colors + other.colors
Args:
other: Palette à ajouter à la fin
Returns:
PixelPalette: Nouvelle palette combinée
"""
combined = [c.create_copy() for c in self.colors] + \
[c.create_copy() for c in other.colors]
name = f"{self.name} + {other.name}"
return PixelPalette(name=name, colors=combined)
def insert_palette(self, other: 'PixelPalette', index: int) -> 'PixelPalette':
"""
Retourne une nouvelle palette avec other.colors insérées à l'index donné
Args:
other: Palette à insérer
index: Position d'insertion (0 = début, len(self) = fin)
Returns:
PixelPalette: Nouvelle palette avec les couleurs insérées
Raises:
IndexError: Si index hors de [0, len(self)]
"""
if index < 0 or index > len(self.colors):
raise IndexError("Index d'insertion hors limites")
before = [c.create_copy() for c in self.colors[:index]]
inserted = [c.create_copy() for c in other.colors]
after = [c.create_copy() for c in self.colors[index:]]
return PixelPalette(colors=before + inserted + after)
def mix_with_palette(self, other: 'PixelPalette', ratio: float = 0.5,
color_space: str = "rgb", offset: int = 0) -> 'PixelPalette':
"""
Mixe couleur par couleur avec une autre palette (position + offset)
Les couleurs sans correspondance (taille différente) sont copiées telles quelles.
Args:
other: Palette avec laquelle mixer
ratio: Ratio du mélange (0.0 = tout self, 1.0 = tout other)
color_space: Espace colorimétrique pour le mélange
offset: Décalage d'index appliqué à other
Returns:
PixelPalette: Nouvelle palette mixée
"""
from .color.color_space_registry import ColorSpaceRegistry
mixer = ColorSpaceRegistry.get_mixer_class(color_space)
max_len = max(len(self.colors), len(other.colors) + offset)
result_colors = []
for i in range(max_len):
has_self = i < len(self.colors)
other_idx = i - offset
has_other = 0 <= other_idx < len(other.colors)
if has_self and has_other:
r, g, b = mixer.mix_with(self.colors[i], other.colors[other_idx], ratio)
result_colors.append(PixelColor(r, g, b))
elif has_self:
result_colors.append(self.colors[i].create_copy())
elif has_other:
result_colors.append(other.colors[other_idx].create_copy())
return PixelPalette(colors=result_colors)
# === Export ===
def to_list(self, format_type: str = "hex", include_names: bool = False) -> List[str]:
"""
Exporte la palette comme liste de strings
Args:
format_type: "hex", "rgb", etc.
include_names: Inclure les noms des couleurs
Returns:
List[str]: Liste des couleurs formatées
"""
with PaletteExporter(self, format_type) as ctx:
return ctx.export_list(include_names=include_names)
def to_formatted_string(self, format_type: str = "rgb", separator: str = "\n",
include_header: bool = True, include_names: bool = True) -> str:
"""
Formate la palette selon le type demandé
Args:
format_type: "rgb", "hex", "raw", "gimp"
separator: Séparateur entre les couleurs
include_header: Inclure les métadonnées en en-tête
include_names: Inclure les noms des couleurs
"""
if self.is_empty:
return "# Palette vide"
# En-tête avec métadonnées
header = ""
if include_header:
header_lines = [
f"# Palette: {self.name}",
f"# Couleurs: {self.color_count}",
f"# Format: {self.format_type}"
]
if self.source_filename:
header_lines.append(f"# Source: {self.source_filename}")
header_lines.append("#")
header = "\n".join(header_lines) + "\n"
# Utiliser le contexte d'export
with PaletteExporter(self, format_type) as ctx:
content = ctx.export(separator=separator, include_names=include_names)
return header + content
def to_rgb_tuples(self) -> List[Tuple[int, int, int]]:
"""
Exporte la palette comme liste de tuples RGB
Returns:
List[Tuple[int, int, int]]: Liste des tuples RGB
"""
with PaletteExporter(self, "rgb") as ctx:
return ctx.export_tuples()
# === Propriétés ===
@property
def is_empty(self) -> bool:
"""Vérifie si la palette est vide"""
return len(self.colors) == 0
@property
def color_count(self) -> int:
"""Nombre de couleurs dans la palette"""
return len(self.colors)
# @property
# def name(self) -> str:
# """Nom de la palette"""
# return self.metadata.get('name', Path(self.source_filename).stem if self.source_filename else "Sans nom")
@property
def is_valid(self) -> bool:
"""Vérifie si la palette est valide"""
return True # Une palette peut être vide mais reste valide
def __len__(self):
return len(self.colors)
def __getitem__(self, index):
return self.colors[index]
def __iter__(self):
return iter(self.colors)
# def __str__(self):
# return f"PixelPalette('{self.name}', {self.color_count} couleurs, format: {self.format_type})"
# def __repr__(self):
# return self.__str__()