770 lines
31 KiB
Python
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
|