diff --git a/README.md b/README.md index ea72ef0..7086713 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,12 @@ Clipboard menu - [ ] Cut Image menu -Select -- [x] Rectangular selection -- [x] Free-form selection (Lasso) -- [ ] Polygon selection -- [x] Crop -- [x] Resize +- Select + - [x] Rectangular selection + - [x] Free-form selection (Lasso) + - [ ] Polygon selection + - [x] Crop + - [x] Resize - [ ] Rotate right 90 degrees - [ ] Rotate Left 90 degrees - [ ] Flip vertical diff --git a/src/GUI.py b/src/GUI.py index c4c9653..052b5ef 100644 --- a/src/GUI.py +++ b/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("", self._onMouseClick) self._imageCanvas.bind("", self._onMouseDrag) self._imageCanvas.bind("", self._onMouseRelease) + self._imageCanvas.bind("", self._onDoubleClick) # Double-click for polygon completion self._imageCanvas.bind("", 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('', lambda event: self._undo()) @@ -219,6 +236,9 @@ class GUI: root.bind_all('', lambda event: self._toggleSelectionMode()) root.bind_all('', lambda event: self._toggleSelectionShape()) root.bind_all('', lambda event: self._toggleBrushMode() if self._brushHandler else None) + root.bind_all('', lambda event: self._zoomIn()) + root.bind_all('', lambda event: self._zoomIn()) # Plus key without shift + root.bind_all('', 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() diff --git a/src/ImageManipulation/ManipulationList.py b/src/ImageManipulation/ManipulationList.py index be40e74..92bd74d 100644 --- a/src/ImageManipulation/ManipulationList.py +++ b/src/ImageManipulation/ManipulationList.py @@ -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: diff --git a/src/ImageManipulation/ZoomImage.py b/src/ImageManipulation/ZoomImage.py new file mode 100644 index 0000000..11c9a09 --- /dev/null +++ b/src/ImageManipulation/ZoomImage.py @@ -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) + diff --git a/src/SelectionArea.py b/src/SelectionArea.py index 4e8927c..6c196f4 100644 --- a/src/SelectionArea.py +++ b/src/SelectionArea.py @@ -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) diff --git a/src/utils/area_selection.py b/src/utils/area_selection.py index f00536b..e4a8402 100644 --- a/src/utils/area_selection.py +++ b/src/utils/area_selection.py @@ -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, diff --git a/src/utils/filter_params.py b/src/utils/filter_params.py new file mode 100644 index 0000000..570f08a --- /dev/null +++ b/src/utils/filter_params.py @@ -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("", lambda e: on_ok()) + dialog.bind("", 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("", lambda e: on_ok()) + dialog.bind("", 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("", lambda e: on_ok()) + dialog.bind("", 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 + +