objscale 1.2.0__tar.gz → 1.3.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: objscale
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Object-based analysis functions for fractal dimensions and size distributions
5
5
  Author-email: Thomas DeWitt <thomas.dewitt@utah.edu>
6
6
  License: MIT
@@ -162,10 +162,11 @@ ind_dim, ind_error = objscale.individual_fractal_dimension(arrays)
162
162
 
163
163
  ### Object Analysis
164
164
 
165
- - `get_structure_areas` - Calculate areas of structures (O(n), fast)
166
- - `get_structure_perimeters` - Calculate perimeters of structures (O(n), fast)
167
- - `get_structure_height_width` - Calculate height and width of structures
168
- - `get_structure_props` - Calculate perimeter, area, width, height of structures (wrapper)
165
+ - `label_structures` - Label connected components (wraps scipy.ndimage.label with NaN handling and periodic boundaries)
166
+ - `get_structure_areas` - Calculate areas of labelled structures (O(n), fast)
167
+ - `get_structure_perimeters` - Calculate perimeters of labelled structures (O(n), fast)
168
+ - `get_structure_height_width` - Calculate height and width of labelled structures
169
+ - `get_structure_props` - Calculate perimeter, area, width, height from a binary array (convenience wrapper)
169
170
  - `get_every_boundary_perimeter` - Perimeters of every boundary including nested holes
170
171
  - `total_perimeter` - Total perimeter of all objects
171
172
  - `total_number` - Count number of structures
@@ -173,7 +174,6 @@ ind_dim, ind_error = objscale.individual_fractal_dimension(arrays)
173
174
  - `remove_structures_touching_border_nan` - Remove border-touching structures
174
175
  - `remove_structure_holes` - Fill holes in structures
175
176
  - `label_size` - Label each structure with its size value
176
- - `label_periodic_boundaries` - Merge labels across periodic boundaries
177
177
  - `clear_border_adjacent` - Clear structures touching array edges
178
178
 
179
179
  ### Utilities
@@ -121,10 +121,11 @@ ind_dim, ind_error = objscale.individual_fractal_dimension(arrays)
121
121
 
122
122
  ### Object Analysis
123
123
 
124
- - `get_structure_areas` - Calculate areas of structures (O(n), fast)
125
- - `get_structure_perimeters` - Calculate perimeters of structures (O(n), fast)
126
- - `get_structure_height_width` - Calculate height and width of structures
127
- - `get_structure_props` - Calculate perimeter, area, width, height of structures (wrapper)
124
+ - `label_structures` - Label connected components (wraps scipy.ndimage.label with NaN handling and periodic boundaries)
125
+ - `get_structure_areas` - Calculate areas of labelled structures (O(n), fast)
126
+ - `get_structure_perimeters` - Calculate perimeters of labelled structures (O(n), fast)
127
+ - `get_structure_height_width` - Calculate height and width of labelled structures
128
+ - `get_structure_props` - Calculate perimeter, area, width, height from a binary array (convenience wrapper)
128
129
  - `get_every_boundary_perimeter` - Perimeters of every boundary including nested holes
129
130
  - `total_perimeter` - Total perimeter of all objects
130
131
  - `total_number` - Count number of structures
@@ -132,7 +133,6 @@ ind_dim, ind_error = objscale.individual_fractal_dimension(arrays)
132
133
  - `remove_structures_touching_border_nan` - Remove border-touching structures
133
134
  - `remove_structure_holes` - Fill holes in structures
134
135
  - `label_size` - Label each structure with its size value
135
- - `label_periodic_boundaries` - Merge labels across periodic boundaries
136
136
  - `clear_border_adjacent` - Clear structures touching array edges
137
137
 
138
138
  ### Utilities
@@ -20,13 +20,13 @@ from ._fractal_dimensions import (
20
20
  )
21
21
 
22
22
  from ._object_analysis import (
23
+ label_structures,
23
24
  get_structure_props,
24
25
  get_structure_areas,
25
26
  get_structure_perimeters,
26
27
  get_structure_height_width,
27
28
  get_every_boundary_perimeter,
28
29
  remove_structures_touching_border_nan,
29
- label_periodic_boundaries,
30
30
  remove_structure_holes,
31
31
  clear_border_adjacent,
32
32
  )
