From 94b662b5fd2036dd9d339249bcb0555495022186 Mon Sep 17 00:00:00 2001 From: admin <51248046+danton267@users.noreply.github.com> Date: Thu, 13 Jul 2023 12:44:44 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20cross-layer-annotation=20saving?= =?UTF-8?q?,=20show/disable=20ann?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- callbacks/control_bar.py | 31 ++++++++++++++++++++----------- callbacks/image_viewer.py | 39 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/callbacks/control_bar.py b/callbacks/control_bar.py index 5e0b1c83..239226bf 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 @@ -26,10 +26,15 @@ 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 "visible" in annotation_store: + if not annotation_store["visible"]: + raise PreventUpdate + patched_figure = Patch() triggered = ctx.triggered_id open_style = {"border": "1px solid"} @@ -37,6 +42,7 @@ 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" open_style = {"border": "3px solid black"} @@ -90,7 +96,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 +104,28 @@ 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"]["dragmode"] = True + if str(image_idx) in annotation_store: + patched_figure["layout"]["shapes"] = annotation_store[image_idx] 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[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..80d79472 100644 --- a/callbacks/image_viewer.py +++ b/callbacks/image_viewer.py @@ -13,12 +13,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_data, ): if image_idx: image_idx -= 1 # slider starts at 1, so subtract 1 to get the correct index @@ -42,24 +44,57 @@ 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_data: + if "visible" in annotation_data: + print("visible" in annotation_data, not annotation_data["visible"]) + if not annotation_data["visible"]: + fig["layout"]["shapes"] = [] + fig["layout"]["dragmode"] = False + return fig + if str(image_idx) in annotation_data: + fig["layout"]["shapes"] = annotation_data[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_data): + """ + 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_data[str(img_idx - 1)] = relayout_data["shapes"] + return annotation_data + + @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"), ) def update_slider_values(project_name): """ 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 = {} return ( min_slider_value, max_slider_value, slider_value, disable_slider, + annotation_store, ) From 4c9b98e47bece23ccf77c924577a8e6c752df214 Mon Sep 17 00:00:00 2001 From: admin <51248046+danton267@users.noreply.github.com> Date: Thu, 13 Jul 2023 13:13:39 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=9A=B8=20improve=20figure=20dragmode?= =?UTF-8?q?=20and=20visibility=20saving?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- callbacks/control_bar.py | 32 ++++++++++++++++++++++++-------- callbacks/image_viewer.py | 32 ++++++++++++++++---------------- components/control_bar.py | 5 ++++- 3 files changed, 44 insertions(+), 25 deletions(-) diff --git a/callbacks/control_bar.py b/callbacks/control_bar.py index 239226bf..03cfdbb6 100644 --- a/callbacks/control_bar.py +++ b/callbacks/control_bar.py @@ -21,6 +21,7 @@ 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"), @@ -31,9 +32,8 @@ ) def annotation_mode(open, closed, circle, rect, off_mode, annotation_store): """This callback determines which drawing mode the graph is in""" - if "visible" in annotation_store: - if not annotation_store["visible"]: - raise PreventUpdate + if not annotation_store["visible"]: + raise PreventUpdate patched_figure = Patch() triggered = ctx.triggered_id @@ -45,20 +45,33 @@ def annotation_mode(open, closed, circle, rect, off_mode, annotation_store): 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( @@ -113,16 +126,19 @@ def annotation_visibility(checked, annotation_store, figure, image_idx): patched_figure = Patch() if checked: annotation_store["visible"] = True - patched_figure["layout"]["dragmode"] = True - if str(image_idx) in annotation_store: - patched_figure["layout"]["shapes"] = annotation_store[image_idx] + 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[image_idx] = new_annotation_data + annotation_store["annotations"][image_idx] = new_annotation_data patched_figure["layout"]["shapes"] = [] return annotation_store, patched_figure diff --git a/callbacks/image_viewer.py b/callbacks/image_viewer.py index 80d79472..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 @@ -20,7 +19,7 @@ def render_image( project_name, annotation_width, annotation_color, - annotation_data, + annotation_store, ): if image_idx: image_idx -= 1 # slider starts at 1, so subtract 1 to get the correct index @@ -50,16 +49,16 @@ def render_image( 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 - if annotation_data: - if "visible" in annotation_data: - print("visible" in annotation_data, not annotation_data["visible"]) - if not annotation_data["visible"]: - fig["layout"]["shapes"] = [] - fig["layout"]["dragmode"] = False - return fig - if str(image_idx) in annotation_data: - fig["layout"]["shapes"] = annotation_data[str(image_idx)] + 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 @@ -71,15 +70,15 @@ def render_image( State("annotation-store", "data"), prevent_initial_call=True, ) -def locally_store_annotations(relayout_data, img_idx, annotation_data): +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_data[str(img_idx - 1)] = relayout_data["shapes"] - return annotation_data + annotation_store["annotations"][str(img_idx - 1)] = relayout_data["shapes"] + return annotation_store @callback( @@ -89,8 +88,9 @@ def locally_store_annotations(relayout_data, img_idx, annotation_data): 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 @@ -106,7 +106,7 @@ 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 = {} + annotation_store["annotations"] = {} return ( min_slider_value, max_slider_value, 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"), ],