Skip to content

Commit 8d3d3ec

Browse files
committed
Add inset images
1 parent 424c93f commit 8d3d3ec

4 files changed

Lines changed: 311 additions & 27 deletions

File tree

plotnine/_mpl/layout_manager/_plot_side_space.py

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -780,11 +780,15 @@ def _arrange_insets(self):
780780
781781
The host's panel/plot/full region is now finalised, so the
782782
inset's fractional bounding box is scaled into figure
783-
coordinates and applied to the inset's own gridspec. The
784-
inset's side-space layout then runs to lay out its content
785-
within those bounds.
783+
coordinates. For ggplot / Compose insets the bbox drives the
784+
inset's gridspec and its side-space layout runs to position
785+
its content. For image insets the adapter's `_arrange_in_box`
786+
does the aspect-fit math directly — no gridspec or side-space
787+
work needed.
786788
"""
787789
from plotnine import ggplot
790+
from plotnine.composition import Compose
791+
from plotnine.composition._inset_image import _InsetImage
788792

789793
from ._composition_side_space import CompositionSideSpaces
790794

@@ -799,21 +803,28 @@ def _arrange_insets(self):
799803
bbox = self.plot._gridspec.bbox_relative
800804
(x1, y1), (x2, y2) = (bbox.x0, bbox.y0), (bbox.x1, bbox.y1)
801805

802-
params = GridSpecParams(
803-
left=x1 + inset.left * (x2 - x1),
804-
bottom=y1 + inset.bottom * (y2 - y1),
805-
right=x1 + inset.right * (x2 - x1),
806-
top=y1 + inset.top * (y2 - y1),
807-
wspace=0,
808-
hspace=0,
809-
)
810-
inset.obj._gridspec.update_params_and_artists(params)
811-
812-
if isinstance(inset.obj, ggplot):
813-
inset.obj._sidespaces = PlotSideSpaces(inset.obj)
814-
else:
815-
inset.obj._sidespaces = CompositionSideSpaces(inset.obj)
816-
inset.obj._sidespaces.arrange()
806+
left = x1 + inset.left * (x2 - x1)
807+
bottom = y1 + inset.bottom * (y2 - y1)
808+
right = x1 + inset.right * (x2 - x1)
809+
top = y1 + inset.top * (y2 - y1)
810+
811+
if isinstance(inset.obj, (ggplot, Compose)):
812+
params = GridSpecParams(
813+
left=left,
814+
bottom=bottom,
815+
right=right,
816+
top=top,
817+
wspace=0,
818+
hspace=0,
819+
)
820+
inset.obj._gridspec.update_params_and_artists(params)
821+
if isinstance(inset.obj, ggplot):
822+
inset.obj._sidespaces = PlotSideSpaces(inset.obj)
823+
else:
824+
inset.obj._sidespaces = CompositionSideSpaces(inset.obj)
825+
inset.obj._sidespaces.arrange()
826+
elif isinstance(inset.obj, _InsetImage):
827+
inset.obj._arrange_in_box(left, bottom, right, top)
817828

818829
def resize_gridspec(self):
819830
"""

plotnine/composition/_inset_element.py

Lines changed: 74 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@
44
from dataclasses import dataclass
55
from typing import TYPE_CHECKING, Any, Literal
66

7+
from ._inset_image import _InsetImage
8+
79
if TYPE_CHECKING:
10+
import numpy as np
811
from matplotlib.figure import Figure
12+
from PIL.Image import Image as PILImage
913

