waaa
This commit is contained in:
128
src/GUI.py
128
src/GUI.py
@@ -3,12 +3,15 @@ from tkinter import filedialog, colorchooser, messagebox
|
||||
from PIL import ImageTk, ImageDraw, Image
|
||||
from ImageContainer import ImageContainer
|
||||
from ImageManipulation.ManipulationList import *
|
||||
from ImageManipulation.Filters import GaussianBlur, SobelEdge, BinaryThreshold, HistogramThreshold
|
||||
from ImageManipulation.ZoomImage import ZoomImage
|
||||
from SelectionArea import SelectionArea
|
||||
from utils.area_selection import AreaSelectionHandler
|
||||
from utils.brush import BrushHandler
|
||||
from utils.text_entry import TextEntryHandler
|
||||
from utils.file_operations import FileOperationsHandler
|
||||
from utils.image_properties import ImagePropertiesHandler
|
||||
from utils.filter_params import FilterParamsHandler
|
||||
from functools import partial
|
||||
|
||||
|
||||
@@ -39,6 +42,9 @@ class GUI:
|
||||
# Image properties handler (will be initialized in initialise())
|
||||
_imagePropertiesHandler = None
|
||||
|
||||
# Filter parameters handler (will be initialized in initialise())
|
||||
_filterParamsHandler = None
|
||||
|
||||
_canvasImageWidth = None # Store canvas image dimensions
|
||||
_canvasImageHeight = None
|
||||
|
||||
@@ -105,18 +111,25 @@ class GUI:
|
||||
select_menu = tk.Menu(image_menu, tearoff=0)
|
||||
image_menu.add_cascade(label="Select", menu=select_menu)
|
||||
select_menu.add_command(label="Rectangular Selection", command=lambda: self._setRectangularSelection())
|
||||
select_menu.add_command(label="Free-form Selection (Lasso)", command=lambda: self._setLassoSelection())
|
||||
select_menu.add_command(label="Circular Selection", command=lambda: self._setCircularSelection())
|
||||
select_menu.add_command(label="Free-form Selection (Lasso)", command=lambda: self._setLassoSelection())
|
||||
select_menu.add_command(label="Polygon Selection", command=lambda: self._setPolygonSelection())
|
||||
|
||||
image_menu.add_separator()
|
||||
image_menu.add_command(label="Crop", command=lambda: self._cropImage())
|
||||
image_menu.add_command(label="Resize", command=lambda: self._resizeImage())
|
||||
filter_menu = tk.Menu(menu, tearoff=0)
|
||||
menu.add_cascade(label="Filter", menu=filter_menu)
|
||||
filter_menu.add_command(label="Gaussian Filter", command=lambda: self._applyGaussianFilter())
|
||||
filter_menu.add_command(label="Sobel Filter", command=lambda: self._applySobelFilter())
|
||||
filter_menu.add_command(label="Binary Filter", command=lambda: self._applyBinaryFilter())
|
||||
filter_menu.add_command(label="Histogram Thresholding", command=lambda: self._applyHistogramThreshold())
|
||||
clipboard_menu = tk.Menu(menu, tearoff=0)
|
||||
menu.add_cascade(label="Clipboard", menu=clipboard_menu)
|
||||
tools_menu = tk.Menu(menu, tearoff=0)
|
||||
menu.add_cascade(label="Tools", menu=tools_menu)
|
||||
tools_menu.add_command(label="Zoom In", accelerator="Ctrl+Plus", command=lambda: self._zoomIn())
|
||||
tools_menu.add_command(label="Zoom Out", accelerator="Ctrl+Minus", command=lambda: self._zoomOut())
|
||||
shapes_menu = tk.Menu(menu, tearoff=0)
|
||||
menu.add_cascade(label="Shapes", menu=shapes_menu)
|
||||
colors_menu = tk.Menu(menu, tearoff=0)
|
||||
@@ -143,6 +156,7 @@ class GUI:
|
||||
self._imageCanvas.bind("<Button-1>", self._onMouseClick)
|
||||
self._imageCanvas.bind("<B1-Motion>", self._onMouseDrag)
|
||||
self._imageCanvas.bind("<ButtonRelease-1>", self._onMouseRelease)
|
||||
self._imageCanvas.bind("<Double-Button-1>", self._onDoubleClick) # Double-click for polygon completion
|
||||
self._imageCanvas.bind("<Button-3>", self._onRightClick) # Right-click for context menu
|
||||
|
||||
# Settings panel for brush controls
|
||||
@@ -207,6 +221,9 @@ class GUI:
|
||||
self._imagePropertiesHandler = ImagePropertiesHandler(
|
||||
get_current_image=get_current_image
|
||||
)
|
||||
|
||||
# Filter parameters handler from utils/filter_params.py
|
||||
self._filterParamsHandler = FilterParamsHandler(root)
|
||||
|
||||
# Key bindings
|
||||
root.bind_all('<Control-z>', lambda event: self._undo())
|
||||
@@ -219,6 +236,9 @@ class GUI:
|
||||
root.bind_all('<Control-Shift-S>', lambda event: self._toggleSelectionMode())
|
||||
root.bind_all('<Control-Shift-C>', lambda event: self._toggleSelectionShape())
|
||||
root.bind_all('<Control-b>', lambda event: self._toggleBrushMode() if self._brushHandler else None)
|
||||
root.bind_all('<Control-plus>', lambda event: self._zoomIn())
|
||||
root.bind_all('<Control-equal>', lambda event: self._zoomIn()) # Plus key without shift
|
||||
root.bind_all('<Control-minus>', lambda event: self._zoomOut())
|
||||
|
||||
root.mainloop()
|
||||
|
||||
@@ -332,6 +352,16 @@ class GUI:
|
||||
if self._areaSelectionHandler:
|
||||
self._areaSelectionHandler.set_lasso_selection()
|
||||
|
||||
def _setPolygonSelection(self) -> None:
|
||||
"""Set selection to polygon mode."""
|
||||
if self._areaSelectionHandler:
|
||||
self._areaSelectionHandler.set_polygon_selection()
|
||||
|
||||
def _onDoubleClick(self, event) -> None:
|
||||
"""Handle double-click for polygon completion."""
|
||||
if self._areaSelectionHandler:
|
||||
self._areaSelectionHandler.on_double_click(event)
|
||||
|
||||
def _cropImage(self) -> None:
|
||||
"""Crop the image to the selected rectangular area. Starts selection mode if no selection exists."""
|
||||
if not self._areaSelectionHandler:
|
||||
@@ -440,6 +470,102 @@ class GUI:
|
||||
"""Display image properties in a dialog."""
|
||||
if self._imagePropertiesHandler:
|
||||
self._imagePropertiesHandler.show_properties()
|
||||
|
||||
def _applyGaussianFilter(self) -> None:
|
||||
"""Apply Gaussian blur filter to the image."""
|
||||
if self._currentImage is None:
|
||||
messagebox.showwarning("No Image", "Please load an image first.")
|
||||
return
|
||||
|
||||
# Get default parameters
|
||||
default_params = self._getDefaultParams(GaussianBlur())
|
||||
default_ksize = default_params.get("ksize", 5)
|
||||
|
||||
# Show parameter dialog
|
||||
if self._filterParamsHandler:
|
||||
params = self._filterParamsHandler.show_gaussian_filter_dialog(default_ksize)
|
||||
if params is None: # User cancelled
|
||||
return
|
||||
else:
|
||||
params = default_params
|
||||
|
||||
manipulation = GaussianBlur()
|
||||
self._applyManipulation(manipulation, params)
|
||||
|
||||
def _applySobelFilter(self) -> None:
|
||||
"""Apply Sobel edge detection filter to the image."""
|
||||
if self._currentImage is None:
|
||||
messagebox.showwarning("No Image", "Please load an image first.")
|
||||
return
|
||||
|
||||
# Get default parameters
|
||||
default_params = self._getDefaultParams(SobelEdge())
|
||||
default_dx = default_params.get("dx", 1)
|
||||
default_dy = default_params.get("dy", 0)
|
||||
default_ksize = default_params.get("ksize", 3)
|
||||
|
||||
# Show parameter dialog
|
||||
if self._filterParamsHandler:
|
||||
params = self._filterParamsHandler.show_sobel_filter_dialog(default_dx, default_dy, default_ksize)
|
||||
if params is None: # User cancelled
|
||||
return
|
||||
else:
|
||||
params = default_params
|
||||
|
||||
manipulation = SobelEdge()
|
||||
self._applyManipulation(manipulation, params)
|
||||
|
||||
def _applyBinaryFilter(self) -> None:
|
||||
"""Apply binary threshold filter to the image."""
|
||||
if self._currentImage is None:
|
||||
messagebox.showwarning("No Image", "Please load an image first.")
|
||||
return
|
||||
|
||||
# Get default parameters
|
||||
default_params = self._getDefaultParams(BinaryThreshold())
|
||||
default_thresh = default_params.get("thresh", 127)
|
||||
|
||||
# Show parameter dialog
|
||||
if self._filterParamsHandler:
|
||||
params = self._filterParamsHandler.show_binary_filter_dialog(default_thresh)
|
||||
if params is None: # User cancelled
|
||||
return
|
||||
else:
|
||||
params = default_params
|
||||
|
||||
manipulation = BinaryThreshold()
|
||||
self._applyManipulation(manipulation, params)
|
||||
|
||||
def _applyHistogramThreshold(self) -> None:
|
||||
"""Apply histogram-based thresholding (Otsu's method) to the image."""
|
||||
if self._currentImage is None:
|
||||
messagebox.showwarning("No Image", "Please load an image first.")
|
||||
return
|
||||
|
||||
# Histogram thresholding uses Otsu's method which doesn't need parameters
|
||||
manipulation = HistogramThreshold()
|
||||
params = {} # No parameters needed
|
||||
self._applyManipulation(manipulation, params)
|
||||
|
||||
def _zoomIn(self) -> None:
|
||||
"""Zoom in the image by a factor of 1.2."""
|
||||
if self._currentImage is None:
|
||||
messagebox.showwarning("No Image", "Please load an image first.")
|
||||
return
|
||||
|
||||
manipulation = ZoomImage()
|
||||
params = {"scale": 1.2}
|
||||
self._applyManipulation(manipulation, params)
|
||||
|
||||
def _zoomOut(self) -> None:
|
||||
"""Zoom out the image by a factor of 0.8."""
|
||||
if self._currentImage is None:
|
||||
messagebox.showwarning("No Image", "Please load an image first.")
|
||||
return
|
||||
|
||||
manipulation = ZoomImage()
|
||||
params = {"scale": 0.8}
|
||||
self._applyManipulation(manipulation, params)
|
||||
|
||||
def _openCamera(self):
|
||||
camera = CameraWindow()
|
||||
|
||||
@@ -13,6 +13,7 @@ from .CopyImage import CopyImage
|
||||
from .BackgroundBlur import BackgroundBlur
|
||||
from .CameraWindow import CameraWindow
|
||||
from .AddText import AddText
|
||||
from .ZoomImage import ZoomImage
|
||||
|
||||
|
||||
def GetImageManipulationList() -> list:
|
||||
|
||||
38
src/ImageManipulation/ZoomImage.py
Normal file
38
src/ImageManipulation/ZoomImage.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from .ImageManipulation import ImageManipulation
|
||||
from ImageContainer import ImageContainer
|
||||
from typing import Any, List
|
||||
from utils.image_utils import pil_to_cv2, cv2_to_pil
|
||||
import cv2
|
||||
|
||||
|
||||
class ZoomImage(ImageManipulation):
|
||||
def getManipulationName(self) -> str:
|
||||
return "Zoom"
|
||||
|
||||
def getParameters(self) -> List[str]:
|
||||
return ["scale"]
|
||||
|
||||
def manipulateImage(self, image: ImageContainer, parameters: Any) -> None:
|
||||
if image is None or image.getImage() is None:
|
||||
return
|
||||
pil_img = image.getImage()
|
||||
scale = 1.0
|
||||
if isinstance(parameters, dict) and parameters.get("scale") is not None:
|
||||
scale = float(parameters.get("scale"))
|
||||
|
||||
# Get current dimensions
|
||||
width, height = pil_img.size
|
||||
|
||||
# Calculate new dimensions
|
||||
new_width = int(width * scale)
|
||||
new_height = int(height * scale)
|
||||
|
||||
# Ensure minimum size of 1 pixel
|
||||
new_width = max(1, new_width)
|
||||
new_height = max(1, new_height)
|
||||
|
||||
# Convert to cv2 and resize
|
||||
cv_img = pil_to_cv2(pil_img)
|
||||
resized = cv2.resize(cv_img, (new_width, new_height), interpolation=cv2.INTER_LINEAR)
|
||||
image._imageData = cv2_to_pil(resized)
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Dict, Optional, Tuple, List
|
||||
|
||||
|
||||
class SelectionArea:
|
||||
"""Class to handle selected areas on images (rectangle, circle, or lasso)."""
|
||||
"""Class to handle selected areas on images (rectangle, circle, lasso, or polygon)."""
|
||||
|
||||
def __init__(self):
|
||||
self.left: Optional[int] = None
|
||||
@@ -11,8 +11,9 @@ class SelectionArea:
|
||||
self.right: Optional[int] = None
|
||||
self.bottom: Optional[int] = None
|
||||
self.is_active: bool = False
|
||||
self.shape: str = "rectangle" # "rectangle", "circle", or "lasso"
|
||||
self.shape: str = "rectangle" # "rectangle", "circle", "lasso", or "polygon"
|
||||
self.lasso_path: List[Tuple[int, int]] = [] # List of (x, y) points for lasso selection
|
||||
self.polygon_path: List[Tuple[int, int]] = [] # List of (x, y) points for polygon selection
|
||||
|
||||
def set_coordinates(self, left: int, top: int, right: int, bottom: int) -> None:
|
||||
"""Set the selection coordinates."""
|
||||
@@ -30,11 +31,12 @@ class SelectionArea:
|
||||
self.bottom = None
|
||||
self.is_active = False
|
||||
self.lasso_path = []
|
||||
self.polygon_path = []
|
||||
self.shape = self.shape # keep last used shape
|
||||
|
||||
def set_shape(self, shape: str) -> None:
|
||||
"""Set the selection shape: 'rectangle', 'circle', or 'lasso'."""
|
||||
if shape in ("rectangle", "circle", "lasso"):
|
||||
"""Set the selection shape: 'rectangle', 'circle', 'lasso', or 'polygon'."""
|
||||
if shape in ("rectangle", "circle", "lasso", "polygon"):
|
||||
self.shape = shape
|
||||
|
||||
def set_lasso_path(self, path: List[Tuple[int, int]]) -> None:
|
||||
@@ -73,10 +75,30 @@ class SelectionArea:
|
||||
}
|
||||
return None
|
||||
|
||||
def set_polygon_path(self, path: List[Tuple[int, int]]) -> None:
|
||||
"""Set the polygon selection path."""
|
||||
if not path:
|
||||
return
|
||||
|
||||
self.polygon_path = path
|
||||
self.shape = "polygon"
|
||||
self.is_active = True
|
||||
|
||||
# Calculate bounding box for polygon
|
||||
if path:
|
||||
xs = [p[0] for p in path]
|
||||
ys = [p[1] for p in path]
|
||||
self.left = min(xs)
|
||||
self.top = min(ys)
|
||||
self.right = max(xs)
|
||||
self.bottom = max(ys)
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
"""Check if the selection is valid (has area > 0)."""
|
||||
if self.shape == "lasso":
|
||||
return len(self.lasso_path) >= 3 # Need at least 3 points for a polygon
|
||||
if self.shape == "polygon":
|
||||
return len(self.polygon_path) >= 3 # Need at least 3 points for a polygon
|
||||
coords = self.get_coordinates()
|
||||
if coords:
|
||||
left, top, right, bottom = coords
|
||||
@@ -88,8 +110,8 @@ class SelectionArea:
|
||||
if not self.is_valid():
|
||||
return None
|
||||
|
||||
if self.shape == "lasso":
|
||||
# For lasso, crop to bounding box and apply mask
|
||||
if self.shape == "lasso" or self.shape == "polygon":
|
||||
# For lasso and polygon, crop to bounding box and apply mask
|
||||
coords = self.get_coordinates()
|
||||
if not coords:
|
||||
return None
|
||||
@@ -98,14 +120,17 @@ class SelectionArea:
|
||||
# Crop to bounding box
|
||||
cropped = image.crop(coords)
|
||||
|
||||
# Create mask for lasso path
|
||||
# Create mask for path
|
||||
width = right - left
|
||||
height = bottom - top
|
||||
mask = Image.new("L", (width, height), 0)
|
||||
draw = ImageDraw.Draw(mask)
|
||||
|
||||
# Adjust lasso path to be relative to bounding box
|
||||
adjusted_path = [(x - left, y - top) for x, y in self.lasso_path]
|
||||
# Use appropriate path (lasso or polygon)
|
||||
path = self.lasso_path if self.shape == "lasso" else self.polygon_path
|
||||
|
||||
# Adjust path to be relative to bounding box
|
||||
adjusted_path = [(x - left, y - top) for x, y in path]
|
||||
|
||||
# Draw filled polygon
|
||||
if len(adjusted_path) >= 3:
|
||||
@@ -142,12 +167,14 @@ class SelectionArea:
|
||||
draw = ImageDraw.Draw(mask)
|
||||
draw.ellipse((0, 0, width, height), fill=255)
|
||||
result_image.paste(processed_selection, (self.left, self.top), mask)
|
||||
elif self.shape == "lasso":
|
||||
# Create lasso mask for blending only inside the lasso path
|
||||
elif self.shape == "lasso" or self.shape == "polygon":
|
||||
# Create mask for blending only inside the path
|
||||
mask = Image.new("L", (width, height), 0)
|
||||
draw = ImageDraw.Draw(mask)
|
||||
# Adjust lasso path to be relative to bounding box
|
||||
adjusted_path = [(x - self.left, y - self.top) for x, y in self.lasso_path]
|
||||
# Use appropriate path (lasso or polygon)
|
||||
path = self.lasso_path if self.shape == "lasso" else self.polygon_path
|
||||
# Adjust path to be relative to bounding box
|
||||
adjusted_path = [(x - self.left, y - self.top) for x, y in path]
|
||||
if len(adjusted_path) >= 3:
|
||||
draw.polygon(adjusted_path, fill=255)
|
||||
result_image.paste(processed_selection, (self.left, self.top), mask)
|
||||
|
||||
@@ -49,7 +49,7 @@ class AreaSelectionHandler:
|
||||
|
||||
# Selection state
|
||||
self._selectionMode = False
|
||||
self._selectionType = "rectangle" # "rectangle", "circle", or "lasso"
|
||||
self._selectionType = "rectangle" # "rectangle", "circle", "lasso", or "polygon"
|
||||
self._selectionStartX = None
|
||||
self._selectionStartY = None
|
||||
self._selectionEndX = None
|
||||
@@ -57,6 +57,9 @@ class AreaSelectionHandler:
|
||||
self._selectionRectangle = None
|
||||
self._lassoPath = [] # List of canvas coordinates for lasso drawing
|
||||
self._lassoLine = None # Canvas line item for lasso path
|
||||
self._polygonPath = [] # List of canvas coordinates for polygon points
|
||||
self._polygonLines = [] # List of canvas line items for polygon drawing
|
||||
self._polygonPoints = [] # List of canvas point markers for polygon vertices
|
||||
|
||||
# Pending operation callback (called when selection is completed)
|
||||
self._pendingOperationCallback = None
|
||||
@@ -120,6 +123,72 @@ class AreaSelectionHandler:
|
||||
"""Set selection to lasso (free-form) mode and enable selection."""
|
||||
self.set_selection_shape("lasso")
|
||||
|
||||
def set_polygon_selection(self) -> None:
|
||||
"""Set selection to polygon mode and enable selection."""
|
||||
self.set_selection_shape("polygon")
|
||||
|
||||
def _complete_polygon_selection(self) -> None:
|
||||
"""Complete the polygon selection by converting canvas coordinates to image coordinates."""
|
||||
if not self._polygonPath or len(self._polygonPath) < 3:
|
||||
return
|
||||
|
||||
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 = 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._polygonPath]
|
||||
|
||||
# Set the polygon path in selection area
|
||||
self._selectionArea.set_polygon_path(image_path)
|
||||
|
||||
# Clear polygon drawing elements
|
||||
for line in self._polygonLines:
|
||||
self._canvas.delete(line)
|
||||
for point in self._polygonPoints:
|
||||
self._canvas.delete(point)
|
||||
self._polygonLines = []
|
||||
self._polygonPoints = []
|
||||
|
||||
# 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()
|
||||
|
||||
def on_double_click(self, event) -> bool:
|
||||
"""Handle double-click to complete polygon selection.
|
||||
|
||||
Returns:
|
||||
True if the double-click was handled, False otherwise
|
||||
"""
|
||||
if not self._selectionMode or self._selectionType != "polygon":
|
||||
return False
|
||||
|
||||
if len(self._polygonPath) >= 3:
|
||||
self._complete_polygon_selection()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def set_pending_operation_callback(self, callback: Optional[Callable]) -> None:
|
||||
"""Set a callback to be executed when a selection is completed.
|
||||
|
||||
@@ -153,6 +222,45 @@ class AreaSelectionHandler:
|
||||
self._lassoPath.append((canvas_x, canvas_y))
|
||||
return True
|
||||
|
||||
# Handle polygon mode
|
||||
if self._selectionType == "polygon":
|
||||
# If this is the first point, clear any existing polygon
|
||||
if not self._polygonPath:
|
||||
# Clear any existing selection
|
||||
if self._selectionArea.is_active:
|
||||
self._selectionArea.clear()
|
||||
# Clear any existing polygon drawing elements
|
||||
for line in self._polygonLines:
|
||||
self._canvas.delete(line)
|
||||
for point in self._polygonPoints:
|
||||
self._canvas.delete(point)
|
||||
self._polygonLines = []
|
||||
self._polygonPoints = []
|
||||
|
||||
canvas_x = self._canvas.canvasx(event.x)
|
||||
canvas_y = self._canvas.canvasy(event.y)
|
||||
|
||||
# Add point to polygon
|
||||
self._polygonPath.append((canvas_x, canvas_y))
|
||||
|
||||
# Draw point marker
|
||||
point_marker = self._canvas.create_oval(
|
||||
canvas_x - 3, canvas_y - 3, canvas_x + 3, canvas_y + 3,
|
||||
fill="red", outline="red", width=2
|
||||
)
|
||||
self._polygonPoints.append(point_marker)
|
||||
|
||||
# Draw lines connecting points
|
||||
if len(self._polygonPath) > 1:
|
||||
prev_point = self._polygonPath[-2]
|
||||
line = self._canvas.create_line(
|
||||
prev_point[0], prev_point[1], canvas_x, canvas_y,
|
||||
fill="red", width=2
|
||||
)
|
||||
self._polygonLines.append(line)
|
||||
|
||||
return True
|
||||
|
||||
# Handle rectangle/circle mode
|
||||
# Clear any existing selection
|
||||
self.clear_selection()
|
||||
@@ -262,6 +370,10 @@ class AreaSelectionHandler:
|
||||
callback()
|
||||
return True
|
||||
|
||||
# Handle polygon mode - complete polygon on double-click
|
||||
# (Polygon completion happens on double-click, not on mouse release)
|
||||
return True
|
||||
|
||||
# Handle rectangle/circle mode
|
||||
if self._selectionStartX is None:
|
||||
return False
|
||||
@@ -544,7 +656,14 @@ class AreaSelectionHandler:
|
||||
if self._lassoLine:
|
||||
self._canvas.delete(self._lassoLine)
|
||||
self._lassoLine = None
|
||||
for line in self._polygonLines:
|
||||
self._canvas.delete(line)
|
||||
for point in self._polygonPoints:
|
||||
self._canvas.delete(point)
|
||||
self._lassoPath = []
|
||||
self._polygonPath = []
|
||||
self._polygonLines = []
|
||||
self._polygonPoints = []
|
||||
self._selectionStartX = None
|
||||
self._selectionStartY = None
|
||||
self._selectionEndX = None
|
||||
@@ -599,6 +718,16 @@ class AreaSelectionHandler:
|
||||
*[coord for point in closed_path for coord in point],
|
||||
fill="red", width=3, smooth=False
|
||||
)
|
||||
elif self._selectionArea.shape == "polygon" and self._selectionArea.polygon_path:
|
||||
# Convert polygon path from image coordinates to canvas coordinates
|
||||
canvas_path = [(int(x * scale_x), int(y * scale_y)) for x, y in self._selectionArea.polygon_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,
|
||||
|
||||
272
src/utils/filter_params.py
Normal file
272
src/utils/filter_params.py
Normal file
@@ -0,0 +1,272 @@
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox
|
||||
from typing import Optional, Dict
|
||||
|
||||
|
||||
class FilterParamsHandler:
|
||||
"""Handler for filter parameter input dialogs in the GUI.
|
||||
|
||||
This class encapsulates dialogs for configuring filter parameters
|
||||
before applying filters to images.
|
||||
"""
|
||||
|
||||
def __init__(self, root_window: tk.Tk):
|
||||
"""Initialize the filter parameters handler.
|
||||
|
||||
Args:
|
||||
root_window: The root tkinter window for creating dialogs
|
||||
"""
|
||||
self._root = root_window
|
||||
|
||||
def show_gaussian_filter_dialog(self, default_ksize: int = 5) -> Optional[Dict[str, int]]:
|
||||
"""Show a dialog window for Gaussian filter parameters.
|
||||
|
||||
Args:
|
||||
default_ksize: Default kernel size value
|
||||
|
||||
Returns:
|
||||
A dictionary with filter parameters if user confirmed, None if cancelled.
|
||||
Dictionary contains: ksize
|
||||
"""
|
||||
dialog = tk.Toplevel(self._root)
|
||||
dialog.title("Gaussian Filter Parameters")
|
||||
dialog.geometry("350x150")
|
||||
dialog.resizable(False, False)
|
||||
dialog.transient(self._root)
|
||||
dialog.grab_set() # Make dialog modal
|
||||
|
||||
# Center the dialog
|
||||
dialog.update_idletasks()
|
||||
x = (dialog.winfo_screenwidth() // 2) - (dialog.winfo_width() // 2)
|
||||
y = (dialog.winfo_screenheight() // 2) - (dialog.winfo_height() // 2)
|
||||
dialog.geometry(f"+{x}+{y}")
|
||||
|
||||
result = {"ksize": default_ksize}
|
||||
dialog_closed = [False]
|
||||
|
||||
# Kernel size input
|
||||
ksize_frame = tk.Frame(dialog)
|
||||
ksize_frame.pack(pady=20, padx=20, fill="x")
|
||||
|
||||
tk.Label(ksize_frame, text="Kernel Size (must be odd):", font=("Arial", 10)).pack(anchor="w", pady=5)
|
||||
ksize_entry = tk.Entry(ksize_frame, width=15, font=("Arial", 10))
|
||||
ksize_entry.pack(anchor="w", pady=5)
|
||||
ksize_entry.insert(0, str(default_ksize))
|
||||
ksize_entry.focus()
|
||||
|
||||
tk.Label(ksize_frame, text="Note: Kernel size will be adjusted to nearest odd number if even.",
|
||||
font=("Arial", 8), fg="gray").pack(anchor="w", pady=2)
|
||||
|
||||
# Buttons
|
||||
button_frame = tk.Frame(dialog)
|
||||
button_frame.pack(pady=20)
|
||||
|
||||
def on_ok():
|
||||
try:
|
||||
ksize = int(ksize_entry.get() or str(default_ksize))
|
||||
if ksize < 1:
|
||||
messagebox.showerror("Invalid Input", "Kernel size must be at least 1.")
|
||||
return
|
||||
result["ksize"] = ksize
|
||||
dialog_closed[0] = True
|
||||
dialog.destroy()
|
||||
except ValueError:
|
||||
messagebox.showerror("Invalid Input", "Please enter a valid integer for kernel size.")
|
||||
|
||||
def on_cancel():
|
||||
dialog_closed[0] = True
|
||||
dialog.destroy()
|
||||
|
||||
tk.Button(button_frame, text="OK", command=on_ok, width=10).pack(side="left", padx=5)
|
||||
tk.Button(button_frame, text="Cancel", command=on_cancel, width=10).pack(side="left", padx=5)
|
||||
|
||||
dialog.bind("<Return>", lambda e: on_ok())
|
||||
dialog.bind("<Escape>", lambda e: on_cancel())
|
||||
|
||||
# Wait for dialog to close
|
||||
dialog.wait_window()
|
||||
|
||||
# Return None if cancelled, otherwise return result
|
||||
if not dialog_closed[0]:
|
||||
return None
|
||||
return result
|
||||
|
||||
def show_sobel_filter_dialog(self, default_dx: int = 1, default_dy: int = 0, default_ksize: int = 3) -> Optional[Dict[str, int]]:
|
||||
"""Show a dialog window for Sobel filter parameters.
|
||||
|
||||
Args:
|
||||
default_dx: Default dx value (horizontal derivative)
|
||||
default_dy: Default dy value (vertical derivative)
|
||||
default_ksize: Default kernel size value
|
||||
|
||||
Returns:
|
||||
A dictionary with filter parameters if user confirmed, None if cancelled.
|
||||
Dictionary contains: dx, dy, ksize
|
||||
"""
|
||||
dialog = tk.Toplevel(self._root)
|
||||
dialog.title("Sobel Filter Parameters")
|
||||
dialog.geometry("350x220")
|
||||
dialog.resizable(False, False)
|
||||
dialog.transient(self._root)
|
||||
dialog.grab_set() # Make dialog modal
|
||||
|
||||
# Center the dialog
|
||||
dialog.update_idletasks()
|
||||
x = (dialog.winfo_screenwidth() // 2) - (dialog.winfo_width() // 2)
|
||||
y = (dialog.winfo_screenheight() // 2) - (dialog.winfo_height() // 2)
|
||||
dialog.geometry(f"+{x}+{y}")
|
||||
|
||||
result = {"dx": default_dx, "dy": default_dy, "ksize": default_ksize}
|
||||
dialog_closed = [False]
|
||||
|
||||
# Parameters frame
|
||||
params_frame = tk.Frame(dialog)
|
||||
params_frame.pack(pady=20, padx=20, fill="x")
|
||||
|
||||
# DX (horizontal derivative)
|
||||
dx_frame = tk.Frame(params_frame)
|
||||
dx_frame.pack(fill="x", pady=5)
|
||||
tk.Label(dx_frame, text="Horizontal Derivative (dx):", width=25, anchor="w").pack(side="left")
|
||||
dx_entry = tk.Entry(dx_frame, width=10)
|
||||
dx_entry.pack(side="left", padx=5)
|
||||
dx_entry.insert(0, str(default_dx))
|
||||
|
||||
# DY (vertical derivative)
|
||||
dy_frame = tk.Frame(params_frame)
|
||||
dy_frame.pack(fill="x", pady=5)
|
||||
tk.Label(dy_frame, text="Vertical Derivative (dy):", width=25, anchor="w").pack(side="left")
|
||||
dy_entry = tk.Entry(dy_frame, width=10)
|
||||
dy_entry.pack(side="left", padx=5)
|
||||
dy_entry.insert(0, str(default_dy))
|
||||
|
||||
# Kernel size
|
||||
ksize_frame = tk.Frame(params_frame)
|
||||
ksize_frame.pack(fill="x", pady=5)
|
||||
tk.Label(ksize_frame, text="Kernel Size (ksize):", width=25, anchor="w").pack(side="left")
|
||||
ksize_entry = tk.Entry(ksize_frame, width=10)
|
||||
ksize_entry.pack(side="left", padx=5)
|
||||
ksize_entry.insert(0, str(default_ksize))
|
||||
|
||||
tk.Label(params_frame, text="Note: dx and dy should be 0 or 1. ksize should be 1, 3, 5, or 7.",
|
||||
font=("Arial", 8), fg="gray").pack(anchor="w", pady=5)
|
||||
|
||||
# Buttons
|
||||
button_frame = tk.Frame(dialog)
|
||||
button_frame.pack(pady=20)
|
||||
|
||||
def on_ok():
|
||||
try:
|
||||
dx = int(dx_entry.get() or str(default_dx))
|
||||
dy = int(dy_entry.get() or str(default_dy))
|
||||
ksize = int(ksize_entry.get() or str(default_ksize))
|
||||
|
||||
if dx not in [0, 1] or dy not in [0, 1]:
|
||||
messagebox.showerror("Invalid Input", "dx and dy must be 0 or 1.")
|
||||
return
|
||||
if dx == 0 and dy == 0:
|
||||
messagebox.showerror("Invalid Input", "At least one of dx or dy must be 1.")
|
||||
return
|
||||
if ksize not in [1, 3, 5, 7]:
|
||||
messagebox.showerror("Invalid Input", "Kernel size must be 1, 3, 5, or 7.")
|
||||
return
|
||||
|
||||
result["dx"] = dx
|
||||
result["dy"] = dy
|
||||
result["ksize"] = ksize
|
||||
dialog_closed[0] = True
|
||||
dialog.destroy()
|
||||
except ValueError:
|
||||
messagebox.showerror("Invalid Input", "Please enter valid integers for all parameters.")
|
||||
|
||||
def on_cancel():
|
||||
dialog_closed[0] = True
|
||||
dialog.destroy()
|
||||
|
||||
tk.Button(button_frame, text="OK", command=on_ok, width=10).pack(side="left", padx=5)
|
||||
tk.Button(button_frame, text="Cancel", command=on_cancel, width=10).pack(side="left", padx=5)
|
||||
|
||||
dialog.bind("<Return>", lambda e: on_ok())
|
||||
dialog.bind("<Escape>", lambda e: on_cancel())
|
||||
|
||||
# Wait for dialog to close
|
||||
dialog.wait_window()
|
||||
|
||||
# Return None if cancelled, otherwise return result
|
||||
if not dialog_closed[0]:
|
||||
return None
|
||||
return result
|
||||
|
||||
def show_binary_filter_dialog(self, default_thresh: int = 127) -> Optional[Dict[str, int]]:
|
||||
"""Show a dialog window for Binary filter parameters.
|
||||
|
||||
Args:
|
||||
default_thresh: Default threshold value
|
||||
|
||||
Returns:
|
||||
A dictionary with filter parameters if user confirmed, None if cancelled.
|
||||
Dictionary contains: thresh
|
||||
"""
|
||||
dialog = tk.Toplevel(self._root)
|
||||
dialog.title("Binary Filter Parameters")
|
||||
dialog.geometry("350x150")
|
||||
dialog.resizable(False, False)
|
||||
dialog.transient(self._root)
|
||||
dialog.grab_set() # Make dialog modal
|
||||
|
||||
# Center the dialog
|
||||
dialog.update_idletasks()
|
||||
x = (dialog.winfo_screenwidth() // 2) - (dialog.winfo_width() // 2)
|
||||
y = (dialog.winfo_screenheight() // 2) - (dialog.winfo_height() // 2)
|
||||
dialog.geometry(f"+{x}+{y}")
|
||||
|
||||
result = {"thresh": default_thresh}
|
||||
dialog_closed = [False]
|
||||
|
||||
# Threshold input
|
||||
thresh_frame = tk.Frame(dialog)
|
||||
thresh_frame.pack(pady=20, padx=20, fill="x")
|
||||
|
||||
tk.Label(thresh_frame, text="Threshold Value (0-255):", font=("Arial", 10)).pack(anchor="w", pady=5)
|
||||
thresh_entry = tk.Entry(thresh_frame, width=15, font=("Arial", 10))
|
||||
thresh_entry.pack(anchor="w", pady=5)
|
||||
thresh_entry.insert(0, str(default_thresh))
|
||||
thresh_entry.focus()
|
||||
|
||||
tk.Label(thresh_frame, text="Pixels above threshold become white (255), below become black (0).",
|
||||
font=("Arial", 8), fg="gray").pack(anchor="w", pady=2)
|
||||
|
||||
# Buttons
|
||||
button_frame = tk.Frame(dialog)
|
||||
button_frame.pack(pady=20)
|
||||
|
||||
def on_ok():
|
||||
try:
|
||||
thresh = int(thresh_entry.get() or str(default_thresh))
|
||||
if thresh < 0 or thresh > 255:
|
||||
messagebox.showerror("Invalid Input", "Threshold must be between 0 and 255.")
|
||||
return
|
||||
result["thresh"] = thresh
|
||||
dialog_closed[0] = True
|
||||
dialog.destroy()
|
||||
except ValueError:
|
||||
messagebox.showerror("Invalid Input", "Please enter a valid integer for threshold (0-255).")
|
||||
|
||||
def on_cancel():
|
||||
dialog_closed[0] = True
|
||||
dialog.destroy()
|
||||
|
||||
tk.Button(button_frame, text="OK", command=on_ok, width=10).pack(side="left", padx=5)
|
||||
tk.Button(button_frame, text="Cancel", command=on_cancel, width=10).pack(side="left", padx=5)
|
||||
|
||||
dialog.bind("<Return>", lambda e: on_ok())
|
||||
dialog.bind("<Escape>", lambda e: on_cancel())
|
||||
|
||||
# Wait for dialog to close
|
||||
dialog.wait_window()
|
||||
|
||||
# Return None if cancelled, otherwise return result
|
||||
if not dialog_closed[0]:
|
||||
return None
|
||||
return result
|
||||
|
||||
|
||||
Reference in New Issue
Block a user