Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add option to smooth radar mask #379

Merged
merged 11 commits into from
Jul 18, 2024
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/psf/black
rev: 24.3.0
rev: 24.4.2
hooks:
- id: black
language_version: python3
54 changes: 51 additions & 3 deletions pysteps/blending/steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
conditional=False,
probmatching_method="cdf",
mask_method="incremental",
smooth_radar_mask_range=0,
callback=None,
return_output=True,
seed=None,
Expand Down Expand Up @@ -210,6 +211,11 @@
'obs' = apply precip_thr to the most recently observed precipitation intensity
field, 'incremental' = iteratively buffer the mask with a certain rate
(currently it is 1 km/min), None=no masking.
smooth_radar_mask_range: int, Default is 0.
If 0 this generates a normal forecast. To create a smooth mask, this range
dnerini marked this conversation as resolved.
Show resolved Hide resolved
should be a positive value, representing a buffer band of a number of pixels
by which the mask is cropped and smoothed. The smooth radar mask removes
the hard edges between NWP and radar in the final blended product.
probmatching_method: {'cdf','mean',None}, optional
Method for matching the statistics of the forecast field with those of
the most recently observed one. 'cdf'=map the forecast CDF to the observed
Expand Down Expand Up @@ -1451,10 +1457,52 @@
# forecast outside the radar domain. Therefore, fill these
# areas with the "..._mod_only" blended forecasts, consisting
# of the NWP and noise components.

nan_indices = np.isnan(R_f_new)
R_f_new[nan_indices] = R_f_new_mod_only[nan_indices]
nan_indices = np.isnan(R_pm_blended)
R_pm_blended[nan_indices] = R_pm_blended_mod_only[nan_indices]
if smooth_radar_mask_range != 0:
dnerini marked this conversation as resolved.
Show resolved Hide resolved
# Compute the smooth dilated mask
new_mask = blending.utils.compute_smooth_dilated_mask(

Check warning on line 1464 in pysteps/blending/steps.py

View check run for this annotation

Codecov / codecov/patch

pysteps/blending/steps.py#L1464

Added line #L1464 was not covered by tests
nan_indices,
max_padding_size_in_px=smooth_radar_mask_range,
gaussian_kernel_size=9,
RubenImhoff marked this conversation as resolved.
Show resolved Hide resolved
inverted=False,
non_linear_growth_kernel_sizes=False,
)

# Ensure mask values are between 0 and 1
dnerini marked this conversation as resolved.
Show resolved Hide resolved
dnerini marked this conversation as resolved.
Show resolved Hide resolved
mask_model = np.clip(new_mask, 0, 1)
mask_radar = np.clip(1 - new_mask, 0, 1)

Check warning on line 1474 in pysteps/blending/steps.py

View check run for this annotation

Codecov / codecov/patch

pysteps/blending/steps.py#L1473-L1474

Added lines #L1473 - L1474 were not covered by tests

# Handle NaNs in R_f_new and R_f_new_mod_only by setting NaNs to 0 in the blending step
R_f_new_mod_only_no_nan = np.nan_to_num(

Check warning on line 1477 in pysteps/blending/steps.py

View check run for this annotation

Codecov / codecov/patch

pysteps/blending/steps.py#L1477

Added line #L1477 was not covered by tests
R_f_new_mod_only, nan=0
)
R_f_new_no_nan = np.nan_to_num(R_f_new, nan=0)

Check warning on line 1480 in pysteps/blending/steps.py

View check run for this annotation

Codecov / codecov/patch

pysteps/blending/steps.py#L1480

Added line #L1480 was not covered by tests

# Perform the blending of radar and model inside the radar domain using a weighted combination
R_f_new = np.nansum(

Check warning on line 1483 in pysteps/blending/steps.py

View check run for this annotation

Codecov / codecov/patch

pysteps/blending/steps.py#L1483

Added line #L1483 was not covered by tests
[
mask_model * R_f_new_mod_only_no_nan,
mask_radar * R_f_new_no_nan,
],
axis=0,
)