@@ -58,7 +58,7 @@ __all__ = [
58
58
  'get_structure_height_width',
59
59
  'get_every_boundary_perimeter',
60
60
  'remove_structures_touching_border_nan',
61
- 'label_periodic_boundaries',
61
+ 'label_structures',
62
62
  'remove_structure_holes',
63
63
  'clear_border_adjacent',
64
64
  'linear_regression',
@@ -66,7 +66,7 @@ __all__ = [
66
66
  'set_num_threads',
67
67
  ]
68
68
 
69
- __version__ = "1.2.0"
69
+ __version__ = "1.3.0"
70
70
  __author__ = "Thomas DeWitt"
71
71
  __email__ = "thomas.dewitt@utah.edu"
72
72
  __description__ = "Object-based analysis functions for fractal dimensions and size distributions"
@@ -7,11 +7,12 @@ from numba.typed import List
7
7
  from scipy.ndimage import label
8
8
  from warnings import warn
9
9
  from ._object_analysis import (
10
+ label_structures,
11
+ _merge_periodic_labels,
10
12
  remove_structures_touching_border_nan,
11
13
  remove_structure_holes,
12
14
  get_structure_areas,
13
15
  get_structure_perimeters,
14
- label_periodic_boundaries,
15
16
  )
16
17
  from ._utils import linear_regression, encase_in_value
17
18
 
@@ -233,6 +234,7 @@ def individual_correlation_dimension(
233
234
  return_C_l: bool = False,
234
235
  point_reduction_factor: float = 1,
235
236
  nbins: int = 50,
237
+ filled: bool = True,
236
238
  ) -> tuple[float, float] | tuple[float, float, NDArray, NDArray]:
237
239
  """
238
240
  Calculate the correlation dimension of the Nth largest structure in an array.
@@ -262,6 +264,10 @@ def individual_correlation_dimension(
262
264
  return_C_l : bool, default=False
263
265
  If True, return dimension, error, bins, C_l. Otherwise, return dimension,
264
266
  error.
267
+ filled : bool, default=True
268
+ If True, fill interior holes in the isolated structure before computing
269
+ the correlation dimension, so that only the outer boundary contributes.
270
+ If False, holes are left as-is and interior boundaries are included.
265
271
  point_reduction_factor : float, default=1
266
272
  Draw N/point_reduction_factor circles, where N is the total number of
267
273
  available circles. Must be >= 1.
@@ -306,6 +312,10 @@ def individual_correlation_dimension(
306
312
  # Isolate the Nth largest structure
307
313
  isolated = isolate_nth_largest_structure(binary, n=n)
308
314
 
315
+ # Optionally fill interior holes so only the outer boundary contributes
316
+ if filled:
317
+ isolated = remove_structure_holes(isolated.astype(float)).astype(bool)
318
+
309
319
  # Crop to bounding box
310
320
  rows = np.any(isolated, axis=1)
311
321
  cols = np.any(isolated, axis=0)
@@ -582,7 +592,8 @@ def individual_fractal_dimension(
582
592
  min_a: float = 10,
583
593
  max_a: float = np.inf,
584
594
  bins: int | None = 30,
585
- return_values: bool = False
595
+ return_values: bool = False,
596
+ filled: bool = True,
586
597
  ) -> tuple[float, float] | tuple[float, float, NDArray, NDArray]:
587
598
  """
588
599
  Calculate the individual fractal dimension Df of objects within arrays.
@@ -613,6 +624,10 @@ def individual_fractal_dimension(
613
624
  points without binning.
614
625
  return_values : bool, default=False
615
626
  If True, return additional data used in the calculation.
627
+ filled : bool, default=True
628
+ If True, fill interior holes in structures before computing areas and
629
+ perimeters. If False, holes are left as-is, so perimeters include
630
+ interior boundaries and areas exclude hole pixels.
616
631
 
617
632
  Returns
618
633
  -------
@@ -656,14 +671,21 @@ def individual_fractal_dimension(
656
671
  raise ValueError('Each array shape must match corresponding pixel sizes shape')
657
672
 
658
673
  array = remove_structures_touching_border_nan(array)
659
- array = remove_structure_holes(array)
660
- new_a = get_structure_areas(array, xs, ys)
661
- new_p = get_structure_perimeters(array, xs, ys)
662
- areas.extend(new_a)
663
- perimeters.extend(new_p)
674
+ if filled:
675
+ array = remove_structure_holes(array)
676
+ lab, nm, nl = label_structures(array, wrap='both')
677
+ if lab is None:
678
+ continue
679
+ new_a = get_structure_areas(lab, nm, nl, xs, ys)
680
+ new_p = get_structure_perimeters(lab, nm, nl, xs, ys)
681
+ # Filter out labels with zero area or zero perimeter (e.g. NaN-surrounded)
682
+ valid = (new_a > 0) & (new_p > 0)
683
+ areas.extend(new_a[valid])
684
+ perimeters.extend(new_p[valid])
664
685
 
665
686
  areas, perimeters = np.array(areas), np.array(perimeters)
666
- areas, perimeters = areas[(areas > min_a) & (areas < max_a)], perimeters[(areas > min_a) & (areas < max_a)]
687
+ mask = (areas > min_a) & (areas < max_a)
688
+ areas, perimeters = areas[mask], perimeters[mask]
667
689
 
668
690
  log_sqrt_a = np.log10(np.sqrt(areas))
669
691
  log_p = np.log10(perimeters)
@@ -1088,7 +1110,7 @@ def label_size(
1088
1110
  if wrap is None:
1089
1111
  pass
1090
1112
  elif wrap == 'both' or wrap == 'sides':
1091
- labelled_array = label_periodic_boundaries(labelled_array, wrap)
1113
+ labelled_array = _merge_periodic_labels(labelled_array, wrap)
1092
1114
  else:
1093
1115
  raise ValueError(f'wrap={wrap} not supported')
1094
1116
 
@@ -10,29 +10,58 @@ from skimage.segmentation import clear_border
10
10
  from ._utils import encase_in_value
11
11
 
12
12
  __all__ = [
13
+ 'label_structures',
13
14
  'get_structure_props',
14
15
  'get_structure_areas',
15
16
  'get_structure_perimeters',
16
17
  'get_structure_height_width',
17
- 'label_periodic_boundaries',
18
18
  'get_every_boundary_perimeter',
19
19
  'remove_structures_touching_border_nan',
20
20
  'clear_border_adjacent',
21
21
  'remove_structure_holes',
22
22
  ]
23
23
 
24
+ DEFAULT_STRUCTURE = np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]])
25
+
24
26
 
25
27
  # =============================================================================
26
- # Shared labeling helper
28
+ # Labeling
27
29
  # =============================================================================
28
30
 
29
- def _label_array(array, structure, wrap):
30
- """Label connected structures, handle nans and periodic wrapping.
31
+ def label_structures(
32
+ array: NDArray,
33
+ structure: NDArray = DEFAULT_STRUCTURE,
34
+ wrap: str | None = 'both',
35
+ ) -> tuple[NDArray | None, NDArray | None, int]:
36
+ """
37
+ Label connected components in a binary array.
38
+
39
+ Wrapper on ``scipy.ndimage.label`` with NaN handling and optional
40
+ periodic boundary merging.
41
+
42
+ Parameters
43
+ ----------
44
+ array : np.ndarray
45
+ 2-D binary array (0s, 1s, and optionally NaN).
46
+ structure : np.ndarray, default=4-connectivity cross
47
+ Connectivity kernel passed to ``scipy.ndimage.label``.
48
+ wrap : str or None, default='both'
49
+ Periodic boundary handling:
50
+ - ``'both'``: merge labels across left-right and top-bottom edges.
51
+ - ``'sides'``: merge labels across left-right edges only.
52
+ - ``None``: no periodic merging.
31
53
 
32
- Returns (labelled_array, nan_mask, n_labels).
33
- labelled_array has 0 where input had nan (safe for integer indexing).
34
- nan_mask is a boolean array indicating where nans were in the input.
35
- Returns (None, None, 0) if no structures.
54
+ Returns
55
+ -------
56
+ labelled_array : np.ndarray or None
57
+ Float32 array where each unique positive value is a connected
58
+ component label. Pixels that were NaN in the input are 0.
59
+ ``None`` if no structures exist.
60
+ nan_mask : np.ndarray or None
61
+ Boolean array indicating NaN locations in the input.
62
+ ``None`` if no structures exist.
63
+ n_labels : int
64
+ Number of connected components found (0 if none).
36
65
  """
37
66
  nan_mask = np.isnan(array)
38
67
  no_nans = array.copy()
@@ -41,18 +70,31 @@ def _label_array(array, structure, wrap):
41
70
  return None, None, 0
42
71
  labelled_array, n_labels = label(no_nans.astype(bool), structure, output=np.float32)
43
72
 
44
- if wrap is None:
45
- pass
46
- elif wrap == 'both' or wrap == 'sides':
47
- labelled_array = label_periodic_boundaries(labelled_array, wrap)
48
- else:
49
- raise ValueError(f'wrap={wrap} not supported')
73
+ if wrap == 'both' or wrap == 'sides':
74
+ labelled_array = _merge_periodic_labels(labelled_array, wrap)
75
+ elif wrap is not None:
76
+ raise ValueError(f'wrap={wrap!r} not supported')
50
77
 
51
- # NaN pixels get label 0 (already the case from no_nans). Do NOT restore
52
- # nans into the labeled array — callers use nan_mask where needed.
53
78
  return labelled_array, nan_mask, n_labels
54
79
 
55
80
 
81
+ def _merge_periodic_labels(labelled_array: NDArray, wrap: str) -> NDArray:
82
+ """Merge labels that span periodic boundaries (internal helper)."""
83
+ if wrap == 'sides' or wrap == 'both':
84
+ for j, value in enumerate(labelled_array[:, 0]):
85
+ if value != 0:
86
+ if labelled_array[j, labelled_array.shape[1] - 1] != 0 and labelled_array[j, labelled_array.shape[1] - 1] != value:
87
+ labelled_array[labelled_array == labelled_array[j, labelled_array.shape[1] - 1]] = value
88
+
89
+ if wrap == 'both':
90
+ for i, value in enumerate(labelled_array[0, :]):
91
+ if value != 0:
92
+ if labelled_array[labelled_array.shape[0] - 1, i] != 0 and labelled_array[labelled_array.shape[0] - 1, i] != value:
93
+ labelled_array[labelled_array == labelled_array[labelled_array.shape[0] - 1, i]] = value
94
+
95
+ return labelled_array
96
+
97
+
56
98
  def _validate_inputs(array, x_sizes, y_sizes):
57
99
  """Validate that array, x_sizes, y_sizes have matching shapes and no bad nans."""
58
100
  if array.shape != x_sizes.shape or array.shape != y_sizes.shape:
@@ -64,47 +106,59 @@ def _validate_inputs(array, x_sizes, y_sizes):
64
106
  raise ValueError('x or y sizes are nan in locations where array is not')
65
107
 
66
108
 
109
+ def _validate_labelled(labelled_array):
110
+ """Raise TypeError if the array looks binary instead of labelled."""
111
+ if labelled_array.dtype == bool:
112
+ raise TypeError(
113
+ 'labelled_array is boolean. '
114
+ 'Pass a labelled array from label_structures() instead.'
115
+ )
116
+
117
+
67
118
  # =============================================================================
68
119
  # Area: pure numpy bincount — O(n)
69
120
  # =============================================================================
70
121
 
71
122
  def get_structure_areas(
72
- array: NDArray,
123
+ labelled_array: NDArray,
124
+ nan_mask: NDArray,
125
+ n_labels: int,
73
126
  x_sizes: NDArray,
74
127
  y_sizes: NDArray,
75
- structure: NDArray = np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]]),
76
128
  ) -> NDArray:
77
129
  """
78
- Calculate areas of structures in a binary array.
79
-
80
- Assumes toroidal (periodic) boundary conditions. For non-periodic domains,
81
- pad edges with 0 or np.nan before calling.
130
+ Calculate areas of labelled structures.
82
131
 
83
132
  Parameters
84
133
  ----------
85
- array : np.ndarray
86
- Binary array of structures: 2-d array, padded with 0's or np.nan's.
134
+ labelled_array : np.ndarray
135
+ Labelled array from :func:`label_structures`.
136
+ nan_mask : np.ndarray
137
+ Boolean NaN mask from :func:`label_structures`.
138
+ n_labels : int
139
+ Number of labels from :func:`label_structures`.
87
140
  x_sizes : np.ndarray
88
- Sizes of pixels in horizontal direction, same shape as array.
141
+ Pixel sizes in horizontal direction, same shape as labelled_array.
89
142
  y_sizes : np.ndarray
90
- Sizes of pixels in vertical direction, same shape as array.
91
- structure : np.ndarray, default=np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]])
92
- Defines connectivity.
143
+ Pixel sizes in vertical direction, same shape as labelled_array.
93
144
 
