Repository: napari/napari

Issue:
Multiscale 3D rendering warning should happen once only
When I switch to 3D view in a multiscale array, I get the following warning:

```pytb
/Users/jni/projects/napari/napari/layers/image/_slice.py:113: UserWarning: Multiscale rendering is only supported in 2D. In 3D, only the lowest resolution scale is displayed!
```

This is fine, except the warning happens on *every* slice event:

```pytb
...
/Users/jni/projects/napari/napari/layers/image/_slice.py:113: UserWarning: Multiscale rendering is only supported in 2D. In 3D, only the lowest resolution scale is displayed!
/Users/jni/projects/napari/napari/layers/image/_slice.py:113: UserWarning: Multiscale rendering is only supported in 2D. In 3D, only the lowest resolution scale is displayed!
/Users/jni/projects/napari/napari/layers/image/_slice.py:113: UserWarning: Multiscale rendering is only supported in 2D. In 3D, only the lowest resolution scale is displayed!
/Users/jni/projects/napari/napari/layers/image/_slice.py:113: UserWarning: Multiscale rendering is only supported in 2D. In 3D, only the lowest resolution scale is displayed!
...
```

That's a fraction of the console output, and the toast pop-up on the canvas just keeps popping as you interact with the viewer.

@kephale is making good progress to help us get rid of that warning altogether, but in the meantime, a quick fix would be to set a flag for whether we've warned already, and not warn if so.


Discussion:
I was optimistic that this could be fixed with `warnings.simplefilter('once')` and maybe `warnings.catch_warnings`, but that doesn't seem to have effect here. 

