Skip to content

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. frac sets 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 of Figure.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 Figure to stamp. The mark is drawn on top of whatever the figure already contains.

required
path str | PathLike | ndarray

The mark image. A file path (any format PIL can open, read as RGBA) or an in-memory (H, W, 3) / (H, W, 4) array -- either uint8 0-255 or float 0-1; RGB gains an opaque alpha.

required
frac float

The size of the mark's longer on-figure side as a fraction of the corresponding figure side, in (0, 1] -- the width for a landscape mark, the height for a portrait one -- so the mark always fits and is never distorted. Defaults to 0.11.

0.11
corner str

Which corner to anchor to -- "lower right" (default), "lower left", "upper right", or "upper left".

'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, 1). Either a scalar applied to both axes or an (x, y) pair -- a pair is what lets a mark tuck hard into a corner on one axis (margin=(0.025, 0.0)) while keeping a gap on the other. Defaults to 0.025.

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.

True
blur float

Halo blur sigma, as a fraction of the mark's own unpadded width. Defaults to DEFAULT_BLUR. Must be non-negative (validated even when shadow=False, where it is otherwise unused); 0 is treated as no halo.

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. ax.set_zorder(...)). With shadow=True

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 corner is not one of the four accepted anchors, if frac is not in (0, 1], if margin is not a scalar or (x, y) pair in [0, 1), if margin + mark size exceeds the figure, if blur is negative, or if an image array is out of contract (wrong shape, a non-uint8 non-float dtype, or a float outside [0, 1] or containing NaN/inf -- see _as_rgba).

FileNotFoundError

If path is a file path that does not exist.

UnidentifiedImageError

If path is a file that is not an image PIL can decode.

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
def stamp_mark(
    fig: Figure,
    path: str | os.PathLike | np.ndarray,
    *,
    frac: float = 0.11,
    corner: str = "lower right",
    margin: float | tuple[float, float] = 0.025,
    shadow: bool = True,
    blur: float = DEFAULT_BLUR,
) -> Axes:
    """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.

    Args:
        fig: The matplotlib `Figure` to stamp. The mark is drawn on top of
            whatever the figure already contains.
        path: The mark image. A file path (any format `PIL` can open, read as
            RGBA) or an in-memory ``(H, W, 3)`` / ``(H, W, 4)`` array -- either
            ``uint8`` ``0-255`` or float ``0-1``; RGB gains an opaque alpha.
        frac: The size of the mark's *longer* on-figure side as a fraction of
            the corresponding figure side, in ``(0, 1]`` -- the width for a
            landscape mark, the height for a portrait one -- so the mark always
            fits and is never distorted. Defaults to ``0.11``.
        corner: Which corner to anchor to -- ``"lower right"`` (default),
            ``"lower left"``, ``"upper right"``, or ``"upper left"``.
        margin: The gap between the **mark** and the figure edges, as a fraction
            of the figure, each in ``[0, 1)``. Either a scalar applied to both
            axes or an ``(x, y)`` pair -- a pair is what lets a mark tuck hard
            into a corner on one axis (``margin=(0.025, 0.0)``) while keeping a
            gap on the other. Defaults to ``0.025``.
        shadow: Whether to composite a gaussian-blurred halo behind the mark so
            it separates from a busy or dark canvas. Defaults to ``True``.
        blur: Halo blur sigma, as a fraction of the mark's own **unpadded**
            width. Defaults to `DEFAULT_BLUR`. Must be non-negative (validated
            even when ``shadow=False``, where it is otherwise unused); ``0`` is
            treated as no halo.

    Returns:
        Axes: The frameless inset axes the mark was drawn on, so the caller can
        further adjust it (e.g. ``ax.set_zorder(...)``). With ``shadow=True``
        that axes holds the mark *and* its halo, so its bbox is larger than the
        mark by the halo pad -- see the sizing note below.

    Raises:
        ValueError: If `corner` is not one of the four accepted anchors, if
            `frac` is not in ``(0, 1]``, if `margin` is not a scalar or
            ``(x, y)`` pair in ``[0, 1)``, if `margin + mark size` exceeds the
            figure, if `blur` is negative, or if an image array is out of
            contract (wrong shape, a non-``uint8`` non-float dtype, or a float
            outside ``[0, 1]`` or containing NaN/inf -- see `_as_rgba`).
        FileNotFoundError: If `path` is a file path that does not exist.
        PIL.UnidentifiedImageError: If `path` is a file that is not an image
            `PIL` can decode.

    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:
            ```python
            >>> 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)

            ```
    """
    if corner not in _CORNERS:
        raise ValueError(f"corner must be one of {list(_CORNERS)}, got {corner!r}.")
    if not 0.0 < frac <= 1.0:
        raise ValueError(f"frac must be in (0, 1], got {frac!r}.")
    margin_x, margin_y = _as_margins(margin)
    if blur < 0.0:
        raise ValueError(f"blur must be non-negative, got {blur!r}.")

    image = _as_rgba(path)
    img_h, img_w = image.shape[:2]
    if img_h == 0 or img_w == 0:
        raise ValueError(
            f"the mark image has a zero-size dimension {image.shape[:2]}; "
            "it must have a positive height and width."
        )
    fig_w_in, fig_h_in = fig.get_size_inches()

    width = float(frac)
    # Keep the image undistorted: its on-figure height is its width scaled by the
    # image aspect and corrected for the figure's own aspect, because a unit of
    # figure-fraction height spans fewer inches than a unit of width (or more).
    height = width * (img_h / img_w) * (fig_w_in / fig_h_in)
    # `frac` sizes the mark's *longer* on-figure side: for a landscape mark that
    # is the width (unchanged), but a portrait mark whose derived height exceeds
    # `frac` is scaled down so its height is `frac` instead -- otherwise a tall
    # logo would silently overflow the figure. Aspect is preserved either way.
    longest = max(width, height)
    if longest > frac:
        scale = frac / longest
        width *= scale
        height *= scale
    # `frac` and `margin` are each in range on their own, but their sum must
    # still leave the mark on the figure: `margin + size > 1` would place the
    # mark off the opposite edge (`x0 = 1 - margin - width < 0`).
    if margin_x + width > 1.0 or margin_y + height > 1.0:
        raise ValueError(
            f"margin + mark size exceeds the figure: margin={(margin_x, margin_y)} "
            f"leaves no room for a {width:.3g}x{height:.3g} (figure-fraction) mark. "
            "Reduce frac or margin."
        )
    x0, y0 = _corner_origin(corner, width, height, margin_x, margin_y)

    # `width`/`height` are the MARK's rect. When a halo is composited in, the
    # image handed to `imshow` is the padded canvas, so the axes rect has to grow
    # by the same ratio (about the mark's centre) or the mark would render at
    # 1/grow of the requested `frac`.
    drawn, grow_w, grow_h = (image, 1.0, 1.0)
    # `blur == 0` yields an invisible halo but still pads the canvas (min 1 px),
    # so skip the composite entirely -- it only wastes work and inflates the bbox.
    if shadow and blur > 0.0:
        drawn, grow_w, grow_h = _composite_halo(image, blur)
    rect_w = width * grow_w
    rect_h = height * grow_h
    rect_x = x0 - (rect_w - width) / 2.0
    rect_y = y0 - (rect_h - height) / 2.0

    ax = fig.add_axes(
        (rect_x, rect_y, rect_w, rect_h), frameon=False, zorder=_MARK_ZORDER
    )
    ax.imshow(drawn, aspect="auto", interpolation="antialiased")
    ax.axis("off")
    ax.set_in_layout(False)
    return ax