nan_indices = np.isnan(R_pm_blended)
R_pm_blended = np.nansum(

Check warning on line 1492 in pysteps/blending/steps.py

View check run for this annotation

Codecov / codecov/patch

pysteps/blending/steps.py#L1491-L1492

Added lines #L1491 - L1492 were not covered by tests
[
R_pm_blended * mask_radar,
R_pm_blended_mod_only * mask_model,
],
axis=0,
)
else:
R_f_new[nan_indices] = R_f_new_mod_only[nan_indices]
nan_indices = np.isnan(R_pm_blended)
R_pm_blended[nan_indices] = R_pm_blended_mod_only[
nan_indices
]

# Finally, fill the remaining nan values, if present, with
# the minimum value in the forecast
nan_indices = np.isnan(R_f_new)
Expand Down
91 changes: 91 additions & 0 deletions pysteps/blending/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
compute_store_nwp_motion
load_NWP
check_norain
compute_smooth_dilated_mask
"""

import datetime
Expand All @@ -35,6 +36,13 @@
except ImportError:
NETCDF4_IMPORTED = False

try:
import cv2

CV2_IMPORTED = True
except ImportError:
CV2_IMPORTED = False

Check warning on line 44 in pysteps/blending/utils.py

View check run for this annotation

Codecov / codecov/patch

pysteps/blending/utils.py#L43-L44

Added lines #L43 - L44 were not covered by tests


def stack_cascades(R_d, donorm=True):
"""Stack the given cascades into a larger array.
Expand Down Expand Up @@ -557,3 +565,86 @@
f"Rain fraction is: {str(rain_pixels.size / precip_arr.size)}, while minimum fraction is {str(norain_thr)}"
)
return norain


def compute_smooth_dilated_mask(
original_mask,
max_padding_size_in_px=0,
gaussian_kernel_size=9,
inverted=False,
non_linear_growth_kernel_sizes=False,
):
"""
Compute a smooth dilated mask using Gaussian blur and dilation with varying kernel sizes.

Parameters
----------
original_mask : array_like
Two-dimensional boolean array containing the input mask.
max_padding_size_in_px : int
The maximum size of the padding in pixels. Default is 100.
gaussian_kernel_size : int, optional
Size of the Gaussian kernel to use for blurring. Default is 9.
inverted : bool, optional
If True, invert the original mask before processing. Default is False.
non_linear_growth_kernel_sizes : bool, optional
If True, use non-linear growth for kernel sizes. Default is False.

Returns
-------
initial_mask : array_like
The initial binary mask after Gaussian blur and thresholding.
new_mask : array_like
RubenImhoff marked this conversation as resolved.
Show resolved Hide resolved
The smooth dilated mask normalized to the range [0,1].
"""
if not CV2_IMPORTED:
raise MissingOptionalDependency(

Check warning on line 601 in pysteps/blending/utils.py

View check run for this annotation

Codecov / codecov/patch

pysteps/blending/utils.py#L600-L601

Added lines #L600 - L601 were not covered by tests
"CV2 package is required to transform the mask into a smoot mask."
" Please install it using `pip install opencv-python`."
)

if max_padding_size_in_px < 0:
raise ValueError("max_padding_size_in_px must be greater than or equal to 0.")

Check warning on line 607 in pysteps/blending/utils.py

View check run for this annotation

Codecov / codecov/patch

pysteps/blending/utils.py#L606-L607

Added lines #L606 - L607 were not covered by tests

# Convert the original mask to uint8 numpy array and invert if needed
array_2d = np.array(original_mask, dtype=np.uint8)
if inverted:
array_2d = np.bitwise_not(array_2d)

Check warning on line 612 in pysteps/blending/utils.py

View check run for this annotation

Codecov / codecov/patch

pysteps/blending/utils.py#L610-L612

Added lines #L610 - L612 were not covered by tests

# Rescale the 2D array values to 0-255 (black or white)
rescaled_array = array_2d * 255

Check warning on line 615 in pysteps/blending/utils.py

View check run for this annotation

Codecov / codecov/patch

pysteps/blending/utils.py#L615

Added line #L615 was not covered by tests

# Apply Gaussian blur to the rescaled array
blurred_image = cv2.GaussianBlur(

Check warning on line 618 in pysteps/blending/utils.py

View check run for this annotation

Codecov / codecov/patch

pysteps/blending/utils.py#L618

Added line #L618 was not covered by tests
rescaled_array, (gaussian_kernel_size, gaussian_kernel_size), 0
)

