From 75d8797e0c2a67ed7ca9b614ab867b077cb890e7 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 9 Dec 2020 11:41:04 +0100 Subject: [PATCH 1/4] fix detecting layout changes, and use different timeout for rate-limiting depending on source --- dash_slicer/slicer.py | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/dash_slicer/slicer.py b/dash_slicer/slicer.py index 2002242..e5e69ad 100644 --- a/dash_slicer/slicer.py +++ b/dash_slicer/slicer.py @@ -490,16 +490,20 @@ def _create_client_callbacks(self): app.clientside_callback( """ - function update_index_rate_limiting(index, relayoutData, n_intervals, interval, info, figure) { + function update_index_rate_limiting(index, relayoutData, n_intervals, info, figure) { if (!window._slicer_{{ID}}) window._slicer_{{ID}} = {}; let private_state = window._slicer_{{ID}}; let now = window.performance.now(); // Get whether the slider was moved - let slider_was_moved = false; + let slider_value_changed = false; + let graph_layout_changed = false; + let timer_ticked = false; for (let trigger of dash_clientside.callback_context.triggered) { - if (trigger.prop_id.indexOf('slider') >= 0) slider_was_moved = true; + if (trigger.prop_id.indexOf('slider') >= 0) slider_value_changed = true; + if (trigger.prop_id.indexOf('graph') >= 0) graph_layout_changed = true; + if (trigger.prop_id.indexOf('timer') >= 0) timer_ticked = true; } // Calculate view range based on the volume @@ -513,17 +517,8 @@ def _create_client_callbacks(self): ]; // Get view range from the figure. We make range[0] < range[1] - let range_was_changed = false; let xrangeFig = figure.layout.xaxis.range let yrangeFig = figure.layout.yaxis.range; - if (relayoutData && relayoutData.xaxis && relayoutData.xaxis.range) { - xrangeFig = relayoutData.xaxis.range; - range_was_changed = true; - } - if (relayoutData && relayoutData.yaxis && 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])]; @@ -549,18 +544,25 @@ 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 || range_was_changed) { + + if (slider_value_changed) { + private_state.new_time = now; + private_state.timeout = 200; + } else if (graph_layout_changed) { private_state.new_time = now; + private_state.timeout = 400; // need longer timeout for smooth scroll zoom } else if (!n_intervals) { private_state.new_time = now; + private_state.timeout = 100; } - // We can either update the rate-limited index interval ms after - // the real index changed, or interval ms after it stopped + // We can either update the rate-limited index timeout ms after + // the real index changed, or timeout ms after it stopped // 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 (now - private_state.new_time >= interval) { + // experience, and the timeout can be set much lower. + if (private_state.timeout && timer_ticked && now - private_state.new_time >= private_state.timeout) { + private_state.timeout = 0; disable_timer = true; new_state = { index: index, @@ -574,7 +576,6 @@ def _create_client_callbacks(self): if (index != private_state.index) { private_state.index = index; new_state.index_changed = true; - console.log('requesting slice ' + index); } } @@ -593,7 +594,6 @@ def _create_client_callbacks(self): Input(self._timer.id, "n_intervals"), ], [ - State(self._timer.id, "interval"), State(self._info.id, "data"), State(self._graph.id, "figure"), ], From 48668c0be3e5bc83faa01a6252b5cbe41f35956b Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 9 Dec 2020 11:41:31 +0100 Subject: [PATCH 2/4] Fix that initialization of figures was sometimes weird --- dash_slicer/slicer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dash_slicer/slicer.py b/dash_slicer/slicer.py index e5e69ad..56bed56 100644 --- a/dash_slicer/slicer.py +++ b/dash_slicer/slicer.py @@ -737,6 +737,7 @@ def _create_client_callbacks(self): State(self._info.id, "data"), State(self._state.id, "data"), ], + prevent_initial_call=True, ) # ---------------------------------------------------------------------- From a303d0082893b400e6f5dba37a315f03bd6ba351 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 9 Dec 2020 14:05:10 +0100 Subject: [PATCH 3/4] produce more reasonable sized thumbnails for elongated data --- dash_slicer/slicer.py | 31 ++++++++++++++++++------------- dash_slicer/utils.py | 20 +++++++++++++++----- tests/test_utils.py | 11 ++++++----- 3 files changed, 39 insertions(+), 23 deletions(-) diff --git a/dash_slicer/slicer.py b/dash_slicer/slicer.py index 56bed56..51da1a1 100644 --- a/dash_slicer/slicer.py +++ b/dash_slicer/slicer.py @@ -35,7 +35,7 @@ class VolumeSlicer: 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". - thumbnail (int or bool): linear size of low-resolution data to be + thumbnail (int or bool): preferred size of low-resolution data to be uploaded to the client. If ``False``, the full-resolution data are uploaded client-side. If ``True`` (default), a default value of 32 is used. @@ -108,12 +108,18 @@ def __init__( # Check and store thumbnail if not (isinstance(thumbnail, (int, bool))): raise ValueError("thumbnail must be a boolean or an integer.") - # No thumbnail if thumbnail size is larger than image size - if isinstance(thumbnail, int) and thumbnail > np.max(volume.shape): - thumbnail = False - if thumbnail is True: - thumbnail = 32 # default size - self._thumbnail = thumbnail + if thumbnail is False: + self._thumbnail = False + elif thumbnail is None or thumbnail is True: + self._thumbnail = 32 # default size + else: + thumbnail = int(thumbnail) + if thumbnail >= np.max(volume.shape[:3]): + self._thumbnail = False # dont go larger than image size + elif thumbnail <= 0: + self._thumbnail = False # consider 0 and -1 the same as False + else: + self._thumbnail = thumbnail # Check and store scene id, and generate if scene_id is None: @@ -299,15 +305,14 @@ def _create_dash_components(self): """Create the graph, slider, figure, etc.""" info = self._slice_info - # Prep low-res slices - if self._thumbnail is False: + # Prep low-res slices. The get_thumbnail_size() is a bit like + # a simulation to get the low-res size. + if not self._thumbnail: thumbnail_size = None info["lowres_size"] = info["size"] else: - thumbnail_size = get_thumbnail_size( - info["size"][:2], (self._thumbnail, self._thumbnail) - ) - info["lowres_size"] = thumbnail_size + thumbnail_size = self._thumbnail + info["lowres_size"] = get_thumbnail_size(info["size"][:2], thumbnail_size) thumbnails = [ img_array_to_uri(self._slice(i), thumbnail_size) for i in range(info["size"][2]) diff --git a/dash_slicer/utils.py b/dash_slicer/utils.py index 1b00513..0bd026d 100644 --- a/dash_slicer/utils.py +++ b/dash_slicer/utils.py @@ -18,15 +18,23 @@ def img_as_ubyte(img): return img.astype(np.uint8) -def img_array_to_uri(img_array, new_size=None): +def _thumbnail_size_from_scalar(image_size, ref_size): + if image_size[0] > image_size[1]: + return int(ref_size * image_size[0] / image_size[1]), ref_size + else: + return ref_size, int(ref_size * image_size[1] / image_size[0]) + + +def img_array_to_uri(img_array, ref_size=None): """Convert the given image (numpy array) into a base64-encoded PNG.""" img_array = img_as_ubyte(img_array) # todo: leverage this Plotly util once it becomes part of the public API (also drops the Pillow dependency) # from plotly.express._imshow import _array_to_b64str # return _array_to_b64str(img_array) img_pil = PIL.Image.fromarray(img_array) - if new_size: - img_pil.thumbnail(new_size) + if ref_size: + size = img_array.shape[1], img_array.shape[0] + img_pil.thumbnail(_thumbnail_size_from_scalar(size, ref_size)) # The below was taken from plotly.utils.ImageUriValidator.pil_image_to_uri() f = io.BytesIO() img_pil.save(f, format="PNG") @@ -34,13 +42,15 @@ def img_array_to_uri(img_array, new_size=None): return "data:image/png;base64," + base64_str -def get_thumbnail_size(size, new_size): +def get_thumbnail_size(size, ref_size): """Given an image size (w, h), and a preferred smaller size, get the actual size if we let Pillow downscale it. """ + # Note that if you call thumbnail() to get the resulting size, then call + # thumbnail() again with that size, the result may be yet another size. img_array = np.zeros(list(reversed(size)), np.uint8) img_pil = PIL.Image.fromarray(img_array) - img_pil.thumbnail(new_size) + img_pil.thumbnail(_thumbnail_size_from_scalar(size, ref_size)) return img_pil.size diff --git a/tests/test_utils.py b/tests/test_utils.py index 1bda1f9..c4f30a2 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -31,8 +31,8 @@ def test_img_array_to_uri(): im = np.random.uniform(0, 255, (100, 100)).astype(np.uint8) r1 = img_array_to_uri(im) - r2 = img_array_to_uri(im, (32, 32)) - r3 = img_array_to_uri(im, (8, 8)) + r2 = img_array_to_uri(im, 32) + r3 = img_array_to_uri(im, 8) for r in (r1, r2, r3): assert isinstance(r, str) @@ -43,9 +43,10 @@ def test_img_array_to_uri(): def test_get_thumbnail_size(): - assert get_thumbnail_size((100, 100), (16, 16)) == (16, 16) - assert get_thumbnail_size((50, 100), (16, 16)) == (8, 16) - assert get_thumbnail_size((100, 100), (8, 16)) == (8, 8) + assert get_thumbnail_size((100, 100), 16) == (16, 16) + assert get_thumbnail_size((50, 100), 16) == (16, 32) + assert get_thumbnail_size((100, 100), 8) == (8, 8) + assert get_thumbnail_size((100, 50), 8) == (16, 8) def test_shape3d_to_size2d(): From 630b0190b9a4ec4e2d01d85ac3cfbd430b8a6810 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 9 Dec 2020 14:12:55 +0100 Subject: [PATCH 4/4] rename lowres -> thumbnail --- dash_slicer/slicer.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/dash_slicer/slicer.py b/dash_slicer/slicer.py index 51da1a1..de12b03 100644 --- a/dash_slicer/slicer.py +++ b/dash_slicer/slicer.py @@ -309,10 +309,12 @@ def _create_dash_components(self): # a simulation to get the low-res size. if not self._thumbnail: thumbnail_size = None - info["lowres_size"] = info["size"] + info["thumbnail_size"] = info["size"] else: thumbnail_size = self._thumbnail - info["lowres_size"] = get_thumbnail_size(info["size"][:2], thumbnail_size) + info["thumbnail_size"] = get_thumbnail_size( + info["size"][:2], thumbnail_size + ) thumbnails = [ img_array_to_uri(self._slice(i), thumbnail_size) for i in range(info["size"][2]) @@ -366,8 +368,8 @@ def _create_dash_components(self): # A dict of static info for this slicer self._info = Store(id=self._subid("info"), data=info) - # A list of low-res slices (encoded as base64-png) - self._lowres_data = Store(id=self._subid("lowres"), data=thumbnails) + # A list of low-res slices, or the full-res data (encoded as base64-png) + self._thumbs_data = Store(id=self._subid("thumbs"), data=thumbnails) # A list of mask slices (encoded as base64-png or null) self._overlay_data = Store(id=self._subid("overlay"), data=[]) @@ -394,7 +396,7 @@ def _create_dash_components(self): self._stores = [ self._info, - self._lowres_data, + self._thumbs_data, self._overlay_data, self._server_data, self._img_traces, @@ -609,7 +611,7 @@ def _create_client_callbacks(self): app.clientside_callback( """ - function update_image_traces(index, server_data, overlays, lowres, info, current_traces) { + function update_image_traces(index, server_data, overlays, thumbnails, info, current_traces) { // Prepare traces let slice_trace = { @@ -626,16 +628,16 @@ def _create_client_callbacks(self): overlay_trace.hovertemplate = ''; let new_traces = [slice_trace, overlay_trace]; - // Use full data, or use lowres + // Use full data, or use thumbnails if (index == server_data.index) { slice_trace.source = server_data.slice; } else { - slice_trace.source = lowres[index]; + slice_trace.source = thumbnails[index]; // Scale the image to take the exact same space as the full-res // version. Note that depending on how the low-res data is // 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.dx *= info.size[0] / info.thumbnail_size[0]; + slice_trace.dy *= info.size[1] / info.thumbnail_size[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]; } @@ -659,7 +661,7 @@ def _create_client_callbacks(self): Input(self._overlay_data.id, "data"), ], [ - State(self._lowres_data.id, "data"), + State(self._thumbs_data.id, "data"), State(self._info.id, "data"), State(self._img_traces.id, "data"), ],