mirror of
https://github.com/photoprism/photoprism.git
synced 2026-01-23 02:24:24 +00:00
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:
parent
b791896fc6
commit
35e9294d87
74 changed files with 1052 additions and 329 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -49,6 +49,7 @@ frontend/coverage/
|
|||
/assets/nasnet
|
||||
/assets/nsfw
|
||||
/assets/static/build/
|
||||
/assets/*net
|
||||
/pro
|
||||
/plus
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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")
|
||||
|
|
@ -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)
|
||||
|
|
@ -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" {
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
62
internal/ai/vision/labels.go
Normal file
62
internal/ai/vision/labels.go
Normal 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
|
||||
}
|
||||
31
internal/ai/vision/labels_response.go
Normal file
31
internal/ai/vision/labels_response.go
Normal 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"`
|
||||
}
|
||||
32
internal/ai/vision/labels_test.go
Normal file
32
internal/ai/vision/labels_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
71
internal/ai/vision/model.go
Normal file
71
internal/ai/vision/model.go
Normal 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
|
||||
}
|
||||
16
internal/ai/vision/models.go
Normal file
16
internal/ai/vision/models.go
Normal 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"}}
|
||||
)
|
||||
74
internal/ai/vision/options.go
Normal file
74
internal/ai/vision/options.go
Normal 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
|
||||
}
|
||||
30
internal/ai/vision/options_test.go
Normal file
30
internal/ai/vision/options_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
2
internal/ai/vision/testdata/.gitignore
vendored
Normal file
2
internal/ai/vision/testdata/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
||||
7
internal/ai/vision/thresholds.go
Normal file
7
internal/ai/vision/thresholds.go
Normal 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"`
|
||||
}
|
||||
31
internal/ai/vision/vision.go
Normal file
31
internal/ai/vision/vision.go
Normal 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
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
28
internal/api/vision_caption.go
Normal file
28
internal/api/vision_caption.go
Normal 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)
|
||||
})
|
||||
}
|
||||
28
internal/api/vision_faces.go
Normal file
28
internal/api/vision_faces.go
Normal 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)
|
||||
})
|
||||
}
|
||||
35
internal/api/vision_labels.go
Normal file
35
internal/api/vision_labels.go
Normal 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)
|
||||
})
|
||||
}
|
||||
|
|
@ -59,6 +59,7 @@ const (
|
|||
ResourceLogs Resource = "logs"
|
||||
ResourceWebDAV Resource = "webdav"
|
||||
ResourceMetrics Resource = "metrics"
|
||||
ResourceVision Resource = "vision"
|
||||
ResourceFeedback Resource = "feedback"
|
||||
ResourceDefault Resource = "default"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -67,6 +67,10 @@ var (
|
|||
AccessOwn: true,
|
||||
ActionUpdate: true,
|
||||
}
|
||||
GrantCreateAll = Grant{
|
||||
AccessAll: true,
|
||||
ActionCreate: true,
|
||||
}
|
||||
GrantViewOwn = Grant{
|
||||
AccessOwn: true,
|
||||
ActionView: true,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ var ResourceNames = []Resource{
|
|||
ResourceLogs,
|
||||
ResourceWebDAV,
|
||||
ResourceMetrics,
|
||||
ResourceVision,
|
||||
ResourceFeedback,
|
||||
ResourceDefault,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,6 +95,10 @@ var Rules = ACL{
|
|||
RoleAdmin: GrantFullAccess,
|
||||
RoleClient: GrantViewAll,
|
||||
},
|
||||
ResourceVision: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleClient: GrantCreateAll,
|
||||
},
|
||||
ResourceFeedback: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", "")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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(), "")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue