From 18e085d3ee40567867223b8754742b53ef88d0f8 Mon Sep 17 00:00:00 2001 From: Markus Rosenstihl Date: Sun, 18 Oct 2026 19:10:17 +0200 Subject: [PATCH] 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 --- src/damaris/gui/DamarisGUI.py | 164 +++++++++++++++++++++++++++++++++- 1 file changed, 161 insertions(+), 3 deletions(-) diff --git a/src/damaris/gui/DamarisGUI.py b/src/damaris/gui/DamarisGUI.py index 279d83f..ed117dd 100644 --- a/src/damaris/gui/DamarisGUI.py +++ b/src/damaris/gui/DamarisGUI.py @@ -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 ):