Skip to content

Neighbor based analysing tool#642

Open
Tagebh wants to merge 2 commits into
pyxem:developfrom
Tagebh:develop
Open

Neighbor based analysing tool#642
Tagebh wants to merge 2 commits into
pyxem:developfrom
Tagebh:develop

Conversation

@Tagebh
Copy link
Copy Markdown

@Tagebh Tagebh commented Mar 23, 2026

Description of the change

I am currently working on hybrid indexing as part of my master at NTNU, and I also wrote a project thesis on hybrid indexing in the autumn of last year, where I used the hybrid indexing tutorial made på Håkon as a starting point. As part of this work I have made a Kernel Average Misorientation (KAM) tool based on the code from @argerlt in issue #531.

Progress of the PR

I have structured the code so it has the potential to be used for many things involving neighbors (not only KAM).

The Neighbors() function performs the general preperations to do neighbor-based calculations on a xmap or any 2d array really, and can be used on it's own to do whatever you want with neighbors.

The neighbor_misorientation() and KAM_calc() then uses Neighbors() to make a KAM map.

I have also included a slight variation of KAM which I am calling Number of Same Neighbors, which also uses the results from Neighbors() and neighbor_misorientation() but does a slightly different calculation. A user defines what degree of misorientation that counts as a "different" orientation, and then it counts how many neighbors has the same orientation as a central point, where each point in the dataset acts as a central point once.

import numpy as np

from orix.crystal_map import CrystalMap
from orix.quaternion import Orientation

from skimage.morphology._util import _raveled_offsets_and_distances
from skimage.util._map_array import map_array

def Neighbors(data,foot,mask=None):  
    """Performs the general preperations to do neighbor-based calculations on CrystalMaps in a vectorized form
        
    Args:
        data: 2d array. Can be a CrystalMap object
        mask: 2D array with same shape as CrystalMap indicating what points are valid or not eg. Non-indexed points
        foot: The kernel that defines which neighbors to look at. Can be both binary and not
        
    Returns: (maybe should be returned as an object instead?)
        indices: Array where the index for each valid point is repeated as many times as it has valid neighbors
        neighbor_indices: Array with the corosponding valid neighbors
        foot_values: contains how each neighbor in neighbor_indices is weighted defined by a given footprine/kernel
        num_neigbors_valid: the number of neighbors each valid point has
        nodes_valid: The index for valid points with at least one valid neighbor
        nodes_no_valid_neighbors: The index for valid points with no valid neighbors
        nodes_not_valid: The index for non-valid points 
        
    Raises:
        Have to add ValueErrors
    """
    if mask is None:
        if isinstance(data, CrystalMap):
            mask = data.is_indexed.reshape(data.shape)
        else:
            mask = np.full(data.shape, True) #All pointd are valid
    nodes = np.flatnonzero(mask)
    
    pad = np.max(foot.shape)//2
    padded_mask = np.pad(mask, pad, mode='constant', constant_values=False) 
    padded_nodes = np.flatnonzero(padded_mask)

    #The offset are given in 1D for the neighbors defined in 2D
    neighbor_offsets, dist = _raveled_offsets_and_distances(padded_mask.shape, footprint=foot)

    padded_neighbors = padded_nodes[:, np.newaxis] + neighbor_offsets
    neighbors = map_array(padded_neighbors, padded_nodes, nodes) #finds the indeces (in a non-padded format) for the neighbors belonging to valid points
    neighbors_mask = padded_mask.reshape(-1)[padded_neighbors] #sets which of those neigbors are valid
    
    num_neighbors = np.sum(neighbors_mask, axis=1)#the number of neighbors each valid point has
    indices = np.repeat(nodes, num_neighbors) #Array where the index for each valid point is repeated as many times as it has valid neighbors
    neighbor_indices = neighbors[neighbors_mask] #Array with the corosponding valid neighbors

    #Support for non-binary footprints 
    foot_offsets, dist = _raveled_offsets_and_distances(foot.shape, footprint=foot)
    foot_nodes = foot_offsets+foot.size//2
    foot_values = foot.reshape(-1)[np.repeat([foot_nodes],mask.sum(),axis=0)[neighbors_mask]]#contains how each neighbor in neighbor_indices is weighted defined by a given footprine

    valid_neighbor_mask = num_neighbors !=0
    nodes_valid = nodes[valid_neighbor_mask] #The index for valid points with at least one valid neighbor
    nodes_no_valid_neighbors = nodes[~valid_neighbor_mask] #The index for valid points with no valid neighbors
    nodes_invalid = np.flatnonzero(~mask) #The index for non-valid points (same as the mask input, not really neccesary)
    
    num_neighbors_valid = num_neighbors[valid_neighbor_mask]
    
    return(indices, neighbor_indices, foot_values, num_neighbors_valid, nodes_valid, nodes_no_valid_neighbors, nodes_invalid)


