Skip to content

Commit cbd299e

Browse files
Enable navigating by clicking (#21)
* Enable moving a slicer by clicking in another * update examples and add docs * dont update if not needed * deploy example with 3 views * requirements * implement with panning too * remove ability to control other slicers indices by panning Co-authored-by: Emmanuelle Gouillart <[email protected]>
1 parent 29debc1 commit cbd299e

File tree

7 files changed

+104
-21
lines changed

7 files changed

+104
-21
lines changed

Procfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
web: gunicorn examples.threshold_overlay:server
1+
web: gunicorn examples.slicer_with_3_views:server

dash_slicer/slicer.py

Lines changed: 80 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ class VolumeSlicer:
3838
style ``display: none``.
3939
* ``stores``: a list of dcc.Store objects.
4040
41+
To programatically set the position of the slicer, use a store with
42+
a dictionary-id with the following fields:
43+
44+
* 'context': a unique name for this store.
45+
* 'scene': the scene_id for which to set the position
46+
* 'name': 'setpos'
47+
48+
The value in the store must be an 3-element tuple (x, y, z) in scene coordinates.
49+
To apply the position for one position only, use e.g ``(None, None, x)``.
4150
"""
4251

4352
_global_slicer_counter = 0
@@ -72,6 +81,9 @@ def __init__(
7281
raise ValueError("The given axis must be 0, 1, or 2.")
7382
self._axis = int(axis)
7483
self._reverse_y = bool(reverse_y)
84+
# Select the *other* axii
85+
self._other_axii = [0, 1, 2]
86+
self._other_axii.pop(self._axis)
7587

7688
# Check and store scene id, and generate
7789
if scene_id is None:
@@ -192,19 +204,21 @@ def create_overlay_data(self, mask, color=(0, 255, 255, 100)):
192204

193205
return overlay_slices
194206

195-
def _subid(self, name, use_dict=False):
207+
def _subid(self, name, use_dict=False, **kwargs):
196208
"""Given a name, get the full id including the context id prefix."""
197209
if use_dict:
198210
# A dict-id is nice to query objects with pattern matching callbacks,
199211
# and we use that to show the position of other sliders. But it makes
200212
# the id's very long, which is annoying e.g. in the callback graph.
201-
return {
213+
d = {
202214
"context": self._context_id,
203215
"scene": self._scene_id,
204-
"axis": self._axis,
205216
"name": name,
206217
}
218+
d.update(kwargs)
219+
return d
207220
else:
221+
assert not kwargs
208222
return self._context_id + "-" + name
209223

210224
def _slice(self, index):
@@ -230,7 +244,8 @@ def _create_dash_components(self):
230244
self._fig = fig = Figure(data=[])
231245
fig.update_layout(
232246
template=None,
233-
margin=dict(l=0, r=0, b=0, t=0, pad=4),
247+
margin={"l": 0, "r": 0, "b": 0, "t": 0, "pad": 4},
248+
dragmode="pan", # good default mode
234249
)
235250
fig.update_xaxes(
236251
showgrid=False,
@@ -265,7 +280,10 @@ def _create_dash_components(self):
265280

266281
# Create the stores that we need (these must be present in the layout)
267282
self._info = Store(id=self._subid("info"), data=info)
268-
self._position = Store(id=self._subid("position", True), data=0)
283+
self._position = Store(
284+
id=self._subid("position", True, axis=self._axis), data=0
285+
)
286+
self._setpos = Store(id=self._subid("setpos", True), data=None)
269287
self._requested_index = Store(id=self._subid("req-index"), data=0)
270288
self._request_data = Store(id=self._subid("req-data"), data="")
271289
self._lowres_data = Store(id=self._subid("lowres"), data=thumbnails)
@@ -275,6 +293,7 @@ def _create_dash_components(self):
275293
self._stores = [
276294
self._info,
277295
self._position,
296+
self._setpos,
278297
self._requested_index,
279298
self._request_data,
280299
self._lowres_data,
@@ -299,6 +318,58 @@ def _create_client_callbacks(self):
299318
"""Create the callbacks that run client-side."""
300319
app = self._app
301320

321+
# ----------------------------------------------------------------------
322+
# Callback to trigger fellow slicers to go to a specific position.
323+
324+
app.clientside_callback(
325+
"""
326+
function trigger_setpos(data, index, info) {
327+
if (data && data.points && data.points.length) {
328+
let point = data["points"][0];
329+
let xyz = [point["x"], point["y"]];
330+
let depth = info.origin[2] + index * info.spacing[2];
331+
xyz.splice(2 - info.axis, 0, depth);
332+
return xyz;
333+
}
334+
return dash_clientside.no_update;
335+
}
336+
""",
337+
Output(self._setpos.id, "data"),
338+
[Input(self._graph.id, "clickData")],
339+
[State(self._slider.id, "value"), State(self._info.id, "data")],
340+
)
341+
342+
# ----------------------------------------------------------------------
343+
# Callback to update index from external setpos signal.
344+
345+
app.clientside_callback(
346+
"""
347+
function respond_to_setpos(positions, cur_index, info) {
348+
for (let trigger of dash_clientside.callback_context.triggered) {
349+
if (!trigger.value) continue;
350+
let pos = trigger.value[2 - info.axis];
351+
if (typeof pos !== 'number') continue;
352+
let index = Math.round((pos - info.origin[2]) / info.spacing[2]);
353+
if (index == cur_index) continue;
354+
return Math.max(0, Math.min(info.size[2] - 1, index));
355+
}
356+
return dash_clientside.no_update;
357+
}
358+
""",
359+
Output(self._slider.id, "value"),
360+
[
361+
Input(
362+
{
363+
"scene": self._scene_id,
364+
"context": ALL,
365+
"name": "setpos",
366+
},
367+
"data",
368+
)
369+
],
370+
[State(self._slider.id, "value"), State(self._info.id, "data")],
371+
)
372+
302373
# ----------------------------------------------------------------------
303374
# Callback to update position (in scene coordinates) from the index.
304375

@@ -309,7 +380,7 @@ def _create_client_callbacks(self):
309380
}
310381
""",
311382
Output(self._position.id, "data"),
312-
[Input(self.slider.id, "value")],
383+
[Input(self._slider.id, "value")],
313384
[State(self._info.id, "data")],
314385
)
315386

