Repository: napari/napari

Issue:
Multi-scale images with negative floating values cannot be added
## 🐛 Bug

If you have multi-scale image data that has a floating-point dtype (e.g. `np.float64`) and has negative values, then try to add that as a multi-scale image to napari, you will see an error related to contrast limits.

```
Traceback (most recent call last):
  File "/Users/asweet/temp/example.py", line 11, in <module>
    viewer.add_image(data, multiscale=True)
  File "/Users/asweet/projects/napari/napari/utils/migrations.py", line 44, in _update_from_dict
    return func(*args, **kwargs)
  File "/Users/asweet/projects/napari/napari/components/viewer_model.py", line 819, in add_image
    layer = Image(data, **kwargs)
  File "/Users/asweet/projects/napari/napari/utils/migrations.py", line 44, in _update_from_dict
    return func(*args, **kwargs)
  File "/Users/asweet/projects/napari/napari/layers/image/image.py", line 382, in __init__
    self._update_dims()
  File "/Users/asweet/projects/napari/napari/layers/base/base.py", line 671, in _update_dims
    self.refresh()  # This call is need for invalidate cache of extent in LayerList. If you remove it pleas ad another workaround.
  File "/Users/asweet/projects/napari/napari/layers/base/base.py", line 1131, in refresh
    self.set_view_slice()
  File "/Users/asweet/projects/napari/napari/layers/base/base.py", line 888, in set_view_slice
    self._set_view_slice()
  File "/Users/asweet/projects/napari/napari/layers/image/image.py", line 828, in _set_view_slice
    self.reset_contrast_limits_range()
  File "/Users/asweet/projects/napari/napari/layers/intensity_mixin.py", line 62, in reset_contrast_limits_range
    self.contrast_limits_range = self._calc_data_range(mode)
  File "/Users/asweet/projects/napari/napari/layers/intensity_mixin.py", line 146, in contrast_limits_range
    self.contrast_limits = (new_min, new_max)
  File "/Users/asweet/projects/napari/napari/layers/intensity_mixin.py", line 91, in contrast_limits
    _validate_increasing(contrast_limits)
  File "/Users/asweet/projects/napari/napari/utils/validators.py", line 127, in _validate_increasing
    raise ValueError(
ValueError: Sequence (0, 0.0) must be monotonically increasing.
```

Credit to @Czaki for [the bug discovery in #5245](https://github.com/napari/napari/pull/5245#issuecomment-1284263313).

## To Reproduce

Running the following example should be enough to reproduce.

```python
import napari
import numpy as np

shapes = [(16, 16), (8, 8)]
data = [np.zeros(s) for s in shapes]

for d in data:
    d[-1, -1] = -1

viewer = napari.Viewer()
viewer.add_image(data, multiscale=True)
```

I think this is only true for multi-scale image with negative values. Using a single-scale image with floating-point negative values, a multi-scale image with floating-point non-negative values, or a multi-scale image with a signed integer dtype and negative values all work as expected.

## Expected behavior

I expect to be able to add the image without an error.

## Environment

```
napari: 0.4.17rc4.dev49+g3351f6f1f
Platform: macOS-10.16-x86_64-i386-64bit
System: MacOS 12.6
Python: 3.9.6 (default, Jul 30 2021, 09:31:09) [Clang 10.0.0 ]
Qt: 5.15.2
PyQt5: 5.15.4
NumPy: 1.23.2
SciPy: 1.8.0
Dask: 2022.7.0
VisPy: 0.11.0
magicgui: 0.4.0
superqt: 0.3.2
in-n-out: 0.1.5
app-model: 0.0.9
npe2: 0.5.2

OpenGL:
- GL version: 2.1 INTEL-18.8.5
- MAX_TEXTURE_SIZE: 16384

Screens:
- screen 1: resolution 1920x1080, scale 1.0
```

## Additional context

This was not present in v0.4.16. Maybe a regression caused by #5036. Whoever picks this up should likely `git-bisect` with the failing example/test above to find out.



Discussion:

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
@@ -131,19 +131,15 @@ def contrast_limits_range(self, value):
         self._contrast_limits_range = value
         self.events.contrast_limits_range()
 
-        # make sure that the current values fit within the new range
+        # make sure that the contrast limits fit within the new range
         # this also serves the purpose of emitting events.contrast_limits()
         # and updating the views/controllers
         if hasattr(self, '_contrast_limits') and any(self._contrast_limits):
-            cur_min, cur_max = self.contrast_limits
-            # 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
+            clipped_limits = np.clip(self.contrast_limits, *value)
+            if clipped_limits[0] < clipped_limits[1]:
+                self.contrast_limits = tuple(clipped_limits)
             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)
+                self.contrast_limits = tuple(value)
 
     @property
     def gamma(self):


Test patch:
diff --git a/napari/components/_tests/test_viewer_model.py b/napari/components/_tests/test_viewer_model.py
--- a/napari/components/_tests/test_viewer_model.py
+++ b/napari/components/_tests/test_viewer_model.py
@@ -104,6 +104,21 @@ def test_add_multiscale():
     assert viewer.dims.ndim == 2
 
 
+def test_add_multiscale_image_with_negative_floats():
+    """See https://github.com/napari/napari/issues/5257"""
+    viewer = ViewerModel()
+    shapes = [(20, 10), (10, 5)]
+    data = [np.zeros(s, dtype=np.float64) for s in shapes]
+    data[0][-4:, -2:] = -1
+    data[1][-2:, -1:] = -1
+
+    viewer.add_image(data, multiscale=True)
+
+    assert len(viewer.layers) == 1
+    assert np.all(viewer.layers[0].data == data)
+    assert viewer.dims.ndim == 2
+
+
 def test_add_labels():
     """Test adding labels image."""
     viewer = ViewerModel()
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
@@ -432,6 +432,24 @@ def test_set_contrast_limits_range():
     assert layer.contrast_limits == [60, 100]
 
 
+@pytest.mark.parametrize(
+    'contrast_limits_range',
+    (
+        [-2, -1],  # range below lower boundary of [0, 1]
+        [-1, 0],  # range on lower boundary of [0, 1]
+        [1, 2],  # range on upper boundary of [0, 1]
+        [2, 3],  # range above upper boundary of [0, 1]
+    ),
+)
+def test_set_contrast_limits_range_at_boundary_of_contrast_limits(
+    contrast_limits_range,
+):
+    """See https://github.com/napari/napari/issues/5257"""
+    layer = Image(np.zeros((6, 5)), contrast_limits=[0, 1])
+    layer.contrast_limits_range = contrast_limits_range
+    assert layer.contrast_limits == contrast_limits_range
+
+
 def test_gamma():
     """Test setting gamma."""
     np.random.seed(0)