Refactoring Threading and Concurrency

Issue:
The script uses a combination of ThreadPoolExecutor, custom threads, and threading locks, which can lead to complexity and potential concurrency issues.

Recommendation:

    Encapsulate Concurrency Logic: Wrap threading logic within classes or dedicated functions to make it more manageable.
    Use concurrent.futures Effectively: Utilize as_completed for better handling of tasks.
    Ensure Proper Shutdown: Make sure all threads can be gracefully stopped to prevent hanging.
Python code: import ctypes
import logging
import os
import shutil
import subprocess
import threading
import time
import tkinter as tk
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from queue import Queue
from tkinter import filedialog
from tkinter import ttk
from typing import Dict, List, Tuple, Set
from dataclasses import dataclass, field

import numpy as np
from PIL import Image, ImageOps

# --- Configuration ---

class Config:
    """Configuration class for the image processing application."""
    SCRIPT_DIR = Path(__file__).parent.resolve()
    INPUT_FOLDER = Path(os.getenv('INPUT_FOLDER', SCRIPT_DIR / '1. Input'))
    OUTPUT_FOLDER = Path(os.getenv('OUTPUT_FOLDER', SCRIPT_DIR / '2. Output'))
    DEFAULT_INPUT_FOLDER = INPUT_FOLDER
    DEFAULT_OUTPUT_FOLDER = OUTPUT_FOLDER
    IMAGEMAGICK = Path(os.getenv('IMAGEMAGICK', 'D:/Bilder/Python/0. Code/Requirements/ImageMagick.exe'))
    FFMPEG = Path(os.getenv('FFMPEG', 'D:/Bilder/Python/0. Code/Requirements/ffmpeg.exe'))

    AUDIO_EXTENSIONS: Set[str] = {'.mka', '.mpga', '.mp3', '.aac', '.flac', '.opus'}
    IMAGE_EXTENSIONS: Set[str] = {'.png', '.jpg', '.jpeg', '.webp'}
    VIDEO_EXTENSIONS: Set[str] = {'.mkv', '.mp4'}

    # Constants for formatting and display
    SEPARATOR_LENGTH = 310
    PADDING_LENGTH = 82
    PROGRESS_BAR_LENGTH = 100

    # Constants for resize options
    DEFAULT_RESIZE_MODE = "Shortest Side"
    DEFAULT_RESIZE_WIDTH = 0
    DEFAULT_RESIZE_HEIGHT = 0
    DEFAULT_RESIZE_SIDE = 1600
    DEFAULT_RESIZE_FORMAT = "png"

    # Thresholds for color detection
    COLORDIFF_THRESHOLD: int = 5  # Threshold for color difference between channels
    FRACTION_COLORED_PIXELS_THRESHOLD: float = 0.03  # Fraction threshold to consider an image as color

    @classmethod
    def setup(cls):
        """Set up the output folder and configure logging."""
        cls.OUTPUT_FOLDER.mkdir(parents=True, exist_ok=True)
        logging.getLogger("PIL").setLevel(logging.WARNING)

    @classmethod
    def initialize(cls):
        """Initialize the configuration and return a SharedState instance."""
        cls.setup()
        return SharedState()

@dataclass
class SharedState:
    processed_files_lock: threading.Lock = field(default_factory=threading.Lock)
    processed_files_count: int = 0
    total_files_count: int = 0
    log_message_queue: Queue = field(default_factory=Queue)
    tab_messages: Dict[str, List[str]] = field(default_factory=lambda: {
        "Log": [],
        "Black": [],
        "White": [],
        "Color": []
    })
    stop_event: threading.Event = field(default_factory=threading.Event)

# --- Utility Functions ---

def log_error(message: str):
    """Log an error message to the shared state queue."""
    shared_state.log_message_queue.put(f"- {message}")

def clear_console():
    """Clear the console screen."""
    os.system('cls' if os.name == 'nt' else 'clear')

def count_image_files(folder: Path) -> int:
    """Count the number of image files in a folder and its subfolders."""
    return sum(1 for path in folder.rglob('*') if path.is_file() and path.suffix.lower() in Config.IMAGE_EXTENSIONS)

def is_valid_image(path: Path) -> bool:
    """Check if the given path is a valid image file."""
    try:
        with Image.open(path):
            return True
    except (OSError, ValueError) as e:
        log_error(f"Error reading image {path}: {e}")
        return False

def open_and_convert_image(path: Path) -> Image.Image:
    with Image.open(path) as img:
        if img.mode == 'P':  # Palette mode (indexed)
            return img.convert('L')  # Convert to true grayscale
        elif img.mode in ['L', 'RGB', 'RGBA']:
            return img.copy()  # Return a copy of the image
        else:
            return img.convert('RGB')  # Convert other modes to RGB

def create_temp_folder():
    """Create a temporary folder for processing."""
    temp_dir = Config.OUTPUT_FOLDER / f"temp_{int(time.time())}"
    temp_dir.mkdir(parents=True, exist_ok=True)
    return temp_dir

def cleanup_temp_folder(temp_folder):
    """Remove the temporary folder and its contents."""
    shutil.rmtree(temp_folder, ignore_errors=True)

def check_imagemagick():
    """Check if ImageMagick is available in the system."""
    if not shutil.which(str(Config.IMAGEMAGICK)):
        raise FileNotFoundError(f"- ImageMagick executable not found at: {Config.IMAGEMAGICK}")

def check_ffmpeg():
    """Check if FFmpeg is available in the system."""
    if not shutil.which(str(Config.FFMPEG)):
        raise FileNotFoundError(f"- FFmpeg executable not found at: {Config.FFMPEG}")

