Skip to content

silx.gui.plot.PlotWidget: fix resetting zoom for curves/scatters to take the visible data in the X-range into acount#4633

Open
woutdenolf wants to merge 10 commits into
mainfrom
179-plot-when-autoscale-is-disabled-on-an-axis-reset-zoom-should-only-consider
Open

silx.gui.plot.PlotWidget: fix resetting zoom for curves/scatters to take the visible data in the X-range into acount#4633
woutdenolf wants to merge 10 commits into
mainfrom
179-plot-when-autoscale-is-disabled-on-an-axis-reset-zoom-should-only-consider

Conversation

@woutdenolf

@woutdenolf woutdenolf commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

PR summary

  • Item.getBounds() now returns a named tuple Bounds instead of tuple
  • Item.getResetBounds() introduced for the bounds used when resetting the zoom (current axes autoscale and limits properties have an effect on the returns Bounds)

AI Disclosure

Before this PR: reset zoom takes entire X-range to determine Y-range

before

After this PR: reset zoom takes current X-range to determine Y-range

after

Code for the example

import sys
import numpy

from silx.gui import qt
from silx.gui.plot import PlotWindow

app = qt.QApplication(sys.argv)

plot = PlotWindow()

x = numpy.linspace(0, 100, 5000)

y_left_main = numpy.sin(x * 0.2)
y_left_main += 5 * numpy.exp(-0.5 * ((x - 50) / 0.5) ** 2)

y_right = 0.5 * numpy.cos(x * 0.2)
y_right += 200 * numpy.exp(-0.5 * ((x - 85) / 0.2) ** 2)

y_left_extra = 0.3 * numpy.cos(x * 0.5)
y_left_extra += 8 * numpy.exp(-0.5 * ((x - 50) / 0.4) ** 2)

plot.addCurve(x, y_left_main, legend="left_main", yaxis="left")
plot.addCurve(x, y_left_extra, legend="left_extra", yaxis="left")
plot.addCurve(x, y_right, legend="right_hidden_spike", yaxis="right")

plot.show()

print("")
print("=== REPRO STEPS (#179) ===")
print("1. Zoom X to [40, 60]")
print("2. Disable X autoscale (lock X axis)")
print("3. Click Reset Zoom")
print("")
print("EXPECTED:")
print("- Y limits based ONLY on visible region (x in [40, 60])")
print("- Spike at x≈85 must NOT influence autoscale")
print("")
print("BUG (before fix):")
print("- Y axis explodes due to hidden spike at x≈85")
print("")

sys.exit(app.exec())

@woutdenolf woutdenolf changed the title 179 plot when autoscale is disabled on an axis reset zoom should only consider PlotWidget: fix resetting zoom for curve to take the visible data in the X-range into acount Jun 17, 2026
Comment thread src/silx/gui/plot/PlotWidget.py Outdated
Comment thread src/silx/gui/plot/items/axis.py Outdated
@woutdenolf woutdenolf force-pushed the 179-plot-when-autoscale-is-disabled-on-an-axis-reset-zoom-should-only-consider branch from e34a910 to a92f32f Compare June 17, 2026 18:31
@woutdenolf woutdenolf marked this pull request as ready for review June 17, 2026 18:33
@woutdenolf woutdenolf requested a review from a team June 17, 2026 18:33
Comment thread src/silx/gui/plot/PlotWidget.py Outdated
@woutdenolf woutdenolf changed the title PlotWidget: fix resetting zoom for curve to take the visible data in the X-range into acount silx.gui.plot.PlotWidget: fix resetting zoom for curves/scatters to take the visible data in the X-range into acount Jun 17, 2026
Comment thread src/silx/gui/plot/PlotWidget.py Outdated
@woutdenolf woutdenolf marked this pull request as draft June 18, 2026 07:43
@woutdenolf

woutdenolf commented Jun 18, 2026

Copy link
Copy Markdown
Contributor Author

I added Item.getAutoScaleBounds in analogy to Item.getBounds. Then PlotWidget uses getAutoScaleBounds when resetting the zoom with at least one fixed axis.

Another example, this time a scatter plot:

Peek 2026-06-18 11-15
import sys
import numpy

