Skip to content

Add Boxplot feature#2562

Merged
j9ac9k merged 1 commit intopyqtgraph:masterfrom
noonchen:master
Nov 15, 2025
Merged

Add Boxplot feature#2562
j9ac9k merged 1 commit intopyqtgraph:masterfrom
noonchen:master

Conversation

@noonchen
Copy link
Copy Markdown
Contributor

Add BoxplotItem and example code.

Closes #2542 .

@pijyoi
Copy link
Copy Markdown
Contributor

pijyoi commented Dec 26, 2022

I think you left out pyqtgraph/__init__.py
Also please add your example to pyqtgraph/examples/utils.py so that it gets added to ExampleApp and also gets executed by the CI.

From #2561, I found that getting the bounding rectangle right is not so straightforward. Below is a script that demonstrates two issues.

  1. the size of the outlier symbol is not accounted for
  2. when a thick pen is used, QPicture.boundingRect() computes the bounding rectangle wrongly
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()

np.random.seed(8)
n = 5
data = [np.random.normal(500, 30, 1000) for _ in range(n)]

bpi = pg.BoxplotItem()
pen = pg.mkPen('y', width=6)
bpi.setData(data=data, pen=pen, symbol='star', symbolBrush='g')
pw.addItem(bpi)
rect = ObjectBounds(bpi)

pg.exec()

@noonchen
Copy link
Copy Markdown
Contributor Author

@pijyoi Thanks for pointing out these issues, let me try to fix them.

@noonchen noonchen marked this pull request as draft December 26, 2022 14:20
@noonchen noonchen marked this pull request as ready for review December 26, 2022 15:49
@noonchen
Copy link
Copy Markdown
Contributor Author

@pijyoi bounding rect should be correct now.

image

@pijyoi
Copy link
Copy Markdown
Contributor

pijyoi commented Dec 26, 2022

I am getting the plot below on Windows/PyQt6 and also on WSL2/PySide6. Note that there is no big empty space.
Did you paste an older screenshot?
Screenshot 2022-12-27 071041

@noonchen
Copy link
Copy Markdown
Contributor Author

noonchen commented Dec 27, 2022

No, it's the screenshot from latest changes. I can't explain why there are still blank spaces surround bounding rect.

I'm running on macos + pyqt5

@pijyoi
Copy link
Copy Markdown
Contributor

pijyoi commented Dec 27, 2022

Ah, I see that the bounding rectangle is tight but the viewbox is not in your screenshot.
Perhaps this is platform specific.

Could you try out the example in #2561 on both #2561 and #2565 on your macOS system and see if there are any differences?
On {Windows,Linux} x {Qt5,Qt6} they look the same to me, i.e. no empty space.

Or even better, try out the script in #2565 (comment)

@noonchen
Copy link
Copy Markdown
Contributor Author

noonchen commented Oct 5, 2025

Or even better, try out the script in #2565 (comment)

@pijyoi No sure if you are still interested, but here is the image output from macos + pyqt5:
image

@pijyoi
Copy link
Copy Markdown
Contributor

pijyoi commented Oct 6, 2025

BTW, I saw there was a long discussion in #2565 regarding the bound calculation, do I need to make any changes as well?

If I follow the thread correctly, this is what happened before:

  1. When you run the script in Add Boxplot feature #2562 (comment), you get Add Boxplot feature #2562 (comment) on macos+pyqt5, which is a large blank space on the left and on the right of the data. This doesn't occur on Windows nor Linux. Is this still happening for you?
  2. The script in BarGraphItem: implement dataBounds and pixelPadding #2565 (comment) is supposed to show the differences between
    i) using QPicture's integer boundingRect (top plot)
    ii) implementing only boundingRect (middle plot) (which is what your current PR does)
    iii) implementing additionally dataBounds and pixelPadding (bottom plot)

