Skip to content

Commit 54de849

Browse files
committed
perf(image): reuse image renderings when only overlays change
Moving or resizing a shape, a marker, a cursor or an annotation over an image, or drawing a new shape, rendered the image again for each mouse move, although it did not change: e.g. 68 ms per mouse move for a 2048 x 2048 masked image with the anti-aliasing interpolation, 175 ms with a 200% display scaling. The interaction handlers now redraw the plot with the new `BasePlot.replot_overlays` method, which does not increment the replot count of the plot (`BasePlot.get_replot_count`), unlike `replot` and `invalidate`. Image items record the offscreen images they draw (`_draw_offscreen`), and draw them again instead of rendering the item when the key of the rendering does not change: replot count, plot geometry, item position, LUT and interpolation values, and data identity (weakly referenced). Only `draw_image` methods decorated with `reusable_rendering` (which only draw offscreen images) are concerned: those of `BaseImageItem`, `ImageItem`, `XYImageItem`, the masked image items and `Histogram2DItem`, but not those of `TrImageItem` (rotation point), `QuadGridItem` (offscreen image cleared after drawing), filters and subclasses which override `draw_image`. The mask overlay of masked images is now rendered in its own offscreen image, so that the rendering of the image may be drawn again, and it is no longer rendered when it is fully transparent (no masked pixel and transparent unmasked area, the default): panning or zooming masked images without masked pixels is up to 25% faster. Each mouse move now takes about 4 ms (10 ms with a 200% display scaling), whatever the interpolation: see the new image interaction benchmark (plotpy/tests/benchmarks/test_image_interaction.py). Renderings drawn again are identical to new renderings, and new renderings are unchanged. Assisted-by: Claude Opus 5.5
1 parent b245570 commit 54de849

8 files changed

Lines changed: 660 additions & 61 deletions

File tree

‎plotpy/events.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1607,7 +1607,8 @@ def move(self, filter: StatefulEventFilter, event: QC.QEvent) -> None:
16071607
self.active, self.first_pos, event.pos(), self.handle, ctrl
16081608
)
16091609
self.last_pos = QC.QPointF(event.pos())
1610-
filter.plot.replot()
1610+
# Images are drawn again without being rendered, unless they are moved
1611+
filter.plot.replot_overlays()
16111612

16121613
def stop_tracking(self, filter: StatefulEventFilter, event: QC.QEvent) -> None:
16131614
"""Stop tracking the object.
@@ -1723,7 +1724,7 @@ def move(self, filter: StatefulEventFilter, event: QMouseEvent) -> None:
17231724
"""
17241725
self.shape.move_local_point_to(self.shape_h1, QC.QPointF(event.pos()))
17251726
self.move_action(filter, event)
1726-
filter.plot.replot()
1727+
filter.plot.replot_overlays()
17271728

17281729
def move_action(self, filter: StatefulEventFilter, event: QMouseEvent) -> None:
17291730
"""Move the object.
@@ -1882,7 +1883,7 @@ def move(self, filter: StatefulEventFilter, event: QMouseEvent) -> None:
18821883
event: The mouse event.
18831884
"""
18841885
self.shape.move_local_point_to(self.current_handle, QC.QPointF(event.pos()))
1885-
filter.plot.replot()
1886+
filter.plot.replot_overlays()
18861887

