├── requirements.txt ├── .gitattributes ├── screenshot01.png ├── screenshot02.png ├── screenshot03.png ├── LICENSE ├── FEATURES.md ├── .gitignore ├── README.md └── sprite_toolz.py /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy 2 | pillow 3 | PyQt6 4 | imageio -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /screenshot01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/non-npc/Sprite-Toolz/HEAD/screenshot01.png -------------------------------------------------------------------------------- /screenshot02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/non-npc/Sprite-Toolz/HEAD/screenshot02.png -------------------------------------------------------------------------------- /screenshot03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/non-npc/Sprite-Toolz/HEAD/screenshot03.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 non-npc 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 | -------------------------------------------------------------------------------- /FEATURES.md: -------------------------------------------------------------------------------- 1 | # Sprite Toolz Features 2 | 3 | ## 1. Advanced Frame Selection and Export 4 | - **Multiple selection modes:** 5 | - Regular click for single frame 6 | - Shift+Click for entire rows 7 | - Ctrl+Click for entire columns 8 | - Ctrl+Shift+Click for custom frame selection in any order 9 | - **Export options:** 10 | - Sprite strips (horizontal arrangement) 11 | - Individual frame files 12 | - Animated GIF/APNG with customizable frame timing 13 | 14 | ## 2. Batch Processing Capabilities 15 | - Process multiple sprite sheets at once 16 | - Support for recursive subfolder processing 17 | - **Multiple export formats per batch:** 18 | - Individual frames 19 | - Row strips 20 | - Animated GIFs 21 | - Animated PNGs (APNG) 22 | - Maintains folder structure in output 23 | 24 | ## 3. Smart Cell Size Management 25 | - **Two modes for defining cell size:** 26 | - Manual width/height input 27 | - Automatic calculation based on row/column count 28 | - Dynamic grid overlay with customizable color 29 | - Padding system with live preview and permanent application 30 | 31 | ## 4. Interactive Canvas Features 32 | - Checkered background for transparency visualization 33 | - Zoom functionality (1x to 6x) with scroll support 34 | - Real-time grid overlay 35 | - Visual selection highlighting 36 | - Center-aligned sprite sheet display 37 | 38 | ## 5. Row/Column Manipulation Tools 39 | - Duplicate, delete, or add blank rows/columns 40 | - Export individual rows or columns 41 | - Frame-by-frame operations (duplicate, delete, export) 42 | - Maintains transparency in all operations 43 | - Supports undo through selection clearing -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.txt 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sprite Toolz 2 | 3 | A powerful sprite sheet manipulation tool built with Python.\ 4 | **[Sprite Toolz](https://github.com/non-npc/Sprite-Toolz)** provides a comprehensive set of features for working with sprite sheets, including frame manipulation, batch processing, and animation export. 5 | 6 | Read the [Features List](FEATURES.md) for a highlight of features or keep reading: 7 | 8 | ![Sprite Toolz - Main options Screenshot](screenshot01.png) 9 | ![Sprite Toolz - Manipulation options Screenshot](screenshot02.png) 10 | ![Sprite Toolz - Batch options Screenshot](screenshot03.png) 11 | 12 | ## Features 13 | 14 | ### Basic Operations 15 | - **File Operations** 16 | - Load sprite sheets (supports PNG, JPG, BMP, GIF) 17 | - Export selections as GIF or APNG animations 18 | 19 | - **Cell Size Configuration** 20 | - Two methods for setting cell size: 21 | - Direct input: Set width and height manually 22 | - Grid-based: Specify number of rows and columns 23 | - Toggle between input methods 24 | - Auto-calculation of complementary values 25 | - Add padding to cells with live preview 26 | - Apply padding permanently to resize cells 27 | 28 | - **Grid Display** 29 | - Toggle grid visibility 30 | - Customizable grid color 31 | - Semi-transparent grid overlay 32 | 33 | - **Zoom Controls** 34 | - Multiple zoom levels (1x, 2x, 4x, 6x) 35 | - Zoom in/out with centered view 36 | - Reset zoom to default 37 | - Automatic scrollbars when zoomed 38 | 39 | ### Sprite Sheet Manipulation 40 | - **Row Operations** 41 | - Duplicate rows 42 | - Delete rows 43 | - Add blank rows before/after 44 | - Export rows as separate files 45 | 46 | - **Column Operations** 47 | - Duplicate columns 48 | - Delete columns 49 | - Add blank columns before/after 50 | - Export columns as separate files 51 | 52 | - **Frame Operations** 53 | - Duplicate individual frames 54 | - Delete frames 55 | - Export single frames 56 | 57 | - **Selection Tools** 58 | - Select individual frames (Click) 59 | - Select entire rows (Shift + Click) 60 | - Select entire columns (Ctrl + Click) 61 | - Select multiple frames by dragging 62 | - Custom frame selection (Ctrl + Shift + Click) 63 | - Select frames in any order from any row/column 64 | - Click selected frame again to remove from selection 65 | - Export selected frames as strip or animation 66 | - Export selections as PNG/GIF/APNG animations 67 | 68 | ### Batch Processing 69 | - **Input Options** 70 | - Process entire folders of sprite sheets 71 | - Optional subfolder processing 72 | - Supports multiple image formats 73 | 74 | - **Batch Operations** 75 | - Set cell dimensions for all files 76 | - Apply padding to all sprites 77 | - Export options: 78 | - Individual frames as PNG 79 | - Rows as sprite strips 80 | - Rows as GIF animations 81 | - Rows as APNG animations 82 | 83 | - **Output Organization** 84 | - Creates organized output in "processed" folder 85 | - Maintains folder structure when processing subfolders 86 | - Clear progress tracking and status updates 87 | 88 | ## Usage Tips 89 | - Use Shift+Click to select entire rows 90 | - Use Ctrl+Click to select entire columns 91 | - Click and drag to select multiple frames 92 | - Preview padding changes before applying them 93 | - Use the batch processing feature for multiple files 94 | - Export animations in either GIF or APNG format 95 | - Customize grid color for better visibility 96 | 97 | ## Interface 98 | - Three main tabs for organized access to features: 99 | 1. **Basic**: Core operations and display settings 100 | 2. **Manipulate**: Row, column, and frame manipulation tools 101 | 3. **Batch**: Bulk processing operations 102 | - Transparent sprite support with checkered background 103 | - Real-time status updates 104 | - Progress tracking for batch operations 105 | 106 | ## Requirements 107 | - Python 3.x 108 | - PyQt6 109 | - Pillow (PIL) 110 | - NumPy 111 | - imageio 112 | 113 | ## Installation 114 | 1. Ensure Python 3.x is installed 115 | 2. Install required packages: 116 | ```bash 117 | pip install PyQt6 Pillow numpy imageio 118 | ``` 119 | 3. Run the application: 120 | ```bash 121 | python sprite_toolz.py 122 | ``` 123 | 124 | 125 | ## Output Formats 126 | - **PNG**: Individual frames and sprite strips 127 | - **GIF**: Animated sequences with customizable frame duration 128 | - **APNG**: (Animated PNG) High-quality animations with transparency support 129 | 130 | ## Notes 131 | - All operations preserve transparency 132 | - GIF exports include proper frame disposal for clean animations 133 | - APNG (Animated PNG) exports maintain full color depth and alpha channel 134 | - Batch processing creates organized subfolders for different export types 135 | 136 | Read the [Features List](FEATURES.md) for a highlight of features 137 | -------------------------------------------------------------------------------- /sprite_toolz.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sys 3 | import os 4 | import numpy as np 5 | from PIL import Image 6 | import imageio 7 | from PyQt6.QtWidgets import (QApplication, QMainWindow, QLabel, QScrollArea, 8 | QVBoxLayout, QHBoxLayout, QWidget, QPushButton, 9 | QFileDialog, QSpinBox, QCheckBox, QColorDialog, 10 | QGridLayout, QGroupBox, QSlider, QFrame, QSizePolicy, 11 | QMessageBox, QTabWidget, QRadioButton) 12 | from PyQt6.QtGui import QPixmap, QPainter, QPen, QColor, QImage, QCursor 13 | from PyQt6.QtCore import Qt, QRect, QSize, QPoint 14 | 15 | 16 | class SpriteCanvas(QLabel): 17 | def __init__(self, parent=None): 18 | super().__init__(parent) 19 | self.spritesheet = None 20 | self.sprite_image = None 21 | self.original_image = None # Store the original image without padding 22 | self.cell_width = 32 23 | self.cell_height = 32 24 | self.show_grid = True 25 | self.grid_color = QColor(255, 0, 0, 128) # Semi-transparent red 26 | self.padding = 0 27 | self.padding_preview = 0 # New variable for padding preview 28 | self.setMinimumSize(800, 600) 29 | self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) 30 | self.setMouseTracking(True) # Enable mouse tracking for hover effects 31 | self.setAlignment(Qt.AlignmentFlag.AlignCenter) # Center the image 32 | 33 | # Zoom variables 34 | self.zoom_factor = 1.0 # Default zoom level (1x) 35 | self.zoom_levels = [1.0, 2.0, 4.0, 6.0] # Available zoom levels 36 | self.current_zoom_index = 0 # Start at 1x zoom 37 | 38 | # Create a checkered background for transparent sprites 39 | self.setStyleSheet("background-color: white;") 40 | 41 | # Selection variables 42 | self.selected_cells = [] 43 | self.selection_start = None 44 | self.selection_end = None 45 | self.is_selecting = False 46 | self.selected_row = -1 47 | self.selected_column = -1 48 | 49 | # Add custom frame selection variables 50 | self.custom_frame_selection = [] # List to store frames in selection order 51 | self.is_custom_selecting = False # Flag for custom selection mode 52 | 53 | def load_spritesheet(self, filename): 54 | self.sprite_image = Image.open(filename) 55 | self.original_image = self.sprite_image.copy() # Store original image 56 | self.spritesheet = np.array(self.sprite_image) 57 | self.update_pixmap() 58 | 59 | def update_pixmap(self): 60 | if self.sprite_image is None: 61 | return 62 | 63 | # Convert PIL Image to QPixmap with proper transparency 64 | if self.sprite_image.mode == 'RGBA': 65 | # For RGBA images (with transparency) 66 | data = self.sprite_image.tobytes("raw", "RGBA") 67 | qim = QImage(data, self.sprite_image.size[0], self.sprite_image.size[1], 68 | self.sprite_image.size[0] * 4, QImage.Format.Format_RGBA8888) 69 | elif self.sprite_image.mode == 'RGB': 70 | # For RGB images (no transparency) 71 | data = self.sprite_image.tobytes("raw", "RGB") 72 | qim = QImage(data, self.sprite_image.size[0], self.sprite_image.size[1], 73 | self.sprite_image.size[0] * 3, QImage.Format.Format_RGB888) 74 | else: 75 | # Convert other modes to RGBA for consistent handling 76 | converted_img = self.sprite_image.convert('RGBA') 77 | data = converted_img.tobytes("raw", "RGBA") 78 | qim = QImage(data, converted_img.size[0], converted_img.size[1], 79 | converted_img.size[0] * 4, QImage.Format.Format_RGBA8888) 80 | 81 | # Create pixmap and set it to the label 82 | pixmap = QPixmap.fromImage(qim) 83 | 84 | # Apply zoom if needed 85 | if self.zoom_factor != 1.0: 86 | zoom_width = int(pixmap.width() * self.zoom_factor) 87 | zoom_height = int(pixmap.height() * self.zoom_factor) 88 | pixmap = pixmap.scaled(zoom_width, zoom_height, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.FastTransformation) 89 | 90 | self.setPixmap(pixmap) 91 | 92 | # Set an appropriate size for the canvas 93 | self.resize(pixmap.size()) 94 | self.setMinimumSize(pixmap.size()) 95 | 96 | # Ensure update 97 | self.update() 98 | 99 | def mousePressEvent(self, event): 100 | if self.sprite_image is None: 101 | return 102 | 103 | if event.button() == Qt.MouseButton.LeftButton: 104 | x, y = event.position().x(), event.position().y() 105 | 106 | # Calculate offset for centering 107 | x_offset = max(0, (self.width() - int(self.sprite_image.size[0] * self.zoom_factor)) // 2) 108 | y_offset = max(0, (self.height() - int(self.sprite_image.size[1] * self.zoom_factor)) // 2) 109 | 110 | # Adjust for offset 111 | x = x - x_offset 112 | y = y - y_offset 113 | 114 | # Check if click is outside the image area 115 | if (x < 0 or y < 0 or 116 | x >= self.sprite_image.size[0] * self.zoom_factor or 117 | y >= self.sprite_image.size[1] * self.zoom_factor): 118 | return 119 | 120 | # Account for zoom factor when calculating cell coordinates 121 | cell_x = int(x // (self.cell_width * self.zoom_factor)) 122 | cell_y = int(y // (self.cell_height * self.zoom_factor)) 123 | 124 | # Get keyboard modifiers 125 | modifiers = QApplication.keyboardModifiers() 126 | 127 | # Check for Ctrl+Shift combination for custom frame selection 128 | if (modifiers & Qt.KeyboardModifier.ControlModifier and 129 | modifiers & Qt.KeyboardModifier.ShiftModifier): 130 | # Custom frame selection mode 131 | self.is_custom_selecting = True 132 | frame_pos = (cell_x, cell_y) 133 | if frame_pos not in self.custom_frame_selection: 134 | self.custom_frame_selection.append(frame_pos) 135 | else: 136 | self.custom_frame_selection.remove(frame_pos) 137 | self.selected_cells = self.custom_frame_selection.copy() 138 | self.selected_row = -1 139 | self.selected_column = -1 140 | else: 141 | # Regular selection mode 142 | self.is_custom_selecting = False 143 | self.custom_frame_selection.clear() 144 | self.selected_cells = [] 145 | self.selection_start = (cell_x, cell_y) 146 | self.selection_end = (cell_x, cell_y) 147 | 148 | # Select the whole row or column if holding Shift or Ctrl 149 | if modifiers & Qt.KeyboardModifier.ShiftModifier: 150 | self.selected_row = cell_y 151 | self.selected_column = -1 152 | elif modifiers & Qt.KeyboardModifier.ControlModifier: 153 | self.selected_column = cell_x 154 | self.selected_row = -1 155 | else: 156 | self.selected_row = -1 157 | self.selected_column = -1 158 | 159 | self.is_selecting = True 160 | self.update_selection() 161 | self.update() 162 | 163 | # Update UI 164 | if hasattr(self.parent().parent().parent(), 'update_selection_label'): 165 | self.parent().parent().parent().update_selection_label() 166 | if hasattr(self.parent().parent().parent(), 'update_button_states'): 167 | self.parent().parent().parent().update_button_states() 168 | 169 | def mouseMoveEvent(self, event): 170 | if self.sprite_image is None or self.is_custom_selecting: 171 | return 172 | 173 | if self.is_selecting and event.buttons() & Qt.MouseButton.LeftButton: 174 | x, y = event.position().x(), event.position().y() 175 | 176 | # Calculate offset for centering 177 | x_offset = max(0, (self.width() - int(self.sprite_image.size[0] * self.zoom_factor)) // 2) 178 | y_offset = max(0, (self.height() - int(self.sprite_image.size[1] * self.zoom_factor)) // 2) 179 | 180 | # Adjust for offset 181 | x = x - x_offset 182 | y = y - y_offset 183 | 184 | # Clamp coordinates to image boundaries 185 | x = max(0, min(x, self.sprite_image.size[0] * self.zoom_factor - 1)) 186 | y = max(0, min(y, self.sprite_image.size[1] * self.zoom_factor - 1)) 187 | 188 | # Account for zoom factor when calculating cell coordinates 189 | cell_x = max(0, min(int(x // (self.cell_width * self.zoom_factor)), 190 | int(self.sprite_image.size[0] // self.cell_width) - 1)) 191 | cell_y = max(0, min(int(y // (self.cell_height * self.zoom_factor)), 192 | int(self.sprite_image.size[1] // self.cell_height) - 1)) 193 | 194 | self.selection_end = (cell_x, cell_y) 195 | self.update_selection() 196 | self.update() 197 | 198 | # Update UI 199 | if hasattr(self.parent().parent().parent(), 'update_selection_label'): 200 | self.parent().parent().parent().update_selection_label() 201 | 202 | def mouseReleaseEvent(self, event): 203 | if event.button() == Qt.MouseButton.LeftButton: 204 | self.is_selecting = False 205 | 206 | # Update UI 207 | if hasattr(self.parent().parent().parent(), 'update_selection_label'): 208 | self.parent().parent().parent().update_selection_label() 209 | if hasattr(self.parent().parent().parent(), 'update_button_states'): 210 | self.parent().parent().parent().update_button_states() 211 | 212 | def update_selection(self): 213 | if self.is_custom_selecting: 214 | # For custom selection, selected_cells is already updated 215 | return 216 | 217 | if self.selection_start is None or self.selection_end is None: 218 | return 219 | 220 | if self.selected_row >= 0: 221 | # Select entire row 222 | row = self.selected_row 223 | max_cols = self.sprite_image.size[0] // self.cell_width 224 | self.selected_cells = [(col, row) for col in range(max_cols)] 225 | elif self.selected_column >= 0: 226 | # Select entire column 227 | col = self.selected_column 228 | max_rows = self.sprite_image.size[1] // self.cell_height 229 | self.selected_cells = [(col, row) for row in range(max_rows)] 230 | else: 231 | # Select rectangle of cells 232 | start_x, start_y = self.selection_start 233 | end_x, end_y = self.selection_end 234 | 235 | min_x, max_x = min(start_x, end_x), max(start_x, end_x) 236 | min_y, max_y = min(start_y, end_y), max(start_y, end_y) 237 | 238 | self.selected_cells = [] 239 | for y in range(min_y, max_y + 1): 240 | for x in range(min_x, max_x + 1): 241 | self.selected_cells.append((x, y)) 242 | 243 | def paintEvent(self, event): 244 | # Draw checkered background for transparency 245 | painter = QPainter(self) 246 | 247 | # Create checkered pattern for transparent areas 248 | checker_size = 10 249 | light_gray = QColor(220, 220, 220) 250 | white = QColor(255, 255, 255) 251 | 252 | for y in range(0, self.height(), checker_size): 253 | for x in range(0, self.width(), checker_size): 254 | color = light_gray if ((x // checker_size) + (y // checker_size)) % 2 else white 255 | painter.fillRect(x, y, checker_size, checker_size, color) 256 | 257 | # Center the pixmap in the canvas 258 | if self.pixmap() and not self.pixmap().isNull(): 259 | # Calculate center position 260 | x_offset = max(0, (self.width() - self.pixmap().width()) // 2) 261 | y_offset = max(0, (self.height() - self.pixmap().height()) // 2) 262 | 263 | # Draw the pixmap at the centered position 264 | painter.drawPixmap(x_offset, y_offset, self.pixmap()) 265 | 266 | if self.sprite_image is None: 267 | painter.end() 268 | return 269 | 270 | # Get center offset for grid and selection drawing 271 | x_offset = max(0, (self.width() - int(self.sprite_image.size[0] * self.zoom_factor)) // 2) 272 | y_offset = max(0, (self.height() - int(self.sprite_image.size[1] * self.zoom_factor)) // 2) 273 | 274 | # Draw the grid 275 | if self.show_grid: 276 | pen = QPen(self.grid_color) 277 | pen.setWidth(1) 278 | painter.setPen(pen) 279 | 280 | # Draw vertical lines with zoom factor 281 | cell_width_zoomed = self.cell_width * self.zoom_factor 282 | sprite_width_zoomed = self.sprite_image.size[0] * self.zoom_factor 283 | 284 | # Draw all vertical lines including the right edge 285 | for x in range(0, int(sprite_width_zoomed) + 1, int(cell_width_zoomed)): 286 | painter.drawLine( 287 | x + x_offset, 288 | y_offset, 289 | x + x_offset, 290 | int(self.sprite_image.size[1] * self.zoom_factor) + y_offset 291 | ) 292 | 293 | # Draw horizontal lines with zoom factor 294 | cell_height_zoomed = self.cell_height * self.zoom_factor 295 | sprite_height_zoomed = self.sprite_image.size[1] * self.zoom_factor 296 | 297 | # Draw all horizontal lines including the bottom edge 298 | for y in range(0, int(sprite_height_zoomed) + 1, int(cell_height_zoomed)): 299 | painter.drawLine( 300 | x_offset, 301 | y + y_offset, 302 | int(sprite_width_zoomed) + x_offset, 303 | y + y_offset 304 | ) 305 | 306 | # Draw selection 307 | if self.selected_cells: 308 | highlight_color = QColor(0, 0, 255, 80) # Semi-transparent blue 309 | painter.setBrush(highlight_color) 310 | painter.setPen(Qt.PenStyle.NoPen) 311 | 312 | for cell_x, cell_y in self.selected_cells: 313 | rect = QRect( 314 | int(cell_x * self.cell_width * self.zoom_factor) + x_offset, 315 | int(cell_y * self.cell_height * self.zoom_factor) + y_offset, 316 | int(self.cell_width * self.zoom_factor), 317 | int(self.cell_height * self.zoom_factor) 318 | ) 319 | painter.drawRect(rect) 320 | 321 | # Draw bold outline around selection 322 | outline_color = QColor(0, 0, 255, 200) # More opaque blue 323 | painter.setPen(QPen(outline_color, 2)) 324 | painter.setBrush(Qt.BrushStyle.NoBrush) 325 | 326 | if self.selected_row >= 0: 327 | # Draw row outline 328 | rect = QRect( 329 | x_offset, 330 | int(self.selected_row * self.cell_height * self.zoom_factor) + y_offset, 331 | int(self.sprite_image.size[0] * self.zoom_factor), 332 | int(self.cell_height * self.zoom_factor) 333 | ) 334 | painter.drawRect(rect) 335 | elif self.selected_column >= 0: 336 | # Draw column outline 337 | rect = QRect( 338 | int(self.selected_column * self.cell_width * self.zoom_factor) + x_offset, 339 | y_offset, 340 | int(self.cell_width * self.zoom_factor), 341 | int(self.sprite_image.size[1] * self.zoom_factor) 342 | ) 343 | painter.drawRect(rect) 344 | else: 345 | # Draw rectangle outline 346 | start_x, start_y = self.selection_start 347 | end_x, end_y = self.selection_end 348 | 349 | min_x, max_x = min(start_x, end_x), max(start_x, end_x) 350 | min_y, max_y = min(start_y, end_y), max(start_y, end_y) 351 | 352 | rect = QRect( 353 | int(min_x * self.cell_width * self.zoom_factor) + x_offset, 354 | int(min_y * self.cell_height * self.zoom_factor) + y_offset, 355 | int((max_x - min_x + 1) * self.cell_width * self.zoom_factor), 356 | int((max_y - min_y + 1) * self.cell_height * self.zoom_factor) 357 | ) 358 | painter.drawRect(rect) 359 | 360 | painter.end() 361 | 362 | def set_cell_size(self, width, height): 363 | self.cell_width = width 364 | self.cell_height = height 365 | self.update() 366 | 367 | def set_grid_visible(self, visible): 368 | """Set grid visibility and force update""" 369 | self.show_grid = visible 370 | self.update() # Force a repaint 371 | 372 | def set_grid_color(self, color): 373 | self.grid_color = color 374 | self.update() 375 | 376 | def set_padding(self, padding): 377 | """Preview padding without applying it""" 378 | if self.sprite_image is None or padding == self.padding_preview: 379 | return 380 | 381 | self.padding_preview = padding 382 | 383 | if padding == 0: 384 | # Reset to original image for preview 385 | self.sprite_image = self.original_image.copy() 386 | self.spritesheet = np.array(self.sprite_image) 387 | self.update_pixmap() 388 | return 389 | 390 | # Calculate number of cells in the sheet 391 | cols = self.original_image.size[0] // self.cell_width 392 | rows = self.original_image.size[1] // self.cell_height 393 | 394 | # Create a new image with padding for preview 395 | padded_width = cols * (self.cell_width + 2 * padding) 396 | padded_height = rows * (self.cell_height + 2 * padding) 397 | padded_image = Image.new( 398 | self.original_image.mode, 399 | (padded_width, padded_height), 400 | (0, 0, 0, 0) # Transparent background 401 | ) 402 | 403 | # Copy each cell with padding 404 | for row in range(rows): 405 | for col in range(cols): 406 | # Source coordinates from original image 407 | src_x = col * self.cell_width 408 | src_y = row * self.cell_height 409 | 410 | # Destination coordinates (with padding) 411 | dst_x = col * (self.cell_width + 2 * padding) + padding 412 | dst_y = row * (self.cell_height + 2 * padding) + padding 413 | 414 | # Extract cell from original image and paste it in the new image 415 | cell = self.original_image.crop( 416 | (src_x, src_y, src_x + self.cell_width, src_y + self.cell_height) 417 | ) 418 | padded_image.paste(cell, (dst_x, dst_y)) 419 | 420 | # Update the sprite image for preview 421 | self.sprite_image = padded_image 422 | self.spritesheet = np.array(self.sprite_image) 423 | self.update_pixmap() 424 | 425 | def apply_padding(self): 426 | """Actually apply the padding permanently""" 427 | if self.sprite_image is None or self.padding_preview == 0: 428 | return 429 | 430 | # Update the original image with the current padded version 431 | self.original_image = self.sprite_image.copy() 432 | 433 | # Update cell dimensions to include padding 434 | self.cell_width = self.cell_width + 2 * self.padding_preview 435 | self.cell_height = self.cell_height + 2 * self.padding_preview 436 | 437 | # Reset padding preview 438 | self.padding_preview = 0 439 | 440 | # Update display 441 | self.update_pixmap() 442 | 443 | def remove_row(self, row_index): 444 | if self.sprite_image is None or row_index < 0: 445 | return False 446 | 447 | # Calculate number of cells in the sheet 448 | cols = self.sprite_image.size[0] // self.cell_width 449 | rows = self.sprite_image.size[1] // self.cell_height 450 | 451 | if row_index >= rows: 452 | return False 453 | 454 | # Create a new image without the selected row 455 | new_width = self.sprite_image.size[0] 456 | new_height = self.sprite_image.size[1] - self.cell_height 457 | new_image = Image.new( 458 | self.sprite_image.mode, 459 | (new_width, new_height), 460 | (0, 0, 0, 0) # Transparent background 461 | ) 462 | 463 | # Copy each row except the one to be removed 464 | y_offset = 0 465 | for row in range(rows): 466 | if row == row_index: 467 | continue 468 | 469 | # Source coordinates for this row 470 | src_y = row * self.cell_height 471 | 472 | # Extract row and paste it in the new image 473 | row_img = self.sprite_image.crop( 474 | (0, src_y, new_width, src_y + self.cell_height) 475 | ) 476 | new_image.paste(row_img, (0, y_offset)) 477 | y_offset += self.cell_height 478 | 479 | # Update the sprite image 480 | self.sprite_image = new_image 481 | self.spritesheet = np.array(self.sprite_image) 482 | 483 | # Clear selection 484 | self.selected_cells = [] 485 | self.selected_row = -1 486 | 487 | # Update display 488 | self.update_pixmap() 489 | return True 490 | 491 | def remove_column(self, col_index): 492 | if self.sprite_image is None or col_index < 0: 493 | return False 494 | 495 | # Calculate number of cells in the sheet 496 | cols = self.sprite_image.size[0] // self.cell_width 497 | rows = self.sprite_image.size[1] // self.cell_height 498 | 499 | if col_index >= cols: 500 | return False 501 | 502 | # Create a new image without the selected column 503 | new_width = self.sprite_image.size[0] - self.cell_width 504 | new_height = self.sprite_image.size[1] 505 | new_image = Image.new( 506 | self.sprite_image.mode, 507 | (new_width, new_height), 508 | (0, 0, 0, 0) # Transparent background 509 | ) 510 | 511 | # Copy each column except the one to be removed 512 | x_offset = 0 513 | for col in range(cols): 514 | if col == col_index: 515 | continue 516 | 517 | # Source coordinates for this column 518 | src_x = col * self.cell_width 519 | 520 | # Extract column and paste it in the new image 521 | col_img = self.sprite_image.crop( 522 | (src_x, 0, src_x + self.cell_width, new_height) 523 | ) 524 | new_image.paste(col_img, (x_offset, 0)) 525 | x_offset += self.cell_width 526 | 527 | # Update the sprite image 528 | self.sprite_image = new_image 529 | self.spritesheet = np.array(self.sprite_image) 530 | 531 | # Clear selection 532 | self.selected_cells = [] 533 | self.selected_column = -1 534 | 535 | # Update display 536 | self.update_pixmap() 537 | return True 538 | 539 | def export_selection_as_gif(self, filename): 540 | if not self.selected_cells or self.sprite_image is None: 541 | return False 542 | 543 | # Extract selected cells as frames 544 | frames = [] 545 | 546 | # If using custom frame selection, use the frames in selection order 547 | if self.is_custom_selecting: 548 | for col, row in self.custom_frame_selection: 549 | # Extract the frame 550 | x = col * self.cell_width 551 | y = row * self.cell_height 552 | frame = self.sprite_image.crop( 553 | (x, y, x + self.cell_width, y + self.cell_height) 554 | ) 555 | frames.append(frame) 556 | else: 557 | # If a row is selected, export frames horizontally 558 | if self.selected_row >= 0: 559 | row = self.selected_row 560 | max_cols = self.sprite_image.size[0] // self.cell_width 561 | 562 | for col in range(max_cols): 563 | # Extract the frame 564 | x = col * self.cell_width 565 | y = row * self.cell_height 566 | frame = self.sprite_image.crop( 567 | (x, y, x + self.cell_width, y + self.cell_height) 568 | ) 569 | frames.append(frame) 570 | 571 | # If a column is selected, export frames vertically 572 | elif self.selected_column >= 0: 573 | col = self.selected_column 574 | max_rows = self.sprite_image.size[1] // self.cell_height 575 | 576 | for row in range(max_rows): 577 | # Extract the frame 578 | x = col * self.cell_width 579 | y = row * self.cell_height 580 | frame = self.sprite_image.crop( 581 | (x, y, x + self.cell_width, y + self.cell_height) 582 | ) 583 | frames.append(frame) 584 | 585 | # If a custom selection is made, export frames in reading order 586 | else: 587 | # Sort cells by row then column for reading order 588 | cells = sorted(self.selected_cells, key=lambda c: (c[1], c[0])) 589 | 590 | # Save frames as GIF animation 591 | if frames: 592 | frames[0].save( 593 | filename, 594 | format='GIF', 595 | append_images=frames[1:], 596 | save_all=True, 597 | duration=100, # 100ms per frame 598 | loop=0 # Loop forever 599 | ) 600 | return True 601 | 602 | return False 603 | 604 | def export_selection_as_apng(self, filename): 605 | if not self.selected_cells or self.sprite_image is None: 606 | return False 607 | 608 | # Extract selected cells as frames 609 | frames = [] 610 | 611 | # If using custom frame selection, use the frames in selection order 612 | if self.is_custom_selecting: 613 | for col, row in self.custom_frame_selection: 614 | # Extract the frame 615 | x = col * self.cell_width 616 | y = row * self.cell_height 617 | frame = self.sprite_image.crop( 618 | (x, y, x + self.cell_width, y + self.cell_height) 619 | ) 620 | frames.append(np.array(frame)) 621 | else: 622 | # Similar logic to the GIF export 623 | if self.selected_row >= 0: 624 | row = self.selected_row 625 | max_cols = self.sprite_image.size[0] // self.cell_width 626 | 627 | for col in range(max_cols): 628 | x = col * self.cell_width 629 | y = row * self.cell_height 630 | frame = self.sprite_image.crop( 631 | (x, y, x + self.cell_width, y + self.cell_height) 632 | ) 633 | frames.append(np.array(frame)) 634 | 635 | elif self.selected_column >= 0: 636 | col = self.selected_column 637 | max_rows = self.sprite_image.size[1] // self.cell_height 638 | 639 | for row in range(max_rows): 640 | x = col * self.cell_width 641 | y = row * self.cell_height 642 | frame = self.sprite_image.crop( 643 | (x, y, x + self.cell_width, y + self.cell_height) 644 | ) 645 | frames.append(np.array(frame)) 646 | 647 | else: 648 | cells = sorted(self.selected_cells, key=lambda c: (c[1], c[0])) 649 | 650 | for col, row in cells: 651 | x = col * self.cell_width 652 | y = row * self.cell_height 653 | frame = self.sprite_image.crop( 654 | (x, y, x + self.cell_width, y + self.cell_height) 655 | ) 656 | frames.append(np.array(frame)) 657 | 658 | # Save frames as APNG 659 | if frames: 660 | imageio.mimsave(filename, frames, format='APNG', fps=10) 661 | return True 662 | 663 | return False 664 | 665 | def set_zoom(self, zoom_index): 666 | if 0 <= zoom_index < len(self.zoom_levels): 667 | self.current_zoom_index = zoom_index 668 | self.zoom_factor = self.zoom_levels[zoom_index] 669 | self.update_pixmap() # Update with new zoom factor 670 | 671 | def zoom_in(self): 672 | if self.current_zoom_index < len(self.zoom_levels) - 1: 673 | self.current_zoom_index += 1 674 | self.zoom_factor = self.zoom_levels[self.current_zoom_index] 675 | self.update_pixmap() 676 | return self.zoom_factor 677 | return None 678 | 679 | def zoom_out(self): 680 | if self.current_zoom_index > 0: 681 | self.current_zoom_index -= 1 682 | self.zoom_factor = self.zoom_levels[self.current_zoom_index] 683 | self.update_pixmap() 684 | return self.zoom_factor 685 | return None 686 | 687 | def zoom_reset(self): 688 | self.current_zoom_index = 0 # Reset to 1x zoom 689 | self.zoom_factor = self.zoom_levels[self.current_zoom_index] 690 | self.update_pixmap() 691 | return self.zoom_factor 692 | 693 | def export_selection_as_png(self, filename): 694 | """Export the selected area as a PNG image""" 695 | if not self.selected_cells or self.sprite_image is None: 696 | return False 697 | 698 | # If using custom frame selection, create a strip of selected frames 699 | if self.is_custom_selecting: 700 | # Create a new image wide enough for all selected frames 701 | width = len(self.custom_frame_selection) * self.cell_width 702 | height = self.cell_height 703 | new_img = Image.new('RGBA', (width, height), (0, 0, 0, 0)) 704 | 705 | # Copy frames in selection order 706 | for i, (col, row) in enumerate(self.custom_frame_selection): 707 | # Calculate source and destination coordinates 708 | src_x = col * self.cell_width 709 | src_y = row * self.cell_height 710 | dst_x = i * self.cell_width 711 | 712 | # Extract and paste the cell 713 | cell = self.sprite_image.crop( 714 | (src_x, src_y, 715 | src_x + self.cell_width, 716 | src_y + self.cell_height) 717 | ) 718 | new_img.paste(cell, (dst_x, 0)) 719 | 720 | new_img.save(filename) 721 | return True 722 | 723 | # If a row is selected, export the entire row 724 | if self.selected_row >= 0: 725 | row = self.selected_row 726 | # Extract the row 727 | row_img = self.sprite_image.crop( 728 | (0, row * self.cell_height, 729 | self.sprite_image.size[0], (row + 1) * self.cell_height) 730 | ) 731 | row_img.save(filename) 732 | return True 733 | 734 | # If a column is selected, export the entire column 735 | elif self.selected_column >= 0: 736 | col = self.selected_column 737 | # Extract the column 738 | col_img = self.sprite_image.crop( 739 | (col * self.cell_width, 0, 740 | (col + 1) * self.cell_width, self.sprite_image.size[1]) 741 | ) 742 | col_img.save(filename) 743 | return True 744 | 745 | # If multiple cells are selected, create a new image with all selected cells 746 | else: 747 | # Calculate the size of the output image 748 | cells = sorted(self.selected_cells, key=lambda c: (c[1], c[0])) # Sort by row, then column 749 | min_col = min(cell[0] for cell in cells) 750 | max_col = max(cell[0] for cell in cells) 751 | min_row = min(cell[1] for cell in cells) 752 | max_row = max(cell[1] for cell in cells) 753 | 754 | width = (max_col - min_col + 1) * self.cell_width 755 | height = (max_row - min_row + 1) * self.cell_height 756 | 757 | # Create new image 758 | new_img = Image.new('RGBA', (width, height), (0, 0, 0, 0)) 759 | 760 | # Copy selected cells 761 | for col, row in cells: 762 | # Calculate source and destination coordinates 763 | src_x = col * self.cell_width 764 | src_y = row * self.cell_height 765 | dst_x = (col - min_col) * self.cell_width 766 | dst_y = (row - min_row) * self.cell_height 767 | 768 | # Extract and paste the cell 769 | cell = self.sprite_image.crop( 770 | (src_x, src_y, 771 | src_x + self.cell_width, 772 | src_y + self.cell_height) 773 | ) 774 | new_img.paste(cell, (dst_x, dst_y)) 775 | 776 | new_img.save(filename) 777 | return True 778 | 779 | return False 780 | 781 | 782 | class SpriteToolz(QMainWindow): 783 | def __init__(self): 784 | super().__init__() 785 | self.initUI() 786 | 787 | def initUI(self): 788 | # Set window properties 789 | self.setWindowTitle("Sprite Toolz") 790 | self.resize(1280, 720) 791 | self.center_window() 792 | 793 | # Create main widget and layout 794 | main_widget = QWidget() 795 | main_layout = QHBoxLayout() 796 | main_widget.setLayout(main_layout) 797 | self.setCentralWidget(main_widget) 798 | 799 | # Create sprite canvas 800 | self.sprite_canvas = SpriteCanvas() 801 | 802 | # Create scroll area for the canvas 803 | self.scroll_area = QScrollArea() 804 | self.scroll_area.setWidget(self.sprite_canvas) 805 | self.scroll_area.setWidgetResizable(False) # Important for proper zooming 806 | self.scroll_area.setMinimumWidth(800) 807 | self.scroll_area.setMinimumHeight(600) 808 | self.scroll_area.setAlignment(Qt.AlignmentFlag.AlignCenter) # Center the canvas 809 | 810 | # Create tab widget for controls 811 | tab_widget = QTabWidget() 812 | tab_widget.setMaximumWidth(300) 813 | 814 | # Create basic operations tab 815 | basic_tab = QWidget() 816 | basic_layout = QVBoxLayout() 817 | basic_tab.setLayout(basic_layout) 818 | 819 | # File operations group 820 | file_group = QGroupBox("File Operations") 821 | file_layout = QVBoxLayout() 822 | 823 | self.load_button = QPushButton("Load Sprite Sheet") 824 | self.load_button.clicked.connect(self.load_spritesheet) 825 | file_layout.addWidget(self.load_button) 826 | 827 | # Export options group 828 | export_group = QGroupBox("Export Options") 829 | export_layout = QVBoxLayout() 830 | 831 | # Export format selection 832 | self.export_format_group = QGroupBox("Export Format") 833 | format_layout = QVBoxLayout() 834 | 835 | self.strip_radio = QRadioButton("Sprite Strip") 836 | self.strip_radio.setChecked(True) 837 | self.frames_radio = QRadioButton("Individual Frames") 838 | self.animation_radio = QRadioButton("Animation") 839 | 840 | format_layout.addWidget(self.strip_radio) 841 | format_layout.addWidget(self.frames_radio) 842 | format_layout.addWidget(self.animation_radio) 843 | 844 | self.export_format_group.setLayout(format_layout) 845 | export_layout.addWidget(self.export_format_group) 846 | 847 | self.export_button = QPushButton("Export Selection") 848 | self.export_button.clicked.connect(self.export_selection) 849 | self.export_button.setEnabled(False) 850 | export_layout.addWidget(self.export_button) 851 | 852 | export_group.setLayout(export_layout) 853 | 854 | file_layout.addWidget(export_group) 855 | file_group.setLayout(file_layout) 856 | basic_layout.addWidget(file_group) 857 | 858 | # Cell size group 859 | cell_group = QGroupBox("Cell Size") 860 | cell_layout = QGridLayout() 861 | 862 | # Cell size mode toggle 863 | self.cell_size_mode_cb = QCheckBox("Use Row/Column Count") 864 | self.cell_size_mode_cb.stateChanged.connect(self.toggle_cell_size_mode) 865 | cell_layout.addWidget(self.cell_size_mode_cb, 0, 0, 1, 2) 866 | 867 | # Manual cell size widgets 868 | self.manual_size_widget = QWidget() 869 | manual_layout = QGridLayout() 870 | manual_layout.setContentsMargins(0, 0, 0, 0) 871 | 872 | manual_layout.addWidget(QLabel("Width:"), 0, 0) 873 | self.cell_width_spin = QSpinBox() 874 | self.cell_width_spin.setRange(1, 1000) 875 | self.cell_width_spin.setValue(32) 876 | self.cell_width_spin.valueChanged.connect(self.update_cell_size) 877 | manual_layout.addWidget(self.cell_width_spin, 0, 1) 878 | 879 | manual_layout.addWidget(QLabel("Height:"), 1, 0) 880 | self.cell_height_spin = QSpinBox() 881 | self.cell_height_spin.setRange(1, 1000) 882 | self.cell_height_spin.setValue(32) 883 | self.cell_height_spin.valueChanged.connect(self.update_cell_size) 884 | manual_layout.addWidget(self.cell_height_spin, 1, 1) 885 | 886 | self.manual_size_widget.setLayout(manual_layout) 887 | cell_layout.addWidget(self.manual_size_widget, 1, 0, 2, 2) 888 | 889 | # Row/Column count widgets 890 | self.count_size_widget = QWidget() 891 | count_layout = QGridLayout() 892 | count_layout.setContentsMargins(0, 0, 0, 0) 893 | 894 | count_layout.addWidget(QLabel("Rows:"), 0, 0) 895 | self.row_count_spin = QSpinBox() 896 | self.row_count_spin.setRange(1, 1000) 897 | self.row_count_spin.setValue(1) 898 | self.row_count_spin.valueChanged.connect(self.update_cell_size_from_count) 899 | count_layout.addWidget(self.row_count_spin, 0, 1) 900 | 901 | count_layout.addWidget(QLabel("Columns:"), 1, 0) 902 | self.col_count_spin = QSpinBox() 903 | self.col_count_spin.setRange(1, 1000) 904 | self.col_count_spin.setValue(1) 905 | self.col_count_spin.valueChanged.connect(self.update_cell_size_from_count) 906 | count_layout.addWidget(self.col_count_spin, 1, 1) 907 | 908 | self.count_size_widget.setLayout(count_layout) 909 | cell_layout.addWidget(self.count_size_widget, 1, 0, 2, 2) 910 | self.count_size_widget.hide() # Initially hidden 911 | 912 | # Padding controls 913 | cell_layout.addWidget(QLabel("Padding:"), 3, 0) 914 | self.padding_spin = QSpinBox() 915 | self.padding_spin.setRange(0, 100) 916 | self.padding_spin.setValue(0) 917 | self.padding_spin.valueChanged.connect(self.update_padding) 918 | cell_layout.addWidget(self.padding_spin, 3, 1) 919 | 920 | self.apply_padding_button = QPushButton("Apply Padding") 921 | self.apply_padding_button.clicked.connect(self.apply_padding) 922 | self.apply_padding_button.setEnabled(False) 923 | cell_layout.addWidget(self.apply_padding_button, 4, 0, 1, 2) 924 | 925 | cell_group.setLayout(cell_layout) 926 | basic_layout.addWidget(cell_group) 927 | 928 | # Grid display group 929 | grid_group = QGroupBox("Grid") 930 | grid_layout = QVBoxLayout() 931 | 932 | self.show_grid_checkbox = QCheckBox("Show Grid") 933 | self.show_grid_checkbox.setChecked(True) 934 | self.show_grid_checkbox.stateChanged.connect(self.toggle_grid) 935 | 936 | self.grid_color_button = QPushButton("Grid Color") 937 | self.grid_color_button.clicked.connect(self.change_grid_color) 938 | 939 | grid_layout.addWidget(self.show_grid_checkbox) 940 | grid_layout.addWidget(self.grid_color_button) 941 | grid_group.setLayout(grid_layout) 942 | basic_layout.addWidget(grid_group) 943 | 944 | # Zoom group 945 | zoom_group = QGroupBox("Zoom") 946 | zoom_layout = QGridLayout() 947 | 948 | self.zoom_in_button = QPushButton("Zoom In") 949 | self.zoom_in_button.clicked.connect(self.zoom_in) 950 | self.zoom_in_button.setEnabled(False) 951 | 952 | self.zoom_out_button = QPushButton("Zoom Out") 953 | self.zoom_out_button.clicked.connect(self.zoom_out) 954 | self.zoom_out_button.setEnabled(False) 955 | 956 | self.zoom_reset_button = QPushButton("Reset Zoom") 957 | self.zoom_reset_button.clicked.connect(self.zoom_reset) 958 | self.zoom_reset_button.setEnabled(False) 959 | 960 | self.zoom_label = QLabel("Zoom: 1x") 961 | 962 | zoom_layout.addWidget(self.zoom_in_button, 0, 0) 963 | zoom_layout.addWidget(self.zoom_out_button, 0, 1) 964 | zoom_layout.addWidget(self.zoom_reset_button, 1, 0, 1, 2) 965 | zoom_layout.addWidget(self.zoom_label, 2, 0, 1, 2) 966 | 967 | zoom_group.setLayout(zoom_layout) 968 | basic_layout.addWidget(zoom_group) 969 | 970 | # Add stretch to push everything to the top 971 | basic_layout.addStretch() 972 | 973 | # Create manipulation tab 974 | manip_tab = QWidget() 975 | manip_layout = QVBoxLayout() 976 | manip_tab.setLayout(manip_layout) 977 | 978 | # Selection info 979 | self.selection_label = QLabel("No selection") 980 | manip_layout.addWidget(self.selection_label) 981 | 982 | # Row operations 983 | row_ops_group = QGroupBox("Row Operations") 984 | row_ops_layout = QVBoxLayout() 985 | 986 | duplicate_row_btn = QPushButton("Duplicate Row") 987 | duplicate_row_btn.clicked.connect(self.duplicate_row) 988 | row_ops_layout.addWidget(duplicate_row_btn) 989 | 990 | delete_row_btn = QPushButton("Delete Row") 991 | delete_row_btn.clicked.connect(self.delete_row) 992 | row_ops_layout.addWidget(delete_row_btn) 993 | 994 | add_row_before_btn = QPushButton("Add Blank Row Before") 995 | add_row_before_btn.clicked.connect(self.add_row_before) 996 | row_ops_layout.addWidget(add_row_before_btn) 997 | 998 | add_row_after_btn = QPushButton("Add Blank Row After") 999 | add_row_after_btn.clicked.connect(self.add_row_after) 1000 | row_ops_layout.addWidget(add_row_after_btn) 1001 | 1002 | export_row_btn = QPushButton("Export Row") 1003 | export_row_btn.clicked.connect(self.export_row) 1004 | row_ops_layout.addWidget(export_row_btn) 1005 | 1006 | row_ops_group.setLayout(row_ops_layout) 1007 | manip_layout.addWidget(row_ops_group) 1008 | 1009 | # Column operations 1010 | col_ops_group = QGroupBox("Column Operations") 1011 | col_ops_layout = QVBoxLayout() 1012 | 1013 | duplicate_col_btn = QPushButton("Duplicate Column") 1014 | duplicate_col_btn.clicked.connect(self.duplicate_column) 1015 | col_ops_layout.addWidget(duplicate_col_btn) 1016 | 1017 | delete_col_btn = QPushButton("Delete Column") 1018 | delete_col_btn.clicked.connect(self.delete_column) 1019 | col_ops_layout.addWidget(delete_col_btn) 1020 | 1021 | add_col_before_btn = QPushButton("Add Blank Column Before") 1022 | add_col_before_btn.clicked.connect(self.add_column_before) 1023 | col_ops_layout.addWidget(add_col_before_btn) 1024 | 1025 | add_col_after_btn = QPushButton("Add Blank Column After") 1026 | add_col_after_btn.clicked.connect(self.add_column_after) 1027 | col_ops_layout.addWidget(add_col_after_btn) 1028 | 1029 | export_col_btn = QPushButton("Export Column") 1030 | export_col_btn.clicked.connect(self.export_column) 1031 | col_ops_layout.addWidget(export_col_btn) 1032 | 1033 | col_ops_group.setLayout(col_ops_layout) 1034 | manip_layout.addWidget(col_ops_group) 1035 | 1036 | # Frame operations 1037 | frame_ops_group = QGroupBox("Frame Operations") 1038 | frame_ops_layout = QVBoxLayout() 1039 | 1040 | duplicate_frame_btn = QPushButton("Duplicate Frame") 1041 | duplicate_frame_btn.clicked.connect(self.duplicate_frame) 1042 | frame_ops_layout.addWidget(duplicate_frame_btn) 1043 | 1044 | delete_frame_btn = QPushButton("Delete Frame") 1045 | delete_frame_btn.clicked.connect(self.delete_frame) 1046 | frame_ops_layout.addWidget(delete_frame_btn) 1047 | 1048 | export_frame_btn = QPushButton("Export Frame") 1049 | export_frame_btn.clicked.connect(self.export_frame) 1050 | frame_ops_layout.addWidget(export_frame_btn) 1051 | 1052 | frame_ops_group.setLayout(frame_ops_layout) 1053 | manip_layout.addWidget(frame_ops_group) 1054 | 1055 | # Add stretch to push everything to the top 1056 | manip_layout.addStretch() 1057 | 1058 | # Add tabs to tab widget 1059 | tab_widget.addTab(basic_tab, "Basic") 1060 | tab_widget.addTab(manip_tab, "Manipulate") 1061 | 1062 | # Create batch operations tab 1063 | batch_tab = QWidget() 1064 | batch_layout = QVBoxLayout() 1065 | batch_tab.setLayout(batch_layout) 1066 | 1067 | # Input folder group 1068 | input_group = QGroupBox("Input") 1069 | input_layout = QGridLayout() 1070 | 1071 | self.input_folder_label = QLabel("No folder selected") 1072 | input_layout.addWidget(self.input_folder_label, 0, 0, 1, 2) 1073 | 1074 | select_folder_btn = QPushButton("Select Folder") 1075 | select_folder_btn.clicked.connect(self.select_input_folder) 1076 | input_layout.addWidget(select_folder_btn, 1, 0, 1, 2) 1077 | 1078 | self.include_subfolders_cb = QCheckBox("Process Subfolders") 1079 | input_layout.addWidget(self.include_subfolders_cb, 2, 0, 1, 2) 1080 | 1081 | input_group.setLayout(input_layout) 1082 | batch_layout.addWidget(input_group) 1083 | 1084 | # Batch operations group 1085 | batch_ops_group = QGroupBox("Batch Operations") 1086 | batch_ops_layout = QVBoxLayout() 1087 | 1088 | # Cell size adjustment 1089 | cell_size_layout = QGridLayout() 1090 | cell_size_layout.addWidget(QLabel("Cell Width:"), 0, 0) 1091 | self.batch_cell_width_spin = QSpinBox() 1092 | self.batch_cell_width_spin.setRange(1, 1000) 1093 | self.batch_cell_width_spin.setValue(32) 1094 | cell_size_layout.addWidget(self.batch_cell_width_spin, 0, 1) 1095 | 1096 | cell_size_layout.addWidget(QLabel("Cell Height:"), 1, 0) 1097 | self.batch_cell_height_spin = QSpinBox() 1098 | self.batch_cell_height_spin.setRange(1, 1000) 1099 | self.batch_cell_height_spin.setValue(32) 1100 | cell_size_layout.addWidget(self.batch_cell_height_spin, 1, 1) 1101 | 1102 | cell_size_layout.addWidget(QLabel("Padding:"), 2, 0) 1103 | self.batch_padding_spin = QSpinBox() 1104 | self.batch_padding_spin.setRange(0, 100) 1105 | self.batch_padding_spin.setValue(0) 1106 | cell_size_layout.addWidget(self.batch_padding_spin, 2, 1) 1107 | 1108 | batch_ops_layout.addLayout(cell_size_layout) 1109 | 1110 | # Export options 1111 | self.export_frames_cb = QCheckBox("Export Individual Frames") 1112 | batch_ops_layout.addWidget(self.export_frames_cb) 1113 | 1114 | self.export_rows_cb = QCheckBox("Export Rows as Strips") 1115 | batch_ops_layout.addWidget(self.export_rows_cb) 1116 | 1117 | self.export_gif_cb = QCheckBox("Export Rows as GIF") 1118 | batch_ops_layout.addWidget(self.export_gif_cb) 1119 | 1120 | self.export_apng_cb = QCheckBox("Export Rows as APNG") 1121 | batch_ops_layout.addWidget(self.export_apng_cb) 1122 | 1123 | # Process button 1124 | self.process_batch_btn = QPushButton("Process Folder") 1125 | self.process_batch_btn.clicked.connect(self.process_batch) 1126 | self.process_batch_btn.setEnabled(False) 1127 | batch_ops_layout.addWidget(self.process_batch_btn) 1128 | 1129 | batch_ops_group.setLayout(batch_ops_layout) 1130 | batch_layout.addWidget(batch_ops_group) 1131 | 1132 | # Progress group 1133 | progress_group = QGroupBox("Progress") 1134 | progress_layout = QVBoxLayout() 1135 | 1136 | self.batch_progress_label = QLabel("Ready") 1137 | progress_layout.addWidget(self.batch_progress_label) 1138 | 1139 | progress_group.setLayout(progress_layout) 1140 | batch_layout.addWidget(progress_group) 1141 | 1142 | # Add stretch to push everything to the top 1143 | batch_layout.addStretch() 1144 | 1145 | # Add batch tab 1146 | tab_widget.addTab(batch_tab, "Batch") 1147 | 1148 | # Add widgets to main layout 1149 | main_layout.addWidget(self.scroll_area) 1150 | main_layout.addWidget(tab_widget) 1151 | 1152 | # Add status bar 1153 | self.statusBar().showMessage("Ready") 1154 | 1155 | def center_window(self): 1156 | # Center the window on the screen 1157 | screen_geometry = QApplication.primaryScreen().geometry() 1158 | x = (screen_geometry.width() - self.width()) // 2 1159 | y = (screen_geometry.height() - self.height()) // 2 1160 | self.move(x, y) 1161 | 1162 | def load_spritesheet(self): 1163 | filename, _ = QFileDialog.getOpenFileName( 1164 | self, "Open Sprite Sheet", "", "Image Files (*.png *.jpg *.bmp *.gif)" 1165 | ) 1166 | 1167 | if filename: 1168 | self.sprite_canvas.load_spritesheet(filename) 1169 | self.export_button.setEnabled(True) 1170 | # Enable zoom buttons 1171 | self.zoom_in_button.setEnabled(True) 1172 | self.zoom_out_button.setEnabled(True) 1173 | self.zoom_reset_button.setEnabled(True) 1174 | self.statusBar().showMessage(f"Loaded: {filename}") 1175 | 1176 | def export_selection(self): 1177 | if self.sprite_canvas.sprite_image is None or not self.sprite_canvas.selected_cells: 1178 | QMessageBox.warning(self, "No Selection", "Please select cells to export.") 1179 | return 1180 | 1181 | # Determine export format based on radio button selection 1182 | if self.strip_radio.isChecked(): 1183 | # Export as sprite strip 1184 | filename, _ = QFileDialog.getSaveFileName( 1185 | self, "Save Selection", "", "PNG Files (*.png)" 1186 | ) 1187 | if filename: 1188 | success = self.sprite_canvas.export_selection_as_png(filename) 1189 | if success: 1190 | self.statusBar().showMessage(f"Exported sprite strip to: {filename}") 1191 | else: 1192 | QMessageBox.warning(self, "Export Failed", "Failed to export the sprite strip.") 1193 | 1194 | elif self.frames_radio.isChecked(): 1195 | # Export as individual frames 1196 | directory = QFileDialog.getExistingDirectory( 1197 | self, "Select Export Directory", "", 1198 | QFileDialog.Option.ShowDirsOnly 1199 | ) 1200 | if directory: 1201 | success = self.export_individual_frames(directory) 1202 | if success: 1203 | self.statusBar().showMessage(f"Exported individual frames to: {directory}") 1204 | else: 1205 | QMessageBox.warning(self, "Export Failed", "Failed to export individual frames.") 1206 | 1207 | else: # Animation radio is checked 1208 | # Get file name for saving animation 1209 | filename, filter_used = QFileDialog.getSaveFileName( 1210 | self, "Save Animation", "", "GIF (*.gif);;PNG (*.png)" 1211 | ) 1212 | if filename: 1213 | success = False 1214 | if filter_used == "GIF (*.gif)": 1215 | success = self.sprite_canvas.export_selection_as_gif(filename) 1216 | else: # PNG (APNG) 1217 | success = self.sprite_canvas.export_selection_as_apng(filename) 1218 | 1219 | if success: 1220 | self.statusBar().showMessage(f"Exported animation to: {filename}") 1221 | else: 1222 | QMessageBox.warning(self, "Export Failed", "Failed to export the animation.") 1223 | 1224 | def export_individual_frames(self, directory): 1225 | """Export selected frames as individual PNG files""" 1226 | try: 1227 | if self.sprite_canvas.is_custom_selecting: 1228 | # Export frames in selection order 1229 | for i, (col, row) in enumerate(self.sprite_canvas.custom_frame_selection): 1230 | # Extract the frame 1231 | x = col * self.sprite_canvas.cell_width 1232 | y = row * self.sprite_canvas.cell_height 1233 | frame = self.sprite_canvas.sprite_image.crop( 1234 | (x, y, 1235 | x + self.sprite_canvas.cell_width, 1236 | y + self.sprite_canvas.cell_height) 1237 | ) 1238 | # Save frame 1239 | frame_path = os.path.join(directory, f"frame_{i:03d}.png") 1240 | frame.save(frame_path) 1241 | else: 1242 | # Handle regular selection (row, column, or area) 1243 | cells = sorted(self.sprite_canvas.selected_cells, key=lambda c: (c[1], c[0])) 1244 | for i, (col, row) in enumerate(cells): 1245 | # Extract the frame 1246 | x = col * self.sprite_canvas.cell_width 1247 | y = row * self.sprite_canvas.cell_height 1248 | frame = self.sprite_canvas.sprite_image.crop( 1249 | (x, y, 1250 | x + self.sprite_canvas.cell_width, 1251 | y + self.sprite_canvas.cell_height) 1252 | ) 1253 | # Save frame 1254 | frame_path = os.path.join(directory, f"frame_{i:03d}.png") 1255 | frame.save(frame_path) 1256 | return True 1257 | except Exception as e: 1258 | self.statusBar().showMessage(f"Error exporting frames: {str(e)}") 1259 | return False 1260 | 1261 | def toggle_cell_size_mode(self, state): 1262 | """Toggle between manual cell size and row/column count mode""" 1263 | if state == Qt.CheckState.Checked.value: 1264 | self.manual_size_widget.hide() 1265 | self.count_size_widget.show() 1266 | self.update_cell_size_from_count() 1267 | else: 1268 | self.manual_size_widget.show() 1269 | self.count_size_widget.hide() 1270 | self.update_cell_size() 1271 | 1272 | def update_cell_size_from_count(self): 1273 | """Update cell size based on row and column counts""" 1274 | if not self.sprite_canvas.sprite_image: 1275 | return 1276 | 1277 | img_width = self.sprite_canvas.sprite_image.size[0] 1278 | img_height = self.sprite_canvas.sprite_image.size[1] 1279 | 1280 | cols = self.col_count_spin.value() 1281 | rows = self.row_count_spin.value() 1282 | 1283 | if cols > 0 and rows > 0: 1284 | # Calculate cell size based on image dimensions and row/column counts 1285 | cell_width = img_width // cols 1286 | cell_height = img_height // rows 1287 | 1288 | # Update the manual spinboxes (without triggering their signals) 1289 | self.cell_width_spin.blockSignals(True) 1290 | self.cell_height_spin.blockSignals(True) 1291 | 1292 | self.cell_width_spin.setValue(cell_width) 1293 | self.cell_height_spin.setValue(cell_height) 1294 | 1295 | self.cell_width_spin.blockSignals(False) 1296 | self.cell_height_spin.blockSignals(False) 1297 | 1298 | # Update the canvas 1299 | self.sprite_canvas.cell_width = cell_width 1300 | self.sprite_canvas.cell_height = cell_height 1301 | self.sprite_canvas.update() 1302 | 1303 | def update_cell_size(self): 1304 | """Update cell size based on manual width/height values""" 1305 | if not self.sprite_canvas.sprite_image: 1306 | return 1307 | 1308 | width = self.cell_width_spin.value() 1309 | height = self.cell_height_spin.value() 1310 | 1311 | if width > 0 and height > 0: 1312 | # Calculate row/column counts based on cell size 1313 | img_width = self.sprite_canvas.sprite_image.size[0] 1314 | img_height = self.sprite_canvas.sprite_image.size[1] 1315 | 1316 | cols = img_width // width 1317 | rows = img_height // height 1318 | 1319 | # Update the count spinboxes (without triggering their signals) 1320 | self.row_count_spin.blockSignals(True) 1321 | self.col_count_spin.blockSignals(True) 1322 | 1323 | self.row_count_spin.setValue(rows) 1324 | self.col_count_spin.setValue(cols) 1325 | 1326 | self.row_count_spin.blockSignals(False) 1327 | self.col_count_spin.blockSignals(False) 1328 | 1329 | # Update the canvas 1330 | self.sprite_canvas.cell_width = width 1331 | self.sprite_canvas.cell_height = height 1332 | self.sprite_canvas.update() 1333 | 1334 | def update_padding(self): 1335 | padding = self.padding_spin.value() 1336 | self.sprite_canvas.set_padding(padding) 1337 | 1338 | def apply_padding(self): 1339 | padding = self.padding_spin.value() 1340 | if padding > 0: 1341 | self.sprite_canvas.apply_padding() 1342 | self.cell_width_spin.setValue(self.sprite_canvas.cell_width) 1343 | self.cell_height_spin.setValue(self.sprite_canvas.cell_height) 1344 | self.padding_spin.setValue(0) 1345 | self.statusBar().showMessage(f"Applied padding: {padding} pixels") 1346 | else: 1347 | self.statusBar().showMessage("No padding to apply") 1348 | 1349 | def toggle_grid(self, state): 1350 | """Toggle grid visibility in the sprite canvas""" 1351 | self.sprite_canvas.set_grid_visible(state == Qt.CheckState.Checked.value) 1352 | self.statusBar().showMessage("Grid " + ("shown" if state == Qt.CheckState.Checked.value else "hidden")) 1353 | 1354 | def change_grid_color(self): 1355 | color = QColorDialog.getColor(self.sprite_canvas.grid_color, self) 1356 | if color.isValid(): 1357 | self.sprite_canvas.set_grid_color(color) 1358 | 1359 | def duplicate_row(self): 1360 | """Duplicate the selected row""" 1361 | if not self.sprite_canvas.sprite_image or not self.sprite_canvas.selection_start: 1362 | return 1363 | 1364 | row = self.sprite_canvas.selection_start[1] // self.sprite_canvas.cell_height 1365 | img = self.sprite_canvas.sprite_image 1366 | height = self.sprite_canvas.cell_height 1367 | 1368 | # Create new image with extra row 1369 | new_height = img.size[1] + height 1370 | new_img = Image.new('RGBA', (img.size[0], new_height), (0, 0, 0, 0)) 1371 | 1372 | # Copy original image 1373 | new_img.paste(img, (0, 0)) 1374 | 1375 | # Copy selected row to new position 1376 | row_img = img.crop((0, row * height, img.size[0], (row + 1) * height)) 1377 | new_img.paste(row_img, (0, img.size[1])) 1378 | 1379 | # Update the sprite image 1380 | self.sprite_canvas.sprite_image = new_img 1381 | self.sprite_canvas.update_pixmap() 1382 | self.statusBar().showMessage(f"Duplicated row {row}") 1383 | 1384 | def delete_row(self): 1385 | """Delete the selected row""" 1386 | if not self.sprite_canvas.sprite_image or not self.sprite_canvas.selection_start: 1387 | return 1388 | 1389 | row = self.sprite_canvas.selection_start[1] // self.sprite_canvas.cell_height 1390 | img = self.sprite_canvas.sprite_image 1391 | height = self.sprite_canvas.cell_height 1392 | 1393 | # Create new image without the selected row 1394 | new_height = img.size[1] - height 1395 | new_img = Image.new('RGBA', (img.size[0], new_height), (0, 0, 0, 0)) 1396 | 1397 | # Copy parts before and after the selected row 1398 | if row > 0: 1399 | new_img.paste(img.crop((0, 0, img.size[0], row * height)), (0, 0)) 1400 | if row < (img.size[1] // height - 1): 1401 | new_img.paste(img.crop((0, (row + 1) * height, img.size[0], img.size[1])), 1402 | (0, row * height)) 1403 | 1404 | # Update the sprite image 1405 | self.sprite_canvas.sprite_image = new_img 1406 | self.sprite_canvas.update_pixmap() 1407 | self.statusBar().showMessage(f"Deleted row {row}") 1408 | 1409 | def add_row_before(self): 1410 | """Add a blank row before the selected row""" 1411 | if not self.sprite_canvas.sprite_image or not self.sprite_canvas.selection_start: 1412 | return 1413 | 1414 | row = self.sprite_canvas.selection_start[1] // self.sprite_canvas.cell_height 1415 | img = self.sprite_canvas.sprite_image 1416 | height = self.sprite_canvas.cell_height 1417 | 1418 | # Create new image with extra row 1419 | new_height = img.size[1] + height 1420 | new_img = Image.new('RGBA', (img.size[0], new_height), (0, 0, 0, 0)) 1421 | 1422 | # Copy parts before and after the insertion point 1423 | if row > 0: 1424 | new_img.paste(img.crop((0, 0, img.size[0], row * height)), (0, 0)) 1425 | new_img.paste(img.crop((0, row * height, img.size[0], img.size[1])), 1426 | (0, (row + 1) * height)) 1427 | 1428 | # Update the sprite image 1429 | self.sprite_canvas.sprite_image = new_img 1430 | self.sprite_canvas.update_pixmap() 1431 | self.statusBar().showMessage(f"Added blank row before row {row}") 1432 | 1433 | def add_row_after(self): 1434 | """Add a blank row after the selected row""" 1435 | if not self.sprite_canvas.sprite_image or not self.sprite_canvas.selection_start: 1436 | return 1437 | 1438 | row = self.sprite_canvas.selection_start[1] // self.sprite_canvas.cell_height 1439 | img = self.sprite_canvas.sprite_image 1440 | height = self.sprite_canvas.cell_height 1441 | 1442 | # Create new image with extra row 1443 | new_height = img.size[1] + height 1444 | new_img = Image.new('RGBA', (img.size[0], new_height), (0, 0, 0, 0)) 1445 | 1446 | # Copy parts before and after the insertion point 1447 | new_img.paste(img.crop((0, 0, img.size[0], (row + 1) * height)), (0, 0)) 1448 | if row < (img.size[1] // height - 1): 1449 | new_img.paste(img.crop((0, (row + 1) * height, img.size[0], img.size[1])), 1450 | ((row + 2) * height)) 1451 | 1452 | # Update the sprite image 1453 | self.sprite_canvas.sprite_image = new_img 1454 | self.sprite_canvas.update_pixmap() 1455 | self.statusBar().showMessage(f"Added blank row after row {row}") 1456 | 1457 | def export_row(self): 1458 | """Export the selected row as a new sprite sheet""" 1459 | if not self.sprite_canvas.sprite_image or not self.sprite_canvas.selection_start: 1460 | return 1461 | 1462 | row = self.sprite_canvas.selection_start[1] // self.sprite_canvas.cell_height 1463 | img = self.sprite_canvas.sprite_image 1464 | height = self.sprite_canvas.cell_height 1465 | 1466 | # Extract the row 1467 | row_img = img.crop((0, row * height, img.size[0], (row + 1) * height)) 1468 | 1469 | # Get save filename 1470 | filename, _ = QFileDialog.getSaveFileName(self, "Save Row", 1471 | "", "PNG Files (*.png);;All Files (*)") 1472 | if filename: 1473 | row_img.save(filename) 1474 | self.statusBar().showMessage(f"Row exported to {filename}") 1475 | 1476 | def duplicate_column(self): 1477 | """Duplicate the selected column""" 1478 | if not self.sprite_canvas.sprite_image or not self.sprite_canvas.selection_start: 1479 | return 1480 | 1481 | col = self.sprite_canvas.selection_start[0] // self.sprite_canvas.cell_width 1482 | img = self.sprite_canvas.sprite_image 1483 | width = self.sprite_canvas.cell_width 1484 | 1485 | # Create new image with extra column 1486 | new_width = img.size[0] + width 1487 | new_img = Image.new('RGBA', (new_width, img.size[1]), (0, 0, 0, 0)) 1488 | 1489 | # Copy original image 1490 | new_img.paste(img, (0, 0)) 1491 | 1492 | # Copy selected column to new position 1493 | col_img = img.crop((col * width, 0, (col + 1) * width, img.size[1])) 1494 | new_img.paste(col_img, (img.size[0], 0)) 1495 | 1496 | # Update the sprite image 1497 | self.sprite_canvas.sprite_image = new_img 1498 | self.sprite_canvas.update_pixmap() 1499 | self.statusBar().showMessage(f"Duplicated column {col}") 1500 | 1501 | def delete_column(self): 1502 | """Delete the selected column""" 1503 | if not self.sprite_canvas.sprite_image or not self.sprite_canvas.selection_start: 1504 | return 1505 | 1506 | col = self.sprite_canvas.selection_start[0] // self.sprite_canvas.cell_width 1507 | img = self.sprite_canvas.sprite_image 1508 | width = self.sprite_canvas.cell_width 1509 | 1510 | # Create new image without the selected column 1511 | new_width = img.size[0] - width 1512 | new_height = img.size[1] 1513 | new_img = Image.new('RGBA', (new_width, new_height), (0, 0, 0, 0)) 1514 | 1515 | # Copy parts before and after the selected column 1516 | if col > 0: 1517 | new_img.paste(img.crop((0, 0, col * width, img.size[1])), (0, 0)) 1518 | if col < (img.size[0] // width - 1): 1519 | new_img.paste(img.crop(((col + 1) * width, 0, img.size[0], img.size[1])), 1520 | (col * width, 0)) 1521 | 1522 | # Update the sprite image 1523 | self.sprite_canvas.sprite_image = new_img 1524 | self.sprite_canvas.update_pixmap() 1525 | self.statusBar().showMessage(f"Deleted column {col}") 1526 | 1527 | def add_column_before(self): 1528 | """Add a blank column before the selected column""" 1529 | if not self.sprite_canvas.sprite_image or not self.sprite_canvas.selection_start: 1530 | return 1531 | 1532 | col = self.sprite_canvas.selection_start[0] // self.sprite_canvas.cell_width 1533 | img = self.sprite_canvas.sprite_image 1534 | width = self.sprite_canvas.cell_width 1535 | 1536 | # Create new image with extra column 1537 | new_width = img.size[0] + width 1538 | new_img = Image.new('RGBA', (new_width, img.size[1]), (0, 0, 0, 0)) 1539 | 1540 | # Copy parts before and after the insertion point 1541 | if col > 0: 1542 | new_img.paste(img.crop((0, 0, col * width, img.size[1])), (0, 0)) 1543 | new_img.paste(img.crop((col * width, 0, img.size[0], img.size[1])), 1544 | ((col + 1) * width, 0)) 1545 | 1546 | # Update the sprite image 1547 | self.sprite_canvas.sprite_image = new_img 1548 | self.sprite_canvas.update_pixmap() 1549 | self.statusBar().showMessage(f"Added blank column before column {col}") 1550 | 1551 | def add_column_after(self): 1552 | """Add a blank column after the selected column""" 1553 | if not self.sprite_canvas.sprite_image or not self.sprite_canvas.selection_start: 1554 | return 1555 | 1556 | col = self.sprite_canvas.selection_start[0] // self.sprite_canvas.cell_width 1557 | img = self.sprite_canvas.sprite_image 1558 | width = self.sprite_canvas.cell_width 1559 | 1560 | # Create new image with extra column 1561 | new_width = img.size[0] + width 1562 | new_img = Image.new('RGBA', (new_width, img.size[1]), (0, 0, 0, 0)) 1563 | 1564 | # Copy parts before and after the insertion point 1565 | new_img.paste(img.crop((0, 0, (col + 1) * width, img.size[1])), (0, 0)) 1566 | if col < (img.size[0] // width - 1): 1567 | new_img.paste(img.crop(((col + 1) * width, 0, img.size[0], img.size[1])), 1568 | ((col + 2) * width, 0)) 1569 | 1570 | # Update the sprite image 1571 | self.sprite_canvas.sprite_image = new_img 1572 | self.sprite_canvas.update_pixmap() 1573 | self.statusBar().showMessage(f"Added blank column after column {col}") 1574 | 1575 | def export_column(self): 1576 | """Export the selected column as a new sprite sheet""" 1577 | if not self.sprite_canvas.sprite_image or not self.sprite_canvas.selection_start: 1578 | return 1579 | 1580 | col = self.sprite_canvas.selection_start[0] // self.sprite_canvas.cell_width 1581 | img = self.sprite_canvas.sprite_image 1582 | width = self.sprite_canvas.cell_width 1583 | 1584 | # Extract the column 1585 | col_img = img.crop((col * width, 0, (col + 1) * width, img.size[1])) 1586 | 1587 | # Get save filename 1588 | filename, _ = QFileDialog.getSaveFileName(self, "Save Column", 1589 | "", "PNG Files (*.png);;All Files (*)") 1590 | if filename: 1591 | col_img.save(filename) 1592 | self.statusBar().showMessage(f"Column exported to {filename}") 1593 | 1594 | def duplicate_frame(self): 1595 | """Duplicate the selected frame""" 1596 | if not self.sprite_canvas.sprite_image or not self.sprite_canvas.selection_start: 1597 | return 1598 | 1599 | col = self.sprite_canvas.selection_start[0] // self.sprite_canvas.cell_width 1600 | row = self.sprite_canvas.selection_start[1] // self.sprite_canvas.cell_height 1601 | img = self.sprite_canvas.sprite_image 1602 | width = self.sprite_canvas.cell_width 1603 | height = self.sprite_canvas.cell_height 1604 | 1605 | # Extract the frame 1606 | frame = img.crop((col * width, row * height, (col + 1) * width, (row + 1) * height)) 1607 | 1608 | # Create new image with space for the duplicated frame 1609 | new_width = img.size[0] + width 1610 | new_img = Image.new('RGBA', (new_width, img.size[1]), (0, 0, 0, 0)) 1611 | 1612 | # Copy original image 1613 | new_img.paste(img, (0, 0)) 1614 | 1615 | # Add duplicated frame at the end of the row 1616 | new_img.paste(frame, (img.size[0], row * height)) 1617 | 1618 | # Update the sprite image 1619 | self.sprite_canvas.sprite_image = new_img 1620 | self.sprite_canvas.update_pixmap() 1621 | self.statusBar().showMessage(f"Duplicated frame at ({col}, {row})") 1622 | 1623 | def delete_frame(self): 1624 | """Delete the selected frame""" 1625 | if not self.sprite_canvas.sprite_image or not self.sprite_canvas.selection_start: 1626 | return 1627 | 1628 | col = self.sprite_canvas.selection_start[0] // self.sprite_canvas.cell_width 1629 | row = self.sprite_canvas.selection_start[1] // self.sprite_canvas.cell_height 1630 | img = self.sprite_canvas.sprite_image 1631 | width = self.sprite_canvas.cell_width 1632 | height = self.sprite_canvas.cell_height 1633 | 1634 | # Create new image without the selected frame 1635 | new_width = img.size[0] - width 1636 | new_img = Image.new('RGBA', (new_width, img.size[1]), (0, 0, 0, 0)) 1637 | 1638 | # Copy all frames except the selected one 1639 | if col > 0: 1640 | new_img.paste(img.crop((0, 0, col * width, img.size[1])), (0, 0)) 1641 | if col < (img.size[0] // width - 1): 1642 | new_img.paste(img.crop(((col + 1) * width, 0, img.size[0], img.size[1])), 1643 | (col * width, 0)) 1644 | 1645 | # Update the sprite image 1646 | self.sprite_canvas.sprite_image = new_img 1647 | self.sprite_canvas.update_pixmap() 1648 | self.statusBar().showMessage(f"Deleted frame at ({col}, {row})") 1649 | 1650 | def export_frame(self): 1651 | """Export the selected frame as an individual image file""" 1652 | if not self.sprite_canvas.sprite_image or not self.sprite_canvas.selection_start: 1653 | return 1654 | 1655 | col = self.sprite_canvas.selection_start[0] // self.sprite_canvas.cell_width 1656 | row = self.sprite_canvas.selection_start[1] // self.sprite_canvas.cell_height 1657 | img = self.sprite_canvas.sprite_image 1658 | width = self.sprite_canvas.cell_width 1659 | height = self.sprite_canvas.cell_height 1660 | 1661 | # Extract the frame 1662 | frame = img.crop((col * width, row * height, (col + 1) * width, (row + 1) * height)) 1663 | 1664 | # Get save filename 1665 | filename, _ = QFileDialog.getSaveFileName(self, "Save Frame", 1666 | "", "PNG Files (*.png);;All Files (*)") 1667 | if filename: 1668 | frame.save(filename) 1669 | self.statusBar().showMessage(f"Frame exported to {filename}") 1670 | 1671 | def zoom_in(self): 1672 | """Handle zoom in button click""" 1673 | new_zoom = self.sprite_canvas.zoom_in() 1674 | if new_zoom: 1675 | self.zoom_label.setText(f"Zoom: {int(new_zoom)}x") 1676 | # Update scroll area to adjust scroll bars 1677 | self.scroll_area.setWidgetResizable(False) 1678 | self.scroll_area.updateGeometry() 1679 | self.statusBar().showMessage(f"Zoomed in to {int(new_zoom)}x") 1680 | 1681 | def zoom_out(self): 1682 | """Handle zoom out button click""" 1683 | new_zoom = self.sprite_canvas.zoom_out() 1684 | if new_zoom: 1685 | self.zoom_label.setText(f"Zoom: {int(new_zoom)}x") 1686 | # Update scroll area to adjust scroll bars 1687 | self.scroll_area.setWidgetResizable(False) 1688 | self.scroll_area.updateGeometry() 1689 | self.statusBar().showMessage(f"Zoomed out to {int(new_zoom)}x") 1690 | 1691 | def zoom_reset(self): 1692 | """Handle zoom reset button click""" 1693 | new_zoom = self.sprite_canvas.zoom_reset() 1694 | self.zoom_label.setText(f"Zoom: {int(new_zoom)}x") 1695 | # Update scroll area to adjust scroll bars 1696 | self.scroll_area.setWidgetResizable(False) 1697 | self.scroll_area.updateGeometry() 1698 | self.statusBar().showMessage("Zoom reset to 1x") 1699 | 1700 | def update_button_states(self): 1701 | """Update the enabled/disabled state of manipulation buttons based on selection""" 1702 | has_image = self.sprite_canvas.sprite_image is not None 1703 | has_selection = self.sprite_canvas.selection_start is not None and self.sprite_canvas.selection_end is not None 1704 | 1705 | # Find all manipulation buttons and update their states 1706 | for group in self.findChildren(QGroupBox): 1707 | if group.title() in ["Row Operations", "Column Operations", "Frame Operations"]: 1708 | for button in group.findChildren(QPushButton): 1709 | button.setEnabled(has_image and has_selection) 1710 | 1711 | def update_selection_label(self): 1712 | """Update the selection info label""" 1713 | if not self.sprite_canvas.sprite_image or not self.sprite_canvas.selection_start: 1714 | self.selection_label.setText("No selection") 1715 | return 1716 | 1717 | start_row = self.sprite_canvas.selection_start[1] // self.sprite_canvas.cell_height 1718 | end_row = self.sprite_canvas.selection_end[1] // self.sprite_canvas.cell_height 1719 | start_col = self.sprite_canvas.selection_start[0] // self.sprite_canvas.cell_width 1720 | end_col = self.sprite_canvas.selection_end[0] // self.sprite_canvas.cell_width 1721 | 1722 | if start_row == end_row and start_col == end_col: 1723 | self.selection_label.setText(f"Selected frame: ({start_col}, {start_row})") 1724 | elif start_row == end_row: 1725 | self.selection_label.setText(f"Selected row: {start_row}") 1726 | elif start_col == end_col: 1727 | self.selection_label.setText(f"Selected column: {start_col}") 1728 | else: 1729 | self.selection_label.setText(f"Selected area: ({start_col}, {start_row}) to ({end_col}, {end_row})") 1730 | 1731 | def select_input_folder(self): 1732 | """Open folder selection dialog for batch processing""" 1733 | folder = QFileDialog.getExistingDirectory( 1734 | self, 1735 | "Select Input Folder", 1736 | "", 1737 | QFileDialog.Option.ShowDirsOnly 1738 | ) 1739 | 1740 | if folder: 1741 | self.input_folder_label.setText(folder) 1742 | self.process_batch_btn.setEnabled(True) 1743 | self.statusBar().showMessage(f"Selected folder: {folder}") 1744 | 1745 | def process_batch(self): 1746 | """Process all sprite sheets in the selected folder""" 1747 | input_folder = self.input_folder_label.text() 1748 | if input_folder == "No folder selected": 1749 | return 1750 | 1751 | # Get processing options 1752 | cell_width = self.batch_cell_width_spin.value() 1753 | cell_height = self.batch_cell_height_spin.value() 1754 | padding = self.batch_padding_spin.value() 1755 | export_frames = self.export_frames_cb.isChecked() 1756 | export_rows = self.export_rows_cb.isChecked() 1757 | export_gif = self.export_gif_cb.isChecked() 1758 | export_apng = self.export_apng_cb.isChecked() 1759 | include_subfolders = self.include_subfolders_cb.isChecked() 1760 | 1761 | # Create output folder 1762 | output_folder = os.path.join(input_folder, "processed") 1763 | os.makedirs(output_folder, exist_ok=True) 1764 | 1765 | # Get list of files to process 1766 | if include_subfolders: 1767 | sprite_files = [] 1768 | for root, _, files in os.walk(input_folder): 1769 | for file in files: 1770 | if file.lower().endswith(('.png', '.jpg', '.bmp', '.gif')): 1771 | sprite_files.append(os.path.join(root, file)) 1772 | else: 1773 | sprite_files = [ 1774 | os.path.join(input_folder, f) for f in os.listdir(input_folder) 1775 | if f.lower().endswith(('.png', '.jpg', '.bmp', '.gif')) 1776 | ] 1777 | 1778 | if not sprite_files: 1779 | self.batch_progress_label.setText("No sprite sheets found") 1780 | return 1781 | 1782 | # Process each file 1783 | total_files = len(sprite_files) 1784 | for i, file_path in enumerate(sprite_files, 1): 1785 | self.batch_progress_label.setText(f"Processing {i}/{total_files}: {os.path.basename(file_path)}") 1786 | self.statusBar().showMessage(f"Processing {os.path.basename(file_path)}") 1787 | QApplication.processEvents() # Update UI 1788 | 1789 | try: 1790 | # Load sprite sheet 1791 | img = Image.open(file_path) 1792 | 1793 | # Apply padding if needed 1794 | if padding > 0: 1795 | # Calculate cells 1796 | cols = img.size[0] // cell_width 1797 | rows = img.size[1] // cell_height 1798 | 1799 | # Create padded image 1800 | padded_width = cols * (cell_width + 2 * padding) 1801 | padded_height = rows * (cell_height + 2 * padding) 1802 | padded_img = Image.new(img.mode, (padded_width, padded_height), (0, 0, 0, 0)) 1803 | 1804 | # Copy cells with padding 1805 | for row in range(rows): 1806 | for col in range(cols): 1807 | src_x = col * cell_width 1808 | src_y = row * cell_height 1809 | dst_x = col * (cell_width + 2 * padding) + padding 1810 | dst_y = row * (cell_height + 2 * padding) + padding 1811 | 1812 | cell = img.crop( 1813 | (src_x, src_y, src_x + cell_width, src_y + cell_height) 1814 | ) 1815 | padded_img.paste(cell, (dst_x, dst_y)) 1816 | 1817 | img = padded_img 1818 | cell_width += 2 * padding 1819 | cell_height += 2 * padding 1820 | 1821 | # Create output subfolder matching input structure 1822 | rel_path = os.path.relpath(os.path.dirname(file_path), input_folder) 1823 | curr_output_folder = os.path.join(output_folder, rel_path) 1824 | os.makedirs(curr_output_folder, exist_ok=True) 1825 | 1826 | base_name = os.path.splitext(os.path.basename(file_path))[0] 1827 | 1828 | # Export individual frames if requested 1829 | if export_frames: 1830 | frames_folder = os.path.join(curr_output_folder, f"{base_name}_frames") 1831 | os.makedirs(frames_folder, exist_ok=True) 1832 | 1833 | cols = img.size[0] // cell_width 1834 | rows = img.size[1] // cell_height 1835 | frame_count = 0 1836 | 1837 | for row in range(rows): 1838 | for col in range(cols): 1839 | frame = img.crop( 1840 | (col * cell_width, row * cell_height, 1841 | (col + 1) * cell_width, (row + 1) * cell_height)) 1842 | frame.save(os.path.join(frames_folder, f"frame_{frame_count:03d}.png")) 1843 | frame_count += 1 1844 | 1845 | # Export rows if requested 1846 | if export_rows or export_gif or export_apng: 1847 | rows_folder = os.path.join(curr_output_folder, f"{base_name}_rows") 1848 | os.makedirs(rows_folder, exist_ok=True) 1849 | 1850 | rows = img.size[1] // cell_height 1851 | cols = img.size[0] // cell_width 1852 | 1853 | for row in range(rows): 1854 | # Export row as strip if requested 1855 | if export_rows: 1856 | row_img = img.crop( 1857 | (0, row * cell_height, 1858 | img.size[0], (row + 1) * cell_height)) 1859 | row_img.save(os.path.join(rows_folder, f"row_{row:03d}.png")) 1860 | 1861 | # Export as GIF if requested 1862 | if export_gif: 1863 | frames = [] 1864 | for col in range(cols): 1865 | frame = img.crop( 1866 | (col * cell_width, row * cell_height, 1867 | (col + 1) * cell_width, (row + 1) * cell_height)) 1868 | # Convert frame to RGBA if it isn't already 1869 | if frame.mode != 'RGBA': 1870 | frame = frame.convert('RGBA') 1871 | frames.append(frame) 1872 | 1873 | if frames: 1874 | try: 1875 | gif_path = os.path.join(rows_folder, f"row_{row:03d}.gif") 1876 | frames[0].save( 1877 | gif_path, 1878 | format='GIF', 1879 | append_images=frames[1:], 1880 | save_all=True, 1881 | duration=100, 1882 | loop=0, 1883 | transparency=0, 1884 | disposal=2 # Clear previous frame 1885 | ) 1886 | self.statusBar().showMessage(f"Created GIF: {os.path.basename(gif_path)}") 1887 | except Exception as e: 1888 | self.statusBar().showMessage(f"Error creating GIF for row {row}: {str(e)}") 1889 | 1890 | # Export as APNG if requested 1891 | if export_apng: 1892 | frames = [] 1893 | for col in range(cols): 1894 | frame = img.crop( 1895 | (col * cell_width, row * cell_height, 1896 | (col + 1) * cell_width, (row + 1) * cell_height)) 1897 | # Convert frame to RGBA if it isn't already 1898 | if frame.mode != 'RGBA': 1899 | frame = frame.convert('RGBA') 1900 | frames.append(np.array(frame)) 1901 | 1902 | if frames: 1903 | try: 1904 | apng_path = os.path.join(rows_folder, f"row_{row:03d}.png") 1905 | # Save as animated PNG with proper animation settings 1906 | imageio.mimsave( 1907 | apng_path, 1908 | frames, 1909 | format='APNG', 1910 | fps=10, # 10 frames per second 1911 | loop=0, # Loop forever 1912 | duration=100 # 100ms per frame 1913 | ) 1914 | self.statusBar().showMessage(f"Created animated PNG for row {row}") 1915 | except Exception as e: 1916 | self.statusBar().showMessage(f"Error creating animated PNG for row {row}: {str(e)}") 1917 | continue 1918 | 1919 | except Exception as e: 1920 | error_msg = f"Error processing {os.path.basename(file_path)}: {str(e)}" 1921 | self.statusBar().showMessage(error_msg) 1922 | self.batch_progress_label.setText(error_msg) 1923 | continue 1924 | 1925 | self.batch_progress_label.setText("Processing complete") 1926 | self.statusBar().showMessage("Batch processing complete") 1927 | 1928 | 1929 | def main(): 1930 | app = QApplication(sys.argv) 1931 | window = SpriteToolz() 1932 | window.show() 1933 | sys.exit(app.exec()) 1934 | 1935 | 1936 | if __name__ == "__main__": 1937 | main() --------------------------------------------------------------------------------