diff --git a/plotnine/composition/_inset_image.py b/plotnine/composition/_inset_image.py index 3d3ef7525..2df9e66e3 100644 --- a/plotnine/composition/_inset_image.py +++ b/plotnine/composition/_inset_image.py @@ -4,12 +4,14 @@ from typing import TYPE_CHECKING, Literal import numpy as np +from PIL import Image from PIL.Image import Image as PILImage from ..themes.theme import theme if TYPE_CHECKING: from matplotlib.figure import Figure + from matplotlib.image import BboxImage from matplotlib.patches import Rectangle from matplotlib.transforms import Bbox @@ -62,6 +64,10 @@ class _InsetImage: # ratio doesn't match. _anchor: tuple[float, float] + # BboxImage artist created in draw(); its data is updated by + # _arrange_in_box once the layout engine computes the final bbox. + _image_artist: BboxImage + def __init__( self, image: PILImage | np.ndarray, @@ -70,8 +76,8 @@ def __init__( ): from matplotlib.transforms import Bbox - self._image = image - self._image_size = _image_size(image) # (W, H) px + self._image = _to_pil_image(image) + self._image_size = self._image.size # (W, H) px self._frac_bbox = Bbox.unit() self._anchor = _resolve_anchor(anchor) self.theme = theme() @@ -106,6 +112,8 @@ def _arrange_in_box( Fractional figure-coordinates of the box assigned to this inset by `inset_element.align_to`. """ + from matplotlib.transforms import TransformedBbox + l, b, r, t = _fit_aspect( left, bottom, @@ -118,6 +126,26 @@ def _arrange_in_box( self._frac_bbox.bounds = (l, b, r - l, t - b) # pyright: ignore[reportAttributeAccessIssue] self.patch.set_bounds(left, bottom, right - left, top - bottom) + # The layout engine has finalised the bbox, so its device-pixel + # size is now known. _fit_aspect preserves the source aspect, so + # both axes scale by the same factor. + source_w, source_h = self._image_size + tbox = TransformedBbox(self._frac_bbox, self.figure.transFigure) + tw = max(1, round(tbox.width)) + th = max(1, round(tbox.height)) + downscaling = tw < source_w and th < source_h + + # LANCZOS to shrink: it antialiases the downscale. + # Not LANCZOS to enlarge: it looks hazy and rings badly on + # hard edges, so NEAREST. + if downscaling: + resampling = Image.Resampling.LANCZOS + else: + resampling = Image.Resampling.NEAREST + + resized = self._image.resize((tw, th), resampling) + self._image_artist.set_data(np.asarray(resized)) + def draw(self): from matplotlib.image import BboxImage from matplotlib.transforms import TransformedBbox @@ -127,11 +155,13 @@ def draw(self): self.theme._setup(self) # pyright: ignore[reportArgumentType] self._draw_plot_background() - image_artist = BboxImage( - TransformedBbox(self._frac_bbox, self.figure.transFigure) + tbox = TransformedBbox(self._frac_bbox, self.figure.transFigure) + # The image data is set later by _arrange_in_box, once the layout + # engine has computed the final device-pixel dimensions. + self._image_artist = BboxImage( + tbox, interpolation="none", resample=False ) - image_artist.set_data(np.asarray(self._image)) - self.figure.add_artist(image_artist) + self.figure.add_artist(self._image_artist) self.theme.apply() @@ -150,16 +180,22 @@ def _draw_plot_background(self): self.theme.targets.plot_background = self.patch -def _image_size(obj: PILImage | np.ndarray) -> tuple[int, int]: +def _to_pil_image(obj: PILImage | np.ndarray) -> PILImage: """ - Return the (width, height) of a PIL image or ndarray in pixels + Normalise an image input to a PIL Image for resampling + + A `PIL.Image` is returned unchanged. A uint8 ndarray (L / RGB / + RGBA) is wrapped directly. A float ndarray follows matplotlib's + `[0, 1]` convention and is clipped then scaled to uint8; the final + inset output is an 8-bit raster regardless, so nothing visible is + lost. """ if isinstance(obj, PILImage): - return obj.size # PIL exposes (W, H) - + return obj arr = np.asarray(obj) - h, w = arr.shape[:2] # ndarray is HWC or HW - return w, h + if arr.dtype != np.uint8: + arr = (np.clip(arr, 0, 1) * 255).round().astype(np.uint8) + return Image.fromarray(arr) # Named anchors → (h, v) fractions in [0, 1]², where `h = 0` diff --git a/tests/baseline_images/test_inset_element/image_aspect_fit_bottom.png b/tests/baseline_images/test_inset_element/image_aspect_fit_bottom.png index 2b6836d7d..690eacc4e 100644 Binary files a/tests/baseline_images/test_inset_element/image_aspect_fit_bottom.png and b/tests/baseline_images/test_inset_element/image_aspect_fit_bottom.png differ diff --git a/tests/baseline_images/test_inset_element/image_aspect_fit_top_right.png b/tests/baseline_images/test_inset_element/image_aspect_fit_top_right.png index 7a9210ca9..ff58d8e2e 100644 Binary files a/tests/baseline_images/test_inset_element/image_aspect_fit_top_right.png and b/tests/baseline_images/test_inset_element/image_aspect_fit_top_right.png differ diff --git a/tests/baseline_images/test_inset_element/image_standalone.png b/tests/baseline_images/test_inset_element/image_standalone.png index cd8c53adc..6a30d570a 100644 Binary files a/tests/baseline_images/test_inset_element/image_standalone.png and b/tests/baseline_images/test_inset_element/image_standalone.png differ