Skip to content

Commit bfc3332

Browse files
committed
fix(image): render images at the screen resolution
With a display scaling such as 125%, 150% or 200%, image items were rendered in an offscreen image of the canvas size (logical pixels), which was then upscaled to the screen: images were shown with half the screen resolution with a 200% scaling, and with one screen pixel row and column out of five duplicated with a 125% scaling. The offscreen image is now rendered at the resolution of the paint device, given by the painter device transform (`get_offscreen_ratio`). Destination rectangles are converted to offscreen image pixels (`_offscreen_rect`) and the offscreen image is drawn back in canvas coordinates (`_draw_offscreen`), so that the `draw_image` API is unchanged. Printing and exporting with a scaling painter transform keep the canvas resolution, as does the new `image/device_resolution` option of the `plot` configuration section when disabled. The rendering is unchanged with a 100% scaling. Converting destination rectangles to integers also fixes `RawImageItem`, whose drawing failed with "Invalid destination rectangle" errors. Assisted-by: Claude Opus 5.5
1 parent 775f245 commit bfc3332

8 files changed

Lines changed: 243 additions & 35 deletions

File tree

‎plotpy/config.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ def get_plotpy_defaults() -> dict[str, int | float | str | bool]:
5252
"curve/cosmetic_hairline": True,
5353
# Draw thick curves as several one-pixel strokes on screen
5454
"curve/fast_thick_lines": True,
55+
# Render images at the resolution of the screen (e.g. 125%, 200%)
56+
"image/device_resolution": True,
5557
"title/font/size": 12,
5658
"title/font/bold": False,
5759
"selected_curve_symbol/marker": "Rect",

‎plotpy/items/image/base.py‎

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from __future__ import annotations
77

