Coverage for /usr/local/lib/python3.8/site-packages/spam/label/contacts.py: 84%

380 statements  

« prev     ^ index     » next       coverage.py v7.2.3, created at 2023-11-22 13:26 +0000

1""" 

2Library of SPAM functions for dealing with contacts between particles 

3Copyright (C) 2020 SPAM Contributors 

4 

5This program is free software: you can redistribute it and/or modify it 

6under the terms of the GNU General Public License as published by the Free 

7Software Foundation, either version 3 of the License, or (at your option) 

8any later version. 

9 

10This program is distributed in the hope that it will be useful, but WITHOUT 

11ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 

12FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for 

13more details. 

14 

15You should have received a copy of the GNU General Public License along with 

16this program. If not, see <http://www.gnu.org/licenses/>. 

17""" 

18 

19import multiprocessing # for contact detection and orientation of assemblies 

20 

21import numpy 

22import progressbar 

23import scipy.ndimage # for the contact detection 

24import skimage.feature # for the maxima of the EDM 

25import spam.label 

26from spam.label.labelToolkit import labelContacts 

27 

28contactType = "<u4" 

29# Global number of processes 

30nProcessesDefault = multiprocessing.cpu_count() 

31 

32 

33def contactingLabels(lab, labelsList=None, areas=False, boundingBoxes=None, centresOfMass=None): 

34 """ 

35 This function returns contacting labels for a given label or list of labels, 

36 and optionally the number of voxels involved in the contact. 

37 This is designed for an interpixel watershed where there are no watershed lines, 

38 and so contact detection is done by dilating the particle in question once and 

39 seeing what other labels in comes in contact with. 

40 

41 Parameters 

42 ----------- 

43 lab : 3D numpy array of ints ( of type spam.label.toolkit.labelType) 

44 An array of labels with 0 as the background 

45 

46 labels : int or a list of labels 

47 Labels for which we should compute neighbours 

48 

49 areas : bool, optional (default = False) 

50 Compute contact "areas"? *i.e.*, number of voxels 

51 Careful, this is not a physically correct quantity, and 

52 is measured only as the overlap from ``label`` to its 

53 neightbours. See ``c ontactPoint`` for something 

54 more meaningful 

55 

56 boundingBoxes : lab.max()x6 array of ints, optional 

57 Bounding boxes in format returned by ``boundingBoxes``. 

58 If not defined (Default = None), it is recomputed by running ``boundingBoxes`` 

59 

60 centresOfMass : lab.max()x3 array of floats, optional 

61 Centres of mass in format returned by ``centresOfMass``. 

62 If not defined (Default = None), it is recomputed by running ``centresOfMass`` 

63 

64 Returns 

65 -------- 

66 For a single "labels", a list of contacting labels. 

67 if areas == True, then a list of "areas" is also returned. 

68 

69 For multiple labels, as above but a lsit of lists in the order of the labels 

70 

71 Note 

72 ----- 

73 Because of dilation, this function is not very safe at the edges, 

74 and will throw an exception 

75 """ 

76 # Default state for single 

77 single = False 

78 

79 if boundingBoxes is None: 

80 boundingBoxes = spam.label.boundingBoxes(lab) 

81 if centresOfMass is None: 

82 centresOfMass = spam.label.centresOfMass(lab, boundingBoxes=boundingBoxes) 

83 

84 if labelsList is None: 

85 labelsList = list(range(0, lab.max() + 1)) 

86 

87 # I guess there's only one label 

88 if type(labelsList) != list: 

89 labelsList = [labelsList] 

90 single = True 

91 

92 contactingLabels = [] 

93 contactingAreas = [] 

94 

95 for label in labelsList: 

96 # catch zero and return it in order to keep arrays aligned 

97 if label == 0: 

98 contactingLabels.append([0]) 

99 if areas: 

100 contactingAreas.append([0]) 

101 else: 

102 p1 = spam.label.getLabel( 

103 lab, 

104 label, 

105 boundingBoxes=boundingBoxes, 

106 centresOfMass=centresOfMass, 

107 margin=2, 

108 ) 

109 p2 = spam.label.getLabel( 

110 lab, 

111 label, 

112 boundingBoxes=boundingBoxes, 

113 centresOfMass=centresOfMass, 

114 margin=2, 

115 labelDilate=1, 

116 ) 

117 

118 dilOnly = numpy.logical_xor(p2["subvol"], p1["subvol"]) 

119 

120 labSlice = lab[ 

121 p1["slice"][0].start : p1["slice"][0].stop, 

122 p1["slice"][1].start : p1["slice"][1].stop, 

123 p1["slice"][2].start : p1["slice"][2].stop, 

124 ] 

125 

126 if dilOnly.shape == labSlice.shape: 

127 intersection = dilOnly * labSlice 

128 

129 counts = numpy.unique(intersection, return_counts=True) 

130 

131 # Print counts starting from the 1th column since we'll always have a ton of zeros 

132 # print "\tLabels:\n\t",counts[0][1:] 

133 # print "\tCounts:\n\t",counts[1][1:] 

134 

135 contactingLabels.append(counts[0][1:]) 

136 if areas: 

137 contactingAreas.append(counts[1][1:]) 

138 

139 else: 

140 # The particle is near the edge, pad it 

141 

142 padArray = numpy.zeros((3, 2)).astype(int) 

143 sliceArray = numpy.zeros((3, 2)).astype(int) 

144 for i, sl in enumerate(p1["slice"]): 

145 if sl.start < 0: 

146 padArray[i, 0] = -1 * sl.start 

147 sliceArray[i, 0] = 0 

148 else: 

149 sliceArray[i, 0] = sl.start 

150 if sl.stop > lab.shape[i]: 

151 padArray[i, 1] = sl.stop - lab.shape[i] 

152 sliceArray[i, 1] = lab.shape[i] 

153 else: 

154 sliceArray[i, 1] = sl.stop 

155 labSlice = lab[ 

156 sliceArray[0, 0] : sliceArray[0, 1], 

157 sliceArray[1, 0] : sliceArray[1, 1], 

158 sliceArray[2, 0] : sliceArray[2, 1], 

159 ] 

160 labSlicePad = numpy.pad(labSlice, padArray) 

161 intersection = dilOnly * labSlicePad 

162 counts = numpy.unique(intersection, return_counts=True) 

