objscale 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
objscale/__init__.py ADDED
@@ -0,0 +1,56 @@
1
+ # objscale/__init__.py
2
+ from ._size_distributions import (
3
+ finite_array_size_distribution,
4
+ finite_array_powerlaw_exponent,
5
+ array_size_distribution,
6
+ )
7
+
8
+ from ._fractal_dimensions import (
9
+ individual_fractal_dimension,
10
+ ensemble_correlation_dimension,
11
+ ensemble_box_dimension,
12
+ ensemble_coarsening_dimension,
13
+ total_perimeter,
14
+ total_number,
15
+ isolate_largest_structure,
16
+ coarsen_array,
17
+ )
18
+
19
+ from ._object_analysis import (
20
+ get_structure_props,
21
+ remove_structures_touching_border_nan,
22
+ label_periodic_boundaries,
23
+ remove_structure_holes,
24
+ clear_border_adjacent,
25
+ )
26
+
27
+ from ._utils import (
28
+ linear_regression,
29
+ encase_in_value,
30
+ )
31
+
32
+ __all__ = [
33
+ 'finite_array_size_distribution',
34
+ 'finite_array_powerlaw_exponent',
35
+ 'array_size_distribution',
36
+ 'individual_fractal_dimension',
37
+ 'ensemble_correlation_dimension',
38
+ 'ensemble_box_dimension',
39
+ 'ensemble_coarsening_dimension',
40
+ 'total_perimeter',
41
+ 'total_number',
42
+ 'isolate_largest_structure',
43
+ 'coarsen_array',
44
+ 'get_structure_props',
45
+ 'remove_structures_touching_border_nan',
46
+ 'label_periodic_boundaries',
47
+ 'remove_structure_holes',
48
+ 'clear_border_adjacent',
49
+ 'linear_regression',
50
+ 'encase_in_value',
51
+ ]
52
+
53
+ __version__ = "0.1.0"
54
+ __author__ = "Thomas DeWitt"
55
+ __email__ = "thomas.dewitt@utah.edu"
56
+ __description__ = "Object-based analysis functions for fractal dimensions and size distributions"
@@ -0,0 +1,632 @@
1
+ import numpy as np
2
+ from numba import njit, prange
3
+ from numba.typed import List
4
+ from scipy.ndimage import label
5
+ from skimage.segmentation import clear_border
6
+ from warnings import warn
7
+ from ._object_analysis import remove_structures_touching_border_nan, remove_structure_holes
8
+ from ._utils import linear_regression, encase_in_value
9
+
10
+
11
+ def ensemble_correlation_dimension(arrays, x_sizes=None, y_sizes=None, middle_ninth=True, return_C_l=False, bins=None, point_reduction_factor=1):
12
+ """
13
+ Given a binary array, calculate the correlation dimension D where C_l\propto l^D
14
+
15
+ Input:
16
+ array:
17
+ binary array to calculate correlation dimension of
18
+ x_sizes, y_sizes:
19
+ pixel sizes in the x and y directions.
20
+ If None, assume all pixel dimensions are 1.
21
+ If np.ndarray, use these for each array in 'arrays'
22
+ If list, assume x_sizes[i] corresponds to arrays[i], etc, for all i
23
+ middle_ninth:
24
+ For each pixel, only use distances between that pixel and pixels within the central 9th section of the array. This reduces boundary effects.
25
+ return_C_l:
26
+ if True,
27
+ return dimension, error, bins, C_l
28
+ else, return dimension, error
29
+ bins:
30
+ Values of l to use for the regression
31
+ if None, automatically calculate as
32
+ logarithmically spaced intervals between
33
+ 3*minimum length and the array width or height
34
+ point_reduction_factor: float, >= 1
35
+ Draw N/point_reduction_factor circles, where N is the total number of available circles.
36
+ Choose the circle centers randomly.
37
+
38
+ Output:
39
+ if return_C_l:
40
+ return dimension, error, bins, C_l
41
+ else:
42
+ return dimension, error
43
+ """
44
+ nbins = 50
45
+
46
+ if type(arrays) == np.ndarray: arrays = [arrays]
47
+
48
+ if x_sizes is None: x_sizes = np.ones(arrays[0].shape, dtype=np.float32)
49
+ if y_sizes is None: y_sizes = np.ones(arrays[0].shape, dtype=np.float32)
50
+ locations_x, locations_y = get_locations_from_pixel_sizes(x_sizes, y_sizes)
51
+
52
+ h = x_sizes.shape[0]
53
+ w = x_sizes.shape[1]
54
+
55
+ if middle_ninth: # min(width, height) of middle 9th, where width, height are calculated in the center
56
+ maxlength = np.sqrt((locations_x[int(h/2), int(w/3)]-locations_x[int(h/2), int(2*w/3)])**2 + (locations_y[int(h/3), int(w/2)]-locations_y[int(2*h/3), int(w/2)])**2)
57
+ else: # min(width, height) of entire array, where width, height are calculated in the center
58
+ maxlength = np.sqrt((locations_x[int(h/2), 0]-locations_x[int(h/2), w-1])**2 + (locations_y[0, int(w/2)]-locations_y[h-1, int(w/2)])**2)
59
+
60
+ minlength = 3*min(np.nanmin(x_sizes), np.nanmin(y_sizes))
61
+ if bins is None: bins = np.geomspace(minlength, maxlength, nbins)
62
+
63
+ C_l = np.zeros(bins.shape)
64
+
65
+ for array in arrays:
66
+ if np.any(array.shape != x_sizes.shape): raise ValueError(f'All arrays must be same shape as pixel sizes (currently {array.shape} and {x_sizes.shape}, respectively)')
67
+
68
+ array = array.astype(np.float16)
69
+
70
+ all_boundary_coordinates = get_coords_of_boundaries(array)
71
+
72
+ if middle_ninth:
73
+ middle_coordinates = all_boundary_coordinates[np.argwhere((all_boundary_coordinates[:,0]<int(2*h/3)) & (all_boundary_coordinates[:,0]>int(h/3))).flatten()]
74
+ middle_coordinates = middle_coordinates[np.argwhere((middle_coordinates[:,1]<int(2*w/3)) & (middle_coordinates[:,1]>int(w/3))).flatten()]
75
+
76
+ circle_centers = middle_coordinates
77
+ else:
78
+ circle_centers = all_boundary_coordinates
79
+
80
+ if point_reduction_factor>1:
81
+ circle_centers = circle_centers[np.random.choice(np.arange(len(circle_centers)), int(len(circle_centers)/point_reduction_factor), replace=True)]
82
+ elif point_reduction_factor<1: raise ValueError('point_reduction_factor must be >= 1')
83
+
84
+ C_l += correlation_integral(circle_centers, all_boundary_coordinates, locations_x, locations_y, bins)
85
+
86
+ # Perform linear regression to estimate dimension
87
+ x,y = np.log10(bins), np.log10(C_l)
88
+ index = np.isfinite(x) & np.isfinite(y)
89
+ if len(x[index]) <3:
90
+ warn('Not enough data to estimate correlation dimension, returning nan')
91
+ if return_C_l:
92
+ return np.nan,np.nan,np.array([np.nan]),np.array([np.nan])
93
+ else:
94
+ return np.nan,np.nan
95
+ coefficients, cov = np.polyfit(x[index], y[index], 1, cov=True)
96
+ fit_error = np.sqrt(np.diag(cov))
97
+ dimension, error = coefficients[0], 2*fit_error[0] # fit error is for 95% conf. int.
98
+
99
+ if return_C_l:
100
+ return dimension, error, bins, C_l
101
+ else:
102
+ return dimension, error
103
+
104
+
105
+ def ensemble_box_dimension(binary_arrays, set = 'edge', min_pixels=1, min_box_size=2, box_sizes = 'default', return_values=False):
106
+ """
107
+ Calculate the ensemble box-counting dimension of binary arrays.
108
+
109
+ This function estimates the box-counting dimension (also known as Minkowski-Bouligand dimension)
110
+ for a list of binary arrays. It averages the results across multiple arrays.
111
+
112
+ Parameters:
113
+ -----------
114
+ binary_arrays : list of numpy.ndarray or numpy.ndarray
115
+ A list of 2D binary arrays or a single 2D binary array.
116
+ set : str, optional (default='edge')
117
+ Specifies which set to consider for box counting:
118
+ - 'edge': Box dimension of the set of boundaries between 0 and 1: Count boxes that contain at least one pixel with value 1 AND one pixel with value 0.
119
+ - 'ones': Box dimension of the set of values equal to 1: Count boxes that contain at least one pixel with value 1.
120
+ min_pixels : int, optional (default=1)
121
+ Largest box size, in units of the number of boxes required to cover the array in the smaller direction
122
+ min_box_size : int, optional (default=2)
123
+ Smallest box size
124
+ box_sizes : array-like or 'default', optional (default='default')
125
+ Box sizes used. If 'default', uses those powers of 2 up to 2^14 that satisfy the above criteria.
126
+ return_values : bool, optional (default=False)
127
+ If True, return additional data used in the calculation.
128
+
129
+ Returns:
130
+ --------
131
+ float
132
+ The estimated box-counting dimension.
133
+ float
134
+ The error of the estimate.
135
+ tuple, optional
136
+ If return_values is True, also returns:
137
+ box_sizes
138
+ mean_number_boxes
139
+
140
+ Raises:
141
+ -------
142
+ ValueError
143
+ If an unsupported value is provided for 'set' or 'upscale_factors'.
144
+ If an array contains nan values
145
+
146
+ Notes:
147
+ ------
148
+ The function uses linear regression on log-log plot of box counts vs. scale
149
+ to estimate the box-counting dimension. The slope of this regression gives
150
+ the negative of the box-counting dimension.
151
+
152
+ """
153
+
154
+ if type(binary_arrays) == np.ndarray: binary_arrays = [binary_arrays]
155
+
156
+ if type(box_sizes) == str: # to not try elementwise comparison
157
+ if box_sizes != 'default': raise ValueError(f'upscale_factors={box_sizes} not supported')
158
+
159
+ box_sizes = 2**np.arange(1,15) # assumed any array is smaller than 32768 pixels
160
+ else: box_sizes = np.array(box_sizes)
161
+
162
+ max_upscale_factor = min(binary_arrays[0].shape)/min_pixels
163
+ box_sizes = box_sizes[box_sizes<=max_upscale_factor]
164
+ box_sizes = box_sizes[box_sizes>=min_box_size]
165
+
166
+ mean_number_boxes = np.empty((0,box_sizes.size), dtype=np.float32)
167
+
168
+ for array in binary_arrays:
169
+ number_boxes = []
170
+ if np.count_nonzero(np.isnan(array))>0: raise ValueError('array has nan values')
171
+ for factor in box_sizes:
172
+
173
+ # Upscale
174
+ upscaled_array = encase_in_value(coarsen_array(array, factor), np.nan)
175
+
176
+ # Count boxes
177
+ if set == 'edge':
178
+ nboxes = np.count_nonzero((upscaled_array>0) & (upscaled_array<1))
179
+ elif set == 'ones':
180
+ nboxes = np.count_nonzero(upscaled_array>0)
181
+ else: raise ValueError(f'set={set} not supported (supported values are "edge" or "ones")')
182
+
183
+ number_boxes.append(nboxes)
184
+
185
+ mean_number_boxes = np.append(mean_number_boxes, [number_boxes], axis=0)
186
+
187
+ mean_number_boxes = np.mean(mean_number_boxes, axis=0)
188
+ mean_number_boxes[mean_number_boxes==0] = np.nan # eliminate warning when logging 0
189
+
190
+ (slope, _), (error,_) = linear_regression(np.log10(box_sizes), np.log10(mean_number_boxes))
191
+
192
+ if return_values: return -slope, error, box_sizes, mean_number_boxes
193
+ return -slope, error
194
+
195
+
196
+ def ensemble_coarsening_dimension(arrays, x_sizes=None, y_sizes=None, cloudy_threshold=0.5, min_pixels=30, return_values=False, upscale_factors = 'default', count_exterior=False):
197
+ """
198
+ Given a list of 2-D arrays, calculate the ensemble fractal dimension by
199
+ coarsening the resolution and calculating the total perimeter as a function of resolution.
200
+
201
+ Input:
202
+ arrays: array, or list of arrays, to upscale, apply cloudy_thresh to make binary, then calculate total perimeter
203
+ min_pixels: limit the upscale factors such that upscaled matrices always have shape >= (min_pixels, min_pixels)
204
+ return_values: if True, return (D_e, error), (upscale_factors, mean_total_perimeters)
205
+ x_sizes, y_sizes: Pixel sizes in the x and y directions.
206
+ If None, assume all pixel dimensions are 1.
207
+ If np.ndarray, use these for each array in 'arrays'
208
+ If list, assume x_sizes[i] corresponds to arrays[i], etc, for all i
209
+ return:
210
+ D_e, error (95% conf)
211
+ if return_values: D_e, error, upscale_factors, mean_total_perimeters
212
+ """
213
+ if type(arrays) == np.ndarray: arrays = [arrays]
214
+ if x_sizes is None: x_sizes = np.ones(arrays[0].shape, dtype=bool)
215
+ if y_sizes is None: y_sizes = np.ones(arrays[0].shape, dtype=bool)
216
+
217
+ if type(upscale_factors) == str: # to not try elementwise comparison
218
+ if upscale_factors != 'default': raise ValueError(f'upscale_factors={upscale_factors} not supported')
219
+ if np.count_nonzero((arrays[0]<1) & (arrays[0]>0))==0:
220
+ # If a binary array, even upscale factors can be ambiguous because sometimes the superpixel is half cloudy. In this case, use the following
221
+ upscale_factors = 3**np.arange(0,10)
222
+ else:
223
+ # Otherwise, use more upscale factors:
224
+ upscale_factors = 2**np.arange(0,15)
225
+ else: upscale_factors = np.array(upscale_factors)
226
+
227
+ max_upscale_factor = min(arrays[0].shape)/min_pixels
228
+ upscale_factors = upscale_factors[upscale_factors<=max_upscale_factor]
229
+
230
+ mean_total_perimeters = np.empty((0,upscale_factors.size), dtype=np.float32)
231
+
232
+ for i in range(len(arrays)):
233
+ array = arrays[i]
234
+ if type(x_sizes) == list:
235
+ xs = x_sizes[i]
236
+ else:
237
+ xs = x_sizes
238
+ if type(y_sizes) == list:
239
+ ys = y_sizes[i]
240
+ else:
241
+ ys = y_sizes
242
+
243
+ total_perimeters = []
244
+ for factor in upscale_factors:
245
+
246
+ # Upscale
247
+ upscaled_array = encase_in_value(coarsen_array(array, factor), np.nan)
248
+ upscaled_x_sizes = factor*encase_in_value(coarsen_array(xs, factor), 0) # the value appended here is irrelevant since the array is 0 here, just to make the shape the same
249
+ upscaled_y_sizes = factor*encase_in_value(coarsen_array(ys, factor), 0) # the value appended here is irrelevant since the array is 0 here, just to make the shape the same
250
+
251
+ nanmask = (np.isnan(upscaled_array) | np.isnan(upscaled_x_sizes) | np.isnan(upscaled_y_sizes))
252
+ # Make binary
253
+ upscaled_array_binary = (upscaled_array>cloudy_threshold).astype(np.float32)
254
+
255
+ # To not count exterior perimeter, set to nan, to count it, set to 0
256
+ if count_exterior: padding = 0
257
+ else: padding = np.nan
258
+ upscaled_array_binary[nanmask] = padding
259
+
260
+ total_p = total_perimeter(upscaled_array_binary, upscaled_x_sizes, upscaled_y_sizes)
261
+ total_perimeters.append(total_p)
262
+ mean_total_perimeters = np.append(mean_total_perimeters, [total_perimeters], axis=0)
263
+
264
+ mean_total_perimeters = np.mean(mean_total_perimeters, axis=0)
265
+ mean_total_perimeters[mean_total_perimeters==0] = np.nan # eliminate warning when logging 0
266
+
267
+ (slope, _), (error,_) = linear_regression(np.log10(upscale_factors), np.log10(mean_total_perimeters))
268
+ if return_values: return 1-slope, error, upscale_factors, mean_total_perimeters
269
+
270
+ return 1-slope, error
271
+
272
+
273
+ def individual_fractal_dimension(arrays, x_sizes=None, y_sizes=None, min_a=10, max_a=np.inf, return_values=False):
274
+ """
275
+ Calculate the individual fractal dimension Df of objects within arrays.
276
+
277
+ The method is by a linear regression to log a vs. log p, where a and p
278
+ are calculated not including structure holes, and omitting structures touching the array edge.
279
+
280
+
281
+ Input:
282
+ arrays: list of boolean 2D np.ndarrays
283
+ min_a, max_a: use structure areas within these bounds to calculate Df
284
+ return_values: if True, return (Df, err), (log10(sqrt(a)), log10(p))
285
+ x_sizes, y_sizes: Pixel sizes in the x and y directions.
286
+ If None, assume all pixel dimensions are 1.
287
+ If np.ndarray, use these for each array in 'arrays'
288
+ If list, assume x_sizes[i] corresponds to arrays[i], etc, for all i
289
+ Output:
290
+ Df, uncertainty (95% conf)
291
+ if return_values: Df, err, log10_sqrt(a), log10(p)
292
+
293
+ """
294
+ from ._object_analysis import get_structure_props
295
+
296
+ areas, perimeters = [], []
297
+ if (type(arrays) != list): arrays = [arrays]
298
+
299
+ if x_sizes is None: x_sizes = np.ones_like(arrays[0])
300
+ if y_sizes is None: y_sizes = np.ones_like(arrays[0])
301
+
302
+ for i in range(len(arrays)):
303
+ array = arrays[i]
304
+ if type(x_sizes) == list:
305
+ xs = x_sizes[i]
306
+ else:
307
+ xs = x_sizes
308
+ if type(y_sizes) == list:
309
+ ys = y_sizes[i]
310
+ else:
311
+ ys = y_sizes
312
+
313
+ if np.any(array.shape != xs.shape): raise ValueError('Each array shape must match corresponding pixel sizes shape')
314
+
315
+ array = remove_structures_touching_border_nan(array)
316
+ array = remove_structure_holes(array)
317
+ new_p, new_a, _, _ = get_structure_props(array, xs, ys)
318
+ areas.extend(new_a)
319
+ perimeters.extend(new_p)
320
+
321
+ areas, perimeters = np.array(areas), np.array(perimeters)
322
+ areas, perimeters = areas[(areas>min_a) & (areas<max_a)], perimeters[(areas>min_a) & (areas<max_a)]
323
+
324
+ (slope, _), (err, _) = linear_regression(np.log10(np.sqrt(areas)), np.log10(perimeters))
325
+
326
+ if return_values: return slope, err, np.log10(np.sqrt(areas)), np.log10(perimeters)
327
+ else: return slope, err
328
+
329
+
330
+ # Helper functions for fractal dimension calculations
331
+
332
+ def get_coords_of_boundaries(array):
333
+ """
334
+ Find coordinates of pixels of value 1 that are adjacent to pixels of value 0
335
+ Input:
336
+ -array: 2-D binary np.ndarray
337
+ Output:
338
+ np.ndarray of shape(n_boundaries, 2)
339
+ where each element is a pair of indicies corresponding to the locations
340
+ of pixels with value 1 that are adjacent to a pixel of value 0
341
+
342
+ Note:
343
+ Topology is toroidal, so pixels on one edge are considered adjacent to pixels
344
+ on the opposite edge.
345
+ Example:
346
+
347
+ array1 = np.zeros((10,10))
348
+ array1[6:8,6:8] = 1
349
+ array1[3:4,2:5] = 1
350
+ array2 = np.zeros((10,10))
351
+ for i,j in get_coords_of_boundaries(array1):
352
+ array2[i,j] = 1
353
+ print(np.all(array2==array1))
354
+
355
+ >>> True
356
+ """
357
+ array = array.astype(np.int16)
358
+ shifted_right = np.roll(array, shift=1, axis=1)
359
+ shifted_down = np.roll(array, 1, axis=0)
360
+ diff_right = shifted_right-array
361
+ diff_down = shifted_down-array
362
+
363
+ right_side_of_pixel = np.argwhere(diff_right==1)
364
+ right_side_of_pixel[:,1] -= 1
365
+ left_side_of_pixel = np.argwhere(diff_right==-1)
366
+ bottom_of_pixel = np.argwhere(diff_down==1)
367
+ bottom_of_pixel[:,0] -= 1
368
+ top_of_pixel = np.argwhere(diff_down==-1)
369
+
370
+ all_coords = np.append(right_side_of_pixel, left_side_of_pixel, axis=0)
371
+ all_coords = np.append(all_coords, top_of_pixel, axis=0)
372
+ all_coords = np.append(all_coords, bottom_of_pixel, axis=0)
373
+
374
+ # remove duplicates
375
+ all_coords = np.unique(all_coords, axis=0)
376
+
377
+ return all_coords
378
+
379
+
380
+ def get_locations_from_pixel_sizes(pixel_sizes_x, pixel_sizes_y):
381
+ return np.nancumsum(pixel_sizes_x, 1), np.nancumsum(pixel_sizes_y, 0)
382
+
383
+
384
+ @njit(parallel=True)
385
+ def correlation_integral(coordinates_to_check, coordinates_to_count, locations_x, locations_y, bins):
386
+ """
387
+ Input:
388
+ coordinatess: np array of coordinates of boundaries of shape
389
+ [[x1,y2],
390
+ [x2,y2],
391
+ [x3,y3],
392
+ ...
393
+ ]
394
+ locations_x, locations_y: locations of each pixel.
395
+ For example, np.sqrt((locations_x[i,j]-locations_x[p,q])**2 + (locations_y[i,j]-locations_y[p,q])**2)
396
+ should represent the physical distance between pixel locations at i,j and p,q
397
+ bins: np 1-D array of distances to bin
398
+ Output:
399
+ of same shape as bins
400
+ For each coordinates_to_check:
401
+ Calculate how many coordinates_to_count are less than a distance bin_i of the chosen coord, for each bin_i in bin.
402
+ """
403
+ C_l = np.zeros(bins.shape) # will be count
404
+
405
+ for i in prange(coordinates_to_check.shape[0]):
406
+ for j in range(coordinates_to_count.shape[0]):
407
+ p,q = coordinates_to_check[i]
408
+ r,s = coordinates_to_count[j]
409
+
410
+ dx = (locations_x[p,q]-locations_x[r,s])
411
+ dy = (locations_y[p,q]-locations_y[r,s])
412
+
413
+ distance = np.sqrt((dx**2)+(dy**2))
414
+ for bin_index, bin in enumerate(bins):
415
+ if distance<bin: C_l[bin_index] += 1
416
+ return C_l
417
+
418
+
419
+ def coarsen_array(array, factor):
420
+ """
421
+ Coarsen a given array by a specified factor by averaging along both dimensions.
422
+
423
+ This function takes an input array and reduces it by a given factor along both the x and y dimensions. The
424
+ upscaling is achieved by summing 'superpixel' regions of the original array and dividing by the square of the
425
+ scaling factor. Optionally, the resulting upscaled array can be thresholded to create a binary array, where
426
+ values above 0.5 are set to 1 and values below or equal to 0.5 are set to 0.
427
+
428
+ Args:
429
+ array (numpy.ndarray): The input array to be upscaled.
430
+ factor (int): The scaling factor for enlarging the array. Must be a positive integer.
431
+
432
+ Returns:
433
+ numpy.ndarray: The upscaled array.
434
+
435
+ Raises:
436
+ ValueError: If an even scaling factor is provided while attempting to create a binary array, as this may lead
437
+ to rounding issues.
438
+
439
+ Example:
440
+ original_array = np.array([[1, 2],
441
+ [3, 4]])
442
+ coarsened = coarsen_array(original_array, factor=2)
443
+ # Resulting coarsened array:
444
+ # array([2.5])
445
+
446
+ """
447
+
448
+ upscaled_array = np.add.reduceat(array, np.arange(array.shape[0], step=factor), axis=0) # add points in x direction
449
+ upscaled_array = np.add.reduceat(upscaled_array, np.arange(array.shape[1], step=factor), axis=1) # add points in y direction
450
+
451
+ # The number of pixels that are upscaled is usually factor**2, but not for edge superpixels if the factor does not evenly divide into array size. Solution:
452
+ pixel_counts = np.add.reduceat(np.ones(array.shape), np.arange(array.shape[0], step=factor), axis=0) # add points in x direction
453
+ pixel_counts = np.add.reduceat(pixel_counts, np.arange(array.shape[1], step=factor), axis=1) # add points in y direction
454
+
455
+ upscaled_array = upscaled_array/pixel_counts
456
+
457
+ return upscaled_array
458
+
459
+
460
+ @njit()
461
+ def total_perimeter(array, x_sizes, y_sizes):
462
+ """
463
+ Given a binary array, calculate the total perimeter. Boundary conditions are assumed periodic.
464
+ Only counts perimeter along edges between 1 and 0. (so that for different b.c. the array could be encased in any other value)
465
+ Assumes periodic B.C.; for something else, padd inputs with 0s or nans.
466
+
467
+ Raises ValueError if x_sizes or y_sizes is nan where array is 1
468
+ """
469
+ perimeter = 0
470
+ for (i, j), value in np.ndenumerate(array):
471
+ if value == 1:
472
+ if np.isnan(x_sizes[i,j]) or np.isnan(y_sizes[i,j]):
473
+ raise ValueError('x_sizes or y_sizes is nan where array is 1')
474
+ if i != array.shape[0]-1 and array[i+1, j] == 0: perimeter += x_sizes[i,j]
475
+ elif i == array.shape[0]-1 and array[0, j] == 0: perimeter += x_sizes[i,j]
476
+
477
+ if i != 0 and array[i-1, j] == 0: perimeter += x_sizes[i,j]
478
+ elif i == 0 and array[array.shape[0]-1, j] == 0: perimeter += x_sizes[i,j]
479
+
480
+ if j != array.shape[1]-1 and array[i, j+1] == 0: perimeter += y_sizes[i,j]
481
+ elif j == array.shape[1]-1 and array[i, 0] == 0: perimeter += y_sizes[i,j]
482
+
483
+ if j != 0 and array[i, j-1] == 0: perimeter += y_sizes[i,j]
484
+ elif j == 0 and array[i, 0] == 0: perimeter += y_sizes[i,j]
485
+
486
+ return perimeter
487
+
488
+
489
+ def total_number(array, structure = np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]])):
490
+ """
491
+ Given a 2-D array with 0's, nans, and 1's, calculate number of objects of connected 1's where connectivity is defined by structure
492
+ """
493
+ array_copy = array.copy()
494
+ array_copy[np.isnan(array_copy)] = 0
495
+ _, n_structures = label(array_copy.astype(bool), structure, output=np.float32)
496
+ return n_structures
497
+
498
+
499
+ def isolate_largest_structure(binary_array, structure=np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]])):
500
+
501
+ labelled_array = label(binary_array, structure)[0]
502
+ cloud_values = labelled_array[labelled_array!= 0] # remove background
503
+ values, counts = np.unique(cloud_values, return_counts=True)
504
+ most_common = values[np.argmax(counts)]
505
+ return labelled_array == most_common
506
+
507
+
508
+ def label_size(array, variable='area',wrap='both', x_sizes=None, y_sizes=None):
509
+ """
510
+ Input:
511
+ array - Binary array of strc: 2-d np.ndarray, padded with 0's or np.nan's
512
+ variable - 'area', 'perimeter', 'width', 'height'
513
+ What variable to use for 'size'
514
+ x_sizes, y_sizes - 2-D np.ndarray of pixel sizes in the x and y directions
515
+ wrap = None, 'sides', 'both':
516
+ if 'sides', connect structures that span the left/right edge
517
+ if 'both', connect structures that span the left/right edge and top/bottom edge
518
+ Output:
519
+ labelled_array, total_P
520
+ : label where structures are labelled with their perimeter, and the background is 0
521
+
522
+ Note: if x_sizes or y_sizes are not uniform, the width will be the sum of the average pixel widths of the pixels in the column and in the object.
523
+ Similarly, the height will be the sum of the average pixel heights of the pixels in the row and in the object.
524
+ Given a array and the sizes of each pixel in each direction, calculate properties of structures.
525
+ Any perimeter between structure and nan is not counted.
526
+ """
527
+ from ._object_analysis import label_periodic_boundaries
528
+
529
+ labelled_array, n_structures = label(array.astype(bool), output=np.float32) # creates array where every unique structure is composed of a unique number, 1 to n_structures
530
+
531
+ if variable not in ['area', 'perimeter', 'width', 'height']:
532
+ raise ValueError(f'variable={variable} not supported (supported values are "area", "perimeter", "width", "height")')
533
+
534
+ if x_sizes is None:
535
+ x_sizes = np.ones_like(labelled_array)
536
+ if y_sizes is None:
537
+ y_sizes = np.ones_like(labelled_array)
538
+
539
+ if wrap is None: pass
540
+ elif wrap == 'sides':
541
+ # set those on right to the same i.d. as those on left
542
+ for j,value in enumerate(labelled_array[:,0]):
543
+ if value != 0:
544
+ if labelled_array[j, labelled_array.shape[1]-1] != 0 and labelled_array[j, labelled_array.shape[1]-1] != value:
545
+ # want not a structure and not already changed
546
+ labelled_array[labelled_array == labelled_array[j, labelled_array.shape[1]-1]] = value # set to same identification number
547
+
548
+ if wrap is None: pass
549
+ elif wrap == 'both' or wrap == 'sides':
550
+ labelled_array = label_periodic_boundaries(labelled_array, wrap)
551
+ else: raise ValueError(f'wrap={wrap} not supported')
552
+
553
+ # Flatten arrays to find their indices.
554
+ values = np.sort(labelled_array.flatten())
555
+ original_locations = np.argsort(labelled_array.flatten()) # Get indices where the original values were
556
+ indices_2d = np.array(np.unravel_index(original_locations, labelled_array.shape)).T # convert flattened indices to 2-d
557
+
558
+ labelled_array[np.isnan(array)] = np.nan # Turn this back to nan so perimeter along it is not included
559
+ split_here = np.roll(values, shift=-1)-values # Split where the values changed.
560
+ split_here[-1] = 0 # Last value rolled over from first
561
+
562
+ separated_structure_indices = np.split(indices_2d, np.where(split_here!=0)[0]+1)
563
+ separated_structure_indices = separated_structure_indices[1:] # Remove the locations that were 0 (not structure)
564
+ if len(separated_structure_indices) == 0: return np.zeros(array.shape),0
565
+
566
+ labelled_with_sizes = np.zeros(array.shape, dtype=int)
567
+
568
+ # must use numba.typed.List here for some reason https://numba.readthedocs.io/en/stable/reference/pysupported.html#feature-typed-list
569
+ labelled_with_sizes = _label_size_helper(labelled_array, List(separated_structure_indices), labelled_with_sizes, variable, x_sizes, y_sizes)
570
+ return labelled_with_sizes
571
+
572
+
573
+ @njit()
574
+ def _label_size_helper(labelled_array, separated_structure_indices, labelled_with_sizes, variable, x_sizes, y_sizes):
575
+
576
+ for indices in separated_structure_indices:
577
+ perimeter = 0
578
+ area = 0
579
+
580
+ y_coords_structure = np.array([c[0] for c in indices])
581
+ x_coords_structure = np.array([c[1] for c in indices])
582
+ unique_y_coords = []
583
+ unique_x_coords = []
584
+ height = 0
585
+ width = 0
586
+
587
+ for (i,j) in indices:
588
+ # Height, Width
589
+ if i not in unique_y_coords:
590
+ unique_y_coords.append(i)
591
+ mask = (y_coords_structure==i)
592
+ y_sizes_here = []
593
+ for loc,take in enumerate(mask):
594
+ if take: y_sizes_here.append(y_sizes[y_coords_structure[loc],x_coords_structure[loc]])
595
+ y_sizes_here = np.array(y_sizes_here)
596
+ height += np.mean(y_sizes_here)
597
+ if j not in unique_x_coords:
598
+ unique_x_coords.append(j)
599
+ mask = (x_coords_structure==j)
600
+ x_sizes_here = []
601
+ for loc,take in enumerate(mask):
602
+ if take: x_sizes_here.append(x_sizes[y_coords_structure[loc],x_coords_structure[loc]])
603
+ x_sizes_here = np.array(x_sizes_here)
604
+ width += np.mean(x_sizes_here)
605
+
606
+ # Perimeter:
607
+ if i != labelled_array.shape[0]-1 and labelled_array[i+1, j] == 0: perimeter += x_sizes[i,j]
608
+ elif i == labelled_array.shape[0]-1 and labelled_array[0, j] == 0: perimeter += x_sizes[i,j]
609
+
610
+ if i != 0 and labelled_array[i-1, j] == 0: perimeter += x_sizes[i,j]
611
+ elif i == 0 and labelled_array[labelled_array.shape[0]-1, j] == 0: perimeter += x_sizes[i,j]
612
+
613
+ if j != labelled_array.shape[1]-1 and labelled_array[i, j+1] == 0: perimeter += y_sizes[i,j]
614
+ elif j == labelled_array.shape[1]-1 and labelled_array[i, 0] == 0: perimeter += y_sizes[i,j]
615
+
616
+ if j != 0 and labelled_array[i, j-1] == 0: perimeter += y_sizes[i,j]
617
+ elif j == 0 and labelled_array[i, 0] == 0: perimeter += y_sizes[i,j]
618
+
619
+ # Area:
620
+ area += y_sizes[i,j] * x_sizes[i,j]
621
+ for (i,j) in indices:
622
+ if variable == 'perimeter':
623
+ labelled_with_sizes[i,j] = perimeter
624
+ elif variable == 'area':
625
+ labelled_with_sizes[i,j] = area
626
+ elif variable == 'width':
627
+ labelled_with_sizes[i,j] = width
628
+ elif variable == 'height':
629
+ labelled_with_sizes[i,j] = height
630
+
631
+
632
+ return labelled_with_sizes