# --- Color Detection ---

def is_color_image(img: np.ndarray, diff_threshold: int = Config.COLORDIFF_THRESHOLD,
                  fraction_threshold: float = Config.FRACTION_COLORED_PIXELS_THRESHOLD) -> bool:
    """Determine if an image is color based on RGB channel differences."""
    # Ensure the image has three channels
    if img.ndim != 3 or img.shape[2] != 3:
        return False

    # Convert channels to int16 to prevent overflow/underflow
    R = img[:, :, 0].astype(np.int16)
    G = img[:, :, 1].astype(np.int16)
    B = img[:, :, 2].astype(np.int16)

    # Compute absolute differences between channels
    diff_RG = np.abs(R - G)
    diff_RB = np.abs(R - B)
    diff_GB = np.abs(G - B)

    # Find the maximum difference per pixel
    max_diff = np.maximum(np.maximum(diff_RG, diff_RB), diff_GB)

    # Count pixels where max difference exceeds the threshold
    num_colored_pixels = np.sum(max_diff > diff_threshold)
    total_pixels = img.shape[0] * img.shape[1]
    fraction_colored_pixels = num_colored_pixels / total_pixels

    # Determine if the fraction exceeds the threshold
    return fraction_colored_pixels >= fraction_threshold

# --- Process Bar and Multithreading ---

