Note
Go to the end to download the full example code.
Floating keys — IPF triangles and colour wheels#
Some plots are coloured by a direction, not by a magnitude, and a colorbar cannot say what the colours mean. An orientation map is coloured by which crystal axis points at you; a polarization map by which way the moment lies in the plane. Both need a small picture as their legend: an inverse pole figure triangle, or a hue wheel.
add_key() pins that picture over the panel in screen
space — it does not pan or zoom with the data, exactly like the scale bar it is
modelled on.
This is deliberately not add_inset(), which is a
draggable window with a title bar and its own canvas stack. That is the right
tool when the overlay is a live plot; a key is a static picture that should
read as part of the figure.
import numpy as np
import anyplotlib as apl
The IPF colour key#
The standard cubic stereographic triangle. Each pixel’s colour is its barycentric distance to the three corners, which is the classic IPF key: a grain pointing [1 0 0] at the detector reads red, [1 1 0] green, [1 1 1] blue.
The triangle is built as an (H, W, 4) RGBA array, and alpha 0 outside
the triangle is what lets it sit on the map without a rectangular card
around it.
KEY_N = 220
def ipf_triangle(n=KEY_N):
"""RGBA image of the 001–011–111 stereographic triangle."""
yy, xx = np.mgrid[0:n, 0:n]
u = xx / (n - 1)
v = 1.0 - yy / (n - 1)
inside = v <= u + 1e-9 # lower-right half
# Barycentric-ish weights: distance to each corner, normalised.
d100 = np.hypot(u, v) # corner (0, 0)
d110 = np.hypot(u - 1.0, v) # corner (1, 0)
d111 = np.hypot(u - 1.0, v - 1.0) # corner (1, 1)
far = np.maximum.reduce([d100, d110, d111])
rgb = np.stack([1 - d100 / far, 1 - d110 / far, 1 - d111 / far], -1)
rgb /= rgb.max(-1, keepdims=True) + 1e-9 # full saturation at the corners
img = np.zeros((n, n, 4), np.uint8)
img[..., :3] = np.clip(rgb, 0, 1) * 255
img[..., 3] = np.where(inside, 255, 0)
return img
A synthetic orientation map#
Voronoi grains, each with a random orientation, coloured through the same key so the map and its legend agree by construction.
rng = np.random.default_rng(11)
H, W, NGRAIN = 210, 280, 40
cy, cx = rng.uniform(0, H, NGRAIN), rng.uniform(0, W, NGRAIN)
yy, xx = np.mgrid[0:H, 0:W]
grain = np.hypot(yy[..., None] - cy, xx[..., None] - cx).argmin(-1)
# Each grain gets a point in the triangle, then reads its colour off the key.
gu = rng.uniform(0, 1, NGRAIN)
gv = rng.uniform(0, 1, NGRAIN) * gu # keep it inside v <= u
key_img = ipf_triangle()
kx = np.clip((gu * (KEY_N - 1)).astype(int), 0, KEY_N - 1)
ky = np.clip(((1 - gv) * (KEY_N - 1)).astype(int), 0, KEY_N - 1)
grain_rgb = key_img[ky, kx, :3]
ipf_map = grain_rgb[grain] # (H, W, 3) true colour
Pinning the key#
labels draws text inside the picture, positioned as fractions of the
key image, so the corner indices stay on the corners at any size.
fig, ax = apl.subplots(1, 1, figsize=(520, 420))
vmap = ax.imshow(ipf_map)
vmap.set_title("orientation map")
vmap.add_key(
key_img,
corner="bottom-right",
size=0.34,
# `align` keeps a label inside the key: centring text on a corner would
# hang half of it off the edge, where the panel clips it.
labels=[
{"x": 0.02, "y": 0.93, "text": "[1 0 0]", "align": "left"},
{"x": 0.98, "y": 0.93, "text": "[1 1 0]", "align": "right"},
{"x": 0.98, "y": 0.08, "text": "[1 1 1]", "align": "right"},
],
name="ipf",
)
fig
A colour wheel over a polarization map#
Same mechanism, different legend. Here the key gets a translucent card
(bgcolor) because the field underneath is saturated everywhere and a bare
wheel would fight with it.
def hue_wheel(n=KEY_N):
"""RGBA colour wheel: hue = in-plane angle, value = magnitude."""
yy, xx = np.mgrid[0:n, 0:n]
ang = (np.arctan2(-(yy - n / 2), xx - n / 2) + np.pi) / (2 * np.pi)
rad = np.hypot(yy - n / 2, xx - n / 2) / (n / 2)
h6 = ang * 6.0
chan = np.clip(
np.abs(((h6 + np.array([0, 4, 2])[:, None, None]) % 6) - 3) - 1, 0, 1)
img = np.zeros((n, n, 4), np.uint8)
img[..., :3] = chan.transpose(1, 2, 0) * 255 * np.clip(rad, 0, 1)[..., None]
img[..., 3] = np.where(rad <= 1.0, 255, 0)
return img
# A vortex: the moment angle winds once around the centre.
ang = np.arctan2(yy - H / 2, xx - W / 2)
mag = np.clip(np.hypot(yy - H / 2, xx - W / 2) / (0.5 * min(H, W)), 0, 1)
a6 = ((ang + np.pi) / (2 * np.pi)) * 6.0
chan = np.clip(np.abs(((a6 + np.array([0, 4, 2])[:, None, None]) % 6) - 3) - 1, 0, 1)
polar_map = (chan.transpose(1, 2, 0) * 255 * (0.3 + 0.7 * mag)[..., None]).astype(np.uint8)
fig2, ax2 = apl.subplots(1, 1, figsize=(520, 420))
vpol = ax2.imshow(polar_map)
vpol.set_title("in-plane magnetic polarization")
vpol.add_key(
hue_wheel(),
corner="top-right",
size=0.26,
bgcolor="rgba(0,0,0,0.45)", # legible over a busy field
border="#ffffff",
label="moment direction",
labels=[
(0.5, 0.06, "N"), (0.94, 0.5, "E"),
(0.5, 0.94, "S"), (0.06, 0.5, "W"),
],
name="wheel",
)
fig2
Keeping it out of the way#
hover_only=True shows the key only while the pointer is over the panel —
a reading aid that does not sit on the data while you study it. PNG export
renders the panel as though the pointer were there, so an exported figure
still carries the key.
Everything is live: set() restyles a key without
re-sending the picture, and set_image() swaps
the picture without disturbing the placement:
key = vmap.get_key("ipf")
key.set(size=0.4, corner="top-left")
key.visible = False
Total running time of the script: (0 minutes 1.586 seconds)