ADR-002: PyQtGraph vs Matplotlib for GUI Plotting

Status: ACCEPTED Date: 2026-04-06 Deciders: Architecture Team Supersedes: None


Context

The pyXRayLabTool GUI currently uses matplotlib embedded in PySide6 via FigureCanvasQTAgg and NavigationToolbar2QT. There are three matplotlib-based plot widgets:

Widget

File

Purpose

Complexity

PlotCanvas

gui/widgets/plot_canvas.py

Primary property plot (energy vs. property)

Medium – log axes, grid, legend, markers

F1F2Plot

gui/widgets/sweep_plots.py

Single-material f1/f2 scattering factors

Simple – two line plots

MultiF1F2Plot

gui/widgets/sweep_plots.py

Multi-material f1/f2 comparison (2 subplots)

Medium – shared x-axis, dual subplot

All three widgets follow the same pattern:

  1. self.figure = Figure(figsize=...)

  2. self.canvas = FigureCanvasQTAgg(self.figure)

  3. self.figure.clear() then self.figure.add_subplot(111)

  4. ax.plot(x, y, label=..., marker=..., linewidth=...)

  5. ax.set_xlabel/ylabel/xscale

  6. self.canvas.draw_idle()

Pain points with matplotlib in the GUI:

  1. Rendering speed: canvas.draw_idle() redraws the entire figure. For energy sweeps with 500+ points across 5+ materials, plot updates feel sluggish.

  2. Theme integration: The update_theme() method on each widget manually iterates over axes, spines, ticks, and legend to apply Qt palette colors via mpl.rcParams. This is fragile and requires synchronization with the ColorPalette dataclass.

  3. Interactivity: matplotlib’s NavigationToolbar provides zoom/pan but no smooth real-time interaction (e.g., hover tooltips, cursor tracking).

  4. Memory: Each FigureCanvasQTAgg embeds a full Agg renderer. Three plot widgets means three independent renderers in memory.

  5. Import time: matplotlib is one of the heaviest imports, adding ~500ms to GUI startup.

What PyQtGraph offers:

  1. Native Qt rendering: Renders directly via QPainter, so it shares the Qt event loop and palette natively. No Agg renderer overhead.

  2. Real-time performance: Optimized for live data display (oscilloscope-style). Can handle 10k+ points at 60fps.

  3. Built-in interactivity: Mouse-wheel zoom, drag-to-pan, hover crosshair, region-of-interest selection – all built in.

  4. Theme integration: Uses Qt’s QPalette directly. The existing ColorPalette.to_qpalette() method works without additional matplotlib synchronization.

  5. Lighter weight: ~50MB vs matplotlib’s ~100MB. Faster import.

Decision

Replace matplotlib with PyQtGraph for all interactive GUI plots. Retain matplotlib as an optional dependency for publication-quality static export.

Migration strategy:

  1. Define a PlotWidget protocol matching the current interface (clear(), plot_single(), plot_multi(), update_theme(), set_scales()).

  2. Implement PyQtGraphPlotCanvas, PyQtGraphF1F2Plot, PyQtGraphMultiF1F2Plot behind this protocol.

  3. Swap imports in gui/main_window.py from matplotlib widgets to PyQtGraph widgets.

  4. Remove apply_matplotlib_theme() from gui/style.py.

  5. Add an optional export_publication_plot() function that uses matplotlib for high-DPI vector export (PDF/SVG).

Consequences

Positive

  • Rendering performance: Plot updates for 500+ point energy sweeps will be near-instantaneous instead of the ~100ms matplotlib redraw.

  • Theme consistency: PyQtGraph inherits the Qt palette automatically. The manual update_theme() methods (25 lines each across 3 widgets) become trivial or unnecessary.

  • Interactivity: Users get smooth zoom/pan, hover crosshair with value readout, and real-time cursor tracking without custom code.

  • Startup time: Removing matplotlib from the GUI import chain saves ~500ms of startup time.

  • Code reduction: The three matplotlib widget classes (~165 lines total) can be replaced with simpler PyQtGraph equivalents (~100 lines total), since PyQtGraph handles more out of the box.

Negative

  • Log-scale axes: PyQtGraph’s setLogMode(x=True) works but tick formatting is less polished than matplotlib. May need custom AxisItem subclass for publication-quality log ticks.

  • Legend placement: PyQtGraph’s LegendItem has less automatic layout intelligence than matplotlib’s ax.legend(). May need manual positioning.

  • Publication export: PyQtGraph produces raster output by default. For vector (PDF/SVG) export, either use PyQtGraph’s exportFile() or fall back to matplotlib.

  • Familiarity: Team members experienced with matplotlib will need to learn PyQtGraph’s API (PlotWidget, PlotItem, PlotDataItem).

Migration Details

Current matplotlib pattern:

# plot_canvas.py
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
from matplotlib.figure import Figure

class PlotCanvas(QWidget):
    def __init__(self):
        self.figure = Figure(figsize=(6, 4))
        self.canvas = FigureCanvasQTAgg(self.figure)

    def plot_single(self, result, property_name, ylabel):
        self.figure.clear()
        ax = self.figure.add_subplot(111)
        ax.plot(x, y, label=label, marker='o', linewidth=1.5)
        ax.set_xlabel("Energy (keV)")
        self.canvas.draw_idle()

Target PyQtGraph pattern:

# plot_canvas.py
import pyqtgraph as pg

class PlotCanvas(QWidget):
    def __init__(self):
        self.plot_widget = pg.PlotWidget()
        self.plot_widget.setBackground(None)  # Use Qt palette
        self.plot_widget.showGrid(x=True, y=True, alpha=0.3)

    def plot_single(self, result, property_name, ylabel):
        self.plot_widget.clear()
        self.plot_widget.plot(x, y, name=label,
            pen=pg.mkPen(width=1.5), symbol='o', symbolSize=6)
        self.plot_widget.setLabel('bottom', "Energy (keV)")

Log scale handling:

# PyQtGraph log mode
self.plot_widget.setLogMode(x=self.log_x, y=self.log_y)
# Custom tick formatting for nice log labels
if self.log_x:
    self.plot_widget.getAxis('bottom').setStyle(tickTextOffset=4)

Appendix: Feature Parity Checklist

Feature

matplotlib (current)

PyQtGraph (target)

Notes

Line plot

ax.plot()

pw.plot()

Direct equivalent

Markers

marker='o', markersize=6

symbol='o', symbolSize=6

Direct equivalent

Log axes

ax.set_xscale('log')

pw.setLogMode(x=True)

Direct equivalent

Grid

ax.grid(True, alpha=0.3)

pw.showGrid(x=True, y=True, alpha=0.3)

Direct equivalent

Legend

ax.legend()

pw.addLegend()

PyQtGraph needs manual call

Axis labels

ax.set_xlabel()

pw.setLabel('bottom', ...)

Different API

Dual subplot

fig.add_subplot(211)

Two PlotWidget in QSplitter

Architectural change

Theme colors

Manual rcParams sync

Qt QPalette (automatic)

Simplification

Zoom/Pan

NavigationToolbar

Built-in mouse interaction

Feature upgrade

Export

fig.savefig()

pg.exportFile() or matplotlib fallback

May need both