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

FIX: reference scene heuristic and uniform pixel size in SceneCollection returned from mapper #91

Merged
Binary file added data/sample_polygons/utm_zones_western-ch.gpkg
Binary file not shown.
2 changes: 1 addition & 1 deletion eodal/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
The ``Settings`` class uses ``pydantic``. This means all attributes of the class can
be **overwritten** using environmental variables or a `.env` file.

Copyright (C) 2022 Lukas Valentin Graf
Copyright (C) 2022, 2023 Lukas Valentin Graf

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
Expand Down
86 changes: 56 additions & 30 deletions eodal/mapper/mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -453,11 +453,18 @@ def _process_scene(
scene = scene_modifier.__call__(scene, **scene_modifier_kwargs)

# reproject scene if necessary
scene.reproject(
target_crs=item.target_epsg,
interpolation_method=reprojection_method,
inplace=True
)
# ..versionadd 0.2.3 check for the EPSG to make sure no unnecessary
# operations are undertaken to save runtime and avoid floating
# point inaccuracies
epsg_scene = [b.geo_info.epsg for _, b in scene]
intersection = set(epsg_scene).intersection(set([item.target_epsg]))
# we need to reproject only if the intersection returns an empty set.
if len(intersection) == 0:
scene.reproject(
target_crs=item.target_epsg,
interpolation_method=reprojection_method,
inplace=True
)

return scene

Expand Down Expand Up @@ -705,12 +712,34 @@ def _load_scenes_collection(
))
bounds_gdf = pd.concat(bounds_list)
total_bounds = bounds_gdf.total_bounds

# ..versionadd 0.2.3 (https://github.com/EOA-team/eodal/issues/90)
# we need to ensure that all scenes have not only the same extent but
# also the same pixel size and, hence, number of rows and columns
# The heuristic is:
# 1) is there a since that was not reprojected (_duplicated is False)
# 2) If no: use the first scene as reference scene
# 3) If yes: use the first scene that was not reprojected

# is there at a least a single scene that was not reprojected
if not self.metadata._duplicated.all():
reference_time = self.metadata[
~self.metadata._duplicated][self.time_column].values[0]
timestamps = scoll.timestamps
reference_timestamp_idx = np.argmin(
[pd.to_datetime(x) - reference_time for x in timestamps])
reference_scene = scoll[
timestamps[reference_timestamp_idx]]
else:
reference_scene = scoll[scoll.timestamps[0]]

# loop over scenes and project them onto the total bounds
for _, scene in scoll:
if scene.is_bandstack():
# get a band of the SceneCollection
reference_band = scene[scene.band_names[0]]
# loop over the bands and reproject them
# onto the target grid
for band_name, band in scene:
# get the reference band
reference_band = reference_scene[band_name]
geo_info = reference_band.geo_info
# get the transform of the destination extent
minx, maxy = total_bounds[0], total_bounds[-1]
Expand All @@ -721,28 +750,25 @@ def _load_scenes_collection(
d=0,
e=geo_info.pixres_y,
f=maxy)

# loop over the bands and reproject them
# onto the target grid
for _, band in scene:
dst_shape = (max(nrows), max(ncols))
destination = np.zeros(
dst_shape,
dtype=band.values.dtype
)
# determine nodata
if not np.isnan(band.nodata):
dst_nodata = band.nodata
else:
# if band nodata is NaN we set
# no-data to None (rasterio default)
dst_nodata = None
band.reproject(
inplace=True,
target_crs=reference_band.crs,
dst_transform=dst_transform,
destination=destination,
dst_nodata=dst_nodata)

dst_shape = (max(nrows), max(ncols))
destination = np.zeros(
dst_shape,
dtype=band.values.dtype
)
# determine nodata
if not np.isnan(band.nodata):
dst_nodata = band.nodata
else:
# if band nodata is NaN we set
# no-data to None (rasterio default)
dst_nodata = None
band.reproject(
inplace=True,
target_crs=reference_band.crs,
dst_transform=dst_transform,
destination=destination,
dst_nodata=dst_nodata)

self.data = scoll

Expand Down
15 changes: 15 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,21 @@ def _get_polygons():
return _get_polygons


@pytest.fixture()
def get_polygons_utm_zones(get_project_root_path):
"""
Returns path to an area of interest at the boundary of two UTM zones
"""
def _get_polygons():

