K.Mulier
K.Mulier

Reputation: 9620

Size in pixels of x-axis from a matplotlib figure embedded in a PyQt5 window

I've got a live matplotlib graph in a PyQt5 window:

enter image description here

You can read more about how I got this code working here:
How to make a fast matplotlib live plot in a PyQt5 GUI

Please copy-paste the code below to a python file, and run it with Python 3.7:

#####################################################################################
#                                                                                   #
#                     PLOT A LIVE GRAPH IN A PYQT WINDOW                            #
#                                                                                   #
#####################################################################################

from __future__ import annotations
from typing import *
import sys
import os
from PyQt5 import QtWidgets, QtCore
from matplotlib.backends.backend_qt5agg import FigureCanvas
import matplotlib as mpl
import matplotlib.figure as mpl_fig
import matplotlib.animation as anim
import matplotlib.style as style
import numpy as np

style.use('ggplot')

class ApplicationWindow(QtWidgets.QMainWindow):
    '''
    The PyQt5 main window.

    '''
    def __init__(self):
        super().__init__()
        # 1. Window settings
        self.setGeometry(300, 300, 800, 400)
        self.setWindowTitle("Matplotlib live plot in PyQt")
        self.frm = QtWidgets.QFrame(self)
        self.frm.setStyleSheet("QWidget { background-color: #eeeeec; }")
        self.lyt = QtWidgets.QVBoxLayout()
        self.frm.setLayout(self.lyt)
        self.setCentralWidget(self.frm)

        # 2. Place the matplotlib figure
        self.myFig = MyFigureCanvas(x_len=200, y_range=[0, 100], interval=20)
        self.lyt.addWidget(self.myFig)

        # 3. Show
        self.show()
        return

class MyFigureCanvas(FigureCanvas, anim.FuncAnimation):
    '''
    This is the FigureCanvas in which the live plot is drawn.

    '''
    def __init__(self, x_len:int, y_range:List, interval:int) -> None:
        '''
        :param x_len:       The nr of data points shown in one plot.
        :param y_range:     Range on y-axis.
        :param interval:    Get a new datapoint every .. milliseconds.

        '''
        FigureCanvas.__init__(self, mpl_fig.Figure())
        # Range settings
        self._x_len_ = x_len
        self._y_range_ = y_range

        # Store two lists _x_ and _y_
        x = list(range(0, x_len))
        y = [0] * x_len

        # Store a figure and ax
        self._ax_  = self.figure.subplots()
        self._ax_.set_ylim(ymin=self._y_range_[0], ymax=self._y_range_[1])
        self._line_, = self._ax_.plot(x, y)

        # Call superclass constructors
        anim.FuncAnimation.__init__(self, self.figure, self._update_canvas_, fargs=(y,), interval=interval, blit=True)
        return

    def _update_canvas_(self, i, y) -> None:
        '''
        This function gets called regularly by the timer.

        '''
        y.append(round(get_next_datapoint(), 2))     # Add new datapoint
        y = y[-self._x_len_:]                        # Truncate list _y_
        self._line_.set_ydata(y)

        # Print size of bounding box (in pixels)
        bbox = self.figure.get_window_extent().transformed(self.figure.dpi_scale_trans.inverted())
        width, height = bbox.width * self.figure.dpi, bbox.height * self.figure.dpi
        print(f"bbox size in pixels = {width} x {height}")

        return self._line_,

# Data source
# ------------
n = np.linspace(0, 499, 500)
d = 50 + 25 * (np.sin(n / 8.3)) + 10 * (np.sin(n / 7.5)) - 5 * (np.sin(n / 1.5))
i = 0
def get_next_datapoint():
    global i
    i += 1
    if i > 499:
        i = 0
    return d[i]

if __name__ == "__main__":
    qapp = QtWidgets.QApplication(sys.argv)
    app = ApplicationWindow()
    qapp.exec_()

 

1. The problem

I need to know the nr of pixels from x_min to x_max:

