Repository: napari/napari

Issue:
2D Image Pyramids not working with add_image (0.2.0)
## 🐛 Bug

Napari is not displaying an image pyramid that I've constructed and passed to `add_image()`

## To Reproduce

Steps to reproduce the behavior:
```python
import numpy as np
from skimage.transform import pyramid_gaussian

import napari

img = np.random.randint(0, 255, (1000, 1000))
pyramid_test = np.asarray(tuple(pyramid_gaussian(img)))
viewer = napari.Viewer()
viewer.add_image(pyramid_test, is_pyramid=True)
```
What I did to trigger the error: 
```python
>>> regions
<xarray.Regions 'array-baa123768d7ef2a6d2cdfa885e8f3eaa' (x_region: 31725, y_region: 51669)>
array([[0, 0, 0, ..., 0, 0, 0],
       [0, 0, 0, ..., 0, 0, 0],
       [0, 0, 0, ..., 0, 0, 0],
       ...,
       [0, 0, 0, ..., 0, 0, 0],
       [0, 0, 0, ..., 0, 0, 0],
       [0, 0, 0, ..., 0, 0, 0]], dtype=int16)
Dimensions without coordinates: x_region, y_region
Attributes:
    assay:             osmFISH
    authors:           ['Simone Codeluppi', 'Lars E. Borm', 'Amit Zeisel', 'G...
    organism:          mouse
    publication_name:  Spatial organization of the somatosensory cortex revea...
    publication_url:   https://www.nature.com/articles/s41592-018-0175-z
    sample_type:       somatosensory cortex
    year:              2018

>>> pyramid = tuple(pyramid_gaussian(regions), multichannel=False)

>>> [i.shape for i in pyramid]
[(31725, 51669),
 (15863, 25835),
 (7932, 12918),
 (3966, 6459),
 (1983, 3230),
 (992, 1615),
 (496, 808),
 (248, 404),
 (124, 202),
 (62, 101),
 (31, 51),
 (16, 26),
 (8, 13),
 (4, 7),
 (2, 4),
 (1, 2),
 (1, 1)]

>>> viewer = napari.Viewer()

>>> viewer.add_image(np.asarray(pyramid), is_pyramid=True)
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-35-665dfea0cb05> in <module>
----> 1 viewer.add_image(np.asarray(pyramid), is_pyramid=True)

/usr/local/lib/python3.7/site-packages/napari/components/viewer_model.py in add_image(self, data, rgb, is_pyramid, colormap, contrast_limits, interpolation, rendering, name, metadata, scale, translate, opacity, blending, visible)
    426             opacity=opacity,
    427             blending=blending,
--> 428             visible=visible,
    429         )
    430         self.add_layer(layer)

/usr/local/lib/python3.7/site-packages/napari/layers/image/image.py in __init__(self, data, rgb, is_pyramid, colormap, contrast_limits, interpolation, rendering, name, metadata, scale, translate, opacity, blending, visible)
    185             else:
    186                 input_data = self.data
--> 187             self._contrast_limits_range = calc_data_range(input_data)
    188         else:
    189             self._contrast_limits_range = contrast_limits

/usr/local/lib/python3.7/site-packages/napari/util/misc.py in calc_data_range(data)
    268         reduced_data = data
    269 
--> 270     min_val = reduced_data.min()
    271     max_val = reduced_data.max()
    272 

/usr/local/lib/python3.7/site-packages/numpy/core/_methods.py in _amin(a, axis, out, keepdims, initial)
     30 def _amin(a, axis=None, out=None, keepdims=False,
     31           initial=_NoValue):
---> 32     return umr_minimum(a, axis, None, out, keepdims, initial)
     33 
     34 def _sum(a, axis=None, dtype=None, out=None, keepdims=False,

ValueError: operands could not be broadcast together with shapes (31725,51669) (15863,25835) 
```
## Expected behavior

<!-- A clear and concise description of what you expected to happen. -->

## Environment

 - napari Version: 0.2.0
 - OS: OS X 10.14
 - Python version: 3.7


Discussion:
I knew I didn't like the contrast limits calculation...! =P As a preliminary workaround, you can pass `contrast_limits=[min_intensity, max_intensity]` and it should work. (Note that your current code should also work, but I just wanted to get you going as fast as possible.
Adding the `contrast_limits` explicitly is good, but you'll still get an error because of the `np.asarray` call (which is actually causing the problem here).

The offending line of code is inside the `is_pyramid` function in our `util/misc`  https://github.com/napari/napari/blob/b41f25e422d850615cd87bbdb344fa08381e395e/napari/util/misc.py#L65
where we assume that a pyramid must be a list - and not either a tuple, or in your case an np.ndarray with dtype obj.

Replacing `np.asarray(tuple(pyramid_gaussian(img)))` with `list(pyramid_gaussian(img))` should fix it, as should `add_image(img)` - we will now generate the image pyramid on the fly for you so you don't have to.

Regardless we still need to fix this.

@jni maybe you can propose a better pythonic way to catch these things? In theory one might also want to specify a dask array, or maybe even a zarr array that is like this too, where what we have is list like - so iterable, and each object inside the list is an array of decreasing size.

I'll note also that even though you had `is_pyramid` set to `True`, we didn't believe you and overwrote it to `False`, which maybe we shouldn't have done.

Thanks for reporting this. Over the next couple of days if we can collect a couple of these bugs we can then fix them and make a new release if it helps
I don't have a specific suggestion here... It's always seemed brittle and magical things tend to be... This is precisely why we've shied away from them in skimage. The only suggestion I have right now is to test for more potential cases in that function. type=array and dtype=object is a good initial candidate.
> I'll note also that even though you had is_pyramid set to True, we didn't believe you and overwrote it to False, which maybe we shouldn't have done.

Oh yeah, definitely not. Here and everywhere, the convention should be:

- False: False
- True: True
- None: [guess]

and recommend that people avoid None if possible.
@jni if someone says `True` but we know that it will cause a downstream error should we raise an error saying you said `True` but we know that's not the case? (thinking of someone saying `rgb=True` but passing a (512, 512) image)

also in the pyrmaid case if someone says `True` but passes a `(20_000, 20_000)` image do we interpret that as they want us to turn that into a pyramid in which case we do it?

in the pyrmaid case if someone says `False` but passes a `(20_000, 20_000)` image which we would otherwise convert then should we not convert? 

i agree that the magical things / conversions are potentially a big nightmare at the expense of convenience for some users who are doing the most standard things. We basically have such magic in a couple places in the code base now `is_pyramid`, `rgb` on image layers, and `size` on the points layer, and the data going into a vectors layer (which is either image like or coordinate like). I think everywhere else we are pretty explicit about things. Definitely worth thinking all that over again carefully.
Hey! Thanks for the suggestions @jni and @sofroniewn. The following works, so I'm good. I should have just tried the simple thing ;-)

```python
>>> regions
<xarray.Regions 'array-baa123768d7ef2a6d2cdfa885e8f3eaa' (x_region: 31725, y_region: 51669)>
 
>>> viewer.add_image(regions)
<Image layer 'Image' at 0x13184e780>
```

This does _not_ work, if it helps your debugging process: 

