"""
Temporal-slice grain-structure operations for MCGS outputs.
This module defines the core per-time-slice data model used after Monte-Carlo
grain-growth simulation and provides utilities for grain detection,
characterization, neighborhood analysis, species partitioning, and grain
merging workflows.
Imports
-------
from upxo.pxtal.mcgs2_temporal_slice import mcgs2_grain_structure
Primary Classes
---------------
* labeled_feature_image_2d
* mcgs2_grain_structure
Classes of Operations
---------------------
* Grid and state setup:
initialization of LFI/state containers, state-to-grain mappings, and
per-slice bookkeeping.
* Feature/grain detection:
connected-component based detection, relabeling, and feature extraction from
labelled images.
* Morphological characterization:
computation of grain/feature properties (area, axes, perimeter, moments,
compactness, aspect ratio, etc.).
* Neighborhood analysis:
O(1)/higher-order neighbor extraction, fast neighbor approximations, and
neighbor-driven querying.
* Boundary and topology utilities:
grain-boundary segment extraction, boundary/junction-point identification,
and graph-ready grain-pair construction.
* Species partitioning:
state-combination and per-instance feature decomposition workflows.
* Merge and threshold workflows:
property-threshold based merge selection, merge execution, and post-merge
renumbering/recount operations.
* Property access and filtering:
targeted property computation, retrieval, validation, and bounds-based
grain-ID selection.
Definitions
-----------
* LFI: Labelled Feature Image
* gid/fid: Grain/Feature ID (1-based in most public APIs)
* tslice: Monte-Carlo temporal slice
Metadata
--------
* Module: upxo.pxtal.mcgs2_temporal_slice
* Package: upxo
* Author: Dr. Sunil Anandatheertha
* Email: vaasu.anandatheertha@ukaea.uk
* Status: Active development
* Last updated: 2026-03-12
"""
import os
import math
import numpy as np
import random
import seaborn as sns
# from pathlib import Path
from copy import deepcopy
from typing import Iterable
import matplotlib.pyplot as plt
from collections import defaultdict
from numba import njit, prange
# from skimage.measure import label as skimg_label
import pandas as pd
# from skimage.measure import label as skim_label
# from upxo.geoEntities.point2d import point2d
# from upxo.meshing.mesher_2d import mesh_mcgs2d
from upxo.dclasses.features import twingen
import upxo.gsdataops.gid_ops as GidOps
import upxo.charops.mchar as mcharOps
import upxo.gbops.mcgb2dops as gbOps
import upxo.viz.gbviz as gbViz
import upxo.gsdataops.grid_ops as gridOps
import upxo.connops.neighbour_ops as neighOps
import upxo.jpops.jpops as jpOps
from upxo._sup.gops import att
from upxo._sup.data_ops import find_intersection, find_union_with_counts
from upxo._sup.data_ops import increase_grid_resolution, decrease_grid_resolution
from upxo._sup import dataTypeHandlers as dth
from upxo._sup.validation_values import _validation
from upxo._sup.data_templates import dict_templates
from upxo._sup.console_formats import print_incrementally
from upxo.geoEntities.sline2d import Sline2d as sl2d
from upxo.geoEntities.mulpoint2d import MPoint2d as mulpoint2d
from upxo.pxtalops import manipulator_mergers as manm
import upxo._sup.decorators as decorators
@njit(parallel=True)
def get_neighbor_mask(arr, gid):
"""
Mark pixels adjacent to a given grain ID.
Parameters
----------
arr : numpy.ndarray
Integer array that is updated in place.
gid : int
Grain ID to trace.
Returns
-------
numpy.ndarray
The updated array, with non-``gid`` 4-neighbours marked as ``-1`` and
all other non-marked values cleared to ``0``.
"""
rows, cols = arr.shape
# Create a boolean mask instead of copying the integer array
# This saves memory and is faster to initialize
# mask = np.zeros((rows, cols), dtype=np.bool_)
# prange allows parallel execution of the outer loop
for row in prange(rows):
for col in range(cols):
# We only care if the current pixel is the Grain ID we are tracking
if arr[row, col] == gid:
# Check Up
if row - 1 >= 0:
if arr[row-1, col] != gid:
arr[row-1, col] = -1
# Check Down
if row + 1 < rows:
if arr[row+1, col] != gid:
arr[row+1, col] = -1
# Check Left
if col - 1 >= 0:
if arr[row, col-1] != gid:
arr[row, col-1] = -1
# Check Right
if col + 1 < cols:
if arr[row, col+1] != gid:
arr[row, col+1] = -1
if arr[row, col] != -1:
arr[row, col] = 0
return arr
[docs]
class labeled_feature_image_2d():
__slots__ = ('lfi', 'ftypes', 'nfeatures', 'props_df',)
def __init__(self, lfi=None, ftypes=None, nfeatures=None, props_df=None):
"""Initialise a labelled-feature image container."""
self.lfi = lfi
self.ftypes = ftypes
self.nfeatures = nfeatures
self.props_df = props_df
[docs]
class mcgs2_grain_structure():
__slots__ = ('dim', 'uigrid', 'uimesh', 'xgr', 'ygr', 'zgr', 'm', 's', 'S',
'fcores', 'fbz', 'pixConn',
'binaryStructure2D', 'binaryStructure3D', 'n', 'lgi', 'species',
'spart_flag', 'gid', 's_gid', 'gid_s', 's_n', 'g', 'gb',
'positions', 'mp', 'vtgs', 'mesh', 'px_size', 'dim',
'prop_flag', 'prop', 'are_properties_available', 'prop_stat',
'__gi__', 'uinputs', 'display_messages', 'info',
'print_interval_bool', 'EAPGLB', 'EASGLB',
'__ori_assign_status_stack__', '__ori_assign_status_slice__',
'scaled', 'scaled_gs', '__resolution_state__', 'gbjp',
'xomap', 'val', 'neigh_gid', 'valid_mprops', 'features',
'twingen', 'pxtal', '_gid_bf_merger_', '_char_fx_version_',
'_global_iteration_counter_'
)
"""
Slot variables description
--------------------------
dim: Dimension of the grain structure. int
uigrid: User input grid. np.ndarray
uimesh: User input mesh. dataclass
xgr, ygr: Grid vectors in x and y directions. np.ndarray
m: Material properties. dict
s: State map. np.ndarray
S: Total number of states. int
binaryStructure2D: 2D binary structure for ndimage labelling. np.ndarray
binaryStructure3D: 3D binary structure for ndimage labelling. np.ndarray
n: Total number of grains. int
lgi: Labelled grain image. np.ndarray
species: Species data structure. dict
spart_flag: State partitioning flag. dict
gid: List of grain IDs. list
s_gid: Dict of state to grain IDs. dict
gid_s: List of state for each grain ID. list
s_n: List of number of grains in each state. list
g: Grain data structure. dict
gb: Grain boundary data structure. dict
positions: Positions of grains. dict
mp: Multipoint data structure. dict
vtgs: Voronoi tessellation grain structure. dataclass
mesh: Mesh data structure. dataclass
px_size: Size of pixel. float
dim: Dimension of the grain structure. int
prop_flag: Property calculation flag. dict
prop: Property data structure. dict
are_properties_available: Flag indicating if properties are available. bool
prop_stat: Property statistics data structure. dict
__gi__: Grain iterator index. int
uinputs: User inputs data structure. dataclass
display_messages: Flag for displaying messages. bool
info: Information data structure. dict
print_interval_bool: Flag for print interval. bool
EAPGLB: Global equivalent axis-angle orientation data structure. dict
EASGLB: Global equivalent axis-angle orientation data structure. dict
__ori_assign_status_stack__: Orientation assignment status for stack. dict
__ori_assign_status_slice__: Orientation assignment status for slice. dict
scaled: Scaled data structure. dict
scaled_gs: Scaled grain structure. dataclass
__resolution_state__: Resolution state data structure. dict
gbjp: Grain boundary junction points data structure. dict
xomap: Orientation map. np.ndarray
val: Validation data structure. dataclass
neigh_gid: Neighbour grain IDs data structure. dict
valid_mprops: Valid material properties flags. dict
features: Features data structure. dataclass
twingen: Twin generation data structure. dataclass
pxtal: Parent crystal data structure. dataclass
_gid_bf_merger_: Grain ID before merger data structure. dict=
"""
EPS = 1e-12
grain_coords_dtype = np.float16
__maxGridSizeToIgnoreStoringGrids = 1000**2
valid_skprop_names = ['area', 'area_bbox', 'area_convex', 'area_filled',
'axis_major_length', 'axis_minor_length', 'bbox',
'centroid', 'centroid_local', 'centroid_weighted', 'centroid_weighted_local',
'coords', 'coords_scaled', 'eccentricity', 'equivalent_diameter_area',
'euler_number', 'extent', 'feret_diameter_max', 'image', 'image_convex',
'image_filled', 'image_intensity', 'inertia_tensor', 'inertia_tensor_eigvals',
'intensity_max', 'intensity_mean', 'intensity_min', 'intensity_std',
'label', 'moments', 'moments_central', 'moments_hu', 'moments_normalized',
'moments_weighted', 'moments_weighted_central', 'moments_weighted_hu',
'moments_weighted_normalized', 'num_pixels', 'orientation', 'perimeter',
'perimeter_crofton', 'slice', 'solidity']
valid_morpho_props = ['area', 'area_bbox', 'area_convex', 'area_filled',
'axis_major_length', 'axis_minor_length', 'bbox',
'centroid', 'centroid_local', 'centroid_weighted', 'centroid_weighted_local',
'coords', 'coords_scaled', 'eccentricity', 'equivalent_diameter_area',
'euler_number', 'extent', 'feret_diameter_max', 'image', 'image_convex',
'image_filled', 'image_intensity', 'inertia_tensor', 'inertia_tensor_eigvals',
'intensity_max', 'intensity_mean', 'intensity_min', 'intensity_std',
'label', 'moments', 'moments_central', 'moments_hu', 'moments_normalized',
'moments_weighted', 'moments_weighted_central', 'moments_weighted_hu',
'moments_weighted_normalized', 'num_pixels', 'orientation', 'perimeter',
'perimeter_crofton', 'slice', 'solidity']
# Global Topological Invariants
# euler_characteristic: Chi = V - E + G (usually 1)
# avg_nneigh: Average number of neighbors (\bar{n})
# Grain-Specific Properties (Per Grain)
# nneigh: Number of neighbors (or edges, n) for a grain
# Junction/Vertex Counts (Global)
# n_vertices: Total number of vertices (V)
# n_boundaries: Total number of grain boundaries (E)
# ntjp: Number of triple junctions (V3)
# nqp: Number of quad junctions (V4, relevant during T1 events)
# Statistical & Correlation Properties
# nneigh_dist_P_n: The probability distribution P(n)
# aboav_weaire_params: Parameters describing the Aboav-Weaire correlation.
# They quantify the topological correlation between a grain's size and the
# average size of its neighbors.
valid_topo_props = ['euler_characteristic', 'avg_nneigh', 'nneigh',
'n_vertices', 'n_boundaries', 'ntjp', 'nqp',
'nneigh_dist_P_n', 'aboav_weaire_params']
def __init__(self, dim=2, m=None, uidata=None, S_total=None, px_size=None,
xgr=None, ygr=None, zgr=None, uigrid=None, uimesh=None,
EAPGLB=None, assign_ori_stack=False, assign_ori_slice=True,
oripert_tc=True, oripert_gr=True):
"""
Initialise a 2D temporal-slice grain-structure container.
Parameters
----------
dim : int, optional
Spatial dimension of the structure. The default is 2.
m : int, optional
Temporal slice index.
uidata : object, optional
User input data bundle.
S_total : int, optional
Total number of states.
px_size : float, optional
Pixel size.
xgr, ygr, zgr : numpy.ndarray, optional
Grid coordinates.
uigrid, uimesh : object, optional
Grid and mesh metadata.
EAPGLB : object, optional
Global orientation data.
assign_ori_stack : bool, optional
Flag for stack-level orientation assignment bookkeeping.
assign_ori_slice : bool, optional
Flag for slice-level orientation assignment bookkeeping.
oripert_tc, oripert_gr : bool, optional
Orientation-perturbation flags.
"""
self.uinputs = uidata
self.val = _validation()
self.dim, self.m, self.S, self.px_size = 2, m, S_total, px_size
self.uigrid, self.uimesh = uigrid, uimesh
self.xgr, self.ygr = xgr, ygr
self.set__spart_flag(S_total)
self.set__s_gid(S_total)
self.set__gid_s()
self.set__s_n(S_total)
self.g, self.gb, self.info = {}, {}, {}
self.EAPGLB = {}
self.EAPGLB['statewise'] = EAPGLB
self.EASGLB = self.EAPGLB
# Above EASGLB needs to be updated in the orinetation mapping stage
self.mp = dict_templates.mulpnt_gs2d
self.scaled = {'xmin': None, 'xmax': None, 'xinc': None, 'xgr': None,
'ymin': None, 'ymax': None, 'yinc': None, 'ygr': None,
's': None, 'grains': None, 'prop': None}
self.scaled_gs = None
self.are_properties_available, self.display_messages = False, False
self.__setup__positions__()
self.xomap = None
self.neigh_gid = None
self.species = {}
if assign_ori_stack:
self.__ori_assign_status_stack__ = {'status': False,
'info': 'to be developed'}
if assign_ori_slice:
__info = "..-t:u-s:u-..-ru-..-d:c-..-ea:s-.."
self.__ori_assign_status_slice__ = {'status': True,
'info': __info}
self.valid_mprops = {'npixels': False, 'npixels_gb': False, 'area': True,
'eq_diameter': False, 'perimeter': False,
'perimeter_crofton': False, 'compactness': False,
'gb_length_px': False, 'aspect_ratio': False,
'solidity': False, 'morph_ori': False, 'circularity': False,
'eccentricity': False, 'feret_diameter': False,
'major_axis_length': False, 'minor_axis_length': False,
'euler_number': False, 'char_grain_positions': False}
self.pxtal = {}
def __iter__(self):
"""Return an iterator over the grain objects in this structure."""
# Initialize grain iterator
self.__gi__ = 1
return self
def __next1__(self):
"""Return the next grain-pixel coordinate set for iteration helpers."""
# Iterator to get pixel indices of grains one after the other
if self.n:
if self.__gi__ <= self.n:
grain_pixel_indices = np.argwhere(self.lgi == self.__gi__)
self.__gi__ += 1
return grain_pixel_indices
else:
raise StopIteration
def __next__(self):
"""Return the next grain object from this iterator."""
# Iterator to get grain objects one after the other
if self.n:
if self.__gi__ <= self.n:
if self._char_fx_version_ == 1:
thisgrain = self.g[self.__gi__]['grain']
elif self._char_fx_version_ == 2:
thisgrain = self.g[self.__gi__]
self.__gi__ += 1
return thisgrain
else:
raise StopIteration
def __str__(self):
"""Return a short string describing this grain-structure object."""
# String representation of the object
return 'grains :: att : n, lgi, id, ind, spart'
def __att__(self):
"""Return a formatted listing of attributes for this instance."""
return att(self)
[docs]
def set_grain_coords_dtype(self, dtype):
"""Set the dtype used to store grain coordinates."""
self.grain_coords_dtype = dtype
[docs]
@classmethod
def from_image(cls, fdb=np.random.random((50, 50)),
xmin=0, ymin=0, xinc=1, yinc=1, xmax=50, ymax=50,):
"""
Create a structure instance from a 2D image and grid bounds.
Parameters
----------
fdb : numpy.ndarray, optional
Image data used to build the grid.
xmin, ymin, xinc, yinc, xmax, ymax : int or float, optional
Grid bounds and increments.
Returns
-------
mcgs2_grain_structure
A new instance initialised from the supplied image data.
"""
uigrid = {'fdb': fdb,
'xmin': xmin, 'ymin': ymin,
'xinc': xinc, 'yinc': yinc,
'xmax': xmax, 'ymax': ymax,
'xgr': np.arange(xmin, xmax, xinc),
'ygr': np.arange(ymin, ymax, yinc)}
return cls(uigrid)
@property
def get_px_size(self):
"""Return the configured pixel size."""
return self.px_size
[docs]
def set__s_n(self, S_total,):
"""Initialise the per-state grain-count list."""
self.s_n = [0 for s in range(1, S_total+1)]
[docs]
def set__s_gid(self, S_total):
"""Initialise the state-to-grain-ID mapping dictionary."""
self.s_gid = {s: None for s in range(1, S_total+1)}
[docs]
def set__gid_s(self):
"""Reset the grain-to-state mapping list."""
self.gid_s = []
[docs]
def set__spart_flag(self, S_total):
"""Initialise per-state partitioning flags to ``False``."""
self.spart_flag = {_s_: False for _s_ in range(1, S_total+1)}
def _check_lgi_dtype_uint8(self, lgi):
"""Validate ``lgi`` and coerce it to ``uint8`` when possible."""
if type(lgi) == np.ndarray and np.size(lgi) > 0 and np.ndim(lgi) == 2:
if self.lgi.dtype.name != 'uint8':
self.lgi = lgi.astype(np.uint8)
else:
self.lgi = lgi
else:
self.lgi = 'invalid mcgs 4685'
@property
def lfi(self):
"""Return the labelled feature image alias used by this class."""
return self.lgi
[docs]
def calc_num_grains(self, throw=False):
"""
Compute the number of grains from the current labelled image.
Parameters
----------
throw : bool, optional
If True, return the computed grain count.
Returns
-------
int or None
The grain count when ``throw`` is True, otherwise ``None``.
"""
if self.lgi.size > 0:
self.n = self.lgi.max()
if throw:
return self.n
[docs]
def get_property_bounded_grains(self, pnames=None, mprops=None, pvalue_thresholds=None):
"""
Return grain IDs whose property values lie outside the supplied bounds.
Parameters
----------
pnames : list
Property names to evaluate.
mprops : dict
Mapping of property names to per-grain value arrays.
pvalue_thresholds : dict
Mapping of property names to ``[lower_threshold, upper_threshold]``.
Returns
-------
dict
Grain IDs selected for each requested property.
Examples
--------
.. code-block:: python
pnames=['area', 'aspect_ratio', 'perimeter', 'solidity']
mprops = gsan.gsstack[gsid].get_mprops(pnames, set_missing_mprop=True)
pvalue_thresholds = {'area': [10, None], 'aspect_ratio': [2.0, None],
'perimeter': [15, None], 'solidity': [0.8, None]}
props_gids = self.get_propery_bounded_grains(mprops, pnames, pvalue_thresholds)
"""
props_gids = {pname: None for pname in pnames}
for pname in pnames:
LTh, UTh = pvalue_thresholds[pname]
gids_subset_lth = np.where(mprops[pname] <= LTh)[0]+1 # Get grain IDs of low value grains
gids_subset_uth = np.where(mprops[pname] >= UTh)[0]+1 if UTh else np.array([]) # Get grain IDs of high value grains
gids_subset = [int(i) for i in np.concatenate((gids_subset_lth, gids_subset_uth))]
props_gids[pname] = gids_subset
return props_gids
[docs]
def find_neigh(self, include_central_grain=False, print_msg=True,
user_defined_bbox_ex_bounds=False, bbox_ex_bounds=None,
update_grain_object=True, use_numba=False):
"""
Populate ``self.neigh_gid`` with neighbour IDs for every grain.
Parameters
----------
include_central_grain : bool, optional
Include the central grain in each neighbour list.
print_msg : bool, optional
Print progress information while computing neighbours.
user_defined_bbox_ex_bounds : bool, optional
Use caller-supplied extended bounding boxes.
bbox_ex_bounds : dict, optional
Extended bounding-box limits keyed by grain ID.
update_grain_object : bool, optional
Update grain objects with neighbour information.
use_numba : bool, optional
Use the numba-accelerated path when available.
Returns
-------
None
Examples
--------
.. code-block:: python
from upxo.ggrowth.mcgs import mcgs
pxtal = mcgs(study='independent', input_dashboard='input_dashboard_for_testing_50x50_alg202.xls')
pxtal.simulate()
pxtal.detect_grains()
tslice = 10
pxtal.gs[tslice].char_morph_2d(char_gb=True)
pxtal.gs[tslice].find_neigh(include_central_grain=True)
pxtal.gs[tslice].neigh_gid[10]
pxtal.gs[tslice].find_neigh(include_central_grain=False)
pxtal.gs[tslice].neigh_gid[10]
"""
self.neigh_gid = {}
if self.gid.size == 1:
self.neigh_gid[self.gid[0]] = [int(self.gid[0])]
return
# ---------------------------------------------------------------------
if print_msg:
print('\nExtracting neigh list for all grains\n')
for gid in self.gid:
bbox_ex_bounds_fid = bbox_ex_bounds[gid] if user_defined_bbox_ex_bounds else None
self.neigh_gid[gid] = [int(ngid) for ngid in self.find_neigh_gid(gid,
include_central_grain=include_central_grain,
update_grain_object=update_grain_object,
throw=True,
user_defined_bbox_ex_bounds=user_defined_bbox_ex_bounds,
bbox_ex_bounds_fid=bbox_ex_bounds_fid,
use_numba=use_numba)]
[docs]
def find_neigh_v2(self, p=1.0, include_central_grain=False,
throw_numba_dict=False, verbosity_nfids=1000):
"""
Populate ``self.neigh_gid`` using the O(1) neighbour finder.
Parameters
----------
p : float, optional
Neighbourhood expansion parameter passed to the backend helper.
include_central_grain : bool, optional
Include the central grain in each neighbour list.
throw_numba_dict : bool, optional
Return the raw numba dictionary when supported.
verbosity_nfids : int, optional
Progress reporting frequency used by the backend helper.
Returns
-------
None
"""
_lgi_ = deepcopy(self.lgi)
_lgi_ = _lgi_.astype(np.int32)
self.neigh_gid = GidOps.find_O1_neigh_2d(_lgi_, p=p,
include_central_grain=include_central_grain,
throw_numba_dict=throw_numba_dict,
validate_input=False,
verbosity_nfids=verbosity_nfids)
[docs]
@decorators.port_doc('upxo.connops.neighbour_ops', 'find_neigh_fid')
def find_neigh_gid(self, fid, include_central_grain=False,
update_grain_object=True,
user_defined_bbox_ex_bounds=False,
bbox_ex_bounds_fid=None, use_numba=False,
get_gbsegs=False, save_gbsegs=False, throw_gbsegs=False,
throw=False):
"""
Backward-compatible wrapper for ``neighOps.find_neigh_fid``.
Parameters
----------
fid : int
Feature or grain ID to query.
include_central_grain : bool, optional
Include the queried grain in the returned neighbour list.
update_grain_object : bool, optional
Update the underlying grain object.
user_defined_bbox_ex_bounds : bool, optional
Use a caller-supplied extended bounding box.
bbox_ex_bounds_fid : object, optional
Extended bounding-box limits for the queried feature.
use_numba : bool, optional
Use the numba-accelerated implementation when available.
get_gbsegs, save_gbsegs, throw_gbsegs, throw : bool, optional
Control grain-boundary segment handling and return behaviour.
Returns
-------
list or None
Neighbour IDs when ``throw`` is True, otherwise ``None``.
"""
if len(self.g) == 0:
print('\n', 25*'-')
print('NOTE: ', '\t', "This gs tslice has'nt been characterised. Charecterising to proceed.")
self.char_morph_2d(char_gb=False)
print('\n', 25*'-')
neighbour_ids = neighOps.find_neigh_fid(self.g, self.lfi, fid, self.n,
include_central_grain=include_central_grain,
update_grain_object=update_grain_object,
user_defined_bbox_ex_bounds=user_defined_bbox_ex_bounds,
bbox_ex_bounds_fid=bbox_ex_bounds_fid,
use_numba=use_numba, _char_fx_version_=self._char_fx_version_,
get_gbsegs=get_gbsegs, save_gbsegs=save_gbsegs,
throw=throw, throw_gbsegs=throw_gbsegs)
if throw:
return neighbour_ids
[docs]
@decorators.port_doc('upxo.connops.neighbour_ops', 'find_neigh_fid')
def find_neigh_fid(self, fid, include_central_grain=False,
update_grain_object=True,
user_defined_bbox_ex_bounds=False,
bbox_ex_bounds_fid=None, use_numba=False,
get_gbsegs=False, save_gbsegs=False, throw_gbsegs=False,
throw=False):
"""
Delegate neighbour lookup to ``neighOps.find_neigh_fid``.
Parameters
----------
fid : int
Feature or grain ID to query.
include_central_grain : bool, optional
Include the queried grain in the returned neighbour list.
update_grain_object : bool, optional
Update the underlying grain object.
user_defined_bbox_ex_bounds : bool, optional
Use a caller-supplied extended bounding box.
bbox_ex_bounds_fid : object, optional
Extended bounding-box limits for the queried feature.
use_numba : bool, optional
Use the numba-accelerated implementation when available.
get_gbsegs, save_gbsegs, throw_gbsegs, throw : bool, optional
Control grain-boundary segment handling and return behaviour.
Returns
-------
list or None
Neighbour IDs when ``throw`` is True, otherwise ``None``.
"""
neighbour_ids = neighOps.find_neigh_fid(self.g, self.lfi, fid, self.n,
include_central_grain=include_central_grain,
update_grain_object=update_grain_object,
user_defined_bbox_ex_bounds=user_defined_bbox_ex_bounds,
bbox_ex_bounds_fid=bbox_ex_bounds_fid,
use_numba=use_numba, _char_fx_version_=self._char_fx_version_,
get_gbsegs=get_gbsegs, save_gbsegs=save_gbsegs,
throw=throw, throw_gbsegs=throw_gbsegs)
if throw:
return neighbour_ids
@property
def neigh_fid(self):
"""Return the neighbour-ID dictionary used for backward compatibility."""
return self.neigh_gid
[docs]
@decorators.port_doc('upxo.pxtalops.grid_ops', 'find_feature_extended_bbox_pix')
def find_extended_bounding_box(self, fid, make_binary=False):
"""
Return the extended bounding box for a single feature ID.
This delegates to :func:`upxo.pxtalops.grid_ops.find_feature_extended_bbox_pix`.
Returns
-------
numpy.ndarray
Extended bounding-box mask for the requested feature.
"""
grain_lfi_ExtBBox = gridOps.find_feature_extended_bbox_pix(fid=fid, lfi=self.lgi,
make_binary=make_binary)
return grain_lfi_ExtBBox
[docs]
@decorators.port_doc('upxo.pxtalops.grid_ops', 'find_extended_bbox_pix_fids')
def find_extended_bounding_box_all_grains(self, make_binary=False):
"""
Return extended bounding boxes for all grains.
Returns
-------
numpy.ndarray
Extended bounding-box masks for every grain ID.
"""
print('Finding extended bounding boxes for all grains...')
grain_lgi_ex_all = gridOps.find_extended_bbox_pix_fids(fids=self.gid, lfi=self.lgi,
make_binary=make_binary)
return grain_lgi_ex_all
[docs]
@decorators.port_doc('upxo.pxtalops.grid_ops', 'find_extended_bbox_pix_fids')
def find_extended_bounding_box_fids(self, fids=None, make_binary=False):
"""
Return extended bounding boxes for the requested grain IDs.
Returns
-------
numpy.ndarray
Extended bounding-box masks for the requested grain IDs.
"""
print('Finding extended bounding boxes for specified grains...')
grain_lgi_ex_fids = gridOps.find_extended_bbox_pix_fids(fids=self.gid if fids is None else fids, lfi=self.lgi,
make_binary=make_binary)
return grain_lgi_ex_fids
[docs]
def assign_species(self, method='mc state partitioned global combined',
ignore_vf=True, vf={}, spid=1, combineids=[], ninstances=10,
detect_features=True, bso=1, characterise_features=True,
make_feature_skprops=True, extract_feature_coords=True,
throw_feature_bounding_box=True
):
"""
Assign species based on state partitioning methods.
Parameters
----------
method : str, optional
Method for state partitioning. Default is 'mc state partitioned global combined'.
ignore_vf : bool, optional
Whether to ignore volume fractions. Default is True.
vf : dict, optional
Volume fractions for each state. Default is an empty dict.
spid : int, optional
Species ID. Default is 1.
combineids : list of list of int, optional
List of state combinations for partitioning. Default is an empty list.
ninstances : int, optional
Number of instances to create. Default is 10.
detect_features : bool, optional
Whether to detect features in the image. Default is True.
bso : int, optional
Binary structure order for feature detection. Default is 1.
characterise_features : bool, optional
Whether to characterise detected features. Default is True.
make_feature_skprops : bool, optional
Whether to create scikit-image regionprops for features. Default is True.
extract_feature_coords : bool, optional
Whether to extract feature coordinates. Default is True.
throw_feature_bounding_box : bool, optional
Whether to throw bounding box for features. Default is True.
Returns
-------
None
Examples
--------
.. code-block:: python
from upxo.ggrowth.mcgs import mcgs
pxtal = mcgs(study='independent',
input_dashboard='input_dashboard_for_testing_50x50_alg202.xls')
pxtal.simulate()
pxtal.detect_grains()
tslice = 10
pxtal.gs[tslice].assign_species(method='mc state partitioned global combined',
ignore_vf=True, vf={}, spid=1,
combineids=[[1,2],[3,4]], ninstances=5,
detect_features=True, bso=1, characterise_features=True,
make_feature_skprops=True, extract_feature_coords=True,
throw_feature_bounding_box=True)
"""
if type(vf) != dict:
raise ValueError('Invalid vf type.')
# ---------------------------------------------
if method in ('mc state partitioned global',
'mc state partitioned global vf',):
S, S_ = self.S, 1/self.S
if len(vf) == 0:
vf = {s_: S_ for s_ in range(self.S)}
N = {} # To be developed
elif sum(vf.keys()) < 1.0:
raise ValueError('sum(Vf) is not unity.')
# ---------------------------------------------
if method in ('mc state partitioned local',
'mc state partitioned local vf',):
s = np.unique(self.s, dtype=np.int16)
if len(vf) == 0:
vf = {}
for s_ in self.S:
if s_ in s:
vf[s_] = 1/len(s)
else:
vf[s_] = 0.0
if len(set([k in s for k in vf.keys()])) != 1:
raise ValueError('Not all keys of vf are in S. Try local instead.')
N = {s: None} # To be developed
# ---------------------------------------------
if method == 'mc state partitioned global' and len(vf) == 0:
self.species[spid] = deepcopy(self.s)
# ---------------------------------------------
fx1 = mcharOps.characterise_features_in_image_2d
Xgrid, Ygrid = self.xgr, self.ygr
# ---------------------------------------------
if ignore_vf:
if method == 'mc state partitioned global combined':
if type(combineids) not in dth.dt.ITERABLES:
raise ValueError('combine should be an iterable of state values.')
if not all(isinstance(sublist, (list, tuple)) for sublist in combineids):
raise ValueError('combine must be an iterable of iterables (list of lists).')
if ninstances >= 1:
self.species[spid] = {}
for i in range(ninstances):
species = gridOps.combine_partitions(deepcopy(self.s), combineids)
self.species[spid]['inst_'+str(i+1)] = deepcopy(species)
if detect_features:
features = gridOps.detect_features_in_image_MCstateWise_2d(deepcopy(species),
binary_structure_order=bso)
# instance number: i+1: image
name = 'inst_'+str(i+1)+'_img'
self.species[spid][name] = features[0]
# instance number: i+1: image mappings - original labels to feature ID labels
name = 'inst_'+str(i+1)+'_orig_to_labels'
self.species[spid][name] = {int(k): tuple(v) for k, v in features[1].items()}
# instance number: i+1: image mappings - feature ID labels to original labels
name = 'inst_'+str(i+1)+'_labels_to_orig'
self.species[spid][name] = {k: int(v) for k, v in features[2].items()}
if characterise_features:
skprops, bbox_limits_ex, bboxes_ex, coords_dict = fx1(features[0], Xgrid, Ygrid,
make_skprops=make_feature_skprops,
extract_coords=extract_feature_coords,
throw_bounding_box=throw_feature_bounding_box)
# instance number: i+1: Scikit-image regionprops of features like grains
name = 'inst_'+str(i+1)+'_feature_skprops'
self.species[spid][name] = {int(k): v for k, v in skprops.items()}
# instance number: i+1: Feature bounding box limits (extended) of gfeatures
name = 'inst_'+str(i+1)+'_feature_bbox_limits'
self.species[spid][name] = bbox_limits_ex
# instance number: i+1: Feature bounding box limits (extended) of gfeatures
name = 'inst_'+str(i+1)+'_feature_bboxes_ex'
self.species[spid][name] = bboxes_ex
# instance number: i+1: Feature co-ordinates
name = 'inst_'+str(i+1)+'_feature_coords'
self.species[spid][name] = coords_dict
else:
# To be developed
pass
# ---------------------------------------------
if method == 'mc state partitioned global vf':
phases = deepcopy(self.s)
N = {s: None} # To be developed
# ---------------------------------------------
if method == 'mc state partitioned local':
pass
# ---------------------------------------------
if method == 'mc state partitioned local vf':
pass
# ---------------------------------------------
if method == 'ng vf':
pass
[docs]
def find_neigh_gid_fast(self, gid, include_parent=False, return_type='tuple'):
"""
Find neighbouring grains of a given gid.
Parameters
----------
gid : int
Grain ID for which to find neighbours.
include_parent : bool, optional
Whether to include the parent gid in the neighbours list. Default is False.
return_type : str, optional
Type of return value: 'tuple' or 'list'. Default is 'tuple'.
Returns
-------
tuple or list
Neighbouring grain IDs.
Examples
--------
.. code-block:: python
from upxo.ggrowth.mcgs import mcgs
pxtal = mcgs(study='independent',
input_dashboard='input_dashboard_for_testing_50x50_alg202.xls')
pxtal.simulate()
pxtal.detect_grains()
np.unique(pxtal.gs[16].find_extended_bounding_box(10))
pxtal.gs[10].find_neigh_gid_fast(10)
"""
neighbours = list(np.unique(self.find_extended_bounding_box(gid)))
if not include_parent:
neighbours.remove(gid)
return tuple(neighbours)
[docs]
def find_neigh_gid_fast_all_grains(self, include_parent=False,
saa=True, throw=False):
"""
Find neighbouring grains for all gids.
Parameters
----------
include_parent : bool, optional
Whether to include the parent gid in the neighbours list. Default is False.
saa : bool, optional
Whether to store the result as an attribute. Default is True.
throw : bool, optional
Whether to return the result. Default is False.
Returns
-------
dict
Dictionary with grain IDs as keys and their neighbouring grain IDs as values.
Examples
--------
.. code-block:: python
from upxo.ggrowth.mcgs import mcgs
pxtal = mcgs(study='independent',
input_dashboard='input_dashboard_for_testing_50x50_alg202.xls')
pxtal.simulate()
pxtal.detect_grains()
np.unique(pxtal.gs[16].find_extended_bounding_box(10))
pxtal.gs[10].find_neigh_gid_fast_all_grains(include_parent=False)
pxtal.gs[10].neigh_gid
"""
neigh_gid = {gid: self.find_neigh_gid_fast(gid, include_parent=include_parent)
for gid in self.gid}
for gid, neighs in neigh_gid.items():
neigh_gid[gid] = [int(gid) for gid in neighs]
if saa:
self.neigh_gid = neigh_gid
if throw:
return neigh_gid
[docs]
def get_upto_nth_order_neighbors(self, grain_id, neigh_order,
fast_estimate=False,
recalculate=False, include_parent=True,
output_type='list'):
"""
Calculates the nth order neighbours for a given gid.
Parameters
----------
grain_id : int
The ID of the grain for which to find neighbors.
neigh_order : int
The order of neighbors to calculate (1st order, 2nd order, etc.).
fast_estimate : bool, optional
Whether to use a fast estimation method. Default is False.
recalculate : bool, optional
Whether to recalculate neighbors even if they are already computed. Default is False.
include_parent : bool, optional
Whether to include the parent grain ID in the neighbors list. Default is True.
output_type : str, optional
The type of output: 'list', 'nparray', or 'set'. Default is 'list'.
Returns
-------
list, np.ndarray, or set
Neighbors of the specified order.
Examples
--------
.. code-block:: python
from upxo.ggrowth.mcgs import mcgs
# pxtal = mcgs(study='independent', input_dashboard='input_dashboard_for_testing_50x50_alg202.xls')
pxtal = mcgs(study='independent', input_dashboard='input_dashboard.xls')
pxtal.simulate()
pxtal.detect_grains()
tslice = 18
pxtal.gs[tslice].char_morph_2d(char_gb=True)
neigh_order = 3
gid = 6
neighbours = pxtal.gs[tslice].get_upto_nth_order_neighbors(gid, neigh_order,
fast_estimate=False,
recalculate=True,
include_parent=True,
output_type='list')
pxtal.gs[tslice].neigh_gid[gid]
pxtal.gs[tslice].plot_grains_gids(pxtal.gs[tslice].gid, gclr='color', title='')
pxtal.gs[tslice].plot_grains_gids([gid], gclr='color', title='parent gid: '+str(gid))
pxtal.gs[tslice].plot_grains_gids(neighbours, gclr='color', cmap_name='nipy_spectral')
"""
if recalculate or not self.neigh_gid:
if fast_estimate:
self.find_neigh_gid_fast_all_grains(include_parent=include_parent)
else:
self.find_neigh(include_central_grain=include_parent)
return neighOps.get_upto_nth_order_neighbors(
self.neigh_gid, grain_id, neigh_order,
include_parent=include_parent, output_type=output_type)
[docs]
def get_nth_order_neighbors(self, grain_id, neigh_order, fast_estimate=False,
recalculate=False, include_parent=True):
"""
Calculates the 1st till nth order neighbours for a given gid.
Parameters
----------
grain_id : int
The ID of the grain for which to find neighbors.
neigh_order : int
The order of neighbors to calculate (1st order, 2nd order, etc.).
fast_estimate : bool, optional
Whether to use a fast estimation method. Default is False.
recalculate : bool, optional
Whether to recalculate neighbors even if they are already computed. Default is False.
include_parent : bool, optional
Whether to include the parent grain ID in the neighbors list. Default is True.
Returns
-------
list
A list containing the nth order neighbors.
Examples
--------
from upxo.ggrowth.mcgs import mcgs
pxtal = mcgs(study='independent', input_dashboard='input_dashboard.xls')
pxtal.simulate()
pxtal.detect_grains()
gid = 10
tslice = 16
neigh_order = 6
neighbours = pxtal.gs[tslice].get_nth_order_neighbors(gid, neigh_order,
fast_estimate=False,
recalculate=True,
include_parent=True)
pxtal.gs[tslice].plot_grains_gids(neighbours, gclr='color')
"""
if recalculate or not self.neigh_gid:
if fast_estimate:
self.find_neigh_gid_fast_all_grains(include_parent=include_parent)
else:
self.find_neigh(include_central_grain=include_parent)
return neighOps.get_nth_order_neighbors(
self.neigh_gid, grain_id, neigh_order, include_parent=include_parent)
[docs]
def get_upto_nth_order_neighbors_all_grains(self, neigh_order,
recalculate=False, fast_estimate=False,
include_parent=True, output_type='list'):
"""
Calculates 1st to nth order neighbors of all gids.
Parameters
----------
neigh_order : int
The order of neighbors to calculate (1st order, 2nd order, etc.).
recalculate : bool, optional
Whether to recalculate neighbors even if they are already computed. Default is False.
fast_estimate : bool, optional
Whether to use a fast estimation method. Default is False.
include_parent : bool, optional
Whether to include the parent grain ID in the neighbors list. Default is True.
output_type : str, optional
The type of output: 'list', 'nparray', or 'set'. Default is 'list'.
Returns
-------
dict
Dictionary with grain IDs as keys and their neighbors of specified order as values.
Examples
--------
.. code-block:: python
from upxo.ggrowth.mcgs import mcgs
pxtal = mcgs(study='independent',
input_dashboard='input_dashboard_for_testing_50x50_alg202.xls')
pxtal.simulate()
pxtal.detect_grains()
neigh_order = 1
pxtal.gs[16].get_upto_nth_order_neighbors_all_grains(neigh_order,
recalculate=False,
include_parent=True,
output_type='list')
"""
if recalculate or not self.neigh_gid:
if fast_estimate:
self.find_neigh_gid_fast_all_grains(include_parent=include_parent)
else:
self.find_neigh(include_central_grain=include_parent)
return neighOps.get_upto_nth_order_neighbors_all_grains(
self.neigh_gid, self.gid, neigh_order,
include_parent=include_parent, output_type=output_type)
[docs]
def get_nth_order_neighbors_all_grains(self, neigh_order, fast_estimate=False,
recalculate=False, include_parent=True):
"""
Calculates the nth order neighbours of all gids.
Parameters
----------
neigh_order : int
The order of neighbors to calculate (1st order, 2nd order, etc.).
fast_estimate : bool, optional
Whether to use a fast estimation method. Default is False.
recalculate : bool, optional
Whether to recalculate neighbors even if they are already computed. Default is False.
include_parent : bool, optional
Whether to include the parent grain ID in the neighbors list. Default is True.
Returns
-------
dict
Dictionary with grain IDs as keys and their neighbors of specified order as values.
Examples
--------
from upxo.ggrowth.mcgs import mcgs
pxtal = mcgs(study='independent', input_dashboard='input_dashboard.xls')
pxtal.simulate()
pxtal.detect_grains()
tslice = 99
pxtal.gs[tslice].char_morph_2d(char_gb=True)
no_clr = ['k', 'b', 'r', 'g']
no_mrk = ['o', 's', 'x', '+']
no_msz = [8, 8, 8, 8]
gb_clr = ['k', 'b', 'r', 'g']
cg = 1
NO = [2]
ANO = [None, None, None]
plt.figure(figsize=(5, 5), dpi=100)
for no in NO:
A = pxtal.gs[tslice].get_nth_order_neighbors_all_grains(no,
fast_estimate=False,
recalculate=False,
include_parent=True,)
for gid in A[cg]:
plt.plot(*np.roll(pxtal.gs[tslice].g[gid]['grain'].gbloc, 1, axis=1).T,
gb_clr[no]+'.', markersize=2)
gidcentroid = pxtal.gs[tslice].g[gid]['grain'].centroid
plt.plot(*gidcentroid, no_clr[no]+no_mrk[no],
markersize=no_msz[no])
plt.plot(*np.roll(pxtal.gs[tslice].g[cg]['grain'].gbloc, 1, axis=1).T,
'c.', markersize=4)
cgcentroid = pxtal.gs[tslice].g[cg]['grain'].centroid
plt.plot(*cgcentroid, no_clr[0]+no_mrk[0],
markersize=no_msz[0])
plt.gca().set_aspect('equal')
cg = 10 # central_grain
neigh_order = 1
A = pxtal.gs[tslice].get_upto_nth_order_neighbors_all_grains(neigh_order,
recalculate=False,
include_parent=True,
output_type='list')
A = pxtal.gs[tslice].get_nth_order_neighbors_all_grains(neigh_order,
recalculate=False,
include_parent=True,)
pxtal.gs[tslice].plot_grains_gids(A[cg], gclr='color', title="user grains",
cmap_name='CMRmap_r', )
neigh_order = 2
A = pxtal.gs[tslice].get_upto_nth_order_neighbors_all_grains(neigh_order,
recalculate=False,
include_parent=True,
output_type='list')
A = pxtal.gs[tslice].get_nth_order_neighbors_all_grains(neigh_order,
recalculate=False,
include_parent=True,)
pxtal.gs[tslice].plot_grains_gids(A[cg], gclr='color', title="user grains",
cmap_name='CMRmap_r', )
neigh_order = 3
A = pxtal.gs[tslice].get_upto_nth_order_neighbors_all_grains(neigh_order,
recalculate=False,
include_parent=True,
output_type='list')
A = pxtal.gs[tslice].get_nth_order_neighbors_all_grains(neigh_order,
recalculate=False,
include_parent=True,)
pxtal.gs[tslice].plot_grains_gids(A[cg], gclr='color', title="user grains",
cmap_name='CMRmap_r', )
# -----------------------------------------
no_clr = ['k', 'b', 'k', 'k']
no_mrk = ['o', 's', 'x', '+']
no_msz = [8, 8, 8, 8]
plt.figure(figsize=(5, 5), dpi=100)
for gid in A[cg]:
plt.plot(*np.roll(pxtal.gs[tslice].g[gid]['grain'].gbloc, 1, axis=1).T,
'k.', markersize=3)
gidcentroid = pxtal.gs[tslice].g[gid]['grain'].centroid
plt.plot(*gidcentroid, no_clr[neigh_order]+no_mrk[neigh_order],
markersize=no_msz[neigh_order])
plt.plot(*np.roll(pxtal.gs[tslice].g[cg]['grain'].gbloc, 1, axis=1).T,
'r.', markersize=4)
cgcentroid = pxtal.gs[tslice].g[cg]['grain'].centroid
plt.plot(*cgcentroid, no_clr[0]+no_mrk[0],
markersize=no_msz[0])
plt.gca().set_aspect('equal')
# =================================================================
all_neighs = pxtal.gs[tslice].neigh_gid
neigh_order = 1
cg = 17
plt.figure(figsize=(5, 5), dpi=100)
for gid in all_neighs[cg]:
plt.plot(*np.roll(pxtal.gs[tslice].g[gid]['grain'].gbloc, 1, axis=1).T,
'k.', markersize=3)
gidcentroid = pxtal.gs[tslice].g[gid]['grain'].centroid
plt.plot(*gidcentroid, no_clr[neigh_order]+no_mrk[neigh_order],
markersize=no_msz[neigh_order])
plt.plot(*np.roll(pxtal.gs[tslice].g[cg]['grain'].gbloc, 1, axis=1).T,
'r.', markersize=4)
cgcentroid = pxtal.gs[tslice].g[cg]['grain'].centroid
plt.plot(*cgcentroid, no_clr[0]+no_mrk[0],
markersize=no_msz[0])
plt.gca().set_aspect('equal')
"""
if recalculate or not self.neigh_gid:
if fast_estimate:
self.find_neigh_gid_fast_all_grains(include_parent=include_parent)
else:
self.find_neigh(include_central_grain=include_parent)
return neighOps.get_nth_order_neighbors_all_grains(
self.neigh_gid, self.gid, neigh_order, include_parent=include_parent)
[docs]
def get_upto_nth_order_neighbors_all_grains_prob(self, neigh_order,
recalculate=False,
include_parent=False,
print_msg=False,
_int_approx_=0.05):
"""
Calculates 1st to nth order neighbors of all gids.
Allows float values for neigh_order for probabilistic selection.
Parameters
----------
neigh_order : int or float
The order of neighbors to calculate (1st order, 2nd order, etc.).
If float, probabilistic selection is done between floor and ceil values.
recalculate : bool, optional
Whether to recalculate neighbors even if they are already computed. Default is False.
include_parent : bool, optional
Whether to include the parent grain ID in the neighbors list. Default is False.
print_msg : bool, optional
Whether to print messages during execution. Default is False.
_int_approx_ : float, optional
Threshold to consider a float as an integer. Default is 0.05.
Returns
-------
dict
Dictionary with grain IDs as keys and their neighbors of specified order as values.
Examples
--------
.. code-block:: python
from upxo.ggrowth.mcgs import mcgs
pxt = mcgs()
pxt.simulate()
pxt.detect_grains()
tslice = 10
def_neigh = pxt.gs[tslice].get_upto_nth_order_neighbors_all_grains_prob
neigh0 = def_neigh(1, recalculate=False, include_parent=True)
neigh1 = def_neigh(1.06, recalculate=False, include_parent=True)
neigh2 = def_neigh(1.5, recalculate=False, include_parent=True)
neigh0[22] # list of neighbours of grain 22 at order 1
neigh1[22] # probabilistic blend for grain 22 (order ~1.06)
neigh2[22] # probabilistic blend for grain 22 (order ~1.5)
"""
if recalculate or not self.neigh_gid:
self.find_neigh(include_central_grain=include_parent)
return neighOps.get_upto_nth_order_neighbors_all_grains_prob(
self.neigh_gid, self.gid, neigh_order,
include_parent=include_parent, print_msg=print_msg,
_int_approx_=_int_approx_)
[docs]
def char_morph_2d(self, use_characterization_settings=False, use_version=1,
bso=1, def_feat_name='grain',
bbox=True, bbox_ex=True, npixels=False,
npixels_gb=False, identify_pixel_locations=True,
area=False, eq_diameter=False,
perimeter=False, perimeter_crofton=False,
compactness=False, gb_length_px=False, aspect_ratio=False,
solidity=False, morph_ori=False, circularity=False,
eccentricity=False, feret_diameter=False,
major_axis_length=False, minor_axis_length=False,
euler_number=False, moments_hu=True,
append=False, saa=True, throw=False,
char_grain_positions=False, find_neigh=False,
char_gb=False, make_skim_prop=False,
get_grain_coords=True):
"""
Characterize the 2D grain structure and populate grain properties.
This is the main morphology-characterisation entry point and delegates
to the versioned implementation selected by ``use_version``.
"""
if use_version in [1, 2]:
# Make data holder for properties
from upxo._sup.data_templates import pd_templates
__ = pd_templates()
__a, __b, __c = __.make_prop2d_df(bbox=bbox, bbox_ex=bbox_ex, npixels=npixels,
npixels_gb=npixels_gb, area=area, eq_diameter=eq_diameter,
perimeter=perimeter, perimeter_crofton=perimeter_crofton,
compactness=compactness, gb_length_px=gb_length_px,
aspect_ratio=aspect_ratio, solidity=solidity,
morph_ori=morph_ori, circularity=circularity,
eccentricity=eccentricity, feret_diameter=feret_diameter,
major_axis_length=major_axis_length,
minor_axis_length=minor_axis_length,
euler_number=euler_number, moments_hu=moments_hu,
append=append)
self.prop_flag, self.prop, self.prop_stat = __a, __b, __c
# -------------------------------------
property_flag_kwargs = {'bbox': bbox,
'bbox_ex': bbox_ex,
'npixels': npixels,
'npixels_gb': npixels_gb,
'identify_pixel_locations': identify_pixel_locations,
'area': area,
'eq_diameter': eq_diameter,
'perimeter': perimeter,
'perimeter_crofton': perimeter_crofton,
'compactness': compactness,
'gb_length_px': gb_length_px,
'aspect_ratio': aspect_ratio,
'solidity': solidity,
'morph_ori': morph_ori,
'circularity': circularity,
'eccentricity': eccentricity,
'feret_diameter': feret_diameter,
'major_axis_length': major_axis_length,
'minor_axis_length': minor_axis_length,
'euler_number': euler_number,
'moments_hu': moments_hu,
'char_grain_positions': char_grain_positions,
'find_neigh': find_neigh,
'char_gb': char_gb,
'make_skim_prop': make_skim_prop,
'get_grain_coords': get_grain_coords,
}
if use_version == 1:
print(f"Characterising MC simulation time-slice {self.m}")
self.prop_flag, self.prop, self.prop_stat = __a, __b, __c
self.char_morph_2d_v1(use_characterization_settings=use_characterization_settings,
append=append, saa=saa, throw=throw,
make_pd=False, **property_flag_kwargs)
elif use_version == 2:
print(f"Characterising MC simulation time-slice {self.m}")
self.char_morph_2d_v2(bso=bso, def_feat_name=def_feat_name,
saa=saa, throw=throw,
append=False, make_pd=False, **property_flag_kwargs)
else:
raise ValueError('Invalid use_version specified')
[docs]
def char_morph_2d_v1(self, use_characterization_settings=False,
bbox=True, bbox_ex=True, npixels=False,
npixels_gb=False, identify_pixel_locations=True,
area=False, eq_diameter=False,
perimeter=False, perimeter_crofton=False,
compactness=False, gb_length_px=False, aspect_ratio=False,
solidity=False, morph_ori=False, circularity=False,
eccentricity=False, feret_diameter=False,
major_axis_length=False, minor_axis_length=False,
euler_number=False, moments_hu=True,
append=False, saa=True, throw=False,
char_grain_positions=False, find_neigh=False,
char_gb=False, make_skim_prop=False,
get_grain_coords=True, make_pd=True):
"""
This method allows user to calculate morphological parameters
of a given grain structure slice.
Parameters
----------
use_characterization_settings : bool, optional
Whether to use pre-defined characterization settings. Default is False.
bbox : bool, optional
Whether to extract bounding box property. Default is True.
bbox_ex : bool, optional
Whether to extract extended bounding box property. Default is True.
npixels : bool, optional
Whether to extract number of pixels property. Default is False.
npixels_gb : bool, optional
Whether to extract number of grain boundary pixels property. Default is False.
area : bool, optional
Whether to extract area property. Default is False.
eq_diameter : bool, optional
Whether to extract equivalent diameter property. Default is False.
perimeter : bool, optional
Whether to extract perimeter property. Default is False.
perimeter_crofton : bool, optional
Whether to extract perimeter (Crofton) property. Default is False.
compactness : bool, optional
Whether to extract compactness property. Default is False.
gb_length_px : bool, optional
Whether to extract grain boundary length in pixels property. Default is False.
aspect_ratio : bool, optional
Whether to extract aspect ratio property. Default is False.
solidity : bool, optional
Whether to extract solidity property. Default is False.
morph_ori : bool, optional
Whether to extract morphological orientation property. Default is False.
circularity : bool, optional
Whether to extract circularity property. Default is False.
eccentricity : bool, optional
Whether to extract eccentricity property. Default is False.
feret_diameter : bool, optional
Whether to extract feret diameter property. Default is False.
major_axis_length : bool, optional
Whether to extract major axis length property. Default is False.
minor_axis_length : bool, optional
Whether to extract minor axis length property. Default is False.
euler_number : bool, optional
Whether to extract euler number property. Default is False.
moments_hu : bool, optional
Whether to extract Hu moments property. Default is True.
append : bool, optional
Whether to append to existing properties. Default is False.
saa : bool, optional
Whether to store as attribute. Default is True.
throw : bool, optional
Whether to return the properties. Default is False.
char_grain_positions : bool, optional
Whether to characterize grain positions. Default is False.
find_neigh : bool, optional
Whether to find neighboring grains. Default is False.
char_gb : bool, optional
Whether to characterize grain boundaries. Default is False.
make_skim_prop : bool, optional
Whether to make skim properties. Default is False.
get_grain_coords : bool, optional
Whether to get grain coordinates. Default is True.
"""
from upxo.xtal.mcgrain2d_definitions import grain2d
self._char_fx_version_ = 1
if make_pd:
# Make data holder for properties
from upxo._sup.data_templates import pd_templates
__ = pd_templates()
__a, __b, __c = __.make_prop2d_df(bbox=bbox, bbox_ex=bbox_ex, npixels=npixels,
npixels_gb=npixels_gb, area=area, eq_diameter=eq_diameter,
perimeter=perimeter, perimeter_crofton=perimeter_crofton,
compactness=compactness, gb_length_px=gb_length_px,
aspect_ratio=aspect_ratio, solidity=solidity,
morph_ori=morph_ori, circularity=circularity,
eccentricity=eccentricity, feret_diameter=feret_diameter,
major_axis_length=major_axis_length,
minor_axis_length=minor_axis_length,
euler_number=euler_number, moments_hu=moments_hu,
append=append, make_pd=True)
self.prop_flag, self.prop, self.prop_stat = __a, __b, __c
# ---------------------------------------------
Rlab, Clab = self.lgi.shape[0], self.lgi.shape[1]
# ---------------------------------------------
if make_skim_prop:
from skimage.measure import regionprops
# ---------------------------------------------
for s in self.s_gid.keys():
if s % 5 == 0:
print(f"--------State value: {s} of {self.S}")
s_gid_keys_npy = [skey for skey in self.s_gid.keys()
if self.s_gid[skey]]
# ---------------------------------------------
sn = 1
for state in s_gid_keys_npy:
grains = self.s_gid[state]
# Iterate through each grain of this state value
_ngrains_ = len(grains)
for i, gn in enumerate(grains, start=1):
gn = int(gn)
#if _ngrains_%100 == 0:
# print(f'....grain no. {i}/{_ngrains_}')
grain_mask = (self.lgi == gn).astype(np.uint8)
self.g[gn] = {'s': state, 'grain': grain2d()}
self.g[gn]['grain'].gid = gn
if identify_pixel_locations:
locations = np.argwhere(grain_mask)
self.g[gn]['grain'].loc = locations
_ = locations.T
self.g[gn]['grain'].xmin = _[0].min()
self.g[gn]['grain'].xmax = _[0].max()
self.g[gn]['grain'].ymin = _[1].min()
self.g[gn]['grain'].ymax = _[1].max()
self.g[gn]['grain'].npixels = locations.shape[0]
self.g[gn]['grain'].s = state
self.g[gn]['grain'].sn = sn
self.g[gn]['grain']._px_area = self.px_size
sn += 1
# ---------------------------------------------
# Extract grain boundary indices
if char_gb:
self.g[gn]['grain'].gbloc = deepcopy(gbOps.compute_grain_boundary_locs(grain_mask))
# ---------------------------------------------
rmin = np.where(grain_mask)[0].min()
rmax = np.where(grain_mask)[0].max()+1
cmin = np.where(grain_mask)[1].min()
cmax = np.where(grain_mask)[1].max()+1
# ---------------------------------------------
if bbox_ex:
# Extract bounding rectangle
Rlab = grain_mask.shape[0]
Clab = grain_mask.shape[1]
rmin_ex = rmin - int(rmin != 0)
rmax_ex = rmax + int(rmin != Rlab)
cmin_ex = cmin - int(cmin != 0)
cmax_ex = cmax + int(cmax != Clab)
# ---------------------------------------------
if bbox:
# Store the bounds of the bounding box
self.g[gn]['grain'].bbox_bounds = [rmin, rmax, cmin, cmax]
if bbox:
# Store bounding box
self.g[gn]['grain'].bbox = grain_mask[rmin:rmax, cmin:cmax].copy()
if bbox_ex:
# Store the bounds of the extended bounding box
self.g[gn]['grain'].bbox_ex_bounds = [rmin_ex, rmax_ex,
cmin_ex, cmax_ex]
if bbox_ex:
# Store the extended bounding box
self.g[gn]['grain'].bbox_ex = grain_mask[rmin_ex:rmax_ex,
cmin_ex:cmax_ex].copy()
if make_skim_prop:
# Store the scikit-image regionproperties generator
self.g[gn]['grain'].make_prop(regionprops, skprop=True)
if get_grain_coords:
# Make coordinates
_coords_ = np.array([[self.xgr[ij[0], ij[1]],
self.ygr[ij[0], ij[1]]]
for ij in self.g[gn]['grain'].loc],
dtype=self.grain_coords_dtype)
self.g[gn]['grain'].coords = deepcopy(_coords_)
print(40*'-')
self.build_prop(correct_aspect_ratio=True)
self.are_properties_available = True
if char_grain_positions:
#self.char_grain_positions_2d()
self.char_grain_positions_2d_v1()
if find_neigh:
print('Identifying grain neighbours.')
self.find_neigh()
[docs]
def char_morph_2d_v2(self, bso=1, def_feat_name='grain',
bbox=True, bbox_ex=True, npixels=False,
npixels_gb=False, identify_pixel_locations=True,
area=False, eq_diameter=False,
perimeter=False, perimeter_crofton=False,
compactness=False, gb_length_px=False, aspect_ratio=False,
solidity=False, morph_ori=False, circularity=False,
eccentricity=False, feret_diameter=False,
major_axis_length=False, minor_axis_length=False,
euler_number=False, moments_hu=True,
char_gb=False, char_grain_positions=False, find_neigh=True,
make_skim_prop=False, get_grain_coords=True,
append=False, saa=True, throw=False, make_pd=True,
_redo_lgi_=False, make_grain_object=True
):
"""
Characterize the 2D grain structure using the version-2 workflow.
This implementation detects features, builds grain objects, and
optionally computes morphology, neighbourhoods, and grain positions.
"""
self._char_fx_version_ = 2
_mgo_ = make_grain_object
# ---------------------------------------------
if make_pd:
# Make data holder for properties
from upxo._sup.data_templates import pd_templates
__ = pd_templates()
__a, __b, __c = __.make_prop2d_df(bbox=bbox, bbox_ex=bbox_ex,
npixels=npixels, npixels_gb=npixels_gb,
area=area, eq_diameter=eq_diameter,
perimeter=perimeter, perimeter_crofton=perimeter_crofton,
compactness=compactness, gb_length_px=gb_length_px,
aspect_ratio=aspect_ratio, solidity=solidity,
morph_ori=morph_ori, circularity=circularity,
eccentricity=eccentricity, feret_diameter=feret_diameter,
major_axis_length=major_axis_length,
minor_axis_length=minor_axis_length,
euler_number=euler_number, moments_hu=moments_hu,
append=append, )
self.prop_flag, self.prop, self.prop_stat = __a, __b, __c
# ---------------------------------------------
from upxo.xtal.mcgrain2d_definitions import grain2d
# ---------------------------------------------
# Rlab, Clab = self.lgi.shape[0], self.lgi.shape[1]
if _redo_lgi_:
features = gridOps.detect_features_in_image_MCstateWise_2d(deepcopy(self.lgi),
binary_structure_order=bso)
self.lgi = deepcopy(features[0])
use_characterise_features_in_image_version = 2
# ---------------------------------------------
if use_characterise_features_in_image_version == 1:
fx1 = mcharOps.characterise_features_in_image_2d
skprops, bbox_limits_ex, bboxes_ex, coords_dict = fx1(self.lgi,
self.xgr, self.ygr, make_skprops=True, extract_coords=True,
throw_bounding_box=True)
elif use_characterise_features_in_image_version == 2:
fx1 = mcharOps.characterise_features_in_image_v2
_fx1Output_ = fx1(self.lgi, Xgrid=self.xgr, Ygrid=self.ygr,
make_skprops=True, extract_coords=True,
throw_bounding_box=True)
skprops, bbox_limits, bbox_limits_ex, bboxes, bboxes_ex, coords_dict = _fx1Output_
# ---------------------------------------------
self.gid = np.array(list(skprops.keys()), dtype=np.int32)
self.n = len(self.gid)
features = {}
_n_features_ = len(self.gid)
frequency_progress_print = 9
if _n_features_ >= frequency_progress_print:
interval = _n_features_ // frequency_progress_print
else:
interval = 1
# ---------------------------------------------
def set_val(target, key, value):
"""Set a mapping key or object attribute to ``value``."""
if isinstance(target, dict):
target[key] = value
else:
setattr(target, key, value)
# ---------------------------------------------
for fid in self.gid:
if fid % interval == 0 or fid == _n_features_:
print(f"{np.round((list(self.gid).index(fid)+1)/_n_features_*100, 1)}%",
end=', ' if fid != _n_features_ else '')
features[fid] = grain2d() if _mgo_ else dict()
# ----------------------
set_val(features[fid], 'm', self.m)
set_val(features[fid], 'gid', fid)
set_val(features[fid], '_px_area', self.px_size)
# ----------------------
if make_skim_prop:
features[fid].skprop = skprops[fid] if _mgo_ else None
# ----------------------
if identify_pixel_locations:
set_val(features[fid], 'loc', np.argwhere(self.lgi == fid))
_ = features[fid].loc.T if _mgo_ else features[fid]['loc'].T
set_val(features[fid], 'xmin', _[0].min())
set_val(features[fid], 'xmax', _[0].max())
set_val(features[fid], 'ymin', _[1].min())
set_val(features[fid], 'ymax', _[1].max())
# ----------------------
'''if _mgo_:
features[fid].loc = np.argwhere(self.lgi == fid)
_ = features[fid].loc.T
features[fid].xmin = _[0].min()
features[fid].xmax = _[0].max()
features[fid].ymin = _[1].min()
features[fid].ymax = _[1].max()
else:
features[fid]['loc'] = np.argwhere(self.lgi == fid)
_ = features[fid]['loc'].T
features[fid]['xmin'] = _[0].min()
features[fid]['xmax'] = _[0].max()
features[fid]['ymin'] = _[1].min()
features[fid]['ymax'] = _[1].max()'''
# ----------------------
if npixels:
set_val(features[fid], 'npixels', features[fid].loc.shape[0])
'''features[fid].npixels = features[fid].loc.shape[0]'''
# ----------------------
if get_grain_coords:
set_val(features[fid], 'coords', np.asarray(coords_dict[fid],
dtype=self.grain_coords_dtype))
'''features[fid].coords = np.asarray(coords_dict[fid],
dtype=self.grain_coords_dtype)'''
# ----------------------
if bbox_ex or bbox:
set_val(features[fid], 'bbox_ex_bounds', bbox_limits_ex[fid])
set_val(features[fid], 'bbox_ex', bboxes_ex[fid])
set_val(features[fid], 'bbox_bounds', bbox_limits[fid])
set_val(features[fid], 'bbox', bboxes[fid])
# ----------------------
if char_gb:
fid_mask = (self.lgi == fid).astype(bool)
set_val(features[fid], 'gbloc', gbOps.compute_grain_boundary_locs(fid_mask))
self.g = features
if _mgo_:
self.build_prop(correct_aspect_ratio=True)
else:
print('Skipping building properties as grain objects were not created.')
if char_grain_positions:
# self.char_grain_positions_2d()
self.char_grain_positions_2d_v1()
if find_neigh:
self.find_neigh(include_central_grain=False, print_msg=False,
user_defined_bbox_ex_bounds=True,
bbox_ex_bounds=bbox_limits_ex,
update_grain_object=False)
if throw:
return features
[docs]
def build_grain_pairs(self, neigh_gid):
"""Build unique grain pairs from the neighbour list."""
return gbOps.build_grain_pairs(neigh_gid)
[docs]
def pad_lfi(self):
"""Return the labelled feature image padded for boundary operations."""
return gridOps.pad_lfi(self.lfi, 1, self.n+1)
[docs]
def find_gb(self, gsimage, plot_gb=False, figsize=(6, 6), dpi=100, cmap='nipy_spectral'):
"""Return grain-boundary pixels for ``gsimage`` using the grid helper."""
return gridOps.find_gb_v1(gsimage, plot_gb=plot_gb, figsize=figsize, dpi=dpi, cmap=cmap)
[docs]
def segment_gb(self, gsimage, gbimage, neigh_fid, connectivity=8):
"""Segment grain-boundary pixels for the specified neighbouring grain IDs."""
return gridOps.segment_grain_boundaries(gsimage, gbimage, neigh_fid,
connectivity=connectivity)
[docs]
def make_gbsegImage(self, gbMask, segments, nsegments, neigh_fid):
"""Build and return a grain-boundary segment image."""
return gridOps.make_gbsegImage(gbMask, segments, nsegments, neigh_fid)
[docs]
def see_all_gbsegs(self, gbsegImage):
"""Visualise all grain-boundary segments in ``gbsegImage``."""
gbViz.see_all_gbsegs(gbsegImage)
[docs]
def see_gbsegs_fid(self, gbsegImage, fid):
"""Visualise the grain-boundary segments for a specific grain ID."""
gbViz.see_gbsegs_fid(gbsegImage, fid)
[docs]
def findJP(self, segments):
"""Return grain-boundary junction points for the given segments."""
junctions = jpOps.findJP(segments)
return junctions
[docs]
def separate_junctions_by_order(self, segments):
"""Group junction points by their order."""
jp_by_jpo = jpOps.separate_junctions_by_order(segments)
return jp_by_jpo
[docs]
def see_gbsegs_jp_by_jpo(self, gbsegs, jps_by_order, style_by_order=None, default_style=None,
figsize=(5, 5), dpi=80, legend_anchor=(1.02, 1.0), ms2=4,
ms3=4, ms4=4, ms5=4, legend_loc='upper left', legend_title='Junction point data',
legend_frameon=True, hide_axis=True, cmap='rainbow'):
"""Visualise grain-boundary segments together with junctions by order."""
gbViz.see_gbsegs_jp_by_jpo(gbsegs, jps_by_order, style_by_order=style_by_order,
default_style=default_style, figsize=figsize, dpi=dpi, legend_anchor=legend_anchor,
ms2=ms2, ms3=ms3, ms4=ms4, ms5=ms5, legend_loc=legend_loc, legend_title=legend_title,
legend_frameon=legend_frameon, hide_axis=hide_axis, cmap=cmap)
[docs]
def make_graph(self, neigh_gid):
"""
Create a graph representation from the neighbor list.
Parameters
----------
neigh_gid : dict
Dictionary where key is grain ID and value is list of neighbor grain IDs.
Returns
-------
graph
Graph representation of the grain neighbors.
"""
import upxo.netops.kmake as kmake
return kmake.make_gid_net_from_neighlist(neigh_gid)
[docs]
def identify_grain_boundary_pixels(self, grain_pairs):
"""Identify grain boundary pixels for the specified grain pairs."""
return gbOps.identify_grain_boundary_pixels(self.lgi, grain_pairs)
[docs]
def plot_boundaries_standalone(self, gb_dict):
"""
Plot grain boundaries from the provided dictionary of boundary coordinates.
Parameters
----------
gb_dict : dict
Dictionary where:
- Key: tuple (grain_id1, grain_id2) as standard Python ints (sorted)
- Value: Nx2 NumPy array of coordinates (float64)
"""
plt.figure(figsize=(8, 8))
for pair, coords in gb_dict.items():
# rows (y), cols (x)
rows = coords[:, 0]
cols = coords[:, 1]
plt.scatter(cols, rows, s=1)
plt.gca().invert_yaxis()
plt.axis('equal')
plt.title(f"Extracted Boundaries ({len(gb_dict)} segments)")
plt.show()
[docs]
def find_grain_boundary_junction_points(self, xorimap=False, IN=None):
"""
Identify grain boundary junction points in the microstructure.
Parameters
----------
xorimap : bool, optional
If True, the junction points will be calculated for the
instance number IN in the pxtal dictionary.
If False, junction points will be calculated for the current
instance. Default is False.
IN : int, optional
Instance number in the pxtal dictionary for which junction
points are to be calculated if xorimap is True.
Default is None.
"""
if not xorimap:
self.gbjp = gbOps.find_gb_junction_point_map(self.lgi)
else:
if IN in self.pxtal.keys():
self.pxtal[IN].gbjp = gbOps.find_gb_junction_point_map(self.pxtal[IN].lgi)
else:
print(f'Invalid Instance number, IN: {IN}')
[docs]
def do_single_pixel_grains_exist(self):
"""Return True when any single-pixel grains are present."""
# Check if any single-pixel grains exist in the grain structure
single_pixel_gids = self.single_pixel_grains
if len(single_pixel_gids) > 0:
return True
else:
return False
pass
[docs]
def do_straightline_grains_exist(self):
"""
Check if any straight-line grains exist in the grain structure.
Straight-line grains are grains that are only one pixel wide in at least one
dimension, excluding single-pixel grains. These are identified by having a
minor axis length of zero when trying to fit an ellipse.
Returns
-------
bool
True if straight-line grains exist, False otherwise.
Notes
-----
This method uses the `straight_line_grains` property which identifies grains
where skimage cannot fit an ellipse due to unit pixel width. Single pixel
grains are excluded from this check.
Examples
--------
.. code-block:: python
from upxo.ggrowth.mcgs import mcgs
pxt = mcgs(study='independent', input_dashboard='input_dashboard.xls')
pxt.simulate()
pxt.detect_grains()
tslice = 10
pxt.gs[tslice].char_morph_2d(make_skim_prop=True)
has_straight = pxt.gs[tslice].do_straightline_grains_exist()
print(f"Straight-line grains present: {has_straight}")
See Also
--------
straight_line_grains : Property that returns the IDs of straight-line grains
do_single_pixel_grains_exist : Check for single-pixel grains
single_pixel_grains : Property that returns the IDs of single-pixel grains
"""
straightline_grain_gids, _ = self.straight_line_grains
if len(straightline_grain_gids) > 0:
return True
else:
return False
[docs]
def check_for_neigh(self, parent_gid, other_gid):
"""
Check if other_gid is indeed a O(1) neighbour of parent_gid.
Parameters
----------
parent_gid:
Grain ID of the parent.
other_gid:
Grain ID of the other grain being checked for O(1) neighbourhood
with parent_gid.
Returns
-------
True if other_gid is a valid O(1) neighbour of parent_gid, else False.
"""
return True if other_gid in self.neigh_gid[parent_gid] else False
[docs]
def get_two_rand_o1_neighs(self):
"""
Calculate at random, two neighbouring O(1) grains.
Examples
--------
.. code-block:: python
from upxo.ggrowth.mcgs import mcgs
mcgs = mcgs(study='independent', input_dashboard='input_dashboard.xls')
mcgs.simulate()
mcgs.detect_grains()
mcgs.gs[35].char_morph_2d()
mcgs.gs[35].find_neigh()
mcgs.gs[35].neigh_gid
mcgs.gs[35].get_two_rand_o1_neighs()
mcgs.gs[35].plot_two_rand_neighs(return_gids=True)
"""
if self.neigh_gid:
rand_gid = random.sample(self.gid, 1)[0]
rand_neigh_rand_grain = random.sample(self.neigh_gid[rand_gid],
1)[0]
return [rand_gid, rand_neigh_rand_grain]
else:
print('Please build neigh_gid data before using this function.')
return [None, None]
[docs]
def plot_two_rand_neighs(self, return_gids=True):
"""
Plot two random neighbouring grains.
Parameters
----------
return_gids: bool
Flag to return the random neigh gid numbers. Defaults to True.
Returns
-------
rand_neigh_gids: list
random neigh gid numbers. Will be gids if return_gids is True.
Else, will be [None, None].
Examples
--------
.. code-block:: python
Please refer to use in the example provided for the definition,
get_two_rand_o1_neighs()
"""
rand_neigh_gids = self.get_two_rand_o1_neighs()
self.plot_grains_gids(rand_neigh_gids, cmap_name='viridis')
if return_gids:
return rand_neigh_gids
else:
return [None, None]
[docs]
def find_gids_by_mprop(self, mprop='area', method='at', distr_loc='mean',
bounds=[(0, 2), (10, 15)], ineq_spec_lb='>=',
ineq_spec_ub='<=', validate_ui=True,
recalculate_area=False,):
"""
Find grain IDs based on morphological property criteria.
Parameters
----------
mprop : str
Morphological property to filter grains by.
method : str
Method to use for filtering. Options are 'at' or 'bounded'.
distr_loc : str
Location statistic to use when method is 'at'. Options are
'mean', 'median', 'min', 'max', or quantiles like 'q1', 'q2', 'q3'.
ineq_spec_lb : str
Inequality specification for lower bound when method is 'bounded'.
Options are '>' or '>='.
ineq_spec_ub : str
Inequality specification for upper bound when method is 'bounded'.
Options are '<' or '<='.
bounds : list of tuples
List of (lower_bound, upper_bound) tuples to use when method is
'bounded'.
Returns
-------
dict
Dictionary containing:
- 'mprop': The morphological property used for filtering.
- 'method': The method used for filtering.
- 'gids': Numpy array of grain IDs that meet the specified criteria.
Examples
--------
.. code-block:: python
from upxo.ggrowth.mcgs import mcgs
pxt = mcgs(study='independent', input_dashboard='input_dashboard.xls')
pxt.simulate()
pxt.detect_grains()
tslice = 10
pxt.gs[tslice].char_morph_2d()
# Find grains with area close to mean area
mean_area_gids = pxt.gs[tslice].find_gids_by_mprop(mprop='area',
method='at',
distr_loc='mean')
print("Grain IDs with area close to mean area:", mean_area_gids)
# Find grains with area within specified bounds
bounded_area_gids = pxt.gs[tslice].find_gids_by_mprop(mprop='area',
method='bounded',
bounds=[(50, 100), (200, 300)])
print("Grain IDs with area within specified bounds:", bounded_area_gids)
"""
# -----------------------------------------
# Validations
if validate_ui:
if hasattr(self, 'prop') is False and mprop == 'area' and not recalculate_area:
raise ValueError("Morphological properties not calculated. "
"Please run char_morph_2d() first.")
if mprop not in self.prop.columns:
raise ValueError(f"Invalid morphological property: {mprop}")
if method not in ('at', 'bounded'):
raise ValueError(f"Invalid method: {method}. Choose 'at' or 'bounded'.")
if distr_loc not in ('mean', 'median', 'min', 'max', 'q1', 'q2', 'q3') and not (distr_loc[0] == 'q' and distr_loc[1:].isnumeric()):
raise ValueError(f"Invalid distr_loc: {distr_loc}.")
if ineq_spec_lb not in ('>', '>='):
raise ValueError(f"Invalid ineq_spec_lb: {ineq_spec_lb}. Choose '>' or '>='.")
if ineq_spec_ub not in ('<', '<='):
raise ValueError(f"Invalid ineq_spec_ub: {ineq_spec_ub}. Choose '<' or '<='.")
# -----------------------------------------
if mprop == 'area' and recalculate_area:
prop_values = self.find_grain_size_fast(metric='pxarea', recalculate_gid=True)
else:
prop_values = self.prop[mprop].to_numpy()
# -----------------------------------------
# Finding gids
if method == 'at':
if distr_loc == 'mean':
target_value = np.mean(prop_values)
elif distr_loc == 'median':
target_value = np.median(prop_values)
elif distr_loc == 'min':
target_value = np.min(prop_values)
elif distr_loc == 'max':
target_value = np.max(prop_values)
elif distr_loc == 'q1':
target_value = np.percentile(prop_values, 25)
elif distr_loc == 'q2':
target_value = np.percentile(prop_values, 50)
elif distr_loc == 'q3':
target_value = np.percentile(prop_values, 75)
elif distr_loc[0] == 'q' and distr_loc[1:].isnumeric():
qnum = int(distr_loc[1:])
if 0 < qnum < 100:
target_value = np.percentile(prop_values, qnum)
else:
raise ValueError(f"Invalid quantile number in distr_loc: {distr_loc}. Must be between 1 and 99.")
else:
raise ValueError(f"Invalid distr_loc: {distr_loc}.")
gids = self.prop.index[np.isclose(prop_values, target_value)].tolist()
elif method == 'bounded':
gids = []
for bound in bounds:
lb, ub = min(bound), max(bound)
b_flags = prop_values > lb if ineq_spec_lb == '>' else prop_values >= lb
b_flags &= prop_values < ub if ineq_spec_ub == '<' else prop_values <= ub
bound_gids = self.prop.index[b_flags].tolist()
gids.extend(bound_gids)
gids = list(set(gids))
# -----------------------------------------
# Convert indexing from 0-based to 1-based as these will be featuire ids
gids = np.array(gids)+1
# -----------------------------------------
# Assimilate return data
_ = {'mprop': mprop, 'method': method, 'gids': gids}
return _
[docs]
def thresholding(self, prop_type='mprop', threshold_type='lower',
method='merge', bso=2, recalculate_neigh='all',
update_grain_object=False, validate_ui=True,
recursive_search_and_merge=True, niter=10,
kwargs_lower_mprop_threshold={'threshold': 1.0,
'pname': 'area', 'sink_metric': 'mean',
'recalculate_mprop': True, 'ineq_spec_ub': '<=',
'sink_select_uncertainty': [-5, 5]},):
"""Apply repeated threshold-based merge or erosion operations."""
for iter_number in range(1, niter+1):
print(f'Iteration {iter_number} of {niter} for lath application.')
if prop_type == 'mprop' and threshold_type == 'lower':
self.lower_mprop_thresholding(method=method, bso=bso,
recalculate_neigh=recalculate_neigh,
update_grain_object=update_grain_object,
validate_ui=validate_ui,
recursive_search_and_merge=recursive_search_and_merge,
**kwargs_lower_mprop_threshold)
[docs]
def lower_mprop_thresholding(self, threshold=1.0, sink_metric='mean',
method='merge', pname='area',
bso=2, recalculate_mprop=True, ineq_spec_ub='<=',
recalculate_neigh='all', update_grain_object=False,
validate_ui=True, sink_select_uncertainty=[-5, 5],
post_merge_ops_frequency=1, recursive_search_and_merge=True,):
"""
Apply lower-threshold morphology-driven grain merging or erosion.
The operation selects grains whose property values fall below
``threshold`` and then uses neighbouring grains to choose merge sinks
or perform erosion, depending on ``method``.
"""
# Validations
qnum = None
if validate_ui:
if hasattr(self, 'prop') is False:
raise ValueError("Morphological properties not calculated. "
"Please run char_morph_2d() first.")
if pname not in self.prop.columns:
raise ValueError(f"{pname} property not calculated. "
f"Please run char_morph_2d() with {pname}=True first.")
if sink_metric not in ('mean', 'median', 'min', 'max', 'q0', 'q1', 'q2', 'q3', 'q4') and not (sink_metric[0] == 'q' and sink_metric[1:].isnumeric()):
raise ValueError(f"Invalid sink_metric: {sink_metric}.")
if method not in ('erode', 'merge'):
raise ValueError(f"Invalid method: {method}. Choose 'erode' or 'merge'.")
if ineq_spec_ub not in ('<', '<='):
raise ValueError(f"Invalid ineq_spec_ub: {ineq_spec_ub}. Choose '<' or '<='.")
if recalculate_neigh not in ('all', 'specific'):
raise ValueError(f"Invalid recalculate_neigh: {recalculate_neigh}. Choose 'all' or 'specific'.")
if post_merge_ops_frequency == 0:
raise Warning("post_merge_ops_frequency is set to 0. No post-merge operations will be performed.")
if post_merge_ops_frequency < 0:
raise ValueError("post_merge_ops_frequency cannot be negative.")
if type(post_merge_ops_frequency) not in dth.dt.INTEGERS:
raise ValueError("post_merge_ops_frequency must be an integer.")
if post_merge_ops_frequency > 1:
raise Warning("post_merge_ops_frequency > 1 could make the lath application slower.")
# -----------------------------------------
# Find grain areas and gids satisfying threshold specification
if recalculate_mprop and pname == 'area':
grain_areas = self.find_grain_size_fast(metric='pxarea', recalculate_gid=True)
gids_mprop = np.where(grain_areas < threshold)[0]+1 # +1 for gid indexing
if not recalculate_mprop and pname == 'area':
if 'area' not in self.prop.columns:
raise ValueError(f"Invalid morphological property: area. Please run char_morph_2d() with area=True first.")
grain_areas = self.prop['area'].to_numpy()
gids_mprop = self.find_gids_by_mprop(mprop='area', method='bounded',
bounds=[(0, threshold)],
ineq_spec_ub=ineq_spec_ub,
recalculate_area=False)['gids']
# -----------------------------------------
if len(gids_mprop) == 0:
print('No grains found below specified threshold value. Exiting lath application.')
return 'thresholding complete - no grains found below threshold'
# -----------------------------------------
# Calculate neigh_gid subset
if recalculate_neigh == 'all':
self.find_neigh()
NG = GidOps.extract_neigh_gid_subset(neigh_gids=self.neigh_gid,
subset_gids=gids_mprop,
type_correction=True,
validate_input=False)
elif recalculate_neigh == 'specific':
NG = {gid: self.find_neigh_gid(gid, include_central_grain=False, throw=True,
update_grain_object=update_grain_object)
for gid in gids_mprop}
# -----------------------------------------
# Build area dictionary for neighbour grains
NG_areas = {int(cgid): np.array([grain_areas[gid-1] for gid in neighs], dtype=np.float32)
for cgid, neighs in NG.items()}
# -----------------------------------------
ssu = sink_select_uncertainty
NG_sinks = {}
if method == 'merge' and sink_metric == 'min':
NG_sinks = manm.select_sinks_min_area(NG, NG_areas, ssu, NG_sinks,
force_assign=False)
elif method == 'merge' and sink_metric == 'mean':
NG_sinks = manm.select_sinks_mean_area(NG, NG_areas, ssu, NG_sinks,
force_assign=False)
elif method == 'merge' and sink_metric == 'max':
NG_sinks = manm.select_sinks_max_area(NG, NG_areas, ssu, NG_sinks,
force_assign=False)
elif method == 'merge' and sink_metric == 'median':
NG_sinks = manm.select_sinks_median_area(NG, NG_areas, ssu, NG_sinks,
force_assign=False)
elif method == 'merge' and sink_metric[0] == 'q' and sink_metric[1:].isnumeric():
NG_sinks = manm.select_sinks_quantile_area(NG, NG_areas, ssu, NG_sinks,
sink_metric=sink_metric, force_assign=False)
# -----------------------------------------
print(NG_sinks)
# recursive_search_and_merge
# post_merge_ops_frequency
if method == 'merge':
# Perform grain merges based on identified sinks.
# Example:
# self.apply_lath(lath=1.0, sink_metric='mean', method='merge',
# recalculate_area=True, ineq_spec_ub='<=', recalculate_neigh='all',
# update_grain_object=False, validate_ui=True, sink_select_uncertainty=[-5, 5],
# post_merge_ops_frequency=1, recursive_search_and_merge=True)
for other_gid, parent_gid in NG_sinks.items():
self._merge_two_grains_(parent_gid, other_gid, print_msg=False)
# Re-number the lgi
old_gids = np.unique(self.lgi)
new_gids = np.arange(start=1, stop=np.unique(self.lgi).size+1, step=1)
for og, ng in zip(old_gids, new_gids):
self.lgi[self.lgi == og] = ng
if self._char_fx_version_ == 1:
self.char_morph_2d(bbox=True, bbox_ex=True, npixels=False,
npixels_gb=False, area=True, eq_diameter=False,
perimeter=False, perimeter_crofton=False,
compactness=False, gb_length_px=False, aspect_ratio=False,
solidity=False, morph_ori=False, circularity=False,
eccentricity=False, feret_diameter=False,
major_axis_length=False, minor_axis_length=False,
euler_number=False, moments_hu=False,
append=False, saa=True, throw=False,
char_grain_positions=False, find_neigh=False,
char_gb=False, make_skim_prop=True,
get_grain_coords=True)
elif self._char_fx_version_ == 2:
self.char_morph_2d_v2(bso=bso, def_feat_name='grain', char_gb=False,
char_grain_positions=False, find_neigh=True, saa=True,
throw=False)
# Reset gid
self.gid = np.array(range(1, np.unique(self.lgi).size+1), dtype=np.int32)
# reset number of grains
self.n = len(self.gid)
# Calculate new neighbourhood database
self.find_neigh(include_central_grain=False, print_msg=False)
# -----------------------------------------
if method == 'erode':
# Identify subset of gb_pixels for only the specific grain neighbours
grain_pairs = self.build_grain_pairs(NG)
# Identify grain boundary pixels for all grain pairs
gb_pixels = self.identify_grain_boundary_pixels(grain_pairs)
# -----------------------------------------
def _merge_two_grains_(self, parent_gid, other_gid, print_msg=False):
"""Low level merge operation. No checks done. Just merging.
Parameters
----------
parent_gid: int
Parent grain ID number.
other_gid: int
Other grain ID number.
print_msg: bool
Defaults to False.
Returns
-------
None
Notes
-----
Internal use only.
"""
self.lgi[self.lgi == other_gid] = parent_gid
if print_msg:
print(f"Grain {other_gid} merged with grain {parent_gid}.")
[docs]
def merge_two_neigh_grains(self, parent_gid, other_gid,
check_for_neigh=True, simple_merge=True):
"""
Merge other_gid grain to the parent_gid grain.
Parameters
----------
parent_gid:
Grain ID of the parent.
other_gid:
Grain ID of the other grain being merged into the parent.
check_for_neigh: bool.
If True, other_gid will be checked if it can be merged to the
parent grain. Defaults to True.
Returns
-------
merge_success: bool
True, if successfully merged, else False.
"""
def MergeGrains():
"""Merge the selected grain pair using the configured strategy."""
if simple_merge:
self._merge_two_grains_(parent_gid, other_gid, print_msg=False)
merge_success = True
else:
print("Special merge process. To be developed.")
merge_success = False # As of now, this willd efault to False.
return merge_success
# ---------------------------------------
if not check_for_neigh:
merge_success = MergeGrains()
else:
if check_for_neigh and not self.check_for_neigh(parent_gid, other_gid):
# print('Check for neigh failed. Nothing merged.')
merge_success = False
# ---------------------------------------
if any((check_for_neigh, self.check_for_neigh(parent_gid, other_gid))):
merge_success = MergeGrains()
# print(f"Grain {other_gid} merged with grain {parent_gid}.")
return merge_success
[docs]
def perform_post_grain_merge_ops(self, merge_success, merged_gid):
"""Run post-merge bookkeeping after a successful grain merge."""
self.renumber_gid_post_grain_merge(merged_gid)
self.recalculate_ngrains_post_grain_merge()
# Update lgi
# Update neigh_gid
pass
[docs]
def renumber_gid_post_grain_merge(self, merged_gid):
"""Renumber grain IDs after a merge removed ``merged_gid``."""
# self._gid_bf_merger_ = deepcopy(self.gid) # May nor be needed
GID_left = self.gid[0:merged_gid-1]
GID_right = [gid-1 for gid in self.gid[merged_gid:]]
self.gid = np.array(GID_left + GID_right, dtype=np.int32)
[docs]
def recalculate_ngrains_post_grain_merge(self):
"""Recompute the grain count after a merge."""
# gid must have been recalculated for tjhis as a pre-requisite.
self.n = len(self.gid)
[docs]
def renumber_lgi_post_grain_merge(self, merged_gid):
"""Renumber the labelled grain image after a merge."""
# LGI_left = self.lgi[self.lgi < merged_gid]
self.lgi[self.lgi > merged_gid] -= 1
[docs]
def validate_propnames(self, mpnames, return_type='dict'):
"""
Validate that each requested property name is supported.
Parameters
----------
mpnames : iterable
Property names to validate.
return_type : str, optional
Return a dictionary or a tuple of validation flags.
Returns
-------
dict or tuple
Validation results for each property name.
Examples
--------
.. code-block:: python
self.validate_propnames(['area', 'perimeter', 'solidity'])
"""
_ = {pn: pn in self.valid_mprops.keys() for pn in mpnames}
if return_type == 'dict':
return _
elif return_type in ('list', 'tuple'):
return tuple(_.values())
else:
raise ValueError('Invalid return_type specification.')
[docs]
def check_mpnamevals_exists(self, mpnames, return_type='dict'):
"""
Check whether each requested property has already been computed.
Parameters
----------
mpnames : iterable
Property names to check.
return_type : str, optional
Return a dictionary or a sequence of boolean flags.
Returns
-------
dict or list or tuple
Existence flags for each property name.
"""
if return_type == 'dict':
return {mpn: mpn in self.prop.columns for mpn in mpnames}
elif return_type in ('list', 'tuple'):
return [mpn in self.prop.columns for mpn in mpnames]
[docs]
def set_mprops(self, mpnames, char_grain_positions=True,
char_gb=False, set_grain_coords=True,
saa=True, throw=False):
"""
Compute and cache the requested morphology properties.
Parameters
----------
mpnames : iterable
Property names to calculate.
char_grain_positions : bool, optional
Update grain position classification.
char_gb : bool, optional
Recompute grain-boundary geometry.
set_grain_coords : bool, optional
Update stored grain coordinates.
saa : bool, optional
Store computed properties on the instance.
throw : bool, optional
Return computed property arrays.
Returns
-------
dict
Requested property values when ``throw`` is True, otherwise
``None`` placeholders.
Examples
--------
.. code-block:: python
self.set_mprops(mpnames, recharacterize=True)
"""
VALMPROPS = deepcopy(self.valid_mprops)
# ----------------------------
if not all(self.validate_propnames(mpnames, return_type='tuple')):
raise ValueError('Invalid propnames.')
# ----------------------------
for mpn in mpnames:
# Check if each user input morph0ological propetrty name and
# corresponding values exist in self.prop pd dataFrame.
VALMPROPS[mpn] = True
if char_grain_positions:
VALMPROPS['char_grain_positions'] = True
# ----------------------------
self.char_morph_2d(bbox=True, bbox_ex=True, append=False, saa=saa,
throw=False, char_gb=char_gb, make_skim_prop=True,
get_grain_coords=set_grain_coords, **VALMPROPS)
# ----------------------------
if throw:
mprop_values = {mpn: self.prop[mpn].to_numpy() for mpn in mpnames}
else:
mprop_values = {mpn: None for mpn in mpnames}
return mprop_values
[docs]
def get_mprops(self, mpnames, set_missing_mprop=False):
"""
Return morphology-property arrays, computing missing ones if needed.
Parameters
----------
mpnames : iterable
Property names to return.
set_missing_mprop : bool, optional
Compute missing properties before returning values.
Returns
-------
dict
Mapping of property name to numpy array or ``None``.
Examples
--------
.. code-block:: python
from upxo.ggrowth.mcgs import mcgs
mcgs = mcgs(study='independent', input_dashboard='input_dashboard.xls')
mcgs.simulate()
mcgs.detect_grains()
mcgs.gs[mcgs.m[-1]].char_morph_2d(bbox=True, bbox_ex=True,
area=True,aspect_ratio=True,
make_skim_prop=True,)
mpnames=['area', 'aspect_ratio', 'perimeter', 'solidity']
mcgs.gs[mcgs.m[-1]].prop
mprop_values = mcgs.gs[mcgs.m[-1]].get_mprops(mpnames,
set_missing_mprop=True)
mprop_values
"""
if not all(self.validate_propnames(mpnames, return_type='list')):
raise ValueError('Invalid mpname values.')
val_exists = self.check_mpnamevals_exists(mpnames, return_type='dict')
# ----------------------------
if not set_missing_mprop:
mprop_values = {}
for mpn in mpnames:
if val_exists[mpn]:
mprop_values[mpn] = self.prop[mpn].to_numpy()
else:
mprop_values[mpn] = None
# ----------------------------
if set_missing_mprop:
set_propnames = [mpn for mpn in mpnames if not val_exists[mpn]]
self.set_mprops(mpnames, char_grain_positions=False,
char_gb=False, set_grain_coords=False)
mprop_values = self.get_mprops(mpnames, set_missing_mprop=False)
return mprop_values
[docs]
def validata_gids(self, gids):
"""Return True when all requested grain IDs exist in ``self.gid``."""
return all([gid in self.gid for gid in gids])
[docs]
def get_gids_in_params_bounds(self,
search_gid_source='all',
search_gids=None,
mpnames=['area', 'aspect_ratio',
'perimeter', 'solidity'],
fx_stats=[np.mean, np.mean, np.mean, np.mean],
pdslh=[[50, 50], [50, 50], [50, 50], [50, 50]],
param_priority=[1, 2, 3, 2],
plot_mprop=True
):
"""
Get gids of grains whose morphological property values lie within
user specified bounds.
Parameters
----------
search_gid_source: str
Source of gids to be searched. Valid choices:
'all' : All gids in self.gid will be searched.
'user': Only user provided gids in search_gids will be searched.
search_gids: Iterable of ints
User provided gids to be searched. Only valid if
search_gid_source is 'user'.
mpnames: dth.dt.ITERABLES
List of user specified names of morphological properties.
fx_stats: dth.dt.ITERABLES
List of functions to compute the statistic of the morphological
property. The length of fx_stats must be same as length of
mpnames. Valid functions include numpy functions like np.mean,
np.median, np.std, etc.
pdslh: dth.dt.ITERABLES
List of lists containing percentages of distance from stat to
minimum and stat to maximum. The length of pdslh must be same as
length of mpnames. Each element of pdslh must be a list of two
numbers. The first number is percentage of distance from stat to
minimum and the second number is percentage of distance from stat
to maximum.
param_priority: dth.dt.ITERABLES
List of integers specifying the priority of each morphological
property. The length of param_priority must be same as length of
mpnames. Higher the number, higher the priority.
plot_mprop: bool
If True, plots the morphological property maps with bounds
indicated in the title.
Returns
-------
GIDs: dict
Dictionary containing the following keys:
'intersection': List of gids that lie within the bounds of all
morphological properties.
'union': List of gids that lie within the bounds of at least one
morphological property.
'presence': Dictionary with gids as keys and number of
morphological properties that the gid lies within
bounds as values.
'mpmapped': Dictionary with morphological property names as keys
and list of gids that lie within the bounds of the
morphological property as values.
VALIND: dict
Dictionary containing the following keys:
'stat': Dictionary with morphological property names as keys and
their corresponding statistic values as values.
'statmap': List of functions used to compute the statistic of
each morphological property.
'bounds': Dictionary with morphological property names as keys
and their corresponding bounds as values.
'indices': Dictionary with morphological property names as keys
and list of indices of grains that lie within the
bounds of the morphological property as values.
Example
-------
"""
# Validations
# ---------------------------
pname_val = self.validate_propnames(mpnames, return_type='dict')
# pname_val = mcgs.gs[35].validate_propnames(mpnames, return_type='dict')
mprop_values = self.get_mprops(mpnames, set_missing_mprop=True)
# mcgs.gs[35].prop
# mprop_values = mcgs.gs[35].get_mprops(mpnames, set_missing_mprop=True)
# ---------------------------
'''Sub-select gids as per user request.'''
if search_gid_source == 'user' and dth.IS_ITER(search_gids):
if self.validata_gids(search_gids):
search_gids = np.sort(search_gids)
for mpn in mpnames:
mprop_values[mpn] = mprop_values[mpn][search_gids]
# ---------------------------
'''Data processing and extract indices of parameters for parameter
values valid to the user provided bound.'''
mprop_KEYS = list(mprop_values.keys())
mprop_VALS = list(mprop_values.values())
mpinds = {mpn: None for mpn in mprop_KEYS}
mp_stats = {mpn: None for mpn in mprop_KEYS}
mp_bounds = {mpn: None for mpn in mprop_KEYS}
for i, (KEY, VAL) in enumerate(zip(mprop_KEYS, mprop_VALS)):
masked_VAL = np.ma.masked_invalid(VAL)
# Compute the stat value of the morpho prop
mp_stat = fx_stats[i](masked_VAL)
mp_stats[KEY] = mp_stat
# COmpute min and max of the mprop array
mp_gmin, mp_gmax = np.min(masked_VAL), np.max(masked_VAL)
# Compute distance from stat to low and stat to high
mp_dlow, mp_dhigh = abs(mp_stat-mp_gmin), abs(mp_stat-mp_gmax)
# Compute bounds of arrays using varper
dfsmin = pdslh[i][0]/100 # Distance factor from stat to prop min.
dfsmax = pdslh[i][1]/100 # Distance factor from stat to prop max.
# Compute lower bound and upper boubnd
boundlow = mp_stat - dfsmin*mp_dlow
boundhigh = mp_stat + dfsmax*mp_dhigh
mp_bounds[KEY] = [boundlow, boundhigh]
# Mask the mprop array and get indices
mpinds[KEY] = np.where((VAL >= boundlow) & (VAL <= boundhigh))[0]
# ---------------------------
# Find the intersection
intersection = find_intersection(mpinds.values())
# Find the union with counts
union, counts = find_union_with_counts(mpinds.values())
# Copnvert array indices to gid notation.
intersection = [i+1 for i in intersection]
union = [u+1 for u in union]
counts = {c+1: v for c, v in counts.items()}
mpinds_gids = {}
for mpn in mpinds:
mpinds_gids[mpn] = [i+1 for i in mpinds[mpn]]
# Collate the GID related results
GIDs = {'intersection': intersection, 'union': union, 'presence': counts, 'mpmapped': mpinds_gids}
# Collate the Values and Indices related results
VALIND = {'stat': mp_stats, 'statmap': fx_stats, 'bounds': mp_bounds, 'indices': mpinds,}
if plot_mprop:
fig, ax = plt.subplots(nrows=1, ncols=len(GIDs['mpmapped'].keys()),
figsize=(5, 5), dpi=120, sharey=True)
for i, mpn in enumerate(GIDs['mpmapped'].keys(), start=0):
LGI = deepcopy(self.lgi)
if len(GIDs['mpmapped'][mpn]) > 0:
for gid in self.gid:
if gid in GIDs['mpmapped'][mpn]:
pass
else:
LGI[LGI == gid] = -10
ax[i].imshow(LGI, cmap='nipy_spectral')
bounds = ", ".join(f"{b:.2f}" for b in VALIND['bounds'][mpn])
ax[i].set_title(f"{mpn}: bounds: [{bounds}]", fontsize=10)
return GIDs, VALIND
[docs]
def get_gid_mprop_map(self, mpropname, querry_gids):
"""
Map a morphology property onto the requested grain IDs.
Parameters
----------
mpropname : str
Property name to extract.
querry_gids : iterable
Grain IDs to map.
Returns
-------
dict
Grain-ID to property-value mapping.
"""
# Validations
self.validate_propnames([mpropname])
self.validata_gids(querry_gids)
# ------------------------------------
if mpropname in self.prop.columns:
mpvalues = self.prop.loc[[gid-1 for gid in list(querry_gids)],
'aspect_ratio'].to_numpy()
gid_mprop_map = {gid: mpv
for gid, mpv in zip(querry_gids, mpvalues)}
return gid_mprop_map
else:
raise ValueError(f'mpropname must be in {list(self.prop.columns())}.')
[docs]
def map_scalar_to_lgi(self, scalars_dict, default_scalar=-1,
plot=True, throw_axis=True, plot_centroid=True,
plot_gid_number=True, title='title',
centroid_kwargs={'marker': 'o', 'mfc': 'yellow', 'mec': 'black', 'ms': 2.5},
gid_text_kwargs={'fontsize': 10},
title_kwargs={'fontsize': 10}, label_kwargs={'fontsize': 10}):
"""
Map to LGI, the gid keyed values in scalars_dict.
Parameters
----------
scalars_dict: dict
Dictionary with gids as keys and scalar values as values.
default_scalar: float
Default scalar value to be assigned to gids not in scalars_dict.
Defaults to -1.
plot: bool
If True, plot the mapped LGI. Defaults to True.
throw_axis: bool
If True, returns the axis object along with the mapped LGI.
Defaults to True.
plot_centroid: bool
If True, plots the centroids of the grains. Defaults to True.
plot_gid_number: bool
If True, plots the gid number at the centroid of the grains.
Defaults to True.
title: str
Title of the plot. Defaults to 'title'.
centroid_kwargs: dict
Keyword arguments for plotting centroids. Defaults to
{'marker': 'o', 'mfc': 'yellow', 'mec': 'black', 'ms': 2.5}.
gid_text_kwargs: dict
Keyword arguments for plotting gid numbers. Defaults to
{'fontsize': 10}.
title_kwargs: dict
Keyword arguments for the plot title. Defaults to
{'fontsize': 10}.
label_kwargs: dict
Keyword arguments for the plot labels. Defaults to
{'fontsize': 10}.
Returns
-------
result: dict
Dictionary with the following keys:
'lgi': Mapped LGI as a numpy array.
'ax': Axis object if throw_axis is True, else None.
Examples
--------
.. code-block:: python
from upxo.ggrowth.mcgs import mcgs
pxt = mcgs()
pxt.simulate()
pxt.detect_grains()
tslice = 10
def_neigh = pxt.gs[tslice].get_upto_nth_order_neighbors_all_grains_prob
neigh1 = def_neigh(1.38, recalculate=False, include_parent=True)
sf_no = pxt.gs[tslice]
from upxo.growth.mcgs import mcgs
mcgs = mcgs(study='independent', input_dashboard='input_dashboard.xls')
mcgs.simulate()
mcgs.detect_grains()
mcgs.gs[35].char_morph_2d(bbox=True, bbox_ex=True, area=True,
aspect_ratio=True,
make_skim_prop=True,)
GIDs, VALIND = mcgs.gs[35].get_gids_in_params_bounds(mpnames=['aspect_ratio', 'area'],
fx_stats=[np.mean, np.mean],
pdslh=[[50, 30], [50, 30]], plot_mprop=False
)
mcgs.gs[35].map_scalar_to_lgi(GIDs['presence'], default_scalar=-1,
plot=True, throw_axis=True)
gid_mprop_map = mcgs.gs[35].get_gid_mprop_map('aspect_ratio',
GIDs['mpmapped']['aspect_ratio'])
MPLGIAX = mcgs.gs[35].map_scalar_to_lgi(gid_mprop_map, default_scalar=-1,
plot=True, throw_axis=True)
"""
# Validations
self.validata_gids(scalars_dict.keys())
# -------------------
LGI = deepcopy(self.lgi).astype(float)
for gid in self.gid:
if gid in scalars_dict.keys():
LGI[LGI == gid] = scalars_dict[gid]
else:
LGI[LGI == gid] = default_scalar
# -------------------
if plot:
# VMIN, VMAX = min(scalars_dict.values()), max(scalars_dict.values())
plt.figure(figsize=(5, 5), dpi=120)
plt.imshow(LGI, cmap='viridis')
if plot_centroid or plot_gid_number:
centroid_x, centroid_y = [], []
for gid in scalars_dict.keys():
centroid_x.append(self.xgr[self.lgi == gid].mean())
centroid_y.append(self.ygr[self.lgi == gid].mean())
if plot_centroid:
plt.plot(centroid_x, centroid_y, linestyle='None',
marker=centroid_kwargs['marker'],
mfc=centroid_kwargs['mfc'],
mec=centroid_kwargs['mec'],
ms=centroid_kwargs['ms'])
if plot_gid_number:
for i, (cenx, ceny) in enumerate(zip(centroid_x,
centroid_y), start=1):
plt.text(cenx, ceny, str(i),
fontsize=gid_text_kwargs['fontsize'])
ax = plt.gca()
ax.set_title('Title', fontsize=10)
ax.set_xlabel(r"X-axis, $\mu m$", fontsize=10)
ax.set_ylabel(r"Y-axis, $\mu m$", fontsize=10)
plt.colorbar()
# -------------------
if plot and throw_axis:
return {'lgi': LGI, 'ax': ax}
else:
return {'lgi': LGI, 'ax': None}
[docs]
def merge_two_neigh_grains_simple(self, method_id='1',
method_params_parent_sel=['area'], method_params_other_sel=['area'],
method_params_merging=['area'], parent_gid=[], return_gids=True,
plot_gs_bf=True, plot_gs_af=True, plot_area_kde_diff=True, bandwidth=1.0):
"""
Find two random neighbouring grains and merge them.
Parameters
----------
method_id: int
0: parenmt_gid will be selected at random and other_gid will also
be selected at random.
1: parent_gid should be provided by user and othet_gid should also
be provided by user.
2: parent_gid sahould be provide by user and other_gid will be
selected at random.
NOTE: other_grain will allways be O(1) neighbour of parent_grain.
method_params_parent_sel: str
Morphological parameter of choice for parent grain selection.
method_params_other_sel: str
Morphological parameter of choice for other grain selection.
method_params_merging: str
Morphological parameter of choice for merging decision makjing.
plot_bf: bool
Plot grain structure before merging. Defaults to True.
plot_af: bool
Plot grain structure after merging. Defaults to True.
Returns
-------
gids: list
[parent_gid, other_gid]. Other_gid merged into parent_gid.
"""
def plot_kde_difference(area1, area2, bandwidth=1):
"""
Calculates KDEs for two arrays of data and plots their difference.
Parameters
----------
area1: np.ndarray
The first array of data.
area2: np.ndarray
The second array of data.
bandwidth: float, optional
The bandwidth (smoothing parameter) for KDEs (default: 0.2).
"""
plt.figure(figsize=(5,5), dpi=120)
kde1 = sns.kdeplot(area1, bw_adjust=bandwidth, fill=True, label='Area 1', color='blue')
kde2 = sns.kdeplot(area2, bw_adjust=bandwidth, fill=True, label='Area 2', color='orange')
# Get the KDE curve data
x1, y1 = kde1.get_lines()[0].get_data()
x2, y2 = kde2.get_lines()[0].get_data()
# Interpolate if x values don't exactly match (to ensure we can subtract)
y2_interp = np.interp(x1, x2, y2)
# Calculate and plot the difference
y_diff = y1 - y2_interp
plt.fill_between(x1, y_diff, 0, color='green', alpha=0.5, label='Difference (Area 1 - Area 2)')
# Label axes and add a title
plt.xlabel('Area')
plt.ylabel('Density')
plt.title('KDEs of area distributions and their difference.')
plt.legend()
plt.show()
# ============================================================
if method_id == '1':
'''parenmt_gid will be selected at random and other_gid will also
be selected at random. NOTE: other_grain will allways be O(1)
neighbour of parent_grain.'''
parent_gid, other_gid = self.get_two_rand_o1_neighs()
if method_id == '2':
'''parent_gid should be provided by user and othet_gid should also
be provided by user.'''
parent_gid = parent_gid
other_gid = self.get_o1_neigh(parent_gid)
parent_gid, other_gid = self.get_two_rand_o1_neighs()
if method_id == '3':
'''parent_gid sahould be provide by user and other_gid will be
selected at random. NOTE: other_grain will allways be O(1)
neighbour of parent_grain.'''
pass
if method_id == '1-stat($varper$)':
'''method1 + more. Below provides deytails.
stat: statistic. Valids: mean, median.
varper: percentage variation in the stat defining target area
bound for parent_gid.
'''
pass
# -------------------------------------
if plot_gs_bf:
self.plotgs(plot_centroid=True, plot_gid_number=True, plot_cbar=False,
title=f'Before merging {other_gid} into {parent_gid}.')
# -------------------------------------
if plot_area_kde_diff:
# Get area before merging
area_bf = self.prop['area'].to_numpy()
# -------------------------------------
self.merge_two_neigh_grains(parent_gid, other_gid, check_for_neigh=False,
simple_merge=True,)
# -------------------------------------
if plot_gs_af:
self.plotgs(plot_centroid=True, plot_gid_number=True, plot_cbar=False,
title=f'After merging {other_gid} into {parent_gid}')
# -------------------------------------
if plot_area_kde_diff:
area_af = deepcopy(area_bf)
area_af[parent_gid-1] += area_af[other_gid-1]
area_af = np.delete(area_af, other_gid-1)
# Get area after merging
plot_kde_difference(area_bf, area_af, bandwidth=bandwidth)
# -------------------------------------
if return_gids:
return parent_gid, other_gid
[docs]
def merge_neigh_grains(self, gid_pairs, check_for_neigh=True, simple_merge=True):
"""
Merge multiple pairs of neighbouring grains.
Parameters
----------
gid_pairs: dth.dt.ITERABLES
Iterable of tuples containing pairs of grain IDs to be merged.
Each tuple should be in the form (parent_gid, other_gid).
check_for_neigh: bool
If True, each pair will be checked for neighbourhood before
merging. Defaults to True.
simple_merge: bool
If True, simple merging will be performed. Defaults to True.
Returns
-------
None
"""
hit = 0
for parent_gid, other_gid in gid_pairs:
if self.check_for_neigh(parent_gid, other_gid):
self.merge_two_neigh_grains(parent_gid, other_gid,
check_for_neigh=check_for_neigh, simple_merge=simple_merge)
[docs]
def set_twingen(self, vf=0.2, tspec='absolute', trel='minil',
tdis='user', t=[0.2, 0.5, 0.6, 0.7], tw=[1, 1, 1, 1],
tmin=0.2, tmean=0.5, tmax=1.0,
nmax_pg=1, placement='centroid', factor_min=0.0,
factor_max=1.0,):
"""
Set twin generation parameters.
Parameters
----------
vf: float
Twin volume fraction.
tspec: str
Twin thickness specification. Valid choices:
'absolute': Absolute thickness values will be used.
'relative': Relative thickness values will be used.
'minil': Thickness values will be specified as multiples of
minimum inter-lattice distance.
trel: str
Twin thickness relation. Valid choices:
'minil': Thickness values will be specified as multiples of
minimum inter-lattice distance.
'grain_size': Thickness values will be specified as multiples of
grain size.
tdis: str
Twin thickness distribution. Valid choices:
'user': User provided thickness values will be used.
'normal': Normal distribution will be used.
'lognormal': Log-normal distribution will be used.
'uniform': Uniform distribution will be used.
t: dth.dt.ITERABLES
List of twin thickness values. Only valid if tdis is 'user'.
tw: dth.dt.ITERABLES
List of twin weights corresponding to the twin thickness values.
Only valid if tdis is 'user'.
tmin: float
Minimum twin thickness. Only valid if tdis is not 'user'.
tmean: float
Mean twin thickness. Only valid if tdis is not 'user'.
tmax: float
Maximum twin thickness. Only valid if tdis is not 'user'.
nmax_pg: int
Maximum number of twins per grain.
placement: str
Twin placement method. Valid choices:
'centroid': Twins will be placed at the centroid of the grain.
'random': Twins will be placed at random locations within the
grain.
factor_min: float
Minimum factor for twin placement. Only valid if placement is
'random'.
factor_max: float
Maximum factor for twin placement. Only valid if placement is
'random'.
Returns
-------
None
"""
self.twingen = twingen(vf=0.2, tmin=0.2, tmean=0.5, tmax=1.0,
tdis='user', tvalues=[0.2, 0.5, 1.0, 0.75],
allow_partial=True, partial_prob=0.2)
[docs]
def introduce_single_twins(self, GIDs=[1], full_twin=True,
throw_lgi=True,
LFAL_kwargs={'factor': 0.5,
'angle_min': 0,
'angle_max': 360,
'length': 50},
twdis_kwargs={'max_count_per_grain': 1,
'min_thickness': 4.5,
'mean_thickness': 4.5,
'max_thickness': 4.5,
'distribution': 'normal',
'variance': 1.0,
},
plotgs_original=True,
plotgs_twinned=True,
save_to_features=True,
):
"""
Introduce twinned grain features into self.lgi.
This method creates twin lamellae within specified grains by defining slip lines
supports visualization of grain structures before and after twin introduction,
and can optionally store the twin features for later analysis.
Parameters
----------
GIDs : list of int, optional
List of grain IDs to be twinned. Default is [1].
full_twin : bool, optional
will be introduced. Default is True.
throw_lgi : bool, optional
Default is True.
LFAL_kwargs : dict, optional
Keyword arguments for the slip line 2D class method by_LFAL.
Default is {'factor': 0.5, 'angle_min': 0, 'angle_max': 360, 'length': 50}.
- factor : float
Scale factor for line generation
- angle_min : float
Minimum angle in degrees
- angle_max : float
Maximum angle in degrees
- length : int
Length of the generated line
twdis_kwargs : dict, optional
Keyword arguments for twin thickness distribution. Default parameters:
- max_count_per_grain : int
Maximum number of twin lamellae per grain
- min_thickness : float
Minimum thickness of twin lamellae in micrometers
- mean_thickness : float
Mean thickness of twin lamellae in micrometers
- max_thickness : float
Maximum thickness of twin lamellae in micrometers
- distribution : str
Type of distribution ('normal', etc.)
- variance : float
Variance of the distribution
plotgs_original : bool, optional
If True, plots the original grain structure before twin introduction.
Default is True.
plotgs_twinned : bool, optional
Default is True.
save_to_features : bool, optional
Default is True.
None or numpy.ndarray
Returns None by default. If throw_lgi is True, returns the modified
Local Grain Index (LGI) array as a numpy array with twin regions
marked as -1.
Examples
--------
.. code-block:: python
from upxo.ggrowth.mcgs import mcgs
mcgs = mcgs(study='independent', input_dashboard='input_dashboard.xls')
mcgs.simulate()
mcgs.detect_grains()
mcgs.gs[35].char_morph_2d(bbox=True, bbox_ex=True, area=True,
aspect_ratio=True, perimeter=True, solidity=True,
make_skim_prop=True,)
mcgs.gs[35].prop.columns
mcgs.gs[35].find_neigh()
mcgs.gs[35].g[12]['grain'].coords
mcgs.gs[35].g[12]['grain'].centroid
GIDs, VALIND = mcgs.gs[35].get_gids_in_params_bounds(mpnames=['area'],
fx_stats=[np.mean],
pdslh=[[50, 50]],
plot_mprop=False
)
gids = GIDs['mpmapped']['area']
mcgs.gs[35].introduce_single_twins(GIDs=gids, full_twin=True,
throw_lgi=True, plotgs_original=False,
plotgs_twinned=True)
Notes
-----
- Twin regions are identified perpendicular to generated slip lines
- Twin indices are marked with value -1 in the output LGI array
- The method modifies self.lgi_1 to track twinned regions across multiple grains
- Visualization uses the plotgs method with custom colormap and grain ID labels
"""
# Validations
# -----------------------------------------
if plotgs_original:
self.plotgs(figsize=(6, 6), dpi=120,
cmap='coolwarm', plot_centroid=True,
centroid_kwargs={'marker': 'o', 'mfc': 'yellow',
'mec': 'black', 'ms': 2.5},
plot_gid_number=True)
# -----------------------------------------
gscoords = (self.xgr.ravel(), self.ygr.ravel())
LGI_1 = deepcopy(self.lgi)
# -----------------------------------------
for gid in GIDs:
LGI = deepcopy(self.lgi).ravel()
xc = self.xgr[self.lgi == gid].mean()
yc = self.ygr[self.lgi == gid].mean()
remaining_indices = list(range(gscoords[0].size))
# -----------------------------------------
lines = [sl2d.by_LFAL(location=[xc, yc], **LFAL_kwargs)]
# -----------------------------------------
twin_indices = []
for line in lines:
_fx_ = line.find_neigh_point_by_perp_distance
_twin_indices_ = _fx_(gscoords, 4.5, use_bounding_rec=True)
if _twin_indices_:
twin_indices.append(_twin_indices_)
remaining_indices = list(set(remaining_indices) - set(_twin_indices_))
# -----------------------------------------
LGI[twin_indices[0]] = -1
LGI = np.reshape(LGI, self.lgi.shape)
LGI_1[(LGI == -1) & (LGI_1 == gid)] = -1
# -----------------------------------------
self.plotgs(figsize=(6, 6), dpi=120,
cmap='coolwarm', custom_lgi=LGI_1,
plot_centroid=True,
centroid_kwargs={'marker': 'o', 'mfc': 'yellow',
'mec': 'black', 'ms': 2.5},
plot_gid_number=True)
[docs]
def add_pxtal(self):
"""
Add a new polycrystal orientation map instance to the grain structure.
Returns
-------
None
"""
from upxo.pxtal.pxtal_ori_map_2d import polyxtal2d as PXTAL
if len(self.pxtal.keys()) == 0:
self.pxtal[1] = PXTAL()
else:
self.pxtal[max(list(self.pxtal.keys()))+1] = PXTAL()
[docs]
def set_pxtal(self, instance_no=1, path_filename_noext=None,
map_type='ebsd', apply_kuwahara=False, kuwahara_misori=5, gb_misori=10,
min_grain_size=1, print_closs=True,):
"""
Crystal Orientation Map. EBSD dataswt is one which can be loadsed.
Parameters
----------
instance_no: int
Instance number of the polycrystal orientation map to be set up.
Defaults to 1.
path_filename_noext: str
Path and filename without extension of the orientation map file.
For example, if the file is 'D:/data/map.ctf', then the
path_filename_noext should be 'D:/data/map'.
map_type: str
Type of orientation map file. Valid choices:
'ebsd': EBSD orientation map file.
apply_kuwahara: bool
If True, applies Kuwahara filter to the orientation map. Defaults to
False.
kuwahara_misori: float
Misorientation threshold for Kuwahara filter in degrees. Defaults to
5 degrees.
gb_misori: float
Grain boundary misorientation threshold in degrees. Defaults to
10 degrees.
min_grain_size: int
Minimum grain size in number of pixels. Defaults to 1.
print_closs: bool
If True, prints the conversion loss after setting up the
polycrystal orientation map. Defaults to True.
Returns
-------
None
Examples
--------
.. code-block:: python
from upxo.ggrowth.mcgs import mcgs
pxt = mcgs()
pxt.simulate()
pxt.detect_grains()
tslice = 20 # Temporal slice number
pxt.char_morph_2d(tslice)
pxt.gs[tslice].export_ctf(r'D:/export_folder', 'sunil')
path_filename_noext = r'D:/export_folder/sunil'
pxt.gs[tslice].set_pxtal(path_filename_noext=path_filename_noext)
pxt.gs[tslice].pxtal.map
"""
IN, _fn_ = instance_no, path_filename_noext
_khfflag_, _khfmo_ = apply_kuwahara, kuwahara_misori
# -------------------------------------------
self.add_pxtal()
print(_fn_)
self.pxtal[IN].setup(map_type='ebsd', path_filename_noext=_fn_,
apply_kuwahara=_khfflag_, kuwahara_misori=_khfmo_,)
self.pxtal[IN].find_grains_gb(gb_misori=gb_misori, min_grain_size=min_grain_size,
print_msg=True,)
self.pxtal[IN].port_essentials(print_msg=True)
# self.pxtal[IN].char_grain_positions_2d()
self.pxtal[IN].set_conversion_loss(refn=np.unique(self.lgi).size)
self.find_grain_boundary_junction_points(xorimap=True, IN=IN)
self.pxtal[IN].set_bjp()
self.pxtal[IN].find_neigh(update_gid=True, reset_lgi=False)
self.pxtal[IN].find_gbseg1()
[docs]
def make_linear_grid(self, sf=1):
"""
Make linear grid for interpolation.
Parameters
----------
sf: float
Scale factor for grid spacing.
Returns
-------
x: np.ndarray
1D array of x-coordinates.
y: np.ndarray
1D array of y-coordinates.
xinc: float
Increment in x-coordinates.
yinc: float
Increment in y-coordinates.
"""
# Validate for maximum sf
# -------------------------------------------------
# Make the base space
xinc, yinc = self.uigrid.xinc*sf, self.uigrid.yinc*sf
xmin, xmax, xinc = self.uigrid.xmin, self.uigrid.xmax, self.uigrid.xinc
ymin, ymax, yinc = self.uigrid.ymin, self.uigrid.ymax, self.uigrid.yinc
x = np.arange(xmin, xmax+xinc, xinc)
y = np.arange(ymin, ymax+yinc, yinc)
return x, y, xinc, yinc
[docs]
def scale(self, sf):
"""
Apply a scale factor to the current grain structure temporal slice.
Parameters
----------
sf: float
Scale factor to apply.
Returns
-------
None
"""
from scipy.interpolate import RegularGridInterpolator
# -------------------------------------------------
# VALIDATE input, f
# Make the base linear space
x, y, xinc, yinc = self.make_linear_grid(sf=1)
# Construct the inerpolator
intmeth = 'nearest'
interpolator = RegularGridInterpolator((x, y), self.s, method=intmeth)
# Make the updated linear space
_x_, _y_, _xinc_, _yinc_ = self.make_linear_grid(sf=sf)
# Make the new grid
_xgr_, _ygr_ = np.meshgrid(_x_, _y_)
# Interpolate state values
s = interpolator(np.array([_xgr_.flatten(), _ygr_.flatten()]).T)
s = s.reshape(len(_x_), len(_y_)).T
# ---------------------------------------------
# TODO: VALIDATE IF CREATED S DIMENTSIONS ARE CONSISTENT
# ---------------------------------------------
# Create a new grain structure database
self.scaled['sf'] = sf
self.scaled['xmin'], self.scaled['xmax'] = _x_.min(), _x_.max()
self.scaled['ymin'], self.scaled['ymax'] = _y_.min(), _y_.max()
self.scaled['xinc'], self.scaled['yinc'] = _xinc_, _yinc_
self.scaled.xgr, self.scaled.ygr, self.s = _xgr_, _ygr_, s
self.scaled.char_morph_2d()
if sf != 1:
self.__resolution_state__ = f'finer_sf={sf}'
[docs]
def coarser(self, Grid_Data, ParentStateMatrix, Factor, InterpMethod):
"""
Create a coarser grid from parent grid and parent state matrix.
Parameters
----------
Grid_Data: dict
Dictionary containing grid parameters of the parent grid.
ParentStateMatrix: np.ndarray
2D array representing the state matrix of the parent grid.
Factor: int
Factor by which to decrease the resolution.
InterpMethod: str
Interpolation method to use. Valid choices: 'nearest', 'linear',
'cubic'.
Returns
-------
cogrid_NG: tuple of np.ndarray
Tuple containing the new coordinate grid arrays (X, Y).
OSM_NG: np.ndarray
2D array representing the new orientation state matrix.
"""
# Use to decrease resolution
# Unpack parent grid parameters
xmin, xmax, xinc = Grid_Data['xmin'], Grid_Data['xmax'], Grid_Data['xinc']
ymin, ymax, yinc = Grid_Data['ymin'], Grid_Data['ymax'], Grid_Data['yinc']
# Reconstruct the parent co-ordinate grid
xvec_OG = np.arange(xmin, xmax+1, float(xinc)) # Parent grid axes
yvec_OG = np.arange(ymin, ymax+1, float(yinc)) # Parent grid axes
cogrid_OG = np.meshgrid(xvec_OG, yvec_OG, copy=True, sparse=False, indexing='xy') # grid
# Construct the new co-ordinate grid
xvec_NG = np.arange(xmin, xmax+1, float(xinc*Factor)) # NG: 'of' New grid
yvec_NG = np.arange(ymin, ymax+1, float(yinc*Factor))
cogrid_NG = np.meshgrid(xvec_NG, yvec_NG, copy=True, sparse=False, indexing='xy')
# Construct the new orientation state matrix
from scipy.interpolate import griddata
OSM_NG = np.round(griddata((np.concatenate(cogrid_OG[0]), np.concatenate(cogrid_OG[1])),
np.concatenate(ParentStateMatrix),
(np.concatenate(cogrid_NG[0]), np.concatenate(cogrid_NG[1])),
method=InterpMethod)
.reshape((xvec_NG.shape[0], yvec_NG.shape[0])))
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# print(abc.shape)
return cogrid_NG, OSM_NG
def __setup__positions__(self):
"""
Setup position categories for grains in 2D grain structure.
Returns
-------
None
"""
self.positions = {'top_left': [], 'bottom_left': [],
'bottom_right': [], 'top_right': [],
'pure_right': [], 'pure_bottom': [],
'pure_left': [], 'pure_top': [],
'left': [], 'bottom': [], 'right': [], 'top': [],
'boundary': [], 'corner': [], 'internal': [], }
[docs]
def char_grain_positions_2d(self):
"""
Characterize and categorize the spatial positions of grains in a 2D grain structure.
This method analyzes each grain's pixel locations to determine whether the grain
is positioned at the boundary, corner, or interior of the microstructure domain.
Grains are classified based on which edges of the image domain they touch.
Position Categories
-------------------
Corner Positions:
- 'top_left': Grain touches both top and left boundaries
- 'top_right': Grain touches both top and right boundaries
- 'bottom_left': Grain touches both bottom and left boundaries
- 'bottom_right': Grain touches both bottom and right boundaries
Edge Positions (not at corners):
- 'pure_top': Grain touches only the top boundary
- 'pure_bottom': Grain touches only the bottom boundary
- 'pure_left': Grain touches only the left boundary
- 'pure_right': Grain touches only the right boundary
Aggregate Positions:
- 'top': All grains touching top boundary (corner + pure_top)
- 'bottom': All grains touching bottom boundary (corner + pure_bottom)
- 'left': All grains touching left boundary (corner + pure_left)
- 'right': All grains touching right boundary (corner + pure_right)
- 'boundary': All grains touching any boundary
- 'corner': All grains at corners only
- 'internal': Grains not touching any boundary
Attributes Modified
-------------------
For each grain in self.g:
grain.position : list
[x_centroid, y_centroid, position_category_string]
self.positions : dict
Dictionary with position categories as keys and lists of grain IDs as values:
- Keys: 'top_left', 'bottom_left', 'bottom_right', 'top_right',
'pure_right', 'pure_bottom', 'pure_left', 'pure_top',
'left', 'bottom', 'right', 'top', 'boundary', 'corner', 'internal'
- Values: List of grain IDs (gids) belonging to each category
Returns
-------
None
Results are stored in grain.position attributes and self.positions dictionary
Notes
-----
- Legacy codes. To be updated with better implementation using defs in gid_ops module.
- A grain can belong to multiple aggregate categories simultaneously
- Internal grains are those with no pixels on any boundary
- Position determination is based on pixel-level analysis, not centroids
- Domain boundaries are defined by image array indices (0, row_max, col_max)
Examples
--------
.. code-block:: python
from upxo.ggrowth.mcgs import mcgs
pxt = mcgs(study='independent', input_dashboard='input_dashboard.xls')
pxt.simulate()
pxt.detect_grains()
pxt.gs[10].char_grain_positions_2d()
# Access corner grains
corner_grains = pxt.gs[10].positions['corner']
# Access internal grains
internal_grains = pxt.gs[10].positions['internal']
# Get position of a specific grain
grain_5_position = pxt.gs[10].g[5]['grain'].position
print(f"Grain 5 centroid: ({grain_5_position[0]:.2f}, {grain_5_position[1]:.2f})")
print(f"Grain 5 category: {grain_5_position[2]}")
See Also
--------
plot_grains_at_position : Visualize grains at specific positions
find_border_internal_grains_fast : Fast alternative for border/internal classification
"""
row_max, col_max = self.lgi.shape[0]-1, self.lgi.shape[1]-1
for grain in self:
# Calculate normalized centroids serving as numerical position
# values
grain.position = list(grain.centroid)
# Determine the location strings for all grains
all_pixel_locations = grain.loc.tolist()
apl = np.array(all_pixel_locations).T # all_pixel_locations
if 0 in apl[0]: # TOP
'''
grain touches either:
top and/or left boundary, OR, top and/or right boundary
'''
if 0 in apl[1]: # TOP AND LEFT
'''
BRANCH.1.A. Grain touches top and left boundary: top_left
grain. This means the grain is TOP_LEFT CORNER GRAIN
'''
grain.position.append('top_left')
elif col_max in apl[1]: # TOP AND RIGHT
'''
BRANCH.1.B. Grain touches top and right boundary: top_right
grain This means the grain is a TOP_RIGHT CORNER GRAIN
'''
grain.position.append('top_right')
else: # TOP, NOT LEFT, NOT RIGHT: //PURE TOP//
'''
BRANCH.1.C. Grain touches top boundary only and not the
corners of the top boundary. This means the grain is a
TOP GRAIN
'''
grain.position.append('pure_top')
if row_max in apl[0]: # BOTTOM
'''
grain touches either:
* bottom and/or left boundary, OR,
* bottom and/or right boundary
'''
if 0 in apl[1]: # BOTTOM AND LEFT
'''
BRANCH.2.A. Grain touches bottom and left boundary:
bot_left grain. This means the grain is BOTTOM_LEFT CORNER
GRAIN
'''
grain.position.append('bottom_left')
elif col_max in apl[1]: # BOTTOM AND RIGHT
'''
BRANCH.2.B. Grain touches bottom and right boundary:
bot_right grain. This means the grain is BOTTOM_RIGHT
CORNER GRAIN
'''
grain.position.append('bottom_right')
else: # BOTTOM, NOT LEFT, NOT RIGHT: //PURE BOTTOM//
'''
BRANCH.2.C. Grain touches only bottom boundary and not the
corners of the bottom boundary. This means the grain is a
BOTTOM GRAIN
'''
grain.position.append('pure_bottom')
if 0 in apl[1]: # LEFT
'''
grain touches either:
* left and/or top boundary, OR,
* left and/or bottom boundary
'''
if 0 in apl[0]: # LEFT AND TOP
'''
BRANCH.3.A. Grain touches left and top boundary: top_left
grain. This means the grain is LEFT_TOP CORNER GRAIN
'''
# THIS BRANCH HAS ALREADY BEEN VISITED IN BRANCH.1.A
# NOTHING MORE TO DO HERE. SKIP.
pass
elif row_max in apl[0]: # LEFT AND BOTTOM
'''
BRANCH.3.B. Grain touches left and bottom boundary:
bot_left grain. This means the grain is a LEFT_BOTTOM
CORNER GRAIN
'''
# THIS BRANCH HAS ALREADY BEEN VISISITED IN BRANCH.2.A
# NOTHING MORE TO DO HERE. SKIP.
pass
else: # LEFT, NOT TOP, NOT BOTTOM: //PURE LEFT//
'''
BRANCH.3.C. Grain touches left boundary only and not the
corners of the left boundary. This means the grain is a #
LEFT GRAIN
'''
grain.position.append('pure_left')
if col_max in apl[1]: # RIGHT
'''
grain touches either:
* right and/or top boundary, OR,
* right and/or bottom boundary
'''
if 0 in apl[0]: # RIGHT AND TOP
'''
BRANCH.4.A. Grain touches right and top boundary: top_right
grain. This means the grain is RIGHT_TOP CORNER GRAIN
'''
# THIS BRANCH HAS ALREADY BEEN VISITED IN BRANCH.1.B
# NOTHING MORE TO DO HERE. SKIP.
pass
elif row_max in apl[0]: # RIGHT AND BOTTOM
'''
BRANCH.4.B. Grain touches left and bottom boundary:
bot_left grain. This means the grain is a RIGHT_BOTTOM
CORNER GRAIN
'''
# THIS BRANCH HAS ALREADY VBEEN VISISITED IN BRANCH.2.B
# NOTHING MORE TO DO HERE. SKIP.
pass
else: # RIGHT, NOT TOP, NOT BOTTOM: //PURE RIGHT//
'''
BRANCH.4.C. Grain touches left boundary only and not the
corners of the left boundary. This means the grain is a
RIGHT GRAIN
'''
grain.position.append('pure_right')
if 0 not in apl[0] and row_max not in apl[0]:
# NOT TOP, NOT BOTTOM
if 0 not in apl[1] and col_max not in apl[1]:
# NOT LEFT, NOT RIGHT
grain.position.append('internal')
for grain in self:
position = grain.position[2]
gid = grain.gid
_ = [position == 'top_left', position == 'bottom_left',
position == 'bottom_right', position == 'top_right',
position == 'pure_right', position == 'pure_bottom',
position == 'pure_left', position == 'pure_top',
position == 'left', position == 'bottom',
position == 'right', position == 'top',
position == 'boundary', position == 'corner',
position == 'internal']
self.positions[[_*position for _ in _ if _*position][0]].append(gid)
for pos in ['top_left', 'bottom_left', 'pure_left']:
if self.positions[pos]:
for value in self.positions[pos]:
self.positions['left'].append(value)
for pos in ['bottom_left', 'pure_bottom', 'bottom_right']:
if self.positions[pos]:
for value in self.positions[pos]:
self.positions['bottom'].append(value)
for pos in ['bottom_right', 'pure_right', 'top_right']:
if self.positions[pos]:
for value in self.positions[pos]:
self.positions['right'].append(value)
for pos in ['top_right', 'pure_top', 'top_left']:
if self.positions[pos]:
for value in self.positions[pos]:
self.positions['top'].append(value)
for pos in ['top_left', 'bottom_left', 'bottom_right', 'top_right']:
if self.positions[pos]:
for value in self.positions[pos]:
self.positions['corner'].append(value)
for pos in ['top_left', 'bottom_left', 'bottom_right', 'top_right',
'pure_left', 'pure_bottom', 'pure_right', 'pure_top'
]:
if self.positions[pos]:
for value in self.positions[pos]:
self.positions['boundary'].append(value)
[docs]
def char_grain_positions_2d_v1(self):
"""
Characterize and categorize the spatial positions of grains in a 2D grain structure.
This method analyzes each grain's pixel locations to determine whether the grain
is positioned at the boundary, corner, or interior of the microstructure domain.
Grains are classified based on which edges of the image domain they touch.
Position Categories
-------------------
Corner Positions:
- 'top_left': Grain touches both top and left boundaries
- 'top_right': Grain touches both top and right boundaries
- 'bottom_left': Grain touches both bottom and left boundaries
- 'bottom_right': Grain touches both bottom and right boundaries
Edge Positions (not at corners):
- 'pure_top': Grain touches only the top boundary
- 'pure_bottom': Grain touches only the bottom boundary
- 'pure_left': Grain touches only the left boundary
- 'pure_right': Grain touches only the right boundary
Aggregate Positions:
- 'top': All grains touching top boundary (corner + pure_top)
- 'bottom': All grains touching bottom boundary (corner + pure_bottom)
- 'left': All grains touching left boundary (corner + pure_left)
- 'right': All grains touching right boundary (corner + pure_right)
- 'boundary': All grains touching any boundary
- 'corner': All grains at corners only
- 'internal': Grains not touching any boundary
Algorithm
---------
For each grain:
1. Extract all pixel locations belonging to the grain
2. Check if any pixels lie on domain boundaries (row=0, row=max, col=0, col=max)
3. Classify based on which boundaries are touched
4. Store classification in grain.position attribute (list format: [x_centroid, y_centroid, position_string])
Attributes Modified
-------------------
For each grain in self.g:
grain.position : list
[x_centroid, y_centroid, position_category_string]
self.positions : dict
Dictionary with position categories as keys and lists of grain IDs as values:
- Keys: 'top_left', 'bottom_left', 'bottom_right', 'top_right',
'pure_right', 'pure_bottom', 'pure_left', 'pure_top',
'left', 'bottom', 'right', 'top', 'boundary', 'corner', 'internal'
- Values: List of grain IDs (gids) belonging to each category
Returns
-------
None
Results are stored in grain.position attributes and self.positions dictionary
Notes
-----
- A grain can belong to multiple aggregate categories simultaneously
- Internal grains are those with no pixels on any boundary
- Position determination is based on pixel-level analysis, not centroids
- Domain boundaries are defined by image array indices (0, row_max, col_max)
Examples
--------
.. code-block:: python
from upxo.ggrowth.mcgs import mcgs
pxt = mcgs(study='independent', input_dashboard='input_dashboard.xls')
pxt.simulate()
pxt.detect_grains()
pxt.gs[10].char_grain_positions_2d()
# Access corner grains
corner_grains = pxt.gs[10].positions['corner']
# Access internal grains
internal_grains = pxt.gs[10].positions['internal']
# Get position of a specific grain
grain_5_position = pxt.gs[10].g[5]['grain'].position
print(f"Grain 5 category: {grain_5_position}[2]}")
print(f"Grain 5 category: {grain_5_position[2]}")
See Also
--------
plot_grains_at_position : Visualize grains at specific positions
find_border_internal_grains_fast : Fast alternative for border/internal classification
"""
self.positions = mcharOps.classify_grain_positions_2d(self.lgi, self.gid)
pos = self.positions
for grain in self:
gid = grain.gid
for category in ('top_left', 'top_right', 'bottom_left', 'bottom_right',
'pure_top', 'pure_bottom', 'pure_left', 'pure_right', 'internal'):
if gid in pos[category]:
grain.position = category
break
[docs]
def find_border_internal_grains_fast(self):
"""
Identify border and internal grains.
Quickly find border and internal grains without doing anything else.
Returns
-------
border_gids: Numpy array of border grain IDs.
internal_gids: Numpy array of internal grain IDs.
lgi_border: Numpy array of Local Grain Index (LGI) with only border
grains.
lgi_internal: Numpy array of Local Grain Index (LGI) with only internal
grains.
Examples
--------
.. code-block:: python
border_gids, internal_gids, lgi_border, lgi_internal = find_border_internal_grains_fast()
plt.figure()
plt.imshow(lgi_border)
plt.figure()
plt.imshow(lgi_internal)
"""
lgi = self.lgi
lgi_border = deepcopy(lgi)
lgi_border[1:-1, 1:-1] = 0
border_gids = np.unique(lgi_border[lgi_border != 0])
internal_gids = np.array(list(set(self.gid) - set(border_gids)))
lgi_border = deepcopy(lgi)
lgi_internal = deepcopy(lgi)
for bgid in border_gids:
lgi_internal[lgi_internal == bgid] = 0
for nbgid in internal_gids:
lgi_border[lgi_border == nbgid] = 0
return border_gids, internal_gids, lgi_border, lgi_internal
[docs]
def find_grain_size_fast(self, metric='npixels', recalculate_gid=False):
"""
Quickly find the grain sizes without doing anything else.
Notes
-----
Order of grain_sizes is that of pxtal.gs[m].gid
Parameters
----------
metric: Specify which ares metric is needed. Options include:
* 'npixels': Number of pixels
* 'pxarea': Pixel wise calculated area
* 'eq_dia': Equivalent diameter
Returns
-------
grain_sizes: Numpy array of grain areas.
Examples
--------
.. code-block:: python
from upxo.ggrowth.mcgs import mcgs
pxtal = mcgs(study='independent', input_dashboard='input_dashboard.xls')
pxtal.simulate()
pxtal.detect_grains()
grain_areas_all_grains = pxtal.gs[2].find_grain_size_fast(metric='npixels')
"""
if recalculate_gid:
self.gid = np.unique(self.lgi)
self.n = len(self.gid)
if len(self.gid) == 0:
return np.array([])
max_gid = np.max(self.gid)
counts = np.bincount(self.lgi.ravel(), minlength=max_gid + 1)
pixel_counts = counts[self.gid]
if metric == 'npixels':
grain_size = pixel_counts
elif metric == 'pxarea':
grain_size = pixel_counts*(self.uigrid.px_size**2)
elif metric == 'eq_dia':
area = pixel_counts*(self.uigrid.px_size**2)
grain_size = 2*np.sqrt(area/np.pi)
return np.array(grain_size)
[docs]
def find_npixels_border_grains_fast(self, metric='npixels'):
"""
Find the number of pixels in each of the border grains.
Parameters
----------
metric: Specify which ares metric is needed. Options include:
* 'npixels': Number of pixels
* 'pxarea': Pixel wise calculated area
* 'eq_dia': Equivalent diameter
Returns
-------
border_grain_npixels: Numpy array of number of pixels in each border
grain.
Examples
--------
.. code-block:: python
from upxo.growth.mcgs import mcgs
pxtal = mcgs(study='independent',input_dashboard='input_dashboard.xls')
pxtal.simulate()
pxtal.detect_grains()
grain_areas_border_grains = pxtal.gs[2].find_npixels_border_grains_fast(metric='npixels')
"""
border_gids, _, __, ___ = self.find_border_internal_grains_fast()
counts = np.bincount(self.lgi.ravel(), minlength=self.gid.max() + 1)
pixel_counts = counts[self.gid]
if metric == 'npixels':
grain_size = pixel_counts
elif metric == 'pxarea':
grain_size = pixel_counts*(self.uigrid.px_size**2)
elif metric == 'eq_dia':
area = pixel_counts*(self.uigrid.px_size**2)
grain_size = 2*np.sqrt(area/np.pi)
border_grain_size = []
for bg in border_gids:
border_grain_size.append(grain_size[bg-1])
return np.array(border_grain_size)
[docs]
def find_npixels_internal_grains_fast(self, metric='npixels'):
"""
Find the number of pixels in each of the internal grains.
Parameters
----------
metric: Specify which ares metric is needed. Options include:
* 'npixels': Number of pixels
* 'pxarea': Pixel wise calculated area
* 'eq_dia': Equivalent diameter
Returns
-------
internal_grain_npixels: Numpy array of number of pixels in each
internal grain.
Examples
--------
.. code-block:: python
from upxo.ggrowth.mcgs import mcgs
pxtal = mcgs(study='independent',input_dashboard='input_dashboard.xls')
pxtal.simulate()
pxtal.detect_grains()
grain_areas_internal_grains = pxtal.gs[2].find_npixels_internal_grains_fast(metric='npixels')
"""
_, internal_grains, __, ___ = self.find_border_internal_grains_fast()
counts = np.bincount(self.lgi.ravel(), minlength=self.gid.max() + 1)
pixel_counts = counts[self.gid]
if metric == 'npixels':
grain_size = pixel_counts
elif metric == 'pxarea':
grain_size = pixel_counts*(self.uigrid.px_size**2)
elif metric == 'eq_dia':
area = pixel_counts*(self.uigrid.px_size**2)
grain_size = 2*np.sqrt(area/np.pi)
border_grain_size = []
for ig in internal_grains:
border_grain_size.append(grain_size[ig-1])
return np.array(border_grain_size)
internal_grain_npixels = []
if metric in ('npixels'):
for ig in internal_grains:
internal_grain_npixels.append(np.where(self.lgi == ig)[0].size)
return np.array(internal_grain_npixels)
[docs]
def find_ags(self, grains_to_include='all', gids=None, method='npixels'):
"""
Find average grain size of the grain structure.
Parameters
----------
grains_to_include: str
Specify which grains to include in the average grain size
calculation. Options include:
* 'all': Include all grains.
* 'border': Include only border grains.
* 'internal': Include only internal grains.
* 'gid' or 'gids': Include only grains with specified GIDs.
gids: list or np.ndarray, optional
List or array of grain IDs to include if grains_to_include is
'gid' or 'gids'. Defaults to None.
method: str
Specify which area metric to use for average grain size calculation.
Options include:
* 'npixels': Number of pixels.
* 'pxarea': Pixel wise calculated area.
* 'eq_dia': Equivalent diameter.
Returns
-------
ags: float
Average grain size based on the specified criteria.
Examples
--------
.. code-block:: python
from upxo.ggrowth.mcgs import mcgs
pxtal = mcgs(study='independent',input_dashboard='input_dashboard.xls')
pxtal.simulate()
pxtal.detect_grains()
ags_all = pxtal.gs[2].find_ags(grains_to_include='all', method='npixels')
ags_border = pxtal.gs[2].find_ags(grains_to_include='border', method='npixels')
ags_internal = pxtal.gs[2].find_ags(grains_to_include='internal', method='npixels')
ags_specific = pxtal.gs[2].find_ags(grains_to_include='gids', gids=[1,2,3], method='npixels')
"""
if grains_to_include == 'all':
pass
elif grains_to_include == 'border':
pass
elif grains_to_include == 'internal':
pass
elif grains_to_include in ('gid', 'gids'):
pass
return ags
[docs]
def find_prop_npixels(self):
"""
Get grain NUMBER OF PIXELS into pandas dataframe
Returns
-------
None
"""
self.prop['npixels'] = mcharOps.extract_prop_npixels(self._collect_locs())
if self.display_messages:
print(' Number of Pixels making the grains: DONE')
[docs]
def find_prop_npixels_gb(self):
"""
Get grain GRAIN BOUNDARY LENGTH (NO. PIXELS) into pandas dataframe
Returns
-------
None
"""
self.prop['npixels_gb'] = mcharOps.extract_prop_gb_pixels(self._collect_gblocs())
[docs]
def find_prop_gb_length_px(self):
"""
Get grain GRAIN BOUNDARY LENGTH (NO. PIXELS) into pandas dataframe
Returns
-------
None
"""
self.prop['gb_length_px'] = mcharOps.extract_prop_gb_pixels(self._collect_gblocs())
[docs]
def find_prop_area(self):
"""
Get grain AREA into pandas dataframe
Returns
-------
None
"""
self.prop['area'] = mcharOps.extract_prop_area(self._collect_skprops())
[docs]
def find_prop_eq_diameter(self):
"""
Get grain EQUIVALENT DIAMETER into pandas dataframe
Returns
-------
None
"""
self.prop['eq_diameter'] = mcharOps.extract_prop_eq_diameter(self._collect_skprops())
[docs]
def find_prop_perimeter(self):
"""
Get grain PERIMETER into pandas dataframe
Returns
-------
None
"""
self.prop['perimeter'] = mcharOps.extract_prop_perimeter(self._collect_skprops())
[docs]
def find_prop_perimeter_crofton(self):
"""
Get grain CROFTON PERIMETER into pandas dataframe
Returns
-------
None
"""
self.prop['perimeter_crofton'] = mcharOps.extract_prop_perimeter_crofton(self._collect_skprops())
[docs]
def find_prop_compactness(self):
"""
Get grain COMPACTNESS into pandas dataframe
Returns
-------
None
"""
skp = self._collect_skprops()
a = list(self.prop['area']) if 'area' in self.prop.columns else mcharOps.extract_prop_area(skp)
p = list(self.prop['perimeter']) if 'perimeter' in self.prop.columns else mcharOps.extract_prop_perimeter(skp)
self.prop['compactness'] = mcharOps.extract_prop_compactness(a, p, EPS=self.EPS)
[docs]
def find_prop_aspect_ratio(self):
"""
Get grain ASPECT RATIO into pandas dataframe
Returns
-------
None
"""
self.prop['aspect_ratio'] = mcharOps.extract_prop_aspect_ratio(
self._collect_skprops(), EPS=self.EPS)
[docs]
def find_prop_solidity(self):
"""
Get grain SOLIDITY into pandas dataframe
Returns
-------
None
"""
self.prop['solidity'] = mcharOps.extract_prop_solidity(self._collect_skprops())
[docs]
def find_prop_circularity(self):
"""
Get grain CIRCULARITY into pandas dataframe
Returns
-------
None
"""
# if self.prop_flag['circularity']:
circularity = []
[docs]
def find_prop_major_axis_length(self):
"""
Get grain MAJOR AXIS LENGTH into pandas dataframe
Returns
-------
None
"""
self.prop['major_axis_length'] = mcharOps.extract_prop_major_axis_length(self._collect_skprops())
[docs]
def find_prop_minor_axis_length(self):
"""
Get grain MINOR AXIS LENGTH into pandas dataframe
Returns
-------
None
"""
self.prop['minor_axis_length'] = mcharOps.extract_prop_minor_axis_length(self._collect_skprops())
[docs]
def find_prop_morph_ori(self):
"""
Get grain MORPHOLOGICAL ORIENTATION into pandas dataframe
Returns
-------
None
"""
self.prop['morph_ori'] = mcharOps.extract_prop_morph_ori(self._collect_skprops())
[docs]
def find_prop_feret_diameter(self):
"""
Get grain FERET DIAMETER into pandas dataframe
Returns
-------
None
"""
self.prop['feret_diameter'] = mcharOps.extract_prop_feret_diameter(self._collect_skprops())
[docs]
def find_prop_euler_number(self):
"""
Get grain EULER NUMBER into pandas dataframe
Returns
-------
None
"""
self.prop['euler_number'] = mcharOps.extract_prop_euler_number(self._collect_skprops())
[docs]
def find_prop_eccentricity(self):
"""
Get grain ECCENTRICITY into pandas dataframe
Returns
-------
None
"""
self.prop['eccentricity'] = mcharOps.extract_prop_eccentricity(self._collect_skprops())
def _collect_skprops(self):
"""Return {fid: skprop} from self.g regardless of _char_fx_version_."""
if self._char_fx_version_ == 1:
return {gid: g['grain'].skprop for gid, g in self.g.items()}
return {gid: g.skprop for gid, g in self.g.items()}
def _collect_locs(self):
"""Return list of per-grain pixel-location arrays from self.g."""
if self._char_fx_version_ == 1:
return [g['grain'].loc for g in self.g.values()]
return [g.loc for g in self.g.values()]
def _collect_gblocs(self):
"""Return list of per-grain boundary pixel-location arrays from self.g."""
if self._char_fx_version_ == 1:
return [g['grain'].gbloc for g in self.g.values()]
return [g.gbloc for g in self.g.values()]
[docs]
def build_prop(self, correct_aspect_ratio=False):
"""
Build the grain structure properties as requested by user in the
'prop_flag' attribute.
Returns
-------
None
"""
skprops = self._collect_skprops()
locs_list = self._collect_locs() if self.prop_flag.get('npixels') else None
gblocs_list = (self._collect_gblocs()
if (self.prop_flag.get('npixels_gb') or self.prop_flag.get('gb_length_px'))
else None)
props = mcharOps.build_grain_props(skprops, self.prop_flag,
locs_list=locs_list, gblocs_list=gblocs_list,
EPS=self.EPS)
for key, vals in props.items():
self.prop[key] = vals
# ------------------------------------------
if correct_aspect_ratio:
if not all(c in self.prop.columns for c in
('area', 'aspect_ratio', 'major_axis_length', 'minor_axis_length')):
print('Need area, aspect_ratio, major_axis_length, minor_axis_length',
'to correct aspect ratio. Skipping aspect ratio correction.')
else:
df = deepcopy(self.prop)
df.loc[df['area'] == 1, ['major_axis_length', 'minor_axis_length', 'aspect_ratio']] = 1
df.loc[df['minor_axis_length'] == 0, 'minor_axis_length'] = 1
df.loc[df['minor_axis_length'] == 1, 'aspect_ratio'] = df['major_axis_length']
self.prop = df
[docs]
def get_stat(self, PROP_NAME, saa=True, throw=False, ):
"""
Calculates ths statistics of a property in the 'prop' attribute.
Notes
-----
Input data is not sanitised before calculating the statistics.
Will results in an error if invalid entries are found.
Parameters
----------
PROP_NAME : str
Name of the property, whos statistics is to be calculated. They
could be from the following list:
1. npixels
2. npixels_gb
3. area
4. eq_diameter
5. perimeter
6. perimeter_crofton
7. compactness
8. gb_length_px
9. aspect_ratio
10. solidity
11. morph_ori
12. circularity
13. eccentricity
14. feret_diameter
15. major_axis_length
16. minor_axis_length
17. euler_number
saa : bool, optional
Flag to save the statistics as attribute.
The default is True.
throw : bool, optional
Flag to return the computed statistics.
The default is False.
Returns
-------
metrics : TYPE
DESCRIPTION.
Notes
-----
Following statistical metrics will be calculated:
count: Data count value
mean: Mean of the data
std: Standard deviation of the data
min: Minimum value of the data
25%: First quartile of the data
50%: Second quartile of the data
75%: Third quartile of the data
max: Maximum value of the data
median: Median value of the data
mode: List of modes of the data
var: Variance of the data
skew: Skewness of the data
kurt: Kurtosis of the data
nunique: Number of unique values in the data
sem: Standard error of the mean of the data
Examples
--------
.. code-block:: python
PXGS.gs[4].extract_statistics_prop('area')
"""
# Extract the values of the PROP_NAME
values = np.array(self.prop[PROP_NAME])
# Extract non-inf subset
values = values[np.where(values != np.inf)[0]]
# Make the values dataframe
import pandas as pd
values_df = pd.DataFrame(columns=['temp'])
values_df['temp'] = values
# Extract basic statistics
values_stats = values_df.describe()
metrics = {'PROP_NAME': PROP_NAME,
'count': values_stats['temp']['count'],
'mean': values_stats['temp']['mean'],
'std': values_stats['temp']['std'],
'min': values_stats['temp']['min'],
'25%': values_stats['temp']['25%'],
'50%': values_stats['temp']['50%'],
'75%': values_stats['temp']['75%'],
'max': values_stats['temp']['max'],
'median': values_df['temp'].median(),
'mode': [i for i in values_df['temp'].mode()],
'var': values_df['temp'].var(),
'skew': values_df['temp'].skew(),
'kurt': values_df['temp'].kurt(),
'nunique': values_df['temp'].nunique(),
'sem': values_df['temp'].sem(),
}
if saa:
self.prop_stat = metrics
if throw:
return metrics
[docs]
def make_valid_prop(self, PROP_NAME='aspect_ratio',
rem_nan=True, rem_inf=True, PROP_df_column=None, ):
"""
Remove invalid entries from a column in a Pandas dataframe and
returns sanitized pandas column with the PROP_NAME as column name
Parameters
----------
PROP_NAME : str, optional
Property to be cleansed. The default is 'aspect_ratio'.
rem_nan : TYPE, optional
Boolean flag to remove np.nan. The default is True.
rem_inf : TYPE, optional
Boolean flag to remove np.inf. Both negative and positive inf
will be removed. The default is True.
Returns
-------
subset : pd.data_frame
A single column pandas dataframe with cleansed values.#
ratio : float
Ratio of total number of values removed to the size of the property
column in the self.prop dataframe
"""
if not PROP_df_column:
# This means internal data in prop atrtribute is to be cleaned
if hasattr(self, 'prop'):
if PROP_NAME in self.prop.columns:
_prop_size_ = self.prop[PROP_NAME].size
subset = self.prop[PROP_NAME]
subset = subset.replace([-np.inf,
np.inf],
np.nan).dropna()
ratio = (_prop_size_-subset.size)/_prop_size_
else:
subset, ratio = None, None
print(f"Property {PROP_NAME} has not been calculated in"
" temporal slice {self.m}")
else:
subset, ratio = None, None
print(f"Temporal slice {self.m} has no prop. Skipped")
else:
# This means the user provided single-colulmn pandas dataframe,
# named "PROP_df_column" is to be cleaned
# It will be assumed user has input valid dataframe column
_prop_size_ = PROP_df_column.size
PROP_df_column = PROP_df_column.replace([-np.inf,
np.inf],
np.nan).dropna()
ratio = (_prop_size_-PROP_df_column.size)/_prop_size_
return subset, ratio
[docs]
def s_prop(self,
s=1,
PROP_NAME='area'
):
"""
Extract state wise partitioned property. Property name has to be
specified by the user.
Parameters
----------
s : int, optional
Value of the
The default is 1.
PROP_NAME : TYPE, optional
DESCRIPTION. The default is 'area'.
Returns
-------
TYPE
DESCRIPTION.
# TODO
1: add validity checking layers for s and PROP_NAME
2: if s = 0, then any of the available be selected at random and
returned
3: if s = -1, then the state with the minimum number of grains
will be returned
4: if s = -2, then the state with the maximum number of grains
will be returned
"""
if hasattr(self, 'prop'):
if PROP_NAME in self.prop.columns:
if s in self.s_gid.keys():
# __ = self.make_valid_prop(rem_nan=True,
# rem_inf=True,
# PROP_df_column=self.prop[PROP_NAME], )
# PROP_VALUES_VALID = __
subset = self.prop[PROP_NAME].iloc[[i-1 for i in self.s_gid[s]]]
else:
subset = None
print(f"Temporal slice {self.m} has no grains in s:"
" {s}. Skipped")
else:
subset, ratio = None, None
print(f"Property {PROP_NAME} has not been calculated in "
"temporal slice {self.m}")
else:
print(f"Temporal slice {self.m} has no prop. Skipped")
return subset
[docs]
def get_gid_prop_range(self,
PROP_NAME='area',
reminf=True,
remnan=True,
range_type='percentage',
value_range=[1, 2],
percentage_range=[0, 20],
rank_range=[60, 90],
pivot=None):
"""
Get GIDs of grains whose property values fall within a specified
range.
Parameters
----------
PROP_NAME : str, optional
Name of the property to filter grains by. The default is 'area'.
reminf : bool, optional
Flag to remove infinite values from the property data. The default is
True.
remnan : bool, optional
Flag to remove NaN values from the property data. The default is
True.
range_type : str, optional
Type of range to use for filtering. Options are 'percentage',
'value', or 'rank'. The default is 'percentage'.
value_range : list, optional
List of two values specifying the min and max property values for
filtering when range_type is 'value'. The default is [1, 2].
percentage_range : list, optional
List of two values specifying the min and max percentage for
filtering when range_type is 'percentage'. The default is [0, 20].
rank_range : list, optional
List of two values specifying the min and max rank percentiles for
filtering when range_type is 'rank'. The default is [60, 90].
pivot : TYPE, optional
DESCRIPTION. The default is None.
Returns
-------
gids : np.ndarray
Numpy array of grain IDs (GIDs) that fall within the specified
property range.
A_B_values : np.ndarray
Numpy array of property values corresponding to the selected GIDs.
A_B_indices : pd.Index
Pandas Index of the selected GIDs.
Notes
-----
To understand how the sub-selection is done, consider the following
illustration of the property distribution:
PROP_min--inf--------A-----nan------B---nan----inf------PROP_max
1. clean data for inf and nans
2. Then subselect from A to PROP_max
3. Then subselect from A to B, which is what we need
Examples
--------
.. code-block:: python
gid, value, df_loc = PXGS.gs[8].get_gid_prop_range(PROP_NAME='aspect_ratio',
range_type='rank', value_range=[80, 100])
gid, value, df_loc = PXGS.gs[8].get_gid_prop_range(PROP_NAME='area',
range_type='percentage', value_range=[80, 100])
gid, value, df_loc = PXGS.gs[8].get_gid_prop_range(PROP_NAME='aspect_ratio',
range_type='value', value_range=[2, 2.5])
"""
gids, A_B_values, A_B_indices = [], [], []
if PROP_NAME in self.prop.columns:
PROPERTY = self.prop[PROP_NAME].replace([-np.inf, np.inf],
np.nan).dropna()
if range_type in ('percentage', '%',
'perc', 'by_percentage',
'by_perc', 'by%'
):
# If the user chooses to use percentage to describe the range
# Get the minimum and maximum of the property
PROP_min = PROPERTY.min()
PROP_max = PROPERTY.max()
# Calculate the fuill range if the proiperty
PROP_range_full = PROP_max - PROP_min
# Calculate the Lower cut-off
lco = min(percentage_range)*PROP_range_full/100
# Caluclate the upper cut-off
uco = max(percentage_range)*PROP_max/100
# w.r.t the the illustration in the DocString, subselect between A
# and PROP_max
A_MAX = self.prop[PROP_NAME][self.prop[PROP_NAME].index[self.prop[PROP_NAME] >= lco]]
A_B_indices = A_MAX.index[A_MAX <= uco]
A_B_values = A_MAX[A_B_indices].to_numpy()
gids = A_B_indices+1
elif range_type in ('value', 'by_value'):
# If the user chooses to use values to describe the range of
# objects
lco = min(value_range)
uco = max(value_range)
# w.r.t the the illustration in the DocString, subselect between A
# and PROP_max
A_MAX = self.prop[PROP_NAME][self.prop[PROP_NAME].index[self.prop[PROP_NAME] >= lco]]
A_B_indices = A_MAX.index[A_MAX <= uco]
A_B_values = A_MAX[A_B_indices].to_numpy()
gids = A_B_indices+1
elif range_type in ('rank', 'by_rank'):
'''
# TODO: debug for the case where two entered values are same
# TODO: Handle invalud user data
'''
values = self.prop[PROP_NAME]
_ = values.replace([-np.inf,
np.inf],
np.nan).dropna().sort_values(ascending=False)
indices = _.index
ptile_i, ptile_j = [100-max(rank_range), 100-min(rank_range)]
A_B_values = _[indices[int(ptile_i*_.size/100):int(ptile_j*_.size/100)]]
A_B_indices = A_B_values.index
gids = A_B_values.index.to_numpy()+1
return gids, A_B_values, A_B_indices
[docs]
def plot_largest_grain(self):
"""
Plot the largest grain in a temporal slice of a grain structure
Returns
-------
None.
# TODO: WRAP THIS INSIDE A FIND_LARGEST_GRAIN AND HAVE IT TRHOW
THE GID TO THE USER
"""
if 'area' in self.prop.columns:
gid = self.prop['area'].idxmax()+1
else:
areas = self.find_grain_size_fast(metric='npixels')
gid = 1
self.g[gid]['grain'].plot()
[docs]
def plot_longest_grain(self):
"""
A humble method to just plot the longest grain in a temporal slice
of a grain structure
Returns
-------
None.
# TODO: WRAP THIS INSIDE A FIND_LONGEST_GRAIN AND HAVE IT TRHOW
THE GID TO THE USER
"""
gids, _, _ = self.get_gid_prop_range(PROP_NAME='aspect_ratio',
range_type='percentage',
percentage_range=[100, 100],
)
# plt.imshow(self.g[gid[0]]['grain'].bbox_ex)
self.plot_grains_gids(list(gids))
#for _gid_ in gid:
# self.g[_gid_]['grain'].plot()
[docs]
def mask_lgi_with_gids(self, gids, masker=-10):
"""
Mask the lgi (PXGS.gs[n] specific lgi array: lattice of grain IDs)
against user input grain indices, with a default UPXO-reserved
place-holder value of -10.
Parameters
----------
gids : int/list
Either a single grain index number or list of them
kwargs:
masker:
An int value, preferably -10, but compulsorily less than -5.
Returns
-------
s_masked : np.ndarray(dtype=int)
lgi masked against gid values
Internal calls (@dev)
---------------------
None
"""
# -----------------------------------------
lgi_masked = deepcopy(self.lgi).astype(int)
print('========================================')
print(gids)
print('========================================')
for gid in gids:
if gid in self.gid:
lgi_masked[lgi_masked == gid] = masker
else:
print(f"Invalid gid: {gid}. Skipped")
# -----------------------------------------
return lgi_masked, masker
[docs]
def mask_s_with_gids(self, gids, masker=-10, force_masker=False):
"""
Mask the s (PXGS.gs[n] specific s array) against user input grain
indices
Parameters
----------
gids : int/list
Either a single grain index number or list of them
kwargs:
masker:
An int value, preferably -10.
force_masker:
This is here to satisfy the tussle of future development needs
and user-readiness!! Please go with it for now.
If True, user value for masker will be forced to
masker variable, else the defaultr value of -10 will be used.
Returns
-------
lgi_masked : np.ndarray(dtype=int)
lgi masked against gid values
Internal calls (@dev)
---------------------
self.mask_lgi_with_gids()
"""
# Validate suer supplied masker
masker = (-10*(not force_masker) + int(masker*(force_masker and type(masker)==int)))
# -----------------------------------------
lgi_masked, masker = self.mask_lgi_with_gids(gids, masker)
# -----------------------------------------
if masker != -10:
'''
Redundant branching !!
~~RETAIN~~ as an entry space for further development for needs
of having different masker values, example using differnet#
masker values for different phases like particles, voids, etc.
'''
__new_mask__ = -10
lgi_masked[lgi_masked == masker] = __new_mask__
s_masked = deepcopy(self.s)
s_masked[lgi_masked != __new_mask__] = masker
else:
__new_mask__ = -10
lgi_masked[lgi_masked == -10] = __new_mask__
s_masked = deepcopy(self.s)
s_masked[lgi_masked != -10] = masker
# -----------------------------------------
return s_masked, masker
[docs]
def plotgs(self, figsize=(6, 6), dpi=120,
custom_lgi=None,
cmap='coolwarm', plot_cbar=True,
title='Title',
plot_centroid=False, plot_gid_number=False,
centroid_kwargs={'marker': 'o',
'mfc': 'yellow',
'mec': 'black',
'ms': 2.5},
gid_text_kwargs={'fontsize': 10},
title_kwargs={'fontsize': 10},
label_kwargs={'fontsize': 10}
):
"""
Method to plot the grain structure of a temporal slice
Parameters
----------
figsize : tuple, optional
Figure size in inches. The default is (6, 6).
dpi : int, optional
Dots per inch. The default is 120.
custom_lgi : np.ndarray, optional
Custom lgi to be plotted instead of the internal one. The default is None.
cmap : str, optional
Colormap name. The default is 'coolwarm'.
plot_cbar : bool, optional
Flag to plot colorbar. The default is True.
title : str, optional
Plot title. The default is 'Title'.
plot_centroid : bool, optional
Flag to plot centroids of grains. The default is False.
plot_gid_number : bool, optional
Flag to plot gid numbers at centroids. The default is False.
centroid_kwargs : dict, optional
Keyword arguments for centroid plotting. The default is
{'marker': 'o', 'mfc': 'yellow', 'mec': 'black', 'ms': 2.5}.
gid_text_kwargs : dict, optional
Keyword arguments for gid text plotting. The default is
{'fontsize': 10}.
title_kwargs : dict, optional
Keyword arguments for title. The default is {'fontsize': 10}.
label_kwargs : dict, optional
Keyword arguments for axis labels. The default is {'fontsize': 10}.
Returns
-------
None.
Examples
--------
.. code-block:: python
from upxo.ggrowth.mcgs import mcgs
mcgs = mcgs(study='independent', input_dashboard='input_dashboard.xls')
mcgs.simulate()
mcgs.detect_grains()
mcgs.gs[35].plotgs(figsize=(6, 6), dpi=120, cmap='coolwarm',
plot_centroid=True,
centroid_kwargs={'marker':'o','mfc':'yellow',
'mec':'black','ms':2.5},
plot_gid_number=True)
"""
plt.figure(figsize=figsize, dpi=dpi)
if custom_lgi is None:
LGI = self.lgi
else:
LGI = custom_lgi
plt.imshow(LGI, cmap=cmap)
if plot_centroid or plot_gid_number:
centroid_x, centroid_y = [], []
for gid in self.gid:
centroid_x.append(self.xgr[self.lgi == gid].mean())
centroid_y.append(self.ygr[self.lgi == gid].mean())
if plot_centroid:
plt.plot(centroid_x, centroid_y, linestyle='None',
marker=centroid_kwargs['marker'],
mfc=centroid_kwargs['mfc'], mec=centroid_kwargs['mec'],
ms=centroid_kwargs['ms'])
if plot_gid_number:
for i, (cenx, ceny) in enumerate(zip(centroid_x, centroid_y), start=1):
plt.text(cenx, ceny, str(i),
fontsize=gid_text_kwargs['fontsize'])
plt.xlabel(r"X-axis, $\mu m$", fontsize=label_kwargs['fontsize'])
plt.ylabel(r"Y-axis, $\mu m$", fontsize=label_kwargs['fontsize'])
plt.title(f'tslice={self.m}. {title}')
if plot_cbar:
plt.colorbar()
[docs]
def plot_grains_gids(self, gids, gclr='color', title="user grains",
cmap_name='CMRmap_r', ):
"""
Method to plot grains specified by user input grain indices
Parameters
----------
gids : int/list
Either a single grain index number or list of them
title : TYPE, optional
DESCRIPTION. The default is "user grains".
gclr : str, optional
Color scheme for plotting. The default is 'color'.
cmap_name : str, optional
Colormap name. The default is 'CMRmap_r'.
Returns
-------
None.
Examples
--------
After acquiring gids for aspect_ratio between ranks 80 and 100,
we will visualize those grains.
As we are only interested in gid, we will not use the other
two values returned by PXGS.gs[n].get_gid_prop_range() method:
.. code-block:: python
gid, _, __ = PXGS.gs[8].get_gid_prop_range(PROP_NAME='aspect_ratio',
range_type='rank',
rank_range=[80, 100]
)
# Now, pass gid as input for the PXGS.gs[n].plot_grains_gids(),
# which will then plot the grain strucure with only these values:
PXGS.gs[8].plot_grains_gids(gid, cmap_name='CMRmap_r')
"""
if not dth.IS_ITER(gids):
gids = [gids]
if gclr not in ('binary', 'grayscale'):
s, _ = self.mask_s_with_gids(gids)
plt.imshow(s, cmap=cmap_name, vmin=1)
plt.colorbar()
elif gclr in ('binary', 'grayscale'):
s, _ = self.mask_s_with_gids(gids, masker=0, force_masker=True)
s[s != 0] = 1
plt.imshow(s, cmap='gray_r', vmin=0, vmax=1)
plt.title(title)
plt.xlabel(r"X-axis, $\mu m$", fontsize=12)
plt.ylabel(r"Y-axis, $\mu m$", fontsize=12)
plt.show()
[docs]
def plot_neigh_grains_of_gid(self, neigh_gid_subset):
"""
Method to plot neighbouring grains of user specified grain indices
Parameters
----------
neigh_gid_subset : dict
A dictionary with key as gid and value as list of neighbouring
gids.
Returns
-------
None.
"""
if type(neigh_gid_subset) != dict:
raise ValueError("Input neigh_gid_subset must be a dictionary")
all_gids = []
for gid, neighs in neigh_gid_subset.items():
all_gids.append(gid)
all_gids.extend(neighs)
self.plot_grains_gids(neighs, gclr='color', title=f"Neigh grains of gid: {gid}",
cmap_name='CMRmap_r')
[docs]
def plot_grains_prop_range(self, PROP_NAME='area', range_type='percentage',
value_range=[1, 2], percentage_range=[0, 20], rank_range=[60, 90],
pivot=None, gclr='color', title=None, cmap_name='CMRmap_r'):
"""
Method to plot grains having properties within the domain defined by
the range description specified by the user.
Parameters
----------
PROP_NAME : str, optional
Name of the grain structure property. The default is 'area'.
range_type : str, optional
Range description type. The default is 'percentage'.
value_range : iterable, optional
Range of the actual PROP_NAME values. The default is [1, 2].
percentage_range : iterable, optional
Percentage range defining the PROP_NAME values. The default is
[0, 20].
rank_range : iterable, optional
Ranks defining the range of PROP_NAME values.
If rank_range=[6, 10] and there are 20 grains, then
those grains having 12th to 20th largest PROP_NAME values will
be selected. The default is [60, 90].
pivot : str, optional
Describes the range location.
Options: ('ends', 'mean', 'primary_mode'):
- If 'ends' and percentage_range=[5, 8], then this means that
PROP_NAME vaklues between 5% and 8% of vaklues will be used to
select the grains.
- If 'mean' and percentage_range=[5, 8], then this means that
PROP_NAME values between 0.95*mean and 1.08*mean will be used
to select the grains.
- If 'primary_mode' and percentage_range=[5, 8], then this
means that PROP_NAME values between 0.95*primary_mode and
1.08*primary_mode will be used to select the grains.
The default is None.
gclr : str, optional
Specify whether grains are to have colours or grayscale.
Choose 'binary' or 'grayscale' for grayscale
The default is 'color'.
title : str, optional
DESCRIPTION.
The default is None.
cmap_name : str, optional
DESCRIPTION.
The default is 'CMRmap_r'.
Returns
-------
None.
"""
if range_type in ('percentage', 'value', 'rank'):
gid, value, _ = self.get_gid_prop_range(PROP_NAME=PROP_NAME, range_type=range_type,
rank_range=rank_range)
_rdesc_ = {'percentage': percentage_range,
'value': value_range,
'rank': rank_range
}
title = f"Grains by area. \n {range_type} bounds: {_rdesc_[range_type]}"
self.plot_grains_gids(gid, gclr='color', title=title, cmap_name=cmap_name)
else:
print(f"Invalid range_type: {range_type}")
print("range_type must be either of the follwonig:")
print(".......(percentage, value, rank)")
[docs]
def plot_large_grains(self, extent=5):
"""
Method to plot large grains based on area percentage extent.
Parameters
----------
extent : int, optional
Percentage extent to consider for large grains. The default is 5.
Returns
-------
None.
"""
gids, _, _ = self.get_gid_prop_range(PROP_NAME='area', range_type='percentage',
percentage_range=[100-extent, 100],)
for gid in gids:
plt.imshow(self.g[gid]['grain'].bbox_ex)
plt.imshow
[docs]
def plot_neigh_grains(self, gids=[None], throw=True,
gclr="color", title="Neigh grains", cmap_name="CMRmap_r"):
"""
Method to plot neighbouring grains of user specified grain indices
Parameters
----------
gids : int/list, optional
Either a single grain index number or list of them. The default is [None].
throw : bool, optional
Flag to return the list of neighbouring gids. The default is True.
gclr : str, optional
Color scheme for plotting. The default is 'color'.
title : str, optional
Plot title. The default is "Neigh grains".
cmap_name : str, optional
Colormap name. The default is 'CMRmap_r'.
Returns
-------
neighbours : list
List of neighbouring grain IDs.
"""
neighbours = [self.g[gid]["grain"].neigh for gid in gids]
_neighbours_ = []
for neighs in neighbours:
for gid in neighs:
_neighbours_.append(gid)
self.plot_grains_gids(gids=_neighbours_, gclr=gclr, title=title+f" of \n grains: {gids}",
cmap_name=cmap_name)
if throw:
return neighbours
[docs]
def plot(self, PROP_NAME=None, title='auto', cmap='CMRmap_r',
vmin = 1, vmax = 5, ):
"""
Plot the grain structure based on user input property name
Parameters
----------
PROP_NAME : str, optional
Name of the property to plot the grain structure by.
The default is None.
title : str, optional
Plot title. The default is 'auto'.
cmap : str, optional
Colormap name. The default is 'CMRmap_r'.
Returns
-------
None.
Notes
-----
1. if no kwargs: plot the entire greain structure: just use plotgs()
"""
if not PROP_NAME:
plt.imshow(self.s, cmap=cmap)
elif PROP_NAME in ('npixels', 'area', 'aspect_ratio',
'perimeter', 'eq_diameter', 'solidity',
'eccentricity', 'compactness', 'circularity',
'major_axis_length', 'minor_axis_length'
):
PROP_LGI = deepcopy(self.lgi)
for gid in self.gid:
PROP_LGI[PROP_LGI==gid]=self.prop[PROP_NAME][gid-1]
plt.imshow(PROP_LGI, cmap=cmap)
elif PROP_NAME in ('phi1', 'psi', 'phi2'):
pass
elif PROP_NAME in ('gnd_avg'):
pass
if title == 'auto':
title = f"Grain structure by {PROP_NAME}"
plt.title(f"{title}")
plt.xlabel("x-axis, um")
plt.ylabel("y-axis, um")
if PROP_NAME and PROP_NAME in ('aspect_ratio'):
plt.colorbar(extend='both')
else:
plt.colorbar()
plt.show()
[docs]
def plot_grain(self, gid, neigh=False, neigh_hops=1, save_png=False,
filename='auto', field_variable=None, throw=False):
"""
Plots the nth grain.
Parameters
----------
Ng : int
The grain number to plot. Grain number is global and not state
specific.
neigh : bool
Flag to decide plotting of grains neighbouring to Ng
neigh_hops : 1
Non-locality of neighbours.
If 1, only neighbours of Ng will be plotted along with Ng grain
If 2, neighbours of neighbours of Ng will be plotted along with
Ng grain
NOTE: maximum number of hops permitted = 2
If a number greater than 2 is provided, then hops will be
restricted to 2.
save_png : bool
Flag to consider saving .png image to disk
filename : str
Use this filename for the .png imaage.
If 'auto', then filename will be generated containing:
* Grain structure temporal slice number
* Global grain number
If None or an invalid, image will not be saved to disk.
field_variable : str
Global field variable
This is @ future development when SDVs can be re-mapped from
CPFE simulation to UPXO.mcgs2d
Returns
-------
grain_plot : bool
matplotlib.plt.imshow object
Examples
--------
.. code-block:: python
PXGS.gs[4].plot_grain(3, filename='t4_ng3.png'
# TODO
1. Add validity checking layer for gid
2. Add validity check for save_png and filename
2. Generate automatic filename
3. Save image to file
4. Add branching for dimensionality
5. Add validity check for existence of data
"""
operation_validity = False
if self.g[gid]['grain']:
if hasattr(self.g[gid]['grain'], 'bbox_ex'):
if not neigh:
if not field_variable:
grain_plot = plt.imshow(self.g[gid]['grain'].bbox_ex)
operation_validity = True
else:
# 1. check field variable validity
# 2. check if the field variable data is available
# 3. Extract field data map relavant to current grain
# only. No need to extract from remaining portions
# of bbox_ex, whcih would be containing neighbouring
# grains
# 4. PLot the data
pass
else:
if hasattr(self.g[gid]['grain'], 'neigh'):
if len(self.g[gid]['grain'].neigh) > 0:
grain_plot = plt.imshow(self.g[gid]['grain'].bbox_ex)
if save_png and type(filename) == str:
if filename == 'auto':
# Generate automatic filename
pass
else:
# Use the user input name for storing the filename.
pass
# Save the image file
elif save_png and type(filename) != str:
print("Invalid filename to store image")
pass
if operation_validity and throw:
return grain_plot
[docs]
def plot_grains(self, gids, hide_non_actors=True,
default_cmap='jet', title="user grains",
throw_plt_object=False, figsize=(6, 6), dpi=120):
"""
Method to plot grains specified by user input grain indices
Parameters
----------
gids : iterable
An iterable containing grain index numbers
Examples
--------
.. code-block:: python
self.plot_grains([1, 2, 3, 4])
"""
if not isinstance(gids, Iterable):
raise TypeError('gids should be an Iterable')
lgi = {gid: None for gid in gids}
for gid in gids:
lgi[gid] = gid*(self.lgi == gid)
lgi = np.sum(list(lgi.values()), axis=0)
if hide_non_actors:
lgi[lgi == 0] = -10
import matplotlib.cm as mpltcm
cmap = mpltcm.get_cmap(default_cmap, 50)
cmap.set_under('white')
# ---------------------------------
plt.figure(figsize=figsize, dpi=dpi)
plt.imshow(lgi, vmin=1, cmap=cmap)
plt.title(title)
plt.xlabel(r"X-axis, $\mu m$")
plt.ylabel(r"Y-axis, $\mu m$")
plt.show()
if throw_plt_object:
return plt
else:
return None
[docs]
def plot_grains_prop_bounds_s(self, s, PROP_NAME=None, prop_min=0, prop_max='', ):
"""
Placeholder for plotting grains in a state by property bounds.
Parameters
----------
s : int
State value to inspect.
PROP_NAME : str, optional
Property name used to filter grains.
prop_min : float, optional
Lower property bound.
prop_max : float or str, optional
Upper property bound.
Returns
-------
None
Notes
-----
This method is not yet implemented.
"""
pass
[docs]
def plot_grains_at_position(self, position='corner', overlay_centroids=True,
markersize=6, ):
"""
Method to plot grains at specified positions in the grain structure
Parameters
----------
position : str, optional
Position in the grain structure to plot grains at.
Options: 'corner', 'boundary', 'triple_point'
The default is 'corner'.
overlay_centroids : bool, optional
Flag to overlay centroids of the grains on the plot. The default is True.
markersize : int, optional
Size of the markers for centroids. The default is 6.
Returns
-------
None.
Examples
--------
.. code-block:: python
PXGS.gs[tslice].plot_grains_at_position(position='boundary')
"""
LGI = deepcopy(self.lgi)
boundary_array = self.positions[position]
pseudos = np.arange(-len(boundary_array), 0)
for pseudo, ba in zip(pseudos, boundary_array):
LGI[LGI == ba] = pseudo
LGI[LGI > 0] = 0
for i, pseudo in enumerate(pseudos):
LGI[LGI == pseudo] = boundary_array[i]
plt.figure()
plt.imshow(LGI)
if overlay_centroids:
for grain in self:
if grain.gid in boundary_array:
x, y = grain.position[0:2]
plt.plot(x, y,
'ko',
markersize=markersize,
)
plt.title(f"Corner grains. Ng: {len(self.positions[position])}")
plt.xlabel("x-axis, um")
plt.ylabel("y-axis, um")
plt.show()
[docs]
def detect_grain_boundaries(self):
"""
Placeholder for grain-boundary detection logic.
Notes
-----
This method is not yet implemented.
"""
for label in np.unique(self.lgi):
pass
[docs]
def hist(self, PROP_NAME=None, bins=20, kde=True, bw_adjust=None,
stat='density', color='blue', edgecolor='black', alpha=1.0,
line_kws={'color': 'k', 'lw': 2, 'ls': '-'},
auto_xbounds=True, auto_ybounds=True,
xbounds=[0, 50], ybounds=[0, 0.2], peaks=False, height=0,
prominance=0.2, __stack_call__=False, __tslice__=None, ):
"""
Plot histogram of grain property distribution
Parameters
----------
PROP_NAME : str, optional
Name of the grain property. The default is None.
bins : int, optional
Number of bins for the histogram. The default is 20.
kde : bool, optional
Flag to plot kernel density estimate (KDE). The default is True.
bw_adjust : float, optional
Bandwidth adjustment for KDE. The default is None.
stat : str, optional
Statistic to plot. Options: 'density', 'frequency', 'count'.
The default is 'density'.
color : str, optional
Color of the histogram bars. The default is 'blue'.
edgecolor : str, optional
Edge color of the histogram bars. The default is 'black'.
alpha : float, optional
Transparency of the histogram bars. The default is 1.0.
line_kws : dict, optional
Keyword arguments for the KDE line. The default is
{'color': 'k', 'lw': 2, 'ls': '-'}.
auto_xbounds : bool, optional
Flag to automatically set x-axis bounds. The default is True.
auto_ybounds : bool, optional
Flag to automatically set y-axis bounds. The default is True.
xbounds : list, optional
User-defined x-axis bounds if auto_xbounds is False. The default is [0, 50].
ybounds : list, optional
User-defined y-axis bounds if auto_ybounds is False. The default is [0, 0.2].
peaks : bool, optional
Flag to identify and plot peaks in the KDE. The default is False.
height : float, optional
Minimum height of peaks to identify. The default is 0.
prominance : float, optional
Minimum prominence of peaks to identify. The default is 0.2.
__stack_call__ : bool, optional
Internal flag for stack calls. The default is False.
__tslice__ : int, optional
Temporal slice number for stack calls. The default is None.
Returns
-------
None.
"""
if self.are_properties_available:
if PROP_NAME in self.prop.columns:
self.prop[PROP_NAME].replace([-np.inf, np.inf],
np.nan,
inplace=True
)
sns.histplot(self.prop[PROP_NAME].dropna(),
bins=bins, kde=False, stat=stat, color=color, edgecolor=edgecolor,
line_kws=line_kws)
if kde and bw_adjust:
if peaks:
x, y = (sns.kdeplot(data=self.prop[PROP_NAME].dropna(),
bw_adjust=bw_adjust,
color=line_kws['color'], linewidth=line_kws['lw'],
fill=False, alpha=0.5, ).lines[0].get_data() )
peaks, peaks_properties = find_peaks(y, height=0, prominence=0.02)
plt.plot(x, y)
plt.plot(x[peaks], peaks_properties["peak_heights"], "o",
markerfacecolor='black', markersize=8, markeredgewidth=1.5,
markeredgecolor='black')
plt.vlines(x=x[peaks], ymin=y[peaks] - peaks_properties["prominences"],
ymax=y[peaks], color="gray", linewidth=1,)
# Find the minima and plot it
minima_indices = argrelextrema(y, np.less)[0]
plt.plot(x[minima_indices], y[minima_indices], "s",
markerfacecolor='white', markersize=8,
markeredgewidth=1.5, markeredgecolor='black')
else:
sns.kdeplot(self.prop[PROP_NAME].dropna(),
bw_adjust=bw_adjust, label='KDE', color=line_kws['color'],
linewidth=line_kws['lw'], fill=False, alpha=0.5,)
if kde and not bw_adjust:
if peaks:
x, y = (sns.kdeplot(data=self.prop[PROP_NAME].dropna(),
color=line_kws['color'], linewidth=line_kws['lw'],
fill=False, alpha=0.5, ).lines[0].get_data())
peaks, peaks_properties = find_peaks(y, height=0, prominence=0.02)
plt.plot(x, y)
plt.plot(x[peaks], peaks_properties["peak_heights"], "o",
markerfacecolor='black', markersize=8, markeredgewidth=1.5,
markeredgecolor='black')
plt.vlines(x=x[peaks], ymin=y[peaks]-peaks_properties["prominences"],
ymax=y[peaks], color="gray", linewidth=1,)
# Find the minima and plot it
minima_indices = argrelextrema(y, np.less)[0]
plt.plot(x[minima_indices], y[minima_indices], "s",
markerfacecolor='white', markersize=8,
markeredgewidth=1.5, markeredgecolor='black')
if __stack_call__:
plt.title(f"Distribution of {PROP_NAME} @ tslice: {__tslice__}")
else:
plt.title(f"Distribution of {PROP_NAME}")
plt.xlabel(f'{PROP_NAME}')
plt.ylabel(f'{stat}')
if auto_xbounds == 'user':
plt.xlim(xbounds)
if auto_ybounds == 'user':
plt.ylim(ybounds)
plt.show()
else:
if not __stack_call__:
print(f"PROP_NAME: {PROP_NAME} has not yet been caluclated. Skipped")
else:
print(f"PROP_NAME: {PROP_NAME} has not yet been caluclated. Skipped")
[docs]
def kde(self, PROP_NAMES, bw_adjust, ):
"""
Plot kernel density estimate (KDE) of grain property distribution
Parameters
----------
PROP_NAMES : list
List of grain property names.
bw_adjust : float
Bandwidth adjustment for KDE.
Returns
-------
None.
"""
print(PROP_NAMES)
for PROP_NAME in PROP_NAMES:
if PROP_NAME in self.prop.columns:
self.prop[PROP_NAME].replace([-np.inf, np.inf], np.nan, inplace=True)
sns.kdeplot(self.prop[PROP_NAME].dropna(),
bw_adjust=bw_adjust, label='KDE', color='red', attrs=['bold'])
plt.title(f"{PROP_NAME} distribution")
plt.xlabel(f"{PROP_NAME}")
plt.ylabel("Density")
plt.legend()
if PROP_NAME == PROP_NAMES[-1]:
plt.show()
[docs]
@decorators.port_doc('upxo.viz.dataviz', 'see_distr')
def see_distr(self, viz='hist', prop_names=['area', 'perimeter', 'orientation', 'solidity', ],
props={'area': [], 'perimeter': [], 'orientation': [], 'solidity': []},
prop_units={'area': 'μm²', 'perimeter': 'μm', 'orientation': 'degrees', 'solidity': ''},
probability_density=False,
nbins_values={'area': 30, 'perimeter': 30, 'orientation': 30, 'solidity': 30},
bw_adjust_values={'area': None, 'perimeter': None, 'orientation': None, 'solidity': None},
alpha_values={'area': 0.7, 'perimeter': 0.7, 'orientation': 0.7, 'solidity': 0.7},
color_values={'area': 'blue', 'perimeter': 'blue', 'orientation': 'blue', 'solidity': 'blue'},
edgecolor_values={'area': 'black', 'perimeter': 'black', 'orientation': 'black', 'solidity': 'black'},
binsize=30, alpha=0.7, color='blue', edgecolor='black',
ncolumns=3, ylabel='count', gsdim=2):
"""Delegate to the plotting helper that visualises property distributions."""
from upxo.viz.dataviz import see_distr
see_distr(gsdim=gsdim, viz=viz,
prop_data_format='dataframe', prop_df=self.prop,
prop_names=prop_names, props=props,
prop_units=prop_units,
probability_density=probability_density,
nbins_values=nbins_values, bw_adjust_values=bw_adjust_values,
alpha_values=alpha_values, color_values=color_values,
edgecolor_values=edgecolor_values,
binsize=binsize, alpha=alpha, color=color, edgecolor=edgecolor,
ncolumns=ncolumns, ylabel=ylabel)
[docs]
def femesh(self, saa=True, throw=False, ):
"""
Set up finite element mesh of the poly-xtal
Parameters
----------
saa : bool, optional
Flag to set the mesh attribute of the grain structure object.
The default is True.
throw : bool, optional
Flag to return the mesh object. The default is False.
Returns
-------
mesh : pxtal.mesh.mesh_mcgs2d object
Finite element mesh of the grain structure.
Notes
-----
Use saa=True to update grain structure mesh atttribute
Use saa=True and throw=True to update and return mesh
Use saa=False and throw=True to only return mesh
"""
# from mcgs import _uidata_mcgs_gridding_definitions_
# uigrid = _uidata_mcgs_gridding_definitions_(self.uinputs)
# from mcgs import _uidata_mcgs_mesh_
# uimesh = _uidata_mcgs_mesh_(self.uinputs)
from upxo.meshing.mesher_2d import mesh_mcgs2d
if saa:
self.mesh = mesh_mcgs2d(self.uinputs['uimesh'], self.uigrid, self.dim, self.m, self.lgi)
if throw:
return self.mesh
if not saa:
if throw:
return mesh_mcgs2d(self.uinputs['uimesh'], self.uigrid, self.dim, self.m, self.lgi)
else:
return 'Please enter valid saa and throw arguments'
@property
def pxtal_length(self):
"""Return the physical length of the polycrystal domain."""
# Calculate length of the pxtal in microns
return self.uigrid.xmax-self.uigrid.xmin+self.uigrid.xinc
@property
def pxtal_height(self):
"""Return the physical height of the polycrystal domain."""
# Calculate height of the pxtal in microns
return self.uigrid.ymax-self.uigrid.ymin+self.uigrid.yinc
@property
def pxtal_area(self):
"""Return the physical area of the polycrystal domain."""
# Calculate area of the pxtal in square microns
return self.pxtal_length*self.pxtal_height
@property
def centroids(self):
"""Return grain centroids as an ``(n, 2)`` array."""
# Calculate centroids of the grains
centroids = []
for gid in self.gid:
locs = self.lgi == gid
centroids.append([self.xgr[locs].mean(), self.ygr[locs].mean()])
return np.array(centroids)
@property
def bboxes(self):
"""Return bounding boxes for all grains."""
# Calculate bounding boxes of the grains
return [grain.bbox for grain in self]
@property
def bboxes_bounds(self):
"""Return bounding-box bounds for all grains."""
# Calculate bounding box bounds of the grains
return [grain.bbox_bounds for grain in self]
@property
def bboxes_ex(self):
"""Return extended bounding boxes for all grains."""
# Calculate extended bounding boxes of the grains
return [grain.bbox_ex for grain in self]
@property
def bboxes_ex_bounds(self):
"""Return extended bounding-box bounds for all grains."""
# Calculate extended bounding box bounds of the grains
return [grain.bbox_ex_bounds for grain in self]
@property
def areas(self):
"""Return grain areas as a numpy array."""
# Calculate areas of the grains
return np.array([self.px_size*grain.loc.shape[0] for grain in self])
@property
def areas_min(self):
"""Return the minimum grain area."""
# Calculate minimum area of the grains
return self.areas.min()
@property
def areas_mean(self):
"""Return the mean grain area."""
# Calculate mean area of the grains
return self.areas.mean()
@property
def areas_std(self):
"""Return the standard deviation of grain areas."""
# Calculate standard deviation of the areas of the grains
return self.areas.std()
@property
def areas_var(self):
"""Return the variance of grain areas."""
# Calculate variance of the areas of the grains
return self.areas.var()
@property
def areas_max(self):
"""Return the maximum grain area."""
# Calculate maximum area of the grains
return self.areas.max()
@property
def areas_stat(self):
"""Return basic statistics for grain areas."""
# Calculate statistics of the areas of the grains
areas = self.areas
return {'min': areas.min(),
'mean': areas.mean(),
'max': areas.max(),
'std': areas.std(),
'var': areas.var()
}
@property
def aspect_ratios(self):
"""Return grain aspect ratios."""
# Calculate aspect ratios of the grains
gid_stright_grains = self.straight_line_grains
mj_axis = [grain.skprop.axis_major_length for grain in self]
mn_axis = [grain.skprop.axis_minor_length for grain in self]
npixels = [len(grain.loc) for grain in self]
ar = []
for i, (npx, mja, mna) in enumerate(zip(npixels, mj_axis, mn_axis)):
if i+1 not in gid_stright_grains:
ar.append(mja/mna)
else:
if npx == 1:
ar.append(1)
else:
ar.append(len(self.g[i+1]['grain'].loc))
return ar
@property
def aspect_ratios_min(self):
"""Return the minimum grain aspect ratio."""
# Calculate minimum aspect ratio of the grains
return self.aspect_ratios.min()
@property
def aspect_ratios_mean(self):
"""Return the mean grain aspect ratio."""
# Calculate mean aspect ratio of the grains
return self.aspect_ratios.mean()
@property
def aspect_ratios_std(self):
"""Return the standard deviation of grain aspect ratios."""
# Calculate standard deviation of the aspect ratios of the grains
return self.aspect_ratios.std()
@property
def aspect_ratios_var(self):
"""Return the variance of grain aspect ratios."""
# Calculate variance of the aspect ratios of the grains
return self.aspect_ratios.var()
@property
def aspect_ratios_max(self):
"""Return the maximum grain aspect ratio."""
# Calculate maximum aspect ratio of the grains
return self.aspect_ratios.max()
@property
def aspect_ratios_stat(self):
"""Return basic statistics for grain aspect ratios."""
# Calculate statistics of the aspect ratios of the grains
aspect_ratios = self.aspect_ratios
return {'min': aspect_ratios.min(), 'mean': aspect_ratios.mean(),
'max': aspect_ratios.max(), 'std': aspect_ratios.std(), 'var': aspect_ratios.var()}
@property
def npixels(self):
"""Return the number of pixels for each grain."""
# Calculate number of pixels of the grains
npx = np.array([len(grain.loc) for grain in self])
return npx
@property
def single_pixel_grains(self):
"""Return the grain IDs that contain a single pixel."""
# Retrieve the grain IDs of single pixel grains
return np.where(self.npixels == 1)[0]+1
@property
def plot_single_pixel_grains(self):
"""Plot the grains that consist of a single pixel."""
# Plot single pixel grains
self.plot_grains_gids(self.single_pixel_grains)
@property
def straight_line_grains(self):
"""Return grain IDs that are effectively straight-line grains."""
# get the axis lengths of all availabel grains
mja = [grain.skprop.axis_major_length for grain in self]
mna = np.array([grain.skprop.axis_minor_length for grain in self])
# retrieve the grains where minor axis is zero. These are the grains
# where skimage is unable to fit ellipse, as they are unit pixel wide.
# some of them could be for single pixel grains too.
gid_mna0 = list(np.where(mna == 0)[0]+1)
# Now, retrieve the single pixel grains.
gid_npx1 = self.single_pixel_grains
# Remove the single pixel grains
if len(gid_npx1) > 0:
# This means single pixel grains exist
for _gid_npx1_ in gid_npx1:
gid_mna0.remove(_gid_npx1_)
gid_ar = np.array([len(self.g[_gid_mna0_]['grain'].loc) for _gid_mna0_ in gid_mna0])
return np.array(gid_mna0, dtype=int), gid_ar
@property
def locations(self):
"""Return stored grain locations."""
# Calculate locations of the grains
return [grain.position for grain in self]
@property
def perimeters(self):
"""Return grain perimeters estimated from pixel counts."""
# Calculate perimeters of the grains
characteristic_length = math.sqrt(self.px_size)
return np.array([characteristic_length*grain.gbloc.shape[0] for grain in self])
@property
def perimeters_min(self):
"""Return the minimum grain perimeter."""
# Calculate minimum perimeter of the grains
return self.perimeters.min()
@property
def perimeters_mean(self):
"""Return the mean grain perimeter."""
# Calculate mean perimeter of the grains
return self.perimeters.mean()
@property
def perimeters_std(self):
"""Return the standard deviation of grain perimeters."""
# Calculate standard deviation of the perimeters of the grains
return self.perimeters.std()
@property
def perimeters_var(self):
"""Return the variance of grain perimeters."""
# Calculate variance of the perimeters of the grains
return self.perimeters.var()
@property
def perimeters_stat(self):
"""Return basic statistics for grain perimeters."""
# Calculate statistics of the perimeters of the grains
perimeters = self.perimeters
return {'min': perimeters.min(), 'mean': perimeters.mean(),
'max': perimeters.max(), 'std': perimeters.std(), 'var': perimeters.var()}
@property
def ratio_p_a(self):
"""Return the perimeter-to-area ratio for each grain."""
# Calculate perimeter to area ratio of the grains
return np.array([p/a for p, a in zip(self.perimeters, self.areas)])
@property
def AF_bgrains_igrains(self):
"""Return area fractions for boundary and internal grains."""
# Calculate area fractions of boundary grains and internal grains
areas = self.areas
A_bgr = [areas[gid-1] for gid in np.unique(self.positions['boundary'])]
A_igr = [areas[gid-1] for gid in np.unique(self.positions['internal'])]
pxtal_area = self.pxtal_area
AF = (np.array(A_bgr).sum()/pxtal_area, np.array(A_igr).sum()/pxtal_area)
return AF
@property
def grains(self):
"""Return a generator over the grains in this structure."""
# Generator to iterate over grains
return (_ for _ in self)
[docs]
def make_mulpoint2d_grain_centroids(self):
"""Build and store a mulpoint2d object from grain centroids."""
# Create mulpoint2d object for grain centroids
from upxo.geoEntities.mulpoint2d import MPoint2d
self.mp['gc'] = MPoint2d.from_coords(self.centroids)
# self.mp['gc'] = mulpoint2d(self.centroids)
[docs]
def plot_mcgs_mpcentroids(self):
"""
Plot the stored grain-centroid multipoint overlay on the slice image.
Returns
-------
None
"""
plt.figure()
# Plot the grain structure
plt.imshow(self.s)
# Plot the grain mulpoints of the grain centroids
plt.plot(self.mp['gc'].locx, self.mp['gc'].locy, 'ko', markersize=6)
plt.xlabel('x-axis um', fontdict={'fontsize': 12})
plt.ylabel('y-axis um', fontdict={'fontsize': 12})
plt.title(f"MCGS tslice:{self.m}.\nUPXO.mulpoint2d of grain centroids",
fontdict={'fontsize': 12})
plt.show()
[docs]
def vtgs2d(self, visualize=True):
"""
Build a 2D Voronoi tessellation from the grain centroids.
Parameters
----------
visualize : bool, optional
Plot the tessellation after construction.
Returns
-------
None
"""
# from polyxtal import polyxtal2d as polyxtal
from upxo.pxtal.polyxtal import vtpolyxtal2d as vtpxtal
self.make_mulpoint2d_grain_centroids()
self.vtgs = vtpxtal(gsgen_method='vt', vt_base_tool='shapely',
point_method='mulpoints', mulpoint_object=self.mp['gc'],
xbound=[self.uigrid.xmin, self.uigrid.xmax+self.uigrid.xinc],
ybound=[self.uigrid.ymin, self.uigrid.ymax+self.uigrid.yinc],
vis_vtgs=visualize)
if visualize:
self.vtgs.plot(dpi=100, default_par_faces={'clr': 'teal', 'alpha': 1.0, },
default_par_lines={'width': 1.5, 'clr': 'black', },
xtal_marker_vertex=True, xtal_marker_centroid=True)
[docs]
def ebsd_write_ctf(self, folder='upxo_ctf', file='ctf.ctf'):
"""
Write a small synthetic CTF file for testing purposes.
Parameters
----------
folder : str
Folder to write the ctf file to.
file : str
Name of the ctf file.
Returns
-------
None.
"""
x = np.arange(0, 100.1, 2.5)
y = np.arange(0, 100.1, 2.5)
X, Y = np.meshgrid(x, y)
PHI1 = np.random.uniform(low=0, high=360, size=X.shape)
PSI = np.random.uniform(low=0, high=360, size=X.shape)
PHI2 = np.random.uniform(low=0, high=180, size=X.shape)
os.makedirs(folder, exist_ok=True)
file = file
file_path = os.path.join(folder, file)
with open(file_path, 'w') as f:
f.write("Channel Text File\n")
f.write("Prj C:/CHANNEL5_olddata/Joe's Creeping Crud/Joes creeping crud on Cu/Cugrid_after 2nd_15kv_2kx_2.cpr\n")
f.write("Author [Unknown]\n")
f.write("JobMode Grid\n")
f.write("XCells 550\n")
f.write("YCells 400\n")
f.write("XStep 0.1\n")
f.write("YStep 0.1\n")
f.write("AcqE1 0\n")
f.write("AcqE2 0\n")
f.write("AcqE3 0\n")
f.write("Euler angles refer to Sample Coordinate system (CS0)! Mag 2000 Coverage 100 Device 0 KV 15 TiltAngle 70 TiltAxis 0\n")
f.write("Phases 1\n")
f.write("3.6144;3.6144;3.6144 90;90;90 Copper 11 225 3803863129_5.0.6.3 -906185425 Ann. Acad. Sci. Fenn., Ser. A6 [AAFPA4], vol. 223A, pages 1-10\n")
f.write('Phase X Y Euler1 Euler2 Euler3\n')
for i in range(X.shape[0]):
for j in range(X.shape[1]):
x = X[i, j]
y = Y[i, j]
phi1 = PHI1[i, j]
psi = PSI[i, j]
phi2 = PHI2[i, j]
f.write(f"1 {x} {y} {phi1} {psi} {phi2}\n")
f.close()
[docs]
def export_vtk2d(self):
"""
Placeholder for exporting the grain structure to a 2D VTK file.
Returns
-------
None
"""
pass
[docs]
def export_ctf(self, folder, fileName, pathFinding='direct',
headerFileLocation='C:\\Development\\UPXO\\upxo_library\\src\\upxo\\_writer_data\\_ctf_header_CuCrZr_1.txt',
factor=1, method='nearest'):
"""
Export the grain structure to a CTF file for downstream EBSD tools.
Parameters
----------
folder : str
Directory path string where the CTF file will be exported.
fileName : str
Name of the CTF file to be exported.
factor : float
Resolution modification factor.
If factor < 1.0, the grid resolution will be decreased by the
specified factor using decimation.
If factor = 1.0, the grid resolution will remain unchanged.
If factor > 1.0, the grid resolution will be increased by the
specified factor using nearest neighbor interpolation.
method : str
Method to modify grid resolution.
Options include 'nearest' and 'decimate'.
Returns
-------
None.
Examples
--------
.. code-block:: python
ctf.export_ctf('D:/export_folder', 'sunil')
"""
if method not in ('nearest', 'decimate'):
raise ValueError('Invalid method provided. Valid: nearest or decimate')
from upxo._sup.export_data import ctf
ctf = ctf()
ctf.load_header_file(pathFinding=pathFinding, filePath=folder,
headerFileLocation=headerFileLocation)
ctf.make_header_from_lines()
ctf.set_phase_name(phase_name='PHNAME')
# ------------------------------------
if factor > 0.0 and factor < 1.0:
XGRID, YGRID, SMATRIX = decrease_grid_resolution(self.xgr, self.ygr, self.s, factor)
elif factor == 1.0:
XGRID, YGRID, SMATRIX = self.xgr, self.ygr, self.s
elif factor > 1.0:
XGRID, YGRID, SMATRIX = increase_grid_resolution(self.xgr, self.ygr, self.s, factor)
# ------------------------------------
ctf.set_grid(XGRID, YGRID)
ctf.set_state(self.S, SMATRIX)
"""Orientation export hook reserved for future implementation."""
# ctf.set_ori(self.euler1, self.euler2, self.euler3)
ctf.set_grid_data()
# ndata = ctf.assemble_grid_data()
# ndata = ctf.assemble_grid_data_orix()
ctf.write_ctf_file_ORIX(folder, fileName)
[docs]
def export_slices(self, xboundPer=None, yboundPer=None, zboundPer=None,
mlist=None, sliceStepSize=None, sliceNormal=None,
xoriConsideration=None, resolution_factor=None,
exportDir=None, fileFormats=None, overwrite=None, ):
"""
Exports datafiles of slices through the grain structures.
Parameters
----------
xboundPer : list/tuple
(min%, max%), min% < max%. min% shows the percentage length from
grid's xmin, where the bound starts and the max% shows the
percentage xlength from grid's xmin, where the bounds ends
yboundPer : list/tuple
(min%, max%), min% < max%. min% shows the percentage length from
grid's ymin, where the bound starts and the max% shows the
percentage ylength from grid's ymin, where the bounds ends
zboundPer : list/tuple
(min%, max%), min% < max%. min% shows the percentage length from
grid's zmin, where the bound starts and the max% shows the
percentage ylength from grid's zmin, where the bounds ends
mlist : list/tuple of int values
List of monte-carlo temporal time values, where slices are needed.
For each entry, a seperate folder will be created.
sliceStepSize : int
Pixel-distance (number of pixels) between each individual slice.
Minimum should be 1, in which case, the every adjqacent possible
slice will be sliced and exported. If 2, slices 0, 2, 4, ... will
be considered. If 5, slices, 0, 5, 10, ... will be considered.
sliceNormal : str
Options include x, y, z
xoriConsideration : dict
Xtal orientation consideration
Mandatory key: 'method'. Options include:
* 'ignore'. Only when crystallographical orientations have
already been mapped to grains.
* 'random'. Value could be a dummy value.
* 'userValues'. Value to be a numpy array of 3 Bunge's Euler
angles, shaped (nori, 3).
* 'import'.
resolution_factor : float
exportDir : str
Directory path string which would be parent directory for all
exports made from this PXGS.export_slices(.). If directory does
not exit, it will be created.
fileFormats : dict
Keys include txt, h5d, ctf, vtk.
* Include txt or h5d to export for for further work in UPXO
* Include ctf for export to MTEX or Dream3D's h5ebsd reconstruction
pipeline
* Include vtk2d for export to VTK format of each slice
* Include vtk3d for export to VTK of entire grain structure
overwrite : bool
If True, any existing contents in all child directories inside
exportDir will be overwritten
If False, existing contents will not be altered.
Returns
-------
None.
Examples
--------
.. code-block:: python
xboundPer = (0, 100)
yboundPer = (0, 100)
zboundPer = (0, 100)
mlist = [0, 10, 20]
sliceStepSize = 1
sliceNormal = 'z'
xoriConsideration = {'method': 'random'}
exportDir = 'FULL PATH'
fileFormats = {'.ctf': {},
'.vtk3d': {},
}
overwrite = True
PXGS.export_slices(xboundPer, yboundPer, zboundPer, mlist,
sliceStepSize, sliceNormal, exportDir,
fileFormats, overwrite)
"""
xsz = math.floor((self.uigrid.xmax-self.uigrid.xmin)/self.uigrid.xinc)
ysz = math.floor((self.uigrid.ymax-self.uigrid.ymin)/self.uigrid.yinc)
zsz = math.floor((self.uigrid.zmax-self.uigrid.zmin)/self.uigrid.zinc)
Smax = self.uisim.S
slices = list(range(0, 9, sliceStepSize))
phase_name = 1
phi1 = np.random.rand(Smax)*180
psi = np.random.rand(Smax)*90
phi2 = np.random.rand(Smax)*180
textureInstanceNumber = 1
[docs]
def import_ctf(self, filePath, fileName, convertUPXOgs=True):
"""Import a CTF file into the grain-structure workflow."""
pass
[docs]
def import_crc(self, filePath, fileName, convertUPXOgs=True):
"""Import a CRC file into the grain-structure workflow."""
pass
[docs]
def clean_exp_gs(self, minGrainSize=10):
"""Clean imported experimental grain-structure data."""
pass
[docs]
def import_dream3d(self, filePath, fileName, convertUPXOgs=True):
"""Import a Dream3D file into the grain-structure workflow."""
pass