|
5 | 5 |
|
6 | 6 | from __future__ import annotations |
7 | 7 |
|
| 8 | +import math |
8 | 9 | import os |
9 | 10 | import os.path as osp |
10 | 11 | from collections import OrderedDict |
|
25 | 26 | _histogram, |
26 | 27 | _scale_rect, |
27 | 28 | ) |
28 | | -from plotpy.config import _ |
| 29 | +from plotpy.config import CONF, _ |
29 | 30 | from plotpy.constants import LUT_MAX, LUT_SIZE, LUTAlpha |
30 | 31 | from plotpy.coords import pixelround |
31 | 32 | from plotpy.interfaces import ( |
@@ -165,6 +166,35 @@ def _get_colormap_lut( |
165 | 166 | return colors, lut |
166 | 167 |
|
167 | 168 |
|
| 169 | +def get_offscreen_ratio(painter: QPainter) -> float: |
| 170 | + """Return the resolution at which image items are rendered before being |
| 171 | + drawn with the given painter, in pixels per canvas pixel. |
| 172 | +
|
| 173 | + Images are rendered at the resolution of the paint device, e.g. 1.25 or 2 |
| 174 | + pixels per canvas pixel with a 125% or 200% screen scaling, instead of being |
| 175 | + upscaled from the canvas resolution, which takes more time (up to about 4 |
| 176 | + times with a 200% screen scaling). Images are rendered at the canvas |
| 177 | + resolution when printing or exporting with a scaling (or rotating) painter |
| 178 | + transform, or when the ``image/device_resolution`` option of the ``plot`` |
| 179 | + configuration section is disabled. |
| 180 | +
|
| 181 | + Args: |
| 182 | + painter: Painter |
| 183 | +
|
| 184 | + Returns: |
| 185 | + Resolution, in pixels per canvas pixel |
| 186 | + """ |
| 187 | + world = painter.worldTransform() |
| 188 | + if ( |
| 189 | + world.isScaling() |
| 190 | + or world.isRotating() |
| 191 | + or not CONF.get("plot", "image/device_resolution", True) |
| 192 | + ): |
| 193 | + return 1.0 |
| 194 | + ratio = abs(painter.deviceTransform().m11()) |
| 195 | + return ratio if ratio > 0.0 else 1.0 |
| 196 | + |
| 197 | + |
168 | 198 | class BaseImageItem(QwtPlotItem): |
169 | 199 | """Base class for image items |
170 | 200 |
|
@@ -229,6 +259,8 @@ def __init__( |
229 | 259 | self._image: QG.QImage | None = None |
230 | 260 |
|
231 | 261 | self._offscreen = np.array((1, 1), np.uint32) |
| 262 | + #: Resolution of the offscreen image, in pixels per canvas pixel |
| 263 | + self._offscreen_ratio = 1.0 |
232 | 264 |
|
233 | 265 | # Linear interpolation is the default interpolation algorithm: |
234 | 266 | # it's almost as fast as 'nearest pixel' method but far smoother |
@@ -882,12 +914,39 @@ def draw_image( |
882 | 914 | self._get_display_data(), |
883 | 915 | src_rect, |
884 | 916 | self._offscreen, |
885 | | - dst_rect, |
| 917 | + self._offscreen_rect(dst_rect), |
886 | 918 | self.lut, |
887 | 919 | self.interpolate, |
888 | 920 | ) |
889 | | - qrect = QC.QRectF(QC.QPointF(dest[0], dest[1]), QC.QPointF(dest[2], dest[3])) |
890 | | - painter.drawImage(qrect, self._image, qrect) |
| 921 | + self._draw_offscreen(painter, dest) |
| 922 | + |
| 923 | + def _offscreen_rect( |
| 924 | + self, rect: tuple[float, float, float, float] |
| 925 | + ) -> tuple[int, int, int, int]: |
| 926 | + """Convert a rectangle from canvas coordinates to offscreen image pixels |
| 927 | +
|
| 928 | + Args: |
| 929 | + rect: Rectangle (x1, y1, x2, y2), in canvas coordinates |
| 930 | +
|
| 931 | + Returns: |
| 932 | + Rectangle (x1, y1, x2, y2), in offscreen image pixels |
| 933 | + """ |
| 934 | + ratio = self._offscreen_ratio |
| 935 | + return tuple(int(value * ratio) for value in rect) |
| 936 | + |
| 937 | + def _draw_offscreen( |
| 938 | + self, painter: QPainter, rect: tuple[int, int, int, int] |
| 939 | + ) -> None: |
| 940 | + """Draw a part of the offscreen image on the canvas |
| 941 | +
|
| 942 | + Args: |
| 943 | + painter: Painter |
| 944 | + rect: Rectangle (x1, y1, x2, y2) to be drawn, in offscreen image pixels |
| 945 | + """ |
| 946 | + source = QC.QRectF(QC.QPointF(rect[0], rect[1]), QC.QPointF(rect[2], rect[3])) |
| 947 | + ratio = self._offscreen_ratio |
| 948 | + target = QC.QRectF(source.topLeft() / ratio, source.bottomRight() / ratio) |
| 949 | + painter.drawImage(target, self._image, source) |
891 | 950 |
|
892 | 951 | def export_roi( |
893 | 952 | self, |
@@ -941,20 +1000,26 @@ def draw( |
941 | 1000 | yMap: Y axis scale map |
942 | 1001 | canvasRect: Canvas rectangle |
943 | 1002 | """ |
944 | | - x1, y1, x2, y2 = canvasRect.getCoords() |
| 1003 | + # The offscreen image covers the canvas at the resolution of the paint |
| 1004 | + # device (see `get_offscreen_ratio`), rounded up to whole pixels |
| 1005 | + ratio = self._offscreen_ratio = get_offscreen_ratio(painter) |
| 1006 | + W = math.ceil(canvasRect.right() * ratio) |
| 1007 | + H = math.ceil(canvasRect.bottom() * ratio) |
| 1008 | + x1, y1 = canvasRect.left(), canvasRect.top() |
| 1009 | + x2, y2 = W / ratio, H / ratio |
945 | 1010 | i1, i2 = xMap.invTransform(x1), xMap.invTransform(x2) |
946 | 1011 | j1, j2 = yMap.invTransform(y1), yMap.invTransform(y2) |
947 | 1012 |
|
| 1013 | + # Destination rectangle, extended by one offscreen image pixel to the right |
| 1014 | + # and to the bottom (offscreen image pixels being truncated) |
948 | 1015 | xl, yt, xr, yb = self.boundingRect().getCoords() |
949 | 1016 | dest = ( |
950 | 1017 | xMap.transform(xl), |
951 | 1018 | yMap.transform(yt), |
952 | | - xMap.transform(xr) + 1, |
953 | | - yMap.transform(yb) + 1, |
| 1019 | + xMap.transform(xr) + 1 / ratio, |
| 1020 | + yMap.transform(yb) + 1 / ratio, |
954 | 1021 | ) |
955 | 1022 |
|
956 | | - W = int(canvasRect.right()) |
957 | | - H = int(canvasRect.bottom()) |
958 | 1023 | if self._offscreen.shape != (H, W): |
959 | 1024 | self._offscreen = np.empty((H, W), np.uint32) |
960 | 1025 | self._image = QG.QImage(self._offscreen, W, H, QG.QImage.Format_ARGB32) |
|
0 commit comments