Source code for spam.label.label

"""
Library of SPAM functions for dealing with labelled images
Copyright (C) 2020 SPAM Contributors

This program is free software: you can redistribute it and/or modify it
under the terms of the GNU General Public License as published by the Free
Software Foundation, either version 3 of the License, or (at your option)
any later version.

This program is distributed in the hope that it will be useful, but WITHOUT
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
more details.

You should have received a copy of the GNU General Public License along with
this program.  If not, see <http://www.gnu.org/licenses/>.
"""

import multiprocessing

try:
    multiprocessing.set_start_method("fork")
except RuntimeError:
    pass

import matplotlib
import matplotlib.pyplot as plt
import numpy
import progressbar
import scipy.ndimage
import scipy.spatial
import spam.DIC
import spam.filters
import numba
import random
from spam.label.labelToolkit import boundingBoxes as boundingBoxesCPP
from spam.label.labelToolkit import centresOfMass as centresOfMassCPP
from spam.label.labelToolkit import labelToFloat as labelToFloatCPP
from spam.label.labelToolkit import momentOfInertia as momentOfInertiaCPP
from spam.label.labelToolkit import relabel as relabelCPP
from spam.label.labelToolkit import setVoronoi as setVoronoiCPP
from spam.label.labelToolkit import tetPixelLabel as tetPixelLabelCPP
from spam.label.labelToolkit import volumes as volumesCPP

# Define a random colourmap for showing labels
#   This is taken from https://gist.github.com/jgomezdans/402500
randomCmapVals = numpy.random.rand(256, 3)
randomCmapVals[0, :] = numpy.array([1.0, 1.0, 1.0])
randomCmapVals[-1, :] = numpy.array([0.0, 0.0, 0.0])
randomCmap = matplotlib.colors.ListedColormap(randomCmapVals)
del randomCmapVals


# If you change this, remember to change the typedef in tools/labelToolkit/labelToolkitC.hpp
labelType = "<u4"

# Global number of processes
nProcessesDefault = multiprocessing.cpu_count()


[docs] def boundingBoxes(lab): """ Returns bounding boxes for labelled objects using fast C-code which runs a single time through lab Parameters ---------- lab : 3D array of integers Labelled volume, with lab.max() labels Returns ------- boundingBoxes : lab.max()x6 array of ints This array contains, for each label, 6 integers: - Zmin, Zmax - Ymin, Ymax - Xmin, Xmax Note ---- Bounding boxes `are not slices` and so to extract the correct bounding box from a numpy array you should use: lab[ Zmin:Zmax+1, Ymin:Ymax+1, Xmin:Xmax+1 ] Otherwise said, the bounding box of a single-voxel object at 1,1,1 will be: 1,1,1,1,1,1 Also note: for labelled images where some labels are missing, the bounding box returned for this case will be obviously wrong: `e.g.`, Zmin = (z dimension-1) and Zmax = 0 """ # Catch 2D image, and pad if lab.ndim == 2: lab = lab[numpy.newaxis, ...] lab = lab.astype(labelType) boundingBoxes = numpy.zeros((lab.max() + 1, 6), dtype="<u2") boundingBoxesCPP(lab, boundingBoxes) return boundingBoxes
[docs] def centresOfMass(lab, boundingBoxes=None, minVol=None): """ Calculates (binary) centres of mass of each label in labelled image Parameters ---------- lab : 3D array of integers Labelled volume, with lab.max() labels boundingBoxes : lab.max()x6 array of ints, optional Bounding boxes in format returned by ``boundingBoxes``. If not defined (Default = None), it is recomputed by running ``boundingBoxes`` minVol : int, optional The minimum volume in vx to be treated, any object below this threshold is returned as 0 Returns ------- centresOfMass : lab.max()x3 array of floats This array contains, for each label, 3 floats, describing the centre of mass of each label in Z, Y, X order """ if boundingBoxes is None: boundingBoxes = spam.label.boundingBoxes(lab) if minVol is None: minVol = 0 # Catch 2D image, and pad if lab.ndim == 2: lab = lab[numpy.newaxis, ...] lab = lab.astype(labelType) centresOfMass = numpy.zeros((lab.max() + 1, 3), dtype="<f4") centresOfMassCPP(lab, boundingBoxes, centresOfMass, minVol) return centresOfMass
[docs] def volumes(lab, boundingBoxes=None): """ Calculates (binary) volumes each label in labelled image, using potentially slow numpy.where Parameters ---------- lab : 3D array of integers Labelled volume, with lab.max() labels boundingBoxes : lab.max()x6 array of ints, optional Bounding boxes in format returned by ``boundingBoxes``. If not defined (Default = None), it is recomputed by running ``boundingBoxes`` Returns ------- volumes : lab.max()x1 array of ints This array contains the volume in voxels of each label """ # print "label.toolkit.volumes(): Warning this is a crappy python implementation" lab = lab.astype(labelType) if boundingBoxes is None: boundingBoxes = spam.label.boundingBoxes(lab) volumes = numpy.zeros((lab.max() + 1), dtype="<u4") volumesCPP(lab, boundingBoxes, volumes) return volumes
[docs] def equivalentRadii(lab, boundingBoxes=None, volumes=None): """ Calculates (binary) equivalent sphere radii of each label in labelled image Parameters ---------- lab : 3D array of integers Labelled volume, with lab.max() labels boundingBoxes : lab.max()x6 array of ints, optional Bounding boxes in format returned by ``boundingBoxes``. If not defined (Default = None), it is recomputed by running ``boundingBoxes`` volumes : lab.max()x1 array of ints Vector contining volumes, if this is passed, the others are ignored Returns ------- equivRadii : lab.max()x1 array of floats This array contains the equivalent sphere radius in pixels of each label """ def vol2rad(volumes): return ((3.0 * volumes) / (4.0 * numpy.pi)) ** (1.0 / 3.0) # If we have volumes, just go for it if volumes is not None: return vol2rad(volumes) # If we don't have bounding boxes, recalculate them if boundingBoxes is None: boundingBoxes = spam.label.boundingBoxes(lab) return vol2rad(spam.label.volumes(lab, boundingBoxes=boundingBoxes))
[docs] def momentOfInertia(lab, boundingBoxes=None, minVol=None, centresOfMass=None): """ Calculates (binary) moments of inertia of each label in labelled image Parameters ---------- lab : 3D array of integers Labelled volume, with lab.max() labels boundingBoxes : lab.max()x6 array of ints, optional Bounding boxes in format returned by ``boundingBoxes``. If not defined (Default = None), it is recomputed by running ``boundingBoxes`` centresOfMass : lab.max()x3 array of floats, optional Centres of mass in format returned by ``centresOfMass``. If not defined (Default = None), it is recomputed by running ``centresOfMass`` minVol : int, optional The minimum volume in vx to be treated, any object below this threshold is returned as 0 Default = default for spam.label.centresOfMass Returns ------- eigenValues : lab.max()x3 array of floats The values of the three eigenValues of the moment of inertia of each labelled shape eigenVectors : lab.max()x9 array of floats 3 x Z,Y,X components of the three eigenValues in the order of the eigenValues """ if boundingBoxes is None: boundingBoxes = spam.label.boundingBoxes(lab) if centresOfMass is None: centresOfMass = spam.label.centresOfMass(lab, boundingBoxes=boundingBoxes, minVol=minVol) lab = lab.astype(labelType) eigenValues = numpy.zeros((lab.max() + 1, 3), dtype="<f4") eigenVectors = numpy.zeros((lab.max() + 1, 9), dtype="<f4") momentOfInertiaCPP(lab, boundingBoxes, centresOfMass, eigenValues, eigenVectors) return [eigenValues, eigenVectors]
[docs] def ellipseAxes(lab, volumes=None, MOIeigenValues=None, enforceVolume=True, twoD=False): """ Calculates length of half-axes a,b,c of the ellipitic fit of the particle. These are half-axes and so are comparable to the radius -- and not the diameter -- of the particle. See appendix of for inital work: "Three-dimensional study on the interconnection and shape of crystals in a graphic granite by X-ray CT and image analysis.", Ikeda, S., Nakano, T., & Nakashima, Y. (2000). Parameters ---------- lab : 3D array of integers Labelled volume, with lab.max() labels Note: This is not strictly necessary if volumes and MOI is given volumes : 1D array of particle volumes (optional, default = None) Volumes of particles (length of array = lab.max()) MOIeigenValues : lab.max()x3 array of floats, (optional, default = None) Bounding boxes in format returned by ``boundingBoxes``. If not defined (Default = None), it is recomputed by running ``boundingBoxes`` enforceVolume = bool (default = True) Should a, b and c be scaled to enforce the fitted ellipse volume to be the same as the particle? This causes eigenValues are no longer completely consistent with fitted ellipse twoD : bool (default = False) Are these in fact 2D ellipses? Not implemented!! Returns ------- ABCaxes : lab.max()x3 array of floats a, b, c lengths of particle in pixels Note ----- Our elliptic fit is not necessarily of the same volume as the original particle, although by default we scale all axes linearly with `enforceVolumes` to enforce this condition. Reminder: volume of an ellipse is (4/3)*pi*a*b*c Useful check from TM: Ia = (4/15)*pi*a*b*c*(b**2+c**2) Function contributed by Takashi Matsushima (University of Tsukuba) """ # Full ref: # @misc{ikeda2000three, # title={Three-dimensional study on the interconnection and shape of crystals in a graphic granite by X-ray CT and image analysis}, # author={Ikeda, S and Nakano, T and Nakashima, Y}, # year={2000}, # publisher={De Gruyter} # } if volumes is None: volumes = spam.label.volumes(lab) if MOIeigenValues is None: MOIeigenValues = spam.label.momentOfInertia(lab)[0] ABCaxes = numpy.zeros((volumes.shape[0], 3)) Ia = MOIeigenValues[:, 0] Ib = MOIeigenValues[:, 1] Ic = MOIeigenValues[:, 2] # Initial derivation -- has quite a different volume from the original particle # Use the particle's V. This is a source of inconsistency, # since the condition V = (4/3) * pi * a * b * c is not necessarily respected # ABCaxes[:,2] = numpy.sqrt( numpy.multiply((5.0/(2.0*volumes.ravel())),( Ib + Ia - Ic ) ) ) # ABCaxes[:,1] = numpy.sqrt( numpy.multiply((5.0/(2.0*volumes.ravel())),( Ia + Ic - Ib ) ) ) # ABCaxes[:,0] = numpy.sqrt( numpy.multiply((5.0/(2.0*volumes.ravel())),( Ic + Ib - Ia ) ) ) mask = numpy.logical_and(Ia != 0, numpy.isfinite(Ia)) # Calculate a, b and c: TM calculation 2018-03-30 # 2018-04-30 EA and MW: swap A and C so that A is the biggest ABCaxes[mask, 2] = ((15.0 / (8.0 * numpy.pi)) * numpy.square(Ib[mask] + Ic[mask] - Ia[mask]) / numpy.sqrt((Ia[mask] - Ib[mask] + Ic[mask]) * (Ia[mask] + Ib[mask] - Ic[mask]))) ** (1.0 / 5.0) ABCaxes[mask, 1] = ((15.0 / (8.0 * numpy.pi)) * numpy.square(Ic[mask] + Ia[mask] - Ib[mask]) / numpy.sqrt((Ib[mask] - Ic[mask] + Ia[mask]) * (Ib[mask] + Ic[mask] - Ia[mask]))) ** (1.0 / 5.0) ABCaxes[mask, 0] = ((15.0 / (8.0 * numpy.pi)) * numpy.square(Ia[mask] + Ib[mask] - Ic[mask]) / numpy.sqrt((Ic[mask] - Ia[mask] + Ib[mask]) * (Ic[mask] + Ia[mask] - Ib[mask]))) ** (1.0 / 5.0) if enforceVolume: # Compute volume of ellipse: ellipseVol = (4.0 / 3.0) * numpy.pi * ABCaxes[:, 0] * ABCaxes[:, 1] * ABCaxes[:, 2] # filter zeros and infs # print volumes.shape # print ellipseVol.shape volRatio = (volumes[mask] / ellipseVol[mask]) ** (1.0 / 3.0) # print volRatio ABCaxes[mask, 0] = ABCaxes[mask, 0] * volRatio ABCaxes[mask, 1] = ABCaxes[mask, 1] * volRatio ABCaxes[mask, 2] = ABCaxes[mask, 2] * volRatio return ABCaxes
[docs] def convertLabelToFloat(lab, vector): """ Replaces all values of a labelled array with a given value. Useful for visualising properties attached to labels, `e.g.`, sand grain displacements. Parameters ---------- lab : 3D array of integers Labelled volume, with lab.max() labels vector : a lab.max()x1 vector with values to replace each label with Returns ------- relabelled : 3D array of converted floats """ lab = lab.astype(labelType) relabelled = numpy.zeros_like(lab, dtype="<f4") vector = vector.ravel().astype("<f4") labelToFloatCPP(lab, vector, relabelled) return relabelled
[docs] def makeLabelsSequential(lab): """ This function fills gaps in labelled images, by relabelling them to be sequential integers. Don't forget to recompute all your grain properties since the label numbers will change Parameters ----------- lab : 3D numpy array of ints ( of type spam.label.toolkit.labelType) An array of labels with 0 as the background Returns -------- lab : 3D numpy array of ints ( of type spam.label.toolkit.labelType) An array of labels with 0 as the background """ maxLabel = int(lab.max()) lab = lab.astype(labelType) uniqueLabels = numpy.unique(lab) # print uniqueLabels relabelMap = numpy.zeros((maxLabel + 1), dtype=labelType) relabelMap[uniqueLabels] = range(len(uniqueLabels)) relabelCPP(lab, relabelMap) return lab
[docs] def getLabel( labelledVolume, label, boundingBoxes=None, centresOfMass=None, margin=0, extractCube=False, extractCubeSize=None, maskOtherLabels=True, labelDilate=0, labelDilateMaskOtherLabels=False, ): """ Helper function to extract labels from a labelled image/volume. A dictionary is returned with the a subvolume around the particle. Passing boundingBoxes and centresOfMass is highly recommended. Parameters ---------- labelVolume : 3D array of ints 3D Labelled volume label : int Label that we want information about boundingBoxes : nLabels*2 array of ints, optional Bounding boxes as returned by ``boundingBoxes``. Optional but highly recommended. If unset, bounding boxes are recalculated for every call. centresOfMass : nLabels*3 array of floats, optional Centres of mass as returned by ``centresOfMass``. Optional but highly recommended. If unset, centres of mass are recalculated for every call. extractCube : bool, optional Return label subvolume in the middle of a cube? Default = False extractCubeSize : int, optional half-size of cube to extract. Default = calculate minimum cube margin : int, optional Extract a ``margin`` pixel margin around bounding box or cube. Default = 0 maskOtherLabels : bool, optional In the returned subvolume, should other labels be masked? If true, the mask is directly returned. Default = True labelDilate : int, optional Number of times label should be dilated before returning it? This can be useful for catching the outside/edge of an image. ``margin`` should at least be equal to this value. Requires ``maskOtherLabels``. Default = 0 labelDilateMaskOtherLabels : bool, optional Strictly cut the other labels out of the dilated image of the requested label? Only pertinent for positive labelDilate values. Default = False Returns ------- Dictionary containing: Keys: subvol : 3D array of bools or ints subvolume from labelled image slice : tuple of 3*slices Slice used to extract subvol for the bounding box mode sliceCube : tuple of 3*slices Slice used to extract subvol for the cube mode, warning, if the label is near the edge, this is the slice up to the edge, and so it will be smaller than the returned cube boundingBox : 1D numpy array of 6 ints Bounding box, including margin, in bounding box mode. Contains: [Zmin, Zmax, Ymin, Ymax, Xmin, Xmax] Note: uses the same convention as spam.label.boundingBoxes, so if you want to use this to extract your subvolume, add +1 to max boundingBoxCube : 1D numpy array of 6 ints Bounding box, including margin, in cube mode. Contains: [Zmin, Zmax, Ymin, Ymax, Xmin, Xmax] Note: uses the same convention as spam.label.boundingBoxes, so if you want to use this to extract your subvolume, add +1 to max centreOfMassABS : 3*float Centre of mass with respect to ``labelVolume`` centreOfMassREL : 3*float Centre of mass with respect to ``subvol`` volumeInitial : int Volume of label (before dilating) volumeDilated : int Volume of label (after dilating, if requested) """ import spam.mesh if boundingBoxes is None: print("\tlabel.toolkit.getLabel(): Bounding boxes not passed.") print("\tThey will be recalculated for each label, highly recommend calculating outside this function") boundingBoxes = spam.label.boundingBoxes(labelledVolume) if centresOfMass is None: print("\tlabel.toolkit.getLabel(): Centres of mass not passed.") print("\tThey will be recalculated for each label, highly recommend calculating outside this function") centresOfMass = spam.label.centresOfMass(labelledVolume) # Check if there is a bounding box for this label: if label >= boundingBoxes.shape[0]: return raise "No bounding boxes for this grain" bbo = boundingBoxes[label] com = centresOfMass[label] comRound = numpy.floor(centresOfMass[label]) # 1. Check if boundingBoxes are correct: if (bbo[0] == labelledVolume.shape[0] - 1) and (bbo[1] == 0) and (bbo[2] == labelledVolume.shape[1] - 1) and (bbo[3] == 0) and (bbo[4] == labelledVolume.shape[2] - 1) and (bbo[5] == 0): pass # print("\tlabel.toolkit.getLabel(): Label {} does not exist".format(label)) else: # Define output dictionary since we'll add different things to it output = {} output["centreOfMassABS"] = com # We have a bounding box, let's extract it. if extractCube: # Calculate offsets between centre of mass and bounding box offsetTop = numpy.ceil(com - bbo[0::2]) offsetBot = numpy.ceil(com - bbo[0::2]) offset = numpy.max(numpy.hstack([offsetTop, offsetBot])) # If is none, assume closest fitting cube. if extractCubeSize is not None: if extractCubeSize < offset: print("\tlabel.toolkit.getLabel(): size of desired cube is smaller than minimum to contain label. Continuing anyway.") offset = int(extractCubeSize) # if a margin is set, add it to offset # if margin is not None: offset += margin offset = int(offset) # we may go outside the volume. Let's check this labSubVol = numpy.zeros(3 * [2 * offset + 1]) topOfSlice = numpy.array( [ int(comRound[0] - offset), int(comRound[1] - offset), int(comRound[2] - offset), ] ) botOfSlice = numpy.array( [ int(comRound[0] + offset + 1), int(comRound[1] + offset + 1), int(comRound[2] + offset + 1), ] ) labSubVol = spam.helpers.slicePadded( labelledVolume, [ topOfSlice[0], botOfSlice[0], topOfSlice[1], botOfSlice[1], topOfSlice[2], botOfSlice[2], ], ) output["sliceCube"] = ( slice(topOfSlice[0], botOfSlice[0]), slice(topOfSlice[1], botOfSlice[1]), slice(topOfSlice[2], botOfSlice[2]), ) output["boundingBoxCube"] = numpy.array( [ topOfSlice[0], botOfSlice[0] - 1, topOfSlice[1], botOfSlice[1] - 1, topOfSlice[2], botOfSlice[2] - 1, ] ) output["centreOfMassREL"] = com - topOfSlice # We have a bounding box, let's extract it. else: topOfSlice = numpy.array([int(bbo[0] - margin), int(bbo[2] - margin), int(bbo[4] - margin)]) botOfSlice = numpy.array( [ int(bbo[1] + margin + 1), int(bbo[3] + margin + 1), int(bbo[5] + margin + 1), ] ) labSubVol = spam.helpers.slicePadded( labelledVolume, [ topOfSlice[0], botOfSlice[0], topOfSlice[1], botOfSlice[1], topOfSlice[2], botOfSlice[2], ], ) output["slice"] = ( slice(topOfSlice[0], botOfSlice[0]), slice(topOfSlice[1], botOfSlice[1]), slice(topOfSlice[2], botOfSlice[2]), ) output["boundingBox"] = numpy.array( [ topOfSlice[0], botOfSlice[0] - 1, topOfSlice[1], botOfSlice[1] - 1, topOfSlice[2], botOfSlice[2] - 1, ] ) output["centreOfMassREL"] = com - topOfSlice # Get mask for this label maskLab = labSubVol == label volume = numpy.sum(maskLab) output["volumeInitial"] = volume # if we should mask, just return the mask. if maskOtherLabels: # 2019-09-07 EA: changing dilation/erosion into a single pass by a spherical element, rather than repeated # iterations of the standard. if labelDilate > 0: if labelDilate >= margin: print("\tlabel.toolkit.getLabel(): labelDilate requested with a margin smaller than or equal to the number of times to dilate. I hope you know what you're doing!") strucuringElement = spam.mesh.structuringElement(radius=labelDilate, order=2, dim=3) maskLab = scipy.ndimage.binary_dilation(maskLab, structure=strucuringElement, iterations=1) if labelDilateMaskOtherLabels: # remove voxels that are neither our label nor pore maskLab[numpy.logical_and(labSubVol != label, labSubVol != 0)] = 0 if labelDilate < 0: strucuringElement = spam.mesh.structuringElement(radius=-1 * labelDilate, order=2, dim=3) maskLab = scipy.ndimage.binary_erosion(maskLab, structure=strucuringElement, iterations=1) # Just overwrite "labSubVol" labSubVol = maskLab # Update volume output output["volumeDilated"] = labSubVol.sum() output["subvol"] = labSubVol return output
[docs] def getImagettesLabelled( lab1, label, Phi, im1, im2, searchRange, boundingBoxes, centresOfMass, margin=0, labelDilate=0, maskOtherLabels=True, applyF="all", volumeThreshold=100, ): """ This function is responsible for extracting correlation windows ("imagettes") from two larger images (im1 and im2) with the help of a labelled im1. This is generally to do image correlation, this function will be used for spam-ddic and pixelSearch modes. Parameters ---------- lab1 : 3D numpy array of ints Labelled image containing nLabels label : int Label of interest Phi : 4x4 numpy array of floats Phi matrix representing the movement of imagette1, if not equal to `I`, imagette1 is deformed by the non-translation parts of Phi (F) and the displacement is added to the search range (see below) im1 : 3D numpy array This is the large input reference image of greyvalues im2 : 3D numpy array This is the large input deformed image of greyvalues searchRange : 6-component numpy array of ints This defines where imagette2 should be extracted with respect to imagette1's position in im1. The 6 components correspond to [ Zbot Ztop Ybot Ytop Xbot Xtop ]. If Z, Y and X values are the same, then imagette2 will be displaced and the same size as imagette1. If 'bot' is lower than 'top', imagette2 will be larger in that dimension boundingBoxes : nLabels*2 array of ints Bounding boxes as returned by ``boundingBoxes`` centresOfMass : nLabels*3 array of floats Centres of mass as returned by ``centresOfMass`` margin : int, optional Margin around the grain to extract in pixels Default = 0 labelDilate : int, optional How much to dilate the label before computing the mask? Default = 0 maskOtherLabels : bool, optional In the returned subvolume, should other labels be masked? If true, the mask is directly returned. Default = True applyF : string, optional If a non-identity Phi is passed, should the F be applied to the returned imagette1? Options are: 'all', 'rigid', 'no' Default = 'all' Note: as of January 2021, it seems to make more sense to have this as 'all' for pixelSearch, and 'no' for local DIC volumeThreshold : int, optional Pixel volume of labels that are discarded Default = 100 Returns ------- Dictionary : 'imagette1' : 3D numpy array, 'imagette1mask': 3D numpy array of same size as imagette1 or None, 'imagette2': 3D numpy array, bigger or equal size to imagette1 'returnStatus': int, Describes success in extracting imagette1 and imagette2. If == 1 success, otherwise negative means failure. 'pixelSearchOffset': 3-component list of ints Coordinates of the top of the pixelSearch range in im1, i.e., the displacement that needs to be added to the raw pixelSearch output to make it a im1 -> im2 displacement """ returnStatus = 1 imagette1 = None imagette1mask = None imagette2 = None intDisplacement = numpy.round(Phi[0:3, 3]).astype(int) PhiNoDisp = Phi.copy() # PhiNoDisp[0:3,-1] -= intDisplacement PhiNoDisp[0:3, -1] = numpy.zeros(3) if applyF == "rigid": PhiNoDisp = spam.deformation.computeRigidPhi(PhiNoDisp) gottenLabel = spam.label.getLabel( lab1, label, extractCube=False, boundingBoxes=boundingBoxes, centresOfMass=centresOfMass, margin=labelDilate + margin, maskOtherLabels=True, labelDilate=labelDilate, labelDilateMaskOtherLabels=maskOtherLabels, ) # In case the label is missing or the Phi is duff if gottenLabel is None or not numpy.all(numpy.isfinite(Phi)): returnStatus = -7 else: # Maskette 1 is either a boolean array if args.MASK # otherwise it contains ints i.e., labels # Use new padded slicer, to remain aligned with getLabel['subvol'] # + add 1 on the "max" side for bounding box -> slice imagette1 = spam.helpers.slicePadded(im1, gottenLabel["boundingBox"] + numpy.array([0, 1, 0, 1, 0, 1])) if applyF == "all" or applyF == "rigid": imagette1 = spam.DIC.applyPhi(imagette1, PhiNoDisp, PhiCentre=gottenLabel["centreOfMassREL"]) imagette1mask = ( spam.DIC.applyPhi( gottenLabel["subvol"] > 0, PhiNoDisp, PhiCentre=gottenLabel["centreOfMassREL"], interpolationOrder=0, ) > 0 ) elif applyF == "no": imagette1mask = gottenLabel["subvol"] else: print("spam.label.getImagettesLabelled(): unknown option for applyF options are: ['all', 'rigid', 'no']") maskette1vol = numpy.sum(imagette1mask) if maskette1vol > volumeThreshold: # 2020-09-25 OS and EA: Prepare startStop array for imagette 2 to be extracted with new # slicePadded, this should solved "Boss: failed imDiff" and RS=-5 forever startStopIm2 = [ int(gottenLabel["boundingBox"][0] - margin - max(labelDilate, 0) + searchRange[0] + intDisplacement[0]), int(gottenLabel["boundingBox"][1] + margin + max(labelDilate, 0) + searchRange[1] + intDisplacement[0] + 1), int(gottenLabel["boundingBox"][2] - margin - max(labelDilate, 0) + searchRange[2] + intDisplacement[1]), int(gottenLabel["boundingBox"][3] + margin + max(labelDilate, 0) + searchRange[3] + intDisplacement[1] + 1), int(gottenLabel["boundingBox"][4] - margin - max(labelDilate, 0) + searchRange[4] + intDisplacement[2]), int(gottenLabel["boundingBox"][5] + margin + max(labelDilate, 0) + searchRange[5] + intDisplacement[2] + 1), ] imagette2 = spam.helpers.slicePadded(im2, startStopIm2) # imagette2imagette1sizeDiff = numpy.array(imagette2.shape) - numpy.array(imagette1.shape) # If all of imagette2 is nans it fell outside im2 (or in any case it's going to be difficult to correlate) if numpy.all(numpy.isnan(imagette2)): returnStatus = -5 else: # Failed volume condition returnStatus = -5 return { "imagette1": imagette1, "imagette1mask": imagette1mask, "imagette2": imagette2, "returnStatus": returnStatus, "pixelSearchOffset": searchRange[0::2] - numpy.array([max(labelDilate, 0)] * 3) - margin + intDisplacement, }
[docs] def labelsOnEdges(lab): """ Return labels on edges of volume Parameters ---------- lab : 3D numpy array of ints Labelled volume Returns ------- uniqueLabels : list of ints List of labels on edges """ numpy.arange(lab.max() + 1) uniqueLabels = [] uniqueLabels.append(numpy.unique(lab[:, :, 0])) uniqueLabels.append(numpy.unique(lab[:, :, -1])) uniqueLabels.append(numpy.unique(lab[:, 0, :])) uniqueLabels.append(numpy.unique(lab[:, -1, :])) uniqueLabels.append(numpy.unique(lab[0, :, :])) uniqueLabels.append(numpy.unique(lab[-1, :, :])) # Flatten list of lists: # https://stackoverflow.com/questions/952914/making-a-flat-list-out-of-list-of-lists-in-python?utm_medium=organic&utm_source=google_rich_qa&utm_campaign=google_rich_qa uniqueLabels = [item for sublist in uniqueLabels for item in sublist] # There might well be labels that appears on multiple faces of the cube, remove them uniqueLabels = numpy.unique(numpy.array(uniqueLabels)) return uniqueLabels.astype(labelType)
[docs] def removeLabels(lab, listOfLabelsToRemove): """ Resets a list of labels to zero in a labelled volume. Parameters ---------- lab : 3D numpy array of ints Labelled volume listOfLabelsToRemove : list-like of ints Labels to remove Returns ------- lab : 3D numpy array of ints Labelled volume with desired labels blanked Note ---- You might want to use `makeLabelsSequential` after using this function, but don't forget to recompute all your grain properties since the label numbers will change """ lab = lab.astype(labelType) # define a vector with sequential ints arrayOfLabels = numpy.arange(lab.max() + 1, dtype=labelType) # Remove the ones that have been asked for for label in listOfLabelsToRemove: arrayOfLabels[label] = 0 relabelCPP(lab, arrayOfLabels) return lab
[docs] def setVoronoi(lab, poreEDT=None, maxPoreRadius=10): """ This function computes an approximate set Voronoi for a given labelled image. This is a voronoi which does not have straight edges, and which necessarily passes through each contact point, so it is respectful of non-spherical grains. See: Schaller, F. M., Kapfer, S. C., Evans, M. E., Hoffmann, M. J., Aste, T., Saadatfar, M., ... & Schroder-Turk, G. E. (2013). Set Voronoi diagrams of 3D assemblies of aspherical particles. Philosophical Magazine, 93(31-33), 3993-4017. https://doi.org/10.1080/14786435.2013.834389 and Weis, S., Schonhofer, P. W., Schaller, F. M., Schroter, M., & Schroder-Turk, G. E. (2017). Pomelo, a tool for computing Generic Set Voronoi Diagrams of Aspherical Particles of Arbitrary Shape. In EPJ Web of Conferences (Vol. 140, p. 06007). EDP Sciences. Parameters ----------- lab: 3D numpy array of labelTypes Labelled image poreEDT: 3D numpy array of floats (optional, default = None) Euclidean distance map of the pores. If not given, it is computed by scipy.ndimage.distance_transform_edt maxPoreRadius: int (optional, default = 10) Maximum pore radius to be considered (this threshold is for speed optimisation) Returns -------- lab: 3D numpy array of labelTypes Image labelled with set voronoi labels """ if poreEDT is None: # print( "\tlabel.toolkit.setVoronoi(): Calculating the Euclidean Distance Transform of the pore with" ) # print "\t\tscipy.ndimage.distance_transform_edt, this takes a lot of memory" poreEDT = scipy.ndimage.distance_transform_edt(lab == 0).astype("<f4") lab = lab.astype(labelType) labOut = numpy.zeros_like(lab) maxPoreRadius = int(maxPoreRadius) # Prepare sorted distances in a cube to fit a maxPoreRadius. # This precomutation saves a lot of time # Local grid of values, centred at zero gridD = numpy.mgrid[ -maxPoreRadius : maxPoreRadius + 1, -maxPoreRadius : maxPoreRadius + 1, -maxPoreRadius : maxPoreRadius + 1, ] # Compute distances from centre Rarray = numpy.sqrt(numpy.square(gridD[0]) + numpy.square(gridD[1]) + numpy.square(gridD[2])).ravel() sortedIndices = numpy.argsort(Rarray) # Array to hold sorted points coords = numpy.zeros((len(Rarray), 3), dtype="<i4") # Fill in with Z, Y, X points in order of distance to centre coords[:, 0] = gridD[0].ravel()[sortedIndices] coords[:, 1] = gridD[1].ravel()[sortedIndices] coords[:, 2] = gridD[2].ravel()[sortedIndices] del gridD # Now define a simple array (by building a list) that gives the linear # entry point into coords at the nearest integer values sortedDistances = Rarray[sortedIndices] indices = [] n = 0 i = 0 while i <= maxPoreRadius + 1: if sortedDistances[n] >= i: # indices.append( [ i, n ] ) indices.append(n) i += 1 n += 1 indices = numpy.array(indices).astype("<i4") # Call C++ code setVoronoiCPP(lab, poreEDT.astype("<f4"), labOut, coords, indices) return labOut
[docs] def labelTetrahedra(dims, points, connectivity, nThreads=1): """ Labels voxels corresponding to tetrahedra according to a connectivity matrix and node points Parameters ---------- dims: tuple representing z,y,x dimensions of the desired labelled output points: number of points x 3 array of floats List of points that define the vertices of the tetrahedra in Z,Y,X format. These points are referred to by line number in the connectivity array connectivity: number of tetrahedra x 4 array of integers Connectivity matrix between points that define tetrahedra. Each line defines a tetrahedron whose number is the line number + 1. Each line contains 4 integers that indicate the 4 points in the nodePos array. nThreads: int (optional, default=1) The number of threads used for the cpp parallelisation. Returns ------- 3D array of ints, shape = dims Labelled 3D volume where voxels are numbered according to the tetrahedron number they fall inside of # WARNING: Voxels outside of the mesh get a value of #tetrahedra + 1 """ assert len(dims) == 3, "spam.label.labelTetrahedra(): dim is not length 3" assert points.shape[1] == 3, "spam.label.labelTetrahedra(): points doesn't have 3 colums" assert connectivity.shape[1] == 4, "spam.label.labelTetrahedra(): connectivity doesn't have 4 colums" assert points.shape[0] >= connectivity.max(), "spam.label.labelTetrahedra(): connectivity should not refer to points numbers biggest than the number of rows in points" dims = numpy.array(dims).astype("<u2") # WARNING: here we set the background to be number of tetra + 1 # bold choice but that's ok lab = numpy.ones(tuple(dims), dtype=labelType) * connectivity.shape[0] + 1 connectivity = connectivity.astype("<u4") points = points.astype("<f4") tetPixelLabelCPP(lab, connectivity, points, nThreads) return lab
[docs] def labelTetrahedraForScipyDelaunay(dims, delaunay): """ Labels voxels corresponding to tetrahedra coming from scipy.spatial.Delaunay Apparently the cells are not well-numbered, which causes a number of zeros when using `labelledTetrahedra` Parameters ---------- dims: tuple represents z,y,x dimensions of the desired labelled output delaunay: "delaunay" object Object returned by scipy.spatial.Delaunay( centres ) Hint: If using label.toolkit.centresOfMass( ), do centres[1:] to remove the position of zero. Returns ------- lab: 3D array of ints, shape = dims Labelled 3D volume where voxels are numbered according to the tetrahedron number they fall inside of """ # Big matrix of points poisitions points = numpy.zeros((dims[0] * dims[1] * dims[2], 3)) mgrid = numpy.mgrid[0 : dims[0], 0 : dims[1], 0 : dims[2]] for i in [0, 1, 2]: points[:, i] = mgrid[i].ravel() del mgrid lab = numpy.ones(tuple(dims), dtype=labelType) * delaunay.nsimplex + 1 lab = delaunay.find_simplex(points).reshape(dims) return lab
[docs] def filterIsolatedCells(array, struct, size): """ Return array with completely isolated single cells removed Parameters ---------- array: 3-D (labelled or binary) array Array with completely isolated single cells struct: 3-D binary array Structure array for generating unique regions size: integer Size of the isolated cells to exclude (Number of Voxels) Returns ------- filteredArray: 3-D (labelled or binary) array Array with minimum region size > size Notes ----- function from: http://stackoverflow.com/questions/28274091/removing-completely-isolated-cells-from-python-array """ filteredArray = ((array > 0) * 1).astype("uint8") idRegions, numIDs = scipy.ndimage.label(filteredArray, structure=struct) idSizes = numpy.array(scipy.ndimage.sum(filteredArray, idRegions, range(numIDs + 1))) areaMask = idSizes <= size filteredArray[areaMask[idRegions]] = 0 filteredArray = ((filteredArray > 0) * 1).astype("uint8") array = filteredArray * array return array
[docs] def trueSphericity(lab, boundingBoxes=None, centresOfMass=None, gaussianFilterSigma=0.75, minVol=256): """ Calculates the degree of True Sphericity (psi) for all labels, as per: "Sphericity measures of sand grains" Rorato et al., Engineering Geology, 2019 and originlly proposed in: "Volume, shape, and roundness of rock particles", Waddell, The Journal of Geology, 1932. True Sphericity (psi) = Surface area of equivalent sphere / Actual surface area The actual surface area is computed by extracting each particle with getLabel, a Gaussian smooth of 0.75 is applied and the marching cubes algorithm from skimage is used to mesh the surface and compute the surface area. Parameters ---------- lab : 3D array of integers Labelled volume, with lab.max() labels boundingBoxes : lab.max()x6 array of ints, optional Bounding boxes in format returned by ``boundingBoxes``. If not defined (Default = None), it is recomputed by running ``boundingBoxes`` centresOfMass : lab.max()x3 array of floats, optional Centres of mass in format returned by ``centresOfMass``. If not defined (Default = None), it is recomputed by running ``centresOfMass`` gaussianFilterSigma : float, optional Sigma of the Gaussian filter used to smooth the binarised shape Default = 0.75 minVol : int, optional The minimum volume in vx to be treated, any object below this threshold is returned as 0 Default = 256 voxels Returns ------- trueSphericity : lab.max() array of floats The values of the degree of true sphericity for each particle Notes ----- Function contributed by Riccardo Rorato (UPC Barcelona) Due to numerical errors, this value can be >1, it should be clipped at 1.0 """ import skimage.measure lab = lab.astype(labelType) if boundingBoxes is None: boundingBoxes = spam.label.boundingBoxes(lab) if centresOfMass is None: centresOfMass = spam.label.centresOfMass(lab, boundingBoxes=boundingBoxes, minVol=minVol) trueSphericity = numpy.zeros((lab.max() + 1), dtype="<f4") sphereSurfaceArea = 4.0 * numpy.pi * (equivalentRadii(lab, boundingBoxes=boundingBoxes) ** 2) for label in range(1, lab.max() + 1): if not (centresOfMass[label] == numpy.array([0.0, 0.0, 0.0])).all(): # Extract grain GL = spam.label.getLabel( lab, label, boundingBoxes=boundingBoxes, centresOfMass=centresOfMass, extractCube=True, margin=2, maskOtherLabels=True, ) # Gaussian smooth grainCubeFiltered = scipy.ndimage.gaussian_filter(GL["subvol"].astype("<f4"), sigma=gaussianFilterSigma) # mesh edge verts, faces, _, _ = skimage.measure.marching_cubes(grainCubeFiltered, level=0.5) # compute surface surfaceArea = skimage.measure.mesh_surface_area(verts, faces) # compute psi trueSphericity[label] = sphereSurfaceArea[label] / surfaceArea return trueSphericity
[docs] def convexVolume( lab, boundingBoxes=None, centresOfMass=None, volumes=None, nProcesses=nProcessesDefault, verbose=True, ): """ This function compute the convex hull of each label of the labelled image and return a list with the convex volume of each particle. Parameters ---------- lab : 3D array of integers Labelled volume, with lab.max() labels boundingBoxes : lab.max()x6 array of ints, optional Bounding boxes in format returned by ``boundingBoxes``. If not defined (Default = None), it is recomputed by running ``boundingBoxes`` centresOfMass : lab.max()x3 array of floats, optional Centres of mass in format returned by ``centresOfMass``. If not defined (Default = None), it is recomputed by running ``centresOfMass`` volumes : lab.max()x1 array of ints Volumes in format returned by ``volumes`` If not defined (Default = None), it is recomputed by running ``volumes`` nProcesses : integer (optional, default = nProcessesDefault) Number of processes for multiprocessing Default = number of CPUs in the system verbose : boolean (optional, default = False) True for printing the evolution of the process False for not printing the evolution of process Returns -------- convexVolume : lab.max()x1 array of floats with the convex volume. Note ---- convexVolume can only be computed for particles with volume greater than 3 voxels. If it is not the case, it will return 0. """ lab = lab.astype(labelType) # Compute boundingBoxes if needed if boundingBoxes is None: boundingBoxes = spam.label.boundingBoxes(lab) # Compute centresOfMass if needed if centresOfMass is None: centresOfMass = spam.label.centresOfMass(lab) # Compute volumes if needed if volumes is None: volumes = spam.label.volumes(lab) # Compute number of labels nLabels = lab.max() # Result array convexVolume = numpy.zeros(nLabels + 1, dtype="float") if verbose: widgets = [ progressbar.FormatLabel(""), " ", progressbar.Bar(), " ", progressbar.AdaptiveETA(), ] pbar = progressbar.ProgressBar(widgets=widgets, maxval=nLabels) pbar.start() finishedNodes = 0 # Function for convex volume global computeConvexVolume def computeConvexVolume(label): labelI = spam.label.getLabel(lab, label, boundingBoxes=boundingBoxes, centresOfMass=centresOfMass) subvol = labelI["subvol"] points = numpy.transpose(numpy.where(subvol)) try: hull = scipy.spatial.ConvexHull(points) deln = scipy.spatial.Delaunay(points[hull.vertices]) idx = numpy.stack(numpy.indices(subvol.shape), axis=-1) out_idx = numpy.nonzero(deln.find_simplex(idx) + 1) hullIm = numpy.zeros(subvol.shape) hullIm[out_idx] = 1 hullVol = spam.label.volumes(hullIm) return label, hullVol[-1] except Exception: return label, 0 # Run multiprocessing with multiprocessing.Pool(processes=nProcesses) as pool: for returns in pool.imap_unordered(computeConvexVolume, range(1, nLabels + 1)): if verbose: finishedNodes += 1 pbar.update(finishedNodes) convexVolume[returns[0]] = returns[1] pool.close() pool.join() if verbose: pbar.finish() return convexVolume
[docs] def moveLabels( lab, PhiField, returnStatus=None, boundingBoxes=None, centresOfMass=None, margin=3, PhiCOM=True, threshold=0.5, labelDilate=0, nProcesses=nProcessesDefault, ): """ This function applies a discrete Phi field (from DDIC?) over a labelled image. Parameters ----------- lab : 3D numpy array Labelled image PhiField : (multidimensional x 4 x 4 numpy array of floats) Spatial field of Phis returnStatus : lab.max()x1 array of ints, optional Array with the return status for each label (usually returned by ``spam-ddic``) If not defined (Default = None), all the labels will be moved If returnStatus[i] == 2, the label will be moved, otherwise is omitted and erased from the final image boundingBoxes : lab.max()x6 array of ints, optional Bounding boxes in format returned by ``boundingBoxes``. If not defined (Default = None), it is recomputed by running ``boundingBoxes`` centresOfMass : lab.max()x3 array of floats, optional Centres of mass in format returned by ``centresOfMass``. If not defined (Default = None), it is recomputed by running ``centresOfMass`` margin : int, optional Margin, in pixels, to take in each label. Default = 3 PhiCOM : bool, optional Apply Phi to centre of mass of particle?, otherwise it will be applied in the middle of the particle\'s bounding box. Default = True threshold : float, optional Threshold to keep interpolated voxels in the binary image. Default = 0.5 labelDilate : int, optional Number of times label should be dilated/eroded before returning it. If ``labelDilate > 0`` a dilated label is returned, while ``labelDilate < 0`` returns an eroded label. Default = 0 nProcesses : integer (optional, default = nProcessesDefault) Number of processes for multiprocessing Default = number of CPUs in the system Returns -------- labOut : 3D numpy array New labelled image with the labels moved by the deformations established by the PhiField. """ # Check for boundingBoxes if boundingBoxes is None: boundingBoxes = spam.label.boundingBoxes(lab) # Check for centresOfMass if centresOfMass is None: centresOfMass = spam.label.centresOfMass(lab) # Create output label image labOut = numpy.zeros_like(lab, dtype=spam.label.labelType) # Get number of labels numberOfLabels = min(lab.max(), PhiField.shape[0] - 1) numberOfLabelsToMove = 0 labelsToMove = [] # Add the labels to move for label in range(1, numberOfLabels + 1): # Skip the particles if the returnStatus == 2 and returnStatus != None if type(returnStatus) == numpy.ndarray and returnStatus[label] != 2: pass else: # Add the particles labelsToMove.append(label) numberOfLabelsToMove += 1 # Function for moving labels global funMoveLabels def funMoveLabels(label): getLabelReturn = spam.label.getLabel( lab, label, labelDilate=labelDilate, margin=margin, boundingBoxes=boundingBoxes, centresOfMass=centresOfMass, extractCube=True, ) # Check that the label exist if getLabelReturn is not None: # Get Phi field Phi = PhiField[label].copy() # Phi will be split into a local part and a part of floored displacements disp = numpy.floor(Phi[0:3, -1]).astype(int) Phi[0:3, -1] -= disp # Check that the displacement exist if numpy.isfinite(disp).sum() == 3: # Just move binary label # Need to do backtracking here to avoid holes in the NN interpolation # Here we will cheat and do order 1 and re-threshold full pixels if PhiCOM: labSubvolDefInterp = spam.DIC.applyPhi( getLabelReturn["subvol"], Phi=Phi, interpolationOrder=1, PhiCentre=getLabelReturn["centreOfMassREL"], ) else: labSubvolDefInterp = spam.DIC.applyPhi( getLabelReturn["subvol"], Phi=Phi, interpolationOrder=1, PhiCentre=(numpy.array(getLabelReturn["subvol"].shape) - 1) / 2.0, ) # "death mask" labSubvolDefMask = labSubvolDefInterp >= threshold del labSubvolDefInterp # Get the boundary of the cube topOfSlice = numpy.array( [ getLabelReturn["boundingBoxCube"][0] + disp[0], getLabelReturn["boundingBoxCube"][2] + disp[1], getLabelReturn["boundingBoxCube"][4] + disp[2], ] ) botOfSlice = numpy.array( [ getLabelReturn["boundingBoxCube"][1] + disp[0], getLabelReturn["boundingBoxCube"][3] + disp[1], getLabelReturn["boundingBoxCube"][5] + disp[2], ] ) topOfSliceCrop = numpy.array( [ max(topOfSlice[0], 0), max(topOfSlice[1], 0), max(topOfSlice[2], 0), ] ) botOfSliceCrop = numpy.array( [ min(botOfSlice[0], lab.shape[0]), min(botOfSlice[1], lab.shape[1]), min(botOfSlice[2], lab.shape[2]), ] ) # Update grainSlice with disp grainSlice = ( slice(topOfSliceCrop[0], botOfSliceCrop[0]), slice(topOfSliceCrop[1], botOfSliceCrop[1]), slice(topOfSliceCrop[2], botOfSliceCrop[2]), ) # Update labSubvolDefMask labSubvolDefMaskCrop = labSubvolDefMask[ topOfSliceCrop[0] - topOfSlice[0] : labSubvolDefMask.shape[0] - 1 + botOfSliceCrop[0] - botOfSlice[0], topOfSliceCrop[1] - topOfSlice[1] : labSubvolDefMask.shape[1] - 1 + botOfSliceCrop[1] - botOfSlice[1], topOfSliceCrop[2] - topOfSlice[2] : labSubvolDefMask.shape[2] - 1 + botOfSliceCrop[2] - botOfSlice[2], ] return label, grainSlice, labSubvolDefMaskCrop, 1 # Nan displacement, run away else: return label, 0, 0, -1 # Got None from getLabel() else: return label, 0, 0, -1 # Create progressbar widgets = [ progressbar.FormatLabel(""), " ", progressbar.Bar(), " ", progressbar.AdaptiveETA(), ] pbar = progressbar.ProgressBar(widgets=widgets, maxval=numberOfLabels) pbar.start() finishedNodes = 0 # Run multiprocessing with multiprocessing.Pool(processes=nProcesses) as pool: for returns in pool.imap_unordered(funMoveLabels, labelsToMove): finishedNodes += 1 # widgets[0] = progressbar.FormatLabel("{}/{} ".format(finishedNodes, numberOfLabels)) pbar.update(finishedNodes) # Set voxels in slice to the value of the label if not in greyscale mode if returns[0] > 0 and returns[3] == 1: labOut[returns[1]][returns[2]] = returns[0] pool.close() pool.join() # End progressbar pbar.finish() return labOut
[docs] def erodeLabels(lab, erosion=1, boundingBoxes=None, centresOfMass=None, nProcesses=nProcessesDefault): """ This function erodes a labelled image. Parameters ----------- lab : 3D numpy array Labelled image erosion : int, optional Erosion level boundingBoxes : lab.max()x6 array of ints, optional Bounding boxes in format returned by ``boundingBoxes``. If not defined (Default = None), it is recomputed by running ``boundingBoxes`` centresOfMass : lab.max()x3 array of floats, optional Centres of mass in format returned by ``centresOfMass``. If not defined (Default = None), it is recomputed by running ``centresOfMass`` nProcesses : integer (optional, default = nProcessesDefault) Number of processes for multiprocessing Default = number of CPUs in the system Returns -------- erodeImage : 3D numpy array New labelled image with the eroded labels. Note ---- The function makes use of spam.label.moveLabels() to generate the eroded image. """ # Get number of labels numberOfLabels = lab.max() # Create the Empty Phi field PhiField = numpy.zeros((numberOfLabels + 1, 4, 4)) # Setup Phi as the identity for i in range(0, numberOfLabels + 1, 1): PhiField[i] = numpy.eye(4) # Use moveLabels erodeImage = spam.label.moveLabels( lab, PhiField, boundingBoxes=boundingBoxes, centresOfMass=centresOfMass, margin=1, PhiCOM=True, threshold=0.5, labelDilate=-erosion, nProcesses=nProcesses, ) return erodeImage
[docs] def convexFillHoles(lab, boundingBoxes=None, centresOfMass=None): """ This function fills the holes computing the convex volume around each label. Parameters ----------- lab : 3D numpy array Labelled image boundingBoxes : lab.max()x6 array of ints, optional Bounding boxes in format returned by ``boundingBoxes``. If not defined (Default = None), it is recomputed by running ``boundingBoxes`` centresOfMass : lab.max()x3 array of floats, optional Centres of mass in format returned by ``centresOfMass``. If not defined (Default = None), it is recomputed by running ``centresOfMass`` Returns -------- labOut : 3D numpy array New labelled image. Note ---- The function works nicely for convex particles. For non-convex particles, it will alter the shape. """ # Check for boundingBoxes if boundingBoxes is None: boundingBoxes = spam.label.boundingBoxes(lab) # Check for centresOfMass if centresOfMass is None: centresOfMass = spam.label.centresOfMass(lab) # Create output label image labOut = numpy.zeros_like(lab, dtype=spam.label.labelType) # Get number of labels numberOfLabels = lab.max() # Create progressbar widgets = [ progressbar.FormatLabel(""), " ", progressbar.Bar(), " ", progressbar.AdaptiveETA(), ] pbar = progressbar.ProgressBar(widgets=widgets, maxval=numberOfLabels) pbar.start() for i in range(1, numberOfLabels + 1, 1): # Get label getLabelReturn = spam.label.getLabel( lab, i, labelDilate=0, margin=3, boundingBoxes=boundingBoxes, centresOfMass=centresOfMass, maskOtherLabels=False, ) # Get subvolume subVol = getLabelReturn["subvol"] # Transform to binary subVolBinMask = (subVol > 0).astype(int) # Mask out all the other labels subVolBinMaskLabel = numpy.where(subVol == i, 1, 0).astype(int) # Mask only the current label - save all the other labels subVolMaskOtherLabel = subVolBinMask - subVolBinMaskLabel # Fill holes with convex volume points = numpy.transpose(numpy.where(subVolBinMaskLabel)) hull = scipy.spatial.ConvexHull(points) deln = scipy.spatial.Delaunay(points[hull.vertices]) idx = numpy.stack(numpy.indices(subVol.shape), axis=-1) out_idx = numpy.nonzero(deln.find_simplex(idx) + 1) hullIm = numpy.zeros(subVol.shape) hullIm[out_idx] = 1 hullIm = hullIm > 0 # Identify added voxels subVolAdded = hullIm - subVolBinMaskLabel # Identify the wrong voxels - they are inside other labels subVolWrongAdded = subVolAdded * subVolMaskOtherLabel # Remove wrong filling areas subVolCorrect = (hullIm - subVolWrongAdded) > 0 # Get slice grainSlice = ( slice(getLabelReturn["slice"][0].start, getLabelReturn["slice"][0].stop), slice(getLabelReturn["slice"][1].start, getLabelReturn["slice"][1].stop), slice(getLabelReturn["slice"][2].start, getLabelReturn["slice"][2].stop), ) # Add it to the output file labOut[grainSlice][subVolCorrect] = i # Update the progressbar widgets[0] = progressbar.FormatLabel("{}/{} ".format(i, numberOfLabels)) pbar.update(i) return labOut
[docs] def getNeighbours( lab, listOfLabels, method="getLabel", neighboursRange=None, centresOfMass=None, boundingBoxes=None, ): """ This function computes the neighbours for a list of labels. Parameters ----------- lab : 3D numpy array Labelled image listOfLabels : list of ints List of labels to which the neighbours will be computed method : string Method to compute the neighbours. 'getLabel' : The neighbours are the labels inside the subset obtained through spam.getLabel() 'mesh' : The neighbours are computed using a tetrahedral connectivity matrix Default = 'getLabel' neighboursRange : int Parameter controlling the search range to detect neighbours for each method. For 'getLabel', it correspond to the size of the subset. Default = meanRadii For 'mesh', it correspond to the size of the alpha shape used for carving the mesh. Default = 5*meanDiameter. boundingBoxes : lab.max()x6 array of ints, optional Bounding boxes in format returned by ``boundingBoxes``. If not defined (Default = None), it is recomputed by running ``boundingBoxes`` centresOfMass : lab.max()x3 array of floats, optional Centres of mass in format returned by ``centresOfMass``. If not defined (Default = None), it is recomputed by running ``centresOfMass`` Returns -------- neighbours : list List with the neighbours for each label in listOfLabels. """ # Create result list neighbours = [] # Compute centreOfMass if needed if centresOfMass is None: centresOfMass = spam.label.centresOfMass(lab) # Compute boundingBoxes if needed if boundingBoxes is None: boundingBoxes = spam.label.boundingBoxes(lab) # Compute Radii radii = spam.label.equivalentRadii(lab) if method == "getLabel": # Compute neighboursRange if needed if neighboursRange is None: neighboursRange = numpy.mean(radii) # Compute for each label in the list of labels for label in listOfLabels: getLabelReturn = spam.label.getLabel( lab, label, labelDilate=neighboursRange, margin=neighboursRange, boundingBoxes=boundingBoxes, centresOfMass=centresOfMass, maskOtherLabels=False, ) # Get subvolume subVol = getLabelReturn["subvol"] # Get neighbours neighboursLabel = numpy.unique(subVol) # Remove label and 0 from the list of neighbours neighboursLabel = neighboursLabel[~numpy.in1d(neighboursLabel, label)] neighboursLabel = neighboursLabel[~numpy.in1d(neighboursLabel, 0)] # Add the neighbours to the list neighbours.append(neighboursLabel) elif method == "mesh": # Compute neighboursRange if needed if neighboursRange is None: neighboursRange = 5 * 2 * numpy.mean(radii) # Get connectivity matrix conn = spam.mesh.triangulate(centresOfMass, weights=radii**2, alpha=neighboursRange) # Compute for each label in the list of labels for label in listOfLabels: neighboursLabel = numpy.unique(conn[numpy.where(numpy.sum(conn == label, axis=1))]) # Remove label from the list of neighbours neighboursLabel = neighboursLabel[~numpy.in1d(neighboursLabel, label)] # Add the neighbours to the list neighbours.append(neighboursLabel) else: print("spam.label.getNeighbours(): Wrong method, aborting") return neighbours
[docs] def detectUnderSegmentation(lab, nProcesses=nProcessesDefault, verbose=True): """ This function computes the coefficient of undersegmentation for each particle, defined as the ratio of the convex volume and the actual volume. Parameters ----------- lab : 3D numpy array Labelled image nProcesses : integer (optional, default = nProcessesDefault) Number of processes for multiprocessing Default = number of CPUs in the system verbose : boolean (optional, default = False) True for printing the evolution of the process Returns -------- underSegCoeff : lab.max() array of floats An array of float values that suggests the respective labels are undersegmentated. Note ---- For perfect convex particles, any coefficient higher than 1 should be interpreted as a particle with undersegmentation problems. However, for natural materials the threshold to define undersegmentation varies. It is suggested to plot the histogram of the undersegmentation coefficient and select the threshold accordingly. """ # Compute the volume vol = spam.label.volumes(lab) # Compute the convex volume convexVol = spam.label.convexVolume(lab, verbose=verbose, nProcesses=nProcesses) # Set the volume of the void to 0 to avoid the division by zero error vol[0] = 1 # Compute the underSegmentation Coefficient underSegCoeff = convexVol / vol # Set the coefficient of the void to 0 underSegCoeff[0] = 0 return underSegCoeff
[docs] def detectOverSegmentation(lab): """ This function computes the coefficient of oversegmentation for each particle, defined as the ratio between a characteristic lenght of the maximum contact area and a characteristic length of the particle. Parameters ----------- lab : 3D numpy array Labelled image Returns -------- overSegCoeff : lab.max() array of floats An array of float values with the oversegmentation coefficient sharedLabel : lab.max() array of floats Array of floats with the the oversegmentation coefficient neighbours - label that share the maximum contact area Note ---- The threshold to define oversegmentation is dependent on each material and conditions of the test. It is suggested to plot the histogram of the oversegmentation coefficient and select the threshold accordingly. """ # Get the labels labels = list(range(0, lab.max() + 1)) # Compute the volumes vol = spam.label.volumes(lab) # Compute the eq diameter eqDiam = spam.label.equivalentRadii(lab) # Compute the areas contactLabels = spam.label.contactingLabels(lab, areas=True) # Create result list overSegCoeff = [] sharedLabel = [] for label in labels: if label == 0: overSegCoeff.append(0) sharedLabel.append(0) else: # Check if there are contacting areas and volumes if len(contactLabels[1][label]) > 0 and vol[label] > 0: # We have areas on the list, compute the area maxArea = numpy.max(contactLabels[1][label]) # Get the label for the max contacting area maxLabel = contactLabels[0][label][numpy.argmax(contactLabels[1][label])] # Compute the coefficient overSegCoeff.append(maxArea * eqDiam[label] / vol[label]) # Add the label sharedLabel.append(maxLabel) else: overSegCoeff.append(0) sharedLabel.append(0) overSegCoeff = numpy.array(overSegCoeff) sharedLabel = numpy.array(sharedLabel) return overSegCoeff, sharedLabel
[docs] def fixUndersegmentation( lab, imGrey, targetLabels, underSegCoeff, boundingBoxes=None, centresOfMass=None, imShowProgress=False, verbose=True, ): """ This function fixes undersegmentation problems, by performing a watershed with a higher local threshold for the problematic labels. Parameters ----------- lab : 3D numpy array Labelled image imGrey : 3D numpy array Normalised greyscale of the labelled image, with a greyscale range between 0 and 1 and with void/solid peaks at 0.25 and 0.75, respectively. You can use helpers.histogramTools.findHistogramPeaks and helpers.histogramTools.histogramNorm to obtain a normalized greyscale image. targetLabels : int or a list of labels List of target labels to solve undersegmentation underSegCoeff : lab.max() array of floats Undersegmentation coefficient as returned by ``detectUnderSegmentation`` boundingBoxes : lab.max()x6 array of ints, optional Bounding boxes in format returned by ``boundingBoxes``. If not defined (Default = None), it is recomputed by running ``boundingBoxes`` centresOfMass : lab.max()x3 array of floats, optional Centres of mass in format returned by ``centresOfMass``. If not defined (Default = None), it is recomputed by running ``centresOfMass``boundingBoxes : 3D numpy array Labelled image imShowProgress : bool, optional Graphical interface to observe the process for each label. Default = False verbose : boolean (optional, default = False) True for printing the evolution of the process Returns -------- lab : 3D numpy array Labelled image after running ``makeLabelsSequential`` """ # Usual checks if boundingBoxes is None: boundingBoxes = spam.label.boundingBoxes(lab) if centresOfMass is None: centresOfMass = spam.label.centresOfMass(lab) # Check if imGrey is normalised (limits [0,1]) if imGrey.max() > 1 or imGrey.min() < 0: print("\n spam.label.fixUndersegmentation(): imGrey is not normalised. Limits exceed [0,1]") return # Start counters labelCounter = numpy.max(lab) labelDummy = numpy.zeros(lab.shape) successCounter = 0 finishedLabels = 0 if verbose: widgets = [ progressbar.FormatLabel(""), " ", progressbar.Bar(), " ", progressbar.AdaptiveETA(), ] pbar = progressbar.ProgressBar(widgets=widgets, maxval=len(targetLabels)) pbar.start() # Main loop for label in targetLabels: # Get the subset labelData = spam.label.getLabel( lab, label, margin=5, boundingBoxes=boundingBoxes, centresOfMass=centresOfMass, extractCube=True, ) # Get the slice on the greyscale imGreySlice = imGrey[ labelData["sliceCube"][0].start : labelData["sliceCube"][0].stop, labelData["sliceCube"][1].start : labelData["sliceCube"][1].stop, labelData["sliceCube"][2].start : labelData["sliceCube"][2].stop, ] # Mask the imGreySubset greySubset = imGreySlice * labelData["subvol"] # Create seeds # 2021-08-02 GP: Maybe this can be changed by just a serie of binary erosion? seeds = spam.label.watershed(greySubset >= 0.75) # Do we have seeds? if numpy.max(seeds) < 1: # The threshold was too harsh on the greySubset and there are no seeds # We shouldn't change this label passBool = "Decline" else: # We have at least one seed, Run watershed again with markers imLabSubset = spam.label.watershed(labelData["subvol"], markers=seeds) # Run again the underSegCoeff for the subset res = detectUnderSegmentation(imLabSubset, verbose=False) # Safety check - do we have any labels at all? if len(res) > 2: # We have at least one label # Check if it should pass or not - is the new underSegCoeff of all the new labels less than the original coefficient? if all(map(lambda x: x < underSegCoeff[label], res[1:])): # We can modify this label passBool = "Accept" successCounter += 1 # Remove the label from the original label image lab = spam.label.removeLabels(lab, [label]) # Assign the new labels to the grains # Create a subset to fill with the new labels imLabSubsetNew = numpy.zeros(imLabSubset.shape) for newLab in numpy.unique(imLabSubset[imLabSubset != 0]): imLabSubsetNew = numpy.where(imLabSubset == newLab, labelCounter + 1, imLabSubsetNew) labelCounter += 1 # Create a disposable dummy sample to allocate the grains labelDummyUnit = numpy.zeros(lab.shape) # Alocate the grains labelDummyUnit[ labelData["sliceCube"][0].start : labelData["sliceCube"][0].stop, labelData["sliceCube"][1].start : labelData["sliceCube"][1].stop, labelData["sliceCube"][2].start : labelData["sliceCube"][2].stop, ] = imLabSubsetNew # Add the grains labelDummy = labelDummy + labelDummyUnit else: # We shouldn't change this label passBool = "Decline" if imShowProgress: # Enter graphical mode # Change the labels to show different colourss fig = plt.figure() # Plot plt.subplot(3, 2, 1) plt.gca().set_title("Before") plt.imshow( labelData["subvol"][labelData["subvol"].shape[0] // 2, :, :], cmap="Greys_r", ) plt.subplot(3, 2, 2) plt.gca().set_title("After") plt.imshow(imLabSubset[imLabSubset.shape[0] // 2, :, :], cmap="cubehelix") plt.subplot(3, 2, 3) plt.imshow( labelData["subvol"][:, labelData["subvol"].shape[1] // 2, :], cmap="Greys_r", ) plt.subplot(3, 2, 4) plt.imshow(imLabSubset[:, imLabSubset.shape[1] // 2, :], cmap="cubehelix") plt.subplot(3, 2, 5) plt.imshow( labelData["subvol"][:, :, labelData["subvol"].shape[2] // 2], cmap="Greys_r", ) plt.subplot(3, 2, 6) plt.imshow(imLabSubset[:, :, imLabSubset.shape[2] // 2], cmap="cubehelix") fig.suptitle( # r"Label {}. Status: $\bf{}$".format(label, passBool), # breaks for matplotlib 3.7.0 f"Label {label}. Status: {passBool}", fontsize="xx-large", ) plt.show() else: # We shouldn't change this label passBool = "Decline" if verbose: finishedLabels += 1 pbar.update(finishedLabels) # We finish, lets add the new grains to the labelled image lab = lab + labelDummy # Update the labels lab = spam.label.makeLabelsSequential(lab) if verbose: pbar.finish() print(f"\n spam.label.fixUndersegmentation(): From {len(targetLabels)} target labels, {successCounter} were modified") return lab
[docs] def fixOversegmentation(lab, targetLabels, sharedLabel, verbose=True, imShowProgress=False): """ This function fixes oversegmentation problems, by merging each target label with its oversegmentation coefficient neighbour. Parameters ----------- lab : 3D numpy array Labelled image targetLabels : int or a list of labels List of target labels to solve oversegmentation sharedLabel : lab.max() array of floats List ofoversegmentation coefficient neighbour as returned by ``detectOverSegmentation`` imShowProgress : bool, optional Graphical interface to observe the process for each label. Default = False verbose : boolean (optional, default = False) True for printing the evolution of the process Returns -------- lab : 3D numpy array Labelled image after running ``makeLabelsSequential`` """ # Start counters labelDummy = numpy.zeros(lab.shape) finishedLabelsCounter = 0 finishedLabels = [] if verbose: widgets = [ progressbar.FormatLabel(""), " ", progressbar.Bar(), " ", progressbar.AdaptiveETA(), ] pbar = progressbar.ProgressBar(widgets=widgets, maxval=len(targetLabels)) pbar.start() # Main loop for labelA in targetLabels: # Verify that the label is not on the finished list if labelA in finishedLabels: # It is already on the list, move on pass else: # Get the touching label labelB = sharedLabel[labelA] # Add then to the list finishedLabels.append(labelA) finishedLabels.append(labelB) # Get the subset of the two labels subset = spam.label.fetchTwoGrains(lab, [labelA, labelB]) # Change the labelB by labelA in the subset subVolLabNew = numpy.where(subset["subVolLab"] == labelB, labelA, subset["subVolLab"]) # Create a disposable dummy sample to allocate the grains labelDummyUnit = numpy.zeros(lab.shape) # Alocate the grains labelDummyUnit[ subset["slice"][0].start : subset["slice"][0].stop, subset["slice"][1].start : subset["slice"][1].stop, subset["slice"][2].start : subset["slice"][2].stop, ] = subVolLabNew # Add the grains labelDummy = labelDummy + labelDummyUnit # Remove the label from the original label image lab = spam.label.removeLabels(lab, [labelA, labelB]) # Enter graphical mode if imShowProgress: # Change the labels to show different colourss subVolLabNorm = numpy.where(subset["subVolLab"] == labelA, 1, subset["subVolLab"]) subVolLabNorm = numpy.where(subset["subVolLab"] == labelB, 2, subVolLabNorm) fig = plt.figure() plt.subplot(3, 2, 1) plt.gca().set_title("Before") plt.imshow( subVolLabNorm[subset["subVolLab"].shape[0] // 2, :, :], cmap="cubehelix", ) plt.subplot(3, 2, 2) plt.gca().set_title("After") plt.imshow(subVolLabNew[subVolLabNew.shape[0] // 2, :, :], cmap="cubehelix") plt.subplot(3, 2, 3) plt.imshow( subVolLabNorm[:, subset["subVolLab"].shape[1] // 2, :], cmap="cubehelix", ) plt.subplot(3, 2, 4) plt.imshow(subVolLabNew[:, subVolLabNew.shape[1] // 2, :], cmap="cubehelix") plt.subplot(3, 2, 5) plt.imshow( subVolLabNorm[:, :, subset["subVolLab"].shape[2] // 2], cmap="cubehelix", ) plt.subplot(3, 2, 6) plt.imshow(subVolLabNew[:, :, subVolLabNew.shape[2] // 2], cmap="cubehelix") fig.suptitle("Label {} and {}".format(labelA, labelB), fontsize="xx-large") plt.show() if verbose: finishedLabelsCounter += 1 pbar.update(finishedLabelsCounter) # We finish, lets add the new grains to the labelled image lab = lab + labelDummy # Update the labels lab = spam.label.makeLabelsSequential(lab) if verbose: pbar.finish() return lab
@numba.njit(cache=True) def _updateLabels(lab, newLabels): """ This function uses numba to go through all the voxels of a label image and assign a new label based on the newLabels list. Parameters ----------- lab : 3D array of integers Labelled volume, with lab.max() labels newLabels : 1D array of integers Array with the order of the new labels Returns -------- lab : 3D array of integers Labelled volume, with lab.max() labels """ # Loop over the image to change the values for z in range(lab.shape[0]): for y in range(lab.shape[1]): for x in range(lab.shape[2]): if lab[z,y,x] != 0: lab[z,y,x] = newLabels[lab[z,y,x]] return lab
[docs] def shuffleLabels(lab): """ This function re-assigns randomly the labels of a label image, usually for visualisation purposes. Parameters ----------- lab : 3D array of integers Labelled volume, with lab.max() labels Returns -------- lab : 3D array of integers Labelled volume, with lab.max() labels """ # Ge the list of labels and a copy labels = numpy.unique(lab).tolist() labelsList = labels.copy() # Empty list ot fill newLabels = [] # Loop over the labels for i in range(len(labelsList)): if i == 0: # Add the 0 to the list newLabels.append(0) # Remove 0 from the list labelsList.remove(0) else: # Generate a random number from a list length pos = random.randrange(len(labelsList)) # Add the label to the list newLabels.append(labelsList[pos]) # Remove 0 from the list labelsList.remove(labelsList[pos]) # Transform them into array for easier indexing labels = numpy.asarray(labels) newLabels = numpy.asarray(newLabels) # Update the labels using the numba function lab = _updateLabels(lab, newLabels) return lab