This commit is contained in:
vb
2025-11-07 13:24:30 +01:00
parent 5aba41a4f9
commit 0a83e847ca
7 changed files with 614 additions and 21 deletions

View File

@@ -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()

View File

@@ -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:

View 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)

View File

@@ -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)

View File

@@ -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
View 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