From ceaedf9860a97f0f4d3c66af64876ca18f4c4186 Mon Sep 17 00:00:00 2001 From: Tammy Do <61042740+tamidodo@users.noreply.github.com> Date: Wed, 2 Aug 2023 23:51:19 -0700 Subject: [PATCH 1/8] add straight line mode and delete annotations with class --- callbacks/control_bar.py | 120 +++++++++++++++++++++++++++++++------ components/control_bar.py | 25 ++++++-- components/image_viewer.py | 15 +---- 3 files changed, 121 insertions(+), 39 deletions(-) diff --git a/callbacks/control_bar.py b/callbacks/control_bar.py index 74507b79..f6f96984 100644 --- a/callbacks/control_bar.py +++ b/callbacks/control_bar.py @@ -22,6 +22,7 @@ Output("image-viewer", "figure", allow_duplicate=True), Output("open-freeform", "style"), Output("closed-freeform", "style"), + Output("line", "style"), Output("circle", "style"), Output("rectangle", "style"), Output("drawing-off", "style"), @@ -29,13 +30,14 @@ Output("current-ann-mode", "data", 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"), State("annotation-store", "data"), prevent_initial_call=True, ) -def annotation_mode(open, closed, circle, rect, off_mode, annotation_store): +def annotation_mode(open, closed, line, circle, rect, off_mode, annotation_store): """This callback determines which drawing mode the graph is in""" if not annotation_store["visible"]: raise PreventUpdate @@ -46,6 +48,7 @@ def annotation_mode(open, closed, circle, rect, off_mode, annotation_store): inactive = {"border": "1px solid"} open_style = inactive close_style = inactive + line_style = inactive circle_style = inactive rect_style = inactive pan_style = inactive @@ -58,6 +61,10 @@ def annotation_mode(open, closed, circle, rect, off_mode, annotation_store): 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" @@ -74,6 +81,7 @@ def annotation_mode(open, closed, circle, rect, off_mode, annotation_store): patched_figure, open_style, close_style, + line_style, circle_style, rect_style, pan_style, @@ -184,13 +192,38 @@ def open_delete_class_modal(delete, remove, opened): @callback( Output("create-annotation-class", "disabled"), + Output("bad-label-color", "children"), Input("annotation-class-label", "value"), + Input("annotation-class-colorpicker", "value"), + State({"type": "annotation-color", "index": ALL}, "children"), + State({"type": "annotation-color", "index": ALL}, "style"), + prevent_initial_call=True, ) -def disable_class_creation(label): - if label is None or len(label) == 0: - return True +def disable_class_creation(label, color, current_labels, current_colors): + triggered_id = ctx.triggered_id + warning_text = [] + if triggered_id == "annotation-class-label": + if label in current_labels: + warning_text.append( + dmc.Text("This annotation class label is already in use.", color="red") + ) + if color is None: + color = "rgb(255, 255, 255)" + if color == "rgb(255, 255, 255)" or triggered_id == "annotation-class-colorpicker": + current_colors = [style["background-color"] for style in current_colors] + if color in current_colors: + warning_text.append( + dmc.Text("This annotation class color is already in use.", color="red") + ) + if ( + label is None + or len(label) == 0 + or label in current_labels + or color in current_colors + ): + return True, warning_text else: - return False + return False, warning_text @callback( @@ -216,34 +249,47 @@ def disable_class_deletion(highlighted): Output("annotation-class-selection", "children"), Output("annotation-class-label", "value"), Output("annotation-store", "data", allow_duplicate=True), + Output("image-viewer", "figure", allow_duplicate=True), Input("create-annotation-class", "n_clicks"), Input("remove-annotation-class", "n_clicks"), State("annotation-class-label", "value"), State("annotation-class-colorpicker", "value"), State("annotation-class-selection", "children"), State({"type": "annotation-delete-buttons", "index": ALL}, "style"), + State({"type": "annotation-delete-buttons", "index": ALL}, "children"), State("annotation-store", "data"), + State("image-selection-slider", "value"), prevent_initial_call=True, ) -def add_new_class( +def add_delete_classes( create, remove, class_label, class_color, current_classes, classes_to_delete, + class_names, annotation_store, + image_idx, ): """Updates the list of available annotation classes""" triggered = ctx.triggered_id + image_idx = str(image_idx - 1) + current_stored_classes = annotation_store["label_mapping"] + if class_color is None: + class_color = "rgb(255,255,255)" + else: + class_color = class_color.replace(" ", "") if triggered == "create-annotation-class": - # TODO: Check that the randint isn't already assigned to a class + last_id = int(current_stored_classes[-1]["id"]) annotation_store["label_mapping"].append( - {"color": class_color, "id": random.randint(1, 100), "label": class_label} + { + "color": class_color, + "id": last_id + 1, + "label": class_label, + } ) - if class_color is None: - class_color = "rgb(255, 255, 255)" - if class_color == "rgb(255, 255, 255)": + if class_color == "rgb(255,255,255)": current_classes.append( dmc.ActionIcon( id={"type": "annotation-color", "index": class_color}, @@ -267,20 +313,41 @@ def add_new_class( children=class_label, ) ) - output_classes = current_classes + return current_classes, "", annotation_store, no_update else: - # TODO: Remove mapping from the store color_to_delete = [] color_to_keep = [] - for color_opt in classes_to_delete: - if color_opt["border"] == "3px solid black": - color_to_delete.append(color_opt["background-color"]) + annotations_to_keep = {} + current_annotations = annotation_store["annotations"] + for i in range(len(classes_to_delete)): + if classes_to_delete[i]["border"] == "3px solid black": + color_to_delete.append( + classes_to_delete[i]["background-color"].replace(" ", "") + ) + current_stored_classes = [ + class_pair + for class_pair in current_stored_classes + if class_pair["color"] not in color_to_delete + ] + for key, val in current_annotations.items(): + val = [ + shape for shape in val if shape["line"]["color"] not in color_to_delete + ] + if len(val): + annotations_to_keep[key] = val for color in current_classes: if color["props"]["id"]["index"] not in color_to_delete: color_to_keep.append(color) - output_classes = color_to_keep - - return output_classes, "", annotation_store + annotation_store["label_mapping"] = current_stored_classes + annotation_store["annotations"] = annotations_to_keep + patched_figure = Patch() + if image_idx in annotation_store["annotations"]: + patched_figure["layout"]["shapes"] = annotation_store["annotations"][ + image_idx + ] + else: + patched_figure["layout"]["shapes"] = [] + return color_to_keep, "", annotation_store, patched_figure @callback( @@ -352,6 +419,21 @@ def reset_filters(n_clicks): return default_brightness, default_contrast +# TODO: check this when plotly is updated +clientside_callback( + """ + function eraseShape(_, graph_id) { + Plotly.eraseActiveShape(graph_id) + return dash_clientside.no_update + } + """, + Output("image-viewer", "id", allow_duplicate=True), + Input("eraser", "n_clicks"), + State("image-viewer", "id"), + prevent_initial_call=True, +) + + @callback( Output("notifications-container", "children"), Output("export-annotation-metadata", "data"), diff --git a/components/control_bar.py b/components/control_bar.py index d48d714e..322ef83a 100644 --- a/components/control_bar.py +++ b/components/control_bar.py @@ -149,6 +149,16 @@ def layout(): label="Closed Freeform: draw a shape that will auto-complete", multiline=True, ), + dmc.Tooltip( + dmc.ActionIcon( + id="line", + variant="outline", + color="gray", + children=DashIconify(icon="ci:line-l"), + ), + label="Line: draw a straight line", + multiline=True, + ), dmc.Tooltip( dmc.ActionIcon( id="circle", @@ -180,7 +190,7 @@ def layout(): color="gray", children=DashIconify(icon="ph:eraser"), ), - label="Eraser: click on shapes to delete them", + label="Eraser: click on the shape to erase then click this button to delete the selected shape", multiline=True, ), dmc.Tooltip( @@ -312,6 +322,7 @@ def layout(): variant="light", ), ), + html.Div(id="bad-label-color"), ], ), dmc.Modal( @@ -336,10 +347,12 @@ def layout(): ), ] ), - dmc.Text( - "There must be at least one annotation class!", - color="red", - id="at-least-one", + dmc.Center( + dmc.Text( + "There must be at least one annotation class!", + color="red", + id="at-least-one", + ), ), ], ), @@ -385,7 +398,7 @@ def layout(): { "color": "rgb(249,82,82)", "label": "1", - "id": random.randint(1, 100), + "id": "1", } ], }, diff --git a/components/image_viewer.py b/components/image_viewer.py index 5c8d3bc2..af86b25a 100644 --- a/components/image_viewer.py +++ b/components/image_viewer.py @@ -13,21 +13,8 @@ } FIGURE_CONFIG = { - "modeBarButtonsToAdd": [ - "drawopenpath", - "drawclosedpath", - "eraseshape", - "drawline", - "drawcircle", - "drawrect", - ], + "displayModeBar": False, "scrollZoom": True, - "modeBarButtonsToRemove": [ - "zoom", - "zoomin", - "zoomout", - "autoscale", - ], } From 001d56ea631dd51b515a7aaa85d4b5d616c64475 Mon Sep 17 00:00:00 2001 From: Tammy Do <61042740+tamidodo@users.noreply.github.com> Date: Thu, 3 Aug 2023 00:15:03 -0700 Subject: [PATCH 2/8] auto set the color of the brush to newest added class --- callbacks/control_bar.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/callbacks/control_bar.py b/callbacks/control_bar.py index f6f96984..19fa00b3 100644 --- a/callbacks/control_bar.py +++ b/callbacks/control_bar.py @@ -145,6 +145,8 @@ def annotation_color(color_value, current_style): This callback is responsible for changing the color of the brush. """ color = ctx.triggered_id["index"] + if all(v is None for v in color_value): + color = current_style[-1]["background-color"] for i in range(len(current_style)): if current_style[i]["background-color"] == color: current_style[i]["border"] = "3px solid black" @@ -272,9 +274,15 @@ def add_delete_classes( annotation_store, image_idx, ): - """Updates the list of available annotation classes""" + """ + Updates the list of available annotation classes, + triggers other things that should happen when classes + are added or deleted like removing annotations or updating + the drawing mode. + """ triggered = ctx.triggered_id image_idx = str(image_idx - 1) + patched_figure = Patch() current_stored_classes = annotation_store["label_mapping"] if class_color is None: class_color = "rgb(255,255,255)" @@ -340,7 +348,6 @@ def add_delete_classes( color_to_keep.append(color) annotation_store["label_mapping"] = current_stored_classes annotation_store["annotations"] = annotations_to_keep - patched_figure = Patch() if image_idx in annotation_store["annotations"]: patched_figure["layout"]["shapes"] = annotation_store["annotations"][ image_idx From ab488c9c075f3ddff413d30cf2fde8c0ef99beb3 Mon Sep 17 00:00:00 2001 From: Tammy Do <61042740+tamidodo@users.noreply.github.com> Date: Thu, 3 Aug 2023 08:53:49 -0700 Subject: [PATCH 3/8] wrap to new line for classes when they don't fit anymore --- callbacks/control_bar.py | 11 ++++++++++- components/control_bar.py | 21 +++++++++++++++------ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/callbacks/control_bar.py b/callbacks/control_bar.py index 19fa00b3..aeb54b8b 100644 --- a/callbacks/control_bar.py +++ b/callbacks/control_bar.py @@ -307,6 +307,9 @@ def add_delete_classes( "background-color": class_color, "border": "1px solid", "color": "black", + "width": "fit-content", + "padding": "5px", + "margin-right": "10px", }, children=class_label, ) @@ -317,7 +320,13 @@ def add_delete_classes( id={"type": "annotation-color", "index": class_color}, w=30, variant="filled", - style={"background-color": class_color, "border": "1px solid"}, + style={ + "background-color": class_color, + "border": "1px solid", + "width": "fit-content", + "padding": "5px", + "margin-right": "10px", + }, children=class_label, ) ) diff --git a/components/control_bar.py b/components/control_bar.py index 322ef83a..574dea0f 100644 --- a/components/control_bar.py +++ b/components/control_bar.py @@ -254,9 +254,7 @@ def layout(): ), dmc.Space(h=20), dmc.Text("Annotation class", size="sm"), - dmc.Group( - spacing="xs", - grow=True, + html.Div( id="annotation-class-selection", children=[ dmc.ActionIcon( @@ -269,10 +267,18 @@ def layout(): style={ "background-color": "rgb(249,82,82)", "border": "3px solid black", + "width": "fit-content", + "padding": "5px", + "margin-right": "10px", }, children="1", ), ], + style={ + "display": "flex", + "flex-wrap": "wrap", + "justify-content": "space-evenly", + }, ), dmc.Space(h=5), html.Div( @@ -332,10 +338,13 @@ def layout(): dmc.Text( "Select all generated classes to remove:" ), - dmc.Group( - spacing="xs", - grow=True, + html.Div( id="current-annotation-classes", + style={ + "display": "flex", + "flex-wrap": "wrap", + "justify-content": "space-evenly", + }, ), dmc.Space(h=10), dmc.Center( From f6389d614294cca31e262aac53c4f269c53e6857 Mon Sep 17 00:00:00 2001 From: Tammy Do <61042740+tamidodo@users.noreply.github.com> Date: Thu, 3 Aug 2023 11:32:58 -0700 Subject: [PATCH 4/8] enable editing of class names --- callbacks/control_bar.py | 36 +++++++++++++++++++++++------ components/control_bar.py | 48 +++++++++++++++++++++++++++++++++------ 2 files changed, 70 insertions(+), 14 deletions(-) diff --git a/callbacks/control_bar.py b/callbacks/control_bar.py index aeb54b8b..65d0af57 100644 --- a/callbacks/control_bar.py +++ b/callbacks/control_bar.py @@ -106,16 +106,17 @@ def annotation_width(width_value): @callback( Output("current-annotation-classes", "children"), + Output("current-annotation-classes-edit", "data"), Input("annotation-class-selection", "children"), - prevent_initial_call=True, ) def make_class_delete_modal(current_classes): - """Creates buttons for the delete selected classes modal""" + """Creates buttons for the delete selected classes and edit selected class modal""" + current_classes_edit = [button["props"]["children"] for button in current_classes] for button in current_classes: color = button["props"]["style"]["background-color"] button["props"]["id"] = {"type": "annotation-delete-buttons", "index": color} button["props"]["style"]["border"] = "1px solid" - return current_classes + return current_classes, current_classes_edit @callback( @@ -192,6 +193,17 @@ def open_delete_class_modal(delete, remove, opened): return not opened +@callback( + Output("edit-annotation-class-modal", "opened"), + Input("edit-annotation-class", "n_clicks"), + Input("relabel-annotation-class", "n_clicks"), + State("edit-annotation-class-modal", "opened"), + prevent_initial_call=True, +) +def open_edit_class_modal(edit, relabel, opened): + return not opened + + @callback( Output("create-annotation-class", "disabled"), Output("bad-label-color", "children"), @@ -254,23 +266,27 @@ def disable_class_deletion(highlighted): Output("image-viewer", "figure", allow_duplicate=True), Input("create-annotation-class", "n_clicks"), Input("remove-annotation-class", "n_clicks"), + Input("relabel-annotation-class", "n_clicks"), State("annotation-class-label", "value"), State("annotation-class-colorpicker", "value"), State("annotation-class-selection", "children"), State({"type": "annotation-delete-buttons", "index": ALL}, "style"), - State({"type": "annotation-delete-buttons", "index": ALL}, "children"), + State("current-annotation-classes-edit", "value"), + State("annotation-class-label-edit", "value"), State("annotation-store", "data"), State("image-selection-slider", "value"), prevent_initial_call=True, ) -def add_delete_classes( +def add_delete_edit_classes( create, remove, + edit, class_label, class_color, current_classes, classes_to_delete, - class_names, + old_label, + new_label, annotation_store, image_idx, ): @@ -284,6 +300,7 @@ def add_delete_classes( image_idx = str(image_idx - 1) patched_figure = Patch() current_stored_classes = annotation_store["label_mapping"] + current_annotations = annotation_store["annotations"] if class_color is None: class_color = "rgb(255,255,255)" else: @@ -331,11 +348,16 @@ def add_delete_classes( ) ) return current_classes, "", annotation_store, no_update + elif triggered == "relabel-annotation-class": + for i in range(len(current_stored_classes)): + if current_stored_classes[i]["label"] == old_label: + annotation_store["label_mapping"][i]["label"] = new_label + current_classes[i]["props"]["children"] = new_label + return current_classes, "", annotation_store, no_update else: color_to_delete = [] color_to_keep = [] annotations_to_keep = {} - current_annotations = annotation_store["annotations"] for i in range(len(classes_to_delete)): if classes_to_delete[i]["border"] == "3px solid black": color_to_delete.append( diff --git a/components/control_bar.py b/components/control_bar.py index 574dea0f..d5a1623b 100644 --- a/components/control_bar.py +++ b/components/control_bar.py @@ -287,21 +287,23 @@ def layout(): id="generate-annotation-class", children="Generate Class", variant="outline", - leftIcon=DashIconify( - icon="ic:baseline-plus" - ), + style={"padding": "0px 12px"}, + ), + dmc.Button( + id="edit-annotation-class", + children="Edit Class", + variant="outline", + style={"padding": "0px 12px"}, ), dmc.Button( id="delete-annotation-class", children="Delete Class", variant="outline", - style={"margin-left": "auto"}, - leftIcon=DashIconify( - icon="octicon:trash-24" - ), + style={"padding": "0px 12px"}, ), ], className="flex-row", + style={"justify-content": "space-evenly"}, ), dmc.Modal( id="generate-annotation-class-modal", @@ -331,6 +333,32 @@ def layout(): html.Div(id="bad-label-color"), ], ), + dmc.Modal( + id="edit-annotation-class-modal", + title="Edit a Custom Annotation Class", + children=[ + dmc.Text("Select a generated class to edit:"), + dmc.Select( + id="current-annotation-classes-edit" + ), + dmc.Space(h=10), + dmc.Center( + dmc.TextInput( + id="annotation-class-label-edit", + placeholder="New Annotation Class Label", + ), + ), + dmc.Space(h=10), + dmc.Center( + dmc.Button( + id="relabel-annotation-class", + children="Edit Annotation Class", + variant="light", + ), + ), + html.Div(id="bad-label"), + ], + ), dmc.Modal( id="delete-annotation-class-modal", title="Delete Custom Annotation Class(es)", @@ -347,6 +375,12 @@ def layout(): }, ), dmc.Space(h=10), + dmc.Center( + dmc.Text( + "NOTE: Deleting a class will delete all annotations associated with that class!", + color="red", + ) + ), dmc.Center( [ dmc.Button( From 7881202137bf63e0284544228eae57332baeaff5 Mon Sep 17 00:00:00 2001 From: Tammy Do <61042740+tamidodo@users.noreply.github.com> Date: Thu, 3 Aug 2023 11:35:26 -0700 Subject: [PATCH 5/8] fix tooltip width --- components/control_bar.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/components/control_bar.py b/components/control_bar.py index d5a1623b..86079097 100644 --- a/components/control_bar.py +++ b/components/control_bar.py @@ -122,9 +122,7 @@ def layout(): ), dmc.Space(h=20), dmc.Text("Annotation mode", size="sm"), - dmc.Group( - spacing="xs", - grow=True, + html.Div( children=[ dmc.Tooltip( dmc.ActionIcon( @@ -216,6 +214,8 @@ def layout(): multiline=True, ), ], + className="flex-row", + style={"justify-content": "space-evenly"}, ), dmc.Modal( title="Warning", From 51f7e83e7d9a9d6b9cd7806b1d1b022bda11ed21 Mon Sep 17 00:00:00 2001 From: Tammy Do <61042740+tamidodo@users.noreply.github.com> Date: Tue, 8 Aug 2023 15:03:08 -0700 Subject: [PATCH 6/8] restrict user from renaming to existing class label --- callbacks/control_bar.py | 98 +++++++++++++++++++++++++++++++++++---- components/control_bar.py | 65 ++++++++++++++++++++++---- 2 files changed, 143 insertions(+), 20 deletions(-) diff --git a/callbacks/control_bar.py b/callbacks/control_bar.py index 65d0af57..6c6b1438 100644 --- a/callbacks/control_bar.py +++ b/callbacks/control_bar.py @@ -15,7 +15,7 @@ from dash_iconify import DashIconify import json from utils.annotations import Annotations -import random +import copy @callback( @@ -107,16 +107,23 @@ def annotation_width(width_value): @callback( Output("current-annotation-classes", "children"), Output("current-annotation-classes-edit", "data"), + Output("current-annotation-classes-hide", "children"), Input("annotation-class-selection", "children"), ) -def make_class_delete_modal(current_classes): +def make_class_delete_edit_hide_modal(current_classes): """Creates buttons for the delete selected classes and edit selected class modal""" current_classes_edit = [button["props"]["children"] for button in current_classes] - for button in current_classes: + current_classes_delete = copy.deepcopy(current_classes) + current_classes_hide = copy.deepcopy(current_classes) + for button in current_classes_delete: color = button["props"]["style"]["background-color"] button["props"]["id"] = {"type": "annotation-delete-buttons", "index": color} button["props"]["style"]["border"] = "1px solid" - return current_classes, current_classes_edit + for button in current_classes_hide: + color = button["props"]["style"]["background-color"] + button["props"]["id"] = {"type": "annotation-hide-buttons", "index": color} + button["props"]["style"]["border"] = "1px solid" + return current_classes_delete, current_classes_edit, current_classes_hide @callback( @@ -126,6 +133,23 @@ def make_class_delete_modal(current_classes): prevent_initial_call=True, ) def highlight_selected_classes(selected_classes, current_styles): + """Highlights selected buttons in delete modal""" + for i in range(len(selected_classes)): + if selected_classes[i] is not None and selected_classes[i] % 2 != 0: + current_styles[i]["border"] = "3px solid black" + else: + current_styles[i]["border"] = "1px solid" + return current_styles + + +@callback( + Output({"type": "annotation-hide-buttons", "index": ALL}, "style"), + Input({"type": "annotation-hide-buttons", "index": ALL}, "n_clicks"), + State({"type": "annotation-hide-buttons", "index": ALL}, "style"), + prevent_initial_call=True, +) +def highlight_selected_hide_classes(selected_classes, current_styles): + """Highlights selected buttons in hide modal""" for i in range(len(selected_classes)): if selected_classes[i] is not None and selected_classes[i] % 2 != 0: current_styles[i]["border"] = "3px solid black" @@ -204,6 +228,17 @@ def open_edit_class_modal(edit, relabel, opened): return not opened +@callback( + Output("hide-annotation-class-modal", "opened"), + Input("hide-annotation-class", "n_clicks"), + Input("conceal-annotation-class", "n_clicks"), + State("hide-annotation-class-modal", "opened"), + prevent_initial_call=True, +) +def open_hide_class_modal(hide, conceal, opened): + return not opened + + @callback( Output("create-annotation-class", "disabled"), Output("bad-label-color", "children"), @@ -222,8 +257,10 @@ def disable_class_creation(label, color, current_labels, current_colors): dmc.Text("This annotation class label is already in use.", color="red") ) if color is None: - color = "rgb(255, 255, 255)" - if color == "rgb(255, 255, 255)" or triggered_id == "annotation-class-colorpicker": + color = "rgb(255,255,255)" + else: + color = color.replace(" ", "") + if color == "rgb(255,255,255)" or triggered_id == "annotation-class-colorpicker": current_colors = [style["background-color"] for style in current_colors] if color in current_colors: warning_text.append( @@ -240,6 +277,25 @@ def disable_class_creation(label, color, current_labels, current_colors): return False, warning_text +@callback( + Output("relabel-annotation-class", "disabled"), + Output("bad-label", "children"), + Input("annotation-class-label-edit", "value"), + State({"type": "annotation-color", "index": ALL}, "children"), + prevent_initial_call=True, +) +def disable_class_editing(label, current_labels): + warning_text = [] + if label in current_labels: + warning_text.append( + dmc.Text("This annotation class label is already in use.", color="red") + ) + if label is None or len(label) == 0 or label in current_labels: + return True, warning_text + else: + return False, warning_text + + @callback( Output("remove-annotation-class", "disabled"), Output("at-least-one", "style"), @@ -259,32 +315,54 @@ def disable_class_deletion(highlighted): return False, {"display": "none"} +@callback( + Output("conceal-annotation-class", "disabled"), + Output("at-least-one-hide", "style"), + Input({"type": "annotation-hide-buttons", "index": ALL}, "style"), + prevent_initial_call=True, +) +def disable_class_hiding(highlighted): + num_selected = 0 + for style in highlighted: + if style["border"] == "3px solid black": + num_selected += 1 + if num_selected == 0: + return True, {"display": "initial"} + else: + return False, {"display": "none"} + + @callback( Output("annotation-class-selection", "children"), Output("annotation-class-label", "value"), + Output("annotation-class-label-edit", "value"), Output("annotation-store", "data", allow_duplicate=True), Output("image-viewer", "figure", allow_duplicate=True), Input("create-annotation-class", "n_clicks"), Input("remove-annotation-class", "n_clicks"), Input("relabel-annotation-class", "n_clicks"), + Input("conceal-annotation-class", "n_clicks"), State("annotation-class-label", "value"), State("annotation-class-colorpicker", "value"), State("annotation-class-selection", "children"), State({"type": "annotation-delete-buttons", "index": ALL}, "style"), + State({"type": "annotation-hide-buttons", "index": ALL}, "style"), State("current-annotation-classes-edit", "value"), State("annotation-class-label-edit", "value"), State("annotation-store", "data"), State("image-selection-slider", "value"), prevent_initial_call=True, ) -def add_delete_edit_classes( +def add_delete_edit_hide_classes( create, remove, edit, + hide, class_label, class_color, current_classes, classes_to_delete, + classes_to_hide, old_label, new_label, annotation_store, @@ -347,13 +425,13 @@ def add_delete_edit_classes( children=class_label, ) ) - return current_classes, "", annotation_store, no_update + return current_classes, "", "", annotation_store, no_update elif triggered == "relabel-annotation-class": for i in range(len(current_stored_classes)): if current_stored_classes[i]["label"] == old_label: annotation_store["label_mapping"][i]["label"] = new_label current_classes[i]["props"]["children"] = new_label - return current_classes, "", annotation_store, no_update + return current_classes, "", "", annotation_store, no_update else: color_to_delete = [] color_to_keep = [] @@ -385,7 +463,7 @@ def add_delete_edit_classes( ] else: patched_figure["layout"]["shapes"] = [] - return color_to_keep, "", annotation_store, patched_figure + return color_to_keep, "", "", annotation_store, patched_figure @callback( diff --git a/components/control_bar.py b/components/control_bar.py index 86079097..03e903f4 100644 --- a/components/control_bar.py +++ b/components/control_bar.py @@ -280,30 +280,45 @@ def layout(): "justify-content": "space-evenly", }, ), - dmc.Space(h=5), - html.Div( - [ + dmc.Space(h=10), + dmc.Group( + grow=True, + children=[ dmc.Button( id="generate-annotation-class", children="Generate Class", variant="outline", - style={"padding": "0px 12px"}, + leftIcon=DashIconify( + icon="ic:baseline-plus" + ), ), dmc.Button( id="edit-annotation-class", children="Edit Class", variant="outline", - style={"padding": "0px 12px"}, + leftIcon=DashIconify(icon="uil:edit"), + ), + ], + ), + dmc.Space(h=10), + dmc.Group( + grow=True, + children=[ + dmc.Button( + id="hide-annotation-class", + children="Hide/Show Classes", + variant="outline", + leftIcon=DashIconify(icon="mdi:hide"), ), dmc.Button( id="delete-annotation-class", - children="Delete Class", + children="Delete Classes", variant="outline", - style={"padding": "0px 12px"}, + leftIcon=DashIconify( + icon="octicon:trash-24" + ), ), ], - className="flex-row", - style={"justify-content": "space-evenly"}, ), dmc.Modal( id="generate-annotation-class-modal", @@ -345,7 +360,7 @@ def layout(): dmc.Center( dmc.TextInput( id="annotation-class-label-edit", - placeholder="New Annotation Class Label", + placeholder="New Class Label", ), ), dmc.Space(h=10), @@ -359,6 +374,36 @@ def layout(): html.Div(id="bad-label"), ], ), + dmc.Modal( + id="hide-annotation-class-modal", + title="Hide/Show Annotation Classes", + children=[ + dmc.Text("Select annotation classes to hide:"), + html.Div( + id="current-annotation-classes-hide", + style={ + "display": "flex", + "flex-wrap": "wrap", + "justify-content": "space-evenly", + }, + ), + dmc.Space(h=10), + dmc.Center( + dmc.Button( + id="conceal-annotation-class", + children="Apply Changes", + variant="light", + ), + ), + dmc.Center( + dmc.Text( + "No classes selected", + color="red", + id="at-least-one-hide", + ), + ), + ], + ), dmc.Modal( id="delete-annotation-class-modal", title="Delete Custom Annotation Class(es)", From 8439a77111179fb80fb622a073c601d024d6afb0 Mon Sep 17 00:00:00 2001 From: Tammy Do <61042740+tamidodo@users.noreply.github.com> Date: Thu, 10 Aug 2023 09:21:33 -0700 Subject: [PATCH 7/8] fix class selection --- callbacks/control_bar.py | 13 +++++++++++-- callbacks/image_viewer.py | 2 +- components/control_bar.py | 2 ++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/callbacks/control_bar.py b/callbacks/control_bar.py index 6c6b1438..71f3b9ae 100644 --- a/callbacks/control_bar.py +++ b/callbacks/control_bar.py @@ -161,6 +161,7 @@ def highlight_selected_hide_classes(selected_classes, current_styles): @callback( Output("image-viewer", "figure", allow_duplicate=True), Output({"type": "annotation-color", "index": ALL}, "style"), + Output({"type": "annotation-color", "index": ALL}, "n_clicks"), Input({"type": "annotation-color", "index": ALL}, "n_clicks"), State({"type": "annotation-color", "index": ALL}, "style"), prevent_initial_call=True, @@ -170,8 +171,9 @@ def annotation_color(color_value, current_style): This callback is responsible for changing the color of the brush. """ color = ctx.triggered_id["index"] - if all(v is None for v in color_value): + 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" @@ -180,7 +182,7 @@ def annotation_color(color_value, current_style): patched_figure = Patch() patched_figure["layout"]["newshape"]["fillcolor"] = color patched_figure["layout"]["newshape"]["line"]["color"] = color - return patched_figure, current_style + return patched_figure, current_style, color_value @callback( @@ -432,6 +434,13 @@ def add_delete_edit_hide_classes( annotation_store["label_mapping"][i]["label"] = new_label current_classes[i]["props"]["children"] = new_label return current_classes, "", "", annotation_store, no_update + # elif triggered == "conceal-annotation-class": + # ann_show = annotation_store["classes_shown"] + # ann_hide = annotation_store["classes_hidden"] + # patched_figure["layout"]["shapes"] + # print(current_annotations) + # print(classes_to_hide) + # return no_update, no_update, no_update, no_update, no_update else: color_to_delete = [] color_to_keep = [] diff --git a/callbacks/image_viewer.py b/callbacks/image_viewer.py index 5e174fef..c7e2fbf1 100644 --- a/callbacks/image_viewer.py +++ b/callbacks/image_viewer.py @@ -101,7 +101,7 @@ def render_image( State("annotation-store", "data"), prevent_initial_call=True, ) -def update_viefinder(relayout_data, annotation_store): +def update_viewfinder(relayout_data, annotation_store): """ When relayoutData is triggered, update the viewfinder box to match the new view position of the image (inlude zooming). The viewfinder box is downsampled to match the size of the viewfinder. diff --git a/components/control_bar.py b/components/control_bar.py index 03e903f4..79e671bc 100644 --- a/components/control_bar.py +++ b/components/control_bar.py @@ -489,6 +489,8 @@ def layout(): "id": "1", } ], + "classes_shown": {}, + "classes_hidden": {}, }, ), dmc.NotificationsProvider(html.Div(id="notifications-container")), From 7bbe002d329e3bdec9cd0e3ffc00c75f565ca7ad Mon Sep 17 00:00:00 2001 From: Tammy Do <61042740+tamidodo@users.noreply.github.com> Date: Thu, 10 Aug 2023 09:26:56 -0700 Subject: [PATCH 8/8] add docstrings --- callbacks/control_bar.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/callbacks/control_bar.py b/callbacks/control_bar.py index 71f3b9ae..a9f25002 100644 --- a/callbacks/control_bar.py +++ b/callbacks/control_bar.py @@ -194,6 +194,7 @@ def annotation_color(color_value, current_style): prevent_initial_call=True, ) def open_warning_modal(delete, cancel, delete_4_real, opened): + """Opens and closes the modal that warns you when you're deleting all annotations""" return not opened @@ -205,6 +206,7 @@ def open_warning_modal(delete, cancel, delete_4_real, opened): prevent_initial_call=True, ) def open_annotation_class_modal(generate, create, opened): + """Opens and closes the modal that is used to create a new annotation class""" return not opened @@ -216,6 +218,7 @@ def open_annotation_class_modal(generate, create, opened): prevent_initial_call=True, ) def open_delete_class_modal(delete, remove, opened): + """Opens and closes the modal that is used to select annotation classes to delete""" return not opened @@ -227,6 +230,7 @@ def open_delete_class_modal(delete, remove, opened): prevent_initial_call=True, ) def open_edit_class_modal(edit, relabel, opened): + """Opens and closes the modal that allows you to relabel an existing annotation class""" return not opened @@ -238,6 +242,7 @@ def open_edit_class_modal(edit, relabel, opened): prevent_initial_call=True, ) def open_hide_class_modal(hide, conceal, opened): + """Opens and closes the modal that allows you to select which classes to hide/show""" return not opened @@ -251,6 +256,7 @@ def open_hide_class_modal(hide, conceal, opened): prevent_initial_call=True, ) def disable_class_creation(label, color, current_labels, current_colors): + """Disables the create class button when the user selects a color or label that belongs to an existing annotation class""" triggered_id = ctx.triggered_id warning_text = [] if triggered_id == "annotation-class-label": @@ -287,6 +293,7 @@ def disable_class_creation(label, color, current_labels, current_colors): prevent_initial_call=True, ) def disable_class_editing(label, current_labels): + """Disables the edit class button when the user tries to rename a class to the same name as an existing class""" warning_text = [] if label in current_labels: warning_text.append( @@ -305,6 +312,7 @@ def disable_class_editing(label, current_labels): prevent_initial_call=True, ) def disable_class_deletion(highlighted): + """Disables the delete class button when all classes would be removed or if no classes are selected to remove""" num_selected = 0 for style in highlighted: if style["border"] == "3px solid black": @@ -324,6 +332,7 @@ def disable_class_deletion(highlighted): prevent_initial_call=True, ) def disable_class_hiding(highlighted): + """Disables the class hide/show button when no classes are selected to either hide or show""" num_selected = 0 for style in highlighted: if style["border"] == "3px solid black":