Indexing: Add "convert to jpeg" and "create thumbnails" options

Signed-off-by: Michael Mayer <michael@liquidbytes.net>
This commit is contained in:
Michael Mayer 2019-12-11 04:12:54 +01:00
parent e207c83242
commit 27ca260942
20 changed files with 529 additions and 318 deletions

View file

@ -18,7 +18,8 @@ class Config {
title: "PhotoPrism",
};
this.subscriptionId = Event.subscribe("config.updated", (ev, data) => this.setValues(data));
Event.subscribe("config.updated", (ev, data) => this.setValues(data));
Event.subscribe("count", (ev, data) => this.onCount(ev, data));
if(this.hasValue("settings")) {
this.setTheme(this.getValue("settings").theme);
@ -43,6 +44,32 @@ class Config {
return this;
}
onCount(ev, data) {
const type = ev.split('.')[1];
switch (type) {
case "favorites":
this.values.count.favorites += data.count;
break;
case "albums":
this.values.count.albums += data.count;
break;
case "photos":
this.values.count.photos += data.count;
break;
case "countries":
this.values.count.countries += data.count;
break;
case "labels":
this.values.count.labels += data.count;
break;
default:
console.warn("unknown count type", ev, data)
}
this.values.count
}
updateSettings(values, $vuetify) {
this.setValue("settings", values);
this.setTheme(values.theme);

View file

@ -59,6 +59,7 @@
<v-list-tile-content>
<v-list-tile-title>
<translate>Photos</translate>
<span v-if="config.count.photos > 0" class="p-navigation-count">{{ config.count.photos }}</span>
</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
@ -97,6 +98,7 @@
<v-list-tile-content>
<v-list-tile-title>
<translate>Albums</translate>
<span v-if="config.count.albums > 0" class="p-navigation-count">{{ config.count.albums }}</span>
</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
@ -119,18 +121,7 @@
<v-list-tile-content>
<v-list-tile-title>
<translate>Favorites</translate>
</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
<v-list-tile to="/places" @click="" class="p-navigation-places">
<v-list-tile-action>
<v-icon>place</v-icon>
</v-list-tile-action>
<v-list-tile-content>
<v-list-tile-title>
<translate>Places</translate>
<span v-if="config.count.favorites > 0" class="p-navigation-count">{{ config.count.favorites }}</span>
</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
@ -143,6 +134,20 @@
<v-list-tile-content>
<v-list-tile-title>
<translate>Labels</translate>
<span v-if="config.count.labels > 0" class="p-navigation-count">{{ config.count.labels }}</span>
</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
<v-list-tile to="/places" @click="" class="p-navigation-places">
<v-list-tile-action>
<v-icon>place</v-icon>
</v-list-tile-action>
<v-list-tile-content>
<v-list-tile-title>
<translate>Places</translate>
<span v-if="config.count.countries > 0" class="p-navigation-count">{{ config.count.countries }}</span>
</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
@ -223,7 +228,7 @@
export default {
name: "p-navigation",
data() {
let mini = (window.innerWidth < 1600);
let mini = (window.innerWidth < 1400);
return {
drawer: null,

View file

@ -39,6 +39,15 @@ main {
z-index: 10;
}
#p-navigation .p-navigation-count {
opacity: 0.5;
position: absolute;
left: 80px;
width: 40px;
display: inline-block;
text-align: right;
}
#photoprism div.loading {
position: fixed;
top: 0;
@ -128,6 +137,10 @@ main {
color: #F48FB1;
}
#photoprism .p-log-warning {
color: #FFD600;
}
#photoprism .p-log-fatal {
color: #FFECB3;
}
@ -137,5 +150,5 @@ main {
}
#photoprism .p-log-debug {
color: #EEEEEE;
color: #DDDDDD;
}

View file

@ -8,11 +8,11 @@
slider-color="secondary-dark"
height="64"
>
<v-tab id="tab-maintenance" ripple @click="changePath('/library')">
<v-tab id="tab-originals" ripple @click="changePath('/library')">
<translate>Originals</translate>
</v-tab>
<v-tab-item>
<p-tab-maintenance></p-tab-maintenance>
<p-tab-originals></p-tab-originals>
</v-tab-item>
<v-tab id="tab-import" v-if="!readonly" ripple @click="changePath('/library/import')">
@ -35,7 +35,7 @@
<script>
import uploadTab from "pages/library/upload.vue";
import importTab from "pages/library/import.vue";
import maintenanceTab from "pages/library/maintenance.vue";
import originalsTab from "pages/library/originals.vue";
export default {
name: 'p-page-library',
@ -43,7 +43,7 @@
tab: Number
},
components: {
'p-tab-maintenance': maintenanceTab,
'p-tab-originals': originalsTab,
'p-tab-import': importTab,
'p-tab-upload': uploadTab,
},

View file

@ -3,21 +3,39 @@
<v-form ref="form" class="p-photo-index" lazy-validation @submit.prevent="submit" dense>
<v-container fluid>
<p class="subheading">
<span v-if="fileName">Indexing {{ fileName }}...</span>
<span v-if="fileName">{{ action }} {{ fileName }}...</span>
<span v-else-if="busy">Indexing photos and sidecar files...</span>
<span v-else-if="completed">Done.</span>
<span v-else>Press button to start indexing...</span>
</p>
<p class="options">
<v-progress-linear color="secondary-dark" :value="completed" :indeterminate="busy"></v-progress-linear>
<v-progress-linear color="secondary-dark" :value="completed"
:indeterminate="busy"></v-progress-linear>
</p>
<v-checkbox
v-model="options.skip"
class="mb-0 mt-4 pa-0"
v-model="options.skipUnchanged"
color="secondary-dark"
:disabled="busy"
:label="labels.skip"
:label="labels.skipUnchanged"
></v-checkbox>
<v-checkbox
v-if="!readonly"
class="ma-0 pa-0"
v-model="options.convertRaw"
color="secondary-dark"
:disabled="busy"
:label="labels.convertRaw"
></v-checkbox>
<v-checkbox
v-if="!readonly"
class="ma-0 pa-0"
v-model="options.createThumbs"
color="secondary-dark"
:disabled="busy"
:label="labels.createThumbs"
></v-checkbox>
<v-btn
@ -27,7 +45,7 @@
depressed
@click.stop="startIndexing()"
>
<translate>Start</translate>
<translate>Index</translate>
<v-icon right dark>update</v-icon>
</v-btn>
</v-container>
@ -45,17 +63,23 @@
name: 'p-tab-index',
data() {
return {
readonly: this.$config.getValue("readonly"),
started: false,
busy: false,
completed: 0,
subscriptionId: '',
fileName: '',
subscriptionId: "",
action: "",
fileName: "",
source: null,
options: {
skip: true
skipUnchanged: true,
createThumbs: false,
convertRaw: false,
},
labels: {
skip: this.$gettext("Skip unchanged photos and sidecar files"),
skipUnchanged: this.$gettext("Skip unchanged files"),
createThumbs: this.$gettext("Pre-render thumbnails"),
convertRaw: this.$gettext("Convert RAW to JPEG"),
}
}
},
@ -73,7 +97,7 @@
const ctx = this;
Notify.blockUI();
Api.post('index', this.options, { cancelToken: this.source.token }).then(function () {
Api.post('index', this.options, {cancelToken: this.source.token}).then(function () {
Notify.unblockUI();
ctx.busy = false;
ctx.completed = 100;
@ -94,7 +118,7 @@
});
},
handleEvent(ev, data) {
if(this.source) {
if (this.source) {
this.source.cancel('run in background');
this.source = null;
Notify.unblockUI();
@ -103,12 +127,26 @@
const type = ev.split('.')[1];
switch (type) {
case 'file':
case "indexing":
this.action = "Indexing";
this.busy = true;
this.completed = 0;
this.fileName = data.fileName;
break;
case "converting":
this.action = "Converting";
this.busy = true;
this.completed = 0;
this.fileName = data.fileName;
break;
case "thumbnails":
this.action = "Creating thumbnails for";
this.busy = true;
this.completed = 0;
this.fileName = data.fileName;
break;
case 'completed':
this.action = "";
this.busy = false;
this.completed = 100;
this.fileName = '';

View file

@ -86,6 +86,10 @@ func CreateAlbum(router *gin.RouterGroup, conf *config.Config) {
return
}
event.Publish("count.albums", event.Data{
"count": 1,
})
event.Success(fmt.Sprintf("album \"%s\" created", m.AlbumName))
c.JSON(http.StatusOK, m)

View file

@ -46,6 +46,10 @@ func BatchPhotosDelete(router *gin.RouterGroup, conf *config.Config) {
elapsed := int(time.Since(start).Seconds())
event.Publish("count.photos", event.Data{
"count": len(f.Photos) * -1,
})
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("photos deleted in %d s", elapsed)})
})
}

View file

@ -47,9 +47,20 @@ func Index(router *gin.RouterGroup, conf *config.Config) {
event.Info(fmt.Sprintf("indexing photos in \"%s\"", filepath.Base(path)))
if f.ConvertRaw {
converter := photoprism.NewConverter(conf)
converter.ConvertAll(conf.OriginalsPath())
}
if f.CreateThumbs {
if err := photoprism.CreateThumbnailsFromOriginals(conf.OriginalsPath(), conf.ThumbnailsPath(), false); err != nil {
event.Error(err.Error())
}
}
initIndexer(conf)
if f.SkipExisting {
if f.SkipUnchanged {
indexer.IndexOriginals(photoprism.IndexerOptionsNone())
} else {
indexer.IndexOriginals(photoprism.IndexerOptionsAll())

View file

@ -6,6 +6,7 @@ import (
"strconv"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/util"
"github.com/gin-gonic/gin"
@ -110,6 +111,10 @@ func LikePhoto(router *gin.RouterGroup, conf *config.Config) {
m.PhotoFavorite = true
conf.Db().Save(&m)
event.Publish("count.favorites", event.Data{
"count": 1,
})
c.JSON(http.StatusOK, gin.H{"photo": m})
})
}
@ -136,6 +141,10 @@ func DislikePhoto(router *gin.RouterGroup, conf *config.Config) {
m.PhotoFavorite = false
conf.Db().Save(&m)
event.Publish("count.favorites", event.Data{
"count": -1,
})
c.JSON(http.StatusOK, gin.H{"photo": m})
})
}

View file

@ -30,7 +30,7 @@ func wsReader(ws *websocket.Conn) {
func wsWriter(ws *websocket.Conn, conf *config.Config) {
pingTicker := time.NewTicker(10 * time.Second)
s := event.Subscribe("log.*", "notify.*", "index.*", "upload.*", "import.*", "config.*")
s := event.Subscribe("log.*", "notify.*", "index.*", "upload.*", "import.*", "config.*", "count.*")
defer func() {
pingTicker.Stop()

View file

@ -525,11 +525,46 @@ func (c *Config) ClientConfig() ClientConfig {
}
var countries []country
var count = struct {
Photos uint `json:"photos"`
Favorites uint `json:"favorites"`
Private uint `json:"private"`
Stories uint `json:"stories"`
Labels uint `json:"labels"`
Albums uint `json:"albums"`
Countries uint `json:"countries"`
}{}
db.Model(&models.Location{}).Select("DISTINCT loc_country_code, loc_country").Scan(&countries)
db.Table("photos").
Select("COUNT(*) AS photos, SUM(photo_favorite) AS favorites, SUM(photo_private) AS private, SUM(photo_story) AS stories").
Where("deleted_at IS NULL").
Take(&count)
db.Where("deleted_at IS NULL").Limit(1000).Order("camera_model").Find(&cameras)
db.Where("deleted_at IS NULL AND album_favorite = 1").Limit(20).Order("album_name").Find(&albums)
db.Table("labels").
Select("COUNT(*) AS labels").
Where("deleted_at IS NULL").
Take(&count)
db.Table("albums").
Select("COUNT(*) AS albums").
Where("deleted_at IS NULL").
Take(&count)
db.Table("countries").
Select("COUNT(*) AS countries").
Take(&count)
db.Model(&models.Location{}).
Select("DISTINCT loc_country_code, loc_country").
Scan(&countries)
db.Where("deleted_at IS NULL").
Limit(1000).Order("camera_model").
Find(&cameras)
db.Where("deleted_at IS NULL AND album_favorite = 1").
Limit(20).Order("album_name").
Find(&albums)
jsHash := util.Hash(c.HttpStaticBuildPath() + "/app.js")
cssHash := util.Hash(c.HttpStaticBuildPath() + "/app.css")
@ -548,6 +583,7 @@ func (c *Config) ClientConfig() ClientConfig {
"jsHash": jsHash,
"cssHash": cssHash,
"settings": c.Settings(),
"count": count,
}
return result

View file

@ -1,5 +1,7 @@
package form
type IndexerOptions struct {
SkipExisting bool `json:"skip"`
SkipUnchanged bool `json:"skipUnchanged"`
CreateThumbs bool `json:"createThumbs"`
ConvertRaw bool `json:"convertRaw"`
}

View file

@ -13,6 +13,7 @@ type Country struct {
CountryNotes string `gorm:"type:text;"`
CountryPhoto *Photo
CountryPhotoID uint
New bool `gorm:"-"`
}
// Create a new country
@ -41,3 +42,7 @@ func (m *Country) FirstOrCreate(db *gorm.DB) *Country {
return m
}
func (m *Country) AfterCreate(scope *gorm.Scope) error {
return scope.SetColumn("New", true)
}

View file

@ -17,6 +17,7 @@ type Label struct {
LabelDescription string `gorm:"type:text;"`
LabelNotes string `gorm:"type:text;"`
LabelCategories []*Label `gorm:"many2many:categories;association_jointable_foreignkey:category_id"`
New bool `gorm:"-"`
}
func NewLabel(labelName string, labelPriority int) *Label {
@ -42,3 +43,7 @@ func (m *Label) FirstOrCreate(db *gorm.DB) *Label {
return m
}
func (m *Label) AfterCreate(scope *gorm.Scope) error {
return scope.SetColumn("New", true)
}

View file

@ -7,6 +7,7 @@ import (
"path/filepath"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/event"
)
// Converter wraps a darktable cli binary.
@ -98,12 +99,21 @@ func (c *Converter) ConvertToJpeg(image *MediaFile) (*MediaFile, error) {
log.Infof("converting \"%s\" to \"%s\"", image.filename, jpegFilename)
fileName := image.RelativeFilename(c.conf.OriginalsPath())
xmpFilename := baseFilename + ".xmp"
if _, err := os.Stat(xmpFilename); err != nil {
xmpFilename = ""
}
event.Publish("index.converting", event.Data{
"fileType": image.Type(),
"fileName": fileName,
"baseName": filepath.Base(fileName),
"xmpName": filepath.Base(xmpFilename),
})
if convertCommand, err := c.ConvertCommand(image, jpegFilename, xmpFilename); err != nil {
return nil, err
} else if err := convertCommand.Run(); err != nil {

View file

@ -51,7 +51,7 @@ func (i *Indexer) IndexRelated(mediaFile *MediaFile, o IndexerOptions) map[strin
mainIndexResult := i.indexMediaFile(mainFile, o)
indexed[mainFile.Filename()] = true
log.Infof("%s main %s file \"%s\"", mainIndexResult, mainFile.Type(), mainFile.RelativeFilename(i.originalsPath()))
log.Infof("index: %s main %s file \"%s\"", mainIndexResult, mainFile.Type(), mainFile.RelativeFilename(i.originalsPath()))
for _, relatedMediaFile := range relatedFiles {
if indexed[relatedMediaFile.Filename()] {
@ -61,7 +61,7 @@ func (i *Indexer) IndexRelated(mediaFile *MediaFile, o IndexerOptions) map[strin
indexResult := i.indexMediaFile(relatedMediaFile, o)
indexed[relatedMediaFile.Filename()] = true
log.Infof("%s related %s file \"%s\"", indexResult, relatedMediaFile.Type(), relatedMediaFile.RelativeFilename(i.originalsPath()))
log.Infof("index: %s related %s file \"%s\"", indexResult, relatedMediaFile.Type(), relatedMediaFile.RelativeFilename(i.originalsPath()))
}
return indexed

View file

@ -21,6 +21,200 @@ const (
type IndexResult string
func (i *Indexer) indexMediaFile(mediaFile *MediaFile, o IndexerOptions) IndexResult {
var photo models.Photo
var file, primaryFile models.File
var isPrimary = false
var exifData *Exif
var photoQuery, fileQuery *gorm.DB
var keywords []string
labels := Labels{}
fileBase := mediaFile.Basename()
filePath := mediaFile.RelativePath(i.originalsPath())
fileName := mediaFile.RelativeFilename(i.originalsPath())
fileHash := mediaFile.Hash()
fileChanged := true
fileExists := false
photoExists := false
event.Publish("index.indexing", event.Data{
"fileHash": fileHash,
"fileName": fileName,
"baseName": filepath.Base(fileName),
})
fileQuery = i.db.Unscoped().First(&file, "file_hash = ? OR file_name = ?", fileHash, fileName)
fileExists = fileQuery.Error == nil
if !fileExists {
photoQuery = i.db.Unscoped().First(&photo, "photo_path = ? AND photo_name = ?", filePath, fileBase)
if photoQuery.Error != nil && mediaFile.HasTimeAndPlace() {
exifData, _ = mediaFile.Exif()
photoQuery = i.db.Unscoped().First(&photo, "photo_lat = ? AND photo_long = ? AND taken_at = ?", exifData.Lat, exifData.Long, exifData.TakenAt)
}
} else {
photoQuery = i.db.Unscoped().First(&photo, "id = ?", file.PhotoID)
fileChanged = file.FileHash != fileHash
isPrimary = file.FilePrimary
}
photoExists = photoQuery.Error == nil
if !fileChanged && photoExists && !photo.TakenAt.IsZero() && o.SkipUnchanged() {
return indexResultSkipped
}
photo.PhotoPath = filePath
photo.PhotoName = fileBase
if isPrimary || !photoExists || photo.TakenAt.IsZero() {
if jpeg, err := mediaFile.Jpeg(); err == nil {
if fileChanged || o.UpdateLabels || o.UpdateTitle {
// Image classification labels
labels = i.classifyImage(jpeg)
}
if fileChanged || o.UpdateExif {
// Read UpdateExif data
if exifData, err := jpeg.Exif(); err == nil {
photo.PhotoLat = exifData.Lat
photo.PhotoLong = exifData.Long
photo.TakenAt = exifData.TakenAt
photo.TakenAtLocal = exifData.TakenAtLocal
photo.TimeZone = exifData.TimeZone
photo.PhotoAltitude = exifData.Altitude
photo.PhotoArtist = exifData.Artist
if exifData.UUID != "" {
log.Debugf("index: photo uuid \"%s\"", exifData.UUID)
photo.PhotoUUID = exifData.UUID
} else {
log.Debug("index: no photo uuid in exif data")
}
}
}
if fileChanged || o.UpdateCamera {
// Set UpdateCamera, Lens, Focal Length and F Number
photo.Camera = models.NewCamera(mediaFile.CameraModel(), mediaFile.CameraMake()).FirstOrCreate(i.db)
photo.Lens = models.NewLens(mediaFile.LensModel(), mediaFile.LensMake()).FirstOrCreate(i.db)
photo.PhotoFocalLength = mediaFile.FocalLength()
photo.PhotoFNumber = mediaFile.FNumber()
photo.PhotoIso = mediaFile.Iso()
photo.PhotoExposure = mediaFile.Exposure()
}
}
if fileChanged || o.UpdateLocation || o.UpdateTitle {
keywords, labels = i.indexLocation(mediaFile, &photo, keywords, labels, fileChanged, o)
}
if (fileChanged || o.UpdateTitle) && photo.PhotoTitle == "" {
if len(labels) > 0 && labels[0].Priority >= -1 && labels[0].Uncertainty <= 85 && labels[0].Name != "" {
photo.PhotoTitle = fmt.Sprintf("%s / %s", util.Title(labels[0].Name), mediaFile.DateCreated().Format("2006"))
} else if !photo.TakenAtLocal.IsZero() {
var daytimeString string
hour := photo.TakenAtLocal.Hour()
switch {
case hour < 17:
daytimeString = "Unknown"
case hour < 20:
daytimeString = "Sunset"
default:
daytimeString = "Unknown"
}
photo.PhotoTitle = fmt.Sprintf("%s / %s", daytimeString, photo.TakenAtLocal.Format("2006"))
}
}
log.Debugf("index: changed empty photo title to \"%s\"", photo.PhotoTitle)
}
// This should never happen
if photo.TakenAt.IsZero() || photo.TakenAtLocal.IsZero() {
photo.TakenAt = mediaFile.DateCreated()
photo.TakenAtLocal = photo.TakenAt
log.Warnf("index: %s has invalid date, set to \"%s\"", filepath.Base(mediaFile.Filename()), photo.TakenAt.Format("2006-01-02 15:04:05"))
}
if photoExists {
// Estimate location
if o.UpdateLocation && photo.LocationID == 0 {
i.estimateLocation(&photo)
}
i.db.Unscoped().Save(&photo)
} else {
event.Publish("count.photos", event.Data{
"count": 1,
})
photo.PhotoFavorite = false
i.db.Create(&photo)
}
if len(labels) > 0 {
log.Infof("index: adding labels %+v", labels)
}
if fileChanged || o.UpdateLabels {
i.addLabels(photo.ID, labels)
}
if result := i.db.Where("file_type = 'jpg' AND file_primary = 1 AND photo_id = ?", photo.ID).First(&primaryFile); result.Error != nil {
isPrimary = mediaFile.IsJpeg()
} else {
isPrimary = mediaFile.IsJpeg() && (fileName == primaryFile.FileName || fileHash == primaryFile.FileHash)
}
if (fileChanged || o.UpdateKeywords || o.UpdateTitle) && isPrimary {
photo.IndexKeywords(keywords, i.db)
}
file.PhotoID = photo.ID
file.PhotoUUID = photo.PhotoUUID
file.FilePrimary = isPrimary
file.FileMissing = false
file.FileName = fileName
file.FileHash = fileHash
file.FileType = mediaFile.Type()
file.FileMime = mediaFile.MimeType()
file.FileOrientation = mediaFile.Orientation()
if fileChanged || o.UpdateColors {
// Color information
if p, err := mediaFile.Colors(i.thumbnailsPath()); err == nil {
file.FileMainColor = p.MainColor.Name()
file.FileColors = p.Colors.Hex()
file.FileLuminance = p.Luminance.Hex()
file.FileChroma = p.Chroma.Uint()
}
}
if fileChanged || o.UpdateSize {
if mediaFile.Width() > 0 && mediaFile.Height() > 0 {
file.FileWidth = mediaFile.Width()
file.FileHeight = mediaFile.Height()
file.FileAspectRatio = mediaFile.AspectRatio()
file.FilePortrait = mediaFile.Width() < mediaFile.Height()
}
}
if fileQuery.Error == nil {
i.db.Unscoped().Save(&file)
return indexResultUpdated
}
i.db.Create(&file)
return indexResultAdded
}
// classifyImage returns all matching labels for a media file.
func (i *Indexer) classifyImage(jpeg *MediaFile) (results Labels) {
start := time.Now()
@ -70,289 +264,126 @@ func (i *Indexer) classifyImage(jpeg *MediaFile) (results Labels) {
elapsed := time.Since(start)
log.Debugf("finding %+v labels for %s took %s", results, jpeg.Filename(), elapsed)
log.Debugf("index: image classification took %s", elapsed)
return results
}
func (i *Indexer) indexMediaFile(mediaFile *MediaFile, o IndexerOptions) IndexResult {
var photo models.Photo
var file, primaryFile models.File
var isPrimary = false
var exifData *Exif
var photoQuery, fileQuery *gorm.DB
var keywords []string
func (i *Indexer) addLabels(photoId uint, labels Labels) {
for _, label := range labels {
lm := models.NewLabel(label.Name, label.Priority).FirstOrCreate(i.db)
labels := Labels{}
fileBase := mediaFile.Basename()
filePath := mediaFile.RelativePath(i.originalsPath())
fileName := mediaFile.RelativeFilename(i.originalsPath())
fileHash := mediaFile.Hash()
fileChanged := true
fileExists := false
photoExists := false
event.Publish("index.file", event.Data{
"fileHash": fileHash,
"fileName": fileName,
"baseName": filepath.Base(fileName),
})
exifData, err := mediaFile.Exif()
if err != nil {
log.Debug(err)
}
fileQuery = i.db.Unscoped().First(&file, "file_hash = ? OR file_name = ?", fileHash, fileName)
fileExists = fileQuery.Error == nil
if !fileExists {
photoQuery = i.db.Unscoped().First(&photo, "photo_path = ? AND photo_name = ?", filePath, fileBase)
if photoQuery.Error != nil && mediaFile.HasTimeAndPlace() {
photoQuery = i.db.Unscoped().First(&photo, "photo_lat = ? AND photo_long = ? AND taken_at = ?", exifData.Lat, exifData.Long, exifData.TakenAt)
}
} else {
photoQuery = i.db.Unscoped().First(&photo, "id = ?", file.PhotoID)
fileChanged = file.FileHash != fileHash
}
photoExists = photoQuery.Error == nil
if !fileChanged && photoExists && o.SkipUnchanged() {
return indexResultSkipped
}
photo.PhotoPath = filePath
photo.PhotoName = fileBase
if jpeg, err := mediaFile.Jpeg(); err == nil {
if fileChanged || o.UpdateLabels {
// Image classification labels
labels = i.classifyImage(jpeg)
if lm.New {
event.Publish("count.labels", event.Data{
"count": 1,
})
}
if fileChanged || o.UpdateExif {
// Read UpdateExif data
if exifData, err := jpeg.Exif(); err == nil {
photo.PhotoLat = exifData.Lat
photo.PhotoLong = exifData.Long
photo.TakenAt = exifData.TakenAt
photo.TakenAtLocal = exifData.TakenAtLocal
photo.TimeZone = exifData.TimeZone
photo.PhotoAltitude = exifData.Altitude
photo.PhotoArtist = exifData.Artist
if exifData.UUID != "" {
log.Debugf("photo uuid: %s", exifData.UUID)
photo.PhotoUUID = exifData.UUID
} else {
log.Debug("no photo uuid")
}
}
if lm.LabelPriority != label.Priority {
lm.LabelPriority = label.Priority
i.db.Save(&lm)
}
if fileChanged || o.UpdateCamera {
// Set UpdateCamera, Lens, Focal Length and F Number
photo.Camera = models.NewCamera(mediaFile.CameraModel(), mediaFile.CameraMake()).FirstOrCreate(i.db)
photo.Lens = models.NewLens(mediaFile.LensModel(), mediaFile.LensMake()).FirstOrCreate(i.db)
photo.PhotoFocalLength = mediaFile.FocalLength()
photo.PhotoFNumber = mediaFile.FNumber()
photo.PhotoIso = mediaFile.Iso()
photo.PhotoExposure = mediaFile.Exposure()
}
}
plm := models.NewPhotoLabel(photoId, lm.ID, label.Uncertainty, label.Source).FirstOrCreate(i.db)
if fileChanged || o.UpdateDate {
if photo.TakenAt.IsZero() || photo.TakenAtLocal.IsZero() {
photo.TakenAt = mediaFile.DateCreated()
photo.TakenAtLocal = photo.TakenAt
}
}
if fileChanged || o.UpdateLocation {
if location, err := mediaFile.Location(); err == nil {
i.db.FirstOrCreate(location, "id = ?", location.ID)
photo.Location = location
photo.LocationEstimated = false
photo.Country = models.NewCountry(location.LocCountryCode, location.LocCountry).FirstOrCreate(i.db)
keywords = append(keywords, util.Keywords(location.LocDisplayName)...)
// Append labels from OpenStreetMap
if location.LocCity != "" {
labels = append(labels, NewLocationLabel(location.LocCity, 0, -2))
}
if location.LocCountry != "" {
labels = append(labels, NewLocationLabel(location.LocCountry, 0, -2))
}
if location.LocCategory != "" {
labels = append(labels, NewLocationLabel(location.LocCategory, 0, -2))
}
if location.LocType != "" {
labels = append(labels, NewLocationLabel(location.LocType, 0, -1))
}
// Sort by priority and uncertainty
sort.Sort(labels)
if (fileChanged || o.UpdateTitle) && photo.PhotoTitleChanged == false {
log.Infof("setting title based on the following labels: %#v", labels)
if len(labels) > 0 && labels[0].Priority >= -1 && labels[0].Uncertainty <= 60 && labels[0].Name != "" { // TODO: User defined title format
log.Infof("label for title: %#v", labels[0])
if location.LocCity == "" || len(location.LocCity) > 16 || strings.Contains(labels[0].Name, location.LocCity) {
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", util.Title(labels[0].Name), location.LocCountry, photo.TakenAt.Format("2006"))
} else {
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", util.Title(labels[0].Name), location.LocCity, photo.TakenAt.Format("2006"))
}
} else if location.LocName != "" && location.LocCity != "" {
if len(location.LocName) > 45 {
photo.PhotoTitle = util.Title(location.LocName)
} else if len(location.LocName) > 20 || len(location.LocCity) > 16 || strings.Contains(location.LocName, location.LocCity) {
photo.PhotoTitle = fmt.Sprintf("%s / %s", util.Title(location.LocName), photo.TakenAt.Format("2006"))
} else {
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", util.Title(location.LocName), location.LocCity, photo.TakenAt.Format("2006"))
}
} else if location.LocCity != "" && location.LocCountry != "" {
if len(location.LocCity) > 20 {
photo.PhotoTitle = fmt.Sprintf("%s / %s", location.LocCity, photo.TakenAt.Format("2006"))
} else {
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", location.LocCity, location.LocCountry, photo.TakenAt.Format("2006"))
}
} else if location.LocCounty != "" && location.LocCountry != "" {
if len(location.LocCounty) > 20 {
photo.PhotoTitle = fmt.Sprintf("%s / %s", location.LocCounty, photo.TakenAt.Format("2006"))
} else {
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", location.LocCounty, location.LocCountry, photo.TakenAt.Format("2006"))
}
}
}
} else {
log.Debugf("location cannot be determined precisely: %s", err)
}
}
if (fileChanged || o.UpdateTitle) && photo.PhotoTitleChanged == false && photo.PhotoTitle == "" {
if len(labels) > 0 && labels[0].Priority >= -1 && labels[0].Uncertainty <= 85 && labels[0].Name != "" {
photo.PhotoTitle = fmt.Sprintf("%s / %s", util.Title(labels[0].Name), mediaFile.DateCreated().Format("2006"))
} else if !photo.TakenAtLocal.IsZero() {
var daytimeString string
hour := photo.TakenAtLocal.Hour()
switch {
case hour < 17:
daytimeString = "Unknown"
case hour < 20:
daytimeString = "Sunset"
default:
daytimeString = "Unknown"
}
photo.PhotoTitle = fmt.Sprintf("%s / %s", daytimeString, photo.TakenAtLocal.Format("2006"))
}
}
log.Debugf("title: \"%s\"", photo.PhotoTitle)
if photoExists {
// Estimate location
if o.UpdateLocation && photo.LocationID == 0 {
var recentPhoto models.Photo
if result := i.db.Unscoped().Order(gorm.Expr("ABS(DATEDIFF(taken_at, ?)) ASC", photo.TakenAt)).Preload("Country").First(&recentPhoto); result.Error == nil {
if recentPhoto.Country != nil {
photo.Country = recentPhoto.Country
photo.LocationEstimated = true
log.Debugf("approximate location: %s", recentPhoto.Country.CountryName)
}
}
// Add categories
for _, category := range label.Categories {
sn := models.NewLabel(category, -1).FirstOrCreate(i.db)
i.db.Model(&lm).Association("LabelCategories").Append(sn)
}
i.db.Unscoped().Save(&photo)
} else {
photo.PhotoFavorite = false
i.db.Create(&photo)
}
log.Infof("adding labels: %+v", labels)
if fileChanged || o.UpdateLabels {
for _, label := range labels {
lm := models.NewLabel(label.Name, label.Priority).FirstOrCreate(i.db)
if lm.LabelPriority != label.Priority {
lm.LabelPriority = label.Priority
i.db.Save(&lm)
}
plm := models.NewPhotoLabel(photo.ID, lm.ID, label.Uncertainty, label.Source).FirstOrCreate(i.db)
// Add categories
for _, category := range label.Categories {
sn := models.NewLabel(category, -1).FirstOrCreate(i.db)
i.db.Model(&lm).Association("LabelCategories").Append(sn)
}
if plm.LabelUncertainty > label.Uncertainty {
plm.LabelUncertainty = label.Uncertainty
plm.LabelSource = label.Source
i.db.Save(&plm)
}
if plm.LabelUncertainty > label.Uncertainty {
plm.LabelUncertainty = label.Uncertainty
plm.LabelSource = label.Source
i.db.Save(&plm)
}
}
}
func (i *Indexer) indexLocation (mediaFile *MediaFile, photo *models.Photo, keywords []string, labels Labels, fileChanged bool, o IndexerOptions) ([]string, Labels){
if location, err := mediaFile.Location(); err == nil {
i.db.FirstOrCreate(location, "id = ?", location.ID)
photo.Location = location
photo.LocationEstimated = false
photo.Country = models.NewCountry(location.LocCountryCode, location.LocCountry).FirstOrCreate(i.db)
if photo.Country.New {
event.Publish("count.countries", event.Data{
"count": 1,
})
}
keywords = append(keywords, util.Keywords(location.LocDisplayName)...)
// Append labels from OpenStreetMap
if location.LocCity != "" {
labels = append(labels, NewLocationLabel(location.LocCity, 0, -2))
}
if location.LocCountry != "" {
labels = append(labels, NewLocationLabel(location.LocCountry, 0, -2))
}
if location.LocCategory != "" {
labels = append(labels, NewLocationLabel(location.LocCategory, 0, -2))
}
if location.LocType != "" {
labels = append(labels, NewLocationLabel(location.LocType, 0, -1))
}
// Sort by priority and uncertainty
sort.Sort(labels)
if (fileChanged || o.UpdateTitle) && photo.PhotoTitleChanged == false {
if len(labels) > 0 && labels[0].Priority >= -1 && labels[0].Uncertainty <= 60 && labels[0].Name != "" { // TODO: User defined title format
log.Infof("index: using label %s to create photo title (%d%% uncertainty)", labels[0].Name, labels[0].Uncertainty)
if location.LocCity == "" || len(location.LocCity) > 16 || strings.Contains(labels[0].Name, location.LocCity) {
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", util.Title(labels[0].Name), location.LocCountry, photo.TakenAt.Format("2006"))
} else {
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", util.Title(labels[0].Name), location.LocCity, photo.TakenAt.Format("2006"))
}
} else if location.LocName != "" && location.LocCity != "" {
if len(location.LocName) > 45 {
photo.PhotoTitle = util.Title(location.LocName)
} else if len(location.LocName) > 20 || len(location.LocCity) > 16 || strings.Contains(location.LocName, location.LocCity) {
photo.PhotoTitle = fmt.Sprintf("%s / %s", util.Title(location.LocName), photo.TakenAt.Format("2006"))
} else {
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", util.Title(location.LocName), location.LocCity, photo.TakenAt.Format("2006"))
}
} else if location.LocCity != "" && location.LocCountry != "" {
if len(location.LocCity) > 20 {
photo.PhotoTitle = fmt.Sprintf("%s / %s", location.LocCity, photo.TakenAt.Format("2006"))
} else {
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", location.LocCity, location.LocCountry, photo.TakenAt.Format("2006"))
}
} else if location.LocCounty != "" && location.LocCountry != "" {
if len(location.LocCounty) > 20 {
photo.PhotoTitle = fmt.Sprintf("%s / %s", location.LocCounty, photo.TakenAt.Format("2006"))
} else {
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", location.LocCounty, location.LocCountry, photo.TakenAt.Format("2006"))
}
}
log.Infof("index: new photo title is \"%s\"", photo.PhotoTitle)
}
} else {
log.Debugf("index: location cannot be determined precisely (%s)", err.Error())
}
return keywords, labels
}
func (i *Indexer) estimateLocation(photo *models.Photo) {
var recentPhoto models.Photo
if result := i.db.Unscoped().Order(gorm.Expr("ABS(DATEDIFF(taken_at, ?)) ASC", photo.TakenAt)).Preload("Country").First(&recentPhoto); result.Error == nil {
if recentPhoto.Country != nil {
photo.Country = recentPhoto.Country
photo.LocationEstimated = true
log.Debugf("index: approximate location is \"%s\"", recentPhoto.Country.CountryName)
}
}
if result := i.db.Where("file_type = 'jpg' AND file_primary = 1 AND photo_id = ?", photo.ID).First(&primaryFile); result.Error != nil {
isPrimary = mediaFile.IsJpeg()
} else {
isPrimary = mediaFile.IsJpeg() && (fileName == primaryFile.FileName || fileHash == primaryFile.FileHash)
}
if (fileChanged || o.UpdateKeywords) && isPrimary {
photo.IndexKeywords(keywords, i.db)
}
file.PhotoID = photo.ID
file.PhotoUUID = photo.PhotoUUID
file.FilePrimary = isPrimary
file.FileMissing = false
file.FileName = fileName
file.FileHash = fileHash
file.FileType = mediaFile.Type()
file.FileMime = mediaFile.MimeType()
file.FileOrientation = mediaFile.Orientation()
if fileChanged || o.UpdateColors {
// Color information
if p, err := mediaFile.Colors(i.thumbnailsPath()); err == nil {
file.FileMainColor = p.MainColor.Name()
file.FileColors = p.Colors.Hex()
file.FileLuminance = p.Luminance.Hex()
file.FileChroma = p.Chroma.Uint()
}
}
if fileChanged || o.UpdateSize {
if mediaFile.Width() > 0 && mediaFile.Height() > 0 {
file.FileWidth = mediaFile.Width()
file.FileHeight = mediaFile.Height()
file.FileAspectRatio = mediaFile.AspectRatio()
file.FilePortrait = mediaFile.Width() < mediaFile.Height()
}
}
if fileQuery.Error == nil {
i.db.Unscoped().Save(&file)
return indexResultUpdated
}
i.db.Create(&file)
return indexResultAdded
}

View file

@ -323,10 +323,8 @@ func (m *MediaFile) RelativePath(directory string) string {
if i := strings.Index(pathname, directory); i == 0 {
if i := strings.LastIndex(directory, string(os.PathSeparator)); i == len(directory)-1 {
pathname = pathname[len(directory):]
log.Info(pathname)
} else if i := strings.LastIndex(directory, string(os.PathSeparator)); i != len(directory) {
pathname = pathname[len(directory)+1:]
log.Info(pathname)
}
}

View file

@ -9,6 +9,7 @@ import (
"io/ioutil"
"math"
"os"
"path/filepath"
"sort"
"strings"
@ -51,11 +52,12 @@ func (t *TensorFlow) loadLabelRules() (err error) {
fileName := t.conf.ConfigPath() + "/labels.yml"
log.Debugf("loading label rules from \"%s\"", fileName)
log.Debugf("tensorflow: loading label rules from \"%s\"", filepath.Base(fileName))
if !util.Exists(fileName) {
log.Errorf("label rules file not found: \"%s\"", fileName)
return fmt.Errorf("label rules file not found: \"%s\"", fileName)
e := fmt.Errorf("tensorflow: label rules file not found in \"%s\"", filepath.Base(fileName))
log.Error(e.Error())
return e
}
yamlConfig, err := ioutil.ReadFile(fileName)
@ -115,7 +117,9 @@ func (t *TensorFlow) Labels(img []byte) (result Labels, err error) {
// Return best labels
result = t.bestLabels(output[0].Value().([][]float32)[0])
log.Debugf("labels: %v", result)
if len(result) > 0 {
log.Debugf("tensorflow: image classified as %+v", result)
}
return result, nil
}
@ -123,7 +127,7 @@ func (t *TensorFlow) Labels(img []byte) (result Labels, err error) {
func (t *TensorFlow) loadLabels(path string) error {
modelLabels := path + "/labels.txt"
log.Infof("loading classification labels from \"%s\"", modelLabels)
log.Infof("tensorflow: loading classification labels from labels.txt")
// Load labels
f, err := os.Open(modelLabels)
@ -156,7 +160,7 @@ func (t *TensorFlow) loadModel() error {
path := t.conf.TensorFlowModelPath()
log.Infof("loading image classification model from \"%s\"", path)
log.Infof("tensorflow: loading image classification model from \"%s\"", filepath.Base(path))
// Load model
model, err := tf.LoadSavedModel(path, []string{"photoprism"}, nil)

View file

@ -10,6 +10,7 @@ import (
"time"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/event"
"github.com/disintegration/imaging"
"github.com/photoprism/photoprism/internal/util"
@ -93,6 +94,14 @@ func CreateThumbnailsFromOriginals(originalsPath string, thumbnailsPath string,
return nil
}
fileName := mediaFile.RelativeFilename(originalsPath)
event.Publish("index.thumbnails", event.Data{
"fileName": fileName,
"baseName": filepath.Base(fileName),
"force": force,
})
if err := mediaFile.CreateDefaultThumbnails(thumbnailsPath, force); err != nil {
log.Errorf("could not create default thumbnails: %s", err)
return err