├── .gitignore ├── click.wav ├── folder.png ├── app_icon.ico ├── delete_crop.png ├── open_folder.png ├── rotate_left.png ├── delete_image.png ├── input_folder.png ├── output_folder.png ├── rotate_right.png ├── requirements.txt ├── LICENSE ├── pruneriq.py ├── README.md └── PixelPruner.py /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | usersettings.json 3 | __pycache__/pruneriq.cpython-313.pyc 4 | -------------------------------------------------------------------------------- /click.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theallyprompts/PixelPruner/HEAD/click.wav -------------------------------------------------------------------------------- /folder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theallyprompts/PixelPruner/HEAD/folder.png -------------------------------------------------------------------------------- /app_icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theallyprompts/PixelPruner/HEAD/app_icon.ico -------------------------------------------------------------------------------- /delete_crop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theallyprompts/PixelPruner/HEAD/delete_crop.png -------------------------------------------------------------------------------- /open_folder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theallyprompts/PixelPruner/HEAD/open_folder.png -------------------------------------------------------------------------------- /rotate_left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theallyprompts/PixelPruner/HEAD/rotate_left.png -------------------------------------------------------------------------------- /delete_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theallyprompts/PixelPruner/HEAD/delete_image.png -------------------------------------------------------------------------------- /input_folder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theallyprompts/PixelPruner/HEAD/input_folder.png -------------------------------------------------------------------------------- /output_folder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theallyprompts/PixelPruner/HEAD/output_folder.png -------------------------------------------------------------------------------- /rotate_right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theallyprompts/PixelPruner/HEAD/rotate_right.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | #pip install -r requirements.txt 2 | 3 | # GUI and file management 4 | tkinterdnd2 5 | 6 | # Image handling 7 | pillow 8 | 9 | # Version parsing 10 | packaging 11 | 12 | # Image Analysis 13 | opencv-python 14 | 15 | # Image Analysis 16 | numpy -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 TheAlly 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 | -------------------------------------------------------------------------------- /pruneriq.py: -------------------------------------------------------------------------------- 1 | """Utility functions for analysing cropped images. 2 | 3 | Metrics explained 4 | ----------------- 5 | ``contrast`` 6 | Standard deviation of all pixel intensities. Higher values indicate 7 | greater difference between dark and light areas. 8 | ``clarity`` 9 | Variance of the Laplacian of the greyscale image. This acts as a 10 | measure of sharpness where larger numbers mean more defined edges. 11 | ``noise`` 12 | Estimate of noise based on the variance between the original 13 | greyscale image and a blurred version. Lower values are better. 14 | ``aesthetic`` 15 | Placeholder score for a future aesthetic model. 16 | 17 | Each image is also given a simple rating (``Poor`` through ``Excellent``) 18 | derived from threshold values of the above metrics. In addition to the 19 | raw values, each metric is converted to a 0-100 ``*_pct`` score using the 20 | thresholds as reference points. These scores provide an easy-to-read 21 | percentage indicating how close a metric is to the desired range. The 22 | ``reason`` field in the returned dictionary briefly explains why a 23 | particular rating was chosen. 24 | """ 25 | 26 | import os 27 | import json 28 | from PIL import Image 29 | import cv2 30 | import numpy as np 31 | 32 | # Empirically tuned thresholds for a "good" image 33 | # Typical high‑quality crops have a contrast standard deviation 34 | # around 60–80 on the 0‑255 intensity scale. 35 | CONTRAST_THRESHOLD = 70 36 | 37 | # Laplacian variance for sharp images can easily exceed 200. 38 | CLARITY_THRESHOLD = 200 39 | 40 | # Noise variance from the blur residual often reaches the 41 | # thousands. Values under about 15000 typically correspond to 42 | # images that look relatively clean while larger values show 43 | # obvious grain. 44 | NOISE_THRESHOLD = 15000 45 | 46 | def _scale_score(value: float, threshold: float, reverse: bool = False) -> float: 47 | """Return a 0-100 score relative to the given threshold.""" 48 | ratio = value / threshold 49 | ratio = max(0.0, min(ratio, 1.0)) 50 | score = (1.0 - ratio) if reverse else ratio 51 | return score * 100 52 | 53 | def _rate_image(contrast: float, clarity: float, noise: float): 54 | """Return a textual rating and explanation for the given metrics.""" 55 | score = 0 56 | reasons = [] 57 | if contrast >= CONTRAST_THRESHOLD: 58 | score += 1 59 | else: 60 | reasons.append("low contrast") 61 | if clarity >= CLARITY_THRESHOLD: 62 | score += 1 63 | else: 64 | reasons.append("low clarity") 65 | if noise <= NOISE_THRESHOLD: 66 | score += 1 67 | else: 68 | reasons.append("high noise") 69 | 70 | rating_map = {3: "Excellent", 2: "Good", 1: "Fair", 0: "Poor"} 71 | rating = rating_map[score] 72 | if not reasons: 73 | reasons.append("meets all thresholds") 74 | return rating, ", ".join(reasons) 75 | 76 | def analyze_image(image_path): 77 | image = cv2.imread(image_path) 78 | 79 | # Contrast: Standard deviation of intensity 80 | contrast = float(np.std(image)) 81 | 82 | # Clarity: Variance of Laplacian 83 | gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) 84 | clarity = float(cv2.Laplacian(gray, cv2.CV_64F).var()) 85 | 86 | # Noise: Estimate via FFT residuals or pixel variance 87 | noise = float(np.var(cv2.GaussianBlur(gray, (3,3), 0) - gray)) 88 | 89 | # Placeholder: Aesthetic score stub 90 | aesthetic = 0.0 # Will replace with actual model output later 91 | 92 | contrast_pct = _scale_score(contrast, CONTRAST_THRESHOLD) 93 | clarity_pct = _scale_score(clarity, CLARITY_THRESHOLD) 94 | noise_pct = _scale_score(noise, NOISE_THRESHOLD, reverse=True) 95 | 96 | rating, reason = _rate_image(contrast, clarity, noise) 97 | 98 | return { 99 | "filename": os.path.basename(image_path), 100 | "contrast": contrast, 101 | "contrast_pct": contrast_pct, 102 | "clarity": clarity, 103 | "clarity_pct": clarity_pct, 104 | "noise": noise, 105 | "noise_pct": noise_pct, 106 | "aesthetic": aesthetic, 107 | "rating": rating, 108 | "reason": reason 109 | } 110 | 111 | def analyze_folder(folder_path, crops_only=True, progress_callback=None): 112 | """Analyze images in ``folder_path``. 113 | 114 | Parameters 115 | ---------- 116 | folder_path : str 117 | Directory containing images to analyze. 118 | crops_only : bool, optional 119 | If ``True`` only files whose names start with ``"cropped_"`` will be 120 | processed. When ``False`` all supported image files are analyzed. 121 | 122 | progress_callback : callable, optional 123 | Function called with the current index and total count after each image 124 | is processed. This can be used to update a progress indicator. 125 | 126 | Returns 127 | ------- 128 | list[dict] 129 | A list of metric dictionaries for each image. 130 | """ 131 | 132 | results = [] 133 | files = [ 134 | f 135 | for f in os.listdir(folder_path) 136 | if (not crops_only or f.lower().startswith("cropped_")) 137 | and f.lower().endswith((".png", ".jpg", ".jpeg", ".webp")) 138 | ] 139 | total = len(files) 140 | for idx, file in enumerate(files, 1): 141 | image_path = os.path.join(folder_path, file) 142 | result = analyze_image(image_path) 143 | results.append(result) 144 | if progress_callback: 145 | progress_callback(idx, total) 146 | return results 147 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PixelPruner 2 | PixelPruner is a user-friendly image cropping app for AI-generated art. It supports PNG, JPG, JPEG, and WEBP formats. Easily crop, preview, and manage images with interactive previews, thumbnail views, rotation tools, and customizable output folders. Streamline your workflow and achieve perfect crops every time with PixelPruner. 3 | 4 | ![image](https://github.com/theallyprompts/PixelPruner/assets/133992794/bf264a2a-1192-428b-9c73-7da4b17d7313) 5 | 6 | --- 7 | 8 | # Features 9 | - **Multi-Format Support**: Supports cropping images in PNG, JPG, JPEG, and WEBP formats. Crops are converted to PNG. 10 | 11 | - **Interactive Crop Previews**: Preview your crop selection in real-time with an interactive preview pane, before you make the crop. 12 | 13 | ![image](https://github.com/theallyprompts/PixelPruner/assets/133992794/5768c2f2-7573-4700-a79d-b38508ac9307) 14 | 15 | - **Thumbnail View of Crops**: View all your cropped images as thumbnails in a dedicated pane, making it easy to manage and review your work. 16 | 17 | ![image](https://github.com/theallyprompts/PixelPruner/assets/133992794/e96b3dbb-924e-41b6-bf9f-46d581f3fcde) 18 | 19 | - **Rotation Tools**: Easily rotate images to achieve the perfect orientation before cropping. 20 | 21 | ![image](https://github.com/theallyprompts/PixelPruner/assets/133992794/c1df184c-9aa5-4af5-bca9-c45cf40c24e4) 22 | 23 | - **Multi-Crops**: Make multiple crops from the same image. Multiple faces? No problem! 24 | 25 | ![image](https://github.com/theallyprompts/PixelPruner/assets/133992794/9dac7fdb-6bb8-4c46-a863-9506701b219a) 26 | 27 | - **Source Gallery View**: Cropping a lot of source images? View them in the gallery style Sources pane via `View > Sources Pane`. Click on one in this pane to load it for cropping! 28 | 29 | ![image](https://github.com/user-attachments/assets/96710251-6af6-46d8-9ece-b15540ba65cf) 30 | 31 | - **Custom Crop Sizes**: Choose from preset dimensions or enter your own width and height. 32 | 33 | - **Customizable Output Folder**: Choose a custom folder to save your cropped images. 34 | 35 | - **Default Directories**: Set default input and output folders from the welcome screen or via `Settings > Set Default Paths`. 36 | 37 | - **Zip Crops**: Quickly zip all cropped images into a single archive for easy sharing, storage, or upload to the Civitai.com on-site LoRA Trainer 38 | 39 | ![image](https://github.com/theallyprompts/PixelPruner/assets/133992794/2c02c817-80ce-4280-8eca-2e6a198425e4) 40 | 41 | - **Undo Crop Actions**: Made a mistake? Simply undo the last crop with the click of a button. 42 | 43 | - **Keyboard Shortcuts**: Navigate and manipulate images effortlessly with convenient WASD keyboard shortcuts. 44 | 45 | - **Flexible Analysis**: The PrunerIQ window includes a `Crops Only` checkbox so 46 | you can analyze either just the cropped images or all images in a folder. 47 | 48 | ### PrunerIQ Analysis 49 | 50 | The built-in *PrunerIQ* analysis, accessed from the `Tools` menu, examines your cropped images and scores them using: 51 | 52 | - **Contrast** – standard deviation of pixel intensities. Higher is better. 53 | - **Clarity** – variance of the Laplacian; larger values indicate sharper images. 54 | - **Noise** – difference between the image and a blurred copy. Lower numbers mean less noise. 55 | - **Aesthetic** – placeholder score for future updates! 56 | 57 | ![image](https://github.com/user-attachments/assets/f9d068f5-d1a9-48bb-9f9c-54cc12a2076b) 58 | 59 | Each metric is also normalised into a 0‑100 percentage shown in the analysis table 60 | (`Contrast (%)`, `Clarity (%)`, and `Noise (%)`). These give a quick visual cue 61 | of how close the measurement is to the recommended threshold values. Each crop 62 | receives a rating from **Poor** to **Excellent** based on the metrics. When 63 | viewing the analysis window you can sort, filter ranges, and delete any 64 | undesirable crops. 65 | 66 | --- 67 | 68 | ## Installation Guide - Prebuilt App 69 | 70 | Head to the **[Releases](https://github.com/theallyprompts/PixelPruner/releases)** and download the latest .exe version - pre-packaged with Python and ready to run (no installation required!) 71 | 72 | --- 73 | 74 | ## Installation Guide - Manual Install 75 | 76 | Follow these steps to install and run PixelPruner on your local machine. 77 | 78 | ### Prerequisites 79 | 80 | 1. **Python 3.x**: Make sure you have Python 3.x installed on your system. You can download it from the [official Python website](https://www.python.org/downloads/). 81 | 82 | 2. **Pillow**: This library is required for image processing. You can install it with the `pip` package manager. 83 | 84 | 3. **Packaging**: The packaging library is very useful for handling version numbers in Python projects. It allows you to reliably compare versions. 85 | 86 | ### Step-by-Step Installation 87 | 88 | 1. **Clone the Repository** 89 | 90 | Clone the PixelPruner repository from GitHub to your local machine using the following command: 91 | 92 | ```sh 93 | git clone https://github.com/theallyprompts/PixelPruner.git 94 | ``` 95 | 96 | 2. **Navigate to the cloned directory** 97 | 98 | 3. **Set Up a Virtual Environment (Optional but Recommended)** 99 | 100 | It is recommended to use a virtual environment to manage dependencies. Create and activate a virtual environment: 101 | 102 | ```sh 103 | python -m venv venv 104 | source venv/bin/activate # On Windows use `venv\Scripts\activate` 105 | ``` 106 | 107 | 4. **Install Dependencies** 108 | 109 | Install the required dependencies using pip: 110 | 111 | ```sh 112 | pip install pillow[webp] 113 | pip install tkinterdnd2 114 | pip install packaging 115 | ``` 116 | 117 | If you're on Linux, you might need to install tkinter separately: 118 | 119 | ```sh 120 | sudo apt-get install python3-tk 121 | ``` 122 | 123 | 5. **Running the Application** 124 | 125 | Run the PixelPruner application using the following command: 126 | 127 | ```sh 128 | python PixelPruner.py 129 | ``` 130 | 131 | --- 132 | 133 | ### Using PixelPruner 134 | 135 | **Select Folder**: After launching the app, you can either drag a set of images into the main pane, or go to `File > Set Input Folder`, to select a folder of input images. 136 | 137 | **Select Crop Dimensions**: From the dropdown in the top left, select the output dimensions for your crops. 138 | 139 | **Set an output directory** (optional): Click `File > Set Output Directory` to choose a folder to save your crops. If no directory is chosen, PixelPruner will place crops beside the original images. Note: if you've dragged images into PixelPruner, you must manually set an output directory. 140 | 141 | **Crop and Manage Images**: Use the interactive tools to crop, rotate, and manage your images. Cropped images can be previewed and saved to a custom output folder. 142 | 143 | **Analyze your Crops**: Check your crops for clarity (blurryness), Noise, and Contrast using new `PrunerIQ` from the `Tools` menu! 144 | 145 | **Keyboard Shortcuts**: Use keyboard shortcuts (W, S) to navigate through images and (A, D) to rotate them. Ctrl+Z will undo the last crop. 146 | 147 | --- 148 | 149 | ### Roadmap 150 | 151 | 1. ~~**Add a toggle for advance-on-crop**: Why on earth did I add an auto-advance on crop? What a huge mistake! The next update will add a toggle to choose whether to advance-on-crop or remain on the current image.~~ Completed in v1.1.0 152 | 153 | 2. ~~Input and Output Folder selection improvements - I want to be able to switch input directory mid cropping-session!~~ Completed in v1.2.0 154 | 155 | 3. ~~**More Options for Input** - Ability to drag a selection of images into the app for cropping, rather than select from a folder. That sounds useful.~~ Completed in v2.0.0 156 | 157 | 4. ~~**A Better Menu** - A proper file menu system.~~ Added in v2.0.0 158 | 159 | 5. ~~**User Settings** - The ability to save user preferences.~~ Added in v3.0.0 160 | 161 | 6. **Simple image editing** - brightness, contrast, sharpness, etc. I want to be able to do as much as possible as easily as possible, without having to photoshop anything prior to upload to Civitai. 162 | 163 | 7. **API Access to Civitai.com's LoRA Trainer**: Upload zipped crops directly into Civitai.com's on-site LoRA trainer. The API for this doesn't exist yet, but maybe if I ask nicely... 164 | 165 | --- 166 | 167 | ### Created By... 168 | 169 | **[TheAlly](https://civitai.com/user/theally)** - Vibe coding ftw! If you've enjoyed PixelPruner, consider [shooting me a tip](https://ko-fi.com/theallyprompts)! Thanks! 170 | -------------------------------------------------------------------------------- /PixelPruner.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import json 4 | import tkinter as tk 5 | from tkinter import filedialog, ttk, messagebox 6 | from tkinterdnd2 import TkinterDnD, DND_FILES 7 | import subprocess 8 | import zipfile 9 | from datetime import datetime 10 | import webbrowser 11 | import winsound 12 | import threading 13 | from packaging.version import parse 14 | 15 | # For Pillow >= 10 16 | try: 17 | from PIL import Image, ImageTk, __version__ as PILLOW_VERSION 18 | Resampling = Image.Resampling 19 | except AttributeError: 20 | # For older versions 21 | Resampling = Image 22 | 23 | def resource_path(relative_path): 24 | """ Get absolute path to resource, works for dev and for PyInstaller """ 25 | try: 26 | # PyInstaller creates a temp folder and stores path in _MEIPASS 27 | base_path = sys._MEIPASS 28 | except Exception: 29 | base_path = os.path.abspath(os.path.dirname(__file__)) 30 | 31 | return os.path.join(base_path, relative_path) 32 | 33 | def app_path(): 34 | """Return the directory containing the running script or executable.""" 35 | if getattr(sys, 'frozen', False): 36 | return os.path.dirname(sys.executable) 37 | return os.path.abspath(os.path.dirname(__file__)) 38 | 39 | class ToolTip: 40 | def __init__(self, widget, text): 41 | self.widget = widget 42 | self.text = text 43 | self.tip_window = None 44 | self.widget.bind("", self.show_tooltip) 45 | self.widget.bind("", self.hide_tooltip) 46 | 47 | def show_tooltip(self, event): 48 | if self.tip_window or not self.text: 49 | return 50 | x, y, cx, cy = self.widget.bbox("insert") 51 | x = x + self.widget.winfo_rootx() + 25 52 | y = y + self.widget.winfo_rooty() + 25 53 | self.tip_window = tw = tk.Toplevel(self.widget) 54 | tw.wm_overrideredirect(True) 55 | tw.wm_geometry(f"+{x}+{y}") 56 | label = tk.Label(tw, text=self.text, justify=tk.LEFT, 57 | background="#ffffe0", relief=tk.SOLID, borderwidth=1, 58 | font=("tahoma", "8", "normal")) 59 | label.pack(ipadx=1) 60 | 61 | def hide_tooltip(self, event): 62 | tw = self.tip_window 63 | self.tip_window = None 64 | if tw: 65 | tw.destroy() 66 | 67 | class PixelPruner: 68 | def __init__(self, master): 69 | self.master = master 70 | self.master.title("PixelPruner") 71 | 72 | self.showing_popup = False # Flag to track if popup is already shown 73 | 74 | # Create the menu bar 75 | self.menu_bar = tk.Menu(master) 76 | master.config(menu=self.menu_bar) 77 | 78 | # Create the File menu 79 | self.file_menu = tk.Menu(self.menu_bar, tearoff=0) 80 | self.menu_bar.add_cascade(label="File", menu=self.file_menu) 81 | self.file_menu.add_command(label="Set Input Folder", command=self.select_input_folder) 82 | self.file_menu.add_command(label="Set Output Folder", command=self.select_output_folder) 83 | self.file_menu.add_command(label="Open Current Input Folder", command=self.open_input_folder) 84 | self.file_menu.add_command(label="Open Current Output Folder", command=self.open_output_folder) 85 | self.file_menu.add_separator() 86 | self.file_menu.add_command(label="Exit", command=master.quit) 87 | 88 | # Create the Edit menu 89 | self.edit_menu = tk.Menu(self.menu_bar, tearoff=0) 90 | self.menu_bar.add_cascade(label="Edit", menu=self.edit_menu) 91 | self.edit_menu.add_command(label="Undo Last Crop", command=self.undo_last_crop) 92 | self.edit_menu.add_command(label="Zip Crops", command=self.zip_crops) 93 | 94 | # Create the View menu 95 | self.view_menu = tk.Menu(self.menu_bar, tearoff=0) 96 | self.menu_bar.add_cascade(label="View", menu=self.view_menu) 97 | self.view_menu.add_command(label="Preview Pane", command=lambda: self.toggle_pane("preview")) 98 | self.view_menu.add_command(label="Crops Pane", command=lambda: self.toggle_pane("crops")) 99 | self.view_menu.add_command(label="Sources Pane", command=lambda: self.toggle_pane("source")) 100 | 101 | # Create the Settings menu 102 | self.settings_menu = tk.Menu(self.menu_bar, tearoff=0) 103 | self.menu_bar.add_cascade(label="Settings", menu=self.settings_menu) 104 | self.auto_advance_var = tk.BooleanVar(value=False) 105 | self.crop_sound_var = tk.BooleanVar(value=False) 106 | self.show_welcome_var = tk.BooleanVar(value=True) 107 | self.safe_mode_var = tk.BooleanVar(value=False) 108 | self.default_input_folder = "" 109 | self.default_output_folder = "" 110 | self.settings_menu.add_checkbutton(label="Auto-advance", variable=self.auto_advance_var, command=self.save_settings) 111 | self.settings_menu.add_checkbutton(label="Crop Sound", variable=self.crop_sound_var, command=self.save_settings) 112 | self.settings_menu.add_command(label="Set Defaults", command=self.show_welcome_screen) 113 | 114 | # Create the Tools menu 115 | self.tools_menu = tk.Menu(self.menu_bar, tearoff=0) 116 | self.menu_bar.add_cascade(label="Tools", menu=self.tools_menu) 117 | self.tools_menu.add_command(label="PrunerIQ Analysis", command=self.launch_pruneriq) 118 | 119 | # Create the Help menu 120 | self.help_menu = tk.Menu(self.menu_bar, tearoff=0) 121 | self.menu_bar.add_cascade(label="Help", menu=self.help_menu) 122 | self.help_menu.add_command(label="About", command=self.show_about) 123 | 124 | control_frame = tk.Frame(master) 125 | control_frame.pack(fill=tk.X, side=tk.TOP) 126 | 127 | tk.Label(control_frame, text="Select crop size:").pack(side=tk.LEFT, padx=(10, 2)) 128 | 129 | self.size_var = tk.StringVar() 130 | self.custom_option = "Custom..." 131 | self.size_options = [ 132 | "512x512", 133 | "768x768", 134 | "1024x1024", 135 | "2048x2048", 136 | "512x768", 137 | "768x512", 138 | self.custom_option, 139 | ] 140 | self.size_dropdown = ttk.Combobox( 141 | control_frame, 142 | textvariable=self.size_var, 143 | state="readonly", 144 | values=self.size_options, 145 | ) 146 | self.size_dropdown.pack(side=tk.LEFT, padx=(2, 20)) 147 | self.size_dropdown.set("512x512") # Default size 148 | self.previous_size = "512x512" 149 | self.size_dropdown.bind("<>", self.on_size_selected) 150 | ToolTip(self.size_dropdown, "Choose the size of the crop area") 151 | 152 | self.prev_button = tk.Button(control_frame, text="< Prev", command=self.load_previous_image) 153 | self.prev_button.pack(side=tk.LEFT, padx=(10, 2)) 154 | ToolTip(self.prev_button, "Load the previous image (S)") 155 | 156 | self.next_button = tk.Button(control_frame, text="Next >", command=self.load_next_image) 157 | self.next_button.pack(side=tk.LEFT, padx=(10, 2)) 158 | ToolTip(self.next_button, "Load the next image (W)") 159 | 160 | # Load rotate left image 161 | try: 162 | self.rotate_left_image = tk.PhotoImage(file=resource_path("rotate_left.png")) 163 | except Exception as e: 164 | print(f"Error loading rotate_left.png: {e}") 165 | self.rotate_left_image = tk.PhotoImage() # Placeholder if load fails 166 | 167 | # Load rotate right image 168 | try: 169 | self.rotate_right_image = tk.PhotoImage(file=resource_path("rotate_right.png")) 170 | except Exception as e: 171 | print(f"Error loading rotate_right.png: {e}") 172 | self.rotate_right_image = tk.PhotoImage() # Placeholder if load fails 173 | 174 | self.rotate_left_button = tk.Button(control_frame, image=self.rotate_left_image, command=lambda: self.rotate_image(90)) 175 | self.rotate_left_button.pack(side=tk.LEFT, padx=(10, 2)) 176 | ToolTip(self.rotate_left_button, "Rotate image counterclockwise (A)") 177 | 178 | self.rotate_right_button = tk.Button(control_frame, image=self.rotate_right_image, command=lambda: self.rotate_image(-90)) 179 | self.rotate_right_button.pack(side=tk.LEFT, padx=(10, 2)) 180 | ToolTip(self.rotate_right_button, "Rotate image clockwise (D)") 181 | 182 | # Load delete image for the control frame 183 | try: 184 | self.delete_image = tk.PhotoImage(file=resource_path("delete_image.png")) 185 | except Exception as e: 186 | print(f"Error loading delete_image.png: {e}") 187 | self.delete_image = tk.PhotoImage() # Placeholder if load fails 188 | 189 | self.delete_button = tk.Button(control_frame, image=self.delete_image, command=self.delete_current_image) 190 | self.delete_button.pack(side=tk.LEFT, padx=(10, 2)) 191 | ToolTip(self.delete_button, "Delete the current image (Delete)") 192 | 193 | # Load folder icon for generic folder-related actions 194 | try: 195 | self.folder_icon = tk.PhotoImage(file=resource_path("folder.png")) 196 | except Exception as e: 197 | print(f"Error loading folder.png: {e}") 198 | self.folder_icon = tk.PhotoImage() 199 | 200 | # Load set input folder icon 201 | try: 202 | self.input_folder_icon = tk.PhotoImage(file=resource_path("input_folder.png")) 203 | except Exception as e: 204 | print(f"Error loading folder.png: {e}") 205 | self.input_folder_icon = tk.PhotoImage() 206 | 207 | # Load set output folder icon 208 | try: 209 | self.output_folder_icon = tk.PhotoImage(file=resource_path("output_folder.png")) 210 | except Exception as e: 211 | print(f"Error loading folder.png: {e}") 212 | self.output_folder_icon = tk.PhotoImage() 213 | 214 | # Load open output folder icon 215 | try: 216 | self.open_output_folder_icon = tk.PhotoImage(file=resource_path("open_folder.png")) 217 | except Exception as e: 218 | print(f"Error loading folder.png: {e}") 219 | self.open_output_folder_icon = tk.PhotoImage() 220 | 221 | # Set Input Folder button 222 | self.input_folder_button = tk.Button(control_frame, image=self.input_folder_icon, command=self.select_input_folder) 223 | self.input_folder_button.pack(side=tk.LEFT, padx=(10, 2)) 224 | ToolTip(self.input_folder_button, "Set the input folder") 225 | 226 | # Set Output Folder button 227 | self.output_folder_button = tk.Button(control_frame, image=self.output_folder_icon, command=self.select_output_folder) 228 | self.output_folder_button.pack(side=tk.LEFT, padx=(10, 2)) 229 | ToolTip(self.output_folder_button, "Set the output folder") 230 | 231 | # Open Output Folder button 232 | self.open_output_button = tk.Button(control_frame, image=self.open_output_folder_icon, command=self.open_output_folder) 233 | self.open_output_button.pack(side=tk.LEFT, padx=(10, 2)) 234 | ToolTip(self.open_output_button, "Open the current output folder") 235 | 236 | # Undo Last Crop button (text based) 237 | self.undo_button = tk.Button(control_frame, text="Undo", command=self.undo_last_crop) 238 | self.undo_button.pack(side=tk.LEFT, padx=(10, 2)) 239 | ToolTip(self.undo_button, "Undo the last crop (Ctrl+Z)") 240 | 241 | # Zip Crops button (text based) 242 | self.zip_button = tk.Button(control_frame, text="Zip", command=self.zip_crops) 243 | self.zip_button.pack(side=tk.LEFT, padx=(10, 2)) 244 | ToolTip(self.zip_button, "Zip current folder crops into an archive") 245 | 246 | # Launch PrunerIQ button (text based) 247 | self.pruneriq_button = tk.Button(control_frame, text="PrunerIQ", command=self.launch_pruneriq) 248 | self.pruneriq_button.pack(side=tk.LEFT, padx=(10, 2)) 249 | ToolTip(self.pruneriq_button, "Launch PrunerIQ analysis") 250 | 251 | self.image_counter_label = tk.Label(control_frame, text="Viewing 0 of 0") 252 | self.image_counter_label.pack(side=tk.RIGHT, padx=(10, 20)) 253 | 254 | self.main_frame = tk.Frame(master) 255 | self.main_frame.pack(fill=tk.BOTH, expand=True) 256 | 257 | self.canvas = tk.Canvas(self.main_frame, cursor="cross", bg="gray") 258 | self.canvas.pack(side=tk.LEFT, fill="both", expand=True) 259 | 260 | self.preview_canvas = tk.Canvas(self.main_frame, width=512, height=512, bg="gray") 261 | self.preview_canvas.pack_forget() # Hide preview pane initially 262 | 263 | # Create a frame for the crops pane with a scrollable canvas 264 | self.crops_frame = tk.Frame(self.main_frame) 265 | self.crops_canvas = tk.Canvas(self.crops_frame, bg="gray", width=512) # Set width to match preview pane 266 | self.crops_scrollbar = tk.Scrollbar(self.crops_frame, orient="vertical", command=self.crops_canvas.yview) 267 | self.crops_canvas.configure(yscrollcommand=self.crops_scrollbar.set) 268 | self.crops_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) 269 | self.crops_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) 270 | self.crops_frame.pack_forget() # Hide crops pane initially 271 | 272 | self.crops_canvas.bind("", self.bind_crops_mouse_wheel) 273 | self.crops_canvas.bind("", self.unbind_crops_mouse_wheel) 274 | 275 | # Create a frame for the source images pane with a scrollable canvas 276 | self.source_frame = tk.Frame(self.main_frame) 277 | self.source_canvas = tk.Canvas(self.source_frame, bg="gray", width=512) 278 | self.source_scrollbar = tk.Scrollbar(self.source_frame, orient="vertical", command=self.source_canvas.yview) 279 | self.source_canvas.configure(yscrollcommand=self.source_scrollbar.set) 280 | self.source_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) 281 | self.source_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) 282 | self.source_frame.pack_forget() # Hide source pane initially 283 | 284 | self.source_canvas.bind("", lambda e: self.source_canvas.bind_all("", self.on_source_mouse_wheel)) 285 | self.source_canvas.bind("", lambda e: self.source_canvas.unbind_all("")) 286 | 287 | self.status_bar = tk.Frame(master, bd=1, relief=tk.SUNKEN) 288 | self.status_bar.pack(side=tk.BOTTOM, fill=tk.X) 289 | self.status_label = tk.Label(self.status_bar, text="Welcome to PixelPruner - Version 3.1.0", anchor=tk.W) 290 | self.status_label.pack(side=tk.LEFT, padx=10) 291 | self.cropped_images_label = tk.Label(self.status_bar, text="Images Cropped: 0", anchor=tk.E) 292 | self.cropped_images_label.pack(side=tk.RIGHT, padx=10) 293 | 294 | self.folder_path = None 295 | self.images = [] 296 | self.image_index = 0 297 | self.current_image = None 298 | self.image_scale = 1 299 | self.rect = None 300 | self.image_offset_x = 0 301 | self.image_offset_y = 0 302 | self.output_folder = None 303 | self.original_size = (512, 512) 304 | self.current_size = (512, 512) 305 | self.crop_counter = 0 # Global counter for all crops 306 | self.cropped_images = [] # List to keep track of cropped images 307 | self.cropped_thumbnails = [] # List to keep track of cropped thumbnails 308 | self.source_thumbnails = [] # Thumbnails for source images 309 | self.preview_enabled = False # Preview pane toggle 310 | self.crops_enabled = False # Crop thumbnails pane toggle 311 | self.source_enabled = False # Source images pane toggle 312 | 313 | # Load delete image for the crops pane 314 | try: 315 | self.delete_crop_image = tk.PhotoImage(file=resource_path("delete_crop.png")) 316 | except Exception as e: 317 | print(f"Error loading delete_crop.png: {e}") 318 | self.delete_crop_image = tk.PhotoImage() # Placeholder if load fails 319 | 320 | # Enable drag-and-drop for the main frame 321 | self.main_frame.drop_target_register(DND_FILES) 322 | self.main_frame.dnd_bind('<>', self.on_drop) 323 | 324 | # Update the window and canvas sizes before displaying the first image 325 | self.master.update_idletasks() 326 | self.canvas.update_idletasks() 327 | 328 | self.canvas.bind("", self.on_mouse_move) 329 | self.canvas.bind("", self.on_button_press) 330 | self.canvas.bind("", self.on_button_release) 331 | self.canvas.bind("", self.on_mouse_wheel) 332 | 333 | # Track window state to resize images when the window is maximized or restored 334 | self.last_state = self.master.state() 335 | self.master.bind("", self.on_window_resize) 336 | 337 | self.master.minsize(1300, 750) # Set a minimum size for the window 338 | 339 | # Bind keyboard shortcuts 340 | self.master.bind("w", lambda event: self.load_next_image()) 341 | self.master.bind("s", lambda event: self.load_previous_image()) 342 | self.master.bind("a", lambda event: self.rotate_image(90)) 343 | self.master.bind("d", lambda event: self.rotate_image(-90)) 344 | self.master.bind("", lambda event: self.undo_last_crop()) 345 | self.master.bind("", lambda event: self.delete_current_image()) 346 | 347 | # Set the focus to the master window 348 | master.focus_set() 349 | 350 | # Load user settings and apply them 351 | self.load_settings() 352 | self.update_safe_mode_ui() 353 | self.master.protocol("WM_DELETE_WINDOW", self.on_close) 354 | 355 | # Center the window on the screen 356 | self.center_window() 357 | 358 | if self.show_welcome_var.get(): 359 | self.show_welcome_screen() 360 | 361 | def center_window(self): 362 | self.master.update_idletasks() 363 | window_width = self.master.winfo_width() 364 | window_height = self.master.winfo_height() 365 | screen_width = self.master.winfo_screenwidth() 366 | screen_height = self.master.winfo_screenheight() 367 | x = (screen_width // 2) - (window_width // 2) 368 | y = (screen_height // 2) - (window_height // 2) 369 | self.master.geometry(f'{window_width}x{window_height}+{x}+{y}') 370 | 371 | def update_status(self, message): 372 | self.status_label.config(text=message) 373 | 374 | def update_image_counter(self): 375 | self.image_counter_label.config(text=f"Viewing {self.image_index + 1} of {len(self.images)}") 376 | 377 | def update_cropped_images_counter(self): 378 | self.cropped_images_label.config(text=f"Images Cropped: {len(self.cropped_images)}") 379 | 380 | def show_info_message(self, title, message): 381 | if not self.showing_popup: 382 | self.showing_popup = True 383 | messagebox.showinfo(title, message) 384 | self.showing_popup = False 385 | 386 | def update_safe_mode_ui(self): 387 | """Enable or disable delete-related widgets based on safe mode.""" 388 | state = tk.DISABLED if self.safe_mode_var.get() else tk.NORMAL 389 | self.delete_button.config(state=state) 390 | self.undo_button.config(state=state) 391 | # Update Edit menu entry for Undo Last Crop 392 | try: 393 | self.edit_menu.entryconfig("Undo Last Crop", state=state) 394 | except Exception: 395 | pass 396 | 397 | def on_window_resize(self, event): 398 | """Redraw the image when the window is resized or state changes.""" 399 | if event.widget is self.master and self.current_image: 400 | # Only redraw when the zoom state or canvas size changes 401 | state = self.master.state() 402 | if state != self.last_state or event.width != self.canvas.winfo_width() or event.height != self.canvas.winfo_height(): 403 | self.last_state = state 404 | self.display_image() 405 | 406 | def load_image(self): 407 | if not self.folder_path and not self.images: 408 | self.show_info_message("Information", "Please select an input folder.") 409 | return 410 | if 0 <= self.image_index < len(self.images): 411 | try: 412 | image_path = self.images[self.image_index] 413 | self.current_image = Image.open(image_path) 414 | except IOError: 415 | messagebox.showerror("Error", f"Failed to load image: {image_path}") 416 | return 417 | 418 | self.display_image() 419 | 420 | def display_image(self): 421 | aspect_ratio = self.current_image.width / self.current_image.height 422 | 423 | # Determine available canvas space. When the window is maximized 424 | # ("zoomed" state on Windows), use the full canvas size. Otherwise 425 | # limit the image to the default 800x600 viewing area. 426 | is_zoomed = self.master.state() == "zoomed" 427 | max_w = self.canvas.winfo_width() if is_zoomed else 800 428 | max_h = self.canvas.winfo_height() if is_zoomed else 600 429 | 430 | self.scaled_width = min(self.current_image.width, max_w) 431 | self.scaled_height = int(self.scaled_width / aspect_ratio) 432 | if self.scaled_height > max_h: 433 | self.scaled_height = min(self.current_image.height, max_h) 434 | self.scaled_width = int(self.scaled_height * aspect_ratio) 435 | 436 | resampling_filter = Resampling.LANCZOS 437 | 438 | self.tkimage = ImageTk.PhotoImage(self.current_image.resize((self.scaled_width, self.scaled_height), resampling_filter)) 439 | 440 | # Center the image within the canvas 441 | self.center_image_on_canvas() 442 | 443 | self.canvas.delete("all") 444 | self.canvas.create_image(self.image_offset_x, self.image_offset_y, anchor="nw", image=self.tkimage) 445 | self.image_scale = self.current_image.width / self.scaled_width 446 | size = tuple(map(int, self.size_var.get().split('x'))) 447 | self.original_size = size 448 | self.current_size = size 449 | scaled_size = (int(size[0] / self.image_scale), int(size[1] / self.image_scale)) # Scale crop box to match displayed image 450 | self.rect = self.canvas.create_rectangle(self.image_offset_x, self.image_offset_y, self.image_offset_x + scaled_size[0], self.image_offset_y + scaled_size[1], outline='red') 451 | self.update_crop_box_size() 452 | self.update_image_counter() 453 | 454 | def center_image_on_canvas(self): 455 | canvas_width = self.canvas.winfo_width() 456 | canvas_height = self.canvas.winfo_height() 457 | self.image_offset_x = (canvas_width - self.scaled_width) // 2 458 | self.image_offset_y = (canvas_height - self.scaled_height) // 2 459 | 460 | def rotate_image(self, angle): 461 | if not self.folder_path and not self.images: 462 | self.show_info_message("Information", "Please set an Input Folder from the File Menu!") 463 | return 464 | if self.current_image: 465 | self.current_image = self.current_image.rotate(angle, expand=True) 466 | self.display_image() 467 | self.update_status(f"Image rotated by {angle} degrees") 468 | 469 | def on_size_selected(self, event=None): 470 | selection = self.size_var.get() 471 | if selection == self.custom_option: 472 | # restore previous size while dialog is open 473 | self.size_var.set(self.previous_size) 474 | self.open_custom_size_dialog() 475 | else: 476 | self.previous_size = selection 477 | self.update_crop_box_size() 478 | 479 | def open_custom_size_dialog(self): 480 | dialog = tk.Toplevel(self.master) 481 | dialog.title("Custom Size") 482 | dialog.resizable(False, False) 483 | dialog.transient(self.master) 484 | dialog.grab_set() 485 | 486 | width_var = tk.StringVar(value=str(self.current_size[0])) 487 | height_var = tk.StringVar(value=str(self.current_size[1])) 488 | 489 | tk.Label(dialog, text="Width:").grid(row=0, column=0, padx=10, pady=(10, 5)) 490 | width_entry = tk.Entry(dialog, textvariable=width_var, width=10) 491 | width_entry.grid(row=0, column=1, padx=10, pady=(10, 5)) 492 | 493 | tk.Label(dialog, text="Height:").grid(row=1, column=0, padx=10, pady=5) 494 | height_entry = tk.Entry(dialog, textvariable=height_var, width=10) 495 | height_entry.grid(row=1, column=1, padx=10, pady=5) 496 | 497 | def apply(): 498 | try: 499 | w = int(width_var.get()) 500 | h = int(height_var.get()) 501 | if w <= 0 or h <= 0: 502 | raise ValueError 503 | except ValueError: 504 | messagebox.showerror("Invalid Input", "Please enter positive integers for width and height.") 505 | return 506 | value = f"{w}x{h}" 507 | values = list(self.size_dropdown["values"]) 508 | if value not in values: 509 | values.insert(-1, value) 510 | self.size_dropdown["values"] = values 511 | self.size_var.set(value) 512 | self.previous_size = value 513 | self.update_crop_box_size() 514 | dialog.destroy() 515 | 516 | def cancel(): 517 | self.size_var.set(self.previous_size) 518 | dialog.destroy() 519 | 520 | btn_frame = tk.Frame(dialog) 521 | btn_frame.grid(row=2, column=0, columnspan=2, pady=(5, 10)) 522 | tk.Button(btn_frame, text="OK", command=apply).pack(side=tk.LEFT, padx=5) 523 | tk.Button(btn_frame, text="Cancel", command=cancel).pack(side=tk.LEFT, padx=5) 524 | 525 | dialog.update_idletasks() 526 | w = dialog.winfo_width() 527 | h = dialog.winfo_height() 528 | sw = dialog.winfo_screenwidth() 529 | sh = dialog.winfo_screenheight() 530 | dialog.geometry(f"{w}x{h}+{sw//2 - w//2}+{sh//2 - h//2}") 531 | 532 | def update_crop_box_size(self, event=None): 533 | if self.rect: 534 | self.canvas.delete(self.rect) # Remove existing rectangle before creating a new one 535 | size = tuple(map(int, self.size_var.get().split('x'))) 536 | self.original_size = size 537 | self.current_size = size 538 | scaled_size = (int(size[0] / self.image_scale), int(size[1] / self.image_scale)) 539 | if scaled_size[0] > self.scaled_width: 540 | scaled_size = (self.scaled_width, self.scaled_width) 541 | if scaled_size[1] > self.scaled_height: 542 | scaled_size = (self.scaled_height, self.scaled_height) 543 | self.rect = self.canvas.create_rectangle(self.image_offset_x, self.image_offset_y, self.image_offset_x + scaled_size[0], self.image_offset_y + scaled_size[1], outline='red') 544 | 545 | def on_mouse_move(self, event): 546 | if self.rect: 547 | size = self.current_size 548 | scaled_size = (int(size[0] / self.image_scale), int(size[1] / self.image_scale)) 549 | if scaled_size[0] > self.scaled_width: 550 | scaled_size = (self.scaled_width, self.scaled_width) 551 | if scaled_size[1] > self.scaled_height: 552 | scaled_size = (self.scaled_height, self.scaled_height) 553 | x1, y1 = max(self.image_offset_x, min(event.x, self.image_offset_x + self.scaled_width - scaled_size[0])), max(self.image_offset_y, min(event.y, self.image_offset_y + self.scaled_height - scaled_size[1])) 554 | x2, y2 = x1 + scaled_size[0], y1 + scaled_size[1] 555 | self.canvas.coords(self.rect, x1, y1, x2, y2) 556 | 557 | if self.preview_enabled: 558 | self.update_preview(x1, y1, x2, y2) 559 | 560 | def update_preview(self, x1, y1, x2, y2): 561 | if self.current_image: 562 | real_x1, real_y1 = (x1 - self.image_offset_x) * self.image_scale, (y1 - self.image_offset_y) * self.image_scale 563 | real_x2, real_y2 = (x2 - self.image_offset_x) * self.image_scale, (y2 - self.image_offset_y) * self.image_scale 564 | 565 | if real_x1 > real_x2: 566 | real_x1, real_x2 = real_x2, real_x1 567 | if real_y1 > real_y2: 568 | real_y1, real_y2 = real_y2, real_y1 569 | 570 | cropped = self.current_image.crop((real_x1, real_y1, real_x2, real_y2)) 571 | 572 | # Parse desired output size 573 | target_width, target_height = self.original_size 574 | 575 | # Set max size for preview pane display 576 | preview_max = 512 577 | aspect_ratio = target_width / target_height 578 | 579 | if aspect_ratio >= 1: 580 | preview_w = preview_max 581 | preview_h = int(preview_w / aspect_ratio) 582 | else: 583 | preview_h = preview_max 584 | preview_w = int(preview_h * aspect_ratio) 585 | 586 | cropped = cropped.resize((preview_w, preview_h), Resampling.LANCZOS) 587 | self.tkpreview = ImageTk.PhotoImage(cropped) 588 | 589 | self.preview_canvas.delete("all") 590 | canvas_w = self.preview_canvas.winfo_width() 591 | canvas_h = self.preview_canvas.winfo_height() 592 | offset_x = (canvas_w - preview_w) // 2 593 | offset_y = (canvas_h - preview_h) // 2 594 | 595 | self.preview_canvas.create_image(offset_x, offset_y, anchor="nw", image=self.tkpreview) 596 | 597 | 598 | def on_button_press(self, event): 599 | self.start_x = event.x 600 | self.start_y = event.y 601 | 602 | def on_button_release(self, event): 603 | self.perform_crop() 604 | 605 | def on_mouse_wheel(self, event): 606 | if self.rect: 607 | increment = 50 if event.delta > 0 else -50 608 | new_width = self.current_size[0] + increment 609 | new_height = self.current_size[1] + increment 610 | if new_width < 50 or new_height < 50: 611 | return # Prevent the rectangle from becoming too small 612 | scaled_width = int(new_width / self.image_scale) 613 | scaled_height = int(new_height / self.image_scale) 614 | if scaled_width > self.scaled_width or scaled_height > self.scaled_height: 615 | return # Prevent the rectangle from extending beyond the image boundaries 616 | x1, y1, x2, y2 = self.canvas.coords(self.rect) 617 | new_x2 = x1 + scaled_width 618 | new_y2 = y1 + scaled_height 619 | self.canvas.coords(self.rect, x1, y1, new_x2, new_y2) 620 | self.current_size = (new_width, new_height) 621 | 622 | def bind_crops_mouse_wheel(self, event): 623 | self.crops_canvas.bind_all("", self.on_crops_mouse_wheel) 624 | 625 | def unbind_crops_mouse_wheel(self, event): 626 | self.crops_canvas.unbind_all("") 627 | 628 | def on_crops_mouse_wheel(self, event): 629 | self.crops_canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") 630 | 631 | def on_source_mouse_wheel(self, event): 632 | self.source_canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") 633 | 634 | def crop_image(self, x1, y1, x2, y2): 635 | real_x1, real_y1 = (x1 - self.image_offset_x) * self.image_scale, (y1 - self.image_offset_y) * self.image_scale 636 | real_x2, real_y2 = (x2 - self.image_offset_x) * self.image_scale, (y2 - self.image_offset_y) * self.image_scale 637 | if real_x1 > real_x2: 638 | real_x1, real_x2 = real_x2, real_x1 639 | if real_y1 > real_y2: 640 | real_y1, real_y2 = real_y2, real_y1 641 | size = self.current_size 642 | cropped = self.current_image.crop((real_x1, real_y1, real_x2, real_y2)) 643 | cropped = cropped.resize(self.original_size) 644 | 645 | # Prompt for output folder if not set and images are dragged in 646 | if not self.output_folder and not self.folder_path: 647 | self.select_output_folder() 648 | if not self.output_folder: 649 | self.show_info_message("Information", "Please set an Output Folder from the File Menu!") 650 | return 651 | 652 | # Use input folder as output folder if set and output folder is not set 653 | if not self.output_folder: 654 | self.output_folder = self.folder_path 655 | 656 | # Generate a unique filename by appending a global counter 657 | self.crop_counter += 1 658 | image_path = self.images[self.image_index] 659 | base_filename = os.path.basename(image_path) 660 | filename, ext = os.path.splitext(base_filename) 661 | cropped_filename = f"cropped_{self.crop_counter}_{filename}.png" 662 | cropped_filepath = os.path.join(self.output_folder, cropped_filename) 663 | cropped.save(cropped_filepath, "PNG") 664 | self.cropped_images.insert(0, cropped_filepath) # Insert at the beginning of the list 665 | self.update_cropped_images_counter() 666 | 667 | # Play crop sound if enabled 668 | if self.crop_sound_var.get(): 669 | winsound.PlaySound(resource_path("click.wav"), winsound.SND_FILENAME | winsound.SND_ASYNC) 670 | 671 | # Create thumbnail and update crops canvas 672 | self.update_crops_canvas(cropped, cropped_filepath) 673 | cropped_filepath = os.path.join(self.output_folder, cropped_filename) 674 | normalized_path = os.path.normpath(cropped_filepath) 675 | self.update_status(f"Cropped image saved as {normalized_path}") 676 | 677 | def update_crops_canvas(self, cropped, filepath): 678 | cropped.thumbnail((256, 256)) # Create larger thumbnail 679 | tkthumbnail = ImageTk.PhotoImage(cropped) 680 | self.cropped_thumbnails.insert(0, (tkthumbnail, filepath)) # Insert at the beginning of the list 681 | 682 | self.refresh_crops_canvas() 683 | 684 | def refresh_crops_canvas(self): 685 | """Rebuild the thumbnail grid in the crops canvas.""" 686 | self.crops_canvas.delete("all") # Clear previous thumbnails 687 | cols = 2 # Number of columns in the grid 688 | spacing = 10 # Space between thumbnails 689 | 690 | for index, (thumbnail, path) in enumerate(self.cropped_thumbnails): 691 | row, col = divmod(index, cols) 692 | x, y = col * (256 + spacing), row * (256 + spacing) 693 | self.crops_canvas.create_image(x, y, anchor="nw", image=thumbnail) 694 | 695 | # Add delete icon at the bottom left corner of each thumbnail 696 | delete_icon_x = x + 5 697 | delete_icon_y = y + 256 - 25 698 | delete_icon = self.crops_canvas.create_image(delete_icon_x, delete_icon_y, anchor="nw", image=self.delete_crop_image) 699 | self.crops_canvas.tag_bind(delete_icon, "", lambda event, path=path: self.delete_crop(path)) 700 | 701 | # Update scroll region to accommodate all thumbnails 702 | self.crops_canvas.config(scrollregion=self.crops_canvas.bbox("all")) 703 | 704 | def delete_crop(self, filepath): 705 | if self.safe_mode_var.get(): 706 | self.show_info_message("Safe Mode", "Safe Mode is enabled. Delete operations are disabled.") 707 | return 708 | if messagebox.askyesno("Delete Crop", "Are you sure you want to delete this crop?"): 709 | if os.path.exists(filepath): 710 | os.remove(filepath) 711 | self.cropped_images = [img for img in self.cropped_images if img != filepath] 712 | self.cropped_thumbnails = [(thumb, path) for thumb, path in self.cropped_thumbnails if path != filepath] 713 | filepath_forward_slash = filepath.replace("\\", "/") 714 | self.refresh_crops_canvas() 715 | self.update_cropped_images_counter() 716 | self.update_status(f"Deleted crop {filepath_forward_slash}") 717 | 718 | def update_crops_canvas_layout(self): 719 | """Legacy wrapper kept for backward compatibility.""" 720 | self.refresh_crops_canvas() 721 | 722 | def update_source_canvas(self, progress_callback=None): 723 | """Generate thumbnails for source images and rebuild the gallery.""" 724 | self.source_thumbnails = [] 725 | total = len(self.images) 726 | for idx, path in enumerate(self.images, start=1): 727 | try: 728 | img = Image.open(path) 729 | img.thumbnail((128, 128)) 730 | tkthumb = ImageTk.PhotoImage(img) 731 | self.source_thumbnails.append((tkthumb, path)) 732 | except Exception: 733 | continue 734 | if progress_callback: 735 | progress_callback(idx, total) 736 | self.refresh_source_canvas() 737 | 738 | def refresh_source_canvas(self): 739 | self.source_canvas.delete("all") 740 | self.source_canvas.update_idletasks() 741 | canvas_width = self.source_canvas.winfo_width() 742 | cols = 3 743 | spacing = 10 744 | thumb_w = 128 745 | total_width = cols * thumb_w + (cols - 1) * spacing 746 | offset_x = max(0, (canvas_width - total_width) // 2) 747 | for index, (thumb, path) in enumerate(self.source_thumbnails): 748 | row, col = divmod(index, cols) 749 | x = offset_x + col * (thumb_w + spacing) 750 | y = row * (128 + spacing) 751 | img_id = self.source_canvas.create_image(x, y, anchor="nw", image=thumb) 752 | self.source_canvas.tag_bind(img_id, "", lambda e, p=path: self.load_image_from_gallery(p)) 753 | self.source_canvas.config(scrollregion=self.source_canvas.bbox("all")) 754 | 755 | def load_image_from_gallery(self, path): 756 | if path in self.images: 757 | self.image_index = self.images.index(path) 758 | self.load_image() 759 | 760 | def perform_crop(self): 761 | if not self.folder_path and not self.images: 762 | self.show_info_message("Information", "Please set an Input Folder from the File Menu!") 763 | return 764 | x1, y1, x2, y2 = self.canvas.coords(self.rect) 765 | self.crop_image(x1, y1, x2, y2) 766 | if self.auto_advance_var.get(): 767 | self.load_next_image() 768 | 769 | def load_next_image(self): 770 | if not self.folder_path and not self.images: 771 | self.show_info_message("Information", "Please set an Input Folder from the File Menu!") 772 | return 773 | 774 | if not self.images: 775 | self.show_info_message("Information", "No images loaded.") 776 | return 777 | 778 | self.image_index = (self.image_index + 1) % len(self.images) 779 | self.load_image() 780 | 781 | def load_previous_image(self): 782 | if not self.folder_path and not self.images: 783 | self.show_info_message("Information", "Please set an Input Folder from the File Menu!") 784 | return 785 | 786 | if not self.images: 787 | self.show_info_message("Information", "No images loaded.") 788 | return 789 | 790 | self.image_index = (self.image_index - 1) % len(self.images) 791 | self.load_image() 792 | 793 | def select_input_folder(self): 794 | selected_folder = filedialog.askdirectory(title="Select Input Folder", initialdir=self.default_input_folder or None) 795 | if selected_folder: 796 | self.folder_path = selected_folder 797 | self.load_images_from_folder() 798 | else: 799 | messagebox.showwarning("Warning", "No input folder selected!") 800 | 801 | def select_output_folder(self): 802 | selected_folder = filedialog.askdirectory(title="Select Custom Output Folder", initialdir=self.default_output_folder or None) 803 | if selected_folder: 804 | self.output_folder = selected_folder 805 | else: 806 | messagebox.showwarning("Warning", "No output folder selected! Crops can't be saved until one is set!") 807 | 808 | def open_input_folder(self): 809 | if not self.folder_path: 810 | self.show_info_message("Information", "Please set an Input Folder from the File Menu!") 811 | return 812 | if os.path.isdir(self.folder_path): 813 | subprocess.Popen(['explorer', self.folder_path.replace("/", "\\")]) 814 | 815 | def open_output_folder(self): 816 | folder_to_open = self.output_folder or self.folder_path 817 | if not folder_to_open: 818 | self.show_info_message("Information", "Please set an Output Folder from the File Menu!") 819 | return 820 | if os.path.isdir(folder_to_open): 821 | subprocess.Popen(['explorer', folder_to_open.replace("/", "\\")]) 822 | 823 | def zip_crops(self): 824 | if not self.cropped_images: 825 | messagebox.showinfo("Info", "No cropped images to zip!") 826 | return 827 | 828 | num_images = len(self.cropped_images) 829 | current_date = datetime.now().strftime("%Y%m%d") 830 | zip_filename = os.path.join(self.output_folder or self.folder_path, f"{num_images}_{current_date}.zip").replace("\\", "/") 831 | 832 | with zipfile.ZipFile(zip_filename, 'w') as zipf: 833 | for file in self.cropped_images: 834 | zipf.write(file, os.path.basename(file)) 835 | 836 | messagebox.showinfo("Info", f"Cropped images have been zipped into {zip_filename}") 837 | self.update_status(f"{num_images} cropped images zipped into {zip_filename}") 838 | 839 | def toggle_pane(self, pane): 840 | if not self.folder_path and not self.images: 841 | self.show_info_message("Information", "Please set an Input Folder from the File Menu!") 842 | return 843 | 844 | if pane == "preview": 845 | self.preview_enabled = not self.preview_enabled 846 | self.crops_enabled = False 847 | self.source_enabled = False 848 | elif pane == "crops": 849 | self.crops_enabled = not self.crops_enabled 850 | self.preview_enabled = False 851 | self.source_enabled = False 852 | elif pane == "source": 853 | self.source_enabled = not self.source_enabled 854 | self.preview_enabled = False 855 | self.crops_enabled = False 856 | 857 | if self.preview_enabled: 858 | self.master.geometry(f"1550x800") # Adjusted size to fit the larger preview pane 859 | self.preview_canvas.pack(side=tk.RIGHT, padx=5, pady=5, fill=tk.BOTH, expand=False) 860 | self.crops_frame.pack_forget() 861 | self.source_frame.pack_forget() 862 | elif self.crops_enabled: 863 | self.master.geometry(f"1550x800") # Adjusted size to fit the crops pane 864 | self.crops_frame.pack(side=tk.RIGHT, padx=5, pady=5, fill=tk.BOTH, expand=False) 865 | self.preview_canvas.pack_forget() 866 | self.source_frame.pack_forget() 867 | elif self.source_enabled: 868 | self.master.geometry(f"1550x800") 869 | self.source_frame.pack(side=tk.RIGHT, padx=5, pady=5, fill=tk.BOTH, expand=False) 870 | self.preview_canvas.pack_forget() 871 | self.crops_frame.pack_forget() 872 | else: 873 | self.preview_canvas.pack_forget() 874 | self.crops_frame.pack_forget() 875 | self.source_frame.pack_forget() 876 | self.master.geometry(f"1300x750") # Resize the window back to normal 877 | 878 | # Workaround to ensure the main image is centered after toggling panes 879 | self.master.after(100, self.load_next_image) 880 | self.master.after(200, self.load_previous_image) 881 | 882 | def undo_last_crop(self): 883 | if self.safe_mode_var.get(): 884 | self.show_info_message("Safe Mode", "Safe Mode is enabled. Delete operations are disabled.") 885 | return 886 | if not self.cropped_images: 887 | messagebox.showinfo("Info", "No cropped images to undo!") 888 | return 889 | 890 | last_cropped_image = self.cropped_images.pop(0) # Remove the first item in the list 891 | if os.path.exists(last_cropped_image): 892 | os.remove(last_cropped_image) 893 | 894 | self.cropped_thumbnails.pop(0) 895 | self.refresh_crops_canvas() 896 | self.update_cropped_images_counter() 897 | self.update_status("Last crop undone") 898 | 899 | def load_images_from_folder(self): 900 | if not self.folder_path: 901 | messagebox.showwarning("Warning", f"No input folder set! Got: {self.folder_path}") 902 | return 903 | 904 | self.images = [os.path.join(self.folder_path, img) for img in os.listdir(self.folder_path) if img.lower().endswith(('.png', '.jpg', '.jpeg', '.webp'))] 905 | if not self.images: 906 | messagebox.showerror("Error", "No valid images found in the selected directory.") 907 | return 908 | 909 | progress = None 910 | progress_var = None 911 | if len(self.images) > 30: 912 | progress = tk.Toplevel(self.master) 913 | progress.title("Loading") 914 | tk.Label(progress, text="Loading images...").pack(padx=20, pady=(10, 5)) 915 | progress_var = tk.StringVar(value="") 916 | tk.Label(progress, textvariable=progress_var).pack(padx=20, pady=(0, 10)) 917 | progress.update_idletasks() 918 | pw = progress.winfo_width() 919 | ph = progress.winfo_height() 920 | sw = progress.winfo_screenwidth() 921 | sh = progress.winfo_screenheight() 922 | progress.geometry(f"{pw}x{ph}+{sw//2 - pw//2}+{sh//2 - ph//2}") 923 | progress.transient(self.master) 924 | progress.grab_set() 925 | 926 | def cb(idx, total): 927 | progress_var.set(f"{idx} of {total}") 928 | progress.update_idletasks() 929 | else: 930 | def cb(idx, total): 931 | pass 932 | 933 | self.image_index = 0 934 | self.load_image() 935 | self.update_image_counter() 936 | self.update_status(f"Loaded {len(self.images)} images from {self.folder_path}") 937 | self.update_source_canvas(cb) 938 | if progress: 939 | progress.destroy() 940 | 941 | def load_images_from_list(self, file_list): 942 | self.images = [file for file in file_list if file.lower().endswith(('.png', '.jpg', '.jpeg', '.webp'))] 943 | if not self.images: 944 | messagebox.showerror("Error", "No valid images found in the dropped files.") 945 | return 946 | 947 | progress = None 948 | progress_var = None 949 | if len(self.images) > 30: 950 | progress = tk.Toplevel(self.master) 951 | progress.title("Loading") 952 | tk.Label(progress, text="Loading images...").pack(padx=20, pady=(10, 5)) 953 | progress_var = tk.StringVar(value="") 954 | tk.Label(progress, textvariable=progress_var).pack(padx=20, pady=(0, 10)) 955 | progress.update_idletasks() 956 | pw = progress.winfo_width() 957 | ph = progress.winfo_height() 958 | sw = progress.winfo_screenwidth() 959 | sh = progress.winfo_screenheight() 960 | progress.geometry(f"{pw}x{ph}+{sw//2 - pw//2}+{sh//2 - ph//2}") 961 | progress.transient(self.master) 962 | progress.grab_set() 963 | 964 | def cb(idx, total): 965 | progress_var.set(f"{idx} of {total}") 966 | progress.update_idletasks() 967 | else: 968 | def cb(idx, total): 969 | pass 970 | 971 | self.image_index = 0 972 | self.load_image() 973 | self.update_image_counter() 974 | self.update_status(f"Loaded {len(self.images)} images from dropped files") 975 | self.update_source_canvas(cb) 976 | if progress: 977 | progress.destroy() 978 | 979 | def on_drop(self, event): 980 | file_list = self.master.tk.splitlist(event.data) 981 | self.load_images_from_list(file_list) 982 | 983 | def view_image(self, image_path): 984 | """Display an image on the canvas without altering the loaded list.""" 985 | if not image_path or not os.path.exists(image_path): 986 | return 987 | try: 988 | self.current_image = Image.open(image_path) 989 | self.image_scale = 1 990 | self.display_image() 991 | self.update_status(f"Viewing {os.path.basename(image_path)}") 992 | except Exception as exc: 993 | messagebox.showerror("Error", f"Failed to load {image_path}: {exc}") 994 | 995 | def toggle_auto_advance(self): 996 | self.auto_advance_var.set(not self.auto_advance_var.get()) 997 | 998 | def delete_current_image(self): 999 | if self.safe_mode_var.get(): 1000 | self.show_info_message("Safe Mode", "Safe Mode is enabled. Delete operations are disabled.") 1001 | return 1002 | if not self.folder_path and not self.images: 1003 | self.show_info_message("Information", "Please set an Input Folder from the File Menu!") 1004 | return 1005 | if messagebox.askyesno("Delete Image", "Are you sure you want to delete this image?"): 1006 | image_path = self.images.pop(self.image_index) 1007 | if os.path.exists(image_path): 1008 | os.remove(image_path) 1009 | if self.image_index >= len(self.images): 1010 | self.image_index = 0 1011 | self.load_image() 1012 | self.update_image_counter() 1013 | # Remove from source thumbnails and refresh gallery 1014 | self.source_thumbnails = [(t, p) for t, p in self.source_thumbnails if p != image_path] 1015 | self.refresh_source_canvas() 1016 | image_path_forward_slash = image_path.replace("\\", "/") 1017 | self.update_status(f"Deleted image {image_path_forward_slash}") 1018 | 1019 | def show_about(self): 1020 | about_window = tk.Toplevel(self.master) 1021 | about_window.title("About") 1022 | about_window.geometry("400x200") 1023 | about_window.resizable(False, False) 1024 | 1025 | # Center the about window 1026 | about_window.update_idletasks() 1027 | window_width = about_window.winfo_width() 1028 | window_height = about_window.winfo_height() 1029 | screen_width = about_window.winfo_screenwidth() 1030 | screen_height = about_window.winfo_screenheight() 1031 | x = (screen_width // 2) - (window_width // 2) 1032 | y = (screen_height // 2) - (window_height // 2) 1033 | about_window.geometry(f'{window_width}x{window_height}+{x}+{y}') 1034 | 1035 | about_text = ( 1036 | "Version 3.1.0 - 6/11/2025\n\n" 1037 | "Developed by TheAlly and GPT4o\n\n" 1038 | "About: Prepare your LoRA training data with ease! " 1039 | "Check out the GitHub Repo for the full feature list.\n\n" 1040 | ) 1041 | 1042 | label = tk.Label(about_window, text=about_text, justify=tk.LEFT, padx=10, pady=10, wraplength=380) 1043 | label.pack(fill="both", expand=True) 1044 | 1045 | link_frame = tk.Frame(about_window) 1046 | link_frame.pack(fill="both", expand=True) 1047 | 1048 | profile_label = tk.Label(link_frame, text="GitHub:", justify=tk.LEFT, padx=10) 1049 | profile_label.pack(side=tk.LEFT) 1050 | link = tk.Label(link_frame, text="https://github.com/theallyprompts/PixelPruner", fg="blue", cursor="hand2", padx=10) 1051 | link.pack(side=tk.LEFT) 1052 | link.bind("", lambda e: webbrowser.open_new("https://github.com/theallyprompts/PixelPruner")) 1053 | 1054 | def show_welcome_screen(self): 1055 | welcome = tk.Toplevel(self.master) 1056 | welcome.title("Welcome to PixelPruner") 1057 | welcome.geometry("520x380") 1058 | welcome.resizable(False, False) 1059 | welcome.protocol("WM_DELETE_WINDOW", lambda: None) 1060 | welcome.overrideredirect(True) 1061 | welcome.grab_set() 1062 | 1063 | # Center it 1064 | welcome.update_idletasks() 1065 | w = welcome.winfo_width() 1066 | h = welcome.winfo_height() 1067 | sw = welcome.winfo_screenwidth() 1068 | sh = welcome.winfo_screenheight() 1069 | welcome.geometry(f"{w}x{h}+{(sw - w) // 2}+{(sh - h) // 2}") 1070 | 1071 | frame = tk.Frame(welcome, padx=20, pady=20) 1072 | frame.pack(expand=True, fill="both") 1073 | 1074 | tk.Label(frame, text="Welcome to PixelPruner!", font=("Helvetica", 14, "bold")).pack(pady=(0, 10)) 1075 | tk.Label(frame, text="Crop, curate, and conquer your datasets.\n", justify="center").pack() 1076 | 1077 | link = tk.Label(frame, text="View on GitHub", fg="blue", cursor="hand2") 1078 | link.pack() 1079 | link.bind("", lambda e: webbrowser.open_new("https://github.com/theallyprompts/PixelPruner")) 1080 | 1081 | donate = tk.Label(frame, text="Buy me a coffee ☕", fg="blue", cursor="hand2") 1082 | donate.pack(pady=(5, 15)) 1083 | donate.bind("", lambda e: webbrowser.open_new("https://ko-fi.com/theallyprompts")) 1084 | 1085 | # Input Folder 1086 | tk.Label(frame, text="Default Input Folder:").pack(anchor="w") 1087 | input_row = tk.Frame(frame) 1088 | input_row.pack(fill="x") 1089 | input_entry = tk.Entry(input_row, width=50) 1090 | input_entry.insert(0, self.settings.get("default_input_folder", "")) 1091 | input_entry.pack(side="left", fill="x", expand=True) 1092 | tk.Button(input_row, text="Select Folder", command=lambda: self._browse_folder(input_entry)).pack(side="right") 1093 | 1094 | # Output Folder 1095 | tk.Label(frame, text="Default Output Folder:").pack(anchor="w", pady=(10, 0)) 1096 | output_row = tk.Frame(frame) 1097 | output_row.pack(fill="x") 1098 | output_entry = tk.Entry(output_row, width=50) 1099 | output_entry.insert(0, self.settings.get("default_output_folder", "")) 1100 | output_entry.pack(side="left", fill="x", expand=True) 1101 | tk.Button(output_row, text="Select Folder", command=lambda: self._browse_folder(output_entry)).pack(side="right") 1102 | 1103 | # Footer controls 1104 | footer = tk.Frame(frame) 1105 | footer.pack(fill="x", pady=(20, 0), padx=10) 1106 | 1107 | left_footer = tk.Frame(footer) 1108 | left_footer.pack(side="left") 1109 | 1110 | show_var = tk.BooleanVar(value=self.settings.get("show_welcome", True)) 1111 | tk.Checkbutton(left_footer, text="Show Welcome at startup", variable=show_var).pack(side="left") 1112 | 1113 | safe_var = tk.BooleanVar(value=self.settings.get("safe_mode", False)) 1114 | tk.Checkbutton(left_footer, text="Safe Mode - Delete actions disabled", variable=safe_var).pack(side="left", padx=(10, 0)) 1115 | 1116 | def save_and_close(): 1117 | self.settings["show_welcome"] = show_var.get() 1118 | self.show_welcome_var.set(show_var.get()) 1119 | self.settings["default_input_folder"] = input_entry.get() 1120 | self.settings["default_output_folder"] = output_entry.get() 1121 | self.settings["safe_mode"] = safe_var.get() 1122 | self.safe_mode_var.set(safe_var.get()) 1123 | self.default_input_folder = input_entry.get() 1124 | self.default_output_folder = output_entry.get() 1125 | self.folder_path = self.default_input_folder 1126 | self.output_folder = self.default_output_folder 1127 | self.save_settings() 1128 | self.folder_path = input_entry.get() 1129 | self.output_folder = output_entry.get() 1130 | if self.folder_path: 1131 | self.load_images_from_folder() 1132 | self.update_status("Ready.") 1133 | self.update_safe_mode_ui() 1134 | welcome.destroy() 1135 | 1136 | button_frame = tk.Frame(frame) 1137 | button_frame.pack(fill="x", pady=(15, 10)) 1138 | 1139 | tk.Button(button_frame, text="Start Using PixelPruner", command=save_and_close).pack(side="top", anchor="center") 1140 | 1141 | def _browse_folder(self, entry_widget): 1142 | path = filedialog.askdirectory(title="Select Folder") 1143 | if path: 1144 | entry_widget.delete(0, tk.END) 1145 | entry_widget.insert(0, path) 1146 | 1147 | def load_settings(self): 1148 | #Load settings from usersettings.json, creating it with defaults if needed. 1149 | self.settings_path = os.path.join(app_path(), "usersettings.json") 1150 | defaults = { 1151 | "auto_advance": False, 1152 | "crop_sound": False, 1153 | "show_welcome": True, 1154 | "safe_mode": False, 1155 | "default_input_folder": "", 1156 | "default_output_folder": "", 1157 | } 1158 | if not os.path.exists(self.settings_path): 1159 | self.settings = defaults 1160 | self.save_settings() 1161 | return 1162 | try: 1163 | with open(self.settings_path, "r") as f: 1164 | self.settings = json.load(f) 1165 | except Exception: 1166 | self.settings = defaults 1167 | self.auto_advance_var.set(self.settings.get("auto_advance", False)) 1168 | self.crop_sound_var.set(self.settings.get("crop_sound", False)) 1169 | self.show_welcome_var.set(self.settings.get("show_welcome", True)) 1170 | self.safe_mode_var.set(self.settings.get("safe_mode", False)) 1171 | self.default_input_folder = self.settings.get("default_input_folder", "") 1172 | self.default_output_folder = self.settings.get("default_output_folder", "") 1173 | 1174 | if self.default_input_folder and os.path.isdir(self.default_input_folder): 1175 | self.folder_path = self.default_input_folder 1176 | if self.default_output_folder and os.path.isdir(self.default_output_folder): 1177 | self.output_folder = self.default_output_folder 1178 | 1179 | def save_settings(self): 1180 | # Update self.settings dict from current UI values 1181 | self.settings["auto_advance"] = self.auto_advance_var.get() 1182 | self.settings["crop_sound"] = self.crop_sound_var.get() 1183 | self.settings["show_welcome"] = self.show_welcome_var.get() 1184 | self.settings["safe_mode"] = self.safe_mode_var.get() 1185 | self.settings["default_input_folder"] = self.default_input_folder 1186 | self.settings["default_output_folder"] = self.default_output_folder 1187 | 1188 | try: 1189 | with open(os.path.join(app_path(), "usersettings.json"), "w") as f: 1190 | json.dump(self.settings, f, indent=4) 1191 | except Exception as e: 1192 | print(f"Failed to save settings: {e}") 1193 | 1194 | def launch_pruneriq(self): 1195 | from pruneriq import analyze_folder 1196 | if not self.output_folder: 1197 | self.show_info_message("Information", "Please set or create an Output Folder first!") 1198 | return 1199 | 1200 | folder = self.output_folder 1201 | 1202 | loading = tk.Toplevel(self.master) 1203 | loading.title("Please Wait") 1204 | tk.Label(loading, text="Analyzing images...").pack(padx=20, pady=20) 1205 | loading.update() 1206 | 1207 | results = [] 1208 | 1209 | def run_analysis(): 1210 | nonlocal results 1211 | results = analyze_folder(folder, True) 1212 | self.master.after(0, finish) 1213 | 1214 | def finish(): 1215 | loading.destroy() 1216 | self.show_analysis_results(results, folder) 1217 | 1218 | threading.Thread(target=run_analysis, daemon=True).start() 1219 | 1220 | 1221 | def show_analysis_results(self, results, folder_path): 1222 | from pruneriq import analyze_folder 1223 | if not results: 1224 | self.show_info_message( 1225 | "Analysis", 1226 | "No valid images were found in the selected output folder.", 1227 | ) 1228 | return 1229 | 1230 | window = tk.Toplevel(self.master) 1231 | window.title("PrunerIQ - Dataset Analysis") 1232 | window.geometry("900x500") 1233 | 1234 | window.update_idletasks() 1235 | window_width = window.winfo_width() 1236 | window_height = window.winfo_height() 1237 | screen_width = window.winfo_screenwidth() 1238 | screen_height = window.winfo_screenheight() 1239 | x = (screen_width // 2) - (window_width // 2) 1240 | y = (screen_height // 2) - (window_height // 2) 1241 | window.geometry(f"{window_width}x{window_height}+{x}+{y}") 1242 | 1243 | current_folder = folder_path 1244 | path_var = tk.StringVar(value=current_folder) 1245 | 1246 | path_frame = tk.Frame(window) 1247 | path_frame.pack(fill=tk.X, padx=5, pady=5) 1248 | tk.Label(path_frame, text="Analyzing Images in:").pack(side=tk.LEFT) 1249 | tk.Label(path_frame, textvariable=path_var, anchor="w").pack( 1250 | side=tk.LEFT, fill=tk.X, expand=True 1251 | ) 1252 | 1253 | crops_only_var = tk.BooleanVar(value=True) 1254 | tk.Checkbutton( 1255 | path_frame, 1256 | text="Crops Only", 1257 | variable=crops_only_var, 1258 | ).pack(side=tk.RIGHT, padx=5) 1259 | 1260 | def run_analysis(path): 1261 | nonlocal all_results, current_folder 1262 | current_folder = path 1263 | path_var.set(path) 1264 | 1265 | progress = tk.Toplevel(window) 1266 | progress.title("Analyzing") 1267 | tk.Label(progress, text="Analyzing images...").pack(padx=20, pady=(10, 5)) 1268 | progress_var = tk.StringVar(value="") 1269 | tk.Label(progress, textvariable=progress_var).pack(padx=20, pady=(0, 10)) 1270 | 1271 | progress.update_idletasks() 1272 | pw = progress.winfo_width() 1273 | ph = progress.winfo_height() 1274 | sx = progress.winfo_screenwidth() 1275 | sy = progress.winfo_screenheight() 1276 | progress.geometry(f"{pw}x{ph}+{sx//2 - pw//2}+{sy//2 - ph//2}") 1277 | progress.transient(window) 1278 | progress.grab_set() 1279 | 1280 | def progress_callback(idx, total): 1281 | window.after(0, lambda: progress_var.set(f"{idx} of {total}")) 1282 | 1283 | def worker(): 1284 | res = analyze_folder(path, crops_only_var.get(), progress_callback) 1285 | window.after(0, lambda: finish(res)) 1286 | 1287 | def finish(res): 1288 | progress.destroy() 1289 | nonlocal all_results 1290 | all_results = res 1291 | populate_tree(all_results) 1292 | update_summary() 1293 | 1294 | threading.Thread(target=worker, daemon=True).start() 1295 | 1296 | def change_folder(): 1297 | path = filedialog.askdirectory(title="Select Folder") 1298 | if path: 1299 | run_analysis(path) 1300 | 1301 | def manual_reanalyze(): 1302 | run_analysis(current_folder) 1303 | 1304 | def open_current_folder(): 1305 | if os.path.isdir(current_folder): 1306 | subprocess.Popen([ 1307 | "explorer", 1308 | current_folder.replace("/", "\\"), 1309 | ]) 1310 | 1311 | tk.Button(path_frame, text="Change Folder", command=change_folder).pack( 1312 | side=tk.RIGHT, padx=5 1313 | ) 1314 | tk.Button(path_frame, text="Open Folder", command=open_current_folder).pack( 1315 | side=tk.RIGHT, padx=5 1316 | ) 1317 | tk.Button(path_frame, text="Re-analyze", command=manual_reanalyze).pack( 1318 | side=tk.RIGHT, padx=5 1319 | ) 1320 | 1321 | columns = ( 1322 | "filename", 1323 | "contrast", 1324 | "clarity", 1325 | "noise", 1326 | "rating", 1327 | ) 1328 | tree_frame = tk.Frame(window) 1329 | tree_frame.pack(fill=tk.BOTH, expand=True) 1330 | tree_scrollbar = tk.Scrollbar(tree_frame, orient="vertical") 1331 | tree = ttk.Treeview( 1332 | tree_frame, 1333 | columns=columns, 1334 | show="headings", 1335 | yscrollcommand=tree_scrollbar.set, 1336 | ) 1337 | tree_scrollbar.config(command=tree.yview) 1338 | tree_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) 1339 | tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) 1340 | 1341 | all_results = results 1342 | explanations = {} 1343 | 1344 | def sort_tree(col, reverse): 1345 | data = [(tree.set(k, col), k) for k in tree.get_children("")] 1346 | if col in ("filename", "rating"): 1347 | data.sort(reverse=reverse) 1348 | else: 1349 | data.sort(key=lambda t: float(t[0]), reverse=reverse) 1350 | for index, (val, k) in enumerate(data): 1351 | tree.move(k, "", index) 1352 | tree.heading(col, command=lambda: sort_tree(col, not reverse)) 1353 | 1354 | col_widths = { 1355 | "filename": 200, 1356 | "contrast": 110, 1357 | "clarity": 110, 1358 | "noise": 110, 1359 | "rating": 80, 1360 | } 1361 | 1362 | heading_names = { 1363 | "contrast": "Contrast (%)", 1364 | "clarity": "Clarity (%)", 1365 | "noise": "Noise (%)", 1366 | } 1367 | 1368 | for col in columns: 1369 | text = heading_names.get(col, col.title()) 1370 | tree.heading(col, text=text, command=lambda c=col: sort_tree(c, False)) 1371 | anchor = "w" if col == "filename" else "center" 1372 | width = col_widths.get(col, 100) 1373 | tree.column(col, anchor=anchor, width=width, stretch=False) 1374 | 1375 | def populate_tree(items): 1376 | tree.delete(*tree.get_children()) 1377 | explanations.clear() 1378 | for result in items: 1379 | item = tree.insert( 1380 | "", 1381 | "end", 1382 | values=( 1383 | result["filename"], 1384 | f"{result['contrast']:.2f} ({result['contrast_pct']:.0f}%)", 1385 | f"{result['clarity']:.2f} ({result['clarity_pct']:.0f}%)", 1386 | f"{result['noise']:.2f} ({result['noise_pct']:.0f}%)", 1387 | result["rating"], 1388 | ), 1389 | ) 1390 | explanations[item] = result.get("reason", "") 1391 | 1392 | populate_tree(all_results) 1393 | 1394 | filter_frame = tk.Frame(window) 1395 | filter_frame.pack(fill=tk.X, padx=5, pady=5) 1396 | 1397 | entries = {} 1398 | metrics = ["contrast", "clarity", "noise"] 1399 | for i, metric in enumerate(metrics): 1400 | tk.Label(filter_frame, text=f"{metric.title()} Min").grid(row=0, column=i*2, sticky="e") 1401 | e_min = tk.Entry(filter_frame, width=6) 1402 | e_min.grid(row=0, column=i*2+1, sticky="w") 1403 | tk.Label(filter_frame, text=f"Max").grid(row=1, column=i*2, sticky="e") 1404 | e_max = tk.Entry(filter_frame, width=6) 1405 | e_max.grid(row=1, column=i*2+1, sticky="w") 1406 | entries[metric] = (e_min, e_max) 1407 | 1408 | tk.Label(filter_frame, text="Rating").grid(row=0, column=6, sticky="e") 1409 | rating_var = tk.StringVar(value="All") 1410 | rating_box = ttk.Combobox(filter_frame, textvariable=rating_var, state="readonly", 1411 | values=["All", "Poor", "Fair", "Good", "Excellent"]) 1412 | rating_box.grid(row=0, column=7, sticky="w") 1413 | 1414 | info_label = tk.Label(window, text="", anchor="w") 1415 | info_label.pack(fill=tk.X, padx=5) 1416 | 1417 | def apply_filter(): 1418 | filtered = [] 1419 | for r in all_results: 1420 | passes = True 1421 | for metric in metrics: 1422 | min_val = entries[metric][0].get() 1423 | max_val = entries[metric][1].get() 1424 | value = r[metric] 1425 | if min_val: 1426 | try: 1427 | if value < float(min_val): 1428 | passes = False 1429 | break 1430 | except ValueError: 1431 | pass 1432 | if max_val: 1433 | try: 1434 | if value > float(max_val): 1435 | passes = False 1436 | break 1437 | except ValueError: 1438 | pass 1439 | if rating_var.get() != "All" and r["rating"] != rating_var.get(): 1440 | passes = False 1441 | if passes: 1442 | filtered.append(r) 1443 | populate_tree(filtered) 1444 | 1445 | def reset_filter(): 1446 | for metric in metrics: 1447 | entries[metric][0].delete(0, tk.END) 1448 | entries[metric][1].delete(0, tk.END) 1449 | rating_var.set("All") 1450 | populate_tree(all_results) 1451 | 1452 | tk.Button(filter_frame, text="Apply Filter", command=apply_filter).grid(row=0, column=8, padx=5) 1453 | tk.Button(filter_frame, text="Reset", command=reset_filter).grid(row=1, column=8, padx=5) 1454 | 1455 | def on_select(event): 1456 | selected = tree.selection() 1457 | if selected: 1458 | info_label.config(text=explanations.get(selected[0], "")) 1459 | 1460 | tree.bind("<>", on_select) 1461 | 1462 | def delete_selected(): 1463 | if self.safe_mode_var.get(): 1464 | self.show_info_message( 1465 | "Safe Mode", 1466 | "Safe Mode is enabled. Delete operations are disabled.", 1467 | ) 1468 | return 1469 | for item in tree.selection(): 1470 | filename = tree.set(item, "filename") 1471 | path = os.path.join(current_folder, filename) 1472 | if os.path.exists(path): 1473 | os.remove(path) 1474 | tree.delete(item) 1475 | 1476 | def on_double_click(event): 1477 | item = tree.focus() 1478 | if item: 1479 | filename = tree.set(item, "filename") 1480 | path = os.path.join(current_folder, filename) 1481 | self.view_image(path) 1482 | 1483 | tree.bind("", on_double_click) 1484 | 1485 | button_frame = tk.Frame(window) 1486 | button_frame.pack(fill=tk.X, pady=5) 1487 | tk.Button(button_frame, text="Delete Selected", command=delete_selected).pack( 1488 | side=tk.RIGHT, padx=5 1489 | ) 1490 | 1491 | summary_label = tk.Label( 1492 | window, font=("Helvetica", 10), anchor="w", justify="left" 1493 | ) 1494 | summary_label.pack(padx=10, pady=10, anchor="w") 1495 | 1496 | def update_summary(): 1497 | if not all_results: 1498 | summary = "Images: 0" 1499 | else: 1500 | avg_contrast = sum(r["contrast"] for r in all_results) / len(all_results) 1501 | avg_clarity = sum(r["clarity"] for r in all_results) / len(all_results) 1502 | avg_noise = sum(r["noise"] for r in all_results) / len(all_results) 1503 | avg_contrast_pct = sum(r["contrast_pct"] for r in all_results) / len(all_results) 1504 | avg_clarity_pct = sum(r["clarity_pct"] for r in all_results) / len(all_results) 1505 | avg_noise_pct = sum(r["noise_pct"] for r in all_results) / len(all_results) 1506 | summary = ( 1507 | f"Images: {len(all_results)}\n" 1508 | f"Avg Contrast: {avg_contrast:.2f} ({avg_contrast_pct:.0f}%) " 1509 | f"Avg Clarity: {avg_clarity:.2f} ({avg_clarity_pct:.0f}%) " 1510 | f"Avg Noise: {avg_noise:.2f} ({avg_noise_pct:.0f}%)" 1511 | ) 1512 | summary_label.config(text=summary) 1513 | 1514 | update_summary() 1515 | 1516 | 1517 | def on_close(self): 1518 | """Handle application close.""" 1519 | self.save_settings() 1520 | self.master.destroy() 1521 | 1522 | def main(): 1523 | root = TkinterDnD.Tk() 1524 | app = PixelPruner(root) 1525 | root.mainloop() 1526 | 1527 | if __name__ == "__main__": 1528 | main() 1529 | --------------------------------------------------------------------------------