Skip to content

BarGraphItem: implement dataBounds and pixelPadding#2565

Merged
j9ac9k merged 1 commit intopyqtgraph:masterfrom
pijyoi:bargraph-databounds-pixelpadding
Jan 19, 2023
Merged

BarGraphItem: implement dataBounds and pixelPadding#2565
j9ac9k merged 1 commit intopyqtgraph:masterfrom
pijyoi:bargraph-databounds-pixelpadding

Conversation

@pijyoi
Copy link
Copy Markdown
Contributor

@pijyoi pijyoi commented Dec 27, 2022

Alternate implementation of #2561.
Implements methods dataBounds and pixelPadding which are used by ViewBox.

One difference that I noticed between this implementation and #2561 is that this implementation takes less iterations to stabilize on Windows. (7 vs 3).

@pijyoi pijyoi mentioned this pull request Dec 27, 2022
@pijyoi
Copy link
Copy Markdown
Contributor Author

pijyoi commented Dec 27, 2022

For a easier comparison of the various ways of computing the bounding rectangle, a standalone script that plots them all together.
Apparently there are some differences when running on macOS, so it would be appreciated if someone could paste the screenshot from running on macOS.

import pyqtgraph as pg
from pyqtgraph import QtCore, QtGui, QtWidgets

class CandlestickItem(pg.GraphicsObject):
    def __init__(self, data):
        pg.GraphicsObject.__init__(self)
        self.data = data  ## data must have fields: time, open, close, min, max
        self.generatePicture()
    
    def generatePicture(self):
        ## pre-computing a QPicture object allows paint() to run much more quickly, 
        ## rather than re-drawing the shapes every time.
        dataBounds = QtCore.QRectF()
        self.picture = QtGui.QPicture()
        p = QtGui.QPainter(self.picture)
        pen = pg.mkPen('w', width=6)
        p.setPen(pen)
        brush_pos = pg.mkBrush('r')
        brush_neg = pg.mkBrush('g')
        w = (self.data[1][0] - self.data[0][0]) / 3.
        for (t, open, close, min, max) in self.data:
            p.drawLine(QtCore.QPointF(t, min), QtCore.QPointF(t, max))
            if open > close:
                p.setBrush(brush_pos)
            else:
                p.setBrush(brush_neg)
            p.drawRect(QtCore.QRectF(t-w, open, w*2, close-open))
            dataBounds |= QtCore.QRectF(t-w, min, w*2, max-min)
        p.end()
        self._dataBounds = dataBounds
        self._pixelPadding = pen.widthF()*0.7072

    def paint(self, p, *args):
        p.drawPicture(0, 0, self.picture)
    
    def boundingRect(self):
        if self.picture is None:
            self.generatePicture()
        return QtCore.QRectF(self.picture.boundingRect())

class ItemX(CandlestickItem):
    # provide boundingRect that takes into account pen width
    def boundingRect(self):
        if self.picture is None:
            self.generatePicture()

        px = py = 0.0
        pxPad = self._pixelPadding
        if pxPad > 0:
            # determine length of pixel in local x, y directions
            px, py = self.pixelVectors()
            try:
                px = 0 if px is None else px.length()
            except OverflowError:
                px = 0
            try:
                py = 0 if py is None else py.length()
            except OverflowError:
                py = 0

            # return bounds expanded by pixel size
            px *= pxPad
            py *= pxPad
        boundingRect = self._dataBounds.adjusted(-px, -py, px, py)
        return boundingRect

class ItemY(ItemX):
    # also provide dataBounds and pixelPadding
    def dataBounds(self, ax, frac=1.0, orthoRange=None):
        if self.picture is None:
            self.generatePicture()
        br = self._dataBounds
        if ax == 0:
            return [br.left(), br.right()]
        else:
            return [br.top(), br.bottom()]

    def pixelPadding(self):
        if self.picture is None:
            self.generatePicture()
        return self._pixelPadding

class ObjectBounds(QtWidgets.QGraphicsItem):
    def paint(self, painter, *args):
        pen = QtGui.QPen(QtCore.Qt.GlobalColor.red, 0, QtCore.Qt.PenStyle.DashLine)
        rect = self.boundingRect()
        painter.setPen(pen)
        painter.drawRect(rect)
        # print(rect)

    def boundingRect(self):
        return self.parentItem().boundingRect()

data = [  ## fields are (time, open, close, min, max).
    (1., 10, 13, 5, 15),
    (2., 13, 17, 9, 20),
    (3., 17, 14, 11, 23),
    (4., 14, 15, 5, 19),
    (5., 15, 9, 8, 22),
    (6., 9, 15, 8, 16),
]

pg.mkQApp()
glw = pg.GraphicsLayoutWidget()
glw.show()
items = [CandlestickItem(data), ItemX(data), ItemY(data)]
rects = []
for idx, item in enumerate(items):
    plt = glw.addPlot(row=idx, col=0)
    plt.addItem(item)
    rects.append(ObjectBounds(item))
pg.exec()

Here's how it looks like on Windows and Linux.
Screenshot 2022-12-27 225050

@pijyoi
Copy link
Copy Markdown
Contributor Author

pijyoi commented Dec 28, 2022

I have a test case here where (on Windows) the implementation of #2561 and this PR have different behavior.

With #2561 and on Qt6, the viewbox is not tightly sized on the initial plot. Pressing the "A" button will make it tightly sized. Even minimizing and restoring the window will make it tightly sized.
It doesn't occur with PySide2 and PyQt5.

With this PR, the viewbox is tightly sized on the initial plot.

I am not sure if this is a bug of pyqtgraph.

This test-case also shows another deficiency of using QPicture.boundingRect() for non-integer coords.

import numpy as np
import pyqtgraph as pg
from pyqtgraph.Qt import QtCore, QtGui, QtWidgets

class ObjectBounds(QtWidgets.QGraphicsItem):
    def paint(self, painter, *args):
        pen = QtGui.QPen(QtCore.Qt.GlobalColor.red, 0, QtCore.Qt.PenStyle.DashLine)
        rect = self.boundingRect()
        painter.setPen(pen)
        painter.drawRect(rect)
        print(rect)

    def boundingRect(self):
        return self.parentItem().boundingRect()

pg.mkQApp()
pw = pg.PlotWidget()
pw.show()

x = np.linspace(0, 1, 100)
y = np.sin(2*np.pi*1.0*x)

pen = pg.mkPen('w', width=1)
bgi = pg.BarGraphItem(x=x, width=x[1]-x[0], height=y, pen=pen, brush=(0,0,255,150))
pw.addItem(bgi)
rect = ObjectBounds(bgi)

pg.exec()

As far as I can tell, this is what happened:
If the item implements only boundingRect but not dataBounds and pixelPadding, ViewBox calls item.boundingRect() early on before a valid pixelVectors() is available, with the result that unit vectors are returned by pixelVectors(). Thus ViewBox ends up computing an over-sized initial boundingRect.
Now, if the data were integers only, this would not have been so obvious.

and implement boundingRect in terms of dataBounds and pixelPadding
@pijyoi pijyoi force-pushed the bargraph-databounds-pixelpadding branch from 02014b4 to b2de5fc Compare December 31, 2022 07:19
@pijyoi
Copy link
Copy Markdown
Contributor Author

pijyoi commented Dec 31, 2022

If you have a hidpi screen, you will be able to see that the bounding rectangle has a small gap, compared to a device pixel ratio 1 screen. (This effect is more obvious with thicker line widths.)
This means that the pixel padding computation should by right be dependent on device pixel ratio. It's harmless to have a slightly over-sized bounding rectangle though.

@SimenZhor
Copy link
Copy Markdown
Contributor

