Metadata: Estimate latitude and longitude if possible #1668

This commit is contained in:
Michael Mayer 2021-11-22 18:18:41 +01:00
parent 21c60dd2fa
commit d813171204
16 changed files with 459 additions and 58 deletions

2
go.mod
View File

@ -33,7 +33,7 @@ require (
github.com/klauspost/cpuid/v2 v2.0.9
github.com/leandro-lugaresi/hub v1.1.1
github.com/leonelquinteros/gotext v1.5.0
github.com/lib/pq v1.3.0 // indirect
github.com/lib/pq v1.8.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0
github.com/manifoldco/promptui v0.9.0
github.com/mattn/go-isatty v0.0.14 // indirect

11
go.sum
View File

@ -151,8 +151,6 @@ github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/open-location-code/go v0.0.0-20211110234603-604ed00fe9d8 h1:+C1yt4bGEM1u3akLWEDqtNhRV28xyCrPscLEgE3NGYc=
github.com/google/open-location-code/go v0.0.0-20211110234603-604ed00fe9d8/go.mod h1:eJfRN6aj+kR/rnua/rw9jAgYhqoMHldQkdTi+sePRKk=
github.com/google/open-location-code/go v0.0.0-20211115190122-6707912175c3 h1:wXfRNEdg7/vPWFXtECTJulGgxygx0xZqlB0g3JMJlXs=
github.com/google/open-location-code/go v0.0.0-20211115190122-6707912175c3/go.mod h1:eJfRN6aj+kR/rnua/rw9jAgYhqoMHldQkdTi+sePRKk=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
@ -210,8 +208,8 @@ github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ic
github.com/leonelquinteros/gotext v1.5.0 h1:ODY7LzLpZWWSJdAHnzhreOr6cwLXTAmc914FOauSkBM=
github.com/leonelquinteros/gotext v1.5.0/go.mod h1:OCiUVHuhP9LGFBQ1oAmdtNCHJCiHiQA8lf4nAifHkr0=
github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU=
github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.8.0 h1:9xohqzkUwzR4Ga4ivdTcawVS89YSDVxXMa3xJX3cGzg=
github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/machinebox/progress v0.2.0/go.mod h1:hl4FywxSjfmkmCrersGhmJH7KwuKl+Ueq9BXkOny+iE=
@ -308,8 +306,6 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa h1:idItI2DDfCokpg0N51B2VtiLdJ4vAuXC9fnCb2gACo4=
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871 h1:/pEO3GD/ABYAjuakUS6xSEmmlyVS4kxBNkeA9tLJiTI=
golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@ -375,8 +371,7 @@ golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211111083644-e5c967477495 h1:cjxxlQm6d4kYbhpZ2ghvmI8xnq0AG+jXmzrhzfkyu5A=
golang.org/x/net v0.0.0-20211111083644-e5c967477495/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211116231205-47ca1ff31462 h1:2vmJlzGKvQ7e/X9XT0XydeWDxmqx8DnegiIMRT+5ssI=
golang.org/x/net v0.0.0-20211116231205-47ca1ff31462/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=

View File

@ -659,7 +659,7 @@ func (m *Photo) SetTakenAt(taken, local time.Time, zone, source string) {
// UpdateTimeZone updates the time zone.
func (m *Photo) UpdateTimeZone(zone string) {
if zone == "" || zone == time.UTC.String() {
if zone == "" || zone == time.UTC.String() || zone == m.TimeZone {
return
}

View File

@ -6,13 +6,14 @@ import (
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/geo"
"github.com/photoprism/photoprism/pkg/txt"
)
// EstimateCountry updates the photo with an estimated country if possible.
func (m *Photo) EstimateCountry() {
if SrcPriority[m.PlaceSrc] > SrcPriority[SrcEstimate] || m.HasLocation() || m.HasPlace() {
// Do nothing.
// Ignore.
return
}
@ -40,14 +41,14 @@ func (m *Photo) EstimateCountry() {
if countryCode != unknown {
m.PhotoCountry = countryCode
m.PlaceSrc = SrcEstimate
log.Debugf("photo: probable country for %s is %s", m, txt.Quote(m.CountryName()))
log.Debugf("estimate: probable country for %s is %s", m, txt.Quote(m.CountryName()))
}
}
// EstimatePlace updates the photo with an estimated place and country if possible.
func (m *Photo) EstimatePlace(force bool) {
if SrcPriority[m.PlaceSrc] > SrcPriority[SrcEstimate] || m.HasLocation() {
// Don't estimate if location is known or set otherwise.
// EstimateLocation updates the photo with an estimated place and country if possible.
func (m *Photo) EstimateLocation(force bool) {
if SrcPriority[m.PlaceSrc] > SrcPriority[SrcEstimate] {
// Ignore if location was set otherwise.
return
} else if force || m.EstimatedAt == nil {
// Proceed.
@ -58,6 +59,7 @@ func (m *Photo) EstimatePlace(force bool) {
// Only estimate country if date isn't known with certainty.
if m.TakenSrc == SrcAuto {
m.RemoveLocation()
m.PlaceID = UnknownPlace.ID
m.PlaceSrc = SrcEstimate
m.EstimateCountry()
@ -67,55 +69,95 @@ func (m *Photo) EstimatePlace(force bool) {
var err error
rangeMin := m.TakenAt.Add(-1 * time.Hour * 72)
rangeMax := m.TakenAt.Add(time.Hour * 72)
rangeMin := m.TakenAt.Add(-1 * time.Hour * 48)
rangeMax := m.TakenAt.Add(time.Hour * 48)
// Find photo with location info taken at a similar time...
var recentPhoto Photo
var mostRecent Photos
switch DbDialect() {
case MySQL:
err = UnscopedDb().
Where("place_id IS NOT NULL AND place_id <> '' AND place_id <> 'zz' AND place_src <> '' AND place_src <> ?", SrcEstimate).
Where("taken_at BETWEEN CAST(? AS DATETIME) AND CAST(? AS DATETIME)", rangeMin, rangeMax).
Order(gorm.Expr("ABS(DATEDIFF(taken_at, ?)) ASC", m.TakenAt)).
Preload("Place").First(&recentPhoto).Error
Where("photo_lat <> 0 AND photo_lng <> 0").
Where("place_src <> '' AND place_src <> ? AND place_id IS NOT NULL AND place_id <> '' AND place_id <> 'zz'", SrcEstimate).
Where("taken_src <> '' AND taken_at BETWEEN CAST(? AS DATETIME) AND CAST(? AS DATETIME)", rangeMin, rangeMax).
Order(gorm.Expr("ABS(DATEDIFF(taken_at, ?)) ASC", m.TakenAt)).Limit(2).
Preload("Place").Find(&mostRecent).Error
case SQLite:
err = UnscopedDb().
Where("place_id IS NOT NULL AND place_id <> '' AND place_id <> 'zz' AND place_src <> '' AND place_src <> ?", SrcEstimate).
Where("taken_at BETWEEN ? AND ?", rangeMin, rangeMax).
Order(gorm.Expr("ABS(JulianDay(taken_at) - JulianDay(?)) ASC", m.TakenAt)).
Preload("Place").First(&recentPhoto).Error
Where("photo_lat <> 0 AND photo_lng <> 0").
Where("place_src <> '' AND place_src <> ? AND place_id IS NOT NULL AND place_id <> '' AND place_id <> 'zz'", SrcEstimate).
Where("taken_src <> '' AND taken_at BETWEEN ? AND ?", rangeMin, rangeMax).
Order(gorm.Expr("ABS(JulianDay(taken_at) - JulianDay(?)) ASC", m.TakenAt)).Limit(2).
Preload("Place").Find(&mostRecent).Error
default:
log.Warnf("photo: unsupported sql dialect %s", txt.Quote(DbDialect()))
log.Warnf("estimate: unsupported sql dialect %s", txt.Quote(DbDialect()))
return
}
// Found?
if err != nil {
log.Debugf("photo: can't estimate place at %s", m.TakenAt)
if err != nil || len(mostRecent) == 0 {
log.Debugf("estimate: unknown position at %s", m.TakenAt)
m.RemoveLocation()
m.EstimateCountry()
} else {
} else if recentPhoto := mostRecent[0]; recentPhoto.HasLocation() && recentPhoto.HasPlace() {
// Too much time difference?
if hours := recentPhoto.TakenAt.Sub(m.TakenAt) / time.Hour; hours < -36 || hours > 36 {
log.Debugf("photo: can't estimate position of %s, %d hours time difference", m, hours)
} else if recentPhoto.HasPlace() {
log.Debugf("estimate: skipping %s, %d hours time difference to recent position", m, hours)
} else if len(mostRecent) == 1 {
m.RemoveLocation()
m.Place = recentPhoto.Place
m.PlaceID = recentPhoto.PlaceID
m.PhotoCountry = recentPhoto.PhotoCountry
m.PlaceSrc = SrcEstimate
m.UpdateTimeZone(recentPhoto.TimeZone)
log.Debugf("photo: approximate position of %s is %s (id %s)", m, txt.Quote(m.CountryName()), recentPhoto.PlaceID)
log.Debugf("estimate: approximate place of %s is %s (id %s)", m, txt.Quote(m.Place.Label()), recentPhoto.PlaceID)
} else if recentPhoto.HasPlace() {
p1 := mostRecent[0]
p2 := mostRecent[1]
m.PlaceSrc = SrcEstimate
movement := geo.NewMovement(p1.Position(), p2.Position(), p1.TakenAt, p2.TakenAt)
if movement.DistKm < 100 {
estimate := movement.Position(m.TakenAt)
m.PhotoLat = float32(estimate.Lat)
m.PhotoLng = float32(estimate.Lng)
log.Debugf("estimate: positioned %s at lat %f, lng %f", m, m.PhotoLat, m.PhotoLng)
m.UpdateLocation()
if m.Place == nil {
log.Warnf("estimate: failed updating position of %s", m)
} else {
log.Debugf("estimate: approximate place of %s is %s (id %s)", m, txt.Quote(m.Place.Label()), m.PlaceID)
}
} else {
m.RemoveLocation()
m.Place = recentPhoto.Place
m.PlaceID = recentPhoto.PlaceID
m.PhotoCountry = recentPhoto.PhotoCountry
m.UpdateTimeZone(recentPhoto.TimeZone)
}
} else if recentPhoto.HasCountry() {
m.RemoveLocation()
m.PhotoCountry = recentPhoto.PhotoCountry
m.PlaceSrc = SrcEstimate
m.UpdateTimeZone(recentPhoto.TimeZone)
log.Debugf("photo: probable country for %s is %s", m, txt.Quote(m.CountryName()))
log.Debugf("estimate: probable country for %s is %s", m, txt.Quote(m.CountryName()))
} else {
m.RemoveLocation()
m.EstimateCountry()
}
} else {
log.Warnf("estimate: %s has no location, uid %s", recentPhoto.PhotoName, recentPhoto.PhotoUID)
m.RemoveLocation()
m.EstimateCountry()
}
m.EstimatedAt = TimePointer()

View File

@ -60,29 +60,34 @@ func TestPhoto_EstimateCountry(t *testing.T) {
}
func TestPhoto_EstimatePlace(t *testing.T) {
func TestPhoto_EstimateLocation(t *testing.T) {
t.Run("photo already has location", func(t *testing.T) {
p := &Place{ID: "1000000001", PlaceCountry: "mx"}
m := Photo{TakenSrc: SrcMeta, PhotoName: "PhotoWithLocation", OriginalName: "demo/xyz.jpg", Place: p, PlaceID: "1000000001", PlaceSrc: SrcManual, PhotoCountry: "mx"}
assert.True(t, m.HasPlace())
assert.Equal(t, "mx", m.CountryCode())
assert.Equal(t, "Mexico", m.CountryName())
m.EstimatePlace(true)
m.EstimateLocation(true)
assert.Equal(t, "mx", m.CountryCode())
assert.Equal(t, "Mexico", m.CountryName())
})
t.Run("RecentlyEstimates", func(t *testing.T) {
m2 := Photo{TakenSrc: SrcMeta, PhotoName: "PhotoWithoutLocation", OriginalName: "demo/xyy.jpg", EstimatedAt: TimePointer(), TakenAt: time.Date(2016, 11, 11, 8, 7, 18, 0, time.UTC)}
assert.Equal(t, UnknownID, m2.CountryCode())
m2.EstimatePlace(false)
m2.EstimateLocation(false)
assert.Equal(t, "zz", m2.CountryCode())
assert.Equal(t, UnknownCountry.CountryName, m2.CountryName())
assert.Equal(t, SrcAuto, m2.PlaceSrc)
})
t.Run("ForceEstimate", func(t *testing.T) {
m2 := Photo{TakenSrc: SrcMeta, PhotoName: "PhotoWithoutLocation", OriginalName: "demo/xyy.jpg", EstimatedAt: TimePointer(), TakenAt: time.Date(2016, 11, 11, 8, 7, 18, 0, time.UTC)}
m2 := Photo{
TakenSrc: SrcMeta,
PhotoName: "PhotoWithoutLocation",
OriginalName: "demo/xyy.jpg",
EstimatedAt: TimePointer(),
TakenAt: time.Date(2016, 11, 11, 8, 7, 18, 0, time.UTC)}
assert.Equal(t, UnknownID, m2.CountryCode())
m2.EstimatePlace(true)
m2.EstimateLocation(true)
assert.Equal(t, "mx", m2.CountryCode())
assert.Equal(t, "Mexico", m2.CountryName())
assert.Equal(t, SrcEstimate, m2.PlaceSrc)
@ -90,7 +95,7 @@ func TestPhoto_EstimatePlace(t *testing.T) {
t.Run("recent photo has place", func(t *testing.T) {
m2 := Photo{TakenSrc: SrcMeta, PhotoName: "PhotoWithoutLocation", OriginalName: "demo/xyy.jpg", TakenAt: time.Date(2016, 11, 11, 8, 7, 18, 0, time.UTC)}
assert.Equal(t, UnknownID, m2.CountryCode())
m2.EstimatePlace(false)
m2.EstimateLocation(false)
assert.Equal(t, "mx", m2.CountryCode())
assert.Equal(t, "Mexico", m2.CountryName())
assert.Equal(t, SrcEstimate, m2.PlaceSrc)
@ -98,7 +103,7 @@ func TestPhoto_EstimatePlace(t *testing.T) {
t.Run("SrcAuto", func(t *testing.T) {
m2 := Photo{TakenSrc: SrcAuto, PhotoName: "PhotoWithoutLocation", OriginalName: "demo/xyy.jpg", TakenAt: time.Date(2016, 11, 11, 8, 7, 18, 0, time.UTC)}
assert.Equal(t, UnknownID, m2.CountryCode())
m2.EstimatePlace(false)
m2.EstimateLocation(false)
assert.Equal(t, "zz", m2.CountryCode())
assert.Equal(t, "Unknown", m2.CountryName())
assert.Equal(t, "zz", m2.PlaceID)
@ -107,13 +112,13 @@ func TestPhoto_EstimatePlace(t *testing.T) {
t.Run("cant estimate - out of scope", func(t *testing.T) {
m2 := Photo{TakenSrc: SrcMeta, PhotoName: "PhotoWithoutLocation", OriginalName: "demo/xyy.jpg", TakenAt: time.Date(2016, 11, 13, 8, 7, 18, 0, time.UTC)}
assert.Equal(t, UnknownID, m2.CountryCode())
m2.EstimatePlace(true)
m2.EstimateLocation(true)
assert.Equal(t, UnknownID, m2.CountryCode())
})
/*t.Run("recent photo has country", func(t *testing.T) {
m2 := Photo{PhotoName: "PhotoWithoutLocation", OriginalName: "demo/zzz.jpg", TakenAt: time.Date(2001, 1, 1, 7, 20, 0, 0, time.UTC)}
assert.Equal(t, UnknownID, m2.CountryCode())
m2.EstimatePlace()
m2.EstimateLocation()
assert.Equal(t, "mx", m2.CountryCode())
assert.Equal(t, "Mexico", m2.CountryName())
assert.Equal(t, SrcEstimate, m2.PlaceSrc)

View File

@ -522,9 +522,9 @@ var PhotoFixtures = PhotoMap{
"Photo08": { // JPG, Indexed, Monochrome, Places meta
ID: 1000008,
PhotoUID: "pt9jtdre2lvl0y15",
TakenAt: time.Date(2016, 11, 11, 9, 7, 18, 0, time.UTC),
TakenAtLocal: time.Date(2016, 11, 11, 9, 7, 18, 0, time.UTC),
TakenSrc: "",
TakenAt: time.Date(2016, 11, 11, 8, 7, 18, 0, time.UTC),
TakenAtLocal: time.Date(2016, 11, 11, 8, 7, 18, 0, time.UTC),
TakenSrc: SrcMeta,
PhotoType: "image",
TypeSrc: "",
PhotoTitle: "Black beach",
@ -581,9 +581,9 @@ var PhotoFixtures = PhotoMap{
"Photo09": { // jpg + jpg, stack sequential name, indexed
ID: 1000009,
PhotoUID: "pt9jtdre2lvl0y16",
TakenAt: time.Date(2016, 11, 11, 9, 7, 18, 0, time.UTC),
TakenAtLocal: time.Date(2016, 11, 11, 9, 7, 18, 0, time.UTC),
TakenSrc: "",
TakenAt: time.Date(2016, 11, 11, 8, 6, 18, 0, time.UTC),
TakenAtLocal: time.Date(2016, 11, 11, 8, 6, 18, 0, time.UTC),
TakenSrc: SrcMeta,
PhotoType: "image",
TypeSrc: "",
PhotoTitle: "Title",
@ -1714,9 +1714,9 @@ var PhotoFixtures = PhotoMap{
PhotoStack: IsStackable,
PhotoFaces: 3,
},
"EstimateTimeZone": {
"PhotoTimeZone": {
ID: 1000028,
PhotoUID: "pr2xmef3ki00x54g", // 2015-05-17T23:02:46Z
PhotoUID: "pr2xmef3ki00x54g",
TakenAt: time.Date(2015, 5, 17, 23, 2, 46, 0, time.UTC),
TakenAtLocal: time.Date(2015, 5, 17, 23, 2, 46, 0, time.UTC),
TakenSrc: SrcMeta,
@ -1780,6 +1780,72 @@ var PhotoFixtures = PhotoMap{
PhotoStack: IsStackable,
PhotoFaces: 0,
},
"VideoTimeZone": {
ID: 1000029,
PhotoUID: "pr2xu7myk7wrbk2u",
TakenAt: time.Date(2015, 5, 17, 17, 48, 46, 0, time.UTC),
TakenAtLocal: time.Date(2015, 5, 17, 17, 48, 46, 0, time.UTC),
TakenSrc: SrcMeta,
PhotoType: "video",
TypeSrc: "",
PhotoTitle: "Estimate / 2015",
TitleSrc: SrcName,
PhotoDescription: "",
DescriptionSrc: "",
PhotoPath: "2015/05",
PhotoName: "Estimate",
OriginalName: "",
PhotoFavorite: false,
PhotoPrivate: false,
PhotoScan: false,
PhotoPanorama: false,
TimeZone: "UTC",
Place: &UnknownPlace,
PlaceID: UnknownPlace.ID,
PlaceSrc: SrcAuto,
Cell: nil,
CellID: UnknownPlace.ID,
CellAccuracy: 0,
PhotoAltitude: 0,
PhotoLat: 0,
PhotoLng: 0,
PhotoCountry: UnknownCountry.ID,
PhotoYear: 2015,
PhotoMonth: 5,
PhotoDay: 17,
PhotoIso: 100,
PhotoExposure: "",
PhotoFNumber: 2.6,
PhotoFocalLength: 3,
PhotoQuality: 3,
PhotoResolution: 0,
Camera: CameraFixtures.Pointer("canon-eos-6d"),
CameraID: CameraFixtures.Pointer("canon-eos-6d").ID,
CameraSerial: "",
CameraSrc: "",
Lens: LensFixtures.Pointer("lens-f-380"),
LensID: LensFixtures.Pointer("lens-f-380").ID,
Details: &Details{
PhotoID: 1000029,
CreatedAt: TimeStamp(),
UpdatedAt: TimeStamp(),
},
Keywords: []Keyword{},
Albums: []Album{},
Files: []File{},
Labels: []PhotoLabel{
LabelFixtures.PhotoLabel(10000018, "landscape", 20, "image"),
LabelFixtures.PhotoLabel(10000018, "likeLabel", 20, "image")},
CreatedAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC),
EditedAt: nil,
CheckedAt: nil,
EstimatedAt: nil,
DeletedAt: nil,
PhotoColor: 12,
PhotoStack: IsStackable,
PhotoFaces: 0,
},
}
// CreatePhotoFixtures inserts known entities into the database for testing.

View File

@ -7,6 +7,7 @@ import (
"github.com/photoprism/photoprism/internal/classify"
"github.com/photoprism/photoprism/internal/maps"
"github.com/photoprism/photoprism/pkg/geo"
"github.com/photoprism/photoprism/pkg/txt"
"gopkg.in/photoprism/go-tz.v2/tz"
)
@ -16,6 +17,14 @@ func (m *Photo) UnknownLocation() bool {
return m.CellID == "" || m.CellID == UnknownLocation.ID || m.NoLatLng()
}
// RemoveLocation removes the current location.
func (m *Photo) RemoveLocation() {
m.PhotoLat = 0
m.PhotoLng = 0
m.Cell = &UnknownLocation
m.CellID = UnknownLocation.ID
}
// HasLocation tests if the photo has a known location.
func (m *Photo) HasLocation() bool {
return !m.UnknownLocation()
@ -94,6 +103,15 @@ func (m *Photo) LoadPlace() error {
return nil
}
// Position returns the coordinates as geo.Position.
func (m *Photo) Position() geo.Position {
if m.NoLatLng() {
return geo.Position{}
}
return geo.Position{Lat: float64(m.PhotoLat), Lng: float64(m.PhotoLng)}
}
// HasLatLng checks if the photo has a latitude and longitude.
func (m *Photo) HasLatLng() bool {
return m.PhotoLat != 0.0 || m.PhotoLng != 0.0

View File

@ -30,7 +30,7 @@ func (m *Photo) Optimize(mergeMeta, mergeUuid, estimatePlace, force bool) (updat
// Estimate if feature is enabled and place wasn't set otherwise.
if estimatePlace && SrcPriority[m.PlaceSrc] <= SrcPriority[SrcEstimate] {
m.EstimatePlace(force)
m.EstimateLocation(force)
}
labels := m.ClassifyLabels()

View File

@ -24,11 +24,11 @@ func (m *Photo) QualityScore() (score int) {
score += 3
}
if m.TakenSrc != SrcAuto {
if SrcPriority[m.TakenSrc] > SrcPriority[SrcEstimate] {
score++
}
if m.HasLatLng() {
if SrcPriority[m.PlaceSrc] > SrcPriority[SrcEstimate] {
score++
}

View File

@ -14,7 +14,7 @@ func TestPhoto_QualityScore(t *testing.T) {
assert.Equal(t, 7, PhotoFixtures.Pointer("Photo01").QualityScore())
})
t.Run("PhotoFixturePhoto06 - taken at after 2012 - resolution 2", func(t *testing.T) {
assert.Equal(t, 4, PhotoFixtures.Pointer("Photo06").QualityScore())
assert.Equal(t, 3, PhotoFixtures.Pointer("Photo06").QualityScore())
})
t.Run("PhotoFixturePhoto07 - score < 3 bit edited", func(t *testing.T) {
assert.Equal(t, 3, PhotoFixtures.Pointer("Photo07").QualityScore())

View File

@ -395,8 +395,8 @@ func TestPhoto_SetTakenAt(t *testing.T) {
}
func TestPhoto_UpdateTimeZone(t *testing.T) {
t.Run("Estimate", func(t *testing.T) {
m := PhotoFixtures.Get("EstimateTimeZone")
t.Run("PhotoTimeZone", func(t *testing.T) {
m := PhotoFixtures.Get("PhotoTimeZone")
takenLocal := time.Date(2015, time.May, 17, 23, 2, 46, 0, time.UTC)
takenJerusalemUtc := time.Date(2015, time.May, 17, 20, 2, 46, 0, time.UTC)
@ -414,6 +414,12 @@ func TestPhoto_UpdateTimeZone(t *testing.T) {
assert.Equal(t, takenJerusalemUtc, m.TakenAt)
assert.Equal(t, takenLocal, m.TakenAtLocal)
m.UpdateTimeZone(zone1)
assert.Equal(t, zone1, m.TimeZone)
assert.Equal(t, takenJerusalemUtc, m.TakenAt)
assert.Equal(t, takenLocal, m.TakenAtLocal)
zone2 := "Asia/Shanghai"
m.UpdateTimeZone(zone2)
@ -430,6 +436,47 @@ func TestPhoto_UpdateTimeZone(t *testing.T) {
assert.Equal(t, takenShanghaiUtc, m.TakenAt)
assert.Equal(t, takenLocal, m.TakenAtLocal)
})
t.Run("VideoTimeZone", func(t *testing.T) {
m := PhotoFixtures.Get("VideoTimeZone")
takenUtc := time.Date(2015, 5, 17, 17, 48, 46, 0, time.UTC)
takenJerusalem := time.Date(2015, time.May, 17, 20, 48, 46, 0, time.UTC)
takenShanghaiUtc := time.Date(2015, time.May, 17, 12, 48, 46, 0, time.UTC)
assert.Equal(t, "UTC", m.TimeZone)
assert.Equal(t, takenUtc, m.TakenAt)
assert.Equal(t, takenUtc, m.TakenAtLocal)
zone1 := "Asia/Jerusalem"
m.UpdateTimeZone(zone1)
assert.Equal(t, zone1, m.TimeZone)
assert.Equal(t, takenUtc, m.TakenAt)
assert.Equal(t, takenJerusalem, m.TakenAtLocal)
m.UpdateTimeZone(zone1)
assert.Equal(t, zone1, m.TimeZone)
assert.Equal(t, takenUtc, m.TakenAt)
assert.Equal(t, takenJerusalem, m.TakenAtLocal)
zone2 := "Asia/Shanghai"
m.UpdateTimeZone(zone2)
assert.Equal(t, zone2, m.TimeZone)
assert.Equal(t, takenShanghaiUtc, m.TakenAt)
assert.Equal(t, takenJerusalem, m.TakenAtLocal)
zone3 := "UTC"
m.UpdateTimeZone(zone3)
assert.Equal(t, zone2, m.TimeZone)
assert.Equal(t, takenShanghaiUtc, m.TakenAt)
assert.Equal(t, takenJerusalem, m.TakenAtLocal)
})
t.Run("UTC", func(t *testing.T) {
m := PhotoFixtures.Get("Photo12")
m.TimeZone = "UTC"

38
pkg/geo/dist.go Normal file
View File

@ -0,0 +1,38 @@
package geo
import (
"math"
)
// Position represents a geo coordinate.
type Position struct {
Lat float64
Lng float64
}
// DegToRad converts a value from degrees to radians.
func DegToRad(d float64) float64 {
return d * math.Pi / 180
}
// Dist returns the shortest path between two positions in km.
func Dist(p, q Position) (km float64) {
if p.Lat == q.Lat && p.Lng == q.Lng {
return 0.0
}
lat1 := DegToRad(p.Lat)
lng1 := DegToRad(p.Lng)
lat2 := DegToRad(q.Lat)
lng2 := DegToRad(q.Lng)
diffLat := lat2 - lat1
diffLng := lng2 - lng1
a := math.Pow(math.Sin(diffLat/2), 2) + math.Cos(lat1)*math.Cos(lat2)*
math.Pow(math.Sin(diffLng/2), 2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return c * EarthRadiusKm
}

18
pkg/geo/dist_test.go Normal file
View File

@ -0,0 +1,18 @@
package geo
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestDist(t *testing.T) {
t.Run("BerlinShanghai", func(t *testing.T) {
berlin := Position{52.5243700, 13.4105300}
shanghai := Position{31.2222200, 121.4580600}
result := Dist(berlin, shanghai)
assert.Equal(t, 8396, int(result))
})
}

36
pkg/geo/geo.go Normal file
View File

@ -0,0 +1,36 @@
/*
Package geo provides earth geometry functions and constants.
Copyright (c) 2018 - 2021 Michael Mayer <hello@photoprism.org>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
PhotoPrism® is a registered trademark of Michael Mayer. You may use it as required
to describe our software, run your own server, for educational purposes, but not for
offering commercial goods, products, or services without prior written permission.
In other words, please ask.
Feel free to send an e-mail to hello@photoprism.org if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
https://docs.photoprism.org/developer-guide/
*/
package geo
const (
EarthRadiusKm = 6371 // Earth radius in km
)

96
pkg/geo/movement.go Normal file
View File

@ -0,0 +1,96 @@
package geo
import (
"math"
"time"
)
// Movement represents a position change in degrees per second.
type Movement struct {
Start Position
StartTime time.Time
End Position
EndTime time.Time
Duration time.Duration
LatDiff float64
LngDiff float64
SpeedKmh float64
DistKm float64
}
// NewMovement returns the movement between two positions and points in time.
func NewMovement(pos1, pos2 Position, time1, time2 time.Time) (m Movement) {
t1 := time1.UTC()
t2 := time2.UTC()
m = Movement{DistKm: Dist(pos1, pos2), Duration: t2.Sub(t1)}
if m.Duration >= 0 {
m.Start = pos1
m.StartTime = time1
m.End = pos2
m.EndTime = time2
m.LatDiff = pos2.Lat - pos1.Lat
m.LngDiff = pos2.Lng - pos1.Lng
} else {
m.Start = pos2
m.StartTime = time2
m.End = pos1
m.EndTime = time1
m.LatDiff = pos1.Lat - pos2.Lat
m.LngDiff = pos1.Lng - pos2.Lng
}
if m.DistKm > 0.001 && m.Seconds() > 1 {
m.SpeedKmh = m.DistKm / m.Hours()
}
return m
}
// Midpoint returns the movement midpoint position.
func (m *Movement) Midpoint() Position {
return Position{
Lat: (m.Start.Lat + m.End.Lat) / 2,
Lng: (m.Start.Lng + m.End.Lng) / 2,
}
}
// Seconds returns the movement duration in seconds.
func (m *Movement) Seconds() float64 {
return math.Abs(m.Duration.Seconds())
}
// Hours returns the movement duration in hours.
func (m *Movement) Hours() float64 {
return math.Abs(m.Duration.Hours())
}
// DegPerSecond returns the position change in degrees per second.
func (m *Movement) DegPerSecond() (latSec, lngSec float64) {
s := m.Seconds()
if s < 1 {
return 0, 0
}
return m.LatDiff / s, m.LngDiff / s
}
// Position returns the absolute position in degrees at a given time.
func (m *Movement) Position(t time.Time) Position {
t = t.UTC()
d := t.Sub(m.StartTime)
s := d.Seconds()
if m.Seconds() < 1 || math.Abs(s) < 1 {
return m.Midpoint()
}
latSec, lngSec := m.DegPerSecond()
return Position{
Lat: m.Start.Lat + latSec*s,
Lng: m.Start.Lng + lngSec*s,
}
}

40
pkg/geo/movement_test.go Normal file
View File

@ -0,0 +1,40 @@
package geo
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestMovement(t *testing.T) {
t.Run("BerlinShanghai", func(t *testing.T) {
berlin := Position{52.5243700, 13.4105300}
shanghai := Position{31.2222200, 121.4580600}
time1 := time.Date(2015, 5, 17, 17, 48, 46, 0, time.UTC)
time2 := time.Date(2015, 5, 17, 23, 14, 34, 0, time.UTC)
result := NewMovement(berlin, shanghai, time1, time2)
assert.Equal(t, 8396, int(result.DistKm))
assert.Equal(t, 19548, int(time2.Sub(time1).Seconds()))
assert.Equal(t, 1546, int(result.SpeedKmh))
assert.Equal(t, -21, int(result.LatDiff))
assert.Equal(t, 108, int(result.LngDiff))
assert.Equal(t, 5, int(result.Hours()))
assert.Equal(t, 19548, int(result.Seconds()))
timeEst := time.Date(2015, 5, 17, 18, 14, 34, 0, time.UTC)
posEst := result.Position(timeEst)
assert.Equal(t, 50, int(posEst.Lat))
assert.Equal(t, 21, int(posEst.Lng))
posMid := result.Midpoint()
assert.Equal(t, 41, int(posMid.Lat))
assert.Equal(t, 67, int(posMid.Lng))
})
}