```python
>>> min_intensity = 0 
>>> max_intensity = np.max(pyramid[0])
>>> viewer.add_image(
>>>    pyramid, contrast_limits=[min_intensity, max_intensity], 
>>>    is_pyramid=True
>>>    )
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-15-aa091bf69d2c> in <module>
----> 1 viewer.add_image(np.asarray(pyramid[:-3]), contrast_limits=[min_intensity, max_intensity])

/usr/local/lib/python3.7/site-packages/napari/components/viewer_model.py in add_image(self, data, rgb, is_pyramid, colormap, contrast_limits, interpolation, rendering, name, metadata, scale, translate, opacity, blending, visible)
    426             opacity=opacity,
    427             blending=blending,
--> 428             visible=visible,
    429         )
    430         self.add_layer(layer)

/usr/local/lib/python3.7/site-packages/napari/layers/image/image.py in __init__(self, data, rgb, is_pyramid, colormap, contrast_limits, interpolation, rendering, name, metadata, scale, translate, opacity, blending, visible)
    189             self._contrast_limits_range = contrast_limits
    190         self._contrast_limits = copy(self._contrast_limits_range)
--> 191         self.colormap = colormap
    192         self.contrast_limits = self._contrast_limits
    193         self.interpolation = interpolation

/usr/local/lib/python3.7/site-packages/napari/layers/image/image.py in colormap(self, colormap)
    294         self._cmap = self._colormaps[name]
    295         self._colorbar = make_colorbar(self._cmap)
--> 296         self._update_thumbnail()
    297         self.events.colormap()
    298 

/usr/local/lib/python3.7/site-packages/napari/layers/image/image.py in _update_thumbnail(self)
    526                 )
    527             low, high = self.contrast_limits
--> 528             downsampled = np.clip(downsampled, low, high)
    529             color_range = high - low
    530             if color_range != 0:

/usr/local/lib/python3.7/site-packages/numpy/core/fromnumeric.py in clip(a, a_min, a_max, out)
   1956 
   1957     """
-> 1958     return _wrapfunc(a, 'clip', a_min, a_max, out=out)
   1959 
   1960 

/usr/local/lib/python3.7/site-packages/numpy/core/fromnumeric.py in _wrapfunc(obj, method, *args, **kwds)
     54 def _wrapfunc(obj, method, *args, **kwds):
     55     try:
---> 56         return getattr(obj, method)(*args, **kwds)
     57 
     58     # An AttributeError occurs if the object does not have

/usr/local/lib/python3.7/site-packages/xarray/core/arithmetic.py in __array_ufunc__(self, ufunc, method, *inputs, **kwargs)
     77             dataset_fill_value=np.nan,
     78             kwargs=kwargs,
---> 79             dask="allowed"
     80         )
     81 

/usr/local/lib/python3.7/site-packages/xarray/core/computation.py in apply_ufunc(func, input_core_dims, output_core_dims, exclude_dims, vectorize, join, dataset_join, dataset_fill_value, keep_attrs, kwargs, dask, output_dtypes, output_sizes, *args)
   1055             join=join,
   1056             exclude_dims=exclude_dims,
-> 1057             keep_attrs=keep_attrs
   1058         )
   1059     elif any(isinstance(a, Variable) for a in args):

/usr/local/lib/python3.7/site-packages/xarray/core/computation.py in apply_dataarray_vfunc(func, signature, join, exclude_dims, keep_attrs, *args)
    237 
    238     data_vars = [getattr(a, "variable", a) for a in args]
--> 239     result_var = func(*data_vars)
    240 
    241     if signature.num_outputs > 1:

/usr/local/lib/python3.7/site-packages/xarray/core/computation.py in apply_variable_ufunc(func, signature, exclude_dims, dask, output_dtypes, output_sizes, keep_attrs, *args)
    629                 "applied function returned data with unexpected "
    630                 "number of dimensions: {} vs {}, for dimensions {}".format(
--> 631                     data.ndim, len(dims), dims
    632                 )
    633             )

ValueError: applied function returned data with unexpected number of dimensions: 2 vs 0, for dimensions ()
```
@ambrosejcarr - what is `pyramid` in your above example that fails. Is it `list(pyramid_gaussian(regions), multichannel=False)` ?
That's right -- sorry for the lack of specificity. 

Resolving patch:
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
@@ -1,6 +1,7 @@
 import warnings
 from xml.etree.ElementTree import Element
 from base64 import b64encode
+import types
 from imageio import imwrite
 import numpy as np
 from copy import copy
@@ -134,6 +135,9 @@ def __init__(
         blending='translucent',
         visible=True,
     ):
+        if isinstance(data, types.GeneratorType):
+            data = list(data)
+
         ndim, rgb, is_pyramid, data_pyramid = get_pyramid_and_rgb(
             data, pyramid=is_pyramid, rgb=rgb
         )
diff --git a/napari/util/misc.py b/napari/util/misc.py
--- a/napari/util/misc.py
+++ b/napari/util/misc.py
@@ -60,10 +60,10 @@ def is_rgb(shape):
 
 
 def is_pyramid(data):
