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

incorrect xarray size when downsampling? #94

Closed
johntruckenbrodt opened this issue Nov 22, 2022 · 11 comments
Closed

incorrect xarray size when downsampling? #94

johntruckenbrodt opened this issue Nov 22, 2022 · 11 comments

Comments

@johntruckenbrodt
Copy link

johntruckenbrodt commented Nov 22, 2022

Hi there. Thanks a lot for this awesome tool!

I am working with some Sentinel-2 data which I am downsampling for testing purposes.

I wonder whether the following behaviour is expected...

Let's get some STAC items (with a reproducible example from here):

import pystac_client
import planetary_computer

catalog = pystac_client.Client.open(
    "https://planetarycomputer.microsoft.com/api/stac/v1",
    modifier=planetary_computer.sign_inplace,
)

time_range = "2020-12-01/2020-12-31"
bbox = [-122.2751, 47.5469, -121.9613, 47.7458]

search = catalog.search(collections=["landsat-c2-l2"], bbox=bbox, datetime=time_range)
items = search.get_all_items()

...get the size of one band at full resolution:

out = odc_stac.load(
    items,
    bands=['blue'],
    chunks={'x': 256, 'y': 256}
)
x = out.sizes['x']
y = out.sizes['y']
res = items[0].to_dict()['assets']['blue']['raster:bands'][0]['spatial_resolution']

..and then load the same band with different resolutions:

for factor in [2, 3, 4]:
    print('factor:', factor)
    out = odc_stac.load(
        items,
        bands=['blue'],
        resolution=res * factor,
        chunks={'x': 256, 'y': 256}
    )
    print(f'expected size: {y // factor} x {x // 2}')
    print(f'actual size:   {out.sizes["y"]} x {out.sizes["x"]}')
    print('-' * 30)

This gives me:

factor: 2
expected size: 3986 x 6186
actual size:   3987 x 6187
------------------------------
factor: 3
expected size: 2657 x 6186
actual size:   2658 x 4125
------------------------------
factor: 4
expected size: 1993 x 6186
actual size:   1994 x 3094
------------------------------
@Kirill888
Copy link
Member

Kirill888 commented Nov 22, 2022

    print(f'expected size: {y // factor} x {x // 2}')

That // 2, should be // factor, but I assume you mean an extra 1 pixel of padding 2658 instead of 2657?

Yes extra padding like that can happen, if you need exact pixel grid you can specify that with GeoBox class. You say you are working with Sentinel 2, but your example is using Landsat. Landsat is a bit special as it anchors its' pixel grid to pixel centers unlike most other that anchor grid to pixel edges. odc-stac defaults to creating pixel grids that snap pixel edges to X=0,Y=0, hence the +1 on the size. I suggest you play around with anchor= parameter and see what impacts that have on output shapes.

On an unrelated note: chunk size of 256 pixels is way too small, I'd suggest you start with 2048, especially when working with high res data, and only go down from that in situations when you experience issues with RAM or not enough concurrency.

@Kirill888
Copy link
Member

Writing up pixel grid construction rules is a long-standing documentation task that doesn't get done...

Best I have is this here:

https://odc-geo.readthedocs.io/en/latest/intro-geobox.html

Usually the translation part of linear mapping is rounded to pixel size in projection units, most of the time such that
tx%resolution == 0, but when anchoring to pixel centers it's chosen such that (tx + resolution/2)%resolution == 0

@johntruckenbrodt
Copy link
Author

Sorry @Kirill888 for the faulty example and thanks for still taking the time to answer. Here's a correct one:

import pystac_client
import planetary_computer

catalog = pystac_client.Client.open(
    "https://planetarycomputer.microsoft.com/api/stac/v1",
    modifier=planetary_computer.sign_inplace,
)

time_range = "2020-12-01/2020-12-31"
bbox = [-122.2751, 47.5469, -121.9613, 47.7458]

search = catalog.search(collections=["sentinel-2-l2a"], bbox=bbox, datetime=time_range)
items = search.get_all_items()

out = odc_stac.load(
    items,
    bands=['B02'],
    chunks={'x': 256, 'y': 256}
)
x = out.sizes['x']
y = out.sizes['y']
res = items[0].to_dict()['assets']['B02']['gsd']

for factor in [4, 5]:
    print('factor:', factor)
    out = odc_stac.load(
        items,
        bands=['B02'],
        resolution=res * factor,
        chunks={'x': 256, 'y': 256}
    )
    print(f'expected size: {y // factor} x {x // factor}')
    print(f'actual size:   {out.sizes["y"]} x {out.sizes["x"]}')
    print('-' * 30)