163 contactingLabels.append(counts[0][1:]) 

164 if areas: 

165 contactingAreas.append(counts[1][1:]) 

166 

167 # Flatten if it's a list with only one object 

168 if single: 

169 contactingLabels = contactingLabels[0] 

170 if areas: 

171 contactingAreas = contactingAreas[0] 

172 # Now return things 

173 if areas: 

174 return contactingLabels, contactingAreas 

175 else: 

176 return contactingLabels 

177 

178 

179def contactPoints( 

180 lab, 

181 contactPairs, 

182 returnContactZones=False, 

183 boundingBoxes=None, 

184 centresOfMass=None, 

185 verbose=False, 

186): 

187 """ 

188 Get the point, or area of contact between contacting particles. 

189 This is done by looking at the overlap of a 1-dilation of particle1 onto particle 2 

190 and vice versa. 

191 

192 Parameters 

193 ----------- 

194 lab : 3D numpy array of ints ( of type spam.label.toolkit.labelType) 

195 An array of labels with 0 as the background 

196 

197 contactPairs : list (or list of list) of labels 

198 Pairs of labels for which we should compute neighbours 

199 

200 returnContactZones : bool, optional (default = False) 

201 Output a labelled volume where contact zones are labelled according to the order 

202 of contact pairs? 

203 If False, the centres of mass of the contacts are returned 

204 

205 boundingBoxes : lab.max()x6 array of ints, optional 

206 Bounding boxes in format returned by ``boundingBoxes``. 

207 If not defined (Default = None), it is recomputed by running ``boundingBoxes`` 

208 

209 centresOfMass : lab.max()x3 array of floats, optional 

210 Centres of mass in format returned by ``centresOfMass``. 

211 If not defined (Default = None), it is recomputed by running ``centresOfMass`` 

212 

213 Returns 

214 -------- 

215 if returnContactZones: 

216 lab : 3D numpy array of ints ( of type spam.label.toolkit.labelType) 

217 else: 

218 Z Y X Centres of mass of contacts 

219 """ 

220 # detect list of lists with code from: https://stackoverflow.com/questions/5251663/determine-if-a-list-contains-other-lists 

221 # if not any(isinstance(el, list) for el in contactPairs ): 

222 # contactPairs = [ contactPairs ] 

223 # different approach: 

224 contactPairs = numpy.array(contactPairs) 

225 # Pad with extra dim if only one contact pair 

226 if len(contactPairs.shape) == 1: 

227 contactPairs = contactPairs[numpy.newaxis, ...] 

228 

229 if boundingBoxes is None: 

230 boundingBoxes = spam.label.boundingBoxes(lab) 

231 if centresOfMass is None: 

232 centresOfMass = spam.label.centresOfMass(lab, boundingBoxes=boundingBoxes) 

233 

234 # To this volume will be added each contact "area", labelled 

235 # with the contact pair number (+1) 

236 analysisVolume = numpy.zeros_like(lab, dtype=numpy.uint32) 

237 if verbose: 

238 widgets = [ 

239 progressbar.FormatLabel(""), 

240 " ", 

241 progressbar.Bar(), 

242 " ", 

243 progressbar.AdaptiveETA(), 

244 ] 

245 pbar = progressbar.ProgressBar(widgets=widgets, maxval=len(contactPairs)) 

246 pbar.start() 

247 finishedNodes = 0 

248 for n, contactPair in enumerate(contactPairs): 

249 # Do both orders in contacts 

250 if len(contactPair) != 2: 

251 print("spam.label.contacts.contactPoints(): contact pair does not have 2 elements") 

252 for label1, label2 in [contactPair, contactPair[::-1]]: 

253 # print( "Label1: {} Label2: {}".format( label1, label2 ) ) 

254 p1 = spam.label.getLabel( 

255 lab, 

256 label1, 

257 boundingBoxes=boundingBoxes, 

258 centresOfMass=centresOfMass, 

259 margin=2, 

260 ) 

261 p2 = spam.label.getLabel( 

262 lab, 

263 label1, 

264 boundingBoxes=boundingBoxes, 

265 centresOfMass=centresOfMass, 

266 margin=2, 

267 labelDilate=1, 

268 ) 

269 

270 dilOnly = numpy.logical_xor(p2["subvol"], p1["subvol"]) 

271 

272 labSlice = ( 

273 slice(p1["slice"][0].start, p1["slice"][0].stop), 

274 slice(p1["slice"][1].start, p1["slice"][1].stop), 

275 slice(p1["slice"][2].start, p1["slice"][2].stop), 

276 ) 

277 

278 labSubvol = lab[labSlice] 

279 

280 if dilOnly.shape == labSubvol.shape: 

281 intersection = dilOnly * labSubvol 

282 

283 analysisVolume[labSlice][intersection == label2] = n + 1 

284 

285 else: 

286 raise Exception("\tdilOnly and labSlice are not the same size... getLabel probably did some safe cutting around the edges") 

287 if verbose: 

288 finishedNodes += 1 

289 widgets[0] = progressbar.FormatLabel("{}/{} ".format(finishedNodes, len(contactPairs))) 

290 pbar.update(finishedNodes) 

291 

292 if returnContactZones: 

293 return analysisVolume 

294 else: 

295 return spam.label.centresOfMass(analysisVolume) 

296 

297 

298def labelledContacts(lab, maximumCoordinationNumber=20): 

299 """ 

300 Uniquely names contacts based on grains. 

301 

302 Parameters 

303 ----------- 

304 lab : 3D numpy array of ints ( of type spam.label.toolkit.labelType) 

305 An array of labels with 0 as the background 

306 

307 maximumCoordinationNumber : int (optional, default = 20) 

308 Maximum number of neighbours to consider per grain 

309 

310 Returns 

311 -------- 

312 An array, containing: 

313 contactVolume : array of ints 

314 3D array where contact zones are uniquely labelled 

315 

316 Z : array of ints 

317 Vector of length lab.max()+1 contaning the coordination number 

318 (number of touching labels for each label) 

319 

320 contactTable : array of ints 

321 2D array of lab.max()+1 by 2*maximumCoordinationNumber 

322 containing, per grain: touching grain label, contact number 

323 

324 contactingLabels : array of ints 

325 2D array of numberOfContacts by 2 

326 containing, per contact: touching grain label 1, touching grain label 2 

327 """ 