-    """If data is a list of arrays of decreasing size.
+    """If shape of arrays along first axis is strictly decreasing.
     """
-    if isinstance(data, list):
-        size = [np.prod(d.shape) for d in data]
+    size = np.array([np.prod(d.shape) for d in data])
+    if len(size) > 1:
         return np.all(size[:-1] > size[1:])
     else:
         return False
@@ -110,7 +110,7 @@ def get_pyramid_and_rgb(data, pyramid=None, rgb=None):
 
     Parameters
     ----------
-    data : array or list
+    data : array, list, or tuple
         Data to be checked if pyramid or if needs to be turned into a pyramid.
     pyramid : bool, optional
         Value that can force data to be considered as a pyramid or not,
@@ -134,7 +134,8 @@ def get_pyramid_and_rgb(data, pyramid=None, rgb=None):
     # Determine if data currently is a pyramid
     currently_pyramid = is_pyramid(data)
     if currently_pyramid:
-        init_shape = data[0].shape
+        shapes = [d.shape for d in data]
+        init_shape = shapes[0]
     else:
         init_shape = data.shape
 
@@ -144,7 +145,14 @@ def get_pyramid_and_rgb(data, pyramid=None, rgb=None):
     else:
         # If rgb is True or None then guess if rgb
         # allowed or not, and if allowed set it to be True
-        rgb = is_rgb(init_shape)
+        rgb_guess = is_rgb(init_shape)
+        if rgb and rgb_guess is False:
+            raise ValueError(
+                "Non rgb or rgba data was passed, but rgb data was"
+                " requested."
+            )
+        else:
+            rgb = rgb_guess
 
     if rgb:
         ndim = len(init_shape) - 1
@@ -154,8 +162,8 @@ def get_pyramid_and_rgb(data, pyramid=None, rgb=None):
     if pyramid is False:
         if currently_pyramid:
             raise ValueError(
-                """Non pyramided data was requested, but pyramid
-                             data was passed"""
+                "Non pyramided data was requested, but pyramid"
+                " data was passed"
             )
         else:
             data_pyramid = None
@@ -164,8 +172,12 @@ def get_pyramid_and_rgb(data, pyramid=None, rgb=None):
             data_pyramid = trim_pyramid(data)
             pyramid = True
         else:
-            # Guess if data should be pyramid
-            pyr_axes = should_be_pyramid(data.shape)
+            # Guess if data should be pyramid or if a pyramid was requested
+            if pyramid:
+                pyr_axes = [True] * ndim
+            else:
+                pyr_axes = should_be_pyramid(data.shape)
+
             if np.any(pyr_axes):
                 pyramid = True
                 # Set axes to be downsampled to have a factor of 2


Test patch:
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
@@ -198,19 +198,15 @@ def test_non_rgb_image():
     assert layer._data_view.shape == shape[-2:]
 
 
-def test_non_rgb_image():
-    """Test forcing Image layer to be 3D and not rgb."""
+def test_error_non_rgb_image():
+    """Test error on trying non rgb as rgb."""
     # If rgb is set to be True in constructor but the last dim has a
     # size > 4 then data cannot actually be rgb
     shape = (10, 15, 6)
     np.random.seed(0)
     data = np.random.random(shape)
-    layer = Image(data, rgb=True)
-    assert np.all(layer.data == data)
-    assert layer.ndim == len(shape)
-    assert layer.shape == shape
-    assert layer.rgb == False
-    assert layer._data_view.shape == shape[-2:]
+    with pytest.raises(ValueError):
+        layer = Image(data, rgb=True)
 
 
 def test_changing_image():
diff --git a/napari/layers/image/tests/test_pyramid.py b/napari/layers/image/tests/test_pyramid.py
--- a/napari/layers/image/tests/test_pyramid.py
+++ b/napari/layers/image/tests/test_pyramid.py
@@ -1,7 +1,9 @@
 import numpy as np
+from skimage.transform import pyramid_gaussian
 from xml.etree.ElementTree import Element
 from vispy.color import Colormap
 from napari.layers import Image
+import pytest
 
 
 def test_random_pyramid():
@@ -12,6 +14,7 @@ def test_random_pyramid():
     layer = Image(data, is_pyramid=True)
     assert layer.data == data
     assert layer.is_pyramid == True
+    assert len(layer._data_pyramid) > 0
     assert layer.ndim == len(shapes[0])
     assert layer.shape == shapes[0]
     assert layer.rgb == False
@@ -26,12 +29,81 @@ def test_infer_pyramid():
     layer = Image(data)
     assert layer.data == data
     assert layer.is_pyramid == True
+    assert len(layer._data_pyramid) > 0
     assert layer.ndim == len(shapes[0])
     assert layer.shape == shapes[0]
     assert layer.rgb == False
     assert layer._data_view.ndim == 2
 
 
+def test_error_pyramid():
+    """Test error on forcing non pyramid."""
+    shapes = [(40, 20), (20, 10), (10, 5)]
+    np.random.seed(0)
+    data = [np.random.random(s) for s in shapes]
+    with pytest.raises(ValueError):
+        layer = Image(data, is_pyramid=False)
+
+
+def test_infer_tuple_pyramid():
+    """Test instantiating Image layer with random 2D pyramid data."""
+    shapes = [(40, 20), (20, 10), (10, 5)]
+    np.random.seed(0)
+    data = tuple(np.random.random(s) for s in shapes)
+    layer = Image(data)
+    assert layer.data == data
+    assert layer.is_pyramid == True
+    assert layer.ndim == len(shapes[0])
+    assert layer.shape == shapes[0]
+    assert layer.rgb == False
+    assert layer._data_view.ndim == 2
+
+
+def test_forcing_pyramid():
+    """Test instantiating Image layer forcing 2D pyramid data."""
+    shape = (40, 20)
+    np.random.seed(0)
+    data = np.random.random(shape)
+    layer = Image(data, is_pyramid=True)
+    assert np.all(layer.data == data)
+    assert layer.is_pyramid == True
+    assert len(layer._data_pyramid) > 0
+    assert layer.ndim == len(shape)
+    assert layer.shape == shape
+    assert layer.rgb == False
+    assert layer._data_view.ndim == 2
+
+
+def test_blocking_pyramid():
+    """Test instantiating Image layer blocking 2D pyramid data."""
+    shape = (40, 20)
+    np.random.seed(0)
+    data = np.random.random(shape)
+    layer = Image(data, is_pyramid=False)
+    assert np.all(layer.data == data)
+    assert layer.is_pyramid == False
+    assert layer._data_pyramid == None
+    assert layer.ndim == len(shape)
+    assert layer.shape == shape
+    assert layer.rgb == False
+    assert layer._data_view.ndim == 2
+
+
+def test_pyramid_tuple():
+    """Test instantiating Image layer pyramid tuple."""
+    shape = (40, 20)
+    np.random.seed(0)
+    img = np.random.random(shape)
+    data = tuple(pyramid_gaussian(img, multichannel=False))
+    layer = Image(data)
+    assert layer.data == data
+    assert layer.is_pyramid == True
+    assert layer.ndim == len(shape)
+    assert layer.shape == shape
+    assert layer.rgb == False
+    assert layer._data_view.ndim == 2
+
+
 def test_3D_pyramid():
     """Test instantiating Image layer with 3D data."""
     shapes = [(8, 40, 20), (4, 20, 10), (2, 10, 5)]
diff --git a/napari/util/tests/test_misc.py b/napari/util/tests/test_misc.py
--- a/napari/util/tests/test_misc.py
+++ b/napari/util/tests/test_misc.py
@@ -1,4 +1,5 @@
 import numpy as np
+from skimage.transform import pyramid_gaussian
 from napari.util.misc import (
     is_rgb,
     callsignature,
@@ -84,6 +85,19 @@ def test_is_pyramid():
     data = [np.random.random((10, 15, 6)), np.random.random((10, 7, 3))]
     assert is_pyramid(data)
 
+    data = tuple(data)
+    assert is_pyramid(data)
+
+    data = tuple(
+        pyramid_gaussian(np.random.random((10, 15)), multichannel=False)
+    )
+    assert is_pyramid(data)
+
+    data = np.asarray(
+        tuple(pyramid_gaussian(np.random.random((10, 15)), multichannel=False))
+    )
+    assert is_pyramid(data)
+
 
 def test_trim_pyramid():
 
@@ -191,6 +205,13 @@ def test_get_pyramid_and_rgb():
     assert not rgb
     assert ndim == 2
 
+    data = np.random.random((80, 40))
+    ndim, rgb, pyramid, data_pyramid = get_pyramid_and_rgb(data, pyramid=True)
+    assert pyramid
+    assert data_pyramid[0].shape == (80, 40)
+    assert not rgb
+    assert ndim == 2
+
     data = [np.random.random((10, 15, 6)), np.random.random((5, 7, 3))]
     ndim, rgb, pyramid, data_pyramid = get_pyramid_and_rgb(data)
     assert pyramid