628 lines
24 KiB
Python
628 lines
24 KiB
Python
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", or "lasso"
|
|
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
|
|
|
|
# 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_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 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 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
|
|
self._lassoPath = []
|
|
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 == "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
|
|
|