adding area selection

This commit is contained in:
vb
2025-10-25 22:17:47 +02:00
parent b1f9d0d8c1
commit 2e5bda4531
3 changed files with 529 additions and 181 deletions

View File

@@ -1,151 +1,427 @@
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 functools import partial from SelectionArea import SelectionArea
from functools import partial
class GUI:
"""The GUI class responsible for the main application GUI. class GUI:
"""The GUI class responsible for the main application GUI.
@warning This class is a singleton.
""" @warning This class is a singleton.
_instance = None """
_instance = None
# If the GUI has been initialised.
_isInitialised = False # If the GUI has been initialised.
_isInitialised = False
_currentImage = None
_currentImage = None
def __new__(cls):
if cls._instance is None: # Interactive selection variables
cls._instance = super(GUI, cls).__new__(cls) _selectionMode = False
return cls._instance _selectionStartX = None
_selectionStartY = None
def initialise(self): _selectionEndX = None
"""Initialise the GUI.""" _selectionEndY = None
_selectionRectangle = None
if self._isInitialised: _selectionArea = None
return
else: def __new__(cls):
self._isInitialised = True if cls._instance is None:
cls._instance = super(GUI, cls).__new__(cls)
# Main window return cls._instance
root = tk.Tk()
root.title("Image Viewer") def initialise(self):
root.geometry("800x600") """Initialise the GUI."""
root.config(bg="white")
icon = tk.PhotoImage(file='icon.png') if self._isInitialised:
root.tk.call('wm', 'iconphoto', root._w, icon) return
root.minsize(800, 600) else:
self._isInitialised = True
# Menus
menu = tk.Menu(root) # Main window
root.config(menu=menu) root = tk.Tk()
file_menu = tk.Menu(menu, tearoff=0) root.title("Image Viewer")
menu.add_cascade(label="File", menu=file_menu) root.geometry("800x600")
file_menu.add_command(label="Open Image", command=lambda: self._openImage()) root.config(bg="white")
file_menu.add_command(label="Save Image", command=lambda: self._saveImage()) icon = tk.PhotoImage(file='icon.png')
file_menu.add_command(label="Exit", command=root.quit) root.tk.call('wm', 'iconphoto', root._w, icon)
root.minsize(800, 600)
edit_menu = tk.Menu(menu, tearoff=0)
menu.add_cascade(label="Edit", menu=edit_menu) # Menus
edit_menu.add_command(label="Undo", accelerator="Ctrl+Z", command=lambda: self._undo()) menu = tk.Menu(root)
root.config(menu=menu)
file_menu = tk.Menu(menu, tearoff=0)
test_menu = tk.Menu(menu, tearoff=0) menu.add_cascade(label="File", menu=file_menu)
menu.add_cascade(label="Test", menu=test_menu) file_menu.add_command(label="Open Image", command=lambda: self._openImage())
for manipulation in GetImageManipulationList(): file_menu.add_command(label="Save Image", command=lambda: self._saveImage())
test_menu.add_command( file_menu.add_command(label="Exit", command=root.quit)
label=manipulation.getManipulationName(),
command=partial(self._applyManipulation, manipulation) test_menu = tk.Menu(menu, tearoff=0)
) menu.add_cascade(label="Test", menu=test_menu)
for manipulation in GetImageManipulationList():
# Manual Test menu listing all manipulations explicitly test_menu.add_command(
# manual_menu = tk.Menu(menu, tearoff=0) label=manipulation.getManipulationName(),
# menu.add_cascade(label="Test", menu=manual_menu) command=partial(self._applyManipulation, manipulation)
# 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 Test menu listing all manipulations explicitly
# manual_menu.add_command(label="Copy", command=partial(self._applyManipulation, CopyImage())) # manual_menu = tk.Menu(menu, tearoff=0)
# manual_menu.add_command(label="Greyscale", command=partial(self._applyManipulation, Grayscale())) # menu.add_cascade(label="Test", menu=manual_menu)
# manual_menu.add_command(label="HSV", command=partial(self._applyManipulation, HSV())) # manual_menu.add_command(label="Padding", command=partial(self._applyManipulation, Padding(), {"border_width": 50}))
# manual_menu.add_command(label="Hue Shifted", command=partial(self._applyManipulation, HueShift(), {"hue": 50})) # manual_menu.add_command(label="Crop", command=partial(self._applyManipulation, CropImage()))
# manual_menu.add_command(label="Smoothed", command=partial(self._applyManipulation, BoxBlur(), {"ksize": 15})) # manual_menu.add_command(label="Resize", command=partial(self._applyManipulation, ResizeImage(), {"width": 200, "height": 200}))
# manual_menu.add_command(label="Rotated", command=partial(self._applyManipulation, RotateImage(), {"angle": 90})) # manual_menu.add_command(label="Copy", command=partial(self._applyManipulation, CopyImage()))
# manual_menu.add_command(label="Flip (Horizontal)", command=partial(self._applyManipulation, FlipImage(), {"mode": "horizontal"})) # manual_menu.add_command(label="Greyscale", command=partial(self._applyManipulation, Grayscale()))
# manual_menu.add_command(label="Flip (Vertical)", command=partial(self._applyManipulation, FlipImage(), {"mode": "vertical"})) # manual_menu.add_command(label="HSV", command=partial(self._applyManipulation, HSV()))
# manual_menu.add_command(label="Color Adjust", command=partial(self._applyManipulation, ColorAdjust(), {"brightness": 10, "contrast": 1.2, "saturation": 1.1})) # manual_menu.add_command(label="Hue Shifted", command=partial(self._applyManipulation, HueShift(), {"hue": 50}))
# manual_menu.add_command(label="Gaussian Blur", command=partial(self._applyManipulation, GaussianBlur(), {"ksize": 5})) # manual_menu.add_command(label="Smoothed", command=partial(self._applyManipulation, BoxBlur(), {"ksize": 15}))
# manual_menu.add_command(label="Sobel Edge", command=partial(self._applyManipulation, SobelEdge(), {"dx": 1, "dy": 0, "ksize": 3})) # manual_menu.add_command(label="Rotated", command=partial(self._applyManipulation, RotateImage(), {"angle": 90}))
# manual_menu.add_command(label="Binary Threshold", command=partial(self._applyManipulation, BinaryThreshold(), {"thresh": 127})) # manual_menu.add_command(label="Flip (Horizontal)", command=partial(self._applyManipulation, FlipImage(), {"mode": "horizontal"}))
# manual_menu.add_command(label="Histogram Threshold", command=partial(self._applyManipulation, HistogramThreshold())) # manual_menu.add_command(label="Flip (Vertical)", command=partial(self._applyManipulation, FlipImage(), {"mode": "vertical"}))
# manual_menu.add_command(label="Color Adjust", command=partial(self._applyManipulation, ColorAdjust(), {"brightness": 10, "contrast": 1.2, "saturation": 1.1}))
# manual_menu.add_command(label="Gaussian Blur", command=partial(self._applyManipulation, GaussianBlur(), {"ksize": 5}))
edit_menu = tk.Menu(menu, tearoff=0) # manual_menu.add_command(label="Sobel Edge", command=partial(self._applyManipulation, SobelEdge(), {"dx": 1, "dy": 0, "ksize": 3}))
menu.add_cascade(label="Edit", menu=edit_menu) # manual_menu.add_command(label="Binary Threshold", command=partial(self._applyManipulation, BinaryThreshold(), {"thresh": 127}))
edit_menu.add_command(label="Undo", accelerator="Ctrl+Z", command=lambda: self._undo()) # manual_menu.add_command(label="Histogram Threshold", command=partial(self._applyManipulation, HistogramThreshold()))
# Frame to hold image
imgframe = tk.Frame(root, width=500, height=500, bg="lightgray", relief="sunken", bd=2) edit_menu = tk.Menu(menu, tearoff=0)
imgframe.pack(side="top", pady=10) menu.add_cascade(label="Edit", menu=edit_menu)
edit_menu.add_command(label="Undo", accelerator="Ctrl+Z", command=lambda: self._undo())
# Label to display image edit_menu.add_separator()
self._imageLabel = tk.Label(imgframe, width=500, height=500, bg="white") edit_menu.add_command(label="Toggle Selection Mode", accelerator="Ctrl+S", command=lambda: self._toggleSelectionMode())
self._imageLabel.pack(expand=True)
# Frame to hold image
self._root = root imgframe = tk.Frame(root, width=500, height=500, bg="lightgray", relief="sunken", bd=2)
imgframe.pack(side="top", pady=10)
# Key bindings
root.bind_all('<Control-z>', lambda event: self._undo()) # Canvas to display image and draw selection
self._imageCanvas = tk.Canvas(imgframe, width=500, height=500, bg="white")
root.mainloop() self._imageCanvas.pack(expand=True)
def _openImage(self) -> None: # Label to hold the image (will be placed on canvas)
file_path = filedialog.askopenfilename( self._imageLabel = tk.Label(self._imageCanvas, bg="white")
filetypes=[("Image files", "*.jpg *.jpeg *.png *.gif *.bmp")]
) # Bind mouse events for interactive selection
if not file_path: self._imageCanvas.bind("<Button-1>", self._onMouseClick)
return self._imageCanvas.bind("<B1-Motion>", self._onMouseDrag)
self._imageCanvas.bind("<ButtonRelease-1>", self._onMouseRelease)
self._currentImage = ImageContainer() self._imageCanvas.bind("<Button-3>", self._onRightClick) # Right-click for context menu
self._currentImage.loadImage(file_path)
self._renderCurrentImage() self._root = root
def _renderCurrentImage(self) -> None: # Initialize selection area
if self._currentImage is None or self._currentImage.getImage() is None: self._selectionArea = SelectionArea()
return
pil_img = self._currentImage.getImage() # Key bindings
width, height = pil_img.size root.bind_all('<Control-z>', lambda event: self._undo())
tk_img = ImageTk.PhotoImage(pil_img) root.bind_all('<Control-s>', lambda event: self._toggleSelectionMode())
# Keep reference to avoid garbage collection
self._imageLabel.image = tk_img root.mainloop()
self._imageLabel.config(image=tk_img, width=width, height=height)
def _openImage(self) -> None:
def _applyManipulation(self, manipulation, params: dict | None = None) -> None: file_path = filedialog.askopenfilename(
if self._currentImage is None: filetypes=[("Image files", "*.jpg *.jpeg *.png *.gif *.bmp")]
return )
# Take undo snapshot if not file_path:
self._currentImage.snapshot() return
if params is None:
# default demo params for crop self._currentImage = ImageContainer()
params = {"width": 200, "height": 200} self._currentImage.loadImage(file_path)
manipulation.manipulateImage(self._currentImage, params) self._renderCurrentImage()
self._renderCurrentImage()
def _renderCurrentImage(self) -> None:
def _saveImage(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
path = filedialog.asksaveasfilename(defaultextension=".png", filetypes=[ # Clear canvas
("PNG", "*.png"), ("JPEG", "*.jpg;*.jpeg"), ("Bitmap", "*.bmp"), ("All Files", "*.*") self._imageCanvas.delete("all")
])
if not path: pil_img = self._currentImage.getImage()
return width, height = pil_img.size
self._currentImage.saveImage(path) tk_img = ImageTk.PhotoImage(pil_img)
def _undo(self) -> None: # Keep reference to avoid garbage collection
if self._currentImage is None: self._imageLabel.image = tk_img
return
self._currentImage.undo() # Place image on canvas
self._renderCurrentImage() 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:
if self._currentImage is None:
return
# Take undo snapshot
self._currentImage.snapshot()
if params is None:
# default demo params for crop
params = {"width": 200, "height": 200}
manipulation.manipulateImage(self._currentImage, params)
self._renderCurrentImage()
def _saveImage(self) -> None:
if self._currentImage is None or self._currentImage.getImage() is None:
return
path = filedialog.asksaveasfilename(defaultextension=".png", filetypes=[
("PNG", "*.png"), ("JPEG", "*.jpg;*.jpeg"), ("Bitmap", "*.bmp"), ("All Files", "*.*")
])
if not path:
return
self._currentImage.saveImage(path)
def _undo(self) -> None:
if self._currentImage is None:
return
self._currentImage.undo()
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")
messagebox.showinfo("Selection Mode", "Selection mode enabled. Click and drag to select area, then right-click for manipulation options.")
else:
self._root.config(cursor="")
self._clearSelection()
messagebox.showinfo("Selection Mode", "Selection mode disabled.")
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 rectangle
if self._selectionRectangle:
self._imageCanvas.delete(self._selectionRectangle)
# Draw new rectangle with colored edges only
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"]
)
messagebox.showinfo("Selection", "Area selected! Right-click for manipulation options.")
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
self._imageCanvas.create_rectangle(
canvas_left, canvas_top, canvas_right, canvas_bottom,
outline="red", width=3, fill=""
)