def neighbor_misorientation(xmap, indices, neighbor_indices, degree=False, crystall_symmetry=None):
    """Calculates the misorientation angles between a central point and its neighbors
    Args:
        xmap: CrystalMap object
        indices: Array where the index for each valid point is repeated as many times as it has valid neighbors
        neighbor_indices: Array with the corosponding valid neighbors
        degree (bool): True-return results in degree. False-return results in radiens
        crystall_symmetry: used to differentiate between rotation and orientation
    Returns:
        d: misorientation angles given in radiens
        
    Raises:
        Have to add ValueErrors
    """
    if crystall_symmetry is None:
        crystall_symmetry = xmap.phases[0].point_group #This can pehaps lead to problems, maybe make a check for multiple phases
    
    O_central_points = Orientation(xmap.rotations[indices], crystall_symmetry)
    O_neighbors = Orientation(xmap.rotations[neighbor_indices], crystall_symmetry)
    mis_ori = O_central_points.angle_with(O_neighbors)

    if degree:
        mis_ori = mis_ori*180/np.pi
    
    return mis_ori

def KAM_calc(xmap, mis_ori, non_index_value, no_neighbors_value, num_neighbors_valid, nodes_valid, nodes_no_valid_neighbors, nodes_invalid, foot_values=None):
    
    """Makes the KAM map
    Args:
        xmap: CrystalMap object (or any 2d array actually)
        mis_ori: Misorientation angles given in radiens or degrees (from neighbor_misorientation())
        non_index_value (int or float): Kam value given to a non-indexed points in the xmap
        no_neigbors_value (int or float): Kam value given to an indexed point without a single indexed neighbor

        These are from Neighbors()
        num_neigbors_valid: the number of neighbors each valid point has
        nodes_valid: The index for valid points with at least one valid neighbor
        nodes_no_valid_neighbors: The index for valid points with no valid neighbors
        nodes_not_valid: The index for non-valid points 
        foot_values: The weight of different neighbors if non-binary foot is used
        
    Returns:
        kam_map_im: 2D array with same shape as xmap with all the KAM values
        
    Raises:
        Have to add ValueErrors
    """
    if foot_values is None:
        cumulative_miso = np.add.reduceat(mis_ori, np.r_[0, np.cumsum(num_neighbors_valid)[:-1]]) 
    else:
        cumulative_miso = np.add.reduceat(mis_ori*foot_values, np.r_[0, np.cumsum(num_neighbors_valid)[:-1]])
    
    kam_map = np.full(xmap.size, np.nan, dtype=np.float32)
    kam_map[nodes_valid] = cumulative_miso/num_neighbors_valid
    kam_map[nodes_no_valid_neighbors] = no_neighbors_value
    kam_map[nodes_invalid] = non_index_value
    kam_map_im = kam_map.reshape(xmap.shape)
    
    return kam_map_im