def update_status(start_time, processed_count, total_files):
    elapsed_time = time.time() - start_time
    elapsed_minutes = int(elapsed_time // 60)
    elapsed_seconds = int(elapsed_time % 60)
    elapsed_time_str = f"{elapsed_minutes:02d}:{elapsed_seconds:02d}"
    start_time_str = time.strftime('%H:%M:%S', time.localtime(start_time))
    status_text = (
        f"Start Time: {start_time_str} - "
        f"Processed Files: {processed_count}/{shared_state.total_files_count} - "
        f"Elapsed Time: {elapsed_time_str} Minutes"
    )
    padded_status_text = status_text.ljust(Config.PADDING_LENGTH)
    app.update_status_text(padded_status_text)

def update_elapsed_time(start_time, total_files, stop_event):
    """Continuously update the elapsed time until the stop event is set."""
    while not stop_event.is_set():
        update_status(start_time, shared_state.processed_files_count, total_files)
        time.sleep(1)

def start_elapsed_time_thread(start_time, total_files, stop_event):
    """Start a thread to update the elapsed time."""
    elapsed_time_thread = threading.Thread(target=update_elapsed_time, args=(start_time, total_files, stop_event))
    elapsed_time_thread.start()
    return elapsed_time_thread

def run_with_thread_pool(tasks, process_func, *args):
    shared_state.processed_files_count = 0
    shared_state.total_files_count = len(tasks)  # Set total files count
    start_time = time.time()
    total = len(tasks)
    
    app.update_progress(0)

    stop_event = threading.Event()
    elapsed_time_thread = start_elapsed_time_thread(start_time, total, stop_event)

    try:
        with ThreadPoolExecutor() as executor:
            futures = [executor.submit(process_func, task, *args) for task in tasks]
            for i, future in enumerate(futures):
                if shared_state.stop_event.is_set():
                    for f in futures:
                        f.cancel()
                    break
                try:
                    future.result()
                except Exception as e:
                    log_error(f"Error during processing: {e}")
                finally:
                    progress = (i + 1) / total * 100
                    app.update_progress(progress)
    finally:
        stop_event.set()
        elapsed_time_thread.join()

    return total

# --- Image Processing Functions ---

def process_grayscale_image(img: np.ndarray, path: Path, image_processor: 'ImageProcessor', temp_folder: Path) -> Tuple[Path, int, int]:
    """Process a grayscale image, adjusting its values and saving the result."""
    try:
        adjusted, modal_black, modal_white = adjust_values(img)

        out_path = temp_folder / path.parent.relative_to(Config.INPUT_FOLDER) / (path.stem + ".png")
        out_path.parent.mkdir(parents=True, exist_ok=True)
        Image.fromarray(adjusted).convert('L').save(out_path, format='PNG', compress_level=5)

        subfolder = str(path.parent.relative_to(Config.INPUT_FOLDER))
        filename = path.name
        if modal_black:
            image_processor.black_results[subfolder].append((filename, modal_black))
        if modal_white != 255:
            image_processor.white_results[subfolder].append((filename, modal_white))

        return out_path, modal_black, modal_white
    except (ValueError, OSError) as e:
        log_error(f"Error processing grayscale image {path}: {e}")
        return path, 0, 255

def process_color_image(img: np.ndarray, path: Path, image_processor: 'ImageProcessor', temp_folder: Path,
                       diff_threshold: int = Config.COLORDIFF_THRESHOLD,
                       fraction_threshold: float = Config.FRACTION_COLORED_PIXELS_THRESHOLD) -> Tuple[Path, bool]:
    """Process a color image, determining if it's color or grayscale based on RGB differences."""
    try:
        if is_color_image(img, diff_threshold, fraction_threshold):
            # It's a color image - save it as is
            rel_path = path.relative_to(Config.INPUT_FOLDER)
            out_path = temp_folder / rel_path.with_suffix('.png')
            out_path.parent.mkdir(parents=True, exist_ok=True)
            Image.fromarray(img).save(out_path, format='PNG', compress_level=5)

            subfolder = str(path.parent.relative_to(Config.INPUT_FOLDER))
            filename = path.name
            image_processor.color_results[subfolder].append(filename)

            return out_path, False
        else:
            # It's a grayscale image - process accordingly
            gray = ImageOps.grayscale(Image.fromarray(img))
            out_path, modal_black, modal_white = process_grayscale_image(np.array(gray), path, image_processor, temp_folder)
            return out_path, modal_black != 0
    except (OSError, ValueError) as e:
        log_error(f"Error processing color image {path}: {e}")
        return path, False

def adjust_values(img: np.ndarray) -> Tuple[np.ndarray, int, int]:
    """Adjust the values of a grayscale image."""
    try:
        hist, bins = np.histogram(img[img >= 250], bins=6, range=(250, 256))
        modal_white = int(bins[np.argmax(hist)])

        hist, bins = np.histogram(img[img < 61], bins=61, range=(0, 61))
        modal_black = int(bins[np.argmax(hist)])

        if modal_white != 255:
            img = img.astype(np.float64)
            img = img * (255.0 / modal_white)
            img = np.clip(img, 0, 255)
            img = img.astype(np.uint8)

        if modal_black:
            img = img.astype(np.float64)
            adjusted = (img - modal_black) * (255.0 / (255 - modal_black))
            adjusted = np.clip(adjusted, 0, 255)
            
            gamma = 1.0 - (modal_black / 255.0)
            adjusted = np.power(adjusted / 255.0, gamma) * 255 

            adjusted[img == 0] = 0
            adjusted[img == 255] = 255

            adjusted = adjusted.astype(np.uint8)
        else:
            adjusted = img

        return adjusted, modal_black, modal_white
    except ValueError as e:
        log_error(f"Error adjusting values for image: {e}")
        return img, 0, 255

def analyze_images(image_processor: 'ImageProcessor'):
    """Analyze images in the input folder for color properties."""
    image_processor.reset_results()
    tasks = [path for path in Config.INPUT_FOLDER.rglob('*') 
             if path.is_file() and path.suffix.lower() in Config.IMAGE_EXTENSIONS]
    
    if not tasks:
        log_error(f"No image files found in '{Config.INPUT_FOLDER}'")
        return

    def process_task(path, start_time, total):
        analyze_file(path)
        with shared_state.processed_files_lock:
            shared_state.processed_files_count += 1
        update_status(start_time, shared_state.processed_files_count, total)

    run_with_thread_pool(tasks, process_task, time.time(), len(tasks))

def analyze_file(path, diff_threshold: int = Config.COLORDIFF_THRESHOLD,
                fraction_threshold: float = Config.FRACTION_COLORED_PIXELS_THRESHOLD):
    """Analyze a single image file using RGB differences to determine color status."""
    if is_valid_image(path):
        try:
            img = np.array(open_and_convert_image(path))
            if img.ndim == 2 or (img.ndim == 3 and img.shape[2] == 1):
                # Grayscale image
                adjusted, modal_black, modal_white = adjust_values(img)  
                subfolder = str(path.parent.relative_to(Config.INPUT_FOLDER))
                filename = path.name
                if modal_black:
                    image_processor.black_results[subfolder].append((filename, modal_black))
                if modal_white != 255:
                    image_processor.white_results[subfolder].append((filename, modal_white))
            else:
                # Determine if the image is color or grayscale based on RGB differences
                if is_color_image(img, diff_threshold, fraction_threshold):
                    subfolder = str(path.parent.relative_to(Config.INPUT_FOLDER))
                    filename = path.name
                    image_processor.color_results[subfolder].append(filename)
                else:
                    adjusted, modal_black, modal_white = adjust_values(img) 
                    subfolder = str(path.parent.relative_to(Config.INPUT_FOLDER))
                    filename = path.name
                    if modal_black:
                        image_processor.black_results[subfolder].append((filename, modal_black))
                    if modal_white != 255:
                        image_processor.white_results[subfolder].append((filename, modal_white))
        except (OSError, ValueError) as e:
            log_error(f"Error processing image {path}: {e}")

def find_color_images(image_processor: 'ImageProcessor'):
    """Find and move color images to the output folder."""
    image_processor.reset_results()
    tasks = [path for path in Config.INPUT_FOLDER.rglob('*') 
             if path.is_file() and path.suffix.lower() in Config.IMAGE_EXTENSIONS]
    
    if not tasks:
        log_error(f"No image files found in '{Config.INPUT_FOLDER}'")
        return

    run_with_thread_pool(tasks, move_color_image_file, time.time(), len(tasks), image_processor)

def move_color_image_file(path, start_time, total_files, image_processor: 'ImageProcessor'):
    """Move a color image file to the output folder."""
    if is_valid_image(path):
        try:
            img = np.array(open_and_convert_image(path))
            if img.ndim == 3 and img.shape[2] == 3:
                if is_color_image(img, Config.COLORDIFF_THRESHOLD, Config.FRACTION_COLORED_PIXELS_THRESHOLD):
                    rel_path = path.relative_to(Config.INPUT_FOLDER)
                    out_path = Config.OUTPUT_FOLDER / rel_path
                    out_path.parent.mkdir(parents=True, exist_ok=True)
                    shutil.move(str(path), str(out_path))
                    subfolder = str(path.parent.relative_to(Config.INPUT_FOLDER))
                    filename = path.name
                    image_processor.color_results[subfolder].append(filename)
        except (OSError, ValueError) as e:
            log_error(f"Error processing image {path}: {e}")

        with shared_state.processed_files_lock:
            shared_state.processed_files_count += 1
        update_status(start_time, shared_state.processed_files_count, total_files)

# --- Corrupted Files ---

def check_file_for_corruption(file_path: Path, error_queue):
    """Check a file for corruption using FFmpeg."""
    try:
        file_extension = file_path.suffix.lower()
        if file_extension in Config.VIDEO_EXTENSIONS | Config.AUDIO_EXTENSIONS | Config.IMAGE_EXTENSIONS:
            base_command = [str(Config.FFMPEG), '-v', 'error', '-i', str(file_path)]

            if file_extension in Config.VIDEO_EXTENSIONS:
                command = base_command + ['-map', '0:a:0', '-f', 'null', '-']
            else:
                command = base_command + ['-f', 'null', '-']

            process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
            output, error = process.communicate()
            combined_output = output.decode('utf-8', errors='ignore') + error.decode('utf-8', errors='ignore')
            error_messages = [line.strip() for line in combined_output.split('\n') if 'error' in line.lower()]

            if error_messages:
                error_queue.put((file_path.resolve(), error_messages))
    except Exception as e:
        error_queue.put((file_path.resolve(), [f"Error processing file {file_path}: {str(e)}"]))

def check_for_corrupted_files():
    """Check for corrupted files in the input folder."""
    image_processor.reset_results()

    try:
        check_ffmpeg()
    except FileNotFoundError as e:
        log_error(str(e))
        return

    tasks = [file_path for file_path in Config.INPUT_FOLDER.rglob('*') 
             if file_path.is_file() and file_path.suffix.lower() in 
             Config.VIDEO_EXTENSIONS | Config.AUDIO_EXTENSIONS | Config.IMAGE_EXTENSIONS]

    if not tasks:
        log_error(f"No valid media files found in '{Config.INPUT_FOLDER}'")
        return

    error_queue = Queue()

    def process_task(file_path, error_queue, start_time, total_files):
        check_file_for_corruption(file_path, error_queue)
        with shared_state.processed_files_lock:
            shared_state.processed_files_count += 1
        update_status(start_time, shared_state.processed_files_count, total_files)

    total_files = run_with_thread_pool(tasks, process_task, error_queue, time.time(), len(tasks))
    
    log_errors(error_queue)

# --- Images ---

def convert_png_to_jpg(input_path: Path, output_path: Path):
    """Convert a PNG image to JPG format."""
    try:
        with Image.open(input_path) as img:
            # Convert image to 'RGB' if it's not already in 'RGB' or 'L' mode
            if img.mode not in ('RGB', 'L'):
                img = img.convert('RGB')
            
            jpg_path = output_path.with_suffix('.jpg')
            img.save(jpg_path, 'JPEG', quality=80, subsampling=0)
        
        input_path.unlink()
    except Exception as e:
        log_error(f"Error converting {input_path} to JPG: {e}")

def resize_image(input_path: Path, output_path: Path):
    """Resize an image using ImageMagick."""
    output_path = output_path.with_suffix('.png')

    identify_command = [
        str(Config.IMAGEMAGICK),
        str(input_path),
        '-format', '%wx%h',
        'info:'
    ]
    result = subprocess.run(identify_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True)
    current_width, current_height = map(int, result.stdout.strip().split('x'))

    resize_mode = app.RESIZE_MODE.get()
    if resize_mode == "Fit":
        target_width = int(app.RESIZE_WIDTH.get()) if app.RESIZE_WIDTH.get() else 0
        target_height = int(app.RESIZE_HEIGHT.get()) if app.RESIZE_HEIGHT.get() else 0 
        if app.maintain_aspect_ratio.get():
            if target_width == 0:
                target_width = int(target_height * (current_width / current_height))
            elif target_height == 0:
                target_height = int(target_width * (current_height / current_width))
        resize_arg = f'{target_width}x{target_height}!'
    else:  # Shortest Side
        target_size = int(app.RESIZE_SIDE.get())
        resize_arg = f'{target_size}x{target_size}^'

    is_upscaling = (resize_mode == "Fit" and (target_width > current_width or target_height > current_height)) or \
                   (resize_mode == "Shortest Side" and target_size > min(current_width, current_height))

    if is_upscaling:
        command = [
            str(Config.IMAGEMAGICK),
            str(input_path),
            '-filter', 'LanczosSharp',
            '-distort', 'Resize', resize_arg,
        ]
    else:
        command = [
            str(Config.IMAGEMAGICK),
            str(input_path),
            '-colorspace', 'RGB',
            '-filter', 'Lanczos2Sharp',
            '-resize', resize_arg,
            '-colorspace', 'sRGB',
        ]

    command.extend(['-define', 'png:compression-level=5'])
    command.append(str(output_path))

    result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True)

    if result.stderr:
        log_error(f"ImageMagick: {result.stderr.strip()}")

    return output_path

