Repository: napari/napari

Issue:
Setting contrast_limits to [1,1] causes hard to debug error
## 🐛 Bug

Hi all,

I just debugged an issue deep in napari and it turned out, I was unintentionally setting contrast_limits to [1,1]. If min and max of contrast limits are equal, this causes an error and it took me quite some time to understand what's the reason. I'm suggesting to catch this situation early with a clear error message. 

## To Reproduce

From python (I did in a juptyer notebook), call this:
```
import napari
import numpy as np

viewer = napari.Viewer()
image = np.asarray([[0,1], [0, np.inf]])
viewer.add_image(image, contrast_limits=[1,1])
```

This error happens:

```
File ~\miniconda3\envs\bio_39\lib\site-packages\napari\_qt\layer_controls\qt_image_controls_base.py:287, in range_to_decimals(range_, dtype)
[...]
OverflowError: cannot convert float infinity to integer
```


<details>
<summary markdown="span">Detailed log</summary>
<pre>
divide by zero encountered in log10
---------------------------------------------------------------------------
OverflowError                             Traceback (most recent call last)
Input In [3], in <module>
      1 image = np.asarray([[0,1], [0, np.inf]])
----> 3 viewer.add_image(image, contrast_limits=[1,1])

File ~\miniconda3\envs\bio_39\lib\site-packages\napari\components\viewer_model.py:732, in ViewerModel.add_image(self, data, channel_axis, rgb, colormap, contrast_limits, gamma, interpolation, rendering, iso_threshold, attenuation, name, metadata, scale, translate, rotate, shear, affine, opacity, blending, visible, multiscale, cache, experimental_slicing_plane, experimental_clipping_planes)
    724             raise TypeError(
    725                 trans._(
    726                     "Received sequence for argument '{argument}', did you mean to specify a 'channel_axis'? ",
   (...)
    729                 )
    730             )
    731     layer = Image(data, **kwargs)
--> 732     self.layers.append(layer)
    734     return layer
    735 else:

File ~\miniconda3\envs\bio_39\lib\_collections_abc.py:1073, in MutableSequence.append(self, value)
   1071 def append(self, value):
   1072     'S.append(value) -- append value to the end of the sequence'
-> 1073     self.insert(len(self), value)

File ~\miniconda3\envs\bio_39\lib\site-packages\napari\components\layerlist.py:90, in LayerList.insert(self, index, value)
     88 new_layer = self._type_check(value)
     89 new_layer.name = self._coerce_name(new_layer.name)
---> 90 super().insert(index, new_layer)

File ~\miniconda3\envs\bio_39\lib\site-packages\napari\utils\events\containers\_selectable_list.py:65, in SelectableEventedList.insert(self, index, value)
     64 def insert(self, index: int, value: _T):
---> 65     super().insert(index, value)
     66     if self._activate_on_insert:
     67         # Make layer selected and unselect all others
     68         self.selection.active = value

File ~\miniconda3\envs\bio_39\lib\site-packages\napari\utils\events\containers\_evented_list.py:179, in EventedList.insert(self, index, value)
    177 self.events.inserting(index=index)
    178 super().insert(index, value)
--> 179 self.events.inserted(index=index, value=value)
    180 self._connect_child_emitters(value)

File ~\miniconda3\envs\bio_39\lib\site-packages\napari\utils\events\event.py:715, in EventEmitter.__call__(self, *args, **kwargs)
    712     self._block_counter.update([cb])
    713     continue
--> 715 self._invoke_callback(cb, event if pass_event else None)
    716 if event.blocked:
    717     break

File ~\miniconda3\envs\bio_39\lib\site-packages\napari\utils\events\event.py:752, in EventEmitter._invoke_callback(self, cb, event)
    750     self.disconnect(cb)
    751     return
--> 752 _handle_exception(
    753     self.ignore_callback_errors,
    754     self.print_callback_errors,
    755     self,
    756     cb_event=(cb, event),
    757 )

File ~\miniconda3\envs\bio_39\lib\site-packages\napari\utils\events\event.py:739, in EventEmitter._invoke_callback(self, cb, event)
    737 try:
    738     if event is not None:
--> 739         cb(event)
    740     else:
    741         cb()

