Skip to content

Commit 92e000f

Browse files
authored
Tweaking the API and cleaning up (#13)
* Fix install * Remove unneeded store - the slider.value is the reference index * rename example ans show more config in this example * docs * limit the use of id generation to instantiating of components * Make most id's str except the one we need to be dict. * add tests (and a little refactoring) * refactor a bit, and use actual properties for public attributes * bit of cleanup
1 parent 77961e0 commit 92e000f

File tree

7 files changed

+269
-118
lines changed

7 files changed

+269
-118
lines changed

dash_slicer/slicer.py

Lines changed: 130 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from dash.dependencies import Input, Output, State, ALL
55
from dash_core_components import Graph, Slider, Store
66

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
88

99

1010
class VolumeSlicer:
@@ -15,34 +15,29 @@ class VolumeSlicer:
1515
volume (ndarray): the 3D numpy array to slice through. The dimensions
1616
are assumed to be in zyx order. If this is not the case, you can
1717
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".
2121
origin (tuple of floats): The offset for each dimension (zyx).
2222
axis (int): the dimension to slice in. Default 0.
2323
reverse_y (bool): Whether to reverse the y-axis, so that the origin of
2424
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.)
2626
scene_id (str): the scene that this slicer is part of. Slicers
2727
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)``.
2929
3030
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:
3233
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.
3740
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
4641
"""
4742

4843
_global_slicer_counter = 0
@@ -58,10 +53,11 @@ def __init__(
5853
reverse_y=True,
5954
scene_id=None
6055
):
61-
# todo: also implement xyz dim order?
56+
6257
if not isinstance(app, Dash):
6358
raise TypeError("Expect first arg to be a Dash app.")
6459
self._app = app
60+
6561
# Check and store volume
6662
if not (isinstance(volume, np.ndarray) and volume.ndim == 3):
6763
raise TypeError("Expected volume to be a 3D numpy array")
@@ -70,33 +66,97 @@ def __init__(
7066
spacing = float(spacing[0]), float(spacing[1]), float(spacing[2])
7167
origin = (0, 0, 0) if origin is None else origin
7268
origin = float(origin[0]), float(origin[1]), float(origin[2])
69+
7370
# Check and store axis
7471
if not (isinstance(axis, int) and 0 <= axis <= 2):
7572
raise ValueError("The given axis must be 0, 1, or 2.")
7673
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
7877
if scene_id is None:
7978
scene_id = "volume_" + hex(id(volume))[2:]
8079
elif not isinstance(scene_id, str):
8180
raise TypeError("scene_id must be a string")
82-
self.scene_id = scene_id
81+
self._scene_id = scene_id
82+
8383
# Get unique id scoped to this slicer object
8484
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)
8686

87-
# Prepare slice info
88-
info = {
87+
# Prepare slice info that we use at the client side
88+
self._slice_info = {
8989
"shape": tuple(volume.shape),
9090
"axis": self._axis,
9191
"size": shape3d_to_size2d(volume.shape, axis),
9292
"origin": shape3d_to_size2d(origin, axis),
9393
"spacing": shape3d_to_size2d(spacing, axis),
9494
}
9595

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+
96158
# 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))
100160
thumbnails = [
101161
img_array_to_uri(self._slice(i), thumbnail_size)
102162
for i in range(info["size"][2])
@@ -109,35 +169,35 @@ def __init__(
109169
source="", dx=1, dy=1, hovertemplate="(%{x}, %{y})<extra></extra>"
110170
)
111171
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
113174
self._fig = fig = Figure(data=[image_trace, scatter_trace])
114175
fig.update_layout(
115176
template=None,
116177
margin=dict(l=0, r=0, b=0, t=0, pad=4),
117178
)
118179
fig.update_xaxes(
119-
# range=(0, slice_size[0]),
120180
showgrid=False,
121181
showticklabels=False,
122182
zeroline=False,
123183
)
124184
fig.update_yaxes(
125-
# range=(slice_size[1], 0), # todo: allow flipping x or y
126185
showgrid=False,
127186
scaleanchor="x",
128187
showticklabels=False,
129188
zeroline=False,
130-
autorange="reversed" if reverse_y else True,
189+
autorange="reversed" if self._reverse_y else True,
131190
)
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(
135194
id=self._subid("graph"),
136195
figure=fig,
137196
config={"scrollZoom": True},
138197
)
198+
139199
# Create a slider object that the user can put in the layout (or not)
140-
self.slider = Slider(
200+
self._slider = Slider(
141201
id=self._subid("slider"),
142202
min=0,
143203
max=info["size"][2] - 1,
@@ -146,45 +206,30 @@ def __init__(
146206
tooltip={"always_visible": False, "placement": "left"},
147207
updatemode="drag",
148208
)
209+
149210
# 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,
158224
]
159225

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-
181226
def _create_server_callbacks(self):
182227
"""Create the callbacks that run server-side."""
183228
app = self._app
184229

185230
@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")],
188233
)
189234
def upload_requested_slice(slice_index):
190235
slice = self._slice(slice_index)
@@ -194,25 +239,15 @@ def _create_client_callbacks(self):
194239
"""Create the callbacks that run client-side."""
195240
app = self._app
196241

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-
207242
app.clientside_callback(
208243
"""
209244
function update_position(index, info) {
210245
return info.origin[2] + index * info.spacing[2];
211246
}
212247
""",
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")],
216251
)
217252

218253
app.clientside_callback(
@@ -228,21 +263,12 @@ def _create_client_callbacks(self):
228263
}
229264
}
230265
""".replace(
231-
"{{ID}}", self.context_id
266+
"{{ID}}", self._context_id
232267
),
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")],
235270
)
236271

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-
246272
app.clientside_callback(
247273
"""
248274
function handle_incoming_slice(index, index_and_data, indicators, ori_figure, lowres, info) {
@@ -282,18 +308,18 @@ def _create_client_callbacks(self):
282308
return figure;
283309
}
284310
""".replace(
285-
"{{ID}}", self.context_id
311+
"{{ID}}", self._context_id
286312
),
287-
Output(self._subid("graph"), "figure"),
313+
Output(self.graph.id, "figure"),
288314
[
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"),
292318
],
293319
[
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"),
297323
],
298324
)
299325

@@ -334,11 +360,11 @@ def _create_client_callbacks(self):
334360
};
335361
}
336362
""",
337-
Output(self._subid("_indicators"), "data"),
363+
Output(self._indicators.id, "data"),
338364
[
339365
Input(
340366
{
341-
"scene": self.scene_id,
367+
"scene": self._scene_id,
342368
"context": ALL,
343369
"name": "position",
344370
"axis": axis,
@@ -348,7 +374,7 @@ def _create_client_callbacks(self):
348374
for axis in axii
349375
],
350376
[
351-
State(self._subid("info"), "data"),
352-
State(self._subid("_indicators"), "data"),
377+
State(self._info.id, "data"),
378+
State(self._indicators.id, "data"),
353379
],
354380
)

0 commit comments

Comments
 (0)