from silx.gui import qt
from silx.gui.plot import PlotWindow
from silx.gui.colors import Colormap


app = qt.QApplication(sys.argv)

plot = PlotWindow()

# visible clusters
x = 50 + 7 * numpy.random.randn(80)
y = 1.0 + 5 * numpy.random.randn(80)
values = numpy.random.rand(len(x))
item = plot.addScatter(x, y, values, legend="A", colormap=Colormap("red"))

x = 65 + 2 * numpy.random.randn(80)
y = 2.0 + 10 * numpy.random.randn(80)
values = numpy.random.rand(len(x))
item = plot.addScatter(x, y, values, legend="B", colormap=Colormap("green"))

# offscreen cluster after zoom
x = 85 + 3 * numpy.random.randn(40)
y = 50 + 2 * numpy.random.randn(40)
values = numpy.random.rand(len(x))
item = plot.addScatter(x, y, values, legend="C", colormap=Colormap("blue"))

plot.resetZoom()

plot.show()
sys.exit(app.exec())

@woutdenolf woutdenolf force-pushed the 179-plot-when-autoscale-is-disabled-on-an-axis-reset-zoom-should-only-consider branch 3 times, most recently from 7e37d0c to 689467a Compare June 18, 2026 12:52

@t20100 t20100 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fix in YRightAxis._getDataRange should be ranges.yright instead of ranges.y, otherwise LGTM!

Haven't saw the draft mode, removing approval

Comment thread src/silx/gui/plot/test/test_bounds.py Outdated
@t20100 t20100 self-requested a review June 18, 2026 14:23
@woutdenolf woutdenolf force-pushed the 179-plot-when-autoscale-is-disabled-on-an-axis-reset-zoom-should-only-consider branch 4 times, most recently from 95fb656 to 8b9cc2e Compare June 18, 2026 18:30
@woutdenolf

woutdenolf commented Jun 18, 2026

Copy link
Copy Markdown
Contributor Author

Another example for histograms

Peek 2026-06-18 20-40
import sys
import numpy

from silx.gui import qt
from silx.gui.plot import PlotWindow

app = qt.QApplication(sys.argv)

plot = PlotWindow()

rng = numpy.random.default_rng(42)

# Main population around x=50 (visible after zoom)
data_visible = rng.normal(loc=50, scale=2, size=5000)

# Strong population around x=85 (outside zoom window)
data_hidden = rng.normal(loc=85, scale=0.5, size=30000)

plot.addHistogram(
    histogram=numpy.histogram(data_visible, bins=80)[0],
    edges=numpy.histogram(data_visible, bins=80)[1],
    legend="visible_peak",
)

plot.addHistogram(
    histogram=numpy.histogram(data_hidden, bins=80)[0],
    edges=numpy.histogram(data_hidden, bins=80)[1],
    legend="hidden_peak",
)

plot.show()

print("")
print("=== HISTOGRAM RESETZOOM TEST ===")
print("1. Zoom X to [40, 60]")
print("2. Disable X autoscale")
print("3. Click Reset Zoom")
print("")
print("EXPECTED:")
print("- Y autoscale uses only visible histogram bins")
print("- Peak around x≈85 must not contribute")
print("")

sys.exit(app.exec())

@woutdenolf

Copy link
Copy Markdown
Contributor Author

Another example for images

Peek 2026-06-18 20-50
import sys
import numpy

from silx.gui import qt
from silx.gui.plot import PlotWindow

app = qt.QApplication(sys.argv)

plot = PlotWindow()

image = numpy.random.normal(size=(300, 300))

# bright hotspot in center
image[140:160, 140:160] += 10

# huge hotspot far away
image[250:260, 250:260] += 100

plot.addImage(image)

plot.show()

print("")
print("=== IMAGE AUTOSCALE TEST ===")
print("1. Zoom around the center hotspot")
print("2. Disable X autoscale")
print("3. Click Reset Zoom")
print("")
print("EXPECTED:")
print("- Y limits follow visible image region")
print("- Far hotspot should not affect autoscale")
print("")

sys.exit(app.exec())

