Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Respect array alpha with interpolation_stage='rgba' in _Imagebase::_make_image #28437

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Feb 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions doc/api/next_api_changes/behavior/28437-CH.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
*alpha* parameter handling on images
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

When passing and array to ``imshow(..., alpha=...)``, the parameter was silently ignored
if the image data was a RGB or RBGA image or if :rc:`interpolation_state`
resolved to "rbga".

This is now fixed, and the alpha array overwrites any previous transparency information.
32 changes: 21 additions & 11 deletions lib/matplotlib/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -501,17 +501,27 @@ def _make_image(self, A, in_bbox, out_bbox, clip_bbox, magnification=1.0,
if A.ndim == 2: # interpolation_stage = 'rgba'
self.norm.autoscale_None(A)
A = self.to_rgba(A)
alpha = self._get_scalar_alpha()
if A.shape[2] == 3:
# No need to resample alpha or make a full array; NumPy will expand
# this out and cast to uint8 if necessary when it's assigned to the
# alpha channel below.
output_alpha = (255 * alpha) if A.dtype == np.uint8 else alpha
else:
output_alpha = _resample( # resample alpha channel
self, A[..., 3], out_shape, t, alpha=alpha)
output = _resample( # resample rgb channels
self, _rgb_to_rgba(A[..., :3]), out_shape, t, alpha=alpha)
Comment on lines -511 to -514
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAICS, these two calls were the only ones using the _resample(..., alpha=...) parameter. If it is not needed anymore, we should remove it.

alpha = self.get_alpha()
if alpha is None: # alpha parameter not specified
if A.shape[2] == 3: # image has no alpha channel
output_alpha = 255 if A.dtype == np.uint8 else 1.0
else:
output_alpha = _resample( # resample alpha channel
self, A[..., 3], out_shape, t)
output = _resample( # resample rgb channels
self, _rgb_to_rgba(A[..., :3]), out_shape, t)
elif np.ndim(alpha) > 0: # Array alpha
# user-specified array alpha overrides the existing alpha channel
output_alpha = _resample(self, alpha, out_shape, t)
output = _resample(
self, _rgb_to_rgba(A[..., :3]), out_shape, t)
else: # Scalar alpha
if A.shape[2] == 3: # broadcast scalar alpha
output_alpha = (255 * alpha) if A.dtype == np.uint8 else alpha
else: # or apply scalar alpha to existing alpha channel
output_alpha = _resample(self, A[..., 3], out_shape, t) * alpha
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be checked, but I think and explicit alpha should overwrite existing alpha rather than scaling it.

Copy link
Contributor Author

@chaoyihu chaoyihu Jul 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing this will cause conflicts:

I've observed conflicts when experimenting with different behaviors, for example, test_image::test_image_composite_alpha fails when I change 2.b. to replacing instead of multiplying.

test_image::test_image_composite_alpha creates color stripes with fading alpha peaking at 1.0 at the center, and applied scalar alpha to the stripe at different locations, for example:

    arr = np.zeros((11, 21, 4))
    arr[:, :, 0] = 1
    arr[:, :, 3] = np.concatenate(
        (np.arange(0, 1.1, 0.1), np.arange(0, 1, 0.1)[::-1]))
    ax.imshow(arr, extent=[1, 2, 5, 0], alpha=0.3)

And the baseline image uses a combined alpha, see below.
If the alpha channel was overwritten by a scalar, the fading effects would have been removed.
image_composite_alpha

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok there is currently a different behavior between scalar and array alpha if the image already has an alpha.

The documentation is not clear about the intended behavior:

The alpha blending value, between 0 (transparent) and 1 (opaque). If alpha is an array, the alpha blending values are applied pixel by pixel, and alpha must have the same shape as X.

My point is that this behavior is quite surprising and inconsistent:

  • scalar alpha: multiply the alpha channel with user-specified scalar.
  • array alpha: replace the alpha channel with user-specified array.

In all other cases, when we have a rgba color and an explicit alpha, the explicit alpha overwrites the rgba alpha; effectively through https://matplotlib.org/stable/api/_as_gen/matplotlib.colors.to_rgba.html / https://matplotlib.org/stable/api/_as_gen/matplotlib.colors.to_rgba_array.html

Additionally, I would have expected that scalar and array behave the same - either the scalar scales all elements and then the array also scales element-wise, or the both the scalar and the array overwrite.

Since tests cover the current behavior, this PR should be fine in that it does not introduce a behavior change. OTOH I'm wondering whether this behavior is really intended. The test and alpha handling came in in #1955, but this PR only states to implement missing alpha handling. There has been no discussion whether overwriting or multiplying is the right choice.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My point is that this behavior is quite surprising and inconsistent

The documentation is not clear about the intended behavior

It should be possible to make the behaviors consistent. Forcing scalar alpha to overwrite causes conflicts, while multiplying array alpha pixel-wise does not seem to break any existing test case in test_image.py. Not sure if it has any side effects since I haven't ran the full test suite. Do you want me to try to change it this way?

This is a design choice so it's up to you. Either way, I can clarify the behavior in the docstring of imshow.

when we have a rgba color and an explicit alpha, the explicit alpha overwrites the rgba alpha; effectively through matplotlib.colors.to_rgba / matplotlib.colors.to_rgba_array

I see two methods of the same name to_rgba in the code base:

  • colors.to_rgba: If alpha is given, force the alpha value of the returned RGBA tuple to alpha.
  • cm.ScalarMappable.to_rgba: If the last dimension is 4, the alpha kwarg is ignored; it does not replace the preexisting alpha.

The second one is directly invoked in image.py thus looks more relevant to me. It shows yet another different behavior, but I think it is specific to this method for utility purposes and does not imply any design consideration about alpha handling.

I'm not sure about to_rgba_array, but intuitively I think it can be used as a setter and not for visualization purposes, so replacing preexisting alpha makes sense in its case.

The test and alpha handling came in in #1955, but this PR only states to implement missing alpha handling.

I think this PR and its related test case should be deemed correct - intended or not, because it does not cause conflicts anyway, either if we choose to keep the current behavior, or multiply array alpha.

Copy link
Member

@timhoffm timhoffm Feb 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jklymak @anntzer I would like your opinion as image/interpolation experts.

The underlying problem here is that for RGB and RGBA images the kwarg alpha has been ignored if it was an array. The solution for RGB is straight forward (just use the given alpha array). However, what is the expected behavior for RGBA images, i.e. imshow(rgba_array, alpha=alpha)?

Should the kwarg alpha replace the intrinsic alpha channel? Generally I would say yes, this is how alpha also works in other cases (which is coded in color.to_rgba_array). The problem is that a scalar alpha kwarg does not exhibit this behavior, instead imshow(rgba_array, alpha=0.5) will scale the intrinsic alpha channel rather than replacing it.

Basically we have three options:

  • array alpha replaces intrinsic alpha - we live with the API inconsistency (scalar alpha scales, array alpha replaces)
  • array alpha scales intrinsic alpha - this is consistent with the existing scalar alpha for images, but different to the usual behavoir of alpha kwargs.
  • array alpha and scalar alpha replace intrinsic alpha, and intrinsic - this means an API change for scalar alpha on images.

This is the current behavior:

image

Code
N = 20
img_data = np.random.rand(N, N)
rgb_data = np.random.rand(N, N, 3)
rgba_data = np.random.rand(N, N, 4)
alpha = np.ones_like(img_data)
alpha[:, 0:N//2] = 0.2


with plt.rc_context({'image.interpolation_stage': 'rgba'}):
    fig, axs = plt.subplots(3, 3, figsize=(6, 6))
    for ax in axs.flat:
        ax.set(xticks=[], yticks=[])
    # no alpha
    axs[0, 0].imshow(img_data)
    axs[0, 1].imshow(rgb_data)
    axs[0, 2].imshow(rgba_data)
    # scalar alpha
    axs[1, 0].imshow(img_data, alpha=0.2)
    axs[1, 1].imshow(rgb_data, alpha=0.2)
    axs[1, 2].imshow(rgba_data, alpha=0.2)
    # array alpha
    axs[2, 0].imshow(img_data, alpha=alpha)
    axs[2, 1].imshow(rgb_data, alpha=alpha)
    axs[2, 2].imshow(rgba_data, alpha=alpha)
    
    axs[0, 0].set_title('scalar data')
    axs[0, 1].set_title('RGB data')
    axs[0, 2].set_title('RGBA data')
    axs[0, 0].set_ylabel('no alpha')
    axs[1, 0].set_ylabel('scalar alpha')
    axs[2, 0].set_ylabel('array alpha')
```

</details>

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can live with that. Then let's take this PR basically as is so that we can release it as a bugfix in 3.10.1, which is the important part. We can then decide how to change/migrate the current behavior for RGBA and scaler alpha.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good to me. But I know @tacaswell and @anntzer have thoughts on this as well.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree taking this as is.

I however disagree that multiplying in the scalar case for already RGBA data is inconsistent. In the case of lines the layer we add when rendering has variable alpha if anti-aliasing is turned on. When we set the alpha on a line we expect the result to be the combination of the AA alpha + the user alpha.

By analogy, if the user provides us an RGBA image with non-unifrom alpha and provides an artist-level alpha the expectation would be that they are combined not replaced.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From a consistency PoV I have a mild preference for scaling, which at least is internally consistent within imshow() (e.g. if we decide to also handle e.g. alpha=[[scalar]], a 1x1 array which is broadcastable to a 2D array, our life will be easier if we choose to scale) even though it is not consistent with other functions of matplotlib (but that's a secondary concern IMO).
But I can't say that solution is really great either, so I don't feel very strongly either way.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The difference here, versus an antialiased line, is that the user has access to the alpha channel. I still think simpler is better. If someone sets alpha with a scalar and they meant it to be multiplicative, they can go back to their original alpha array and do the multiplication easily enough.

output = _resample(
self, _rgb_to_rgba(A[..., :3]), out_shape, t)
output[..., 3] = output_alpha # recombine rgb and alpha

# output is now either a 2D array of normed (int or float) data
Expand Down
46 changes: 46 additions & 0 deletions lib/matplotlib/tests/test_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -1711,3 +1711,49 @@ def test_resample_dtypes(dtype, ndim):
axes_image = ax.imshow(data)
# Before fix the following raises ValueError for some dtypes.
axes_image.make_image(None)[0]


@pytest.mark.parametrize('intp_stage', ('data', 'rgba'))
@check_figures_equal()
def test_interpolation_stage_rgba_respects_alpha_param(fig_test, fig_ref, intp_stage):
axs_tst = fig_test.subplots(2, 3)
axs_ref = fig_ref.subplots(2, 3)
ny, nx = 3, 3
scalar_alpha = 0.5
array_alpha = np.random.rand(ny, nx)

# When the image does not have an alpha channel, alpha should be specified
# by the user or default to 1.0
im_rgb = np.random.rand(ny, nx, 3)
im_concat_default_a = np.ones((ny, nx, 1)) # alpha defaults to 1.0
im_rgba = np.concatenate( # combine rgb channels with array alpha
(im_rgb, array_alpha.reshape((ny, nx, 1))), axis=-1
)
axs_tst[0][0].imshow(im_rgb)
axs_ref[0][0].imshow(np.concatenate((im_rgb, im_concat_default_a), axis=-1))
axs_tst[0][1].imshow(im_rgb, interpolation_stage=intp_stage, alpha=scalar_alpha)
axs_ref[0][1].imshow(
np.concatenate( # combine rgb channels with broadcasted scalar alpha
(im_rgb, scalar_alpha * im_concat_default_a), axis=-1
), interpolation_stage=intp_stage
)
axs_tst[0][2].imshow(im_rgb, interpolation_stage=intp_stage, alpha=array_alpha)
axs_ref[0][2].imshow(im_rgba, interpolation_stage=intp_stage)

# When the image already has an alpha channel, multiply it by the
# scalar alpha param, or replace it by the array alpha param
axs_tst[1][0].imshow(im_rgba)
axs_ref[1][0].imshow(im_rgb, alpha=array_alpha)
axs_tst[1][1].imshow(im_rgba, interpolation_stage=intp_stage, alpha=scalar_alpha)
axs_ref[1][1].imshow(
np.concatenate( # combine rgb channels with scaled array alpha
(im_rgb, scalar_alpha * array_alpha.reshape((ny, nx, 1))), axis=-1
), interpolation_stage=intp_stage
)
new_array_alpha = np.random.rand(ny, nx)
axs_tst[1][2].imshow(im_rgba, interpolation_stage=intp_stage, alpha=new_array_alpha)
axs_ref[1][2].imshow(
np.concatenate( # combine rgb channels with new array alpha
(im_rgb, new_array_alpha.reshape((ny, nx, 1))), axis=-1
), interpolation_stage=intp_stage
)
Loading