Merge pull request #9 from UiA-IKT213-Gruppe4/area-selection

Area selection
This commit is contained in:
viljarb0
2025-11-03 21:20:08 +01:00
committed by GitHub
6 changed files with 592 additions and 173 deletions

View File

@@ -1,10 +1,12 @@
import tkinter as tk import tkinter as tk
from tkinter import filedialog from tkinter import filedialog, messagebox
from PIL import ImageTk from PIL import ImageTk
from ImageContainer import ImageContainer from ImageContainer import ImageContainer
from ImageManipulation.ManipulationList import * from ImageManipulation.ManipulationList import *
from SelectionArea import SelectionArea
from functools import partial from functools import partial
class GUI: class GUI:
"""The GUI class responsible for the main application GUI. """The GUI class responsible for the main application GUI.
@@ -17,6 +19,15 @@ class GUI:
_currentImage = None _currentImage = None
# Interactive selection variables
_selectionMode = False
_selectionStartX = None
_selectionStartY = None
_selectionEndX = None
_selectionEndY = None
_selectionRectangle = None
_selectionArea = None
def __new__(cls): def __new__(cls):
if cls._instance is None: if cls._instance is None:
cls._instance = super(GUI, cls).__new__(cls) cls._instance = super(GUI, cls).__new__(cls)
@@ -45,15 +56,9 @@ class GUI:
file_menu = tk.Menu(menu, tearoff=0) file_menu = tk.Menu(menu, tearoff=0)
menu.add_cascade(label="File", menu=file_menu) menu.add_cascade(label="File", menu=file_menu)
file_menu.add_command(label="Open Image", command=lambda: self._openImage()) file_menu.add_command(label="Open Image", command=lambda: self._openImage())
file_menu.add_command(label="Open Camera", command=lambda: self._openCamera())
file_menu.add_command(label="Save Image", command=lambda: self._saveImage()) file_menu.add_command(label="Save Image", command=lambda: self._saveImage())
file_menu.add_command(label="Exit", command=root.quit) file_menu.add_command(label="Exit", command=root.quit)
edit_menu = tk.Menu(menu, tearoff=0)
menu.add_cascade(label="Edit", menu=edit_menu)
edit_menu.add_command(label="Undo", accelerator="Ctrl+Z", command=lambda: self._undo())
test_menu = tk.Menu(menu, tearoff=0) test_menu = tk.Menu(menu, tearoff=0)
menu.add_cascade(label="Test", menu=test_menu) menu.add_cascade(label="Test", menu=test_menu)
for manipulation in GetImageManipulationList(): for manipulation in GetImageManipulationList():
@@ -62,20 +67,49 @@ class GUI:
command=partial(self._applyManipulation, manipulation) command=partial(self._applyManipulation, manipulation)
) )
# Manual Test menu listing all manipulations explicitly
manual_menu = tk.Menu(menu, tearoff=0)
menu.add_cascade(label="menuuuu", menu=manual_menu)
manual_menu.add_command(label="Padding", command=partial(self._applyManipulation, Padding(), {"border_width": 50}))
manual_menu.add_command(label="Crop", command=partial(self._applyManipulation, CropImage()))
manual_menu.add_command(label="Resize", command=partial(self._applyManipulation, ResizeImage(), {"width": 200, "height": 200}))
manual_menu.add_command(label="Copy", command=partial(self._applyManipulation, CopyImage()))
edit_menu = tk.Menu(menu, tearoff=0)
menu.add_cascade(label="Edit", menu=edit_menu)
edit_menu.add_command(label="Undo", accelerator="Ctrl+Z", command=lambda: self._undo())
edit_menu.add_command(label="Redo", accelerator="Ctrl+Y", command=lambda: self._redo())
edit_menu.add_separator()
edit_menu.add_command(label="Toggle Selection Mode", accelerator="Ctrl+S", command=lambda: self._toggleSelectionMode())
edit_menu.add_command(label="Toggle Selection Shape (Rect/Circle)", accelerator="Ctrl+Shift+C", command=lambda: self._toggleSelectionShape())
# Frame to hold image # Frame to hold image
imgframe = tk.Frame(root, width=500, height=500, bg="lightgray", relief="sunken", bd=2) imgframe = tk.Frame(root, width=500, height=500, bg="lightgray", relief="sunken", bd=2)
imgframe.pack(side="top", pady=10) imgframe.pack(side="top", pady=10)
# Label to display image # Canvas to display image and draw selection
self._imageLabel = tk.Label(imgframe, width=500, height=500, bg="white") self._imageCanvas = tk.Canvas(imgframe, width=500, height=500, bg="white")
self._imageLabel.pack(expand=True) self._imageCanvas.pack(expand=True)
# Label to hold the image (will be placed on canvas)
self._imageLabel = tk.Label(self._imageCanvas, bg="white")
# Bind mouse events for interactive selection
self._imageCanvas.bind("<Button-1>", self._onMouseClick)
self._imageCanvas.bind("<B1-Motion>", self._onMouseDrag)
self._imageCanvas.bind("<ButtonRelease-1>", self._onMouseRelease)
self._imageCanvas.bind("<Button-3>", self._onRightClick) # Right-click for context menu
self._root = root self._root = root
# Initialize selection area
self._selectionArea = SelectionArea()
# Key bindings # Key bindings
root.bind_all('<Control-z>', lambda event: self._undo()) root.bind_all('<Control-z>', lambda event: self._undo())
root.bind_all('<Control-y>', lambda event: self._redo())
root.bind_all('<Control-s>', lambda event: self._toggleSelectionMode())
root.bind_all('<Control-Shift-C>', lambda event: self._toggleSelectionShape())
root.mainloop() root.mainloop()
@@ -93,12 +127,26 @@ class GUI:
def _renderCurrentImage(self) -> None: def _renderCurrentImage(self) -> None:
if self._currentImage is None or self._currentImage.getImage() is None: if self._currentImage is None or self._currentImage.getImage() is None:
return return
# Clear canvas
self._imageCanvas.delete("all")
pil_img = self._currentImage.getImage() pil_img = self._currentImage.getImage()
width, height = pil_img.size width, height = pil_img.size
tk_img = ImageTk.PhotoImage(pil_img) tk_img = ImageTk.PhotoImage(pil_img)
# Keep reference to avoid garbage collection # Keep reference to avoid garbage collection
self._imageLabel.image = tk_img self._imageLabel.image = tk_img
self._imageLabel.config(image=tk_img, width=width, height=height)
# Place image on canvas
self._imageCanvas.create_image(0, 0, anchor="nw", image=tk_img)
# Update canvas size
self._imageCanvas.config(width=width, height=height)
# If there's an active selection, draw it
if self._selectionArea.is_active and self._selectionArea.is_valid():
self._drawSelectionHighlight()
def _applyManipulation(self, manipulation, params: dict | None = None) -> None: def _applyManipulation(self, manipulation, params: dict | None = None) -> None:
if self._currentImage is None: if self._currentImage is None:
@@ -127,6 +175,269 @@ class GUI:
self._currentImage.undo() self._currentImage.undo()
self._renderCurrentImage() self._renderCurrentImage()
def _openCamera(self): def _redo(self) -> None:
camera = CameraWindow() if self._currentImage is None:
camera.run(self) return
self._currentImage.redo()
self._renderCurrentImage()
def _toggleSelectionMode(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._clearSelection()
def _toggleSelectionShape(self) -> None:
"""Toggle selection shape between rectangle and circle."""
if self._selectionArea is None:
return
new_shape = "circle" if self._selectionArea.shape == "rectangle" else "rectangle"
self._selectionArea.set_shape(new_shape)
# Refresh selection drawing if active
if self._selectionArea.is_active and self._selectionArea.is_valid():
self._renderCurrentImage()
def _onMouseClick(self, event) -> None:
"""Handle mouse click for selection."""
if not self._selectionMode or self._currentImage is None:
return
# Clear any existing selection
self._clearSelection()
# Store starting coordinates
self._selectionStartX = event.x
self._selectionStartY = event.y
def _onMouseDrag(self, event) -> None:
"""Handle mouse drag for selection."""
if not self._selectionMode or self._currentImage is None or self._selectionStartX is None:
return
# Clear previous shape
if self._selectionRectangle:
self._imageCanvas.delete(self._selectionRectangle)
# Draw shape outline depending on selection shape
if self._selectionArea.shape == "circle":
self._selectionRectangle = self._imageCanvas.create_oval(
self._selectionStartX, self._selectionStartY, event.x, event.y,
outline="red", width=3, fill=""
)
else:
self._selectionRectangle = self._imageCanvas.create_rectangle(
self._selectionStartX, self._selectionStartY, event.x, event.y,
outline="red", width=3, fill=""
)
def _onMouseRelease(self, event) -> None:
"""Handle mouse release to finalize selection."""
if not self._selectionMode or self._currentImage is None or self._selectionStartX is None:
return
# Store ending coordinates
self._selectionEndX = event.x
self._selectionEndY = event.y
# Convert screen coordinates to image coordinates
selection_coords = self._convertToImageCoordinates(
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"]
)
def _onRightClick(self, event) -> None:
"""Handle right-click to show context menu for manipulation options."""
if not self._selectionArea.is_active or not self._selectionArea.is_valid():
return
# Create context menu with all available manipulations
context_menu = tk.Menu(self._root, tearoff=0)
# 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._applyManipulationToSelection(m)
)
context_menu.add_separator()
context_menu.add_command(label="Clear Selection", command=self._clearSelection)
# Show context menu at cursor position
try:
context_menu.tk_popup(event.x_root, event.y_root)
finally:
context_menu.grab_release()
def _convertToImageCoordinates(self, start_x, start_y, end_x, end_y):
"""Convert screen coordinates to image coordinates."""
if self._currentImage is None:
return None
pil_image = self._currentImage.getImage()
if pil_image is None:
return None
# Get image dimensions
img_width, img_height = pil_image.size
# Get canvas dimensions
canvas_width = self._imageCanvas.winfo_width()
canvas_height = self._imageCanvas.winfo_height()
if canvas_width <= 0 or canvas_height <= 0:
return None
# Calculate scaling factors
scale_x = img_width / canvas_width
scale_y = img_height / canvas_height
# Convert coordinates
left = int(min(start_x, end_x) * scale_x)
top = int(min(start_y, end_y) * scale_y)
right = int(max(start_x, end_x) * scale_x)
bottom = int(max(start_y, end_y) * 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 _applyManipulationToSelection(self, manipulation) -> None:
"""Apply the specified manipulation to the selected area only."""
if self._currentImage is None or not self._selectionArea.is_active:
return
# Take undo snapshot
self._currentImage.snapshot()
# Get the original image
original_image = self._currentImage.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 = self._getDefaultParams(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
self._currentImage._imageData = result_image
# Clear selection before re-rendering
self._clearSelection()
# Re-render the image (without selection)
self._renderCurrentImage()
def _getDefaultParams(self, manipulation) -> dict:
"""Get default parameters for a manipulation."""
manipulation_name = manipulation.getManipulationName().lower()
if "crop" in manipulation_name:
return {"width": 200, "height": 200}
elif "resize" in manipulation_name:
return {"width": 200, "height": 200}
elif "rotate" in manipulation_name:
return {"angle": 90}
elif "flip" in manipulation_name:
return {"mode": "horizontal"}
elif "color" in manipulation_name or "adjust" in manipulation_name:
return {"brightness": 10, "contrast": 1.2, "saturation": 1.1}
elif "blur" in manipulation_name:
return {"ksize": 5}
elif "hue" in manipulation_name:
return {"hue": 50}
elif "padding" in manipulation_name:
return {"border_width": 50}
elif "sobel" in manipulation_name:
return {"dx": 1, "dy": 0, "ksize": 3}
elif "threshold" in manipulation_name:
return {"thresh": 127}
else:
return {}
def _clearSelection(self) -> None:
"""Clear the selection rectangle and reset selection state."""
if self._selectionRectangle:
self._imageCanvas.delete(self._selectionRectangle)
self._selectionRectangle = None
self._selectionStartX = None
self._selectionStartY = None
self._selectionEndX = None
self._selectionEndY = None
self._selectionArea.clear()
def _drawSelectionHighlight(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
pil_image = self._currentImage.getImage()
img_width, img_height = pil_image.size
canvas_width = self._imageCanvas.winfo_width()
canvas_height = self._imageCanvas.winfo_height()
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 == "circle":
self._imageCanvas.create_oval(
canvas_left, canvas_top, canvas_right, canvas_bottom,
outline="red", width=3, fill=""
)
else:
self._imageCanvas.create_rectangle(
canvas_left, canvas_top, canvas_right, canvas_bottom,
outline="red", width=3, fill=""
)

View File

@@ -5,6 +5,7 @@ class ImageContainer:
_imageData = None _imageData = None
_path = None _path = None
_history = None _history = None
_redo_history = None
def loadImage(self, path: str) -> None: def loadImage(self, path: str) -> None:
""" Load image file from the path. """ Load image file from the path.
@@ -14,13 +15,14 @@ class ImageContainer:
imgcv2 = cv2.imread(path) imgcv2 = cv2.imread(path)
height, width, channels = imgcv2.shape height, width, channels = imgcv2.shape
# Open and resize image (optional) # Open and resize image
self._imageData = Image.open(path) self._imageData = Image.open(path)
# PIL expects (width, height) # PIL expects (width, height)
self._imageData = self._imageData.resize((width, height), Image.LANCZOS) self._imageData = self._imageData.resize((width, height), Image.LANCZOS)
self._path = path self._path = path
self._history = [] self._history = []
self._redo_history = []
print("Opened image:", path) print("Opened image:", path)
@@ -39,13 +41,31 @@ class ImageContainer:
"""Push a copy of current image to history for undo.""" """Push a copy of current image to history for undo."""
if self._imageData is None: if self._imageData is None:
return return
# Ensure a deep copy (PIL copy is sufficient) # Ensure a deep copy (but PIL copy should be sufficient)
self._history.append(self._imageData.copy()) self._history.append(self._imageData.copy())
# Cap history size to avoid memory blow-up # New action invalidates redo history
if self._redo_history is not None:
self._redo_history.clear()
# Cap history size to avoid memory blow up
if len(self._history) > 20: if len(self._history) > 20:
self._history.pop(0) self._history.pop(0)
def undo(self) -> None: def undo(self) -> None:
if not self._history: if not self._history:
return return
# Move current state to redo stack
if self._redo_history is None:
self._redo_history = []
if self._imageData is not None:
self._redo_history.append(self._imageData.copy())
# Restore last snapshot
self._imageData = self._history.pop() self._imageData = self._history.pop()
def redo(self) -> None:
if not self._redo_history:
return
# Moving forward: save current to undo history
if self._imageData is not None:
self._history.append(self._imageData.copy())
# Apply redo state
self._imageData = self._redo_history.pop()

View File

@@ -36,7 +36,6 @@ class CropImage(ImageManipulation):
right = left + crop_width right = left + crop_width
bottom = top + crop_height bottom = top + crop_height
else: else:
# Default: trim margins similar to demo in root main.py
img_width, img_height = pil_image.size img_width, img_height = pil_image.size
left = 80 left = 80
top = 80 top = 80

View File

@@ -10,7 +10,7 @@ class FlipImage(ImageManipulation):
return "Flip" return "Flip"
def getParameters(self) -> List[str]: def getParameters(self) -> List[str]:
return ["mode"] # horizontal|vertical return ["mode"]
def manipulateImage(self, image: ImageContainer, parameters: Any) -> None: def manipulateImage(self, image: ImageContainer, parameters: Any) -> None:
if image is None or image.getImage() is None: if image is None or image.getImage() is None:

89
src/SelectionArea.py Normal file
View File

@@ -0,0 +1,89 @@
from PIL import Image, ImageDraw
from typing import Dict, Optional, Tuple
class SelectionArea:
"""Class to handle selected rectangular areas on images."""
def __init__(self):
self.left: Optional[int] = None
self.top: Optional[int] = None
self.right: Optional[int] = None
self.bottom: Optional[int] = None
self.is_active: bool = False
self.shape: str = "rectangle" # "rectangle" or "circle"
def set_coordinates(self, left: int, top: int, right: int, bottom: int) -> None:
"""Set the selection coordinates."""
self.left = left
self.top = top
self.right = right
self.bottom = bottom
self.is_active = True
def clear(self) -> None:
"""Clear the selection."""
self.left = None
self.top = None
self.right = None
self.bottom = None
self.is_active = False
self.shape = self.shape # keep last used shape
def set_shape(self, shape: str) -> None:
"""Set the selection shape: 'rectangle' or 'circle'."""
if shape in ("rectangle", "circle"):
self.shape = shape
def get_coordinates(self) -> Optional[Tuple[int, int, int, int]]:
"""Get the selection coordinates as a tuple."""
if self.is_active and all(coord is not None for coord in [self.left, self.top, self.right, self.bottom]):
return (self.left, self.top, self.right, self.bottom)
return None
def get_dict(self) -> Optional[Dict[str, int]]:
"""Get the selection coordinates as a dictionary."""
coords = self.get_coordinates()
if coords:
return {
"left": coords[0],
"top": coords[1],
"right": coords[2],
"bottom": coords[3]
}
return None
def is_valid(self) -> bool:
"""Check if the selection is valid (has area > 0)."""
coords = self.get_coordinates()
if coords:
left, top, right, bottom = coords
return right > left and bottom > top
return False
def crop_image(self, image: Image.Image) -> Optional[Image.Image]:
"""Crop the given image to the selection area."""
coords = self.get_coordinates()
if coords and self.is_valid():
# For both rectangle and circle, return the bounding box crop
return image.crop(coords)
return None
def apply_to_image(self, image: Image.Image, processed_selection: Image.Image) -> Image.Image:
"""Apply the processed selection back to the original image."""
coords = self.get_coordinates()
if coords and self.is_valid():
# Create a copy to avoid modifying the original
result_image = image.copy()
if self.shape == "circle":
# Create circular mask for blending only inside the circle
width = self.right - self.left
height = self.bottom - self.top
mask = Image.new("L", (width, height), 0)
draw = ImageDraw.Draw(mask)
draw.ellipse((0, 0, width, height), fill=255)
result_image.paste(processed_selection, (self.left, self.top), mask)
else:
result_image.paste(processed_selection, (self.left, self.top))
return result_image
return image