get elevation from lat/long of geotiff data in gdal

Question:

I have a mosaic tif file (gdalinfo below) I made (with some additional info on the tiles here) and have looked extensively for a function that simply returns the elevation (the z value of this mosaic) for a given lat/long. The functions I’ve seen want me to input the coordinates in the coordinates of the mosaic, but I want to use lat/long, is there something about GetGeoTransform() that I’m missing to achieve this?

This example for instance here shown below:

from osgeo import gdal 
import affine
import numpy as np

def retrieve_pixel_value(geo_coord, data_source):
    """Return floating-point value that corresponds to given point."""
    x, y = geo_coord[0], geo_coord[1]
    forward_transform =  
        affine.Affine.from_gdal(*data_source.GetGeoTransform())
    reverse_transform = ~forward_transform
    px, py = reverse_transform * (x, y)
    px, py = int(px + 0.5), int(py + 0.5)
    pixel_coord = px, py

    data_array = np.array(data_source.GetRasterBand(1).ReadAsArray())
    return data_array[pixel_coord[0]][pixel_coord[1]]

This gives me an out of bounds error as it’s likely expecting x/y coordinates (e.g. retrieve_pixel_value([153.023499,-27.468968],dataset). I’ve also tried the following from here:

import rasterio
dat = rasterio.open(fname)
z = dat.read()[0]

def getval(lon, lat):
    idx = dat.index(lon, lat, precision=1E-6)    
    return dat.xy(*idx), z[idx]

Is there a simple adjustment I can make so my function can query the mosaic in lat/long coords?

Much appreciated.

    Driver: GTiff/GeoTIFF
    Files: mosaic.tif
    Size is 25000, 29460
    Coordinate System is:
    PROJCRS["GDA94 / MGA zone 56",
        BASEGEOGCRS["GDA94",
            DATUM["Geocentric Datum of Australia 1994",
                ELLIPSOID["GRS 1980",6378137,298.257222101004,
                    LENGTHUNIT["metre",1]],
                ID["EPSG",6283]],
            PRIMEM["Greenwich",0,
                ANGLEUNIT["degree",0.0174532925199433,
                    ID["EPSG",9122]]]],
        CONVERSION["UTM zone 56S",
            METHOD["Transverse Mercator",
                ID["EPSG",9807]],
            PARAMETER["Latitude of natural origin",0,
                ANGLEUNIT["degree",0.0174532925199433],
                ID["EPSG",8801]],
            PARAMETER["Longitude of natural origin",153,
                ANGLEUNIT["degree",0.0174532925199433],
                ID["EPSG",8802]],
            PARAMETER["Scale factor at natural origin",0.9996,
                SCALEUNIT["unity",1],
                ID["EPSG",8805]],
            PARAMETER["False easting",500000,
                LENGTHUNIT["metre",1],
                ID["EPSG",8806]],
            PARAMETER["False northing",10000000,
                LENGTHUNIT["metre",1],
                ID["EPSG",8807]],
            ID["EPSG",17056]],
        CS[Cartesian,2],
            AXIS["easting",east,
                ORDER[1],
                LENGTHUNIT["metre",1,
                    ID["EPSG",9001]]],
            AXIS["northing",north,
                ORDER[2],
                LENGTHUNIT["metre",1,
                    ID["EPSG",9001]]]]
    Data axis to CRS axis mapping: 1,2
    Origin = (491000.000000000000000,6977000.000000000000000)
    Pixel Size = (1.000000000000000,-1.000000000000000)
    Metadata:
      AREA_OR_POINT=Area
    Image Structure Metadata:
      INTERLEAVE=BAND
    Corner Coordinates:
    Upper Left  (  491000.000, 6977000.000) (152d54'32.48"E, 27d19'48.33"S)
    Lower Left  (  491000.000, 6947540.000) (152d54'31.69"E, 27d35'45.80"S)
    Upper Right (  516000.000, 6977000.000) (153d 9'42.27"E, 27d19'48.10"S)
    Lower Right (  516000.000, 6947540.000) (153d 9'43.66"E, 27d35'45.57"S)
    Center      (  503500.000, 6962270.000) (153d 2' 7.52"E, 27d27'47.16"S)
    Band 1 Block=25000x1 Type=Float32, ColorInterp=Gray
      NoData Value=-999

Update 1 – I tried the following:

tif = r"mosaic.tif"
dataset = rio.open(tif)
d = dataset.read()[0]

def get_xy_coords(latlng):
    transformer = Transformer.from_crs("epsg:4326", dataset.crs)
    coords = [transformer.transform(x, y) for x,y in latlng][0]
    #idx = dataset.index(coords[1], coords[0])    
    return coords #.xy(*idx), z[idx]

longx,laty = 153.023499,-27.468968
coords = get_elevation([(laty,longx)])
print(coords[0],coords[1])
print(dataset.width,dataset.height)
(502321.11181384244, 6961618.891167777)
25000 29460

So something is still not right. Maybe I need to subtract the coordinates from the bottom left/right of image e.g.

coords[0]-dataset.bounds.left,coords[1]-dataset.bounds.bottom

where

In [78]: dataset.bounds
Out[78]: BoundingBox(left=491000.0, bottom=6947540.0, right=516000.0, top=6977000.0)

Update 2 – Indeed, subtracting the corners of my box seems to get closer.. though I’m sure there is a much nice way just using the tif metadata to get what I want.

longx,laty = 152.94646, -27.463175
coords = get_xy_coords([(laty,longx)])
elevation = d[int(coords[1]-dataset.bounds.bottom),int(coords[0]-dataset.bounds.left)]
fig,ax = plt.subplots(figsize=(12,12))
ax.imshow(d,vmin=0,vmax=400,cmap='terrain',extent=[dataset.bounds.left,dataset.bounds.right,dataset.bounds.bottom,dataset.bounds.top])
ax.plot(coords[0],coords[1],'ko')
plt.show()
Asked By: Griff

||

Answers:

You basically have two distinct steps:

  1. Convert lon/lat coordinates to map coordinates, this is only necessary if your input raster is not already in lon/lat. Map coordinates are the coordinates in the projection that the raster itself uses
  2. Convert the map coordinates to pixel coordinates.

There are all kinds of tool you might use, perhaps to make things simpler (like pyproj, rasterio etc). But for such a simple case it’s probably nice to start with doing it all in GDAL, that probably also enhances your understanding of what steps are needed.

Inputs

from osgeo import gdal, osr

raster_file = r'D:somefile.tif'
lon = 153.023499
lat = -27.468968

lon/lat to map coordinates

# fetch metadata required for transformation
ds = gdal.OpenEx(raster_file)
raster_proj = ds.GetProjection()
gt = ds.GetGeoTransform()
ds = None # close file, could also keep it open till after reading

# coordinate transformation (lon/lat to map)
# define source projection
# this definition ensures the order is always lon/lat compared
# to EPSG:4326 for which it depends on the GDAL version (2 vs 3)
source_srs = osr.SpatialReference()
source_srs.ImportFromWkt(osr.GetUserInputAsWKT("urn:ogc:def:crs:OGC:1.3:CRS84"))

# define target projection based on the file
target_srs = osr.SpatialReference()
target_srs.ImportFromWkt(raster_proj)

# convert 
ct = osr.CoordinateTransformation(source_srs, target_srs)
mapx, mapy, *_ = ct.TransformPoint(lon, lat)

You could verify this intermediate result by for example adding it as Point WKT in something like QGIS (using the QuickWKT plugin, making sure the viewer has the same projection as the raster).

map coordinates to pixel

# apply affine transformation to get pixel coordinates
gt_inv = gdal.InvGeoTransform(gt) # invert for map -> pixel
px, py = gdal.ApplyGeoTransform(gt_inv, mapx, mapy)

# it wil return fractional pixel coordinates, so convert to int
# before using them to read. Round to nearest with +0.5
py = int(py + 0.5)
px = int(px + 0.5)

# read pixel data
ds = gdal.OpenEx(raster_file) # open file again
elevation_value = ds.ReadAsArray(px, py, 1, 1)
ds = None

The elevation_value variable should be the value you’re after. I would definitelly verify the result independently, try a few points in QGIS or the gdallocationinfo utility:

gdallocationinfo -l_srs "urn:ogc:def:crs:OGC:1.3:CRS84" filename.tif 153.023499 -27.468968

# Report:
#   Location: (4228P,4840L)
#   Band 1:
#    Value: 1804.51879882812

If you’re reading a lot of points, there will be some threshold at which it would be faster to read a large chunk and extract the values from that array, compared to reading every point individually.

edit:

For applying the same workflow on multiple points at once a few things change.

So for example having the inputs:

lats = np.array([-27.468968, -27.468968, -27.468968])
lons = np.array([153.023499, 153.023499, 153.023499])

The coordinate transformation needs to use ct.TransformPoints instead of ct.TransformPoint which also requires the coordinates to be stacked in a single array of shape [n_points, 2]:

coords = np.stack([lons.ravel(), lats.ravel()], axis=1)
mapx, mapy, *_ = np.asarray(ct.TransformPoints(coords)).T

# reshape in case of non-1D inputs
mapx = mapx.reshape(lons.shape)
mapy = mapy.reshape(lons.shape)

Converting from map to pixel coordinates changes because the GDAL method for this only takes single point. But manually doing this on the arrays would be:

px = gt_inv[0] + mapx * gt_inv[1] + mapy * gt_inv[2]
py = gt_inv[3] + mapx * gt_inv[4] + mapy * gt_inv[5]

And rounding the arrays to integer changes to:

px = (px + 0.5).astype(np.int32)
py = (py + 0.5).astype(np.int32)

If the raster (easily) fits in memory, reading all points would become:

ds = gdal.OpenEx(raster_file)
all_elevation_data = ds.ReadAsArray()
ds = None

elevation_values = all_elevation_data[py, px]

That last step could be optimized by checking highest/lowest pixel coordinates in both dimensions and only read that subset for example, but it would require normalizing the coordinates again to be valid for that subset.

The py and px arrays might also need to be clipped (eg np.clip) if the input coordinates fall outside the raster. In that case the pixel coordinates will be < 0 or >= xsize/ysize.

Answered By: Rutger Kassies
Categories: questions Tags: , , ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.