igraph / python-igraph

Python interface for igraph
GNU General Public License v2.0
1.31k stars 249 forks source link

matplotlib aspect ratio of vertices (e.g. circles) #665

Open iosonofabio opened 1 year ago

iosonofabio commented 1 year ago

Taken from #638 where @alex180500 requests matplotlib plots where vertices (e.g. circles) do not become ellipses upon stretching the x-axis. Here is his example, which reproduces on my machine:

import igraph as ig
import matplotlib.pyplot as plt

g = ig.Graph.Lattice([4, 4], circular=False)
g.es["label"] = [edge.tuple for edge in g.es]
g.es["label_size"] = 8
g.vs["label"] = g.vs.indices
g.vs["label_size"] = 8

# matplotlib
fig, ax = plt.subplots(figsize=(8, 8), dpi=100)
ig.plot(g, target=ax, layout="grid", vertex_size=0.2)
ax.set_aspect(0.5)

The question from the user is whether we can make the matplotlib plot look closer to how it looks in Cairo.

Now the issue is that matplotlib has two ways of drawing e.g. circles:

The current choice works in such a way that when you zoom in the circles become bigger. That is what you would get by literally using a loupe on a raster image with the plot - i.e. what you get with Cairo + manual zoom. However, because the circle is defined in data units, it really dislikes changes of aspect ratio (e.g. zoom in only one axis), in which cases the circles become ellipses. Notice that in Cairo's result (e.g. PNG), if you zoomed in only horizontally you would get the same artifact.

The alternative would be friendly to changes of aspect ratios, but the dot size is independent of zoom, i.e. the radius is fixed in dots/pixels.

The short answer is that unless we create a customized third way of making circles in matplotlib (and triangles, etc.) which is currently out of question because of time constraints, we cannot reproduce Cairo's behaviour exactly within matplotlib.

Proposed solutions One way to reproduce something close to (but not exactly equal to) Cairo would be to rescale manually the markers for x and y axis separately (e.g. circles would become ellipses). We would need to:

  1. compute the rough x/y limits on the plot based on the vertex layout coordinates, and get the data aspect ratio (e.g. if xlim=(-2, 2) and ylim=(-1, 9), the ratio is 2/5)
  2. compute the axes size in pixels and get that aspect ratio (e.g. if the axes is 300px wide and 100px tall, it would be 3/1)
  3. combine them to compute the composite aspect ratio (e.g. 5/2 * 3/1 = 15/2)
  4. construct artists (e.g. circles -> ellipses) that use data coordinates, but skew the offsets from the center of the shape differently for x and y. In the above example, we would make the ellipsis look like an egg in data coordinates, e.g. the height is 0.15 but the width is 0.02. This way when the ellipsis gets squashed by the horizontal rectangle and then squashed again because the same pixel covers more mileage in y-data coordinates, it ends up roughly round.
  5. We then set the aspect ratio of the Axes and autoscale_view, as we currently do.

The main issue with this - in addition to the fact it's hacky - is that as soon as the user starts changing the aspect ratio or zooming around, it will all fall apart. The other problem is that an Axes does not really have a fixed number of pixels - a Figure does, but our Axes could be a subplot and the padding between subplots can be adjusted post-facto: all of that would change the aspect ratio, sometimes slightly, sometimes not so much, and circles are not circles anymore.

The other way to solve this would be to switch to scale-free ax.scatter for our vertices. That could be done but because we allow per-vertex setting of shapes, we would need to create a PolyCollection for each vertex. Not a problem, just hacky. We would also need to undo the square-root size thing like seaborn has done long ago. Finally, we would be constrained in terms of shapes to the ones covered by ax.scatter, listed in matplotlib.markers. Tbh, I'm having a look right now and there's everything we need there including custom vertices and paths.

Next steps If we implement this, especially solution 2, this will change quite significantly the way matplotlib + igraph plots behave. I'm generally in favour but it'd be best to hear a few people's feedback including @tacaswell if possible. I did notice that the old project grave (networkx + matplotlib container artist) faced the exact same issue and there did not appear to be a straight solution there.

szhorvat commented 1 year ago

Regarding the stretching of plots: IMO the right approach here is to just use the natural aspect ratio, and simply not stretch. Unfortunately, matplotlib stretches by default, and requires an extra setting to avoid this. People often skip this, which is why so many plots I see in papers are just slightly off, circles being just slightly oval ...

With almost all graph layouts, the horizontal and vertical coordinates are comparable (i.e. effectively have the same unit). Thus stretching will mess up not only the node rendering, but also the layout. Ideally, the default would be a natural aspect ratio. If someone truly needs to stretch the layout, which should be rare, they can still easily rescale the horizontal coordinates while leaving the vertical ones intact.


All that said, the feature to keep the aspect ratio of nodes even the plot aspect ratio is changed is not a bad one.

But please do consider what tradeoffs need to be made to implement this feature. If there's no tradeoff, go for it. However, if there's a drawback, or if it constrains what we can do in the future, then perhaps it's better not to do it.

ntamas commented 1 year ago

Looking at the two proposed solutions, I'm definitely in favour of option 2 instead of option 1. But, I was wondering whether this closes the door for other directions, like adding images, pie charts or other custom artists as nodes.

stale[bot] commented 1 year ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed in 14 days if no further activity occurs. Thank you for your contributions.

alex180500 commented 1 year ago

Maybe another answer could be to use a PathCollection instead but I'm not an expert in matplotlib...

iosonofabio commented 1 year ago

Thank you, this is all done in the develop branch