The screenshot that you showed (#2562 (comment)) seems to indicate that implementing (ii) but not (iii) is not the cause of the large blank space described in (1)

@noonchen
Copy link
Copy Markdown
Contributor Author

noonchen commented Oct 6, 2025

@pijyoi It seems the large blank space is due to pyqt5, could you confirm it on your side?

here is the output on macos + pyqt6, no blank space:
image

If I switch back to pyqt5, the space is back:
image

@noonchen
Copy link
Copy Markdown
Contributor Author

noonchen commented Oct 6, 2025

I noticed a difference in stdout.

paint() of ObjectBounds in your example code will print the rect.

Using pyqt5, there is only one line printed:

PyQt5.QtCore.QRectF(-0.5380747244056322, 373.8655287414976, 5.076149448811265, 229.95947290784426)

Using pyqt6, there are multiple lines, and the first line is the same as pyqt5, then ObjectBounds is painted several times.

PyQt6.QtCore.QRectF(-0.5380747244056322, 373.8655287414976, 5.076149448811265, 229.95947290784426)
PyQt6.QtCore.QRectF(-0.4386291851954595, 373.969709113589, 4.877258370390919, 229.75111216366162)
PyQt6.QtCore.QRectF(-0.437115636317614, 373.97322709173284, 4.874231272635228, 229.74407620737384)
PyQt6.QtCore.QRectF(-0.4370926002898161, 373.9733458873368, 4.874185200579634, 229.74383861616593)
PyQt6.QtCore.QRectF(-0.43709224968430577, 373.9733498988442, 4.874184499368612, 229.74383059315113)
PyQt6.QtCore.QRectF(-0.43709224434813304, 373.9733500343054, 4.874184488696267, 229.7438303222287)
PyQt6.QtCore.QRectF(-0.4370922442669171, 373.9733500388797, 4.874184488533835, 229.7438303130802)
PyQt6.QtCore.QRectF(-0.4370922442669171, 373.9733500388797, 4.874184488533835, 229.7438303130802)

I am not familiar with pyqt6, but I hope it can give you some insight.

FYI, I am still running in my current branch, which was 2 years ago. I will test it again using latest main branch and post the results here if result is different.

Update: latest main has no difference.

@pijyoi
Copy link
Copy Markdown
Contributor

pijyoi commented Oct 6, 2025

On Windows, both on PyQt5 and PySide6, I am getting the no blank space image.
For both of them, I am getting the multiple lines print-out of the QRectF.

I am guessing that there's something happening with macOS and Qt5 that got fixed in Qt6.
This "something" doesn't affect Windows and Linux, whether Qt5 or Qt6.

It's possible that implementing dataBounds and pixelPadding might make a difference.

@noonchen
Copy link
Copy Markdown
Contributor Author

noonchen commented Oct 6, 2025

@pijyoi

I need to move outlier drawing code to generatePicture() in order to implement dataBounds() and pixelPadding(), so that I can get bounding box that contains outlier.

But it is not working as expected, I think I did something wrong with QPainter:

image

I put the code in another branch, would you please take a look?

noonchen@ca1ae8f#diff-07cd0e5f16a681a9fabeb53a0f4bee545b8bbec8b16ad04f0c615fae9e22e82c

FYI, if I disable the outlier, there is no blank space using pyqt5.

bpi.setData(data=data, pen=pen, symbol='star', symbolBrush='g', outlier=False)
image

@pijyoi
Copy link
Copy Markdown
Contributor

pijyoi commented Oct 6, 2025

If you look at the width of the blank space, it corresponds to 0.707 * symbolSize (10) ~= 7.

Quoting #2565 (comment)
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.

It seems to me that the computation of the data bounds needs to be decoupled with the rendering.
Something like the following:
(pen width, symbol width hasn't been taken into account yet)

    def calculateDataBounds(self):
        loc, data = self.opts["loc"], self.opts["data"]
        if data is None:
            return QtCore.QRectF()

        lst_lower = []
        lst_upper = []
        for dataset in data:
            dataset = np.asarray(dataset)
            if self.opts["outlier"]:
                lower, upper = np.min(dataset), np.max(dataset)
            else:
                lower, upper = self.whiskerFunc(dataset)
            lst_lower.append(lower)
            lst_upper.append(upper)
        miny = np.min(lst_lower)
        maxy = np.max(lst_upper)

        if loc is None:
            loc = np.arange(len(data))
        loc = np.array(loc)
        minx, maxx = np.min(loc), np.max(loc)
        width = 0.8 if self.opts["width"] is None else self.opts["width"]
        minx -= width/2
        maxx += width/2

        if not self.opts["locAsX"]:
            minx, maxx, miny, maxy = miny, maxy, minx, maxx

        return QtCore.QRectF(QtCore.QPointF(minx, miny), QtCore.QPointF(maxx, maxy))

@noonchen
Copy link
Copy Markdown
Contributor Author

noonchen commented Oct 6, 2025

Thanks @pijyoi ,

Now the viewbox is normal using pyqt5, and there is only one printed line.

bpi.setData(data=data, pen=pen, symbol='star', symbolBrush='g')

# stdout
# PyQt5.QtCore.QRectF(-0.43704178233953345, 374.03488896335114, 4.874083564679067, 229.62075246413724)
image

And users can set pen and brush to None to hide boxes.

bpi.setData(data=data, pen=None, medianPen=None, symbol='star', symbolBrush='g')
image

BTW, how to solve the failing doc check?

@noonchen noonchen requested a review from pijyoi October 6, 2025 20:56
@noonchen noonchen requested a review from pijyoi October 8, 2025 07:36
if (self.opts["pen"] is None and
self.opts["brush"] is None and
self.opts["medianPen"] is None):
self.opts["width"] = 0.001
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not just 0?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There will be unexpected gray lines if width is zero, maybe that's what happened when we are trying to draw a 0-width rect? i am not sure, but a non-zero small number works.

image

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This effect can be attributed to the following behaviour:
https://codebrowser.dev/qt5/qtbase/src/gui/painting/qpaintengine.cpp.html#_ZN12QPaintEngine9drawLinesEPK6QLineFi

If the two endpoints are the same, then a point is drawn instead. This point is of dimensions 1x1. So it occupies 1 unit on the horizontal axis and 1 unit on the vertical axis. And this appears as a "line" to us.

Actually, why is there even a need to have the branch to special case the width value? You could just remove the whole "if" branch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not able to access the link. the width is used in the dataBounds:

image

@pijyoi
Copy link
Copy Markdown
Contributor

pijyoi commented Oct 9, 2025

Could you please rebase your branch onto master?

@pijyoi
Copy link
Copy Markdown
Contributor

pijyoi commented Oct 10, 2025

Setting width to 0.001 is arbitrary and fails to give a tight bounding box when non-default locations are used.

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()

rng = np.random.default_rng(0)
data = [rng.normal(500, 30, 1000) for _ in range(5)]

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

bpi = pg.BoxplotItem()
pen = pg.mkPen('y', width=6)
xscale = 1e-4
bpi.setData(loc=np.arange(len(data)) * xscale, width=xscale * 0.8, data=data, pen=None, medianPen=None, symbolBrush='g')
pw.addItem(bpi)
rect = ObjectBounds(bpi)

pw.show()
pg.exec()

@noonchen
Copy link
Copy Markdown
Contributor Author

noonchen commented Oct 10, 2025 via email

@pijyoi
Copy link
Copy Markdown
Contributor

pijyoi commented Oct 10, 2025

Something like the following seems to work. It also permits the user to pass width=0 to mean don't draw boxes.

It also demonstrates another thing: normally in the library, None is distinct from the 0 value. None means the user didn't provide a value and some library default should be substituted in. 0 means the user wanted it to be 0-valued.

diff --git a/pyqtgraph/graphicsItems/BoxplotItem.py b/pyqtgraph/graphicsItems/BoxplotItem.py
index e0c452fe..dd72fc44 100644
--- a/pyqtgraph/graphicsItems/BoxplotItem.py
+++ b/pyqtgraph/graphicsItems/BoxplotItem.py
@@ -92,13 +92,16 @@ class BoxplotItem(GraphicsObject):
         `symbolBrush`:  Brush for filling outlier symbols.
         '''
         self.opts.update(opts)
-        # set box width to a tiny number if not draw
-        if (self.opts["pen"] is None and
+
+        if self.opts["width"] is None:
+            self.opts["width"] = DEFAULT_BOX_WIDTH
+
+        if (
+            self.opts["pen"] is None and
             self.opts["brush"] is None and
-            self.opts["medianPen"] is None):
-            self.opts["width"] = 0.001
-        else:
-            self.opts["width"] = self.opts["width"] or DEFAULT_BOX_WIDTH
+            self.opts["medianPen"] is None
+        ):
+            self.opts["width"] = 0

         # prepare pen and brush object
         self._pen = fn.mkPen(self.opts["pen"])
@@ -158,6 +161,9 @@ class BoxplotItem(GraphicsObject):
                 mask = np.logical_or(dataset<lower, dataset>upper)
                 self.outlierData[pos] = dataset[mask]

+            if self.opts["width"] == 0:
+                continue
+
             p.setPen(self._pen)
             # whiskers
             if locAsX:

@noonchen
Copy link
Copy Markdown
Contributor Author

@pijyoi Thanks! It works and much better than a small width value, I should've thought it 👍

@noonchen noonchen requested a review from pijyoi October 12, 2025 03:39
@noonchen noonchen requested a review from pijyoi October 12, 2025 05:17
@noonchen
Copy link
Copy Markdown
Contributor Author

@pijyoi Is there anything else requires modification before merging?

@pijyoi
Copy link
Copy Markdown
Contributor

pijyoi commented Oct 16, 2025

This PR is more or less fine to me. Merging can only be done by maintainers.

@noonchen
Copy link
Copy Markdown
Contributor Author

This PR is more or less fine to me. Merging can only be done by maintainers.

Should I at someone explicitly or simply wait?

@pijyoi
Copy link
Copy Markdown
Contributor

pijyoi commented Oct 16, 2025

Just be patient and wait

@j9ac9k
Copy link
Copy Markdown
Member

j9ac9k commented Oct 16, 2025

Sorry I've been slacking; Thanks for your work on this @noonchen and thanks for your review/feedback @pijyoi

I had a work trip recently and followed up with covid which knocked me out. I'm getting caught up with things and should get to this in the coming day or so.

@noonchen
Copy link
Copy Markdown
Contributor Author

noonchen commented Nov 1, 2025

rebased

@j9ac9k
Copy link
Copy Markdown
Member

j9ac9k commented Nov 15, 2025

Thanks @noonchen for this PR and your continued efforts to get it working across the combination of dependencies we support, and thank you @pijyoi for reviewing and providing feedback. I'm going to merge this, but we should add documentation so it doesn't become a hidden feature like NonUniformImage

@j9ac9k j9ac9k merged commit 0d6a282 into pyqtgraph:master Nov 15, 2025
36 checks passed
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.

Feature: Boxplot support

3 participants