dea-tools 0.4.8.dev12__tar.gz → 0.4.8.dev13__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/PKG-INFO +3 -1
  2. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/bom.py +2 -2
  3. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/temporal.py +359 -61
  4. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/pyproject.toml +3 -0
  5. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/.gitignore +0 -0
  6. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/LICENSE +0 -0
  7. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/README.md +0 -0
  8. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/__init__.py +0 -0
  9. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/__main__.py +0 -0
  10. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/app/__init__.py +0 -0
  11. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/app/animations.py +0 -0
  12. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/app/changefilmstrips.py +0 -0
  13. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/app/crophealth.py +0 -0
  14. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/app/deacoastlines.py +0 -0
  15. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/app/geomedian.py +0 -0
  16. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/app/imageexport.py +0 -0
  17. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/app/miningrehab.py +0 -0
  18. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/app/wetlandsinsighttool.py +0 -0
  19. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/app/widgetconstructors.py +0 -0
  20. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/bandindices.py +0 -0
  21. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/classification.py +0 -0
  22. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/coastal.py +0 -0
  23. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/dask.py +0 -0
  24. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/datahandling.py +0 -0
  25. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/landcover.py +0 -0
  26. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/maps.py +0 -0
  27. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/mosaics/README.md +0 -0
  28. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/mosaics/__init__.py +0 -0
  29. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/mosaics/cog.py +0 -0
  30. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/mosaics/styling.py +0 -0
  31. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/mosaics/utils.py +0 -0
  32. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/mosaics/vrt.py +0 -0
  33. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/plotting.py +0 -0
  34. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/spatial.py +0 -0
  35. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/validation.py +0 -0
  36. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/waterbodies.py +0 -0
  37. {dea_tools-0.4.8.dev12 → dea_tools-0.4.8.dev13}/Tools/dea_tools/wetlands.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dea-tools
3
- Version: 0.4.8.dev12
3
+ Version: 0.4.8.dev13
4
4
  Summary: Open-source tools for geospatial analysis with Digital Earth Australia, Open Data Cube, and Xarray
5
5
  Project-URL: Homepage, https://knowledge.dea.ga.gov.au/notebooks/Tools/
6
6
  Project-URL: Repository, https://github.com/GeoscienceAustralia/dea-notebooks
@@ -77,6 +77,8 @@ Requires-Dist: opencv-python>=4.6.0.66; extra == 'all'
77
77
  Requires-Dist: pydotplus>=2.0.0; extra == 'all'
78
78
  Requires-Dist: statsmodels>=0.14.0; extra == 'all'
79
79
  Requires-Dist: sunriset>=1.0.0; extra == 'all'
80
+ Provides-Extra: cv
81
+ Requires-Dist: opencv-python>=4.6.0.66; extra == 'cv'
80
82
  Provides-Extra: dask-gateway
81
83
  Requires-Dist: dask-gateway>=2023.1.0; extra == 'dask-gateway'
82
84
  Provides-Extra: datacube
@@ -31,7 +31,7 @@ from dateutil import parser
31
31
  def get_stations(
32
32
  time=None,
33
33
  observation="http://bom.gov.au/waterdata/services/parameters/Water Course Discharge",
34
- url="http://www.bom.gov.au/waterdata/services",
34
+ url="https://www.bom.gov.au/waterdata/services"
35
35
  ):
36
36
  """Get list of stations
37
37
 
@@ -56,7 +56,7 @@ def get_station_data(
56
56
  station,
57
57
  time=None,
58
58
  observation="http://bom.gov.au/waterdata/services/parameters/Water Course Discharge",
59
- url="http://www.bom.gov.au/waterdata/services",
59
+ url="https://www.bom.gov.au/waterdata/services"
60
60
  ):
61
61
  """
62
62
  Query Gauge Data.
@@ -16,13 +16,13 @@ here: https://gis.stackexchange.com/questions/tagged/open-data-cube).
16
16
  If you would like to report an issue with this script, file one on
17
17
  GitHub: https://github.com/GeoscienceAustralia/dea-notebooks/issues/new
18
18
 
