paulbrodersen / netgraph

Publication-quality network visualisations in python
GNU General Public License v3.0
660 stars 39 forks source link

Unable to scale figure with Netgraph on PyQt window #46

Closed LBeghini closed 1 year ago

LBeghini commented 2 years ago

Hello!

I am trying to embed Netgraph's plots inside my PyQt application, however I am not being able to make the figure occupy the entire window nor scale with it. The graphs keep touching only one of the borders of the figure, and I can't make the figure in only one direction (x or y).

It's been a while since I started to try to make this right, looking into MatPlotLib's documentations. I have even written a few questions at StackOverflow about it, however without any success.(Scalable MatPlotLib Figure with PyQt window and FigureCanvas not entirely filing PyQt Window)

The behavior I face can be seen in the image:

image

It is possible to see that the graph can't touch the horizontal borders of the Window, doesn't matter how I scale it. I also keep receiving this Warnings whenever I touch the blank space between the border of the Figure and the border of the Window.

What I want is for the graph to always touch the border of the window.

Could you help me somehow? At this point I don't know if this could be something related to the Netgraph library itself. Here is the code I am using for testing:

import sys
import matplotlib; matplotlib.use("Qt5Agg")

from PyQt5 import QtWidgets
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg
import matplotlib.pyplot as plt
from netgraph import EditableGraph

class MplCanvas(FigureCanvasQTAgg):
    def __init__(self, parent=None):
        figure = plt.figure()
        figure.set_figheight(8)
        figure.set_figwidth(6)
        plt.tight_layout()
        figure.patch.set_visible(False)
        super(MplCanvas, self).__init__(figure)
        self.setParent(parent)
        self.ax = plt.axes([0,0,1,1], frameon=False)
        self.ax.axis('off')
        self.ax.get_xaxis().set_visible(False)
        self.ax.get_yaxis().set_visible(False)
        self.graph = EditableGraph([(0, 1)], ax=self.ax)
        plt.autoscale(axis='both', tight=True)
        self.updateGeometry()

class MainWindow(QtWidgets.QMainWindow):
    def __init__(self, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)

        self.canvas = MplCanvas(self)
        self.lbl = QtWidgets.QLabel(self)
        self.setCentralWidget(self.canvas)

def main():

    app = QtWidgets.QApplication(sys.argv)
    w = MainWindow()
    w.show()
    app.exec_()

if __name__ == "__main__":
    main()
paulbrodersen commented 2 years ago

I also keep receiving this Warnings whenever I touch the blank space between the border of the Figure and the border of the Window.

That is the expected/desired behaviour. You can suppress warnings easily, though; some options are listed here.

LBeghini commented 2 years ago

This warnings are not much of a problem, I just mentioned cause I thought it could help in the issue somehow, since it mention about the axis limit and it is something configurable in MatPlotLib. I tried to change this config using the steps mentioned here, but with no success either.

paulbrodersen commented 2 years ago

It is possible to see that the graph can't touch the horizontal borders of the Window, doesn't matter how I scale it.

The axis is tight around the graph. You can tell by the clipping of the node artists but you can confirm it if you turn the axis frame back on (self.ax.axis('on') or self.ax.set_frame_on(True)). So the issue is the aspect ratio of the axis (which is square) compared to the aspect ratio of the figure (which is just a rectangle).

The axis is square, as netgraph by default enforces an equal x and y aspect, and the default scale is (1,1).

The figure is a rectangle as you are setting them to be rectangular:

figure.set_figheight(8)
figure.set_figwidth(6)

If you want the figure to be tight around the axis, you either need to make the figure square, or you need to make the scale rectangular (e.g. scale=(1.5, 2)). You can also set the axis aspect to auto but then the artists will look slightly distorted.

paulbrodersen commented 2 years ago

Just to be perfectly clear, you should get a tighter fit with:

self.graph = EditableGraph([(0, 1)], scale=(1.5, 2), ax=self.ax)
LBeghini commented 2 years ago

I also want to make the figure tight with the QtWindow. Even if the figure is squared, it still have this space where I can not stretch the drawing of the graph.

image

import sys
import matplotlib; matplotlib.use("Qt5Agg")

from PyQt5 import QtWidgets
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg
import matplotlib.pyplot as plt
from netgraph import EditableGraph

class MplCanvas(FigureCanvasQTAgg):
    def __init__(self, parent=None):
        figure = plt.figure()
        figure.set_figheight(6)
        figure.set_figwidth(6) #changed here, now it is squared
        plt.tight_layout()
        figure.patch.set_visible(False)
        super(MplCanvas, self).__init__(figure)
        self.setParent(parent)
        self.ax = plt.axes([0,0,1,1], frameon=False)
        self.ax.axis('off')
        self.ax.get_xaxis().set_visible(False)
        self.ax.get_yaxis().set_visible(False)
        # i've played with the scale a little bit. at first it perfectly fits the window but it does not follow it as i scale horizontally 
        self.graph = EditableGraph([(0, 1)], scale=(2, 2), ax=self.ax)
        plt.autoscale(axis='both', tight=True)
        self.updateGeometry()

class MainWindow(QtWidgets.QMainWindow):
    def __init__(self, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)

        self.canvas = MplCanvas(self)
        self.lbl = QtWidgets.QLabel(self)
        self.setCentralWidget(self.canvas)

def main():

    app = QtWidgets.QApplication(sys.argv)
    w = MainWindow()
    w.show()
    app.exec_()

if __name__ == "__main__":
    main()

I've left comments on the code

paulbrodersen commented 2 years ago

On my machine, your code now produces a tight fitting window.

import sys
import matplotlib; matplotlib.use("Qt5Agg")

from PyQt5 import QtWidgets
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg
import matplotlib.pyplot as plt
from netgraph import EditableGraph

class MplCanvas(FigureCanvasQTAgg):
    def __init__(self, parent=None):
        figure = plt.figure()
        figure.set_figheight(8)
        figure.set_figwidth(6)
        plt.tight_layout()
        figure.patch.set_visible(False)
        super(MplCanvas, self).__init__(figure)
        self.setParent(parent)
        self.ax = plt.axes([0,0,1,1], frameon=True)
        self.ax.axis('off')
        self.ax.get_xaxis().set_visible(False)
        self.ax.get_yaxis().set_visible(False)
        self.graph = EditableGraph([(0, 1)], scale=(1.5, 2), ax=self.ax)
        plt.autoscale(axis='both', tight=True)
        self.updateGeometry()

class MainWindow(QtWidgets.QMainWindow):
    def __init__(self, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)

        self.canvas = MplCanvas(self)
        self.lbl = QtWidgets.QLabel(self)
        self.setCentralWidget(self.canvas)

def main():

    app = QtWidgets.QApplication(sys.argv)
    w = MainWindow()
    w.show()
    app.exec_()

if __name__ == "__main__":
    main()

Screenshot from 2022-06-08 14-41-34

paulbrodersen commented 2 years ago

Are you sure, you are not changing the figure dimensions in some other way, e.g. by maximizing the window?

LBeghini commented 2 years ago

So, the behavior I want is to scale with the window. I am scaling it. Vertical scaling works perfectly as it maximizes the graph. Horizontally it creates this invisible border that I would like to stretch as the window increases.

paulbrodersen commented 2 years ago

Then you will probably need to write a custom class that handles ResizeEvents.

class ResizableGraph(EditableGraph):

    def __init__(self, *args, **kwargs):

        super().__init__(*args, **kwargs)

        self.origin = kwargs["origin"]
        self.scale = kwargs["scale"]
        self.figure_width = self.fig.width
        self.figure_height = self.fig.height
        self.fig.canvas.mpl_connect('resize_event', self._on_resize)

    def _on_resize(event):
        # TODO determine new figure dimensions
        # TODO determine new scale
        # TODO rescale node positions

        # redraw
        self._update_node_artists(self.nodes)
        self._update_node_label_positions()
        self._update_edges(self.edges)
        self._update_edge_label_positions(edges)
        self.fig.canvas.draw()
LBeghini commented 2 years ago

I have gotten here so far.

import sys
import matplotlib; matplotlib.use("Qt5Agg")

from PyQt5 import QtWidgets
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg
import matplotlib.pyplot as plt
from netgraph import EditableGraph

class ResizableGraph(EditableGraph):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.origin = kwargs["origin"]
        self.scale = kwargs["scale"]
        # Can not use these width and height cause it throws 
        # an Attribute Error saying there is no such parameter in fig
        # self.figure_width = self.fig.width     
        # self.figure_height = self.fig.height
        self.fig.canvas.mpl_connect('resize_event', self._on_resize)

    def _on_resize(self, event):
        print(event)

        height = event.height/self.fig.dpi
        width = event.width / self.fig.dpi

        # TODO determine new figure dimensions
        self.fig.set_figheight(height)
        self.fig.set_figwidth(width)

        # TODO determine new scale
        # Here is a calculus to define the proportion of the scale
        if height > width:
            self.scale = (1, height / width)
        elif width > height:
            self.scale = (width / height, 1)
        else:
            self.scale = (1, 1)

        # TODO rescale node positions

        # redraw
        self._update_node_artists(self.nodes)
        self._update_node_label_positions()
        self._update_edges(self.edges)
        self._update_edge_label_positions(self.edges)
        self.fig.canvas.draw()

