|
| 1 | +""" |
| 2 | +Floating keys — IPF triangles and colour wheels |
| 3 | +=============================================== |
| 4 | +
|
| 5 | +Some plots are coloured by a *direction*, not by a magnitude, and a colorbar |
| 6 | +cannot say what the colours mean. An orientation map is coloured by which |
| 7 | +crystal axis points at you; a polarization map by which way the moment lies in |
| 8 | +the plane. Both need a small picture as their legend: an inverse pole figure |
| 9 | +triangle, or a hue wheel. |
| 10 | +
|
| 11 | +:meth:`~anyplotlib.Plot2D.add_key` pins that picture over the panel in *screen* |
| 12 | +space — it does not pan or zoom with the data, exactly like the scale bar it is |
| 13 | +modelled on. |
| 14 | +
|
| 15 | +This is deliberately not :meth:`~anyplotlib.Figure.add_inset`, which is a |
| 16 | +draggable window with a title bar and its own canvas stack. That is the right |
| 17 | +tool when the overlay is a live plot; a key is a static picture that should |
| 18 | +read as part of the figure. |
| 19 | +""" |
| 20 | +import numpy as np |
| 21 | + |
| 22 | +import anyplotlib as apl |
| 23 | + |
| 24 | +# %% |
| 25 | +# The IPF colour key |
| 26 | +# ------------------ |
| 27 | +# The standard cubic stereographic triangle. Each pixel's colour is its |
| 28 | +# barycentric distance to the three corners, which is the classic IPF key: a |
| 29 | +# grain pointing [1 0 0] at the detector reads red, [1 1 0] green, [1 1 1] blue. |
| 30 | +# |
| 31 | +# The triangle is built as an ``(H, W, 4)`` RGBA array, and **alpha 0 outside |
| 32 | +# the triangle** is what lets it sit on the map without a rectangular card |
| 33 | +# around it. |
| 34 | + |
| 35 | +KEY_N = 220 |
| 36 | + |
| 37 | + |
| 38 | +def ipf_triangle(n=KEY_N): |
| 39 | + """RGBA image of the 001–011–111 stereographic triangle.""" |
| 40 | + yy, xx = np.mgrid[0:n, 0:n] |
| 41 | + u = xx / (n - 1) |
| 42 | + v = 1.0 - yy / (n - 1) |
| 43 | + inside = v <= u + 1e-9 # lower-right half |
| 44 | + |
| 45 | + # Barycentric-ish weights: distance to each corner, normalised. |
| 46 | + d100 = np.hypot(u, v) # corner (0, 0) |
| 47 | + d110 = np.hypot(u - 1.0, v) # corner (1, 0) |
| 48 | + d111 = np.hypot(u - 1.0, v - 1.0) # corner (1, 1) |
| 49 | + far = np.maximum.reduce([d100, d110, d111]) |
| 50 | + rgb = np.stack([1 - d100 / far, 1 - d110 / far, 1 - d111 / far], -1) |
| 51 | + rgb /= rgb.max(-1, keepdims=True) + 1e-9 # full saturation at the corners |
| 52 | + |
| 53 | + img = np.zeros((n, n, 4), np.uint8) |
| 54 | + img[..., :3] = np.clip(rgb, 0, 1) * 255 |
| 55 | + img[..., 3] = np.where(inside, 255, 0) |
| 56 | + return img |
| 57 | + |
| 58 | + |
| 59 | +# %% |
| 60 | +# A synthetic orientation map |
| 61 | +# --------------------------- |
| 62 | +# Voronoi grains, each with a random orientation, coloured through the same key |
| 63 | +# so the map and its legend agree by construction. |
| 64 | + |
| 65 | +rng = np.random.default_rng(11) |
| 66 | +H, W, NGRAIN = 210, 280, 40 |
| 67 | + |
| 68 | +cy, cx = rng.uniform(0, H, NGRAIN), rng.uniform(0, W, NGRAIN) |
| 69 | +yy, xx = np.mgrid[0:H, 0:W] |
| 70 | +grain = np.hypot(yy[..., None] - cy, xx[..., None] - cx).argmin(-1) |
| 71 | + |
| 72 | +# Each grain gets a point in the triangle, then reads its colour off the key. |
| 73 | +gu = rng.uniform(0, 1, NGRAIN) |
| 74 | +gv = rng.uniform(0, 1, NGRAIN) * gu # keep it inside v <= u |
| 75 | +key_img = ipf_triangle() |
| 76 | +kx = np.clip((gu * (KEY_N - 1)).astype(int), 0, KEY_N - 1) |
| 77 | +ky = np.clip(((1 - gv) * (KEY_N - 1)).astype(int), 0, KEY_N - 1) |
| 78 | +grain_rgb = key_img[ky, kx, :3] |
| 79 | +ipf_map = grain_rgb[grain] # (H, W, 3) true colour |
| 80 | + |
| 81 | +# %% |
| 82 | +# Pinning the key |
| 83 | +# --------------- |
| 84 | +# ``labels`` draws text *inside* the picture, positioned as fractions of the |
| 85 | +# key image, so the corner indices stay on the corners at any ``size``. |
| 86 | + |
| 87 | +fig, ax = apl.subplots(1, 1, figsize=(520, 420)) |
| 88 | +vmap = ax.imshow(ipf_map) |
| 89 | +vmap.set_title("orientation map") |
| 90 | + |
| 91 | +vmap.add_key( |
| 92 | + key_img, |
| 93 | + corner="bottom-right", |
| 94 | + size=0.34, |
| 95 | + # `align` keeps a label inside the key: centring text on a corner would |
| 96 | + # hang half of it off the edge, where the panel clips it. |
| 97 | + labels=[ |
| 98 | + {"x": 0.02, "y": 0.93, "text": "[1 0 0]", "align": "left"}, |
| 99 | + {"x": 0.98, "y": 0.93, "text": "[1 1 0]", "align": "right"}, |
| 100 | + {"x": 0.98, "y": 0.08, "text": "[1 1 1]", "align": "right"}, |
| 101 | + ], |
| 102 | + name="ipf", |
| 103 | +) |
| 104 | + |
| 105 | +fig |
| 106 | + |
| 107 | +# %% |
| 108 | +# A colour wheel over a polarization map |
| 109 | +# -------------------------------------- |
| 110 | +# Same mechanism, different legend. Here the key gets a translucent card |
| 111 | +# (``bgcolor``) because the field underneath is saturated everywhere and a bare |
| 112 | +# wheel would fight with it. |
| 113 | + |
| 114 | +def hue_wheel(n=KEY_N): |
| 115 | + """RGBA colour wheel: hue = in-plane angle, value = magnitude.""" |
| 116 | + yy, xx = np.mgrid[0:n, 0:n] |
| 117 | + ang = (np.arctan2(-(yy - n / 2), xx - n / 2) + np.pi) / (2 * np.pi) |
| 118 | + rad = np.hypot(yy - n / 2, xx - n / 2) / (n / 2) |
| 119 | + h6 = ang * 6.0 |
| 120 | + chan = np.clip( |
| 121 | + np.abs(((h6 + np.array([0, 4, 2])[:, None, None]) % 6) - 3) - 1, 0, 1) |
| 122 | + img = np.zeros((n, n, 4), np.uint8) |
| 123 | + img[..., :3] = chan.transpose(1, 2, 0) * 255 * np.clip(rad, 0, 1)[..., None] |
| 124 | + img[..., 3] = np.where(rad <= 1.0, 255, 0) |
| 125 | + return img |
| 126 | + |
| 127 | + |
| 128 | +# A vortex: the moment angle winds once around the centre. |
| 129 | +ang = np.arctan2(yy - H / 2, xx - W / 2) |
| 130 | +mag = np.clip(np.hypot(yy - H / 2, xx - W / 2) / (0.5 * min(H, W)), 0, 1) |
| 131 | +a6 = ((ang + np.pi) / (2 * np.pi)) * 6.0 |
| 132 | +chan = np.clip(np.abs(((a6 + np.array([0, 4, 2])[:, None, None]) % 6) - 3) - 1, 0, 1) |
| 133 | +polar_map = (chan.transpose(1, 2, 0) * 255 * (0.3 + 0.7 * mag)[..., None]).astype(np.uint8) |
| 134 | + |
| 135 | +fig2, ax2 = apl.subplots(1, 1, figsize=(520, 420)) |
| 136 | +vpol = ax2.imshow(polar_map) |
| 137 | +vpol.set_title("in-plane magnetic polarization") |
| 138 | + |
| 139 | +vpol.add_key( |
| 140 | + hue_wheel(), |
| 141 | + corner="top-right", |
| 142 | + size=0.26, |
| 143 | + bgcolor="rgba(0,0,0,0.45)", # legible over a busy field |
| 144 | + border="#ffffff", |
| 145 | + label="moment direction", |
| 146 | + labels=[ |
| 147 | + (0.5, 0.06, "N"), (0.94, 0.5, "E"), |
| 148 | + (0.5, 0.94, "S"), (0.06, 0.5, "W"), |
| 149 | + ], |
| 150 | + name="wheel", |
| 151 | +) |
| 152 | + |
| 153 | +fig2 |
| 154 | + |
| 155 | +# %% |
| 156 | +# Keeping it out of the way |
| 157 | +# ------------------------- |
| 158 | +# ``hover_only=True`` shows the key only while the pointer is over the panel — |
| 159 | +# a reading aid that does not sit on the data while you study it. PNG export |
| 160 | +# renders the panel as though the pointer were there, so an exported figure |
| 161 | +# still carries the key. |
| 162 | +# |
| 163 | +# Everything is live: :meth:`~anyplotlib.KeyOverlay.set` restyles a key without |
| 164 | +# re-sending the picture, and :meth:`~anyplotlib.KeyOverlay.set_image` swaps |
| 165 | +# the picture without disturbing the placement:: |
| 166 | +# |
| 167 | +# key = vmap.get_key("ipf") |
| 168 | +# key.set(size=0.4, corner="top-left") |
| 169 | +# key.visible = False |
0 commit comments