# --- Processing ---

def resize_images(input_folder: Path, output_folder: Path, image_processor: 'ImageProcessor'):
    """Resize images in the input folder and save them to the output folder."""
    try:
        check_imagemagick()
    except FileNotFoundError as e:
        log_error(str(e))
        return

    tasks = [input_path for input_path in input_folder.rglob('*') 
             if input_path.is_file() and input_path.suffix.lower() in Config.IMAGE_EXTENSIONS]

    if not tasks:
        log_error(f"No image files found in '{input_folder}'")
        return

    temp_folder = create_temp_folder()
    try:
        run_with_thread_pool(tasks, process_file, time.time(), len(tasks), image_processor, temp_folder, True)
    finally:
        try:
            cleanup_temp_folder(temp_folder)
        except Exception as e:
            log_error(f"Error removing temporary folder {temp_folder}: {e}")

def process_file(path: Path, start_time, total_files, image_processor: 'ImageProcessor', temp_folder: Path, resize=False, auto_correct_and_resize=False) -> None:
    """Process a single image file."""
    if shared_state.stop_event.is_set():
        return

    if not is_valid_image(path):
        log_error(f"Invalid image file: {path}")
        with shared_state.processed_files_lock:
            shared_state.processed_files_count += 1
        update_status(start_time, shared_state.processed_files_count, total_files)
        return

    try:
        img = np.array(open_and_convert_image(path))
    except (OSError, ValueError) as e:
        log_error(f"Error reading image {path}: {e}")
        with shared_state.processed_files_lock:
            shared_state.processed_files_count += 1
        update_status(start_time, shared_state.processed_files_count, total_files)
        return

    relative_path = path.relative_to(Config.INPUT_FOLDER)
    temp_output_path = temp_folder / relative_path
    temp_output_path = temp_output_path.with_suffix('.png')
    temp_output_path.parent.mkdir(parents=True, exist_ok=True)

    if auto_correct_and_resize:
        if img.ndim == 2 or (img.ndim == 3 and img.shape[2] == 1):
            intermediate_path, _, _ = process_grayscale_image(img, path, image_processor, temp_folder)
        else:
            intermediate_path, _ = process_color_image(img, path, image_processor, temp_folder)
        temp_output_path = resize_image(intermediate_path, temp_output_path)
    elif resize:
        temp_output_path = resize_image(path, temp_output_path)
    else:
        if img.ndim == 2 or (img.ndim == 3 and img.shape[2] == 1):
            temp_output_path, _, _ = process_grayscale_image(img, path, image_processor, temp_folder)
        else:
            temp_output_path, _ = process_color_image(img, path, image_processor, temp_folder)

    final_output_path = Config.OUTPUT_FOLDER / relative_path
    final_output_path.parent.mkdir(parents=True, exist_ok=True)

    if app.RESIZE_FORMAT.get() == 'jpg':
        final_output_path = final_output_path.with_suffix('.jpg')
        convert_png_to_jpg(temp_output_path, final_output_path)
    else:
        final_output_path = final_output_path.with_suffix('.png')
        shutil.move(str(temp_output_path), str(final_output_path))

    with shared_state.processed_files_lock:
        shared_state.processed_files_count += 1
    update_status(start_time, shared_state.processed_files_count, total_files)

