From 10886babde7bc8ec19a1a1ea7b1e1a74c96fbe71 Mon Sep 17 00:00:00 2001 From: niksirbi Date: Thu, 30 Mar 2023 13:58:07 +0100 Subject: [PATCH 1/8] replaced pose estimation page with events page --- wazp/app.py | 2 ++ wazp/callbacks/events.py | 13 +++++++++++++ wazp/pages/{03_pose_estimation.py => 03_events.py} | 4 ++-- 3 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 wazp/callbacks/events.py rename wazp/pages/{03_pose_estimation.py => 03_events.py} (72%) diff --git a/wazp/app.py b/wazp/app.py index e1b6156..da48469 100644 --- a/wazp/app.py +++ b/wazp/app.py @@ -5,6 +5,7 @@ from dash import Dash, dcc, html import wazp.callbacks.dashboard as dashboard +import wazp.callbacks.events as events import wazp.callbacks.home as home import wazp.callbacks.metadata as metadata import wazp.callbacks.roi as roi @@ -106,6 +107,7 @@ home.get_callbacks(app) metadata.get_callbacks(app) roi.get_callbacks(app) +events.get_callbacks(app) dashboard.get_callbacks(app) diff --git a/wazp/callbacks/events.py b/wazp/callbacks/events.py new file mode 100644 index 0000000..dd7f61e --- /dev/null +++ b/wazp/callbacks/events.py @@ -0,0 +1,13 @@ +import dash + + +def get_callbacks(app: dash.Dash) -> None: + """ + Return all callback functions for the events tab. + + Parameters + ---------- + app : dash.Dash + Dash app object for which these callbacks are defined + """ + pass diff --git a/wazp/pages/03_pose_estimation.py b/wazp/pages/03_events.py similarity index 72% rename from wazp/pages/03_pose_estimation.py rename to wazp/pages/03_events.py index 481bed6..d56d1f2 100644 --- a/wazp/pages/03_pose_estimation.py +++ b/wazp/pages/03_events.py @@ -11,10 +11,10 @@ ################ layout = html.Div( children=[ - html.H1(children="Pose estimation inference"), + html.H1(children="Event tagging"), html.Div( children=""" - This is the Pose estimation page content. + Define events in time. """ ), ] From c911b033686b8427aa1096c6983587ddce13a09b Mon Sep 17 00:00:00 2001 From: niksirbi Date: Thu, 30 Mar 2023 15:40:32 +0100 Subject: [PATCH 2/8] added basic layout for the events page --- wazp/pages/03_events.py | 97 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 86 insertions(+), 11 deletions(-) diff --git a/wazp/pages/03_events.py b/wazp/pages/03_events.py index d56d1f2..6b2754c 100644 --- a/wazp/pages/03_events.py +++ b/wazp/pages/03_events.py @@ -1,21 +1,96 @@ import dash -from dash import html +import dash_bootstrap_components as dbc +from dash import dash_table, dcc, html ###################### # Add page to registry ######################### dash.register_page(__name__) -############### -# Layout -################ -layout = html.Div( - children=[ - html.H1(children="Event tagging"), - html.Div( - children=""" - Define events in time. - """ +############################### +# Initialize variables # +############################### + +# Get initial video +init_videos = ["No videos found yet"] +# Get initial set of event tags to initialize table +init_event_tags = ["start", "end"] + +# Columns for events table +init_events_table_columns = ["tag", "frame index", "seconds"] +# Initialize the events storage dictionary +init_events_storage: dict = {v: {} for v in init_videos} +# Initialize the events status alert +init_events_status: dict = {"message": "No events defined", "color": "light"} + +############################### +# Table of events # +############################### + +events_table = dash_table.DataTable( + id="events-table", + columns=[dict(name=c, id=c) for c in init_events_table_columns], + data=[], + editable=True, + style_data={"height": 40}, + style_cell={ + "overflow": "hidden", + "textOverflow": "ellipsis", + "maxWidth": 0, + "textAlign": "left", + }, + style_data_conditional=[], + fill_width=True, +) + +# video selection dropdown +events_video_select = dcc.Dropdown( + id="events-video-select", + placeholder="Select video", + options=[{"label": v, "value": v} for v in init_videos], + clearable=False, +) + +# events status alert +events_status_alert = dbc.Alert( + init_events_status["message"], + id="event-status-alert", + color=init_events_status["color"], + is_open=False, + dismissable=True, +) + +############################### +# Put elements into card # +############################### + +# events table card +events_table_card = dbc.Card( + [ + dbc.CardHeader(events_video_select), + dbc.CardBody( + [ + dbc.Row(dbc.Col(html.H4("Defined events"))), + dbc.Row(dbc.Col(events_table)), + dcc.Store( + id="events-storage", + data=init_events_storage, + storage_type="session", + ), + ] ), + dbc.CardFooter(events_status_alert), ] ) + +############################### +# Define page layout # +############################### + +layout = dbc.Container( + [ + html.H1(children="Event tagging"), + dbc.Row(dbc.Col(events_table_card)), + ], + fluid=True, +) From 7e1f9ca653b5007959096deea9429cf882ea174a Mon Sep 17 00:00:00 2001 From: niksirbi Date: Thu, 30 Mar 2023 15:45:13 +0100 Subject: [PATCH 3/8] store video paths as a separate field in session storage for convenience --- wazp/callbacks/home.py | 17 +++++++++++++++++ wazp/callbacks/roi.py | 18 +++--------------- wazp/utils.py | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 15 deletions(-) diff --git a/wazp/callbacks/home.py b/wazp/callbacks/home.py index 2aeb62d..766f171 100644 --- a/wazp/callbacks/home.py +++ b/wazp/callbacks/home.py @@ -1,10 +1,13 @@ import base64 +import pathlib as pl from typing import Any import dash import yaml from dash import Input, Output, State +from wazp import utils + def get_callbacks(app: dash.Dash) -> None: """Return all callback functions for the home tab. @@ -47,6 +50,8 @@ def save_input_config_to_storage( - 'config': a dict with the project configuration parameters - 'metadata_fields': a dict with a set of attributes (description, type...) for each metadata field + - 'video_paths': a dict with the path to the video file for each video + (key: video name, value: video path) up_message_state : bool visibility of the upload message output_message : str @@ -71,10 +76,22 @@ def save_input_config_to_storage( with open(config["metadata_fields_file_path"]) as mdf: metadata_fields_dict = yaml.safe_load(mdf) + # get video paths from the videos directory + video_paths = utils.get_video_paths_from_folder( + pl.Path(config["videos_dir_path"]), + video_extensions=[".mp4", ".avi"], + ) + video_paths.sort() + # store video paths as a dict with video names as keys + video_paths_dict = { + v.name: v.absolute().as_posix() for v in video_paths + } + # bundle data data_to_store = { "config": config, "metadata_fields": metadata_fields_dict, + "video_paths": video_paths_dict, } # output message diff --git a/wazp/callbacks/roi.py b/wazp/callbacks/roi.py index d397bad..022c955 100644 --- a/wazp/callbacks/roi.py +++ b/wazp/callbacks/roi.py @@ -54,26 +54,14 @@ def update_video_select_options( str value of the first video in the list """ - if "config" in app_storage.keys(): - # Get videos directory from stored config - config = app_storage["config"] - videos_dir = config["videos_dir_path"] - # get all videos in the videos directory - video_paths = [] - for video_type in VIDEO_TYPES: - video_paths += [ - p for p in pl.Path(videos_dir).glob(f"*{video_type}") - ] - video_paths.sort() - video_names = [p.name for p in video_paths] - video_paths_str = [p.absolute().as_posix() for p in video_paths] + if "video_paths" in app_storage.keys(): # Video names become the labels and video paths the values # of the video select dropdown options = [ {"label": v, "value": p} - for v, p in zip(video_names, video_paths_str) + for v, p in app_storage["video_paths"].items() ] - value = video_paths_str[0] + value = options[0]["value"] return options, value else: return dash.no_update, dash.no_update diff --git a/wazp/utils.py b/wazp/utils.py index 4bc02eb..a2ed0a4 100644 --- a/wazp/utils.py +++ b/wazp/utils.py @@ -529,6 +529,42 @@ def assign_roi_colors( } +def get_video_paths_from_folder( + folder_path: pl.Path, + video_extensions: list[str] = [".mp4", ".avi"], +) -> list[pl.Path]: + """ + Get the paths to all videos in a folder. + + Parameters + ---------- + folder_path : pathlib.Path + Path to the video-containing folder + video_extensions : list of str + Which video extensions to consider. + Defaults to ['.mp4', '.avi']. + + Returns + ------- + list of pathlib.Path + List of video paths + """ + video_paths = [] + for video_extension in video_extensions: + video_paths.extend( + [ + pl.Path(video_path) + for video_path in folder_path.glob(f"*{video_extension}") + ] + ) + if len(video_paths) == 0: + raise FileNotFoundError( + f"No videos found in folder {folder_path} " + f"with extensions {video_extensions}" + ) + return video_paths + + def get_num_frames(video_path) -> int: """ Get the number of frames in a video. From da72dec2ec94167722dc510c2998cb696ccff051 Mon Sep 17 00:00:00 2001 From: niksirbi Date: Thu, 30 Mar 2023 16:00:17 +0100 Subject: [PATCH 4/8] callback for updating video dropdown in events page --- wazp/callbacks/events.py | 43 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/wazp/callbacks/events.py b/wazp/callbacks/events.py index dd7f61e..305c0e0 100644 --- a/wazp/callbacks/events.py +++ b/wazp/callbacks/events.py @@ -1,4 +1,7 @@ +from typing import Optional + import dash +from dash import Input, Output def get_callbacks(app: dash.Dash) -> None: @@ -10,4 +13,42 @@ def get_callbacks(app: dash.Dash) -> None: app : dash.Dash Dash app object for which these callbacks are defined """ - pass + + @app.callback( + [ + Output("events-video-select", "options"), + Output("events-video-select", "value"), + ], + Input("session-storage", "data"), + ) + def update_events_video_select( + app_storage: dict, + ) -> tuple[list, Optional[str]]: + """Update the video selection dropdown with the videos defined in + the project configuration file. + + Parameters + ---------- + app_storage : dict + dictionary with the following keys and values: + - 'config': a dict with the project configuration parameters + - 'metadata_fields': a dict with a set of attributes + - 'video_paths': a dict storing paths to the video files + + Returns + ------- + options : list + list of dictionaries with the following keys and values: + - 'label': video name + - 'value': video name + value : str + Currently selected video name + """ + + options = [] + if "video_paths" in app_storage: + options = [ + {"label": v, "value": v} for v in app_storage["video_paths"] + ] + value = options[0]["value"] if options else None + return options, value From 7207edde1873e19ca504508de91d5fb2431a8daf Mon Sep 17 00:00:00 2001 From: niksirbi Date: Thu, 30 Mar 2023 16:55:26 +0100 Subject: [PATCH 5/8] added dropdown and input field for tagging events with frame index --- wazp/pages/03_events.py | 41 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/wazp/pages/03_events.py b/wazp/pages/03_events.py index 6b2754c..d27ec41 100644 --- a/wazp/pages/03_events.py +++ b/wazp/pages/03_events.py @@ -31,7 +31,7 @@ id="events-table", columns=[dict(name=c, id=c) for c in init_events_table_columns], data=[], - editable=True, + editable=False, style_data={"height": 40}, style_cell={ "overflow": "hidden", @@ -51,6 +51,24 @@ clearable=False, ) +event_dropdown = dcc.Dropdown( + id="event-select", + placeholder="Select event", + options=[{"label": e, "value": e} for e in init_event_tags], + value=init_event_tags[0], + clearable=False, +) + +frame_index_input = dbc.Input( + id="frame-index-input", + type="number", + placeholder="Frame index", + min=0, + max=1, + step=1, + value=0, +) + # events status alert events_status_alert = dbc.Alert( init_events_status["message"], @@ -66,8 +84,25 @@ # events table card events_table_card = dbc.Card( - [ - dbc.CardHeader(events_video_select), + children=[ + dbc.CardHeader( + [ + dbc.Row( + [ + dbc.Col(dcc.Markdown("Select video"), width=3), + dbc.Col(events_video_select, width=9), + ], + ), + dbc.Row( + [ + dbc.Col(dcc.Markdown("Tag event for"), width=3), + dbc.Col(event_dropdown, width=3), + dbc.Col(dcc.Markdown("at frame index"), width=3), + dbc.Col(dcc.Loading(frame_index_input), width=3), + ], + ), + ], + ), dbc.CardBody( [ dbc.Row(dbc.Col(html.H4("Defined events"))), From 86199d3dcff064bde7cb1ed73df0a00d8415bfdd Mon Sep 17 00:00:00 2001 From: niksirbi Date: Thu, 30 Mar 2023 16:55:50 +0100 Subject: [PATCH 6/8] added callback for updating event dropdown --- wazp/callbacks/events.py | 55 ++++++++++++++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/wazp/callbacks/events.py b/wazp/callbacks/events.py index 305c0e0..53fac37 100644 --- a/wazp/callbacks/events.py +++ b/wazp/callbacks/events.py @@ -1,5 +1,3 @@ -from typing import Optional - import dash from dash import Input, Output @@ -23,17 +21,15 @@ def get_callbacks(app: dash.Dash) -> None: ) def update_events_video_select( app_storage: dict, - ) -> tuple[list, Optional[str]]: + ) -> tuple[list, str]: """Update the video selection dropdown with the videos defined in the project configuration file. Parameters ---------- app_storage : dict - dictionary with the following keys and values: - - 'config': a dict with the project configuration parameters - - 'metadata_fields': a dict with a set of attributes - - 'video_paths': a dict storing paths to the video files + data held in temporary memory storage, + accessible to all tabs in the app Returns ------- @@ -45,10 +41,49 @@ def update_events_video_select( Currently selected video name """ - options = [] if "video_paths" in app_storage: options = [ {"label": v, "value": v} for v in app_storage["video_paths"] ] - value = options[0]["value"] if options else None - return options, value + value = options[0]["value"] + return options, value + else: + return dash.no_update, dash.no_update + + @app.callback( + [ + Output("event-select", "options"), + Output("event-select", "value"), + ], + Input("session-storage", "data"), + ) + def update_event_select_options( + app_storage: dict, + ) -> tuple[list, str]: + """Update the options of the event select dropdown with + the event tags defined in the project configuration file. + + Parameters + ---------- + app_storage : dict + data held in temporary memory storage, + accessible to all tabs in the app + + Returns + ------- + options : list + list of dictionaries with the following keys and values: + - 'label': event tag + - 'value': event tag + value : str + Currently selected event tag + """ + if "config" in app_storage.keys(): + # Get ROI names from stored config + config = app_storage["config"] + event_tags = config["event_tags"] + options = [{"label": t, "value": t} for t in event_tags] + value = event_tags[0] + return options, value + else: + return dash.no_update, dash.no_update From 5d44bbba2f8b119466c210ed6ce3b01fce6d39f4 Mon Sep 17 00:00:00 2001 From: niksirbi Date: Thu, 30 Mar 2023 17:22:17 +0100 Subject: [PATCH 7/8] added button for tagging an event --- wazp/pages/03_events.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/wazp/pages/03_events.py b/wazp/pages/03_events.py index d27ec41..1472fa3 100644 --- a/wazp/pages/03_events.py +++ b/wazp/pages/03_events.py @@ -23,6 +23,14 @@ # Initialize the events status alert init_events_status: dict = {"message": "No events defined", "color": "light"} +disabled_button_style = { + "n_clicks": 0, + "outline": False, + "color": "dark", + "disabled": True, + "class_name": "w-100", +} + ############################### # Table of events # ############################### @@ -69,6 +77,12 @@ value=0, ) +tag_event_button = dbc.Button( + "Tag event", + id="tag-event-button", + **disabled_button_style, +) + # events status alert events_status_alert = dbc.Alert( init_events_status["message"], @@ -89,16 +103,17 @@ [ dbc.Row( [ - dbc.Col(dcc.Markdown("Select video"), width=3), - dbc.Col(events_video_select, width=9), + dbc.Col(dcc.Markdown("Select video"), width=2), + dbc.Col(events_video_select, width=10), ], ), dbc.Row( [ - dbc.Col(dcc.Markdown("Tag event for"), width=3), + dbc.Col(dcc.Markdown("Select event"), width=2), dbc.Col(event_dropdown, width=3), - dbc.Col(dcc.Markdown("at frame index"), width=3), - dbc.Col(dcc.Loading(frame_index_input), width=3), + dbc.Col(dcc.Markdown("at frame"), width=2), + dbc.Col(dcc.Loading(frame_index_input), width=2), + dbc.Col(tag_event_button, width=3), ], ), ], From 4636fb1e78607a4a1d2b1ecb8f5a1c63880211d9 Mon Sep 17 00:00:00 2001 From: niksirbi Date: Thu, 30 Mar 2023 17:33:14 +0100 Subject: [PATCH 8/8] button click updates event storage and event table --- wazp/callbacks/events.py | 110 ++++++++++++++++++++++++++++++++++++++- wazp/pages/03_events.py | 4 +- 2 files changed, 111 insertions(+), 3 deletions(-) diff --git a/wazp/callbacks/events.py b/wazp/callbacks/events.py index 53fac37..b048884 100644 --- a/wazp/callbacks/events.py +++ b/wazp/callbacks/events.py @@ -1,5 +1,5 @@ import dash -from dash import Input, Output +from dash import Input, Output, State def get_callbacks(app: dash.Dash) -> None: @@ -87,3 +87,111 @@ def update_event_select_options( return options, value else: return dash.no_update, dash.no_update + + @app.callback( + Output("tag-event-button", "disabled"), + Input("event-select", "value"), + ) + def disable_tag_event_button( + event_tag: str, + ) -> bool: + """Disable the tag event button if the event tag is empty + + Parameters + ---------- + event_tag : str + Currently selected event tag + + Returns + ------- + disabled : bool + True if the tag event button should be disabled, False otherwise + """ + if event_tag == "" or event_tag is None: + return True + else: + return False + + @app.callback( + Output("events-storage", "data"), + Input("tag-event-button", "n_clicks"), + [ + State("frame-index-input", "value"), + State("events-video-select", "value"), + State("event-select", "value"), + State("events-storage", "data"), + ], + ) + def update_events_storage( + n_clicks: int, + frame_index: int, + video_name: str, + event_tag: str, + events_storage: dict, + ) -> dict: + """Update the events storage with the currently selected event tag + and frame index, when the tag event button is clicked. + + Parameters + ---------- + n_clicks : int + Number of times the tag event button has been clicked + frame_index : int + Currently selected frame index + video_name : str + Currently selected video name + event_tag : str + Currently selected event tag + events_storage : dict + Dictionary storing event tags for each video. + + Returns + ------- + events_storage : dict + data held in temporary memory storage, + accessible to all tabs in the app + """ + if n_clicks > 0: + if video_name not in events_storage.keys(): + events_storage[video_name] = dict() + events_storage[video_name][event_tag] = frame_index + return events_storage + + @app.callback( + Output("events-table", "data"), + [ + Input("events-video-select", "value"), + Input("events-storage", "data"), + ], + ) + def update_events_table( + video_name: str, + events_storage: dict, + ) -> list[dict]: + """Update the events table based on the stored events. + + Parameters + ---------- + video_name : str + Currently selected video name + events_storage : dict + Dictionary storing event tags for each video. + + Returns + ------- + data : list[dict] + list of dictionaries with the following keys and values: + - 'tag': event tag + - 'frame index': frame index + - 'seconds': frame index converted to seconds, based on the + video FPS + """ + fps = 40 + rows = [] + if video_name in events_storage.keys(): + for event, frame_idx in events_storage[video_name].items(): + sec = frame_idx / fps + rows.append( + {"tag": event, "frame index": frame_idx, "seconds": sec} + ) + return rows diff --git a/wazp/pages/03_events.py b/wazp/pages/03_events.py index 1472fa3..6ec13ac 100644 --- a/wazp/pages/03_events.py +++ b/wazp/pages/03_events.py @@ -12,9 +12,9 @@ ############################### # Get initial video -init_videos = ["No videos found yet"] +init_videos = [""] # Get initial set of event tags to initialize table -init_event_tags = ["start", "end"] +init_event_tags = [""] # Columns for events table init_events_table_columns = ["tag", "frame index", "seconds"]