94
145
  Returns
95
146
  -------
96
147
  areas : np.ndarray
97
- 1-D array, each element the area of an individual structure.
148
+ 1-D array of shape ``(n_labels,)`` where ``areas[i]`` is the area of
149
+ label ``i + 1``. Guarantees index alignment with other
150
+ ``get_structure_*`` functions called on the same labelled array.
98
151
  """
99
- _validate_inputs(array, x_sizes, y_sizes)
100
- labelled_array, nan_mask, n_labels = _label_array(array, structure, 'both')
101
- if labelled_array is None:
102
- return np.array([], dtype=np.float32)
152
+ _validate_labelled(labelled_array)
103
153
  return _compute_areas(labelled_array, x_sizes, y_sizes, n_labels)
104
154
 
105
155
 
106
156
  def _compute_areas(labelled_array, x_sizes, y_sizes, n_labels):
107
- """Compute per-label areas via np.bincount."""
157
+ """Compute per-label areas via np.bincount.
158
+
159
+ Returns array of shape (n_labels,) — index i is label i+1.
160
+ Merged labels from periodic wrapping may have area 0.
161
+ """
108
162
  pixel_areas = (x_sizes * y_sizes).ravel()
109
163
  labels_flat = labelled_array.ravel()
110
164
  mask = labels_flat > 0
@@ -113,10 +167,7 @@ def _compute_areas(labelled_array, x_sizes, y_sizes, n_labels):
113
167
  weights=pixel_areas[mask],
114
168
  minlength=n_labels + 1,
115
169
  )
116
- # Only return areas for labels that exist (skip label 0)
117
- areas = areas[1:].astype(np.float32)
118
- # Filter out labels with zero area (can happen with periodic wrapping merging labels)
119
- return areas[areas > 0]
170
+ return areas[1:].astype(np.float32)
120
171
 
121
172
 
122
173
  # =============================================================================
@@ -124,41 +175,39 @@ def _compute_areas(labelled_array, x_sizes, y_sizes, n_labels):
124
175
  # =============================================================================
125
176
 
126
177
  def get_structure_perimeters(
127
- array: NDArray,
178
+ labelled_array: NDArray,
179
+ nan_mask: NDArray,
180
+ n_labels: int,
128
181
  x_sizes: NDArray,
129
182
  y_sizes: NDArray,
130
- structure: NDArray = np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]]),
131
183
  ) -> NDArray:
132
184
  """
