4
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 img_array_to_uri , get_thumbnail_size_from_shape , shape3d_to_size2d
7
+ from .utils import img_array_to_uri , get_thumbnail_size , shape3d_to_size2d
8
8
9
9
10
10
class VolumeSlicer :
@@ -15,34 +15,29 @@ class VolumeSlicer:
15
15
volume (ndarray): the 3D numpy array to slice through. The dimensions
16
16
are assumed to be in zyx order. If this is not the case, you can
17
17
use ``np.swapaxes`` to make it so.
18
- spacing (tuple of floats): The distance between voxels for each dimension (zyx).
19
- The spacing and origin are applied to make the slice drawn in
20
- "scene space" rather than "voxel space".
18
+ spacing (tuple of floats): The distance between voxels for each
19
+ dimension (zyx). The spacing and origin are applied to make the slice
20
+ drawn in "scene space" rather than "voxel space".
21
21
origin (tuple of floats): The offset for each dimension (zyx).
22
22
axis (int): the dimension to slice in. Default 0.
23
23
reverse_y (bool): Whether to reverse the y-axis, so that the origin of
24
24
the slice is in the top-left, rather than bottom-left. Default True.
25
- (This sets the figure's yaxes ``autorange`` to either "reversed" or True.)
25
+ (This sets the figure's yaxes ``autorange`` to "reversed" or True.)
26
26
scene_id (str): the scene that this slicer is part of. Slicers
27
27
that have the same scene-id show each-other's positions with
28
- line indicators. By default this is a hash of ``id(volume)``.
28
+ line indicators. By default this is derived from ``id(volume)``.
29
29
30
30
This is a placeholder object, not a Dash component. The components
31
- that make up the slicer can be accessed as attributes:
31
+ that make up the slicer can be accessed as attributes. These must all
32
+ be present in the app layout:
32
33
33
- * ``graph``: the Graph object.
34
- * ``slider``: the Slider object.
35
- * ``stores``: a list of Store objects. Some are "public" values, others
36
- used internally. Make sure to put them somewhere in the layout.
34
+ * ``graph``: the dcc.Graph object. Use ``graph.figure`` to access the
35
+ Plotly figure object.
36
+ * ``slider``: the dcc.Slider object, its value represents the slice
37
+ index. If you don't want to use the slider, wrap it in a div with
38
+ style ``display: none``.
39
+ * ``stores``: a list of dcc.Store objects.
37
40
38
- Each component is given a dict-id with the following keys:
39
-
40
- * "context": a unique string id for this slicer instance.
41
- * "scene": the scene_id.
42
- * "axis": the int axis.
43
- * "name": the name of the (sub) component.
44
-
45
- TODO: iron out these details, list the stores that are public
46
41
"""
47
42
48
43
_global_slicer_counter = 0
@@ -58,10 +53,11 @@ def __init__(
58
53
reverse_y = True ,
59
54
scene_id = None
60
55
):
61
- # todo: also implement xyz dim order?
56
+
62
57
if not isinstance (app , Dash ):
63
58
raise TypeError ("Expect first arg to be a Dash app." )
64
59
self ._app = app
60
+
65
61
# Check and store volume
66
62
if not (isinstance (volume , np .ndarray ) and volume .ndim == 3 ):
67
63
raise TypeError ("Expected volume to be a 3D numpy array" )
@@ -70,33 +66,97 @@ def __init__(
70
66
spacing = float (spacing [0 ]), float (spacing [1 ]), float (spacing [2 ])
71
67
origin = (0 , 0 , 0 ) if origin is None else origin
72
68
origin = float (origin [0 ]), float (origin [1 ]), float (origin [2 ])
69
+
73
70
# Check and store axis
74
71
if not (isinstance (axis , int ) and 0 <= axis <= 2 ):
75
72
raise ValueError ("The given axis must be 0, 1, or 2." )
76
73
self ._axis = int (axis )
77
- # Check and store id
74
+ self ._reverse_y = bool (reverse_y )
75
+
76
+ # Check and store scene id, and generate
78
77
if scene_id is None :
79
78
scene_id = "volume_" + hex (id (volume ))[2 :]
80
79
elif not isinstance (scene_id , str ):
81
80
raise TypeError ("scene_id must be a string" )
82
- self .scene_id = scene_id
81
+ self ._scene_id = scene_id
82
+
83
83
# Get unique id scoped to this slicer object
84
84
VolumeSlicer ._global_slicer_counter += 1
85
- self .context_id = "slicer_ " + str (VolumeSlicer ._global_slicer_counter )
85
+ self ._context_id = "slicer " + str (VolumeSlicer ._global_slicer_counter )
86
86
87
- # Prepare slice info
88
- info = {
87
+ # Prepare slice info that we use at the client side
88
+ self . _slice_info = {
89
89
"shape" : tuple (volume .shape ),
90
90
"axis" : self ._axis ,
91
91
"size" : shape3d_to_size2d (volume .shape , axis ),
92
92
"origin" : shape3d_to_size2d (origin , axis ),
93
93
"spacing" : shape3d_to_size2d (spacing , axis ),
94
94
}
95
95
96
+ # Build the slicer
97
+ self ._create_dash_components ()
98
+ self ._create_server_callbacks ()
99
+ self ._create_client_callbacks ()
100
+
101
+ # Note(AK): we could make some stores public, but let's do this only when actual use-cases arise?
102
+
103
+ @property
104
+ def scene_id (self ):
105
+ """The id of the "virtual scene" for this slicer. Slicers that have
106
+ the same scene_id show each-other's positions.
107
+ """
108
+ return self ._scene_id
109
+
110
+ @property
111
+ def axis (self ):
112
+ """The axis at which the slicer is slicing."""
113
+ return self ._axis
114
+
115
+ @property
116
+ def graph (self ):
117
+ """The dcc.Graph for this slicer."""
118
+ return self ._graph
119
+
120
+ @property
121
+ def slider (self ):
122
+ """The dcc.Slider to change the index for this slicer."""
123
+ return self ._slider
124
+
125
+ @property
126
+ def stores (self ):
127
+ """A list of dcc.Stores that the slicer needs to work. These must
128
+ be added to the app layout.
129
+ """
130
+ return self ._stores
131
+
132
+ def _subid (self , name , use_dict = False ):
133
+ """Given a name, get the full id including the context id prefix."""
134
+ if use_dict :
135
+ # A dict-id is nice to query objects with pattern matching callbacks,
136
+ # and we use that to show the position of other sliders. But it makes
137
+ # the id's very long, which is annoying e.g. in the callback graph.
138
+ return {
139
+ "context" : self ._context_id ,
140
+ "scene" : self ._scene_id ,
141
+ "axis" : self ._axis ,
142
+ "name" : name ,
143
+ }
144
+ else :
145
+ return self ._context_id + "-" + name
146
+
147
+ def _slice (self , index ):
148
+ """Sample a slice from the volume."""
149
+ indices = [slice (None ), slice (None ), slice (None )]
150
+ indices [self ._axis ] = index
151
+ im = self ._volume [tuple (indices )]
152
+ return (im .astype (np .float32 ) * (255 / im .max ())).astype (np .uint8 )
153
+
154
+ def _create_dash_components (self ):
155
+ """Create the graph, slider, figure, etc."""
156
+ info = self ._slice_info
157
+
96
158
# Prep low-res slices
97
- thumbnail_size = get_thumbnail_size_from_shape (
98
- (info ["size" ][1 ], info ["size" ][0 ]), 32
99
- )
159
+ thumbnail_size = get_thumbnail_size (info ["size" ][:2 ], (32 , 32 ))
100
160
thumbnails = [
101
161
img_array_to_uri (self ._slice (i ), thumbnail_size )
102
162
for i in range (info ["size" ][2 ])
@@ -109,35 +169,35 @@ def __init__(
109
169
source = "" , dx = 1 , dy = 1 , hovertemplate = "(%{x}, %{y})<extra></extra>"
110
170
)
111
171
scatter_trace = Scatter (x = [], y = []) # placeholder
112
- # Create the figure object
172
+
173
+ # Create the figure object - can be accessed by user via slicer.graph.figure
113
174
self ._fig = fig = Figure (data = [image_trace , scatter_trace ])
114
175
fig .update_layout (
115
176
template = None ,
116
177
margin = dict (l = 0 , r = 0 , b = 0 , t = 0 , pad = 4 ),
117
178
)
118
179
fig .update_xaxes (
119
- # range=(0, slice_size[0]),
120
180
showgrid = False ,
121
181
showticklabels = False ,
122
182
zeroline = False ,
123
183
)
124
184
fig .update_yaxes (
125
- # range=(slice_size[1], 0), # todo: allow flipping x or y
126
185
showgrid = False ,
127
186
scaleanchor = "x" ,
128
187
showticklabels = False ,
129
188
zeroline = False ,
130
- autorange = "reversed" if reverse_y else True ,
189
+ autorange = "reversed" if self . _reverse_y else True ,
131
190
)
132
- # Wrap the figure in a graph
133
- # todo: or should the user provide this?
134
- self .graph = Graph (
191
+
192
+ # Create the graph (graph is a Dash component wrapping a Plotly figure)
193
+ self ._graph = Graph (
135
194
id = self ._subid ("graph" ),
136
195
figure = fig ,
137
196
config = {"scrollZoom" : True },
138
197
)
198
+
139
199
# Create a slider object that the user can put in the layout (or not)
140
- self .slider = Slider (
200
+ self ._slider = Slider (
141
201
id = self ._subid ("slider" ),
142
202
min = 0 ,
143
203
max = info ["size" ][2 ] - 1 ,
@@ -146,45 +206,30 @@ def __init__(
146
206
tooltip = {"always_visible" : False , "placement" : "left" },
147
207
updatemode = "drag" ,
148
208
)
209
+
149
210
# Create the stores that we need (these must be present in the layout)
150
- self .stores = [
151
- Store (id = self ._subid ("info" ), data = info ),
152
- Store (id = self ._subid ("index" ), data = volume .shape [self ._axis ] // 2 ),
153
- Store (id = self ._subid ("position" ), data = 0 ),
154
- Store (id = self ._subid ("_requested-slice-index" ), data = 0 ),
155
- Store (id = self ._subid ("_slice-data" ), data = "" ),
156
- Store (id = self ._subid ("_slice-data-lowres" ), data = thumbnails ),
157
- Store (id = self ._subid ("_indicators" ), data = []),
211
+ self ._info = Store (id = self ._subid ("info" ), data = info )
212
+ self ._position = Store (id = self ._subid ("position" , True ), data = 0 )
213
+ self ._requested_index = Store (id = self ._subid ("req-index" ), data = 0 )
214
+ self ._request_data = Store (id = self ._subid ("req-data" ), data = "" )
215
+ self ._lowres_data = Store (id = self ._subid ("lowres-data" ), data = thumbnails )
216
+ self ._indicators = Store (id = self ._subid ("indicators" ), data = [])
217
+ self ._stores = [
218
+ self ._info ,
219
+ self ._position ,
220
+ self ._requested_index ,
221
+ self ._request_data ,
222
+ self ._lowres_data ,
223
+ self ._indicators ,
158
224
]
159
225
160
- self ._create_server_callbacks ()
161
- self ._create_client_callbacks ()
162
-
163
- def _subid (self , name ):
164
- """Given a subid, get the full id including the slicer's prefix."""
165
- # return self.context_id + "-" + name
166
- # todo: is there a penalty for using a dict-id vs a string-id?
167
- return {
168
- "context" : self .context_id ,
169
- "scene" : self .scene_id ,
170
- "axis" : self ._axis ,
171
- "name" : name ,
172
- }
173
-
174
- def _slice (self , index ):
175
- """Sample a slice from the volume."""
176
- indices = [slice (None ), slice (None ), slice (None )]
177
- indices [self ._axis ] = index
178
- im = self ._volume [tuple (indices )]
179
- return (im .astype (np .float32 ) * (255 / im .max ())).astype (np .uint8 )
180
-
181
226
def _create_server_callbacks (self ):
182
227
"""Create the callbacks that run server-side."""
183
228
app = self ._app
184
229
185
230
@app .callback (
186
- Output (self ._subid ( "_slice-data" ) , "data" ),
187
- [Input (self ._subid ( "_requested-slice-index" ) , "data" )],
231
+ Output (self ._request_data . id , "data" ),
232
+ [Input (self ._requested_index . id , "data" )],
188
233
)
189
234
def upload_requested_slice (slice_index ):
190
235
slice = self ._slice (slice_index )
@@ -194,25 +239,15 @@ def _create_client_callbacks(self):
194
239
"""Create the callbacks that run client-side."""
195
240
app = self ._app
196
241
197
- app .clientside_callback (
198
- """
199
- function handle_slider_move(index) {
200
- return index;
201
- }
202
- """ ,
203
- Output (self ._subid ("index" ), "data" ),
204
- [Input (self ._subid ("slider" ), "value" )],
205
- )
206
-
207
242
app .clientside_callback (
208
243
"""
209
244
function update_position(index, info) {
210
245
return info.origin[2] + index * info.spacing[2];
211
246
}
212
247
""" ,
213
- Output (self ._subid ( "position" ) , "data" ),
214
- [Input (self ._subid ( "index" ) , "data " )],
215
- [State (self ._subid ( "info" ) , "data" )],
248
+ Output (self ._position . id , "data" ),
249
+ [Input (self .slider . id , "value " )],
250
+ [State (self ._info . id , "data" )],
216
251
)
217
252
218
253
app .clientside_callback (
@@ -228,21 +263,12 @@ def _create_client_callbacks(self):
228
263
}
229
264
}
230
265
""" .replace (
231
- "{{ID}}" , self .context_id
266
+ "{{ID}}" , self ._context_id
232
267
),
233
- Output (self ._subid ( "_requested-slice-index" ) , "data" ),
234
- [Input (self ._subid ( "index" ) , "data " )],
268
+ Output (self ._requested_index . id , "data" ),
269
+ [Input (self .slider . id , "value " )],
235
270
)
236
271
237
- # app.clientside_callback("""
238
- # function update_slider_pos(index) {
239
- # return index;
240
- # }
241
- # """,
242
- # [Output("index", "data")],
243
- # [State("slider", "value")],
244
- # )
245
-
246
272
app .clientside_callback (
247
273
"""
248
274
function handle_incoming_slice(index, index_and_data, indicators, ori_figure, lowres, info) {
@@ -282,18 +308,18 @@ def _create_client_callbacks(self):
282
308
return figure;
283
309
}
284
310
""" .replace (
285
- "{{ID}}" , self .context_id
311
+ "{{ID}}" , self ._context_id
286
312
),
287
- Output (self ._subid ( " graph" ) , "figure" ),
313
+ Output (self .graph . id , "figure" ),
288
314
[
289
- Input (self ._subid ( "index" ) , "data " ),
290
- Input (self ._subid ( "_slice-data" ) , "data" ),
291
- Input (self ._subid ( " _indicators" ) , "data" ),
315
+ Input (self .slider . id , "value " ),
316
+ Input (self ._request_data . id , "data" ),
317
+ Input (self ._indicators . id , "data" ),
292
318
],
293
319
[
294
- State (self ._subid ( " graph" ) , "figure" ),
295
- State (self ._subid ( "_slice-data-lowres" ) , "data" ),
296
- State (self ._subid ( "info" ) , "data" ),
320
+ State (self .graph . id , "figure" ),
321
+ State (self ._lowres_data . id , "data" ),
322
+ State (self ._info . id , "data" ),
297
323
],
298
324
)
299
325
@@ -334,11 +360,11 @@ def _create_client_callbacks(self):
334
360
};
335
361
}
336
362
""" ,
337
- Output (self ._subid ( " _indicators" ) , "data" ),
363
+ Output (self ._indicators . id , "data" ),
338
364
[
339
365
Input (
340
366
{
341
- "scene" : self .scene_id ,
367
+ "scene" : self ._scene_id ,
342
368
"context" : ALL ,
343
369
"name" : "position" ,
344
370
"axis" : axis ,
@@ -348,7 +374,7 @@ def _create_client_callbacks(self):
348
374
for axis in axii
349
375
],
350
376
[
351
- State (self ._subid ( "info" ) , "data" ),
352
- State (self ._subid ( " _indicators" ) , "data" ),
377
+ State (self ._info . id , "data" ),
378
+ State (self ._indicators . id , "data" ),
353
379
],
354
380
)
0 commit comments