Skip to content

[Bug]: Cannot use empty markers in scatter #24404

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

Open
Atcold opened this issue Nov 8, 2022 · 17 comments · Fixed by #29130 · May be fixed by #25593
Open

[Bug]: Cannot use empty markers in scatter #24404

Atcold opened this issue Nov 8, 2022 · 17 comments · Fixed by #29130 · May be fixed by #25593
Labels
API: consistency Difficulty: Medium https://matplotlib.org/devdocs/devel/contribute.html#good-first-issues Good first issue Open a pull request against these issues if there are no active ones!

Comments

@Atcold
Copy link

Atcold commented Nov 8, 2022

Bug summary

I'd like to have a scatter plot with empty 'o' markers (just the edge).
This seems impossible.

Code for reproduction

from matplotlib.pylab import *
x = arange(0, 10)
scatter(x, x, c=x, facecolors='none')

Actual outcome

image

Expected outcome

Empty markers.

Matplotlib Version

3.5.1

@jklymak
Copy link
Member

jklymak commented Nov 8, 2022

It's inconvenient, but not impossible.

import numpy as np
import matplotlib.pyplot as plt
import matplotlib as mpl

x = np.arange(0, 10)
norm = plt.Normalize(0, 10)
cmap = mpl.colormaps['viridis']
cols = cmap(norm(x))
plt.scatter(x, x, facecolors='none', edgecolors=cols)

@Atcold
Copy link
Author

Atcold commented Nov 8, 2022

Okay, rephrasing.
It's impossible without additional code.

Can we add your code to this function when c=x, facecolors='none' are specified?
It seems like reasonable behaviour to me.

@jklymak
Copy link
Member

jklymak commented Nov 8, 2022

scatter already has a lot of implicit behaviour around colormapping that has accrued over the years. I would personally lean towards the less-used case needing a few more lines of explicit user code rather than more implicit behaviour. facecolors='none' doesn't necessarily mean "please add edgecolors".

A counter proposal would be that plt.scatter(x, y, facecolors='none', edgecolors=x) would do the mapping (just like c=x) for the edgecolor. Thats what I tried first, but it didn't work. I think it's usually clear that x is not an array of colors for scatter so there wouldn't be ambiguity: Nx3 or Nx4 would be treated as colors. Nx1 would be passed through norm and colormap.

@tacaswell
Copy link
Member

The default behavior is for the edge colors to follow the face color, layer with if you specify c we ignore the facecolor. I agree with @Atcold that still doing the mapping but just setting the edges is the expected behavior, however I think it might be messy internally.

I have a worry about allowing the edge color to be mapped independently. On one hand you could say the face color and edge color must share the name norm and cmap, but if it will soon seem reasonable to relax that and let each have their own and new we will end up with a ScalarMappable that is really two and the question of how you make a color bar out of that gets complicated....

Sorry for rambling post.

@jklymak
Copy link
Member

jklymak commented Nov 9, 2022

Oh, you are right - we indeed show and color the edges. In that case, I think I also agree with @Atcold that this is a "bug". Not sure how easy it is to fix without breaking the brittle dance we do here.

@Atcold
Copy link
Author

Atcold commented Nov 10, 2022

Rather than having the edge colours ‘follow’ the face colours, we could have a mapping that is given to both the face and edge. If either face or edge colour is specified, that takes precedence over the mapping.

@tacaswell tacaswell added this to the v3.7.0 milestone Nov 11, 2022
@tacaswell tacaswell added Difficulty: Medium https://matplotlib.org/devdocs/devel/contribute.html#good-first-issues Good first issue Open a pull request against these issues if there are no active ones! labels Nov 11, 2022
@tacaswell
Copy link
Member

That is reasonable, but there is a fair amount of book-keeping that will have to be reworked through (having the edge color follow the face it is easier in the case where you re-set the data / colormap / norm in that we only have to track it one place internally and the rest "just works"). Because scatter both takes mappings and fixed colors iirc using 'face' as the color of the edges also helps in that case.