328 contacts = numpy.zeros_like(lab, dtype=contactType) 

329 Z = numpy.zeros((lab.max() + 1), dtype="<u1") 

330 contactTable = numpy.zeros((lab.max() + 1, maximumCoordinationNumber * 2), dtype=contactType) 

331 contactingLabels = numpy.zeros(((lab.max() + 1) * maximumCoordinationNumber, 2), dtype=spam.label.labelType) 

332 

333 labelContacts(lab, contacts, Z, contactTable, contactingLabels) 

334 

335 # removed the first row of contactingLabels, as it is 0, 0 

336 return [contacts, Z, contactTable, contactingLabels[1 : contacts.max() + 1, :]] 

337 

338 

339def fetchTwoGrains(volLab, labels, volGrey=None, boundingBoxes=None, padding=0, size_exclude=5): 

340 """ 

341 Fetches the sub-volume of two grains from a labelled image 

342 

343 Parameters 

344 ---------- 

345 volLab : 3D array of integers 

346 Labelled volume, with lab.max() labels 

347 

348 labels : 1x2 array of integers 

349 the two labels that should be contained in the subvolume 

350 

351 volGrey : 3D array 

352 Grey-scale volume 

353 

354 boundingBoxes : lab.max()x6 array of ints, optional 

355 Bounding boxes in format returned by ``boundingBoxes``. 

356 If not defined (Default = None), it is recomputed by running ``boundingBoxes`` 

357 

358 padding : integer 

359 padding of the subvolume 

360 for some purpose it might be benefitial to have a subvolume 

361 with a boundary of zeros 

362 

363 Returns 

364 ------- 

365 subVolLab : 3D array of integers 

366 labelled sub-volume containing the two input labels 

367 

368 subVolBin : 3D array of integers 

369 binary subvolume 

370 

371 subVolGrey : 3D array 

372 grey-scale subvolume 

373 """ 

374 

375 # check if bounding boxes are given 

376 if boundingBoxes is None: 

377 # print "bounding boxes are not given. calculating ...." 

378 boundingBoxes = spam.label.boundingBoxes(volLab) 

379 # else: 

380 # print "bounding boxes are given" 

381 lab1, lab2 = labels 

382 # Define output dictionary since we'll add different things to it 

383 output = {} 

384 # get coordinates of the big bounding box 

385 startZ = min(boundingBoxes[lab1, 0], boundingBoxes[lab2, 0]) - padding 

386 stopZ = max(boundingBoxes[lab1, 1], boundingBoxes[lab2, 1]) + padding 

387 startY = min(boundingBoxes[lab1, 2], boundingBoxes[lab2, 2]) - padding 

388 stopY = max(boundingBoxes[lab1, 3], boundingBoxes[lab2, 3]) + padding 

389 startX = min(boundingBoxes[lab1, 4], boundingBoxes[lab2, 4]) - padding 

390 stopX = max(boundingBoxes[lab1, 5], boundingBoxes[lab2, 5]) + padding 

391 output["slice"] = ( 

392 slice(startZ, stopZ + 1), 

393 slice(startY, stopY + 1), 

394 slice(startX, stopX + 1), 

395 ) 

396 subVolLab = volLab[ 

397 output["slice"][0].start : output["slice"][0].stop, 

398 output["slice"][1].start : output["slice"][1].stop, 

399 output["slice"][2].start : output["slice"][2].stop, 

400 ] 

401 

402 subVolLab_A = numpy.where(subVolLab == lab1, lab1, 0) 

403 subVolLab_B = numpy.where(subVolLab == lab2, lab2, 0) 

404 subVolLab = subVolLab_A + subVolLab_B 

405 

406 struc = numpy.zeros((3, 3, 3)) 

407 struc[1, 1:2, :] = 1 

408 struc[1, :, 1:2] = 1 

409 struc[0, 1, 1] = 1 

410 struc[2, 1, 1] = 1 

411 subVolLab = spam.label.filterIsolatedCells(subVolLab, struc, size_exclude) 

412 output["subVolLab"] = subVolLab 

413 subVolBin = numpy.where(subVolLab != 0, 1, 0) 

414 output["subVolBin"] = subVolBin 

415 if volGrey is not None: 

416 subVolGrey = volGrey[ 

417 output["slice"][0].start : output["slice"][0].stop, 

418 output["slice"][1].start : output["slice"][1].stop, 

419 output["slice"][2].start : output["slice"][2].stop, 

420 ] 

421 subVolGrey = subVolGrey * subVolBin 

422 output["subVolGrey"] = subVolGrey 

423 

424 return output 

425 

426 

427def localDetection(subVolGrey, localThreshold, radiusThresh=None): 

428 """ 

429 local contact refinement 

430 checks whether two particles are in contact with a local threshold, 

431 that is higher than the global one used for binarisation 

432 

433 Parameters 

434 ---------- 

435 subVolGrey : 3D array 

436 Grey-scale volume 

437 

438 localThreshold : integer or float, same type as the 3D array 

439 threshold for binarisation of the subvolume 

440 

441 radiusThresh : integer, optional 

442 radius for excluding patches that might not be relevant, 

443 e.g. noise can lead to patches that are not connected with the grains in contact 

444 the patches are calculated with ``equivalentRadii()`` 

445 Default is None and such patches are not excluded from the subvolume 

446 

447 Returns 

448 ------- 

449 CONTACT : boolean 

450 if True, that particles appear in contact for the local threshold 

451 

452 Note 

453 ---- 

454 see https://doi.org/10.1088/1361-6501/aa8dbf for further information 

455 """ 

456 

457 CONTACT = False 

458 subVolBin = ((subVolGrey > localThreshold) * 1).astype("uint8") 

459 if radiusThresh is not None: 

460 # clean the image of isolated voxels or pairs of voxels 

461 # due to higher threshold they could exist 

462 subVolLab, numObjects = scipy.ndimage.label(subVolBin) 

463 if numObjects > 1: 

464 radii = spam.label.equivalentRadii(subVolLab) 

465 labelsToRemove = numpy.where(radii < radiusThresh) 

466 if len(labelsToRemove[0]) > 1: 

467 subVolLab = spam.label.removeLabels(subVolLab, labelsToRemove) 

468 subVolBin = ((subVolLab > 0) * 1).astype("uint8") 