File ~\miniconda3\envs\bio_39\lib\site-packages\napari\_qt\layer_controls\qt_layer_controls_container.py:128, in QtLayerControlsContainer._add(self, event)
    120 """Add the controls target layer to the list of control widgets.
    121 
    122 Parameters
   (...)
    125     Event with the target layer at `event.value`.
    126 """
    127 layer = event.value
--> 128 controls = create_qt_layer_controls(layer)
    129 self.addWidget(controls)
    130 self.widgets[layer] = controls

File ~\miniconda3\envs\bio_39\lib\site-packages\napari\_qt\layer_controls\qt_layer_controls_container.py:67, in create_qt_layer_controls(layer)
     65 candidates.sort(key=lambda layer_type: layer_cls.mro().index(layer_type))
     66 controls = layer_to_controls[candidates[0]]
---> 67 return controls(layer)

File ~\miniconda3\envs\bio_39\lib\site-packages\napari\_qt\layer_controls\qt_image_controls.py:46, in QtImageControls.__init__(self, layer)
     45 def __init__(self, layer):
---> 46     super().__init__(layer)
     48     self.layer.events.interpolation.connect(self._on_interpolation_change)
     49     self.layer.events.rendering.connect(self._on_rendering_change)

File ~\miniconda3\envs\bio_39\lib\site-packages\napari\_qt\layer_controls\qt_image_controls_base.py:92, in QtBaseImageControls.__init__(self, layer)
     90 # Create contrast_limits slider
     91 self.contrastLimitsSlider = _QDoubleRangeSlider(Qt.Horizontal, self)
---> 92 decimals = range_to_decimals(
     93     self.layer.contrast_limits_range, self.layer.dtype
     94 )
     95 self.contrastLimitsSlider.setRange(*self.layer.contrast_limits_range)
     96 self.contrastLimitsSlider.setSingleStep(10 ** -decimals)

File ~\miniconda3\envs\bio_39\lib\site-packages\napari\_qt\layer_controls\qt_image_controls_base.py:287, in range_to_decimals(range_, dtype)
    279 else:
    280     # scale precision with the log of the data range order of magnitude
    281     # eg.   0 - 1   (0 order of mag)  -> 3 decimal places
   (...)
    284     #       ≥ 3 orders of mag -> no decimals
    285     # no more than 64 decimals
    286     d_range = np.subtract(*range_[::-1])
--> 287     return min(64, max(int(3 - np.log10(d_range)), 0))

OverflowError: cannot convert float infinity to integer
</pre>
</details>

## Expected behavior

Either no error happens at all, or a clear warning message tells that contrast_limits must be two different numbers. I'd be happy to implement the second case , e.g. [here](https://github.com/napari/napari/blob/main/napari/layers/image/image.py#L329), and send a PR if there is concensus that this is the right place and strategy.

## Environment

 - Please copy and paste the information at napari info option in help menubar here:
```
napari: 0.4.14
Platform: Windows-10-10.0.19042-SP0
Python: 3.9.10 | packaged by conda-forge | (main, Feb 1 2022, 21:22:07) [MSC v.1929 64 bit (AMD64)]
Qt: 5.12.9
PyQt5: 5.12.3
NumPy: 1.21.5
SciPy: 1.8.0
Dask: 2022.02.0
VisPy: 0.9.6

OpenGL:
- GL version: 4.6.14761 Compatibility Profile Context 21.30.02.01 30.0.13002.1001
- MAX_TEXTURE_SIZE: 16384

Screens:
- screen 1: resolution 1920x1200, scale 1.0

Plugins:
- PlatyMatch: 0.0.3
- RedLionfish: 0.6
- aicsimageio-in-memory: 0.5.0
- aicsimageio-out-of-memory: 0.5.0
- animation: 0.0.2
- cellpose-napari: 0.1.4
- clEsperanto: 0.15.4
- console: 0.0.4
- devbio_napari: 0.4.0
- napari-3d-ortho-viewer: 0.0.1
- napari-accelerated-pixel-and-object-classification: 0.6.6
- napari-allencell-segmenter: 1.1.4
- napari-brightness-contrast: 0.1.6
- napari-clusters-plotter: 0.2.2
- napari-crop: 0.1.5
- napari-curtain: 0.1.1
- napari-folder-browser: 0.1.3
- napari-itk-io: 0.1.0
- napari-ome-zarr: 0.1.0
- napari-plot-profile: 0.2.1
- napari-plugin-search: 0.1.3
- napari-segment-blobs-and-things-with-membranes: 0.2.17
- napari-simpleitk-image-processing: 0.2.1
- napari-tabu: 0.1.5
- napari-time-slicer: 0.4.3
- napari-tools-menu: 0.1.10
- napari-workflow-optimizer: 0.1.3
- napari_skimage_regionprops1: 0.2.9
- napari_skimage_regionprops2: 0.2.9
- ome-types: 0.2.10
- ome_zarr: 0.0.18
- scikit-image: 0.4.14
- svg: 0.1.5
```
 - Any other relevant information:

