1
Fork 0

Merge pull request #197 from photoview/face-detection

Implement Face Detection
This commit is contained in:
Viktor Strate Kløvedal 2021-02-22 20:01:43 +01:00 committed by GitHub
commit a156af40e2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 3693 additions and 123 deletions

View File

@ -16,7 +16,7 @@ on:
jobs:
build:
name: Build and deploy docker images
runs-on: ubuntu-latest
runs-on: ubuntu-20.04
steps:
- name: Checkout
uses: actions/checkout@v2

View File

@ -13,7 +13,7 @@ jobs:
analyze:
name: Analyze
if: github.repository == 'photoview/photoview'
runs-on: ubuntu-latest
runs-on: ubuntu-20.04
strategy:
fail-fast: false

View File

@ -9,7 +9,7 @@ on:
jobs:
test-api:
name: Test API
runs-on: ubuntu-latest
runs-on: ubuntu-20.04
defaults:
run:
@ -25,7 +25,12 @@ jobs:
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- name: Get dependencies
- name: Get C dependencies
run: |
sudo apt-get update
sudo apt-get install -y libdlib-dev libblas-dev liblapack-dev libjpeg-turbo8-dev
- name: Get GO dependencies
run: |
go get -v -t -d ./...
if [ -f Gopkg.toml ]; then

View File

@ -22,11 +22,25 @@ RUN npm run build -- --public-url $UI_PUBLIC_URL
### Build API ###
FROM --platform=${BUILDPLATFORM:-linux/amd64} golang:1.15-buster AS api
# Install GCC cross compilers
# Install G++/GCC cross compilers
RUN dpkg --add-architecture arm64
RUN dpkg --add-architecture armhf
RUN apt-get update
RUN apt-get install -y gcc-aarch64-linux-gnu libc6-dev-arm64-cross gcc-arm-linux-gnueabihf libc6-dev-armhf-cross
RUN apt-get install -y g++-aarch64-linux-gnu libc6-dev-arm64-cross g++-arm-linux-gnueabihf libc6-dev-armhf-cross
COPY --from=tonistiigi/xx:golang / /
# Install go-face dependencies
RUN apt-get install -y \
libdlib-dev libdlib-dev:arm64 libdlib-dev:armhf \
libblas-dev libblas-dev:arm64 libblas-dev:armhf \
liblapack-dev liblapack-dev:arm64 liblapack-dev:armhf \
libjpeg62-turbo-dev libjpeg62-turbo-dev:arm64 libjpeg62-turbo-dev:armhf
RUN echo $PATH
COPY docker/go_wrapper.sh /go/bin/go
RUN chmod +x /go/bin/go
ENV CGO_ENABLED 1
ARG TARGETPLATFORM
RUN go env
@ -38,8 +52,11 @@ WORKDIR /app
COPY api/go.mod api/go.sum /app/
RUN go mod download
# Build go-face
RUN sed -i 's/-march=native//g' /go/pkg/mod/github.com/!kagami/go-face*/face.go
RUN go install github.com/Kagami/go-face
# Build go-sqlite3 dependency with CGO
ENV CGO_ENABLED 1
RUN go install github.com/mattn/go-sqlite3
# Copy api source

View File