469 

470 # fill holes 

471 subVolBin = scipy.ndimage.binary_fill_holes(subVolBin).astype("uint8") 

472 labeledArray, numObjects = scipy.ndimage.label(subVolBin, structure=None, output=None) 

473 if numObjects == 1: 

474 CONTACT = True 

475 

476 return CONTACT 

477 

478 

479def contactOrientations(volBin, volLab, watershed="ITK", peakDistance=1, markerShrink=True, verbose=False): 

480 """ 

481 determines contact normal orientation between two particles 

482 uses the random walker implementation from skimage 

483 

484 Parameters 

485 ---------- 

486 volBin : 3D array 

487 binary volume containing two particles in contact 

488 

489 volLab : 3D array of integers 

490 labelled volume containing two particles in contact 

491 

492 watershed : string 

493 sets the basis for the determination of the orientation 

494 options are "ITK" for the labelled image from the input, 

495 "RW" for a further segmentation by the random walker 

496 Default = "ITK" 

497 

498 peakDistance : int, optional 

499 Distance in pixels used in skimage.feature.peak_local_max 

500 Default value is 1 

501 

502 markerShrink : boolean 

503 shrinks the markers to contain only one voxel. 

504 For large markers, the segmentation might yield strange results depending on the shape. 

505 Default = True 

506 

507 verbose : boolean (optional, default = False) 

508 True for printing the evolution of the process 

509 False for not printing the evolution of process 

510 

511 

512 Returns 

513 ------- 

514 contactNormal : 1x3 array 

515 contact normal orientation in z,y,x coordinates 

516 the vector is flipped such that the z coordinate is positive 

517 

518 len(coordIntervox) : integer 

519 the number of points for the principal component analysis 

520 indicates the quality of the fit 

521 

522 notTreatedContact : boolean 

523 if False that contact is not determined 

524 if the contact consisted of too few voxels a fit is not possible or feasible 

525 """ 

526 notTreatedContact = False 

527 from skimage.segmentation import random_walker 

528 

529 if watershed == "ITK": 

530 # ------------------------------ 

531 # ITK watershed for orientations 

532 # ------------------------------ 

533 # need to relabel, because the _contactPairs functions looks for 1 and 2 

534 labels = numpy.unique(volLab) 

535 volLab[volLab == labels[1]] = 1 

536 volLab[volLab == labels[2]] = 2 

537 

538 contactITK = _contactPairs(volLab) 

539 

540 if len(contactITK) <= 2: 

541 # print('WARNING: not enough contacting voxels (beucher)... aborting this calculation') 

542 notTreatedContact = True 

543 return numpy.zeros(3), 0, notTreatedContact 

544 

545 coordIntervox = contactITK[:, 0:3] 

546 # Determining the contact orientation! using PCA 

547 contactNormal = _contactNormals(coordIntervox) 

548 

549 if watershed == "RW": 

550 # Get Labels 

551 label1, label2 = numpy.unique(volLab)[1:] 

552 # Create sub-domains 

553 subVolBinA = numpy.where(volLab == label1, 1, 0) 

554 subVolBinB = numpy.where(volLab == label2, 1, 0) 

555 volBin = subVolBinA + subVolBinB 

556 # Calculate distance map 

557 distanceMapA = scipy.ndimage.distance_transform_edt(subVolBinA) 

558 distanceMapB = scipy.ndimage.distance_transform_edt(subVolBinB) 

559 # Calculate local Max 

560 localMaxiPointsA = skimage.feature.peak_local_max( 

561 distanceMapA, 

562 min_distance=peakDistance, 

563 exclude_border=False, 

564 labels=subVolBinA, 

565 ) 

566 localMaxiPointsB = skimage.feature.peak_local_max( 

567 distanceMapB, 

568 min_distance=peakDistance, 

569 exclude_border=False, 

570 labels=subVolBinB, 

571 ) 

572 # 2022-09-26: Dropping indices, and so rebuilding image of peaks 

573 localMaxiA = numpy.zeros_like(distanceMapA, dtype=bool) 

574 localMaxiB = numpy.zeros_like(distanceMapB, dtype=bool) 

575 localMaxiA[tuple(localMaxiPointsA.T)] = True 

576 localMaxiB[tuple(localMaxiPointsB.T)] = True 

577 # Label the Peaks 

578 struc = numpy.ones((3, 3, 3)) 

579 markersA, numMarkersA = scipy.ndimage.label(localMaxiA, structure=struc) 

580 markersB, numMarkersB = scipy.ndimage.label(localMaxiB, structure=struc) 

581 if numMarkersA == 0 or numMarkersB == 0: 

582 notTreatedContact = True 

583 if verbose: 

584 print("spam.contacts.contactOrientations: No grain markers found for this contact. Setting the contact as non-treated") 

585 return numpy.zeros(3), 0, notTreatedContact 

586 # Shrink the markers 

587 # The index property should be a list with all the labels encountered, exlucing the label for the backgroun (0). 

588 centerOfMassA = scipy.ndimage.center_of_mass(markersA, labels=markersA, index=list(numpy.unique(markersA)[1:])) 

589 centerOfMassB = scipy.ndimage.center_of_mass(markersB, labels=markersB, index=list(numpy.unique(markersB)[1:])) 

590 # Calculate the value of the distance map for the markers 

591 marksValA = [] 

592 marksValB = [] 

593 for x in range(len(centerOfMassA)): 

594 marksValA.append( 

595 distanceMapA[ 

596 int(centerOfMassA[x][0]), 

597 int(centerOfMassA[x][1]), 

598 int(centerOfMassA[x][2]), 

599 ] 

600 ) 

601 for x in range(len(centerOfMassB)): 

602 marksValB.append( 

603 distanceMapB[ 

604 int(centerOfMassB[x][0]), 

605 int(centerOfMassB[x][1]), 

606 int(centerOfMassB[x][2]), 

607 ] 

608 ) 

609 # Sort the markers coordinates acoording to their distance map value 

610 # First element correspond to the coordinates of the marker with the higest value in the distance map 

611 centerOfMassA = numpy.flipud([x for _, x in sorted(zip(marksValA, centerOfMassA))]) 

612 centerOfMassB = numpy.flipud([x for _, x in sorted(zip(marksValB, centerOfMassB))]) 

