From 96aea962afb5eb4cbf5ed5c9067f02cc1494fcd0 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 30 Nov 2020 17:39:57 +0100 Subject: [PATCH 01/16] Add support to assign a color to a slicer, and show indicators in that color --- dash_slicer/slicer.py | 97 ++++++++++++++++++++++++++++--------------- 1 file changed, 64 insertions(+), 33 deletions(-) diff --git a/dash_slicer/slicer.py b/dash_slicer/slicer.py index 8c418ec..9ce3092 100644 --- a/dash_slicer/slicer.py +++ b/dash_slicer/slicer.py @@ -30,6 +30,7 @@ class VolumeSlicer: 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)``. + color (str): the color for this slicer. This is a placeholder object, not a Dash component. The components that make up the slicer can be accessed as attributes. These must all @@ -73,6 +74,7 @@ def __init__( axis=0, reverse_y=True, scene_id=None, + color=None, ): if not isinstance(app, Dash): @@ -93,9 +95,6 @@ def __init__( raise ValueError("The given axis must be 0, 1, or 2.") self._axis = int(axis) self._reverse_y = bool(reverse_y) - # Select the *other* axii - self._other_axii = [0, 1, 2] - self._other_axii.pop(self._axis) # Check and store scene id, and generate if scene_id is None: @@ -116,6 +115,7 @@ def __init__( "size": shape3d_to_size2d(volume.shape, axis), "origin": shape3d_to_size2d(origin, axis), "spacing": shape3d_to_size2d(spacing, axis), + "color": color or "#00ffff", } # Build the slicer @@ -340,9 +340,7 @@ def _create_dash_components(self): # 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 - ) + self._pos = Store(id=self._subid("pos", True), 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) @@ -502,10 +500,17 @@ def _create_client_callbacks(self): # ---------------------------------------------------------------------- # Callback to update position (in scene coordinates) from the index. + # todo: replace index store with this info, drop the pos store + # todo: include info about axis range app.clientside_callback( """ function update_pos(index, info) { - return info.origin[2] + index * info.spacing[2]; + return { + index: index, + pos: info.origin[2] + index * info.spacing[2], + axis: info.axis, + color: info.color, + }; } """, Output(self._pos.id, "data"), @@ -579,32 +584,45 @@ def _create_client_callbacks(self): app.clientside_callback( """ - function update_indicator_traces(positions1, positions2, info, current) { + function update_indicator_traces(states, info) { 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]; let d = ((x1 - x0) + (y1 - y0)) * 0.5 * 0.05; - let version = (current.version || 0) + 1; - let x = [], y = []; - for (let pos of positions1) { - // x relative to our slice, y in scene-coords - x.push(...[x0 - d, x0, null, x1, x1 + d, null]); - y.push(...[pos, pos, pos, pos, pos, pos]); + + let axii = [0, 1, 2]; + axii.splice(info.axis, 1); + let traces = []; + + for (let state of states) { + let pos = state.pos; + if (state.axis == axii[0]) { + // x relative to our slice, y in scene-coords + traces.push({ + //x: [x0 - d, x0, null, x1, x1 + d, null], + x: [x0, x1], + y: [pos, pos], + line: {color: state.color, width: 1} + }); + } else if (state.axis == axii[1]) { + // x in scene-coords, y relative to our slice + traces.push({ + x: [pos, pos], + y: [y0, y1], + //y: [y0 - d, y0, null, y1, y1 + d, null], + line: {color: state.color, width: 1} + }); + } } - for (let pos of positions2) { - // x in scene-coords, y relative to our slice - x.push(...[pos, pos, pos, pos, pos, pos]); - y.push(...[y0 - d, y0, null, y1, y1 + d, null]); + + for (let trace of traces) { + trace.type = 'scatter'; + trace.mode = 'lines'; + trace.hoverinfo = 'skip'; + trace.showlegend = false; } - return [{ - type: 'scatter', - mode: 'lines', - line: {color: '#ff00aa'}, - x: x, - y: y, - hoverinfo: 'skip', - version: version - }]; + + return traces; } """, Output(self._indicator_traces.id, "data"), @@ -614,15 +632,12 @@ def _create_client_callbacks(self): "scene": self._scene_id, "context": ALL, "name": "pos", - "axis": axis, }, "data", ) - for axis in self._other_axii ], [ State(self._info.id, "data"), - State(self._indicator_traces.id, "data"), ], ) @@ -631,26 +646,42 @@ def _create_client_callbacks(self): app.clientside_callback( """ - function update_figure(img_traces, indicators, ori_figure) { + function update_figure(img_traces, indicators, ori_figure, info) { // Collect traces let traces = []; for (let trace of img_traces) { traces.push(trace); } for (let trace of indicators) { traces.push(trace); } + // Show our own color as a rectangle around the image, + // But only if there are multiple slicers with the same scene id. + let shapes = []; + if (indicators.length > 1) { + shapes.push({ + type: 'rect', + //xref: 'paper', yref: 'paper', x0: 0, y0: 0, x1: 1, y1: 1, + xref: 'x', yref: 'y', + x0: info.origin[0] - info.spacing[0]/2, y0: info.origin[1] - info.spacing[1]/2, + x1: info.origin[0] + (info.size[0] - 0.5) * info.spacing[0], y1: info.origin[1] + (info.size[1] - 0.5) * info.spacing[1], + line: {color: info.color, width: 3} + }); + } + // Update figure let figure = {...ori_figure}; figure.data = traces; + figure.layout.shapes = shapes; return figure; } """, - Output(self.graph.id, "figure"), + Output(self._graph.id, "figure"), [ Input(self._img_traces.id, "data"), Input(self._indicator_traces.id, "data"), ], [ - State(self.graph.id, "figure"), + State(self._graph.id, "figure"), + State(self._info.id, "data"), ], ) From 5652bc9ebda49d3748b839327439b896fce3669f Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 30 Nov 2020 17:40:03 +0100 Subject: [PATCH 02/16] Update example --- examples/slicer_with_1_plus_2_views.py | 6 +++--- examples/slicer_with_3_views.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/slicer_with_1_plus_2_views.py b/examples/slicer_with_1_plus_2_views.py index 017b882..c4ed28d 100644 --- a/examples/slicer_with_1_plus_2_views.py +++ b/examples/slicer_with_1_plus_2_views.py @@ -28,10 +28,10 @@ ori = 1000, 2000, 3000 -slicer1 = VolumeSlicer(app, vol1, axis=1, origin=ori, scene_id="scene1") -slicer2 = VolumeSlicer(app, vol1, axis=0, origin=ori, scene_id="scene1") +slicer1 = VolumeSlicer(app, vol1, axis=1, origin=ori, scene_id="scene1", color="red") +slicer2 = VolumeSlicer(app, vol1, axis=0, origin=ori, scene_id="scene1", color="green") slicer3 = VolumeSlicer( - app, vol2, axis=0, origin=ori, spacing=spacing, scene_id="scene1" + app, vol2, axis=0, origin=ori, spacing=spacing, scene_id="scene1", color="blue" ) app.layout = html.Div( diff --git a/examples/slicer_with_3_views.py b/examples/slicer_with_3_views.py index 440bb04..ab3754d 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, axis=0) -slicer2 = VolumeSlicer(app, vol, axis=1) -slicer3 = VolumeSlicer(app, vol, axis=2) +slicer1 = VolumeSlicer(app, vol, axis=0, color="red") +slicer2 = VolumeSlicer(app, vol, axis=1, color="green") +slicer3 = VolumeSlicer(app, vol, axis=2, color="blue") # Calculate isosurface and create a figure with a mesh object verts, faces, _, _ = marching_cubes(vol, 300, step_size=2) From 19d60f1fe6fb8c01aec2c51d90a4bf7a44365fea Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 2 Dec 2020 14:02:42 +0100 Subject: [PATCH 03/16] use traces instead of shapes to avoid overwriting layout.shapes --- dash_slicer/slicer.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/dash_slicer/slicer.py b/dash_slicer/slicer.py index 9ce3092..f52e592 100644 --- a/dash_slicer/slicer.py +++ b/dash_slicer/slicer.py @@ -655,14 +655,18 @@ def _create_client_callbacks(self): // Show our own color as a rectangle around the image, // But only if there are multiple slicers with the same scene id. - let shapes = []; if (indicators.length > 1) { - shapes.push({ - type: 'rect', - //xref: 'paper', yref: 'paper', x0: 0, y0: 0, x1: 1, y1: 1, - xref: 'x', yref: 'y', - x0: info.origin[0] - info.spacing[0]/2, y0: info.origin[1] - info.spacing[1]/2, - x1: info.origin[0] + (info.size[0] - 0.5) * info.spacing[0], y1: info.origin[1] + (info.size[1] - 0.5) * info.spacing[1], + let x0 = info.origin[0] - info.spacing[0]/2; + let y0 = info.origin[1] - info.spacing[1]/2; + let x1 = info.origin[0] + (info.size[0] - 0.5) * info.spacing[0]; + let y1 = info.origin[1] + (info.size[1] - 0.5) * info.spacing[1]; + traces.push({ + type: 'scatter', + mode: 'lines', + hoverinfo: 'skip', + showlegend: false, + x: [x0, x1, x1, x0, x0], + y: [y0, y0, y1, y1, y0], line: {color: info.color, width: 3} }); } @@ -670,8 +674,6 @@ def _create_client_callbacks(self): // Update figure let figure = {...ori_figure}; figure.data = traces; - figure.layout.shapes = shapes; - return figure; } """, From d57917811a732f77229598776191b14303f6eadb Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 2 Dec 2020 14:14:22 +0100 Subject: [PATCH 04/16] only draw at corners --- dash_slicer/slicer.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/dash_slicer/slicer.py b/dash_slicer/slicer.py index f52e592..25df3dd 100644 --- a/dash_slicer/slicer.py +++ b/dash_slicer/slicer.py @@ -656,17 +656,19 @@ def _create_client_callbacks(self): // Show our own color as a rectangle around the image, // But only if there are multiple slicers with the same scene id. if (indicators.length > 1) { - let x0 = info.origin[0] - info.spacing[0]/2; - let y0 = info.origin[1] - info.spacing[1]/2; - let x1 = info.origin[0] + (info.size[0] - 0.5) * info.spacing[0]; - let y1 = info.origin[1] + (info.size[1] - 0.5) * info.spacing[1]; + let x1 = info.origin[0] - info.spacing[0]/2; + let y1 = info.origin[1] - info.spacing[1]/2; + let x4 = info.origin[0] + (info.size[0] - 0.5) * info.spacing[0]; + let y4 = info.origin[1] + (info.size[1] - 0.5) * info.spacing[1]; + let x2 = x1 + 0.25 * (x4 - x1), x3 = x1 + 0.75 * (x4 - x1); + let y2 = y1 + 0.25 * (y4 - y1), y3 = y1 + 0.75 * (y4 - y1); traces.push({ type: 'scatter', mode: 'lines', hoverinfo: 'skip', showlegend: false, - x: [x0, x1, x1, x0, x0], - y: [y0, y0, y1, y1, y0], + x: [x1, x1, x2, null, x3, x4, x4, null, x4, x4, x3, null, x2, x1, x1], + y: [y2, y1, y1, null, y1, y1, y2, null, y3, y4, y4, null, y4, y4, y3], line: {color: info.color, width: 3} }); } From f52c5546e8c3af0fb994fabe8b66d5f47c5de273 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 2 Dec 2020 14:29:31 +0100 Subject: [PATCH 05/16] Nicer default color? --- dash_slicer/slicer.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/dash_slicer/slicer.py b/dash_slicer/slicer.py index 25df3dd..32a6996 100644 --- a/dash_slicer/slicer.py +++ b/dash_slicer/slicer.py @@ -30,7 +30,9 @@ class VolumeSlicer: 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)``. - color (str): the color for this slicer. + color (str): the color for this slicer. By default the color is + red, green, or blue, depending on the axis. Set to empty string + for "no color". This is a placeholder object, not a Dash component. The components that make up the slicer can be accessed as attributes. These must all @@ -104,6 +106,10 @@ def __init__( raise TypeError("scene_id must be a string") self._scene_id = scene_id + # Check color + if color is None: + color = ("red", "green", "blue")[self._axis] + # Get unique id scoped to this slicer object VolumeSlicer._global_slicer_counter += 1 self._context_id = "slicer" + str(VolumeSlicer._global_slicer_counter) @@ -115,7 +121,7 @@ def __init__( "size": shape3d_to_size2d(volume.shape, axis), "origin": shape3d_to_size2d(origin, axis), "spacing": shape3d_to_size2d(spacing, axis), - "color": color or "#00ffff", + "color": color, } # Build the slicer @@ -651,11 +657,13 @@ def _create_client_callbacks(self): // Collect traces let traces = []; for (let trace of img_traces) { traces.push(trace); } - for (let trace of indicators) { traces.push(trace); } + for (let trace of indicators) { if (trace.line.color) traces.push(trace); } // Show our own color as a rectangle around the image, // But only if there are multiple slicers with the same scene id. - if (indicators.length > 1) { + + console.log(indicators.length) + if (indicators.length > 0 && info.color) { let x1 = info.origin[0] - info.spacing[0]/2; let y1 = info.origin[1] - info.spacing[1]/2; let x4 = info.origin[0] + (info.size[0] - 0.5) * info.spacing[0]; From a1e306bc322b6ee628ea09ee961254f454a95ce1 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 3 Dec 2020 10:28:34 +0100 Subject: [PATCH 06/16] Replace 'index' and 'pos' stores with 'state' store --- dash_slicer/slicer.py | 118 ++++++++++++++------------------ examples/slicer_with_3_views.py | 6 +- 2 files changed, 55 insertions(+), 69 deletions(-) diff --git a/dash_slicer/slicer.py b/dash_slicer/slicer.py index 32a6996..7aada9d 100644 --- a/dash_slicer/slicer.py +++ b/dash_slicer/slicer.py @@ -115,6 +115,7 @@ def __init__( self._context_id = "slicer" + str(VolumeSlicer._global_slicer_counter) # Prepare slice info that we use at the client side + # todo: rename origing and spacing self._slice_info = { "shape": tuple(volume.shape), "axis": self._axis, @@ -166,18 +167,15 @@ 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 + def state(self): + """A dcc.Store representing the current state of the slicer (present + in slicer.stores). Its data is a dict with the fields: + index (int), pos (float), axis (int), color (str). - @property - def pos(self): - """A dcc.Store containing the float position in scene coordinates, - along the slice-axis. + Its id is a dictionary so it can be used in a pattern matching Input. + Fields: context, scene, name. Where scene is the scene_id and name is "state". """ - return self._pos + return self._state @property def overlay_data(self): @@ -201,7 +199,6 @@ def create_overlay_data(self, mask, color=(0, 255, 255, 100)): mask = mask.astype(np.uint8, copy=False) # need int to index # Create a colormap (list) from the given color(s) - # todo: also support hex colors and css color names color = np.array(color, np.uint8) if color.ndim == 1: if color.shape[0] != 4: @@ -303,7 +300,12 @@ def _create_dash_components(self): ) initial_index = info["size"][2] // 2 - initial_pos = info["origin"][2] + initial_index * info["spacing"][2] + initial_state = { + "index": initial_index, + "pos": info["origin"][2] + initial_index * info["spacing"][2], + "axis": info["axis"], + "color": info["color"], + } # Create a slider object that the user can put in the layout (or not). # Note that the tooltip introduces a measurable performance penalty, @@ -341,12 +343,8 @@ def _create_dash_components(self): # 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 - 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), data=initial_pos) + # The (public) state of the slicer. This value is rate-limited + self._state = Store(id=self._subid("state", True), data=initial_state) # Signal to set the position of other slicers with the same scene_id. self._setpos = Store(id=self._subid("setpos", True), data=None) @@ -359,8 +357,7 @@ def _create_dash_components(self): self._img_traces, self._indicator_traces, self._timer, - self._index, - self._pos, + self._state, self._setpos, ] @@ -370,18 +367,19 @@ def _create_server_callbacks(self): @app.callback( Output(self._server_data.id, "data"), - [Input(self._index.id, "data")], + [Input(self._state.id, "data")], ) - def upload_requested_slice(slice_index): - slice = img_array_to_uri(self._slice(slice_index)) - return {"index": slice_index, "slice": slice} + def upload_requested_slice(state): + index = state["index"] + slice = img_array_to_uri(self._slice(index)) + return {"index": index, "slice": slice} def _create_client_callbacks(self): """Create the callbacks that run client-side.""" # setpos (external) # \ - # slider --[rate limit]--> index --> pos + # slider --[rate limit]--> state # \ \ # \ server_data (a new slice) # \ \ @@ -391,7 +389,7 @@ def _create_client_callbacks(self): # / # indicator_traces # / - # pos (external) + # state (external) app = self._app @@ -452,10 +450,10 @@ def _create_client_callbacks(self): app.clientside_callback( """ - function update_index_rate_limiting(index, n_intervals, interval) { + function update_index_rate_limiting(index, n_intervals, interval, info) { if (!window._slicer_{{ID}}) window._slicer_{{ID}} = {}; - let slicer_state = window._slicer_{{ID}}; + let private_state = window._slicer_{{ID}}; let now = window.performance.now(); // Get whether the slider was moved @@ -465,14 +463,14 @@ def _create_client_callbacks(self): } // Initialize return values - let req_index = dash_clientside.no_update; + let new_state = dash_clientside.no_update; let disable_timer = false; // If the slider moved, remember the time when this happened - slicer_state.new_time = slicer_state.new_time || 0; + private_state.new_time = private_state.new_time || 0; if (slider_was_moved) { - slicer_state.new_time = now; + private_state.new_time = now; } else if (!n_intervals) { disable_timer = true; // start disabled } @@ -482,47 +480,37 @@ 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_state.req_index) { - if (now - slicer_state.new_time >= interval) { - req_index = slicer_state.req_index = index; + if (index != private_state.index) { + if (now - private_state.new_time >= interval) { + private_state.index = index; disable_timer = true; - console.log('requesting slice ' + req_index); + console.log('requesting slice ' + index); + new_state = { + index: index, + pos: info.origin[2] + index * info.spacing[2], + axis: info.axis, + color: info.color, + }; } } - return [req_index, disable_timer]; + return [new_state, disable_timer]; } """.replace( "{{ID}}", self._context_id ), [ - Output(self._index.id, "data"), + Output(self._state.id, "data"), Output(self._timer.id, "disabled"), ], [Input(self._slider.id, "value"), Input(self._timer.id, "n_intervals")], - [State(self._timer.id, "interval")], + [ + State(self._timer.id, "interval"), + State(self._info.id, "data"), + ], ) - # ---------------------------------------------------------------------- - # Callback to update position (in scene coordinates) from the index. - - # todo: replace index store with this info, drop the pos store - # todo: include info about axis range - app.clientside_callback( - """ - function update_pos(index, info) { - return { - index: index, - pos: info.origin[2] + index * info.spacing[2], - axis: info.axis, - color: info.color, - }; - } - """, - Output(self._pos.id, "data"), - [Input(self._index.id, "data")], - [State(self._info.id, "data")], - ) + # todo: include info about axis range in state # ---------------------------------------------------------------------- # Callback that creates a list of image traces (slice and overlay). @@ -637,7 +625,7 @@ def _create_client_callbacks(self): { "scene": self._scene_id, "context": ALL, - "name": "pos", + "name": "state", }, "data", ) @@ -659,17 +647,15 @@ def _create_client_callbacks(self): for (let trace of img_traces) { traces.push(trace); } for (let trace of indicators) { if (trace.line.color) traces.push(trace); } - // Show our own color as a rectangle around the image, - // But only if there are multiple slicers with the same scene id. - - console.log(indicators.length) + // Show our own color as a rectangle around the image, but only if + // there are other slicers with the same scene id, on a different axis. if (indicators.length > 0 && info.color) { let x1 = info.origin[0] - info.spacing[0]/2; let y1 = info.origin[1] - info.spacing[1]/2; let x4 = info.origin[0] + (info.size[0] - 0.5) * info.spacing[0]; let y4 = info.origin[1] + (info.size[1] - 0.5) * info.spacing[1]; - let x2 = x1 + 0.25 * (x4 - x1), x3 = x1 + 0.75 * (x4 - x1); - let y2 = y1 + 0.25 * (y4 - y1), y3 = y1 + 0.75 * (y4 - y1); + let x2 = x1 + 0.1 * (x4 - x1), x3 = x1 + 0.9 * (x4 - x1); + let y2 = y1 + 0.1 * (y4 - y1), y3 = y1 + 0.9 * (y4 - y1); traces.push({ type: 'scatter', mode: 'lines', @@ -677,7 +663,7 @@ def _create_client_callbacks(self): showlegend: false, x: [x1, x1, x2, null, x3, x4, x4, null, x4, x4, x3, null, x2, x1, x1], y: [y2, y1, y1, null, y1, y1, y2, null, y3, y4, y4, null, y4, y4, y3], - line: {color: info.color, width: 3} + line: {color: info.color, width: 5} }); } diff --git a/examples/slicer_with_3_views.py b/examples/slicer_with_3_views.py index ab3754d..440bb04 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, axis=0, color="red") -slicer2 = VolumeSlicer(app, vol, axis=1, color="green") -slicer3 = VolumeSlicer(app, vol, axis=2, color="blue") +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 712df9d1f856508423451f156c04f910f6b46f6d Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 3 Dec 2020 11:35:29 +0100 Subject: [PATCH 07/16] Use D3 colormap, and tweak appearance of current-slice color hint --- dash_slicer/slicer.py | 81 +++++++++++++++++++++++------------ examples/threshold_overlay.py | 2 +- 2 files changed, 55 insertions(+), 28 deletions(-) diff --git a/dash_slicer/slicer.py b/dash_slicer/slicer.py index 7aada9d..5a8d08a 100644 --- a/dash_slicer/slicer.py +++ b/dash_slicer/slicer.py @@ -1,12 +1,15 @@ import numpy as np -from plotly.graph_objects import Figure -from dash import Dash +import plotly +import dash from dash.dependencies import Input, Output, State, ALL from dash_core_components import Graph, Slider, Store, Interval from .utils import img_array_to_uri, get_thumbnail_size, shape3d_to_size2d +# The default colors to use for indicators and overlays +discrete_colors = plotly.colors.qualitative.D3 + _assigned_scene_ids = {} # id(volume) -> str @@ -39,7 +42,7 @@ class VolumeSlicer: be present in the app layout: * ``graph``: the dcc.Graph object. Use ``graph.figure`` to access the - Plotly figure object. + Plotly Figure object. * ``slider``: the dcc.Slider object, its value represents the slice index. If you don't want to use the slider, wrap it in a div with style ``display: none``. @@ -79,7 +82,7 @@ def __init__( color=None, ): - if not isinstance(app, Dash): + if not isinstance(app, dash.Dash): raise TypeError("Expect first arg to be a Dash app.") self._app = app @@ -108,7 +111,7 @@ def __init__( # Check color if color is None: - color = ("red", "green", "blue")[self._axis] + color = discrete_colors[self._axis] # Get unique id scoped to this slicer object VolumeSlicer._global_slicer_counter += 1 @@ -185,9 +188,11 @@ def overlay_data(self): """ return self._overlay_data - def create_overlay_data(self, mask, color=(0, 255, 255, 100)): + def create_overlay_data(self, mask, color=None): """Given a 3D mask array and an index, create an object that - can be used as output for ``slicer.overlay_data``. + can be used as output for ``slicer.overlay_data``. The color + can be an rgb/rgba tuple, or a hex color. Alternatively, color + can be a list of such colors, defining a colormap. """ # Check the mask if mask.dtype not in (np.bool, np.uint8): @@ -199,19 +204,34 @@ def create_overlay_data(self, mask, color=(0, 255, 255, 100)): mask = mask.astype(np.uint8, copy=False) # need int to index # Create a colormap (list) from the given color(s) - color = np.array(color, np.uint8) - if color.ndim == 1: - if color.shape[0] != 4: - raise ValueError("Overlay color must be 4 ints (0..255).") - colormap = [(0, 0, 0, 0), tuple(color)] - elif color.ndim == 2: - if color.shape[1] != 4: - raise ValueError("Overlay colors must be 4 ints (0..255).") - colormap = [tuple(x) for x in color] + if color is None: + colormap = discrete_colors[3:] + elif isinstance(color, (tuple, list)) and all( + isinstance(x, (int, float)) for x in color + ): + colormap = [color] else: - raise ValueError( - "Overlay color must be a single color or a list of colors." - ) + colormap = list(color) + + # Normalize the colormap so each element is a 4-element tuple + for i in range(len(colormap)): + c = colormap[i] + if isinstance(c, str): + if c.startswith("#"): + c = plotly.colors.hex_to_rgb(c) + else: + raise ValueError( + "Named colors are not (yet) supported, hex colors are." + ) + c = tuple(int(x) for x in c) + if len(c) == 3: + c = c + (100,) + elif len(c) != 4: + raise ValueError("Expected color tuples to be 3 or 4 elements.") + colormap[i] = c + + # Insert zero stub color for where mask is zero + colormap.insert(0, (0, 0, 0, 0)) # Produce slices (base64 png strings) overlay_slices = [] @@ -271,7 +291,7 @@ def _create_dash_components(self): info["lowres_size"] = thumbnail_size # Create the figure object - can be accessed by user via slicer.graph.figure - self._fig = fig = Figure(data=[]) + self._fig = fig = plotly.graph_objects.Figure(data=[]) fig.update_layout( template=None, margin={"l": 0, "r": 0, "b": 0, "t": 0, "pad": 4}, @@ -649,13 +669,20 @@ def _create_client_callbacks(self): // Show our own color as a rectangle around the image, but only if // there are other slicers with the same scene id, on a different axis. + // We do some math to make sure that these indicators are the same + // size (in scene coordinates) for all slicers of the same data. if (indicators.length > 0 && info.color) { - let x1 = info.origin[0] - info.spacing[0]/2; - let y1 = info.origin[1] - info.spacing[1]/2; - let x4 = info.origin[0] + (info.size[0] - 0.5) * info.spacing[0]; - let y4 = info.origin[1] + (info.size[1] - 0.5) * info.spacing[1]; - let x2 = x1 + 0.1 * (x4 - x1), x3 = x1 + 0.9 * (x4 - x1); - let y2 = y1 + 0.1 * (y4 - y1), y3 = y1 + 0.9 * (y4 - y1); + let fraction, x1, x2, x3, x4, y1, y2, y3, y4, z1, z4, dd; + fraction = 0.1; + x1 = info.origin[0] - info.spacing[0]/2; + y1 = info.origin[1] - info.spacing[1]/2; + z1 = info.origin[2] - info.spacing[2]/2; + x4 = info.origin[0] + (info.size[0] - 0.5) * info.spacing[0]; + y4 = info.origin[1] + (info.size[1] - 0.5) * info.spacing[1]; + z4 = info.origin[2] + (info.size[2] - 0.5) * info.spacing[2]; + dd = fraction * (x4-x1 + y4-y1 + z4-z1) / 3; // average + dd = Math.min(dd, 0.45 * Math.min(x4-x1, y4-y1, z4-z1)); // failsafe + x2 = x1 + dd, x3 = x4 - dd, y2 = y1 + dd, y3 = y4 - dd; traces.push({ type: 'scatter', mode: 'lines', @@ -663,7 +690,7 @@ def _create_client_callbacks(self): showlegend: false, x: [x1, x1, x2, null, x3, x4, x4, null, x4, x4, x3, null, x2, x1, x1], y: [y2, y1, y1, null, y1, y1, y2, null, y3, y4, y4, null, y4, y4, y3], - line: {color: info.color, width: 5} + line: {color: info.color, width: 4} }); } diff --git a/examples/threshold_overlay.py b/examples/threshold_overlay.py index 4a536ca..997039f 100644 --- a/examples/threshold_overlay.py +++ b/examples/threshold_overlay.py @@ -47,7 +47,7 @@ # Define colormap to make the lower threshold shown in yellow, and higher in red -colormap = [(0, 0, 0, 0), (255, 255, 0, 50), (255, 0, 0, 100)] +colormap = [(255, 255, 0, 50), (255, 0, 0, 100)] @app.callback( From 08c21ff312db9ddccf274d28d786e9f1f5ed5d21 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 3 Dec 2020 12:00:40 +0100 Subject: [PATCH 08/16] Rename variables in (internal) info object, for clarity --- dash_slicer/slicer.py | 51 ++++++++++++++++++++++--------------------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/dash_slicer/slicer.py b/dash_slicer/slicer.py index 5a8d08a..b56e240 100644 --- a/dash_slicer/slicer.py +++ b/dash_slicer/slicer.py @@ -25,7 +25,7 @@ class VolumeSlicer: dimension (zyx).The spacing and origin are applied to make the slice drawn in "scene space" rather than "voxel space". origin (tuple of floats): The offset for each dimension (zyx). - axis (int): the dimension to slice in. Default 0. + axis (int): the dimension to slice in. The default 0. 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.) @@ -117,14 +117,15 @@ def __init__( VolumeSlicer._global_slicer_counter += 1 self._context_id = "slicer" + str(VolumeSlicer._global_slicer_counter) - # Prepare slice info that we use at the client side - # todo: rename origing and spacing + # Prepare slice info that we use at the client side. + # Note that shape, origin and spacing are in zyx order. + # The size, offset, stepsize are in xyz local to the slicer + # (z is in direction of the axis). self._slice_info = { - "shape": tuple(volume.shape), "axis": self._axis, "size": shape3d_to_size2d(volume.shape, axis), - "origin": shape3d_to_size2d(origin, axis), - "spacing": shape3d_to_size2d(spacing, axis), + "offset": shape3d_to_size2d(origin, axis), + "stepsize": shape3d_to_size2d(spacing, axis), "color": color, } @@ -322,7 +323,7 @@ def _create_dash_components(self): initial_index = info["size"][2] // 2 initial_state = { "index": initial_index, - "pos": info["origin"][2] + initial_index * info["spacing"][2], + "pos": info["offset"][2] + initial_index * info["stepsize"][2], "axis": info["axis"], "color": info["color"], } @@ -422,7 +423,7 @@ def _create_client_callbacks(self): if (data && data.points && data.points.length) { let point = data["points"][0]; let xyz = [point["x"], point["y"]]; - let depth = info.origin[2] + index * info.spacing[2]; + let depth = info.offset[2] + index * info.stepsize[2]; xyz.splice(2 - info.axis, 0, depth); return xyz; } @@ -444,7 +445,7 @@ def _create_client_callbacks(self): if (!trigger.value) continue; let pos = trigger.value[2 - info.axis]; if (typeof pos !== 'number') continue; - let index = Math.round((pos - info.origin[2]) / info.spacing[2]); + let index = Math.round((pos - info.offset[2]) / info.stepsize[2]); if (index == cur_index) continue; return Math.max(0, Math.min(info.size[2] - 1, index)); } @@ -507,7 +508,7 @@ def _create_client_callbacks(self): console.log('requesting slice ' + index); new_state = { index: index, - pos: info.origin[2] + index * info.spacing[2], + pos: info.offset[2] + index * info.stepsize[2], axis: info.axis, color: info.color, }; @@ -542,10 +543,10 @@ def _create_client_callbacks(self): // Prepare traces let slice_trace = { type: 'image', - x0: info.origin[0], - y0: info.origin[1], - dx: info.spacing[0], - dy: info.spacing[1], + x0: info.offset[0], + y0: info.offset[1], + dx: info.stepsize[0], + dy: info.stepsize[1], hovertemplate: '(%{x:.2f}, %{y:.2f})' }; let overlay_trace = {...slice_trace}; @@ -564,8 +565,8 @@ def _create_client_callbacks(self): // 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]; - slice_trace.y0 += 0.5 * slice_trace.dy - 0.5 * info.spacing[1]; + slice_trace.x0 += 0.5 * slice_trace.dx - 0.5 * info.stepsize[0]; + slice_trace.y0 += 0.5 * slice_trace.dy - 0.5 * info.stepsize[1]; } // Has the image data even changed? @@ -599,9 +600,9 @@ def _create_client_callbacks(self): app.clientside_callback( """ function update_indicator_traces(states, info) { - 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]; + let x0 = info.offset[0], y0 = info.offset[1]; + let x1 = x0 + info.size[0] * info.stepsize[0], y1 = y0 + info.size[1] * info.stepsize[1]; + x0 = x0 - info.stepsize[0], y0 = y0 - info.stepsize[1]; let d = ((x1 - x0) + (y1 - y0)) * 0.5 * 0.05; let axii = [0, 1, 2]; @@ -674,12 +675,12 @@ def _create_client_callbacks(self): if (indicators.length > 0 && info.color) { let fraction, x1, x2, x3, x4, y1, y2, y3, y4, z1, z4, dd; fraction = 0.1; - x1 = info.origin[0] - info.spacing[0]/2; - y1 = info.origin[1] - info.spacing[1]/2; - z1 = info.origin[2] - info.spacing[2]/2; - x4 = info.origin[0] + (info.size[0] - 0.5) * info.spacing[0]; - y4 = info.origin[1] + (info.size[1] - 0.5) * info.spacing[1]; - z4 = info.origin[2] + (info.size[2] - 0.5) * info.spacing[2]; + x1 = info.offset[0] - info.stepsize[0]/2; + y1 = info.offset[1] - info.stepsize[1]/2; + z1 = info.offset[2] - info.stepsize[2]/2; + x4 = info.offset[0] + (info.size[0] - 0.5) * info.stepsize[0]; + y4 = info.offset[1] + (info.size[1] - 0.5) * info.stepsize[1]; + z4 = info.offset[2] + (info.size[2] - 0.5) * info.stepsize[2]; dd = fraction * (x4-x1 + y4-y1 + z4-z1) / 3; // average dd = Math.min(dd, 0.45 * Math.min(x4-x1, y4-y1, z4-z1)); // failsafe x2 = x1 + dd, x3 = x4 - dd, y2 = y1 + dd, y3 = y4 - dd; From 5c375ac3771f38a69fb1de166f849f7e14e427f0 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 4 Dec 2020 13:54:33 +0100 Subject: [PATCH 09/16] slicer indicators show visible range too --- dash_slicer/slicer.py | 152 +++++++++++++++++++++++------------------- 1 file changed, 83 insertions(+), 69 deletions(-) diff --git a/dash_slicer/slicer.py b/dash_slicer/slicer.py index b56e240..d8f4152 100644 --- a/dash_slicer/slicer.py +++ b/dash_slicer/slicer.py @@ -28,7 +28,6 @@ class VolumeSlicer: axis (int): the dimension to slice in. The default 0. 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 @@ -302,6 +301,7 @@ def _create_dash_components(self): showgrid=False, showticklabels=False, zeroline=False, + autorange=True, constrain="range", ) fig.update_yaxes( @@ -320,14 +320,6 @@ def _create_dash_components(self): config={"scrollZoom": True}, ) - initial_index = info["size"][2] // 2 - initial_state = { - "index": initial_index, - "pos": info["offset"][2] + initial_index * info["stepsize"][2], - "axis": info["axis"], - "color": info["color"], - } - # 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? @@ -336,7 +328,7 @@ def _create_dash_components(self): min=0, max=info["size"][2] - 1, step=1, - value=initial_index, + value=info["size"][2] // 2, updatemode="drag", tooltip={"always_visible": False, "placement": "left"}, ) @@ -364,8 +356,8 @@ def _create_dash_components(self): # 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 (public) state of the slicer. This value is rate-limited - self._state = Store(id=self._subid("state", True), data=initial_state) + # The (public) state of the slicer. This value is rate-limited. Initially null. + self._state = Store(id=self._subid("state", True), data=None) # Signal to set the position of other slicers with the same scene_id. self._setpos = Store(id=self._subid("setpos", True), data=None) @@ -391,6 +383,8 @@ def _create_server_callbacks(self): [Input(self._state.id, "data")], ) def upload_requested_slice(state): + if state is None: + return dash.no_update index = state["index"] slice = img_array_to_uri(self._slice(index)) return {"index": index, "slice": slice} @@ -471,7 +465,7 @@ def _create_client_callbacks(self): app.clientside_callback( """ - function update_index_rate_limiting(index, n_intervals, interval, info) { + function update_index_rate_limiting(index, relayoutData, n_intervals, interval, info, figure) { if (!window._slicer_{{ID}}) window._slicer_{{ID}} = {}; let private_state = window._slicer_{{ID}}; @@ -483,6 +477,19 @@ def _create_client_callbacks(self): if (trigger.prop_id.indexOf('slider') >= 0) slider_was_moved = true; } + // Get axis ranges + let range_was_changed = false; + let xrange = figure.layout.xaxis.range + let yrange = figure.layout.yaxis.range; + if (relayoutData && relayoutData.xaxis && relayoutData.xaxis.range) { + xrange = relayoutData.xaxis.range; + range_was_changed = true; + } + if (relayoutData && relayoutData.yaxis && relayoutData.yaxis.range) { + yrange = relayoutData.yaxis.range; + range_was_changed = true + } + // Initialize return values let new_state = dash_clientside.no_update; let disable_timer = false; @@ -490,10 +497,10 @@ def _create_client_callbacks(self): // If the slider moved, remember the time when this happened private_state.new_time = private_state.new_time || 0; - if (slider_was_moved) { + if (slider_was_moved || range_was_changed) { private_state.new_time = now; } else if (!n_intervals) { - disable_timer = true; // start disabled + private_state.new_time = now; } // We can either update the rate-limited index interval ms after @@ -501,18 +508,17 @@ 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 != private_state.index) { - if (now - private_state.new_time >= interval) { - private_state.index = index; - disable_timer = true; - console.log('requesting slice ' + index); - new_state = { - index: index, - pos: info.offset[2] + index * info.stepsize[2], - axis: info.axis, - color: info.color, - }; - } + if (now - private_state.new_time >= interval) { + disable_timer = true; + console.log('requesting slice ' + index); + new_state = { + index: index, + xrange: xrange, + yrange: yrange, + zpos: info.offset[2] + index * info.stepsize[2], + axis: info.axis, + color: info.color, + }; } return [new_state, disable_timer]; @@ -524,14 +530,18 @@ def _create_client_callbacks(self): Output(self._state.id, "data"), Output(self._timer.id, "disabled"), ], - [Input(self._slider.id, "value"), Input(self._timer.id, "n_intervals")], + [ + Input(self._slider.id, "value"), + Input(self._graph.id, "relayoutData"), + Input(self._timer.id, "n_intervals"), + ], [ State(self._timer.id, "interval"), State(self._info.id, "data"), + State(self._graph.id, "figure"), ], ) - - # todo: include info about axis range in state + # todo: bring back new store for req-slice # ---------------------------------------------------------------------- # Callback that creates a list of image traces (slice and overlay). @@ -600,33 +610,28 @@ def _create_client_callbacks(self): app.clientside_callback( """ function update_indicator_traces(states, info) { - let x0 = info.offset[0], y0 = info.offset[1]; - let x1 = x0 + info.size[0] * info.stepsize[0], y1 = y0 + info.size[1] * info.stepsize[1]; - x0 = x0 - info.stepsize[0], y0 = y0 - info.stepsize[1]; - let d = ((x1 - x0) + (y1 - y0)) * 0.5 * 0.05; - - let axii = [0, 1, 2]; - axii.splice(info.axis, 1); let traces = []; for (let state of states) { - let pos = state.pos; - if (state.axis == axii[0]) { - // x relative to our slice, y in scene-coords - traces.push({ - //x: [x0 - d, x0, null, x1, x1 + d, null], - x: [x0, x1], - y: [pos, pos], - line: {color: state.color, width: 1} - }); - } else if (state.axis == axii[1]) { - // x in scene-coords, y relative to our slice - traces.push({ - x: [pos, pos], - y: [y0, y1], - //y: [y0 - d, y0, null, y1, y1 + d, null], - line: {color: state.color, width: 1} - }); + if (!state) continue; + let zpos = [state.zpos, state.zpos]; + let trace = null; + if (info.axis == 0 && state.axis == 1) { + trace = {x: state.xrange, y: zpos}; + } else if (info.axis == 0 && state.axis == 2) { + trace = {x: zpos, y: state.xrange}; + } else if (info.axis == 1 && state.axis == 2) { + trace = {x: zpos, y: state.yrange}; + } else if (info.axis == 1 && state.axis == 0) { + trace = {x: state.xrange, y: zpos}; + } else if (info.axis == 2 && state.axis == 0) { + trace = {x: state.yrange, y: zpos}; + } else if (info.axis == 2 && state.axis == 1) { + trace = {x: zpos, y: state.yrange}; + } + if (trace) { + trace.line = {color: state.color, width: 1}; + traces.push(trace); } } @@ -637,7 +642,11 @@ def _create_client_callbacks(self): trace.showlegend = false; } - return traces; + if (states.length && !traces.length) { + return dash_clientside.no_update; + } else { + return traces; + } } """, Output(self._indicator_traces.id, "data"), @@ -651,9 +660,7 @@ def _create_client_callbacks(self): "data", ) ], - [ - State(self._info.id, "data"), - ], + [State(self._info.id, "data")], ) # ---------------------------------------------------------------------- @@ -661,18 +668,20 @@ def _create_client_callbacks(self): app.clientside_callback( """ - function update_figure(img_traces, indicators, ori_figure, info) { + function update_figure(img_traces, indicators, info, ori_figure) { // Collect traces let traces = []; for (let trace of img_traces) { traces.push(trace); } for (let trace of indicators) { if (trace.line.color) traces.push(trace); } - // Show our own color as a rectangle around the image, but only if - // there are other slicers with the same scene id, on a different axis. - // We do some math to make sure that these indicators are the same - // size (in scene coordinates) for all slicers of the same data. - if (indicators.length > 0 && info.color) { + // Show our own color around the image, but only if there are other + // slicers with the same scene id, on a different axis. We do some + // math to make sure that these indicators are the same size (in + // scene coordinates) for all slicers of the same data. We add it + // here and not in update_indicator_traces() because we want that the + // indicator IS taken into account with autoscale. + if (info.color) { let fraction, x1, x2, x3, x4, y1, y2, y3, y4, z1, z4, dd; fraction = 0.1; x1 = info.offset[0] - info.stepsize[0]/2; @@ -684,6 +693,7 @@ def _create_client_callbacks(self): dd = fraction * (x4-x1 + y4-y1 + z4-z1) / 3; // average dd = Math.min(dd, 0.45 * Math.min(x4-x1, y4-y1, z4-z1)); // failsafe x2 = x1 + dd, x3 = x4 - dd, y2 = y1 + dd, y3 = y4 - dd; + let color = indicators.length ? info.color : null; traces.push({ type: 'scatter', mode: 'lines', @@ -691,12 +701,19 @@ def _create_client_callbacks(self): showlegend: false, x: [x1, x1, x2, null, x3, x4, x4, null, x4, x4, x3, null, x2, x1, x1], y: [y2, y1, y1, null, y1, y1, y2, null, y3, y4, y4, null, y4, y4, y3], - line: {color: info.color, width: 4} + line: {color: color, width: 4} }); } // Update figure + // We start with autorange to nicely snap around the image, but then + // we want it turned off, so that 'large' traces (e.g. indicators of + // other slicers) don't cause the figure to zoom out. let figure = {...ori_figure}; + if (ori_figure.data.length) { + figure.layout.xaxis.autorange = false; + figure.layout.yaxis.autorange = false; + } figure.data = traces; return figure; } @@ -706,8 +723,5 @@ def _create_client_callbacks(self): Input(self._img_traces.id, "data"), Input(self._indicator_traces.id, "data"), ], - [ - State(self._graph.id, "figure"), - State(self._info.id, "data"), - ], + [State(self._info.id, "data"), State(self._graph.id, "figure")], ) From dafc68cc1c5a03cbb7f412367e54bd0376caedae Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 4 Dec 2020 14:15:58 +0100 Subject: [PATCH 10/16] prevent unnecesary high res pulls --- dash_slicer/slicer.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/dash_slicer/slicer.py b/dash_slicer/slicer.py index d8f4152..b7c1b11 100644 --- a/dash_slicer/slicer.py +++ b/dash_slicer/slicer.py @@ -383,7 +383,7 @@ def _create_server_callbacks(self): [Input(self._state.id, "data")], ) def upload_requested_slice(state): - if state is None: + if state is None or not state["index_changed"]: return dash.no_update index = state["index"] slice = img_array_to_uri(self._slice(index)) @@ -510,15 +510,20 @@ def _create_client_callbacks(self): // experience, and the interval can be set much lower. if (now - private_state.new_time >= interval) { disable_timer = true; - console.log('requesting slice ' + index); new_state = { index: index, + index_changed: false, xrange: xrange, yrange: yrange, zpos: info.offset[2] + index * info.stepsize[2], axis: info.axis, color: info.color, }; + if (index != private_state.index) { + private_state.index = index; + new_state.index_changed = true; + console.log('requesting slice ' + index); + } } return [new_state, disable_timer]; @@ -541,7 +546,6 @@ def _create_client_callbacks(self): State(self._graph.id, "figure"), ], ) - # todo: bring back new store for req-slice # ---------------------------------------------------------------------- # Callback that creates a list of image traces (slice and overlay). From d975c2f0565d308282ffe2f4adf77ea3698339dd Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 4 Dec 2020 15:29:51 +0100 Subject: [PATCH 11/16] Show slicer views in 3D view, in one example --- dash_slicer/slicer.py | 11 +-------- examples/slicer_with_3_views.py | 44 ++++++++++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/dash_slicer/slicer.py b/dash_slicer/slicer.py index b7c1b11..e2ae195 100644 --- a/dash_slicer/slicer.py +++ b/dash_slicer/slicer.py @@ -654,16 +654,7 @@ def _create_client_callbacks(self): } """, Output(self._indicator_traces.id, "data"), - [ - Input( - { - "scene": self._scene_id, - "context": ALL, - "name": "state", - }, - "data", - ) - ], + [Input({"scene": self._scene_id, "context": ALL, "name": "state"}, "data")], [State(self._info.id, "data")], ) diff --git a/examples/slicer_with_3_views.py b/examples/slicer_with_3_views.py index 440bb04..a624aa9 100644 --- a/examples/slicer_with_3_views.py +++ b/examples/slicer_with_3_views.py @@ -8,6 +8,7 @@ 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 from skimage.measure import marching_cubes import imageio @@ -21,11 +22,10 @@ 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) +verts, faces, _, _ = marching_cubes(vol, 300, step_size=4) x, y, z = verts.T i, j, k = faces.T -fig_mesh = go.Figure() -fig_mesh.add_trace(go.Mesh3d(x=z, y=y, z=x, opacity=0.2, i=k, j=j, k=i)) +mesh = go.Mesh3d(x=z, y=y, z=x, opacity=0.2, i=k, j=j, k=i) # Put everything together in a 2x2 grid app.layout = html.Div( @@ -62,12 +62,48 @@ ] ), html.Div( - [html.Center(html.H1("3D")), dcc.Graph(id="graph-helper", figure=fig_mesh)] + [ + html.Center(html.H1("3D")), + dcc.Graph(id="3Dgraph", figure=go.Figure(data=[mesh])), + ] ), ], ) +# Callback to display slicer view positions in the 3D view +app.clientside_callback( + """ +function update_3d_figure(states, ori_figure) { + let traces = [ori_figure.data[0]] + for (let state of states) { + if (!state) continue; + let xrange = state.xrange; + let yrange = state.yrange; + let xyz = [ + [xrange[0], xrange[1], xrange[1], xrange[0], xrange[0]], + [yrange[0], yrange[0], yrange[1], yrange[1], yrange[0]], + [state.zpos, state.zpos, state.zpos, state.zpos, state.zpos] + ]; + xyz.splice(2 - state.axis, 0, xyz.pop()); + let s = { + type: 'scatter3d', + x: xyz[0], y: xyz[1], z: xyz[2], + mode: 'lines', line: {color: state.color} + }; + traces.push(s); + } + let figure = {...ori_figure}; + figure.data = traces; + return figure; +} + """, + Output("3Dgraph", "figure"), + Input({"scene": slicer1.scene_id, "context": ALL, "name": "state"}, "data"), + State("3Dgraph", "figure"), +) + + if __name__ == "__main__": # Note: dev_tools_props_check negatively affects the performance of VolumeSlicer app.run_server(debug=True, dev_tools_props_check=False) From c77e295a2f8235a4a1745cdc49829f1477abc5b8 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 4 Dec 2020 15:46:32 +0100 Subject: [PATCH 12/16] tweaks --- dash_slicer/slicer.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/dash_slicer/slicer.py b/dash_slicer/slicer.py index e2ae195..dccf365 100644 --- a/dash_slicer/slicer.py +++ b/dash_slicer/slicer.py @@ -1,5 +1,5 @@ import numpy as np -import plotly +import plotly.graph_objects import dash from dash.dependencies import Input, Output, State, ALL from dash_core_components import Graph, Slider, Store, Interval @@ -25,7 +25,7 @@ class VolumeSlicer: dimension (zyx).The spacing and origin are applied to make the slice drawn in "scene space" rather than "voxel space". origin (tuple of floats): The offset for each dimension (zyx). - axis (int): the dimension to slice in. The default 0. + axis (int): the dimension to slice in. Default 0. 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. Note: setting this to False affects performance, see #12. @@ -172,8 +172,9 @@ def stores(self): @property def state(self): """A dcc.Store representing the current state of the slicer (present - in slicer.stores). Its data is a dict with the fields: - index (int), pos (float), axis (int), color (str). + in slicer.stores). Its data is a dict with the fields: index (int), + index_changed (bool), xrange (2 floats), yrange (2 floats), + zpos (float), axis (int), color (str). Its id is a dictionary so it can be used in a pattern matching Input. Fields: context, scene, name. Where scene is the scene_id and name is "state". @@ -191,7 +192,7 @@ def overlay_data(self): def create_overlay_data(self, mask, color=None): """Given a 3D mask array and an index, create an object that can be used as output for ``slicer.overlay_data``. The color - can be an rgb/rgba tuple, or a hex color. Alternatively, color + can be a hex color or an rgb/rgba tuple. Alternatively, color can be a list of such colors, defining a colormap. """ # Check the mask From 7dc8ffbaf4ca2bfb1fbf622032b7bb5bb9331b68 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 4 Dec 2020 15:59:30 +0100 Subject: [PATCH 13/16] fix example --- examples/slicer_with_3_views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/slicer_with_3_views.py b/examples/slicer_with_3_views.py index a624aa9..60c9f86 100644 --- a/examples/slicer_with_3_views.py +++ b/examples/slicer_with_3_views.py @@ -99,8 +99,8 @@ } """, Output("3Dgraph", "figure"), - Input({"scene": slicer1.scene_id, "context": ALL, "name": "state"}, "data"), - State("3Dgraph", "figure"), + [Input({"scene": slicer1.scene_id, "context": ALL, "name": "state"}, "data")], + [State("3Dgraph", "figure")], ) From 93561e2bf48c95dca04c3c6a105aa3d7965514f7 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 4 Dec 2020 17:33:33 +0100 Subject: [PATCH 14/16] limit state.xrange and state.yrange to the volume bounds --- dash_slicer/slicer.py | 113 +++++++++++++++++++++++------------------- 1 file changed, 63 insertions(+), 50 deletions(-) diff --git a/dash_slicer/slicer.py b/dash_slicer/slicer.py index dccf365..e5ac63e 100644 --- a/dash_slicer/slicer.py +++ b/dash_slicer/slicer.py @@ -478,18 +478,41 @@ def _create_client_callbacks(self): if (trigger.prop_id.indexOf('slider') >= 0) slider_was_moved = true; } - // Get axis ranges + // Calculate view range based on the volume + let xrangeVol = [ + info.offset[0] - 0.5 * info.stepsize[0], + info.offset[0] + (info.size[0] - 0.5) * info.stepsize[0] + ]; + let yrangeVol = [ + info.offset[1] - 0.5 * info.stepsize[1], + info.offset[1] + (info.size[1] - 0.5) * info.stepsize[1] + ]; + + // Get view range from the figure. We make range[0] < range[1] let range_was_changed = false; - let xrange = figure.layout.xaxis.range - let yrange = figure.layout.yaxis.range; + let xrangeFig = figure.layout.xaxis.range + let yrangeFig = figure.layout.yaxis.range; if (relayoutData && relayoutData.xaxis && relayoutData.xaxis.range) { - xrange = relayoutData.xaxis.range; + xrangeFig = relayoutData.xaxis.range; range_was_changed = true; } if (relayoutData && relayoutData.yaxis && relayoutData.yaxis.range) { - yrange = relayoutData.yaxis.range; + yrangeFig = relayoutData.yaxis.range; range_was_changed = true } + xrangeFig = [Math.min(xrangeFig[0], xrangeFig[1]), Math.max(xrangeFig[0], xrangeFig[1])]; + yrangeFig = [Math.min(yrangeFig[0], yrangeFig[1]), Math.max(yrangeFig[0], yrangeFig[1])]; + + // Add a little offset to avoid the corner-indicators for THIS slicer to + // only be half-visible. The 400 is an estimate of the figure width/height. + xrangeFig[0] += 2 * (xrangeFig[1] - xrangeFig[0]) / 400; + xrangeFig[1] -= 2 * (xrangeFig[1] - xrangeFig[0]) / 400; + yrangeFig[0] += 2 * (yrangeFig[1] - yrangeFig[0]) / 400; + yrangeFig[1] -= 2 * (yrangeFig[1] - yrangeFig[0]) / 400; + + // Combine the ranges + let xrange = [Math.max(xrangeVol[0], xrangeFig[0]), Math.min(xrangeVol[1], xrangeFig[1])]; + let yrange = [Math.max(yrangeVol[0], yrangeFig[0]), Math.min(yrangeVol[1], yrangeFig[1])]; // Initialize return values let new_state = dash_clientside.no_update; @@ -614,7 +637,7 @@ def _create_client_callbacks(self): app.clientside_callback( """ - function update_indicator_traces(states, info) { + function update_indicator_traces(states, info, thisState) { let traces = []; for (let state of states) { @@ -640,23 +663,52 @@ def _create_client_callbacks(self): } } + // Show our own color around the image, but only if there are other + // slicers with the same scene id, on a different axis. We do some + // math to make sure that these indicators are the same size (in + // scene coordinates) for all slicers of the same data. + if (thisState && info.color && traces.length) { + let fraction = 0.1; + let lengthx = info.size[0] * info.stepsize[0]; + let lengthy = info.size[1] * info.stepsize[1]; + let lengthz = info.size[2] * info.stepsize[2]; + let dd = fraction * (lengthx + lengthy + lengthz) / 3; // average + dd = Math.min(dd, 0.45 * Math.min(lengthx, lengthy, lengthz)); // failsafe + let x1 = thisState.xrange[0]; + let x2 = thisState.xrange[0] + dd; + let x3 = thisState.xrange[1] - dd; + let x4 = thisState.xrange[1]; + let y1 = thisState.yrange[0]; + let y2 = thisState.yrange[0] + dd; + let y3 = thisState.yrange[1] - dd; + let y4 = thisState.yrange[1]; + traces.push({ + x: [x1, x1, x2, null, x3, x4, x4, null, x4, x4, x3, null, x2, x1, x1], + y: [y2, y1, y1, null, y1, y1, y2, null, y3, y4, y4, null, y4, y4, y3], + line: {color: info.color, width: 4} + }); + } + + // Post-process the traces we created above for (let trace of traces) { trace.type = 'scatter'; trace.mode = 'lines'; trace.hoverinfo = 'skip'; trace.showlegend = false; } - - if (states.length && !traces.length) { - return dash_clientside.no_update; - } else { + if (thisState) { return traces; + } else { + return dash_clientside.no_update; } } """, Output(self._indicator_traces.id, "data"), [Input({"scene": self._scene_id, "context": ALL, "name": "state"}, "data")], - [State(self._info.id, "data")], + [ + State(self._info.id, "data"), + State(self._state.id, "data"), + ], ) # ---------------------------------------------------------------------- @@ -665,51 +717,12 @@ def _create_client_callbacks(self): app.clientside_callback( """ function update_figure(img_traces, indicators, info, ori_figure) { - // Collect traces let traces = []; for (let trace of img_traces) { traces.push(trace); } for (let trace of indicators) { if (trace.line.color) traces.push(trace); } - - // Show our own color around the image, but only if there are other - // slicers with the same scene id, on a different axis. We do some - // math to make sure that these indicators are the same size (in - // scene coordinates) for all slicers of the same data. We add it - // here and not in update_indicator_traces() because we want that the - // indicator IS taken into account with autoscale. - if (info.color) { - let fraction, x1, x2, x3, x4, y1, y2, y3, y4, z1, z4, dd; - fraction = 0.1; - x1 = info.offset[0] - info.stepsize[0]/2; - y1 = info.offset[1] - info.stepsize[1]/2; - z1 = info.offset[2] - info.stepsize[2]/2; - x4 = info.offset[0] + (info.size[0] - 0.5) * info.stepsize[0]; - y4 = info.offset[1] + (info.size[1] - 0.5) * info.stepsize[1]; - z4 = info.offset[2] + (info.size[2] - 0.5) * info.stepsize[2]; - dd = fraction * (x4-x1 + y4-y1 + z4-z1) / 3; // average - dd = Math.min(dd, 0.45 * Math.min(x4-x1, y4-y1, z4-z1)); // failsafe - x2 = x1 + dd, x3 = x4 - dd, y2 = y1 + dd, y3 = y4 - dd; - let color = indicators.length ? info.color : null; - traces.push({ - type: 'scatter', - mode: 'lines', - hoverinfo: 'skip', - showlegend: false, - x: [x1, x1, x2, null, x3, x4, x4, null, x4, x4, x3, null, x2, x1, x1], - y: [y2, y1, y1, null, y1, y1, y2, null, y3, y4, y4, null, y4, y4, y3], - line: {color: color, width: 4} - }); - } - // Update figure - // We start with autorange to nicely snap around the image, but then - // we want it turned off, so that 'large' traces (e.g. indicators of - // other slicers) don't cause the figure to zoom out. let figure = {...ori_figure}; - if (ori_figure.data.length) { - figure.layout.xaxis.autorange = false; - figure.layout.yaxis.autorange = false; - } figure.data = traces; return figure; } From 84d30e88dc034db1171a1a123d8b70db77ffa868 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Sat, 5 Dec 2020 23:42:52 +0100 Subject: [PATCH 15/16] prevent camera reset --- examples/slicer_with_3_views.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/slicer_with_3_views.py b/examples/slicer_with_3_views.py index 60c9f86..2462f20 100644 --- a/examples/slicer_with_3_views.py +++ b/examples/slicer_with_3_views.py @@ -26,6 +26,9 @@ x, y, z = verts.T i, j, k = faces.T mesh = go.Mesh3d(x=z, y=y, z=x, opacity=0.2, i=k, j=j, k=i) +fig = go.Figure(data=[mesh]) +fig.update_layout(uirevision="anything") # prevent orientation reset on update + # Put everything together in a 2x2 grid app.layout = html.Div( @@ -64,7 +67,7 @@ html.Div( [ html.Center(html.H1("3D")), - dcc.Graph(id="3Dgraph", figure=go.Figure(data=[mesh])), + dcc.Graph(id="3Dgraph", figure=fig), ] ), ], From 5a4b76869c99bd68fe13fbd514e49f46df787786 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Sun, 6 Dec 2020 00:07:22 +0100 Subject: [PATCH 16/16] use actual plot size --- dash_slicer/slicer.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/dash_slicer/slicer.py b/dash_slicer/slicer.py index e5ac63e..c0de323 100644 --- a/dash_slicer/slicer.py +++ b/dash_slicer/slicer.py @@ -503,12 +503,16 @@ def _create_client_callbacks(self): xrangeFig = [Math.min(xrangeFig[0], xrangeFig[1]), Math.max(xrangeFig[0], xrangeFig[1])]; yrangeFig = [Math.min(yrangeFig[0], yrangeFig[1]), Math.max(yrangeFig[0], yrangeFig[1])]; - // Add a little offset to avoid the corner-indicators for THIS slicer to - // only be half-visible. The 400 is an estimate of the figure width/height. - xrangeFig[0] += 2 * (xrangeFig[1] - xrangeFig[0]) / 400; - xrangeFig[1] -= 2 * (xrangeFig[1] - xrangeFig[0]) / 400; - yrangeFig[0] += 2 * (yrangeFig[1] - yrangeFig[0]) / 400; - yrangeFig[1] -= 2 * (yrangeFig[1] - yrangeFig[0]) / 400; + // Add offset to avoid the corner-indicators for THIS slicer to only be half-visible + let plotSize = [400, 400]; // This estimate results in ok results + let graphDiv = document.getElementById('{{ID}}-graph'); + let plotDiv = graphDiv.getElementsByClassName('js-plotly-plot')[0]; + if (plotDiv && plotDiv._fullLayout) + plotSize = [plotDiv._fullLayout.width, plotDiv._fullLayout.height]; + xrangeFig[0] += 2 * (xrangeFig[1] - xrangeFig[0]) / plotSize[0]; + xrangeFig[1] -= 2 * (xrangeFig[1] - xrangeFig[0]) / plotSize[0]; + yrangeFig[0] += 2 * (yrangeFig[1] - yrangeFig[0]) / plotSize[1]; + yrangeFig[1] -= 2 * (yrangeFig[1] - yrangeFig[0]) / plotSize[1]; // Combine the ranges let xrange = [Math.max(xrangeVol[0], xrangeFig[0]), Math.min(xrangeVol[1], xrangeFig[1])];