def NSN_calc(xmap, mis_ori, non_index_value, no_neighbors_value, lim, num_neighbors_valid, nodes_valid, nodes_no_valid_neighbors, nodes_invalid, foot_values=None):
    """Makes a Number of same neighbors (NSN) map. Each point in the xmap gets assigned the value equal to the number similar 
       oriented neighbors. Where similar is defined by a user set limit 
    Args:
        xmap: CrystalMap object (or any 2d array actually)
        mis_ori: Misorientation angles given in radiens or degrees (from neighbor_misorientation())
        non_index_value (int or float): NSN value given to a non-indexed points in the xmap
        no_neigbors_value (int or float): NSN value given to an indexed point without a single indexed neighbor
        lim (int or float): The limit for when two neighboring point are defined to have different/same orientations

        These are from Neighbors()
        num_neigbors_valid: the number of neighbors each valid point has
        nodes_valid: The index for valid points with at least one valid neighbor
        nodes_no_valid_neighbors: The index for valid points with no valid neighbors
        nodes_not_valid: The index for non-valid points
        foot_values: The weight of different neighbors if non-binary foot is used
        
    Returns:
        NDN_map_im: 2D array with same shape as xmap with all the NDN values
        
    Raises:
        Have to add ValueErrors
    """

    same_neighbors = mis_ori < lim
    if foot_values is None:
        NSN = np.add.reduceat(same_neighbors, np.r_[0, np.cumsum(num_neighbors_valid)[:-1]]) 
    else:
        NSN = np.add.reduceat(same_neighbors*foot_values, np.r_[0, np.cumsum(num_neighbors_valid)[:-1]])
    
    NSN_map = np.full(xmap.size, np.nan, dtype=np.float32)
    NSN_map[nodes_valid] = NSN 
    NSN_map[nodes_no_valid_neighbors] = no_neighbors_value
    NSN_map[nodes_invalid] = non_index_value
        
    NSN_map_im = NSN_map.reshape(xmap.shape)
    
    return NSN_map_im

Here is the functions as .py file
_neighbors.py

The reulting KAM and NDN (Number of different neigbors (NDN) = total neighbors - NSN) map using a 3x3 binary kernel (8 neighbors with equal weighting) could look something like this (using one of the nickel datasets already in kikuchipy):

image

I have also made a small jupyter notebook that I used to test the functions
test_neighbors.ipynb

Here is an example from that notebook where the amount of equal colored neighbors is counted in a small dummy dataset

import _neighbors as Neighbors #import my library

#make a dummy dataset
test_mask = np.array([[True,True,True,False,True,True,True,],
                      [True,False,True,True,True,True,True],
                      [True,True,True,False,False,False,True,],
                      [True,True,True,False,True,False,False],
                      [True,True,True,False,False,False,True,]])

test_data = np.array([[1,2,1,0,2,1,1],
                      [1,0,2,1,2,2,1],
                      [2,2,2,0,0,0,2],
                      [2,2,2,0,1,0,0],
                      [2,2,2,0,0,0,2]])

#define the footprint and run the preperation function neighbors()
x=3
foot = np.ones([x,x])
indices, neighbor_indices, foot_values, num_neighbors_valid, nodes_valid, nodes_no_valid_neighbors, nodes_not_valid=Neighbors.Neighbors(data = test_data,
                                                                                                                                        foot = foot,
                                                                                                                                        mask = test_mask)
#calculate how many neighbors how the same value/color as the central point
central_points = test_data.reshape(-1)[indices]
neighbors = test_data.reshape(-1)[neighbor_indices]

l = []
for i in range(len(ori)):
    if central_points[i] == neighbors[i]:
        l.append(1)
    else:
        l.append(0)
same_N = np.array(l)

np.r_[0, np.cumsum(num_neighbors_valid)[:-1]]
sum_same_N = np.add.reduceat(same_N*foot_values, np.r_[0, np.cumsum(num_neighbors_valid)[:-1]])