8+
import math
89
import os
910
import os.path as osp
1011
from collections import OrderedDict
@@ -25,7 +26,7 @@
2526
_histogram,
2627
_scale_rect,
2728
)
28-
from plotpy.config import _
29+
from plotpy.config import CONF, _
2930
from plotpy.constants import LUT_MAX, LUT_SIZE, LUTAlpha
3031
from plotpy.coords import pixelround
3132
from plotpy.interfaces import (
@@ -165,6 +166,35 @@ def _get_colormap_lut(
165166
return colors, lut
166167

167168

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+
168198
class BaseImageItem(QwtPlotItem):
169199
"""Base class for image items
170200
@@ -229,6 +259,8 @@ def __init__(
229259
self._image: QG.QImage | None = None
230260

231261
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
232264

233265
# Linear interpolation is the default interpolation algorithm:
234266
# it's almost as fast as 'nearest pixel' method but far smoother
@@ -882,12 +914,39 @@ def draw_image(
882914
self._get_display_data(),
883915
src_rect,
884916
self._offscreen,
885-
dst_rect,
917+
self._offscreen_rect(dst_rect),
886918
self.lut,
887919
self.interpolate,
888920
)
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)
891950

892951
def export_roi(
893952
self,
@@ -941,20 +1000,26 @@ def draw(
9411000
yMap: Y axis scale map
9421001
canvasRect: Canvas rectangle
9431002
"""
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
9451010
i1, i2 = xMap.invTransform(x1), xMap.invTransform(x2)
9461011
j1, j2 = yMap.invTransform(y1), yMap.invTransform(y2)
9471012

1013+
# Destination rectangle, extended by one offscreen image pixel to the right
1014+
# and to the bottom (offscreen image pixels being truncated)
9481015
xl, yt, xr, yb = self.boundingRect().getCoords()
9491016
dest = (
9501017
xMap.transform(xl),
9511018
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,
9541021
)
9551022

956-
W = int(canvasRect.right())
957-
H = int(canvasRect.bottom())
9581023
if self._offscreen.shape != (H, W):
9591024
self._offscreen = np.empty((H, W), np.uint32)
9601025
self._image = QG.QImage(self._offscreen, W, H, QG.QImage.Format_ARGB32)

‎plotpy/items/image/filter.py‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -349,12 +349,11 @@ def draw_image(
349349
new_data,
350350
(x, y, src_rect),
351351
self._offscreen,
352-
dstRect.getCoords(),
352+
self._offscreen_rect(dstRect.getCoords()),
353353
lut,
354354
self.interpolate,
355355
)
356-
qrect = QC.QRectF(QC.QPointF(dest[0], dest[1]), QC.QPointF(dest[2], dest[3]))
357-
painter.drawImage(qrect, self._image, qrect)
356+
self._draw_offscreen(painter, dest)
358357

359358

360359
assert_interfaces_valid(ImageFilterItem)

‎plotpy/items/image/masked.py‎

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import guidata.io
88
import numpy as np
99
import numpy.ma as ma
10-
from qtpy import QtCore as QC
1110

1211
from plotpy import io
1312
from plotpy._scaler import INTERP_NEAREST, _scale_rect, _scale_xy
@@ -556,14 +555,11 @@ def draw_image(
556555
lut = (1, 0, bg, cmap)
557556
shown_data = np.ma.getmaskarray(self.data)
558557
src2 = self._rescale_src_rect(src_rect)
559-
dst_rect = tuple([int(i) for i in dst_rect])
558+
dst_rect = self._offscreen_rect(dst_rect)
560559
dest = _scale_rect(
561560
shown_data, src2, self._offscreen, dst_rect, lut, (INTERP_NEAREST,)
562561
)
563-
qrect = QC.QRectF(
564-
QC.QPointF(dest[0], dest[1]), QC.QPointF(dest[2], dest[3])
565-
)
566-
painter.drawImage(qrect, self._image, qrect)
562+
self._draw_offscreen(painter, dest)
567563

568564
# ---- RawImageItem API -----------------------------------------------------
569565
def set_data(
@@ -734,14 +730,11 @@ def draw_image(
734730
shown_data = np.ma.getmaskarray(self.data)
735731

736732
xytr = self.x, self.y, src_rect
737-
dst_rect = tuple([int(i) for i in dst_rect])
733+
dst_rect = self._offscreen_rect(dst_rect)
738734
dest = _scale_xy(
739735
shown_data, xytr, self._offscreen, dst_rect, lut, (INTERP_NEAREST,)
740736
)
741-
qrect = QC.QRectF(
742-
QC.QPointF(dest[0], dest[1]), QC.QPointF(dest[2], dest[3])
743-
)
744-
painter.drawImage(qrect, self._image, qrect)
737+
self._draw_offscreen(painter, dest)
745738

746739
def set_data(
747740
self, data: np.ndarray, lut_range: list[float, float] | None = None

‎plotpy/items/image/misc.py‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,13 +180,12 @@ def draw_image(
180180
self.data,
181181
src_rect,
182182
self._offscreen,
183-
dst_rect,
183+
self._offscreen_rect(dst_rect),
184184
self.lut,
185185
self.interpolate,
186186
self.grid,
187187
)
188-
qrect = QC.QRectF(QC.QPointF(dest[0], dest[1]), QC.QPointF(dest[2], dest[3]))
189-
painter.drawImage(qrect, self._image, qrect)
188+
self._draw_offscreen(painter, dest)
190189
xl, yt, xr, yb = dest
191190
self._offscreen[yt:yb, xl:xr] = 0
192191

‎plotpy/items/image/standard.py‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ def draw_image(
357357
return
358358

359359
src2 = self._rescale_src_rect(src_rect)
360-
dst_rect = tuple([int(i) for i in dst_rect])
360+
dst_rect = self._offscreen_rect(dst_rect)
361361

362362
try:
363363
dest = _scale_rect(
@@ -371,8 +371,7 @@ def draw_image(
371371
except ValueError:
372372
# This exception is raised when zooming unreasonably inside a pixel
373373
return
374-
qrect = QC.QRectF(QC.QPointF(dest[0], dest[1]), QC.QPointF(dest[2], dest[3]))
375-
painter.drawImage(qrect, self._image, qrect)
374+
self._draw_offscreen(painter, dest)
376375

377376
def export_roi(
378377
self,
@@ -658,7 +657,7 @@ def draw_image(
658657
if self.warn_if_non_linear_scale(painter, canvasRect):
659658
return
660659
xytr = self.x, self.y, src_rect
661-
dst_rect = tuple([int(i) for i in dst_rect])
660+
dst_rect = self._offscreen_rect(dst_rect)
662661
dest = _scale_xy(
663662
self._get_display_data(),
664663
xytr,
@@ -667,8 +666,7 @@ def draw_image(
667666
self.lut,
668667
self.interpolate,
669668
)
670-
qrect = QC.QRectF(QC.QPointF(dest[0], dest[1]), QC.QPointF(dest[2], dest[3]))
671-
painter.drawImage(qrect, self._image, qrect)
669+
self._draw_offscreen(painter, dest)
672670

673671
def export_roi(
674672
self,

‎plotpy/items/image/transform.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -271,9 +271,13 @@ def draw_image(
271271
sy = (y1 - y0) / (H - 1)
272272
# tr1 = tr(x0,y0)@scale(sx,sy)@tr(-cx,-cy)
273273
tr = np.array([[sx, 0, x0 - cx * sx], [0, sy, y0 - cy * sy], [0, 0, 1]], float)
274+
ratio = self._offscreen_ratio
275+
if ratio != 1.0:
276+
# From offscreen image pixels to canvas coordinates
277+
tr = tr @ np.diag([1.0 / ratio, 1.0 / ratio, 1.0])
274278
mat = self.tr @ tr
275279

276-
dst_rect = tuple([int(i) for i in dst_rect])
280+
dst_rect = self._offscreen_rect(dst_rect)
277281
dest = _scale_tr(
278282
self._get_display_data(),
279283
mat,
@@ -282,8 +286,7 @@ def draw_image(
282286
self.lut,
283287
self.interpolate,
284288
)
285-
qrect = QC.QRectF(QC.QPointF(dest[0], dest[1]), QC.QPointF(dest[2], dest[3]))
286-
painter.drawImage(qrect, self._image, qrect)
289+
self._draw_offscreen(painter, dest)
287290
# -- rotation circle
288291
if self.can_rotate():
289292
if self.rotation_point is None:

0 commit comments

Comments
 (0)