transferring functionalities from main.py to src

This commit is contained in:
vb
2025-10-20 14:37:49 +02:00
parent 8f8f4b11ce
commit 2e168f408e
18 changed files with 767 additions and 187 deletions

View File

@@ -3,6 +3,7 @@ from tkinter import filedialog
from PIL import ImageTk
from ImageContainer import ImageContainer
from ImageManipulation.ManipulationList import *
from functools import partial
class GUI:
@@ -45,22 +46,54 @@ class GUI:
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="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=lambda: manipulation.manipulateImage())
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
image_label = tk.Label(imgframe, width=500, height=500, bg="white")
image_label.pack(expand=True)
self._imageLabel = tk.Label(imgframe, width=500, height=500, bg="white")
self._imageLabel.pack(expand=True)
self._root = root
# Key bindings
root.bind_all('<Control-z>', lambda event: self._undo())
root.mainloop()
@@ -73,3 +106,41 @@ class GUI:
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()

View File

@@ -4,6 +4,7 @@ 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.
@@ -14,12 +15,37 @@ class ImageContainer:
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
self._imageData = Image.open(path)
# PIL expects (width, height)
self._imageData = self._imageData.resize((width, height), Image.LANCZOS)
_path = path
self._path = path
self._history = []
print("Opened image:", path)
def getDimensions(self) -> tuple[int,int]:
return self._imageData.shape
# 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()

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
from ImageManipulation.ImageManipulation import ImageManipulation
from .ImageManipulation import ImageManipulation
from ImageContainer import ImageContainer
from typing import Any, List
from PIL import Image
class CropImage(ImageManipulation):
@@ -18,7 +19,36 @@ class CropImage(ImageManipulation):
image: The image to manipulate
parameters: Parameters for the manipulation (e.g., crop coordinates)
"""
pass
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]:
"""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
from abc import ABC, abstractmethod
from typing import Any
from ImageContainer import ImageContainer

View File

@@ -1,4 +1,31 @@
from ImageManipulation.CropImage import *
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()]
return [
CropImage(),
ResizeImage(),
RotateImage(),
FlipImage(),
ColorAdjust(),
Padding(),
Grayscale(),
HSV(),
HueShift(),
BoxBlur(),
CopyImage(),
GaussianBlur(),
SobelEdge(),
BinaryThreshold(),
HistogramThreshold(),
]

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,11 @@
#!/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__":

44
src/utils/image_utils.py Normal file
View File

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