class MplCanvas(FigureCanvasQTAgg):
    def __init__(self, parent=None):
        figure = plt.figure()
        figure.set_figheight(6)
        figure.set_figwidth(6)
        plt.tight_layout()
        figure.patch.set_visible(False)
        super(MplCanvas, self).__init__(figure)
        self.setParent(parent)
        self.ax = plt.axes([0, 0, 1, 1], frameon=False)
        self.ax.axis('off')
        self.ax.get_xaxis().set_visible(False)
        self.ax.get_yaxis().set_visible(False)
        self.graph = ResizableGraph([(0, 1)], scale=(1, 1), ax=self.ax, origin=(0, 0))
        plt.autoscale(axis='both', tight=True)
        self.updateGeometry()

class MainWindow(QtWidgets.QMainWindow):
    def __init__(self, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)

        self.canvas = MplCanvas(self)
        self.lbl = QtWidgets.QLabel(self)
        self.setCentralWidget(self.canvas)

def main():

    app = QtWidgets.QApplication(sys.argv)
    w = MainWindow()
    w.show()
    app.exec_()

if __name__ == "__main__":
    main()

I have tested the scale along with different window sizes and it worked with the calculus I mention in the code. However, I am not being able to change it inside the _on_resize function, and I am not sure why. The scale visually remains the same.

Also, when you say to rescale the node positions, I am not sure what do you mean.

paulbrodersen commented 2 years ago

Also, when you say to rescale the node positions, I am not sure what do you mean.

I have a newborn at home at the moment, so I am getting very little sleep. Take everything that I say with a grain of salt.

That being said, I had imagined that on each resize, the node positions are stretched such that they fill the axis. This is my current attempt; however, there still is a bug with the stretching of the node positions, and I don't see it right now.

import sys
import numpy as np
import matplotlib; matplotlib.use("Qt5Agg")

from PyQt5 import QtWidgets
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg
import matplotlib.pyplot as plt
from netgraph import EditableGraph

class ResizableGraph(EditableGraph):

    def __init__(self, *args, **kwargs):

        super().__init__(*args, **kwargs)

        kwargs.setdefault('origin', (0., 0.))
        kwargs.setdefault('scale', (1., 1.))
        self.origin = kwargs["origin"]
        self.scale = kwargs["scale"]
        self.figure_width = self.fig.bbox.width
        self.figure_height = self.fig.bbox.height
        self.fig.canvas.mpl_connect('resize_event', self._on_resize)

    def _on_resize(self, event):
        scale_x_by = self.fig.bbox.width / self.figure_width
        scale_y_by = self.fig.bbox.height / self.figure_height

        # rescale node positions
        for node, (x, y) in self.node_positions.items():
            new_x = ((x - self.origin[0]) * scale_x_by) + self.origin[0]
            new_y = ((y - self.origin[1]) * scale_y_by) + self.origin[1]
            self.node_positions[node] = np.array([new_x, new_y])

        # update
        self.figure_width = self.fig.bbox.width
        self.figure_height = self.fig.bbox.height
        self.scale = (scale_x_by * self.scale[0], scale_y_by * self.scale[1])

        # redraw
        self._update_node_artists(self.nodes)
        self._update_node_label_positions()
        self._update_edges(self.edges)
        self._update_edge_label_positions(self.edges)
        self.ax.autoscale_view()
        self.fig.canvas.draw()

class MplCanvas(FigureCanvasQTAgg):
    def __init__(self, parent=None):
        figure = plt.figure()
        figure.set_figheight(8)
        figure.set_figwidth(6)
        # plt.tight_layout()
        figure.patch.set_visible(False)
        super(MplCanvas, self).__init__(figure)
        self.setParent(parent)
        self.ax = plt.axes([0, 0, 1, 1], frameon=True)
        self.graph = ResizableGraph([(0, 1)], scale=(1.5, 2), ax=self.ax)
        # self.ax.set_xlim(0, 1.5)
        # self.ax.set_ylim(0, 2)
        self.graph.ax.set_frame_on(True)
        plt.autoscale(axis='both', tight=True)
        self.updateGeometry()