im = np.full(test_data.size, np.nan)
im[nodes_valid] = sum_same_N
im[nodes_no_valid_neighbors] = -1
im[nodes_not_valid] = -2

im = im.reshape(test_data.shape)

#display the footprint used, the dataset and the number of same neighbors

print('Footprint:')
print()
print(foot)

fig, ax = plt.subplots()
rows = test_data.shape[0]
cols = test_data.shape[1]
ax.imshow(test_data, extent=[0,cols, rows, 0], origin='lower')
ax.set_xlim(0, cols)
ax.set_ylim(0, rows)
ax.set_xticks(np.arange(0, cols + 1))
ax.set_yticks(np.arange(0, rows + 1))
ax.set_xticklabels([])
ax.set_yticklabels([])
ax.grid(which='major', color='k', linestyle='-', linewidth=2)

for i in range(rows):
    for j in range(cols):
        ax.text(0.5 + j, 0.5 + i, im[rows - 1 - i, j], ha='center', va='center', color='red', fontsize=12)
        
ax.set_title('Number of same neighbors')

plt.show()

print('purple: data value is 0 (invalid)')
print('teal: data value is 1')
print('yellow: data value is 2')
print()
print('-2: invalid point (value set by user)')
print('-1: valid point without a valid neighbor (value set by user) (can be both green and yellow)')

The output with a 3x3 binary footprint
image

The output when the footprint is 3x3 and not binary (0.5 in the corners)
image

I do not have any experience with git hub, so I do not really know how to impliment it, but I hope this seems usefull and that someone wants to pick it up.

Kind regards
Tage

For reviewers

  • The PR title is short, concise, and will make sense 1 year later.
  • New functions are imported in corresponding __init__.py.
  • New features, API changes, and deprecations are mentioned in the unreleased
    section in CHANGELOG.rst.
  • Contributor(s) are listed correctly in __credits__ in orix/__init__.py and in
    .zenodo.json.

@review-notebook-app
Copy link
Copy Markdown

Check out this pull request on  ReviewNB

See visual diffs & provide feedback on Jupyter Notebooks.


Powered by ReviewNB

@hakonanes hakonanes added the enhancement New feature or request label Mar 23, 2026
@hakonanes
Copy link
Copy Markdown
Member

Thank you @Tagebh, the functionality in your comment will be very useful to have in orix!

When adding your own commits, can you use the develop branch as the target branch here in the orix repo?

@Tagebh Tagebh changed the title Neighbor based analysing tool Neighbor based analysing tool (wrong?) Apr 1, 2026
@Tagebh Tagebh changed the base branch from main to develop April 1, 2026 09:56
@Tagebh Tagebh changed the title Neighbor based analysing tool (wrong?) Neighbor based analysing tool Apr 1, 2026
@Tagebh Tagebh marked this pull request as ready for review April 1, 2026 10:01
@Tagebh
Copy link
Copy Markdown
Author

Tagebh commented Apr 1, 2026

Hey @hakonanes

I changed the PR so it commits to the developer branch, and I added the code to orix/crystal_map/_neighbors.py. I also made the PR ready for review

@hakonanes
Copy link
Copy Markdown
Member

Great, thank you!

Just to confirm, is it fine with you if I push to your branch? I plan to push stuff like tests, documentation, and perhaps some restructuring of your file. Alternatively, I can make a PR to the develop branch in your fork, so you can go through my suggested changes before being visible here.

@argerlt
Copy link
Copy Markdown
Collaborator

argerlt commented Apr 10, 2026

@hakonanes and @Tagebh , do either of you care if I try tackling this actually? Same comment as @hakonanes, making pushes directly to the original branch.

@hakonanes, if you've already started, ignore this comment, there are plenty of other things for me to do in ORIX.

Also, general question: how do we feel about this pattern of keeping all the neighborhood functions in a _neighbors.py file? I think i like it, but just trying to think of potential pitfalls.

