diff --git a/README.md b/README.md index 47228b7..948fe23 100644 --- a/README.md +++ b/README.md @@ -44,11 +44,11 @@ Filters Shapes menu - [ ] List of Shapes - [ ] Outline color -- [ ] Fill color +- [x] Fill color Colors menu -- [ ] Color pallet -- [ ] Size of brush +- [x] Color pallet +- [x] Size of brush ### Optional features - [x] Camera diff --git a/src/GUI.py b/src/GUI.py index b086776..b21a9cf 100644 --- a/src/GUI.py +++ b/src/GUI.py @@ -17,6 +17,7 @@ from utils.filter_params import FilterParamsHandler from ImageManipulation.CopyToClipboard import CopyToClipboard from ImageManipulation.PasteFromClipboard import PasteFromClipboard from ImageManipulation.CutToClipboard import CutToClipboard +from ImageManipulation.FillSelection import FillSelection from functools import partial @@ -146,6 +147,7 @@ class GUI: 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) + shapes_menu.add_command(label="Fill Selection", command=lambda: self._fillSelection()) colors_menu = tk.Menu(menu, tearoff=0) menu.add_cascade(label="Colors", menu=colors_menu) @@ -212,7 +214,8 @@ class GUI: get_canvas_dimensions=get_canvas_dimensions, render_image=self._renderCurrentImage, root_window=root, - area_selection_handler=self._areaSelectionHandler + area_selection_handler=self._areaSelectionHandler, + fill_callback=self._fillSelection ) # Create brush UI panel @@ -707,6 +710,48 @@ class GUI: if hasattr(self._currentImage, '_clipboard_paste_error'): delattr(self._currentImage, '_clipboard_paste_error') + def _fillSelection(self) -> None: + """Fill the selected area with the brush color.""" + if self._currentImage is None or self._currentImage.getImage() is None: + messagebox.showwarning("No Image", "Please load an image first.") + return + + if not self._areaSelectionHandler or not self._areaSelectionHandler.selection_area: + messagebox.showwarning("No Selection", "Please select an area first.") + return + + selection_area = self._areaSelectionHandler.selection_area + + if not selection_area.is_active or not selection_area.is_valid(): + messagebox.showwarning("No Selection", "Please select an area first.") + return + + # Get brush color + if not self._brushHandler: + messagebox.showwarning("Error", "Brush handler not available.") + return + + brush_color_hex = self._brushHandler.brush_color + + # Take undo snapshot + self._currentImage.snapshot() + + # Use FillSelection manipulation class + manipulation = FillSelection() + params = { + "color": brush_color_hex, + "selection_area": selection_area + } + manipulation.manipulateImage(self._currentImage, params) + + # Clear selection before re-rendering + self._areaSelectionHandler.clear_selection() + + # Re-render the image + self._renderCurrentImage() + + messagebox.showinfo("Fill", "Selection filled with color.") + def _openCamera(self): camera = CameraWindow() camera.run(self) \ No newline at end of file diff --git a/src/ImageManipulation/FillSelection.py b/src/ImageManipulation/FillSelection.py new file mode 100644 index 0000000..742fa15 --- /dev/null +++ b/src/ImageManipulation/FillSelection.py @@ -0,0 +1,128 @@ +from .ImageManipulation import ImageManipulation +from ImageContainer import ImageContainer +from typing import Any, List, Optional +from PIL import Image, ImageDraw +from SelectionArea import SelectionArea + + +class FillSelection(ImageManipulation): + """Image manipulation class for filling selected areas with a color.""" + + def getManipulationName(self) -> str: + return "Fill Selection" + + def getParameters(self) -> List[str]: + return ["color", "selection_area"] + + def manipulateImage(self, image: ImageContainer, parameters: Any) -> None: + """Fill the selected area with the specified color. + + Args: + image: The ImageContainer to modify + parameters: Dictionary containing: + - "color": RGB tuple (r, g, b) or hex color string + - "selection_area": SelectionArea object with the selection + """ + if image is None or image.getImage() is None: + return + + if not isinstance(parameters, dict): + return + + # Get color from parameters + color = parameters.get("color") + if color is None: + return + + # Convert color to RGB tuple if it's a hex string + if isinstance(color, str): + hex_color = color.lstrip('#') + rgb_color = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) + elif isinstance(color, (tuple, list)) and len(color) >= 3: + rgb_color = tuple(color[:3]) + else: + return + + # Get selection area from parameters + selection_area = parameters.get("selection_area") + if not selection_area or not isinstance(selection_area, SelectionArea): + return + + if not selection_area.is_active or not selection_area.is_valid(): + return + + # Get the original image + original_image = image.getImage() + + # Get selection coordinates + coords = selection_area.get_coordinates() + if not coords: + return + + left, top, right, bottom = coords + width = right - left + height = bottom - top + + # Create a filled image based on selection shape + if selection_area.shape == "rectangle": + # Simple rectangle fill + filled = Image.new("RGB", (width, height), rgb_color) + + elif selection_area.shape == "circle": + # Create circular mask and fill + filled = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(filled) + draw.ellipse((0, 0, width, height), fill=rgb_color) + + elif selection_area.shape == "lasso" or selection_area.shape == "polygon": + # Create mask for path and fill + filled = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(filled) + # Use appropriate path (lasso or polygon) + path = selection_area.lasso_path if selection_area.shape == "lasso" else selection_area.polygon_path + # Adjust path to be relative to bounding box + adjusted_path = [(x - left, y - top) for x, y in path] + if len(adjusted_path) >= 3: + draw.polygon(adjusted_path, fill=rgb_color) + else: + # Default to rectangle + filled = Image.new("RGB", (width, height), rgb_color) + + # Convert filled image to match original image mode + if original_image.mode == "RGBA": + if filled.mode != "RGBA": + filled = filled.convert("RGBA") + else: + if filled.mode == "RGBA": + # Convert RGBA to RGB by compositing on white background + background = Image.new("RGB", filled.size, (255, 255, 255)) + background.paste(filled, (0, 0), filled.split()[3] if filled.mode == "RGBA" else None) + filled = background + else: + filled = filled.convert(original_image.mode) + + # Apply the filled area to the original image + result_image = original_image.copy() + + if selection_area.shape == "circle" or selection_area.shape == "lasso" or selection_area.shape == "polygon": + # Create mask for blending + mask = Image.new("L", (width, height), 0) + draw = ImageDraw.Draw(mask) + + if selection_area.shape == "circle": + draw.ellipse((0, 0, width, height), fill=255) + else: + # Use appropriate path (lasso or polygon) + path = selection_area.lasso_path if selection_area.shape == "lasso" else selection_area.polygon_path + adjusted_path = [(x - left, y - top) for x, y in path] + if len(adjusted_path) >= 3: + draw.polygon(adjusted_path, fill=255) + + result_image.paste(filled, (left, top), mask) + else: + # Rectangle - direct paste + result_image.paste(filled, (left, top)) + + # Update the image + image._imageData = result_image + diff --git a/src/utils/brush.py b/src/utils/brush.py index 66663fc..d31687c 100644 --- a/src/utils/brush.py +++ b/src/utils/brush.py @@ -24,7 +24,8 @@ class BrushHandler: get_canvas_dimensions: Callable, render_image: Callable, root_window: tk.Tk, - area_selection_handler=None): + area_selection_handler=None, + fill_callback: Optional[Callable] = None): """Initialize the brush handler. Args: @@ -34,6 +35,7 @@ class BrushHandler: render_image: Function to re-render the current image root_window: The root tkinter window area_selection_handler: Optional AreaSelectionHandler to disable when brush is active + fill_callback: Optional callback function to call when fill button is clicked """ self._canvas = canvas self._get_current_image = get_current_image @@ -41,6 +43,7 @@ class BrushHandler: self._render_image = render_image self._root = root_window self._area_selection_handler = area_selection_handler + self._fill_callback = fill_callback # Brush state self._brushMode = False @@ -58,6 +61,7 @@ class BrushHandler: self._brushStatusLabel = None self._brushToggleButton = None self._eraseToggleButton = None + self._fillButton = None self._colorSwatches = [] # List of color swatch buttons def create_ui_panel(self, parent_frame: tk.Frame) -> None: @@ -104,6 +108,22 @@ class BrushHandler: ) self._eraseToggleButton.pack(pady=5) + # Fill button + self._fillButton = tk.Button( + button_frame, + text="Fill Selection", + command=self._on_fill_clicked, + bg="#2196F3", + fg="white", + font=("Arial", 10, "bold"), + relief="raised", + bd=2, + cursor="hand2", + width=20, + height=2 + ) + self._fillButton.pack(pady=5) + # Separator tk.Frame(parent_frame, bg="lightgray", height=2, relief="sunken", bd=1).pack(pady=5, fill="x", padx=15) @@ -264,6 +284,11 @@ class BrushHandler: """Update the brush shape based on the selected radio button.""" self._brushShape = self._shapeVar.get() + def _on_fill_clicked(self) -> None: + """Handle fill button click.""" + if self._fill_callback: + self._fill_callback() + def toggle_brush_mode(self) -> None: """Toggle brush mode on/off.""" self._brushMode = not self._brushMode @@ -507,4 +532,9 @@ class BrushHandler: def brush_mode(self) -> bool: """Get the current brush mode state.""" return self._brushMode + + @property + def brush_color(self) -> str: + """Get the current brush color.""" + return self._brushColor