class MainWindow(QtWidgets.QMainWindow):
    def __init__(self, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)

        self.canvas = MplCanvas(self)
        self.lbl = QtWidgets.QLabel(self)
        self.setCentralWidget(self.canvas)

def main():

    app = QtWidgets.QApplication(sys.argv)
    w = MainWindow()
    w.show()
    app.exec_()

if __name__ == "__main__":
    main()
LBeghini commented 2 years ago

🎉 Congratulations on the newborn! And a special thanks for your help in our request.

First of all, it is a nice feature to rescale the node positions when resize the window, but it is not a requirement. However, I really think that is related to the main problem I imagine we are facing:

The self.scale attribution is not working. From some tests I have been doing, when I set the scale as soon as I declare the graph, I can make it match the window size if the window size is squared.

For exemple with a window size of 700x700:

import sys
import matplotlib; matplotlib.use("Qt5Agg")

from PyQt5 import QtWidgets
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg
import matplotlib.pyplot as plt
from netgraph import EditableGraph

class MplCanvas(FigureCanvasQTAgg):
    def __init__(self, parent=None):
        figure = plt.figure()
        super(MplCanvas, self).__init__(figure)
        self.setParent(parent)
        self.ax = plt.axes([0, 0, 1, 1], frameon=True)
        self.graph = EditableGraph([(0, 1)], ax=self.ax)

class MainWindow(QtWidgets.QMainWindow):
    def __init__(self, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)

        self.canvas = MplCanvas(self)
        self.lbl = QtWidgets.QLabel(self)
        self.setCentralWidget(self.canvas)

        self.setFixedHeight(700)
        self.setFixedWidth(700)

def main():
    app = QtWidgets.QApplication(sys.argv)
    w = MainWindow()
    w.show()
    app.exec_()

if __name__ == "__main__":
    main()

With the above code, I have a figure that fill the window (the graph can touch the edges): image

However, if I change the size of the window to something not squared, for example (600x500):

import sys
import matplotlib; matplotlib.use("Qt5Agg")

from PyQt5 import QtWidgets
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg
import matplotlib.pyplot as plt
from netgraph import EditableGraph

class MplCanvas(FigureCanvasQTAgg):
    def __init__(self, parent=None):
        figure = plt.figure()
        super(MplCanvas, self).__init__(figure)
        self.setParent(parent)
        self.ax = plt.axes([0, 0, 1, 1], frameon=True)
        self.graph = EditableGraph([(0, 1)], ax=self.ax)

class MainWindow(QtWidgets.QMainWindow):
    def __init__(self, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)

        self.canvas = MplCanvas(self)
        self.lbl = QtWidgets.QLabel(self)
        self.setCentralWidget(self.canvas)

        self.setFixedHeight(600)
        self.setFixedWidth(500)

def main():
    app = QtWidgets.QApplication(sys.argv)
    w = MainWindow()
    w.show()
    app.exec_()

if __name__ == "__main__":
    main()

The graph can not touch the edges anymore: image

However, if I apply the proportion calculus I have mentioned before:

if height > width:
    self.scale = (1, height / width)
elif width > height:
    self.scale = (width / height, 1)
else:
    self.scale = (1, 1)

I get as a result a scale of (1, 1.2). And then if I changed it in the code:

import sys
import matplotlib; matplotlib.use("Qt5Agg")

from PyQt5 import QtWidgets
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg
import matplotlib.pyplot as plt
from netgraph import EditableGraph

class MplCanvas(FigureCanvasQTAgg):
    def __init__(self, parent=None):
        figure = plt.figure()
        super(MplCanvas, self).__init__(figure)
        self.setParent(parent)
        self.ax = plt.axes([0, 0, 1, 1], frameon=True)
        self.graph = EditableGraph([(0, 1)], scale=(1, 1.2), ax=self.ax) #changed here

class MainWindow(QtWidgets.QMainWindow):
    def __init__(self, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)

        self.canvas = MplCanvas(self)
        self.lbl = QtWidgets.QLabel(self)
        self.setCentralWidget(self.canvas)

        self.setFixedHeight(600)
        self.setFixedWidth(500)

def main():
    app = QtWidgets.QApplication(sys.argv)
    w = MainWindow()
    w.show()
    app.exec_()

if __name__ == "__main__":
    main()

Then it matches the borders again: image

What makes me think that the scale is not being updated (and that is why the frame in your example remains the same even with the graph being stretched). It seems like the scale is set only once and remains the same, what would explain the bug behavior you have mentioned.