1014
from ..ggplot import ggplot
1115
from ._compose import Compose
@@ -25,7 +29,12 @@ class inset_element:
2529
Parameters
2630
----------
2731
obj :
28-
The object to render as an inset.
32+
The object to render as an inset. One of:
33+
34+
- `ggplot` or `Compose` — full plot pipeline.
35+
- `PIL.Image.Image` or `numpy.ndarray` — raster image. The
36+
image is letterboxed inside the user's bbox so its aspect
37+
ratio is preserved.
2938
left, bottom, right, top :
3039
Bounding box of the inset as fractional coordinates in the
3140
range ``[0, 1]``, relative to the host region selected by
@@ -52,9 +61,28 @@ class inset_element:
5261
inset shares the host's figure, so these values come from the host
5362
theme. The canvas size of the inset is determined by the bounding
5463
box and the area it is `align_to`.
64+
65+
For image insets, ``inset_element(...) + theme(...)`` draws a
66+
sibling rectangle around the image; only `plot_background` is
67+
honored today.
68+
69+
Examples
70+
--------
71+
Composed with a host plot:
72+
73+
>>> p = ggplot(mtcars, aes("wt", "mpg")) + geom_point() # doctest: +SKIP
74+
>>> p + inset_element(p, 0.6, 0.6, 1, 1) # doctest: +SKIP
75+
76+
Image inset with a black border:
77+
78+
>>> from PIL import Image # doctest: +SKIP
79+
>>> p + ( # doctest: +SKIP
80+
... inset_element(Image.open("logo.png"), 0.7, 0.7, 1, 1)
81+
... + theme(plot_background=element_rect(color="black", size=1))
82+
... )
5583
"""
5684

57-
obj: ggplot | Compose
85+
obj: ggplot | Compose | PILImage | np.ndarray | _InsetImage
5886
left: float
5987
bottom: float
6088
right: float
@@ -63,13 +91,20 @@ class inset_element:
6391
on_top: bool = True
6492

6593
def __post_init__(self):
94+
import numpy as np
95+
from PIL.Image import Image as PILImage
96+
6697
from ..ggplot import ggplot
6798
from ._compose import Compose
6899

69-
if not isinstance(self.obj, (ggplot, Compose)):
100+
if isinstance(self.obj, (ggplot, Compose)):
101+
pass
102+
elif isinstance(self.obj, (PILImage, np.ndarray)):
103+
self.obj = _InsetImage(self.obj)
104+
else:
70105
raise TypeError(
71-
"inset_element requires a ggplot or Compose, got "
72-
f"{type(self.obj).__name__!r}."
106+
"inset_element requires a ggplot, Compose, PIL image, "
107+
f"or ndarray, got {type(self.obj).__name__!r}."
73108
)
74109

75110
if not 0.0 <= self.left < self.right <= 1.0:
@@ -93,8 +128,35 @@ def _setup(self, parent: ggplot):
93128
parent :
94129
The host plot whose figure this inset adopts.
95130
"""
96-
self.obj.figure = parent.figure
97-
self.obj.theme._inherit_figure_props(parent.theme)
131+
from ..ggplot import ggplot
132+
from ._compose import Compose
133+
134+
if isinstance(self.obj, (ggplot, Compose)):
135+
self.obj.figure = parent.figure
136+
self.obj.theme._inherit_figure_props(parent.theme)
137+
elif isinstance(self.obj, _InsetImage):
138+
self.obj._setup(parent)
139+
140+
def __add__(self, other: object) -> inset_element:
141+
"""
142+
Attach a theme to this inset
143+
144+
Returns a new `inset_element` with the theme folded into the
145+
underlying `obj`. For `ggplot` / `Compose` insets this is a
146+
shortcut for ``obj + theme``; for image insets the theme is
147+
stored on the adapter and drives a sibling `Rectangle` via
148+
`plot_background`.
149+
"""
150+
from ..ggplot import ggplot
151+
from ..themes.theme import theme
152+
from ._compose import Compose
153+
154+
if not isinstance(other, theme):
155+
return NotImplemented
156+
new = deepcopy(self)
157+
if isinstance(new.obj, (ggplot, Compose, _InsetImage)):
158+
new.obj = new.obj + other
159+
return new
98160

