#!/usr/bin/env python3 import argparse import logging import os from pathlib import Path import sys try: from PIL import Image import numpy as np from skimage.color import rgb2lab, lab2rgb import pytoml except ImportError: print("Dependencies not found. Please install them with 'pip install Pillow scikit-image numpy pytoml'", file=sys.stderr) sys.exit(1) # --- Configuration & Setup --- APP_NAME = "paintr" LOG_LEVELS = ["FATAL", "ERROR", "WARN", "INFO", "DEBUG"] # Default theme data embedded in the script for first-run setup. DEFAULT_THEME_NAME = "catppuccin" DEFAULT_THEME_CONTENT = """[data] name = "Catppuccin" version = "1.0" variant = "dark" [colors] background = "#1e1e2e" foreground = "#cdd6f4" primary = "#cba6f7" secondary = "#89b4fa" accent = "#f38ba8" muted = "#6c7086" """ # --- Logging Setup --- def setup_logging(level): """Sets up the logging configuration based on the provided level.""" log_level = getattr(logging, level.upper(), logging.INFO) logging.basicConfig( level=log_level, format='%(asctime)s - %(levelname)s - %(message)s' ) # --- Helper Functions --- def get_config_dir(): """Returns the XDG config directory for the application, creating it if necessary.""" xdg_config_home = os.environ.get("XDG_CONFIG_HOME") config_dir = Path(xdg_config_home) / APP_NAME if xdg_config_home else Path.home() / ".config" / APP_NAME # Create the directory if it doesn't exist if not config_dir.exists(): try: config_dir.mkdir(parents=True) logging.info(f"Created configuration directory at {config_dir}") except Exception as e: logging.error(f"Failed to create configuration directory: {e}") return config_dir def create_default_theme_file(config_dir): """Creates a default theme file if one doesn't exist.""" theme_file_path = config_dir / f"{DEFAULT_THEME_NAME}.toml" if not theme_file_path.exists(): try: with open(theme_file_path, "w") as f: f.write(DEFAULT_THEME_CONTENT) logging.info(f"Created default theme file '{DEFAULT_THEME_NAME}.toml'") except Exception as e: logging.error(f"Failed to create default theme file: {e}") def hex_to_rgb(hex_color): """Converts a hex color string to an RGB tuple.""" hex_color = hex_color.lstrip('#') return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) def load_palette(theme_name, variant_name): """ Loads a color palette from a TOML file based on its theme name and variant. """ config_dir = get_config_dir() # Iterate through all TOML files to find a matching theme and variant for theme_file in config_dir.glob("*.toml"): try: with open(theme_file, 'rb') as f: palette_data = pytoml.load(f) # Check for name and variant match data = palette_data.get('data', {}) if data.get('name') == theme_name and data.get('variant') == variant_name: if "colors" not in palette_data: logging.fatal(f"Invalid theme file: '{theme_file.name}' is missing the [colors] table.") sys.exit(1) logging.info(f"Loaded theme '{theme_name}' variant '{variant_name}' from '{theme_file.name}'") # Filter and convert colors from the [colors] table hex_colors = [v for k, v in palette_data['colors'].items() if isinstance(v, str)] return [hex_to_rgb(h) for h in hex_colors] except Exception as e: logging.debug(f"Could not parse theme file {theme_file.name}: {e}") continue # If loop completes without finding a match logging.fatal(f"Color scheme '{theme_name}:{variant_name}' not found.") sys.exit(1) def list_themes(): """Lists available theme files, grouping them by name and showing variants.""" config_dir = get_config_dir() if not config_dir.is_dir(): logging.info(f"Theme directory not found at {config_dir}") return # Dictionary to store themes, grouped by name themes = {} for theme_file in sorted(config_dir.glob("*.toml")): file_name = theme_file.stem try: with open(theme_file, 'rb') as f: data = pytoml.load(f) display_name = data.get('data', {}).get('name', file_name) variant = data.get('data', {}).get('variant', 'default') # Generate the color blocks color_blocks = "" colors = data.get('colors', {}) for hex_color in colors.values(): if isinstance(hex_color, str) and hex_color.startswith('#'): rgb = hex_to_rgb(hex_color) color_blocks += f"\033[48;2;{rgb[0]};{rgb[1]};{rgb[2]}m \033[0m" if display_name not in themes: themes[display_name] = [] themes[display_name].append({ 'variant': variant, 'file_name': f"{file_name}.toml", 'color_blocks': color_blocks }) except Exception as e: logging.debug(f"Error parsing {theme_file}: {e}") if 'unknown' not in themes: themes['unknown'] = [] themes['unknown'].append({ 'variant': 'N/A', 'file_name': f"{file_name}.toml", 'color_blocks': '' }) print("Available color schemes:") for display_name in sorted(themes.keys()): print(f"- {display_name}") for variant_info in themes[display_name]: print(f" - {variant_info['color_blocks']} {variant_info['variant']} ({variant_info['file_name']})") def clamp(value, min_val=0, max_val=255): """Clamps a value within a specified range.""" return max(min_val, min(max_val, value)) def adjust_brightness(r, g, b, brightness): """ Adjusts the brightness of an RGB color. Brightness is a value from -100 (darken) to 100 (lighten). """ adjustment = (brightness / 100) * 255 return ( clamp(r + adjustment), clamp(g + adjustment), clamp(b + adjustment) ) def rgb_to_hsl(r, g, b): r /= 255.0 g /= 255.0 b /= 255.0 max_val = max(r, g, b) min_val = min(r, g, b) h, s, l = 0, 0, (max_val + min_val) / 2.0 if max_val != min_val: diff = max_val - min_val s = diff / (2.0 - max_val - min_val) if l > 0.5 else diff / (max_val + min_val) if max_val == r: h = (g - b) / diff + (6 if g < b else 0) elif max_val == g: h = (b - r) / diff + 2 elif max_val == b: h = (r - g) / diff + 4 h /= 6.0 return [h * 360, s, l] def hsl_to_rgb(h, s, l): h /= 360.0 def hue_to_rgb(p, q, t): if t < 0: t += 1 if t > 1: t -= 1 if t < 1/6: return p + (q - p) * 6 * t if t < 1/2: return q if t < 2/3: return p + (q - p) * (2/3 - t) * 6 return p if s == 0: r = g = b = l else: q = l * (1 + s) if l < 0.5 else l + s - l * s p = 2 * l - q r = hue_to_rgb(p, q, h + 1/3) g = hue_to_rgb(p, q, h) b = hue_to_rgb(p, q, h - 1/3) return [int(r * 255), int(g * 255), int(b * 255)] def adjust_saturation(r, g, b, saturation): """Adjusts the saturation of an RGB color.""" h, s, l = rgb_to_hsl(r, g, b) new_s = max(0, min(1, s * saturation)) return hsl_to_rgb(h, new_s, l) # --- Core Colorization Logic (Translated from imageColorizer.ts) --- def colorize_image(image_path, selected_colors, brightness_adjustment=0, saturation_adjustment=1.0): """ Colorizes an image using the provided colors based on their declared order, with options for brightness and saturation adjustment. """ logging.info(f"Colorizing image '{image_path}' with {len(selected_colors)} colors...") try: img = Image.open(image_path).convert('RGB') pixels = np.array(img) except FileNotFoundError: logging.fatal(f"Input image not found: {image_path}") sys.exit(1) except Exception as e: logging.fatal(f"Error opening image: {e}") sys.exit(1) # Convert image and palette to Lab color space image_lab = rgb2lab(pixels / 255.0) palette_lab = rgb2lab(np.array(selected_colors) / 255.0) new_pixels_lab = np.zeros_like(image_lab) for i in range(image_lab.shape[0]): for j in range(image_lab.shape[1]): pixel_lab = image_lab[i, j] original_L = pixel_lab[0] # Map the original lightness (0-100) to an index in the palette. # This ensures the original order of colors is used. palette_index = (original_L / 100) * (len(palette_lab) - 1) lower_index = int(palette_index) upper_index = min(lower_index + 1, len(palette_lab) - 1) # Get the colors to interpolate between lower_color = palette_lab[lower_index] upper_color = palette_lab[upper_index] amount = palette_index - lower_index # Interpolate the color in Lab space interpolated_L = original_L interpolated_a = lower_color[1] + (upper_color[1] - lower_color[1]) * amount interpolated_b = lower_color[2] + (upper_color[2] - lower_color[2]) * amount new_pixels_lab[i, j] = [interpolated_L, interpolated_a, interpolated_b] # Convert back to RGB new_pixels_rgb = (lab2rgb(new_pixels_lab) * 255).astype(np.uint8) # Apply saturation and brightness adjustments for i in range(new_pixels_rgb.shape[0]): for j in range(new_pixels_rgb.shape[1]): r, g, b = new_pixels_rgb[i, j] # Apply saturation adjustment first if saturation_adjustment != 1.0: r, g, b = adjust_saturation(r, g, b, saturation_adjustment) # Apply brightness adjustment second if brightness_adjustment != 0: r, g, b = adjust_brightness(r, g, b, brightness_adjustment) new_pixels_rgb[i, j] = [r, g, b] return Image.fromarray(new_pixels_rgb) # --- Main Execution --- def main(): parser = argparse.ArgumentParser( description="A command-line utility to colorize wallpapers with color palettes." ) parser.add_argument("-i", "--input", help="Path to the input image file.", required=False) parser.add_argument("-t", "--theme", help="The theme name and variant to use, e.g., 'Catppuccin:dark'.", required=False) parser.add_argument("-s", "--saturation", type=float, default=1.0, help="Adjust saturation. 0.0 is desaturated, 1.0 is default, >1.0 is more saturated.") parser.add_argument("-d", "--darken", type=float, default=0, help="Darken (-100) or lighten (100) the image.") parser.add_argument("-l", "--list", action="store_true", help="Lists available color schemes.") parser.add_argument("output", nargs="?", help="Path for the output image file.") log_group = parser.add_mutually_exclusive_group() log_group.add_argument("-L", "--log-level", choices=LOG_LEVELS, default="INFO", help="Set the logging level.") log_group.add_argument("-v", "--verbose", action="store_true", help="Alias for -L DEBUG.") args = parser.parse_args() # Handle logging level first log_level = "DEBUG" if args.verbose else args.log_level setup_logging(log_level) # Prepare configuration directory and default theme config_dir = get_config_dir() create_default_theme_file(config_dir) # Handle list command if args.list: list_themes() sys.exit(0) # Validate required arguments for colorization if not all([args.input, args.theme, args.output]): parser.error("The -i, -t, and output arguments are required unless -l is used.") # Parse theme name and variant if ':' not in args.theme: logging.fatal("Theme selection must be in the format 'name:variant'.") sys.exit(1) theme_name, variant_name = args.theme.split(':', 1) # Main colorization process selected_colors_rgb = load_palette(theme_name, variant_name) output_image = colorize_image(args.input, selected_colors_rgb, args.darken, args.saturation) try: output_image.save(args.output) logging.info(f"Successfully saved colorized image to '{args.output}'") except Exception as e: logging.fatal(f"Error saving output image: {e}") sys.exit(1) if __name__ == "__main__": main()