1
1
import numpy as np
2
- from plotly .graph_objects import Figure , Image
2
+ from plotly .graph_objects import Figure , Image , Scatter
3
3
from dash import Dash
4
- from dash .dependencies import Input , Output , State
4
+ from dash .dependencies import Input , Output , State , ALL
5
5
from dash_core_components import Graph , Slider , Store
6
6
7
- from .utils import gen_random_id , img_array_to_uri
7
+ from .utils import img_array_to_uri , get_thumbnail_size_from_shape
8
8
9
9
10
10
class DashVolumeSlicer :
11
- """A slicer to show 3D image data in Dash."""
11
+ """A slicer to show 3D image data in Dash.
12
12
13
- def __init__ (self , app , volume , axis = 0 , id = None ):
13
+ Parameters:
14
+ app (dash.Dash): the Dash application instance.
15
+ volume (ndarray): the 3D numpy array to slice through.
16
+ axis (int): the dimension to slice in. Default 0.
17
+ scene_id (str): the scene that this slicer is part of. Slicers
18
+ that have the same scene-id show each-other's positions with
19
+ line indicators. By default this is a hash of ``id(volume)``.
20
+
21
+ This is a placeholder object, not a Dash component. The components
22
+ that make up the slicer can be accessed as attributes:
23
+
24
+ * ``graph``: the Graph object.
25
+ * ``slider``: the Slider object.
26
+ * ``stores``: a list of Store objects. Some are "public" values, others
27
+ used internally. Make sure to put them somewhere in the layout.
28
+
29
+ Each component is given a dict-id with the following keys:
30
+
31
+ * "context": a unique string id for this slicer instance.
32
+ * "scene": the scene_id.
33
+ * "axis": the int axis.
34
+ * "name": the name of the (sub) component.
35
+
36
+ TODO: iron out these details, list the stores that are public
37
+ """
38
+
39
+ _global_slicer_counter = 0
40
+
41
+ def __init__ (self , app , volume , axis = 0 , scene_id = None ):
14
42
if not isinstance (app , Dash ):
15
43
raise TypeError ("Expect first arg to be a Dash app." )
44
+ self ._app = app
16
45
# Check and store volume
17
46
if not (isinstance (volume , np .ndarray ) and volume .ndim == 3 ):
18
47
raise TypeError ("Expected volume to be a 3D numpy array" )
@@ -22,29 +51,36 @@ def __init__(self, app, volume, axis=0, id=None):
22
51
raise ValueError ("The given axis must be 0, 1, or 2." )
23
52
self ._axis = int (axis )
24
53
# Check and store id
25
- if id is None :
26
- id = gen_random_id ()
27
- elif not isinstance (id , str ):
28
- raise TypeError ("Id must be a string" )
29
- self ._id = id
54
+ if scene_id is None :
55
+ scene_id = "volume_" + hex (id (volume ))[2 :]
56
+ elif not isinstance (scene_id , str ):
57
+ raise TypeError ("scene_id must be a string" )
58
+ self .scene_id = scene_id
59
+ # Get unique id scoped to this slicer object
60
+ DashVolumeSlicer ._global_slicer_counter += 1
61
+ self .context_id = "slicer_" + str (DashVolumeSlicer ._global_slicer_counter )
30
62
31
63
# Get the slice size (width, height), and max index
32
- # arr_shape = list(volume.shape)
33
- # arr_shape.pop(self._axis)
34
- # slice_size = list (reversed(arr_shape))
64
+ arr_shape = list (volume .shape )
65
+ arr_shape .pop (self ._axis )
66
+ self . _slice_size = tuple (reversed (arr_shape ))
35
67
self ._max_index = self ._volume .shape [self ._axis ] - 1
36
68
37
69
# Prep low-res slices
70
+ thumbnail_size = get_thumbnail_size_from_shape (arr_shape , 32 )
38
71
thumbnails = [
39
- img_array_to_uri (self ._slice (i ), ( 32 , 32 ) )
72
+ img_array_to_uri (self ._slice (i ), thumbnail_size )
40
73
for i in range (self ._max_index + 1 )
41
74
]
42
75
43
76
# Create a placeholder trace
44
77
# todo: can add "%{z[0]}", but that would be the scaled value ...
45
- trace = Image (source = "" , hovertemplate = "(%{x}, %{y})<extra></extra>" )
78
+ image_trace = Image (
79
+ source = "" , dx = 1 , dy = 1 , hovertemplate = "(%{x}, %{y})<extra></extra>"
80
+ )
81
+ scatter_trace = Scatter (x = [], y = []) # placeholder
46
82
# Create the figure object
47
- fig = Figure (data = [trace ])
83
+ self . _fig = fig = Figure (data = [image_trace , scatter_trace ])
48
84
fig .update_layout (
49
85
template = None ,
50
86
margin = dict (l = 0 , r = 0 , b = 0 , t = 0 , pad = 4 ),
@@ -70,6 +106,7 @@ def __init__(self, app, volume, axis=0, id=None):
70
106
config = {"scrollZoom" : True },
71
107
)
72
108
# Create a slider object that the user can put in the layout (or not)
109
+ # todo: use tooltip to show current value?
73
110
self .slider = Slider (
74
111
id = self ._subid ("slider" ),
75
112
min = 0 ,
@@ -81,18 +118,29 @@ def __init__(self, app, volume, axis=0, id=None):
81
118
)
82
119
# Create the stores that we need (these must be present in the layout)
83
120
self .stores = [
84
- Store (id = self ._subid ("slice-index" ), data = volume .shape [self ._axis ] // 2 ),
121
+ Store (
122
+ id = self ._subid ("_slice-size" ), data = self ._slice_size + thumbnail_size
123
+ ),
124
+ Store (id = self ._subid ("index" ), data = volume .shape [self ._axis ] // 2 ),
85
125
Store (id = self ._subid ("_requested-slice-index" ), data = 0 ),
86
126
Store (id = self ._subid ("_slice-data" ), data = "" ),
87
127
Store (id = self ._subid ("_slice-data-lowres" ), data = thumbnails ),
128
+ Store (id = self ._subid ("_indicators" ), data = []),
88
129
]
89
130
90
- self ._create_server_callbacks (app )
91
- self ._create_client_callbacks (app )
131
+ self ._create_server_callbacks ()
132
+ self ._create_client_callbacks ()
92
133
93
- def _subid (self , subid ):
134
+ def _subid (self , name ):
94
135
"""Given a subid, get the full id including the slicer's prefix."""
95
- return self ._id + "-" + subid
136
+ # return self.context_id + "-" + name
137
+ # todo: is there a penalty for using a dict-id vs a string-id?
138
+ return {
139
+ "context" : self .context_id ,
140
+ "scene" : self .scene_id ,
141
+ "axis" : self ._axis ,
142
+ "name" : name ,
143
+ }
96
144
97
145
def _slice (self , index ):
98
146
"""Sample a slice from the volume."""
@@ -101,8 +149,9 @@ def _slice(self, index):
101
149
im = self ._volume [tuple (indices )]
102
150
return (im .astype (np .float32 ) * (255 / im .max ())).astype (np .uint8 )
103
151
104
- def _create_server_callbacks (self , app ):
152
+ def _create_server_callbacks (self ):
105
153
"""Create the callbacks that run server-side."""
154
+ app = self ._app
106
155
107
156
@app .callback (
108
157
Output (self ._subid ("_slice-data" ), "data" ),
@@ -112,16 +161,17 @@ def upload_requested_slice(slice_index):
112
161
slice = self ._slice (slice_index )
113
162
return [slice_index , img_array_to_uri (slice )]
114
163
115
- def _create_client_callbacks (self , app ):
164
+ def _create_client_callbacks (self ):
116
165
"""Create the callbacks that run client-side."""
166
+ app = self ._app
117
167
118
168
app .clientside_callback (
119
169
"""
120
170
function handle_slider_move(index) {
121
171
return index;
122
172
}
123
173
""" ,
124
- Output (self ._subid ("slice- index" ), "data" ),
174
+ Output (self ._subid ("index" ), "data" ),
125
175
[Input (self ._subid ("slider" ), "value" )],
126
176
)
127
177
@@ -138,24 +188,24 @@ def _create_client_callbacks(self, app):
138
188
}
139
189
}
140
190
""" .replace (
141
- "{{ID}}" , self ._id
191
+ "{{ID}}" , self .context_id
142
192
),
143
193
Output (self ._subid ("_requested-slice-index" ), "data" ),
144
- [Input (self ._subid ("slice- index" ), "data" )],
194
+ [Input (self ._subid ("index" ), "data" )],
145
195
)
146
196
147
197
# app.clientside_callback("""
148
198
# function update_slider_pos(index) {
149
199
# return index;
150
200
# }
151
201
# """,
152
- # [Output("slice- index", "data")],
202
+ # [Output("index", "data")],
153
203
# [State("slider", "value")],
154
204
# )
155
205
156
206
app .clientside_callback (
157
207
"""
158
- function handle_incoming_slice(index, index_and_data, ori_figure, lowres) {
208
+ function handle_incoming_slice(index, index_and_data, indicators, ori_figure, lowres, slice_size ) {
159
209
let new_index = index_and_data[0];
160
210
let new_data = index_and_data[1];
161
211
// Store data in cache
@@ -164,30 +214,97 @@ def _create_client_callbacks(self, app):
164
214
slice_cache[new_index] = new_data;
165
215
// Get the data we need *now*
166
216
let data = slice_cache[index];
217
+ let x0 = 0, y0 = 0, dx = 1, dy = 1;
167
218
//slice_cache[new_index] = undefined; // todo: disabled cache for now!
168
219
// Maybe we do not need an update
169
220
if (!data) {
170
221
data = lowres[index];
222
+ // Scale the image to take the exact same space as the full-res
223
+ // version. It's not correct, but it looks better ...
224
+ // slice_size = full_w, full_h, low_w, low_h
225
+ dx = slice_size[0] / slice_size[2];
226
+ dy = slice_size[1] / slice_size[3];
227
+ x0 = 0.5 * dx - 0.5;
228
+ y0 = 0.5 * dy - 0.5;
171
229
}
172
- if (data == ori_figure.data[0].source) {
230
+ if (data == ori_figure.data[0].source && indicators.version == ori_figure.data[1].version ) {
173
231
return window.dash_clientside.no_update;
174
232
}
175
233
// Otherwise, perform update
176
234
console.log("updating figure");
177
235
let figure = {...ori_figure};
178
236
figure.data[0].source = data;
237
+ figure.data[0].x0 = x0;
238
+ figure.data[0].y0 = y0;
239
+ figure.data[0].dx = dx;
240
+ figure.data[0].dy = dy;
241
+ figure.data[1] = indicators;
179
242
return figure;
180
243
}
181
244
""" .replace (
182
- "{{ID}}" , self ._id
245
+ "{{ID}}" , self .context_id
183
246
),
184
247
Output (self ._subid ("graph" ), "figure" ),
185
248
[
186
- Input (self ._subid ("slice- index" ), "data" ),
249
+ Input (self ._subid ("index" ), "data" ),
187
250
Input (self ._subid ("_slice-data" ), "data" ),
251
+ Input (self ._subid ("_indicators" ), "data" ),
188
252
],
189
253
[
190
254
State (self ._subid ("graph" ), "figure" ),
191
255
State (self ._subid ("_slice-data-lowres" ), "data" ),
256
+ State (self ._subid ("_slice-size" ), "data" ),
257
+ ],
258
+ )
259
+
260
+ # Select the *other* axii
261
+ axii = [0 , 1 , 2 ]
262
+ axii .pop (self ._axis )
263
+
264
+ # Create a callback to create a trace representing all slice-indices that:
265
+ # * corresponding to the same volume data
266
+ # * match any of the selected axii
267
+ app .clientside_callback (
268
+ """
269
+ function handle_indicator(indices1, indices2, slice_size, current) {
270
+ let w = slice_size[0], h = slice_size[1];
271
+ let dx = w / 20, dy = h / 20;
272
+ let version = (current.version || 0) + 1;
273
+ let x = [], y = [];
274
+ for (let index of indices1) {
275
+ x.push(...[-dx, -1, null, w, w + dx, null]);
276
+ y.push(...[index, index, index, index, index, index]);
277
+ }
278
+ for (let index of indices2) {
279
+ x.push(...[index, index, index, index, index, index]);
280
+ y.push(...[-dy, -1, null, h, h + dy, null]);
281
+ }
282
+ return {
283
+ type: 'scatter',
284
+ mode: 'lines',
285
+ line: {color: '#ff00aa'},
286
+ x: x,
287
+ y: y,
288
+ hoverinfo: 'skip',
289
+ version: version
290
+ };
291
+ }
292
+ """ ,
293
+ Output (self ._subid ("_indicators" ), "data" ),
294
+ [
295
+ Input (
296
+ {
297
+ "scene" : self .scene_id ,
298
+ "context" : ALL ,
299
+ "name" : "index" ,
300
+ "axis" : axis ,
301
+ },
302
+ "data" ,
303
+ )
304
+ for axis in axii
305
+ ],
306
+ [
307
+ State (self ._subid ("_slice-size" ), "data" ),
308
+ State (self ._subid ("_indicators" ), "data" ),
192
309
],
193
310
)
0 commit comments