Skip to content

Commit d77fe73

Browse files
authored
Add option to smooth radar mask (#379)
1 parent d224015 commit d77fe73

File tree

5 files changed

+221
-26
lines changed

5 files changed

+221
-26
lines changed

pysteps/blending/steps.py

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ def forecast(
9090
conditional=False,
9191
probmatching_method="cdf",
9292
mask_method="incremental",
93+
smooth_radar_mask_range=0,
9394
callback=None,
9495
return_output=True,
9596
seed=None,
@@ -210,6 +211,13 @@ def forecast(
210211
'obs' = apply precip_thr to the most recently observed precipitation intensity
211212
field, 'incremental' = iteratively buffer the mask with a certain rate
212213
(currently it is 1 km/min), None=no masking.
214+
smooth_radar_mask_range: int, Default is 0.
215+
Method to smooth the transition between the radar-NWP-noise blend and the NWP-noise
216+
blend near the edge of the radar domain (radar mask), where the radar data is either
217+
not present anymore or is not reliable. If set to 0 (grid cells), this generates a normal forecast without smoothing. To create a smooth mask, this range
218+
should be a positive value, representing a buffer band of a number of pixels
219+
by which the mask is cropped and smoothed. The smooth radar mask removes
220+
the hard edges between NWP and radar in the final blended product. Typically, a value between 50 and 100 km can be used. 80 km generally gives good results.
213221
probmatching_method: {'cdf','mean',None}, optional
214222
Method for matching the statistics of the forecast field with those of
215223
the most recently observed one. 'cdf'=map the forecast CDF to the observed
@@ -1451,10 +1459,49 @@ def worker(j):
14511459
# forecast outside the radar domain. Therefore, fill these
14521460
# areas with the "..._mod_only" blended forecasts, consisting
14531461
# of the NWP and noise components.
1462+
14541463
nan_indices = np.isnan(R_f_new)
1455-
R_f_new[nan_indices] = R_f_new_mod_only[nan_indices]
1456-
nan_indices = np.isnan(R_pm_blended)
1457-
R_pm_blended[nan_indices] = R_pm_blended_mod_only[nan_indices]
1464+
if smooth_radar_mask_range != 0:
1465+
# Compute the smooth dilated mask
1466+
new_mask = blending.utils.compute_smooth_dilated_mask(
1467+
nan_indices,
1468+
max_padding_size_in_px=smooth_radar_mask_range,
1469+
)
1470+
1471+
# Ensure mask values are between 0 and 1
1472+
mask_model = np.clip(new_mask, 0, 1)
1473+
mask_radar = np.clip(1 - new_mask, 0, 1)
1474+
1475+
# Handle NaNs in R_f_new and R_f_new_mod_only by setting NaNs to 0 in the blending step
1476+
R_f_new_mod_only_no_nan = np.nan_to_num(
1477+
R_f_new_mod_only, nan=0
1478+
)
1479+
R_f_new_no_nan = np.nan_to_num(R_f_new, nan=0)
1480+
1481+
# Perform the blending of radar and model inside the radar domain using a weighted combination
1482+
R_f_new = np.nansum(
1483+
[
1484+
mask_model * R_f_new_mod_only_no_nan,
1485+
mask_radar * R_f_new_no_nan,
1486+
],
1487+
axis=0,
1488+
)
1489+
1490+
nan_indices = np.isnan(R_pm_blended)
1491+
R_pm_blended = np.nansum(
1492+
[
1493+
R_pm_blended * mask_radar,
1494+
R_pm_blended_mod_only * mask_model,
1495+
],
1496+
axis=0,
1497+
)
1498+
else:
1499+
R_f_new[nan_indices] = R_f_new_mod_only[nan_indices]
1500+
nan_indices = np.isnan(R_pm_blended)
1501+
R_pm_blended[nan_indices] = R_pm_blended_mod_only[
1502+
nan_indices
1503+
]
1504+
14581505
# Finally, fill the remaining nan values, if present, with
14591506
# the minimum value in the forecast
14601507
nan_indices = np.isnan(R_f_new)

pysteps/blending/utils.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
compute_store_nwp_motion
1717
load_NWP
1818
check_norain
19+
compute_smooth_dilated_mask
1920
"""
2021

2122
import datetime
@@ -35,6 +36,13 @@
3536
except ImportError:
3637
NETCDF4_IMPORTED = False
3738

39+
try:
40+
import cv2
41+
42+
CV2_IMPORTED = True
43+
except ImportError:
44+
CV2_IMPORTED = False
45+
3846

3947
def stack_cascades(R_d, donorm=True):
4048
"""Stack the given cascades into a larger array.
@@ -557,3 +565,92 @@ def check_norain(precip_arr, precip_thr=None, norain_thr=0.0):
557565
f"Rain fraction is: {str(rain_pixels.size / precip_arr.size)}, while minimum fraction is {str(norain_thr)}"
558566
)
559567
return norain
568+
569+
570+
def compute_smooth_dilated_mask(
571+
original_mask,
572+
max_padding_size_in_px=0,
573+
gaussian_kernel_size=9,
574+
inverted=False,
575+
non_linear_growth_kernel_sizes=False,
576+
):
577+
"""
578+
Compute a smooth dilated mask using Gaussian blur and dilation with varying kernel sizes.
579+
580+
Parameters
581+
----------
582+
original_mask : array_like
583+
Two-dimensional boolean array containing the input mask.
584+
max_padding_size_in_px : int
585+
The maximum size of the padding in pixels. Default is 100.
586+
gaussian_kernel_size : int, optional
587+
Size of the Gaussian kernel to use for blurring, this should be an uneven number. This option ensures
588+
that the nan-fields are large enough to start the smoothing. Without it, the method will also be applied
589+
to local nan-values in the radar domain. Default is 9, which is generally a recommended number to work
590+
with.
591+
inverted : bool, optional
592+
Typically, the smoothed mask works from the outside of the radar domain inward, using the
593+
max_padding_size_in_px. If set to True, it works from the edge of the radar domain outward
594+
(generally not recommended). Default is False.
595+
non_linear_growth_kernel_sizes : bool, optional
596+
If True, use non-linear growth for kernel sizes. Default is False.
597+
598+
Returns
599+
-------
600+
final_mask : array_like
601+
The smooth dilated mask normalized to the range [0,1].
602+
"""
603+
if not CV2_IMPORTED:
604+
raise MissingOptionalDependency(
605+
"CV2 package is required to transform the mask into a smoot mask."
606+
" Please install it using `pip install opencv-python`."
607+
)
608+
609+
if max_padding_size_in_px < 0:
610+
raise ValueError("max_padding_size_in_px must be greater than or equal to 0.")
611+
612+
# Check if gaussian_kernel_size is an uneven number
613+
assert gaussian_kernel_size % 2
614+
615+
# Convert the original mask to uint8 numpy array and invert if needed
616+
array_2d = np.array(original_mask, dtype=np.uint8)
617+
if inverted:
618+
array_2d = np.bitwise_not(array_2d)
619+
620+
# Rescale the 2D array values to 0-255 (black or white)
621+
rescaled_array = array_2d * 255
622+
623+
# Apply Gaussian blur to the rescaled array
624+
blurred_image = cv2.GaussianBlur(
625+
rescaled_array, (gaussian_kernel_size, gaussian_kernel_size), 0
626+
)
627+
628+
# Apply binary threshold to negate the blurring effect
629+
_, binary_image = cv2.threshold(blurred_image, 128, 255, cv2.THRESH_BINARY)
630+
631+
# Define kernel sizes
632+
if non_linear_growth_kernel_sizes:
633+
lin_space = np.linspace(0, np.sqrt(max_padding_size_in_px), 10)
634+
non_lin_space = np.power(lin_space, 2)
635+
kernel_sizes = list(set(non_lin_space.astype(np.uint8)))
636+
else:
637+
kernel_sizes = np.linspace(0, max_padding_size_in_px, 10, dtype=np.uint8)
638+
639+
# Process each kernel size
640+
final_mask = np.zeros_like(binary_image, dtype=np.float64)
641+
for kernel_size in kernel_sizes:
642+
if kernel_size == 0:
643+
dilated_image = binary_image
644+
else:
645+
kernel = cv2.getStructuringElement(
646+
cv2.MORPH_ELLIPSE, (kernel_size, kernel_size)
647+
)
648+
dilated_image = cv2.dilate(binary_image, kernel)
649+
650+
# Convert the dilated image to a binary array
651+
_, binary_array = cv2.threshold(dilated_image, 128, 1, cv2.THRESH_BINARY)
652+
final_mask += binary_array
653+
654+
final_mask = final_mask / final_mask.max()
655+
656+
return final_mask

pysteps/nowcasts/utils.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222

2323
from pysteps import extrapolation
2424

25-
2625
try:
2726
import dask
2827

pysteps/tests/test_blending_steps.py

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,31 +8,39 @@
88

99

1010
steps_arg_values = [
11-
(1, 3, 4, 8, None, None, False, "spn", True, 4, False, False),
12-
(1, 3, 4, 8, "obs", None, False, "spn", True, 4, False, False),
13-
(1, 3, 4, 8, "incremental", None, False, "spn", True, 4, False, False),
14-
(1, 3, 4, 8, None, "mean", False, "spn", True, 4, False, False),
15-
(1, 3, 4, 8, None, "cdf", False, "spn", True, 4, False, False),
16-
(1, 3, 4, 8, "incremental", "cdf", False, "spn", True, 4, False, False),
17-
(1, 3, 4, 6, "incremental", "cdf", False, "bps", True, 4, False, False),
18-
(1, 3, 4, 6, "incremental", "cdf", False, "bps", False, 4, False, False),
19-
(1, 3, 4, 9, "incremental", "cdf", False, "spn", True, 4, False, False),
20-
(2, 3, 10, 8, "incremental", "cdf", False, "spn", True, 10, False, False),
21-
(5, 3, 5, 8, "incremental", "cdf", False, "spn", True, 5, False, False),
22-
(1, 10, 1, 8, "incremental", "cdf", False, "spn", True, 1, False, False),
23-
(2, 3, 2, 8, "incremental", "cdf", True, "spn", True, 2, False, False),
24-
(1, 3, 6, 8, None, None, False, "spn", True, 6, False, False),
11+
(1, 3, 4, 8, None, None, False, "spn", True, 4, False, False, 0),
12+
(1, 3, 4, 8, "obs", None, False, "spn", True, 4, False, False, 0),
13+
(1, 3, 4, 8, "incremental", None, False, "spn", True, 4, False, False, 0),
14+
(1, 3, 4, 8, None, "mean", False, "spn", True, 4, False, False, 0),
15+
(1, 3, 4, 8, None, "cdf", False, "spn", True, 4, False, False, 0),
16+
(1, 3, 4, 8, "incremental", "cdf", False, "spn", True, 4, False, False, 0),
17+
(1, 3, 4, 6, "incremental", "cdf", False, "bps", True, 4, False, False, 0),
18+
(1, 3, 4, 6, "incremental", "cdf", False, "bps", False, 4, False, False, 0),
19+
(1, 3, 4, 9, "incremental", "cdf", False, "spn", True, 4, False, False, 0),
20+
(2, 3, 10, 8, "incremental", "cdf", False, "spn", True, 10, False, False, 0),
21+
(5, 3, 5, 8, "incremental", "cdf", False, "spn", True, 5, False, False, 0),
22+
(1, 10, 1, 8, "incremental", "cdf", False, "spn", True, 1, False, False, 0),
23+
(2, 3, 2, 8, "incremental", "cdf", True, "spn", True, 2, False, False, 0),
24+
(1, 3, 6, 8, None, None, False, "spn", True, 6, False, False, 0),
2525
# Test the case where the radar image contains no rain.
26-
(1, 3, 6, 8, None, None, False, "spn", True, 6, True, False),
27-
(5, 3, 5, 6, "incremental", "cdf", False, "spn", False, 5, True, False),
26+
(1, 3, 6, 8, None, None, False, "spn", True, 6, True, False, 0),
27+
(5, 3, 5, 6, "incremental", "cdf", False, "spn", False, 5, True, False, 0),
2828
# Test the case where the NWP fields contain no rain.
29-
(1, 3, 6, 8, None, None, False, "spn", True, 6, False, True),
30-
(5, 3, 5, 6, "incremental", "cdf", False, "spn", False, 5, False, True),
29+
(1, 3, 6, 8, None, None, False, "spn", True, 6, False, True, 0),
30+
(5, 3, 5, 6, "incremental", "cdf", False, "spn", False, 5, False, True, 0),
3131
# Test the case where both the radar image and the NWP fields contain no rain.
32-
(1, 3, 6, 8, None, None, False, "spn", True, 6, True, True),
33-
(5, 3, 5, 6, "incremental", "cdf", False, "spn", False, 5, True, True),
34-
(5, 3, 5, 6, "obs", "mean", True, "spn", True, 5, True, True),
32+
(1, 3, 6, 8, None, None, False, "spn", True, 6, True, True, 0),
33+
(5, 3, 5, 6, "incremental", "cdf", False, "spn", False, 5, True, True, 0),
34+
(5, 3, 5, 6, "obs", "mean", True, "spn", True, 5, True, True, 0),
35+
# Test for smooth radar mask
36+
(1, 3, 6, 8, None, None, False, "spn", True, 6, False, False, 80),
37+
(5, 3, 5, 6, "incremental", "cdf", False, "spn", False, 5, False, False, 80),
38+
(5, 3, 5, 6, "obs", "mean", False, "spn", False, 5, False, False, 80),
39+
(1, 3, 6, 8, None, None, False, "spn", True, 6, False, True, 80),
40+
(5, 3, 5, 6, "incremental", "cdf", False, "spn", False, 5, True, False, 80),
41+
(5, 3, 5, 6, "obs", "mean", False, "spn", False, 5, True, True, 80),
3542
]
43+
3644
steps_arg_names = (
3745
"n_models",
3846
"n_timesteps",
@@ -46,6 +54,7 @@
4654
"expected_n_ens_members",
4755
"zero_radar",
4856
"zero_nwp",
57+
"smooth_radar_mask_range",
4958
)
5059

5160

@@ -63,6 +72,7 @@ def test_steps_blending(
6372
expected_n_ens_members,
6473
zero_radar,
6574
zero_nwp,
75+
smooth_radar_mask_range,
6676
):
6777
pytest.importorskip("cv2")
6878

@@ -254,6 +264,7 @@ def test_steps_blending(
254264
conditional=False,
255265
probmatching_method=probmatching_method,
256266
mask_method=mask_method,
267+
smooth_radar_mask_range=smooth_radar_mask_range,
257268
callback=None,
258269
return_output=True,
259270
seed=None,

pysteps/tests/test_blending_utils.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
compute_store_nwp_motion,
1717
load_NWP,
1818
check_norain,
19+
compute_smooth_dilated_mask,
1920
)
2021

2122
pytest.importorskip("netCDF4")
@@ -140,12 +141,27 @@
140141
)
141142
]
142143

