Repository: vispy/vispy

Issue:
VolumeVisual.set_data method and copy=False may lead to unexpected behavior
An issue recently arose in the napari repo (see napari/napari#575) in which calling the `VolumeVisual.set_data()` method was altering the input data and causing unexpected results (but only with float32 input data).  We eventually realized it was caused by line 477 below in the `set_data()` method, in which the data is converted to float32 with `copy=False`.

https://github.com/vispy/vispy/blob/8b61cd439076aa3f50ac5f6dacb4c0af8c1d0684/vispy/visuals/volume.py#L477-L483

I realize we can simply copy the data on our end before calling the `VolumeVisual.set_data()` method, but I'm wondering whether the default behavior shouldn't be to copy the volume before applying the clim normalizations, so as to avoid unexpectedly overwriting the input data)?  (it was particularly hard to find due to the fact that the memory address of the input volume is unchanged _only_ for float32 data)


Discussion:
Just to add a +1 here. imho a visualisation library should never modify its input data.

Short term, changing to `copy=True` would be a good fix. Longer term, should it not be possible to do the clim adjustment in the shader? Apologies for the naive question as I know very little about shaders!
Wow, what a bug. My initial feeling is that the user should be responsible for copying so we at least allow for the fast no-copy version of the code. That is only coming from a performance point of view and not one concerned about user experience. As you pointed out, only **not** copying when it is float32 data is not very useful and is strange. I vote for an explicit copy but I'd like to hear the arguments from @larsoner and @kmuehlbauer too.

I don't have a lot of experience with the VolumeVisual but do with the ImageVisual which has similar logic. Looking at the source code now it looks like the ImageVisual starts with an out of place operation so it copies the data:

https://github.com/vispy/vispy/blob/61fcfe17c6eaa6cbb7657ba4ae9cfe07957ff85f/vispy/visuals/image.py#L406

@jni You are right that shaders could do this logic. The main issue is that OpenGL 2.0 (ES 2) do not support 32-bit float textures. This means that you end up getting something equivalent to an 8-bit integer in the GPU (even though they are represented as 0-1 floats in the GL shaders) and storing 32-bit or 64-bit floats in to an 8-bit float loses you a lot of precision. By doing the clim scaling in the CPU we can reduce the amount of lost precision/data. I think because vispy was originally written with this backwards and mobile compatibility as a main goal, it did not allow the user to do things that weren't compatible. In my own work I've made a custom version of the ImageVisual which does exactly as you propose, it takes the clims, gives them to the shader, and then stores the data on the GPU as a 32-bit float (iirc). The shader then does the scaling and gets the benefit of having all of the original data locally in the GPU. I would be open to the VolumeVisual and ImageVisual getting the functionality to declare their textures in a way to support this.
@djhoese an explicit copy with the option of turning that off for performance would be good. Then the option in the keyword argument could have a fat warning that the data would me modified in-place.

Thank you for the background info. From long experience, indeed most choices make much more sense when framed in the historical context of the library!
Agreed we should have a `copy=True` parameter, silently changing people's values (and doing so by default) is dangerous
@tlambert03 or @jni Interested in making a pull request (for that sweet sweet hacktoberfest shirt)?

Resolving patch:
diff --git a/vispy/visuals/volume.py b/vispy/visuals/volume.py
--- a/vispy/visuals/volume.py
+++ b/vispy/visuals/volume.py
@@ -448,7 +448,7 @@ def __init__(self, vol, clim=None, method='mip', threshold=None,
         self.threshold = threshold if (threshold is not None) else vol.mean()
         self.freeze()
     
-    def set_data(self, vol, clim=None):
+    def set_data(self, vol, clim=None, copy=True):
         """ Set the volume data. 
 
         Parameters
@@ -457,6 +457,8 @@ def set_data(self, vol, clim=None):
             The 3D volume.
         clim : tuple | None
             Colormap limits to use. None will use the min and max values.
+        copy : bool | True
+            Whether to copy the input volume prior to applying clim normalization.
         """
         # Check volume
         if not isinstance(vol, np.ndarray):
@@ -473,8 +475,8 @@ def set_data(self, vol, clim=None):
         if self._clim is None:
             self._clim = vol.min(), vol.max()
         
-        # Apply clim
-        vol = np.array(vol, dtype='float32', copy=False)
+        # Apply clim (copy data by default... see issue #1727)
+        vol = np.array(vol, dtype='float32', copy=copy)
         if self._clim[1] == self._clim[0]:
             if self._clim[0] != 0.:
                 vol *= 1.0 / self._clim[0]


Test patch:
diff --git a/vispy/visuals/tests/test_volume.py b/vispy/visuals/tests/test_volume.py
--- a/vispy/visuals/tests/test_volume.py
+++ b/vispy/visuals/tests/test_volume.py
@@ -57,4 +57,26 @@ def test_volume_draw():
         assert_image_approved(c.render(), 'visuals/volume.png')
 
 
+@requires_pyopengl()
+def test_set_data_does_not_change_input():
+    # Create volume
+    V = scene.visuals.Volume(np.zeros((20, 20, 20)))
+
+    # calling Volume.set_data() should NOT alter the values of the input array
+    # regardless of data type
+    vol = np.random.randint(0, 200, (20, 20, 20))
+    for dtype in ['uint8', 'int16', 'uint16', 'float32', 'float64']:
+        vol_copy = np.array(vol, dtype=dtype, copy=True)
+        # setting clim so that normalization would otherwise change the data
+        V.set_data(vol_copy, clim=(0, 200))
+        assert np.allclose(vol, vol_copy)
+
+    # for those using float32 who want to avoid the copy operation,
+    # using set_data() with `copy=False` should be expected to alter the data.
+    vol2 = np.array(vol, dtype='float32', copy=True)
+    assert np.allclose(vol, vol2)
+    V.set_data(vol2, clim=(0, 200), copy=False)
+    assert not np.allclose(vol, vol2)
+
+
 run_tests_if_main()