diff --git a/internal/form/search_photos.go b/internal/form/search_photos.go index fc4f8d0a2..2ec45a2fa 100644 --- a/internal/form/search_photos.go +++ b/internal/form/search_photos.go @@ -46,8 +46,8 @@ type SearchPhotos struct { Near string `form:"near" example:"near:pqbcf5j446s0futy" notes:"Finds nearby pictures (UID)"` S2 string `form:"s2" example:"s2:4799e370ca54c8b9" notes:"S2 Position (Cell ID)"` Olc string `form:"olc" example:"olc:8FWCHX7W+" notes:"OLC Position (Open Location Code)"` - Lat float32 `form:"lat" example:"lat:41.894043" notes:"GPS Position (Latitude)"` - Lng float32 `form:"lng" example:"lng:-87.62448" notes:"GPS Position (Longitude)"` + Lat float64 `form:"lat" example:"lat:41.894043" notes:"GPS Position (Latitude)"` + Lng float64 `form:"lng" example:"lng:-87.62448" notes:"GPS Position (Longitude)"` Dist uint `form:"dist" example:"dist:50" notes:"Distance to Position (km)"` Latlng string `form:"latlng" notes:"GPS Bounding Box (Lat N, Lng E, Lat S, Lng W)"` Fmin float32 `form:"fmin" notes:"F-number (min)"` diff --git a/internal/form/search_photos_geo.go b/internal/form/search_photos_geo.go index 70eeb2c22..4eaea898c 100644 --- a/internal/form/search_photos_geo.go +++ b/internal/form/search_photos_geo.go @@ -44,8 +44,8 @@ type SearchPhotosGeo struct { Near string `form:"near" example:"near:pqbcf5j446s0futy" notes:"Finds nearby pictures (UID)"` S2 string `form:"s2" example:"s2:4799e370ca54c8b9" notes:"S2 Position (Cell ID)"` Olc string `form:"olc" example:"olc:8FWCHX7W+" notes:"OLC Position (Open Location Code)"` - Lat float32 `form:"lat" example:"lat:41.894043" notes:"GPS Position (Latitude)"` - Lng float32 `form:"lng" example:"lng:-87.62448" notes:"GPS Position (Longitude)"` + Lat float64 `form:"lat" example:"lat:41.894043" notes:"GPS Position (Latitude)"` + Lng float64 `form:"lng" example:"lng:-87.62448" notes:"GPS Position (Longitude)"` Dist uint `form:"dist" example:"dist:50" notes:"Distance to Position (km)"` Latlng string `form:"latlng" notes:"GPS Bounding Box (Lat N, Lng E, Lat S, Lng W)"` Person string `form:"person"` // Alias for Subject diff --git a/internal/form/search_photos_geo_test.go b/internal/form/search_photos_geo_test.go index 4cc905aae..ff470ab49 100644 --- a/internal/form/search_photos_geo_test.go +++ b/internal/form/search_photos_geo_test.go @@ -108,7 +108,7 @@ func TestSearchPhotosGeo(t *testing.T) { assert.Equal(t, "fooBar baz", form.Query) assert.Equal(t, time.Date(2019, 01, 15, 0, 0, 0, 0, time.UTC), form.Before) assert.Equal(t, uint(0x61a8), form.Dist) - assert.Equal(t, float32(33.45343), form.Lat) + assert.Equal(t, 33.45343166666667, form.Lat) }) t.Run("valid query path empty folder not empty", func(t *testing.T) { form := &SearchPhotosGeo{Query: "q:\"fooBar baz\" before:2019-01-15 dist:25000 lat:33.45343166666667 folder:test"} @@ -126,7 +126,7 @@ func TestSearchPhotosGeo(t *testing.T) { assert.Equal(t, "", form.Folder) assert.Equal(t, time.Date(2019, 01, 15, 0, 0, 0, 0, time.UTC), form.Before) assert.Equal(t, uint(0x61a8), form.Dist) - assert.Equal(t, float32(33.45343), form.Lat) + assert.Equal(t, 33.45343166666667, form.Lat) }) t.Run("valid query with filter", func(t *testing.T) { form := &SearchPhotosGeo{Query: "keywords:cat title:\"fooBar baz\"", Filter: "keywords:dog"} diff --git a/internal/form/search_photos_test.go b/internal/form/search_photos_test.go index dbc6d1254..f56975934 100644 --- a/internal/form/search_photos_test.go +++ b/internal/form/search_photos_test.go @@ -122,7 +122,7 @@ func TestParseQueryString(t *testing.T) { assert.Equal(t, time.Date(2019, 01, 15, 0, 0, 0, 0, time.UTC), form.Before) assert.Equal(t, "false", form.Favorite) assert.Equal(t, uint(0x61a8), form.Dist) - assert.Equal(t, float32(33.45343), form.Lat) + assert.Equal(t, 33.45343166666667, form.Lat) }) t.Run("valid query 2", func(t *testing.T) { form := &SearchPhotos{Query: "chroma:200 title:\"te:st\" after:2018-01-15 favorite:true lng:33.45343166666667"} @@ -138,7 +138,7 @@ func TestParseQueryString(t *testing.T) { assert.Equal(t, int16(200), form.Chroma) assert.Equal(t, "te:st", form.Title) assert.Equal(t, time.Date(2018, 01, 15, 0, 0, 0, 0, time.UTC), form.After) - assert.Equal(t, float32(33.45343), form.Lng) + assert.Equal(t, 33.45343166666667, form.Lng) }) t.Run("valid query with filter", func(t *testing.T) { form := &SearchPhotos{Query: "label:cat title:\"fooBar baz\"", Filter: "label:dog"} diff --git a/internal/search/photos.go b/internal/search/photos.go index 5358b5d39..767e473f8 100644 --- a/internal/search/photos.go +++ b/internal/search/photos.go @@ -15,6 +15,7 @@ import ( "github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" + "github.com/photoprism/photoprism/pkg/geo" "github.com/photoprism/photoprism/pkg/pluscode" "github.com/photoprism/photoprism/pkg/rnd" "github.com/photoprism/photoprism/pkg/s2" @@ -70,18 +71,13 @@ func searchPhotos(f form.SearchPhotos, sess *entity.Session, resultCols string) // Set the S2 Cell ID to search for. f.S2 = photo.CellID - - // Set the search distance if unspecified. - if f.Dist <= 0 { - f.Dist = 2 - } } // Set default search distance. if f.Dist <= 0 { - f.Dist = 50 - } else if f.Dist > 5000 { - f.Dist = 5000 + f.Dist = geo.DefaultDist + } else if f.Dist > geo.DistLimit { + f.Dist = geo.DistLimit } // Specify table names and joins. @@ -118,9 +114,11 @@ func searchPhotos(f form.SearchPhotos, sess *entity.Session, resultCols string) s = s.Where("files.photo_uid NOT IN (SELECT photo_uid FROM photos_albums pa WHERE pa.hidden = 1 AND pa.album_uid = ?)", a.AlbumUID) } - // Limit search distance. - if f.Dist <= 0 || f.Dist > 50 { - f.Dist = 50 + // Enforce search distance range (km). + if f.Dist <= 0 { + f.Dist = geo.DefaultDist + } else if f.Dist > geo.ScopeDistLimit { + f.Dist = geo.ScopeDistLimit } } else { f.Scope = "" @@ -668,24 +666,19 @@ func searchPhotos(f form.SearchPhotos, sess *entity.Session, resultCols string) } // Filter by GPS Bounds (Lat N, Lng E, Lat S, Lng W). - if latNorth, lngEast, latSouth, lngWest, parseErr := clean.GPSBounds(f.Latlng); parseErr == nil { - s = s.Where("photos.photo_lat BETWEEN ? AND ?", latSouth, latNorth) - s = s.Where("photos.photo_lng BETWEEN ? AND ?", lngWest, lngEast) + if latN, lngE, latS, lngW, boundsErr := clean.GPSBounds(f.Latlng); boundsErr == nil { + s = s.Where("photos.photo_lat BETWEEN ? AND ?", latS, latN) + s = s.Where("photos.photo_lng BETWEEN ? AND ?", lngW, lngE) } - // Filter by approx distance to coordinates. - if f.Lat != 0 && f.Lat >= -90 && f.Lat <= 90 { - // Latitude (from +90 to -90 degrees). - latNorth := f.Lat + Radius*float32(f.Dist) - latSouth := f.Lat - Radius*float32(f.Dist) - s = s.Where("photos.photo_lat BETWEEN ? AND ?", latSouth, latNorth) + // Filter by GPS Latitude (from +90 to -90 degrees). + if latN, latS, latErr := clean.GPSLatRange(f.Lat, f.Dist); latErr == nil { + s = s.Where("photos.photo_lat BETWEEN ? AND ?", latS, latN) } - if f.Lng != 0 && f.Lng >= -180 && f.Lng <= 180 { - // Longitude (from -180 to +180 degrees). - lngWest := f.Lng - Radius*float32(f.Dist) - lngEast := f.Lng + Radius*float32(f.Dist) - s = s.Where("photos.photo_lng BETWEEN ? AND ?", lngWest, lngEast) + // Filter by GPS Longitude (from -180 to +180 degrees). + if lngE, lngW, lngErr := clean.GPSLngRange(f.Lng, f.Dist); lngErr == nil { + s = s.Where("photos.photo_lng BETWEEN ? AND ?", lngW, lngE) } // Find photos taken before date. diff --git a/internal/search/photos_geo.go b/internal/search/photos_geo.go index d213fa666..98beccfad 100644 --- a/internal/search/photos_geo.go +++ b/internal/search/photos_geo.go @@ -15,6 +15,7 @@ import ( "github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" + "github.com/photoprism/photoprism/pkg/geo" "github.com/photoprism/photoprism/pkg/pluscode" "github.com/photoprism/photoprism/pkg/rnd" "github.com/photoprism/photoprism/pkg/s2" @@ -54,15 +55,15 @@ func UserPhotosGeo(f form.SearchPhotosGeo, sess *entity.Session) (results GeoRes // Set the search distance if unspecified. if f.Dist <= 0 { - f.Dist = 2 + f.Dist = geo.DefaultDist } } // Set default search distance. if f.Dist <= 0 { - f.Dist = 50 - } else if f.Dist > 5000 { - f.Dist = 5000 + f.Dist = geo.DefaultDist + } else if f.Dist > geo.DistLimit { + f.Dist = geo.DistLimit } // Specify table names and joins. @@ -99,9 +100,11 @@ func UserPhotosGeo(f form.SearchPhotosGeo, sess *entity.Session) (results GeoRes s = s.Where("files.photo_uid NOT IN (SELECT photo_uid FROM photos_albums pa WHERE pa.hidden = 1 AND pa.album_uid = ?)", a.AlbumUID) } - // Limit search distance. - if f.Dist <= 0 || f.Dist > 50 { - f.Dist = 50 + // Enforce search distance range (km). + if f.Dist <= 0 { + f.Dist = geo.DefaultDist + } else if f.Dist > geo.ScopeDistLimit { + f.Dist = geo.ScopeDistLimit } } else { f.Scope = "" @@ -533,24 +536,19 @@ func UserPhotosGeo(f form.SearchPhotosGeo, sess *entity.Session) (results GeoRes } // Filter by GPS Bounds (Lat N, Lng E, Lat S, Lng W). - if latNorth, lngEast, latSouth, lngWest, parseErr := clean.GPSBounds(f.Latlng); parseErr == nil { - s = s.Where("photos.photo_lat BETWEEN ? AND ?", latSouth, latNorth) - s = s.Where("photos.photo_lng BETWEEN ? AND ?", lngWest, lngEast) + if latN, lngE, latS, lngW, boundsErr := clean.GPSBounds(f.Latlng); boundsErr == nil { + s = s.Where("photos.photo_lat BETWEEN ? AND ?", latS, latN) + s = s.Where("photos.photo_lng BETWEEN ? AND ?", lngW, lngE) } - // Filter by approx distance to coordinates. - if f.Lat != 0 && f.Lat >= -90 && f.Lat <= 90 { - // Latitude (from +90 to -90 degrees). - latNorth := f.Lat + Radius*float32(f.Dist) - latSouth := f.Lat - Radius*float32(f.Dist) - s = s.Where("photos.photo_lat BETWEEN ? AND ?", latSouth, latNorth) + // Filter by GPS Latitude (from +90 to -90 degrees). + if latN, latS, latErr := clean.GPSLatRange(f.Lat, f.Dist); latErr == nil { + s = s.Where("photos.photo_lat BETWEEN ? AND ?", latS, latN) } - if f.Lng != 0 && f.Lng >= -180 && f.Lng <= 180 { - // Longitude (from -180 to +180 degrees). - lngWest := f.Lng - Radius*float32(f.Dist) - lngEast := f.Lng + Radius*float32(f.Dist) - s = s.Where("photos.photo_lng BETWEEN ? AND ?", lngWest, lngEast) + // Filter by GPS Longitude (from -180 to +180 degrees). + if lngE, lngW, lngErr := clean.GPSLngRange(f.Lng, f.Dist); lngErr == nil { + s = s.Where("photos.photo_lng BETWEEN ? AND ?", lngW, lngE) } // Find photos taken before date. diff --git a/pkg/clean/gps.go b/pkg/clean/gps.go index 0941ea4cd..c3224a12a 100644 --- a/pkg/clean/gps.go +++ b/pkg/clean/gps.go @@ -5,6 +5,7 @@ import ( "math" "strings" + "github.com/photoprism/photoprism/pkg/geo" "github.com/photoprism/photoprism/pkg/txt" ) @@ -79,3 +80,47 @@ func GPSBounds(bounds string) (latN, lngE, latS, lngW float32, err error) { // Return rounded coordinates. return gpsCeil(latNorth), gpsCeil(lngEast), gpsFloor(latSouth), gpsFloor(lngWest), nil } + +// GPSLatRange returns a range based on the specified latitude and distance in km, or an error otherwise. +func GPSLatRange(lat float64, km uint) (latN, latS float32, err error) { + // Latitude (from +90 to -90 degrees). + if lat == 0 || lat < -90 || lat > 90 { + return 0, 0, fmt.Errorf("invalid latitude") + } + + // Approximate range. + latN = gpsCeil(lat + geo.KmToDeg(km)) + latS = gpsFloor(lat - geo.KmToDeg(km)) + + if latN > 90 { + latN = 90 + } + + if latS < -90 { + latS = -90 + } + + return latN, latS, nil +} + +// GPSLngRange returns a range based on the specified longitude and distance in km, or an error otherwise. +func GPSLngRange(lng float64, km uint) (lngE, lngW float32, err error) { + // Longitude (from -180 to +180 degrees). + if lng == 0 || lng < -180 || lng > 180 { + return 0, 0, fmt.Errorf("invalid longitude") + } + + // Approximate range. + lngE = gpsCeil(lng + geo.KmToDeg(km)) + lngW = gpsFloor(lng - geo.KmToDeg(km)) + + if lngE > 180 { + lngE = 180 + } + + if lngW < -180 { + lngW = -180 + } + + return lngE, lngW, nil +} diff --git a/pkg/clean/gps_test.go b/pkg/clean/gps_test.go index c271b3b0f..6d7d93ab5 100644 --- a/pkg/clean/gps_test.go +++ b/pkg/clean/gps_test.go @@ -64,3 +64,33 @@ func TestGPSBounds(t *testing.T) { assert.Error(t, err) }) } + +func TestGPSLatRange(t *testing.T) { + t.Run("Valid", func(t *testing.T) { + latNorth, latSouth, err := GPSLatRange(41.87760543823242, 2) + assert.Equal(t, float32(41.8958), latNorth) + assert.Equal(t, float32(41.8594), 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.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.6434), lngWest) + assert.Equal(t, float32(-87.607), 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) + assert.Error(t, err) + }) +} diff --git a/pkg/geo/dist.go b/pkg/geo/dist.go index edb77f8a9..22c1358fc 100644 --- a/pkg/geo/dist.go +++ b/pkg/geo/dist.go @@ -4,6 +4,17 @@ import ( "math" ) +const ( + DistLimit uint = 5000 + ScopeDistLimit uint = 50 + DefaultDist uint = 2 +) + +// KmToDeg returns the approximate distance in decimal degrees. +func KmToDeg(km uint) float64 { + return 0.009009009 * float64(km) +} + // DegToRad converts a value from degrees to radians. func DegToRad(d float64) float64 { return d * math.Pi / 180 diff --git a/pkg/geo/dist_test.go b/pkg/geo/dist_test.go index 926a31521..0bb626ef5 100644 --- a/pkg/geo/dist_test.go +++ b/pkg/geo/dist_test.go @@ -6,7 +6,13 @@ import ( "github.com/stretchr/testify/assert" ) -func TestDist(t *testing.T) { +func TestKmToDeg(t *testing.T) { + t.Run("10km", func(t *testing.T) { + assert.Equal(t, 0.09009009, KmToDeg(10)) + }) +} + +func TestKm(t *testing.T) { t.Run("BerlinShanghai", func(t *testing.T) { berlin := Position{Name: "Berlin", Lat: 52.5243700, Lng: 13.4105300} shanghai := Position{Name: "Shanghai", Lat: 31.2222200, Lng: 121.4580600} diff --git a/pkg/s2/s2_test.go b/pkg/s2/s2_test.go index be5d116e3..05b73ae4e 100644 --- a/pkg/s2/s2_test.go +++ b/pkg/s2/s2_test.go @@ -106,7 +106,6 @@ func TestLevel(t *testing.T) { t.Run("8000", func(t *testing.T) { assert.Equal(t, 0, Level(8000)) }) - t.Run("150", func(t *testing.T) { assert.Equal(t, 6, Level(150)) })