# -*- coding: utf-8 -*-
"""
Created on Thu Nov 20 04:37:59 2025

@author: mstep
"""
# Terry Pratchett image from https://commons.wikimedia.org/wiki/File:Terry_Pratchett_(3x4_cropped).jpg

from PIL import Image, ImageDraw

class Filter:
    '''A public class to represent filters.'''
    def __init__(self, kernel, weight=None, offset=0):
        # Make sure the kernel has an odd number of rows and columns.
        if len(kernel) % 2 == 0:
            raise ValueError('The kernel must have an odd number of rows')
        if len(kernel[0]) % 2 == 0:
            raise ValueError('The kernel must have an odd number of columns')

        # Save values.
        self.kernel = kernel
        if weight is None:
            self.normalize()
        else:
            self.weight = weight
        self.offset = offset

    def get_total(self):
        '''Return the kernel's total weight.'''
        total = 0
        for row in self.kernel:
            total += sum(row)
        return total

    def normalize(self):
        '''Set the filter's weight equal to the sum of the kernel's values.'''
        self.weight = self.get_total()

    def __str__(self):
        '''Return a string represention of the filter.'''
        return f'{self.kernel}\nweight = {self.weight}, offset = {self.offset}'

    def apply(self, image):
        '''Apply the filter to a PIL image.'''
        # Get the pixels.
        pixels = image.load()

        # Make a copy.
        result_image = image.copy()

        # Fill the image with a default color if desired.
        dr = ImageDraw.Draw(result_image)
        dr.rectangle((0, 0, result_image.width, result_image.height),
                     fill='white')

        # Load the result pixels.
        result_pixels = result_image.load()

        # Find the number of rows and columns, and the middle row and column.
        num_rows = len(self.kernel)
        num_cols = len(self.kernel[0])
        mid_y = num_rows // 2
        mid_x = num_cols // 2

        # Get bounds for pixels to which we can apply the kernel.
        min_y = mid_y
        max_y = image.height - mid_y
        min_x = mid_x
        max_x = image.width - mid_x

        # Apply the filter.
        for y in range(min_y, max_y):
            for x in range(min_x, max_x):
                # Apply the filters to pixel[x, y]
                # Process the pixels under the kernel.
                red = green = blue = 0
                for dy in range(num_rows):
                    pix_y = y + dy - mid_y
                    for dx in range(num_cols):
                        pix_x = x + dx - mid_x
                        pix_red, pix_green, pix_blue = pixels[pix_x, pix_y]

                        red   += self.kernel[dy][dx] * pix_red
                        green += self.kernel[dy][dx] * pix_green
                        blue  += self.kernel[dy][dx] * pix_blue

                # Divide by the weight, add the offset, and
                # make sure the result is between 0 and 255.
                red = int(self.offset + red / self.weight)
                red = max(min(red, 255), 0)

                green = int(self.offset + green / self.weight)
                green = max(min(green, 255), 0)

                blue = int(self.offset + blue / self.weight)
                blue = max(min(blue, 255), 0)

                # Set the new pixel's value.
                result_pixels[x, y] = (red, green, blue)

        # Return the result.
        return result_image

    # Filters.
    @classmethod
    def embossing_filter1(cls):
        '''Create a standard embossing filter.'''
        return cls(
            [
                [-1,  0,  0],
                [ 0,  0,  0],
                [ 0,  0,  1],
            ], weight=1, offset=127)

    @classmethod
    def embossing_filter2(cls):
        '''Create a standard embossing filter.'''
        return cls(
            [
                [ 2,  0,  0],
                [ 0, -1,  0],
                [ 0,  0, -1],
            ], weight=1, offset=127)

    @classmethod
    def box_filter(cls, size):
        '''Create a {size}x{size} low-pass filter.'''
        return cls([[1 for _ in range(size)] for _ in range(size)])

    @classmethod
    def sharpen_filter1(cls):
        '''Create a 3x3 high-pass filter.'''
        return cls(
            [
                [ 0, -1,  0],
                [-1,  5, -1],
                [ 0, -1,  0],
            ])

    @classmethod
    def sharpen_filter2(cls):
        '''Create a 3x3 high-pass filter.'''
        return cls(
            [
                [-1, -1, -1],
                [-1,  9, -1],
                [-1, -1, -1],
            ])

    @classmethod
    def sharpen_filter3(cls):
        '''Create a 5x5 high-pass filter.'''
        return cls(
            [
                [-1, -1, -1, -1, -1],
                [-1,  1,  2,  1, -1],
                [-1,  2,  5,  2, -1],
                [-1,  1,  2,  1, -1],
                [-1, -1, -1, -1, -1],
            ])

    @classmethod
    def edge_filter1(cls):
        '''Create an edge detection filter.'''
        # Get a sharpening filter.
        filter = cls.sharpen_filter1()

        # Set the middle entry to make the total 0.
        filter.kernel[1][1] = filter.kernel[1][1] - filter.get_total()
        return filter

    @classmethod
    def edge_filter2(cls):
        '''Create an edge detection filter.'''
        # Get a sharpening filter.
        filter = cls.sharpen_filter2()

        # Set the middle entry to make the total 0.
        filter.kernel[1][1] = filter.kernel[1][1] - filter.get_total()
        return filter

