moving area selection, text input, and brush functionalities out of GUI.py and into their own files in the utils directory
This commit is contained in:
523
src/utils/area_selection.py
Normal file
523
src/utils/area_selection.py
Normal file
@@ -0,0 +1,523 @@
|
||||
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
|
||||
|
||||
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.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 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()
|
||||
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()
|
||||
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 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
|
||||
|
||||
Reference in New Issue
Block a user