diff --git a/.idea/workspace.xml b/.idea/workspace.xml deleted file mode 100644 index c0e083b..0000000 --- a/.idea/workspace.xml +++ /dev/null @@ -1,100 +0,0 @@ - - - - - - - - - - - - - - - - - - - { - "associatedIndex": 7 -} - - - - - - - - - - - - - - - - - - - - 1757587375498 - - - - - - - - - - - - \ No newline at end of file diff --git a/README.md b/README.md index 948fe23..76ba8e9 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Colors menu ### Optional features - [x] Camera - [x] Snapchat camera filters -- [ ] Smart Scissors +- [x] Smart Scissors - [ ] Geofilters ## requirements diff --git a/lena copy.png b/lena copy.png deleted file mode 100644 index e9ad1e0..0000000 --- a/lena copy.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7e497501a28bcf9a353ccadf6eb9216bf098ac32888fb542fb9bfe71d486761f -size 473831 diff --git a/src/ImageManipulation/FillSelection.py b/src/ImageManipulation/FillSelection.py index 742fa15..ac186be 100644 --- a/src/ImageManipulation/FillSelection.py +++ b/src/ImageManipulation/FillSelection.py @@ -1,7 +1,7 @@ from .ImageManipulation import ImageManipulation from ImageContainer import ImageContainer -from typing import Any, List, Optional -from PIL import Image, ImageDraw +from typing import Any, List +from PIL import Image, ImageOps from SelectionArea import SelectionArea @@ -53,76 +53,15 @@ class FillSelection(ImageManipulation): # Get the original image original_image = image.getImage() - - # Get selection coordinates - coords = selection_area.get_coordinates() - if not coords: + + mask = selection_area.create_mask(original_image.size) + if mask is None: 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 + if selection_area.is_inverted(): + mask = ImageOps.invert(mask) + + fill_layer = Image.new("RGB", original_image.size, rgb_color).convert(original_image.mode) + result_image = Image.composite(fill_layer, original_image, mask) + image._imageData = result_image diff --git a/src/SelectionArea.py b/src/SelectionArea.py index 81975c6..06c9cd4 100644 --- a/src/SelectionArea.py +++ b/src/SelectionArea.py @@ -1,4 +1,4 @@ -from PIL import Image, ImageDraw +from PIL import Image, ImageDraw, ImageOps from typing import Dict, Optional, Tuple, List @@ -14,6 +14,7 @@ class SelectionArea: 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 + self.inverted: bool = False def set_coordinates(self, left: int, top: int, right: int, bottom: int) -> None: """Set the selection coordinates.""" @@ -22,6 +23,7 @@ class SelectionArea: self.right = right self.bottom = bottom self.is_active = True + self.inverted = False def clear(self) -> None: """Clear the selection.""" @@ -33,6 +35,7 @@ class SelectionArea: self.lasso_path = [] self.polygon_path = [] self.shape = self.shape # keep last used shape + self.inverted = False def set_shape(self, shape: str) -> None: """Set the selection shape: 'rectangle', 'circle', 'lasso', or 'polygon'.""" @@ -48,6 +51,7 @@ class SelectionArea: self.lasso_path = normalized_path self.shape = "lasso" self.is_active = True + self.inverted = False xs = [p[0] for p in normalized_path] ys = [p[1] for p in normalized_path] @@ -82,6 +86,7 @@ class SelectionArea: self.polygon_path = path self.shape = "polygon" self.is_active = True + self.inverted = False # Calculate bounding box for polygon if path: @@ -108,6 +113,11 @@ class SelectionArea: """Crop the given image to the selection area.""" if not self.is_valid(): return None + + if self.inverted: + # Cropping an inverted selection would effectively return the whole image. + # Return a copy to avoid unexpected behavior. + return image.copy() if self.shape == "lasso" or self.shape == "polygon": # For lasso and polygon, crop to bounding box and apply mask @@ -153,12 +163,19 @@ class SelectionArea: def apply_to_image(self, image: Image.Image, processed_selection: Image.Image) -> Image.Image: """Apply the processed selection back to the original image.""" + if self.inverted: + # Inverted selections are handled externally with masks. + return image + coords = self.get_coordinates() if coords and self.is_valid(): # Create a copy to avoid modifying the original result_image = image.copy() width = self.right - self.left height = self.bottom - self.top + + if processed_selection.size != (width, height): + processed_selection = processed_selection.resize((width, height)) if self.shape == "circle": # Create circular mask for blending only inside the circle @@ -181,3 +198,63 @@ class SelectionArea: result_image.paste(processed_selection, (self.left, self.top)) return result_image return image + + def toggle_inverted(self) -> None: + """Toggle whether the selection is inverted.""" + if self.is_active and self.is_valid(): + self.inverted = not self.inverted + + def set_inverted(self, inverted: bool) -> None: + """Explicitly set the inverted state.""" + if self.is_active and self.is_valid(): + self.inverted = bool(inverted) + + def is_inverted(self) -> bool: + """Return True if the selection is inverted.""" + return self.inverted + + def create_mask(self, size: Tuple[int, int], inverted: bool = False) -> Optional[Image.Image]: + """Create a binary mask for the selection over a given image size. + + Args: + size: Tuple of (width, height) for the target image. + inverted: If True, the mask is inverted (selected area becomes background). + + Returns: + PIL.Image in "L" mode with 255 indicating the selected pixels, or None if invalid. + """ + if not self.is_active or not self.is_valid(): + return None + + width, height = size + if width <= 0 or height <= 0: + return None + + mask = Image.new("L", size, 0) + draw = ImageDraw.Draw(mask) + + coords = self.get_coordinates() + if not coords: + return None + left, top, right, bottom = coords + + if self.shape == "rectangle": + draw.rectangle([left, top, right, bottom], fill=255) + elif self.shape == "circle": + draw.ellipse([left, top, right, bottom], fill=255) + elif self.shape == "lasso": + if len(self.lasso_path) >= 3: + draw.polygon(self.lasso_path, fill=255) + else: + return None + elif self.shape == "polygon": + if len(self.polygon_path) >= 3: + draw.polygon(self.polygon_path, fill=255) + else: + return None + else: + return None + + if inverted: + mask = ImageOps.invert(mask) + return mask diff --git a/src/utils/area_selection.py b/src/utils/area_selection.py index e37f7f2..3c44074 100644 --- a/src/utils/area_selection.py +++ b/src/utils/area_selection.py @@ -171,6 +171,16 @@ class AreaSelectionHandler: """Set selection to polygon mode and enable selection.""" self.set_selection_shape("polygon") + def toggle_selection_inversion(self) -> bool: + """Invert the current active selection.""" + if not self._selectionArea or not self._selectionArea.is_active: + return False + if not self._selectionArea.is_valid(): + return False + self._selectionArea.toggle_inverted() + self._render_image() + return True + 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: @@ -489,7 +499,7 @@ class AreaSelectionHandler: # Add crop to selection option first (most common use case) context_menu.add_command(label="Crop to Selection", command=self.crop_to_selection) context_menu.add_separator() - + # Add manipulation options manipulations = GetImageManipulationList() for manipulation in manipulations: @@ -498,9 +508,12 @@ class AreaSelectionHandler: label=f"Apply {manipulation_name} to Selection", command=lambda m=manipulation: self.apply_manipulation_to_selection(m, get_default_params) ) - + context_menu.add_separator() context_menu.add_command(label="Clear Selection", command=self.clear_selection) + + invert_label = "Select Inside Area" if self._selectionArea.is_inverted() else "Select Outside Area" + context_menu.add_command(label=invert_label, command=self.toggle_selection_inversion) # Show context menu at cursor position try: @@ -561,6 +574,10 @@ class AreaSelectionHandler: current_image = self._get_current_image() if current_image is None or not self._selectionArea or not self._selectionArea.is_active: return + + if self._selectionArea.is_inverted(): + # Cropping with an inverted selection would keep the original image unchanged. + return # Get the selection coordinates coords = self._selectionArea.get_coordinates() @@ -673,7 +690,8 @@ class AreaSelectionHandler: def apply_manipulation_to_selection(self, manipulation, get_default_params: Callable) -> None: """Apply the specified manipulation to the selected area only.""" current_image = self._get_current_image() - if current_image is None or not self._selectionArea.is_active: + selection_area = self._selectionArea + if current_image is None or not selection_area.is_active: return # Take undo snapshot @@ -681,27 +699,49 @@ class AreaSelectionHandler: # Get the original image original_image = current_image.getImage() - - # Crop the selection area - selected_area = self._selectionArea.crop_image(original_image) - if selected_area is None: + if original_image is None: return - - # Create a temporary ImageContainer for the selected area - temp_container = ImageContainer() - temp_container._imageData = selected_area - - # Apply manipulation to the selected area - # Use default parameters if none specified + params = get_default_params(manipulation) - manipulation.manipulateImage(temp_container, params) - - # Get the processed selection - processed_selection = temp_container.getImage() - - # Apply the processed selection back to the original image - result_image = self._selectionArea.apply_to_image(original_image, processed_selection) - + + if selection_area.is_inverted(): + mask = selection_area.create_mask(original_image.size, inverted=True) + if mask is None: + return + + temp_container = ImageContainer() + temp_container._imageData = original_image.copy() + manipulation.manipulateImage(temp_container, params) + manipulated_image = temp_container.getImage() + if manipulated_image is None: + return + if manipulated_image.size != original_image.size: + manipulated_image = manipulated_image.resize(original_image.size) + if manipulated_image.mode != original_image.mode: + manipulated_image = manipulated_image.convert(original_image.mode) + + result_image = Image.composite(manipulated_image, original_image, mask) + else: + # Crop the selection area + selected_area = selection_area.crop_image(original_image) + if selected_area is None: + return + + # Create a temporary ImageContainer for the selected area + temp_container = ImageContainer() + temp_container._imageData = selected_area + + # Apply manipulation to the selected area + manipulation.manipulateImage(temp_container, params) + + # Get the processed selection + processed_selection = temp_container.getImage() + if processed_selection is None: + return + + # Apply the processed selection back to the original image + result_image = selection_area.apply_to_image(original_image, processed_selection) + # Update the current image current_image._imageData = result_image @@ -996,6 +1036,8 @@ class AreaSelectionHandler: canvas_right = int(right * scale_x) canvas_bottom = int(bottom * scale_y) + outline_color = "orange" if self._selectionArea.is_inverted() else "red" + # Draw colored edges only if self._selectionArea.shape == "lasso" and self._selectionArea.lasso_path: # Convert lasso path from image coordinates to canvas coordinates @@ -1005,7 +1047,7 @@ class AreaSelectionHandler: 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 + fill=outline_color, width=3, smooth=False ) elif self._selectionArea.shape == "polygon" and self._selectionArea.polygon_path: # Convert polygon path from image coordinates to canvas coordinates @@ -1015,17 +1057,17 @@ class AreaSelectionHandler: 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 + fill=outline_color, width=3, smooth=False ) elif self._selectionArea.shape == "circle": self._canvas.create_oval( canvas_left, canvas_top, canvas_right, canvas_bottom, - outline="red", width=3, fill="" + outline=outline_color, width=3, fill="" ) else: self._canvas.create_rectangle( canvas_left, canvas_top, canvas_right, canvas_bottom, - outline="red", width=3, fill="" + outline=outline_color, width=3, fill="" ) @property