def process_folder(folder: Path, image_processor: 'ImageProcessor', auto_correct_and_resize=False) -> None:
    """Process all images in a folder."""
    image_processor.reset_results()

    if auto_correct_and_resize:
        try:
            check_imagemagick()
        except FileNotFoundError as e:
            log_error(str(e))
            return

    tasks = [path for path in folder.rglob('*') 
             if path.is_file() and path.suffix.lower() in Config.IMAGE_EXTENSIONS]

    if not tasks:
        log_error(f"No image files found in '{folder}'")
        return

    temp_folder = create_temp_folder()
    try:
        run_with_thread_pool(tasks, process_file, time.time(), len(tasks), image_processor, temp_folder, False, auto_correct_and_resize)
    finally:
        try:
            cleanup_temp_folder(temp_folder)
        except Exception as e:
            log_error(f"Error removing temporary folder {temp_folder}: {e}")

# --- Logging ---

def log_errors(error_queue):
    """Log errors from the error queue."""
    error_dict = defaultdict(list)
    while not error_queue.empty():
        file_path, error_messages = error_queue.get()
        error_dict[file_path].extend(error_messages)

    if error_dict:
        shared_state.log_message_queue.put("UPDATE_LOG")
        for file_path in sorted(error_dict.keys()):
            shared_state.log_message_queue.put(f"{file_path.resolve()}")
            shared_state.log_message_queue.put("")
            for error_message in sorted(error_dict[file_path]):
                log_error(error_message)
            shared_state.log_message_queue.put("")

def log_and_execute(choice: str, label: str, function: callable, *args):
    """Log the execution of a function and run it in a separate thread."""
    def run_in_thread():
        start_time = time.time()
        separator = '-' * Config.SEPARATOR_LENGTH
        shared_state.log_message_queue.put(f"{separator}\n{label}\n{separator}\n")

        try:
            total_files = function(*args)  # Assuming the function returns the total file count
        except FileNotFoundError as e:
            log_error(str(e))
            total_files = 0
        except (OSError, ValueError) as e:
            log_error(f"An error occurred during '{label}': {e}")
            total_files = 0

        end_time = time.time()
        execution_time = end_time - start_time
        
        shared_state.log_message_queue.put("")
        image_processor.write_results_to_files(Config.OUTPUT_FOLDER, choice)
        
        app.after(0, app.update_final_status, execution_time)
        app.after(0, app.update_text_area)

    thread = threading.Thread(target=run_in_thread)
    thread.start()

