Repository: napari/napari

Issue:
Contrast limits range with dask data
## 🚀 Feature
Right now, if a `dask` array is passed to `Image`, the contrast limits range will be set to `0, 1` (same for anything that's not `np.ndarray`). This is unfortunately really bad with some of my data (which can sometimes be between `-0.0001` and +`0.0001`,  or similarly tiny ranges). Now, I know that I can manually set these limits, but what if I don't know this?

At a first glance, it seems that the code already has some [logic to handle very big data](https://github.com/napari/napari/blob/d0b6b3f17298578f157188b50b2660e533c10d97/napari/layers/utils/layer_utils.py#L100-L127). Could we implement a similar solution which uses just a few chunks of the dask array?



Discussion:
i think we used to guess, but then we changed the behaviour, cc @jni @tlambert03. There are some closed issues that discuss this for sure
> Right now, if a dask array is passed to Image, the contrast limits range will be set to 0, 1 (same for anything that's not np.ndarray).

it _should_ schedule an autoscale to happen [right here](https://github.com/napari/napari/blob/6ba0845de10dcd63b2583dcc719454358b0f5b2a/napari/layers/image/image.py#L311), which should execute [right here](https://github.com/napari/napari/blob/6ba0845de10dcd63b2583dcc719454358b0f5b2a/napari/layers/image/image.py#L681) using the data from the first view chunk ... can you maybe try to confirm that that code is indeed not executing for you, and see why not?
Actually, after trying again with different data, everything seemed to work fine. I need to get back to the office and try with that first data I had to make sure it wasn't somwthing wrong with the data itself.
Ok, took a look at this (at last :laughing: ) and the issue is cause by the fact that the autoscale on `set_view_slice` only happens for `contrast limits`, and not `contrast_limits_range`. It's easy enough to replace this, but I'm not 100% sure we *do* want to reset the contrast limits *range* every time we change slice, cause this could cause us to lose the contrast picked in a different slice. One thing we could do is pick the biggest range possible:

```
new_range = [min(old_low, new_low), max(old_high, new_high)]
```

This would ensure we never lose previously set `clim`.
Here's a minimal example:

```py
import napari
import numpy as np
import dask.array as da
np_arr = np.array([[0.000001, -0.0002], [0, 0.0000004]])
da_arr = da.array(np_arr)
v = napari.Viewer()
l_np = v.add_image(np_arr)
l_da = v.add_image(da_arr)
print(l_np.contrast_limits_range)
print(l_da.contrast_limits_range)
```

Resolving patch:
diff --git a/napari/_qt/layer_controls/qt_image_controls_base.py b/napari/_qt/layer_controls/qt_image_controls_base.py
--- a/napari/_qt/layer_controls/qt_image_controls_base.py
+++ b/napari/_qt/layer_controls/qt_image_controls_base.py
@@ -76,6 +76,9 @@ def __init__(self, layer: Image):
         self.layer.events.contrast_limits.connect(
             self._on_contrast_limits_change
         )
+        self.layer.events.contrast_limits_range.connect(
+            self._on_contrast_limits_range_change
+        )
 
         comboBox = QtColormapComboBox(self)
         comboBox.setObjectName("colormapComboBox")
@@ -142,15 +145,28 @@ def changeColor(self, text):
     def _on_contrast_limits_change(self):
         """Receive layer model contrast limits change event and update slider."""
         with qt_signals_blocked(self.contrastLimitsSlider):
+            self.contrastLimitsSlider.setValue(self.layer.contrast_limits)
+
+        if self.clim_popup:
+            with qt_signals_blocked(self.clim_popup.slider):
+                self.clim_popup.slider.setValue(self.layer.contrast_limits)
+
+    def _on_contrast_limits_range_change(self):
+        """Receive layer model contrast limits change event and update slider."""
+        with qt_signals_blocked(self.contrastLimitsSlider):
+            decimals = range_to_decimals(
+                self.layer.contrast_limits_range, self.layer.dtype
+            )
             self.contrastLimitsSlider.setRange(
                 *self.layer.contrast_limits_range
             )
-            self.contrastLimitsSlider.setValue(self.layer.contrast_limits)
+            self.contrastLimitsSlider.setSingleStep(10**-decimals)
 
         if self.clim_popup:
-            self.clim_popup.slider.setRange(*self.layer.contrast_limits_range)
             with qt_signals_blocked(self.clim_popup.slider):
-                self.clim_popup.slider.setValue(self.layer.contrast_limits)
+                self.clim_popup.slider.setRange(
+                    *self.layer.contrast_limits_range
+                )
 
     def _on_colormap_change(self):
         """Receive layer model colormap change event and update dropdown menu."""
diff --git a/napari/layers/image/image.py b/napari/layers/image/image.py
--- a/napari/layers/image/image.py
+++ b/napari/layers/image/image.py
@@ -411,6 +411,10 @@ def _data_view(self):
         return self._slice.image.view
 
     def _calc_data_range(self, mode='data'):
+        """
+        Calculate the range of the data values in the currently viewed slice
+        or full data array
+        """
         if mode == 'data':
             input_data = self.data[-1] if self.multiscale else self.data
         elif mode == 'slice':
@@ -801,9 +805,12 @@ def _set_view_slice(self):
             self, image_indices, image, thumbnail_source
         )
         self._load_slice(data)
-        if self._keep_auto_contrast or self._should_calc_clims:
+        if self._should_calc_clims:
+            self.reset_contrast_limits_range()
             self.reset_contrast_limits()
             self._should_calc_clims = False
+        elif self._keep_auto_contrast:
+            self.reset_contrast_limits()
 
     @property
     def _SliceDataClass(self):
diff --git a/napari/layers/intensity_mixin.py b/napari/layers/intensity_mixin.py
--- a/napari/layers/intensity_mixin.py
+++ b/napari/layers/intensity_mixin.py
@@ -2,6 +2,7 @@
 
 import numpy as np
 
+from ..utils._dtype import normalize_dtype
 from ..utils.colormaps import ensure_colormap
 from ..utils.events import Event
 from ..utils.status_messages import format_float
@@ -26,7 +27,12 @@ def __init__(self):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
-        self.events.add(contrast_limits=Event, gamma=Event, colormap=Event)
+        self.events.add(
+            contrast_limits=Event,
+            contrast_limits_range=Event,
+            gamma=Event,
+            colormap=Event,
+        )
         self._gamma = 1
         self._colormap_name = ''
         self._contrast_limits_msg = ''
@@ -40,15 +46,17 @@ def reset_contrast_limits(self: 'Image', mode=None):
         mode = mode or self._auto_contrast_source
         self.contrast_limits = self._calc_data_range(mode)
 
-    def reset_contrast_limits_range(self):
-        """Scale contrast limits range to data type.
-
-        Currently, this only does something if the data type is an integer...
-        otherwise it's unclear what the full range should be.
+    def reset_contrast_limits_range(self, mode=None):
+        """Scale contrast limits range to data type if dtype is an integer,
+        or use the current maximum data range otherwise.
         """
-        if np.issubdtype(self.dtype, np.integer):
-            info = np.iinfo(self.dtype)
+        dtype = normalize_dtype(self.dtype)
+        if np.issubdtype(dtype, np.integer):
+            info = np.iinfo(dtype)
             self.contrast_limits_range = (info.min, info.max)
+        else:
+            mode = mode or self._auto_contrast_source
+            self.contrast_limits_range = self._calc_data_range(mode)
 
     @property
     def colormap(self):
@@ -109,6 +117,7 @@ def contrast_limits_range(self, value):
         for i in range(2):
             value[i] = current_range[i] if value[i] is None else value[i]
         self._contrast_limits_range = value
+        self.events.contrast_limits_range()
 
         # make sure that the current values fit within the new range
         # this also serves the purpose of emitting events.contrast_limits()
@@ -118,7 +127,6 @@ def contrast_limits_range(self, value):
             new_min = min(max(value[0], cur_min), value[1])
             new_max = max(min(value[1], cur_max), value[0])
             self.contrast_limits = (new_min, new_max)
-            self.events.contrast_limits()
 
     @property
     def gamma(self):


Test patch:
diff --git a/napari/layers/_tests/test_dask_layers.py b/napari/layers/_tests/test_dask_layers.py
--- a/napari/layers/_tests/test_dask_layers.py
+++ b/napari/layers/_tests/test_dask_layers.py
@@ -287,3 +287,12 @@ def test_prevent_dask_cache(delayed_dask_stack):
     for i in range(3):
         v.dims.set_point(0, i)
     assert len(_dask_utils._DASK_CACHE.cache.heap.heap) == 0
+
+
+@pytest.mark.sync_only
+def test_dask_contrast_limits_range_init():
+    np_arr = np.array([[0.000001, -0.0002], [0, 0.0000004]])
+    da_arr = da.array(np_arr)
+    i1 = layers.Image(np_arr)
+    i2 = layers.Image(da_arr)
+    assert i1.contrast_limits_range == i2.contrast_limits_range