Workflows
This page shows complete, annotated code examples for common UPXO tasks covering all major capabilities: 2D and 3D grain generation (MCGS, Voronoi), hierarchical microstructures (FM Steels), twinned microstructures (FCC), EBSD integration, meshing, and visualization.
All examples reference real, importable classes and functions, verified against the current source
(not just plausible-looking API guesses). Where a full pipeline needs inputs this page can’t fabricate
(real EBSD scan data, an active gmsh session, etc.), the example shows the correct real call pattern and
points to the actual tested demo notebook under src/upxo/demos/ for the complete runnable reference.
Note
Some workflows reference Excel dashboard files (input_dashboard.xls). These use placeholder paths — replace
them with paths to your own dashboard files. Template dashboards are provided under src/upxo/interfaces/user_inputs/.
Part 1: 2D Grain Structures
Workflow 1 — MCGS2D: Simulate, Detect Grains, Characterise
This is the standard MCGS2D pipeline, equivalent to what gschar1.ipynb demonstrates.
from upxo.ggrowth.mcgs import mcgs
# Step 1 — Load dashboard and run the MC simulation
pxt = mcgs(input_dashboard='path/to/input_dashboard.xls')
pxt.simulate()
# Step 2 — Detect grains at every saved time slice
pxt.detect_grains()
# Step 3 — Pick a time slice
# pxt.m is the list of saved MCS step indices
tslice = pxt.m[-1] # last saved step
gs = pxt.gs[tslice] # mcgs2_grain_structure object
# Step 4 — Characterise: request exactly the properties you need
gs.char_morph_2d(
use_version=2,
npixels=True,
area=True,
aspect_ratio=True,
solidity=True,
circularity=True,
char_gb=False,
make_skim_prop=True,
get_grain_coords=True,
)
# Step 5 — Inspect results
print(f"Number of grains: {gs.n}")
print(gs.prop.columns.tolist()) # shows which columns were computed
print(gs.prop.head())
Properties are only present in gs.prop if the matching flag was set to True
in the char_morph_2d call. Available flags include: npixels, area,
aspect_ratio, solidity, circularity, eccentricity,
major_axis_length, minor_axis_length, perimeter, eq_diameter,
compactness, morph_ori, euler_number.
Workflow 1b — 2D Voronoi Polycrystal Generation (High-Fidelity VTGS)
UPXO supports high-fidelity 2D Voronoi tessellation with:
Direct Coordinate Input: Pass
(N, 2)numpy arrays orMPoint2dobjects directly.Periodic Boundary Conditions (PBC): Generate fully periodic Representative Volume Elements (RVEs).
Laguerre / Power Diagrams: Seed-weighted Voronoi tessellation for custom grain-size distributions.
Centroidal Voronoi (CVT): Lloyd relaxation iterations to produce regular, equiaxed microstructure seeds.
Non-linear Interface Perturbation: Curved, natural grain boundary morphologies while preserving manifold junctions.
import numpy as np
from upxo.pxtal.vortess2d import gtess2d
# 1. Generate seeds or load coordinates
seeds = np.array([
[10.0, 10.0], [30.0, 10.0], [50.0, 10.0],
[20.0, 30.0], [40.0, 30.0],
[10.0, 50.0], [30.0, 50.0], [50.0, 50.0],
])
bounds = [[0.0, 60.0], [0.0, 60.0]]
# 2. Build high-fidelity tessellation with CVT and boundary curvature
tess = gtess2d.from_seed_points(
seeds,
bounds=bounds,
periodic=(True, True), # Periodic Boundary Conditions
cvt_iterations=5, # Lloyd relaxation iterations
perturb_factor=0.03, # Boundary interface curvature
)
# 3. Access grain polygons and properties
pxtal = tess.pxtals[1] # Shapely MultiPolygon of grains
print(f"Number of grains: {len(pxtal.geoms)}")
print(f"Domain area: {pxtal.area:.2f}")
# 4. Conformal 2D FE Meshing with GMSH
from upxo.meshing.conformal_mesher2d import confMesh2dGMSH
mesher = confMesh2dGMSH.from_geometric_pxtal(
pxtal=pxtal, xbound=(0, 60), ybound=(0, 60)
)
mesher.femesh_gmsh(mesh_size_gb=2.0, mesh_size_bulk=4.0, recombine_to_quads=False)
mesher.form_elsets_gmsh()
print(f"Created {len(mesher.elsets)} grain element sets.")
Workflow 2 — Visualise the Labelled Grain Image (2D)
After detect_grains(), the labelled grain image is stored in gs.lgi.
import matplotlib.pyplot as plt
tslice = pxt.m[-1]
gs = pxt.gs[tslice]
plt.figure()
plt.imshow(gs.lgi, cmap='tab20')
plt.colorbar(label='Grain ID')
plt.title(f'MCGS2D — tslice {tslice}, {gs.n} grains')
plt.axis('off')
plt.tight_layout()
plt.show()
To visualise the raw MC spin state (before grain detection):
plt.imshow(pxt.S, cmap='nipy_spectral')
plt.title('MC spin state (final)')
plt.show()
Workflow 3 — Grain Size Distribution (2D)
Request npixels=True (pixel count per grain) when calling char_morph_2d,
then plot the distribution.
import matplotlib.pyplot as plt
gs.char_morph_2d(use_version=2, npixels=True, make_skim_prop=True)
pixel_counts = gs.prop['npixels'].values
plt.figure()
plt.hist(pixel_counts, bins=20, edgecolor='k')
plt.xlabel('Grain size (pixels)')
plt.ylabel('Count')
plt.title('Grain size distribution')
plt.tight_layout()
plt.show()
Workflow 4 — Grain Neighbourhood (2D)
tslice = pxt.m[-1]
gs = pxt.gs[tslice]
# Characterise first so bounding boxes exist for the neighbour search
gs.char_morph_2d(use_version=2, bbox=True, bbox_ex=True, make_skim_prop=True)
# Compute neighbours for every grain
gs.find_neigh(include_central_grain=False, print_msg=True, use_numba=True)
# Neighbours of grain with ID 10
print(gs.neigh_gid[10])
Note
A known bug in some builds causes the central grain to appear in its own
neighbour list when include_central_grain=False. The workaround used in
the demo notebooks is:
for gid in gs.neigh_gid.keys():
if gid in gs.neigh_gid[gid]:
gs.neigh_gid[gid].remove(gid)
Workflow 5 — Finding Small and Boundary Grains (2D)
Use the gid_ops module to query the labelled image directly.
import upxo.gsdataops.gid_ops as gidOps
lfi = gs.lgi
# Grains with 5 pixels or fewer
small_grains = gidOps.find_small_fids(lfi, threshold=5)
print("Small grain IDs:", small_grains)
# Grains whose pixels touch the domain boundary
boundary_grains = gidOps.find_boundary_fids2d(lfi)
print("Boundary grain IDs:", boundary_grains)
Workflow 6 — Resampling and Rescaling the Grid
Use grid_ops to change the resolution of the state array or labelled image.
from upxo.gsdataops.grid_ops import resample_grid_2d, rescale_grid_2d
# Downsample by factor 0.25 using the simulation's own grid object
resampled, x_new, y_new, xinc_new, yinc_new = resample_grid_2d(
pxt.S, pxt.uigrid, sf=0.25, method='nearest'
)
print("Original shape:", pxt.S.shape)
print("Resampled shape:", resampled.shape)
# Rescale to twice the resolution
scaled = rescale_grid_2d(pxt.S, scale_factor=2, method='nearest')
print("Scaled shape:", scaled.shape)
Workflow 7 — Merging Small Grains
Single-pixel or sub-threshold grains can be absorbed into their largest neighbour before downstream analysis.
import numpy as np
from upxo.pxtalops.gssmooth2d import _merge_small_grains
lfi = gs.lgi
lfi_clean = _merge_small_grains(lfi, area_threshold=3)
print("Unique grains before:", len(np.unique(lfi)))
print("Unique grains after :", len(np.unique(lfi_clean)))
Workflow 8 — Comparing Grain Size Across Time Slices
Iterate over saved time slices to track how mean grain size evolves.
import numpy as np
import matplotlib.pyplot as plt
mean_sizes = []
for tslice in pxt.m:
gs = pxt.gs[tslice]
gs.char_morph_2d(use_version=2, npixels=True, make_skim_prop=True)
mean_sizes.append(gs.prop['npixels'].mean())
plt.figure()
plt.plot(pxt.m, mean_sizes, marker='o')
plt.xlabel('Monte-Carlo step (tslice)')
plt.ylabel('Mean grain size (pixels)')
plt.title('Grain growth kinetics')
plt.tight_layout()
plt.show()
Part 2: 3D Grain Structures
Workflow 9 — MCGS3D: Monte Carlo 3D Grain Growth
3D Monte Carlo grain growth uses the same mcgs class as the 2D workflow above —
dimensionality is read from the input dashboard (dim: 3), not a separate 3D class.
This is equivalent to gschar2.ipynb.
from upxo.ggrowth.mcgs import mcgs
# Step 1 — Load a 3D dashboard (dim=3) and run the MC simulation
pxt = mcgs(input_dashboard='path/to/input_dashboard_3d.xls')
pxt.simulate()
# Step 2 — Detect grains at every saved time slice
pxt.detect_grains()
# Step 3 — Pick a time slice
tslice = pxt.m[-1]
gs = pxt.gs[tslice]
# Step 4 — Access the 3D labelled grain image
lgi_3d = gs.lgi # 3D array of grain IDs
# Step 5 — Basic characterisation
import numpy as np
print(f"Domain size: {lgi_3d.shape}")
print(f"Number of grains detected: {len(np.unique(lgi_3d)) - 1}") # exclude background (0)
# Step 6 — Extract a 2D slice for visualization
import matplotlib.pyplot as plt
mid_z = lgi_3d.shape[2] // 2
plt.figure()
plt.imshow(lgi_3d[:, :, mid_z], cmap='tab20')
plt.title(f'3D MCGS — XY slice at Z={mid_z}')
plt.colorbar(label='Grain ID')
plt.show()
See src/upxo/demos/gschar/gschar2.ipynb for the complete reference, including
morphological characterisation and 3D visualisation with PyVista.
Workflow 10 — 3D Voronoi Tessellation
3D Voronoi tessellation is built via gtess3d (in upxo.pxtal.vortess3d) —
not a class named Voronoi3D, which does not exist in this codebase.
bounds must be shape (3, 2) — [[xmin, xmax], [ymin, ymax], [zmin, zmax]] —
a flat 6-tuple will raise ValueError.
import numpy as np
from upxo.pxtal.vortess3d import gtess3d
# Step 1 — Define seed points and domain bounds
np.random.seed(42)
seed_points = np.random.uniform(0, 100, size=(50, 3))
bounds = [[0, 100], [0, 100], [0, 100]]
# Step 2 — Build the tessellation directly from seed coordinates
vor3d = gtess3d.from_seed_points(seed_points, bounds=bounds)
# Step 3 — Access results (instance 1; gtess3d supports multi-instance ensembles)
print(f"Number of grains: {vor3d.tprop[1]['ncells']}")
print(f"Bounds: {vor3d.bounds['bbox']}")
print(f"Seed coordinates: {vor3d.sp['coords'][0].shape}")
Periodic boundaries on selected axes:
vor3d_periodic = gtess3d.from_seed_points(
seed_points, bounds=bounds, periodic=(True, True, False)
)
Alternative constructors on the same class: gtess3d.from_mpoint3d (from an
MPoint3d seed object with richer metadata), gtess3d.from_regular_lattice
(structured seed lattices), and gtess3d.from_seed_point_random.
Workflow 11 — 3D Grain Characterisation
Compute morphological properties directly from a 3D labelled grain image
(works the same whether lgi_3d came from MCGS3D or gtess3d).
import numpy as np
lgi_3d = gs.lgi # from Workflow 9, or vor3d.pxtals[...] voxel data from Workflow 10
# Step 1 — Compute volume of each grain
unique_ids = np.unique(lgi_3d)
grain_volumes = {}
for gid in unique_ids:
if gid != 0: # skip background
grain_volumes[gid] = np.sum(lgi_3d == gid)
# Step 2 — Compute center of mass
grain_centers = {}
for gid in unique_ids:
if gid != 0:
mask = (lgi_3d == gid)
coords = np.argwhere(mask)
grain_centers[gid] = coords.mean(axis=0)
# Step 3 — Compute principal moments (inertia tensor)
# This gives aspect ratios and orientation
grain_moments = {}
for gid in unique_ids:
if gid != 0:
mask = (lgi_3d == gid)
coords = np.argwhere(mask) - grain_centers[gid]
# Inertia tensor
Ixx = np.sum(coords[:, 1]**2 + coords[:, 2]**2)
Iyy = np.sum(coords[:, 0]**2 + coords[:, 2]**2)
Izz = np.sum(coords[:, 0]**2 + coords[:, 1]**2)
grain_moments[gid] = {'Ixx': Ixx, 'Iyy': Iyy, 'Izz': Izz}
# Step 4 — Summary statistics
volumes = np.array(list(grain_volumes.values()))
print(f"Grain volume stats:")
print(f" Mean: {volumes.mean():.1f} voxels")
print(f" Std: {volumes.std():.1f} voxels")
print(f" Min: {volumes.min()} voxels")
print(f" Max: {volumes.max()} voxels")
Part 3: Specialized Microstructures
Workflow 12 — Ferritic-Martensitic Steel Hierarchical Microstructure
Generate a hierarchical lath-based microstructure for FM steels (Eurofer, F82H, T91)
using the real fm_steel_3d chainable pipeline. Each stage returns a new
object of the next class in the chain (FMSteel3DBase → ...WithPAGs →
...WithBlocks → ...WithOrientations → ...WithSubBlocks) — it does not
mutate in place. This exact sequence is verified to run end-to-end.
import numpy as np
from upxo.pxtal.fm_steel_3d import FMSteel3DBase
# Step 1 — Start from an existing labelled grain image (LFI/LGI), e.g. from
# a Voronoi or MCGS3D base structure (Workflows 9-10), with physical dimensions
lfi = np.random.randint(1, 100, (50, 50, 50)) # replace with a real base structure
fm = FMSteel3DBase.from_lfi(lfi, physical_dimensions=(100, 100, 100))
# Step 2 — Partition grains into Prior Austenite Grain (PAG) clusters
fm_pag = fm.generate_pag_clusters(
pag_size_distribution={'sizes': [3, 4, 6], 'probs': [0.25, 0.5, 0.25]},
pag_grain_fraction=0.8,
random_seed=42,
)
# Step 3 — Assign PAG orientations, then generate martensitic blocks
fm_pag.assign_pag_orientations(pag_ori_mode='random', random_seed=42)
fm_blk = fm_pag.generate_blocks(block_thickness_range=(2.0, 5.0), random_seed=42)
# Step 4 — Assign Kurdjumov-Sachs block orientations
fm_ori = fm_blk.assign_orientations(
ks_variant_selection='random_per_block', random_seed=42
)
# Step 5 — Optional: sub-block (lath) generation with intra-block scatter
fm_sub = fm_ori.generate_subblocks(
subblock_thickness_range=(0.5, 1.5), random_seed=42
)
# Step 6 — Inspect results
print(f"Grains: {fm_ori.n_grains} Blocks: {fm_ori.n_blocks}")
stats = fm_ori.get_full_hierarchy_statistics()
print(stats)
# Step 7 — Visualise
fm_ori.visualize_block_ipf_map()
fm_ori.plot_pag_map_pyvista()
Real, verified parameters (from the module’s own usage example in
upxo/pxtal/fm_steel_3d/__init__.py, confirmed by running the pipeline above):
pag_size_distribution: dict withsizes(grains-per-PAG options) and matchingprobs.pag_grain_fraction: fraction of base grains absorbed into PAG clusters (0.0–1.0).block_thickness_range: tuple, physical block thickness bounds.ks_variant_selection: variant-assignment strategy, e.g.'random_per_block'.
See src/upxo/demos/FMSteel3D/block_level_01.ipynb for the complete reference
notebook, including retained austenite and mesh export.
Workflow 13 — Twinned FCC Microstructure Generation
Generate a microstructure with Sigma-3 twin lamellae in FCC materials (Cu, CuCrZr, OFHC-Cu)
using the real twinned_simple_3d package. Host-grain setup below is verified to run;
the full physical twin-introduction step (TwinGenerator3D.introduce_primary_twins)
additionally requires EBSD-derived twin-thickness and volume-fraction targets, which
this snippet doesn’t fabricate — see the note and demo notebook below for that part.
import numpy as np
from upxo.pxtal.vortess3d import gtess3d
from upxo.pxtal.twinned_simple_3d import TwinnedSimple3DBase, TwinGenerator3D
# Step 1 — Generate base (host) grain structure, e.g. via Voronoi (Workflow 10)
seed_points = np.random.uniform(0, 100, size=(40, 3))
vor3d = gtess3d.from_seed_points(seed_points, bounds=[[0, 100], [0, 100], [0, 100]])
# Step 2 — Wrap a labelled grain image (lgi) as a TwinnedSimple3DBase
# (voxel_size is required; units default to 'microns')
twin_base = TwinnedSimple3DBase(lgi=lgi_3d, voxel_size=1.0, rng_seed=42)
# Step 3 — Allocate twin-host grains, spatial-dispersal-aware (MIS algorithm)
# avoids selecting adjacent grains as hosts
twin_base.allocate_twin_hosts_spatial(
target_hosting_fraction=0.3, seed=42
)
print(f"Host grains: {len(twin_base.host_grain_ids)}")
# Step 4 — Configure the twin generator (real constructor, all kwargs shown
# have defaults except `base`)
twin_gen = TwinGenerator3D(
base=twin_base,
n_lamellae_per_host=2,
twin_nucleation_site='random_gb',
meshing_route='conformal',
rng_seed=42,
)
Note
TwinGenerator3D.introduce_primary_twins(host_orientations, twin_thickness, tvf)
requires twin_thickness and tvf dicts derived from real EBSD measurements
(via a representativeness/registry object’s compute_mc_twin_thickness and
compute_ebsd_tvf methods) — these encode the target twin volume fraction and
thickness distribution to match. Fabricating placeholder values here would produce
a misleadingly “complete-looking” example that doesn’t reflect how the physics is
actually constrained. For the full, real, working pipeline (EBSD import through
twin generation, cleaning, and export), see:
src/upxo/demos/Twinned3D/repOFHCCu3d_ebsdVf.1.0.ipynb— EBSD-measured volume fractionsrc/upxo/demos/Twinned3D/repOFHCCu3d_probVf.1.0.ipynb— probability-weighted volume fractionsrc/upxo/demos/twins/mcgs3d02.ipynb— end-to-end MC-grown host + twin workflow
After twin generation, StructureCleaner3D.clean(...) removes voxel spikes and
splits disconnected lobes; RepresentativenessValidator3D validates the result
against multi-axis 2D-slice misorientation distributions. See the notebooks above
for both in context.
Workflow 14 — EBSD-Guided Microstructure Generation
Import experimental EBSD data and use it to guide synthetic microstructure generation.
from upxo.interfaces.defdap.ebsd_reader import EBSDReader
# Step 1 — Load and grain-detect an EBSD scan in one call
# Supported formats depend on the underlying DefDAP reader (e.g. .cif/.ctf, .crc)
ebsd = EBSDReader.from_file('path/to/ebsd_scan.cif', min_grain_size=10)
# Step 2 — Inspect the scan
print(f"Scan dimensions: {ebsd.nx} x {ebsd.ny}")
print(f"Number of grains: {ebsd.n_grains}")
# Step 3 — Visualise
ebsd.plot_grain_map()
ebsd.plot_euler_maps()
ebsd.plot_grain_size_histogram()
# Step 4 — Re-characterise the grain-labelled image if needed
# (e.g. after cropping to a region of interest)
ebsd_cropped = ebsd.crop(region=(0, 200, 0, 200))
ebsd_cropped.rechar_lfi(connectivity=4)
Building a texture profile from EBSD-measured orientations, for use in synthetic
generation, uses TextureComponentProfile (in upxo.material.texture) — a
dataclass of crystal_family plus a component_fractions dict (component name
→ volume fraction), fitted from measured data rather than constructed by hand for
anything beyond a quick baseline. See src/upxo/demos/Twinned3D/repOFHCCu3d_ebsdVf.1.0.ipynb
for the complete EBSD-to-synthetic-microstructure pipeline in context, including
how the fitted texture feeds into host-grain orientation assignment (Workflow 13).
Part 4: Meshing and Export
Workflow 15 — Conformal Tetrahedral Meshing (3D)
Conformal (grain-boundary-aligned) tetrahedral meshing is a 5-stage functional
pipeline in upxo.meshing.confMesh3d — not a single mesher class. Each stage
is a plain function taking the previous stage’s result.
from upxo.meshing.confMesh3d import (
run_surface_nets, build_conformal_surface_complex,
validate_surface_complex, fix_winding,
generate_conformal_tet_mesh, export_conformal_mesh,
)
voxel_size = 1.0 # microns per voxel
# Stage 1 — Extract the multi-label grain-boundary surface (marching-cubes-like)
sn_result = run_surface_nets(lgi_3d, voxel_size)
# Stage 2 — Build the shared-vertex conformal surface complex
complex_ = build_conformal_surface_complex(sn_result)
# Stage 3 — Validate (watertight, volume, bounds) and fix triangle winding
report = validate_surface_complex(complex_, lgi_3d)
print(report)
complex_ = fix_winding(complex_)
# Stage 4 — Generate the conformal tet mesh via gmsh
# Must be called before gmsh.finalize()
gmsh_result = generate_conformal_tet_mesh(complex_)
# Stage 5 — Export to Abaqus .inp (+ optional meshio formats)
# all_quats: {grain_id: quaternion(4,)} — per-grain crystal orientation
export_conformal_mesh(gmsh_result, all_quats, voxel_size=voxel_size)
See src/upxo/demos/confMesh/confMesh3d1.ipynb (and the numbered notebooks
confMesh3d2.ipynb through confMesh3d12.ipynb, each covering a specific
aspect of the pipeline) for complete, runnable references.
Workflow 16 — Export FM Steel Structure to Abaqus
FM Steel mesh export uses MeshExporter3D (in upxo.pxtal.fm_steel_3d),
operating on the state object produced by the pipeline in Workflow 12
(fm_ori or fm_sub), via element-type-specific export_c3d8 /
export_c3d4 / export_c3d20 / export_c3d10 methods.
from upxo.pxtal.fm_steel_3d import MeshExporter3D
exporter = MeshExporter3D(verbosity=1)
# fm_state is the pipeline object from Workflow 12 (fm_ori or fm_sub)
exporter.export_c3d8(fm_state, folder_name='fm_steel_export')
Note
The exported 07_interactions.inp and 08_steps_output.inp files contain
only placeholder ** TODO content — real interaction (periodic BCs, contact)
and *Step/*Output definitions require simulation-specific choices this
exporter cannot infer, and calling this now emits a warnings.warn making that
explicit. Edit those two files before submitting the job.
Workflow 17 — Export Twinned FCC Structure to Abaqus
Twinned FCC export uses AbaqusExporter3D (in upxo.pxtal.twinned_simple_3d).
twin_role, twin_parent_of, and all_quats are all required — they are the
real outputs of the twin-generation pipeline in Workflow 13, not optional extras.
from upxo.pxtal.twinned_simple_3d.abaqus_exporter_3d import AbaqusExporter3D
# lgi, twin_role, twin_parent_of, all_quats come from the Workflow 13 pipeline
exporter = AbaqusExporter3D(
lgi=lgi_with_twins,
twin_role=twin_role, # {gid: 'host' | 'primary_twin' | 'secondary_twin' | 'non_host'}
twin_parent_of=twin_parent_of, # {child_gid: parent_gid}
all_quats=all_quats, # {gid: quaternion(4,)}
twinmake=twin_gen, # the TwinGenerator3D instance, for variant ELSETs
voxel_size_um=1.0,
)
exporter.write(out_dir='twinned_fcc_export')
Note
es_variant_ptwin_<v> ELSETs currently group primary-twin grains by a
round-robin placeholder, not each grain’s real Sigma-3 {111} variant (that
index is computed during generation but not yet persisted per grain) — do
not assign variant-specific material behaviour from this grouping. As with
Workflow 16, 07_interactions.inp/08_steps_output.inp are placeholders;
both limitations now emit a warnings.warn at export time.
See the notebooks listed in Workflow 13 for the complete pipeline through to export.
Part 5: Advanced Visualization
Workflow 18 — 3D Visualization with PyVista
UPXO provides ready-made PyVista grid helpers in upxo.viz.gsviz for
visualising a 3D labelled grain image — use these rather than building a
PyVista grid by hand.
from upxo.viz import gsviz
# Step 1 — Build a PyVista ImageData grid from the labelled grain image
pvgrid = gsviz.make_pvgrid(lgi_3d, scalar_name='lgi')
# Step 2 — Plot interactively
gsviz.plot_pvgrid(
pvgrid, scalar_name='lgi', show_edges=False,
cmap='nipy_spectral', title='3D grain structure',
)
# Step 3 — Export to VTK format for ParaView, using PyVista directly
pvgrid.save('microstructure.vti')
gsviz also provides grain_viewer(lfi) for a quick interactive viewer,
and view_selected_grain_boundary_voxels(lfi, grain_ids, ...) to highlight
specific grains’ boundary voxels.
Workflow 19 — Grain Boundary Visualization
Highlight and visualize grain boundaries using edge detection.
import numpy as np
import matplotlib.pyplot as plt
from scipy import ndimage
# Assume lgi_3d is a 3D labelled grain image
# Step 1 — Detect grain boundaries (edges where label changes)
gb_edges = np.zeros_like(lgi_3d, dtype=bool)
for i in range(lgi_3d.shape[0] - 1):
gb_edges[i, :, :] |= (lgi_3d[i, :, :] != lgi_3d[i + 1, :, :])
for j in range(lgi_3d.shape[1] - 1):
gb_edges[:, j, :] |= (lgi_3d[:, j, :] != lgi_3d[:, j + 1, :])
for k in range(lgi_3d.shape[2] - 1):
gb_edges[:, :, k] |= (lgi_3d[:, :, k] != lgi_3d[:, :, k + 1])
# Step 2 — Visualise slice with GB overlay
slice_idx = lgi_3d.shape[0] // 2
slice_data = lgi_3d[slice_idx, :, :]
slice_gb = gb_edges[slice_idx, :, :]
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))
# Show grain IDs
ax1.imshow(slice_data, cmap='tab20')
ax1.set_title('Grain IDs')
# Show grain boundaries
ax2.imshow(slice_data, cmap='gray', alpha=0.5)
ax2.imshow(slice_gb, cmap='Reds', alpha=0.7)
ax2.set_title('Grain Boundaries')
plt.tight_layout()
plt.show()
# Step 3 — Compute GB network statistics
num_gb_voxels = np.sum(gb_edges)
total_voxels = gb_edges.size
gb_fraction = num_gb_voxels / total_voxels
print(f"Grain boundary voxel fraction: {gb_fraction:.4f}")
Workflow 20 — Texture Visualization (Pole Figure)
Plot crystallographic pole figures directly from a 3D labelled grain image and
per-grain orientations, using the real plot_pole_figure_from_3d function
(in upxo.viz.xphy.pole_figure).
import matplotlib.pyplot as plt
from upxo.viz.xphy.pole_figure import plot_pole_figure_from_3d
# quats_dict: {grain_id: quaternion(4,)} — e.g. from the Workflow 12/13 pipelines
fig, ax = plot_pole_figure_from_3d(
lgi_3d, quats_dict,
axis=2, # slice normal: 0=X, 1=Y, 2=Z
pole_family='111', # or '100', '110', or an explicit (h, k, l) tuple
plot_type='density', # 'scatter' or 'density'
)
plt.show()
The same module’s PoleFigure class, plot_components(), and plot_variants()
support multi-pole-figure layouts and KS/Sigma3-variant-coloured pole figures — see
src/upxo/viz/xphy/pole_figure.py for the full function list, and
src/upxo/demos/Twinned3D/repOFHCCu3d_ebsdVf.1.0.ipynb for pole figures used
alongside real EBSD-fitted texture.
Next Steps
Key Concepts — understand the data model behind these examples
API Reference — full module and class documentation
Grain Characterisation wiki — extended characterisation workflows
Use Cases wiki — material-specific applications
Meshing wiki — detailed meshing documentation