Skip to content

Commit

Permalink
Merge pull request #50 from mlexchange/update-annotation-ui
Browse files Browse the repository at this point in the history
Update annotation UI
  • Loading branch information
hannahker authored Jul 24, 2023
2 parents e749003 + 7641555 commit 57db682
Show file tree
Hide file tree
Showing 6 changed files with 350 additions and 84 deletions.
5 changes: 3 additions & 2 deletions app.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from dash import Dash
from dash import Dash, dcc
import dash_mantine_components as dmc
from components.control_bar import layout as control_bar_layout
from components.image_viewer import layout as image_viewer_layout
Expand All @@ -16,7 +16,8 @@
control_bar_layout(),
image_viewer_layout(),
],
)
),
dcc.Store(id="current-ann-mode"),
],
)

Expand Down
13 changes: 4 additions & 9 deletions assets/style.css
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
/* These css classes highlight the selected annotation color (class) */

.red>.red-icon,
.grape>.grape-icon,
.violet>.violet-icon,
.blue>.blue-icon,
.yellow>.yellow-icon {
border: 3px solid black;
.flex-row {
display: flex;
flex-direction: row;
}

.mantine-Grid-col > .mantine-1avyp1d {
.mantine-Grid-col>.mantine-1avyp1d {
margin: auto;
}
234 changes: 197 additions & 37 deletions callbacks/control_bar.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@
)
from dash.exceptions import PreventUpdate
import dash_mantine_components as dmc
import json
from utils.data_utils import convert_hex_to_rgba, data
from utils.data_utils import data


@callback(
Expand All @@ -22,6 +21,7 @@
Output("rectangle", "style"),
Output("drawing-off", "style"),
Output("annotation-store", "data", allow_duplicate=True),
Output("current-ann-mode", "data", allow_duplicate=True),
Input("open-freeform", "n_clicks"),
Input("closed-freeform", "n_clicks"),
Input("circle", "n_clicks"),
Expand All @@ -37,32 +37,34 @@ def annotation_mode(open, closed, circle, rect, off_mode, annotation_store):

patched_figure = Patch()
triggered = ctx.triggered_id
open_style = {"border": "1px solid"}
close_style = {"border": "1px solid"}
circle_style = {"border": "1px solid"}
rect_style = {"border": "1px solid"}
pan_style = {"border": "1px solid"}
active = {"border": "3px solid black"}
inactive = {"border": "1px solid"}
open_style = inactive
close_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 = {"border": "3px solid black"}
open_style = active
if triggered == "closed-freeform" and closed > 0:
patched_figure["layout"]["dragmode"] = "drawclosedpath"
annotation_store["dragmode"] = "drawclosedpath"
close_style = {"border": "3px solid black"}
close_style = active
if triggered == "circle" and circle > 0:
patched_figure["layout"]["dragmode"] = "drawcircle"
annotation_store["dragmode"] = "drawcircle"
circle_style = {"border": "3px solid black"}
circle_style = active
if triggered == "rectangle" and rect > 0:
patched_figure["layout"]["dragmode"] = "drawrect"
annotation_store["dragmode"] = "drawrect"
rect_style = {"border": "3px solid black"}
rect_style = active
if triggered == "drawing-off" and off_mode > 0:
patched_figure["layout"]["dragmode"] = "pan"
annotation_store["dragmode"] = "pan"
pan_style = {"border": "3px solid black"}
pan_style = active
return (
patched_figure,
open_style,
Expand All @@ -71,6 +73,7 @@ def annotation_mode(open, closed, circle, rect, off_mode, annotation_store):
rect_style,
pan_style,
annotation_store,
triggered,
)


Expand All @@ -88,58 +91,215 @@ def annotation_width(width_value):
return patched_figure


@callback(
Output("current-annotation-classes", "children"),
Input("annotation-class-selection", "children"),
prevent_initial_call=True,
)
def make_class_delete_modal(current_classes):
"""Creates buttons for the delete selected classes modal"""
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


@callback(
Output({"type": "annotation-delete-buttons", "index": ALL}, "style"),
Input({"type": "annotation-delete-buttons", "index": ALL}, "n_clicks"),
State({"type": "annotation-delete-buttons", "index": ALL}, "style"),
prevent_initial_call=True,
)
def highlight_selected_classes(selected_classes, current_styles):
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("image-viewer", "figure", allow_duplicate=True),
Output("annotation-class-selection", "className"),
Output({"type": "annotation-color", "index": ALL}, "style"),
Input({"type": "annotation-color", "index": ALL}, "n_clicks"),
State({"type": "annotation-color", "index": ALL}, "style"),
prevent_initial_call=True,
)
def annotation_color(color_value):
def annotation_color(color_value, current_style):
"""
This callback is responsible for changing the color of the brush.
"""
color_name = json.loads(ctx.triggered[0]["prop_id"].split(".")[0])["index"]
hex_color = dmc.theme.DEFAULT_COLORS[color_name][7]
color = ctx.triggered_id["index"]
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"
patched_figure = Patch()
patched_figure["layout"]["newshape"]["fillcolor"] = convert_hex_to_rgba(
hex_color, 0.3
)
patched_figure["layout"]["newshape"]["line"]["color"] = hex_color
return patched_figure, color_name
patched_figure["layout"]["newshape"]["fillcolor"] = color
patched_figure["layout"]["newshape"]["line"]["color"] = color
return patched_figure, current_style


@callback(
Output("delete-all-warning", "opened"),
Input("delete-all", "n_clicks"),
Input("modal-cancel-button", "n_clicks"),
Input("modal-delete-button", "n_clicks"),
State("delete-all-warning", "opened"),
prevent_initial_call=True,
)
def open_warning_modal(delete, cancel, delete_4_real, opened):
return not opened


@callback(
Output("generate-annotation-class-modal", "opened"),
Input("generate-annotation-class", "n_clicks"),
Input("create-annotation-class", "n_clicks"),
State("generate-annotation-class-modal", "opened"),
prevent_initial_call=True,
)
def open_annotation_class_modal(generate, create, opened):
return not opened


@callback(
Output("delete-annotation-class-modal", "opened"),
Input("delete-annotation-class", "n_clicks"),
Input("remove-annotation-class", "n_clicks"),
State("delete-annotation-class-modal", "opened"),
prevent_initial_call=True,
)
def open_delete_class_modal(delete, remove, opened):
return not opened


@callback(
Output("create-annotation-class", "disabled"),
Input("annotation-class-label", "value"),
)
def disable_class_creation(label):
if label is None or len(label) == 0:
return True
else:
return False


@callback(
Output("remove-annotation-class", "disabled"),
Output("at-least-one", "style"),
Input({"type": "annotation-delete-buttons", "index": ALL}, "style"),
prevent_initial_call=True,
)
def disable_class_deletion(highlighted):
num_selected = 0
for style in highlighted:
if style["border"] == "3px solid black":
num_selected += 1
if num_selected == 0:
return True, {"display": "none"}
if num_selected == len(highlighted):
return True, {"display": "initial"}
else:
return False, {"display": "none"}


@callback(
Output("annotation-class-selection", "children"),
Output("annotation-class-label", "value"),
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"),
prevent_initial_call=True,
)
def add_new_class(
create, remove, class_label, class_color, current_classes, classes_to_delete
):
"""Updates the list of available annotation classes"""
triggered = ctx.triggered_id
if triggered == "create-annotation-class":
if class_color is None:
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},
w=30,
variant="filled",
style={
"background-color": class_color,
"border": "1px solid",
"color": "black",
},
children=class_label,
)
)
else:
current_classes.append(
dmc.ActionIcon(
id={"type": "annotation-color", "index": class_color},
w=30,
variant="filled",
style={"background-color": class_color, "border": "1px solid"},
children=class_label,
)
)
return current_classes, ""
else:
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"])
for color in current_classes:
if color["props"]["id"]["index"] not in color_to_delete:
color_to_keep.append(color)
return color_to_keep, ""