## Additional context

<!-- Add any other context about the problem here. -->



Discussion:
> I'd be happy to implement the second case , e.g. [here](https://github.com/napari/napari/blob/main/napari/layers/image/image.py#L329), and send a PR if there is concensus that this is the right place and strategy.

I would put it here https://github.com/napari/napari/blob/842008f6df909bcbc391789badd7f2414eed6ec4/napari/layers/intensity_mixin.py#L76 AND here https://github.com/napari/napari/blob/842008f6df909bcbc391789badd7f2414eed6ec4/napari/layers/intensity_mixin.py#L99 sorry that's a bit hard to find. You could make a function `validate_increasing` and call it after `validate_2_tuple`.

Resolving patch:
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
@@ -6,7 +6,7 @@
 from ..utils.colormaps import ensure_colormap
 from ..utils.events import Event
 from ..utils.status_messages import format_float
-from ..utils.validators import validate_n_seq
+from ..utils.validators import _validate_increasing, validate_n_seq
 
 validate_2_tuple = validate_n_seq(2)
 
@@ -22,6 +22,9 @@ class IntensityVisualizationMixin:
         class Image(IntensityVisualizationMixin, Layer):
             def __init__(self):
                 ...
+
+    Note: `contrast_limits_range` is range extent available on the widget,
+    and `contrast_limits` is the visible range (the set values on the widget)
     """
 
     def __init__(self, *args, **kwargs):
@@ -85,6 +88,7 @@ def contrast_limits(self):
     @contrast_limits.setter
     def contrast_limits(self, contrast_limits):
         validate_2_tuple(contrast_limits)
+        _validate_increasing(contrast_limits)
         self._contrast_limits_msg = (
             format_float(contrast_limits[0])
             + ', '
@@ -106,8 +110,16 @@ def contrast_limits_range(self):
 
     @contrast_limits_range.setter
     def contrast_limits_range(self, value):
-        """Set the valid range of the contrast limits"""
+        """Set the valid range of the contrast limits.
+        If either value is "None", the current range will be preserved.
+        If the range overlaps the current contrast limits, the range will be set
+        requested and there will be no change the contrast limits.
+        If the requested contrast range limits are completely outside the
+        current contrast limits, the range will be set as requested and the
+        contrast limits will be reset to the new range.
+        """
         validate_2_tuple(value)
+        _validate_increasing(value)
         if list(value) == self.contrast_limits_range:
             return
 
@@ -124,9 +136,14 @@ def contrast_limits_range(self, value):
         # and updating the views/controllers
         if hasattr(self, '_contrast_limits') and any(self._contrast_limits):
             cur_min, cur_max = self.contrast_limits
-            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)
+            # if range limits are outside of current limits, override
+            if value[0] > cur_max or value[1] < cur_min:
+                self.contrast_limits = tuple(value)
+            # if there is some overlap, clip to range
+            else:
+                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)
 
     @property
     def gamma(self):
diff --git a/napari/utils/validators.py b/napari/utils/validators.py
--- a/napari/utils/validators.py
+++ b/napari/utils/validators.py
@@ -1,4 +1,6 @@
 from collections.abc import Collection, Generator
+from itertools import tee
+from typing import Iterable
 
 from .translations import trans
 
@@ -82,3 +84,50 @@ def func(obj):
                     )
 
     return func
+
+
+def _pairwise(iterable: Iterable):
+    """Convert iterable to a zip object containing tuples of pairs along the
+    sequence.
+
+    Examples
+    --------
+    >>> pairwise([1, 2, 3, 4])
+    <zip at 0x10606df80>
+
+    >>> list(pairwise([1, 2, 3, 4]))
+    [(1, 2), (2, 3), (3, 4)]
+    """
+    # duplicate the iterable
+    a, b = tee(iterable)
+    # shift b by one position
+    next(b, None)
+    # create tuple pairs from the values in a and b
+    return zip(a, b)
+
+
+def _validate_increasing(values: Iterable) -> None:
+    """Ensure that values in an iterable are monotocially increasing.
+
+    Examples
+    --------
+    >>> _validate_increasing([1, 2, 3, 4])
+    None
+
+    >>> _validate_increasing([1, 4, 3, 4])
+    ValueError: Sequence [1, 4, 3, 4] must be monotonically increasing.
+
+    Raises
+    ------
+    ValueError
+        If `values` is constant or decreasing from one value to the next.
+    """
+    # convert iterable to pairwise tuples, check each tuple
+    if any(a >= b for a, b in _pairwise(values)):
+        raise ValueError(
+            trans._(
+                "Sequence {sequence} must be monotonically increasing.",
+                deferred=True,
+                sequence=values,
+            )
+        )


Test patch:
diff --git a/napari/layers/_tests/test_layer_attributes.py b/napari/layers/_tests/test_layer_attributes.py
--- a/napari/layers/_tests/test_layer_attributes.py
+++ b/napari/layers/_tests/test_layer_attributes.py
@@ -56,6 +56,15 @@ def test_update_data_updates_layer_extent_cache(Layer, data, ndim):
     assert new_extent is layer.extent
 
 
+def test_contrast_limits_must_be_increasing():
+    np.random.seed(0)
+    Image(np.random.rand(8, 8), contrast_limits=[0, 1])
+    with pytest.raises(ValueError):
+        Image(np.random.rand(8, 8), contrast_limits=[1, 1])
+    with pytest.raises(ValueError):
+        Image(np.random.rand(8, 8), contrast_limits=[1, 0])
+
+
 def _check_subpixel_values(layer, val_dict):
     ndisplay = layer._ndisplay
     for center, expected_value in val_dict.items():
diff --git a/napari/layers/image/_tests/test_image.py b/napari/layers/image/_tests/test_image.py
--- a/napari/layers/image/_tests/test_image.py
+++ b/napari/layers/image/_tests/test_image.py
@@ -421,14 +421,15 @@ def test_set_contrast_limits_range():
     # clim values should stay within the contrast limits range
     layer.contrast_limits_range = [0, 30]
     assert layer.contrast_limits == [20, 30]
-    # setting contrast limits range should clamp both of the clims values
+    # setting clim range outside of clim should override clim
     layer.contrast_limits_range = [0, 10]
-    assert layer.contrast_limits == [10, 10]
+    assert layer.contrast_limits == [0, 10]
+
     # in both directions...
     layer.contrast_limits_range = [0, 100]
     layer.contrast_limits = [20, 40]
     layer.contrast_limits_range = [60, 100]
-    assert layer.contrast_limits == [60, 60]
+    assert layer.contrast_limits == [60, 100]
 
 
 def test_gamma():
diff --git a/napari/utils/_tests/test_validators.py b/napari/utils/_tests/test_validators.py
--- a/napari/utils/_tests/test_validators.py
+++ b/napari/utils/_tests/test_validators.py
@@ -17,3 +17,19 @@ def test_sequence_validator():
 
     with pytest.raises(TypeError):
         validate([1.4, 5])  # raises TypeError
+
+
+def test_pairwise():
+    source = [1, 2, 3]
+    output = validators._pairwise(source)
+    assert list(output) == [(1, 2), (2, 3)]
+
+
+def test_validate_increasing():
+    valid_source = [1, 2, 3]
+    validators._validate_increasing(valid_source)
+
+    invalid_sources = [[3, 2, 1], [1, 1, 2]]
+    for source in invalid_sources:
+        with pytest.raises(ValueError):
+            validators._validate_increasing(source)