Places: Use float64 for all coordinates to avoid rounding errors #3953

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2024-09-15 13:52:31 +02:00
parent e808de45e3
commit 735a3a2d13
32 changed files with 510 additions and 377 deletions

View file

@ -2,25 +2,24 @@ package clean
import (
"fmt"
"math"
"strings"
"github.com/photoprism/photoprism/pkg/geo"
"github.com/photoprism/photoprism/pkg/txt"
)
// gpsCeil converts a GPS coordinate to a rounded float32 for use in queries.
func gpsCeil(f float64) float32 {
return float32((math.Ceil(f*10000) / 10000) + 0.0001)
// GPSBoundsDefaultPadding specifies the default padding of the GPS coordinates in meters.
const GPSBoundsDefaultPadding = 5.0
// GPSBounds parses the GPS bounds (Lat N, Lng E, Lat S, Lng W)
// and returns the coordinates with default padding.
func GPSBounds(bounds string) (latN, lngE, latS, lngW float64, err error) {
return GPSBoundsWithPadding(bounds, GPSBoundsDefaultPadding)
}
// gpsFloor converts a GPS coordinate to a rounded float32 for use in queries.
func gpsFloor(f float64) float32 {
return float32((math.Floor(f*10000) / 10000) - 0.0001)
}
// GPSBounds parses the GPS bounds (Lat N, Lng E, Lat S, Lng W) and returns the coordinates if any.
func GPSBounds(bounds string) (latN, lngE, latS, lngW float32, err error) {
// GPSBoundsWithPadding parses the GPS bounds (Lat N, Lng E, Lat S, Lng W)
// and returns the coordinates with a custom padding in meters.
func GPSBoundsWithPadding(bounds string, padding float64) (latN, lngE, latS, lngW float64, err error) {
// Bounds string not long enough?
if len(bounds) < 7 {
return 0, 0, 0, 0, fmt.Errorf("no coordinates found")
@ -29,19 +28,19 @@ func GPSBounds(bounds string) (latN, lngE, latS, lngW float32, err error) {
// Trim whitespace and invalid characters.
bounds = strings.Trim(bounds, " |\\<>\n\r\t\"'#$%!^*()[]{}")
// Split string into values.
// Split bounding box string into coordinate values.
values := strings.SplitN(bounds, ",", 5)
found := len(values)
// Invalid number of values?
// Return error if number of coordinates is invalid.
if found != 4 {
return 0, 0, 0, 0, fmt.Errorf("invalid number of coordinates")
}
// Parse floating point coordinates.
// Convert coordinate strings to floating point values.
latNorth, lngEast, latSouth, lngWest := txt.Float(values[0]), txt.Float(values[1]), txt.Float(values[2]), txt.Float(values[3])
// Latitudes (from +90 to -90 degrees).
// Latitudes have a valid range of +90 to -90 degrees.
if latNorth > 90 {
latNorth = 90
} else if latNorth < -90 {
@ -54,12 +53,12 @@ func GPSBounds(bounds string) (latN, lngE, latS, lngW float32, err error) {
latSouth = -90
}
// latSouth must be smaller.
// Make sure latSouth is smaller than latNorth.
if latSouth > latNorth {
latNorth, latSouth = latSouth, latNorth
}
// Longitudes (from -180 to +180 degrees).
// Longitudes have a valid range of -180 to +180 degrees.
if lngEast > 180 {
lngEast = 180
} else if lngEast < -180 {
@ -72,17 +71,20 @@ func GPSBounds(bounds string) (latN, lngE, latS, lngW float32, err error) {
lngWest = -180
}
// lngWest must be smaller.
// Make sure lngWest is smaller than lngEast.
if lngWest > lngEast {
lngEast, lngWest = lngWest, lngEast
}
// Return rounded coordinates.
return gpsCeil(latNorth), gpsCeil(lngEast), gpsFloor(latSouth), gpsFloor(lngWest), nil
// Calculate the latitude and longitude padding in degrees.
dLat, dLng := geo.Deg((latNorth+latSouth)/2.0, padding)
// Return the coordinates of the bounding box with padding applied.
return latNorth + dLat, lngEast + dLng, latSouth - dLat, lngWest - dLng, nil
}
// GPSLatRange returns a range based on the specified latitude and distance in km, or an error otherwise.
func GPSLatRange(lat float64, km float64) (latN, latS float32, err error) {
func GPSLatRange(lat float64, km float64) (latN, latS float64, err error) {
// Latitude (from +90 to -90 degrees).
if lat == 0 || lat < -90 || lat > 90 {
return 0, 0, fmt.Errorf("invalid latitude")
@ -93,8 +95,10 @@ func GPSLatRange(lat float64, km float64) (latN, latS float32, err error) {
// Approximate longitude range,
// see https://en.wikipedia.org/wiki/Decimal_degrees
latN = gpsCeil(lat + geo.Deg(r))
latS = gpsFloor(lat - geo.Deg(r))
dLat, _ := geo.DegKm(lat, r)
latN = lat + dLat
latS = lat - dLat
if latN > 90 {
latN = 90
@ -108,7 +112,7 @@ func GPSLatRange(lat float64, km float64) (latN, latS float32, err error) {
}
// GPSLngRange returns a range based on the specified longitude and distance in km, or an error otherwise.
func GPSLngRange(lng float64, km float64) (lngE, lngW float32, err error) {
func GPSLngRange(lat, lng float64, km float64) (lngE, lngW float64, err error) {
// Longitude (from -180 to +180 degrees).
if lng == 0 || lng < -180 || lng > 180 {
return 0, 0, fmt.Errorf("invalid longitude")
@ -119,8 +123,10 @@ func GPSLngRange(lng float64, km float64) (lngE, lngW float32, err error) {
// Approximate longitude range,
// see https://en.wikipedia.org/wiki/Decimal_degrees
lngE = gpsCeil(lng + geo.Deg(r))
lngW = gpsFloor(lng - geo.Deg(r))
_, dLng := geo.DegKm(lat, r)
lngE = lng + dLng
lngW = lng - dLng
if lngE > 180 {
lngE = 180

View file

@ -6,77 +6,97 @@ import (
"github.com/stretchr/testify/assert"
)
func TestGPSBoundsWithPadding(t *testing.T) {
t.Run("Valid", func(t *testing.T) {
latNorth, lngEast, latSouth, lngWest, err := GPSBoundsWithPadding("41.87760543823242,-87.62521362304688,41.89404296875,-87.6215591430664", 1000)
assert.InEpsilon(t, 41.903036, latNorth, 0.00001)
assert.InEpsilon(t, -87.609479, lngEast, 0.00001)
assert.InEpsilon(t, 41.868612, latSouth, 0.00001)
assert.InEpsilon(t, -87.637294, lngWest, 0.00001)
assert.NoError(t, err)
})
}
func TestGPSBounds(t *testing.T) {
t.Run("Valid", func(t *testing.T) {
latNorth, lngEast, latSouth, lngWest, err := GPSBounds("41.87760543823242,-87.62521362304688,41.89404296875,-87.6215591430664")
assert.Equal(t, float32(41.8942), latNorth)
assert.Equal(t, float32(41.8775), latSouth)
assert.Equal(t, float32(-87.6254), lngWest)
assert.Equal(t, float32(-87.6214), lngEast)
assert.InEpsilon(t, 41.8942, latNorth, 0.00001)
assert.InEpsilon(t, -87.6214, lngEast, 0.00001)
assert.InEpsilon(t, 41.8775, latSouth, 0.00001)
assert.InEpsilon(t, -87.6254, lngWest, 0.00001)
assert.NoError(t, err)
})
t.Run("China", func(t *testing.T) {
// Actual postion: Lat 39.8922, Lng 116.315
latNorth, lngEast, latSouth, lngWest, err := GPSBounds("39.8922004699707,116.31500244140625,39.8922004699707,116.31500244140625")
assert.InEpsilon(t, 39.8924, latNorth, 0.00001)
assert.InEpsilon(t, 116.3152, lngEast, 0.00001)
assert.InEpsilon(t, 39.8921, latSouth, 0.00001)
assert.InEpsilon(t, 116.3149, lngWest, 0.00001)
assert.NoError(t, err)
})
t.Run("FlippedLat", func(t *testing.T) {
latNorth, lngEast, latSouth, lngWest, err := GPSBounds("41.89404296875,-87.62521362304688,41.87760543823242,-87.6215591430664")
assert.Equal(t, float32(41.8942), latNorth)
assert.Equal(t, float32(41.8775), latSouth)
assert.Equal(t, float32(-87.6254), lngWest)
assert.Equal(t, float32(-87.6214), lngEast)
assert.InEpsilon(t, 41.8942, latNorth, 0.00001)
assert.InEpsilon(t, -87.6214, lngEast, 0.00001)
assert.InEpsilon(t, 41.8775, latSouth, 0.00001)
assert.InEpsilon(t, -87.6254, lngWest, 0.00001)
assert.NoError(t, err)
})
t.Run("FlippedLng", func(t *testing.T) {
latNorth, lngEast, latSouth, lngWest, err := GPSBounds("41.87760543823242,-87.6215591430664,41.89404296875,-87.62521362304688")
assert.Equal(t, float32(41.8942), latNorth)
assert.Equal(t, float32(41.8775), latSouth)
assert.Equal(t, float32(-87.6254), lngWest)
assert.Equal(t, float32(-87.6214), lngEast)
assert.InEpsilon(t, 41.8942, latNorth, 0.00001)
assert.InEpsilon(t, -87.6214, lngEast, 0.00001)
assert.InEpsilon(t, 41.8775, latSouth, 0.00001)
assert.InEpsilon(t, -87.6254, lngWest, 0.00001)
assert.NoError(t, err)
})
t.Run("Empty", func(t *testing.T) {
latNorth, lngEast, latSouth, lngWest, err := GPSBounds("")
assert.Equal(t, float32(0), latNorth)
assert.Equal(t, float32(0), lngEast)
assert.Equal(t, float32(0), latSouth)
assert.Equal(t, float32(0), lngWest)
assert.Equal(t, 0.0, latNorth)
assert.Equal(t, 0.0, lngEast)
assert.Equal(t, 0.0, latSouth)
assert.Equal(t, 0.0, lngWest)
assert.Error(t, err)
})
t.Run("One", func(t *testing.T) {
latNorth, lngEast, latSouth, lngWest, err := GPSBounds("41.87760543823242")
assert.Equal(t, float32(0), latNorth)
assert.Equal(t, float32(0), lngEast)
assert.Equal(t, float32(0), latSouth)
assert.Equal(t, float32(0), lngWest)
assert.Equal(t, 0.0, latNorth)
assert.Equal(t, 0.0, lngEast)
assert.Equal(t, 0.0, latSouth)
assert.Equal(t, 0.0, lngWest)
assert.Error(t, err)
})
t.Run("Three", func(t *testing.T) {
latNorth, lngEast, latSouth, lngWest, err := GPSBounds("41.87760543823242,-87.62521362304688,41.89404296875")
assert.Equal(t, float32(0), latNorth)
assert.Equal(t, float32(0), lngEast)
assert.Equal(t, float32(0), latSouth)
assert.Equal(t, float32(0), lngWest)
assert.Equal(t, 0.0, latNorth)
assert.Equal(t, 0.0, lngEast)
assert.Equal(t, 0.0, latSouth)
assert.Equal(t, 0.0, lngWest)
assert.Error(t, err)
})
t.Run("Five", func(t *testing.T) {
latNorth, lngEast, latSouth, lngWest, err := GPSBounds("41.87760543823242,-87.62521362304688,41.89404296875,-87.6215591430664,41.89404296875")
assert.Equal(t, float32(0), latNorth)
assert.Equal(t, float32(0), lngEast)
assert.Equal(t, float32(0), latSouth)
assert.Equal(t, float32(0), lngWest)
assert.Equal(t, 0.0, latNorth)
assert.Equal(t, 0.0, lngEast)
assert.Equal(t, 0.0, latSouth)
assert.Equal(t, 0.0, lngWest)
assert.Error(t, err)
})
t.Run("Invalid", func(t *testing.T) {
latNorth, lngEast, latSouth, lngWest, err := GPSBounds("95.87760543823242,-197.62521362304688,98.89404296875,-197.6215591430664")
assert.Equal(t, float32(90.0001), latNorth)
assert.Equal(t, float32(89.9999), latSouth)
assert.Equal(t, float32(-180.0001), lngWest)
assert.Equal(t, float32(-179.9999), lngEast)
assert.InEpsilon(t, 90.000045, latNorth, 0.00001)
assert.InEpsilon(t, -179.974236, lngEast, 0.00001)
assert.InEpsilon(t, 89.999955, latSouth, 0.00001)
assert.InEpsilon(t, -180.025764, lngWest, 0.00001)
assert.NoError(t, err)
})
t.Run("Invalid2", func(t *testing.T) {
latNorth, lngEast, latSouth, lngWest, err := GPSBounds("-95.87760543823242,197.62521362304688,-98.89404296875,197.6215591430664")
assert.Equal(t, float32(-89.9999), latNorth)
assert.Equal(t, float32(-90.0001), latSouth)
assert.Equal(t, float32(179.9999), lngWest)
assert.Equal(t, float32(180.0001), lngEast)
assert.InEpsilon(t, -89.999955, latNorth, 0.00001)
assert.InEpsilon(t, 180.025764, lngEast, 0.00001)
assert.InEpsilon(t, -90.000045, latSouth, 0.00001)
assert.InEpsilon(t, 179.974236, lngWest, 0.00001)
assert.NoError(t, err)
})
}
@ -84,29 +104,41 @@ func TestGPSBounds(t *testing.T) {
func TestGPSLatRange(t *testing.T) {
t.Run("Valid", func(t *testing.T) {
latNorth, latSouth, err := GPSLatRange(41.87760543823242, 2)
assert.Equal(t, float32(41.8913), latNorth)
assert.Equal(t, float32(41.8639), latSouth)
assert.Equal(t, float32(41.891094), float32(latNorth))
assert.Equal(t, float32(41.864117), float32(latSouth))
assert.NoError(t, err)
})
t.Run("Zero", func(t *testing.T) {
latNorth, latSouth, err := GPSLatRange(0, 2)
assert.Equal(t, float32(0), latNorth)
assert.Equal(t, float32(0), latSouth)
assert.Equal(t, 0.0, latNorth)
assert.Equal(t, 0.0, latSouth)
assert.Error(t, err)
})
}
func TestGPSLngRange(t *testing.T) {
t.Run("Valid", func(t *testing.T) {
lngEast, lngWest, err := GPSLngRange(-87.62521362304688, 2)
assert.Equal(t, float32(-87.6389), lngWest)
assert.Equal(t, float32(-87.6116), lngEast)
t.Run("Lat0", func(t *testing.T) {
lngEast, lngWest, err := GPSLngRange(0.0, -87.62521362304688, 2)
assert.Equal(t, float32(-87.6387), float32(lngWest))
assert.Equal(t, float32(-87.611725), float32(lngEast))
assert.NoError(t, err)
})
t.Run("Lat45", func(t *testing.T) {
lngEast, lngWest, err := GPSLngRange(45.0, -87.62521362304688, 2)
assert.Equal(t, float32(-87.644295), float32(lngWest))
assert.Equal(t, float32(-87.60613), float32(lngEast))
assert.NoError(t, err)
})
t.Run("Lat67", func(t *testing.T) {
lngEast, lngWest, err := GPSLngRange(67.0, -87.62521362304688, 2)
assert.Equal(t, float32(-87.65974), float32(lngWest))
assert.Equal(t, float32(-87.59069), float32(lngEast))
assert.NoError(t, err)
})
t.Run("Zero", func(t *testing.T) {
lngEast, lngWest, err := GPSLngRange(0, 2)
assert.Equal(t, float32(0), lngEast)
assert.Equal(t, float32(0), lngWest)
lngEast, lngWest, err := GPSLngRange(0, 0, 2)
assert.Equal(t, 0.0, lngEast)
assert.Equal(t, 0.0, lngWest)
assert.Error(t, err)
})
}

View file

@ -10,10 +10,35 @@ const (
DefaultDist float64 = 2
)
// Deg returns the approximate distance in decimal degrees,
// see https://en.wikipedia.org/wiki/Decimal_degrees.
func Deg(km float64) float64 {
return 0.009009009 * km
// Deg returns the distance in decimal degrees based on the specified distance in meters and the latitude,
// see https://en.wikipedia.org/wiki/Decimal_degrees#Precision.
func Deg(lat, meter float64) (dLat, dLng float64) {
if meter <= 0.0 {
return 0, 0
}
// Calculate latitude distance in degrees.
dLat = (meter / AverageEarthRadiusMeter) * (180.0 / math.Pi)
// Do not calculate the exact longitude distance in
// degrees if the latitude is zero or out of range.
if lat == 0.0 {
return dLat, dLat
} else if lat < -89.9 {
lat = -89.9
} else if lat > 89.9 {
lat = 89.9
}
// Calculate longitude distance in degrees.
dLng = (meter / AverageEarthRadiusMeter) * (180.0 / math.Pi) / math.Cos(lat*math.Pi/180.0)
return dLat, dLng
}
// DegKm returns the distance in decimal degrees based on the specified distance in kilometers and the latitude.
func DegKm(lat, km float64) (dLat, dLng float64) {
return Deg(lat, km*1000.0)
}
// DegToRad converts a value from degrees to radians.
@ -40,5 +65,5 @@ func Km(p, q Position) (km float64) {
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return c * EarthRadiusKm
return c * AverageEarthRadiusKm
}

View file

@ -7,8 +7,76 @@ import (
)
func TestDeg(t *testing.T) {
t.Run("10km", func(t *testing.T) {
assert.Equal(t, 0.09009009, Deg(10))
t.Run("Lat0", func(t *testing.T) {
dLat, dLng := Deg(0.0, 11.1)
t.Logf("dLat: %f, dLng: %f", dLat, dLng)
assert.InEpsilon(t, 0.000100, dLat, 0.01)
assert.InEpsilon(t, 0.000100, dLng, 0.01)
})
t.Run("Lat23", func(t *testing.T) {
dLat, dLng := Deg(23.0, 11.1)
t.Logf("dLat: %f, dLng: %f", dLat, dLng)
assert.InEpsilon(t, 0.000100, dLat, 0.01)
assert.InEpsilon(t, 0.000108, dLng, 0.01)
})
t.Run("Lat45", func(t *testing.T) {
dLat, dLng := Deg(45.0, 11.1)
t.Logf("dLat: %f, dLng: %f", dLat, dLng)
assert.InEpsilon(t, 0.000100, dLat, 0.01)
assert.InEpsilon(t, 0.000141, dLng, 0.01)
})
t.Run("Lat67", func(t *testing.T) {
dLat, dLng := Deg(67.0, 11.1)
t.Logf("dLat: %f, dLng: %f", dLat, dLng)
assert.InEpsilon(t, 0.000100, dLat, 0.01)
assert.InEpsilon(t, 0.000255, dLng, 0.01)
})
t.Run("ZeroMeters", func(t *testing.T) {
dLat, dLng := Deg(50.0, 0.0)
t.Logf("dLat: %f, dLng: %f", dLat, dLng)
assert.Equal(t, 0.0, dLat)
assert.Equal(t, 0.0, dLng)
})
t.Run("LatOutOfRange", func(t *testing.T) {
dLat, dLng := Deg(123.0, 11.1)
t.Logf("dLat: %f, dLng: %f", dLat, dLng)
assert.InEpsilon(t, 0.000100, dLat, 0.01)
assert.InEpsilon(t, 0.057195, dLng, 0.01)
dLat, dLng = Deg(-600.0, 11.1)
t.Logf("dLat: %f, dLng: %f", dLat, dLng)
assert.InEpsilon(t, 0.000100, dLat, 0.01)
assert.InEpsilon(t, 0.057195, dLng, 0.01)
dLat, dLng = Deg(-600.0, 11.1)
t.Logf("dLat: %f, dLng: %f", dLat, dLng)
assert.InEpsilon(t, 0.000100, dLat, 0.01)
assert.InEpsilon(t, 0.057195, dLng, 0.01)
})
}
func TestDegKm(t *testing.T) {
t.Run("Lat0", func(t *testing.T) {
dLat, dLng := DegKm(0.0, 0.0111)
t.Logf("dLat: %f, dLng: %f", dLat, dLng)
assert.InEpsilon(t, 0.000100, dLat, 0.01)
assert.InEpsilon(t, 0.000100, dLng, 0.01)
})
t.Run("Lat23", func(t *testing.T) {
dLat, dLng := DegKm(23.0, 0.0111)
t.Logf("dLat: %f, dLng: %f", dLat, dLng)
assert.InEpsilon(t, 0.000100, dLat, 0.01)
assert.InEpsilon(t, 0.000108, dLng, 0.01)
})
t.Run("Lat45", func(t *testing.T) {
dLat, dLng := DegKm(45.0, 0.0111)
t.Logf("dLat: %f, dLng: %f", dLat, dLng)
assert.InEpsilon(t, 0.000100, dLat, 0.01)
assert.InEpsilon(t, 0.000141, dLng, 0.01)
})
t.Run("Lat67", func(t *testing.T) {
dLat, dLng := DegKm(67.0, 0.0111)
t.Logf("dLat: %f, dLng: %f", dLat, dLng)
assert.InEpsilon(t, 0.000100, dLat, 0.01)
assert.InEpsilon(t, 0.000255, dLng, 0.01)
})
}

View file

@ -25,5 +25,8 @@ Additional information can be found in our Developer Guide:
package geo
const (
EarthRadiusKm = 6371 // Earth radius in km
AverageEarthRadiusKm = 6371.0 // Global-average earth radius in km
AverageEarthRadiusMeter = AverageEarthRadiusKm * 1000.0 // Global-average earth radius in m
WGS84EarthRadiusKm = 6378.137 // WGS84 earth radius in km
WGS84EarthRadiusMeter = WGS84EarthRadiusKm * 1000.0 // WGS84 earth radius in m
)