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
from tkinter import filedialog
from PIL import ImageTk
from ImageContainer import ImageContainer
from ImageManipulation.ManipulationList import *
from functools import partial
class GUI:
"""The GUI class responsible for the main application GUI.
@warning This class is a singleton.
"""
_instance = None
# If the GUI has been initialised.
_isInitialised = False
_currentImage = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(GUI, cls).__new__(cls)
return cls._instance
def initialise(self):
"""Initialise the GUI."""
if self._isInitialised:
return
else:
self._isInitialised = True
# Main window
root = tk.Tk()
root.title("Image Viewer")
root.geometry("800x600")
root.config(bg="white")
icon = tk.PhotoImage(file='icon.png')
root.tk.call('wm', 'iconphoto', root._w, icon)
root.minsize(800, 600)
# Menus
menu = tk.Menu(root)
root.config(menu=menu)
file_menu = tk.Menu(menu, tearoff=0)
menu.add_cascade(label="File", menu=file_menu)
file_menu.add_command(label="Open Image", command=lambda: self._openImage())
file_menu.add_command(label="Save Image", command=lambda: self._saveImage())
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)
menu.add_cascade(label="Test", menu=test_menu)
for manipulation in GetImageManipulationList():
test_menu.add_command(
label=manipulation.getManipulationName(),
command=partial(self._applyManipulation, manipulation)
)
# Manual Test menu listing all manipulations explicitly
# manual_menu = tk.Menu(menu, tearoff=0)
# menu.add_cascade(label="Test", 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()))
# manual_menu.add_command(label="Greyscale", command=partial(self._applyManipulation, Grayscale()))
# manual_menu.add_command(label="HSV", command=partial(self._applyManipulation, HSV()))
# manual_menu.add_command(label="Hue Shifted", command=partial(self._applyManipulation, HueShift(), {"hue": 50}))
# manual_menu.add_command(label="Smoothed", command=partial(self._applyManipulation, BoxBlur(), {"ksize": 15}))
# manual_menu.add_command(label="Rotated", command=partial(self._applyManipulation, RotateImage(), {"angle": 90}))
# manual_menu.add_command(label="Flip (Horizontal)", command=partial(self._applyManipulation, FlipImage(), {"mode": "horizontal"}))
# 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}))
# manual_menu.add_command(label="Sobel Edge", command=partial(self._applyManipulation, SobelEdge(), {"dx": 1, "dy": 0, "ksize": 3}))
# manual_menu.add_command(label="Binary Threshold", command=partial(self._applyManipulation, BinaryThreshold(), {"thresh": 127}))
# manual_menu.add_command(label="Histogram Threshold", command=partial(self._applyManipulation, HistogramThreshold()))
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())
# Frame to hold image
imgframe = tk.Frame(root, width=500, height=500, bg="lightgray", relief="sunken", bd=2)
imgframe.pack(side="top", pady=10)
# Label to display image
self._imageLabel = tk.Label(imgframe, width=500, height=500, bg="white")
self._imageLabel.pack(expand=True)
self._root = root
# Key bindings
root.bind_all('<Control-z>', lambda event: self._undo())
root.mainloop()
def _openImage(self) -> None:
file_path = filedialog.askopenfilename(
filetypes=[("Image files", "*.jpg *.jpeg *.png *.gif *.bmp")]
)
if not file_path:
return
self._currentImage = ImageContainer()
self._currentImage.loadImage(file_path)
self._renderCurrentImage()
def _renderCurrentImage(self) -> None:
if self._currentImage is None or self._currentImage.getImage() is None:
return
pil_img = self._currentImage.getImage()
width, height = pil_img.size
tk_img = ImageTk.PhotoImage(pil_img)
# Keep reference to avoid garbage collection
self._imageLabel.image = tk_img
self._imageLabel.config(image=tk_img, width=width, height=height)
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()
import tkinter as tk
from tkinter import filedialog, messagebox
from PIL import ImageTk
from ImageContainer import ImageContainer
from ImageManipulation.ManipulationList import *
from SelectionArea import SelectionArea
from functools import partial
class GUI:
"""The GUI class responsible for the main application GUI.
@warning This class is a singleton.
"""
_instance = None
# If the GUI has been initialised.
_isInitialised = False
_currentImage = None
# Interactive selection variables
_selectionMode = False
_selectionStartX = None
_selectionStartY = None
_selectionEndX = None
_selectionEndY = None
_selectionRectangle = None
_selectionArea = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(GUI, cls).__new__(cls)
return cls._instance
def initialise(self):
"""Initialise the GUI."""
if self._isInitialised:
return
else:
self._isInitialised = True
# Main window
root = tk.Tk()
root.title("Image Viewer")
root.geometry("800x600")
root.config(bg="white")
icon = tk.PhotoImage(file='icon.png')
root.tk.call('wm', 'iconphoto', root._w, icon)
root.minsize(800, 600)
# Menus
menu = tk.Menu(root)
root.config(menu=menu)
file_menu = tk.Menu(menu, tearoff=0)
menu.add_cascade(label="File", menu=file_menu)
file_menu.add_command(label="Open Image", command=lambda: self._openImage())
file_menu.add_command(label="Save Image", command=lambda: self._saveImage())
file_menu.add_command(label="Exit", command=root.quit)
test_menu = tk.Menu(menu, tearoff=0)
menu.add_cascade(label="Test", menu=test_menu)
for manipulation in GetImageManipulationList():
test_menu.add_command(
label=manipulation.getManipulationName(),
command=partial(self._applyManipulation, manipulation)
)
# Manual Test menu listing all manipulations explicitly
# manual_menu = tk.Menu(menu, tearoff=0)
# menu.add_cascade(label="Test", 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()))
# manual_menu.add_command(label="Greyscale", command=partial(self._applyManipulation, Grayscale()))
# manual_menu.add_command(label="HSV", command=partial(self._applyManipulation, HSV()))
# manual_menu.add_command(label="Hue Shifted", command=partial(self._applyManipulation, HueShift(), {"hue": 50}))
# manual_menu.add_command(label="Smoothed", command=partial(self._applyManipulation, BoxBlur(), {"ksize": 15}))
# manual_menu.add_command(label="Rotated", command=partial(self._applyManipulation, RotateImage(), {"angle": 90}))
# manual_menu.add_command(label="Flip (Horizontal)", command=partial(self._applyManipulation, FlipImage(), {"mode": "horizontal"}))
# 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}))
# manual_menu.add_command(label="Sobel Edge", command=partial(self._applyManipulation, SobelEdge(), {"dx": 1, "dy": 0, "ksize": 3}))
# manual_menu.add_command(label="Binary Threshold", command=partial(self._applyManipulation, BinaryThreshold(), {"thresh": 127}))
# manual_menu.add_command(label="Histogram Threshold", command=partial(self._applyManipulation, HistogramThreshold()))
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_separator()
edit_menu.add_command(label="Toggle Selection Mode", accelerator="Ctrl+S", command=lambda: self._toggleSelectionMode())
# Frame to hold image
imgframe = tk.Frame(root, width=500, height=500, bg="lightgray", relief="sunken", bd=2)
imgframe.pack(side="top", pady=10)
# Canvas to display image and draw selection
self._imageCanvas = tk.Canvas(imgframe, width=500, height=500, bg="white")
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
# Initialize selection area
self._selectionArea = SelectionArea()
# Key bindings
root.bind_all('<Control-z>', lambda event: self._undo())
root.bind_all('<Control-s>', lambda event: self._toggleSelectionMode())
root.mainloop()
def _openImage(self) -> None:
file_path = filedialog.askopenfilename(
filetypes=[("Image files", "*.jpg *.jpeg *.png *.gif *.bmp")]
)
if not file_path:
return
self._currentImage = ImageContainer()
self._currentImage.loadImage(file_path)
self._renderCurrentImage()
def _renderCurrentImage(self) -> None:
if self._currentImage is None or self._currentImage.getImage() is None:
return
# Clear canvas
self._imageCanvas.delete("all")
pil_img = self._currentImage.getImage()
width, height = pil_img.size
tk_img = ImageTk.PhotoImage(pil_img)
# Keep reference to avoid garbage collection
self._imageLabel.image = tk_img
# 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:
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 .ResizeImage import ResizeImage
from .RotateImage import RotateImage
from .FlipImage import FlipImage
from .ColorAdjust import ColorAdjust
from .Filters import GaussianBlur, SobelEdge, BinaryThreshold, HistogramThreshold
from .Padding import Padding
from .Grayscale import Grayscale
from .HSV import HSV
from .HueShift import HueShift
from .BoxBlur import BoxBlur
from .CopyImage import CopyImage
def GetImageManipulationList() -> list:
return [
CropImage(),
ResizeImage(),
RotateImage(),
FlipImage(),
ColorAdjust(),
Padding(),
Grayscale(),
HSV(),
HueShift(),
BoxBlur(),
CopyImage(),
GaussianBlur(),
SobelEdge(),
BinaryThreshold(),
HistogramThreshold(),
from .CropImage import CropImage
from .ResizeImage import ResizeImage
from .RotateImage import RotateImage
from .FlipImage import FlipImage
from .ColorAdjust import ColorAdjust
from .Filters import GaussianBlur, SobelEdge, BinaryThreshold, HistogramThreshold
from .Padding import Padding
from .Grayscale import Grayscale
from .HSV import HSV
from .HueShift import HueShift
from .BoxBlur import BoxBlur
from .CopyImage import CopyImage
def GetImageManipulationList() -> list:
return [
CropImage(),
ResizeImage(),
RotateImage(),
FlipImage(),
ColorAdjust(),
Padding(),
Grayscale(),
HSV(),
HueShift(),
BoxBlur(),
CopyImage(),
GaussianBlur(),
SobelEdge(),
BinaryThreshold(),
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