2
2
from plotly .graph_objects import Figure
3
3
from dash import Dash
4
4
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
6
6
7
7
from .utils import img_array_to_uri , get_thumbnail_size , shape3d_to_size2d
8
8
@@ -26,6 +26,7 @@ class VolumeSlicer:
26
26
reverse_y (bool): Whether to reverse the y-axis, so that the origin of
27
27
the slice is in the top-left, rather than bottom-left. Default True.
28
28
(This sets the figure's yaxes ``autorange`` to "reversed" or True.)
29
+ Note: setting this to False affects performance, see #12.
29
30
scene_id (str): the scene that this slicer is part of. Slicers
30
31
that have the same scene-id show each-other's positions with
31
32
line indicators. By default this is derived from ``id(volume)``.
@@ -51,9 +52,13 @@ class VolumeSlicer:
51
52
The value in the store must be an 3-element tuple (x, y, z) in scene coordinates.
52
53
To apply the position for one position only, use e.g ``(None, None, x)``.
53
54
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
+
57
62
"""
58
63
59
64
_global_slicer_counter = 0
@@ -154,6 +159,20 @@ def stores(self):
154
159
"""
155
160
return self ._stores
156
161
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
+
157
176
@property
158
177
def overlay_data (self ):
159
178
"""A dcc.Store containing the overlay data. The form of this
@@ -277,63 +296,107 @@ def _create_dash_components(self):
277
296
config = {"scrollZoom" : True },
278
297
)
279
298
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?
281
305
self ._slider = Slider (
282
306
id = self ._subid ("slider" ),
283
307
min = 0 ,
284
308
max = info ["size" ][2 ] - 1 ,
285
309
step = 1 ,
286
- value = info ["size" ][2 ] // 2 ,
287
- tooltip = {"always_visible" : False , "placement" : "left" },
310
+ value = initial_index ,
288
311
updatemode = "drag" ,
312
+ tooltip = {"always_visible" : False , "placement" : "left" },
289
313
)
290
314
291
315
# Create the stores that we need (these must be present in the layout)
316
+
317
+ # A dict of static info for this slicer
292
318
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)
299
321
self ._lowres_data = Store (id = self ._subid ("lowres" ), data = thumbnails )
322
+
323
+ # A list of mask slices (encoded as base64-png or null)
300
324
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.
301
330
self ._img_traces = Store (id = self ._subid ("img-traces" ), data = [])
331
+
332
+ # Store indicator traces for the slicer.
302
333
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
+
303
350
self ._stores = [
304
351
self ._info ,
305
- self ._position ,
306
- self ._setpos ,
307
- self ._requested_index ,
308
- self ._request_data ,
309
352
self ._lowres_data ,
310
353
self ._overlay_data ,
354
+ self ._server_data ,
311
355
self ._img_traces ,
312
356
self ._indicator_traces ,
357
+ self ._timer ,
358
+ self ._index ,
359
+ self ._pos ,
360
+ self ._setpos ,
313
361
]
314
362
315
363
def _create_server_callbacks (self ):
316
364
"""Create the callbacks that run server-side."""
317
365
app = self ._app
318
366
319
367
@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" )],
322
370
)
323
371
def upload_requested_slice (slice_index ):
324
372
slice = img_array_to_uri (self ._slice (slice_index ))
325
373
return {"index" : slice_index , "slice" : slice }
326
374
327
375
def _create_client_callbacks (self ):
328
376
"""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
+
329
392
app = self ._app
330
393
331
394
# ----------------------------------------------------------------------
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 .
333
396
334
397
app .clientside_callback (
335
398
"""
336
- function trigger_setpos (data, index, info) {
399
+ function update_setpos_from_click (data, index, info) {
337
400
if (data && data.points && data.points.length) {
338
401
let point = data["points"][0];
339
402
let xyz = [point["x"], point["y"]];
@@ -350,11 +413,11 @@ def _create_client_callbacks(self):
350
413
)
351
414
352
415
# ----------------------------------------------------------------------
353
- # Callback to update index from external setpos signal .
416
+ # Callback to update slider based on external setpos signals .
354
417
355
418
app .clientside_callback (
356
419
"""
357
- function respond_to_setpos (positions, cur_index, info) {
420
+ function update_slider_value (positions, cur_index, info) {
358
421
for (let trigger of dash_clientside.callback_context.triggered) {
359
422
if (!trigger.value) continue;
360
423
let pos = trigger.value[2 - info.axis];
@@ -381,64 +444,81 @@ def _create_client_callbacks(self):
381
444
)
382
445
383
446
# ----------------------------------------------------------------------
384
- # Callback to update position (in scene coordinates) from the index .
447
+ # Callback to rate-limit the index (using a timer/interval) .
385
448
386
449
app .clientside_callback (
387
450
"""
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) {
396
452
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();
401
456
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
+ }
405
462
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 ;
409
466
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
+ }
417
487
}
488
+
489
+ return [req_index, disable_timer];
418
490
}
419
491
""" .replace (
420
492
"{{ID}}" , self ._context_id
421
493
),
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" )],
424
500
)
425
501
426
502
# ----------------------------------------------------------------------
427
- # Callback that creates a list of image traces (slice and overlay) .
503
+ # Callback to update position (in scene coordinates) from the index .
428
504
429
505
app .clientside_callback (
430
506
"""
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
+ )
432
515
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) {
442
522
443
523
// Prepare traces
444
524
let slice_trace = {
@@ -455,14 +535,14 @@ def _create_client_callbacks(self):
455
535
overlay_trace.hovertemplate = '';
456
536
let new_traces = [slice_trace, overlay_trace];
457
537
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;
462
541
} else {
463
542
slice_trace.source = lowres[index];
464
543
// 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.
466
546
slice_trace.dx *= info.size[0] / info.lowres_size[0];
467
547
slice_trace.dy *= info.size[1] / info.lowres_size[1];
468
548
slice_trace.x0 += 0.5 * slice_trace.dx - 0.5 * info.spacing[0];
@@ -474,7 +554,7 @@ def _create_client_callbacks(self):
474
554
if (new_traces[0].source == current_traces[0].source &&
475
555
new_traces[1].source == current_traces[1].source)
476
556
{
477
- new_traces = window. dash_clientside.no_update;
557
+ new_traces = dash_clientside.no_update;
478
558
}
479
559
return new_traces;
480
560
}
@@ -483,8 +563,8 @@ def _create_client_callbacks(self):
483
563
),
484
564
Output (self ._img_traces .id , "data" ),
485
565
[
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" ),
488
568
Input (self ._overlay_data .id , "data" ),
489
569
],
490
570
[
@@ -497,12 +577,9 @@ def _create_client_callbacks(self):
497
577
# ----------------------------------------------------------------------
498
578
# Callback to create scatter traces from the positions of other slicers.
499
579
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
503
580
app .clientside_callback (
504
581
"""
505
- function handle_indicator (positions1, positions2, info, current) {
582
+ function update_indicator_traces (positions1, positions2, info, current) {
506
583
let x0 = info.origin[0], y0 = info.origin[1];
507
584
let x1 = x0 + info.size[0] * info.spacing[0], y1 = y0 + info.size[1] * info.spacing[1];
508
585
x0 = x0 - info.spacing[0], y0 = y0 - info.spacing[1];
@@ -536,7 +613,7 @@ def _create_client_callbacks(self):
536
613
{
537
614
"scene" : self ._scene_id ,
538
615
"context" : ALL ,
539
- "name" : "position " ,
616
+ "name" : "pos " ,
540
617
"axis" : axis ,
541
618
},
542
619
"data" ,
@@ -562,7 +639,6 @@ def _create_client_callbacks(self):
562
639
for (let trace of indicators) { traces.push(trace); }
563
640
564
641
// Update figure
565
- console.log("updating figure");
566
642
let figure = {...ori_figure};
567
643
figure.data = traces;
568
644
0 commit comments