613 marksValA = numpy.flipud(sorted(marksValA)) 

614 marksValB = numpy.flipud(sorted(marksValB)) 

615 # Select markers 

616 markers = numpy.zeros(volLab.shape) 

617 markers[int(centerOfMassA[0][0]), int(centerOfMassA[0][1]), int(centerOfMassA[0][2])] = 1.0 

618 markers[int(centerOfMassB[0][0]), int(centerOfMassB[0][1]), int(centerOfMassB[0][2])] = 2.0 

619 # set the void phase of the binary image to -1, excludes this phase from calculation of the random walker (saves time) 

620 volRWbin = volBin.astype(float) 

621 markers[~volRWbin.astype(bool)] = -1 

622 if numpy.max(numpy.unique(markers)) != 2: 

623 notTreatedContact = True 

624 if verbose: 

625 print("spam.contacts.contactOrientations: The grain markers where wrongly computed. Setting the contact as non-treated") 

626 return numpy.zeros(3), 0, notTreatedContact 

627 probMaps = random_walker(volRWbin, markers, beta=80, mode="cg_mg", return_full_prob=True) 

628 # reset probability of voxels in the void region to 0! (-1 right now, as they were not taken into account!) 

629 probMaps[:, ~volRWbin.astype(bool)] = 0 

630 # create a map of the probabilities of belonging to either label 1 oder label 2 (which is just the inverse map) 

631 labRW1 = probMaps[0].astype(numpy.float32) 

632 # label the image depending on the probabilty of each voxel belonging to marker = 1, save one power watershed 

633 labRW = numpy.array(labRW1) 

634 labRW[labRW > 0.5] = 1 

635 labRW[(labRW < 0.5) & (labRW > 0)] = 2 

636 # seed of the second marker has to be labelled by 2 as well 

637 labRW[markers == 2] = 2 

638 

639 contactVox = _contactPairs(labRW) 

640 if len(contactVox) <= 2: 

641 # print 'WARNING: not enough contacting voxels (rw).... aborting this calculation' 

642 notTreatedContact = True 

643 if verbose: 

644 print("spam.contacts.contactOrientations: Not enough contacting voxels, aborting this calculation. Setting the contact as non-treated") 

645 return numpy.zeros(3), 0, notTreatedContact 

646 

647 # get subvoxel precision - using the probability values at the contacting voxels! 

648 coordIntervox = _contactPositions(contactVox, labRW1) 

649 # Determining the contact orientation! using PCA 

650 contactNormal = _contactNormals(coordIntervox) 

651 

652 return contactNormal[0], len(coordIntervox), notTreatedContact 

653 

654 

655def _contactPairs(lab): 

656 """ 

657 finds the voxels involved in the contact 

658 i.e. the voxels of one label in direct contact with another label 

659 

660 Parameters 

661 ---------- 

662 lab : 3D array of integers 

663 labelled volume containing two particles in contact 

664 labels of the considered particles are 1 and 2 

665 

666 Returns 

667 ------- 

668 contact : (len(contactVoxels))x4 array 

669 z,y,x positions and the label of the voxel 

670 """ 

671 dimensions = numpy.shape(lab) 

672 dimZ, dimY, dimX = dimensions[0], dimensions[1], dimensions[2] 

673 

674 # position of the voxels labelled by 1 ... row 1 = coord index 1, row 2 = coord 2, ... 

675 positionLabel = numpy.array(numpy.where(lab == 1)) 

676 # initialize the array for the contact positions and labels! 

677 contact = numpy.array([[], [], [], []]).transpose() 

678 

679 # loop over all voxels labeled with 1 

680 for x in range(0, len(positionLabel.transpose())): 

681 pix = numpy.array([positionLabel[0][x], positionLabel[1][x], positionLabel[2][x], 1]) 

682 # pix ... positions (axis 0,1,2) of the first voxel containing 1 

683 # x axis neighbor 

684 if positionLabel[0][x] < dimZ - 1: # if the voxel is still in the image! 

685 # if neighbor in pos. x-direction is 2 then save the actual voxel and the neighbor!! 

686 if lab[positionLabel[0][x] + 1, positionLabel[1][x], positionLabel[2][x]] == 2: 

687 vx1 = numpy.array( 

688 [ 

689 positionLabel[0][x] + 1, 

690 positionLabel[1][x], 

691 positionLabel[2][x], 

692 2, 

693 ] 

694 ) 

695 # stacks the data, adds a row to the data, so you get (contact, pix, vx2) 

696 contact = numpy.vstack((contact, pix)) 

697 contact = numpy.vstack((contact, vx1)) 

698 # this condition prevents from getting stuck at the border of the image, where the x-value cannot be reduced! 

699 if positionLabel[0][x] != 0: 

700 # if neighbor in neg. x-direction is 2 then save the actual voxel and the neighbor!! 

701 if lab[positionLabel[0][x] - 1, positionLabel[1][x], positionLabel[2][x]] == 2: 

702 vx2 = numpy.array( 

703 [ 

704 positionLabel[0][x] - 1, 

705 positionLabel[1][x], 

706 positionLabel[2][x], 

707 2, 

708 ] 

709 ) 

710 contact = numpy.vstack((contact, pix)) 

711 contact = numpy.vstack((contact, vx2)) 

712 # y axis neighbor 

713 if positionLabel[1][x] < dimY - 1: 

714 if lab[positionLabel[0][x], positionLabel[1][x] + 1, positionLabel[2][x]] == 2: 

715 vy1 = numpy.array( 

716 [ 

717 positionLabel[0][x], 

718 positionLabel[1][x] + 1, 

719 positionLabel[2][x], 

720 2, 

721 ] 

722 ) 

723 contact = numpy.vstack((contact, pix)) 

724 contact = numpy.vstack((contact, vy1)) 

725 if positionLabel[1][x] != 0: 

726 if lab[positionLabel[0][x], positionLabel[1][x] - 1, positionLabel[2][x]] == 2: 

727 vy2 = numpy.array( 

728 [ 

729 positionLabel[0][x], 

730 positionLabel[1][x] - 1, 

731 positionLabel[2][x], 

732 2, 

733 ] 

734 ) 

735 contact = numpy.vstack((contact, pix)) 

736 contact = numpy.vstack((contact, vy2)) 

737 # z axis neighbor 

