10 Commits

Author SHA1 Message Date
Kim André Bjørkede
e0b75b5796 Update signature of manipulateImage to return an ImageContainer 2025-10-27 10:59:19 +01:00
Kim André Bjørkede
60cd4e5636 Add sequence diagram 2025-10-27 10:59:01 +01:00
Kim André Bjørkede
1e535938b4 Update CONTRIBUTING.MD to include UML updating 2025-10-27 10:52:03 +01:00
Kim André Bjørkede
a85142c1cb Fix return signature for getImage 2025-10-27 10:50:41 +01:00
Kim André Bjørkede
53c577d591 Remove unecessary comments
This is really ugly when working with Git
2025-10-27 10:50:21 +01:00
Kim André Bjørkede
c393947296 Fix direct access to private member 2025-10-27 10:49:57 +01:00
Kim André Bjørkede
c642945176 Update UML class diagram 2025-10-27 10:49:39 +01:00
Kim André Bjørkede
17d0b4b8c0 Add CONTRIBUTING.md 2025-10-23 11:58:40 +02:00
Kim André Bjørkede
cfadd5245a Update PyCharm run configuration and move project over to Git LFS 2025-10-23 11:27:55 +02:00
viljarb0
b1f9d0d8c1 Update README.md 2025-10-20 22:03:16 +02:00
17 changed files with 101 additions and 260 deletions

3
.gitattributes vendored Normal file
View File

@@ -0,0 +1,3 @@
*.jpg filter=lfs diff=lfs merge=lfs -text
*.png filter=lfs diff=lfs merge=lfs -text
*.jpeg filter=lfs diff=lfs merge=lfs -text

View File

@@ -1,7 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
</content>
<orderEntry type="jdk" jdkName="ikt213" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>

View File

@@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run project" type="PythonConfigurationType" factoryName="Python">
<configuration default="false" name="Run Project" type="PythonConfigurationType" factoryName="Python">
<module name="assignment_2_improved" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
@@ -9,12 +9,12 @@
</envs>
<option name="SDK_HOME" value="" />
<option name="SDK_NAME" value="ikt213" />
<option name="WORKING_DIRECTORY" value="." />
<option name="WORKING_DIRECTORY" value="$Projectpath$" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/src/Main.py" />
<option name="SCRIPT_NAME" value="Main.py" />
<option name="PARAMETERS" value="" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />

25
.idea/workspace.xml generated
View File

@@ -5,8 +5,11 @@
</component>
<component name="ChangeListManager">
<list default="true" id="5e47ddf2-717f-442d-8925-70fd842ea97b" name="Changes" comment="">
<change beforePath="$PROJECT_DIR$/.idea/assignment_2_improved.iml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/assignment_2_improved.iml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/runConfigurations/Run_project.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/runConfigurations/Run_Project.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/docs/UML/Class Diagram.puml" beforeDir="false" afterPath="$PROJECT_DIR$/docs/UML/Class Diagram.puml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/icon.png" beforeDir="false" afterPath="$PROJECT_DIR$/src/icon.png" afterDir="false" />
<change beforePath="$PROJECT_DIR$/main.py" beforeDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -34,24 +37,31 @@
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"ModuleVcsDetector.initialDetectionPerformed": "true",
"Python.Main.executor": "Coverage",
"Python.Main.executor": "Run",
"Python.Run project.executor": "Run",
"Python.demo.executor": "Run",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.git.unshallow": "true",
"git-widget-placeholder": "Refactor__and__add__documentation",
"com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1": "true",
"git-widget-placeholder": "main",
"junie.onboarding.icon.badge.shown": "true",
"last_opened_file_path": "/home/kim/Dokumenter/Universitet/Machine Vision/ikt213_project/src/Main.py",
"last_opened_file_path": "/home/kim/Dokumenter/Universitet/Machine Vision/ikt213_project/src",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"settings.editor.selected.configurable": "preferences.pluginManager",
"settings.editor.selected.configurable": "com.intellij.pycharm.community.ide.impl.configuration.PythonContentEntriesConfigurable",
"to.speed.mode.migration.done": "true",
"vue.rearranger.settings.migration": "true"
}
}]]></component>
<component name="RecentsManager">
<key name="MoveFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/src" />
<recent name="$PROJECT_DIR$" />
</key>
</component>
<component name="SharedIndexes">
<attachedChunks>
<set>
@@ -70,6 +80,7 @@
<workItem from="1757587376542" duration="189000" />
<workItem from="1760178939983" duration="46000" />
<workItem from="1760179006753" duration="6999000" />
<workItem from="1761202447788" duration="4986000" />
</task>
<servers />
</component>
@@ -78,7 +89,7 @@
</component>
<component name="com.intellij.coverage.CoverageDataManagerImpl">
<SUITE FILE_PATH="coverage/ikt213_project$demo.coverage" NAME="demo Coverage Results" MODIFIED="1760179584389" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$" />
<SUITE FILE_PATH="coverage/ikt213_project$Main.coverage" NAME="Main Coverage Results" MODIFIED="1760194900099" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$/src" />
<SUITE FILE_PATH="coverage/ikt213_project$Run_project.coverage" NAME="Run project Coverage Results" MODIFIED="1760197454610" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="" />
<SUITE FILE_PATH="coverage/ikt213_project$Run_project.coverage" NAME="Run project Coverage Results" MODIFIED="1761211029222" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$Sourcepath$" />
<SUITE FILE_PATH="coverage/ikt213_project$Main.coverage" NAME="Main Coverage Results" MODIFIED="1761211517199" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$Projectpath$" />
</component>
</project>

