Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

advanced annotation features #11

Closed
wants to merge 14 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pri-venv
*.log
.ipynb_checkpoints
*/.ipynb_checkpoints/*
exported_user_data.json
.tmp
data/

Expand Down
176 changes: 170 additions & 6 deletions callbacks/control_bar.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,27 @@
from dash import Input, Output, State, callback, Patch, ALL, ctx, clientside_callback
import dash_mantine_components as dmc
from dash.exceptions import PreventUpdate
import json
from utils.data_utils import (
convert_hex_to_rgba,
DEV_load_exported_json_data,
DEV_filter_json_data_by_timestamp,
data,
)
import json
from utils.data_utils import convert_hex_to_rgba, data
import numpy as np

import os
import time

EXPORT_FILE_PATH = "data/exported_annotation_data.json"
USER_NAME = "user1"

# Create an empty file if it doesn't exist
if not os.path.exists(EXPORT_FILE_PATH):
with open(EXPORT_FILE_PATH, "w") as f:
pass


@callback(
Expand Down Expand Up @@ -39,15 +59,15 @@ 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"),
State("image-viewer", "figure"),
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.
Expand All @@ -56,16 +76,17 @@ def annotation_visibility(checked, store, figure, image_idx):

patched_figure = Patch()
if checked:
store["visible"] = True
patched_figure["layout"]["shapes"] = store[image_idx]
annotation_store["visible"] = True
patched_figure["layout"]["shapes"] = annotation_store[image_idx]
else:
annotation_data = (
[] if "shapes" not in figure["layout"] else figure["layout"]["shapes"]
)
store[image_idx] = annotation_data
if annotation_data:
annotation_store[image_idx] = annotation_data
patched_figure["layout"]["shapes"] = []

return store, patched_figure
return annotation_store, patched_figure


clientside_callback(
Expand Down Expand Up @@ -94,3 +115,146 @@ def reset_filters(n_clicks):
default_brightness = 100
default_contrast = 100
return default_brightness, default_contrast


@callback(
Output("data-management-modal", "opened"),
Output("data-modal-save-status", "children", allow_duplicate=True),
Input("open-data-management-modal-button", "n_clicks"),
State("data-management-modal", "opened"),
prevent_initial_call=True,
)
def toggle_modal(n_clicks, opened):
return not opened, ""


@callback(
Output("data-modal-save-status", "children"),
# Output("annotation-store", "data", allow_duplicate=True),
Input("save-annotations", "n_clicks"),
State("annotation-store", "data"),
# State("image-viewer", "figure"),
# State("image-selection-slider", "value"),
State("project-name-src", "value"),
prevent_initial_call=True,
)
def save_data(n_clicks, annotation_store, image_src):
"""
This callback is responsible for saving the annotation data to the store.
"""
if not n_clicks:
raise PreventUpdate
# annotation_data = (
# [] if "shapes" not in figure["layout"] else figure["layout"]["shapes"]
# )
# if annotation_data:
# store[str(image_idx)] = annotation_data

# TODO: save store to the server file-user system, this will be changed to DB later
export_data = {
"user": USER_NAME,
"source": image_src,
"time": time.strftime("%Y-%m-%d-%H:%M:%S"),
"data": json.dumps(annotation_store),
}
# Convert export_data to JSON string
export_data_json = json.dumps(export_data)

# Append export_data JSON string to the file
if export_data["data"] != "{}":
with open(EXPORT_FILE_PATH, "a+") as f:
f.write(export_data_json + "\n")
return "Data saved!"


@callback(
Output("export-annotations", "data"),
Input("export-annotations-json", "n_clicks"),
Input("export-annotations-tiff", "n_clicks"),
State("annotation-store", "data"),
prevent_initial_call=True,
)
def export_annotations(n_clicks_json, n_clicks_tiff, store):
"""
This callback is responsible for exporting the annotations to a JSON file.
"""
if ctx.triggered[0]["prop_id"].split(".")[0] == "export-annotations-json":
data = json.dumps(store)
filename = "annotations.json"
mime_type = "application/json"
else:
# TODO: export annotations to tiff
danton267 marked this conversation as resolved.
Show resolved Hide resolved
data = json.dumps(store)
filename = "annotations.tiff"
mime_type = "image/tiff"

return dict(content=data, filename=filename, type=mime_type)


@callback(
Output("load-annotations-server-container", "children"),
Input("open-data-management-modal-button", "n_clicks"),
State("project-name-src", "value"),
prevent_initial_call=True,
)
def populate_load_server_annotations(modal_opened, image_src):
"""
This callback is responsible for saving the annotation data the storage, and also creting a layout for selecting saved annotations so they are loaded.
"""
if not modal_opened:
raise PreventUpdate

# TODO : when quering from the server, get (annotation save time) for user, source, order by time
data = DEV_load_exported_json_data(EXPORT_FILE_PATH, USER_NAME, image_src)
if not data:
return "No annotations found for the selected data source."
# TODO : when quering from the server, load data for user, source, order by time

buttons = [
dmc.Button(
f"{data_json['time']}",
id={"type": "load-server-annotations", "index": data_json["time"]},
variant="light",
)
for i, data_json in enumerate(data)
]

return dmc.Stack(
buttons,
spacing="xs",
style={"overflow": "auto", "max-height": "300px"},
)


@callback(
Output("image-viewer", "figure", allow_duplicate=True),
Output("annotation-store", "data", allow_duplicate=True),
Output("data-management-modal", "opened", allow_duplicate=True),
Input({"type": "load-server-annotations", "index": ALL}, "n_clicks"),
State("project-name-src", "value"),
State("image-selection-slider", "value"),
prevent_initial_call=True,
)
def load_and_apply_selected_annotations(selected_annotation, image_src, img_idx):
"""
This callback is responsible for loading and applying the selected annotations when user selects them from the modal.
"""
# this callback is triggered when the buttons are created, when that happens we can stop it
if all([x is None for x in selected_annotation]):
raise PreventUpdate

selected_annotation_timestamp = json.loads(
ctx.triggered[0]["prop_id"].split(".")[0]
)["index"]

# TODO : when quering from the server, load (data) for user, source, time
data = DEV_load_exported_json_data(EXPORT_FILE_PATH, USER_NAME, image_src)
data = DEV_filter_json_data_by_timestamp(data, str(selected_annotation_timestamp))
data = data[0]["data"]
# TODO : when quering from the server, load (data) for user, source, time
patched_figure = Patch()
if str(img_idx) in data:
patched_figure["layout"]["shapes"] = data[str(img_idx)]
else:
patched_figure["layout"]["shapes"] = []
return patched_figure, data, False
36 changes: 32 additions & 4 deletions callbacks/image_viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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_data,
):
if image_idx:
image_idx -= 1 # slider starts at 1, so subtract 1 to get the correct index
Expand All @@ -42,40 +43,67 @@ 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 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)] = 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
"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
"""

disable_slider = project_name is None
if not disable_slider:
tiff_file = data[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,
)


Expand Down
Loading