Search: Improve location search filters and related docs #1187 #3558

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2023-09-20 12:10:49 +02:00
parent ca20be57ce
commit 06d8816a7f
14 changed files with 348 additions and 231 deletions

View file

@ -43,12 +43,13 @@ type SearchPhotos struct {
Private bool `form:"private" notes:"Finds private pictures"`
Favorite string `form:"favorite" example:"favorite:yes" notes:"Finds favorites only"`
Unsorted bool `form:"unsorted" notes:"Finds pictures not in an album"`
Lat float32 `form:"lat" notes:"GPS Position (Latitude)"`
Lng float32 `form:"lng" notes:"GPS Position (Longitude)"`
Dist uint `form:"dist" example:"dist:5" notes:"Distance to GPS Position (km)"`
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)"`
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)"`
S2 string `form:"s2" notes:"S2 Position (Cell ID)"`
Olc string `form:"olc" notes:"Open Location Code (OLC)"`
Fmin float32 `form:"fmin" notes:"F-number (min)"`
Fmax float32 `form:"fmax" notes:"F-number (max)"`
Chroma int16 `form:"chroma" example:"chroma:70" notes:"Chroma (0-100)"`

View file

@ -13,7 +13,6 @@ type SearchPhotosGeo struct {
Filter string `form:"filter" serialize:"-" notes:"-"`
ID string `form:"id" example:"id:123e4567-e89b-..." notes:"Finds pictures by Exif UID, XMP Document ID or Instance ID"`
UID string `form:"uid" example:"uid:pqbcf5j446s0futy" notes:"Limits results to the specified internal unique IDs"`
Near string `form:"near"`
Type string `form:"type"`
Path string `form:"path"`
Folder string `form:"folder"` // Alias for Path
@ -42,12 +41,13 @@ type SearchPhotosGeo struct {
Face string `form:"face" notes:"Face ID, yes, no, new, or kind"`
Faces string `form:"faces"` // Find or exclude faces if detected.
Subject string `form:"subject"`
Lat float32 `form:"lat" notes:"GPS Position (Latitude)"`
Lng float32 `form:"lng" notes:"GPS Position (Longitude)"`
Dist uint `form:"dist" example:"dist:5" notes:"Distance to GPS Position (km)"`
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)"`
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)"`
S2 string `form:"s2" notes:"S2 Position (Cell ID)"`
Olc string `form:"olc" notes:"Open Location Code (OLC)"`
Person string `form:"person"` // Alias for Subject
Subjects string `form:"subjects"` // Text
People string `form:"people"` // Alias for Subjects

View file

