People: Generate photo titles from subject names #22

This commit is contained in:
Michael Mayer 2021-09-02 14:23:40 +02:00
parent 1be409d654
commit 9acd4a25b9
11 changed files with 720 additions and 371 deletions

View file

@ -103,17 +103,12 @@ func UpdateMarker(router *gin.RouterGroup) {
// Update photo metadata.
if p, err := query.PhotoByUID(file.PhotoUID); err != nil {
AbortEntityNotFound(c)
return
log.Errorf("faces: %s (find photo))", err)
} else if err := p.UpdateAndSaveTitle(); err != nil {
log.Errorf("faces: %s (update photo title)", err)
} else {
if faceCount := file.FaceCount(); p.PhotoFaces == faceCount {
// Do nothing.
} else if err := p.Update("PhotoFaces", faceCount); err != nil {
log.Errorf("photo: %s (update face count)", err)
} else {
// Notify clients.
PublishPhotoEvent(EntityUpdated, file.PhotoUID, c)
}
// Notify clients.
PublishPhotoEvent(EntityUpdated, file.PhotoUID, c)
}
event.SuccessMsg(i18n.MsgChangesSaved)
@ -132,7 +127,7 @@ func UpdateMarker(router *gin.RouterGroup) {
// id: int Marker ID as returned by the API
func ClearMarkerSubject(router *gin.RouterGroup) {
router.DELETE("/markers/:marker_uid/subject", func(c *gin.Context) {
_, marker, err := findFileMarker(c)
file, marker, err := findFileMarker(c)
if err != nil {
log.Debugf("api: %s (clear marker subject)", err)
@ -145,6 +140,16 @@ func ClearMarkerSubject(router *gin.RouterGroup) {
return
}
// Update photo metadata.
if p, err := query.PhotoByUID(file.PhotoUID); err != nil {
log.Errorf("faces: %s (find photo))", err)
} else if err := p.UpdateAndSaveTitle(); err != nil {
log.Errorf("faces: %s (update photo title)", err)
} else {
// Notify clients.
PublishPhotoEvent(EntityUpdated, file.PhotoUID, c)
}
event.SuccessMsg(i18n.MsgChangesSaved)
c.JSON(http.StatusOK, marker)

View file

@ -94,12 +94,12 @@ func FirstFileByHash(fileHash string) (File, error) {
}
// PrimaryFile returns the primary file for a photo uid.
func PrimaryFile(photoUID string) (File, error) {
var file File
func PrimaryFile(photoUID string) (*File, error) {
file := File{}
res := Db().Unscoped().First(&file, "file_primary = 1 AND photo_uid = ?", photoUID)
return file, res.Error
return &file, res.Error
}
// BeforeCreate creates a random UID if needed before inserting a new row to the database.

View file

@ -463,16 +463,21 @@ func TestFile_FaceCount(t *testing.T) {
func TestFile_Rename(t *testing.T) {
t.Run("success", func(t *testing.T) {
m := FileFixtures.Get("exampleFileName.jpg")
assert.Equal(t, "2790/07/27900704_070228_D6D51B6C.jpg", m.FileName)
assert.Equal(t, RootOriginals, m.FileRoot)
assert.Equal(t, false, m.FileMissing)
assert.Nil(t, m.DeletedAt)
p := m.RelatedPhoto()
assert.Equal(t, "2790/07", p.PhotoPath)
assert.Equal(t, "27900704_070228_D6D51B6C", p.PhotoName)
m.Rename("x/y/newName.jpg", "newRoot", "x/y", "newBase")
if err := m.Rename("x/y/newName.jpg", "newRoot", "x/y", "newBase"); err != nil {
t.Fatal(err)
}
assert.Equal(t, "x/y/newName.jpg", m.FileName)
assert.Equal(t, "newRoot", m.FileRoot)
assert.Equal(t, false, m.FileMissing)
@ -480,7 +485,10 @@ func TestFile_Rename(t *testing.T) {
assert.Equal(t, "x/y", p.PhotoPath)
assert.Equal(t, "newBase", p.PhotoName)
m.Rename("2790/07/27900704_070228_D6D51B6C.jpg", RootOriginals, "2790/07", "27900704_070228_D6D51B6C")
if err := m.Rename("2790/07/27900704_070228_D6D51B6C.jpg", RootOriginals, "2790/07", "27900704_070228_D6D51B6C"); err != nil {
t.Fatal(err)
}
assert.Equal(t, "2790/07/27900704_070228_D6D51B6C.jpg", m.FileName)
assert.Equal(t, RootOriginals, m.FileRoot)
assert.Equal(t, false, m.FileMissing)
@ -491,13 +499,37 @@ func TestFile_Rename(t *testing.T) {
}
func TestFile_SubjectNames(t *testing.T) {
f := FileFixtures.Get("Video.mp4")
names := f.SubjectNames()
t.Run("Video.jpg", func(t *testing.T) {
m := FileFixtures.Get("Video.jpg")
assert.Len(t, names, 1)
if len(names) != 1 {
t.Fatal("there should be one name")
} else {
assert.Equal(t, "Actress A", names[0])
}
names := m.SubjectNames()
if len(names) != 1 {
t.Errorf("there should be one name: %#v", names)
} else {
assert.Equal(t, "Actor A", names[0])
}
})
t.Run("Video.mp4", func(t *testing.T) {
m := FileFixtures.Get("Video.mp4")
names := m.SubjectNames()
if len(names) != 1 {
t.Errorf("there should be one name: %#v", names)
} else {
assert.Equal(t, "Actress A", names[0])
}
})
t.Run("bridge.jpg", func(t *testing.T) {
m := FileFixtures.Get("bridge.jpg")
names := m.SubjectNames()
if len(names) != 2 {
t.Errorf("two names expected: %#v", names)
} else {
assert.Equal(t, []string{"Corn McCornface", "Jens Mander"}, names)
}
})
}

View file

@ -1,5 +1,7 @@
package entity
import "github.com/photoprism/photoprism/pkg/txt"
type Markers []Marker
// Save stores the markers in the database.
@ -51,7 +53,7 @@ func (m Markers) SubjectNames() (names []string) {
}
}
return names
return txt.UniqueNames(names)
}
// Append adds a marker.

View file

@ -27,3 +27,15 @@ func TestMarkers_FaceCount(t *testing.T) {
assert.Equal(t, 2, m.FaceCount())
}
func TestMarkers_SubjectNames(t *testing.T) {
m1 := MarkerFixtures.Get("1000003-3")
m2 := MarkerFixtures.Get("1000003-4")
m3 := MarkerFixtures.Get("1000003-5")
m1.MarkerInvalid = true
m := Markers{m1, m2, m3}
assert.Equal(t, []string{"Jens Mander", "Corn McCornface"}, m.SubjectNames())
}

View file

@ -14,7 +14,6 @@ import (
"github.com/photoprism/photoprism/internal/classify"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/ulule/deepcopier"
@ -284,7 +283,7 @@ func (m *Photo) Find() error {
return nil
}
// Save the photo to the database.
// SaveLabels updates the photo after labels have changed.
func (m *Photo) SaveLabels() error {
if !m.HasID() {
return errors.New("photo: can't save to database, id is empty")
@ -602,11 +601,6 @@ func (m *Photo) UnknownCountry() bool {
return m.CountryCode() == UnknownCountry.ID
}
// NoTitle checks if the photo has no Title
func (m *Photo) NoTitle() bool {
return m.PhotoTitle == ""
}
// NoCameraSerial checks if the photo has no CameraSerial
func (m *Photo) NoCameraSerial() bool {
return m.CameraSerial == ""
@ -622,11 +616,6 @@ func (m *Photo) UnknownLens() bool {
return m.LensID == 0 || m.LensID == UnknownLens.ID
}
// HasTitle checks if the photo has a title.
func (m *Photo) HasTitle() bool {
return m.PhotoTitle != ""
}
// HasDescription checks if the photo has a description.
func (m *Photo) HasDescription() bool {
return m.PhotoDescription != ""
@ -664,116 +653,6 @@ func (m *Photo) SaveDetails() error {
}
}
// FileTitle returns a photo title based on the file name and/or path.
func (m *Photo) FileTitle() string {
// Generate title based on photo name, if not generated:
if !fs.IsGenerated(m.PhotoName) {
if title := txt.FileTitle(m.PhotoName); title != "" {
return title
}
}
// Generate title based on original file name, if any:
if m.OriginalName != "" {
if title := txt.FileTitle(m.OriginalName); !fs.IsGenerated(m.OriginalName) && title != "" {
return title
} else if title := txt.FileTitle(filepath.Dir(m.OriginalName)); title != "" {
return title
}
}
// Generate title based on photo path, if any:
if m.PhotoPath != "" && !fs.IsGenerated(m.PhotoPath) {
return txt.FileTitle(m.PhotoPath)
}
return ""
}
// UpdateTitle updated the photo title based on location and labels.
func (m *Photo) UpdateTitle(labels classify.Labels) error {
if m.TitleSrc != SrcAuto && m.HasTitle() {
return fmt.Errorf("photo: won't update title, %s was modified", m.PhotoUID)
}
var knownLocation bool
oldTitle := m.PhotoTitle
fileTitle := m.FileTitle()
if m.LocationLoaded() {
knownLocation = true
loc := m.Cell
// TODO: User defined title format
if title := labels.Title(loc.Name()); title != "" {
log.Debugf("photo: using label %s to create title for %s", txt.Quote(title), m.PhotoUID)
if loc.NoCity() || loc.LongCity() || loc.CityContains(title) {
m.SetTitle(fmt.Sprintf("%s / %s / %s", txt.Title(title), loc.CountryName(), m.TakenAt.Format("2006")), SrcAuto)
} else {
m.SetTitle(fmt.Sprintf("%s / %s / %s", txt.Title(title), loc.City(), m.TakenAt.Format("2006")), SrcAuto)
}
} else if loc.Name() != "" && loc.City() != "" {
if len(loc.Name()) > 45 {
m.SetTitle(txt.Title(loc.Name()), SrcAuto)
} else if len(loc.Name()) > 20 || len(loc.City()) > 16 || strings.Contains(loc.Name(), loc.City()) {
m.SetTitle(fmt.Sprintf("%s / %s", loc.Name(), m.TakenAt.Format("2006")), SrcAuto)
} else {
m.SetTitle(fmt.Sprintf("%s / %s / %s", loc.Name(), loc.City(), m.TakenAt.Format("2006")), SrcAuto)
}
} else if loc.City() != "" && loc.CountryName() != "" {
if len(loc.City()) > 20 {
m.SetTitle(fmt.Sprintf("%s / %s", loc.City(), m.TakenAt.Format("2006")), SrcAuto)
} else {
m.SetTitle(fmt.Sprintf("%s / %s / %s", loc.City(), loc.CountryName(), m.TakenAt.Format("2006")), SrcAuto)
}
}
} else if m.PlaceLoaded() {
knownLocation = true
if title := labels.Title(fileTitle); title != "" {
log.Debugf("photo: using label %s to create title for %s", txt.Quote(title), m.PhotoUID)
if m.Place.NoCity() || m.Place.LongCity() || m.Place.CityContains(title) {
m.SetTitle(fmt.Sprintf("%s / %s / %s", txt.Title(title), m.Place.CountryName(), m.TakenAt.Format("2006")), SrcAuto)
} else {
m.SetTitle(fmt.Sprintf("%s / %s / %s", txt.Title(title), m.Place.City(), m.TakenAt.Format("2006")), SrcAuto)
}
} else if m.Place.City() != "" && m.Place.CountryName() != "" {
if len(m.Place.City()) > 20 {
m.SetTitle(fmt.Sprintf("%s / %s", m.Place.City(), m.TakenAt.Format("2006")), SrcAuto)
} else {
m.SetTitle(fmt.Sprintf("%s / %s / %s", m.Place.City(), m.Place.CountryName(), m.TakenAt.Format("2006")), SrcAuto)
}
}
}
if !knownLocation || m.NoTitle() {
if fileTitle == "" && len(labels) > 0 && labels[0].Priority >= -1 && labels[0].Uncertainty <= 85 && labels[0].Name != "" {
if m.TakenSrc != SrcAuto {
m.SetTitle(fmt.Sprintf("%s / %s", txt.Title(labels[0].Name), m.TakenAt.Format("2006")), SrcAuto)
} else {
m.SetTitle(txt.Title(labels[0].Name), SrcAuto)
}
} else if fileTitle != "" && len(fileTitle) <= 20 && !m.TakenAtLocal.IsZero() && m.TakenSrc != SrcAuto {
m.SetTitle(fmt.Sprintf("%s / %s", fileTitle, m.TakenAtLocal.Format("2006")), SrcAuto)
} else if fileTitle != "" {
m.SetTitle(fileTitle, SrcAuto)
} else {
if m.TakenSrc != SrcAuto {
m.SetTitle(fmt.Sprintf("%s / %s", UnknownTitle, m.TakenAt.Format("2006")), SrcAuto)
} else {
m.SetTitle(UnknownTitle, SrcAuto)
}
}
}
if m.PhotoTitle != oldTitle {
log.Debugf("photo: changed title of %s to %s", m.PhotoUID, txt.Quote(m.PhotoTitle))
}
return nil
}
// AddLabels updates the entity with additional or updated label information.
func (m *Photo) AddLabels(labels classify.Labels) {
for _, classifyLabel := range labels {
@ -813,22 +692,6 @@ func (m *Photo) AddLabels(labels classify.Labels) {
Db().Set("gorm:auto_preload", true).Model(m).Related(&m.Labels)
}
// SetTitle changes the photo title and clips it to 300 characters.
func (m *Photo) SetTitle(title, source string) {
newTitle := txt.Clip(title, txt.ClipDefault)
if newTitle == "" {
return
}
if (SrcPriority[source] < SrcPriority[m.TitleSrc]) && m.HasTitle() {
return
}
m.PhotoTitle = newTitle
m.TitleSrc = source
}
// SetDescription changes the photo description if not empty and from the same source.
func (m *Photo) SetDescription(desc, source string) {
newDesc := txt.Clip(desc, txt.ClipDescription)
@ -1159,7 +1022,7 @@ func (m *Photo) Links() Links {
}
// PrimaryFile returns the primary file for this photo.
func (m *Photo) PrimaryFile() (File, error) {
func (m *Photo) PrimaryFile() (*File, error) {
return PrimaryFile(m.PhotoUID)
}
@ -1211,3 +1074,12 @@ func (m *Photo) SetCameraSerial(s string) {
m.CameraSerial = val
}
}
// FaceCount returns the current number of faces on the primary picture.
func (m *Photo) FaceCount() int {
if f, err := m.PrimaryFile(); err != nil {
return 0
} else {
return f.FaceCount()
}
}

View file

@ -261,28 +261,6 @@ func TestPhoto_HasPlace(t *testing.T) {
})
}
func TestPhoto_HasTitle(t *testing.T) {
t.Run("false", func(t *testing.T) {
m := PhotoFixtures.Get("Photo03")
assert.False(t, m.HasTitle())
})
t.Run("true", func(t *testing.T) {
m := PhotoFixtures.Get("Photo04")
assert.True(t, m.HasTitle())
})
}
func TestPhoto_NoTitle(t *testing.T) {
t.Run("true", func(t *testing.T) {
m := PhotoFixtures.Get("Photo03")
assert.True(t, m.NoTitle())
})
t.Run("false", func(t *testing.T) {
m := PhotoFixtures.Get("Photo04")
assert.False(t, m.NoTitle())
})
}
func TestPhoto_NoCameraSerial(t *testing.T) {
t.Run("true", func(t *testing.T) {
m := PhotoFixtures.Get("Photo04")
@ -331,170 +309,6 @@ func TestPhoto_GetDetails(t *testing.T) {
})
}
func TestPhoto_FileTitle(t *testing.T) {
t.Run("non-latin", func(t *testing.T) {
photo := Photo{PhotoName: "桥", PhotoPath: "", OriginalName: ""}
result := photo.FileTitle()
assert.Equal(t, "桥", result)
})
t.Run("changing-of-the-guard--buckingham-palace_7925318070_o.jpg", func(t *testing.T) {
photo := Photo{PhotoName: "20200102_194030_9EFA9E5E", PhotoPath: "2000/05", OriginalName: "flickr import/changing-of-the-guard--buckingham-palace_7925318070_o.jpg"}
result := photo.FileTitle()
assert.Equal(t, "Changing of the Guard / Buckingham Palace", result)
})
t.Run("empty title", func(t *testing.T) {
photo := Photo{PhotoName: "", PhotoPath: "", OriginalName: ""}
result := photo.FileTitle()
assert.Equal(t, "", result)
})
t.Run("return title", func(t *testing.T) {
photo := Photo{PhotoName: "sun, beach, fun", PhotoPath: "", OriginalName: "", PhotoTitle: ""}
result := photo.FileTitle()
assert.Equal(t, "Sun, Beach, Fun", result)
})
t.Run("return title", func(t *testing.T) {
photo := Photo{PhotoName: "", PhotoPath: "vacation", OriginalName: "20200102_194030_9EFA9E5E", PhotoTitle: ""}
result := photo.FileTitle()
assert.Equal(t, "Vacation", result)
})
}
func TestPhoto_UpdateTitle(t *testing.T) {
t.Run("wont update title was modified", func(t *testing.T) {
m := PhotoFixtures.Get("Photo08")
classifyLabels := &classify.Labels{}
assert.Equal(t, "Black beach", m.PhotoTitle)
err := m.UpdateTitle(*classifyLabels)
if err == nil {
t.Fatal()
}
assert.Equal(t, "Black beach", m.PhotoTitle)
})
t.Run("photo with location without city and label", func(t *testing.T) {
m := PhotoFixtures.Get("Photo10")
classifyLabels := &classify.Labels{{Name: "tree", Uncertainty: 30, Source: "manual", Priority: 5, Categories: []string{"plant"}}}
assert.Equal(t, "Title", m.PhotoTitle)
err := m.UpdateTitle(*classifyLabels)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "Tree / Germany / 2016", m.PhotoTitle)
})
t.Run("photo with location and short city and label", func(t *testing.T) {
m := PhotoFixtures.Get("Photo09")
classifyLabels := &classify.Labels{{Name: "tree", Uncertainty: 30, Source: "manual", Priority: 5, Categories: []string{"plant"}}}
assert.Equal(t, "Title", m.PhotoTitle)
err := m.UpdateTitle(*classifyLabels)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "Tree / Teotihuacán / 2016", m.PhotoTitle)
})
t.Run("photo with location and locname >45", func(t *testing.T) {
m := PhotoFixtures.Get("Photo13")
classifyLabels := &classify.Labels{}
assert.Equal(t, "Title", m.PhotoTitle)
err := m.UpdateTitle(*classifyLabels)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "LonglonglonglonglonglonglonglonglonglonglonglonglongName", m.PhotoTitle)
})
t.Run("photo with location and locname >20", func(t *testing.T) {
m := PhotoFixtures.Get("Photo14")
classifyLabels := &classify.Labels{}
assert.Equal(t, "Title", m.PhotoTitle)
err := m.UpdateTitle(*classifyLabels)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "longlonglonglonglonglongName / 2016", m.PhotoTitle)
})
t.Run("photo with location and short city", func(t *testing.T) {
m := PhotoFixtures.Get("Photo09")
classifyLabels := &classify.Labels{}
assert.Equal(t, "Title", m.PhotoTitle)
err := m.UpdateTitle(*classifyLabels)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "Adosada Platform / Teotihuacán / 2016", m.PhotoTitle)
})
t.Run("photo with location without city", func(t *testing.T) {
m := PhotoFixtures.Get("Photo10")
classifyLabels := &classify.Labels{}
assert.Equal(t, "Title", m.PhotoTitle)
err := m.UpdateTitle(*classifyLabels)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "Holiday Park / Germany / 2016", m.PhotoTitle)
})
t.Run("photo with location without loc name and long city", func(t *testing.T) {
m := PhotoFixtures.Get("Photo11")
classifyLabels := &classify.Labels{}
assert.Equal(t, "Title", m.PhotoTitle)
err := m.UpdateTitle(*classifyLabels)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "longlonglonglonglongcity / 2016", m.PhotoTitle)
})
t.Run("photo with location without loc name and short city", func(t *testing.T) {
m := PhotoFixtures.Get("Photo12")
classifyLabels := &classify.Labels{}
assert.Equal(t, "Title", m.PhotoTitle)
err := m.UpdateTitle(*classifyLabels)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "shortcity / Germany / 2016", m.PhotoTitle)
})
t.Run("no location original name", func(t *testing.T) {
m := PhotoFixtures.Get("19800101_000002_D640C559")
classifyLabels := &classify.Labels{{Name: "classify", Uncertainty: 30, Source: SrcManual, Priority: 5, Categories: []string{"flower", "plant"}}}
assert.Equal(t, "Lake / 2790", m.PhotoTitle)
err := m.UpdateTitle(*classifyLabels)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "Examplefilenameoriginal", m.PhotoTitle)
})
t.Run("no location", func(t *testing.T) {
m := PhotoFixtures.Get("Photo01")
classifyLabels := &classify.Labels{{Name: "classify", Uncertainty: 30, Source: SrcManual, Priority: 5, Categories: []string{"flower", "plant"}}}
assert.Equal(t, "", m.PhotoTitle)
err := m.UpdateTitle(*classifyLabels)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "Classify / Germany / 2006", m.PhotoTitle)
})
t.Run("no location no labels", func(t *testing.T) {
m := PhotoFixtures.Get("Photo02")
classifyLabels := &classify.Labels{}
assert.Equal(t, "", m.PhotoTitle)
err := m.UpdateTitle(*classifyLabels)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "Bridge / 1990", m.PhotoTitle)
})
t.Run("no location no labels no takenAt", func(t *testing.T) {
m := PhotoFixtures.Get("Photo20")
classifyLabels := &classify.Labels{}
assert.Equal(t, "", m.PhotoTitle)
err := m.UpdateTitle(*classifyLabels)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "Unknown", m.PhotoTitle)
})
}
func TestPhoto_AddLabels(t *testing.T) {
t.Run("add label", func(t *testing.T) {
m := PhotoFixtures.Get("19800101_000002_D640C559")
@ -516,27 +330,6 @@ func TestPhoto_AddLabels(t *testing.T) {
})
}
func TestPhoto_SetTitle(t *testing.T) {
t.Run("empty title", func(t *testing.T) {
m := PhotoFixtures.Get("Photo15")
assert.Equal(t, "TitleToBeSet", m.PhotoTitle)
m.SetTitle("", SrcManual)
assert.Equal(t, "TitleToBeSet", m.PhotoTitle)
})
t.Run("title not from the same source", func(t *testing.T) {
m := PhotoFixtures.Get("Photo15")
assert.Equal(t, "TitleToBeSet", m.PhotoTitle)
m.SetTitle("NewTitleSet", SrcAuto)
assert.Equal(t, "TitleToBeSet", m.PhotoTitle)
})
t.Run("success", func(t *testing.T) {
m := PhotoFixtures.Get("Photo15")
assert.Equal(t, "TitleToBeSet", m.PhotoTitle)
m.SetTitle("NewTitleSet", SrcName)
assert.Equal(t, "NewTitleSet", m.PhotoTitle)
})
}
func TestPhoto_SetDescription(t *testing.T) {
t.Run("empty description", func(t *testing.T) {
m := PhotoFixtures.Get("Photo15")

View file

@ -0,0 +1,246 @@
package entity
import (
"fmt"
"path/filepath"
"strings"
"github.com/photoprism/photoprism/internal/classify"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
)
// HasTitle checks if the photo has a title.
func (m *Photo) HasTitle() bool {
return m.PhotoTitle != ""
}
// NoTitle checks if the photo has no Title
func (m *Photo) NoTitle() bool {
return m.PhotoTitle == ""
}
// SetTitle changes the photo title and clips it to 300 characters.
func (m *Photo) SetTitle(title, source string) {
newTitle := txt.Clip(title, txt.ClipDefault)
if newTitle == "" {
return
}
if (SrcPriority[source] < SrcPriority[m.TitleSrc]) && m.HasTitle() {
return
}
m.PhotoTitle = newTitle
m.TitleSrc = source
}
// UpdateTitle updated the photo title based on location and labels.
func (m *Photo) UpdateTitle(labels classify.Labels) error {
if m.TitleSrc != SrcAuto && m.HasTitle() {
return fmt.Errorf("photo: won't update title, %s was modified", m.PhotoUID)
}
var names string
var knownLocation bool
oldTitle := m.PhotoTitle
fileTitle := m.FileTitle()
people := m.SubjectNames()
m.UpdateDescription(people)
if n := len(people); n > 0 && n < 4 {
names = txt.JoinNames(people)
}
if m.LocationLoaded() {
knownLocation = true
loc := m.Cell
// TODO: User defined title format
if names != "" {
log.Debugf("photo: using %s to create title for %s", txt.Quote(names), m.PhotoUID)
if l := len([]rune(names)); l > 35 {
m.SetTitle(names, SrcAuto)
} else if l > 20 && (loc.NoCity() || loc.LongCity()) {
m.SetTitle(fmt.Sprintf("%s / %s", names, m.TakenAt.Format("2006")), SrcAuto)
} else if l > 20 {
m.SetTitle(fmt.Sprintf("%s / %s", names, loc.City()), SrcAuto)
} else if loc.NoCity() || loc.LongCity() {
m.SetTitle(fmt.Sprintf("%s / %s / %s", names, loc.CountryName(), m.TakenAt.Format("2006")), SrcAuto)
} else {
m.SetTitle(fmt.Sprintf("%s / %s / %s", names, loc.City(), m.TakenAt.Format("2006")), SrcAuto)
}
} else if title := labels.Title(loc.Name()); title != "" {
log.Debugf("photo: using label %s to create title for %s", txt.Quote(title), m.PhotoUID)
if loc.NoCity() || loc.LongCity() || loc.CityContains(title) {
m.SetTitle(fmt.Sprintf("%s / %s / %s", txt.Title(title), loc.CountryName(), m.TakenAt.Format("2006")), SrcAuto)
} else {
m.SetTitle(fmt.Sprintf("%s / %s / %s", txt.Title(title), loc.City(), m.TakenAt.Format("2006")), SrcAuto)
}
} else if loc.Name() != "" && loc.City() != "" {
if len(loc.Name()) > 45 {
m.SetTitle(txt.Title(loc.Name()), SrcAuto)
} else if len(loc.Name()) > 20 || len(loc.City()) > 16 || strings.Contains(loc.Name(), loc.City()) {
m.SetTitle(fmt.Sprintf("%s / %s", loc.Name(), m.TakenAt.Format("2006")), SrcAuto)
} else {
m.SetTitle(fmt.Sprintf("%s / %s / %s", loc.Name(), loc.City(), m.TakenAt.Format("2006")), SrcAuto)
}
} else if loc.City() != "" && loc.CountryName() != "" {
if len(loc.City()) > 20 {
m.SetTitle(fmt.Sprintf("%s / %s", loc.City(), m.TakenAt.Format("2006")), SrcAuto)
} else {
m.SetTitle(fmt.Sprintf("%s / %s / %s", loc.City(), loc.CountryName(), m.TakenAt.Format("2006")), SrcAuto)
}
}
} else if m.PlaceLoaded() {
knownLocation = true
if names != "" {
log.Debugf("photo: using %s to create title for %s", txt.Quote(names), m.PhotoUID)
if l := len([]rune(names)); l > 35 {
m.SetTitle(names, SrcAuto)
} else if l > 20 && (m.Place.NoCity() || m.Place.LongCity()) {
m.SetTitle(fmt.Sprintf("%s / %s", names, m.TakenAt.Format("2006")), SrcAuto)
} else if l > 20 {
m.SetTitle(fmt.Sprintf("%s / %s", names, m.Place.City()), SrcAuto)
} else if m.Place.NoCity() || m.Place.LongCity() {
m.SetTitle(fmt.Sprintf("%s / %s / %s", names, m.Place.CountryName(), m.TakenAt.Format("2006")), SrcAuto)
} else {
m.SetTitle(fmt.Sprintf("%s / %s / %s", names, m.Place.City(), m.TakenAt.Format("2006")), SrcAuto)
}
} else if title := labels.Title(fileTitle); title != "" {
log.Debugf("photo: using label %s to create title for %s", txt.Quote(title), m.PhotoUID)
if m.Place.NoCity() || m.Place.LongCity() || m.Place.CityContains(title) {
m.SetTitle(fmt.Sprintf("%s / %s / %s", txt.Title(title), m.Place.CountryName(), m.TakenAt.Format("2006")), SrcAuto)
} else {
m.SetTitle(fmt.Sprintf("%s / %s / %s", txt.Title(title), m.Place.City(), m.TakenAt.Format("2006")), SrcAuto)
}
} else if m.Place.City() != "" && m.Place.CountryName() != "" {
if len(m.Place.City()) > 20 {
m.SetTitle(fmt.Sprintf("%s / %s", m.Place.City(), m.TakenAt.Format("2006")), SrcAuto)
} else {
m.SetTitle(fmt.Sprintf("%s / %s / %s", m.Place.City(), m.Place.CountryName(), m.TakenAt.Format("2006")), SrcAuto)
}
}
}
if !knownLocation || m.NoTitle() {
if names != "" {
if len([]rune(names)) <= 35 && m.TakenSrc != SrcAuto {
m.SetTitle(fmt.Sprintf("%s / %s", names, m.TakenAt.Format("2006")), SrcAuto)
} else {
m.SetTitle(names, SrcAuto)
}
} else if fileTitle == "" && len(labels) > 0 && labels[0].Priority >= -1 && labels[0].Uncertainty <= 85 && labels[0].Name != "" {
if m.TakenSrc != SrcAuto {
m.SetTitle(fmt.Sprintf("%s / %s", txt.Title(labels[0].Name), m.TakenAt.Format("2006")), SrcAuto)
} else {
m.SetTitle(txt.Title(labels[0].Name), SrcAuto)
}
} else if fileTitle != "" && len(fileTitle) <= 20 && !m.TakenAtLocal.IsZero() && m.TakenSrc != SrcAuto {
m.SetTitle(fmt.Sprintf("%s / %s", fileTitle, m.TakenAtLocal.Format("2006")), SrcAuto)
} else if fileTitle != "" {
m.SetTitle(fileTitle, SrcAuto)
} else {
if m.TakenSrc != SrcAuto {
m.SetTitle(fmt.Sprintf("%s / %s", UnknownTitle, m.TakenAt.Format("2006")), SrcAuto)
} else {
m.SetTitle(UnknownTitle, SrcAuto)
}
}
}
if m.PhotoTitle != oldTitle {
log.Debugf("photo: changed title of %s to %s", m.PhotoUID, txt.Quote(m.PhotoTitle))
}
return nil
}
// UpdateAndSaveTitle updates the photo title and saves it.
func (m *Photo) UpdateAndSaveTitle() error {
if !m.HasID() {
return fmt.Errorf("photo: can't save to database, id is empty")
}
m.PhotoFaces = m.FaceCount()
labels := m.ClassifyLabels()
m.UpdateDateFields()
if err := m.UpdateTitle(labels); err != nil {
log.Info(err)
}
details := m.GetDetails()
w := txt.UniqueWords(txt.Words(details.Keywords))
w = append(w, labels.Keywords()...)
details.Keywords = strings.Join(txt.UniqueWords(w), ", ")
if err := m.IndexKeywords(); err != nil {
log.Errorf("photo: %s", err.Error())
}
if err := m.Save(); err != nil {
return err
}
return nil
}
// UpdateDescription updates the photo descriptions based on available metadata.
func (m *Photo) UpdateDescription(people []string) {
if m.DescriptionSrc != SrcAuto {
return
}
// Add subject names to description when there's more than one person.
if len(people) > 3 {
m.PhotoDescription = txt.JoinNames(people)
} else {
m.PhotoDescription = ""
}
}
// FileTitle returns a photo title based on the file name and/or path.
func (m *Photo) FileTitle() string {
// Generate title based on photo name, if not generated:
if !fs.IsGenerated(m.PhotoName) {
if title := txt.FileTitle(m.PhotoName); title != "" {
return title
}
}
// Generate title based on original file name, if any:
if m.OriginalName != "" {
if title := txt.FileTitle(m.OriginalName); !fs.IsGenerated(m.OriginalName) && title != "" {
return title
} else if title := txt.FileTitle(filepath.Dir(m.OriginalName)); title != "" {
return title
}
}
// Generate title based on photo path, if any:
if m.PhotoPath != "" && !fs.IsGenerated(m.PhotoPath) {
return txt.FileTitle(m.PhotoPath)
}
return ""
}
// SubjectNames returns all known subject names.
func (m *Photo) SubjectNames() []string {
if f, err := m.PrimaryFile(); err == nil {
return f.SubjectNames()
}
return nil
}

View file

@ -0,0 +1,305 @@
package entity
import (
"testing"
"github.com/photoprism/photoprism/internal/classify"
"github.com/stretchr/testify/assert"
)
func TestPhoto_HasTitle(t *testing.T) {
t.Run("false", func(t *testing.T) {
m := PhotoFixtures.Get("Photo03")
assert.False(t, m.HasTitle())
})
t.Run("true", func(t *testing.T) {
m := PhotoFixtures.Get("Photo04")
assert.True(t, m.HasTitle())
})
}
func TestPhoto_NoTitle(t *testing.T) {
t.Run("true", func(t *testing.T) {
m := PhotoFixtures.Get("Photo03")
assert.True(t, m.NoTitle())
})
t.Run("false", func(t *testing.T) {
m := PhotoFixtures.Get("Photo04")
assert.False(t, m.NoTitle())
})
}
func TestPhoto_SetTitle(t *testing.T) {
t.Run("empty title", func(t *testing.T) {
m := PhotoFixtures.Get("Photo15")
assert.Equal(t, "TitleToBeSet", m.PhotoTitle)
m.SetTitle("", SrcManual)
assert.Equal(t, "TitleToBeSet", m.PhotoTitle)
})
t.Run("title not from the same source", func(t *testing.T) {
m := PhotoFixtures.Get("Photo15")
assert.Equal(t, "TitleToBeSet", m.PhotoTitle)
m.SetTitle("NewTitleSet", SrcAuto)
assert.Equal(t, "TitleToBeSet", m.PhotoTitle)
})
t.Run("success", func(t *testing.T) {
m := PhotoFixtures.Get("Photo15")
assert.Equal(t, "TitleToBeSet", m.PhotoTitle)
m.SetTitle("NewTitleSet", SrcName)
assert.Equal(t, "NewTitleSet", m.PhotoTitle)
})
}
func TestPhoto_UpdateTitle(t *testing.T) {
t.Run("wont update title was modified", func(t *testing.T) {
m := PhotoFixtures.Get("Photo08")
classifyLabels := &classify.Labels{}
assert.Equal(t, "Black beach", m.PhotoTitle)
err := m.UpdateTitle(*classifyLabels)
if err == nil {
t.Fatal()
}
assert.Equal(t, "Black beach", m.PhotoTitle)
})
t.Run("photo with location without city and label", func(t *testing.T) {
m := PhotoFixtures.Get("Photo10")
classifyLabels := &classify.Labels{{Name: "tree", Uncertainty: 30, Source: "manual", Priority: 5, Categories: []string{"plant"}}}
assert.Equal(t, "Title", m.PhotoTitle)
err := m.UpdateTitle(*classifyLabels)
if err != nil {
t.Fatal(err)
}
// TODO: Unstable
if len(m.SubjectNames()) > 0 {
assert.Equal(t, "Actor A / Germany / 2016", m.PhotoTitle)
} else {
assert.Equal(t, "Tree / Germany / 2016", m.PhotoTitle)
}
})
t.Run("photo with location and short city and label", func(t *testing.T) {
m := PhotoFixtures.Get("Photo09")
classifyLabels := &classify.Labels{{Name: "tree", Uncertainty: 30, Source: "manual", Priority: 5, Categories: []string{"plant"}}}
assert.Equal(t, "Title", m.PhotoTitle)
err := m.UpdateTitle(*classifyLabels)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "Tree / Teotihuacán / 2016", m.PhotoTitle)
})
t.Run("photo with location and locname >45", func(t *testing.T) {
m := PhotoFixtures.Get("Photo13")
classifyLabels := &classify.Labels{}
assert.Equal(t, "Title", m.PhotoTitle)
err := m.UpdateTitle(*classifyLabels)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "LonglonglonglonglonglonglonglonglonglonglonglonglongName", m.PhotoTitle)
})
t.Run("photo with location and locname >20", func(t *testing.T) {
m := PhotoFixtures.Get("Photo14")
classifyLabels := &classify.Labels{}
assert.Equal(t, "Title", m.PhotoTitle)
err := m.UpdateTitle(*classifyLabels)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "longlonglonglonglonglongName / 2016", m.PhotoTitle)
})
t.Run("photo with location and short city", func(t *testing.T) {
m := PhotoFixtures.Get("Photo09")
classifyLabels := &classify.Labels{}
assert.Equal(t, "Title", m.PhotoTitle)
err := m.UpdateTitle(*classifyLabels)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "Adosada Platform / Teotihuacán / 2016", m.PhotoTitle)
})
t.Run("photo with location without city", func(t *testing.T) {
m := PhotoFixtures.Get("Photo10")
classifyLabels := &classify.Labels{}
assert.Equal(t, "Title", m.PhotoTitle)
err := m.UpdateTitle(*classifyLabels)
if err != nil {
t.Fatal(err)
}
// TODO: Unstable
if len(m.SubjectNames()) > 0 {
assert.Equal(t, "Actor A / Germany / 2016", m.PhotoTitle)
} else {
assert.Equal(t, "Holiday Park / Germany / 2016", m.PhotoTitle)
}
})
t.Run("photo with location without loc name and long city", func(t *testing.T) {
m := PhotoFixtures.Get("Photo11")
classifyLabels := &classify.Labels{}
assert.Equal(t, "Title", m.PhotoTitle)
err := m.UpdateTitle(*classifyLabels)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "longlonglonglonglongcity / 2016", m.PhotoTitle)
})
t.Run("photo with location without loc name and short city", func(t *testing.T) {
m := PhotoFixtures.Get("Photo12")
classifyLabels := &classify.Labels{}
assert.Equal(t, "Title", m.PhotoTitle)
err := m.UpdateTitle(*classifyLabels)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "shortcity / Germany / 2016", m.PhotoTitle)
})
t.Run("no location original name", func(t *testing.T) {
m := PhotoFixtures.Get("19800101_000002_D640C559")
classifyLabels := &classify.Labels{{Name: "classify", Uncertainty: 30, Source: SrcManual, Priority: 5, Categories: []string{"flower", "plant"}}}
assert.Equal(t, "Lake / 2790", m.PhotoTitle)
err := m.UpdateTitle(*classifyLabels)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "Examplefilenameoriginal", m.PhotoTitle)
})
t.Run("no location", func(t *testing.T) {
m := PhotoFixtures.Get("Photo01")
classifyLabels := &classify.Labels{{Name: "classify", Uncertainty: 30, Source: SrcManual, Priority: 5, Categories: []string{"flower", "plant"}}}
assert.Equal(t, "", m.PhotoTitle)
err := m.UpdateTitle(*classifyLabels)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "Classify / Germany / 2006", m.PhotoTitle)
})
t.Run("no location no labels", func(t *testing.T) {
m := PhotoFixtures.Get("Photo02")
classifyLabels := &classify.Labels{}
assert.Equal(t, "", m.PhotoTitle)
err := m.UpdateTitle(*classifyLabels)
if err != nil {
t.Fatal(err)
}
// TODO: Unstable
if len(m.SubjectNames()) > 0 {
assert.Equal(t, "Actress A / 1990", m.PhotoTitle)
} else {
assert.Equal(t, "Bridge / 1990", m.PhotoTitle)
}
})
t.Run("no location no labels no takenAt", func(t *testing.T) {
m := PhotoFixtures.Get("Photo20")
classifyLabels := &classify.Labels{}
assert.Equal(t, "", m.PhotoTitle)
err := m.UpdateTitle(*classifyLabels)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "Unknown", m.PhotoTitle)
})
t.Run("OnePerson", func(t *testing.T) {
m := PhotoFixtures.Get("Photo10")
assert.Equal(t, SrcAuto, m.TitleSrc)
assert.Equal(t, SrcAuto, m.DescriptionSrc)
assert.Equal(t, "Title", m.PhotoTitle)
assert.Equal(t, "", m.PhotoDescription)
err := m.UpdateTitle(classify.Labels{})
if err != nil {
t.Fatal(err)
}
assert.Equal(t, SrcAuto, m.TitleSrc)
assert.Equal(t, SrcAuto, m.DescriptionSrc)
// TODO: Unstable
if len(m.SubjectNames()) > 0 {
assert.Equal(t, "Actor A / Germany / 2016", m.PhotoTitle)
} else {
assert.Equal(t, "Holiday Park / Germany / 2016", m.PhotoTitle)
}
assert.Equal(t, "", m.PhotoDescription)
})
t.Run("People", func(t *testing.T) {
m := PhotoFixtures.Get("Photo04")
assert.Equal(t, SrcAuto, m.TitleSrc)
assert.Equal(t, SrcAuto, m.DescriptionSrc)
assert.Equal(t, "Neckarbrücke", m.PhotoTitle)
assert.Equal(t, "", m.PhotoDescription)
err := m.UpdateTitle(classify.Labels{})
if err != nil {
t.Fatal(err)
}
assert.Equal(t, SrcAuto, m.TitleSrc)
assert.Equal(t, SrcAuto, m.DescriptionSrc)
assert.Equal(t, "Corn McCornface & Jens Mander / 2014", m.PhotoTitle)
assert.Equal(t, "", m.PhotoDescription)
})
}
func TestPhoto_FileTitle(t *testing.T) {
t.Run("non-latin", func(t *testing.T) {
photo := Photo{PhotoName: "桥", PhotoPath: "", OriginalName: ""}
result := photo.FileTitle()
assert.Equal(t, "桥", result)
})
t.Run("changing-of-the-guard--buckingham-palace_7925318070_o.jpg", func(t *testing.T) {
photo := Photo{PhotoName: "20200102_194030_9EFA9E5E", PhotoPath: "2000/05", OriginalName: "flickr import/changing-of-the-guard--buckingham-palace_7925318070_o.jpg"}
result := photo.FileTitle()
assert.Equal(t, "Changing of the Guard / Buckingham Palace", result)
})
t.Run("empty title", func(t *testing.T) {
photo := Photo{PhotoName: "", PhotoPath: "", OriginalName: ""}
result := photo.FileTitle()
assert.Equal(t, "", result)
})
t.Run("return title", func(t *testing.T) {
photo := Photo{PhotoName: "sun, beach, fun", PhotoPath: "", OriginalName: "", PhotoTitle: ""}
result := photo.FileTitle()
assert.Equal(t, "Sun, Beach, Fun", result)
})
t.Run("return title", func(t *testing.T) {
photo := Photo{PhotoName: "", PhotoPath: "vacation", OriginalName: "20200102_194030_9EFA9E5E", PhotoTitle: ""}
result := photo.FileTitle()
assert.Equal(t, "Vacation", result)
})
}
func TestPhoto_SubjectNames(t *testing.T) {
t.Run("Photo09", func(t *testing.T) {
m := PhotoFixtures.Get("Photo09")
if names := m.SubjectNames(); len(names) > 0 {
t.Errorf("no name expected: %#v", names)
}
})
t.Run("Photo10", func(t *testing.T) {
m := PhotoFixtures.Get("Photo10")
if names := m.SubjectNames(); len(names) == 1 {
assert.Equal(t, "Actor A", names[0])
} else {
t.Logf("unstable subject list: %#v", names)
}
})
t.Run("Photo04", func(t *testing.T) {
m := PhotoFixtures.Get("Photo04")
if names := m.SubjectNames(); len(names) != 2 {
t.Errorf("two names expected: %#v", names)
} else {
assert.Equal(t, []string{"Corn McCornface", "Jens Mander"}, names)
}
})
}