@ -35,6 +35,7 @@ Password: **demo**
- **Sharing**. Albums, as well as individual media, can easily be shared with a public link, the link can optinally be password protected.
- **Made for photography**. Photoview is built with photographers in mind, and thus supports **RAW** file formats, and **EXIF** parsing.
- **Video support**. Many common video formats are supported. Videos will automatically be optimized for web.
- **Face recognition**. Faces will automatically be detected in photos, and photos of the same person will be grouped together.
- **Performant**. Thumbnails are automatically generated and photos first load when they are visible on the screen. In full screen, thumbnails are displayed until the high resolution image has been fully loaded.
- **Secure**. All media resources are protected with a cookie-token, all passwords are properly hashed, and the API uses a strict [CORS policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS).
@ -107,6 +108,20 @@ The photos will have to be scanned before they show up, you can start a scan man
### Start API server
Make sure [golang](https://golang.org/) is installed.
Some C libraries are needed to compile the API, see [go-face requirements](https://github.com/Kagami/go-face#requirements) for more details.
They can be installed as shown below:
```sh
# Ubuntu
sudo apt-get install libdlib-dev libblas-dev liblapack-dev libjpeg-turbo8-dev
# Debian
sudo apt-get install libdlib-dev libblas-dev liblapack-dev libjpeg62-turbo-dev
# macOS
brew install dlib
```
Then run the following commands:
```bash

Binary file not shown.

Binary file not shown.

View File

@ -164,6 +164,10 @@ func MigrateDatabase(db *gorm.DB) error {
&models.VideoMetadata{},
&models.ShareToken{},
&models.UserMediaData{},
// Face detection
&models.FaceGroup{},
&models.ImageFace{},
)
// v2.1.0 - Replaced by Media.CreatedAt

View File

@ -4,6 +4,7 @@ go 1.13
require (
github.com/99designs/gqlgen v0.13.0
github.com/Kagami/go-face v0.0.0-20200825065730-3dd2d74dccfb
github.com/agnivade/levenshtein v1.1.0 // indirect
github.com/disintegration/imaging v1.6.2
github.com/gorilla/handlers v1.5.1

View File

@ -1,6 +1,8 @@
github.com/99designs/gqlgen v0.13.0 h1:haLTcUp3Vwp80xMVEg5KRNwzfUrgFdRmtBY8fuB8scA=
github.com/99designs/gqlgen v0.13.0/go.mod h1:NV130r6f4tpRWuAI+zsrSdooO/eWUv+Gyyoi3rEfXIk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Kagami/go-face v0.0.0-20200825065730-3dd2d74dccfb h1:DXwA1Te9paM+nsdTGc7uve37lq7WEbQO+gwGBPVwQuQ=
github.com/Kagami/go-face v0.0.0-20200825065730-3dd2d74dccfb/go.mod h1:9wdDJkRgo3SGTcFwbQ7elVIQhIr2bbBjecuY7VoqmPU=
github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM=
github.com/agnivade/levenshtein v1.0.3/go.mod h1:4SFRZbbXWLF4MU1T9Qg0pGgH3Pjs+t6ie5efyrwRJXs=
github.com/agnivade/levenshtein v1.1.0 h1:n6qGwyHG61v3ABce1rPVZklEYRT8NFpCMrpZdBUbYGM=

View File

@ -32,6 +32,8 @@ models:
fields:
exif:
resolver: true
faces:
resolver: true
MediaURL:
model: github.com/photoview/photoview/api/graphql/models.MediaURL
MediaEXIF:
@ -42,5 +44,14 @@ models:
model: github.com/photoview/photoview/api/graphql/models.Album
ShareToken:
model: github.com/photoview/photoview/api/graphql/models.ShareToken
FaceGroup:
model: github.com/photoview/photoview/api/graphql/models.FaceGroup
ImageFace:
model: github.com/photoview/photoview/api/graphql/models.ImageFace
fields:
faceGroup:
resolver: true
FaceRectangle:
model: github.com/photoview/photoview/api/graphql/models.FaceRectangle
SiteInfo:
model: github.com/photoview/photoview/api/graphql/models.SiteInfo

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,120 @@
package models
import (
"bytes"
"database/sql/driver"
"encoding/binary"
"fmt"
"image"
"strconv"
"strings"
"github.com/Kagami/go-face"
"github.com/photoview/photoview/api/scanner/image_helpers"
)
type FaceGroup struct {
Model
Label *string
ImageFaces []ImageFace `gorm:"constraint:OnDelete:CASCADE;"`
}
type ImageFace struct {
Model
FaceGroupID int `gorm:"not null;index"`
FaceGroup *FaceGroup
MediaID int `gorm:"not null;index"`
Media Media `gorm:"constraint:OnDelete:CASCADE;"`
Descriptor FaceDescriptor `gorm:"not null"`
Rectangle FaceRectangle `gorm:"not null"`
}
type FaceDescriptor face.Descriptor
// GormDataType datatype used in database
func (fd FaceDescriptor) GormDataType() string {
return "BLOB"
}
// Scan tells GORM how to convert database data to Go format
func (fd *FaceDescriptor) Scan(value interface{}) error {
byteValue := value.([]byte)
reader := bytes.NewReader(byteValue)
binary.Read(reader, binary.LittleEndian, fd)
return nil
}
// Value tells GORM how to save into the database
func (fd FaceDescriptor) Value() (driver.Value, error) {
buf := new(bytes.Buffer)
if err := binary.Write(buf, binary.LittleEndian, fd); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
type FaceRectangle struct {
MinX, MaxX float64
MinY, MaxY float64
}
// ToDBFaceRectangle converts a pixel absolute rectangle to a relative FaceRectangle to be saved in the database
func ToDBFaceRectangle(imgRec image.Rectangle, imagePath string) (*FaceRectangle, error) {
size, err := image_helpers.GetPhotoDimensions(imagePath)
if err != nil {
return nil, err
}
return &FaceRectangle{
MinX: float64(imgRec.Min.X) / float64(size.Width),
MaxX: float64(imgRec.Max.X) / float64(size.Width),
MinY: float64(imgRec.Min.Y) / float64(size.Height),
MaxY: float64(imgRec.Max.Y) / float64(size.Height),
}, nil
}
// GormDataType datatype used in database
func (fr FaceRectangle) GormDataType() string {
return "VARCHAR(64)"
}
// Scan tells GORM how to convert database data to Go format
func (fr *FaceRectangle) Scan(value interface{}) error {
byteArray := value.([]uint8)
slices := strings.Split(string(byteArray), ":")
if len(slices) != 4 {
return fmt.Errorf("Invalid face rectangle format, expected 4 values, got %d", len(slices))
}
var err error
fr.MinX, err = strconv.ParseFloat(slices[0], 32)
if err != nil {
return err
}
fr.MaxX, err = strconv.ParseFloat(slices[1], 32)
if err != nil {
return err
}
fr.MinY, err = strconv.ParseFloat(slices[2], 32)
if err != nil {
return err
}
fr.MaxY, err = strconv.ParseFloat(slices[3], 32)
if err != nil {
return err
}
return nil
}
// Value tells GORM how to save into the database
func (fr FaceRectangle) Value() (driver.Value, error) {
result := fmt.Sprintf("%f:%f:%f:%f", fr.MinX, fr.MaxX, fr.MinY, fr.MaxY)
return result, nil
}

View File

@ -1,11 +1,14 @@
package models
import (
"fmt"
"path"
"strconv"
"strings"
"time"
"github.com/photoview/photoview/api/utils"
"github.com/pkg/errors"
"gorm.io/gorm"
)
@ -25,6 +28,7 @@ type Media struct {
VideoMetadata *VideoMetadata `gorm:"constraint:OnDelete:CASCADE;"`
SideCarPath *string
SideCarHash *string `gorm:"unique"`
Faces []*ImageFace `gorm:"constraint:OnDelete:CASCADE;"`
// Only used internally
CounterpartPath *string `gorm:"-"`
@ -59,7 +63,7 @@ const (
type MediaURL struct {
Model
MediaID int `gorm:"not null;index"`
Media Media `gorm:"constraint:OnDelete:CASCADE;"`
Media *Media `gorm:"constraint:OnDelete:CASCADE;"`
MediaName string `gorm:"not null"`
Width int `gorm:"not null"`
Height int `gorm:"not null"`
@ -80,6 +84,24 @@ func (p *MediaURL) URL() string {
return imageUrl.String()
}
func (p *MediaURL) CachedPath() (string, error) {
var cachedPath string
if p.Media == nil {
return "", errors.New("mediaURL.Media is nil")
}
if p.Purpose == PhotoThumbnail || p.Purpose == PhotoHighRes || p.Purpose == VideoThumbnail {
cachedPath = path.Join(utils.MediaCachePath(), strconv.Itoa(int(p.Media.AlbumID)), strconv.Itoa(int(p.MediaID)), p.MediaName)
} else if p.Purpose == MediaOriginal {
cachedPath = p.Media.Path
} else {
return "", errors.New(fmt.Sprintf("cannot determine cache path for purpose (%s)", p.Purpose))
}
return cachedPath, nil
}
func SanitizeMediaName(mediaName string) string {
result := mediaName
result = strings.ReplaceAll(result, "/", "")

View File

@ -0,0 +1,347 @@
package resolvers
import (
"context"
api "github.com/photoview/photoview/api/graphql"
"github.com/photoview/photoview/api/graphql/auth"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner/face_detection"
"github.com/pkg/errors"
"gorm.io/gorm"
)
type imageFaceResolver struct {
*Resolver
}
func (r *Resolver) ImageFace() api.ImageFaceResolver {
return imageFaceResolver{r}
}
func (r imageFaceResolver) FaceGroup(ctx context.Context, obj *models.ImageFace) (*models.FaceGroup, error) {
if obj.FaceGroup != nil {
return obj.FaceGroup, nil
}
var faceGroup models.FaceGroup
if err := r.Database.Model(&obj).Association("FaceGroup").Find(&faceGroup); err != nil {
return nil, err
}
obj.FaceGroup = &faceGroup
return &faceGroup, nil
}
func (r *queryResolver) MyFaceGroups(ctx context.Context, paginate *models.Pagination) ([]*models.FaceGroup, error) {
user := auth.UserFromContext(ctx)
if user == nil {
return nil, errors.New("unauthorized")
}
if err := user.FillAlbums(r.Database); err != nil {
return nil, err
}
userAlbumIDs := make([]int, len(user.Albums))
for i, album := range user.Albums {
userAlbumIDs[i] = album.ID
}
imageFaceQuery := r.Database.
Joins("Media").
Where("media.album_id IN (?)", userAlbumIDs)
var imageFaces []*models.ImageFace
if err := imageFaceQuery.Find(&imageFaces).Error; err != nil {
return nil, err
}
faceGroupMap := make(map[int][]models.ImageFace)
for _, face := range imageFaces {
_, found := faceGroupMap[face.FaceGroupID]
if found {
faceGroupMap[face.FaceGroupID] = append(faceGroupMap[face.FaceGroupID], *face)
} else {
faceGroupMap[face.FaceGroupID] = make([]models.ImageFace, 1)
faceGroupMap[face.FaceGroupID][0] = *face
}
}
faceGroupIDs := make([]int, len(faceGroupMap))
i := 0
for groupID := range faceGroupMap {
faceGroupIDs[i] = groupID
i++
}
faceGroupQuery := r.Database.
Joins("LEFT JOIN image_faces ON image_faces.id = face_groups.id").
Where("face_groups.id IN (?)", faceGroupIDs).
Order("CASE WHEN label IS NULL THEN 1 ELSE 0 END")
var faceGroups []*models.FaceGroup
if err := faceGroupQuery.Find(&faceGroups).Error; err != nil {
return nil, err
}
for _, faceGroup := range faceGroups {
faceGroup.ImageFaces = faceGroupMap[faceGroup.ID]
}
return faceGroups, nil
}
func (r *mutationResolver) SetFaceGroupLabel(ctx context.Context, faceGroupID int, label *string) (*models.FaceGroup, error) {
user := auth.UserFromContext(ctx)
if user == nil {
return nil, errors.New("unauthorized")
}
faceGroup, err := userOwnedFaceGroup(r.Database, user, faceGroupID)
if err != nil {
return nil, err
}
if err := r.Database.Model(faceGroup).Update("label", label).Error; err != nil {
return nil, err
}
return faceGroup, nil
}
func (r *mutationResolver) CombineFaceGroups(ctx context.Context, destinationFaceGroupID int, sourceFaceGroupID int) (*models.FaceGroup, error) {
user := auth.UserFromContext(ctx)
if user == nil {
return nil, errors.New("unauthorized")
}
destinationFaceGroup, err := userOwnedFaceGroup(r.Database, user, destinationFaceGroupID)
if err != nil {
return nil, err
}
sourceFaceGroup, err := userOwnedFaceGroup(r.Database, user, sourceFaceGroupID)
if err != nil {
return nil, err
}
updateError := r.Database.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&models.ImageFace{}).Where("face_group_id = ?", sourceFaceGroup.ID).Update("face_group_id", destinationFaceGroup.ID).Error; err != nil {
return err
}
if err := tx.Delete(&sourceFaceGroup).Error; err != nil {
return err
}
return nil
})
if updateError != nil {
return nil, updateError
}
face_detection.GlobalFaceDetector.MergeCategories(int32(sourceFaceGroupID), int32(destinationFaceGroupID))
return destinationFaceGroup, nil
}
func (r *mutationResolver) MoveImageFaces(ctx context.Context, imageFaceIDs []int, destinationFaceGroupID int) (*models.FaceGroup, error) {
user := auth.UserFromContext(ctx)
if user == nil {
return nil, errors.New("unauthorized")
}
userOwnedImageFaceIDs := make([]int, 0)
var destFaceGroup *models.FaceGroup
transErr := r.Database.Transaction(func(tx *gorm.DB) error {
var err error
destFaceGroup, err = userOwnedFaceGroup(tx, user, destinationFaceGroupID)
if err != nil {
return err
}
userOwnedImageFaces, err := getUserOwnedImageFaces(tx, user, imageFaceIDs)
if err != nil {
return err
}
for _, imageFace := range userOwnedImageFaces {
userOwnedImageFaceIDs = append(userOwnedImageFaceIDs, imageFace.ID)
}
var sourceFaceGroups []*models.FaceGroup
if err := tx.
Joins("LEFT JOIN image_faces ON image_faces.face_group_id = face_groups.id").
Where("image_faces.id IN (?)", userOwnedImageFaceIDs).
Find(&sourceFaceGroups).Error; err != nil {
return err
}
if err := tx.
Model(&models.ImageFace{}).
Where("id IN (?)", userOwnedImageFaceIDs).
Update("face_group_id", destFaceGroup.ID).Error; err != nil {
return err
}
// delete face groups if they have become empty
for _, faceGroup := range sourceFaceGroups {
var count int64
if err := tx.Model(&models.ImageFace{}).Where("face_group_id = ?", faceGroup.ID).Count(&count).Error; err != nil {
return err
}
if count == 0 {
if err := tx.Delete(&faceGroup).Error; err != nil {
return err
}
}
}
return nil
})
if transErr != nil {
return nil, transErr
}
face_detection.GlobalFaceDetector.MergeImageFaces(userOwnedImageFaceIDs, int32(destFaceGroup.ID))
return destFaceGroup, nil
}
func (r *mutationResolver) RecognizeUnlabeledFaces(ctx context.Context) ([]*models.ImageFace, error) {
user := auth.UserFromContext(ctx)
if user == nil {
return nil, errors.New("unauthorized")
}
var updatedImageFaces []*models.ImageFace
transactionError := r.Database.Transaction(func(tx *gorm.DB) error {
var err error
updatedImageFaces, err = face_detection.GlobalFaceDetector.RecognizeUnlabeledFaces(tx, user)
return err
})
if transactionError != nil {
return nil, transactionError
}
return updatedImageFaces, nil
}
func (r *mutationResolver) DetachImageFaces(ctx context.Context, imageFaceIDs []int) (*models.FaceGroup, error) {
user := auth.UserFromContext(ctx)
if user == nil {
return nil, errors.New("unauthorized")
}
userOwnedImageFaceIDs := make([]int, 0)
newFaceGroup := models.FaceGroup{}
transactionError := r.Database.Transaction(func(tx *gorm.DB) error {
userOwnedImageFaces, err := getUserOwnedImageFaces(tx, user, imageFaceIDs)
if err != nil {
return err
}
for _, imageFace := range userOwnedImageFaces {
userOwnedImageFaceIDs = append(userOwnedImageFaceIDs, imageFace.ID)
}
if err := tx.Save(&newFaceGroup).Error; err != nil {
return err
}
if err := tx.
Model(&models.ImageFace{}).
Where("id IN (?)", userOwnedImageFaceIDs).
Update("face_group_id", newFaceGroup.ID).Error; err != nil {
return err
}
return nil
})
if transactionError != nil {
return nil, transactionError
}
face_detection.GlobalFaceDetector.MergeImageFaces(userOwnedImageFaceIDs, int32(newFaceGroup.ID))
return &newFaceGroup, nil
}
func userOwnedFaceGroup(db *gorm.DB, user *models.User, faceGroupID int) (*models.FaceGroup, error) {
if user.Admin {
var faceGroup models.FaceGroup
if err := db.Where("id = ?", faceGroupID).Find(&faceGroup).Error; err != nil {
return nil, err
}
return &faceGroup, nil
}
if err := user.FillAlbums(db); err != nil {
return nil, err
}
userAlbumIDs := make([]int, len(user.Albums))
for i, album := range user.Albums {
userAlbumIDs[i] = album.ID
}
// Verify that user owns at leat one of the images in the face group
imageFaceQuery := db.
Select("image_faces.id").
Table("image_faces").
Joins("LEFT JOIN media ON media.id = image_faces.media_id").
Where("media.album_id IN (?)", userAlbumIDs)
faceGroupQuery := db.
Model(&models.FaceGroup{}).
Joins("JOIN image_faces ON face_groups.id = image_faces.face_group_id").
Where("face_groups.id = ?", faceGroupID).
Where("image_faces.id IN (?)", imageFaceQuery)
var faceGroup models.FaceGroup
if err := faceGroupQuery.Find(&faceGroup).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.Wrap(err, "face group does not exist or is not owned by the user")
}
return nil, err
}
return &faceGroup, nil
}
func getUserOwnedImageFaces(tx *gorm.DB, user *models.User, imageFaceIDs []int) ([]*models.ImageFace, error) {
if err := user.FillAlbums(tx); err != nil {
return nil, err
}
userAlbumIDs := make([]int, len(user.Albums))
for i, album := range user.Albums {
userAlbumIDs[i] = album.ID
}
var userOwnedImageFaces []*models.ImageFace
if err := tx.
Joins("JOIN media ON media.id = image_faces.media_id").
Where("media.album_id IN (?)", userAlbumIDs).
Where("image_faces.id IN (?)", imageFaceIDs).
Find(&userOwnedImageFaces).Error; err != nil {
return nil, err
}
return userOwnedImageFaces, nil
}

View File

@ -208,3 +208,16 @@ func (r *mutationResolver) FavoriteMedia(ctx context.Context, mediaID int, favor
return &media, nil
}
func (r *mediaResolver) Faces(ctx context.Context, media *models.Media) ([]*models.ImageFace, error) {
if media.Faces != nil {
return media.Faces, nil
}
var faces []*models.ImageFace
if err := r.Database.Model(&media).Association("Faces").Find(&faces); err != nil {
return nil, err
}
return faces, nil
}

View File

@ -12,6 +12,7 @@ import (
"github.com/photoview/photoview/api/graphql/auth"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner"
"github.com/photoview/photoview/api/utils"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
@ -252,7 +253,7 @@ func (r *mutationResolver) DeleteUser(ctx context.Context, id int) (*models.User
// If there is only one associated user, clean up the cache folder and delete the album row
for _, deletedAlbumID := range deletedAlbumIDs {
cachePath := path.Join(scanner.MediaCachePath(), strconv.Itoa(int(deletedAlbumID)))
cachePath := path.Join(utils.MediaCachePath(), strconv.Itoa(int(deletedAlbumID)))
if err := os.RemoveAll(cachePath); err != nil {
return &user, err
}
@ -370,7 +371,7 @@ func (r *mutationResolver) UserRemoveRootAlbum(ctx context.Context, userID int,
if deletedAlbumIDs != nil {
// Delete albums from cache
for _, id := range deletedAlbumIDs {
cacheAlbumPath := path.Join(scanner.MediaCachePath(), strconv.Itoa(id))
cacheAlbumPath := path.Join(utils.MediaCachePath(), strconv.Itoa(id))
if err := os.RemoveAll(cacheAlbumPath); err != nil {
return nil, err

View File

@ -59,6 +59,8 @@ type Query {
shareTokenValidatePassword(token: String!, password: String): Boolean!
search(query: String!, limitMedia: Int, limitAlbums: Int): SearchResult!
myFaceGroups(paginate: Pagination): [FaceGroup!]!
}
type Mutation {
@ -113,6 +115,17 @@ type Mutation {
"Set max number of concurrent scanner jobs running at once"
setScannerConcurrentWorkers(workers: Int!): Int!
"Assign a label to a face group, set label to null to remove the current one"
setFaceGroupLabel(faceGroupID: ID!, label: String): FaceGroup!
"Merge two face groups into a single one, all ImageFaces from source will be moved to destination"
combineFaceGroups(destinationFaceGroupID: ID!, sourceFaceGroupID: ID!): FaceGroup!
"Move a list of ImageFaces to another face group"
moveImageFaces(imageFaceIDs: [ID!]!, destinationFaceGroupID: ID!): FaceGroup!
"Check all unlabeled faces to see if they match a labeled FaceGroup, and move them if they match"
recognizeUnlabeledFaces: [ImageFace!]!
"Move a list of ImageFaces to a new face group"
detachImageFaces(imageFaceIDs: [ID!]!): FaceGroup!
}
type Subscription {
@ -262,6 +275,8 @@ type Media {
shares: [ShareToken!]!
downloads: [MediaDownload!]!
faces: [ImageFace!]!
}
"EXIF metadata from the camera"
@ -314,3 +329,23 @@ type TimelineGroup {
mediaTotal: Int!
date: Time!
}
type FaceGroup {
id: ID!
label: String
imageFaces: [ImageFace!]!
}
type ImageFace {
id: ID!
media: Media!
rectangle: FaceRectangle
faceGroup: FaceGroup!
}
type FaceRectangle {
minX: Float!
maxX: Float!
minY: Float!
maxY: Float!
}

View File

@ -4,8 +4,6 @@ import (
"log"
"net/http"
"os"
"path"
"strconv"
"github.com/gorilla/mux"
"gorm.io/gorm"
@ -27,7 +25,7 @@ func RegisterPhotoRoutes(db *gorm.DB, router *mux.Router) {
return
}
media := &mediaURL.Media
media := mediaURL.Media
if success, response, status, err := authenticateMedia(media, db, r); !success {
if err != nil {
@ -38,14 +36,9 @@ func RegisterPhotoRoutes(db *gorm.DB, router *mux.Router) {
return
}
var cachedPath string
if mediaURL.Purpose == models.PhotoThumbnail || mediaURL.Purpose == models.PhotoHighRes || mediaURL.Purpose == models.VideoThumbnail {
cachedPath = path.Join(scanner.MediaCachePath(), strconv.Itoa(int(media.AlbumID)), strconv.Itoa(int(mediaURL.MediaID)), mediaURL.MediaName)
} else if mediaURL.Purpose == models.MediaOriginal {
cachedPath = media.Path
} else {
log.Printf("ERROR: Can not handle media_purpose for photo: %s\n", mediaURL.Purpose)
cachedPath, err := mediaURL.CachedPath()
if err != nil {
log.Printf("ERROR: %s\n", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("internal server error"))
return

View File

@ -10,6 +10,7 @@ import (
"github.com/gorilla/mux"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner"
"github.com/photoview/photoview/api/utils"
"gorm.io/gorm"
)
@ -26,7 +27,7 @@ func RegisterVideoRoutes(db *gorm.DB, router *mux.Router) {
return
}
var media = &mediaURL.Media
var media = mediaURL.Media
if success, response, status, err := authenticateMedia(media, db, r); !success {
if err != nil {
@ -40,7 +41,7 @@ func RegisterVideoRoutes(db *gorm.DB, router *mux.Router) {
var cachedPath string
if mediaURL.Purpose == models.VideoWeb {
cachedPath = path.Join(scanner.MediaCachePath(), strconv.Itoa(int(media.AlbumID)), strconv.Itoa(int(mediaURL.MediaID)), mediaURL.MediaName)
cachedPath = path.Join(utils.MediaCachePath(), strconv.Itoa(int(media.AlbumID)), strconv.Itoa(int(mediaURL.MediaID)), mediaURL.MediaName)
} else {
log.Printf("ERROR: Can not handle media_purpose for video: %s\n", mediaURL.Purpose)
w.WriteHeader(http.StatusInternalServerError)

View File

@ -6,6 +6,7 @@ import (
"strconv"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/utils"
"github.com/pkg/errors"
"gorm.io/gorm"
)
@ -36,7 +37,7 @@ func CleanupMedia(db *gorm.DB, albumId int, albumMedia []*models.Media) []error
for _, media := range mediaList {
mediaIDs = append(mediaIDs, media.ID)
cachePath := path.Join(MediaCachePath(), strconv.Itoa(int(albumId)), strconv.Itoa(int(media.ID)))
cachePath := path.Join(utils.MediaCachePath(), strconv.Itoa(int(albumId)), strconv.Itoa(int(media.ID)))
err := os.RemoveAll(cachePath)
if err != nil {
deleteErrors = append(deleteErrors, errors.Wrapf(err, "delete unused cache folder (%s)", cachePath))
@ -90,7 +91,7 @@ func deleteOldUserAlbums(db *gorm.DB, scannedAlbums []*models.Album, user *model
deleteAlbumIDs := make([]int, len(deleteAlbums))
for i, album := range deleteAlbums {
deleteAlbumIDs[i] = album.ID
cachePath := path.Join(MediaCachePath(), strconv.Itoa(int(album.ID)))
cachePath := path.Join(utils.MediaCachePath(), strconv.Itoa(int(album.ID)))
err := os.RemoveAll(cachePath)
if err != nil {
deleteErrors = append(deleteErrors, errors.Wrapf(err, "delete unused cache folder (%s)", cachePath))

View File

@ -6,18 +6,14 @@ import (
"os"
"github.com/disintegration/imaging"
"github.com/pkg/errors"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner/image_helpers"
"github.com/photoview/photoview/api/utils"
"github.com/pkg/errors"
"gopkg.in/vansante/go-ffprobe.v2"
"gorm.io/gorm"
)
type PhotoDimensions struct {
Width int
Height int
}
func DecodeImage(imagePath string) (image.Image, error) {
file, err := os.Open(imagePath)
if err != nil {
@ -33,32 +29,6 @@ func DecodeImage(imagePath string) (image.Image, error) {
return image, nil
}
func PhotoDimensionsFromRect(rect image.Rectangle) PhotoDimensions {
return PhotoDimensions{
Width: rect.Bounds().Max.X,
Height: rect.Bounds().Max.Y,
}
}
func (dimensions *PhotoDimensions) ThumbnailScale() PhotoDimensions {
aspect := float64(dimensions.Width) / float64(dimensions.Height)
var width, height int
if aspect > 1 {
width = 1024
height = int(1024 / aspect)
} else {
width = int(1024 * aspect)
height = 1024
}
return PhotoDimensions{
Width: width,
Height: height,
}
}
// EncodeMediaData is used to easily decode media data, with a cache so expensive operations are not repeated
type EncodeMediaData struct {
media *models.Media
@ -83,24 +53,6 @@ func EncodeImageJPEG(image image.Image, outputPath string, jpegQuality int) erro
return nil
}
func GetPhotoDimensions(imagePath string) (*PhotoDimensions, error) {
photoFile, err := os.Open(imagePath)
if err != nil {
return nil, err
}
defer photoFile.Close()
config, _, err := image.DecodeConfig(photoFile)
if err != nil {
return nil, err
}
return &PhotoDimensions{
Width: config.Width,
Height: config.Height,
}, nil
}
// ContentType reads the image to determine its content type
func (img *EncodeMediaData) ContentType() (*MediaType, error) {
if img._contentType != nil {
@ -148,13 +100,13 @@ func (img *EncodeMediaData) EncodeHighRes(tx *gorm.DB, outputPath string) error
return nil
}
func EncodeThumbnail(inputPath string, outputPath string) (*PhotoDimensions, error) {
func EncodeThumbnail(inputPath string, outputPath string) (*image_helpers.PhotoDimensions, error) {
inputImage, err := DecodeImage(inputPath)
if err != nil {
return nil, err
}
dimensions := PhotoDimensionsFromRect(inputImage.Bounds())
dimensions := image_helpers.PhotoDimensionsFromRect(inputImage.Bounds())
dimensions = dimensions.ThumbnailScale()
thumbImage := imaging.Resize(inputImage, dimensions.Width, dimensions.Height, imaging.NearestNeighbor)

View File

@ -0,0 +1,281 @@
package face_detection
import (
"log"
"path/filepath"
"sync"
"github.com/Kagami/go-face"
"github.com/photoview/photoview/api/graphql/models"
"github.com/pkg/errors"
"gorm.io/gorm"
)
type FaceDetector struct {
mutex sync.Mutex
db *gorm.DB
rec *face.Recognizer
faceDescriptors []face.Descriptor
faceGroupIDs []int32
imageFaceIDs []int
}
var GlobalFaceDetector FaceDetector
func InitializeFaceDetector(db *gorm.DB) error {
log.Println("Initializing face detector")
rec, err := face.NewRecognizer(filepath.Join("data", "models"))
if err != nil {
return errors.Wrap(err, "initialize facedetect recognizer")
}
faceDescriptors, faceGroupIDs, imageFaceIDs, err := getSamplesFromDatabase(db)
if err != nil {
return errors.Wrap(err, "get face detection samples from database")
}
GlobalFaceDetector = FaceDetector{
db: db,
rec: rec,
faceDescriptors: faceDescriptors,
faceGroupIDs: faceGroupIDs,
imageFaceIDs: imageFaceIDs,
}
return nil
}
func getSamplesFromDatabase(db *gorm.DB) (samples []face.Descriptor, faceGroupIDs []int32, imageFaceIDs []int, err error) {
var imageFaces []*models.ImageFace
if err = db.Find(&imageFaces).Error; err != nil {
return
}
samples = make([]face.Descriptor, len(imageFaces))
faceGroupIDs = make([]int32, len(imageFaces))
imageFaceIDs = make([]int, len(imageFaces))
for i, imgFace := range imageFaces {
samples[i] = face.Descriptor(imgFace.Descriptor)
faceGroupIDs[i] = int32(imgFace.FaceGroupID)
imageFaceIDs[i] = imgFace.ID
}
return
}
// DetectFaces finds the faces in the given image and saves them to the database
func (fd *FaceDetector) DetectFaces(media *models.Media) error {
if err := fd.db.Model(media).Preload("MediaURL").First(&media).Error; err != nil {
return err
}
var thumbnailURL *models.MediaURL
for _, url := range media.MediaURL {
if url.Purpose == models.PhotoThumbnail {
thumbnailURL = &url
thumbnailURL.Media = media
break
}
}
if thumbnailURL == nil {
return errors.New("thumbnail url is missing")
}
thumbnailPath, err := thumbnailURL.CachedPath()
if err != nil {
return err
}
fd.mutex.Lock()
faces, err := fd.rec.RecognizeFile(thumbnailPath)
fd.mutex.Unlock()
if err != nil {
return errors.Wrap(err, "error read faces")
}
for _, face := range faces {
fd.classifyFace(&face, media, thumbnailPath)
}
return nil
}
func (fd *FaceDetector) classifyDescriptor(descriptor face.Descriptor) int32 {
return int32(fd.rec.ClassifyThreshold(descriptor, 0.3))
}
func (fd *FaceDetector) classifyFace(face *face.Face, media *models.Media, imagePath string) error {
fd.mutex.Lock()
defer fd.mutex.Unlock()
match := fd.classifyDescriptor(face.Descriptor)
faceRect, err := models.ToDBFaceRectangle(face.Rectangle, imagePath)
if err != nil {
return err
}
imageFace := models.ImageFace{
MediaID: media.ID,
Descriptor: models.FaceDescriptor(face.Descriptor),
Rectangle: *faceRect,
}
var faceGroup models.FaceGroup
// If no match add it new to samples
if match < 0 {
log.Println("No match, assigning new face")
faceGroup = models.FaceGroup{
ImageFaces: []models.ImageFace{imageFace},
}
if err := fd.db.Create(&faceGroup).Error; err != nil {
return err
}
} else {
log.Println("Found match")
if err := fd.db.First(&faceGroup, int(match)).Error; err != nil {
return err
}
if err := fd.db.Model(&faceGroup).Association("ImageFaces").Append(&imageFace); err != nil {
return err
}
}
fd.faceDescriptors = append(fd.faceDescriptors, face.Descriptor)
fd.faceGroupIDs = append(fd.faceGroupIDs, int32(faceGroup.ID))
fd.imageFaceIDs = append(fd.imageFaceIDs, imageFace.ID)
fd.rec.SetSamples(fd.faceDescriptors, fd.faceGroupIDs)
return nil
}
func (fd *FaceDetector) MergeCategories(sourceID int32, destID int32) {
fd.mutex.Lock()
defer fd.mutex.Unlock()
for i := range fd.faceGroupIDs {
if fd.faceGroupIDs[i] == sourceID {
fd.faceGroupIDs[i] = destID
}
}
}
func (fd *FaceDetector) MergeImageFaces(imageFaceIDs []int, destFaceGroupID int32) {
fd.mutex.Lock()
defer fd.mutex.Unlock()
for i := range fd.faceGroupIDs {
imageFaceID := fd.imageFaceIDs[i]
for _, id := range imageFaceIDs {
if imageFaceID == id {
fd.faceGroupIDs[i] = destFaceGroupID
break
}
}
}
}
func (fd *FaceDetector) RecognizeUnlabeledFaces(tx *gorm.DB, user *models.User) ([]*models.ImageFace, error) {
unrecognizedDescriptors := make([]face.Descriptor, 0)
unrecognizedFaceGroupIDs := make([]int32, 0)
unrecognizedImageFaceIDs := make([]int, 0)
newFaceGroupIDs := make([]int32, 0)
newDescriptors := make([]face.Descriptor, 0)
newImageFaceIDs := make([]int, 0)
var unlabeledFaceGroups []*models.FaceGroup
err := tx.
Joins("JOIN image_faces ON image_faces.face_group_id = face_groups.id").
Joins("JOIN media ON image_faces.media_id = media.id").
Where("face_groups.label IS NULL").
Where("media.album_id IN (?)",
tx.Select("album_id").Table("user_albums").Where("user_id = ?", user.ID),
).
Find(&unlabeledFaceGroups).Error
if err != nil {
return nil, err
}
fd.mutex.Lock()
defer fd.mutex.Unlock()
for i := range fd.faceDescriptors {
descriptor := fd.faceDescriptors[i]
faceGroupID := fd.faceGroupIDs[i]
imageFaceID := fd.imageFaceIDs[i]
isUnlabeled := false
for _, unlabeledFaceGroup := range unlabeledFaceGroups {
if faceGroupID == int32(unlabeledFaceGroup.ID) {
isUnlabeled = true
continue
}
}
if isUnlabeled {
unrecognizedFaceGroupIDs = append(unrecognizedFaceGroupIDs, faceGroupID)
unrecognizedDescriptors = append(unrecognizedDescriptors, descriptor)
unrecognizedImageFaceIDs = append(unrecognizedImageFaceIDs, imageFaceID)
} else {
newFaceGroupIDs = append(newFaceGroupIDs, faceGroupID)
newDescriptors = append(newDescriptors, descriptor)
newImageFaceIDs = append(newImageFaceIDs, imageFaceID)
}
}
fd.faceGroupIDs = newFaceGroupIDs
fd.faceDescriptors = newDescriptors
fd.imageFaceIDs = newImageFaceIDs
updatedImageFaces := make([]*models.ImageFace, 0)
for i := range unrecognizedDescriptors {
descriptor := unrecognizedDescriptors[i]
faceGroupID := unrecognizedFaceGroupIDs[i]
imageFaceID := unrecognizedImageFaceIDs[i]
match := fd.classifyDescriptor(descriptor)
if match < 0 {
// still no match, we can readd it to the list
fd.faceGroupIDs = append(fd.faceGroupIDs, faceGroupID)
fd.faceDescriptors = append(fd.faceDescriptors, descriptor)
fd.imageFaceIDs = append(fd.imageFaceIDs, imageFaceID)
} else {
// found new match, update the database
var imageFace models.ImageFace
if err := tx.Model(&models.ImageFace{}).First(imageFace, imageFaceID).Error; err != nil {
return nil, err
}
if err := tx.Model(&imageFace).Update("face_group_id", int(faceGroupID)).Error; err != nil {
return nil, err
}
updatedImageFaces = append(updatedImageFaces, &imageFace)
fd.faceGroupIDs = append(fd.faceGroupIDs, match)
fd.faceDescriptors = append(fd.faceDescriptors, descriptor)
fd.imageFaceIDs = append(fd.imageFaceIDs, imageFaceID)
}
}
return updatedImageFaces, nil
}

View File

@ -0,0 +1,55 @@
package image_helpers
import (
"image"
"os"
)
type PhotoDimensions struct {
Width int
Height int
}
func GetPhotoDimensions(imagePath string) (*PhotoDimensions, error) {
photoFile, err := os.Open(imagePath)
if err != nil {
return nil, err
}
defer photoFile.Close()
config, _, err := image.DecodeConfig(photoFile)
if err != nil {
return nil, err
}
return &PhotoDimensions{
Width: config.Width,
Height: config.Height,
}, nil
}
func PhotoDimensionsFromRect(rect image.Rectangle) PhotoDimensions {
return PhotoDimensions{
Width: rect.Bounds().Max.X,
Height: rect.Bounds().Max.Y,
}
}
func (dimensions *PhotoDimensions) ThumbnailScale() PhotoDimensions {
aspect := float64(dimensions.Width) / float64(dimensions.Height)
var width, height int
if aspect > 1 {
width = 1024
height = int(1024 / aspect)
} else {
width = int(1024 * aspect)
height = 1024
}
return PhotoDimensions{
Width: width,
Height: height,
}
}

View File

@ -150,6 +150,7 @@ var fileExtensions = map[string]MediaType{
".arw": TypeARW,
".sr2": TypeSR2,
".srf": TypeSRF,
".srw": TypeSRW,
".cr2": TypeCR2,
".crw": TypeCRW,
".erf": TypeERF,
@ -159,15 +160,22 @@ var fileExtensions = map[string]MediaType{
".mrw": TypeMRW,
".nef": TypeNEF,
".nrw": TypeNRW,
".mdc": TypeMDC,
".mef": TypeMEF,
".orf": TypeORF,
".pef": TypePEF,
".raf": TypeRAF,
".raw": TypeRAW,
".rw2": TypeRW2,
".dcs": TypeDCS,
".drf": TypeDRF,
".gpr": TypeGPR,
".3fr": Type3FR,
".fff": TypeFFF,
".cap": TypeCap,
".iiq": TypeIIQ,
".mos": TypeMOS,
".rwl": TypeRWL,
// Video formats
".mp4": TypeMP4,

View File

@ -8,6 +8,7 @@ import (
"strconv"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner/image_helpers"
"github.com/photoview/photoview/api/utils"
"github.com/pkg/errors"
"gorm.io/gorm"
@ -110,7 +111,7 @@ func processPhoto(tx *gorm.DB, imageData *EncodeMediaData, photoCachePath *strin
return false, errors.Wrap(err, "error processing photo highres")
}
var photoDimensions *PhotoDimensions
var photoDimensions *image_helpers.PhotoDimensions
var baseImagePath string = photo.Path
mediaType, err := getMediaType(photo.Path)
@ -161,7 +162,7 @@ func processPhoto(tx *gorm.DB, imageData *EncodeMediaData, photoCachePath *strin
// Make sure photo dimensions is set
if photoDimensions == nil {
photoDimensions, err = GetPhotoDimensions(baseImagePath)
photoDimensions, err = image_helpers.GetPhotoDimensions(baseImagePath)
if err != nil {
return false, err
}
@ -217,14 +218,14 @@ func processPhoto(tx *gorm.DB, imageData *EncodeMediaData, photoCachePath *strin
func makeMediaCacheDir(media *models.Media) (*string, error) {
// Make root cache dir if not exists
if _, err := os.Stat(MediaCachePath()); os.IsNotExist(err) {
if err := os.Mkdir(MediaCachePath(), os.ModePerm); err != nil {
if _, err := os.Stat(utils.MediaCachePath()); os.IsNotExist(err) {
if err := os.Mkdir(utils.MediaCachePath(), os.ModePerm); err != nil {
return nil, errors.Wrap(err, "could not make root image cache directory")
}
}
// Make album cache dir if not exists
albumCachePath := path.Join(MediaCachePath(), strconv.Itoa(int(media.AlbumID)))
albumCachePath := path.Join(utils.MediaCachePath(), strconv.Itoa(int(media.AlbumID)))
if _, err := os.Stat(albumCachePath); os.IsNotExist(err) {
if err := os.Mkdir(albumCachePath, os.ModePerm); err != nil {
return nil, errors.Wrap(err, "could not make album image cache directory")
@ -242,7 +243,7 @@ func makeMediaCacheDir(media *models.Media) (*string, error) {
return &photoCachePath, nil
}
func saveOriginalPhotoToDB(tx *gorm.DB, photo *models.Media, imageData *EncodeMediaData, photoDimensions *PhotoDimensions) error {
func saveOriginalPhotoToDB(tx *gorm.DB, photo *models.Media, imageData *EncodeMediaData, photoDimensions *image_helpers.PhotoDimensions) error {
originalImageName := generateUniqueMediaName(photo.Path)
contentType, err := imageData.ContentType()
@ -256,7 +257,7 @@ func saveOriginalPhotoToDB(tx *gorm.DB, photo *models.Media, imageData *EncodeMe
}
mediaURL := models.MediaURL{
Media: *photo,
Media: photo,
MediaName: originalImageName,
Width: photoDimensions.Width,
Height: photoDimensions.Height,
@ -279,7 +280,7 @@ func generateSaveHighResJPEG(tx *gorm.DB, media *models.Media, imageData *Encode
return nil, errors.Wrap(err, "creating high-res cached image")
}
photoDimensions, err := GetPhotoDimensions(imagePath)
photoDimensions, err := image_helpers.GetPhotoDimensions(imagePath)
if err != nil {
return nil, err
}

View File

@ -10,6 +10,7 @@ import (
"time"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner/image_helpers"
"github.com/photoview/photoview/api/utils"
"github.com/pkg/errors"
"gopkg.in/vansante/go-ffprobe.v2"
@ -131,7 +132,7 @@ func processVideo(tx *gorm.DB, mediaData *EncodeMediaData, videoCachePath *strin
return false, errors.Wrapf(err, "failed to generate thumbnail for video (%s)", video.Title)
}
thumbDimensions, err := GetPhotoDimensions(thumbImagePath)
thumbDimensions, err := image_helpers.GetPhotoDimensions(thumbImagePath)
if err != nil {
return false, errors.Wrap(err, "get dimensions of video thumbnail image")
}
@ -167,7 +168,7 @@ func processVideo(tx *gorm.DB, mediaData *EncodeMediaData, videoCachePath *strin
return false, errors.Wrapf(err, "failed to generate thumbnail for video (%s)", video.Title)
}
thumbDimensions, err := GetPhotoDimensions(thumbImagePath)
thumbDimensions, err := image_helpers.GetPhotoDimensions(thumbImagePath)
if err != nil {
return false, errors.Wrap(err, "get dimensions of video thumbnail image")
}

View File

@ -8,6 +8,7 @@ import (
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/graphql/notification"
"github.com/photoview/photoview/api/scanner/face_detection"
"github.com/photoview/photoview/api/utils"
"github.com/pkg/errors"
"gorm.io/gorm"
@ -54,7 +55,7 @@ func scanAlbum(album *models.Album, cache *AlbumScannerCache, db *gorm.DB) {
notifyThrottle.Trigger(nil)
// Scan for photos
albumPhotos, err := findMediaForAlbum(album, cache, db, func(photo *models.Media, newPhoto bool) {
albumMedia, err := findMediaForAlbum(album, cache, db, func(photo *models.Media, newPhoto bool) {
if newPhoto {
notifyThrottle.Trigger(func() {
notification.BroadcastNotification(&models.Notification{
@ -71,25 +72,33 @@ func scanAlbum(album *models.Album, cache *AlbumScannerCache, db *gorm.DB) {
}
album_has_changes := false
for count, photo := range albumPhotos {
for count, media := range albumMedia {
// tx, err := db.Begin()
transactionError := db.Transaction(func(tx *gorm.DB) error {
processing_was_needed, err := ProcessMedia(tx, photo)
processing_was_needed, err := ProcessMedia(tx, media)
if err != nil {
return errors.Wrapf(err, "failed to process photo (%s)", photo.Path)
return errors.Wrapf(err, "failed to process photo (%s)", media.Path)
}
if processing_was_needed {
album_has_changes = true
progress := float64(count) / float64(len(albumPhotos)) * 100.0
progress := float64(count) / float64(len(albumMedia)) * 100.0
notification.BroadcastNotification(&models.Notification{
Key: album_notify_key,
Type: models.NotificationTypeProgress,
Header: fmt.Sprintf("Processing media for album '%s'", album.Title),
Content: fmt.Sprintf("Processed media at %s", photo.Path),
Content: fmt.Sprintf("Processed media at %s", media.Path),
Progress: &progress,
})
if media.Type == models.MediaTypePhoto {
go func() {
if err := face_detection.GlobalFaceDetector.DetectFaces(media); err != nil {
ScannerError("Error detecting faces in image (%s): %s", media.Path, err)
}
}()
}
}
return nil
@ -100,7 +109,7 @@ func scanAlbum(album *models.Album, cache *AlbumScannerCache, db *gorm.DB) {
}
}
cleanup_errors := CleanupMedia(db, album.ID, albumPhotos)
cleanup_errors := CleanupMedia(db, album.ID, albumMedia)
for _, err := range cleanup_errors {
ScannerError("Failed to delete old media: %s", err)
}
@ -138,14 +147,14 @@ func findMediaForAlbum(album *models.Album, cache *AlbumScannerCache, db *gorm.D
}
err := db.Transaction(func(tx *gorm.DB) error {
photo, isNewPhoto, err := ScanMedia(tx, photoPath, album.ID, cache)
media, isNewMedia, err := ScanMedia(tx, photoPath, album.ID, cache)
if err != nil {
return errors.Wrapf(err, "Scanning media error (%s)", photoPath)
}
onScanPhoto(photo, isNewPhoto)
onScanPhoto(media, isNewMedia)
albumPhotos = append(albumPhotos, photo)
albumPhotos = append(albumPhotos, media)
return nil
})

View File

@ -220,13 +220,3 @@ func ScannerError(format string, args ...interface{}) {
Negative: true,
})
}
// MediaCachePath returns the path for where the media cache is located on the file system
func MediaCachePath() string {
photoCache := utils.EnvMediaCachePath.GetValue()
if photoCache == "" {
photoCache = "./media_cache"
}
return photoCache
}

View File

@ -4,6 +4,7 @@ import (
"log"
"net/http"
"path"
"time"
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
@ -15,6 +16,7 @@ import (
"github.com/photoview/photoview/api/graphql/dataloader"
"github.com/photoview/photoview/api/routes"
"github.com/photoview/photoview/api/scanner"
"github.com/photoview/photoview/api/scanner/face_detection"
"github.com/photoview/photoview/api/server"
"github.com/photoview/photoview/api/utils"
@ -51,6 +53,10 @@ func main() {
scanner.InitializeExecutableWorkers()
if err := face_detection.InitializeFaceDetector(db); err != nil {
log.Panicf("Could not initialize face detector: %s\n", err)
}
rootRouter := mux.NewRouter()
rootRouter.Use(dataloader.Middleware(db))
@ -83,6 +89,7 @@ func main() {
handler.GraphQL(photoview_graphql.NewExecutableSchema(graphqlConfig),
handler.IntrospectionEnabled(devMode),
handler.WebsocketUpgrader(server.WebsocketUpgrader(devMode)),
handler.WebsocketKeepAliveDuration(time.Second*10),
handler.WebsocketInitFunc(auth.AuthWebsocketInit(db)),
),
)

View File

@ -41,3 +41,13 @@ func HandleError(message string, err error) PhotoviewError {
original: err,
}
}
// MediaCachePath returns the path for where the media cache is located on the file system
func MediaCachePath() string {
photoCache := EnvMediaCachePath.GetValue()
if photoCache == "" {
photoCache = "./media_cache"
}
return photoCache
}

107
docker/go_wrapper.sh Normal file
View File

@ -0,0 +1,107 @@
#!/bin/sh
# Script to configure environment variables for Go compiler
# to allow cross compilation
: ${TARGETPLATFORM=}
: ${TARGETOS=}
: ${TARGETARCH=}
: ${TARGETVARIANT=}
: ${CGO_ENABLED=}
: ${GOARCH=}
: ${GOOS=}
: ${GOARM=}
: ${GOBIN=}
set -eu
if [ ! -z "$TARGETPLATFORM" ]; then
os="$(echo $TARGETPLATFORM | cut -d"/" -f1)"
arch="$(echo $TARGETPLATFORM | cut -d"/" -f2)"
if [ ! -z "$os" ] && [ ! -z "$arch" ]; then
export GOOS="$os"
export GOARCH="$arch"
if [ "$arch" = "arm" ]; then
case "$(echo $TARGETPLATFORM | cut -d"/" -f3)" in
"v5")
export GOARM="5"
;;
"v6")
export GOARM="6"
;;
*)
export GOARM="7"
;;
esac
fi
fi
fi
if [ ! -z "$TARGETOS" ]; then
export GOOS="$TARGETOS"
fi
if [ ! -z "$TARGETARCH" ]; then
export GOARCH="$TARGETARCH"
fi
if [ "$TARGETARCH" = "arm" ]; then
if [ ! -z "$TARGETVARIANT" ]; then
case "$TARGETVARIANT" in
"v5")
export GOARM="5"
;;
"v6")
export GOARM="6"
;;
*)
export GOARM="7"
;;
esac
else
export GOARM="7"
fi
fi
if [ "$CGO_ENABLED" = "1" ]; then
case "$GOARCH" in
"amd64")
export CC="x86_64-linux-gnu-gcc"
export CXX="x86_64-linux-gnu-g++"
;;
"ppc64le")
export CC="powerpc64le-linux-gnu-gcc"
export CXX="powerpc64le-linux-gnu-g++"
;;
"s390x")
export CC="s390x-linux-gnu-gcc"
export CXX="s390x-linux-gnu-g++"
;;
"arm64")
export CC="aarch64-linux-gnu-gcc"
export CXX="aarch64-linux-gnu-g++"
;;
"arm")
case "$GOARM" in
"5")
export CC="arm-linux-gnueabi-gcc"
export CXX="arm-linux-gnueabi-g++"
;;
*)
export CC="arm-linux-gnueabihf-gcc"
export CXX="arm-linux-gnueabihf-g++"
;;
esac
;;
esac
fi
if [ "$GOOS" = "wasi" ]; then
export GOOS="js"
fi
if [ -z "$GOBIN" ] && [ -n "$GOPATH" ] && [ -n "$GOARCH" ] && [ -n "$GOOS" ]; then
export PATH=${GOPATH}/bin/${GOOS}_${GOARCH}:${PATH}
fi
exec /usr/local/go/bin/go "$@"

View File

@ -104,19 +104,23 @@ export const SideMenu = () => {
return (
<SideMenuContainer>
<SideButton to="/photos" exact>
<Icon name="image outline" />
<Icon name="image" />
<SideButtonLabel>Photos</SideButtonLabel>
</SideButton>
<SideButton to="/albums" exact>
<Icon name="images outline" />
<Icon name="images" />
<SideButtonLabel>Albums</SideButtonLabel>
</SideButton>
{mapboxEnabled ? (
<SideButton to="/places" exact>
<Icon name="map outline" />
<Icon name="map" />
<SideButtonLabel>Places</SideButtonLabel>
</SideButton>
) : null}
<SideButton to="/people" exact>
<Icon name="user" />
<SideButtonLabel>People</SideButtonLabel>
</SideButton>
{isAdmin ? (
<SideButton to="/settings" exact>
<Icon name="settings" />

View File

@ -0,0 +1,101 @@
import React from 'react'
import PropTypes from 'prop-types'
import styled from 'styled-components'
import { ProtectedImage } from '../../components/photoGallery/ProtectedMedia'
const FaceImage = styled(ProtectedImage)`
position: absolute;
transform-origin: ${({ $origin }) =>
`${$origin.x * 100}% ${$origin.y * 100}%`};
object-fit: cover;
transition: transform 250ms ease-out;
`
const FaceImagePortrait = styled(FaceImage)`
width: 100%;
top: 50%;
transform: translateY(-50%)
${({ $origin, $scale }) =>
`translate(${(0.5 - $origin.x) * 100}%, ${
(0.5 - $origin.y) * 100
}%) scale(${Math.max($scale * 0.8, 1)})`};
${({ $selectable, $origin, $scale }) =>
$selectable
? `
&:hover {
transform: translateY(-50%) translate(${(0.5 - $origin.x) * 100}%, ${
(0.5 - $origin.y) * 100
}%) scale(${Math.max($scale * 0.85, 1)})
`
: ''}
`
const FaceImageLandscape = styled(FaceImage)`
height: 100%;
left: 50%;
transform: translateX(-50%)
${({ $origin, $scale }) =>
`translate(${(0.5 - $origin.x) * 100}%, ${
(0.5 - $origin.y) * 100
}%) scale(${Math.max($scale * 0.8, 1)})`};
${({ $selectable, $origin, $scale }) =>
$selectable
? `
&:hover {
transform: translateX(-50%) translate(${(0.5 - $origin.x) * 100}%, ${
(0.5 - $origin.y) * 100
}%) scale(${Math.max($scale * 0.85, 1)})
`
: ''}
`
const CircleImageWrapper = styled.div`
background-color: #eee;
position: relative;
border-radius: 50%;
width: ${({ size }) => size};
height: ${({ size }) => size};
object-fit: fill;
overflow: hidden;
`
const FaceCircleImage = ({ imageFace, selectable, size = '150px' }) => {
if (!imageFace) {
return null
}
const rect = imageFace.rectangle
let scale = Math.min(1 / (rect.maxX - rect.minX), 1 / (rect.maxY - rect.minY))
let origin = {
x: (rect.minX + rect.maxX) / 2,
y: (rect.minY + rect.maxY) / 2,
}
const SpecificFaceImage =
imageFace.media.thumbnail.width > imageFace.media.thumbnail.height
? FaceImageLandscape
: FaceImagePortrait
return (
<CircleImageWrapper size={size}>
<SpecificFaceImage
$selectable={selectable}
$scale={scale}
$origin={origin}
src={imageFace.media.thumbnail.url}
/>
</CircleImageWrapper>
)
}
FaceCircleImage.propTypes = {
imageFace: PropTypes.object,
selectable: PropTypes.bool,
size: PropTypes.string,
}
export default FaceCircleImage

View File

@ -0,0 +1,253 @@
import React, { createRef, useEffect, useState } from 'react'
import PropTypes from 'prop-types'
import { gql, useMutation, useQuery } from '@apollo/client'
import Layout from '../../Layout'
import styled from 'styled-components'
import { Link } from 'react-router-dom'
import SingleFaceGroup from './SingleFaceGroup/SingleFaceGroup'
import { Button, Icon, Input } from 'semantic-ui-react'
import FaceCircleImage from './FaceCircleImage'
export const MY_FACES_QUERY = gql`
query myFaces {
myFaceGroups {
id
label
imageFaces {
id
rectangle {
minX
maxX
minY
maxY
}
media {
id
type
title
thumbnail {
url
width
height
}
highRes {
url
}
favorite
}
}
}
}
`
export const SET_GROUP_LABEL_MUTATION = gql`
mutation($groupID: ID!, $label: String) {
setFaceGroupLabel(faceGroupID: $groupID, label: $label) {
id
label
}
}
`
const RECOGNIZE_UNLABELED_FACES_MUTATION = gql`
mutation recognizeUnlabeledFaces {
recognizeUnlabeledFaces {
id
}
}
`
const FaceDetailsButton = styled.button`
color: ${({ labeled }) => (labeled ? 'black' : '#aaa')};
margin: 12px auto 24px;
text-align: center;
display: block;
background: none;
border: none;
cursor: pointer;
&:hover,
&:focus-visible {
color: #2683ca;
}
`
const FaceLabel = styled.span``
const FaceDetails = ({ group }) => {
const [editLabel, setEditLabel] = useState(false)
const [inputValue, setInputValue] = useState(group.label ?? '')
const inputRef = createRef()
const [setGroupLabel, { loading }] = useMutation(SET_GROUP_LABEL_MUTATION, {
variables: {
groupID: group.id,
},
})
const resetLabel = () => {
setInputValue(group.label ?? '')
setEditLabel(false)
}
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus()
}
}, [inputRef])
useEffect(() => {
if (!loading) {
resetLabel()
}
}, [loading])
const onKeyUp = e => {
if (e.key == 'Escape') {
resetLabel()
return
}
if (e.key == 'Enter') {
setGroupLabel({
variables: {
label: e.target.value == '' ? null : e.target.value,
},
})
return
}
}
let label
if (!editLabel) {
label = (
<FaceDetailsButton
labeled={!!group.label}
onClick={() => setEditLabel(true)}
>
<FaceImagesCount>{group.imageFaces.length}</FaceImagesCount>
<FaceLabel>{group.label ?? 'Unlabeled'}</FaceLabel>
<EditIcon name="pencil" />
</FaceDetailsButton>
)
} else {
label = (
<FaceDetailsButton labeled={!!group.label}>
<Input
loading={loading}
ref={inputRef}
size="mini"
placeholder="Label"
icon="arrow right"
value={inputValue}
onKeyUp={onKeyUp}
onChange={e => setInputValue(e.target.value)}
onBlur={() => {
resetLabel()
}}
/>
</FaceDetailsButton>
)
}
return label
}
FaceDetails.propTypes = {
group: PropTypes.object.isRequired,
}
const FaceImagesCount = styled.span`
background-color: #eee;
color: #222;
font-size: 0.9em;
padding: 0 4px;
margin-right: 6px;
border-radius: 4px;
`
const EditIcon = styled(Icon)`
margin-left: 6px !important;
opacity: 0 !important;
transition: opacity 100ms;
${FaceDetailsButton}:hover &, ${FaceDetailsButton}:focus-visible & {
opacity: 1 !important;
}
`
const FaceGroup = ({ group }) => {
const previewFace = group.imageFaces[0]
return (
<div style={{ margin: '12px' }}>
<Link to={`/people/${group.id}`}>
<FaceCircleImage imageFace={previewFace} selectable />
</Link>
<FaceDetails group={group} />
</div>
)
}
FaceGroup.propTypes = {
group: PropTypes.any,
}
const FaceGroupsWrapper = styled.div`
display: flex;
flex-wrap: wrap;
`
const PeoplePage = ({ match }) => {
const { data, error } = useQuery(MY_FACES_QUERY)
const [
recognizeUnlabeled,
{ loading: recognizeUnlabeledLoading },
] = useMutation(RECOGNIZE_UNLABELED_FACES_MUTATION)
if (error) {
return error.message
}
const faceGroup = match.params.person
if (faceGroup) {
return (
<Layout>
<SingleFaceGroup
faceGroup={data?.myFaceGroups?.find(x => x.id == faceGroup)}
/>
</Layout>
)
}
let faces = null
if (data) {
faces = data.myFaceGroups.map(faceGroup => (
<FaceGroup key={faceGroup.id} group={faceGroup} />
))
}
return (
<Layout title={'People'}>
<FaceGroupsWrapper>{faces}</FaceGroupsWrapper>
<Button
loading={recognizeUnlabeledLoading}
disabled={recognizeUnlabeledLoading}
onClick={() => {
recognizeUnlabeled()
}}
>
<Icon name="sync" />
Recognize unlabeled faces
</Button>
</Layout>
)
}
PeoplePage.propTypes = {
match: PropTypes.object.isRequired,
}
export default PeoplePage

View File

@ -0,0 +1,96 @@
import { gql, useMutation } from '@apollo/client'
import PropTypes from 'prop-types'
import React, { useEffect, useState } from 'react'
import { useHistory } from 'react-router-dom'
import { Button, Modal } from 'semantic-ui-react'
import { MY_FACES_QUERY } from '../PeoplePage'
import SelectImageFacesTable from './SelectImageFacesTable'
const DETACH_IMAGE_FACES_MUTATION = gql`
mutation detachImageFaces($faceIDs: [ID!]!) {
detachImageFaces(imageFaceIDs: $faceIDs) {
id
label
}
}
`
const DetachImageFacesModal = ({ open, setOpen, faceGroup }) => {
const [selectedImageFaces, setSelectedImageFaces] = useState([])
let history = useHistory()
const [detachImageFacesMutation] = useMutation(DETACH_IMAGE_FACES_MUTATION, {
variables: {},
refetchQueries: [
{
query: MY_FACES_QUERY,
},
],
})
useEffect(() => {
if (!open) {
setSelectedImageFaces([])
}
}, [open])
if (open == false) return null
const detachImageFaces = () => {
const faceIDs = selectedImageFaces.map(face => face.id)
detachImageFacesMutation({
variables: {
faceIDs,
},
}).then(({ data }) => {
setOpen(false)
history.push(`/people/${data.detachImageFaces.id}`)
})
}
const imageFaces = faceGroup?.imageFaces ?? []
return (
<Modal
onClose={() => setOpen(false)}
onOpen={() => setOpen(true)}
open={open}
>
<Modal.Header>Detach Image Faces</Modal.Header>
<Modal.Content scrolling>
<Modal.Description>
<p>
Detach selected images of this face group and move them to a new
face group
</p>
<SelectImageFacesTable
imageFaces={imageFaces}
selectedImageFaces={selectedImageFaces}
setSelectedImageFaces={setSelectedImageFaces}
title="Select images to detach"
/>
</Modal.Description>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button
disabled={selectedImageFaces.length == 0}
content="Detach image faces"
labelPosition="right"
icon="checkmark"
onClick={() => detachImageFaces()}
positive
/>
</Modal.Actions>
</Modal>
)
}
DetachImageFacesModal.propTypes = {
open: PropTypes.bool.isRequired,
setOpen: PropTypes.func.isRequired,
faceGroup: PropTypes.object,
}
export default DetachImageFacesModal

View File

@ -0,0 +1,164 @@
import { useMutation } from '@apollo/client'
import PropTypes from 'prop-types'
import React, { useState, useEffect, createRef } from 'react'
import { Dropdown, Input } from 'semantic-ui-react'
import styled from 'styled-components'
import { SET_GROUP_LABEL_MUTATION } from '../PeoplePage'
import DetachImageFacesModal from './DetachImageFacesModal'
import MergeFaceGroupsModal from './MergeFaceGroupsModal'
import MoveImageFacesModal from './MoveImageFacesModal'
const TitleWrapper = styled.div`
min-height: 3.5em;
`
const TitleLabel = styled.h1`
display: inline-block;
color: ${({ labeled }) => (labeled ? 'black' : '#888')};
margin-right: 12px;
`
const TitleDropdown = styled(Dropdown)`
vertical-align: middle;
margin-top: -10px;
color: #888;
&:hover {
color: #1e70bf;
}
`
const FaceGroupTitle = ({ faceGroup }) => {
const [editLabel, setEditLabel] = useState(false)
const [inputValue, setInputValue] = useState(faceGroup?.label ?? '')
const inputRef = createRef()
const [mergeModalOpen, setMergeModalOpen] = useState(false)
const [moveModalOpen, setMoveModalOpen] = useState(false)
const [detachModalOpen, setDetachModalOpen] = useState(false)
const [setGroupLabel, { loading: setLabelLoading }] = useMutation(
SET_GROUP_LABEL_MUTATION,
{
variables: {
groupID: faceGroup?.id,
},
}
)
const resetLabel = () => {
setInputValue(faceGroup?.label ?? '')
setEditLabel(false)
}
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus()
}
}, [inputRef])
useEffect(() => {
if (!setLabelLoading) {
resetLabel()
}
}, [setLabelLoading])
const onKeyUp = e => {
if (e.key == 'Escape') {
resetLabel()
return
}
if (e.key == 'Enter') {
setGroupLabel({
variables: {
label: e.target.value == '' ? null : e.target.value,
},
})
return
}
}
let title
if (!editLabel) {
title = (
<TitleWrapper>
<TitleLabel labeled={!!faceGroup?.label}>
{faceGroup?.label ?? 'Unlabeled person'}
</TitleLabel>
<TitleDropdown
icon={{
name: 'settings',
size: 'large',
}}
>
<Dropdown.Menu>
<Dropdown.Item
icon="pencil"
text={faceGroup?.label ? 'Change Label' : 'Add Label'}
onClick={() => setEditLabel(true)}
/>
<Dropdown.Item
icon="object group"
text="Merge Face"
onClick={() => setMergeModalOpen(true)}
/>
<Dropdown.Item
icon="object ungroup"
text="Detach Faces"
onClick={() => setDetachModalOpen(true)}
/>
<Dropdown.Item
icon="clone"
text="Move Faces"
onClick={() => setMoveModalOpen(true)}
/>
</Dropdown.Menu>
</TitleDropdown>
</TitleWrapper>
)
} else {
title = (
<TitleWrapper>
<Input
loading={setLabelLoading}
ref={inputRef}
placeholder="Label"
icon="arrow right"
value={inputValue}
onKeyUp={onKeyUp}
onChange={e => setInputValue(e.target.value)}
onBlur={() => {
resetLabel()
}}
/>
</TitleWrapper>
)
}
return (
<>
{title}
<MergeFaceGroupsModal
open={mergeModalOpen}
setOpen={setMergeModalOpen}
sourceFaceGroup={faceGroup}
/>
<MoveImageFacesModal
open={moveModalOpen}
setOpen={setMoveModalOpen}
faceGroup={faceGroup}
/>
<DetachImageFacesModal
open={detachModalOpen}
setOpen={setDetachModalOpen}
faceGroup={faceGroup}
/>
</>
)
}
FaceGroupTitle.propTypes = {
faceGroup: PropTypes.object,
}
export default FaceGroupTitle

View File

@ -0,0 +1,94 @@
import { gql, useMutation, useQuery } from '@apollo/client'
import PropTypes from 'prop-types'
import React, { useState } from 'react'
import { useHistory } from 'react-router-dom'
import { Button, Modal } from 'semantic-ui-react'
import { MY_FACES_QUERY } from '../PeoplePage'
import SelectFaceGroupTable from './SelectFaceGroupTable'
const COMBINE_FACES_MUTATION = gql`
mutation($destID: ID!, $srcID: ID!) {
combineFaceGroups(
destinationFaceGroupID: $destID
sourceFaceGroupID: $srcID
) {
id
}
}
`
const MergeFaceGroupsModal = ({ open, setOpen, sourceFaceGroup }) => {
const [selectedFaceGroup, setSelectedFaceGroup] = useState(null)
let history = useHistory()
const { data } = useQuery(MY_FACES_QUERY)
const [combineFacesMutation] = useMutation(COMBINE_FACES_MUTATION, {
variables: {
srcID: sourceFaceGroup?.id,
},
refetchQueries: [
{
query: MY_FACES_QUERY,
},
],
})
if (open == false) return null
const filteredFaceGroups =
data?.myFaceGroups.filter(x => x.id != sourceFaceGroup?.id) ?? []
const mergeFaceGroups = () => {
combineFacesMutation({
variables: {
destID: selectedFaceGroup.id,
},
}).then(() => {
setOpen(false)
history.push(`/people/${selectedFaceGroup.id}`)
})
}
return (
<Modal
onClose={() => setOpen(false)}
onOpen={() => setOpen(true)}
open={open}
>
<Modal.Header>Merge Face Groups</Modal.Header>
<Modal.Content scrolling>
<Modal.Description>
<p>
All images within this face group will be merged into the selected
face group.
</p>
<SelectFaceGroupTable
title="Select the destination face"
faceGroups={filteredFaceGroups}
selectedFaceGroup={selectedFaceGroup}
setSelectedFaceGroup={setSelectedFaceGroup}
/>
</Modal.Description>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button
disabled={selectedFaceGroup == null}
content="Merge"
labelPosition="right"
icon="checkmark"
onClick={() => mergeFaceGroups()}
positive
/>
</Modal.Actions>
</Modal>
)
}
MergeFaceGroupsModal.propTypes = {
open: PropTypes.bool.isRequired,
setOpen: PropTypes.func.isRequired,
sourceFaceGroup: PropTypes.object,
}
export default MergeFaceGroupsModal

View File

@ -0,0 +1,155 @@
import { gql, useLazyQuery, useMutation } from '@apollo/client'
import PropTypes from 'prop-types'
import React, { useEffect, useState } from 'react'
import { useHistory } from 'react-router-dom'
import { Button, Modal } from 'semantic-ui-react'
import SelectFaceGroupTable from './SelectFaceGroupTable'
import SelectImageFacesTable from './SelectImageFacesTable'
import { MY_FACES_QUERY } from '../PeoplePage'
const MOVE_IMAGE_FACES_MUTATION = gql`
mutation moveImageFaces($faceIDs: [ID!]!, $destFaceGroupID: ID!) {
moveImageFaces(
imageFaceIDs: $faceIDs
destinationFaceGroupID: $destFaceGroupID
) {
id
imageFaces {
id
}
}
}
`
const MoveImageFacesModal = ({ open, setOpen, faceGroup }) => {
const [selectedImageFaces, setSelectedImageFaces] = useState([])
const [selectedFaceGroup, setSelectedFaceGroup] = useState(null)
const [imagesSelected, setImagesSelected] = useState(false)
let history = useHistory()
const [moveImageFacesMutation] = useMutation(MOVE_IMAGE_FACES_MUTATION, {
variables: {},
refetchQueries: [
{
query: MY_FACES_QUERY,
},
],
})
const [loadFaceGroups, { data: faceGroupsData }] = useLazyQuery(
MY_FACES_QUERY
)
useEffect(() => {
if (imagesSelected) {
loadFaceGroups()
}
}, [imagesSelected])
useEffect(() => {
if (!open) {
setImagesSelected(false)
setSelectedImageFaces([])
setSelectedFaceGroup(null)
}
}, [open])
if (open == false) return null
const moveImageFaces = () => {
const faceIDs = selectedImageFaces.map(face => face.id)
moveImageFacesMutation({
variables: {
faceIDs,
destFaceGroupID: selectedFaceGroup.id,
},
}).then(() => {
setOpen(false)
history.push(`/people/${selectedFaceGroup.id}`)
})
}
const imageFaces = faceGroup?.imageFaces ?? []
let table = null
if (!imagesSelected) {
table = (
<SelectImageFacesTable
imageFaces={imageFaces}
selectedImageFaces={selectedImageFaces}
setSelectedImageFaces={setSelectedImageFaces}
title="Select images to move"
/>
)
} else {
if (faceGroupsData) {
const filteredFaceGroups = faceGroupsData.myFaceGroups.filter(
x => x != faceGroup
)
table = (
<SelectFaceGroupTable
title="Select destination face group"
faceGroups={filteredFaceGroups}
selectedFaceGroup={selectedFaceGroup}
setSelectedFaceGroup={setSelectedFaceGroup}
/>
)
} else {
table = <div>Loading...</div>
}
}
let positiveButton = null
if (!imagesSelected) {
positiveButton = (
<Button
disabled={selectedImageFaces.length == 0}
content="Next"
labelPosition="right"
icon="arrow right"
onClick={() => setImagesSelected(true)}
positive
/>
)
} else {
positiveButton = (
<Button
disabled={!selectedFaceGroup}
content="Move image faces"
labelPosition="right"
icon="checkmark"
onClick={() => moveImageFaces()}
positive
/>
)
}
return (
<Modal
onClose={() => setOpen(false)}
onOpen={() => setOpen(true)}
open={open}
>
<Modal.Header>Move Image Faces</Modal.Header>
<Modal.Content scrolling>
<Modal.Description>
<p>Move selected images of this face group to another face group</p>
{table}
</Modal.Description>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setOpen(false)}>Cancel</Button>
{positiveButton}
</Modal.Actions>
</Modal>
)
}
MoveImageFacesModal.propTypes = {
open: PropTypes.bool.isRequired,
setOpen: PropTypes.func.isRequired,
faceGroup: PropTypes.object,
}
export default MoveImageFacesModal

View File

@ -0,0 +1,125 @@
import PropTypes from 'prop-types'
import React, { useState, useEffect } from 'react'
import { Input, Pagination, Table } from 'semantic-ui-react'
import styled from 'styled-components'
import FaceCircleImage from '../FaceCircleImage'
const FaceCircleWrapper = styled.div`
display: inline-block;
border-radius: 50%;
border: 2px solid
${({ $selected }) => ($selected ? `#2185c9` : 'rgba(255,255,255,0)')};
`
const FlexCell = styled(Table.Cell)`
display: flex;
align-items: center;
`
export const RowLabel = styled.span`
${({ $selected }) => $selected && `font-weight: bold;`}
margin-left: 12px;
`
const FaceGroupRow = ({ faceGroup, faceSelected, setFaceSelected }) => {
return (
<Table.Row key={faceGroup.id} onClick={setFaceSelected}>
<FlexCell>
<FaceCircleWrapper $selected={faceSelected}>
<FaceCircleImage imageFace={faceGroup.imageFaces[0]} size="50px" />
</FaceCircleWrapper>
<RowLabel $selected={faceSelected}>{faceGroup.label}</RowLabel>
</FlexCell>
</Table.Row>
)
}
FaceGroupRow.propTypes = {
faceGroup: PropTypes.object.isRequired,
faceSelected: PropTypes.bool.isRequired,
setFaceSelected: PropTypes.func.isRequired,
}
const SelectFaceGroupTable = ({
faceGroups,
selectedFaceGroup,
setSelectedFaceGroup,
title,
}) => {
const PAGE_SIZE = 6
const [page, setPage] = useState(0)
const [searchValue, setSearchValue] = useState('')
useEffect(() => {
setPage(0)
}, [searchValue])
const rows = faceGroups
.filter(
x =>
searchValue == '' ||
(x.label && x.label.toLowerCase().includes(searchValue.toLowerCase()))
)
.map(face => (
<FaceGroupRow
key={face.id}
faceGroup={face}
faceSelected={selectedFaceGroup == face}
setFaceSelected={() => setSelectedFaceGroup(face)}
/>
))
const pageRows = rows.filter(
(_, i) => i >= page * PAGE_SIZE && i < (page + 1) * PAGE_SIZE
)
return (
<Table selectable>
<Table.Header>
<Table.Row>
<Table.HeaderCell>{title}</Table.HeaderCell>
</Table.Row>
<Table.Row>
<Table.HeaderCell>
<Input
value={searchValue}
onChange={e => setSearchValue(e.target.value)}
icon="search"
placeholder="Search faces..."
fluid
/>
</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>{pageRows}</Table.Body>
<Table.Footer>
<Table.Row>
<Table.HeaderCell>
<Pagination
floated="right"
firstItem={null}
lastItem={null}
// nextItem={null}
// prevItem={null}
activePage={page + 1}
totalPages={rows.length / PAGE_SIZE}
onPageChange={(_, { activePage }) => {
setPage(Math.ceil(activePage) - 1)
}}
/>
</Table.HeaderCell>
</Table.Row>
</Table.Footer>
</Table>
)
}
SelectFaceGroupTable.propTypes = {
faceGroups: PropTypes.array,
selectedFaceGroup: PropTypes.object,
setSelectedFaceGroup: PropTypes.func.isRequired,
title: PropTypes.string,
}
export default SelectFaceGroupTable

View File

@ -0,0 +1,130 @@
import PropTypes from 'prop-types'
import React, { useEffect, useState } from 'react'
import { Checkbox, Input, Pagination, Table } from 'semantic-ui-react'
import styled from 'styled-components'
import { ProtectedImage } from '../../../components/photoGallery/ProtectedMedia'
import { RowLabel } from './SelectFaceGroupTable'
const SelectImagePreview = styled(ProtectedImage)`
max-width: 120px;
max-height: 80px;
`
const ImageFaceRow = ({ imageFace, faceSelected, setFaceSelected }) => {
return (
<Table.Row key={imageFace.id}>
<Table.Cell>
<Checkbox checked={faceSelected} onChange={setFaceSelected} />
</Table.Cell>
<Table.Cell>
<SelectImagePreview
src={imageFace.media.thumbnail.url}
onClick={setFaceSelected}
/>
</Table.Cell>
<Table.Cell width={16}>
<RowLabel $selected={faceSelected} onClick={setFaceSelected}>
{imageFace.media.title}
</RowLabel>
</Table.Cell>
</Table.Row>
)
}
ImageFaceRow.propTypes = {
imageFace: PropTypes.object.isRequired,
faceSelected: PropTypes.bool.isRequired,
setFaceSelected: PropTypes.func.isRequired,
}
const SelectImageFacesTable = ({
imageFaces,
selectedImageFaces,
setSelectedImageFaces,
title,
}) => {
const PAGE_SIZE = 6
const [page, setPage] = useState(0)
const [searchValue, setSearchValue] = useState('')
useEffect(() => {
setPage(0)
}, [searchValue])
const rows = imageFaces
.filter(
face =>
searchValue == '' ||
face.media.title.toLowerCase().includes(searchValue.toLowerCase())
)
.map(face => (
<ImageFaceRow
key={face.id}
imageFace={face}
faceSelected={selectedImageFaces.includes(face)}
setFaceSelected={() =>
setSelectedImageFaces(faces => {
if (faces.includes(face)) {
return faces.filter(x => x != face)
} else {
return [...faces, face]
}
})
}
/>
))
const pageRows = rows.filter(
(_, i) => i >= page * PAGE_SIZE && i < (page + 1) * PAGE_SIZE
)
return (
<Table selectable>
<Table.Header>
<Table.Row>
<Table.HeaderCell colSpan={3}>{title}</Table.HeaderCell>
</Table.Row>
<Table.Row>
<Table.HeaderCell colSpan={3}>
<Input
value={searchValue}
onChange={e => setSearchValue(e.target.value)}
icon="search"
placeholder="Search images..."
fluid
/>
</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>{pageRows}</Table.Body>
<Table.Footer>
<Table.Row>
<Table.HeaderCell colSpan={3}>
<Pagination
floated="right"
firstItem={null}
lastItem={null}
// nextItem={null}
// prevItem={null}
activePage={page + 1}
totalPages={rows.length / PAGE_SIZE}
onPageChange={(_, { activePage }) => {
setPage(Math.ceil(activePage) - 1)
}}
/>
</Table.HeaderCell>
</Table.Row>
</Table.Footer>
</Table>
)
}
SelectImageFacesTable.propTypes = {
imageFaces: PropTypes.array,
selectedImageFaces: PropTypes.array,
setSelectedImageFaces: PropTypes.func.isRequired,
title: PropTypes.string,
}
export default SelectImageFacesTable

View File

@ -0,0 +1,47 @@
import PropTypes from 'prop-types'
import React, { useState } from 'react'
import PhotoGallery from '../../../components/photoGallery/PhotoGallery'
import FaceGroupTitle from './FaceGroupTitle'
const SingleFaceGroup = ({ faceGroup }) => {
const [presenting, setPresenting] = useState(false)
const [activeIndex, setActiveIndex] = useState(-1)
let mediaGallery = null
if (faceGroup) {
const media = faceGroup.imageFaces.map(x => x.media)
const nextImage = () =>
setActiveIndex(i => Math.min(i + 1, media.length - 1))
const previousImage = () => setActiveIndex(i => Math.max(i - 1, 0))
mediaGallery = (
<div>
<PhotoGallery
media={media}
loading={false}
presenting={presenting}
setPresenting={setPresenting}
onSelectImage={setActiveIndex}
activeIndex={activeIndex}
nextImage={nextImage}
previousImage={previousImage}
/>
</div>
)
}
return (
<div>
<FaceGroupTitle faceGroup={faceGroup} />
{mediaGallery}
</div>
)
}
SingleFaceGroup.propTypes = {
faceGroup: PropTypes.object,
}
export default SingleFaceGroup

View File

@ -112,7 +112,7 @@ const MapPage = () => {
}
return (
<Layout>
<Layout title="Places">
<MapWrapper>
<MapContainer ref={mapContainer}></MapContainer>
</MapWrapper>

View File

@ -25,7 +25,7 @@ export const InputLabelDescription = styled.p`
const SettingsPage = () => {
return (
<Layout>
<Layout title="Settings">
<ScannerSection />
<UsersTable />
</Layout>

View File

@ -0,0 +1,70 @@
import PropTypes from 'prop-types'
import React from 'react'
import { Link } from 'react-router-dom'
import styled from 'styled-components'
const FaceBoxStyle = styled(Link)`
box-shadow: inset 0 0 2px 1px rgba(0, 0, 0, 0.3), 0 0 0 1px rgb(255, 255, 255);
border-radius: 50%;
position: absolute;
top: ${({ $minY }) => $minY * 100}%;
bottom: ${({ $maxY }) => (1 - $maxY) * 100}%;
left: ${({ $minX }) => $minX * 100}%;
right: ${({ $maxX }) => (1 - $maxX) * 100}%;
`
const FaceBox = ({ face /*media*/ }) => {
return (
<FaceBoxStyle
to={`/people/${face.faceGroup.id}`}
$minX={face.rectangle.minX}
$maxX={face.rectangle.maxX}
$minY={face.rectangle.minY}
$maxY={face.rectangle.maxY}
></FaceBoxStyle>
)
}
FaceBox.propTypes = {
face: PropTypes.object.isRequired,
media: PropTypes.object.isRequired,
}
const SidebarFacesOverlayWrapper = styled.div`
position: absolute;
width: ${({ width }) => width * 100}%;
left: ${({ width }) => (100 - width * 100) / 2}%;
height: 100%;
top: 0;
opacity: 0;
user-select: none;
transition: opacity ease 200ms;
&:hover {
opacity: 1;
}
`
export const SidebarFacesOverlay = ({ media }) => {
if (media.type != 'photo') return null
const faceBoxes = media.faces?.map(face => (
<FaceBox key={face.id} face={face} media={media} />
))
let wrapperWidth = 1
if (media.thumbnail.width * 0.75 < media.thumbnail.height) {
wrapperWidth = (media.thumbnail.width * 0.75) / media.thumbnail.height
}
return (
<SidebarFacesOverlayWrapper width={wrapperWidth}>
{faceBoxes}
</SidebarFacesOverlayWrapper>
)
}
SidebarFacesOverlay.propTypes = {
media: PropTypes.object.isRequired,
}

View File

@ -14,6 +14,7 @@ const AlbumPage = React.lazy(() => import('../../Pages/AlbumPage/AlbumPage'))
const PhotosPage = React.lazy(() => import('../../Pages/PhotosPage/PhotosPage'))
const PlacesPage = React.lazy(() => import('../../Pages/PlacesPage/PlacesPage'))
const SharePage = React.lazy(() => import('../../Pages/SharePage/SharePage'))
const PeoplePage = React.lazy(() => import('../../Pages/PeoplePage/PeoplePage'))
const LoginPage = React.lazy(() => import('../../Pages/LoginPage/LoginPage'))
const InitialSetupPage = React.lazy(() =>
@ -44,9 +45,10 @@ const Routes = () => {
<Route path="/initialSetup" component={InitialSetupPage} />
<Route path="/share" component={SharePage} />
<AuthorizedRoute exact path="/albums" component={AlbumsPage} />
<AuthorizedRoute path="/album/:id/:subPage?" component={AlbumPage} />
<AuthorizedRoute path="/photos/:subPage?" component={PhotosPage} />
<AuthorizedRoute path="/album/:id" component={AlbumPage} />
<AuthorizedRoute path="/photos" component={PhotosPage} />
<AuthorizedRoute path="/places" component={PlacesPage} />
<AuthorizedRoute path="/people/:person?" component={PeoplePage} />
<AuthorizedRoute admin path="/settings" component={SettingsPage} />
<Route path="/" exact render={() => <Redirect to="/photos" />} />
<Route render={() => <div>Page not found</div>} />

View File

@ -7,6 +7,7 @@ import { ProtectedImage, ProtectedVideo } from '../photoGallery/ProtectedMedia'
import SidebarShare from './Sharing'
import SidebarDownload from './SidebarDownload'
import SidebarItem from './SidebarItem'
import { SidebarFacesOverlay } from '../facesOverlay/FacesOverlay'
const mediaQuery = gql`
query sidebarPhoto($id: ID!) {
@ -41,6 +42,7 @@ const mediaQuery = gql`
audio
}
exif {
id
camera
maker
lens
@ -52,6 +54,18 @@ const mediaQuery = gql`
flash
exposureProgram
}
faces {
id
rectangle {
minX
maxX
minY
maxY
}
faceGroup {
id
}
}
}
}
`
@ -203,6 +217,7 @@ const SidebarContent = ({ media, hidePreview }) => {
{!hidePreview && (
<PreviewImageWrapper imageAspect={imageAspect}>
<PreviewMedia previewImage={previewImage} media={media} />
<SidebarFacesOverlay media={media} />
</PreviewImageWrapper>
)}
<Name>{media && media.title}</Name>