diff --git a/callbacks/control_bar.py b/callbacks/control_bar.py index a9f25002..2e52a209 100644 --- a/callbacks/control_bar.py +++ b/callbacks/control_bar.py @@ -15,7 +15,9 @@ from dash_iconify import DashIconify import json from utils.annotations import Annotations +from constants import KEYBINDS, ANNOT_ICONS, ANNOT_NOTIFICATION_MSGS import copy +import random @callback( @@ -25,68 +27,156 @@ Output("line", "style"), Output("circle", "style"), Output("rectangle", "style"), - Output("drawing-off", "style"), + Output("eraser", "style"), + Output("delete-all", "style"), + Output("pan-and-zoom", "style"), Output("annotation-store", "data", allow_duplicate=True), Output("current-ann-mode", "data", allow_duplicate=True), + Output("notifications-container", "children", allow_duplicate=True), Input("open-freeform", "n_clicks"), Input("closed-freeform", "n_clicks"), - Input("line", "n_clicks"), Input("circle", "n_clicks"), Input("rectangle", "n_clicks"), - Input("drawing-off", "n_clicks"), + Input("line", "n_clicks"), + Input("eraser", "n_clicks"), + Input("delete-all", "n_clicks"), + Input("pan-and-zoom", "n_clicks"), + Input("keybind-event-listener", "event"), State("annotation-store", "data"), + State("image-viewer-loading", "zIndex"), + State("generate-annotation-class-modal", "opened"), + State("edit-annotation-class-modal", "opened"), prevent_initial_call=True, ) -def annotation_mode(open, closed, line, circle, rect, off_mode, annotation_store): - """This callback determines which drawing mode the graph is in""" +def annotation_mode( + open, + closed, + circle, + rect, + line, + erase_annotation, + delete_all_annotations, + pan_and_zoom, + keybind_event_listener, + annotation_store, + figure_overlay_z_index, + generate_modal_opened, + edit_annotation_modal_opened, +): + """ + This callback is responsible for changing the annotation mode and the style of the buttons. + It also accepts keybinds to change the annotation mode. + """ + if generate_modal_opened or edit_annotation_modal_opened: + # user is going to type on this page and we don't want to trigger this callback using keys + raise PreventUpdate if not annotation_store["visible"]: raise PreventUpdate + # if the image is loading stop the callback when keybinds are pressed + if figure_overlay_z_index != -1: + raise PreventUpdate + + key_modes = { + KEYBINDS["open-freeform"]: ("drawopenpath", "open-freeform"), + KEYBINDS["closed-freeform"]: ("drawclosedpath", "closed-freeform"), + KEYBINDS["circle"]: ("drawcircle", "circle"), + KEYBINDS["rectangle"]: ("drawrect", "rectangle"), + KEYBINDS["line"]: ("drawline", "line"), + KEYBINDS["pan-and-zoom"]: ("pan", "pan-and-zoom"), + KEYBINDS["erase"]: ("eraseshape", "eraser"), + KEYBINDS["delete-all"]: ("deleteshape", "delete-all"), + } - patched_figure = Patch() triggered = ctx.triggered_id + pressed_key = ( + keybind_event_listener.get("key", None) if keybind_event_listener else None + ) + + if pressed_key in key_modes: + mode, triggered = key_modes[pressed_key] + else: + # if the callback was triggered by pressing a key that is not in the `key_modes`, stop the callback + if triggered == "keybind-event-listener": + raise PreventUpdate + mode = None + active = {"border": "3px solid black"} inactive = {"border": "1px solid"} - open_style = inactive - close_style = inactive - line_style = inactive - circle_style = inactive - rect_style = inactive - pan_style = inactive - - if triggered == "open-freeform" and open > 0: - patched_figure["layout"]["dragmode"] = "drawopenpath" - annotation_store["dragmode"] = "drawopenpath" - open_style = active - if triggered == "closed-freeform" and closed > 0: - patched_figure["layout"]["dragmode"] = "drawclosedpath" - annotation_store["dragmode"] = "drawclosedpath" - close_style = active - if triggered == "line" and line > 0: - patched_figure["layout"]["dragmode"] = "drawline" - annotation_store["dragmode"] = "drawline" - line_style = active - if triggered == "circle" and circle > 0: - patched_figure["layout"]["dragmode"] = "drawcircle" - annotation_store["dragmode"] = "drawcircle" - circle_style = active - if triggered == "rectangle" and rect > 0: - patched_figure["layout"]["dragmode"] = "drawrect" - annotation_store["dragmode"] = "drawrect" - rect_style = active - if triggered == "drawing-off" and off_mode > 0: - patched_figure["layout"]["dragmode"] = "pan" - annotation_store["dragmode"] = "pan" - pan_style = active + + patched_figure = Patch() + + # Define a dictionary to store the styles + styles = { + "open-freeform": inactive, + "closed-freeform": inactive, + "circle": inactive, + "rectangle": inactive, + "line": inactive, + "eraser": inactive, + "delete-all": inactive, + "pan-and-zoom": inactive, + } + + if mode: + patched_figure["layout"]["dragmode"] = mode + annotation_store["dragmode"] = mode + styles[triggered] = active + else: + if triggered == "open-freeform" and open > 0: + patched_figure["layout"]["dragmode"] = "drawopenpath" + annotation_store["dragmode"] = "drawopenpath" + styles[triggered] = active + elif triggered == "closed-freeform" and closed > 0: + patched_figure["layout"]["dragmode"] = "drawclosedpath" + annotation_store["dragmode"] = "drawclosedpath" + styles[triggered] = active + elif triggered == "line" and line > 0: + patched_figure["layout"]["dragmode"] = "drawline" + annotation_store["dragmode"] = "drawline" + styles[triggered] = active + elif triggered == "circle" and circle > 0: + patched_figure["layout"]["dragmode"] = "drawcircle" + annotation_store["dragmode"] = "drawcircle" + styles[triggered] = active + elif triggered == "rectangle" and rect > 0: + patched_figure["layout"]["dragmode"] = "drawrect" + annotation_store["dragmode"] = "drawrect" + styles[triggered] = active + elif triggered == "eraser" and erase_annotation > 0: + patched_figure["layout"]["dragmode"] = "eraseshape" + annotation_store["dragmode"] = "eraseshape" + styles[triggered] = active + elif triggered == "delete-all" and delete_all_annotations > 0: + patched_figure["layout"]["dragmode"] = "deleteshape" + annotation_store["dragmode"] = "deleteshape" + styles[triggered] = active + elif triggered == "pan-and-zoom" and pan_and_zoom > 0: + patched_figure["layout"]["dragmode"] = "pan" + annotation_store["dragmode"] = "pan" + styles[triggered] = active + + notification = dmc.Notification( + title=ANNOT_NOTIFICATION_MSGS[triggered], + message="", + color="indigo", + id=f"notification-{random.randint(0, 10000)}", + action="show", + icon=DashIconify(icon=ANNOT_ICONS[triggered], width=40), + styles={"icon": {"height": "50px", "width": "50px"}}, + ) return ( patched_figure, - open_style, - close_style, - line_style, - circle_style, - rect_style, - pan_style, + styles["open-freeform"], + styles["closed-freeform"], + styles["line"], + styles["circle"], + styles["rectangle"], + styles["eraser"], + styles["delete-all"], + styles["pan-and-zoom"], annotation_store, triggered, + notification, ) @@ -162,27 +252,85 @@ def highlight_selected_hide_classes(selected_classes, current_styles): Output("image-viewer", "figure", allow_duplicate=True), Output({"type": "annotation-color", "index": ALL}, "style"), Output({"type": "annotation-color", "index": ALL}, "n_clicks"), + Output("notifications-container", "children", allow_duplicate=True), Input({"type": "annotation-color", "index": ALL}, "n_clicks"), + Input("keybind-event-listener", "event"), State({"type": "annotation-color", "index": ALL}, "style"), + State("generate-annotation-class-modal", "opened"), + State("edit-annotation-class-modal", "opened"), + State("annotation-store", "data"), prevent_initial_call=True, ) -def annotation_color(color_value, current_style): +def annotation_color( + color_value, + keybind_event_listener, + current_style, + generate_modal_opened, + edit_annotation_modal_opened, + annotation_store, +): """ This callback is responsible for changing the color of the brush. """ - color = ctx.triggered_id["index"] - if color_value[-1] is None: - color = current_style[-1]["background-color"] - color_value[-1] = 1 - for i in range(len(current_style)): - if current_style[i]["background-color"] == color: - current_style[i]["border"] = "3px solid black" - else: - current_style[i]["border"] = "1px solid" + if ctx.triggered_id == "keybind-event-listener": + if generate_modal_opened or edit_annotation_modal_opened: + # user is going to type on this page and we don't want to trigger this callback using keys + raise PreventUpdate + pressed_key = ( + keybind_event_listener.get("key", None) if keybind_event_listener else None + ) + if not pressed_key: + raise PreventUpdate + if pressed_key not in KEYBINDS["classes"]: + # if key pressed is not a valid keybind for class selection + raise PreventUpdate + selected_color_idx = KEYBINDS["classes"].index(pressed_key) + + if selected_color_idx >= len(current_style): + # if the key pressed corresponds to a class that doesn't exist + raise PreventUpdate + + color = current_style[selected_color_idx]["background-color"] + color_label = annotation_store["label_mapping"][selected_color_idx]["label"] + for i in range(len(current_style)): + if current_style[i]["background-color"] == color: + current_style[i]["border"] = "3px solid black" + else: + current_style[i]["border"] = "1px solid" + else: + color = ctx.triggered_id["index"] + if color_value[-1] is None: + color = current_style[-1]["background-color"] + color_value[-1] = 1 + selected_color_idx = -1 + for i in range(len(current_style)): + if current_style[i]["background-color"] == color: + current_style[i]["border"] = "3px solid black" + selected_color_idx = i + else: + current_style[i]["border"] = "1px solid" + color_label = annotation_store["label_mapping"][selected_color_idx]["label"] + patched_figure = Patch() patched_figure["layout"]["newshape"]["fillcolor"] = color patched_figure["layout"]["newshape"]["line"]["color"] = color - return patched_figure, current_style, color_value + + label_name = color_label + notification = dmc.Notification( + title=f"{label_name} class selected", + message="", + id=f"notification-{random.randint(0, 10000)}", + action="show", + icon=DashIconify(icon="mdi:color", width=30), + styles={ + "icon": { + "height": "50px", + "width": "50px", + "background-color": f"{color} !important", + } + }, + ) + return patched_figure, current_style, color_value, notification @callback( @@ -190,12 +338,22 @@ def annotation_color(color_value, current_style): Input("delete-all", "n_clicks"), Input("modal-cancel-button", "n_clicks"), Input("modal-delete-button", "n_clicks"), + Input("keybind-event-listener", "event"), State("delete-all-warning", "opened"), prevent_initial_call=True, ) -def open_warning_modal(delete, cancel, delete_4_real, opened): +def open_warning_modal(delete, cancel, delete_4_real, keybind_event_listener, opened): """Opens and closes the modal that warns you when you're deleting all annotations""" - return not opened + if ctx.triggered_id == "keybind-event-listener": + pressed_key = ( + keybind_event_listener.get("key", None) if keybind_event_listener else None + ) + if not pressed_key: + raise PreventUpdate + if pressed_key is not KEYBINDS["delete-all"]: + # if key pressed is not a valid keybind for class selection + raise PreventUpdate + return True @callback( @@ -608,7 +766,7 @@ def export_annotation(n_clicks, annotation_store): title=notification_title, message=notification_message, color=notification_color, - id="export-annotation-notification", + id=f"notification-{random.randint(0, 10000)}", action="show", icon=DashIconify(icon="entypo:export"), ) diff --git a/components/control_bar.py b/components/control_bar.py index 6f47b07d..0f8e7483 100644 --- a/components/control_bar.py +++ b/components/control_bar.py @@ -2,7 +2,9 @@ from dash import html, dcc from dash_iconify import DashIconify from utils import data_utils +from constants import ANNOT_ICONS, KEYBINDS import random +from dash_extensions import EventListener def _accordion_item(title, icon, value, children, id): @@ -129,7 +131,9 @@ def layout(): id="open-freeform", variant="outline", color="gray", - children=DashIconify(icon="mdi:draw"), + children=DashIconify( + icon=ANNOT_ICONS["open-freeform"] + ), style={"border": "3px solid black"}, ), label="Open Freeform: draw any open shape", @@ -141,7 +145,7 @@ def layout(): variant="outline", color="gray", children=DashIconify( - icon="fluent:draw-shape-20-regular" + icon=ANNOT_ICONS["closed-freeform"] ), ), label="Closed Freeform: draw a shape that will auto-complete", @@ -163,7 +167,7 @@ def layout(): variant="outline", color="gray", children=DashIconify( - icon="gg:shape-circle" + icon=ANNOT_ICONS["circle"] ), ), label="Circle: create a filled circle", @@ -175,7 +179,7 @@ def layout(): variant="outline", color="gray", children=DashIconify( - icon="gg:shape-square" + icon=ANNOT_ICONS["rectangle"] ), ), label="Rectangle: create a filled rectangle", @@ -186,7 +190,9 @@ def layout(): id="eraser", variant="outline", color="gray", - children=DashIconify(icon="ph:eraser"), + children=DashIconify( + icon=ANNOT_ICONS["eraser"] + ), ), label="Eraser: click on the shape to erase then click this button to delete the selected shape", multiline=True, @@ -197,7 +203,7 @@ def layout(): variant="outline", color="gray", children=DashIconify( - icon="octicon:trash-24" + icon=ANNOT_ICONS["delete-all"] ), ), label="Clear All Annotations", @@ -205,10 +211,12 @@ def layout(): ), dmc.Tooltip( dmc.ActionIcon( - id="drawing-off", + id="pan-and-zoom", variant="outline", color="gray", - children=DashIconify(icon="el:off"), + children=DashIconify( + icon=ANNOT_ICONS["pan-and-zoom"] + ), ), label="Stop Drawing: pan, zoom, select annotations and edit them using the nodes", multiline=True, @@ -473,31 +481,6 @@ def layout(): ), ], ), - dcc.Store( - id="annotation-store", - data={ - "dragmode": "drawopenpath", - "visible": True, - "annotations": {}, - "view": {}, - "active_img_shape": [], - # TODO: Hard-coding default annotation class - "label_mapping": [ - { - "color": "rgb(249,82,82)", - "label": "1", - "id": "1", - } - ], - "classes_shown": {}, - "classes_hidden": {}, - }, - ), - dmc.NotificationsProvider(html.Div(id="notifications-container")), - dcc.Download(id="export-annotation-metadata"), - dcc.Download(id="export-annotation-mask"), - dcc.Store(id="project-data"), - html.Div(id="dummy-output"), ], ) ) @@ -550,5 +533,127 @@ def drawer_section(children): children=children, opened=True, ), + dcc.Store( + id="annotation-store", + data={ + "dragmode": "drawopenpath", + "visible": True, + "annotations": {}, + "view": {}, + "active_img_shape": [], + # TODO: Hard-coding default annotation class + "label_mapping": [ + { + "color": "rgb(249,82,82)", + "label": "1", + "id": "1", + } + ], + "classes_shown": {}, + "classes_hidden": {}, + }, + ), + create_info_card_affix(), + dmc.NotificationsProvider(html.Div(id="notifications-container")), + dcc.Download(id="export-annotation-metadata"), + dcc.Download(id="export-annotation-mask"), + dcc.Store(id="project-data"), + html.Div(id="dummy-output"), + EventListener( + events=[ + { + "event": "keydown", + "props": ["key", "ctrlKey", "ctrlKey"], + } + ], + id="keybind-event-listener", + ), ] ) + + +def create_keybind_row(keys, text): + keybinds = [] + for key in keys: + keybinds.append(dmc.Kbd(key)) + keybinds.append(" + ") + keybinds.pop() + return dmc.Group( + position="apart", + children=[html.Div(keybinds), dmc.Text(text, size="sm")], + ) + + +def create_info_card_affix(): + return dmc.Affix( + position={"bottom": 20, "left": 20}, + zIndex=9999999, + children=dmc.HoverCard( + shadow="md", + position="top-start", + children=[ + dmc.HoverCardTarget( + dmc.ActionIcon( + DashIconify(icon="entypo:info"), + size="lg", + radius="lg", + variant="filled", + mb=10, + ), + ), + dmc.HoverCardDropdown( + [ + dmc.Text( + "Keybinding Shortcuts", + size="lg", + weight=700, + ), + dmc.Stack( + [ + dmc.Divider(variant="solid", color="gray"), + create_keybind_row( + KEYBINDS["open-freeform"].upper(), + "Open Freeform", + ), + create_keybind_row( + KEYBINDS["closed-freeform"].upper(), + "Closed Freeform", + ), + create_keybind_row( + KEYBINDS["line"].upper(), + "Line Annotation Mode", + ), + create_keybind_row( + KEYBINDS["circle"].upper(), + "Circle Annotation Mode", + ), + create_keybind_row( + KEYBINDS["rectangle"].upper(), + "Rectangle Annotation Mode", + ), + dmc.Divider(variant="solid", color="gray"), + create_keybind_row( + KEYBINDS["pan-and-zoom"].upper(), + "Pan and Zoom Mode", + ), + create_keybind_row( + KEYBINDS["erase"].upper(), + "Erase Annotation Mode", + ), + create_keybind_row( + KEYBINDS["delete-all"].upper(), + "Delete all annotations", + ), + dmc.Divider(variant="solid", color="gray"), + create_keybind_row( + ["1-9"], + "Select annotation class 1-9", + ), + ], + p=0, + ), + ] + ), + ], + ), + ) diff --git a/constants.py b/constants.py new file mode 100644 index 00000000..5c24b370 --- /dev/null +++ b/constants.py @@ -0,0 +1,43 @@ +KEYBINDS = { + "open-freeform": "q", + "closed-freeform": "w", + "line": "e", + "circle": "r", + "rectangle": "t", + "pan-and-zoom": "a", + "erase": "s", + "delete-all": "d", + "classes": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + ], +} + +ANNOT_ICONS = { + "open-freeform": "mdi:draw", + "closed-freeform": "fluent:draw-shape-20-regular", + "line": "pepicons-pop:line-y", + "circle": "gg:shape-circle", + "rectangle": "gg:shape-square", + "eraser": "ph:eraser", + "delete-all": "octicon:trash-24", + "pan-and-zoom": "el:off", +} + +ANNOT_NOTIFICATION_MSGS = { + "open-freeform": "Open freeform annotation mode", + "closed-freeform": "Closed freeform annotation mode", + "line": "Line annotation mode", + "circle": "Circle annotation mode", + "rectangle": "Rectangle annotation mode", + "eraser": "Eraser annotation mode", + "delete-all": "Delete all annotations", + "pan-and-zoom": "Pan and zoom mode", +} diff --git a/requirements.txt b/requirements.txt index 6456a9e9..c130a225 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,3 +30,4 @@ scikit-image svgpathtools matplotlib scipy +dash-extensions==1.0.1 \ No newline at end of file