37
pkg/txt/names.go Normal file
View file

@ -0,0 +1,37 @@
package txt
import (
"fmt"
"strings"
)
// UniqueNames removes exact duplicates from a list of strings without changing their order.
func UniqueNames(names []string) (result []string) {
if len(names) < 1 {
return []string{}
}
k := make(map[string]bool)
for _, n := range names {
if _, value := k[n]; !value {
k[n] = true
result = append(result, n)
}
}
return result
}
// JoinNames joins a list of names to be used in titles and descriptions.
func JoinNames(names []string) string {
if l := len(names); l == 0 {
return ""
} else if l == 1 {
return names[0]
} else if l == 2 {
return strings.Join(names, " & ")
} else {
return fmt.Sprintf("%s & %s", strings.Join(names[:l-1], ", "), names[l-1])
}
}

45
pkg/txt/names_test.go Normal file
View file

@ -0,0 +1,45 @@
package txt
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestUniqueNames(t *testing.T) {
t.Run("ManyNames", func(t *testing.T) {
result := UniqueNames([]string{"lazy", "jpg", "Brown", "apple", "brown", "new-york", "JPG"})
assert.Equal(t, []string{"lazy", "jpg", "Brown", "apple", "brown", "new-york", "JPG"}, result)
})
t.Run("OneNames", func(t *testing.T) {
result := UniqueNames([]string{"foo bar"})
assert.Equal(t, []string{"foo bar"}, result)
})
t.Run("None", func(t *testing.T) {
result := UniqueNames(nil)
assert.Equal(t, []string{}, result)
})
}
func TestJoinNames(t *testing.T) {
t.Run("NoName", func(t *testing.T) {
result := JoinNames([]string{})
assert.Equal(t, "", result)
})
t.Run("OneName", func(t *testing.T) {
result := JoinNames([]string{"Jens Mander"})
assert.Equal(t, "Jens Mander", result)
})
t.Run("TwoNames", func(t *testing.T) {
result := JoinNames([]string{"Jens Mander", "Name 2"})
assert.Equal(t, "Jens Mander & Name 2", result)
})
t.Run("ThreeNames", func(t *testing.T) {
result := JoinNames([]string{"Jens Mander", "Name 2", "Name 3"})
assert.Equal(t, "Jens Mander, Name 2 & Name 3", result)
})
t.Run("ManyNames", func(t *testing.T) {
result := JoinNames([]string{"Jens Mander", "Name 2", "Name 3", "Name 4"})
assert.Equal(t, "Jens Mander, Name 2, Name 3 & Name 4", result)
})
}