15
CONTRIBUTING.MD Normal file
View File

@@ -0,0 +1,15 @@
# Contribution Guide
## Making changes
Please, when making any changes, make a new branch. Start the branch name with one of the following texts:
- `feature/`
- `fix/`
- `docs/`
- `refactor/`
For example `docs/update-uml-diagrams`.
### UML
When changing anything in the code structure. You have to update the UML class diagram and relevant sequence diagrams.
The UML diagrams shall always reflect the current state of the codebase.

View File

@@ -1,14 +1,14 @@
# ikt213_project
## requirements
opencv2
pillow
numpy
* opencv2
* pillow
* numpy
## setup (python venv)
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
* python3 -m venv .venv
* source .venv/bin/activate
* pip install -r requirements.txt
## run
python src/Main.py

View File

@@ -22,6 +22,16 @@ abstract class ImageManipulation{
+str getManipulationName()
}
abstract class Filters{
list[ImageManipulation] getFilters()
}
class LiveFilters{
}
class PostFilters{
}
class CropImage{
- str _name
}
@@ -46,8 +56,28 @@ class HueShiftImage{
- str _name
}
class GeoFilter{
- str _name
}
class GeoLocation{
str getUserCurrentTown()
}
class FaceRecognition{
pair[int,int] getFaceCentre(ImageContainer)
}
GUI ..> ImageContainer
GUI ..> ImageManipulation
GUI ..> LiveFilters
GUI ..> PostFilters
LiveFilters ..|> Filters
PostFilters ..|> Filters
Filters ..> ImageManipulation
FaceRecognition ..> ImageContainer
GeoFilter ..> GeoLocation
CropImage ..|> ImageManipulation
GreyScaleImage ..|> ImageManipulation
@@ -55,6 +85,7 @@ SmoothImage ..|> ImageManipulation
RotateImage ..|> ImageManipulation
HSVImage ..|> ImageManipulation
HueShiftImage ..|> ImageManipulation
GeoFilter ..|> ImageManipulation
@enduml

View File

@@ -0,0 +1,14 @@
@startuml
'https://plantuml.com/sequence-diagram
Actor User
Boundary GUI
User --> GUI : User selects crop
GUI --> User : Dialogue box pops up asking for co-ordinates
User --> GUI : User specifies co-ordinates
GUI --> CropImage : manipulateImage()
CropImage --> GUI : New image is created and sent back to the GUI
GUI --> User : User sees new image
@enduml

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 130 B

BIN
icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

BIN
lena.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 131 B