19
- Last modified: May 2024
19
+ Last modified: October 2025
20
20
  """
21
21
 
22
22
  import warnings
23
23
 
24
24
  import dask
25
- import dask.array as da
25
+ import dask.array as daskarray
26
26
  import numpy as np
27
27
  import pandas as pd
28
28
  import scipy.signal
@@ -30,6 +30,9 @@ import xarray as xr
30
30
  from odc.geo.xr import assign_crs
31
31
  from packaging import version
32
32
  from scipy.stats import t
33
+ import concurrent.futures
34
+ from tqdm import tqdm
35
+ from skimage.registration import optical_flow_ilk, optical_flow_tvl1
33
36
 
34
37
 
35
38
  def allNaN_arg(da, dim, stat):
@@ -298,13 +301,9 @@ def xr_phenology(
298
301
  "ROS": np.float32,
299
302
  }
300
303
  da_template = da.isel(time=0).drop("time")
301
- template = xr.Dataset(
302
- {
303
- var_name: da_template.astype(var_dtype)
304
- for var_name, var_dtype in stats_dtype.items()
305
- if var_name in stats
306
- }
307
- )
304
+ template = xr.Dataset({
305
+ var_name: da_template.astype(var_dtype) for var_name, var_dtype in stats_dtype.items() if var_name in stats
306
+ })
308
307
  da_all_time = da.chunk({"time": -1})
309
308
 
310
309
  lazy_phenology = da_all_time.map_blocks(
@@ -431,9 +430,7 @@ def fourier_mean(x, n=3, step=5):
431
430
  for j in range(x.shape[1]):
432
431
  y = np.fft.fft(x[i, j, :])
433
432
  for k in range(n):
434
- result[i, j, k] = np.mean(
435
- np.abs(y[1 + k * step : ((k + 1) * step + 1) or None])
436
- )
433
+ result[i, j, k] = np.mean(np.abs(y[1 + k * step : ((k + 1) * step + 1) or None]))
437
434
 
438
435
  return result
439
436
 
@@ -448,9 +445,7 @@ def fourier_std(x, n=3, step=5):
448
445
  for j in range(x.shape[1]):
449
446
  y = np.fft.fft(x[i, j, :])
450
447
  for k in range(n):
451
- result[i, j, k] = np.std(
452
- np.abs(y[1 + k * step : ((k + 1) * step + 1) or None])
453
- )
448
+ result[i, j, k] = np.std(np.abs(y[1 + k * step : ((k + 1) * step + 1) or None]))
454
449
 
455
450
  return result
456
451
 
@@ -465,9 +460,7 @@ def fourier_median(x, n=3, step=5):
465
460
  for j in range(x.shape[1]):
466
461
  y = np.fft.fft(x[i, j, :])
467
462
  for k in range(n):
468
- result[i, j, k] = np.median(
469
- np.abs(y[1 + k * step : ((k + 1) * step + 1) or None])
470
- )
463
+ result[i, j, k] = np.median(np.abs(y[1 + k * step : ((k + 1) * step + 1) or None]))
471
464
 
472
465
  return result
473
466
 
@@ -602,9 +595,7 @@ def temporal_statistics(da, stats):
602
595
  da_all_time = da.chunk({"time": -1})
603
596
 
604
597
  # apply function across chunks
605
- lazy_ds = da_all_time.map_blocks(
606
- temporal_statistics, kwargs={"stats": stats}, template=template
607
- )
598
+ lazy_ds = da_all_time.map_blocks(temporal_statistics, kwargs={"stats": stats}, template=template)
608
599
 
609
600
  try:
610
601
  crs = da.odc.geobox.crs
@@ -650,28 +641,23 @@ def temporal_statistics(da, stats):
650
641
  n3 = zz[:, :, 2]
651
642
 
652
643
  # intialise dataset with first statistic
653
- ds = xr.DataArray(
654
- n1, attrs=attrs, coords={x_dim: x, y_dim: y}, dims=[y_dim, x_dim]
655
- ).to_dataset(name=stats[0] + "_n1")
644
+ ds = xr.DataArray(n1, attrs=attrs, coords={x_dim: x, y_dim: y}, dims=[y_dim, x_dim]).to_dataset(
645
+ name=stats[0] + "_n1"
646
+ )
656
647
 
657
648
  # add other datasets
658
649
  for i, j in zip([n2, n3], ["n2", "n3"]):
659
- ds[stats[0] + "_" + j] = xr.DataArray(
660
- i, attrs=attrs, coords={"x": x, "y": y}, dims=["y", "x"]
661
- )
650
+ ds[stats[0] + "_" + j] = xr.DataArray(i, attrs=attrs, coords={"x": x, "y": y}, dims=["y", "x"])
662
651
  else:
663
652
  # simpler if first function isn't fourier transform
664
653
  first_func = stats_dict.get(str(stats[0]))
665
654
  ds = first_func(da)
666
655
 
667
656
  # convert back to xarray dataset
668
- ds = xr.DataArray(
669
- ds, attrs=attrs, coords={x_dim: x, y_dim: y}, dims=[y_dim, x_dim]
670
- ).to_dataset(name=stats[0])
657
+ ds = xr.DataArray(ds, attrs=attrs, coords={x_dim: x, y_dim: y}, dims=[y_dim, x_dim]).to_dataset(name=stats[0])
671
658
 
672
659
  # loop through the other functions
673
660
  for stat in stats[1:]:
674
-
675
661
  # handle the fourier transform examples
676
662
  if stat in ("f_std", "f_median", "f_mean"):
677
663
  stat_func = stats_dict.get(str(stat))
@@ -681,9 +667,7 @@ def temporal_statistics(da, stats):
681
667
  n3 = zz[:, :, 2]
682
668
 
683
669
  for i, j in zip([n1, n2, n3], ["n1", "n2", "n3"]):
684
- ds[stat + "_" + j] = xr.DataArray(
685
- i, attrs=attrs, coords={x_dim: x, y_dim: y}, dims=[y_dim, x_dim]
686
- )
670
+ ds[stat + "_" + j] = xr.DataArray(i, attrs=attrs, coords={x_dim: x, y_dim: y}, dims=[y_dim, x_dim])
687
671
 
688
672
  else:
689
673
  # Select a stats function from the dictionary
@@ -731,12 +715,8 @@ def time_buffer(input_date, buffer="30 days", output_format="%Y-%m-%d"):
731
715
  `input_date='2018-01-01'` and `buffer='30 days'`
732
716
  """