# Apply binary threshold to negate the blurring effect
_, binary_image = cv2.threshold(blurred_image, 128, 255, cv2.THRESH_BINARY)

Check warning on line 623 in pysteps/blending/utils.py

View check run for this annotation

Codecov / codecov/patch

pysteps/blending/utils.py#L623

Added line #L623 was not covered by tests

# Define kernel sizes
if non_linear_growth_kernel_sizes:
lin_space = np.linspace(0, np.sqrt(max_padding_size_in_px), 10)
non_lin_space = np.power(lin_space, 2)
kernel_sizes = list(set(non_lin_space.astype(np.uint8)))

Check warning on line 629 in pysteps/blending/utils.py

View check run for this annotation

Codecov / codecov/patch

pysteps/blending/utils.py#L626-L629

Added lines #L626 - L629 were not covered by tests
else:
kernel_sizes = np.linspace(0, max_padding_size_in_px, 10, dtype=np.uint8)

Check warning on line 631 in pysteps/blending/utils.py

View check run for this annotation

Codecov / codecov/patch

pysteps/blending/utils.py#L631

Added line #L631 was not covered by tests

# Process each kernel size
final_mask = np.zeros_like(binary_image, dtype=np.float64)
for kernel_size in kernel_sizes:
if kernel_size == 0:
dilated_image = binary_image

Check warning on line 637 in pysteps/blending/utils.py

View check run for this annotation

Codecov / codecov/patch

pysteps/blending/utils.py#L634-L637

Added lines #L634 - L637 were not covered by tests
else:
kernel = cv2.getStructuringElement(

Check warning on line 639 in pysteps/blending/utils.py

View check run for this annotation

Codecov / codecov/patch

pysteps/blending/utils.py#L639

Added line #L639 was not covered by tests
cv2.MORPH_ELLIPSE, (kernel_size, kernel_size)
)
dilated_image = cv2.dilate(binary_image, kernel)

Check warning on line 642 in pysteps/blending/utils.py

View check run for this annotation

Codecov / codecov/patch

pysteps/blending/utils.py#L642

Added line #L642 was not covered by tests

# Convert the dilated image to a binary array
_, binary_array = cv2.threshold(dilated_image, 128, 1, cv2.THRESH_BINARY)
final_mask += binary_array

Check warning on line 646 in pysteps/blending/utils.py

View check run for this annotation

Codecov / codecov/patch

pysteps/blending/utils.py#L645-L646

Added lines #L645 - L646 were not covered by tests

final_mask = final_mask / final_mask.max()

Check warning on line 648 in pysteps/blending/utils.py

View check run for this annotation

Codecov / codecov/patch

pysteps/blending/utils.py#L648

Added line #L648 was not covered by tests

return final_mask

Check warning on line 650 in pysteps/blending/utils.py

View check run for this annotation

Codecov / codecov/patch

pysteps/blending/utils.py#L650

Added line #L650 was not covered by tests
1 change: 0 additions & 1 deletion pysteps/nowcasts/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@

from pysteps import extrapolation


try:
import dask

Expand Down
52 changes: 31 additions & 21 deletions pysteps/tests/test_blending_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,31 +8,39 @@


