Skip to content

Commit 893d0e2

Browse files
Add a rate limiter on the slicer's index (#24)
* Add a rate limiter on the slicer index * dont use reverse_y=False in the examples * Refactoring * prevent updating title, just in case * more refactoring/cleaning and disable cache for now * fix ci * make two stores part of public API, plus add notes on performance * set update_title=None in most our examples * remove caching code and tweak docstrings * remove convenience function again * Update dash_slicer/slicer.py Co-authored-by: Emmanuelle Gouillart <[email protected]> Co-authored-by: Emmanuelle Gouillart <[email protected]>
1 parent 89df957 commit 893d0e2

7 files changed

+164
-92
lines changed

dash_slicer/slicer.py

Lines changed: 152 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from plotly.graph_objects import Figure
33
from dash import Dash
44
from dash.dependencies import Input, Output, State, ALL
5-
from dash_core_components import Graph, Slider, Store
5+
from dash_core_components import Graph, Slider, Store, Interval
66

77
from .utils import img_array_to_uri, get_thumbnail_size, shape3d_to_size2d
88

@@ -26,6 +26,7 @@ class VolumeSlicer:
2626
reverse_y (bool): Whether to reverse the y-axis, so that the origin of
2727
the slice is in the top-left, rather than bottom-left. Default True.
2828
(This sets the figure's yaxes ``autorange`` to "reversed" or True.)
29+
Note: setting this to False affects performance, see #12.
2930
scene_id (str): the scene that this slicer is part of. Slicers
3031
that have the same scene-id show each-other's positions with
3132
line indicators. By default this is derived from ``id(volume)``.
@@ -51,9 +52,13 @@ class VolumeSlicer:
5152
The value in the store must be an 3-element tuple (x, y, z) in scene coordinates.
5253
To apply the position for one position only, use e.g ``(None, None, x)``.
5354
54-
Some notes on performance: for a smooth experience, create the `Dash`
55-
application with `update_title=None`, and when running the server in debug
56-
mode, consider setting `dev_tools_props_check=False`.
55+
Some notes on performance: for a smooth experience, avoid triggering
56+
unnecessary figure updates. When adding a callback that uses the
57+
slicer position, use the (rate limited) `index` and `pos` stores
58+
rather than the slider value. Further, create the `Dash` application
59+
with `update_title=None`, and when running the server in debug mode,
60+
consider setting `dev_tools_props_check=False`.
61+
5762
"""
5863

5964
_global_slicer_counter = 0
@@ -154,6 +159,20 @@ def stores(self):
154159
"""
155160
return self._stores
156161

162+
@property
163+
def index(self):
164+
"""A dcc.Store containing the integer slice number. This value
165+
is a rate-limited version of the slider value.
166+
"""
167+
return self._index
168+
169+
@property
170+
def pos(self):
171+
"""A dcc.Store containing the float position in scene coordinates,
172+
along the slice-axis.
173+
"""
174+
return self._pos
175+
157176
@property
158177
def overlay_data(self):
159178
"""A dcc.Store containing the overlay data. The form of this
@@ -277,63 +296,107 @@ def _create_dash_components(self):
277296
config={"scrollZoom": True},
278297
)
279298

280-
# Create a slider object that the user can put in the layout (or not)
299+
initial_index = info["size"][2] // 2
300+
initial_pos = info["origin"][2] + initial_index * info["spacing"][2]
301+
302+
# Create a slider object that the user can put in the layout (or not).
303+
# Note that the tooltip introduces a measurable performance penalty,
304+
# so maybe we can display it in a different way?
281305
self._slider = Slider(
282306
id=self._subid("slider"),
283307
min=0,
284308
max=info["size"][2] - 1,
285309
step=1,
286-
value=info["size"][2] // 2,
287-
tooltip={"always_visible": False, "placement": "left"},
310+
value=initial_index,
288311
updatemode="drag",
312+
tooltip={"always_visible": False, "placement": "left"},
289313
)
290314

291315
# Create the stores that we need (these must be present in the layout)
316+
317+
# A dict of static info for this slicer
292318
self._info = Store(id=self._subid("info"), data=info)
293-
self._position = Store(
294-
id=self._subid("position", True, axis=self._axis), data=0
295-
)
296-
self._setpos = Store(id=self._subid("setpos", True), data=None)
297-
self._requested_index = Store(id=self._subid("req-index"), data=0)
298-
self._request_data = Store(id=self._subid("req-data"), data="")
319+
320+
# A list of low-res slices (encoded as base64-png)
299321
self._lowres_data = Store(id=self._subid("lowres"), data=thumbnails)
322+
323+
# A list of mask slices (encoded as base64-png or null)
300324
self._overlay_data = Store(id=self._subid("overlay"), data=[])
325+
326+
# Slice data provided by the server
327+
self._server_data = Store(id=self._subid("server-data"), data="")
328+
329+
# Store image traces for the slicer.
301330
self._img_traces = Store(id=self._subid("img-traces"), data=[])
331+
332+
# Store indicator traces for the slicer.
302333
self._indicator_traces = Store(id=self._subid("indicator-traces"), data=[])
334+
335+
# A timer to apply a rate-limit between slider.value and index.data
336+
self._timer = Interval(id=self._subid("timer"), interval=100, disabled=True)
337+
338+
# The (integer) index of the slice to show. This value is rate-limited
339+
self._index = Store(id=self._subid("index"), data=initial_index)
340+
341+
# The (float) position (in scene coords) of the current slice,
342+
# used to publish our position to slicers with the same scene_id.
343+
self._pos = Store(
344+
id=self._subid("pos", True, axis=self._axis), data=initial_pos
345+
)
346+
347+
# Signal to set the position of other slicers with the same scene_id.
348+
self._setpos = Store(id=self._subid("setpos", True), data=None)
349+
303350
self._stores = [
304351
self._info,
305-
self._position,
306-
self._setpos,
307-
self._requested_index,
308-
self._request_data,
309352
self._lowres_data,
310353
self._overlay_data,
354+
self._server_data,
311355
self._img_traces,
312356
self._indicator_traces,
357+
self._timer,
358+
self._index,
359+
self._pos,
360+
self._setpos,
313361
]
314362

315363
def _create_server_callbacks(self):
316364
"""Create the callbacks that run server-side."""
317365
app = self._app
318366

319367
@app.callback(
320-
Output(self._request_data.id, "data"),
321-
[Input(self._requested_index.id, "data")],
368+
Output(self._server_data.id, "data"),
369+
[Input(self._index.id, "data")],
322370
)
323371
def upload_requested_slice(slice_index):
324372
slice = img_array_to_uri(self._slice(slice_index))
325373
return {"index": slice_index, "slice": slice}
326374

327375
def _create_client_callbacks(self):
328376
"""Create the callbacks that run client-side."""
377+
378+
# setpos (external)
379+
# \
380+
# slider --[rate limit]--> index --> pos
381+
# \ \
382+
# \ server_data (a new slice)
383+
# \ \
384+
# \ --> image_traces
385+
# ----------------------- / \
386+
# -----> figure
387+
# /
388+
# indicator_traces
389+
# /
390+
# pos (external)
391+
329392
app = self._app
330393

331394
# ----------------------------------------------------------------------
332-
# Callback to trigger fellow slicers to go to a specific position.
395+
# Callback to trigger fellow slicers to go to a specific position on click.
333396

334397
app.clientside_callback(
335398
"""
336-
function trigger_setpos(data, index, info) {
399+
function update_setpos_from_click(data, index, info) {
337400
if (data && data.points && data.points.length) {
338401
let point = data["points"][0];
339402
let xyz = [point["x"], point["y"]];
@@ -350,11 +413,11 @@ def _create_client_callbacks(self):
350413
)
351414

352415
# ----------------------------------------------------------------------
353-
# Callback to update index from external setpos signal.
416+
# Callback to update slider based on external setpos signals.
354417

355418
app.clientside_callback(
356419
"""
357-
function respond_to_setpos(positions, cur_index, info) {
420+
function update_slider_value(positions, cur_index, info) {
358421
for (let trigger of dash_clientside.callback_context.triggered) {
359422
if (!trigger.value) continue;
360423
let pos = trigger.value[2 - info.axis];
@@ -381,64 +444,81 @@ def _create_client_callbacks(self):
381444
)
382445

383446
# ----------------------------------------------------------------------
384-
# Callback to update position (in scene coordinates) from the index.
447+
# Callback to rate-limit the index (using a timer/interval).
385448

386449
app.clientside_callback(
387450
"""
388-
function update_position(index, info) {
389-
return info.origin[2] + index * info.spacing[2];
390-
}
391-
""",
392-
Output(self._position.id, "data"),
393-
[Input(self._slider.id, "value")],
394-
[State(self._info.id, "data")],
395-
)
451+
function update_index_rate_limiting(index, n_intervals, interval) {
396452
397-
# ----------------------------------------------------------------------
398-
# Callback to request new slices.
399-
# Note: this callback cannot be merged with the one below, because
400-
# it would create a circular dependency.
453+
if (!window._slicer_{{ID}}) window._slicer_{{ID}} = {};
454+
let slicer_state = window._slicer_{{ID}};
455+
let now = window.performance.now();
401456
402-
app.clientside_callback(
403-
"""
404-
function update_request(index) {
457+
// Get whether the slider was moved
458+
let slider_was_moved = false;
459+
for (let trigger of dash_clientside.callback_context.triggered) {
460+
if (trigger.prop_id.indexOf('slider') >= 0) slider_was_moved = true;
461+
}
405462
406-
// Clear the cache?
407-
if (!window.slicecache_for_{{ID}}) { window.slicecache_for_{{ID}} = {}; }
408-
let slice_cache = window.slicecache_for_{{ID}};
463+
// Initialize return values
464+
let req_index = dash_clientside.no_update;
465+
let disable_timer = false;
409466
410-
// Request a new slice (or not)
411-
let request_index = index;
412-
if (slice_cache[index]) {
413-
return window.dash_clientside.no_update;
414-
} else {
415-
console.log('requesting slice ' + index);
416-
return index;
467+
// If the slider moved, remember the time when this happened
468+
slicer_state.new_time = slicer_state.new_time || 0;
469+
470+
if (slider_was_moved) {
471+
slicer_state.new_time = now;
472+
} else if (!n_intervals) {
473+
disable_timer = true; // start disabled
474+
}
475+
476+
// We can either update the rate-limited index interval ms after
477+
// the real index changed, or interval ms after it stopped
478+
// changing. The former makes the indicators come along while
479+
// dragging the slider, the latter is better for a smooth
480+
// experience, and the interval can be set much lower.
481+
if (index != slicer_state.req_index) {
482+
if (now - slicer_state.new_time >= interval) {
483+
req_index = slicer_state.req_index = index;
484+
disable_timer = true;
485+
console.log('requesting slice ' + req_index);
486+
}
417487
}
488+
489+
return [req_index, disable_timer];
418490
}
419491
""".replace(
420492
"{{ID}}", self._context_id
421493
),
422-
Output(self._requested_index.id, "data"),
423-
[Input(self.slider.id, "value")],
494+
[
495+
Output(self._index.id, "data"),
496+
Output(self._timer.id, "disabled"),
497+
],
498+
[Input(self._slider.id, "value"), Input(self._timer.id, "n_intervals")],
499+
[State(self._timer.id, "interval")],
424500
)
425501

426502
# ----------------------------------------------------------------------
427-
# Callback that creates a list of image traces (slice and overlay).
503+
# Callback to update position (in scene coordinates) from the index.
428504

429505
app.clientside_callback(
430506
"""
431-
function update_image_traces(index, req_data, overlays, lowres, info, current_traces) {
507+
function update_pos(index, info) {
508+
return info.origin[2] + index * info.spacing[2];
509+
}
510+
""",
511+
Output(self._pos.id, "data"),
512+
[Input(self._index.id, "data")],
513+
[State(self._info.id, "data")],
514+
)
432515

433-
// Add data to the cache if the data is indeed new
434-
if (!window.slicecache_for_{{ID}}) { window.slicecache_for_{{ID}} = {}; }
435-
let slice_cache = window.slicecache_for_{{ID}};
436-
for (let trigger of dash_clientside.callback_context.triggered) {
437-
if (trigger.prop_id.indexOf('req-data') >= 0) {
438-
slice_cache[req_data.index] = req_data;
439-
break;
440-
}
441-
}
516+
# ----------------------------------------------------------------------
517+
# Callback that creates a list of image traces (slice and overlay).
518+
519+
app.clientside_callback(
520+
"""
521+
function update_image_traces(index, server_data, overlays, lowres, info, current_traces) {
442522
443523
// Prepare traces
444524
let slice_trace = {
@@ -455,14 +535,14 @@ def _create_client_callbacks(self):
455535
overlay_trace.hovertemplate = '';
456536
let new_traces = [slice_trace, overlay_trace];
457537
458-
// Depending on the state of the cache, use full data, or use lowres and request slice
459-
if (slice_cache[index]) {
460-
let cached = slice_cache[index];
461-
slice_trace.source = cached.slice;
538+
// Use full data, or use lowres
539+
if (index == server_data.index) {
540+
slice_trace.source = server_data.slice;
462541
} else {
463542
slice_trace.source = lowres[index];
464543
// Scale the image to take the exact same space as the full-res
465-
// version. It's not correct, but it looks better ...
544+
// version. Note that depending on how the low-res data is
545+
// created, the pixel centers may not be correctly aligned.
466546
slice_trace.dx *= info.size[0] / info.lowres_size[0];
467547
slice_trace.dy *= info.size[1] / info.lowres_size[1];
468548
slice_trace.x0 += 0.5 * slice_trace.dx - 0.5 * info.spacing[0];
@@ -474,7 +554,7 @@ def _create_client_callbacks(self):
474554
if (new_traces[0].source == current_traces[0].source &&
475555
new_traces[1].source == current_traces[1].source)
476556
{
477-
new_traces = window.dash_clientside.no_update;
557+
new_traces = dash_clientside.no_update;
478558
}
479559
return new_traces;
480560
}
@@ -483,8 +563,8 @@ def _create_client_callbacks(self):
483563
),
484564
Output(self._img_traces.id, "data"),
485565
[
486-
Input(self.slider.id, "value"),
487-
Input(self._request_data.id, "data"),
566+
Input(self._slider.id, "value"),
567+
Input(self._server_data.id, "data"),
488568
Input(self._overlay_data.id, "data"),
489569
],
490570
[
@@ -497,12 +577,9 @@ def _create_client_callbacks(self):
497577
# ----------------------------------------------------------------------
498578
# Callback to create scatter traces from the positions of other slicers.
499579

500-
# Create a callback to create a trace representing all slice-indices that:
501-
# * corresponding to the same volume data
502-
# * match any of the selected axii
503580
app.clientside_callback(
504581
"""
505-
function handle_indicator(positions1, positions2, info, current) {
582+
function update_indicator_traces(positions1, positions2, info, current) {
506583
let x0 = info.origin[0], y0 = info.origin[1];
507584
let x1 = x0 + info.size[0] * info.spacing[0], y1 = y0 + info.size[1] * info.spacing[1];
508585
x0 = x0 - info.spacing[0], y0 = y0 - info.spacing[1];
@@ -536,7 +613,7 @@ def _create_client_callbacks(self):
536613
{
537614
"scene": self._scene_id,
538615
"context": ALL,
539-
"name": "position",
616+
"name": "pos",
540617
"axis": axis,
541618
},
542619
"data",
@@ -562,7 +639,6 @@ def _create_client_callbacks(self):
562639
for (let trace of indicators) { traces.push(trace); }
563640
564641
// Update figure
565-
console.log("updating figure");
566642
let figure = {...ori_figure};
567643
figure.data = traces;
568644

examples/bring_your_own_slider.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import imageio
1515

1616

17-
app = dash.Dash(__name__)
17+
app = dash.Dash(__name__, update_title=None)
1818

1919
vol = imageio.volread("imageio:stent.npz")
2020
slicer = VolumeSlicer(app, vol)

0 commit comments

Comments
 (0)