The work here is:

  • switch from using 'face' as the edge color by default in the case where there is color
  • make sure the edge color also being mapped does not mean we do the data -> norm -> colormap pipelines twice
  • add tests of making empty markers with edges that are still mapped
  • extend one of the scatter examples to show this
  • need to make sure that edge color still tracks the face color is all of the cases where it currently does when changed post-hoc (which maybe annoying, but I think would be an API change). I suspect this will be the thorniest part of this work.

I think this is a good first issue because there is no API design here (that this should work seems non-controversial), but medium difficulty as it will require reading and understanding the internals of both the scatter method and the collections (sub-)classes to sort out how to do the book keeping correctly. This is not a good issue for someone new to Python, but is a good issue for someone new to working on Matplotlib.

@greglucas
Copy link
Contributor

This should probably follow similar semantics to the pcolormesh face/edge discussion as well. xref to this comment which has a nice summary plot of the combinations: #18480 (comment)

@GraceJiang0312
Copy link

GraceJiang0312 commented Dec 5, 2022

non-empty face colors and non-empty edge colors:

import numpy as np
import matplotlib.pyplot as plt

x = np.arange(0, 10)
plt.scatter(x, x, c=x, facecolors='none')

scatter_facecolor

empty face colors and non-empty edge colors:

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.cm

x = np.arange(0, 10)
norm = plt.Normalize(0, 10)
cmap = matplotlib.cm.get_cmap('viridis')
cols = cmap(norm(x))
plt.scatter(x, x, c='none', facecolors='none', edgecolors=cols)

scatter_edgecolor

@wu-theodore
Copy link

wu-theodore commented Dec 6, 2022

Hello, new contributor here. I'd like to outline my exploration/thoughts so far and invite discussion to clarify the source of the issue and what the change would look like.

I took a look at the internals of the scatter method under _axes.py and the edgecolor parameter seems to be set by _parse_scatter_color_args. There, the specified edge color argument precedence seems to be as follows:

  • kwargs['edgecolor']
  • edgecolors (is an explicit kw argument in scatter())
  • kwargs['color'] (==kwcolor)
  • 'face' if not in classic mode else None

Now, the proposed change seems to be related to the case where edgecolor and color are implicitly None. In this case, we would skip the first 3 cases and enter the 4th case, which would be to set edgecolor = 'face'. As far as I can see, this condition is implemented by

if edgecolors is None and not mpl.rcParams['_internal.classic_mode']:
     edgecolors = mpl.rcParams['scatter.edgecolors']

A fix then would be to add another check prior to this conditional that checks for whether c is defined, and if so, try to assign c to edgecolor (without breaking duplicating the data->norm->colormap pipeline, of course).

I suppose that alternatively, we could instead handle edgecolor value assignment when the face color is none within cbook._combine_masks from

if plotnonfinite and colors is None:
            c = np.ma.masked_invalid(c)
            x, y, s, edgecolors, linewidths = \
                cbook._combine_masks(x, y, s, edgecolors, linewidths)

This would more closely resemble what is already being done when the facecolor (i.e., colors) is None.

As someone new to working with MatPlotLib, I am not sure about the ramifications of this change and would like some input from a more experienced contributor. Specifically, would this create any issues with the bookkeeping in collections? What kind of unexpected implicit behaviour might arise from such a change?

@Atcold
Copy link
Author

Atcold commented Dec 6, 2022

non-empty face colors and non-empty edge colors:

import numpy as np import matplotlib.pyplot as plt

x = np.arange(0, 10) plt.scatter(x, x, c=x, facecolors='none')

scatter_facecolor

empty face colors and non-empty edge colors:

import numpy as np import matplotlib.pyplot as plt import matplotlib.cm

x = np.arange(0, 10) norm = plt.Normalize(0, 10) cmap = matplotlib.cm.get_cmap('viridis') cols = cmap(norm(x)) plt.scatter(x, x, c='none', facecolors='none', edgecolors=cols)

scatter_edgecolor

I'm not sure what this message means.
I was reporting a bug.

@mikevin920
Copy link

Hello, new contributor here. I'd like to outline my exploration/thoughts so far and invite discussion to clarify the source of the issue and what the change would look like.

