Improved photo search

This commit is contained in:
Michael Mayer 2018-08-09 23:10:05 +02:00
parent 6a6017a478
commit cea3d70835
15 changed files with 303 additions and 85 deletions

View file

@ -133,5 +133,5 @@ func (c *Config) GetDb() *gorm.DB {
func (c *Config) MigrateDb() {
db := c.GetDb()
db.AutoMigrate(&File{}, &Photo{}, &Tag{}, &Album{})
db.AutoMigrate(&File{}, &Photo{}, &Tag{}, &Album{}, &Location{})
}

View file

@ -6,12 +6,14 @@ import (
type File struct {
gorm.Model
Photo *Photo
PhotoID uint
Filename string
FileType string `gorm:"type:varchar(30)"`
MimeType string `gorm:"type:varchar(50)"`
Width int
Height int
Orientation int
AspectRatio float64
Hash string `gorm:"type:varchar(100);unique_index"`
}

View file

@ -24,7 +24,7 @@
}
#alerts .warning {
background-color: #c18200;
background-color: #a89a00;
}
#alerts .warning:before {

View file

@ -22,6 +22,7 @@
body {
background: #fefefe;
color: #333333;
font-family: Helvetica, Arial, sans-serif;
}
footer {
@ -36,12 +37,20 @@ main {
main div.page {
margin: 43px 0 0 0;
padding: 2rem;
padding: 0;
}
main div.page {
border-top-width: 3px;
border-top-style: solid;
main div.page .page-container {
padding: 2rem;
overflow: auto;
}
main div.page .page-form {
padding: 2.4rem 2rem 0 2rem;
float: none;
}
main div.page .page-form form {
}
.navbar {
@ -51,6 +60,7 @@ main div.page {
right: 0;
background: black !important;
position: fixed !important;
box-shadow: 0px 6px 30px 0px rgba(0,0,0,0.75);
z-index: 1000;
}
@ -60,7 +70,6 @@ main div.page {
}
main div.page-photos {
border-top-color: #c8e5ff;
}
.navbar .nav-item-albums .nav-link.active,
@ -69,7 +78,6 @@ main div.page-photos {
}
main div.page-albums {
border-top-color: #c4ffcb;
}
.navbar .nav-item-import .nav-link.active,
@ -78,7 +86,6 @@ main div.page-albums {
}
main div.page-import {
border-top-color: #feffb8;
}
.navbar .nav-item-export .nav-link.active,
@ -87,7 +94,6 @@ main div.page-import {
}
main div.page-export {
border-top-color: #ffedc1;
}
.navbar .nav-item-settings .nav-link.active,
@ -96,7 +102,6 @@ main div.page-export {
}
main div.page-settings {
border-top-color: #f5c5c5;
}
.photo-grid .photo {
@ -105,7 +110,7 @@ main div.page-settings {
width: 250px;
height: 250px;
float: left;
margin: 2px;
margin: 3px;
overflow: hidden;
position: relative;
cursor: pointer;
@ -138,6 +143,10 @@ main div.page-settings {
float: right;
}
.photo-grid .photo .left {
float: left;
}
.photo-grid .photo .actions {
display: block;
position: absolute;
@ -149,7 +158,7 @@ main div.page-settings {
font-size: 14px;
padding: 6px 12px;
color: white;
text-align: left;
text-align: center;
cursor: default;
}
@ -162,8 +171,25 @@ main div.page-settings {
display: none;
}
.photo-grid .photo .location {
display: none;
font-size: 12px;
text-decoration: none;
color: white;
}
.photo-grid .photo:hover .location {
display: inline;
}
.photo-grid .photo:hover .actions .action,
.photo-grid .photo .actions .action.liked {
.photo-grid .photo .actions .action.favorite {
display: inline;
cursor: pointer;
}
@media(min-width: 576px) {
.mb-sm-0, .my-sm-0 {
margin-bottom: 8px !important;
}
}

View file

@ -1,14 +1,63 @@
<template>
<div class="page page-photos">
<div class="photo-grid">
<div class="page-form">
<b-form inline @submit="formChange">
<b-form-select class="mb-2 mr-sm-2 mb-sm-0"
v-b-tooltip.hover title="Category"
v-model="form.category"
:options="{ 'junction': 'Junction', 'tourism': 'Tourism', 'historic': 'Historic' }"
id="inlineFormCustomSelectPref">
<option slot="first" :value="null"></option>
</b-form-select>
<b-form-select @change="formChange" class="mb-2 mr-sm-2 mb-sm-0"
v-model="form.country"
:options="{ '1': 'One', '2': 'Two', '3': 'Three' }"
id="inlineFormCustomSelectPref">
<option slot="first" :value="null">Country</option>
</b-form-select>
<b-form-select @change="formChange" class="mb-2 mr-sm-2 mb-sm-0"
:v-model="form.camera"
:options="{ '1': 'One', '2': 'Two', '3': 'Three' }"
id="inlineFormCustomSelectPref">
<option slot="first" :value="null">Camera Model</option>
</b-form-select>
<b-form-select @change="formChange" class="mb-2 mr-sm-2 mb-sm-0"
v-model="dir"
:options="{ 'asc': 'Ascending', 'desc': 'Descending' }"
id="inlineFormCustomSelectPref">
<option slot="first" :value="null">Sort Order</option>
</b-form-select>
<b-form-select @change="formChange" class="mb-2 mr-sm-2 mb-sm-0"
v-model="form.view"
:options="{ 'list': 'List View', 'tile': 'Tile View (small)', 'tilel_large': 'Tile View (large)' }"
id="inlineFormCustomSelectPref">
</b-form-select>
<b-form-input class="mb-2 mr-sm-2 mb-sm-0" v-b-tooltip.hover title="Date" type="date"/>
<b-form-input class="mb-2 mr-sm-2 mb-sm-0" placeholder="Tags" v-b-tooltip.hover title="Tags" type="text"/>
<b-form-checkbox class="mb-2 mr-sm-2 mb-sm-0">
Favorites only
</b-form-checkbox>
</b-form>
<div class="clearfix"></div>
</div>
<div class="page-container photo-grid">
<template v-for="photo in rows">
<div class="photo">
<div class="info">{{ photo.CreatedAt | moment("DD.MM.YYYY hh:mm:ss") }}<span class="right">{{ photo.CameraModel }}</span></div>
<div class="info">{{ photo.TakenAt | moment("DD.MM.YYYY hh:mm:ss") }}<span class="right">{{ photo.CameraModel }}</span></div>
<div class="actions">
<a class="action like" v-bind:class="{ liked: photo.Liked, notliked: !photo.Liked }" v-on:click="likePhoto(photo)">
<i v-if="!photo.Liked" class="far fa-heart"></i>
<i v-if="photo.Liked" class="fas fa-heart"></i>
</a>
<span class="left">
<a class="action like" v-bind:class="{ favorite: photo.Favorite }" v-on:click="likePhoto(photo)">
<i v-if="!photo.Favorite" class="far fa-heart"></i>
<i v-if="photo.Favorite" class="fas fa-heart"></i>
</a>
</span>
<span class="center" v-if="photo.Location">
<a class="location" target="_blank" :href="photo.getGoogleMapsLink()" v-b-tooltip.hover :title="photo.Location.DisplayName">{{ photo.Location.Country }}</a>
</span>
<span class="right">
<a class="action delete" v-on:click="deletePhoto(photo)">
<i class="fas fa-trash"></i>
@ -33,18 +82,22 @@
props: {},
data() {
const query = this.$route.query;
const resultCount = query.hasOwnProperty('count') ? parseInt(query['count']) : 15;
const resultCount = query.hasOwnProperty('count') ? parseInt(query['count']) : 70;
const resultPage = query.hasOwnProperty('page') ? parseInt(query['page']) : 1;
const resultOffset = resultCount * (resultPage - 1);
const order = query.hasOwnProperty('order') ? query['order'] : '';
const dir = query.hasOwnProperty('dir') ? query['dir'] : '';
const q = query.hasOwnProperty('q') ? query['q'] : '';
return {
'rows': [],
'images': [],
'form': {
category: '',
camera: '',
dir: 'asc',
view: 'list',
},
'page': resultPage,
'order': order,
'dir': dir,
'q': q,
'pageOptions': [15, 30, 50, 100],
@ -57,19 +110,21 @@
},
methods: {
likePhoto(photo) {
photo.Liked = !photo.Liked;
photo.Favorite = !photo.Favorite;
},
deletePhoto(photo) {
this.$alert.success('Photo deleted');
},
formChange(event) {
this.$alert.success('Form change');
this.refreshList();
},
refreshList() {
// Compose query parameters
const params = {
count: this.resultCount,
offset: this.resultCount * (this.page - 1),
order: this.order !== '' ? this.order + ' ' + this.dir : '',
dir: this.dir,
};
Object.assign(params, this.query);
@ -83,7 +138,6 @@
const urlParams = {
count: this.resultCount,
page: this.page,
order: this.order,
dir: this.dir,
q: this.q
};
@ -92,13 +146,13 @@
this.$router.replace({query: urlParams});
Photo.search(params).then(response => {
Photo.search(urlParams).then(response => {
console.log(response);
this.resultTotal = parseInt(response.headers['x-result-total']);
this.resultCount = parseInt(response.headers['x-result-count']);
this.resultOffset = parseInt(response.headers['x-result-offset']);
this.rows = response.models;
this.$alert.info(this.rows.length + ' photos loaded');
this.$alert.info(this.rows.length + ' photos found');
});
}
},
@ -109,17 +163,4 @@
</script>
<style scoped>
h1, h2 {
font-weight: normal;
}
ul {
list-style-type: none;
padding: 0;
}
li {
display: inline-block;
margin: 0 10px;
}
</style>

View file

@ -19,7 +19,7 @@
<!-- Right aligned nav items -->
<b-navbar-nav class="ml-auto">
<b-nav-form>
<b-nav-form action="/photos">
<b-form-input size="sm" class="mr-sm-2" type="text" name="q" :value="q" placeholder="Search"/>
<b-button size="sm" class="my-2 my-sm-0" type="submit">Search</b-button>
</b-nav-form>
@ -38,31 +38,10 @@
const q = query.hasOwnProperty('q') ? query['q'] : '';
return {
'q': q,
q: q,
};
},
methods: {
toggleLeftSidenav() {
this.$refs.leftSidenav.toggle();
},
open(ref) {
},
close(ref) {
},
login() {
this.$refs.loginDialog.open();
},
register() {
this.$refs.registerDialog.open();
},
logout() {
this.$session.logout();
},
}
};
</script>

View file

@ -9,6 +9,10 @@ class Photo extends Abstract {
return this.ID;
}
getGoogleMapsLink() {
return 'https://www.google.com/maps/place/' + this.Lat + ',' + this.Long;
}
static getCollectionResource() {
return 'photos';
}

View file

@ -69,13 +69,19 @@ func (i *Indexer) IndexMediaFile(mediaFile *MediaFile) {
photo.Tags = i.GetImageTags(jpeg)
}
if location, err := mediaFile.GetLocation(); err == nil {
i.db.FirstOrCreate(location, "id = ?", location.ID)
photo.Location = location
}
photo.TakenAt = mediaFile.GetDateCreated()
photo.CanonicalName = canonicalName
photo.Files = []File{}
photo.Albums = []Album{}
photo.Author = ""
photo.CameraModel = mediaFile.GetCameraModel()
photo.LocationName = ""
photo.Liked = false
photo.Favorite = false
photo.Private = true
photo.Deleted = false
@ -88,6 +94,7 @@ func (i *Indexer) IndexMediaFile(mediaFile *MediaFile) {
file.Hash = fileHash
file.FileType = mediaFile.GetType()
file.MimeType = mediaFile.GetMimeType()
file.Orientation = mediaFile.GetOrientation()
if mediaFile.GetWidth() > 0 && mediaFile.GetHeight() > 0 {
file.Width = mediaFile.GetWidth()

104
location.go Normal file
View file

@ -0,0 +1,104 @@
package photoprism
import (
"fmt"
"encoding/json"
"net/http"
"github.com/jinzhu/gorm"
"strconv"
"errors"
)
type Location struct {
gorm.Model
DisplayName string
Lat float64
Long float64
City string
Postcode string
County string
State string
Country string
CountryCode string
LocationCategory string
LocationType string
Favorite bool
}
type OpenstreetmapAddress struct {
Town string `json:"town"`
City string `json:"city"`
Postcode string `json:"postcode"`
County string `json:"county"`
State string `json:"state"`
Country string `json:"country"`
CountryCode string `json:"country_code"`
}
type OpenstreetmapLocation struct {
PlaceId string `json:"place_id"`
Lat string `json:"lat"`
Lon string `json:"lon"`
Category string `json:"category"`
Type string `json:"type"`
DisplayName string `json:"display_name"`
Address *OpenstreetmapAddress `json:"address"`
}
// See https://wiki.openstreetmap.org/wiki/Nominatim#Reverse_Geocoding
func (m *MediaFile) GetLocation() (*Location, error) {
if m.location != nil {
return m.location, nil
}
location := &Location{}
openstreetmapLocation := &OpenstreetmapLocation{
Address: &OpenstreetmapAddress{},
}
if exifData, err := m.GetExifData(); err == nil {
url := fmt.Sprintf("https://nominatim.openstreetmap.org/reverse?lat=%f&lon=%f&format=jsonv2", exifData.Lat, exifData.Long)
if res, err := http.Get(url); err == nil {
json.NewDecoder(res.Body).Decode(openstreetmapLocation)
} else {
return nil, err
}
} else {
return nil, err
}
if id, err := strconv.Atoi(openstreetmapLocation.PlaceId); err == nil && id > 0 {
location.ID = uint(id)
} else {
return nil, errors.New("no location found")
}
if openstreetmapLocation.Address.City != "" {
location.City = openstreetmapLocation.Address.City
} else {
location.City = openstreetmapLocation.Address.Town
}
if lat, err := strconv.ParseFloat(openstreetmapLocation.Lat, 64); err == nil {
location.Lat = lat
}
if lon, err := strconv.ParseFloat(openstreetmapLocation.Lon, 64); err == nil {
location.Long = lon
}
location.Postcode = openstreetmapLocation.Address.Postcode
location.County = openstreetmapLocation.Address.County
location.State = openstreetmapLocation.Address.State
location.Country = openstreetmapLocation.Address.Country
location.CountryCode = openstreetmapLocation.Address.CountryCode
location.DisplayName = openstreetmapLocation.DisplayName
location.LocationCategory = openstreetmapLocation.Category
location.LocationType = openstreetmapLocation.Type
m.location = location
return m.location, nil
}

View file

@ -58,9 +58,10 @@ type MediaFile struct {
mimeType string
perceptualHash string
tags []string
exifData *ExifData
width int
height int
exifData *ExifData
location *Location
}
func NewMediaFile(filename string) *MediaFile {
@ -408,5 +409,13 @@ func (m *MediaFile) GetAspectRatio() float64 {
aspectRatio := width / height
return math.Round(aspectRatio * 100) / 100
}
return math.Round(aspectRatio*100) / 100
}
func (m *MediaFile) GetOrientation() int {
if exif, err := m.GetExifData(); err == nil {
return exif.Orientation
} else {
return 1
}
}

View file

@ -82,6 +82,8 @@ func (m *MediaFile) GetExifData() (*ExifData, error) {
if orientation, err := x.Get(exif.Orientation); err == nil {
m.exifData.Orientation, _ = orientation.Int(0)
} else {
m.exifData.Orientation = 1
}

View file

@ -2,12 +2,14 @@ package photoprism
import (
"github.com/jinzhu/gorm"
"time"
)
type Photo struct {
gorm.Model
Title string
Description string `gorm:"type:text;"`
TakenAt time.Time
CanonicalName string
PerceptualHash string
Tags []Tag `gorm:"many2many:photo_tags;"`
@ -15,10 +17,11 @@ type Photo struct {
Albums []Album `gorm:"many2many:album_photos;"`
Author string
CameraModel string
LocationName string
Lat float64
Long float64
Liked bool
Location *Location
LocationID uint
Favorite bool
Private bool
Deleted bool
}
}

View file

@ -18,8 +18,17 @@ func NewQuery(originalsPath string, db *gorm.DB) *Search {
return instance
}
func (s *Search) FindPhotos (count int, offset int) (photos []Photo) {
s.db.Preload("Tags").Preload("Files").Preload("Albums").Where(&Photo{Deleted: false}).Limit(count).Offset(offset).Find(&photos)
func (s *Search) FindPhotos (query string, count int, offset int) (photos []Photo) {
q := s.db.Preload("Tags").Preload("Files").Preload("Location").Preload("Albums")
if query != "" {
q = q.Joins("JOIN photo_tags ON photo_tags.photo_id=photos.id")
q = q.Joins("JOIN tags ON photo_tags.tag_id=tags.id")
q = q.Where("tags.label LIKE ?", "%" + query + "%")
}
q = q.Where(&Photo{Deleted: false}).Order("taken_at").Limit(count).Offset(offset)
q = q.Find(&photos)
return photos
}

View file

@ -6,6 +6,7 @@ import (
"net/http"
"github.com/photoprism/photoprism"
"strconv"
"log"
)
func Start(address string, port int, conf *photoprism.Config) {
@ -22,11 +23,24 @@ func Start(address string, port int, conf *photoprism.Config) {
v1.GET("/photos", func(c *gin.Context) {
search := photoprism.NewQuery(conf.OriginalsPath, conf.GetDb())
photos := search.FindPhotos(70, 0)
count, _ := strconv.Atoi(c.DefaultQuery("count", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
query := c.DefaultQuery("q", "")
photos := search.FindPhotos(query, count, offset)
log.Printf("Query: %s, Count: %d", query, count)
c.Header("x-result-total", strconv.Itoa(len(photos)))
c.Header("x-result-count", strconv.Itoa(count))
c.Header("x-result-offset", strconv.Itoa(offset))
c.JSON(http.StatusOK, photos)
})
// v1.OPTIONS()
v1.GET("/files", func(c *gin.Context) {
search := photoprism.NewQuery(conf.OriginalsPath, conf.GetDb())

View file

@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"strings"
"image"
)
func CreateThumbnailsFromOriginals(originalsPath string, thumbnailsPath string, size int, square bool) {
@ -60,21 +61,36 @@ func (m *MediaFile) GetThumbnail(path string, size int) (result *MediaFile, err
return m.CreateThumbnail(thumbnailFilename, size)
}
func (m *MediaFile) fixImageOrientation(img image.Image) image.Image {
switch orientation := m.GetOrientation(); orientation {
case 3:
img = imaging.Rotate180(img)
case 6:
img = imaging.Rotate270(img)
case 8:
img = imaging.Rotate90(img)
}
return img
}
// Resize preserving the aspect ratio
func (m *MediaFile) CreateThumbnail(filename string, size int) (result *MediaFile, err error) {
image, err := imaging.Open(m.filename)
img, err := imaging.Open(m.filename)
if err != nil {
log.Printf("open failed: %s", err.Error())
return nil, err
}
image = imaging.Fit(image, size, size, imaging.Lanczos)
img = m.fixImageOrientation(img)
err = imaging.Save(image, filename)
img = imaging.Fit(img, size, size, imaging.Lanczos)
err = imaging.Save(img, filename)
if err != nil {
log.Fatalf("failed to save image: %v", err)
log.Fatalf("failed to save img: %v", err)
return nil, err
}
@ -102,19 +118,21 @@ func (m *MediaFile) GetSquareThumbnail(path string, size int) (result *MediaFile
// Resize and crop to square format
func (m *MediaFile) CreateSquareThumbnail(filename string, size int) (result *MediaFile, err error) {
image, err := imaging.Open(m.filename)
img, err := imaging.Open(m.filename)
if err != nil {
log.Printf("open failed: %s", err.Error())
return nil, err
}
image = imaging.Fill(image, size, size, imaging.Center, imaging.Lanczos)
img = m.fixImageOrientation(img)
err = imaging.Save(image, filename)
img = imaging.Fill(img, size, size, imaging.Center, imaging.Lanczos)
err = imaging.Save(img, filename)
if err != nil {
log.Fatalf("failed to save image: %v", err)
log.Fatalf("failed to save img: %v", err)
return nil, err
}