@Tagebh
Copy link
Copy Markdown
Author

Tagebh commented Apr 11, 2026

@argerlt, as for me you are more than welcome to tackle this problem if you want to. I don't really know if @hakonanes has started or not, so it's probably best to wait for him to answer as well.

I feel like that it's natural to add new neighborhood functions into _neighbors.py

@argerlt
Copy link
Copy Markdown
Collaborator

argerlt commented Apr 30, 2026

@Tagebh, sorry this took so long, thank you for your patience.

Some starting notes:

  1. To help improve readability and consistency, ORIX tries to stick as close as possible to the numpydoc style guide. In terms of this PR, this means switching docstrings from this:
    """Performs the general preperations to do neighbor-based calculations on CrystalMaps in a vectorized form
        
    Args:
        data: 2d array. Can be a CrystalMap object
        mask: 2D array with same shape as CrystalMap indicating what points are valid or not eg. Non-indexed points
        foot: The kernel that defines which neighbors to look at. Can be both binary and not
        
    Returns: (maybe should be returned as an object instead?)
        indices: Array where the index for each valid point is repeated as many times as it has valid neighbors
        neighbor_indices: Array with the corosponding valid neighbors
        foot_values: contains how each neighbor in neighbor_indices is weighted defined by a given footprine/kernel
        num_neigbors_valid: the number of neighbors each valid point has
        nodes_valid: The index for valid points with at least one valid neighbor
        nodes_no_valid_neighbors: The index for valid points with no valid neighbors
        nodes_not_valid: The index for non-valid points 
        
    Raises:
        Have to add ValueErrors
    """

to something like this:

def Neighbors(data: np.ndarray,
                          foot:np.ndarray,
                          mask:np.ndarray |None=None):  
    """Calculates the arrays necessary for neighbor-based calculations on CrystalMaps in a vectorized form
        
    Parameters
    ---------
        data
            either a 2D array or a crystal map.
        foot
            The kernel defining the local neighborhood.
        mask
            Boolean array of the same shape as `data`, indicating which points are included in the
            neigborhood lookup.
        
    Returns
    -------
        indices
            Array where the index for each valid point is repeated as many times as it has valid neighbors.
        neighbor_indices
            Array with the corosponding valid neighbors
    """
  1. Again for sanity purposes, we try to stick with the PEP 8 naming guidelines, specifically that funcitons should be lowercase, and functions intended for private use should have underscores before them. in this case, Neigbors should be _neighbors, KAM_calc should be kam_calc, etc.

  2. I know my bad code is the root source of this error, but we try to avoid adding new dependencies when we can and especially dependencies on private functions like skimage.morphology._util._raveled_offsets_and_distances and skimage.util._map_array.map_array (note the underscores indicating private functions and modules)

  3. ORIX as a whole also has a strictly enforced style rules to help ensure people a year from now can still read your code. Check the ORIX style guide as well as the guide for setting up a development environment as a starting point, but ill also add you can cheese the system a bit by autofixing your PR by adding the following comment:

pre-commit.ci autofix

@argerlt
Copy link
Copy Markdown
Collaborator

argerlt commented Apr 30, 2026

^ If you click on pre-commit.ci's commit above, you can see what rules it enforced.

from skimage.util._map_array import map_array

from orix.crystal_map import CrystalMap
from orix.quaternion import Orientation
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
from orix.quaternion import Orientation
import orix.quaternion as oqu

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not an official change yet, but the current practice is to import modules as described here to avoid confusion


from orix.crystal_map import CrystalMap
from orix.quaternion import Orientation

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the following function will replace '_raveled_offsets_and_distances`

