Moved all assets to assets/ and improved config
|
@ -1,3 +1,11 @@
|
||||||
photos/
|
assets/photos/*
|
||||||
|
assets/thumbnails/*
|
||||||
|
assets/testdata/import
|
||||||
|
assets/testdata/originals
|
||||||
|
assets/testdata/thumbnails
|
||||||
frontend/node_modules/*
|
frontend/node_modules/*
|
||||||
server/assets/public/build/*
|
assets/public/build/*
|
||||||
|
Dockerfile
|
||||||
|
photoprism
|
||||||
|
docker-compose*
|
||||||
|
.travis.yml
|
17
.gitignore
vendored
|
@ -1,11 +1,17 @@
|
||||||
|
# Application files and directories
|
||||||
|
/photoprism
|
||||||
|
assets/photos/originals/*
|
||||||
|
assets/photos/import/*
|
||||||
|
assets/photos/export/*
|
||||||
|
frontend/node_modules/*
|
||||||
|
*.log
|
||||||
|
|
||||||
# Binaries for programs and plugins
|
# Binaries for programs and plugins
|
||||||
*.exe
|
*.exe
|
||||||
*.dll
|
*.dll
|
||||||
*.so
|
*.so
|
||||||
*.dylib
|
*.dylib
|
||||||
/*.zip
|
/*.zip
|
||||||
/photoprism
|
|
||||||
vendor/
|
|
||||||
# Test binary, build with `go test -c`
|
# Test binary, build with `go test -c`
|
||||||
*.test
|
*.test
|
||||||
|
|
||||||
|
@ -15,11 +21,6 @@ vendor/
|
||||||
# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736
|
# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736
|
||||||
.glide/
|
.glide/
|
||||||
|
|
||||||
# Compiled, config and log files
|
|
||||||
*.log
|
|
||||||
config.yml
|
|
||||||
/tweethog
|
|
||||||
|
|
||||||
# Generated files
|
# Generated files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.DS_Store?
|
.DS_Store?
|
||||||
|
@ -36,5 +37,3 @@ Thumbs.db
|
||||||
.settings
|
.settings
|
||||||
.swp
|
.swp
|
||||||
.tmp
|
.tmp
|
||||||
/photos/
|
|
||||||
node_modules/
|
|
14
Dockerfile
|
@ -102,24 +102,14 @@ ENV PATH $GOBIN:/usr/local/go/bin:$PATH
|
||||||
ENV GO111MODULE on
|
ENV GO111MODULE on
|
||||||
ENV NODE_ENV production
|
ENV NODE_ENV production
|
||||||
|
|
||||||
RUN mkdir -p "$GOPATH/src" "$GOPATH/bin" /etc/photoprism /var/photos && chmod -R 777 "$GOPATH"
|
RUN mkdir -p "$GOPATH/src" "$GOPATH/bin" && chmod -R 777 "$GOPATH"
|
||||||
|
|
||||||
# Download InceptionV3 model
|
|
||||||
RUN mkdir -p /model && \
|
|
||||||
wget "https://storage.googleapis.com/download.tensorflow.org/models/inception5h.zip" -O /model/inception.zip && \
|
|
||||||
unzip /model/inception.zip -d /model && \
|
|
||||||
chmod -R 777 /model
|
|
||||||
|
|
||||||
# Set up project directory
|
# Set up project directory
|
||||||
WORKDIR "/go/src/github.com/photoprism/photoprism"
|
WORKDIR "/go/src/github.com/photoprism/photoprism"
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN cp config.prod.yml /etc/photoprism/config.yml
|
|
||||||
|
|
||||||
# Build PhotoPrism
|
# Build PhotoPrism
|
||||||
RUN make dep js install
|
RUN make all install
|
||||||
|
|
||||||
RUN cp -r server/assets /etc/photoprism
|
|
||||||
|
|
||||||
# Expose HTTP port
|
# Expose HTTP port
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
12
Makefile
|
@ -9,9 +9,15 @@ GOGET=$(GOCMD) get
|
||||||
GOFMT=$(GOCMD) fmt
|
GOFMT=$(GOCMD) fmt
|
||||||
BINARY_NAME=photoprism
|
BINARY_NAME=photoprism
|
||||||
|
|
||||||
all: dep js build
|
all: tensorflow-model dep js build
|
||||||
install:
|
install: install-bin install-assets install-config
|
||||||
|
install-bin:
|
||||||
$(GOINSTALL) cmd/photoprism/photoprism.go
|
$(GOINSTALL) cmd/photoprism/photoprism.go
|
||||||
|
install-assets:
|
||||||
|
cp -r assets /var/photoprism
|
||||||
|
install-config:
|
||||||
|
mkdir -p /etc/photoprism
|
||||||
|
cp config.prod.yml /etc/photoprism/config.yml
|
||||||
build:
|
build:
|
||||||
$(GOBUILD) cmd/photoprism/photoprism.go
|
$(GOBUILD) cmd/photoprism/photoprism.go
|
||||||
js:
|
js:
|
||||||
|
@ -26,6 +32,8 @@ test:
|
||||||
clean:
|
clean:
|
||||||
$(GOCLEAN)
|
$(GOCLEAN)
|
||||||
rm -f $(BINARY_NAME)
|
rm -f $(BINARY_NAME)
|
||||||
|
tensorflow-model:
|
||||||
|
scripts/download-tf-model.sh
|
||||||
image:
|
image:
|
||||||
docker build . --tag photoprism/photoprism
|
docker build . --tag photoprism/photoprism
|
||||||
docker push photoprism/photoprism
|
docker push photoprism/photoprism
|
||||||
|
|
|
@ -21,7 +21,7 @@ Originals are stored in the file system in a structured way for easy backup and
|
||||||
|
|
||||||
Our goal is to provide the following features (tested as a proof-of-concept):
|
Our goal is to provide the following features (tested as a proof-of-concept):
|
||||||
- High-performance command line tool
|
- High-performance command line tool
|
||||||
- [Web frontend](docs/img/screenshot.jpg)
|
- [Web frontend](assets/docs/img/screenshot.jpg)
|
||||||
- No proprietary or binary data formats
|
- No proprietary or binary data formats
|
||||||
- Automatic RAW to JPEG conversion
|
- Automatic RAW to JPEG conversion
|
||||||
- Duplicate detection (JPEG and RAW can be used simultaneously)
|
- Duplicate detection (JPEG and RAW can be used simultaneously)
|
||||||
|
@ -35,7 +35,7 @@ Web Frontend
|
||||||
Open a terminal an type `photoprism start` to start the built-in server. It will listen on port 80 by default.
|
Open a terminal an type `photoprism start` to start the built-in server. It will listen on port 80 by default.
|
||||||
The UI is based on [Vuetify](https://vuetifyjs.com/en/), a [Material Design](https://material.io/) component framework for Vue.js 2.
|
The UI is based on [Vuetify](https://vuetifyjs.com/en/), a [Material Design](https://material.io/) component framework for Vue.js 2.
|
||||||
|
|
||||||
![](docs/img/screenshot.jpg)
|
![](assets/docs/img/screenshot.jpg)
|
||||||
|
|
||||||
Setup
|
Setup
|
||||||
-----
|
-----
|
||||||
|
@ -73,4 +73,4 @@ See [Quick and easy guide for migrating to Go 1.11 modules](https://blog.liquidb
|
||||||
Concept
|
Concept
|
||||||
-------
|
-------
|
||||||
|
|
||||||
![](docs/img/concept.jpg)
|
![](assets/docs/img/concept.jpg)
|
||||||
|
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 110 KiB |
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
Before Width: | Height: | Size: 330 KiB After Width: | Height: | Size: 330 KiB |
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
Before Width: | Height: | Size: 510 KiB After Width: | Height: | Size: 510 KiB |
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
2
assets/tensorflow/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
*
|
||||||
|
!.gitignore
|
2
assets/testdata/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
*
|
||||||
|
!.gitignore
|
2
assets/thumbnails/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
*
|
||||||
|
!.gitignore
|
|
@ -10,8 +10,6 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
conf := photoprism.NewConfig()
|
|
||||||
|
|
||||||
app := cli.NewApp()
|
app := cli.NewApp()
|
||||||
app.Name = "PhotoPrism"
|
app.Name = "PhotoPrism"
|
||||||
app.Usage = "Digital Photo Archive"
|
app.Usage = "Digital Photo Archive"
|
||||||
|
@ -22,9 +20,7 @@ func main() {
|
||||||
Name: "config",
|
Name: "config",
|
||||||
Usage: "Displays global configuration values",
|
Usage: "Displays global configuration values",
|
||||||
Action: func(context *cli.Context) error {
|
Action: func(context *cli.Context) error {
|
||||||
conf.SetValuesFromFile(photoprism.GetExpandedFilename(context.GlobalString("config-file")))
|
conf := photoprism.NewConfig(context)
|
||||||
|
|
||||||
conf.SetValuesFromCliContext(context)
|
|
||||||
|
|
||||||
fmt.Printf("NAME VALUE\n")
|
fmt.Printf("NAME VALUE\n")
|
||||||
fmt.Printf("debug %t\n", conf.Debug)
|
fmt.Printf("debug %t\n", conf.Debug)
|
||||||
|
@ -32,12 +28,14 @@ func main() {
|
||||||
fmt.Printf("server-ip %s\n", conf.ServerIP)
|
fmt.Printf("server-ip %s\n", conf.ServerIP)
|
||||||
fmt.Printf("server-port %d\n", conf.ServerPort)
|
fmt.Printf("server-port %d\n", conf.ServerPort)
|
||||||
fmt.Printf("server-mode %s\n", conf.ServerMode)
|
fmt.Printf("server-mode %s\n", conf.ServerMode)
|
||||||
fmt.Printf("server-assets-path %s\n", conf.ServerAssetsPath)
|
fmt.Printf("assets-path %s\n", conf.AssetsPath)
|
||||||
fmt.Printf("darktable-cli %s\n", conf.DarktableCli)
|
|
||||||
fmt.Printf("originals-path %s\n", conf.OriginalsPath)
|
fmt.Printf("originals-path %s\n", conf.OriginalsPath)
|
||||||
fmt.Printf("thumbnails-path %s\n", conf.ThumbnailsPath)
|
fmt.Printf("thumbnails-path %s\n", conf.ThumbnailsPath)
|
||||||
fmt.Printf("import-path %s\n", conf.ImportPath)
|
fmt.Printf("import-path %s\n", conf.ImportPath)
|
||||||
fmt.Printf("export-path %s\n", conf.ExportPath)
|
fmt.Printf("export-path %s\n", conf.ExportPath)
|
||||||
|
fmt.Printf("darktable-cli %s\n", conf.DarktableCli)
|
||||||
|
fmt.Printf("database-driver %s\n", conf.DatabaseDriver)
|
||||||
|
fmt.Printf("database-dsn %s\n", conf.DatabaseDsn)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
@ -63,9 +61,7 @@ func main() {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Action: func(context *cli.Context) error {
|
Action: func(context *cli.Context) error {
|
||||||
conf.SetValuesFromFile(photoprism.GetExpandedFilename(context.GlobalString("config-file")))
|
conf := photoprism.NewConfig(context)
|
||||||
|
|
||||||
conf.SetValuesFromCliContext(context)
|
|
||||||
|
|
||||||
if context.IsSet("server-ip") {
|
if context.IsSet("server-ip") {
|
||||||
conf.ServerIP = context.String("server-ip")
|
conf.ServerIP = context.String("server-ip")
|
||||||
|
@ -96,9 +92,7 @@ func main() {
|
||||||
Name: "migrate-db",
|
Name: "migrate-db",
|
||||||
Usage: "Automatically migrates / initializes database",
|
Usage: "Automatically migrates / initializes database",
|
||||||
Action: func(context *cli.Context) error {
|
Action: func(context *cli.Context) error {
|
||||||
conf.SetValuesFromFile(photoprism.GetExpandedFilename(context.GlobalString("config-file")))
|
conf := photoprism.NewConfig(context)
|
||||||
|
|
||||||
conf.SetValuesFromCliContext(context)
|
|
||||||
|
|
||||||
fmt.Println("Migrating database...")
|
fmt.Println("Migrating database...")
|
||||||
|
|
||||||
|
@ -113,9 +107,7 @@ func main() {
|
||||||
Name: "import",
|
Name: "import",
|
||||||
Usage: "Imports photos",
|
Usage: "Imports photos",
|
||||||
Action: func(context *cli.Context) error {
|
Action: func(context *cli.Context) error {
|
||||||
conf.SetValuesFromFile(photoprism.GetExpandedFilename(context.GlobalString("config-file")))
|
conf := photoprism.NewConfig(context)
|
||||||
|
|
||||||
conf.SetValuesFromCliContext(context)
|
|
||||||
|
|
||||||
conf.CreateDirectories()
|
conf.CreateDirectories()
|
||||||
|
|
||||||
|
@ -123,7 +115,9 @@ func main() {
|
||||||
|
|
||||||
fmt.Printf("Importing photos from %s...\n", conf.ImportPath)
|
fmt.Printf("Importing photos from %s...\n", conf.ImportPath)
|
||||||
|
|
||||||
indexer := photoprism.NewIndexer(conf.OriginalsPath, conf.GetDb())
|
tensorFlow := photoprism.NewTensorFlow(conf.GetTensorFlowModelPath())
|
||||||
|
|
||||||
|
indexer := photoprism.NewIndexer(conf.OriginalsPath, tensorFlow, conf.GetDb())
|
||||||
|
|
||||||
importer := photoprism.NewImporter(conf.OriginalsPath, indexer)
|
importer := photoprism.NewImporter(conf.OriginalsPath, indexer)
|
||||||
|
|
||||||
|
@ -138,9 +132,7 @@ func main() {
|
||||||
Name: "index",
|
Name: "index",
|
||||||
Usage: "Re-indexes all originals",
|
Usage: "Re-indexes all originals",
|
||||||
Action: func(context *cli.Context) error {
|
Action: func(context *cli.Context) error {
|
||||||
conf.SetValuesFromFile(photoprism.GetExpandedFilename(context.GlobalString("config-file")))
|
conf := photoprism.NewConfig(context)
|
||||||
|
|
||||||
conf.SetValuesFromCliContext(context)
|
|
||||||
|
|
||||||
conf.CreateDirectories()
|
conf.CreateDirectories()
|
||||||
|
|
||||||
|
@ -148,7 +140,9 @@ func main() {
|
||||||
|
|
||||||
fmt.Printf("Indexing photos in %s...\n", conf.OriginalsPath)
|
fmt.Printf("Indexing photos in %s...\n", conf.OriginalsPath)
|
||||||
|
|
||||||
indexer := photoprism.NewIndexer(conf.OriginalsPath, conf.GetDb())
|
tensorFlow := photoprism.NewTensorFlow(conf.GetTensorFlowModelPath())
|
||||||
|
|
||||||
|
indexer := photoprism.NewIndexer(conf.OriginalsPath, tensorFlow, conf.GetDb())
|
||||||
|
|
||||||
indexer.IndexAll()
|
indexer.IndexAll()
|
||||||
|
|
||||||
|
@ -161,9 +155,7 @@ func main() {
|
||||||
Name: "convert",
|
Name: "convert",
|
||||||
Usage: "Converts RAW originals to JPEG",
|
Usage: "Converts RAW originals to JPEG",
|
||||||
Action: func(context *cli.Context) error {
|
Action: func(context *cli.Context) error {
|
||||||
conf.SetValuesFromFile(photoprism.GetExpandedFilename(context.GlobalString("config-file")))
|
conf := photoprism.NewConfig(context)
|
||||||
|
|
||||||
conf.SetValuesFromCliContext(context)
|
|
||||||
|
|
||||||
conf.CreateDirectories()
|
conf.CreateDirectories()
|
||||||
|
|
||||||
|
@ -196,9 +188,7 @@ func main() {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Action: func(context *cli.Context) error {
|
Action: func(context *cli.Context) error {
|
||||||
conf.SetValuesFromFile(photoprism.GetExpandedFilename(context.GlobalString("config-file")))
|
conf := photoprism.NewConfig(context)
|
||||||
|
|
||||||
conf.SetValuesFromCliContext(context)
|
|
||||||
|
|
||||||
conf.CreateDirectories()
|
conf.CreateDirectories()
|
||||||
|
|
||||||
|
@ -247,9 +237,7 @@ func main() {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Action: func(context *cli.Context) error {
|
Action: func(context *cli.Context) error {
|
||||||
conf.SetValuesFromFile(photoprism.GetExpandedFilename(context.GlobalString("config-file")))
|
conf := photoprism.NewConfig(context)
|
||||||
|
|
||||||
conf.SetValuesFromCliContext(context)
|
|
||||||
|
|
||||||
conf.CreateDirectories()
|
conf.CreateDirectories()
|
||||||
|
|
||||||
|
@ -313,27 +301,27 @@ var globalCliFlags = []cli.Flag{
|
||||||
cli.StringFlag{
|
cli.StringFlag{
|
||||||
Name: "originals-path",
|
Name: "originals-path",
|
||||||
Usage: "originals path",
|
Usage: "originals path",
|
||||||
Value: "/var/photoprism/originals",
|
Value: "/var/photoprism/photos/originals",
|
||||||
},
|
},
|
||||||
cli.StringFlag{
|
cli.StringFlag{
|
||||||
Name: "thumbnails-path",
|
Name: "thumbnails-path",
|
||||||
Usage: "thumbnails path",
|
Usage: "thumbnails path",
|
||||||
Value: "/var/photoprism/thumbnails",
|
Value: "/var/photoprism/photos/thumbnails",
|
||||||
},
|
},
|
||||||
cli.StringFlag{
|
cli.StringFlag{
|
||||||
Name: "import-path",
|
Name: "import-path",
|
||||||
Usage: "import path",
|
Usage: "import path",
|
||||||
Value: "/var/photoprism/import",
|
Value: "/var/photoprism/photos/import",
|
||||||
},
|
},
|
||||||
cli.StringFlag{
|
cli.StringFlag{
|
||||||
Name: "export-path",
|
Name: "export-path",
|
||||||
Usage: "export path",
|
Usage: "export path",
|
||||||
Value: "/var/photoprism/export",
|
Value: "/var/photoprism/photos/export",
|
||||||
},
|
},
|
||||||
cli.StringFlag{
|
cli.StringFlag{
|
||||||
Name: "server-assets-path",
|
Name: "assets-path",
|
||||||
Usage: "server assets path for templates, js and css",
|
Usage: "assets path",
|
||||||
Value: "/var/photoprism/server",
|
Value: "/var/photoprism",
|
||||||
},
|
},
|
||||||
cli.StringFlag{
|
cli.StringFlag{
|
||||||
Name: "database-driver",
|
Name: "database-driver",
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
debug: true
|
debug: true
|
||||||
darktable-cli: /usr/bin/darktable-cli
|
darktable-cli: /usr/bin/darktable-cli
|
||||||
originals-path: photos/originals
|
assets-path: assets
|
||||||
thumbnails-path: photos/thumbnails
|
thumbnails-path: assets/thumbnails
|
||||||
import-path: photos/import
|
originals-path: assets/photos/originals
|
||||||
export-path: photos/export
|
import-path: assets/photos/import
|
||||||
|
export-path: assets/photos/export
|
||||||
server-ip:
|
server-ip:
|
||||||
server-mode: debug
|
server-mode: debug
|
||||||
server-port: 80
|
server-port: 80
|
||||||
server-assets-path: server/assets
|
|
||||||
database-driver: mysql
|
database-driver: mysql
|
||||||
database-dsn: photoprism:photoprism@tcp(database:3306)/photoprism?parseTime=true
|
database-dsn: photoprism:photoprism@tcp(database:3306)/photoprism?parseTime=true
|
64
config.go
|
@ -15,26 +15,30 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Debug bool
|
Debug bool
|
||||||
ConfigFile string
|
ConfigFile string
|
||||||
ServerIP string
|
ServerIP string
|
||||||
ServerPort int
|
ServerPort int
|
||||||
ServerMode string
|
ServerMode string
|
||||||
ServerAssetsPath string
|
AssetsPath string
|
||||||
DarktableCli string
|
ThumbnailsPath string
|
||||||
OriginalsPath string
|
OriginalsPath string
|
||||||
ThumbnailsPath string
|
ImportPath string
|
||||||
ImportPath string
|
ExportPath string
|
||||||
ExportPath string
|
DarktableCli string
|
||||||
DatabaseDriver string
|
DatabaseDriver string
|
||||||
DatabaseDsn string
|
DatabaseDsn string
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConfigValues map[string]interface{}
|
type ConfigValues map[string]interface{}
|
||||||
|
|
||||||
func NewConfig() *Config {
|
func NewConfig(context *cli.Context) *Config {
|
||||||
return &Config{}
|
c := &Config{}
|
||||||
|
c.SetValuesFromFile(GetExpandedFilename(context.GlobalString("config-file")))
|
||||||
|
c.SetValuesFromCliContext(context)
|
||||||
|
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) SetValuesFromFile(fileName string) error {
|
func (c *Config) SetValuesFromFile(fileName string) error {
|
||||||
|
@ -62,18 +66,18 @@ func (c *Config) SetValuesFromFile(fileName string) error {
|
||||||
c.ServerMode = serverMode
|
c.ServerMode = serverMode
|
||||||
}
|
}
|
||||||
|
|
||||||
if serverAssetsPath, err := yamlConfig.Get("server-assets-path"); err == nil {
|
if assetsPath, err := yamlConfig.Get("assets-path"); err == nil {
|
||||||
c.ServerAssetsPath = GetExpandedFilename(serverAssetsPath)
|
c.AssetsPath = GetExpandedFilename(assetsPath)
|
||||||
}
|
|
||||||
|
|
||||||
if originalsPath, err := yamlConfig.Get("originals-path"); err == nil {
|
|
||||||
c.OriginalsPath = GetExpandedFilename(originalsPath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if thumbnailsPath, err := yamlConfig.Get("thumbnails-path"); err == nil {
|
if thumbnailsPath, err := yamlConfig.Get("thumbnails-path"); err == nil {
|
||||||
c.ThumbnailsPath = GetExpandedFilename(thumbnailsPath)
|
c.ThumbnailsPath = GetExpandedFilename(thumbnailsPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if originalsPath, err := yamlConfig.Get("originals-path"); err == nil {
|
||||||
|
c.OriginalsPath = GetExpandedFilename(originalsPath)
|
||||||
|
}
|
||||||
|
|
||||||
if importPath, err := yamlConfig.Get("import-path"); err == nil {
|
if importPath, err := yamlConfig.Get("import-path"); err == nil {
|
||||||
c.ImportPath = GetExpandedFilename(importPath)
|
c.ImportPath = GetExpandedFilename(importPath)
|
||||||
}
|
}
|
||||||
|
@ -102,14 +106,18 @@ func (c *Config) SetValuesFromCliContext(context *cli.Context) error {
|
||||||
c.Debug = context.GlobalBool("debug")
|
c.Debug = context.GlobalBool("debug")
|
||||||
}
|
}
|
||||||
|
|
||||||
if context.GlobalIsSet("originals-path") {
|
if context.GlobalIsSet("assets-path") {
|
||||||
c.OriginalsPath = GetExpandedFilename(context.GlobalString("originals-path"))
|
c.AssetsPath = GetExpandedFilename(context.GlobalString("assets-path"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if context.GlobalIsSet("thumbnails-path") {
|
if context.GlobalIsSet("thumbnails-path") {
|
||||||
c.ThumbnailsPath = GetExpandedFilename(context.GlobalString("thumbnails-path"))
|
c.ThumbnailsPath = GetExpandedFilename(context.GlobalString("thumbnails-path"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if context.GlobalIsSet("originals-path") {
|
||||||
|
c.OriginalsPath = GetExpandedFilename(context.GlobalString("originals-path"))
|
||||||
|
}
|
||||||
|
|
||||||
if context.GlobalIsSet("import-path") {
|
if context.GlobalIsSet("import-path") {
|
||||||
c.ImportPath = GetExpandedFilename(context.GlobalString("import-path"))
|
c.ImportPath = GetExpandedFilename(context.GlobalString("import-path"))
|
||||||
}
|
}
|
||||||
|
@ -118,10 +126,6 @@ func (c *Config) SetValuesFromCliContext(context *cli.Context) error {
|
||||||
c.ExportPath = GetExpandedFilename(context.GlobalString("export-path"))
|
c.ExportPath = GetExpandedFilename(context.GlobalString("export-path"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if context.GlobalIsSet("server-assets-path") {
|
|
||||||
c.ServerAssetsPath = GetExpandedFilename(context.GlobalString("server-assets-path"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if context.GlobalIsSet("darktable-cli") {
|
if context.GlobalIsSet("darktable-cli") {
|
||||||
c.DarktableCli = GetExpandedFilename(context.GlobalString("darktable-cli"))
|
c.DarktableCli = GetExpandedFilename(context.GlobalString("darktable-cli"))
|
||||||
}
|
}
|
||||||
|
@ -168,6 +172,10 @@ func (c *Config) ConnectToDatabase() error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Config) GetTensorFlowModelPath() string {
|
||||||
|
return c.AssetsPath + "/tensorflow"
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Config) GetDb() *gorm.DB {
|
func (c *Config) GetDb() *gorm.DB {
|
||||||
if c.db == nil {
|
if c.db == nil {
|
||||||
c.ConnectToDatabase()
|
c.ConnectToDatabase()
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
debug: false
|
debug: false
|
||||||
darktable-cli: /Applications/darktable.app/Contents/MacOS/darktable-cli
|
darktable-cli: /Applications/darktable.app/Contents/MacOS/darktable-cli
|
||||||
originals-path: ~/Photos/Originals
|
assets-path: /var/photoprism
|
||||||
thumbnails-path: ~/Photos/Thumbnails
|
thumbnails-path: /var/photoprism/thumbnails
|
||||||
import-path: ~/Photos/Import
|
originals-path: ~/Pictures/Originals
|
||||||
export-path: ~/Photos/Export
|
import-path: ~/Pictures/Import
|
||||||
|
export-path: ~/Pictures/Export
|
||||||
server-ip:
|
server-ip:
|
||||||
server-mode: release
|
server-mode: release
|
||||||
server-port: 8080
|
server-port: 8080
|
||||||
server-assets-path: ~/Photos/Server
|
|
||||||
database-driver: mysql
|
database-driver: mysql
|
||||||
database-dsn: photoprism:photoprism@tcp(localhost:3306)/photoprism?parseTime=true
|
database-dsn: photoprism:photoprism@tcp(localhost:3306)/photoprism?parseTime=true
|
|
@ -1,12 +1,12 @@
|
||||||
debug: false
|
debug: false
|
||||||
darktable-cli: /usr/bin/darktable-cli
|
darktable-cli: /usr/bin/darktable-cli
|
||||||
originals-path: /var/photos/originals
|
assets-path: /var/photoprism
|
||||||
thumbnails-path: /var/photos/thumbnails
|
thumbnails-path: /var/photoprism/thumbnails
|
||||||
import-path: /var/photos/import
|
originals-path: /var/photoprism/photos/originals
|
||||||
export-path: /var/photos/export
|
import-path: /var/photoprism/photos/import
|
||||||
|
export-path: /var/photoprism/photos/export
|
||||||
server-ip:
|
server-ip:
|
||||||
server-mode: release
|
server-mode: release
|
||||||
server-port: 80
|
server-port: 80
|
||||||
server-assets-path: /etc/photoprism/assets
|
|
||||||
database-driver: mysql
|
database-driver: mysql
|
||||||
database-dsn: photoprism:photoprism@tcp(database:3306)/photoprism?parseTime=true
|
database-dsn: photoprism:photoprism@tcp(database:3306)/photoprism?parseTime=true
|
|
@ -1,24 +1,26 @@
|
||||||
package photoprism
|
package photoprism
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/jinzhu/gorm"
|
"github.com/jinzhu/gorm"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/urfave/cli"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
const testDataPath = "testdata"
|
const testDataPath = "assets/testdata"
|
||||||
const testDataUrl = "https://www.dropbox.com/s/na9p9wwt98l7m5b/import.zip?dl=1"
|
const testDataUrl = "https://www.dropbox.com/s/na9p9wwt98l7m5b/import.zip?dl=1"
|
||||||
const testDataHash = "ed3bdb2fe86ea662bc863b63e219b47b8d9a74024757007f7979887d"
|
const testDataHash = "ed3bdb2fe86ea662bc863b63e219b47b8d9a74024757007f7979887d"
|
||||||
|
|
||||||
var darktableCli = "/usr/bin/darktable-cli"
|
var darktableCli = "/usr/bin/darktable-cli"
|
||||||
var testDataZip = GetExpandedFilename(testDataPath + "/import.zip")
|
var testDataZip = GetExpandedFilename(testDataPath + "/import.zip")
|
||||||
var originalsPath = GetExpandedFilename(testDataPath + "/originals")
|
var assetsPath = GetExpandedFilename("assets")
|
||||||
var thumbnailsPath = GetExpandedFilename(testDataPath + "/thumbnails")
|
var thumbnailsPath = GetExpandedFilename(testDataPath + "/thumbnails")
|
||||||
|
var originalsPath = GetExpandedFilename(testDataPath + "/originals")
|
||||||
var importPath = GetExpandedFilename(testDataPath + "/import")
|
var importPath = GetExpandedFilename(testDataPath + "/import")
|
||||||
var exportPath = GetExpandedFilename(testDataPath + "/export")
|
var exportPath = GetExpandedFilename(testDataPath + "/export")
|
||||||
var serverAssetsPath = GetExpandedFilename("server/assets")
|
|
||||||
var databaseDriver = "mysql"
|
var databaseDriver = "mysql"
|
||||||
var databaseDsn = "photoprism:photoprism@tcp(database:3306)/photoprism?parseTime=true"
|
var databaseDsn = "photoprism:photoprism@tcp(database:3306)/photoprism?parseTime=true"
|
||||||
|
|
||||||
|
@ -66,34 +68,53 @@ func (c *Config) InitializeTestData(t *testing.T) {
|
||||||
|
|
||||||
func NewTestConfig() *Config {
|
func NewTestConfig() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
Debug: false,
|
Debug: false,
|
||||||
DarktableCli: darktableCli,
|
AssetsPath: assetsPath,
|
||||||
OriginalsPath: originalsPath,
|
ThumbnailsPath: thumbnailsPath,
|
||||||
ThumbnailsPath: thumbnailsPath,
|
OriginalsPath: originalsPath,
|
||||||
ImportPath: importPath,
|
ImportPath: importPath,
|
||||||
ExportPath: exportPath,
|
ExportPath: exportPath,
|
||||||
ServerAssetsPath: serverAssetsPath,
|
DarktableCli: darktableCli,
|
||||||
DatabaseDriver: databaseDriver,
|
DatabaseDriver: databaseDriver,
|
||||||
DatabaseDsn: databaseDsn,
|
DatabaseDsn: databaseDsn,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getTestCliContext() *cli.Context {
|
||||||
|
globalSet := flag.NewFlagSet("test", 0)
|
||||||
|
globalSet.Bool("debug", false, "doc")
|
||||||
|
globalSet.String("config-file", "config.dev.yml", "doc")
|
||||||
|
globalSet.String("assets-path", assetsPath, "doc")
|
||||||
|
globalSet.String("originals-path", originalsPath, "doc")
|
||||||
|
globalSet.String("darktable-cli ", darktableCli, "doc")
|
||||||
|
|
||||||
|
return cli.NewContext(nil, globalSet, nil)
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewConfig(t *testing.T) {
|
func TestNewConfig(t *testing.T) {
|
||||||
c := NewConfig()
|
context := getTestCliContext()
|
||||||
|
|
||||||
|
assert.False(t, context.IsSet("assets-path"))
|
||||||
|
assert.False(t, context.Bool("debug"))
|
||||||
|
|
||||||
|
c := NewConfig(context)
|
||||||
|
|
||||||
assert.IsType(t, &Config{}, c)
|
assert.IsType(t, &Config{}, c)
|
||||||
|
t.Log(c.AssetsPath, c.OriginalsPath, c.DarktableCli)
|
||||||
|
assert.Equal(t, assetsPath, c.AssetsPath)
|
||||||
|
assert.True(t, c.Debug)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfig_SetValuesFromFile(t *testing.T) {
|
func TestConfig_SetValuesFromFile(t *testing.T) {
|
||||||
c := NewConfig()
|
c := NewConfig(getTestCliContext())
|
||||||
|
|
||||||
c.SetValuesFromFile(GetExpandedFilename("config.dev.yml"))
|
c.SetValuesFromFile(GetExpandedFilename("config.dev.yml"))
|
||||||
|
|
||||||
assert.Equal(t, GetExpandedFilename("photos/originals"), c.OriginalsPath)
|
assert.Equal(t, GetExpandedFilename("assets"), c.AssetsPath)
|
||||||
assert.Equal(t, GetExpandedFilename("photos/thumbnails"), c.ThumbnailsPath)
|
assert.Equal(t, GetExpandedFilename("assets/thumbnails"), c.ThumbnailsPath)
|
||||||
assert.Equal(t, GetExpandedFilename("photos/import"), c.ImportPath)
|
assert.Equal(t, GetExpandedFilename("assets/photos/originals"), c.OriginalsPath)
|
||||||
assert.Equal(t, GetExpandedFilename("photos/export"), c.ExportPath)
|
assert.Equal(t, GetExpandedFilename("assets/photos/import"), c.ImportPath)
|
||||||
assert.Equal(t, GetExpandedFilename("server/assets"), c.ServerAssetsPath)
|
assert.Equal(t, GetExpandedFilename("assets/photos/export"), c.ExportPath)
|
||||||
assert.Equal(t, databaseDriver, c.DatabaseDriver)
|
assert.Equal(t, databaseDriver, c.DatabaseDriver)
|
||||||
assert.Equal(t, databaseDsn, c.DatabaseDsn)
|
assert.Equal(t, databaseDsn, c.DatabaseDsn)
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- 80:80
|
- 80:80
|
||||||
volumes:
|
volumes:
|
||||||
- photo-data:/var/photos
|
- photo-data:/var/photoprism/photos
|
||||||
|
|
||||||
database:
|
database:
|
||||||
image: mysql:latest
|
image: mysql:latest
|
||||||
|
|
|
@ -7,7 +7,7 @@ const webpack = require('webpack');
|
||||||
const PATHS = {
|
const PATHS = {
|
||||||
app: path.join(__dirname, 'src/app.js'),
|
app: path.join(__dirname, 'src/app.js'),
|
||||||
css: path.join(__dirname, 'css'),
|
css: path.join(__dirname, 'css'),
|
||||||
build: path.join(__dirname, '../server/assets/public/build'),
|
build: path.join(__dirname, '../assets/public/build'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const cssPlugin = new ExtractTextPlugin({
|
const cssPlugin = new ExtractTextPlugin({
|
||||||
|
|
|
@ -8,7 +8,9 @@ import (
|
||||||
func TestNewImporter(t *testing.T) {
|
func TestNewImporter(t *testing.T) {
|
||||||
conf := NewTestConfig()
|
conf := NewTestConfig()
|
||||||
|
|
||||||
indexer := NewIndexer(conf.OriginalsPath, conf.GetDb())
|
tensorFlow := NewTensorFlow(conf.GetTensorFlowModelPath())
|
||||||
|
|
||||||
|
indexer := NewIndexer(conf.OriginalsPath, tensorFlow, conf.GetDb())
|
||||||
|
|
||||||
importer := NewImporter(conf.OriginalsPath, indexer)
|
importer := NewImporter(conf.OriginalsPath, indexer)
|
||||||
|
|
||||||
|
@ -20,7 +22,9 @@ func TestImporter_ImportPhotosFromDirectory(t *testing.T) {
|
||||||
|
|
||||||
conf.InitializeTestData(t)
|
conf.InitializeTestData(t)
|
||||||
|
|
||||||
indexer := NewIndexer(conf.OriginalsPath, conf.GetDb())
|
tensorFlow := NewTensorFlow(conf.GetTensorFlowModelPath())
|
||||||
|
|
||||||
|
indexer := NewIndexer(conf.OriginalsPath, tensorFlow, conf.GetDb())
|
||||||
|
|
||||||
importer := NewImporter(conf.OriginalsPath, indexer)
|
importer := NewImporter(conf.OriginalsPath, indexer)
|
||||||
|
|
||||||
|
@ -31,7 +35,9 @@ func TestImporter_GetDestinationFilename(t *testing.T) {
|
||||||
conf := NewTestConfig()
|
conf := NewTestConfig()
|
||||||
conf.InitializeTestData(t)
|
conf.InitializeTestData(t)
|
||||||
|
|
||||||
indexer := NewIndexer(conf.OriginalsPath, conf.GetDb())
|
tensorFlow := NewTensorFlow(conf.GetTensorFlowModelPath())
|
||||||
|
|
||||||
|
indexer := NewIndexer(conf.OriginalsPath, tensorFlow, conf.GetDb())
|
||||||
|
|
||||||
importer := NewImporter(conf.OriginalsPath, indexer)
|
importer := NewImporter(conf.OriginalsPath, indexer)
|
||||||
|
|
||||||
|
|
22
indexer.go
|
@ -3,8 +3,6 @@ package photoprism
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/jinzhu/gorm"
|
"github.com/jinzhu/gorm"
|
||||||
"github.com/photoprism/photoprism/recognize"
|
|
||||||
"io/ioutil"
|
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
@ -14,12 +12,14 @@ import (
|
||||||
|
|
||||||
type Indexer struct {
|
type Indexer struct {
|
||||||
originalsPath string
|
originalsPath string
|
||||||
|
tensorFlow *TensorFlow
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewIndexer(originalsPath string, db *gorm.DB) *Indexer {
|
func NewIndexer(originalsPath string, tensorFlow *TensorFlow, db *gorm.DB) *Indexer {
|
||||||
instance := &Indexer{
|
instance := &Indexer{
|
||||||
originalsPath: originalsPath,
|
originalsPath: originalsPath,
|
||||||
|
tensorFlow: tensorFlow,
|
||||||
db: db,
|
db: db,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -27,17 +27,15 @@ func NewIndexer(originalsPath string, db *gorm.DB) *Indexer {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *Indexer) GetImageTags(jpeg *MediaFile) (results []*Tag) {
|
func (i *Indexer) GetImageTags(jpeg *MediaFile) (results []*Tag) {
|
||||||
if imageBuffer, err := ioutil.ReadFile(jpeg.filename); err == nil {
|
tags, err := i.tensorFlow.GetImageTagsFromFile(jpeg.filename)
|
||||||
tags, err := recognize.GetImageTags(string(imageBuffer))
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tag := range tags {
|
for _, tag := range tags {
|
||||||
if tag.Probability > 0.2 { // TODO: Use config variable
|
if tag.Probability > 0.15 { // TODO: Use config variable
|
||||||
results = i.appendTag(results, tag.Label)
|
results = i.appendTag(results, tag.Label)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Before Width: | Height: | Size: 130 KiB |
|
@ -1,63 +0,0 @@
|
||||||
package recognize
|
|
||||||
|
|
||||||
import (
|
|
||||||
tf "github.com/tensorflow/tensorflow/tensorflow/go"
|
|
||||||
"github.com/tensorflow/tensorflow/tensorflow/go/op"
|
|
||||||
)
|
|
||||||
|
|
||||||
func makeTensorFromImage(image string, imageFormat string) (*tf.Tensor, error) {
|
|
||||||
tensor, err := tf.NewTensor(image)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
graph, input, output, err := makeTransformImageGraph(imageFormat)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
session, err := tf.NewSession(graph, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer session.Close()
|
|
||||||
normalized, err := session.Run(
|
|
||||||
map[tf.Output]*tf.Tensor{input: tensor},
|
|
||||||
[]tf.Output{output},
|
|
||||||
nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return normalized[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Creates a graph to decode, rezise and normalize an image
|
|
||||||
func makeTransformImageGraph(imageFormat string) (graph *tf.Graph, input, output tf.Output, err error) {
|
|
||||||
const (
|
|
||||||
H, W = 224, 224
|
|
||||||
Mean = float32(117)
|
|
||||||
Scale = float32(1)
|
|
||||||
)
|
|
||||||
s := op.NewScope()
|
|
||||||
input = op.Placeholder(s, tf.String)
|
|
||||||
// Decode PNG or JPEG
|
|
||||||
var decode tf.Output
|
|
||||||
if imageFormat == "png" {
|
|
||||||
decode = op.DecodePng(s, input, op.DecodePngChannels(3))
|
|
||||||
} else {
|
|
||||||
decode = op.DecodeJpeg(s, input, op.DecodeJpegChannels(3))
|
|
||||||
}
|
|
||||||
// Div and Sub perform (value-Mean)/Scale for each pixel
|
|
||||||
output = op.Div(s,
|
|
||||||
op.Sub(s,
|
|
||||||
// Resize to 224x224 with bilinear interpolation
|
|
||||||
op.ResizeBilinear(s,
|
|
||||||
// Create a batch containing a single image
|
|
||||||
op.ExpandDims(s,
|
|
||||||
// Use decoded pixel values
|
|
||||||
op.Cast(s, decode, tf.Float),
|
|
||||||
op.Const(s.SubScope("make_batch"), int32(0))),
|
|
||||||
op.Const(s.SubScope("size"), []int32{H, W})),
|
|
||||||
op.Const(s.SubScope("mean"), Mean)),
|
|
||||||
op.Const(s.SubScope("scale"), Scale))
|
|
||||||
graph, err = s.Finalize()
|
|
||||||
return graph, input, output, err
|
|
||||||
}
|
|
|
@ -1,115 +0,0 @@
|
||||||
package recognize
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"errors"
|
|
||||||
tf "github.com/tensorflow/tensorflow/tensorflow/go"
|
|
||||||
"io/ioutil"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"sort"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ClassifyResult struct {
|
|
||||||
Filename string `json:"filename"`
|
|
||||||
Labels []LabelResult `json:"labels"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type LabelResult struct {
|
|
||||||
Label string `json:"label"`
|
|
||||||
Probability float32 `json:"probability"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
graph *tf.Graph
|
|
||||||
labels []string
|
|
||||||
)
|
|
||||||
|
|
||||||
func GetImageTags(image string) (result []LabelResult, err error) {
|
|
||||||
if err := loadModel(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make tensor
|
|
||||||
tensor, err := makeTensorFromImage(image, "jpeg")
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.New("invalid image")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run inference
|
|
||||||
session, err := tf.NewSession(graph, nil)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer session.Close()
|
|
||||||
|
|
||||||
output, err := session.Run(
|
|
||||||
map[tf.Output]*tf.Tensor{
|
|
||||||
graph.Operation("input").Output(0): tensor,
|
|
||||||
},
|
|
||||||
[]tf.Output{
|
|
||||||
graph.Operation("output").Output(0),
|
|
||||||
},
|
|
||||||
nil)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.New("could not run inference")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return best labels
|
|
||||||
return findBestLabels(output[0].Value().([][]float32)[0]), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadModel() error {
|
|
||||||
// Load inception model
|
|
||||||
model, err := ioutil.ReadFile("/model/tensorflow_inception_graph.pb")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
graph = tf.NewGraph()
|
|
||||||
if err := graph.Import(model, ""); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load labels
|
|
||||||
labelsFile, err := os.Open("/model/imagenet_comp_graph_label_strings.txt")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer labelsFile.Close()
|
|
||||||
scanner := bufio.NewScanner(labelsFile)
|
|
||||||
|
|
||||||
// Labels are separated by newlines
|
|
||||||
for scanner.Scan() {
|
|
||||||
labels = append(labels, scanner.Text())
|
|
||||||
}
|
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type ByProbability []LabelResult
|
|
||||||
|
|
||||||
func (a ByProbability) Len() int { return len(a) }
|
|
||||||
func (a ByProbability) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
|
|
||||||
func (a ByProbability) Less(i, j int) bool { return a[i].Probability > a[j].Probability }
|
|
||||||
|
|
||||||
func findBestLabels(probabilities []float32) []LabelResult {
|
|
||||||
// Make a list of label/probability pairs
|
|
||||||
var resultLabels []LabelResult
|
|
||||||
for i, p := range probabilities {
|
|
||||||
if i >= len(labels) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
resultLabels = append(resultLabels, LabelResult{Label: labels[i], Probability: p})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by probability
|
|
||||||
sort.Sort(ByProbability(resultLabels))
|
|
||||||
|
|
||||||
// Return top 5 labels
|
|
||||||
return resultLabels[:5]
|
|
||||||
}
|
|
|
@ -1,25 +0,0 @@
|
||||||
package recognize
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"io/ioutil"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGetImageTags(t *testing.T) {
|
|
||||||
if imageBuffer, err := ioutil.ReadFile("cat.jpg"); err != nil {
|
|
||||||
t.Error(err)
|
|
||||||
} else {
|
|
||||||
result, err := GetImageTags(string(imageBuffer))
|
|
||||||
|
|
||||||
assert.NotNil(t, result)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.IsType(t, []LabelResult{}, result)
|
|
||||||
assert.Equal(t, 5, len(result))
|
|
||||||
|
|
||||||
assert.Equal(t, "tabby", result[0].Label)
|
|
||||||
assert.Equal(t, "tiger cat", result[1].Label)
|
|
||||||
|
|
||||||
assert.Equal(t, float32(0.23251747), result[1].Probability)
|
|
||||||
}
|
|
||||||
}
|
|
8
scripts/download-tf-model.sh
Executable file
|
@ -0,0 +1,8 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
if [[ ! -e assets/tensorflow/tensorflow_inception_graph.pb ]]; then
|
||||||
|
wget "https://storage.googleapis.com/download.tensorflow.org/models/inception5h.zip" -O assets/tensorflow/inception.zip &&
|
||||||
|
unzip assets/tensorflow/inception.zip -d assets/tensorflow
|
||||||
|
else
|
||||||
|
echo "TensorFlow InceptionV3 model already downloaded."
|
||||||
|
fi
|
|
@ -10,13 +10,13 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func ConfigureRoutes(app *gin.Engine, conf *photoprism.Config) {
|
func ConfigureRoutes(app *gin.Engine, conf *photoprism.Config) {
|
||||||
serverAssetsPath := conf.ServerAssetsPath
|
assetsPath := conf.AssetsPath
|
||||||
app.LoadHTMLGlob(serverAssetsPath + "/templates/*")
|
app.LoadHTMLGlob(assetsPath + "/templates/*")
|
||||||
|
|
||||||
app.StaticFile("/favicon.ico", serverAssetsPath + "/favicons/favicon.ico")
|
app.StaticFile("/favicon.ico", assetsPath+"/favicons/favicon.ico")
|
||||||
app.StaticFile("/favicon.png", serverAssetsPath + "/favicons/favicon.png")
|
app.StaticFile("/favicon.png", assetsPath+"/favicons/favicon.png")
|
||||||
|
|
||||||
app.Static("/assets", serverAssetsPath + "/public")
|
app.Static("/assets", assetsPath+"/public")
|
||||||
|
|
||||||
// JSON-REST API Version 1
|
// JSON-REST API Version 1
|
||||||
v1 := app.Group("/api/v1")
|
v1 := app.Group("/api/v1")
|
||||||
|
|
190
tensorflow.go
Normal file
|
@ -0,0 +1,190 @@
|
||||||
|
package photoprism
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"errors"
|
||||||
|
tf "github.com/tensorflow/tensorflow/tensorflow/go"
|
||||||
|
"github.com/tensorflow/tensorflow/tensorflow/go/op"
|
||||||
|
"io/ioutil"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TensorFlow struct {
|
||||||
|
modelPath string
|
||||||
|
graph *tf.Graph
|
||||||
|
labels []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTensorFlow(tensorFlowModelPath string) *TensorFlow {
|
||||||
|
return &TensorFlow{modelPath:tensorFlowModelPath}
|
||||||
|
}
|
||||||
|
|
||||||
|
type TensorFlowLabel struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Probability float32 `json:"probability"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TensorFlowLabels []TensorFlowLabel
|
||||||
|
|
||||||
|
func (a TensorFlowLabels) Len() int { return len(a) }
|
||||||
|
func (a TensorFlowLabels) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
|
||||||
|
func (a TensorFlowLabels) Less(i, j int) bool { return a[i].Probability > a[j].Probability }
|
||||||
|
|
||||||
|
func (t *TensorFlow) GetImageTagsFromFile(filename string) (result []TensorFlowLabel, err error) {
|
||||||
|
imageBuffer, err := ioutil.ReadFile(filename)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.GetImageTags(string(imageBuffer))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TensorFlow) GetImageTags(image string) (result []TensorFlowLabel, err error) {
|
||||||
|
if err := t.loadModel(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make tensor
|
||||||
|
tensor, err := t.makeTensorFromImage(image, "jpeg")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("invalid image")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run inference
|
||||||
|
session, err := tf.NewSession(t.graph, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
output, err := session.Run(
|
||||||
|
map[tf.Output]*tf.Tensor{
|
||||||
|
t.graph.Operation("input").Output(0): tensor,
|
||||||
|
},
|
||||||
|
[]tf.Output{
|
||||||
|
t.graph.Operation("output").Output(0),
|
||||||
|
},
|
||||||
|
nil)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("could not run inference")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return best labels
|
||||||
|
return t.findBestLabels(output[0].Value().([][]float32)[0]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TensorFlow) loadModel() error {
|
||||||
|
if t.graph != nil {
|
||||||
|
// Already loaded
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load inception model
|
||||||
|
model, err := ioutil.ReadFile(t.modelPath + "/tensorflow_inception_graph.pb")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t.graph = tf.NewGraph()
|
||||||
|
if err := t.graph.Import(model, ""); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load labels
|
||||||
|
labelsFile, err := os.Open(t.modelPath + "/imagenet_comp_graph_label_strings.txt")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer labelsFile.Close()
|
||||||
|
scanner := bufio.NewScanner(labelsFile)
|
||||||
|
|
||||||
|
// Labels are separated by newlines
|
||||||
|
for scanner.Scan() {
|
||||||
|
t.labels = append(t.labels, scanner.Text())
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TensorFlow) findBestLabels(probabilities []float32) []TensorFlowLabel {
|
||||||
|
// Make a list of label/probability pairs
|
||||||
|
var resultLabels []TensorFlowLabel
|
||||||
|
for i, p := range probabilities {
|
||||||
|
if i >= len(t.labels) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
resultLabels = append(resultLabels, TensorFlowLabel{Label: t.labels[i], Probability: p})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by probability
|
||||||
|
sort.Sort(TensorFlowLabels(resultLabels))
|
||||||
|
|
||||||
|
// Return top 5 labels
|
||||||
|
return resultLabels[:5]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (t *TensorFlow) makeTensorFromImage(image string, imageFormat string) (*tf.Tensor, error) {
|
||||||
|
tensor, err := tf.NewTensor(image)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
graph, input, output, err := t.makeTransformImageGraph(imageFormat)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
session, err := tf.NewSession(graph, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
normalized, err := session.Run(
|
||||||
|
map[tf.Output]*tf.Tensor{input: tensor},
|
||||||
|
[]tf.Output{output},
|
||||||
|
nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return normalized[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates a graph to decode, rezise and normalize an image
|
||||||
|
func (t *TensorFlow) makeTransformImageGraph(imageFormat string) (graph *tf.Graph, input, output tf.Output, err error) {
|
||||||
|
const (
|
||||||
|
H, W = 224, 224
|
||||||
|
Mean = float32(117)
|
||||||
|
Scale = float32(1)
|
||||||
|
)
|
||||||
|
s := op.NewScope()
|
||||||
|
input = op.Placeholder(s, tf.String)
|
||||||
|
// Decode PNG or JPEG
|
||||||
|
var decode tf.Output
|
||||||
|
if imageFormat == "png" {
|
||||||
|
decode = op.DecodePng(s, input, op.DecodePngChannels(3))
|
||||||
|
} else {
|
||||||
|
decode = op.DecodeJpeg(s, input, op.DecodeJpegChannels(3))
|
||||||
|
}
|
||||||
|
// Div and Sub perform (value-Mean)/Scale for each pixel
|
||||||
|
output = op.Div(s,
|
||||||
|
op.Sub(s,
|
||||||
|
// Resize to 224x224 with bilinear interpolation
|
||||||
|
op.ResizeBilinear(s,
|
||||||
|
// Create a batch containing a single image
|
||||||
|
op.ExpandDims(s,
|
||||||
|
// Use decoded pixel values
|
||||||
|
op.Cast(s, decode, tf.Float),
|
||||||
|
op.Const(s.SubScope("make_batch"), int32(0))),
|
||||||
|
op.Const(s.SubScope("size"), []int32{H, W})),
|
||||||
|
op.Const(s.SubScope("mean"), Mean)),
|
||||||
|
op.Const(s.SubScope("scale"), Scale))
|
||||||
|
graph, err = s.Finalize()
|
||||||
|
|
||||||
|
return graph, input, output, err
|
||||||
|
}
|
51
tensorflow_test.go
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
package photoprism
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"io/ioutil"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTensorFlow_GetImageTags(t *testing.T) {
|
||||||
|
conf := NewTestConfig()
|
||||||
|
|
||||||
|
conf.InitializeTestData(t)
|
||||||
|
|
||||||
|
tensorFlow := NewTensorFlow(conf.GetTensorFlowModelPath())
|
||||||
|
|
||||||
|
if imageBuffer, err := ioutil.ReadFile(conf.ImportPath + "/iphone/IMG_6788.JPG"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
} else {
|
||||||
|
result, err := tensorFlow.GetImageTags(string(imageBuffer))
|
||||||
|
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.IsType(t, []TensorFlowLabel{}, result)
|
||||||
|
assert.Equal(t, 5, len(result))
|
||||||
|
|
||||||
|
assert.Equal(t, "tabby", result[0].Label)
|
||||||
|
assert.Equal(t, "tiger cat", result[1].Label)
|
||||||
|
|
||||||
|
assert.Equal(t, float32(0.1648176), result[1].Probability)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTensorFlow_GetImageTagsFromFile(t *testing.T) {
|
||||||
|
conf := NewTestConfig()
|
||||||
|
|
||||||
|
conf.InitializeTestData(t)
|
||||||
|
|
||||||
|
tensorFlow := NewTensorFlow(conf.GetTensorFlowModelPath())
|
||||||
|
|
||||||
|
result, err := tensorFlow.GetImageTagsFromFile(conf.ImportPath + "/iphone/IMG_6788.JPG")
|
||||||
|
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.IsType(t, []TensorFlowLabel{}, result)
|
||||||
|
assert.Equal(t, 5, len(result))
|
||||||
|
|
||||||
|
assert.Equal(t, "tabby", result[0].Label)
|
||||||
|
assert.Equal(t, "tiger cat", result[1].Label)
|
||||||
|
|
||||||
|
assert.Equal(t, float32(0.1648176), result[1].Probability)
|
||||||
|
}
|
|
@ -46,7 +46,9 @@ func TestCreateThumbnailsFromOriginals(t *testing.T) {
|
||||||
|
|
||||||
conf.InitializeTestData(t)
|
conf.InitializeTestData(t)
|
||||||
|
|
||||||
indexer := NewIndexer(conf.OriginalsPath, conf.GetDb())
|
tensorFlow := NewTensorFlow(conf.GetTensorFlowModelPath())
|
||||||
|
|
||||||
|
indexer := NewIndexer(conf.OriginalsPath, tensorFlow, conf.GetDb())
|
||||||
|
|
||||||
importer := NewImporter(conf.OriginalsPath, indexer)
|
importer := NewImporter(conf.OriginalsPath, indexer)
|
||||||
|
|
||||||
|
|
6
util.go
|
@ -20,7 +20,11 @@ func GetExpandedFilename(filename string) string {
|
||||||
usr, _ := user.Current()
|
usr, _ := user.Current()
|
||||||
dir := usr.HomeDir
|
dir := usr.HomeDir
|
||||||
|
|
||||||
if filename[:2] == "~/" {
|
if filename == "" {
|
||||||
|
panic("filename was empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(filename) > 2 && filename[:2] == "~/" {
|
||||||
filename = filepath.Join(dir, filename[2:])
|
filename = filepath.Join(dir, filename[2:])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|