Open In Colab # Show4D — Quick Demo Synthetic nanoparticle sample: ~200 crystalline particles with 10 distinct zone-axis orientations scattered on an amorphous substrate (128×128 scan, 128×128 detector). Click on a nanoparticle to see its zone-axis diffraction pattern with Bragg spots, Kikuchi bands, and HOLZ ring. Click on the background to see diffuse amorphous rings. Particles vary in size and cluster density — some isolated, some aggregated.

[1]:
# Install in Google Colab
try:
    import google.colab
    !pip install -q -i https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ quantem-widget
except ImportError:
    pass  # Not in Colab, skip
[2]:
try:
    %load_ext autoreload
    %autoreload 2
    %env ANYWIDGET_HMR=1
except Exception:
    pass  # autoreload unavailable (Colab Python 3.12+)
env: ANYWIDGET_HMR=1
[3]:
import numpy as np
import torch
device = torch.device(
    "mps" if torch.backends.mps.is_available() else
    "cuda" if torch.cuda.is_available() else "cpu"
)
print(f"Using device: {device}")
def make_nanoparticle_sample(nav=128, det=128, n_particles=200, n_orientations=10):
    """Nanoparticle sample: crystalline particles on amorphous substrate.
    Fully vectorized GPU-accelerated computation — no Python loops over pixels."""
    rng = np.random.default_rng(42)
    c = det // 2
    # Detector grid on GPU
    yy, xx = torch.meshgrid(
        torch.arange(det, device=device, dtype=torch.float32),
        torch.arange(det, device=device, dtype=torch.float32), indexing="ij",
    )
    r = torch.sqrt((xx - c) ** 2 + (yy - c) ** 2)
    # ── Vectorized particle map (no loop over particles) ──
    radii = rng.lognormal(mean=1.5, sigma=0.5, size=n_particles).clip(2, 15).astype(np.float32)
    centers_row = (rng.beta(2, 1.2, n_particles) * (nav - 4) + 2).astype(np.float32)
    centers_col = rng.uniform(2, nav - 2, n_particles).astype(np.float32)
    orientations_arr = rng.integers(0, n_orientations, size=n_particles)
    # Broadcasting: (n_particles, nav, nav) — all distances at once
    ii = np.arange(nav, dtype=np.float32)[:, None]
    jj = np.arange(nav, dtype=np.float32)[None, :]
    dist_sq = ((ii[None] - centers_row[:, None, None]) ** 2 +
               (jj[None] - centers_col[:, None, None]) ** 2)
    inside = dist_sq <= radii[:, None, None] ** 2
    # "Last particle wins" — find highest-index particle covering each pixel
    particle_map = np.full((nav, nav), -1, dtype=np.int32)
    any_inside = inside.any(axis=0)
    last_idx = n_particles - 1 - np.argmax(inside[::-1], axis=0)
    particle_map[any_inside] = orientations_arr[last_idx[any_inside]]
    crystalline_frac = np.sum(particle_map >= 0) / particle_map.size
    print(f"Particle coverage: {crystalline_frac:.1%}")
    # ── Crystallographic properties on GPU ──
    grain_rot = torch.tensor(rng.uniform(0, np.pi, n_orientations), device=device, dtype=torch.float32)
    grain_a1 = torch.tensor(rng.uniform(11, 22, n_orientations), device=device, dtype=torch.float32)
    grain_a2 = torch.tensor(rng.uniform(11, 22, n_orientations), device=device, dtype=torch.float32)
    grain_angle_rad = torch.deg2rad(torch.tensor(rng.uniform(55, 125, n_orientations), device=device, dtype=torch.float32))
    # ── All (h,k) pairs except (0,0) ──
    hk_list = [(h, k) for h in range(-4, 5) for k in range(-4, 5) if not (h == 0 and k == 0)]
    hk = torch.tensor(hk_list, device=device, dtype=torch.float32)  # (80, 2)
    # Kikuchi band directions
    dh_arr = torch.tensor([1, 0, 1], device=device, dtype=torch.float32)
    dk_arr = torch.tensor([0, 1, 1], device=device, dtype=torch.float32)
    s_arr = torch.tensor([0.018, 0.014, 0.009], device=device, dtype=torch.float32)
    # ── Amorphous template ──
    amorphous_t = (0.02 * torch.exp(-r / 45) + 0.12 * torch.exp(-((r - 15) ** 2) / 50)
                 + 0.06 * torch.exp(-((r - 32) ** 2) / 80) + 0.03 * torch.exp(-((r - 48) ** 2) / 100))
    amorphous_np = amorphous_t.cpu().numpy().astype(np.float32)
    # ── Per-orientation templates (vectorized over spots within each orientation) ──
    templates = np.zeros((n_orientations, det, det), dtype=np.float32)
    for o in range(n_orientations):
        rot = grain_rot[o]
        a1m, a2m = grain_a1[o], grain_a2[o]
        angle = grain_angle_rad[o]
        # Reciprocal lattice vectors
        a1x, a1y = a1m * torch.cos(rot), a1m * torch.sin(rot)
        a2x, a2y = a2m * torch.cos(rot + angle), a2m * torch.sin(rot + angle)
        # Background + central beam
        dp = 0.03 * torch.exp(-r / 50) + torch.clamp(1.0 - torch.clamp(r - 7, min=0) / 1.5, min=0, max=1)
        # ── Vectorized Bragg spots: all 80 (h,k) at once ──
        spot_x = c + hk[:, 0] * a1x + hk[:, 1] * a2x  # (80,)
        spot_y = c + hk[:, 0] * a1y + hk[:, 1] * a2y
        valid = (spot_x > -5) & (spot_x < det + 5) & (spot_y > -5) & (spot_y < det + 5)
        sx, sy = spot_x[valid], spot_y[valid]
        h_v, k_v = hk[valid, 0], hk[valid, 1]
        # Structure factors + radial falloff: (n_valid,)
        f = torch.where((h_v + k_v) % 2 == 0, 0.6, 0.07)
        g_sq = (sx - c) ** 2 + (sy - c) ** 2
        # All Gaussians at once: (n_valid, det, det) → sum → (det, det)
        dx = xx[None] - sx[:, None, None]
        dy = yy[None] - sy[:, None, None]
        dp = dp + (f[:, None, None] * torch.exp(-g_sq[:, None, None] / 5500)
                   * torch.exp(-(dx ** 2 + dy ** 2) / 6.5)).sum(dim=0)
        # ── Vectorized Kikuchi bands: all 3 at once ──
        gx = dh_arr * a1x + dk_arr * a2x  # (3,)
        gy = dh_arr * a1y + dk_arr * a2y
        g_len = torch.sqrt(gx ** 2 + gy ** 2)
        band_valid = g_len >= 1
        if band_valid.any():
            gx_v, gy_v = gx[band_valid], gy[band_valid]
            g_len_v, s_v = g_len[band_valid], s_arr[band_valid]
            perp = ((xx[None] - c) * (-gy_v[:, None, None])
                    + (yy[None] - c) * gx_v[:, None, None]) / g_len_v[:, None, None]
            half_g = g_len_v[:, None, None] / 2
            band = torch.exp(-((perp - half_g) ** 2) / 16) + torch.exp(-((perp + half_g) ** 2) / 16)
            dp = dp + (s_v[:, None, None] * band * torch.exp(-r / 55)[None]).sum(dim=0)
        # HOLZ ring
        dp = dp + 0.02 * torch.exp(-((r - 50) ** 2) / 4.5)
        templates[o] = dp.cpu().numpy()
    # ── Assign patterns (batched, memory-bounded) ──
    data = np.zeros((nav, nav, det, det), dtype=np.float32)
    BATCH = 2048
    # Amorphous positions
    amorphous_idx = np.argwhere(particle_map == -1)
    for start in range(0, len(amorphous_idx), BATCH):
        idx = amorphous_idx[start:start + BATCH]
        n = len(idx)
        batch = np.empty((n, det, det), dtype=np.float32)
        batch[:] = amorphous_np
        batch += 0.008 * rng.standard_normal((n, det, det)).astype(np.float32)
        np.maximum(batch, 0, out=batch)
        data[idx[:, 0], idx[:, 1]] = rng.poisson(np.clip(batch * 300, 0, 1e6)).astype(np.float32) / 300
    # Crystalline positions
    for o in range(n_orientations):
        o_idx = np.argwhere(particle_map == o)
        if len(o_idx) == 0:
            continue
        t_vals = (0.5 + 0.5 * rng.random(len(o_idx))).astype(np.float32)
        for start in range(0, len(o_idx), BATCH):
            idx = o_idx[start:start + BATCH]
            t_batch = t_vals[start:start + BATCH]
            scaled = templates[o][None, :, :] * t_batch[:, None, None]
            np.maximum(scaled, 0, out=scaled)
            data[idx[:, 0], idx[:, 1]] = rng.poisson(np.clip(scaled * 400, 0, 1e6)).astype(np.float32) / 400
    return data, particle_map
data, particle_map = make_nanoparticle_sample()
print(f"Shape: {data.shape}, dtype: {data.dtype}")
print(f"Range: [{data.min():.3f}, {data.max():.3f}]")
print(f"Memory: {data.nbytes / 1e6:.0f} MB")
print(f"Orientations: {len(np.unique(particle_map[particle_map >= 0]))}")
Using device: mps
Particle coverage: 57.9%
Shape: (128, 128, 128, 128), dtype: float32
Range: [0.000, 1.237]
Memory: 1074 MB
Orientations: 10
[4]:
import quantem.widget
from quantem.widget import Show4D
w = Show4D(
    data,
    title="Nanoparticle Diffraction",
    nav_pixel_size=2.39,
    nav_pixel_unit="\u00c5",
    sig_pixel_size=0.46,
    sig_pixel_unit="mrad",
)
print(f"quantem.widget {quantem.widget.__version__}")
quantem.widget 0.4.0a3
[5]:
w
[5]:

Inspect Widget State#

[6]:
w.summary()
Nanoparticle Diffraction
════════════════════════════════
Nav:      128×128 (2.39 Å/px)
Signal:   128×128 (0.46 mrad/px)
Position: (64, 64)
Display:  inferno | auto contrast | linear