Watermark — Stamp a Logo or Brand Text on a Figure#
The cleopatra.styling.watermark module puts a mark on a finished matplotlib Figure with a
single call, so anything you publish or share can carry one without re-rolling the same glue in
every notebook. Two entry points, designed to be used together:
glyph.stamp_mark(...)— a logo image, anchored in a corner.glyph.stamp_watermark(...)— diagonal brand text across the middle, with an optional credit line along the bottom.
Both size by a fraction of the figure and position by a margin from its edge, so a mark keeps its proportions across the several dpis a figure is exported at.
Both are also available on every glyph as methods, so the common case needs no import:
glyph.plot()
glyph.stamp_mark(LOGO, frac=0.18, corner="lower left")
glyph.stamp_watermark("earthlens", credit="github.com/serapeum-org/earthlens")
Both stamp the glyph's own figure, and for the common single-glyph case both are the way in:
the functions underneath (_stamp_mark, _stamp_watermark) are private, so a stamp goes through the
glyph rather than a second equivalent free function. A stamp lands on the whole figure, so on a
figure carrying several glyphs it does not matter which one you call it through.
The one sanctioned exception is the mark image on a composite figure no single glyph owns —
several glyphs' panels laid out in one plt.figure (the documented multi-panel pattern), or a figure
built entirely outside cleopatra. For that, the module-level stamp_mark_on(fig, path, ...) is a
supported, public escape hatch onto the same image stamp — see the stamp_mark_on section below.
(Brand text on such a figure stays glyph-only; there is no stamp_watermark_on.)
stamp_mark — a logo image#
glyph.stamp_mark(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 — tiled / repeated marks and any licensing /
provenance semantics are out of scope.
stamp_mark takes 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.
stamp_mark_on — a figure no glyph owns#
stamp_mark_on(fig, path, *, frac=0.11, corner="lower right", margin=0.025, shadow=True,
blur=0.065) is the module-level counterpart of glyph.stamp_mark, for a composite figure no
single glyph owns: several glyphs' panels laid out in one plt.figure (the sanctioned
multi-panel pattern — each panel drawn by a glyph bound to that panel's axes,
ArrayGlyph(arr, ax=ax).plot()), or a figure built entirely outside cleopatra. There is no one
glyph whose stamp_mark covers the shared figure, so this takes the Figure directly. It draws
the same corner mark with the same options and returns the same frameless inset Axes.
import matplotlib.pyplot as plt
from cleopatra.glyphs.gridded.array_glyph import ArrayGlyph
from cleopatra.styling.watermark import stamp_mark_on
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
for ax, arr in zip(axes, arrays):
ArrayGlyph(arr, ax=ax).plot() # each glyph owns only its own panel axes
stamp_mark_on(fig, "brand/logo.png", corner="lower right")
Reach for the glyph method glyph.stamp_mark for the common single-glyph case — it stays the
primary API. stamp_mark_on is the explicit, supported escape hatch so a composite layout never
has to import the private _stamp_mark.
stamp_watermark — brand text#
glyph.stamp_watermark(text, *, frac=0.55, angle=30.0, alpha=0.65, color="white", credit=None,
credit_frac=0.28, credit_alpha=1.0, margin=0.014) stamps translucent brand text across the middle
of the figure, and optionally a credit line along the bottom.
- The fraction means the same thing for any text. Scaling a point size off the figure width —
the obvious shortcut — renders a short word small and a long one straight off the canvas,
because how much of a frame a string covers depends on how many characters it has.
fracis measured on what is actually rendered, so a two-letter brand and a twenty-character one both land at the fraction you asked for. - The credit line is placed by
margin, a fraction of the figure height above the bottom edge, and sized bycredit_frac— the same shapesstamp_markuses, rather than a hardcoded offset and point size. - Only the credit line is outlined. That asymmetry is deliberate: an outline on the large diagonal text makes it read as a solid caption rather than a watermark, while the credit is small enough that it needs the stroke to stay legible against whatever the frame contains.
It returns (brand_text, credit_text) — the second is None when no credit was given.
glyph.stamp_mark(LOGO, frac=0.18, corner="lower left")
glyph.stamp_watermark("earthlens", credit="github.com/serapeum-org/earthlens")
Call both last
Like stamp_mark, the text size is baked from the figure's current size, so stamp after
any tight_layout() and after the final set_size_inches. The proportion holds across dpi.
It does not survive a later set_size_inches, and here the text differs from the mark: a mark
lives on an inset axes in figure-fraction coordinates and keeps its share of a figure resized
proportionally afterwards, whereas text is measured in points and keeps its absolute size,
halving its share when the figure doubles.
Usage#
import numpy as np
from cleopatra.glyphs.gridded.array_glyph import ArrayGlyph
glyph = ArrayGlyph(np.arange(60.0).reshape(6, 10))
glyph.plot()
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...
glyph.stamp_mark("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
glyph.stamp_mark(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:
glyph.stamp_mark("brand/logo.png", margin=(0.025, 0.0)) # flush with the bottom, inset from the right
Stamp 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).
Method Documentation#
cleopatra.styling.watermark.WatermarkMixin
#
The glyph-side entry point for both figure stamps.
Both stamps act on a whole Figure, and every glyph knows its own, so the
glyph is where they are offered: glyph.stamp_mark(logo). The functions
underneath are private, which makes this the one supported way in rather
than a convenience beside an equally public free function.
A figure is what these need, and glyphs spell it differently. Glyph keeps
the one it rendered on in fig. HistogramGlyph and TexturedGlobeGlyph
do not inherit Glyph and use _fig for something else -- the figure bound
at construction, consulted on every render to decide where to draw -- so
they record the figure actually drawn on as _rendered_fig.
_watermark_figure prefers that, then fig, then _fig, then the axes'
own figure, so the mixin works on any of them without first making them
agree on a name.
Note that a figure may carry several glyphs. The stamp lands on the whole figure, not on the glyph's own axes, whichever glyph it was called through.
Source code in src/cleopatra/styling/watermark.py
814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 | |
stamp_mark(path, **kwargs)
#
Stamp a logo image on this glyph's figure.
The primary way to stamp a logo, for the common single-glyph case.
_stamp_mark underneath takes a bare Figure and is private; a
composite figure no single glyph owns has its own supported entry
point, stamp_mark_on.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str | PathLike | ndarray
|
The mark image, as |
required |
**kwargs
|
Any
|
Forwarded verbatim ( |
{}
|
Returns:
| Name | Type | Description |
|---|---|---|
Axes |
Axes
|
The frameless inset axes the mark was drawn on. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the glyph has not been rendered yet, or as
|
See Also
cleopatra.styling.watermark.stamp_mark_on: The figure-taking counterpart, for a composite figure no single glyph owns. cleopatra.styling.watermark._stamp_mark: The private figure stamp underneath.
Source code in src/cleopatra/styling/watermark.py
stamp_watermark(text, **kwargs)
#
Stamp diagonal brand text on this glyph's figure.
The supported way to stamp brand text. _stamp_watermark underneath
takes a bare Figure and is private: a figure no glyph owns is out of
scope.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
text
|
str
|
The brand text. |
required |
**kwargs
|
Any
|
Forwarded verbatim ( |
{}
|
Returns:
| Type | Description |
|---|---|
Text
|
tuple[Text, Text | None]: The brand-text artist and the credit-line |
Text | None
|
artist, the second being |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the glyph has not been rendered yet, or as
|
See Also
cleopatra.styling.watermark._stamp_watermark: The private function underneath.
Examples:
- Stamp a logo and brand text without importing either:
>>> import matplotlib >>> matplotlib.use("Agg") >>> import numpy as np >>> import matplotlib.pyplot as plt >>> from cleopatra.glyphs.gridded.array_glyph import ArrayGlyph >>> glyph = ArrayGlyph(np.arange(60.0).reshape(6, 10)) >>> _ = glyph.plot() >>> brand, credit = glyph.stamp_watermark("cleopatra") >>> brand.get_text() 'cleopatra' >>> plt.close("all")
Source code in src/cleopatra/styling/watermark.py
cleopatra.styling.watermark.stamp_mark_on(fig, path, *, frac=0.11, corner=_DEFAULT_CORNER, margin=0.025, shadow=True, blur=DEFAULT_BLUR)
#
Stamp a logo image on a figure that no single glyph owns.
The supported escape hatch for a composite figure -- one assembled from
several glyphs' panels (a GridSpec whose panels are each an
ArrayGlyph(arr, ax=ax).plot()), or one built entirely outside cleopatra
-- where no single glyph owns the
whole figure, so there is no glyph whose stamp_mark covers it. For the
common single-glyph case use the glyph method WatermarkMixin.stamp_mark,
which stays the primary, discoverable API; this is its figure-taking
counterpart, so a composite layout need not reach for the private
_stamp_mark. It draws the same corner mark; the options below are
_stamp_mark's, forwarded unchanged.
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 mark's longer on-figure side as a fraction of the figure, in
|
0.11
|
corner
|
str
|
Which corner to anchor to -- |
_DEFAULT_CORNER
|
margin
|
float | tuple[float, float]
|
The gap between the mark and the figure edges, a scalar or an
|
0.025
|
shadow
|
bool
|
Whether to composite a gaussian-blurred halo behind the mark so
it reads on a busy or dark canvas. Defaults to |
True
|
blur
|
float
|
Halo blur sigma as a fraction of the mark's own width; must be
non-negative. Defaults to |
DEFAULT_BLUR
|
Returns:
| Name | Type | Description |
|---|---|---|
Axes |
Axes
|
The frameless inset axes the mark was drawn on. |
Raises:
| Type | Description |
|---|---|
ValueError
|
For an unknown |
FileNotFoundError
|
If |
UnidentifiedImageError
|
If |
Examples:
- Stamp a logo once on a composite figure built from several panels:
>>> import matplotlib >>> matplotlib.use("Agg") >>> import numpy as np >>> import matplotlib.pyplot as plt >>> from cleopatra.styling.watermark import stamp_mark_on >>> fig, axes = plt.subplots(1, 3, figsize=(8, 6)) >>> for panel in axes: # three glyph panels no single glyph owns ... _ = panel.imshow(np.zeros((4, 4))) >>> logo = np.full((40, 80, 4), 255, dtype=np.uint8) >>> ax = stamp_mark_on(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)
See Also
WatermarkMixin.stamp_mark: The glyph method for the common single-glyph case -- the primary API. cleopatra.styling.watermark._stamp_mark: The private figure stamp this delegates to.
Source code in src/cleopatra/styling/watermark.py
732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 | |