View File

@@ -1,31 +1,31 @@
from .CropImage import CropImage from .CropImage import CropImage
from .ResizeImage import ResizeImage from .ResizeImage import ResizeImage
from .RotateImage import RotateImage from .RotateImage import RotateImage
from .FlipImage import FlipImage from .FlipImage import FlipImage
from .ColorAdjust import ColorAdjust from .ColorAdjust import ColorAdjust
from .Filters import GaussianBlur, SobelEdge, BinaryThreshold, HistogramThreshold from .Filters import GaussianBlur, SobelEdge, BinaryThreshold, HistogramThreshold
from .Padding import Padding from .Padding import Padding
from .Grayscale import Grayscale from .Grayscale import Grayscale
from .HSV import HSV from .HSV import HSV
from .HueShift import HueShift from .HueShift import HueShift
from .BoxBlur import BoxBlur from .BoxBlur import BoxBlur
from .CopyImage import CopyImage from .CopyImage import CopyImage
def GetImageManipulationList() -> list: def GetImageManipulationList() -> list:
return [ return [
CropImage(), CropImage(),
ResizeImage(), ResizeImage(),
RotateImage(), RotateImage(),
FlipImage(), FlipImage(),
ColorAdjust(), ColorAdjust(),
Padding(), Padding(),
Grayscale(), Grayscale(),
HSV(), HSV(),
HueShift(), HueShift(),
BoxBlur(), BoxBlur(),
CopyImage(), CopyImage(),
GaussianBlur(), GaussianBlur(),
SobelEdge(), SobelEdge(),
BinaryThreshold(), BinaryThreshold(),
HistogramThreshold(), HistogramThreshold(),
] ]

72
src/SelectionArea.py Normal file
View File

@@ -0,0 +1,72 @@
from PIL import Image
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
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
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():
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()
result_image.paste(processed_selection, (self.left, self.top))
return result_image
return image