@ -9,6 +9,7 @@ import (
var (
ErrForbidden = i18n.Error(i18n.ErrForbidden)
ErrBadRequest = i18n.Error(i18n.ErrBadRequest)
ErrNotFound = i18n.Error(i18n.ErrNotFound)
ErrBadSortOrder = fmt.Errorf("invalid sort order")
ErrBadFilter = fmt.Errorf("invalid search filter")
ErrInvalidId = fmt.Errorf("invalid ID specified")

View file

@ -58,8 +58,31 @@ func searchPhotos(f form.SearchPhotos, sess *entity.Session, resultCols string)
return PhotoResults{}, 0, ErrBadRequest
}
// Size of S2 Cells.
S2Levels := 7
// Find photos near another?
if txt.NotEmpty(f.Near) {
photo := Photo{}
// Find a nearby picture using the UID or return an empty result otherwise.
if err = Db().First(&photo, "photo_uid = ?", f.Near).Error; err != nil {
log.Debugf("search: %s (find nearby)", err)
return PhotoResults{}, 0, ErrNotFound
}
// 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
}
// Specify table names and joins.
s := UnscopedDb().Table(entity.File{}.TableName()).Select(resultCols).
@ -94,6 +117,11 @@ func searchPhotos(f form.SearchPhotos, sess *entity.Session, resultCols string)
f.Filter = a.AlbumFilter
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
}
} else {
f.Scope = ""
}
@ -629,13 +657,13 @@ func searchPhotos(f form.SearchPhotos, sess *entity.Session, resultCols string)
}
// Filter by location code.
if f.S2 != "" {
if txt.NotEmpty(f.S2) {
// S2 Cell ID.
s2Min, s2Max := s2.PrefixedRange(f.S2, S2Levels)
s2Min, s2Max := s2.PrefixedRange(f.S2, s2.Level(f.Dist))
s = s.Where("photos.cell_id BETWEEN ? AND ?", s2Min, s2Max)
} else if f.Olc != "" {
} else if txt.NotEmpty(f.Olc) {
// Open Location Code (OLC).
s2Min, s2Max := s2.PrefixedRange(pluscode.S2(f.Olc), S2Levels)
s2Min, s2Max := s2.PrefixedRange(pluscode.S2(f.Olc), s2.Level(f.Dist))
s = s.Where("photos.cell_id BETWEEN ? AND ?", s2Min, s2Max)
}
@ -646,22 +674,18 @@ func searchPhotos(f form.SearchPhotos, sess *entity.Session, resultCols string)
}
// Filter by approx distance to coordinates.
if f.Dist == 0 {
f.Dist = 20
} else if f.Dist > 5000 {
f.Dist = 5000
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)
}
if f.Lat != 0 {
latNorth := f.Lat - Radius*float32(f.Dist)
latSouth := f.Lat + Radius*float32(f.Dist)
s = s.Where("photos.photo_lat BETWEEN ? AND ?", latNorth, latSouth)
}
if f.Lng != 0 {
lngEast := f.Lng - Radius*float32(f.Dist)
lngWest := f.Lng + Radius*float32(f.Dist)
s = s.Where("photos.photo_lng BETWEEN ? AND ?", lngEast, lngWest)
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)
}
// Find photos taken before date.

View file

@ -39,21 +39,30 @@ func UserPhotosGeo(f form.SearchPhotosGeo, sess *entity.Session) (results GeoRes
return GeoResults{}, ErrBadRequest
}
// Size of S2 Cells.
S2Levels := 7
// Search for nearby photos.
if f.Near != "" {
// Find photos near another?
if txt.NotEmpty(f.Near) {
photo := Photo{}
// Find photo to get location.
// Find a nearby picture using the UID or return an empty result otherwise.
if err = Db().First(&photo, "photo_uid = ?", f.Near).Error; err != nil {
return GeoResults{}, err
log.Debugf("search: %s (find nearby)", err)
return GeoResults{}, ErrNotFound
}
// Set the S2 Cell ID to search for.
f.S2 = photo.CellID
S2Levels = 12
// 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
}
// Specify table names and joins.
@ -90,7 +99,10 @@ 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)
}
S2Levels = 18
// Limit search distance.
if f.Dist <= 0 || f.Dist > 50 {
f.Dist = 50
}
} else {
f.Scope = ""
}
@ -510,13 +522,13 @@ func UserPhotosGeo(f form.SearchPhotosGeo, sess *entity.Session) (results GeoRes
}
// Filter by location code.
if f.S2 != "" {
if txt.NotEmpty(f.S2) {
// S2 Cell ID.
s2Min, s2Max := s2.PrefixedRange(f.S2, S2Levels)
s2Min, s2Max := s2.PrefixedRange(f.S2, s2.Level(f.Dist))
s = s.Where("photos.cell_id BETWEEN ? AND ?", s2Min, s2Max)
} else if f.Olc != "" {
} else if txt.NotEmpty(f.Olc) {
// Open Location Code (OLC).
s2Min, s2Max := s2.PrefixedRange(pluscode.S2(f.Olc), S2Levels)
s2Min, s2Max := s2.PrefixedRange(pluscode.S2(f.Olc), s2.Level(f.Dist))
s = s.Where("photos.cell_id BETWEEN ? AND ?", s2Min, s2Max)
}
@ -527,22 +539,18 @@ func UserPhotosGeo(f form.SearchPhotosGeo, sess *entity.Session) (results GeoRes
}
// Filter by approx distance to coordinates.
if f.Dist == 0 {
f.Dist = 20
} else if f.Dist > 5000 {
f.Dist = 5000
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)
}
if f.Lat != 0 {
latNorth := f.Lat - Radius*float32(f.Dist)
latSouth := f.Lat + Radius*float32(f.Dist)
s = s.Where("photos.photo_lat BETWEEN ? AND ?", latNorth, latSouth)
}
if f.Lng != 0 {
lngEast := f.Lng - Radius*float32(f.Dist)
lngWest := f.Lng + Radius*float32(f.Dist)
s = s.Where("photos.photo_lng BETWEEN ? AND ?", lngEast, lngWest)
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)
}
// Find photos taken before date.

