Source code for fmask.landsatangles

#!/usr/bin/env python
# This file is part of 'python-fmask' - a cloud masking module
# Copyright (C) 2015  Neil Flood
#
# 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 the Free Software Foundation; either version 3
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
"""
Functions relating to estimating the per-pixel sun and satellite angles for 
a given Landsat image. These are rough estimates, using the generic
characteristics of the Landsat 5 platform, and are not particularly accurate,
but good enough for the current purposes. 

Historically, the USGS have not supplied satellite zenith/azimuth angles, and have only 
supplied scene-centre values for sun zenith/azimuth angles. Since the satellite
view geometry is important in correctly tracking a shadow when matching shadows
to their respective clouds, the Fmask algorithm requires good estimates of all these
angles. The routines contained here are used to derive per-pixel estimates of 
these angles. 

As of mid-2016, the USGS are planning to supply sufficient information to calculate
these angles directly from orbit ephemeris data. When that comes about, it seems likely
that the need for the routines here will diminish, but any data downloaded from USGS
prior to then will still require this approach, as the associated angle metadata will 
not be present. 

The core Fmask code in this package is adaptable enough to be configured for either 
approach. 

The general approach for satellite angles is to estimate the nadir line by running it
down the middle of the image data area. The satellite azimuth is assumed to be
at right angles to this nadir line, which is only roughly correct. For the whisk-broom 
sensors on Landsat-5 and Landsat-7, this angle is not 90 degrees, but is affected by 
earth rotation and is latitude dependent. For Landsat-8, the scan line is at 
right angles, due to the compensation for earth rotation, but the push-broom is 
made up of sub-modules which point in slightly different directions, giving 
slightly different satellite azimuths along the scan line. None of these effects
are included in the current estimates. The satellite zenith is estimated based on the
nadir point, the scan-line, and the assumed satellite altitude, and includes the
appropriate allowance for earth curvature. 

Because this works by searching the imagery for the non-null area, and assumes that 
this represents a full-swath image, it would not work for a subset of a full image. 

The sun angles are approximated using the algorithm found in the Fortran code with
6S (Second Simulation of the Satellite Signal in the Solar Spectrum). The subroutine
in question is the POSSOL() routine. I translated the Fortran code into Python for
inclusion here. 

"""
from __future__ import print_function, division

import datetime

import numpy
from osgeo import osr

from rios import applier
from rios import fileinfo

[docs]def findImgCorners(img, imgInfo): """ Find the corners of the data within the given template image Return a numpy array of (x, y) coordinates. The array has 2 columns, for X and Y. Each row is a corner, in the order: top-left, top-right, bottom-left, bottom-right. Uses RIOS to pass through the image searching for non-null data, and find the extremes. Assumes we are working with a full-swathe Landsat image. Each list element is a numpy array of (x, y) """ infiles = applier.FilenameAssociations() outfiles = applier.FilenameAssociations() otherargs = applier.OtherInputs() infiles.img = img otherargs.tl = None otherargs.tr = None otherargs.bl = None otherargs.br = None otherargs.nullVal = imgInfo.nodataval[0] if otherargs.nullVal is None: otherargs.nullVal = 0 applier.apply(findCorners, infiles, outfiles, otherargs) corners = numpy.array([ otherargs.tl, otherargs.tr, otherargs.bl, otherargs.br, ]) return corners
[docs]def findCorners(info, inputs, outputs, otherargs): """ Called from RIOS Checks non-null area of image block. Finds extremes, records coords of extremes against those already in otherargs. Note that the logic is very specific to the orientation of the usual Landsat descending pass imagery. The same logic should not be applied to swathes oriented in other, e.g. for other satellites. """ (xblock, yblock) = info.getBlockCoordArrays() nonnull = (inputs.img != otherargs.nullVal).all(axis=0) xNonnull = xblock[nonnull] yNonnull = yblock[nonnull] if len(xNonnull) > 0: topNdx = numpy.argmax(yNonnull) topXY = (xNonnull[topNdx], yNonnull[topNdx]) leftNdx = numpy.argmin(xNonnull) leftXY = (xNonnull[leftNdx], yNonnull[leftNdx]) bottomNdx = numpy.argmin(yNonnull) bottomXY = (xNonnull[bottomNdx], yNonnull[bottomNdx]) rightNdx = numpy.argmax(xNonnull) rightXY = (xNonnull[rightNdx], yNonnull[rightNdx]) # If these are more extreme than those already in otherargs, replace them if otherargs.tl is None or topXY[1] > otherargs.tl[1]: otherargs.tl = topXY if otherargs.tr is None or rightXY[0] > otherargs.tr[0]: otherargs.tr = rightXY if otherargs.bl is None or leftXY[0] < otherargs.bl[0]: otherargs.bl = leftXY if otherargs.br is None or bottomXY[1] < otherargs.br[1]: otherargs.br = bottomXY
[docs]def findNadirLine(corners): """ Return the equation of the nadir line, from the given corners of the swathe. Returns a numpy array of [b, m], for the equation y = mx + b giving the y coordinate of the nadir as a function of the x coordinate. """ # Find the top and bottom mid-points. topMid = (corners[0] + corners[1]) / 2.0 bottomMid = (corners[2] + corners[3]) / 2.0 slope = (topMid[1] - bottomMid[1]) / (topMid[0] - bottomMid[0]) intercept = topMid[1] - slope * topMid[0] coeffs = numpy.array([intercept, slope]) return coeffs
[docs]def satAzLeftRight(nadirLine): """ Calculate the satellite azimuth for the left and right sides of the nadir line. Assume that the satellite azimuth vector is at right angles to the nadir line (which is not really true, but reasonably close), and that there are only two possibilities, as a pixel is either to the left or to the right of the nadir line. Return a numpy array of [satAzLeft, satAzRight], with angles in radians, in the range [-pi, pi] """ slope = nadirLine[1] # Slope of a line perpendicular to the nadir slopePerp = -1 / slope # Azimuth for pixels to the left of the line azimuthLeft = numpy.pi / 2.0 - numpy.arctan(slopePerp) # Azimuth for pixels to the right is directly opposite azimuthRight = azimuthLeft - numpy.pi return numpy.array([azimuthLeft, azimuthRight])
[docs]def localRadius(latitude): """ Calculate a local radius of curvature, for the given geodetic latitude. This approximates the earth curvature at this latitude. The given latitude is in degrees. This is probably overkill, given some of the other approximations I am making.... """ latRadians = numpy.radians(latitude) # Earth axis lengths a = osr.SRS_WGS84_SEMIMAJOR invFlat = osr.SRS_WGS84_INVFLATTENING f = 1 / invFlat eSqd = 2*f - f**2 # Radius of curvature R = a / numpy.sqrt(1 - eSqd * numpy.sin(latRadians)**2) return R
[docs]def sunAnglesForExtent(imgInfo, mtlInfo): """ Return array of sun azimuth and zenith for each of the corners of the image extent. Note that this is the raster extent, not the corners of the swathe. The algorithm used here has been copied from the 6S possol() subroutine. The Fortran code I copied it from was .... up to the usual standard in 6S. So, the notation is not always clear. """ cornerLatLong = imgInfo.getCorners(outEPSG=4326) (ul_long, ul_lat, ur_long, ur_lat, lr_long, lr_lat, ll_long, ll_lat) = cornerLatLong pts = numpy.array([ [ul_long, ul_lat], [ur_long, ur_lat], [ll_long, ll_lat], [lr_long, lr_lat] ]) longDeg = pts[:, 0] latDeg = pts[:, 1] # Date/time in UTC dateStr = mtlInfo['DATE_ACQUIRED'] timeStr = mtlInfo['SCENE_CENTER_TIME'].replace('Z', '') ymd = [int(i) for i in dateStr.split('-')] dateObj = datetime.date(ymd[0], ymd[1], ymd[2]) julianDay = (dateObj - datetime.date(ymd[0], 1, 1)).days + 1 juldayYearEnd = (datetime.date(ymd[0], 12, 31) - datetime.date(ymd[0], 1, 1)).days + 1 # Julian day as a proportion of the year jdp = julianDay / juldayYearEnd # Hour in UTC hms = [float(x) for x in timeStr.split(':')] hourGMT = hms[0] + hms[1] / 60.0 + hms[2] / 3600.0 (sunAz, sunZen) = sunAnglesForPoints(latDeg, longDeg, hourGMT, jdp) sunAngles = numpy.vstack((sunAz, sunZen)).T return sunAngles
[docs]def sunAnglesForPoints(latDeg, longDeg, hourGMT, jdp): """ Calculate sun azimuth and zenith for the given location(s), for the given time of year. jdp is the julian day as a proportion, ranging from 0 to 1, where Jan 1 is 1.0/365 and Dec 31 is 1.0. Location is given in latitude/longitude, in degrees, and can be arrays to calculate for multiple locations. hourGMT is a decimal hour number giving the time of day in GMT (i.e. UTC). Return a tuple of (sunAz, sunZen). If latDeg and longDeg are arrays, then returned values will be arrays of the same shape. """ latRad = numpy.radians(latDeg) # Express jdp in radians jdpr = jdp * 2 * numpy.pi # Now work out the solar position. This is copied from the 6S code, but # is also documented in the 6S manual. The notation a = numpy.array([0.000075, 0.001868, 0.032077, 0.014615, 0.040849]) meanSolarTime = hourGMT + longDeg / 15.0 localSolarDiff = (a[0] + a[1] * numpy.cos(jdpr) - a[2] * numpy.sin(jdpr) - a[3] * numpy.cos(2*jdpr) - a[4] * numpy.sin(2*jdpr)) * 12 * 60 / numpy.pi trueSolarTime = meanSolarTime + localSolarDiff / 60 - 12.0 # Hour as an angle ah = trueSolarTime * numpy.radians(15) b = numpy.array([0.006918, 0.399912, 0.070257, 0.006758, 0.000907, 0.002697, 0.001480]) delta = (b[0] - b[1]*numpy.cos(jdpr) + b[2]*numpy.sin(jdpr) - b[3]*numpy.cos(2.*jdpr) + b[4]*numpy.sin(2.*jdpr) - b[5]*numpy.cos(3.*jdpr) + b[6]*numpy.sin(3.*jdpr)) cosSunZen = (numpy.sin(latRad) * numpy.sin(delta) + numpy.cos(latRad) * numpy.cos(delta) * numpy.cos(ah)) sunZen = numpy.arccos(cosSunZen) # sun azimuth from south, turning west (yeah, I know, weird, isn't it....) sinSunAzSW = numpy.cos(delta) * numpy.sin(ah) / numpy.sin(sunZen) sinSunAzSW = sinSunAzSW.clip(-1.0, 1.0) # This next bit seems to be to get the azimuth in the correct quadrant. I do # not really understand it. cosSunAzSW = (-numpy.cos(latRad) * numpy.sin(delta) + numpy.sin(latRad) * numpy.cos(delta) * numpy.cos(ah)) / numpy.sin(sunZen) sunAzSW = numpy.arcsin(sinSunAzSW) sunAzSW = numpy.where(cosSunAzSW <= 0, numpy.pi - sunAzSW, sunAzSW) sunAzSW = numpy.where((cosSunAzSW > 0) & (sinSunAzSW <= 0), 2 * numpy.pi + sunAzSW, sunAzSW) # Now convert to azimuth from north, turning east, as is usual convention sunAz = sunAzSW + numpy.pi # Keep within [0, 2pi] range sunAz = numpy.where(sunAz > 2 * numpy.pi, sunAz - 2 * numpy.pi, sunAz) return (sunAz, sunZen)
[docs]def makeAnglesImage(templateimg, outfile, nadirLine, extentSunAngles, satAzimuth, imgInfo): """ Make a single output image file of the sun and satellite angles for every pixel in the template image. """ imgInfo = fileinfo.ImageInfo(templateimg) infiles = applier.FilenameAssociations() outfiles = applier.FilenameAssociations() otherargs = applier.OtherInputs() controls = applier.ApplierControls() infiles.img = templateimg outfiles.angles = outfile (ctrLat, ctrLong) = getCtrLatLong(imgInfo) otherargs.R = localRadius(ctrLat) otherargs.nadirLine = nadirLine otherargs.xMin = imgInfo.xMin otherargs.xMax = imgInfo.xMax otherargs.yMin = imgInfo.yMin otherargs.yMax = imgInfo.yMax otherargs.extentSunAngles = extentSunAngles otherargs.satAltitude = 705000 # Landsat nominal altitude in metres otherargs.satAzimuth = satAzimuth otherargs.radianScale = 100 # Store pixel values as (radians * radianScale) controls.setStatsIgnore(500) applier.apply(makeAngles, infiles, outfiles, otherargs, controls=controls)
[docs]def makeAngles(info, inputs, outputs, otherargs): """ Called from RIOS Make 4-layer sun and satellite angles for the image block """ (xblock, yblock) = info.getBlockCoordArrays() # Nadir line coefficients of y=mx+b (b, m) = otherargs.nadirLine # Distance of each pixel from the nadir line dist = numpy.absolute((m * xblock - yblock + b) / numpy.sqrt(m**2 + 1)) # Zenith angle assuming a flat earth satZenith = numpy.arctan(dist / otherargs.satAltitude) # Adjust satZenith for earth curvature. This is a very simple approximation, but # the adjustment is less than one degree anyway, so this is accurate enough. curveAngle = numpy.arctan(dist / otherargs.R) satZenith += curveAngle # Work out whether we are left or right of the nadir line isLeft = (yblock - (m * xblock + b)) > 0 (satAzimuthLeft, satAzimuthRight) = otherargs.satAzimuth satAzimuth = numpy.where(isLeft, satAzimuthLeft, satAzimuthRight) # Interpolate the sun angles from those calculated at the corners of the whole raster extent (xMin, xMax, yMin, yMax) = (otherargs.xMin, otherargs.xMax, otherargs.yMin, otherargs.yMax) sunAzimuth = bilinearInterp(xMin, xMax, yMin, yMax, otherargs.extentSunAngles[:, 0], xblock, yblock) sunZenith = bilinearInterp(xMin, xMax, yMin, yMax, otherargs.extentSunAngles[:, 1], xblock, yblock) angleStack = numpy.array([satAzimuth, satZenith, sunAzimuth, sunZenith]) angleStackDN = angleStack * otherargs.radianScale outputs.angles = numpy.round(angleStackDN).astype(numpy.int16)
[docs]def bilinearInterp(xMin, xMax, yMin, yMax, cornerVals, x, y): """ Evaluate the given value on a grid of (x, y) points. The exact value is given on a set of corner points (top-left, top-right, bottom-left, bottom-right). The corner locations are implied from xMin, xMax, yMin, yMax. """ p = (y - yMin) / (yMax - yMin) q = (x - xMin) / (xMax - xMin) # Give the known corner values some simple names (tl, tr, bl, br) = cornerVals # Calculate the interpolated values vals = tr * p * q + tl * p * (1-q) + br * (1-p) * q + bl * (1-p) * (1-q) return vals
[docs]def getCtrLatLong(imgInfo): """ Return the lat/long of the centre of the image as (ctrLat, ctrLong) """ cornerLatLong = imgInfo.getCorners(outEPSG=4326) (ul_long, ul_lat, ur_long, ur_lat, lr_long, lr_lat, ll_long, ll_lat) = cornerLatLong ctrLat = numpy.array([ul_lat, ur_lat, lr_lat, ll_lat]).mean() ctrLong = numpy.array([ul_long, ur_long, lr_long, ll_long]).mean() return (ctrLat, ctrLong)