Suggested change
def _raveled_offsets(array_shape: tuple,
kernel: np.ndarray):
"""Compute neighboring pixel offsets in raveled coordinate space.
This is a simlification of the "_raveled_offsets_and_distances"
function in scikit-image.morphology._util, version 0.26.0.
Parameters
----------
array_shape
the shape of the array the kernel is being applied to.
equivalent to `array.shape`.
kernel
the 2D array representing the neighborhood. this is normally
a 2D Von Neumann neighborhood such as [[0,1,0],[1,0,1],[0,1,0]],
but could also be a larger array of weighted values. all
non-zero entries will produce an offset value.
Returns
-------
raveled_offsets
an array of offsets for each non-zero entry in the kernel.
"""
center = tuple(s // 2 for s in kernel.shape)
offsets = np.stack(
[(idx - c) for idx, c in zip(np.nonzero(kernel), center)], axis=-1
)
ravel_factors = array_shape[1:] + (1,)
raveled_offsets = (offsets * ravel_factors).sum(axis=1)
return np.sort(raveled_offsets)

from orix.quaternion import Orientation


def Neighbors(data, foot, mask=None):
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Few comments on this function:

  1. Avoid capitalizing functions

  2. In general, to improve usability and reduce maintenance down-the-road, we want to require as few inputs and return as few outputs as necessary in a function. I believe (though feel free to disagree, the minimum you need for this function is:
    _find_neighbors(mask, kernel)--> [neighbors, indices]

Everything else you output can be quickly calculated if and when you need if from just the nxm raveled neighbors list, I would also go a step farther and make the indices an optional output since they can be calculated in one line from the mask.

Comment on lines +9 to +91
def Neighbors(data, foot, mask=None):
"""Performs the general preperations to do neighbor-based calculations on CrystalMaps in a vectorized form

Args:
data: 2d array. Can be a CrystalMap object
mask: 2D array with same shape as CrystalMap indicating what points are valid or not eg. Non-indexed points
foot: The kernel that defines which neighbors to look at. Can be both binary and not

Returns: (maybe should be returned as an object instead?)
indices: Array where the index for each valid point is repeated as many times as it has valid neighbors
neighbor_indices: Array with the corosponding valid neighbors
foot_values: contains how each neighbor in neighbor_indices is weighted defined by a given footprine/kernel
num_neigbors_valid: the number of neighbors each valid point has
nodes_valid: The index for valid points with at least one valid neighbor
nodes_no_valid_neighbors: The index for valid points with no valid neighbors
nodes_not_valid: The index for non-valid points

Raises:
Have to add ValueErrors
"""
if mask is None:
if isinstance(data, CrystalMap):
mask = data.is_indexed.reshape(data.shape)
else:
mask = np.full(data.shape, True) # All pointd are valid
nodes = np.flatnonzero(mask)

pad = np.max(foot.shape) // 2
padded_mask = np.pad(mask, pad, mode="constant", constant_values=False)
padded_nodes = np.flatnonzero(padded_mask)

# The offset are given in 1D for the neighbors defined in 2D
neighbor_offsets, dist = _raveled_offsets_and_distances(
padded_mask.shape, footprint=foot
)

padded_neighbors = padded_nodes[:, np.newaxis] + neighbor_offsets
neighbors = map_array(
padded_neighbors, padded_nodes, nodes
) # finds the indeces (in a non-padded format) for the neighbors belonging to valid points
neighbors_mask = padded_mask.reshape(-1)[
padded_neighbors
] # sets which of those neigbors are valid

num_neighbors = np.sum(
neighbors_mask, axis=1
) # the number of neighbors each valid point has
indices = np.repeat(
nodes, num_neighbors
) # Array where the index for each valid point is repeated as many times as it has valid neighbors
neighbor_indices = neighbors[
neighbors_mask
] # Array with the corosponding valid neighbors

# Support for non-binary footprints
foot_offsets, dist = _raveled_offsets_and_distances(foot.shape, footprint=foot)
foot_nodes = foot_offsets + foot.size // 2
foot_values = foot.reshape(-1)[
np.repeat([foot_nodes], mask.sum(), axis=0)[neighbors_mask]
] # contains how each neighbor in neighbor_indices is weighted defined by a given footprine

valid_neighbor_mask = num_neighbors != 0
nodes_valid = nodes[
valid_neighbor_mask
] # The index for valid points with at least one valid neighbor
nodes_no_valid_neighbors = nodes[
~valid_neighbor_mask
] # The index for valid points with no valid neighbors
nodes_invalid = np.flatnonzero(
~mask
) # The index for non-valid points (same as the mask input, not really neccesary)

num_neighbors_valid = num_neighbors[valid_neighbor_mask]

return (
indices,
neighbor_indices,
foot_values,
num_neighbors_valid,
nodes_valid,
nodes_no_valid_neighbors,
nodes_invalid,
)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is my suggestion for an alternate function, which will require editing the remaining functions slightly, but I think will be far more useful down the road

Suggested change
def Neighbors(data, foot, mask=None):
"""Performs the general preperations to do neighbor-based calculations on CrystalMaps in a vectorized form
Args:
data: 2d array. Can be a CrystalMap object
mask: 2D array with same shape as CrystalMap indicating what points are valid or not eg. Non-indexed points
foot: The kernel that defines which neighbors to look at. Can be both binary and not
Returns: (maybe should be returned as an object instead?)
indices: Array where the index for each valid point is repeated as many times as it has valid neighbors
neighbor_indices: Array with the corosponding valid neighbors
foot_values: contains how each neighbor in neighbor_indices is weighted defined by a given footprine/kernel
num_neigbors_valid: the number of neighbors each valid point has
nodes_valid: The index for valid points with at least one valid neighbor
nodes_no_valid_neighbors: The index for valid points with no valid neighbors
nodes_not_valid: The index for non-valid points
Raises:
Have to add ValueErrors
"""
if mask is None:
if isinstance(data, CrystalMap):
mask = data.is_indexed.reshape(data.shape)
else:
mask = np.full(data.shape, True) # All pointd are valid
nodes = np.flatnonzero(mask)
pad = np.max(foot.shape) // 2
padded_mask = np.pad(mask, pad, mode="constant", constant_values=False)
padded_nodes = np.flatnonzero(padded_mask)
# The offset are given in 1D for the neighbors defined in 2D
neighbor_offsets, dist = _raveled_offsets_and_distances(
padded_mask.shape, footprint=foot
)
padded_neighbors = padded_nodes[:, np.newaxis] + neighbor_offsets
neighbors = map_array(
padded_neighbors, padded_nodes, nodes
) # finds the indeces (in a non-padded format) for the neighbors belonging to valid points
neighbors_mask = padded_mask.reshape(-1)[
padded_neighbors
] # sets which of those neigbors are valid
num_neighbors = np.sum(
neighbors_mask, axis=1
) # the number of neighbors each valid point has
indices = np.repeat(
nodes, num_neighbors
) # Array where the index for each valid point is repeated as many times as it has valid neighbors
neighbor_indices = neighbors[
neighbors_mask
] # Array with the corosponding valid neighbors
# Support for non-binary footprints
foot_offsets, dist = _raveled_offsets_and_distances(foot.shape, footprint=foot)
foot_nodes = foot_offsets + foot.size // 2
foot_values = foot.reshape(-1)[
np.repeat([foot_nodes], mask.sum(), axis=0)[neighbors_mask]
] # contains how each neighbor in neighbor_indices is weighted defined by a given footprine
valid_neighbor_mask = num_neighbors != 0
nodes_valid = nodes[
valid_neighbor_mask
] # The index for valid points with at least one valid neighbor
nodes_no_valid_neighbors = nodes[
~valid_neighbor_mask
] # The index for valid points with no valid neighbors
nodes_invalid = np.flatnonzero(
~mask
) # The index for non-valid points (same as the mask input, not really neccesary)
num_neighbors_valid = num_neighbors[valid_neighbor_mask]
return (
indices,
neighbor_indices,
foot_values,
num_neighbors_valid,
nodes_valid,
nodes_no_valid_neighbors,
nodes_invalid,
)
def _find_neighbors(mask: np.ndarray | CrystalMap,
kernel: int | np.ndarray = 8,
return_indices: bool=False,
):
"""Given a 2D boolean array, return the neighbors for each pixel.
Parameters
----------
mask
kernel
return_indices
Returns
-------
neighbors
indices
"""
# Convert mask to a 2D aray of booleans
if isinstance(mask, CrystalMap):
mask = mask.is_indexed.reshape(mask.shape)
mask = np.atleast_2d(mask).astype(bool)
# Convert kernel to a 2D array of floats
if isinstance(kernel, int):
von_neuman_dict = {
4:np.array([[0,1,0],[1,0,1],[0,1,0]]),
8:np.array([[1,1,1],[1,0,1],[1,1,1]]),
}
try:
kernel = von_neuman_dict[kernel]
except KeyError:
raise ValueError(
"'kernel` must be either 4, 8, or a 2D numpy array")
kernel = np.atleast_2d(kernel).astype(float)
if not np.all([mask.ndim ==2,kernel.ndim==2]):
raise ValueError("_find_neighbors only supports 2D arrays")
# calculate the padding, offsets, and indices's of the queried points.
# note: padding must be at least one in all directions.
pad = [(max(x//2,1),max(x-(x//2)-1,1)) for x in kernel.shape]
offsets = _raveled_offsets(padded_mask.shape, kernel)
nodes = np.flatnonzero(mask)
# calculate neighbors for the padded array.
padded_mask = np.pad(mask, pad, mode='constant', constant_values=False)
padded_nodes = np.flatnonzero(padded_mask)
neighbors = padded_nodes[:, np.newaxis] + offsets
# replace the padded indices with the correct unpadded ones, and set
# out-of-bounds or invalid neighbor indices to -1
# NOTE: this section is roughly equivalent to the following, faster
# Cython code:
# from skimage.util._map_array import map_array
# neighbors = map_array(neighbors, padded_nodes, nodes+1) -1
#
# However, _map_array is a private function in skimage 0.26, so the
# following vectorized method is used instead. If this slowdown becomes
# problematic in the future, we should consider writing our own cython
# code.
is_neighbor = np.isin(neighbors,padded_nodes)
depad_dict = dict(zip(padded_nodes, nodes))
depad_func = np.vectorize(lambda x: depad_dict.get(x,-1))
neighbors[is_neighbor] = depad_func(neighbors[is_neighbor])
neighbors[~is_neighbor] = -1
if return_indices:
return neighbors, indices
return neigbors

)


def neighbor_misorientation(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this needs to be a function, but also the quick one-line version of this is:

(ori1*~ori2).reduce().angle

(reads as "find the difference between ori1 and ori2, reduce them with regard to their symmetries, and return just the angle")

it also works for big arrays, ie:

oris = xmap.orientations
misos = oris[nodes,np.newaxis]*~oris[neighbors]

return mis_ori


def KAM_calc(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function should take xmap, and kernel as required inputs and mask and phases as optional (you can skip phase and I can do it if you want, but for multiphase EBSD's, there should be options for all, some, or none of the phases to be included). The docstrings should also be reformatted and the name changed (I'd recommend _kernel_averaged_misorientation_map or '_calc_kam`)

.... also, not an issue now, but down the road, should we include a grain_map input? Most software calculates KAM maps intragrainularly.

return kam_map_im


def NSN_calc(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same advice as with KAM_calc.

@argerlt
Copy link
Copy Markdown
Collaborator

argerlt commented Apr 30, 2026

@Tagebh, I added some comments and suggested changes as a review, feel free to ask for clarification or suggest alternatives.

Also, once these changes are made, you/we will need to write unit tests and examples.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants