Skip to content

Commit

Permalink
Oblique projections, distortion (#1)
Browse files Browse the repository at this point in the history
* Oblique projection transform

* More usage on readme

* A couple more azimuthal projections, distortion calculations, big README update
  • Loading branch information
robertkleffner committed Jan 11, 2024
1 parent cfc492d commit b6c397e
Show file tree
Hide file tree
Showing 5 changed files with 345 additions and 1 deletion.
70 changes: 69 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,80 @@ A [Go](https://go.dev/) library for converting between spherical and planar coor

## Usage

// converting from one projection to another
#### Projecting/Inverse

Go from latitude/longitude pairs to points on the x/y plane, or the reverse.

mercator := flatsphere.NewMercator() // or some other projection
x, y := mercator.Project(lat, lon)

rlat, rlon := mercator.Inverse(sampleX, sampleY)

#### Bounds of Projection Plane

Determine the domain of the projection inverse function, to know which valid x/y values can be supplied. Helpful when iterating over the projected space.

mercator := flatsphere.NewMercator()
bounds := mercator.PlanarBounds()
for x := bounds.XMin; x <= bounds.XMax; x += bounds.Width() / xSteps {
for y := bounds.YMin; y <= bounds.YMax; y += bounds.Height() / ySteps {
lat, lon := mercator.Inverse(x, y)
}
}

#### Reprojecting

Convert planar points in one projection into another projection.

origProj := flatsphere.NewMercator()
newProj := flatsphere.NewLambert()
lat, lon := origProj.Inverse(origX, origY)
newX, newY := newProj.Project(lat, lon)

#### Oblique Projections

Easily create variants of existing projections with different center points and rotations around the center point.

mercator := flatsphere.NewMercator() // or some other projection
transverseMercator := flatsphere.NewOblique(mercator, 0, math.Pi/2, -math.Pi/2)
x, y := transverseMercator.Project(lat, lon)

#### Distortion

Determine how representative of reality a projection is at a point on the sphere.

mercator := flatsphere.NewMercator() // or some other projection
areaDistortion, angularDistortion = proj.DistortionAt(lat, lon)

## Projections

A list of the predefined projections supported by the package.

**Invertability**: all projections in this library have a well defined inverse function. However, only some of them reasonably satisfy the invertability property `x = f_1(f(x))` due to floating-point error in computations, edge-cases resulting in infinities or NaNs, or ambiguity at some type of critical point (commonly the poles or prime meridian). The table below checks of *invertible* for all projections which satisfy the `x = f_1(f(x))` property for all valid spherical locations to a reasonably fine degree of precision, referred to here as **everywhere floating-point invertible**. Oblique transforms of floating-point invertible standard projections do not necessarily share that property.

Efforts are ongoing to improve the coverage of this property to more projections where possible.

|Projection|Everywhere Floating-point Invertible|
|----------|----------|
|Mercator|:white-check-mark:|
|Plate carrée|:white-check-mark:|
|Equirectangular|:white-check-mark:|
|Lambert cylindrical|:white-check-mark:|
|Behrmann|:white-check-mark:|
|Gall orthographic|:white-check-mark:|
|Hobo-Dyer|:white-check-mark:|
|Gall stereographic|:white-check-mark:|
|Miller|:white-check-mark:|
|Central|:white-check-mark:|
|Sinusoidal|:white-check-mark:|
|HEALPix| |
|Mollweide| |
|Stereographic| |
|Polar| |
|Lambert azimuthal| |
|Gnomonic| |
|Orthographic| |

## Credits

Inspired by the [Map-Projections](https://github.com/jkunimune/Map-Projections) library made by [@jkunimune](https://github.com/jkunimune). Right now this library is essentially a Go-flavored port of his original Java, minus a few things to keep it focused on library use cases.
42 changes: 42 additions & 0 deletions azimuthal.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,45 @@ func (p LambertAzimuthal) Inverse(x float64, y float64) (float64, float64) {
func (l LambertAzimuthal) PlanarBounds() Bounds {
return NewCircleBounds(1)
}

// An ancient projection in which all great cirlces are straight lines. Many use cases
// but rapidly distorts the further away from the center of the projection.
// https://en.wikipedia.org/wiki/Gnomonic_projection
type Gnomonic struct{}

func NewGnomonic() Gnomonic {
return Gnomonic{}
}

func (g Gnomonic) Project(latitude float64, longitude float64) (float64, float64) {
r := math.Tan(math.Pi/2 - latitude)
return r * math.Sin(longitude), -r * math.Cos(longitude)
}

func (g Gnomonic) Inverse(x float64, y float64) (float64, float64) {
return math.Pi/2 - math.Atan(math.Hypot(x, y)), math.Atan2(x, -y)
}

func (g Gnomonic) PlanarBounds() Bounds {
return NewRectangleBounds(4, 4)
}

// A projection of a hemisphere of a sphere as if viewed from an infinite distance away.
// https://en.wikipedia.org/wiki/Orthographic_map_projection
type Orthographic struct{}

func NewOrthographic() Orthographic {
return Orthographic{}
}

func (o Orthographic) Project(latitude float64, longitude float64) (float64, float64) {
return math.Cos(latitude) * math.Sin(longitude), -math.Cos(latitude) * math.Cos(longitude)
}

func (o Orthographic) Inverse(x float64, y float64) (float64, float64) {
return math.Acos(math.Hypot(x, y)), math.Atan2(x, -y)
}

func (o Orthographic) PlanarBounds() Bounds {
return NewCircleBounds(1)
}
31 changes: 31 additions & 0 deletions invertability_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ func FuzzSinusoidal(f *testing.F) {
// projectInverseFuzz(f, NewLambertAzimuthal())
//}

//func FuzzTransverseMercator(f *testing.F) {
// projectInverseFuzz(f, NewObliqueProjection(NewMercator(), 0, math.Pi/2, -math.Pi/2))
//}

func withinTolerance(n1, n2, tolerance float64) bool {
if n1 == n2 {
return true
Expand Down Expand Up @@ -103,3 +107,30 @@ func projectInverseFuzz(f *testing.F, proj Projection) {
}
})
}

/*func FuzzObliqueTransformInverse(f *testing.F) {
f.Add(0.0, 0.0, 0.0, math.Pi/4, -math.Pi/4)
f.Fuzz(func(t *testing.T, lat float64, lon float64, poleLat float64, poleLon float64, poleTheta float64) {
if math.Abs(lat) > math.Pi/2 {
lat = math.Mod(lat, math.Pi/2)
}
if math.Abs(lon) > math.Pi {
lon = math.Mod(lon, math.Pi)
}
if math.Abs(poleLat) > math.Pi/2 {
poleLat = math.Mod(poleLat, math.Pi/2)
}
if math.Abs(poleLon) > math.Pi {
poleLon = math.Mod(poleLon, math.Pi)
}
if math.Abs(poleTheta) > math.Pi {
poleTheta = math.Mod(poleTheta, math.Pi)
}
oblique := NewObliqueProjection(nil, poleLat, poleLon, poleTheta)
x, y := oblique.TransformToOblique(lat, lon)
rlat, rlon := oblique.TransformFromOblique(x, y)
if !withinTolerance(lat, rlat, 0.00001) || !withinTolerance(lon, rlon, 0.00001) {
t.Errorf("expected %e,%e, got %e,%e", lat, lon, rlat, rlon)
}
})
}*/
135 changes: 135 additions & 0 deletions oblique.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package flatsphere

import (
"math"
)

type ObliqueProjection struct {
orig Projection
poleLat float64
poleLon float64
poleTheta float64

sinPoleLat float64 // cached precompute of sin(poleLat)
cosPoleLat float64 // cached precompute of cos(poleLat)
}

func NewObliqueProjection(original Projection, poleLat float64, poleLon float64, poleTheta float64) ObliqueProjection {
return ObliqueProjection{
orig: original,
poleLat: poleLat,
poleLon: poleLon,
poleTheta: poleTheta,
sinPoleLat: math.Sin(poleLat),
cosPoleLat: math.Cos(poleLat),
}
}

func (o ObliqueProjection) Project(latitude float64, longitude float64) (float64, float64) {
return o.orig.Project(o.TransformFromOblique(latitude, longitude))
}

func (o ObliqueProjection) Inverse(x float64, y float64) (float64, float64) {
return o.TransformToOblique(o.orig.Inverse(x, y))
}

func (o ObliqueProjection) PlanarBounds() Bounds {
return o.orig.PlanarBounds()
}

// Applies the pole shift and rotation of the Oblique projection transform to the given
// input latitude and longitude points, so that the returned latitude/longitude are able
// to be used for the non-transformed 'original' projection.
func (o ObliqueProjection) TransformFromOblique(latitude float64, longitude float64) (float64, float64) {
var newLat, newLon float64

poleRelCos := math.Cos(o.poleLon - longitude)

// relative latitude
if o.poleLat == math.Pi/2 {
newLat = latitude
} else {
preAsin := o.sinPoleLat*math.Sin(latitude) + o.cosPoleLat*math.Cos(latitude)*poleRelCos
if preAsin > 1 && preAsin < 1+1e-9 {
preAsin = 1
}
newLat = math.Asin(preAsin)
}

// relative longitude
if o.poleLat == math.Pi/2 {
newLon = longitude - o.poleLon
} else if o.poleLat == -math.Pi/2 {
newLon = o.poleLon - longitude - math.Pi
} else {
numer := o.cosPoleLat*math.Sin(latitude) - o.sinPoleLat*math.Cos(latitude)*poleRelCos
denom := math.Cos(newLat)
newLon = math.Acos(numer/denom) - math.Pi

if math.IsNaN(newLon) {
if (poleRelCos >= 0 && latitude < o.poleLat) || (poleRelCos < 0 && latitude < -o.poleLat) {
newLon = 0
} else {
newLon = -math.Pi
}
} else if math.Sin(longitude-o.poleLon) > 0 {
newLon = -newLon
}
}

// apply rotate
newLon = newLon - o.poleTheta

// constrain and kill roundoff error
if math.Abs(newLon) > math.Pi {
newLon = coerceAngle(newLon)
}
if newLon >= math.Pi-1e-7 {
newLon = -math.Pi
}

return newLat, newLon
}

func coerceAngle(angle float64) float64 {
x := angle + math.Pi
y := 2 * math.Pi
floorMod := x - math.Floor(x/y)*y
return floorMod - math.Pi
}

// Given a latitude/longitude in the non-transformed 'original' projection space, applies
// the pole shift and rotation of the Oblique projection so that the returned latitude/longitude
// are in the Oblique projection space.
func (o ObliqueProjection) TransformToOblique(latitude float64, longitude float64) (float64, float64) {
rotateLon := longitude + o.poleTheta

preAsin := o.sinPoleLat*math.Sin(latitude) - o.cosPoleLat*math.Cos(latitude)*math.Cos(rotateLon)
if preAsin > 1 && preAsin < 1+1e-9 {
preAsin = 1
}
if preAsin < -1 && preAsin > -1-1e-9 {
preAsin = -1
}
newLat := math.Asin(preAsin)
var newLon float64
inner := math.Sin(latitude)/o.cosPoleLat/math.Cos(newLat) - math.Tan(o.poleLat)*math.Tan(newLat)
if o.poleLat == -math.Pi/2 {
newLon = o.poleLon + rotateLon
} else if math.Abs(inner) > 1 {
if (rotateLon == 0 && latitude < -o.poleLat) || (rotateLon != 0 && latitude < o.poleLat) {
newLon = o.poleLon + math.Pi
} else {
newLon = o.poleLon
}
} else if math.Sin(rotateLon) > 0 {
newLon = o.poleLon + math.Acos(inner)
} else {
newLon = o.poleLon - math.Acos(inner)
}

if math.Abs(newLon) > math.Pi {
newLon = coerceAngle(newLon)
}
return newLat, newLon
}
68 changes: 68 additions & 0 deletions projection.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package flatsphere

import "math"

// A package of functionality for converting from spherical locations to planar coordinates, or from
// planar coordinates into spherical locations. Contains information specifying some characteristics
// of the planar space mapped to by the projection functions, which can differ between projections.
Expand All @@ -11,3 +13,69 @@ type Projection interface {
// Retrieve the planar bounds of the projection.
PlanarBounds() Bounds
}

// Compute both area distortion and angular distortion at a particular location on the sphere, for the given projection.
func DistortionAt(proj Projection, latitude float64, longitude float64) (area float64, angular float64) {
nudge := 1e-8
nudgedLat := latitude + nudge
// step to the side to avoid interruptions
pCx, pCy := proj.Project(nudgedLat, longitude)
// consider a point slightly east
pEx, pEy := proj.Project(nudgedLat, longitude+nudge/math.Cos(nudgedLat))
// consider a point slightly north
pNx, pNy := proj.Project(nudgedLat+nudge, longitude)

deltaA := (pEx-pCx)*(pNy-pCy) - (pEy-pCy)*(pNx-pCx)
areaDistortion := math.Log(math.Abs(deltaA / (nudge * nudge)))
if math.Abs(areaDistortion) > 25.0 {
areaDistortion = math.NaN()
}

s1ps2 := math.Hypot((pEx-pCx)+(pNy-pCy), (pEy-pCy)-(pNx-pCx))
s1ms2 := math.Hypot((pEx-pCx)-(pNy-pCy), (pEy-pCy)+(pNx-pCx))
angularDistortion := math.Abs(math.Log(math.Abs((s1ps2 - s1ms2) / (s1ps2 + s1ms2))))
if angularDistortion > 25.0 {
angularDistortion = math.NaN()
}

return areaDistortion, angularDistortion
}

// Compute the area distortion of a projection at a particular location.
func AreaDistortionAt(proj Projection, latitude float64, longitude float64) float64 {
nudge := 1e-8
nudgedLat := latitude + nudge
// step to the side to avoid interruptions
pCx, pCy := proj.Project(nudgedLat, longitude)
// consider a point slightly east
pEx, pEy := proj.Project(nudgedLat, longitude+nudge/math.Cos(nudgedLat))
// consider a point slightly north
pNx, pNy := proj.Project(nudgedLat+nudge, longitude)

deltaA := (pEx-pCx)*(pNy-pCy) - (pEy-pCy)*(pNx-pCx)
areaDistortion := math.Log(math.Abs(deltaA / (nudge * nudge)))
if math.Abs(areaDistortion) > 25.0 {
areaDistortion = math.NaN()
}
return areaDistortion
}

// Compute the angular distortion of a projection at a particular location.
func AngularDistortionAt(proj Projection, latitude float64, longitude float64) float64 {
nudge := 1e-8
nudgedLat := latitude + nudge
// step to the side to avoid interruptions
pCx, pCy := proj.Project(nudgedLat, longitude)
// consider a point slightly east
pEx, pEy := proj.Project(nudgedLat, longitude+nudge/math.Cos(nudgedLat))
// consider a point slightly north
pNx, pNy := proj.Project(nudgedLat+nudge, longitude)

s1ps2 := math.Hypot((pEx-pCx)+(pNy-pCy), (pEy-pCy)-(pNx-pCx))
s1ms2 := math.Hypot((pEx-pCx)-(pNy-pCy), (pEy-pCy)+(pNx-pCx))
angularDistortion := math.Abs(math.Log(math.Abs((s1ps2 - s1ms2) / (s1ps2 + s1ms2))))
if angularDistortion > 25.0 {
angularDistortion = math.NaN()
}
return angularDistortion
}

0 comments on commit b6c397e

Please sign in to comment.