738 if positionLabel[2][x] < dimX - 1: 

739 if lab[positionLabel[0][x], positionLabel[1][x], positionLabel[2][x] + 1] == 2: 

740 vz1 = numpy.array( 

741 [ 

742 positionLabel[0][x], 

743 positionLabel[1][x], 

744 positionLabel[2][x] + 1, 

745 2, 

746 ] 

747 ) 

748 contact = numpy.vstack((contact, pix)) 

749 contact = numpy.vstack((contact, vz1)) 

750 if positionLabel[2][x] != 0: 

751 if lab[positionLabel[0][x], positionLabel[1][x], positionLabel[2][x] - 1] == 2: 

752 vz2 = numpy.array( 

753 [ 

754 positionLabel[0][x], 

755 positionLabel[1][x], 

756 positionLabel[2][x] - 1, 

757 2, 

758 ] 

759 ) 

760 contact = numpy.vstack((contact, pix)) 

761 contact = numpy.vstack((contact, vz2)) 

762 

763 return contact 

764 

765 

766def _contactPositions(contact, probMap): 

767 """ 

768 determines the position of the points that define the contact area 

769 for the random walker probability map 

770 the 50 percent probability plane is considered as the area of contact 

771 linear interpolation is used to determine the 50 percent probability area 

772 

773 Parameters 

774 ---------- 

775 contact : (len(contactVoxels))x4 array 

776 z,y,x positions and the label of the voxel 

777 as returned by the function contactPairs() 

778 

779 probMap : 3D array of floats 

780 probability map of one of the labels 

781 as determined by the random walker 

782 

783 Returns 

784 ------- 

785 coordIntervox : (len(coordIntervox))x3 array 

786 z,y,x positions of the 50 percent probability area 

787 """ 

788 coordIntervox = numpy.array([[], [], []]).transpose() 

789 for x in range(0, len(contact), 2): # call only contact pairs (increment 2) 

790 prob1 = probMap[int(contact[x][0]), int(contact[x][1]), int(contact[x][2])] 

791 # pick the probability value of the concerning contact voxel! 

792 prob2 = probMap[int(contact[x + 1][0]), int(contact[x + 1][1]), int(contact[x + 1][2])] 

793 if prob2 - prob1 != 0: 

794 add = float((0.5 - prob1) / (prob2 - prob1)) 

795 else: 

796 add = 0.5 # if both voxels have the same probability (0.5), the center is just in between them! 

797 # check whether the next contact voxel is x-axis (0) neighbor or not 

798 # x axis neighbor 

799 if contact[x][0] != contact[x + 1][0]: 

800 # 2 possibilities: a coordinates > or < b coordinates 

801 if contact[x][0] < contact[x + 1][0]: # find the contact point in subvoxel resolution! 

802 midX = numpy.array([contact[x][0] + add, contact[x][1], contact[x][2]]) 

803 coordIntervox = numpy.vstack((coordIntervox, midX)) 

804 else: 

805 midX = numpy.array([contact[x][0] - add, contact[x][1], contact[x][2]]) 

806 coordIntervox = numpy.vstack((coordIntervox, midX)) 

807 # y axis neighbor 

808 elif contact[x][1] != contact[x + 1][1]: 

809 if contact[x][1] < contact[x + 1][1]: 

810 midY = numpy.array([contact[x][0], contact[x][1] + add, contact[x][2]]) 

811 coordIntervox = numpy.vstack((coordIntervox, midY)) 

812 else: 

813 midY = numpy.array([contact[x][0], contact[x][1] - add, contact[x][2]]) 

814 coordIntervox = numpy.vstack((coordIntervox, midY)) 

815 # z axis neighbor 

816 else: 

817 if contact[x][2] < contact[x + 1][2]: 

818 midZ = numpy.array([contact[x][0], contact[x][1], contact[x][2] + add]) 

819 coordIntervox = numpy.vstack((coordIntervox, midZ)) 

820 else: 

821 midZ = numpy.array([contact[x][0], contact[x][1], contact[x][2] - add]) 

822 coordIntervox = numpy.vstack((coordIntervox, midZ)) 

823 

824 return coordIntervox 

825 

826 

827def _contactNormals(dataSet): 

828 """ 

829 determines the contact normal orientation 

830 based on the intervoxel positions determined by the function contactPositions() 

831 uses a principal component analysis 

832 the smallest eigenvector of the covariance matrix is considered as the contact orientation 

833 

834 Parameters 

835 ---------- 

836 dataSet : (len(dataSet))x3 array 

837 z,y,x positions of the 50 percent probability area of the random walker 

838 

839 Returns 

840 ------- 

841 contactNormal : 1x3 array 

842 z,y,x directions of the normalised contact normal orientation 

843 """ 

844 covariance = numpy.cov(dataSet, rowvar=0, bias=0) 

845 

846 # step 3: calculate the eigenvectors and eigenvalues 

847 # eigenvalues are not necessarily ordered ... 

848 # colum[:,i] corresponds to eig[i] 

849 eigVal, eigVec = numpy.linalg.eig(covariance) 

850 

851 # look for the eigenvector corresponding to the minimal eigenvalue! 

852 minEV = eigVec[:, numpy.argmin(eigVal)] 

853 

854 # vector orientation 

855 contactNormal = numpy.zeros((1, 3)) 

856 if minEV[2] < 0: 

857 contactNormal[0, 0], contactNormal[0, 1], contactNormal[0, 2] = ( 

858 -minEV[0], 

859 -minEV[1], 

860 -minEV[2], 

861 ) 

862 else: 

863 contactNormal[0, 0], contactNormal[0, 1], contactNormal[0, 2] = ( 

864 minEV[0], 

865 minEV[1], 

866 minEV[2], 

867 ) 

868 

869 return contactNormal 

870 

871 

872def _markerCorrection( 

873 markers, 

874 numMarkers, 

875 distanceMap, 

876 volBin, 

877 peakDistance=5, 

878 struc=numpy.ones((3, 3, 3)), 

879): 