I then noticed `filterwarnings` in `pyproject.toml`, which attempts to ignore [all warnings matching this message](https://github.com/napari/napari/blob/c43a2e7c0181b5c4ed877fb1f59cae6f4d0ff0d2/pyproject.toml#L156). I'm not sure if that would have an effect in editable installs, but I could also reproduce this issue on my non-editable v0.4.17 install, so it doesn't seem to have an effect at all - or am I misinterpreting standard warning filtering in Python? I also wonder if napari is handling warnings in a special way that means these get ignored?

We could of course fix this by storing a static boolean that gets set after the first warning, but that feels silly given there are built-in mechanisms for handling this stuff.

I'm going to unassign myself for now and hope someone with more background/knowledge steps in here.
> I then noticed `filterwarnings` in `pyproject.toml`, which attempts to ignore [all warnings matching this message](https://github.com/napari/napari/blob/c43a2e7c0181b5c4ed877fb1f59cae6f4d0ff0d2/pyproject.toml#L156). I'm not sure if that would have an effect in editable installs, but I could also reproduce this issue on my non-editable v0.4.17 install, so it doesn't seem to have an effect at all - or am I misinterpreting standard warning filtering in Python?

From community meeting debugging, I am misinterpreting because that `filterwarnings` only applies to tests. So one mystery solved.
I couldn't resist picking this one back and I think this is actually a Python bug: https://github.com/python/cpython/issues/73858

If I understand correctly, the problem is that on exiting `catch_warnings` forgets about any previously seen warnings, which is why we get repeats even though the `'default'` action is to show once. The tiny minimal reproducing example there shows this off:

```python
import warnings
for _ in range(3):
    with warnings.catch_warnings():
        pass
    warnings.warn('warning')
```

In our case, the troublemaker is (of course...) the thumbnail code which uses `catch_warnings` twice:
- [Image._update_thumbnail](https://github.com/napari/napari/blob/c43a2e7c0181b5c4ed877fb1f59cae6f4d0ff0d2/napari/layers/image/image.py#L927)
- [Image.thumbnail setter](https://github.com/napari/napari/blob/c43a2e7c0181b5c4ed877fb1f59cae6f4d0ff0d2/napari/layers/base/base.py#L872)

To fix this specific issue (i.e. when going through the slicing path), we might be able to remove the first usage (as we require scipy >= 1.4) and we may also be able to remove the second one if we use the `scikit-image` utility and that doesn't issue a warning.

But the general problem is that any other use of `catch_warnings` that gets hit while using napari will cause it to forget, so the issue may get hit again (though likely not on every slice/refresh).

Resolving patch:
diff --git a/napari/layers/base/base.py b/napari/layers/base/base.py
--- a/napari/layers/base/base.py
+++ b/napari/layers/base/base.py
@@ -869,9 +869,7 @@ def thumbnail(self, thumbnail):
         if 0 in thumbnail.shape:
             thumbnail = np.zeros(self._thumbnail_shape, dtype=np.uint8)
         if thumbnail.dtype != np.uint8:
-            with warnings.catch_warnings():
-                warnings.simplefilter("ignore")
-                thumbnail = convert_to_uint8(thumbnail)
+            thumbnail = convert_to_uint8(thumbnail)
 
         padding_needed = np.subtract(self._thumbnail_shape, thumbnail.shape)
         pad_amounts = [(p // 2, (p + 1) // 2) for p in padding_needed]
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
@@ -901,12 +901,9 @@ def _update_thumbnail(self):
         )
         zoom_factor = tuple(new_shape / image.shape[:2])
         if self.rgb:
-            # warning filter can be removed with scipy 1.4
-            with warnings.catch_warnings():
-                warnings.simplefilter("ignore")
-                downsampled = ndi.zoom(
-                    image, zoom_factor + (1,), prefilter=False, order=0
-                )
+            downsampled = ndi.zoom(
+                image, zoom_factor + (1,), prefilter=False, order=0
+            )
             if image.shape[2] == 4:  # image is RGBA
                 colormapped = np.copy(downsampled)
                 colormapped[..., 3] = downsampled[..., 3] * self.opacity
@@ -923,12 +920,9 @@ def _update_thumbnail(self):
                     alpha = np.full(downsampled.shape[:2] + (1,), self.opacity)
                 colormapped = np.concatenate([downsampled, alpha], axis=2)
         else:
-            # warning filter can be removed with scipy 1.4
-            with warnings.catch_warnings():
-                warnings.simplefilter("ignore")
-                downsampled = ndi.zoom(
-                    image, zoom_factor, prefilter=False, order=0
-                )
+            downsampled = ndi.zoom(
+                image, zoom_factor, prefilter=False, order=0
+            )
             low, high = self.contrast_limits
             downsampled = np.clip(downsampled, low, high)
             color_range = high - low
diff --git a/napari/layers/utils/layer_utils.py b/napari/layers/utils/layer_utils.py
--- a/napari/layers/utils/layer_utils.py
+++ b/napari/layers/utils/layer_utils.py
@@ -278,18 +278,20 @@ def segment_normal(a, b, p=(0, 0, 1)):
 
 def convert_to_uint8(data: np.ndarray) -> np.ndarray:
     """
-    Convert array content to uint8.
+    Convert array content to uint8, always returning a copy.
 
-    If all negative values are changed on 0.
+    Based on skimage.util.dtype._convert but limited to an output type uint8,
+    so should be equivalent to skimage.util.dtype.img_as_ubyte.
 
-    If values are integer and bellow 256 it is simple casting otherwise maximum value for this data type is picked
-    and values are scaled by 255/maximum type value.
+    If all negative, values are clipped to 0.
 
-    Binary images ar converted to [0,255] images.
+    If values are integers and below 256, this simply casts.
+    Otherwise the maximum value for the input data type is determined and
+    output values are proportionally scaled by this value.
 
-    float images are multiply by 255 and then casted to uint8.
+    Binary images are converted so that False -> 0, True -> 255.
 
-    Based on skimage.util.dtype.convert but limited to output type uint8
+    Float images are multiplied by 255 and then cast to uint8.
     """
     out_dtype = np.dtype(np.uint8)
     out_max = np.iinfo(out_dtype).max
@@ -302,6 +304,7 @@ def convert_to_uint8(data: np.ndarray) -> np.ndarray:
         image_out = np.multiply(data, out_max, dtype=data.dtype)
         np.rint(image_out, out=image_out)
         np.clip(image_out, 0, out_max, out=image_out)
+        image_out = np.nan_to_num(image_out, copy=False)
         return image_out.astype(out_dtype)
 
     if in_kind in "ui":


Test patch: