diff --git a/src/GUI.py b/src/GUI.py index 20718a4..b930786 100644 --- a/src/GUI.py +++ b/src/GUI.py @@ -1,75 +1,146 @@ -import tkinter as tk -from tkinter import filedialog -from PIL import ImageTk -from ImageContainer import ImageContainer -from ImageManipulation.ManipulationList import * - - -class GUI: - """The GUI class responsible for the main application GUI. - - @warning This class is a singleton. - """ - _instance = None - - # If the GUI has been initialised. - _isInitialised = False - - _currentImage = None - - def __new__(cls): - if cls._instance is None: - cls._instance = super(GUI, cls).__new__(cls) - return cls._instance - - def initialise(self): - """Initialise the GUI.""" - - if self._isInitialised: - return - else: - self._isInitialised = True - - # Main window - root = tk.Tk() - root.title("Image Viewer") - root.geometry("800x600") - root.config(bg="white") - icon = tk.PhotoImage(file='icon.png') - root.tk.call('wm', 'iconphoto', root._w, icon) - root.minsize(800, 600) - - # Menus - menu = tk.Menu(root) - root.config(menu=menu) - file_menu = tk.Menu(menu, tearoff=0) - menu.add_cascade(label="File", menu=file_menu) - file_menu.add_command(label="Open Image", command=lambda: self._openImage()) - file_menu.add_command(label="Save Image", command=lambda: _save_image(OPENED_IMAGE)) - file_menu.add_command(label="Exit", command=root.quit) - - - test_menu = tk.Menu(menu, tearoff=0) - menu.add_cascade(label="Filters", menu=test_menu) - for manipulation in GetImageManipulationList(): - test_menu.add_command(label=manipulation.getManipulationName(), command=lambda: manipulation.manipulateImage()) - - # Frame to hold image - imgframe = tk.Frame(root, width=500, height=500, bg="lightgray", relief="sunken", bd=2) - imgframe.pack(side="top", pady=10) - - # Label to display image - image_label = tk.Label(imgframe, width=500, height=500, bg="white") - image_label.pack(expand=True) - - root.mainloop() - - def _openImage(self) -> None: - file_path = filedialog.askopenfilename( - filetypes=[("Image files", "*.jpg *.jpeg *.png *.gif *.bmp")] - ) - if not file_path: - return - - self._currentImage = ImageContainer() - self._currentImage.loadImage(file_path) \ No newline at end of file +import tkinter as tk +from tkinter import filedialog +from PIL import ImageTk +from ImageContainer import ImageContainer +from ImageManipulation.ManipulationList import * +from functools import partial + + +class GUI: + """The GUI class responsible for the main application GUI. + + @warning This class is a singleton. + """ + _instance = None + + # If the GUI has been initialised. + _isInitialised = False + + _currentImage = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super(GUI, cls).__new__(cls) + return cls._instance + + def initialise(self): + """Initialise the GUI.""" + + if self._isInitialised: + return + else: + self._isInitialised = True + + # Main window + root = tk.Tk() + root.title("Image Viewer") + root.geometry("800x600") + root.config(bg="white") + icon = tk.PhotoImage(file='icon.png') + root.tk.call('wm', 'iconphoto', root._w, icon) + root.minsize(800, 600) + + # Menus + menu = tk.Menu(root) + root.config(menu=menu) + file_menu = tk.Menu(menu, tearoff=0) + menu.add_cascade(label="File", menu=file_menu) + file_menu.add_command(label="Open Image", command=lambda: self._openImage()) + file_menu.add_command(label="Save Image", command=lambda: self._saveImage()) + file_menu.add_command(label="Exit", command=root.quit) + + edit_menu = tk.Menu(menu, tearoff=0) + menu.add_cascade(label="Edit", menu=edit_menu) + edit_menu.add_command(label="Undo", accelerator="Ctrl+Z", command=lambda: self._undo()) + + + test_menu = tk.Menu(menu, tearoff=0) + menu.add_cascade(label="Filters", menu=test_menu) + for manipulation in GetImageManipulationList(): + test_menu.add_command( + label=manipulation.getManipulationName(), + command=partial(self._applyManipulation, manipulation) + ) + + # Manual Test menu listing all manipulations explicitly + manual_menu = tk.Menu(menu, tearoff=0) + menu.add_cascade(label="Test", menu=manual_menu) + manual_menu.add_command(label="Padding", command=partial(self._applyManipulation, Padding(), {"border_width": 50})) + manual_menu.add_command(label="Crop", command=partial(self._applyManipulation, CropImage())) + manual_menu.add_command(label="Resize", command=partial(self._applyManipulation, ResizeImage(), {"width": 200, "height": 200})) + manual_menu.add_command(label="Copy", command=partial(self._applyManipulation, CopyImage())) + manual_menu.add_command(label="Greyscale", command=partial(self._applyManipulation, Grayscale())) + manual_menu.add_command(label="HSV", command=partial(self._applyManipulation, HSV())) + manual_menu.add_command(label="Hue Shifted", command=partial(self._applyManipulation, HueShift(), {"hue": 50})) + manual_menu.add_command(label="Smoothed", command=partial(self._applyManipulation, BoxBlur(), {"ksize": 15})) + manual_menu.add_command(label="Rotated", command=partial(self._applyManipulation, RotateImage(), {"angle": 90})) + manual_menu.add_command(label="Flip (Horizontal)", command=partial(self._applyManipulation, FlipImage(), {"mode": "horizontal"})) + manual_menu.add_command(label="Flip (Vertical)", command=partial(self._applyManipulation, FlipImage(), {"mode": "vertical"})) + manual_menu.add_command(label="Color Adjust", command=partial(self._applyManipulation, ColorAdjust(), {"brightness": 10, "contrast": 1.2, "saturation": 1.1})) + manual_menu.add_command(label="Gaussian Blur", command=partial(self._applyManipulation, GaussianBlur(), {"ksize": 5})) + manual_menu.add_command(label="Sobel Edge", command=partial(self._applyManipulation, SobelEdge(), {"dx": 1, "dy": 0, "ksize": 3})) + manual_menu.add_command(label="Binary Threshold", command=partial(self._applyManipulation, BinaryThreshold(), {"thresh": 127})) + manual_menu.add_command(label="Histogram Threshold", command=partial(self._applyManipulation, HistogramThreshold())) + + # Frame to hold image + imgframe = tk.Frame(root, width=500, height=500, bg="lightgray", relief="sunken", bd=2) + imgframe.pack(side="top", pady=10) + + # Label to display image + self._imageLabel = tk.Label(imgframe, width=500, height=500, bg="white") + self._imageLabel.pack(expand=True) + + self._root = root + + # Key bindings + root.bind_all('', lambda event: self._undo()) + + root.mainloop() + + def _openImage(self) -> None: + file_path = filedialog.askopenfilename( + filetypes=[("Image files", "*.jpg *.jpeg *.png *.gif *.bmp")] + ) + if not file_path: + return + + self._currentImage = ImageContainer() + self._currentImage.loadImage(file_path) + self._renderCurrentImage() + + def _renderCurrentImage(self) -> None: + if self._currentImage is None or self._currentImage.getImage() is None: + return + pil_img = self._currentImage.getImage() + width, height = pil_img.size + tk_img = ImageTk.PhotoImage(pil_img) + # Keep reference to avoid garbage collection + self._imageLabel.image = tk_img + self._imageLabel.config(image=tk_img, width=width, height=height) + + def _applyManipulation(self, manipulation, params: dict | None = None) -> None: + if self._currentImage is None: + return + # Take undo snapshot + self._currentImage.snapshot() + if params is None: + # default demo params for crop + params = {"width": 200, "height": 200} + manipulation.manipulateImage(self._currentImage, params) + self._renderCurrentImage() + + def _saveImage(self) -> None: + if self._currentImage is None or self._currentImage.getImage() is None: + return + path = filedialog.asksaveasfilename(defaultextension=".png", filetypes=[ + ("PNG", "*.png"), ("JPEG", "*.jpg;*.jpeg"), ("Bitmap", "*.bmp"), ("All Files", "*.*") + ]) + if not path: + return + self._currentImage.saveImage(path) + + def _undo(self) -> None: + if self._currentImage is None: + return + self._currentImage.undo() + self._renderCurrentImage() \ No newline at end of file diff --git a/src/ImageContainer.py b/src/ImageContainer.py index 4f948eb..9004d99 100644 --- a/src/ImageContainer.py +++ b/src/ImageContainer.py @@ -1,25 +1,51 @@ -import cv2 -from PIL import Image, ImageTk - -class ImageContainer: - _imageData = None - _path = None - - def loadImage(self, path: str) -> None: - """ Load image file from the path. - - :param path: Path to the image file. - """ - imgcv2 = cv2.imread(path) - height, width, channels = imgcv2.shape - - # Open and resize image (optional) - _imageData = Image.open(path) - _imageData = _imageData.resize((height, width), Image.LANCZOS) # Resize to fit frame - - _path = path - - print("Opened image:", path) - - def getDimensions(self) -> tuple[int,int]: - return self._imageData.shape \ No newline at end of file +import cv2 +from PIL import Image, ImageTk + +class ImageContainer: + _imageData = None + _path = None + _history = None + + def loadImage(self, path: str) -> None: + """ Load image file from the path. + + :param path: Path to the image file. + """ + imgcv2 = cv2.imread(path) + height, width, channels = imgcv2.shape + + # Open and resize image (optional) + self._imageData = Image.open(path) + # PIL expects (width, height) + self._imageData = self._imageData.resize((width, height), Image.LANCZOS) + + self._path = path + self._history = [] + + print("Opened image:", path) + + def getDimensions(self) -> tuple[int,int]: + # PIL Image.size returns (width, height) + return self._imageData.size if self._imageData is not None else (0, 0) + + def getImage(self) -> Image: + return self._imageData + + def saveImage(self, path: str) -> None: + if self._imageData is not None: + self._imageData.save(path) + + def snapshot(self) -> None: + """Push a copy of current image to history for undo.""" + if self._imageData is None: + return + # Ensure a deep copy (PIL copy is sufficient) + self._history.append(self._imageData.copy()) + # Cap history size to avoid memory blow-up + if len(self._history) > 20: + self._history.pop(0) + + def undo(self) -> None: + if not self._history: + return + self._imageData = self._history.pop() \ No newline at end of file diff --git a/src/ImageManipulation/BoxBlur.py b/src/ImageManipulation/BoxBlur.py new file mode 100644 index 0000000..f821151 --- /dev/null +++ b/src/ImageManipulation/BoxBlur.py @@ -0,0 +1,28 @@ +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 BoxBlur(ImageManipulation): + def getManipulationName(self) -> str: + return "Box Blur" + + def getParameters(self) -> List[str]: + return ["ksize"] + + def manipulateImage(self, image: ImageContainer, parameters: Any) -> None: + if image is None or image.getImage() is None: + return + pil_img = image.getImage() + cv_img = pil_to_cv2(pil_img) + k = 15 + if isinstance(parameters, dict) and parameters.get("ksize"): + k = int(parameters.get("ksize")) + if k % 2 == 0: + k += 1 + blurred = cv2.blur(cv_img, (k, k)) + image._imageData = cv2_to_pil(blurred) + + diff --git a/src/ImageManipulation/ColorAdjust.py b/src/ImageManipulation/ColorAdjust.py new file mode 100644 index 0000000..bf63be5 --- /dev/null +++ b/src/ImageManipulation/ColorAdjust.py @@ -0,0 +1,45 @@ +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 +import numpy as np + + +class ColorAdjust(ImageManipulation): + def getManipulationName(self) -> str: + return "Color Adjust" + + def getParameters(self) -> List[str]: + return ["brightness", "contrast", "saturation"] + + def manipulateImage(self, image: ImageContainer, parameters: Any) -> None: + if image is None or image.getImage() is None: + return + pil_img = image.getImage() + cv_img = pil_to_cv2(pil_img) + + brightness = 0.0 + contrast = 1.0 + saturation = 1.0 + + if isinstance(parameters, dict): + if parameters.get("brightness") is not None: + brightness = float(parameters.get("brightness")) # -100..100 (additive) + if parameters.get("contrast") is not None: + contrast = float(parameters.get("contrast")) # 0.0..3.0 (multiplicative) + if parameters.get("saturation") is not None: + saturation = float(parameters.get("saturation")) # 0.0..3.0 (multiplicative) + + # Apply brightness/contrast on BGR + adjusted = cv2.convertScaleAbs(cv_img, alpha=contrast, beta=brightness) + + # Adjust saturation in HSV + hsv = cv2.cvtColor(adjusted, cv2.COLOR_BGR2HSV).astype(np.float32) + h, s, v = cv2.split(hsv) + s = np.clip(s * saturation, 0, 255) + hsv = cv2.merge([h, s, v]).astype(np.uint8) + result = cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR) + + image._imageData = cv2_to_pil(result) + diff --git a/src/ImageManipulation/CopyImage.py b/src/ImageManipulation/CopyImage.py new file mode 100644 index 0000000..d552d61 --- /dev/null +++ b/src/ImageManipulation/CopyImage.py @@ -0,0 +1,23 @@ +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 numpy as np + + +class CopyImage(ImageManipulation): + def getManipulationName(self) -> str: + return "Copy" + + def getParameters(self) -> List[str]: + return [] + + def manipulateImage(self, image: ImageContainer, parameters: Any) -> None: + if image is None or image.getImage() is None: + return + # Deep copy using numpy array roundtrip to ensure a new buffer + pil_img = image.getImage() + arr = np.array(pil_img) + image._imageData = pil_img.copy() + + diff --git a/src/ImageManipulation/CropImage.py b/src/ImageManipulation/CropImage.py index 0887a14..18d25cf 100644 --- a/src/ImageManipulation/CropImage.py +++ b/src/ImageManipulation/CropImage.py @@ -1,39 +1,69 @@ -from ImageManipulation.ImageManipulation import ImageManipulation -from ImageContainer import ImageContainer -from typing import Any, List - - -class CropImage(ImageManipulation): - """Concrete implementation for cropping images.""" - - def __init__(self): - # You can initialize any instance variables here - self.manipulation_name = "Crop" - - def manipulateImage(self, image: ImageContainer, parameters: Any) -> None: - """ - Manipulate the given image using the provided parameters. - - Args: - image: The image to manipulate - parameters: Parameters for the manipulation (e.g., crop coordinates) - """ - pass - - def getParameters(self) -> List[str]: - """ - Get the list of parameters required for cropping. - - Returns: - List of parameter names - """ - return ["width", "height"] - - def getManipulationName(self) -> str: - """ - Get the name of this manipulation operation. - - Returns: - The name of the manipulation - """ +from .ImageManipulation import ImageManipulation +from ImageContainer import ImageContainer +from typing import Any, List +from PIL import Image + + +class CropImage(ImageManipulation): + """Concrete implementation for cropping images.""" + + def __init__(self): + # You can initialize any instance variables here + self.manipulation_name = "Crop" + + def manipulateImage(self, image: ImageContainer, parameters: Any) -> None: + """ + Manipulate the given image using the provided parameters. + + Args: + image: The image to manipulate + parameters: Parameters for the manipulation (e.g., crop coordinates) + """ + if image is None or getattr(image, "_imageData", None) is None: + return + + pil_image = image._imageData + + # If parameters provided as dict with width/height, do centered crop + if isinstance(parameters, dict) and "width" in parameters and "height" in parameters: + target_width = int(parameters["width"]) if parameters["width"] is not None else None + target_height = int(parameters["height"]) if parameters["height"] is not None else None + if target_width is None or target_height is None: + return + img_width, img_height = pil_image.size + crop_width = min(target_width, img_width) + crop_height = min(target_height, img_height) + left = (img_width - crop_width) // 2 + top = (img_height - crop_height) // 2 + right = left + crop_width + bottom = top + crop_height + else: + # Default: trim margins similar to demo in root main.py + img_width, img_height = pil_image.size + left = 80 + top = 80 + right = max(0, img_width - 130) + bottom = max(0, img_height - 130) + if right <= left or bottom <= top: + return + + cropped = pil_image.crop((left, top, right, bottom)) + image._imageData = cropped + + def getParameters(self) -> List[str]: + """ + Get the list of parameters required for cropping. + + Returns: + List of parameter names + """ + return ["width", "height"] + + def getManipulationName(self) -> str: + """ + Get the name of this manipulation operation. + + Returns: + The name of the manipulation + """ return "Crop Image" \ No newline at end of file diff --git a/src/ImageManipulation/Filters.py b/src/ImageManipulation/Filters.py new file mode 100644 index 0000000..1675218 --- /dev/null +++ b/src/ImageManipulation/Filters.py @@ -0,0 +1,84 @@ +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 GaussianBlur(ImageManipulation): + def getManipulationName(self) -> str: + return "Gaussian Blur" + + def getParameters(self) -> List[str]: + return ["ksize"] # odd int + + def manipulateImage(self, image: ImageContainer, parameters: Any) -> None: + if image is None or image.getImage() is None: + return + pil_img = image.getImage() + ksize = 5 + if isinstance(parameters, dict) and parameters.get("ksize"): + ksize = int(parameters.get("ksize")) + if ksize % 2 == 0: + ksize += 1 + cv_img = pil_to_cv2(pil_img) + blurred = cv2.GaussianBlur(cv_img, (ksize, ksize), 0) + image._imageData = cv2_to_pil(blurred) + + +class SobelEdge(ImageManipulation): + def getManipulationName(self) -> str: + return "Sobel Edge" + + def getParameters(self) -> List[str]: + return ["dx", "dy", "ksize"] + + def manipulateImage(self, image: ImageContainer, parameters: Any) -> None: + if image is None or image.getImage() is None: + return + pil_img = image.getImage() + cv_img = pil_to_cv2(pil_img) + gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY) + dx = int(parameters.get("dx", 1)) if isinstance(parameters, dict) else 1 + dy = int(parameters.get("dy", 0)) if isinstance(parameters, dict) else 0 + ksize = int(parameters.get("ksize", 3)) if isinstance(parameters, dict) else 3 + sobel = cv2.Sobel(gray, cv2.CV_64F, dx, dy, ksize=ksize) + abs_sobel = cv2.convertScaleAbs(sobel) + image._imageData = cv2_to_pil(abs_sobel) + + +class BinaryThreshold(ImageManipulation): + def getManipulationName(self) -> str: + return "Binary Threshold" + + def getParameters(self) -> List[str]: + return ["thresh"] + + def manipulateImage(self, image: ImageContainer, parameters: Any) -> None: + if image is None or image.getImage() is None: + return + pil_img = image.getImage() + cv_img = pil_to_cv2(pil_img) + gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY) + thresh_val = int(parameters.get("thresh", 127)) if isinstance(parameters, dict) else 127 + _, thresh = cv2.threshold(gray, thresh_val, 255, cv2.THRESH_BINARY) + image._imageData = cv2_to_pil(thresh) + + +class HistogramThreshold(ImageManipulation): + def getManipulationName(self) -> str: + return "Histogram Threshold" + + def getParameters(self) -> List[str]: + return [] + + def manipulateImage(self, image: ImageContainer, parameters: Any) -> None: + if image is None or image.getImage() is None: + return + pil_img = image.getImage() + cv_img = pil_to_cv2(pil_img) + gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY) + # Otsu's threshold as histogram-based method + _, otsu = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + image._imageData = cv2_to_pil(otsu) + diff --git a/src/ImageManipulation/FlipImage.py b/src/ImageManipulation/FlipImage.py new file mode 100644 index 0000000..c78cdc8 --- /dev/null +++ b/src/ImageManipulation/FlipImage.py @@ -0,0 +1,26 @@ +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 FlipImage(ImageManipulation): + def getManipulationName(self) -> str: + return "Flip" + + def getParameters(self) -> List[str]: + return ["mode"] # horizontal|vertical + + def manipulateImage(self, image: ImageContainer, parameters: Any) -> None: + if image is None or image.getImage() is None: + return + pil_img = image.getImage() + mode = "horizontal" + if isinstance(parameters, dict) and parameters.get("mode") in ("horizontal", "vertical"): + mode = parameters.get("mode") + cv_img = pil_to_cv2(pil_img) + flip_code = 1 if mode == "horizontal" else 0 + flipped = cv2.flip(cv_img, flip_code) + image._imageData = cv2_to_pil(flipped) + diff --git a/src/ImageManipulation/Grayscale.py b/src/ImageManipulation/Grayscale.py new file mode 100644 index 0000000..a54c239 --- /dev/null +++ b/src/ImageManipulation/Grayscale.py @@ -0,0 +1,23 @@ +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 Grayscale(ImageManipulation): + def getManipulationName(self) -> str: + return "Grayscale" + + def getParameters(self) -> List[str]: + return [] + + def manipulateImage(self, image: ImageContainer, parameters: Any) -> None: + if image is None or image.getImage() is None: + return + pil_img = image.getImage() + cv_img = pil_to_cv2(pil_img) + gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY) + image._imageData = cv2_to_pil(gray) + + diff --git a/src/ImageManipulation/HSV.py b/src/ImageManipulation/HSV.py new file mode 100644 index 0000000..d7c4897 --- /dev/null +++ b/src/ImageManipulation/HSV.py @@ -0,0 +1,23 @@ +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 HSV(ImageManipulation): + def getManipulationName(self) -> str: + return "HSV" + + def getParameters(self) -> List[str]: + return [] + + def manipulateImage(self, image: ImageContainer, parameters: Any) -> None: + if image is None or image.getImage() is None: + return + pil_img = image.getImage() + cv_img = pil_to_cv2(pil_img) + hsv = cv2.cvtColor(cv_img, cv2.COLOR_BGR2HSV) + image._imageData = cv2_to_pil(hsv) + + diff --git a/src/ImageManipulation/HueShift.py b/src/ImageManipulation/HueShift.py new file mode 100644 index 0000000..8ec1a90 --- /dev/null +++ b/src/ImageManipulation/HueShift.py @@ -0,0 +1,32 @@ +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 +import numpy as np + + +class HueShift(ImageManipulation): + def getManipulationName(self) -> str: + return "Hue Shift" + + def getParameters(self) -> List[str]: + return ["hue"] + + def manipulateImage(self, image: ImageContainer, parameters: Any) -> None: + if image is None or image.getImage() is None: + return + pil_img = image.getImage() + cv_img = pil_to_cv2(pil_img) + hsv = cv2.cvtColor(cv_img, cv2.COLOR_BGR2HSV).astype(np.uint8) + hue_delta = 50 + if isinstance(parameters, dict) and parameters.get("hue") is not None: + hue_delta = int(parameters.get("hue")) + h, s, v = cv2.split(hsv) + # OpenCV H range is [0,179]; wrap around using modulo + h = ((h.astype(np.int16) + hue_delta) % 180).astype(np.uint8) + hsv_shifted = cv2.merge([h, s, v]) + bgr = cv2.cvtColor(hsv_shifted, cv2.COLOR_HSV2BGR) + image._imageData = cv2_to_pil(bgr) + + diff --git a/src/ImageManipulation/ImageManipulation.py b/src/ImageManipulation/ImageManipulation.py index 909cd3c..caef028 100644 --- a/src/ImageManipulation/ImageManipulation.py +++ b/src/ImageManipulation/ImageManipulation.py @@ -1,39 +1,38 @@ -from abc import ABC, abstractmethod -from typing import Any - -from ImageContainer import ImageContainer - - -class ImageManipulation(ABC): - """Abstract base class for image manipulation operations.""" - - @abstractmethod - def manipulateImage(self, image: ImageContainer, parameters: Any) -> None: - """ - Manipulate the given image using the provided parameters. - - Args: - image: The image to manipulate - parameters: Parameters for the manipulation - """ - pass - - @abstractmethod - def getParameters(self) -> list[str]: - """ - Get the list of parameters required for this manipulation. - - Returns: - List of parameter names - """ - pass - - @abstractmethod - def getManipulationName(self) -> str: - """ - Get the name of this manipulation operation. - - Returns: - The name of the manipulation - """ - pass +from abc import ABC, abstractmethod +from typing import Any +from ImageContainer import ImageContainer + + +class ImageManipulation(ABC): + """Abstract base class for image manipulation operations.""" + + @abstractmethod + def manipulateImage(self, image: ImageContainer, parameters: Any) -> None: + """ + Manipulate the given image using the provided parameters. + + Args: + image: The image to manipulate + parameters: Parameters for the manipulation + """ + pass + + @abstractmethod + def getParameters(self) -> list[str]: + """ + Get the list of parameters required for this manipulation. + + Returns: + List of parameter names + """ + pass + + @abstractmethod + def getManipulationName(self) -> str: + """ + Get the name of this manipulation operation. + + Returns: + The name of the manipulation + """ + pass diff --git a/src/ImageManipulation/ManipulationList.py b/src/ImageManipulation/ManipulationList.py index dcc9d23..3cee483 100644 --- a/src/ImageManipulation/ManipulationList.py +++ b/src/ImageManipulation/ManipulationList.py @@ -1,4 +1,31 @@ -from ImageManipulation.CropImage import * - -def GetImageManipulationList() -> list: - return [CropImage()] +from .CropImage import CropImage +from .ResizeImage import ResizeImage +from .RotateImage import RotateImage +from .FlipImage import FlipImage +from .ColorAdjust import ColorAdjust +from .Filters import GaussianBlur, SobelEdge, BinaryThreshold, HistogramThreshold +from .Padding import Padding +from .Grayscale import Grayscale +from .HSV import HSV +from .HueShift import HueShift +from .BoxBlur import BoxBlur +from .CopyImage import CopyImage + +def GetImageManipulationList() -> list: + return [ + CropImage(), + ResizeImage(), + RotateImage(), + FlipImage(), + ColorAdjust(), + Padding(), + Grayscale(), + HSV(), + HueShift(), + BoxBlur(), + CopyImage(), + GaussianBlur(), + SobelEdge(), + BinaryThreshold(), + HistogramThreshold(), + ] \ No newline at end of file diff --git a/src/ImageManipulation/Padding.py b/src/ImageManipulation/Padding.py new file mode 100644 index 0000000..1dbd7ea --- /dev/null +++ b/src/ImageManipulation/Padding.py @@ -0,0 +1,33 @@ +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 Padding(ImageManipulation): + def getManipulationName(self) -> str: + return "Padding" + + def getParameters(self) -> List[str]: + return ["border_width"] + + def manipulateImage(self, image: ImageContainer, parameters: Any) -> None: + if image is None or image.getImage() is None: + return + pil_img = image.getImage() + cv_img = pil_to_cv2(pil_img) + border_width = 50 + if isinstance(parameters, dict) and parameters.get("border_width") is not None: + border_width = int(parameters.get("border_width")) + padded = cv2.copyMakeBorder( + cv_img, + top=border_width, + bottom=border_width, + left=border_width, + right=border_width, + borderType=cv2.BORDER_REFLECT, + ) + image._imageData = cv2_to_pil(padded) + + diff --git a/src/ImageManipulation/ResizeImage.py b/src/ImageManipulation/ResizeImage.py new file mode 100644 index 0000000..561e57f --- /dev/null +++ b/src/ImageManipulation/ResizeImage.py @@ -0,0 +1,31 @@ +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 ResizeImage(ImageManipulation): + def getManipulationName(self) -> str: + return "Resize" + + def getParameters(self) -> List[str]: + return ["width", "height"] + + def manipulateImage(self, image: ImageContainer, parameters: Any) -> None: + if image is None or image.getImage() is None: + return + pil_img = image.getImage() + width = None + height = None + if isinstance(parameters, dict): + width = parameters.get("width") + height = parameters.get("height") + if not width or not height: + # Default: scale to half + w, h = pil_img.size + width, height = max(1, w // 2), max(1, h // 2) + cv_img = pil_to_cv2(pil_img) + resized = cv2.resize(cv_img, (int(width), int(height)), interpolation=cv2.INTER_AREA) + image._imageData = cv2_to_pil(resized) + diff --git a/src/ImageManipulation/RotateImage.py b/src/ImageManipulation/RotateImage.py new file mode 100644 index 0000000..3c4f973 --- /dev/null +++ b/src/ImageManipulation/RotateImage.py @@ -0,0 +1,28 @@ +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 RotateImage(ImageManipulation): + def getManipulationName(self) -> str: + return "Rotate" + + def getParameters(self) -> List[str]: + return ["angle"] + + def manipulateImage(self, image: ImageContainer, parameters: Any) -> None: + if image is None or image.getImage() is None: + return + pil_img = image.getImage() + angle = 90 + if isinstance(parameters, dict) and parameters.get("angle") is not None: + angle = int(parameters.get("angle")) + cv_img = pil_to_cv2(pil_img) + (h, w) = cv_img.shape[:2] + center = (w // 2, h // 2) + M = cv2.getRotationMatrix2D(center, angle, 1.0) + rotated = cv2.warpAffine(cv_img, M, (w, h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT) + image._imageData = cv2_to_pil(rotated) + diff --git a/src/Main.py b/src/Main.py index d79ee9f..d7ecdd7 100644 --- a/src/Main.py +++ b/src/Main.py @@ -1,6 +1,13 @@ -#!/usr/bin/python -from GUI import GUI - -if __name__ == "__main__": - gui = GUI() - gui.initialise() \ No newline at end of file +#!/usr/bin/python +import os +import sys + +CURRENT_DIR = os.path.dirname(__file__) +if CURRENT_DIR not in sys.path: + sys.path.insert(0, CURRENT_DIR) + +from GUI import GUI + +if __name__ == "__main__": + gui = GUI() + gui.initialise() diff --git a/src/utils/image_utils.py b/src/utils/image_utils.py new file mode 100644 index 0000000..a53f3da --- /dev/null +++ b/src/utils/image_utils.py @@ -0,0 +1,44 @@ +import cv2 +import numpy as np +from PIL import Image + + +def pil_to_cv2(pil_image: Image.Image) -> np.ndarray: + """Convert PIL Image to OpenCV image (always BGR 3-channel when color).""" + mode = pil_image.mode + arr = np.array(pil_image) + + # Handle grayscale + if mode in ("1", "L"): + # arr is 2D. Convert to 3-channel BGR for downstream ops expecting color + return cv2.cvtColor(arr, cv2.COLOR_GRAY2BGR) + + # Handle images with alpha channel by dropping alpha for processing + if mode in ("LA", "RGBA"): + # Convert to RGB first + pil_rgb = pil_image.convert("RGB") + arr = np.array(pil_rgb) + return cv2.cvtColor(arr, cv2.COLOR_RGB2BGR) + + # Assume RGB-like + if arr.ndim == 3 and arr.shape[2] == 3: + return cv2.cvtColor(arr, cv2.COLOR_RGB2BGR) + + # Fallback: if still single-channel, expand to BGR + if arr.ndim == 2: + return cv2.cvtColor(arr, cv2.COLOR_GRAY2BGR) + + return arr + + +def cv2_to_pil(cv_image: np.ndarray) -> Image.Image: + """Convert OpenCV image (BGR or GRAY) to PIL Image (RGB or L).""" + if cv_image.ndim == 2: + return Image.fromarray(cv_image) + rgb = cv2.cvtColor(cv_image, cv2.COLOR_BGR2RGB) + return Image.fromarray(rgb) + + +def clamp_int(value: float, min_value: int = 0, max_value: int = 255) -> int: + return int(max(min_value, min(max_value, round(value)))) +