99161
@property
100162
def _blank_host(self) -> ggplot:
@@ -156,7 +218,11 @@ def _draw_in_host(self):
156218
157219
For standalone use, call `draw()` instead.
158220
"""
159-
self.obj.draw()
221+
from ..ggplot import ggplot
222+
from ._compose import Compose
223+
224+
if isinstance(self.obj, (ggplot, Compose, _InsetImage)):
225+
self.obj.draw()
160226

161227
def __radd__(self, other: ggplot) -> ggplot:
162228
"""
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
from __future__ import annotations
2+
3+
from copy import deepcopy
4+
from typing import TYPE_CHECKING
5+
6+
import numpy as np
7+
from PIL.Image import Image as PILImage
8+
9+
from ..themes.theme import theme
10+
11+
if TYPE_CHECKING:
12+
from matplotlib.figure import Figure
13+
from matplotlib.patches import Rectangle
14+
from matplotlib.transforms import Bbox
15+
16+
from plotnine._mpl.figure import p9Figure
17+
18+
from ..ggplot import ggplot
19+
from ..themes.theme import theme as theme_type
20+
21+
22+
class _InsetImage:
23+
"""
24+
A raster image rendered inside an `inset_element`'s bounding box
25+
26+
The image keeps its intrinsic aspect ratio — when the bbox does
27+
not match, the image is letterboxed with transparent padding on
28+
the two opposing edges so it stays centred.
29+
30+
Theming the inset (`inset_element(...) + theme(...)`) draws a
31+
background rectangle around the image; only `plot_background`
32+
is honored, and it styles that rectangle's fill and border.
33+
"""
34+
35+
# The host figure this inset renders into.
36+
figure: p9Figure
37+
# Theme that styles the background rectangle.
38+
theme: theme_type
39+
# Background rectangle drawn around the image.
40+
patch: Rectangle
41+
42+
# Bbox that defines the position and size of the final image
43+
# artist. When the original image is mapped onto the figure,
44+
# this bbox sets where on the figure it lands and how big it is.
45+
_frac_bbox: Bbox
46+
47+
def __init__(self, image: PILImage | np.ndarray):
48+
from matplotlib.transforms import Bbox
49+
50+
self._image = image
51+
self._image_size = _image_size(image) # (W, H) px
52+
self._frac_bbox = Bbox.unit()
53+
self.theme = theme()
54+
55+
def __add__(self, other: object) -> _InsetImage:
56+
if not isinstance(other, theme):
57+
return NotImplemented
58+
59+
new = deepcopy(self)
60+
new.theme = (new.theme or theme()) + other
61+
return new
62+
63+
def _setup(self, parent: ggplot):
64+
self.figure = parent.figure
65+
66+
def _arrange_in_box(
67+
self, left: float, bottom: float, right: float, top: float
68+
):
69+
"""
70+
Place the image inside the given box, preserving aspect ratio
71+
72+
The image is letterboxed — centered inside the box with
73+
transparent padding on the two opposing edges — so its
74+
intrinsic aspect ratio survives. The background rectangle
75+
tracks the same fitted box.
76+
77+
Parameters
78+
----------
79+
left, bottom, right, top :
80+
Fractional figure-coordinates of the box assigned to this
81+
inset by `inset_element.align_to`.
82+
"""
83+
l, b, r, t = _fit_aspect(
84+
left, bottom, right, top, self._image_size, self.figure
85+
)
86+
w, h = r - l, t - b
87+
self._frac_bbox.bounds = (l, b, w, h) # pyright: ignore[reportAttributeAccessIssue]
88+
self.patch.set_bounds(l, b, w, h)
89+
90+
def draw(self):
91+
from matplotlib.image import BboxImage
92+
from matplotlib.transforms import TransformedBbox
93+
94+
image_artist = BboxImage(
95+
TransformedBbox(self._frac_bbox, self.figure.transFigure)
96+
)
97+
image_artist.set_data(np.asarray(self._image))
98+
self.figure.add_artist(image_artist)
99+
100+
self.theme._setup(self) # pyright: ignore[reportArgumentType]
101+
self._draw_plot_background()
102+
self.theme.apply()
103+
104+
def _draw_plot_background(self):
105+
from matplotlib.patches import Rectangle
106+
107+
self.patch = self.figure.add_artist(
108+
Rectangle(
109+
(0, 0),
110+
1,
111+
1,
112+
facecolor="none",
113+
transform=self.figure.transFigure,
114+
)
115+
)
116+
self.theme.targets.plot_background = self.patch
117+
118+
119+
def _image_size(obj: PILImage | np.ndarray) -> tuple[int, int]:
120+
"""
121+
Return the (width, height) of a PIL image or ndarray in pixels
122+
"""
123+
if isinstance(obj, PILImage):
124+
return obj.size # PIL exposes (W, H)
125+
126+
arr = np.asarray(obj)
127+
h, w = arr.shape[:2] # ndarray is HWC or HW
128+
return w, h
129+
130+
131+
def _fit_aspect(
132+
left: float,
133+
bottom: float,
134+
right: float,
135+
top: float,
136+
image_size: tuple[int, int],
137+
fig: Figure,
138+
) -> tuple[float, float, float, float]:
139+
"""
140+
Shrink the user's bbox to the largest centered sub-bbox with the
141+
image's intrinsic aspect ratio
142+
"""
143+
# figure size in px
144+
W, H = fig.bbox.size
145+
146+
# box size in px
147+
box_w = (right - left) * W
148+
box_h = (top - bottom) * H
149+
150+
img_w, img_h = image_size
151+
img_aspect = img_w / img_h
152+
box_aspect = box_w / box_h
153+
154+
if img_aspect > box_aspect:
155+
# Wider than the box: fit width, letterbox vertically
156+
new_box_h = box_w / img_aspect
157+
pad_frac = (box_h - new_box_h) / 2 / H
158+
return left, bottom + pad_frac, right, top - pad_frac
159+
160+
# Taller (or equal): fit height, letterbox horizontally
161+
new_box_w = box_h * img_aspect
162+
pad_frac = (box_w - new_box_w) / 2 / W
163+
return left + pad_frac, bottom, right - pad_frac, top

0 commit comments

Comments
 (0)