extracting color themes (very poorly)

This commit is contained in:
Андреев Григорий 2025-09-19 14:47:35 +03:00
commit b17c82bf2a
12 changed files with 389 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
.idea
.venv
__pycache__
answer.png
thumbnails
themes
monitor.png

62
apply_colorscheme.py Normal file
View File

@ -0,0 +1,62 @@
import re
import sys
import argparse
import os
import stat
from dataclasses import dataclass
from typing import List, Union, Tuple
import shutil
from pathlib import Path
# One element per monitor
# swaybar: background
sway_color_bar_background = 0
# swaybar: statusline
sway_color_bar_statusline = 1
#swaybar: focused_background
sway_color_bar_focused_background = 2
#swaybar: focused_statusbar
sway_color_bar_focused_statusline = 3
#swaybar: focused_workspace
sway_color_bar_workspace_focused_border = 4
sway_color_bar_workspace_focused_background = 5
sway_color_bar_workspace_focused_text = 6
#swaybar: active_workspace
sway_color_bar_workspace_active_border = 7
sway_color_bar_workspace_active_background = 8
sway_color_bar_workspace_active_text = 9
#swaybar: inactive_workspace
sway_color_bar_workspace_inactive_border = 10
sway_color_bar_workspace_inactive_background = 11
sway_color_bar_workspace_inactive_text = 12
#swaybar: urgent_workspace
sway_color_bar_workspace_urgent_border = 13
sway_color_bar_workspace_urgent_background = 14
sway_color_bar_workspace_urgent_text = 15
#swaybar: binding_mode
sway_color_bar_mode_indicator_border = 16
sway_color_bar_mode_indicator_background = 17
sway_color_bar_mode_indicator_text = 18
# n when constructing my ColorSchemeSearchConf
sway_color_n = 19
if __name__ == "__main__":
arg_parser = argparse.ArgumentParser(description="Activate a precompiled colorscheme for sway")
# todo: continue from here (We almost finished)
arg_parser.add_argument('wallpaper_path')
arg_parser.add_argument('themes_path', help='Path to the colorscheme file')
arg_parser.add_argument('config_output_path', help='Path for the output config file')
arg_parser.add_argument('bar_name', help="Which bar to configure", nargs='?', default='*')
# Optional flag arguments (no arguments)
arg_parser.add_argument('--message', action='store_true', help='Use swaymsg to send colorscheme immediately')
args = arg_parser.parse_args()
with open(args.colorscheme_path, 'r') as f:
colors = list(filter(lambda line: len(line) > 0, f.readlines()))
config_text = f"""bar {args.bar_name} colors """
assert len(colors) == sway_color_n

319
extract_colors.py Normal file
View File

@ -0,0 +1,319 @@
import re
import sys
import argparse
import os
import stat
from math import *
from dataclasses import dataclass
from typing import List, Union, Tuple
import shutil
from pathlib import Path
import random
from PIL import Image, ImageDraw
import numpy as np
def read_image(filename):
rgb_img = Image.open(filename).convert('RGB')
pixel_array = np.array(rgb_img, dtype=np.float32) / 255.0
return pixel_array
# We only care about color distribution
def read_and_compress_image(filename):
img = Image.open(filename).convert('RGB')
width = min(img.width, 50)
height = min(img.height, 50)
img = img.resize((width, height))
pixel_array = np.array(img, dtype=np.float32) / 255.0
return pixel_array
def conv_color(color: List[float]) -> Tuple[int, int, int, int]:
return (int(round(255 * color[0])), int(round(255 * color[1])), int(round(255 * color[2])), 255)
def create_color_row_image(colors, output_filename, rect_width = 5, rect_height = 3):
num_colors = len(colors)
assert num_colors > 0
img_width = num_colors * rect_width + (num_colors - 1)
img_height = rect_height
new_image = Image.new('RGBA', (img_width, img_height))
pixels = new_image.load()
for X, color in enumerate(colors):
for i in range(rect_width):
for j in range(rect_height):
pixels[X + X * rect_width + i, j] = conv_color(color)
new_image.save(output_filename)
print(f"Image saved as {output_filename}")
def euclidian_distance(c1: List[float], c2: List[float]):
return sqrt(sum(map(lambda i: (c1[i] - c2[i]) * (c1[i] - c2[i]), range(3))))
@dataclass
class ColorDistancePrice:
def apply(self, c1: List[float], c2: List[float]) -> float:
pass
def show_image_quality(self, c2: List[float], image):
new_image = Image.new('RGB', (image.shape[1], image.shape[0]))
pixels = new_image.load()
for y in range(image.shape[0]):
for x in range(image.shape[1]):
cost: float = self.apply(image[y, x], c2)
cc: int = int(round(255 * cost))
pixels[x, y] = (min(cc, 255), max(0, min(cc - 255, 255)), 0)
new_image.show()
@dataclass
class OmegaPrice(ColorDistancePrice):
d: float
def apply(self, c1: List[float], c2: List[float]) -> float:
e = euclidian_distance(c1, c2)
if e <= self.d:
return pow(self.d - e, 2)
return 0
@dataclass
class EtaPrice(ColorDistancePrice):
d: float
def apply(self, c1: List[float], c2: List[float]) -> float:
e = euclidian_distance(c1, c2)
if e <= self.d:
return 1 - e / self.d
return 1 - exp(self.d - e)
@dataclass
class AlphaPrice(ColorDistancePrice):
dd: float
dp: float
def apply(self, c1: List[float], c2: List[float]) -> float:
e = euclidian_distance(c1, c2)
if e < self.dd:
return 0
return self.dp + pow(e / sqrt(3), 3)
@dataclass
class ColorDistanceRule:
i: int
j: int
func: ColorDistancePrice
def annealing(T: float, cost_delta: float) -> bool:
if cost_delta < 0:
return True
P = exp(-(cost_delta / T))
return random.random() < P
class ColorSchemeSearchConf:
def __init__(self, image100: np.ndarray, n, pair_rules: List[ColorDistanceRule], image_pixel_price: ColorDistancePrice):
self.n = n
self.pair_price = [[[] for i in range(m)] for m in range(n) ]
for rule in pair_rules:
self.pair_price[max(rule.i, rule.j)][min(rule.i, rule.j)].append(rule.func)
self.image_cost_c = len(pair_rules)
self.image_pixel_price = image_pixel_price
self.image100 = image100
def drift(self, sample_pos: List[Tuple[int, int]], coef: float) -> List[Tuple[int, int]]:
w: int = self.image100.shape[1]
h: int = self.image100.shape[0]
def drift_point(pos: Tuple[int, int]):
return (
max(0, min(pos[0] + int(round(coef * (random.random() - .5))), w - 1)),
max(0, min(pos[1] + int(round(coef * (random.random() - .5))), h - 1))
)
return list(map(drift_point, sample_pos))
def sample_image(self, sample_positions: List[Tuple[int, int]]) -> List[List[float]]:
return list(map(lambda pos: self.image100[pos[1], pos[0]], sample_positions))
def evaluate_colorscheme(self, colorscheme: List[List[float]]):
w: int = self.image100.shape[1]
h: int = self.image100.shape[0]
assert len(colorscheme) == self.n
C_with_image = 0
for y in range(h):
for x in range(w):
c1 = self.image100[x, y]
for i in range(self.n):
C_with_image += self.image_pixel_price.apply(c1, colorscheme[i])
C_with_image *= (self.image_cost_c / w / h)
C_with_each_other = 0
for i, func_arr_arr in enumerate(self.pair_price):
for j, func_arr in enumerate(func_arr_arr):
for func in func_arr:
a = func.apply(colorscheme[i], colorscheme[j])
C_with_each_other += a
return C_with_image + C_with_each_other
def find_best_colorscheme(self, steps: int, initial_temperature: float):
positions = [(0, 0) for i in range(self.n)]
colors: List[List[float]] = self.sample_image(positions)
cost = self.evaluate_colorscheme(colors)
T = initial_temperature
for i in range(steps):
print(f"Temperature {T}. Cost: {cost}. Positions: {positions}")
new_positions = self.drift(positions, 200 * (1 - exp(-T)))
new_colorscheme = self.sample_image(new_positions)
new_cost = self.evaluate_colorscheme(new_colorscheme)
if annealing(T, new_cost - cost):
positions = new_positions
colors = new_colorscheme
cost = new_cost
T *= (1 - 10/steps)
return colors
def color_to_hex(clr: List[float]) -> str:
return f"#{int(round(255 * clr[0])):02X}{int(round(255 * clr[1])):02X}{int(round(255 * clr[2])):02X}"
# One element per monitor
# swaybar: background
sway_color_bar_background = 0
# swaybar: statusline
sway_color_bar_statusline = 1
#swaybar: focused_background
sway_color_bar_focused_background = 2
#swaybar: focused_statusbar
sway_color_bar_focused_statusline = 3
#swaybar: focused_workspace
sway_color_bar_workspace_focused_border = 4
sway_color_bar_workspace_focused_background = 5
sway_color_bar_workspace_focused_text = 6
#swaybar: active_workspace
sway_color_bar_workspace_active_border = 7
sway_color_bar_workspace_active_background = 8
sway_color_bar_workspace_active_text = 9
#swaybar: inactive_workspace
sway_color_bar_workspace_inactive_border = 10
sway_color_bar_workspace_inactive_background = 11
sway_color_bar_workspace_inactive_text = 12
#swaybar: urgent_workspace
sway_color_bar_workspace_urgent_border = 13
sway_color_bar_workspace_urgent_background = 14
sway_color_bar_workspace_urgent_text = 15
#swaybar: binding_mode
sway_color_bar_mode_indicator_border = 16
sway_color_bar_mode_indicator_background = 17
sway_color_bar_mode_indicator_text = 18
# n when constructing my ColorSchemeSearchConf
sway_color_n = 19
def sway_monitor_image(scheme, output_filename):
lwksp = 20
gap = 5
ltxt = 10
ls = (lwksp + gap * 3 + ltxt)
wksp_gap = (lwksp - ltxt) / 2
img = Image.new('RGBA', (5 * lwksp + 6 * gap, 2 * ls))
draw = ImageDraw.Draw(img)
def draw_section(i, ho):
draw.rectangle([(0, i * ls), (5 * lwksp + 6 * gap, i * ls + ls)], fill=conv_color(scheme[ho]))
draw.rectangle([(gap, i * ls + 2 * gap + lwksp), (5 * lwksp + 5 * gap, i * ls + 2 * gap + lwksp + ltxt)], fill=conv_color(scheme[ho+1]))
def draw_wksp(j, brdr):
draw.rectangle([((j+1) * gap + j * lwksp, ls * i + gap), ((j+1) * gap + j * lwksp + lwksp, ls * i + gap + lwksp)], fill=conv_color(scheme[brdr + 1]), outline=conv_color(scheme[brdr]))
draw.rectangle([((j+1) * gap + j * lwksp + wksp_gap, ls * i + gap + wksp_gap),
((j+1) * gap + j * lwksp + wksp_gap + ltxt, ls * i + gap + wksp_gap + ltxt)],
fill=conv_color(scheme[brdr + 2]))
draw_wksp(0, sway_color_bar_workspace_focused_border)
draw_wksp(1, sway_color_bar_workspace_active_border)
draw_wksp(2, sway_color_bar_workspace_inactive_border)
draw_wksp(3, sway_color_bar_workspace_urgent_border)
draw_wksp(4, sway_color_bar_mode_indicator_border)
draw_section(0, sway_color_bar_background)
draw_section(1, sway_color_bar_focused_background)
img.save(output_filename)
print(f"Image saved as {output_filename}")
def get_sway_search_conf(image100: np.ndarray) -> ColorSchemeSearchConf:
def wild_trio(first_ind):
return [
ColorDistanceRule(first_ind + 0, first_ind + 1, OmegaPrice(0.2)),
ColorDistanceRule(first_ind + 1, first_ind + 2, OmegaPrice(0.6)),
ColorDistanceRule(first_ind + 0, first_ind + 2, OmegaPrice(0.5)),
]
rules = [
ColorDistanceRule(sway_color_bar_background, sway_color_bar_statusline, OmegaPrice(10)),
ColorDistanceRule(sway_color_bar_focused_background, sway_color_bar_focused_statusline, OmegaPrice(10)),
ColorDistanceRule(sway_color_bar_background, sway_color_bar_focused_background, OmegaPrice(0.2)),
ColorDistanceRule(sway_color_bar_statusline, sway_color_bar_focused_statusline, OmegaPrice(0.2)),
ColorDistanceRule(sway_color_bar_mode_indicator_background, sway_color_bar_workspace_urgent_background, EtaPrice(0.025)),
ColorDistanceRule(sway_color_bar_workspace_focused_background, sway_color_bar_workspace_active_background, OmegaPrice(0.4)),
ColorDistanceRule(sway_color_bar_workspace_focused_background, sway_color_bar_workspace_inactive_background, OmegaPrice(0.4)),
ColorDistanceRule(sway_color_bar_workspace_active_background, sway_color_bar_workspace_inactive_background, OmegaPrice(0.1)),
ColorDistanceRule(sway_color_bar_background, sway_color_bar_workspace_inactive_background, OmegaPrice(0.07)),
ColorDistanceRule(sway_color_bar_focused_background, sway_color_bar_workspace_inactive_background, OmegaPrice(0.07)),
ColorDistanceRule(sway_color_bar_background, sway_color_bar_workspace_active_background, OmegaPrice(0.07)),
ColorDistanceRule(sway_color_bar_focused_background, sway_color_bar_workspace_active_background, OmegaPrice(0.07)),
ColorDistanceRule(sway_color_bar_background, sway_color_bar_workspace_focused_background, OmegaPrice(0.07)),
ColorDistanceRule(sway_color_bar_focused_background, sway_color_bar_workspace_focused_background,OmegaPrice(0.07)),
]
rules += wild_trio(sway_color_bar_workspace_focused_border)
rules += wild_trio(sway_color_bar_workspace_active_border)
rules += wild_trio(sway_color_bar_workspace_inactive_border)
rules += wild_trio(sway_color_bar_workspace_urgent_border)
rules += wild_trio(sway_color_bar_mode_indicator_border)
return ColorSchemeSearchConf(image100, sway_color_n, rules, AlphaPrice(0.1, 0.5))
def extract_colors_for_sway_complete_io(source_path, dest_colorscheme_path, dest_thumbnail_path):
image100 = read_and_compress_image(source_path)
print(f"Image shape: {image100.shape}", file=sys.stderr)
searcher = get_sway_search_conf(image100)
colorscheme = searcher.find_best_colorscheme(20, 1000)
with open(dest_colorscheme_path, 'a') as f:
for i in range(searcher.n):
print(color_to_hex(colorscheme[i]), file=f)
sway_monitor_image(colorscheme, dest_thumbnail_path)
def compile_a_directory(source_dir, target_txt_dir, target_thumbnail_path):
image_extensions = {'.jpg', '.jpeg', '.png', '.webp'}
source_path = Path(source_dir)
target_txt_path = Path(target_txt_dir)
target_thumbnail_path = Path(target_thumbnail_path)
# Walk through all directories and files
for root, dirs, files in os.walk(source_dir):
root_path = Path(root)
for file in files:
file_path = root_path / file
file_ext = file_path.suffix.lower()
if file_ext in image_extensions:
# Create corresponding target directory and colorscheme
relative_path = file_path.relative_to(source_path)
target_txt_dir_path = target_txt_path / relative_path.parent
target_txt_dir_path.mkdir(parents=True, exist_ok=True)
target_txt_file_path = target_txt_dir_path / f"{file_path.stem}.txt"
target_thumbnail_dir_path = target_thumbnail_path / relative_path.parent
target_thumbnail_dir_path.mkdir(parents=True, exist_ok=True)
target_thumbnail_file_path = target_thumbnail_dir_path / f"{file_path.stem}.png"
print(str(file_path), "To", str(target_txt_file_path), "And", str(target_thumbnail_file_path))
extract_colors_for_sway_complete_io(str(file_path), str(target_txt_file_path), str(target_thumbnail_file_path))
if __name__ == "__main__":
# parser_arg = argparse.ArgumentParser(description="Extract colors from image")
# parser_arg.add_argument("input_file", help="Path to the input image file")
# parser_arg.add_argument("color_file", help="Path for color blocks output image", nargs='?', default=None)
# parser_arg.add_argument("monitor_file", help="Path for monitor thumbnail output image", nargs='?', default=None)
# args = parser_arg.parse_args()
parser_arg = argparse.ArgumentParser(description="Extract colors from a set of images")
parser_arg.add_argument("source_dir", help="Path to directory with input files")
parser_arg.add_argument("dest_txt_dir", help="Path to output directory for colorscheme specs")
parser_arg.add_argument("dest_thumbnail_dir", help="Path to output directory for thumbnail of sway theme")
args = parser_arg.parse_args()
compile_a_directory(args.source_dir, args.dest_txt_dir, args.dest_thumbnail_dir)

BIN
wallpaper/anime/1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
wallpaper/anime/2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 978 KiB

BIN
wallpaper/anime/3.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
wallpaper/anime/4.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
wallpaper/anime/5.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

BIN
wallpaper/anime/6.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
wallpaper/anime/7.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
wallpaper/anime/8.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
wallpaper/anime/9.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 KiB