plotly / dash-slicer

A volume slicer for Dash
https://dash.plotly.com/slicer
MIT License
24 stars 8 forks source link

Could use example on how to read from and write to slicer index positions from interactive element #44

Closed surchs closed 3 years ago

surchs commented 3 years ago

Hey,

I am trying to have a simple app where I show the current slicer index positions in a datatable or similar element and allow for a user to change the slicer values from this datatable (this could also just be Input fields or something like NumericInput from dash-daq). My goal is something like this: image

Here is the general strategy I am using: Suppose I have three slicers:

slicer0 = VolumeSlicer(app, vol, axis=0)
slicer1 = VolumeSlicer(app, vol, axis=1)
slicer2 = VolumeSlicer(app, vol, axis=2)

then to

Write to slicer position:

  1. Make store for slicer positions like described in the examples:
    setpos_store = dcc.Store(
    id={"context": "app", "scene": slicer0.scene_id, "name": "setpos"}
    )
  2. Write new index positions to the store like so:
    return x, y, z 
    ...
    Output(setpos_store.id, "data")

Read from slicer position

This is more difficult. I cannot just listen to Input({"scene": slicer0.scene_id, "context": ALL, "name": "state"}, "data"), as described in the examples, because that gives me circular dependencies. As per the new devnotes, I tried listening to the slider values instead like so:

[
        Input(slicer0.slider.id, "value"),
        Input(slicer1.slider.id, "value"),
        Input(slicer2.slider.id, "value"),
    ],

However, this also results in a circular dependency problem (i.e. the values in the datatable both depend on and affect the slider positions).

A nice workaround proposed by @emmanuelle is to listen to the clickData events from the slicer.graph objects as the trigger, and have the slicer position info as state. This is possible. For example like so:

[
        Input(slicer0.graph.id, "clickData"),
        Input(slicer1.graph.id, "clickData"),
        Input(slicer2.graph.id, "clickData"),
    ],
    [
        State({"scene": slicer0.scene_id, "context": ALL, "name": "state"}, "data"),
    ],

This works, but the slicer position reported by the State here lags one user click event behind the true position. In other words: the coordinates I get in this way are from the position before the current click event. So also not ideal, particularly not if I want to use the coordinates as starting points to let the user make changes. Here is a brief example to show the latter problem:

import dash
import dash_html_components as html
import dash_core_components as dcc
from dash_slicer import VolumeSlicer
from dash.dependencies import Input, Output, State, ALL
import imageio
import dash_daq as daq

app = dash.Dash(__name__, update_title=None)
server = app.server

# Read volume
vol = imageio.volread("imageio:stent.npz")
vol = vol[::3, :, :]
spacing = 1, 1, 1
ori = 0, 0, 0

# Create slicer objects
slicer0 = VolumeSlicer(app, vol, spacing=spacing, origin=ori, axis=0, thumbnail=False)
slicer1 = VolumeSlicer(
    app, vol, spacing=spacing, origin=ori, axis=1, thumbnail=8, reverse_y=False
)
slicer2 = VolumeSlicer(app, vol, spacing=spacing, origin=ori, axis=2, color="#00ff99")

setpos_store = dcc.Store(
    id={"context": "app", "scene": slicer0.scene_id, "name": "setpos"}
)

app.layout = html.Div(style={
    "display": "grid",
    "gridTemplateColumns": "33% 33% 33%",
}, children=[
    html.Div(
        [
            slicer0.graph,
            html.Div(slicer0.slider, style={"display": "none"}),
            *slicer0.stores
        ]
    ),
    html.Div(
        [
            slicer1.graph,
            html.Div(slicer1.slider, style={"display": "none"}),
            *slicer1.stores
        ]
    ),
    html.Div(
        [
            slicer2.graph,
            html.Div(slicer2.slider, style={"display": "none"}),
            *slicer2.stores
        ]
    ),
    html.Pre(id="slicer_click_output"),
    html.Pre(id="slicer_direct_output"),
]
)

# Show the current slicer coordinates in the numeric input fields
@app.callback(
    Output("slicer_click_output", "children"),
    [
        Input(slicer0.graph.id, "clickData"),
        Input(slicer1.graph.id, "clickData"),
        Input(slicer2.graph.id, "clickData"),
    ],
    [
        State({"scene": slicer0.scene_id, "context": ALL, "name": "state"}, "data"),
    ],
    prevent_initial_call=True
)
def read_slicer_pos(fire1, fire2, fire3, slicer_index):
    if slicer_index is None or any([i is None for i in slicer_index]):
        return dash.no_update
    z_pos = slicer_index[0]["index"]
    y_pos = slicer_index[1]["index"]
    x_pos = slicer_index[2]["index"]
    return f"ClickEvent output from slicers\nx: {x_pos}\ny: {y_pos}\nz: {z_pos}"

@app.callback(
    Output("slicer_direct_output", "children"),
    Input({"scene": slicer0.scene_id, "context": ALL, "name": "state"}, "data"),
    prevent_initial_call=True
)
def write_coords(slicer_index):
    if slicer_index is None or any([i is None for i in slicer_index]):
        return dash.no_update
    z_pos = slicer_index[0]["index"]
    y_pos = slicer_index[1]["index"]
    x_pos = slicer_index[2]["index"]
    return f"Direct output from slicers\nx: {x_pos}\ny: {y_pos}\nz: {z_pos}"

if __name__ == "__main__":
    app.run_server(debug=True, dev_tools_props_check=False)

Summary

It's not clear (to me) how to both listen to and change through user input the slicer positions with the same interactive component (e.g. datatable or input field) in an app. It would probably be good to add an example that illustrates this.

almarklein commented 3 years ago

One solution could be to use the new drag_value property of the slider. However, this feature is not yet in a released version of dcc. In the example below I use a second slider as "something that both gets and sets the index".

import dash
import dash_html_components as html
from dash_slicer import VolumeSlicer
import dash_core_components as dcc
from dash.dependencies import Input, Output, State, ALL
import imageio

app = dash.Dash(__name__, update_title=None)

vol = imageio.volread("imageio:stent.npz")
slicer = VolumeSlicer(app, vol)
slider = dcc.Slider(id="slider", max=slicer.nslices)
setpos_store = dcc.Store(
    id={"context": "app", "scene": slicer.scene_id, "name": "setpos"}
)

app.layout = html.Div([slicer.graph, slicer.slider, slider, setpos_store, *slicer.stores])

@app.callback(
    Output("slider", "value"),
    Input(slicer.slider.id, "drag_value")
)
def set_slider(index):
    return index

@app.callback(
    Output(setpos_store.id, "data"),
    Input("slider", "value"),
)
def get_slider(value):
    return None, None, value

if __name__ == "__main__":
    # Note: dev_tools_props_check negatively affects the performance of VolumeSlicer
    app.run_server(debug=True, dev_tools_props_check=False)
almarklein commented 3 years ago

I was expecting that the pattern matching callback for setpos would break the loop, but it does not. This means we need another way to break the loop.

In the above example the slider can do this, because slider.value copies to slider.drag_value - because the system is not aware of this, it breaks the loop. Maybe there is another way to do a similar trick.

almarklein commented 3 years ago

I think we could break the loop by splitting the rate limiting logic into two callbacks. I'll have a go later.

almarklein commented 3 years ago

It looks like we can do this. With #45 the loop is broken in the slicer itself, so application code does not have to worry about it at all.

surchs commented 3 years ago

This is amazing! Thanks @almarklein, I'll try this out right away on the brain app!