I randomly encountered a need for this exact functionality in PColorMeshItem only to discover that you've put down some good work here already. For any potential future reference, the PlotDataItem contains a good description of the frac and orthoRange functionality:

"""
Returns the range occupied by the data (along a specific axis) in this item.
This method is called by :class:`ViewBox` when auto-scaling.
=============== ====================================================================
**Arguments:**
ax              (0 or 1) the axis for which to return this item's data range
frac            (float 0.0-1.0) Specifies what fraction of the total data
                range to return. By default, the entire range is returned.
                This allows the :class:`ViewBox` to ignore large spikes in the data
                when auto-scaling.
orthoRange      ([min,max] or None) Specifies that only the data within the
                given range (orthogonal to *ax*) should me measured when
                returning the data range. (For example, a ViewBox might ask
                what is the y-range of all data with x-values between min
                and max)
=============== ====================================================================
"""

I had some trouble trying to figure out exactly what they were supposed to do from reading methods that didn't implement their respective functionality.

Another potentially useful resource is the PlotCurveItem which actually implements their functionality in its dataBounds method.

Would it be possible to write some of this functionality in a generic way that can be reused across different classes? I think it would be useful for any item that uses QPicture for drawing, as the basic boundingRect functionality of directly converting the bounding rect of QPicture from QRect to QRectF can cause huge margins to appear for small x/y data.

@pijyoi
Copy link
Copy Markdown
Contributor Author

pijyoi commented Jan 4, 2023

Would it be possible to write some of this functionality in a generic way that can be reused across different classes?

I don't understand how frac and orthoRange are actually used by the library enough to implement it.

The scope that I hope to achieve here in this PR is to implement a simple enough replacement for current uses of QPicture::boundingRect such that it can be copied easily when implementing new custom GraphicsObjects. For instance, besides BarGraphItem and PColorMeshItem, customGraphicsItem and NonUniformImage also currently use QPicture::boundingRect to compute their bounding rectangle.

"Simple" here means that we don't handle things like non-finites or overflow.
In fact, it now seems to me that non-cosmetic pens would also fall into the category of edge cases.

@pijyoi
Copy link
Copy Markdown
Contributor Author

pijyoi commented Jan 13, 2023

There is one example https://github.com/pyqtgraph/pyqtgraph/blob/master/pyqtgraph/examples/PlotAutoRange.py that exercises the frac argument.

There are only two GraphicsObjects, PlotCurveItem and ScatterPlotItem, which do handle the frac argument.

@j9ac9k
Copy link
Copy Markdown
Member

j9ac9k commented Jan 15, 2023

One difference that I noticed between this implementation and #2561 is that this implementation takes less iterations to stabilize on Windows. (7 vs 3).

👀

that's ...absolutely fantastic.

The script you added, here is the output on macOS (this is with PyQt 6.4)

image

This PR looks great @pijyoi I'm 👍🏻 on merging ... curious if there are any other potential issues we would like to have a closer look, but I don't want to hold off on this PR to handle things like non-finite values and such...

@pijyoi
Copy link
Copy Markdown
Contributor Author

pijyoi commented Jan 17, 2023

Slightly related:
#60 some notes on dataBounds by campagnola
#1141 visual artifacts when boundingRect doesn't take pen width into account

@j9ac9k
Copy link
Copy Markdown
Member

j9ac9k commented Jan 19, 2023

hi @pijyoi thanks again for this PR, this looks good to me. Thanks for diving into this tricky issue, I'm sure it wasn't easy to sort out!

@j9ac9k j9ac9k merged commit ceb4053 into pyqtgraph:master Jan 19, 2023
@pijyoi pijyoi deleted the bargraph-databounds-pixelpadding branch January 19, 2023 04:48
@pijyoi
Copy link
Copy Markdown
Contributor Author

pijyoi commented Jan 19, 2023

fixes #2550
fixes #2582

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants