chore: add thinkpad wallpapers
This commit is contained in:
358
.paintr
Executable file
358
.paintr
Executable file
@@ -0,0 +1,358 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user