Repository: vispy/vispy

Issue:
Fix scalabe textures using clim_normalized when "auto" limits are used
If you create an ImageVisual with clims and then set clims to "auto" later, the ImageVisual fails. Here's an updated image test showing the use case

```python
@requires_application()
@pytest.mark.parametrize('is_3d', [True, False])
def test_image_auto_clim(is_3d):
    """Test image visual"""
    size = (100, 50)
    with TestingCanvas(size=size, bgcolor='w') as c:
        image = Image(cmap='grays', clim=[0, 1], parent=c.scene)
        shape = (size[1]-10, size[0]-10) + ((3,) if is_3d else ())
        np.random.seed(379823)
        data = np.random.rand(*shape)
        image.set_data(data)
        assert_image_approved(c.render(), "visuals/image%s.png" %
                              ("_rgb" if is_3d else "_mono"))

        # change to auto clims later
        image.clim = "auto"
        assert_image_approved(c.render(), "visuals/image%s.png" %
                              ("_rgb" if is_3d else "_mono"))
```

Here's the code in the ImageVisual that fails:

```python
    @clim.setter
    def clim(self, clim):
        if self._texture.set_clim(clim):
            self._need_texture_upload = True
        # shortcut so we don't have to rebuild the whole color transform
        if not self._need_colortransform_update:
            self.shared_program.frag['color_transform'][1]['clim'] = self._texture.clim_normalized
        self.update()
```

So because no other color transform changes have been done, we take the shortcut to assign `clim_normalized`. However, the data hasn't actually been set yet so auto clims haven't been calculated. So `clim_normalized` fails when it tries to split the clims into two elements (min, max).

I'm working on a PR with this updated test. My guess is that the easiest fix is to just update this colortransform if statement with an "auto" check...or maybe try/except with a new exception in clim_normalized. I'll make the PR...


Discussion:

Resolving patch:
diff --git a/vispy/visuals/_scalable_textures.py b/vispy/visuals/_scalable_textures.py
--- a/vispy/visuals/_scalable_textures.py
+++ b/vispy/visuals/_scalable_textures.py
@@ -114,7 +114,19 @@ def set_clim(self, clim):
     @property
     def clim_normalized(self):
         """Normalize current clims to match texture data inside the shader.
+
+        If data is scaled on the CPU then the texture data will be in the range
+        0-1 in the _build_texture() method. Inside the fragment shader the
+        final contrast adjustment will be applied based on this normalized
+        ``clim``.
+
         """
+        if isinstance(self.clim, str) and self.clim == "auto":
+            raise RuntimeError("Can't return 'auto' normalized color limits "
+                               "until data has been set. Call "
+                               "'scale_and_set_data' first.")
+        if self.clim[0] == self.clim[1]:
+            return self.clim[0], np.inf
         # if the internalformat of the texture is normalized we need to
         # also normalize the clims so they match in-shader
         clim_min = self.normalize_value(self.clim[0], self._data_dtype)
