AI: Add vision package and vision API endpoints #127 #1090

These changes allow to configure the computer vision models through an
optional vision.yml configuration file. Note that the API endpoints
are not yet functional and require further work.

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2025-04-06 23:39:37 +02:00
parent b791896fc6
commit 35e9294d87
74 changed files with 1052 additions and 329 deletions

1
.gitignore vendored
View file

@ -49,6 +49,7 @@ frontend/coverage/
/assets/nasnet
/assets/nsfw
/assets/static/build/
/assets/*net
/pro
/plus

View file

@ -44,6 +44,17 @@ func LocationLabel(name string, uncertainty int) Label {
}
// Title returns a formatted label title as string.
func (l Label) Title() string {
func (l *Label) Title() string {
return txt.Title(txt.Clip(l.Name, txt.ClipDefault))
}
// Confidence returns a matching confidence in percent.
func (l *Label) Confidence() int {
if l.Uncertainty > 100 {
return 0
} else if l.Uncertainty < 0 {
return 100
} else {
return 100 - l.Uncertainty
}
}

View file

@ -12,6 +12,7 @@ import (
"runtime/debug"
"sort"
"strings"
"sync"
"github.com/disintegration/imaging"
tf "github.com/wamuir/graft/tensorflow"
@ -19,33 +20,40 @@ import (
"github.com/photoprism/photoprism/pkg/clean"
)
// TensorFlow is a wrapper for tensorflow low-level API.
type TensorFlow struct {
// Model represents a TensorFlow classification model.
type Model struct {
model *tf.SavedModel
modelsPath string
disabled bool
modelName string
modelPath string
assetsPath string
resolution int
modelTags []string
labels []string
disabled bool
mutex sync.Mutex
}
// New returns new TensorFlow instance with Nasnet model.
func New(modelsPath string, disabled bool) *TensorFlow {
return &TensorFlow{modelsPath: modelsPath, disabled: disabled, modelName: "nasnet", modelTags: []string{"photoprism"}}
// NewModel returns new TensorFlow classification model instance.
func NewModel(assetsPath, modelPath string, resolution int, modelTags []string, disabled bool) *Model {
return &Model{assetsPath: assetsPath, modelPath: modelPath, resolution: resolution, modelTags: modelTags, disabled: disabled}
}
// NewNasnet returns new Nasnet TensorFlow classification model instance.
func NewNasnet(assetsPath string, disabled bool) *Model {
return NewModel(assetsPath, "nasnet", 224, []string{"photoprism"}, disabled)
}
// Init initialises tensorflow models if not disabled
func (t *TensorFlow) Init() (err error) {
if t.disabled {
func (m *Model) Init() (err error) {
if m.disabled {
return nil
}
return t.loadModel()
return m.loadModel()
}
// File returns matching labels for a jpeg media file.
func (t *TensorFlow) File(filename string) (result Labels, err error) {
if t.disabled {
func (m *Model) File(filename string, confidenceThreshold int) (result Labels, err error) {
if m.disabled {
return result, nil
}
@ -55,39 +63,39 @@ func (t *TensorFlow) File(filename string) (result Labels, err error) {
return nil, err
}
return t.Labels(imageBuffer)
return m.Labels(imageBuffer, confidenceThreshold)
}
// Labels returns matching labels for a jpeg media string.
func (t *TensorFlow) Labels(img []byte) (result Labels, err error) {
func (m *Model) Labels(img []byte, confidenceThreshold int) (result Labels, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("classify: %s (inference panic)\nstack: %s", r, debug.Stack())
}
}()
if t.disabled {
if m.disabled {
return result, nil
}
if err := t.loadModel(); err != nil {
return nil, err
if loadErr := m.loadModel(); loadErr != nil {
return nil, loadErr
}
// Create tensor from image.
tensor, err := t.createTensor(img, "jpeg")
tensor, err := m.createTensor(img)
if err != nil {
return nil, err
}
// Run inference.
output, err := t.model.Session.Run(
output, err := m.model.Session.Run(
map[tf.Output]*tf.Tensor{
t.model.Graph.Operation("input_1").Output(0): tensor,
m.model.Graph.Operation("input_1").Output(0): tensor,
},
[]tf.Output{
t.model.Graph.Operation("predictions/Softmax").Output(0),
m.model.Graph.Operation("predictions/Softmax").Output(0),
},
nil)
@ -100,7 +108,7 @@ func (t *TensorFlow) Labels(img []byte) (result Labels, err error) {
}
// Return best labels
result = t.bestLabels(output[0].Value().([][]float32)[0])
result = m.bestLabels(output[0].Value().([][]float32)[0], confidenceThreshold)
if len(result) > 0 {
log.Tracef("classify: image classified as %+v", result)
@ -109,7 +117,7 @@ func (t *TensorFlow) Labels(img []byte) (result Labels, err error) {
return result, nil
}
func (t *TensorFlow) loadLabels(path string) error {
func (m *Model) loadLabels(path string) error {
modelLabels := path + "/labels.txt"
log.Infof("classify: loading labels from labels.txt")
@ -127,7 +135,7 @@ func (t *TensorFlow) loadLabels(path string) error {
// Labels are separated by newlines
for scanner.Scan() {
t.labels = append(t.labels, scanner.Text())
m.labels = append(m.labels, scanner.Text())
}
if err := scanner.Err(); err != nil {
@ -138,37 +146,40 @@ func (t *TensorFlow) loadLabels(path string) error {
}
// ModelLoaded tests if the TensorFlow model is loaded.
func (t *TensorFlow) ModelLoaded() bool {
return t.model != nil
func (m *Model) ModelLoaded() bool {
return m.model != nil
}
func (t *TensorFlow) loadModel() error {
if t.ModelLoaded() {
func (m *Model) loadModel() error {
m.mutex.Lock()
defer m.mutex.Unlock()
if m.ModelLoaded() {
return nil
}
modelPath := path.Join(t.modelsPath, t.modelName)
modelPath := path.Join(m.assetsPath, m.modelPath)
log.Infof("classify: loading %s", clean.Log(filepath.Base(modelPath)))
// Load model
model, err := tf.LoadSavedModel(modelPath, t.modelTags, nil)
model, err := tf.LoadSavedModel(modelPath, m.modelTags, nil)
if err != nil {
return err
}
t.model = model
m.model = model
return t.loadLabels(modelPath)
return m.loadLabels(modelPath)
}
// bestLabels returns the best 5 labels (if enough high probability labels) from the prediction of the model
func (t *TensorFlow) bestLabels(probabilities []float32) Labels {
func (m *Model) bestLabels(probabilities []float32, confidenceThreshold int) Labels {
var result Labels
for i, p := range probabilities {
if i >= len(t.labels) {
if i >= len(m.labels) {
// break if probabilities and labels does not match
break
}
@ -178,7 +189,7 @@ func (t *TensorFlow) bestLabels(probabilities []float32) Labels {
continue
}
labelText := strings.ToLower(t.labels[i])
labelText := strings.ToLower(m.labels[i])
rule, _ := Rules.Find(labelText)
@ -194,9 +205,11 @@ func (t *TensorFlow) bestLabels(probabilities []float32) Labels {
labelText = strings.TrimSpace(labelText)
uncertainty := 100 - int(math.Round(float64(p*100)))
confidence := int(math.Round(float64(p * 100)))
result = append(result, Label{Name: labelText, Source: SrcImage, Uncertainty: uncertainty, Priority: rule.Priority, Categories: rule.Categories})
if confidence >= confidenceThreshold {
result = append(result, Label{Name: labelText, Source: SrcImage, Uncertainty: 100 - confidence, Priority: rule.Priority, Categories: rule.Categories})
}
}
// Sort by probability
@ -211,14 +224,14 @@ func (t *TensorFlow) bestLabels(probabilities []float32) Labels {
}
// createTensor converts bytes jpeg image in a tensor object required as tensorflow model input
func (t *TensorFlow) createTensor(image []byte, imageFormat string) (*tf.Tensor, error) {
func (m *Model) createTensor(image []byte) (*tf.Tensor, error) {
img, err := imaging.Decode(bytes.NewReader(image), imaging.AutoOrientation(true))
if err != nil {
return nil, err
}
width, height := 224, 224
width, height := m.resolution, m.resolution
img = imaging.Fill(img, width, height, imaging.Center, imaging.Lanczos)

View file

@ -15,12 +15,11 @@ var assetsPath = fs.Abs("../../../assets")
var modelPath = assetsPath + "/nasnet"
var examplesPath = assetsPath + "/examples"
var once sync.Once
var testInstance *TensorFlow
var testInstance *Model
// NewTest returns a new TensorFlow test instance.
func NewTest(t *testing.T) *TensorFlow {
func NewModelTest(t *testing.T) *Model {
once.Do(func() {
testInstance = New(assetsPath, false)
testInstance = NewNasnet(assetsPath, false)
if err := testInstance.loadModel(); err != nil {
t.Fatal(err)
}
@ -29,11 +28,11 @@ func NewTest(t *testing.T) *TensorFlow {
return testInstance
}
func TestTensorFlow_LabelsFromFile(t *testing.T) {
func TestModel_LabelsFromFile(t *testing.T) {
t.Run("chameleon_lime.jpg", func(t *testing.T) {
tensorFlow := NewTest(t)
tensorFlow := NewModelTest(t)
result, err := tensorFlow.File(examplesPath + "/chameleon_lime.jpg")
result, err := tensorFlow.File(examplesPath+"/chameleon_lime.jpg", 10)
assert.Nil(t, err)
@ -52,16 +51,16 @@ func TestTensorFlow_LabelsFromFile(t *testing.T) {
assert.Equal(t, 7, result[0].Uncertainty)
})
t.Run("not existing file", func(t *testing.T) {
tensorFlow := NewTest(t)
tensorFlow := NewModelTest(t)
result, err := tensorFlow.File(examplesPath + "/notexisting.jpg")
result, err := tensorFlow.File(examplesPath+"/notexisting.jpg", 10)
assert.Contains(t, err.Error(), "no such file or directory")
assert.Empty(t, result)
})
t.Run("disabled true", func(t *testing.T) {
tensorFlow := New(assetsPath, true)
tensorFlow := NewNasnet(assetsPath, true)
result, err := tensorFlow.File(examplesPath + "/chameleon_lime.jpg")
result, err := tensorFlow.File(examplesPath+"/chameleon_lime.jpg", 10)
assert.Nil(t, err)
if err != nil {
@ -76,18 +75,18 @@ func TestTensorFlow_LabelsFromFile(t *testing.T) {
})
}
func TestTensorFlow_Labels(t *testing.T) {
func TestModel_Labels(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode.")
}
t.Run("chameleon_lime.jpg", func(t *testing.T) {
tensorFlow := NewTest(t)
tensorFlow := NewModelTest(t)
if imageBuffer, err := os.ReadFile(examplesPath + "/chameleon_lime.jpg"); err != nil {
t.Error(err)
} else {
result, err := tensorFlow.Labels(imageBuffer)
result, err := tensorFlow.Labels(imageBuffer, 10)
t.Log(result)
@ -106,12 +105,12 @@ func TestTensorFlow_Labels(t *testing.T) {
}
})
t.Run("dog_orange.jpg", func(t *testing.T) {
tensorFlow := NewTest(t)
tensorFlow := NewModelTest(t)
if imageBuffer, err := os.ReadFile(examplesPath + "/dog_orange.jpg"); err != nil {
t.Error(err)
} else {
result, err := tensorFlow.Labels(imageBuffer)
result, err := tensorFlow.Labels(imageBuffer, 10)
t.Log(result)
@ -130,23 +129,23 @@ func TestTensorFlow_Labels(t *testing.T) {
}
})
t.Run("Random.docx", func(t *testing.T) {
tensorFlow := NewTest(t)
tensorFlow := NewModelTest(t)
if imageBuffer, err := os.ReadFile(examplesPath + "/Random.docx"); err != nil {
t.Error(err)
} else {
result, err := tensorFlow.Labels(imageBuffer)
result, err := tensorFlow.Labels(imageBuffer, 10)
assert.Empty(t, result)
assert.Error(t, err)
}
})
t.Run("6720px_white.jpg", func(t *testing.T) {
tensorFlow := NewTest(t)
tensorFlow := NewModelTest(t)
if imageBuffer, err := os.ReadFile(examplesPath + "/6720px_white.jpg"); err != nil {
t.Error(err)
} else {
result, err := tensorFlow.Labels(imageBuffer)
result, err := tensorFlow.Labels(imageBuffer, 10)
if err != nil {
t.Fatal(err)
@ -156,12 +155,12 @@ func TestTensorFlow_Labels(t *testing.T) {
}
})
t.Run("disabled true", func(t *testing.T) {
tensorFlow := New(assetsPath, true)
tensorFlow := NewNasnet(assetsPath, true)
if imageBuffer, err := os.ReadFile(examplesPath + "/dog_orange.jpg"); err != nil {
t.Error(err)
} else {
result, err := tensorFlow.Labels(imageBuffer)
result, err := tensorFlow.Labels(imageBuffer, 10)
t.Log(result)
@ -174,13 +173,13 @@ func TestTensorFlow_Labels(t *testing.T) {
})
}
func TestTensorFlow_LoadModel(t *testing.T) {
func TestModel_LoadModel(t *testing.T) {
t.Run("model loaded", func(t *testing.T) {
tf := NewTest(t)
tf := NewModelTest(t)
assert.True(t, tf.ModelLoaded())
})
t.Run("model path does not exist", func(t *testing.T) {
tensorFlow := New(assetsPath+"foo", false)
tensorFlow := NewNasnet(assetsPath+"foo", false)
if err := tensorFlow.loadModel(); err != nil {
assert.Contains(t, err.Error(), "Could not find SavedModel")
} else {
@ -189,19 +188,19 @@ func TestTensorFlow_LoadModel(t *testing.T) {
})
}
func TestTensorFlow_BestLabels(t *testing.T) {
func TestModel_BestLabels(t *testing.T) {
t.Run("labels not loaded", func(t *testing.T) {
tensorFlow := New(assetsPath, false)
tensorFlow := NewNasnet(assetsPath, false)
p := make([]float32, 1000)
p[666] = 0.5
result := tensorFlow.bestLabels(p)
result := tensorFlow.bestLabels(p, 10)
assert.Empty(t, result)
})
t.Run("labels loaded", func(t *testing.T) {
tensorFlow := New(assetsPath, false)
tensorFlow := NewNasnet(assetsPath, false)
if err := tensorFlow.loadLabels(modelPath); err != nil {
t.Fatal(err)
@ -212,7 +211,7 @@ func TestTensorFlow_BestLabels(t *testing.T) {
p[8] = 0.7
p[1] = 0.5
result := tensorFlow.bestLabels(p)
result := tensorFlow.bestLabels(p, 10)
assert.Equal(t, "chicken", result[0].Name)
assert.Equal(t, "bird", result[0].Categories[0])
assert.Equal(t, "image", result[0].Source)
@ -220,9 +219,9 @@ func TestTensorFlow_BestLabels(t *testing.T) {
})
}
func TestTensorFlow_MakeTensor(t *testing.T) {
func TestModel_MakeTensor(t *testing.T) {
t.Run("cat_brown.jpg", func(t *testing.T) {
tensorFlow := NewTest(t)
tensorFlow := NewModelTest(t)
imageBuffer, err := os.ReadFile(examplesPath + "/cat_brown.jpg")
@ -230,17 +229,17 @@ func TestTensorFlow_MakeTensor(t *testing.T) {
t.Fatal(err)
}
result, err := tensorFlow.createTensor(imageBuffer, "jpeg")
result, err := tensorFlow.createTensor(imageBuffer)
assert.Equal(t, tensorflow.DataType(0x1), result.DataType())
assert.Equal(t, int64(1), result.Shape()[0])
assert.Equal(t, int64(224), result.Shape()[2])
})
t.Run("Random.docx", func(t *testing.T) {
tensorFlow := NewTest(t)
tensorFlow := NewModelTest(t)
imageBuffer, err := os.ReadFile(examplesPath + "/Random.docx")
assert.Nil(t, err)
result, err := tensorFlow.createTensor(imageBuffer, "jpeg")
result, err := tensorFlow.createTensor(imageBuffer)
assert.Empty(t, result)
assert.EqualError(t, err, "image: unknown format")

View file

@ -14,24 +14,25 @@ import (
"github.com/photoprism/photoprism/pkg/clean"
)
// Net is a wrapper for the TensorFlow Facenet model.
type Net struct {
model *tf.SavedModel
modelPath string
cachePath string
disabled bool
modelName string
modelTags []string
mutex sync.Mutex
// Model is a wrapper for the TensorFlow Facenet model.
type Model struct {
model *tf.SavedModel
modelName string
modelPath string
cachePath string
resolution int
modelTags []string
disabled bool
mutex sync.Mutex
}
// NewNet returns a new TensorFlow Facenet instance.
func NewNet(modelPath, cachePath string, disabled bool) *Net {
return &Net{modelPath: modelPath, cachePath: cachePath, disabled: disabled, modelTags: []string{"serve"}}
// NewModel returns a new TensorFlow Facenet instance.
func NewModel(modelPath, cachePath string, disabled bool) *Model {
return &Model{modelPath: modelPath, cachePath: cachePath, resolution: CropSize.Width, modelTags: []string{"serve"}, disabled: disabled}
}
// Detect runs the detection and facenet algorithms over the provided source image.
func (t *Net) Detect(fileName string, minSize int, cacheCrop bool, expected int) (faces Faces, err error) {
func (t *Model) Detect(fileName string, minSize int, cacheCrop bool, expected int) (faces Faces, err error) {
faces, err = Detect(fileName, false, minSize)
if err != nil {
@ -56,8 +57,8 @@ func (t *Net) Detect(fileName string, minSize int, cacheCrop bool, expected int)
continue
}
if img, err := crop.ImageFromThumb(fileName, f.CropArea(), CropSize, cacheCrop); err != nil {
log.Errorf("faces: failed to decode image: %s", err)
if img, imgErr := crop.ImageFromThumb(fileName, f.CropArea(), CropSize, cacheCrop); imgErr != nil {
log.Errorf("faces: failed to decode image: %s", imgErr)
} else if embeddings := t.getEmbeddings(img); !embeddings.Empty() {
faces[i].Embeddings = embeddings
}
@ -67,12 +68,12 @@ func (t *Net) Detect(fileName string, minSize int, cacheCrop bool, expected int)
}
// ModelLoaded tests if the TensorFlow model is loaded.
func (t *Net) ModelLoaded() bool {
func (t *Model) ModelLoaded() bool {
return t.model != nil
}
// loadModel loads the TensorFlow model.
func (t *Net) loadModel() error {
func (t *Model) loadModel() error {
t.mutex.Lock()
defer t.mutex.Unlock()
@ -97,8 +98,8 @@ func (t *Net) loadModel() error {
}
// getEmbeddings returns the face embeddings for an image.
func (t *Net) getEmbeddings(img image.Image) Embeddings {
tensor, err := imageToTensor(img, CropSize.Width, CropSize.Height)
func (t *Model) getEmbeddings(img image.Image) Embeddings {
tensor, err := imageToTensor(img, t.resolution)
if err != nil {
log.Errorf("faces: failed to convert image to tensor: %s", err)
@ -131,25 +132,25 @@ func (t *Net) getEmbeddings(img image.Image) Embeddings {
return nil
}
func imageToTensor(img image.Image, imageHeight, imageWidth int) (tfTensor *tf.Tensor, err error) {
func imageToTensor(img image.Image, resolution int) (tfTensor *tf.Tensor, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("faces: %s (panic)\nstack: %s", r, debug.Stack())
}
}()
if imageHeight <= 0 || imageWidth <= 0 {
return tfTensor, fmt.Errorf("faces: image width and height must be > 0")
if resolution <= 0 {
return tfTensor, fmt.Errorf("faces: invalid model resolution")
}
var tfImage [1][][][3]float32
for j := 0; j < imageHeight; j++ {
tfImage[0] = append(tfImage[0], make([][3]float32, imageWidth))
for j := 0; j < resolution; j++ {
tfImage[0] = append(tfImage[0], make([][3]float32, resolution))
}
for i := 0; i < imageWidth; i++ {
for j := 0; j < imageHeight; j++ {
for i := 0; i < resolution; i++ {
for j := 0; j < resolution; j++ {
r, g, b, _ := img.At(i, j).RGBA()
tfImage[0][j][i][0] = convertValue(r)
tfImage[0][j][i][1] = convertValue(g)

View file

@ -54,7 +54,7 @@ func TestNet(t *testing.T) {
var embeddings = make(Embeddings, 11)
faceNet := NewNet(modelPath, "testdata/cache", false)
faceNet := NewModel(modelPath, "testdata/cache", false)
if err := fastwalk.Walk("testdata", func(fileName string, info os.FileMode) error {
if info.IsDir() || filepath.Base(filepath.Dir(fileName)) != "testdata" {

View file

@ -15,22 +15,28 @@ import (
"github.com/photoprism/photoprism/pkg/media/http/header"
)
// Detector uses TensorFlow to label drawing, hentai, neutral, porn and sexy images.
type Detector struct {
model *tf.SavedModel
modelPath string
modelTags []string
labels []string
mutex sync.Mutex
const (
Mean = float32(117)
Scale = float32(1)
)
// Model uses TensorFlow to label drawing, hentai, neutral, porn and sexy images.
type Model struct {
model *tf.SavedModel
modelPath string
resolution int
modelTags []string
labels []string
mutex sync.Mutex
}
// New returns a new detector instance.
func New(modelPath string) *Detector {
return &Detector{modelPath: modelPath, modelTags: []string{"serve"}}
// NewModel returns a new detector instance.
func NewModel(modelPath string) *Model {
return &Model{modelPath: modelPath, resolution: 224, modelTags: []string{"serve"}}
}
// File returns matching labels for a jpeg media file.
func (t *Detector) File(filename string) (result Labels, err error) {
func (t *Model) File(filename string) (result Labels, err error) {
if fs.MimeType(filename) != header.ContentTypeJpeg {
return result, fmt.Errorf("nsfw: %s is not a jpeg file", clean.Log(filepath.Base(filename)))
}
@ -45,13 +51,13 @@ func (t *Detector) File(filename string) (result Labels, err error) {
}
// Labels returns matching labels for a jpeg media string.
func (t *Detector) Labels(img []byte) (result Labels, err error) {
if err := t.loadModel(); err != nil {
return result, err
func (t *Model) Labels(img []byte) (result Labels, err error) {
if loadErr := t.loadModel(); loadErr != nil {
return result, loadErr
}
// Make tensor
tensor, err := createTensorFromImage(img, "jpeg")
tensor, err := createTensorFromImage(img, "jpeg", t.resolution)
if err != nil {
return result, fmt.Errorf("nsfw: %s", err)
@ -83,7 +89,7 @@ func (t *Detector) Labels(img []byte) (result Labels, err error) {
return result, nil
}
func (t *Detector) loadLabels(path string) error {
func (t *Model) loadLabels(path string) error {
modelLabels := path + "/labels.txt"
log.Infof("nsfw: loading labels from labels.txt")
@ -111,7 +117,7 @@ func (t *Detector) loadLabels(path string) error {
return nil
}
func (t *Detector) loadModel() error {
func (t *Model) loadModel() error {
t.mutex.Lock()
defer t.mutex.Unlock()
@ -134,7 +140,7 @@ func (t *Detector) loadModel() error {
return t.loadLabels(t.modelPath)
}
func (t *Detector) getLabels(p []float32) Labels {
func (t *Model) getLabels(p []float32) Labels {
return Labels{
Drawing: p[0],
Hentai: p[1],
@ -144,12 +150,9 @@ func (t *Detector) getLabels(p []float32) Labels {
}
}
func transformImageGraph(imageFormat string) (graph *tf.Graph, input, output tf.Output, err error) {
const (
H, W = 224, 224
Mean = float32(117)
Scale = float32(1)
)
func transformImageGraph(imageFormat string, resolution int) (graph *tf.Graph, input, output tf.Output, err error) {
var H, W = int32(resolution), int32(resolution)
s := op.NewScope()
input = op.Placeholder(s, tf.String)
// Decode PNG or JPEG
@ -176,12 +179,12 @@ func transformImageGraph(imageFormat string) (graph *tf.Graph, input, output tf.
return graph, input, output, err
}
func createTensorFromImage(image []byte, imageFormat string) (*tf.Tensor, error) {
func createTensorFromImage(image []byte, imageFormat string, resolution int) (*tf.Tensor, error) {
tensor, err := tf.NewTensor(string(image))
if err != nil {
return nil, err
}
graph, input, output, err := transformImageGraph(imageFormat)
graph, input, output, err := transformImageGraph(imageFormat, resolution)
if err != nil {
return nil, err
}

View file

@ -13,7 +13,7 @@ import (
var modelPath, _ = filepath.Abs("../../../assets/nsfw")
var detector = New(modelPath)
var detector = NewModel(modelPath)
func TestIsSafe(t *testing.T) {
detect := func(filename string) Labels {
@ -100,7 +100,7 @@ func TestIsSafe(t *testing.T) {
func TestNSFW(t *testing.T) {
porn := Labels{0, 0, 0.11, 0.88, 0}
sexy := Labels{0, 0, 0.2, 0.59, 0.98}
max := Labels{0, 0.999, 0.1, 0.999, 0.999}
maxi := Labels{0, 0.999, 0.1, 0.999, 0.999}
drawing := Labels{0.999, 0, 0, 0, 0}
hentai := Labels{0, 0.80, 0.2, 0, 0}
@ -108,17 +108,17 @@ func TestNSFW(t *testing.T) {
assert.Equal(t, true, sexy.NSFW(ThresholdSafe))
assert.Equal(t, true, hentai.NSFW(ThresholdSafe))
assert.Equal(t, false, drawing.NSFW(ThresholdSafe))
assert.Equal(t, true, max.NSFW(ThresholdSafe))
assert.Equal(t, true, maxi.NSFW(ThresholdSafe))
assert.Equal(t, true, porn.NSFW(ThresholdMedium))
assert.Equal(t, true, sexy.NSFW(ThresholdMedium))
assert.Equal(t, false, hentai.NSFW(ThresholdMedium))
assert.Equal(t, false, drawing.NSFW(ThresholdMedium))
assert.Equal(t, true, max.NSFW(ThresholdMedium))
assert.Equal(t, true, maxi.NSFW(ThresholdMedium))
assert.Equal(t, false, porn.NSFW(ThresholdHigh))
assert.Equal(t, false, sexy.NSFW(ThresholdHigh))
assert.Equal(t, false, hentai.NSFW(ThresholdHigh))
assert.Equal(t, false, drawing.NSFW(ThresholdHigh))
assert.Equal(t, true, max.NSFW(ThresholdHigh))
assert.Equal(t, true, maxi.NSFW(ThresholdHigh))
}

View file

@ -0,0 +1,62 @@
package vision
import (
"errors"
"sort"
"github.com/photoprism/photoprism/internal/ai/classify"
)
// Labels returns suitable labels for the specified image thumbnail.
func Labels(thumbnails []string) (result classify.Labels, err error) {
if len(thumbnails) == 0 {
return result, errors.New("missing thumbnail filenames")
}
if Config == nil {
return result, errors.New("missing configuration")
} else if len(Config.Labels) == 0 {
return result, errors.New("missing labels model configuration")
}
config := Config.Labels[0]
model := config.ClassifyModel()
if model == nil {
return result, errors.New("missing labels model")
}
for i := range thumbnails {
labels, modelErr := model.File(thumbnails[i], Config.Thresholds.Confidence)
if modelErr != nil {
return result, modelErr
}
for j := range labels {
found := false
for k := range result {
if labels[j].Name == result[k].Name {
found = true
if labels[j].Uncertainty < result[k].Uncertainty {
result[k].Uncertainty = labels[j].Uncertainty
}
if labels[j].Priority > result[k].Priority {
result[k].Priority = labels[j].Priority
}
}
}
if !found {
result = append(result, labels...)
}
}
}
sort.Sort(result)
return result, nil
}

View file

@ -0,0 +1,31 @@
package vision
type LabelsResponse struct {
Id string `yaml:"Id,omitempty" json:"id,omitempty"`
Model *Model `yaml:"Model,omitempty" json:"model"`
Result []LabelResult `yaml:"Result,omitempty" json:"result"`
}
func NewLabelsResponse(id string, model *Model, results []LabelResult) LabelsResponse {
if model == nil {
model = NasnetModel
}
if results == nil {
results = []LabelResult{}
}
return LabelsResponse{
Id: id,
Model: model,
Result: results,
}
}
type LabelResult struct {
Id string `yaml:"Id,omitempty" json:"id,omitempty"`
Name string `yaml:"Name,omitempty" json:"name"`
Category string `yaml:"Category,omitempty" json:"category"`
Confidence float64 `yaml:"Confidence,omitempty" json:"confidence,omitempty"`
Topicality float64 `yaml:"Topicality,omitempty" json:"topicality,omitempty"`
}

View file

@ -0,0 +1,32 @@
package vision
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/ai/classify"
"github.com/photoprism/photoprism/pkg/fs"
)
func TestLabels(t *testing.T) {
var assetsPath = fs.Abs("../../../assets")
var examplesPath = assetsPath + "/examples"
t.Run("Success", func(t *testing.T) {
result, err := Labels([]string{examplesPath + "/chameleon_lime.jpg"})
assert.NoError(t, err)
assert.IsType(t, classify.Labels{}, result)
assert.Equal(t, 1, len(result))
t.Log(result)
assert.Equal(t, "chameleon", result[0].Name)
assert.Equal(t, 7, result[0].Uncertainty)
})
t.Run("InvalidFile", func(t *testing.T) {
_, err := Labels([]string{examplesPath + "/notexisting.jpg"})
assert.Error(t, err)
})
}

View file

@ -0,0 +1,71 @@
package vision
import (
"sync"
"github.com/photoprism/photoprism/internal/ai/classify"
"github.com/photoprism/photoprism/pkg/clean"
)
var modelMutex = sync.Mutex{}
// Model represents a computer vision model configuration.
type Model struct {
Name string `yaml:"Name,omitempty" json:"name,omitempty"`
Version string `yaml:"Version,omitempty" json:"version,omitempty"`
Resolution int `yaml:"Resolution,omitempty" json:"resolution,omitempty"`
Url string `yaml:"Url,omitempty" json:"-"`
Method string `yaml:"Method,omitempty" json:"-"`
Format string `yaml:"Format,omitempty" json:"-"`
Path string `yaml:"Path,omitempty" json:"-"`
Tags []string `yaml:"Tags,omitempty" json:"-"`
Disabled bool `yaml:"Disabled,omitempty" json:"-"`
classifyModel *classify.Model
}
// Models represents a set of computer vision models.
type Models []*Model
// ClassifyModel returns the matching classify model instance, if any.
func (m *Model) ClassifyModel() *classify.Model {
modelMutex.Lock()
defer modelMutex.Unlock()
if m.classifyModel != nil {
return m.classifyModel
}
switch m.Name {
case "":
log.Warnf("vision: missing name, model instance cannot be created")
return nil
case NasnetModel.Name, "nasnet":
if model := classify.NewNasnet(AssetsPath, m.Disabled); model == nil {
return nil
} else if err := model.Init(); err != nil {
log.Errorf("vision: %s (init nasnet model)", err)
return nil
} else {
m.classifyModel = model
}
default:
if m.Path == "" {
m.Path = clean.TypeLowerUnderscore(m.Name)
}
if m.Resolution <= 0 {
m.Resolution = DefaultResolution
}
if model := classify.NewModel(AssetsPath, m.Path, m.Resolution, m.Tags, m.Disabled); model == nil {
return nil
} else if err := model.Init(); err != nil {
log.Errorf("vision: %s (init %s)", err, m.Path)
return nil
} else {
m.classifyModel = model
}
}
return m.classifyModel
}

View file

@ -0,0 +1,16 @@
package vision
import (
"github.com/photoprism/photoprism/pkg/fs"
)
type ModelType = string
// AssetsPath specifies the default path to load local TensorFlow models from.
var AssetsPath = fs.Abs("../../../assets")
var DefaultResolution = 224
// NasnetModel is a standard TensorFlow model used for label generation.
var (
NasnetModel = &Model{Name: "Nasnet", Resolution: 224, Tags: []string{"photoprism"}}
)

View file

@ -0,0 +1,74 @@
package vision
import (
"fmt"
"os"
"gopkg.in/yaml.v2"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
)
// Config reference the current configuration options.
var Config = NewOptions()
// Options represents a computer vision configuration for the supported Model types.
type Options struct {
Caption Models `yaml:"Caption,omitempty" json:"caption,omitempty"`
Faces Models `yaml:"Faces,omitempty" json:"faces,omitempty"`
Labels Models `yaml:"Labels,omitempty" json:"labels,omitempty"`
Nsfw Models `yaml:"Nsfw,omitempty" json:"nsfw,omitempty"`
Thresholds Thresholds `yaml:"Thresholds" json:"thresholds"`
}
// NewOptions returns a new computer vision config with defaults.
func NewOptions() *Options {
return &Options{
Caption: Models{},
Faces: Models{},
Labels: Models{NasnetModel},
Nsfw: Models{},
Thresholds: Thresholds{Confidence: 10},
}
}
// Load user settings from file.
func (c *Options) Load(fileName string) error {
if fileName == "" {
return fmt.Errorf("missing config filename")
} else if !fs.FileExists(fileName) {
return fmt.Errorf("%s not found", clean.Log(fileName))
}
yamlConfig, err := os.ReadFile(fileName)
if err != nil {
return err
}
if err = yaml.Unmarshal(yamlConfig, c); err != nil {
return err
}
return nil
}
// Save user settings to a file.
func (c *Options) Save(fileName string) error {
if fileName == "" {
return fmt.Errorf("missing config filename")
}
data, err := yaml.Marshal(c)
if err != nil {
return err
}
if err = os.WriteFile(fileName, data, fs.ModeConfigFile); err != nil {
return err
}
return nil
}

View file

@ -0,0 +1,30 @@
package vision
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/fs"
)
func TestOptions(t *testing.T) {
var configPath = fs.Abs("testdata")
var configFile = filepath.Join(configPath, "vision.yml")
t.Run("Save", func(t *testing.T) {
_ = os.Remove(configFile)
options := NewOptions()
err := options.Save(configFile)
assert.NoError(t, err)
err = options.Load(configFile)
assert.NoError(t, err)
})
t.Run("LoadMissingFile", func(t *testing.T) {
options := NewOptions()
err := options.Load(filepath.Join(configPath, "invalid.yml"))
assert.Error(t, err)
})
}

View file

@ -0,0 +1,2 @@
*
!.gitignore

View file

@ -0,0 +1,7 @@
package vision
// Thresholds are percentages, e.g. to determine the minimum confidence level
// a model must have for a prediction to be considered valid or "accepted".
type Thresholds struct {
Confidence int `yaml:"Confidence" json:"confidence"`
}

View file

@ -0,0 +1,31 @@
/*
Package vision provides a computer vision request handler and a client for using external APIs.
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://www.photoprism.app/trademark>
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
package vision
import (
"github.com/photoprism/photoprism/internal/event"
)
var log = event.Log

View file

@ -94,6 +94,10 @@ func AbortDeleteFailed(c *gin.Context) {
Abort(c, http.StatusInternalServerError, i18n.ErrDeleteFailed)
}
func AbortNotImplemented(c *gin.Context) {
Abort(c, http.StatusNotImplemented, i18n.ErrUnsupported)
}
func AbortUnexpectedError(c *gin.Context) {
Abort(c, http.StatusInternalServerError, i18n.ErrUnexpected)
}

View file

@ -31,7 +31,7 @@ func SearchAlbums(router *gin.RouterGroup) {
router.GET("/albums", func(c *gin.Context) {
s := AuthAny(c, acl.ResourceAlbums, acl.Permissions{acl.ActionSearch, acl.ActionView, acl.AccessShared})
// Abort if permission was not granted.
// Abort if permission is not granted.
if s.Abort(c) {
return
}

View file

@ -30,7 +30,7 @@ func GetConfigOptions(router *gin.RouterGroup) {
s := Auth(c, acl.ResourceConfig, acl.AccessAll)
conf := get.Config()
// Abort if permission was not granted.
// Abort if permission is not granted.
if s.Invalid() || conf.Public() || conf.DisableSettings() {
AbortForbidden(c)
return
@ -111,7 +111,7 @@ func SaveConfigOptions(router *gin.RouterGroup) {
}
// Write YAML data to file.
if err = fs.WriteFile(fileName, yamlData); err != nil {
if err = fs.WriteFile(fileName, yamlData, fs.ModeConfigFile); err != nil {
log.Errorf("config: failed writing values to %s (%s)", clean.Log(fileName), err)
c.AbortWithStatusJSON(http.StatusInternalServerError, err)
return

View file

@ -25,7 +25,7 @@ func GetSettings(router *gin.RouterGroup) {
router.GET("/settings", func(c *gin.Context) {
s := AuthAny(c, acl.ResourceSettings, acl.Permissions{acl.AccessAll, acl.AccessOwn})
// Abort if permission was not granted.
// Abort if permission is not granted.
if s.Abort(c) {
return
}
@ -56,7 +56,7 @@ func SaveSettings(router *gin.RouterGroup) {
router.POST("/settings", func(c *gin.Context) {
s := AuthAny(c, acl.ResourceSettings, acl.Permissions{acl.ActionView, acl.ActionUpdate, acl.ActionManage})
// Abort if permission was not granted.
// Abort if permission is not granted.
if s.Abort(c) {
return
}

View file

@ -29,7 +29,7 @@ func GetFace(router *gin.RouterGroup) {
router.GET("/faces/:id", func(c *gin.Context) {
s := Auth(c, acl.ResourcePeople, acl.ActionView)
// Abort if permission was not granted.
// Abort if permission is not granted.
if s.Abort(c) {
return
}
@ -61,7 +61,7 @@ func UpdateFace(router *gin.RouterGroup) {
router.PUT("/faces/:id", func(c *gin.Context) {
s := Auth(c, acl.ResourcePeople, acl.ActionUpdate)
// Abort if permission was not granted.
// Abort if permission is not granted.
if s.Abort(c) {
return
}

View file

@ -30,7 +30,7 @@ func SearchFaces(router *gin.RouterGroup) {
router.GET("/faces", func(c *gin.Context) {
s := Auth(c, acl.ResourcePeople, acl.ActionSearch)
// Abort if permission was not granted.
// Abort if permission is not granted.
if s.Abort(c) {
return
}

View file

@ -25,7 +25,7 @@ func SendFeedback(router *gin.RouterGroup) {
s := Auth(c, acl.ResourceFeedback, acl.ActionCreate)
// Abort if permission was not granted.
// Abort if permission is not granted.
if s.Abort(c) {
return
}

View file

@ -24,7 +24,7 @@ func GetFile(router *gin.RouterGroup) {
router.GET("/files/:hash", func(c *gin.Context) {
s := Auth(c, acl.ResourceFiles, acl.ActionView)
// Abort if permission was not granted.
// Abort if permission is not granted.
if s.Abort(c) {
return
}

View file

@ -47,7 +47,7 @@ func SearchFolders(router *gin.RouterGroup, urlPath, rootName, rootPath string)
handler := func(c *gin.Context) {
s := Auth(c, acl.ResourceFiles, acl.AccessLibrary)
// Abort if permission was not granted.
// Abort if permission is not granted.
if s.Abort(c) {
return
}

View file

@ -81,7 +81,7 @@ func CreateMarker(router *gin.RouterGroup) {
router.POST("/markers", func(c *gin.Context) {
s := Auth(c, acl.ResourceFiles, acl.ActionUpdate)
// Abort if permission was not granted.
// Abort if permission is not granted.
if s.Abort(c) {
return
}

View file

@ -23,7 +23,7 @@ func GetMetrics(router *gin.RouterGroup) {
router.GET("/metrics", func(c *gin.Context) {
s := Auth(c, acl.ResourceMetrics, acl.AccessAll)
// Abort if permission was not granted.
// Abort if permission is not granted.
if s.Abort(c) {
return
}

View file

@ -41,7 +41,7 @@ func SearchPhotos(router *gin.RouterGroup) {
searchForm := func(c *gin.Context) (frm form.SearchPhotos, s *entity.Session, err error) {
s = AuthAny(c, acl.ResourcePhotos, acl.Permissions{acl.ActionSearch, acl.ActionView, acl.AccessShared})
// Abort if permission was not granted.
// Abort if permission is not granted.
if s.Abort(c) {
return frm, s, i18n.Error(i18n.ErrForbidden)
}

View file

@ -38,7 +38,7 @@ func SearchGeo(router *gin.RouterGroup) {
handler := func(c *gin.Context) {
s := AuthAny(c, acl.ResourcePlaces, acl.Permissions{acl.ActionSearch, acl.ActionView, acl.AccessShared})
// Abort if permission was not granted.
// Abort if permission is not granted.
if s.Abort(c) {
return
}

View file

@ -18,7 +18,7 @@ func StopServer(router *gin.RouterGroup) {
s := Auth(c, acl.ResourceConfig, acl.ActionManage)
conf := get.Config()
// Abort if permission was not granted.
// Abort if permission is not granted.
if s.Invalid() || conf.Public() || conf.DisableSettings() || conf.DisableRestart() {
AbortForbidden(c)
return

View file

@ -4850,6 +4850,126 @@
}
}
},
"/api/v1/vision/caption": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Vision"
],
"summary": "returns a suitable caption for an image",
"operationId": "PostVisionCaption",
"responses": {
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"429": {
"description": "Too Many Requests",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"501": {
"description": "Not Implemented",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
}
}
}
},
"/api/v1/vision/faces": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Vision"
],
"summary": "returns the positions and embeddings of detected faces",
"operationId": "PostVisionFaces",
"responses": {
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"429": {
"description": "Too Many Requests",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"501": {
"description": "Not Implemented",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
}
}
}
},
"/api/v1/vision/labels": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Vision"
],
"summary": "returns suitable labels for an image",
"operationId": "PostVisionLabels",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/vision.LabelsResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"429": {
"description": "Too Many Requests",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
}
}
}
},
"/api/v1/zip": {
"post": {
"tags": [
@ -5029,6 +5149,9 @@
"HttpsProxyInsecure": {
"type": "boolean"
},
"ImportAllow": {
"type": "string"
},
"IndexSchedule": {
"type": "string"
},
@ -5132,6 +5255,9 @@
"Trace": {
"type": "boolean"
},
"UsageInfo": {
"type": "boolean"
},
"WakeupInterval": {
"$ref": "#/definitions/time.Duration"
},
@ -5140,6 +5266,37 @@
}
}
},
"customize.AlbumsOrder": {
"type": "object",
"properties": {
"album": {
"type": "string"
},
"folder": {
"type": "string"
},
"moment": {
"type": "string"
},
"month": {
"type": "string"
},
"state": {
"type": "string"
}
}
},
"customize.AlbumsSettings": {
"type": "object",
"properties": {
"download": {
"$ref": "#/definitions/customize.DownloadSettings"
},
"order": {
"$ref": "#/definitions/customize.AlbumsOrder"
}
}
},
"customize.DownloadName": {
"type": "string",
"enum": [
@ -5185,6 +5342,9 @@
"archive": {
"type": "boolean"
},
"calendar": {
"type": "boolean"
},
"delete": {
"type": "boolean"
},
@ -5309,12 +5469,21 @@
},
"listView": {
"type": "boolean"
},
"showCaptions": {
"type": "boolean"
},
"showTitles": {
"type": "boolean"
}
}
},
"customize.Settings": {
"type": "object",
"properties": {
"albums": {
"$ref": "#/definitions/customize.AlbumsSettings"
},
"download": {
"$ref": "#/definitions/customize.DownloadSettings"
},
@ -5386,6 +5555,9 @@
"scrollbar": {
"type": "boolean"
},
"startPage": {
"type": "string"
},
"theme": {
"type": "string"
},
@ -5754,6 +5926,9 @@
"OriginalName": {
"type": "string"
},
"Pages": {
"type": "integer"
},
"PhotoUID": {
"type": "string"
},
@ -7022,6 +7197,9 @@
"TakenAtLocal": {
"type": "string"
},
"TimeZone": {
"type": "string"
},
"Title": {
"type": "string"
},
@ -7384,18 +7562,6 @@
1000000,
1000000000,
60000000000,
3600000000000,
1,
1000,
1000000,
1000000000,
60000000000,
3600000000000,
1,
1000,
1000000,
1000000000,
60000000000,
3600000000000
],
"x-enum-varnames": [
@ -7420,20 +7586,59 @@
"Millisecond",
"Second",
"Minute",
"Hour",
"Nanosecond",
"Microsecond",
"Millisecond",
"Second",
"Minute",
"Hour",
"Nanosecond",
"Microsecond",
"Millisecond",
"Second",
"Minute",
"Hour"
]
},
"vision.LabelResult": {
"type": "object",
"properties": {
"category": {
"type": "string"
},
"confidence": {
"type": "number"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"topicality": {
"type": "number"
}
}
},
"vision.LabelsResponse": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"model": {
"$ref": "#/definitions/vision.Model"
},
"result": {
"type": "array",
"items": {
"$ref": "#/definitions/vision.LabelResult"
}
}
}
},
"vision.Model": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"resolution": {
"type": "integer"
},
"version": {
"type": "string"
}
}
}
},
"externalDocs": {

View file

@ -0,0 +1,28 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/auth/acl"
)
// PostVisionCaption returns a suitable caption for an image.
//
// @Summary returns a suitable caption for an image
// @Id PostVisionCaption
// @Tags Vision
// @Produce json
// @Failure 401,403,404,429,501 {object} i18n.Response
// @Router /api/v1/vision/caption [post]
func PostVisionCaption(router *gin.RouterGroup) {
router.POST("/vision/caption", func(c *gin.Context) {
s := Auth(c, acl.ResourceVision, acl.AccessAll)
// Abort if permission is not granted.
if s.Abort(c) {
return
}
AbortNotImplemented(c)
})
}

View file

@ -0,0 +1,28 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/auth/acl"
)
// PostVisionFaces returns the positions and embeddings of detected faces.
//
// @Summary returns the positions and embeddings of detected faces
// @Id PostVisionFaces
// @Tags Vision
// @Produce json
// @Failure 401,403,429,501 {object} i18n.Response
// @Router /api/v1/vision/faces [post]
func PostVisionFaces(router *gin.RouterGroup) {
router.POST("/vision/faces", func(c *gin.Context) {
s := Auth(c, acl.ResourceVision, acl.AccessAll)
// Abort if permission is not granted.
if s.Abort(c) {
return
}
AbortNotImplemented(c)
})
}

View file

@ -0,0 +1,35 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/ai/vision"
"github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/pkg/rnd"
)
// PostVisionLabels returns suitable labels for an image.
//
// @Summary returns suitable labels for an image
// @Id PostVisionLabels
// @Tags Vision
// @Produce json
// @Success 200 {object} vision.LabelsResponse
// @Failure 401,403,429 {object} i18n.Response
// @Router /api/v1/vision/labels [post]
func PostVisionLabels(router *gin.RouterGroup) {
router.POST("/vision/labels", func(c *gin.Context) {
s := Auth(c, acl.ResourceVision, acl.AccessAll)
// Abort if permission is not granted.
if s.Abort(c) {
return
}
response := vision.NewLabelsResponse(rnd.UUID(), vision.NasnetModel, nil)
c.JSON(http.StatusOK, response)
})
}

View file

@ -59,6 +59,7 @@ const (
ResourceLogs Resource = "logs"
ResourceWebDAV Resource = "webdav"
ResourceMetrics Resource = "metrics"
ResourceVision Resource = "vision"
ResourceFeedback Resource = "feedback"
ResourceDefault Resource = "default"
)

View file

@ -67,6 +67,10 @@ var (
AccessOwn: true,
ActionUpdate: true,
}
GrantCreateAll = Grant{
AccessAll: true,
ActionCreate: true,
}
GrantViewOwn = Grant{
AccessOwn: true,
ActionView: true,

View file

@ -24,6 +24,7 @@ var ResourceNames = []Resource{
ResourceLogs,
ResourceWebDAV,
ResourceMetrics,
ResourceVision,
ResourceFeedback,
ResourceDefault,
}

View file

@ -95,6 +95,10 @@ var Rules = ACL{
RoleAdmin: GrantFullAccess,
RoleClient: GrantViewAll,
},
ResourceVision: Roles{
RoleAdmin: GrantFullAccess,
RoleClient: GrantCreateAll,
},
ResourceFeedback: Roles{
RoleAdmin: GrantFullAccess,
},

View file

@ -24,6 +24,9 @@ func TestMain(m *testing.M) {
c := config.NewTestConfig("commands")
get.SetConfig(c)
// Remember to close database connection.
defer c.CloseDb()
// Init config and connect to database.
InitConfig = func(ctx *cli.Context) (*config.Config, error) {
return c, c.Init()
@ -32,9 +35,6 @@ func TestMain(m *testing.M) {
// Run unit tests.
code := m.Run()
// Close database connection.
_ = c.CloseDb()
os.Exit(code)
}

View file

@ -45,6 +45,7 @@ import (
"github.com/urfave/cli/v2"
"github.com/photoprism/photoprism/internal/ai/face"
"github.com/photoprism/photoprism/internal/ai/vision"
"github.com/photoprism/photoprism/internal/config/customize"
"github.com/photoprism/photoprism/internal/config/ttl"
"github.com/photoprism/photoprism/internal/entity"
@ -239,9 +240,16 @@ func (c *Config) Init() error {
// Initialize extensions.
Ext().Init(c)
// Initialize the thumbnail generation package.
// Initialize thumbnail package.
thumb.Init(memory.FreeMemory(), c.IndexWorkers(), c.ThumbLibrary())
// Load optional vision package configuration.
if visionYaml := c.VisionYaml(); !fs.FileExistsNotEmpty(visionYaml) {
// Do nothing.
} else if loadErr := vision.Config.Load(visionYaml); loadErr != nil {
log.Warnf("vision: %s", loadErr)
}
// Update package defaults.
c.Propagate()
@ -262,7 +270,7 @@ func (c *Config) Propagate() {
FlushCache()
log.SetLevel(c.LogLevel())
// Initialize the thumbnail generation package.
// Configure thumbnail package.
thumb.Library = c.ThumbLibrary()
thumb.Color = c.ThumbColor()
thumb.Filter = c.ThumbFilter()
@ -272,6 +280,9 @@ func (c *Config) Propagate() {
thumb.CachePublic = c.HttpCachePublic()
initThumbs()
// Configure computer vision package.
vision.AssetsPath = c.AssetsPath()
// Set cache expiration defaults.
ttl.CacheDefault = c.HttpCacheMaxAge()
ttl.CacheVideo = c.HttpVideoMaxAge()

View file

@ -11,8 +11,8 @@ func (c *Config) TensorFlowVersion() string {
return tf.Version()
}
// TensorFlowModelPath returns the TensorFlow model path.
func (c *Config) TensorFlowModelPath() string {
// NasnetModelPath returns the TensorFlow model path.
func (c *Config) NasnetModelPath() string {
return filepath.Join(c.AssetsPath(), "nasnet")
}

View file

@ -16,7 +16,7 @@ func TestConfig_TensorFlowVersion(t *testing.T) {
func TestConfig_TensorFlowModelPath(t *testing.T) {
c := NewConfig(CliTestContext())
path := c.TensorFlowModelPath()
path := c.NasnetModelPath()
assert.Equal(t, "/go/src/github.com/photoprism/photoprism/assets/nasnet", path)
}

View file

@ -196,7 +196,7 @@ func (c *Config) CreateDirectories() error {
}
// Create TensorFlow model path if it doesn't exist yet.
if dir := c.TensorFlowModelPath(); dir == "" {
if dir := c.NasnetModelPath(); dir == "" {
return notFoundError("tensorflow model")
} else if err := fs.MkdirAll(dir); err != nil {
return createError(dir, err)
@ -257,6 +257,11 @@ func (c *Config) DefaultsYaml() string {
return fs.Abs(c.options.DefaultsYaml)
}
// VisionYaml returns the vision config YAML filename.
func (c *Config) VisionYaml() string {
return filepath.Join(c.ConfigPath(), "vision.yml")
}
// HubConfigFile returns the backend api config file name.
func (c *Config) HubConfigFile() string {
return filepath.Join(c.ConfigPath(), "hub.yml")

View file

@ -412,6 +412,11 @@ func TestConfig_CreateDirectories2(t *testing.T) {
}
*/
func TestConfig_VisionYaml(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, "/go/src/github.com/photoprism/photoprism/storage/testdata/config/vision.yml", c.VisionYaml())
}
func TestConfig_PIDFilename2(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, "/go/src/github.com/photoprism/photoprism/storage/testdata/photoprism.pid", c.PIDFilename())

