import tkinter as tk from tkinter import Menu from PIL import Image from typing import Optional, Callable import sys import os # Add parent directory to path for imports (since we're in utils/ subdirectory) _parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if _parent_dir not in sys.path: sys.path.insert(0, _parent_dir) from SelectionArea import SelectionArea from ImageContainer import ImageContainer from ImageManipulation.ManipulationList import GetImageManipulationList class AreaSelectionHandler: """Handler for area selection functionality in the GUI. This class encapsulates all area selection operations including rectangle, circle, and lasso selections, coordinate conversion, and applying manipulations to selected areas. """ def __init__(self, canvas: tk.Canvas, get_current_image: Callable, get_canvas_dimensions: Callable, render_image: Callable, root_window: tk.Tk, selection_area: SelectionArea): """Initialize the area selection handler. Args: canvas: The tkinter Canvas widget for drawing selections get_current_image: Function that returns the current ImageContainer get_canvas_dimensions: Function that returns (width, height) tuple render_image: Function to re-render the current image root_window: The root tkinter window selection_area: The SelectionArea instance to manage """ self._canvas = canvas self._get_current_image = get_current_image self._get_canvas_dimensions = get_canvas_dimensions self._render_image = render_image self._root = root_window self._selectionArea = selection_area # Selection state self._selectionMode = False self._selectionType = "rectangle" # "rectangle", "circle", "lasso", or "polygon" self._selectionStartX = None self._selectionStartY = None self._selectionEndX = None self._selectionEndY = None self._selectionRectangle = None self._lassoPath = [] # List of canvas coordinates for lasso drawing self._lassoLine = None # Canvas line item for lasso path self._polygonPath = [] # List of canvas coordinates for polygon points self._polygonLines = [] # List of canvas line items for polygon drawing self._polygonPoints = [] # List of canvas point markers for polygon vertices # Pending operation callback (called when selection is completed) self._pendingOperationCallback = None def toggle_selection_mode(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._pendingOperationCallback = None # Clear pending callback when disabling selection mode self.clear_selection() def toggle_selection_shape(self) -> None: """Toggle selection shape between rectangle, circle, and lasso.""" if self._selectionArea is None: return # Cycle through: rectangle -> circle -> lasso -> rectangle current_shape = self._selectionArea.shape if current_shape == "rectangle": new_shape = "circle" elif current_shape == "circle": new_shape = "lasso" else: # lasso new_shape = "rectangle" self._selectionArea.set_shape(new_shape) self._selectionType = new_shape # Refresh selection drawing if active if self._selectionArea.is_active and self._selectionArea.is_valid(): self._render_image() def set_selection_shape(self, shape: str) -> None: """Set the selection shape directly. Args: shape: One of "rectangle", "circle", or "lasso" """ if self._selectionArea is None: return if shape in ("rectangle", "circle", "lasso"): self._selectionArea.set_shape(shape) self._selectionType = shape # Enable selection mode if not already enabled if not self._selectionMode: self.toggle_selection_mode() # Refresh selection drawing if active if self._selectionArea.is_active and self._selectionArea.is_valid(): self._render_image() def set_rectangular_selection(self) -> None: """Set selection to rectangular mode and enable selection.""" self.set_selection_shape("rectangle") def set_circular_selection(self) -> None: """Set selection to circular mode and enable selection.""" self.set_selection_shape("circle") def set_lasso_selection(self) -> None: """Set selection to lasso (free-form) mode and enable selection.""" self.set_selection_shape("lasso") def set_polygon_selection(self) -> None: """Set selection to polygon mode and enable selection.""" self.set_selection_shape("polygon") def _complete_polygon_selection(self) -> None: """Complete the polygon selection by converting canvas coordinates to image coordinates.""" if not self._polygonPath or len(self._polygonPath) < 3: return current_image = self._get_current_image() if current_image is None: return pil_image = current_image.getImage() if pil_image is None: return img_width, img_height = pil_image.size # Get canvas dimensions for scaling canvas_width, canvas_height = self._get_canvas_dimensions() if canvas_width <= 0 or canvas_height <= 0: return # Calculate scaling factors scale_x = img_width / canvas_width scale_y = img_height / canvas_height # Convert canvas coordinates to image coordinates image_path = [(int(x * scale_x), int(y * scale_y)) for x, y in self._polygonPath] # Set the polygon path in selection area self._selectionArea.set_polygon_path(image_path) # Clear polygon drawing elements for line in self._polygonLines: self._canvas.delete(line) for point in self._polygonPoints: self._canvas.delete(point) self._polygonLines = [] self._polygonPoints = [] # Redraw the selection highlight to ensure it's visible self._render_image() # Check if there's a pending operation callback and execute it if self._pendingOperationCallback and self._selectionArea.is_valid(): callback = self._pendingOperationCallback self._pendingOperationCallback = None # Clear callback to prevent multiple calls callback() def on_double_click(self, event) -> bool: """Handle double-click to complete polygon selection. Returns: True if the double-click was handled, False otherwise """ if not self._selectionMode or self._selectionType != "polygon": return False if len(self._polygonPath) >= 3: self._complete_polygon_selection() return True return False def set_pending_operation_callback(self, callback: Optional[Callable]) -> None: """Set a callback to be executed when a selection is completed. Args: callback: Function to call when selection is completed, or None to clear """ self._pendingOperationCallback = callback def on_mouse_click(self, event) -> bool: """Handle mouse click for selection. Returns: True if the click was handled by selection, False otherwise """ # Handle selection mode if not self._selectionMode: return False current_image = self._get_current_image() if current_image is None: return False # Handle lasso mode if self._selectionType == "lasso": # Clear any existing selection self.clear_selection() # Start new lasso path self._lassoPath = [] canvas_x = self._canvas.canvasx(event.x) canvas_y = self._canvas.canvasy(event.y) self._lassoPath.append((canvas_x, canvas_y)) return True # Handle polygon mode if self._selectionType == "polygon": # If this is the first point, clear any existing polygon if not self._polygonPath: # Clear any existing selection if self._selectionArea.is_active: self._selectionArea.clear() # Clear any existing polygon drawing elements for line in self._polygonLines: self._canvas.delete(line) for point in self._polygonPoints: self._canvas.delete(point) self._polygonLines = [] self._polygonPoints = [] canvas_x = self._canvas.canvasx(event.x) canvas_y = self._canvas.canvasy(event.y) # Add point to polygon self._polygonPath.append((canvas_x, canvas_y)) # Draw point marker point_marker = self._canvas.create_oval( canvas_x - 3, canvas_y - 3, canvas_x + 3, canvas_y + 3, fill="red", outline="red", width=2 ) self._polygonPoints.append(point_marker) # Draw lines connecting points if len(self._polygonPath) > 1: prev_point = self._polygonPath[-2] line = self._canvas.create_line( prev_point[0], prev_point[1], canvas_x, canvas_y, fill="red", width=2 ) self._polygonLines.append(line) return True # Handle rectangle/circle mode # Clear any existing selection self.clear_selection() # Store starting coordinates self._selectionStartX = event.x self._selectionStartY = event.y return True def on_mouse_drag(self, event) -> bool: """Handle mouse drag for selection. Returns: True if the drag was handled by selection, False otherwise """ # Handle selection mode if not self._selectionMode: return False current_image = self._get_current_image() if current_image is None: return False # Handle lasso mode if self._selectionType == "lasso" and self._lassoPath: # Add point to lasso path canvas_x = self._canvas.canvasx(event.x) canvas_y = self._canvas.canvasy(event.y) self._lassoPath.append((canvas_x, canvas_y)) # Draw lasso path if len(self._lassoPath) > 1: # Clear previous lasso line if self._lassoLine: self._canvas.delete(self._lassoLine) # Draw new lasso path self._lassoLine = self._canvas.create_line( *[coord for point in self._lassoPath for coord in point], fill="red", width=3, smooth=False ) return True # Handle rectangle/circle mode if self._selectionStartX is None: return False # Clear previous shape if self._selectionRectangle: self._canvas.delete(self._selectionRectangle) # Draw shape outline depending on selection shape if self._selectionArea.shape == "circle": self._selectionRectangle = self._canvas.create_oval( self._selectionStartX, self._selectionStartY, event.x, event.y, outline="red", width=3, fill="" ) else: self._selectionRectangle = self._canvas.create_rectangle( self._selectionStartX, self._selectionStartY, event.x, event.y, outline="red", width=3, fill="" ) return True def on_mouse_release(self, event) -> bool: """Handle mouse release to finalize selection. Returns: True if the release was handled by selection, False otherwise """ # Handle selection mode if not self._selectionMode: return False current_image = self._get_current_image() if current_image is None: return False # Handle lasso mode if self._selectionType == "lasso" and self._lassoPath: if len(self._lassoPath) >= 3: # Convert lasso path from canvas coordinates to image coordinates pil_image = current_image.getImage() img_width, img_height = pil_image.size # Get canvas dimensions for scaling canvas_width, canvas_height = self._get_canvas_dimensions() if canvas_width <= 0 or canvas_height <= 0: return True # Calculate scaling factors scale_x = img_width / canvas_width scale_y = img_height / canvas_height # Convert canvas coordinates to image coordinates image_path = [(int(x * scale_x), int(y * scale_y)) for x, y in self._lassoPath] # Set the lasso path in selection area self._selectionArea.set_lasso_path(image_path) # Redraw the selection highlight to ensure it's visible self._render_image() # Check if there's a pending operation callback and execute it if self._pendingOperationCallback and self._selectionArea.is_valid(): callback = self._pendingOperationCallback self._pendingOperationCallback = None # Clear callback to prevent multiple calls callback() return True # Handle polygon mode - complete polygon on double-click # (Polygon completion happens on double-click, not on mouse release) return True # Handle rectangle/circle mode if self._selectionStartX is None: return False # Store ending coordinates self._selectionEndX = event.x self._selectionEndY = event.y # Convert screen coordinates to image coordinates selection_coords = self._convert_to_image_coordinates( 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"] ) # Redraw the selection highlight to ensure it's visible self._render_image() # Check if there's a pending operation callback and execute it if self._pendingOperationCallback and self._selectionArea.is_valid(): callback = self._pendingOperationCallback self._pendingOperationCallback = None # Clear callback to prevent multiple calls callback() return True def on_right_click(self, event, get_default_params: Callable) -> bool: """Handle right-click to show context menu for manipulation options. Args: event: The mouse event get_default_params: Function to get default parameters for a manipulation Returns: True if the right-click was handled, False otherwise """ if not self._selectionArea or not self._selectionArea.is_active: return False if not self._selectionArea.is_valid(): return False # Create context menu with all available manipulations context_menu = tk.Menu(self._root, tearoff=0) # Add crop to selection option first (most common use case) context_menu.add_command(label="Crop to Selection", command=self.crop_to_selection) context_menu.add_separator() # 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.apply_manipulation_to_selection(m, get_default_params) ) context_menu.add_separator() context_menu.add_command(label="Clear Selection", command=self.clear_selection) # Show context menu at cursor position try: context_menu.tk_popup(event.x_root, event.y_root) finally: context_menu.grab_release() return True def _convert_to_image_coordinates(self, start_x, start_y, end_x, end_y): """Convert screen coordinates to image coordinates.""" current_image = self._get_current_image() if current_image is None: return None pil_image = current_image.getImage() if pil_image is None: return None # Convert widget coordinates to canvas coordinates (accounts for borders/scroll) canvas_x1 = self._canvas.canvasx(start_x) canvas_y1 = self._canvas.canvasy(start_y) canvas_x2 = self._canvas.canvasx(end_x) canvas_y2 = self._canvas.canvasy(end_y) # Get image dimensions img_width, img_height = pil_image.size # Get canvas dimensions for scaling canvas_width, canvas_height = self._get_canvas_dimensions() if canvas_width <= 0 or canvas_height <= 0: return None # Calculate scaling factors to convert canvas coordinates to image coordinates scale_x = img_width / canvas_width scale_y = img_height / canvas_height # Convert canvas coordinates to image coordinates left = int(min(canvas_x1, canvas_x2) * scale_x) top = int(min(canvas_y1, canvas_y2) * scale_y) right = int(max(canvas_x1, canvas_x2) * scale_x) bottom = int(max(canvas_y1, canvas_y2) * 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 crop_to_selection(self) -> None: """Crop the entire image to the selected area.""" current_image = self._get_current_image() if current_image is None or not self._selectionArea or not self._selectionArea.is_active: return # Get the selection coordinates coords = self._selectionArea.get_coordinates() if not coords: return left, top, right, bottom = coords # Validate coordinates if right <= left or bottom <= top: return # Get the original image original_image = current_image.getImage() if original_image is None: return img_width, img_height = original_image.size # 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)) # Validate final coordinates if right <= left or bottom <= top: return try: # Take undo snapshot current_image.snapshot() # Use SelectionArea's crop_image method which handles all shapes including lasso cropped_image = self._selectionArea.crop_image(original_image) if cropped_image is None: return # For lasso, we get an image with transparency, so convert to RGB if needed # Otherwise keep the cropped image as is if self._selectionArea.shape == "lasso" and cropped_image.mode == "RGBA": # Create a white background for lasso crops background = Image.new("RGB", cropped_image.size, (255, 255, 255)) background.paste(cropped_image, (0, 0), cropped_image.split()[3] if cropped_image.mode == "RGBA" else None) cropped_image = background # Update the current image current_image._imageData = cropped_image # Clear selection before re-rendering self.clear_selection() # Re-render the image (without selection) self._render_image() except Exception as e: # Silently handle errors - crop operation failed pass def resize_to_selection_dimensions(self) -> None: """Resize the entire image to match the selection area dimensions.""" current_image = self._get_current_image() if current_image is None or not self._selectionArea or not self._selectionArea.is_active: return # Get the selection coordinates coords = self._selectionArea.get_coordinates() if not coords: return left, top, right, bottom = coords # Calculate dimensions from selection width = right - left height = bottom - top # Validate dimensions if width <= 0 or height <= 0: return # Get the original image original_image = current_image.getImage() if original_image is None: return try: # Take undo snapshot current_image.snapshot() # Resize the entire image to the selection dimensions from utils.image_utils import pil_to_cv2, cv2_to_pil import cv2 cv_img = pil_to_cv2(original_image) resized = cv2.resize(cv_img, (int(width), int(height)), interpolation=cv2.INTER_AREA) resized_image = cv2_to_pil(resized) # Update the current image current_image._imageData = resized_image # Clear selection before re-rendering self.clear_selection() # Re-render the image (without selection) self._render_image() except Exception as e: # Silently handle errors - resize operation failed pass def apply_manipulation_to_selection(self, manipulation, get_default_params: Callable) -> None: """Apply the specified manipulation to the selected area only.""" current_image = self._get_current_image() if current_image is None or not self._selectionArea.is_active: return # Take undo snapshot current_image.snapshot() # Get the original image original_image = current_image.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 = get_default_params(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 current_image._imageData = result_image # Clear selection before re-rendering self.clear_selection() # Re-render the image (without selection) self._render_image() def clear_selection(self) -> None: """Clear the selection rectangle and reset selection state.""" if self._selectionRectangle: self._canvas.delete(self._selectionRectangle) self._selectionRectangle = None if self._lassoLine: self._canvas.delete(self._lassoLine) self._lassoLine = None for line in self._polygonLines: self._canvas.delete(line) for point in self._polygonPoints: self._canvas.delete(point) self._lassoPath = [] self._polygonPath = [] self._polygonLines = [] self._polygonPoints = [] self._selectionStartX = None self._selectionStartY = None self._selectionEndX = None self._selectionEndY = None self._selectionArea.clear() def draw_selection_highlight(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 current_image = self._get_current_image() if current_image is None: return pil_image = current_image.getImage() if pil_image is None: return img_width, img_height = pil_image.size # Get canvas dimensions for scaling canvas_width, canvas_height = self._get_canvas_dimensions() 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 == "lasso" and self._selectionArea.lasso_path: # Convert lasso path from image coordinates to canvas coordinates canvas_path = [(int(x * scale_x), int(y * scale_y)) for x, y in self._selectionArea.lasso_path] if len(canvas_path) >= 3: # Close the path by adding the first point at the end closed_path = canvas_path + [canvas_path[0]] self._canvas.create_line( *[coord for point in closed_path for coord in point], fill="red", width=3, smooth=False ) elif self._selectionArea.shape == "polygon" and self._selectionArea.polygon_path: # Convert polygon path from image coordinates to canvas coordinates canvas_path = [(int(x * scale_x), int(y * scale_y)) for x, y in self._selectionArea.polygon_path] if len(canvas_path) >= 3: # Close the path by adding the first point at the end closed_path = canvas_path + [canvas_path[0]] self._canvas.create_line( *[coord for point in closed_path for coord in point], fill="red", width=3, smooth=False ) elif self._selectionArea.shape == "circle": self._canvas.create_oval( canvas_left, canvas_top, canvas_right, canvas_bottom, outline="red", width=3, fill="" ) else: self._canvas.create_rectangle( canvas_left, canvas_top, canvas_right, canvas_bottom, outline="red", width=3, fill="" ) @property def selection_mode(self) -> bool: """Get the current selection mode state.""" return self._selectionMode @property def selection_type(self) -> str: """Get the current selection type.""" return self._selectionType @property def selection_area(self) -> SelectionArea: """Get the SelectionArea instance.""" return self._selectionArea