217
main.py
View File

@@ -1,217 +0,0 @@
import os
import cv2
import numpy as np
import tkinter as tk
from tkinter import filedialog
from PIL import Image, ImageTk
OPENED_IMAGE = None
def padding(file_path, border_width):
image = cv2.imread(file_path)
if image is None:
print("Failed to load the image.")
return
padded_image = cv2.copyMakeBorder(
image,
top=border_width,
bottom=border_width,
left=border_width,
right=border_width,
borderType=cv2.BORDER_REFLECT
)
_cache_image(padded_image, file_path)
def crop(file_path):
image = cv2.imread(file_path)
height, width = image.shape[:2]
# crop 80 px from top & left, 130 px from bottom & right
x_0 = 80
x_1 = width - 130
y_0 = 80
y_1 = height - 130
cropped_image = image[y_0:y_1, x_0:x_1]
_cache_image(cropped_image, file_path)
def resize(file_path):
width = 200
height = 200
image = cv2.imread(file_path)
resized_image = cv2.resize(image, (width, height))
_cache_image(resized_image, file_path)
def copy(file_path):
image = cv2.imread(file_path)
height, width, channels = image.shape
emptyPictureArray = np.zeros((height, width, 3), dtype=np.uint8)
for y in range(height):
for x in range(width):
for c in range(channels):
emptyPictureArray[y, x, c] = image[y, x, c]
_cache_image(emptyPictureArray, file_path)
def grayscale(file_path):
image = cv2.imread(file_path)
gray_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
_cache_image(gray_image, file_path)
def hsv(file_path):
image = cv2.imread(file_path)
hsv_image = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
_cache_image(hsv_image, file_path)
def hue_shifted(file_path, hue=50):
image = cv2.imread(file_path)
height, width, channels = image.shape
emptyPictureArray = np.zeros((height, width, 3), dtype=np.uint8)
for y in range(height):
for x in range(width):
for c in range(channels):
new_value = image[y, x, c] + hue
# Clip the value to stay within [0, 255]
emptyPictureArray[y, x, c] = np.clip(new_value, 0, 255)
_cache_image(emptyPictureArray, file_path)
def smoothing(file_path):
image = cv2.imread(file_path)
smoothed_image = cv2.blur(image, ksize=(15, 15))
_cache_image(smoothed_image, file_path)
def rotation(file_path, rotation_angle=90):
image = cv2.imread(file_path)
if rotation_angle == 90:
rotated_image = cv2.rotate(image, cv2.ROTATE_90_CLOCKWISE)
elif rotation_angle == 180:
rotated_image = cv2.rotate(image, cv2.ROTATE_180)
else:
print("Invalid angle. Use 90 or 180.")
return
_cache_image(rotated_image, file_path)
def open_image():
"""
Function called from the menu to open an image.
"""
# Ask user to choose an image file
file_path = filedialog.askopenfilename(
filetypes=[("Image files", "*.jpg *.jpeg *.png *.gif *.bmp")]
)
if file_path:
_open_image(file_path)
def _cache_image(image, file_path):
opened_image_basename = os.path.basename(file_path)
opened_image_path = os.path.dirname(file_path)
newfname = "cache/" + opened_image_basename
cv2.imwrite(newfname, image)
print(f"Image cached as '{newfname}'.")
_open_image(newfname)
def _save_image(file_path):
image = cv2.imread(file_path)
opened_image_basename = os.path.basename(file_path)
opened_image_path = os.path.dirname(file_path)
newfname = opened_image_path + '/../' + opened_image_basename
cv2.imwrite(newfname, image)
print(f"Image saved as '{newfname}'.")
_open_image(newfname)
def _open_image(file_path):
"""
Open and display an image in the GUI.
Params:
- file_path: Path to the image file.
"""
# Open with cv2 to get dimensions
imgcv2 = cv2.imread(file_path)
height, width, channels = imgcv2.shape
# Open and resize image (optional)
img = Image.open(file_path)
img = img.resize((height, width), Image.LANCZOS) # Resize to fit frame
tk_img = ImageTk.PhotoImage(img)
# Update label inside frame with the correct dimensions
image_label.config(image=tk_img, height=height, width=width)
global OPENED_IMAGE
OPENED_IMAGE = file_path # Store opened image in global variable, to access later if needed
print("Opened image:", file_path)
if __name__ == "__main__":
# 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=open_image)
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="Test", menu=test_menu)
test_menu.add_command(label="Padding", command=lambda: padding(OPENED_IMAGE, border_width=50) if OPENED_IMAGE is not None else print("No image opened."))
test_menu.add_command(label="Crop", command=lambda: crop(OPENED_IMAGE) if OPENED_IMAGE is not None else print("No image opened."))
test_menu.add_command(label="Resize", command=lambda: resize(OPENED_IMAGE) if OPENED_IMAGE is not None else print("No image opened."))
test_menu.add_command(label="Copy", command=lambda: copy(OPENED_IMAGE) if OPENED_IMAGE is not None else print("No image opened."))
test_menu.add_command(label="Greyscale", command=lambda: grayscale(OPENED_IMAGE) if OPENED_IMAGE is not None else print("No image opened."))
test_menu.add_command(label="Hsv", command=lambda: hsv(OPENED_IMAGE) if OPENED_IMAGE is not None else print("No image opened."))
test_menu.add_command(label="Hue Shifted", command=lambda: hue_shifted(OPENED_IMAGE, hue=50) if OPENED_IMAGE is not None else print("No image opened."))
test_menu.add_command(label="Smoothed", command=lambda: smoothing(OPENED_IMAGE) if OPENED_IMAGE is not None else print("No image opened."))
test_menu.add_command(label="Rotated", command=lambda: rotation(OPENED_IMAGE, rotation_angle=90) if OPENED_IMAGE is not None else print("No image opened."))
clipboard_menu = tk.Menu(menu, tearoff=0)
menu.add_cascade(label="Clipboard", menu=clipboard_menu)
image_menu = tk.Menu(menu, tearoff=0)
menu.add_cascade(label="Image", menu=image_menu)
tools_menu = tk.Menu(menu, tearoff=0)
menu.add_cascade(label="Tools", menu=tools_menu)
shapes_menu = tk.Menu(menu, tearoff=0)
menu.add_cascade(label="Shapes", menu=shapes_menu)
colors_menu = tk.Menu(menu, tearoff=0)
menu.add_cascade(label="Colors", menu=colors_menu)
layer_menu = tk.Menu(menu, tearoff=0)
menu.add_cascade(label="Layer", menu=layer_menu)
filter_menu = tk.Menu(menu, tearoff=0)
menu.add_cascade(label="Filter", menu=filter_menu)
#
# 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()