18871888
def stop_notmoving(self, filter: StatefulEventFilter, event: QMouseEvent) -> None:
18881889
"""Stops tracking when the drag event is not moving.

‎plotpy/items/image/base.py‎

Lines changed: 161 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
import math
99
import os
1010
import os.path as osp
11+
import weakref
1112
from collections import OrderedDict
13+
from collections.abc import Callable
1214
from typing import TYPE_CHECKING, Any
1315

1416
import numpy as np
@@ -195,6 +197,60 @@ def get_offscreen_ratio(painter: QPainter) -> float:
195197
return ratio if ratio > 0.0 else 1.0
196198

197199

200+
def reusable_rendering(draw_image: Callable) -> Callable:
201+
"""Decorator marking a ``draw_image`` method whose rendering may be reused
202+
203+
The decorated method must only draw with :meth:`BaseImageItem._draw_offscreen`,
204+
from offscreen images which are only modified when the item is rendered. The
205+
rendering may then be drawn again without being computed when the plot is
206+
redrawn with :meth:`.BasePlot.replot_overlays` (e.g. while moving a shape over
207+
the image).
208+
209+
Args:
210+
draw_image: ``draw_image`` method
211+
212+
Returns:
213+
The same method
214+
"""
215+
draw_image.reusable_rendering = True
216+
return draw_image
217+
218+
219+
class _RenderKey:
220+
"""Key of the rendering of an image item (see :func:`reusable_rendering`)
221+
222+
The key is made of values, compared by equality, and of objects (e.g. data
223+
arrays), compared by identity. The objects are referenced weakly, so that they
224+
are not kept alive by the key: an object which does not exist anymore does not
225+
match any object, even another one with the same ``id``.
226+
227+
Args:
228+
values: Values
229+
objects: Objects (arrays or None)
230+
"""
231+
232+
__slots__ = ("values", "refs")
233+
234+
def __init__(self, values: tuple, objects: tuple[np.ndarray | None, ...]) -> None:
235+
self.values = values
236+
self.refs = tuple(None if obj is None else weakref.ref(obj) for obj in objects)
237+
238+
def __eq__(self, other: object) -> bool:
239+
if not isinstance(other, _RenderKey) or self.values != other.values:
240+
return False
241+
if len(self.refs) != len(other.refs):
242+
return False
243+
for ref, other_ref in zip(self.refs, other.refs):
244+
if ref is None or other_ref is None:
245+
if ref is not other_ref:
246+
return False
247+
elif ref() is None or ref() is not other_ref():
248+
return False
249+
return True
250+
251+
__hash__ = None
252+
253+
198254
class BaseImageItem(QwtPlotItem):
199255
"""Base class for image items
200256
@@ -261,6 +317,10 @@ def __init__(
261317
self._offscreen = np.array((1, 1), np.uint32)
262318
#: Resolution of the offscreen image, in pixels per canvas pixel
263319
self._offscreen_ratio = 1.0
320+
#: Offscreen images and rectangles drawn by the last rendering of the item
321+
self._offscreen_draws: list[tuple[QG.QImage, tuple[int, int, int, int]]] = []
322+
#: Key of the last rendering of the item (see `_get_render_key`)
323+
self._render_key: _RenderKey | None = None
264324

265325
# Linear interpolation is the default interpolation algorithm:
266326
# it's almost as fast as 'nearest pixel' method but far smoother
@@ -866,6 +926,17 @@ def draw_border(
866926
"""
867927
self.border_rect.draw(painter, xMap, yMap, canvasRect)
868928

929+
def _has_linear_scales(self) -> bool:
930+
"""Return True if the image axes of the plot have linear scales
931+
932+
Returns:
933+
True if linear scales
934+
"""
935+
plot: BasePlot = self.plot()
936+
return isinstance(
937+
plot.axisScaleEngine(plot.X_BOTTOM), QwtLinearScaleEngine
938+
) and isinstance(plot.axisScaleEngine(plot.Y_LEFT), QwtLinearScaleEngine)
939+
869940
def warn_if_non_linear_scale(self, painter: QPainter, canvasRect: QRectF) -> bool:
870941
"""Warn if non-linear scale
871942
@@ -876,10 +947,7 @@ def warn_if_non_linear_scale(self, painter: QPainter, canvasRect: QRectF) -> boo
876947
Returns:
877948
True if non-linear scale
878949
"""
879-
plot: BasePlot = self.plot()
880-
if not isinstance(
881-
plot.axisScaleEngine(plot.X_BOTTOM), QwtLinearScaleEngine
882-
) or not isinstance(plot.axisScaleEngine(plot.Y_LEFT), QwtLinearScaleEngine):
950+
if not self._has_linear_scales():
883951
painter.setPen(QC.Qt.red)
884952
painter.drawText(
885953
canvasRect,
@@ -889,6 +957,7 @@ def warn_if_non_linear_scale(self, painter: QPainter, canvasRect: QRectF) -> boo
889957
return True
890958
return False
891959

960+
@reusable_rendering
892961
def draw_image(
893962
self,
894963
painter: QPainter,
@@ -935,18 +1004,85 @@ def _offscreen_rect(
9351004
return tuple(int(value * ratio) for value in rect)
9361005

9371006
def _draw_offscreen(
938-
self, painter: QPainter, rect: tuple[int, int, int, int]
1007+
self,
1008+
painter: QPainter,
1009+
rect: tuple[int, int, int, int],
1010+
image: QG.QImage | None = None,
9391011
) -> None:
940-
"""Draw a part of the offscreen image on the canvas
1012+
"""Draw a part of an offscreen image on the canvas
9411013
9421014
Args:
9431015
painter: Painter
9441016
rect: Rectangle (x1, y1, x2, y2) to be drawn, in offscreen image pixels
1017+
image: Offscreen image (default: offscreen image of the item)
1018+
"""
1019+
if image is None:
1020+
image = self._image
1021+
self._offscreen_draws.append((image, rect))
1022+
self._draw_offscreen_image(painter, image, rect)
1023+
1024+
def _draw_offscreen_image(
1025+
self, painter: QPainter, image: QG.QImage, rect: tuple[int, int, int, int]
1026+
) -> None:
1027+
"""Draw a part of an offscreen image on the canvas
1028+
1029+
Args:
1030+
painter: Painter
1031+
image: Offscreen image
1032+
rect: Rectangle (x1, y1, x2, y2) to be drawn, in offscreen image pixels
9451033
"""
9461034
source = QC.QRectF(QC.QPointF(rect[0], rect[1]), QC.QPointF(rect[2], rect[3]))
9471035
ratio = self._offscreen_ratio
9481036
target = QC.QRectF(source.topLeft() / ratio, source.bottomRight() / ratio)
949-
painter.drawImage(target, self._image, source)
1037+
painter.drawImage(target, image, source)
1038+
1039+
def _get_render_key(
1040+
self,
1041+
canvasRect: QRectF,
1042+
src_rect: tuple[float, float, float, float],
1043+
dst_rect: tuple[float, float, float, float],
1044+
) -> _RenderKey | None:
1045+
"""Return the key of the rendering of the item: the previous rendering may
1046+
be drawn again as long as the key does not change
1047+
1048+
Args:
1049+
canvasRect: Canvas rectangle
1050+
src_rect: Source rectangle
1051+
dst_rect: Destination rectangle
1052+
1053+
Returns:
1054+
Key, or None if the rendering may not be reused (``draw_image`` not
1055+
decorated with :func:`reusable_rendering`)
1056+
"""
1057+
if not getattr(type(self).draw_image, "reusable_rendering", False):
1058+
return None
1059+
if not self._has_linear_scales():
1060+
# A warning is drawn instead of the image
1061+
return None
1062+
# No LUT for RGB images
1063+
lut_values = None
1064+
if self.lut is not None:
1065+
# The colormap may be changed in place (e.g. by `set_color_map`)
1066+
a, b, bg, cmap = self.lut
1067+
lut_values = (a, b, bg, cmap.tobytes())
1068+
interp_values = tuple(
1069+
value.tobytes() if isinstance(value, np.ndarray) else value
1070+
for value in self.interpolate
1071+
)
1072+
values = (
1073+
# Image items are rendered again on each replot (data, contrast, ...)
1074+
self.plot().get_replot_count(),
1075+
# Plot geometry and item position
1076+
self._offscreen_ratio,
1077+
canvasRect.getCoords(),
1078+
src_rect,
1079+
dst_rect,
1080+
# LUT and interpolation changes, even without replot
1081+
lut_values,
1082+
interp_values,
1083+
)
1084+
# Data changes, even without replot
1085+
return _RenderKey(values, (self._get_display_data(),))
9501086

9511087
def export_roi(
9521088
self,
@@ -1020,12 +1156,24 @@ def draw(
10201156
yMap.transform(yb) + 1 / ratio,
10211157
)
10221158

1023-
if self._offscreen.shape != (H, W):
1024-
self._offscreen = np.empty((H, W), np.uint32)
1025-
self._image = QG.QImage(self._offscreen, W, H, QG.QImage.Format_ARGB32)
1026-
self._image.ndarray = self._offscreen
1027-
self.notify_new_offscreen()
1028-
self.draw_image(painter, canvasRect, (i1, j1, i2, j2), dest, xMap, yMap)
1159+
src_rect = (i1, j1, i2, j2)
1160+
key = self._get_render_key(canvasRect, src_rect, dest)
1161+
if key is not None and key == self._render_key:
1162+
# The item has not changed (e.g. a shape is moved over the image): its
1163+
# previous rendering is drawn again
1164+
for image, rect in self._offscreen_draws:
1165+
self._draw_offscreen_image(painter, image, rect)
1166+
else:
1167+
self._render_key = None
1168+
self._offscreen_draws = []
1169+
if self._offscreen.shape != (H, W):
1170+
self._offscreen = np.empty((H, W), np.uint32)
1171+
self._image = QG.QImage(self._offscreen, W, H, QG.QImage.Format_ARGB32)
1172+
self._image.ndarray = self._offscreen
1173+
self.notify_new_offscreen()
1174+
self.draw_image(painter, canvasRect, src_rect, dest, xMap, yMap)
1175+
# Key after rendering, which may update the item (e.g. its LUT range)
1176+
self._render_key = self._get_render_key(canvasRect, src_rect, dest)
10291177
self.draw_border(painter, xMap, yMap, canvasRect)
10301178

10311179
def boundingRect(self) -> QRectF:

0 commit comments

Comments
 (0)