View file

@ -135,7 +135,7 @@ func (s Settings) StackMeta() bool {
// Load user settings from file.
func (s *Settings) Load(fileName string) error {
if fileName == "" {
return fmt.Errorf("no settings filename provided")
return fmt.Errorf("missing settings filename")
} else if !fs.FileExists(fileName) {
return fmt.Errorf("settings file not found: %s", clean.Log(fileName))
}
@ -157,6 +157,10 @@ func (s *Settings) Load(fileName string) error {
// Save user settings to a file.
func (s *Settings) Save(fileName string) error {
if fileName == "" {
return fmt.Errorf("missing settings filename")
}
data, err := yaml.Marshal(s)
if err != nil {
@ -165,7 +169,7 @@ func (s *Settings) Save(fileName string) error {
s.Propagate()
if err = os.WriteFile(fileName, data, fs.ModeFile); err != nil {
if err = os.WriteFile(fileName, data, fs.ModeConfigFile); err != nil {
return err
}

View file

@ -134,10 +134,12 @@ func (c *Config) Report() (rows [][]string, cols []string) {
{"raw-presets", fmt.Sprintf("%t", c.RawPresets())},
{"exif-bruteforce", fmt.Sprintf("%t", c.ExifBruteForce())},
// TensorFlow.
// Computer Vision.
{"detect-nsfw", fmt.Sprintf("%t", c.DetectNSFW())},
{"nsfw-model-path", c.NSFWModelPath()},
{"nasnet-model-path", c.NasnetModelPath()},
{"facenet-model-path", c.FaceNetModelPath()},
{"tensorflow-version", c.TensorFlowVersion()},
{"tensorflow-model-path", c.TensorFlowModelPath()},
// Customization.
{"default-locale", c.DefaultLocale()},

View file

@ -53,7 +53,7 @@ func (m *Album) SaveAsYaml(fileName string) error {
defer albumYamlMutex.Unlock()
// Write YAML data to file.
if err = fs.WriteFile(fileName, data); err != nil {
if err = fs.WriteFile(fileName, data, fs.ModeFile); err != nil {
return err
}

View file

@ -58,7 +58,7 @@ func (m *Photo) SaveAsYaml(fileName string) error {
defer photoYamlMutex.Unlock()
// Write YAML data to file.
if err = fs.WriteFile(fileName, data); err != nil {
if err = fs.WriteFile(fileName, data, fs.ModeFile); err != nil {
return err
}

View file

@ -131,7 +131,7 @@ func Database(backupPath, fileName string, toStdOut, force bool, retain int) (er
if toStdOut {
log.Infof("backup: sending database backup to stdout")
f = os.Stdout
} else if f, err = os.OpenFile(fileName, os.O_TRUNC|os.O_RDWR|os.O_CREATE, fs.ModeBackup); err != nil {
} else if f, err = os.OpenFile(fileName, os.O_TRUNC|os.O_RDWR|os.O_CREATE, fs.ModeBackupFile); err != nil {
return fmt.Errorf("failed to create %s (%s)", clean.Log(fileName), err)
} else {
log.Infof("backup: %s database backup file %s", backupAction, clean.Log(filepath.Base(fileName)))

View file

@ -9,10 +9,10 @@ import (
var onceClassify sync.Once
func initClassify() {
services.Classify = classify.New(Config().AssetsPath(), Config().DisableClassification())
services.Classify = classify.NewNasnet(Config().AssetsPath(), Config().DisableClassification())
}
func Classify() *classify.TensorFlow {
func Classify() *classify.Model {
onceClassify.Do(initClassify)
return services.Classify

View file

@ -9,10 +9,10 @@ import (
var onceFaceNet sync.Once
func initFaceNet() {
services.FaceNet = face.NewNet(conf.FaceNetModelPath(), "", conf.DisableFaces())
services.FaceNet = face.NewModel(conf.FaceNetModelPath(), "", conf.DisableFaces())
}
func FaceNet() *face.Net {
func FaceNet() *face.Model {
onceFaceNet.Do(initFaceNet)
return services.FaceNet

View file

@ -9,7 +9,7 @@ import (
var onceIndex sync.Once
func initIndex() {
services.Index = photoprism.NewIndex(Config(), Classify(), NsfwDetector(), FaceNet(), Convert(), Files(), Photos())
services.Index = photoprism.NewIndex(Config(), NsfwDetector(), FaceNet(), Convert(), Files(), Photos())
}
func Index() *photoprism.Index {

View file

@ -9,10 +9,10 @@ import (
var onceNsfwDetector sync.Once
func initNsfwDetector() {
services.Nsfw = nsfw.New(conf.NSFWModelPath())
services.Nsfw = nsfw.NewModel(conf.NSFWModelPath())
}
func NsfwDetector() *nsfw.Detector {
func NsfwDetector() *nsfw.Model {
onceNsfwDetector.Do(initNsfwDetector)
return services.Nsfw

View file

@ -43,7 +43,7 @@ var services struct {
FolderCache *gc.Cache
CoverCache *gc.Cache
ThumbCache *gc.Cache
Classify *classify.TensorFlow
Classify *classify.Model
Convert *photoprism.Convert
Files *photoprism.Files
Photos *photoprism.Photos
@ -54,8 +54,8 @@ var services struct {
Places *photoprism.Places
Purge *photoprism.Purge
CleanUp *photoprism.CleanUp
Nsfw *nsfw.Detector
FaceNet *face.Net
Nsfw *nsfw.Model
FaceNet *face.Model
Query *query.Query
Thumbs *photoprism.Thumbs
Session *session.Session

View file

@ -31,7 +31,7 @@ func TestThumbCache(t *testing.T) {
}
func TestClassify(t *testing.T) {
assert.IsType(t, &classify.TensorFlow{}, Classify())
assert.IsType(t, &classify.Model{}, Classify())
}
func TestConvert(t *testing.T) {
@ -59,7 +59,7 @@ func TestCleanUp(t *testing.T) {
}
func TestNsfwDetector(t *testing.T) {
assert.IsType(t, &nsfw.Detector{}, NsfwDetector())
assert.IsType(t, &nsfw.Model{}, NsfwDetector())
}
func TestQuery(t *testing.T) {

View file

@ -67,7 +67,6 @@ func (imp *Import) Start(opt ImportOptions) fs.Done {
return done
}
ind := imp.index
importPath := opt.Path
// Check if the import folder exists.
@ -86,11 +85,6 @@ func (imp *Import) Start(opt ImportOptions) fs.Done {
defer mutex.IndexWorker.Stop()
}
if err := ind.tensorFlow.Init(); err != nil {
log.Errorf("import: %s", err.Error())
return done
}
jobs := make(chan ImportJob)
// Start a fixed number of goroutines to import files.

View file

@ -5,7 +5,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/ai/classify"
"github.com/photoprism/photoprism/internal/ai/face"
"github.com/photoprism/photoprism/internal/ai/nsfw"
"github.com/photoprism/photoprism/internal/config"
@ -14,12 +13,11 @@ import (
func TestNewImport(t *testing.T) {
conf := config.TestConfig()
tf := classify.New(conf.AssetsPath(), conf.DisableTensorFlow())
nd := nsfw.New(conf.NSFWModelPath())
fn := face.NewNet(conf.FaceNetModelPath(), "", conf.DisableTensorFlow())
nd := nsfw.NewModel(conf.NSFWModelPath())
fn := face.NewModel(conf.FaceNetModelPath(), "", conf.DisableTensorFlow())
convert := NewConvert(conf)
ind := NewIndex(conf, tf, nd, fn, convert, NewFiles(), NewPhotos())
ind := NewIndex(conf, nd, fn, convert, NewFiles(), NewPhotos())
imp := NewImport(conf, ind, convert)
assert.IsType(t, &Import{}, imp)
@ -32,12 +30,11 @@ func TestImport_DestinationFilename(t *testing.T) {
t.Fatal(err)
}
tf := classify.New(conf.AssetsPath(), conf.DisableTensorFlow())
nd := nsfw.New(conf.NSFWModelPath())
fn := face.NewNet(conf.FaceNetModelPath(), "", conf.DisableTensorFlow())
nd := nsfw.NewModel(conf.NSFWModelPath())
fn := face.NewModel(conf.FaceNetModelPath(), "", conf.DisableTensorFlow())
convert := NewConvert(conf)
ind := NewIndex(conf, tf, nd, fn, convert, NewFiles(), NewPhotos())
ind := NewIndex(conf, nd, fn, convert, NewFiles(), NewPhotos())
imp := NewImport(conf, ind, convert)
@ -77,12 +74,11 @@ func TestImport_Start(t *testing.T) {
conf.InitializeTestData()
tf := classify.New(conf.AssetsPath(), conf.DisableTensorFlow())
nd := nsfw.New(conf.NSFWModelPath())
fn := face.NewNet(conf.FaceNetModelPath(), "", conf.DisableTensorFlow())
nd := nsfw.NewModel(conf.NSFWModelPath())
fn := face.NewModel(conf.FaceNetModelPath(), "", conf.DisableTensorFlow())
convert := NewConvert(conf)
ind := NewIndex(conf, tf, nd, fn, convert, NewFiles(), NewPhotos())
ind := NewIndex(conf, nd, fn, convert, NewFiles(), NewPhotos())
imp := NewImport(conf, ind, convert)

View file

@ -5,7 +5,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/ai/classify"
"github.com/photoprism/photoprism/internal/ai/face"
"github.com/photoprism/photoprism/internal/ai/nsfw"
"github.com/photoprism/photoprism/internal/config"
@ -19,11 +18,10 @@ func TestImportWorker_OriginalFileNames(t *testing.T) {
t.Fatal(err)
}
tf := classify.New(c.AssetsPath(), c.DisableTensorFlow())
nd := nsfw.New(c.NSFWModelPath())
fn := face.NewNet(c.FaceNetModelPath(), "", c.DisableTensorFlow())
nd := nsfw.NewModel(c.NSFWModelPath())
fn := face.NewModel(c.FaceNetModelPath(), "", c.DisableTensorFlow())
convert := NewConvert(c)
ind := NewIndex(c, tf, nd, fn, convert, NewFiles(), NewPhotos())
ind := NewIndex(c, nd, fn, convert, NewFiles(), NewPhotos())
imp := &Import{c, ind, convert, c.ImportAllow()}
mediaFileName := c.ExamplesPath() + "/beach_sand.jpg"

View file

@ -11,7 +11,6 @@ import (
"github.com/karrick/godirwalk"
"github.com/photoprism/photoprism/internal/ai/classify"
"github.com/photoprism/photoprism/internal/ai/face"
"github.com/photoprism/photoprism/internal/ai/nsfw"
"github.com/photoprism/photoprism/internal/config"
@ -27,9 +26,8 @@ import (
// Index represents an indexer that indexes files in the originals directory.
type Index struct {
conf *config.Config
tensorFlow *classify.TensorFlow
nsfwDetector *nsfw.Detector
faceNet *face.Net
nsfwDetector *nsfw.Model
faceNet *face.Model
convert *Convert
files *Files
photos *Photos
@ -40,7 +38,7 @@ type Index struct {
}
// NewIndex returns a new indexer and expects its dependencies as arguments.
func NewIndex(conf *config.Config, tensorFlow *classify.TensorFlow, nsfwDetector *nsfw.Detector, faceNet *face.Net, convert *Convert, files *Files, photos *Photos) *Index {
func NewIndex(conf *config.Config, nsfwDetector *nsfw.Model, faceNet *face.Model, convert *Convert, files *Files, photos *Photos) *Index {
if conf == nil {
log.Errorf("index: config is not set")
return nil
@ -48,7 +46,6 @@ func NewIndex(conf *config.Config, tensorFlow *classify.TensorFlow, nsfwDetector
i := &Index{
conf: conf,
tensorFlow: tensorFlow,
nsfwDetector: nsfwDetector,
faceNet: faceNet,
convert: convert,
@ -107,12 +104,6 @@ func (ind *Index) Start(o IndexOptions) (found fs.Done, updated int) {
defer mutex.IndexWorker.Stop()
if err := ind.tensorFlow.Init(); err != nil {
log.Errorf("index: %s", clean.Error(err))
return found, updated
}
jobs := make(chan IndexJob)
// Start a fixed number of goroutines to index files.

View file

@ -1,66 +1,55 @@
package photoprism
import (
"sort"
"time"
"github.com/dustin/go-humanize/english"
"github.com/photoprism/photoprism/internal/ai/classify"
"github.com/photoprism/photoprism/internal/ai/vision"
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/clean"
)
// Labels classifies a JPEG image and returns matching labels.
func (ind *Index) Labels(jpeg *MediaFile) (results classify.Labels) {
func (ind *Index) Labels(file *MediaFile) (labels classify.Labels) {
start := time.Now()
var err error
var sizes []thumb.Name
var thumbnails []string
if jpeg.Square() {
// The thumbnail size may need to be adjusted to use other models.
if file.Square() {
// Only one thumbnail is required for square images.
sizes = []thumb.Name{thumb.Tile224}
thumbnails = make([]string, 0, 1)
} else {
// Use three thumbnails otherwise (center, left, right).
sizes = []thumb.Name{thumb.Tile224, thumb.Left224, thumb.Right224}
thumbnails = make([]string, 0, 3)
}
var labels classify.Labels
// Get thumbnail filenames for the selected sizes.
for _, size := range sizes {
filename, err := jpeg.Thumbnail(Config().ThumbCachePath(), size)
if err != nil {
log.Debugf("%s in %s", err, clean.Log(jpeg.BaseName()))
if thumbnail, fileErr := file.Thumbnail(Config().ThumbCachePath(), size); fileErr != nil {
log.Debugf("index: %s in %s", err, clean.Log(file.BaseName()))
continue
}
imageLabels, err := ind.tensorFlow.File(filename)
if err != nil {
log.Debugf("%s in %s", err, clean.Log(jpeg.BaseName()))
continue
}
labels = append(labels, imageLabels...)
}
// Sort by priority and uncertainty
sort.Sort(labels)
var confidence int
for _, label := range labels {
if confidence == 0 {
confidence = 100 - label.Uncertainty
}
if (100 - label.Uncertainty) > (confidence / 3) {
results = append(results, label)
} else {
thumbnails = append(thumbnails, thumbnail)
}
}
if l := len(labels); l == 1 {
log.Infof("index: matched %d label with %s [%s]", l, clean.Log(jpeg.BaseName()), time.Since(start))
} else if l > 1 {
log.Infof("index: matched %d labels with %s [%s]", l, clean.Log(jpeg.BaseName()), time.Since(start))
// Get matching labels from computer vision model.
if labels, err = vision.Labels(thumbnails); err != nil {
log.Debugf("labels: %s in %s", err, clean.Log(file.BaseName()))
return labels
}
return results
// Log number of labels found and return results.
if n := len(labels); n > 0 {
log.Infof("index: found %s for %s [%s]", english.Plural(n, "label", "labels"), clean.Log(file.BaseName()), time.Since(start))
}
return labels
}

View file

@ -5,7 +5,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/ai/classify"
"github.com/photoprism/photoprism/internal/ai/face"
"github.com/photoprism/photoprism/internal/ai/nsfw"
"github.com/photoprism/photoprism/internal/config"
@ -22,12 +21,11 @@ func TestIndex_MediaFile(t *testing.T) {
cfg.InitializeTestData()
tf := classify.New(cfg.AssetsPath(), cfg.DisableTensorFlow())
nd := nsfw.New(cfg.NSFWModelPath())
fn := face.NewNet(cfg.FaceNetModelPath(), "", cfg.DisableTensorFlow())
nd := nsfw.NewModel(cfg.NSFWModelPath())
fn := face.NewModel(cfg.FaceNetModelPath(), "", cfg.DisableTensorFlow())
convert := NewConvert(cfg)
ind := NewIndex(cfg, tf, nd, fn, convert, NewFiles(), NewPhotos())
ind := NewIndex(cfg, nd, fn, convert, NewFiles(), NewPhotos())
indexOpt := IndexOptionsAll()
mediaFile, err := NewMediaFile("testdata/flash.jpg")
@ -59,12 +57,11 @@ func TestIndex_MediaFile(t *testing.T) {
cfg.InitializeTestData()
tf := classify.New(cfg.AssetsPath(), cfg.DisableTensorFlow())
nd := nsfw.New(cfg.NSFWModelPath())
fn := face.NewNet(cfg.FaceNetModelPath(), "", cfg.DisableTensorFlow())
nd := nsfw.NewModel(cfg.NSFWModelPath())
fn := face.NewModel(cfg.FaceNetModelPath(), "", cfg.DisableTensorFlow())
convert := NewConvert(cfg)
ind := NewIndex(cfg, tf, nd, fn, convert, NewFiles(), NewPhotos())
ind := NewIndex(cfg, nd, fn, convert, NewFiles(), NewPhotos())
indexOpt := IndexOptionsAll()
mediaFile, err := NewMediaFile(cfg.ExamplesPath() + "/blue-go-video.mp4")
if err != nil {
@ -82,12 +79,11 @@ func TestIndex_MediaFile(t *testing.T) {
cfg.InitializeTestData()
tf := classify.New(cfg.AssetsPath(), cfg.DisableTensorFlow())
nd := nsfw.New(cfg.NSFWModelPath())
fn := face.NewNet(cfg.FaceNetModelPath(), "", cfg.DisableTensorFlow())
nd := nsfw.NewModel(cfg.NSFWModelPath())
fn := face.NewModel(cfg.FaceNetModelPath(), "", cfg.DisableTensorFlow())
convert := NewConvert(cfg)
ind := NewIndex(cfg, tf, nd, fn, convert, NewFiles(), NewPhotos())
ind := NewIndex(cfg, nd, fn, convert, NewFiles(), NewPhotos())
indexOpt := IndexOptionsAll()
result := ind.MediaFile(nil, indexOpt, "blue-go-video.mp4", "")

View file

@ -6,7 +6,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/ai/classify"
"github.com/photoprism/photoprism/internal/ai/face"
"github.com/photoprism/photoprism/internal/ai/nsfw"
"github.com/photoprism/photoprism/internal/config"
@ -53,12 +52,11 @@ func TestIndexRelated(t *testing.T) {
t.Fatal(err)
}
tf := classify.New(conf.AssetsPath(), conf.DisableTensorFlow())
nd := nsfw.New(conf.NSFWModelPath())
fn := face.NewNet(conf.FaceNetModelPath(), "", conf.DisableTensorFlow())
nd := nsfw.NewModel(conf.NSFWModelPath())
fn := face.NewModel(conf.FaceNetModelPath(), "", conf.DisableTensorFlow())
convert := NewConvert(conf)
ind := NewIndex(conf, tf, nd, fn, convert, NewFiles(), NewPhotos())
ind := NewIndex(conf, nd, fn, convert, NewFiles(), NewPhotos())
opt := IndexOptionsAll()
result := IndexRelated(related, ind, opt)
@ -114,12 +112,11 @@ func TestIndexRelated(t *testing.T) {
t.Fatal(err)
}
tf := classify.New(conf.AssetsPath(), conf.DisableTensorFlow())
nd := nsfw.New(conf.NSFWModelPath())
fn := face.NewNet(conf.FaceNetModelPath(), "", conf.DisableTensorFlow())
nd := nsfw.NewModel(conf.NSFWModelPath())
fn := face.NewModel(conf.FaceNetModelPath(), "", conf.DisableTensorFlow())
convert := NewConvert(conf)
ind := NewIndex(conf, tf, nd, fn, convert, NewFiles(), NewPhotos())
ind := NewIndex(conf, nd, fn, convert, NewFiles(), NewPhotos())
opt := IndexOptionsAll()
result := IndexRelated(related, ind, opt)

View file

@ -7,7 +7,6 @@ import (
"github.com/dustin/go-humanize/english"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/ai/classify"
"github.com/photoprism/photoprism/internal/ai/face"
"github.com/photoprism/photoprism/internal/ai/nsfw"
"github.com/photoprism/photoprism/internal/config"
@ -22,12 +21,11 @@ func TestIndex_Start(t *testing.T) {
conf.InitializeTestData()
tf := classify.New(conf.AssetsPath(), conf.DisableTensorFlow())
nd := nsfw.New(conf.NSFWModelPath())
fn := face.NewNet(conf.FaceNetModelPath(), "", conf.DisableTensorFlow())
nd := nsfw.NewModel(conf.NSFWModelPath())
fn := face.NewModel(conf.FaceNetModelPath(), "", conf.DisableTensorFlow())
convert := NewConvert(conf)
ind := NewIndex(conf, tf, nd, fn, convert, NewFiles(), NewPhotos())
ind := NewIndex(conf, nd, fn, convert, NewFiles(), NewPhotos())
imp := NewImport(conf, ind, convert)
opt := ImportOptionsMove(conf.ImportPath(), "")
@ -71,12 +69,11 @@ func TestIndex_File(t *testing.T) {
conf.InitializeTestData()
tf := classify.New(conf.AssetsPath(), conf.DisableTensorFlow())
nd := nsfw.New(conf.NSFWModelPath())
fn := face.NewNet(conf.FaceNetModelPath(), "", conf.DisableTensorFlow())
nd := nsfw.NewModel(conf.NSFWModelPath())
fn := face.NewModel(conf.FaceNetModelPath(), "", conf.DisableTensorFlow())
convert := NewConvert(conf)
ind := NewIndex(conf, tf, nd, fn, convert, NewFiles(), NewPhotos())
ind := NewIndex(conf, nd, fn, convert, NewFiles(), NewPhotos())
err := ind.FileName("xxx", IndexOptionsAll())

View file

@ -9,7 +9,6 @@ import (
"github.com/disintegration/imaging"
"github.com/photoprism/photoprism/internal/ai/classify"
"github.com/photoprism/photoprism/internal/ai/face"
"github.com/photoprism/photoprism/internal/ai/nsfw"
"github.com/photoprism/photoprism/internal/config"
@ -30,12 +29,11 @@ func TestResample_Start(t *testing.T) {
conf.InitializeTestData()
tf := classify.New(conf.AssetsPath(), conf.DisableTensorFlow())
nd := nsfw.New(conf.NSFWModelPath())
fn := face.NewNet(conf.FaceNetModelPath(), "", conf.DisableTensorFlow())
nd := nsfw.NewModel(conf.NSFWModelPath())
fn := face.NewModel(conf.FaceNetModelPath(), "", conf.DisableTensorFlow())
convert := NewConvert(conf)
ind := NewIndex(conf, tf, nd, fn, convert, NewFiles(), NewPhotos())
ind := NewIndex(conf, nd, fn, convert, NewFiles(), NewPhotos())
imp := NewImport(conf, ind, convert)
opt := ImportOptionsMove(conf.ImportPath(), "")

View file

@ -163,6 +163,11 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.SearchFoldersImport(APIv1)
api.FolderCover(APIv1)
// Computer Vision.
api.PostVisionCaption(APIv1)
api.PostVisionFaces(APIv1)
api.PostVisionLabels(APIv1)
// People.
api.SearchSubjects(APIv1)
api.GetSubject(APIv1)

View file

@ -184,7 +184,7 @@ func WebDAVSetFavoriteFlag(fileName string) {
}
// Write YAML data to file.
if err := fs.WriteFile(yamlName, []byte("Favorite: true\n")); err != nil {
if err := fs.WriteFile(yamlName, []byte("Favorite: true\n"), fs.ModeConfigFile); err != nil {
log.Errorf("webdav: %s", err.Error())
return
}

View file

@ -334,7 +334,7 @@ func (c *Config) Save() error {
return err
}
if err = fs.WriteFile(c.FileName, data); err != nil {
if err = fs.WriteFile(c.FileName, data, fs.ModeConfigFile); err != nil {
return err
}

View file

@ -23,7 +23,13 @@ const (
Left224 Name = "left_224"
Right224 Name = "right_224"
Tile224 Name = "tile_224"
Left384 Name = "left_384"
Right384 Name = "right_384"
Tile384 Name = "tile_384"
Fit720 Name = "fit_720"
Tile480 Name = "tile_480"
Left480 Name = "left_480"
Right480 Name = "right_480"
Tile500 Name = "tile_500"
Tile1080 Name = "tile_1080"
Fit1280 Name = "fit_1280"
@ -55,12 +61,12 @@ var Names = []Name{
Colors,
}
// Find returns the largest default thumbnail type for the given size limit.
func Find(limit int) (name Name, size Size) {
// Find returns the largest thumbnail type for the given pixel size.
func Find(pixels int) (name Name, size Size) {
for _, name = range Names {
t := Sizes[name]
if t.Width <= limit && t.Height <= limit {
if t.Width <= pixels && t.Height <= pixels {
return name, t
}
}

View file

@ -41,10 +41,16 @@ var (
SizeColors = Size{Colors, Fit720, "Color Detection", 3, 3, false, false, false, true, Options{ResampleResize, ResampleNearestNeighbor, ResamplePng}}
SizeTile50 = Size{Tile50, Fit720, "List View", 50, 50, false, false, false, true, Options{ResampleFillCenter, ResampleDefault}}
SizeTile100 = Size{Tile100, Fit720, "Places View", 100, 100, false, false, false, true, Options{ResampleFillCenter, ResampleDefault}}
SizeTile224 = Size{Tile224, Fit720, "TensorFlow, Mosaic View", 224, 224, false, false, false, true, Options{ResampleFillCenter, ResampleDefault}}
SizeLeft224 = Size{Left224, Fit720, "TensorFlow", 224, 224, false, false, false, false, Options{ResampleFillTopLeft, ResampleDefault}}
SizeRight224 = Size{Right224, Fit720, "TensorFlow", 224, 224, false, false, false, false, Options{ResampleFillBottomRight, ResampleDefault}}
SizeTile224 = Size{Tile224, Fit720, "AI, Mosaic View", 224, 224, false, false, false, true, Options{ResampleFillCenter, ResampleDefault}}
SizeLeft224 = Size{Left224, Fit720, "AI", 224, 224, false, false, false, false, Options{ResampleFillTopLeft, ResampleDefault}}
SizeRight224 = Size{Right224, Fit720, "AI", 224, 224, false, false, false, false, Options{ResampleFillBottomRight, ResampleDefault}}
SizeTile384 = Size{Tile384, Fit720, "AI", 384, 384, false, false, true, false, Options{ResampleFillCenter, ResampleDefault}}
SizeLeft384 = Size{Left384, Fit720, "AI", 384, 384, false, false, true, false, Options{ResampleFillTopLeft, ResampleDefault}}
SizeRight384 = Size{Right384, Fit720, "AI", 384, 384, false, false, true, false, Options{ResampleFillBottomRight, ResampleDefault}}
SizeFit720 = Size{Fit720, "", "SD TV, Mobile", 720, 720, true, true, false, true, Options{ResampleFit, ResampleDefault}}
SizeTile480 = Size{Tile480, Fit1920, "AI", 480, 480, false, false, true, false, Options{ResampleFillCenter, ResampleDefault}}
SizeLeft480 = Size{Left480, Fit1920, "AI", 480, 480, false, false, true, false, Options{ResampleFillTopLeft, ResampleDefault}}
SizeRight480 = Size{Right480, Fit1920, "AI", 480, 480, false, false, true, false, Options{ResampleFillBottomRight, ResampleDefault}}
SizeTile500 = Size{Tile500, Fit1920, "Cards View", 500, 500, false, false, false, true, Options{ResampleFillCenter, ResampleDefault}}
SizeTile1080 = Size{Tile1080, Fit1920, "Instagram", 1080, 1080, false, false, true, false, Options{ResampleFillCenter, ResampleDefault}}
SizeFit1280 = Size{Fit1280, Fit1920, "HD TV, SXGA", 1280, 1024, true, true, false, false, Options{ResampleFit, ResampleDefault}}
@ -63,9 +69,15 @@ var Sizes = SizeMap{
Colors: SizeColors,
Tile50: SizeTile50,
Tile100: SizeTile100,
Tile224: SizeTile224,
Left224: SizeLeft224,
Right224: SizeRight224,
Tile224: SizeTile224,
Tile384: SizeTile384,
Left384: SizeLeft384,
Right384: SizeRight384,
Tile480: SizeTile480,
Left480: SizeLeft480,
Right480: SizeRight480,
Fit720: SizeFit720,
Tile500: SizeTile500,
Tile1080: SizeTile1080, // Optional
@ -79,3 +91,7 @@ var Sizes = SizeMap{
Fit5120: SizeFit5120,
Fit7680: SizeFit7680,
}
func ParseSize(s string) Size {
return Sizes[Name(s)]
}

View file

@ -7,10 +7,11 @@ import (
// File and directory permissions.
var (
ModeDir os.FileMode = 0o777
ModeSocket os.FileMode = 0o666
ModeFile os.FileMode = 0o666
ModeBackup os.FileMode = 0o600
ModeDir os.FileMode = 0o777
ModeSocket os.FileMode = 0o666
ModeFile os.FileMode = 0o666
ModeConfigFile os.FileMode = 0o664
ModeBackupFile os.FileMode = 0o600
)
// ParseMode parses and returns a filesystem permission mode,

View file

@ -4,14 +4,32 @@ import (
"errors"
"io"
"os"
"path/filepath"
"strconv"
"time"
)
// WriteFile overwrites a file with the specified bytes as content.
// If the path does not exist or the file cannot be written, an error is returned.
func WriteFile(fileName string, data []byte) error {
file, err := os.OpenFile(fileName, os.O_RDWR|os.O_CREATE|os.O_TRUNC, ModeFile)
func WriteFile(fileName string, data []byte, perm os.FileMode) error {
// Return error if no filename was provided.
if fileName == "" {
return errors.New("missing filename")
}
// Default to regular file permissions.
if perm == 0 {
perm = ModeFile
}
// Create storage directory if it does not exist yet.
if dir := filepath.Dir(fileName); dir != "" && dir != "/" && dir != "." && dir != ".." && !PathExists(dir) {
if err := MkdirAll(dir); err != nil {
return err
}
}
file, err := os.OpenFile(fileName, os.O_RDWR|os.O_CREATE|os.O_TRUNC, perm)
if err != nil {
return err
@ -29,7 +47,7 @@ func WriteFile(fileName string, data []byte) error {
// WriteString overwrites a file with the specified string as content.
// If the path does not exist or the file cannot be written, an error is returned.
func WriteString(fileName string, s string) error {
return WriteFile(fileName, []byte(s))
return WriteFile(fileName, []byte(s), ModeFile)
}
// WriteUnixTime overwrites a file with the current Unix timestamp as content.

View file

@ -30,7 +30,7 @@ func TestWriteFile(t *testing.T) {
assert.True(t, PathExists(dir))
fileErr := WriteFile(filePath, fileData)
fileErr := WriteFile(filePath, fileData, ModeFile)
assert.NoError(t, fileErr)
assert.FileExists(t, filePath)