class ImageProcessor:
    """Class to handle image processing results."""
    def __init__(self):
        self.white_results = defaultdict(list)
        self.black_results = defaultdict(list)
        self.color_results = defaultdict(list)

    def write_results_to_files(self, output_folder: Path, choice: str):
        """Write processing results to files."""
        results_map = {
            '2': ("2. Analyze Images", "Modal Value Black", "Modal Value White"),
            '3': ("3. Find Color Images", "Filename"),
            '5': ("5. Automatic Color Correction", "Modal Value Black", "Modal Value White"),
            '6': ("6. Automatic Color Correction + Resize", "Modal Value Black", "Modal Value White")
        }
        headers = results_map.get(choice)
        if headers:
            if choice == '3':
                self._write_results("Color", self.color_results, headers[0], headers[1])
            else:
                self._write_results("Black", self.black_results, headers[0], headers[1])
                self._write_results("White", self.white_results, headers[0], headers[2])
                self._write_results("Color", self.color_results, headers[0], "Filename")

    def _write_results(self, file_path: str, results: Dict[str, List[Tuple[str, int]]], header: str,
                       value_name: str):
        """Write results to a specific file."""
        separator = '-' * Config.SEPARATOR_LENGTH
        content = f"{separator}\n{header}\n{separator}\n"
        if results:
            for subfolder, subfolder_results in sorted(results.items()):
                full_subfolder_path = Config.INPUT_FOLDER / subfolder
                content += f"\n{full_subfolder_path}\n\n"
                if value_name == "Filename":
                    subfolder_results.sort()
                    content += '\n'.join(f" - {filename}" for filename in subfolder_results)
                else:
                    subfolder_results.sort(key=lambda x: x[1], reverse=True)
                    content += '\n'.join(
                        f" - {filename}, {value_name}: {value}" for filename, value in subfolder_results)
                content += "\n"
        shared_state.tab_messages[file_path].append(content)

    def reset_results(self):
        """Reset all results."""
        self.white_results = defaultdict(list)
        self.black_results = defaultdict(list)
        self.color_results = defaultdict(list)