factor: 4
expected size: 2745 x 2745
actual size:   2745 x 2746
------------------------------
factor: 5
expected size: 2196 x 2196
actual size:   2197 x 2197
------------------------------

I am not using the Planetary Computer in my own work but am observing the same thing. Sometimes both dimensions get a padding of an extra pixel, sometimes only one. Interestingly, I first stumbled across this when downsampling to 60 m (which fits into the MGRS tile so the bounding box does not change) where the y-dimension got a padding. I cannot observe this here though.
Overall, itt seems like this padding is expected. The behavior just feels a bit counter-intuitive to me. I'll have a look at GeoBox and anchor options.

Thanks for the hint with the chunk size. However, in the work that I am currently doing the chunk extends to about 40 time steps and with 256x256 I have observed the best performance w.r.t. compute time and memory consumption on a SLURM cluster.

@Kirill888
Copy link
Member

@johntruckenbrodt ok, similar story here. S2 has 10m pixel aligned such that x%10 == 0, just because x%10 == 0 does not mean that x%60 == 0. Feature request linked below describes the issue well, we really need to move that into docs someplace:

opendatacube/odc-geo#59

One have to remember that we are not loading one single image, but a bunch, there is no guarantee that all source pixel grids across different images perfectly overlap, so there is no "true native grid" in a general case. So we pick a grid that snaps pixels such that X%RES == 0 (configurable), that way at least after load we get consistent grids for same resolution data. Just because two images share same projection and resolution does not mean their pixels are part of the same grid, it's not the case for reduced resolution slices in particular.

@johntruckenbrodt
Copy link
Author

The issue with alignment of different pixel grids sure needs to be considered. However, in this case I think it does not apply. Yes, in my example multiple images are considered, but one can reduce the example to a single item and still get the same result.

items = search.get_all_items()[:1]

The image in question has a resolution of 10 m and is exactly 10980 x 10980 pixels large to fit into the MGRS tile. I would expect that this image can be downsampled to 20, 30 and 60 m and still fit into the tile (with 109800 m corner length) and the overlap of two tiles in the same projection (9780 m). This will produce square tiles with 5490, 3660 or 1830 pixels in both dimensions.
Of course, if we start to mosaic multiple tiles in different projections or use a resolution other than the ones above we are running into pixel alignment issues when mosaicing neighbouring tiles. For example, 40 m would align with the tile itself but not with the overlap with a neighbouring tile.
So it is more like this situation, where both images can be downsampled by a factor of 2 and 3 but only factor 2 would preserve overlap with neighbours:
image

@Kirill888
Copy link
Member

Kirill888 commented Nov 23, 2022

But that's the thing, we are NOT choosing extent based on min/max of pixel coordinates, we ALSO perform pixel snapping so that all outputs for the same resolution have compatible grids regardless where one starts at.

Choosing load area is like this:

  1. Find min/max extents
  2. Snap pixel edges such that X%RES == 0
  3. Use ALL the pixels that overlap with initial extent

Example of 2 60m pixels becoming 3:

  10     70     130
 +------+------+            << Image data
+------+------+------+      << What get's loaded
0      60      120    180

@Kirill888
Copy link
Member

This is just a reasonable default that works well when combining different data sources, if you want to precisely control output grid you have an option of specifying exact pixel grid and image size using GeoBox class.

@johntruckenbrodt
Copy link
Author

johntruckenbrodt commented Nov 23, 2022

Just for completeness, here are some actual UTM coordinates...

upper left convention
xmin, xmax, diff:  199980.0  309780.0 109800.0 (10980 px)
ymin, ymax, diff: 9590200.0 9700000.0 109800.0 (10980 px)
------------------------------------------------
xarray 10 m
xmin, xmax, diff:  199985.0  309775.0 109790.0 (10980 px)
ymin, ymax, diff: 9590205.0 9699995.0 109790.0 (10980 px)
------------------------------------------------
xarray 60 m
xmin, xmax, diff:  200010.0  309750.0 109740.0 (1830 px)
ymin, ymax, diff: 9590190.0 9699990.0 109800.0 (1831 px)
------------------------------------------------

Everything makes sense to me except the y dimensions for 60 m. I would've expected

ymin, ymax, diff: 9590230.0 9699970.0 109740.0 (1830 px)

If I write this xarray to a GeoTIFF I get the following:

