From 93f93498442a3090529c2c187b7903259862a27c Mon Sep 17 00:00:00 2001 From: NicklasVraa Date: Tue, 22 Aug 2023 12:41:32 +0200 Subject: [PATCH] Bug fixes --- color_manager/utils.py | 161 ++++++++++++++++++++++------------------- readme.md | 15 ++-- test.ipynb | 49 ++++++------- 3 files changed, 120 insertions(+), 105 deletions(-) diff --git a/color_manager/utils.py b/color_manager/utils.py index 6e8fa2f7..88bbcfd2 100644 --- a/color_manager/utils.py +++ b/color_manager/utils.py @@ -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"]) diff --git a/readme.md b/readme.md index dfe93908..00b74f1e 100644 --- a/readme.md +++ b/readme.md @@ -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 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
~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
~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 diff --git a/test.ipynb b/test.ipynb index bbf1d738..d99db35f 100644 --- a/test.ipynb +++ b/test.ipynb @@ -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": {