ge-utils/image_cutting/main.py
2025-07-18 18:25:05 +07:00

770 lines
31 KiB
Python

"""
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