Search: Refactor "lat", "lng", and "dist" filters #1187 #3558

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2023-09-20 16:56:38 +02:00
parent d5a1526291
commit 4d1003846c
11 changed files with 138 additions and 56 deletions

View file

@ -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)"`

View file

@ -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

View file

@ -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"}

View file

@ -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"}

View file

@ -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.

View file

@ -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.

View file

@ -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
}

View file

@ -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)
})
}

View file

@ -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

View file

@ -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}

View file

@ -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))
})