733
717
  # Use assertions to check we have the correct function input
734
- assert isinstance(
735
- input_date, str
736
- ), "Input date must be a string in quotes in 'yyyy-mm-dd' format"
737
- assert isinstance(
738
- buffer, str
739
- ), "Buffer must be a string supported by `pandas.Timedelta`, e.g. '5 days'"
718
+ assert isinstance(input_date, str), "Input date must be a string in quotes in 'yyyy-mm-dd' format"
719
+ assert isinstance(buffer, str), "Buffer must be a string supported by `pandas.Timedelta`, e.g. '5 days'"
740
720
 
741
721
  # Convert inputs to pandas format
742
722
  buffer = pd.Timedelta(buffer)
@@ -828,11 +808,7 @@ class LinregressResult:
828
808
 
829
809
  def __repr__(self):
830
810
  return "LinregressResult({})".format(
831
- ", ".join(
832
- "{}={}".format(k, getattr(self, k))
833
- for k in dir(self)
834
- if not k.startswith("_")
835
- )
811
+ ", ".join("{}={}".format(k, getattr(self, k)) for k in dir(self) if not k.startswith("_"))
836
812
  )
837
813
 
838
814
 
@@ -1027,9 +1003,7 @@ def xr_regression(
1027
1003
  assert dim in x.dims, f"Array `x` does not contain dimension '{dim}'."
1028
1004
 
1029
1005
  # Assert that both arrays have the same length along "dim"
1030
- assert len(x[dim]) == len(
1031
- y[dim]
1032
- ), f"Arrays `x` and `y` have different lengths along dimension '{dim}'."
1006
+ assert len(x[dim]) == len(y[dim]), f"Arrays `x` and `y` have different lengths along dimension '{dim}'."
1033
1007
 
1034
1008
  # Apply optional outlier masking to x and y variable
1035
1009
  if outliers_y is not None:
@@ -1069,7 +1043,7 @@ def xr_regression(
1069
1043
  if dask.is_dask_collection(cor):
1070
1044
  _pvalue_lazy = dask.delayed(_pvalue)
1071
1045
  pval = xr.DataArray(
1072
- da.from_delayed(
1046
+ daskarray.from_delayed(
1073
1047
  _pvalue_lazy(tstats, n, alternative),
1074
1048
  shape=cor.shape,
1075
1049
  dtype=cor.dtype,
@@ -1086,18 +1060,16 @@ def xr_regression(
1086
1060
  )
1087
1061
 
1088
1062
  # Combine into single dataset
1089
- regression_ds = xr.merge(
1090
- [
1091
- cov.rename("cov").astype(np.float32),
1092
- cor.rename("cor").astype(np.float32),
1093
- r2.rename("r2").astype(np.float32),
1094
- slope.rename("slope").astype(np.float32),
1095
- intercept.rename("intercept").astype(np.float32),
1096
- pval.rename("pvalue").astype(np.float32),
1097
- stderr.rename("stderr").astype(np.float32),
1098
- n.rename("n").astype(np.int16),
1099
- ]
1100
- )
1063
+ regression_ds = xr.merge([
1064
+ cov.rename("cov").astype(np.float32),
1065
+ cor.rename("cor").astype(np.float32),
1066
+ r2.rename("r2").astype(np.float32),
1067
+ slope.rename("slope").astype(np.float32),
1068
+ intercept.rename("intercept").astype(np.float32),
1069
+ pval.rename("pvalue").astype(np.float32),
1070
+ stderr.rename("stderr").astype(np.float32),
1071
+ n.rename("n").astype(np.int16),
1072
+ ])
1101
1073
 
1102
1074
  return regression_ds
1103
1075
 
@@ -1155,3 +1127,329 @@ def calculate_stsad(vec, window_size=365, step=10, progress=None, window="hann")
1155
1127
  progress=progress,
1156
1128
  window=window,
1157
1129
  )
1130
+
1131
+
1132
+ def _ilk_optical_flow(a, b, feature_kwargs=None, **kwargs):
1133
+ """Compute optical flow using the scikit-image `optical_flow_ilk` method."""
1134
+
1135
+ # Set default params for optical flow analysis
1136
+ params = {"radius": 20}
1137
+ params.update(kwargs)
1138
+
1139
+ # Run optical flow analysis
1140
+ return optical_flow_ilk(a, b, **params)
1141
+
1142
+
1143
+ def _tvl1_optical_flow(a, b, feature_kwargs=None, **kwargs):
1144
+ """Compute optical flow using the scikit-image `optical_flow_tvl1` method."""
1145
+
1146
+ # Run optical flow analysis
1147
+ return optical_flow_tvl1(a, b, **kwargs)
1148
+
1149
+
1150
+ def _farneback_optical_flow(a, b, feature_kwargs=None, **kwargs):
1151
+ """Compute optical flow using the OpenCV `cv.calcOpticalFlowFarneback` method."""
1152
+
1153
+ # Attempt to import OpenCV and raise an error if not available
1154
+ try:
1155
+ import cv2 as cv
1156
+ except ImportError as e:
1157
+ raise ImportError(
1158
+ "`cv2` is required for optical flow analysis with `method='farneback'`. "
1159
+ "Please install DEA Tools with the `[cv]` or `[notebooks]` extra, e.g.: "
1160
+ "`pip install dea-tools[notebooks]`"
1161
+ ) from e
1162
+
1163
+ # Set default params for optical flow analysis
1164
+ params = {
1165
+ "pyr_scale": 0.5,
1166
+ "levels": 3,
1167
+ "winsize": 15,
1168
+ "iterations": 3,
1169
+ "poly_n": 5,
1170
+ "poly_sigma": 1.2,
1171
+ "flags": 0,
1172
+ }
1173
+ params.update(kwargs)
1174
+
1175
+ # Run optical flow analysis
1176
+ flow = cv.calcOpticalFlowFarneback(a, b, None, **params)
1177
+ return flow[..., 1], flow[..., 0]
1178
+
1179
+
1180
+ def _deepflow_optical_flow(a, b, feature_kwargs=None, **kwargs):
1181
+ """Compute optical flow using the OpenCV `cv.optflow.createOptFlow_DeepFlow` method."""
1182
+
1183
+ # Attempt to import OpenCV and raise an error if not available
1184
+ try:
1185
+ import cv2 as cv
1186
+ except ImportError as e:
1187
+ raise ImportError(
1188
+ "`cv2` is required for optical flow analysis with `method='deepflow'`. "
1189
+ "Please install DEA Tools with the `[cv]` or `[notebooks]` extra, e.g.: "
1190
+ "`pip install dea-tools[notebooks]`"
1191
+ ) from e
1192
+
1193
+ # Run optical flow analysis
1194
+ flow = cv.optflow.createOptFlow_DeepFlow().calc(a, b, None)
1195
+ return flow[..., 1], flow[..., 0]
1196
+
1197
+
1198
+ def _lucas_kanade_optical_flow(a, b, feature_kwargs=None, **kwargs):
1199
+ """Compute optical flow using the OpenCV `cv.calcOpticalFlowPyrLK` method.
1200
+
1201
+ This is a sparse optical flow method, which will return optical flow
1202
+ for a series of point locations identified using `cv.goodFeaturesToTrack`.
1203
+ """
1204
+ # Attempt to import OpenCV and raise an error if not available
1205
+ try:
1206
+ import cv2 as cv
1207
+ except ImportError as e:
1208
+ raise ImportError(
1209
+ "`cv2` is required for optical flow analysis with `method='lucas_kanade'`. "
1210
+ "Please install DEA Tools with the `[cv]` or `[notebooks]` extra, e.g.: "
1211
+ "`pip install dea-tools[notebooks]`"
1212
+ ) from e
1213
+
1214
+ # Use empty dict if nothing is provided
1215
+ if feature_kwargs is None:
1216
+ feature_kwargs = {}
1217
+
1218
+ # Set default params for feature extraction (ShiTomasi corner detection)
1219
+ feature_params = {
1220
+ "mask": None,
1221
+ "maxCorners": 20000,
1222
+ "qualityLevel": 0.1,
1223
+ "minDistance": 10,
1224
+ "blockSize": 15,
1225
+ }
1226
+ feature_params.update(feature_kwargs)
1227
+
1228
+ # Set default params for optical flow analysis
1229
+ params = {
1230
+ "winSize": (25, 25),
1231
+ "maxLevel": 1,
1232
+ "criteria": (cv.TERM_CRITERIA_EPS | cv.TERM_CRITERIA_COUNT, 30, 0.03),
1233
+ }
1234
+ params.update(kwargs)
1235
+
1236
+ # Extract good features to track
1237
+ p0 = cv.goodFeaturesToTrack(a, **feature_params)
1238
+
1239
+ # Raise error if no points were found
1240
+ if p0 is None:
1241
+ raise ValueError("No valid points to track found.")
1242
+
1243
+ # Run optical flow analysis
1244
+ p1, st, _ = cv.calcOpticalFlowPyrLK(a, b, p0, None, **params)
1245
+
1246
+ # Compute displacement vectors
1247
+ u = p1[:, 0, 0] - p0[:, 0, 0] # horizontal displacement (x)
1248
+ v = p1[:, 0, 1] - p0[:, 0, 1] # vertical displacement (y)
1249
+
1250
+ # Mask out invalid values
1251
+ v[st.squeeze() != 1] = np.nan
1252
+ u[st.squeeze() != 1] = np.nan
1253
+
1254
+ return v, u, p1
1255
+
1256
+
1257
+ def xr_optical_flow(
1258
+ da,
1259
+ baseline="dynamic",
1260
+ method="ilk",
1261
+ rescale_units=False,
1262
+ parallel=True,
1263
+ feature_kwargs=None,
1264
+ **kwargs,
1265
+ ):
1266
+ """
1267
+ Compute optical flow between xarray.DataArray observations.
1268
+
1269
+ Optical flow can be computed using a variety of dense and sparse
1270
+ methods from scikit-image or OpenCV. Several different baselines
1271
+ are supported, including dynamic baselines where change is computed
1272
+ between each consecutive pair of timesteps.
1273
+
1274
+ Parameters
1275
+ ----------
1276
+ da : xarray.DataArray
1277
+ Input data representing either a temporal image sequence, or a single
1278
+ array that will be compared against `baseline`.
1279
+ baseline : str or xr.DataArray, optional
1280
+ Defines the baseline or reference array used to compute optical flow:
1281
+ * "dynamic": Calculate optical flow independently on each pair of
1282
+ timesteps, using the first array in each pair as the reference
1283
+ * "first": Compare every timestep against the first timestep
1284
+ * ``xr.DataArray``: Compare every timestep against a custom array
1285
+ method : str, optional
1286
+ Optical flow algorithm to use:
1287
+ - "ilk": Dense iterative Lucas–Kanade (scikit-image, fast and robust)
1288
+ - "tvl1": Dense Total Variation L1 (scikit-image, more accurate but slower)
1289
+ - "farneback": Dense Gunnar Farneback dense optical flow (OpenCV)
1290
+ - "deepflow": Dense DeepFlow (OpenCV, accurate but slower)
1291
+ - "lucas_kanade": Sparse pyramidal Lucas–Kanade (OpenCV, uses goodFeaturesToTrack)
1292
+ rescale_units : bool, optional
1293
+ By default, ``u``, ``v`` and ``magnitude`` are returned in pixel
1294
+ units. Optionally, results can instead be re-scaled by pixel
1295
+ resolution to get outputs in real-world units (note however
1296
+ that this can interfere with quiver plotting using ``xarray``.)
1297
+ parallel : bool, optional
1298
+ If True, computations are parallelised across time steps.
1299
+ feature_kwargs : dict, optional
1300
+ Extra keyword arguments passed to feature detection functions
1301
+ (used only for sparse 'lucas_kanade' method).
1302
+ **kwargs : dict
1303
+ Additional keyword arguments passed to optical flow functions.
1304
+
1305
+ Returns
1306
+ -------
1307
+ xarray.Dataset
1308
+ Dataset containing:
1309
+ - ``v``: Vertical (y-axis) component of optical flow.
1310
+ - ``u``: Horizontal (x-axis) component of optical flow.
1311
+ - ``magnitude``: Euclidean norm of the vertical and horizontal
1312
+ flow components, often representing either displacement distance
1313
+ or speed.
1314
+ """
1315
+ # Get dimension names
1316
+ y_dim, x_dim = da.odc.spatial_dims
1317
+
1318
+ # Determine if baseline is an array or a keyword
1319
+ is_array = isinstance(baseline, xr.DataArray)
1320
+ has_time = "time" in da.dims
1321
+
1322
+ # Define dict linking functions to each analysis method
1323
+ method_dict = {
1324
+ "ilk": _ilk_optical_flow,
1325
+ "tvl1": _tvl1_optical_flow,
1326
+ "farneback": _farneback_optical_flow,
1327
+ "deepflow": _deepflow_optical_flow,
1328
+ "lucas_kanade": _lucas_kanade_optical_flow,
1329
+ }
1330
+
1331
+ # Select relevant function
1332
+ try:
1333
+ flow_func = method_dict[method]
1334
+ except KeyError:
1335
+ raise ValueError(
1336
+ f"Unsupported method '{method}'. Use one of 'ilk', 'tvl1', 'farneback', 'deepflow', or 'lucas_kanade'."
1337
+ )
1338
+
1339
+ # Raise error if a time series baseline is provided but `da` does not contain time
1340
+ if not is_array and not has_time:
1341
+ raise ValueError(
1342
+ f"The '{baseline}' baseline option requires `da` to have a time dimension. "
1343
+ "Provide time-series data to `da`, or use a different `baseline`."
1344
+ )
1345
+
1346
+ # Raise error if a baseline array is provided, but
1347
+ if is_array and "time" in baseline.dims:
1348
+ if len(baseline.time) > 1:
1349
+ raise ValueError(f"The provided `baseline` array must not contain multiple timesteps.")
1350
+
1351
+ # Raise error if "lucas_kanade" is provided alongside a "dynamic" baseline
1352
+ if not is_array:
1353
+ if (method == "lucas_kanade") and (baseline == "dynamic"):
1354
+ raise ValueError(
1355
+ "To ensure that consistent features are returned for all timesteps, "
1356
+ "the `lucas_kanade` method is not compatible with `baseline='dynamic'`. "
1357
+ "Try `baseline='first'` or pass a custom array to `baseline`."
1358
+ )
1359
+
1360
+ # Rescale both array and baseline to 8 bit for analysis
1361
+ da_min, da_max = da.min(), da.max()
1362
+ da = ((da - da_min) / (da_max - da_min) * 255).astype(np.uint8)
1363
+ if is_array:
1364
+ baseline = ((baseline - da_min) / (da_max - da_min) * 255).astype(np.uint8)
1365
+
1366
+ # Determine indices to iterate over for different baseline options
1367
+ if is_array and not has_time:
1368
+ indices = [0]
1369
+ elif is_array and has_time:
1370
+ indices = range(len(da.time))
1371
+ elif baseline in ("dynamic", "first"):
1372
+ indices = range(1, len(da.time))
1373
+ else:
1374
+ raise ValueError(
1375
+ f"Invalid baseline: {baseline}. Use one of 'dynamic', 'first', or provide a custom `xr.DataArray`."
1376
+ )
1377
+
1378
+ def select_pair(t):
1379
+ # Custom baseline arrray
1380
+ if is_array:
1381
+ return baseline, da.isel(time=t) if has_time else da
1382
+
1383
+ # First: Compare every array against the first array
1384
+ if baseline == "first":
1385
+ return da.isel(time=0), da.isel(time=t)
1386
+
1387
+ # Dynamic: Compare every array against the previous array
1388
+ return da.isel(time=t - 1), da.isel(time=t)
1389
+
1390
+ def compute_flow(t):
1391
+ # Select pairs of arrays to analyse
1392
+ a, b = select_pair(t)
1393
+
1394
+ # Run optical flow analysis
1395
+ flow_outputs = flow_func(a.values, b.values, feature_kwargs, **kwargs)
1396
+
1397
+ # Unpack outputs of function
1398
+ try:
1399
+ v, u = flow_outputs
1400
+ except ValueError:
1401
+ v, u, p1 = flow_outputs
1402
+
1403
+ # Optionally re-scale coordinates by resolution
1404
+ if rescale_units:
1405
+ v *= da.odc.geobox.resolution.y
1406
+ u *= da.odc.geobox.resolution.x
1407
+
1408
+ # Add time dimension if necessary
1409
+ if has_time:
1410
+ b = b.expand_dims("time")
1411
+ v, u = v[None], u[None] # add time axis
1412
+
1413
+ # Return as xarray data
1414
+ if method == "lucas_kanade":
1415
+ # Convert point coordinates to spatial coordinates
1416
+ x, y = da.odc.geobox.translate_pix(0.5, 0.5).affine * p1.squeeze().T
1417
+
1418
+ # Determine coords and dims
1419
+ dims = ("time", "feature") if has_time else ("feature",)
1420
+ coords = {"x": (("feature",), x), "y": (("feature",), y)}
1421
+
1422
+ # Add time coordinates if required
1423
+ if has_time:
1424
+ coords["time"] = b.time
1425
+
1426
+ return xr.Dataset(
1427
+ data_vars={"v": (dims, v), "u": (dims, u)},
1428
+ coords=coords,
1429
+ )
1430
+ else:
1431
+ return xr.Dataset(
1432
+ data_vars={"v": (b.dims, v), "u": (b.dims, u)},
1433
+ coords=b.coords,
1434
+ )
1435
+
1436
+ # Run analysis in parallel
1437
+ if parallel:
1438
+ with concurrent.futures.ThreadPoolExecutor() as executor:
1439
+ flow_results = list(
1440
+ tqdm(
1441
+ executor.map(compute_flow, indices),
1442
+ total=len(indices),
1443
+ desc=f"Computing optical flow ({method}) in parallel",
1444
+ )
1445
+ )
1446
+
1447
+ # Run analysis in series
1448
+ else:
1449
+ flow_results = [compute_flow(t) for t in tqdm(indices, desc=f"Computing optical flow ({method})")]
1450
+
1451
+ # Combine all outputs
1452
+ ds = flow_results[0] if len(flow_results) == 1 else xr.concat(flow_results, dim="time")
1453
+
1454
+ # Add magnitude
1455
+ return ds.assign({"magnitude": (ds.u**2 + ds.v**2) ** 0.5})
@@ -91,6 +91,9 @@ jupyter = [
91
91
  dask_gateway = [
92
92
  "dask_gateway>=2023.1.0",
93
93
  ]
94
+ cv = [
95
+ "opencv-python>=4.6.0.66",
96
+ ]
94
97
  notebooks = [
95
98
  "affine>=2.3.1",
96
99
  "beautifulsoup4>=4.12.0",
File without changes