diff --git a/OTAnalytics/plugin_ui/application.py b/OTAnalytics/plugin_ui/application.py index c12bcf021..e264712a3 100644 --- a/OTAnalytics/plugin_ui/application.py +++ b/OTAnalytics/plugin_ui/application.py @@ -1,12 +1,10 @@ -import tkinter from abc import abstractmethod from pathlib import Path import customtkinter -from customtkinter import CTk, CTkButton +from customtkinter import CTk from OTAnalytics.application.datastore import Datastore -from OTAnalytics.domain.section import Coordinate, LineSection, Section from OTAnalytics.domain.track import CalculateTrackClassificationByMaxConfidence from OTAnalytics.plugin_parser.otvision_parser import ( OtEventListParser, @@ -14,6 +12,10 @@ OttrkParser, OttrkVideoParser, ) +from OTAnalytics.plugin_ui.constants import PADX, STICKY +from OTAnalytics.plugin_ui.frame_canvas import FrameCanvas, TrackImage +from OTAnalytics.plugin_ui.frame_sections import FrameSections +from OTAnalytics.plugin_ui.frame_tracks import FrameTracks class OTAnalyticsApplication: @@ -48,63 +50,35 @@ def __init__(self, datastore: Datastore, app: CTk = CTk()) -> None: super().__init__(datastore) self._app: CTk = app - def _load_tracks_in_file(self) -> None: - track_file = Path("") # TODO read from file chooser - self._datastore.load_track_file(file=track_file) - - def _load_sections_in_file(self) -> None: - section_file = Path("") # TODO read from file chooser - self._datastore.load_section_file(file=section_file) - - def _save_sections_to_file(self) -> None: - section_file = Path("") # TODO read from file choser - self._datastore.save_section_file(file=section_file) - def start_internal(self) -> None: self._show_gui() def _show_gui(self) -> None: customtkinter.set_appearance_mode("System") - customtkinter.set_default_color_theme("blue") - - self._app.geometry("800x600") + customtkinter.set_default_color_theme("green") - self._add_track_loader() - self._add_section_loader() + self._app.title("OTAnalytics") - self._app.mainloop() - - def _add_track_loader(self) -> None: - button = CTkButton( - master=self._app, - text="Read tracks", - command=self._load_tracks_in_file, - ) - button.place(relx=0.25, rely=0.5, anchor=tkinter.CENTER) - - def _add_section_loader(self) -> None: - button = CTkButton( - master=self._app, - text="Read sections", - command=self._load_sections_in_file, + self._get_widgets() + self._place_widgets() + image = TrackImage( + Path(r"tests/data/Testvideo_Cars-Cyclist_FR20_2020-01-01_00-00-00.mp4") ) - button.place(relx=0.5, rely=0.5, anchor=tkinter.CENTER) + self.frame_canvas.add_image(image) + self._app.mainloop() - def _add_section_button(self) -> None: - button = CTkButton( - master=self._app, - text="Add sections", - command=self._add_section, - ) - button.place(relx=0.75, rely=0.5, anchor=tkinter.CENTER) + def _get_widgets(self) -> None: + self.frame_canvas = FrameCanvas(master=self._app) + self.frame_tracks = FrameTracks(master=self._app, datastore=self._datastore) + self.frame_sections = FrameSections(master=self._app) - def _add_section(self) -> None: - section: Section = LineSection( - id="north", - start=Coordinate(0, 1), - end=Coordinate(2, 3), + def _place_widgets(self) -> None: + PADY = 10 + self.frame_canvas.grid( + row=0, column=0, rowspan=2, padx=PADX, pady=PADY, sticky=STICKY ) - self._datastore.add_section(section) + self.frame_tracks.grid(row=0, column=1, padx=PADX, pady=PADY, sticky=STICKY) + self.frame_sections.grid(row=1, column=1, padx=PADX, pady=PADY, sticky=STICKY) class ApplicationStarter: diff --git a/OTAnalytics/plugin_ui/constants.py b/OTAnalytics/plugin_ui/constants.py new file mode 100644 index 000000000..e96986a79 --- /dev/null +++ b/OTAnalytics/plugin_ui/constants.py @@ -0,0 +1,3 @@ +PADX = 10 +PADY = 5 +STICKY = "NESW" diff --git a/OTAnalytics/plugin_ui/frame_canvas.py b/OTAnalytics/plugin_ui/frame_canvas.py new file mode 100644 index 000000000..a563afa8f --- /dev/null +++ b/OTAnalytics/plugin_ui/frame_canvas.py @@ -0,0 +1,75 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import customtkinter +from customtkinter import CTkCanvas, CTkFrame +from moviepy.editor import VideoFileClip +from PIL import Image, ImageTk + +from OTAnalytics.plugin_ui.constants import PADX, STICKY + + +@dataclass +class TrackImage: + path: Path + + def load_image(self) -> Any: + video = VideoFileClip(str(self.path)) + return video.get_frame(0) + + def width(self) -> int: + return self.pillow_image.width + + def height(self) -> int: + return self.pillow_image.height + + def convert_image(self) -> None: + self.pillow_image = Image.fromarray(self.load_image()) + + def create_photo(self) -> ImageTk.PhotoImage: + self.convert_image() + self.pillow_photo_image = ImageTk.PhotoImage(image=self.pillow_image) + return self.pillow_photo_image + + +class FrameCanvas(CTkFrame): + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._get_widgets() + self._place_widgets() + + def _get_widgets(self) -> None: + self.canvas_background = CanvasBackground(master=self) + + def _place_widgets(self) -> None: + PADY = 10 + self.canvas_background.grid( + row=0, column=0, padx=PADX, pady=PADY, sticky=STICKY + ) + + def add_image(self, image: TrackImage) -> None: + self.canvas_background.add_image(image) + PADX = 10 + PADY = 5 + STICKY = "NESW" + self.canvas_background.grid( + row=0, column=0, padx=PADX, pady=PADY, sticky=STICKY + ) + + +class CanvasBackground(CTkCanvas): + def __init__(self, *args: Any, **kwargs: Any): + super().__init__(*args, **kwargs) + + def add_image(self, image: TrackImage) -> None: + self.create_image(0, 0, image=image.create_photo(), anchor=customtkinter.NW) + self.config(width=image.width(), height=image.height()) + + def show_rectangle(self) -> None: + self.create_rectangle(10, 10, 70, 70) + + def on_click(self, event: Any) -> None: + x = event.x + y = event.y + print(f"Canvas clicked at x={x} and y={y}") diff --git a/OTAnalytics/plugin_ui/frame_sections.py b/OTAnalytics/plugin_ui/frame_sections.py new file mode 100644 index 000000000..beb984c7c --- /dev/null +++ b/OTAnalytics/plugin_ui/frame_sections.py @@ -0,0 +1,201 @@ +from tkinter import Listbox +from tkinter.filedialog import askopenfilename, asksaveasfilename +from tkinter.ttk import Treeview +from typing import Any + +from customtkinter import CTkButton, CTkFrame, CTkLabel + +from OTAnalytics.plugin_ui.constants import PADX, PADY, STICKY +from OTAnalytics.plugin_ui.toplevel_sections import ToplevelSections + + +class FrameSections(CTkFrame): + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._get_widgets() + self._place_widgets() + + def _get_widgets(self) -> None: + self.label = CTkLabel(master=self, text="Sections") + self.listbox_sections = TreeviewSections(master=self) + self.button_load_sections = ButtonLoadSections( + master=self, + text="Load", + ) + self.button_save_sections = ButtonSaveSections(master=self, text="Save") + self.button_new_section = ButtonNewSection(master=self, text="New") + self.button_delete_selected_sections = ButtonDeleteSelectedSections( + master=self, text="Remove" + ) + self.button_edit_geometry_selected_section = ( + ButtonUpdateSelectedSectionGeometry(master=self, text="Edit geometry") + ) + self.button_edit_metadata_selected_section = ( + ButtonUpdateSelectedSectionMetadata(master=self, text="Edit metadata") + ) + + def _place_widgets(self) -> None: + self.label.grid(row=0, column=0, padx=PADX, pady=PADY, sticky=STICKY) + self.button_load_sections.grid( + row=1, column=0, padx=PADX, pady=PADY, sticky=STICKY + ) + self.button_save_sections.grid( + row=2, column=0, padx=PADX, pady=PADY, sticky=STICKY + ) + self.listbox_sections.grid(row=3, column=0, padx=PADX, pady=PADY, sticky=STICKY) + self.button_new_section.grid( + row=4, column=0, padx=PADX, pady=PADY, sticky=STICKY + ) + self.button_edit_geometry_selected_section.grid( + row=5, column=0, padx=PADX, pady=PADY, sticky=STICKY + ) + self.button_edit_metadata_selected_section.grid( + row=6, column=0, padx=PADX, pady=PADY, sticky=STICKY + ) + self.button_delete_selected_sections.grid( + row=7, column=0, padx=PADX, pady=PADY, sticky=STICKY + ) + + +class TreeviewSections(Treeview): + def __init__(self, **kwargs: Any) -> None: + super().__init__(show="tree", **kwargs) + self.bind("", self._deselect_sections) + self._define_columns() + # This call should come from outside later + sections = ["North", "West", "South", "East"] + self.add_sections(sections=sections) + + def _define_columns(self) -> None: + self["columns"] = "Section" + self.column(column="#0", width=0) + self.column(column="Section", anchor="center", width=80, minwidth=40) + self["displaycolumns"] = "Section" + + def add_sections(self, sections: list[str]) -> None: + for id, section in enumerate(sections): + self.insert(parent="", index="end", iid=str(id), text="", values=[section]) + + def _deselect_sections(self, event: Any) -> None: + for item in self.selection(): + self.selection_remove(item) + + +class ListboxSections(Listbox): + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + + # This call should come from outside later + sections = ["North", "West", "South", "East"] + self.show(sections=sections) + + def show(self, sections: list[str]) -> None: + for i, section in enumerate(sections): + self.insert(i, section) + + +class ButtonLoadSections(CTkButton): + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + + self.bind("", self.on_click) + + def on_click(self, events: Any) -> None: + self.sections_file = askopenfilename( + title="Load sections file", filetypes=[("sections file", "*.otflow")] + ) + print(f"Sections file to load: {self.sections_file}") + + +class ButtonSaveSections(CTkButton): + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + + self.bind("", self.on_click) + + def on_click(self, events: Any) -> None: + self.sections_file = asksaveasfilename( + title="Load sections file", filetypes=[("sections file", "*.otflow")] + ) + print(f"Sections file to save: {self.sections_file}") + + +class ButtonNewSection(CTkButton): + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + + self.toplevel_sections: ToplevelSections | None = None + + self.bind("", self.on_click) + + self.toplevel_sections = None + + def on_click(self, events: Any) -> None: + # TODO: Enter drawing mode + self.get_metadata() + # TODO: Yield geometry and metadata + print( + "Add new section with geometry = and" + + f"metadata = {self.section_metadata}" + ) + + def get_metadata(self) -> None: + if self.toplevel_sections is None or not self.toplevel_sections.winfo_exists(): + self.toplevel_sections = ToplevelSections(title="New section") + else: + self.toplevel_sections.focus() + self.section_metadata = self.toplevel_sections.show() + + +class ButtonUpdateSelectedSectionGeometry(CTkButton): + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + + self.bind("", self.on_click) + + def on_click(self, events: Any) -> None: + # TODO: Make sure only one section is selected + # TODO: Get currently selected section + # TODO: Enter drawing mode (there, old section is deleted, first) + # TODO: Yield updated geometry + print("Update geometry of selected section") + + +class ButtonUpdateSelectedSectionMetadata(CTkButton): + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + + self.toplevel_sections: ToplevelSections | None = None + + self.bind("", self.on_click) + + self.toplevel_sections = None + + def on_click(self, events: Any) -> None: + # TODO: Make sure only one section is selected + # TODO: Get currently selected section + self.get_metadata() + # TODO: Yield updated metadata + print(f"Update selected section with metadata={self.section_metadata}") + + def get_metadata(self) -> None: + # TODO: Retrieve sections metadata via ID from selection in Treeview + INPUT_VALUES: dict = {"name": "Existing Section"} + if self.toplevel_sections is None or not self.toplevel_sections.winfo_exists(): + self.toplevel_sections = ToplevelSections( + title="New section", input_values=INPUT_VALUES + ) + else: + self.toplevel_sections.focus() + self.section_metadata = self.toplevel_sections.show() + + +class ButtonDeleteSelectedSections(CTkButton): + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + + self.bind("", self.on_click) + + def on_click(self, events: Any) -> None: + # TODO: Get currently selected sections (?) + print("Delete selected sections") diff --git a/OTAnalytics/plugin_ui/frame_tracks.py b/OTAnalytics/plugin_ui/frame_tracks.py new file mode 100644 index 000000000..2a1247bb7 --- /dev/null +++ b/OTAnalytics/plugin_ui/frame_tracks.py @@ -0,0 +1,37 @@ +from pathlib import Path +from tkinter.filedialog import askopenfilename +from typing import Any + +from customtkinter import CTkButton, CTkFrame, CTkLabel + +from OTAnalytics.application.datastore import Datastore +from OTAnalytics.plugin_ui.constants import PADX, PADY, STICKY + + +class FrameTracks(CTkFrame): + def __init__(self, datastore: Datastore, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._datastore = datastore + self._get_widgets() + self._place_widgets() + + def _get_widgets(self) -> None: + self.label = CTkLabel(master=self, text="Tracks") + self.button_load_tracks = CTkButton( + master=self, + text="Load tracks", + command=self._load_tracks_in_file, + ) + + def _place_widgets(self) -> None: + self.label.grid(row=0, column=0, padx=PADX, pady=PADY, sticky=STICKY) + self.button_load_tracks.grid( + row=1, column=0, padx=PADX, pady=PADY, sticky=STICKY + ) + + def _load_tracks_in_file(self) -> None: + track_file = askopenfilename( + title="Load tracks file", filetypes=[("tracks file", "*.ottrk")] + ) + print(f"Tracks file to load: {track_file}") + self._datastore.load_track_file(file=Path(track_file)) diff --git a/OTAnalytics/plugin_ui/scripted_canvas.py b/OTAnalytics/plugin_ui/scripted_canvas.py new file mode 100644 index 000000000..051557a4c --- /dev/null +++ b/OTAnalytics/plugin_ui/scripted_canvas.py @@ -0,0 +1,67 @@ +from typing import Any + +import customtkinter +from customtkinter import CTk, CTkCanvas, CTkFrame +from moviepy.editor import VideoFileClip +from PIL import Image, ImageTk + + +class DummyImage: + def load_image(self) -> Any: + video = VideoFileClip( + r"tests/data/Testvideo_Cars-Cyclist_FR20_2020-01-01_00-00-00.mp4" + ) + image = video.get_frame(0) + return image + + def width(self) -> int: + return self.pillow_image.width + + def height(self) -> int: + return self.pillow_image.height + + def convert_image(self) -> None: + self.pillow_image = Image.fromarray(self.load_image()) + + def create_photo(self) -> ImageTk.PhotoImage: + self.convert_image() + self.pillow_photo_image = ImageTk.PhotoImage(image=self.pillow_image) + return self.pillow_photo_image + + +class DummyCanvas(CTkCanvas): + def __init__(self, *args: Any, **kwargs: Any): + super().__init__(*args, **kwargs) + + def add_image(self, dummy_image: DummyImage) -> None: + self.create_image( + 0, 0, image=dummy_image.create_photo(), anchor=customtkinter.NW + ) + self.config(width=dummy_image.width(), height=dummy_image.height()) + + +class DummyFrame(CTkFrame): + def __init__(self, **kwargs: Any): + super().__init__(**kwargs) + self.canvas = DummyCanvas(master=self.master) + + def add_image(self, dummy_image: DummyImage) -> None: + self.canvas.add_image(dummy_image) + PADX = 10 + PADY = 5 + STICKY = "NSEW" + self.canvas.grid(row=0, column=0, padx=PADX, pady=PADY, sticky=STICKY) + + +class Dummy: + def __init__(self) -> None: + self.app = CTk() + + def run(self) -> None: + dummy_image = DummyImage() + dummy_frame = DummyFrame(master=self.app) + dummy_frame.add_image(dummy_image) + self.app.mainloop() + + +Dummy().run() diff --git a/OTAnalytics/plugin_ui/toplevel_sections.py b/OTAnalytics/plugin_ui/toplevel_sections.py new file mode 100644 index 000000000..c81852a8f --- /dev/null +++ b/OTAnalytics/plugin_ui/toplevel_sections.py @@ -0,0 +1,39 @@ +from typing import Any + +from customtkinter import CTkButton, CTkEntry, CTkLabel, CTkToplevel + +from OTAnalytics.plugin_ui.constants import PADX, PADY, STICKY + + +class ToplevelSections(CTkToplevel): + def __init__( + self, title: str, input_values: dict | None = None, **kwargs: Any + ) -> None: + super().__init__(**kwargs) + self.title(title) + self.input_values: dict = {"name": ""} if input_values is None else input_values + self.protocol("WM_DELETE_WINDOW", self.close) + self._get_widgets() + self._place_widgets() + + def _get_widgets(self) -> None: + self.label_name = CTkLabel(master=self, text="Name:") + self.entry_name = CTkEntry(master=self) + self.entry_name.insert(0, self.input_values["name"]) + self.button_ok = CTkButton(master=self, text="Ok", command=self.close) + + def _place_widgets(self) -> None: + self.label_name.grid(row=0, column=0, padx=PADX, pady=PADY, sticky="E") + self.entry_name.grid(row=0, column=1, padx=PADX, pady=PADY, sticky="W") + self.button_ok.grid( + row=1, column=0, columnspan=2, padx=PADX, pady=PADY, sticky=STICKY + ) + + def close(self) -> None: + self.input_values["name"] = self.entry_name.get() + self.destroy() + self.update() + + def show(self) -> dict: + self.wait_window() + return self.input_values