View file

@ -39,7 +39,7 @@ func TestPhotosGeoFilterNear(t *testing.T) {
f.Near = "%gold"
_, err := PhotosGeo(f)
assert.Equal(t, err.Error(), "record not found")
assert.Equal(t, err.Error(), "Not found")
})
t.Run("CenterPercent", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -47,7 +47,7 @@ func TestPhotosGeoFilterNear(t *testing.T) {
f.Near = "I love % dog"
_, err := PhotosGeo(f)
assert.Equal(t, err.Error(), "record not found")
assert.Equal(t, err.Error(), "Not found")
})
//TODO error
/*t.Run("EndsWithPercent", func(t *testing.T) {

View file

@ -13,6 +13,7 @@ func TestPhotosGeoFilterS2(t *testing.T) {
var f form.SearchPhotosGeo
f.S2 = "1ef744d1e283"
f.Dist = 2
photos, err := PhotosGeo(f)
@ -25,6 +26,7 @@ func TestPhotosGeoFilterS2(t *testing.T) {
var f form.SearchPhotosGeo
f.S2 = "85d1ea7d382c"
f.Dist = 2
photos, err := PhotosGeo(f)
@ -37,25 +39,27 @@ func TestPhotosGeoFilterS2(t *testing.T) {
var f form.SearchPhotosGeo
f.S2 = "%gold"
f.Dist = 2
photos, err := PhotosGeo(f)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("CenterPercent", func(t *testing.T) {
var f form.SearchPhotosGeo
f.S2 = "I love % dog"
f.Dist = 2
photos, err := PhotosGeo(f)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("EndsWithPercent", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -67,7 +71,7 @@ func TestPhotosGeoFilterS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("StartsWithAmpersand", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -79,7 +83,7 @@ func TestPhotosGeoFilterS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("CenterAmpersand", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -91,7 +95,7 @@ func TestPhotosGeoFilterS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("EndsWithAmpersand", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -103,7 +107,7 @@ func TestPhotosGeoFilterS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("StartsWithSingleQuote", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -115,7 +119,7 @@ func TestPhotosGeoFilterS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("CenterSingleQuote", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -128,7 +132,7 @@ func TestPhotosGeoFilterS2(t *testing.T) {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("EndsWithSingleQuote", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -140,7 +144,7 @@ func TestPhotosGeoFilterS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("StartsWithAsterisk", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -152,7 +156,7 @@ func TestPhotosGeoFilterS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("CenterAsterisk", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -164,7 +168,7 @@ func TestPhotosGeoFilterS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("EndsWithAsterisk", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -176,7 +180,7 @@ func TestPhotosGeoFilterS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("StartsWithPipe", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -188,7 +192,7 @@ func TestPhotosGeoFilterS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("CenterPipe", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -201,7 +205,7 @@ func TestPhotosGeoFilterS2(t *testing.T) {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("EndsWithPipe", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -214,7 +218,7 @@ func TestPhotosGeoFilterS2(t *testing.T) {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("StartsWithNumber", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -226,7 +230,7 @@ func TestPhotosGeoFilterS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("CenterNumber", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -238,7 +242,7 @@ func TestPhotosGeoFilterS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("EndsWithNumber", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -250,7 +254,7 @@ func TestPhotosGeoFilterS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
}
@ -259,13 +263,14 @@ func TestPhotosGeoQueryS2(t *testing.T) {
var f form.SearchPhotosGeo
f.Query = "s2:1ef744d1e283"
f.Dist = 2
photos, err := PhotosGeo(f)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 3)
assert.Equal(t, 3, len(photos))
})
t.Run("s2:85d1ea7d382c", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -277,7 +282,7 @@ func TestPhotosGeoQueryS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 8)
assert.Equal(t, 8, len(photos))
})
t.Run("85d1ea7d382c pipe 1ef744d1e283", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -289,7 +294,7 @@ func TestPhotosGeoQueryS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("StartsWithPercent", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -301,7 +306,7 @@ func TestPhotosGeoQueryS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("CenterPercent", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -313,7 +318,7 @@ func TestPhotosGeoQueryS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("EndsWithPercent", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -325,7 +330,7 @@ func TestPhotosGeoQueryS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("StartsWithAmpersand", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -337,7 +342,7 @@ func TestPhotosGeoQueryS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("CenterAmpersand", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -349,7 +354,7 @@ func TestPhotosGeoQueryS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("EndsWithAmpersand", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -361,7 +366,7 @@ func TestPhotosGeoQueryS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("StartsWithSingleQuote", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -373,7 +378,7 @@ func TestPhotosGeoQueryS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("CenterSingleQuote", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -386,7 +391,7 @@ func TestPhotosGeoQueryS2(t *testing.T) {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("EndsWithSingleQuote", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -398,7 +403,7 @@ func TestPhotosGeoQueryS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("StartsWithAsterisk", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -410,7 +415,7 @@ func TestPhotosGeoQueryS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("CenterAsterisk", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -422,7 +427,7 @@ func TestPhotosGeoQueryS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("EndsWithAsterisk", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -434,7 +439,7 @@ func TestPhotosGeoQueryS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("StartsWithPipe", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -446,7 +451,7 @@ func TestPhotosGeoQueryS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("CenterPipe", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -459,7 +464,7 @@ func TestPhotosGeoQueryS2(t *testing.T) {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("EndsWithPipe", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -471,7 +476,7 @@ func TestPhotosGeoQueryS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("StartsWithNumber", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -483,7 +488,7 @@ func TestPhotosGeoQueryS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("CenterNumber", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -495,7 +500,7 @@ func TestPhotosGeoQueryS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
t.Run("EndsWithNumber", func(t *testing.T) {
var f form.SearchPhotosGeo
@ -507,6 +512,6 @@ func TestPhotosGeoQueryS2(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 0)
assert.Equal(t, 0, len(photos))
})
}

41
pkg/s2/level.go Normal file
View file

@ -0,0 +1,41 @@
package s2
// DefaultLevel specifies the default S2 cell size.
var DefaultLevel = 21
// Level returns the S2 cell level based on the approximate cell size in km.
// see https://s2geometry.io/resources/s2cell_statistics.html
func Level(km uint) (level int) {
switch {
case km >= 7842:
return 0
case km >= 3921:
return 1
case km >= 1825:
return 2
case km >= 1130:
return 3
case km >= 579:
return 4
case km >= 287:
return 5
case km >= 143:
return 6
case km >= 72:
return 7
case km >= 36:
return 8
case km >= 18:
return 9
case km >= 9:
return 10
case km >= 4:
return 11
case km >= 2:
return 12
case km >= 1:
return 13
default:
return 14
}
}

35
pkg/s2/range.go Normal file
View file

@ -0,0 +1,35 @@
package s2
import gs2 "github.com/golang/geo/s2"
// Range returns a token range to find nearby cells within the specified S2 level.
func Range(token string, level int) (start, end string) {
token = NormalizeToken(token)
cell := gs2.CellIDFromToken(token)
if !cell.IsValid() {
return start, end
}
// See https://s2geometry.io/resources/s2cell_statistics.html
cellLevel := cell.Level()
// Range level must not be greater than the cell level.
if level > cellLevel {
level = cellLevel
}
// Get parent cell ID for the given level.
parentCell := cell.Parent(level)
// Return computed S2 cell token range.
return parentCell.Prev().ChildBeginAtLevel(cellLevel).ToToken(), parentCell.Next().ChildBeginAtLevel(cellLevel).ToToken()
}
// PrefixedRange returns a prefixed token range to find nearby cells within the specified S2 level.
func PrefixedRange(token string, level int) (start, end string) {
start, end = Range(token, level)
return Prefix(start), Prefix(end)
}

View file

@ -1,5 +1,5 @@
/*
Package s2 encapsulates Google's S2 library.
Package s2 provides a geolocation abstraction based on Google's S2 library.
See https://s2geometry.io/
@ -25,74 +25,3 @@ Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
package s2
import (
gs2 "github.com/golang/geo/s2"
)
// DefaultLevel see https://s2geometry.io/resources/s2cell_statistics.html.
var DefaultLevel = 21
// Token returns the S2 cell token for coordinates using the default level.
func Token(lat, lng float64) string {
return TokenLevel(lat, lng, DefaultLevel)
}
// TokenLevel returns the S2 cell token for coordinates.
func TokenLevel(lat, lng float64, level int) string {
if lat == 0.0 && lng == 0.0 {
return ""
}
if lat < -90 || lat > 90 {
return ""
}
if lng < -180 || lng > 180 {
return ""
}
l := gs2.LatLngFromDegrees(lat, lng)
return gs2.CellIDFromLatLng(l).Parent(level).ToToken()
}
// LatLng returns the coordinates for a S2 cell token.
func LatLng(token string) (lat, lng float64) {
token = NormalizeToken(token)
if len(token) < 3 {
return 0.0, 0.0
}
c := gs2.CellIDFromToken(token)
if !c.IsValid() {
return 0.0, 0.0
}
l := c.LatLng()
return l.Lat.Degrees(), l.Lng.Degrees()
}
// IsZero returns true if the coordinates are both empty.
func IsZero(lat, lng float64) bool {
return lat == 0.0 && lng == 0.0
}
// Range returns a token range for finding nearby locations.
func Range(token string, levelUp int) (min, max string) {
token = NormalizeToken(token)
c := gs2.CellIDFromToken(token)
if !c.IsValid() {
return min, max
}
// See https://s2geometry.io/resources/s2cell_statistics.html
lvl := c.Level()
parent := c.Parent(lvl - levelUp)
return parent.Prev().ChildBeginAtLevel(lvl).ToToken(), parent.Next().ChildBeginAtLevel(lvl).ToToken()
}

View file

@ -102,19 +102,32 @@ func TestTokenLevel(t *testing.T) {
})
}
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))
})
t.Run("0", func(t *testing.T) {
assert.Equal(t, 14, Level(0))
})
}
func TestLatLng(t *testing.T) {
t.Run("valid", func(t *testing.T) {
t.Run("Valid", func(t *testing.T) {
lat, lng := LatLng("4799e370ca54c8b9")
assert.Equal(t, 48.56344835921243, lat)
assert.Equal(t, 8.996878323369781, lng)
})
t.Run("invalid", func(t *testing.T) {
t.Run("Invalid", func(t *testing.T) {
lat, lng := LatLng("4799e370ca5q")
assert.Equal(t, 0.0, lat)
assert.Equal(t, 0.0, lng)
})
t.Run("empty", func(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
lat, lng := LatLng("")
assert.Equal(t, 0.0, lat)
assert.Equal(t, 0.0, lng)
@ -122,45 +135,60 @@ func TestLatLng(t *testing.T) {
}
func TestIsZero(t *testing.T) {
t.Run("valid", func(t *testing.T) {
t.Run("Valid", func(t *testing.T) {
lat, lng := LatLng("4799e370ca54c8b9")
assert.False(t, IsZero(lat, lng))
})
t.Run("invalid", func(t *testing.T) {
t.Run("Invalid", func(t *testing.T) {
lat, lng := LatLng("4799e370ca5q")
assert.True(t, IsZero(lat, lng))
})
}
func TestRange(t *testing.T) {
t.Run("valid_1", func(t *testing.T) {
min, max := Range("4799e370ca54c8b9", 1)
assert.Equal(t, "4799e370ca54c8b1", min)
assert.Equal(t, "4799e370ca54c8c1", max)
t.Run("Level1", func(t *testing.T) {
start, end := Range("4799e370ca54c8b9", 1)
assert.Equal(t, "3800000000000001", start)
assert.Equal(t, "4800000000000001", end)
})
t.Run("valid_2", func(t *testing.T) {
min, max := Range("4799e370ca54c8b9", 2)
assert.Equal(t, "4799e370ca54c881", min)
assert.Equal(t, "4799e370ca54c8c1", max)
t.Run("Level2", func(t *testing.T) {
start, end := Range("4799e370ca54c8b9", 2)
assert.Equal(t, "4400000000000001", start)
assert.Equal(t, "4800000000000001", end)
})
t.Run("valid_3", func(t *testing.T) {
min, max := Range("4799e370ca54c8b9", 3)
assert.Equal(t, "4799e370ca54c801", min)
assert.Equal(t, "4799e370ca54c901", max)
t.Run("Level5", func(t *testing.T) {
start, end := Range("4799e370ca54c8b9", 5)
assert.Equal(t, "4790000000000001", start)
assert.Equal(t, "47a0000000000001", end)
})
t.Run("valid_4", func(t *testing.T) {
min, max := Range("4799e370ca54c8b9", 4)
assert.Equal(t, "4799e370ca54c601", min)
assert.Equal(t, "4799e370ca54ca01", max)
t.Run("Level7", func(t *testing.T) {
start, end := Range("4799e370ca54c8b9", 7)
assert.Equal(t, "4799000000000001", start)
assert.Equal(t, "479a000000000001", end)
})
t.Run("valid_5", func(t *testing.T) {
min, max := Range("4799e370ca54c8b9", 5)
assert.Equal(t, "4799e370ca54c001", min)
assert.Equal(t, "4799e370ca54d001", max)
t.Run("Level10", func(t *testing.T) {
start, end := Range("4799e370ca54c8b9", 10)
assert.Equal(t, "4799e00000000001", start)
assert.Equal(t, "4799e40000000001", end)
})
t.Run("invalid", func(t *testing.T) {
min, max := Range("4799e370ca5q", 1)
assert.Equal(t, "", min)
assert.Equal(t, "", max)
t.Run("Level14", func(t *testing.T) {
start, end := Range("4799e370ca54c8b9", 14)
assert.Equal(t, "4799e36e00000001", start)
assert.Equal(t, "4799e37200000001", end)
})
t.Run("Level21", func(t *testing.T) {
start, end := Range("4799e370ca54c8b9", 21)
assert.Equal(t, "4799e370ca480001", start)
assert.Equal(t, "4799e370ca580001", end)
})
t.Run("Level23", func(t *testing.T) {
start, end := Range("4799e370ca54c8b9", 23)
assert.Equal(t, "4799e370ca540001", start)
assert.Equal(t, "4799e370ca550001", end)
})
t.Run("Invalid", func(t *testing.T) {
start, end := Range("4799e370ca5q", 1)
assert.Equal(t, "", start)
assert.Equal(t, "", end)
})
}

52
pkg/s2/token.go Normal file
View file

@ -0,0 +1,52 @@
package s2
import (
gs2 "github.com/golang/geo/s2"
)
// IsZero returns true if the coordinates are both empty.
func IsZero(lat, lng float64) bool {
return lat == 0.0 && lng == 0.0
}
// Token returns the S2 cell token for coordinates using the default level.
func Token(lat, lng float64) string {
return TokenLevel(lat, lng, DefaultLevel)
}
// TokenLevel returns the S2 cell token for coordinates.
func TokenLevel(lat, lng float64, level int) string {
if lat == 0.0 && lng == 0.0 {
return ""
}
if lat < -90 || lat > 90 {
return ""
}
if lng < -180 || lng > 180 {
return ""
}
l := gs2.LatLngFromDegrees(lat, lng)
return gs2.CellIDFromLatLng(l).Parent(level).ToToken()
}
// LatLng returns the coordinates for a S2 cell token.
func LatLng(token string) (lat, lng float64) {
token = NormalizeToken(token)
if len(token) < 3 {
return 0.0, 0.0
}
cell := gs2.CellIDFromToken(token)
if !cell.IsValid() {
return 0.0, 0.0
}
l := cell.LatLng()
return l.Lat.Degrees(), l.Lng.Degrees()
}

View file

@ -35,10 +35,3 @@ func Prefix(token string) string {
func PrefixedToken(lat, lng float64) string {
return Prefix(Token(lat, lng))
}
// PrefixedRange returns a token range for finding nearby locations.
func PrefixedRange(token string, levelUp int) (min, max string) {
min, max = Range(token, levelUp)
return Prefix(min), Prefix(max)
}

View file

@ -78,34 +78,34 @@ func TestPrefixedToken(t *testing.T) {
}
func TestPrefixedRange(t *testing.T) {
t.Run("valid_1", func(t *testing.T) {
min, max := PrefixedRange("4799e370ca54c8b9", 1)
assert.Equal(t, TokenPrefix+"4799e370ca54c8b1", min)
assert.Equal(t, TokenPrefix+"4799e370ca54c8c1", max)
t.Run("Level1", func(t *testing.T) {
start, end := PrefixedRange("4799e370ca54c8b9", 1)
assert.Equal(t, TokenPrefix+"3800000000000001", start)
assert.Equal(t, TokenPrefix+"4800000000000001", end)
})
t.Run("valid_2", func(t *testing.T) {
min, max := PrefixedRange(TokenPrefix+"4799e370ca54c8b9", 2)
assert.Equal(t, TokenPrefix+"4799e370ca54c881", min)
assert.Equal(t, TokenPrefix+"4799e370ca54c8c1", max)
t.Run("Level2", func(t *testing.T) {
start, end := PrefixedRange(TokenPrefix+"4799e370ca54c8b9", 2)
assert.Equal(t, TokenPrefix+"4400000000000001", start)
assert.Equal(t, TokenPrefix+"4800000000000001", end)
})
t.Run("valid_3", func(t *testing.T) {
min, max := PrefixedRange("4799e370ca54c8b9", 3)
assert.Equal(t, TokenPrefix+"4799e370ca54c801", min)
assert.Equal(t, TokenPrefix+"4799e370ca54c901", max)
t.Run("Level3", func(t *testing.T) {
start, end := PrefixedRange("4799e370ca54c8b9", 3)
assert.Equal(t, TokenPrefix+"4700000000000001", start)
assert.Equal(t, TokenPrefix+"4800000000000001", end)
})
t.Run("valid_4", func(t *testing.T) {
min, max := PrefixedRange(TokenPrefix+"4799e370ca54c8b9", 4)
assert.Equal(t, TokenPrefix+"4799e370ca54c601", min)
assert.Equal(t, TokenPrefix+"4799e370ca54ca01", max)
t.Run("Level4", func(t *testing.T) {
start, end := PrefixedRange(TokenPrefix+"4799e370ca54c8b9", 4)
assert.Equal(t, TokenPrefix+"4760000000000001", start)
assert.Equal(t, TokenPrefix+"47a0000000000001", end)
})
t.Run("valid_5", func(t *testing.T) {
min, max := PrefixedRange("4799e370ca54c8b9", 5)
assert.Equal(t, TokenPrefix+"4799e370ca54c001", min)
assert.Equal(t, TokenPrefix+"4799e370ca54d001", max)
t.Run("Level5", func(t *testing.T) {
start, end := PrefixedRange("4799e370ca54c8b9", 5)
assert.Equal(t, TokenPrefix+"4790000000000001", start)
assert.Equal(t, TokenPrefix+"47a0000000000001", end)
})
t.Run("invalid", func(t *testing.T) {
min, max := PrefixedRange("4799e370ca5q", 1)
assert.Equal(t, "", min)
assert.Equal(t, "", max)
t.Run("Invalid", func(t *testing.T) {
start, end := PrefixedRange("4799e370ca5q", 1)
assert.Equal(t, "", start)
assert.Equal(t, "", end)
})
}