Metadata: Estimate latitude and longitude if possible #1668
This commit is contained in:
parent
21c60dd2fa
commit
d813171204
16 changed files with 459 additions and 58 deletions
2
go.mod
2
go.mod
|
@ -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
11
go.sum
|
@ -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=
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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++
|
||||
}
|
||||
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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
38
pkg/geo/dist.go
Normal 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
18
pkg/geo/dist_test.go
Normal 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
36
pkg/geo/geo.go
Normal 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
96
pkg/geo/movement.go
Normal 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
40
pkg/geo/movement_test.go
Normal 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))
|
||||
})
|
||||
}
|
Loading…
Add table
Reference in a new issue