880 """ 

881 corrects the number of markers used for the random walker segmentation of two particles 

882 if too many markers are found, the markers with the largest distance are considered 

883 if too few markers are found, the minimum distance between parameters is reduced 

884 

885 Parameters 

886 ---------- 

887 markers : 3D array of integers 

888 volume containing the potential markers for the random walker 

889 

890 numMarkers : integer 

891 number of markers found so far 

892 

893 distanceMap : 3D array of floats 

894 euclidean distance map 

895 

896 volBin : 3D array of integers 

897 binary volume 

898 

899 peakDistance : integer 

900 peak distance as used in skimage.feature.peak_local_max 

901 Default value is 15 px 

902 

903 struc=numpy.ones((3,3,3)) : 3x3x3 array of integers 

904 structuring element for the labelling of the markers 

905 Default element is a 3x3x3 array of ones 

906 

907 Returns 

908 ------- 

909 markers : 3-D array of integers 

910 volume containing the markers 

911 """ 

912 counter = 0 

913 # If there are too many markers, slowly increse peakDistance until it's the size of the image, if this overshoots the next bit will bring it back down 

914 if numpy.amax(markers) > 2: 

915 while numpy.amax(markers) > 2 and peakDistance < min(volBin.shape): 

916 peakDistance = max(1, int(peakDistance * 1.3)) 

917 localMaxiPoints = skimage.feature.peak_local_max( 

918 distanceMap, 

919 min_distance=peakDistance, 

920 exclude_border=False, 

921 labels=volBin, 

922 num_peaks=2, 

923 ) 

924 localMaxi = numpy.zeros_like(distanceMap) 

925 localMaxi[tuple(localMaxiPoints.T)] = True 

926 markers, numMarkers = scipy.ndimage.label(localMaxi, structure=struc) 

927 

928 # If there are no markers, decrease peakDistance 

929 while numpy.any(markers) is False or numpy.amax(markers) < 2: 

930 if counter > 10: 

931 markers = numpy.zeros(markers.shape) 

932 return markers 

933 peakDistance = max(1, int(peakDistance / 1.3)) 

934 localMaxiPoints = skimage.feature.peak_local_max( 

935 distanceMap, 

936 min_distance=peakDistance, 

937 exclude_border=False, 

938 labels=volBin, 

939 num_peaks=2, 

940 ) 

941 localMaxi = numpy.zeros_like(distanceMap, dtype=bool) 

942 localMaxi[tuple(localMaxiPoints.T)] = True 

943 markers, numMarkers = scipy.ndimage.label(localMaxi, structure=struc) 

944 counter = counter + 1 

945 

946 if numpy.amax(markers) > 2: 

947 centerOfMass = scipy.ndimage.center_of_mass(markers, labels=markers, index=range(1, numMarkers + 1)) 

948 distances = numpy.zeros((numMarkers, numMarkers)) 

949 for i in range(0, numMarkers): 

950 for j in range(i, numMarkers): 

951 distances[i, j] = numpy.linalg.norm(numpy.array(centerOfMass[i]) - numpy.array(centerOfMass[j])) 

952 

953 maxDistance = numpy.amax(distances) 

954 posMaxDistance = numpy.where(distances == maxDistance) 

955 

956 # let's choose the markers with the maximum distance 

957 markers1 = numpy.where(markers == posMaxDistance[0][0] + 1, 1, 0) 

958 markers2 = numpy.where(markers == posMaxDistance[1][0] + 1, 2, 0) 

959 

960 localMaxi = markers1 + markers2 

961 markers, numMarkers = scipy.ndimage.label(localMaxi, structure=struc) 

962 

963 return markers 

964 

965 

966def localDetectionAssembly( 

967 volLab, 

968 volGrey, 

969 contactList, 

970 localThreshold, 

971 boundingBoxes=None, 

972 nProcesses=nProcessesDefault, 

973 radiusThresh=4, 

974): 

975 """ 

976 Local contact refinement of a set of contacts 

977 checks whether two particles are in contact with a local threshold using ``localDetection()`` 

978 

979 Parameters 

980 ---------- 

981 volLab : 3D array 

982 Labelled volume 

983 

984 volGrey : 3D array 

985 Grey-scale volume 

986 

987 contactList : (ContactNumber)x2 array of integers 

988 contact list with grain labels of each contact in a seperate row, 

989 as given by ``spam.label.contacts.labelledContacts`` in the list contactingLabels 

990 

991 localThreshold : integer or float, same type as the 3D array 

992 threshold for binarisation of the subvolume 

993 

994 boundingBoxes : lab.max()x6 array of ints, optional 

995 Bounding boxes in format returned by ``boundingBoxes``. 

996 If not defined (Default = None), it is recomputed by running ``boundingBoxes`` 

997 

998 nProcesses : integer (optional, default = nProcessesDefault) 

999 Number of processes for multiprocessing 

1000 Default = number of CPUs in the system 

1001 

1002 radiusThresh : integer, optional 

1003 radius in px for excluding patches that might not be relevant, 

1004 e.g. noise can lead to patches that are not connected with the grains in contact 

1005 the patches are calculated with ``equivalentRadii()`` 

1006 Default is 4 

1007 

1008 Returns 

1009 ------- 

1010 contactListRefined : (ContactNumber)x2 array of integers 

1011 refined contact list, based on the chosen local threshold 

1012 

1013 Note 

1014 ---- 

1015 see https://doi.org/10.1088/1361-6501/aa8dbf for further information 

1016 """ 

1017 import progressbar 

1018 

1019 # check if bounding boxes are given 

1020 if boundingBoxes is None: 

1021 # print "bounding boxes are not given. calculating ...." 

1022 boundingBoxes = spam.label.boundingBoxes(volLab) 

1023 

1024 contactListRefined = [] 

1025 numberOfJobs = len(contactList) 

1026 # print ("\n\tLocal contact refinement") 

1027 # print ("\tApplying a local threshold of ", localThreshold, " to each contact.") 

1028 # print ("\tNumber of contacts for treatment ", numberOfJobs) 

1029 # print ("") 

1030 

1031 ########################################## 

1032 

1033 # Function for localDetectionAssembly 

1034 global funLocalDetectionAssembly 

1035 

1036 def funLocalDetectionAssembly(job): 

1037 grainA, grainB = contactList[job].astype("int") 

1038 labels = [grainA, grainB] 

1039 subset = fetchTwoGrains(volLab, labels, volGrey, boundingBoxes) 

1040 contact = localDetection(subset["subVolGrey"], localThreshold, radiusThresh) 

1041 if contact is True: 

1042 # print ("we are in contact!") 