testdata_dir = get_project_root_path.joinpath('data')
testdata_polys = testdata_dir.joinpath(
Path('sample_polygons').joinpath('utm_zones_western-ch.gpkg')
)
return testdata_polys
return _get_polygons


@pytest.fixture()
def get_scene_collection(get_bandstack):
"""fixture returing a SceneCollection with three scenes"""
Expand Down
126 changes: 126 additions & 0 deletions tests/mapper/test_grid_alignment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
'''
Ensure that all scenes in a SceneCollection have the same spatial
extent, pixel size and number of rows and columns. We test this
behavior for Sentinel-2 scenes at the boundary of two UTM zones.
'''

import geopandas as gpd
import pytest

from datetime import datetime
from eodal.config import get_settings
from eodal.core.raster import RasterCollection
from eodal.core.sensors import Sentinel2
from eodal.mapper.feature import Feature
from eodal.mapper.filter import Filter
from eodal.mapper.mapper import Mapper, MapperConfigs

settings = get_settings()


def test_grid_alignment(get_polygons_utm_zones):
"""test grid alignment for Sentinel-2"""

settings.USE_STAC = True

time_start = datetime(2018, 3, 2)
time_end = datetime(2018, 4, 6)
metadata_filters = [
Filter('cloudy_pixel_percentage', '<', 70),
Filter('processing_level', '==', 'Level-2A')
]
feature = Feature.from_geoseries(
gds=gpd.read_file(get_polygons_utm_zones()).geometry)
mapper_configs = MapperConfigs(
collection='sentinel2-msi',
time_start=time_start,
time_end=time_end,
feature=feature,
metadata_filters=metadata_filters
)

mapper = Mapper(mapper_configs)
mapper.query_scenes()

def resample(ds: RasterCollection, **kwargs):
return ds.resample(inplace=False, **kwargs)

scene_kwargs = {
'scene_constructor': Sentinel2.from_safe,
'scene_constructor_kwargs': {
'band_selection': ['red', 'red_edge_1'],
'apply_scaling': False
},
'scene_modifier': resample,
'scene_modifier_kwargs': {'target_resolution': 10}
}
mapper.load_scenes(scene_kwargs=scene_kwargs)

# all scenes should have the same extent and, since, we resampled
# all bands to a common spatial extent, the same pixel size and number
# of rows and columns
scoll = mapper.data

pixres_x, pixres_y, ulx, uly, nrows, ncols = [], [], [], [], [], []
for _, scene in scoll:
for _, band in scene:
geo_info = band.geo_info
pixres_x.append(geo_info.pixres_x)
pixres_y.append(geo_info.pixres_y)
ulx.append(geo_info.ulx)
uly.append(geo_info.uly)
nrows.append(band.nrows)
ncols.append(band.ncols)

assert len(set(pixres_x)) == 1
assert pixres_x[0] == 10
assert len(set(pixres_y)) == 1
assert pixres_y[0] == -10
assert len(set(ulx)) == 1
assert len(set(uly)) == 1
assert len(set(nrows)) == 1
assert len(set(ncols)) == 1

# this should also work when the bands are not bandstacked
# i.e., have a different pixel size
scene_kwargs = {
'scene_constructor': Sentinel2.from_safe,
'scene_constructor_kwargs': {
'band_selection': ['red', 'red_edge_1'],
'apply_scaling': False
}
}
mapper.load_scenes(scene_kwargs=scene_kwargs)

scoll = mapper.data

band_names = scoll[scoll.timestamps[0]].band_names
# the upper left corner must be always the same
ulx, uly = [], []
for band_name in band_names:
pixres_x, pixres_y, nrows, ncols = [], [], [], []
for _, scene in scoll:
band = scene[band_name]
geo_info = band.geo_info
pixres_x.append(geo_info.pixres_x)
pixres_y.append(geo_info.pixres_y)
ulx.append(geo_info.ulx)
uly.append(geo_info.uly)
nrows.append(band.nrows)
ncols.append(band.ncols)

assert len(set(pixres_x)) == 1
assert len(set(pixres_y)) == 1
assert len(set(nrows)) == 1
assert len(set(ncols)) == 1
# only the red band should have a spatial resolution of 10 m
# as we did not resample the 20 m bands
if band_name == 'B04':
assert pixres_x[0] == 10.
assert pixres_y[0] == -10.
else:
assert pixres_x[0] == 20.
assert pixres_y[0] == -20.

assert len(set(ulx)) == 1
assert len(set(uly)) == 1