enter image description here

Please notice that the x-axis actually goes beyond the x_min and x_max borders. I don't need to know the total length. Just the length from x_min to x_max.

 

2. What I tried so far

I already found a way to get the graph's bounding box. Notice the following codelines in the _update_canvas_() function:

# Print size of bounding box (in pixels)
bbox = self.figure.get_window_extent().transformed(self.figure.dpi_scale_trans.inverted())
width, height = bbox.width * self.figure.dpi, bbox.height * self.figure.dpi
print(f"bbox size in pixels = {width} x {height}")

That gave me a bounding box size of 778.0 x 378.0 pixels. It's a nice starting point, but I don't know how to proceed from here.

enter image description here

I also noticed that this bounding box size isn't printed out correctly from the first go. The first run of the _update_canvas_() function prints out a bouding box of 640.0 x 480.0 pixels, which is just plain wrong. From the second run onwards, the printed size is correct. Why?

 

Edit

I tried two solutions. The first one is based on a method described by @ImportanceOfBeingErnes (see Axes class - set explicitly size (width/height) of axes in given units) and the second one is based on the answer from @Eyllanesc.

#####################################################################################
#                                                                                   #
#                PLOT A LIVE GRAPH IN A PYQT WINDOW                                 #
#                                                                                   #
#####################################################################################

from __future__ import annotations
from typing import *
import sys
import os
from PyQt5 import QtWidgets, QtCore
from matplotlib.backends.backend_qt5agg import FigureCanvas
import matplotlib as mpl
import matplotlib.figure as mpl_fig
import matplotlib.animation as anim
import matplotlib.style as style
import numpy as np

style.use('ggplot')

def get_width_method_a(ax, dpi, canvas):
    l = float(ax.figure.subplotpars.left)
    r = float(ax.figure.subplotpars.right)
    x, y, w, h = ax.figure.get_tightbbox(renderer=canvas.get_renderer()).bounds
    return float(dpi) * float(w - (l + r))

def get_width_eyllanesc(ax):
    """ Based on answer from @Eyllanesc"""
    """ See below """
    y_fake = 0
    x_min, x_max = 0, 200
    x_pixel_min, _ = ax.transData.transform((x_min, y_fake))
    x_pixel_max, _ = ax.transData.transform((x_max, y_fake))
    return x_pixel_max - x_pixel_min

class ApplicationWindow(QtWidgets.QMainWindow):
    '''
    The PyQt5 main window.

    '''
    def __init__(self):
        super().__init__()
        # 1. Window settings
        self.setGeometry(300, 300, 800, 400)
        self.setWindowTitle("Matplotlib live plot in PyQt")
        self.frm = QtWidgets.QFrame(self)
        self.frm.setStyleSheet("QWidget { background-color: #eeeeec; }")
        self.lyt = QtWidgets.QVBoxLayout()
        self.frm.setLayout(self.lyt)
        self.setCentralWidget(self.frm)

        # 2. Place the matplotlib figure
        self.myFig = MyFigureCanvas(x_len=200, y_range=[0, 100], interval=20)
        self.lyt.addWidget(self.myFig)

        # 3. Show
        self.show()
        return