133
- Calculate perimeters of structures in a binary array.
185
+ Calculate perimeters of labelled structures.
134
186
 
135
- Assumes toroidal (periodic) boundary conditions. Perimeter between a
136
- structure and nan is not counted. For non-periodic domains, pad edges
137
- with 0 or np.nan before calling.
187
+ Perimeter between a structure and NaN is not counted.
138
188
 
139
189
  Parameters
140
190
  ----------
141
- array : np.ndarray
142
- Binary array of structures: 2-d array, padded with 0's or np.nan's.
191
+ labelled_array : np.ndarray
192
+ Labelled array from :func:`label_structures`.
193
+ nan_mask : np.ndarray
194
+ Boolean NaN mask from :func:`label_structures`.
195
+ n_labels : int
196
+ Number of labels from :func:`label_structures`.
143
197
  x_sizes : np.ndarray
144
- Sizes of pixels in horizontal direction, same shape as array.
198
+ Pixel sizes in horizontal direction, same shape as labelled_array.
145
199
  y_sizes : np.ndarray
146
- Sizes of pixels in vertical direction, same shape as array.
147
- structure : np.ndarray, default=np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]])
148
- Defines connectivity.
200
+ Pixel sizes in vertical direction, same shape as labelled_array.
149
201
 
150
202
  Returns