144+
smoothing_arg_names = (
145+
"precip_nwp",
146+
"max_padding_size_in_px",
147+
"gaussian_kernel_size",
148+
"inverted",
149+
"non_linear_growth_kernel_sizes",
150+
)
151+
152+
smoothing_arg_values = [
153+
(precip_nwp, 80, 9, False, False),
154+
(precip_nwp, 10, 9, False, False),
155+
(precip_nwp, 80, 5, False, False),
156+
(precip_nwp, 80, 9, True, False),
157+
(precip_nwp, 80, 9, False, True),
158+
]
159+
143160

144161
###
145162
# The test
146163
###
147164
@pytest.mark.parametrize(utils_arg_names, utils_arg_values)
148-
149165
# The test function to be used
150166
def test_blending_utils(
151167
precip_nwp,
@@ -404,3 +420,28 @@ def test_blending_utils(
404420

405421
# should always give norain if the threshold is set to 100%
406422
assert check_norain(precip_arr, norain_thr=1.0)
423+
424+
425+
# Finally, also test the compute_smooth_dilated mask functionality
426+
@pytest.mark.parametrize(smoothing_arg_names, smoothing_arg_values)
427+
def test_blending_smoothing_utils(
428+
precip_nwp,
429+
max_padding_size_in_px,
430+
gaussian_kernel_size,
431+
inverted,
432+
non_linear_growth_kernel_sizes,
433+
):
434+
# First add some nans to indicate a mask
435+
precip_nwp[:, 0:100, 0:100] = np.nan
436+
nan_indices = np.isnan(precip_nwp[0])
437+
new_mask = compute_smooth_dilated_mask(
438+
nan_indices,
439+
max_padding_size_in_px=max_padding_size_in_px,
440+
gaussian_kernel_size=gaussian_kernel_size,
441+
inverted=inverted,
442+
non_linear_growth_kernel_sizes=non_linear_growth_kernel_sizes,
443+
)
444+
assert new_mask.shape == nan_indices.shape
445+
446+
if max_padding_size_in_px > 0 and inverted == False:
447+
assert np.sum((new_mask > 0) & (new_mask < 1)) > 0

0 commit comments

Comments
 (0)