diff --git a/callbacks/control_bar.py b/callbacks/control_bar.py index 5e0b1c83..03cfdbb6 100644 --- a/callbacks/control_bar.py +++ b/callbacks/control_bar.py @@ -7,8 +7,8 @@ ALL, ctx, clientside_callback, - no_update, ) +from dash.exceptions import PreventUpdate import dash_mantine_components as dmc import json from utils.data_utils import convert_hex_to_rgba, data @@ -21,15 +21,20 @@ Output("circle", "style"), Output("rectangle", "style"), Output("drawing-off", "style"), + Output("annotation-store", "data", allow_duplicate=True), Input("open-freeform", "n_clicks"), Input("closed-freeform", "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): +def annotation_mode(open, closed, circle, rect, off_mode, annotation_store): """This callback determines which drawing mode the graph is in""" + if not annotation_store["visible"]: + raise PreventUpdate + patched_figure = Patch() triggered = ctx.triggered_id open_style = {"border": "1px solid"} @@ -37,22 +42,36 @@ def annotation_mode(open, closed, circle, rect, off_mode): circle_style = {"border": "1px solid"} rect_style = {"border": "1px solid"} pan_style = {"border": "1px solid"} + if triggered == "open-freeform" and open > 0: patched_figure["layout"]["dragmode"] = "drawopenpath" + annotation_store["dragmode"] = "drawopenpath" open_style = {"border": "3px solid black"} if triggered == "closed-freeform" and closed > 0: patched_figure["layout"]["dragmode"] = "drawclosedpath" + annotation_store["dragmode"] = "drawclosedpath" close_style = {"border": "3px solid black"} if triggered == "circle" and circle > 0: patched_figure["layout"]["dragmode"] = "drawcircle" + annotation_store["dragmode"] = "drawcircle" circle_style = {"border": "3px solid black"} if triggered == "rectangle" and rect > 0: patched_figure["layout"]["dragmode"] = "drawrect" + annotation_store["dragmode"] = "drawrect" rect_style = {"border": "3px solid black"} if triggered == "drawing-off" and off_mode > 0: patched_figure["layout"]["dragmode"] = "pan" + annotation_store["dragmode"] = "pan" pan_style = {"border": "3px solid black"} - return patched_figure, open_style, close_style, circle_style, rect_style, pan_style + return ( + patched_figure, + open_style, + close_style, + circle_style, + rect_style, + pan_style, + annotation_store, + ) @callback( @@ -90,7 +109,7 @@ def annotation_color(color_value): @callback( - Output("annotation-store", "data"), + Output("annotation-store", "data", allow_duplicate=True), Output("image-viewer", "figure", allow_duplicate=True), Input("view-annotations", "checked"), State("annotation-store", "data"), @@ -98,25 +117,31 @@ def annotation_color(color_value): State("image-selection-slider", "value"), prevent_initial_call=True, ) -def annotation_visibility(checked, store, figure, image_idx): +def annotation_visibility(checked, 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) - + image_idx = str(image_idx - 1) patched_figure = Patch() if checked: - store["visible"] = True - patched_figure["layout"]["shapes"] = store[image_idx] + 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: - annotation_data = ( + new_annotation_data = ( [] if "shapes" not in figure["layout"] else figure["layout"]["shapes"] ) - store[image_idx] = annotation_data + annotation_store["visible"] = False + patched_figure["layout"]["dragmode"] = False + annotation_store["annotations"][image_idx] = new_annotation_data patched_figure["layout"]["shapes"] = [] - return store, patched_figure + return annotation_store, patched_figure clientside_callback( diff --git a/callbacks/image_viewer.py b/callbacks/image_viewer.py index a10504e8..e4e0be70 100644 --- a/callbacks/image_viewer.py +++ b/callbacks/image_viewer.py @@ -3,7 +3,6 @@ from tifffile import imread import plotly.express as px import numpy as np -from utils import data_utils from utils.data_utils import convert_hex_to_rgba, data @@ -13,12 +12,14 @@ State("project-name-src", "value"), State("paintbrush-width", "value"), State("annotation-class-selection", "className"), + State("annotation-store", "data"), ) def render_image( image_idx, project_name, annotation_width, annotation_color, + annotation_store, ): if image_idx: image_idx -= 1 # slider starts at 1, so subtract 1 to get the correct index @@ -42,24 +43,58 @@ def render_image( hex_color = dmc.theme.DEFAULT_COLORS[annotation_color][7] fig.update_layout( newshape=dict( - line=dict(color=annotation_color, width=annotation_width), + line=dict( + color=convert_hex_to_rgba(hex_color, 0.3), width=annotation_width + ), fillcolor=convert_hex_to_rgba(hex_color, 0.3), ) ) + if annotation_store: + if not annotation_store["visible"]: + fig["layout"]["shapes"] = [] + fig["layout"]["dragmode"] = False + return fig + + fig["layout"]["dragmode"] = annotation_store["dragmode"] + + if str(image_idx) in annotation_store["annotations"]: + fig["layout"]["shapes"] = annotation_store["annotations"][str(image_idx)] + return fig +@callback( + Output("annotation-store", "data", allow_duplicate=True), + Input("image-viewer", "relayoutData"), + State("image-selection-slider", "value"), + State("annotation-store", "data"), + prevent_initial_call=True, +) +def locally_store_annotations(relayout_data, img_idx, annotation_store): + """ + Upon finishing relayout event (drawing, but it also includes panning, zooming), + this function takes the annotation shapes, and stores it in the dcc.Store, which is then used elsewhere + to preserve drawn annotations, or to save them. + """ + if "shapes" in relayout_data: + annotation_store["annotations"][str(img_idx - 1)] = relayout_data["shapes"] + return annotation_store + + @callback( Output("image-selection-slider", "min"), Output("image-selection-slider", "max"), Output("image-selection-slider", "value"), Output("image-selection-slider", "disabled"), + Output("annotation-store", "data"), Input("project-name-src", "value"), + State("annotation-store", "data"), ) -def update_slider_values(project_name): +def update_slider_values(project_name, annotation_store): """ When the data source is loaded, this callback will set the slider values and chain call "update_selection_and_image" callback which will update image and slider selection component + It also resets "annotation-store" data to {} so that existing annotations don't carry over to the new project. ## todo - change Input("project-name-src", "data") to value when image-src will contain buckets of data and not just one image ## todo - eg, when a different image source is selected, update slider values which is then used to select image within that source @@ -71,11 +106,13 @@ def update_slider_values(project_name): min_slider_value = 0 if disable_slider else 1 max_slider_value = 0 if disable_slider else len(tiff_file) slider_value = 0 if disable_slider else 1 + annotation_store["annotations"] = {} return ( min_slider_value, max_slider_value, slider_value, disable_slider, + annotation_store, ) diff --git a/components/control_bar.py b/components/control_bar.py index f6f2a172..f5a882c8 100644 --- a/components/control_bar.py +++ b/components/control_bar.py @@ -244,7 +244,10 @@ def layout(): ), ], ), - dcc.Store(id="annotation-store", data={}), + dcc.Store( + id="annotation-store", + data={"dragmode": "drawopenpath", "visible": True, "annotations": {}}, + ), dcc.Store(id="project-data"), html.Div(id="dummy-output"), ],