151
203
  -------
152
204
  perimeters : np.ndarray
153
- 1-D array, each element the perimeter of an individual structure.
205
+ 1-D array of shape ``(n_labels,)`` where ``perimeters[i]`` is the
206
+ perimeter of label ``i + 1``. Guarantees index alignment with other
207
+ ``get_structure_*`` functions called on the same labelled array.
154
208
  """
155
- _validate_inputs(array, x_sizes, y_sizes)
156
- labelled_array, nan_mask, n_labels = _label_array(array, structure, 'both')
157
- if labelled_array is None:
158
- return np.array([], dtype=np.float32)
159
- # Pass nan_mask so perimeter along nan edges is not counted
160
- perimeters = _compute_perimeters(labelled_array, nan_mask, x_sizes, y_sizes, n_labels)
161
- return perimeters[perimeters > 0]
209
+ _validate_labelled(labelled_array)
210
+ return _compute_perimeters(labelled_array, nan_mask, x_sizes, y_sizes, n_labels)
162
211
 
163
212
 
164
213
  @njit(parallel=True)
@@ -225,34 +274,36 @@ def _compute_perimeters(labelled_array, nan_mask, x_sizes, y_sizes, n_labels):
225
274
  # =============================================================================
226
275
 
227
276
  def get_structure_height_width(
228
- array: NDArray,
277
+ labelled_array: NDArray,
278
+ nan_mask: NDArray,
279
+ n_labels: int,
229
280
  x_sizes: NDArray,
230
281
  y_sizes: NDArray,
231
- structure: NDArray = np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]]),
232
282
  ) -> tuple[NDArray, NDArray]:
233
283
  """
234
- Calculate heights and widths of structures in a binary array.
235
-
236
- Assumes toroidal (periodic) boundary conditions. For non-periodic domains,
237
- pad edges with 0 or np.nan before calling.
284
+ Calculate heights and widths of labelled structures.
238
285
 
239
286
  Parameters
240
287
  ----------
241
- array : np.ndarray
242
- Binary array of structures: 2-d array, padded with 0's or np.nan's.
288
+ labelled_array : np.ndarray
289
+ Labelled array from :func:`label_structures`.
290
+ nan_mask : np.ndarray
291
+ Boolean NaN mask from :func:`label_structures`.
292
+ n_labels : int
293
+ Number of labels from :func:`label_structures`.
243
294
  x_sizes : np.ndarray
244
- Sizes of pixels in horizontal direction, same shape as array.
295
+ Pixel sizes in horizontal direction, same shape as labelled_array.
245
296
  y_sizes : np.ndarray
246
- Sizes of pixels in vertical direction, same shape as array.
247
- structure : np.ndarray, default=np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]])
248
- Defines connectivity.
297
+ Pixel sizes in vertical direction, same shape as labelled_array.
249
298
 
250
299
  Returns
251
300
  -------
252
301
  heights : np.ndarray
253
- 1-D array, each element the height of an individual structure.
302
+ 1-D array of shape ``(n_labels,)`` where ``heights[i]`` is the height
303
+ of label ``i + 1``.
254
304
  widths : np.ndarray
255
- 1-D array, each element the width of an individual structure.
305
+ 1-D array of shape ``(n_labels,)`` where ``widths[i]`` is the width
306
+ of label ``i + 1``.
256
307
 
257
308
  Notes
258
309
  -----
@@ -260,23 +311,29 @@ def get_structure_height_width(
260
311
  pixel widths of the pixels in the column and in the object. Similarly, the height
261
312
  will be the sum of the average pixel heights of the pixels in the row and in the object.
262
313
  """
263
- _validate_inputs(array, x_sizes, y_sizes)
264
- labelled_array, nan_mask, n_labels = _label_array(array, structure, 'both')
265
- if labelled_array is None:
266
- return np.array([], dtype=np.float32), np.array([], dtype=np.float32)
314
+ _validate_labelled(labelled_array)
267
315
 
268
- # Index separation needed for per-structure iteration.
269
- # labelled_array has 0 where nans were (not nan), so separation is safe.
270
- separated = _get_separated_structure_indices(labelled_array)
316
+ separated, label_ids = _get_separated_structure_indices(labelled_array)
271
317
  if len(separated) == 0:
272
- return np.array([], dtype=np.float32), np.array([], dtype=np.float32)
318
+ return np.zeros(n_labels, dtype=np.float32), np.zeros(n_labels, dtype=np.float32)
319
+
320
+ h_sparse, w_sparse = _compute_height_width(labelled_array, List(separated), x_sizes, y_sizes)
273
321
 
274
- h, w = _compute_height_width(labelled_array, List(separated), x_sizes, y_sizes)
275
- return np.array(h, dtype=np.float32), np.array(w, dtype=np.float32)
322
+ # Map sparse results back to label-indexed arrays
323
+ heights = np.zeros(n_labels, dtype=np.float32)
324
+ widths = np.zeros(n_labels, dtype=np.float32)
325
+ for k, lab in enumerate(label_ids):
326
+ heights[lab - 1] = h_sparse[k]
327
+ widths[lab - 1] = w_sparse[k]
328
+ return heights, widths
276
329
 
277
330
 
278
331
  def _get_separated_structure_indices(labelled_array):
279
- """Get list of 2D index arrays, one per structure label."""
332
+ """Get list of 2D index arrays, one per structure label.
333
+
334
+ Returns (separated, label_ids) where label_ids[k] is the label value
335
+ for separated[k].
336
+ """
280
337
  values = np.sort(labelled_array.flatten())
281
338
  original_locations = np.argsort(labelled_array.flatten())
282
339
  indices_2d = np.array(np.unravel_index(original_locations, labelled_array.shape)).T
@@ -284,9 +341,14 @@ def _get_separated_structure_indices(labelled_array):
284
341
  split_here = np.roll(values, shift=-1) - values
285
342
  split_here[-1] = 0
286
343
 
287
- separated = np.split(indices_2d, np.where(split_here != 0)[0] + 1)
344
+ split_points = np.where(split_here != 0)[0] + 1
345
+ separated = np.split(indices_2d, split_points)
288
346
  separated = separated[1:] # Remove label 0
289
- return separated
347
+
348
+ # Extract the label id for each group
349
+ unique_labels = np.unique(values)
350
+ label_ids = unique_labels[unique_labels > 0].astype(int)
351
+ return separated, label_ids
290
352
 
291
353
 
292
354
  @njit(parallel=True)
@@ -330,9 +392,7 @@ def _compute_height_width(labelled_array, separated_structure_indices, x_sizes,
330
392
  h[iteration] = height
331
393
  w[iteration] = width
332
394
 
333
- # Filter out zero-area structures (shouldn't happen, but be safe)
334
- valid = (h > 0) | (w > 0)
335
- return h[valid], w[valid]
395
+ return h, w
336
396
 
337
397
 
338
398
  # =============================================================================
@@ -394,66 +454,21 @@ def get_structure_props(
394
454
  get_structure_height_width.
395
455
  """
396
456
  _validate_inputs(array, x_sizes, y_sizes)
397
- labelled_array, nan_mask, n_labels = _label_array(array, structure, 'both')
457
+ labelled_array, nan_mask, n_labels = label_structures(array, structure, wrap='both')
398
458
  if labelled_array is None:
399
459
  if print_none:
400
460
  print('No structures found')
401
461
  return np.array([]), np.array([]), np.array([]), np.array([])
402
462
 
403
- a = _compute_areas(labelled_array, x_sizes, y_sizes, n_labels)
404
- p_all = _compute_perimeters(labelled_array, nan_mask, x_sizes, y_sizes, n_labels)
405
- # Filter perimeters to match areas (both skip zero-area labels from merged wrapping)
406
- p = p_all[p_all > 0]
407
-
408
- separated = _get_separated_structure_indices(labelled_array)
409
- if len(separated) == 0:
410
- return np.array([]), np.array([]), np.array([]), np.array([])
411
- h, w = _compute_height_width(labelled_array, List(separated), x_sizes, y_sizes)
412
-
413
- return p, a, np.array(h, dtype=np.float32), np.array(w, dtype=np.float32)
463
+ a = get_structure_areas(labelled_array, nan_mask, n_labels, x_sizes, y_sizes)
464
+ p = get_structure_perimeters(labelled_array, nan_mask, n_labels, x_sizes, y_sizes)
465
+ h, w = get_structure_height_width(labelled_array, nan_mask, n_labels, x_sizes, y_sizes)
414
466
 
467
+ # Filter out labels with zero area (from periodic wrapping merging labels)
468
+ valid = a > 0
469
+ return p[valid], a[valid], h[valid], w[valid]
415
470
 
416
- def label_periodic_boundaries(labelled_array: NDArray, wrap: str) -> NDArray:
417
- """
418
- Make labelled structures that span a periodic boundary have the same label.
419
-
420
- Parameters
421
- ----------
422
- labelled_array : np.ndarray
423
- A 2D array where each unique non-zero element represents a distinct label.
424
- Should be the output of scipy.ndimage.label().
425
- wrap : str
426
- Determines how the boundaries of the array should be wrapped.
427
- 'sides': Sets labels on the right boundary to match those on the left.
428
- 'both': Also sets labels on the top boundary to match those on the bottom.
429
-
430
- Returns
431
- -------
432
- np.ndarray
433
- The input array with periodic boundaries labelled according to the wrap parameter.
434
-
435
- Raises
436
- ------
437
- ValueError
438
- If wrap is neither 'sides' nor 'both'.
439
- """
440
- if wrap == 'sides' or wrap == 'both':
441
- # set those on right to the same i.d. as those on left
442
- for j, value in enumerate(labelled_array[:, 0]):
443
- if value != 0:
444
- if labelled_array[j, labelled_array.shape[1] - 1] != 0 and labelled_array[j, labelled_array.shape[1] - 1] != value:
445
- labelled_array[labelled_array == labelled_array[j, labelled_array.shape[1] - 1]] = value
446
471
 
447
- if wrap == 'both':
448
- # set those on top to the same i.d. as those on bottom
449
- for i, value in enumerate(labelled_array[0, :]):
450
- if value != 0:
451
- if labelled_array[labelled_array.shape[0] - 1, i] != 0 and labelled_array[labelled_array.shape[0] - 1, i] != value:
452
- labelled_array[labelled_array == labelled_array[labelled_array.shape[0] - 1, i]] = value
453
-
454
- if wrap not in ['sides', 'both']:
455
- raise ValueError(f'wrap = {wrap} not supported')
456
- return labelled_array
457
472
 
458
473
 
459
474
  def get_every_boundary_perimeter(
@@ -499,8 +514,13 @@ def get_every_boundary_perimeter(
499
514
  if counter > 100:
500
515
  raise ValueError('Hole layer limit reached: 100 layers')
501
516
  all_holes_filled = remove_structure_holes(array)
502
- exterior_perimeters = get_structure_perimeters(encase_in_value(all_holes_filled), encase_in_value(x_sizes), encase_in_value(y_sizes))
503
- perimeters.extend(exterior_perimeters)
517
+ encased = encase_in_value(all_holes_filled)
518
+ enc_xs = encase_in_value(x_sizes)
519
+ enc_ys = encase_in_value(y_sizes)
520
+ lab, nm, nl = label_structures(encased, wrap='both')
521
+ if lab is not None:
522
+ p = get_structure_perimeters(lab, nm, nl, enc_xs, enc_ys)
523
+ perimeters.extend(p[p > 0])
504
524
 
505
525
  # remove one layer
506
526
  new_array = all_holes_filled - array
@@ -625,7 +645,7 @@ def remove_structure_holes(
625
645
  # invert and label
626
646
  labelled, _ = label((1 - filled))
627
647
  if periodic is not False:
628
- labelled = label_periodic_boundaries(labelled, periodic)
648
+ labelled = _merge_periodic_labels(labelled, periodic)
629
649
  # largest structure will be the background or the cloudy areas.
630
650
  unique_values, unique_counts = np.unique(labelled.flatten(), return_counts=True)
631
651
  # Make sure we don't identify the cloudy areas as the background.
@@ -8,6 +8,7 @@ import numpy as np
8
8
  from numpy.typing import NDArray
9
9
  from warnings import warn
10
10
  from ._object_analysis import (
11
+ label_structures,
11
12
  remove_structures_touching_border_nan,
12
13
  get_structure_areas,
13
14
  get_structure_perimeters,
@@ -385,13 +386,20 @@ def array_size_distribution(
385
386
  np.full((1, y_sizes.shape[1]), np.nan)], axis=0)
386
387
  # wrap='both': fully periodic, no padding needed
387
388
 
388
- if variable == 'area':
389
- to_bin = get_structure_areas(array, x_sizes, y_sizes, structure)
389
+ lab, nm, nl = label_structures(array, structure, wrap='both')
390
+ if lab is None:
391
+ to_bin = np.array([], dtype=np.float32)
392
+ elif variable == 'area':
393
+ a = get_structure_areas(lab, nm, nl, x_sizes, y_sizes)
394
+ to_bin = a[a > 0]
390
395
  elif variable == 'perimeter':
391
- to_bin = get_structure_perimeters(array, x_sizes, y_sizes, structure)
396
+ p = get_structure_perimeters(lab, nm, nl, x_sizes, y_sizes)
397
+ to_bin = p[p > 0]
392
398
  elif variable in ('height', 'width'):
393
- h, w = get_structure_height_width(array, x_sizes, y_sizes, structure)
394
- to_bin = h if variable == 'height' else w
399
+ h, w = get_structure_height_width(lab, nm, nl, x_sizes, y_sizes)
400
+ a = get_structure_areas(lab, nm, nl, x_sizes, y_sizes)
401
+ valid = a > 0
402
+ to_bin = h[valid] if variable == 'height' else w[valid]
395
403
  elif variable == 'nested perimeter':
396
404
  to_bin = get_every_boundary_perimeter(array, x_sizes, y_sizes, False)
397
405
  else:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: objscale
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Object-based analysis functions for fractal dimensions and size distributions
5
5
  Author-email: Thomas DeWitt <thomas.dewitt@utah.edu>
6
6
  License: MIT
@@ -162,10 +162,11 @@ ind_dim, ind_error = objscale.individual_fractal_dimension(arrays)
162
162
 
163
163
  ### Object Analysis
164
164
 
165
- - `get_structure_areas` - Calculate areas of structures (O(n), fast)
166
- - `get_structure_perimeters` - Calculate perimeters of structures (O(n), fast)
167
- - `get_structure_height_width` - Calculate height and width of structures
168
- - `get_structure_props` - Calculate perimeter, area, width, height of structures (wrapper)
165
+ - `label_structures` - Label connected components (wraps scipy.ndimage.label with NaN handling and periodic boundaries)
166
+ - `get_structure_areas` - Calculate areas of labelled structures (O(n), fast)
167
+ - `get_structure_perimeters` - Calculate perimeters of labelled structures (O(n), fast)
168
+ - `get_structure_height_width` - Calculate height and width of labelled structures
169
+ - `get_structure_props` - Calculate perimeter, area, width, height from a binary array (convenience wrapper)
169
170
  - `get_every_boundary_perimeter` - Perimeters of every boundary including nested holes
170
171
  - `total_perimeter` - Total perimeter of all objects
171
172
  - `total_number` - Count number of structures
@@ -173,7 +174,6 @@ ind_dim, ind_error = objscale.individual_fractal_dimension(arrays)
173
174
  - `remove_structures_touching_border_nan` - Remove border-touching structures
174
175
  - `remove_structure_holes` - Fill holes in structures
175
176
  - `label_size` - Label each structure with its size value
176
- - `label_periodic_boundaries` - Merge labels across periodic boundaries
177
177
  - `clear_border_adjacent` - Clear structures touching array edges
178
178
 
179
179
  ### Utilities
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "objscale"
7
- version = "1.2.0"
7
+ version = "1.3.0"
8
8
  description = "Object-based analysis functions for fractal dimensions and size distributions"
9
9
  readme = "README.md"
10
10
  authors = [
File without changes
File without changes
File without changes