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
7
+ from .utils import img_array_to_uri , get_thumbnail_size_from_shape , shape3d_to_size2d
8
8
9
9
10
10
class DashVolumeSlicer :
11
11
"""A slicer to show 3D image data in Dash.
12
12
13
13
Parameters:
14
14
app (dash.Dash): the Dash application instance.
15
- volume (ndarray): the 3D numpy array to slice through.
15
+ volume (ndarray): the 3D numpy array to slice through. The dimensions
16
+ are assumed to be in zyx order. If this is not the case, you can
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".
21
+ origin (tuple of floats): The offset for each dimension (zyx).
16
22
axis (int): the dimension to slice in. Default 0.
23
+ reverse_y (bool): Whether to reverse the y-axis, so that the origin of
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.)
17
26
scene_id (str): the scene that this slicer is part of. Slicers
18
27
that have the same scene-id show each-other's positions with
19
28
line indicators. By default this is a hash of ``id(volume)``.
@@ -38,14 +47,29 @@ class DashVolumeSlicer:
38
47
39
48
_global_slicer_counter = 0
40
49
41
- def __init__ (self , app , volume , axis = 0 , scene_id = None ):
50
+ def __init__ (
51
+ self ,
52
+ app ,
53
+ volume ,
54
+ * ,
55
+ spacing = None ,
56
+ origin = None ,
57
+ axis = 0 ,
58
+ reverse_y = True ,
59
+ scene_id = None
60
+ ):
61
+ # todo: also implement xyz dim order?
42
62
if not isinstance (app , Dash ):
43
63
raise TypeError ("Expect first arg to be a Dash app." )
44
64
self ._app = app
45
65
# Check and store volume
46
66
if not (isinstance (volume , np .ndarray ) and volume .ndim == 3 ):
47
67
raise TypeError ("Expected volume to be a 3D numpy array" )
48
68
self ._volume = volume
69
+ spacing = (1 , 1 , 1 ) if spacing is None else spacing
70
+ spacing = float (spacing [0 ]), float (spacing [1 ]), float (spacing [2 ])
71
+ origin = (0 , 0 , 0 ) if origin is None else origin
72
+ origin = float (origin [0 ]), float (origin [1 ]), float (origin [2 ])
49
73
# Check and store axis
50
74
if not (isinstance (axis , int ) and 0 <= axis <= 2 ):
51
75
raise ValueError ("The given axis must be 0, 1, or 2." )
@@ -60,20 +84,26 @@ def __init__(self, app, volume, axis=0, scene_id=None):
60
84
DashVolumeSlicer ._global_slicer_counter += 1
61
85
self .context_id = "slicer_" + str (DashVolumeSlicer ._global_slicer_counter )
62
86
63
- # Get the slice size (width, height), and max index
64
- arr_shape = list (volume .shape )
65
- arr_shape .pop (self ._axis )
66
- self ._slice_size = tuple (reversed (arr_shape ))
67
- self ._max_index = self ._volume .shape [self ._axis ] - 1
87
+ # Prepare slice info
88
+ info = {
89
+ "shape" : tuple (volume .shape ),
90
+ "axis" : self ._axis ,
91
+ "size" : shape3d_to_size2d (volume .shape , axis ),
92
+ "origin" : shape3d_to_size2d (origin , axis ),
93
+ "spacing" : shape3d_to_size2d (spacing , axis ),
94
+ }
68
95
69
96
# Prep low-res slices
70
- thumbnail_size = get_thumbnail_size_from_shape (arr_shape , 32 )
97
+ thumbnail_size = get_thumbnail_size_from_shape (
98
+ (info ["size" ][1 ], info ["size" ][0 ]), 32
99
+ )
71
100
thumbnails = [
72
101
img_array_to_uri (self ._slice (i ), thumbnail_size )
73
- for i in range (self . _max_index + 1 )
102
+ for i in range (info [ "size" ][ 2 ] )
74
103
]
104
+ info ["lowres_size" ] = thumbnail_size
75
105
76
- # Create a placeholder trace
106
+ # Create traces
77
107
# todo: can add "%{z[0]}", but that would be the scaled value ...
78
108
image_trace = Image (
79
109
source = "" , dx = 1 , dy = 1 , hovertemplate = "(%{x}, %{y})<extra></extra>"
@@ -97,6 +127,7 @@ def __init__(self, app, volume, axis=0, scene_id=None):
97
127
scaleanchor = "x" ,
98
128
showticklabels = False ,
99
129
zeroline = False ,
130
+ autorange = "reversed" if reverse_y else True ,
100
131
)
101
132
# Wrap the figure in a graph
102
133
# todo: or should the user provide this?
@@ -106,22 +137,20 @@ def __init__(self, app, volume, axis=0, scene_id=None):
106
137
config = {"scrollZoom" : True },
107
138
)
108
139
# Create a slider object that the user can put in the layout (or not)
109
- # todo: use tooltip to show current value?
110
140
self .slider = Slider (
111
141
id = self ._subid ("slider" ),
112
142
min = 0 ,
113
- max = self . _max_index ,
143
+ max = info [ "size" ][ 2 ] - 1 ,
114
144
step = 1 ,
115
- value = self . _max_index // 2 ,
145
+ value = info [ "size" ][ 2 ] // 2 ,
116
146
tooltip = {"always_visible" : False , "placement" : "left" },
117
147
updatemode = "drag" ,
118
148
)
119
149
# Create the stores that we need (these must be present in the layout)
120
150
self .stores = [
121
- Store (
122
- id = self ._subid ("_slice-size" ), data = self ._slice_size + thumbnail_size
123
- ),
151
+ Store (id = self ._subid ("info" ), data = info ),
124
152
Store (id = self ._subid ("index" ), data = volume .shape [self ._axis ] // 2 ),
153
+ Store (id = self ._subid ("position" ), data = 0 ),
125
154
Store (id = self ._subid ("_requested-slice-index" ), data = 0 ),
126
155
Store (id = self ._subid ("_slice-data" ), data = "" ),
127
156
Store (id = self ._subid ("_slice-data-lowres" ), data = thumbnails ),
@@ -175,6 +204,17 @@ def _create_client_callbacks(self):
175
204
[Input (self ._subid ("slider" ), "value" )],
176
205
)
177
206
207
+ app .clientside_callback (
208
+ """
209
+ function update_position(index, info) {
210
+ return info.origin[2] + index * info.spacing[2];
211
+ }
212
+ """ ,
213
+ Output (self ._subid ("position" ), "data" ),
214
+ [Input (self ._subid ("index" ), "data" )],
215
+ [State (self ._subid ("info" ), "data" )],
216
+ )
217
+
178
218
app .clientside_callback (
179
219
"""
180
220
function handle_slice_index(index) {
@@ -205,7 +245,7 @@ def _create_client_callbacks(self):
205
245
206
246
app .clientside_callback (
207
247
"""
208
- function handle_incoming_slice(index, index_and_data, indicators, ori_figure, lowres, slice_size ) {
248
+ function handle_incoming_slice(index, index_and_data, indicators, ori_figure, lowres, info ) {
209
249
let new_index = index_and_data[0];
210
250
let new_data = index_and_data[1];
211
251
// Store data in cache
@@ -214,18 +254,18 @@ def _create_client_callbacks(self):
214
254
slice_cache[new_index] = new_data;
215
255
// Get the data we need *now*
216
256
let data = slice_cache[index];
217
- let x0 = 0, y0 = 0, dx = 1, dy = 1;
257
+ let x0 = info.origin[0], y0 = info.origin[1];
258
+ let dx = info.spacing[0], dy = info.spacing[1];
218
259
//slice_cache[new_index] = undefined; // todo: disabled cache for now!
219
260
// Maybe we do not need an update
220
261
if (!data) {
221
262
data = lowres[index];
222
263
// Scale the image to take the exact same space as the full-res
223
264
// 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;
265
+ dx *= info.size[0] / info.lowres_size[0];
266
+ dy *= info.size[1] / info.lowres_size[1];
267
+ x0 += 0.5 * dx - 0.5 * info.spacing[0];
268
+ y0 += 0.5 * dy - 0.5 * info.spacing[1];
229
269
}
230
270
if (data == ori_figure.data[0].source && indicators.version == ori_figure.data[1].version) {
231
271
return window.dash_clientside.no_update;
@@ -253,7 +293,7 @@ def _create_client_callbacks(self):
253
293
[
254
294
State (self ._subid ("graph" ), "figure" ),
255
295
State (self ._subid ("_slice-data-lowres" ), "data" ),
256
- State (self ._subid ("_slice-size " ), "data" ),
296
+ State (self ._subid ("info " ), "data" ),
257
297
],
258
298
)
259
299
@@ -266,18 +306,22 @@ def _create_client_callbacks(self):
266
306
# * match any of the selected axii
267
307
app .clientside_callback (
268
308
"""
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;
309
+ function handle_indicator(positions1, positions2, info, current) {
310
+ let x0 = info.origin[0], y0 = info.origin[1];
311
+ let x1 = x0 + info.size[0] * info.spacing[0], y1 = y0 + info.size[1] * info.spacing[1];
312
+ x0 = x0 - info.spacing[0], y0 = y0 - info.spacing[1];
313
+ let d = ((x1 - x0) + (y1 - y0)) * 0.5 * 0.05;
272
314
let version = (current.version || 0) + 1;
273
315
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]);
316
+ for (let pos of positions1) {
317
+ // x relative to our slice, y in scene-coords
318
+ x.push(...[x0 - d, x0, null, x1, x1 + d, null]);
319
+ y.push(...[pos, pos, pos, pos, pos, pos]);
277
320
}
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]);
321
+ for (let pos of positions2) {
322
+ // x in scene-coords, y relative to our slice
323
+ x.push(...[pos, pos, pos, pos, pos, pos]);
324
+ y.push(...[y0 - d, y0, null, y1, y1 + d, null]);
281
325
}
282
326
return {
283
327
type: 'scatter',
@@ -296,15 +340,15 @@ def _create_client_callbacks(self):
296
340
{
297
341
"scene" : self .scene_id ,
298
342
"context" : ALL ,
299
- "name" : "index " ,
343
+ "name" : "position " ,
300
344
"axis" : axis ,
301
345
},
302
346
"data" ,
303
347
)
304
348
for axis in axii
305
349
],
306
350
[
307
- State (self ._subid ("_slice-size " ), "data" ),
351
+ State (self ._subid ("info " ), "data" ),
308
352
State (self ._subid ("_indicators" ), "data" ),
309
353
],
310
354
)
0 commit comments