From e03409fcd423c06b6613731473c1e2f3444bf878 Mon Sep 17 00:00:00 2001 From: qkinader Date: Wed, 28 Dec 2022 22:30:19 +0100 Subject: [PATCH] Initial commit --- README.md | 41 ++++++++++ Storage/.gitkeep | 0 classes.txt | 15 ++++ environment.yml | 16 ++++ src/boundingBox.py | 187 ++++++++++++++++++++++++++++++++++++++++++++ src/imageHandler.py | 46 +++++++++++ src/imageViewer.py | 57 ++++++++++++++ src/main.py | 5 ++ src/settings.py | 49 ++++++++++++ src/window.py | 56 +++++++++++++ 10 files changed, 472 insertions(+) create mode 100755 README.md create mode 100644 Storage/.gitkeep create mode 100644 classes.txt create mode 100644 environment.yml create mode 100644 src/boundingBox.py create mode 100644 src/imageHandler.py create mode 100644 src/imageViewer.py create mode 100755 src/main.py create mode 100644 src/settings.py create mode 100644 src/window.py diff --git a/README.md b/README.md new file mode 100755 index 0000000..9ad24e6 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# Real Video Labeling Tool Py +Python rewrite of [Real-Video-Annotation-Tool] + +## 1. Installation +Here are two options to setup the project. + +### 1.1.1 Setup with Conda +1. If [conda] is not yet installed, follow the [installation guide] for your operating system. + > Note: [miniconda] and especially [mamba] are faster and lighter than Anaconda +2. From the projects root directory create the environment with: + ``` + conda env create -f environment.yml + ``` +3. Activate the new environment with: + ``` + conda activate labeling_tool + ``` + > Note: The environment should always be activated when working on this project +4. To update the environment after changes in environment.yml run: +```conda env update --name labeling_tool --file environment.yml --prune``` + +#### Troubleshoot +- Make sure to restart your terminal after installing conda. Or run ```source ~/.bashrc``` +- Run ```conda config --set auto_activate_base false``` to deactivate automatic conda activation on startup +- If ```conda activate``` fails, try ```conda init``` and then ```conda activate``` again + +### 1.1.2 Install dependencies with pip +You can also install dependencies with pip: +```pip install -r requirements.txt``` + +### 1.2 Run +Run program with ```python3 src/main.py``` or from within IDE. +Images and Labels are stored in /Storage + + +## Note +[Real-Video-Annotation-Tool]: https://sam-dev.cs.hm.edu/SAM-DEV/Sampler_CNN_Training +[conda]: https://docs.conda.io/ +[installation guide]: https://docs.conda.io/projects/conda/en/latest/user-guide/install/index.html +[miniconda]: https://docs.conda.io/en/latest/miniconda.html +[mamba]: https://mamba.readthedocs.io/en/latest/installation.html#installation diff --git a/Storage/.gitkeep b/Storage/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/classes.txt b/classes.txt new file mode 100644 index 0000000..f0755a7 --- /dev/null +++ b/classes.txt @@ -0,0 +1,15 @@ +person +child +car +emergency +trafficlight +trafficlight_green +trafficlight_yellow +trafficlight_red +pit_in +pit_out +park_parallel +park_cross +overtaking_prohibited +overtaking_permitted +traffic_sign diff --git a/environment.yml b/environment.yml new file mode 100644 index 0000000..b9c8497 --- /dev/null +++ b/environment.yml @@ -0,0 +1,16 @@ +name: labeling_tool + +channels: + - conda-forge + - nodefaults + +dependencies: + - python==3.9 + - pip + - numpy==1.23.5 + - tk==8.6.12 + - pip: + - ttkbootstrap==1.10.0 + - opencv-contrib-python-headless==4.6.0.66 + - pillow==9.3.0 + - natsort==8.2.0 diff --git a/src/boundingBox.py b/src/boundingBox.py new file mode 100644 index 0000000..960ffca --- /dev/null +++ b/src/boundingBox.py @@ -0,0 +1,187 @@ +import cv2 +import random +import ttkbootstrap as ttk +from tkinter import StringVar, Frame, Label + + +def get_color() -> str: + de = ("%02x" % random.randint(0, 255)) + re = ("%02x" % random.randint(0, 255)) + we = ("%02x" % random.randint(0, 255)) + color = "#" + de + re + we + return color + + +class BoundingBox: + bboxes = [] + current = None + counter = 0 + current_index = 0 + classes = [(i.strip()) for i in open('classes.txt', 'r').readlines()] + classes = dict(zip(classes, range(0, len(classes) + 1))) + tracker = cv2.TrackerCSRT_create() + + @classmethod + def new_label(cls, canvas, frm_buttons): + BoundingBox(canvas, frm_buttons) + cls.current_index = len(cls.bboxes)-1 + cls.current = cls.bboxes[cls.current_index] + + @classmethod + def switch_box(cls, _): + try: + cls.current_index = (cls.current_index + 1) % len(cls.bboxes) + cls.current = cls.bboxes[cls.current_index] + cls.current.__set_current() + except ZeroDivisionError: + pass + + @classmethod + def move_current(cls, event): + cls.current.move(event) + + @classmethod + def motion_current(cls, event): + cls.current.motion(event) + + @classmethod + def track_boxes(cls, images): + tracker = cv2.TrackerCSRT_create() + for box in cls.bboxes: + bbox = (box.start_x, box.start_y, box.x - box.start_x, box.y - box.start_y) + tracker.init(images.previous_image, bbox) + + ok, bbox = tracker.update(images.images[images.current_index]) + + box.start_x = bbox[0] + box.start_y = bbox[1] + box.x = box.start_x + bbox[2] + box.y = box.start_y + bbox[3] + box.update_coords() + + @classmethod + def write_labels(cls, file): + filename = str(file) + filename = filename.replace(".jpg", ".txt") + with open(filename, "w") as file: + for box in cls.bboxes: + width = box.x - box.start_x + height = box.y - box.start_y + box_properties = [cls.classes[box.lbl_class], box.start_x, box.start_y, width, height] + file.writelines(", ".join(map(str, box_properties))) + file.write("\n") + + def __init__(self, canvas, frm_buttons): + self.canvas = canvas + self.frm_buttons = frm_buttons + + BoundingBox.counter += 1 + + self.lbl_class: str = next(iter(BoundingBox.classes)) + self.color: str = get_color() + + # Coordinates + self.start_x = 0 + self.start_y = 0 + self.x = 50 + self.y = 50 + + # Draw initial Rectangle with Label text + self.rect = self.canvas.create_rectangle(self.start_x, self.start_y, self.x, self.y, + outline=self.color, width=5) + self.text = self.canvas.create_text(self.start_x, self.start_y - 20, + text=f"{BoundingBox.counter}-{self.lbl_class}", + fill=self.color, font=('Arial', 30), anchor='w') + + # Create Box Selection Frame with Buttons (Select, Remove etc.) for Bounding Box + self.lbl_frame = Frame(self.frm_buttons, bg=self.color, width=200, height=80) + self.lbl_text = Label(self.lbl_frame, text=f"{BoundingBox.counter}-{self.lbl_class}", font=("Arial", 12)) + self.lbl_frame.bind("", self.__set_current_click) + self.__setup_box_buttons() + + self.__set_current() + BoundingBox.bboxes.append(self) + + def move(self, event, motion=False): + if motion: + self.x, self.y = (event.x, event.y) + elif event.num == 1: + self.start_x = event.x + self.start_y = event.y + return + elif event.keysym == "a": + self.start_x -= 1 # left + elif event.keysym == "s": + self.start_y += 1 # down + elif event.keysym == "w": + self.start_y -= 1 # up + elif event.keysym == "d": + self.start_x += 1 # right + elif event.keysym == "j": + self.x -= 1 # left + elif event.keysym == "k": + self.y += 1 # down + elif event.keysym == "i": + self.y -= 1 # up + elif event.keysym == "l": + self.x += 1 # right + self.update_coords() + + def motion(self, event): + self.move(event, motion=True) + + def update_coords(self): + self.canvas.coords(self.rect, self.start_x, self.start_y, self.x, self.y) + self.canvas.coords(self.text, self.start_x, self.start_y-20) + + def __setup_box_buttons(self): + self.lbl_frame.grid(pady=0, padx=2, sticky='ew') + self.lbl_frame.grid_propagate(True) + + # Bounding Box Title as number of box and box class + self.lbl_text.grid(row=0, column=0, columnspan=3, sticky='w') + + # Class Selection Dropdown + variable = StringVar(self.frm_buttons, self.lbl_class) + lbl_class_om = ttk.OptionMenu(self.lbl_frame, variable, self.lbl_class, *BoundingBox.classes.keys(), + command=self.change_class) + lbl_class_om.grid(row=1, column=0, pady=10, columnspan=3) + + # Buttons (Select, New Color and Remove) + select_btn = ttk.Button(self.lbl_frame, text='Select', command=self.__set_current) + select_btn.grid(row=2, column=0, pady=10) + + new_color = ttk.Button(self.lbl_frame, text='New Color', command=self.new_color) + new_color.grid(row=2, column=1, pady=10) + + rm_btn = ttk.Button(self.lbl_frame, text='Remove', command=self.remove) + rm_btn.grid(row=2, column=2, pady=10) + + self.new_color() + + def change_class(self, event): + self.lbl_class = event + self.lbl_text.configure(text=f"{BoundingBox.counter}-{self.lbl_class}") # TODO fix counter when changing class + self.canvas.itemconfig(self.text, text=f"{BoundingBox.counter}-{self.lbl_class}") + + def __set_current_click(self, _): + self.__set_current() + + def __set_current(self): + BoundingBox.current = self + for box in BoundingBox.bboxes: + box.lbl_frame.configure(highlightthickness=0) + box.canvas.itemconfigure(box.rect, width=3) + self.canvas.itemconfigure(self.rect, width=5) + self.lbl_frame.configure(highlightbackground="white", highlightcolor="white", highlightthickness=6) + + def new_color(self): + self.color = get_color() + self.canvas.itemconfig(self.rect, outline=self.color) + self.canvas.itemconfig(self.text, fill=self.color) + self.lbl_frame.configure(background=self.color, highlightbackground="white", highlightcolor="white") + + def remove(self): + self.lbl_frame.destroy() + self.canvas.delete(self.rect, self.text) + BoundingBox.bboxes = [box for box in BoundingBox.bboxes if box != self] diff --git a/src/imageHandler.py b/src/imageHandler.py new file mode 100644 index 0000000..5e02e81 --- /dev/null +++ b/src/imageHandler.py @@ -0,0 +1,46 @@ +import cv2 +import pathlib +from tkinter.filedialog import askopenfilename +from natsort import natsorted + +IMAGE_PATH = "Storage/" # TODO: Windows?? + + +class ImageHandler: + def __init__(self): + self.filepath = askopenfilename(filetypes=[("Text Files", "*.mp4"), ("All Files", "*.*")]) + self.image_path = IMAGE_PATH + self.video = cv2.VideoCapture(self.filepath) + + # Read Video with opencv + success, self.current_cv_read = self.video.read() + self.current_index = 0 + self.images = [self.current_cv_read] + self.previous_image = self.images[self.current_index] + + # Read first frame + self.write_img() + self.current_img = pathlib.Path(self.get_file_list()[self.current_index]) + + def write_img(self): + cv2.imwrite(f"{self.image_path}frame%d.jpg" % self.current_index, self.current_cv_read) + + def get_file_list(self): + return natsorted(list(pathlib.Path(self.image_path).glob("*.jpg"))) + + def next(self): + self.current_index += 1 + # If we want to read a new image + if self.current_index == len(self.images): + success, self.current_cv_read = self.video.read() + self.write_img() + self.images.append(self.current_cv_read) + + self.current_img = pathlib.Path(self.get_file_list()[self.current_index]) + self.previous_image = self.images[self.current_index - 1] + + def previous(self): + if self.current_index > 0: + self.current_index -= 1 + self.previous_image = self.images[self.current_index-1] + self.current_img = pathlib.Path(self.get_file_list()[self.current_index]) diff --git a/src/imageViewer.py b/src/imageViewer.py new file mode 100644 index 0000000..10e2648 --- /dev/null +++ b/src/imageViewer.py @@ -0,0 +1,57 @@ +import ttkbootstrap as ttk +from PIL import ImageTk, Image +import cv2 +from boundingBox import BoundingBox +from imageHandler import ImageHandler + + +class ImageViewer(ttk.Canvas): + def __init__(self, window, frame): + # Initialize and create Tkinter Canvas + super().__init__(frame, width=window.winfo_width(), height=window.winfo_height(), cursor="cross") + self.pack(side="top", expand=True) + + # Create boxes and Image Handler + self.image = ImageHandler() + + self.displayed_img = self.__get_current_photo_image() + self.image_container = self.create_image(0, 0, anchor="nw", image=self.displayed_img) + self.display_img() + + # Keyboard Bindings + self.focus_set() + window.bind("", self.forward) + window.bind("", self.backward) + self.bind("", BoundingBox.switch_box) + self.bind("", BoundingBox.move_current) + self.bind("", BoundingBox.motion_current) + self.bind("a", BoundingBox.move_current) + self.bind("s", BoundingBox.move_current) + self.bind("w", BoundingBox.move_current) + self.bind("d", BoundingBox.move_current) + self.bind("j", BoundingBox.move_current) + self.bind("k", BoundingBox.move_current) + self.bind("i", BoundingBox.move_current) + self.bind("l", BoundingBox.move_current) + + def __get_current_photo_image(self) -> ImageTk.PhotoImage: + rgb_image = cv2.cvtColor(self.image.current_cv_read, cv2.COLOR_BGR2RGB) + img = Image.fromarray(rgb_image) + return ImageTk.PhotoImage(img) + + def display_img(self): + # Opencv reads frames as bgr image, we need to convert it into rgb to display it correctly + rgb_image = cv2.cvtColor(self.image.images[self.image.current_index], cv2.COLOR_BGR2RGB) + img = Image.fromarray(rgb_image) + self.displayed_img = ImageTk.PhotoImage(img) + self.itemconfig(self.image_container, image=self.displayed_img) + + def forward(self, _): + BoundingBox.write_labels(file=self.image.current_img) + self.image.next() + self.display_img() + BoundingBox.track_boxes(self.image) + + def backward(self, _): + self.image.previous() + self.display_img() diff --git a/src/main.py b/src/main.py new file mode 100755 index 0000000..f7640df --- /dev/null +++ b/src/main.py @@ -0,0 +1,5 @@ +from window import Window + +if __name__ == "__main__": + window = Window() + window.mainloop() diff --git a/src/settings.py b/src/settings.py new file mode 100644 index 0000000..9e94927 --- /dev/null +++ b/src/settings.py @@ -0,0 +1,49 @@ +import cv2 +import ttkbootstrap as ttk +from tkinter import Toplevel, Frame, Label, IntVar +from boundingBox import BoundingBox + +""" +WIP +""" + + +class Settings(Toplevel): + def __init__(self, master=None): + Toplevel.__init__(self, master) + self.wm_title("Settings") + self.rowconfigure(0, minsize=480, weight=1) + self.columnconfigure(0, minsize=640, weight=1) + self.grab_set() + self.frame = Frame(self, padx=80, pady=40) + self.frame.pack(side='top') + + # Tracker type options + tracker_type_txt = Label(self.frame, text="Tracker Type (WIP)", font=('Arial', 14), pady=20) + tracker_type_txt.pack() + self.trackers = {"CSRT": 1, + "GOTURN": 2, # TODO + "KCF": 3} # TODO + + self.v2 = IntVar(self.frame, 1) + self.v2.trace_add('write', self.set_tracker) + for (text, value) in self.trackers.items(): + ttk.Radiobutton(self.frame, text=text, variable=self.v2, value=value).pack(side='top', anchor='w') + + # Label Revision Options + label_revision_txt = Label(self.frame, text="Label Revision (WIP)", font=('Arial', 14), pady=20) + label_revision_txt.pack() + revision_types = {"Overwrite Files (Tracker active)": 1, + "Add (Tracker only for new Boxes)": 2, # TODO + "Adjust (No Tracker)": 3} # TODO + v = IntVar(self.frame, 1) + for (text, value) in revision_types.items(): + ttk.Radiobutton(self.frame, text=text, variable=v, value=value).pack(side='top', anchor='w') + + def set_tracker(self, _): + cv_trackers = {1: cv2.TrackerCSRT_create(), + 2: cv2.TrackerGOTURN_create(), + 3: cv2.TrackerKCF_create()} + + BoundingBox.tracker = cv_trackers[self.v2.get()] + print(f"Tracker type changed to {BoundingBox.tracker}") diff --git a/src/window.py b/src/window.py new file mode 100644 index 0000000..d45ae70 --- /dev/null +++ b/src/window.py @@ -0,0 +1,56 @@ +import tkinter as tk +from tkinter import Menu +import ttkbootstrap as ttk +from imageViewer import ImageViewer +from settings import Settings +from boundingBox import BoundingBox + +IMAGE_SIZE_X = 960 +IMAGE_SIZE_Y = 1280 + + +class Window(ttk.Window): + def __init__(self): + super().__init__(themename="superhero") + + # Set window properties + self.wm_title("Labeling Tool") + self.rowconfigure(0, minsize=IMAGE_SIZE_X, weight=1) + self.columnconfigure(0, minsize=IMAGE_SIZE_Y, weight=1) + + # Set Menu Bar for Settings and opening Video file + self.__menu_bar() + + # Sidebar (Frame) for Buttons and BoundingBox Selection + frm_buttons = tk.Frame(self, relief=tk.RAISED, bd=1) + frm_buttons.grid(row=0, column=2, sticky="ns") + + # Image Frame to display Image as Canvas + frm_image = tk.Frame(self, relief=tk.RAISED) + frm_image.grid(row=0, column=0, sticky="ew") + self.update() + + # Load Canvas into image Frame to display Video frame and draw BBoxes + image_view = ImageViewer(self, frm_image) + + # Button for adding Bounding Boxes + btn_add_class = tk.Button(frm_buttons, text="Add Class", + command=lambda: BoundingBox.new_label(image_view, frm_buttons)) + btn_add_class.grid(row=0, column=0, sticky="nwe", padx=5, pady=5) + + def __menu_bar(self): + menu = Menu() + self.config(menu=menu) + + # File Opener Menu + file_menu = Menu(menu) + menu.add_cascade(label='File', menu=file_menu) + # file_menu.add_command(label='Open...', command=Fileloader.open_video) + + # Settings Menu + settings = Menu(menu) + menu.add_cascade(label='Settings', menu=settings) + settings.add_command(label='Settings', command=self.__open_settings) + + def __open_settings(self): + Settings(self)