Files
IKT213-photo-app/src/utils/area_selection.py

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