""" Image Slicer Application This PyQt6-based application merges and slices images from folders. It can process multiple folders containing images, merge them vertically, and slice them into specified dimensions. Features: - Browse and select image folders - Set custom width and height for slices (in pixels) - Option to save combined images before slicing - Option to skip merging and just scan folders - Progress tracking with visual progress bar - Comprehensive logging system - Save logs to files Author: [Your Name] Date: [Date] Version: 1.0 """ import sys import os import datetime import subprocess import re import math import time from PyQt6.QtWidgets import ( QApplication, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QLineEdit, QFileDialog, QProgressBar, QTextEdit, QMessageBox, QCheckBox ) from PyQt6.QtCore import Qt, QTimer from PIL import Image class ImageSlicer(QWidget): """ Main application class for the Image Slicer GUI. This class handles the user interface and coordinates all image processing operations. It provides functionality to merge multiple images vertically and slice them into smaller pieces based on user-defined dimensions. """ def __init__(self): """ Initialize the ImageSlicer application. Sets up initial state variables and UI components. """ super().__init__() # Processing state variables self.is_slicing = False # Flag to track if slicing operation is in progress self.progress_target = 0 # Target progress value for smooth progress bar animation # Timer for smooth progress bar updates self.progress_timer = QTimer(self) self.progress_timer.timeout.connect(self.update_progress_bar) # Initialize the user interface self.initUI() def initUI(self): """ Initialize and set up the user interface components. Creates all UI elements including: - Folder selection controls - Dimension input fields - Checkboxes for options - Progress bar - Log display area - Action buttons """ self.setWindowTitle('Image Slicer') # Main layout containers main_layout = QVBoxLayout() # Primary vertical layout form_layout = QVBoxLayout() # Layout for form elements button_layout = QHBoxLayout() # Layout for action buttons # === FOLDER SELECTION SECTION === folder_layout = QHBoxLayout() self.folder_label = QLabel('Image Folder:') self.folder_path = QLineEdit() # Text field to display selected folder path self.folder_button = QPushButton('Browse') self.folder_button.clicked.connect(self.browse_folder) # Add folder selection components to layout folder_layout.addWidget(self.folder_label) folder_layout.addWidget(self.folder_path) folder_layout.addWidget(self.folder_button) form_layout.addLayout(folder_layout) # === DIMENSION INPUT SECTION === dimensions_layout = QHBoxLayout() self.width_label = QLabel('Width (px):') self.width_input = QLineEdit('0') # Default width of 0 means auto-detect self.height_label = QLabel('Height (px):') self.height_input = QLineEdit() # Height must be specified by user # Add dimension input components to layout dimensions_layout.addWidget(self.width_label) dimensions_layout.addWidget(self.width_input) dimensions_layout.addWidget(self.height_label) dimensions_layout.addWidget(self.height_input) form_layout.addLayout(dimensions_layout) # === OPTIONS CHECKBOXES SECTION === checkbox_layout = QHBoxLayout() # Checkbox to save the combined image before slicing self.save_combined_checkbox = QCheckBox("Save Combined Image") self.save_combined_checkbox.setChecked(False) # Checkbox to skip image merging and just scan folders self.skip_merge_checkbox = QCheckBox("Skip Image Merging") self.skip_merge_checkbox.stateChanged.connect(self.update_save_combined_state) # Add checkboxes to layout checkbox_layout.addWidget(self.save_combined_checkbox) checkbox_layout.addWidget(self.skip_merge_checkbox) form_layout.addLayout(checkbox_layout) # === PROGRESS BAR SECTION === self.progress_bar = QProgressBar() # Custom styling for orange progress bar self.progress_bar.setStyleSheet(""" QProgressBar::chunk { background-color: orange; } """) form_layout.addWidget(self.progress_bar) # === LOG DISPLAY SECTION === self.log_screen = QTextEdit() self.log_screen.setReadOnly(True) # Make log area read-only form_layout.addWidget(self.log_screen) # === ACTION BUTTONS SECTION === self.start_button = QPushButton('Start') self.start_button.clicked.connect(self.toggle_slicing) self.save_log_button = QPushButton('Save Log') self.save_log_button.clicked.connect(self.save_log) # Add buttons to layout button_layout.addWidget(self.start_button) button_layout.addWidget(self.save_log_button) # === ASSEMBLE MAIN LAYOUT === main_layout.addLayout(form_layout) main_layout.addLayout(button_layout) self.setLayout(main_layout) def browse_folder(self): """ Open a folder selection dialog and update the folder path field. This method is called when the 'Browse' button is clicked. It prevents folder selection during active processing operations. """ # Prevent folder selection during processing if self.is_slicing: return # Open folder selection dialog folder = QFileDialog.getExistingDirectory(self, 'Select Folder') if folder: # Update the folder path field and log the selection self.folder_path.setText(folder) self.log(f"Selected folder: {folder}") def update_save_combined_state(self): """ Update the state of the 'Save Combined Image' checkbox. When 'Skip Image Merging' is checked, the 'Save Combined Image' option becomes disabled since there's no combined image to save. """ is_checked = self.skip_merge_checkbox.isChecked() # Disable/enable the save combined checkbox based on skip merge state self.save_combined_checkbox.setEnabled(not is_checked) # Uncheck save combined if skip merge is enabled if is_checked: self.save_combined_checkbox.setChecked(False) def log(self, message): """ Add a message to the log display area. Args: message (str): The message to log This method also processes Qt events to keep the UI responsive during long-running operations. """ self.log_screen.append(message) QApplication.processEvents() # Keep UI responsive during logging def cm_to_pixels(self, cm): """ Convert centimeters to pixels using standard DPI conversion. Args: cm (float): Length in centimeters Returns: int: Equivalent length in pixels (96 DPI standard) Note: This function is kept for potential future use but is not currently used since the application now works with pixels directly. """ return int(cm * 96 / 2.54) def transform_folder_name(self, folder_name): """ Transform folder names with Korean naming patterns. Args: folder_name (str): Original folder name Returns: str: Transformed folder name Example: Input: "C1_이 시국에 개인교습 의 06화" Output: "C1_이 시국에 개인교_06화" Note: This function handles specific Korean manga/webtoon naming conventions. """ # Check for Korean naming pattern with ' 의 ' separator if ' 의 ' in folder_name: parts = folder_name.split(' 의 ') prefix_part = parts[0] episode_part = parts[1] # Remove trailing '습' character if present if prefix_part.endswith('습'): prefix_part = prefix_part[:-1] return f"{prefix_part}_{episode_part}" # Return unchanged if pattern not found return folder_name def create_combined_folder_name(self, root_folder_path, subfolder_path): """ Create a combined folder name using root and subfolder names. Args: root_folder_path (str): Path to the root folder (user-selected) subfolder_path (str): Path to the subfolder being processed Returns: str: Combined folder name in format "RootName_SubfolderName" Example: Root: "/Users/MyImages" Subfolder: "/Users/MyImages/Chapter1" Result: "MyImages_Chapter1" """ root_folder_name = os.path.basename(root_folder_path) subfolder_name = os.path.basename(subfolder_path) combined_name = f"{root_folder_name}_{subfolder_name}" return combined_name def open_folder(self, path): """ Open a folder in the system's default file manager. Args: path (str): Path to the folder to open This method handles cross-platform folder opening: - Windows: uses os.startfile() - macOS: uses 'open' command - Linux: uses 'xdg-open' command """ if sys.platform == "win32": os.startfile(path) elif sys.platform == "darwin": subprocess.Popen(["open", path]) else: subprocess.Popen(["xdg-open", path]) def toggle_slicing(self, checked=False): """ Toggle between starting and stopping the slicing process. Args: checked (bool): Unused parameter from signal connection This method serves as the main controller for the Start/Stop button. """ if self.is_slicing: self.stop_slicing() else: self.start_slicing() def stop_slicing(self): """ Stop the current slicing operation. Sets the slicing flag to False, which causes the processing loops to exit gracefully at their next iteration. """ self.is_slicing = False self.log("Stopping process...") def update_progress_bar(self): """ Update the progress bar value smoothly towards the target. This method is called by a timer to create smooth progress bar animations. It gradually moves the current value towards the target value. """ current_value = self.progress_bar.value() if current_value < self.progress_target: # Increment progress bar value self.progress_bar.setValue(current_value + 1) elif current_value > self.progress_target: # Jump directly to target if we've overshot self.progress_bar.setValue(self.progress_target) def save_folder_paths_to_info_txt(self, folder_path): """ Scan for folders containing images and save their paths to info.txt. Args: folder_path (str): Root folder path to scan This method is used when "Skip Image Merging" is enabled. It finds all subfolders containing image files and saves their paths to an info.txt file for reference. """ self.log("Finding all subdirectories with images...") self.progress_target = 20 folders_with_images = [] # Walk through all subdirectories for root, dirs, files in os.walk(folder_path): QApplication.processEvents() # Keep UI responsive during scanning # Skip folders that are processing results if os.path.basename(root).endswith(('_combined', '_log')): continue # Check if folder contains image files image_extensions = ('.png', '.jpg', '.jpeg', '.bmp', '.gif') if any(f.lower().endswith(image_extensions) for f in files): folders_with_images.append(root) # Handle case where no image folders are found if not folders_with_images: self.log("No folders with images found.") self.progress_target = 100 return self.log(f"Found {len(folders_with_images)} folders. Saving to info.txt...") self.progress_target = 60 # Write folder paths to info.txt info_file_path = os.path.join(folder_path, "info.txt") try: with open(info_file_path, 'w', encoding='utf-8') as f: for folder in sorted(folders_with_images): f.write(f"{folder}\n") self.log(f"Successfully saved paths to info.txt (overwrote previous content)") except Exception as e: self.log(f"Error saving info.txt: {e}") self.progress_target = 100 def merge_and_slice_images(self, folder_path, slice_width_px, slice_height_px, save_combined): """ Main image processing method that merges and slices images. Args: folder_path (str): Root folder containing image folders slice_width_px (int): Target width for slices in pixels (0 = auto) slice_height_px (int): Target height for slices in pixels save_combined (bool): Whether to save the combined image This method: 1. Finds all folders containing images 2. For each folder, merges all images vertically 3. Slices the combined image into pieces of specified dimensions 4. Saves results with combined folder names """ self.log("Finding folders to process...") self.progress_target = 5 QApplication.processEvents() folders_to_process = [] processed_result_folders = [] # Track processed folders for info.txt # Check if the root folder itself contains images image_extensions = ('.png', '.jpg', '.jpeg', '.bmp', '.gif') if any(f.lower().endswith(image_extensions) for f in os.listdir(folder_path)): folders_to_process.append(folder_path) # Find subfolders containing images for item in os.listdir(folder_path): item_path = os.path.join(folder_path, item) if os.path.isdir(item_path): if any(f.lower().endswith(image_extensions) for f in os.listdir(item_path)): folders_to_process.append(item_path) self.progress_target = 10 # Exit if no folders with images found if not folders_to_process: self.log("No folders with images found to process.") self.progress_target = 100 return # Calculate progress increment per folder total_folders = len(folders_to_process) progress_per_folder = 90 / total_folders try: # Process each folder containing images for i, current_folder in enumerate(folders_to_process): # Check if user requested to stop if not self.is_slicing: break # Calculate base progress for this folder base_progress = 10 + (i * progress_per_folder) self.log(f"--- Processing folder: {os.path.basename(current_folder)} ---") self.progress_target = int(base_progress) # === STEP 1: GATHER AND SORT IMAGE FILES === image_files = [ os.path.join(current_folder, f) for f in os.listdir(current_folder) if f.lower().endswith(image_extensions) ] # Sort files naturally with proper zero-padding handling def natural_sort_key(s): """ Enhanced natural sorting that properly handles zero-padded numbers. Example: - "file_01.jpg" comes before "file_1.jpg" - "file_1.jpg" comes before "file_2.jpg" - "file_2.jpg" comes before "file_10.jpg" """ parts = re.split('([0-9]+)', s) result = [] for part in parts: if part.isdigit(): # For numeric parts, use (numeric_value, original_string, length) # This ensures proper ordering: 01 < 1 < 02 < 2 < 10 numeric_value = int(part) original_length = len(part) result.append((0, numeric_value, original_length, part)) else: # For text parts, use simple string comparison result.append((1, part.lower(), 0, part)) return result image_files.sort(key=natural_sort_key) # Skip folder if no images found if not image_files: continue self.log(f"Merging {len(image_files)} images...") self.progress_target = int(base_progress + (progress_per_folder * 0.2)) # === STEP 2: SET UP SLICE DIMENSIONS === slice_height_px = slice_height_px # Height for each slice target_width_px = slice_width_px if slice_width_px > 0 else 0 # 0 = auto-detect # === STEP 3: PROCESS AND RESIZE IMAGES === processed_images = [] first_image_width = 0 # Store width of first image for auto-detection for image_path in image_files: # Check if user requested to stop if not self.is_slicing: break # Load image img = Image.open(image_path) # Determine target width if target_width_px == 0: # Auto-detect width from first image if first_image_width == 0: first_image_width = img.width current_target_width = first_image_width else: # Use specified width current_target_width = target_width_px # Resize image if width doesn't match target if img.width != current_target_width: aspect_ratio = img.height / img.width new_height = int(current_target_width * aspect_ratio) img = img.resize((current_target_width, new_height), Image.Resampling.LANCZOS) processed_images.append(img) QApplication.processEvents() # Keep UI responsive # Exit if user stopped during processing if not self.is_slicing: break self.progress_target = int(base_progress + (progress_per_folder * 0.6)) # === STEP 4: MERGE IMAGES VERTICALLY === total_height = sum(p_img.height for p_img in processed_images) combined_image = Image.new('RGB', (processed_images[0].width, total_height)) current_height = 0 for p_img in processed_images: # Paste each image below the previous one combined_image.paste(p_img, (0, current_height)) current_height += p_img.height # === STEP 5: CREATE OUTPUT FOLDER NAME === if current_folder == folder_path: # Processing root folder itself result_folder_name = os.path.basename(folder_path) else: # Processing subfolder - combine root and subfolder names result_folder_name = self.create_combined_folder_name(folder_path, current_folder) # === STEP 6: CREATE OUTPUT DIRECTORY === output_dir = os.path.join(current_folder, result_folder_name) # Clear existing files in the output directory if os.path.exists(output_dir): for existing_file in os.listdir(output_dir): existing_file_path = os.path.join(output_dir, existing_file) if os.path.isfile(existing_file_path): os.remove(existing_file_path) self.log(f"Cleared existing files in {result_folder_name}") os.makedirs(output_dir, exist_ok=True) processed_result_folders.append(output_dir) self.log(f"Created output folder: {result_folder_name}") # === STEP 7: SAVE COMBINED IMAGE (OPTIONAL) === if save_combined: combined_folder_name = f"{result_folder_name}_combined" combined_dir = os.path.join(current_folder, combined_folder_name) # Clear existing files in the combined directory if os.path.exists(combined_dir): for existing_file in os.listdir(combined_dir): existing_file_path = os.path.join(combined_dir, existing_file) if os.path.isfile(existing_file_path): os.remove(existing_file_path) self.log(f"Cleared existing files in {combined_folder_name}") os.makedirs(combined_dir, exist_ok=True) combined_image_path = os.path.join(combined_dir, "combined_image.png") combined_image.save(combined_image_path, 'PNG') self.log(f"Saved combined image to: {combined_image_path}") # === STEP 8: SLICE COMBINED IMAGE === num_slices = math.ceil(combined_image.height / slice_height_px) for j in range(num_slices): # Check if user requested to stop if not self.is_slicing: break # Calculate slice boundaries top = j * slice_height_px bottom = min((j + 1) * slice_height_px, combined_image.height) box = (0, top, combined_image.width, bottom) # Skip empty slices if top >= bottom: continue # Crop the slice from combined image cropped_img = combined_image.crop(box) # Create filename with zero-padded index slice_filename = f"{result_folder_name}_{j+1:02d}.jpg" slice_path = os.path.join(output_dir, slice_filename) # Save slice as JPEG with high quality cropped_img.save(slice_path, 'JPEG', quality=95) self.log(f"Saved {num_slices} slices in {result_folder_name} " f"(format: {result_folder_name}_01.jpg, {result_folder_name}_02.jpg, ...)") self.progress_target = int(base_progress + progress_per_folder) # === STEP 9: SAVE PROCESSED FOLDERS TO INFO.TXT === if processed_result_folders: info_file_path = os.path.join(folder_path, "info.txt") try: with open(info_file_path, 'w', encoding='utf-8') as f: for result_folder in sorted(processed_result_folders): f.write(f"{result_folder}\n") self.log(f"Successfully saved processed folder paths to info.txt (overwrote previous content)") except Exception as e: self.log(f"Error saving info.txt for processed folders: {e}") except Exception as e: # Re-raise exception to be handled by start_slicing method raise def start_slicing(self): """ Start the image processing operation. This method: 1. Validates user inputs 2. Sets up the processing environment 3. Chooses between folder scanning or image processing 4. Handles errors and cleanup """ # === INITIALIZE PROCESSING ENVIRONMENT === self.log_screen.clear() # Clear previous log messages self.progress_bar.setValue(0) # Reset progress bar self.progress_target = 0 # Get user inputs folder_path = self.folder_path.text() slice_width_px_str = self.width_input.text() slice_height_px_str = self.height_input.text() # === VALIDATE FOLDER PATH === if not os.path.isdir(folder_path): QMessageBox.warning(self, "Warning", "Please select a valid folder.") self.log("Error: Invalid folder path.") return # === VALIDATE DIMENSION INPUTS === try: # Convert inputs to integers (pixels must be whole numbers) slice_width_px = int(slice_width_px_str) if slice_width_px_str else 0 slice_height_px = int(slice_height_px_str) if slice_height_px_str else 0 # Validate values (width can be 0 for auto-detect, height must be positive) if slice_width_px < 0 or (not self.skip_merge_checkbox.isChecked() and slice_height_px <= 0): raise ValueError except (ValueError, TypeError): QMessageBox.warning(self, "Warning", "Please enter valid positive integers for height (width can be 0).") self.log("Error: Invalid input.") return # === START PROCESSING === self.is_slicing = True # Set processing flag self.start_button.setText("Stop") # Change button text self.progress_timer.start(10) # Start progress bar animation timer # Get processing options save_combined = self.save_combined_checkbox.isChecked() skip_merge = self.skip_merge_checkbox.isChecked() try: if skip_merge: # === FOLDER SCANNING MODE === self.log("--- Skipping Merge: Saving folder paths to info.txt ---") self.save_folder_paths_to_info_txt(folder_path) else: # === IMAGE PROCESSING MODE === self.log("--- Starting Image Merge and Slice Process ---") self.merge_and_slice_images(folder_path, slice_width_px, slice_height_px, save_combined) # === COMPLETION HANDLING === if self.is_slicing: # Check if not stopped by user # Animate progress bar to 100% self.progress_target = 100 while self.progress_bar.value() < 99: time.sleep(0.01) QApplication.processEvents() self.log("--- All tasks completed ---") QMessageBox.information(self, "Success", "Image processing completed for all tasks.") except Exception as e: # === ERROR HANDLING === self.log(f"An unexpected error occurred: {e}") QMessageBox.critical(self, "Error", f"An unexpected error occurred: {e}") finally: # === CLEANUP === self.stop_slicing_ui() def stop_slicing_ui(self): """ Reset the UI to non-processing state. This method is called when processing completes (successfully or with errors) or when the user manually stops the operation. """ self.is_slicing = False # Clear processing flag self.start_button.setText("Start") # Reset button text self.progress_timer.stop() # Stop progress bar animation self.progress_bar.setValue(0) # Reset progress bar def save_log(self): """ Save the current log content to a timestamped file. This method creates a log folder within the selected base folder and saves the log with a timestamp in the filename. """ # Prevent log saving during processing if self.is_slicing: QMessageBox.warning(self, "Warning", "Cannot save log while process is running.") return # Validate base folder selection folder_path = self.folder_path.text() if not folder_path or not os.path.isdir(folder_path): QMessageBox.warning(self, "Warning", "Please select a valid base folder first.") return # Check if log has content log_content = self.log_screen.toPlainText() if not log_content: QMessageBox.warning(self, "Warning", "Log is empty.") return try: # === CREATE LOG DIRECTORY === log_dir_name = f"{os.path.basename(folder_path)}_log" log_dir_path = os.path.join(folder_path, log_dir_name) os.makedirs(log_dir_path, exist_ok=True) # === GENERATE TIMESTAMPED FILENAME === now = datetime.datetime.now() log_filename = f"log_{now.strftime('%H%M%S')}.log" # Format: log_HHMMSS.log save_path = os.path.join(log_dir_path, log_filename) # === SAVE LOG FILE === with open(save_path, 'w', encoding='utf-8') as f: f.write(log_content) # === CONFIRM SUCCESS === self.log(f"Log saved to {save_path}") QMessageBox.information(self, "Success", f"Log successfully saved to\n{save_path}") except Exception as e: # === HANDLE SAVE ERRORS === self.log(f"Error saving log: {e}") QMessageBox.critical(self, "Error", f"Failed to save log file: {e}") if __name__ == '__main__': """ Application entry point. Creates the QApplication instance, initializes the ImageSlicer widget, shows the main window, and starts the event loop. """ app = QApplication(sys.argv) # Create Qt application ex = ImageSlicer() # Create main window instance ex.show() # Show the window sys.exit(app.exec()) # Start event loop and exit when closed