@@ -331,7 +402,7 @@ def _create_client_callbacks(self):
331402
if (slice_cache[index]) {
332403
return window.dash_clientside.no_update;
333404
} else {
334-
console.log('request slice ' + index);
405+
console.log('requesting slice ' + index);
335406
return index;
336407
}
337408
}
@@ -416,10 +487,6 @@ def _create_client_callbacks(self):
416487
# ----------------------------------------------------------------------
417488
# Callback to create scatter traces from the positions of other slicers.
418489

419-
# Select the *other* axii
420-
axii = [0, 1, 2]
421-
axii.pop(self._axis)
422-
423490
# Create a callback to create a trace representing all slice-indices that:
424491
# * corresponding to the same volume data
425492
# * match any of the selected axii
@@ -464,7 +531,7 @@ def _create_client_callbacks(self):
464531
},
465532
"data",
466533
)
467-
for axis in axii
534+
for axis in self._other_axii
468535
],
469536
[
470537
State(self._info.id, "data"),
@@ -488,6 +555,7 @@ def _create_client_callbacks(self):
488555
console.log("updating figure");
489556
let figure = {...ori_figure};
490557
figure.data = traces;
558+
491559
return figure;
492560
}
493561
""",

examples/bring_your_own_slider.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
"""
22
Bring your own slider ... or dropdown. This example shows how to use a
3-
different input element for the slice index. The slider's value is used
4-
as an output, but the slider element itself is hidden.
3+
different input element for the slice position. A store is created with
4+
certain predefined elements. The value set to this store is an xyz
5+
position in scene coordinates. None can be used to ignore certain
6+
dimensions. The slider element itself is hidden.
57
"""
68

79
import dash
@@ -17,6 +19,10 @@
1719
vol = imageio.volread("imageio:stent.npz")
1820
slicer = VolumeSlicer(app, vol)
1921

22+
setpos_store = dcc.Store(
23+
id={"context": "app", "scene": slicer.scene_id, "name": "setpos"}
24+
)
25+
2026
dropdown = dcc.Dropdown(
2127
id="dropdown",
2228
options=[{"label": f"slice {i}", "value": i} for i in range(0, vol.shape[0], 10)],
@@ -30,17 +36,18 @@
3036
slicer.graph,
3137
dropdown,
3238
html.Div(slicer.slider, style={"display": "none"}),
39+
setpos_store,
3340
*slicer.stores,
3441
]
3542
)
3643

3744

3845
@app.callback(
39-
Output(slicer.slider.id, "value"),
46+
Output(setpos_store.id, "data"),
4047
[Input(dropdown.id, "value")],
4148
)
4249
def handle_dropdown_input(index):
43-
return index
50+
return None, None, index # xyz in scene coords
4451

4552

4653
if __name__ == "__main__":

examples/slicer_customized.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import dash
77
import dash_html_components as html
8+
import dash_core_components as dcc
89
from dash.dependencies import Input, Output, State
910
from dash_slicer import VolumeSlicer
1011
import imageio
@@ -17,7 +18,7 @@
1718

1819

1920
# We can access the components, and modify them
20-
slicer.slider.value = 0
21+
slicer.slider.value = 10
2122

2223
# The graph can be configured
2324
slicer.graph.config.update({"modeBarButtonsToAdd": ["drawclosedpath", "eraseshape"]})
@@ -27,6 +28,10 @@
2728
slicer.graph.figure.update_xaxes(showgrid=True, showticklabels=True)
2829
slicer.graph.figure.update_yaxes(showgrid=True, showticklabels=True)
2930

31+
setpos_store = dcc.Store(
32+
id={"context": "app", "scene": slicer.scene_id, "name": "setpos"}
33+
)
34+
3035

3136
# Define the layout, including extra buttons
3237
app.layout = html.Div(
@@ -42,6 +47,7 @@
4247
html.Button(">", id="increase-index"),
4348
],
4449
),
50+
setpos_store,
4551
*slicer.stores,
4652
]
4753
)
@@ -58,15 +64,15 @@ def show_slider_value(index):
5864

5965

6066
@app.callback(
61-
Output(slicer.slider.id, "value"),
67+
Output(setpos_store.id, "data"),
6268
[Input("decrease-index", "n_clicks"), Input("increase-index", "n_clicks")],
6369
[State(slicer.slider.id, "value")],
6470
)
6571
def handle_button_input(press1, press2, index):
6672
ctx = dash.callback_context
6773
if ctx.triggered:
6874
index += 1 if "increase" in ctx.triggered[0]["prop_id"] else -1
69-
return index
75+
return None, None, index # xyz in scene coords
7076

7177

7278
if __name__ == "__main__":

examples/slicer_with_1_plus_2_views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525

2626
vol2 = vol1[::3, ::2, :]
2727
spacing = 3, 2, 1
28-
ori = 110, 120, 140
28+
ori = 1000, 2000, 3000
2929

3030

3131
slicer1 = VolumeSlicer(

examples/slicer_with_3_views.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import imageio
1313

1414
app = dash.Dash(__name__)
15+
server = app.server
1516

1617
# Read volumes and create slicer objects
1718
vol = imageio.volread("imageio:stent.npz")

test_deploy/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ dash
55
dash_core_components
66
imageio
77
gunicorn
8+
scikit-image

0 commit comments

Comments
 (0)