diff --git a/src/GUI.py b/src/GUI.py index e66530e..57a48a9 100644 --- a/src/GUI.py +++ b/src/GUI.py @@ -1,132 +1,443 @@ -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="Open Camera", command=lambda: self._openCamera()) - 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) - ) - - - - # 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() - - def _openCamera(self): - camera = CameraWindow() - camera.run(self) +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="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) + 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()) + 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) + 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._redo()) + root.bind_all('', lambda event: self._toggleSelectionMode()) + root.bind_all('', lambda event: self._toggleSelectionShape()) + + 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 _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 + if self._selectionMode: + self._root.config(cursor="crosshair") + else: + 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: + 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 shape + if self._selectionRectangle: + self._imageCanvas.delete(self._selectionRectangle) + + # 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.""" + 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"] + ) + + 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 + 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="" + ) diff --git a/src/ImageContainer.py b/src/ImageContainer.py index faf2c0f..46b9d43 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. @@ -14,13 +15,14 @@ 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) self._path = path self._history = [] + self._redo_history = [] print("Opened image:", path) @@ -39,13 +41,31 @@ 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 + # 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) 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 diff --git a/src/ImageManipulation/CropImage.py b/src/ImageManipulation/CropImage.py index ef2198a..06e6ca7 100644 --- a/src/ImageManipulation/CropImage.py +++ b/src/ImageManipulation/CropImage.py @@ -36,7 +36,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: diff --git a/src/ImageManipulation/ManipulationList.py b/src/ImageManipulation/ManipulationList.py index 2e8b8f2..6b97e00 100644 --- a/src/ImageManipulation/ManipulationList.py +++ b/src/ImageManipulation/ManipulationList.py @@ -1,36 +1,36 @@ -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 -from .BackgroundBlur import BackgroundBlur -from .CameraWindow import CameraWindow - - -def GetImageManipulationList() -> list: - return [ - CropImage(), - ResizeImage(), - RotateImage(), - FlipImage(), - ColorAdjust(), - Padding(), - Grayscale(), - HSV(), - HueShift(), - BoxBlur(), - CopyImage(), - GaussianBlur(), - SobelEdge(), - BinaryThreshold(), - HistogramThreshold(), - BackgroundBlur(), - CameraWindow(), +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 +from .BackgroundBlur import BackgroundBlur +from .CameraWindow import CameraWindow + + +def GetImageManipulationList() -> list: + return [ + CropImage(), + ResizeImage(), + RotateImage(), + FlipImage(), + ColorAdjust(), + Padding(), + Grayscale(), + HSV(), + HueShift(), + BoxBlur(), + CopyImage(), + GaussianBlur(), + SobelEdge(), + BinaryThreshold(), + HistogramThreshold(), + BackgroundBlur(), + CameraWindow(), ] \ No newline at end of file diff --git a/src/SelectionArea.py b/src/SelectionArea.py new file mode 100644 index 0000000..07c65dc --- /dev/null +++ b/src/SelectionArea.py @@ -0,0 +1,89 @@ +from PIL import Image, ImageDraw +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 + self.shape: str = "rectangle" # "rectangle" or "circle" + + 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 + 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.""" + 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(): + # For both rectangle and circle, return the bounding box crop + 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() + 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