#%%
from PIL import Image, ImageChops, ImageEnhance

class UnsharpMaskingFilter(Filter):
    def __init__(self, blurring_filter, scale):
        # Use the filter to initialize our kernel, weight, and offset.
        super().__init__(
            blurring_filter.kernel,
            blurring_filter.weight,
            blurring_filter.offset)
        self.scale = scale

    def apply(self, image):
        '''Apply the filter to a PIL image.'''
        # Apply the blurring filter.
        blurred = super().apply(image)

        # Subtract the blurred result from the image.
        subtracted = ImageChops.subtract(image, blurred)

        # Scale the result.
        enhancer = ImageEnhance.Brightness(subtracted)
        brightened = enhancer.enhance(self.scale)

        # Add the scaled difference to the original image.
        return ImageChops.add(image, brightened)

#%%
import tkinter as tk
import tkinter.messagebox as msg
from tkinter.filedialog import askopenfilename, asksaveasfilename

class ScrolledFrame(tk.Frame):
    def __init__(self, frame, *args, **kwargs):
        tk.Frame.__init__(self, frame, *args, **kwargs)

        # Make row 0 and column 0 expand.
        self.columnconfigure(0, weight=1)
        self.rowconfigure(0, weight=1)

        # Scrollbars.
        self.v_scroll = tk.Scrollbar(self, orient=tk.VERTICAL)
        self.v_scroll.grid(row=0, column=1, sticky=tk.NSEW)

        self.h_scroll = tk.Scrollbar(self, orient=tk.HORIZONTAL)
        self.h_scroll.grid(row=1, column=0, sticky=tk.NSEW)

        # Canvas.
        self.canvas = tk.Canvas(self,
            xscrollcommand=self.h_scroll.set,
            yscrollcommand=self.v_scroll.set)
        self.canvas.grid(row=0, column=0, sticky=tk.NSEW)

        # Configure the canvas.
        self.v_scroll.config(command=self.canvas.yview)
        self.h_scroll.config(command=self.canvas.xview)

        # Frame.
        self.frame = tk.Frame(self.canvas)
        self.frame_window = self.canvas.create_window(0, 0,
            window=self.frame, anchor=tk.NW)


    def configure_frame(self):
        self.frame.update_idletasks()
        width = self.frame.winfo_reqwidth()
        height = self.frame.winfo_reqheight()
        self.canvas.config(scrollregion=(0, 0, width, height))

        # Bind the mouse wheel to scroll the canvas.
        for child in widget_and_descendants(self.canvas):
            child.bind('<MouseWheel>', self.wheel_scroll)
            #child.bind('<Button-4>', self.wheel_scroll)
            #child.bind('<Button-5>', self.wheel_scroll)

    def wheel_scroll(self, event):
        ''' Scroll the canvas.'''
        # This is the Windows version.
        # Divide event.delta by some number to slow scrolling. Some people
        # recommend dividing by 120 for Windows, but I left it alone to make
        # it scroll about the same speed when the mouse is over the scrollbar.
        units = -int(event.delta / 30)
        self.canvas.yview_scroll(units, 'units')