upper left convention
xmin, xmax, diff:  199980.0  309780.0 109800.0 (1830 px)
ymin, ymax, diff: 9590160.0 9700020.0 109860.0 (1831 px)

This makes it even more strange. x is fine but y is shifted north by 20 m and one pixel row is added to the south.
image

I am sure there are good reasons for it but in this particular case it just feels odd. I'll make sure to use the GeoBox class.
Thanks a lot for your time.

@Kirill888
Copy link
Member

Kirill888 commented Nov 23, 2022

xarray coordinates are for the center of the pixel, that's what all of xarray ecosystem assumes, so you need to subtract half a pixel width from min to get left edge and add half a pixel to the max to get right edge. With that in mind 10m is exactly as original, for 60m case we get

res = 60
y1, y2 = 9590190.0, 9699990.0
y1-res/2, y2 + res/2
(9590160, 9700020)

so 9590160 instead of original 9590200, we extend top edge (inverted axis) by 40m (2/3 of pixel) because 9590200 is not an integer multiple of pixel size and we want it to be, to make sure that all 60m images we load result in perfectly overlapping grids.

9590160%60 ==> 0, 9590200%60 ==> 40 and this is where extra pixel comes from, 2/3 of that extra pixel are at the top and 1/3 at the bottom. If you use nearest neighbour resampling you might think that odc-stac added offset to your data cause you get all the same pixels but wrong coordinates. But that's just an artifact of nearest neighbor resampling, if you use other resampling method that bias will be gone but you will be getting sub-pixel shifted image with different pixels than the source.

If you need to faithfully load existing raster without harmonizing across possibly many different images you can use rioxarray or rasterio, or just specify exact pixel grid to odc-stac via GeoBox, defaults in odc-stac are optimized for multi-image case. A neighbouring S2 tile is likely to have 60m pixels starting at other fractions of the 60m pixel, because only 10m pixels are made to align exactly between tiles by construction when ESA is processing the original data.

Example of two images that have aligned 10m pixels (+) but misaligned 60m pixels (|):

    Im1:          |-0-+---+---+---+---+---|---+---+---+---+---+---|
    Im2:  |---+---+-2-+---+---+---|---+---+---+---+---+---|

Im1@10m[0] covers same area as Im2@10m[2], but at 60m no pixel in image 1 overlaps exactly with image 2. And I know you don't have the second image, but this is the motivation for picking output grid the way it is done by aligning pixel edges to multiples of pixel sizes.

@Kirill888
Copy link
Member

@johntruckenbrodt closing this as this is expected behavior, but see #95, in particular try anchor=odc.geo.geobox.AnchorEnum.FLOATING and see if that produces expected results for you.

@johntruckenbrodt
Copy link
Author

johntruckenbrodt commented Nov 24, 2022

Thanks a billion @Kirill888. I have finally understood the point and setting anchor as suggested does the trick.

I was wrong in my conception of how the Sentinel-2 MGRS grid is structured. I thought the overlap between tiles is always 9780 m. If that would be the case, then we wouldn't have this conversation. Every coordinate would be a multiple of 10, 20 and 60. However, the Sentinel-2 grid needs to make a compromise between the actual MGRS grid and the pixel sizes of the individual bands (which are 10, 20 and 60 m).
If the overlap would always strictly be 9780 m, the Sentinel-2 grid would not stay aligned with the actual MGRS grid. Therefore, the overlap alternates between 9780, 9760 and 9800 m.

mgrs ymin_mgrs ymin_tile ymin_expt diff_mgrs_tile diff_mgrs_expt
33NVA 0.0 -9780.0 -9780.0 9780.0 9780.0
33NVB 100000.0 90240.0 90240.0 9760.0 9760.0
33NVC 200000.0 190200.0 190260.0 9800.0 9740.0
33NVD 300000.0 290220.0 290280.0 9780.0 9720.0
33NVE 400000.0 390240.0 390300.0 9760.0 9700.0
33NVF 500000.0 490200.0 490320.0 9800.0 9680.0

mgrs: the tile ID
ymin_mgrs: the minimum y coordinate of the MGRS tile
ymin_tile: the minimum y coordinate of the Sentinel-2 tile
ymin_expt: the minimum y coordinate when strictly considering 9780 m overlap (previously expected)
diff_mgrs_tile: ymin_mgrs - ymin_tile
diff_mgrs_expt: ymin_mgrs - ymin_expt

diff_mgrs_expt decreases by 20 m per tile while diff_mgrs_tile is kept fairly constant.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants