Bug fixes

This commit is contained in:
NicklasVraa
2023-08-22 12:41:32 +02:00
parent 31e9fe60a3
commit 93f9349844
3 changed files with 120 additions and 105 deletions

View File

@@ -9,7 +9,8 @@ from colormath.color_diff import delta_e_cie2000
from PIL import Image, ImageDraw
import os, re, shutil, json, subprocess
# Utility ----------------------------------------------------------------------
# Basic utility ----------------------------------------------------------------
def expand_path(path:str) -> str:
""" Returns the absolute version of the given path, and expands unix notation like tilde for home folder. """
return os.path.abspath(os.path.expanduser(path))
@@ -24,6 +25,31 @@ def load_json_file(path:str) -> Dict:
obj = json.load(file)
return obj
def check_path(path:str) -> None:
if not os.path.exists(expand_path(path)):
raise Exception("Invalid path: " + path)
def svg_to_png(src_path:str, dest_path:str, width:int = 300) -> None:
""" Generate pngs at given destination path from a given source folder with a given width. """
src_path = expand_path(src_path)
dest_path = expand_path(dest_path)
try:
subprocess.run(['inkscape', '--version'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except FileNotFoundError:
raise RuntimeError("Inkscape is not installed.")
os.makedirs(dest_path, exist_ok=True)
svgs = [file for file in os.listdir(src_path) if file.endswith('.svg')]
for svg in svgs:
svg_path = os.path.join(src_path, svg)
png = os.path.splitext(svg)[0] + '.png'
png_path = os.path.join(dest_path, png)
command = ['inkscape', svg_path, '-o', png_path, '-w', str(width)]
subprocess.run(command)
# Color conversion -------------------------------------------------------------
def hex_to_rgb(hex:str) -> Tuple[int,int,int]:
@@ -110,29 +136,6 @@ def norm_hsl(h:int, s:int, l:int) -> Tuple[float,float,float]:
""" Normalize hsl color values. """
return h/360, s/100, l/100
# File conversion --------------------------------------------------------------
def svg_to_png(src_path:str, dest_path:str, width:int = 300) -> None:
""" Generate pngs at given destination path from a given source folder with a given width. """
src_path = expand_path(src_path)
dest_path = expand_path(dest_path)
try:
subprocess.run(['inkscape', '--version'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except FileNotFoundError:
raise RuntimeError("Inkscape is not installed.")
os.makedirs(dest_path, exist_ok=True)
svgs = [file for file in os.listdir(src_path) if file.endswith('.svg')]
for svg in svgs:
svg_path = os.path.join(src_path, svg)
png = os.path.splitext(svg)[0] + '.png'
png_path = os.path.join(dest_path, png)
command = ['inkscape', svg_path, '-o', png_path, '-w', str(width)]
subprocess.run(command)
# Preprocessing ----------------------------------------------------------------
def expand_css_rgba(match) -> str:
@@ -177,15 +180,15 @@ def generate_palette_dict(colors:List[str]) -> Dict[str,LabColor]:
def get_input_colors(resource) -> Tuple[List[str],bool,bool]:
""" Returns an HSL tuple, or a list of colors, depending on the input, as well as a boolean indicating which one, as well as if the palettes specifies smoothing pngs/jpgs. """
# If resource is an hsl color.
if isinstance(resource, tuple) and len(resource) == 3:
return resource, False, True
elif type(resource) is str:
palette_file = load_json_file(resource)
return generate_palette_dict(palette_file["colors"]), palette_file["smooth"], False
else:
return generate_palette_dict(resource["colors"]), palette_file["smooth"], False
# If resource is a path to a palette.
if type(resource) is str: resource = load_json_file(resource)
return generate_palette_dict(resource["colors"]), resource["smooth"], False
def get_file_colors(text:str) -> Set[str]:
""" Return a set of all unique colors within a given string representing an svg-file. """
@@ -302,9 +305,9 @@ def copy_pack(src_path:str, dest_path:str, name:str) -> str:
return dest_path
# SVG/CSS recoloring -----------------------------------------------------------
# Vector-based recoloring ------------------------------------------------------
def monochrome_vec(svg:str, colors:Set[str], hsl:Tuple[float,float,float]) -> str:
def apply_monotones_to_vec(svg:str, colors:Set[str], hsl:Tuple[float,float,float]) -> str:
""" Replace every instance of color within the given list with their monochrome equivalent in the given string representing an svg-file, determined by the given hue, saturation and lightness offset. """
h, s, l_offset = hsl
@@ -326,7 +329,7 @@ def monochrome_vec(svg:str, colors:Set[str], hsl:Tuple[float,float,float]) -> st
return svg
def multichrome_vec(svg:str, colors:Set[str], new_colors:Dict[str,LabColor]) -> str:
def apply_palette_to_vec(svg:str, colors:Set[str], new_colors:Dict[str,LabColor]) -> str:
""" Replace colors in a given svg/css with the closest match within a given color palette. """
for color in colors:
@@ -335,9 +338,9 @@ def multichrome_vec(svg:str, colors:Set[str], new_colors:Dict[str,LabColor]) ->
return svg
# PNG/JPG recoloring -----------------------------------------------------------
# Pixel-based recoloring -------------------------------------------------------
def monochrome_img(img:Image, hsl:Tuple[float,float,float]) -> Image:
def apply_monotones_to_img(img:Image, hsl:Tuple[float,float,float]) -> Image:
""" Replace every instance of color within the given list with their monochrome equivalent in the given image, determined by the given hue, saturation and lightness offset. """
mode = img.mode
@@ -368,7 +371,7 @@ def monochrome_img(img:Image, hsl:Tuple[float,float,float]) -> Image:
return img
def multichrome_img(img:Image, new_colors:Dict[str,LabColor], smooth:bool) -> Image:
def apply_palette_to_img(img:Image, new_colors:Dict[str,LabColor], smooth:bool) -> Image:
""" Replace colors in a given image with the closest match within a given color palette. """
if smooth: img = img.convert("P", palette=Image.ADAPTIVE, colors=256)
@@ -391,76 +394,83 @@ def multichrome_img(img:Image, new_colors:Dict[str,LabColor], smooth:bool) -> Im
# User interface functions -----------------------------------------------------
def recolor(src_path:str, dest_path:str, name:str, replacement) -> None:
""" Recursively copies and converts a source folder into a destination, given a either a color or a palette. """
""" Recursively copies and converts a source folder into a destination, given either an hsl color, a palette, or a color mapping. """
check_path(src_path)
check_path(dest_path)
new_colors, smooth, is_mono = get_input_colors(replacement)
dest_path = copy_pack(src_path, dest_path, name)
svg_paths = get_paths(dest_path, [".svg", ".xml"])
png_paths = get_paths(dest_path, [".png"])
jpg_paths = get_paths(dest_path, [".jpg", ".jpeg"])
css_paths = get_paths(dest_path, [".css", "rc"])
for path in tqdm(svg_paths, desc="svg", disable=is_empty(svg_paths)):
# Recolor vector graphics.
paths = get_paths(dest_path, [".svg", ".xml"])
for path in tqdm(paths, desc="svg", disable=is_empty(paths)):
with open(path, 'r') as file: x = file.read()
x = expand_all_hex(x)
colors = get_file_colors(x)
if is_mono: x = monochrome_vec(x, colors, new_colors)
else: x = multichrome_vec(x, colors, new_colors)
if is_mono: x = apply_monotones_to_vec(x, colors, new_colors)
else: x = apply_palette_to_vec(x, colors, new_colors)
with open(path, 'w') as file: file.write(x)
for path in tqdm(png_paths, desc="png", disable=is_empty(png_paths)):
# Recolor stylesheets.
paths = get_paths(dest_path, [".css", "rc"])
for path in tqdm(paths, desc="css", disable=is_empty(paths)):
with open(path, 'r') as file: x = file.read()
x = css_to_hex(x)
x = expand_all_hex(x)
colors = get_file_colors(x)
if is_mono: x = apply_monotones_to_vec(x, colors, new_colors)
else: x = apply_palette_to_vec(x, colors, new_colors)
with open(path, 'w') as file: file.write(x)
# Recolor pngs.
paths = get_paths(dest_path, [".png"])
for path in tqdm(paths, desc="png", disable=is_empty(paths)):
x = Image.open(path)
x = x.convert("RGBA")
a = x.split()[3] # Save original alpha channel.
if is_mono: x = monochrome_img(x, new_colors)
else: x = multichrome_img(x, new_colors, smooth)
if is_mono: x = apply_monotones_to_img(x, new_colors)
else: x = apply_palette_to_img(x, new_colors, smooth)
x = x.convert("RGBA")
r,g,b,_ = x.split()
x = Image.merge("RGBA",(r,g,b,a)) # Restore original alpha channel.
x.save(path)
for path in tqdm(jpg_paths, desc="jpg", disable=is_empty(jpg_paths)):
# Recolor jpgs.
paths = get_paths(dest_path, [".jpg", ".jpeg"])
for path in tqdm(paths, desc="jpg", disable=is_empty(paths)):
x = Image.open(path)
x = x.convert("RGB")
if is_mono: x = monochrome_img(x, new_colors)
else: x = multichrome_img(x, new_colors, smooth)
if is_mono: x = apply_monotones_to_img(x, new_colors)
else: x = apply_palette_to_img(x, new_colors, smooth)
x = x.convert("RGB")
x.save(path)
for path in tqdm(css_paths, desc="css", disable=is_empty(css_paths)):
with open(path, 'r') as file: x = file.read()
def extract_colors(src_path:str, num_colors:int=8, save_path:str=None, pixels:int=50, cols:int=10) -> List[str]:
""" Returns and optionally saves the color palette of the given image, as its own image. Optionally specify the number of unique colors you want to be found. """
x = css_to_hex(x)
x = expand_all_hex(x)
colors = get_file_colors(x)
if is_mono: x = monochrome_vec(x, colors, new_colors)
else: x = multichrome_vec(x, colors, new_colors)
with open(path, 'w') as file: file.write(x)
def extract_colors(img_path:str, num_colors:int=8, save_path:str=None, pixels:int=50, cols:int=10) -> List[str]:
""" Returns and optionally saves the color palette of the given image, finding the specified number of colors. """
_, ext = os.path.splitext(img_path)
check_path(src_path)
_, ext = os.path.splitext(src_path)
if ext == ".svg":
with open(img_path, 'r') as file:
with open(src_path, 'r') as file:
svg = file.read()
colors = list(get_file_colors(svg))
num_colors = len(colors)
else:
img = Image.open(img_path)
img = Image.open(src_path)
colors = img.convert('P', palette=Image.ADAPTIVE, colors=num_colors)
colors = colors.getpalette()[0:num_colors*3]
@@ -468,6 +478,8 @@ def extract_colors(img_path:str, num_colors:int=8, save_path:str=None, pixels:in
colors = ['#{:02X}{:02X}{:02X}'.format(colors[i], colors[i+1], colors[i+2]) for i in range(0, len(colors), 3)]
if save_path != None:
check_path(save_path)
if num_colors < cols: cols = num_colors
rows = -(-len(colors) // cols)
@@ -486,10 +498,11 @@ def extract_colors(img_path:str, num_colors:int=8, save_path:str=None, pixels:in
return colors
def clean_svg(input_path:str, output_path:str=None) -> str:
def clean_svg(src_path:str, dest_path:str=None) -> str:
""" Removes needless metadata from svgs and optionally saves as copy, if output path is specified. """
with open(input_path, 'r') as f:
check_path(src_path)
with open(src_path, 'r') as f:
svg = f.read()
patterns = [
@@ -501,15 +514,17 @@ def clean_svg(input_path:str, output_path:str=None) -> str:
for pattern in patterns:
svg = re.sub(pattern, '', svg)
if output_path is None:
output_path = input_path
if dest_path is None: dest_path = src_path
else: check_path(dest_path)
with open(output_path, 'w') as f:
with open(dest_path, 'w') as f:
f.write(svg)
def add_backdrop(src_path:str, dest_path:str, name:str, color:str="#000000", padding=0, rounding=0):
""" Add a customizable backdrop to all svg-based icons. """
""" Add a customizable backdrop to all svg-based icons. Optionally specify the backdrop color, the padding to the edge of the graphic, and the corner rounding factor. """
check_path(src_path)
check_path(dest_path)
dest_path = copy_pack(src_path, dest_path, name)
svg_paths = get_paths(dest_path, [".svg"])

View File

@@ -45,19 +45,20 @@ Color Manager is a program for recoloring and manipulating existing icon packs,
- [ ] Optional automatic palette extending.
- [ ] Basic framework for manipulating GTK, Cinnamon and Metacity themes.
- [ ] Intelligent color inversion.
- [ ] Update GUI to support new features.
- [ ] GNU/Linux binary (deb, flatpak, appimage).
## Features <a name="features"></a>
Currently, three operations are supported:
| Type | Result | Speed | Supports |
| ---- | ------ | ----- | -------- |
| Monochrome recoloring | A monochromatic variant, colored by appropriate shades of the provided base color. | ~5000 svgs/sec | svg, png, jpg, css |
| Multichrome recoloring | A multichromatic variant, where all colors are replaced by their nearest perceived equivalent that adheres to the provided color palette. | ~100 svgs/sec | svg, png, jpg, css |
| Extract colors | Returns and optionally saves the color palette of an image, in specified detail. | ~100 colors/sec | svg, png, jpg, css |
| Add backdrop | Add a rectangular or elliptical background (and anything in between) to all svg icons. | ~5000 svgs/sec | svg |
| Type | Result | Speed | Support |
| :--: | ------ | ----- | ------- |
| **Monochrome recoloring** | A monochromatic variant, colored by appropriate shades of the provided base color. | ~5000svg/s<br> ~500png/s | svg, xml, css, png, jpg |
| **Palette recoloring** | A multichromatic variant, where all colors are replaced by their nearest perceived equivalent that adheres to the provided color palette. | ~100svg/s<br> ~3png/s | svg, xml, css, png, jpg |
| **Extract colors** | Returns and optionally saves the color palette of an image, in specified detail. | ~10colors/s | svg, xml, css, png, jpg |
| **Add backdrop** | Add a rectangular or elliptical background (and anything in between) to all svg icons. | ~5000svg/s | svg |
Speeds were recorded with an Intel i7-4770K CPU. Any asset can serve as the base for any color palette or base color. Svg recolorings will always be perfect, but png/jpgs may require experimentation.
Speeds were recorded with an Intel i7-4770K CPU and is strongly dependent on file sizes. Any asset can serve as the base for any color palette or base color. Svg/css recolorings will always be perfect, but png/jpgs may require experimentation.
**Tip**: To increase the quality, i.e. decrease the perceived noise of multichromatic recolorings of pngs/jpgs, either...
- Increase the number of colors in the palette you provide to the program, e.g. populate it with slight variations of the existing colors

View File

@@ -2,9 +2,18 @@
"cells": [
{
"cell_type": "code",
"execution_count": null,
"execution_count": 8,
"metadata": {},
"outputs": [],
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"The autoreload extension is already loaded. To reload it, use:\n",
" %reload_ext autoreload\n"
]
}
],
"source": [
"%load_ext autoreload\n",
"%autoreload 2\n",
@@ -14,42 +23,32 @@
},
{
"cell_type": "code",
"execution_count": 4,
"execution_count": 10,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"/home/nv/Downloads/my_pack/index.theme\n"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
"svg: 100%|██████████| 43/43 [00:00<00:00, 250.05it/s]\n",
"png: 100%|██████████| 439/439 [00:01<00:00, 360.71it/s]\n",
"css: 100%|██████████| 12/12 [00:00<00:00, 84.68it/s]\n"
"ename": "Exception",
"evalue": "Invalid path: ~/Downloads",
"output_type": "error",
"traceback": [
"\u001b[0;31m---------------------------------------------------------------------------\u001b[0m",
"\u001b[0;31mException\u001b[0m Traceback (most recent call last)",
"Cell \u001b[0;32mIn[10], line 7\u001b[0m\n\u001b[1;32m 4\u001b[0m hsl \u001b[39m=\u001b[39m (\u001b[39m0.6\u001b[39m, \u001b[39m0.54\u001b[39m, \u001b[39m0.5\u001b[39m) \u001b[39m# = rc.normalize_hsl(180, 50, 50)\u001b[39;00m\n\u001b[1;32m 5\u001b[0m palette \u001b[39m=\u001b[39m \u001b[39m\"\u001b[39m\u001b[39mpalettes/nord.json\u001b[39m\u001b[39m\"\u001b[39m\n\u001b[0;32m----> 7\u001b[0m utils\u001b[39m.\u001b[39;49mrecolor(src, dest, name, hsl) \u001b[39m# Either hsl or palette\u001b[39;00m\n",
"File \u001b[0;32m~/Projects/Color_manager/color_manager/utils.py:402\u001b[0m, in \u001b[0;36mrecolor\u001b[0;34m(src_path, dest_path, name, replacement)\u001b[0m\n\u001b[1;32m 399\u001b[0m \u001b[39m\u001b[39m\u001b[39m\"\"\" Recursively copies and converts a source folder into a destination, given either an hsl color, a palette, or a color mapping. \"\"\"\u001b[39;00m\n\u001b[1;32m 401\u001b[0m check_path(src_path)\n\u001b[0;32m--> 402\u001b[0m check_path(dest_path)\n\u001b[1;32m 404\u001b[0m new_colors, smooth, is_mono \u001b[39m=\u001b[39m get_input_colors(replacement)\n\u001b[1;32m 405\u001b[0m dest_path \u001b[39m=\u001b[39m copy_pack(src_path, dest_path, name)\n",
"File \u001b[0;32m~/Projects/Color_manager/color_manager/utils.py:30\u001b[0m, in \u001b[0;36mcheck_path\u001b[0;34m(path)\u001b[0m\n\u001b[1;32m 28\u001b[0m \u001b[39mdef\u001b[39;00m \u001b[39mcheck_path\u001b[39m(path:\u001b[39mstr\u001b[39m) \u001b[39m-\u001b[39m\u001b[39m>\u001b[39m \u001b[39mNone\u001b[39;00m:\n\u001b[1;32m 29\u001b[0m \u001b[39mif\u001b[39;00m \u001b[39mnot\u001b[39;00m os\u001b[39m.\u001b[39mpath\u001b[39m.\u001b[39mexists(path):\n\u001b[0;32m---> 30\u001b[0m \u001b[39mraise\u001b[39;00m \u001b[39mException\u001b[39;00m(\u001b[39m\"\u001b[39m\u001b[39mInvalid path: \u001b[39m\u001b[39m\"\u001b[39m \u001b[39m+\u001b[39m path)\n",
"\u001b[0;31mException\u001b[0m: Invalid path: ~/Downloads"
]
}
],
"source": [
"src = \"test_theme\" # = \"test_theme\"\n",
"name = \"my_pack\" # = \"my_theme\"\n",
"src = \"test_pack\" # = \"test_theme\"\n",
"name = \"my_pack3\" # = \"my_theme\"\n",
"dest = \"~/Downloads\"\n",
"hsl = (0.6, 0.54, 0.5) # = rc.normalize_hsl(180, 50, 50)\n",
"palette = \"palettes/nord.json\"\n",
"\n",
"utils.recolor(src, dest, name, hsl) # Either hsl or palette"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Priority: Preserve transparency in PNGs after multichrome recoloring."
]
}
],
"metadata": {