├── .gitignore ├── LICENSE ├── README.md ├── color_tools └── hex_tools.py ├── image_tools ├── manipulator.py └── metadata.py ├── image_widgets.py ├── main.py ├── menu.py ├── panel.py ├── settings.py └── theme ├── custom.json ├── image-editing.ico └── logo.ico /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Ali Arous 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A-Star Photo Editor 2 | Thanks to CutsomTkinter for providing the awesome customizable widgets, their repo can be found at: https://github.com/TomSchimansky/CustomTkinter/ 3 | 4 | This is a quite simple photo editing app for quick editing, mainly for applying filters. 5 | Includes functionality to extract a color palette from an image, display image tag data, and export in JPG or PNG format (more formats might be added in the future). 6 | 7 | More features are planned for future releases. Stay tuned :) 8 | 9 | The app's theme adapts to your system settings. 10 | This is a preview of the ligth theme: 11 | ![light](https://user-images.githubusercontent.com/80627670/234813123-3b02c8de-127f-4e74-b217-388e3ad1c3b9.png) 12 | 13 | And this is a peek at the dark theme: 14 | ![dark](https://user-images.githubusercontent.com/80627670/234813527-c9b2d911-d882-4849-93bc-6b593d9ed3ca.png) 15 | -------------------------------------------------------------------------------- /color_tools/hex_tools.py: -------------------------------------------------------------------------------- 1 | 2 | def rgb_to_hex(r: float, g: float, b: float) -> str: 3 | """ 4 | Convert an RGB color into a Hex color code. 5 | 6 | Args: 7 | r (float): The red channel component. 8 | g (float): The green channel component. 9 | b (float): The blue channel component. 10 | 11 | Returns: 12 | str: Resulting hex color code. 13 | """ 14 | return f'#{r:02x}{g:02x}{b:02x}' 15 | 16 | def clamp(val: float, minimum: int = 0, maximum: int = 255): 17 | """ 18 | Clamp a given value within the range [``minimum``, ``maximum``] 19 | 20 | Args: 21 | val (float): provided value 22 | minimum (int, optional): The start of the range. Defaults to 0. 23 | maximum (int, optional): The end of the range. Defaults to 255. 24 | 25 | Returns: 26 | int: resulting value as an integer. 27 | """ 28 | if val < minimum: 29 | return int(minimum) 30 | if val > maximum: 31 | return int(maximum) 32 | return int(val) 33 | 34 | def colorscale(hexstr: str, scale_factor: float = None) -> str: 35 | """ 36 | Scales a hex string by ``scale_factor``. Returns scaled hex string. 37 | 38 | To darken the color, use a float value between 0 and 1. 39 | To brighten the color, use a float value greater than 1. 40 | 41 | >>> colorscale("#DF3C3C", .5) 42 | #6F1E1E 43 | >>> colorscale("#52D24F", 1.6) 44 | #83FF7E 45 | >>> colorscale("#4F75D2", 1) 46 | #4F75D2 47 | 48 | Args: 49 | hexstr (str): The provided Hex color code. 50 | scale_factor (float): Value used to scale the color, 51 | if less than 1.0, darkens the color, if greater, it brightens it. 52 | Returns: 53 | str: The new hex color code, after scaling. 54 | """ 55 | 56 | hexstr = hexstr.strip('#') 57 | 58 | 59 | r, g, b = int(hexstr[:2], 16), int(hexstr[2:4], 16), int(hexstr[4:], 16) 60 | 61 | if scale_factor < 0 or len(hexstr) != 6: 62 | return hexstr 63 | 64 | r = clamp(r * scale_factor) 65 | g = clamp(g * scale_factor) 66 | b = clamp(b * scale_factor) 67 | 68 | return "#%02x%02x%02x" % (r, g, b) 69 | -------------------------------------------------------------------------------- /image_tools/manipulator.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module responsible for providing image manipulation functionalities. 3 | """ 4 | 5 | from settings import * 6 | from PIL import Image, ImageOps, ImageEnhance, ImageFilter 7 | import numpy as np 8 | 9 | def sepia_palette() -> list[int]: 10 | """ 11 | Generate a sepia palette to apply to images. 12 | 13 | Returns: 14 | list[int]: the resulting palette. 15 | """ 16 | BASE_COLOR = (255, 240, 192) 17 | palette = [] 18 | r, g, b = BASE_COLOR 19 | for i in range(255): 20 | new_red = r * i // 255 21 | new_green = g * i // 255 22 | new_blue = b * i // 255 23 | palette.extend((new_red, new_green, new_blue)) 24 | 25 | return palette 26 | 27 | def sepia_filter(image: Image) -> Image: 28 | """ 29 | Converts an image to sepia tone. 30 | 31 | Args: 32 | image: A PIL.Image object. 33 | 34 | Returns: 35 | A PIL.Image object in sepia tone. 36 | """ 37 | sepia = sepia_palette() 38 | gray = image.convert("L") 39 | gray.putpalette(sepia) 40 | 41 | return gray.convert("RGB") 42 | 43 | 44 | class ImageManipulator: 45 | """ 46 | Class containing all image-manipulation methods used in the app. 47 | """ 48 | def __init__(self, image_file: Image.Image) -> None: 49 | self.used_image = image_file 50 | if self.used_image.mode == 'P': 51 | self.used_image = self.used_image.convert('RGB') 52 | 53 | 54 | def rotate_image(self, rotation_angle: float) -> None: 55 | """ 56 | Apply a rotation by a given angle to the image. 57 | 58 | Args: 59 | rotation_angle (float): rotation angle. 60 | """ 61 | if rotation_angle != ROTATE_DEFAULT: 62 | self.used_image = self.used_image.rotate(angle=rotation_angle) 63 | 64 | def zoom_image(self, zoom_amount: float) -> None: 65 | """ 66 | Zoom the image by a given amount. 67 | 68 | Args: 69 | zoom_amount (float): The given zoom amount. 70 | """ 71 | if zoom_amount != ZOOM_DEFAULT: 72 | self.used_image = ImageOps.crop(self.used_image, border=zoom_amount) 73 | 74 | def flip_image(self, flip_option: str) -> None: 75 | """ 76 | Flip the image horizontally, vertically, or both ways. 77 | 78 | Args: 79 | flip_option (str): The flip type, can be 'X' for horizontal, 80 | 'Y' for vertical, and 'Both' for both directions. 81 | """ 82 | if flip_option != 'None': 83 | mirror = flip_option in ('X', 'Both') 84 | flip = flip_option in ('Y', 'Both') 85 | if mirror: 86 | self.used_image = ImageOps.mirror(self.used_image) 87 | if flip: 88 | self.used_image = ImageOps.flip(self.used_image) 89 | 90 | def apply_brightness(self, brightness_value: float) -> None: 91 | """ 92 | Change the image brightness by a given amount. 93 | 94 | Args: 95 | brightness_value (float): New brightness value. When set to 0, 96 | the image becomes completely black. 97 | """ 98 | if brightness_value != BRIGHTNESS_DEFAULT: 99 | brightness_enhancer = ImageEnhance.Brightness(self.used_image) 100 | self.used_image = brightness_enhancer.enhance(brightness_value) 101 | 102 | def apply_vibrance(self, vibrance_value: float) -> None: 103 | """ 104 | Change the image vibrance by a given amount. 105 | 106 | Args: 107 | vibrance_value (float): New vibrance value. When set to 0, 108 | the image becomes grayscale. 109 | """ 110 | if vibrance_value != VIBRANCE_DEFAULT: 111 | brightness_enhancer = ImageEnhance.Color(self.used_image) 112 | self.used_image = brightness_enhancer.enhance(vibrance_value) 113 | 114 | def apply_grayscale(self, grayscale_flag: bool) -> None: 115 | """ 116 | Convert the image to grayscale (Black and White). 117 | 118 | Args: 119 | grayscale_flag (bool): Set to True, if the filter is chosen. 120 | """ 121 | if grayscale_flag: 122 | self.used_image = ImageOps.grayscale(self.used_image) 123 | 124 | def invert_colors(self, invert_flag: bool) -> None: 125 | """ 126 | Invert the image colors (Negative filter). 127 | 128 | Args: 129 | invert_flag (bool): Set to True, if the filter is chosen. 130 | """ 131 | if invert_flag: 132 | try: 133 | self.used_image = ImageOps.invert(self.used_image) 134 | except: 135 | raise OSError 136 | 137 | def apply_sepia(self, sepia_flag: bool) -> None: 138 | """ 139 | Apply a sepia filter to the image. 140 | 141 | Args: 142 | sepia_flag (bool): Set to True, if the filter is chosen. 143 | """ 144 | if sepia_flag: 145 | self.used_image = sepia_filter(self.used_image) 146 | 147 | def apply_4color_filter(self, four_col_flag: bool) -> None: 148 | """ 149 | Apply a 4-color filter to the image i.e. display the image using only 4 colors, 150 | extracted from the image by an algorithm implemented in Pillow. 151 | 152 | Args: 153 | four_col_flag (bool): Set to True, if the filter is chosen. 154 | """ 155 | if four_col_flag: 156 | self.used_image = self.used_image.convert("P", palette=Image.ADAPTIVE, colors=4) 157 | 158 | def blur_image(self, blur_value: float) -> None: 159 | """ 160 | Blur the image by a given amount. 161 | 162 | Args: 163 | blur_value (float): Blur intensity. 164 | """ 165 | if blur_value != BLUR_DEFAULT: 166 | blur_filter = ImageFilter.GaussianBlur(blur_value) 167 | self.used_image = self.used_image.filter(blur_filter) 168 | 169 | def change_contrast(self, contrast_value: float) -> None: 170 | """ 171 | Change the image contrast by a given amount. 172 | 173 | Args: 174 | contrast_value (float): Used contrast value. 175 | """ 176 | if contrast_value != CONTRAST_DEFAULT: 177 | contrast_filter = ImageFilter.UnsharpMask(contrast_value) 178 | self.used_image = self.used_image.filter(contrast_filter) 179 | 180 | def change_balance(self, balance_value: float) -> None: 181 | """ 182 | Change the image balance by a given amount. 183 | 184 | Args: 185 | balance_value (float): Used balance value. 186 | """ 187 | if balance_value != BALANCE_DEFAULT: 188 | balance_enhancer = ImageEnhance.Color(self.used_image) 189 | self.used_image = balance_enhancer.enhance(balance_value) 190 | 191 | 192 | def change_hue(self, hue_value: int) -> None: 193 | """ 194 | Change the image hue (color) by a given amount. 195 | 196 | Args: 197 | hue_value (int): Ranges from -100 to 100. 198 | """ 199 | if hue_value != HUE_DEFAULT: 200 | used_operation = np.add if hue_value > 0 else np.subtract 201 | hue_value = abs(hue_value) 202 | hsv_im = self.used_image.convert("HSV") 203 | h_chan, s_chan, v_chan = hsv_im.split() 204 | 205 | h_chan_np = np.array(h_chan) 206 | h_chan_np = used_operation(h_chan_np, hue_value) 207 | h_chan = Image.fromarray(h_chan_np) 208 | 209 | result = Image.merge("HSV", (h_chan, s_chan, v_chan)) 210 | self.used_image = result.convert("RGB") 211 | 212 | def apply_effect(self, effect_name: str) -> None: 213 | """ 214 | Applies different types of Pillow built-in filters. 215 | 216 | Args: 217 | effect_name (str): Selected effect, valid values are 218 | [Emboss, Find edges, Contour, Edge enhance] 219 | """ 220 | applied_effect = None 221 | match effect_name: 222 | case 'Emboss': 223 | applied_effect = ImageFilter.EMBOSS 224 | case 'Find edges': 225 | applied_effect = ImageFilter.FIND_EDGES 226 | case 'Contour': 227 | applied_effect = ImageFilter.CONTOUR 228 | case 'Edge enhance': 229 | applied_effect = ImageFilter.EDGE_ENHANCE 230 | 231 | if applied_effect: 232 | self.used_image = self.used_image.filter(applied_effect) 233 | 234 | @property 235 | def image_result(self) -> Image.Image: 236 | """ 237 | Get the resulting image after applying all effects and filters. 238 | 239 | Returns: 240 | Image.Image: The resulting image. 241 | """ 242 | return self.used_image -------------------------------------------------------------------------------- /image_tools/metadata.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module used to extract metadata from an image, 3 | such as EXIF, GPS, and TIFF tags. 4 | 5 | """ 6 | 7 | from PIL import Image 8 | from PIL.ExifTags import TAGS, GPSTAGS 9 | from GPSPhoto import gpsphoto 10 | import os 11 | 12 | ONE_MEGABYTE = 1048576 13 | 14 | def get_image(image: str | Image.Image) -> Image.Image: 15 | """ 16 | Returns a given image if it's an instance of ``PIL.Image.Image``, 17 | otherwise, opens the image using the ``PIL.Image.open`` method. 18 | 19 | Args: 20 | image (str | Image.Image): Pillow Image instance, or a path to an image. 21 | 22 | Returns: 23 | Image.Image: The image file. 24 | """ 25 | return Image.open(image) if isinstance(image, str) else image 26 | 27 | def get_bits(image: Image.Image) -> str: 28 | return str(image.bits) if "bits" in image.__dir__() else None 29 | 30 | def format_size(size: int) -> str: 31 | """ 32 | Return a formatted string of a file size in KB or MB. 33 | 34 | Args: 35 | size (int): Size in bytes 36 | 37 | Returns: 38 | str: formatted result string 39 | """ 40 | result = 0 41 | if size >= ONE_MEGABYTE: 42 | result = (size / (1024 * 1024)) 43 | return f"{result:.2f} MB" 44 | else: 45 | result = size / 1024 46 | return f"{result:.2f} KB" 47 | 48 | def get_image_info(image: Image.Image) -> str: 49 | """ 50 | Get multiple attributes of the image file as a string. 51 | 52 | Args: 53 | image (Image.Image): The provided image file. 54 | 55 | Returns: 56 | str: image information as a multi-line string 57 | """ 58 | bit_count = get_bits(image) 59 | bytes = os.path.getsize(image.filename) 60 | info_str = f"File: \"{image.filename}\"\n" 61 | info_str += f"Size: {format_size(bytes)}\n" 62 | if bit_count: info_str += f"Number of bits: {bit_count}\n" 63 | info_str += f"Entropy: {image.entropy():.3f}\n" 64 | if image.format: 65 | info_str += f"Format: {image.format} ({image.format_description})\n" 66 | 67 | info_str += f"Number of bands: {len(image.getbands())}.\nSize: {image.size[0]}x{image.size[1]}" 68 | return info_str 69 | 70 | 71 | def get_metadata(image: str | Image.Image) -> tuple[str, str, str]: 72 | """ 73 | Extract different metadata types from an image. 74 | 75 | Args: 76 | image (str | Image.Image): Pillow Image instance, or a path to an image. 77 | 78 | Returns: 79 | tuple[str, str, str]: EXIF, GPS, and TIFF data as strings. 80 | """ 81 | exif_table = {} 82 | image_file = get_image(image) 83 | info = image_file.getexif() 84 | 85 | # Collect EXIF data from image 86 | for tag, value in info.items(): 87 | decoded = TAGS.get(tag, tag) 88 | exif_table[decoded] = value 89 | 90 | # Collect GPS data from image (if any) 91 | gps_info = {} 92 | if 'GPSInfo' in exif_table: 93 | gps_info = gpsphoto.getGPSData(image_file.filename) 94 | 95 | for key, value in gps_info.items(): 96 | exif_table[key] = value 97 | 98 | tiff_metadata = {} 99 | if image_file.format.lower() == 'tiff': 100 | for tag in image_file.tag.items(): 101 | tiff_metadata[TAGS.get(tag[0])] = tag[1] 102 | 103 | return stringfy(exif_table, tiff_metadata) 104 | 105 | 106 | def stringfy(exif_table: dict, tiff_metadata: dict) -> tuple[str, str, str]: 107 | """ 108 | Parse tags tables and return them as strings. 109 | 110 | Args: 111 | exif_table (dict): A ``dict`` of EXIF and GPS image data. 112 | tiff_metadata (dict): A ``dict`` of TIFF image data (for ``.tiff`` images only). 113 | 114 | Returns: 115 | tuple[str, str, str]: Strings containing all data. 116 | """ 117 | EXIF_STRING = "" 118 | for key, val in exif_table.items(): 119 | EXIF_STRING += f'{str(key)}: {str(val)}\n' 120 | EXIF_STRING += f'---------------------------------------------\n' 121 | 122 | TIFF_STRING = "" 123 | for key, val in tiff_metadata.items(): 124 | TIFF_STRING += f'{str(key)}: {str(val)}\n' 125 | 126 | return EXIF_STRING, TIFF_STRING -------------------------------------------------------------------------------- /image_widgets.py: -------------------------------------------------------------------------------- 1 | """ 2 | Widgets responsible for opening, displaying the image, 3 | in addition to closing the editor menus. 4 | """ 5 | 6 | import tkinter 7 | from typing import Callable 8 | import customtkinter as ctk 9 | from tkinter import Event, filedialog 10 | from settings import * 11 | 12 | 13 | class ImageImport(ctk.CTkFrame): 14 | """ 15 | First frame to display when opening the app, allowing for opening of an image file. 16 | """ 17 | def __init__(self, parent: ctk.CTk, import_func: Callable[[str], None]): 18 | super().__init__(master=parent) 19 | self.grid(column=0, columnspan=2, row=0, sticky='nsew') 20 | self.import_func = import_func 21 | ctk.CTkButton(self, text='Open Image', command=self.open_dialog).pack(expand=True) 22 | 23 | 24 | def open_dialog(self): 25 | """ 26 | Ask user to select image file to open. 27 | """ 28 | path = filedialog.askopenfile().name 29 | self.import_func(path) 30 | 31 | 32 | class ImageOutput(tkinter.Canvas): 33 | """ 34 | Main canvas that displays the open image. 35 | """ 36 | def __init__(self, parent: ctk.CTk, resize_func: Callable[[Event], None]): 37 | super().__init__(master=parent, background=CANVAS_BACKGROUND, 38 | bd=0, highlightthickness=0, relief='ridge') 39 | self.grid(row=0, column=1, sticky='nsew', padx=10, pady=10) 40 | self.bind('', resize_func) 41 | 42 | 43 | class CloseOutputButton(ctk.CTkButton): 44 | """ 45 | Button to close the open image and editor menus. 46 | """ 47 | def __init__(self, parent: ctk.CTk, close_func: Callable[[], None]): 48 | super().__init__(master=parent, text='X', 49 | text_color=CLOSE_BUTTON_COLOR, 50 | fg_color='transparent', 51 | hover_color='firebrick2', 52 | border_color='gray25', 53 | width=40, height=40, command=close_func) 54 | self.place(relx = 0.99, rely = 0.02, anchor = 'ne') -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import customtkinter as ctk 2 | from tkinter import messagebox, Event 3 | from PIL import Image, ImageTk, ImageOps 4 | # App-specific imports 5 | from image_widgets import ImageImport, ImageOutput, CloseOutputButton 6 | from image_tools.manipulator import ImageManipulator 7 | from menu import Menu 8 | from settings import * 9 | 10 | class App(ctk.CTk): 11 | """ 12 | Main application UI and functionality. Instance of CTK Main Window. 13 | """ 14 | def __init__(self) -> None: 15 | # Setup 16 | super().__init__() 17 | 18 | self.image: Image.Image = None 19 | self.tk_image: ImageTk = None 20 | self.image_import: ImageImport | None = None 21 | self.image_output: ImageOutput | None = None 22 | 23 | ctk.set_appearance_mode('System') 24 | ctk.set_default_color_theme('theme/custom.json') 25 | self.geometry('1250x660+50+50') 26 | self.title('A-Star Photo Editor') 27 | self.iconbitmap('theme/logo.ico') 28 | self.init_parameters() 29 | 30 | # Layout 31 | self.rowconfigure(0, weight=1) 32 | self.columnconfigure(0, weight=2, uniform='a') 33 | self.columnconfigure(1, weight=6, uniform='a') 34 | 35 | # Canvas data 36 | self.image_width = 0 37 | self.image_height = 0 38 | self.canvas_width = 0 39 | self.canvas_height = 0 40 | 41 | # Widgets 42 | self.image_import = ImageImport(parent=self, import_func=self.import_image) 43 | 44 | self.mainloop() 45 | 46 | 47 | def init_parameters(self) -> None: 48 | """ 49 | Initialize menu parameters used for various effects. 50 | """ 51 | self.selected_theme = ctk.StringVar(value='System') 52 | 53 | self.position_vars = { 54 | 'rotate': ctk.DoubleVar(value=ROTATE_DEFAULT), 55 | 'zoom': ctk.DoubleVar(value=ZOOM_DEFAULT), 56 | 'flip': ctk.StringVar(value=FLIP_OPTIONS[0]), 57 | } 58 | 59 | self.color_vars = { 60 | 'brightness': ctk.DoubleVar(value=BRIGHTNESS_DEFAULT), 61 | 'grayscale': ctk.BooleanVar(value=GRAYSCALE_DEFAULT), 62 | 'sepia': ctk.BooleanVar(value=SEPIA_DEFAULT), 63 | 'invert': ctk.BooleanVar(value=INVERT_DEFAULT), 64 | '4-color': ctk.BooleanVar(value=FOUR_COLOR_DEFAULT), 65 | 'vibrance': ctk.DoubleVar(value=VIBRANCE_DEFAULT), 66 | } 67 | 68 | self.effect_vars = { 69 | 'blur': ctk.DoubleVar(value=BLUR_DEFAULT), 70 | 'contrast': ctk.IntVar(value=CONTRAST_DEFAULT), 71 | 'balance': ctk.IntVar(value=BALANCE_DEFAULT), 72 | 'hue': ctk.IntVar(value=HUE_DEFAULT), 73 | 'effect': ctk.StringVar(value=EFFECT_OPTIONS[0]), 74 | } 75 | 76 | all_vars = ( 77 | list(self.position_vars.values()) 78 | + list(self.color_vars.values()) 79 | + list(self.effect_vars.values()) 80 | ) 81 | 82 | for var in all_vars: 83 | var.trace('w', self.manipulate_image) 84 | 85 | 86 | def import_image(self, path: str) -> None: 87 | """ 88 | Import image file to the app, then display the image and editing menu. 89 | 90 | Args: 91 | path (str): path to the image file 92 | """ 93 | self.original = Image.open(path) # To revert back to the image 94 | self.image = self.original 95 | self.tk_image = ImageTk.PhotoImage(self.image) 96 | self.image_ratio = self.image.size[0] / self.image.size[1] 97 | self.image_import.grid_forget() # Destroy import button to display editor 98 | 99 | self.image_output = ImageOutput(self, self.resize_image) 100 | self.close_button = CloseOutputButton(self, close_func=self.close_editor) 101 | 102 | self.editor_menu = Menu( 103 | self, 104 | self.position_vars, 105 | self.color_vars, 106 | self.effect_vars, 107 | self.image, 108 | self.export_image, 109 | self.save_thumbnail, 110 | ) 111 | 112 | def manipulate_image(self, *args): 113 | self.image = self.original 114 | manipulator = ImageManipulator(self.image) 115 | 116 | manipulator.rotate_image(self.position_vars['rotate'].get()) 117 | 118 | manipulator.zoom_image(self.position_vars['zoom'].get()) 119 | 120 | manipulator.flip_image(self.position_vars['flip'].get()) 121 | 122 | manipulator.apply_brightness(self.color_vars['brightness'].get()) 123 | 124 | manipulator.apply_vibrance(self.color_vars['vibrance'].get()) 125 | 126 | manipulator.apply_grayscale(self.color_vars['grayscale'].get()) 127 | 128 | try: 129 | manipulator.invert_colors(self.color_vars['invert'].get()) 130 | except OSError: 131 | messagebox.showerror("Invalid operation", "Cannot apply this operation on this type of image.") 132 | 133 | manipulator.apply_sepia(self.color_vars['sepia'].get()) 134 | 135 | manipulator.apply_4color_filter(self.color_vars['4-color'].get()) 136 | 137 | manipulator.blur_image(self.effect_vars['blur'].get()) 138 | 139 | manipulator.change_contrast(self.effect_vars['contrast'].get()) 140 | 141 | manipulator.change_balance(self.effect_vars['balance'].get()) 142 | 143 | manipulator.change_hue(self.effect_vars['hue'].get()) 144 | 145 | manipulator.apply_effect(self.effect_vars['effect'].get()) 146 | 147 | 148 | self.image = manipulator.image_result 149 | self.display_image() 150 | 151 | 152 | def close_editor(self) -> None: 153 | """ 154 | Close the editing panel and the open image. 155 | """ 156 | self.image_output.grid_forget() 157 | self.close_button.place_forget() 158 | self.editor_menu.grid_forget() 159 | self.editor_menu.pack_forget() 160 | self.image_import = ImageImport(parent=self, import_func=self.import_image) 161 | 162 | 163 | def resize_image(self, event: Event) -> None: 164 | """ 165 | Resizes the image relative to the canvas size, adapts with 166 | when changing the window size. 167 | 168 | Args: 169 | event (tkinter.Event): Window change event. 170 | """ 171 | self.canvas_height = event.height 172 | self.canvas_width = event.width 173 | canvas_ratio = self.canvas_width / self.canvas_height 174 | 175 | # resize image 176 | if canvas_ratio > self.image_ratio: # Canvas is wider than image 177 | self.image_height = int(event.height) 178 | self.image_width = int(self.image_height * self.image_ratio) 179 | else: # Canvas is taller than image 180 | self.image_width = int(event.width) 181 | self.image_height = int(self.image_width / self.image_ratio) 182 | 183 | self.display_image() 184 | 185 | 186 | def display_image(self) -> None: 187 | """ 188 | Display image on the output canvas. 189 | """ 190 | self.image_output.delete('all') 191 | resized_image = self.image.resize( 192 | (self.image_width, self.image_height) 193 | ) 194 | self.tk_image = ImageTk.PhotoImage(resized_image) 195 | self.image_output.create_image( 196 | self.canvas_width / 2, 197 | self.canvas_height / 2, 198 | image=self.tk_image, 199 | anchor='center', 200 | ) 201 | 202 | def export_image(self, filename: str, extension: str, output_path: str, quality: int = 100) -> None: 203 | """ 204 | Save image to the output folder. 205 | 206 | Args: 207 | filename (str): name of the saved file. 208 | extension (str): file type extension (jpg, png, ...) 209 | output_path (str): output folder path. 210 | """ 211 | export_str = f'{output_path}/{filename}.{extension}' 212 | 213 | OPTIMIZE = True if quality != 100 else False 214 | IS_JPG = self.image.format and self.image.format.lower() in ('jpg', 'jpeg') 215 | if quality == 100 and IS_JPG: 216 | quality = 'keep' 217 | 218 | self.image.save(export_str, quality=quality, optimize=OPTIMIZE) 219 | messagebox.showinfo( 220 | title='Done', message='Successfully exported image file.' 221 | ) 222 | 223 | def save_thumbnail(self, name: str, size: tuple[int, int], output_path: str) -> None: 224 | """ 225 | Save thumbnail to the output folder. 226 | 227 | Args: 228 | name (str): name of the saved file. 229 | size tuple(int, int): size of the thumbnail 230 | output_path (str): output folder path. 231 | """ 232 | copy = self.image 233 | if copy.mode == 'P': 234 | copy = copy.convert('RGB') 235 | export_str = f'{output_path}/{name}.jpg' 236 | copy.thumbnail(size) 237 | copy.save(export_str) 238 | messagebox.showinfo(title='Done', message="Successfully created thumbnail.") 239 | 240 | 241 | if __name__ == '__main__': 242 | App() -------------------------------------------------------------------------------- /menu.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module to create and manage editor menus. 3 | """ 4 | import customtkinter as ctk 5 | import image_tools.metadata as Metadata 6 | from panel import * 7 | from settings import * 8 | from typing import Any, Callable 9 | from PIL.Image import Image 10 | 11 | 12 | class Menu(ctk.CTkTabview): 13 | """ 14 | Main tabbed menu for the editor. Subtype of customtkinter.CTkTabView 15 | """ 16 | def __init__( 17 | self, parent: ctk.CTk, pos_vars: dict[Any], color_vars: dict[Any], 18 | effect_vars: dict[Any], image: Image, export_func: Callable[[str, str, str],None], 19 | save_thumb_func: Callable[[str, str, str],None] 20 | ): 21 | super().__init__(master=parent) 22 | self.grid(row=0, column=0, sticky='nsew', padx=10, pady=10) 23 | 24 | # Tabs 25 | self.add('Position') 26 | self.add('Color') 27 | self.add('Effect') 28 | self.add('Export') 29 | self.add('File') 30 | 31 | # Widgets 32 | PositionFrame(self.tab('Position'), pos_vars) 33 | ColorFrame(self.tab('Color'), image, color_vars) 34 | EffectFrame(self.tab('Effect'), effect_vars) 35 | ExportFrame(self.tab('Export'), export_func, save_thumb_func) 36 | InfoFrame(self.tab('File'), image) 37 | 38 | 39 | class InfoFrame(ctk.CTkFrame): 40 | """ 41 | CTkFrame to display image EXIF, GPS, and TIFF tags (if found). 42 | """ 43 | def __init__(self, parent: ctk.CTkFrame, image: Image) -> None: 44 | super().__init__(master=parent, fg_color='transparent') 45 | self.pack(expand=True, fill='both') 46 | EXIF_STRING, TIFF_STRING = Metadata.get_metadata(image) 47 | IMAGE_INFO = Metadata.get_image_info(image) 48 | 49 | InfoPanel(self, panel_name='Image Data', info_str=IMAGE_INFO, custom_box_height=130) 50 | if EXIF_STRING: 51 | InfoPanel(self, panel_name='EXIF & GPS Data', info_str=EXIF_STRING) 52 | if TIFF_STRING: 53 | InfoPanel(self, panel_name='TIFF Data', info_str=TIFF_STRING) 54 | 55 | 56 | class PositionFrame(ctk.CTkFrame): 57 | """ 58 | CTkFrame to control image positioning, such as rotation, zoom and flipping. 59 | """ 60 | def __init__(self, parent: ctk.CTkFrame, pos_vars: dict[Any]) -> None: 61 | super().__init__(master=parent, fg_color='transparent') 62 | self.pack(expand=True, fill='both') 63 | 64 | SliderPanel( 65 | self, 66 | panel_name='Rotation', 67 | data_var=pos_vars['rotate'], 68 | min_value=0, 69 | max_value=360, 70 | ) 71 | SliderPanel( 72 | self, 73 | panel_name='Zoom', 74 | data_var=pos_vars['zoom'], 75 | min_value=0, 76 | max_value=400, 77 | ) 78 | SegmentedPanel( 79 | self, 80 | panel_name='Invert', 81 | data_var=pos_vars['flip'], 82 | options=FLIP_OPTIONS, 83 | ) 84 | RevertButton( 85 | self, 86 | (pos_vars['rotate'], ROTATE_DEFAULT), 87 | (pos_vars['zoom'], ZOOM_DEFAULT), 88 | (pos_vars['flip'], FLIP_OPTIONS[0]), 89 | ) 90 | 91 | 92 | class ColorFrame(ctk.CTkFrame): 93 | """ 94 | CTkFrame to apply image color filters and effects (grayscale, sepia, ...), 95 | with functionality to extract colors from the open image. 96 | """ 97 | def __init__(self, parent: ctk.CTkFrame, image: Image, color_vars: dict[Any]) -> None: 98 | super().__init__(master=parent, fg_color='transparent') 99 | self.pack(expand=True, fill='both') 100 | self.image_file = Metadata.get_image(image) 101 | 102 | SwitchPanel( 103 | self, 104 | (color_vars['grayscale'], 'B/W'), 105 | (color_vars['sepia'], 'Sepia'), 106 | (color_vars['invert'], 'Negative'), 107 | (color_vars['4-color'], '4-Color'), 108 | ) 109 | SliderPanel( 110 | self, 111 | panel_name='Brightness', 112 | data_var=color_vars['brightness'], 113 | min_value=0, 114 | max_value=5, 115 | ) 116 | SliderPanel( 117 | self, 118 | panel_name='Vibrance', 119 | data_var=color_vars['vibrance'], 120 | min_value=0, 121 | max_value=5, 122 | ) 123 | 124 | ColorsPanel(self, self.image_file) 125 | 126 | RevertButton( 127 | self, 128 | (color_vars['grayscale'], GRAYSCALE_DEFAULT), 129 | (color_vars['sepia'], SEPIA_DEFAULT), 130 | (color_vars['invert'], INVERT_DEFAULT), 131 | (color_vars['4-color'], FOUR_COLOR_DEFAULT), 132 | (color_vars['brightness'], BRIGHTNESS_DEFAULT), 133 | (color_vars['vibrance'], VIBRANCE_DEFAULT), 134 | ) 135 | 136 | 137 | class EffectFrame(ctk.CTkFrame): 138 | """ 139 | CTkFrame to apply different effects to the open image, 140 | such as blurring, changing contrast, balance. 141 | """ 142 | def __init__(self, parent: ctk.CTkFrame, effect_vars: dict[Any]) -> None: 143 | super().__init__(master=parent, fg_color='transparent') 144 | self.pack(expand=True, fill='both') 145 | 146 | DropDownPanel( 147 | self, data_var=effect_vars['effect'], options=EFFECT_OPTIONS 148 | ) 149 | SliderPanel( 150 | self, 151 | panel_name='Blur', 152 | data_var=effect_vars['blur'], 153 | min_value=0, 154 | max_value=30, 155 | ) 156 | SliderPanel( 157 | self, 158 | panel_name='Contrast', 159 | data_var=effect_vars['contrast'], 160 | min_value=0, 161 | max_value=10, 162 | ) 163 | SliderPanel( 164 | self, 165 | panel_name='Balance', 166 | data_var=effect_vars['balance'], 167 | min_value=0, 168 | max_value=10, 169 | ) 170 | SliderPanel( 171 | self, 172 | panel_name='Hue', 173 | data_var=effect_vars['hue'], 174 | min_value=-100, 175 | max_value=100, 176 | ) 177 | 178 | RevertButton( 179 | self, 180 | (effect_vars['effect'], EFFECT_OPTIONS[0]), 181 | (effect_vars['blur'], BLUR_DEFAULT), 182 | (effect_vars['contrast'], CONTRAST_DEFAULT), 183 | (effect_vars['balance'], BALANCE_DEFAULT), 184 | (effect_vars['hue'], HUE_DEFAULT), 185 | ) 186 | 187 | 188 | class ExportFrame(ctk.CTkFrame): 189 | """ 190 | CTkFrame to export the open image in different output formats. 191 | """ 192 | def __init__(self, parent: ctk.CTkFrame, export_func: Callable, thumbnail_save_func: Callable): 193 | super().__init__(master=parent, fg_color='transparent') 194 | self.pack(expand=True, fill='both') 195 | 196 | self.file_name = ctk.StringVar() 197 | self.file_extension = ctk.StringVar(value='jpg') 198 | self.path = ctk.StringVar() 199 | self.quality = ctk.DoubleVar(value=100) 200 | 201 | self.thumbnail_name = ctk.StringVar() 202 | self.thumbnail_path = ctk.StringVar() 203 | self.thumbnail_width = ctk.IntVar(value=200) 204 | self.thumbnail_height = ctk.IntVar(value=200) 205 | 206 | FileNamePanel(self, self.file_name, self.file_extension, self.quality) 207 | FilePathPanel(self, self.path) 208 | ThumbnailPanel( 209 | self, 210 | self.thumbnail_name, 211 | self.thumbnail_path, 212 | (self.thumbnail_width, self.thumbnail_height), 213 | thumbnail_save_func 214 | ) 215 | 216 | ExportButton( 217 | self, export_func, self.file_name, self.file_extension, self.path, 218 | self.quality 219 | ) -------------------------------------------------------------------------------- /panel.py: -------------------------------------------------------------------------------- 1 | """ 2 | Reusable panel components, used throughout the app to display editor widgets. 3 | """ 4 | from typing import Callable, Optional 5 | import customtkinter as ctk 6 | import colorgram 7 | from tkinter import filedialog, messagebox, END 8 | from settings import * 9 | from color_tools import hex_tools as HEX 10 | from PIL.Image import Image 11 | 12 | 13 | class Panel(ctk.CTkFrame): 14 | """ 15 | Base Panel class. 16 | """ 17 | def __init__(self, parent: ctk.CTkFrame) -> None: 18 | super().__init__(master=parent, fg_color=PANEL_BG) 19 | self.pack(fill='x', pady=4, ipady=8) 20 | 21 | 22 | class CardPanel(ctk.CTkFrame): 23 | """ 24 | Base card panel (primarily to display text). 25 | """ 26 | def __init__(self, parent: ctk.CTkFrame) -> None: 27 | super().__init__(master=parent, fg_color=PANEL_BG) 28 | self.pack(fill='both', pady=4, ipady=8) 29 | 30 | 31 | class SliderPanel(Panel): 32 | """ 33 | Panel containing a slider used to implement editor features. 34 | """ 35 | def __init__(self, parent: ctk.CTkFrame, 36 | panel_name: str, data_var: ctk.Variable, 37 | min_value: int = 0, 38 | max_value: int = 1): 39 | super().__init__(parent=parent) 40 | 41 | # Layout 42 | self.rowconfigure((0, 1), weight=1) 43 | self.columnconfigure((0, 1), weight=1) 44 | 45 | self.panel_var = data_var 46 | self.panel_var.trace('w', self.update_text) 47 | 48 | ctk.CTkLabel(self, text=panel_name).grid( 49 | row=0, column=0, sticky='W', padx=10 50 | ) 51 | self.num_label = ctk.CTkLabel(self, text=data_var.get()) 52 | self.num_label.grid(row=0, column=1, sticky='E', padx=10) 53 | 54 | ctk.CTkSlider( 55 | self, 56 | fg_color=SLIDER_BG, 57 | variable=self.panel_var, 58 | from_=min_value, 59 | to=max_value, 60 | ).grid(row=1, column=0, columnspan=2, sticky='ew', padx=10, pady=5) 61 | 62 | def update_text(self, *args): 63 | """ 64 | Update SliderPanel label to match the slider's value. 65 | """ 66 | self.num_label.configure(text=f'{round(self.panel_var.get(), 2)}') 67 | 68 | 69 | class SegmentedPanel(Panel): 70 | """ 71 | Panel with multiple grouped button using ctk.CTkSegmentedButton. 72 | """ 73 | def __init__(self, parent: ctk.CTkFrame, panel_name: str, data_var: ctk.Variable, 74 | options: list[str]) -> None: 75 | super().__init__(parent=parent) 76 | ctk.CTkLabel(self, text=panel_name).pack() 77 | ctk.CTkSegmentedButton(self, variable=data_var, 78 | values=options).pack(expand=True, fill='both', padx=4, pady=4) 79 | 80 | 81 | class SwitchPanel(Panel): 82 | """ 83 | Panel supporting a varied-number of switches. 84 | """ 85 | def __init__(self, parent: ctk.CTkFrame, *args): # args: ((var1, text1), (var2, text2), ...) 86 | super().__init__(parent=parent) 87 | row, col = 0, 0 88 | for var, text in args: 89 | switch = ctk.CTkSwitch( 90 | self, 91 | text=text, 92 | variable=var, 93 | button_color=THEME_COLOR, 94 | fg_color=SLIDER_BG, 95 | ) 96 | switch.grid(row=row, column=col, padx=20) 97 | col += 1 98 | if col > 1: col, row = 0, row + 1 99 | 100 | 101 | class FileNamePanel(Panel): 102 | """ 103 | Panel to name and select output file type. 104 | """ 105 | def __init__(self, parent: ctk.CTkFrame, file_name: ctk.StringVar, file_extension: ctk.StringVar, 106 | quality: ctk.IntVar): 107 | super().__init__(parent=parent) 108 | 109 | self.name = file_name 110 | self.name.trace('w', self.update_text) 111 | self.extension = file_extension 112 | self.image_quality = quality 113 | 114 | ctk.CTkLabel(self, text='Save Image As').pack( 115 | anchor='w', padx=20, pady=2 116 | ) 117 | ctk.CTkEntry(self, textvariable=self.name).pack( 118 | fill='x', padx=20, pady=5 119 | ) 120 | 121 | frame = ctk.CTkFrame(self, fg_color='transparent') 122 | jpg_check = ctk.CTkCheckBox( 123 | frame, 124 | text='JPG', 125 | variable=self.extension, 126 | command=lambda: self.update_extension('jpg'), 127 | onvalue='jpg', 128 | offvalue='png', 129 | ) 130 | png_check = ctk.CTkCheckBox( 131 | frame, 132 | text='PNG', 133 | variable=self.extension, 134 | command=lambda: self.update_extension('png'), 135 | onvalue='png', 136 | offvalue='jpg', 137 | ) 138 | jpg_check.pack(side='left', fill='x', expand=True) 139 | png_check.pack(side='left', fill='x', expand=True) 140 | frame.pack(expand=True, fill='x', padx=20) 141 | 142 | self.output = ctk.CTkLabel( 143 | self, text='example.jpg', fg_color=DARK_GREY, corner_radius=8 144 | ) 145 | self.output.pack(pady=5) 146 | 147 | quality_frame = ctk.CTkFrame(self, fg_color='transparent') 148 | 149 | ctk.CTkLabel(quality_frame, text='Quality:').grid(row=0, column=0, padx=2) 150 | ctk.CTkEntry(quality_frame, width=45, textvariable=self.image_quality).grid(row=0, column=1) 151 | ctk.CTkLabel(quality_frame, text='%').grid(row=0, column=3, padx=1) 152 | 153 | quality_frame.pack(expand=True, fill='x', padx=82) 154 | 155 | 156 | def update_text(self, *args): 157 | """ 158 | Replace output file's name with underscores instead of spaces, 159 | then preview the chosen name. 160 | """ 161 | if self.name.get(): 162 | fixed_text = ( 163 | self.name.get().replace(' ', '_') + '.' + self.extension.get() 164 | ) 165 | self.output.configure(text=fixed_text) 166 | 167 | def update_extension(self, selected_extension: str) -> None: 168 | """ 169 | Set the output file extension and display the new file name. 170 | """ 171 | self.extension.set(selected_extension) 172 | self.update_text() 173 | 174 | 175 | class FilePathPanel(Panel): 176 | """ 177 | Panel to select output folder for exporting. 178 | """ 179 | def __init__(self, parent: ctk.CTkFrame, path_string: ctk.StringVar) -> None: 180 | super().__init__(parent=parent) 181 | self.path = path_string 182 | ctk.CTkLabel(self, text='Export Image To').pack( 183 | anchor='w', padx=5, pady=2 184 | ) 185 | ctk.CTkEntry(self, textvariable=self.path).pack( 186 | expand=True, fill='x', padx=5, pady=5 187 | ) 188 | ctk.CTkButton( 189 | self, 190 | text='Select output folder', 191 | command=self.select_file_location, 192 | ).pack(pady=2, padx=5, fill='x') 193 | 194 | def select_file_location(self) -> None: 195 | """ 196 | Ask user to select the output directory to export image to. 197 | """ 198 | self.path.set(filedialog.askdirectory()) 199 | 200 | 201 | class ThumbnailPanel(Panel): 202 | """ 203 | Panel to create a thumbnail of the image and export it. 204 | """ 205 | def __init__(self, parent: ctk.CTkFrame, 206 | thumb_name: ctk.StringVar, 207 | thumb_path: ctk.StringVar, 208 | size: tuple[ctk.IntVar, ctk.IntVar], 209 | save_thumb_func: Callable[[str, tuple[int, int], str], None] 210 | ) -> None: 211 | 212 | super().__init__(parent=parent) 213 | self.configure(height=200) 214 | self.thumb_name = thumb_name 215 | self.thumb_path = thumb_path 216 | self.thumb_width = size[0] 217 | self.thumb_height = size[1] 218 | self.save_thumb_func = save_thumb_func 219 | 220 | ctk.CTkLabel(self, text='Create thumbnail').place(relx=0.02, rely=0.01) 221 | 222 | ctk.CTkLabel(self, text='Width').place(relx=0.10, rely=0.18) 223 | ctk.CTkEntry(self, width=100, textvariable=self.thumb_width).place(relx=0.10, rely=0.30) 224 | 225 | ctk.CTkLabel(self, text='x').place(relx=0.485, rely=0.30) 226 | 227 | ctk.CTkLabel(self, text='Height').place(relx=0.55, rely=0.18) 228 | ctk.CTkEntry(self, width=100, textvariable=self.thumb_height).place(relx=0.55, rely=0.30) 229 | 230 | ctk.CTkLabel(self, text='Thumbnail name:').place(relx=0.02, rely=0.58) 231 | ctk.CTkEntry(self, width=270, textvariable=self.thumb_name).place(relx=0.02, rely=0.70) 232 | ctk.CTkButton( 233 | self, 234 | text='Save thumbnail to folder...', 235 | width=270, 236 | command=self.save_thumbnail_to, 237 | ).place(relx=0.02, rely=0.85) 238 | 239 | def save_thumbnail_to(self) -> None: 240 | """ 241 | Ask user to select the output directory to export thumbnail to. 242 | """ 243 | self.thumb_path.set(filedialog.askdirectory()) 244 | size = (self.thumb_width.get(), self.thumb_height.get()) 245 | self.save_thumb_func(self.thumb_name.get(), size, 246 | self.thumb_path.get() 247 | ) 248 | 249 | 250 | class DropDownPanel(ctk.CTkOptionMenu): 251 | """ 252 | Panel with a drop-down menu. 253 | """ 254 | def __init__(self, parent: ctk.CTkFrame, data_var: ctk.Variable, options: list[str]) -> None: 255 | super().__init__( 256 | master=parent, 257 | values=options, 258 | fg_color=PANEL_BG, 259 | button_color=THEME_COLOR, 260 | button_hover_color=THEME_HOVER, 261 | dropdown_fg_color=DARK_GREY, 262 | variable=data_var, 263 | ) 264 | self.pack(fill='x', pady=4) 265 | 266 | 267 | class InfoPanel(CardPanel): 268 | """ 269 | Card that displays information, with a header label. 270 | """ 271 | def __init__(self, parent: ctk.CTkFrame, panel_name: str, info_str: str, 272 | custom_box_height: int = 200) -> None: 273 | super().__init__(parent=parent) 274 | 275 | # Layout 276 | self.rowconfigure((0, 1), weight=1) 277 | self.columnconfigure((0, 1), weight=1) 278 | 279 | ctk.CTkLabel( 280 | self, text=panel_name, font=('Open Sans', 13, 'bold') 281 | ).grid(row=0, column=0, sticky='W', padx=10) 282 | 283 | self.info = ctk.CTkTextbox(master=self, fg_color=PANEL_BG, height=custom_box_height) 284 | self.info.insert('0.0', info_str) 285 | self.info.configure(state='disabled') 286 | self.info.grid(row=1, column=0, columnspan=2, sticky='ew') 287 | 288 | 289 | class ColorsPanel(CardPanel): 290 | """ 291 | Card to display extracted colors from the image. 292 | """ 293 | def __init__(self, parent: ctk.CTkFrame, image_file: Image): 294 | super().__init__(parent=parent) 295 | self.image = image_file 296 | self.hex_colors = [] 297 | self.run_button = ctk.CTkButton(self, corner_radius=8, 298 | text='Extract colors from image', 299 | command=self.generate_palette).pack(expand=True, fill='x', padx=10) 300 | self.frame = ctk.CTkFrame(self, fg_color='transparent') 301 | 302 | 303 | def generate_palette(self): 304 | """ 305 | Extract the top 14 most frequent colors from the open image. 306 | """ 307 | colors = colorgram.extract(self.image.filename, 2**32) 308 | colors = sorted(colors, key=lambda color: color.proportion)[:14] 309 | self.hex_colors = [HEX.rgb_to_hex(color.rgb.r, color.rgb.g, color.rgb.b) for color in colors] 310 | self.draw_colors() 311 | 312 | def draw_colors(self): 313 | """ 314 | Display extracted colors on the panel. 315 | """ 316 | self.frame.grid_forget() 317 | self.frame.pack(expand=True, fill='both', pady=10, padx=5) 318 | 319 | row, col = 0, 0 320 | for color in self.hex_colors: 321 | ctk.CTkButton(self.frame, corner_radius=14, width=125, height=18, 322 | text=str(color), 323 | fg_color=color, 324 | text_color=WHITE, 325 | hover_color=HEX.colorscale(color, 0.5)).grid(row=row, column=col, padx=5, pady=2) 326 | 327 | col += 1 328 | if col > 1: col, row = 0, row + 1 329 | 330 | 331 | class ButtonPanel(CardPanel): 332 | """ 333 | Button card to apply single functionality. 334 | """ 335 | def __init__(self, parent: ctk.CTkFrame, button_text: str, command_func: Callable) -> None: 336 | super().__init__(parent=parent) 337 | ctk.CTkButton(self, corner_radius=8, 338 | text=button_text, 339 | command=command_func).pack(expand=True, fill='x', padx=10) 340 | 341 | 342 | class RevertButton(ctk.CTkButton): 343 | """ 344 | Button used to revert (undo) all effects used in its frame. 345 | """ 346 | def __init__(self, parent: ctk.CTkFrame, *args) -> None: 347 | super().__init__(master=parent, text='Revert', command=self.reset_vars) 348 | self.pack(side='bottom', pady=10) 349 | self.button_args = args 350 | 351 | def reset_vars(self): 352 | """ 353 | Reset the values of all provided variables to defaults. 354 | """ 355 | for tk_var, default_val in self.button_args: 356 | tk_var.set(default_val) 357 | 358 | 359 | class ExportButton(ctk.CTkButton): 360 | """ 361 | Button used to trigger the export image functionality. 362 | """ 363 | def __init__(self, parent: ctk.CTkFrame, 364 | export_func: Callable, filename: ctk.StringVar, 365 | extension: ctk.StringVar, path: ctk.StringVar, 366 | quality: ctk.IntVar) -> None: 367 | super().__init__(master=parent, text='Export', command=self.save) 368 | 369 | self.export_func = export_func 370 | self.filename = filename 371 | self.extension = extension 372 | self.path = path 373 | self.image_quality = quality 374 | 375 | self.pack(side='bottom', pady=10) 376 | 377 | def save(self) -> None: 378 | """ 379 | Call the export function and provide the full file name and output path. 380 | """ 381 | self.export_func( 382 | self.filename.get(), self.extension.get(), self.path.get(), 383 | int(self.image_quality.get()) 384 | ) 385 | -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Global values and flags used across the application. 3 | """ 4 | 5 | import darkdetect 6 | 7 | ROTATE_DEFAULT = 0 8 | ZOOM_DEFAULT = 0 9 | FLIP_OPTIONS = ['None', 'X', 'Y', 'Both'] 10 | BLUR_DEFAULT = 0 11 | CONTRAST_DEFAULT = 0 12 | BALANCE_DEFAULT = 0 13 | EFFECT_OPTIONS = ['None', 'Emboss', 'Find edges', 'Contour', 'Edge enhance'] 14 | THEME_OPTIONS = ['Light', 'Dark', 'System'] 15 | BRIGHTNESS_DEFAULT = 1 16 | VIBRANCE_DEFAULT = 1 17 | HUE_DEFAULT = 0 18 | GRAYSCALE_DEFAULT = False 19 | SEPIA_DEFAULT = False 20 | FOUR_COLOR_DEFAULT = False 21 | INVERT_DEFAULT = False 22 | # ----------------------------- COLORS -------------------------- 23 | WHITE = '#FFF' 24 | GREY = 'grey' 25 | DARK_GREY = '#212121' if darkdetect.isDark() else "gray70" 26 | CANVAS_BACKGROUND = '#242424' if darkdetect.isDark() else "gray95" 27 | CLOSE_BUTTON_COLOR = '#242424' if darkdetect.isLight() else "gray95" 28 | BLUE = '#1F6AA5' 29 | CLOSE_RED = '#8a0606' 30 | SLIDER_BG = '#64686b' 31 | PANEL_BG = '#181818' if darkdetect.isDark() else "gray75" 32 | DROPDOWN_MAIN_COLOR = '#444' 33 | DROPDOWN_HOVER_COLOR = '#333' 34 | DROPDOWN_MENU_COLOR = '#666' 35 | # ---------------------- APP PALETTE ---------------------------- 36 | BACKGROUND_PRIMARY = "#131c22" 37 | BACKGROUND_SECONDARY = "#2C394B" 38 | BACKGROUND_TERNARY = "#334756" 39 | THEME_COLOR = "#FF4C29" if darkdetect.isDark() else "#2F58CD" 40 | THEME_GRADIENT = "#ff401a" if darkdetect.isDark() else "#172c66" 41 | THEME_HOVER = "#ff2b00" if darkdetect.isDark() else "#1c347b" 42 | THEME_HOVER_GRADIENT = "#e62600" if darkdetect.isDark() else "#162962" -------------------------------------------------------------------------------- /theme/custom.json: -------------------------------------------------------------------------------- 1 | { 2 | "CTk": { 3 | "fg_color": ["gray95", "#242424"] 4 | }, 5 | "CTkToplevel": { 6 | "fg_color": ["gray95", "#242424"] 7 | }, 8 | "CTkFrame": { 9 | "corner_radius": 6, 10 | "border_width": 0, 11 | "fg_color": ["gray90", "gray13"], 12 | "top_fg_color": ["gray85", "gray16"], 13 | "border_color": ["gray65", "gray28"] 14 | }, 15 | "CTkButton": { 16 | "corner_radius": 6, 17 | "border_width": 0, 18 | "fg_color": ["#2F58CD", "#ff401a"], 19 | "hover_color": ["#1c347b", "#e62600"], 20 | "border_color": ["#3E454A", "#949A9F"], 21 | "text_color": ["#DCE4EE", "#DCE4EE"], 22 | "text_color_disabled": ["gray74", "gray60"] 23 | }, 24 | "CTkLabel": { 25 | "corner_radius": 0, 26 | "fg_color": "transparent", 27 | "text_color": ["gray14", "gray84"] 28 | }, 29 | "CTkEntry": { 30 | "corner_radius": 6, 31 | "border_width": 2, 32 | "fg_color": ["#F9F9FA", "#343638"], 33 | "border_color": ["#979DA2", "#565B5E"], 34 | "text_color": ["gray14", "gray84"], 35 | "placeholder_text_color": ["gray52", "gray62"] 36 | }, 37 | "CTkCheckbox": { 38 | "corner_radius": 6, 39 | "border_width": 3, 40 | "fg_color": ["#2F58CD", "#ff401a"], 41 | "border_color": ["#3E454A", "#2C394B"], 42 | "hover_color": ["#1c347b", "#e62600"], 43 | "checkmark_color": ["#DCE4EE", "gray90"], 44 | "text_color": ["gray14", "gray84"], 45 | "text_color_disabled": ["gray60", "gray45"], 46 | "bg_color": "#082032" 47 | }, 48 | "CTkSwitch": { 49 | "corner_radius": 1000, 50 | "border_width": 3, 51 | "button_length": 0, 52 | "fg_Color": ["#939BA2", "#4A4D50"], 53 | "progress_color": ["#2F58CD", "#ff401a"], 54 | "button_color": ["gray36", "#D5D9DE"], 55 | "button_hover_color": ["gray20", "gray100"], 56 | "text_color": ["gray14", "gray84"], 57 | "text_color_disabled": ["gray60", "gray45"] 58 | }, 59 | "CTkRadiobutton": { 60 | "corner_radius": 1000, 61 | "border_width_checked": 6, 62 | "border_width_unchecked": 3, 63 | "fg_color": ["#2F58CD", "#ff401a"], 64 | "border_color": ["#3E454A", "#949A9F"], 65 | "hover_color": ["#1c347b", "#e62600"], 66 | "text_color": ["gray14", "gray84"], 67 | "text_color_disabled": ["gray60", "gray45"] 68 | }, 69 | "CTkProgressBar": { 70 | "corner_radius": 1000, 71 | "border_width": 0, 72 | "fg_color": ["#939BA2", "#4A4D50"], 73 | "progress_color": ["#2F58CD", "#ff401a"], 74 | "border_color": ["gray", "gray"] 75 | }, 76 | "CTkSlider": { 77 | "corner_radius": 1000, 78 | "button_corner_radius": 1000, 79 | "border_width": 6, 80 | "button_length": 0, 81 | "fg_color": ["#939BA2", "#4A4D50"], 82 | "progress_color": ["gray40", "#AAB0B5"], 83 | "button_color": ["#2F58CD", "#ff401a"], 84 | "button_hover_color": ["#1c347b", "#e62600"] 85 | }, 86 | "CTkOptionMenu": { 87 | "corner_radius": 6, 88 | "fg_color": ["#2F58CD", "#ff401a"], 89 | "button_color": ["#172c66", "#e62600"], 90 | "button_hover_color": ["#1c347b", "#1e2c40"], 91 | "text_color": ["gray40", "#DCE4EE"], 92 | "text_color_disabled": ["gray74", "gray60"] 93 | }, 94 | "CTkComboBox": { 95 | "corner_radius": 6, 96 | "border_width": 2, 97 | "fg_color": ["#F9F9FA", "#343638"], 98 | "border_color": ["#979DA2", "#565B5E"], 99 | "button_color": ["#979DA2", "#565B5E"], 100 | "button_hover_color": ["#6E7174", "#7A848D"], 101 | "text_color": ["gray14", "gray84"], 102 | "text_color_disabled": ["gray50", "gray45"] 103 | }, 104 | "CTkScrollbar": { 105 | "corner_radius": 1000, 106 | "border_spacing": 4, 107 | "fg_color": "transparent", 108 | "button_color": ["gray55", "gray41"], 109 | "button_hover_color": ["gray40", "gray53"] 110 | }, 111 | "CTkSegmentedButton": { 112 | "corner_radius": 6, 113 | "border_width": 2, 114 | "fg_color": ["#979DA2", "gray29"], 115 | "selected_color": ["#2F58CD", "#ff401a"], 116 | "selected_hover_color": ["#1c347b", "#e62600"], 117 | "unselected_color": ["#979DA2", "gray29"], 118 | "unselected_hover_color": ["gray70", "gray41"], 119 | "text_color": ["#DCE4EE", "#DCE4EE"], 120 | "text_color_disabled": ["gray74", "gray60"] 121 | }, 122 | "CTkTextbox": { 123 | "corner_radius": 6, 124 | "border_width": 0, 125 | "fg_color": ["gray100", "gray20"], 126 | "border_color": ["#979DA2", "#565B5E"], 127 | "text_color": ["gray14", "gray84"], 128 | "scrollbar_button_color": ["gray55", "gray41"], 129 | "scrollbar_button_hover_color": ["gray40", "gray53"] 130 | }, 131 | "CTkScrollableFrame": { 132 | "label_fg_color": ["gray80", "gray21"] 133 | }, 134 | "DropdownMenu": { 135 | "fg_color": ["gray90", "gray20"], 136 | "hover_color": ["gray75", "gray28"], 137 | "text_color": ["gray14", "gray84"] 138 | }, 139 | "CTkFont": { 140 | "macOS": { 141 | "family": "SF Display", 142 | "size": 13, 143 | "weight": "normal" 144 | }, 145 | "Windows": { 146 | "family": "Open Sans", 147 | "size": 13, 148 | "weight": "normal" 149 | }, 150 | "Linux": { 151 | "family": "Roboto", 152 | "size": 13, 153 | "weight": "normal" 154 | } 155 | } 156 | } -------------------------------------------------------------------------------- /theme/image-editing.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ali10-star/A-Star-Photo-Editor/084dc42018236467d8e4ad416cbe896317ca26d8/theme/image-editing.ico -------------------------------------------------------------------------------- /theme/logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ali10-star/A-Star-Photo-Editor/084dc42018236467d8e4ad416cbe896317ca26d8/theme/logo.ico --------------------------------------------------------------------------------