Files
wallpapers/.paintr
2026-03-05 19:10:44 +01:00

359 lines
12 KiB
Python
Executable File

#!/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()