From 2e5bda453194618b4a0fd00a8797e674a8501033 Mon Sep 17 00:00:00 2001 From: vb Date: Sat, 25 Oct 2025 22:17:47 +0200 Subject: [PATCH 1/4] adding area selection --- src/GUI.py | 578 ++++++++++++++++------ src/ImageManipulation/ManipulationList.py | 60 +-- src/SelectionArea.py | 72 +++ 3 files changed, 529 insertions(+), 181 deletions(-) create mode 100644 src/SelectionArea.py diff --git a/src/GUI.py b/src/GUI.py index 0be7de7..c20ec3a 100644 --- a/src/GUI.py +++ b/src/GUI.py @@ -1,151 +1,427 @@ -import tkinter as tk -from tkinter import filedialog -from PIL import ImageTk -from ImageContainer import ImageContainer -from ImageManipulation.ManipulationList import * -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 - - 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("800x600") - root.config(bg="white") - icon = tk.PhotoImage(file='icon.png') - root.tk.call('wm', 'iconphoto', root._w, icon) - root.minsize(800, 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="Open Image", command=lambda: self._openImage()) - file_menu.add_command(label="Save Image", command=lambda: self._saveImage()) - file_menu.add_command(label="Exit", command=root.quit) - - 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()) - - - 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) - ) - - # Manual Test menu listing all manipulations explicitly -# manual_menu = tk.Menu(menu, tearoff=0) -# menu.add_cascade(label="Test", menu=manual_menu) -# manual_menu.add_command(label="Padding", command=partial(self._applyManipulation, Padding(), {"border_width": 50})) -# manual_menu.add_command(label="Crop", command=partial(self._applyManipulation, CropImage())) -# manual_menu.add_command(label="Resize", command=partial(self._applyManipulation, ResizeImage(), {"width": 200, "height": 200})) -# manual_menu.add_command(label="Copy", command=partial(self._applyManipulation, CopyImage())) -# manual_menu.add_command(label="Greyscale", command=partial(self._applyManipulation, Grayscale())) -# manual_menu.add_command(label="HSV", command=partial(self._applyManipulation, HSV())) -# manual_menu.add_command(label="Hue Shifted", command=partial(self._applyManipulation, HueShift(), {"hue": 50})) -# manual_menu.add_command(label="Smoothed", command=partial(self._applyManipulation, BoxBlur(), {"ksize": 15})) -# manual_menu.add_command(label="Rotated", command=partial(self._applyManipulation, RotateImage(), {"angle": 90})) -# manual_menu.add_command(label="Flip (Horizontal)", command=partial(self._applyManipulation, FlipImage(), {"mode": "horizontal"})) -# manual_menu.add_command(label="Flip (Vertical)", command=partial(self._applyManipulation, FlipImage(), {"mode": "vertical"})) -# manual_menu.add_command(label="Color Adjust", command=partial(self._applyManipulation, ColorAdjust(), {"brightness": 10, "contrast": 1.2, "saturation": 1.1})) -# manual_menu.add_command(label="Gaussian Blur", command=partial(self._applyManipulation, GaussianBlur(), {"ksize": 5})) -# manual_menu.add_command(label="Sobel Edge", command=partial(self._applyManipulation, SobelEdge(), {"dx": 1, "dy": 0, "ksize": 3})) -# manual_menu.add_command(label="Binary Threshold", command=partial(self._applyManipulation, BinaryThreshold(), {"thresh": 127})) -# manual_menu.add_command(label="Histogram Threshold", command=partial(self._applyManipulation, HistogramThreshold())) - - - 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()) - - # Frame to hold image - imgframe = tk.Frame(root, width=500, height=500, bg="lightgray", relief="sunken", bd=2) - imgframe.pack(side="top", pady=10) - - # Label to display image - self._imageLabel = tk.Label(imgframe, width=500, height=500, bg="white") - self._imageLabel.pack(expand=True) - - self._root = root - - # Key bindings - root.bind_all('', lambda event: self._undo()) - - root.mainloop() - - 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: - 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 - self._imageLabel.config(image=tk_img, width=width, height=height) - - def _applyManipulation(self, manipulation, params: dict | None = None) -> None: - if self._currentImage is None: - 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: - if self._currentImage is None or self._currentImage.getImage() is None: - return - path = filedialog.asksaveasfilename(defaultextension=".png", filetypes=[ - ("PNG", "*.png"), ("JPEG", "*.jpg;*.jpeg"), ("Bitmap", "*.bmp"), ("All Files", "*.*") - ]) - if not path: - return - self._currentImage.saveImage(path) - - def _undo(self) -> None: - if self._currentImage is None: - return - self._currentImage.undo() - self._renderCurrentImage() \ No newline at end of file +import tkinter as tk +from tkinter import filedialog, messagebox +from PIL import ImageTk +from ImageContainer import ImageContainer +from ImageManipulation.ManipulationList import * +from SelectionArea import SelectionArea +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 + + # Interactive selection variables + _selectionMode = False + _selectionStartX = None + _selectionStartY = None + _selectionEndX = None + _selectionEndY = None + _selectionRectangle = None + _selectionArea = 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("800x600") + root.config(bg="white") + icon = tk.PhotoImage(file='icon.png') + root.tk.call('wm', 'iconphoto', root._w, icon) + root.minsize(800, 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="Open Image", command=lambda: self._openImage()) + file_menu.add_command(label="Save Image", command=lambda: self._saveImage()) + file_menu.add_command(label="Exit", 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) + ) + + # Manual Test menu listing all manipulations explicitly +# manual_menu = tk.Menu(menu, tearoff=0) +# menu.add_cascade(label="Test", menu=manual_menu) +# manual_menu.add_command(label="Padding", command=partial(self._applyManipulation, Padding(), {"border_width": 50})) +# manual_menu.add_command(label="Crop", command=partial(self._applyManipulation, CropImage())) +# manual_menu.add_command(label="Resize", command=partial(self._applyManipulation, ResizeImage(), {"width": 200, "height": 200})) +# manual_menu.add_command(label="Copy", command=partial(self._applyManipulation, CopyImage())) +# manual_menu.add_command(label="Greyscale", command=partial(self._applyManipulation, Grayscale())) +# manual_menu.add_command(label="HSV", command=partial(self._applyManipulation, HSV())) +# manual_menu.add_command(label="Hue Shifted", command=partial(self._applyManipulation, HueShift(), {"hue": 50})) +# manual_menu.add_command(label="Smoothed", command=partial(self._applyManipulation, BoxBlur(), {"ksize": 15})) +# manual_menu.add_command(label="Rotated", command=partial(self._applyManipulation, RotateImage(), {"angle": 90})) +# manual_menu.add_command(label="Flip (Horizontal)", command=partial(self._applyManipulation, FlipImage(), {"mode": "horizontal"})) +# manual_menu.add_command(label="Flip (Vertical)", command=partial(self._applyManipulation, FlipImage(), {"mode": "vertical"})) +# manual_menu.add_command(label="Color Adjust", command=partial(self._applyManipulation, ColorAdjust(), {"brightness": 10, "contrast": 1.2, "saturation": 1.1})) +# manual_menu.add_command(label="Gaussian Blur", command=partial(self._applyManipulation, GaussianBlur(), {"ksize": 5})) +# manual_menu.add_command(label="Sobel Edge", command=partial(self._applyManipulation, SobelEdge(), {"dx": 1, "dy": 0, "ksize": 3})) +# manual_menu.add_command(label="Binary Threshold", command=partial(self._applyManipulation, BinaryThreshold(), {"thresh": 127})) +# manual_menu.add_command(label="Histogram Threshold", command=partial(self._applyManipulation, HistogramThreshold())) + + + 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_separator() + edit_menu.add_command(label="Toggle Selection Mode", accelerator="Ctrl+S", command=lambda: self._toggleSelectionMode()) + + # Frame to hold image + imgframe = tk.Frame(root, width=500, height=500, bg="lightgray", relief="sunken", bd=2) + imgframe.pack(side="top", pady=10) + + # Canvas to display image and draw selection + self._imageCanvas = tk.Canvas(imgframe, width=500, height=500, bg="white") + self._imageCanvas.pack(expand=True) + + # Label to hold the image (will be placed on canvas) + self._imageLabel = tk.Label(self._imageCanvas, bg="white") + + # Bind mouse events for interactive selection + self._imageCanvas.bind("", self._onMouseClick) + self._imageCanvas.bind("", self._onMouseDrag) + self._imageCanvas.bind("", self._onMouseRelease) + self._imageCanvas.bind("", self._onRightClick) # Right-click for context menu + + self._root = root + + # Initialize selection area + self._selectionArea = SelectionArea() + + # Key bindings + root.bind_all('', lambda event: self._undo()) + root.bind_all('', lambda event: self._toggleSelectionMode()) + + root.mainloop() + + 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: + if self._currentImage is None or self._currentImage.getImage() is None: + return + + # Clear canvas + self._imageCanvas.delete("all") + + 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 + + # 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._selectionArea.is_active and self._selectionArea.is_valid(): + self._drawSelectionHighlight() + + def _applyManipulation(self, manipulation, params: dict | None = None) -> None: + if self._currentImage is None: + 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: + if self._currentImage is None or self._currentImage.getImage() is None: + return + path = filedialog.asksaveasfilename(defaultextension=".png", filetypes=[ + ("PNG", "*.png"), ("JPEG", "*.jpg;*.jpeg"), ("Bitmap", "*.bmp"), ("All Files", "*.*") + ]) + if not path: + return + self._currentImage.saveImage(path) + + def _undo(self) -> None: + if self._currentImage is None: + return + self._currentImage.undo() + self._renderCurrentImage() + + def _toggleSelectionMode(self) -> None: + """Toggle interactive selection mode on/off.""" + self._selectionMode = not self._selectionMode + if self._selectionMode: + self._root.config(cursor="crosshair") + messagebox.showinfo("Selection Mode", "Selection mode enabled. Click and drag to select area, then right-click for manipulation options.") + else: + self._root.config(cursor="") + self._clearSelection() + messagebox.showinfo("Selection Mode", "Selection mode disabled.") + + def _onMouseClick(self, event) -> None: + """Handle mouse click for selection.""" + if not self._selectionMode or self._currentImage is None: + return + + # Clear any existing selection + self._clearSelection() + + # Store starting coordinates + self._selectionStartX = event.x + self._selectionStartY = event.y + + def _onMouseDrag(self, event) -> None: + """Handle mouse drag for selection.""" + if not self._selectionMode or self._currentImage is None or self._selectionStartX is None: + return + + # Clear previous rectangle + if self._selectionRectangle: + self._imageCanvas.delete(self._selectionRectangle) + + # Draw new rectangle with colored edges only + self._selectionRectangle = self._imageCanvas.create_rectangle( + self._selectionStartX, self._selectionStartY, event.x, event.y, + outline="red", width=3, fill="" + ) + + def _onMouseRelease(self, event) -> None: + """Handle mouse release to finalize selection.""" + if not self._selectionMode or self._currentImage is None or self._selectionStartX is None: + return + + # Store ending coordinates + self._selectionEndX = event.x + self._selectionEndY = event.y + + # Convert screen coordinates to image coordinates + selection_coords = self._convertToImageCoordinates( + self._selectionStartX, self._selectionStartY, + self._selectionEndX, self._selectionEndY + ) + + if selection_coords: + # Set the selection area + self._selectionArea.set_coordinates( + selection_coords["left"], selection_coords["top"], + selection_coords["right"], selection_coords["bottom"] + ) + messagebox.showinfo("Selection", "Area selected! Right-click for manipulation options.") + + def _onRightClick(self, event) -> None: + """Handle right-click to show context menu for manipulation options.""" + if not self._selectionArea.is_active or not self._selectionArea.is_valid(): + return + + # Create context menu with all available manipulations + context_menu = tk.Menu(self._root, tearoff=0) + + # Add manipulation options + manipulations = GetImageManipulationList() + for manipulation in manipulations: + manipulation_name = manipulation.getManipulationName() + context_menu.add_command( + label=f"Apply {manipulation_name} to Selection", + command=lambda m=manipulation: self._applyManipulationToSelection(m) + ) + + context_menu.add_separator() + context_menu.add_command(label="Clear Selection", command=self._clearSelection) + + # Show context menu at cursor position + try: + context_menu.tk_popup(event.x_root, event.y_root) + finally: + context_menu.grab_release() + + def _convertToImageCoordinates(self, start_x, start_y, end_x, end_y): + """Convert screen coordinates to image coordinates.""" + if self._currentImage is None: + return None + + pil_image = self._currentImage.getImage() + if pil_image is None: + return None + + # Get image dimensions + img_width, img_height = pil_image.size + + # Get canvas dimensions + canvas_width = self._imageCanvas.winfo_width() + canvas_height = self._imageCanvas.winfo_height() + + if canvas_width <= 0 or canvas_height <= 0: + return None + + # Calculate scaling factors + scale_x = img_width / canvas_width + scale_y = img_height / canvas_height + + # Convert coordinates + left = int(min(start_x, end_x) * scale_x) + top = int(min(start_y, end_y) * scale_y) + right = int(max(start_x, end_x) * scale_x) + bottom = int(max(start_y, end_y) * scale_y) + + # Ensure coordinates are within image bounds + left = max(0, min(left, img_width)) + top = max(0, min(top, img_height)) + right = max(left, min(right, img_width)) + bottom = max(top, min(bottom, img_height)) + + # Only return valid selection coordinates + if right > left and bottom > top: + return {"left": left, "top": top, "right": right, "bottom": bottom} + + return None + + def _applyManipulationToSelection(self, manipulation) -> None: + """Apply the specified manipulation to the selected area only.""" + if self._currentImage is None or not self._selectionArea.is_active: + return + + # Take undo snapshot + self._currentImage.snapshot() + + # Get the original image + original_image = self._currentImage.getImage() + + # Crop the selection area + selected_area = self._selectionArea.crop_image(original_image) + if selected_area is None: + return + + # Create a temporary ImageContainer for the selected area + temp_container = ImageContainer() + temp_container._imageData = selected_area + + # Apply manipulation to the selected area + # Use default parameters if none specified + params = self._getDefaultParams(manipulation) + manipulation.manipulateImage(temp_container, params) + + # Get the processed selection + processed_selection = temp_container.getImage() + + # Apply the processed selection back to the original image + result_image = self._selectionArea.apply_to_image(original_image, processed_selection) + + # Update the current image + self._currentImage._imageData = result_image + + # Clear selection before re-rendering + self._clearSelection() + + # Re-render the image (without selection) + self._renderCurrentImage() + + 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} + else: + return {} + + def _clearSelection(self) -> None: + """Clear the selection rectangle and reset selection state.""" + if self._selectionRectangle: + self._imageCanvas.delete(self._selectionRectangle) + self._selectionRectangle = None + self._selectionStartX = None + self._selectionStartY = None + self._selectionEndX = None + self._selectionEndY = None + self._selectionArea.clear() + + def _drawSelectionHighlight(self) -> None: + """Draw a colored highlight over the selected area.""" + if not self._selectionArea.is_active or not self._selectionArea.is_valid(): + return + + coords = self._selectionArea.get_coordinates() + if not coords: + return + + left, top, right, bottom = coords + + # Convert image coordinates to canvas coordinates + pil_image = self._currentImage.getImage() + img_width, img_height = pil_image.size + canvas_width = self._imageCanvas.winfo_width() + canvas_height = self._imageCanvas.winfo_height() + + if canvas_width <= 0 or canvas_height <= 0: + return + + # Calculate scaling factors + scale_x = canvas_width / img_width + scale_y = canvas_height / img_height + + # Convert coordinates + canvas_left = int(left * scale_x) + canvas_top = int(top * scale_y) + canvas_right = int(right * scale_x) + canvas_bottom = int(bottom * scale_y) + + # Draw colored edges only + self._imageCanvas.create_rectangle( + canvas_left, canvas_top, canvas_right, canvas_bottom, + outline="red", width=3, fill="" + ) \ No newline at end of file diff --git a/src/ImageManipulation/ManipulationList.py b/src/ImageManipulation/ManipulationList.py index 3cee483..4a670d9 100644 --- a/src/ImageManipulation/ManipulationList.py +++ b/src/ImageManipulation/ManipulationList.py @@ -1,31 +1,31 @@ -from .CropImage import CropImage -from .ResizeImage import ResizeImage -from .RotateImage import RotateImage -from .FlipImage import FlipImage -from .ColorAdjust import ColorAdjust -from .Filters import GaussianBlur, SobelEdge, BinaryThreshold, HistogramThreshold -from .Padding import Padding -from .Grayscale import Grayscale -from .HSV import HSV -from .HueShift import HueShift -from .BoxBlur import BoxBlur -from .CopyImage import CopyImage - -def GetImageManipulationList() -> list: - return [ - CropImage(), - ResizeImage(), - RotateImage(), - FlipImage(), - ColorAdjust(), - Padding(), - Grayscale(), - HSV(), - HueShift(), - BoxBlur(), - CopyImage(), - GaussianBlur(), - SobelEdge(), - BinaryThreshold(), - HistogramThreshold(), +from .CropImage import CropImage +from .ResizeImage import ResizeImage +from .RotateImage import RotateImage +from .FlipImage import FlipImage +from .ColorAdjust import ColorAdjust +from .Filters import GaussianBlur, SobelEdge, BinaryThreshold, HistogramThreshold +from .Padding import Padding +from .Grayscale import Grayscale +from .HSV import HSV +from .HueShift import HueShift +from .BoxBlur import BoxBlur +from .CopyImage import CopyImage + +def GetImageManipulationList() -> list: + return [ + CropImage(), + ResizeImage(), + RotateImage(), + FlipImage(), + ColorAdjust(), + Padding(), + Grayscale(), + HSV(), + HueShift(), + BoxBlur(), + CopyImage(), + GaussianBlur(), + SobelEdge(), + BinaryThreshold(), + HistogramThreshold(), ] \ No newline at end of file diff --git a/src/SelectionArea.py b/src/SelectionArea.py new file mode 100644 index 0000000..5d07a3e --- /dev/null +++ b/src/SelectionArea.py @@ -0,0 +1,72 @@ +from PIL import Image +from typing import Dict, Optional, Tuple + + +class SelectionArea: + """Class to handle selected rectangular areas on images.""" + + def __init__(self): + self.left: Optional[int] = None + self.top: Optional[int] = None + self.right: Optional[int] = None + self.bottom: Optional[int] = None + self.is_active: bool = False + + def set_coordinates(self, left: int, top: int, right: int, bottom: int) -> None: + """Set the selection coordinates.""" + self.left = left + self.top = top + self.right = right + self.bottom = bottom + self.is_active = True + + def clear(self) -> None: + """Clear the selection.""" + self.left = None + self.top = None + self.right = None + self.bottom = None + self.is_active = False + + def get_coordinates(self) -> Optional[Tuple[int, int, int, int]]: + """Get the selection coordinates as a tuple.""" + if self.is_active and all(coord is not None for coord in [self.left, self.top, self.right, self.bottom]): + return (self.left, self.top, self.right, self.bottom) + return None + + def get_dict(self) -> Optional[Dict[str, int]]: + """Get the selection coordinates as a dictionary.""" + coords = self.get_coordinates() + if coords: + return { + "left": coords[0], + "top": coords[1], + "right": coords[2], + "bottom": coords[3] + } + return None + + def is_valid(self) -> bool: + """Check if the selection is valid (has area > 0).""" + coords = self.get_coordinates() + if coords: + left, top, right, bottom = coords + return right > left and bottom > top + return False + + def crop_image(self, image: Image.Image) -> Optional[Image.Image]: + """Crop the given image to the selection area.""" + coords = self.get_coordinates() + if coords and self.is_valid(): + return image.crop(coords) + return None + + def apply_to_image(self, image: Image.Image, processed_selection: Image.Image) -> Image.Image: + """Apply the processed selection back to the original image.""" + coords = self.get_coordinates() + if coords and self.is_valid(): + # Create a copy to avoid modifying the original + result_image = image.copy() + result_image.paste(processed_selection, (self.left, self.top)) + return result_image + return image From b417803472579573eb11ab3f33c6e7f44a9fb108 Mon Sep 17 00:00:00 2001 From: vb Date: Sat, 25 Oct 2025 23:32:37 +0200 Subject: [PATCH 2/4] removing test message --- src/GUI.py | 3 --- src/ImageContainer.py | 6 +++--- src/ImageManipulation/CropImage.py | 1 - src/ImageManipulation/FlipImage.py | 2 +- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/GUI.py b/src/GUI.py index c20ec3a..d52d55c 100644 --- a/src/GUI.py +++ b/src/GUI.py @@ -189,11 +189,9 @@ class GUI: self._selectionMode = not self._selectionMode if self._selectionMode: self._root.config(cursor="crosshair") - messagebox.showinfo("Selection Mode", "Selection mode enabled. Click and drag to select area, then right-click for manipulation options.") else: self._root.config(cursor="") self._clearSelection() - messagebox.showinfo("Selection Mode", "Selection mode disabled.") def _onMouseClick(self, event) -> None: """Handle mouse click for selection.""" @@ -243,7 +241,6 @@ class GUI: selection_coords["left"], selection_coords["top"], selection_coords["right"], selection_coords["bottom"] ) - messagebox.showinfo("Selection", "Area selected! Right-click for manipulation options.") def _onRightClick(self, event) -> None: """Handle right-click to show context menu for manipulation options.""" diff --git a/src/ImageContainer.py b/src/ImageContainer.py index 9004d99..5622276 100644 --- a/src/ImageContainer.py +++ b/src/ImageContainer.py @@ -14,7 +14,7 @@ class ImageContainer: imgcv2 = cv2.imread(path) height, width, channels = imgcv2.shape - # Open and resize image (optional) + # Open and resize image self._imageData = Image.open(path) # PIL expects (width, height) self._imageData = self._imageData.resize((width, height), Image.LANCZOS) @@ -39,9 +39,9 @@ class ImageContainer: """Push a copy of current image to history for undo.""" if self._imageData is None: return - # Ensure a deep copy (PIL copy is sufficient) + # Ensure a deep copy (but PIL copy should be sufficient) self._history.append(self._imageData.copy()) - # Cap history size to avoid memory blow-up + # Cap history size to avoid memory blow up if len(self._history) > 20: self._history.pop(0) diff --git a/src/ImageManipulation/CropImage.py b/src/ImageManipulation/CropImage.py index 18d25cf..7a548e1 100644 --- a/src/ImageManipulation/CropImage.py +++ b/src/ImageManipulation/CropImage.py @@ -38,7 +38,6 @@ class CropImage(ImageManipulation): right = left + crop_width bottom = top + crop_height else: - # Default: trim margins similar to demo in root main.py img_width, img_height = pil_image.size left = 80 top = 80 diff --git a/src/ImageManipulation/FlipImage.py b/src/ImageManipulation/FlipImage.py index c78cdc8..6ac1143 100644 --- a/src/ImageManipulation/FlipImage.py +++ b/src/ImageManipulation/FlipImage.py @@ -10,7 +10,7 @@ class FlipImage(ImageManipulation): return "Flip" def getParameters(self) -> List[str]: - return ["mode"] # horizontal|vertical + return ["mode"] def manipulateImage(self, image: ImageContainer, parameters: Any) -> None: if image is None or image.getImage() is None: From 80fa44a8361b1b964aefed7719b68ff13cf7b3b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viljar=20Bergst=C3=B8l?= Date: Mon, 3 Nov 2025 16:03:44 +0100 Subject: [PATCH 3/4] adding redo functionality --- src/GUI.py | 8 ++++++++ src/ImageContainer.py | 22 +++++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/GUI.py b/src/GUI.py index d52d55c..30aec02 100644 --- a/src/GUI.py +++ b/src/GUI.py @@ -91,6 +91,7 @@ class GUI: 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+S", command=lambda: self._toggleSelectionMode()) @@ -118,6 +119,7 @@ class GUI: # Key bindings root.bind_all('', lambda event: self._undo()) + root.bind_all('', lambda event: self._redo()) root.bind_all('', lambda event: self._toggleSelectionMode()) root.mainloop() @@ -184,6 +186,12 @@ class GUI: 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.""" self._selectionMode = not self._selectionMode diff --git a/src/ImageContainer.py b/src/ImageContainer.py index 5622276..d1e85ba 100644 --- a/src/ImageContainer.py +++ b/src/ImageContainer.py @@ -5,6 +5,7 @@ class ImageContainer: _imageData = None _path = None _history = None + _redo_history = None def loadImage(self, path: str) -> None: """ Load image file from the path. @@ -21,6 +22,7 @@ class ImageContainer: self._path = path self._history = [] + self._redo_history = [] print("Opened image:", path) @@ -41,6 +43,9 @@ class ImageContainer: return # Ensure a deep copy (but PIL copy should be sufficient) self._history.append(self._imageData.copy()) + # New action invalidates redo history + if self._redo_history is not None: + self._redo_history.clear() # Cap history size to avoid memory blow up if len(self._history) > 20: self._history.pop(0) @@ -48,4 +53,19 @@ class ImageContainer: def undo(self) -> None: if not self._history: return - self._imageData = self._history.pop() \ No newline at end of file + # Move current state to redo stack + if self._redo_history is None: + self._redo_history = [] + if self._imageData is not None: + self._redo_history.append(self._imageData.copy()) + # Restore last snapshot + self._imageData = self._history.pop() + + def redo(self) -> None: + if not self._redo_history: + return + # Moving forward: save current to undo history + if self._imageData is not None: + self._history.append(self._imageData.copy()) + # Apply redo state + self._imageData = self._redo_history.pop() \ No newline at end of file From 9aee76e7fe6b584423d8043fb9166fb551aa4ba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viljar=20Bergst=C3=B8l?= Date: Mon, 3 Nov 2025 16:51:55 +0100 Subject: [PATCH 4/4] adding redo and area selection --- src/GUI.py | 71 +++++++++++++++++++++++++------------------- src/SelectionArea.py | 21 +++++++++++-- 2 files changed, 60 insertions(+), 32 deletions(-) diff --git a/src/GUI.py b/src/GUI.py index 30aec02..8567916 100644 --- a/src/GUI.py +++ b/src/GUI.py @@ -68,25 +68,12 @@ class GUI: ) # Manual Test menu listing all manipulations explicitly -# manual_menu = tk.Menu(menu, tearoff=0) -# menu.add_cascade(label="Test", menu=manual_menu) -# manual_menu.add_command(label="Padding", command=partial(self._applyManipulation, Padding(), {"border_width": 50})) -# manual_menu.add_command(label="Crop", command=partial(self._applyManipulation, CropImage())) -# manual_menu.add_command(label="Resize", command=partial(self._applyManipulation, ResizeImage(), {"width": 200, "height": 200})) -# manual_menu.add_command(label="Copy", command=partial(self._applyManipulation, CopyImage())) -# manual_menu.add_command(label="Greyscale", command=partial(self._applyManipulation, Grayscale())) -# manual_menu.add_command(label="HSV", command=partial(self._applyManipulation, HSV())) -# manual_menu.add_command(label="Hue Shifted", command=partial(self._applyManipulation, HueShift(), {"hue": 50})) -# manual_menu.add_command(label="Smoothed", command=partial(self._applyManipulation, BoxBlur(), {"ksize": 15})) -# manual_menu.add_command(label="Rotated", command=partial(self._applyManipulation, RotateImage(), {"angle": 90})) -# manual_menu.add_command(label="Flip (Horizontal)", command=partial(self._applyManipulation, FlipImage(), {"mode": "horizontal"})) -# manual_menu.add_command(label="Flip (Vertical)", command=partial(self._applyManipulation, FlipImage(), {"mode": "vertical"})) -# manual_menu.add_command(label="Color Adjust", command=partial(self._applyManipulation, ColorAdjust(), {"brightness": 10, "contrast": 1.2, "saturation": 1.1})) -# manual_menu.add_command(label="Gaussian Blur", command=partial(self._applyManipulation, GaussianBlur(), {"ksize": 5})) -# manual_menu.add_command(label="Sobel Edge", command=partial(self._applyManipulation, SobelEdge(), {"dx": 1, "dy": 0, "ksize": 3})) -# manual_menu.add_command(label="Binary Threshold", command=partial(self._applyManipulation, BinaryThreshold(), {"thresh": 127})) -# manual_menu.add_command(label="Histogram Threshold", command=partial(self._applyManipulation, HistogramThreshold())) - + manual_menu = tk.Menu(menu, tearoff=0) + menu.add_cascade(label="menuuuu", menu=manual_menu) + manual_menu.add_command(label="Padding", command=partial(self._applyManipulation, Padding(), {"border_width": 50})) + manual_menu.add_command(label="Crop", command=partial(self._applyManipulation, CropImage())) + manual_menu.add_command(label="Resize", command=partial(self._applyManipulation, ResizeImage(), {"width": 200, "height": 200})) + manual_menu.add_command(label="Copy", command=partial(self._applyManipulation, CopyImage())) edit_menu = tk.Menu(menu, tearoff=0) menu.add_cascade(label="Edit", menu=edit_menu) @@ -94,6 +81,7 @@ class GUI: 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+S", command=lambda: self._toggleSelectionMode()) + edit_menu.add_command(label="Toggle Selection Shape (Rect/Circle)", accelerator="Ctrl+Shift+C", command=lambda: self._toggleSelectionShape()) # Frame to hold image imgframe = tk.Frame(root, width=500, height=500, bg="lightgray", relief="sunken", bd=2) @@ -102,7 +90,7 @@ class GUI: # Canvas to display image and draw selection self._imageCanvas = tk.Canvas(imgframe, width=500, height=500, bg="white") self._imageCanvas.pack(expand=True) - + # Label to hold the image (will be placed on canvas) self._imageLabel = tk.Label(self._imageCanvas, bg="white") @@ -121,6 +109,7 @@ class GUI: root.bind_all('', lambda event: self._undo()) root.bind_all('', lambda event: self._redo()) root.bind_all('', lambda event: self._toggleSelectionMode()) + root.bind_all('', lambda event: self._toggleSelectionShape()) root.mainloop() @@ -201,6 +190,16 @@ class GUI: self._root.config(cursor="") self._clearSelection() + def _toggleSelectionShape(self) -> None: + """Toggle selection shape between rectangle and circle.""" + if self._selectionArea is None: + return + new_shape = "circle" if self._selectionArea.shape == "rectangle" else "rectangle" + self._selectionArea.set_shape(new_shape) + # Refresh selection drawing if active + if self._selectionArea.is_active and self._selectionArea.is_valid(): + self._renderCurrentImage() + def _onMouseClick(self, event) -> None: """Handle mouse click for selection.""" if not self._selectionMode or self._currentImage is None: @@ -218,15 +217,21 @@ class GUI: if not self._selectionMode or self._currentImage is None or self._selectionStartX is None: return - # Clear previous rectangle + # Clear previous shape if self._selectionRectangle: self._imageCanvas.delete(self._selectionRectangle) - # Draw new rectangle with colored edges only - self._selectionRectangle = self._imageCanvas.create_rectangle( - self._selectionStartX, self._selectionStartY, event.x, event.y, - outline="red", width=3, fill="" - ) + # Draw shape outline depending on selection shape + if self._selectionArea.shape == "circle": + self._selectionRectangle = self._imageCanvas.create_oval( + self._selectionStartX, self._selectionStartY, event.x, event.y, + outline="red", width=3, fill="" + ) + else: + self._selectionRectangle = self._imageCanvas.create_rectangle( + self._selectionStartX, self._selectionStartY, event.x, event.y, + outline="red", width=3, fill="" + ) def _onMouseRelease(self, event) -> None: """Handle mouse release to finalize selection.""" @@ -426,7 +431,13 @@ class GUI: canvas_bottom = int(bottom * scale_y) # Draw colored edges only - self._imageCanvas.create_rectangle( - canvas_left, canvas_top, canvas_right, canvas_bottom, - outline="red", width=3, fill="" - ) \ No newline at end of file + if self._selectionArea.shape == "circle": + self._imageCanvas.create_oval( + canvas_left, canvas_top, canvas_right, canvas_bottom, + outline="red", width=3, fill="" + ) + else: + self._imageCanvas.create_rectangle( + canvas_left, canvas_top, canvas_right, canvas_bottom, + outline="red", width=3, fill="" + ) \ No newline at end of file diff --git a/src/SelectionArea.py b/src/SelectionArea.py index 5d07a3e..07c65dc 100644 --- a/src/SelectionArea.py +++ b/src/SelectionArea.py @@ -1,4 +1,4 @@ -from PIL import Image +from PIL import Image, ImageDraw from typing import Dict, Optional, Tuple @@ -11,6 +11,7 @@ class SelectionArea: self.right: Optional[int] = None self.bottom: Optional[int] = None self.is_active: bool = False + self.shape: str = "rectangle" # "rectangle" or "circle" def set_coordinates(self, left: int, top: int, right: int, bottom: int) -> None: """Set the selection coordinates.""" @@ -27,6 +28,12 @@ class SelectionArea: self.right = None self.bottom = None self.is_active = False + self.shape = self.shape # keep last used shape + + def set_shape(self, shape: str) -> None: + """Set the selection shape: 'rectangle' or 'circle'.""" + if shape in ("rectangle", "circle"): + self.shape = shape def get_coordinates(self) -> Optional[Tuple[int, int, int, int]]: """Get the selection coordinates as a tuple.""" @@ -58,6 +65,7 @@ class SelectionArea: """Crop the given image to the selection area.""" coords = self.get_coordinates() if coords and self.is_valid(): + # For both rectangle and circle, return the bounding box crop return image.crop(coords) return None @@ -67,6 +75,15 @@ class SelectionArea: if coords and self.is_valid(): # Create a copy to avoid modifying the original result_image = image.copy() - result_image.paste(processed_selection, (self.left, self.top)) + if self.shape == "circle": + # Create circular mask for blending only inside the circle + width = self.right - self.left + height = self.bottom - self.top + mask = Image.new("L", (width, height), 0) + draw = ImageDraw.Draw(mask) + draw.ellipse((0, 0, width, height), fill=255) + result_image.paste(processed_selection, (self.left, self.top), mask) + else: + result_image.paste(processed_selection, (self.left, self.top)) return result_image return image