import tkinter as tk from tkinter import filedialog, colorchooser, messagebox from PIL import ImageTk, ImageDraw, Image from ImageContainer import ImageContainer from ImageManipulation.ManipulationList import * from ImageManipulation.Filters import GaussianBlur, SobelEdge, BinaryThreshold, HistogramThreshold from ImageManipulation.ZoomImage import ZoomImage from ImageManipulation.FlipImage import FlipImage from ImageManipulation.RotateImage import RotateImage from SelectionArea import SelectionArea from utils.area_selection import AreaSelectionHandler from utils.brush import BrushHandler from utils.text_entry import TextEntryHandler from utils.file_operations import FileOperationsHandler from utils.image_properties import ImagePropertiesHandler from utils.filter_params import FilterParamsHandler from ImageManipulation.CopyToClipboard import CopyToClipboard from ImageManipulation.PasteFromClipboard import PasteFromClipboard from ImageManipulation.CutToClipboard import CutToClipboard from functools import partial class GUI: """The GUI class responsible for the main application GUI. @warning This class is a singleton. """ _instance = None # If the GUI has been initialised. _isInitialised = False _currentImage = None # Area selection handler (will be initialized in initialise()) _areaSelectionHandler = None # Brush handler (will be initialized in initialise()) _brushHandler = None # Text entry handler (will be initialized in initialise()) _textEntryHandler = None # File operations handler (will be initialized in initialise()) _fileOperationsHandler = None # Image properties handler (will be initialized in initialise()) _imagePropertiesHandler = None # Filter parameters handler (will be initialized in initialise()) _filterParamsHandler = None _canvasImageWidth = None # Store canvas image dimensions _canvasImageHeight = None def __new__(cls): if cls._instance is None: cls._instance = super(GUI, cls).__new__(cls) return cls._instance def initialise(self): """Initialise the GUI.""" if self._isInitialised: return else: self._isInitialised = True # Main window root = tk.Tk() root.title("Image Viewer") root.geometry("1100x900") root.config(bg="white") icon = tk.PhotoImage(file='icon.png') root.tk.call('wm', 'iconphoto', root._w, icon) root.minsize(1100, 600) # Menus menu = tk.Menu(root) root.config(menu=menu) file_menu = tk.Menu(menu, tearoff=0) menu.add_cascade(label="File", menu=file_menu) file_menu.add_command(label="New", accelerator="Ctrl+N", command=lambda: self._newImage()) file_menu.add_command(label="Open", accelerator="Ctrl+O", command=lambda: self._openImage()) file_menu.add_command(label="Open Camera", accelerator="Ctrl+K", command=lambda: self._openCamera()) file_menu.add_separator() file_menu.add_command(label="Save", accelerator="Ctrl+S", command=lambda: self._saveImage()) file_menu.add_command(label="Save As", command=lambda: self._saveImageAs()) file_menu.add_separator() file_menu.add_command(label="Properties", command=lambda: self._showProperties()) file_menu.add_separator() file_menu.add_command(label="Exit", accelerator="Ctrl+Q", command=root.quit) test_menu = tk.Menu(menu, tearoff=0) menu.add_cascade(label="Test", menu=test_menu) for manipulation in GetImageManipulationList(): test_menu.add_command( label=manipulation.getManipulationName(), command=partial(self._applyManipulation, manipulation) ) edit_menu = tk.Menu(menu, tearoff=0) menu.add_cascade(label="Edit", menu=edit_menu) edit_menu.add_command(label="Undo", accelerator="Ctrl+Z", command=lambda: self._undo()) edit_menu.add_command(label="Redo", accelerator="Ctrl+Y", command=lambda: self._redo()) edit_menu.add_separator() edit_menu.add_command(label="Toggle Selection Mode", accelerator="Ctrl+Shift+S", command=lambda: self._toggleSelectionMode()) edit_menu.add_command(label="Toggle Selection Shape (Rect/Circle/Lasso)", accelerator="Ctrl+Shift+C", command=lambda: self._toggleSelectionShape()) edit_menu.add_separator() edit_menu.add_command(label="Brush Tool", accelerator="Ctrl+B", command=lambda: self._toggleBrushMode()) image_menu = tk.Menu(menu, tearoff=0) menu.add_cascade(label="Image", menu=image_menu) # Select submenu select_menu = tk.Menu(image_menu, tearoff=0) image_menu.add_cascade(label="Select", menu=select_menu) select_menu.add_command(label="Rectangular Selection", command=lambda: self._setRectangularSelection()) select_menu.add_command(label="Circular Selection", command=lambda: self._setCircularSelection()) select_menu.add_command(label="Free-form Selection (Lasso)", command=lambda: self._setLassoSelection()) select_menu.add_command(label="Polygon Selection", command=lambda: self._setPolygonSelection()) image_menu.add_separator() image_menu.add_command(label="Crop", command=lambda: self._cropImage()) image_menu.add_command(label="Resize", command=lambda: self._resizeImage()) image_menu.add_separator() image_menu.add_command(label="Flip Horizontal", command=lambda: self._flipHorizontal()) image_menu.add_command(label="Flip Vertical", command=lambda: self._flipVertical()) image_menu.add_separator() image_menu.add_command(label="Rotate Right 90°", command=lambda: self._rotateRight()) image_menu.add_command(label="Rotate Left 90°", command=lambda: self._rotateLeft()) filter_menu = tk.Menu(menu, tearoff=0) menu.add_cascade(label="Filter", menu=filter_menu) filter_menu.add_command(label="Gaussian Filter", command=lambda: self._applyGaussianFilter()) filter_menu.add_command(label="Sobel Filter", command=lambda: self._applySobelFilter()) filter_menu.add_command(label="Binary Filter", command=lambda: self._applyBinaryFilter()) filter_menu.add_command(label="Histogram Thresholding", command=lambda: self._applyHistogramThreshold()) clipboard_menu = tk.Menu(menu, tearoff=0) menu.add_cascade(label="Clipboard", menu=clipboard_menu) clipboard_menu.add_command(label="Copy Image", accelerator="Ctrl+C", command=lambda: self._copyImage()) clipboard_menu.add_command(label="Cut Image", accelerator="Ctrl+X", command=lambda: self._cutImage()) clipboard_menu.add_command(label="Paste Image", accelerator="Ctrl+V", command=lambda: self._pasteImage()) tools_menu = tk.Menu(menu, tearoff=0) menu.add_cascade(label="Tools", menu=tools_menu) tools_menu.add_command(label="Zoom In", accelerator="Ctrl+Plus", command=lambda: self._zoomIn()) tools_menu.add_command(label="Zoom Out", accelerator="Ctrl+Minus", command=lambda: self._zoomOut()) shapes_menu = tk.Menu(menu, tearoff=0) menu.add_cascade(label="Shapes", menu=shapes_menu) colors_menu = tk.Menu(menu, tearoff=0) menu.add_cascade(label="Colors", menu=colors_menu) # Main container frame for image and settings main_container = tk.Frame(root, bg="white") main_container.pack(side="top", fill="both", expand=True, padx=10, pady=10) # Frame to hold image imgframe = tk.Frame(main_container, width=500, height=500, bg="lightgray", relief="sunken", bd=2) imgframe.pack(side="left", fill="both", expand=True, padx=(0, 10)) # Canvas to display image and draw selection self._imageCanvas = tk.Canvas(imgframe, width=500, height=500, bg="white") self._imageCanvas.pack(expand=True, fill="both") # Label to hold the image (will be placed on canvas) self._imageLabel = tk.Label(self._imageCanvas, bg="white") # Bind mouse events for interactive selection and brush self._imageCanvas.bind("", self._onMouseClick) self._imageCanvas.bind("", self._onMouseDrag) self._imageCanvas.bind("", self._onMouseRelease) self._imageCanvas.bind("", self._onDoubleClick) # Double-click for polygon completion self._imageCanvas.bind("", self._onRightClick) # Right-click for context menu # Settings panel for brush controls settings_panel = tk.Frame(main_container, width=250, bg="lightgray", relief="sunken", bd=2) settings_panel.pack(side="right", fill="y", padx=(10, 0)) settings_panel.pack_propagate(False) # Prevent frame from shrinking self._root = root # Initialize selection area selection_area = SelectionArea() # Initialize area selection handler def get_current_image(): return self._currentImage def get_canvas_dimensions(): if self._canvasImageWidth and self._canvasImageHeight: return (self._canvasImageWidth, self._canvasImageHeight) else: canvas_width = self._imageCanvas.winfo_width() canvas_height = self._imageCanvas.winfo_height() return (canvas_width, canvas_height) # Area selection handler from utils/area_selection.py self._areaSelectionHandler = AreaSelectionHandler( canvas=self._imageCanvas, get_current_image=get_current_image, get_canvas_dimensions=get_canvas_dimensions, render_image=self._renderCurrentImage, root_window=root, selection_area=selection_area ) # Brush handler from utils/brush.py self._brushHandler = BrushHandler( canvas=self._imageCanvas, get_current_image=get_current_image, get_canvas_dimensions=get_canvas_dimensions, render_image=self._renderCurrentImage, root_window=root, area_selection_handler=self._areaSelectionHandler ) # Create brush UI panel self._brushHandler.create_ui_panel(settings_panel) # Text entry handler from utils/text_entry.py self._textEntryHandler = TextEntryHandler(root) # File operations handler from utils/file_operations.py def set_current_image(image): self._currentImage = image self._fileOperationsHandler = FileOperationsHandler( get_current_image=get_current_image, set_current_image=set_current_image, render_image=self._renderCurrentImage ) # Image properties handler from utils/image_properties.py self._imagePropertiesHandler = ImagePropertiesHandler( get_current_image=get_current_image ) # Filter parameters handler from utils/filter_params.py self._filterParamsHandler = FilterParamsHandler(root) # Key bindings root.bind_all('', lambda event: self._undo()) root.bind_all('', lambda event: self._redo()) root.bind_all('', lambda event: self._newImage()) root.bind_all('', lambda event: self._openImage()) root.bind_all('', lambda event: self._saveImage()) root.bind_all('', lambda event: self._openCamera()) root.bind_all('', lambda event: root.quit()) root.bind_all('', lambda event: self._toggleSelectionMode()) root.bind_all('', lambda event: self._toggleSelectionShape()) root.bind_all('', lambda event: self._toggleBrushMode() if self._brushHandler else None) root.bind_all('', lambda event: self._zoomIn()) root.bind_all('', lambda event: self._zoomIn()) # Plus key without shift root.bind_all('', lambda event: self._zoomOut()) root.bind_all('', lambda event: self._copyImage()) root.bind_all('', lambda event: self._cutImage()) root.bind_all('', lambda event: self._pasteImage()) root.mainloop() def _newImage(self) -> None: """Create a new blank image with user-specified dimensions.""" if self._fileOperationsHandler: self._fileOperationsHandler.new_image() def _openImage(self) -> None: file_path = filedialog.askopenfilename( filetypes=[("Image files", "*.jpg *.jpeg *.png *.gif *.bmp")] ) if not file_path: return self._currentImage = ImageContainer() self._currentImage.loadImage(file_path) self._renderCurrentImage() def _renderCurrentImage(self) -> None: # Clear canvas self._imageCanvas.delete("all") if self._currentImage is None or self._currentImage.getImage() is None: return pil_img = self._currentImage.getImage() width, height = pil_img.size tk_img = ImageTk.PhotoImage(pil_img) # Keep reference to avoid garbage collection self._imageLabel.image = tk_img # Store canvas image dimensions for coordinate conversion self._canvasImageWidth = width self._canvasImageHeight = height # Place image on canvas self._imageCanvas.create_image(0, 0, anchor="nw", image=tk_img) # Update canvas size self._imageCanvas.config(width=width, height=height) # If there's an active selection, draw it if self._areaSelectionHandler and self._areaSelectionHandler.selection_area.is_active and self._areaSelectionHandler.selection_area.is_valid(): self._areaSelectionHandler.draw_selection_highlight() def _applyManipulation(self, manipulation, params: dict | None = None) -> None: if self._currentImage is None: return # Special handling for AddText - show dialog to get text input if manipulation.getManipulationName() == "Add Text" and params is None: if self._textEntryHandler: params = self._textEntryHandler.show_text_input_dialog() if params is None: # User cancelled return # Take undo snapshot self._currentImage.snapshot() if params is None: # default demo params for crop params = {"width": 200, "height": 200} manipulation.manipulateImage(self._currentImage, params) self._renderCurrentImage() def _saveImage(self) -> None: """Save the current image without asking for a name (uses current path).""" if self._fileOperationsHandler: self._fileOperationsHandler.save_image() def _saveImageAs(self) -> None: """Save the current image with a new name (prompts for filename).""" if self._fileOperationsHandler: self._fileOperationsHandler.save_image_as() def _undo(self) -> None: if self._currentImage is None: return self._currentImage.undo() self._renderCurrentImage() def _redo(self) -> None: if self._currentImage is None: return self._currentImage.redo() self._renderCurrentImage() def _toggleSelectionMode(self) -> None: """Toggle interactive selection mode on/off.""" if self._areaSelectionHandler: self._areaSelectionHandler.toggle_selection_mode() def _toggleSelectionShape(self) -> None: """Toggle selection shape between rectangle, circle, and lasso.""" if self._areaSelectionHandler: self._areaSelectionHandler.toggle_selection_shape() def _setRectangularSelection(self) -> None: """Set selection to rectangular mode.""" if self._areaSelectionHandler: self._areaSelectionHandler.set_rectangular_selection() def _setCircularSelection(self) -> None: """Set selection to circular mode.""" if self._areaSelectionHandler: self._areaSelectionHandler.set_circular_selection() def _setLassoSelection(self) -> None: """Set selection to lasso (free-form) mode.""" if self._areaSelectionHandler: self._areaSelectionHandler.set_lasso_selection() def _setPolygonSelection(self) -> None: """Set selection to polygon mode.""" if self._areaSelectionHandler: self._areaSelectionHandler.set_polygon_selection() def _onDoubleClick(self, event) -> None: """Handle double-click for polygon completion.""" if self._areaSelectionHandler: self._areaSelectionHandler.on_double_click(event) def _cropImage(self) -> None: """Crop the image to the selected rectangular area. Starts selection mode if no selection exists.""" if not self._areaSelectionHandler: return # If there's already a valid selection, crop immediately if (self._areaSelectionHandler.selection_area and self._areaSelectionHandler.selection_area.is_active and self._areaSelectionHandler.selection_area.is_valid()): self._areaSelectionHandler.crop_to_selection() else: # Set up rectangular selection mode and callback self._areaSelectionHandler.set_rectangular_selection() self._areaSelectionHandler.set_pending_operation_callback( lambda: self._areaSelectionHandler.crop_to_selection() ) def _resizeImage(self) -> None: """Resize the image to match the selected rectangular area dimensions. Starts selection mode if no selection exists.""" if not self._areaSelectionHandler: return # If there's already a valid selection, resize immediately if (self._areaSelectionHandler.selection_area and self._areaSelectionHandler.selection_area.is_active and self._areaSelectionHandler.selection_area.is_valid()): self._areaSelectionHandler.resize_to_selection_dimensions() else: # Set up rectangular selection mode and callback self._areaSelectionHandler.set_rectangular_selection() self._areaSelectionHandler.set_pending_operation_callback( lambda: self._areaSelectionHandler.resize_to_selection_dimensions() ) def _flipHorizontal(self) -> None: """Flip the image horizontally.""" if self._currentImage is None: messagebox.showwarning("No Image", "Please load an image first.") return manipulation = FlipImage() params = {"mode": "horizontal"} self._applyManipulation(manipulation, params) def _flipVertical(self) -> None: """Flip the image vertically.""" if self._currentImage is None: messagebox.showwarning("No Image", "Please load an image first.") return manipulation = FlipImage() params = {"mode": "vertical"} self._applyManipulation(manipulation, params) def _rotateRight(self) -> None: """Rotate the image 90 degrees to the right (clockwise).""" if self._currentImage is None: messagebox.showwarning("No Image", "Please load an image first.") return manipulation = RotateImage() params = {"angle": -90} # Negative angle for clockwise rotation self._applyManipulation(manipulation, params) def _rotateLeft(self) -> None: """Rotate the image 90 degrees to the left (counter-clockwise).""" if self._currentImage is None: messagebox.showwarning("No Image", "Please load an image first.") return manipulation = RotateImage() params = {"angle": 90} # Positive angle for counter-clockwise rotation self._applyManipulation(manipulation, params) def _onMouseClick(self, event) -> None: """Handle mouse click for selection or brush.""" # Handle brush mode - delegate to handler if self._brushHandler and self._brushHandler.on_mouse_click(event): return # Handle selection mode - delegate to handler if self._areaSelectionHandler: self._areaSelectionHandler.on_mouse_click(event) def _onMouseDrag(self, event) -> None: """Handle mouse drag for selection or brush.""" # Handle brush mode - delegate to handler if self._brushHandler and self._brushHandler.on_mouse_drag(event): return # Handle selection mode - delegate to handler if self._areaSelectionHandler: self._areaSelectionHandler.on_mouse_drag(event) def _onMouseRelease(self, event) -> None: """Handle mouse release to finalize selection or stop brush.""" # Handle brush mode - delegate to handler if self._brushHandler and self._brushHandler.on_mouse_release(event): return # Handle selection mode - delegate to handler if self._areaSelectionHandler: self._areaSelectionHandler.on_mouse_release(event) def _onRightClick(self, event) -> None: """Handle right-click to show context menu for manipulation options.""" if self._areaSelectionHandler: self._areaSelectionHandler.on_right_click(event, self._getDefaultParams) def _getDefaultParams(self, manipulation) -> dict: """Get default parameters for a manipulation.""" manipulation_name = manipulation.getManipulationName().lower() if "crop" in manipulation_name: return {"width": 200, "height": 200} elif "resize" in manipulation_name: return {"width": 200, "height": 200} elif "rotate" in manipulation_name: return {"angle": 90} elif "flip" in manipulation_name: return {"mode": "horizontal"} elif "color" in manipulation_name or "adjust" in manipulation_name: return {"brightness": 10, "contrast": 1.2, "saturation": 1.1} elif "blur" in manipulation_name: return {"ksize": 5} elif "hue" in manipulation_name: return {"hue": 50} elif "padding" in manipulation_name: return {"border_width": 50} elif "sobel" in manipulation_name: return {"dx": 1, "dy": 0, "ksize": 3} elif "threshold" in manipulation_name: return {"thresh": 127} elif "text" in manipulation_name or "add text" in manipulation_name: return {"text": "Hello World", "x": 10, "y": 10, "font_size": 40, "color": (0, 0, 0)} else: return {} def _toggleBrushMode(self) -> None: """Toggle brush mode on/off.""" if self._brushHandler: self._brushHandler.toggle_brush_mode() def _showProperties(self) -> None: """Display image properties in a dialog.""" if self._imagePropertiesHandler: self._imagePropertiesHandler.show_properties() def _applyGaussianFilter(self) -> None: """Apply Gaussian blur filter to the image.""" if self._currentImage is None: messagebox.showwarning("No Image", "Please load an image first.") return # Get default parameters default_params = self._getDefaultParams(GaussianBlur()) default_ksize = default_params.get("ksize", 5) # Show parameter dialog if self._filterParamsHandler: params = self._filterParamsHandler.show_gaussian_filter_dialog(default_ksize) if params is None: # User cancelled return else: params = default_params manipulation = GaussianBlur() self._applyManipulation(manipulation, params) def _applySobelFilter(self) -> None: """Apply Sobel edge detection filter to the image.""" if self._currentImage is None: messagebox.showwarning("No Image", "Please load an image first.") return # Get default parameters default_params = self._getDefaultParams(SobelEdge()) default_dx = default_params.get("dx", 1) default_dy = default_params.get("dy", 0) default_ksize = default_params.get("ksize", 3) # Show parameter dialog if self._filterParamsHandler: params = self._filterParamsHandler.show_sobel_filter_dialog(default_dx, default_dy, default_ksize) if params is None: # User cancelled return else: params = default_params manipulation = SobelEdge() self._applyManipulation(manipulation, params) def _applyBinaryFilter(self) -> None: """Apply binary threshold filter to the image.""" if self._currentImage is None: messagebox.showwarning("No Image", "Please load an image first.") return # Get default parameters default_params = self._getDefaultParams(BinaryThreshold()) default_thresh = default_params.get("thresh", 127) # Show parameter dialog if self._filterParamsHandler: params = self._filterParamsHandler.show_binary_filter_dialog(default_thresh) if params is None: # User cancelled return else: params = default_params manipulation = BinaryThreshold() self._applyManipulation(manipulation, params) def _applyHistogramThreshold(self) -> None: """Apply histogram-based thresholding (Otsu's method) to the image.""" if self._currentImage is None: messagebox.showwarning("No Image", "Please load an image first.") return # Histogram thresholding uses Otsu's method which doesn't need parameters manipulation = HistogramThreshold() params = {} # No parameters needed self._applyManipulation(manipulation, params) def _zoomIn(self) -> None: """Zoom in the image by a factor of 1.2.""" if self._currentImage is None: messagebox.showwarning("No Image", "Please load an image first.") return manipulation = ZoomImage() params = {"scale": 1.2} self._applyManipulation(manipulation, params) def _zoomOut(self) -> None: """Zoom out the image by a factor of 0.8.""" if self._currentImage is None: messagebox.showwarning("No Image", "Please load an image first.") return manipulation = ZoomImage() params = {"scale": 0.8} self._applyManipulation(manipulation, params) def _copyImage(self) -> None: """Copy the current image to the system clipboard.""" if self._currentImage is None or self._currentImage.getImage() is None: messagebox.showwarning("No Image", "Please load an image first.") return manipulation = CopyToClipboard() manipulation.manipulateImage(self._currentImage, {}) # Check if copy was successful if self._currentImage._clipboard_copy_success: messagebox.showinfo("Copy", "Image copied to clipboard.") else: messagebox.showerror( "Copy Error", "Failed to copy image to clipboard.\n\n" "Windows: Please install pywin32 (pip install pywin32)\n" "Linux: Please install xclip (sudo apt-get install xclip)" ) # Clean up the attribute delattr(self._currentImage, '_clipboard_copy_success') def _cutImage(self) -> None: """Cut the current image to the system clipboard (copy and remove).""" if self._currentImage is None or self._currentImage.getImage() is None: messagebox.showwarning("No Image", "Please load an image first.") return manipulation = CutToClipboard() manipulation.manipulateImage(self._currentImage, {}) # Check if cut was successful if hasattr(self._currentImage, '_clipboard_cut_success'): if self._currentImage._clipboard_cut_success: # Re-render the canvas (will be empty now) self._renderCurrentImage() messagebox.showinfo("Cut", "Image cut to clipboard.") else: error_msg = getattr(self._currentImage, '_clipboard_cut_error', "Failed to cut image to clipboard.") messagebox.showerror( "Cut Error", f"{error_msg}\n\n" "Windows: Please install pywin32 (pip install pywin32)\n" "Linux: Please install xclip (sudo apt-get install xclip)" ) # Clean up the attributes if hasattr(self._currentImage, '_clipboard_cut_success'): delattr(self._currentImage, '_clipboard_cut_success') if hasattr(self._currentImage, '_clipboard_cut_error'): delattr(self._currentImage, '_clipboard_cut_error') def _pasteImage(self) -> None: """Paste an image from the system clipboard.""" manipulation = PasteFromClipboard() # If no current image exists, create a new ImageContainer if self._currentImage is None or self._currentImage.getImage() is None: self._currentImage = ImageContainer() manipulation.manipulateImage(self._currentImage, {}) # Check if paste was successful if hasattr(self._currentImage, '_clipboard_paste_success'): if self._currentImage._clipboard_paste_success: # Re-render the image self._renderCurrentImage() messagebox.showinfo("Paste", "Image pasted from clipboard.") else: error_msg = getattr(self._currentImage, '_clipboard_paste_error', "Failed to paste image from clipboard.") messagebox.showerror( "Paste Error", f"{error_msg}\n\n" "Windows: Please install pywin32 (pip install pywin32)\n" "Linux: Please install xclip (sudo apt-get install xclip)" ) # Clean up the attributes if hasattr(self._currentImage, '_clipboard_paste_success'): delattr(self._currentImage, '_clipboard_paste_success') if hasattr(self._currentImage, '_clipboard_paste_error'): delattr(self._currentImage, '_clipboard_paste_error') def _openCamera(self): camera = CameraWindow() camera.run(self)