Watermark — Stamp a Logo / Brand-Mark on a Figure#
The cleopatra.styling.watermark module places a logo or watermark image onto a finished
matplotlib Figure with a single call, so anything you publish or share can carry a mark
without re-rolling the same inset-axes glue in every notebook.
The one entry point is stamp_mark(fig, path, *, frac=0.11, corner="lower right", margin=0.025,
shadow=True, blur=0.065). Two things make it more than a one-liner over imshow:
- Fraction-of-figure sizing. The mark is drawn on a frameless inset axes in
figure-fraction coordinates, so it stays the same proportion (and corner offset) no
matter what dpi the figure is later saved at — the MP4 master, the smaller web copy, and
the GIF all get a mark of the same relative size.
fracsets the width relative to the figure width; the height is derived from the image and figure aspect ratios, so the image is never stretched. (This is the dpi-independent counterpart ofFigure.figimage, which is pixel-based.) - Optional halo. With
shadow=True(the default) a gaussian-blurred black copy of the mark's alpha is composited behind it so the mark separates from a busy or dark canvas. The blur uses Pillow (already a cleopatra dependency), so no new dependency — and no SciPy — is pulled in.
The halo is centred, not offset. A mark is composited over arbitrary imagery — night
ocean, sunlit cloud, a bright limb — and a symmetric halo reads the same whichever way the
background falls, where a down-right drop shadow implies a light direction nothing else in
the frame has. blur is the halo's sigma as a fraction of the mark's own width.
It is a presentation helper, not a glyph: it takes whatever Figure you hand it and draws on
top. Single-image, corner-anchored marks only — text watermarks, tiled / repeated marks, and
any licensing / provenance semantics are out of scope.
stamp_mark accepts the mark either as a file path (any format Pillow can open, read as
RGBA) or as an in-memory (H, W, 3) / (H, W, 4) NumPy array (uint8 0-255 or float
0-1; RGB gains an opaque alpha). It returns the frameless inset Axes it drew on, so you
can adjust it further.
Usage#
import matplotlib.pyplot as plt
import numpy as np
from cleopatra.styling.watermark import stamp_mark
fig = plt.figure(figsize=(12, 8))
fig.add_subplot(111).imshow(np.random.default_rng(0).random((60, 90)), cmap="magma")
# a file on disk...
stamp_mark(fig, "brand/logo.png", frac=0.12, corner="lower right")
# ...or an in-memory RGBA array, in a different corner, without the shadow
logo = np.zeros((80, 160, 4), dtype=np.uint8)
logo[..., :3] = 255
logo[..., 3] = 255
stamp_mark(fig, logo, frac=0.09, corner="upper left", shadow=False)
fig.savefig("figure.png", dpi=200) # the mark keeps its proportion at any dpi
corner is one of "lower right" (default), "lower left", "upper right", or
"upper left"; anything else raises a ValueError naming the bad value. margin is the gap
between the mark and the figure edges as a fraction of the figure — either a scalar for
both axes or an (x, y) pair. The pair matters when a mark has to tuck hard into a corner on
one axis while keeping a gap on the other:
stamp_mark(fig, "brand/logo.png", margin=(0.025, 0.0)) # flush with the bottom, inset from the right
Call stamp_mark last, and save the whole figure
The mark is baked at stamp time from the figure's current size, so stamp after any
tight_layout() / layout finalization and after the final set_size_inches (stamping first
then calling tight_layout() emits a UserWarning). The fraction-of-figure sizing assumes the
whole figure is saved: a plain dpi= save keeps the mark proportional, but
savefig(bbox_inches="tight") crops surrounding whitespace and so changes the mark's relative
margin and size.
frac sizes the mark, not the halo canvas
The halo needs a transparent pad of three sigmas on each side to hold its own tail, which
makes the composited canvas about 1.39x the mark's width at the default blur. stamp_mark
grows the inset axes by exactly that factor, so the visible mark still measures frac.
Sizing the padded canvas to frac instead would render the mark at roughly 72 % of the
requested size — easy to miss, because the axes bounding box still looks correct. With
shadow=True the returned axes' bbox therefore covers mark and halo, and is larger than
frac; margin is still measured to the mark, so a halo beside a small margin is clipped at
the figure edge (which is what you want when tucking a mark into a corner).
cleopatra.styling.watermark.stamp_mark(fig, path, *, frac=0.11, corner='lower right', margin=0.025, shadow=True, blur=DEFAULT_BLUR)
#
Stamp a logo / watermark image onto a figure, sized as a fraction of it.
Places path in one corner of fig on a frameless inset axes in
figure-fraction coordinates, so the mark keeps the same proportion (and
corner offset) no matter what dpi the figure is later saved at. The image
is drawn undistorted: frac sets its width relative to the figure width
and the height is derived from the image and figure aspect ratios.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
fig
|
Figure
|
The matplotlib |
required |
path
|
str | PathLike | ndarray
|
The mark image. A file path (any format |
required |
frac
|
float
|
The size of the mark's longer on-figure side as a fraction of
the corresponding figure side, in |
0.11
|
corner
|
str
|
Which corner to anchor to -- |
'lower right'
|
margin
|
float | tuple[float, float]
|
The gap between the mark and the figure edges, as a fraction
of the figure, each in |
0.025
|
shadow
|
bool
|
Whether to composite a gaussian-blurred halo behind the mark so
it separates from a busy or dark canvas. Defaults to |
True
|
blur
|
float
|
Halo blur sigma, as a fraction of the mark's own unpadded
width. Defaults to |
DEFAULT_BLUR
|
Returns:
| Name | Type | Description |
|---|---|---|
Axes |
Axes
|
The frameless inset axes the mark was drawn on, so the caller can |
Axes
|
further adjust it (e.g. |
|
Axes
|
that axes holds the mark and its halo, so its bbox is larger than the |
|
Axes
|
mark by the halo pad -- see the sizing note below. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
FileNotFoundError
|
If |
UnidentifiedImageError
|
If |
Notes
The mark is baked at stamp time from the figure's current size, so call
stamp_mark last -- after any tight_layout() / layout
finalization (stamping first then calling tight_layout() warns), and
after the final set_size_inches. Placement holds across dpi but not
across a later figure-size change. Saving with bbox_inches="tight"
changes the mark's relative margin / size -- it crops surrounding
whitespace, and a halo tucked near an edge (whose grown axes overflows
the figure) can even extend the tight bbox outward; a plain dpi=
save preserves the placement.
frac always sizes the mark itself, never the canvas it is
composited on. The halo needs a transparent pad of
_HALO_SIGMAS * blur on each side to hold its own tail, which makes
that canvas 1 + 2 * _HALO_SIGMAS * blur times the mark's width
(1.39x at the defaults). The axes rect is grown by exactly that factor
so the visible mark still measures frac; sizing the padded canvas to
frac instead would silently render the mark at ~72% of the requested
size, which is easy to miss because the axes bbox looks right.
margin is measured to the mark, so a halo next to a small margin is
clipped at the figure edge -- which is what you want when tucking a
mark hard into a corner.
Examples:
- Stamp a logo array in the lower-right corner at 11 % of the width:
>>> import matplotlib >>> matplotlib.use("Agg") >>> import numpy as np >>> import matplotlib.pyplot as plt >>> from cleopatra.styling.watermark import stamp_mark >>> fig = plt.figure(figsize=(8, 6)) >>> logo = np.zeros((40, 80, 4), dtype=np.uint8) >>> logo[..., :3] = 255 # white >>> logo[..., 3] = 255 # opaque >>> ax = stamp_mark(fig, logo, frac=0.2, shadow=False) >>> [round(float(v), 3) for v in ax.get_position().bounds] [0.775, 0.025, 0.2, 0.133] >>> plt.close(fig)
Source code in src/cleopatra/styling/watermark.py
60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 | |