Skip to content

Commit

Permalink
Merge pull request #138 from mlexchange/eraser-mode-clea
Browse files Browse the repository at this point in the history
Add ability to erase and modify shapes
  • Loading branch information
cleaaum authored Oct 6, 2023
2 parents 96df7d3 + cfd3203 commit 6f3afbe
Show file tree
Hide file tree
Showing 6 changed files with 57 additions and 55 deletions.
18 changes: 13 additions & 5 deletions assets/functions.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,24 @@ function changeFilters(js_path, brightness, contrast) {

window.dash_clientside = Object.assign({}, window.dash_clientside, {
clientside: {
get_container_size: function(url) {
get_container_size: function (url) {
let W = window.innerWidth;
let H = window.innerHeight;
if(W == 0 || H == 0){
if (W == 0 || H == 0) {
return dash_clientside.no_update
}
// keep `remove_focus()` here or add it to a separate callback if necessary
// so that it executes ONCE at when the app loads
remove_focus();
return {'W': W, 'H':H}
return { 'W': W, 'H': H }
},
delete_active_shape: function (pressed_key, n_events, graph_id) {
if (pressed_key["key"] == "Backspace") {
var gd = document.querySelector('#' + graph_id + ' .js-plotly-plot');
Plotly.deleteActiveShape(gd);

}
return dash_clientside.no_update
}

}
Expand All @@ -33,11 +41,11 @@ window.dash_clientside = Object.assign({}, window.dash_clientside, {
*/
function remove_focus() {
const sliderContainer = document.getElementById('image-selection-slider');

sliderContainer.addEventListener('focus', () => {
sliderContainer.blur();
});

sliderContainer.addEventListener('blur', () => {
sliderContainer.blur();
});
Expand Down
22 changes: 6 additions & 16 deletions callbacks/control_bar.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from dash import (
ALL,
MATCH,
ClientsideFunction,
Input,
Output,
Patch,
Expand Down Expand Up @@ -151,14 +152,12 @@ def update_selected_class_style(selected_class, all_annotation_classes):
Output("closed-freeform", "style"),
Output("circle", "style"),
Output("rectangle", "style"),
Output("eraser", "style"),
Output("pan-and-zoom", "style"),
Output("annotation-store", "data", allow_duplicate=True),
Output("notifications-container", "children", allow_duplicate=True),
Input("closed-freeform", "n_clicks"),
Input("circle", "n_clicks"),
Input("rectangle", "n_clicks"),
Input("eraser", "n_clicks"),
Input("pan-and-zoom", "n_clicks"),
Input("keybind-event-listener", "event"),
State("annotation-store", "data"),
Expand All @@ -171,7 +170,6 @@ def annotation_mode(
closed,
circle,
rect,
erase_annotation,
pan_and_zoom,
keybind_event_listener,
annotation_store,
Expand Down Expand Up @@ -212,7 +210,6 @@ def annotation_mode(
"closed-freeform": inactive,
"circle": inactive,
"rectangle": inactive,
"eraser": inactive,
"pan-and-zoom": inactive,
}

Expand All @@ -237,10 +234,7 @@ def annotation_mode(
patched_figure["layout"]["dragmode"] = "drawrect"
annotation_store["dragmode"] = "drawrect"
styles[trigger] = active
elif trigger == "eraser" and erase_annotation > 0:
patched_figure["layout"]["dragmode"] = "eraseshape"
annotation_store["dragmode"] = "eraseshape"
styles[trigger] = active

elif trigger == "pan-and-zoom" and pan_and_zoom > 0:
patched_figure["layout"]["dragmode"] = "pan"
annotation_store["dragmode"] = "pan"
Expand All @@ -251,7 +245,6 @@ def annotation_mode(
styles["closed-freeform"],
styles["circle"],
styles["rectangle"],
styles["eraser"],
styles["pan-and-zoom"],
annotation_store,
notification,
Expand Down Expand Up @@ -663,14 +656,10 @@ def reset_filters(n_clicks):

# TODO: check this when plotly is updated
clientside_callback(
"""
function eraseShape(_, graph_id) {
Plotly.eraseActiveShape(graph_id)
return dash_clientside.no_update
}
""",
ClientsideFunction(namespace="clientside", function_name="delete_active_shape"),
Output("image-viewer", "id", allow_duplicate=True),
Input("eraser", "n_clicks"),
Input("keybind-event-listener", "event"),
Input("keybind-event-listener", "n_events"),
State("image-viewer", "id"),
prevent_initial_call=True,
)
Expand Down Expand Up @@ -856,6 +845,7 @@ def update_current_annotated_slices_values(all_classes):
all_annotated_slices = []
for a in all_classes:
all_annotated_slices += list(a["annotations"].keys())
all_annotated_slices = sorted(list(set(all_annotated_slices)))
dropdown_values = [
{"value": int(slice) + 1, "label": f"Slice {str(int(slice) + 1)}"}
for slice in all_annotated_slices
Expand Down
42 changes: 31 additions & 11 deletions callbacks/image_viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,31 +275,51 @@ def update_viewfinder(relayout_data, annotation_store):
State("image-selection-slider", "value"),
State("annotation-store", "data"),
State({"type": "annotation-class-store", "index": ALL}, "data"),
State("current-class-selection", "data"),
State("image-viewer", "figure"),
prevent_initial_call=True,
)
def locally_store_annotations(
relayout_data, img_idx, annotation_store, all_annotation_class_store, current_color
relayout_data, img_idx, annotation_store, all_annotation_class_store, fig
):
"""
Upon finishing a relayout event (drawing, panning or zooming), this function takes the
Upon finishing a relayout event (drawing, modifying, panning or zooming), this function takes the
currently drawn shapes or zoom/pan data, and stores the lastest added shape to the
appropriate class-annotation-store, or the image pan/zoom position to the anntations-store.
"""
img_idx = str(img_idx - 1)
if "shapes" in relayout_data:
last_shape = relayout_data["shapes"][-1]
for a_class in all_annotation_class_store:
if a_class["color"] == current_color:
if img_idx in a_class["annotations"]:
a_class["annotations"][img_idx].append(last_shape)
else:
a_class["annotations"][img_idx] = [last_shape]
shapes = []
# Case 1: panning/zooming, no need to update all the class annotation stores
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 all_annotation_class_store, annotation_store
# Case 2: a shape is modified, the relayoutData will have the format "shape[_].path", with the new path (not very useful)
# we can instead take all the shapes from the fig layout directly and reset them in the store
if (
any(["shapes" in key for key in relayout_data])
and "shapes" in fig["layout"].keys()
):
shapes = fig["layout"]["shapes"]
# Case 3: a new shape is drawn, we have access to all this data directly from the relayoutData under the 'shapes' key
elif "shapes" in relayout_data:
shapes = relayout_data["shapes"]
# Clear all annotation from the stores at the current slice, except for the hidden shapes
for a_class in all_annotation_class_store:
if not a_class["is_visible"]:
continue
if img_idx in a_class["annotations"]:
del a_class["annotations"][img_idx]
# Add back each annotation on the current slice in each respective store
for shape in shapes:
for a_class in all_annotation_class_store:
if a_class["color"] == shape["line"]["color"]:
if img_idx in a_class["annotations"]:
a_class["annotations"][img_idx].append(shape)
else:
a_class["annotations"][img_idx] = [shape]
break

return all_annotation_class_store, annotation_store

Expand Down
22 changes: 5 additions & 17 deletions components/control_bar.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,19 +352,6 @@ def layout():
size="lg",
),
),
_tooltip(
f"Eraser ({KEYBINDS['erase'].upper()})",
dmc.ActionIcon(
id="eraser",
variant="subtle",
color="gray",
children=DashIconify(
icon=ANNOT_ICONS["eraser"],
width=20,
),
size="lg",
),
),
],
className="flex-row",
style={
Expand Down Expand Up @@ -765,15 +752,16 @@ def create_info_card_affix():
KEYBINDS["pan-and-zoom"].upper(),
"Pan and Zoom Mode",
),
create_keybind_row(
KEYBINDS["erase"].upper(),
"Erase Annotation Mode",
),
dmc.Divider(variant="solid", color="gray"),
create_keybind_row(
["1-9"],
"Select annotation class 1-9",
),
dmc.Divider(variant="solid", color="gray"),
create_keybind_row(
["del"],
"Delete an annotation",
),
],
p=0,
),
Expand Down
4 changes: 0 additions & 4 deletions constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
"circle": "w",
"rectangle": "e",
"pan-and-zoom": "a",
"erase": "s",
"slice-right": "ArrowRight",
"slice-left": "ArrowLeft",
"classes": [
Expand All @@ -23,7 +22,6 @@
"closed-freeform": "fluent:draw-shape-20-filled",
"circle": "gg:shape-circle",
"rectangle": "gg:shape-square",
"eraser": "ph:eraser",
"pan-and-zoom": "material-symbols:drag-pan-rounded",
"slice-right": "line-md:arrow-right",
"slice-left": "line-md:arrow-left",
Expand All @@ -37,7 +35,6 @@
"closed-freeform": "Closed freeform annotation mode",
"circle": "Circle annotation mode",
"rectangle": "Rectangle annotation mode",
"eraser": "Eraser annotation mode",
"pan-and-zoom": "Pan and zoom mode",
"slice-right": "Next slice",
"slice-left": "Previous slice",
Expand All @@ -53,5 +50,4 @@
KEYBINDS["circle"]: ("drawcircle", "circle"),
KEYBINDS["rectangle"]: ("drawrect", "rectangle"),
KEYBINDS["pan-and-zoom"]: ("pan", "pan-and-zoom"),
KEYBINDS["erase"]: ("eraseshape", "eraser"),
}
4 changes: 2 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
click==8.1.3
dash==2.10.2
dash==2.13.0
dash-core-components==2.0.0
dash-html-components==2.0.0
dash-iconify==0.1.2
Expand All @@ -13,7 +13,7 @@ MarkupSafe==2.1.3
numpy
packaging==23.1
pandas==2.0.2
plotly==5.15.0
plotly==5.17.0
python-dateutil==2.8.2
pytz==2023.3
six==1.16.0
Expand Down

0 comments on commit 6f3afbe

Please sign in to comment.