|
| 1 | +""" |
| 2 | +Inverse Pole Figure (IPF) Explorer |
| 3 | +================================== |
| 4 | +
|
| 5 | +An EBSD-style orientation explorer for a synthetic polycrystal: |
| 6 | +
|
| 7 | +* **Left panel** — IPF-Z orientation map, colored with the standard cubic |
| 8 | + IPF key (red = ⟨001⟩, green = ⟨011⟩, blue = ⟨111⟩). Rendered as a |
| 9 | + true-color RGB image. |
| 10 | +* **Right panel** — the *reduced 3-D inverse pole figure*: every grain's |
| 11 | + sample-Z direction, expressed in crystal coordinates and folded into the |
| 12 | + cubic fundamental sector, plotted as an IPF-colored point cloud on a |
| 13 | + shaded, wireframed unit sphere. |
| 14 | +
|
| 15 | +Drag the crosshair on the map: the grain's orientation is marked with a |
| 16 | +highlighted dot on the sphere, and the sphere **rotates so that direction |
| 17 | +faces you**. Drag on the sphere to orbit freely; the next crosshair move |
| 18 | +re-aims the camera. |
| 19 | +""" |
| 20 | + |
| 21 | +import numpy as np |
| 22 | +import anyplotlib as apl |
| 23 | + |
| 24 | +rng = np.random.default_rng(42) |
| 25 | + |
| 26 | +# ── 1. Synthetic polycrystal: nearest-seed grain map ──────────────────────── |
| 27 | +H = W = 192 |
| 28 | +N_GRAINS = 60 |
| 29 | + |
| 30 | +seeds = rng.uniform(0, [H, W], size=(N_GRAINS, 2)) |
| 31 | +yy, xx = np.mgrid[0:H, 0:W] |
| 32 | +d2 = (yy[..., None] - seeds[:, 0]) ** 2 + (xx[..., None] - seeds[:, 1]) ** 2 |
| 33 | +grain_id = np.argmin(d2, axis=-1) # (H, W) labels |
| 34 | + |
| 35 | + |
| 36 | +# ── 2. Random orientation per grain (uniform rotations via quaternions) ───── |
| 37 | +def random_rotations(n): |
| 38 | + """Uniform random rotation matrices, shape (n, 3, 3) (Shoemake method).""" |
| 39 | + u1, u2, u3 = rng.random((3, n)) |
| 40 | + q = np.stack([ |
| 41 | + np.sqrt(1 - u1) * np.sin(2 * np.pi * u2), |
| 42 | + np.sqrt(1 - u1) * np.cos(2 * np.pi * u2), |
| 43 | + np.sqrt(u1) * np.sin(2 * np.pi * u3), |
| 44 | + np.sqrt(u1) * np.cos(2 * np.pi * u3), |
| 45 | + ], axis=1) # (n, 4) unit quats |
| 46 | + x, y, z, w = q.T |
| 47 | + return np.stack([ |
| 48 | + np.stack([1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)], -1), |
| 49 | + np.stack([2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)], -1), |
| 50 | + np.stack([2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)], -1), |
| 51 | + ], axis=1) |
| 52 | + |
| 53 | + |
| 54 | +rotations = random_rotations(N_GRAINS) |
| 55 | + |
| 56 | +# Sample-Z expressed in each grain's crystal frame: d = Rᵀ · ẑ |
| 57 | +dirs = rotations[:, 2, :] # row 2 of R == Rᵀ·ẑ |
| 58 | + |
| 59 | +# ── 3. Reduce to the cubic fundamental sector and IPF-color ──────────────── |
| 60 | +# For cubic symmetry, sorting |components| ascending lands every direction |
| 61 | +# in the standard 001–011–111 stereographic triangle. |
| 62 | +reduced = np.sort(np.abs(dirs), axis=1) # (a ≤ b ≤ c) |
| 63 | +a, b, c = reduced.T |
| 64 | + |
| 65 | +# Classic IPF key: distance to each triangle corner → R, G, B |
| 66 | +rgb = np.stack([c - b, b - a, a], axis=1) |
| 67 | +rgb /= rgb.max(axis=1, keepdims=True) + 1e-12 # vivid normalisation |
| 68 | +grain_rgb_u8 = (rgb * 255).astype(np.uint8) # (N_GRAINS, 3) |
| 69 | + |
| 70 | +ipf_map = grain_rgb_u8[grain_id] # (H, W, 3) true-color |
| 71 | + |
| 72 | + |
| 73 | +# ── 4. Figure: RGB map + reduced 3-D IPF point cloud ─────────────────────── |
| 74 | +fig, (ax_map, ax_ipf) = apl.subplots( |
| 75 | + 1, 2, figsize=(880, 420), |
| 76 | + help="Drag the crosshair: the sphere rotates to face that grain's\n" |
| 77 | + "crystal direction. Drag the sphere to orbit freely.") |
| 78 | + |
| 79 | +vmap = ax_map.imshow(ipf_map) # (H, W, 3) → RGB |
| 80 | +vmap.set_title("IPF-Z orientation map") |
| 81 | +cross = vmap.add_widget("crosshair", cx=W // 2, cy=H // 2, color="#ffffff") |
| 82 | + |
| 83 | +# reduced directions live on the unit sphere → fix bounds to keep the |
| 84 | +# origin centred and the geometry origin-true |
| 85 | +vipf = ax_ipf.scatter3d( |
| 86 | + reduced[:, 0], reduced[:, 1], reduced[:, 2], |
| 87 | + colors=grain_rgb_u8, point_size=6, |
| 88 | + x_label="[100]", y_label="[010]", z_label="[001]", |
| 89 | + bounds=((-1, 1),) * 3, zoom=1.4, |
| 90 | +) |
| 91 | +vipf.set_title("Reduced 3D IPF (cubic fundamental sector)") |
| 92 | +# Shaded unit sphere with lat/long wireframe behind the direction vectors |
| 93 | +vipf.set_sphere(1.0) |
| 94 | + |
| 95 | + |
| 96 | +# ── 5. Crosshair → highlight + rotate-to-face ─────────────────────────────── |
| 97 | +def face_camera(v): |
| 98 | + """(azimuth°, elevation°) that aim the camera straight down *v*. |
| 99 | +
|
| 100 | + With the turntable camera, the view faces unit vector ``v`` when |
| 101 | + ``el = asin(vz)`` and ``az = atan2(vx, -vy)``. |
| 102 | + """ |
| 103 | + vx, vy, vz = v |
| 104 | + el = np.degrees(np.arcsin(np.clip(vz, -1.0, 1.0))) |
| 105 | + az = np.degrees(np.arctan2(vx, -vy)) |
| 106 | + return az, el |
| 107 | + |
| 108 | + |
| 109 | +def show_orientation(gid: int) -> None: |
| 110 | + v = reduced[gid] |
| 111 | + vipf.set_highlight(*v, color="#ffffff", size=8) |
| 112 | + az, el = face_camera(v) |
| 113 | + vipf.set_view(azimuth=az, elevation=el) |
| 114 | + |
| 115 | + |
| 116 | +@cross.add_event_handler("pointer_move") |
| 117 | +def on_move(event): |
| 118 | + ix = int(np.clip(round(cross.cx), 0, W - 1)) |
| 119 | + iy = int(np.clip(round(cross.cy), 0, H - 1)) |
| 120 | + show_orientation(int(grain_id[iy, ix])) |
| 121 | + |
| 122 | + |
| 123 | +show_orientation(int(grain_id[H // 2, W // 2])) |
| 124 | + |
| 125 | +fig # Interactive |
0 commit comments