make the crosshair cursor fast on HiDPI screens with dirty-strip blitting

The previous blit path converted the whole axes bbox (several megapixels
on HiDPI displays) from RGBA to premultiplied ARGB on every motion
event, costing about 84 ms per frame on a maximized window. Per frame
now only the thin strips around the old and new line positions are
erased from a cached full-figure background, redrawn and blitted,
which brings the frame cost below 1 ms.

- BlitSafeCanvas paints blitted regions immediately instead of queueing
  them, which kept regular paints partial and wiped the axis labels
- CrosshairCursor coalesces motion events to 60 fps and erases strips
  by direct numpy copy from the background; matplotlib's restore_region
  with a bbox argument addresses a different coordinate frame and
  restores the wrong area, so it is avoided
- after a full render the old line positions are blitted right away,
  otherwise motion events processed before the pending repaint drew the
  cursor at its new position next to the stale one on screen
- clear_display recreates the crosshair so it survives plot switches

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
markusro committed 2026-10-18 19:10:17 +02:00
1 parent fe65073988
commit 18e085d3ee
1 file changed
+161 -3
+161 -3
View File
@@ -48,6 +48,9 @@ import numpy
# math graphics
import matplotlib
import cairo
from matplotlib import cbook
from matplotlib.transforms import Bbox
# force noninteractive
matplotlib.rcParams[ "interactive" ] = "False"
matplotlib.rcParams[ "text.usetex" ] = "False"
@@ -3011,6 +3014,156 @@ crosshair_xpm = (
)
class BlitSafeCanvas( FigureCanvas ):
"""
GTK3Agg canvas that paints blitted regions immediately.
The stock blit appends the region to an internal bbox queue and the next
paint then redraws only that region; a full redraw (data update, zoom)
happening meanwhile is clipped to it, wiping the axis labels from the
screen. Painting the region right away keeps every regular paint a
complete one and stays a partial update.
"""
def blit( self, bbox=None ):
if bbox is None:
bbox = self.figure.bbox
window = self.get_window( )
if window is None:
return
scale = self.device_pixel_ratio
allocation = self.get_allocation( )
x = int( bbox.x0 / scale )
y = allocation.height - int( bbox.y1 / scale )
width = ( int( bbox.x1 ) - int( bbox.x0 ) ) // scale
height = ( int( bbox.y1 ) - int( bbox.y0 ) ) // scale
region = cairo.Region( cairo.RectangleInt( x, y, width, height ) )
context = window.begin_draw_frame( region )
try:
buffer = cbook._unmultiplied_rgba8888_to_premultiplied_argb32(
numpy.asarray( self.copy_from_bbox( bbox ) ) )
image = cairo.ImageSurface.create_for_data(
buffer.ravel( ).data, cairo.FORMAT_ARGB32,
int( bbox.x1 ) - int( bbox.x0 ), int( bbox.y1 ) - int( bbox.y0 ) )
image.set_device_scale( scale, scale )
cr = context.get_cairo_context( )
cr.set_source_surface( image, x, y )
cr.paint( )
finally:
window.end_draw_frame( context )
class CrosshairCursor( matplotlib.widgets.Cursor ):
"""
Crosshair cursor with blitting that coalesces motion events to a fixed
frame rate. GTK delivers motion-notify events at the mouse's full
reporting rate (up to 1000 Hz); restoring the plot background for every
single event makes the GUI sluggish, in particular on remote X11
connections. A frame rate limit is visually indistinguishable.
Per frame only the thin strips around the old and the new line position
are restored, redrawn and blitted. Blitting the full axes bbox instead
requires a pixel format conversion of several megapixels per frame,
which is far too slow on HiDPI screens.
"""
def __init__( self, ax, frame_rate=60, **lineprops ):
matplotlib.widgets.Cursor.__init__( self, ax, useblit=True, **lineprops )
self.frame_interval = 1.0 / frame_rate
self.last_frame_time = 0.0
self._last_x = None
self._last_y = None
def clear( self, event ):
"""Refresh the saved background after a full render."""
if self.ignore( event ):
return
if self.useblit:
# save the full figure so the saved array stays aligned with
# the canvas buffer and strips can be restored by direct
# array copy (restore_region with a bbox argument addresses
# a different coordinate frame and restores the wrong area)
self.background = self.canvas.copy_from_bbox(
self.canvas.figure.bbox )
# the render removed the cursor lines from the buffer, but
# the screen still shows them until the repaint arrives;
# motion events processed in between would draw the cursor
# at its new position next to the stale one, so wipe the old
# line positions right away
for strip in ( self._strip_bbox( self._last_x, None, True ),
self._strip_bbox( self._last_y, None, False ) ):
if strip is not None:
self.canvas.blit( strip )
self.linev.set_visible( False )
self.lineh.set_visible( False )
self._last_x = None
self._last_y = None
def _strip_bbox( self, prev, cur, vertical ):
"""Bbox covering the old and new position of one cursor line.
The strips are padded by a pixel on each side so that integer
truncation downstream never clips a line pixel at the edges.
"""
bbox = self.ax.bbox
positions = [p for p in ( prev, cur ) if p is not None]
if not positions:
return None
if vertical:
lo = max( min( positions ) - 4, bbox.x0 )
hi = min( max( positions ) + 5, bbox.x1 )
if hi <= lo:
return None
return Bbox( [[lo, bbox.y0 - 1], [hi, bbox.y1 + 1]] )
lo = max( min( positions ) - 4, bbox.y0 )
hi = min( max( positions ) + 5, bbox.y1 )
if hi <= lo:
return None
return Bbox( [[bbox.x0 - 1, lo], [bbox.x1 + 1, hi]] )
def onmove( self, event ):
if self.ignore( event ) or not self.canvas.widgetlock.available( self ):
return
now = time.monotonic( )
if now - self.last_frame_time < self.frame_interval:
return
self.last_frame_time = now
if self.background is None:
return
if event.inaxes is self.ax and event.xdata is not None and event.ydata is not None:
self.linev.set_xdata( ( event.xdata, event.xdata ) )
self.lineh.set_ydata( ( event.ydata, event.ydata ) )
self.linev.set_visible( self.visible and self.vertOn )
self.lineh.set_visible( self.visible and self.horizOn )
new_x, new_y = event.x, event.y
else:
# pointer left the plot: hide the lines by restoring the plain
# background, no full render needed
self.linev.set_visible( False )
self.lineh.set_visible( False )
new_x = new_y = None
bbox = self.ax.bbox
vstrip = self._strip_bbox( self._last_x, new_x, True )
hstrip = self._strip_bbox( self._last_y, new_y, False )
self._last_x, self._last_y = new_x, new_y
# erase the previous lines by copying the strips back from the
# saved background
background = numpy.asarray( self.background )
width, height = self.canvas.get_width_height( physical=True )
buffer = numpy.frombuffer( self.canvas.buffer_rgba( ),
dtype=numpy.uint8 ).reshape( height, width, 4 )
for strip in ( vstrip, hstrip ):
if strip is not None:
rows = slice( height - int( strip.y1 ),
height - int( strip.y0 ) + 1 )
cols = slice( int( strip.x0 ), int( strip.x1 ) + 1 )
buffer[rows, cols] = background[rows, cols]
if self.linev.get_visible( ) or self.lineh.get_visible( ):
self.ax.draw_artist( self.linev )
self.ax.draw_artist( self.lineh )
for strip in ( vstrip, hstrip ):
if strip is not None:
self.canvas.blit( strip )
class MonitorWidgets:
"""Manages the matplotlib-based data visualization panel.
@@ -3100,7 +3253,7 @@ class MonitorWidgets:
self.matplot_axes.set_xscale( "linear" )
# add figure to canvas
self.matplot_canvas = FigureCanvas( self.matplot_figure )
self.matplot_canvas = BlitSafeCanvas( self.matplot_figure )
# add ctrl + right-click context menu to canvas
self.matplot_canvas.connect("button-press-event", self.on_canvas_button_press)
@@ -3640,8 +3793,8 @@ class MonitorWidgets:
if widget.get_active():
# Activate cursor
if not hasattr(self, 'matplot_cursor') or self.matplot_cursor is None:
self.matplot_cursor = matplotlib.widgets.Cursor(self.matplot_axes,
useblit=False, color='red', linewidth=1)
self.matplot_cursor = CrosshairCursor(self.matplot_axes,
color='red', linewidth=1)
self.matplot_canvas.draw()
# TODO: Deactivate the pane button
@@ -3838,6 +3991,11 @@ class MonitorWidgets:
except:
pass
self.clipping_marker = None
# the crosshair line artists were removed from the axes above,
# recreate the crosshair if it is enabled
if self.matplot_cursor_button.get_active( ):
self.matplot_cursor = CrosshairCursor(self.matplot_axes,
color='red', linewidth=1)
self.matplot_canvas.draw_idle( )
def update_display( self, subject=None ):