2021-08-12 04:54:20 +02:00
|
|
|
package entity
|
|
|
|
|
|
|
|
import (
|
|
|
|
"crypto/sha1"
|
2021-08-12 12:05:10 +02:00
|
|
|
"encoding/base32"
|
2021-08-15 20:57:26 +02:00
|
|
|
"encoding/json"
|
2021-08-21 16:36:00 +02:00
|
|
|
"fmt"
|
2021-09-18 15:32:39 +02:00
|
|
|
"strings"
|
2021-08-16 00:29:36 +02:00
|
|
|
"sync"
|
2021-08-12 04:54:20 +02:00
|
|
|
"time"
|
2021-08-21 16:36:00 +02:00
|
|
|
|
|
|
|
"github.com/photoprism/photoprism/internal/face"
|
2021-09-22 19:33:41 +02:00
|
|
|
"github.com/photoprism/photoprism/pkg/rnd"
|
2021-08-12 04:54:20 +02:00
|
|
|
)
|
|
|
|
|
2021-08-16 00:29:36 +02:00
|
|
|
var faceMutex = sync.Mutex{}
|
|
|
|
|
|
|
|
// Face represents the face of a Subject.
|
2021-08-13 20:31:41 +02:00
|
|
|
type Face struct {
|
2021-08-21 16:36:00 +02:00
|
|
|
ID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"ID" yaml:"ID"`
|
|
|
|
FaceSrc string `gorm:"type:VARBINARY(8);" json:"Src" yaml:"Src,omitempty"`
|
2021-09-18 20:41:30 +02:00
|
|
|
FaceHidden bool `json:"Hidden" yaml:"Hidden,omitempty"`
|
2021-09-21 12:11:51 +02:00
|
|
|
SubjUID string `gorm:"type:VARBINARY(42);index;default:'';" json:"SubjUID" yaml:"SubjUID,omitempty"`
|
2021-08-21 16:36:00 +02:00
|
|
|
Samples int `json:"Samples" yaml:"Samples,omitempty"`
|
|
|
|
SampleRadius float64 `json:"SampleRadius" yaml:"SampleRadius,omitempty"`
|
|
|
|
Collisions int `json:"Collisions" yaml:"Collisions,omitempty"`
|
|
|
|
CollisionRadius float64 `json:"CollisionRadius" yaml:"CollisionRadius,omitempty"`
|
|
|
|
EmbeddingJSON json.RawMessage `gorm:"type:MEDIUMBLOB;" json:"-" yaml:"EmbeddingJSON,omitempty"`
|
2021-09-30 13:44:23 +02:00
|
|
|
embedding face.Embedding `gorm:"-"`
|
2021-08-23 16:22:01 +02:00
|
|
|
MatchedAt *time.Time `json:"MatchedAt" yaml:"MatchedAt,omitempty"`
|
2021-08-21 16:36:00 +02:00
|
|
|
CreatedAt time.Time `json:"CreatedAt" yaml:"CreatedAt,omitempty"`
|
|
|
|
UpdatedAt time.Time `json:"UpdatedAt" yaml:"UpdatedAt,omitempty"`
|
2021-08-12 04:54:20 +02:00
|
|
|
}
|
|
|
|
|
2021-08-29 13:26:05 +02:00
|
|
|
// Faceless can be used as argument to match unmatched face markers.
|
|
|
|
var Faceless = []string{""}
|
|
|
|
|
2021-08-12 04:54:20 +02:00
|
|
|
// TableName returns the entity database table name.
|
2021-08-13 20:31:41 +02:00
|
|
|
func (Face) TableName() string {
|
2021-09-21 12:11:51 +02:00
|
|
|
return "faces"
|
2021-08-12 04:54:20 +02:00
|
|
|
}
|
|
|
|
|
2021-08-14 15:45:51 +02:00
|
|
|
// NewFace returns a new face.
|
2021-09-30 13:44:23 +02:00
|
|
|
func NewFace(subjUID, faceSrc string, embeddings face.Embeddings) *Face {
|
2021-08-13 20:31:41 +02:00
|
|
|
result := &Face{
|
2021-09-17 14:26:12 +02:00
|
|
|
SubjUID: subjUID,
|
|
|
|
FaceSrc: faceSrc,
|
2021-08-15 20:57:26 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
if err := result.SetEmbeddings(embeddings); err != nil {
|
|
|
|
log.Errorf("face: failed setting embeddings (%s)", err)
|
2021-08-12 04:54:20 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
return result
|
|
|
|
}
|
|
|
|
|
2021-10-07 09:32:17 +02:00
|
|
|
// Unsuitable tests if the face is unsuitable for clustering and matching.
|
|
|
|
func (m *Face) Unsuitable() bool {
|
|
|
|
return m.Embedding().Unsuitable()
|
|
|
|
}
|
|
|
|
|
2021-08-15 20:57:26 +02:00
|
|
|
// SetEmbeddings assigns face embeddings.
|
2021-09-30 13:44:23 +02:00
|
|
|
func (m *Face) SetEmbeddings(embeddings face.Embeddings) (err error) {
|
|
|
|
m.embedding, m.SampleRadius, m.Samples = face.EmbeddingsMidpoint(embeddings)
|
2021-09-06 03:24:11 +02:00
|
|
|
|
2021-09-06 05:25:20 +02:00
|
|
|
// Limit sample radius to reduce false positives.
|
|
|
|
if m.SampleRadius > 0.35 {
|
|
|
|
m.SampleRadius = 0.35
|
2021-09-06 03:24:11 +02:00
|
|
|
}
|
|
|
|
|
2021-08-15 20:57:26 +02:00
|
|
|
m.EmbeddingJSON, err = json.Marshal(m.embedding)
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
s := sha1.Sum(m.EmbeddingJSON)
|
|
|
|
m.ID = base32.StdEncoding.EncodeToString(s[:])
|
2021-08-29 13:26:05 +02:00
|
|
|
m.UpdatedAt = TimeStamp()
|
|
|
|
|
|
|
|
// Reset match timestamp.
|
|
|
|
m.MatchedAt = nil
|
2021-08-15 20:57:26 +02:00
|
|
|
|
|
|
|
if m.CreatedAt.IsZero() {
|
|
|
|
m.CreatedAt = m.UpdatedAt
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2021-08-29 13:26:05 +02:00
|
|
|
// Matched updates the match timestamp.
|
|
|
|
func (m *Face) Matched() error {
|
|
|
|
m.MatchedAt = TimePointer()
|
2021-08-23 16:22:01 +02:00
|
|
|
return UnscopedDb().Model(m).UpdateColumns(Values{"MatchedAt": m.MatchedAt}).Error
|
|
|
|
}
|
|
|
|
|
2021-08-15 20:57:26 +02:00
|
|
|
// Embedding returns parsed face embedding.
|
2021-09-30 13:44:23 +02:00
|
|
|
func (m *Face) Embedding() face.Embedding {
|
2021-08-15 20:57:26 +02:00
|
|
|
if len(m.EmbeddingJSON) == 0 {
|
2021-09-30 13:44:23 +02:00
|
|
|
return face.Embedding{}
|
2021-08-15 20:57:26 +02:00
|
|
|
} else if len(m.embedding) > 0 {
|
|
|
|
return m.embedding
|
|
|
|
} else if err := json.Unmarshal(m.EmbeddingJSON, &m.embedding); err != nil {
|
|
|
|
log.Errorf("failed parsing face embedding json: %s", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
return m.embedding
|
2021-08-12 04:54:20 +02:00
|
|
|
}
|
|
|
|
|
2021-08-21 16:36:00 +02:00
|
|
|
// Match tests if embeddings match this face.
|
2021-09-30 13:44:23 +02:00
|
|
|
func (m *Face) Match(embeddings face.Embeddings) (match bool, dist float64) {
|
2021-08-21 16:36:00 +02:00
|
|
|
dist = -1
|
|
|
|
|
2021-09-30 13:44:23 +02:00
|
|
|
if embeddings.Empty() {
|
2021-08-21 16:36:00 +02:00
|
|
|
// Np embeddings, no match.
|
|
|
|
return false, dist
|
|
|
|
}
|
|
|
|
|
|
|
|
faceEmbedding := m.Embedding()
|
|
|
|
|
|
|
|
if len(faceEmbedding) == 0 {
|
|
|
|
// Should never happen.
|
|
|
|
return false, dist
|
|
|
|
}
|
|
|
|
|
2021-09-30 13:44:23 +02:00
|
|
|
// Calculate the smallest distance to embeddings.
|
2021-08-21 16:36:00 +02:00
|
|
|
for _, e := range embeddings {
|
2021-09-30 13:44:23 +02:00
|
|
|
if d := e.Distance(faceEmbedding); d < dist || dist < 0 {
|
2021-08-21 16:36:00 +02:00
|
|
|
dist = d
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Any reasons embeddings do not match this face?
|
|
|
|
switch {
|
|
|
|
case dist < 0:
|
|
|
|
// Should never happen.
|
|
|
|
return false, dist
|
2021-09-23 13:16:05 +02:00
|
|
|
case dist > (m.SampleRadius + face.MatchDist):
|
2021-08-21 16:36:00 +02:00
|
|
|
// Too far.
|
|
|
|
return false, dist
|
2021-08-22 16:14:34 +02:00
|
|
|
case m.CollisionRadius > 0.1 && dist > m.CollisionRadius:
|
2021-08-21 16:36:00 +02:00
|
|
|
// Within radius of reported collisions.
|
|
|
|
return false, dist
|
|
|
|
}
|
|
|
|
|
|
|
|
// If not, at least one of the embeddings match!
|
|
|
|
return true, dist
|
|
|
|
}
|
|
|
|
|
2021-09-01 12:48:17 +02:00
|
|
|
// ResolveCollision resolves a collision with a different subject's face.
|
2021-09-30 13:44:23 +02:00
|
|
|
func (m *Face) ResolveCollision(embeddings face.Embeddings) (resolved bool, err error) {
|
2021-09-17 14:26:12 +02:00
|
|
|
if m.SubjUID == "" {
|
2021-08-21 16:36:00 +02:00
|
|
|
// Ignore reports for anonymous faces.
|
|
|
|
return false, nil
|
2021-08-26 16:00:11 +02:00
|
|
|
} else if m.ID == "" {
|
2021-08-21 16:36:00 +02:00
|
|
|
return false, fmt.Errorf("invalid face id")
|
|
|
|
} else if len(m.EmbeddingJSON) == 0 {
|
2021-08-22 16:14:34 +02:00
|
|
|
return false, fmt.Errorf("embedding must not be empty")
|
2021-08-21 16:36:00 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
if match, dist := m.Match(embeddings); !match {
|
|
|
|
// Embeddings don't match this face. Ignore.
|
|
|
|
return false, nil
|
|
|
|
} else if dist < 0 {
|
|
|
|
// Should never happen.
|
|
|
|
return false, fmt.Errorf("collision distance must be positive")
|
2021-09-01 21:16:08 +02:00
|
|
|
} else if dist < 0.02 {
|
|
|
|
// Ignore if distance is very small as faces may belong to the same person.
|
2021-10-05 18:42:39 +02:00
|
|
|
log.Warnf("face %s: clearing ambiguous subject %s, similar face at dist %f with source %s", m.ID, m.SubjUID, dist, SrcString(m.FaceSrc))
|
2021-09-01 21:16:08 +02:00
|
|
|
|
|
|
|
// Reset subject UID just in case.
|
2021-09-17 14:26:12 +02:00
|
|
|
m.SubjUID = ""
|
2021-09-01 21:16:08 +02:00
|
|
|
|
2021-09-17 14:26:12 +02:00
|
|
|
return false, m.Updates(Values{"SubjUID": m.SubjUID})
|
2021-09-01 21:16:08 +02:00
|
|
|
} else {
|
2021-08-29 13:26:05 +02:00
|
|
|
m.MatchedAt = nil
|
2021-08-21 16:36:00 +02:00
|
|
|
m.Collisions++
|
2021-09-01 12:48:17 +02:00
|
|
|
m.CollisionRadius = dist - 0.01
|
2021-08-21 16:36:00 +02:00
|
|
|
}
|
|
|
|
|
2021-08-29 13:26:05 +02:00
|
|
|
err = m.Updates(Values{"Collisions": m.Collisions, "CollisionRadius": m.CollisionRadius, "MatchedAt": m.MatchedAt})
|
2021-08-22 21:06:44 +02:00
|
|
|
|
2021-09-01 21:16:08 +02:00
|
|
|
if err != nil {
|
|
|
|
return true, err
|
|
|
|
}
|
2021-08-23 16:22:01 +02:00
|
|
|
|
2021-09-01 21:16:08 +02:00
|
|
|
if revised, err := m.ReviseMatches(); err != nil {
|
|
|
|
return true, err
|
|
|
|
} else if r := len(revised); r > 0 {
|
2021-10-05 18:42:39 +02:00
|
|
|
log.Infof("faces: revised %d matches after conflict", r)
|
2021-08-22 21:06:44 +02:00
|
|
|
}
|
|
|
|
|
2021-09-01 21:16:08 +02:00
|
|
|
return true, nil
|
2021-08-22 21:06:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// ReviseMatches updates marker matches after face parameters have been changed.
|
|
|
|
func (m *Face) ReviseMatches() (revised Markers, err error) {
|
2021-09-01 12:48:17 +02:00
|
|
|
if m.ID == "" {
|
|
|
|
return revised, fmt.Errorf("empty face id")
|
|
|
|
}
|
|
|
|
|
2021-08-22 21:06:44 +02:00
|
|
|
var matches Markers
|
|
|
|
|
|
|
|
if err := Db().Where("face_id = ?", m.ID).Where("marker_type = ?", MarkerFace).
|
|
|
|
Find(&matches).Error; err != nil {
|
2021-09-01 12:48:17 +02:00
|
|
|
log.Debugf("faces: %s (revise matches)", err)
|
2021-08-22 21:06:44 +02:00
|
|
|
return revised, err
|
|
|
|
} else {
|
|
|
|
for _, marker := range matches {
|
|
|
|
if ok, _ := m.Match(marker.Embeddings()); !ok {
|
|
|
|
if updated, err := marker.ClearFace(); err != nil {
|
2021-09-01 12:48:17 +02:00
|
|
|
log.Debugf("faces: %s (revise matches)", err)
|
2021-08-22 21:06:44 +02:00
|
|
|
return revised, err
|
|
|
|
} else if updated {
|
|
|
|
revised = append(revised, marker)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return revised, nil
|
2021-08-21 16:36:00 +02:00
|
|
|
}
|
|
|
|
|
2021-08-23 16:22:01 +02:00
|
|
|
// MatchMarkers finds and references matching markers.
|
2021-08-29 13:26:05 +02:00
|
|
|
func (m *Face) MatchMarkers(faceIds []string) error {
|
2021-08-23 16:22:01 +02:00
|
|
|
var markers Markers
|
|
|
|
|
|
|
|
err := Db().
|
2021-08-29 13:26:05 +02:00
|
|
|
Where("marker_invalid = 0 AND marker_type = ? AND face_id IN (?)", MarkerFace, faceIds).
|
2021-08-23 16:22:01 +02:00
|
|
|
Find(&markers).Error
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
log.Debugf("faces: %s (match markers)", err)
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, marker := range markers {
|
2021-08-29 13:26:05 +02:00
|
|
|
if ok, dist := m.Match(marker.Embeddings()); !ok {
|
2021-08-23 16:22:01 +02:00
|
|
|
// Ignore.
|
2021-08-29 13:26:05 +02:00
|
|
|
} else if _, err = marker.SetFace(m, dist); err != nil {
|
2021-08-23 16:22:01 +02:00
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2021-09-01 12:48:17 +02:00
|
|
|
// SetSubjectUID updates the face's subject uid and related markers.
|
2021-09-17 14:26:12 +02:00
|
|
|
func (m *Face) SetSubjectUID(subjUID string) (err error) {
|
2021-09-01 12:48:17 +02:00
|
|
|
// Update face.
|
2021-09-17 14:26:12 +02:00
|
|
|
if err = m.Update("SubjUID", subjUID); err != nil {
|
2021-09-01 12:48:17 +02:00
|
|
|
return err
|
|
|
|
} else {
|
2021-09-17 14:26:12 +02:00
|
|
|
m.SubjUID = subjUID
|
2021-09-01 12:48:17 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// Update related markers.
|
|
|
|
if err = Db().Model(&Marker{}).
|
|
|
|
Where("face_id = ?", m.ID).
|
2021-09-17 14:26:12 +02:00
|
|
|
Where("subj_src = ?", SrcAuto).
|
|
|
|
Where("subj_uid <> ?", m.SubjUID).
|
2021-09-18 15:32:39 +02:00
|
|
|
Where("marker_invalid = 0").
|
2021-09-24 22:46:03 +02:00
|
|
|
UpdateColumns(Values{"subj_uid": m.SubjUID, "marker_review": false}).Error; err != nil {
|
2021-09-01 12:48:17 +02:00
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2021-09-06 00:52:10 +02:00
|
|
|
return m.RefreshPhotos()
|
|
|
|
}
|
|
|
|
|
|
|
|
// RefreshPhotos flags related photos for metadata maintenance.
|
2021-09-06 01:16:36 +02:00
|
|
|
func (m *Face) RefreshPhotos() error {
|
2021-09-06 00:52:10 +02:00
|
|
|
if m.ID == "" {
|
|
|
|
return fmt.Errorf("empty face id")
|
|
|
|
}
|
|
|
|
|
2021-09-24 22:46:03 +02:00
|
|
|
update := fmt.Sprintf(
|
|
|
|
"UPDATE photos SET checked_at = NULL WHERE id IN (SELECT f.photo_id FROM files f JOIN %s m ON m.file_uid = f.file_uid WHERE m.face_id = ?)",
|
|
|
|
Marker{}.TableName())
|
|
|
|
|
|
|
|
return UnscopedDb().Exec(update, m.ID).Error
|
2021-09-01 12:48:17 +02:00
|
|
|
}
|
|
|
|
|
2021-09-18 20:41:30 +02:00
|
|
|
// Hide hides the face by default.
|
|
|
|
func (m *Face) Hide() (err error) {
|
|
|
|
return m.Update("FaceHidden", true)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Show shows the face by default.
|
|
|
|
func (m *Face) Show() (err error) {
|
|
|
|
return m.Update("FaceHidden", false)
|
|
|
|
}
|
|
|
|
|
2021-08-12 04:54:20 +02:00
|
|
|
// Save updates the existing or inserts a new face.
|
2021-08-13 20:31:41 +02:00
|
|
|
func (m *Face) Save() error {
|
2021-08-16 00:29:36 +02:00
|
|
|
faceMutex.Lock()
|
|
|
|
defer faceMutex.Unlock()
|
2021-08-12 04:54:20 +02:00
|
|
|
|
2021-08-12 12:05:10 +02:00
|
|
|
return Save(m, "ID")
|
2021-08-12 04:54:20 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// Create inserts the face to the database.
|
2021-08-13 20:31:41 +02:00
|
|
|
func (m *Face) Create() error {
|
2021-08-16 00:29:36 +02:00
|
|
|
faceMutex.Lock()
|
|
|
|
defer faceMutex.Unlock()
|
2021-08-12 04:54:20 +02:00
|
|
|
|
|
|
|
return Db().Create(m).Error
|
|
|
|
}
|
|
|
|
|
|
|
|
// Delete removes the face from the database.
|
2021-08-13 20:31:41 +02:00
|
|
|
func (m *Face) Delete() error {
|
2021-09-29 20:09:34 +02:00
|
|
|
// Remove face id from markers before deleting.
|
|
|
|
if err := Db().Model(&Marker{}).
|
|
|
|
Where("face_id = ?", m.ID).
|
|
|
|
UpdateColumns(Values{"face_id": "", "face_dist": -1}).Error; err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2021-08-12 04:54:20 +02:00
|
|
|
return Db().Delete(m).Error
|
|
|
|
}
|
|
|
|
|
|
|
|
// Update a face property in the database.
|
2021-08-13 20:31:41 +02:00
|
|
|
func (m *Face) Update(attr string, value interface{}) error {
|
2021-08-12 12:05:10 +02:00
|
|
|
return UnscopedDb().Model(m).Update(attr, value).Error
|
|
|
|
}
|
|
|
|
|
|
|
|
// Updates face properties in the database.
|
2021-08-13 20:31:41 +02:00
|
|
|
func (m *Face) Updates(values interface{}) error {
|
2021-08-12 12:05:10 +02:00
|
|
|
return UnscopedDb().Model(m).Updates(values).Error
|
2021-08-12 04:54:20 +02:00
|
|
|
}
|
2021-08-19 23:12:51 +02:00
|
|
|
|
|
|
|
// FirstOrCreateFace returns the existing entity, inserts a new entity or nil in case of errors.
|
|
|
|
func FirstOrCreateFace(m *Face) *Face {
|
|
|
|
result := Face{}
|
|
|
|
|
|
|
|
if err := UnscopedDb().Where("id = ?", m.ID).First(&result).Error; err == nil {
|
2021-09-17 14:26:12 +02:00
|
|
|
log.Warnf("faces: %s has ambiguous subject %s", m.ID, m.SubjUID)
|
2021-08-19 23:12:51 +02:00
|
|
|
return &result
|
|
|
|
} else if createErr := m.Create(); createErr == nil {
|
|
|
|
return m
|
|
|
|
} else if err := UnscopedDb().Where("id = ?", m.ID).First(&result).Error; err == nil {
|
2021-09-17 14:26:12 +02:00
|
|
|
log.Warnf("faces: %s has ambiguous subject %s", m.ID, m.SubjUID)
|
2021-08-19 23:12:51 +02:00
|
|
|
return &result
|
|
|
|
} else {
|
2021-08-31 15:33:42 +02:00
|
|
|
log.Errorf("faces: %s when trying to create %s", createErr, m.ID)
|
2021-08-19 23:12:51 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// FindFace returns an existing entity if exists.
|
|
|
|
func FindFace(id string) *Face {
|
|
|
|
if id == "" {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2021-09-18 15:32:39 +02:00
|
|
|
f := Face{}
|
2021-08-19 23:12:51 +02:00
|
|
|
|
2021-09-18 15:32:39 +02:00
|
|
|
if err := Db().Where("id = ?", strings.ToUpper(id)).First(&f).Error; err != nil {
|
2021-08-19 23:12:51 +02:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2021-09-18 15:32:39 +02:00
|
|
|
return &f
|
2021-08-19 23:12:51 +02:00
|
|
|
}
|
2021-09-22 19:33:41 +02:00
|
|
|
|
2021-09-23 23:46:17 +02:00
|
|
|
// ValidFaceCount counts the number of valid face markers for a file uid.
|
|
|
|
func ValidFaceCount(fileUID string) (c int) {
|
2021-09-22 19:33:41 +02:00
|
|
|
if !rnd.IsPPID(fileUID, 'f') {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := Db().Model(Marker{}).
|
|
|
|
Where("file_uid = ? AND marker_type = ?", fileUID, MarkerFace).
|
|
|
|
Where("marker_invalid = 0").
|
|
|
|
Count(&c).Error; err != nil {
|
|
|
|
log.Errorf("file: %s (count faces)", err)
|
|
|
|
return 0
|
|
|
|
} else {
|
|
|
|
return c
|
|
|
|
}
|
|
|
|
}
|