Repository: vispy/vispy

Issue:
Regression with `Image` clim and visibility interaction
As [mentioned in this comment](https://github.com/vispy/vispy/pull/1920#discussion_r723993819), there seems to be a regression that we didn't catch with #1920. Unfortunately, I wasn't able to pinpoint the exact issue so far. @tlambert03, @djhoese, @jni , do you remember how this works?


Discussion:
What is the actual regression? You've pointed out the related issues, but not what is actually broken.
Sorry, I was not clear. #1911 introduced a fix to a bug that caused contrast limits to be wrong after the visibility of the visual changed. #1920 changed that code and, as far as I can tell, nullified #1911.

I have not tested this in vispy yet, in napari we [had added a test for this behaviour](https://github.com/napari/napari/blob/6c228a62e30ebf796f7640c90fe119153c6dd918/napari/_vispy/_tests/test_image_rendering.py#L44-L59), and that fails. I can dig a bit deeper, but I'm not familiar with this part of the codebase so I was hoping someone might spot the issue faster than me :)
Thanks for pointing out the test. I'll try to take a look some time this morning, but am also preparing for a long weekend so might not finish it today.
@brisvag I'm a little confused by that test. It takes a screenshot when the Image layer isn't visible (the default setting, right?) and then compares the screenshot when the image is visible and then makes sure that the difference is a maximum of 5? Shouldn't it be a huge difference (no image versus image)?
No, the image layer is visible by default. Doing `visible = True` is (supposedly) a no_op, since it's simply passing the call down to the vispy node. It's equivalent to `Image.visible = True` when image is already visible. AFAIK, this simply fires the `update` event of the visual node. After that, I'm a bit lost on what exactly happens cause I don't quite understand how events are magically connected in vispy.


I'm not sure why that would have an effect, but I'll look. Next question, is this failing with napari using the new `texture_format` (GPU scaling) or is it still using the old CPU scaling?

Edit: Doing `.visible = True` does call `self.update` so that's correct. I'm still not sure why that matters in this test though.
We're not setting `texture_format`, so it's still using CPU scaling. Still, I cannot reproduce in pure vispy with `examples/scene/image.py`, which as far as I can tell should be similar?
There must be something non-obvious that napari is doing that makes the test not a 1:1 translation to vispy. For example, any idea if napari sets the data to some small image size when things are invisible to save GPU memory? Based on the fix by @tlambert03 there should be something related to setting the data (changing the texture) that triggers this issue. At least as far as I understand.
Yes, there must be something. I found out that in napari, setting `visible` triggers `ImageVisual._build_texture`, while in vispy it doesn't. It's probably a bug on napari side, then!
There's always a chance that napari is trying to work around an issue in vispy or is trying to optimize something. I guess try changing napari and see what tests fail. I do see some specific checks in vispy for the thing that Talley was originally trying to fix, but I also see some things that don't immediately make sense to me. Either way, I need some code I can put in a vispy test to make it fail so I can fix it. So far I haven't been able to.
Yeah, this is not urgent :) Hopefully someone else more acquanted with napari's image layer can chip in; otherwise, this will get more attention once we actually start trying to merge the vendor-less branch I'm working on!

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
@@ -125,6 +125,10 @@ def clim_normalized(self):
             raise RuntimeError("Can't return 'auto' normalized color limits "
                                "until data has been set. Call "
                                "'scale_and_set_data' first.")
+        if self._data_dtype is None:
+            raise RuntimeError("Can't return 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
@@ -276,6 +280,10 @@ def clim_normalized(self):
             raise RuntimeError("Can't return 'auto' normalized color limits "
                                "until data has been set. Call "
                                "'scale_and_set_data' first.")
+        if self._data_limits is None:
+            raise RuntimeError("Can't return 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
diff --git a/vispy/visuals/image.py b/vispy/visuals/image.py
--- a/vispy/visuals/image.py
+++ b/vispy/visuals/image.py
@@ -521,22 +521,24 @@ def _update_method(self, view):
         self._prepare_transforms(view)
 
     def _build_texture(self):
-        pre_clims = self._texture.clim
+        try:
+            pre_clims = self._texture.clim_normalized
+        except RuntimeError:
+            pre_clims = "auto"
         pre_internalformat = self._texture.internalformat
         self._texture.scale_and_set_data(self._data)
-        post_clims = self._texture.clim
+        post_clims = self._texture.clim_normalized
         post_internalformat = self._texture.internalformat
         # color transform needs rebuilding if the internalformat was changed
         # new color limits need to be assigned if the normalized clims changed
         # otherwise, the original color transform should be fine
-        # Note that this assumes that if clim changed, clim_normalized changed
         new_if = post_internalformat != pre_internalformat
         new_cl = post_clims != pre_clims
-        if not new_if and new_cl and not self._need_colortransform_update:
+        if new_if:
+            self._need_colortransform_update = True
+        elif new_cl and not self._need_colortransform_update:
             # shortcut so we don't have to rebuild the whole color transform
             self.shared_program.frag['color_transform'][1]['clim'] = self._texture.clim_normalized
-        elif new_if:
-            self._need_colortransform_update = True
         self._need_texture_upload = False
 
     def _compute_bounds(self, axis, view):


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
@@ -263,4 +263,38 @@ def test_image_vertex_updates():
             build_vertex_mock.assert_called_once()
 
 
+@requires_application()
+@pytest.mark.parametrize(
+    ("dtype", "init_clim"),
+    [
+        (np.float32, "auto"),
+        (np.float32, (0, 5)),
+        (np.uint8, "auto"),
+        (np.uint8, (0, 5)),
+    ]
+)
+def test_change_clim_float(dtype, init_clim):
+    """Test that with an image of floats, clim is correctly set from the first try.
+
+    See https://github.com/vispy/vispy/pull/2245.
+    """
+    size = (40, 40)
+    np.random.seed(0)
+    data = (np.random.rand(*size) * 100).astype(dtype)
+
+    with TestingCanvas(size=size[::-1], bgcolor="w") as c:
+        image = Image(data=data, clim=init_clim, parent=c.scene)
+
+        # needed to properly initialize the canvas
+        c.render()
+
+        image.clim = 0, 10
+        rendered1 = c.render()
+        # set clim to same values
+        image.clim = 0, 10
+        rendered2 = c.render()
+
+        assert np.allclose(rendered1, rendered2)
+
+
 run_tests_if_main()