Skip to content

Commit 1b32270

Browse files
authored
Merge pull request #32 from GeoscienceAustralia/30-pipeline-rema-antimeridian-handling
30 pipeline rema antimeridian handling
2 parents 4ec51d2 + ca46f05 commit 1b32270

File tree

5 files changed

+294
-29
lines changed

5 files changed

+294
-29
lines changed

dem_handler/dem/geoid.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ def apply_geoid(
6161
dem_profile: dict,
6262
geoid_path=str | Path,
6363
buffer_pixels: int = 2,
64+
dem_mask_buffer: int = 0,
6465
save_path: str | Path = "",
6566
mask_array: np.ndarray | None = None,
6667
method: Literal["add", "subtract"] = "add",
@@ -77,6 +78,10 @@ def apply_geoid(
7778
Path to the geoid file
7879
buffer_pixels : int, optional
7980
Additional pixel buffer for geoid, by default 2.
81+
dem_mask_buffer : int, optional
82+
An additional buffer for the mask that gets applied when
83+
reading the geoid. The mask is based on the dem bounds. Value
84+
is in geographic units. e.g. degrees for 4326 and metres for 3031.
8085
save_path : str | Path, optional
8186
Location to save dem, by default "".
8287
mask_array : np.ndarray | None, optional
@@ -107,11 +112,13 @@ def apply_geoid(
107112
dem_crs = dem_profile["crs"].to_epsg()
108113
if geoid_crs != dem_crs:
109114
mask_dem_bounds = transform_polygon(
110-
shapely.geometry.box(*dem_bounds), dem_crs, geoid_crs
115+
shapely.geometry.box(*dem_bounds).buffer(dem_mask_buffer),
116+
dem_crs,
117+
geoid_crs,
111118
).bounds
112119
geoid_array, geoid_transform = rasterio.mask.mask(
113120
src,
114-
[shapely.geometry.box(*mask_dem_bounds)],
121+
[shapely.geometry.box(*mask_dem_bounds).buffer(dem_mask_buffer)],
115122
all_touched=True,
116123
crop=True,
117124
pad=True,

dem_handler/dem/rema.py

Lines changed: 122 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@
2121

2222
from dem_handler.dem.geoid import apply_geoid
2323
from dem_handler.download.aws import download_egm_08_geoid
24-
24+
from dem_handler.utils.spatial import (
25+
check_bounds_likely_cross_antimeridian,
26+
split_antimeridian_shape_into_east_west_bounds,
27+
)
28+
from dem_handler.utils.raster import reproject_and_merge_rasters
2529

2630
# Create a custom type that allows use of BoundingBox or tuple(left, bottom, right, top)
2731
BBox = BoundingBox | tuple[float | int, float | int, float | int, float | int]
@@ -43,6 +47,7 @@ def get_rema_dem_for_bounds(
4347
return_over_ocean: bool = True,
4448
geoid_tif_path: Path | str = "egm_08_geoid.tif",
4549
download_geoid: bool = False,
50+
check_geoid_crosses_antimeridian: bool = True,
4651
num_cpus: int = 1,
4752
num_tasks: int | None = None,
4853
return_paths: bool = False,
@@ -77,6 +82,9 @@ def get_rema_dem_for_bounds(
7782
Path to the existing ellipsoid file, by default "egm_08_geoid.tif"
7883
download_geoid : bool, optional
7984
Flag to download the ellipsoid file, by default False
85+
check_geoid_crosses_antimeridian : bool, optional
86+
Check if the geoid crosses the antimeridian. Set to False if it is known
87+
The requested bounds do not cross the antimeridian to stop false positives.
8088
num_cpus : int, optional
8189
Number of cpus to be used for parallel download, by default 1.
8290
Setting to -1 will use all available cpus
@@ -125,6 +133,26 @@ def get_rema_dem_for_bounds(
125133
# Log the requested bounds
126134
logging.info(f"Getting REMA DEM for bounds: {bounds.bounds}")
127135

136+
# Check if bounds cross the antimeridian
137+
if bounds.left > bounds.right:
138+
logging.warning(
139+
f"left longitude value ({bounds[0]}) is greater than the right longitude value {({bounds[2]})} "
140+
f"for the bounds provided. Assuming the bounds cross the antimeridian : {bounds}"
141+
)
142+
dem_crosses_antimeridian = True
143+
else:
144+
dem_crosses_antimeridian = False
145+
# run a basic to check if the bounds likely cross the antimeridian but
146+
# are just formatted wrong. If so, warn the user.
147+
if bounds_src_crs == 4326:
148+
if check_bounds_likely_cross_antimeridian(bounds):
149+
logging.warning(
150+
"Provided bounds have very large longitude extent. If the shape crosses the "
151+
f"antimeridian, reformat the bounds as : ({bounds[2]}, {bounds[1]}, {bounds[0]}, {bounds[3]}). "
152+
"For large areas, provide the inputs bounds in 3031 to avoid transform errors between "
153+
"coordinate systems."
154+
)
155+
128156
if bounds_src_crs != REMA_CRS:
129157
logging.warning(
130158
f"Transforming bounds from {bounds_src_crs} to {REMA_CRS}. This may return data beyond the requested bounds. If this is not desired, provide the bounds in EPSG:{REMA_CRS}."
@@ -228,47 +256,114 @@ def get_rema_dem_for_bounds(
228256

229257
if dem_novalues_count == 0 and ellipsoid_heights:
230258
# we have data everywhere and the values are already ellipsoid referenced
259+
logging.info(f"All DEM values already referenced to ellipsoid")
260+
logging.info(f"Returning DEM referenced to ellipsoidal heights")
231261
if save_path:
232262
logging.info(f"DEM saved to : {save_path}")
233263
logging.info(f"Dem array shape = {dem_array.shape}")
234264
return dem_array, dem_profile, raster_paths
235265
else:
236-
geoid_bounds = bounds
237-
if bounds_crs != GEOID_CRS:
238-
geoid_bounds = transform_polygon(
239-
box(*bounds.bounds), bounds_crs, GEOID_CRS
240-
).bounds
241-
if not download_geoid and not geoid_tif_path.exists():
266+
# we need to apply the geoid to the DEM
267+
if bounds_crs == GEOID_CRS:
268+
# bounds already in correct CRS (4326 for egm_08)
269+
geoid_bounds = bounds
270+
geoid_poly = box(*bounds.bounds)
271+
else:
272+
geoid_poly = transform_polygon(box(*bounds.bounds), bounds_crs, GEOID_CRS)
273+
geoid_bounds = geoid_poly.bounds
274+
275+
# check if the bounds likely the antimeridian
276+
if check_geoid_crosses_antimeridian:
277+
logging.info(
278+
"Checking if geoid likely crosses the antimeridian. "
279+
"If this is not desired, set check_geoid_crosses_antimeridian = False"
280+
)
281+
geoid_crosses_antimeridian = check_bounds_likely_cross_antimeridian(
282+
geoid_bounds
283+
)
284+
else:
285+
geoid_crosses_antimeridian = False
286+
if geoid_crosses_antimeridian or dem_crosses_antimeridian:
287+
logging.info(
288+
"Geoid crosses antimeridian, splitting into east and west hemispheres"
289+
)
290+
# separate the geoid into east and west sections
291+
east_geoid_tif_path = geoid_tif_path.with_stem(
292+
geoid_tif_path.stem + "_eastern"
293+
)
294+
west_geoid_tif_path = geoid_tif_path.with_stem(
295+
geoid_tif_path.stem + "_western"
296+
)
297+
# construct the bounds for east and west hemisphere
298+
east_geoid_bounds, west_geoid_bounds = (
299+
split_antimeridian_shape_into_east_west_bounds(
300+
geoid_poly, buffer_degrees=0.1
301+
)
302+
)
303+
logging.info(f"East geoid bounds : {east_geoid_bounds.bounds}")
304+
logging.info(f"West geoid bounds : {west_geoid_bounds.bounds}")
305+
geoid_tifs_to_apply = [east_geoid_tif_path, west_geoid_tif_path]
306+
geoid_bounds_to_apply = [
307+
east_geoid_bounds.bounds,
308+
west_geoid_bounds.bounds,
309+
]
310+
else:
311+
geoid_tifs_to_apply = [geoid_tif_path]
312+
geoid_bounds_to_apply = [geoid_bounds] # bounds already tuple
313+
314+
if not download_geoid and not all(p.exists() for p in geoid_tifs_to_apply):
242315
raise FileExistsError(
243-
f"Geoid file does not exist: {geoid_tif_path}. "
316+
f"Required geoid files do not exist: {geoid_tifs_to_apply}. "
244317
"correct path or set download_geoid = True"
245318
)
246-
elif download_geoid and not geoid_tif_path.exists():
319+
elif download_geoid and not all(p.exists() for p in geoid_tifs_to_apply):
247320
logging.info(f"Downloading the egm_08 geoid")
248-
download_egm_08_geoid(geoid_tif_path, bounds=geoid_bounds)
249-
elif download_geoid and geoid_tif_path.exists():
250-
# Check that the existing geiod covers the dem
251-
with rasterio.open(geoid_tif_path) as src:
252-
existing_geoid_bounds = shapely.geometry.box(*src.bounds)
253-
if existing_geoid_bounds.covers(shapely.geometry.box(*bounds.bounds)):
254-
logging.info(
255-
f"Skipping geoid download. The existing geoid file covers the DEM bounds. Existing geoid file: {geoid_tif_path}."
256-
)
257-
else:
258-
logging.info(
259-
f"The existing geoid file does not cover the DEM bounds. A new geoid file covering the bounds will be downloaded, overwriting the existing geiod file: {geoid_tif_path}."
260-
)
261-
download_egm_08_geoid(geoid_tif_path, bounds=geoid_bounds)
262-
263-
logging.info(f"Using geoid file: {geoid_tif_path}")
321+
for geoid_path, geoid_bounds in zip(
322+
geoid_tifs_to_apply, geoid_bounds_to_apply
323+
):
324+
download_egm_08_geoid(geoid_path, bounds=geoid_bounds)
325+
elif download_geoid and all(p.exists() for p in geoid_tifs_to_apply):
326+
# Check that the existing geoid covers the dem
327+
for geoid_path, geoid_bounds in zip(
328+
geoid_tifs_to_apply, geoid_bounds_to_apply
329+
):
330+
with rasterio.open(geoid_path) as src:
331+
existing_geoid_bounds = shapely.geometry.box(*src.bounds)
332+
if existing_geoid_bounds.covers(shapely.geometry.box(*bounds.bounds)):
333+
logging.info(
334+
f"Skipping geoid download. The existing geoid file covers the DEM bounds. Existing geoid file: {geoid_path}."
335+
)
336+
else:
337+
logging.info(
338+
f"The existing geoid file does not cover the DEM bounds. A new geoid file covering the bounds will be downloaded, overwriting the existing geiod file: {geoid_tif_path}."
339+
)
340+
download_egm_08_geoid(geoid_path, bounds=geoid_bounds)
341+
342+
if geoid_crosses_antimeridian:
343+
logging.info(
344+
f"Reproject and merge east and west hemisphere geoid rasters to EPSG:{REMA_CRS}"
345+
)
346+
reproject_and_merge_rasters(
347+
geoid_tifs_to_apply, REMA_CRS, save_path=geoid_tif_path
348+
)
349+
# add a larger buffer to ensure geoid is applied correctly to all of dem
350+
dem_mask_buffer = 5_000
351+
else:
352+
# no buffer is required
353+
dem_mask_buffer = 0
264354

265355
if ellipsoid_heights:
266356
logging.info(f"Returning DEM referenced to ellipsoidal heights")
357+
logging.info(f"Applying geoid to DEM : {geoid_tif_path}")
358+
# Apply the geoid only on areas where no rema data was found
359+
# i.e. these values were set to zero and will be replaced with
360+
# ellipsoid heights
267361
dem_array = apply_geoid(
268362
dem_array=dem_array,
269363
dem_profile=dem_profile,
270364
geoid_path=geoid_tif_path,
271365
buffer_pixels=2,
366+
dem_mask_buffer=dem_mask_buffer,
272367
save_path=save_path,
273368
mask_array=dem_novalues_mask,
274369
method="add",
@@ -279,11 +374,13 @@ def get_rema_dem_for_bounds(
279374
# rema dem is by default referenced to the ellipsoid. We do this only in
280375
# areas with data, leaving the nodata areas at zero values
281376
logging.info(f"Returning DEM referenced to geoid heights")
377+
logging.info(f"Applying geoid to DEM : {geoid_tif_path}")
282378
dem_array = apply_geoid(
283379
dem_array=dem_array,
284380
dem_profile=dem_profile,
285381
geoid_path=geoid_tif_path,
286382
buffer_pixels=2,
383+
dem_mask_buffer=dem_mask_buffer,
287384
save_path=save_path,
288385
mask_array=dem_values_mask,
289386
method="subtract",

dem_handler/utils/raster.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,3 +483,51 @@ def read_raster_with_bounds(file_path, bounds, buffer_pixels=0):
483483
)
484484

485485
return data, profile
486+
487+
488+
def reproject_and_merge_rasters(
489+
file_paths: list[str] | list[Path], target_crs, save_path=None
490+
) -> tuple[np.ndarray, dict]:
491+
"""
492+
Reproject multiple raster files to a common CRS and merge them into a single array.
493+
494+
Parameters
495+
----------
496+
file_paths : list of str or Path
497+
List of file paths to the input raster files.
498+
target_crs : rasterio CRS or compatible
499+
The target coordinate reference system to which all rasters will be reprojected.
500+
save_path : str or Path, optional
501+
Path to save the merged raster to disk. If None, the merged raster is not saved.
502+
503+
Returns
504+
-------
505+
merged_array : np.ndarray
506+
The merged raster data as a NumPy array.
507+
merged_profile : dict
508+
The rasterio profile (metadata) associated with the merged raster.
509+
510+
Notes
511+
-----
512+
- Each raster in `file_paths` is first reprojected individually using `reproject_raster`.
513+
- The resulting arrays are then merged using `merge_arrays_with_geometadata` with a
514+
pixel-wise maximum as the default method.
515+
- The output CRS of the merged array matches `target_crs`.
516+
"""
517+
518+
arrays = []
519+
profiles = []
520+
521+
for raster_path in file_paths:
522+
array, profile = reproject_raster(raster_path, target_crs)
523+
arrays.append(array)
524+
profiles.append(profile)
525+
526+
array, profile = merge_arrays_with_geometadata(
527+
arrays=arrays,
528+
profiles=profiles,
529+
method="max",
530+
output_path=save_path,
531+
)
532+
533+
return array, profile

dem_handler/utils/spatial.py

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import geopandas as gpd
44
import pyproj
55
from shapely import segmentize
6-
from shapely.geometry import Polygon, box
6+
from shapely.geometry import Polygon, box, MultiPolygon
77
from pyproj.database import query_utm_crs_info
88
from pyproj.aoi import AreaOfInterest
99
from pyproj import CRS
@@ -349,7 +349,7 @@ def split_bounds_at_antimeridian(
349349
return (bounds_eastern_hemisphere, bounds_western_hemisphere)
350350

351351

352-
def get_all_lat_lon_coords(
352+
def get_all_lon_lat_coords(
353353
geom: shapely.geometry.Polygon | shapely.geometry.MultiPolygon,
354354
) -> tuple[list, list]:
355355
"""
@@ -626,3 +626,67 @@ def check_dem_type_in_bounds(
626626
else:
627627
logger.info(f"{intersecting_tiles} intersecting tiles found")
628628
return True
629+
630+
631+
def split_antimeridian_shape_into_east_west_bounds(
632+
antimeridian_shape: Polygon | MultiPolygon, buffer_degrees: float = 0
633+
) -> tuple[BoundingBox, BoundingBox]:
634+
"""
635+
Split a geometry that crosses the antimeridian into eastern and western bounding boxes.
636+
637+
This function derives longitude/latitude bounds from a polygon or multipolygon
638+
that spans the antimeridian (±180°) and returns two bounding boxes:
639+
one covering the eastern hemisphere (-180 to <0) and one covering the
640+
western hemisphere (>0 to 180).
641+
642+
An optional buffer can be applied to slightly expand the bounds, which is
643+
useful when downloading or resampling rasters to avoid edge effects.
644+
645+
Parameters
646+
----------
647+
antimeridian_shape : Polygon or MultiPolygon
648+
Geometry that crosses the antimeridian, expressed in a geographic
649+
coordinate system (longitude, latitude).
650+
buffer_degrees : float, optional
651+
Buffer (in degrees) to apply to the resulting bounds in all directions.
652+
Longitude buffers expand away from the antimeridian, and latitude buffers
653+
are clipped to valid ranges [-90, 90]. Default is 0 (no buffer).
654+
655+
Returns
656+
-------
657+
east_bounds : BoundingBox
658+
Bounding box covering the eastern hemisphere portion
659+
(-180, min_lat, east_lon_min, max_lat).
660+
west_bounds : BoundingBox
661+
Bounding box covering the western hemisphere portion
662+
(west_lon_min, min_lat, 180, max_lat).
663+
664+
Notes
665+
-----
666+
- The split is determined by the maximum negative longitude (east side)
667+
and the minimum positive longitude (west side) present in the geometry.
668+
- This is commonly used when downloading or applying global rasters
669+
(e.g. geoids) that cannot be queried across the antimeridian in a single request.
670+
"""
671+
# get the lat and lon points to construct the bounds either side of the AM
672+
lons, lats = get_all_lon_lat_coords(antimeridian_shape)
673+
east_lon_min = max([x for x in lons if x < 0])
674+
west_lon_min = min([x for x in lons if x > 0])
675+
min_lat, max_lat = min(lats), max(lats)
676+
677+
# add slight buffer to the bounds
678+
if buffer_degrees:
679+
logging.info(
680+
f"Adding small buffer of {buffer_degrees} degrees to bounds either side of the antimeridian"
681+
)
682+
east_lon_min = (
683+
east_lon_min + buffer_degrees
684+
) # push further east (-177 -> -176.9)
685+
west_lon_min = west_lon_min - buffer_degrees # push further west (177 -> 176.9)
686+
min_lat = max(min_lat - buffer_degrees, -90) # ensure valid
687+
max_lat = min(max_lat + buffer_degrees, 90)
688+
689+
# construct the bounds
690+
east_bounds = BoundingBox(-180, min_lat, east_lon_min, max_lat)
691+
west_bounds = BoundingBox(west_lon_min, min_lat, 180, max_lat)
692+
return east_bounds, west_bounds

0 commit comments

Comments
 (0)