diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 6680a8f..c0e083b 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -5,11 +5,11 @@ - - + - - + + + @@ -91,5 +95,6 @@ + \ No newline at end of file diff --git a/src/GUI.py b/src/GUI.py index b21a9cf..37eb518 100644 --- a/src/GUI.py +++ b/src/GUI.py @@ -44,13 +44,13 @@ class GUI: # 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 @@ -109,10 +109,11 @@ class GUI: 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()) - + edit_menu.add_command(label="Magic Scissors", accelerator="Ctrl+M", command=lambda: self._enableMagicScissorsMode()) + 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) @@ -120,7 +121,7 @@ class GUI: 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()) @@ -223,25 +224,30 @@ class GUI: # 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) + # Status bar + self._status_var = tk.StringVar(value="Ready") + status_bar = tk.Label(root, textvariable=self._status_var, anchor="w", bg="#f0f0f0", relief="sunken") + status_bar.pack(side="bottom", fill="x") + # Key bindings root.bind_all('', lambda event: self._undo()) root.bind_all('', lambda event: self._redo()) @@ -259,6 +265,10 @@ class GUI: root.bind_all('', lambda event: self._copyImage()) root.bind_all('', lambda event: self._cutImage()) root.bind_all('', lambda event: self._pasteImage()) + root.bind_all('', lambda event: self._enableMagicScissorsMode()) + root.bind_all('', lambda event: self._finalizeMagicScissors()) + root.bind_all('', lambda event: self._cancelMagicScissors()) + root.bind_all('', lambda event: self._undoMagicScissorsSegment()) root.mainloop() @@ -277,14 +287,15 @@ class GUI: self._currentImage = ImageContainer() self._currentImage.loadImage(file_path) self._renderCurrentImage() + self._updateStatus("Image loaded. Use Edit → Magic Scissors to try the new intelligent lasso.") 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) @@ -350,46 +361,55 @@ class GUI: def _toggleSelectionMode(self) -> None: """Toggle interactive selection mode on/off.""" if self._areaSelectionHandler: + was_magic = self._areaSelectionHandler.selection_type == "magic_scissors" self._areaSelectionHandler.toggle_selection_mode() + if self._areaSelectionHandler.selection_mode: + self._updateStatus("Selection mode enabled. Choose rectangle, circle, lasso, or Magic Scissors.") + else: + self._updateStatus("Selection mode disabled.") + self._renderCurrentImage() + if was_magic and self._areaSelectionHandler.selection_type != "magic_scissors": + self._updateStatus("Magic Scissors cancelled.") def _toggleSelectionShape(self) -> None: """Toggle selection shape between rectangle, circle, and lasso.""" if self._areaSelectionHandler: + was_magic = self._areaSelectionHandler.selection_type == "magic_scissors" 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 + 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: @@ -398,15 +418,15 @@ class GUI: 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 + 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: @@ -415,46 +435,50 @@ class GUI: 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) + self._updateStatus(f"Selection shape: {self._areaSelectionHandler.selection_type.title()}.") + self._renderCurrentImage() + if was_magic and self._areaSelectionHandler.selection_type != "magic_scissors": + self._updateStatus("Magic Scissors cancelled.") def _onMouseClick(self, event) -> None: """Handle mouse click for selection or brush.""" @@ -465,6 +489,8 @@ class GUI: # Handle selection mode - delegate to handler if self._areaSelectionHandler: self._areaSelectionHandler.on_mouse_click(event) + if self._areaSelectionHandler.selection_type == "magic_scissors": + self._updateStatus("Magic Scissors active: click to set points, move mouse to follow edges, Enter to finalize, Esc to cancel.") def _onMouseDrag(self, event) -> None: """Handle mouse drag for selection or brush.""" @@ -524,23 +550,31 @@ class GUI: def _toggleBrushMode(self) -> None: """Toggle brush mode on/off.""" if self._brushHandler: + if self._areaSelectionHandler and self._areaSelectionHandler.selection_type == "magic_scissors": + self._areaSelectionHandler.cancel_magic_scissors() + self._renderCurrentImage() + self._updateStatus("Magic Scissors cancelled.") self._brushHandler.toggle_brush_mode() + if self._brushHandler.brush_mode: + self._updateStatus("Brush mode active. Hold left mouse to paint. Ctrl+B to exit.") + else: + self._updateStatus("Brush mode inactive.") 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) @@ -548,22 +582,22 @@ class GUI: 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) @@ -571,20 +605,20 @@ class GUI: 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) @@ -592,16 +626,16 @@ class GUI: 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 @@ -612,7 +646,7 @@ class GUI: 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) @@ -622,7 +656,7 @@ class GUI: 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) @@ -632,10 +666,10 @@ class GUI: 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.") @@ -654,10 +688,10 @@ class GUI: 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: @@ -665,7 +699,7 @@ class GUI: self._renderCurrentImage() messagebox.showinfo("Cut", "Image cut to clipboard.") else: - error_msg = getattr(self._currentImage, '_clipboard_cut_error', + error_msg = getattr(self._currentImage, '_clipboard_cut_error', "Failed to cut image to clipboard.") messagebox.showerror( "Cut Error", @@ -682,13 +716,13 @@ class GUI: 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: @@ -696,7 +730,7 @@ class GUI: self._renderCurrentImage() messagebox.showinfo("Paste", "Image pasted from clipboard.") else: - error_msg = getattr(self._currentImage, '_clipboard_paste_error', + error_msg = getattr(self._currentImage, '_clipboard_paste_error', "Failed to paste image from clipboard.") messagebox.showerror( "Paste Error", @@ -715,27 +749,27 @@ class GUI: if self._currentImage is None or self._currentImage.getImage() is None: messagebox.showwarning("No Image", "Please load an image first.") return - + if not self._areaSelectionHandler or not self._areaSelectionHandler.selection_area: messagebox.showwarning("No Selection", "Please select an area first.") return - + selection_area = self._areaSelectionHandler.selection_area - + if not selection_area.is_active or not selection_area.is_valid(): messagebox.showwarning("No Selection", "Please select an area first.") return - + # Get brush color if not self._brushHandler: messagebox.showwarning("Error", "Brush handler not available.") return - + brush_color_hex = self._brushHandler.brush_color - + # Take undo snapshot self._currentImage.snapshot() - + # Use FillSelection manipulation class manipulation = FillSelection() params = { @@ -743,15 +777,46 @@ class GUI: "selection_area": selection_area } manipulation.manipulateImage(self._currentImage, params) - + # Clear selection before re-rendering self._areaSelectionHandler.clear_selection() - + # Re-render the image self._renderCurrentImage() - + messagebox.showinfo("Fill", "Selection filled with color.") def _openCamera(self): camera = CameraWindow() - camera.run(self) \ No newline at end of file + camera.run(self) + + def _enableMagicScissorsMode(self) -> None: + if not self._areaSelectionHandler: + return + self._areaSelectionHandler.enable_magic_scissors_mode() + if self._areaSelectionHandler.selection_type == "magic_scissors": + self._updateStatus("Magic Scissors: click to set a start point, move to trace edges, click to add segments, Enter to finalize, Esc to cancel, Backspace to undo last segment.") + + def _finalizeMagicScissors(self) -> None: + if not self._areaSelectionHandler: + return + self._areaSelectionHandler.finalize_magic_scissors() + if self._areaSelectionHandler.selection_type != "magic_scissors": + self._updateStatus("Magic Scissors selection finalized.") + + def _cancelMagicScissors(self) -> None: + if not self._areaSelectionHandler: + return + self._areaSelectionHandler.cancel_magic_scissors() + self._updateStatus("Magic Scissors cancelled.") + + def _undoMagicScissorsSegment(self) -> None: + if not self._areaSelectionHandler: + return + self._areaSelectionHandler.undo_magic_scissors_segment() + if self._areaSelectionHandler.selection_type == "magic_scissors": + self._updateStatus("Magic Scissors: last segment removed. Continue tracing, Enter to finalize.") + + def _updateStatus(self, message: str) -> None: + if hasattr(self, "_status_var") and self._status_var is not None: + self._status_var.set(message) \ No newline at end of file diff --git a/src/SelectionArea.py b/src/SelectionArea.py index 6c196f4..81975c6 100644 --- a/src/SelectionArea.py +++ b/src/SelectionArea.py @@ -43,19 +43,18 @@ class SelectionArea: """Set the lasso selection path.""" if not path: return - - self.lasso_path = path + + normalized_path = [(int(x), int(y)) for x, y in path] + self.lasso_path = normalized_path self.shape = "lasso" self.is_active = True - - # Calculate bounding box for lasso - if path: - xs = [p[0] for p in path] - ys = [p[1] for p in path] - self.left = min(xs) - self.top = min(ys) - self.right = max(xs) - self.bottom = max(ys) + + xs = [p[0] for p in normalized_path] + ys = [p[1] for p in normalized_path] + self.left = min(xs) + self.top = min(ys) + self.right = max(xs) + self.bottom = max(ys) def get_coordinates(self) -> Optional[Tuple[int, int, int, int]]: """Get the selection coordinates as a tuple.""" diff --git a/src/utils/area_selection.py b/src/utils/area_selection.py index 48364e9..e37f7f2 100644 --- a/src/utils/area_selection.py +++ b/src/utils/area_selection.py @@ -1,7 +1,7 @@ import tkinter as tk from tkinter import Menu from PIL import Image -from typing import Optional, Callable +from typing import Optional, Callable, List, Tuple import sys import os @@ -13,6 +13,7 @@ if _parent_dir not in sys.path: from SelectionArea import SelectionArea from ImageContainer import ImageContainer from ImageManipulation.ManipulationList import GetImageManipulationList +from utils.magic_scissors import MagicScissors class AreaSelectionHandler: @@ -57,27 +58,41 @@ class AreaSelectionHandler: self._selectionRectangle = None self._lassoPath = [] # List of canvas coordinates for lasso drawing self._lassoLine = None # Canvas line item for lasso path + self._magic_scissors: Optional[MagicScissors] = None + self._magic_seed: Optional[Tuple[int, int]] = None + self._magic_committed_points: List[Tuple[int, int]] = [] + self._magic_live_path: List[Tuple[int, int]] = [] + self._magic_committed_canvas_ids: List[int] = [] + self._magic_live_canvas_id: Optional[int] = None + self._magic_last_target: Optional[Tuple[int, int]] = None + self._magic_committed_lengths: List[int] = [] + self._magic_scissors_active: bool = False + self._selection_finalized: bool = False 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") + self._selection_finalized = False else: self._root.config(cursor="") self._pendingOperationCallback = None # Clear pending callback when disabling selection mode + self._reset_magic_scissors(clear_canvas=True) self.clear_selection() - + self._selection_finalized = False + def toggle_selection_shape(self) -> None: """Toggle selection shape between rectangle, circle, and lasso.""" if self._selectionArea is None: return + self._selection_finalized = False # Cycle through: rectangle -> circle -> lasso -> rectangle current_shape = self._selectionArea.shape if current_shape == "rectangle": @@ -87,15 +102,44 @@ class AreaSelectionHandler: else: # lasso new_shape = "rectangle" + if self._selectionType == "magic_scissors" and new_shape != "lasso": + self._reset_magic_scissors(clear_canvas=True) + 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() - + if self._selectionType != "magic_scissors": + self._selection_finalized = False + + def enable_magic_scissors_mode(self) -> None: + """Enable magic scissors selection mode.""" + current_image = self._get_current_image() + if current_image is None or current_image.getImage() is None: + return + + if not self._selectionMode: + self.toggle_selection_mode() + + self._reset_magic_scissors(clear_canvas=True) + + try: + self._magic_scissors = MagicScissors(current_image.getImage()) + except Exception: + self._magic_scissors = None + return + + self._selectionArea.clear() + self._selectionArea.set_shape("lasso") + self._selectionType = "magic_scissors" + self._selectionArea.is_active = False + self._magic_scissors_active = True + self._selection_finalized = False + def set_selection_shape(self, shape: str) -> None: """Set the selection shape directly. - + Args: shape: One of "rectangle", "circle", "lasso", or "polygon" """ @@ -110,53 +154,53 @@ class AreaSelectionHandler: # 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) @@ -164,39 +208,39 @@ class AreaSelectionHandler: 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. @@ -210,7 +254,13 @@ class AreaSelectionHandler: current_image = self._get_current_image() if current_image is None: return False - + + if self._selection_finalized and self._selectionType != "magic_scissors": + return False + + if self._selectionType == "magic_scissors": + return self._handle_magic_scissors_click(event) + # Handle lasso mode if self._selectionType == "lasso": # Clear any existing selection @@ -221,7 +271,7 @@ class AreaSelectionHandler: 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 @@ -236,20 +286,20 @@ class AreaSelectionHandler: 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] @@ -258,9 +308,9 @@ class AreaSelectionHandler: fill="red", width=2 ) self._polygonLines.append(line) - + return True - + # Handle rectangle/circle mode # Clear any existing selection self.clear_selection() @@ -283,7 +333,13 @@ class AreaSelectionHandler: current_image = self._get_current_image() if current_image is None: return False - + + if self._selection_finalized and self._selectionType != "magic_scissors": + return False + + if self._selectionType == "magic_scissors": + return self._handle_magic_scissors_drag(event) + # Handle lasso mode if self._selectionType == "lasso" and self._lassoPath: # Add point to lasso path @@ -337,7 +393,10 @@ class AreaSelectionHandler: current_image = self._get_current_image() if current_image is None: return False - + + if self._selection_finalized and self._selectionType != "magic_scissors": + return False + # Handle lasso mode if self._selectionType == "lasso" and self._lassoPath: if len(self._lassoPath) >= 3: @@ -362,14 +421,14 @@ class AreaSelectionHandler: # 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) if self._selectionType == "polygon": @@ -397,7 +456,7 @@ class AreaSelectionHandler: ) # 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 @@ -410,7 +469,7 @@ class AreaSelectionHandler: Works for all selection types: rectangle, circle, lasso, and polygon. The selection must be active and valid (completed) before the context menu appears. - + Args: event: The mouse event get_default_params: Function to get default parameters for a manipulation @@ -553,64 +612,64 @@ class AreaSelectionHandler: current_image._imageData = cropped_image # Clear selection before re-rendering - self.clear_selection() + self.clear_selection(reset_finalized=False) # 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() @@ -647,12 +706,234 @@ class AreaSelectionHandler: current_image._imageData = result_image # Clear selection before re-rendering - self.clear_selection() + self.clear_selection(reset_finalized=False) # Re-render the image (without selection) self._render_image() - def clear_selection(self) -> None: + def finalize_magic_scissors(self) -> None: + """Finalize the current magic scissors path.""" + if self._selectionType != "magic_scissors": + return + + combined: List[Tuple[int, int]] = [] + if self._magic_committed_points: + combined.extend(self._magic_committed_points) + + if self._magic_live_path: + if combined: + combined.extend(self._magic_live_path[1:]) + else: + combined.extend(self._magic_live_path) + + if len(combined) >= 3: + self._selectionArea.set_lasso_path(combined) + self._selectionArea.is_active = True + self._selection_finalized = True + self._magic_scissors_active = False + self._selectionType = "lasso" + self._render_image() + + self._reset_magic_scissors(clear_canvas=True) + + def cancel_magic_scissors(self) -> None: + """Cancel the magic scissors selection in progress.""" + if self._selectionType != "magic_scissors": + return + self._reset_magic_scissors(clear_canvas=True) + self._selectionType = "lasso" + self._selectionArea.clear() + self._render_image() + self._selection_finalized = False + self._magic_scissors_active = False + + def undo_magic_scissors_segment(self) -> None: + """Undo the last committed magic scissors segment.""" + if self._selectionType != "magic_scissors": + return + if not self._magic_committed_points or not self._magic_committed_lengths: + return + self._selection_finalized = False + + if self._magic_committed_canvas_ids: + canvas_id = self._magic_committed_canvas_ids.pop() + try: + self._canvas.delete(canvas_id) + except Exception: + pass + + length = self._magic_committed_lengths.pop() + remove_count = min(length, max(0, len(self._magic_committed_points) - 1)) + for _ in range(remove_count): + if len(self._magic_committed_points) <= 1: + break + self._magic_committed_points.pop() + + if self._magic_committed_points: + self._magic_seed = self._magic_committed_points[-1] + if self._magic_scissors: + self._magic_scissors.set_seed(*self._magic_seed) + else: + self._magic_seed = None + + self._magic_last_target = None + self._remove_magic_live_overlay() + + def _handle_magic_scissors_click(self, event) -> bool: + if self._magic_scissors is None: + return False + + image_point = self._canvas_to_image_point(event.x, event.y) + if image_point is None: + return False + + ix, iy = image_point + self._selection_finalized = False + + if self._magic_seed is None: + self._magic_seed = (ix, iy) + self._magic_scissors.set_seed(ix, iy) + self._magic_committed_points = [(ix, iy)] + self._selectionArea.is_active = False + self._magic_last_target = None + return True + + if self._magic_last_target != (ix, iy): + self._magic_live_path = self._magic_scissors.get_path_to(ix, iy) + + if not self._magic_live_path: + return True + + self._commit_magic_segment(self._magic_live_path) + self._magic_seed = (ix, iy) + self._magic_scissors.set_seed(ix, iy) + self._magic_live_path = [] + self._magic_last_target = None + self._remove_magic_live_overlay() + return True + + def _handle_magic_scissors_drag(self, event) -> bool: + if self._magic_scissors is None or self._magic_seed is None: + return False + + image_point = self._canvas_to_image_point(event.x, event.y) + if image_point is None: + return False + + ix, iy = image_point + if self._magic_last_target == (ix, iy): + return True + + path = self._magic_scissors.get_path_to(ix, iy) + self._magic_live_path = path + self._magic_last_target = (ix, iy) + self._draw_magic_live_overlay(path) + return True + + def _commit_magic_segment(self, segment: List[Tuple[int, int]]) -> None: + if not segment: + return + if not self._magic_committed_points: + self._magic_committed_points = segment.copy() + self._magic_committed_lengths.append(max(0, len(segment) - 1)) + else: + self._magic_committed_points.extend(segment[1:]) + self._magic_committed_lengths.append(max(0, len(segment) - 1)) + + canvas_points = self._image_points_to_canvas(segment) + if len(canvas_points) >= 4: + line_id = self._canvas.create_line( + *canvas_points, + fill="yellow", + width=2, + smooth=False, + ) + self._magic_committed_canvas_ids.append(line_id) + + def _draw_magic_live_overlay(self, path: List[Tuple[int, int]]) -> None: + self._remove_magic_live_overlay() + canvas_points = self._image_points_to_canvas(path) + if len(canvas_points) >= 4: + self._magic_live_canvas_id = self._canvas.create_line( + *canvas_points, + fill="cyan", + width=2, + dash=(4, 2), + smooth=False, + ) + + def _remove_magic_live_overlay(self) -> None: + if self._magic_live_canvas_id is not None: + try: + self._canvas.delete(self._magic_live_canvas_id) + except Exception: + pass + self._magic_live_canvas_id = None + + def _reset_magic_scissors(self, clear_canvas: bool = False) -> None: + if clear_canvas: + for item_id in self._magic_committed_canvas_ids: + try: + self._canvas.delete(item_id) + except Exception: + pass + self._magic_committed_canvas_ids = [] + self._remove_magic_live_overlay() + + self._magic_scissors = None + self._magic_seed = None + self._magic_committed_points = [] + self._magic_live_path = [] + self._magic_last_target = None + self._magic_committed_lengths = [] + self._magic_scissors_active = False + if self._selectionType == "magic_scissors": + self._selectionType = "lasso" + + def _canvas_to_image_point(self, canvas_x: float, canvas_y: float) -> Optional[Tuple[int, int]]: + current_image = self._get_current_image() + if current_image is None or current_image.getImage() is None: + return None + + pil_image = current_image.getImage() + img_width, img_height = pil_image.size + + canvas_width, canvas_height = self._get_canvas_dimensions() + if canvas_width <= 0 or canvas_height <= 0: + return None + + cx = self._canvas.canvasx(canvas_x) + cy = self._canvas.canvasy(canvas_y) + + scale_x = img_width / canvas_width + scale_y = img_height / canvas_height + + ix = int(max(0, min(img_width - 1, cx * scale_x))) + iy = int(max(0, min(img_height - 1, cy * scale_y))) + return ix, iy + + def _image_points_to_canvas(self, points: List[Tuple[int, int]]) -> List[float]: + current_image = self._get_current_image() + if current_image is None or current_image.getImage() is None: + return [] + + pil_image = current_image.getImage() + img_width, img_height = pil_image.size + + canvas_width, canvas_height = self._get_canvas_dimensions() + if canvas_width <= 0 or canvas_height <= 0: + return [] + + scale_x = canvas_width / img_width + scale_y = canvas_height / img_height + + coords: List[float] = [] + for x, y in points: + coords.append(x * scale_x) + coords.append(y * scale_y) + return coords + + def clear_selection(self, reset_finalized: bool = True) -> None: """Clear the selection rectangle and reset selection state.""" if self._selectionRectangle: self._canvas.delete(self._selectionRectangle) @@ -673,7 +954,11 @@ class AreaSelectionHandler: self._selectionEndX = None self._selectionEndY = None self._selectionArea.clear() - + self._reset_magic_scissors(clear_canvas=True) + if reset_finalized: + self._selection_finalized = False + self._selection_finalized = False + 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(): diff --git a/src/utils/magic_scissors.py b/src/utils/magic_scissors.py new file mode 100644 index 0000000..9aaf1b2 --- /dev/null +++ b/src/utils/magic_scissors.py @@ -0,0 +1,138 @@ +import heapq +from typing import List, Tuple, Optional + +import cv2 +import numpy as np + +from utils.image_utils import pil_to_cv2 + + +class MagicScissors: + """Live-wire / intelligent scissors implementation on top of a PIL image.""" + + _EDGE_EPSILON = 1e-6 + + def __init__( + self, + pil_image, + blur_ksize: int = 3, + diagonal_penalty: float = 0.41421356237, + ) -> None: + """ + Args: + pil_image: PIL.Image.Image instance to operate on. + blur_ksize: Kernel size for the pre-smoothing Gaussian blur. Must be odd. + diagonal_penalty: Additional cost added for diagonal neighbor transitions. + """ + if pil_image is None: + raise ValueError("MagicScissors requires a valid PIL image.") + + self._pil_image = pil_image + self._blur_ksize = blur_ksize if blur_ksize % 2 == 1 else blur_ksize + 1 + self._diagonal_penalty = max(0.0, diagonal_penalty) + + self._width: int + self._height: int + self._cost_map: np.ndarray + self._prepare_image() + + self._seed: Optional[Tuple[int, int]] = None + self._distances: Optional[np.ndarray] = None + self._predecessors: Optional[np.ndarray] = None + + @property + def image_size(self) -> Tuple[int, int]: + """Return image dimensions as (width, height).""" + return self._width, self._height + + def _prepare_image(self) -> None: + """Convert input image to a cost map suitable for shortest-path search.""" + cv_img = pil_to_cv2(self._pil_image) + gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY) + + if self._blur_ksize > 1: + gray = cv2.GaussianBlur(gray, (self._blur_ksize, self._blur_ksize), 0) + + sobel_x = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3) + sobel_y = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3) + magnitude = cv2.magnitude(sobel_x, sobel_y) + + max_val = float(magnitude.max()) + if max_val > 0: + strength = magnitude / max_val + else: + strength = magnitude + + self._cost_map = 1.0 / (strength + self._EDGE_EPSILON) + self._height, self._width = strength.shape + + def set_seed(self, x: int, y: int) -> None: + """Run Dijkstra from the given seed pixel and store shortest paths.""" + if not (0 <= x < self._width and 0 <= y < self._height): + raise ValueError("Seed coordinates are out of bounds.") + + self._seed = (int(x), int(y)) + self._distances = np.full((self._height, self._width), np.inf, dtype=np.float64) + self._predecessors = np.full((self._height, self._width, 2), -1, dtype=np.int32) + + self._distances[y, x] = 0.0 + queue: List[Tuple[float, int, int]] = [(0.0, y, x)] + + neighbors = [ + (-1, -1), + (-1, 0), + (-1, 1), + (0, -1), + (0, 1), + (1, -1), + (1, 0), + (1, 1), + ] + + while queue: + cost, cy, cx = heapq.heappop(queue) + if cost > self._distances[cy, cx]: + continue + + for dy, dx in neighbors: + ny, nx = cy + dy, cx + dx + if ny < 0 or ny >= self._height or nx < 0 or nx >= self._width: + continue + + step_cost = self._cost_map[ny, nx] + if dx != 0 and dy != 0: + step_cost += self._diagonal_penalty + + new_cost = cost + step_cost + if new_cost < self._distances[ny, nx]: + self._distances[ny, nx] = new_cost + self._predecessors[ny, nx] = (cx, cy) + heapq.heappush(queue, (new_cost, ny, nx)) + + def get_path_to(self, x: int, y: int) -> List[Tuple[int, int]]: + """Return the minimum-cost path from the current seed to (x, y).""" + if self._seed is None or self._distances is None or self._predecessors is None: + raise RuntimeError("No seed has been set. Call set_seed() first.") + + if not (0 <= x < self._width and 0 <= y < self._height): + raise ValueError("Target coordinates are out of bounds.") + + if not np.isfinite(self._distances[y, x]): + return [] + + path: List[Tuple[int, int]] = [] + cx, cy = int(x), int(y) + sx, sy = self._seed + + while True: + path.append((cx, cy)) + if (cx, cy) == (sx, sy): + break + px, py = self._predecessors[cy, cx] + if px < 0 or py < 0: + return [] + cx, cy = int(px), int(py) + + path.reverse() + return path +