@woutdenolf woutdenolf force-pushed the 179-plot-when-autoscale-is-disabled-on-an-axis-reset-zoom-should-only-consider branch 5 times, most recently from ab9f584 to dbe4a71 Compare June 19, 2026 06:19
@woutdenolf

woutdenolf commented Jun 19, 2026

Copy link
Copy Markdown
Contributor Author

Another example for BoundRect (not sure how to visualize)

import sys

from silx.gui import qt
from silx.gui.plot import PlotWindow
from silx.gui.plot.items.shape import BoundingRect

app = qt.QApplication(sys.argv)

plot = PlotWindow()

# Visible bounding region
rect_visible = BoundingRect()
rect_visible.setBounds((45, 55, 10, 20))
plot.addItem(rect_visible)

# Hidden bounding region
rect_hidden = BoundingRect()
rect_hidden.setBounds((80, 90, 100, 200))
plot.addItem(rect_hidden)

plot.show()

print("")
print("=== BOUNDINGRECT RESETZOOM TEST ===")
print("1. Zoom X to [40, 60]")
print("2. Disable X autoscale")
print("3. Click Reset Zoom")
print("")
print("EXPECTED:")
print("- Y limits become [10, 20]")
print("- BoundingRect at x=[80,90] must not contribute")
print("")

sys.exit(app.exec())

@woutdenolf

woutdenolf commented Jun 19, 2026

Copy link
Copy Markdown
Contributor Author

Another example for axis extents (not sure how to visualize)

import sys

from silx.gui import qt
from silx.gui.plot import PlotWindow
from silx.gui.plot.items import XAxisExtent, YAxisExtent

app = qt.QApplication(sys.argv)

plot = PlotWindow()

# Visible extents
x_visible = XAxisExtent()
x_visible.setRange(45, 55)
plot.addItem(x_visible)

y_visible = YAxisExtent()
y_visible.setRange(45, 55)
plot.addItem(y_visible)

# Hidden extents
x_hidden = XAxisExtent()
x_hidden.setRange(80, 90)
plot.addItem(x_hidden)

y_hidden = YAxisExtent()
y_hidden.setRange(80, 90)
plot.addItem(y_hidden)

plot.show()

print("")
print("=== EXTENT RESETZOOM TEST ===")
print("")
print("CASE A: X fixed")
print("1. Zoom X to [40, 60]")
print("2. Disable X autoscale")
print("3. Click Reset Zoom")
print("")
print("EXPECTED:")
print("- Y limits become [45, 55]")
print("- YAxisExtent [80,90] ignored")
print("")
print("CASE B: Y fixed")
print("1. Zoom Y to [40, 60]")
print("2. Disable Y autoscale")
print("3. Click Reset Zoom")
print("")
print("EXPECTED:")
print("- X limits become [45, 55]")
print("- XAxisExtent [80,90] ignored")
print("")

sys.exit(app.exec())

@woutdenolf woutdenolf force-pushed the 179-plot-when-autoscale-is-disabled-on-an-axis-reset-zoom-should-only-consider branch 2 times, most recently from 7e1fcf9 to 28184a2 Compare June 19, 2026 09:55
@woutdenolf

woutdenolf commented Jun 19, 2026

Copy link
Copy Markdown
Contributor Author

@t20100 @payno The Item.getBounds() and the new Item.getResetBounds() return NaN on the y limits for XAxisExtent and NaN on the x limits for YAxisExtent.

When resetting the zoom we now call Item.getResetBounds() instead of Item.getBounds() but I have I need a way to handle the NaNs because we set all 4

class BackendBase:
    def setLimits(self, xmin, xmax, ymin, ymax, y2min=None, y2max=None):

In this case it seems that instead of [nan, nan] we have [1, 100] somehow.

I can either do this: uncomment https://github.com/silx-kit/silx/pull/4633/changes#diff-73bf8890a30feb0887556785fbfd8cbdc73ea4aca5e81db9de86907c4e42dd3bR942-R943

Or BackendBase.setLimits should handle NaN. What do you think?

@woutdenolf woutdenolf force-pushed the 179-plot-when-autoscale-is-disabled-on-an-axis-reset-zoom-should-only-consider branch 2 times, most recently from 70f5b48 to 2461225 Compare June 19, 2026 10:32
@t20100

