Skip to content

open_geotiff overview_level coord offset wrong for PixelIsPoint COGs #1642

@brendancol

Description

@brendancol

Summary

open_geotiff(path, overview_level=N) returns coords that are silently
off by half a level-0 pixel (multiplied by the overview reduction
factor) when the source COG declares raster_type = PixelIsPoint
(GeoKey 1025 = 2).

PR #1641 inherits the level-0 georef on overview reads, scaling the
pixel size by width_full / width_overview. It keeps the level-0
origin_x / origin_y unchanged. That is correct for the default
PixelIsArea convention, where the origin is the upper-left corner of
pixel (0, 0) and the overview's upper-left corner sits in the same
place as level 0's. It is wrong for PixelIsPoint, where the origin
is the center of pixel (0, 0): an overview pixel that spans the
first scale_x columns of level 0 has its center at
origin_x + (scale_x - 1) * 0.5 * pixel_width_lvl0, not at
origin_x.

Reproduction

import numpy as np
import xarray as xr
from xrspatial.geotiff import to_geotiff, open_geotiff

H = W = 1024
data = np.arange(H * W, dtype=np.float32).reshape(H, W)
# PixelIsPoint: coords are pixel CENTERS. Pixel (0, 0) center is at (0, 0).
x = np.arange(W) * 10.0
y = -(np.arange(H) * 10.0)
da = xr.DataArray(data, coords={'y': y, 'x': x}, dims=('y', 'x'))
da.attrs['raster_type'] = 'point'
da.attrs['crs'] = 'EPSG:32610'

to_geotiff(da, 'pp.tif', cog=True, overview_levels=[1, 2])

da0 = open_geotiff('pp.tif', overview_level=0)
da1 = open_geotiff('pp.tif', overview_level=1)
print('level0 x[:3]:', da0.x.values[:3])  # [ 0. 10. 20.]
print('level1 x[:3]:', da1.x.values[:3])  # [ 0. 20. 40.]  WRONG
# Level-1 pixel 0 covers original pixels 0 and 1 (centers 0 and 10),
# so its center sits at x = 5, not x = 0.

Expected level1 x[:3] for PixelIsPoint: [5. 25. 45.].

Impact

Cat 5 (backend / convention inconsistency) plus Cat 3 (off-by-one /
boundary handling).

  • da.sel(x=v, method='nearest') on the overview snaps to the wrong
    pixel for any v near a boundary because the coord vector is
    shifted by half an overview pixel.
  • da.interp(...) produces silently-wrong interpolated values.
  • Reproject / hillshade / slope-and-aspect chains that consume an
    overview-read DataArray pick up the wrong pixel positions and
    produce silently-wrong results downstream.

The bug only affects raster_type = PixelIsPoint COGs. That convention
is mainstream for DEMs (USGS, OpenTopography, Copernicus DEM all emit
it). PixelIsArea is unaffected.

Proposed fix

In extract_geo_info_with_overview_inheritance (xrspatial/geotiff/_geotags.py),
after computing scale_x and scale_y, branch on the inherited
raster_type:

  • PixelIsArea: keep origin_x / origin_y (existing behaviour).
  • PixelIsPoint: shift origin by (scale - 1) * 0.5 * pixel_size_lvl0
    along each axis so the overview pixel-0 center matches the centroid
    of the level-0 pixels it covers.

Add regression coverage in
xrspatial/geotiff/tests/test_overview_geo_inheritance_1640.py
(or a follow-on file) for raster_type='point' across all four
backends and at least two reduction levels.

Categories: 3, 5 (accuracy sweep).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions