marEx 2.0.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.
- marEx/__init__.py +78 -0
- marEx/detect.py +490 -0
- marEx/helper.py +251 -0
- marEx/plotX/__init__.py +83 -0
- marEx/plotX/base.py +387 -0
- marEx/plotX/gridded.py +55 -0
- marEx/plotX/unstructured.py +147 -0
- marEx/track.py +3646 -0
- marex-2.0.0.dist-info/METADATA +247 -0
- marex-2.0.0.dist-info/RECORD +12 -0
- marex-2.0.0.dist-info/WHEEL +5 -0
- marex-2.0.0.dist-info/top_level.txt +1 -0
marEx/track.py
ADDED
|
@@ -0,0 +1,3646 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MarEx: Marine Extreme Event Identification, Tracking, and Splitting/Merging Module
|
|
3
|
+
|
|
4
|
+
MarEx identifies and tracks extreme events in oceanographic data across time,
|
|
5
|
+
supporting both structured (regular grid) and unstructured datasets. It can identify
|
|
6
|
+
discrete objects at single time points and track them as evolving events through time,
|
|
7
|
+
seamlessly handling splitting and merging.
|
|
8
|
+
|
|
9
|
+
This package provides algorithms to:
|
|
10
|
+
- Identify binary objects in spatial data at each time step
|
|
11
|
+
- Track these objects across time to form coherent events
|
|
12
|
+
- Handle merging and splitting of objects over time
|
|
13
|
+
- Calculate and maintain object/event properties through time
|
|
14
|
+
- Filter by size criteria to focus on significant events
|
|
15
|
+
|
|
16
|
+
Key terminology:
|
|
17
|
+
- Object: A connected region in binary data at a single time point
|
|
18
|
+
- Event: One or more objects tracked through time and identified as the same entity
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import xarray as xr
|
|
22
|
+
import numpy as np
|
|
23
|
+
from dask.distributed import wait
|
|
24
|
+
from dask_image.ndmeasure import label
|
|
25
|
+
from skimage.measure import regionprops_table
|
|
26
|
+
from dask_image.ndmorph import binary_closing as binary_closing_dask
|
|
27
|
+
from dask_image.ndmorph import binary_opening as binary_opening_dask
|
|
28
|
+
from scipy.ndimage import binary_closing, binary_opening
|
|
29
|
+
from scipy.sparse import coo_matrix, csr_matrix, eye
|
|
30
|
+
from scipy.sparse.csgraph import connected_components
|
|
31
|
+
from dask import persist
|
|
32
|
+
import dask.array as dsa
|
|
33
|
+
from dask.base import is_dask_collection
|
|
34
|
+
from numba import jit, njit, prange
|
|
35
|
+
import warnings
|
|
36
|
+
import logging
|
|
37
|
+
import os
|
|
38
|
+
import shutil
|
|
39
|
+
import gc
|
|
40
|
+
import time
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
import jax.numpy as jnp
|
|
44
|
+
HAS_JAX = True
|
|
45
|
+
except ImportError:
|
|
46
|
+
import numpy as np
|
|
47
|
+
jnp = np # Alias for jnp
|
|
48
|
+
HAS_JAX = False
|
|
49
|
+
|
|
50
|
+
warnings.warn(
|
|
51
|
+
"JAX not installed. Some functionality will be slower. "
|
|
52
|
+
"For best performance, install with: pip install marEx[jax]",
|
|
53
|
+
ImportWarning,
|
|
54
|
+
stacklevel=2
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ============================
|
|
59
|
+
# Main Tracker Class
|
|
60
|
+
# ============================
|
|
61
|
+
|
|
62
|
+
class tracker:
|
|
63
|
+
"""
|
|
64
|
+
Tracker identifies and tracks arbitrary binary objects in spatial data through time.
|
|
65
|
+
|
|
66
|
+
The tracker supports both structured (regular grid) and unstructured data,
|
|
67
|
+
and seamlessly handles splitting & merging of objects. It identifies
|
|
68
|
+
connected regions in binary data at each time step, and tracks these as
|
|
69
|
+
evolving events through time.
|
|
70
|
+
|
|
71
|
+
Main workflow:
|
|
72
|
+
1. Preprocessing: Fill spatiotemporal holes, filter small objects
|
|
73
|
+
2. Object identification: Label connected components at each time
|
|
74
|
+
3. Tracking: Determine object correspondences across time
|
|
75
|
+
4. Optional splitting & merging: Handle complex event evolution
|
|
76
|
+
|
|
77
|
+
Parameters
|
|
78
|
+
----------
|
|
79
|
+
data_bin : xarray.DataArray
|
|
80
|
+
Binary data to identify and track objects in (True = object, False = background)
|
|
81
|
+
mask : xarray.DataArray
|
|
82
|
+
Binary mask indicating valid regions (True = valid, False = invalid)
|
|
83
|
+
R_fill : int
|
|
84
|
+
Radius for filling holes/gaps in spatial domain (in grid cells)
|
|
85
|
+
area_filter_quartile : float
|
|
86
|
+
Quantile (0-1) for filtering smallest objects (e.g., 0.25 removes smallest 25%)
|
|
87
|
+
temp_dir : str, optional
|
|
88
|
+
Path to temporary directory for storing intermediate results
|
|
89
|
+
T_fill : int, default=2
|
|
90
|
+
Number of timesteps for filling temporal gaps (must be even)
|
|
91
|
+
allow_merging : bool, default=True
|
|
92
|
+
Allow objects to split and merge across time
|
|
93
|
+
nn_partitioning : bool, default=False
|
|
94
|
+
Use nearest-neighbor partitioning for merging events
|
|
95
|
+
overlap_threshold : float, default=0.5
|
|
96
|
+
Minimum fraction of overlap between objects to consider them the same
|
|
97
|
+
unstructured_grid : bool, default=False
|
|
98
|
+
Whether data is on an unstructured grid
|
|
99
|
+
timedim : str, default='time'
|
|
100
|
+
Name of time dimension
|
|
101
|
+
xdim : str, default='lon'
|
|
102
|
+
Name of x/longitude dimension
|
|
103
|
+
ydim : str, default='lat'
|
|
104
|
+
Name of y/latitude dimension
|
|
105
|
+
neighbours : xarray.DataArray, optional
|
|
106
|
+
For unstructured grid, indicates connectivity between cells
|
|
107
|
+
cell_areas : xarray.DataArray, optional
|
|
108
|
+
For unstructured grid, area of each cell
|
|
109
|
+
max_iteration : int, default=40
|
|
110
|
+
Maximum number of iterations for merging/splitting algorithm
|
|
111
|
+
checkpoint : str, default='None'
|
|
112
|
+
Checkpoint strategy ('save', 'load', or None)
|
|
113
|
+
debug : int, default=0
|
|
114
|
+
Debug level (0-2)
|
|
115
|
+
verbosity : int, default=0
|
|
116
|
+
Verbosity level
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(self, data_bin, mask, R_fill, area_filter_quartile,
|
|
120
|
+
temp_dir=None, T_fill=2, allow_merging=True, nn_partitioning=False,
|
|
121
|
+
overlap_threshold=0.5, unstructured_grid=False, timedim='time',
|
|
122
|
+
xdim='lon', ydim='lat', neighbours=None, cell_areas=None,
|
|
123
|
+
max_iteration=40, checkpoint=None, debug=0, verbosity=0):
|
|
124
|
+
|
|
125
|
+
self.data_bin = data_bin
|
|
126
|
+
self.mask = mask
|
|
127
|
+
self.R_fill = int(R_fill)
|
|
128
|
+
self.T_fill = T_fill
|
|
129
|
+
self.area_filter_quartile = area_filter_quartile
|
|
130
|
+
self.allow_merging = allow_merging
|
|
131
|
+
self.nn_partitioning = nn_partitioning
|
|
132
|
+
self.overlap_threshold = overlap_threshold
|
|
133
|
+
self.timedim = timedim
|
|
134
|
+
self.xdim = xdim
|
|
135
|
+
self.ydim = ydim
|
|
136
|
+
self.lat = data_bin.lat.persist()
|
|
137
|
+
self.lon = data_bin.lon.persist()
|
|
138
|
+
self.timechunks = data_bin.chunks[data_bin.dims.index(timedim)][0]
|
|
139
|
+
self.unstructured_grid = unstructured_grid
|
|
140
|
+
self.mean_cell_area = 1.0 # For structured grids, units are pixels
|
|
141
|
+
self.checkpoint = checkpoint
|
|
142
|
+
self.debug = debug
|
|
143
|
+
self.verbosity = verbosity
|
|
144
|
+
|
|
145
|
+
# Input validation and preparation
|
|
146
|
+
self._validate_inputs()
|
|
147
|
+
|
|
148
|
+
# Special setup for unstructured grids
|
|
149
|
+
if unstructured_grid:
|
|
150
|
+
self._setup_unstructured_grid(temp_dir, neighbours, cell_areas, max_iteration)
|
|
151
|
+
|
|
152
|
+
self._configure_warnings()
|
|
153
|
+
|
|
154
|
+
def _validate_inputs(self):
|
|
155
|
+
"""Validate input parameters and data."""
|
|
156
|
+
# For unstructured grids, adjust dimensions
|
|
157
|
+
if self.unstructured_grid:
|
|
158
|
+
self.ydim = None
|
|
159
|
+
if ((self.timedim, self.xdim) != self.data_bin.dims):
|
|
160
|
+
try:
|
|
161
|
+
self.data_bin = self.data_bin.transpose(self.timedim, self.xdim)
|
|
162
|
+
except:
|
|
163
|
+
raise ValueError(
|
|
164
|
+
f'Unstructured MarEx only supports 2D DataArrays with dimensions '
|
|
165
|
+
f'({self.timedim} and {self.xdim}). Found {list(self.data_bin.dims)}'
|
|
166
|
+
)
|
|
167
|
+
else:
|
|
168
|
+
# For structured grids, ensure 3D data
|
|
169
|
+
if ((self.timedim, self.ydim, self.xdim) != self.data_bin.dims):
|
|
170
|
+
try:
|
|
171
|
+
self.data_bin = self.data_bin.transpose(self.timedim, self.ydim, self.xdim)
|
|
172
|
+
except:
|
|
173
|
+
raise ValueError(
|
|
174
|
+
f'Gridded (structured) MarEx only supports 3D DataArrays with dimensions '
|
|
175
|
+
f'({self.timedim}, {self.ydim}, and {self.xdim}). Found {list(self.data_bin.dims)}'
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Check data type and structure
|
|
179
|
+
if (self.data_bin.data.dtype != bool):
|
|
180
|
+
raise ValueError('The input DataArray must be binary (boolean type)')
|
|
181
|
+
|
|
182
|
+
if not is_dask_collection(self.data_bin.data):
|
|
183
|
+
raise ValueError('The input DataArray must be backed by a Dask array')
|
|
184
|
+
|
|
185
|
+
if (self.mask.data.dtype != bool):
|
|
186
|
+
raise ValueError('The mask must be binary (boolean type)')
|
|
187
|
+
|
|
188
|
+
if (self.mask == False).all():
|
|
189
|
+
raise ValueError('Mask contains only False values. It should indicate valid regions with True')
|
|
190
|
+
|
|
191
|
+
if (self.area_filter_quartile < 0) or (self.area_filter_quartile > 1):
|
|
192
|
+
raise ValueError('area_filter_quartile must be between 0 and 1')
|
|
193
|
+
|
|
194
|
+
if (self.T_fill % 2 != 0):
|
|
195
|
+
raise ValueError('T_fill must be even for symmetry')
|
|
196
|
+
|
|
197
|
+
# Check geographic coordinates
|
|
198
|
+
if ((self.lon.max().compute().item() - self.lon.min().compute().item()) < 100):
|
|
199
|
+
raise ValueError('Lat/Lon coordinates must be in degrees')
|
|
200
|
+
|
|
201
|
+
def _setup_unstructured_grid(self, temp_dir, neighbours, cell_areas, max_iteration):
|
|
202
|
+
"""Set up special handling for unstructured grids."""
|
|
203
|
+
if not temp_dir:
|
|
204
|
+
raise ValueError('Unstructured grid requires a temporary directory for memory-efficient processing')
|
|
205
|
+
|
|
206
|
+
self.scratch_dir = temp_dir
|
|
207
|
+
|
|
208
|
+
# Clear any existing temporary storage
|
|
209
|
+
if os.path.exists(f'{self.scratch_dir}/marEx_temp_field.zarr/'):
|
|
210
|
+
shutil.rmtree(f'{self.scratch_dir}/marEx_temp_field.zarr/')
|
|
211
|
+
|
|
212
|
+
# Remove coordinate variables to avoid memory issues
|
|
213
|
+
self.data_bin = self.data_bin.drop_vars({'lat', 'lon'})
|
|
214
|
+
self.mask = self.mask.drop_vars({'lat', 'lon'})
|
|
215
|
+
self.lat = self.lat.drop_vars(self.lat.coords)
|
|
216
|
+
self.lon = self.lon.drop_vars(self.lon.coords)
|
|
217
|
+
neighbours = neighbours.drop_vars({'lat', 'lon', 'nv'})
|
|
218
|
+
|
|
219
|
+
self.max_iteration = max_iteration
|
|
220
|
+
|
|
221
|
+
# Store cell areas (in square metres)
|
|
222
|
+
self.cell_area = cell_areas.astype(np.float32).drop_vars({'lat', 'lon'}).persist()
|
|
223
|
+
self.mean_cell_area = cell_areas.mean().compute().item()
|
|
224
|
+
|
|
225
|
+
# Initialise dilation array for unstructured grid
|
|
226
|
+
self.neighbours_int = neighbours.astype(np.int32) - 1 # Convert to 0-based indexing
|
|
227
|
+
|
|
228
|
+
# Validate neighbour array structure
|
|
229
|
+
if self.neighbours_int.shape[0] != 3:
|
|
230
|
+
raise ValueError('Unstructured MarEx only supports triangular grids. Neighbours array must have shape (3, ncells)')
|
|
231
|
+
if self.neighbours_int.dims != ('nv', self.xdim):
|
|
232
|
+
raise ValueError('Neighbours array must have dimensions (nv, xdim)')
|
|
233
|
+
|
|
234
|
+
# Construct sparse dilation matrix
|
|
235
|
+
self._build_sparse_dilation_matrix()
|
|
236
|
+
|
|
237
|
+
def _build_sparse_dilation_matrix(self):
|
|
238
|
+
"""Build sparse matrix for efficient dilation operations on unstructured grid."""
|
|
239
|
+
# Create row and column indices for sparse matrix
|
|
240
|
+
row_indices = jnp.repeat(jnp.arange(self.neighbours_int.shape[1]), 3)
|
|
241
|
+
col_indices = self.neighbours_int.data.compute().T.flatten()
|
|
242
|
+
|
|
243
|
+
# Filter out negative values (invalid connections)
|
|
244
|
+
valid_mask = col_indices >= 0
|
|
245
|
+
row_indices = row_indices[valid_mask]
|
|
246
|
+
col_indices = col_indices[valid_mask]
|
|
247
|
+
|
|
248
|
+
max_neighbour = self.neighbours_int.max().compute().item() + 1
|
|
249
|
+
|
|
250
|
+
# Create the sparse matrix for dilation
|
|
251
|
+
dilate_coo = coo_matrix(
|
|
252
|
+
(jnp.ones_like(row_indices, dtype=bool), (row_indices, col_indices)),
|
|
253
|
+
shape=(self.neighbours_int.shape[1], max_neighbour)
|
|
254
|
+
)
|
|
255
|
+
self.dilate_sparse = csr_matrix(dilate_coo)
|
|
256
|
+
|
|
257
|
+
# Add identity matrix to include self-connections
|
|
258
|
+
identity = eye(self.neighbours_int.shape[1], dtype=bool, format='csr')
|
|
259
|
+
self.dilate_sparse = self.dilate_sparse + identity
|
|
260
|
+
|
|
261
|
+
if self.verbosity > 0:
|
|
262
|
+
print('Finished constructing the sparse dilation matrix')
|
|
263
|
+
|
|
264
|
+
def _configure_warnings(self):
|
|
265
|
+
"""Configure warning and logging suppression based on debug level."""
|
|
266
|
+
if self.debug < 2:
|
|
267
|
+
# Configure logging warning filters
|
|
268
|
+
logging.getLogger('distributed.scheduler').setLevel(logging.ERROR)
|
|
269
|
+
|
|
270
|
+
def filter_dask_warnings(record):
|
|
271
|
+
msg = str(record.msg)
|
|
272
|
+
|
|
273
|
+
if self.debug == 0:
|
|
274
|
+
# Suppress both run_spec and large graph warnings
|
|
275
|
+
if any(pattern in msg for pattern in [
|
|
276
|
+
'Detected different `run_spec`',
|
|
277
|
+
'Sending large graph',
|
|
278
|
+
'This may cause some slowdown'
|
|
279
|
+
]):
|
|
280
|
+
return False
|
|
281
|
+
return True
|
|
282
|
+
else:
|
|
283
|
+
# Suppress only run_spec warnings
|
|
284
|
+
if 'Detected different `run_spec`' in msg:
|
|
285
|
+
return False
|
|
286
|
+
return True
|
|
287
|
+
|
|
288
|
+
logging.getLogger('distributed.scheduler').addFilter(filter_dask_warnings)
|
|
289
|
+
|
|
290
|
+
# Configure Python warnings
|
|
291
|
+
if self.debug == 0:
|
|
292
|
+
warnings.filterwarnings('ignore',
|
|
293
|
+
category=UserWarning,
|
|
294
|
+
module='distributed.client')
|
|
295
|
+
warnings.filterwarnings('ignore',
|
|
296
|
+
message='.*Sending large graph.*\n.*This may cause some slowdown.*',
|
|
297
|
+
category=UserWarning)
|
|
298
|
+
|
|
299
|
+
# ============================
|
|
300
|
+
# Main Public Methods
|
|
301
|
+
# ============================
|
|
302
|
+
|
|
303
|
+
def run(self, return_merges=False, checkpoint=None):
|
|
304
|
+
"""
|
|
305
|
+
Run the complete object identification and tracking pipeline.
|
|
306
|
+
|
|
307
|
+
This method executes the full workflow:
|
|
308
|
+
1. Preprocessing: morphological operations and size filtering
|
|
309
|
+
2. Identification and tracking of objects through time
|
|
310
|
+
3. Computing and attaching statistics to the results
|
|
311
|
+
|
|
312
|
+
Parameters
|
|
313
|
+
----------
|
|
314
|
+
return_merges : bool, default=False
|
|
315
|
+
If True, return merge events dataset alongside the main events
|
|
316
|
+
checkpoint : str, optional
|
|
317
|
+
Override the instance checkpoint setting
|
|
318
|
+
|
|
319
|
+
Returns
|
|
320
|
+
-------
|
|
321
|
+
events_ds : xarray.Dataset
|
|
322
|
+
Dataset containing tracked events and their properties
|
|
323
|
+
merges_ds : xarray.Dataset, optional
|
|
324
|
+
Dataset with merge event information (only if return_merges=True)
|
|
325
|
+
"""
|
|
326
|
+
# Preprocess the binary data
|
|
327
|
+
data_bin_preprocessed, object_stats = self.run_preprocess(checkpoint=checkpoint)
|
|
328
|
+
|
|
329
|
+
# Run identification and tracking
|
|
330
|
+
events_ds, merges_ds, N_events_final = self.run_tracking(data_bin_preprocessed)
|
|
331
|
+
|
|
332
|
+
# Compute statistics and finalise output
|
|
333
|
+
events_ds = self.run_stats_attributes(events_ds, merges_ds, object_stats, N_events_final)
|
|
334
|
+
|
|
335
|
+
if self.allow_merging and return_merges:
|
|
336
|
+
return events_ds, merges_ds
|
|
337
|
+
else:
|
|
338
|
+
return events_ds
|
|
339
|
+
|
|
340
|
+
def run_preprocess(self, checkpoint=None):
|
|
341
|
+
"""
|
|
342
|
+
Preprocess binary data to prepare for tracking.
|
|
343
|
+
|
|
344
|
+
This performs morphological operations to fill holes/gaps in both space and time,
|
|
345
|
+
then filters small objects according to the area_filter_quartile.
|
|
346
|
+
|
|
347
|
+
Parameters
|
|
348
|
+
----------
|
|
349
|
+
checkpoint : str, optional
|
|
350
|
+
Checkpoint strategy override
|
|
351
|
+
|
|
352
|
+
Returns
|
|
353
|
+
-------
|
|
354
|
+
data_bin_filtered : xarray.DataArray
|
|
355
|
+
Preprocessed binary data
|
|
356
|
+
object_stats : tuple
|
|
357
|
+
Statistics about the preprocessing
|
|
358
|
+
"""
|
|
359
|
+
if not checkpoint:
|
|
360
|
+
checkpoint = self.checkpoint
|
|
361
|
+
|
|
362
|
+
def load_data_from_checkpoint():
|
|
363
|
+
"""Load preprocessed data from checkpoint files."""
|
|
364
|
+
data_bin_preprocessed = xr.open_zarr(
|
|
365
|
+
f'{self.scratch_dir}/marEx_checkpoint_proc_bin.zarr',
|
|
366
|
+
chunks={self.timedim: self.timechunks}
|
|
367
|
+
)['data_bin_preproc']
|
|
368
|
+
return data_bin_preprocessed
|
|
369
|
+
def load_stats_from_checkpoint():
|
|
370
|
+
object_stats_npz = np.load(f'{self.scratch_dir}/marEx_checkpoint_stats.npz')
|
|
371
|
+
object_stats = [
|
|
372
|
+
object_stats_npz[key] for key in [
|
|
373
|
+
'total_area_IDed', 'N_objects_prefiltered', 'N_objects_filtered',
|
|
374
|
+
'area_threshold', 'accepted_area_fraction', 'preprocessed_area_fraction'
|
|
375
|
+
]
|
|
376
|
+
]
|
|
377
|
+
return object_stats
|
|
378
|
+
|
|
379
|
+
if checkpoint == 'load':
|
|
380
|
+
print('Loading preprocessed data & stats...')
|
|
381
|
+
return load_data_from_checkpoint(), load_stats_from_checkpoint()
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# Compute area of initial binary data
|
|
385
|
+
raw_area = self.compute_area(self.data_bin)
|
|
386
|
+
|
|
387
|
+
# Fill small holes & gaps between objects
|
|
388
|
+
data_bin_filled = self.fill_holes(self.data_bin)
|
|
389
|
+
del self.data_bin # Free memory
|
|
390
|
+
if self.verbosity > 0:
|
|
391
|
+
print('Finished filling spatial holes')
|
|
392
|
+
|
|
393
|
+
# Fill small time-gaps between objects
|
|
394
|
+
data_bin_filled = self.fill_time_gaps(data_bin_filled).persist()
|
|
395
|
+
if self.verbosity > 0:
|
|
396
|
+
print('Finished filling spatio-temporal holes')
|
|
397
|
+
|
|
398
|
+
# Remove small objects
|
|
399
|
+
data_bin_filtered, area_threshold, object_areas, N_objects_prefiltered, N_objects_filtered = (
|
|
400
|
+
self.filter_small_objects(data_bin_filled)
|
|
401
|
+
)
|
|
402
|
+
del data_bin_filled # Free memory
|
|
403
|
+
if self.verbosity > 0:
|
|
404
|
+
print('Finished filtering small objects')
|
|
405
|
+
|
|
406
|
+
# Persist preprocessed data &/or Save checkpoint
|
|
407
|
+
if checkpoint and 'save' in checkpoint:
|
|
408
|
+
print('Saving preprocessed data & stats...')
|
|
409
|
+
time.sleep(5)
|
|
410
|
+
data_bin_filtered.name = 'data_bin_preproc'
|
|
411
|
+
data_bin_filtered.to_zarr(f'{self.scratch_dir}/marEx_checkpoint_proc_bin.zarr', mode='w') # N.B.: This needs to be done without .persist() due to dask to_zarr tuple bug...
|
|
412
|
+
data_bin_filtered = load_data_from_checkpoint()
|
|
413
|
+
else:
|
|
414
|
+
data_bin_filtered = data_bin_filtered.persist()
|
|
415
|
+
wait(data_bin_filtered)
|
|
416
|
+
|
|
417
|
+
# Compute area of processed data
|
|
418
|
+
processed_area = self.compute_area(data_bin_filtered)
|
|
419
|
+
|
|
420
|
+
# Compute statistics
|
|
421
|
+
object_areas = object_areas.compute()
|
|
422
|
+
total_area_IDed = object_areas.sum().item()
|
|
423
|
+
|
|
424
|
+
accepted_area = object_areas.where(object_areas > area_threshold, drop=True).sum().item()
|
|
425
|
+
accepted_area_fraction = accepted_area / total_area_IDed
|
|
426
|
+
|
|
427
|
+
total_hobday_area = raw_area.sum().compute().item()
|
|
428
|
+
total_processed_area = processed_area.sum().compute().item()
|
|
429
|
+
preprocessed_area_fraction = total_hobday_area / total_processed_area
|
|
430
|
+
|
|
431
|
+
object_stats = (
|
|
432
|
+
total_area_IDed, N_objects_prefiltered, N_objects_filtered,
|
|
433
|
+
area_threshold, accepted_area_fraction, preprocessed_area_fraction
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
# Save checkpoint
|
|
437
|
+
if checkpoint and 'save' in checkpoint:
|
|
438
|
+
np.savez(
|
|
439
|
+
f'{self.scratch_dir}/marEx_checkpoint_stats.npz',
|
|
440
|
+
total_area_IDed=total_area_IDed,
|
|
441
|
+
N_objects_prefiltered=N_objects_prefiltered,
|
|
442
|
+
N_objects_filtered=N_objects_filtered,
|
|
443
|
+
area_threshold=area_threshold,
|
|
444
|
+
accepted_area_fraction=accepted_area_fraction,
|
|
445
|
+
preprocessed_area_fraction=preprocessed_area_fraction
|
|
446
|
+
)
|
|
447
|
+
# Reload to refresh the dask graph
|
|
448
|
+
data_bin_filtered = load_data_from_checkpoint()
|
|
449
|
+
object_stats = load_stats_from_checkpoint()
|
|
450
|
+
|
|
451
|
+
return data_bin_filtered, object_stats
|
|
452
|
+
|
|
453
|
+
def run_tracking(self, data_bin_preprocessed):
|
|
454
|
+
"""
|
|
455
|
+
Track objects through time to identify events.
|
|
456
|
+
|
|
457
|
+
Parameters
|
|
458
|
+
----------
|
|
459
|
+
data_bin_preprocessed : xarray.DataArray
|
|
460
|
+
Preprocessed binary data
|
|
461
|
+
|
|
462
|
+
Returns
|
|
463
|
+
-------
|
|
464
|
+
events_ds : xarray.Dataset
|
|
465
|
+
Dataset containing tracked events
|
|
466
|
+
merges_ds : xarray.Dataset
|
|
467
|
+
Dataset with merge information
|
|
468
|
+
N_events_final : int
|
|
469
|
+
Final number of unique events
|
|
470
|
+
"""
|
|
471
|
+
if self.allow_merging or self.unstructured_grid:
|
|
472
|
+
# Track with merging & splitting
|
|
473
|
+
events_ds, merges_ds, N_events_final = self.track_objects(data_bin_preprocessed)
|
|
474
|
+
else:
|
|
475
|
+
# Track without merging or splitting
|
|
476
|
+
events_da, _, N_events_final = self.identify_objects(
|
|
477
|
+
data_bin_preprocessed, time_connectivity=True
|
|
478
|
+
)
|
|
479
|
+
events_ds = xr.Dataset({'ID_field': events_da})
|
|
480
|
+
merges_ds = xr.Dataset()
|
|
481
|
+
|
|
482
|
+
# Set all filler IDs < 0 to 0
|
|
483
|
+
events_ds['ID_field'] = events_ds.ID_field.where(events_ds.ID_field > 0, drop=False, other=0)
|
|
484
|
+
|
|
485
|
+
# Delete the last ID -- it is all 0s
|
|
486
|
+
events_ds = events_ds.isel(ID=slice(None,-1)) # At least for the gridded algorithm
|
|
487
|
+
|
|
488
|
+
if self.verbosity > 0:
|
|
489
|
+
print('Finished tracking all extreme events!\n\n')
|
|
490
|
+
|
|
491
|
+
return events_ds, merges_ds, N_events_final
|
|
492
|
+
|
|
493
|
+
def run_stats_attributes(self, events_ds, merges_ds, object_stats, N_events_final):
|
|
494
|
+
"""
|
|
495
|
+
Add statistics and attributes to the events dataset.
|
|
496
|
+
|
|
497
|
+
Parameters
|
|
498
|
+
----------
|
|
499
|
+
events_ds : xarray.Dataset
|
|
500
|
+
Dataset containing tracked events
|
|
501
|
+
merges_ds : xarray.Dataset
|
|
502
|
+
Dataset with merge information
|
|
503
|
+
object_stats : tuple
|
|
504
|
+
Preprocessed object statistics
|
|
505
|
+
N_events_final : int
|
|
506
|
+
Final number of events
|
|
507
|
+
|
|
508
|
+
Returns
|
|
509
|
+
-------
|
|
510
|
+
events_ds : xarray.Dataset
|
|
511
|
+
Dataset with added statistics and attributes
|
|
512
|
+
"""
|
|
513
|
+
# Unpack object stats
|
|
514
|
+
(total_area_IDed, N_objects_prefiltered, N_objects_filtered,
|
|
515
|
+
area_threshold, accepted_area_fraction, preprocessed_area_fraction) = object_stats
|
|
516
|
+
|
|
517
|
+
# Add general attributes to dataset
|
|
518
|
+
events_ds.attrs['allow_merging'] = int(self.allow_merging)
|
|
519
|
+
events_ds.attrs['N_objects_prefiltered'] = int(N_objects_prefiltered)
|
|
520
|
+
events_ds.attrs['N_objects_filtered'] = int(N_objects_filtered)
|
|
521
|
+
events_ds.attrs['N_events_final'] = int(N_events_final)
|
|
522
|
+
events_ds.attrs['R_fill'] = self.R_fill
|
|
523
|
+
events_ds.attrs['T_fill'] = self.T_fill
|
|
524
|
+
events_ds.attrs['area_filter_quartile'] = self.area_filter_quartile
|
|
525
|
+
events_ds.attrs['area_threshold (cells)'] = area_threshold
|
|
526
|
+
events_ds.attrs['accepted_area_fraction'] = accepted_area_fraction
|
|
527
|
+
events_ds.attrs['preprocessed_area_fraction'] = preprocessed_area_fraction
|
|
528
|
+
|
|
529
|
+
# Print summary statistics
|
|
530
|
+
print('Tracking Statistics:')
|
|
531
|
+
print(f' Binary Hobday to Processed Area Fraction: {preprocessed_area_fraction}')
|
|
532
|
+
print(f' Total Object Area IDed (cells): {total_area_IDed}')
|
|
533
|
+
print(f' Number of Initial Pre-Filtered Objects: {N_objects_prefiltered}')
|
|
534
|
+
print(f' Number of Final Filtered Objects: {N_objects_filtered}')
|
|
535
|
+
print(f' Area Cutoff Threshold (cells): {area_threshold.astype(np.int32)}')
|
|
536
|
+
print(f' Accepted Area Fraction: {accepted_area_fraction}')
|
|
537
|
+
print(f' Total Events Tracked: {N_events_final}')
|
|
538
|
+
|
|
539
|
+
# Add merge-specific attributes if applicable
|
|
540
|
+
if self.allow_merging:
|
|
541
|
+
events_ds.attrs['overlap_threshold'] = self.overlap_threshold
|
|
542
|
+
events_ds.attrs['nn_partitioning'] = int(self.nn_partitioning)
|
|
543
|
+
|
|
544
|
+
# Add merge summary attributes
|
|
545
|
+
events_ds.attrs['total_merges'] = len(merges_ds.merge_ID)
|
|
546
|
+
events_ds.attrs['multi_parent_merges'] = (merges_ds.n_parents > 2).sum().item()
|
|
547
|
+
|
|
548
|
+
print(f" Total Merging Events Recorded: {events_ds.attrs['total_merges']}")
|
|
549
|
+
|
|
550
|
+
# For unstructured grid, restore coordinates and rechunk
|
|
551
|
+
if self.unstructured_grid:
|
|
552
|
+
# Add lat & lon back as coordinates
|
|
553
|
+
events_ds = events_ds.assign_coords(
|
|
554
|
+
lat=self.lat.compute(),
|
|
555
|
+
lon=self.lon.compute()
|
|
556
|
+
)
|
|
557
|
+
# Rechunk to size 1 for better post-processing
|
|
558
|
+
events_ds = events_ds.chunk({self.timedim: 1})
|
|
559
|
+
|
|
560
|
+
return events_ds
|
|
561
|
+
|
|
562
|
+
# ============================
|
|
563
|
+
# Data Processing Methods
|
|
564
|
+
# ============================
|
|
565
|
+
|
|
566
|
+
def compute_area(self, data_bin):
|
|
567
|
+
"""
|
|
568
|
+
Compute the total area of binary data at each time.
|
|
569
|
+
|
|
570
|
+
Parameters
|
|
571
|
+
----------
|
|
572
|
+
data_bin : xarray.DataArray
|
|
573
|
+
Binary data
|
|
574
|
+
|
|
575
|
+
Returns
|
|
576
|
+
-------
|
|
577
|
+
area : xarray.DataArray
|
|
578
|
+
Total area at each time (units: pixels for structured grid, matching cell_area for unstructured)
|
|
579
|
+
"""
|
|
580
|
+
if self.unstructured_grid:
|
|
581
|
+
area = (data_bin * self.cell_area).sum(dim=[self.xdim])
|
|
582
|
+
else:
|
|
583
|
+
area = data_bin.sum(dim=[self.ydim, self.xdim])
|
|
584
|
+
|
|
585
|
+
return area
|
|
586
|
+
|
|
587
|
+
def fill_holes(self, data_bin, R_fill=None):
|
|
588
|
+
"""
|
|
589
|
+
Fill holes and gaps using morphological operations.
|
|
590
|
+
|
|
591
|
+
This performs closing (dilation followed by erosion) to fill small gaps,
|
|
592
|
+
then opening (erosion followed by dilation) to remove small isolated objects.
|
|
593
|
+
|
|
594
|
+
Parameters
|
|
595
|
+
----------
|
|
596
|
+
data_bin : xarray.DataArray
|
|
597
|
+
Binary data to process
|
|
598
|
+
R_fill : int, optional
|
|
599
|
+
Fill radius override
|
|
600
|
+
|
|
601
|
+
Returns
|
|
602
|
+
-------
|
|
603
|
+
data_bin_filled : xarray.DataArray
|
|
604
|
+
Binary data with holes/gaps filled
|
|
605
|
+
"""
|
|
606
|
+
if R_fill is None:
|
|
607
|
+
R_fill = self.R_fill
|
|
608
|
+
|
|
609
|
+
if self.unstructured_grid:
|
|
610
|
+
# Process unstructured grid using sparse matrix operations
|
|
611
|
+
# _Put the data into an xarray.DataArray to pass into the apply_ufunc_ -- Needed for correct memory management !
|
|
612
|
+
sp_data = xr.DataArray(self.dilate_sparse.data, dims='sp_data')
|
|
613
|
+
indices = xr.DataArray(self.dilate_sparse.indices, dims='indices')
|
|
614
|
+
indptr = xr.DataArray(self.dilate_sparse.indptr, dims='indptr')
|
|
615
|
+
|
|
616
|
+
def binary_open_close(bitmap_binary, sp_data, indices, indptr, mask):
|
|
617
|
+
"""
|
|
618
|
+
Binary opening and closing for unstructured grid.
|
|
619
|
+
Uses sparse matrix power operations for efficiency.
|
|
620
|
+
"""
|
|
621
|
+
## Closing: Dilation then Erosion (fills small gaps)
|
|
622
|
+
|
|
623
|
+
# Dilation
|
|
624
|
+
bitmap_binary = sparse_bool_power(bitmap_binary, sp_data, indices, indptr, R_fill)
|
|
625
|
+
|
|
626
|
+
# Set land values to True (to avoid artificially eroding the shore)
|
|
627
|
+
bitmap_binary[:, ~mask] = True
|
|
628
|
+
|
|
629
|
+
# Erosion (negated dilation of negated image)
|
|
630
|
+
bitmap_binary = ~sparse_bool_power(~bitmap_binary, sp_data, indices, indptr, R_fill)
|
|
631
|
+
|
|
632
|
+
## Opening: Erosion then Dilation (removes small objects)
|
|
633
|
+
|
|
634
|
+
# Set land values to True (to avoid artificially eroding the shore)
|
|
635
|
+
bitmap_binary[:, ~mask] = True
|
|
636
|
+
|
|
637
|
+
# Erosion
|
|
638
|
+
bitmap_binary = ~sparse_bool_power(~bitmap_binary, sp_data, indices, indptr, R_fill)
|
|
639
|
+
|
|
640
|
+
# Dilation
|
|
641
|
+
bitmap_binary = sparse_bool_power(bitmap_binary, sp_data, indices, indptr, R_fill)
|
|
642
|
+
|
|
643
|
+
return bitmap_binary
|
|
644
|
+
|
|
645
|
+
# Apply the operations
|
|
646
|
+
data_bin = xr.apply_ufunc(
|
|
647
|
+
binary_open_close, data_bin, sp_data, indices, indptr, self.mask,
|
|
648
|
+
input_core_dims=[[self.xdim], ['sp_data'], ['indices'], ['indptr'], [self.xdim]],
|
|
649
|
+
output_core_dims=[[self.xdim]],
|
|
650
|
+
output_dtypes=[np.bool_],
|
|
651
|
+
vectorize=False,
|
|
652
|
+
dask_gufunc_kwargs={'output_sizes': {self.xdim: data_bin.sizes[self.xdim]}},
|
|
653
|
+
dask='parallelized'
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
else:
|
|
657
|
+
# Structured grid using dask-powered morphological operations
|
|
658
|
+
use_dask_morph = True
|
|
659
|
+
|
|
660
|
+
# Generate structuring element (disk-shaped)
|
|
661
|
+
y, x = np.ogrid[-R_fill:R_fill+1, -R_fill:R_fill+1]
|
|
662
|
+
r = x**2 + y**2
|
|
663
|
+
diameter = 2 * R_fill
|
|
664
|
+
se_kernel = r < (R_fill**2)+1
|
|
665
|
+
|
|
666
|
+
if use_dask_morph:
|
|
667
|
+
# Pad data to avoid edge effects
|
|
668
|
+
data_bin = data_bin.pad({self.ydim: diameter, self.xdim: diameter}, mode='wrap')
|
|
669
|
+
data_coords = data_bin.coords
|
|
670
|
+
data_dims = data_bin.dims
|
|
671
|
+
|
|
672
|
+
# Apply morphological operations
|
|
673
|
+
data_bin = binary_closing_dask(data_bin.data, structure=se_kernel[np.newaxis, :, :]) # N.B.: There may be a rearing bug in constructing the dask task graph when we extract and then re-imbed the dask array into an xarray DataArray
|
|
674
|
+
data_bin = binary_opening_dask(data_bin, structure=se_kernel[np.newaxis, :, :])
|
|
675
|
+
|
|
676
|
+
# Convert back to xarray.DataArray and trim padding
|
|
677
|
+
data_bin = xr.DataArray(data_bin, coords=data_coords, dims=data_dims)
|
|
678
|
+
data_bin = data_bin.isel({
|
|
679
|
+
self.ydim: slice(diameter, -diameter),
|
|
680
|
+
self.xdim: slice(diameter, -diameter)
|
|
681
|
+
})
|
|
682
|
+
else:
|
|
683
|
+
def binary_open_close(bitmap_binary):
|
|
684
|
+
"""Apply binary opening and closing in one function."""
|
|
685
|
+
bitmap_binary_padded = np.pad(
|
|
686
|
+
bitmap_binary,
|
|
687
|
+
((diameter, diameter), (diameter, diameter)),
|
|
688
|
+
mode='wrap'
|
|
689
|
+
)
|
|
690
|
+
s1 = binary_closing(bitmap_binary_padded, se_kernel, iterations=1)
|
|
691
|
+
s2 = binary_opening(s1, se_kernel, iterations=1)
|
|
692
|
+
unpadded = s2[diameter:-diameter, diameter:-diameter]
|
|
693
|
+
return unpadded
|
|
694
|
+
|
|
695
|
+
data_bin = xr.apply_ufunc(
|
|
696
|
+
binary_open_close, data_bin,
|
|
697
|
+
input_core_dims=[[self.ydim, self.xdim]],
|
|
698
|
+
output_core_dims=[[self.ydim, self.xdim]],
|
|
699
|
+
output_dtypes=[data_bin.dtype],
|
|
700
|
+
vectorize=True,
|
|
701
|
+
dask='parallelized'
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
# Mask out edge features from morphological operations
|
|
705
|
+
data_bin = data_bin.where(self.mask, drop=False, other=False)
|
|
706
|
+
|
|
707
|
+
return data_bin
|
|
708
|
+
|
|
709
|
+
def fill_time_gaps(self, data_bin):
|
|
710
|
+
"""
|
|
711
|
+
Fill temporal gaps between objects.
|
|
712
|
+
|
|
713
|
+
Performs binary closing (dilation then erosion) along the time dimension
|
|
714
|
+
to fill small time gaps between objects.
|
|
715
|
+
|
|
716
|
+
Parameters
|
|
717
|
+
----------
|
|
718
|
+
data_bin : xarray.DataArray
|
|
719
|
+
Binary data to process
|
|
720
|
+
|
|
721
|
+
Returns
|
|
722
|
+
-------
|
|
723
|
+
data_bin_filled : xarray.DataArray
|
|
724
|
+
Binary data with temporal gaps filled
|
|
725
|
+
"""
|
|
726
|
+
if self.T_fill == 0:
|
|
727
|
+
return data_bin
|
|
728
|
+
|
|
729
|
+
# Create temporal structuring element
|
|
730
|
+
kernel_size = self.T_fill + 1 # This will then fill a maximum hole size of self.T_fill
|
|
731
|
+
time_kernel = np.ones(kernel_size, dtype=bool)
|
|
732
|
+
|
|
733
|
+
if self.ydim is None:
|
|
734
|
+
# Unstructured grid has only 1 additional dimension
|
|
735
|
+
time_kernel = time_kernel[:, np.newaxis]
|
|
736
|
+
else:
|
|
737
|
+
time_kernel = time_kernel[:, np.newaxis, np.newaxis]
|
|
738
|
+
|
|
739
|
+
# Pad in time to avoid edge effects
|
|
740
|
+
data_bin = data_bin.pad({self.timedim: kernel_size}, mode='constant', constant_values=False)
|
|
741
|
+
|
|
742
|
+
# Apply temporal closing
|
|
743
|
+
data_bin_dask = data_bin.data
|
|
744
|
+
closed_dask_array = binary_closing_dask(data_bin_dask, structure=time_kernel)
|
|
745
|
+
|
|
746
|
+
# Convert back to xarray.DataArray
|
|
747
|
+
data_bin_filled = xr.DataArray(
|
|
748
|
+
closed_dask_array,
|
|
749
|
+
coords=data_bin.coords,
|
|
750
|
+
dims=data_bin.dims,
|
|
751
|
+
attrs=data_bin.attrs
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
# Remove padding
|
|
755
|
+
data_bin_filled = data_bin_filled.isel({self.timedim: slice(kernel_size, -kernel_size)}).persist()
|
|
756
|
+
|
|
757
|
+
# Fill newly-created spatial holes
|
|
758
|
+
data_bin_filled = self.fill_holes(data_bin_filled, R_fill=self.R_fill//2)
|
|
759
|
+
|
|
760
|
+
return data_bin_filled
|
|
761
|
+
|
|
762
|
+
def refresh_dask_graph(self, data_bin):
|
|
763
|
+
"""
|
|
764
|
+
Clear and reset the Dask graph via save/load cycle.
|
|
765
|
+
|
|
766
|
+
This is needed to work around a memory leak bug in Dask where
|
|
767
|
+
"Unmanaged Memory" builds up within loops.
|
|
768
|
+
|
|
769
|
+
Parameters
|
|
770
|
+
----------
|
|
771
|
+
data_bin : xarray.DataArray
|
|
772
|
+
Data to refresh
|
|
773
|
+
|
|
774
|
+
Returns
|
|
775
|
+
-------
|
|
776
|
+
data_new : xarray.DataArray
|
|
777
|
+
Data with fresh Dask graph
|
|
778
|
+
"""
|
|
779
|
+
if self.verbosity > 1:
|
|
780
|
+
print(' Refreshing Dask task graph...')
|
|
781
|
+
|
|
782
|
+
data_bin.name = 'temp'
|
|
783
|
+
data_bin.to_zarr(f'{self.scratch_dir}/marEx_temp_field.zarr', mode='w')
|
|
784
|
+
del data_bin
|
|
785
|
+
gc.collect()
|
|
786
|
+
|
|
787
|
+
data_new = xr.open_zarr(f'{self.scratch_dir}/marEx_temp_field.zarr', chunks={}).temp
|
|
788
|
+
return data_new
|
|
789
|
+
|
|
790
|
+
def filter_small_objects(self, data_bin):
|
|
791
|
+
"""
|
|
792
|
+
Remove objects smaller than a threshold area.
|
|
793
|
+
|
|
794
|
+
Parameters
|
|
795
|
+
----------
|
|
796
|
+
data_bin : xarray.DataArray
|
|
797
|
+
Binary data to filter
|
|
798
|
+
|
|
799
|
+
Returns
|
|
800
|
+
-------
|
|
801
|
+
data_bin_filtered : xarray.DataArray
|
|
802
|
+
Binary data with small objects removed
|
|
803
|
+
area_threshold : float
|
|
804
|
+
Area threshold used for filtering
|
|
805
|
+
object_areas : xarray.DataArray
|
|
806
|
+
Areas of all objects pre-filtering
|
|
807
|
+
N_objects_prefiltered : int
|
|
808
|
+
Number of objects before filtering
|
|
809
|
+
N_objects_filtered : int
|
|
810
|
+
Number of objects after filtering
|
|
811
|
+
"""
|
|
812
|
+
# Cluster & Label Binary Data: Time-independent in 2D (i.e. no time connectivity!)
|
|
813
|
+
object_id_field, _, N_objects_unfiltered = self.identify_objects(data_bin, time_connectivity=False)
|
|
814
|
+
|
|
815
|
+
if self.unstructured_grid:
|
|
816
|
+
# Get the maximum ID to dimension arrays
|
|
817
|
+
# Note: identify_objects() starts at ID=0 for every time slice
|
|
818
|
+
max_ID = object_id_field.max().compute().item()
|
|
819
|
+
|
|
820
|
+
def count_cluster_sizes(object_id_field):
|
|
821
|
+
"""Count the number of cells in each cluster."""
|
|
822
|
+
unique, counts = np.unique(object_id_field[object_id_field > 0], return_counts=True)
|
|
823
|
+
padded_sizes = np.zeros(max_ID, dtype=np.int32)
|
|
824
|
+
padded_unique = np.zeros(max_ID, dtype=np.int32)
|
|
825
|
+
padded_sizes[:len(counts)] = counts
|
|
826
|
+
padded_unique[:len(counts)] = unique
|
|
827
|
+
return padded_sizes, padded_unique
|
|
828
|
+
|
|
829
|
+
# Calculate cluster sizes
|
|
830
|
+
cluster_sizes, unique_cluster_IDs = xr.apply_ufunc(
|
|
831
|
+
count_cluster_sizes,
|
|
832
|
+
object_id_field,
|
|
833
|
+
input_core_dims=[[self.xdim]],
|
|
834
|
+
output_core_dims=[['ID'], ['ID']],
|
|
835
|
+
dask_gufunc_kwargs={'output_sizes': {'ID': max_ID}},
|
|
836
|
+
output_dtypes=(np.int32, np.int32),
|
|
837
|
+
vectorize=True,
|
|
838
|
+
dask='parallelized'
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
results = persist(cluster_sizes, unique_cluster_IDs)
|
|
842
|
+
cluster_sizes, unique_cluster_IDs = results
|
|
843
|
+
|
|
844
|
+
# Pre-filter tiny objects for performance
|
|
845
|
+
cluster_sizes_filtered_dask = cluster_sizes.where(cluster_sizes > 50).data
|
|
846
|
+
cluster_areas_mask = dsa.isfinite(cluster_sizes_filtered_dask)
|
|
847
|
+
object_areas = cluster_sizes_filtered_dask[cluster_areas_mask].compute()
|
|
848
|
+
|
|
849
|
+
# Filter based on area threshold
|
|
850
|
+
N_objects_unfiltered = len(object_areas)
|
|
851
|
+
area_threshold = np.percentile(object_areas, self.area_filter_quartile*100)
|
|
852
|
+
N_objects_filtered = np.sum(object_areas > area_threshold)
|
|
853
|
+
|
|
854
|
+
def filter_area_binary(cluster_IDs_0, keep_IDs_0):
|
|
855
|
+
"""Keep only clusters above threshold area."""
|
|
856
|
+
keep_IDs_0 = keep_IDs_0[keep_IDs_0 > 0]
|
|
857
|
+
keep_where = np.isin(cluster_IDs_0, keep_IDs_0)
|
|
858
|
+
return keep_where
|
|
859
|
+
|
|
860
|
+
# Create filtered binary data
|
|
861
|
+
keep_IDs = xr.where(cluster_sizes > area_threshold, unique_cluster_IDs, 0)
|
|
862
|
+
|
|
863
|
+
data_bin_filtered = xr.apply_ufunc(
|
|
864
|
+
filter_area_binary,
|
|
865
|
+
object_id_field, keep_IDs,
|
|
866
|
+
input_core_dims=[[self.xdim], ['ID']],
|
|
867
|
+
output_core_dims=[[self.xdim]],
|
|
868
|
+
output_dtypes=[data_bin.dtype],
|
|
869
|
+
vectorize=True,
|
|
870
|
+
dask='parallelized'
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
object_areas = cluster_sizes # Store pre-filtered areas
|
|
874
|
+
|
|
875
|
+
else:
|
|
876
|
+
# Structured grid approach
|
|
877
|
+
|
|
878
|
+
# Calculate object properties including area
|
|
879
|
+
object_props = self.calculate_object_properties(object_id_field)
|
|
880
|
+
object_areas, object_ids = object_props.area, object_props.ID
|
|
881
|
+
|
|
882
|
+
# Calculate area threshold
|
|
883
|
+
area_threshold = np.percentile(object_areas, self.area_filter_quartile*100.0)
|
|
884
|
+
|
|
885
|
+
# Keep only objects above threshold
|
|
886
|
+
object_ids_keep = xr.where(object_areas >= area_threshold, object_ids, -1)
|
|
887
|
+
object_ids_keep[0] = -1 # Don't keep ID=0
|
|
888
|
+
|
|
889
|
+
# Create filtered binary data
|
|
890
|
+
data_bin_filtered = object_id_field.isin(object_ids_keep)
|
|
891
|
+
|
|
892
|
+
# Count objects after filtering
|
|
893
|
+
N_objects_filtered = object_ids_keep.where(object_ids_keep > 0).count().item()
|
|
894
|
+
|
|
895
|
+
return data_bin_filtered, area_threshold, object_areas, N_objects_unfiltered, N_objects_filtered
|
|
896
|
+
|
|
897
|
+
# ============================
|
|
898
|
+
# Object Identification Methods
|
|
899
|
+
# ============================
|
|
900
|
+
|
|
901
|
+
def identify_objects(self, data_bin, time_connectivity):
|
|
902
|
+
"""
|
|
903
|
+
Identify connected regions in binary data.
|
|
904
|
+
|
|
905
|
+
Parameters
|
|
906
|
+
----------
|
|
907
|
+
data_bin : xarray.DataArray
|
|
908
|
+
Binary data to identify objects in
|
|
909
|
+
time_connectivity : bool
|
|
910
|
+
Whether to connect objects across time
|
|
911
|
+
|
|
912
|
+
Returns
|
|
913
|
+
-------
|
|
914
|
+
object_id_field : xarray.DataArray
|
|
915
|
+
Field of integer IDs for each object
|
|
916
|
+
None : NoneType
|
|
917
|
+
Placeholder for compatibility with track_objects
|
|
918
|
+
N_objects : int
|
|
919
|
+
Number of objects identified
|
|
920
|
+
"""
|
|
921
|
+
if self.unstructured_grid:
|
|
922
|
+
# The resulting ID field for unstructured grid will start at 0 for each time-slice,
|
|
923
|
+
# which differs from structured grid where IDs are unique across time.
|
|
924
|
+
|
|
925
|
+
if time_connectivity:
|
|
926
|
+
raise ValueError('Cannot automatically compute time-connectivity on unstructured grid')
|
|
927
|
+
|
|
928
|
+
# Use Union-Find (Disjoint Set Union) clustering for unstructured grid
|
|
929
|
+
def cluster_true_values(arr, neighbours_int):
|
|
930
|
+
"""Cluster connected True values in binary data on unstructured grid."""
|
|
931
|
+
t, n = arr.shape
|
|
932
|
+
labels = np.full((t, n), -1, dtype=np.int32)
|
|
933
|
+
|
|
934
|
+
for i in range(t):
|
|
935
|
+
# Get indices of True values
|
|
936
|
+
true_indices = np.where(arr[i])[0]
|
|
937
|
+
mapping = {old_idx: new_idx for new_idx, old_idx in enumerate(true_indices)}
|
|
938
|
+
|
|
939
|
+
# Find connected components
|
|
940
|
+
valid_mask = (neighbours_int != -1) & arr[i][neighbours_int]
|
|
941
|
+
row_ind, col_ind = np.where(valid_mask)
|
|
942
|
+
|
|
943
|
+
# Map to compact indices for graph algorithm
|
|
944
|
+
mapped_row_ind = []
|
|
945
|
+
mapped_col_ind = []
|
|
946
|
+
for r, c in zip(neighbours_int[row_ind, col_ind], col_ind):
|
|
947
|
+
if r in mapping and c in mapping:
|
|
948
|
+
mapped_row_ind.append(mapping[r])
|
|
949
|
+
mapped_col_ind.append(mapping[c])
|
|
950
|
+
|
|
951
|
+
# Create graph and find connected components
|
|
952
|
+
graph = csr_matrix(
|
|
953
|
+
(np.ones(len(mapped_row_ind)), (mapped_row_ind, mapped_col_ind)),
|
|
954
|
+
shape=(len(true_indices), len(true_indices))
|
|
955
|
+
)
|
|
956
|
+
_, labels_true = connected_components(csgraph=graph, directed=False, return_labels=True)
|
|
957
|
+
labels[i, true_indices] = labels_true
|
|
958
|
+
|
|
959
|
+
return labels + 1 # Add 1 so 0 represents no object
|
|
960
|
+
|
|
961
|
+
# Apply mask and cluster
|
|
962
|
+
data_bin = data_bin.where(self.mask, other=False)
|
|
963
|
+
|
|
964
|
+
object_id_field = xr.apply_ufunc(
|
|
965
|
+
cluster_true_values,
|
|
966
|
+
data_bin,
|
|
967
|
+
self.neighbours_int,
|
|
968
|
+
input_core_dims=[[self.xdim], ['nv', self.xdim]],
|
|
969
|
+
output_core_dims=[[self.xdim]],
|
|
970
|
+
output_dtypes=[np.int32],
|
|
971
|
+
dask_gufunc_kwargs={'output_sizes': {self.xdim: data_bin.sizes[self.xdim]}},
|
|
972
|
+
vectorize=False,
|
|
973
|
+
dask='parallelized'
|
|
974
|
+
)
|
|
975
|
+
|
|
976
|
+
# Ensure ID = 0 on invalid regions
|
|
977
|
+
object_id_field = object_id_field.where(self.mask, other=0)
|
|
978
|
+
object_id_field = object_id_field.persist()
|
|
979
|
+
object_id_field = object_id_field.rename('ID_field')
|
|
980
|
+
N_objects = 1 # Placeholder (IDs aren't unique across time)
|
|
981
|
+
|
|
982
|
+
else: # Structured Grid
|
|
983
|
+
# Create connectivity kernel for labeling
|
|
984
|
+
neighbours = np.zeros((3, 3, 3))
|
|
985
|
+
|
|
986
|
+
if time_connectivity:
|
|
987
|
+
# ID objects in 3D (i.e. space & time) -- N.B. IDs are unique across time
|
|
988
|
+
neighbours[:, :, :] = 1 # including +-1 in time, _and also diagonal in time_ -- i.e. edges can touch
|
|
989
|
+
else:
|
|
990
|
+
# ID objects only in 2D (i.e. space) -- N.B. IDs are _not_ unique across time (i.e. each time starts at 0 again)
|
|
991
|
+
neighbours[1, :, :] = 1 # All 8 neighbours, but ignore time
|
|
992
|
+
|
|
993
|
+
# Cluster & label binary data
|
|
994
|
+
object_id_field, N_objects = label( # Apply dask-powered ndimage & persist in memory
|
|
995
|
+
data_bin,
|
|
996
|
+
structure=neighbours,
|
|
997
|
+
wrap_axes=(2,) # Wrap in x-direction
|
|
998
|
+
)
|
|
999
|
+
results = persist(object_id_field, N_objects)
|
|
1000
|
+
object_id_field, N_objects = results
|
|
1001
|
+
|
|
1002
|
+
N_objects = N_objects.compute()
|
|
1003
|
+
|
|
1004
|
+
# Convert to DataArray with same coordinates as input
|
|
1005
|
+
object_id_field = xr.DataArray(
|
|
1006
|
+
object_id_field,
|
|
1007
|
+
coords=data_bin.coords,
|
|
1008
|
+
dims=data_bin.dims,
|
|
1009
|
+
attrs=data_bin.attrs
|
|
1010
|
+
).rename('ID_field').astype(np.int32)
|
|
1011
|
+
|
|
1012
|
+
return object_id_field, None, N_objects
|
|
1013
|
+
|
|
1014
|
+
def calculate_centroid(self, binary_mask, original_centroid=None):
|
|
1015
|
+
"""
|
|
1016
|
+
Calculate object centroid, handling edge cases for periodic boundaries.
|
|
1017
|
+
|
|
1018
|
+
Parameters
|
|
1019
|
+
----------
|
|
1020
|
+
binary_mask : numpy.ndarray
|
|
1021
|
+
2D binary array where True indicates the object (dimensions are (y,x))
|
|
1022
|
+
original_centroid : tuple, optional
|
|
1023
|
+
(y_centroid, x_centroid) from regionprops_table
|
|
1024
|
+
|
|
1025
|
+
Returns
|
|
1026
|
+
-------
|
|
1027
|
+
tuple
|
|
1028
|
+
(y_centroid, x_centroid)
|
|
1029
|
+
"""
|
|
1030
|
+
# Check if object is near either edge of x dimension
|
|
1031
|
+
near_left_BC = np.any(binary_mask[:, :100])
|
|
1032
|
+
near_right_BC = np.any(binary_mask[:, -100:])
|
|
1033
|
+
|
|
1034
|
+
if original_centroid is None:
|
|
1035
|
+
# Calculate y centroid from scratch
|
|
1036
|
+
y_indices = np.nonzero(binary_mask)[0]
|
|
1037
|
+
y_centroid = np.mean(y_indices)
|
|
1038
|
+
else:
|
|
1039
|
+
y_centroid = original_centroid[0]
|
|
1040
|
+
|
|
1041
|
+
# If object is near both edges, recalculate x-centroid to handle wrapping
|
|
1042
|
+
# N.B.: We calculate _near_ rather than touching, to catch the edge case where the object may be split and straddling the boundary !
|
|
1043
|
+
if near_left_BC and near_right_BC:
|
|
1044
|
+
# Adjust x coordinates that are near right edge
|
|
1045
|
+
x_indices = np.nonzero(binary_mask)[1]
|
|
1046
|
+
x_indices_adj = x_indices.copy()
|
|
1047
|
+
right_side = x_indices > binary_mask.shape[1] // 2
|
|
1048
|
+
x_indices_adj[right_side] -= binary_mask.shape[1]
|
|
1049
|
+
|
|
1050
|
+
x_centroid = np.mean(x_indices_adj)
|
|
1051
|
+
if x_centroid < 0: # Ensure centroid is positive
|
|
1052
|
+
x_centroid += binary_mask.shape[1]
|
|
1053
|
+
|
|
1054
|
+
elif original_centroid is None:
|
|
1055
|
+
# Calculate x-centroid from scratch
|
|
1056
|
+
x_indices = np.nonzero(binary_mask)[1]
|
|
1057
|
+
x_centroid = np.mean(x_indices)
|
|
1058
|
+
|
|
1059
|
+
else:
|
|
1060
|
+
x_centroid = original_centroid[1]
|
|
1061
|
+
|
|
1062
|
+
return (y_centroid, x_centroid)
|
|
1063
|
+
|
|
1064
|
+
def calculate_object_properties(self, object_id_field, properties=None):
|
|
1065
|
+
"""
|
|
1066
|
+
Calculate properties of objects from ID field.
|
|
1067
|
+
|
|
1068
|
+
Parameters
|
|
1069
|
+
----------
|
|
1070
|
+
object_id_field : xarray.DataArray
|
|
1071
|
+
Field containing object IDs
|
|
1072
|
+
properties : list, optional
|
|
1073
|
+
List of properties to calculate (defaults to ['label', 'area'])
|
|
1074
|
+
|
|
1075
|
+
Returns
|
|
1076
|
+
-------
|
|
1077
|
+
object_props : xarray.Dataset
|
|
1078
|
+
Dataset containing calculated properties with 'ID' dimension
|
|
1079
|
+
"""
|
|
1080
|
+
# Set default properties
|
|
1081
|
+
if properties is None:
|
|
1082
|
+
properties = ['label', 'area']
|
|
1083
|
+
|
|
1084
|
+
# Ensure 'label' is included
|
|
1085
|
+
if 'label' not in properties:
|
|
1086
|
+
properties = ['label'] + properties # 'label' is actually 'ID' within regionprops
|
|
1087
|
+
|
|
1088
|
+
check_centroids = 'centroid' in properties
|
|
1089
|
+
|
|
1090
|
+
if self.unstructured_grid:
|
|
1091
|
+
# Compute properties on unstructured grid
|
|
1092
|
+
|
|
1093
|
+
# Convert lat/lon to radians
|
|
1094
|
+
lat_rad = np.radians(self.lat)
|
|
1095
|
+
lon_rad = np.radians(self.lon)
|
|
1096
|
+
|
|
1097
|
+
# Calculate buffer size for IDs in chunks
|
|
1098
|
+
max_ID = object_id_field.max().compute().item() + 1
|
|
1099
|
+
ID_buffer_size = int(max_ID / object_id_field[self.timedim].shape[0]) * 4
|
|
1100
|
+
|
|
1101
|
+
def object_properties_chunk(ids, lat, lon, area, buffer_IDs=True):
|
|
1102
|
+
"""
|
|
1103
|
+
Calculate object properties for a chunk of data.
|
|
1104
|
+
Uses vectorised operations for efficiency.
|
|
1105
|
+
"""
|
|
1106
|
+
# Find valid IDs
|
|
1107
|
+
valid_mask = ids > 0
|
|
1108
|
+
ids_chunk = np.unique(ids[valid_mask])
|
|
1109
|
+
n_ids = len(ids_chunk)
|
|
1110
|
+
|
|
1111
|
+
if n_ids == 0:
|
|
1112
|
+
# No objects in this chunk
|
|
1113
|
+
if buffer_IDs:
|
|
1114
|
+
result = np.zeros((3, ID_buffer_size), dtype=np.float32)
|
|
1115
|
+
padded_ids = np.zeros(ID_buffer_size, dtype=np.int32)
|
|
1116
|
+
return result, padded_ids
|
|
1117
|
+
else:
|
|
1118
|
+
result = np.zeros((3, 0), dtype=np.float32)
|
|
1119
|
+
padded_ids = np.array([], dtype=np.int32)
|
|
1120
|
+
return result, padded_ids
|
|
1121
|
+
|
|
1122
|
+
# Map IDs to consecutive indices
|
|
1123
|
+
mapped_indices = np.searchsorted(ids_chunk, ids[valid_mask])
|
|
1124
|
+
|
|
1125
|
+
# Pre-allocate arrays
|
|
1126
|
+
areas = np.zeros(n_ids, dtype=np.float32)
|
|
1127
|
+
weighted_x = np.zeros(n_ids, dtype=np.float32)
|
|
1128
|
+
weighted_y = np.zeros(n_ids, dtype=np.float32)
|
|
1129
|
+
weighted_z = np.zeros(n_ids, dtype=np.float32)
|
|
1130
|
+
|
|
1131
|
+
# Convert to Cartesian for centroid calculation
|
|
1132
|
+
cos_lat = np.cos(lat[valid_mask])
|
|
1133
|
+
x = cos_lat * np.cos(lon[valid_mask])
|
|
1134
|
+
y = cos_lat * np.sin(lon[valid_mask])
|
|
1135
|
+
z = np.sin(lat[valid_mask])
|
|
1136
|
+
|
|
1137
|
+
# Compute areas
|
|
1138
|
+
valid_areas = area[valid_mask]
|
|
1139
|
+
np.add.at(areas, mapped_indices, valid_areas)
|
|
1140
|
+
|
|
1141
|
+
# Compute weighted coordinates
|
|
1142
|
+
np.add.at(weighted_x, mapped_indices, valid_areas * x)
|
|
1143
|
+
np.add.at(weighted_y, mapped_indices, valid_areas * y)
|
|
1144
|
+
np.add.at(weighted_z, mapped_indices, valid_areas * z)
|
|
1145
|
+
|
|
1146
|
+
# Clean intermediate arrays
|
|
1147
|
+
del x, y, z, cos_lat, valid_areas
|
|
1148
|
+
|
|
1149
|
+
# Normalise vectors
|
|
1150
|
+
norm = np.sqrt(weighted_x**2 + weighted_y**2 + weighted_z**2)
|
|
1151
|
+
norm = np.where(norm > 0, norm, 1) # Avoid division by zero
|
|
1152
|
+
|
|
1153
|
+
weighted_x /= norm
|
|
1154
|
+
weighted_y /= norm
|
|
1155
|
+
weighted_z /= norm
|
|
1156
|
+
|
|
1157
|
+
# Convert back to lat/lon
|
|
1158
|
+
centroid_lat = np.degrees(np.arcsin(np.clip(weighted_z, -1, 1)))
|
|
1159
|
+
centroid_lon = np.degrees(np.arctan2(weighted_y, weighted_x))
|
|
1160
|
+
|
|
1161
|
+
# Fix longitude range to [-180, 180]
|
|
1162
|
+
centroid_lon = np.where(
|
|
1163
|
+
centroid_lon > 180., centroid_lon - 360.,
|
|
1164
|
+
np.where(centroid_lon < -180., centroid_lon + 360., centroid_lon)
|
|
1165
|
+
)
|
|
1166
|
+
|
|
1167
|
+
if buffer_IDs:
|
|
1168
|
+
# Create padded output arrays
|
|
1169
|
+
result = np.zeros((3, ID_buffer_size), dtype=np.float32)
|
|
1170
|
+
padded_ids = np.zeros(ID_buffer_size, dtype=np.int32)
|
|
1171
|
+
|
|
1172
|
+
# Fill arrays up to n_ids
|
|
1173
|
+
result[0, :n_ids] = areas
|
|
1174
|
+
result[1, :n_ids] = centroid_lat
|
|
1175
|
+
result[2, :n_ids] = centroid_lon
|
|
1176
|
+
padded_ids[:n_ids] = ids_chunk
|
|
1177
|
+
else:
|
|
1178
|
+
result = np.vstack((areas, centroid_lat, centroid_lon))
|
|
1179
|
+
padded_ids = ids_chunk
|
|
1180
|
+
|
|
1181
|
+
return result, padded_ids
|
|
1182
|
+
|
|
1183
|
+
# Process single time or multiple times
|
|
1184
|
+
if object_id_field[self.timedim].size == 1:
|
|
1185
|
+
props_np, ids = object_properties_chunk(
|
|
1186
|
+
object_id_field.values,
|
|
1187
|
+
lat_rad.values,
|
|
1188
|
+
lon_rad.values,
|
|
1189
|
+
self.cell_area.values,
|
|
1190
|
+
buffer_IDs=False
|
|
1191
|
+
)
|
|
1192
|
+
props = xr.DataArray(props_np, dims=['prop', 'out_id'])
|
|
1193
|
+
|
|
1194
|
+
else:
|
|
1195
|
+
# Process in parallel
|
|
1196
|
+
props_buffer, ids_buffer = xr.apply_ufunc(
|
|
1197
|
+
object_properties_chunk,
|
|
1198
|
+
object_id_field,
|
|
1199
|
+
lat_rad,
|
|
1200
|
+
lon_rad,
|
|
1201
|
+
self.cell_area,
|
|
1202
|
+
input_core_dims=[[self.xdim], [self.xdim], [self.xdim], [self.xdim]],
|
|
1203
|
+
output_core_dims=[['prop', 'out_id'], ['out_id']],
|
|
1204
|
+
output_dtypes=[np.float32, np.int32],
|
|
1205
|
+
dask_gufunc_kwargs={'output_sizes': {'prop': 3, 'out_id': ID_buffer_size}},
|
|
1206
|
+
vectorize=True,
|
|
1207
|
+
dask='parallelized'
|
|
1208
|
+
)
|
|
1209
|
+
results = persist(props_buffer, ids_buffer)
|
|
1210
|
+
props_buffer, ids_buffer = results
|
|
1211
|
+
ids_buffer = ids_buffer.compute().values.reshape(-1)
|
|
1212
|
+
|
|
1213
|
+
# Get valid IDs (non-zero)
|
|
1214
|
+
valid_ids_mask = ids_buffer > 0
|
|
1215
|
+
|
|
1216
|
+
# Check if we have any valid IDs before stacking
|
|
1217
|
+
if np.any(valid_ids_mask):
|
|
1218
|
+
ids = ids_buffer[valid_ids_mask]
|
|
1219
|
+
props = props_buffer.stack(combined=('time', 'out_id')).isel(combined=valid_ids_mask)
|
|
1220
|
+
else:
|
|
1221
|
+
# No valid IDs found
|
|
1222
|
+
ids = np.array([], dtype=np.int32)
|
|
1223
|
+
props = xr.DataArray(np.zeros((3, 0), dtype=np.float32), dims=['prop', 'out_id'])
|
|
1224
|
+
|
|
1225
|
+
# Create object properties dataset
|
|
1226
|
+
if len(ids) > 0:
|
|
1227
|
+
object_props = xr.Dataset(
|
|
1228
|
+
{
|
|
1229
|
+
'area': ('out_id', props.isel(prop=0).data),
|
|
1230
|
+
'centroid-0': ('out_id', props.isel(prop=1).data),
|
|
1231
|
+
'centroid-1': ('out_id', props.isel(prop=2).data)
|
|
1232
|
+
},
|
|
1233
|
+
coords={'ID': ('out_id', ids)}
|
|
1234
|
+
).set_index(out_id='ID').rename({'out_id': 'ID'})
|
|
1235
|
+
else:
|
|
1236
|
+
# Create empty dataset with correct structure
|
|
1237
|
+
object_props = xr.Dataset(
|
|
1238
|
+
{
|
|
1239
|
+
'area': ('ID', []),
|
|
1240
|
+
'centroid-0': ('ID', []),
|
|
1241
|
+
'centroid-1': ('ID', [])
|
|
1242
|
+
},
|
|
1243
|
+
coords={'ID': []}
|
|
1244
|
+
)
|
|
1245
|
+
|
|
1246
|
+
else:
|
|
1247
|
+
# Structured grid approach
|
|
1248
|
+
# N.B.: These operations are simply done on a pixel grid — no cartesian conversion (therefore, polar regions are doubly biased)
|
|
1249
|
+
|
|
1250
|
+
# Define function to calculate properties for each chunk
|
|
1251
|
+
def object_properties_chunk(ids):
|
|
1252
|
+
"""Calculate object properties for a chunk of data."""
|
|
1253
|
+
# Use regionprops_table for standard properties
|
|
1254
|
+
props_slice = regionprops_table(ids, properties=properties)
|
|
1255
|
+
|
|
1256
|
+
# Handle centroid calculation for objects that wrap around edges
|
|
1257
|
+
if check_centroids and len(props_slice['label']) > 0:
|
|
1258
|
+
# Get original centroids
|
|
1259
|
+
centroids = list(zip(props_slice['centroid-0'], props_slice['centroid-1']))
|
|
1260
|
+
centroids_wrapped = []
|
|
1261
|
+
|
|
1262
|
+
# Process each object
|
|
1263
|
+
for ID_idx, ID in enumerate(props_slice['label']):
|
|
1264
|
+
binary_mask = ids == ID
|
|
1265
|
+
centroids_wrapped.append(
|
|
1266
|
+
self.calculate_centroid(binary_mask, centroids[ID_idx])
|
|
1267
|
+
)
|
|
1268
|
+
|
|
1269
|
+
# Update centroid values
|
|
1270
|
+
props_slice['centroid-0'] = [c[0] for c in centroids_wrapped]
|
|
1271
|
+
props_slice['centroid-1'] = [c[1] for c in centroids_wrapped]
|
|
1272
|
+
|
|
1273
|
+
return props_slice
|
|
1274
|
+
|
|
1275
|
+
# Process single time or multiple times
|
|
1276
|
+
if object_id_field[self.timedim].size == 1:
|
|
1277
|
+
object_props = object_properties_chunk(object_id_field.values)
|
|
1278
|
+
object_props = xr.Dataset({key: (['ID'], value) for key, value in object_props.items()})
|
|
1279
|
+
else:
|
|
1280
|
+
# Run in parallel
|
|
1281
|
+
object_props = xr.apply_ufunc(
|
|
1282
|
+
object_properties_chunk,
|
|
1283
|
+
object_id_field,
|
|
1284
|
+
input_core_dims=[[self.ydim, self.xdim]],
|
|
1285
|
+
output_core_dims=[[]],
|
|
1286
|
+
output_dtypes=[object],
|
|
1287
|
+
vectorize=True,
|
|
1288
|
+
dask='parallelized'
|
|
1289
|
+
)
|
|
1290
|
+
|
|
1291
|
+
# Concatenate and convert to dataset
|
|
1292
|
+
object_props = xr.concat([
|
|
1293
|
+
xr.Dataset({key: (['ID'], value) for key, value in item.items()})
|
|
1294
|
+
for item in object_props.values
|
|
1295
|
+
], dim='ID')
|
|
1296
|
+
|
|
1297
|
+
# Set ID as coordinate
|
|
1298
|
+
object_props = object_props.set_index(ID='label')
|
|
1299
|
+
|
|
1300
|
+
# Combine centroid components into a single variable
|
|
1301
|
+
if 'centroid' in properties and len(object_props.ID) > 0:
|
|
1302
|
+
object_props['centroid'] = xr.concat(
|
|
1303
|
+
[object_props['centroid-0'], object_props['centroid-1']],
|
|
1304
|
+
dim='component'
|
|
1305
|
+
)
|
|
1306
|
+
object_props = object_props.drop_vars(['centroid-0', 'centroid-1'])
|
|
1307
|
+
|
|
1308
|
+
return object_props
|
|
1309
|
+
|
|
1310
|
+
# ============================
|
|
1311
|
+
# Overlap and Tracking Methods
|
|
1312
|
+
# ============================
|
|
1313
|
+
|
|
1314
|
+
def check_overlap_slice(self, ids_t0, ids_next):
|
|
1315
|
+
"""
|
|
1316
|
+
Find overlapping objects between two consecutive time slices.
|
|
1317
|
+
|
|
1318
|
+
Parameters
|
|
1319
|
+
----------
|
|
1320
|
+
ids_t0 : numpy.ndarray
|
|
1321
|
+
Object IDs at current time
|
|
1322
|
+
ids_next : numpy.ndarray
|
|
1323
|
+
Object IDs at next time
|
|
1324
|
+
|
|
1325
|
+
Returns
|
|
1326
|
+
-------
|
|
1327
|
+
numpy.ndarray
|
|
1328
|
+
Array of shape (n_overlaps, 3) with [id_t0, id_next, overlap_area]
|
|
1329
|
+
"""
|
|
1330
|
+
# Create masks for valid IDs
|
|
1331
|
+
mask_t0 = ids_t0 > 0
|
|
1332
|
+
mask_next = ids_next > 0
|
|
1333
|
+
|
|
1334
|
+
# Only process cells where both times have valid IDs
|
|
1335
|
+
combined_mask = mask_t0 & mask_next
|
|
1336
|
+
|
|
1337
|
+
if not np.any(combined_mask):
|
|
1338
|
+
return np.empty((0, 3), dtype=np.float32 if self.unstructured_grid else np.int32)
|
|
1339
|
+
|
|
1340
|
+
# Extract the overlapping points
|
|
1341
|
+
ids_t0_valid = ids_t0[combined_mask]
|
|
1342
|
+
ids_next_valid = ids_next[combined_mask]
|
|
1343
|
+
|
|
1344
|
+
# Create a unique identifier for each pair
|
|
1345
|
+
# This is faster than using np.unique with axis=1
|
|
1346
|
+
max_id = max(ids_t0.max(), ids_next.max() + 1).astype(np.int64)
|
|
1347
|
+
pair_ids = ids_t0_valid.astype(np.int64) * max_id + ids_next_valid.astype(np.int64)
|
|
1348
|
+
|
|
1349
|
+
if self.unstructured_grid:
|
|
1350
|
+
# Get unique pairs and their inverse indices
|
|
1351
|
+
unique_pairs, inverse_indices = np.unique(pair_ids, return_inverse=True)
|
|
1352
|
+
|
|
1353
|
+
# Sum areas for overlapping cells
|
|
1354
|
+
areas_valid = self.cell_area.values[combined_mask]
|
|
1355
|
+
areas = np.zeros(len(unique_pairs), dtype=np.float32)
|
|
1356
|
+
np.add.at(areas, inverse_indices, areas_valid)
|
|
1357
|
+
else:
|
|
1358
|
+
# Get unique pairs and their counts (pixel counts)
|
|
1359
|
+
unique_pairs, areas = np.unique(pair_ids, return_counts=True)
|
|
1360
|
+
areas = areas.astype(np.int32)
|
|
1361
|
+
|
|
1362
|
+
# Convert back to original ID pairs
|
|
1363
|
+
id_t0 = (unique_pairs // max_id).astype(np.int32)
|
|
1364
|
+
id_next = (unique_pairs % max_id).astype(np.int32)
|
|
1365
|
+
|
|
1366
|
+
# Stack results
|
|
1367
|
+
result = np.column_stack((id_t0, id_next, areas))
|
|
1368
|
+
|
|
1369
|
+
return result
|
|
1370
|
+
|
|
1371
|
+
def find_overlapping_objects(self, object_id_field):
|
|
1372
|
+
"""
|
|
1373
|
+
Find all overlapping objects across time.
|
|
1374
|
+
|
|
1375
|
+
Parameters
|
|
1376
|
+
----------
|
|
1377
|
+
object_id_field : xarray.DataArray
|
|
1378
|
+
Field containing object IDs
|
|
1379
|
+
|
|
1380
|
+
Returns
|
|
1381
|
+
-------
|
|
1382
|
+
overlap_objects_list_unique_filtered : (N x 3) numpy.ndarray
|
|
1383
|
+
Array of object ID pairs that overlap across time, with overlap area
|
|
1384
|
+
The object in the first column precedes the second column in time.
|
|
1385
|
+
The third column contains:
|
|
1386
|
+
- For structured grid: number of overlapping pixels (int32)
|
|
1387
|
+
- For unstructured grid: total overlapping area in m^2 (float32)
|
|
1388
|
+
"""
|
|
1389
|
+
## Check just for overlap with next time slice.
|
|
1390
|
+
# Keep a running list of all object IDs that overlap
|
|
1391
|
+
object_id_field_next = object_id_field.shift({self.timedim: -1}, fill_value=0)
|
|
1392
|
+
|
|
1393
|
+
# Calculate overlaps in parallel
|
|
1394
|
+
input_dims = [self.xdim] if self.unstructured_grid else [self.ydim, self.xdim]
|
|
1395
|
+
overlap_object_pairs_list = xr.apply_ufunc(
|
|
1396
|
+
self.check_overlap_slice,
|
|
1397
|
+
object_id_field,
|
|
1398
|
+
object_id_field_next,
|
|
1399
|
+
input_core_dims=[input_dims, input_dims],
|
|
1400
|
+
output_core_dims=[[]],
|
|
1401
|
+
vectorize=True,
|
|
1402
|
+
dask="parallelized",
|
|
1403
|
+
output_dtypes=[object]
|
|
1404
|
+
).persist()
|
|
1405
|
+
|
|
1406
|
+
# Concatenate all pairs from different chunks
|
|
1407
|
+
all_pairs_with_areas = np.concatenate(overlap_object_pairs_list.values)
|
|
1408
|
+
|
|
1409
|
+
# Get unique pairs and their indices
|
|
1410
|
+
unique_pairs, inverse_indices = np.unique(all_pairs_with_areas[:, :2], axis=0, return_inverse=True)
|
|
1411
|
+
|
|
1412
|
+
# Sum the overlap areas using the inverse indices
|
|
1413
|
+
output_dtype = np.float32 if self.unstructured_grid else np.int32
|
|
1414
|
+
total_summed_areas = np.zeros(len(unique_pairs), dtype=output_dtype)
|
|
1415
|
+
np.add.at(total_summed_areas, inverse_indices, all_pairs_with_areas[:, 2])
|
|
1416
|
+
|
|
1417
|
+
# Stack the pairs with their summed areas
|
|
1418
|
+
overlap_objects_list_unique = np.column_stack((unique_pairs, total_summed_areas))
|
|
1419
|
+
|
|
1420
|
+
return overlap_objects_list_unique
|
|
1421
|
+
|
|
1422
|
+
def enforce_overlap_threshold(self, overlap_objects_list, object_props):
|
|
1423
|
+
"""
|
|
1424
|
+
Filter object pairs based on overlap threshold.
|
|
1425
|
+
|
|
1426
|
+
Parameters
|
|
1427
|
+
----------
|
|
1428
|
+
overlap_objects_list : (N x 3) numpy.ndarray
|
|
1429
|
+
Array of object ID pairs with overlap area
|
|
1430
|
+
object_props : xarray.Dataset
|
|
1431
|
+
Object properties including area
|
|
1432
|
+
|
|
1433
|
+
Returns
|
|
1434
|
+
-------
|
|
1435
|
+
overlap_objects_list_filtered : (M x 3) numpy.ndarray
|
|
1436
|
+
Filtered array of object ID pairs that meet the overlap threshold
|
|
1437
|
+
"""
|
|
1438
|
+
if len(overlap_objects_list) == 0:
|
|
1439
|
+
return np.empty((0, 3), dtype=np.float32 if self.unstructured_grid else np.int32)
|
|
1440
|
+
|
|
1441
|
+
# Calculate overlap fractions
|
|
1442
|
+
areas_0 = object_props['area'].sel(ID=overlap_objects_list[:, 0]).values
|
|
1443
|
+
areas_1 = object_props['area'].sel(ID=overlap_objects_list[:, 1]).values
|
|
1444
|
+
min_areas = np.minimum(areas_0, areas_1)
|
|
1445
|
+
overlap_fractions = overlap_objects_list[:, 2].astype(float) / min_areas
|
|
1446
|
+
|
|
1447
|
+
# Filter by threshold
|
|
1448
|
+
overlap_objects_list_filtered = overlap_objects_list[overlap_fractions >= self.overlap_threshold]
|
|
1449
|
+
|
|
1450
|
+
return overlap_objects_list_filtered
|
|
1451
|
+
|
|
1452
|
+
def compute_id_time_dict(self, da, child_objects, max_objects, all_objects=True):
|
|
1453
|
+
"""
|
|
1454
|
+
Generate lookup table mapping object IDs to their time index.
|
|
1455
|
+
|
|
1456
|
+
Parameters
|
|
1457
|
+
----------
|
|
1458
|
+
da : xarray.DataArray
|
|
1459
|
+
Field of object IDs
|
|
1460
|
+
child_objects : list or array
|
|
1461
|
+
Object IDs to include in the dictionary
|
|
1462
|
+
max_objects : int
|
|
1463
|
+
Maximum number of objects
|
|
1464
|
+
all_objects : bool, default=True
|
|
1465
|
+
Whether to process all objects or just child_objects
|
|
1466
|
+
|
|
1467
|
+
Returns
|
|
1468
|
+
-------
|
|
1469
|
+
time_index_map : dict
|
|
1470
|
+
Dictionary mapping object IDs to time indices
|
|
1471
|
+
"""
|
|
1472
|
+
# Estimate max objects per time
|
|
1473
|
+
est_objects_per_time_max = int(max_objects / da[self.timedim].shape[0] * 100)
|
|
1474
|
+
|
|
1475
|
+
def unique_pad(x):
|
|
1476
|
+
"""Extract unique values and pad to fixed size."""
|
|
1477
|
+
uniq = np.unique(x)
|
|
1478
|
+
result = np.zeros(est_objects_per_time_max, dtype=x.dtype) # Pad output to maximum size
|
|
1479
|
+
result[:len(uniq)] = uniq
|
|
1480
|
+
return result
|
|
1481
|
+
|
|
1482
|
+
# Get unique IDs for each time slice
|
|
1483
|
+
input_dims = [self.xdim] if self.unstructured_grid else [self.ydim, self.xdim]
|
|
1484
|
+
unique_ids_by_time = xr.apply_ufunc(
|
|
1485
|
+
unique_pad,
|
|
1486
|
+
da,
|
|
1487
|
+
input_core_dims=[input_dims],
|
|
1488
|
+
output_core_dims=[['unique_values']],
|
|
1489
|
+
dask='parallelized',
|
|
1490
|
+
vectorize=True,
|
|
1491
|
+
dask_gufunc_kwargs={'output_sizes': {'unique_values': est_objects_per_time_max}}
|
|
1492
|
+
)
|
|
1493
|
+
|
|
1494
|
+
# Set up IDs to search for
|
|
1495
|
+
if not all_objects:
|
|
1496
|
+
# Just search for the specified child objects
|
|
1497
|
+
search_ids = xr.DataArray(
|
|
1498
|
+
child_objects,
|
|
1499
|
+
dims=['child_id'],
|
|
1500
|
+
coords={'child_id': child_objects}
|
|
1501
|
+
)
|
|
1502
|
+
else:
|
|
1503
|
+
# Search for all possible IDs
|
|
1504
|
+
search_ids = xr.DataArray(
|
|
1505
|
+
np.arange(max_objects, dtype=np.int32),
|
|
1506
|
+
dims=['child_id'],
|
|
1507
|
+
coords={'child_id': np.arange(max_objects)}
|
|
1508
|
+
).chunk({'child_id': 10000}) # Chunk for better parallelism
|
|
1509
|
+
|
|
1510
|
+
# Find the first time index where each ID appears
|
|
1511
|
+
time_indices = ((unique_ids_by_time == search_ids)
|
|
1512
|
+
.any(dim=['unique_values'])
|
|
1513
|
+
.argmax(dim=self.timedim).compute())
|
|
1514
|
+
|
|
1515
|
+
# Convert to dictionary for fast lookup
|
|
1516
|
+
time_index_map = {
|
|
1517
|
+
int(id_val): int(idx.values)
|
|
1518
|
+
for id_val, idx in zip(time_indices.child_id, time_indices)
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
return time_index_map
|
|
1522
|
+
|
|
1523
|
+
# ============================
|
|
1524
|
+
# Event Tracking Methods
|
|
1525
|
+
# ============================
|
|
1526
|
+
|
|
1527
|
+
def track_objects(self, data_bin):
|
|
1528
|
+
"""
|
|
1529
|
+
Track objects through time to form events.
|
|
1530
|
+
|
|
1531
|
+
This is the main tracking method that handles splitting and merging of objects.
|
|
1532
|
+
|
|
1533
|
+
Parameters
|
|
1534
|
+
----------
|
|
1535
|
+
data_bin : xarray.DataArray
|
|
1536
|
+
Preprocessed binary data: Field of globally unique integer IDs of each element in connected regions. ID = 0 indicates no object.
|
|
1537
|
+
|
|
1538
|
+
Returns
|
|
1539
|
+
-------
|
|
1540
|
+
split_merged_events_ds : xarray.Dataset
|
|
1541
|
+
Dataset containing tracked events
|
|
1542
|
+
merge_events : xarray.Dataset
|
|
1543
|
+
Dataset with merge information
|
|
1544
|
+
N_events : int
|
|
1545
|
+
Final number of events
|
|
1546
|
+
"""
|
|
1547
|
+
# Identify objects at each time step
|
|
1548
|
+
object_id_field, _, _ = self.identify_objects(data_bin, time_connectivity=False)
|
|
1549
|
+
object_id_field = object_id_field.persist()
|
|
1550
|
+
del data_bin
|
|
1551
|
+
if self.verbosity > 0:
|
|
1552
|
+
print('Finished object identification')
|
|
1553
|
+
|
|
1554
|
+
# For unstructured grid, make objects unique across time
|
|
1555
|
+
if self.unstructured_grid:
|
|
1556
|
+
cumsum_ids = (object_id_field.max(dim=self.xdim)).cumsum(self.timedim).shift({self.timedim: 1}, fill_value=0)
|
|
1557
|
+
object_id_field = xr.where(object_id_field > 0, object_id_field + cumsum_ids, 0)
|
|
1558
|
+
object_id_field = self.refresh_dask_graph(object_id_field)
|
|
1559
|
+
if self.verbosity > 0:
|
|
1560
|
+
print(f'Finished assigning c. {cumsum_ids.max().compute().values} globally unique object IDs')
|
|
1561
|
+
|
|
1562
|
+
# Calculate object properties
|
|
1563
|
+
object_props = self.calculate_object_properties(object_id_field, properties=['area', 'centroid'])
|
|
1564
|
+
object_props = object_props.persist()
|
|
1565
|
+
wait(object_props)
|
|
1566
|
+
if self.verbosity > 0:
|
|
1567
|
+
print('Finished calculating object properties')
|
|
1568
|
+
|
|
1569
|
+
# Apply splitting & merging logic
|
|
1570
|
+
# This is the most intricate step due to non-trivial loop-wise dependencies
|
|
1571
|
+
# In v2.0_unstruct, this loop has been painstakingly parallelised
|
|
1572
|
+
split_and_merge = self.split_and_merge_objects_parallel if self.unstructured_grid else self.split_and_merge_objects
|
|
1573
|
+
object_id_field, object_props, overlap_objects_list, merge_events = split_and_merge(object_id_field, object_props)
|
|
1574
|
+
if self.verbosity > 0:
|
|
1575
|
+
print('Finished splitting and merging objects')
|
|
1576
|
+
|
|
1577
|
+
# Persist results (This helps avoid block-wise task fusion run_spec issues with dask)
|
|
1578
|
+
results = persist(object_id_field, object_props, overlap_objects_list, merge_events)
|
|
1579
|
+
object_id_field, object_props, overlap_objects_list, merge_events = results
|
|
1580
|
+
|
|
1581
|
+
# Cluster & rename objects to get globally unique event IDs
|
|
1582
|
+
split_merged_events_ds = self.cluster_rename_objects_and_props(
|
|
1583
|
+
object_id_field, object_props, overlap_objects_list, merge_events
|
|
1584
|
+
)
|
|
1585
|
+
|
|
1586
|
+
# Rechunk final output
|
|
1587
|
+
chunk_dict = {
|
|
1588
|
+
self.timedim: self.timechunks,
|
|
1589
|
+
'ID': -1,
|
|
1590
|
+
'component': -1,
|
|
1591
|
+
'sibling_ID': -1,
|
|
1592
|
+
self.xdim: -1
|
|
1593
|
+
}
|
|
1594
|
+
if not self.unstructured_grid:
|
|
1595
|
+
chunk_dict[self.ydim] = -1
|
|
1596
|
+
|
|
1597
|
+
split_merged_events_ds = split_merged_events_ds.chunk(chunk_dict)#.persist()
|
|
1598
|
+
if self.verbosity > 0:
|
|
1599
|
+
print('Finished clustering and renaming objects into coherent consistent events')
|
|
1600
|
+
|
|
1601
|
+
# Count final number of events
|
|
1602
|
+
N_events = split_merged_events_ds.ID_field.max().compute().data
|
|
1603
|
+
|
|
1604
|
+
return split_merged_events_ds, merge_events, N_events
|
|
1605
|
+
|
|
1606
|
+
def cluster_rename_objects_and_props(self, object_id_field_unique, object_props, overlap_objects_list, merge_events):
|
|
1607
|
+
"""
|
|
1608
|
+
Cluster the object pairs and relabel to determine final event IDs.
|
|
1609
|
+
|
|
1610
|
+
Parameters
|
|
1611
|
+
----------
|
|
1612
|
+
object_id_field_unique : xarray.DataArray
|
|
1613
|
+
Field of unique object IDs. IDs must not be repeated across time.
|
|
1614
|
+
object_props : xarray.Dataset
|
|
1615
|
+
Properties of each object that also need to be relabeled.
|
|
1616
|
+
overlap_objects_list : (N x 2) numpy.ndarray
|
|
1617
|
+
Array of object ID pairs that indicate which objects are in the same event. The object in the first column precedes the second column in time.
|
|
1618
|
+
merge_events : xarray.Dataset
|
|
1619
|
+
Information about merge events
|
|
1620
|
+
|
|
1621
|
+
Returns
|
|
1622
|
+
-------
|
|
1623
|
+
split_merged_events_ds : xarray.Dataset
|
|
1624
|
+
Dataset with relabeled events and their properties. ID = 0 indicates no object.
|
|
1625
|
+
"""
|
|
1626
|
+
## Cluster the overlap_pairs into groups of IDs that are actually the same object
|
|
1627
|
+
# Get IDs from overlap pairs
|
|
1628
|
+
max_ID = object_id_field_unique.max().compute().values + 1
|
|
1629
|
+
IDs = np.arange(max_ID)
|
|
1630
|
+
|
|
1631
|
+
# Convert overlap pairs to indices
|
|
1632
|
+
overlap_pairs_indices = np.array([(pair[0], pair[1]) for pair in overlap_objects_list])
|
|
1633
|
+
|
|
1634
|
+
# Create a sparse matrix representation of the graph
|
|
1635
|
+
n = max_ID
|
|
1636
|
+
row_indices, col_indices = overlap_pairs_indices.T
|
|
1637
|
+
data = np.ones(len(overlap_pairs_indices), dtype=np.bool_)
|
|
1638
|
+
graph = csr_matrix((data, (row_indices, col_indices)), shape=(n, n), dtype=np.bool_)
|
|
1639
|
+
|
|
1640
|
+
# Clean up temporary arrays
|
|
1641
|
+
del row_indices
|
|
1642
|
+
del col_indices
|
|
1643
|
+
del data
|
|
1644
|
+
|
|
1645
|
+
# Solve the graph to determine connected components
|
|
1646
|
+
num_components, component_IDs = connected_components(csgraph=graph, directed=False, return_labels=True)
|
|
1647
|
+
|
|
1648
|
+
del graph
|
|
1649
|
+
|
|
1650
|
+
# Group IDs by their component
|
|
1651
|
+
ID_clusters = [[] for _ in range(num_components)]
|
|
1652
|
+
for ID, component_ID in zip(IDs, component_IDs):
|
|
1653
|
+
ID_clusters[component_ID].append(ID)
|
|
1654
|
+
|
|
1655
|
+
## ID_clusters now is a list of lists of equivalent object IDs that have been tracked across time
|
|
1656
|
+
# We now need to replace all IDs in object_id_field_unique that match the equivalent_IDs with the list index: This is the new/final ID field.
|
|
1657
|
+
|
|
1658
|
+
# Create mapping from original IDs to cluster indices
|
|
1659
|
+
min_int32 = np.iinfo(np.int32).min
|
|
1660
|
+
max_old_ID = object_id_field_unique.max().compute().data
|
|
1661
|
+
ID_to_cluster_index_array = np.full(max_old_ID + 1, min_int32, dtype=np.int32)
|
|
1662
|
+
|
|
1663
|
+
# Fill the lookup array
|
|
1664
|
+
for index, cluster in enumerate(ID_clusters):
|
|
1665
|
+
for ID in cluster:
|
|
1666
|
+
ID_to_cluster_index_array[ID] = np.int32(index) # Because these are the connected IDs, there are many fewer!
|
|
1667
|
+
# ID = 0 is still invalid/no object
|
|
1668
|
+
|
|
1669
|
+
# Convert to DataArray for apply_ufunc
|
|
1670
|
+
# N.B.: **Need to pass da into apply_ufunc, otherwise it doesn't manage the memory correctly with large shared-mem numpy arrays**
|
|
1671
|
+
ID_to_cluster_index_da = xr.DataArray(
|
|
1672
|
+
ID_to_cluster_index_array,
|
|
1673
|
+
dims='ID',
|
|
1674
|
+
coords={'ID': np.arange(max_old_ID + 1)}
|
|
1675
|
+
)
|
|
1676
|
+
|
|
1677
|
+
def map_IDs_to_indices(block, ID_to_cluster_index_array):
|
|
1678
|
+
"""Map original IDs to cluster indices."""
|
|
1679
|
+
mask = block > 0
|
|
1680
|
+
new_block = np.zeros_like(block, dtype=np.int32)
|
|
1681
|
+
new_block[mask] = ID_to_cluster_index_array[block[mask]]
|
|
1682
|
+
return new_block
|
|
1683
|
+
|
|
1684
|
+
# Apply the mapping
|
|
1685
|
+
input_dims = [self.xdim] if self.unstructured_grid else [self.ydim, self.xdim]
|
|
1686
|
+
split_merged_relabeled_object_id_field = xr.apply_ufunc(
|
|
1687
|
+
map_IDs_to_indices,
|
|
1688
|
+
object_id_field_unique,
|
|
1689
|
+
ID_to_cluster_index_da,
|
|
1690
|
+
input_core_dims=[input_dims, ['ID']],
|
|
1691
|
+
output_core_dims=[input_dims],
|
|
1692
|
+
vectorize=True,
|
|
1693
|
+
dask="parallelized",
|
|
1694
|
+
output_dtypes=[np.int32]
|
|
1695
|
+
).persist()
|
|
1696
|
+
|
|
1697
|
+
## Relabel the object_props to match the new IDs (and add time dimension)
|
|
1698
|
+
|
|
1699
|
+
max_new_ID = num_components + 1 # New IDs range from 0 to max_new_ID
|
|
1700
|
+
new_ids = np.arange(1, max_new_ID+1, dtype=np.int32)
|
|
1701
|
+
|
|
1702
|
+
# Create new object_props dataset
|
|
1703
|
+
object_props_extended = xr.Dataset(coords={
|
|
1704
|
+
'ID': new_ids,
|
|
1705
|
+
self.timedim: object_id_field_unique[self.timedim]
|
|
1706
|
+
})
|
|
1707
|
+
|
|
1708
|
+
# Create mapping from new IDs to the original IDs _at the corresponding time_
|
|
1709
|
+
valid_new_ids = (split_merged_relabeled_object_id_field > 0)
|
|
1710
|
+
original_ids_field = object_id_field_unique.where(valid_new_ids)
|
|
1711
|
+
new_ids_field = split_merged_relabeled_object_id_field.where(valid_new_ids)
|
|
1712
|
+
|
|
1713
|
+
if not self.unstructured_grid:
|
|
1714
|
+
original_ids_field = original_ids_field.stack(z=(self.ydim, self.xdim), create_index=False)
|
|
1715
|
+
new_ids_field = new_ids_field.stack(z=(self.ydim, self.xdim), create_index=False)
|
|
1716
|
+
|
|
1717
|
+
new_id_to_idx = {id_val: idx for idx, id_val in enumerate(new_ids)}
|
|
1718
|
+
|
|
1719
|
+
def process_timestep(orig_ids, new_ids_t):
|
|
1720
|
+
"""Process a single timestep to create ID mapping."""
|
|
1721
|
+
result = np.zeros(len(new_id_to_idx), dtype=np.int32)
|
|
1722
|
+
|
|
1723
|
+
valid_mask = new_ids_t > 0
|
|
1724
|
+
|
|
1725
|
+
# Get valid points for this timestep
|
|
1726
|
+
if not valid_mask.any():
|
|
1727
|
+
return result
|
|
1728
|
+
|
|
1729
|
+
orig_valid = orig_ids[valid_mask]
|
|
1730
|
+
new_valid = new_ids_t[valid_mask]
|
|
1731
|
+
|
|
1732
|
+
if len(orig_valid) == 0:
|
|
1733
|
+
return result
|
|
1734
|
+
|
|
1735
|
+
unique_pairs = np.unique(np.column_stack((orig_valid, new_valid)), axis=0)
|
|
1736
|
+
|
|
1737
|
+
# Create mapping
|
|
1738
|
+
for orig_id, new_id in unique_pairs:
|
|
1739
|
+
if new_id in new_id_to_idx:
|
|
1740
|
+
result[new_id_to_idx[new_id]] = orig_id
|
|
1741
|
+
|
|
1742
|
+
return result
|
|
1743
|
+
|
|
1744
|
+
# Process in parallel
|
|
1745
|
+
input_dim = ['ncells'] if self.unstructured_grid else ['z']
|
|
1746
|
+
global_id_mapping = xr.apply_ufunc(
|
|
1747
|
+
process_timestep,
|
|
1748
|
+
original_ids_field,
|
|
1749
|
+
new_ids_field,
|
|
1750
|
+
input_core_dims=[input_dim, input_dim],
|
|
1751
|
+
output_core_dims=[['ID']],
|
|
1752
|
+
vectorize=True,
|
|
1753
|
+
dask='parallelized',
|
|
1754
|
+
output_dtypes=[np.int32],
|
|
1755
|
+
dask_gufunc_kwargs={'output_sizes': {'ID': len(new_ids)}}
|
|
1756
|
+
).assign_coords(ID=new_ids).compute()
|
|
1757
|
+
|
|
1758
|
+
# Store original ID mapping
|
|
1759
|
+
object_props_extended['global_ID'] = global_id_mapping
|
|
1760
|
+
# Post-condition: Now, e.g. global_id_mapping.sel(ID=10) --> Given the new ID (10), returns corresponding original_id at every time
|
|
1761
|
+
|
|
1762
|
+
# Transfer all properties from original object_props
|
|
1763
|
+
dummy = object_props.isel(ID=0) * np.nan # Add vale of ID = 0 to this coordinate ID
|
|
1764
|
+
object_props = xr.concat([dummy.assign_coords(ID=0), object_props], dim='ID')
|
|
1765
|
+
|
|
1766
|
+
for var_name in object_props.data_vars:
|
|
1767
|
+
temp = (object_props[var_name]
|
|
1768
|
+
.sel(ID=global_id_mapping.rename({'ID':'new_id'}))
|
|
1769
|
+
.drop_vars('ID').rename({'new_id':'ID'}))
|
|
1770
|
+
|
|
1771
|
+
if var_name == 'ID':
|
|
1772
|
+
temp = temp.astype(np.int32)
|
|
1773
|
+
else:
|
|
1774
|
+
temp = temp.astype(np.float32)
|
|
1775
|
+
|
|
1776
|
+
object_props_extended[var_name] = temp
|
|
1777
|
+
|
|
1778
|
+
## Map the merge_events using the old IDs to be from dimensions (merge_ID, parent_idx)
|
|
1779
|
+
# --> new merge_ledger with dimensions (time, ID, sibling_ID)
|
|
1780
|
+
# i.e. for each merge_ID --> merge_parent_IDs gives the old IDs --> map to new ID using ID_to_cluster_index_da
|
|
1781
|
+
# --> merge_time
|
|
1782
|
+
|
|
1783
|
+
old_parent_IDs = xr.where(merge_events.parent_IDs > 0, merge_events.parent_IDs, 0)
|
|
1784
|
+
new_IDs_parents = ID_to_cluster_index_da.sel(ID=old_parent_IDs)
|
|
1785
|
+
|
|
1786
|
+
# Replace the coordinate merge_ID in new_IDs_parents with merge_time. merge_events.merge_time gives merge_time for each merge_ID
|
|
1787
|
+
new_IDs_parents_t = (new_IDs_parents
|
|
1788
|
+
.assign_coords({'merge_time': merge_events.merge_time})
|
|
1789
|
+
.drop_vars('ID')
|
|
1790
|
+
.swap_dims({'merge_ID': 'merge_time'})
|
|
1791
|
+
.persist())
|
|
1792
|
+
|
|
1793
|
+
# Map new_IDs_parents_t into a new data array with dimensions time, ID, and sibling_ID
|
|
1794
|
+
merge_ledger = (xr.full_like(global_id_mapping, fill_value=-1)
|
|
1795
|
+
.chunk({self.timedim: self.timechunks})
|
|
1796
|
+
.expand_dims({'sibling_ID': new_IDs_parents_t.parent_idx.shape[0]})
|
|
1797
|
+
.copy())
|
|
1798
|
+
|
|
1799
|
+
# Wrapper for processing/mapping mergers in parallel
|
|
1800
|
+
def process_time_group(time_block, IDs_data, IDs_coords):
|
|
1801
|
+
"""Process all mergers for a single block of timesteps."""
|
|
1802
|
+
result = xr.full_like(time_block, -1)
|
|
1803
|
+
|
|
1804
|
+
# Get unique times in this block
|
|
1805
|
+
unique_times = np.unique(time_block[self.timedim])
|
|
1806
|
+
|
|
1807
|
+
for time_val in unique_times:
|
|
1808
|
+
# Get IDs for this time
|
|
1809
|
+
time_mask = IDs_coords['merge_time'] == time_val
|
|
1810
|
+
if not np.any(time_mask):
|
|
1811
|
+
continue
|
|
1812
|
+
|
|
1813
|
+
IDs_at_time = IDs_data[time_mask]
|
|
1814
|
+
|
|
1815
|
+
# Handle single merger case
|
|
1816
|
+
if IDs_at_time.ndim == 1:
|
|
1817
|
+
valid_mask = IDs_at_time > 0
|
|
1818
|
+
if np.any(valid_mask):
|
|
1819
|
+
# Create expanded array for sibling_ID dimension
|
|
1820
|
+
expanded_IDs = np.broadcast_to(
|
|
1821
|
+
IDs_at_time,
|
|
1822
|
+
(len(time_block.sibling_ID), len(IDs_at_time))
|
|
1823
|
+
)
|
|
1824
|
+
result.loc[{self.timedim: time_val, 'ID': IDs_at_time[valid_mask]}] = expanded_IDs[:, valid_mask]
|
|
1825
|
+
|
|
1826
|
+
# Handle multiple mergers case
|
|
1827
|
+
else:
|
|
1828
|
+
for merger_IDs in IDs_at_time:
|
|
1829
|
+
valid_mask = merger_IDs > 0
|
|
1830
|
+
if np.any(valid_mask):
|
|
1831
|
+
expanded_IDs = np.broadcast_to(
|
|
1832
|
+
merger_IDs,
|
|
1833
|
+
(len(time_block.sibling_ID), len(merger_IDs))
|
|
1834
|
+
)
|
|
1835
|
+
result.loc[{self.timedim: time_val, 'ID': merger_IDs[valid_mask]}] = expanded_IDs[:, valid_mask]
|
|
1836
|
+
|
|
1837
|
+
return result
|
|
1838
|
+
|
|
1839
|
+
# Map blocks in parallel
|
|
1840
|
+
merge_ledger = xr.map_blocks(
|
|
1841
|
+
process_time_group,
|
|
1842
|
+
merge_ledger,
|
|
1843
|
+
args=(new_IDs_parents_t.values, new_IDs_parents_t.coords),
|
|
1844
|
+
template=merge_ledger
|
|
1845
|
+
)
|
|
1846
|
+
|
|
1847
|
+
# Format merge ledger
|
|
1848
|
+
merge_ledger = merge_ledger.rename('merge_ledger').transpose(self.timedim, 'ID', 'sibling_ID').persist()
|
|
1849
|
+
|
|
1850
|
+
# For structured grid, convert centroid from pixel to lat/lon
|
|
1851
|
+
if not self.unstructured_grid:
|
|
1852
|
+
y_values = xr.DataArray(
|
|
1853
|
+
split_merged_relabeled_object_id_field[self.ydim].values,
|
|
1854
|
+
coords=[np.arange(len(split_merged_relabeled_object_id_field[self.ydim]))],
|
|
1855
|
+
dims=['pixels']
|
|
1856
|
+
)
|
|
1857
|
+
x_values = xr.DataArray(
|
|
1858
|
+
split_merged_relabeled_object_id_field[self.xdim].values,
|
|
1859
|
+
coords=[np.arange(len(split_merged_relabeled_object_id_field[self.xdim]))],
|
|
1860
|
+
dims=['pixels']
|
|
1861
|
+
)
|
|
1862
|
+
|
|
1863
|
+
object_props_extended['centroid'] = xr.concat([
|
|
1864
|
+
y_values.interp(pixels=object_props_extended['centroid'].sel(component=0)),
|
|
1865
|
+
x_values.interp(pixels=object_props_extended['centroid'].sel(component=1))
|
|
1866
|
+
], dim='component')
|
|
1867
|
+
|
|
1868
|
+
object_props_extended = object_props_extended.drop_vars('pixels')
|
|
1869
|
+
|
|
1870
|
+
# Add start and end time indices for each ID
|
|
1871
|
+
valid_presence = object_props_extended['global_ID'] > 0 # i.e. where there is valid data
|
|
1872
|
+
|
|
1873
|
+
object_props_extended['presence'] = valid_presence
|
|
1874
|
+
object_props_extended['time_start'] = valid_presence.time[valid_presence.argmax(dim=self.timedim)]
|
|
1875
|
+
object_props_extended['time_end'] = valid_presence.time[
|
|
1876
|
+
(valid_presence.sizes[self.timedim] - 1) - (valid_presence[::-1]).argmax(dim=self.timedim)
|
|
1877
|
+
]
|
|
1878
|
+
|
|
1879
|
+
# Combine all components into final dataset
|
|
1880
|
+
split_merged_relabeled_events_ds = xr.merge([
|
|
1881
|
+
split_merged_relabeled_object_id_field.rename('ID_field'),
|
|
1882
|
+
object_props_extended,
|
|
1883
|
+
merge_ledger
|
|
1884
|
+
])
|
|
1885
|
+
|
|
1886
|
+
# Remove the last ID -- it is all 0s
|
|
1887
|
+
return split_merged_relabeled_events_ds.isel(ID=slice(0, -1))
|
|
1888
|
+
|
|
1889
|
+
# ============================
|
|
1890
|
+
# Splitting and Merging Methods
|
|
1891
|
+
# ============================
|
|
1892
|
+
|
|
1893
|
+
def split_and_merge_objects(self, object_id_field_unique, object_props):
|
|
1894
|
+
"""
|
|
1895
|
+
Implement object splitting and merging logic.
|
|
1896
|
+
|
|
1897
|
+
This identifies and processes cases where objects split or merge over time,
|
|
1898
|
+
creating new object IDs as needed.
|
|
1899
|
+
|
|
1900
|
+
Parameters
|
|
1901
|
+
----------
|
|
1902
|
+
object_id_field_unique : xarray.DataArray
|
|
1903
|
+
Field of unique object IDs
|
|
1904
|
+
object_props : xarray.Dataset
|
|
1905
|
+
Properties of each object
|
|
1906
|
+
|
|
1907
|
+
Returns
|
|
1908
|
+
-------
|
|
1909
|
+
tuple
|
|
1910
|
+
(object_id_field, object_props, overlap_objects_list, merge_events)
|
|
1911
|
+
"""
|
|
1912
|
+
# Find overlapping objects
|
|
1913
|
+
overlap_objects_list = self.find_overlapping_objects(object_id_field_unique) # List object pairs that overlap by at least overlap_threshold percent
|
|
1914
|
+
overlap_objects_list = self.enforce_overlap_threshold(overlap_objects_list, object_props)
|
|
1915
|
+
if self.verbosity > 0:
|
|
1916
|
+
print('Finished finding overlapping objects')
|
|
1917
|
+
|
|
1918
|
+
# Initialise merge tracking lists
|
|
1919
|
+
merge_times = [] # When the merge occurred
|
|
1920
|
+
merge_child_ids = [] # Resulting child ID
|
|
1921
|
+
merge_parent_ids = [] # List of parent IDs that merged
|
|
1922
|
+
merge_areas = [] # Areas of overlap
|
|
1923
|
+
next_new_id = object_props.ID.max().item() + 1 # Start new IDs after highest existing ID
|
|
1924
|
+
|
|
1925
|
+
# Find children (t+1 / RHS) that appear multiple times (merging objects) --> Indicates there are 2+ Parent Objects...
|
|
1926
|
+
unique_children, children_counts = np.unique(overlap_objects_list[:, 1], return_counts=True)
|
|
1927
|
+
merging_objects = unique_children[children_counts > 1]
|
|
1928
|
+
|
|
1929
|
+
# Pre-compute time indices for each child object
|
|
1930
|
+
time_index_map = self.compute_id_time_dict(object_id_field_unique, merging_objects, next_new_id)
|
|
1931
|
+
Nx = object_id_field_unique[self.xdim].size
|
|
1932
|
+
|
|
1933
|
+
# Group objects by time-chunk for efficient processing
|
|
1934
|
+
# Pre-condition: Object IDs should be monotonically increasing in time...
|
|
1935
|
+
chunk_boundaries = np.cumsum([0] + list(object_id_field_unique.chunks[0]))
|
|
1936
|
+
objects_by_chunk = {}
|
|
1937
|
+
# Ensure that objects_by_chunk has entry for every key
|
|
1938
|
+
for chunk_idx in range(len(object_id_field_unique.chunks[0])):
|
|
1939
|
+
objects_by_chunk.setdefault(chunk_idx, [])
|
|
1940
|
+
|
|
1941
|
+
object_id_field_unique = object_id_field_unique.persist()
|
|
1942
|
+
|
|
1943
|
+
# Assign objects to chunks
|
|
1944
|
+
for object_id in merging_objects:
|
|
1945
|
+
chunk_idx = np.searchsorted(chunk_boundaries, time_index_map[object_id], side='right') - 1
|
|
1946
|
+
objects_by_chunk.setdefault(chunk_idx, []).append(object_id)
|
|
1947
|
+
|
|
1948
|
+
future_chunk_merges = []
|
|
1949
|
+
updated_chunks = []
|
|
1950
|
+
|
|
1951
|
+
# Process each time chunk
|
|
1952
|
+
for chunk_idx, chunk_objects in objects_by_chunk.items():
|
|
1953
|
+
# We do this to avoid repetetively re-computing and injecting tiny changes into the full dask-backed DataArray object_id_field_unique
|
|
1954
|
+
|
|
1955
|
+
# Extract and load an entire chunk into memory
|
|
1956
|
+
chunk_start = sum(object_id_field_unique.chunks[0][:chunk_idx])
|
|
1957
|
+
chunk_end = chunk_start + object_id_field_unique.chunks[0][chunk_idx] + 1 # We also want access to the object_id_time_p1... But need to remember to remove the last time later
|
|
1958
|
+
|
|
1959
|
+
chunk_data = object_id_field_unique.isel({self.timedim: slice(chunk_start, chunk_end)}).compute()
|
|
1960
|
+
|
|
1961
|
+
# Create a working queue of objects to process
|
|
1962
|
+
objects_to_process = chunk_objects.copy()
|
|
1963
|
+
# Combine only the future_chunk_merges that don't already appear in objects_to_process
|
|
1964
|
+
objects_to_process = objects_to_process + [
|
|
1965
|
+
object_id for object_id in future_chunk_merges
|
|
1966
|
+
if object_id not in objects_to_process
|
|
1967
|
+
] # First, assess the new objects from the end of the previous chunk...
|
|
1968
|
+
future_chunk_merges = []
|
|
1969
|
+
|
|
1970
|
+
# Process each object in this chunk
|
|
1971
|
+
while objects_to_process: # Process until queue is empty
|
|
1972
|
+
child_id = objects_to_process.pop(0)
|
|
1973
|
+
|
|
1974
|
+
# Get time index and data slices
|
|
1975
|
+
child_time_idx = time_index_map[child_id]
|
|
1976
|
+
relative_time_idx = child_time_idx - chunk_start
|
|
1977
|
+
|
|
1978
|
+
object_id_time = chunk_data.isel({self.timedim: relative_time_idx})
|
|
1979
|
+
try:
|
|
1980
|
+
object_id_time_p1 = chunk_data.isel({self.timedim: relative_time_idx+1})
|
|
1981
|
+
except:
|
|
1982
|
+
# Last chunk
|
|
1983
|
+
object_id_time_p1 = xr.full_like(object_id_time, 0)
|
|
1984
|
+
|
|
1985
|
+
# Get previous timestep
|
|
1986
|
+
if relative_time_idx-1 >= 0:
|
|
1987
|
+
object_id_time_m1 = chunk_data.isel({self.timedim: relative_time_idx-1})
|
|
1988
|
+
elif updated_chunks:
|
|
1989
|
+
# Get the last time slice from the previous chunk (stored in updated_chunks)
|
|
1990
|
+
_, _, last_chunk_data = updated_chunks[-1]
|
|
1991
|
+
object_id_time_m1 = last_chunk_data[-1]
|
|
1992
|
+
else:
|
|
1993
|
+
object_id_time_m1 = xr.full_like(object_id_time, 0)
|
|
1994
|
+
|
|
1995
|
+
# Get mask of child object
|
|
1996
|
+
child_mask_2d = (object_id_time == child_id).values
|
|
1997
|
+
|
|
1998
|
+
# Find all pairs involving this child
|
|
1999
|
+
child_mask = overlap_objects_list[:, 1] == child_id
|
|
2000
|
+
child_where = np.where(overlap_objects_list[:, 1] == child_id)[0]
|
|
2001
|
+
merge_group = overlap_objects_list[child_mask]
|
|
2002
|
+
|
|
2003
|
+
# Get parent objects (LHS) that overlap with this child object
|
|
2004
|
+
parent_ids = merge_group[:, 0]
|
|
2005
|
+
num_parents = len(parent_ids)
|
|
2006
|
+
|
|
2007
|
+
# Create new IDs for the other half of the child object & record in the merge ledger
|
|
2008
|
+
new_object_id = np.arange(next_new_id, next_new_id + (num_parents - 1), dtype=np.int32)
|
|
2009
|
+
next_new_id += num_parents - 1
|
|
2010
|
+
|
|
2011
|
+
# Replace the 2nd+ child in the overlap objects list with the new child ID
|
|
2012
|
+
overlap_objects_list[child_where[1:], 1] = new_object_id
|
|
2013
|
+
child_ids = np.concatenate((np.array([child_id]), new_object_id))
|
|
2014
|
+
|
|
2015
|
+
# Record merge event
|
|
2016
|
+
merge_times.append(chunk_data.isel({self.timedim: relative_time_idx}).time.values)
|
|
2017
|
+
merge_child_ids.append(child_ids)
|
|
2018
|
+
merge_parent_ids.append(parent_ids)
|
|
2019
|
+
merge_areas.append(overlap_objects_list[child_mask, 2])
|
|
2020
|
+
|
|
2021
|
+
## Relabel the Original Child Object ID Field to account for the New ID:
|
|
2022
|
+
# Get parent centroids for partitioning
|
|
2023
|
+
parent_centroids = object_props.sel(ID=parent_ids).centroid.values.T
|
|
2024
|
+
|
|
2025
|
+
# Partition the child object based on parent associations
|
|
2026
|
+
if self.nn_partitioning:
|
|
2027
|
+
# Nearest-neighbor partitioning
|
|
2028
|
+
# --> For every (Original) Child Cell in the ID Field, Find the closest (t-1) Parent _Cell_
|
|
2029
|
+
if self.unstructured_grid:
|
|
2030
|
+
# Prepare parent masks
|
|
2031
|
+
parent_masks = np.zeros((len(parent_ids), object_id_time.shape[0]), dtype=bool)
|
|
2032
|
+
for idx, parent_id in enumerate(parent_ids):
|
|
2033
|
+
parent_masks[idx] = (object_id_time_m1 == parent_id).values
|
|
2034
|
+
|
|
2035
|
+
# Calculate maximum search distance
|
|
2036
|
+
max_area = np.max(object_props.sel(ID=parent_ids).area.values) / self.mean_cell_area
|
|
2037
|
+
max_distance = int(np.sqrt(max_area) * 2.0)
|
|
2038
|
+
|
|
2039
|
+
# Use optimised unstructured partitioning
|
|
2040
|
+
new_labels = partition_nn_unstructured(
|
|
2041
|
+
child_mask_2d,
|
|
2042
|
+
parent_masks,
|
|
2043
|
+
child_ids,
|
|
2044
|
+
parent_centroids,
|
|
2045
|
+
self.neighbours_int.values,
|
|
2046
|
+
self.lat.values, # Need to pass these as NumPy arrays for JIT compatibility
|
|
2047
|
+
self.lon.values,
|
|
2048
|
+
max_distance=max(max_distance, 20)*2 # Set minimum threshold, in cells
|
|
2049
|
+
)
|
|
2050
|
+
else:
|
|
2051
|
+
# Prepare parent masks for structured grid
|
|
2052
|
+
parent_masks = np.zeros((len(parent_ids), object_id_time.shape[0], object_id_time.shape[1]), dtype=bool)
|
|
2053
|
+
for idx, parent_id in enumerate(parent_ids):
|
|
2054
|
+
parent_masks[idx] = (object_id_time_m1 == parent_id).values
|
|
2055
|
+
|
|
2056
|
+
# Calculate maximum search distance
|
|
2057
|
+
max_area = np.max(object_props.sel(ID=parent_ids).area.values)
|
|
2058
|
+
max_distance = int(np.sqrt(max_area) * 2.0) # Use 2x the max blob radius
|
|
2059
|
+
|
|
2060
|
+
# Use optimised structured grid partitioning
|
|
2061
|
+
new_labels = partition_nn_grid(
|
|
2062
|
+
child_mask_2d,
|
|
2063
|
+
parent_masks,
|
|
2064
|
+
child_ids,
|
|
2065
|
+
parent_centroids,
|
|
2066
|
+
Nx,
|
|
2067
|
+
max_distance=max(max_distance, 20) # Set minimum threshold, in cells
|
|
2068
|
+
)
|
|
2069
|
+
|
|
2070
|
+
else:
|
|
2071
|
+
# Centroid-based partitioning
|
|
2072
|
+
# --> For every (Original) Child Cell in the ID Field, Find the closest (t-1) Parent _Centroid_
|
|
2073
|
+
if self.unstructured_grid:
|
|
2074
|
+
new_labels = partition_centroid_unstructured(
|
|
2075
|
+
child_mask_2d,
|
|
2076
|
+
parent_centroids,
|
|
2077
|
+
child_ids,
|
|
2078
|
+
self.lat.values,
|
|
2079
|
+
self.lon.values
|
|
2080
|
+
)
|
|
2081
|
+
else:
|
|
2082
|
+
# Calculate distances to each parent centroid
|
|
2083
|
+
distances = wrapped_euclidian_parallel(child_mask_2d, parent_centroids, Nx)
|
|
2084
|
+
|
|
2085
|
+
# Assign based on closest parent
|
|
2086
|
+
new_labels = child_ids[np.argmin(distances, axis=1)]
|
|
2087
|
+
|
|
2088
|
+
## Update values in child_time_idx and assign the updated slice back to the original DataArray
|
|
2089
|
+
temp = np.zeros_like(object_id_time)
|
|
2090
|
+
temp[child_mask_2d] = new_labels
|
|
2091
|
+
object_id_time = object_id_time.where(~child_mask_2d, temp)
|
|
2092
|
+
chunk_data[{self.timedim: relative_time_idx}] = object_id_time
|
|
2093
|
+
|
|
2094
|
+
# Add new entries to time_index_map for each of new_object_id corresponding to the current time index
|
|
2095
|
+
time_index_map.update({new_id: child_time_idx for new_id in new_object_id})
|
|
2096
|
+
|
|
2097
|
+
# Update the Properties of the N Children Objects
|
|
2098
|
+
new_child_props = self.calculate_object_properties(object_id_time, properties=['area', 'centroid'])
|
|
2099
|
+
|
|
2100
|
+
# Update the object_props DataArray: (but first, check if the original children still exists)
|
|
2101
|
+
if child_id in new_child_props.ID:
|
|
2102
|
+
# Update existing entry
|
|
2103
|
+
object_props.loc[dict(ID=child_id)] = new_child_props.sel(ID=child_id)
|
|
2104
|
+
else: # Delete child_id: The object has split/morphed such that it doesn't get a partition of this child...
|
|
2105
|
+
object_props = object_props.drop_sel(ID=child_id) # N.B.: This means that the IDs are no longer continuous...
|
|
2106
|
+
if self.verbosity > 0:
|
|
2107
|
+
print(f"Deleted child_id {child_id} because parents have split/morphed")
|
|
2108
|
+
|
|
2109
|
+
# Add the properties for the N-1 other new child ID
|
|
2110
|
+
new_object_ids_still = new_child_props.ID.where(
|
|
2111
|
+
new_child_props.ID.isin(new_object_id), drop=True
|
|
2112
|
+
).ID
|
|
2113
|
+
object_props = xr.concat([object_props, new_child_props.sel(ID=new_object_ids_still)], dim='ID')
|
|
2114
|
+
|
|
2115
|
+
missing_ids = set(new_object_id) - set(new_object_ids_still.values)
|
|
2116
|
+
if len(missing_ids) > 0 and self.verbosity > 0:
|
|
2117
|
+
print(f"Missing newly created child_ids {missing_ids} because parents have split/morphed in the meantime...")
|
|
2118
|
+
|
|
2119
|
+
## Finally, Re-assess all of the Parent IDs (LHS) equal to the (original) child_id
|
|
2120
|
+
|
|
2121
|
+
# Look at the overlap IDs between the original child_id and the next time-step, and also the new_object_id and the next time-step
|
|
2122
|
+
new_overlaps = self.check_overlap_slice(object_id_time.values, object_id_time_p1.values)
|
|
2123
|
+
new_child_overlaps_list = new_overlaps[(new_overlaps[:, 0] == child_id) | np.isin(new_overlaps[:, 0], new_object_id)]
|
|
2124
|
+
new_child_overlaps_list = self.enforce_overlap_threshold(new_child_overlaps_list, object_props)
|
|
2125
|
+
|
|
2126
|
+
# Replace the lines in the overlap_objects_list where (original) child_id is on the LHS, with these new pairs in new_child_overlaps_list
|
|
2127
|
+
child_mask_LHS = overlap_objects_list[:, 0] == child_id
|
|
2128
|
+
overlap_objects_list = np.concatenate([
|
|
2129
|
+
overlap_objects_list[~child_mask_LHS],
|
|
2130
|
+
new_child_overlaps_list
|
|
2131
|
+
])
|
|
2132
|
+
|
|
2133
|
+
## Finally, _FINALLY_, we need to ensure that of the new children objects we made, they only overlap with their respective parent...
|
|
2134
|
+
new_unique_children, new_children_counts = np.unique(
|
|
2135
|
+
new_child_overlaps_list[:, 1],
|
|
2136
|
+
return_counts=True
|
|
2137
|
+
)
|
|
2138
|
+
new_merging_objects = new_unique_children[new_children_counts > 1]
|
|
2139
|
+
|
|
2140
|
+
if new_merging_objects.size > 0:
|
|
2141
|
+
if relative_time_idx + 1 < chunk_data.sizes[self.timedim] - 1: # If there is a next time-step in this chunk
|
|
2142
|
+
# Add to current queue if in this chunk
|
|
2143
|
+
for new_child_id in new_merging_objects:
|
|
2144
|
+
if new_child_id not in objects_to_process: # We aren't already going to assess this object
|
|
2145
|
+
objects_to_process.insert(0, new_child_id)
|
|
2146
|
+
else: # This is out of our current jurisdiction: Defer this reassessment to the beginning of the next chunk
|
|
2147
|
+
# Add to next chunk's queue
|
|
2148
|
+
future_chunk_merges.extend(new_merging_objects)
|
|
2149
|
+
|
|
2150
|
+
# Store the processed chunk
|
|
2151
|
+
updated_chunks.append((chunk_start, chunk_end-1, chunk_data[:(chunk_end-1-chunk_start)]))
|
|
2152
|
+
|
|
2153
|
+
if chunk_idx % 10 == 0 and self.verbosity > 0:
|
|
2154
|
+
print(f"Processing splitting and merging in chunk {chunk_idx} of {len(objects_by_chunk)}")
|
|
2155
|
+
|
|
2156
|
+
# Periodically update main array to manage memory
|
|
2157
|
+
if len(updated_chunks) > 1: # Keep the last chunk for potential object_id_time_m1 reference
|
|
2158
|
+
for start, end, chunk_data in updated_chunks[:-1]:
|
|
2159
|
+
object_id_field_unique[{self.timedim: slice(start, end)}] = chunk_data
|
|
2160
|
+
updated_chunks = updated_chunks[-1:] # Keep only the last chunk
|
|
2161
|
+
object_id_field_unique = object_id_field_unique.persist()
|
|
2162
|
+
|
|
2163
|
+
# Apply final chunk updates
|
|
2164
|
+
for start, end, chunk_data in updated_chunks:
|
|
2165
|
+
object_id_field_unique[{self.timedim: slice(start, end)}] = chunk_data
|
|
2166
|
+
object_id_field_unique = object_id_field_unique.persist()
|
|
2167
|
+
|
|
2168
|
+
# Process merge events into a dataset
|
|
2169
|
+
max_parents = max(len(ids) for ids in merge_parent_ids)
|
|
2170
|
+
max_children = max(len(ids) for ids in merge_child_ids)
|
|
2171
|
+
|
|
2172
|
+
# Convert lists to padded numpy arrays
|
|
2173
|
+
parent_ids_array = np.full((len(merge_parent_ids), max_parents), -1, dtype=np.int32)
|
|
2174
|
+
child_ids_array = np.full((len(merge_child_ids), max_children), -1, dtype=np.int32)
|
|
2175
|
+
overlap_areas_array = np.full((len(merge_areas), max_parents), -1, dtype=np.int32)
|
|
2176
|
+
|
|
2177
|
+
for i, parents in enumerate(merge_parent_ids):
|
|
2178
|
+
parent_ids_array[i, :len(parents)] = parents
|
|
2179
|
+
|
|
2180
|
+
for i, children in enumerate(merge_child_ids):
|
|
2181
|
+
child_ids_array[i, :len(children)] = children
|
|
2182
|
+
|
|
2183
|
+
for i, areas in enumerate(merge_areas):
|
|
2184
|
+
overlap_areas_array[i, :len(areas)] = areas
|
|
2185
|
+
|
|
2186
|
+
# Create merge events dataset
|
|
2187
|
+
merge_events = xr.Dataset(
|
|
2188
|
+
{
|
|
2189
|
+
'parent_IDs': (('merge_ID', 'parent_idx'), parent_ids_array),
|
|
2190
|
+
'child_IDs': (('merge_ID', 'child_idx'), child_ids_array),
|
|
2191
|
+
'overlap_areas': (('merge_ID', 'parent_idx'), overlap_areas_array),
|
|
2192
|
+
'merge_time': ('merge_ID', merge_times),
|
|
2193
|
+
'n_parents': ('merge_ID', np.array([len(p) for p in merge_parent_ids], dtype=np.int8)),
|
|
2194
|
+
'n_children': ('merge_ID', np.array([len(c) for c in merge_child_ids], dtype=np.int8))
|
|
2195
|
+
},
|
|
2196
|
+
attrs={
|
|
2197
|
+
'fill_value': -1
|
|
2198
|
+
}
|
|
2199
|
+
)
|
|
2200
|
+
|
|
2201
|
+
object_props = object_props.persist()
|
|
2202
|
+
|
|
2203
|
+
return (object_id_field_unique,
|
|
2204
|
+
object_props,
|
|
2205
|
+
overlap_objects_list[:, :2],
|
|
2206
|
+
merge_events)
|
|
2207
|
+
|
|
2208
|
+
|
|
2209
|
+
def split_and_merge_objects_parallel(self, object_id_field_unique, object_props):
|
|
2210
|
+
"""
|
|
2211
|
+
Optimised parallel implementation of object splitting and merging.
|
|
2212
|
+
|
|
2213
|
+
This version is specifically designed for unstructured grids with more efficient
|
|
2214
|
+
memory handling and better parallelism than the standard split_and_merge_objects
|
|
2215
|
+
method. It processes data in chunks, handles merging events, and efficiently
|
|
2216
|
+
updates object IDs.
|
|
2217
|
+
|
|
2218
|
+
Parameters
|
|
2219
|
+
----------
|
|
2220
|
+
object_id_field_unique : xarray.DataArray
|
|
2221
|
+
Field of unique object IDs
|
|
2222
|
+
object_props : xarray.Dataset
|
|
2223
|
+
Properties of each object
|
|
2224
|
+
|
|
2225
|
+
Returns
|
|
2226
|
+
-------
|
|
2227
|
+
tuple
|
|
2228
|
+
(object_id_field, object_props, overlap_objects_list, merge_events)
|
|
2229
|
+
"""
|
|
2230
|
+
|
|
2231
|
+
# Constants for memory allocation
|
|
2232
|
+
MAX_MERGES = 20 # Maximum number of merges per timestep
|
|
2233
|
+
MAX_PARENTS = 10 # Maximum number of parents per merge
|
|
2234
|
+
MAX_CHILDREN = MAX_PARENTS
|
|
2235
|
+
|
|
2236
|
+
def process_chunk(chunk_data_m1_full, chunk_data_p1_full, merging_objects, next_id_start, lat, lon, area, neighbours_int):
|
|
2237
|
+
"""
|
|
2238
|
+
Process a single chunk of merging objects.
|
|
2239
|
+
|
|
2240
|
+
This function handles the complex batch processing of splitting and merging objects
|
|
2241
|
+
across timesteps within a single chunk. It finds overlapping objects, determines
|
|
2242
|
+
parent-child relationships, and creates new IDs as needed.
|
|
2243
|
+
|
|
2244
|
+
Parameters
|
|
2245
|
+
----------
|
|
2246
|
+
chunk_data_m1_full : numpy.ndarray
|
|
2247
|
+
Data from previous timestep (t-1) and current timestep (t)
|
|
2248
|
+
chunk_data_p1_full : numpy.ndarray
|
|
2249
|
+
Data from next timestep (t+1)
|
|
2250
|
+
merging_objects : (n_time, max_merges) numpy.ndarray
|
|
2251
|
+
IDs of objects to process
|
|
2252
|
+
next_id_start : (n_time, max_merges) numpy.ndarray
|
|
2253
|
+
Starting ID values for new objects
|
|
2254
|
+
lat, lon : numpy.ndarray
|
|
2255
|
+
Latitude/longitude arrays
|
|
2256
|
+
area : numpy.ndarray
|
|
2257
|
+
Cell area array
|
|
2258
|
+
neighbours_int : numpy.ndarray
|
|
2259
|
+
Neighbor connectivity array
|
|
2260
|
+
|
|
2261
|
+
Returns
|
|
2262
|
+
-------
|
|
2263
|
+
tuple
|
|
2264
|
+
Contains merge events, object updates, and newly created objects
|
|
2265
|
+
"""
|
|
2266
|
+
|
|
2267
|
+
## Fix Broadcasted dimensions of inputs:
|
|
2268
|
+
# Remove extra dimension if present while preserving time chunks
|
|
2269
|
+
# N.B.: This is a weird artefact/choice of xarray apply_ufunc broadcasting... (i.e. 'nv' dimension gets injected into all the other arrays!)
|
|
2270
|
+
|
|
2271
|
+
chunk_data_m1 = chunk_data_m1_full.squeeze()[0].astype(np.int32).copy()
|
|
2272
|
+
chunk_data = chunk_data_m1_full.squeeze()[1].astype(np.int32).copy()
|
|
2273
|
+
del chunk_data_m1_full # Free memory immediately
|
|
2274
|
+
chunk_data_p1 = chunk_data_p1_full.squeeze().astype(np.int32).copy()
|
|
2275
|
+
del chunk_data_p1_full
|
|
2276
|
+
|
|
2277
|
+
# Extract and prepare input arrays
|
|
2278
|
+
lat = lat.squeeze().astype(np.float32)
|
|
2279
|
+
lon = lon.squeeze().astype(np.float32)
|
|
2280
|
+
area = area.squeeze().astype(np.float32)
|
|
2281
|
+
next_id_start = next_id_start.squeeze()
|
|
2282
|
+
|
|
2283
|
+
# Handle neighbours_int with correct dimensions (nv, ncells)
|
|
2284
|
+
neighbours_int = neighbours_int.squeeze()
|
|
2285
|
+
if neighbours_int.shape[1] != lat.shape[0]:
|
|
2286
|
+
neighbours_int = neighbours_int.T
|
|
2287
|
+
|
|
2288
|
+
# Handle multiple merging objects - ensure proper dimensionality
|
|
2289
|
+
merging_objects = merging_objects.squeeze()
|
|
2290
|
+
if merging_objects.ndim == 1:
|
|
2291
|
+
merging_objects = merging_objects[:, None] # Add dimension for max_merges
|
|
2292
|
+
|
|
2293
|
+
# Pre-convert lat/lon to Cartesian coordinates for efficiency
|
|
2294
|
+
x = (np.cos(np.radians(lat)) * np.cos(np.radians(lon))).astype(np.float32)
|
|
2295
|
+
y = (np.cos(np.radians(lat)) * np.sin(np.radians(lon))).astype(np.float32)
|
|
2296
|
+
z = np.sin(np.radians(lat)).astype(np.float32)
|
|
2297
|
+
|
|
2298
|
+
# Pre-allocate output arrays
|
|
2299
|
+
n_time = chunk_data_p1.shape[0]
|
|
2300
|
+
n_points = chunk_data_p1.shape[1]
|
|
2301
|
+
|
|
2302
|
+
merge_child_ids = np.full((n_time, MAX_MERGES, MAX_PARENTS), -1, dtype=np.int32)
|
|
2303
|
+
merge_parent_ids = np.full((n_time, MAX_MERGES, MAX_PARENTS), -1, dtype=np.int32)
|
|
2304
|
+
merge_areas = np.full((n_time, MAX_MERGES, MAX_PARENTS), -1, dtype=np.float32)
|
|
2305
|
+
merge_counts = np.zeros(n_time, dtype=np.int16) # Number of merges per timestep
|
|
2306
|
+
|
|
2307
|
+
updates_array = np.full((n_time, n_points), 255, dtype=np.uint8)
|
|
2308
|
+
updates_ids = np.full((n_time, 255), -1, dtype=np.int32)
|
|
2309
|
+
has_merge = np.zeros(n_time, dtype=np.bool_)
|
|
2310
|
+
|
|
2311
|
+
# Prepare merging objects list for each timestep
|
|
2312
|
+
merging_objects_list = [list(merging_objects[i][merging_objects[i] > 0])
|
|
2313
|
+
for i in range(merging_objects.shape[0])]
|
|
2314
|
+
final_merging_objects = np.full((n_time, MAX_MERGES), -1, dtype=np.int32)
|
|
2315
|
+
final_merge_count = 0
|
|
2316
|
+
|
|
2317
|
+
# Process each timestep
|
|
2318
|
+
for t in range(n_time):
|
|
2319
|
+
next_new_id = next_id_start[t] # Use the offset for this timestep
|
|
2320
|
+
|
|
2321
|
+
# Get current time slice data
|
|
2322
|
+
if t == 0:
|
|
2323
|
+
data_m1 = chunk_data_m1
|
|
2324
|
+
data_t = chunk_data
|
|
2325
|
+
del chunk_data_m1, chunk_data # Free memory
|
|
2326
|
+
else:
|
|
2327
|
+
data_m1 = data_t
|
|
2328
|
+
data_t = data_p1
|
|
2329
|
+
data_p1 = chunk_data_p1[t]
|
|
2330
|
+
|
|
2331
|
+
# Process each merging object at this timestep
|
|
2332
|
+
while merging_objects_list[t]:
|
|
2333
|
+
child_id = merging_objects_list[t].pop(0)
|
|
2334
|
+
|
|
2335
|
+
# Get child mask and identify overlapping parents
|
|
2336
|
+
child_mask = (data_t == child_id)
|
|
2337
|
+
|
|
2338
|
+
# Find parent objects that overlap with this child
|
|
2339
|
+
potential_parents = np.unique(data_m1[child_mask])
|
|
2340
|
+
parent_iterator = 0
|
|
2341
|
+
parent_masks_uint = np.full(n_points, 255, dtype=np.uint8)
|
|
2342
|
+
parent_centroids = np.full((MAX_PARENTS, 2), -1.e10, dtype=np.float32)
|
|
2343
|
+
parent_ids = np.full(MAX_PARENTS, -1, dtype=np.int32)
|
|
2344
|
+
parent_areas = np.zeros(MAX_PARENTS, dtype=np.float32)
|
|
2345
|
+
overlap_areas = np.zeros(MAX_PARENTS, dtype=np.float32)
|
|
2346
|
+
n_parents = 0
|
|
2347
|
+
|
|
2348
|
+
# Find all unique parent IDs with significant overlap
|
|
2349
|
+
for parent_id in potential_parents[potential_parents > 0]:
|
|
2350
|
+
if n_parents >= MAX_PARENTS:
|
|
2351
|
+
raise RuntimeError(f"Reached maximum number of parents ({MAX_PARENTS}) for child {child_id} at timestep {t}")
|
|
2352
|
+
|
|
2353
|
+
parent_mask = (data_m1 == parent_id)
|
|
2354
|
+
if np.any(parent_mask & child_mask):
|
|
2355
|
+
# Calculate overlap area and check if it's large enough
|
|
2356
|
+
area_0 = area[parent_mask].sum() # Parent area
|
|
2357
|
+
area_1 = area[child_mask].sum() # Child area
|
|
2358
|
+
min_area = np.minimum(area_0, area_1)
|
|
2359
|
+
overlap_area = area[parent_mask & child_mask].sum()
|
|
2360
|
+
|
|
2361
|
+
# Skip if overlap is below threshold
|
|
2362
|
+
if overlap_area / min_area < self.overlap_threshold:
|
|
2363
|
+
continue
|
|
2364
|
+
|
|
2365
|
+
# Record parent information
|
|
2366
|
+
parent_masks_uint[parent_mask] = parent_iterator
|
|
2367
|
+
parent_ids[n_parents] = parent_id
|
|
2368
|
+
overlap_areas[n_parents] = overlap_area
|
|
2369
|
+
|
|
2370
|
+
# Calculate area-weighted centroid for this parent
|
|
2371
|
+
mask_area = area[parent_mask]
|
|
2372
|
+
weighted_coords = np.array([
|
|
2373
|
+
np.sum(mask_area * x[parent_mask]),
|
|
2374
|
+
np.sum(mask_area * y[parent_mask]),
|
|
2375
|
+
np.sum(mask_area * z[parent_mask])
|
|
2376
|
+
], dtype=np.float32)
|
|
2377
|
+
|
|
2378
|
+
norm = np.sqrt(np.sum(weighted_coords * weighted_coords))
|
|
2379
|
+
|
|
2380
|
+
# Convert back to lat/lon
|
|
2381
|
+
parent_centroids[n_parents, 0] = np.degrees(np.arcsin(weighted_coords[2]/norm))
|
|
2382
|
+
parent_centroids[n_parents, 1] = np.degrees(np.arctan2(weighted_coords[1], weighted_coords[0]))
|
|
2383
|
+
|
|
2384
|
+
# Fix longitude range to [-180, 180]
|
|
2385
|
+
if parent_centroids[n_parents, 1] > 180:
|
|
2386
|
+
parent_centroids[n_parents, 1] -= 360
|
|
2387
|
+
elif parent_centroids[n_parents, 1] < -180:
|
|
2388
|
+
parent_centroids[n_parents, 1] += 360
|
|
2389
|
+
|
|
2390
|
+
parent_areas[n_parents] = area_0
|
|
2391
|
+
parent_iterator += 1
|
|
2392
|
+
n_parents += 1
|
|
2393
|
+
|
|
2394
|
+
# Need at least 2 parents for merging
|
|
2395
|
+
if n_parents < 2:
|
|
2396
|
+
continue
|
|
2397
|
+
|
|
2398
|
+
# Create new IDs for each partition
|
|
2399
|
+
new_child_ids = np.arange(next_new_id, next_new_id + (n_parents - 1), dtype=np.int32)
|
|
2400
|
+
child_ids = np.concatenate((np.array([child_id]), new_child_ids))
|
|
2401
|
+
|
|
2402
|
+
# Record merge event
|
|
2403
|
+
curr_merge_idx = merge_counts[t]
|
|
2404
|
+
if curr_merge_idx > MAX_MERGES:
|
|
2405
|
+
raise RuntimeError(f"Reached maximum number of merges ({MAX_MERGES}) at timestep {t}")
|
|
2406
|
+
|
|
2407
|
+
merge_child_ids[t, curr_merge_idx, :n_parents] = child_ids[:n_parents]
|
|
2408
|
+
merge_parent_ids[t, curr_merge_idx, :n_parents] = parent_ids[:n_parents]
|
|
2409
|
+
merge_areas[t, curr_merge_idx, :n_parents] = overlap_areas[:n_parents]
|
|
2410
|
+
merge_counts[t] += 1
|
|
2411
|
+
has_merge[t] = True
|
|
2412
|
+
|
|
2413
|
+
# Partition the child object based on parent associations
|
|
2414
|
+
if self.nn_partitioning:
|
|
2415
|
+
# Estimate maximum search distance based on object size
|
|
2416
|
+
max_area = parent_areas.max() / self.mean_cell_area
|
|
2417
|
+
max_distance = int(np.sqrt(max_area) * 2.0)
|
|
2418
|
+
|
|
2419
|
+
# Use optimised nearest-neighbor partitioning
|
|
2420
|
+
new_labels_uint = partition_nn_unstructured_optimised(
|
|
2421
|
+
child_mask.copy(),
|
|
2422
|
+
parent_masks_uint.copy(),
|
|
2423
|
+
parent_centroids,
|
|
2424
|
+
neighbours_int.copy(),
|
|
2425
|
+
lat,
|
|
2426
|
+
lon,
|
|
2427
|
+
max_distance=max(max_distance, 20)*2
|
|
2428
|
+
)
|
|
2429
|
+
# Returned 'new_labels_uint' is just the index of the child_ids
|
|
2430
|
+
new_labels = child_ids[new_labels_uint]
|
|
2431
|
+
|
|
2432
|
+
# Help garbage collection
|
|
2433
|
+
new_labels_uint = None
|
|
2434
|
+
|
|
2435
|
+
else:
|
|
2436
|
+
# Use centroid-based partitioning
|
|
2437
|
+
new_labels = partition_centroid_unstructured(
|
|
2438
|
+
child_mask,
|
|
2439
|
+
parent_centroids,
|
|
2440
|
+
child_ids,
|
|
2441
|
+
lat,
|
|
2442
|
+
lon
|
|
2443
|
+
)
|
|
2444
|
+
|
|
2445
|
+
# Update slice data for subsequent merging in process_chunk
|
|
2446
|
+
data_t[child_mask] = new_labels
|
|
2447
|
+
|
|
2448
|
+
# Record which cells get which new IDs for later updates
|
|
2449
|
+
spatial_indices_all = np.where(child_mask)[0]
|
|
2450
|
+
child_mask = None # Free memory
|
|
2451
|
+
gc.collect()
|
|
2452
|
+
|
|
2453
|
+
# Record update information for each new ID
|
|
2454
|
+
for new_id in child_ids[1:]:
|
|
2455
|
+
update_idx = np.where(updates_ids[t] == -1)[0][0] # Find next non-negative index in updates_ids
|
|
2456
|
+
updates_ids[t, update_idx] = new_id
|
|
2457
|
+
updates_array[t, spatial_indices_all[new_labels == new_id]] = update_idx
|
|
2458
|
+
|
|
2459
|
+
next_new_id += n_parents - 1
|
|
2460
|
+
|
|
2461
|
+
# Find all child objects in the next timestep that overlap with our newly labeled regions
|
|
2462
|
+
new_merging_list = []
|
|
2463
|
+
for new_id in child_ids:
|
|
2464
|
+
parent_mask = (data_t == new_id)
|
|
2465
|
+
if np.any(parent_mask):
|
|
2466
|
+
area_0 = area[parent_mask].sum()
|
|
2467
|
+
potential_children = np.unique(data_p1[parent_mask])
|
|
2468
|
+
|
|
2469
|
+
for potential_child in potential_children[potential_children > 0]:
|
|
2470
|
+
potential_child_mask = (data_p1 == potential_child)
|
|
2471
|
+
area_1 = area[potential_child_mask].sum()
|
|
2472
|
+
min_area = min(area_0, area_1)
|
|
2473
|
+
overlap_area = area[parent_mask & potential_child_mask].sum()
|
|
2474
|
+
|
|
2475
|
+
if overlap_area / min_area > self.overlap_threshold:
|
|
2476
|
+
new_merging_list.append(potential_child)
|
|
2477
|
+
|
|
2478
|
+
# Add newly found merging objects to processing queue
|
|
2479
|
+
if t < n_time - 1:
|
|
2480
|
+
# Add to next timestep in this chunk
|
|
2481
|
+
for new_object_id in new_merging_list:
|
|
2482
|
+
if new_object_id not in merging_objects_list[t+1]:
|
|
2483
|
+
merging_objects_list[t+1].append(new_object_id)
|
|
2484
|
+
else:
|
|
2485
|
+
# Record for next chunk
|
|
2486
|
+
for new_object_id in new_merging_list:
|
|
2487
|
+
if final_merge_count > MAX_MERGES:
|
|
2488
|
+
raise RuntimeError(f"Reached maximum number of merges ({MAX_MERGES}) at timestep {t}")
|
|
2489
|
+
|
|
2490
|
+
if not np.any(final_merging_objects[t][:final_merge_count] == new_object_id):
|
|
2491
|
+
final_merging_objects[t][final_merge_count] = new_object_id
|
|
2492
|
+
final_merge_count += 1
|
|
2493
|
+
|
|
2494
|
+
|
|
2495
|
+
return (merge_child_ids, merge_parent_ids, merge_areas, merge_counts,
|
|
2496
|
+
has_merge, updates_array, updates_ids, final_merging_objects)
|
|
2497
|
+
|
|
2498
|
+
|
|
2499
|
+
def update_object_id_field_inplace(object_id_field, id_lookup, updates_array, updates_ids, has_merge):
|
|
2500
|
+
"""
|
|
2501
|
+
Update the object field with chunk results using xarray operations.
|
|
2502
|
+
|
|
2503
|
+
This is memory efficient as it avoids creating full copies of the object_id_field.
|
|
2504
|
+
|
|
2505
|
+
Parameters
|
|
2506
|
+
----------
|
|
2507
|
+
object_id_field : xarray.DataArray
|
|
2508
|
+
The full object field to update
|
|
2509
|
+
id_lookup : dict
|
|
2510
|
+
Dictionary mapping temporary IDs to new IDs
|
|
2511
|
+
updates_array : xarray.DataArray
|
|
2512
|
+
Array indicating which spatial indices to update
|
|
2513
|
+
updates_ids : xarray.DataArray
|
|
2514
|
+
The new IDs to assign to updated indices
|
|
2515
|
+
has_merge : xarray.DataArray
|
|
2516
|
+
Boolean indicating whether each timestep has merges
|
|
2517
|
+
|
|
2518
|
+
Returns
|
|
2519
|
+
-------
|
|
2520
|
+
xarray.DataArray
|
|
2521
|
+
Updated object field
|
|
2522
|
+
"""
|
|
2523
|
+
|
|
2524
|
+
# Quick return if no merges to update
|
|
2525
|
+
if not has_merge.any():
|
|
2526
|
+
return object_id_field
|
|
2527
|
+
|
|
2528
|
+
def update_timeslice(data, updates, update_ids, lookup_values):
|
|
2529
|
+
"""Process a single timeslice."""
|
|
2530
|
+
# Extract valid update IDs
|
|
2531
|
+
valid_ids = update_ids[update_ids > -1]
|
|
2532
|
+
if len(valid_ids) == 0:
|
|
2533
|
+
return data
|
|
2534
|
+
|
|
2535
|
+
# Create result array starting with original values
|
|
2536
|
+
result = data.copy()
|
|
2537
|
+
|
|
2538
|
+
# Apply each update
|
|
2539
|
+
for idx, update_id in enumerate(valid_ids):
|
|
2540
|
+
mask = updates == idx
|
|
2541
|
+
if mask.any():
|
|
2542
|
+
result = np.where(mask, lookup_values[update_id], result)
|
|
2543
|
+
|
|
2544
|
+
return result
|
|
2545
|
+
|
|
2546
|
+
# Convert lookup dict to array for vectorized access
|
|
2547
|
+
max_id = max(id_lookup.keys()) + 1
|
|
2548
|
+
lookup_array = np.full(max_id, -1, dtype=np.int32)
|
|
2549
|
+
for temp_id, new_id in id_lookup.items():
|
|
2550
|
+
lookup_array[temp_id] = new_id
|
|
2551
|
+
|
|
2552
|
+
# Apply updates in parallel
|
|
2553
|
+
result = xr.apply_ufunc(
|
|
2554
|
+
update_timeslice,
|
|
2555
|
+
object_id_field,
|
|
2556
|
+
updates_array,
|
|
2557
|
+
updates_ids,
|
|
2558
|
+
kwargs={'lookup_values': lookup_array},
|
|
2559
|
+
input_core_dims=[[self.xdim],
|
|
2560
|
+
[self.xdim],
|
|
2561
|
+
['update_idx']],
|
|
2562
|
+
output_core_dims=[[self.xdim]],
|
|
2563
|
+
vectorize=True,
|
|
2564
|
+
dask='parallelized',
|
|
2565
|
+
output_dtypes=[np.int32]
|
|
2566
|
+
)
|
|
2567
|
+
|
|
2568
|
+
return result
|
|
2569
|
+
|
|
2570
|
+
def update_object_id_field_zarr(object_id_field, id_lookup, updates_array, updates_ids, has_merge):
|
|
2571
|
+
"""
|
|
2572
|
+
Update object field using a temporary zarr store for better memory efficiency.
|
|
2573
|
+
|
|
2574
|
+
This approach minimises memory usage by writing changes directly to disk,
|
|
2575
|
+
allowing for more efficient parallel processing of large datasets.
|
|
2576
|
+
|
|
2577
|
+
Parameters
|
|
2578
|
+
----------
|
|
2579
|
+
object_id_field : xarray.DataArray
|
|
2580
|
+
The object field to update
|
|
2581
|
+
id_lookup : dict
|
|
2582
|
+
Dictionary mapping temporary IDs to new IDs
|
|
2583
|
+
updates_array : xarray.DataArray
|
|
2584
|
+
Array indicating which spatial indices to update
|
|
2585
|
+
updates_ids : xarray.DataArray
|
|
2586
|
+
The new IDs to assign to updated indices
|
|
2587
|
+
has_merge : xarray.DataArray
|
|
2588
|
+
Boolean indicating whether each timestep has merges
|
|
2589
|
+
|
|
2590
|
+
Returns
|
|
2591
|
+
-------
|
|
2592
|
+
xarray.DataArray
|
|
2593
|
+
Updated object field from zarr store
|
|
2594
|
+
"""
|
|
2595
|
+
|
|
2596
|
+
# Early return if no merges to save memory
|
|
2597
|
+
if not has_merge.any().compute().item():
|
|
2598
|
+
return object_id_field
|
|
2599
|
+
|
|
2600
|
+
zarr_path = f'{self.scratch_dir}/marEx_temp_field.zarr/'
|
|
2601
|
+
|
|
2602
|
+
# Initialise zarr store if needed
|
|
2603
|
+
if not os.path.exists(zarr_path):
|
|
2604
|
+
object_id_field.name = 'temp'
|
|
2605
|
+
object_id_field.to_zarr(zarr_path, mode='w')
|
|
2606
|
+
|
|
2607
|
+
def update_time_chunk(ds_chunk, lookup_dict):
|
|
2608
|
+
"""Process a single chunk with optimised memory usage."""
|
|
2609
|
+
|
|
2610
|
+
# Skip processing if no merges in this chunk
|
|
2611
|
+
needs_update = ds_chunk['has_merge'].any().compute().item()
|
|
2612
|
+
if not needs_update:
|
|
2613
|
+
return ds_chunk['object_field']
|
|
2614
|
+
|
|
2615
|
+
# Extract data from the chunk
|
|
2616
|
+
chunk_data = ds_chunk['object_field']
|
|
2617
|
+
chunk_updates = ds_chunk['updates']
|
|
2618
|
+
chunk_update_ids = ds_chunk['update_ids']
|
|
2619
|
+
|
|
2620
|
+
# Get zarr region indices
|
|
2621
|
+
time_idx_start = int(ds_chunk['time_indices'].values[0])
|
|
2622
|
+
time_idx_end = int(ds_chunk['time_indices'].values[-1]) + 1
|
|
2623
|
+
|
|
2624
|
+
updated_chunk = chunk_data.copy()
|
|
2625
|
+
|
|
2626
|
+
# Process each time slice in the chunk
|
|
2627
|
+
for t in range(chunk_data.sizes[self.timedim]):
|
|
2628
|
+
# Get update information for this time
|
|
2629
|
+
updates_slice = chunk_updates.isel({self.timedim: t}).values
|
|
2630
|
+
update_ids_slice = chunk_update_ids.isel({self.timedim: t}).values
|
|
2631
|
+
|
|
2632
|
+
# Get valid update IDs
|
|
2633
|
+
valid_mask = update_ids_slice > -1
|
|
2634
|
+
if not np.any(valid_mask):
|
|
2635
|
+
continue
|
|
2636
|
+
|
|
2637
|
+
valid_ids = update_ids_slice[valid_mask]
|
|
2638
|
+
|
|
2639
|
+
# Get the time slice data and apply updates
|
|
2640
|
+
result_slice = updated_chunk.isel({self.timedim: t})
|
|
2641
|
+
|
|
2642
|
+
for idx, update_id in enumerate(valid_ids):
|
|
2643
|
+
mask = updates_slice == idx
|
|
2644
|
+
if np.any(mask):
|
|
2645
|
+
new_id = lookup_dict.get(int(update_id), update_id)
|
|
2646
|
+
result_slice = xr.where(mask, new_id, result_slice)
|
|
2647
|
+
|
|
2648
|
+
# Store updated slice
|
|
2649
|
+
updated_chunk[t] = result_slice
|
|
2650
|
+
|
|
2651
|
+
# Write the updated chunk directly to zarr
|
|
2652
|
+
updated_chunk.name = 'temp'
|
|
2653
|
+
updated_chunk.to_zarr(zarr_path,
|
|
2654
|
+
region={self.timedim: slice(time_idx_start, time_idx_end)})
|
|
2655
|
+
|
|
2656
|
+
return chunk_data # Return original data for dask graph consistency
|
|
2657
|
+
|
|
2658
|
+
# Create time indices for slicing
|
|
2659
|
+
time_coords = object_id_field[self.timedim].values
|
|
2660
|
+
time_indices = np.arange(len(time_coords))
|
|
2661
|
+
time_index_da = xr.DataArray(time_indices, dims=[self.timedim], coords={self.timedim: time_coords})
|
|
2662
|
+
|
|
2663
|
+
# Create dataset with all necessary components
|
|
2664
|
+
ds = xr.Dataset({
|
|
2665
|
+
'object_field': object_id_field,
|
|
2666
|
+
'updates': updates_array,
|
|
2667
|
+
'update_ids': updates_ids,
|
|
2668
|
+
'time_indices': time_index_da,
|
|
2669
|
+
'has_merge': has_merge
|
|
2670
|
+
}).chunk({self.timedim: self.timechunks})
|
|
2671
|
+
|
|
2672
|
+
# Process chunks in parallel
|
|
2673
|
+
result = xr.map_blocks(
|
|
2674
|
+
update_time_chunk,
|
|
2675
|
+
ds,
|
|
2676
|
+
kwargs={"lookup_dict": id_lookup},
|
|
2677
|
+
template=object_id_field
|
|
2678
|
+
)
|
|
2679
|
+
|
|
2680
|
+
# Force computation to ensure all writes complete
|
|
2681
|
+
result = result.persist()
|
|
2682
|
+
wait(result)
|
|
2683
|
+
|
|
2684
|
+
# Release resources
|
|
2685
|
+
del result, ds, object_id_field
|
|
2686
|
+
gc.collect()
|
|
2687
|
+
|
|
2688
|
+
# Load the updated data from zarr store
|
|
2689
|
+
object_id_field_new = xr.open_zarr(zarr_path, chunks={self.timedim: self.timechunks}).temp
|
|
2690
|
+
|
|
2691
|
+
return object_id_field_new
|
|
2692
|
+
|
|
2693
|
+
|
|
2694
|
+
def merge_objects_parallel_iteration(object_id_field_unique, merging_objects, global_id_counter):
|
|
2695
|
+
"""
|
|
2696
|
+
Perform a single iteration of the parallel merging process.
|
|
2697
|
+
|
|
2698
|
+
This function handles one complete batch of merging objects across all
|
|
2699
|
+
timesteps, updating object IDs and tracking merge events.
|
|
2700
|
+
|
|
2701
|
+
Parameters
|
|
2702
|
+
----------
|
|
2703
|
+
object_id_field_unique : xarray.DataArray
|
|
2704
|
+
Field of unique object IDs
|
|
2705
|
+
merging_objects : set
|
|
2706
|
+
Set of object IDs to process in this iteration
|
|
2707
|
+
global_id_counter : int
|
|
2708
|
+
Current counter for assigning new global IDs
|
|
2709
|
+
|
|
2710
|
+
Returns
|
|
2711
|
+
-------
|
|
2712
|
+
tuple
|
|
2713
|
+
(updated_field, merge_data, new_merging_objects, updated_counter)
|
|
2714
|
+
"""
|
|
2715
|
+
|
|
2716
|
+
n_time = len(object_id_field_unique[self.timedim])
|
|
2717
|
+
|
|
2718
|
+
# Pre-allocate arrays for this iteration
|
|
2719
|
+
child_ids_iter = np.full((n_time, MAX_MERGES, MAX_CHILDREN), -1, dtype=np.int32) # List of child ID arrays for this time
|
|
2720
|
+
parent_ids_iter = np.full((n_time, MAX_MERGES, MAX_PARENTS), -1, dtype=np.int32) # List of parent ID arrays for this time
|
|
2721
|
+
merge_areas_iter = np.full((n_time, MAX_MERGES, MAX_PARENTS), -1, dtype=np.float32) # List of areas for this time
|
|
2722
|
+
merge_counts_iter = np.zeros(n_time, dtype=np.int32)
|
|
2723
|
+
|
|
2724
|
+
# Prepare neighbour information
|
|
2725
|
+
neighbours_int = self.neighbours_int.chunk({self.xdim: -1, 'nv': -1})
|
|
2726
|
+
|
|
2727
|
+
if self.verbosity > 0:
|
|
2728
|
+
print(f"Processing Parallel Iteration {iteration + 1} with {len(merging_objects)} Merging Objects...")
|
|
2729
|
+
|
|
2730
|
+
# Pre-compute the child_time_idx for merging_objects
|
|
2731
|
+
time_index_map = self.compute_id_time_dict(object_id_field_unique, list(merging_objects), global_id_counter)
|
|
2732
|
+
if self.verbosity > 1:
|
|
2733
|
+
print(' Finished Mapping Children to Time Indices')
|
|
2734
|
+
|
|
2735
|
+
# Create uniform array of merging objects for each timestep
|
|
2736
|
+
max_merges = max(len([b for b in merging_objects if time_index_map.get(b, -1) == t]) for t in range(n_time))
|
|
2737
|
+
uniform_merging_objects_array = np.zeros((n_time, max_merges), dtype=np.int64)
|
|
2738
|
+
for t in range(n_time):
|
|
2739
|
+
objects_at_t = [b for b in merging_objects if time_index_map.get(b, -1) == t]
|
|
2740
|
+
if objects_at_t: # Only fill if there are objects at this time
|
|
2741
|
+
uniform_merging_objects_array[t, :len(objects_at_t)] = np.array(objects_at_t, dtype=np.int64)
|
|
2742
|
+
|
|
2743
|
+
# Create DataArrays for parallel processing
|
|
2744
|
+
merging_objects_da = xr.DataArray(
|
|
2745
|
+
uniform_merging_objects_array,
|
|
2746
|
+
dims=[self.timedim, 'merges'],
|
|
2747
|
+
coords={self.timedim: object_id_field_unique[self.timedim]}
|
|
2748
|
+
)
|
|
2749
|
+
|
|
2750
|
+
# Calculate ID offsets for each timestep to ensure unique IDs
|
|
2751
|
+
next_id_offsets = np.arange(n_time) * max_merges * self.timechunks + global_id_counter
|
|
2752
|
+
# N.B.: We also need to account for possibility of newly-split objects then creating more than max_merges by the end of the iteration through the chunk
|
|
2753
|
+
# !!! This is likely the root cause of any errors such as "ID needs to be contiguous/continuous/full/unrepeated"
|
|
2754
|
+
next_id_offsets_da = xr.DataArray(
|
|
2755
|
+
next_id_offsets,
|
|
2756
|
+
dims=[self.timedim],
|
|
2757
|
+
coords={self.timedim: object_id_field_unique[self.timedim]}
|
|
2758
|
+
)
|
|
2759
|
+
|
|
2760
|
+
# Create shifted arrays for time connectivity
|
|
2761
|
+
object_id_field_unique_p1 = object_id_field_unique.shift({self.timedim: -1}, fill_value=0)
|
|
2762
|
+
object_id_field_unique_m1 = object_id_field_unique.shift({self.timedim: 1}, fill_value=0)
|
|
2763
|
+
|
|
2764
|
+
# Align chunks for better parallel processing
|
|
2765
|
+
object_id_field_unique_m1 = object_id_field_unique_m1.chunk({self.timedim: self.timechunks})
|
|
2766
|
+
object_id_field_unique_p1 = object_id_field_unique_p1.chunk({self.timedim: self.timechunks})
|
|
2767
|
+
merging_objects_da = merging_objects_da.chunk({self.timedim: self.timechunks})
|
|
2768
|
+
next_id_offsets_da = next_id_offsets_da.chunk({self.timedim: self.timechunks})
|
|
2769
|
+
|
|
2770
|
+
# Process chunks in parallel
|
|
2771
|
+
results = xr.apply_ufunc(
|
|
2772
|
+
process_chunk,
|
|
2773
|
+
object_id_field_unique_m1,
|
|
2774
|
+
object_id_field_unique_p1,
|
|
2775
|
+
merging_objects_da,
|
|
2776
|
+
next_id_offsets_da,
|
|
2777
|
+
self.lat,
|
|
2778
|
+
self.lon,
|
|
2779
|
+
self.cell_area,
|
|
2780
|
+
neighbours_int,
|
|
2781
|
+
input_core_dims=[
|
|
2782
|
+
[self.xdim], [self.xdim], ['merges'], [],
|
|
2783
|
+
[self.xdim], [self.xdim], [self.xdim], ['nv', self.xdim]
|
|
2784
|
+
],
|
|
2785
|
+
output_core_dims=[
|
|
2786
|
+
['merge', 'parent'], ['merge', 'parent'], ['merge', 'parent'], [],
|
|
2787
|
+
[], [self.xdim], ['update_idx'], ['merge']
|
|
2788
|
+
],
|
|
2789
|
+
output_dtypes=[
|
|
2790
|
+
np.int32, np.int32, np.float32, np.int16,
|
|
2791
|
+
np.bool_, np.uint8, np.int32, np.int32
|
|
2792
|
+
],
|
|
2793
|
+
dask_gufunc_kwargs={
|
|
2794
|
+
'output_sizes': {
|
|
2795
|
+
'merge': MAX_MERGES,
|
|
2796
|
+
'parent': MAX_PARENTS,
|
|
2797
|
+
'update_idx': 255
|
|
2798
|
+
}
|
|
2799
|
+
},
|
|
2800
|
+
vectorize=False,
|
|
2801
|
+
dask='parallelized'
|
|
2802
|
+
)
|
|
2803
|
+
|
|
2804
|
+
# Unpack and persist results
|
|
2805
|
+
(merge_child_ids, merge_parent_ids, merge_areas, merge_counts,
|
|
2806
|
+
has_merge, updates_array, updates_ids, final_merging_objects) = results
|
|
2807
|
+
|
|
2808
|
+
results = persist(
|
|
2809
|
+
merge_child_ids, merge_parent_ids, merge_areas, merge_counts,
|
|
2810
|
+
has_merge, updates_array, updates_ids, final_merging_objects
|
|
2811
|
+
)
|
|
2812
|
+
(merge_child_ids, merge_parent_ids, merge_areas, merge_counts,
|
|
2813
|
+
has_merge, updates_array, updates_ids, final_merging_objects) = results
|
|
2814
|
+
|
|
2815
|
+
# Get time indices where merges occurred
|
|
2816
|
+
has_merge = has_merge.compute()
|
|
2817
|
+
time_indices = np.where(has_merge)[0]
|
|
2818
|
+
|
|
2819
|
+
# Clean up temporary arrays to save memory
|
|
2820
|
+
del object_id_field_unique_p1, object_id_field_unique_m1, merging_objects_da, next_id_offsets_da
|
|
2821
|
+
gc.collect()
|
|
2822
|
+
|
|
2823
|
+
if self.verbosity > 1:
|
|
2824
|
+
print(' Finished Batch Processing Step')
|
|
2825
|
+
|
|
2826
|
+
|
|
2827
|
+
# ====== Global Consolidation of Data ======
|
|
2828
|
+
|
|
2829
|
+
# 1. Collect all temporary IDs and create global mapping
|
|
2830
|
+
all_temp_ids = np.unique(
|
|
2831
|
+
merge_child_ids.where(merge_child_ids >= global_id_counter, other=0).compute().values
|
|
2832
|
+
)
|
|
2833
|
+
all_temp_ids = all_temp_ids[all_temp_ids > 0] # Remove the 0
|
|
2834
|
+
|
|
2835
|
+
if not len(all_temp_ids): # If no temporary IDs exist
|
|
2836
|
+
id_lookup = {}
|
|
2837
|
+
else:
|
|
2838
|
+
# Create mapping from temporary to permanent IDs
|
|
2839
|
+
id_lookup = {
|
|
2840
|
+
temp_id: np.int32(new_id) for temp_id, new_id in zip(
|
|
2841
|
+
all_temp_ids,
|
|
2842
|
+
range(global_id_counter, global_id_counter + len(all_temp_ids))
|
|
2843
|
+
)
|
|
2844
|
+
}
|
|
2845
|
+
global_id_counter += len(all_temp_ids)
|
|
2846
|
+
|
|
2847
|
+
if self.verbosity > 1:
|
|
2848
|
+
print(' Finished Consolidation Step 1: Temporary ID Mapping')
|
|
2849
|
+
|
|
2850
|
+
# 2. Update object ID field with new IDs
|
|
2851
|
+
update_on_disk = True # This is more memory efficient because it refreshes the dask graph every iteration
|
|
2852
|
+
|
|
2853
|
+
if update_on_disk:
|
|
2854
|
+
object_id_field_unique = update_object_id_field_zarr(
|
|
2855
|
+
object_id_field_unique, id_lookup, updates_array, updates_ids, has_merge
|
|
2856
|
+
)
|
|
2857
|
+
else:
|
|
2858
|
+
object_id_field_unique = update_object_id_field_inplace(
|
|
2859
|
+
object_id_field_unique, id_lookup, updates_array, updates_ids, has_merge
|
|
2860
|
+
)
|
|
2861
|
+
object_id_field_unique = object_id_field_unique.chunk({self.timedim: self.timechunks}) # Rechunk to avoid accumulating chunks...
|
|
2862
|
+
|
|
2863
|
+
# Clean up arrays no longer needed
|
|
2864
|
+
del updates_array, updates_ids
|
|
2865
|
+
gc.collect()
|
|
2866
|
+
|
|
2867
|
+
if self.verbosity > 1:
|
|
2868
|
+
print(' Finished Consolidation Step 2: Data Field Update')
|
|
2869
|
+
|
|
2870
|
+
# 3. Update merge events
|
|
2871
|
+
new_merging_objects = set()
|
|
2872
|
+
merge_counts = merge_counts.compute()
|
|
2873
|
+
|
|
2874
|
+
for t in time_indices:
|
|
2875
|
+
count = merge_counts.isel({self.timedim: t}).item()
|
|
2876
|
+
if count > 0:
|
|
2877
|
+
merge_counts_iter[t] = count
|
|
2878
|
+
|
|
2879
|
+
# Extract valid IDs and areas for each merge event
|
|
2880
|
+
for merge_idx in range(count):
|
|
2881
|
+
# Get child IDs
|
|
2882
|
+
child_ids = merge_child_ids.isel({self.timedim: t, 'merge': merge_idx}).compute().values
|
|
2883
|
+
child_ids = child_ids[child_ids >= 0]
|
|
2884
|
+
|
|
2885
|
+
# Get parent IDs and areas
|
|
2886
|
+
parent_ids = merge_parent_ids.isel({self.timedim: t, 'merge': merge_idx}).compute().values
|
|
2887
|
+
areas = merge_areas.isel({self.timedim: t, 'merge': merge_idx}).compute().values
|
|
2888
|
+
valid_mask = parent_ids >= 0
|
|
2889
|
+
parent_ids = parent_ids[valid_mask]
|
|
2890
|
+
areas = areas[valid_mask]
|
|
2891
|
+
|
|
2892
|
+
# Map temporary IDs to permanent IDs
|
|
2893
|
+
mapped_child_ids = [id_lookup.get(id_.item(), id_.item()) for id_ in child_ids]
|
|
2894
|
+
mapped_parent_ids = [id_lookup.get(id_.item(), id_.item()) for id_ in parent_ids]
|
|
2895
|
+
|
|
2896
|
+
# Store in pre-allocated arrays
|
|
2897
|
+
child_ids_iter[t, merge_idx, :len(mapped_child_ids)] = mapped_child_ids
|
|
2898
|
+
parent_ids_iter[t, merge_idx, :len(mapped_parent_ids)] = mapped_parent_ids
|
|
2899
|
+
merge_areas_iter[t, merge_idx, :len(areas)] = areas
|
|
2900
|
+
|
|
2901
|
+
# Process final merging objects for next iteration
|
|
2902
|
+
final_merging_objects = final_merging_objects.compute().values
|
|
2903
|
+
final_merging_objects = final_merging_objects[final_merging_objects > 0]
|
|
2904
|
+
mapped_final_objects = [id_lookup.get(id_, id_) for id_ in final_merging_objects]
|
|
2905
|
+
new_merging_objects.update(mapped_final_objects)
|
|
2906
|
+
|
|
2907
|
+
if self.verbosity > 1:
|
|
2908
|
+
print(' Finished Consolidation Step 3: Merge List Dictionary Consolidation')
|
|
2909
|
+
|
|
2910
|
+
# Clean up memory
|
|
2911
|
+
del merge_child_ids, merge_parent_ids, merge_areas, merge_counts, has_merge
|
|
2912
|
+
gc.collect()
|
|
2913
|
+
|
|
2914
|
+
return (
|
|
2915
|
+
object_id_field_unique,
|
|
2916
|
+
(child_ids_iter, parent_ids_iter, merge_areas_iter, merge_counts_iter),
|
|
2917
|
+
new_merging_objects,
|
|
2918
|
+
global_id_counter
|
|
2919
|
+
)
|
|
2920
|
+
|
|
2921
|
+
# ============================
|
|
2922
|
+
# Main Loop for Parallel Merging
|
|
2923
|
+
# ============================
|
|
2924
|
+
|
|
2925
|
+
# Find overlapping objects
|
|
2926
|
+
overlap_objects_list = self.find_overlapping_objects(object_id_field_unique) # List object pairs that overlap by at least overlap_threshold percent
|
|
2927
|
+
overlap_objects_list = self.enforce_overlap_threshold(overlap_objects_list, object_props)
|
|
2928
|
+
if self.verbosity > 0:
|
|
2929
|
+
print('Finished finding overlapping objects')
|
|
2930
|
+
|
|
2931
|
+
# Find initial merging objects
|
|
2932
|
+
unique_children, children_counts = np.unique(overlap_objects_list[:, 1], return_counts=True)
|
|
2933
|
+
merging_objects = set(unique_children[children_counts > 1].astype(np.int32))
|
|
2934
|
+
del overlap_objects_list
|
|
2935
|
+
|
|
2936
|
+
## Process chunks iteratively until no new merging objects remain
|
|
2937
|
+
|
|
2938
|
+
iteration = 0
|
|
2939
|
+
processed_chunks = set()
|
|
2940
|
+
global_id_counter = object_props.ID.max().item() + 1
|
|
2941
|
+
|
|
2942
|
+
# Initialize global merge event tracking
|
|
2943
|
+
global_child_ids = []
|
|
2944
|
+
global_parent_ids = []
|
|
2945
|
+
global_merge_areas = []
|
|
2946
|
+
global_merge_tidx = []
|
|
2947
|
+
|
|
2948
|
+
while merging_objects and iteration < self.max_iteration:
|
|
2949
|
+
object_id_field_new, merge_data_iter, new_merging_objects, global_id_counter = merge_objects_parallel_iteration(
|
|
2950
|
+
object_id_field_unique, merging_objects, global_id_counter
|
|
2951
|
+
)
|
|
2952
|
+
child_ids_iter, parent_ids_iter, merge_areas_iter, merge_counts_iter = merge_data_iter
|
|
2953
|
+
|
|
2954
|
+
# Consolidate merge events from this iteration
|
|
2955
|
+
for t in range(len(merge_counts_iter)):
|
|
2956
|
+
count = merge_counts_iter[t]
|
|
2957
|
+
if count > 0:
|
|
2958
|
+
for merge_idx in range(count):
|
|
2959
|
+
# Extract valid children
|
|
2960
|
+
children = child_ids_iter[t, merge_idx]
|
|
2961
|
+
children = children[children >= 0]
|
|
2962
|
+
|
|
2963
|
+
# Extract valid parents and areas
|
|
2964
|
+
parents = parent_ids_iter[t, merge_idx]
|
|
2965
|
+
areas = merge_areas_iter[t, merge_idx]
|
|
2966
|
+
valid_mask = parents >= 0
|
|
2967
|
+
parents = parents[valid_mask]
|
|
2968
|
+
areas = areas[valid_mask]
|
|
2969
|
+
|
|
2970
|
+
# Record valid merge events
|
|
2971
|
+
if len(children) > 0 and len(parents) > 0:
|
|
2972
|
+
global_child_ids.append(children)
|
|
2973
|
+
global_parent_ids.append(parents)
|
|
2974
|
+
global_merge_areas.append(areas)
|
|
2975
|
+
global_merge_tidx.append(t)
|
|
2976
|
+
|
|
2977
|
+
# Prepare for next iteration - only process objects not already handled
|
|
2978
|
+
merging_objects = new_merging_objects - processed_chunks
|
|
2979
|
+
processed_chunks.update(new_merging_objects)
|
|
2980
|
+
iteration += 1
|
|
2981
|
+
|
|
2982
|
+
# Update the object field
|
|
2983
|
+
object_id_field_unique = object_id_field_new
|
|
2984
|
+
del object_id_field_new
|
|
2985
|
+
|
|
2986
|
+
# Check if we reached maximum iterations
|
|
2987
|
+
if iteration == self.max_iteration:
|
|
2988
|
+
raise RuntimeError(
|
|
2989
|
+
f"Reached maximum iterations ({self.max_iteration}) in split_and_merge_objects_parallel. "
|
|
2990
|
+
f"Set optional argument 'max_iteration' to a higher value."
|
|
2991
|
+
)
|
|
2992
|
+
|
|
2993
|
+
## Process the collected merge events
|
|
2994
|
+
|
|
2995
|
+
times = object_id_field_unique[self.timedim].values
|
|
2996
|
+
|
|
2997
|
+
# Find maximum dimensions for arrays
|
|
2998
|
+
max_parents = max(len(ids) for ids in global_parent_ids)
|
|
2999
|
+
max_children = max(len(ids) for ids in global_child_ids)
|
|
3000
|
+
|
|
3001
|
+
# Create padded arrays for merge events
|
|
3002
|
+
parent_ids_array = np.full((len(global_parent_ids), max_parents), -1, dtype=np.int32)
|
|
3003
|
+
child_ids_array = np.full((len(global_child_ids), max_children), -1, dtype=np.int32)
|
|
3004
|
+
overlap_areas_array = np.full(
|
|
3005
|
+
(len(global_merge_areas), max_parents),
|
|
3006
|
+
-1,
|
|
3007
|
+
dtype=np.float32 if self.unstructured_grid else np.int32
|
|
3008
|
+
)
|
|
3009
|
+
|
|
3010
|
+
# Fill arrays with merge data
|
|
3011
|
+
for i, parents in enumerate(global_parent_ids):
|
|
3012
|
+
parent_ids_array[i, :len(parents)] = parents
|
|
3013
|
+
|
|
3014
|
+
for i, children in enumerate(global_child_ids):
|
|
3015
|
+
child_ids_array[i, :len(children)] = children
|
|
3016
|
+
|
|
3017
|
+
for i, areas in enumerate(global_merge_areas):
|
|
3018
|
+
overlap_areas_array[i, :len(areas)] = areas
|
|
3019
|
+
|
|
3020
|
+
# Create merge events dataset
|
|
3021
|
+
merge_events = xr.Dataset(
|
|
3022
|
+
{
|
|
3023
|
+
'parent_IDs': (('merge_ID', 'parent_idx'), parent_ids_array),
|
|
3024
|
+
'child_IDs': (('merge_ID', 'child_idx'), child_ids_array),
|
|
3025
|
+
'overlap_areas': (('merge_ID', 'parent_idx'), overlap_areas_array),
|
|
3026
|
+
'merge_time': ('merge_ID', times[global_merge_tidx]),
|
|
3027
|
+
'n_parents': ('merge_ID', np.array([len(p) for p in global_parent_ids], dtype=np.int8)),
|
|
3028
|
+
'n_children': ('merge_ID', np.array([len(c) for c in global_child_ids], dtype=np.int8))
|
|
3029
|
+
},
|
|
3030
|
+
attrs={
|
|
3031
|
+
'fill_value': -1
|
|
3032
|
+
}
|
|
3033
|
+
)
|
|
3034
|
+
|
|
3035
|
+
# Recompute object properties and overlaps after all merging
|
|
3036
|
+
object_id_field_unique = object_id_field_unique.persist(optimize_graph=True)
|
|
3037
|
+
object_props = self.calculate_object_properties(
|
|
3038
|
+
object_id_field_unique,
|
|
3039
|
+
properties=['area', 'centroid']
|
|
3040
|
+
).persist(optimize_graph=True)
|
|
3041
|
+
|
|
3042
|
+
# Recompute overlaps based on final object configuration
|
|
3043
|
+
overlap_objects_list = self.find_overlapping_objects(object_id_field_unique)
|
|
3044
|
+
overlap_objects_list = self.enforce_overlap_threshold(overlap_objects_list, object_props)
|
|
3045
|
+
overlap_objects_list = overlap_objects_list[:, :2].astype(np.int32)
|
|
3046
|
+
|
|
3047
|
+
return (
|
|
3048
|
+
object_id_field_unique,
|
|
3049
|
+
object_props,
|
|
3050
|
+
overlap_objects_list,
|
|
3051
|
+
merge_events
|
|
3052
|
+
)
|
|
3053
|
+
|
|
3054
|
+
|
|
3055
|
+
|
|
3056
|
+
|
|
3057
|
+
|
|
3058
|
+
"""
|
|
3059
|
+
MarEx Helper Functions
|
|
3060
|
+
|
|
3061
|
+
These are the remaining implementations of helper functions for the MarEx package,
|
|
3062
|
+
providing optimised algorithms for partitioning, distance calculations, and spatial
|
|
3063
|
+
operations on both structured and unstructured grids.
|
|
3064
|
+
"""
|
|
3065
|
+
|
|
3066
|
+
@jit(nopython=True, parallel=True, fastmath=True)
|
|
3067
|
+
def wrapped_euclidian_parallel(mask_values, parent_centroids_values, Nx):
|
|
3068
|
+
"""
|
|
3069
|
+
Optimised function for computing wrapped Euclidean distances.
|
|
3070
|
+
|
|
3071
|
+
Efficiently calculates distances between points in a binary mask and a set of
|
|
3072
|
+
centroids, accounting for periodic boundaries in the x dimension.
|
|
3073
|
+
|
|
3074
|
+
Parameters
|
|
3075
|
+
----------
|
|
3076
|
+
mask_values : np.ndarray
|
|
3077
|
+
2D boolean array where True indicates points to calculate distances for
|
|
3078
|
+
parent_centroids_values : np.ndarray
|
|
3079
|
+
Array of shape (n_parents, 2) containing (y, x) coordinates of parent centroids
|
|
3080
|
+
Nx : int
|
|
3081
|
+
Size of the x-dimension for periodic boundary wrapping
|
|
3082
|
+
|
|
3083
|
+
Returns
|
|
3084
|
+
-------
|
|
3085
|
+
distances : np.ndarray
|
|
3086
|
+
Array of shape (n_true_points, n_parents) with minimum distances
|
|
3087
|
+
"""
|
|
3088
|
+
n_parents = len(parent_centroids_values)
|
|
3089
|
+
half_Nx = Nx / 2
|
|
3090
|
+
|
|
3091
|
+
y_indices, x_indices = np.nonzero(mask_values)
|
|
3092
|
+
n_true = len(y_indices)
|
|
3093
|
+
|
|
3094
|
+
distances = np.empty((n_true, n_parents), dtype=np.float64)
|
|
3095
|
+
|
|
3096
|
+
# Precompute for faster access
|
|
3097
|
+
parent_y = parent_centroids_values[:, 0]
|
|
3098
|
+
parent_x = parent_centroids_values[:, 1]
|
|
3099
|
+
|
|
3100
|
+
# Parallel loop over true positions
|
|
3101
|
+
for idx in prange(n_true):
|
|
3102
|
+
y, x = y_indices[idx], x_indices[idx]
|
|
3103
|
+
|
|
3104
|
+
# Pre-compute y differences for all parents
|
|
3105
|
+
dy = y - parent_y
|
|
3106
|
+
|
|
3107
|
+
# Pre-compute x differences for all parents
|
|
3108
|
+
dx = x - parent_x
|
|
3109
|
+
|
|
3110
|
+
# Wrapping correction
|
|
3111
|
+
dx = np.where(dx > half_Nx, dx - Nx, dx)
|
|
3112
|
+
dx = np.where(dx < -half_Nx, dx + Nx, dx)
|
|
3113
|
+
|
|
3114
|
+
distances[idx] = np.sqrt(dy * dy + dx * dx)
|
|
3115
|
+
|
|
3116
|
+
return distances
|
|
3117
|
+
|
|
3118
|
+
@jit(nopython=True, fastmath=True)
|
|
3119
|
+
def create_grid_index_arrays(points_y, points_x, grid_size, ny, nx):
|
|
3120
|
+
"""
|
|
3121
|
+
Create a grid-based spatial index for efficient point lookup.
|
|
3122
|
+
|
|
3123
|
+
This function divides space into a grid and assigns points to grid cells
|
|
3124
|
+
for more efficient spatial queries compared to brute force comparisons.
|
|
3125
|
+
|
|
3126
|
+
Parameters
|
|
3127
|
+
----------
|
|
3128
|
+
points_y, points_x : np.ndarray
|
|
3129
|
+
Coordinates of points to index
|
|
3130
|
+
grid_size : int
|
|
3131
|
+
Size of each grid cell
|
|
3132
|
+
ny, nx : int
|
|
3133
|
+
Dimensions of the overall grid
|
|
3134
|
+
|
|
3135
|
+
Returns
|
|
3136
|
+
-------
|
|
3137
|
+
grid_points : np.ndarray
|
|
3138
|
+
3D array mapping grid cells to point indices
|
|
3139
|
+
grid_counts : np.ndarray
|
|
3140
|
+
2D array with count of points in each grid cell
|
|
3141
|
+
"""
|
|
3142
|
+
n_grids_y = (ny + grid_size - 1) // grid_size
|
|
3143
|
+
n_grids_x = (nx + grid_size - 1) // grid_size
|
|
3144
|
+
max_points_per_cell = len(points_y)
|
|
3145
|
+
|
|
3146
|
+
grid_points = np.full((n_grids_y, n_grids_x, max_points_per_cell), -1, dtype=np.int32)
|
|
3147
|
+
grid_counts = np.zeros((n_grids_y, n_grids_x), dtype=np.int32)
|
|
3148
|
+
|
|
3149
|
+
for idx in range(len(points_y)):
|
|
3150
|
+
grid_y = min(points_y[idx] // grid_size, n_grids_y - 1)
|
|
3151
|
+
grid_x = min(points_x[idx] // grid_size, n_grids_x - 1)
|
|
3152
|
+
count = grid_counts[grid_y, grid_x]
|
|
3153
|
+
if count < max_points_per_cell:
|
|
3154
|
+
grid_points[grid_y, grid_x, count] = idx
|
|
3155
|
+
grid_counts[grid_y, grid_x] += 1
|
|
3156
|
+
|
|
3157
|
+
return grid_points, grid_counts
|
|
3158
|
+
|
|
3159
|
+
@jit(nopython=True, fastmath=True)
|
|
3160
|
+
def calculate_wrapped_distance(y1, x1, y2, x2, nx, half_nx):
|
|
3161
|
+
"""
|
|
3162
|
+
Calculate distance with periodic boundary conditions in x dimension.
|
|
3163
|
+
|
|
3164
|
+
Parameters
|
|
3165
|
+
----------
|
|
3166
|
+
y1, x1 : float
|
|
3167
|
+
Coordinates of first point
|
|
3168
|
+
y2, x2 : float
|
|
3169
|
+
Coordinates of second point
|
|
3170
|
+
nx : int
|
|
3171
|
+
Size of x dimension
|
|
3172
|
+
half_nx : float
|
|
3173
|
+
Half the size of x dimension
|
|
3174
|
+
|
|
3175
|
+
Returns
|
|
3176
|
+
-------
|
|
3177
|
+
float
|
|
3178
|
+
Euclidean distance accounting for periodic boundary in x
|
|
3179
|
+
"""
|
|
3180
|
+
dy = y1 - y2
|
|
3181
|
+
dx = x1 - x2
|
|
3182
|
+
|
|
3183
|
+
if dx > half_nx:
|
|
3184
|
+
dx -= nx
|
|
3185
|
+
elif dx < -half_nx:
|
|
3186
|
+
dx += nx
|
|
3187
|
+
|
|
3188
|
+
return np.sqrt(dy * dy + dx * dx)
|
|
3189
|
+
|
|
3190
|
+
@jit(nopython=True, parallel=True, fastmath=True)
|
|
3191
|
+
def partition_nn_grid(child_mask, parent_masks, child_ids, parent_centroids, Nx, max_distance=20):
|
|
3192
|
+
"""
|
|
3193
|
+
Partition a child object based on nearest parent object points.
|
|
3194
|
+
|
|
3195
|
+
This implementation uses spatial indexing and highly-threaded processing
|
|
3196
|
+
for efficient distance calculations. The algorithm assigns each point
|
|
3197
|
+
in the child object to the closest parent object.
|
|
3198
|
+
|
|
3199
|
+
Parameters
|
|
3200
|
+
----------
|
|
3201
|
+
child_mask : np.ndarray
|
|
3202
|
+
Binary mask of the child object
|
|
3203
|
+
parent_masks : np.ndarray
|
|
3204
|
+
List of binary masks for each parent object
|
|
3205
|
+
child_ids : np.ndarray
|
|
3206
|
+
List of IDs to assign to partitions
|
|
3207
|
+
parent_centroids : np.ndarray
|
|
3208
|
+
Array of shape (n_parents, 2) with parent centroids
|
|
3209
|
+
Nx : int
|
|
3210
|
+
Size of x dimension for periodic boundaries
|
|
3211
|
+
max_distance : int, default=20
|
|
3212
|
+
Maximum search distance
|
|
3213
|
+
|
|
3214
|
+
Returns
|
|
3215
|
+
-------
|
|
3216
|
+
new_labels : np.ndarray
|
|
3217
|
+
Array containing assigned child_ids for each point
|
|
3218
|
+
"""
|
|
3219
|
+
ny, nx = child_mask.shape
|
|
3220
|
+
half_Nx = Nx / 2
|
|
3221
|
+
n_parents = len(parent_masks)
|
|
3222
|
+
grid_size = max(2, max_distance // 4)
|
|
3223
|
+
|
|
3224
|
+
y_indices, x_indices = np.nonzero(child_mask)
|
|
3225
|
+
n_child_points = len(y_indices)
|
|
3226
|
+
|
|
3227
|
+
min_distances = np.full(n_child_points, np.inf)
|
|
3228
|
+
parent_assignments = np.zeros(n_child_points, dtype=np.int32)
|
|
3229
|
+
found_close = np.zeros(n_child_points, dtype=np.bool_)
|
|
3230
|
+
|
|
3231
|
+
for parent_idx in range(n_parents):
|
|
3232
|
+
py, px = np.nonzero(parent_masks[parent_idx])
|
|
3233
|
+
|
|
3234
|
+
if len(py) == 0: # Skip empty parents
|
|
3235
|
+
continue
|
|
3236
|
+
|
|
3237
|
+
# Create grid index for this parent
|
|
3238
|
+
n_grids_y = (ny + grid_size - 1) // grid_size
|
|
3239
|
+
n_grids_x = (nx + grid_size - 1) // grid_size
|
|
3240
|
+
grid_points, grid_counts = create_grid_index_arrays(py, px, grid_size, ny, nx)
|
|
3241
|
+
|
|
3242
|
+
# Process child points in parallel
|
|
3243
|
+
for child_idx in prange(n_child_points):
|
|
3244
|
+
if found_close[child_idx]: # Skip if we already found an exact match
|
|
3245
|
+
continue
|
|
3246
|
+
|
|
3247
|
+
child_y, child_x = y_indices[child_idx], x_indices[child_idx]
|
|
3248
|
+
grid_y = min(child_y // grid_size, n_grids_y - 1)
|
|
3249
|
+
grid_x = min(child_x // grid_size, n_grids_x - 1)
|
|
3250
|
+
|
|
3251
|
+
min_dist_to_parent = np.inf
|
|
3252
|
+
|
|
3253
|
+
# Check nearby grid cells
|
|
3254
|
+
for dy in range(-1, 2):
|
|
3255
|
+
grid_y_check = (grid_y + dy) % n_grids_y
|
|
3256
|
+
|
|
3257
|
+
for dx in range(-1, 2):
|
|
3258
|
+
grid_x_check = (grid_x + dx) % n_grids_x
|
|
3259
|
+
|
|
3260
|
+
# Process points in this grid cell
|
|
3261
|
+
n_points = grid_counts[grid_y_check, grid_x_check]
|
|
3262
|
+
|
|
3263
|
+
for p_idx in range(n_points):
|
|
3264
|
+
point_idx = grid_points[grid_y_check, grid_x_check, p_idx]
|
|
3265
|
+
if point_idx == -1:
|
|
3266
|
+
break
|
|
3267
|
+
|
|
3268
|
+
dist = calculate_wrapped_distance(
|
|
3269
|
+
child_y, child_x,
|
|
3270
|
+
py[point_idx], px[point_idx],
|
|
3271
|
+
Nx, half_Nx
|
|
3272
|
+
)
|
|
3273
|
+
|
|
3274
|
+
if dist > max_distance:
|
|
3275
|
+
continue
|
|
3276
|
+
|
|
3277
|
+
if dist < min_dist_to_parent:
|
|
3278
|
+
min_dist_to_parent = dist
|
|
3279
|
+
|
|
3280
|
+
if dist < 1e-6: # Found exact same point (within numerical precision)
|
|
3281
|
+
min_dist_to_parent = dist
|
|
3282
|
+
found_close[child_idx] = True
|
|
3283
|
+
break
|
|
3284
|
+
|
|
3285
|
+
if found_close[child_idx]:
|
|
3286
|
+
break
|
|
3287
|
+
|
|
3288
|
+
if found_close[child_idx]:
|
|
3289
|
+
break
|
|
3290
|
+
|
|
3291
|
+
# Update assignment if this parent is closer
|
|
3292
|
+
if min_dist_to_parent < min_distances[child_idx]:
|
|
3293
|
+
min_distances[child_idx] = min_dist_to_parent
|
|
3294
|
+
parent_assignments[child_idx] = parent_idx
|
|
3295
|
+
|
|
3296
|
+
# Handle any unassigned points using centroids
|
|
3297
|
+
unassigned = min_distances == np.inf
|
|
3298
|
+
if np.any(unassigned):
|
|
3299
|
+
for child_idx in np.nonzero(unassigned)[0]:
|
|
3300
|
+
child_y, child_x = y_indices[child_idx], x_indices[child_idx]
|
|
3301
|
+
min_dist = np.inf
|
|
3302
|
+
best_parent = 0
|
|
3303
|
+
|
|
3304
|
+
for parent_idx in range(n_parents):
|
|
3305
|
+
# Calculate distance to centroid with periodic boundary conditions
|
|
3306
|
+
dist = calculate_wrapped_distance(
|
|
3307
|
+
child_y, child_x,
|
|
3308
|
+
parent_centroids[parent_idx, 0],
|
|
3309
|
+
parent_centroids[parent_idx, 1],
|
|
3310
|
+
Nx, half_Nx
|
|
3311
|
+
)
|
|
3312
|
+
|
|
3313
|
+
if dist < min_dist:
|
|
3314
|
+
min_dist = dist
|
|
3315
|
+
best_parent = parent_idx
|
|
3316
|
+
|
|
3317
|
+
parent_assignments[child_idx] = best_parent
|
|
3318
|
+
|
|
3319
|
+
# Convert from parent indices to child_ids
|
|
3320
|
+
new_labels = child_ids[parent_assignments]
|
|
3321
|
+
|
|
3322
|
+
return new_labels
|
|
3323
|
+
|
|
3324
|
+
@jit(nopython=True, fastmath=True)
|
|
3325
|
+
def partition_nn_unstructured(child_mask, parent_masks, child_ids, parent_centroids, neighbours_int, lat, lon, max_distance=20):
|
|
3326
|
+
"""
|
|
3327
|
+
Partition a child object on an unstructured grid based on nearest parent points.
|
|
3328
|
+
|
|
3329
|
+
This function implements an efficient algorithm for assigning each cell in a child
|
|
3330
|
+
object to the nearest parent object, using graph traversal and spatial distances.
|
|
3331
|
+
It is optimised for unstructured grids.
|
|
3332
|
+
|
|
3333
|
+
Parameters
|
|
3334
|
+
----------
|
|
3335
|
+
child_mask : np.ndarray
|
|
3336
|
+
1D boolean array where True indicates points in the child object
|
|
3337
|
+
parent_masks : np.ndarray
|
|
3338
|
+
2D boolean array of shape (n_parents, n_points) where True indicates points in each parent object
|
|
3339
|
+
child_ids : np.ndarray
|
|
3340
|
+
1D array containing the IDs to assign to each partition of the child object
|
|
3341
|
+
parent_centroids : np.ndarray
|
|
3342
|
+
Array of shape (n_parents, 2) containing (lat, lon) coordinates of parent centroids in degrees
|
|
3343
|
+
neighbours_int : np.ndarray
|
|
3344
|
+
2D array of shape (3, n_points) containing indices of neighboring cells for each point
|
|
3345
|
+
lat, lon : np.ndarray
|
|
3346
|
+
Latitude/longitude arrays in degrees
|
|
3347
|
+
max_distance : int, default=20
|
|
3348
|
+
Maximum number of edge hops to search for parent points
|
|
3349
|
+
|
|
3350
|
+
Returns
|
|
3351
|
+
-------
|
|
3352
|
+
new_labels : np.ndarray
|
|
3353
|
+
1D array containing the assigned child_ids for each True point in child_mask
|
|
3354
|
+
"""
|
|
3355
|
+
|
|
3356
|
+
# Force contiguous arrays in memory for optimal vectorised performance
|
|
3357
|
+
child_mask = np.ascontiguousarray(child_mask)
|
|
3358
|
+
parent_masks = np.ascontiguousarray(parent_masks)
|
|
3359
|
+
|
|
3360
|
+
n_points = len(child_mask)
|
|
3361
|
+
n_parents = len(parent_masks)
|
|
3362
|
+
|
|
3363
|
+
# Pre-allocate arrays
|
|
3364
|
+
distances = np.full(n_points, np.inf, dtype=np.float32)
|
|
3365
|
+
parent_assignments = np.full(n_points, -1, dtype=np.int32)
|
|
3366
|
+
visited = np.zeros((n_parents, n_points), dtype=np.bool_)
|
|
3367
|
+
|
|
3368
|
+
# Initialise with direct overlaps
|
|
3369
|
+
for parent_idx in range(n_parents):
|
|
3370
|
+
overlap_mask = parent_masks[parent_idx] & child_mask
|
|
3371
|
+
if np.any(overlap_mask):
|
|
3372
|
+
visited[parent_idx, overlap_mask] = True
|
|
3373
|
+
unclaimed_overlap = distances[overlap_mask] == np.inf
|
|
3374
|
+
if np.any(unclaimed_overlap):
|
|
3375
|
+
overlap_points = np.where(overlap_mask)[0]
|
|
3376
|
+
valid_points = overlap_points[unclaimed_overlap]
|
|
3377
|
+
distances[valid_points] = 0
|
|
3378
|
+
parent_assignments[valid_points] = parent_idx
|
|
3379
|
+
|
|
3380
|
+
# Pre-compute trig values for efficiency
|
|
3381
|
+
lat_rad = np.deg2rad(lat)
|
|
3382
|
+
lon_rad = np.deg2rad(lon)
|
|
3383
|
+
cos_lat = np.cos(lat_rad)
|
|
3384
|
+
|
|
3385
|
+
# Graph traversal for remaining points - expanding from parent frontiers
|
|
3386
|
+
current_distance = 0
|
|
3387
|
+
any_unassigned = np.any(child_mask & (parent_assignments == -1))
|
|
3388
|
+
|
|
3389
|
+
while current_distance < max_distance and any_unassigned:
|
|
3390
|
+
current_distance += 1
|
|
3391
|
+
updates_made = False
|
|
3392
|
+
|
|
3393
|
+
for parent_idx in range(n_parents):
|
|
3394
|
+
# Get current frontier points
|
|
3395
|
+
frontier_mask = visited[parent_idx]
|
|
3396
|
+
if not np.any(frontier_mask):
|
|
3397
|
+
continue
|
|
3398
|
+
|
|
3399
|
+
# Process neighbors
|
|
3400
|
+
for i in range(3): # For each neighbor direction
|
|
3401
|
+
neighbors = neighbours_int[i, frontier_mask]
|
|
3402
|
+
valid_neighbors = neighbors >= 0
|
|
3403
|
+
if not np.any(valid_neighbors):
|
|
3404
|
+
continue
|
|
3405
|
+
|
|
3406
|
+
valid_points = neighbors[valid_neighbors]
|
|
3407
|
+
unvisited = ~visited[parent_idx, valid_points]
|
|
3408
|
+
new_points = valid_points[unvisited]
|
|
3409
|
+
|
|
3410
|
+
if len(new_points) > 0:
|
|
3411
|
+
visited[parent_idx, new_points] = True
|
|
3412
|
+
update_mask = distances[new_points] > current_distance
|
|
3413
|
+
if np.any(update_mask):
|
|
3414
|
+
points_to_update = new_points[update_mask]
|
|
3415
|
+
distances[points_to_update] = current_distance
|
|
3416
|
+
parent_assignments[points_to_update] = parent_idx
|
|
3417
|
+
updates_made = True
|
|
3418
|
+
|
|
3419
|
+
if not updates_made:
|
|
3420
|
+
break
|
|
3421
|
+
|
|
3422
|
+
any_unassigned = np.any(child_mask & (parent_assignments == -1))
|
|
3423
|
+
|
|
3424
|
+
# Handle remaining unassigned points using great circle distances
|
|
3425
|
+
unassigned_mask = child_mask & (parent_assignments == -1)
|
|
3426
|
+
if np.any(unassigned_mask):
|
|
3427
|
+
parent_lat_rad = np.deg2rad(parent_centroids[:, 0])
|
|
3428
|
+
parent_lon_rad = np.deg2rad(parent_centroids[:, 1])
|
|
3429
|
+
cos_parent_lat = np.cos(parent_lat_rad)
|
|
3430
|
+
|
|
3431
|
+
unassigned_points = np.where(unassigned_mask)[0]
|
|
3432
|
+
for point in unassigned_points:
|
|
3433
|
+
# Vectorized haversine calculation
|
|
3434
|
+
dlat = parent_lat_rad - lat_rad[point]
|
|
3435
|
+
dlon = parent_lon_rad - lon_rad[point]
|
|
3436
|
+
a = np.sin(dlat/2)**2 + cos_lat[point] * cos_parent_lat * np.sin(dlon/2)**2
|
|
3437
|
+
dist = 2 * np.arctan2(np.sqrt(a), np.sqrt(1-a))
|
|
3438
|
+
parent_assignments[point] = np.argmin(dist)
|
|
3439
|
+
|
|
3440
|
+
# Return only the assignments for points in child_mask
|
|
3441
|
+
child_points = np.where(child_mask)[0]
|
|
3442
|
+
return child_ids[parent_assignments[child_points]]
|
|
3443
|
+
|
|
3444
|
+
@jit(nopython=True, fastmath=True)
|
|
3445
|
+
def partition_nn_unstructured_optimised(child_mask, parent_frontiers, parent_centroids, neighbours_int, lat, lon, max_distance=20):
|
|
3446
|
+
"""
|
|
3447
|
+
Memory-optimised nearest neighbor partitioning for unstructured grids.
|
|
3448
|
+
|
|
3449
|
+
This version uses more efficient memory management compared to partition_nn_unstructured,
|
|
3450
|
+
making it suitable for very large grids. It uses a compact representation of parent
|
|
3451
|
+
frontiers to reduce memory usage during graph traversal.
|
|
3452
|
+
|
|
3453
|
+
Parameters
|
|
3454
|
+
----------
|
|
3455
|
+
child_mask : np.ndarray
|
|
3456
|
+
1D boolean array indicating which cells belong to the child object
|
|
3457
|
+
parent_frontiers : np.ndarray
|
|
3458
|
+
1D uint8 array with parent indices (255 for unvisited points)
|
|
3459
|
+
parent_centroids : np.ndarray
|
|
3460
|
+
Array of shape (n_parents, 2) containing (lat, lon) coordinates
|
|
3461
|
+
neighbours_int : np.ndarray
|
|
3462
|
+
2D array of shape (3, n_points) containing indices of neighboring cells
|
|
3463
|
+
lat, lon : np.ndarray
|
|
3464
|
+
1D arrays of latitude/longitude in degrees
|
|
3465
|
+
max_distance : int, default=20
|
|
3466
|
+
Maximum number of edge hops to search for parent points
|
|
3467
|
+
|
|
3468
|
+
Returns
|
|
3469
|
+
-------
|
|
3470
|
+
result : np.ndarray
|
|
3471
|
+
1D array containing the assigned parent indices for points in child_mask
|
|
3472
|
+
"""
|
|
3473
|
+
|
|
3474
|
+
# Create working copies to ensure memory cleanup
|
|
3475
|
+
parent_frontiers_working = parent_frontiers.copy()
|
|
3476
|
+
child_mask_working = child_mask.copy()
|
|
3477
|
+
|
|
3478
|
+
n_parents = np.max(parent_frontiers_working[parent_frontiers_working < 255]) + 1
|
|
3479
|
+
|
|
3480
|
+
# Graph traversal - expanding frontiers
|
|
3481
|
+
current_distance = 0
|
|
3482
|
+
any_unassigned = np.any(child_mask_working & (parent_frontiers_working == 255))
|
|
3483
|
+
|
|
3484
|
+
while current_distance < max_distance and any_unassigned:
|
|
3485
|
+
current_distance += 1
|
|
3486
|
+
updates_made = False
|
|
3487
|
+
|
|
3488
|
+
for parent_idx in range(n_parents):
|
|
3489
|
+
# Skip if no frontier points for this parent
|
|
3490
|
+
if not np.any(parent_frontiers_working == parent_idx):
|
|
3491
|
+
continue
|
|
3492
|
+
|
|
3493
|
+
# Process neighbours for current parent's frontier
|
|
3494
|
+
for i in range(3):
|
|
3495
|
+
neighbors = neighbours_int[i, parent_frontiers_working == parent_idx]
|
|
3496
|
+
valid_neighbors = neighbors >= 0
|
|
3497
|
+
|
|
3498
|
+
if not np.any(valid_neighbors):
|
|
3499
|
+
continue
|
|
3500
|
+
|
|
3501
|
+
valid_points = neighbors[valid_neighbors]
|
|
3502
|
+
unvisited = parent_frontiers_working[valid_points] == 255
|
|
3503
|
+
|
|
3504
|
+
if not np.any(unvisited):
|
|
3505
|
+
continue
|
|
3506
|
+
|
|
3507
|
+
# Update new frontier points
|
|
3508
|
+
new_points = valid_points[unvisited]
|
|
3509
|
+
parent_frontiers_working[new_points] = parent_idx
|
|
3510
|
+
|
|
3511
|
+
if np.any(child_mask_working[new_points]):
|
|
3512
|
+
updates_made = True
|
|
3513
|
+
|
|
3514
|
+
if not updates_made:
|
|
3515
|
+
break
|
|
3516
|
+
|
|
3517
|
+
any_unassigned = np.any(child_mask_working & (parent_frontiers_working == 255))
|
|
3518
|
+
|
|
3519
|
+
# Handle remaining unassigned points using great circle distances
|
|
3520
|
+
unassigned_mask = child_mask_working & (parent_frontiers_working == 255)
|
|
3521
|
+
if np.any(unassigned_mask):
|
|
3522
|
+
# Pre-compute parent coordinates in radians
|
|
3523
|
+
parent_lat_rad = np.deg2rad(parent_centroids[:, 0])
|
|
3524
|
+
parent_lon_rad = np.deg2rad(parent_centroids[:, 1])
|
|
3525
|
+
cos_parent_lat = np.cos(parent_lat_rad)
|
|
3526
|
+
|
|
3527
|
+
# Process each unassigned point
|
|
3528
|
+
unassigned_points = np.where(unassigned_mask)[0]
|
|
3529
|
+
for point in unassigned_points:
|
|
3530
|
+
dlat = parent_lat_rad - np.deg2rad(lat[point])
|
|
3531
|
+
dlon = parent_lon_rad - np.deg2rad(lon[point])
|
|
3532
|
+
|
|
3533
|
+
a = np.sin(dlat/2)**2 + np.cos(np.deg2rad(lat[point])) * cos_parent_lat * np.sin(dlon/2)**2
|
|
3534
|
+
dist = 2 * np.arctan2(np.sqrt(a), np.sqrt(1-a))
|
|
3535
|
+
|
|
3536
|
+
parent_frontiers_working[point] = np.argmin(dist)
|
|
3537
|
+
|
|
3538
|
+
# Extract result for child points only
|
|
3539
|
+
result = parent_frontiers_working[child_mask_working].copy()
|
|
3540
|
+
|
|
3541
|
+
# Explicitly clear working arrays to help with memory management
|
|
3542
|
+
parent_frontiers_working = None
|
|
3543
|
+
child_mask_working = None
|
|
3544
|
+
|
|
3545
|
+
return result
|
|
3546
|
+
|
|
3547
|
+
@jit(nopython=True, parallel=True, fastmath=True)
|
|
3548
|
+
def partition_centroid_unstructured(child_mask, parent_centroids, child_ids, lat, lon):
|
|
3549
|
+
"""
|
|
3550
|
+
Partition a child object based on closest parent centroids on an unstructured grid.
|
|
3551
|
+
|
|
3552
|
+
This function assigns each cell in the child object to the parent with the closest
|
|
3553
|
+
centroid, using great circle distances on a spherical grid.
|
|
3554
|
+
|
|
3555
|
+
Parameters
|
|
3556
|
+
----------
|
|
3557
|
+
child_mask : np.ndarray
|
|
3558
|
+
1D boolean array indicating which cells belong to the child object
|
|
3559
|
+
parent_centroids : np.ndarray
|
|
3560
|
+
Array of shape (n_parents, 2) containing (lat, lon) coordinates of parent centroids in degrees
|
|
3561
|
+
child_ids : np.ndarray
|
|
3562
|
+
Array of IDs to assign to each partition of the child object
|
|
3563
|
+
lat, lon : np.ndarray
|
|
3564
|
+
Latitude/longitude arrays in degrees
|
|
3565
|
+
|
|
3566
|
+
Returns
|
|
3567
|
+
-------
|
|
3568
|
+
new_labels : np.ndarray
|
|
3569
|
+
1D array containing assigned child_ids for cells in child_mask
|
|
3570
|
+
"""
|
|
3571
|
+
n_cells = len(child_mask)
|
|
3572
|
+
n_parents = len(parent_centroids)
|
|
3573
|
+
|
|
3574
|
+
# Convert to radians for spherical calculations
|
|
3575
|
+
lat_rad = np.deg2rad(lat)
|
|
3576
|
+
lon_rad = np.deg2rad(lon)
|
|
3577
|
+
parent_coords_rad = np.deg2rad(parent_centroids)
|
|
3578
|
+
|
|
3579
|
+
new_labels = np.zeros(n_cells, dtype=child_ids.dtype)
|
|
3580
|
+
|
|
3581
|
+
# Process each child cell in parallel
|
|
3582
|
+
for i in prange(n_cells):
|
|
3583
|
+
if not child_mask[i]:
|
|
3584
|
+
continue
|
|
3585
|
+
|
|
3586
|
+
min_dist = np.inf
|
|
3587
|
+
closest_parent = 0
|
|
3588
|
+
|
|
3589
|
+
# Calculate great circle distance to each parent centroid
|
|
3590
|
+
for j in range(n_parents):
|
|
3591
|
+
dlat = parent_coords_rad[j, 0] - lat_rad[i]
|
|
3592
|
+
dlon = parent_coords_rad[j, 1] - lon_rad[i]
|
|
3593
|
+
|
|
3594
|
+
# Use haversine formula for great circle distance
|
|
3595
|
+
a = np.sin(dlat/2)**2 + np.cos(lat_rad[i]) * np.cos(parent_coords_rad[j, 0]) * np.sin(dlon/2)**2
|
|
3596
|
+
dist = 2 * np.arctan2(np.sqrt(a), np.sqrt(1-a))
|
|
3597
|
+
|
|
3598
|
+
if dist < min_dist:
|
|
3599
|
+
min_dist = dist
|
|
3600
|
+
closest_parent = j
|
|
3601
|
+
|
|
3602
|
+
new_labels[i] = child_ids[closest_parent]
|
|
3603
|
+
|
|
3604
|
+
return new_labels
|
|
3605
|
+
|
|
3606
|
+
@njit(fastmath=True, parallel=True)
|
|
3607
|
+
def sparse_bool_power(vec, sp_data, indices, indptr, exponent):
|
|
3608
|
+
"""
|
|
3609
|
+
Efficient sparse boolean matrix power operation.
|
|
3610
|
+
|
|
3611
|
+
This function implements a fast sparse matrix power operation for boolean matrices,
|
|
3612
|
+
avoiding memory leaks present in scipy+Dask implementations. It's used for efficient
|
|
3613
|
+
morphological operations on unstructured grids.
|
|
3614
|
+
|
|
3615
|
+
Parameters
|
|
3616
|
+
----------
|
|
3617
|
+
vec : np.ndarray
|
|
3618
|
+
Boolean vector to multiply
|
|
3619
|
+
sp_data, indices, indptr : np.ndarray
|
|
3620
|
+
Sparse matrix in CSR format
|
|
3621
|
+
exponent : int
|
|
3622
|
+
Number of times to apply the matrix
|
|
3623
|
+
|
|
3624
|
+
Returns
|
|
3625
|
+
-------
|
|
3626
|
+
np.ndarray
|
|
3627
|
+
Result of (sparse_matrix ^ exponent) * vec
|
|
3628
|
+
"""
|
|
3629
|
+
vec = vec.T
|
|
3630
|
+
num_rows = indptr.size - 1
|
|
3631
|
+
num_cols = vec.shape[1]
|
|
3632
|
+
result = vec.copy()
|
|
3633
|
+
|
|
3634
|
+
for _ in range(exponent):
|
|
3635
|
+
temp_result = np.zeros((num_rows, num_cols), dtype=np.bool_)
|
|
3636
|
+
|
|
3637
|
+
for i in prange(num_rows):
|
|
3638
|
+
for j in range(indptr[i], indptr[i + 1]):
|
|
3639
|
+
if sp_data[j]:
|
|
3640
|
+
for k in range(num_cols):
|
|
3641
|
+
if result[indices[j], k]:
|
|
3642
|
+
temp_result[i, k] = True
|
|
3643
|
+
|
|
3644
|
+
result = temp_result
|
|
3645
|
+
|
|
3646
|
+
return result.T
|