def widget_and_descendants(parent):
    '''Return this widget and all of its descendants.'''
    results = [parent]
    for child in parent.winfo_children():
        results += widget_and_descendants(child)
    return results

#%%
import tkinter as tk
from PIL import Image, ImageTk

class ImageFiltersApp:
    def __init__(self):
        # Make the main interface.
        self.window = tk.Tk()
        self.window.title('image_filters_v3')
        self.window.protocol('WM_DELETE_WINDOW', self.kill_callback)
        self.window.geometry('350x400')

        # Build the File menu.
        self.build_menus()

        # Make a ScrolledFrame.
        self.scrolled_frame = ScrolledFrame(self.window)
        self.scrolled_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True)

        # Make the canvas.
        self.canvas = tk.Canvas(self.scrolled_frame.frame, width=10, height=10)
        self.canvas.grid(row=0, column=0)

        # Configure the frame. (Do this after adding all contained widgets.)
        self.scrolled_frame.configure_frame()

        # Display the window.
        self.window.focus_force()
        self.window.mainloop()

    def show_result(self):
        '''Display the new image on the canvas.'''
        self.tk_image = ImageTk.PhotoImage(self.pil_image)
        self.canvas.delete(tk.ALL)
        self.canvas.create_image(0, 0, anchor=tk.NW,
                                 image=self.tk_image)

    def build_menus(self):
        '''Build the menu bar.'''
        self.menu_bar = tk.Menu(self.window)
        self.window.config(menu=self.menu_bar)
        self.build_file_menu(self.menu_bar)
        self.build_emboss_menu(self.menu_bar)
        self.build_blur_menu(self.menu_bar)
        self.build_sharpen_menu(self.menu_bar)
        self.build_unsharp_menu(self.menu_bar)
        self.build_edge_menu(self.menu_bar)

    def build_file_menu(self, menu_bar):
        '''Build the File menu.'''
        self.file_menu = tk.Menu(menu_bar, tearoff=False)
        menu_bar.add_cascade(label='File', menu=self.file_menu)

        # Note: You must save the photo image in a persistent
        # variable so tkinter can redraw it when needed.

        # Note: You need to include the compound parameter
        # if you want to use both images and text. That value
        # tells where to put the image relative to the text.

        # Open
        self.open_photo_image = ImageTk.PhotoImage(file='open.png')
        self.file_menu.add_command(
            label='Open...', accelerator='Ctrl+O', compound='left',
            image=self.open_photo_image, command=self.mnu_open)
        self.window.bind('<Control-o>', lambda event: self.mnu_open())

        # Save As
        self.save_as_photo_image = ImageTk.PhotoImage(file='save_as.png')
        self.file_menu.add_command(
            label='Save As...', accelerator='Ctrl+O', compound='left',
            image=self.save_as_photo_image, command=self.mnu_save_as,
            state=tk.DISABLED)
        self.window.bind('<Control-s>', lambda event: self.mnu_save_as())

        self.file_menu.add_separator()

        # Restore.
        self.restore_photo_image = ImageTk.PhotoImage(file='restore_image.png')
        self.file_menu.add_command(
            label='Restore Image', accelerator='Ctrl+R', compound='left',
            image=self.restore_photo_image, command=self.mnu_restore_image,
            state=tk.DISABLED)
        self.window.bind('<Control-r>',
                         lambda event: self.mnu_restore_image())

        self.file_menu.add_separator()

        # Exit
        self.exit_photo_image = ImageTk.PhotoImage(file='exit.png')
        self.file_menu.add_command(label='Exit', compound='left',
            command=self.kill_callback, image=self.exit_photo_image)

    def mnu_open(self):
        '''Let the user select the file to open.'''
        filetypes = [
            ('Image Files', '*.png *.jpg *.gif *.bmp'),
            ('All Files', '*.*')]
        filename = askopenfilename(filetypes=filetypes)
        if len(filename) > 0:
            # Load the image.
            try:
                # Load the image.
                self.original_image = Image.open(filename).convert('RGB')
                self.pil_image = self.original_image.copy()

                # Size the canvas to fit.
                self.canvas.config(width=self.pil_image.width,
                                   height=self.pil_image.height)
                self.show_result()

                for menu_name in ['Emboss', 'Blur', 'Sharpen',
                                  'Unsharp Masking', 'Edge']:
                    self.menu_bar.entryconfig(menu_name, state=tk.NORMAL)
                self.file_menu.entryconfig('Save As...', state=tk.NORMAL)
                self.file_menu.entryconfig('Restore Image', state=tk.NORMAL)
            except Exception as e:
                msg.showerror('File Open Error', e)

            # Configure the frame.
            self.scrolled_frame.configure_frame()

    def mnu_save_as(self):
        '''Let the user select the file where we should save the result.'''
        filetypes = [
            ('Image Files', '*.png *.jpg *.gif *.bmp'),
            ('All Files', '*.*')]
        filename = asksaveasfilename(filetypes=filetypes,
                                     defaultextension=".jpg")
        if len(filename) > 0:
            try:
                self.pil_image.save(filename)
            except Exception as e:
                msg.showerror('File Save Error', e)

    def mnu_restore_image(self):
        '''Restore the original image.'''
        self.pil_image = self.original_image.copy()
        self.show_result()

    def build_emboss_menu(self, menu_bar):
        '''Build the Emboss menu.'''
        emboss_menu = tk.Menu(menu_bar, tearoff=False)
        menu_bar.add_cascade(label='Emboss', menu=emboss_menu, state=tk.DISABLED)

        emboss_menu.add_command(label='Emboss 1', accelerator='Ctrl+1',
                                 command=self.mnu_emboss1)
        emboss_menu.add_command(label='Emboss 2', accelerator='Ctrl+2',
                                 command=self.mnu_emboss2)

        self.window.bind('<Control-Key-1>', lambda event: self.mnu_emboss1())
        self.window.bind('<Control-Key-2>', lambda event: self.mnu_emboss2())

    def mnu_emboss1(self):
        '''Apply an embossing filter.'''
        self.pil_image = Filter.embossing_filter1().apply(self.pil_image)
        self.show_result()

    def mnu_emboss2(self):
        '''Apply an embossing filter.'''
        self.pil_image = Filter.embossing_filter2().apply(self.pil_image)
        self.show_result()

    def build_blur_menu(self, menu_bar):
        '''Build the Blur menu.'''
        blur_menu = tk.Menu(menu_bar, tearoff=False)
        menu_bar.add_cascade(label='Blur', menu=blur_menu, state=tk.DISABLED)

        blur_menu.add_command(label='3x3 Box', accelerator='Ctrl+3',
                                 command=lambda: self.mnu_box(3))
        blur_menu.add_command(label='5x5 Box', accelerator='Ctrl+4',
                                 command=lambda: self.mnu_box(5))
        blur_menu.add_command(label='7x7 Box', accelerator='Ctrl+5',
                                 command=lambda: self.mnu_box(7))

        self.window.bind('<Control-Key-3>', lambda event: self.mnu_box(3))
        self.window.bind('<Control-Key-4>', lambda event: self.mnu_box(5))
        self.window.bind('<Control-Key-5>', lambda event: self.mnu_box(7))

    def mnu_box(self, size):
        '''Apply a low-pass filter.'''
        self.pil_image = Filter.box_filter(size).apply(self.pil_image)
        self.show_result()

    def build_sharpen_menu(self, menu_bar):
        '''Build the Sharpen menu.'''
        sharpen_menu = tk.Menu(menu_bar, tearoff=False)
        menu_bar.add_cascade(label='Sharpen', menu=sharpen_menu, state=tk.DISABLED)

        sharpen_menu.add_command(label='Sharpen 1', accelerator='Ctrl+6',
                                 command=self.mnu_sharpen1)
        sharpen_menu.add_command(label='Sharpen 2', accelerator='Ctrl+7',
                                 command=self.mnu_sharpen2)
        sharpen_menu.add_command(label='Sharpen 3', accelerator='Ctrl+8',
                                 command=self.mnu_sharpen3)

        self.window.bind('<Control-Key-6>', lambda event: self.mnu_sharpen1())
        self.window.bind('<Control-Key-7>', lambda event: self.mnu_sharpen2())
        self.window.bind('<Control-Key-8>', lambda event: self.mnu_sharpen3())

    def mnu_sharpen1(self):
        '''Apply a high-pass filter.'''
        self.pil_image = Filter.sharpen_filter1().apply(self.pil_image)
        self.show_result()

    def mnu_sharpen2(self):
        '''Apply a high-pass filter.'''
        self.pil_image = Filter.sharpen_filter2().apply(self.pil_image)
        self.show_result()

    def mnu_sharpen3(self):
        '''Apply a high-pass filter.'''
        self.pil_image = Filter.sharpen_filter3().apply(self.pil_image)
        self.show_result()

    def build_edge_menu(self, menu_bar):
        '''Build the Edge menu.'''
        edge_menu = tk.Menu(menu_bar, tearoff=False)
        menu_bar.add_cascade(label='Edge', menu=edge_menu, state=tk.DISABLED)

        edge_menu.add_command(label='Edge 1', accelerator='Ctrl+9',
                                 command=self.mnu_edge1)
        edge_menu.add_command(label='Edge 2', accelerator='Ctrl+0',
                                 command=self.mnu_edge1)

        self.window.bind('<Control-Key-9>', lambda event: self.mnu_edge1())
        self.window.bind('<Control-Key-0>', lambda event: self.mnu_edge2())

    def mnu_edge1(self):
        '''Apply a high-pass edge detection filter.'''
        self.pil_image = Filter.edge_filter1().apply(self.pil_image)
        self.show_result()

    def mnu_edge2(self):
        '''Apply a high-pass edge detection filter.'''
        self.pil_image = Filter.edge_filter2().apply(self.pil_image)
        self.show_result()


    def build_unsharp_menu(self, menu_bar):
        '''Build the Unsharp Masking menu.'''
        unsharp_menu = tk.Menu(menu_bar, tearoff=False)
        menu_bar.add_cascade(label='Unsharp Masking', menu=unsharp_menu, state=tk.DISABLED)

        unsharp_menu.add_command(label='Unsharp Masking 3x3 Scale 1',
                                 command=self.mnu_unsharp_mask1)
        unsharp_menu.add_command(label='Unsharp Masking 3x3 Scale 3',
                                 command=self.mnu_unsharp_mask2)
        unsharp_menu.add_command(label='Unsharp Masking 5x5 Scale 1',
                                 command=self.mnu_unsharp_mask3)

    def mnu_unsharp_mask1(self):
        '''Apply unsharp masking.'''
        blurrer = Filter.box_filter(3)
        unsharp_filter = UnsharpMaskingFilter(blurrer, 1)
        self.pil_image = unsharp_filter.apply(self.pil_image)
        self.show_result()

    def mnu_unsharp_mask2(self):
        '''Apply unsharp masking.'''
        blurrer = Filter.box_filter(3)
        unsharp_filter = UnsharpMaskingFilter(blurrer, 3)
        self.pil_image = unsharp_filter.apply(self.pil_image)
        self.show_result()

    def mnu_unsharp_mask3(self):
        '''Apply unsharp masking.'''
        blurrer = Filter.box_filter(5)
        unsharp_filter = UnsharpMaskingFilter(blurrer, 1)
        self.pil_image = unsharp_filter.apply(self.pil_image)
        self.show_result()

    def kill_callback(self):
        self.window.destroy()

#%%

ImageFiltersApp()
print('Done')