I took a look at the internals of the scatter method under _axes.py and the edgecolor parameter seems to be set by _parse_scatter_color_args. There, the specified edge color argument precedence seems to be as follows:

  • kwargs['edgecolor']
  • edgecolors (is an explicit kw argument in scatter())
  • kwargs['color'] (==kwcolor)
  • 'face' if not in classic mode else None

Now, the proposed change seems to be related to the case where edgecolor and color are implicitly None. In this case, we would skip the first 3 cases and enter the 4th case, which would be to set edgecolor = 'face'. As far as I can see, this condition is implemented by

if edgecolors is None and not mpl.rcParams['_internal.classic_mode']:
     edgecolors = mpl.rcParams['scatter.edgecolors']

A fix then would be to add another check prior to this conditional that checks for whether c is defined, and if so, try to assign c to edgecolor (without breaking duplicating the data->norm->colormap pipeline, of course).

I suppose that alternatively, we could instead handle edgecolor value assignment when the face color is none within cbook._combine_masks from

if plotnonfinite and colors is None:
            c = np.ma.masked_invalid(c)
            x, y, s, edgecolors, linewidths = \
                cbook._combine_masks(x, y, s, edgecolors, linewidths)

This would more closely resemble what is already being done when the facecolor (i.e., colors) is None.

As someone new to working with MatPlotLib, I am not sure about the ramifications of this change and would like some input from a more experienced contributor. Specifically, would this create any issues with the bookkeeping in collections? What kind of unexpected implicit behaviour might arise from such a change?

Assuming the first suggestion will fix this bug, the book-keeping in the collections sub-class for this issue would behave the same way as expected if these two test cases passed.

   def test_scatter_unfilled(self):
       coll = plt.scatter([0, 1, 2], [1, 3, 2], c=['0.1', '0.3', '0.5'],
                          marker=mmarkers.MarkerStyle('o', fillstyle='none'),
                          linewidths=[1.1, 1.2, 1.3])
       assert coll.get_facecolors().shape == (0, 4)  # no facecolors
       assert_array_equal(coll.get_edgecolors(), [[0.1, 0.1, 0.1, 1],
                                                  [0.3, 0.3, 0.3, 1],
                                                  [0.5, 0.5, 0.5, 1]])
       assert_array_equal(coll.get_linewidths(), [1.1, 1.2, 1.3])
 
   @mpl.style.context('default')
   def test_scatter_unfillable(self):
       coll = plt.scatter([0, 1, 2], [1, 3, 2], c=['0.1', '0.3', '0.5'],
                          marker='x',
                          linewidths=[1.1, 1.2, 1.3])
       assert_array_equal(coll.get_facecolors(), coll.get_edgecolors())
       assert_array_equal(coll.get_edgecolors(), [[0.1, 0.1, 0.1, 1],
                                                  [0.3, 0.3, 0.3, 1],
                                                  [0.5, 0.5, 0.5, 1]])
       assert_array_equal(coll.get_linewidths(), [1.1, 1.2, 1.3])

@Gairick52
Copy link

@Atcold can you please assign this issue to me,i want to work on this

@jklymak
Copy link
Member

jklymak commented Jan 9, 2023

@melissawm
Copy link
Member

Hello @Gairick52 - as pointed above we don't assign issues. As long as there is no Pull Request associated with the issue, you can feel free to work on it. Please take a look at our Contributing guide and if you have specific questions you can also join our gitter room or one of our monthly New contributor meetings (on Zoom). Cheers!

@claytharrison
Copy link

Sorry but how does #29130 close this? Nothing in this issue is about problems using c and facecolors at the same time , it's about being unable to use c for just edgecolors while setting facecolors to "none". It's still impossible to have empty scatter symbols using just keyword args - which gets annoying when using pyplot through wrappers like e.g. Xarray's.

@jklymak jklymak reopened this Feb 19, 2025
@story645
Copy link
Member

Sorry but how does #29130 close this

Sorry, GitHub auto close behavior, should have double checked what it was auto closing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
API: consistency Difficulty: Medium https://matplotlib.org/devdocs/devel/contribute.html#good-first-issues Good first issue Open a pull request against these issues if there are no active ones!
Projects
None yet