t20100 commented Jun 19, 2026

Copy link
Copy Markdown
Member
  • BoundRect and ?AxisExtent are invisible items that contributes to the "data ranges" to influence reset zoom. With them, one can ensure a given plot area is visible when reseting the zoom even if no data is display there, e.g. for a 2d scatter updating live which you know the final size from the start. AFAIR they were added for flint.

  • [1, 100] is the fallback axis range when limits are not valid.

I can either do this: uncomment Or BackendBase.setLimits should handle NaN. What do you think?

How it's done in PlotWidget._updateDataRange (

def _updateDataRange(self):
) is similar to uncommenting the 2 lines in pack, so I would go this way.

BTW, it should be possible to put some code in common between _updateDataRange and _getResetDataRange (I prefer the implementation in _getResetDataRange).

@woutdenolf woutdenolf force-pushed the 179-plot-when-autoscale-is-disabled-on-an-axis-reset-zoom-should-only-consider branch from 2461225 to ebe8a90 Compare June 19, 2026 12:32
@woutdenolf woutdenolf force-pushed the 179-plot-when-autoscale-is-disabled-on-an-axis-reset-zoom-should-only-consider branch from 836de55 to 1abc23b Compare June 19, 2026 13:32
@woutdenolf woutdenolf marked this pull request as ready for review June 19, 2026 13:32
@woutdenolf woutdenolf force-pushed the 179-plot-when-autoscale-is-disabled-on-an-axis-reset-zoom-should-only-consider branch from b3d4e97 to 1abc23b Compare June 19, 2026 14:56
):
"""Reset the plot limits to the bounds of the data and redraw the plot.

This method forces a reset zoom and does not check axis autoscale.

@woutdenolf woutdenolf Jun 19, 2026

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.

This is no longer true, we do take autoscale into account.

The difference between resetZoom and _forceResetZoom is not really clear from the docstrings. I'm not entirely sure about the original purpose either. As far as I understand

  • When at least one axis is autoscaling, resetZoom calls _forceResetZoom and afterwards forces limits, except if both are autoscaling. Weird.
  • When none of the axes are autoscaling, resetZoom does nothing.

@woutdenolf woutdenolf Jun 19, 2026

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.

If I were to describe the new behavior of _forceResetZoom it would be this

Reset the plot limits and redraw the plot.

The plot limits after a reset depend on whether axes have

- fixed limits (autoscale=False) or
- auto-scaling limits (autoscale=True).

See :meth:`getXAxis`, :meth:`getYAxis` and :meth:`Axis.setAutoScale`.

Axes that have fixed limits keep their limits after a reset.

Axes that have auto-scaling limits will have new limits determined by

- the data range within the limits of the axes with fixed limits or
- the full data range if all axes have auto-scaling limits.

Extra margins can be added around the data inside the plot area
(see :meth:`setDataMargins`).
Margins are given as one ratio of the data range per limit of the
data (xMin, xMax, yMin and yMax limits).
For log scale, extra margins are applied in log10 of the data.

This is exactly the same for resetZoom except that when non of the axes are auto-scaling, nothing happens. It also does a thing related to log-scale an limits >=0. I'm wondering whether this should not go to _forceResetZoom or even Item.getResetBounds().

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.

@t20100 You created _forceResetZoom in #1312. Not clear why. It used to be resetZoom only.

@woutdenolf woutdenolf Jun 19, 2026

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 would be inclined to just have

def resetZoom(self, dataMargins = None):
    return self._resetZoom(dataMargins=dataMargins, force=False)

def _resetZoom(self, dataMargins = None, force = True):
    ...

and all the logic happens in _resetZoom (currently _forceResetZoom), including to forcing and log-scale handling.

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'm not sure why force would not always be True.

When all axes have autoscale=False we could just skip for performance reasons. But since "force" exists there must be other things that could influence the resetting even when autoscale=False for all. Something related to changing the aspect ratio fixing.

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.

Ok these is also _notifyLimitsChanged. This code is really convoluted.

Comment thread src/silx/gui/plot/items/core.py
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.

[Plot] When autoscale is disabled on an axis, reset zoom should only consider

3 participants