@callback(
Output("annotation-store", "data", allow_duplicate=True),
Output("image-viewer", "figure", allow_duplicate=True),
Input("view-annotations", "checked"),
Input("modal-delete-button", "n_clicks"),
State("annotation-store", "data"),
State("image-viewer", "figure"),
State("image-selection-slider", "value"),
prevent_initial_call=True,
)
def annotation_visibility(checked, annotation_store, figure, image_idx):
def annotation_visibility(checked, delete_all, annotation_store, figure, image_idx):
"""
This callback is responsible for toggling the visibility of the annotation layer.
It also saves the annotation data to the store when the layer is hidden, and then loads it back in when the layer is shown again.
"""
image_idx = str(image_idx - 1)
patched_figure = Patch()
if checked:
annotation_store["visible"] = True
patched_figure["layout"]["visible"] = True
if str(image_idx) in annotation_store["annotations"]:
patched_figure["layout"]["shapes"] = annotation_store["annotations"][
image_idx
]
patched_figure["layout"]["dragmode"] = annotation_store["dragmode"]
else:
new_annotation_data = (
[] if "shapes" not in figure["layout"] else figure["layout"]["shapes"]
)
annotation_store["visible"] = False
patched_figure["layout"]["dragmode"] = False
annotation_store["annotations"][image_idx] = new_annotation_data
if ctx.triggered_id == "modal-delete-button":
annotation_store["annotations"][image_idx] = []
patched_figure["layout"]["shapes"] = []
else:
if checked:
annotation_store["visible"] = True
patched_figure["layout"]["visible"] = True
if str(image_idx) in annotation_store["annotations"]:
patched_figure["layout"]["shapes"] = annotation_store["annotations"][
image_idx
]
patched_figure["layout"]["dragmode"] = annotation_store["dragmode"]
else:
new_annotation_data = (
[] if "shapes" not in figure["layout"] else figure["layout"]["shapes"]
)
annotation_store["visible"] = False
patched_figure["layout"]["dragmode"] = False
annotation_store["annotations"][image_idx] = new_annotation_data
patched_figure["layout"]["shapes"] = []

return annotation_store, patched_figure

Expand Down
16 changes: 8 additions & 8 deletions callbacks/image_viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from tifffile import imread
import plotly.express as px
import numpy as np
from utils.data_utils import convert_hex_to_rgba, data
from utils.data_utils import data


@callback(
Expand All @@ -13,15 +13,15 @@
Input("image-selection-slider", "value"),
State("project-name-src", "value"),
State("paintbrush-width", "value"),
State("annotation-class-selection", "className"),
State("annotation-class-selection", "children"),
State("annotation-store", "data"),
prevent_initial_call=True,
)
def render_image(
image_idx,
project_name,
annotation_width,
annotation_color,
annotation_colors,
annotation_store,
):
if image_idx:
Expand All @@ -41,13 +41,13 @@ def render_image(
fig.update_traces(hovertemplate=None, hoverinfo="skip")

# set the default annotation style
hex_color = dmc.theme.DEFAULT_COLORS[annotation_color][7]
for color_opt in annotation_colors:
if color_opt["props"]["style"]["border"] != "1px solid":
color = color_opt["props"]["style"]["background-color"]
fig.update_layout(
newshape=dict(
line=dict(
color=convert_hex_to_rgba(hex_color, 0.3), width=annotation_width
),
fillcolor=convert_hex_to_rgba(hex_color, 0.3),
line=dict(color=color, width=annotation_width),
fillcolor=color,
)
)
if annotation_store:
Expand Down
Loading

0 comments on commit 57db682

Please sign in to comment.