1043 return job, grainA, grainB 

1044 

1045 # Create progressbar 

1046 widgets = [ 

1047 progressbar.FormatLabel(""), 

1048 " ", 

1049 progressbar.Bar(), 

1050 " ", 

1051 progressbar.AdaptiveETA(), 

1052 ] 

1053 pbar = progressbar.ProgressBar(widgets=widgets, maxval=numberOfJobs) 

1054 pbar.start() 

1055 finishedNodes = 0 

1056 

1057 # Run multiprocessing 

1058 with multiprocessing.Pool(processes=nProcesses) as pool: 

1059 for returns in pool.imap_unordered(funLocalDetectionAssembly, range(0, numberOfJobs)): 

1060 finishedNodes += 1 

1061 widgets[0] = progressbar.FormatLabel("{}/{} ".format(finishedNodes, numberOfJobs)) 

1062 pbar.update(finishedNodes) 

1063 if returns is not None: 

1064 contactListRefined.append([returns[1], returns[2]]) 

1065 pool.close() 

1066 pool.join() 

1067 

1068 # End progressbar 

1069 pbar.finish() 

1070 

1071 return numpy.asarray(contactListRefined) 

1072 

1073 

1074def contactOrientationsAssembly( 

1075 volLab, 

1076 volGrey, 

1077 contactList, 

1078 watershed="ITK", 

1079 peakDistance=5, 

1080 boundingBoxes=None, 

1081 nProcesses=nProcessesDefault, 

1082 verbose=False, 

1083): 

1084 """ 

1085 Determines contact normal orientation in an assembly of touching particles 

1086 uses either directly the labelled image or the random walker implementation from skimage 

1087 

1088 Parameters 

1089 ---------- 

1090 volLab : 3D array 

1091 Labelled volume 

1092 

1093 volGrey : 3D array 

1094 Grey-scale volume 

1095 

1096 contactList : (ContactNumber)x2 array of integers 

1097 contact list with grain labels of each contact in a seperate row, 

1098 as given by ``spam.label.contacts.labelledContacts`` in the list contactingLabels 

1099 or by ``spam.label.contacts.localDetectionAssembly()`` 

1100 

1101 watershed : string, optional 

1102 sets the basis for the determination of the orientation 

1103 options are "ITK" for the labelled image from the input, 

1104 "RW" for a further segmentation by the random walker 

1105 Default = "ITK" 

1106 

1107 peakDistance : int, optional 

1108 Distance in pixels used in skimage.feature.peak_local_max 

1109 Default value is 5 

1110 

1111 boundingBoxes : lab.max()x6 array of ints, optional 

1112 Bounding boxes in format returned by ``boundingBoxes``. 

1113 If not defined (Default = None), it is recomputed by running ``boundingBoxes`` 

1114 

1115 nProcesses : integer (optional, default = nProcessesDefault) 

1116 Number of processes for multiprocessing 

1117 Default = number of CPUs in the system 

1118 

1119 verbose : boolean (optional, default = False) 

1120 True for printing the evolution of the process 

1121 False for not printing the evolution of process 

1122 

1123 Returns 

1124 ------- 

1125 contactOrientations : (numContacts)x6 array 

1126 contact normal orientation for every contact 

1127 [labelGrainA, labelGrainB, orientationZ, orientationY, orientationX, intervoxel positions for PCA] 

1128 

1129 notTreatedContact : boolean 

1130 if False that contact is not determined 

1131 if the contact consisted of too few voxels a fit is not possible or feasible 

1132 """ 

1133 

1134 # check if bounding boxes are given 

1135 if boundingBoxes is None: 

1136 # print ("bounding boxes are not given. calculating ....") 

1137 boundingBoxes = spam.label.boundingBoxes(volLab) 

1138 

1139 contactOrientations = [] 

1140 numberOfJobs = len(contactList) 

1141 # print ("\n\tDetermining the contact orientations of an assembly of particles") 

1142 # print ("\tNumber of contacts ", numberOfJobs) 

1143 # print ("") 

1144 

1145 ########################################## 

1146 

1147 # Function for ContactOrientationsAssembly 

1148 global funContactOrientationsAssembly 

1149 

1150 def funContactOrientationsAssembly(job): 

1151 grainA, grainB = contactList[job, 0:2].astype("int") 

1152 labels = [grainA, grainB] 

1153 subset = fetchTwoGrains(volLab, labels, volGrey, boundingBoxes) 

1154 contactNormal, intervox, NotTreatedContact = spam.label.contactOrientations( 

1155 subset["subVolBin"], 

1156 subset["subVolLab"], 

1157 watershed, 

1158 peakDistance=peakDistance, 

1159 verbose=verbose, 

1160 ) 

1161 # TODO work on not treated contacts -- output them! 

1162 return ( 

1163 grainA, 

1164 grainB, 

1165 contactNormal[0], 

1166 contactNormal[1], 

1167 contactNormal[2], 

1168 intervox, 

1169 ) 

1170 

1171 # Create progressbar 

1172 widgets = [ 

1173 progressbar.FormatLabel(""), 

1174 " ", 

1175 progressbar.Bar(), 

1176 " ", 

1177 progressbar.AdaptiveETA(), 

1178 ] 

1179 pbar = progressbar.ProgressBar(widgets=widgets, maxval=numberOfJobs) 

1180 pbar.start() 

1181 finishedNodes = 0 

1182 

1183 # Run multiprocessing 

1184 with multiprocessing.Pool(processes=nProcesses) as pool: 

1185 for returns in pool.imap_unordered(funContactOrientationsAssembly, range(0, numberOfJobs)): 

1186 finishedNodes += 1 

1187 widgets[0] = progressbar.FormatLabel("{}/{} ".format(finishedNodes, numberOfJobs)) 

1188 pbar.update(finishedNodes) 

1189 if returns is not None: 

1190 contactOrientations.append( 

1191 [ 

1192 returns[0], 

1193 returns[1], 

1194 returns[2], 

1195 returns[3], 

1196 returns[4], 

1197 returns[5], 

1198 ] 

1199 ) 

1200 # result[2], result[3], result[4], result[5], result[6], result[7] 

1201 pool.close() 

1202 pool.join() 

1203 

1204 # End progressbar 

1205 pbar.finish() 

1206 

1207 return numpy.asarray(contactOrientations)