-
-
Notifications
You must be signed in to change notification settings - Fork 7.9k
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
Conversation
18b1e76
to
f157cba
Compare
Hi @timhoffm, may I request your review on this pr? Sorry for the tagging - I'm not sure how to request a review when there's no suggested reviewers on the GitHub interface. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should be clarified and the tests expanded a bit. What happens if A has an alpha channel and alpha is also a matrix for interpolation_stage='rgba'
? We have a behaviour for interpolation_stage='data'
so this PR should have the same behaviour if possible, and it'll need a test.
I tested with all possible combinations, each repeated with
The expected outcomes seem to align with existing test cases. I've observed conflicts when experimenting with different behaviors, for example, I'm assuming the code is fine since it passed all checks, but please let me know if I'm missing anything. |
lib/matplotlib/tests/test_image.py
Outdated
im1 = np.random.rand(3, 3, 3) | ||
im_concat_arr_a = np.concatenate((im1, np.expand_dims(arr_a, axis=-1)), axis=-1) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe this is more easy to read than concat/expand_dims:
im1 = np.random.rand(3, 3, 3) | |
im_concat_arr_a = np.concatenate((im1, np.expand_dims(arr_a, axis=-1)), axis=-1) | |
im_rgb = np.random.rand(ny, nx, 3) | |
im_rgba = np.empty(ny, nx, 4) | |
im_rgba[..., :3] = im_rgb | |
im_rgba[..., 3] = array_alpha |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I prefer to avoid creating variables that are only used once.
How about this?
im_rgb = np.random.rand(ny, nx, 3)
im_concat_arr_a = np.concatenate( # combine rgb channels with array alpha
(im_rgb, array_alpha.reshape((3, 3, 1))), axis=-1
)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry, my new commit made this outdated. Do you still prefer using temporary variables? If so I'd be happy to change it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No strong opinion. I find it slightly more readable to say "the first three components are im_rgb
, the fourth component is array_alpha
rather than "is a concantenation of im_rgb
and array_alpha
along the most inner axis.
Note that, you can write array_alpha.reshape((3, 3, 1)
as array_alpha[..., np.newaxis]
, which IMHO makes the intent a bit more clear. also concatenate(..., axis=-1)
is equivalent to dstack(...)
in this case. So
# combine rgb channels with array alpha
im_concat_arr_a = np.dstack((im_rgb, array_alpha[..., np.newaxis]))
would also be an option.
I'll leave it to you to decide which way you want to go.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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:
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>
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks right to me. I think it probably needs a very short change note under doc/api/next_api_changes/behavior/
before merging. Thanks for digging into this!
#28061 went in so unfortunately you now have merge clashes, and will need to rebase. However I think its pretty straight forward, but let us know if you have difficulties - if you havent' done these before, it'd be worth backing up your branch... |
9ac09d3
to
bb05da7
Compare
08a0b57
to
4895d6c
Compare
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) |
There was a problem hiding this comment.
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.
We are looking to include this in the 3.10.1 release, so would like to move forward. I think the only outstanding comments on this are rewriting the change note and a question as to whether an internal function requires a parameter after this change. My leaning is to accept the rewritten change note (with or without the additional comment in the proposed change above) and not touch the internal function at this time. I intend to merge the proposed change note rewrite in approximately 20 hours unless there is a) dissent expressed or b) someone else beats me to it. (I'd have said 24, but would like to get the release going earlier in my work day if I'm doing it tomorrow...) |
I'm noting that there was a failed windows test, for which the logs have expired. I'm about 95% sure that is an unrelated flaky test or two, but the tests will be rerun on merging the rewritten change note anyway, so not going to worry about it too much until then. |
11390cd
to
a1247ce
Compare
I took the liberty of rebasing to fix the docs build (circle CI runs on the branch tip (and gets its own config from the branch tip) where as GHA runs on a "synthetic" merge of the branch into the target branch. To make all our CI look "the same" we do the merge as part of the CI workflow, however this means when we bump the minimum python past what we are using on circle you we get "can not install Matplotlib" errors). |
Per the above: mild preference for scaling rather than replacing preexisting alphas with the array alpha (sorry for not commenting earlier), but won't block either way. |
…stage='rgba' in _Imagebase::_make_image
…437-on-v3.10.x Backport PR #28437 on branch v3.10.x (Respect array alpha with interpolation_stage='rgba' in _Imagebase::_make_image)
PR summary
This PR hopefully closes #28382, making sure that array alpha is respected in
_Imagebase::_make_image
when paraminterpolation_stage
is set to 'rgba'.PR checklist