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 @@
-
-
+
-
-
+
+
+
@@ -38,14 +38,16 @@
"keyToString": {
"ModuleVcsDetector.initialDetectionPerformed": "true",
"Python.Main.executor": "Run",
+ "Python.Run Project.executor": "Run",
"Python.Run project.executor": "Run",
"Python.demo.executor": "Run",
"RunOnceActivity.ShowReadmeOnStart": "true",
+ "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true",
"com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1": "true",
- "git-widget-placeholder": "main",
+ "git-widget-placeholder": "Merging main",
"junie.onboarding.icon.badge.shown": "true",
- "last_opened_file_path": "/home/kim/Dokumenter/Universitet/Machine Vision/ikt213_project/src",
+ "last_opened_file_path": "C:/Users/ali-a/ikt213g25h/project/ikt213_project",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
@@ -65,8 +67,8 @@
-
-
+
+
@@ -81,6 +83,8 @@
+
+
@@ -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
+