View File

@@ -62,26 +62,6 @@ class GUI:
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()))
edit_menu = tk.Menu(menu, tearoff=0)
menu.add_cascade(label="Edit", menu=edit_menu)

View File

@@ -28,7 +28,7 @@ class ImageContainer:
# PIL Image.size returns (width, height)
return self._imageData.size if self._imageData is not None else (0, 0)
def getImage(self) -> Image:
def getImage(self) -> Image or None:
return self._imageData
def saveImage(self, path: str) -> None:

View File

@@ -19,10 +19,8 @@ class CropImage(ImageManipulation):
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
pil_image = image.getImage()
# If parameters provided as dict with width/height, do centered crop
if isinstance(parameters, dict) and "width" in parameters and "height" in parameters:

View File

@@ -7,13 +7,14 @@ class ImageManipulation(ABC):
"""Abstract base class for image manipulation operations."""
@abstractmethod
def manipulateImage(self, image: ImageContainer, parameters: Any) -> None:
def manipulateImage(self, image: ImageContainer, parameters: Any) -> ImageContainer:
"""
Manipulate the given image using the provided parameters.
Args:
image: The image to manipulate
parameters: Parameters for the manipulation
Returns: The manipulated image
"""
pass

3
src/icon.png Normal file
View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6eed3c9619d01b38005e4aec35623f40dffd930d15764fe54552ffb0f77c38a5
size 1331