@@ -260,6 +272,11 @@ def clim_normalized(self):
         ``clim``.
 
         """
+        if isinstance(self.clim, str) and self.clim == "auto":
+            raise RuntimeError("Can't return 'auto' normalized color limits "
+                               "until data has been set. Call "
+                               "'scale_and_set_data' first.")
+
         range_min, range_max = self._data_limits
         clim_min, clim_max = self.clim
         if clim_min == clim_max:
@@ -349,25 +366,6 @@ class GPUScaledTextureMixin(_ScaledTextureMixin):
     # instance variable that will be used later on
     _auto_texture_format = False
 
-    @property
-    def clim_normalized(self):
-        """Normalize current clims to match texture data inside the shader.
-
-        If data is scaled on the CPU then the texture data will be in the range
-        0-1 in the _build_texture() method. Inside the fragment shader the
-        final contrast adjustment will be applied based on this normalized
-        ``clim``.
-
-        """
-        if self.clim[0] == self.clim[1]:
-            return self.clim[0], np.inf
-
-        # if the internalformat of the texture is normalized we need to
-        # also normalize the clims so they match in-shader
-        clim_min = self.normalize_value(self.clim[0], self._data_dtype)
-        clim_max = self.normalize_value(self.clim[1], self._data_dtype)
-        return clim_min, clim_max
-
     def _handle_auto_texture_format(self, texture_format, data):
         if isinstance(texture_format, str) and texture_format == 'auto':
             if data is None:
diff --git a/vispy/visuals/image.py b/vispy/visuals/image.py
--- a/vispy/visuals/image.py
+++ b/vispy/visuals/image.py
@@ -348,11 +348,21 @@ def clim(self):
     def clim(self, clim):
         if self._texture.set_clim(clim):
             self._need_texture_upload = True
-        # shortcut so we don't have to rebuild the whole color transform
-        if not self._need_colortransform_update:
-            self.shared_program.frag['color_transform'][1]['clim'] = self._texture.clim_normalized
+        self._update_colortransform_clim()
         self.update()
 
+    def _update_colortransform_clim(self):
+        if self._need_colortransform_update:
+            # we are going to rebuild anyway so just do it later
+            return
+        try:
+            norm_clims = self._texture.clim_normalized
+        except RuntimeError:
+            return
+        else:
+            # shortcut so we don't have to rebuild the whole color transform
+            self.shared_program.frag['color_transform'][1]['clim'] = norm_clims
+
     @property
     def cmap(self):
         """Get the colormap object applied to luminance (single band) data."""


Test patch:
diff --git a/vispy/visuals/tests/test_image.py b/vispy/visuals/tests/test_image.py
--- a/vispy/visuals/tests/test_image.py
+++ b/vispy/visuals/tests/test_image.py
@@ -26,6 +26,11 @@ def test_image(is_3d):
         assert_image_approved(c.render(), "visuals/image%s.png" %
                               ("_rgb" if is_3d else "_mono"))
 
+        # change to auto clims after first draw
+        image.clim = "auto"
+        assert_image_approved(c.render(), "visuals/image%s.png" %
+                              ("_rgb" if is_3d else "_mono"))
+
 
 @requires_application()
 @pytest.mark.parametrize('gamma', [None, -0.5, "0.5"])
diff --git a/vispy/visuals/tests/test_scalable_textures.py b/vispy/visuals/tests/test_scalable_textures.py
--- a/vispy/visuals/tests/test_scalable_textures.py
+++ b/vispy/visuals/tests/test_scalable_textures.py
@@ -1,4 +1,5 @@
 import numpy as np
+import pytest
 
 from vispy.testing import run_tests_if_main
 
@@ -91,7 +92,6 @@ def test_default_clim_non_finite():
 
 
 def test_clim_handling_cpu():
-
     ref_data = np.array([[10, 10, 5], [15, 25, 15]])
 
     # f32 - auto clim
@@ -124,6 +124,15 @@ def test_clim_handling_cpu():
     assert st.clim_normalized == (0, np.inf)
     # assert np.min(st._data) == 0 - does not matter
 
+    # f32 - auto clim
+    st = CPUScaledStub()
+    st.set_clim("auto")
+    assert st.clim == "auto"
+    pytest.raises(RuntimeError, getattr, st, "clim_normalized")
+    st.scale_and_set_data(ref_data.astype(np.float32))
+    assert st.clim == (5, 25)
+    assert st.clim_normalized == (0, 1)
+
 
 def test_clim_handling_gpu():
     ref_data = np.array([[10, 10, 5], [15, 25, 15]])
@@ -158,5 +167,14 @@ def test_clim_handling_gpu():
     assert st.clim_normalized == (10, np.inf)
     # assert np.min(st._data) == 0 - does not matter
 
+    # f32 - auto clim
+    st = GPUScaledStub()
+    st.set_clim("auto")
+    assert st.clim == "auto"
+    pytest.raises(RuntimeError, getattr, st, "clim_normalized")
+    st.scale_and_set_data(ref_data.astype(np.float32))
+    assert st.clim == (5.0, 25.0)
+    assert st.clim_normalized == (5.0, 25.0)
+
 
 run_tests_if_main()