diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml new file mode 100644 index 00000000..50673e66 --- /dev/null +++ b/.github/workflows/publish-image.yml @@ -0,0 +1,45 @@ +name: Create and publish image + +on: + push: + branches: ['main'] + tags: ['v*'] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Log in to the Container registry + uses: docker/login-action@v2 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v4 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Build and push Docker image + uses: docker/build-push-action@v4 + with: + context: . + file: Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..09729130 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.9 + +# Create and set working directory +WORKDIR /app + +# Install Python dependencies +RUN python -m pip install --upgrade pip +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +# Copy the remainder of the code into the image +COPY . ./ + +EXPOSE 8075 +# Run Dash app with gunicorn +CMD ["gunicorn", "-b", "0.0.0.0:8075", "--reload", "app:server"] +# Better than the alternative running of app.py directly with +#CMD ["python", "app.py"] diff --git a/README.md b/README.md index 0f9a13e6..af6aedb1 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,8 @@ pip install -r requirements.txt 2. Configure a connection to the Tiled server via a `.env` file with the following environment variables: ``` -TILED_URI='https://mlex-segmentation.als.lbl.gov' -API_KEY='' +TILED_URI=https://mlex-segmentation.als.lbl.gov +API_KEY= ``` 3. Start a local server: @@ -24,6 +24,15 @@ API_KEY='' python app.py ``` +### Local tiled connection + +To start local tiled connection: +1. Add `SERVE_LOCALLY=True` flag to `env` file (or to your environmental variables) +2. Start the app once, which will create `data/` directory and download 2 sample projects with 2 images each. +3. Open a second terminal and run `tiled serve directory --public data`. + +The app will now connect to the local tiled server. + # Copyright MLExchange Copyright (c) 2023, The Regents of the University of California, through Lawrence Berkeley National Laboratory (subject to receipt of any required approvals from the U.S. Dept. of Energy). All rights reserved. diff --git a/app.py b/app.py index 41a1b9aa..7f0dab24 100644 --- a/app.py +++ b/app.py @@ -1,9 +1,10 @@ -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 from callbacks.image_viewer import * from callbacks.control_bar import * +from callbacks.segmentation import * app = Dash(__name__) server = app.server @@ -16,7 +17,8 @@ control_bar_layout(), image_viewer_layout(), ], - ) + ), + dcc.Store(id="current-ann-mode"), ], ) diff --git a/assets/style.css b/assets/style.css index 9e99241f..d20ca073 100644 --- a/assets/style.css +++ b/assets/style.css @@ -1,9 +1,8 @@ -/* These css classes highlight the selected annotation color (class) */ +.flex-row { + display: flex; + flex-direction: row; +} -.red>.red-icon, -.grape>.grape-icon, -.violet>.violet-icon, -.blue>.blue-icon, -.yellow>.yellow-icon { - border: 3px solid black; +.mantine-Grid-col>.mantine-1avyp1d { + margin: auto; } \ No newline at end of file diff --git a/callbacks/control_bar.py b/callbacks/control_bar.py index ce0356a8..baf7824f 100644 --- a/callbacks/control_bar.py +++ b/callbacks/control_bar.py @@ -14,8 +14,8 @@ import dash_mantine_components as dmc from dash_iconify import DashIconify import json -from utils.data_utils import convert_hex_to_rgba, data from utils.annotations import Annotations +import random @callback( @@ -26,6 +26,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"), @@ -41,32 +42,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, @@ -75,6 +78,7 @@ def annotation_mode(open, closed, circle, rect, off_mode, annotation_store): rect_style, pan_style, annotation_store, + triggered, ) @@ -92,58 +96,230 @@ 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"), + Output("annotation-store", "data", 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("annotation-store", "data"), + prevent_initial_call=True, +) +def add_new_class( + create, + remove, + class_label, + class_color, + current_classes, + classes_to_delete, + annotation_store, +): + """Updates the list of available annotation classes""" + triggered = ctx.triggered_id + if triggered == "create-annotation-class": + # TODO: Check that the randint isn't already assigned to a class + annotation_store["label_mapping"].append( + {"color": class_color, "id": random.randint(1, 100), "label": class_label} + ) + 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, + ) + ) + output_classes = current_classes + 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"]) + 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 @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 diff --git a/callbacks/image_viewer.py b/callbacks/image_viewer.py index af794679..5461b725 100644 --- a/callbacks/image_viewer.py +++ b/callbacks/image_viewer.py @@ -1,17 +1,18 @@ -from dash import Input, Output, State, callback, ctx, clientside_callback +from dash import Input, Output, State, callback, ctx, Patch, clientside_callback import dash_mantine_components as dmc 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( Output("image-viewer", "figure"), + Output("annotation-store", "data", allow_duplicate=True), Output("image-viewer-loading", "zIndex", allow_duplicate=True), 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, ) @@ -19,7 +20,7 @@ def render_image( image_idx, project_name, annotation_width, - annotation_color, + annotation_colors, annotation_store, ): if image_idx: @@ -33,21 +34,19 @@ def render_image( xaxis=dict(visible=False), yaxis=dict(visible=False), dragmode="drawopenpath", - height=620, - width=620, paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", ) 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: @@ -61,8 +60,17 @@ def render_image( if str(image_idx) in annotation_store["annotations"]: fig["layout"]["shapes"] = annotation_store["annotations"][str(image_idx)] + view = annotation_store["view"] + if "xaxis_range_0" in view and annotation_store["image_size"] == tf.size: + fig.update_layout( + xaxis=dict(range=[view["xaxis_range_0"], view["xaxis_range_1"]]), + yaxis=dict(range=[view["yaxis_range_0"], view["yaxis_range_1"]]), + ) + patched_annotation_store = Patch() + patched_annotation_store["image_size"] = tf.size fig_loading_overlay = -1 - return fig, fig_loading_overlay + + return fig, patched_annotation_store, fig_loading_overlay clientside_callback( @@ -91,6 +99,13 @@ def locally_store_annotations(relayout_data, img_idx, annotation_store): """ if "shapes" in relayout_data: annotation_store["annotations"][str(img_idx - 1)] = relayout_data["shapes"] + + if "xaxis.range[0]" in relayout_data: + annotation_store["view"]["xaxis_range_0"] = relayout_data["xaxis.range[0]"] + annotation_store["view"]["xaxis_range_1"] = relayout_data["xaxis.range[1]"] + annotation_store["view"]["yaxis_range_0"] = relayout_data["yaxis.range[0]"] + annotation_store["view"]["yaxis_range_1"] = relayout_data["yaxis.range[1]"] + return annotation_store @@ -116,10 +131,8 @@ def update_slider_values(project_name, annotation_store): disable_slider = project_name is None if not disable_slider: tiff_file = data[project_name] - annotation_store["image_shapes"] = [ - (tiff_file[im_str].shape[0], tiff_file[im_str].shape[1]) - for im_str in tiff_file - ] + # TODO: Assuming that all slices have the same image shape + annotation_store["image_shapes"] = [(tiff_file.shape[1], tiff_file.shape[2])] 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 diff --git a/callbacks/segmentation.py b/callbacks/segmentation.py new file mode 100644 index 00000000..80fd23f0 --- /dev/null +++ b/callbacks/segmentation.py @@ -0,0 +1,50 @@ +from dash import callback, Input, Output, State, no_update +from utils.annotations import Annotations +from utils.data_utils import data +import numpy as np + + +# NEXT STEPS: +# - this function returns a job ID, which would be associated with the workflow run on vaughan +# - then we need another callback to pick up this ID and start polling for successful output +@callback( + Output("output-placeholder", "children"), + Input("run-model", "n_clicks"), + State("annotation-store", "data"), + State("project-name-src", "value"), +) +def run_job(n_clicks, annotation_store, project_name): + # As a placeholder, pulling together the inputs we'd need if we were going to submit a job + if n_clicks: + + annotations = Annotations(annotation_store) + annotations.create_annotation_metadata() + annotations.create_annotation_mask( + sparse=False + ) # TODO: Would sparse need to be true? + + # Get metadata and annotation data + metadata = annotations.get_annotations() + mask = annotations.get_annotation_mask() + + # Get raw images associated with each annotated slice + # Actually we can just pass the indices and have the job point to Tiled directly + img_idx = list(metadata.keys()) + img = data[project_name] + raw = [] + for idx in img_idx: + ar = img[int(idx)] + raw.append(ar) + try: + raw = np.stack(raw) + mask = np.stack(mask) + except ValueError: + return "No annotations to process." + + # Some checks to validate that things are in the format we'd expect + print(metadata) + print(mask.shape) + print(raw.shape) + + return "Running the model..." + return no_update diff --git a/components/control_bar.py b/components/control_bar.py index b33c7648..9362c2ef 100644 --- a/components/control_bar.py +++ b/components/control_bar.py @@ -2,9 +2,10 @@ from dash import html, dcc from dash_iconify import DashIconify from utils import data_utils +import random COMPONENT_STYLE = { - "width": "25vw", + "width": "400px", "height": "calc(100vh - 40px)", "padding": "10px", "borderRadius": "5px", @@ -135,7 +136,8 @@ def layout(): children=DashIconify(icon="mdi:draw"), style={"border": "3px solid black"}, ), - label="Open Freeform", + label="Open Freeform: draw any open shape", + multiline=True, ), dmc.Tooltip( dmc.ActionIcon( @@ -146,7 +148,8 @@ def layout(): icon="fluent:draw-shape-20-regular" ), ), - label="Closed Freeform", + label="Closed Freeform: draw a shape that will auto-complete", + multiline=True, ), dmc.Tooltip( dmc.ActionIcon( @@ -157,7 +160,8 @@ def layout(): icon="gg:shape-circle" ), ), - label="Circle", + label="Circle: create a filled circle", + multiline=True, ), dmc.Tooltip( dmc.ActionIcon( @@ -168,7 +172,30 @@ def layout(): icon="gg:shape-square" ), ), - label="Rectangle", + label="Rectangle: create a filled rectangle", + multiline=True, + ), + dmc.Tooltip( + dmc.ActionIcon( + id="eraser", + variant="outline", + color="gray", + children=DashIconify(icon="ph:eraser"), + ), + label="Eraser: click on shapes to delete them", + multiline=True, + ), + dmc.Tooltip( + dmc.ActionIcon( + id="delete-all", + variant="outline", + color="gray", + children=DashIconify( + icon="octicon:trash-24" + ), + ), + label="Clear All Annotations", + multiline=True, ), dmc.Tooltip( dmc.ActionIcon( @@ -177,7 +204,32 @@ def layout(): color="gray", children=DashIconify(icon="el:off"), ), - label="Stop Drawing", + label="Stop Drawing: pan, zoom, select annotations and edit them using the nodes", + multiline=True, + ), + ], + ), + dmc.Modal( + title="Warning", + id="delete-all-warning", + children=[ + dmc.Text( + "This action will permanently clear all annotations on this image frame. Are you sure you want to proceed?" + ), + dmc.Space(h=20), + dmc.Group( + [ + dmc.Button( + "Cancel", id="modal-cancel-button" + ), + dmc.Button( + "Continue", + color="red", + variant="outline", + id="modal-delete-button", + ), + ], + position="right", ), ], ), @@ -198,40 +250,103 @@ def layout(): spacing="xs", grow=True, id="annotation-class-selection", - className=DEFAULT_ANNOTATION_CLASS, children=[ dmc.ActionIcon( - children=(i + 1), - color=color, - variant="filled", - className=f"{color}-icon", - id={"type": "annotation-color", "index": color}, + id={ + "type": "annotation-color", + "index": "rgb(249,82,82)", + }, w=30, - ) - for i, color in enumerate( + variant="filled", + style={ + "background-color": "rgb(249,82,82)", + "border": "3px solid black", + }, + children="1", + ), + ], + ), + dmc.Space(h=5), + html.Div( + [ + dmc.Button( + id="generate-annotation-class", + children="Generate Class", + variant="outline", + leftIcon=DashIconify(icon="ic:baseline-plus"), + ), + dmc.Button( + id="delete-annotation-class", + children="Delete Class", + variant="outline", + style={"margin-left": "auto"}, + leftIcon=DashIconify(icon="octicon:trash-24"), + ), + ], + className="flex-row", + ), + dmc.Modal( + id="generate-annotation-class-modal", + title="Generate a Custom Annotation Class", + children=[ + dmc.Center( + dmc.ColorPicker( + id="annotation-class-colorpicker", + format="rgb", + ), + ), + dmc.Space(h=10), + dmc.Center( + dmc.TextInput( + id="annotation-class-label", + placeholder="Annotation Class Label", + ), + ), + dmc.Space(h=10), + dmc.Center( + dmc.Button( + id="create-annotation-class", + children="Create Annotation Class", + variant="light", + ), + ), + ], + ), + dmc.Modal( + id="delete-annotation-class-modal", + title="Delete Custom Annotation Class(es)", + children=[ + dmc.Text("Select all generated classes to remove:"), + dmc.Group( + spacing="xs", + grow=True, + id="current-annotation-classes", + ), + dmc.Space(h=10), + dmc.Center( [ - # "gray", - "red", - # "pink", - "grape", - "violet", - # "indigo", - "blue", - # "lime", - "yellow", - # "orange", + dmc.Button( + id="remove-annotation-class", + children="Delete Selected Class(es)", + variant="light", + ), ] - ) + ), + dmc.Text( + "There must be at least one annotation class!", + color="red", + id="at-least-one", + ), ], ), dmc.Space(h=20), - dmc.Center( - dmc.Button( - "Save annotation", - variant="light", - style={"width": "160px", "margin": "5px"}, - ) - ), + # dmc.Center( + # # dmc.Button( + # # "Save annotation", + # # variant="light", + # # style={"width": "160px", "margin": "5px"}, + # # ) + # ), dmc.Center( dmc.Button( "Export annotation", @@ -243,11 +358,39 @@ def layout(): dmc.Space(h=20), ], ), + _accordion_item( + "Model configuration", + "carbon:ibm-watson-machine-learning", + "run-model", + children=[ + dmc.Button( + "Run model", + id="run-model", + variant="light", + style={"width": "160px", "margin": "5px"}, + ), + html.Div(id="output-placeholder"), + ], + ), ], ), dcc.Store( id="annotation-store", - data={"dragmode": "drawopenpath", "visible": True, "annotations": {}}, + data={ + "dragmode": "drawopenpath", + "visible": True, + "annotations": {}, + "view": {}, + "image_size": [], + # TODO: Hard-coding default annotation class + "label_mapping": [ + { + "color": "rgb(249,82,82)", + "label": "1", + "id": random.randint(1, 100), + } + ], + }, ), dmc.NotificationsProvider(html.Div(id="notifications-container")), dcc.Download(id="export-annotation-metadata"), diff --git a/components/image_viewer.py b/components/image_viewer.py index 147e11f8..5a6f2d79 100644 --- a/components/image_viewer.py +++ b/components/image_viewer.py @@ -4,7 +4,7 @@ from utils.plot_utils import blank_fig COMPONENT_STYLE = { - "width": "640px", + "width": "calc(-440px + 100vw)", "height": "calc(100vh - 40px)", "padding": "10px", "borderRadius": "5px", @@ -88,9 +88,13 @@ def layout(): ), children=[ dcc.Graph( - id="image-viewer", config=FIGURE_CONFIG, figure=blank_fig() + id="image-viewer", + config=FIGURE_CONFIG, + figure=blank_fig(), + style={"margin": "auto", "height": "calc(-150px + 100vh)"}, ), ], + style={"display": "flex"}, ), ], ) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..20be981f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +version: '3.7' + +services: + app: + container_name: highres_seg_demo + build: . + command: 'gunicorn -b 0.0.0.0:8075 --reload app:server' + environment: + TILED_URI: '${TILED_URI}' + API_KEY: '${API_KEY}' + volumes: + - ./app.py:/app/app.py + - ./callbacks:/app/callbacks + - ./components:/app/components + - ./utils:/app/utils + ports: + - '8075:8075' + +# networks: +# - computing_api_default + +# networks: +# computing_api_default: +# external: true diff --git a/requirements.txt b/requirements.txt index e727e8eb..6456a9e9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,3 +26,7 @@ tiled[client]==0.1.0a96 gunicorn==20.1.0 requests==2.26.0 python-dotenv +scikit-image +svgpathtools +matplotlib +scipy diff --git a/utils/annotations.py b/utils/annotations.py index 3723f700..41144754 100644 --- a/utils/annotations.py +++ b/utils/annotations.py @@ -1,8 +1,3 @@ -import math -import numpy as np -from skimage.draw import polygon, circle, polygon_perimeter, line - -import re import numpy as np from skimage import draw, morphology import math @@ -21,6 +16,9 @@ def __init__(self, annotation_store): def get_annotations(self): return self.annotations + def get_annotation_mask(self): + return self.annotation_mask + def get_annotation_mask_as_bytes(self): buffer = io.BytesIO() zip_buffer = io.BytesIO() @@ -29,9 +27,11 @@ def get_annotation_mask_as_bytes(self): # Step 1: Save each numpy array to a separate .npy file in buffer npy_files = [] for i, arr in enumerate(self.annotation_mask): + item = self.annotation_store["annotations"].items() + idx = list(item)[i][0] npy_buffer = io.BytesIO() np.save(npy_buffer, arr) - npy_files.append((f"mask_{i}.{file_extension}", npy_buffer.getvalue())) + npy_files.append((f"mask_{idx}.{file_extension}", npy_buffer.getvalue())) # Step 2: Add the .npy files to a .zip file using buffer with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf: @@ -85,7 +85,6 @@ def create_annotation_mask(self, sparse=False): self.set_annotation_type(shape) self.set_annotation_line_width(shape) self.set_annotation_image_shape(image_idx) - print(self.annotation_class) if self.annotation_type == "Closed Freeform": shape_mask = ShapeConversion.closed_path_to_array( shape, self.annotation_image_shape, self.annotation_class @@ -153,27 +152,17 @@ def set_annotation_class(self, annotation): """ This function sets the class of the annotation. """ - map_color_to_class = { - "rgba(240, 62, 62, 0.3)": 1, - "#ae3ec9": 2, - "#7048e8": 3, - "#1c7ed6": 4, - "#f59f00": 5, - "rgba(245, 159, 0, 0.3)": 6, - } - - if annotation["line"]["color"] in map_color_to_class: - self.annotation_class = map_color_to_class[annotation["line"]["color"]] - else: - self.annotation_class = 99 + self.annotation_class = 99 + for item in self.annotation_store["label_mapping"]: + if item["color"] == annotation["line"]["color"]: + self.annotation_class = item["id"] def set_annotation_image_shape(self, image_idx): """ This function sets the the size of the image slice """ - self.annotation_image_shape = self.annotation_store["image_shapes"][ - int(image_idx) - ] + # TODO: Assuming all images in the slice are the same shape + self.annotation_image_shape = self.annotation_store["image_shapes"][0] class ShapeConversion: diff --git a/utils/data_utils.py b/utils/data_utils.py index 944727e6..fdcb41a0 100644 --- a/utils/data_utils.py +++ b/utils/data_utils.py @@ -71,7 +71,3 @@ def download_file(url, destination): def get_data_project_names(): return list(data) - - -def convert_hex_to_rgba(hex, alpha=0.3): - return f"rgba{tuple(int(hex[i:i+2], 16) for i in (1, 3, 5)) + (alpha,)}"