Something else that got me wondering the about the problem of the scale not being updated is that in the code it does not have a "self" keyword attached to it. Wouldn't that make the scale declared on the new class ResizableGraph be unrelated to the original scale?

I am sorry for that quantity of code and images. Hope it brings us some clarification somehow.

LBeghini commented 1 year ago

Hello @paulbrodersen ! It is been a while since the last time I posted here, I had to stop with the scope of our project for a while. Now we are getting back to it, but still there is this issue that we need to fix before launching a new release.

Did you have a chance to read the last comment I left here? Did it help somehow?

If you launched any new update that could have interfered or improved this specific part, let me know so I could test again

paulbrodersen commented 1 year ago

Hi @LBeghini, I haven't looked into this issue further. I will take another stab at the problem early next week (I am unavailable most of today).

paulbrodersen commented 1 year ago

My last proposed solution was actually quite close. I just had to manually force an update of the axis limits as autoscale_view was not doing its job for some reason.

As long as the matplotlib figure has the same (initial) aspect ratio (via figsize) as the (initial) scale argument supplied to netgraph, the following code should work.

import sys
import numpy as np
import matplotlib; matplotlib.use("Qt5Agg")
import matplotlib.pyplot as plt

from PyQt5 import QtWidgets
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from netgraph import EditableGraph

class ResizableGraph(EditableGraph):

    def __init__(self, *args, **kwargs):

        super().__init__(*args, **kwargs)

        kwargs.setdefault('origin', (0., 0.))
        kwargs.setdefault('scale', (1., 1.))
        self.origin = kwargs["origin"]
        self.scale = kwargs["scale"]
        self.figure_width = self.fig.bbox.width
        self.figure_height = self.fig.bbox.height
        self.fig.canvas.mpl_connect('resize_event', self._on_resize)

    def _on_resize(self, event, pad=0.05):
        # determine ratio new : old
        scale_x_by = self.fig.bbox.width / self.figure_width
        scale_y_by = self.fig.bbox.height / self.figure_height

        self.figure_width = self.fig.bbox.width
        self.figure_height = self.fig.bbox.height

        # rescale node positions
        for node, (x, y) in self.node_positions.items():
            new_x = ((x - self.origin[0]) * scale_x_by) + self.origin[0]
            new_y = ((y - self.origin[1]) * scale_y_by) + self.origin[1]
            self.node_positions[node] = np.array([new_x, new_y])

        # update axis dimensions
        self.scale = (scale_x_by * self.scale[0],
                      scale_y_by * self.scale[1])
        xmin = self.origin[0]                 - pad * self.scale[0]
        ymin = self.origin[1]                 - pad * self.scale[1]
        xmax = self.origin[0] + self.scale[0] + pad * self.scale[0]
        ymax = self.origin[1] + self.scale[1] + pad * self.scale[1]
        self.ax.axis([xmin, xmax, ymin, ymax])

        # redraw
        self._update_node_artists(self.nodes)
        self._update_node_label_positions()
        self._update_edges(self.edges)
        self._update_edge_label_positions(self.edges)
        self.fig.canvas.draw()

class MplCanvas(FigureCanvas):
    def __init__(self, parent=None):
        self.fig, self.ax = plt.subplots(figsize=(16, 8))
        self.ax.set_position([0, 0, 1, 1])
        self.graph = ResizableGraph([(0, 1)], scale=(2, 1), ax=self.ax)
        self.graph.ax.set_frame_on(True)
        super(MplCanvas, self).__init__(self.fig)

class MainWindow(QtWidgets.QMainWindow):
    def __init__(self, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)
        self.canvas = MplCanvas(self)
        self.setCentralWidget(self.canvas)

def main():
    app = QtWidgets.QApplication(sys.argv)
    w = MainWindow()
    w.show()
    app.exec_()

if __name__ == "__main__":
    main()

However, as you have noted correctly, the scale argument is not an attribute of the netgraph Graph classes, and thus changes to it in ResizableGraph won't be propagated to the parent classes. For straight-line edges, this does not matter, as edges won't be drawn outside the convex hull of the nodes. However, this can matter for curved edges (including self-loops). Are those essential for your application?

paulbrodersen commented 1 year ago

Hi @LBeghini, I will close the issue for now as I haven't heard from you for a while, and the solution above seems to work (at least in my hands). Feel free to re-open if necessary.

LBeghini commented 1 year ago

That's all right! Yesterday we just merged this proposed changes to our project! It worked perfectly, thanks a lot!