From 77471f9058b8b44600dd0e1d0aedfd24959ce707 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 25 Nov 2020 12:26:21 +0100 Subject: [PATCH 01/11] Add a rate limiter on the slicer index --- dash_slicer/slicer.py | 75 +++++++++++++++++++++++++++++++++---------- 1 file changed, 58 insertions(+), 17 deletions(-) diff --git a/dash_slicer/slicer.py b/dash_slicer/slicer.py index d4e8777..3fd3584 100644 --- a/dash_slicer/slicer.py +++ b/dash_slicer/slicer.py @@ -2,7 +2,7 @@ from plotly.graph_objects import Figure from dash import Dash from dash.dependencies import Input, Output, State, ALL -from dash_core_components import Graph, Slider, Store +from dash_core_components import Graph, Slider, Store, Interval from .utils import img_array_to_uri, get_thumbnail_size, shape3d_to_size2d @@ -298,6 +298,9 @@ def _create_dash_components(self): self._overlay_data = Store(id=self._subid("overlay"), data=[]) self._img_traces = Store(id=self._subid("img-traces"), data=[]) self._indicator_traces = Store(id=self._subid("indicator-traces"), data=[]) + self._interval = Interval( + id=self._subid("interval"), interval=100, disabled=True + ) self._stores = [ self._info, self._position, @@ -308,6 +311,7 @@ def _create_dash_components(self): self._overlay_data, self._img_traces, self._indicator_traces, + self._interval, ] def _create_server_callbacks(self): @@ -386,9 +390,11 @@ def _create_client_callbacks(self): function update_position(index, info) { return info.origin[2] + index * info.spacing[2]; } - """, + """.replace( + "{{ID}}", self._context_id + ), Output(self._position.id, "data"), - [Input(self._slider.id, "value")], + [Input(self._requested_index.id, "data")], [State(self._info.id, "data")], ) @@ -399,26 +405,61 @@ def _create_client_callbacks(self): app.clientside_callback( """ - function update_request(index) { + function rate_limit_index(index, _, interval) { + if (!window._slicer_{{ID}}) window._slicer_{{ID}} = {}; + let slicer_info = window._slicer_{{ID}}; + let now = window.performance.now(); - // Clear the cache? - if (!window.slicecache_for_{{ID}}) { window.slicecache_for_{{ID}} = {}; } - let slice_cache = window.slicecache_for_{{ID}}; + // Get whether the slider was moved + let slider_was_moved = false; + for (let trigger of dash_clientside.callback_context.triggered) { + if (trigger.prop_id.indexOf('slider') >= 0) slider_was_moved = true; + } - // Request a new slice (or not) - let request_index = index; - if (slice_cache[index]) { - return window.dash_clientside.no_update; - } else { - console.log('requesting slice ' + index); - return index; + // Initialize return values + let req_index = dash_clientside.no_update; + let disable_interval = false; + + // If the slider moved, remember the time when this happened + slicer_info.new_time = slicer_info.new_time || 0; + + if (slider_was_moved) { + slicer_info.new_time = now; + } + + // We can either update the rate-limited index interval ms after + // the real index changed, or interval ms after it stopped + // changing. The former makes the indicators come along while + // dragging the slider, the latter is better for a smooth + // experience, and the interval can be set much lower. + if (index != slicer_info.req_index) { + if (now - slicer_info.new_time >= interval) { + req_index = slicer_info.req_index = index; + disable_interval = true; + + // Get cache + // todo: _requested_index is now our rate-limited index, so we need to always apply + //if (!window.slicecache_for_{{ID}}) { window.slicecache_for_{{ID}} = {}; } + //let slice_cache = window.slicecache_for_{{ID}}; + //if (slice_cache[req_index]) { + // req_index = dash_clientside.no_update; + //} else { + console.log('requesting slice ' + req_index); + //} + } } + + return [req_index, disable_interval]; } """.replace( "{{ID}}", self._context_id ), - Output(self._requested_index.id, "data"), - [Input(self.slider.id, "value")], + [ + Output(self._requested_index.id, "data"), + Output(self._interval.id, "disabled"), + ], + [Input(self._slider.id, "value"), Input(self._interval.id, "n_intervals")], + [State(self._interval.id, "interval")], ) # ---------------------------------------------------------------------- @@ -481,7 +522,7 @@ def _create_client_callbacks(self): ), Output(self._img_traces.id, "data"), [ - Input(self.slider.id, "value"), + Input(self._slider.id, "value"), Input(self._request_data.id, "data"), Input(self._overlay_data.id, "data"), ], From c9cbaf1df823b3cd87ec1e5953e40a92edad6655 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 25 Nov 2020 13:45:17 +0100 Subject: [PATCH 02/11] dont use reverse_y=False in the examples --- dash_slicer/slicer.py | 1 + examples/slicer_with_1_plus_2_views.py | 10 +++------- examples/slicer_with_3_views.py | 6 +++--- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/dash_slicer/slicer.py b/dash_slicer/slicer.py index 3fd3584..13b1a63 100644 --- a/dash_slicer/slicer.py +++ b/dash_slicer/slicer.py @@ -26,6 +26,7 @@ class VolumeSlicer: reverse_y (bool): Whether to reverse the y-axis, so that the origin of the slice is in the top-left, rather than bottom-left. Default True. (This sets the figure's yaxes ``autorange`` to "reversed" or True.) + Note: setting this to False affects performance, see #12. scene_id (str): the scene that this slicer is part of. Slicers that have the same scene-id show each-other's positions with line indicators. By default this is derived from ``id(volume)``. diff --git a/examples/slicer_with_1_plus_2_views.py b/examples/slicer_with_1_plus_2_views.py index 3c3d3c5..8377054 100644 --- a/examples/slicer_with_1_plus_2_views.py +++ b/examples/slicer_with_1_plus_2_views.py @@ -28,14 +28,10 @@ ori = 1000, 2000, 3000 -slicer1 = VolumeSlicer( - app, vol1, axis=1, origin=ori, reverse_y=False, scene_id="scene1" -) -slicer2 = VolumeSlicer( - app, vol1, axis=0, origin=ori, reverse_y=False, scene_id="scene1" -) +slicer1 = VolumeSlicer(app, vol1, axis=1, origin=ori, scene_id="scene1") +slicer2 = VolumeSlicer(app, vol1, axis=0, origin=ori, scene_id="scene1") slicer3 = VolumeSlicer( - app, vol2, axis=0, origin=ori, spacing=spacing, reverse_y=False, scene_id="scene1" + app, vol2, axis=0, origin=ori, spacing=spacing, scene_id="scene1" ) app.layout = html.Div( diff --git a/examples/slicer_with_3_views.py b/examples/slicer_with_3_views.py index 04d5ae8..dd8ddf1 100644 --- a/examples/slicer_with_3_views.py +++ b/examples/slicer_with_3_views.py @@ -16,9 +16,9 @@ # Read volumes and create slicer objects vol = imageio.volread("imageio:stent.npz") -slicer1 = VolumeSlicer(app, vol, reverse_y=False, axis=0) -slicer2 = VolumeSlicer(app, vol, reverse_y=False, axis=1) -slicer3 = VolumeSlicer(app, vol, reverse_y=False, axis=2) +slicer1 = VolumeSlicer(app, vol, axis=0) +slicer2 = VolumeSlicer(app, vol, axis=1) +slicer3 = VolumeSlicer(app, vol, axis=2) # Calculate isosurface and create a figure with a mesh object verts, faces, _, _ = marching_cubes(vol, 300, step_size=2) From bd5c0cae7755afb190bd79c41650ef9be319fa97 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 25 Nov 2020 16:17:41 +0100 Subject: [PATCH 03/11] Refactoring --- dash_slicer/slicer.py | 155 ++++++++++++++++++++++++++---------------- 1 file changed, 96 insertions(+), 59 deletions(-) diff --git a/dash_slicer/slicer.py b/dash_slicer/slicer.py index 13b1a63..1591e6f 100644 --- a/dash_slicer/slicer.py +++ b/dash_slicer/slicer.py @@ -276,43 +276,66 @@ def _create_dash_components(self): config={"scrollZoom": True}, ) - # Create a slider object that the user can put in the layout (or not) + initial_index = info["size"][2] // 2 + initial_pos = info["origin"][2] + initial_index * info["spacing"][2] + + # Create a slider object that the user can put in the layout (or not). self._slider = Slider( id=self._subid("slider"), min=0, max=info["size"][2] - 1, step=1, - value=info["size"][2] // 2, + value=initial_index, tooltip={"always_visible": False, "placement": "left"}, updatemode="drag", ) # Create the stores that we need (these must be present in the layout) + + # A dict of static info for this slicer self._info = Store(id=self._subid("info"), data=info) - self._position = Store( - id=self._subid("position", True, axis=self._axis), data=0 - ) - self._setpos = Store(id=self._subid("setpos", True), data=None) - self._requested_index = Store(id=self._subid("req-index"), data=0) - self._request_data = Store(id=self._subid("req-data"), data="") + + # A list of low-res slices (encoded as base64-png) self._lowres_data = Store(id=self._subid("lowres"), data=thumbnails) + + # A list of mask slices (encoded as base64-png or null) self._overlay_data = Store(id=self._subid("overlay"), data=[]) + + # Slice data provided by the server + self._server_data = Store(id=self._subid("server-data"), data="") + + # Store image traces for the slicer. self._img_traces = Store(id=self._subid("img-traces"), data=[]) + + # Store indicator traces for the slicer. self._indicator_traces = Store(id=self._subid("indicator-traces"), data=[]) - self._interval = Interval( - id=self._subid("interval"), interval=100, disabled=True + + # An timer to apply a rate-limit between slider.value and index.data + self._timer = Interval(id=self._subid("timer"), interval=100, disabled=True) + + # The (integer) index of the slice to show. This value is rate-limited + self._index = Store(id=self._subid("index"), data=initial_index) + + # The (float) position (in scene coords) of the current slice, + # used to publish our position to slicers with the same scene_id. + self._pos = Store( + id=self._subid("pos", True, axis=self._axis), data=initial_pos ) + + # Signal to set the position of other slicers with the same scene_id. + self._setpos = Store(id=self._subid("setpos", True), data=None) + self._stores = [ self._info, - self._position, - self._setpos, - self._requested_index, - self._request_data, self._lowres_data, self._overlay_data, + self._server_data, self._img_traces, self._indicator_traces, - self._interval, + self._timer, + self._index, + self._pos, + self._setpos, ] def _create_server_callbacks(self): @@ -320,8 +343,8 @@ def _create_server_callbacks(self): app = self._app @app.callback( - Output(self._request_data.id, "data"), - [Input(self._requested_index.id, "data")], + Output(self._server_data.id, "data"), + [Input(self._index.id, "data")], ) def upload_requested_slice(slice_index): slice = img_array_to_uri(self._slice(slice_index)) @@ -329,14 +352,29 @@ def upload_requested_slice(slice_index): def _create_client_callbacks(self): """Create the callbacks that run client-side.""" + + # setpos (external) + # \ + # slider --[rate limit]--> index --> pos + # \ \ + # \ server_data (a new slice) + # \ \ + # \ --> image_traces + # ----------------------- / \ + # -----> figure + # / + # indicator_traces + # / + # pos (external) + app = self._app # ---------------------------------------------------------------------- - # Callback to trigger fellow slicers to go to a specific position. + # Callback to trigger fellow slicers to go to a specific position on click. app.clientside_callback( """ - function trigger_setpos(data, index, info) { + function update_setpos_from_click(data, index, info) { if (data && data.points && data.points.length) { let point = data["points"][0]; let xyz = [point["x"], point["y"]]; @@ -353,11 +391,11 @@ def _create_client_callbacks(self): ) # ---------------------------------------------------------------------- - # Callback to update index from external setpos signal. + # Callback to update slider based on external setpos signals. app.clientside_callback( """ - function respond_to_setpos(positions, cur_index, info) { + function update_slider_value(positions, cur_index, info) { for (let trigger of dash_clientside.callback_context.triggered) { if (!trigger.value) continue; let pos = trigger.value[2 - info.axis]; @@ -384,29 +422,11 @@ def _create_client_callbacks(self): ) # ---------------------------------------------------------------------- - # Callback to update position (in scene coordinates) from the index. + # Callback to rate-limit the index (using a timer/interval). app.clientside_callback( """ - function update_position(index, info) { - return info.origin[2] + index * info.spacing[2]; - } - """.replace( - "{{ID}}", self._context_id - ), - Output(self._position.id, "data"), - [Input(self._requested_index.id, "data")], - [State(self._info.id, "data")], - ) - - # ---------------------------------------------------------------------- - # Callback to request new slices. - # Note: this callback cannot be merged with the one below, because - # it would create a circular dependency. - - app.clientside_callback( - """ - function rate_limit_index(index, _, interval) { + function update_index_by_rate_limiting_the_slider_value(index, n_intervals, interval) { if (!window._slicer_{{ID}}) window._slicer_{{ID}} = {}; let slicer_info = window._slicer_{{ID}}; let now = window.performance.now(); @@ -419,13 +439,15 @@ def _create_client_callbacks(self): // Initialize return values let req_index = dash_clientside.no_update; - let disable_interval = false; + let disable_timer = false; // If the slider moved, remember the time when this happened slicer_info.new_time = slicer_info.new_time || 0; if (slider_was_moved) { slicer_info.new_time = now; + } else if (!n_intervals) { + disable_timer = true; // start disabled } // We can either update the rate-limited index interval ms after @@ -434,12 +456,12 @@ def _create_client_callbacks(self): // dragging the slider, the latter is better for a smooth // experience, and the interval can be set much lower. if (index != slicer_info.req_index) { - if (now - slicer_info.new_time >= interval) { + if (now - slicer_info.new_time >= interval * 2) { req_index = slicer_info.req_index = index; - disable_interval = true; + disable_timer = true; // Get cache - // todo: _requested_index is now our rate-limited index, so we need to always apply + // todo: _index is now our rate-limited index, so we need to always apply //if (!window.slicecache_for_{{ID}}) { window.slicecache_for_{{ID}} = {}; } //let slice_cache = window.slicecache_for_{{ID}}; //if (slice_cache[req_index]) { @@ -450,17 +472,33 @@ def _create_client_callbacks(self): } } - return [req_index, disable_interval]; + return [req_index, disable_timer]; } """.replace( "{{ID}}", self._context_id ), [ - Output(self._requested_index.id, "data"), - Output(self._interval.id, "disabled"), + Output(self._index.id, "data"), + Output(self._timer.id, "disabled"), ], - [Input(self._slider.id, "value"), Input(self._interval.id, "n_intervals")], - [State(self._interval.id, "interval")], + [Input(self._slider.id, "value"), Input(self._timer.id, "n_intervals")], + [State(self._timer.id, "interval")], + ) + + # ---------------------------------------------------------------------- + # Callback to update position (in scene coordinates) from the index. + + app.clientside_callback( + """ + function update_pos(index, info) { + return info.origin[2] + index * info.spacing[2]; + } + """.replace( + "{{ID}}", self._context_id + ), + Output(self._pos.id, "data"), + [Input(self._index.id, "data")], + [State(self._info.id, "data")], ) # ---------------------------------------------------------------------- @@ -468,14 +506,14 @@ def _create_client_callbacks(self): app.clientside_callback( """ - function update_image_traces(index, req_data, overlays, lowres, info, current_traces) { + function update_image_traces(index, server_data, overlays, lowres, info, current_traces) { // Add data to the cache if the data is indeed new if (!window.slicecache_for_{{ID}}) { window.slicecache_for_{{ID}} = {}; } let slice_cache = window.slicecache_for_{{ID}}; for (let trigger of dash_clientside.callback_context.triggered) { - if (trigger.prop_id.indexOf('req-data') >= 0) { - slice_cache[req_data.index] = req_data; + if (trigger.prop_id.indexOf('server-data') >= 0) { + slice_cache[server_data.index] = server_data; break; } } @@ -524,7 +562,7 @@ def _create_client_callbacks(self): Output(self._img_traces.id, "data"), [ Input(self._slider.id, "value"), - Input(self._request_data.id, "data"), + Input(self._server_data.id, "data"), Input(self._overlay_data.id, "data"), ], [ @@ -536,13 +574,13 @@ def _create_client_callbacks(self): # ---------------------------------------------------------------------- # Callback to create scatter traces from the positions of other slicers. - - # Create a callback to create a trace representing all slice-indices that: + # Creatse a trace representing all slice-indices that: # * corresponding to the same volume data # * match any of the selected axii + app.clientside_callback( """ - function handle_indicator(positions1, positions2, info, current) { + function update_indicator_traces(positions1, positions2, info, current) { let x0 = info.origin[0], y0 = info.origin[1]; let x1 = x0 + info.size[0] * info.spacing[0], y1 = y0 + info.size[1] * info.spacing[1]; x0 = x0 - info.spacing[0], y0 = y0 - info.spacing[1]; @@ -576,7 +614,7 @@ def _create_client_callbacks(self): { "scene": self._scene_id, "context": ALL, - "name": "position", + "name": "pos", "axis": axis, }, "data", @@ -602,7 +640,6 @@ def _create_client_callbacks(self): for (let trace of indicators) { traces.push(trace); } // Update figure - console.log("updating figure"); let figure = {...ori_figure}; figure.data = traces; From c2e07594496bc762d6a3ca71052361d44ca38e69 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 25 Nov 2020 16:24:56 +0100 Subject: [PATCH 04/11] prevent updating title, just in case --- examples/slicer_with_3_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/slicer_with_3_views.py b/examples/slicer_with_3_views.py index dd8ddf1..440bb04 100644 --- a/examples/slicer_with_3_views.py +++ b/examples/slicer_with_3_views.py @@ -11,7 +11,7 @@ from skimage.measure import marching_cubes import imageio -app = dash.Dash(__name__) +app = dash.Dash(__name__, update_title=None) server = app.server # Read volumes and create slicer objects From 4f175239f0c34fb124af00da074c8a801b75e73d Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 25 Nov 2020 20:23:21 +0100 Subject: [PATCH 05/11] more refactoring/cleaning and disable cache for now --- dash_slicer/slicer.py | 89 +++++++++++++++++++++++-------------------- 1 file changed, 47 insertions(+), 42 deletions(-) diff --git a/dash_slicer/slicer.py b/dash_slicer/slicer.py index 1591e6f..667da34 100644 --- a/dash_slicer/slicer.py +++ b/dash_slicer/slicer.py @@ -350,6 +350,19 @@ def upload_requested_slice(slice_index): slice = img_array_to_uri(self._slice(slice_index)) return {"index": slice_index, "slice": slice} + def _clientside_callback(self, code, *args): + """Helper function to define a clientside callback.""" + + slicer_state = """ + if (!window._slicer_{{ID}}) window._slicer_{{ID}} = {}; + let slicer_state = window._slicer_{{ID}}; + """.replace( + "{{ID}}", self._context_id + ) + code = code.replace("let slicer_state;", slicer_state) + + return self._app.clientside_callback(code, *args) + def _create_client_callbacks(self): """Create the callbacks that run client-side.""" @@ -367,12 +380,10 @@ def _create_client_callbacks(self): # / # pos (external) - app = self._app - # ---------------------------------------------------------------------- # Callback to trigger fellow slicers to go to a specific position on click. - app.clientside_callback( + self._clientside_callback( """ function update_setpos_from_click(data, index, info) { if (data && data.points && data.points.length) { @@ -393,7 +404,7 @@ def _create_client_callbacks(self): # ---------------------------------------------------------------------- # Callback to update slider based on external setpos signals. - app.clientside_callback( + self._clientside_callback( """ function update_slider_value(positions, cur_index, info) { for (let trigger of dash_clientside.callback_context.triggered) { @@ -424,11 +435,11 @@ def _create_client_callbacks(self): # ---------------------------------------------------------------------- # Callback to rate-limit the index (using a timer/interval). - app.clientside_callback( + self._clientside_callback( """ function update_index_by_rate_limiting_the_slider_value(index, n_intervals, interval) { - if (!window._slicer_{{ID}}) window._slicer_{{ID}} = {}; - let slicer_info = window._slicer_{{ID}}; + + let slicer_state; // filled in let now = window.performance.now(); // Get whether the slider was moved @@ -442,10 +453,10 @@ def _create_client_callbacks(self): let disable_timer = false; // If the slider moved, remember the time when this happened - slicer_info.new_time = slicer_info.new_time || 0; + slicer_state.new_time = slicer_state.new_time || 0; if (slider_was_moved) { - slicer_info.new_time = now; + slicer_state.new_time = now; } else if (!n_intervals) { disable_timer = true; // start disabled } @@ -455,28 +466,20 @@ def _create_client_callbacks(self): // changing. The former makes the indicators come along while // dragging the slider, the latter is better for a smooth // experience, and the interval can be set much lower. - if (index != slicer_info.req_index) { - if (now - slicer_info.new_time >= interval * 2) { - req_index = slicer_info.req_index = index; + if (index != slicer_state.req_index) { + if (now - slicer_state.new_time >= interval * 2) { + req_index = slicer_state.req_index = index; disable_timer = true; - - // Get cache - // todo: _index is now our rate-limited index, so we need to always apply - //if (!window.slicecache_for_{{ID}}) { window.slicecache_for_{{ID}} = {}; } - //let slice_cache = window.slicecache_for_{{ID}}; - //if (slice_cache[req_index]) { - // req_index = dash_clientside.no_update; - //} else { - console.log('requesting slice ' + req_index); - //} + console.log('requesting slice ' + req_index); + // If we want to re-enable the cache, we'd need an extra store + // that we set here too, and which we do *not* set if req_index + // is already in thec cache. } } return [req_index, disable_timer]; } - """.replace( - "{{ID}}", self._context_id - ), + """, [ Output(self._index.id, "data"), Output(self._timer.id, "disabled"), @@ -488,14 +491,12 @@ def _create_client_callbacks(self): # ---------------------------------------------------------------------- # Callback to update position (in scene coordinates) from the index. - app.clientside_callback( + self._clientside_callback( """ function update_pos(index, info) { return info.origin[2] + index * info.spacing[2]; } - """.replace( - "{{ID}}", self._context_id - ), + """, Output(self._pos.id, "data"), [Input(self._index.id, "data")], [State(self._info.id, "data")], @@ -504,19 +505,21 @@ def _create_client_callbacks(self): # ---------------------------------------------------------------------- # Callback that creates a list of image traces (slice and overlay). - app.clientside_callback( + self._clientside_callback( """ function update_image_traces(index, server_data, overlays, lowres, info, current_traces) { + // Add data to the cache if the data is indeed new - if (!window.slicecache_for_{{ID}}) { window.slicecache_for_{{ID}} = {}; } - let slice_cache = window.slicecache_for_{{ID}}; - for (let trigger of dash_clientside.callback_context.triggered) { - if (trigger.prop_id.indexOf('server-data') >= 0) { - slice_cache[server_data.index] = server_data; - break; - } - } + let slicer_state; // filled in + slicer_state.cache = slicer_state.cache || {}; + // Cache is disabled for now ... + //for (let trigger of dash_clientside.callback_context.triggered) { + // if (trigger.prop_id.indexOf('server-data') >= 0) { + // slicer_state.cache[server_data.index] = server_data; + // break; + // } + //} // Prepare traces let slice_trace = { @@ -534,9 +537,11 @@ def _create_client_callbacks(self): let new_traces = [slice_trace, overlay_trace]; // Depending on the state of the cache, use full data, or use lowres and request slice - if (slice_cache[index]) { - let cached = slice_cache[index]; + if (slicer_state.cache[index]) { + let cached = slicer_state.cache[index]; slice_trace.source = cached.slice; + } else if (index == server_data.index) { + slice_trace.source = server_data.slice; } else { slice_trace.source = lowres[index]; // Scale the image to take the exact same space as the full-res @@ -578,7 +583,7 @@ def _create_client_callbacks(self): # * corresponding to the same volume data # * match any of the selected axii - app.clientside_callback( + self._clientside_callback( """ function update_indicator_traces(positions1, positions2, info, current) { let x0 = info.origin[0], y0 = info.origin[1]; @@ -630,7 +635,7 @@ def _create_client_callbacks(self): # ---------------------------------------------------------------------- # Callback that composes a figure from multiple trace sources. - app.clientside_callback( + self._clientside_callback( """ function update_figure(img_traces, indicators, ori_figure) { From 2a4af1c61444f8ec1139f5bb0ba480ee3a5b13b4 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 25 Nov 2020 20:29:35 +0100 Subject: [PATCH 06/11] fix ci --- tests/test_slicer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_slicer.py b/tests/test_slicer.py index e90ec4f..ee4fe55 100644 --- a/tests/test_slicer.py +++ b/tests/test_slicer.py @@ -28,7 +28,7 @@ def test_slicer_init(): assert isinstance(s.graph, dcc.Graph) assert isinstance(s.slider, dcc.Slider) assert isinstance(s.stores, list) - assert all(isinstance(store, dcc.Store) for store in s.stores) + assert all(isinstance(store, (dcc.Store, dcc.Interval)) for store in s.stores) def test_scene_id_and_context_id(): From 6cbbbf368d3b653e5ed25d68abac91c6ec5a456d Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 26 Nov 2020 17:01:53 +0100 Subject: [PATCH 07/11] make two stores part of public API, plus add notes on performance --- dash_slicer/slicer.py | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/dash_slicer/slicer.py b/dash_slicer/slicer.py index 667da34..aa3a038 100644 --- a/dash_slicer/slicer.py +++ b/dash_slicer/slicer.py @@ -52,9 +52,13 @@ class VolumeSlicer: The value in the store must be an 3-element tuple (x, y, z) in scene coordinates. To apply the position for one position only, use e.g ``(None, None, x)``. - Some notes on performance: for a smooth experience, create the `Dash` - application with `update_title=None`, and when running the server in debug - mode, consider setting `dev_tools_props_check=False`. + Some notes on performance: for a smooth experience, avoid triggering + unnecessary figure updates. When adding a callback that uses the + slicer position, use the (rate limited) `index` and `pos` stores + rather than the slider value. Further, create the `Dash` application + with `update_title=None`, and when running the server in debug mode, + consider setting `dev_tools_props_check=False`. + """ _global_slicer_counter = 0 @@ -155,6 +159,20 @@ def stores(self): """ return self._stores + @property + def index(self): + """A dcc.Store containing the integer slice number. This value + is a rate-limited version of the slider value. + """ + return self._index + + @property + def pos(self): + """A dcc.Store containing the float position in scene coordinates, + along the slice-axis. + """ + return self._pos + @property def overlay_data(self): """A dcc.Store containing the overlay data. The form of this @@ -280,14 +298,16 @@ def _create_dash_components(self): initial_pos = info["origin"][2] + initial_index * info["spacing"][2] # Create a slider object that the user can put in the layout (or not). + # Note that the tooltip introduces a measurable performance penalty, + # so maybe we can display it in a different way? self._slider = Slider( id=self._subid("slider"), min=0, max=info["size"][2] - 1, step=1, value=initial_index, - tooltip={"always_visible": False, "placement": "left"}, updatemode="drag", + tooltip={"always_visible": False, "placement": "left"}, ) # Create the stores that we need (these must be present in the layout) @@ -467,7 +487,7 @@ def _create_client_callbacks(self): // dragging the slider, the latter is better for a smooth // experience, and the interval can be set much lower. if (index != slicer_state.req_index) { - if (now - slicer_state.new_time >= interval * 2) { + if (now - slicer_state.new_time >= interval) { req_index = slicer_state.req_index = index; disable_timer = true; console.log('requesting slice ' + req_index); From d799063a1fede10bf7943fe342798402e3133705 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 26 Nov 2020 17:06:03 +0100 Subject: [PATCH 08/11] set update_title=None in most our examples --- examples/bring_your_own_slider.py | 2 +- examples/slicer_with_1_plus_2_views.py | 2 +- examples/slicer_with_2_views.py | 2 +- examples/threshold_overlay.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/bring_your_own_slider.py b/examples/bring_your_own_slider.py index 1820e09..5049471 100644 --- a/examples/bring_your_own_slider.py +++ b/examples/bring_your_own_slider.py @@ -14,7 +14,7 @@ import imageio -app = dash.Dash(__name__) +app = dash.Dash(__name__, update_title=None) vol = imageio.volread("imageio:stent.npz") slicer = VolumeSlicer(app, vol) diff --git a/examples/slicer_with_1_plus_2_views.py b/examples/slicer_with_1_plus_2_views.py index 8377054..017b882 100644 --- a/examples/slicer_with_1_plus_2_views.py +++ b/examples/slicer_with_1_plus_2_views.py @@ -19,7 +19,7 @@ import imageio -app = dash.Dash(__name__) +app = dash.Dash(__name__, update_title=None) vol1 = imageio.volread("imageio:stent.npz") diff --git a/examples/slicer_with_2_views.py b/examples/slicer_with_2_views.py index 418c7f1..0707dca 100644 --- a/examples/slicer_with_2_views.py +++ b/examples/slicer_with_2_views.py @@ -8,7 +8,7 @@ import imageio -app = dash.Dash(__name__) +app = dash.Dash(__name__, update_title=None) vol = imageio.volread("imageio:stent.npz") slicer1 = VolumeSlicer(app, vol, axis=1) diff --git a/examples/threshold_overlay.py b/examples/threshold_overlay.py index 8edf5a1..4a536ca 100644 --- a/examples/threshold_overlay.py +++ b/examples/threshold_overlay.py @@ -15,7 +15,7 @@ import imageio -app = dash.Dash(__name__) +app = dash.Dash(__name__, update_title=None) server = app.server vol = imageio.volread("imageio:stent.npz") From 539dc90b7a68caae63a1fcafc39becdb899e3474 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 27 Nov 2020 11:50:07 +0100 Subject: [PATCH 09/11] remove caching code and tweak docstrings --- dash_slicer/slicer.py | 32 ++++++-------------------------- 1 file changed, 6 insertions(+), 26 deletions(-) diff --git a/dash_slicer/slicer.py b/dash_slicer/slicer.py index aa3a038..3ac791b 100644 --- a/dash_slicer/slicer.py +++ b/dash_slicer/slicer.py @@ -457,7 +457,7 @@ def _create_client_callbacks(self): self._clientside_callback( """ - function update_index_by_rate_limiting_the_slider_value(index, n_intervals, interval) { + function update_index_rate_limiting(index, n_intervals, interval) { let slicer_state; // filled in let now = window.performance.now(); @@ -491,9 +491,6 @@ def _create_client_callbacks(self): req_index = slicer_state.req_index = index; disable_timer = true; console.log('requesting slice ' + req_index); - // If we want to re-enable the cache, we'd need an extra store - // that we set here too, and which we do *not* set if req_index - // is already in thec cache. } } @@ -529,18 +526,6 @@ def _create_client_callbacks(self): """ function update_image_traces(index, server_data, overlays, lowres, info, current_traces) { - - // Add data to the cache if the data is indeed new - let slicer_state; // filled in - slicer_state.cache = slicer_state.cache || {}; - // Cache is disabled for now ... - //for (let trigger of dash_clientside.callback_context.triggered) { - // if (trigger.prop_id.indexOf('server-data') >= 0) { - // slicer_state.cache[server_data.index] = server_data; - // break; - // } - //} - // Prepare traces let slice_trace = { type: 'image', @@ -556,16 +541,14 @@ def _create_client_callbacks(self): overlay_trace.hovertemplate = ''; let new_traces = [slice_trace, overlay_trace]; - // Depending on the state of the cache, use full data, or use lowres and request slice - if (slicer_state.cache[index]) { - let cached = slicer_state.cache[index]; - slice_trace.source = cached.slice; - } else if (index == server_data.index) { + // Use full data, or use lowres + if (index == server_data.index) { slice_trace.source = server_data.slice; } else { slice_trace.source = lowres[index]; // Scale the image to take the exact same space as the full-res - // version. It's not correct, but it looks better ... + // version. Note that depending on how the low-res data is + // created, the pixel centers may not be correctly aligned. slice_trace.dx *= info.size[0] / info.lowres_size[0]; slice_trace.dy *= info.size[1] / info.lowres_size[1]; slice_trace.x0 += 0.5 * slice_trace.dx - 0.5 * info.spacing[0]; @@ -577,7 +560,7 @@ def _create_client_callbacks(self): if (new_traces[0].source == current_traces[0].source && new_traces[1].source == current_traces[1].source) { - new_traces = window.dash_clientside.no_update; + new_traces = dash_clientside.no_update; } return new_traces; } @@ -599,9 +582,6 @@ def _create_client_callbacks(self): # ---------------------------------------------------------------------- # Callback to create scatter traces from the positions of other slicers. - # Creatse a trace representing all slice-indices that: - # * corresponding to the same volume data - # * match any of the selected axii self._clientside_callback( """ From 7d1807d5caf642f21a3925ff76fc250c62571328 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 27 Nov 2020 11:53:04 +0100 Subject: [PATCH 10/11] remove convenience function again --- dash_slicer/slicer.py | 36 ++++++++++++++---------------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/dash_slicer/slicer.py b/dash_slicer/slicer.py index 3ac791b..29ca995 100644 --- a/dash_slicer/slicer.py +++ b/dash_slicer/slicer.py @@ -370,19 +370,6 @@ def upload_requested_slice(slice_index): slice = img_array_to_uri(self._slice(slice_index)) return {"index": slice_index, "slice": slice} - def _clientside_callback(self, code, *args): - """Helper function to define a clientside callback.""" - - slicer_state = """ - if (!window._slicer_{{ID}}) window._slicer_{{ID}} = {}; - let slicer_state = window._slicer_{{ID}}; - """.replace( - "{{ID}}", self._context_id - ) - code = code.replace("let slicer_state;", slicer_state) - - return self._app.clientside_callback(code, *args) - def _create_client_callbacks(self): """Create the callbacks that run client-side.""" @@ -400,10 +387,12 @@ def _create_client_callbacks(self): # / # pos (external) + app = self._app + # ---------------------------------------------------------------------- # Callback to trigger fellow slicers to go to a specific position on click. - self._clientside_callback( + app.clientside_callback( """ function update_setpos_from_click(data, index, info) { if (data && data.points && data.points.length) { @@ -424,7 +413,7 @@ def _create_client_callbacks(self): # ---------------------------------------------------------------------- # Callback to update slider based on external setpos signals. - self._clientside_callback( + app.clientside_callback( """ function update_slider_value(positions, cur_index, info) { for (let trigger of dash_clientside.callback_context.triggered) { @@ -455,11 +444,12 @@ def _create_client_callbacks(self): # ---------------------------------------------------------------------- # Callback to rate-limit the index (using a timer/interval). - self._clientside_callback( + app.clientside_callback( """ function update_index_rate_limiting(index, n_intervals, interval) { - let slicer_state; // filled in + if (!window._slicer_{{ID}}) window._slicer_{{ID}} = {}; + let slicer_state = window._slicer_{{ID}}; let now = window.performance.now(); // Get whether the slider was moved @@ -496,7 +486,9 @@ def _create_client_callbacks(self): return [req_index, disable_timer]; } - """, + """.replace( + "{{ID}}", self._context_id + ), [ Output(self._index.id, "data"), Output(self._timer.id, "disabled"), @@ -508,7 +500,7 @@ def _create_client_callbacks(self): # ---------------------------------------------------------------------- # Callback to update position (in scene coordinates) from the index. - self._clientside_callback( + app.clientside_callback( """ function update_pos(index, info) { return info.origin[2] + index * info.spacing[2]; @@ -522,7 +514,7 @@ def _create_client_callbacks(self): # ---------------------------------------------------------------------- # Callback that creates a list of image traces (slice and overlay). - self._clientside_callback( + app.clientside_callback( """ function update_image_traces(index, server_data, overlays, lowres, info, current_traces) { @@ -583,7 +575,7 @@ def _create_client_callbacks(self): # ---------------------------------------------------------------------- # Callback to create scatter traces from the positions of other slicers. - self._clientside_callback( + app.clientside_callback( """ function update_indicator_traces(positions1, positions2, info, current) { let x0 = info.origin[0], y0 = info.origin[1]; @@ -635,7 +627,7 @@ def _create_client_callbacks(self): # ---------------------------------------------------------------------- # Callback that composes a figure from multiple trace sources. - self._clientside_callback( + app.clientside_callback( """ function update_figure(img_traces, indicators, ori_figure) { From 1082986f5c9cf38d8d7f290262eee6367a81a061 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 1 Dec 2020 10:50:21 +0100 Subject: [PATCH 11/11] Update dash_slicer/slicer.py Co-authored-by: Emmanuelle Gouillart --- dash_slicer/slicer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dash_slicer/slicer.py b/dash_slicer/slicer.py index 29ca995..f5acfa1 100644 --- a/dash_slicer/slicer.py +++ b/dash_slicer/slicer.py @@ -330,7 +330,7 @@ def _create_dash_components(self): # Store indicator traces for the slicer. self._indicator_traces = Store(id=self._subid("indicator-traces"), data=[]) - # An timer to apply a rate-limit between slider.value and index.data + # A timer to apply a rate-limit between slider.value and index.data self._timer = Interval(id=self._subid("timer"), interval=100, disabled=True) # The (integer) index of the slice to show. This value is rate-limited