Skip to content

Commit d071c64

Browse files
committed
Anchor parameter for image insets
`inset_element(..., anchor=...)` chooses where an aspect-fitted image sits inside the user's bbox. Default `"center"` reproduces the previous behaviour; named anchors cover the eight corners and edges, and a `(h, v)` tuple in [0, 1]² sets the anchor point directly. `_fit_aspect` now splits the letterbox padding by the anchor's fraction instead of always 50/50. The anchor is resolved once at `_InsetImage` construction so the per-render path stays cheap and typed as a plain tuple. The parameter is ignored for ggplot / Compose insets, which fill the bbox via gridspec sizing and have no letterbox to anchor.
1 parent 0d85e56 commit d071c64

2 files changed

Lines changed: 119 additions & 21 deletions

File tree

plotnine/composition/_inset_element.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from ..ggplot import ggplot
1515
from ._compose import Compose
16+
from ._inset_image import Anchor
1617

1718

1819
@dataclass
@@ -37,23 +38,33 @@ class inset_element:
3738
ratio is preserved.
3839
left, bottom, right, top :
3940
Bounding box of the inset as fractional coordinates in the
40-
range ``[0, 1]``, relative to the host region selected by
41+
range `[0, 1]`, relative to the host region selected by
4142
`align_to`. The bottom-left corner of that region is
42-
``(0, 0)`` and the top-right is ``(1, 1)``.
43+
`(0, 0)` and the top-right is `(1, 1)`.
4344
align_to :
4445
Which region of the host plot the bounding box is relative to:
4546
46-
- ``"panel"`` — the data area only (default).
47-
- ``"plot"`` — the panel plus axes, labels, titles, captions
47+
- `"panel"` — the data area only (default).
48+
- `"plot"` — the panel plus axes, labels, titles, captions
4849
and legends
49-
- ``"full"`` — everything the host plot occupies plus plot margin
50+
- `"full"` — everything the host plot occupies plus plot margin
5051
on_top :
5152
When `True` (default) the inset paints above the host plot.
5253
When `False`, the inset paints between the host's
5354
`plot_background` and the rest of the host (panel, titles,
5455
legends, ...), so the host's panel area covers the inset.
5556
Useful for backdrops, decorations, or branding that should
5657
look like part of the page rather than an overlay.
58+
anchor :
59+
Where to anchor the image inside the user's bbox when its
60+
aspect ratio doesn't match. One of `"center"` (default),
61+
`"top"`, `"top-right"`, `"right"`, `"bottom-right"`,
62+
`"bottom"`, `"bottom-left"`, `"left"`, `"top-left"`,
63+
or a `(h, v)` tuple in [0, 1]² with `h = 0` left / `h = 1`
64+
right and `v = 0` bottom / `v = 1` top. Only meaningful for
65+
image insets; plot / composition insets fill the entire area by
66+
resizing without constraining the aspect ratio, so the anchor
67+
has no effect.
5768
5869
Notes
5970
-----
@@ -62,7 +73,7 @@ class inset_element:
6273
theme. The canvas size of the inset is determined by the bounding
6374
box and the area it is `align_to`.
6475
65-
For image insets, ``inset_element(...) + theme(...)`` draws a
76+
For image insets, `inset_element(...) + theme(...)` draws a
6677
sibling rectangle around the image; only `plot_background` is
6778
honored today.
6879
@@ -89,6 +100,7 @@ class inset_element:
89100
top: float
90101
align_to: Literal["panel", "plot", "full"] = "panel"
91102
on_top: bool = True
103+
anchor: Anchor = "center"
92104

93105
def __post_init__(self):
94106
import numpy as np
@@ -100,7 +112,7 @@ def __post_init__(self):
100112
if isinstance(self.obj, (ggplot, Compose)):
101113
pass
102114
elif isinstance(self.obj, (PILImage, np.ndarray)):
103-
self.obj = _InsetImage(self.obj)
115+
self.obj = _InsetImage(self.obj, anchor=self.anchor)
104116
else:
105117
raise TypeError(
106118
"inset_element requires a ggplot, Compose, PIL image, "

plotnine/composition/_inset_image.py

Lines changed: 100 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
from copy import deepcopy
4-
from typing import TYPE_CHECKING
4+
from typing import TYPE_CHECKING, Literal
55

66
import numpy as np
77
from PIL.Image import Image as PILImage
@@ -18,14 +18,28 @@
1818
from ..ggplot import ggplot
1919
from ..themes.theme import theme as theme_type
2020

21+
AnchorName = Literal[
22+
"center",
23+
"top",
24+
"right",
25+
"bottom",
26+
"left",
27+
"top-left",
28+
"top-right",
29+
"bottom-left",
30+
"bottom-right",
31+
]
32+
Anchor = AnchorName | tuple[float, float]
33+
2134

2235
class _InsetImage:
2336
"""
2437
A raster image rendered inside an `inset_element`'s bounding box
2538
2639
The image keeps its intrinsic aspect ratio — when the bbox does
2740
not match, the image is letterboxed with transparent padding on
28-
the two opposing edges so it stays centred.
41+
the two opposing edges. The `anchor` parameter chooses where the
42+
image sits inside the bbox (centered by default).
2943
3044
Theming the inset (`inset_element(...) + theme(...)`) draws a
3145
background rectangle around the image; only `plot_background`
@@ -44,12 +58,22 @@ class _InsetImage:
4458
# this bbox sets where on the figure it lands and how big it is.
4559
_frac_bbox: Bbox
4660

47-
def __init__(self, image: PILImage | np.ndarray):
61+
# Where the image sits inside the user's bbox when its aspect
62+
# ratio doesn't match.
63+
_anchor: tuple[float, float]
64+
65+
def __init__(
66+
self,
67+
image: PILImage | np.ndarray,
68+
*,
69+
anchor: Anchor = "center",
70+
):
4871
from matplotlib.transforms import Bbox
4972

5073
self._image = image
5174
self._image_size = _image_size(image) # (W, H) px
5275
self._frac_bbox = Bbox.unit()
76+
self._anchor = _resolve_anchor(anchor)
5377
self.theme = theme()
5478

5579
def __add__(self, other: object) -> _InsetImage:
@@ -69,10 +93,10 @@ def _arrange_in_box(
6993
"""
7094
Place the image inside the given box, preserving aspect ratio
7195
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.
96+
The image is letterboxed inside the box with transparent
97+
padding on the two opposing edges, positioned by the
98+
configured `anchor`. The background rectangle tracks the
99+
same fitted box.
76100
77101
Parameters
78102
----------
@@ -81,7 +105,13 @@ def _arrange_in_box(
81105
inset by `inset_element.align_to`.
82106
"""
83107
l, b, r, t = _fit_aspect(
84-
left, bottom, right, top, self._image_size, self.figure
108+
left,
109+
bottom,
110+
right,
111+
top,
112+
self._image_size,
113+
self.figure,
114+
anchor=self._anchor,
85115
)
86116
w, h = r - l, t - b
87117
self._frac_bbox.bounds = (l, b, w, h) # pyright: ignore[reportAttributeAccessIssue]
@@ -128,17 +158,71 @@ def _image_size(obj: PILImage | np.ndarray) -> tuple[int, int]:
128158
return w, h
129159

130160

161+
# Named anchors → (h, v) fractions in [0, 1]², where `h = 0`
162+
# aligns the image to the bbox's left edge / `h = 1` to the right,
163+
# and `v = 0` aligns to the bottom / `v = 1` to the top.
164+
_ANCHOR_FRACTIONS: dict[str, tuple[float, float]] = {
165+
"center": (0.5, 0.5),
166+
"top": (0.5, 1.0),
167+
"top-right": (1.0, 1.0),
168+
"right": (1.0, 0.5),
169+
"bottom-right": (1.0, 0.0),
170+
"bottom": (0.5, 0.0),
171+
"bottom-left": (0.0, 0.0),
172+
"left": (0.0, 0.5),
173+
"top-left": (0.0, 1.0),
174+
}
175+
176+
177+
def _resolve_anchor(anchor: Anchor) -> tuple[float, float]:
178+
"""
179+
Normalise an anchor spec to a (h, v) tuple in [0, 1]²
180+
181+
Accepts a named anchor (e.g. `"top-right"`) or a numeric
182+
`(h, v)` tuple. Raises `ValueError` on unknown names or
183+
out-of-range tuple values.
184+
"""
185+
if isinstance(anchor, str):
186+
try:
187+
return _ANCHOR_FRACTIONS[anchor]
188+
except KeyError:
189+
names = ", ".join(repr(k) for k in _ANCHOR_FRACTIONS)
190+
raise ValueError(
191+
f"Unknown anchor {anchor!r}. Expected one of: "
192+
f"{names}, or a (h, v) tuple in [0, 1]²."
193+
) from None
194+
try:
195+
h, v = anchor
196+
except (TypeError, ValueError):
197+
raise ValueError(
198+
f"Anchor must be a name or (h, v) tuple, got {anchor!r}."
199+
) from None
200+
if not (0.0 <= h <= 1.0 and 0.0 <= v <= 1.0):
201+
raise ValueError(
202+
f"Anchor tuple values must lie in [0, 1], got ({h}, {v})."
203+
)
204+
return float(h), float(v)
205+
206+
131207
def _fit_aspect(
132208
left: float,
133209
bottom: float,
134210
right: float,
135211
top: float,
136212
image_size: tuple[int, int],
137213
fig: Figure,
214+
anchor: tuple[float, float] = (0.5, 0.5),
138215
) -> tuple[float, float, float, float]:
139216
"""
140-
Shrink the user's bbox to the largest centered sub-bbox with the
141-
image's intrinsic aspect ratio
217+
Shrink the user's bbox to the largest sub-bbox with the image's
218+
intrinsic aspect ratio, positioned by `anchor`
219+
220+
`anchor` defaults to `"center"` (image centered, padding split
221+
50/50 on the short axis). Named anchors map to corners and edges
222+
of the bbox; a `(h, v)` tuple in [0, 1]² sets the anchor
223+
point directly, where `h = 0` aligns the image to the bbox's
224+
left edge / `h = 1` to the right, and `v = 0` to the bottom /
225+
`v = 1` to the top.
142226
"""
143227
# figure size in px
144228
W, H = fig.bbox.size
@@ -151,13 +235,15 @@ def _fit_aspect(
151235
img_aspect = img_w / img_h
152236
box_aspect = box_w / box_h
153237

238+
h, v = anchor
239+
154240
if img_aspect > box_aspect:
155241
# Wider than the box: fit width, letterbox vertically
156242
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
243+
pad = (box_h - new_box_h) / H
244+
return left, bottom + v * pad, right, top - (1 - v) * pad
159245

160246
# Taller (or equal): fit height, letterbox horizontally
161247
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
248+
pad = (box_w - new_box_w) / W
249+
return left + h * pad, bottom, right - (1 - h) * pad, top

0 commit comments

Comments
 (0)