class MyFigureCanvas(FigureCanvas, anim.FuncAnimation):
    '''
    This is the FigureCanvas in which the live plot is drawn.

    '''
    def __init__(self, x_len:int, y_range:List, interval:int) -> None:
        '''
        :param x_len:       The nr of data points shown in one plot.
        :param y_range:     Range on y-axis.
        :param interval:    Get a new datapoint every .. milliseconds.

        '''
        FigureCanvas.__init__(self, mpl_fig.Figure())
        # Range settings
        self._x_len_ = x_len
        self._y_range_ = y_range

        # Store two lists _x_ and _y_
        x = list(range(0, x_len))
        y = [0] * x_len

        # Store a figure and ax
        self._ax_  = self.figure.subplots()
        self._ax_.set_ylim(ymin=self._y_range_[0], ymax=self._y_range_[1])
        self._line_, = self._ax_.plot(x, y)
        self._line_.set_ydata(y)
        print("")
        print(f"width in pixels (first call, method is 'method_a')  = {get_width_method_a(self._ax_, self.figure.dpi, self)}")
        print(f"width in pixels (first call, method is 'eyllanesc') = {get_width_eyllanesc(self._ax_)}")

        # Call superclass constructors
        anim.FuncAnimation.__init__(self, self.figure, self._update_canvas_, fargs=(y,), interval=interval, blit=True)
        return

    def _update_canvas_(self, i, y) -> None:
        '''
        This function gets called regularly by the timer.

        '''
        y.append(round(get_next_datapoint(), 2))     # Add new datapoint
        y = y[-self._x_len_:]                        # Truncate list _y_
        self._line_.set_ydata(y)
        print("")
        print(f"width in pixels (method is 'method_a')  = {get_width_method_a(self._ax_, self.figure.dpi, self)}")
        print(f"width in pixels (method is 'eyllanesc') = {get_width_eyllanesc(self._ax_)}")
        return self._line_,

# Data source
# ------------
n = np.linspace(0, 499, 500)
d = 50 + 25 * (np.sin(n / 8.3)) + 10 * (np.sin(n / 7.5)) - 5 * (np.sin(n / 1.5))
i = 0
def get_next_datapoint():
    global i
    i += 1
    if i > 499:
        i = 0
    return d[i]

if __name__ == "__main__":
    qapp = QtWidgets.QApplication(sys.argv)
    app = ApplicationWindow()
    qapp.exec_()

Conclusions:
The correct answer is 550 pixels, which is what I measured on a printscreen. Now, I get the following output printed when I run the program:

width in pixels (first call, method is 'method_a')  = 433.0972222222222
width in pixels (first call, method is 'eyllanesc') = 453.1749657377798

width in pixels (method is 'method_a')  = 433.0972222222222
width in pixels (method is 'eyllanesc') = 453.1749657377798

width in pixels (method is 'method_a')  = 540.0472222222223
width in pixels (method is 'eyllanesc') = 550.8908177249887

...

The first call for both methods gives the wrong result. From the third(!) call onwards, they both give pretty good results, with the method from @Eyllanesc being the winner.

How do I fix the problem of the wrong result for the first call?

Upvotes: 2

Views: 964

Answers (1)

eyllanesc
eyllanesc

Reputation: 243887

For an old answer I had to do calculation, which in your case is:

y_fake = 0
x_min, x_max = 0, 200
x_pixel_min, _ = self._ax_.transData.transform((x_min, y_fake))
x_pixel_max, _ = self._ax_.transData.transform((x_max, y_fake))

print(
    f"The length in pixels between x_min: {x_min} and x_max: {x_max} is: {x_pixel_max - x_pixel_min}"
)

Note:

The calculations take into account what is painted, so in the first moments it is still being painted so the results are correct but our eyes cannot distinguish them. If you want to obtain the correct size without the animation you must calculate that value when the painting is stabilized, which is difficult to calculate, a workaround is to use a QTimer to make the measurement a moment later:

    # ...
    self._ax_ = self.figure.subplots()
    self._ax_.set_ylim(ymin=self._y_range_[0], ymax=self._y_range_[1])
    self._line_, = self._ax_.plot(x, y)

    QtCore.QTimer.singleShot(100, self.calculate_length)
    # ...

def calculate_length(self):
    y_fake = 0
    x_min, x_max = 0, 200
    x_pixel_min, _ = self._ax_.transData.transform((x_min, y_fake))
    x_pixel_max, _ = self._ax_.transData.transform((x_max, y_fake))

    print(
        f"The length in pixels between x_min: {x_min} and x_max: {x_max} is: {x_pixel_max - x_pixel_min}"
    )

Upvotes: 1

Related Questions