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 +56 -0
- objscale/_fractal_dimensions.py +632 -0
- objscale/_object_analysis.py +285 -0
- objscale/_size_distributions.py +249 -0
- objscale/_utils.py +40 -0
- objscale-0.1.0.dist-info/METADATA +142 -0
- objscale-0.1.0.dist-info/RECORD +10 -0
- objscale-0.1.0.dist-info/WHEEL +5 -0
- objscale-0.1.0.dist-info/licenses/LICENSE +9 -0
- objscale-0.1.0.dist-info/top_level.txt +1 -0
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
|