# --- Tkinter App ---

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.setup_window()
        self.create_variables()
        self.create_widgets()
        self.setup_logging()
        self.process_start_time = 0

    def setup_window(self):
        self.set_dpi_awareness()
        self.title("Image Processor")
        self.geometry("2100x1180")
        self.default_font = ("Roboto Flex", 16)
        self.apply_default_font()

    def set_dpi_awareness(self):
        try:
            ctypes.windll.shcore.SetProcessDpiAwareness(1)
        except Exception as e:
            print(f"Could not set DPI awareness: {e}")

    def apply_default_font(self):
        self.option_add("*Font", self.default_font)
        self.style = ttk.Style()
        for widget_type in ["TButton", "TLabel", "TEntry", "TRadiobutton", "TNotebook.Tab"]:
            self.style.configure(widget_type, font=self.default_font)
        self.style.configure("TFrame", background="#fdfdfd")
        self.style.configure("TLabel", background="#fdfdfd")
        self.style.configure("TNotebook", background="#f8f8f8")
        self.style.configure("TRadiobutton", background="#fdfdfd")

    def create_variables(self):
        self.RESIZE_MODE = tk.StringVar(value=Config.DEFAULT_RESIZE_MODE)
        self.RESIZE_WIDTH = tk.StringVar(value=str(Config.DEFAULT_RESIZE_WIDTH))
        self.RESIZE_HEIGHT = tk.StringVar(value=str(Config.DEFAULT_RESIZE_HEIGHT))
        self.RESIZE_SIDE = tk.StringVar(value=str(Config.DEFAULT_RESIZE_SIDE))
        self.RESIZE_FORMAT = tk.StringVar(value=Config.DEFAULT_RESIZE_FORMAT)
        self.option_var = tk.StringVar(value="4")
        self.status_text = tk.StringVar(value="")

    def create_widgets(self):
        main_frame = ttk.Frame(self, padding="20")
        main_frame.pack(fill=tk.BOTH, expand=True)

        self.create_folder_selection_frames(main_frame)
        self.create_resize_options_frame(main_frame)
        self.create_options_and_start_frame(main_frame)
        self.create_text_frame(main_frame)
        self.create_progress_bar(main_frame)
        self.create_status_label(main_frame)

    def create_folder_selection_frames(self, parent):
        self.create_folder_frame(parent, "Input", Config.INPUT_FOLDER, self.set_default_input, self.select_input_folder)
        self.create_folder_frame(parent, "Output", Config.OUTPUT_FOLDER, self.set_default_output, self.select_output_folder)

    def create_folder_frame(self, parent, label, default_folder, default_command, select_command):
        frame = ttk.Frame(parent)
        frame.pack(fill=tk.X, pady=(0, 10))

        ttk.Button(frame, text="Default", command=default_command, width=10).pack(side=tk.LEFT, padx=(0, 10))

        entry = ttk.Entry(frame)
        entry.insert(0, str(default_folder))
        entry.pack(side=tk.LEFT, expand=True, fill=tk.X)

        ttk.Button(frame, text=f"Select {label} Folder", command=select_command, width=20).pack(side=tk.LEFT, padx=(10, 0))

        setattr(self, f"{label.lower()}_entry", entry)

    def create_resize_options_frame(self, parent):
        resize_frame = ttk.Frame(parent)
        resize_frame.pack(fill=tk.X, pady=(0, 10))

        ttk.Button(resize_frame, text="Default", command=self.reset_resize_options, width=10).pack(side=tk.LEFT, padx=(0, 10))

        self.create_resize_options(resize_frame)

        self.start_button = ttk.Button(resize_frame, text="Start", command=self.start_processing, width=20)
        self.start_button.pack(side=tk.RIGHT, padx=(10, 0))

    def create_resize_options(self, frame):
        ttk.Label(frame, text="Format:").pack(side=tk.LEFT, padx=(0, 5))
        ttk.Combobox(frame, textvariable=self.RESIZE_FORMAT, values=["png", "jpg"], state="readonly", width=4).pack(side=tk.LEFT, padx=(0, 10))

        ttk.Label(frame, text="Resize:").pack(side=tk.LEFT, padx=(0, 5))
        mode_combo = ttk.Combobox(frame, textvariable=self.RESIZE_MODE, values=["Shortest Side", "Fit"], state="readonly", width=12)
        mode_combo.pack(side=tk.LEFT, padx=(0, 10))
        mode_combo.bind("<<ComboboxSelected>>", self.update_resize_options)

        self.width_label = ttk.Label(frame, text="Width:")
        self.width_entry = ttk.Entry(frame, textvariable=self.RESIZE_WIDTH, width=5)
        self.height_label = ttk.Label(frame, text="Height:")
        self.height_entry = ttk.Entry(frame, textvariable=self.RESIZE_HEIGHT, width=5)
        self.maintain_aspect_ratio = tk.BooleanVar(value=False)
        self.maintain_aspect_ratio_label = ttk.Label(frame, text="Maintain Aspect Ratio", font=self.default_font) 
        self.maintain_aspect_ratio_checkbox = ttk.Checkbutton(frame, variable=self.maintain_aspect_ratio)
        self.side_label = ttk.Label(frame, text="Side:")
        self.side_entry = ttk.Entry(frame, textvariable=self.RESIZE_SIDE, width=5)

        self.update_resize_options()

    def create_options_and_start_frame(self, parent):
        frame = ttk.Frame(parent)
        frame.pack(fill=tk.X, pady=(0, 10))

        self.create_options_frame(frame)
        self.create_stop_button(frame)

    def create_options_frame(self, parent):
        options_frame = ttk.Frame(parent)
        options_frame.pack(side=tk.LEFT, fill=tk.X, expand=True)

        options = [
            ("Check For Corrupted Files", "4"),
            ("Analyze Images", "2"),
            ("Find Color Images", "3"),
            ("Resize Images", "1"),
            ("Automatic Color Correction", "5"),
            ("Automatic Color Correction + Resize", "6")
        ]

        for col, (text, value) in enumerate(options):
            ttk.Radiobutton(options_frame, text=text, variable=self.option_var, value=value).grid(row=0, column=col, sticky="ew")
            options_frame.columnconfigure(col, weight=1)

    def create_stop_button(self, parent):
        self.stop_button = ttk.Button(parent, text="Stop", command=self.stop_processing, width=20, state=tk.DISABLED)
        self.stop_button.pack(side=tk.RIGHT, padx=(10, 0))

    def create_text_frame(self, parent):
        text_frame = ttk.Frame(parent)
        text_frame.pack(fill=tk.BOTH, expand=True)

        self.notebook = ttk.Notebook(text_frame)
        self.notebook.pack(fill=tk.BOTH, expand=True)

        self.text_areas = {}
        for tab in ["Log", "Black", "White", "Color"]:
            frame = ttk.Frame(self.notebook)
            self.notebook.add(frame, text=tab)
            self.text_areas[tab] = self.create_text_area(frame)

    def create_text_area(self, parent):
        frame = ttk.Frame(parent)
        frame.pack(fill=tk.BOTH, expand=True)

        text_area = tk.Text(frame, wrap=tk.NONE, bg="#ffffff", state='disabled', padx=7, pady=7, fg="#000000", font=self.default_font)
        scrollbar_y = ttk.Scrollbar(frame, command=text_area.yview)
        scrollbar_x = ttk.Scrollbar(frame, command=text_area.xview, orient='horizontal')

        scrollbar_y.pack(side=tk.RIGHT, fill=tk.Y)
        text_area.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
        scrollbar_x.pack(side=tk.BOTTOM, fill=tk.X)

        text_area.config(yscrollcommand=scrollbar_y.set, xscrollcommand=scrollbar_x.set)
        return text_area

    def create_progress_bar(self, parent):
        self.progress = ttk.Progressbar(parent, mode="determinate", maximum=Config.PROGRESS_BAR_LENGTH)
        self.progress.pack(pady=(30, 0), padx=20, fill="x")
        self.progress.config(value=0)

    def create_status_label(self, parent):
        ttk.Label(parent, textvariable=self.status_text).pack(pady=(10, 15))

    def setup_logging(self):
        self.queue_thread = threading.Thread(target=self.update_text_area_from_queue, daemon=True)
        self.queue_thread.start()

    def set_default_folder(self, entry, default_folder):
        entry.delete(0, tk.END)
        entry.insert(0, str(default_folder))
        return Path(default_folder)

    def set_default_input(self):
        Config.INPUT_FOLDER = self.set_default_folder(self.input_entry, Config.DEFAULT_INPUT_FOLDER)

    def set_default_output(self):
        Config.OUTPUT_FOLDER = self.set_default_folder(self.output_entry, Config.DEFAULT_OUTPUT_FOLDER)

    def select_folder(self, entry: ttk.Entry, config_attr: str) -> None:
        selected_folder = filedialog.askdirectory()
        if selected_folder:
            path = Path(selected_folder).resolve()
            entry.delete(0, tk.END)
            entry.insert(0, str(path))
            setattr(Config, config_attr, path)

    def select_input_folder(self):
        self.select_folder(self.input_entry, 'INPUT_FOLDER')

    def select_output_folder(self):
        self.select_folder(self.output_entry, 'OUTPUT_FOLDER')

    def update_status_text(self, text):
        self.status_text.set(text)
        self.update_idletasks()

    def update_progress(self, value):
        self.progress['value'] = min(value, Config.PROGRESS_BAR_LENGTH)
        self.update_idletasks()

    def update_text_area(self, content_key=None, message=None):
        for tab_key, text_area in self.text_areas.items():
            text_area.configure(state='normal')
            text_area.delete("1.0", tk.END)
            content = "\n".join(shared_state.tab_messages[tab_key])
            text_area.insert(tk.END, content)
            text_area.configure(state='disabled')

    def start_processing(self):
        shared_state.stop_event.clear()
        self.start_button.config(state=tk.DISABLED)
        self.stop_button.config(state=tk.NORMAL)
        self.process_start_time = time.time()
        if hasattr(self, 'stopped_by_user'):
            delattr(self, 'stopped_by_user')
        choice = self.option_var.get()

        actions = {
            "1": ("1. Resize Images", resize_images, Config.INPUT_FOLDER, Config.OUTPUT_FOLDER, image_processor),
            "2": ("2. Analyze Images", analyze_images, image_processor),
            "3": ("3. Find Color Images", find_color_images, image_processor),
            "4": ("4. Check For Corrupted Files", check_for_corrupted_files),
            "5": ("5. Automatic Color Correction", process_folder, Config.INPUT_FOLDER, image_processor),
            "6": ("6. Automatic Color Correction + Resize", process_folder, Config.INPUT_FOLDER, image_processor, True),
        }

        label, func, *args = actions.get(choice, ("Unknown Action", lambda: None))
        log_and_execute(choice, label, func, *args)

    def stop_processing(self):
        shared_state.stop_event.set()
        self.stopped_by_user = True
        self.stop_button.config(state=tk.DISABLED)
        self.start_button.config(state=tk.NORMAL)

    def update_resize_options(self, event=None):
        mode = self.RESIZE_MODE.get()
        if mode == "Fit":
            self.side_label.pack_forget()
            self.side_entry.pack_forget()
            self.width_label.pack(side=tk.LEFT, padx=(10, 5))
            self.width_entry.pack(side=tk.LEFT, padx=(0, 10))
            self.height_label.pack(side=tk.LEFT, padx=(0, 5))
            self.height_entry.pack(side=tk.LEFT, padx=(0, 10))
            self.maintain_aspect_ratio_label.pack(side=tk.LEFT, padx=(10, 0))
            self.maintain_aspect_ratio_checkbox.pack(side=tk.LEFT, padx=(0, 10))
        else:  # Shortest Side
            self.width_label.pack_forget()
            self.width_entry.pack_forget()
            self.height_label.pack_forget()
            self.height_entry.pack_forget()
            self.maintain_aspect_ratio_checkbox.pack_forget()
            self.maintain_aspect_ratio_label.pack_forget()
            self.side_label.pack(side=tk.LEFT, padx=(10, 5))
            self.side_entry.pack(side=tk.LEFT, padx=(0, 10))

    def reset_resize_options(self):
        self.RESIZE_MODE.set(Config.DEFAULT_RESIZE_MODE)
        self.RESIZE_WIDTH.set(str(Config.DEFAULT_RESIZE_WIDTH))
        self.RESIZE_HEIGHT.set(str(Config.DEFAULT_RESIZE_HEIGHT))
        self.RESIZE_SIDE.set(str(Config.DEFAULT_RESIZE_SIDE))
        self.RESIZE_FORMAT.set(Config.DEFAULT_RESIZE_FORMAT)
        self.update_resize_options()

    def update_text_area_from_queue(self):
        while True:
            if not shared_state.log_message_queue.empty():
                try:
                    item = shared_state.log_message_queue.get(block=True, timeout=0.1)
                    self.process_log_message(item)
                except:
                    pass
            else:
                time.sleep(0.1)

    def process_log_message(self, item):
        update_tabs = {"UPDATE_LOG": "Log", "UPDATE_BLACK": "Black", "UPDATE_WHITE": "White", "UPDATE_COLOR": "Color"}
        if item in update_tabs:
            self.after(0, self.update_text_area, update_tabs[item])
        else:
            self.update_tab_messages(item)

    def update_tab_messages(self, item):
        if "Modal Value Black" in item:
            shared_state.tab_messages["Black"].append(item)
        if "Modal Value White" in item:
            shared_state.tab_messages["White"].append(item)
        if "Filename" in item:
            shared_state.tab_messages["Color"].append(item)
        shared_state.tab_messages["Log"].append(item)

        for tab in ["Log", "Black", "White", "Color"]:
            self.after(0, self.update_text_area, tab)

    def update_final_status(self, execution_time):
        self.update_progress(100)
        end_time = time.time()
        start_time = self.process_start_time
        processed_count = shared_state.processed_files_count
        total_files = shared_state.total_files_count

        start_time_str = time.strftime('%H:%M:%S', time.localtime(start_time))
        elapsed_minutes = int(execution_time // 60)
        elapsed_seconds = int(execution_time % 60)
        elapsed_time_str = f"{elapsed_minutes:02d}:{elapsed_seconds:02d}"

        status_text = (
            f"Start Time: {start_time_str} - "
            f"Processed Files: {processed_count}/{total_files} - "
            f"Elapsed Time: {elapsed_time_str} Minutes"
        )
    
        if hasattr(self, 'stopped_by_user') and self.stopped_by_user:
            status_text += " - Processing stopped by user"
            delattr(self, 'stopped_by_user')

        padded_status_text = status_text.ljust(Config.PADDING_LENGTH)
        self.update_status_text(padded_status_text)
        self.reset_gui()

    def reset_gui(self):
        self.start_button.config(state=tk.NORMAL)
        self.stop_button.config(state=tk.DISABLED)

# --- Main Execution ---

if __name__ == "__main__":
    shared_state = Config.initialize()
    image_processor = ImageProcessor()
    app = App()
    app.mainloop()