steps_arg_values = [
(1, 3, 4, 8, None, None, False, "spn", True, 4, False, False),
(1, 3, 4, 8, "obs", None, False, "spn", True, 4, False, False),
(1, 3, 4, 8, "incremental", None, False, "spn", True, 4, False, False),
(1, 3, 4, 8, None, "mean", False, "spn", True, 4, False, False),
(1, 3, 4, 8, None, "cdf", False, "spn", True, 4, False, False),
(1, 3, 4, 8, "incremental", "cdf", False, "spn", True, 4, False, False),
(1, 3, 4, 6, "incremental", "cdf", False, "bps", True, 4, False, False),
(1, 3, 4, 6, "incremental", "cdf", False, "bps", False, 4, False, False),
(1, 3, 4, 9, "incremental", "cdf", False, "spn", True, 4, False, False),
(2, 3, 10, 8, "incremental", "cdf", False, "spn", True, 10, False, False),
(5, 3, 5, 8, "incremental", "cdf", False, "spn", True, 5, False, False),
(1, 10, 1, 8, "incremental", "cdf", False, "spn", True, 1, False, False),
(2, 3, 2, 8, "incremental", "cdf", True, "spn", True, 2, False, False),
(1, 3, 6, 8, None, None, False, "spn", True, 6, False, False),
(1, 3, 4, 8, None, None, False, "spn", True, 4, False, False, 0),
(1, 3, 4, 8, "obs", None, False, "spn", True, 4, False, False, 0),
(1, 3, 4, 8, "incremental", None, False, "spn", True, 4, False, False, 0),
(1, 3, 4, 8, None, "mean", False, "spn", True, 4, False, False, 0),
(1, 3, 4, 8, None, "cdf", False, "spn", True, 4, False, False, 0),
(1, 3, 4, 8, "incremental", "cdf", False, "spn", True, 4, False, False, 0),
(1, 3, 4, 6, "incremental", "cdf", False, "bps", True, 4, False, False, 0),
(1, 3, 4, 6, "incremental", "cdf", False, "bps", False, 4, False, False, 0),
(1, 3, 4, 9, "incremental", "cdf", False, "spn", True, 4, False, False, 0),
(2, 3, 10, 8, "incremental", "cdf", False, "spn", True, 10, False, False, 0),
(5, 3, 5, 8, "incremental", "cdf", False, "spn", True, 5, False, False, 0),
(1, 10, 1, 8, "incremental", "cdf", False, "spn", True, 1, False, False, 0),
(2, 3, 2, 8, "incremental", "cdf", True, "spn", True, 2, False, False, 0),
(1, 3, 6, 8, None, None, False, "spn", True, 6, False, False, 0),
# Test the case where the radar image contains no rain.
(1, 3, 6, 8, None, None, False, "spn", True, 6, True, False),
(5, 3, 5, 6, "incremental", "cdf", False, "spn", False, 5, True, False),
(1, 3, 6, 8, None, None, False, "spn", True, 6, True, False, 0),
(5, 3, 5, 6, "incremental", "cdf", False, "spn", False, 5, True, False, 0),
# Test the case where the NWP fields contain no rain.
(1, 3, 6, 8, None, None, False, "spn", True, 6, False, True),
(5, 3, 5, 6, "incremental", "cdf", False, "spn", False, 5, False, True),
(1, 3, 6, 8, None, None, False, "spn", True, 6, False, True, 0),
(5, 3, 5, 6, "incremental", "cdf", False, "spn", False, 5, False, True, 0),
# Test the case where both the radar image and the NWP fields contain no rain.
(1, 3, 6, 8, None, None, False, "spn", True, 6, True, True),
(5, 3, 5, 6, "incremental", "cdf", False, "spn", False, 5, True, True),
(5, 3, 5, 6, "obs", "mean", True, "spn", True, 5, True, True),
(1, 3, 6, 8, None, None, False, "spn", True, 6, True, True, 0),
(5, 3, 5, 6, "incremental", "cdf", False, "spn", False, 5, True, True, 0),
(5, 3, 5, 6, "obs", "mean", True, "spn", True, 5, True, True, 0),
# Test for smooth radar mask
(1, 3, 6, 8, None, None, False, "spn", True, 6, False, False, 80),
(5, 3, 5, 6, "incremental", "cdf", False, "spn", False, 5, False, False, 80),
(5, 3, 5, 6, "obs", "mean", False, "spn", False, 5, False, False, 80),
(1, 3, 6, 8, None, None, False, "spn", True, 6, False, True, 80),
(5, 3, 5, 6, "incremental", "cdf", False, "spn", False, 5, True, False, 80),
(5, 3, 5, 6, "obs", "mean", False, "spn", False, 5, True, True, 80),
]

steps_arg_names = (
"n_models",
"n_timesteps",
Expand All @@ -46,6 +54,7 @@
"expected_n_ens_members",
"zero_radar",
"zero_nwp",
"smooth_radar_mask_range",
)


Expand All @@ -63,6 +72,7 @@ def test_steps_blending(
expected_n_ens_members,
zero_radar,
zero_nwp,
smooth_radar_mask_range,
):
pytest.importorskip("cv2")

Expand Down